简介:grabItem 是一款基于 JavaScript 的 Chrome 浏览器扩展工具,面向需要批量采集电商商品信息的运营人员、独立开发者以及前端学习者。它能从淘宝、京东、天猫等商品详情页自动提取标题、价格、优惠价格、主图与附图等关键字段,并支持通过独立配置的 url.js 指定提交地址,方便将抓取结果传送到个人接口。压缩包共包含 13 个文件,主要由 5 个 JavaScript 脚本、2 个 HTML 页面构成,辅以 JSON 配置、说明文档和图标资源,整体体积约 46KB,轻量且结构清晰。目前已有 215 人学习浏览。这份小工具麻雀虽小五脏俱全:三套抓取脚本分别针对不同平台商品页,解析思路清晰且相互独立,适合对照学习;弹出页面和后台脚本展示了浏览器扩展的界面交互、消息通信与数据处理流程,而扩展清单文件的权限配置也有助于理解插件运行机制。读者既可将其作为现成脚本进行改造,也能借源码快速入门 Chrome 扩展开发,实现自己的抓取或内容提取需求。
1. 为什么商品信息抓取要从浏览器插件开始
把几十个商品页面的标题、价格、库存、评价一条条复制到表格里,是件谁干谁烦的事。grabItem 这个方向解决的就是这个问题:不碰服务器、不写独立爬虫,直接在 Chrome 里做一个插件,当前页面是什么样,抓到的就是什么样。它的核心价值不是“爬数据”,而是把浏览器变成你的抓取工作台,点一下按钮,当前页面的商品信息就整理成结构化数据,落到本地文件里。适合电商运营做竞品调研、商品运营做日常比价,也适合想快速验证数据采集方案的新手开发者,不用搭建完整爬虫环境,插件的骨架足够轻,拆开来看也完全能懂。
2. grabItem 背后的机制:从页面 DOM 到本地 JSON 的数据链路
2.1 MV3 三件套:manifest、content script、service worker 各自干什么
Chrome 插件从 Manifest V3(后面统一说 MV3)开始,强制把整个插件拆成了三个能互相协作的部件,理解它们的分工,后面才不会写乱。
manifest.json 是插件的身份证。它声明了插件叫什么、需要哪些权限、入口文件在哪、往哪些页面注入脚本。MV3 时代它还是一个约束框架,因为 Chrome 对 background 页面的限制明显变多,很多老写法在这里会直接翻车。我一般会先写一个最小可用版本,确认插件能被加载,再逐步加功能。
content script 是真正接触页面 DOM 的脚本。它运行在页面上下文里,但又是独立沙箱,能读取和修改 DOM,却不能直接访问页面里的 JS 变量。这个特性决定了它的定位:负责“看”页面,把商品信息从渲染完成的 HTML 里抠出来。
service worker 是 MV3 取代 background page 的产物。它是一个事件驱动的脚本,平时处于休眠状态,有事件来了才被唤醒,处理完又休眠。它不适合做持续性的数据处理,适合做消息中转、调用 Chrome API、触发下载等轻量操作。
三者的协作关系可以理解为:content script 在页面里采集,把数据打包成消息发给 service worker,service worker 负责调用浏览器能力保存或中转,popup 是你点击按钮的操作面板。搞清楚每个部件只能干什么、不能干什么,是写插件的第一步。
2.2 权限边界与消息传递:为什么抓取代码要放在 content script 里
很多第一次写插件的人会问:为什么不直接在 popup 里写 document.querySelector?
因为 popup 页面是浏览器扩展自身的页面,它和当前打开的标签页是两个独立的文档环境。popup 里的 JS 拿不到页面的 DOM,必须通过 chrome.tabs.sendMessage 或 chrome.scripting.executeScript 把代码注入到目标标签页里执行,这就是 content script 之所以存在的根本原因。
MV3 里我常用的注入方式是 chrome.scripting.executeScript,它可以动态执行一段函数,而且函数会序列化到目标页面里运行。这个机制给了 grabItem 一个很灵活的结构:你可以在 popup 里拼好选择器配置,然后注入到当前页面执行抓取逻辑。
还有一条要特别说明的消息传递路径:content script 采集完数据,调用 chrome.runtime.sendMessage 发给 background,background 那边通过 chrome.runtime.onMessage 接收。注意 sendResponse 是异步通道,如果监听器里有异步操作,必须在回调函数里返回 true,否则消息通道会提前关闭,这个细节后面避坑章节还会再说。
权限方面,MV3 推荐用 activeTab 权限代替大范围的 host_permissions。activeTab 在你点击插件图标时才临时授权当前标签页,把抓取范围限制在你主动操作的页面上,既安全又符合 Chrome 线上商店的审核预期。不过如果你要抓的是后台自动轮转的多标签页,就得在 host_permissions 里明确声明域名匹配规则。
2.3 数据流转设计:页面 → 脚本 → 存储 → 导出文件
抓商品信息不能止步于在控制台打印 JSON,得考虑数据最终去哪里。我常用的链路是:content script 采集后立刻清洗成标准字段,推送到 popup 的数据预览区,同时通过消息发给 background,background 调用 downloads API 直接生成 JSON 或 CSV 文件。
这一步的关键决策是:不要把所有数据都堆在内存里。商品列表页动辄几百条数据,每条包含标题、价格、链接、销量、评价数等字段,加起来轻松超过 1MB。如果全部留在 popup 的变量里,页面一刷新就全没了。所以我习惯在 content script 里采集一条就整理一条,先把数据暂存到 chrome.storage.session,最后统一导出文件,避免插件崩溃时数据直接蒸发。
存储这块要分清两个 API:chrome.storage.local 是持久化的,空间配额大约 10MB,适合存配置、存少量历史记录;chrome.storage.session 是会话级的,关闭浏览器就清空,适合存抓取过程中的临时结果,配额大、速度快。
这样设计的好处是数据链路每一段都可观测:采集完可以预览,导出前可以校验,存到本地后还能二次处理。抓取不是黑匣子,每个环节的数据流向都是可控的。
3. 把 grabItem 搭起来:最小可用的插件骨架与抓取主流程
3.1 一个最小 manifest.json:先让插件能被 Chrome 加载
代码先贴出来,这是整个插件的地基。
{ "manifest_version": 3, "name": "grabItem", "version": "1.0.0", "description": "抓取当前页面商品信息并导出 JSON", "permissions": ["activeTab", "scripting", "storage", "downloads"], "host_permissions": ["<all_urls>"], "action": { "default_popup": "popup.html", "default_title": "grabItem" }, "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" } ] }manifest.json 里的几个关键字段要理解清楚。permissions 里的 scripting 是 MV3 执行动态注入的前提,没有它 chrome.scripting.executeScript 就直接报错;downloads 负责把商品数据导出成文件;activeTab 用于点击图标时获取当前页面的临时访问权。host_permissions 配了 <all_urls>,在开发调试阶段这种全量声明最省事,但如果插件要发布到商店,建议收紧成你实际要抓取的域名,不然审核会卡壳。
background.service_worker 指向 background.js,它是插件的后台中枢。content_scripts 里的 matches 指定了哪些页面会被注入 content script,run_at 选 document_idle 表示页面加载差不多完成后再注入,避免太早抓不到异步渲染的 DOM。加载插件的方式就两步:打开 chrome://extensions,打开右上角的开发者模式,点击“加载已解压的扩展程序”,选中这个目录就行了。
3.2 popup 面板与触发逻辑:把抓取动作变成一个按钮
popup 是用户直接操作的面板,grabItem 的交互可以做得非常简单:一个“抓取当前页面”按钮、一个字段预览区、一个“导出 JSON”按钮。
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <style> body { width: 320px; padding: 12px; font-size: 14px; } button { width: 100%; margin: 6px 0; padding: 8px; } pre { max-height: 200px; overflow: auto; background: #f5f5f5; padding: 8px; } </style> </head> <body> <h3>grabItem</h3> <button id="grabBtn">抓取当前页面</button> <pre id="preview">暂无数据</pre> <button id="exportBtn" disabled>导出 JSON</button> <script src="popup.js"></script> </body> </html>popup.js 里最核心的触发逻辑是注入抓取函数到当前标签页执行。
// popup.js document.getElementById('grabBtn').addEventListener('click', async () => { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab?.id) return; // 这里注入的是字符串化的函数,实际运行在目标页面上下文 const results = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: grabPageData, args: [selectors] }); const data = results?.[0]?.result || []; document.getElementById('preview').textContent = JSON.stringify(data, null, 2); document.getElementById('exportBtn').disabled = false; window.__lastData = data; });chrome.scripting.executeScript 的参数要解释一下。target 指定注入到哪个标签页,使用 tab.id;func 是要在页面里执行的函数,这个函数会被序列化后放进目标页面执行,所以它不能引用外部作用域的变量,所有依赖都要通过 args 传进去。args 数组对应 func 的形参列表。executeScript 返回的数组里,每一项对应一个帧的执行结果,我们取 [0] 就是主帧的返回值。
这种设计的好处是:抓取逻辑的定义还在 popup 里,只是执行环境挪到了页面。你可以随时调整抓取函数和选择器,不需要重新加载插件就能生效。
3.3 content script 的字段提取与清洗:标题、价格、链接、销量
商品信息抓取的核心问题不是“能不能拿到 DOM”,而是“拿到的字符串干不干净”。价格里可能带人民币符号和千分位逗号,销量可能是“10万+”这种模糊表达,链接可能缺协议头。我习惯在 content script 里做一层独立的清洗。
// content.js(手动注入版:grabPageData 通过 executeScript 注入) function grabPageData(selectors) { function cleanPrice(raw) { if (!raw) return null; // 去掉货币符号、逗号、不可见空格,再提取数字 const normalized = raw.replace(/[¥¥$\s,,]/g, ''); const match = normalized.match(/(\d+(\.\d+)?)/); return match ? parseFloat(match[0]) : null; } function cleanCount(raw) { if (!raw) return null; const match = raw.replace(/\s/g, '').match(/([\d.]+)(万)?/); if (!match) return null; const num = parseFloat(match[1]); return match[2] === '万' ? Math.round(num * 10000) : num; } const items = []; const nodes = document.querySelectorAll(selectors.item); nodes.forEach((node) => { const titleEl = node.querySelector(selectors.title); const priceEl = node.querySelector(selectors.price); const linkEl = node.querySelector(selectors.link); const salesEl = node.querySelector(selectors.sales); const title = titleEl ? titleEl.textContent.trim() : ''; const rawPrice = priceEl ? priceEl.textContent : ''; const price = cleanPrice(rawPrice); const sales = salesEl ? cleanCount(salesEl.textContent) : null; const link = linkEl ? linkEl.href : ''; // 过滤掉明显不是商品的节点,比如标题为空或者价格为空 if (title && price) { items.push({ title, price, sales, link, grabbedAt: new Date().toISOString() }); } }); return items; }内容和参数说明。selectors 是一个配置对象,包含 item(每个商品卡片的容器选择器)、title、price、link、sales 四个字段的二级选择器。我强烈建议把 selectors 做成可配置参数,因为不同电商平台的 DOM 结构差别很大,每次抓新站点只改配置就行,不用重写抓取函数。
cleanPrice 里用正则把人民币符号、全角逗号、空格全部去掉,只保留数字和小数点,然后转成浮点数。cleanCount 是处理“10万+”这类销量文案的,把中文“万”换算成阿拉伯数字。最后加了一个过滤条件,标题为空或价格提取失败的节点直接丢弃,宁可少抓也不要有脏数据混进来。
3.4 导出 JSON 与本地文件:让抓取结果不是黑匣子
抓完数据只显示在 popup 里没有意义,得能落盘。这里走 background 里的 downloads API。
// background.js chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === 'SAVE_PRODUCTS') { const blob = new Blob([JSON.stringify(msg.data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); chrome.downloads.download({ url, filename: `grabItem_${Date.now()}.json`, saveAs: true }); sendResponse({ ok: true }); return true; // 保持消息通道,等异步下载创建完成 } });popup 里点击导出时调用 chrome.runtime.sendMessage 把当前数据传给 background。
document.getElementById('exportBtn').addEventListener('click', () => { const data = window.__lastData || []; if (data.length === 0) return; chrome.runtime.sendMessage({ type: 'SAVE_PRODUCTS', data }); });这里有个重要的 MV3 细节:background 的 onMessage 监听器里,如果调用了异步 API,必须 return true 表示要异步响应,否则 Chrome 会在监听函数返回后立刻关闭消息通道,sendResponse 调用就无效了。
为什么导出要放 background 而不是直接在 popup 里下载?因为 popup 可能在下载过程中被用户点击其他地方而关闭,一旦 popup 销毁,正在进行的任务也跟着中断。background service worker 的生命周期相对独立,更适合处理这类需要持续完成的操作。
4. 商品列表页的 3 个进阶抓取策略:动态渲染、懒加载与 SPA 切页
4.1 MutationObserver:列表刷新后自动补抓
很多电商列表页不是一次加载完的。用户点击筛选条件或翻页后,页面通过 AJAX 拉数据,DOM 被局部替换。这时你点“抓取当前页面”,页面结构还在,但重新执行一次抓取函数就能拿到新数据。
手动点击倒还好,问题是有些列表页每隔几秒就自动刷新一次推荐位,你抓完的一瞬间数据又变了。这种情况我会用 MutationObserver 盯着商品列表容器,一旦发现子节点数量变化,就自动触发一次轻量抓取。
// content.js 中动态监听示例 let lastCount = 0; const listContainer = document.querySelector('#product-list'); const observer = new MutationObserver(() => { const currentItems = document.querySelectorAll('.product-item').length; if (currentItems !== lastCount) { lastCount = currentItems; // 这里可以通知 popup:页面列表已更新,建议重新抓取 } }); observer.observe(listContainer, { childList: true, subtree: true });MutationObserver 的参数要理解清楚。observe 的第一个参数是监听目标,第二个参数里 childList: true 表示监听子节点的增删,subtree: true 表示递归监听所有后代节点。商品列表里的图片懒加载也会触发子树变化,但真正的列表数量变化通常发生在直接子节点层,所以判断 currentItems 是否变化比判断任意节点变化更可靠。
实际使用时要注意,observer 回调触发频繁,别在里面做重活。我一般只更新一个计数标记,真正抓取仍然由用户点击按钮触发,避免插件自己高频请求导致页面卡顿。
4.2 懒加载商品的滚动触发与等待策略
详情页长列表和瀑布流列表常见的坑是:商品节点在页面里存在,但图片和部分字段是滚动到可视区域后才渲染的。你直接抓,能拿到标题和链接,但价格还没渲染出来,结果是字段缺了一半。
处理懒加载的思路是先滚动到底部,等渲染完成再执行抓取。滚动动作本身要用脚本模拟,而且滚动要分步走,一次滚到底在某些懒加载框架里不生效。
// popup.js 中注入的滚动等待函数示例 async function scrollToBottomAndWait(maxScrolls = 10) { for (let i = 0; i < maxScrolls; i++) { window.scrollTo(0, document.body.scrollHeight); // 等待一段时间让异步渲染完成 await new Promise((r) => setTimeout(r, 800)); const before = document.body.scrollHeight; const after = before; // 如果滚动高度不再变化,说明到底了 if (after === before) break; } }这段代码的节奏设计是每滚一次停 800ms,给页面留出发出请求和渲染 DOM 的时间。maxScrolls 限制最大滚动次数,防止某些无限加载页面停不下来。更稳妥的做法是把滚动和检测分开:每次滚动后检查商品数量是否还在增长,如果连续两次数量没有变化就停止。
需要注意的是,scrollTo 在页面深层次滚动容器里不一定生效。有的页面主体是一个内部 div 做 overflow: auto 滚动,而不是 window 滚动。这时要把滚动目标从 window 换成那个滚动容器,否则滚半天页面纹丝不动,商品一直加载不出来,很多人在这里卡到怀疑人生。
4.3 选择器做成配置表:页面改版时不用改代码
抓取方案最怕什么?最怕平台改版。某电商平台一改 DOM 结构,你的插件直接报废,得重新打开控制台排查新选择器,再更新代码、重新加载插件。我解决这个问题的思路是:把选择器配置外置,存到 chrome.storage 里。
配置结构可以设计成多个平台模板,用户进入某商品列表页时,先选择当前页面对应的平台模板,然后点抓取。
// config.js 中的选择器模板示例 const PLATFORM_TEMPLATES = { platformA: { item: '.product-card', title: '.product-title', price: '.product-price', link: 'a.product-link', sales: '.product-sales' }, platformB: { item: '.goods-item', title: 'h3.goods-name', price: '.now-price', link: '.goods-item a', sales: '.sell-count' } };把选择器配置和抓取逻辑彻底分开后,页面改版时的应对方式是:先抓一个样本节点,用代码尝试所有已配置的选择器,找出哪一个失效了,再手动更新对应字段的选择器字符串。改动的是配置,不是抓取代码,风险面和改动成本都小了。
更进一步,可以加一个“测试选择器”按钮,在 popup 里输入一个选择器,注入一段脚本看看能匹配多少个节点,返回匹配数量和第一个节点的文本,这比反复改代码刷新插件快得多。我自己的习惯是每抓一个新平台,先用测试按钮验证每个字段选择器都能独立匹配到值,再保存模板,这样配置不会带病上线。
5. grabItem 避坑记录:5 个翻车现场与排查路径
5.1 抓不到价格:数据在伪元素里,正则怎么写都没用
现象:标题、链接都能抓到,唯独价格字段永远是 null,控制台能看到价格明明显示在页面上。
原因:价格数字是通过 CSS 伪元素 ::before 或 ::after 渲染上去的,textContent 根本读不到伪元素内容。还有一种变体是价格经过前端 JS 拆成了多个 span,每个 span 只显示一个数字字符,直接用 textContent 拼接也是对的,但如果选择器只选中了父级并取了 textContent.trim(),拿到的是多个数字挤在一起。
解决:先检查元素的计算样式,getComputedStyle(el, '::before').content 能拿到伪元素的文本。如果是拆字渲染,就选中所有子 span 并把它们的 textContent 拼接起来。另外多留一个候选方案,优先从元素的>function validateProduct(item) { const errors = []; if (!item.title || item.title.length < 2) errors.push('标题缺失'); if (!item.price || item.price <= 0) errors.push('价格异常'); if (!/^https?:\/\//.test(item.link || '')) errors.push('链接无效'); if (item.sales !== null && item.sales < 0) errors.push('销量异常'); return errors; }
校验规则里价格必须大于 0、链接必须是完整 http 协议、标题至少两个字符。销量允许为空,因为有的商品不展示销量字段,但只要有值就必须是正数。校验完把每条数据的 errors 数组拼到对象里,导出时一看便知哪些数据需要人工复查。
导出 CSV 时有个细节,直接拼字符串出来的 CSV 在 Excel 里打开中文会乱码,因为缺少 BOM 头。加上 \ufeff 前缀再导出就能正常识别。
function exportToCsv(items) { const header = 'title,price,sales,link,sourceUrl,grabbedAt'; const rows = items.map((it) => [ it.title, it.price, it.sales ?? '', it.link, it.sourceUrl, it.grabbedAt ].join(',')); const csvContent = '\ufeff' + [header, ...rows].join('\n'); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' }); // 用 chrome.downloads.download 下载 }导出后的回归测试也很重要。每次页面改版或扩展更新后,我会先找三条已知商品跑一遍完整流程,校验通过率必须 100% 才继续用。这个回归测试没法完全自动化,但我用一个小技巧简化它:把上次抓到的数据存成基准样本,下次抓取后逐条对比标题和价格,如果差异率超过某个阈值,基本可以判定页面结构变了。
给每条数据都打上 grabbedAt 时间戳和 sourceUrl 来源地址,已经是我写插件雷打不动的习惯。运营同事拿着数据来问“这条是哪个页面抓的”,打开 sourceUrl 一核对就知道答案,省掉了大量扯皮。我现在每次动抓取逻辑前,都先拿三条数据跑一遍校验流程,确认字段完整、链接有效、价格为正,再放全量任务。希望帮到你。
本文还有配套的精品资源,点击获取