Perplexity 最近发布了 Perplexity Search API,专为细粒度的网页检索而设计,返回的是排序后的、富含上下文的片段,而不是整页内容。它面向事实核查、研究和 RAG 流水线等实时场景,在这些场景中速度与精度同样重要。
在本教程中,我们将构建Claim Checker——一个浏览器扩展,可在任意网页上实时核验事实性陈述(基于检索到的网页证据)。您将看到,Perplexity Search API 持续更新的索引与低延迟检索栈,结合语义信号与多阶段重排序,如何让您快速找到最相关的证据。过程中,您将学会:
- 使用 Perplexity Search API 从网络抓取高质量、已排序的证据,并通过自定义排序算法处理搜索结果。
- 开发 Node.js 后端,安全管理您的 API 密钥,处理 CORS 请求,并通过域名提取与基于日期的排序实现智能结果处理。
- 创建带有内容脚本的 Chrome/Edge 扩展,实现文本选取、侧边栏界面,以及浏览器与后端服务器的无缝通信。
- 在交互式侧边栏中展示核验结果,包括来源 URL、标题、摘要片段、域名与发布日期,并进行实时论断处理。
完成后,您将拥有一款在浏览器中无缝运行的全栈事实核查工具。用户可在网页上选择任意文本,并即时获得含来源支撑的核验结果,同时将 API 凭据安全保存在服务器端。
您还可以在另一篇文章中查看我们的Perplexity Comet 浏览器指南。
什么是 Perplexity Search API?
Perplexity Search API 为开发者提供获取快速且细粒度网页搜索结果的便捷方式。
与只返回整页内容的传统搜索 API 不同,Perplexity 提供排序后的片段与文档子块,包含与查询精确匹配的句子和段落。该搜索 API 的一些关键特性包括:
- 成本高效:Search API 定价为每 1,000 次请求 5 美元,无按 token 计费,非常适合需要频繁或定期检查的应用。
- 高质量检索:融合词法与语义排序,并进行多阶段重排序,让您从规模庞大且持续刷新的索引中(约 2 亿次查询/日)获得高度相关、最新的结果。
- 低延迟:Perplexity Search API 返回速度很快,即便同时核验多条论断也能保持界面响应。
对于我们的扩展,这意味着我们可以将任意句子作为查询传入,并获得可靠的支持或反驳证据,附带标题、URL 和上下文片段,非常适合实时事实核查。
Perplexity Search API 项目:浏览器事实核查扩展
本节中,我们将构建一个浏览器事实核查扩展,使用 Perplexity Search API 即时核验事实论断。该项目包含:
- 一个轻量级 Node.js 后端,将选中的文本发送给 Perplexity Search API 并处理结果
- 一个具有侧边栏界面的 Chrome 扩展,用于展示核验详情
- 一段内容脚本,可从任意网页捕获用户选中的文本
项目完成时,您的代码仓库结构应如下所示:
ClaimChecker/
├── server/
│ ├── server.js
│ ├── package.json
│ └── .env
├── extension/
│ ├── manifest.json
│ ├── background.js
│ ├── content.js
│ ├── sidepanel.html
│ ├── sidepanel.js
│ ├── popup.html
│ └── popup.js
└── README.md技术支持
接下来我们将逐一讲解项目的各个部分,看看它们如何协同工作,在浏览器内提供可靠的论断核验功能。
步骤 1:依赖与库
在开始之前,请确保已具备以下前置条件:

