Skip to content

Commit afe62d1

Browse files
author
SqlRush
committed
Expand WebSearch JSON result wrappers
1 parent 2d6fd6c commit afe62d1

4 files changed

Lines changed: 68 additions & 1 deletion

File tree

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ test/parity/ # golden tests against TS/official behavior
186186
- 本轮补充:WebFetch 文本 body 现在会按 BOM、`Content-Type` charset 或 HTML `<meta charset>`/`http-equiv` charset 解码常见网页编码,包括 UTF-8/UTF-16LE/UTF-16BE、Latin-1 和 Windows-1252,并在 structured content 暴露归一化 `charset`
187187
- 本轮补充:WebSearch JSON parser 现在会递归解包 `web``response``search``hits``documents``records``entries` 等常见后端 wrapper,保留 URL 去重和 domain filter。
188188
- 本轮补充:WebSearch JSON result parser 现在支持 `pageUrl`/`targetUrl`/`source_url`/`formattedUrl` 等 URL aliases、`htmlTitle`/`htmlSnippet` 等 HTML 标记字段清理、嵌套 URL object,以及 `deepLinks`/`siteLinks` 子结果递归解析。
189+
- 本轮补充:WebSearch JSON parser 现在继续覆盖 `answerBox`/`answer_box``knowledgeGraph`/`knowledge_graph``news`/`news_results``topStories``peopleAlsoAsk``related_questions` 等常见搜索后端 wrapper,并识别 `website`/`sourceLink` URL alias、`question` title alias、`answer`/`excerpt` snippet alias。
189190
- 本轮补充:WebSearch `query` schema 现在按官方 `min(2)` 约束拒绝单字符查询,通用 tool schema validator 同步支持 `minLength`,让工具定义可直接表达字符串最小长度契约。
190191
- 本轮补充:WebSearch domain filters 现在在 schema 层声明 array `items:string`,通用 tool schema validator 同步支持 `items` 校验;`allowed_domains`/`blocked_domains` 会拒绝空字符串、URL/port、非法 wildcard 和非域名 label。
191192
- 本轮补充:通用 tool schema validator 现在支持 `enum`,可直接执行 Grep output mode、NotebookEdit edit mode/cell type、Todo status/priority、Task target/action、LSP severity 等工具 schema 的枚举契约。

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ Covered behavior:
127127
- `WebFetch` text bodies now decode common web charsets from BOM, `Content-Type`, or HTML `<meta charset>`/`http-equiv` declarations, including UTF-8/UTF-16LE/UTF-16BE, Latin-1, and Windows-1252, and structured results expose the normalized `charset`.
128128
- `WebSearch` initial HTML/JSON-search adapter, including query/max-result/timeout/domain-filter validation, injectable search endpoint, DuckDuckGo HTML link parsing, DuckDuckGo subdomain redirect unwrapping, common JSON result shapes plus nested backend wrappers such as `web`/`response`/`hits`/`documents`, DuckDuckGo result snippet extraction, domain allow/block filtering, structured result payloads, and query-based permission-rule matching.
129129
- `WebSearch` JSON result parsing now accepts additional backend aliases such as `pageUrl`/`targetUrl`/`source_url`/`formattedUrl`, cleans HTML-bearing title/snippet fields like `htmlTitle`/`htmlSnippet`, unwraps nested URL objects, and recursively collects `deepLinks`/`siteLinks` child results.
130+
- `WebSearch` JSON result parsing now also unwraps answer/knowledge/news/people-also-ask style backend containers such as `answerBox`, `knowledgeGraph`, `news_results`, `topStories`, `peopleAlsoAsk`, and `related_questions`, while accepting `website`/`sourceLink`, `question`, `answer`, and `excerpt` result aliases.
130131
- `Grep` now accepts whole-word matching through `word_regexp`/`wordRegexp`/`word-regexp`/`-w`, applying word-boundary filtering for both regex and fixed-string patterns and preserving quoted boolean coercion.
131132
- `Grep` now accepts inverted matching through `invert_match`/`invertMatch`/`invert-match`/`-v`, applying the inverted line set consistently for files-with-matches, content, count, and multiline span output modes while preserving quoted boolean coercion.
132133
- `Grep` content output now accepts ripgrep-style no-line-number controls such as `--no-line-number` and `-N`, plus snake/camel adjacent aliases, to disable default line-number output with structured state preserved.

