
有段时间我每天至少打开十次京东金融的网页版就为了看一眼浙商金今天的积存价格。白天在工位上不方便一直盯盘可积存金价格不是一天只报一次的那种黄金波动起来几分钟不刷新买入成本就完全不一样。手动刷新其实不难难的是你没办法一直盯着它也没办法在价格跌到心理价位时第一时间知道。后来我花了一个周末写了这个“京东金融浙商金价实时监控”Chrome 扩展插件专门在后台盯价格跌破目标价弹通知冲上高位也弹通知顺手把每次价格变动都存下来。这个项目代码量不大但把 Manifest V3 扩展开发里几个关键点全串了一遍适合有 JavaScript 基础、想自己动手做网页监控工具的朋友参考。1. 需求拆解盯浙商金价到底需要什么工具1.1 积存金盯盘的三个真实痛点先说清楚我盯的是什么。京东金融网页版里展示的浙商金本质是一类银行积存金产品按克计价价格跟随黄金市场在交易时段内实时变动。它的页面价格不是收盘才更新而是像股票分时线一样有波动买在当天的相对低点和买在相对高点一克可能差出几块钱。对于经常分批买入的人来说这种分时波动意味着机会也意味着麻烦。我最初遇到的问题是纯手动刷新效率太低。上班时电脑开着网页但注意力在工作上隔几分钟切过去看一眼经常错过价格回调的窗口期。更要命的是刷新完只看到当前这个数没有连续记录晚上想复盘今天的价格走势、看看自己买贵了没有完全没有数据可查。第三个痛点是阈值提醒做不到。手动盯盘没法给价格设一个“到达某个价位就通知我”的规则只能靠人肉实时盯着既不科学也不可持续。所以核心需求其实就三条自动读取页面上的实时金价、把价格历史记录下来、达到设定阈值时主动提醒。至于界面长什么样、代码怎么组织都是围绕这三条需求展开的。1.2 Chrome 扩展对比脚本与自建服务胜在哪里在动手写代码之前我对比过几种常见方案油猴脚本、Python 轮询脚本、手机提醒 App以及最后的 Chrome 扩展插件。方案上手难度能否后台运行原生命令级通知价格历史记录我的评价Chrome 扩展插件中页面开着即可支持本地存储最适合个人盯价场景油猴脚本低页面开着即可需要额外封装部分可通过 GM_setValue 实现适合快速验证做小工具不够顺手Python 轮询脚本中需要独立进程或服务器需要另外接推送数据库或文件要处理登录态和接口复杂度陡增手机提醒 App低适合移动场景支持在 App 内无法自动读取网页价格选择 Chrome 扩展的核心原因有三个。第一它能直接读取已经打开的页面 DOM不需要逆向接口、不需要处理登录态这是最省事也最可靠的数据来源。第二扩展自带 notifications、storage 这些系统级 API做完之后弹通知、存历史都是现成的不用自己搭消息推送。第三它随着浏览器启动和关闭不需要额外维护一台服务器或一个常驻 Python 进程。唯一的限制是目标页面得在浏览器里开着否则扩展无从读起这个限制对个人盯盘场景完全可以接受。2. 插件架构Manifest V3 下的数据链路设计2.1 background service worker 不是常驻进程代码要围绕事件写Chrome 扩展从 Manifest V2 升级到 V3 之后影响最大的是后台脚本。V2 时代的 background page 是常驻页面可以在里面挂 setInterval每秒跑一次都没问题。V3 的 service worker 是事件驱动设计空闲一段时间就会被浏览器回收下次有事件再唤醒。这个改动让扩展更省内存但也逼着开发者改变写法不能再默认后台是一个永不停止的计时器。我设计的数据链路完全围绕事件来组织页面打开时content script 在 DOM 上挂监听价格变化时content script 通过 runtime.sendMessage 把这个消息发给 service workerservice worker 被消息唤醒后做存储、判断阈值、弹通知等动作。动作执行完service worker 可以随时休眠下一次价格变化再把它叫醒。这套链路的好处是节能且响应快价格一变消息立刻触发处理逻辑不需要每秒轮询。2.2 抓价格用 MutationObserver而不是定时器轮询京东金融的网页版是标准的单页应用页面切换、数据更新都是异步渲染价格数字变化时并不会整页刷新。如果只用 setInterval 定时去读容易读到页面更新中间的不完整值而且会做很多无效读取。MutationObserver 是浏览器原生提供的 DOM 变化监听 API可以在指定节点的子节点增删、属性变化、文本变化时触发回调。我选择观察整个页面 body 下的节点变化因为金价页面结构不复杂这种监听方式没有明显性能开销回调触发后再去重新读取价格节点拿到最新值。相比定时器MutationObserver 是事件驱动的价格不变化就不做任何事价格变化后立刻知道。这个机制天然适合单页应用里的价格监控。2.3 存储只放本地通知按阈值触达价格历史数据我用 chrome.storage.local 保存。它相当于浏览器给每个扩展分配的一个本地小仓库数据不会跨设备同步也不会自动消失足够存放几个月的价格记录。每次收到新价格就追加一条记录同时只保留最近几百条避免无限增长。通知策略我也做了约束。用户设置两个阈值一个低于提醒价、一个高于提醒价价格满足条件时触发 chrome.notifications 系统通知。为了避免价格在阈值附近反复横跳时疯狂弹通知我加了一个最短提醒间隔同一个方向上首次触发提醒后5 分钟内不再重复提醒。这样既不会漏掉关键价格点也不会打扰到烦人。3. 核心代码实现从 manifest 到弹窗走通全流程3.1 manifest.json只声明够用的权限Manifest V3 扩展的起点是 manifest.json。这个文件里最关键的是权限声明。我见过不少扩展喜欢把 permissions 写满其实完全没有必要。这个项目只需要三个权限storage 用来存历史价格notifications 用来弹提醒alarms 用来定期唤醒后台做状态检查。{ manifest_version: 3, name: 浙商金价实时监控, version: 1.0.0, description: 监控京东金融浙商金实时价格达到阈值时发送通知, permissions: [storage, notifications, alarms], host_permissions: [https://*.jd.com/*], content_scripts: [ { matches: [https://*.jd.com/*], js: [content.js], run_at: document_idle } ], background: { service_worker: background.js }, action: { default_popup: popup.html, default_title: 浙商金价监控 } }这里有两个细节值得说。一是 host_permissions 没有写all_urls只限定到京东域名下。这样扩展能访问的页面范围被锁死既不给自己留安全隐患也符合善意使用原则。二是 content_scripts 的 matches 和 host_permissions 要保持一致否则 content script 可能没有权限注入到目标页面。如果你实际打开的页面不在*.jd.com下要按真实页面域名调整这两个配置。3.2 content.js写一套不容易失效的价格选择器content script 的核心工作是从页面 DOM 里找到价格数字。这一步看起来简单坑却不少。京东金融页面经过构建工具压缩后很多 class name 是动态生成的比如带随机哈希的 long class页面版本一升级就可能失效。所以我在 content.js 里准备了一套备选选择器而不是只依赖一个 class。(function () { // 多级选择器避免单一 class 失效后完全抓不到 const SELECTORS [ .price-text, [class*price], [data-testidgold-price] ]; let lastPrice null; function findPriceElement() { for (const selector of SELECTORS) { const el document.querySelector(selector); if (el) return el; } return null; } function extractPrice(el) { if (!el) return NaN; const text el.textContent.trim(); // 过滤掉货币符号、逗号等非数字内容 const normalized text.replace(/[^\d.]/g, ); return parseFloat(normalized); } function sendUpdate(price) { chrome.runtime.sendMessage({ type: PRICE_UPDATE, price }) .catch(() {}); } function handlePriceChange() { const el findPriceElement(); const price extractPrice(el); if (!price) return; // 价格精度到小数点后两位即可微小抖动不推送 if (Math.abs(price - lastPrice) 0.001) { lastPrice price; sendUpdate(price); console.log([金价监控] 最新价格 price); } } window.addEventListener(load, () { handlePriceChange(); const observer new MutationObserver(() { handlePriceChange(); }); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); }); })();选择器里加了三级兜底先找固定的业务类名再用包含 price 的 class 模糊匹配最后用>const MIN_NOTIFY_INTERVAL 5 * 60 * 1000; // 5分钟 let lastNotifyTime 0; chrome.runtime.onMessage.addListener((message, sender, sendResponse) { if (message.type PRICE_UPDATE) { handlePriceUpdate(message.price) .then(() sendResponse({ ok: true })) .catch((err) { console.error(err); sendResponse({ ok: false }); }); return true; // 异步响应 } sendResponse({ ok: true }); }); async function handlePriceUpdate(price) { const now Date.now(); const data await chrome.storage.local.get({ history: [], todayLow: 0, todayHigh: 0 }); const history data.history || []; history.push({ time: now, price }); // 只保留最近 500 条避免本地数据无限膨胀 const trimmedHistory history.slice(-500); const todayPrices trimmedHistory.filter((item) { const d new Date(item.time); return d.toDateString() new Date(now).toDateString(); }); const todayLow todayPrices.length ? Math.min(...todayPrices.map((i) i.price)) : price; const todayHigh todayPrices.length ? Math.max(...todayPrices.map((i) i.price)) : price; await chrome.storage.local.set({ history: trimmedHistory, todayLow: todayLow, todayHigh: todayHigh }); const settings await chrome.storage.local.get({ minThreshold: 0, maxThreshold: 0 }); if (settings.minThreshold price settings.minThreshold) { notifyOnce( 浙商金价提醒, 当前价格 price 元/克已低于你设置的 settings.minThreshold 元/克 ); } if (settings.maxThreshold price settings.maxThreshold) { notifyOnce( 浙商金价提醒, 当前价格 price 元/克已高于你设置的 settings.maxThreshold 元/克 ); } } function notifyOnce(title, message) { const now Date.now(); if (now - lastNotifyTime MIN_NOTIFY_INTERVAL) return; lastNotifyTime now; chrome.notifications.create({ type: basic, iconUrl: icon.png, title: title, message: message }); } chrome.alarms.create(gold-price-check, { periodInMinutes: 1 }); chrome.alarms.onAlarm.addListener((alarm) { if (alarm.name gold-price-check) { console.log(后台巡检扩展仍在运行); } });需要注意service worker 不再是常驻进程所以代码里不能用全局变量保存太多状态。每次收到消息时我都从 storage 里读出最新数据再更新确保状态不会丢。actuallylastNotifyTime这个变量在 service worker 休眠后会被重置但它只是一个防止短时间重复提醒的临时变量重置之后最坏情况就是多提醒一次可以接受。alarms 定时器的作用是定期唤醒 service worker让扩展在后台保持活跃。它不能代替 content script 去读价格因为页面数据还是要靠页面里的脚本抓取。这里的定时巡检更像一个保活机制确保后台不会长时间不响应。3.4 popup 页面今日高低价与简易走势图扩展点击后的弹出页面我做成一个极简面板显示最新价格、今日最高最低、最近价格走势线以及两个阈值输入框。这样用户不用打开网页就能快速看一眼当前状态。!DOCTYPE html html langzh-CN head meta charsetUTF-8 style body { width: 320px; font-family: sans-serif; padding: 12px; } .price { font-size: 28px; font-weight: bold; color: #c8791e; } .stat { display: flex; justify-content: space-between; margin: 8px 0; } canvas { background: #fafafa; border: 1px solid #eee; margin: 8px 0; } label { display: block; font-size: 13px; margin-top: 8px; } input { width: 100%; padding: 6px; margin-top: 4px; box-sizing: border-box; } button { margin-top: 10px; padding: 6px 12px; cursor: pointer; } /style /head body div classprice idprice--/div div classstat span今日最低span idlow--/span/span span今日最高span idhigh--/span/span /div canvas idchart width280 height100/canvas label低于此价格提醒元/克/label input typenumber idminInput step0.01 placeholder例如 480.00 label高于此价格提醒元/克/label input typenumber idmaxInput step0.01 placeholder例如 520.00 button idsave保存提醒设置/button button idclear清空历史/button script srcpopup.js/script /body /htmldocument.addEventListener(DOMContentLoaded, async () { const data await chrome.storage.local.get({ history: [], todayLow: 0, todayHigh: 0, minThreshold: 0, maxThreshold: 0 }); const history data.history || []; const current history.length ? history[history.length - 1].price : --; document.getElementById(price).textContent current 元/克; document.getElementById(low).textContent data.todayLow; document.getElementById(high).textContent data.todayHigh; document.getElementById(minInput).value data.minThreshold; document.getElementById(maxInput).value data.maxThreshold; // 画最近 60 个价格点的简单折线 if (history.length 2) { const canvas document.getElementById(chart); const ctx canvas.getContext(2d); const points history.slice(-60); const prices points.map((p) p.price); const min Math.min(...prices); const max Math.max(...prices); const range max - min || 1; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.beginPath(); points.forEach((p, i) { const x (i / (points.length - 1)) * canvas.width; const y canvas.height - ((p.price - min) / range) * (canvas.height - 20) - 10; if (i 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.strokeStyle #c8791e; ctx.lineWidth 1.5; ctx.stroke(); } document.getElementById(save).addEventListener(click, async () { await chrome.storage.local.set({ minThreshold: parseFloat(document.getElementById(minInput).value) || 0, maxThreshold: parseFloat(document.getElementById(maxInput).value) || 0 }); }); document.getElementById(clear).addEventListener(click, async () { await chrome.storage.local.set({ history: [], todayLow: 0, todayHigh: 0 }); location.reload(); }); });popup 页面不是这个项目的复杂度核心但它的存在让插件的体验完整了不少。原来我只是想知道“现在多少钱了”后来发现把历史走势画出来以后自己判断买卖点的心态反而更好了。这一点在后面写心得时还会提到。4. 安装、调试与高频问题排查4.1 开发者模式加载扩展的四个步骤写完代码后安装调试非常简单。在 Chrome 地址栏输入chrome://extensions/打开右上角的“开发者模式”开关点击“加载已解压的扩展程序”选择项目文件夹。如果项目结构正常扩展会立刻出现在列表里。注意 content script 只有在页面重新加载后才会注入所以安装完记得刷新一下京东金融页面。调试有三个入口。第一在chrome://extensions/页面点击扩展卡片上的“service worker”链接会弹出后台脚本的 DevTools 控制台background.js 里的 console.log 都显示在这里。第二在目标页面按 F12Console 面板顶部的上下文切换里可以选择当前扩展的 content script单独调试页面里的抓取逻辑。第三右键扩展图标选择“审查弹出内容”可以调试 popup 页面。把这三个入口都用起来基本能覆盖绝大多数问题排查场景。4.2 高频问题速查表我说几个实际使用中最常遇到的问题都做过具体排查整理成速查表。症状可能原因排查方法页面打开后没有日志输出content script 没有注入确认 matches 包含当前页面域名并刷新页面拿到的是 NaN 或 undefined选择器选中了错误元素F12 检查目标元素 DOM 结构调整 SELECTORS通知一直不弹系统通知权限未开启到系统设置中允许 Chrome 通知再确认阈值设置后台长时间不运行service worker 休眠用 chrome.alarms 定时唤醒但不能代替页面抓取历史数据增长异常忘记做裁剪每次写入后使用 slice(-500) 限制长度价格波动频繁时重复提醒缺少提醒去重增加最短提醒间隔变量4.3 实战心得选择器写得好不好决定插件能活多久这个项目写下来耗时最长的不是逻辑代码而是选择器的反复迭代。第一版我直接用了页面上的一个固定 class效果很好。后来页面一改版class 名里的哈希变了插件当场失效。我被迫去看看有哪些属性是相对稳定的最终确定了三级备选方案。几个经验值得记下来。第一优先找语义化属性比如>