package fqapi import ( "bytes" "encoding/json" "fanqie/internal/common" "fmt" "net/url" "regexp" "strconv" "time" ) var searchLogger = common.NewLogger("search", "INFO", "") // 搜索分页与上游续传参数(对齐书源 20261012:上游翻页依赖上一页返回的 // passback/search_id/next_offset/corrected_query 续传,仅传数字 offset 深翻页会拿到重复/错序结果) const ( searchPageSize = 10 // 对外每页书数 searchMaxUpstreams = 24 // 单次搜索累计上游翻页上限(对齐书源) ) // bookIDKeyRe 纯数字 15~20 位关键词按书 ID 直搜详情 var bookIDKeyRe = regexp.MustCompile(`^\d{15,20}$`) // HandleBookSearch 处理书籍搜索,搜索结果短缓存 10 分钟,减轻上游压力 func HandleBookSearch(key string, offset int, tabType int) (string, error) { return withCache(fmt.Sprintf("search_%s_%d_%d", key, offset, tabType), 600, func() (string, error) { return fetchBookSearch(key, offset, tabType) }) } func fetchBookSearch(key string, offset int, tabType int) (string, error) { if key == "" { return BuildErrorResponseWithCode(1, "需要参数 key"), nil } if tabType == 0 { tabType = 3 } if offset < 0 { offset = 0 } // 书 ID 直搜:跳过搜索接口直接查详情,精确命中单本 if bookIDKeyRe.MatchString(key) { return idSearchResult(key) } // 累计翻页直到凑够本页书:上游单页 count=20,按 book_id 去重后切出 offset 起的 10 本 session := strconv.FormatInt(time.Now().UnixNano(), 10) var ( rows []interface{} // 上游搜索行(含 book_data 包装),原样透传 seen = map[string]bool{} passback = "0" searchID = "" corrected = "" nextOff = 0 hasMore = true ) want := offset + searchPageSize for i := 0; i < searchMaxUpstreams && hasMore && len(rows) < want; i++ { params := []string{ fmt.Sprintf("tab_type=%d", tabType), "update_version_code=" + ClientVersionCode, "query=" + url.QueryEscape(key), "passback=" + passback, fmt.Sprintf("offset=%d", nextOff), "count=20", fmt.Sprintf("is_first_enter_search=%t", i == 0), "normal_session_id=" + session, "cold_start_session_id=" + session, } if searchID != "" { params = append(params, "search_id="+searchID) } if corrected != "" { params = append(params, "corrected_query="+corrected) } tab, err := fetchSearchTab(params, tabType) if err != nil { if i == 0 { return BuildErrorResponseWithCode(1, "搜索请求失败"), err } break // 已有数据时后续页失败,降级返回现有结果 } if tab == nil { break } if data, ok := tab["data"].([]interface{}); ok { for _, r := range data { row, _ := r.(map[string]interface{}) if row == nil { continue } // 行 ID 取 book_data[0].book_id;无包装的行按行本身的 book_id bd, _ := row["book_data"].([]interface{}) if len(bd) > 0 { if b, ok := bd[0].(map[string]interface{}); ok { if id := searchRowID(b); id != "" && !seen[id] { seen[id] = true rows = append(rows, row) } continue } } if id := searchRowID(row); id != "" && !seen[id] { seen[id] = true rows = append(rows, row) } } } hasMore = searchHasMore(tab["has_more"]) nextOff = searchInt(tab["next_offset"]) passback = searchStr(tab["passback"]) searchID = searchStr(tab["search_id"]) corrected = searchStr(tab["corrected_query"]) } start, end := offset, offset+searchPageSize if start > len(rows) { start = len(rows) } if end > len(rows) { end = len(rows) } return marshalNoEscape(UnwrapJSONStrings(map[string]interface{}{ "code": 0, "message": "SUCCESS", "data": rows[start:end], "has_more": hasMore || end < len(rows), "next_offset": offset + searchPageSize, })), nil } // fetchSearchTab 请求一页搜索并返回目标 tab_type 的 tab;nil 表示该页无匹配 tab func fetchSearchTab(params []string, tabType int) (map[string]interface{}, error) { reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/search/tab/v/", params...) resp, err := GorgonRequest(reqURL, map[string]interface{}{ "method": "GET", "headers": map[string]string{ "User-Agent": "com.dragon.read", }, }) if err != nil { searchLogger.Error(fmt.Sprintf("搜索请求失败: %v", err)) return nil, err } // UseNumber 重新解析后按 tab_type 过滤: // 上游 search/tab/v/ 固定返回所有 tab 的 search_tabs; // 大整数 ID 经 float64 重组会丢精度,必须 UseNumber dec := json.NewDecoder(bytes.NewReader(resp.Content)) dec.UseNumber() var pageJSON map[string]interface{} if err := dec.Decode(&pageJSON); err != nil { searchLogger.Error(fmt.Sprintf("解析响应失败: %v", err)) return nil, fmt.Errorf("解析失败") } if code, _ := pageJSON["code"].(float64); code != 0 { return nil, fmt.Errorf("搜索失败 code=%v", pageJSON["code"]) } filterSearchTabs(pageJSON, tabType) tabs, _ := pageJSON["search_tabs"].([]interface{}) for _, t := range tabs { m, ok := t.(map[string]interface{}) if !ok { continue } if tt, ok := m["tab_type"].(json.Number); ok { if v, err := tt.Int64(); err == nil && int(v) == tabType { return m, nil } } } return nil, nil } // idSearchResult 书 ID 直搜:复用详情接口(24h 缓存),包成上游搜索行形状(book_data 包装) func idSearchResult(bookID string) (string, error) { detailJSON, err := HandleGetDetail(bookID) if err != nil { return BuildErrorResponseWithCode(1, "ID 搜索失败"), err } var detail map[string]interface{} if err := json.Unmarshal([]byte(detailJSON), &detail); err != nil { return detailJSON, nil // 透传上游错误响应 } if code, _ := detail["code"].(float64); code != 0 { return detailJSON, nil } return marshalNoEscape(UnwrapJSONStrings(map[string]interface{}{ "code": 0, "message": "SUCCESS", "data": []interface{}{map[string]interface{}{"book_data": []interface{}{detail["data"]}}}, "has_more": false, "next_offset": 0, })), nil } // filterSearchTabs 按 tab_type 只保留 search_tabs 中匹配的项;无匹配时保留原样 func filterSearchTabs(respJSON map[string]interface{}, tabType int) { tabs, ok := respJSON["search_tabs"].([]interface{}) if !ok { return } filtered := make([]interface{}, 0, 1) for _, t := range tabs { m, ok := t.(map[string]interface{}) if !ok { continue } if tt, ok := m["tab_type"].(json.Number); ok { if v, err := tt.Int64(); err == nil && int(v) == tabType { filtered = append(filtered, m) } } } if len(filtered) > 0 { respJSON["search_tabs"] = filtered } } // searchRowID 取行的 book_id(UseNumber 下为 json.Number,也可能是字符串) func searchRowID(m map[string]interface{}) string { if m == nil { return "" } if s, _ := m["book_id"].(string); s != "" { return s } if n, ok := m["book_id"].(json.Number); ok { return n.String() } return "" } func searchStr(v interface{}) string { s, _ := v.(string) return s } func searchInt(v interface{}) int { switch t := v.(type) { case json.Number: n, _ := t.Int64() return int(n) case string: n, _ := strconv.Atoi(t) return n } return 0 } func searchHasMore(v interface{}) bool { switch t := v.(type) { case bool: return t case json.Number: n, _ := t.Int64() return n != 0 } return false }