Files
fanqie-go/internal/fqapi/manga.go
T
chenxin 5bcc8a962e ♻️ refactor(handlers): status 与 review 页面模板化
- 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 顺手格式化
2026-09-30 00:25:09 +08:00

616 lines
16 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 := strings.Replace(header, "&version_name=5.8.9.32", "", -1)
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.Key, newKeyInfo.Ver)
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)
// 尝试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 data
}
unpadding := int(data[length-1])
if unpadding > length {
return data
}
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"
}