Skip to content

Commit e48da39

Browse files
feat: 修正 web search 工具
1 parent d04e00f commit e48da39

13 files changed

Lines changed: 1241 additions & 270 deletions

File tree

DEV-LOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
11
# DEV-LOG
22

3+
## WebSearch Bing 适配器补全 (2026-04-03)
4+
5+
原始 `WebSearchTool` 仅支持 Anthropic API 服务端搜索(`web_search_20250305` server tool),在非官方 API 端点(第三方代理)下搜索功能不可用。本次改动引入适配器架构,新增 Bing 搜索页面解析作为 fallback。
6+
7+
**新增文件:**
8+
9+
| 文件 | 说明 |
10+
|------|------|
11+
| `src/tools/WebSearchTool/adapters/types.ts` | 适配器接口定义:`WebSearchAdapter``SearchResult``SearchOptions``SearchProgress` |
12+
| `src/tools/WebSearchTool/adapters/apiAdapter.ts` | API 适配器 — 将原有 `queryModelWithStreaming` 逻辑封装为 `ApiSearchAdapter` |
13+
| `src/tools/WebSearchTool/adapters/bingAdapter.ts` | Bing 适配器 — 直接抓取 Bing HTML,正则提取搜索结果 |
14+
| `src/tools/WebSearchTool/adapters/index.ts` | 适配器工厂 — 根据环境变量 / API Base URL 选择后端 |
15+
| `src/tools/WebSearchTool/__tests__/bingAdapter.test.ts` | Bing 适配器单元测试(32 cases:decodeHtmlEntities、extractBingResults、search mock) |
16+
| `src/tools/WebSearchTool/__tests__/bingAdapter.integration.ts` | Bing 适配器集成测试 — 真实网络请求验证 |
17+
18+
**重构文件:**
19+
20+
| 文件 | 变更 |
21+
|------|------|
22+
| `src/tools/WebSearchTool/WebSearchTool.ts` | 从直接调用 API 改为 `createAdapter()` 工厂模式;`isEnabled()` 始终返回 true;删除 ~200 行内联 API 调用逻辑 |
23+
| `src/tools/WebFetchTool/utils.ts` | `skipWebFetchPreflight` 默认值从 `!undefined`(即 true)改为显式 `=== false`,使域名预检默认启用 |
24+
25+
**Bing 适配器关键技术细节:**
26+
27+
1. **反爬绕过**:使用完整 Edge 浏览器请求头(含 `Sec-Ch-Ua``Sec-Fetch-*` 等 13 个标头),避免 Bing 返回 JS 渲染的空页面;`setmkt=en-US` 参数强制美式英语市场,避免 IP 地理定位导致的区域化结果(德语论坛、新加坡金价等不相关内容)
28+
2. **URL 解码**`resolveBingUrl()`):Bing 返回的重定向 URL(`bing.com/ck/a?...&u=a1aHR0cHM6Ly9...`)中 `u` 参数为 base64 编码的真实 URL,需解码后使用
29+
3. **摘要提取**`extractSnippet()`):三级降级策略 — `b_lineclamp``b_caption <p>``b_caption` 直接文本
30+
4. **HTML 实体解码**`decodeHtmlEntities()`):处理 7 种常见 HTML 实体
31+
5. **域过滤**:客户端侧 `allowedDomains` / `blockedDomains` 过滤,支持子域名匹配
32+
33+
**当前状态**`adapters/index.ts``createAdapter()` 硬编码返回 `BingSearchAdapter`,跳过了 API/Bing 自动选择逻辑(原逻辑被注释保留)。未来可通过取消注释恢复自动选择。
34+
35+
---
36+
337
## 移除反蒸馏机制 (2026-04-02)
438

