package search import ( "context" "encoding/json" "fmt" "net/http" "strconv" "strings" ) // metasoProfile 是秘塔搜索 Metaso(design/27 §2.1;官方材料见 // DevDocs/reference/search-providers/metaso/)。 // // 走开放平台的搜索端点(**不是**站点自己的 `/search/v2`,那是另一套字段): // // POST https://metaso.cn/api/v1/search // Authorization: Bearer // // 请求字段取**官方前端产物**里确证的那几个(`client-bundle-evidence.md` §4 的组装函数原文): // // q 查询词(必填) // scope "webpage"(官方枚举 webpage/document/scholar/image/video/podcast) // size 结果数量,官方前端发的是**字符串**(默认 "10") // // 刻意**不发 `mode`**:官方前端会发 `mode:"search"`,而第三方誊抄的官方 cURL 样例里没有它 // ⇒「是否必需」**未确证**,拿不准的字段不发(响应侧的 `errCode` 也读不出「缺 mode」)。 // // 🔴 **错误也走 HTTP 200**:`{"errCode":2005,"errMsg":"API密钥无效"}`(本机实测;缺 key 与 // 错 key **同码**)⇒ 必须读响应体的 `errCode` 才能判失败(见 metasoResponseError)。 // // 🔴 **响应 schema 只有第三方来源、且互相打架**(条目级字段 `date` vs `displayDate`;顶层 // `webpages` 三方一致但**无官方来源**)⇒ 本家只能**宽松解析**(多字段名回退,见 metasoSpec), // 候选键**未经实测**。拿到真 key 后必须按真实报文核对并收窄候选键。 type metaso struct{ cfg Config } // metasoPath 是开放平台的搜索端点路径(官方 JS 产物:POST /api/v1/search)。 const metasoPath = "/api/v1/search" // metasoScopeWebpage 是固定的搜索范围「网页」。 // // 固定它的理由:响应侧的结果数组键**随 scope 变**(webpage→webpages),本工具是网页搜索, // 只有固定 scope=webpage 才能让请求与解析两侧对上(第三方的 SCOPE_RESULT_MAPPING 原文如此)。 const metasoScopeWebpage = "webpage" // metasoDefaultSize 是 `size` 的官方前端默认值(**字符串** "10",见文件头)。 const metasoDefaultSize = "10" // metasoMaxSize 是 `size` 的钳制上限。 // // ⚠️ **未确证**:官方页面/JS 产物**没写**上限,1–100 这个区间来自第三方实现 // (`metaso-search-mcp`:`Math.min(Math.max(size,1),100)`)。这里按第三方的保守值钳制 // ——宁可少要几条(本地截断照常生效),也不把越界值发上去换一个参数错。 const metasoMaxSize = 100 // metasoUnauthorizedCode 是凭据问题的业务码(本机实测:无 key 与错 key 都是 2005)。 const metasoUnauthorizedCode = "2005" // metasoAcceptHeader 是官方 cURL 模板四段头之一(搜索端点固定 `application/json`)。 const metasoAcceptHeader = "application/json" // newMetaso 造一家秘塔搜索 provider。 func newMetaso(cfg Config) Provider { return &metaso{cfg: cfg} } // Name 实现 Provider。 func (p *metaso) Name() string { return NameMetaso } // Search 实现 Provider。 func (p *metaso) Search(ctx context.Context, q Query) ([]Result, error) { payload := map[string]any{ "q": q.Text, "scope": metasoScopeWebpage, "size": metasoSize(q.Count), } body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("search: metaso: encode request: %w", err) } data, err := do(ctx, p.cfg, request{ Method: http.MethodPost, URL: baseURLJoin(p.cfg.BaseURL, metasoPath), Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey, "Accept": metasoAcceptHeader}, Body: body, }) if err != nil { return nil, err } return metasoParse(data) } // metasoSize 把调用方要的条数收进 [1,100] 并**转成字符串**(官方前端发的是字符串)。 // 未给条数(≤0)⇒ 官方默认值 "10"。 func metasoSize(n int) string { if n <= 0 { return metasoDefaultSize } if n > metasoMaxSize { n = metasoMaxSize } return strconv.Itoa(n) } // metasoParse 先判错、再把报文交给共享提取器归一化。 // // 判错必须在提取之前:失败报文里**没有**结果数组,直接用共享提取器会把它读成 // 「结构不对」(ErrBadResponse),而真相是「凭据无效」——那正是降级链该前进一格的那种失败。 func metasoParse(data []byte) ([]Result, error) { var root any if err := json.Unmarshal(data, &root); err != nil { return nil, fmt.Errorf("%w: %w", ErrBadResponse, err) } if err := metasoResponseError(root); err != nil { return nil, err } // 判错之后复用共享提取器:同一套多候选键容错 + 「Content 空 ⇒ 回落 Snippet」的口径。 return extractResults(data, metasoSpec()) } // metasoResponseError 读顶层 `errCode`(HTTP 200 上判失败的**唯一**途径)。 // 缺位或 0 ⇒ 这次调用成功(成功响应里 errCode 长什么样**未实测**,因此对 0/缺位都放行)。 func metasoResponseError(root any) error { v, ok := resolvePath(root, "errCode") if !ok { return nil } code := scalarString(v) if code == "" || code == "0" { return nil } sentinel := metasoCodeClass(code) return fmt.Errorf("%w (code=%s)", sentinel, code) } // metasoCodeClass 把秘塔的错误码归成本仓哨兵。 // // 已确证的只有一条:**2005 = 「API密钥无效」**(本机实测,缺 key 与错 key 同码)。官方 // 错误码表未公开,**欠费/限流/未开通的码一个都没取到** ⇒ 一律不猜,落到 ErrUpstream // (仍然是「不可用」,降级链照常前进一格,不会把失败说成空结果)。 // // `401`/`403` 是站点自身接口的码(`/api/my-info` 的「需要登录」实测为 401),这里一并 // 认作凭据问题:开放 API 若沿用同一套码,归类也是对的。 func metasoCodeClass(code string) error { switch strings.TrimSpace(code) { case metasoUnauthorizedCode, "401", "403": return ErrUnauthorized default: return ErrUpstream } } // metasoSpec 是秘塔搜索响应的**候选**形状 —— ⚠️ **全部未经实测**(无官方 schema)。 // // 已知事实只有三条:① 顶层**没有 `data` 信封**(第三方三个实现一致);② 结果数组的键随 // scope 变(本地固定 scope=webpage ⇒ 主候选 `webpages`);③ 条目级字段名**三方不一致** // (`date` vs `displayDate`)。因此候选键给多组回退,且**不把任何一个猜测当事实**: // // Arrays: webpages(主候选)→ data.webpages / documents / data.documents / // results / data.results / data(逐级容错) // Title: title / name // Link: link / url // Snippet: snippet / summary / passage / abstract // Content: rawContent(`includeRawContent=true` 时的正文;本工具不请求该开关 ⇒ 通常为空) // → content / markdown / text // Published: date / displayDate / published / publishDate // Score: score(第三方实测是**字符串** ⇒ pickFloat 认得数字字符串) // // 刻意**不把 `position` / `rank` 当 Score**:它们是**排序位次**(1,2,3…),而 Finalize 按 // Score **降序**排 —— 拿位次当分数会把最相关的一条排到最后。宁可没有分数(保持上游原始 // 顺序,SliceStable)。 func metasoSpec() spec { return spec{ Arrays: []string{ "webpages", "data.webpages", "documents", "data.documents", "results", "data.results", "data", }, Title: []string{"title", "name"}, Link: []string{"link", "url"}, Snippet: []string{"snippet", "summary", "passage", "abstract"}, Content: []string{"rawContent", "content", "markdown", "text"}, Published: []string{"date", "displayDate", "published", "publishDate"}, Score: []string{"score"}, } }