internal/tools/web/web_search.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,9 @@ func collectJSONSearchResults(value any, base *url.URL, results *[]searchResult,
278278
"results", "organic_results", "organicResults", "items", "value", "data",
279279
"webPages", "web_pages", "web", "response", "search",
280280
"hits", "documents", "records", "entries", "organic",
281+
"answer_box", "answerBox", "knowledge_graph", "knowledgeGraph",
282+
"news", "news_results", "newsResults", "top_stories", "topStories",
283+
"people_also_ask", "peopleAlsoAsk", "related_questions", "relatedQuestions",
281284
"deepLinks", "deep_links", "siteLinks", "sitelinks", "pages", "matches",
282285
} {
283286
if child, ok := typed[key]; ok {
@@ -295,7 +298,7 @@ func searchResultFromJSONObject(obj map[string]any, base *url.URL) (searchResult
295298
}
296299
title := cleanJSONSearchText(jsonStringField(obj,
297300
"title", "name", "headline", "heading",
298-
"htmlTitle", "html_title",
301+
"question", "label", "htmlTitle", "html_title",
299302
))
300303
if strings.TrimSpace(title) == "" {
301304
title = resolved
@@ -304,6 +307,7 @@ func searchResultFromJSONObject(obj map[string]any, base *url.URL) (searchResult
304307
"snippet", "description", "content", "text",
305308
"htmlSnippet", "html_snippet",
306309
"summary", "extract", "abstract", "body", "caption",
310+
"answer", "excerpt",
307311
))
308312
return searchResult{
309313
Title: title,
@@ -319,6 +323,8 @@ func jsonSearchURLField(obj map[string]any) string {
319323
"targetUrl", "targetURL", "target_url",
320324
"webUrl", "webURL", "web_url",
321325
"sourceUrl", "sourceURL", "source_url",
326+
"sourceLink", "source_link",
327+
"website", "site",
322328
); raw != "" {
323329
return raw
324330
}

internal/tools/web/web_search_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,65 @@ func TestWebSearchParsesAlternateJSONFieldAliases(t *testing.T) {
229229
}
230230
}
231231

232+
func TestWebSearchParsesSearchBackendWrapperObjects(t *testing.T) {
233+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
234+
w.Header().Set("Content-Type", "application/json")
235+
_, _ = w.Write([]byte(`{
236+
"answerBox": {
237+
"title": "Answer Box",
238+
"link": "https://example.com/answer",
239+
"answer": "direct answer"
240+
},
241+
"knowledgeGraph": {
242+
"title": "Knowledge Graph",
243+
"website": "https://example.com/kg",
244+
"description": "knowledge description"
245+
},
246+
"news": {
247+
"results": [
248+
{"title": "News Result", "url": "https://news.example.com/story", "excerpt": "news excerpt"}
249+
]
250+
},
251+
"peopleAlsoAsk": [
252+
{"question": "Question Result", "sourceLink": "https://example.com/question", "snippet": "question snippet"}
253+
],
254+
"related_questions": [
255+
{"question": "Duplicate Question", "link": "https://example.com/question", "snippet": "duplicate"}
256+
]
257+
}`))
258+
}))
259+
defer server.Close()
260+
executor := webExecutor(t)
261+
result, err := executor.Execute(tool.Context{
262+
Context: context.Background(),
263+
Metadata: map[string]any{
264+
MetadataWebSearchEndpointKey: server.URL,
265+
},
266+
}, contracts.ToolUse{
267+
ID: "toolu_search_backend_wrappers",
268+
Name: "WebSearch",
269+
Input: json.RawMessage(`{"query":"backend wrappers","allowed_domains":["example.com"],"max_results":10}`),
270+
}, nil)
271+
if err != nil {
272+
t.Fatal(err)
273+
}
274+
results, ok := result.StructuredContent["results"].([]map[string]any)
275+
if !ok || len(results) != 4 {
276+
t.Fatalf("structured results = %#v", result.StructuredContent["results"])
277+
}
278+
wantTitles := []string{"Answer Box", "Knowledge Graph", "News Result", "Question Result"}
279+
wantSnippets := []string{"direct answer", "knowledge description", "news excerpt", "question snippet"}
280+
for i := range wantTitles {
281+
if results[i]["title"] != wantTitles[i] || results[i]["snippet"] != wantSnippets[i] {
282+
t.Fatalf("result %d = %#v", i, results[i])
283+
}
284+
}
285+
content := result.Content.(string)
286+
if !strings.Contains(content, "Knowledge Graph") || !strings.Contains(content, "news excerpt") || strings.Contains(content, "Duplicate Question") {
287+
t.Fatalf("content = %#v", content)
288+
}
289+
}
290+
232291
func TestWebSearchBlockedDomainsAndNoResults(t *testing.T) {
233292
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
234293
w.Header().Set("Content-Type", "text/html")

0 commit comments

Comments
 (0)