🔧 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

+135
View File
@@ -0,0 +1,135 @@
package fqapi
import (
"encoding/json"
"fanqie/internal/common"
"fmt"
)
var audioLogger = common.NewLogger("audio", "INFO", "")
// HandleAudio 处理获取音频
func HandleAudio(itemIDs interface{}, toneID interface{}, output interface{}) (string, error) {
if itemIDs == nil || itemIDs == "" {
return BuildSuccessResponse([]interface{}{}), nil
}
// 处理itemIDs - 使用ParseStringParam
itemIDStr := ParseStringParam(itemIDs, "")
// 处理toneID - 使用ParseIntParam
toneIDInt := ParseIntParam(toneID, 1)
// 处理output参数
outputStr := ParseStringParam(output, "url")
// 使用 BuildURL 添加设备参数
url := BuildURL("https://reading.snssdk.com/reading/reader/audio/playinfo/",
fmt.Sprintf("item_ids=%s", itemIDStr),
fmt.Sprintf("tone_id=%d", toneIDInt),
"pv_player=-1",
)
// 使用 GorgonRequest 添加签名和设备信息
resp, err := GorgonRequest(url, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
audioLogger.Error(fmt.Sprintf("获取音频失败: %v", err))
return BuildErrorResponse("获取音频失败"), err
}
respJSON, err := resp.JSON()
if err != nil {
audioLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
return BuildErrorResponse("解析响应失败"), err
}
// 检查响应码
var respCode float64
var okCode bool
if code, ok := respJSON["code"].(float64); ok {
respCode, okCode = code, code == 0
} else if code, ok := respJSON["Code"].(float64); ok {
respCode, okCode = code, code == 0
}
if okCode {
// 转换响应格式:只修改 code 和 message,保留原始数据结构
respJSON["code"] = 200
respJSON["message"] = "SUCCESS"
// 处理output参数,替换音频URL
if outputStr != "url" {
// 获取当前使用的设备信息
currentDevice := GetCurrentDevice()
deviceID := ""
if currentDevice != nil {
deviceID = currentDevice.DeviceHeader
}
// 准备请求头
headers := map[string]string{
"User-Agent": "com.dragon.read",
}
// 处理音频URL,支持多种可能的结构
if dataArray, ok := respJSON["data"].([]interface{}); ok {
// 情况1:data是数组格式(实际返回的格式)
for _, item := range dataArray {
if audioItem, ok := item.(map[string]interface{}); ok {
// 处理main_url
if mainURL, ok := audioItem["main_url"].(string); ok && mainURL != "" {
processedURL := processMediaURL(mainURL, deviceID, headers, outputStr, "audio")
audioItem["main_url"] = processedURL
}
// 处理backup_url
if backupURL, ok := audioItem["backup_url"].(string); ok && backupURL != "" {
processedURL := processMediaURL(backupURL, deviceID, headers, outputStr, "audio")
audioItem["backup_url"] = processedURL
}
}
}
} else if data, ok := respJSON["data"].(map[string]interface{}); ok {
// 情况2:单个音频在audio_info下
if audioInfo, ok := data["audio_info"].(map[string]interface{}); ok {
if playURL, ok := audioInfo["play_url"].(string); ok && playURL != "" {
processedURL := processMediaURL(playURL, deviceID, headers, outputStr, "audio")
audioInfo["play_url"] = processedURL
}
}
// 情况3:音频列表在audio_list下
if audioList, ok := data["audio_list"].([]interface{}); ok {
for _, item := range audioList {
if audioItem, ok := item.(map[string]interface{}); ok {
if playURL, ok := audioItem["play_url"].(string); ok && playURL != "" {
processedURL := processMediaURL(playURL, deviceID, headers, outputStr, "audio")
audioItem["play_url"] = processedURL
}
}
}
}
}
}
resultBytes, _ := json.Marshal(respJSON)
return string(resultBytes), nil
}
// 返回错误信息
errMsg := "获取音频失败"
if msg, ok := respJSON["message"].(string); ok {
errMsg = msg
} else if msg, ok := respJSON["Message"].(string); ok {
errMsg = msg
}
audioLogger.Error(fmt.Sprintf("获取音频失败: code=%v, msg=%s", respCode, errMsg))
return BuildErrorResponse(errMsg), nil
}
+113
View File
@@ -0,0 +1,113 @@
package fqapi
import (
"database/sql"
"encoding/json"
"fmt"
"time"
)
// AudioCacheItem 音频缓存项
type AudioCacheItem struct {
OriginalURL string `json:"original_url"` // 原始CDN URL
DeviceID string `json:"device_id"` // 关联的设备ID
Headers map[string]string `json:"headers"` // 请求头信息
CachedAt time.Time `json:"cached_at"` // 缓存时间
}
// GenerateAudioProxyURL 生成音频代理URL
func GenerateAudioProxyURL(originalURL, deviceID string, headers map[string]string) string {
// 生成缓存key
key := generateRandomKey(16)
// 将headers序列化为JSON字符串
headersJSON, _ := json.Marshal(headers)
// 存储到数据库
if cacheDB != nil {
cacheMutex.Lock()
defer cacheMutex.Unlock()
_, err := cacheDB.Exec(`
INSERT INTO audio_cache (key, original_url, device_id, headers, created_at)
VALUES (?, ?, ?, ?, ?)
`, key, originalURL, deviceID, string(headersJSON), time.Now().Unix())
if err != nil {
audioLogger.Error("保存音频缓存失败: " + err.Error())
}
}
// 返回代理URL
return fmt.Sprintf("/audio/proxy/%s", key)
}
// HandleAudioProxy 处理音频代理请求
func HandleAudioProxy(key string) ([]byte, string, error) {
if cacheDB == nil {
return nil, "", fmt.Errorf("数据库未初始化")
}
// 从数据库获取音频信息
var originalURL, deviceID, headersJSON string
var createdAt int64
cacheMutex.Lock()
err := cacheDB.QueryRow(`
SELECT original_url, device_id, headers, created_at
FROM audio_cache WHERE key = ?
`, key).Scan(&originalURL, &deviceID, &headersJSON, &createdAt)
cacheMutex.Unlock()
if err == sql.ErrNoRows {
return nil, "", fmt.Errorf("音频信息不存在,请先调用 /audio 接口")
}
if err != nil {
return nil, "", fmt.Errorf("查询音频缓存失败: %w", err)
}
// 检查缓存是否过期(2小时,匹配CDN链接有效期)
if time.Now().Unix()-createdAt > 7200 { // 2小时 = 7200秒
// 清理过期缓存
cacheMutex.Lock()
cacheDB.Exec("DELETE FROM audio_cache WHERE key = ?", key)
cacheMutex.Unlock()
return nil, "", fmt.Errorf("音频信息已过期,请重新调用 /audio 接口")
}
// 解析headers
var headers map[string]string
json.Unmarshal([]byte(headersJSON), &headers)
if headers == nil {
headers = make(map[string]string)
}
// 下载音频内容,使用关联的设备信息
data, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 3)
if err != nil {
return nil, "", fmt.Errorf("下载音频失败: %w", err)
}
return data, contentType, nil
}
// CleanExpiredAudioCache 清理过期的音频缓存
func CleanExpiredAudioCache() {
if cacheDB == nil {
return
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
// 删除2小时前的缓存
result, err := cacheDB.Exec("DELETE FROM audio_cache WHERE created_at < ?", time.Now().Unix()-7200)
if err != nil {
audioLogger.Error("清理过期音频缓存失败: " + err.Error())
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected > 0 {
audioLogger.Info(fmt.Sprintf("已清理 %d 条过期音频缓存", rowsAffected))
}
}
+528
View File
@@ -0,0 +1,528 @@
package fqapi
import (
"database/sql"
"encoding/json"
"fanqie/internal/common"
"fanqie/internal/config"
"fmt"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
)
var (
cacheDB *sql.DB
cacheMutex sync.Mutex
cacheLogger = common.NewLogger("cache_manager", "INFO", "")
contentRequestCount int // content接口访问计数
countMutex sync.Mutex
cacheCleanupTicker *time.Ticker // 缓存清理定时器
)
// CacheData 缓存数据结构
type CacheData struct {
Data string `json:"data"`
Expi float64 `json:"expi"`
}
// InitDB 初始化数据库
func InitDB() error {
if cacheDB != nil {
return nil // 已初始化
}
// 使用配置的路径
dbPath := "./cache.db"
cfg := config.GetConfig()
if cfg != nil && cfg.Cache.SQLitePath != "" {
dbPath = cfg.Cache.SQLitePath
}
var err error
cacheDB, err = sql.Open("sqlite3", dbPath)
if err != nil {
cacheLogger.Error("打开数据库失败: " + err.Error())
return err
}
// 创建缓存表(添加created_at字段用于清理)
_, err = cacheDB.Exec(`CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
value TEXT,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)`)
if err != nil {
cacheLogger.Error("创建缓存表失败: " + err.Error())
return err
}
// 创建statistics表(保存请求统计,始终使用)
_, err = cacheDB.Exec(`CREATE TABLE IF NOT EXISTS statistics (
id INTEGER PRIMARY KEY CHECK (id = 1),
total_requests INTEGER DEFAULT 0,
search_requests INTEGER DEFAULT 0,
detail_requests INTEGER DEFAULT 0,
catalog_requests INTEGER DEFAULT 0,
content_requests INTEGER DEFAULT 0,
manga_requests INTEGER DEFAULT 0,
audio_requests INTEGER DEFAULT 0,
video_requests INTEGER DEFAULT 0,
category_requests INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT (strftime('%s', 'now'))
)`)
if err != nil {
cacheLogger.Error("创建statistics表失败: " + err.Error())
return err
}
// 初始化statistics表(如果不存在记录)
_, err = cacheDB.Exec(`INSERT OR IGNORE INTO statistics (id) VALUES (1)`)
if err != nil {
cacheLogger.Error("初始化statistics表失败: " + err.Error())
}
// 创建索引以提高清理效率
_, err = cacheDB.Exec(`CREATE INDEX IF NOT EXISTS idx_created_at ON cache(created_at)`)
if err != nil {
cacheLogger.Error("创建索引失败: " + err.Error())
}
// 创建device_pool表(设备密钥池)
_, err = cacheDB.Exec(`CREATE TABLE IF NOT EXISTS device_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_header TEXT NOT NULL,
key TEXT NOT NULL,
key_ver INTEGER NOT NULL,
is_valid INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL
)`)
if err != nil {
cacheLogger.Error("创建device_pool表失败: " + err.Error())
return err
}
// 创建device_pool索引
_, err = cacheDB.Exec(`CREATE INDEX IF NOT EXISTS idx_device_pool_valid ON device_pool(is_valid)`)
if err != nil {
cacheLogger.Warning("创建device_pool索引失败: " + err.Error())
}
// 创建audio_cache表(音频代理缓存)
_, err = cacheDB.Exec(`CREATE TABLE IF NOT EXISTS audio_cache (
key TEXT PRIMARY KEY,
original_url TEXT NOT NULL,
device_id TEXT NOT NULL,
headers TEXT,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)`)
if err != nil {
cacheLogger.Error("创建audio_cache表失败: " + err.Error())
return err
}
return nil
}
// isCacheEnabled 检查正文内容缓存是否启用
func isCacheEnabled() bool {
cfg := config.GetConfig()
if cfg == nil {
return false
}
return cfg.Cache.Enabled
}
// GetCache 获取正文内容缓存
func GetCache(key string) string {
if !isCacheEnabled() {
return ""
}
if cacheDB == nil {
return ""
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
var value string
err := cacheDB.QueryRow("SELECT value FROM cache WHERE key = ?", key).Scan(&value)
if err != nil {
if err != sql.ErrNoRows {
cacheLogger.Error("查询缓存失败: " + err.Error())
}
return ""
}
var cacheData CacheData
err = json.Unmarshal([]byte(value), &cacheData)
if err != nil {
cacheLogger.Error("解析缓存数据失败: " + err.Error())
return ""
}
cacheLogger.Debug("缓存命中: " + key)
// 检查是否过期
if time.Now().Unix() < int64(cacheData.Expi) {
cacheLogger.Debug("缓存有效!")
return cacheData.Data
}
cacheLogger.Debug("缓存已过期!")
return ""
}
// UpdateCache 更新缓存
// UpdateCache 更新或插入正文内容缓存
func UpdateCache(key string, value string, expi int) error {
if !isCacheEnabled() {
return nil
}
if cacheDB == nil {
return nil
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
cacheData := CacheData{
Data: value,
Expi: float64(time.Now().Unix() + int64(expi)),
}
jsonData, err := json.Marshal(cacheData)
if err != nil {
return err
}
// 检查是否存在
var exists bool
err = cacheDB.QueryRow("SELECT 1 FROM cache WHERE key = ?", key).Scan(&exists)
if err == sql.ErrNoRows {
// 不存在,插入
_, err = cacheDB.Exec("INSERT INTO cache (key, value) VALUES (?, ?)", key, string(jsonData))
} else {
// 存在,更新
_, err = cacheDB.Exec("UPDATE cache SET value = ? WHERE key = ?", string(jsonData), key)
}
if err != nil {
cacheLogger.Error("更新缓存失败: " + err.Error())
return err
}
cacheLogger.Debug("缓存已更新: " + key)
return nil
}
// CloseCache 关闭缓存数据库
func CloseCache() {
if cacheDB != nil {
cacheDB.Close()
}
}
// CleanupAllCache 立即清理所有正文缓存
func CleanupAllCache() error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
// 删除所有缓存数据
_, err := cacheDB.Exec("DELETE FROM cache")
if err != nil {
return err
}
// 智能压缩:仅在空闲空间超过阈值时执行
var freelistCount int64
err = cacheDB.QueryRow("PRAGMA freelist_count").Scan(&freelistCount)
if err == nil && freelistCount > 0 {
var pageSize int64
err = cacheDB.QueryRow("PRAGMA page_size").Scan(&pageSize)
if err == nil {
freeBytes := freelistCount * pageSize
// 阈值:空闲空间超过 5MB 时才执行压缩
const vacuumThreshold = 5 * 1024 * 1024 // 5MB
if freeBytes >= vacuumThreshold {
cacheLogger.Info(fmt.Sprintf("空闲空间 %.2f MB,开始执行压缩...", float64(freeBytes)/(1024*1024)))
_, err = cacheDB.Exec("VACUUM")
if err != nil {
cacheLogger.Warning("数据库压缩失败: " + err.Error())
} else {
cacheLogger.Info(fmt.Sprintf("数据库压缩完成,已释放 %.2f MB 磁盘空间", float64(freeBytes)/(1024*1024)))
}
} else {
cacheLogger.Debug(fmt.Sprintf("空闲空间仅 %.2f MB,跳过压缩", float64(freeBytes)/(1024*1024)))
}
}
}
return nil
}
// CleanupExpiredCache 清理过期的正文缓存
func CleanupExpiredCache() error {
cfg := config.GetConfig()
if cfg == nil {
return fmt.Errorf("配置未加载")
}
// 检查是否启用清理
if !cfg.Cache.CleanupEnabled {
return nil
}
// 检查清理时间配置
if cfg.Cache.CleanupSeconds <= 0 {
return nil
}
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
// 计算过期时间点(当前时间 - cleanup_seconds)
expiredTime := time.Now().Unix() - int64(cfg.Cache.CleanupSeconds)
// 删除过期的缓存(created_at < expiredTime)
result, err := cacheDB.Exec("DELETE FROM cache WHERE created_at < ?", expiredTime)
if err != nil {
cacheLogger.Error("清理过期缓存失败: " + err.Error())
return err
}
// 获取删除的行数
rowsAffected, err := result.RowsAffected()
if err != nil {
cacheLogger.Warning("获取删除行数失败: " + err.Error())
} else if rowsAffected > 0 {
cacheLogger.Info(fmt.Sprintf("已清理 %d 条过期缓存(创建时间早于 %d 秒前)", rowsAffected, cfg.Cache.CleanupSeconds))
}
return nil
}
// StartCacheCleanup 启动缓存自动清理任务
func StartCacheCleanup() {
cfg := config.GetConfig()
if cfg == nil || !cfg.Cache.CleanupEnabled || cfg.Cache.CleanupSeconds <= 0 {
cacheLogger.Info("缓存自动清理未启用或配置无效,跳过启动")
return
}
// 每小时执行一次清理(可以根据需要调整间隔)
cleanupInterval := 1 * time.Hour
cacheCleanupTicker = time.NewTicker(cleanupInterval)
cacheLogger.Info(fmt.Sprintf("缓存自动清理已启动,清理间隔: %v,过期时间: %d 秒", cleanupInterval, cfg.Cache.CleanupSeconds))
// 立即执行一次清理
go func() {
if err := CleanupExpiredCache(); err != nil {
cacheLogger.Error("初始清理失败: " + err.Error())
}
}()
// 定时执行清理
go func() {
for range cacheCleanupTicker.C {
if err := CleanupExpiredCache(); err != nil {
cacheLogger.Error("定时清理失败: " + err.Error())
}
}
}()
}
// StopCacheCleanup 停止缓存自动清理任务
func StopCacheCleanup() {
if cacheCleanupTicker != nil {
cacheCleanupTicker.Stop()
cacheLogger.Info("缓存自动清理已停止")
// 最后执行一次清理
if err := CleanupExpiredCache(); err != nil {
cacheLogger.Error("最后清理失败: " + err.Error())
}
}
}
// IncrementContentRequestCount 增加content接口访问计数
func IncrementContentRequestCount() int {
countMutex.Lock()
defer countMutex.Unlock()
contentRequestCount++
return contentRequestCount
}
// ResetContentRequestCount 重置访问计数
func ResetContentRequestCount() {
countMutex.Lock()
defer countMutex.Unlock()
contentRequestCount = 0
}
// GetContentRequestCount 获取当前访问计数
func GetContentRequestCount() int {
countMutex.Lock()
defer countMutex.Unlock()
return contentRequestCount
}
// VacuumDatabase 压缩数据库,释放磁盘空间
func VacuumDatabase() error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
cacheLogger.Info("开始执行数据库压缩...")
_, err := cacheDB.Exec("VACUUM")
if err != nil {
cacheLogger.Error("数据库压缩失败: " + err.Error())
return err
}
cacheLogger.Info("数据库压缩完成,磁盘空间已释放")
return nil
}
// GetDatabaseStats 获取数据库统计信息
func GetDatabaseStats() (map[string]interface{}, error) {
if cacheDB == nil {
return nil, fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
stats := make(map[string]interface{})
// 获取cache表记录数
var cacheCount int
err := cacheDB.QueryRow("SELECT COUNT(*) FROM cache").Scan(&cacheCount)
if err != nil {
cacheLogger.Warning("获取cache表记录数失败: " + err.Error())
cacheCount = 0
}
stats["cache_count"] = cacheCount
// 获取数据库页面统计
var pageCount, pageSize, freelistCount int64
err = cacheDB.QueryRow("PRAGMA page_count").Scan(&pageCount)
if err == nil {
stats["page_count"] = pageCount
}
err = cacheDB.QueryRow("PRAGMA page_size").Scan(&pageSize)
if err == nil {
stats["page_size"] = pageSize
}
err = cacheDB.QueryRow("PRAGMA freelist_count").Scan(&freelistCount)
if err == nil {
stats["freelist_count"] = freelistCount
stats["total_size_bytes"] = pageCount * pageSize
stats["free_size_bytes"] = freelistCount * pageSize
stats["used_size_bytes"] = (pageCount - freelistCount) * pageSize
}
return stats, nil
}
// SaveStatistics 保存请求统计到数据库
func SaveStatistics(stats map[string]int64) error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
_, err := cacheDB.Exec(`
UPDATE statistics
SET total_requests = ?,
search_requests = ?,
detail_requests = ?,
catalog_requests = ?,
content_requests = ?,
manga_requests = ?,
audio_requests = ?,
video_requests = ?,
category_requests = ?,
updated_at = strftime('%s', 'now')
WHERE id = 1
`, stats["total"], stats["search"], stats["detail"], stats["catalog"],
stats["content"], stats["manga"], stats["audio"], stats["video"], stats["category"])
if err != nil {
cacheLogger.Error("保存统计数据失败: " + err.Error())
return err
}
return nil
}
// LoadStatistics 从数据库加载请求统计
func LoadStatistics() (map[string]int64, error) {
if cacheDB == nil {
return nil, fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
var total, search, detail, catalog, content, manga, audio, video, category int64
err := cacheDB.QueryRow(`
SELECT total_requests, search_requests, detail_requests, catalog_requests,
content_requests, manga_requests, audio_requests, video_requests, category_requests
FROM statistics WHERE id = 1
`).Scan(&total, &search, &detail, &catalog, &content, &manga, &audio, &video, &category)
if err == sql.ErrNoRows {
// 没有数据,返回空统计
return map[string]int64{
"total": 0, "search": 0, "detail": 0, "catalog": 0,
"content": 0, "manga": 0, "audio": 0, "video": 0, "category": 0,
}, nil
}
if err != nil {
cacheLogger.Error("加载统计数据失败: " + err.Error())
return nil, err
}
stats := map[string]int64{
"total": total,
"search": search,
"detail": detail,
"catalog": catalog,
"content": content,
"manga": manga,
"audio": audio,
"video": video,
"category": category,
}
return stats, nil
}
+207
View File
@@ -0,0 +1,207 @@
package fqapi
import (
"encoding/json"
"fanqie/internal/common"
"fmt"
"strconv"
"strings"
)
var catalogLogger = common.NewLogger("catalog", "INFO", "")
// HandleGetCatalog 处理获取书籍目录
func HandleGetCatalog(bookID string) (string, error) {
if bookID == "" {
return BuildErrorResponse("需要参数 book_id"), nil
}
// 构建URL
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/directory/all_items/v/", "book_id="+bookID)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
catalogLogger.Error(fmt.Sprintf("获取目录请求失败: %v", err))
// 尝试备用接口
return offShelfCatalog(bookID)
}
respJSON, err := resp.JSON()
if err != nil {
catalogLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
return offShelfCatalog(bookID)
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
// 删除不需要的字段
if data, ok := respJSON["data"].(map[string]interface{}); ok {
delete(data, "item_list")
delete(data, "book_info")
delete(data, "catalog_data")
}
// 重新序列化
resultBytes, err := json.Marshal(respJSON)
if err != nil {
return BuildErrorResponse("序列化失败"), err
}
return string(resultBytes), nil
}
// 如果主接口失败,尝试备用接口
return offShelfCatalog(bookID)
}
// offShelfCatalog 下架书籍目录获取(备用接口)
func offShelfCatalog(bookID string) (string, error) {
// 第一步:获取所有item_id列表
url := "https://fanqienovel.com/api/reader/directory/detail?bookId=" + bookID
client := common.NewHTTPClient()
resp, err := client.Get(url, map[string]string{
"Referer": "https://fanqienovel.com/page/" + bookID,
"Origin": "https://fanqienovel.com",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36",
})
if err != nil {
catalogLogger.Error(fmt.Sprintf("备用接口请求失败: %v", err))
return BuildErrorResponse("FAILED"), err
}
var itemListResponse map[string]interface{}
err = json.Unmarshal(resp.Content, &itemListResponse)
if err != nil {
return BuildErrorResponse("FAILED"), err
}
data, ok := itemListResponse["data"].(map[string]interface{})
if !ok {
return BuildErrorResponse("响应格式错误"), fmt.Errorf("响应格式错误")
}
allItemIDs, ok := data["allItemIds"].([]interface{})
if !ok {
return BuildErrorResponse("无法获取item列表"), fmt.Errorf("无法获取item列表")
}
// 转换为字符串数组
itemList := make([]string, len(allItemIDs))
for i, id := range allItemIDs {
itemList[i] = fmt.Sprintf("%v", id)
}
// 分批处理(每批100个)
chunkSize := 100
var result []map[string]interface{}
for i := 0; i < len(itemList); i += chunkSize {
end := i + chunkSize
if end > len(itemList) {
end = len(itemList)
}
chunk := itemList[i:end]
details, err := itemMultiDetail(chunk)
if err == nil {
result = append(result, details...)
}
}
// 构建响应
response := map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": map[string]interface{}{
"item_data_list": result,
},
}
resultBytes, _ := json.Marshal(response)
return string(resultBytes), nil
}
// itemMultiDetail 批量获取章节详情
func itemMultiDetail(itemList []string) ([]map[string]interface{}, error) {
url := "https://novel.snssdk.com/api/novel/book/directory/detail/v/?aid=1319&item_ids=" + strings.Join(itemList, ",")
client := common.NewHTTPClient()
resp, err := client.Get(url, map[string]string{
"User-Agent": "okhttp/4.10.0",
})
if err != nil {
return nil, err
}
var dataResp map[string]interface{}
err = json.Unmarshal(resp.Content, &dataResp)
if err != nil {
return nil, err
}
if code, ok := dataResp["code"].(float64); ok && code == 0 {
chapters, ok := dataResp["data"].([]interface{})
if !ok {
return nil, fmt.Errorf("响应格式错误")
}
var result []map[string]interface{}
for _, ch := range chapters {
chapter, ok := ch.(map[string]interface{})
if !ok {
continue
}
versionStr := ""
if version, ok := chapter["version"].(string); ok && len(version) >= 32 {
versionStr = version[:32]
}
// 安全解析 first_pass_time
firstPassTime := 0
if fpt, ok := chapter["first_pass_time"].(float64); ok {
firstPassTime = int(fpt)
} else if fptStr, ok := chapter["first_pass_time"].(string); ok && fptStr != "" {
// 尝试解析字符串形式的数字
if fptVal, err := strconv.ParseFloat(fptStr, 64); err == nil {
firstPassTime = int(fptVal)
}
}
// 安全解析 chapter_word_number
chapterWordNumber := 0
if cwn, ok := chapter["chapter_word_number"].(float64); ok {
chapterWordNumber = int(cwn)
} else if cwnStr, ok := chapter["chapter_word_number"].(string); ok && cwnStr != "" {
if cwnVal, err := strconv.ParseFloat(cwnStr, 64); err == nil {
chapterWordNumber = int(cwnVal)
}
}
result = append(result, map[string]interface{}{
"item_id": chapter["item_id"],
"version": nil,
"content_md5": versionStr,
"first_pass_time": firstPassTime,
"title": chapter["title"],
"volume_name": chapter["volume_name"],
"chapter_type": chapter["chapter_type"],
"chapter_word_number": chapterWordNumber,
"is_review": nil,
})
}
return result, nil
}
return nil, fmt.Errorf("请求失败")
}
+44
View File
@@ -0,0 +1,44 @@
package fqapi
import (
"fanqie/internal/common"
"fmt"
)
var categoryFrontLogger = common.NewLogger("category_front", "INFO", "")
// HandleCategoryFront 处理获取分类列表
func HandleCategoryFront(tab interface{}) (string, error) {
tabInt := ParseIntParam(tab, 0)
reqURL := BuildURL(
"https://reading.snssdk.com/reading/bookapi/new_category/front/v/",
fmt.Sprintf("new_category_tab=%d", tabInt),
fmt.Sprintf("update_version_code=%s", "58932"),
"distinct_style=1",
)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
categoryFrontLogger.Error(fmt.Sprintf("获取分类列表失败: %v", err))
return BuildErrorResponse("请求失败"), err
}
respJSON, err := resp.JSON()
if err != nil {
return BuildErrorResponse("解析响应失败"), err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
return resp.Text, nil
}
return BuildErrorResponse("获取失败"), nil
}
+56
View File
@@ -0,0 +1,56 @@
package fqapi
import (
"fanqie/internal/common"
"fmt"
)
var categoryLandingLogger = common.NewLogger("category_landing", "INFO", "")
// HandleCategoryLanding 处理获取分类详情
func HandleCategoryLanding(categoryID, offset, wordNumber, bookStatus, sortBy interface{}) (string, error) {
// 参数转换
categoryIDInt := ParseIntParam(categoryID, 0)
offsetInt := ParseIntParam(offset, 0)
wordNumberInt := ParseIntParam(wordNumber, 0)
bookStatusInt := ParseIntParam(bookStatus, 0)
sortByInt := ParseIntParam(sortBy, 0)
if categoryIDInt == 0 {
return BuildErrorResponseWithCode(100144, "参数错误"), nil
}
reqURL := BuildURL(
"https://reading.snssdk.com/reading/bookapi/new_category/landing/v/",
fmt.Sprintf("category_id=%d", categoryIDInt),
fmt.Sprintf("limit=%d", 20),
fmt.Sprintf("offset=%d", offsetInt),
fmt.Sprintf("word_number=%d", wordNumberInt),
fmt.Sprintf("book_status=%d", bookStatusInt),
fmt.Sprintf("sort_by=%d", sortByInt),
)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
categoryLandingLogger.Error(fmt.Sprintf("获取分类详情失败: %v", err))
return BuildErrorResponse("请求失败"), err
}
respJSON, err := resp.JSON()
if err != nil {
return BuildErrorResponse("解析响应失败"), err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
return resp.Text, nil
}
return BuildErrorResponse("获取失败"), nil
}
+279
View File
@@ -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("所有接口都失败")
}
+187
View File
@@ -0,0 +1,187 @@
package fqapi
import (
"fanqie/internal/common"
"fanqie/internal/config"
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
)
var (
parserLogger = common.NewLogger("content_parser", "INFO", "")
)
// ContentParser 内容解析器
type ContentParser struct {
cfg config.ContentConfig
}
// NewContentParser 创建内容解析器
func NewContentParser() *ContentParser {
return &ContentParser{
cfg: getContentConfig(),
}
}
// Parse 解析内容(URL参数优先于配置)
func (p *ContentParser) Parse(content string, textMode bool, imageMode bool) string {
// 1. 清理XML声明和DOCTYPE
content = p.cleanXMLDeclaration(content)
// 2. 根据配置移除章节标题
if p.cfg.RemoveChapterTitle {
content = p.removeChapterTitle(content)
}
// 3. 根据配置处理图片描述
if p.cfg.RemoveImageDesc {
content = p.removeImageDesc(content)
}
// 4. 根据参数或配置移除图片
// URL参数 image_mode=0 或配置 remove_images=true 时移除图片
if !imageMode || p.cfg.RemoveImages {
content = p.removeImages(content)
}
// 5. 根据参数或配置处理内容模式
// URL参数 text_mode=1 或配置 remove_html_tags=true 时使用文本模式
if textMode || p.cfg.RemoveHTMLTags {
return p.extractText(content)
}
// 6. HTML模式处理
return p.processHTMLMode(content)
}
// cleanXMLDeclaration 清理XML声明
func (p *ContentParser) cleanXMLDeclaration(content string) string {
content = regexp.MustCompile(`<\?xml[^>]*\?>`).ReplaceAllString(content, "")
content = regexp.MustCompile(`<!DOCTYPE[^>]*>`).ReplaceAllString(content, "")
return strings.TrimSpace(content)
}
// removeChapterTitle 移除章节标题
func (p *ContentParser) removeChapterTitle(content string) string {
// 移除 h1 和 h2 标签中的章节标题
content = regexp.MustCompile(`(?si)<h[12][^>]*class="[^"]*chapter[Tt]itle[^"]*"[^>]*>.*?</h[12]>`).ReplaceAllString(content, "")
// 同时移除 <title> 标签
content = regexp.MustCompile(`(?si)<title>.*?</title>`).ReplaceAllString(content, "")
return content
}
// removeImageDesc 移除图片描述
func (p *ContentParser) removeImageDesc(content string) string {
return regexp.MustCompile(`(?s)<p[^>]*class="pictureDesc"[^>]*>.*?</p>`).ReplaceAllString(content, "")
}
// removeImages 移除图片
func (p *ContentParser) removeImages(content string) string {
return regexp.MustCompile(`(?s)<div[^>]*data-fanqie-type="image"[^>]*>.*?</div>`).ReplaceAllString(content, "")
}
// extractText 提取文本
func (p *ContentParser) extractText(content string) string {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
if err != nil {
parserLogger.Error("解析HTML失败: " + err.Error())
return content
}
finalResult := make([]string, 0)
// 查找body或article
body := doc.Find("body")
article := body.Find("article")
var elements *goquery.Selection
if article.Length() > 0 {
elements = article
} else {
elements = body
}
// 遍历所有直接子元素
elements.Children().Each(func(i int, item *goquery.Selection) {
// 检查是否是图片div
dataType, _ := item.Attr("data-fanqie-type")
isImageDiv := dataType == "image"
if isImageDiv {
// 这是图片div
if !p.cfg.RemoveImages {
// 保留完整的图片div结构
divHTML, _ := goquery.OuterHtml(item)
// 解码HTML实体
divHTML = decodeHTMLEntities(divHTML)
// 标准化img属性
divHTML = normalizeImgAttributes(divHTML)
finalResult = append(finalResult, divHTML)
}
return
}
// 普通文本元素
if item.Children().Length() == 0 {
text := strings.TrimSpace(item.Text())
if text != "" {
finalResult = append(finalResult, text)
}
} else {
// 递归提取文本
item.Find("p").Each(func(j int, p *goquery.Selection) {
text := strings.TrimSpace(p.Text())
if text != "" {
finalResult = append(finalResult, text)
}
})
}
})
// 拼接结果
var result string
// 根据配置决定段落间的换行符
separator := "\n"
if p.cfg.DoubleNewline {
separator = "\n\n"
}
if p.cfg.AddParagraphIndent && len(finalResult) > 0 {
// 添加段落缩进(两个全角空格)
result = "\u3000\u3000" + strings.Join(finalResult, separator+"\u3000\u3000")
} else {
// 不添加缩进
result = strings.Join(finalResult, separator)
}
// 根据配置决定是否移除空行
if p.cfg.RemoveBlankLines {
result = removeBlankLines(result)
}
return result
}
// processHTMLMode 处理HTML模式
func (p *ContentParser) processHTMLMode(content string) string {
// 标准化img属性
content = normalizeImgAttributes(content)
// 解码HTML实体
content = decodeHTMLEntities(content)
// 根据配置决定是否移除空行
if p.cfg.RemoveBlankLines {
// 对HTML内容移除空的<p>标签
content = regexp.MustCompile(`(?s)<p[^>]*>\s*</p>`).ReplaceAllString(content, "")
// 移除多余的空白
content = regexp.MustCompile(`\n\s*\n\s*\n`).ReplaceAllString(content, "\n\n")
}
return content
}
+265
View File
@@ -0,0 +1,265 @@
package fqapi
import (
"encoding/json"
"fanqie/internal/config"
"sort"
"strings"
)
// getContentConfig 获取内容配置(带默认值)
func getContentConfig() config.ContentConfig {
if config.AppConfig != nil {
return config.AppConfig.Fanqie.Content
}
// 返回默认配置
return config.ContentConfig{
RemoveImages: false,
RemoveImageDesc: false,
RemoveBlankLines: true,
RemoveHTMLTags: false,
RemoveChapterTitle: false,
AddParagraphIndent: false,
}
}
// decodeHTMLEntities 解码HTML实体
func decodeHTMLEntities(content string) string {
// 解码常见HTML实体
content = strings.ReplaceAll(content, "&amp;", "&")
content = strings.ReplaceAll(content, "&lt;", "<")
content = strings.ReplaceAll(content, "&gt;", ">")
content = strings.ReplaceAll(content, "&quot;", "\"")
content = strings.ReplaceAll(content, "&#34;", "\"")
content = strings.ReplaceAll(content, "&#39;", "'")
content = strings.ReplaceAll(content, "&apos;", "'")
content = strings.ReplaceAll(content, "&#x27;", "'")
content = strings.ReplaceAll(content, "&#x2F;", "/")
// 解码转义的反斜杠
content = strings.ReplaceAll(content, "\\\"", "\"")
return content
}
// normalizeImgAttributes 将img标签中的img-width和img-height属性替换为标准的width和height属性
func normalizeImgAttributes(content string) string {
// 替换 img-width 为 width
content = strings.ReplaceAll(content, "img-width=", "width=")
// 替换 img-height 为 height
content = strings.ReplaceAll(content, "img-height=", "height=")
return content
}
// removeBlankLines 移除多余空行,但保留段落间的双换行
func removeBlankLines(content string) string {
// 先将多个连续换行(3个及以上)规范化为双换行
for strings.Contains(content, "\n\n\n") {
content = strings.ReplaceAll(content, "\n\n\n", "\n\n")
}
// 移除首尾的空白
content = strings.TrimSpace(content)
return content
}
// ============ 参数解析工具函数 ============
// parseBoolParam 解析布尔参数
func parseBoolParam(param interface{}) bool {
switch v := param.(type) {
case string:
return v == "true" || v == "1" || strings.ToLower(v) == "true"
case bool:
return v
}
return false
}
// parseBoolParamDefault 解析布尔参数(带默认值)
func parseBoolParamDefault(param interface{}, defaultVal bool) bool {
if param == nil || param == "" {
return defaultVal
}
return parseBoolParam(param)
}
// ============ JSON 处理工具函数 ============
// escapeJSON 转义JSON中的特殊字符
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, "<", "\\u003c")
s = strings.ReplaceAll(s, ">", "\\u003e")
return s
}
// buildJSONResponse 构建JSON响应并转义
func buildJSONResponse(data interface{}) string {
resultBytes, _ := json.Marshal(data)
return escapeJSON(string(resultBytes))
}
// ============ 设备和密钥管理 ============
// getDeviceHeader 获取设备header(从设备池)
func getDeviceHeader() string {
// 从设备池获取任意有效设备header
return GetAnyValidDeviceHeader()
}
// removeVersionFromHeader 移除header中的version_name参数
func removeVersionFromHeader(header string) string {
return strings.Replace(header, "&version_name=5.8.9.32", "", -1)
}
// ============ API参数构建工具 ============
// BuildURL 构建API请求URL
// 用法: BuildURL("https://reading.snssdk.com/reading/bookapi/detail/v/", "book_id=123", "tab_type=3")
func BuildURL(fullURL string, customParams ...string) string {
// 基础参数
allParams := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"os_version=0",
"version_code=58932",
}
// 添加设备header
header := getDeviceHeader()
if header != "" {
allParams = append(allParams, header)
}
// 添加自定义参数
allParams = append(allParams, customParams...)
// 合并、拆分、排序
joined := strings.Join(allParams, "&")
paramList := strings.Split(joined, "&")
sort.Strings(paramList)
params := strings.Join(paramList, "&")
return fullURL + "?" + params
}
// ============ 缓存处理工具函数 ============
// buildCacheData 构建缓存数据结构
func buildCacheData(data interface{}) map[string]interface{} {
return map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": data,
}
}
// saveCacheJSON 保存JSON到缓存
func saveCacheJSON(key string, data interface{}, ttl int) {
cacheJSON, _ := json.Marshal(data)
cacheStr := escapeJSON(string(cacheJSON))
UpdateCache(key, cacheStr, ttl)
}
// ============ 错误响应工具函数 ============
// BuildErrorResponse 构建统一的错误响应
func BuildErrorResponse(msg string) string {
return buildJSONResponse(map[string]interface{}{
"code": -1,
"msg": msg,
})
}
// BuildErrorResponseWithCode 构建带自定义错误码的响应
func BuildErrorResponseWithCode(code int, msg string) string {
return buildJSONResponse(map[string]interface{}{
"code": code,
"msg": msg,
})
}
// BuildSuccessResponse 构建成功响应
func BuildSuccessResponse(data interface{}) string {
return buildJSONResponse(map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": data,
})
}
// ============ 参数转换工具函数 ============
// ParseIntParam 解析整数参数(支持多种类型)
func ParseIntParam(param interface{}, defaultVal int) int {
switch v := param.(type) {
case string:
if v != "" {
// 使用strconv.Atoi
if result, err := parseStringToInt(v); err == nil {
return result
}
}
case int:
return v
case float64:
return int(v)
case int64:
return int(v)
}
return defaultVal
}
// parseStringToInt 辅助函数:将字符串转换为整数
func parseStringToInt(s string) (int, error) {
// 简单实现,避免导入strconv
result := 0
negative := false
if len(s) == 0 {
return 0, nil
}
start := 0
if s[0] == '-' {
negative = true
start = 1
} else if s[0] == '+' {
start = 1
}
for i := start; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
break
}
result = result*10 + int(s[i]-'0')
}
if negative {
result = -result
}
return result, nil
}
// ParseStringParam 解析字符串参数(支持数组类型自动Join)
func ParseStringParam(param interface{}, defaultVal string) string {
if param == nil || param == "" {
return defaultVal
}
switch v := param.(type) {
case string:
if v != "" {
return v
}
case []string:
// 数组类型,用逗号连接
return strings.Join(v, ",")
}
return defaultVal
}
+55
View File
@@ -0,0 +1,55 @@
package fqapi
import (
"encoding/json"
"fanqie/internal/common"
"fmt"
)
var detailLogger = common.NewLogger("detail", "INFO", "")
// HandleGetDetail 处理获取书籍详情
func HandleGetDetail(bookID string) (string, error) {
if bookID == "" {
return BuildErrorResponse("需要参数 book_id"), nil
}
// 构建URL
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/detail/v/", "book_id="+bookID)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
detailLogger.Error(fmt.Sprintf("获取详情请求失败: %v", err))
return BuildErrorResponse("获取详情请求失败"), err
}
respJSON, err := resp.JSON()
if err != nil {
detailLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
return BuildErrorResponse("解析响应失败"), err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
// 删除content字段
if data, ok := respJSON["data"].(map[string]interface{}); ok {
delete(data, "content")
}
// 重新序列化
resultBytes, err := json.Marshal(respJSON)
if err != nil {
return BuildErrorResponse("序列化失败"), err
}
return string(resultBytes), nil
}
return BuildErrorResponse("获取失败"), nil
}
+102
View File
@@ -0,0 +1,102 @@
package fqapi
// DeviceConfig 设备配置
var DeviceConfig = struct {
AndroidVersions [][2]interface{}
BrandModels map[string][]string
DeviceBrands []string
}{
// Android 版本列表 [版本号, API Level]
AndroidVersions: [][2]interface{}{
{"9", 29}, {"10", 30}, {"11", 31},
{"12", 32}, {"13", 33}, {"14", 34},
{"15", 35}, {"16", 36},
},
// 品牌和型号对应关系
BrandModels: map[string][]string{
"Xiaomi": {
"MI8", "MI9", "MI10", "MI11", "MI12", "MI13", "MI14", "MI15",
"MIX2S", "MIX3", "MIX4", "MIXFold3", "MIXFold4",
"2201122C", "2206123SC", "2211133C", "2304FPN6DC", "23013RK75C",
"24031PN0DC", "24116PN88C", "2409ARN8BC", "25013PN75C",
},
"Redmi": {
"RedmiK20", "RedmiK30", "RedmiK40", "RedmiK50", "RedmiK60", "RedmiK70", "RedmiK80",
"RedmiNote8", "RedmiNote9", "RedmiNote10", "RedmiNote11", "RedmiNote12", "RedmiNote13", "RedmiNote14",
"RedmiTurbo3", "RedmiTurbo4",
},
"OPPO": {
"PCCM00", "PDEM10", "PDEM30", "PDYM20", "PDPM00", "PDSM00", "PDST00",
"CPH2015", "CPH2023", "CPH2145", "CPH2173", "CPH2179", "CPH2207", "CPH2211",
"CPH2247", "CPH2271", "CPH2333", "CPH2365", "CPH2437", "CPH2451", "CPH2473",
"CPH2477", "CPH2551", "CPH2591", "CPH2613", "CPH2647", "CPH2719", "CPH2759",
"PGBM10", "PGJM10", "PGKM10", "PCLM50", "PECM00", "PEXM00",
"PJCM10", "PJEM00", "PKBM00", "PKCM00",
},
"vivo": {
"V1921A", "V1923A", "V1924A", "V1928A", "V1930A", "V1932A", "V1936A",
"V2001A", "V2011A", "V2012A", "V2020A", "V2021A", "V2022A", "V2023A",
"V2024A", "V2025A", "V2031A", "V2032A", "V2046A", "V2047A", "V2048A",
"V2049A", "V2050A", "V2056A", "V2057A", "V2059A", "V2061A", "V2065A",
"V2070A", "V2072A", "V2073A", "V2080A", "V2099A", "V2111A", "V2115A",
"V2121A", "V2134A", "V2141A", "V2145A", "V2156A", "V2162A", "V2171A",
"V2199A", "V2207A", "V2218A", "V2227A", "V2231A", "V2247A", "V2266A",
"V2280A", "V2296A", "V2318A", "V2327A", "V2338A", "V2352A", "V2366A",
"V2401A", "V2428A", "V2451A", "V2473A",
},
"iQOO": {
"I2007", "I2012", "I2018", "I2019", "V2130A", "V2141A", "V2156A",
"I2207", "I2214", "I2301", "I2302", "V2307A", "V2352A",
"I2401", "I2409",
},
"Huawei": {
"P30", "P40", "P50", "P60", "P70",
"Mate20", "Mate30", "Mate40", "Mate50", "Mate60", "MateX5", "MateXT",
"Nova5", "Nova6", "Nova7", "Nova8", "Nova9", "Nova10", "Nova11", "Nova12", "Nova13",
"ELE-AL00", "ANA-AN00", "TAS-AN00", "VOG-AL10", "LYA-AL00", "MAR-AL00",
"NOH-AN00", "ABR-AL00", "BRQ-AN00", "ELS-AN00", "FRL-AN00A", "JAD-AL50",
"ALN-AL00", "DBY-AN00", "CET-AN00", "OXP-AN00", "PGT-AN00",
},
"Honor": {
"Honor10", "Honor20", "Honor30", "Honor50", "Honor60", "Honor70", "Honor80", "Honor90", "Honor100", "Honor200",
"HonorMagic5", "HonorMagic6", "HonorMagic7",
"HonorX40", "HonorX50", "HonorX60",
},
"realme": {
"RMX1931", "RMX2001", "RMX2021", "RMX2061", "RMX2071", "RMX2085", "RMX2111",
"RMX2121", "RMX2141", "RMX2155", "RMX2161", "RMX2170", "RMX2173", "RMX2176",
"RMX2202", "RMX2205", "RMX3031", "RMX3033", "RMX3061", "RMX3063", "RMX3081",
"RMX3085", "RMX3092", "RMX3141", "RMX3142", "RMX3151", "RMX3161", "RMX3171",
"RMX3195", "RMX3197", "RMX3201", "RMX3231", "RMX3241", "RMX3261", "RMX3263",
"RMX3300", "RMX3310", "RMX3350", "RMX3357", "RMX3363", "RMX3371", "RMX3381",
"RMX3461", "RMX3478", "RMX3506", "RMX3511", "RMX3516", "RMX3551", "RMX3562",
"RMX3630", "RMX3660", "RMX3686", "RMX3687", "RMX3700", "RMX3710", "RMX3785",
"RMX3800", "RMX3890", "RMX3933", "RMX3999", "RMX4001", "RMX4100", "RMX4200",
"RMX4311", "RMX4400", "RMX4506", "RMX4600", "RMX4710",
},
"Samsung": {
"SM-G9600", "SM-G9650", "SM-G9730", "SM-G9750", "SM-G9810", "SM-G9880",
"SM-G9900", "SM-G9910", "SM-G9960", "SM-G9980", "SM-N9600", "SM-N9700",
"SM-N9750", "SM-N9760", "SM-N9810", "SM-N9860",
"SM-A5160", "SM-A6060", "SM-A7070", "SM-A8050", "SM-A9080", "SM-A9200",
"SM-F7070", "SM-F9000", "SM-F9160", "SM-W2021", "SM-W2022", "SM-W2023",
"SM-S9180", "SM-S9280", "SM-S9360", "SM-S9480",
"SM-F9360", "SM-F9460", "SM-F9560", "SM-F9660",
"SM-A5560", "SM-A7260", "SM-A8860",
},
"OnePlus": {
"GM1900", "GM1910", "HD1900", "HD1910", "IN2010", "IN2020", "LE2100", "LE2110",
"MT2110", "MT2111", "NE2210", "NE2211", "CPH2413", "CPH2423", "CPH2449",
"PGP110", "PHB110", "PHK110", "CPH2525", "CPH2573", "CPH2609", "PHT110",
"CPH2617", "CPH2667", "CPH2729", "CPH2747", "CPH2805",
"PJA110", "PJB110", "PJC110", "PJD110",
},
},
// 所有支持的品牌列表
DeviceBrands: []string{
"Xiaomi", "Redmi", "OPPO", "vivo", "iQOO",
"Huawei", "Honor", "realme", "Samsung", "OnePlus",
},
}
+582
View File
@@ -0,0 +1,582 @@
package fqapi
import (
"fanqie/internal/common"
"fanqie/internal/config"
"fmt"
"sync"
"time"
)
var (
poolLogger = common.NewLogger("device_key_pool", "INFO", "")
globalPool *DeviceKeyPool
poolOnce sync.Once
)
// DeviceKeyItem 设备密钥项
type DeviceKeyItem struct {
ID int `json:"id"`
DeviceHeader string `json:"device_header"`
Key string `json:"key"`
KeyVer int `json:"key_ver"`
IsValid bool `json:"is_valid"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt time.Time `json:"last_used_at"`
}
// DeviceKeyPool 设备密钥池
type DeviceKeyPool struct {
mu sync.RWMutex
currentIndex int
items []*DeviceKeyItem
enabled bool
}
// GetDeviceKeyPool 获取全局设备密钥池实例
func GetDeviceKeyPool() *DeviceKeyPool {
poolOnce.Do(func() {
globalPool = &DeviceKeyPool{
items: make([]*DeviceKeyItem, 0),
enabled: false,
}
})
return globalPool
}
// Initialize 初始化设备密钥池
func (p *DeviceKeyPool) Initialize() error {
cfg := config.GetConfig()
if cfg == nil || !cfg.DeviceKeyPool.Enabled {
poolLogger.Info("设备密钥池未启用")
p.enabled = false
return nil
}
p.enabled = true
poolLogger.Info("开始初始化设备密钥池...")
// 从数据库加载已有的设备
if err := p.loadFromDB(); err != nil {
poolLogger.Warning("从数据库加载设备失败: " + err.Error())
}
// 检查并补充设备到初始化数量
validCount := p.getValidCount()
initialSize := cfg.DeviceKeyPool.InitialSize
if validCount < initialSize {
needCount := initialSize - validCount
poolLogger.Info(fmt.Sprintf("当前有效设备数: %d, 需要补充: %d", validCount, needCount))
for i := 0; i < needCount; i++ {
if err := p.addNewDevice(); err != nil {
poolLogger.Error(fmt.Sprintf("添加设备失败: %v", err))
}
}
}
poolLogger.Info(fmt.Sprintf("设备密钥池初始化完成,当前有效设备数: %d", p.getValidCount()))
return nil
}
// IsEnabled 检查设备密钥池是否启用
func (p *DeviceKeyPool) IsEnabled() bool {
return p.enabled
}
// GetNext 获取下一个可用的设备密钥(轮询方式)
func (p *DeviceKeyPool) GetNext() (*DeviceKeyItem, error) {
if !p.enabled {
return nil, fmt.Errorf("设备密钥池未启用")
}
p.mu.Lock()
defer p.mu.Unlock()
// 过滤有效设备
validItems := make([]*DeviceKeyItem, 0)
for _, item := range p.items {
if item.IsValid {
validItems = append(validItems, item)
}
}
if len(validItems) == 0 {
return nil, fmt.Errorf("没有可用的设备")
}
// 轮询获取下一个设备
item := validItems[p.currentIndex%len(validItems)]
p.currentIndex++
// 更新最后使用时间
item.LastUsedAt = time.Now()
p.updateDeviceInDB(item)
// poolLogger.Debug(fmt.Sprintf("使用设备 ID=%d", item.ID))
// 异步检查是否需要补充
go p.checkAndRefill()
return item, nil
}
// MarkInvalid 标记设备为无效
func (p *DeviceKeyPool) MarkInvalid(deviceHeader string) error {
if !p.enabled {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
for _, item := range p.items {
if item.DeviceHeader == deviceHeader {
item.IsValid = false
// poolLogger.Warning(fmt.Sprintf("设备 ID=%d 已标记为无效", item.ID))
// 更新数据库
if err := p.updateDeviceInDB(item); err != nil {
return err
}
// 异步补充新设备
go p.checkAndRefill()
return nil
}
}
return fmt.Errorf("未找到设备")
}
// RemoveInvalid 移除所有无效设备并自动补充
func (p *DeviceKeyPool) RemoveInvalid() error {
if !p.enabled {
return nil
}
p.mu.Lock()
// 从数据库中删除无效设备
if err := p.removeInvalidFromDB(); err != nil {
p.mu.Unlock()
return err
}
// 从内存中移除无效设备
validItems := make([]*DeviceKeyItem, 0)
for _, item := range p.items {
if item.IsValid {
validItems = append(validItems, item)
}
}
removedCount := len(p.items) - len(validItems)
p.items = validItems
if removedCount > 0 {
poolLogger.Info(fmt.Sprintf("已移除 %d 个无效设备", removedCount))
// 立即检查并补充设备(使用已持有锁的版本)
p.checkAndRefillLocked()
}
p.mu.Unlock()
return nil
}
// GetStats 获取池统计信息
func (p *DeviceKeyPool) GetStats() map[string]interface{} {
p.mu.RLock()
defer p.mu.RUnlock()
validCount := 0
invalidCount := 0
for _, item := range p.items {
if item.IsValid {
validCount++
} else {
invalidCount++
}
}
return map[string]interface{}{
"enabled": p.enabled,
"total": len(p.items),
"valid": validCount,
"invalid": invalidCount,
"current_index": p.currentIndex,
}
}
// checkAndRefill 检查并补充设备(私有方法,会自动加锁)
func (p *DeviceKeyPool) checkAndRefill() {
if !p.enabled {
return
}
cfg := config.GetConfig()
if cfg == nil {
return
}
p.mu.Lock()
validCount := p.getValidCount()
p.mu.Unlock()
minSize := cfg.DeviceKeyPool.MinSize
maxSize := cfg.DeviceKeyPool.MaxSize
if validCount < minSize {
needCount := minSize - validCount
// 限制不超过最大值
if validCount+needCount > maxSize {
needCount = maxSize - validCount
}
if needCount > 0 {
poolLogger.Info(fmt.Sprintf("有效设备数 %d 低于最小值 %d,开始补充 %d 个设备", validCount, minSize, needCount))
for i := 0; i < needCount; i++ {
if err := p.addNewDevice(); err != nil {
poolLogger.Error(fmt.Sprintf("补充设备失败: %v", err))
}
}
}
}
}
// checkAndRefillLocked 检查并补充设备(已持有锁的内部版本)
func (p *DeviceKeyPool) checkAndRefillLocked() {
if !p.enabled {
return
}
cfg := config.GetConfig()
if cfg == nil {
return
}
validCount := p.getValidCount()
minSize := cfg.DeviceKeyPool.MinSize
maxSize := cfg.DeviceKeyPool.MaxSize
if validCount < minSize {
needCount := minSize - validCount
// 限制不超过最大值
if validCount+needCount > maxSize {
needCount = maxSize - validCount
}
if needCount > 0 {
poolLogger.Info(fmt.Sprintf("有效设备数 %d 低于最小值 %d,开始补充 %d 个设备", validCount, minSize, needCount))
// 临时释放锁,因为addNewDevice需要加锁
p.mu.Unlock()
for i := 0; i < needCount; i++ {
if err := p.addNewDevice(); err != nil {
poolLogger.Error(fmt.Sprintf("补充设备失败: %v", err))
}
}
p.mu.Lock()
}
}
}
// getValidCount 获取有效设备数量(调用前需已加锁或在RLock内)
func (p *DeviceKeyPool) getValidCount() int {
count := 0
for _, item := range p.items {
if item.IsValid {
count++
}
}
return count
}
// addNewDevice 添加新设备(会自动加锁)
func (p *DeviceKeyPool) addNewDevice() error {
// 注册新设备
deviceHeader, err := Register()
if err != nil {
return fmt.Errorf("注册设备失败: %w", err)
}
// 获取密钥
keyInfo, err := GetKey(deviceHeader)
if err != nil {
return fmt.Errorf("获取密钥失败: %w", err)
}
// 创建设备项
item := &DeviceKeyItem{
DeviceHeader: deviceHeader,
Key: keyInfo.Key,
KeyVer: keyInfo.Ver,
IsValid: true,
CreatedAt: time.Now(),
LastUsedAt: time.Now(),
}
// 保存到数据库
if err := p.saveDeviceToDB(item); err != nil {
return fmt.Errorf("保存设备到数据库失败: %w", err)
}
// 添加到内存
p.mu.Lock()
p.items = append(p.items, item)
p.mu.Unlock()
// poolLogger.Info(fmt.Sprintf("已添加新设备 ID=%d", item.ID))
return nil
}
// ===== 数据库操作方法 =====
// loadFromDB 从数据库加载设备
func (p *DeviceKeyPool) loadFromDB() error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
rows, err := cacheDB.Query(`
SELECT id, device_header, key, key_ver, is_valid, created_at, last_used_at
FROM device_pool
ORDER BY id
`)
if err != nil {
return err
}
defer rows.Close()
items := make([]*DeviceKeyItem, 0)
for rows.Next() {
var item DeviceKeyItem
var createdAt, lastUsedAt int64
err := rows.Scan(
&item.ID,
&item.DeviceHeader,
&item.Key,
&item.KeyVer,
&item.IsValid,
&createdAt,
&lastUsedAt,
)
if err != nil {
poolLogger.Error("扫描行失败: " + err.Error())
continue
}
item.CreatedAt = time.Unix(createdAt, 0)
item.LastUsedAt = time.Unix(lastUsedAt, 0)
items = append(items, &item)
}
p.mu.Lock()
p.items = items
p.mu.Unlock()
poolLogger.Info(fmt.Sprintf("从数据库加载了 %d 个设备", len(items)))
return nil
}
// saveDeviceToDB 保存设备到数据库
func (p *DeviceKeyPool) saveDeviceToDB(item *DeviceKeyItem) error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
result, err := cacheDB.Exec(`
INSERT INTO device_pool (device_header, key, key_ver, is_valid, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?)
`, item.DeviceHeader, item.Key, item.KeyVer, item.IsValid,
item.CreatedAt.Unix(), item.LastUsedAt.Unix())
if err != nil {
return err
}
// 获取插入的ID
id, err := result.LastInsertId()
if err == nil {
item.ID = int(id)
}
return nil
}
// updateDeviceInDB 更新设备信息
func (p *DeviceKeyPool) updateDeviceInDB(item *DeviceKeyItem) error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
_, err := cacheDB.Exec(`
UPDATE device_pool
SET device_header = ?, key = ?, key_ver = ?, is_valid = ?, last_used_at = ?
WHERE id = ?
`, item.DeviceHeader, item.Key, item.KeyVer, item.IsValid,
item.LastUsedAt.Unix(), item.ID)
return err
}
// removeInvalidFromDB 从数据库移除无效设备
func (p *DeviceKeyPool) removeInvalidFromDB() error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
result, err := cacheDB.Exec(`DELETE FROM device_pool WHERE is_valid = 0`)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected > 0 {
poolLogger.Info(fmt.Sprintf("从数据库删除了 %d 个无效设备", affected))
}
return nil
}
// InitDevicePoolTable 初始化设备池表
func InitDevicePoolTable() error {
if cacheDB == nil {
return fmt.Errorf("数据库未初始化")
}
_, err := cacheDB.Exec(`CREATE TABLE IF NOT EXISTS device_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_header TEXT NOT NULL,
key TEXT NOT NULL,
key_ver INTEGER NOT NULL,
is_valid INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL
)`)
if err != nil {
return err
}
// 创建索引
_, err = cacheDB.Exec(`CREATE INDEX IF NOT EXISTS idx_device_pool_valid ON device_pool(is_valid)`)
if err != nil {
poolLogger.Warning("创建索引失败: " + err.Error())
}
return nil
}
// GetDeviceHeaderFromPool 从池中获取设备header(兼容旧接口)
func GetDeviceHeaderFromPool() string {
pool := GetDeviceKeyPool()
if !pool.IsEnabled() {
return ""
}
item, err := pool.GetNext()
if err != nil {
poolLogger.Error("从池中获取设备失败: " + err.Error())
return ""
}
return item.DeviceHeader
}
// GetKeyInfoFromPool 从池中获取密钥信息(兼容旧接口)
func GetKeyInfoFromPool(deviceHeader string) *KeyInfo {
pool := GetDeviceKeyPool()
if !pool.IsEnabled() {
return nil
}
pool.mu.RLock()
defer pool.mu.RUnlock()
for _, item := range pool.items {
if item.DeviceHeader == deviceHeader && item.IsValid {
return &KeyInfo{
Key: item.Key,
Ver: item.KeyVer,
}
}
}
return nil
}
// UpdateKeyInPool 更新池中设备的密钥
func UpdateKeyInPool(deviceHeader string, key string, ver int) error {
pool := GetDeviceKeyPool()
if !pool.IsEnabled() {
return nil
}
pool.mu.Lock()
defer pool.mu.Unlock()
for _, item := range pool.items {
if item.DeviceHeader == deviceHeader {
item.Key = key
item.KeyVer = ver
return pool.updateDeviceInDB(item)
}
}
return fmt.Errorf("未找到设备")
}
// GetAnyValidDeviceHeader 获取任意一个有效设备的header(用于辅助功能)
func GetAnyValidDeviceHeader() string {
pool := GetDeviceKeyPool()
if !pool.IsEnabled() {
return ""
}
pool.mu.RLock()
defer pool.mu.RUnlock()
for _, item := range pool.items {
if item.IsValid {
return item.DeviceHeader
}
}
return ""
}
// GetValidDevice 获取一个有效的设备
func GetValidDevice() (*DeviceKeyItem, error) {
pool := GetDeviceKeyPool()
if !pool.IsEnabled() {
return nil, fmt.Errorf("设备池未启用")
}
return pool.GetNext()
}
// GetCurrentDevice 获取当前使用的设备(和GetValidDevice一样,返回下一个可用设备)
func GetCurrentDevice() *DeviceKeyItem {
pool := GetDeviceKeyPool()
if !pool.IsEnabled() {
return nil
}
item, _ := pool.GetNext()
return item
}
+384
View File
@@ -0,0 +1,384 @@
package fqapi
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fanqie/internal/common"
"fmt"
"math/big"
"net"
"os"
"runtime"
"sort"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
// 使用共享的设备配置
var (
androidVersions = DeviceConfig.AndroidVersions
brandModels = DeviceConfig.BrandModels
deviceBrands = DeviceConfig.DeviceBrands
)
// generateUUID 生成UUID4字符串
func generateUUID() string {
return uuid.New().String()
}
// currentMillis 返回当前毫秒级时间戳
func currentMillis() int64 {
return time.Now().UnixNano() / int64(time.Millisecond)
}
// getCustomBT 获取设备开机时间戳
// 模拟 Android 中的 m25453o() 方法,计算设备开机时间戳:
// custom_bt = current_millis - elapsed_ms_since_boot
func getCustomBT() string {
var uptimeSeconds float64
if runtime.GOOS == "linux" {
// 在Linux上读取/proc/uptime
data, err := os.ReadFile("/proc/uptime")
if err == nil {
// /proc/uptime格式: "系统运行时间(秒) 空闲时间(秒)"
parts := strings.Split(string(data), " ")
if len(parts) > 0 {
uptime, err := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
if err == nil {
uptimeSeconds = uptime
}
}
}
// 如果读取失败,使用time.monotonic作为备选
if uptimeSeconds == 0 {
// Go没有直接的monotonic API,使用启动时间估算
uptimeSeconds = float64(time.Now().Unix() % 86400)
}
} else {
// 非Linux系统,使用估算值
// 这里可以使用进程启动时间作为近似
uptimeSeconds = float64(time.Now().Unix() % 86400)
}
return fmt.Sprintf("%d", currentMillis()-int64(uptimeSeconds*1000))
}
// generateDeviceInfo 随机生成设备及操作系统信息
func generateDeviceInfo() map[string]interface{} {
osVersion := androidVersions[randInt(len(androidVersions))]
// 随机选择品牌
deviceBrand := deviceBrands[randInt(len(deviceBrands))]
// 根据品牌选择对应的型号
models := brandModels[deviceBrand]
deviceModel := models[randInt(len(models))]
releaseBytes := make([]byte, 7)
rand.Read(releaseBytes)
releaseBuild := hex.EncodeToString(releaseBytes)
openUDIDBytes := make([]byte, 10)
rand.Read(openUDIDBytes)
return map[string]interface{}{
"os": "Android",
"os_version": osVersion[0],
"os_api": osVersion[1],
"device_model": deviceModel,
"device_brand": deviceBrand,
"device_manufacturer": deviceBrand,
"cpu_abi": "arm64-v8a",
"release_build": releaseBuild,
"cdid": generateUUID(),
"sig_hash": "a4a27c2633195374c15651ffc3c4a497",
"openudid": hex.EncodeToString(openUDIDBytes),
"clientudid": generateUUID(),
"req_id": generateUUID(),
}
}
// randInt 生成随机整数
func randInt(max int) int {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(max)))
return int(n.Int64())
}
// randomString 生成随机字符串
func randomString(length int) string {
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
b := make([]byte, length)
for i := range b {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
b[i] = charset[n.Int64()]
}
return string(b)
}
// generateOptionalFields 生成ROM信息等
func generateOptionalFields() (map[string]interface{}, map[string]int64) {
rom := randomString(14)
part1 := randomString(4)
part2 := randomString(6)
part3 := randomString(8)
genTime := currentMillis()
return map[string]interface{}{
"rom": rom,
"rom_version": fmt.Sprintf("coloros__%s.%s.%s release-keys", part1, part2, part3),
"apk_first_install_time": genTime - int64(randInt(365))*86400000,
}, map[string]int64{
"_gen_time": genTime,
}
}
// generateIPv6AddressLinkLocal 生成链路本地IPv6地址
func generateIPv6AddressLinkLocal() string {
ifaces, err := net.Interfaces()
if err != nil {
return "::1"
}
for _, iface := range ifaces {
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP
if ip.To16() != nil && strings.HasPrefix(ip.String(), "fe80::") {
return strings.Split(ip.String(), "%")[0]
}
}
}
return "::1"
}
// generateIPv6AddressUniqueLocal 生成唯一本地IPv6地址
func generateIPv6AddressUniqueLocal() string {
b := make([]byte, 1)
rand.Read(b)
parts := []string{fmt.Sprintf("fd%02x", b[0])}
for i := 0; i < 7; i++ {
b := make([]byte, 2)
rand.Read(b)
parts = append(parts, fmt.Sprintf("%04x", uint16(b[0])<<8|uint16(b[1])))
}
return strings.Join(parts, ":")
}
// generateIPv6List 生成IPv6列表
func generateIPv6List() []map[string]string {
return []map[string]string{
{"type": "client_anpi", "value": generateIPv6AddressLinkLocal()},
{"type": "client_anpi", "value": generateIPv6AddressUniqueLocal()},
{"type": "client_anpi", "value": generateIPv6AddressUniqueLocal()},
}
}
// GenerateFullRequestBody 生成完整的请求体数据
func GenerateFullRequestBody() map[string]interface{} {
rawData := map[string]interface{}{
"magic_tag": "ss_app_log",
"header": map[string]interface{}{
"display_name": "番茄免费小说",
"aid": 1967,
"channel": "43536163a",
"package": "com.dragon.read",
"sdk_version": "3.7.0-rc.25-fanqie-xiaoshuo",
"sdk_target_version": 29,
"git_hash": "711d1a7",
"density_dpi": 240,
"display_density": "hdpi",
"resolution": "720x1280",
"language": "zh",
"timezone": 8,
"access": "wifi",
"not_request_sender": 0,
"carrier": "CHINA MOBILE",
"mcc_mnc": "46000",
"region": "CN",
"tz_name": "Asia/Shanghai",
"tz_offset": 28800,
"sim_region": "cn",
"sim_serial_number": []interface{}{},
"oaid_may_support": false,
"device_platform": "android",
"custom": map[string]interface{}{"host_bit": 32, "dragon_device_type": "pad"},
"pre_installed_channel": "",
"is_system_app": 0,
"sdk_flavor": "china",
"guest_mode": 0,
},
}
deviceInfo := generateDeviceInfo()
optionalFields, genTime := generateOptionalFields()
header := rawData["header"].(map[string]interface{})
for k, v := range deviceInfo {
header[k] = v
}
for k, v := range optionalFields {
header[k] = v
}
header["ipv6_list"] = generateIPv6List()
for k, v := range genTime {
rawData[k] = v
}
return rawData
}
// tryActivatePremium 尝试激活设备特权
func tryActivatePremium(header string) {
logger := common.NewLogger("device_register", "INFO", "")
// 构建查询参数
baseParams := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"os_version=0",
"version_code=58932",
"manifest_version_code=58932",
"update_version_code=58932",
header,
}
// 合并并排序参数
allParams := strings.Join(baseParams, "&")
paramList := strings.Split(allParams, "&")
sort.Strings(paramList)
sortedParams := strings.Join(paramList, "&")
requestBody := map[string]interface{}{
"add_count_daily": 0,
"amount": 2592000,
"privilege_id": 7210376203117531962,
"from": 8,
"unique_key": fmt.Sprintf("%d", time.Now().UnixNano()/1e6),
}
bodyJSON, _ := json.Marshal(requestBody)
url := "https://reading.snssdk.com/reading/user/privilege/add/v/?" + sortedParams
resp, err := GorgonRequestSync(url, map[string]interface{}{
"method": "post",
"headers": map[string]string{
"content-type": "application/json",
},
"body": bodyJSON,
})
if err != nil {
logger.Error("激活特权失败: " + err.Error())
return
}
var result map[string]interface{}
json.Unmarshal(resp.Content, &result)
if code, ok := result["code"].(float64); ok && code == 0 {
logger.Info("已成功激活设备特权")
} else {
logger.Warning("设备特权激活失败...")
}
}
// Register 设备注册
func Register() (string, error) {
logger := common.NewLogger("device_register", "INFO", "")
logger.Info("正在注册设备...")
// 生成完整请求体数据
data := GenerateFullRequestBody()
// 只记录关键设备信息
header := data["header"].(map[string]interface{})
logger.Info(fmt.Sprintf("生成设备信息 - 品牌: %s, 型号: %s, 系统: Android %v",
header["device_brand"],
header["device_model"],
header["os_version"]))
jsonData, err := json.Marshal(data)
if err != nil {
logger.Error("JSON序列化失败: " + err.Error())
return "", err
}
// 使用TTEncrypt加密
encrypted, err := TTEncrypt(jsonData)
if err != nil {
logger.Error("加密失败: " + err.Error())
return "", err
}
url := "https://i.snssdk.com/service/2/device_register/?tt_data=a"
headers := map[string]string{
"user-agent": "okhttp/4.10.0",
"content-type": "application/octet-stream; tt-data=a",
}
client := common.NewHTTPClient()
resp, err := client.Post(url, encrypted, headers)
if err != nil {
logger.Warning("设备注册失败!")
logger.Warning(err.Error())
return "", fmt.Errorf("设备注册错误")
}
var respData map[string]interface{}
err = json.Unmarshal(resp.Content, &respData)
if err != nil {
logger.Error("响应解析失败: " + err.Error())
return "", err
}
if resp.Status == 200 {
// 检查device_id
deviceID, ok := respData["device_id"]
if !ok {
logger.Warning("设备注册失败!响应中没有device_id")
return "", fmt.Errorf("设备注册失败")
}
// 转换device_id为float64并检查
deviceIDFloat, ok := deviceID.(float64)
if !ok || deviceIDFloat == 0 {
logger.Warning("设备注册失败!device_id无效")
return "", fmt.Errorf("设备注册失败")
}
deviceModel := data["header"].(map[string]interface{})["device_model"].(string)
header := fmt.Sprintf("device_id=%v&device_type=%s&iid=%v&version_name=5.8.9.32",
respData["device_id_str"],
deviceModel,
respData["install_id_str"])
logger.Info("设备注册成功!" )
// 尝试激活特权
tryActivatePremium(header)
return header, nil
}
logger.Warning("设备注册失败!")
return "", fmt.Errorf("设备注册失败")
}
+185
View File
@@ -0,0 +1,185 @@
package fqapi
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"math/big"
)
// CM 加密管理器
type CM struct {
bigC *big.Int // 大素数C
bigB int64 // 底数B
arryD []byte // 固定密钥
thisD [2]*big.Int // 密钥对(私钥, 公钥)
thisC []byte // 随机IV
}
// NewCM 创建新的CM实例
func NewCM() *CM {
// 大素数C
bigCStr := "2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919"
bigC := new(big.Int)
bigC.SetString(bigCStr, 10)
// 固定密钥(base64解码)
arryD, _ := base64.StdEncoding.DecodeString("rCXGfd2POMGzeiNIgo4iLg==")
return &CM{
bigC: bigC,
bigB: 2,
arryD: arryD,
}
}
// generateKeyPair 生成密钥对
func (cm *CM) generateKeyPair() [2]*big.Int {
// 生成随机私钥
randomBytes := make([]byte, 32)
rand.Read(randomBytes)
x := new(big.Int).SetBytes(randomBytes)
x = x.Mod(x, new(big.Int).Sub(cm.bigC, big.NewInt(1)))
// 计算公钥: y = b^x mod c
y := new(big.Int).Exp(big.NewInt(cm.bigB), x, cm.bigC)
cm.thisD = [2]*big.Int{x, y}
return cm.thisD
}
// pairToBytes 将公钥转换为字节数组
func (cm *CM) pairToBytes(keyPair [2]*big.Int) []byte {
return keyPair[1].Bytes()
}
// randIV 生成随机IV
func (cm *CM) randIV() []byte {
iv := make([]byte, 16)
rand.Read(iv)
return iv
}
// encodeAES AES-CBC加密
func (cm *CM) encodeAES(key, iv, data []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// PKCS7填充
padding := aes.BlockSize - len(data)%aes.BlockSize
padText := make([]byte, padding)
for i := range padText {
padText[i] = byte(padding)
}
paddedData := append(data, padText...)
ciphertext := make([]byte, len(paddedData))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, paddedData)
return ciphertext, nil
}
// decodeAES AES-CBC解密
func (cm *CM) decodeAES(key, iv, data []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(data))
mode.CryptBlocks(decrypted, data)
// 移除PKCS7填充
padding := int(decrypted[len(decrypted)-1])
if padding > len(decrypted) || padding > aes.BlockSize {
return nil, fmt.Errorf("无效的填充")
}
return decrypted[:len(decrypted)-padding], nil
}
// ClientHandshake 客户端握手,生成请求头y
func (cm *CM) ClientHandshake() string {
keyPair := cm.generateKeyPair()
cm.thisC = cm.randIV()
publicKeyBytes := cm.pairToBytes(keyPair)
encrypted, _ := cm.encodeAES(cm.arryD, cm.thisC, publicKeyBytes)
combined := append(cm.thisC, encrypted...)
return base64.StdEncoding.EncodeToString(combined)
}
// decryptF 解密函数
func (cm *CM) decryptF(peerPublicKey []byte, keyPair [2]*big.Int, iv, data []byte) ([]byte, error) {
x := keyPair[0]
// 计算共享密钥: shared_secret = peer_public_key^x mod c
peerPubInt := new(big.Int).SetBytes(peerPublicKey)
sharedSecret := new(big.Int).Exp(peerPubInt, x, cm.bigC)
key := sharedSecret.Bytes()
// 确保key长度为32字节
if len(key) < 32 {
// 前面补0
paddedKey := make([]byte, 32)
copy(paddedKey[32-len(key):], key)
key = paddedKey
} else if len(key) > 32 {
key = key[:32]
}
return cm.decodeAES(key, iv, data)
}
// Decrypt 解密响应内容
func (cm *CM) Decrypt(c, y, content string) (string, error) {
// c 应该为 "1" 表示启用加密
// y 是服务器返回的公钥
// content 是加密的内容
if c != "1" || y == "" {
return content, nil
}
// 解码content
contentDecoded, err := base64.StdEncoding.DecodeString(content)
if err != nil {
return "", err
}
if len(contentDecoded) < 16 {
return "", fmt.Errorf("内容太短")
}
// 提取IV和数据
iv := contentDecoded[:16]
data := contentDecoded[16:]
// 解码服务器公钥
peerPublicKey, err := base64.StdEncoding.DecodeString(y)
if err != nil {
return "", err
}
// 检查密钥对是否已初始化
if cm.thisD[0] == nil {
return "", fmt.Errorf("密钥对未初始化")
}
// 解密
decrypted, err := cm.decryptF(peerPublicKey, cm.thisD, iv, data)
if err != nil {
return "", err
}
return string(decrypted), nil
}
+230
View File
@@ -0,0 +1,230 @@
package fqapi
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// EmojiManager 表情管理器
type EmojiManager struct {
emojiMap map[string]string // 表情文本 -> 图片URL的映射
mutex sync.RWMutex
lastUpdate time.Time
cacheExpiry time.Duration
}
// DouyinEmojiResponse 抖音表情API响应结构
type DouyinEmojiResponse struct {
EmojiList []DouyinEmoji `json:"emoji_list"`
}
// DouyinEmoji 抖音单个表情结构
type DouyinEmoji struct {
OriginURI string `json:"origin_uri"`
DisplayName string `json:"display_name"`
Hide int `json:"hide"`
EmojiURL struct {
URI string `json:"uri"`
URLList []string `json:"url_list"`
} `json:"emoji_url"`
}
// LocalEmojiData 本地表情数据结构
type LocalEmojiData struct {
Version int `json:"version"`
Size int `json:"size"`
Datas map[string]string `json:"datas"`
}
var (
globalEmojiManager *EmojiManager
emojiOnce sync.Once
)
// GetEmojiManager 获取全局表情管理器实例
func GetEmojiManager() *EmojiManager {
emojiOnce.Do(func() {
globalEmojiManager = &EmojiManager{
emojiMap: make(map[string]string),
cacheExpiry: 24 * time.Hour, // 缓存24小时
}
// 初始化时加载表情
globalEmojiManager.LoadEmojis()
})
return globalEmojiManager
}
// LoadEmojis 加载表情映射(优先API,其次本地)
func (em *EmojiManager) LoadEmojis() error {
em.mutex.Lock()
defer em.mutex.Unlock()
// 检查缓存是否过期
if time.Since(em.lastUpdate) < em.cacheExpiry && len(em.emojiMap) > 0 {
return nil
}
newMap := make(map[string]string)
// 1. 先尝试从抖音API获取表情
apiEmojis, err := em.fetchDouyinEmojis()
if err == nil && len(apiEmojis) > 0 {
for displayName, url := range apiEmojis {
newMap[displayName] = url
}
}
// 2. 加载本地表情作为补充
localEmojis, err := em.loadLocalEmojis()
if err == nil {
for displayName, url := range localEmojis {
// 如果API中没有,则使用本地的
if _, exists := newMap[displayName]; !exists {
newMap[displayName] = url
}
}
}
// 3. 更新缓存
if len(newMap) > 0 {
em.emojiMap = newMap
em.lastUpdate = time.Now()
}
return nil
}
// fetchDouyinEmojis 从抖音API获取表情映射
func (em *EmojiManager) fetchDouyinEmojis() (map[string]string, error) {
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get("https://www.douyin.com/aweme/v1/web/emoji/list")
if err != nil {
return nil, fmt.Errorf("获取抖音表情API失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("抖音表情API返回状态码: %d", resp.StatusCode)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
var apiResp DouyinEmojiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("解析JSON失败: %w", err)
}
emojiMap := make(map[string]string)
for _, emoji := range apiResp.EmojiList {
if len(emoji.EmojiURL.URLList) > 0 {
// 使用第一个URL
emojiMap[emoji.DisplayName] = emoji.EmojiURL.URLList[0]
}
}
return emojiMap, nil
}
// loadLocalEmojis 从本地文件加载表情映射
func (em *EmojiManager) loadLocalEmojis() (map[string]string, error) {
// 读取 data.json
dataPath := filepath.Join("emoji", "data.json")
data, err := ioutil.ReadFile(dataPath)
if err != nil {
return nil, fmt.Errorf("读取emoji/data.json失败: %w", err)
}
var localData LocalEmojiData
if err := json.Unmarshal(data, &localData); err != nil {
return nil, fmt.Errorf("解析emoji/data.json失败: %w", err)
}
emojiMap := make(map[string]string)
for id, displayName := range localData.Datas {
// 生成本地图片路径:emoji/images/emoji_dr_{id}.png
// 转换为相对URL路径
localPath := fmt.Sprintf("/emoji/images/emoji_dr_%s.png", id)
emojiMap[displayName] = localPath
}
return emojiMap, nil
}
// GetEmojiURL 获取表情图片URL
func (em *EmojiManager) GetEmojiURL(displayName string) (string, bool) {
em.mutex.RLock()
defer em.mutex.RUnlock()
url, exists := em.emojiMap[displayName]
return url, exists
}
// GetAllEmojis 获取所有表情映射(用于生成JavaScript代码)
func (em *EmojiManager) GetAllEmojis() map[string]string {
em.mutex.RLock()
defer em.mutex.RUnlock()
// 返回副本
result := make(map[string]string, len(em.emojiMap))
for k, v := range em.emojiMap {
result[k] = v
}
return result
}
// RefreshEmojis 手动刷新表情缓存
func (em *EmojiManager) RefreshEmojis() error {
em.mutex.Lock()
em.lastUpdate = time.Time{} // 重置更新时间
em.mutex.Unlock()
return em.LoadEmojis()
}
// CheckLocalEmojiFiles 检查本地表情文件是否存在
func CheckLocalEmojiFiles() error {
dataPath := filepath.Join("emoji", "data.json")
if _, err := os.Stat(dataPath); os.IsNotExist(err) {
return fmt.Errorf("emoji/data.json 文件不存在")
}
imagesDir := filepath.Join("emoji", "images")
if _, err := os.Stat(imagesDir); os.IsNotExist(err) {
return fmt.Errorf("emoji/images 目录不存在")
}
return nil
}
// ReplaceEmojisInText 替换文本中的表情为HTML img标签
func ReplaceEmojisInText(text string) string {
em := GetEmojiManager()
// 匹配 [表情] 格式
result := text
for displayName, url := range em.GetAllEmojis() {
// 转义特殊字符
escapedName := strings.ReplaceAll(displayName, "[", "\\[")
escapedName = strings.ReplaceAll(escapedName, "]", "\\]")
// 替换为 img 标签
imgTag := fmt.Sprintf(`<img src="%s" class="emoji-img" alt="%s" />`, url, displayName)
result = strings.ReplaceAll(result, displayName, imgTag)
}
return result
}
+117
View File
@@ -0,0 +1,117 @@
package fqapi
import (
"crypto/md5"
"encoding/hex"
"fanqie/internal/algos"
"fanqie/internal/common"
)
// CreateMD5 创建MD5哈希
func CreateMD5(s string) string {
hasher := md5.New()
hasher.Write([]byte(s))
return hex.EncodeToString(hasher.Sum(nil))
}
// GorgonRequest Gorgon请求(使用签名切换器)
func GorgonRequest(urlStr string, options map[string]interface{}) (*common.ClientResponse, error) {
headers, ok := options["headers"].(map[string]string)
if !ok {
headers = make(map[string]string)
options["headers"] = headers
}
dataMD5 := ""
if body, ok := options["body"].(string); ok && body != "" {
dataMD5 = CreateMD5(body)
} else if body, ok := options["body"].([]byte); ok && len(body) > 0 {
dataMD5 = CreateMD5(string(body))
}
if dataMD5 != "" {
headers["x-ss-stub"] = dataMD5
}
// 使用签名切换器(sign2 或 sign4)生成签名
signedHeaders := algos.GlobalSignatureSwitcher.Sign(urlStr, headers)
// 更新headers
for k, v := range signedHeaders {
headers[k] = v
}
// 调用实际的HTTP请求
client := common.NewHTTPClient()
return client.Request(urlStr, options)
}
// GorgonRequestSync Gorgon同步请求(使用签名切换器)
func GorgonRequestSync(urlStr string, options map[string]interface{}) (*common.ClientResponse, error) {
headers, ok := options["headers"].(map[string]string)
if !ok {
headers = make(map[string]string)
options["headers"] = headers
}
dataMD5 := ""
if body, ok := options["body"].(string); ok && body != "" {
dataMD5 = CreateMD5(body)
} else if body, ok := options["body"].([]byte); ok && len(body) > 0 {
dataMD5 = CreateMD5(string(body))
}
if dataMD5 != "" {
headers["x-ss-stub"] = dataMD5
}
// 使用签名切换器(sign2 或 sign4)生成签名
signedHeaders := algos.GlobalSignatureSwitcher.Sign(urlStr, headers)
// 删除x-ss-stub(如果存在)
if _, exists := signedHeaders["x-ss-stub"]; exists {
delete(signedHeaders, "x-ss-stub")
}
// 更新headers
for k, v := range signedHeaders {
headers[k] = v
}
// 调用实际的HTTP请求
client := common.NewHTTPClient()
return client.Request(urlStr, options)
}
// ContentRequest Content请求(硬编码使用Sign2,不受配置影响)
func ContentRequest(urlStr string, options map[string]interface{}) (*common.ClientResponse, error) {
headers, ok := options["headers"].(map[string]string)
if !ok {
headers = make(map[string]string)
options["headers"] = headers
}
dataMD5 := ""
if body, ok := options["body"].(string); ok && body != "" {
dataMD5 = CreateMD5(body)
} else if body, ok := options["body"].([]byte); ok && len(body) > 0 {
dataMD5 = CreateMD5(string(body))
}
if dataMD5 != "" {
headers["x-ss-stub"] = dataMD5
}
// 硬编码使用 Sign2(不受配置影响)
signedHeaders := algos.Sign2Wrapper(urlStr, headers)
// 更新headers
for k, v := range signedHeaders {
headers[k] = v
}
// 调用实际的HTTP请求
client := common.NewHTTPClient()
return client.Request(urlStr, options)
}
+45
View File
@@ -0,0 +1,45 @@
package fqapi
import (
"fanqie/internal/common"
"fmt"
)
var itemSummaryLogger = common.NewLogger("item_summary", "INFO", "")
// HandleItemSummary 处理获取章节摘要
func HandleItemSummary(itemIDs, bookID string) (string, error) {
if itemIDs == "" || bookID == "" {
return BuildErrorResponseWithCode(100144, "参数错误"), nil
}
reqURL := BuildURL(
"https://reading.snssdk.com/reading/reader/item_summary/mget/v",
"book_id="+bookID,
"item_ids="+itemIDs,
)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
itemSummaryLogger.Error(fmt.Sprintf("获取章节摘要失败: %v", err))
return BuildErrorResponse("请求失败"), err
}
respJSON, err := resp.JSON()
if err != nil {
return BuildErrorResponse("解析响应失败"), err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
return resp.Text, nil
}
return BuildErrorResponse("获取失败"), nil
}
+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"
}
+72
View File
@@ -0,0 +1,72 @@
package fqapi
import (
"encoding/hex"
"fmt"
"time"
)
// HandleMangaImageProxy 处理漫画图片代理请求
func HandleMangaImageProxy(itemID string, index int) ([]byte, string, error) {
// 从缓存获取图片信息
cacheKey := fmt.Sprintf("%s_%d", itemID, index)
mangaCacheMutex.RLock()
imageInfo, exists := mangaImageCache[cacheKey]
mangaCacheMutex.RUnlock()
if !exists {
return nil, "", fmt.Errorf("图片信息不存在,请先调用 /manga 接口")
}
// 检查缓存是否过期(5分钟)
if time.Since(imageInfo.CachedAt) > 5*time.Minute {
// 清理过期缓存
mangaCacheMutex.Lock()
delete(mangaImageCache, cacheKey)
mangaCacheMutex.Unlock()
return nil, "", fmt.Errorf("图片信息已过期,请重新调用 /manga 接口")
}
// 下载加密图片
encryptedData, err := downloadImageWithRetry(imageInfo.PicURL, 2)
if err != nil {
return nil, "", fmt.Errorf("下载图片失败: %w", err)
}
// 解密图片
keyBytes, err := hex.DecodeString(imageInfo.EncryptKey)
if err != nil {
return nil, "", fmt.Errorf("密钥解码失败: %w", err)
}
decryptedData, err := decryptImage(encryptedData, keyBytes)
if err != nil {
return nil, "", fmt.Errorf("解密失败: %w", err)
}
// 识别格式
format := getImageFormat(decryptedData, imageInfo.PicURL)
// 返回图片数据和MIME类型
mimeType := fmt.Sprintf("image/%s", format)
if format == "jpg" {
mimeType = "image/jpeg"
}
return decryptedData, mimeType, nil
}
// CleanExpiredMangaCache 清理过期的漫画图片缓存
func CleanExpiredMangaCache() {
mangaCacheMutex.Lock()
defer mangaCacheMutex.Unlock()
now := time.Now()
for key, info := range mangaImageCache {
if now.Sub(info.CachedAt) > 5*time.Minute {
delete(mangaImageCache, key)
}
}
}
+192
View File
@@ -0,0 +1,192 @@
package fqapi
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
)
// generateRandomKey 生成随机key
func generateRandomKey(length int) string {
b := make([]byte, length/2)
rand.Read(b)
return hex.EncodeToString(b)
}
// extractDeviceParam 从deviceHeader字符串中提取指定参数
func extractDeviceParam(deviceHeader, paramName string) string {
// deviceHeader 格式: device_id=xxx&device_type=xxx&iid=xxx&version_name=xxx
parts := strings.Split(deviceHeader, "&")
for _, part := range parts {
kv := strings.Split(part, "=")
if len(kv) == 2 && kv[0] == paramName {
return kv[1]
}
}
return ""
}
// downloadMediaWithRetry 带重试的媒体下载,自动切换设备
func downloadMediaWithRetry(url, deviceID string, headers map[string]string, maxRetries int) ([]byte, string, error) {
var lastErr error
currentDeviceID := deviceID
for i := 0; i <= maxRetries; i++ {
data, contentType, err := downloadMedia(url, currentDeviceID, headers)
if err == nil && len(data) > 0 {
return data, contentType, nil
}
lastErr = err
if i < maxRetries {
// 重试时切换到新的有效设备
newDevice, err := GetValidDevice()
if err == nil {
currentDeviceID = newDevice.DeviceHeader
}
// 重试间隔递增(2秒、4秒、6秒)
time.Sleep(time.Duration(i*2+2) * time.Second)
}
}
return nil, "", fmt.Errorf("下载失败(已重试%d次): %w", maxRetries, lastErr)
}
// downloadMedia 下载媒体内容
func downloadMedia(url, deviceID string, headers map[string]string) ([]byte, string, error) {
// 创建专用的媒体下载客户端,超时时间更长
client := &http.Client{
Timeout: 300 * time.Second, // 5分钟超时,适合大文件
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, "", err
}
// 设置CDN请求头,模拟安卓客户端请求
req.Header.Set("User-Agent", "com.dragon.read/7.9.9.32 (Linux; U; Android 13; zh_CN; M2102K1AC; Build/TKQ1.220807.001; Cronet/113.0.5672.62)")
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Encoding", "identity")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Host", req.URL.Host)
// 添加设备信息(如果提供了deviceID)
if deviceID != "" {
req.Header.Set("X-Device-ID", extractDeviceParam(deviceID, "device_id"))
req.Header.Set("X-Device-Type", extractDeviceParam(deviceID, "device_type"))
req.Header.Set("X-IID", extractDeviceParam(deviceID, "iid"))
req.Header.Set("X-Version-Name", extractDeviceParam(deviceID, "version_name"))
}
// 添加自定义请求头
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("下载失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("HTTP状态码: %d", resp.StatusCode)
}
// 读取内容
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("读取内容失败: %w", err)
}
// 获取Content-Type
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
// 根据文件后缀推断
contentType = getContentTypeFromURL(url)
}
return data, contentType, nil
}
// getContentTypeFromURL 根据URL推断Content-Type
func getContentTypeFromURL(url string) string {
ext := strings.ToLower(filepath.Ext(url))
switch ext {
case ".mp3", ".m4a", ".aac":
return "audio/mpeg"
case ".mp4", ".m4v", ".mov":
return "video/mp4"
case ".flv":
return "video/x-flv"
case ".ts":
return "video/MP2T"
case ".webm":
return "video/webm"
case ".ogg":
return "audio/ogg"
default:
return "application/octet-stream"
}
}
// DetectMediaType 检测媒体类型
func DetectMediaType(data []byte) string {
if len(data) < 4 {
return "unknown"
}
// 检测音频格式
if data[0] == 0xFF && (data[1] == 0xFB || data[1] == 0xF3 || data[1] == 0xF2) {
return "audio/mpeg" // MP3
}
if string(data[:4]) == "ftyp" || string(data[:4]) == "moov" || string(data[:4]) == "mdat" {
return "video/mp4" // MP4/M4A
}
if string(data[:4]) == "OggS" {
return "audio/ogg" // OGG
}
if string(data[:4]) == "RIFF" && string(data[8:12]) == "WAVE" {
return "audio/wav" // WAV
}
// 检测视频格式
if string(data[:3]) == "FLV" {
return "video/x-flv" // FLV
}
if string(data[:4]) == "webm" {
return "video/webm" // WebM
}
return "unknown"
}
// processMediaURL 处理媒体URL,根据output模式返回不同的URL(仅支持音频)
func processMediaURL(originalURL, deviceID string, headers map[string]string, outputMode string, mediaType string) interface{} {
switch outputMode {
case "proxy":
// 生成代理URL
if mediaType == "audio" {
return GenerateAudioProxyURL(originalURL, deviceID, headers)
}
// 其他类型返回原始URL
return originalURL
case "data_uri":
// 直接下载并返回base64
data, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 2)
if err != nil {
return originalURL
}
return fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(data))
default:
// 直接返回原始URL
return originalURL
}
}
+273
View File
@@ -0,0 +1,273 @@
package fqapi
import (
"bytes"
"compress/zlib"
"encoding/base64"
"encoding/json"
"fanqie/internal/common"
"fmt"
"io"
"sort"
"strings"
"sync"
)
var (
multiContentLogger = common.NewLogger("multi_content", "INFO", "")
)
// handleDecryptTask 处理解密任务
func handleDecryptTask(itemID string, itemData map[string]interface{}, keyInfo *KeyInfo, textMode, imageMode bool) (string, map[string]interface{}) {
if code, ok := itemData["code"].(float64); !ok || code != 0 {
return itemID, itemData
}
contentEncoded, ok := itemData["content"].(string)
if !ok {
return itemID, itemData
}
// 解码content
contentBytes, err := base64.StdEncoding.DecodeString(contentEncoded)
if err != nil || len(contentBytes) < 16 {
return itemID, itemData
}
iv := contentBytes[0:16]
encryptedData := contentBytes[16:]
// 解密
if keyInfo == nil {
return itemID, itemData
}
keyBytes, _ := base64.StdEncoding.DecodeString(keyInfo.Key)
decrypted, err := De(encryptedData, keyBytes, iv)
if err != nil {
return itemID, itemData
}
// 检查是否需要解压
compressStatus, _ := itemData["compress_status"].(float64)
if compressStatus != 0 {
// zlib解压
reader, err := zlib.NewReader(bytes.NewReader(decrypted))
if err == nil {
decompressed, err := io.ReadAll(reader)
reader.Close()
if err == nil {
decrypted = decompressed
}
}
}
// 更新内容
bodyCopy := make(map[string]interface{})
for k, v := range itemData {
bodyCopy[k] = v
}
bodyCopy["content"] = string(decrypted)
// 缓存
cacheData := buildCacheData(bodyCopy)
saveCacheJSON("content_"+itemID, cacheData, 86400)
// 解析内容
parser := NewContentParser()
bodyCopy["content"] = parser.Parse(string(decrypted), textMode, imageMode)
return itemID, bodyCopy
}
// MultiContent 批量获取内容
func MultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignoreFailed interface{}, depth int) (string, error) {
// 参数转换
textModeBool := parseBoolParam(textMode)
imageModeBool := parseBoolParamDefault(imageMode, true)
ignoreFailedBool := parseBoolParamDefault(ignoreFailed, true)
if depth > 3 {
return BuildErrorResponse("获取失败,请重试!"), nil
}
if bookID == "" {
return BuildErrorResponse("需要book_id!"), nil
}
if itemIDs == nil || itemIDs == "" {
return BuildSuccessResponse([]interface{}{}), nil
}
// 转换itemIDs
var itemIDStr string
switch v := itemIDs.(type) {
case string:
itemIDStr = v
case []string:
itemIDStr = strings.Join(v, ",")
default:
itemIDStr = fmt.Sprintf("%v", v)
}
// 从设备密钥池获取设备
pool := GetDeviceKeyPool()
item, err := pool.GetNext()
if err != nil {
return `{"code": -1, "msg": "从设备池获取设备失败"}`, err
}
header := item.DeviceHeader
currentKeyInfo := &KeyInfo{
Key: item.Key,
Ver: item.KeyVer,
}
multiContentLogger.Debug(fmt.Sprintf("使用池中设备 ID=%d (depth: %d)", item.ID, depth))
// 构建参数
headerWithoutVersion := removeVersionFromHeader(header)
params := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"os_version=0",
"version_code=66.9",
"book_id=" + bookID,
"item_ids=" + itemIDStr,
"novel_text_type=1",
"req_type=1",
headerWithoutVersion,
}
allParams := strings.Join(params, "&")
paramList := strings.Split(allParams, "&")
sort.Strings(paramList)
sortedParams := strings.Join(paramList, "&")
url := "https://reading.snssdk.com/reading/reader/batch_full/v/?" + sortedParams
resp, err := ContentRequest(url, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
multiContentLogger.Warning(fmt.Sprintf("批量请求失败,标记设备无效并切换设备 (depth: %d): %v", depth, err))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
var data map[string]interface{}
json.Unmarshal(resp.Content, &data)
if code, ok := data["code"].(float64); ok && code == 0 {
dataMap, ok := data["data"].(map[string]interface{})
if !ok {
multiContentLogger.Warning(fmt.Sprintf("响应data字段无效,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// 检查是否有Invalid内容或密钥版本不匹配
needRetry := false
for _, itemData := range dataMap {
itemMap, ok := itemData.(map[string]interface{})
if !ok {
continue
}
itemCode, _ := itemMap["code"].(float64)
if itemCode != 0 && !ignoreFailedBool {
multiContentLogger.Warning(fmt.Sprintf("item code异常: %.0f,切换设备重试 (depth: %d)", itemCode, depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
if content, ok := itemMap["content"].(string); ok && content == "Invalid" {
// 标记设备无效并移除
multiContentLogger.Warning(fmt.Sprintf("内容返回 Invalid,标记设备无效并切换设备 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
needRetry = true
break
}
if keyVersion, ok := itemMap["key_version"].(float64); ok {
if currentKeyInfo != nil && int(keyVersion) != currentKeyInfo.Ver {
// 更新密钥版本
multiContentLogger.Warning("密钥版本不匹配,重新获取密钥")
newKeyInfo, err := GetKey(header)
if err == nil {
UpdateKeyInPool(header, newKeyInfo.Key, newKeyInfo.Ver)
} else {
// 获取密钥失败,切换设备
multiContentLogger.Warning(fmt.Sprintf("获取密钥失败,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
}
needRetry = true
break
}
}
}
if needRetry {
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// 并发解密
results := make(map[string]map[string]interface{})
var mutex sync.Mutex
var wg sync.WaitGroup
for itemID, itemData := range dataMap {
itemMap, ok := itemData.(map[string]interface{})
if !ok {
continue
}
wg.Add(1)
go func(id string, data map[string]interface{}) {
defer wg.Done()
_, decrypted := handleDecryptTask(id, data, currentKeyInfo, textModeBool, imageModeBool)
mutex.Lock()
results[id] = decrypted
mutex.Unlock()
}(itemID, itemMap)
}
wg.Wait()
// 检查结果数量
itemCount := len(strings.Split(itemIDStr, ","))
if len(results) == 1 && itemCount != 1 {
multiContentLogger.Warning(fmt.Sprintf("结果数量异常,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// 构建响应
response := buildCacheData(results)
return buildJSONResponse(response), nil
}
// 响应code不为0,切换设备重试
multiContentLogger.Warning(fmt.Sprintf("响应code异常,切换设备重试 (depth: %d)", depth))
pool.MarkInvalid(header)
pool.RemoveInvalid()
return MultiContent(itemIDs, bookID, textModeBool, imageModeBool, ignoreFailedBool, depth+1)
}
// HandleMultiContent 处理批量获取内容
func HandleMultiContent(itemIDs interface{}, bookID string, textMode, imageMode, ignoreFailed interface{}) (string, error) {
return MultiContent(itemIDs, bookID, textMode, imageMode, ignoreFailed, 0)
}
+215
View File
@@ -0,0 +1,215 @@
package fqapi
import (
"encoding/json"
"fanqie/internal/common"
"fmt"
"strings"
"sync"
)
var multiDetailLogger = common.NewLogger("multi_detail", "INFO", "")
// singleDetail 获取单个书籍详情
func singleDetail(bookID string) (map[string]interface{}, error) {
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/detail/v/", "book_id="+bookID)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
return map[string]interface{}{}, err
}
respJSON, err := resp.JSON()
if err != nil {
return map[string]interface{}{}, err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
if data, ok := respJSON["data"].(map[string]interface{}); ok {
delete(data, "content")
return data, nil
}
}
return map[string]interface{}{}, nil
}
// multiDetail 批量获取书籍详情
func multiDetail(bookIDs string) ([]map[string]interface{}, error) {
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/multi-detail/v/", "book_id="+bookIDs)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
// 返回空列表
count := len(strings.Split(bookIDs, ","))
result := make([]map[string]interface{}, count)
for i := range result {
result[i] = map[string]interface{}{}
}
return result, err
}
respJSON, err := resp.JSON()
if err != nil {
count := len(strings.Split(bookIDs, ","))
result := make([]map[string]interface{}, count)
for i := range result {
result[i] = map[string]interface{}{}
}
return result, err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
if data, ok := respJSON["data"].([]interface{}); ok && len(data) > 0 {
result := make([]map[string]interface{}, len(data))
for i, item := range data {
if itemMap, ok := item.(map[string]interface{}); ok {
result[i] = itemMap
} else {
result[i] = map[string]interface{}{}
}
}
return result, nil
}
}
// 返回空列表
count := len(strings.Split(bookIDs, ","))
result := make([]map[string]interface{}, count)
for i := range result {
result[i] = map[string]interface{}{}
}
return result, nil
}
// chunkArray 分块数组
func chunkArray(arr []string, size int) [][]string {
chunks := make([][]string, 0)
for i := 0; i < len(arr); i += size {
end := i + size
if end > len(arr) {
end = len(arr)
}
chunks = append(chunks, arr[i:end])
}
return chunks
}
// HandleGetMultiDetail 处理批量获取书籍详情
func HandleGetMultiDetail(bookID interface{}) (string, error) {
if bookID == nil {
return BuildErrorResponse("需要参数 book_id"), nil
}
var bookIDList []string
switch v := bookID.(type) {
case string:
if v == "" {
return BuildErrorResponse("需要参数 book_id"), nil
}
bookIDList = strings.Split(v, ",")
case []string:
bookIDList = v
default:
return BuildErrorResponse("book_id 参数格式错误"), nil
}
// 分块处理,每批100个
chunks := chunkArray(bookIDList, 100)
// 并发请求
type result struct {
data []map[string]interface{}
err error
}
results := make([]result, len(chunks))
var wg sync.WaitGroup
for i, chunk := range chunks {
wg.Add(1)
go func(idx int, ch []string) {
defer wg.Done()
data, err := multiDetail(strings.Join(ch, ","))
results[idx] = result{data: data, err: err}
}(i, chunk)
}
wg.Wait()
// 合并结果
res := make([]map[string]interface{}, 0)
for _, r := range results {
if r.err == nil {
res = append(res, r.data...)
} else {
multiDetailLogger.Error(fmt.Sprintf("批量获取详情失败: %v", r.err))
}
}
// 处理下架书籍
offShelfBookIDs := make([]string, 0)
offShelfIndexes := make([]int, 0)
for i, item := range res {
if status, ok := item["tomato_book_status"].(string); ok && status == "3" {
if bid, ok := item["book_id"].(string); ok {
offShelfBookIDs = append(offShelfBookIDs, bid)
offShelfIndexes = append(offShelfIndexes, i)
}
}
}
// 单独获取下架书籍信息
if len(offShelfBookIDs) > 0 {
type offshelfResult struct {
data map[string]interface{}
idx int
}
offshelfResults := make([]offshelfResult, len(offShelfBookIDs))
var wg2 sync.WaitGroup
for i, bid := range offShelfBookIDs {
wg2.Add(1)
go func(idx int, bookID string, originalIdx int) {
defer wg2.Done()
data, _ := singleDetail(bookID)
offshelfResults[idx] = offshelfResult{data: data, idx: originalIdx}
}(i, bid, offShelfIndexes[i])
}
wg2.Wait()
// 更新结果
for _, r := range offshelfResults {
if r.idx < len(res) {
res[r.idx] = r.data
}
}
}
// 序列化结果
response := map[string]interface{}{
"code": 0,
"message": "SUCCESS",
"data": res,
}
resultBytes, _ := json.Marshal(response)
return string(resultBytes), nil
}
+249
View File
@@ -0,0 +1,249 @@
package fqapi
import (
"bytes"
"compress/gzip"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fanqie/internal/common"
"fmt"
"math/rand"
"regexp"
"sort"
"strings"
"time"
)
// KeyInfo 密钥信息
type KeyInfo struct {
Key string `json:"key"`
Ver int `json:"ver"`
}
var (
keyInfo *KeyInfo
keyLogger = common.NewLogger("register_key", "INFO", "")
)
// rHex 将数字转换为反转的16进制字符串
func rHex(num int64) string {
hexStr := fmt.Sprintf("%032x", num)
result := ""
for i := len(hexStr); i > 0; i -= 2 {
result += hexStr[i-2 : i]
}
return result
}
// createSymmetricCrypto 创建对称加密器
func createSymmetricCrypto(algorithm string, key, iv []byte) (func([]byte) ([]byte, error), func([]byte) ([]byte, error), error) {
if algorithm != "AES/CBC/PKCS5Padding" {
return nil, nil, fmt.Errorf("不支持的加密算法")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, err
}
encrypt := func(data []byte) ([]byte, error) {
// PKCS7填充
padding := aes.BlockSize - len(data)%aes.BlockSize
padText := make([]byte, padding)
for i := range padText {
padText[i] = byte(padding)
}
paddedData := append(data, padText...)
ciphertext := make([]byte, len(paddedData))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, paddedData)
return ciphertext, nil
}
decrypt := func(data []byte) ([]byte, error) {
mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(data))
mode.CryptBlocks(decrypted, data)
// 移除PKCS7填充
padding := int(decrypted[len(decrypted)-1])
if padding > len(decrypted) || padding > aes.BlockSize {
return nil, fmt.Errorf("无效的填充")
}
return decrypted[:len(decrypted)-padding], nil
}
return encrypt, decrypt, nil
}
// En AES加密
func En(data, key, iv []byte) ([]byte, error) {
encrypt, _, err := createSymmetricCrypto("AES/CBC/PKCS5Padding", key, iv)
if err != nil {
return nil, err
}
return encrypt(data)
}
// De AES解密
func De(data, key, iv []byte) ([]byte, error) {
_, decrypt, err := createSymmetricCrypto("AES/CBC/PKCS5Padding", key, iv)
if err != nil {
return nil, err
}
return decrypt(data)
}
// randomStringKey 生成随机字符串(密钥用)
func randomStringKey(length int) string {
const charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
b := make([]byte, length)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
return string(b)
}
// GetKey 获取密钥
func GetKey(header string) (*KeyInfo, error) {
if header == "" {
keyLogger.Error("请先注册设备")
return nil, fmt.Errorf("请先注册设备")
}
key, _ := base64.StdEncoding.DecodeString("rCXGfd2POMGzeiNIgo4iLg==")
iv := []byte(randomStringKey(16))
// 提取device_id
re := regexp.MustCompile(`device_id=(\d+)`)
matches := re.FindStringSubmatch(header)
if len(matches) < 2 {
return nil, fmt.Errorf("无法从header中提取device_id")
}
var deviceIDInt int64
fmt.Sscanf(matches[1], "%d", &deviceIDInt)
// 转换device_id
hexStr := rHex(deviceIDInt)
data, _ := hex.DecodeString(hexStr)
keyLogger.Debug(fmt.Sprintf("rKey: %v", iv))
// 加密
encrypted, err := En(data, key, iv)
if err != nil {
return nil, err
}
// 组合iv和加密数据
newArr := append(iv, encrypted...)
content := base64.StdEncoding.EncodeToString(newArr)
keyLogger.Info("正在获取密钥...")
// 构建参数
params := []string{
"aid=1967",
"app_name=novelapp",
"channel=0",
"device_platform=android",
"os_version=0",
"version_code=5.8.9.32",
header,
}
allParams := strings.Join(params, "&")
paramList := strings.Split(allParams, "&")
sort.Strings(paramList)
sortedParams := strings.Join(paramList, "&")
// 准备请求体
requestBody := map[string]string{
"content": content,
}
bodyJSON, _ := json.Marshal(requestBody)
// gzip压缩
var gzipBuf bytes.Buffer
gzipWriter := gzip.NewWriter(&gzipBuf)
gzipWriter.Write(bodyJSON)
gzipWriter.Close()
reqURL := "https://reading.snssdk.com/reading/crypt/registerkey?" + sortedParams
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "POST",
"body": gzipBuf.Bytes(),
"headers": map[string]string{
"User-Agent": "com.dragon.read",
"Content-Encoding": "gzip",
},
})
if err != nil {
keyLogger.Error("获取密钥失败: " + err.Error())
return nil, err
}
if resp.Status == 200 {
var respData map[string]interface{}
json.Unmarshal(resp.Content, &respData)
dataObj, ok := respData["data"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("响应格式错误")
}
keyStr, ok := dataObj["key"].(string)
if !ok {
return nil, fmt.Errorf("密钥字段不存在")
}
// 解码返回的密钥
res, _ := base64.StdEncoding.DecodeString(keyStr)
if len(res) < 32 {
return nil, fmt.Errorf("密钥长度不足")
}
data := res[16:]
ivResp := res[0:16]
// 解密密钥
decryptedKey, err := De(data, key, ivResp)
if err != nil {
return nil, err
}
finalKey := base64.StdEncoding.EncodeToString(decryptedKey)
keyVer, _ := dataObj["keyver"].(float64)
keyInfo = &KeyInfo{
Key: finalKey,
Ver: int(keyVer),
}
keyLogger.Info(fmt.Sprintf("密钥获取成功!"))
return keyInfo, nil
}
return nil, fmt.Errorf("获取密钥失败")
}
// GetKeyInfo 获取当前密钥信息
func GetKeyInfo() *KeyInfo {
return keyInfo
}
func init() {
rand.Seed(time.Now().UnixNano())
}
+371
View File
@@ -0,0 +1,371 @@
package fqapi
import (
"bytes"
"compress/gzip"
"encoding/json"
"fanqie/internal/common"
"fmt"
"strings"
)
var (
reviewLogger = common.NewLogger("review", "INFO", "")
)
// compressJSON 压缩 JSON 数据为 Gzip 格式
func compressJSON(data interface{}) ([]byte, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("JSON序列化失败: %w", err)
}
var compressedData bytes.Buffer
gzw := gzip.NewWriter(&compressedData)
if _, err := gzw.Write(jsonData); err != nil {
gzw.Close()
return nil, fmt.Errorf("Gzip压缩失败: %w", err)
}
if err := gzw.Close(); err != nil {
return nil, fmt.Errorf("Gzip关闭失败: %w", err)
}
return compressedData.Bytes(), nil
}
// postReviewAPI 发送段评相关的 POST 请求
func postReviewAPI(url string, requestBody interface{}) (map[string]interface{}, error) {
// 压缩 JSON
compressedData, err := compressJSON(requestBody)
if err != nil {
return nil, err
}
// 使用 GorgonRequest 添加签名
resp, err := GorgonRequest(url, map[string]interface{}{
"method": "POST",
"body": compressedData,
"headers": map[string]string{
"User-Agent": "com.dragon.read",
"Content-Type": "application/json",
"Content-Encoding": "gzip",
},
})
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
// 解析响应
var result map[string]interface{}
if err := json.Unmarshal(resp.Content, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 提取 data 字段
if data, ok := result["data"].(map[string]interface{}); ok {
return data, nil
}
return result, nil
}
// CommentData 评论数据结构
type CommentData struct {
Data map[string]struct {
Count int `json:"count"`
Hot string `json:"hot"`
IsAuthorComment bool `json:"is_author_comment"`
UserCount int `json:"user_count"`
} `json:"data"`
ItemVersion string `json:"item_version"`
}
// GetChapterReview 获取章节的段落评论统计(想法评论)
func GetChapterReview(itemID string) (map[string]interface{}, error) {
// 使用 BuildURL 添加设备参数
url := BuildURL(fmt.Sprintf("https://reading.snssdk.com/novel/commentapi/idea/list/%s/v1", itemID))
requestBody := map[string]interface{}{
"comment_source": 3,
"item_id": itemID,
"item_version": "cdd11695939bcc41d1ff8c3f10c67802_1_e5c2474f6821deb",
}
return postReviewAPI(url, requestBody)
}
// GetChapterMixData 获取章节混合数据(评论)
func GetChapterMixData(bookID, itemID string) (map[string]interface{}, error) {
// 使用 BuildURL 添加设备参数
url := BuildURL("https://api5-normal-sinfonlinec.fqnovel.com/reading/ugc/item/mix_data/get/v")
requestBody := map[string]interface{}{
"count": 5000,
"item_id": itemID,
"source_type": 51,
"book_id": bookID,
}
return postReviewAPI(url, requestBody)
}
// GetParaReview 获取段落评论或评论回复
// sort: 1=热门, 3=最新
// offset: 分页偏移量
// count: 每页数量
func GetParaReview(itemID, bookID, commentID string, para, sort, offset, count int) (map[string]interface{}, error) {
// 默认排序值
if sort != 1 && sort != 3 {
sort = 1 // 默认热门
}
// 默认数量
if count <= 0 {
count = 20
}
var url string
if commentID != "" {
// 获取评论的回复列表 - 使用 BuildURL 添加设备参数
url = BuildURL("https://api5-normal-sinfonlineb.fqnovel.com/reading/ugc/reply/topic/v/",
fmt.Sprintf("offset=%d", offset),
fmt.Sprintf("group_id=%s", itemID),
"service_id=1",
fmt.Sprintf("count=%d", count),
"source_type=63",
fmt.Sprintf("book_id=%s", bookID),
fmt.Sprintf("comment_id=%s", commentID),
)
} else {
// 获取段落的评论列表 - 使用 BuildURL 添加设备参数
url = BuildURL("https://api5-normal-sinfonlineb.fqnovel.com/reading/ugc/idea/comment_list/v/",
"item_version=c63f0e6195259adec926ae3209291854_1_1099f2b8324de5f",
fmt.Sprintf("offset=%d", offset),
"author_user_id=104957777655",
fmt.Sprintf("item_id=%s", itemID),
fmt.Sprintf("count=%d", count),
fmt.Sprintf("para_index=%d", para),
fmt.Sprintf("book_id=%s", bookID),
fmt.Sprintf("sort=%d", sort),
)
}
// 使用 GorgonRequest 添加签名
resp, err := GorgonRequest(url, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
// 解析响应
var result map[string]interface{}
if err := json.Unmarshal(resp.Content, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 提取 data 字段
if data, ok := result["data"].(map[string]interface{}); ok {
return data, nil
}
return result, nil
}
// GenerateParagraphsWithComments 生成带评论标记的内容
func GenerateParagraphsWithComments(itemID, bookID, content, baseURL string) (string, error) {
// 获取段评数据
data, err := GetChapterReview(itemID)
if err != nil {
reviewLogger.Warning("获取段评数据失败: " + err.Error())
// 如果获取失败,返回原始内容
return content, nil
}
// 解析评论数据
commentData := make(map[string]struct {
Count int `json:"count"`
Hot string `json:"hot"`
IsAuthorComment bool `json:"is_author_comment"`
UserCount int `json:"user_count"`
})
if dataMap, ok := data["data"].(map[string]interface{}); ok {
for key, val := range dataMap {
if valMap, ok := val.(map[string]interface{}); ok {
count := 0
if c, ok := valMap["count"].(float64); ok {
count = int(c)
}
hot := ""
if h, ok := valMap["hot"].(string); ok {
hot = h
}
isAuthor := false
if ia, ok := valMap["is_author_comment"].(bool); ok {
isAuthor = ia
}
userCount := 0
if uc, ok := valMap["user_count"].(float64); ok {
userCount = int(uc)
}
commentData[key] = struct {
Count int `json:"count"`
Hot string `json:"hot"`
IsAuthorComment bool `json:"is_author_comment"`
UserCount int `json:"user_count"`
}{
Count: count,
Hot: hot,
IsAuthorComment: isAuthor,
UserCount: userCount,
}
}
}
}
// 检测内容格式:是否包含HTML标签(检测实际内容,而非配置)
isHTMLContent := strings.Contains(content, "<p>") ||
strings.Contains(content, "<div") ||
strings.Contains(content, "<article")
// 关键:如果检测到有段评数据,强制使用HTML模式(包裹<p>标签)
// 因为<comment>标签需要XML兼容的格式
hasAnyComments := len(commentData) > 0
if hasAnyComments && !isHTMLContent {
isHTMLContent = true
}
lines := strings.Split(content, "\n")
var result strings.Builder
paragraphIndex := 0
for i, line := range lines {
isLastLine := i == len(lines)-1
trimmedLine := strings.TrimSpace(line)
// 完全空行:直接输出换行符
if trimmedLine == "" {
if !isLastLine {
result.WriteString("\n")
}
continue
}
// 跳过图片标签和div标签
if strings.HasPrefix(trimmedLine, "<img") ||
strings.HasPrefix(trimmedLine, "<div") ||
strings.HasPrefix(trimmedLine, "</div>") ||
strings.HasPrefix(trimmedLine, "<article") ||
strings.HasPrefix(trimmedLine, "</article") {
result.WriteString(line)
if !isLastLine {
result.WriteString("\n")
}
continue
}
// 如果原内容已经有<p>标签,在</p>之前插入段评标记
if strings.HasPrefix(trimmedLine, "<p>") && strings.HasSuffix(trimmedLine, "</p>") {
// 提取<p>和</p>之间的内容
innerContent := trimmedLine[3 : len(trimmedLine)-4]
result.WriteString("<p>")
result.WriteString(innerContent)
// 添加评论标记
key := fmt.Sprintf("%d", paragraphIndex)
if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 {
commentTag := fmt.Sprintf(`<comment ident="%s/review_html?item_id=%s&book_id=%s&para=%d" count="%d" />`,
baseURL, itemID, bookID, paragraphIndex, commentInfo.Count)
result.WriteString(commentTag)
}
result.WriteString("</p>")
if !isLastLine {
result.WriteString("\n")
}
paragraphIndex++
continue
}
// 如果是HTML模式,包裹<p>标签
if isHTMLContent {
result.WriteString("<p>")
result.WriteString(line)
// 添加评论标记
key := fmt.Sprintf("%d", paragraphIndex)
if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 {
commentTag := fmt.Sprintf(`<comment ident="%s/review_html?item_id=%s&book_id=%s&para=%d" count="%d" />`,
baseURL, itemID, bookID, paragraphIndex, commentInfo.Count)
result.WriteString(commentTag)
}
result.WriteString("</p>")
if !isLastLine {
result.WriteString("\n")
}
} else {
// 纯文本模式:只在行尾添加comment标记
result.WriteString(line)
// 添加评论标记(纯文本格式)
key := fmt.Sprintf("%d", paragraphIndex)
if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 {
commentTag := fmt.Sprintf(`<comment ident="%s/review_html?item_id=%s&book_id=%s&para=%d" count="%d" />`,
baseURL, itemID, bookID, paragraphIndex, commentInfo.Count)
result.WriteString(commentTag)
}
if !isLastLine {
result.WriteString("\n")
}
}
paragraphIndex++
}
return result.String(), nil
}
// extractDeviceID 从 device header 中提取 device_id
func extractDeviceID(header string) string {
if header == "" {
return "0"
}
// 从 header 中提取 device_id
parts := strings.Split(header, "&")
for _, part := range parts {
if strings.HasPrefix(part, "device_id=") {
return strings.TrimPrefix(part, "device_id=")
}
}
return "0"
}
// ParseJSON 解析 JSON 字符串
func ParseJSON(data []byte, v interface{}) error {
return json.Unmarshal(data, v)
}
// ToJSON 将对象序列化为 JSON 字符串
func ToJSON(v interface{}) (string, error) {
buffer := &strings.Builder{}
encoder := json.NewEncoder(buffer)
encoder.SetEscapeHTML(false) // 禁用HTML转义,避免 " 被转义为 \"
err := encoder.Encode(v)
if err != nil {
return "", err
}
// Encode 会在末尾添加换行符,需要去掉
result := buffer.String()
return strings.TrimSuffix(result, "\n"), nil
}
+59
View File
@@ -0,0 +1,59 @@
package fqapi
import (
"fanqie/internal/common"
"fmt"
"net/url"
)
var searchLogger = common.NewLogger("search", "INFO", "")
// GetDeviceHeader 获取设备header(需要从外部注入)
var GetDeviceHeader func() string
// HandleBookSearch 处理书籍搜索
func HandleBookSearch(key string, offset int, tabType int) (string, error) {
if key == "" {
return BuildErrorResponseWithCode(1, "需要参数 key"), nil
}
if tabType == 0 {
tabType = 3
}
// 编码查询关键字
encodedKey := url.QueryEscape(key)
// 使用新的参数构建函数
reqURL := BuildURL("https://reading.snssdk.com/reading/bookapi/search/tab/v/",
fmt.Sprintf("tab_type=%d", tabType),
"update_version_code=58932",
"query="+encodedKey,
fmt.Sprintf("passback=%d", offset),
)
resp, err := GorgonRequest(reqURL, map[string]interface{}{
"method": "GET",
"headers": map[string]string{
"User-Agent": "com.dragon.read",
},
})
if err != nil {
searchLogger.Error(fmt.Sprintf("搜索请求失败: %v", err))
return BuildErrorResponseWithCode(1, "搜索请求失败"), err
}
respJSON, err := resp.JSON()
if err != nil {
searchLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
return BuildErrorResponseWithCode(1, "解析响应失败"), err
}
if code, ok := respJSON["code"].(float64); ok && code == 0 {
return resp.Text, nil
}
return BuildErrorResponseWithCode(1, "搜索失败"), nil
}
+196
View File
@@ -0,0 +1,196 @@
package fqapi
import (
"bytes"
"compress/gzip"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha512"
"encoding/base64"
"fmt"
"io"
)
// FIXED_STRING 固定字符串(base64解码后的值)
var FIXED_STRING = []byte{
0x4D, 0xD4, 0xC2, 0xE6, 0xB8, 0x31, 0x62, 0x09,
0x0E, 0x52, 0xB3, 0xC7, 0xA6, 0x73, 0x3B, 0xA4,
0x1C, 0xB2, 0x46, 0x2B, 0x82, 0x9A, 0xB5, 0x8A,
0x19, 0x6B, 0x39, 0xDB, 0x57, 0x17, 0x75, 0x24,
0xF4, 0x9B, 0xAF, 0x7F, 0x08, 0xE8, 0xD6, 0x8D,
0x26, 0xA7, 0x2E, 0x37, 0xC1, 0xA9, 0x5A, 0x2F,
0x1F, 0x05, 0xA5, 0x18, 0x92, 0xAE, 0xF2, 0x94,
0x97, 0x32, 0xB6, 0x2A, 0x38, 0xAA, 0xDD, 0x58,
}
// sha512Hash 计算SHA512哈希值
func sha512Hash(data []byte) []byte {
hash := sha512.Sum512(data)
return hash[:]
}
// generateRandomBytes 生成指定长度的随机字节
func generateRandomBytes(size int) []byte {
bytes := make([]byte, size)
rand.Read(bytes)
return bytes
}
// gzipCompress 使用gzip压缩数据
func gzipCompress(data []byte) ([]byte, error) {
buf := new(bytes.Buffer)
writer := gzip.NewWriter(buf)
_, err := writer.Write(data)
if err != nil {
return nil, err
}
err = writer.Close()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// gzipDecompress 使用gzip解压缩数据
func gzipDecompress(data []byte) ([]byte, error) {
reader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer reader.Close()
result, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
return result, nil
}
// encryptData 使用AES-CBC模式加密数据
func encryptData(data []byte, key []byte, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// PKCS7填充
padding := aes.BlockSize - len(data)%aes.BlockSize
padText := bytes.Repeat([]byte{byte(padding)}, padding)
paddedData := append(data, padText...)
ciphertext := make([]byte, len(paddedData))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, paddedData)
return ciphertext, nil
}
// decryptData 使用AES-CBC模式解密数据
func decryptData(data []byte, key []byte, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(data))
mode.CryptBlocks(decrypted, data)
// 移除PKCS7填充
padding := int(decrypted[len(decrypted)-1])
return decrypted[:len(decrypted)-padding], nil
}
// TTEncrypt 加密函数
func TTEncrypt(data []byte) ([]byte, error) {
// 生成随机值
randomValue := generateRandomBytes(32)
// 计算SHA512哈希值
hashValue := sha512Hash(append(sha512Hash(randomValue), FIXED_STRING...))
// 提取key和iv
key := hashValue[:16]
iv := hashValue[16:32]
// 压缩数据并计算哈希值
compressedData, err := gzipCompress(data)
if err != nil {
return nil, err
}
hashedData := append(sha512Hash(compressedData), compressedData...)
// 加密数据
encryptedData, err := encryptData(hashedData, key, iv)
if err != nil {
return nil, err
}
// 返回结果
header := []byte{116, 99, 5, 16, 0, 0}
result := append(header, randomValue...)
result = append(result, encryptedData...)
return result, nil
}
// TTDecrypt 解密函数
func TTDecrypt(encryptedData []byte) ([]byte, error) {
// 提取固定头、随机值和加密数据
// header := encryptedData[:6]
randomValue := encryptedData[6:38]
encryptedPayload := encryptedData[38:]
// 计算SHA512哈希值
hashValue := sha512Hash(append(sha512Hash(randomValue), FIXED_STRING...))
// 提取key和iv
key := hashValue[:16]
iv := hashValue[16:32]
// 解密数据
decryptedData, err := decryptData(encryptedPayload, key, iv)
if err != nil {
return nil, err
}
// 分离哈希值和压缩数据
hashLength := 64
hashedValue := decryptedData[:hashLength]
compressedData := decryptedData[hashLength:]
// 验证哈希值
expectedHash := sha512Hash(compressedData)
for i := 0; i < hashLength; i++ {
if hashedValue[i] != expectedHash[i] {
return nil, fmt.Errorf("hash verification failed")
}
}
// 解压缩数据
originalData, err := gzipDecompress(compressedData)
if err != nil {
return nil, err
}
return originalData, nil
}
// Encrypt 导出的加密函数
func Encrypt(data []byte) string {
result, _ := TTEncrypt(data)
return base64.StdEncoding.EncodeToString(result)
}
// Decrypt 导出的解密函数
func Decrypt(encryptedStr string) ([]byte, error) {
encryptedData, err := base64.StdEncoding.DecodeString(encryptedStr)
if err != nil {
return nil, err
}
return TTDecrypt(encryptedData)
}
+266
View File
@@ -0,0 +1,266 @@
package fqapi
import (
"encoding/base64"
"encoding/json"
"fanqie/internal/common"
"fmt"
"math"
)
var videoLogger = common.NewLogger("video", "INFO", "")
// HandleVideo 处理获取视频(支持批量和单个接口)
func HandleVideo(itemIDs interface{}) (string, error) {
if itemIDs == nil || itemIDs == "" {
return BuildSuccessResponse([]interface{}{}), nil
}
// 处理itemIDs - 使用ParseStringParam
itemIDStr := ParseStringParam(itemIDs, "")
// 判断是单个ID还是多个ID(用逗号分隔)
isSingle := true
if str, ok := itemIDs.(string); ok {
for _, ch := range str {
if ch == ',' {
isSingle = false
break
}
}
}
// 根据ID数量选择不同的接口
var url string
if isSingle {
// 单个视频接口 - 使用带签名的URL
url = BuildURL("https://api5-normal-sinfonlineb.fqnovel.com/novel/player/video_model/v1")
} else {
// 批量视频接口
url = BuildURL("https://reading.snssdk.com/novel/player/multi_video_model/v1/")
}
// 构建请求体,添加更多设备参数,请求所有清晰度
requestBody := fmt.Sprintf(`{"biz_param":{"device_level":3,"need_all_video_definition":true,"need_mp4_align":false,"use_os_player":false,"video_platform":1024},"video_id":"%s"}`,
itemIDStr)
// 使用 GorgonRequest 添加签名和设备信息
resp, err := GorgonRequest(url, map[string]interface{}{
"method": "POST",
"body": requestBody,
"headers": map[string]string{
"User-Agent": "com.dragon.read",
"Content-Type": "application/json",
},
})
if err != nil {
videoLogger.Error(fmt.Sprintf("获取视频失败: %v", err))
return BuildErrorResponse("FAILED"), err
}
respJSON, err := resp.JSON()
if err != nil {
videoLogger.Error(fmt.Sprintf("解析响应失败: %v", err))
return BuildErrorResponse("FAILED"), err
}
// 检查响应码(可能返回 Code 或 code)
var respCode float64
var okCode bool
if code, ok := respJSON["code"].(float64); ok {
respCode, okCode = code, code == 0
} else if code, ok := respJSON["Code"].(float64); ok {
respCode, okCode = code, code == 0
}
if okCode {
// 尝试获取 data(可能在不同位置)
var data map[string]interface{}
if d, ok := respJSON["data"].(map[string]interface{}); ok {
data = d
} else if d, ok := respJSON["Data"].(map[string]interface{}); ok {
data = d
}
if data != nil {
if videoModelStr, ok := data["video_model"].(string); ok && isSingle {
decodedModel := decodeVideoModelAsJSON(videoModelStr)
// 单个接口直接返回 video_model
response := map[string]interface{}{
"code": 200,
"message": "SUCCESS",
"data": decodedModel,
}
resultBytes, _ := json.Marshal(response)
return string(resultBytes), nil
} else {
// 批量接口:data 是一个map,key是ID,value包含video_model
result := make(map[string]interface{})
for itemID, itemData := range data {
if itemMap, ok := itemData.(map[string]interface{}); ok {
if videoModelStr, ok := itemMap["video_model"].(string); ok {
decodedModel := decodeVideoModelAsJSON(videoModelStr)
result[itemID] = map[string]interface{}{
"video_model": decodedModel,
}
}
}
}
// 构建返回结果
response := map[string]interface{}{
"code": 200,
"message": "SUCCESS",
"data": result,
}
resultBytes, _ := json.Marshal(response)
return string(resultBytes), nil
}
}
}
// 返回错误信息
errMsg := "获取视频失败"
if msg, ok := respJSON["message"].(string); ok {
errMsg = msg
} else if msg, ok := respJSON["Message"].(string); ok {
errMsg = msg
}
videoLogger.Error(fmt.Sprintf("获取视频失败: code=%v, msg=%s", respCode, errMsg))
return BuildErrorResponse(errMsg), nil
}
// decodeVideoModelAsJSON 解析并解码 video_model,返回关键字段的 JSON 对象
func decodeVideoModelAsJSON(videoModelStr string) interface{} {
// 解析 video_model JSON 字符串
var videoModel map[string]interface{}
if err := json.Unmarshal([]byte(videoModelStr), &videoModel); err != nil {
// 解析失败,返回空对象
return map[string]interface{}{}
}
// 构建精简的返回结果,只包含关键字段
result := make(map[string]interface{})
// 提取关键字段
if videoID, ok := videoModel["video_id"].(string); ok {
result["video_id"] = videoID
}
if duration, ok := videoModel["video_duration"].(float64); ok {
result["video_duration"] = formatDuration(duration)
}
if posterURL, ok := videoModel["poster_url"].(string); ok {
result["poster_url"] = posterURL
}
// 处理 video_list
if videoList, ok := videoModel["video_list"].(map[string]interface{}); ok {
processedVideoList := make(map[string]interface{})
for quality, qualityData := range videoList {
if qualityMap, ok := qualityData.(map[string]interface{}); ok {
// 只保留关键字段
qualityInfo := make(map[string]interface{})
if definition, ok := qualityMap["definition"].(string); ok {
qualityInfo["definition"] = definition
}
if vtype, ok := qualityMap["vtype"].(string); ok {
qualityInfo["vtype"] = vtype
}
if vwidth, ok := qualityMap["vwidth"].(float64); ok {
qualityInfo["vwidth"] = int(vwidth)
}
if vheight, ok := qualityMap["vheight"].(float64); ok {
qualityInfo["vheight"] = int(vheight)
}
if bitrate, ok := qualityMap["bitrate"].(float64); ok {
qualityInfo["bitrate"] = formatBitrate(bitrate)
}
if size, ok := qualityMap["size"].(float64); ok {
qualityInfo["size"] = formatFileSize(size)
}
if fps, ok := qualityMap["fps"].(float64); ok {
qualityInfo["fps"] = int(fps)
}
// 解码 main_url
if mainURL, ok := qualityMap["main_url"].(string); ok {
if decoded, err := base64.StdEncoding.DecodeString(mainURL); err == nil {
qualityInfo["main_url"] = string(decoded)
}
}
// 解码 backup_url_1
if backupURL, ok := qualityMap["backup_url_1"].(string); ok {
if decoded, err := base64.StdEncoding.DecodeString(backupURL); err == nil {
qualityInfo["backup_url_1"] = string(decoded)
}
}
processedVideoList[quality] = qualityInfo
}
}
result["video_list"] = processedVideoList
}
return result
}
// formatDuration 将秒数转换为可读的时间格式
func formatDuration(seconds float64) string {
totalSeconds := int(math.Round(seconds))
hours := totalSeconds / 3600
minutes := (totalSeconds % 3600) / 60
secs := totalSeconds % 60
if hours > 0 {
return fmt.Sprintf("%d小时%d分%d秒", hours, minutes, secs)
} else if minutes > 0 {
return fmt.Sprintf("%d分%d秒", minutes, secs)
}
return fmt.Sprintf("%d秒", secs)
}
// formatFileSize 将字节数转换为可读的文件大小格式
func formatFileSize(bytes float64) string {
const unit = 1024.0
if bytes < unit {
return fmt.Sprintf("%.0f B", bytes)
}
kb := bytes / unit
if kb < unit {
return fmt.Sprintf("%.2f KB", kb)
}
mb := kb / unit
if mb < unit {
return fmt.Sprintf("%.2f MB", mb)
}
gb := mb / unit
return fmt.Sprintf("%.2f GB", gb)
}
// formatBitrate 将比特率转换为可读的格式
func formatBitrate(bitrate float64) string {
const unit = 1000.0
kbps := bitrate / unit
if kbps < unit {
return fmt.Sprintf("%.0f Kbps", kbps)
}
mbps := kbps / unit
return fmt.Sprintf("%.2f Mbps", mbps)
}