diff --git a/.gitignore b/.gitignore index 7a97fb0..b19979e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ fanqie-* # 环境变量(含真实密码,勿提交) .env .finesse/ +fanqie.log diff --git a/README.md b/README.md index edcbb5d..427c395 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ go run ./cmd/fanqie | 接口 | 方法 | 说明 | 参数 | | ------ | ------ | ------ | ------ | -| `/search` | GET | 搜索书籍 | `query`(或 `key`), `tab_type`, `offset`(`search_tabs` 仅返回匹配 `tab_type` 的 tab) | +| `/search` | GET | 搜索书籍 | `query`(或 `key`), `tab_type`, `offset`(结果在顶层 `data`,已按 `tab_type` 过滤展平) | | `/detail` | GET | 书籍详情 | `book_id` | | `/info` | GET | 书籍详情(同 detail) | `book_id` | | `/catalog` | GET | 章节目录 | `book_id` | @@ -163,8 +163,8 @@ go run ./cmd/fanqie | 接口 | 方法 | 说明 | 参数 | | ------ | ------ | ------ | ------ | | `/chapter_review` | GET | 章节评论 | `book_id`, `item_id` | -| `/review` | GET | 书评列表 | `book_id`, `item_id`, `para`, `comment_id`, `offset`, `limit` | -| `/review_html` | GET | 评论 HTML | `book_id`, `item_id`, `para` | +| `/review` | GET | 书评列表 | `book_id`, `item_id`, `para`, `comment_id`, `sort`, `offset`, `count` | +| `/review_html` | GET | 评论 HTML | `book_id`, `item_id`, `para`, `sort`, `page`, `page_size` | ### 分类相关 @@ -205,16 +205,18 @@ GET http://localhost:40180/catalog?book_id=xxx ### 获取章节内容 ```bash -GET http://localhost:40180/content?book_id=xxx&chapter_id=xxx +GET http://localhost:40180/content?item_id=xxx ``` 支持以下 URL 参数覆盖默认配置: | 参数 | 说明 | 可选值 | | ------ | ------ | ------ | -| `text_mode` | 文本模式 | `plain`(纯文本), `html`(HTML) | -| `img_mode` | 图片模式 | `remove`(移除), `keep`(保留) | -| `review_mode` | 评论模式 | `remove`(移除), `keep`(保留) | +| `text_mode` | 文本模式 | `plain`(纯文本), `html`(HTML), 或 `1`/`0` | +| `img_mode`(或 `image_mode`) | 图片模式 | `remove`(移除), `keep`(保留), 或 `0`/`1` | +| `review_mode`(或 `review`) | 评论模式 | `keep`(启用段评), `remove`(禁用), 或 `1`/`0` | + +纯文本输出(`text_mode=plain` 或配置 `remove_html_tags: true`)时不注入段评标记——`` 为 XML 标签,纯文本无法承载;需要段评时配置 `remove_html_tags: false`。 ### 批量获取 @@ -268,10 +270,10 @@ fanqie: # 漫画处理 manga: # 输出模式: - # - "proxy": 代理模式 - # - "url": 直接返回图片 URL + # - "proxy": 代理模式(图片已解密,推荐;"url" 返回的原始链接 404 且内容加密不可用) # - "data_uri": Base64 编码 - output_mode: "url" + # - "url": 直接返回图片 URL(不可用) + output_mode: "proxy" # 缓存配置 cache: @@ -336,7 +338,7 @@ cp .env.example .env | `fanqie.algorithm` | 签名算法 `gorgon` / `8404` | `gorgon` | | `fanqie.signature` | 签名方法 `sign2` / `sign4` | `sign4` | | `fanqie.content.*` | 正文内容处理选项 | 见上文 | -| `fanqie.manga.output_mode` | 漫画输出模式 | `url` | +| `fanqie.manga.output_mode` | 漫画输出模式 | `proxy` | | `cache.enabled` | 是否启用正文内容缓存 | `true` | | `cache.backend` | SQL 存储后端 `sqlite` / `postgres` | `sqlite` | | `cache.sqlite_path` | SQLite 数据库路径 | `./cache.db` | diff --git a/cmd/fanqie/main.go b/cmd/fanqie/main.go index abaf33b..a8ddc70 100644 --- a/cmd/fanqie/main.go +++ b/cmd/fanqie/main.go @@ -194,7 +194,8 @@ func startServer() { addr := host + ":" + port server := &http.Server{ - Addr: addr, + Addr: addr, + Handler: handlers.Gzip(http.DefaultServeMux), // 超时保护:防止慢客户端长期占用连接 ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, diff --git a/config/config.yaml b/config/config.yaml index a92bb05..5c45c57 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -52,10 +52,10 @@ fanqie: # 漫画处理配置 manga: # 输出模式:proxy / data_uri / url - # proxy: 返回本地代理URL /manga/image/{itemID}/{index} + # proxy: 返回本地代理URL /manga/image/{itemID}/{index}(图片已解密,推荐) # data_uri: 返回 data:image/jpeg;base64,/9j/4AAQ... - # url: 返回原始图片URL数组 - output_mode: "url" + # url: 返回原始图片URL数组(不可用:链接404且内容加密) + output_mode: "proxy" # 缓存配置 cache: diff --git a/internal/fqapi/audio.go b/internal/fqapi/audio.go index 1410dd0..b0c107f 100644 --- a/internal/fqapi/audio.go +++ b/internal/fqapi/audio.go @@ -1,7 +1,6 @@ package fqapi import ( - "encoding/json" "fanqie/internal/common" "fmt" ) @@ -118,8 +117,7 @@ func HandleAudio(itemIDs interface{}, toneID interface{}, output interface{}) (s } } - resultBytes, _ := json.Marshal(respJSON) - return string(resultBytes), nil + return marshalNoEscape(UnwrapJSONStrings(respJSON)), nil } // 返回错误信息 diff --git a/internal/fqapi/cache_manager.go b/internal/fqapi/cache_manager.go index 6df86b1..2f445c8 100644 --- a/internal/fqapi/cache_manager.go +++ b/internal/fqapi/cache_manager.go @@ -259,7 +259,9 @@ func isCacheEnabled() bool { // ponytail: 空参等防御分支若漏到此处,其错误 JSON 也会被缓存到 TTL 到期——handler 层 validateParams 已拦截 func withCache(key string, ttl int, fetch func() (string, error)) (string, error) { if c := GetCache(key); c != "" { - return c, nil + // 旧缓存可能存有未解包的 JSON 字符串字段,出口幂等解包 + // (对新写入的已解包数据无影响) + return reencodeUnwrapped(c), nil } result, err := fetch() if err == nil { diff --git a/internal/fqapi/catalog.go b/internal/fqapi/catalog.go index 5cbd711..1fbb026 100644 --- a/internal/fqapi/catalog.go +++ b/internal/fqapi/catalog.go @@ -52,8 +52,8 @@ func fetchCatalog(bookID string) (string, error) { delete(data, "catalog_data") } - // 重新序列化 - resultBytes, err := json.Marshal(respJSON) + // 重新序列化(解包 JSON 字符串字段) + resultBytes, err := json.Marshal(UnwrapJSONStrings(respJSON)) if err != nil { return BuildErrorResponse("序列化失败"), err } @@ -130,8 +130,7 @@ func offShelfCatalog(bookID string) (string, error) { }, } - resultBytes, _ := json.Marshal(response) - return string(resultBytes), nil + return marshalNoEscape(UnwrapJSONStrings(response)), nil } // itemMultiDetail 批量获取章节详情 diff --git a/internal/fqapi/category_front.go b/internal/fqapi/category_front.go index a91ad54..9903853 100644 --- a/internal/fqapi/category_front.go +++ b/internal/fqapi/category_front.go @@ -42,7 +42,7 @@ func fetchCategoryFront(tab interface{}) (string, error) { } if code, ok := respJSON["code"].(float64); ok && code == 0 { - return resp.Text, nil + return reencodeUnwrapped(resp.Text), nil } return BuildErrorResponse("获取失败"), nil diff --git a/internal/fqapi/category_landing.go b/internal/fqapi/category_landing.go index 8742bb8..959cf47 100644 --- a/internal/fqapi/category_landing.go +++ b/internal/fqapi/category_landing.go @@ -54,7 +54,7 @@ func fetchCategoryLanding(categoryID, offset, wordNumber, bookStatus, sortBy int } if code, ok := respJSON["code"].(float64); ok && code == 0 { - return resp.Text, nil + return reencodeUnwrapped(resp.Text), nil } return BuildErrorResponse("获取失败"), nil diff --git a/internal/fqapi/content.go b/internal/fqapi/content.go index 2a83314..6e50ede 100644 --- a/internal/fqapi/content.go +++ b/internal/fqapi/content.go @@ -22,9 +22,9 @@ func HandleGetContent(itemID string, textMode, imageMode interface{}) (string, e return `{"code": -1, "msg": "需要参数 item_id"}`, nil } - // 参数转换 - textModeBool := parseBoolParam(textMode) - imageModeBool := parseBoolParamDefault(imageMode, true) + // 参数转换(URL 参数优先,未传时回退配置) + textModeBool := parseBoolParamDefault(textMode, getContentConfig().RemoveHTMLTags) + imageModeBool := parseBoolParamDefault(imageMode, !getContentConfig().RemoveImages) // contentLogger.Info(fmt.Sprintf("获取内容 item_id=%s text_mode=%v image_mode=%v", itemID, textModeBool, imageModeBool)) @@ -50,7 +50,7 @@ func processCachedContent(cache string, textMode, imageMode bool) (string, error } } - return buildJSONResponse(d), nil + return buildJSONResponse(UnwrapJSONStrings(d)), nil } // fetchContentFromAPI 从API获取内容(带设备切换重试) @@ -193,7 +193,7 @@ func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount i parser := NewContentParser() data["content"] = parser.Parse(contentStr, textMode, imageMode) - return buildJSONResponse(body), nil + return buildJSONResponse(UnwrapJSONStrings(body)), nil } // 响应code不为0,切换设备重试 @@ -270,7 +270,7 @@ func tryBackupAPI(itemID string, textMode, imageMode bool) (string, error) { parser := NewContentParser() data["content"] = parser.Parse(decryptedContent, textMode, imageMode) - return buildJSONResponse(body), nil + return buildJSONResponse(UnwrapJSONStrings(body)), nil } contentLogger.Warning("备用接口1获取正文出错!") diff --git a/internal/fqapi/content_parser.go b/internal/fqapi/content_parser.go index 22bdd9e..400dbe4 100644 --- a/internal/fqapi/content_parser.go +++ b/internal/fqapi/content_parser.go @@ -40,15 +40,15 @@ func (p *ContentParser) Parse(content string, textMode bool, imageMode bool) str content = p.removeImageDesc(content) } - // 4. 根据参数或配置移除图片 - // URL参数 image_mode=0 或配置 remove_images=true 时移除图片 - if !imageMode || p.cfg.RemoveImages { + // 4. 根据参数移除图片 + // imageMode=false 时移除(URL 参数与配置 remove_images 已在调用方合成:URL 优先) + if !imageMode { content = p.removeImages(content) } - // 5. 根据参数或配置处理内容模式 - // URL参数 text_mode=1 或配置 remove_html_tags=true 时使用文本模式 - if textMode || p.cfg.RemoveHTMLTags { + // 5. 根据参数处理内容模式 + // textMode=true 时提取纯文本(URL 参数与配置 remove_html_tags 已在调用方合成:URL 优先) + if textMode { return p.extractText(content) } diff --git a/internal/fqapi/content_utils.go b/internal/fqapi/content_utils.go index 3bfca8a..fa6370b 100644 --- a/internal/fqapi/content_utils.go +++ b/internal/fqapi/content_utils.go @@ -1,6 +1,7 @@ package fqapi import ( + "bytes" "encoding/json" "fanqie/internal/config" "sort" @@ -66,11 +67,16 @@ func removeBlankLines(content string) string { // ============ 参数解析工具函数 ============ -// parseBoolParam 解析布尔参数 -func parseBoolParam(param interface{}) bool { +// 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: - return v == "true" || v == "1" || strings.ToLower(v) == "true" + switch strings.ToLower(v) { + case "1", "true", "plain", "keep": + return true + } case bool: return v } @@ -82,7 +88,7 @@ func parseBoolParamDefault(param interface{}, defaultVal bool) bool { if param == nil || param == "" { return defaultVal } - return parseBoolParam(param) + return ParseBoolParam(param) } // ============ JSON 处理工具函数 ============ @@ -100,6 +106,78 @@ func buildJSONResponse(data interface{}) string { 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(从设备池) diff --git a/internal/fqapi/detail.go b/internal/fqapi/detail.go index 485f7d4..c998bc8 100644 --- a/internal/fqapi/detail.go +++ b/internal/fqapi/detail.go @@ -47,8 +47,8 @@ func fetchDetail(bookID string) (string, error) { delete(data, "content") } - // 重新序列化 - resultBytes, err := json.Marshal(respJSON) + // 重新序列化(解包 JSON 字符串字段) + resultBytes, err := json.Marshal(UnwrapJSONStrings(respJSON)) if err != nil { return BuildErrorResponse("序列化失败"), err } diff --git a/internal/fqapi/item_summary.go b/internal/fqapi/item_summary.go index d9946dc..a7ce3fa 100644 --- a/internal/fqapi/item_summary.go +++ b/internal/fqapi/item_summary.go @@ -43,7 +43,7 @@ func fetchItemSummary(itemIDs, bookID string) (string, error) { } if code, ok := respJSON["code"].(float64); ok && code == 0 { - return resp.Text, nil + return reencodeUnwrapped(resp.Text), nil } return BuildErrorResponse("获取失败"), nil diff --git a/internal/fqapi/manga.go b/internal/fqapi/manga.go index 5680549..2d1bf0a 100644 --- a/internal/fqapi/manga.go +++ b/internal/fqapi/manga.go @@ -234,8 +234,11 @@ func decryptAESContent(encrypted, secretKey string) (string, error) { plaintext := make([]byte, len(ciphertext)) mode.CryptBlocks(plaintext, ciphertext) - // PKCS7 unpadding + // PKCS7 unpadding(无效填充说明密钥不匹配,返回错误触发上层重试) plaintext = pkcs7Unpad(plaintext) + if plaintext == nil { + return "", fmt.Errorf("解密失败(无效填充,密钥可能不匹配)") + } // 尝试gzip解压 if len(plaintext) > 2 && plaintext[0] == 0x1f && plaintext[1] == 0x8b { @@ -257,13 +260,20 @@ func decryptAESContent(encrypted, secretKey string) (string, error) { func pkcs7Unpad(data []byte) []byte { length := len(data) if length == 0 { - return data + return nil } unpadding := int(data[length-1]) - if unpadding > length { - return data + // 完整校验:值 1-16 且尾部 unpadding 个字节全等于填充值。 + // 密钥错误时解密结果是随机字节,弱校验会让乱码静默通过 + if unpadding < 1 || unpadding > 16 || unpadding > length { + return nil } - return data[:(length - unpadding)] + for _, b := range data[length-unpadding:] { + if int(b) != unpadding { + return nil + } + } + return data[:length-unpadding] } // buildMangaResponse 构建漫画响应(统一HTML和JSON格式输出) diff --git a/internal/fqapi/multi_content.go b/internal/fqapi/multi_content.go index c88f0fb..8bb2692 100644 --- a/internal/fqapi/multi_content.go +++ b/internal/fqapi/multi_content.go @@ -82,9 +82,9 @@ func handleDecryptTask(itemID string, itemData map[string]interface{}, keyInfo * // MultiContent 批量获取内容 func MultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignoreFailed interface{}, depth int) (string, error) { - // 参数转换 - textModeBool := parseBoolParam(textMode) - imageModeBool := parseBoolParamDefault(imageMode, true) + // 参数转换(URL 参数优先,未传时回退配置) + textModeBool := parseBoolParamDefault(textMode, getContentConfig().RemoveHTMLTags) + imageModeBool := parseBoolParamDefault(imageMode, !getContentConfig().RemoveImages) ignoreFailedBool := parseBoolParamDefault(ignoreFailed, true) if depth > 3 { @@ -256,7 +256,7 @@ func MultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignor // 构建响应 response := buildCacheData(results) - return buildJSONResponse(response), nil + return buildJSONResponse(UnwrapJSONStrings(response)), nil } // 响应code不为0,切换设备重试 diff --git a/internal/fqapi/multi_detail.go b/internal/fqapi/multi_detail.go index 7fc4176..c5ff2d3 100644 --- a/internal/fqapi/multi_detail.go +++ b/internal/fqapi/multi_detail.go @@ -1,7 +1,6 @@ package fqapi import ( - "encoding/json" "fanqie/internal/common" "fmt" "strings" @@ -215,6 +214,5 @@ func fetchMultiDetail(bookID interface{}) (string, error) { "data": res, } - resultBytes, _ := json.Marshal(response) - return string(resultBytes), nil + return marshalNoEscape(UnwrapJSONStrings(response)), nil } diff --git a/internal/fqapi/register_key.go b/internal/fqapi/register_key.go index fb30e06..164c050 100644 --- a/internal/fqapi/register_key.go +++ b/internal/fqapi/register_key.go @@ -70,11 +70,17 @@ func createSymmetricCrypto(algorithm string, key, iv []byte) (func([]byte) ([]by decrypted := make([]byte, len(data)) mode.CryptBlocks(decrypted, data) - // 移除PKCS7填充 + // 移除PKCS7填充(完整校验:密钥错误时尾部为随机字节, + // 弱校验会让乱码静默通过并返回 200) padding := int(decrypted[len(decrypted)-1]) - if padding > len(decrypted) || padding > aes.BlockSize { + if padding < 1 || padding > len(decrypted) || padding > aes.BlockSize { return nil, fmt.Errorf("无效的填充") } + for _, b := range decrypted[len(decrypted)-padding:] { + if int(b) != padding { + return nil, fmt.Errorf("无效的填充") + } + } return decrypted[:len(decrypted)-padding], nil } diff --git a/internal/fqapi/review.go b/internal/fqapi/review.go index 53eae5b..3cb5892 100644 --- a/internal/fqapi/review.go +++ b/internal/fqapi/review.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fanqie/internal/common" "fmt" + "regexp" + "strconv" "strings" ) @@ -62,8 +64,11 @@ func postReviewAPI(url string, requestBody interface{}) (map[string]interface{}, return nil, fmt.Errorf("解析响应失败: %w", err) } - // 提取 data 字段 + // 提取 data 字段(并解包 JSON 字符串字段,如 post_data.content) if data, ok := result["data"].(map[string]interface{}); ok { + if unwrapped, ok := UnwrapJSONStrings(data).(map[string]interface{}); ok { + return unwrapped, nil + } return data, nil } @@ -169,8 +174,11 @@ func GetParaReview(itemID, bookID, commentID string, para, sort, offset, count i return nil, fmt.Errorf("解析响应失败: %w", err) } - // 提取 data 字段 + // 提取 data 字段(并解包 JSON 字符串字段,如 post_data.content) if data, ok := result["data"].(map[string]interface{}); ok { + if unwrapped, ok := UnwrapJSONStrings(data).(map[string]interface{}); ok { + return unwrapped, nil + } return data, nil } @@ -235,103 +243,33 @@ func GenerateParagraphsWithComments(itemID, bookID, content, baseURL string) (st strings.Contains(content, "标签) - // 因为标签需要XML兼容的格式 - hasAnyComments := len(commentData) > 0 - if hasAnyComments && !isHTMLContent { - isHTMLContent = true + // 纯文本模式下 标签无法承载,保持纯文本原样返回(不注入段评); + // 需要段评标记时配置 remove_html_tags: false 或 text_mode=html + if !isHTMLContent { + return content, nil } - lines := strings.Split(content, "\n") - var result strings.Builder - paragraphIndex := 0 - - for i, line := range lines { - isLastLine := i == len(lines)-1 - trimmedLine := strings.TrimSpace(line) - - // 完全空行:直接输出换行符 - if trimmedLine == "" { - if !isLastLine { - result.WriteString("\n") - } - continue - } - - // 跳过图片标签和div标签 - if strings.HasPrefix(trimmedLine, "") || - strings.HasPrefix(trimmedLine, "标签,在

