package fqapi import ( "bytes" "compress/gzip" "compress/zlib" "encoding/json" "fanqie/internal/config" "io" "net/url" "regexp" "strings" ) // 客户端版本统一(与 2026-10 可用的书源实现一致;旧值 58932/5.8.9.32 混用易触发风控) const ( ClientVersionCode = "72732" ClientVersionName = "7.2.7.32" ) // PermanentNovelImgHost 番茄小说图片的永久 CDN 域名(原始链接带临时参数会过期失效) const PermanentNovelImgHost = "https://p6-novel.byteimg.com" // novelPicMd5Re 匹配图片路径中的 32 位十六进制资源 ID var novelPicMd5Re = regexp.MustCompile(`^/novel-pic/([a-f0-9]{32})`) // NormalizeNovelImageURL 将临时图片链接永久化: // - /novel-pic/{md5} → p6-novel.byteimg.com/origin/novel-pic/{md5}(正文插图原图) // - 含 ~tplv 的模板图(封面缩放)→ p6-novel.byteimg.com 保留原路径 func NormalizeNovelImageURL(raw string) string { u := strings.TrimSpace(strings.ReplaceAll(raw, "&", "&")) if u == "" { return "" } if strings.HasPrefix(u, "//") { u = "https:" + u } idx := strings.Index(u, "/novel-pic/") if idx < 0 { return u } path := u[idx:] if m := novelPicMd5Re.FindStringSubmatch(path); m != nil && !strings.Contains(path, "~tplv") { return PermanentNovelImgHost + "/origin/novel-pic/" + m[1] } return PermanentNovelImgHost + path } // gunzipMaybe 自适应解压上游正文(gzip 与 zlib 帧都可能出现),非压缩数据原样返回 func gunzipMaybe(data []byte) []byte { if r, err := gzip.NewReader(bytes.NewReader(data)); err == nil { if out, err2 := io.ReadAll(r); err2 == nil { r.Close() return out } } if r, err := zlib.NewReader(bytes.NewReader(data)); err == nil { if out, err2 := io.ReadAll(r); err2 == nil { r.Close() return out } } return data } // buildSortedQuery 将若干 "k=v" 段合并为 form 编码的查询串(按键排序)。 // 设备机型等值含空格时必须编码,否则实际请求的 URL 与签名输入不一致, // registerkey 等接口会注册失败(书源 2026-10-10 修复的同类问题) func buildSortedQuery(parts ...string) string { vals := url.Values{} for _, part := range parts { for _, seg := range strings.Split(part, "&") { if seg == "" { continue } k, v, _ := strings.Cut(seg, "=") vals.Add(k, v) } } return vals.Encode() } // getContentConfig 获取内容配置(带默认值) func getContentConfig() config.ContentConfig { if config.GetAppConfig() != nil { return config.GetAppConfig().Fanqie.Content } // 返回默认配置 return config.ContentConfig{ RemoveImages: false, RemoveImageDesc: false, RemoveBlankLines: true, RemoveHTMLTags: false, RemoveChapterTitle: false, AddParagraphIndent: false, } } // decodeHTMLEntities 解码HTML实体 func decodeHTMLEntities(content string) string { // 解码常见HTML实体 content = strings.ReplaceAll(content, "&", "&") content = strings.ReplaceAll(content, "<", "<") content = strings.ReplaceAll(content, ">", ">") content = strings.ReplaceAll(content, """, "\"") content = strings.ReplaceAll(content, """, "\"") content = strings.ReplaceAll(content, "'", "'") content = strings.ReplaceAll(content, "'", "'") content = strings.ReplaceAll(content, "'", "'") content = strings.ReplaceAll(content, "/", "/") // 解码转义的反斜杠 content = strings.ReplaceAll(content, "\\\"", "\"") return content } // normalizeImgAttributes 将img标签中的img-width和img-height属性替换为标准的width和height属性 func normalizeImgAttributes(content string) string { // 替换 img-width 为 width content = strings.ReplaceAll(content, "img-width=", "width=") // 替换 img-height 为 height content = strings.ReplaceAll(content, "img-height=", "height=") return content } // removeBlankLines 移除多余空行,但保留段落间的双换行 func removeBlankLines(content string) string { // 先将多个连续换行(3个及以上)规范化为双换行 for strings.Contains(content, "\n\n\n") { content = strings.ReplaceAll(content, "\n\n\n", "\n\n") } // 移除首尾的空白 content = strings.TrimSpace(content) return content } // ============ 参数解析工具函数 ============ // ParseBoolParam 解析布尔参数,支持 1/0、true/false 及语义别名 // text_mode: plain(纯文本)=true / html=false // image_mode、review: keep(保留/启用)=true / remove(移除/禁用)=false func ParseBoolParam(param interface{}) bool { switch v := param.(type) { case string: switch strings.ToLower(v) { case "1", "true", "plain", "keep": return true } case bool: return v } return false } // parseBoolParamDefault 解析布尔参数(带默认值) func parseBoolParamDefault(param interface{}, defaultVal bool) bool { if param == nil || param == "" { return defaultVal } return ParseBoolParam(param) } // ============ JSON 处理工具函数 ============ // escapeJSON 转义JSON中的特殊字符 func escapeJSON(s string) string { s = strings.ReplaceAll(s, "<", "\\u003c") s = strings.ReplaceAll(s, ">", "\\u003e") return s } // buildJSONResponse 构建JSON响应并转义 func buildJSONResponse(data interface{}) string { resultBytes, _ := json.Marshal(data) return escapeJSON(string(resultBytes)) } // marshalNoEscape 序列化 JSON 时不做 HTML 转义。 // json.Marshal 默认把 URL 中的 & < > 转义为 & 等,直接从响应文本复制 // URL 到浏览器会因字面转义序列打不开;含播放/下载链接的响应用本函数 func marshalNoEscape(v interface{}) string { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { return "{}" } return strings.TrimSuffix(buf.String(), "\n") } // UnwrapJSONStrings 递归解包字符串形式的 JSON 值。上游常把对象/数组 // 双重编码为字符串(如 category_schema: "[{\"category_id\":718,...}]"), // 本函数将其还原为真正的对象/数组。仅处理以 { 或 [ 开头且能完整解析 // 为 object/array 的字符串,普通文本与数字字符串不受影响;嵌套解析使用 // UseNumber,大整数 ID 不丢精度。 func UnwrapJSONStrings(v interface{}) interface{} { switch t := v.(type) { case map[string]interface{}: for k, val := range t { t[k] = UnwrapJSONStrings(val) } return t case []interface{}: for i, val := range t { t[i] = UnwrapJSONStrings(val) } return t case []map[string]interface{}: for i, val := range t { t[i] = UnwrapJSONStrings(val).(map[string]interface{}) } return t case map[string]map[string]interface{}: m := make(map[string]interface{}, len(t)) for k, val := range t { m[k] = UnwrapJSONStrings(val) } return m case string: s := strings.TrimSpace(t) if len(s) > 1 && (s[0] == '{' || s[0] == '[') { dec := json.NewDecoder(strings.NewReader(s)) dec.UseNumber() var parsed interface{} if err := dec.Decode(&parsed); err == nil { switch parsed.(type) { case map[string]interface{}, []interface{}: return UnwrapJSONStrings(parsed) } } } return t default: return v } } // reencodeUnwrapped 解析上游响应文本,解包 JSON 字符串后重新序列化。 // 解析失败时原样返回;UseNumber 保证大整数 ID 不丢精度 func reencodeUnwrapped(text string) string { dec := json.NewDecoder(strings.NewReader(text)) dec.UseNumber() var v interface{} if err := dec.Decode(&v); err != nil { return text } return marshalNoEscape(UnwrapJSONStrings(v)) } // ============ 设备和密钥管理 ============ // getDeviceHeader 获取设备header(从设备池) func getDeviceHeader() string { // 从设备池获取任意有效设备header return GetAnyValidDeviceHeader() } // removeVersionFromHeader 移除header中的version_name参数 func removeVersionFromHeader(header string) string { return strings.Replace(header, "&version_name="+ClientVersionName, "", -1) } // ============ API参数构建工具 ============ // BuildURL 构建API请求URL // 用法: BuildURL("https://reading.snssdk.com/reading/bookapi/detail/v/", "book_id=123", "tab_type=3") func BuildURL(fullURL string, customParams ...string) string { // 基础参数 allParams := []string{ "aid=1967", "app_name=novelapp", "channel=0", "device_platform=android", "os_version=0", "version_code=" + ClientVersionCode, } // 添加设备header header := getDeviceHeader() if header != "" { allParams = append(allParams, header) } // 添加自定义参数 allParams = append(allParams, customParams...) // form 编码 + 排序(机型含空格等特殊字符时保证 URL 与签名一致) return fullURL + "?" + buildSortedQuery(allParams...) } // ============ 缓存处理工具函数 ============ // buildCacheData 构建缓存数据结构 func buildCacheData(data interface{}) map[string]interface{} { return map[string]interface{}{ "code": 0, "message": "SUCCESS", "data": data, } } // saveCacheJSON 保存JSON到缓存 func saveCacheJSON(key string, data interface{}, ttl int) { cacheJSON, _ := json.Marshal(data) cacheStr := escapeJSON(string(cacheJSON)) UpdateCache(key, cacheStr, ttl) } // ============ 错误响应工具函数 ============ // BuildErrorResponse 构建统一的错误响应 func BuildErrorResponse(msg string) string { return buildJSONResponse(map[string]interface{}{ "code": -1, "msg": msg, }) } // BuildErrorResponseWithCode 构建带自定义错误码的响应 func BuildErrorResponseWithCode(code int, msg string) string { return buildJSONResponse(map[string]interface{}{ "code": code, "msg": msg, }) } // BuildSuccessResponse 构建成功响应 func BuildSuccessResponse(data interface{}) string { return buildJSONResponse(map[string]interface{}{ "code": 0, "message": "SUCCESS", "data": data, }) } // ============ 参数转换工具函数 ============ // ParseIntParam 解析整数参数(支持多种类型) func ParseIntParam(param interface{}, defaultVal int) int { switch v := param.(type) { case string: if v != "" { // 使用strconv.Atoi if result, err := parseStringToInt(v); err == nil { return result } } case int: return v case float64: return int(v) case int64: return int(v) } return defaultVal } // parseStringToInt 辅助函数:将字符串转换为整数 func parseStringToInt(s string) (int, error) { // 简单实现,避免导入strconv result := 0 negative := false if len(s) == 0 { return 0, nil } start := 0 if s[0] == '-' { negative = true start = 1 } else if s[0] == '+' { start = 1 } for i := start; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { break } result = result*10 + int(s[i]-'0') } if negative { result = -result } return result, nil } // ParseStringParam 解析字符串参数(支持数组类型自动Join) func ParseStringParam(param interface{}, defaultVal string) string { if param == nil || param == "" { return defaultVal } switch v := param.(type) { case string: if v != "" { return v } case []string: // 数组类型,用逗号连接 return strings.Join(v, ",") } return defaultVal }