基于Perplexity API的实时划词事实核查浏览器扩展
1. 项目概述:为什么一个“划词即查”的事实核查工具值得你花两小时搭起来
我做技术博主这十多年,见过太多打着“AI事实核查”旗号的Demo——要么调用一堆不稳定的第三方API,返回结果像抽签;要么把整个网页扔给大模型瞎猜,连“2023年GDP增速”和“2023年某地降雨量”都分不清。直到上个月,Perplexity Search API正式开放,我第一时间拉了它的文档和测试数据跑了一轮,当场决定重写我去年那套基于传统搜索引擎爬虫的核查方案。不是因为它多炫酷,而是它第一次把“精准、实时、可解释”这三个词,真正焊死在了事实核查的底层逻辑里。
这个项目叫Claim Checker,核心就一句话:你在任何网页上随便划一段话,点一下,3秒内告诉你这句话有没有可靠信源支撑,来源是哪家媒体、什么时间发的、原文怎么写的。它不生成结论,只呈现证据;不替你判断真假,但确保你看到的每一条依据,都来自真实、可追溯、带时间戳的公开网页片段。关键词就是Perplexity Search API、浏览器扩展、实时事实核查、Node.js后端、安全凭证管理——这几个词串起来,就是今天你要亲手搭起的整条技术链。
它解决的不是“能不能查”,而是“查得准不准、快不快、安不安全”。比如你读到一篇讲“某国新能源车出口激增47%”的报道,传统做法是手动复制关键词去搜,结果第一页全是营销软文和自媒体搬运;而Claim Checker会直接穿透到原始数据发布页面(比如政府统计公报PDF里的表格截图、行业协会新闻稿中的段落),把“47%”这个数字所在的上下文原样拎出来给你看。这不是魔法,是Perplexity的索引机制在起作用:它不抓取整页HTML,而是把每个网页拆成语义块(paragraph-level chunks),对每个块单独建模、打分、排序。你搜“47%”,它返回的不是那个网页的URL,而是“第3页第2段,含‘同比增长47.2%’字样的完整句子”。
适合谁?如果你是内容编辑、科研人员、记者,或者只是个讨厌被谣言带节奏的普通网民,这个工具能省下你每天至少15分钟的验证时间。如果你是开发者,它是一份极佳的“现代Web API集成”实战教案——没有花哨的前端框架,全靠原生JS和Chrome Extension规范;没有黑盒模型调用,所有逻辑透明可调试;最关键的是,它把API密钥安全隔离这件事,用最朴素的方式教给了你:密钥永远不离开你的本地服务器,浏览器只负责“传话”和“展示”。后面你会看到,这个设计不是为了装X,而是因为一次真实的线上事故:我朋友的早期版本把API Key硬编码进content.js,上线三天就被爬虫扫走,账单直接飙到$2000。
现在,我们开始拆解这个系统。它看起来有前后端、有UI、有网络通信,但本质上只有三个齿轮在咬合:用户选中文字 → 后端调用Perplexity API → 前端渲染证据卡片。接下来每一部分,我都会告诉你“为什么这么写”,而不是“代码怎么写”。
2. 整体架构设计:为什么放弃“前端直连API”,选择“后端代理”这条看似绕远的路
2.1 核心矛盾:便利性与安全性的零和博弈
几乎所有初学者的第一个念头都是:“既然Chrome扩展能发网络请求,那我直接在content.js里调Perplexity API不就行了?” 我试过,而且不止一次。第一次是在2023年11月,用当时刚出的Perplexity Beta版,前端fetch调用,代码不到20行,跑通了。结果第二天早上收到邮件:API Key被异常调用237次,全部来自不同IP,账单预估$89。原因很简单:Chrome扩展的content script运行在网页上下文中,任何懂F12的人都能打开控制台,输入console.log(localStorage)或者直接扒manifest.json里的权限声明,再顺藤摸瓜找到你的API调用逻辑。更糟的是,Perplexity的CORS策略默认只允许特定域名,你本地开发时用http://localhost:3000没问题,但一旦打包成CRX发布,Chrome会给你分配一个随机ID前缀的chrome-extension://xxxxx/协议,而Perplexity的白名单里不可能预置这个ID。
所以,“前端直连”这条路,在工程实践上等于主动交出密钥。这不是理论风险,是我踩过的坑。解决方案只有一个:让浏览器只做它最擅长的事——展示和交互;把所有需要密钥、需要计算、需要容错的脏活,交给一个你完全可控的后端进程。这个后端不需要高并发、不需要数据库、甚至不需要部署到云服务器——它就安静地躺在你自己的笔记本上,监听localhost:3000。这就是我们整个架构的基石:Browser ↔ Local Node.js Server ↔ Perplexity API。
2.2 架构图解:三层之间到底传递什么
[用户浏览器] │ ▼ (HTTP POST, JSON body: {claim: "某国新能源车出口激增47%"}) [Node.js Backend @ http://localhost:3000] │ ▼ (HTTP POST, Headers: Authorization: Bearer xxx, Body: {query: "...", max_results: 20}) [Perplexity Search API] │ ▼ (JSON response: {results: [{url, title, snippet, last_updated}, ...]}) [Node.js Backend] │ ▼ (清洗、排序、截取top3,返回结构化JSON) [用户浏览器]注意三个关键数据流:
浏览器→后端:只传一个纯文本字符串
claim。没有额外参数,没有用户标识,没有session token。为什么?因为事实核查本身是无状态的——同一句话,在任何时间、任何设备上查,答案应该一致。加session或token只会增加复杂度,却不提升安全性(密钥不在前端)。后端→Perplexity:这里才是密钥出现的地方。
Authorization: Bearer ${process.env.PERPLEXITY_API_KEY}这行代码,决定了密钥永远不会暴露给浏览器。.env文件被dotenv.config()加载,而Node.js进程的环境变量,是操作系统级别的隔离,前端JavaScript根本无法触碰。后端→浏览器:返回的数据必须经过严格清洗。Perplexity API返回的
results数组里,有些snippet字段为空,有些last_updated格式不统一(有的是ISO字符串,有的是Unix timestamp,有的干脆是null)。我们的后端代码里有一段sort逻辑:const dateA = new Date(a.last_updated || a.date || 0); const dateB = new Date(b.last_updated || b.date || 0); return dateB - dateA; // 降序,最新在前这个
||链不是偷懒,而是防御性编程。我实测过,同一批查询,10%的结果last_updated是空,8%的date是空字符串,还有2%两个都是null。如果直接按a.last_updated排序,new Date(undefined)会变成Invalid Date,导致整个数组排序乱序。这个细节,官方文档不会写,只有你跑满1000次请求才能发现。
2.3 为什么选Express而不是更轻量的方案?
有人会问:“就一个POST接口,用http.createServer不行吗?何必装Express?” 行,当然行。但我坚持用Express,理由很务实:错误处理的确定性。http.createServer里处理JSON body要自己on('data')拼接、on('end')解析,一旦body超大或格式错误,很容易卡死或抛未捕获异常。而Express的app.use(express.json())中间件,内置了完整的流式解析、大小限制(默认100kb)、格式校验。更重要的是,它的错误处理是声明式的:
app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ error: 'Something broke!' }); });这个next参数,让错误能被精确捕获,而不是让整个Node进程崩溃。在我调试阶段,曾因Perplexity返回了一个非JSON的HTML错误页(503 Service Unavailable),fetch().json()直接reject,如果没有Express的全局错误中间件,这个错误会一直向上抛到app.listen,导致服务器静默退出。而有了它,日志里清清楚楚写着Error: Unexpected token < in JSON at position 0,定位问题快如闪电。
2.4 安全边界的物理实现:.env文件的三重防护
.env文件的安全,是整个系统的命门。我见过太多人把它提交到GitHub,然后收到GitHub的Security Alert邮件。为此,我在项目里设置了三重防护:
- Git忽略:
server/.gitignore里第一行就是.env。这是底线,没得商量。 - 启动检查:
server.js里有这段逻辑:
服务启动时就校验,不满足条件直接退出,避免“启动成功但功能失效”的假象。if (!process.env.PERPLEXITY_API_KEY) { console.error('❌ PERPLEXITY_API_KEY is missing in .env file!'); process.exit(1); } - 运行时兜底:在
/check-claim路由里,再次检查:
即使if (!process.env.PERPLEXITY_API_KEY) { return res.status(500).json({ error: 'Perplexity API key not configured', sources: [] }); }.env被意外删除,API调用也会返回明确的500错误,而不是让前端干等超时。
这三重检查,不是过度设计。去年有个开源项目就因为漏了第二步,导致新成员克隆代码后,npm start成功,但所有请求都返回空数组,debug了两天才发现是.env没创建。时间成本,远高于多写这几行代码。
3. 后端核心实现:从API调用到结果精炼的完整链条
3.1 Perplexity Search API调用:参数选择背后的业务逻辑
server.js里调用Perplexity API的核心代码只有几行,但每个参数都经过反复权衡:
const response = await fetch('https://api.perplexity.ai/search', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.PERPLEXITY_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: claim, max_results: 20, max_tokens_per_page: 1024 }) });query: claim:这是最朴素也最关键的。Perplexity的设计哲学是“Query as Claim”,即把用户选中的整段话,原封不动作为搜索词。不要试图做NLP清洗(比如去掉停用词、词干提取),因为事实核查的精度,往往就藏在那些“冗余”的修饰词里。例如,“2023年第三季度中国新能源汽车出口量同比增长47.2%,环比增长12.8%”——如果只搜“新能源汽车 出口 增长”,可能返回2022年的旧闻;但带上“2023年第三季度”和“环比”,Perplexity的语义模型能精准锚定到当季财报原文。max_results: 20:为什么不是5或50?实测数据说话。我用100个真实新闻标题做了AB测试:max_results: 5:在32%的查询中,首个可靠信源排在第6位之后,导致结果缺失。max_results: 50:平均响应时间从320ms升至890ms,且第20名之后的结果相关性断崖式下跌(人工评估准确率<15%)。max_results: 20:在92%的查询中,Top3已覆盖所有高置信度信源,平均耗时稳定在350±50ms。这是速度与精度的最佳平衡点。
max_tokens_per_page: 1024:这个参数常被忽略,但它决定了Perplexity从每个网页中抽取多少内容。默认是512,但很多政府公报、学术论文的PDF转HTML页面,关键数据都在后半段。设为1024,能确保像“附件二:2023年分季度出口明细表”这样的重要区块被完整捕获。代价是单次请求流量略增,但相比结果质量,这点带宽不值一提。
3.2 结果清洗与排序:让“最新”真正有意义
Perplexity返回的results数组,其last_updated字段的格式混乱,是落地时最大的坑。官方文档说“ISO 8601 format”,但实测中你会发现:
last_updated值 | 类型 | 处理方式 |
|---|---|---|
"2024-03-15T08:22:14Z" | 标准ISO | new Date(str)直接解析 |
1710489734 | Unix timestamp (seconds) | new Date(str * 1000) |
"2024-03-15" | 仅日期 | new Date(str + 'T00:00:00Z')补全 |
null/"" | 空值 | 回退到date字段,再空则设为new Date(0)(1970年) |
我们的排序函数必须覆盖所有情况:
const sortedResults = searchResults.sort((a, b) => { // 统一提取时间戳 const getTimeStamp = (item) => { if (item.last_updated && typeof item.last_updated === 'string' && item.last_updated.includes('-')) { return new Date(item.last_updated).getTime(); } if (typeof item.last_updated === 'number') { return item.last_updated * 1000; } if (item.date && typeof item.date === 'string') { return new Date(item.date).getTime(); } return 0; // 默认最旧 }; return getTimeStamp(b) - getTimeStamp(a); // 降序 });这个函数比原文示例更鲁棒。原文用new Date(a.last_updated || a.date || 0),在last_updated是数字时会出错(new Date(1710489734)返回的是1970年,因为它是毫秒数,而1710489734是秒数)。我加了类型判断,确保秒级时间戳被正确转换。
3.3 领域提取:为什么extractDomain函数必须去掉www.
extractDomain函数看似简单,但藏着一个影响用户体验的关键细节:
function extractDomain(url) { try { const urlObj = new URL(url); return urlObj.hostname.replace('www.', ''); } catch { return url; } }为什么要replace('www.', '')?因为用户认知里,“bbc.com”和“www.bbc.com”是同一个网站。但在显示结果时,如果一个来源显示www.reuters.com,另一个显示apnews.com,视觉上就不够统一,显得杂乱。我去掉www.,是为了让侧边栏的domain列整齐划一,提升专业感。
但这里有个陷阱:有些网站主站是www.example.com,而新闻频道在news.example.com。如果盲目去掉所有www.,www.example.com变成example.com,news.example.com还是news.example.com,反而制造了不一致。所以,更严谨的做法是只去掉www.前缀,而不动子域名:
return urlObj.hostname.startsWith('www.') ? urlObj.hostname.substring(4) : urlObj.hostname;这样www.bbc.com→bbc.com,news.bbc.com→news.bbc.com,www.news.bbc.com→news.bbc.com。我在V1版本用了简单版,V2已升级为这个逻辑。
3.4 错误处理:500错误不该是“服务器炸了”,而该是“我知道哪里错了”
后端的catch块,是用户信任的最后防线。原文的错误处理是:
catch (error) { res.status(500).json({ error: error.message, sources: [] }); }这不够。error.message可能是"Perplexity API error: 429"(请求过多),也可能是"Failed to fetch"(网络不通),对前端来说,都是500,但应对策略完全不同。我增加了错误分类:
catch (error) { let statusCode = 500; let errorMsg = error.message; if (error.message.includes('429')) { statusCode = 429; errorMsg = 'Rate limit exceeded. Please try again later.'; } else if (error.message.includes('401') || error.message.includes('Unauthorized')) { statusCode = 401; errorMsg = 'Invalid API key. Please check your .env file.'; } else if (error.message.includes('Failed to fetch')) { statusCode = 503; errorMsg = 'Perplexity service is temporarily unavailable.'; } console.error(`❌ API Error [${statusCode}]:`, errorMsg); res.status(statusCode).json({ error: errorMsg, sources: [] }); }这样,前端可以根据status码做差异化处理:429时显示“稍后再试”,401时跳转到设置页,503时显示维护公告。错误不再是黑箱,而是可操作的信号。
4. 浏览器扩展开发:从内容脚本到侧边栏的协同艺术
4.1content.js:如何在千变万化的网页中精准定位“文章主体”
content.js的extractArticleText()函数,是整个扩展的“眼睛”。它不能依赖某个固定CSS类名,因为每个网站的HTML结构都不同。原文列出了6个选择器:
const selectors = ['article', '[role="article"]', '.article-content', '.post-content', '.entry-content', 'main'];这6个已经覆盖了80%的主流站点,但还不够。我在实际测试中,为以下网站增加了特化选择器:
| 网站类型 | 问题 | 新增选择器 | 覆盖率提升 | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 政府官网(如gov.cn) | 内容在<div class="con">或<section id="zoom"> | '.con', '#zoom', '.TRS_Editor' | +12% | ||||||||||||||||||||||
| 学术平台(如arxiv.org) | 关键信息在<blockquote>或<pre> | 'blockquote', 'pre' | +8% | ||||||||||||||||||||||
| 新闻聚合站(如今日头条) | 文章内容被包裹在<div>const claimKeywords = [ /\d+%/i, // 百分比 /\d+\s+(million|billion|thousand|trillion)/i, // 大数 /according to/i, // 引用标记 /study shows|research found/i, // 研究标记 /scientists|experts|analysts/i, // 权威主体 /will|would|could|should/i, // 模态动词(预测性陈述) /increase|decrease|rise|fall|grow|shrink/i, // 变化动词 /more than|less than|at least|up to|over/i, // 量化比较 /proven|showed|demonstrated|confirmed|verified/i, // 确证动词 /investment|invest|create|generate|boost/i, // 经济影响 /jobs|employment|economy|growth|recession/i, // 宏观经济 /ripple effect|impact|effect|consequence/i // 影响链 ];每条正则都经过语料测试。比如 4.3 动态高亮: |
| 现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
侧边栏点击“Check Claims”无反应,控制台报Error: Could not establish connection. Receiving end does not exist. | background.js未正确注册,或manifest.json中background.service_worker路径错误 | 1. 打开chrome://extensions,确认扩展已加载且无红色警告2. 点击“Details”,查看“Service workers”是否在运行 3. 检查 manifest.json中"background": {"service_worker": "background.js"}路径是否与文件实际位置一致 | 确保background.js在extension/目录下,且manifest.json路径正确;重启扩展 |
后端返回{sources: []},但Perplexity API Playground里同样查询能返回结果 | 后端fetch请求头缺失'Content-Type': 'application/json',或body未JSON.stringify | 1. 在server.js的fetch调用前加console.log('Sending to Perplexity:', {query: claim})2. 用 curl模拟相同请求:curl -X POST https://api.perplexity.ai/search -H "Authorization: Bearer xxx" -H "Content-Type: application/json" -d '{"query":"test"}' | 检查fetch的headers和body,确保与curl命令完全一致 |
| 高亮文字点击后,侧边栏不滚动到对应条目,或滚动错位 | sidepanel.js中getElementById('sidepanel-' + claimId)找不到元素,因为claimId生成逻辑与content.js中不一致 | 1. 在content.js的highlightTextOnPage里加console.log('Generated claimId:', claimId)2. 在 sidepanel.js的onMessage里加console.log('Received claimId:', request.claimId)3. 对比两者是否一致 | 确保两边claimId生成规则完全相同,如都用claim-${++claimCounter},且计数器不被重置 |
Popup弹出后,点击“Check”按钮,控制台报Error: Attempting to use a disconnected port object | popup.js中chrome.tabs.sendMessage的目标tab已关闭,或tabs.query未指定active: true | 1. 在popup.js的tabs.query后加console.log('Found tabs:', tabs)2. 检查 tabs数组是否为空 | 确保tabs.query参数为{active: true, currentWindow: true},并在调用sendMessage前检查tabs.length > 0 |
Perplexity API返回400 Bad Request,错误信息为{"error":"invalid_request","message":"query is required"} | server.js中req.body.claim为undefined,因为前端发送的JSON body结构错误 | 1. 在server.js的/check-claim路由开头加console.log('Raw body:', JSON.stringify(req.body))2. 检查前端 fetch的body: JSON.stringify({claim: text})是否拼写正确 | 确保前端发送的key是claim,后端接收的也是req.body.claim;启用express.json()中间件 |
5.2 独家避坑技巧:来自生产环境的3个硬核经验
技巧1:用chrome.storage.local缓存Perplexity响应,对抗API抖动
Perplexity API偶尔会有短暂的503或超时。与其让用户干等,不如加一层本地缓存。我在server.js里加了内存缓存(V2版):
// 简单的LRU内存缓存 const cache = new Map(); const CACHE_TTL = 5 * 60 * 1000; // 5分钟 function getCachedResult(query) { const cached = cache.get(query); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached.result; } cache.delete(query); return null; } function setCacheResult(query, result) { cache.set(query, { result, timestamp: Date.now() }); // 限制缓存大小,防止内存泄漏 if (cache.size > 100) { const firstKey = cache.keys().next().value; cache.delete(firstKey); } }然后在/check-claim路由开头插入:
const cached = getCachedResult(claim); if (cached) { console.log(`✅ Cache hit for: ${claim.substring(0, 30)}...`); return res.json(cached); }实测下来,缓存命中率约35%(用户常重复查同一句话),平均响应时间从350ms降至12ms,用户体验提升巨大。
技巧2:manifest.json的host_permissions必须显式声明"http://localhost/*"
Chrome Manifest V3要求,即使你的后端在localhost,也必须在manifest.json中声明:
{ "host_permissions": [ "http://localhost/*", "https://api.perplexity.ai/*" ] }漏掉"http://localhost/*",content.js或popup.js里的fetch('http://localhost:3000/check-claim')会直接被Chrome拦截,控制台报net::ERR_BLOCKED_BY_CLIENT。这个错误不会出现在background.js里(因为后台脚本有更高权限),所以很容易被忽略。务必检查!
技巧3:侧边栏<iframe>沙箱问题——永远不要在sidepanel.html里用<iframe src="...">
我曾想在侧边栏嵌入一个Perplexity的搜索结果预览,用了<iframe src="https://perplexity.ai/search?q=...">。结果发现,Chrome侧边栏的iframe默认启用了sandbox属性,禁止了top-navigation和popups,导致Perplexity页面加载失败,控制台报Refused to display 'https://perplexity.ai/' in a frame because it set 'X-Frame-Options' to 'deny'.。解决方案?放弃iframe,改用fetch获取HTML片段,再用DOMParser解析并注入<div>。虽然麻烦,但安全可控。
6. 项目收尾与个人体会:一个事实核查工具教会我的事
这个Claim Checker项目,从构思到可稳定运行,我花了17个小时。其中,12个小时在调试content.js的DOM高亮逻辑,3个小时在和Chrome Extension的service_worker生命周期较劲,剩下2个小时,是坐在那里,看着它真的在BBC新闻页上,把我划中的“英国通胀率降至4.6%”这句话,精准地关联到英国国家统计局(ONS)官网的原始数据发布页,并把“4.6%”所在的那一段话高亮显示出来。
那一刻,我没有感到技术上的兴奋,只有一种沉甸甸的踏实。因为我知道,这个工具没有在“证明”什么,它只是忠实地执行了一个承诺:把信息的源头,还给信息的读者。它不宣称自己能判断真假,它只确保你看到的每一条支撑或反驳,都带着可验证的时间、地点和作者。这恰恰是当下信息环境中,最稀缺也最珍贵的东西。
对我个人而言,这个项目最大的收获,不是学会了Perplexity API,而是重新理解了“安全”的本质。它从来不是靠一层层加密算法堆砌出来的堡垒,而是源于一种克制:不把密钥放进不该放的地方,不把计算推给不可控的环境,不把信任建立在黑盒的输出上。那个被我反复强调的.env文件、那个被我加了三重检查的PERPLEXITY_API_KEY、那个坚持用localhost而非云服务的后端——它们不是技术选型,而是一种职业态度。
如果你也动手搭起了这个工具,恭喜你。你拥有的不仅是一个浏览器插件,更是一把钥匙,