- 在 API Portal 的 API Billing 选项卡中添加支付方式。本演示大约 10 美元的额度即可覆盖请求。
- Node.js 版本 18 或更高。
- 基于 Chromium 的浏览器(Google Chrome 或 Microsoft Edge)。
接下来,为后端安装所需依赖:
cd server
npm install express cors dotenv技术支持
在 server 目录创建 .env 文件,并添加您的 Perplexity API 密钥:
PERPLEXITY_API_KEY=your_api_key_here技术支持
至此我们已安装完所有依赖。接下来开始构建后端服务器与浏览器扩展。
步骤 2:构建后端
server.js 使用 Express 搭建了一个轻量级 Node.js 后端。该后端作为浏览器扩展与 Perplexity Search API 之间的桥梁:接收用户选取或输入的论断,向 Perplexity 查询相关证据,并返回清洗过、结构化的响应,便于在扩展 UI 中展示。
const express = require('express');
const cors = require('cors');
require('dotenv').config();
const app = express();
const PORT = 3000;
app.use(cors());
app.use(express.json());
function extractDomain(url) {
try {
const urlObj = new URL(url);
return urlObj.hostname.replace('www.', '');
} catch {
return url;
}
}
app.post('/check-claim', async (req, res) => {
try {
const { claim } = req.body;
if (!claim) {
return res.status(400).json({ error: 'Claim is required' });
}
if (!process.env.PERPLEXITY_API_KEY) {
return res.status(500).json({
error: 'Perplexity API key not configured',
sources: []
});
}
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
})
});
if (!response.ok) {
throw new Error(Perplexity API error: ${response.status});
}
const data = await response.json();
const searchResults = data.results || [];
if (searchResults.length === 0) {
return res.json({
claim,
sources: [],
totalSources: 0,
explanation: 'No relevant sources found to verify this claim.',
summary: 'No sources found for fact-checking.'
});
}
const sortedResults = searchResults.sort((a, b) => {
const dateA = new Date(a.last_updated || a.date || 0);
const dateB = new Date(b.last_updated || b.date || 0);
return dateB - dateA;
});
const sources = sortedResults.slice(0, 3).map((result) => ({
url: result.url,
title: result.title,
snippet: result.snippet || '',
domain: extractDomain(result.url),
date: result.date || null,
lastUpdated: result.last_updated || null
}));
const result = {
claim,
sources,
totalSources: searchResults.length,
explanation: ${searchResults.length} sources found, showing top 3.,
summary: Found ${searchResults.length} relevant sources for fact-checking.
};
res.json(result);
} catch (error) {
res.status(500).json({
error: error.message,
sources: []
});
}
});
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(Server running on http://localhost:${PORT});
console.log('Make sure PERPLEXITY_API_KEY is set in .env file');
});技术支持
上述代码是 Claim Checker 扩展的安全与逻辑层,主要实现以下功能:
- 安全与逻辑后端:通过
POST /check-claim 路由接收论断、校验输入,并使用服务器端存储的 API 密钥调用 Perplexity Search API。
- 处理与结构化:服务器接收相关网页来源与片段列表,利用
last_updated 与日期字段按新近度排序,选取前三条,并返回整洁的 JSON 响应供扩展使用。
- 密钥安全:通过后端运行可将 API 密钥隐藏于浏览器之外,并可在数据到达用户前加入排序、过滤或缓存等额外逻辑。
- 可扩展设计:这种方式让扩展更安全,也更易于在未来加入高级评分、多源融合或分析等功能。
服务器就绪后,我们来构建 Chrome 扩展。
步骤 3:构建 Chrome 扩展
本步骤中,我们将构建 Claim Checker 的 Chrome 扩展端体验。扩展负责从任意网页捕获论断、将其发送到后端,并在直观的界面中展示核验结果。
在逐个文件展开前,先看下这些核心组件的职责:
background.js:作为服务工作线程运行,管理监听用户操作、打开侧边面板、并将论断安全地发送到后端进行核验等后台任务。
content.js:在活动网页中运行的内容脚本。它提取可见的文章文本、识别潜在的事实论断,并动态高亮,便于用户查看正在核验的内容。
sidepanel.js:控制交互式侧边栏 UI,展示提取的论断及其核验状态。它会实时更新结果,并与页面上的高亮内容联动。
popup.js:当用户点击扩展图标时弹出的快捷窗口脚本。无需打开侧边栏即可轻量化地核验论断。
您可以在GitHub 仓库中找到该 Chrome 扩展的一些辅助文件。
步骤 3.1:后台脚本与事件处理
基于我们的 Chrome 扩展结构,background.js 是让扩展保持响应的中枢控制器。它监听用户操作(如点击扩展图标)、按需打开侧边栏,并将论断转发至后端进行核验。
const SERVER_URL = 'http://localhost:3000';
chrome.action.onClicked.addListener(async (tab) => {
try {
await chrome.sidePanel.open({ tabId: tab.id });
} catch (error) {
console.error('Error opening side panel:', error);
chrome.action.setPopup({ popup: 'popup.html' });
}
});
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'checkClaim') {
checkClaim(request.claim)
.then(result => sendResponse({ success: true, result }))
.catch(error => sendResponse({ success: false, error: error.message }));
return true;
}
});
async function checkClaim(claim) {
try {
const response = await fetch(${SERVER_URL}/check-claim, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ claim })
});
if (!response.ok) {
throw new Error(Server error: ${response.status});
}
return await response.json();
} catch (error) {
console.error('Error checking claim:', error);
throw error;
}
}技术支持
上述代码通过以下方式保证扩展的响应性:
- 中心事件枢纽:后台脚本注册了两个主要监听器
chrome.action.onClicked 与 chrome.runtime.onMessage。前者在用户点击扩展图标时响应并尝试打开侧边面板;后者监听来自内容脚本或 UI 组件的自定义消息,如 checkClaim。
- 安全的 API 处理:
checkClaim() 函数使用 fetch API 将选中的论断发送到 http://localhost:3000/check-claim 的后端。由于 API 密钥存储在服务器端,浏览器不会暴露它,从而保障敏感凭据的安全。
- 错误韧性:两个事件监听器均包含
try/catch 块处理运行时错误。若无法打开侧边面板,脚本会回退到设置弹出窗口;当 API 调用失败时,会记录并向 UI 返回结构化的错误信息,避免静默失败。
有了负责通信与安全 API 调用的后台脚本,下一步就是与网页本身交互。
步骤 3.2:提取与高亮论断
content.js 直接在网页中运行,负责提取文章文本、识别潜在事实论断,并以可视化方式为用户高亮显示。它在页面内容与扩展后台逻辑之间搭建桥梁,通过捕获相关文本来启用用户交互。
function extractArticleText() {
const selectors = [
'article',
'[role="article"]',
'.article-content',
'.post-content',
'.entry-content',
'main'
];
for (const selector of selectors) {
const element = document.querySelector(selector);
if (element) {
return element.innerText;
}
}
return document.body.innerText;
}
function extractClaims(text) {
const sentenceMatches = text.match(/[^.!?]*[.!?]+/g) || [];
const sentences = sentenceMatches
.map(s => s.trim())
.filter(s => s.length > 20 && s.length < 1000);
const claimKeywords = [
/\d+%/,
/\d+\s+(million|billion|thousand|trillion)/i,
/according to/i,
/study shows/i,
/research found/i,
/scientists/i,
/experts/i,
/will|would|could|should/i,
/increase|decrease|rise|fall/i,
/more than|less than|at least/i,
/proven|showed|demonstrated/i,
/investment|invest|create|generate/i,
/jobs|employment|economy/i,
/ripple effect|impact|effect/i
];
const claims = [];
for (const sentence of sentences) {
const hasClaimKeyword = claimKeywords.some(regex => regex.test(sentence));
const isValidLength = sentence.length > 30 && sentence.length < 800;
const notNavigation = !/(click here|read more|subscribe|sign up|menu|home|cookie|privacy)/i.test(sentence);
const hasCompleteThought = /[.!?]$/.test(sentence) || sentence.length > 50;
if (hasClaimKeyword && isValidLength && notNavigation && hasCompleteThought) {
let cleanSentence = sentence
.replace(/\s+/g, ' ')
.replace(/^\s*[-•]\s*/, '')
.trim();
if (cleanSentence.length > 30) {
claims.push(cleanSentence);
}
}
}
return claims.slice(0, 10);
}
let highlightedElements = new Map();
let claimIdCounter = 0;
function highlightTextOnPage(claim, claimId) {
const text = document.body.innerText;
const claimWords = claim.toLowerCase().split(/\s+/).filter(word => word.length > 3);
if (claimWords.length < 3) return null;
const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(s => s.length > 20);
let bestMatch = null;
let bestScore = 0;
for (const sentence of sentences) {
const sentenceWords = sentence.toLowerCase().split(/\s+/);
let score = 0;
for (const word of claimWords) {
if (sentenceWords.some(sw => sw.includes(word) || word.includes(sw))) {
score++;
}
}
if (score > bestScore && score >= Math.min(3, claimWords.length * 0.5)) {
bestScore = score;
bestMatch = sentence;
}
}
if (!bestMatch) return null;
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
if (node.textContent.includes(bestMatch)) {
const parent = node.parentElement;
if (parent && !parent.classList.contains('claim-highlight')) {
const highlight = document.createElement('span');
highlight.className = 'claim-highlight';
highlight.id = claim-${claimId};
highlight.style.cssText =
background: linear-gradient(120deg, #fbbf24 0%, #f59e0b 100%);
border-radius: 3px;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
cursor: pointer;
transition: all 0.2s ease;
position: relative;
;
const textContent = node.textContent;
const index = textContent.indexOf(bestMatch);
if (index !== -1) {
const before = textContent.substring(0, index);
const after = textContent.substring(index + bestMatch.length);
const beforeNode = document.createTextNode(before);
const afterNode = document.createTextNode(after);
highlight.textContent = bestMatch;
node.parentNode.insertBefore(beforeNode, node);
node.parentNode.insertBefore(highlight, node);
node.parentNode.insertBefore(afterNode, node);
node.parentNode.removeChild(node);
highlight.addEventListener('mouseenter', () => {
highlight.style.transform = 'scale(1.05)';
highlight.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
});
highlight.addEventListener('mouseleave', () => {
highlight.style.transform = 'scale(1)';
highlight.style.boxShadow = '0 1px 3px rgba(0,0,0,0.2)';
});
highlight.addEventListener('click', () => {
chrome.runtime.sendMessage({
action: 'highlightClaimInPanel',
claimId: claimId
});
highlight.style.background = 'linear-gradient(120deg, #10b981 0%, #059669 100%)';
setTimeout(() => {
highlight.style.background = 'linear-gradient(120deg, #fbbf24 0%, #f59e0b 100%)';
}, 1000);
});
highlightedElements.set(claimId, highlight);
return claimId;
}
}
break;
}
}
return null;
}
function removeHighlights() {
highlightedElements.forEach((element, claimId) => {
if (element && element.parentNode) {
const parent = element.parentNode;
const text = element.textContent;
const textNode = document.createTextNode(text);
parent.insertBefore(textNode, element);
parent.removeChild(element);
}
});
highlightedElements.clear();
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'extractClaims') {
const text = extractArticleText();
const claims = extractClaims(text);
sendResponse({ claims, url: window.location.href });
} else if (request.action === 'highlightClaim') {
const { claim, claimId } = request;
const highlightId = highlightTextOnPage(claim, claimId);
sendResponse({ success: true, highlightId });
} else if (request.action === 'removeHighlights') {
removeHighlights();
sendResponse({ success: true });
}
return true;
});技术支持
下面拆解 content.js 的关键部分,了解它如何提取有意义的文本、检测潜在论断,并将高亮内容与扩展 UI 关联起来。
- 文章文本提取:
extractArticleText() 扫描常见的文章容器,抓取主要可读内容,确保扩展聚焦于正文而非导航或广告。
- 论断检测逻辑:
extractClaims() 将文本分解为句子,按长度与结构过滤,并使用基于正则的关键词来识别可能属于事实论断的陈述。
- 动态高亮:
highlightTextOnPage() 在 DOM 中定位检测到的论断并用带样式的 <span> 包裹,添加悬停与点击交互,帮助用户在页面高亮与侧边栏核验结果之间建立可视关联。
- 消息处理:
chrome.runtime.onMessage 监听执行提取论断、添加高亮、清除高亮等动作,保证页面与扩展同步,提供流畅的交互体验。
在内容脚本完成页面文本提取与论断高亮后,下一步是在界面中呈现这些论断及其核验结果,这就是 sidepanel.js 的职责。
步骤 3.3:侧边栏 UI
sidepanel.js 为交互式侧边栏提供支持,用户可以在此查看提取的论断及其核验结果。它将内容脚本与后端响应连接到扩展内部的整洁、可滚动界面。
let claimCounter = 0;
let currentHighlights = new Map();
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'highlightClaimInPanel') {
const claimId = request.claimId;
const claimElement = document.getElementById(sidepanel-${claimId});
if (claimElement) {
claimElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
claimElement.classList.add('highlighted');
claimElement.style.animation = 'pulse 1s ease-in-out';
setTimeout(() => {
claimElement.classList.remove('highlighted');
claimElement.style.animation = '';
}, 3000);
}
}
});
document.getElementById('check-btn').addEventListener('click', async () => {
const button = document.getElementById('check-btn');
const container = document.getElementById('claims-container');
const status = document.getElementById('status');
button.disabled = true;
container.innerHTML = '<div class="loading">Extracting claims...</div>';
status.textContent = '';
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.tabs.sendMessage(tab.id, { action: 'removeHighlights' });
} catch (error) {
}
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const response = await chrome.tabs.sendMessage(tab.id, { action: 'extractClaims' });
if (!response || !response.claims || response.claims.length === 0) {
container.innerHTML = '<div class="no-claims">No claims found on this page.</div>';
button.disabled = false;
return;
}
status.textContent = Found ${response.claims.length} claims. Checking...;
container.innerHTML = '';
for (let i = 0; i < response.claims.length; i++) {
const claim = response.claims[i];
const claimId = claim-${++claimCounter};
status.textContent = Checking claim ${i + 1} of ${response.claims.length}...;
try {
await chrome.tabs.sendMessage(tab.id, {
action: 'highlightClaim',
claim,
claimId
});
} catch (error) {
}
const claimDiv = document.createElement('div');
claimDiv.className = 'claim-item';
claimDiv.id = sidepanel-${claimId};
claimDiv.innerHTML =
<div class="claim-text">${escapeHtml(claim)}</div>
<div class="loading">Verifying...</div>
;
container.appendChild(claimDiv);
chrome.runtime.sendMessage(
{ action: 'checkClaim', claim },
(checkResponse) => {
if (checkResponse.success) {
const result = checkResponse.result;
const sourcesHtml = result.sources && result.sources.length > 0 ?
<div class="claim-sources">
${result.sources.map(s =>
<div class="source-item">
<a href="${s.url}" target="_blank" class="source-link">
${s.title || s.domain}
</a>
<div class="source-domain">
${s.domain}
${s.lastUpdated || s.date ? • ${new Date(s.lastUpdated || s.date).toLocaleDateString()} : ''}
</div>
${s.snippet ? <div class="source-snippet">${escapeHtml(s.snippet.substring(0, 150))}${s.snippet.length > 150 ? '...' : ''}</div> : ''}
</div>
).join('')}
</div> :
'<div class="claim-sources">No sources found</div>';
claimDiv.innerHTML =
<div class="claim-text">${escapeHtml(claim)}</div>
${sourcesHtml}
;
currentHighlights.set(claimId, claimDiv);
} else {
claimDiv.innerHTML =
<div class="claim-text">${escapeHtml(claim)}</div>
<div class="error">Error: ${checkResponse.error}</div>
;
}
}
);
if (i < response.claims.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
status.textContent = 'All claims checked! Click highlighted text on the page to jump to the corresponding claim details.';
button.disabled = false;
} catch (error) {
container.innerHTML = <div class="error">Error: ${error.message}</div>;
button.disabled = false;
status.textContent = '';
}
});
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}技术支持
侧边栏在浏览器中的工作方式如下:
- 动态论断列表:当用户点击“Check Claims”按钮时,脚本会向活动标签页请求论断,清除旧高亮,并创建带占位状态的论断列表。
- 实时核验更新:每条论断通过
chrome.runtime.sendMessage 以 checkClaim 动作发送给后台脚本。当后端返回结果时,UI 会立即更新来源、域名与片段信息。
- 交互联动:脚本监听
highlightClaimInPanel 消息,使得点击页面上的高亮文本时,侧边栏会滚动至对应论断详情,实现页面高亮与核验数据之间的顺畅联动。
接下来,我们将添加一个快捷弹窗,便于快速启动并使用前面构建的扩展。
步骤 3.4:快捷弹窗 UI
popup.js 是用户点击扩展图标时出现的轻量弹窗界面。与侧边栏不同,弹窗提供更快捷的按需核验方式,无需离开当前页面或打开完整交互面板。
document.getElementById('check-btn').addEventListener('click', async () => {
const button = document.getElementById('check-btn');
const container = document.getElementById('claims-container');
const status = document.getElementById('status');
button.disabled = true;
container.innerHTML = '<div class="loading">Extracting claims...</div>';
status.textContent = '';
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const response = await chrome.tabs.sendMessage(tab.id, { action: 'extractClaims' });
if (!response || !response.claims || response.claims.length === 0) {
container.innerHTML = '<div class="error">No claims found on this page.</div>';
button.disabled = false;
return;
}
status.textContent = Found ${response.claims.length} claims. Checking...;
container.innerHTML = '';
for (let i = 0; i < response.claims.length; i++) {
const claim = response.claims[i];
status.textContent = Checking claim ${i + 1} of ${response.claims.length}...;
const claimDiv = document.createElement('div');
claimDiv.className = 'claim-item';
claimDiv.innerHTML =
<div class="claim-text">${escapeHtml(claim)}</div>
<div class="loading">Verifying...</div>
;
container.appendChild(claimDiv);
chrome.runtime.sendMessage(
{ action: 'checkClaim', claim },
(checkResponse) => {
if (checkResponse.success) {
const result = checkResponse.result;
const confidenceBar = result.confidence ?
<div class="confidence-bar">
<div class="confidence-fill" style="width: ${result.confidence}%"></div>
<span class="confidence-text">${result.confidence}% confidence</span>
</div> : '';
const explanation = result.explanation ?
<div class="claim-explanation">${escapeHtml(result.explanation)}</div> : '';
const rebuttal = result.rebuttal ?
<div class="claim-rebuttal">
<strong>Counter-argument:</strong> ${escapeHtml(result.rebuttal)}
</div> : '';
const sourcesHtml = result.sources && result.sources.length > 0 ?
<div class="claim-sources">
<strong>Sources (${result.sources.length}):</strong>
${result.sources.map(s =>
<div class="source-item">
<a href="${s.url}" target="_blank" class="source-link">
${s.domain || s.title}
</a>
${s.snippet ? <div class="source-snippet">${escapeHtml(s.snippet)}</div> : ''}
</div>
).join('')}
</div> :
'<div class="claim-sources">No sources found</div>';
claimDiv.innerHTML =
<div class="claim-text">${escapeHtml(claim)}</div>
<div class="claim-verdict verdict-${result.verdict.toLowerCase()}">
${result.verdict}
</div>
${confidenceBar}
${explanation}
${rebuttal}
${sourcesHtml}
;
} else {
claimDiv.innerHTML =
<div class="claim-text">${escapeHtml(claim)}</div>
<div class="error">Error: ${checkResponse.error}</div>
;
}
}
);
if (i < response.claims.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
status.textContent = 'All claims checked!';
button.disabled = false;
} catch (error) {
container.innerHTML = <div class="error">Error: ${error.message}</div>;
button.disabled = false;
status.textContent = '';
}
});
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}技术支持
以下是 popup.js 如何直接从扩展工具栏提供按需核查:
- 页面扫描与论断展示:当用户点击“Check”按钮时,弹窗通过内容脚本向活动标签页请求文本,快速提取潜在论断,并在弹窗中列出。
- 后端核验:每条论断都会发送给后台脚本以调用
/check-claim 接口。弹窗会根据服务器返回的判定、置信度、说明、反驳与来源链接实时更新。
至此,我们的扩展已准备好进行测试。
注:您可以在GitHub 仓库中找到此项目的全部代码文件。
步骤 4:运行应用
通过以下命令启动后端服务器:
npm start技术支持
服务器启动后,加载 Chrome 扩展:
- 打开 Chrome,点击“Extensions”(搜索栏右侧)。 。
- 打开“开发者模式(Developer mode)”,点击左上角“加载已解压的扩展(Load unpacked)”。