539
项目中发现三处 anti-distillation 相关代码,全部移除。

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- [x] Auto Mode 回归
1717
- [x] 所有 Feature 现在可以通过环境变量配置, 而不是垃圾的 bun --feature
1818
- [x] 移除牢 A 的反蒸馏代码!!!
19+
- [x] 补全 web search 能力(用的 Bing 搜索)!!!
1920
- [ ] V5 大规模重构石山代码, 全面模块分包
2021
- [ ] V5 将会为全新分支, 届时 main 分支将会封存为历史版本
2122

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/tools/search-and-navigation.mdx

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,137 @@ function getDeferredToolsCacheKey(deferredTools: Tools): string {
139139

140140
AI 的信息获取不局限于本地代码:
141141

142-
- **WebSearch**:搜索互联网获取最新信息
143-
- **WebFetch**:抓取特定网页内容,转换为 Markdown 供 AI 阅读
142+
- **WebSearch**`src/tools/WebSearchTool/`):调用 Anthropic API 的 `web_search_20250305` server tool 搜索互联网
143+
- **WebFetch**`src/tools/WebFetchTool/`):抓取特定 URL 内容,转换为 Markdown 供 AI 阅读
144144

145145
这让 AI 可以查阅文档、搜索 Stack Overflow、阅读 GitHub issue——和人类开发者的工作方式一致。
146146

