上班打开浏览器,顺手点一下扩展栏里的小图标,今天持仓的基金是红是绿、赚了还是亏了、大盘大概什么风向,一眼扫完然后关掉,继续写代码。这就是我做“自选基金助手”这个Chrome扩展的初衷。它不是投资建议,不搞预测,也不是什么量化神器,就是一个老老实实帮你实时追踪基金收益的工具:管理自选列表、拉取最新净值、计算持仓浮盈亏,然后把结果放在浏览器里随时可看。
这篇文章我就把整个开发过程、设计取舍、核心代码,以及安装调试时踩过的那些坑完整说一遍。特别是网上常搜到的那几个问题——比如“该扩展程序未列在 Chrome 应用商店中,并可能是在您不知情的情况下添加的”、“codex安装google chrome扩展程序无法完成安装”、“chrome无法安装扩展程序”,我都会结合实际场景给出排查思路。如果你也想做个类似的工具,或者正打算用代码生成工具帮你写扩展但卡在安装环节,这篇应该能帮上忙。
1. 上班族盯盘,选Chrome扩展才是正经事
1.1 手机App、网页端和浏览器扩展,我为什么选最后者
做这个项目之前,我也认真对比过几种“上班时看基金”的方案。最直接的是手机App,通知推送很全,但问题也在这儿——公司开会、老板在旁边的时候,手机一亮屏全是基金涨跌,场面特别尴尬。而且手机通知容易分散注意力,本来只是扫一眼,结果顺手刷了十分钟资讯。
网页端呢,每次都要打开基金网站或者天天基金这类平台,页面重、广告多,登录状态还容易过期。更麻烦的是,网页上每次想看收益都要手动刷新,盯盘体验很差。
终端脚本我也试过,用Python写个脚本去拉净值再打印到命令行,放在自己电脑上倒没什么问题,但换台电脑、或者想给同事用,成本和门槛一下就上去了。
最后我选择了浏览器扩展,准确说是Chrome扩展,理由是它完美匹配上班族的使用场景:浏览器本来每天都要开,扩展常驻在工具栏里,点一下弹出面板,再点一下收起,轻量且无感。它不像App那样会强推通知,也不像网页那样需要打开一个新标签页,更不会像脚本那样有环境依赖。再加上扩展可以直接使用浏览器的storage能力做本地存储,自选列表、持仓数据都放在本地,隐私性也比云端方案好不少。
从技术实现的角度看,Chrome扩展做这件事还有个天然优势:它天然就是一个“带界面的后台常驻程序”,既能做定时任务,又能展示复杂界面,还能跟浏览器本身深度集成。对“自选基金助手”这种轻量工具来说,这个形态几乎是最优解。
1.2 扩展到底怎么做到“实时追踪”的
很多第一次接触Chrome扩展的读者可能会好奇,一个浏览器插件凭什么能实时拉取外部行情数据?这就要说清楚扩展的基本运行机制。
一个标准的Manifest V3扩展通常由三部分组成:
- manifest.json:扩展的“身份证”,声明名称、版本、权限、入口文件等。
- popup页面:点工具栏图标后弹出的窗口,通常是一个HTML文件,负责展示界面。
- background service worker:后台服务,负责处理定时任务、消息中转、数据获取等不需要界面时也能执行的逻辑。
再加上content script(内容脚本)可以用来操作网页DOM,但“自选基金助手”这类工具并不需要篡改网页内容,所以我没有引入内容脚本,权限也会更干净一些。
扩展能跨域请求外部接口,是因为在manifest.json里通过host_permissions声明了允许访问的域名范围。浏览器渲染引擎对普通网页有严格的同源策略,但扩展属于更高权限的上下文,只要声明了对应权限,就可以像普通后端程序一样去请求行情接口。这也是为什么“自选基金助手”能做到实时追踪:它本质上是一个运行在你本地的、拥有网络请求权限的轻量应用。
它又如何做到“常驻”?在Manifest V3里,background使用service worker实现。浏览器会在需要的时候唤醒它、执行完任务后销毁,而不是一直占着内存。配合chrome.alarms或者setInterval这类定时机制,每隔一段时间去拉一次行情数据,再把最新净值写入storage或直接更新工具栏图标角标,用户一打开popup就能看到相对新鲜的数据,完全不需要手动刷新。
1.3 自选基金助手的功能边界
工具做得再漂亮,也得先划清楚边界。我在设计功能时把“自选基金助手”定位成三个核心能力:
第一,实时追踪基金收益。用户维护一份自选基金列表,扩展在交易时段自动刷新净值,并根据用户持有的份额和成本价计算出当日收益、持仓收益、持仓收益率。
第二,本地自选管理。增删基金、调整排序、记录每只基金的持有份额和持仓成本,这些都是最刚需的功能。数据通过chrome.storage.local保存在本地浏览器里,卸载扩展后可以选择清除,也可以在重装后恢复(保留浏览器用户数据的情况下)。
第三,汇总概览。在popup面板里一眼看到总持仓市值、今日总盈亏、今日收益率等汇总信息,不需要在心里做加法。
做工具最忌讳的就是大而全。我没有做K线图、新闻推送、基金经理访谈这类很“重”的功能,因为这些功能的边际价值很低,却会显著增加扩展体积和权限占用,反而让人不放心。明确的功能边界,也是这个项目能快速做完、稳定运行的重要原因。
2. 工程搭建与配置:从零落地一个扩展项目
2.1 目录结构与manifest配置
先看整个项目最简单的目录结构:
fund-helper/ ├── manifest.json ├── popup.html ├── popup.js ├── popup.css ├── background.js ├── icons/ │ ├── icon16.png │ ├── icon48.png │ └── icon128.png └── README.md核心文件只有5个,对一个小工具来说完全够了。icons目录里的图标文件可以用在线图标生成工具从一张512x512的png导出三个尺寸,并不复杂。
manifest.json是整个扩展的命根子,我用的配置如下:
{ "manifest_version": 3, "name": "自选基金助手", "version": "1.0.0", "description": "上班族自选基金收益追踪工具,实时展示持仓收益与涨跌状态。", "action": { "default_title": "自选基金助手", "default_popup": "popup.html", "default_icon": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" } }, "background": { "service_worker": "background.js" }, "permissions": ["storage", "alarms"], "host_permissions": [ "https://*.eastmoney.com/*", "https://*.sina.com.cn/*" ] }几个关键字段拆开说:
manifest_version必须填3,Chrome已经在逐步淘汰V2版本,新项目直接上V3没有悬念。
action.default_popup指定了点击工具栏图标后弹出的页面,这里我用了popup.html。一个容易踩的坑是,如果default_popup配置了,Chrome会把这个弹窗当作唯一的点击响应,background里写的onClicked监听器就不会触发了。所以如果既要弹窗又要后台任务,一定把任务放在service worker里,而不是依赖action.onClicked。
permissions里我只要了storage和alarms。storage用来读写本地配置和持仓数据,alarms用来做周期性的后台刷新。不需要tabs、不需要scripting、不需要cookies,权限永远是最小化,对用户来说更安全,审核时也更顺利。
host_permissions里我声明了两个行情数据源域名。注意这里有个经验:域名不要写成通配所有网站的格式,比如<all_urls>。尽量精确到你真正要请求的域名,既减少隐私审查风险,也让用户清楚这个扩展不会去读取任意网站的数据。
2.2 行情数据从哪来
做基金工具,数据源是头等大事。我调研了几种方式,最终采用了免费公开的基金行情接口。
天天基金和新浪财经这类平台都有公开的净值/估值接口,返回JSON或者字符串,字段包含基金代码、名称、单位净值、估算净值、估算涨跌幅、更新时间等。对于个人自用型工具来说,这是成本最低、也最容易上手的方案。要注意的是,这类接口并没有官方的“开放平台”承诺,接口地址可能调整、字段可能变化、请求频率也有限制,所以代码里必须做好容错和降级。
我设计数据获取层时遵循三条原则:
- 数据源只读,绝不向外部发送任何用户的持仓数据。所有持仓份额、成本信息都保存在本地,请求行情接口时只传基金代码,不传个人信息。
- 多源降级。如果第一个接口请求失败,自动切换备用源;两个都失败时,保留上一次成功拉取的数据,在界面上标注“数据延迟”。
- 控制频率。交易时段每1-2分钟刷新一次就足够了,净值又不是美股tick数据,刷太频繁徒增服务器压力,也容易触发对方的限流策略。
2.3 存储设计与自选列表管理
扩展的本地存储,我直接用了chrome.storage.local。它相比localStorage的优势在于容量更大(默认可以到10MB以上,M V3下storage.session还能存临时数据)、支持对象级读写、异步API不会阻塞UI线程、并且可以配合chrome.storage.onChanged监听数据变化。
我的存储结构设计很简单:
// 自选基金列表 { fundList: [ { code: "110011", // 基金代码 name: "易方达优质精选混合", shares: 5000.00, // 持有份额 cost: 1.8325, // 持仓成本单价(含手续费摊薄) order: 1 // 排序值 } ] }用户在popup里可以通过表单新增基金、编辑持仓、删除基金,每一次变更都写回storage.local。由于数据都带order字段,渲染列表时按order排序,就能实现类似“上移/下移”的排列调整。成本单价和份额信息非常重要,它们是收益计算的基础,一旦丢失只能手动重新录入,所以我还加了一个“导出配置”的小功能,把整个storage里的数据导出成JSON,方便备份。
3. 核心链路实现:拉数据、算收益、做展示
3.1 数据获取层:封装一个稳定的行情请求
自选基金助手的核心链路是:拉取行情 -> 计算收益 -> 渲染界面。第一步就是数据获取层的稳定性。
我封装了一个fetchQuotes函数,传入一组基金代码,批量获取最新行情:
async function fetchQuotes(codes) { if (!codes || codes.length === 0) return []; const sources = [ // 主源 async () => { const url = `https://fund.eastmoney.com/pingzhongdata/${codes[0]}.js`; // 实际使用中更推荐批量接口,这里示意单只拼接 // 建议优先使用支持批量查询的公开净值接口 const resp = await fetch(url, { credentials: "omit" }); if (!resp.ok) throw new Error("主源请求失败"); return resp.json(); }, // 备用源 async () => { // 新浪等备用接口,格式不同,需要单独解析 const resp = await fetch(`https://hq.sinajs.cn/list=${codes.join(",")}`, { headers: { "Referer": "https://finance.sina.com.cn" } }); if (!resp.ok) throw new Error("备用源请求失败"); return parseSinaResponse(await resp.text()); } ]; for (const source of sources) { try { const data = await source(); return normalizeQuotes(data); } catch (e) { console.warn("[自选基金助手]", e.message); } } return []; // 两个源都失败,返回空数组,由上层决定如何处理 }这里有几个实操细节值得说:
第一,请求接口时credentials要设成omit,避免浏览器附带不必要的cookie信息,对接口方更礼貌,也更不容易被拦截。
第二,新浪系的接口有反爬限制,需要带Referer头才能正常返回数据。这种“经验知识”网上大家总结得很多,但用了别人的接口就要尊重对方的规则,频率必须控制好。
第三,我在数据获取层做了normalizeQuotes,把不同来源的数据统一成内部结构:{ code, name, nav, estNav, estimatePct, updateTime }。这样上层计算和展示完全不用关心数据是从哪里来的,将来换数据源只需改这一个函数,扩展性会好很多。
3.2 收益计算:净值到“赚/亏多少钱”
数据拿到手之后,接下来的问题是:用户花钱买了基金,到底怎么看自己赚了多少?
计算主要基于三个输入参数:
- 持有份额:用户实际买入确认的份额。
- 持仓成本单价:包括申购费摊薄后的买入成本,简单说就是“买这份基金花的总成本除以持有份额”。
- 最新净值:从行情接口拿到的估值或净值。
核心公式就这么几个:
- 当前市值 = 最新净值 × 持有份额
- 当日收益 = (最新净值 - 昨日净值) × 持有份额
- 持仓收益 = (最新净值 - 持仓成本单价) × 持有份额
- 持仓收益率 = (最新净值 - 持仓成本单价) / 持仓成本单价 × 100%
我用一个简单的例子算给读者看:假设你持有某基金5000份,持仓成本单价是1.8325元,今天接口返回的最新估算净值是1.9080元,那么当前市值约9540元,持仓收益约(1.9080-1.8325)×5000=377.5元,持仓收益率约4.12%。如果昨天净值是1.8900元,那当日收益就是(1.9080-1.8900)×5000=90元。
计算并不复杂,但实操中有一个概念需要区分清楚:净值和估值。净值是基金公司收盘后公布的“官方价格”,通常晚上才更新;估值是第三方平台根据持仓股票实时计算出来的“参考价格”,交易时段会跳动。我的扩展在盘中优先展示估值,用于“实时追踪”的感觉;盘后如果净值已更新,则自动切换到净值。界面上一律标注数据的性质,避免用户误解。
计算逻辑都放在一个纯函数里:
function calcFundItem(item, quote) { const currentNav = quote.nav || quote.estNav || item.cost; const marketValue = currentNav * item.shares; const costValue = item.cost * item.shares; const profit = marketValue - costValue; const profitRate = item.cost > 0 ? (currentNav - item.cost) / item.cost : 0; return { ...item, ...quote, currentNav, marketValue, profit, profitRate: profitRate * 100 // 转为百分比 }; }纯函数的好处是方便单测。我本地写了个node脚本,把几组已知输入跑了一遍,确保加减乘除没算错——这点看起来基础,但我见过很多类似工具因为成本单价录入不对或者四舍五入规则混乱,最后算出来的收益差了十万八千里。
3.3 展示层:把popup做成盯盘面板
展示层我坚持一个原则:信息密度高,但一眼能扫完。
popup.html的核心结构很简单,不整那些花哨的框架,一个列表就完了:
<div id="summary"> <div class="summary-item"> <span>总市值</span> <strong id="totalValue">--</strong> </div> <div class="summary-item"> <span>今日盈亏</span> <strong id="todayProfit">--</strong> </div> </div> <div id="fundList" class="fund-list"> <!-- 动态渲染 --> </div>popup.js里的逻辑大致是:onload时从storage读取fundList,再请求行情,计算完数据后调用renderList渲染列表。我给每只基金渲染一张“卡片”,卡片里包含基金名称、代码、当前净值/估值、涨跌幅、持仓收益和收益率,涨跌用颜色区分:红涨绿跌是国内习惯,我这里用class控制颜色,没有写死在HTML里。
渲染函数写得比较朴素:
function renderList(items) { const container = document.getElementById("fundList"); container.innerHTML = items.map((item) => { const profitClass = item.profit >= 0 ? "up" : "down"; const pctClass = item.profitRate >= 0 ? "up" : "down"; return ` <div class="fund-card"> <div class="fund-info"> <div class="fund-name">${item.name || item.code}</div> <div class="fund-code">${item.code}</div> </div> <div class="fund-value">${(item.currentNav ?? "--").toFixed(4)}</div> <div class="fund-profit ${profitClass}"> ${item.profit >= 0 ? "+" : ""}${item.profit.toFixed(2)} </div> <div class="fund-rate ${pctClass}"> ${item.profitRate >= 0 ? "+" : ""}${item.profitRate.toFixed(2)}% </div> </div> `; }).join(""); }刚开始我犯过一个错误:把storage读取和网络请求都直接写在popup.js顶层,结果popup一打开就白屏,因为网络get请求是异步的,storage读取也一样。后来我把数据流程串成async函数,在init里await后再渲染,问题就解决了。另外每次点开popup都重新请求接口,体验上会有可感知的延迟,所以我改成优先渲染上次缓存的数据,接口返回后再“无缝更新”界面,体感立刻好了很多。
4. 安装部署与热搜踩坑实录
4.1 开发者模式加载扩展的正确姿势
开发过程中,扩展还没上架Chrome应用商店,所以要先用开发者模式加载“已解压的扩展程序”。具体步骤很简单:
- 在Chrome地址栏输入 chrome://extensions 并回车。
- 打开右上角的“开发者模式”开关。
- 点击左上角“加载已解压的扩展程序”。
- 选择项目目录,注意是包含manifest.json的那个文件夹本身,不是它的上一层,也不是某个压缩包。
加载成功后,扩展会出现在列表中,工具栏上也会出现对应的图标。如果图标没出现,可以点工具栏右侧的拼图图标,找到“自选基金助手”,点击图钉固定到工具栏。
这里就必然遇到网上搜到的那句红字提示:“该扩展程序未列在 Chrome 应用商店中,并可能是在您不知情的情况下添加的。”
我第一次遇到这个提示也愣了一下,以为电脑中了什么招。后来想明白了,这就是Chrome对一切非商店来源扩展的统一警告。你从商店安装的扩展不会有这行字,而从开发者模式加载、或者通过企业策略安装的本地扩展,都会显示这句话。它不等于“这是病毒”,只代表“Chrome没有经过商店审核渠道对这个扩展做过信任背书”。
所以正确判断方法是:这个扩展是不是你自己加载的?如果是,你知道它的代码是干什么的,那就没问题,可以放心用。如果某天你发现浏览器里多了一个你从来没安装过的扩展,而且你不知道它是哪来的,那才需要严肃对待,先停用、再查来源,必要时清掉整个用户数据目录。
4.2 “chrome无法安装扩展程序”的几个真实原因
很多人用codex这类AI工具生成扩展代码后,直接卡在安装这一步,界面提示“无法完成安装”,逛了一圈论坛也没找到答案。我帮几个同事排查过类似问题,总结下来原因无非这么几类。
第一,文件夹选错了。最常见的错误是选了包含manifest的子目录的上级目录,或者直接选了压缩包。Chrome的“加载已解压的扩展程序”只认目录,不认zip,所以一定要先把压缩包解压,再选择解压后的目录。
第二,manifest.json格式有问题。AI生成的代码偶尔会带上markdown代码块标记,或者文件编码是UTF-8 with BOM,导致Chrome解析失败。解决办法是用VS Code打开manifest.json,确认第一行没有奇怪的```字符,把文件另存为UTF-8 without BOM。
第三,manifest版本不兼容。如果你用的是较新版本的Chrome,manifest_version 2可能会被禁用,报错信息会提示manifest_version无效或不被支持。坚持用3就好。
第四,企业策略限制。公司电脑可能由IT部门统一配置了Chrome策略,禁止加载开发者模式扩展。这种情况在个人电脑上少见,遇到的话只能联系管理员或者使用商店版本。
我把排查过程整理成一张表,方便大家对照:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 加载按钮是灰色不可点 | 文件夹里没有有效manifest.json | 确认目录下直接包含manifest.json,不能嵌套子目录 |
| 提示“无法从该文件添加扩展” | 选了压缩包而非解压目录 | 先解压,再选择文件夹 |
| 加载报错:Could not load manifest | manifest.json语法错误或编码问题 | 用编辑器检查JSON合法性,保存为UTF-8 without BOM |
| 提示manifest版本不受支持 | 使用了V2且Chrome版本过新 | 将manifest_version改为3 |
| 加载成功但没有图标 | 浏览器未固定扩展图标 | 点拼图图标,找到扩展并固定 |
4.3 常见问题速查表
除了安装问题,使用过程中还会遇到一些日常问题,我也一并整理在这里:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| popup打开后数据一直显示“--” | 行情接口请求失败或域名不在host_permissions中 | 按F12打开开发者工具查看Network请求,检查域名和权限配置 |
| 盘中估值不刷新 | background定时任务被休眠或接口被限流 | 检查chrome://extensions里service worker状态,必要时点“更新”重新激活 |
| 收益数据与券商App不一致 | 成本单价或份额录入有误,或一个是估值一个是净值 | 核对持仓成本与份额,确认当前净值数据性质 |
| 扩展图标上出现数字角标 | background通过chrome.action.setBadgeText设置了角标 | 这是设计行为,表示汇总涨跌家数或异常提醒 |
| 修改manifest后不生效 | 扩展没有重新加载 | 在chrome://extensions页面点击扩展卡片上的刷新按钮 |
“该扩展程序未列在chrome应用商店中”的提示还有一个变体,叫“可能是在您不知情的情况下添加的”。很多人一看到“不知情”三个字就慌了,其实这主要是因为Chrome检测到扩展是通过外部方式安装的,并不代表真的有什么恶意东西进来了。你只要回忆一下自己有没有加载过解压扩展、或者通过命令行安装过什么工具,基本就能判断来源。
5. 自用体验优化与安全边界
5.1 刷新频率与性能控制
扩展用了一阵子之后,我开始琢磨怎么让它更“省电”。早期版本我设置每30秒刷新一次,结果发现完全没必要。基金估值在盘中变化本来就不快,30秒和2分钟看到的数字差异小到可以忽略,反而频繁请求容易触发接口限流,还会让service worker频繁唤醒,增加系统资源占用。
后来我把刷新策略改成分时段动态调整:
- 交易时段(工作日9:30-11:30、13:00-15:00):每2分钟刷新一次。
- 午间休市(11:30-13:00):每15分钟刷一次,只做一次健康检查。
- 非交易时段和节假日:不刷新,直接展示上一次的净值数据。
实现方式是在background.js里用chrome.alarms创建周期任务,然后在popup里根据时间判断是否跳过请求。alarms在Chrome里有最小间隔限制,低版本是1分钟,新版本更宽松,但2分钟已经完全够用。
我还做了一个细节优化:如果用户长时间没有打开popup,其实不需要每次都更新界面数据,但后台可以静默更新storage里的缓存。这样当用户真的点开popup时,看到的是几秒前刚更新过的数据,而不是打开瞬间去请求的“半成品”。
5.2 合规边界:数据、权限和风险提示
做金融类工具,无论多小,我都建议把关合规边界当成第一优先级。自选基金助手只做行情展示和收益计算,不提供任何买卖建议、不预测涨跌、不推荐基金。我在扩展描述和README里都明确写了“本工具仅供个人学习与信息记录使用,不构成任何投资建议”。
具体到实现层面,有三条硬性规定:
- 不收集用户任何隐私数据,所有持仓信息只存在用户本地浏览器。
- 不请求与基金行情无关的权限。
- 行情接口只用于只读请求,绝不把自选列表或持仓数据发送到任何服务器。
另外提醒一句,行情数据是有版权归属的。个人自用没有问题,但如果要公开发布、甚至商用,建议仔细阅读数据源的使用条款,避免法律风险。这也是我没有在正文里写死某个接口地址、而是教读者理解数据获取逻辑和封装方式的原因。
5.3 下一步可以怎么玩
目前自选基金助手已经满足了我日常盯盘的需求,但功能上还有不少可玩空间,我列几个方向供参考:
- 阈值提醒:当某只基金的涨幅超过5%或者亏损超过3%时,通过chrome.notifications发出浏览器通知。这个功能很实用,可以让我在开会时不点开面板也能收到提醒。
- Webhook推送:把收益变化推到企业微信机器人或者钉钉机器人,适合自己搭建的自动化工作流。
- 净值走势:用canvas或者Chart.js在popup里画出近30天净值曲线,信息量会更大。
- 多账户支持:按不同的成本策略存储多套持仓配置,切换查看。
- 一键导出:把持仓和收益导出为CSV,方便月末自己整理账目。
这些方向里,我认为阈值提醒的优先级最高,因为它真正抓住了上班族“想盯盘又不想一直盯”的痛点。
最后说点个人体会。这个项目断断续续写了大概三个晚上,核心代码量并不大,真正的挑战并不在怎么写代码,而在于怎么把一个随时都有可能崩掉的数据链路做得稳定、做得让人放心。Chrome扩展开发的门槛其实不高,只要你愿意读一遍manifest文档,完成一个小工具基本不是问题。但如果你和我一样依赖AI工具生成代码,请一定记住:AI能帮你把代码写得像模像样,却不能帮你点那一下“加载已解压的扩展程序”按钮。学会自己安装、排查、调试,才是把想法变成可用工具的分水岭。