简介:这是一款基于 JavaScript 开发的 Chrome 浏览器插件,主要面向需要从淘宝、京东商品详情页批量提取商品信息的电商运营、数据采集开发者;它能自动抓取商品标题、价格、优惠价格、主图及附图,并提交到预先指定的接口,减少手动复制与整理的工作量;使用时需在 url.js 中自定义 REQUEST_URL 变量作为提交地址,体现出较强的可配置性。资源包共 13 个文件,压缩后约 46KB,其中 5 个 js 文件承担各平台抓取逻辑与后台交互,2 个 html 文件构成弹窗及信息展示界面,另有 json 清单、图标图片和说明文档,结构简洁紧凑。目前已有 213 人学习或下载;透过源码可了解 Chrome 扩展中 popup、background 与 content script 的协作方式,并参考其 DOM 解析思路,快速搭建自己的商品信息采集工具。
1. grabItem:为什么商品信息抓取适合做成 Chrome 插件,而不是爬虫脚本
grabItem 解决的是电商详情页里高频出现的一个问题:人已经打开商品页面,却还要手动复制标题、价格、主图链接再进表格。做成浏览器插件后,内容脚本就在页面当前 DOM 里采集,登录态、动态渲染、反爬这些障碍都交给浏览器本身处理,抓取动作只发生在用户正在浏览的那一刻。grabItem 的设计目标很明确:轻量、无后端、纯本地存储。
第一次拆这类源码时,我一度以为里面会有什么自动化控制脚本或者云端采集接口。实际解压后就是 manifest 加内容脚本加 popup 三个文件,核心代码都是 JavaScript。能把几十行代码解决的问题拆得这么薄,反而是多数从业者更想要的一版——没有维护压力,改选择器就能继续用。
2. 扩展骨架与 MV3 清单:先把文件结构和权限匹配定下来
2.1 目录与 manifest:六个参数决定插件能不能跑
在动手写抓取逻辑之前,我习惯先把工程文件摆出个形状来。grabItem 这种规模,五个文件足够撑起整条链路:
| 文件 | 职责 | 是否必需 |
|---|---|---|
| manifest.json | 扩展入口,声明权限、注入规则 | 必需 |
| content.js | 注入商品页,执行字段抓取 | 必需 |
| background.js | Service Worker,接收消息并写入本地存储 | 标配 |
| popup.html / popup.js | 点击图标后的弹窗,展示抓取列表 | 推荐 |
manifest 是整个扩展的“身份证”,权限写得不对,后面一切抓取逻辑都白搭。grabItem 的 manifest 长这样:
{ "manifest_version": 3, "name": "grabItem", "version": "1.0.0", "description": "商品详情页抓取标题、价格、主图,并保存到本地列表", "permissions": ["storage", "downloads"], "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["http://*/*", "https://*/*"], "js": ["content.js"], "run_at": "document_idle", "all_frames": false } ], "action": { "default_popup": "popup.html", "default_title": "grabItem 数据列表" } }这里六个参数,我每次接手类似项目都会逐一过一遍:
manifest_version: 3决定了后台能力边界。MV3 里后台脚本是 Service Worker,事件触发才被唤醒,不能像 MV2 那样维持一个常驻页面。这意味着全局变量极不可靠,所有状态都要落到存储里。content_scripts.matches是页面注入白名单。我把匹配写成http://*/*和https://*/*,目标是覆盖所有普通网页。如果你只想抓某个商城,把域名精确写进 matches 会少掉很多无关页面的注入警告。permissions只列了两个:storage 和 downloads。storage 用来持久化抓取结果,downloads 用于最后导出 CSV、JSON。这里没有配 host_permissions,因为内容脚本的声明式注入由 matches 授予访问权,不需要额外申请运行时主机权限。run_at: document_idle保证 DOM 基本可用时再执行内容脚本。选 document_start 太早,很多动态节点还没挂载;选 document_end 又可能错过部分框架注入内容。action.default_popup指向 popup.html,用户点击扩展图标时弹出抓取列表。all_frames先留 false,遇到 iframe 内商品信息缺失时再打开,这个坑我放到第 4 章专门说。
加载方式不用多解释:在浏览器扩展管理页打开“开发者模式”,点“加载已解压的扩展程序”,选中项目目录即可。改完 manifest 里的权限或注入规则后,记得回扩展管理页点一次刷新,否则生效的是旧配置。
2.2 消息链路:为什么非要有 background.js 这一层
抓取流程里最容易想歪的地方,是让 content.js 直接和 popup 对话。第一次做的时候我也这么干过:content.js 抓到数据后立刻给 popup 发消息,但 popup 根本没打开,消息发出去就丢了。正确做法是让消息先经过一个中转站,把数据落进 chrome.storage,之后谁想取数据谁去查。
background.js 就是这个中转站:
// background.js:Service Worker,只做消息分流和数据落盘 chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (!msg || !msg.type) return; if (msg.type === 'GRAB_ITEM_SAVE') { chrome.storage.local.get('grabItems').then(({ grabItems = [] }) => { const next = [msg.payload, ...grabItems].slice(0, 500); chrome.storage.local.set({ grabItems: next }); }); sendResponse({ ok: true }); } // popup 打开后查询历史抓取列表 if (msg.type === 'GRAB_ITEM_LIST') { chrome.storage.local.get('grabItems').then(({ grabItems = [] }) => { sendResponse({ items: grabItems }); }); return true; } });这段代码里有两个关键点值得展开。第一个是sendResponse与return true的关系:popup 的GRAB_ITEM_LIST请求依赖异步读取 storage,监听器必须在异步回调里调用 sendResponse。为了让消息通道保持打开,监听器需要显式返回 true,否则 popup 拿到的就是一个 undefined。第二个是slice(0, 500)的兜底:chrome.storage.local 的配额按字节计算,商品标题和 URL 叠加起来很容易涨,本地列表只保留最近 500 条,超出部分直接丢弃,避免存储膨胀。
还有一点不要忽略:GRAB_ITEM_SAVE分支里 sendResponse 在storage.local.get回调外调用,这是故意的。对于需要落盘的写入,我只需要返回一个 ok 的确认信号,不需要等待完整的写入周期。内容脚本收到这个响应后,如果是在弹窗未打开时发送的,还会出现Promise rejection,这是正常分支,用 catch 兜掉即可。
从这一层开始,数据流就固定成了:content.js 抓取 → 发消息给 background → 写 storage → popup 读 storage → 展示或导出。这个链路不依赖任何页面长期存在,Service Worker 会被消息事件叫醒,处理完又休眠,非常适合这种低频抓取场景。
3. 内容脚本抓取逻辑:JSON-LD 优先与价格清洗的取舍
3.1 信息模型与结构化数据先行
写抓取函数前,先定字段模型。grabItem 的商品信息模型只有七个字段:
// content.js —— 信息模型与结构化数据提取 const PRODUCT_SCHEMA = { title: '', price: null, originalPrice: null, cover: '', url: '', platform: '', capturedAt: '' }; function extractFromJsonLd() { const node = document.querySelector('script[type="application/ld+json"]'); if (!node) return {}; try { const parsed = JSON.parse(node.textContent); const offers = parsed.offers; const offer = Array.isArray(offers) ? offers[0] : offers; return { title: parsed.name || '', price: offer ? offer.price : null, cover: normalizeImage(parsed.image) }; } catch (e) { console.warn('[grabItem] JSON-LD 解析失败,继续走选择器兜底'); return {}; } }为什么第一个策略选 JSON-LD 而不是直接抓 DOM 节点?因为商品页里的价格文本非常混乱:有促销价、划线价、分期价、优惠信息倒计时,哪个才是“商品当前售价”在 DOM 里根本没有语义保证。而 JSON-LD 是商家在页面源码里显式声明的结构化数据,属于 schema.org 标准,字段名称、价格类型都相对规范。先读它,能稳定解决至少一半页面的取数问题。
很多商城页面上会有不止一个application/ld+json节点,可能是面包屑、可能是评价聚合。要在代码里做一次类型判断,只取@type包含 Product 的那一个,否则可能抓到 WebPage 或 BreadcrumbList,字段全是空的。上面的示例没有写这一步,实际使用我会把判断补上:
const parsed = JSON.parse(node.textContent); if (parsed['@type'] !== 'Product' && !Array.isArray(parsed['@graph'])) return {};@graph数组里才是完整商品信息,这类嵌套结构在大型商城最常见,解析时不要只取第一层。
3.2 兜底选择器与价格清洗
JSON-LD 读不到时,才轮到选择器上场。选择器策略我按“三层兜底”来设计:
| 字段 | 第一优先 | 第二优先 | 第三优先 |
|---|---|---|---|
| 标题 | JSON-LD.name | h1 文本 | document.title |
| 价格 | JSON-LD.offers.price | meta[itemprop=price] | 类名含 price 的节点 |
| 主图 | meta[property=og:image] | JSON-LD.image | 图片标签 alt 相似节点 |
实际写选择器时,通用类名会误伤促销横幅。价格节点的准确定位可以一句话概括:节点文本里必须出现数字,且文本长度不能太长。带这个思路,采集价格文本的函数如下:
function pickPriceNode() { const candidates = document.querySelectorAll('[class*="price" i], [itemprop="price"]'); let best = ''; let bestScore = -1; candidates.forEach((el) => { const text = (el.textContent || '').trim(); if (!text || text.length > 40) return; let score = 0; if (/[¥¥$€]/.test(text)) score += 3; if (/[\d.,]/.test(text)) score += 2; if (text.length < 30) score += 1; if (score > bestScore) { bestScore = score; best = text; } }); return best; }筛选逻辑是给候选节点打分的:出现货币符号加 3 分,出现数字加 2 分,文本足够短再加 1 分。分数最高的才被当成价格文本。为什么要这么做?因为[class*="price"]可能同时匹配到“价格说明”“price-tip”“price-promo”这些干扰节点,它们的文本里同样有数字,但不是你想要的当前售价。靠文本长度和货币符号双重过滤,翻车率会低很多。
拿到文本后进入价格清洗函数。这一步处理钱相关的各种格式:
function parsePrice(text) { if (!text) return null; let normalized = text.replace(/[^\d.,]/g, '').trim(); if (!normalized) return null; // 小数点和逗号同时存在时,看谁在最后 if (normalized.includes(',') && normalized.includes('.')) { if (normalized.lastIndexOf('.') > normalized.lastIndexOf(',')) { normalized = normalized.replace(/,/g, ''); } else { normalized = normalized.replace(/\./g, '').replace(',', '.'); } } else if (normalized.includes(',')) { // 只有逗号时视为小数分隔符 normalized = normalized.replace(',', '.'); } const value = parseFloat(normalized); return Number.isFinite(value) ? value : null; }这里要处理的关键场景是千位分隔符和十进制分隔符的混淆。比如1,299.00是中式写法,逗号是千位符,小数点才是小数位;而1.299,00是欧式写法,点变成了千位符,逗号变成了小数位。当两种符号同时出现时,我采用“谁靠后谁是真实小数位”的规则,先去掉前面的符号再转换。只有逗号的情况默认当成小数位,因为短价格1,99比千位分隔符1,299出现频率高。
3.3 原价字段:促销价与划线价同时存在的处理
抓商品信息时,很多页面同时存在两个价格:一个促销价,一个划线原价。grabItem 的字段模型里留了 originalPrice,就是为这个准备的。处理方法是收集所有候选价格数字,取最小值为现价,最大值为原价:
function extractPricePair(list) { const nums = list.filter((x) => x !== null && Number.isFinite(x)); if (!nums.length) return { price: null, originalPrice: null }; const price = Math.min(...nums); const original = Math.max(...nums); return price === original ? { price, originalPrice: null } : { price, originalPrice: original }; }为什么取最大值为原价而不是选择器区分?因为划线价在 DOM 里的类名很玄学,没有统一规律。从业务逻辑看,绝大多数场景下原价高于现价,数值上取最大最省事。如果两个数字相同,说明页面没给原价,originalPrice 放 null 而不是硬塞一个相同值,后续做价格比较时才不会误判为折扣。
完整 collect 函数把这些逻辑串起来之后,content.js 每次调用返回的就是一条结构完整的商品记录。只要 JSON-LD 和选择器覆盖到两个源头,title、price、cover 这三项基本不会缺。
4. 避坑手册:动态渲染、iframe、消息丢失与误选价格节点
4.1 只抓到标题抓不到价格:动态渲染时机问题
现象:商品标题正常入库,price 字段永远是 null,页面滚动后价格才出现。我一度以为选择器写错了,在 DevTools 里查document.querySelector('[class*="price"]')却发现节点明明存在。
原因:内容脚本在 document_idle 阶段执行,但这个阶段只代表最早一批 DOM 就绪,不代表商家异步拉取的价格数据已经写入页面。骨架屏先渲染,价格节点是后续 Ajax 请求回来才插入的,采集脚本跑得太早,自然拿不到东西。
解决:用 MutationObserver 监听页面变化,在价格节点出现后再补抓一次。注意加防抖,否则页面上每插入一张商品图片就会触发一遍。
let debounceTimer = null; const observer = new MutationObserver(() => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { if (!document.querySelector('[class*="price" i]')) return; const data = collect(); chrome.runtime.sendMessage({ type: 'GRAB_ITEM_SAVE', payload: data }).catch(() => {}); }, 400); }); observer.observe(document.body, { childList: true, subtree: true });防抖时间我习惯给 400ms,网络慢的页面可以提到 800ms。参数越大越稳,但连续滚动时反复触发抓取的风险也越高,需要按目标网站的异步加载速度调。
4.2 iframe 里的商品信息总是缺失
现象:商品页看起来完整,价格就在页面中央区域显示,但插件抓出来的记录里 title 有值,price 和 cover 都是空。
原因:部分商城把商品详情的核心区域放进了 iframe。内容脚本默认不注入子框架,你在顶层 document 里查任何祖先节点都碰不到 iframe 内部的内容。
解决:把 manifest 里 content_scripts 的 all_frames 改成 true,让脚本进入子框架执行,同时加一条帧判断,只在包含价格标记的帧里做采集。
"content_scripts": [ { "matches": ["http://*/*", "https://*/*"], "js": ["content.js"], "run_at": "document_idle", "all_frames": true } ]// 当前帧不是顶层窗口,且帧内没有商品价格特征时,直接返回 if (window.top !== window.self) { if (!document.querySelector('[class*="price" i]')) return; }这种写法的好处是:顶层页面正常抓,价格在 iframe 里的页面也能抓,两套场景都不重复。要注意的是,如果 iframe 里也有一个完整商品页,可能出现两次抓取,靠第 5 章的去重逻辑兜住。
4.3 popup 打开总是白屏,列表不显示
现象:商品页里 console 能看到内容脚本打印的日志,但点击扩展图标,popup 弹出来永远是一张白纸,连表格头都没有。
原因:第一版实现里我只写了 content.js 和 popup,没有 background.js。content.js 抓完数据直接把消息发给 popup,可 popup 是点击图标才打开的,平时根本不加载,消息发出去无人接收,数据自然没有落地。popup 打开后去读内存,什么都没读到。
解决:在中间加一层 Service Worker 充当消息中枢,抓取数据先写 chrome.storage.local,popup 打开后主动去查存储。
// popup.js:从本地存储取出抓取结果,渲染成表格 chrome.storage.local.get('grabItems').then(({ grabItems = [] }) => { const tbody = document.getElementById('list'); tbody.textContent = ''; grabItems.forEach((item) => { const row = document.createElement('tr'); const cells = [ item.platform, item.title, String(item.price ?? '-'), new Date(item.capturedAt).toLocaleString() ]; cells.forEach((cellText) => { const td = document.createElement('td'); td.textContent = cellText; row.appendChild(td); }); tbody.appendChild(row); }); });这里用 textContent 而不是 innerHTML 拼表格,是因为标题字段来自第三方页面,很可能携带着 HTML 标签碎片,直接 innerHTML 注入会增加不必要的风险。养成这个习惯后,很多注入问题都能提前规避。
4.4 价格字段被促销文案劫持
现象:抓到的 price 数值异常,比如页面上明明标着 199 元,插件却录进去一个 39.9,而且看起来像某个角标价格。
原因:通用选择器[class*="price" i]命中了多个节点,比如“price-tip”“price-promo”这类促销提示,它们的文本也带数字。脚本如果不做区分,可能把第一个匹配到的节点文本当成交价。
解决:用“文本长度 + 货币符号 + 数字”三项组合给候选节点打分,选分数最高的。这个逻辑我在 3.2 里写成了 pickPriceNode,实际遇到误选时,可以先用 DevTools 把页面所有节点跑一遍:
document.querySelectorAll('[class*="price" i]').forEach((el) => { console.log(el.className, el.textContent.trim().slice(0, 30)); });把候选结果打出来看一遍,哪个是真正价格一目了然。如果发现某个站点反复误选,就给这个站点单独加一个精确选择器配置,站点级适配表可以放在独立文件里,避免污染通用逻辑。
5. 验证习惯与进阶复用:三页检查法防止选择器翻车
选择器是这套抓取逻辑里最容易老化的部件。我给自己定过一个最低验证标准:改动任何选择器后,至少打开三个不同商品页面,分别检查 title、price、cover 是否入库,类型是否正确。这个流程我简称为“三页检查法”。
第一页用来验证 JSON-LD 是否可用。打开控制台直接执行:
JSON.stringify(collect())如果 price 有值且是数字类型,说明结构化数据通道还活着。第二页用来验证选择器兜底,找那种没有 JSON-LD 的页面,确认 pickPriceNode 能选到价格。第三页用来验证动态渲染,把页面一直滚到底再刷新,确认 MutationObserver 补抓生效。
数据入库后的去重也是必须做的。同一商品页多次访问会产生多条记录,我的做法是按“平台 + URL + 价格”拼一个 key,存储前先查重:
function keyOf(payload) { return `${payload.platform}|${payload.url}|${payload.price}`; } function upsert(list, item) { const key = keyOf(item); const idx = list.findIndex((old) => keyOf(old) === key); if (idx >= 0) { list[idx] = item; } else { list.unshift(item); } return list.slice(0, 500); }key 里带价格是有意的:只有价格变化时,旧记录才被新记录替换。如果 key 只含 URL,同页面的复访就会被吞掉,价格变化的记录就丢了。
进阶复用方面,我会给 grabItem 加一个站点隔离层,把不同商城的精确选择器抽出来:
const SITE_CONFIG = { 'item.shop-a.com': { title: ['h1', '.product-title'], price: ['.price-now', '[itemprop="price"]'], cover: 'meta[name="og:image"]' }, 'item.shop-b.com': { title: ['#itemTitle', 'h1'], price: ['.sale-price'], cover: 'meta[property="og:image"]' } };collect 函数先按 location.hostname 查配置,命中就用站点专用选择器,没命中才走通用逻辑。这套结构让我在目标站点改版时,只需要改配置而不是改核心代码,维护成本明显下降。
从那以后,我每次改选择器都会强制走一遍三页检查:控制台验证 collect、popup 查入库、导出 CSV 核对 price 类型。正是这套习惯,帮我拦下了无数次“看起来改了很稳、上线就翻车”的意外。希望帮到你。
本文还有配套的精品资源,点击获取