147+
### WebSearch 实现机制
148+
149+
WebSearch 通过适配器模式支持两种搜索后端,由 `src/tools/WebSearchTool/adapters/` 中的工厂函数 `createAdapter()` 选择:
150+
151+
```
152+
适配器架构:
153+
WebSearchTool.call()
154+
→ createAdapter() 选择后端
155+
├─ ApiSearchAdapter — Anthropic API 服务端搜索(需官方 API 密钥)
156+
└─ BingSearchAdapter — 直接抓取 Bing 搜索页面解析(无需 API 密钥)
157+
→ adapter.search(query, options)
158+
→ 转换为统一 SearchResult[] 格式返回
159+
```
160+
161+
#### 适配器选择逻辑
162+
163+
`adapters/index.ts` 中的工厂函数按以下优先级选择后端:
164+
165+
| 优先级 | 条件 | 适配器 |
166+
|--------|------|--------|
167+
| 1 | 环境变量 `WEB_SEARCH_ADAPTER=api` | `ApiSearchAdapter` |
168+
| 2 | 环境变量 `WEB_SEARCH_ADAPTER=bing` | `BingSearchAdapter` |
169+
| 3 | API Base URL 指向 Anthropic 官方 | `ApiSearchAdapter` |
170+
| 4 | 第三方代理 / 非官方端点 | `BingSearchAdapter` |
171+
172+
适配器是无状态的,同一会话内缓存复用。
173+
174+
#### ApiSearchAdapter — API 服务端搜索
175+
176+
将搜索请求委托给 Anthropic API 的 `web_search_20250305` server tool:
177+
178+
```
179+
调用链:
180+
ApiSearchAdapter.search(query, options)
181+
→ queryModelWithStreaming() 发起独立的 API 调用
182+
→ 携带 extraToolSchemas: [BetaWebSearchTool20250305]
183+
→ API 服务端执行搜索,返回流式事件
184+
→ server_tool_use / web_search_tool_result / text 交替返回
185+
→ extractSearchResults() 从 content blocks 提取 SearchResult[]
186+
```
187+
188+
| 特性 | 实现 |
189+
|------|------|
190+
| **模型选择** | Feature flag `tengu_plum_vx3` 控制用 Haiku(强制 tool_choice)还是主模型 |
191+
| **搜索上限** | 每次调用最多 8 次搜索(`max_uses: 8`|
192+
| **域过滤** | 支持 `allowedDomains` / `blockedDomains` |
193+
| **进度追踪** | 流式解析 `input_json_delta` 提取 query,实时回调 `onProgress` |
194+
195+
#### BingSearchAdapter — Bing 搜索页面解析
196+
197+
直接抓取 Bing 搜索 HTML 并用正则提取结果,无需 API 密钥:
198+
199+
```
200+
调用链:
201+
BingSearchAdapter.search(query, options)
202+
→ axios.get(bing.com/search?q=...) — 使用浏览器级别 headers 绕过反爬
203+
→ extractBingResults(html)
204+
→ 正则匹配 <li class="b_algo"> 块
205+
→ 提取 <h2><a> 标题和 URL
206+
→ resolveBingUrl() 解码 Bing 重定向链接
207+
→ extractSnippet() 三级降级提取摘要
208+
→ 客户端域过滤 (allowedDomains / blockedDomains)
209+
→ 返回 SearchResult[]
210+
```
211+
212+
**反爬策略**:Bing 对非浏览器 UA 返回需要 JS 渲染的空页面。适配器使用完整的 Edge 浏览器请求头(包含 `Sec-Ch-Ua``Sec-Fetch-*` 等现代浏览器标头)确保获得完整 HTML。同时使用 `setmkt=en-US` 参数统一市场定位,避免 Bing 基于用户 IP 做区域化定向(如跳转到德语/新加坡市场导致结果不相关)。
213+
214+
**URL 解码**:Bing 搜索结果中的 URL 为重定向格式(`bing.com/ck/a?...&u=a1aHR0cHM6Ly9...`),`resolveBingUrl()``u` 参数中 base64 解码出真实目标 URL(`a1` 前缀 = https,`a0` = http)。
215+
216+
**摘要提取**`extractSnippet()`)按优先级尝试三个来源:
217+
1. `<p class="b_lineclamp...">` — 带行截断的摘要段落
218+
2. `<div class="b_caption">` 内的 `<p>` — 普通摘要段落
219+
3. `<div class="b_caption">` 的直接文本内容 — 兜底方案
220+
221+
| 特性 | 实现 |
222+
|------|------|
223+
| **超时** | 30 秒(`FETCH_TIMEOUT_MS`|
224+
| **域过滤** | 支持 `allowedDomains` / `blockedDomains`,含子域名匹配 |
225+
| **进度追踪** | 发送 query_update 和 search_results_received 回调 |
226+
| **中止支持** | 外部 AbortSignal 传播到 axios 请求 |
227+
228+
### WebSearchTool 统一接口
229+
230+
`WebSearchTool``src/tools/WebSearchTool/WebSearchTool.ts`)是面向主循环的工具定义,所有 provider 均可使用(`isEnabled()` 始终返回 true)。它将适配器返回的 `SearchResult[]` 转换为内部 `Output` 格式,`mapToolResultToToolResultBlockParam` 将搜索结果格式化为带 markdown 超链接的文本,并附加 "REMINDER" 要求主模型在回复中包含 Sources。
231+
232+
### WebFetch 实现机制
233+
234+
WebFetch 是一个完整的 HTTP 客户端 + 内容处理管线:
235+
236+
```
237+
调用链:
238+
WebFetchTool.call({ url, prompt })
239+
→ getURLMarkdownContent(url)
240+
→ validateURL() — 长度≤2000、无用户名密码、公网域名
241+
→ URL_CACHE 命中检查(15 分钟 TTL LRU,50MB 上限)
242+
→ checkDomainBlocklist() — 调用 api.anthropic.com/api/web/domain_info 预检
243+
→ getWithPermittedRedirects() — axios 请求,自定义重定向处理
244+
→ HTML → Turndown 转 Markdown(懒加载单例,~1.4MB)
245+
→ 非 HTML → 原始文本
246+
→ 二进制(PDF 等)→ persistBinaryContent() 保存到磁盘
247+
→ applyPromptToMarkdown()
248+
→ 截断到 100K 字符
249+
→ queryHaiku() 用小模型按 prompt 提取信息
250+
→ 返回处理后的结果
251+
```
252+
253+
安全防护多层设计:
254+
255+
| 层级 | 机制 | 说明 |
256+
|------|------|------|
257+
| **域名预检** | `checkDomainBlocklist()` | 调用 `api.anthropic.com/api/web/domain_info?domain=…`,5 分钟缓存 |
258+
| **重定向控制** | `isPermittedRedirect()` | 仅允许同 host(±www)重定向,跨域重定向返回提示让 AI 重新调用 |
259+
| **重定向深度** | `MAX_REDIRECTS = 10` | 防止重定向循环无限挂起 |
260+
| **内容大小** | `MAX_HTTP_CONTENT_LENGTH = 10MB` | 单次响应上限 |
261+
| **请求超时** | `FETCH_TIMEOUT_MS = 60s` | 主请求超时;域名预检 10s |
262+
| **URL 验证** | `validateURL()` | 长度、协议、用户名密码、公网域名检查 |
263+
| **egress 检测** | `X-Proxy-Error: blocked-by-allowlist` | 检测企业代理拦截 |
264+
265+
预批准域名(`src/tools/WebFetchTool/preapproved.ts`):
266+
267+
用户无需手动授权即可抓取的域名列表,包含 ~90 个主流技术文档站点(MDN、Python docs、React docs、AWS docs 等)。列表分为 hostname-only 和 path-prefix 两类,查找复杂度 O(1)。
268+
269+
对预批准域名,WebFetch 跳过 Haiku 摘要步骤(如果内容是 Markdown 且 < 100K 字符),直接返回原文——因为技术文档本身的结构化程度已经足够好。
270+
271+
权限模型方面,WebFetch 按 hostname 生成 `domain:xxx` 规则匹配用户的 allow/deny/ask 规则,支持用户对特定域名配置永久允许或拒绝。
272+
147273
### ripgrep 的流式输出
148274

