3420 words
17 minutes
为 pi 打造网页搜索扩展:从后端选型到 HTML 解析的实现

为 pi 打造网页搜索扩展:从后端选型到 HTML 解析的实现#

为什么 pi 需要一个网页搜索工具#

Pi 是一个极简的终端编码 Agent,默认只给模型四个工具:readwriteeditbash。这套工具足以操作本地文件系统,却无法触达互联网。当用户问「这个库最新版本是什么」「昨天某框架发布了什么」时,模型只能凭训练数据硬猜,而这正是 AI 编码 Agent 最容易翻车的地方。

Pi 的哲学是「不内置 MCP、不内置子 Agent,把能力交给扩展」。网页搜索这种高频需求,理应以一个 TypeScript 扩展的形式存在:模型按需调用 websearch 拿到结果,再用 webfetch 读取完整页面,整条链路完全在 Agent 循环内闭环。

本文记录我为 pi 编写 websearch.ts 扩展的完整思路:从后端选型、网络可达性实测、HTML 解析、代理支持,到 pi 扩展 API 的关键用法。整份扩展是一个 800 行左右的单文件,零外部 npm 依赖。

设计目标与约束#

在动手之前,先把约束钉死,避免后面返工:

  • 开箱即用:不配任何 API Key 也能搜。用户多数时候不想为了搜个网页先去注册账号。
  • 中国网络友好:默认后端必须在这个网络环境下可达。Google/Bing/DuckDuckGo/Tavily 的可达性差异巨大,不能想当然。
  • 可升级:愿意配 Key 的用户能切到质量更高的付费后端(Brave、Tavily、SerpAPI),自建实例的用户能接 SearXNG。
  • 上下文安全:搜索结果和抓取的网页正文都必须截断,不能把几十 KB 的 HTML 灌进模型上下文撑爆窗口。
  • 可取消:用户按 Esc 能打断正在进行的网络请求,不能让 Agent 卡在一个 hang 住的 fetch 上。
  • 零依赖:不引第三方 npm 包,只用 Node 内置能力 + pi 自带工具,保证放进 ~/.pi/agent/extensions/ 就能被 jiti 加载运行。

后端选型:从「想当然」到「实测可达」#

最初我打算用 DuckDuckGo 的 HTML 端点做零配置默认后端——免费、无需 Key、接口稳定。但实测打脸来得很快。

DuckDuckGo HTML 端点的反爬墙#

Attempt that failed:

Terminal window
curl -sS -A "Mozilla/5.0 ..." "https://html.duckduckgo.com/html/" \
--data-urlencode "q=pi coding agent" -o /tmp/ddg.html -w "%{http_code} %{size_download}"

Why it failed: 返回 HTTP 202(不是 200),页面大小只有 14KB,且里面没有任何 result__a 这类结果链接——DDG 返回的是一道 JavaScript 验证墙(Cloudflare 挑战),真正的搜索结果根本没渲染出来。lite.duckduckgo.com 同样是 202。

Working approach: 放弃 DuckDuckGo,改用 Bing HTML 端点作为零配置默认后端。

Terminal window
curl -sS -A "Mozilla/5.0 ..." "https://www.bing.com/search?q=test&count=10"

Why this works: Bing 返回 HTTP 200、78KB 的真实结果 HTML,有机结果稳定地落在 <li class="b_algo"> 块里,可被正则解析。

这次选型验证还顺手测了其他几个候选:Wikipedia API(en/zh 两站都 200,免 Key,适合事实类查询)、Brave API 主机(301,配 Key 可用)、Tavily API 主机(HTTP 000,连接失败——这个网络下被墙了)。

于是后端清单和优先级定为:

  • bing — 默认,免 Key,抓 HTML
  • wikipedia — 免 Key,调 API,适合百科
  • brave / tavily / serpapi — 配 Key 启用(Tavily 在此网络不可达,会自动回退)
  • searxng — 配 SEARXNG_URL 启用,自建实例任何网络都稳

自动选择链为 tavily > brave > serpapi > searxng > bing,取第一个已配置的;都没配就走 bing。可用 PI_WEBSEARCH_BACKEND=bing,wikipedia 逗号串连,把多个后端的结果合并去重。

Bing 结果解析:解一道 base64 重定向谜题#

