Files
fanqie-go/internal/fqapi/manga.go
T
chenxin 84bd3238ab ✨ feat(api): 新增榜单书架历史与搜索续传并重构正文缓存链路
- 新增 /rank 免签名榜单接口(多域名容错)、/bookshelf 与 /read-history(sessionid 登录态,书架支持分组过滤)
- 搜索重写:翻页透传上游 passback/search_id/next_offset/corrected_query 并按 book_id 去重,15~20 位纯数字关键词直查详情(ID 直搜)
- 删除 /login/check 登录校验端点(login.go、路由与文档同步移除,sessionid 说明改为直接供书架/历史使用)
- 正文链路重构:singleflight 合并同章节并发回源防击穿,缓存与解析解耦统一在出口处理,decodeContentBody 按 crypt_status 分支并兜底 500003 明文
- 缓存层:SQL 持久层 TTL 与业务 TTL 解耦(不可变内容过期后由 SQL 回填),动态短缓存只走 Redis,withCache 合并并发 miss
- 密钥池:KeyTs 随正文请求上传降低密钥不匹配重试,密钥超 24 小时异步刷新并防刷新风暴,last_used_at 批量落库
- 签名补 X-Helios(与 X-Ladon 同值,对齐书源实现);设备注册版本号统一走 ClientVersionCode/Name
- 分类 landing 透传新版标签 selected_items;multi-detail 批量详情供书架/历史复用
- README 与 openapi.yaml 补充新接口文档
2026-10-12 14:49:14 +08:00

