跳至主要内容

Perplexity Search API 教程:在浏览器中构建实时事实核查工具

学习如何使用 Perplexity Search API 构建一个可实时核验事实论断的 Chrome 扩展。包含 Node.js 后端搭建与 API 集成指南。
· 10分钟 阅读

使用 AI 探索

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:依赖与库

在开始之前,请确保已具备以下前置条件:

  • 一个 Perplexity 账号。注册或登录后,前往左侧边栏展开 API 部分,访问您的 API 分组及相关设置。

  • 在 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 文档与快速上手指南,解锁更强大的搜索与过滤能力。

Aashi Dutt

作者

Aashi Dutt

我是一名 Google Developers 机器学习(生成式 AI)领域的专家、Kaggle 三项专家,以及 Women Techmakers 大使,拥有 3 年以上的技术从业经验。2020 年我共同创办了一家健康科技初创公司,目前在佐治亚理工学院攻读计算机科学硕士,专攻机器学习。

主题