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(`图片%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" }