为 pi 打造网页搜索扩展:从后端选型到 HTML 解析的实现
为什么 pi 需要一个网页搜索工具
Pi 是一个极简的终端编码 Agent,默认只给模型四个工具:read、write、edit、bash。这套工具足以操作本地文件系统,却无法触达互联网。当用户问「这个库最新版本是什么」「昨天某框架发布了什么」时,模型只能凭训练数据硬猜,而这正是 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:
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 端点作为零配置默认后端。
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,抓 HTMLwikipedia— 免 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, base64href = "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后面就是 base64urlpad— 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 实体(&、'、数字实体等),避免正文里残留"这种乱码\u00a0— 解码出来是不间断空格,要单独替换成普通空格,否则[ \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);}// 没配代理时走全局 fetchconst 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 的分工
为什么分成 websearch 和 webfetch 两个工具,而不是合一个?因为它们对模型而言是两种不同的决策:
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}验证:从单测到真链路
写完立刻验证,不靠「应该能跑」。分了三层:
- 加载冒烟:用 pi 自带的 jiti 加载扩展文件,喂一个 mock 的
pi对象,确认websearch/webfetch工具和websearch命令都注册成功。 - 真链路工具执行:直接调用
websearch.execute,搜Node.js LTS version 2026,拿到 5 条真实 Bing 结果,URL 解码正确;再对 https://example.com 跑webfetch,正文和标题都对。 - pi 启动加载:跑
pi --list-models,启动时扩展被自动发现并加载、无报错,说明 imports 解析和 pi 原生加载器都没问题。
最关键的一步是真链路执行——不真实跑一次 websearch,谁也不敢说 Bing 解析没写歪。验证时还顺手测了强制 PI_WEBSEARCH_BACKEND=wikipedia 走维基后端、非法 ftp:// URL 被拒、空 query 被拒等边界。
落地与使用
扩展就一个文件,丢进 ~/.pi/agent/extensions/websearch.ts,pi 下次启动自动发现,运行中可用 /reload 热加载。默认走 Bing,开箱即搜。
想升级到更好的后端,配个环境变量即可:
# 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 想要的扩展形态。