抓 HTML 搜索引擎最棘手的不是取标题,而是还原真实 URL。Bing 不直接给你目标链接,而是包一层 bing.com/ck/a?...&u=a1<base64url>&... 的跳转。

Bing 的 URL 重定向编码#

Context:<li class="b_algo"> 块里取出 <h2><a href="..."> 的链接,发现是 bing.com/ck/a?...&u=a1aHR0cHM6Ly93d3cuc3BlZWR0ZXN0Lm5ldC8... 这种形态。

Why this way: Bing 用 u=a1 前缀 + base64url 编码包裹真实地址。前缀 a1 是固定的,后面紧跟 base64url 编码的目标 URL。用 Python 复核解码逻辑、确认无误后,再用 TypeScript 复刻。

import re, base64
href = "https://r.bing.com/...&u=a1aHR0cHM6Ly93d3cuc3BlZWR0ZXN0Lm5ldC8&ntb=1"
m = re.search(r'[?&]u=a1([^&]*)', href)
enc = m.group(1)
real = base64.urlsafe_b64decode(enc + '=' * (-len(enc) % 4)).decode('utf-8')
# real == "https://www.speedtest.net/"

Key params:

  • u=a1 — Bing 跳转参数的固定前缀,a1 后面就是 base64url
  • pad — base64url 编码常省略 = 填充,解码前要补齐到 4 的倍数

Why this works: u 参数的值去掉 a1 前缀后就是标准 base64url,Node 的 Buffer.from(s, "base64url") 能直接解码。TypeScript 版只需一个正则 + 一行 Buffer 调用:

function decodeBingUrl(href: string): string {
const m = href.match(/[?&]u=a1([^&]*)/);
if (!m) return href;
try {
const enc = m[1];
const pad = enc.length % 4 === 0 ? enc : enc + "=".repeat(4 - (enc.length % 4));
return Buffer.from(pad, "base64url").toString("utf8");
} catch {
return href;
}
}

解析整页结果的流程是:用 <li class="b_algo">[\s\S]*?</li> 切出每个结果块,块内找 <h2><a href> 取标题与链接,再找第一个 <p> 取摘要。三个字段齐了就是一条完整的 SearchResult

HTML 正文抽取:给模型一份干净的可读文本#

webfetch 要把一个网页变成模型能读的纯文本。直接 stripTags 会把导航栏、页脚、脚本里的文字混进正文,信噪比很差。所以抽取分两步:先删非内容块,再做格式化。

Why this way: 真正的正文几乎都在 <article><main><p> 里,而 <script>/<style>/<nav>/<footer>/<header>/<aside>/<form> 基本是噪声。先正则删掉这些整块标签,再做块级标签到换行符的转换,正文才会自然回流。

function htmlToText(html: string): { title: string; text: string } {
let s = html;
let title = "";
const titleMatch = s.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (titleMatch) title = decodeEntities(stripTags(titleMatch[1])).trim();
// 删非内容块
s = s.replace(/<script[\s\S]*?<\/script>/gi, " ");
s = s.replace(/<style[\s\S]*?<\/style>/gi, " ");
s = s.replace(/<(nav|footer|header|aside|form)[\s\S]*?<\/\1>/gi, " ");
// ... 其余噪声块
// 块级标签 -> 换行
s = s.replace(/<(?:p|div|section|article|li|h[1-6]|br)[^>]*>/gi, "\n");
// 剥离剩余标签
s = s.replace(/<[^>]+>/g, " ");
s = decodeEntities(s);
// 收敛空白
s = s.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
return { title, text: s };
}

Key params:

  • decodeEntities — 先解码 HTML 实体(&amp;&#39;、数字实体等),避免正文里残留 &quot; 这种乱码
  • \u00a0&nbsp; 解码出来是不间断空格,要单独替换成普通空格,否则 [ \t]+ 收不拢

这套抽取不是 Readability 级别的正文识别,但对 Agent 够用了——重点是去掉脚本和导航噪声、让文字可读、控制长度。配合下面的截断策略,一条 webfetch 的返回稳定在 20KB 以内。

输出截断:复用 pi 的内置工具#

自定义工具最容易踩的坑是返回超大输出撑爆上下文。pi 文档反复强调「tools MUST truncate their output」,内置上限是 50KB / 2000 行。

Why this way: pi 直接导出了截断工具函数,扩展可以零成本复用,不必自己造轮子。

import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
formatSize,
truncateHead,
} from "@earendil-works/pi-coding-agent";

