🔧 chore: 初始化番茄小说 API 项目结构
新增项目基础框架,包含核心 API 实现、算法模块、HTTP 处理器等完整功能模块
This commit is contained in:
commit
6981148727
107 files changed
+12463
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("请求失败")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("所有接口都失败")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "&", "&")
|
||||
content = strings.ReplaceAll(content, "<", "<")
|
||||
content = strings.ReplaceAll(content, ">", ">")
|
||||
content = strings.ReplaceAll(content, """, "\"")
|
||||
content = strings.ReplaceAll(content, """, "\"")
|
||||
content = strings.ReplaceAll(content, "'", "'")
|
||||
content = strings.ReplaceAll(content, "'", "'")
|
||||
content = strings.ReplaceAll(content, "'", "'")
|
||||
content = strings.ReplaceAll(content, "/", "/")
|
||||
|
||||
// 解码转义的反斜杠
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("设备注册失败")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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¶=%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¶=%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¶=%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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user