- HTML 提取至 templates/status.html、templates/review.html(go:embed 内嵌) - status.go 1022→271 行、review.go 1281→438 行,动态数据全部经 html/template 按上下文自动转义 - 评论循环/作者徽章/贴纸/图片条件渲染翻译为模板 range/if;emojiMap 以 template.JS 注入 - 与旧版渲染输出实测对比:22 条评论结构完全一致;差异仅为 JS 上下文安全转义(& 等)与 html/template 剥除 CSS 注释,均无功能影响 - 其余文件为 gofmt 顺手格式化
273 lines
7.5 KiB
Go
273 lines
7.5 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) {
|
|
// 参数转换
|
|
textModeBool := parseBoolParam(textMode)
|
|
imageModeBool := parseBoolParamDefault(imageMode, true)
|
|
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(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)
|
|
}
|