
简介这是一款基于 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.jsService 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.jsService 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[typeapplication/ldjson]); 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/ldjson节点可能是面包屑、可能是评价聚合。要在代码里做一次类型判断只取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.nameh1 文本document.title价格JSON-LD.offers.pricemeta[itempropprice]类名含 price 的节点主图meta[propertyog:image]JSON-LD.image图片标签 alt 相似节点实际写选择器时通用类名会误伤促销横幅。价格节点的准确定位可以一句话概括节点文本里必须出现数字且文本长度不能太长。带这个思路采集价格文本的函数如下function pickPriceNode() { const candidates document.querySelectorAll([class*price i], [itempropprice]); 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.localpopup 打开后主动去查存储。// 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, [itempropprice]], cover: meta[nameog:image] }, item.shop-b.com: { title: [#itemTitle, h1], price: [.sale-price], cover: meta[propertyog:image] } };collect 函数先按 location.hostname 查配置命中就用站点专用选择器没命中才走通用逻辑。这套结构让我在目标站点改版时只需要改配置而不是改核心代码维护成本明显下降。从那以后我每次改选择器都会强制走一遍三页检查控制台验证 collect、popup 查入库、导出 CSV 核对 price 类型。正是这套习惯帮我拦下了无数次“看起来改了很稳、上线就翻车”的意外。希望帮到你。本文还有配套的精品资源点击获取