简介:这是一款基于JavaScript开发的Chrome浏览器插件,专门用于从淘宝、京东等商品详情页自动抓取标题、价格、优惠价格以及主图附图等基本信息,并将抓取到的数据提交到用户配置的指定接口地址。插件源码结构清晰,功能拆分合理,适合需要批量获取商品信息的电商运营者、前端开发人员以及Chrome扩展技术爱好者学习。资源压缩包共13个文件,总大小仅46KB,其中包含5个JavaScript脚本,分别实现后台逻辑、弹窗交互以及淘宝、京东、天猫等平台的抓取逻辑;2个HTML文件负责插件界面展示,JSON文件用于扩展配置,另有说明文档和图标等辅助文件。目前该插件已有215人学习,属于轻量实用型工具。通过研究源码,读者可以掌握Chrome扩展的基本开发流程,理解页面DOM解析、图片资源处理以及通过自定义url.js配置提交地址的方法,并可根据自身需求二次改造成定制化的商品信息采集工具。
1. grabItem 是什么:把复制粘贴商品信息变成一次点击
grabItem 是一个运行在 Chrome 里的浏览器插件,目标就一件事:把当前商品页的标题、价格、主图、SKU 抓下来,整理成结构化数据并导出 CSV。很多从业者做选品、比价、库存登记时,每天要在一个电商页面和一个管理表之间来回切换,复制粘贴一小时填二十条已经是熟练工,还会漏列、错行。插件能把这个动作压成两次点击:第一次点工具栏图标,页面信息自动入本地库;第二次点导出,一张 CSV 就下下来了。它适合两类人:一类是每天维护商品清单的运营和采购,不想碰代码也能直接用打包好的版本;另一类是开发者,把 grabItem 当模板改字段、换选择器,就能快速复用到别的站点。这篇文章从架构、可跑代码到踩坑点按落地顺序讲清,照着做就能跑通一个最小版本。
2. 先拆架构:MV3 下 grabItem 的内容脚本与后台脚本怎么分工
2.1 为什么要拆内容脚本和后台脚本
写浏览器插件抓商品,第一反应是在页面里跑一段 document 脚本。但 Chrome 插件内部有两个隔离环境,必须分清。内容脚本(content script)运行在页面上下文里,能访问 DOM,但拿不到页面同源环境里的 window 变量,跨域请求也会被同源策略卡住;后台脚本(background service worker)能做跨域请求、操作 storage、触发下载,却碰不到页面 DOM。所以“抓取商品”这个需求天然要拆两层:内容脚本负责从页面读字段,后台脚本负责组装、存储和导出。
MV3 最大的变化是把后台从常驻页面改成 service worker,生命周期极短,休眠后被事件唤醒,执行完就退出。对 grabItem 这种低频交互反而是好事,用户点一次图标唤醒,抓完自动睡,不占常驻内存。坏处是后台里不能写长循环、不能依赖全局变量保持会话。为此我把数据流设计成事件驱动:点击图标注入内容脚本,内容脚本解析后把商品对象通过 chrome.runtime.sendMessage 送回后台,后台写进 chrome.storage.local,再更新角标数字。所有状态都落 storage,不落内存。
这套设计还有一个附带好处:用户每次抓取的都是“当前页面的实时数据”,而不是某个定时任务在后台偷偷采的旧数据,权限上更干净,用户也更放心。
2.2 manifest.json 最小配置:权限声明与脚本注册
grabItem 的配置文件本质上是一份权限清单。权限写大了安装时吓人,写小了功能跑不起来。我最常用的最小配置如下:
{ "manifest_version": 3, "name": "grabItem", "version": "1.0.0", "permissions": [ "activeTab", "scripting", "storage", "downloads" ], "background": { "service_worker": "background.js" }, "action": { "default_title": "grabItem:抓取当前商品信息" } }JSON 标准不允许写注释,字段含义放代码块后面说。activeTab 是 MV3 里按需授权的核心:用户主动点击插件图标时,Chrome 才临时授予当前标签页的访问权,这也是为什么大部分场景下不需要在安装时就声明“读取所有网站”。scripting 用于向标签页注入 content.js。storage 用于把抓到的记录持久化到本地,页面刷新不丢。downloads 用于导出 CSV 时触发 Chrome 下载。
我没有用 content_scripts 静态注册脚本,而是在用户点击图标后才注入。理由有两个:一是只有主动抓取时才需要解析脚本在页面里跑,静态注册会让用户打开的每个商品页都白跑一份代码;二是静态注册需要在 matches 里写明站点范围,要么写得太宽(<all_urls>)让安装提示很吓人,要么写得太窄漏掉目标站点。动态注入用 activeTab 就能覆盖当前标签页,不需要预先知道用户会访问哪个站点。
2.3 数据流设计:一次抓取从点击到落库的完整链路
用户点击工具栏图标后,后台脚本按“取标签页 → 注入脚本 → 发消息 → 收数据 → 落库”的顺序执行。核心代码如下:
// background.js:点击图标 -> 注入内容脚本 -> 请求解析 chrome.action.onClicked.addListener(async (tab) => { const tabId = tab.id; await chrome.scripting.executeScript({ target: { tabId: tabId }, files: ["content.js"] }); const response = await chrome.tabs.sendMessage(tabId, { action: "parse-product" }); if (response && response.product) { await appendRecord(response.product); } });chrome.action.onClicked 监听工具栏图标点击。executeScript 把 content.js 注入当前标签页,必须等它 resolve 之后才能发消息,否则内容脚本还没就绪,消息会打在空处。这里有个细节:如果用户连续点击图标准备抓取下一个页面,同一个标签页可能被重复注入,Chrome 会提示脚本不能重复注入多次。常见做法是在 executeScript 前先查一次 storage 里的注入标记,或者把重复注入的异常吞掉,让代码继续走 sendMessage。
内容脚本这侧只负责一件事:收到消息就解析,解析完回传商品对象。代码如下:
// content.js:接收消息并返回商品对象 chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.action === "parse-product") { const product = parseProduct(); sendResponse({ product: product }); } // 返回 true 表示异步回应,防止消息通道提前关闭 return true; });无特殊延迟时,sendResponse 同步返回即可。但如果 parseProduct 内部要等服务端数据,就必须在监听器末尾 return true,告诉 Chrome“这个响应是异步的,先别释放消息通道”。很多初版插件抓不到数据,都是因为漏了这行。后台收到商品对象后,把它追加进本地记录并更新角标:
// background.js:存入本地并更新角标 async function appendRecord(product) { const { records = [] } = await chrome.storage.local.get({ records: [] }); records.push({ ...product, crawled_at: new Date().toISOString() }); await chrome.storage.local.set({ records: records }); chrome.action.setBadgeText({ text: String(records.length) }); chrome.action.setBadgeBackgroundColor({ color: "#c0392b" }); }chrome.storage.local.get 的第二个参数传默认值{ records: [] }是个小习惯,能避免首次读取时 records 为 undefined 导致 push 报错。角标数字就是本次抓取会话的累计记录数,用户不用打开弹窗也知道抓了多少条。后台所有状态必须从 storage 读而不是从全局变量读,因为 service worker 休眠后再唤醒时,全局变量已经清空了。
提示:action 配置里不要写 default_popup。一旦配置了 popup,点击图标弹的是小窗,chrome.action.onClicked 事件就不会触发,上面的整套逻辑都会失效。
3. 把商品信息真正抓下来:抓取脚本、字段清洗与 CSV 导出
3.1 商品字段定位:标题、价格、主图、SKU 怎么选
商品页每个站点结构不一样,但字段语义高度一致:标题在 h1 或 og:title 的 meta 里;主图在 og:image 里;价格在价格容器或 product:price:amount 的 meta 里;SKU 藏在规格区域。抓取时先锚定语义稳定的位置,再回退到具体选择器。核心解析函数我通常这样写:
// content.js:核心解析函数(通用版) function parseProduct() { const title = document.querySelector('h1')?.innerText.trim() || document.querySelector('meta[property="og:title"]')?.content.trim() || document.title.trim(); const image = document.querySelector('meta[property="og:image"]')?.content || document.querySelector('.main-image img, [class*="gallery"] img')?.src; const priceText = document.querySelector('.product-price, [class*="price"]')?.innerText || document.querySelector('meta[property="product:price:amount"]')?.content; const skuText = Array.from(document.querySelectorAll( '.sku-item.selected, .spec.active, [class*="selected"]' )) .map((el) => el.innerText.trim()) .filter(Boolean) .join(' / '); const price = parsePrice(priceText); return { title: title, image_url: image, price_low: price.low, price_high: price.high, sku_text: skuText, page_url: location.href }; }title 用三级降级:优先 h1,其次是 og:title 的 meta,最后是 document.title。动态站点 h1 往往由前端后挂,og:title 却是服务端渲染的,所以多数场景 meta 反而更稳。价格同样优先 meta 里的 product:price:amount,它没有促销标签包裹、没有“预计到手价”的干扰文案,数值最干净;CSS 选择器只做兜底。SKU 区域会同时出现多个选中节点(颜色一个、尺码一个),把它们全部收集起来用/拼接,后续去重和数据回溯都方便。
3.2 字段清洗:把“¥1,299 起”拆成可计算的数字
真实页面的价格文本长这样:“¥1,299.00 起”“券后 899”“活动价低至 9.9”。直接存字符串,后续做比价、算毛利都得人工再处理一遍。我习惯抓下来就清洗成 low/high 两个数值字段:
// content.js:价格清洗 function parsePrice(text) { if (!text) return { low: null, high: null }; const nums = (String(text).match(/\d+(?:\.\d+)?/g) || []).map(Number); if (nums.length === 0) return { low: null, high: null }; if (nums.length === 1) return { low: nums[0], high: nums[0] }; return { low: Math.min(...nums), high: Math.max(...nums) }; }正则\d+(?:\.\d+)?同时匹配 899 和 1299.00,price_low / price_high 分别取数字数组的最小值和最大值。单值时 low 和 high 相等,下游计算不会因为缺字段而报错。这里要承认一个局限:文本里出现“领券减 50”时,50 会被算进价格。所以生产环境我会优先用 meta 里的基准价,页面促销文案只在 meta 缺失时才进来兜底,同时把清洗阈值控制在“只信任首尾数字”。SKU 不需要额外清洗,选中态节点的 innerText 本身就是人话,只要把空白行过滤掉再拼接即可。
3.3 字段清洗规则
完整入库的字段我固定成七列,每个字段都有明确的来源和用途:
| 字段 | 来源 | 说明 |
|---|---|---|
| title | h1 / og:title | 三级降级取非空 |
| price_low | 价格文本数字最小值 | 无促销时等于原价 |
| price_high | 价格文本数字最大值 | 单值时与 low 相等 |
| image_url | og:image 或首图 | 用于自建图片比对 |
| sku_text | 规格区域选中态 | 多个规格用/拼接 |
| page_url | location.href | 去重字段之一 |
| crawled_at | 后台追加 | ISO 时间戳 |
字段映射的意义在于清洗和去重时有一条固定链路:先取模板选择器,再解析节点文本,最后按这个表落库。修改采集需求时,只需要动这张表和对应的解析函数,不需要翻整段脚本。
3.4 导出 CSV:BOM、逗号转义与触发下载
CSV 导出放在后台脚本里做。MV3 的 service worker 没有 DOM,用不了 URL.createObjectURL + Blob,最稳的方案是拼 data URL 交给 chrome.downloads.download。构造 CSV 时最容易踩的是三个点:Excel 打开中文乱码、字段里的逗号把列撑破、字段里的换行把行数搞乱。处理函数如下:
// background.js:生成 CSV 并触发下载 function escapeCsv(value) { const str = String(value ?? ''); if (/[",\n\r]/.test(str)) { return '"' + str.replace(/"/g, '""') + '"'; } return str; } function buildCsv(records) { const header = ["title", "price_low", "price_high", "image_url", "sku_text", "page_url", "crawled_at"]; const lines = [header.join(",")]; for (const record of records) { const row = header.map((key) => escapeCsv(record[key])); lines.push(row.join(",")); } return "\ufeff" + lines.join("\r\n"); } function downloadCsv(records) { const csv = buildCsv(records); const dataUrl = "data:text/csv;charset=utf-8," + encodeURIComponent(csv); chrome.downloads.download({ url: dataUrl, filename: "grabItem_" + Date.now() + ".csv" }); }escapeCsv 只对含逗号、引号、换行的值加双引号,内部双引号按 CSV 规范双写转义。条件判断避免了每个字段都被无谓包裹,导出的文件进 Excel 不会出现整列都带引号的尴尬。buildCsv 在文件头加\ufeff(UTF-8 BOM),这是 Excel 认中文的关键,不加就是满屏乱码。行结束统一用\r\n,兼容 Windows 下的记事本和 Excel。encodeURIComponent 负责把中文和特殊字符安全编码进 data URL,省略这步在个别浏览器里会被截断。
3.5 最小可运行的调试路径:先在 F12 里验证选择器
调试阶段别急着反复重载插件。我先在目标商品页按 F12,把 parseProduct 里用到的选择器直接粘贴到 Console 跑一遍,看返回的标题、价格、主图是否和肉眼一致。这一步能把“选择器写错”和“插件机制写错”两类问题隔离开,省掉大量无意义的重载循环。
脚本验证通过后再加载插件:chrome://extensions 打开开发者模式,“加载已解压的扩展程序”选中插件目录。改 manifest.json 后要去扩展管理页点刷新按钮,否则配置不生效;改 content.js 或 background.js 后刷新目标页面即可,脚本会从磁盘重新读取。点击图标没反应时,优先看 Background service worker 面板的控制台,那里会打印 executeScript、sendMessage 的完整报错,绝大多数问题一行报错就能定位。
4. 抓取调试避坑:动态渲染、选择器失效、重复数据与乱码
4.1 内容脚本注入时 DOM 还是空的:动态渲染页面怎么等
现象:点击插件图标,返回的 title 是浏览器标签页标题,价格字段是 null。页面肉眼已经渲染完整,但脚本就是读不到。
原因:页面是前端框架渲染的,商品接口的数据在 document 加载完成后才陆续挂到 DOM 上。executeScript 注入的时机是点击图标那一刻,此刻 h1 可能还没生成。document_idle 只保证初始脚本跑完,不管异步渲染。
解决:解析函数等目标元素出现后再取值。用一个带超时的轮询函数:
// content.js:等待元素出现,超时抛错 async function waitFor(selector, timeout = 5000) { const start = Date.now(); while (!document.querySelector(selector)) { if (Date.now() - start > timeout) { throw new Error("wait timeout: " + selector); } await new Promise((resolve) => setTimeout(resolve, 200)); } return document.querySelector(selector); }waitFor 每 200ms 查一次,等到就把节点返回,超过 5 秒抛错让后台把这条标记为 failed。为什么不用 MutationObserver?单页抓取场景下轮询更直白,代码没有回调嵌套,也不会因为页面频繁增加子树而触发大量 observer 回调。页面特别卡时把 timeout 调到 8000,但别设成无限,后台 worker 被长任务拖住的体验更差。
4.2 昨天能抓的价格今天变成空字符串:选择器没抗住促销改版
现象:同一天上午抓得好好的,下午某平台把价格区域从.price改成了.promo-price,并且新增了“券后价”节点,grabItem 抓回的值要么是空,要么是券后价。
原因:页面改版把价格容器的 class 名改了,或者把折扣信息节点排在了价格节点前面,querySelector 拿到第一个匹配节点恰好是促销标签。
解决:多候选选择器按优先级降级,并对结果做“价格文本特征校验”。只有文本里包含数字的节点才信:
// content.js:带校验的价格节点查找 function findPriceNode() { const candidates = [ 'meta[property="product:price:amount"]', ".product-price", '[class*="price"]', ".price" ]; for (const selector of candidates) { const node = document.querySelector(selector); if (!node) continue; const text = node.innerText || node.content || ""; if (/\d+/.test(text)) return node; } return null; }这个函数解决两个问题:选择器失效时自动降级到下一个候选;候选节点拿到的是非数字文本时直接跳过。选择器失效是插件类工具最常翻车的地方,设计解析函数时把它当作“一定会发生”的前提,而不是侥幸活过今天的小概率事件。页面上线活动、样式改版、A/B 测试都可能让昨天的选择器今天读不到值。
4.3 导出的 CSV 在 Excel 里中文乱码,还重复了几十行
现象:导出后用 Excel 打开,中文变成乱码;同一商品在记录里反复出现,数量对不上。
原因:乱码是 CSV 文件没有 UTF-8 BOM;重复是因为用户多次点击图标,每次点击都重新注入解析,把同一条商品反复 push 进 records。
解决:buildCsv 开头加\ufeff,这在 3.4 节已经用代码验证过。重复问题在 appendRecord 入库前做去重,按“页面 URL + SKU 文本”作为唯一键:
// background.js:按页面与规格去重 function dedupe(records) { const seen = new Set(); return records.filter((record) => { const key = record.page_url + "|" + record.sku_text; if (seen.has(key)) return false; seen.add(key); return true; }); }sku_text 为空时要兜底成 "default",否则同一页面下没规格的商品会因为空字符串相同而被误判为重复,或者被不同规格商品误判成不同记录。去重放在 appendRecord 里,先取旧记录、再过滤、再 push 新记录,保证 storage 里永远不积压重复项。CSV 乱码这个问题最隐蔽,因为用 VS Code 打开文件时编码正常,只有 Excel 会按本地编码解析;上线前用 Excel 实际开一次文件比任何理论检查都可靠。
4.4 内容脚本里请求商品接口被跨域拦截:请求要换后台
现象:在 content.js 里 fetch 商品评论或库存接口,控制台报 CORS error,整个抓取流程中断。
原因:内容脚本发起的请求遵循页面源的同源策略,目标接口只允许自家前端域名访问,第三方域名直接被浏览器拦截。
解决:把请求挪到 background service worker 里发,worker 的请求权限由 host_permissions 控制,不受页面 CORS 策略约束。manifest 里加上接口域名:
{ "host_permissions": [ "https://*.example.com/*" ] }host_permissions 和 permissions 是两码事,加 downloads、storage 不影响跨域权限。如果接口服务端连 worker 的请求也拦,就退回到 DOM 解析路线——继续从页面里读“加载更多”后的节点,不去碰接口。接口签名复杂、参数加密的站点,解析 DOM 反而稳定;这个取舍取决于具体站点,不存在放之四海皆准的答案。
5. 不同站点的差异化适配:用站点模板和字段校验让一版代码跑多个平台
5.1 站点模板与字段映射:别给每个平台写一套解析
很多插件最后死在“只适配了一个站点”。商品信息抓取要长期用,一定会遇到第二个、第三个平台,每个平台的 DOM 结构都不同。常见做法是维护一张站点模板表,按 hostname 匹配,把“选择器”提升为“配置项”,而不是把每个平台的解析逻辑写成 if-else 塞进同一个函数。
| 站点匹配 | 标题选择器 | 价格选择器 | 主图选择器 | SKU 选择器 | 备注 |
|---|---|---|---|---|---|
| 平台A 的 /item/ 路径 | h1 → og:title | product:price:amount → .product-price | og:image → .main-image img | .sku-item.selected | 详情页 |
| 平台B 的 /goods/ 路径 | og:title → h1 | .price-now → [class*="price"] | meta[property="og:image"] | .spec.active | 带券后价 |
模板表落到代码是一个数组,每个元素是站点匹配条件和它对应的选择器组:
// content.js:站点模板配置 const siteTemplates = [ { match: location.hostname.includes("example.com"), title: ["h1", "meta[property='og:title']"], price: ["meta[property='product:price:amount']", ".product-price"], image: ["meta[property='og:image']", ".main-image img"], sku: [".sku-item.selected", ".spec.active"] } ]; function getTemplate() { return siteTemplates.find((template) => template.match) || null; }parseProduct 里不再写死选择器,而是从 getTemplate() 取配置再执行。pickText / pickAttr 的实现就是从候选选择器列表里依次取第一个非空值,逻辑简单但救急。新增一个平台时只需要往 siteTemplates 数组里加一条配置,改一行选择器不用动主流程,这就是“配置驱动”比“if-else 堆站点”的维护成本低得多的原因。平台多了之后,可以再把模板配置抽到单独的 JSON 文件里,插件发布时跟着打包。
5.2 频率控制与失败重试:给批量采集留出安全阀
单页抓取不用在乎频率,批量采集列表页时就要小心。一个商品列表可能有三四百条货,脚本会自动逐个打开详情抓取,瞬间打出去几十个请求,很容易触发站点防护或让目标服务器压力陡增。我给批量流程定的规则很简单:每条请求间隔 800~1500ms 随机;单条失败重试最多 2 次;连续失败超过 5 条就停住,等用户确认再继续。
// background.js:带重试的抓取封装 async function fetchWithRetry(parseFn, retries = 2) { for (let attempt = 0; attempt <= retries; attempt++) { try { const result = await parseFn(); if (result && result.title) return result; throw new Error("empty title"); } catch (error) { if (attempt === retries) { return { failed: true, reason: error.message }; } const delay = 800 * (attempt + 1); await new Promise((resolve) => setTimeout(resolve, delay)); } } }判断成功的标准是 result.title 非空,而不是“函数没抛异常”。有时候脚本静默失败,返回一个 title 为 null 的对象,如果不检查就会把空记录当成功入库。重试间隔按 800ms、1600ms 递增,避免对正在报错的接口立刻重打第二拳。重试两次仍失败就返回{ failed: true },由上层把这个对象写进 failedRecords,而不是让异常一路抛出去把队列打断。批量任务最怕一个坏记录让整个队列中断,失败记录单独收集、最后导出成清单,是成本最低的止损方式。
5.3 抓取结果的完整性校验:不让一条坏数据混进报表
抓取脚本的坑在于它“看起来成功”。页面加载正常、脚本没报错、CSV 也导出来了,但某条记录的价格是促销标签的数字,或者标题带了半截省略号。等数据进了报表再发现,返工成本就高了。所以在入库前加一道字段级校验,三项全过才进 records:
// background.js:入库前的字段校验 const fieldRules = [ { field: "title", test: (value) => value && value.length >= 4 }, { field: "price_low", test: (value) => value !== null && value > 0 }, { field: "page_url", test: (value) => /^https?:\/\//.test(value) } ]; function validateRecord(record) { const failed = []; for (const rule of fieldRules) { if (!rule.test(record[rule.field])) { failed.push(rule.field); } } return failed; }validateRecord 返回失败字段列表,为空说明通过;否则这条记录进 failedRecords,导出时单独生成“抓取失败清单.csv”,让用户知道是哪条记录、缺了哪个字段。规则本身要克制,title 只要求长度不少于 4,price_low 只要求是正数。字段校验的边界是:不同站点的价格和标题格式差异太大,正则一旦写严就会误杀正常记录,从而掩盖真正的问题。校验的目的是分层:机器能判的自动判,机器判不了的留给用户看失败清单人工处理。
6. 再进一步:把单页抓取升级成批量采集
grabItem 从单页抓取到批量采集,只差两件小事:一个任务队列,一个快捷键。批量采集的常规做法是:列表页先收集所有商品链接,存入 pendingUrls 数组,后台脚本每次取出一个 URL,打开新标签页,注入内容脚本抓详情,抓完关标签页,再取下一条。队列保存在 chrome.storage.local 里,每抓完一条就 shift 掉并写回;即使插件中途崩溃或电脑重启,下次打开插件还能从断点继续,这就是“断点续抓”的最小实现。
给抓取动作挂快捷键会顺手很多。在 manifest.json 里加 commands 配置:
{ "commands": { "grab-product": { "suggested_key": { "default": "Alt+Shift+G" }, "description": "抓取当前商品" } } }后台脚本监听 chrome.commands.onCommand,收到 grab-product 就走 2.3 节同一套注入和解析流程。手不离键盘就能连续抓完一页又一页商品,体验比每次移动鼠标点图标快一个量级。
我印象最深的一次踩坑,是接一版抓某平台比价数据的脚本时,没有坚持“上线前先人工核 10 条”的习惯,结果价格字段把促销优惠也算了进去,几百条数据入库后报表对不上,返工大半天。那次之后我给自己定了个规矩:每次改完选择器、模板或批量流程,先抓 10 条和页面对一遍,确认字段语义没变再放量。这个习惯花不了两分钟,却能拦住大多数“静默错误”。希望帮到你。
本文还有配套的精品资源,点击获取