626 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package fqapi
import (
"bytes"
"compress/gzip"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"fanqie/internal/common"
"fanqie/internal/config"
)
var (
mangaLogger = common.NewLogger("manga", "INFO", "")
// 存储漫画图片信息的缓存(用于代理模式)
mangaImageCache = make(map[string]*MangaImageInfo)
mangaCacheMutex sync.RWMutex
)
// MangaImageInfo 漫画图片信息
type MangaImageInfo struct {
PicURL string
EncryptKey string
Width int
Height int
CachedAt time.Time
}
// MangaData 漫画数据结构
type MangaData struct {
PicInfos []PicInfo `json:"picInfos"`
EncryptKey string `json:"encrypt_key"`
}
// PicInfo 图片信息
type PicInfo struct {
PicURL string `json:"picUrl"`
Md5 string `json:"md5"`
Width int `json:"width"`
Height int `json:"height"`
}
// ImageResult 图片结果
type ImageResult struct {
Data string `json:"data"`
Width int `json:"width"`
Height int `json:"height"`
Format string `json:"format"`
Index int `json:"index"`
}
// HandleGetManga 处理漫画请求
func HandleGetManga(itemID string, showHTML bool, urlOutputMode string) (interface{}, error) {
return getMangaWithRetry(itemID, showHTML, urlOutputMode, 0)
}
// getMangaWithRetry 获取漫画内容,支持设备切换重试
func getMangaWithRetry(itemID string, showHTML bool, urlOutputMode string, retryCount int) (interface{}, error) {
// 防止无限递归
const maxRetries = 5
if retryCount >= maxRetries {
mangaLogger.Warning(fmt.Sprintf("已重试 %d 次,获取漫画失败", maxRetries))
return nil, fmt.Errorf("已重试 %d 次,所有设备均失败", maxRetries)
}
// 1. 从设备密钥池获取设备
pool := GetDeviceKeyPool()
item, err := pool.GetNext()
if err != nil {
return nil, fmt.Errorf("从设备池获取设备失败: %w", err)
}
header := item.DeviceHeader
keyInfo := &KeyInfo{
Key: item.Key,
Ver: item.KeyVer,
}
mangaLogger.Debug(fmt.Sprintf("使用池中设备 ID=%d (重试次数: %d)", item.ID, retryCount))
// 2. 构建URL - 使用 BuildURL 添加设备参数
headerWithoutVersion := removeVersionFromHeader(header)
url := BuildURL("https://reading.snssdk.com/reading/reader/full/v/",
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"version_code=66.9",
"item_id="+itemID,
"os_version=0",
headerWithoutVersion,
)
// 3. 调用API - 使用 GorgonRequest 添加签名
response, err := GorgonRequest(url, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
mangaLogger.Warning(fmt.Sprintf("API请求失败,切换设备重试: %v", err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
}
// 4. 解析响应
var body map[string]interface{}
if err := json.Unmarshal(response.Content, &body); err != nil {
mangaLogger.Warning(fmt.Sprintf("JSON解析失败,切换设备重试: %v", err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
}
// 5. 获取加密的content
data, ok := body["data"].(map[string]interface{})
if !ok {
mangaLogger.Warning("响应data字段无效,切换设备重试")
pool.MarkInvalid(header)
pool.RemoveInvalid()
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
}
encryptedContent, ok := data["content"].(string)
if !ok || encryptedContent == "Invalid" {
mangaLogger.Warning(fmt.Sprintf("内容返回 Invalid,标记设备无效并切换设备 (重试: %d)", retryCount))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
}
// 检查密钥版本
if keyVersion, ok := data["key_version"].(float64); ok {
if int(keyVersion) != keyInfo.Ver {
mangaLogger.Warning("密钥版本不匹配,重新获取密钥")
newKeyInfo, err := GetKey(header)
if err == nil {
UpdateKeyInPool(header, newKeyInfo)
keyInfo = newKeyInfo
} else {
mangaLogger.Warning("获取密钥失败,切换设备重试")
pool.MarkInvalid(header)
pool.RemoveInvalid()
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
}
}
}
// 6. 解密content
decrypted, err := decryptAESContent(encryptedContent, keyInfo.Key)
if err != nil {
mangaLogger.Warning(fmt.Sprintf("解密失败,切换设备重试: %v", err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return getMangaWithRetry(itemID, showHTML, urlOutputMode, retryCount+1)
}
// 7. 判断是否为漫画内容
if strings.Contains(decrypted, "picInfos") {
// 是漫画,决定输出模式
// 优先级:URL参数 > 配置文件 > 默认值
outputMode := "proxy" // 默认值
// 从配置文件读取
cfg := config.GetConfig()
if cfg != nil && cfg.Fanqie.Manga.OutputMode != "" {
outputMode = cfg.Fanqie.Manga.OutputMode
}
// URL参数优先级最高
if urlOutputMode != "" {
outputMode = urlOutputMode
}
// 解析漫画图片(传入keyInfo用于解密模式)
images, err := parseMangaImages(decrypted, showHTML, outputMode, itemID, keyInfo)
if err != nil {
return nil, fmt.Errorf("解析漫画失败: %w", err)
}
return images, nil
}
// 不是漫画,返回普通文本
return map[string]interface{}{
"content": decrypted,
}, nil
}
// decryptAESContent 使用AES-128-CBC解密content
func decryptAESContent(encrypted, secretKey string) (string, error) {
// Base64解码加密内容
raw, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
return "", fmt.Errorf("Base64解码失败: %w", err)
}
if len(raw) < 16 {
return "", fmt.Errorf("数据长度不足")
}
// 提取IV和密文
iv := raw[:16]
ciphertext := raw[16:]
// Base64解码密钥
keyBytes, err := base64.StdEncoding.DecodeString(secretKey)
if err != nil {
return "", fmt.Errorf("密钥解码失败: %w", err)
}
if len(keyBytes) != 16 {
return "", fmt.Errorf("密钥长度必须为16字节,实际: %d", len(keyBytes))
}
// 创建AES cipher
block, err := aes.NewCipher(keyBytes)
if err != nil {
return "", fmt.Errorf("创建cipher失败: %w", err)
}
// CBC模式解密
mode := cipher.NewCBCDecrypter(block, iv)
plaintext := make([]byte, len(ciphertext))
mode.CryptBlocks(plaintext, ciphertext)
// PKCS7 unpadding(无效填充说明密钥不匹配,返回错误触发上层重试)
plaintext = pkcs7Unpad(plaintext)
if plaintext == nil {
return "", fmt.Errorf("解密失败(无效填充,密钥可能不匹配)")
}
// 尝试gzip解压
if len(plaintext) > 2 && plaintext[0] == 0x1f && plaintext[1] == 0x8b {
// 是gzip压缩的数据
reader, err := gzip.NewReader(bytes.NewReader(plaintext))
if err == nil {
defer reader.Close()
decompressed, err := io.ReadAll(reader)
if err == nil {
return string(decompressed), nil
}
}
}
return string(plaintext), nil
}
// pkcs7Unpad PKCS7 unpadding
func pkcs7Unpad(data []byte) []byte {
length := len(data)
if length == 0 {
return nil
}
unpadding := int(data[length-1])
// 完整校验:值 1-16 且尾部 unpadding 个字节全等于填充值。
// 密钥错误时解密结果是随机字节,弱校验会让乱码静默通过
if unpadding < 1 || unpadding > 16 || unpadding > length {
return nil
}
for _, b := range data[length-unpadding:] {
if int(b) != unpadding {
return nil
}
}
return data[:length-unpadding]
}
// buildMangaResponse 构建漫画响应(统一HTML和JSON格式输出)
func buildMangaResponse(images []ImageResult, showHTML bool) interface{} {
if showHTML {
var htmlContent strings.Builder
for _, img := range images {
htmlContent.WriteString(fmt.Sprintf(`<img src="%s" width="%d" height="%d" alt="图片%d">`,
img.Data, img.Width, img.Height, img.Index))
htmlContent.WriteString("\n")
}
return map[string]interface{}{
"content": htmlContent.String(),
}
}
return map[string]interface{}{
"images": images,
}
}
// processURLMode 处理URL模式(MD5直链)
func processURLMode(mangaData *MangaData) []ImageResult {
baseDomain := "https://p6-novel.byteimg.com/origin/"
validPath := detectMangaPath(baseDomain, mangaData.PicInfos[0].Md5)
images := make([]ImageResult, len(mangaData.PicInfos))
for i, pic := range mangaData.PicInfos {
imageURL := baseDomain + validPath + pic.Md5
images[i] = ImageResult{
Data: imageURL,
Width: pic.Width,
Height: pic.Height,
Format: "unknown",
Index: i,
}
}
return images
}
// processProxyMode 处理代理模式(生成短URL并缓存)
func processProxyMode(mangaData *MangaData, itemID string) []ImageResult {
images := make([]ImageResult, len(mangaData.PicInfos))
for i, pic := range mangaData.PicInfos {
cacheKey := fmt.Sprintf("%s_%d", itemID, i)
// 存储图片信息到缓存
mangaCacheMutex.Lock()
mangaImageCache[cacheKey] = &MangaImageInfo{
PicURL: pic.PicURL,
EncryptKey: mangaData.EncryptKey,
Width: pic.Width,
Height: pic.Height,
CachedAt: time.Now(),
}
mangaCacheMutex.Unlock()
// 返回短的代理URL
imageData := fmt.Sprintf("/manga/image/%s/%d", itemID, i)
images[i] = ImageResult{
Data: imageData,
Width: pic.Width,
Height: pic.Height,
Format: "unknown",
Index: i,
}
}
return images
}
// fillMangaCache 填充漫画图片缓存(供所有模式使用)
func fillMangaCache(mangaData *MangaData, itemID string) {
for i, pic := range mangaData.PicInfos {
cacheKey := fmt.Sprintf("%s_%d", itemID, i)
// 检查是否已经存在,避免覆盖
mangaCacheMutex.RLock()
_, exists := mangaImageCache[cacheKey]
mangaCacheMutex.RUnlock()
if exists {
continue
}
// 存储图片信息到缓存
mangaCacheMutex.Lock()
mangaImageCache[cacheKey] = &MangaImageInfo{
PicURL: pic.PicURL,
EncryptKey: mangaData.EncryptKey,
Width: pic.Width,
Height: pic.Height,
CachedAt: time.Now(),
}
mangaCacheMutex.Unlock()
}
}
// processDataURIMode 处理DataURI模式(下载、解密、Base64编码)
func processDataURIMode(mangaData *MangaData) []ImageResult {
keyBytes, err := hex.DecodeString(mangaData.EncryptKey)
if err != nil {
mangaLogger.Warning(fmt.Sprintf("密钥解码失败: %v", err))
return []ImageResult{}
}
images := make([]ImageResult, 0, len(mangaData.PicInfos))
for i, pic := range mangaData.PicInfos {
// 下载加密图片
encryptedData, err := downloadImageWithRetry(pic.PicURL, 3)
if err != nil {
mangaLogger.Warning(fmt.Sprintf("下载图片失败 [%d]: %v", i, err))
continue
}
// 解密图片
decryptedData, err := decryptImage(encryptedData, keyBytes)
if err != nil {
mangaLogger.Warning(fmt.Sprintf("解密图片失败 [%d]: %v", i, err))
continue
}
// 识别格式
format := getImageFormat(decryptedData, pic.PicURL)
// Base64编码图片数据
base64Data := base64.StdEncoding.EncodeToString(decryptedData)
// Data URI模式:返回完整的Base64编码
mimeType := fmt.Sprintf("image/%s", format)
if format == "jpg" {
mimeType = "image/jpeg"
}
imageData := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
images = append(images, ImageResult{
Data: imageData,
Width: pic.Width,
Height: pic.Height,
Format: format,
Index: i,
})
}
return images
}
// parseMangaImages 解析并处理漫画图片
func parseMangaImages(content string, showHTML bool, outputMode string, itemID string, keyInfo *KeyInfo) (interface{}, error) {
// 提取JSON部分
jsonStart := strings.Index(content, "{")
jsonEnd := strings.LastIndex(content, "}")
if jsonStart == -1 || jsonEnd == -1 {
return nil, fmt.Errorf("无法找到JSON数据")
}
jsonStr := content[jsonStart : jsonEnd+1]
var mangaData MangaData
if err := json.Unmarshal([]byte(jsonStr), &mangaData); err != nil {
return nil, fmt.Errorf("JSON解析失败: %w", err)
}
if len(mangaData.PicInfos) == 0 {
return nil, fmt.Errorf("未找到图片信息")
}
if mangaData.EncryptKey == "" {
return nil, fmt.Errorf("缺少加密密钥")
}
// 根据不同模式处理图片
var images []ImageResult
switch outputMode {
case "url":
images = processURLMode(&mangaData)
case "proxy":
images = processProxyMode(&mangaData, itemID)
default: // "data_uri"
images = processDataURIMode(&mangaData)
}
// 无论什么模式,都填充缓存以便后续可以通过 /manga/image 访问
fillMangaCache(&mangaData, itemID)
return buildMangaResponse(images, showHTML), nil
}
// downloadImageWithRetry 带重试的图片下载
func downloadImageWithRetry(url string, maxRetries int) ([]byte, error) {
var lastErr error
for i := 0; i <= maxRetries; i++ {
data, err := downloadImage(url)
if err == nil && len(data) > 100 {
return data, nil
}
lastErr = err
if i < maxRetries {
// 重试间隔递增(2秒、4秒、6秒)
time.Sleep(time.Duration(i*2+2) * time.Second)
}
}
return nil, fmt.Errorf("下载失败(已重试%d次): %w", maxRetries, lastErr)
}
// downloadImage 下载图片
func downloadImage(url string) ([]byte, error) {
// 创建专用的图片下载客户端,超时时间更长
client := &http.Client{
Timeout: 120 * time.Second,
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
// 使用与API一致的请求头
req.Header.Set("User-Agent", "okhttp/4.12.0")
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
req.Header.Set("Accept-Encoding", "gzip, deflate")
req.Header.Set("Referer", "https://fanqie.novel.com/")
req.Header.Set("Origin", "https://fanqie.novel.com")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("下载失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP状态码: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// decryptImage 解密图片数据(AES-GCM)
func decryptImage(encryptedData, keyBytes []byte) ([]byte, error) {
// 加密格式: IV(12字节) + Ciphertext + Tag(16字节)
dataLen := len(encryptedData)
if dataLen < 28 {
return nil, fmt.Errorf("数据长度不足(至少需要28字节)")
}
// 提取各部分
iv := encryptedData[:12]
tag := encryptedData[dataLen-16:]
ciphertext := encryptedData[12 : dataLen-16]
// 创建AES cipher
block, err := aes.NewCipher(keyBytes)
if err != nil {
return nil, fmt.Errorf("创建cipher失败: %w", err)
}
// 创建GCM mode
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("创建GCM失败: %w", err)
}
// 组合ciphertext和tag(Go的GCM要求tag附加在ciphertext后面)
ciphertextWithTag := append(ciphertext, tag...)
// 解密
plaintext, err := aesGCM.Open(nil, iv, ciphertextWithTag, nil)
if err != nil {
return nil, fmt.Errorf("解密失败: %w", err)
}
return plaintext, nil
}
// getImageFormat 识别图片格式
// detectMangaPath 探测正确的漫画图片路径(novel-images/ 或 novel-pic/)
func detectMangaPath(baseDomain, firstMd5 string) string {
// 优先尝试 novel-images/
testURL := baseDomain + "novel-images/" + firstMd5
client := &http.Client{
Timeout: 8 * time.Second,
}
resp, err := client.Get(testURL)
if err != nil {
// 请求失败,使用novel-pic/
return "novel-pic/"
}
defer resp.Body.Close()
// 读取响应内容
body, err := io.ReadAll(resp.Body)
if err != nil {
return "novel-pic/"
}
// 检查是否为JSON响应(错误响应通常是JSON)
var jsonData interface{}
if json.Unmarshal(body, &jsonData) == nil {
// 是JSON,说明路径无效,切换到novel-pic/
return "novel-pic/"
}
// 不是JSON,说明返回的是图片,路径有效
return "novel-images/"
}
func getImageFormat(data []byte, url string) string {
// 首先尝试从URL提取扩展名
if idx := strings.LastIndex(url, "."); idx != -1 {
ext := strings.ToLower(url[idx+1:])
// 移除query参数
if qIdx := strings.Index(ext, "?"); qIdx != -1 {
ext = ext[:qIdx]
}
if ext == "jpg" || ext == "jpeg" || ext == "png" || ext == "gif" || ext == "webp" {
return ext
}
}
// 通过文件头识别
if len(data) < 16 {
return "png"
}
// JPEG: FF D8 FF
if bytes.HasPrefix(data, []byte{0xFF, 0xD8, 0xFF}) {
return "jpg"
}
// PNG: 89 50 4E 47
if bytes.HasPrefix(data, []byte{0x89, 0x50, 0x4E, 0x47}) {
return "png"
}
// GIF: 47 49 46 38
if bytes.HasPrefix(data, []byte{0x47, 0x49, 0x46, 0x38}) {
return "gif"
}
// WEBP: RIFF...WEBP
if bytes.HasPrefix(data, []byte{0x52, 0x49, 0x46, 0x46}) &&
len(data) >= 12 &&
bytes.Equal(data[8:12], []byte{0x57, 0x45, 0x42, 0x50}) {
return "webp"
}
return "png"
}