site logo

Marico's space

无需API抓取价格:面向日本电商的Chrome内容脚本模式

前端技术 2026-06-25 17:40:02 4

最近折腾了一个电商比价插件 Arbitra,把日本几个主流购物网站的价格抓下来比一比。说实话,日淘的价差比想象中夸张——同一台相机、同一个游戏机,Amazon.co.jp、乐天市场、煤炉、Yahoo! Auction 能差出 3000 到 20000 日元。全靠手动开四个标签页对比太蠢了,不如让浏览器替我干这活。

这篇聊聊技术实现:怎么在没 API 的情况下从多个日本电商网站抓价格,以及哪些模式能扛住网站改版。

DOM 抓取的核心问题

用 API 拿价格是最干净的:调个接口、解析 JSON、收工。但日本这些电商平台基本没有面向普通开发者的实时价格 API。亚马逊的产品广告 API 需要有活跃的联盟资质,煤炉没有公开 API,乐天的接口数据更新还滞后于页面上显示的价格。

所以只能用内容脚本直接读 DOM。

这样做最大的问题是脆弱——网站一改前端结构,CSS 选择器就废了。但有几个模式可以让这套方案存活得久一点。

选择器策略:多个候选 + 兜底

别押注在单个 CSS 选择器上。用一个有序的候选列表,前一个返回 null 就往后走:

const PRICE_SELECTORS = { amazon: [ '#corePrice_feature_div .a-price-whole', // primary product page '#priceblock_ourprice', // older layout '#priceblock_saleprice', '.a-price.a-text-price .a-offscreen', // price in search results '#price', ], mercari: [ '[data-testid="price"]', // React-rendered '.item-price', // older layout 'h3.item-price', 'div[class*="price"] span', ],
}; function extractPrice(site) { const selectors = PRICE_SELECTORS[site] ?? []; for (const selector of selectors) { const el = document.querySelector(selector); if (el?.textContent?.trim()) { return parseJPPrice(el.textContent); } } return null;
}

当 Amazon 或者煤炉改版了某个页面的结构,列表里通常至少有一个选择器还能用。

解析日本价格字符串

日本价格用 ¥ 或者 ,有时候带千分位逗号,有时候不带:

function parseJPPrice(text) { // Remove ¥, 円, commas, and whitespace; parse as int const cleaned = text.replace(/[¥円,\s]/g, '').trim(); const num = parseInt(cleaned, 10); return isNaN(num) ? null : num;
}

几个边界情况要处理:

  • ¥12,800 → 12800
  • 12,800円(含税) → 12800(去掉非数字后缀)
  • ¥1,280 (¥160/个) → 1280(取第一个数字)
  • ¥–(缺货标记)→ null

跨标签页通信

内容脚本在不同的标签页之间不能直接互相调用。"从标签页 A 提取价格然后发到标签页 B" 的模式是这样的:

内容脚本(生产者标签页):

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.type === 'EXTRACT_PRICE') { const price = extractPrice(request.site); sendResponse({ price, url: location.href }); } return true; // async response
});

Service Worker(协调者):

async function getPriceFromTab(tabId, site) { return chrome.tabs.sendMessage(tabId, { type: 'EXTRACT_PRICE', site, });
} // Example: get prices from both open tabs
async function comparePrices(amazonTabId, mercariTabId) { const [amazonResult, mercariResult] = await Promise.all([ getPriceFromTab(amazonTabId, 'amazon'), getPriceFromTab(mercariTabId, 'mercari'), ]); return { amazon: amazonResult.price, mercari: mercariResult.price, delta: (amazonResult.price ?? 0) - (mercariResult.price ?? 0), };
}

在 Manifest V3(MV3)里,Service Worker 可能会被销毁,不一定能及时处理响应。保持 sendMessage 的来回调用简短,不要维持长时间连接。

同款产品识别问题

比价的前提是确实在比较同一个东西。跨平台识别"同款产品"比听起来难多了:

  • Amazon 有 ASIN,煤炉有商品 ID——没有共同的标识符
  • 产品名称写法不同:"索尼 WH-1000XM5 头戴式耳机 黑色" vs "Sony 耳机 WH1000XM5 黒"
  • 新旧程度差异:Amazon 上是全新的,煤炉上可能是二手的

Arbitra 的务实做法是:从当前页面识别出产品,然后去对比网站搜索。从 Amazon 提取产品名称的逻辑:

function getAmazonProductTitle() { const el = document.querySelector('#productTitle') ?? document.querySelector('.product-title-word-break'); if (!el) return null; // Clean manufacturer suffix — "...耳机【国内正规品】" → "...耳机" return el.textContent.trim().split('【')[0].trim();
}

然后用清理过的标题去煤炉搜索,从搜索结果里提取价格区间。

处理 React 渲染的内容

煤炉是个 React 单页应用(SPA)。商品价格不在初始 HTML 里,是等 hydration 之后才注入的。如果内容脚本在 document.ready 就触发,那价格元素还没生成呢。

两种方案:

方案一:重试循环

async function waitForPrice(site, maxWaitMs = 5000) { const start = Date.now(); while (Date.now() - start < maxWaitMs) { const price = extractPrice(site); if (price !== null) return price; await new Promise(r => setTimeout(r, 200)); } return null;
}

简单但浪费资源。一次性提取的话够用。

方案二:MutationObserver

function waitForPriceElement(site) { return new Promise((resolve) => { const selectors = PRICE_SELECTORS[site] ?? []; // Check if already present for (const s of selectors) { const el = document.querySelector(s); if (el?.textContent?.trim()) { resolve(parseJPPrice(el.textContent)); return; } } const observer = new MutationObserver(() => { for (const s of selectors) { const el = document.querySelector(s); if (el?.textContent?.trim()) { observer.disconnect(); resolve(parseJPPrice(el.textContent)); return; } } }); observer.observe(document.body, { childList: true, subtree: true }); // Timeout fallback setTimeout(() => { observer.disconnect(); resolve(null); }, 5000); });
}

更高效但增加了复杂度。频繁提取的话值得用这个。

网站改版与维护

主要的维护负担:Amazon.co.jp 和煤炉改版都很勤。几个减少出问题的办法:

定期测试选择器是否还能用。 我每周跑一次轻量检查脚本,访问各个网站确认价格选择器还能返回可解析的值。

优先用 data 属性而不是 class 名。 类似 .a-price-whole 这种类名比 React 生成的 .sc-abc123 稳定多了。[data-testid="price"] 是开发者在代码里明确放的,改版时一般不会被删。

记录提取失败。 当内容脚本试完所有选择器都返回 null 时,发个错误事件到 GA4(数据分析平台):

if (price === null) { sendGA4Event('price_extraction_failed', { site, url: location.href.substring(0, 100), // truncate for privacy });
}

这样拿到的是真实用户的失败数据,而不是靠手动测试。

整套插件——跨 Amazon.co.jp、乐天市场、Yahoo! Shopping 和煤炉 比价——在 Chrome 网上应用店可以免费装。

你们搞 DOM 抓取的时候最头疼的是啥?对我来说最烦的是 Yahoo! Auction 的无限滚动——价格数据都在,但分页机制搞得干净提取一团糟。