149275
对于交互式场景(如 QuickOpen),ripgrep 支持**流式输出**`ripGrepStream()`):

package.json

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@aws-sdk/credential-provider-node": "^3.972.28",
6868
"@aws-sdk/credential-providers": "^3.1020.0",
6969
"@azure/identity": "^4.13.1",
70+
"@biomejs/biome": "^2.4.10",
7071
"@commander-js/extra-typings": "^14.0.0",
7172
"@growthbook/growthbook": "^1.6.5",
7273
"@modelcontextprotocol/sdk": "^1.29.0",
@@ -90,6 +91,13 @@
9091
"@opentelemetry/semantic-conventions": "^1.40.0",
9192
"@smithy/core": "^3.23.13",
9293
"@smithy/node-http-handler": "^4.5.1",
94+
"@types/bun": "^1.3.11",
95+
"@types/cacache": "^20.0.1",
96+
"@types/plist": "^3.0.5",
97+
"@types/react": "^19.2.14",
98+
"@types/react-reconciler": "^0.33.0",
99+
"@types/sharp": "^0.32.0",
100+
"@types/turndown": "^5.0.6",
93101
"ajv": "^8.18.0",
94102
"asciichart": "^1.5.25",
95103
"audio-capture-napi": "workspace:*",
@@ -112,12 +120,14 @@
112120
"fuse.js": "^7.1.0",
113121
"get-east-asian-width": "^1.5.0",
114122
"google-auth-library": "^10.6.2",
123+
"he": "^1.2.0",
115124
"highlight.js": "^11.11.1",
116125
"https-proxy-agent": "^8.0.0",
117126
"ignore": "^7.0.5",
118127
"image-processor-napi": "workspace:*",
119128
"indent-string": "^5.0.0",
120129
"jsonc-parser": "^3.3.1",
130+
"knip": "^6.1.1",
121131
"lodash-es": "^4.17.23",
122132
"lru-cache": "^11.2.7",
123133
"marked": "^17.0.5",
@@ -140,6 +150,7 @@
140150
"tree-kill": "^1.2.2",
141151
"turndown": "^7.2.2",
142152
"type-fest": "^5.5.0",
153+
"typescript": "^6.0.2",
143154
"undici": "^7.24.6",
144155
"url-handler-napi": "workspace:*",
145156
"usehooks-ts": "^3.1.1",
@@ -150,16 +161,6 @@
150161
"ws": "^8.20.0",
151162
"xss": "^1.0.15",
152163
"yaml": "^2.8.3",
153-
"zod": "^4.3.6",
154-
"@biomejs/biome": "^2.4.10",
155-
"@types/bun": "^1.3.11",
156-
"@types/cacache": "^20.0.1",
157-
"@types/plist": "^3.0.5",
158-
"@types/react": "^19.2.14",
159-
"@types/react-reconciler": "^0.33.0",
160-
"@types/sharp": "^0.32.0",
161-
"@types/turndown": "^5.0.6",
162-
"knip": "^6.1.1",
163-
"typescript": "^6.0.2"
164+
"zod": "^4.3.6"
164165
}
165166
}

src/tools/WebFetchTool/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ export async function getURLMarkdownContent(
384384
// This is for enterprise customers with restrictive security policies
385385
// that prevent outbound connections to claude.ai
386386
const settings = getSettings_DEPRECATED()
387-
if (!settings.skipWebFetchPreflight) {
387+
if (settings.skipWebFetchPreflight === false) {
388388
const checkResult = await checkDomainBlocklist(hostname)
389389
switch (checkResult.status) {
390390
case 'allowed':

0 commit comments

Comments
 (0)