package fqapi import ( "encoding/base64" "encoding/json" "fanqie/internal/common" "fmt" "strings" "golang.org/x/sync/singleflight" ) var ( contentLogger = common.NewLogger("content", "INFO", "") contentSF singleflight.Group // 合并同一章节的并发回源请求,防缓存击穿 ) // ContentFetcher 内容获取器 type ContentFetcher struct{} // HandleGetContent 处理获取内容 func HandleGetContent(itemID string, textMode, imageMode interface{}) (string, error) { if itemID == "" { return `{"code": -1, "msg": "需要参数 item_id"}`, nil } // 参数转换(URL 参数优先,未传时回退配置) textModeBool := parseBoolParamDefault(textMode, getContentConfig().RemoveHTMLTags) imageModeBool := parseBoolParamDefault(imageMode, !getContentConfig().RemoveImages) cache := GetCache("content_" + itemID) if cache == "" { // 同一章节并发请求只打一次上游,其余等待共享结果 v, err, _ := contentSF.Do("content_"+itemID, func() (interface{}, error) { // 双重检查:等锁期间可能已被并发请求填充 if c := GetCache("content_" + itemID); c != "" { return c, nil } return fetchContentFromAPI(itemID) }) if err != nil { return "", err } cache = v.(string) } // 出口按各自参数解析(缓存/回源共用同一路径) return processCachedContent(cache, textModeBool, imageModeBool) } // processCachedContent 处理缓存内容(content 为解密后的原始正文) func processCachedContent(cache string, textMode, imageMode bool) (string, error) { var d map[string]interface{} json.Unmarshal([]byte(cache), &d) if data, ok := d["data"].(map[string]interface{}); ok { if content, ok := data["content"].(string); ok { parser := NewContentParser() data["content"] = parser.Parse(content, textMode, imageMode) } } return buildJSONResponse(UnwrapJSONStrings(d)), nil } // fetchContentFromAPI 从API获取内容(带设备切换重试),返回缓存格式 JSON func fetchContentFromAPI(itemID string) (string, error) { return fetchContentWithRetry(itemID, 0) } // fetchContentWithRetry 从API获取内容,支持设备切换重试 func fetchContentWithRetry(itemID string, retryCount int) (string, error) { // 防止无限递归 const maxRetries = 5 if retryCount >= maxRetries { contentLogger.Warning(fmt.Sprintf("已重试 %d 次,尝试备用接口", maxRetries)) return tryBackupAPI(itemID) } // 从设备密钥池获取设备 pool := GetDeviceKeyPool() item, err := pool.GetNext() if err != nil { contentLogger.Error("从设备池获取设备失败: " + err.Error()) return `{"code": -1, "msg": "设备池暂无可用设备"}`, err } header := item.DeviceHeader currentKeyInfo := &KeyInfo{ Key: item.Key, Ver: item.KeyVer, Ts: item.KeyTs, } contentLogger.Debug(fmt.Sprintf("使用池中设备 ID=%d (重试次数: %d)", item.ID, retryCount)) // 移除version_name参数 headerWithoutVersion := removeVersionFromHeader(header) params := []string{ "aid=1967", "app_name=novelapp", "channel=0", "device_platform=android", "version_code=" + ClientVersionCode, "item_id=" + itemID, "os_version=0", fmt.Sprintf("key_register_ts=%d", currentKeyInfo.Ts), // 降低上游密钥不匹配概率 headerWithoutVersion, } sortedParams := buildSortedQuery(params...) url := "https://reading.snssdk.com/reading/reader/full/v/?" + sortedParams resp, err := ContentRequest(url, map[string]interface{}{ "method": "GET", "headers": map[string]string{ "User-Agent": "com.dragon.read", }, }) if err != nil { contentLogger.Warning(fmt.Sprintf("主接口请求出错,标记设备无效并切换设备: %v", err)) pool.MarkInvalid(header) pool.RemoveInvalid() return fetchContentWithRetry(itemID, retryCount+1) } var body map[string]interface{} json.Unmarshal(resp.Content, &body) if code, ok := body["code"].(float64); ok && code == 0 { data, ok := body["data"].(map[string]interface{}) if !ok { contentLogger.Warning("响应data字段无效,切换设备重试") pool.MarkInvalid(header) pool.RemoveInvalid() return fetchContentWithRetry(itemID, retryCount+1) } contentEncoded, ok := data["content"].(string) if !ok || contentEncoded == "Invalid" { // 内容无效,标记当前设备为无效并切换设备 contentLogger.Warning(fmt.Sprintf("内容返回 Invalid,标记设备无效并切换设备 (重试: %d)", retryCount)) pool.MarkInvalid(header) pool.RemoveInvalid() return fetchContentWithRetry(itemID, retryCount+1) } // 检查密钥版本 keyVersion, _ := data["key_version"].(float64) if int(keyVersion) != currentKeyInfo.Ver { contentLogger.Warning("密钥版本不匹配,重新获取密钥") // 更新池中的密钥 newKeyInfo, err := GetKey(header) if err == nil { UpdateKeyInPool(header, newKeyInfo) currentKeyInfo = newKeyInfo } else { // 获取密钥失败,切换设备 contentLogger.Warning("获取密钥失败,切换设备重试") pool.MarkInvalid(header) pool.RemoveInvalid() return fetchContentWithRetry(itemID, retryCount+1) } } contentStr, err := decodeContentBody(data, currentKeyInfo) if err != nil { contentLogger.Warning(fmt.Sprintf("解密失败,切换设备重试: %v", err)) pool.MarkInvalid(header) pool.RemoveInvalid() return fetchContentWithRetry(itemID, retryCount+1) } contentStr = strings.ReplaceAll(contentStr, ``, "") contentStr = strings.TrimSpace(contentStr) data["content"] = contentStr // 写缓存并返回缓存格式(按参数的解析统一放在出口 processCachedContent) cacheStr := marshalNoEscape(buildCacheData(UnwrapJSONStrings(data))) UpdateCache("content_"+itemID, cacheStr, 86400) return cacheStr, nil } // 响应code不为0,切换设备重试 contentLogger.Warning(fmt.Sprintf("响应code异常: %.0f,切换设备重试", body["code"])) pool.MarkInvalid(header) pool.RemoveInvalid() return fetchContentWithRetry(itemID, retryCount+1) } // decodeContentBody 按上游 crypt_status 解出正文: // - 0:base64 密文,设备密钥 AES-CBC 解密(compress_status!=0 时解压) // - 1:明文(可能压缩) // - 2:密钥已失效,刷新密钥后由调用方重试 func decodeContentBody(data map[string]interface{}, keyInfo *KeyInfo) (string, error) { contentEncoded, _ := data["content"].(string) cryptStatus, _ := data["crypt_status"].(float64) compressStatus, _ := data["compress_status"].(float64) switch int(cryptStatus) { case 1: if compressStatus != 0 { return string(gunzipMaybe([]byte(contentEncoded))), nil } return contentEncoded, nil case 2: return "", fmt.Errorf("密钥已失效(crypt_status=2),需刷新密钥重试") default: // 500003 场景(设备无需解密)仍下发密文时按明文兜底 if keyInfo == nil || keyInfo.Key == "" { return contentEncoded, nil } contentBytes, err := base64.StdEncoding.DecodeString(contentEncoded) if err != nil || len(contentBytes) < 16 { return "", fmt.Errorf("内容密文长度异常") } iv := contentBytes[0:16] encryptedData := contentBytes[16:] keyBytes, _ := base64.StdEncoding.DecodeString(keyInfo.Key) decrypted, err := De(encryptedData, keyBytes, iv) if err != nil { return "", err } if compressStatus != 0 { decrypted = gunzipMaybe(decrypted) } return string(decrypted), nil } } // tryBackupAPI 尝试备用API(所有设备都失败后的最后手段),返回缓存格式 JSON func tryBackupAPI(itemID string) (string, error) { contentLogger.Warning("尝试备用接口1(所有设备均失败)") // 从设备池获取任意一个有效设备用于备用接口 pool := GetDeviceKeyPool() item, err := pool.GetNext() if err != nil { contentLogger.Error("备用接口:无法获取设备") return `{"code": -1, "msg": "获取失败"}`, err } header := item.DeviceHeader // 备用接口1:使用DH加密 backupAPI := fmt.Sprintf("https://novel.snssdk.com/api/novel/book/reader/content/v1?support_image=1&item_id=%s&aid=1319&app_name=novelapp&channel=0&device_platform=android&os_version=2&version_code=%s&%s", itemID, ClientVersionCode, header) cm := NewCM() resp, err := GorgonRequest(backupAPI, map[string]interface{}{ "method": "GET", "headers": map[string]string{ "User-Agent": "com.dragon.read", "y": cm.ClientHandshake(), }, }) if err != nil { contentLogger.Warning("备用接口1获取正文出错!") return `{"code": -1, "msg": "获取失败"}`, err } var body map[string]interface{} json.Unmarshal(resp.Content, &body) if code, ok := body["code"].(float64); ok && code == 0 { delete(body, "log_id") data, ok := body["data"].(map[string]interface{}) if !ok { return `{"code": -1, "msg": "获取失败"}`, err } contentEncrypted, ok := data["content"].(string) if !ok || contentEncrypted == "Bad Content" { return `{"code": -1, "msg": "获取失败"}`, err } // 解密内容 cHeader := resp.GetHeader("c") yHeader := resp.GetHeader("y") decryptedContent, err := cm.Decrypt(cHeader, yHeader, contentEncrypted) if err != nil { contentLogger.Error("解密失败: " + err.Error()) return `{"code": -1, "msg": "解密失败"}`, err } decryptedContent = string(gunzipMaybe([]byte(decryptedContent))) data["content"] = strings.TrimSpace(decryptedContent) // 写缓存并返回缓存格式 cacheStr := marshalNoEscape(buildCacheData(UnwrapJSONStrings(data))) UpdateCache("content_"+itemID, cacheStr, 86400) return cacheStr, nil } contentLogger.Warning("备用接口1获取正文出错!") return `{"code": -1, "msg": "获取失败"}`, fmt.Errorf("所有接口都失败") }