Key params:

  • truncateHead — 保留头部 N 行/N 字节,适合搜索结果(开头最重要)
  • truncateTail — 保留尾部,适合日志(结尾最重要)
  • formatSize — 人话化的体积显示,截断提示里用上

websearch 对拼好的结果文本跑一次 truncateHead,超了就在末尾追加一句「结果已截断,用 webfetch 读具体 URL」。webfetch 则是先按 max_chars(默认 20000)切片,再过一道 truncateHead 兜底,两层保险。截断时永远告诉模型「截断了、完整内容在哪」,模型才知道下一步该读哪里。

代理支持:绕开 Node fetch 的一个坑#

Node 的全局 fetch(基于 undici)有一个长期被吐槽的行为:它不读 HTTP_PROXY / HTTPS_PROXY 环境变量。在国内,很多用户靠系统级代理翻墙,如果扩展直连 fetch,代理形同虚设。

undici 不可作为模块导入#

Attempt that failed:

import { ProxyAgent, setGlobalDispatcher } from "undici";

Why it failed: Node 26 把 undici 内置为 fetch 的实现,但并不把它作为可导入的包暴露出来import "undici"ERR_MODULE_NOT_FOUND,无法拿到 ProxyAgent。要真用,得把 undici 作为 npm 依赖装进扩展目录,但这破坏了「零依赖」约束。

Working approach: 检测到 PI_WEBSEARCH_PROXY 环境变量时,整条请求改走 curl 子进程。

if (PROXY_URL) {
const args = ["-sS", "-L", "-m", "30", "--connect-timeout", "10",
"-x", PROXY_URL, "-A", UA,
"-w", "\n__PI_STATUS__:%{http_code}\n__PI_CT__:%{content_type}\n__PI_URL__:%{url_effective}",
url];
const res = await runCurl(args, signal);
return parseCurlOutput(res, url);
}
// 没配代理时走全局 fetch
const res = await fetch(url, { headers, redirect: "follow", signal: controller.signal });

Why this works: curl 原生支持 -x proxy-L 跟随跳转、-m 硬超时,几乎每台机器都有。用 -w 在响应体末尾追加一行 __PI_STATUS__:%{http_code} 等元数据,再从输出里 lastIndexOf 这个标记切出 body 和 meta——一个 curl 调用同时拿到正文、状态码、最终 URL、Content-Type。

没配代理时回退到 fetch(),保持流式友好的快速路径。两条路径共用同一个 AbortSignal,Esc 取消能同时打断 fetch 和 curl 子进程。

两个工具:search 与 fetch 的分工#

为什么分成 websearchwebfetch 两个工具,而不是合一个?因为它们对模型而言是两种不同的决策:

  • websearch 回答「去哪儿找」——给关键词,拿回一堆候选 URL + 摘要。模型据此判断哪条值得深读。
  • webfetch 回答「这页写了啥」——给一个 URL,拿回干净正文。模型据此综合答案。

合并成一个工具会让模型在「先搜后读」这条链上多绕一步;拆开则让模型自然地两步走:先 search 看摘要,再 fetch 读全文。每个工具都有 promptSnippet(系统提示里的一行简介)和 promptGuidelines(工具激活时的使用守则),明确告诉模型何时该用、不要凭空编 URL。

pi 扩展 API 的几个关键点#

工具注册与提示注入#

pi.registerTool 不只注册工具,还能往系统提示里注入引导。两个关键字段:

pi.registerTool({
name: "websearch",
description: "Search the web for a query ...",
promptSnippet: "Search the web (Bing by default; ...)",
promptGuidelines: [
"Use websearch when the user asks about recent events ...",
"Prefer websearch + webfetch over guessing URLs. Don't fabricate links ...",
],
parameters: Type.Object({ query: Type.String(), ... }),
async execute(toolCallId, params, signal, onUpdate, ctx) { ... },
});

Key params:

  • promptSnippet — 进系统提示「Available tools」段的一行简介,不写则该工具不出现在那段里
  • promptGuidelines — 进「Guidelines」段的若干条守则,每条必须点名工具名(不能写「Use this tool when…」,模型分不清「this」指谁)
  • signal — Agent 的 abort 信号,透传给 fetch/curl,Esc 即取消
  • onUpdate — 流式进度更新,工具执行中可先回一句「Searching…」给 TUI

