package search import ( "context" "encoding/json" "fmt" "net/http" "unicode/utf8" ) // zhipuProfile 是智谱 BigModel 联网搜索(design/27 §2.1 国内首选;分册 11)。 // // 认证:`Authorization: Bearer `;纯 REST(POST + JSON)⇒ 零新依赖。 // 官方**定价已确证**(分册 11 的官方定价页逐字):Search-Std 0.01 元/次、 // Search-Pro 0.03、Search-Pro-Sogou 0.05、Search-Pro-Quark 0.05。 // // ✅ 官方 schema 已确证(2026-10-07,分册 11「官方 schema 确证」一节,逐条对应官方原文): // - 请求 9 字段:`search_query`(≤ **70** 字)/ `search_engine`(四档 enum,必填)/ // `search_intent`(boolean,页面标 required)/ `count`(**1–50**,默认 10)/ // `search_domain_filter` / `search_recency_filter` / `content_size` / `request_id` / `user_id`; // - 响应:顶层 `id`/`created`/`request_id`/`search_intent`(object[])/`search_result`(object[]), // `search_result[]` 每条 7 键 = `title`/`content`/`link`/`media`/`icon`/`refer`/`publish_date`; // - 🔴 同名字段语义反转:请求侧 `search_intent` 是 **boolean**,响应侧是 **object[]**。 // // ⚠️ 诚实边界:**没有 API key ⇒ 真机未实测**(分册 11「本轮未能确证」逐条)。因此: // // ① 请求只发**确证过的字段**;② 响应用共享提取器按**多组候选键**容错 —— 官方 schema // 已排除的候选键**暂不删**(见 zhipuSpec),待真机实测通过后再收窄 —— 拿未实测的推断 // 去砍掉容错路径,风险大于收益;③ `content_size` 的取值属**半确证**(见常量注释)。 type zhipu struct{ cfg Config } // zhipuPath 是联网搜索端点路径(分册 11 官方原文:POST https://open.bigmodel.cn/api/paas/v4/web_search)。 const zhipuPath = "/api/paas/v4/web_search" // zhipuEngineStd 是默认搜索型号。取 `search_std`(自研基础搜索,官方定价页确证 // 0.01 元/次,四档里最便宜)。官方 API Reference 把 `search_engine` 列为**必填 enum** // (`search_std`/`search_pro`/`search_pro_sogou`/`search_pro_quark`),这里显式写死一档 // 以固定成本;要换档请改这里,或后续开放为配置项。 const zhipuEngineStd = "search_std" // zhipuMaxQueryRunes 是 `search_query` 的**官方硬上限**(API Reference:Maximum string // length 70)。超限就**如实报错、一个请求都不发** —— 静默截断会让她以为搜的是别的东西。 // // 按**字符(rune)**计:官方只写 "string length",未说明是字符还是字节;中文查询词按字符 // 计最宽(一个中文 = 3 字节),拿不准时宁可少发几个字,也不误把合法查询词拒掉。 const zhipuMaxQueryRunes = 70 // zhipuCount 的官方合法区间(API Reference:1–50,默认 10)。 const ( zhipuMinCount = 1 zhipuMaxCount = 50 ) // zhipuContentSize 压短上游返回的正文(`medium` = 摘要),降低撞本仓 1 MiB 读取上限 // (plugins/tools/io 的 http 响应上限)的风险。 // // ⚠️ **半确证**:API Reference 的字段表**没标 default**;「默认 medium」只来自 guide 页 // 示例旁的注释(分册 11「本轮未能确证」第 5 条)。这里按 `medium` 发,属保守取值。 const zhipuContentSize = "medium" // newZhipu 造一家智谱 provider。 func newZhipu(cfg Config) Provider { return &zhipu{cfg: cfg} } // Name 实现 Provider。 func (p *zhipu) Name() string { return NameZhipu } // Search 实现 Provider。 func (p *zhipu) Search(ctx context.Context, q Query) ([]Result, error) { if n := utf8.RuneCountInString(q.Text); n > zhipuMaxQueryRunes { // 官方硬上限:超限**如实报错、不发请求**(截断 = 悄悄把她的查询词换成另一个)。 return nil, fmt.Errorf("search: zhipu: query is %d characters, upstream limit is %d", n, zhipuMaxQueryRunes) } payload := map[string]any{ "search_engine": zhipuEngineStd, "search_query": q.Text, // 官方把 search_intent 标为 required(默认 false):缺发可能触发 1213(未正常接收到 // 该参数)。发 false = 直接搜、不做意图识别。 // 🔴 响应侧同名字段是 object[](意图结果),两侧语义相反 —— 别混用。 "search_intent": false, // content_size 是**半确证**取值(见常量注释):压短正文,降低撞读取上限的风险。 "content_size": zhipuContentSize, } // count 落在官方区间才透传;越界就**不发这个字段**,让上游用默认 10 —— // 把非法值发上去换一个 1210 参数错误没有意义。 if c, ok := zhipuCount(q.Count); ok { payload["count"] = c } body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("search: zhipu: encode request: %w", err) } data, err := do(ctx, p.cfg, request{ Method: http.MethodPost, URL: baseURLJoin(p.cfg.BaseURL, zhipuPath), Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey}, Body: body, }) if err != nil { return nil, err } return extractResults(data, zhipuSpec()) } // zhipuCount 把工具侧的 count 收敛到官方区间 [1,50];越界返回 ok=false(不发该字段)。 func zhipuCount(count int) (int, bool) { if count < zhipuMinCount || count > zhipuMaxCount { return 0, false } return count, true } // zhipuSpec 是智谱响应的候选形状。 // // ✅ 2026-10-07 官方 schema 已确证(分册 11):数组键是**顶层 `search_result`**,每条 7 键 = // title/content/link/media/icon/refer/publish_date;官方**没有** snippet/summary/abstract/ // url/score/rank(「摘要」就在 `content` 里)。据此: // - `publish_date` 是确证的发布时间 ⇒ 已映射到 Published; // - 🔴 **`media` / `icon` / `refer` 当前一律不投影**(主人 2026-10-07 裁定,撤销先前把 // `media` 当 Snippet 候选的写法)。`media` 是**网站名称**(如「搜狐」),不是正文摘录: // 官方响应**根本没有 `snippet`** ⇒ 若把它列为候选,`Snippet` 会被**恒由 `media` 填满** // (不是偶尔兜底),等于每条结果的「摘要位」都变成站点名 —— 往语义位塞了另一类事实; // 更危险的是共享提取器有一条**单向**回落(`extractResults`:`Content` 空 ⇒ 回落 // `Snippet`)⇒ 站点名有可能被当成正文递出去。 // `Result` 没有「来源站点」位(本片不许改 `extract.go` / `search.Result`)⇒ **将来要投影 // `media`,正确做法是给 `Result` 加一个字段并同步契约**(不是塞进 `Snippet`)。 // // ⚠️ **候选键暂不删**:主人还没给 key,容错路径**未经真机验证**;官方 schema 只覆盖 // `search_std` 的文档口径(分册 11「各档引擎返回字段是否真同构」未确证)。**待真机实测 // 通过后**再把这些已排除的候选键收窄成单键。 func zhipuSpec() spec { return spec{ Arrays: []string{"search_result", "results", "data", "data.results", "web_search_result"}, Title: []string{"title", "name"}, Link: []string{"link", "url"}, // 🔴 **不含 `media`**:它是网站名称、不是正文摘录,且官方响应没有 `snippet` // ⇒ 列进来会让 Snippet **恒**被站点名填满,并被共享提取器的单向回落当成正文 // 递出去(理由见上方注释;`media`/`icon`/`refer` 当前一律不投影)。 Snippet: []string{"snippet", "summary", "abstract"}, Content: []string{"content", "text"}, // 官方确证:publish_date(如 "2025-05-23")。 Published: []string{"publish_date", "published", "date"}, Score: []string{"score", "rank"}, } }