🔧 chore: 初始化番茄小说 API 项目结构

新增项目基础框架,包含核心 API 实现、算法模块、HTTP 处理器等完整功能模块
This commit is contained in:
chenxin committed 2026-08-28 15:22:22 +08:00
commit 6981148727
107 files changed
+12463

No files matched your search

+616
View File
@@ -0,0 +1,616 @@
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"
}