Files
fanqie-go/internal/fqapi/multi_content.go
T
chenxin 1a4997e031 ✨ feat(api): 统一响应格式并完善参数体系与文档
- 正文参数支持语义值(plain/html/keep/remove)与别名(img_mode/review_mode),URL 参数优先于配置
- 修复纯文本被段评强制包回 HTML、HTML 模式段评注入失效的问题
- 修复 PKCS7 填充弱校验导致漫画/正文偶发解密乱码返回 200 的问题
- 搜索结果展平:去 search_tabs 层与 selected_tab_idx/server_ab_params,提升 has_more/next_offset
- 全接口解包字符串形式的 JSON 字段(category_schema 等),缓存出口幂等处理
- 序列化关闭 HTML 转义,video/audio 播放链接可直接复制播放
- video 响应透传 CENC 加密状态(key_seed/kid/encryption_method)
- 新增 gzip 中间件,JSON/文本响应压缩约 81%,二进制透传
- 漫画默认输出模式改为 proxy(url 模式链接 404 且内容加密不可用)
- openapi.yaml:补全 23 个路径参数定义、tab_type 完整枚举、CDN 签名说明,示例更新
2026-10-09 21:01:18 +08:00

273 lines
7.6 KiB
Go

package fqapi
import (
"bytes"
"compress/zlib"
"encoding/base64"
"encoding/json"
"fanqie/internal/common"
"fmt"
"io"
"sort"
"strings"
"sync"
)
var (
multiContentLogger = common.NewLogger("multi_content", "INFO", "")
)
// handleDecryptTask 处理解密任务
func handleDecryptTask(itemID string, itemData map[string]interface{}, keyInfo *KeyInfo, textMode, imageMode bool) (string, map[string]interface{}) {
if code, ok := itemData["code"].(float64); !ok || code != 0 {
return itemID, itemData
}
contentEncoded, ok := itemData["content"].(string)
if !ok {
return itemID, itemData
}
// 解码content
contentBytes, err := base64.StdEncoding.DecodeString(contentEncoded)
if err != nil || len(contentBytes) < 16 {
return itemID, itemData
}
iv := contentBytes[0:16]
encryptedData := contentBytes[16:]
// 解密
if keyInfo == nil {
return itemID, itemData
}
keyBytes, _ := base64.StdEncoding.DecodeString(keyInfo.Key)
decrypted, err := De(encryptedData, keyBytes, iv)
if err != nil {
return itemID, itemData
}
// 检查是否需要解压
compressStatus, _ := itemData["compress_status"].(float64)
if compressStatus != 0 {
// zlib解压
reader, err := zlib.NewReader(bytes.NewReader(decrypted))
if err == nil {
decompressed, err := io.ReadAll(reader)
reader.Close()
if err == nil {
decrypted = decompressed
}
}
}
// 更新内容
bodyCopy := make(map[string]interface{})
for k, v := range itemData {
bodyCopy[k] = v
}
bodyCopy["content"] = string(decrypted)
// 缓存
cacheData := buildCacheData(bodyCopy)
saveCacheJSON("content_"+itemID, cacheData, 86400)
// 解析内容
parser := NewContentParser()
bodyCopy["content"] = parser.Parse(string(decrypted), textMode, imageMode)
return itemID, bodyCopy
}
// MultiContent 批量获取内容
func MultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignoreFailed interface{}, depth int) (string, error) {
// 参数转换(URL 参数优先,未传时回退配置)
textModeBool := parseBoolParamDefault(textMode, getContentConfig().RemoveHTMLTags)
imageModeBool := parseBoolParamDefault(imageMode, !getContentConfig().RemoveImages)
ignoreFailedBool := parseBoolParamDefault(ignoreFailed, true)
if depth > 3 {
return BuildErrorResponse("获取失败,请重试!"), nil
}
if bookID == "" {
return BuildErrorResponse("需要book_id!"), nil
}
if itemIDs == nil || itemIDs == "" {
return BuildSuccessResponse([]interface{}{}), nil
}
// 转换itemIDs
var itemIDStr string
switch v := itemIDs.(type) {
case string:
itemIDStr = v
case []string:
itemIDStr = strings.Join(v, ",")
default:
itemIDStr = fmt.Sprintf("%v", v)
}
// 从设备密钥池获取设备
pool := GetDeviceKeyPool()
item, err := pool.GetNext()
if err != nil {
return `{"code": -1, "msg": "从设备池获取设备失败"}`, err
}
header := item.DeviceHeader
currentKeyInfo := &KeyInfo{
Key: item.Key,
Ver: item.KeyVer,
}
multiContentLogger.Debug(fmt.Sprintf("使用池中设备 ID=%d (depth: %d)", item.ID, depth))
// 构建参数
headerWithoutVersion := removeVersionFromHeader(header)
params := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"os_version=0",
"version_code=66.9",
"book_id=" + bookID,
"item_ids=" + itemIDStr,
"novel_text_type=1",
"req_type=1",
headerWithoutVersion,
}
allParams := strings.Join(params, "&")
paramList := strings.Split(allParams, "&")
sort.Strings(paramList)
sortedParams := strings.Join(paramList, "&")
url := "https://reading.snssdk.com/reading/reader/batch_full/v/?" + sortedParams
resp, err := ContentRequest(url, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
multiContentLogger.Warning(fmt.Sprintf("批量请求失败,标记设备无效并切换设备 (depth: %d): %v", depth, err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
var data map[string]interface{}
json.Unmarshal(resp.Content, &data)
if code, ok := data["code"].(float64); ok && code == 0 {
dataMap, ok := data["data"].(map[string]interface{})
if !ok {
multiContentLogger.Warning(fmt.Sprintf("响应data字段无效,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// 检查是否有Invalid内容或密钥版本不匹配
needRetry := false
for _, itemData := range dataMap {
itemMap, ok := itemData.(map[string]interface{})
if !ok {
continue
}
itemCode, _ := itemMap["code"].(float64)
if itemCode != 0 && !ignoreFailedBool {
multiContentLogger.Warning(fmt.Sprintf("item code异常: %.0f,切换设备重试 (depth: %d)", itemCode, depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
if content, ok := itemMap["content"].(string); ok && content == "Invalid" {
// 标记设备无效并移除
multiContentLogger.Warning(fmt.Sprintf("内容返回 Invalid,标记设备无效并切换设备 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
needRetry = true
break
}
if keyVersion, ok := itemMap["key_version"].(float64); ok {
if currentKeyInfo != nil && int(keyVersion) != currentKeyInfo.Ver {
// 更新密钥版本
multiContentLogger.Warning("密钥版本不匹配,重新获取密钥")
newKeyInfo, err := GetKey(header)
if err == nil {
UpdateKeyInPool(header, newKeyInfo.Key, newKeyInfo.Ver)
} else {
// 获取密钥失败,切换设备
multiContentLogger.Warning(fmt.Sprintf("获取密钥失败,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
}
needRetry = true
break
}
}
}
if needRetry {
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// 并发解密
results := make(map[string]map[string]interface{})
var mutex sync.Mutex
var wg sync.WaitGroup
for itemID, itemData := range dataMap {
itemMap, ok := itemData.(map[string]interface{})
if !ok {
continue
}
wg.Add(1)
go func(id string, data map[string]interface{}) {
defer wg.Done()
_, decrypted := handleDecryptTask(id, data, currentKeyInfo, textModeBool, imageModeBool)
mutex.Lock()
results[id] = decrypted
mutex.Unlock()
}(itemID, itemMap)
}
wg.Wait()
// 检查结果数量
itemCount := len(strings.Split(itemIDStr, ","))
if len(results) == 1 && itemCount != 1 {
multiContentLogger.Warning(fmt.Sprintf("结果数量异常,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// 构建响应
response := buildCacheData(results)
return buildJSONResponse(UnwrapJSONStrings(response)), nil
}
// 响应code不为0,切换设备重试
multiContentLogger.Warning(fmt.Sprintf("响应code异常,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// HandleMultiContent 处理批量获取内容
func HandleMultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignoreFailed interface{}) (string, error) {
return MultiContent(itemIDs, bookID, textMode, imageMode, ignoreFailed, 0)
}