- Chrome 确认扩展已添加后,在任意网页点击 Claim Checker 图标,选择您想核验的文本并运行核查。
下面来看结果示例:

结语
现在,您已经拥有了一款由 Perplexity Search API 全面驱动的快速、经济的论断核查工具(无需调用 LLM)。本演示突出了该 API 的关键优势,包括新鲜且可靠的搜索结果、片段级相关性、低延迟以及可预测的定价。
接下来,您可以通过添加高级过滤器、安排自动化检查,或将其集成为 RAG 流水线的预过滤步骤来扩展该项目。想进一步探索,请查阅 Perplexity API 文档与快速上手指南,解锁更强大的搜索与过滤能力。
Perplexity 最近发布了 Perplexity Search API,专为细粒度的网页检索而设计,返回的是排序后的、富含上下文的片段,而不是整页内容。它面向事实核查、研究和 RAG 流水线等实时场景,在这些场景中速度与精度同样重要。
在本教程中,我们将构建Claim Checker——一个浏览器扩展,可在任意网页上实时核验事实性陈述(基于检索到的网页证据)。您将看到,Perplexity Search API 持续更新的索引与低延迟检索栈,结合语义信号与多阶段重排序,如何让您快速找到最相关的证据。过程中,您将学会:
完成后,您将拥有一款在浏览器中无缝运行的全栈事实核查工具。用户可在网页上选择任意文本,并即时获得含来源支撑的核验结果,同时将 API 凭据安全保存在服务器端。
您还可以在另一篇文章中查看我们的Perplexity Comet 浏览器指南。
什么是 Perplexity Search API?
Perplexity Search API 为开发者提供获取快速且细粒度网页搜索结果的便捷方式。
与只返回整页内容的传统搜索 API 不同,Perplexity 提供排序后的片段与文档子块,包含与查询精确匹配的句子和段落。该搜索 API 的一些关键特性包括:
对于我们的扩展,这意味着我们可以将任意句子作为查询传入,并获得可靠的支持或反驳证据,附带标题、URL 和上下文片段,非常适合实时事实核查。
Perplexity Search API 项目:浏览器事实核查扩展
本节中,我们将构建一个浏览器事实核查扩展,使用 Perplexity Search API 即时核验事实论断。该项目包含:
项目完成时,您的代码仓库结构应如下所示:
接下来我们将逐一讲解项目的各个部分,看看它们如何协同工作,在浏览器内提供可靠的论断核验功能。
步骤 1:依赖与库
在开始之前,请确保已具备以下前置条件:
接下来,为后端安装所需依赖:
在 server 目录创建
.env文件,并添加您的 Perplexity API 密钥:至此我们已安装完所有依赖。接下来开始构建后端服务器与浏览器扩展。
步骤 2:构建后端
server.js使用 Express 搭建了一个轻量级 Node.js 后端。该后端作为浏览器扩展与 Perplexity Search API 之间的桥梁:接收用户选取或输入的论断,向 Perplexity 查询相关证据,并返回清洗过、结构化的响应,便于在扩展 UI 中展示。上述代码是 Claim Checker 扩展的安全与逻辑层,主要实现以下功能:
POST /check-claim路由接收论断、校验输入,并使用服务器端存储的 API 密钥调用 Perplexity Search API。last_updated与日期字段按新近度排序,选取前三条,并返回整洁的 JSON 响应供扩展使用。服务器就绪后,我们来构建 Chrome 扩展。
步骤 3:构建 Chrome 扩展
本步骤中,我们将构建 Claim Checker 的 Chrome 扩展端体验。扩展负责从任意网页捕获论断、将其发送到后端,并在直观的界面中展示核验结果。
在逐个文件展开前,先看下这些核心组件的职责:
background.js:作为服务工作线程运行,管理监听用户操作、打开侧边面板、并将论断安全地发送到后端进行核验等后台任务。content.js:在活动网页中运行的内容脚本。它提取可见的文章文本、识别潜在的事实论断,并动态高亮,便于用户查看正在核验的内容。sidepanel.js:控制交互式侧边栏 UI,展示提取的论断及其核验状态。它会实时更新结果,并与页面上的高亮内容联动。popup.js:当用户点击扩展图标时弹出的快捷窗口脚本。无需打开侧边栏即可轻量化地核验论断。您可以在GitHub 仓库中找到该 Chrome 扩展的一些辅助文件。
步骤 3.1:后台脚本与事件处理
基于我们的 Chrome 扩展结构,
background.js是让扩展保持响应的中枢控制器。它监听用户操作(如点击扩展图标)、按需打开侧边栏,并将论断转发至后端进行核验。上述代码通过以下方式保证扩展的响应性:
chrome.action.onClicked与chrome.runtime.onMessage。前者在用户点击扩展图标时响应并尝试打开侧边面板;后者监听来自内容脚本或 UI 组件的自定义消息,如checkClaim。checkClaim()函数使用fetchAPI 将选中的论断发送到http://localhost:3000/check-claim的后端。由于 API 密钥存储在服务器端,浏览器不会暴露它,从而保障敏感凭据的安全。try/catch块处理运行时错误。若无法打开侧边面板,脚本会回退到设置弹出窗口;当 API 调用失败时,会记录并向 UI 返回结构化的错误信息,避免静默失败。有了负责通信与安全 API 调用的后台脚本,下一步就是与网页本身交互。
步骤 3.2:提取与高亮论断
content.js直接在网页中运行,负责提取文章文本、识别潜在事实论断,并以可视化方式为用户高亮显示。它在页面内容与扩展后台逻辑之间搭建桥梁,通过捕获相关文本来启用用户交互。下面拆解
content.js的关键部分,了解它如何提取有意义的文本、检测潜在论断,并将高亮内容与扩展 UI 关联起来。extractArticleText()扫描常见的文章容器,抓取主要可读内容,确保扩展聚焦于正文而非导航或广告。extractClaims()将文本分解为句子,按长度与结构过滤,并使用基于正则的关键词来识别可能属于事实论断的陈述。highlightTextOnPage()在 DOM 中定位检测到的论断并用带样式的<span>包裹,添加悬停与点击交互,帮助用户在页面高亮与侧边栏核验结果之间建立可视关联。chrome.runtime.onMessage监听执行提取论断、添加高亮、清除高亮等动作,保证页面与扩展同步,提供流畅的交互体验。在内容脚本完成页面文本提取与论断高亮后,下一步是在界面中呈现这些论断及其核验结果,这就是
sidepanel.js的职责。步骤 3.3:侧边栏 UI
sidepanel.js为交互式侧边栏提供支持,用户可以在此查看提取的论断及其核验结果。它将内容脚本与后端响应连接到扩展内部的整洁、可滚动界面。侧边栏在浏览器中的工作方式如下:
chrome.runtime.sendMessage以checkClaim动作发送给后台脚本。当后端返回结果时,UI 会立即更新来源、域名与片段信息。highlightClaimInPanel消息,使得点击页面上的高亮文本时,侧边栏会滚动至对应论断详情,实现页面高亮与核验数据之间的顺畅联动。接下来,我们将添加一个快捷弹窗,便于快速启动并使用前面构建的扩展。
步骤 3.4:快捷弹窗 UI
popup.js是用户点击扩展图标时出现的轻量弹窗界面。与侧边栏不同,弹窗提供更快捷的按需核验方式,无需离开当前页面或打开完整交互面板。以下是
popup.js如何直接从扩展工具栏提供按需核查:/check-claim接口。弹窗会根据服务器返回的判定、置信度、说明、反驳与来源链接实时更新。至此,我们的扩展已准备好进行测试。
注:您可以在GitHub 仓库中找到此项目的全部代码文件。
步骤 4:运行应用
通过以下命令启动后端服务器:
服务器启动后,加载 Chrome 扩展:
下面来看结果示例:
结语
现在,您已经拥有了一款由 Perplexity Search API 全面驱动的快速、经济的论断核查工具(无需调用 LLM)。本演示突出了该 API 的关键优势,包括新鲜且可靠的搜索结果、片段级相关性、低延迟以及可预测的定价。
接下来,您可以通过添加高级过滤器、安排自动化检查,或将其集成为 RAG 流水线的预过滤步骤来扩展该项目。想进一步探索,请查阅 Perplexity API 文档与快速上手指南,解锁更强大的搜索与过滤能力。