自定义渲染:让工具行好看#

renderCall / renderResult 接收 theme,可以用主题色画工具行。例如搜索结果行显示「5 results via bing (truncated)」,绿色数字 + 灰色后端名 + 黄色截断标记。这层 UI 不是必须的,但有了之后 TUI 里一眼就能看出这次搜了多少条、走的哪个后端。

错误信号:throw 而不是返回#

Why this way: pi 约定,工具执行失败要抛异常,而不是在返回对象里塞一个 error 字段。抛出的异常会被 pi 捕获、标成 isError: true 报给模型,执行循环继续。如果只是返回一个带错误文案的 content,模型不会把它当成失败,可能继续在错误路径上狂奔。

async execute(_toolCallId, params, signal, onUpdate, _ctx) {
const query = params.query?.trim();
if (!query) throw new Error("query is required");
// ... 业务逻辑里所有失败也 throw
}

验证:从单测到真链路#

写完立刻验证,不靠「应该能跑」。分了三层:

  1. 加载冒烟:用 pi 自带的 jiti 加载扩展文件,喂一个 mock 的 pi 对象,确认 websearch/webfetch 工具和 websearch 命令都注册成功。
  2. 真链路工具执行:直接调用 websearch.execute,搜 Node.js LTS version 2026,拿到 5 条真实 Bing 结果,URL 解码正确;再对 https://example.comwebfetch,正文和标题都对。
  3. pi 启动加载:跑 pi --list-models,启动时扩展被自动发现并加载、无报错,说明 imports 解析和 pi 原生加载器都没问题。

最关键的一步是真链路执行——不真实跑一次 websearch,谁也不敢说 Bing 解析没写歪。验证时还顺手测了强制 PI_WEBSEARCH_BACKEND=wikipedia 走维基后端、非法 ftp:// URL 被拒、空 query 被拒等边界。

落地与使用#

扩展就一个文件,丢进 ~/.pi/agent/extensions/websearch.ts,pi 下次启动自动发现,运行中可用 /reload 热加载。默认走 Bing,开箱即搜。

想升级到更好的后端,配个环境变量即可:

Terminal window
# Brave(免费 2000 次/月,国内可直连)
export BRAVE_API_KEY=你的key
# 或自建 SearXNG,任何网络都稳
export SEARXNG_URL=https://searx.example.com
# 需要代理时
export PI_WEBSEARCH_PROXY=http://127.0.0.1:7890

配完在 pi 里 /reload,或直接对模型说「搜一下 xxx 最新进展」,模型会自己调 websearch + webfetch。也可以手动 /websearch pi coding agent 测一下当前后端通不通。

小结#

这次写 websearch.ts 的几个核心取舍:

  • 选型靠实测,不靠想当然:DuckDuckGo 的 JS 验证墙、Tavily 的网络不通、undici 的不可导入,都是实测才暴露的。零配置默认后端从 DDG 换成 Bing,是这次最关键的一次纠偏。
  • 零依赖优先:HTML 解析、代理、截断全靠 Node 内置 + pi 自带工具,不引一个 npm 包,换来的是「丢进 extensions 目录就能跑」的部署体验。
  • search 与 fetch 分工:让模型在「找」和「读」两步上各自决策,比合并成一个工具更顺。
  • 上下文安全是硬约束:复用 pi 的 truncateHead,两层截断 + 截断提示,绝不撑爆窗口。
  • 失败要 throw、取消要透传:工具失败抛异常、signal 一路透到 fetch/curl,是 pi 扩展的两个容易踩错的约定。

Pi 的「极简内核 + 强扩展」哲学在这次实践里体现得很彻底:核心不给网页搜索,但 registerTool + promptGuidelines + 内置截断工具 + abort 信号透传,凑齐了写一个生产级搜索工具所需的全部积木。800 行单文件,开箱即搜,可按需升级——这就是 pi 想要的扩展形态。

为 pi 打造网页搜索扩展:从后端选型到 HTML 解析的实现
https://sgjki547.top/posts/2026-07-06-为pi打造网页搜索扩展/
Author
SGJki
Published at
2026-07-06
License
CC BY-NC-SA 4.0