
最近折腾了一个电商比价插件 Arbitra,把日本几个主流购物网站的价格抓下来比一比。说实话,日淘的价差比想象中夸张——同一台相机、同一个游戏机,Amazon.co.jp、乐天市场、煤炉、Yahoo! Auction 能差出 3000 到 20000 日元。全靠手动开四个标签页对比太蠢了,不如让浏览器替我干这活。
这篇聊聊技术实现:怎么在没 API 的情况下从多个日本电商网站抓价格,以及哪些模式能扛住网站改版。
用 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 → 1280012,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 的来回调用简短,不要维持长时间连接。
比价的前提是确实在比较同一个东西。跨平台识别"同款产品"比听起来难多了:
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 单页应用(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 的无限滚动——价格数据都在,但分页机制搞得干净提取一团糟。