🔧 chore: 初始化番茄小说 API 项目结构
新增项目基础框架,包含核心 API 实现、算法模块、HTTP 处理器等完整功能模块
This commit is contained in:
commit
6981148727
107 files changed
+12463
No files matched your search
@@ -0,0 +1,279 @@
|
||||
package fqapi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fanqie/internal/common"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
contentLogger = common.NewLogger("content", "INFO", "")
|
||||
)
|
||||
|
||||
// ContentFetcher 内容获取器
|
||||
type ContentFetcher struct{}
|
||||
|
||||
// HandleGetContent 处理获取内容
|
||||
func HandleGetContent(itemID string, textMode, imageMode interface{}) (string, error) {
|
||||
if itemID == "" {
|
||||
return `{"code": -1, "msg": "需要参数 item_id"}`, nil
|
||||
}
|
||||
|
||||
// 参数转换
|
||||
textModeBool := parseBoolParam(textMode)
|
||||
imageModeBool := parseBoolParamDefault(imageMode, true)
|
||||
|
||||
// contentLogger.Info(fmt.Sprintf("获取内容 item_id=%s text_mode=%v image_mode=%v", itemID, textModeBool, imageModeBool))
|
||||
|
||||
// 检查缓存
|
||||
cache := GetCache("content_" + itemID)
|
||||
if cache != "" {
|
||||
return processCachedContent(cache, textModeBool, imageModeBool)
|
||||
}
|
||||
|
||||
// 缓存未命中,从API获取
|
||||
return fetchContentFromAPI(itemID, textModeBool, imageModeBool)
|
||||
}
|
||||
|
||||
// processCachedContent 处理缓存内容
|
||||
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(d), nil
|
||||
}
|
||||
|
||||
// fetchContentFromAPI 从API获取内容(带设备切换重试)
|
||||
func fetchContentFromAPI(itemID string, textMode, imageMode bool) (string, error) {
|
||||
return fetchContentWithRetry(itemID, textMode, imageMode, 0)
|
||||
}
|
||||
|
||||
// fetchContentWithRetry 从API获取内容,支持设备切换重试
|
||||
func fetchContentWithRetry(itemID string, textMode, imageMode bool, retryCount int) (string, error) {
|
||||
// 防止无限递归
|
||||
const maxRetries = 5
|
||||
if retryCount >= maxRetries {
|
||||
contentLogger.Warning(fmt.Sprintf("已重试 %d 次,尝试备用接口", maxRetries))
|
||||
return tryBackupAPI(itemID, textMode, imageMode)
|
||||
}
|
||||
|
||||
// 从设备密钥池获取设备
|
||||
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,
|
||||
}
|
||||
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=66.9",
|
||||
"item_id=" + itemID,
|
||||
"os_version=0",
|
||||
headerWithoutVersion,
|
||||
}
|
||||
|
||||
allParams := strings.Join(params, "&")
|
||||
paramList := strings.Split(allParams, "&")
|
||||
sort.Strings(paramList)
|
||||
sortedParams := strings.Join(paramList, "&")
|
||||
|
||||
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, textMode, imageMode, 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, textMode, imageMode, 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, textMode, imageMode, retryCount+1)
|
||||
}
|
||||
|
||||
// 检查密钥版本
|
||||
keyVersion, _ := data["key_version"].(float64)
|
||||
if currentKeyInfo != nil && int(keyVersion) != currentKeyInfo.Ver {
|
||||
contentLogger.Warning("密钥版本不匹配,重新获取密钥")
|
||||
|
||||
// 更新池中的密钥
|
||||
newKeyInfo, err := GetKey(header)
|
||||
if err == nil {
|
||||
UpdateKeyInPool(header, newKeyInfo.Key, newKeyInfo.Ver)
|
||||
currentKeyInfo = newKeyInfo
|
||||
} else {
|
||||
// 获取密钥失败,切换设备
|
||||
contentLogger.Warning("获取密钥失败,切换设备重试")
|
||||
pool.MarkInvalid(header)
|
||||
pool.RemoveInvalid()
|
||||
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 解密内容
|
||||
contentBytes, _ := base64.StdEncoding.DecodeString(contentEncoded)
|
||||
if len(contentBytes) < 16 {
|
||||
contentLogger.Warning("内容长度不足,切换设备重试")
|
||||
pool.MarkInvalid(header)
|
||||
pool.RemoveInvalid()
|
||||
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
|
||||
}
|
||||
|
||||
iv := contentBytes[0:16]
|
||||
encryptedData := contentBytes[16:]
|
||||
|
||||
keyBytes, _ := base64.StdEncoding.DecodeString(currentKeyInfo.Key)
|
||||
decrypted, err := De(encryptedData, keyBytes, iv)
|
||||
if err != nil {
|
||||
contentLogger.Warning(fmt.Sprintf("解密失败,切换设备重试: %v", err))
|
||||
pool.MarkInvalid(header)
|
||||
pool.RemoveInvalid()
|
||||
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
|
||||
}
|
||||
|
||||
// 处理内容
|
||||
contentStr := string(decrypted)
|
||||
contentStr = strings.Replace(contentStr, `<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?>`, "", -1)
|
||||
contentStr = strings.TrimSpace(contentStr)
|
||||
|
||||
data["content"] = contentStr
|
||||
|
||||
// 更新缓存
|
||||
cacheData := buildCacheData(data)
|
||||
saveCacheJSON("content_"+itemID, cacheData, 86400*2)
|
||||
|
||||
// 解析内容
|
||||
parser := NewContentParser()
|
||||
data["content"] = parser.Parse(contentStr, textMode, imageMode)
|
||||
|
||||
return buildJSONResponse(body), nil
|
||||
}
|
||||
|
||||
// 响应code不为0,切换设备重试
|
||||
contentLogger.Warning(fmt.Sprintf("响应code异常: %.0f,切换设备重试", body["code"]))
|
||||
pool.MarkInvalid(header)
|
||||
pool.RemoveInvalid()
|
||||
return fetchContentWithRetry(itemID, textMode, imageMode, retryCount+1)
|
||||
}
|
||||
|
||||
// tryBackupAPI 尝试备用API(所有设备都失败后的最后手段)
|
||||
func tryBackupAPI(itemID string, textMode, imageMode bool) (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=58932&%s",
|
||||
itemID, 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
|
||||
}
|
||||
|
||||
data["content"] = decryptedContent
|
||||
|
||||
// 更新缓存
|
||||
bodyJSONStr := buildJSONResponse(body)
|
||||
UpdateCache("content_"+itemID, bodyJSONStr, 86400)
|
||||
|
||||
// 解析内容
|
||||
parser := NewContentParser()
|
||||
data["content"] = parser.Parse(decryptedContent, textMode, imageMode)
|
||||
|
||||
return buildJSONResponse(body), nil
|
||||
}
|
||||
|
||||
contentLogger.Warning("备用接口1获取正文出错!")
|
||||
return `{"code": -1, "msg": "获取失败"}`, fmt.Errorf("所有接口都失败")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user