之前插入段评标记 - if strings.HasPrefix(trimmedLine, "

") && strings.HasSuffix(trimmedLine, "

") { - // 提取

和

之间的内容 - innerContent := trimmedLine[3 : len(trimmedLine)-4] - result.WriteString("

") - result.WriteString(innerContent) - - // 添加评论标记 - key := fmt.Sprintf("%d", paragraphIndex) - if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 { - commentTag := fmt.Sprintf(``, - baseURL, itemID, bookID, paragraphIndex, commentInfo.Count) - result.WriteString(commentTag) - } - - result.WriteString("

") - if !isLastLine { - result.WriteString("\n") - } - paragraphIndex++ - continue - } - - // 如果是HTML模式,包裹

标签 - if isHTMLContent { - result.WriteString("

") - result.WriteString(line) - - // 添加评论标记 - key := fmt.Sprintf("%d", paragraphIndex) - if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 { - commentTag := fmt.Sprintf(``, - baseURL, itemID, bookID, paragraphIndex, commentInfo.Count) - result.WriteString(commentTag) - } - - result.WriteString("

") - if !isLastLine { - result.WriteString("\n") - } - } else { - // 纯文本模式:只在行尾添加comment标记 - result.WriteString(line) - - // 添加评论标记(纯文本格式) - key := fmt.Sprintf("%d", paragraphIndex) - if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 { - commentTag := fmt.Sprintf(``, - baseURL, itemID, bookID, paragraphIndex, commentInfo.Count) - result.WriteString(commentTag) - } - - if !isLastLine { - result.WriteString("\n") + // HTML 内容:在每个

前插入段评标记。 + // 兼容

(HTML 模式结构)与无属性的

;段号取 idx,无 idx 时按出现顺序。 + // header/article/div/img 等非段落标签不受影响。 + pRe := regexp.MustCompile(`(?s)]*>.*?

`) + idxRe := regexp.MustCompile(`idx="(\d+)"`) + order := 0 + return pRe.ReplaceAllStringFunc(content, func(m string) string { + defer func() { order++ }() + para := order + if idx := idxRe.FindStringSubmatch(m); idx != nil { + if n, err := strconv.Atoi(idx[1]); err == nil { + para = n } } - - paragraphIndex++ - } - - return result.String(), nil + if commentInfo, exists := commentData[fmt.Sprintf("%d", para)]; exists && commentInfo.Count > 0 { + commentTag := fmt.Sprintf(``, + baseURL, itemID, bookID, para, commentInfo.Count) + return m[:len(m)-len("

")] + commentTag + "

" + } + return m + }), nil } // extractDeviceID 从 device header 中提取 device_id diff --git a/internal/fqapi/search.go b/internal/fqapi/search.go index 152e392..52f81a6 100644 --- a/internal/fqapi/search.go +++ b/internal/fqapi/search.go @@ -69,6 +69,8 @@ func fetchBookSearch(key string, offset int, tabType int) (string, error) { return resp.Text, nil // 解析失败时退回原样转发 } filterSearchTabs(numJSON, tabType) + unwrapSearchTab(numJSON) + UnwrapJSONStrings(numJSON) return ToJSON(numJSON) } @@ -97,3 +99,27 @@ func filterSearchTabs(respJSON map[string]interface{}, tabType int) { respJSON["search_tabs"] = filtered } } + +// unwrapSearchTab 将过滤后的唯一 tab 展平:去掉 search_tabs 包装, +// tab 的 data 直接作为顶层 data,并提升分页字段 has_more/next_offset +func unwrapSearchTab(respJSON map[string]interface{}) { + tabs, ok := respJSON["search_tabs"].([]interface{}) + if !ok || len(tabs) != 1 { + return + } + tab, ok := tabs[0].(map[string]interface{}) + if !ok { + return + } + delete(respJSON, "search_tabs") + delete(respJSON, "selected_tab_idx") // tab 已展平,索引无意义 + delete(respJSON, "server_ab_params") // 上游 AB 实验参数,透传无意义 + if data, ok := tab["data"]; ok { + respJSON["data"] = data + } + for _, k := range []string{"has_more", "next_offset"} { + if v, ok := tab[k]; ok { + respJSON[k] = v + } + } +} diff --git a/internal/fqapi/video.go b/internal/fqapi/video.go index 2d7f825..fdd98aa 100644 --- a/internal/fqapi/video.go +++ b/internal/fqapi/video.go @@ -95,8 +95,7 @@ func HandleVideo(itemIDs interface{}) (string, error) { "data": decodedModel, } - resultBytes, _ := json.Marshal(response) - return string(resultBytes), nil + return marshalNoEscape(response), nil } else { // 批量接口:data 是一个map,key是ID,value包含video_model result := make(map[string]interface{}) @@ -119,8 +118,7 @@ func HandleVideo(itemIDs interface{}) (string, error) { "data": result, } - resultBytes, _ := json.Marshal(response) - return string(resultBytes), nil + return marshalNoEscape(response), nil } } } @@ -144,6 +142,10 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} { // 解析失败,返回空对象 return map[string]interface{}{} } + // 解包嵌套的 JSON 字符串字段(category_schema、recommend_info 等) + if unwrapped, ok := UnwrapJSONStrings(videoModel).(map[string]interface{}); ok { + videoModel = unwrapped + } // 构建精简的返回结果,只包含关键字段 result := make(map[string]interface{}) @@ -158,6 +160,10 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} { if posterURL, ok := videoModel["poster_url"].(string); ok { result["poster_url"] = posterURL } + // 加密信息:视频为 CENC 加密(encrypt=true),解密需要 key_seed/kid + if keySeed, ok := videoModel["key_seed"].(string); ok { + result["key_seed"] = keySeed + } // 处理 video_list if videoList, ok := videoModel["video_list"].(map[string]interface{}); ok { @@ -189,6 +195,15 @@ func decodeVideoModelAsJSON(videoModelStr string) interface{} { if fps, ok := qualityMap["fps"].(float64); ok { qualityInfo["fps"] = int(fps) } + if encrypt, ok := qualityMap["encrypt"].(bool); ok && encrypt { + qualityInfo["encrypt"] = true + if em, ok := qualityMap["encryption_method"].(string); ok { + qualityInfo["encryption_method"] = em + } + if kid, ok := qualityMap["kid"].(string); ok { + qualityInfo["kid"] = kid + } + } // 解码 main_url if mainURL, ok := qualityMap["main_url"].(string); ok { diff --git a/internal/handlers/common.go b/internal/handlers/common.go index eb43981..3d38992 100644 --- a/internal/handlers/common.go +++ b/internal/handlers/common.go @@ -103,6 +103,14 @@ func StopStatisticsSaver() { } } +// getQueryWithAlias 读 URL 参数,主名未传时回退到别名(兼容 README 历史参数名) +func getQueryWithAlias(r *http.Request, name, alias string) string { + if v := r.URL.Query().Get(name); v != "" { + return v + } + return r.URL.Query().Get(alias) +} + // respondError 统一错误响应(JSON 安全编码;isClientErr 为 true 时返回 400,否则 500) func respondError(w http.ResponseWriter, msg string, isClientErr bool) { status := http.StatusInternalServerError diff --git a/internal/handlers/content.go b/internal/handlers/content.go index 5f1f3ae..d35b356 100644 --- a/internal/handlers/content.go +++ b/internal/handlers/content.go @@ -19,10 +19,10 @@ func HandleContent(w http.ResponseWriter, r *http.Request) { } itemID := params["item_id"] - // 获取URL参数(支持动态覆盖配置) - textMode := r.URL.Query().Get("text_mode") // 1=纯文本模式 - imageMode := r.URL.Query().Get("image_mode") // 0=移除图片, 1=保留图片 - reviewParam := r.URL.Query().Get("review") // 1=启用段评, 0=禁用段评 + // 获取URL参数(支持动态覆盖配置;image_mode/review 兼容 README 的 img_mode/review_mode 别名) + textMode := r.URL.Query().Get("text_mode") // plain/1=纯文本, html/0=HTML + imageMode := getQueryWithAlias(r, "image_mode", "img_mode") + reviewParam := getQueryWithAlias(r, "review", "review_mode") // keep/1=启用段评, remove/0=禁用段评 result, err := fqapi.HandleGetContent(itemID, textMode, imageMode) if err != nil { @@ -32,17 +32,15 @@ func HandleContent(w http.ResponseWriter, r *http.Request) { // 判断是否启用段评功能 // 优先使用URL参数,如果没有则使用配置文件 - enableReview := false - if reviewParam == "1" { - enableReview = true - } else if reviewParam == "0" { - enableReview = false - } else { + var enableReview bool + if reviewParam == "" { // 没有URL参数时,使用配置文件 cfg := config.GetConfig() if cfg != nil { enableReview = cfg.Fanqie.Content.EnableReview } + } else { + enableReview = fqapi.ParseBoolParam(reviewParam) } // 如果启用了段评功能,处理正文内容添加段评标记 @@ -65,7 +63,7 @@ func HandleMultiContent(w http.ResponseWriter, r *http.Request) { } textMode := r.URL.Query().Get("text_mode") - imageMode := r.URL.Query().Get("image_mode") + imageMode := getQueryWithAlias(r, "image_mode", "img_mode") ignoreFailed := r.URL.Query().Get("ignore_failed") result, err := fqapi.HandleMultiContent(params["item_ids"], params["book_id"], textMode, imageMode, ignoreFailed) diff --git a/internal/handlers/gzip.go b/internal/handlers/gzip.go new file mode 100644 index 0000000..eb25e08 --- /dev/null +++ b/internal/handlers/gzip.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "compress/gzip" + "net/http" + "strings" +) + +// Gzip 对 JSON/文本响应启用 gzip 压缩(仅客户端声明 Accept-Encoding: gzip 时)。 +// 图片/音频等二进制响应不在白名单内,原样透传。 +func Gzip(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + next.ServeHTTP(w, r) + return + } + gw := &gzipWriter{ResponseWriter: w} + defer gw.close() + next.ServeHTTP(gw, r) + }) +} + +type gzipWriter struct { + http.ResponseWriter + zw *gzip.Writer + decided bool // 是否已根据 Content-Type 做过压缩决定 + headerSent bool +} + +func (g *gzipWriter) WriteHeader(status int) { + if g.headerSent { + return + } + if !g.decided { + g.decide() + } + g.ResponseWriter.WriteHeader(status) + g.headerSent = true +} + +func (g *gzipWriter) Write(b []byte) (int, error) { + if !g.headerSent { + // handler 未显式 WriteHeader(fmt.Fprint 直接写)时的隐式 200 + if !g.decided { + g.decide() + } + g.ResponseWriter.WriteHeader(http.StatusOK) + g.headerSent = true + } + if g.zw != nil { + return g.zw.Write(b) + } + return g.ResponseWriter.Write(b) +} + +// decide 在首个响应头确定时判断是否压缩(此时 handler 已设置 Content-Type) +func (g *gzipWriter) decide() { + g.decided = true + ct := g.Header().Get("Content-Type") + for _, prefix := range []string{"application/json", "text/html", "text/plain"} { + if strings.HasPrefix(ct, prefix) { + g.Header().Set("Content-Encoding", "gzip") + g.Header().Del("Content-Length") + g.zw = gzip.NewWriter(g.ResponseWriter) + return + } + } +} + +func (g *gzipWriter) close() { + if g.zw != nil { + g.zw.Close() + } +} diff --git a/openapi.yaml b/openapi.yaml index 798eec0..7353155 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -98,7 +98,7 @@ paths: required: true description: 搜索关键字(别名 `key`) schema: { type: string } - example: 斗罗大陆 + example: 综漫 - name: key in: query description: "`query` 的别名,`query` 为空时生效" @@ -110,17 +110,38 @@ paths: example: 0 - name: tab_type in: query - description: 搜索类型,默认 3;响应 search_tabs 仅保留匹配该值的 tab - schema: { type: integer, default: 3 } + description: | + 搜索类型,默认 3;仅返回匹配该值的 tab: + * `1` 综合(混合:书籍 + 运营位 cell_data + 猜你喜欢 guess_you_like_data) + * `2` 听书(book_data) + * `3` 书籍(book_data,默认) + * `4` 社区(topic_data) + * `5` 全文(book_data) + * `6` 用户(search_user_data) + * `8` 漫画(book_data) + * `11` 短剧(video_data) + schema: { type: integer, default: 3, enum: [1, 2, 3, 4, 5, 6, 8, 11] } example: 3 responses: "200": - description: 搜索结果(search_tabs 已按请求的 tab_type 过滤,仅保留匹配项) + description: 搜索结果(tab 已展平:结果块在顶层 data,并提升 has_more/next_offset 分页字段) content: application/json: schema: - $ref: "#/components/schemas/UpstreamResponse" - example: { code: 0, msg: success, data: { search_book_data_list: [], has_more: 1 } } + $ref: "#/components/schemas/SearchResponse" + example: + code: 0 + message: SUCCESS + data: + - book_data: + - book_id: "7668727260244872254" + book_name: 综漫:今天的告白,我也很期待哦 + author: 一念1浮生 + abstract: (评分五天从7.5涨到9.0,真的不看看吗,前期有点青涩,往后会好一点)…… + category: 动漫衍生 + word_number: "357281" + has_more: 1 + next_offset: 10 "400": $ref: "#/components/responses/BadRequest" @@ -165,13 +186,18 @@ paths: /catalog: get: tags: [书籍] - summary: 获取章节目录 + summary: 获取章节目录(book_id → 每章 item_id) + description: | + 传入 book_id,返回全部章节列表 `data.item_data_list[]`, + 每项含 `item_id`、`title`、`chapter_word_number`、`volume_name`。 + item_id 用于调用 /content、/multi-content、/item-summary、/audio、/manga、/video 等 + 需要 item_id 的接口;漫画/短剧的每集(话)item_id 也从这里获取。 operationId: getBookCatalog parameters: - $ref: "#/components/parameters/BookId" responses: "200": - description: 章节目录(上游转发) + description: 章节目录(上游转发,含每章 item_id) content: application/json: schema: { $ref: "#/components/schemas/UpstreamResponse" } @@ -226,8 +252,10 @@ paths: tags: [内容] summary: 获取章节正文 description: | - 返回章节正文。启用段评(`review=1` 或配置 `fanqie.content.enable_review: true`)时, - 正文 HTML 中会注入段评标记。 + 返回章节正文。启用段评(`review=keep` 或配置 `fanqie.content.enable_review: true`)时, + 正文 HTML 中会注入段评标记;纯文本输出(text_mode=plain 或配置 remove_html_tags: true) + 时不注入段评标记( 为 XML 标签,纯文本无法承载)。 + URL 参数优先于配置文件。 operationId: getItemContent parameters: - name: item_id @@ -238,18 +266,18 @@ paths: example: "7271032589123412345" - name: text_mode in: query - description: 纯文本模式,1/true=纯文本,0/false=HTML - schema: { type: string, enum: ["0", "1", "true", "false"] } - example: "0" + description: 文本模式,plain/1/true=纯文本,html/0/false=HTML;缺省用配置 remove_html_tags + schema: { type: string, enum: ["plain", "html", "0", "1", "true", "false"] } + example: "plain" - name: image_mode in: query - description: 图片处理,1/true=保留图片,0/false=移除图片 - schema: { type: string, enum: ["0", "1", "true", "false"] } - example: "1" + description: 图片处理,keep/1/true=保留,remove/0/false=移除(别名 `img_mode`);缺省用配置 remove_images 取反 + schema: { type: string, enum: ["keep", "remove", "0", "1", "true", "false"] } + example: "keep" - name: review in: query - description: 段评开关,1=启用,0=禁用;缺省时使用配置文件 - schema: { type: string, enum: ["0", "1"] } + description: 段评开关,keep/1=启用,remove/0=禁用(别名 `review_mode`);缺省用配置 enable_review + schema: { type: string, enum: ["keep", "remove", "0", "1"] } responses: "200": description: 章节正文 @@ -291,12 +319,12 @@ paths: example: "7271032589123412345,7271032589123412346" - name: text_mode in: query - description: 纯文本模式,1/true=纯文本 - schema: { type: string, enum: ["0", "1", "true", "false"] } + description: 文本模式,plain/1/true=纯文本,html/0/false=HTML;缺省用配置 remove_html_tags + schema: { type: string, enum: ["plain", "html", "0", "1", "true", "false"] } - name: image_mode in: query - description: 图片处理,1/true=保留 - schema: { type: string, enum: ["0", "1", "true", "false"] } + description: 图片处理,keep/1/true=保留,remove/0/false=移除(别名 `img_mode`);缺省用配置 remove_images 取反 + schema: { type: string, enum: ["keep", "remove", "0", "1", "true", "false"] } - name: ignore_failed in: query description: 忽略失败章节,1/true=在结果中跳过而非报错 @@ -381,12 +409,24 @@ paths: example: 0 - name: book_id in: query - description: 书籍 ID + description: 书籍 ID(实际必传,缺省时上游返回 101003 错误) schema: { type: string } - name: comment_id in: query description: 评论 ID(获取回复时需要) schema: { type: string } + - name: sort + in: query + description: 排序方式,默认 1(热门) + schema: { type: integer, default: 1 } + - name: offset + in: query + description: 分页偏移量,默认 0 + schema: { type: integer, default: 0, minimum: 0 } + - name: count + in: query + description: 每页数量,默认 20 + schema: { type: integer, default: 20, minimum: 1 } responses: "200": description: 评论/回复数据 @@ -416,6 +456,22 @@ paths: description: 段落索引(从 0 开始) schema: { type: integer, minimum: 0 } example: 0 + - name: book_id + in: query + description: 书籍 ID + schema: { type: string } + - name: page + in: query + description: 页码,默认 1 + schema: { type: integer, default: 1, minimum: 1 } + - name: page_size + in: query + description: 每页数量,默认 20 + schema: { type: integer, default: 20, minimum: 1 } + - name: sort + in: query + description: 排序方式,默认 1(热门) + schema: { type: integer, default: 1 } responses: "200": description: 评论 HTML 页面 @@ -492,12 +548,17 @@ paths: get: tags: [多媒体] summary: 获取视频信息 + description: | + 短剧视频流为 CENC 加密(encrypt=true, cenc-aes-ctr, HEVC+AAC): + 播放 URL 可直接下载,但文件本身加密,通用播放器无法解码。 + 响应透传加密状态字段:顶层 key_seed(Base64,每次请求变化)、 + video_list.*.encrypt/encryption_method/kid,供需要对接官方播放器的调用方使用。 operationId: getVideo parameters: - name: item_ids in: query required: true - description: 章节 ID + description: 视频 ID,多个用逗号分隔;短剧每集的 item_id 从 /catalog 获取 schema: { type: string } responses: "200": @@ -517,9 +578,15 @@ paths: description: | 返回漫画章节内容,图片经解密处理。 输出模式 `output` 优先于配置 `fanqie.manga.output_mode`: - - `url`: 返回原始图片 URL 数组 - - `proxy`: 返回本地代理 URL(/manga/image/{item_id}/{index}) + - `url`: 返回原始图片 URL 数组(不可用:链接 404 且内容加密,仅作上游原始信息展示) + - `proxy`: 返回本地代理 URL(/manga/image/{item_id}/{index})——推荐,先调本接口再访问代理 URL(缓存 5 分钟) - `data_uri`: 返回 Base64 编码(data:image/jpeg;base64,...) + + CDN 链接说明(全接口通用):带 `x-signature` 签名的图片/音视频 URL 可直接 GET, + 但不可修改 URL 任何部分(改尺寸模板/去参数即 403,签名覆盖整个 path)。 + `x-expires` 为过期时间戳:图片约 30 天,音频约 2 小时,短剧视频约 6 小时, + 过期后重新调用相应接口获取新 URL。无签名的 `audio_thumb_uri`/`horiz_thumb_url` 字段 + 为上游占位链接,不可用;封面请使用 `thumb_url`/`audio_thumb_url_hd`。 operationId: getManga parameters: - name: item_ids @@ -619,22 +686,30 @@ paths: get: tags: [分类] summary: 获取分类详情页 - description: 支持按分类筛选、排序与分页的书籍列表。 + description: 支持按分类筛选、排序与分页的书籍列表(每页固定 20 条)。 operationId: getCategoryLanding parameters: - name: category_id in: query required: true - description: 分类 ID + description: 分类 ID(可从 /category-front 或搜索结果 book_data.category_schema 中解析) schema: { type: string } - name: offset in: query description: 偏移量,默认 0 schema: { type: integer, default: 0, minimum: 0 } - - name: limit + - name: word_number in: query - description: 每页数量,默认 20 - schema: { type: integer, default: 20, minimum: 1 } + description: 字数筛选,透传上游(0=不限) + schema: { type: integer, default: 0 } + - name: book_status + in: query + description: 连载状态筛选,透传上游(0=不限) + schema: { type: integer, default: 0 } + - name: sort_by + in: query + description: 排序方式,透传上游(0=默认) + schema: { type: integer, default: 0 } responses: "200": description: 分类书籍列表(上游转发) @@ -736,6 +811,67 @@ components: type: string description: 错误信息 + SearchResponse: + type: object + description: | + 搜索响应(已展平:原上游 `search_tabs[0]` 中的 `data` 提升为顶层 `data`, + 并提升分页字段 `has_more`/`next_offset`;`search_tabs` 字段不再返回)。 + required: [code, data] + properties: + code: + type: integer + description: 0=成功 + example: 0 + message: + type: string + example: SUCCESS + data: + type: array + description: | + 搜索结果块,单个元素的 *_data 字段由 tab_type 决定: + `book_data`(tab_type 1/2/3/5/8:书籍/听书/全文/漫画,含 book_id/book_name/author/abstract 等)、 + `topic_data`(tab_type 4 社区,含 title/comment)、 + `search_user_data`(tab_type 6 用户,含 encode_user_id/is_author)、 + `video_data`(tab_type 11 短剧)、 + `cell_data`/`guess_you_like_data`(tab_type 1 的运营位与猜你喜欢)。 + 所有元素均附带 book_hot_content_data 等上游字段,以实际返回为准。 + items: + type: object + properties: + book_data: + type: array + description: 书籍结果(tab_type 1/2/3/5) + items: { type: object } + topic_data: + type: array + description: 话题结果(tab_type 4) + items: { type: object } + search_user_data: + type: array + description: 用户结果(tab_type 6) + items: { type: object } + video_data: + type: array + description: 短剧结果(tab_type 11) + items: { type: object } + cell_data: + type: array + description: 运营位(tab_type 1 混合出现) + items: { type: object } + guess_you_like_data: + type: array + description: 猜你喜欢(tab_type 1 混合出现) + items: { type: object } + has_more: + type: integer + description: 是否还有更多结果(1=有) + example: 1 + next_offset: + type: integer + description: 下一页 offset 值 + example: 10 + log_id: + type: string UpstreamResponse: type: object description: |