commit 6981148727706a112af095a10a9317a47e5284f0 Author: 陈歆 <2685475410@qq.com> Date: Fri Aug 28 15:22:22 2026 +0800 🔧 chore: 初始化番茄小说 API 项目结构 新增项目基础框架,包含核心 API 实现、算法模块、HTTP 处理器等完整功能模块 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc906de --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Go +bin/ +pkg/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test +*.out + +# Output of the go coverage tool +*.html +_coverage/ + +# Go workspace file +go.work + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Environment variables +.env +.env.local +.env.*.local + +# OS +.DS_Store +Thumbs.db + +# Python (already present) +__pycache__ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ + +# Build output +/dist/ +/build/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7575fc3 --- /dev/null +++ b/README.md @@ -0,0 +1,350 @@ +# 番茄小说 API + +Go 语言实现的番茄小说非官方 API 服务,完整实现了签名算法、设备注册、密钥管理等核心功能。 + +## 功能特性 + +### 核心功能 + +- **书籍搜索** - 关键词搜索,支持分页 +- **书籍详情** - 获取书籍基本信息、封面、作者等 +- **章节目录** - 获取完整章节列表 +- **章节内容** - 获取正文内容,支持纯文本/HTML模式 +- **批量接口** - 支持批量获取书籍详情和章节内容 + +### 内容类型 + +- 📖 小说/电子书 +- 🎨 漫画 +- 🎵 音频/有声书 +- 🎬 视频 + +### 评论功能 + +- 章节段评(段落评论) +- 书籍书评 +- 评论 HTML 渲染 + +### 分类体系 + +- 分类首页数据 +- 分类详情页(支持筛选、排序、分页) + +### 技术特性 + +- **签名算法** - Gorgon、8404、Argus、Ladon、Khronos +- **设备密钥池** - 多设备轮换、自动补充 +- **SQLite 缓存** - 本地缓存、过期清理、数据库压缩 +- **配置热更新** - 修改配置无需重启 +- **访问控制** - 支持 AccessKey 验证 + +## 项目结构 + +```tree +fanqie-go/ +├── cmd/fanqie/ # 程序入口 +│ └── main.go # 主函数,初始化服务 +├── config/ # 配置文件 +│ └── config.yaml # 配置文件 +├── emoji/ # 表情图片静态资源 +├── internal/ +│ ├── algos/ # 签名算法实现 +│ │ ├── gorgon.go # Gorgon 签名算法 +│ │ ├── algo8404.go # 8404 签名算法 +│ │ ├── argus.go # Argus 签名算法 +│ │ ├── ladon.go # Ladon 签名算法 +│ │ ├── signer.go # 签名管理器 +│ │ ├── switcher.go # 算法切换器 +│ │ ├── sm3.go # SM3 哈希算法 +│ │ ├── simon.go # Simon 对称加密 +│ │ ├── pkcs7.go # PKCS7 填充 +│ │ ├── protobuf.go # Protobuf 协议 +│ │ └── bytebuf.go # 字节缓冲区 +│ ├── common/ # 公共工具 +│ │ ├── logger.go # 日志工具 +│ │ ├── http.go # HTTP 工具 +│ │ └── utils.go # 通用工具 +│ ├── config/ # 配置管理 +│ │ ├── config.go # 配置加载 +│ │ └── watcher.go # 配置热更新 +│ ├── fqapi/ # API 封装层 +│ │ ├── cache_manager.go # SQLite 缓存管理 +│ │ ├── device_key_pool.go # 设备密钥池 +│ │ ├── device_register.go # 设备注册 +│ │ ├── register_key.go # 密钥注册 +│ │ ├── dhcry.go # DH 加密交换 +│ │ ├── ttencrypt.go # TTEncrypt 加密 +│ │ ├── search.go # 搜索接口 +│ │ ├── detail.go # 书籍详情 +│ │ ├── catalog.go # 章节目录 +│ │ ├── content.go # 章节内容 +│ │ ├── multi_content.go # 批量内容 +│ │ ├── multi_detail.go # 批量详情 +│ │ ├── item_summary.go # 章节摘要 +│ │ ├── category_front.go # 分类首页 +│ │ ├── category_landing.go # 分类详情 +│ │ ├── audio.go # 音频 +│ │ ├── video.go # 视频 +│ │ ├── manga.go # 漫画 +│ │ ├── manga_proxy.go # 漫画代理 +│ │ ├── review.go # 评论 +│ │ ├── content_parser.go # 内容解析 +│ │ ├── content_utils.go # 内容工具 +│ │ ├── emoji.go # 表情管理 +│ │ ├── device_config.go # 设备配置 +│ │ └── gorgon_utils.go # Gorgon 请求封装 +│ └── handlers/ # HTTP 处理器 +│ ├── common.go # 通用处理 +│ ├── book.go # 书籍接口 +│ ├── content.go # 内容接口 +│ ├── media.go # 媒体接口 +│ ├── review.go # 评论接口 +│ ├── category.go # 分类接口 +│ ├── manga_proxy.go # 漫画代理 +│ └── status.go # 状态页面 +└── go.mod # Go 模块定义 +``` + +## 快速开始 + +### 环境要求 + +- Go 1.24+ +- SQLite3 + +### 安装运行 + +```bash +# 克隆项目 +git clone https://github.com/cyqddx/fanqie-go.git +cd fanqie-go + +# 运行服务 +go run ./cmd/fanqie +``` + +服务默认启动在 `http://0.0.0.0:40180` + +## API 接口文档 + +### 基础接口 + +| 接口 | 方法 | 说明 | +| ------ | ------ | ------ | +| `GET /` | GET | 服务首页 | +| `GET /status` | GET | 服务状态(需 AccessKey) | +| `GET /self-info` | GET | 自我信息(需 AccessKey) | +| `GET /vacuum` | GET | 数据库压缩(需 AccessKey) | + +### 书籍相关 + +| 接口 | 方法 | 说明 | 参数 | +| ------ | ------ | ------ | ------ | +| `/search` | GET | 搜索书籍 | `keyword`, `tab_type`, `offset` | +| `/detail` | GET | 书籍详情 | `book_id` | +| `/info` | GET | 书籍详情(同 detail) | `book_id` | +| `/catalog` | GET | 章节目录 | `book_id` | +| `/directory/all_items` | GET | 章节目录(同 catalog) | `book_id` | +| `/multi-detail` | GET | 批量书籍详情 | `book_ids`(逗号分隔) | + +### 内容相关 + +| 接口 | 方法 | 说明 | 参数 | +| ------ | ------ | ------ | ------ | +| `/content` | GET | 章节内容 | `chapter_id`, `book_id` | +| `/multi-content` | GET | 批量章节内容 | `chapter_ids`(逗号分隔) | +| `/item-summary` | GET | 章节摘要 | `book_id`, `chapter_id` | + +### 评论相关 + +| 接口 | 方法 | 说明 | 参数 | +| ------ | ------ | ------ | ------ | +| `/chapter_review` | GET | 章节评论 | `book_id`, `chapter_id` | +| `/review` | GET | 书评列表 | `book_id`, `offset`, `limit` | +| `/review_html` | GET | 评论 HTML | `book_id`, `chapter_id` | + +### 分类相关 + +| 接口 | 方法 | 说明 | 参数 | +| ------ | ------ | ------ | ------ | +| `/category-front` | GET | 分类首页 | - | +| `/category-landing` | GET | 分类详情 | `category_id`, `word_number`, `book_status`, `sort_by`, `offset` | + +### 媒体相关 + +| 接口 | 方法 | 说明 | 参数 | +| ------ | ------ | ------ | ------ | +| `/audio` | GET | 音频内容 | `audio_id`, `tone_id` | +| `/video` | GET | 视频内容 | `video_id` | +| `/manga` | GET | 漫画内容 | `manga_id`, `chapter_id` | +| `/manga/image/{itemID}/{index}` | GET | 漫画图片 | 路径参数 | + +## 内容获取示例 + +### 搜索书籍 + +```bash +GET http://localhost:40180/search?keyword=斗罗大陆 +``` + +### 获取书籍详情 + +```bash +GET http://localhost:40180/detail?book_id=xxx +``` + +### 获取章节目录 + +```bash +GET http://localhost:40180/catalog?book_id=xxx +``` + +### 获取章节内容 + +```bash +GET http://localhost:40180/content?book_id=xxx&chapter_id=xxx +``` + +支持以下 URL 参数覆盖默认配置: + +| 参数 | 说明 | 可选值 | +| ------ | ------ | ------ | +| `text_mode` | 文本模式 | `plain`(纯文本), `html`(HTML) | +| `img_mode` | 图片模式 | `remove`(移除), `keep`(保留) | +| `review_mode` | 评论模式 | `remove`(移除), `keep`(保留) | + +### 批量获取 + +```bash +GET http://localhost:40180/multi-detail?book_ids=id1,id2,id3 + +GET http://localhost:40180/multi-content?chapter_ids=id1,id2,id3 +``` + +## 配置说明 + +### 完整配置项 + +配置文件位于 `config/config.yaml`: + +```yaml +# 服务器配置 +server: + host: "0.0.0.0" # 监听地址 + port: "40180" # 监听端口 + access_key: "" # 访问密钥(可选) + +# 番茄小说配置 +fanqie: + # 签名算法: "gorgon" / "8404" + algorithm: "gorgon" + + # 签名方法: + # - "sign2": X-Gorgon + X-Khronos + # - "sign4": X-Gorgon + X-Ladon + X-Argus + X-Khronos + signature: "sign4" + + # 内容处理 + content: + remove_images: false # 移除图片 + remove_image_desc: true # 移除图片描述 + remove_blank_lines: true # 移除空行 + remove_html_tags: true # 移除 HTML 标签 + remove_chapter_title: true # 移除章节标题 + add_paragraph_indent: false # 添加段落缩进 + double_newline: false # 段落间双换行 + enable_review: true # 启用段评 + + # 漫画处理 + manga: + # 输出模式: + # - "proxy": 代理模式 + # - "url": 直接返回图片 URL + # - "data_uri": Base64 编码 + output_mode: "url" + +# 缓存配置 +cache: + enabled: true # 启用缓存 + sqlite_path: "./cache.db" # SQLite 路径 + cleanup_enabled: true # 启用自动清理 + cleanup_seconds: 86400 # 清理间隔(秒) + +# 设备密钥池 +device_key_pool: + enabled: true # 启用设备池 + initial_size: 20 # 初始设备数 + min_size: 10 # 最小设备数 + max_size: 30 # 最大设备数 +``` + +### 配置说明表 + +| 配置项 | 说明 | 默认值 | +| ------ | ------ | ------ | +| `server.host` | HTTP 服务监听地址 | `0.0.0.0` | +| `server.port` | HTTP 服务监听端口 | `40180` | +| `server.access_key` | 访问密钥,设为空则不验证 | - | +| `fanqie.algorithm` | 签名算法 `gorgon` / `8404` | `gorgon` | +| `fanqie.signature` | 签名方法 `sign2` / `sign4` | `sign4` | +| `fanqie.content.*` | 正文内容处理选项 | 见上文 | +| `fanqie.manga.output_mode` | 漫画输出模式 | `url` | +| `cache.enabled` | 是否启用 SQLite 缓存 | `true` | +| `cache.sqlite_path` | SQLite 数据库路径 | `./cache.db` | +| `device_key_pool.enabled` | 是否启用设备密钥池 | `true` | +| `device_key_pool.initial_size` | 初始设备数量 | `20` | +| `device_key_pool.min_size` | 最小设备数量 | `10` | +| `device_key_pool.max_size` | 最大设备数量 | `30` | + +## 签名算法详解 + +### 算法实现 + +项目实现了番茄小说的核心签名算法: + +| 算法 | 文件 | 说明 | +| ------ | ------ | ------ | +| **Gorgon** | `algos/gorgon.go` | 核心签名算法,生成 X-Gorgon、X-Khronos、X-SS-REQ-Ticket | +| **8404** | `algos/algo8404.go` | 另一种签名算法实现 | +| **Argus** | `algos/argus.go` | 增强签名,包含 ProtoBuf、SM3、Simon、AES 加密 | +| **Ladon** | `algos/ladon.go` | 附加签名,基于 MD5 变换 | +| **Khronos** | 内嵌于 Gorgon | 时间戳签名 | + +### 签名方法 + +- **sign2**: X-Gorgon + X-Khronos(基础签名) +- **sign4**: X-Gorgon + X-Ladon + X-Argus + X-Khronos(完整签名) + +## 设备密钥池 + +项目实现了设备密钥池管理: + +- **自动注册** - 启动时自动注册多个设备 +- **密钥轮换** - 请求时自动切换设备 +- **自动补充** - 设备数量低于阈值时自动注册新设备 +- **持久化存储** - 设备信息保存到 SQLite + +## 缓存机制 + +- **SQLite 本地缓存** - 缓存书籍内容和章节数据 +- **自动清理** - 定期清理过期缓存 +- **数据库压缩** - 支持 VACUUM 操作优化数据库 + +## 技术栈 + +- **Go 1.24** - 编程语言 +- **Gin** - HTTP 框架(间接依赖) +- **SQLite3** - 本地数据库 +- **Viper** - 配置管理 +- **goquery** - HTML 解析 + +## 注意事项 + +- 本项目仅供学习和研究使用 +- 请遵守番茄小说的服务条款 +- 请合理使用,避免对官方服务造成影响 +- 签名算法和设备注册可能随时失效 + +## License + +MIT License diff --git a/cmd/fanqie/main.go b/cmd/fanqie/main.go new file mode 100644 index 0000000..0693796 --- /dev/null +++ b/cmd/fanqie/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "fanqie/internal/algos" + "fanqie/internal/common" + "fanqie/internal/config" + "fanqie/internal/fqapi" + "fanqie/internal/handlers" + "fmt" + "log" + "net/http" + "os" +) + +var ( + logger = common.NewLogger("main", "INFO", "") + configWatcher *config.ConfigWatcher +) + +func main() { + logger.Info("番茄小说API服务 - v2.0.0") + + // 加载配置文件 + initConfig() + + // 初始化数据库 + if err := fqapi.InitDB(); err != nil { + logger.Error("数据库初始化失败: " + err.Error()) + os.Exit(1) + } + defer fqapi.CloseCache() // 程序退出时关闭数据库 + + // 加载统计数据 + handlers.LoadStatistics() + + // 启动统计数据定期保存 + handlers.StartStatisticsSaver() + defer handlers.StopStatisticsSaver() // 程序退出时保存统计数据 + + // 启动缓存自动清理任务 + fqapi.StartCacheCleanup() + defer fqapi.StopCacheCleanup() // 程序退出时停止清理任务 + + // 初始化算法切换器 + initAlgorithm() + + // 初始化设备密钥池 + if err := initDeviceKeyPool(); err != nil { + logger.Error("设备密钥池初始化失败: " + err.Error()) + os.Exit(1) + } + + // 设置路由 + setupRoutes() + + // 启动服务器 + startServer() +} + +// initConfig 初始化配置 +func initConfig() { + configPath := "./config/config.yaml" + if _, err := os.Stat(configPath); os.IsNotExist(err) { + os.MkdirAll("./config", 0755) + } + + if err := config.LoadConfig(configPath); err != nil { + logger.Warning(fmt.Sprintf("加载配置失败: %v", err)) + } + + // 启动配置文件热更新监控 + watcher, err := config.StartConfigWatcher(configPath) + if err != nil { + logger.Warning(fmt.Sprintf("配置监控失败: %v", err)) + } else { + configWatcher = watcher + } + + // 注册配置更新回调 + config.RegisterConfigCallback(func(cfg *config.Config) { + algos.GlobalAlgorithmSwitcher.SwitchAlgorithm(cfg.Fanqie.Algorithm) + algos.GlobalSignatureSwitcher.SwitchSignature(cfg.Fanqie.Signature) + }) +} + +// initAlgorithm 初始化算法 +func initAlgorithm() { + if config.AppConfig != nil { + algos.GlobalAlgorithmSwitcher.SwitchAlgorithm(config.AppConfig.Fanqie.Algorithm) + logger.Info(fmt.Sprintf("当前算法: %s", config.AppConfig.Fanqie.Algorithm)) + + algos.GlobalSignatureSwitcher.SwitchSignature(config.AppConfig.Fanqie.Signature) + + // 根据签名方法显示详细信息 + var signatureDetails string + if config.AppConfig.Fanqie.Signature == "sign4" { + signatureDetails = "X-Gorgon + X-Ladon + X-Argus + X-Khronos" + } else { + signatureDetails = "X-Gorgon + X-Khronos" + } + logger.Info(fmt.Sprintf("当前签名方法: %s", signatureDetails)) + } else { + algos.GlobalAlgorithmSwitcher.SwitchAlgorithm("gorgon") + algos.GlobalSignatureSwitcher.SwitchSignature("sign2") + } +} + +// initDeviceKeyPool 初始化设备密钥池 +func initDeviceKeyPool() error { + pool := fqapi.GetDeviceKeyPool() + if err := pool.Initialize(); err != nil { + return fmt.Errorf("初始化设备密钥池失败: %w", err) +} + + // 显示池状态 + stats := pool.GetStats() + logger.Info(fmt.Sprintf("设备密钥池状态 - 总数: %d, 有效: %d, 无效: %d", + stats["total"], stats["valid"], stats["invalid"])) + + return nil +} + +// setupRoutes 设置路由 +func setupRoutes() { + http.HandleFunc("/", handlers.HandleMain) + http.HandleFunc("/status", handlers.RequireAccessKey(handlers.HandleStatus)) + http.HandleFunc("/self-info", handlers.RequireAccessKey(handlers.HandleSelfInfo)) + http.HandleFunc("/vacuum", handlers.RequireAccessKey(handlers.HandleVacuum)) + + // API端点 + http.HandleFunc("/search", handlers.HandleSearch) + http.HandleFunc("/detail", handlers.HandleDetail) + http.HandleFunc("/info", handlers.HandleDetail) + http.HandleFunc("/catalog", handlers.HandleCatalog) + http.HandleFunc("/directory/all_items", handlers.HandleCatalog) + http.HandleFunc("/multi-detail", handlers.HandleMultiDetail) + http.HandleFunc("/multi-content", handlers.HandleMultiContent) + http.HandleFunc("/content", handlers.HandleContent) + http.HandleFunc("/item-summary", handlers.HandleItemSummary) + http.HandleFunc("/category-front", handlers.HandleCategoryFront) + http.HandleFunc("/category-landing", handlers.HandleCategoryLanding) + http.HandleFunc("/audio", handlers.HandleAudio) + http.HandleFunc("/audio/proxy/", handlers.HandleAudioProxy) + http.HandleFunc("/video", handlers.HandleVideo) + http.HandleFunc("/manga", handlers.HandleManga) + http.HandleFunc("/manga/image/", handlers.HandleMangaImageProxy) + + // 评论相关端点 + http.HandleFunc("/chapter_review", handlers.HandleChapterReview) + http.HandleFunc("/review", handlers.HandleReview) + http.HandleFunc("/review_html", handlers.HandleReviewHTML) + + // 表情图片静态文件服务 + http.Handle("/emoji/", http.StripPrefix("/emoji/", http.FileServer(http.Dir("emoji")))) +} + +// startServer 启动服务器 +func startServer() { + host := "0.0.0.0" + port := "40180" + + if config.AppConfig != nil { + if config.AppConfig.Server.Host != "" { + host = config.AppConfig.Server.Host + } + if config.AppConfig.Server.Port != "" { + port = config.AppConfig.Server.Port + } + } + + addr := host + ":" + port + logger.Info(fmt.Sprintf("服务器启动在 %s", addr)) + + if err := http.ListenAndServe(addr, nil); err != nil { + logger.Error(fmt.Sprintf("服务器启动失败: %v", err)) + os.Exit(1) + } +} + +func init() { + log.SetFlags(log.LstdFlags | log.Lshortfile) +} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..9d3dcda --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,82 @@ +# 番茄小说API服务配置文件 + +# 服务器配置 +server: + port: "40180" # 监听端口 + host: "0.0.0.0" # 监听地址 + access_key: "" # 访问密钥,为空则不启用验证(用于 /status 和 /self-info) + +# 番茄小说配置 +fanqie: + # 算法类型: "gorgon" 或 "8404" + # gorgon: Gorgon算法 + # 8404: 备用算法 + algorithm: "gorgon" + + # 签名方法: "sign2" 或 "sign4" + # sign2: 仅Gorgon签名 + # sign4: X-Gorgon + X-Ladon + X-Argus 完整签名 + signature: "sign4" + + # 内容处理配置 + content: + # 是否移除图片(默认false,保留图片) + remove_images: false + + # 是否移除图片描述(默认false,保留描述) + remove_image_desc: true + + # 是否移除空行(默认true,移除空行) + remove_blank_lines: true + + # 是否移除HTML标签(默认false,保留HTML) + remove_html_tags: true + + # 是否移除章节名(默认false,保留章节名) + remove_chapter_title: true + + # 是否添加段落缩进(默认true,添加两个全角空格) + add_paragraph_indent: false + + # 段落间使用双换行(true=\n\n, false=\n) + double_newline: false + + # 是否启用段评功能(默认true,启用段评) + enable_review: true + + # 漫画处理配置 + manga: + # 输出模式:proxy / data_uri / url + # proxy: 返回本地代理URL /manga/image/{itemID}/{index} + # data_uri: 返回 data:image/jpeg;base64,/9j/4AAQ... + # url: 返回原始图片URL数组 + output_mode: "url" + +# 缓存配置 +cache: + # 是否启用正文内容缓存 + enabled: true + + # SQLite数据库文件路径 + sqlite_path: "./cache.db" + + # 是否启用正文内容缓存自动清理 + cleanup_enabled: true + + # 正文内容缓存清理时间(秒),0表示不自动清理 + cleanup_seconds: 86400 + +# 设备密钥池配置 +device_key_pool: + # 是否启用设备密钥池 + enabled: true + + # 初始化设备数量 + initial_size: 20 + + # 最小设备数量(低于此数量自动补充) + min_size: 10 + + # 最大设备数量 + max_size: 30 + diff --git a/emoji/data.json b/emoji/data.json new file mode 100644 index 0000000..0894438 --- /dev/null +++ b/emoji/data.json @@ -0,0 +1,55 @@ +{ + "version":1, + "size": 49, + "datas": { + "1": "[微笑]", + "2": "[偷笑]", + "3": "[笑]", + "4": "[什么]", + "5": "[害羞]", + "6": "[爱慕]", + "7": "[飞吻]", + "8": "[奸笑]", + "9": "[尬笑]", + "10": "[思考]", + "11": "[撇嘴]", + "12": "[做鬼脸]", + "13": "[酷]", + "14": "[翻白眼]", + "15": "[惊呆]", + "16": "[震惊]", + "17": "[送心]", + "18": "[委屈]", + "19": "[快哭了]", + "20": "[笑哭]", + "21": "[哭]", + "22": "[大笑]", + "23": "[舔屏]", + "24": "[怒]", + "25": "[捂脸]", + "26": "[吐]", + "27": "[恐惧]", + "28": "[抓狂]", + "29": "[敬礼]", + "30": "[石化]", + "31": "[OK]", + "32": "[赞]", + "33": "[爱心]", + "34": "[伤心]", + "35": "[KISS]", + "36": "[懂了]", + "37": "[探究]", + "38": "[重拳出击]", + "39": "[吃瓜]", + "40": "[盯]", + "41": "[你细品]", + "42": "[赶稿中]", + "43": "[注意]", + "44": "[饱了]", + "45": "[码住]", + "46": "[学会了]", + "47": "[顶帖]", + "48": "[求爆更]", + "49": "[求关注]" + } +} \ No newline at end of file diff --git a/emoji/images/emoji_dr_1.png b/emoji/images/emoji_dr_1.png new file mode 100644 index 0000000..93dbd58 Binary files /dev/null and b/emoji/images/emoji_dr_1.png differ diff --git a/emoji/images/emoji_dr_10.png b/emoji/images/emoji_dr_10.png new file mode 100644 index 0000000..b9ad433 Binary files /dev/null and b/emoji/images/emoji_dr_10.png differ diff --git a/emoji/images/emoji_dr_11.png b/emoji/images/emoji_dr_11.png new file mode 100644 index 0000000..a236269 Binary files /dev/null and b/emoji/images/emoji_dr_11.png differ diff --git a/emoji/images/emoji_dr_12.png b/emoji/images/emoji_dr_12.png new file mode 100644 index 0000000..244b7dc Binary files /dev/null and b/emoji/images/emoji_dr_12.png differ diff --git a/emoji/images/emoji_dr_13.png b/emoji/images/emoji_dr_13.png new file mode 100644 index 0000000..e1927f7 Binary files /dev/null and b/emoji/images/emoji_dr_13.png differ diff --git a/emoji/images/emoji_dr_14.png b/emoji/images/emoji_dr_14.png new file mode 100644 index 0000000..79dc6be Binary files /dev/null and b/emoji/images/emoji_dr_14.png differ diff --git a/emoji/images/emoji_dr_15.png b/emoji/images/emoji_dr_15.png new file mode 100644 index 0000000..ac6a411 Binary files /dev/null and b/emoji/images/emoji_dr_15.png differ diff --git a/emoji/images/emoji_dr_16.png b/emoji/images/emoji_dr_16.png new file mode 100644 index 0000000..6ab75d2 Binary files /dev/null and b/emoji/images/emoji_dr_16.png differ diff --git a/emoji/images/emoji_dr_17.png b/emoji/images/emoji_dr_17.png new file mode 100644 index 0000000..23b1c05 Binary files /dev/null and b/emoji/images/emoji_dr_17.png differ diff --git a/emoji/images/emoji_dr_18.png b/emoji/images/emoji_dr_18.png new file mode 100644 index 0000000..ed0afdf Binary files /dev/null and b/emoji/images/emoji_dr_18.png differ diff --git a/emoji/images/emoji_dr_19.png b/emoji/images/emoji_dr_19.png new file mode 100644 index 0000000..1e8d696 Binary files /dev/null and b/emoji/images/emoji_dr_19.png differ diff --git a/emoji/images/emoji_dr_2.png b/emoji/images/emoji_dr_2.png new file mode 100644 index 0000000..79cc2cd Binary files /dev/null and b/emoji/images/emoji_dr_2.png differ diff --git a/emoji/images/emoji_dr_20.png b/emoji/images/emoji_dr_20.png new file mode 100644 index 0000000..e6585ad Binary files /dev/null and b/emoji/images/emoji_dr_20.png differ diff --git a/emoji/images/emoji_dr_21.png b/emoji/images/emoji_dr_21.png new file mode 100644 index 0000000..8ed4c3c Binary files /dev/null and b/emoji/images/emoji_dr_21.png differ diff --git a/emoji/images/emoji_dr_22.png b/emoji/images/emoji_dr_22.png new file mode 100644 index 0000000..c9af036 Binary files /dev/null and b/emoji/images/emoji_dr_22.png differ diff --git a/emoji/images/emoji_dr_23.png b/emoji/images/emoji_dr_23.png new file mode 100644 index 0000000..281b612 Binary files /dev/null and b/emoji/images/emoji_dr_23.png differ diff --git a/emoji/images/emoji_dr_24.png b/emoji/images/emoji_dr_24.png new file mode 100644 index 0000000..0f1f79d Binary files /dev/null and b/emoji/images/emoji_dr_24.png differ diff --git a/emoji/images/emoji_dr_25.png b/emoji/images/emoji_dr_25.png new file mode 100644 index 0000000..f067b27 Binary files /dev/null and b/emoji/images/emoji_dr_25.png differ diff --git a/emoji/images/emoji_dr_26.png b/emoji/images/emoji_dr_26.png new file mode 100644 index 0000000..c18adbd Binary files /dev/null and b/emoji/images/emoji_dr_26.png differ diff --git a/emoji/images/emoji_dr_27.png b/emoji/images/emoji_dr_27.png new file mode 100644 index 0000000..0691600 Binary files /dev/null and b/emoji/images/emoji_dr_27.png differ diff --git a/emoji/images/emoji_dr_28.png b/emoji/images/emoji_dr_28.png new file mode 100644 index 0000000..b5b2ba7 Binary files /dev/null and b/emoji/images/emoji_dr_28.png differ diff --git a/emoji/images/emoji_dr_29.png b/emoji/images/emoji_dr_29.png new file mode 100644 index 0000000..b02beb5 Binary files /dev/null and b/emoji/images/emoji_dr_29.png differ diff --git a/emoji/images/emoji_dr_3.png b/emoji/images/emoji_dr_3.png new file mode 100644 index 0000000..677a91d Binary files /dev/null and b/emoji/images/emoji_dr_3.png differ diff --git a/emoji/images/emoji_dr_30.png b/emoji/images/emoji_dr_30.png new file mode 100644 index 0000000..b5eda5e Binary files /dev/null and b/emoji/images/emoji_dr_30.png differ diff --git a/emoji/images/emoji_dr_31.png b/emoji/images/emoji_dr_31.png new file mode 100644 index 0000000..6cb04d9 Binary files /dev/null and b/emoji/images/emoji_dr_31.png differ diff --git a/emoji/images/emoji_dr_32.png b/emoji/images/emoji_dr_32.png new file mode 100644 index 0000000..fba6b7c Binary files /dev/null and b/emoji/images/emoji_dr_32.png differ diff --git a/emoji/images/emoji_dr_33.png b/emoji/images/emoji_dr_33.png new file mode 100644 index 0000000..1b17978 Binary files /dev/null and b/emoji/images/emoji_dr_33.png differ diff --git a/emoji/images/emoji_dr_34.png b/emoji/images/emoji_dr_34.png new file mode 100644 index 0000000..df9906e Binary files /dev/null and b/emoji/images/emoji_dr_34.png differ diff --git a/emoji/images/emoji_dr_35.png b/emoji/images/emoji_dr_35.png new file mode 100644 index 0000000..57f6222 Binary files /dev/null and b/emoji/images/emoji_dr_35.png differ diff --git a/emoji/images/emoji_dr_36.png b/emoji/images/emoji_dr_36.png new file mode 100644 index 0000000..218d713 Binary files /dev/null and b/emoji/images/emoji_dr_36.png differ diff --git a/emoji/images/emoji_dr_37.png b/emoji/images/emoji_dr_37.png new file mode 100644 index 0000000..cdf0af4 Binary files /dev/null and b/emoji/images/emoji_dr_37.png differ diff --git a/emoji/images/emoji_dr_38.png b/emoji/images/emoji_dr_38.png new file mode 100644 index 0000000..26a4a04 Binary files /dev/null and b/emoji/images/emoji_dr_38.png differ diff --git a/emoji/images/emoji_dr_39.png b/emoji/images/emoji_dr_39.png new file mode 100644 index 0000000..ec5f227 Binary files /dev/null and b/emoji/images/emoji_dr_39.png differ diff --git a/emoji/images/emoji_dr_4.png b/emoji/images/emoji_dr_4.png new file mode 100644 index 0000000..5ea8316 Binary files /dev/null and b/emoji/images/emoji_dr_4.png differ diff --git a/emoji/images/emoji_dr_40.png b/emoji/images/emoji_dr_40.png new file mode 100644 index 0000000..6107e54 Binary files /dev/null and b/emoji/images/emoji_dr_40.png differ diff --git a/emoji/images/emoji_dr_41.png b/emoji/images/emoji_dr_41.png new file mode 100644 index 0000000..c354337 Binary files /dev/null and b/emoji/images/emoji_dr_41.png differ diff --git a/emoji/images/emoji_dr_42.png b/emoji/images/emoji_dr_42.png new file mode 100644 index 0000000..df740dd Binary files /dev/null and b/emoji/images/emoji_dr_42.png differ diff --git a/emoji/images/emoji_dr_43.png b/emoji/images/emoji_dr_43.png new file mode 100644 index 0000000..3437274 Binary files /dev/null and b/emoji/images/emoji_dr_43.png differ diff --git a/emoji/images/emoji_dr_44.png b/emoji/images/emoji_dr_44.png new file mode 100644 index 0000000..e51bcde Binary files /dev/null and b/emoji/images/emoji_dr_44.png differ diff --git a/emoji/images/emoji_dr_45.png b/emoji/images/emoji_dr_45.png new file mode 100644 index 0000000..a572b8c Binary files /dev/null and b/emoji/images/emoji_dr_45.png differ diff --git a/emoji/images/emoji_dr_46.png b/emoji/images/emoji_dr_46.png new file mode 100644 index 0000000..c2cbf4f Binary files /dev/null and b/emoji/images/emoji_dr_46.png differ diff --git a/emoji/images/emoji_dr_47.png b/emoji/images/emoji_dr_47.png new file mode 100644 index 0000000..5033135 Binary files /dev/null and b/emoji/images/emoji_dr_47.png differ diff --git a/emoji/images/emoji_dr_48.png b/emoji/images/emoji_dr_48.png new file mode 100644 index 0000000..5936780 Binary files /dev/null and b/emoji/images/emoji_dr_48.png differ diff --git a/emoji/images/emoji_dr_49.png b/emoji/images/emoji_dr_49.png new file mode 100644 index 0000000..2f753d9 Binary files /dev/null and b/emoji/images/emoji_dr_49.png differ diff --git a/emoji/images/emoji_dr_5.png b/emoji/images/emoji_dr_5.png new file mode 100644 index 0000000..e61aa11 Binary files /dev/null and b/emoji/images/emoji_dr_5.png differ diff --git a/emoji/images/emoji_dr_6.png b/emoji/images/emoji_dr_6.png new file mode 100644 index 0000000..69d6b0b Binary files /dev/null and b/emoji/images/emoji_dr_6.png differ diff --git a/emoji/images/emoji_dr_7.png b/emoji/images/emoji_dr_7.png new file mode 100644 index 0000000..a72ffc0 Binary files /dev/null and b/emoji/images/emoji_dr_7.png differ diff --git a/emoji/images/emoji_dr_8.png b/emoji/images/emoji_dr_8.png new file mode 100644 index 0000000..16a6ebf Binary files /dev/null and b/emoji/images/emoji_dr_8.png differ diff --git a/emoji/images/emoji_dr_9.png b/emoji/images/emoji_dr_9.png new file mode 100644 index 0000000..38a8924 Binary files /dev/null and b/emoji/images/emoji_dr_9.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..666fd7a --- /dev/null +++ b/go.mod @@ -0,0 +1,64 @@ +module fanqie + +go 1.24.0 + +require ( + github.com/PuerkitoBio/goquery v1.8.1 + github.com/fsnotify/fsnotify v1.7.0 + github.com/google/uuid v1.4.0 + github.com/mattn/go-sqlite3 v1.14.18 + github.com/spf13/viper v1.18.2 +) + +require ( + github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.2 // indirect + github.com/bytedance/sonic/loader v0.4.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.11 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.11.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.55.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/mock v0.6.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bd04443 --- /dev/null +++ b/go.sum @@ -0,0 +1,246 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= +github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= +github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= +github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= +github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= +github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= +github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/algos/algo8404.go b/internal/algos/algo8404.go new file mode 100644 index 0000000..e0f6ec3 --- /dev/null +++ b/internal/algos/algo8404.go @@ -0,0 +1,174 @@ +package algos + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "math/rand" +) + +// Algo8404 8404加密器 +type Algo8404 struct { + unix int64 + params string + xSSStub string +} + +// NewAlgo8404 创建新的Algo8404实例 +func NewAlgo8404(params string, unix int64, xSSStub string) *Algo8404 { + if xSSStub == "" { + xSSStub = "00000000000000000000000000000000" + } + return &Algo8404{ + unix: unix, + params: params, + xSSStub: xSSStub, + } +} + +// getXGorgon 生成X-Gorgon签名 +func (a *Algo8404) getXGorgon() string { + timeHex := fmt.Sprintf("%x", a.unix) + timeBin, _ := hex.DecodeString(timeHex) + + m := md5.Sum([]byte(a.params)) + mBytes := m[:4] + + var aaa []byte + if a.xSSStub != "" { + stubBytes, _ := hex.DecodeString(a.xSSStub) + if len(stubBytes) >= 4 { + aaa = stubBytes[:4] + } else { + aaa = make([]byte, 4) + } + } else { + aaa = make([]byte, 4) + } + + // 生成随机数 + a1 := rand.Intn(256) + a2 := rand.Intn(256) + + // 构建数据 + data := make([]byte, 0) + data = append(data, mBytes...) + data = append(data, aaa...) + data = append(data, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x07, 0x04}...) + data = append(data, timeBin...) + + // 初始化表 + table := make([]int, 256) + for i := 0; i < 256; i++ { + table[i] = i + } + + key := []int{0x4A, 0x00, 0x16, a2, 0x47, 0x6C, 0x00, a1} + + // 第一轮处理 + w8 := 0 + for i := 0; i < 256; i++ { + keyI := i & 7 + w10 := key[keyI] + w9 := w8 + table[i] + w9 = (w9 + w10) & 0xFFFFFFFF + w10 = w9 + w10 &= 0xFFFFFF00 + w20 := (w9 - w10) & 0xFFFFFFFF + table[i] = table[w20] + w8 = w20 + } + + // 第二轮处理 + dataArr := make([]int, len(data)) + for i, b := range data { + dataArr[i] = int(b) + } + + w26 := 0 + for i := 0; i < len(dataArr); i++ { + w25 := i + w8_ := w25 ^ 1 + w9_ := ((w25 & 1) << 1) & 2 + w8_ = (w9_ + w8_) & 0xFFFFFFFF + x10 := w8_ & 0xFF + + w9b := table[x10] + w11 := (w26 ^ w9b) & 0xFFFFFFFF + w9c := ((w26 | w9b) << 1) & 0xFFFFFFFF + w9c = (w9c - w11) & 0xFFFFFFFF + w26 = w9c + x11 := w26 & 0xFF + + w12 := table[x11] + table[x10] = w12 + table[x11] = w12 + + w10_ := w12 + w11_ := dataArr[i] + w13_ := w10_ | w12 + w10b := w10_ & w12 + wX10 := (w13_ + w10b) & 0xFF + ks := table[wX10] + + dataArr[i] = ks ^ w11_ + } + + // 第三轮处理 + w2 := 0xFFFFFFAA + w3 := 0x55 + w4 := 0x33 + w8_2 := (^len(dataArr)) & 0xFFFFFFFF + + for i := 0; i < len(dataArr); i++ { + val := dataArr[i] + w16 := ((val >> 4) & 0x0F) | ((val & 0x0F) << 4) + dataArr[i] = w16 & 0xFF + + if i == len(dataArr)-1 { + dataArr[i] = dataArr[0] ^ w16 + } else { + w7 := dataArr[i] + w16n := dataArr[i+1] + w20 := w7 | w16n + wI2 := w7 & w16n + dataArr[i] = (w20 - wI2) & 0xFF + } + + w13_ := dataArr[i] + t1 := w2 & (w13_ << 1) + t2 := w3 & (w13_ >> 1) + w13_ = t1 | t2 + t3 := w13_ << 2 + t4 := w4 & (w13_ >> 2) + mix := (t3 & 0xFFFFFFCF) | t4 + high := (mix >> 4) & 0x0F + low := mix & 0x0FFFFFFF + mix = ((low << 4) | high) & 0xFFFFFFFF + w13_ = mix ^ w8_2 + + dataArr[i] = w13_ & 0xFF + } + + // 构建输出 + output := make([]byte, 0) + output = append(output, 0x84, 0x04) + output = append(output, byte(a1), byte(a2)) + output = append(output, 0x00, 0x00) + + for _, val := range dataArr { + output = append(output, byte(val)) + } + + return hex.EncodeToString(output) +} + +// GetValue 获取加密值 +func (a *Algo8404) GetValue() map[string]string { + return map[string]string{ + "X-Gorgon": a.getXGorgon(), + "X-Khronos": fmt.Sprintf("%d", a.unix), + "X-SS-REQ-Ticket": fmt.Sprintf("%d", a.unix*1000), + } +} + diff --git a/internal/algos/argus.go b/internal/algos/argus.go new file mode 100644 index 0000000..2acbd6e --- /dev/null +++ b/internal/algos/argus.go @@ -0,0 +1,177 @@ +package algos + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "fmt" + "math/rand" + "net/url" +) + +// Argus Argus加密器 +type Argus struct{} + +// encryptEncPb 加密ProtoBuf +func encryptEncPb(data []byte, l int) []byte { + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + + xorArray := dataCopy[:8] + + for i := 8; i < l; i++ { + dataCopy[i] ^= xorArray[i%8] + } + + // 反转数组 + for i := 0; i < l/2; i++ { + dataCopy[i], dataCopy[l-1-i] = dataCopy[l-1-i], dataCopy[i] + } + + return dataCopy[:l] +} + +// GetBodyHash 获取Body哈希 +func GetBodyHash(stub string) []byte { + sm3 := NewSM3() + if stub == "" || len(stub) == 0 { + return sm3.Hash(make([]byte, 16))[0:6] + } + stubBytes, _ := hex.DecodeString(stub) + return sm3.Hash(stubBytes)[0:6] +} + +// GetQueryHash 获取Query哈希 +func GetQueryHash(query string) []byte { + sm3 := NewSM3() + if query == "" || len(query) == 0 { + return sm3.Hash(make([]byte, 16))[0:6] + } + return sm3.Hash([]byte(query))[0:6] +} + +// Encrypt Argus加密 +func (a *Argus) Encrypt(xargusBean map[int]interface{}) string { + // 创建ProtoBuf + protobuf := NewProtoBuf(xargusBean).ToBuf() + + // PKCS7填充 + protobuf = AddPKCS7Padding(protobuf, 16) + newLen := len(protobuf) + + signKey := []byte{ + 0xAC, 0x1A, 0xDA, 0xAE, 0x95, 0xA7, 0xAF, 0x94, + 0xA5, 0x11, 0x4A, 0xB3, 0xB3, 0xA9, 0x7D, 0xD8, + 0x00, 0x50, 0xAA, 0x0A, 0x39, 0x31, 0x4C, 0x40, + 0x52, 0x8C, 0xAE, 0xC9, 0x52, 0x56, 0xC2, 0x8C, + } + + sm3Output := []byte{ + 0xFC, 0x78, 0xE0, 0xA9, 0x65, 0x7A, 0x0C, 0x74, + 0x8C, 0xE5, 0x15, 0x59, 0x90, 0x3C, 0xCF, 0x03, + 0x51, 0x0E, 0x51, 0xD3, 0xCF, 0xF2, 0x32, 0xD7, + 0x13, 0x43, 0xE8, 0x8A, 0x32, 0x1C, 0x53, 0x04, + } + + key := sm3Output[:32] + keyList := make([]uint64, 0) + encPb := make([]byte, newLen) + + for i := 0; i < 2; i++ { + val1 := binary.LittleEndian.Uint64(key[i*16 : i*16+8]) + val2 := binary.LittleEndian.Uint64(key[i*16+8 : i*16+16]) + keyList = append(keyList, val1, val2) + } + + for i := 0; i < newLen/16; i++ { + pt := []uint64{ + binary.LittleEndian.Uint64(protobuf[i*16 : i*16+8]), + binary.LittleEndian.Uint64(protobuf[i*16+8 : i*16+16]), + } + ct := SimonEnc(pt, keyList, 0) + binary.LittleEndian.PutUint64(encPb[i*16:i*16+8], ct[0]) + binary.LittleEndian.PutUint64(encPb[i*16+8:i*16+16], ct[1]) + } + + header := []byte{0xF2, 0xF7, 0xFC, 0xFF, 0xF2, 0xF7, 0xFC, 0xFF} + bBuffer := encryptEncPb(append(header, encPb...), newLen+8) + bBuffer = append([]byte{0xA6, 0x6E, 0xAD, 0x9F, 0x77, 0x01, 0xD0, 0x0C, 0x18}, bBuffer...) + bBuffer = append(bBuffer, []byte("ao")...) + + // AES加密 + keyHash := md5.Sum(signKey[:16]) + ivHash := md5.Sum(signKey[16:]) + + block, _ := aes.NewCipher(keyHash[:]) + paddedBuffer := AddPKCS7Padding(bBuffer, aes.BlockSize) + ciphertext := make([]byte, len(paddedBuffer)) + + mode := cipher.NewCBCEncrypter(block, ivHash[:]) + mode.CryptBlocks(ciphertext, paddedBuffer) + + result := append([]byte{0xF2, 0x81}, ciphertext...) + return base64.StdEncoding.EncodeToString(result) +} + +// GetSign 获取签名 +func (a *Argus) GetSign( + queryhash string, + data string, + timestamp int64, + aid int, + licenseID int, + platform int, + secDeviceID string, + sdkVersion string, + sdkVersionInt int, +) string { + paramsDict, _ := url.ParseQuery(queryhash) + + deviceID := "" + if val, ok := paramsDict["device_id"]; ok && len(val) > 0 { + deviceID = val[0] + } + + versionName := "" + if val, ok := paramsDict["version_name"]; ok && len(val) > 0 { + versionName = val[0] + } + + xargusBean := map[int]interface{}{ + 1: 0x20200929 << 1, + 2: 2, + 3: rand.Int63n(0x7FFFFFFF), + 4: fmt.Sprintf("%d", aid), + 5: deviceID, + 6: fmt.Sprintf("%d", licenseID), + 7: versionName, + 8: sdkVersion, + 9: sdkVersionInt, + 10: make([]byte, 8), + 11: platform, + 12: timestamp << 1, + 13: GetBodyHash(data), + 14: GetQueryHash(queryhash), + 15: map[int]interface{}{ + 1: 1, + 2: 1, + 3: 1, + 7: 3348294860, + }, + 16: secDeviceID, + 20: "none", + 21: 738, + 23: map[int]interface{}{ + 1: "NX551J", + 2: 8196, + 4: 2162219008, + }, + 25: 2, + } + + return a.Encrypt(xargusBean) +} + diff --git a/internal/algos/bytebuf.go b/internal/algos/bytebuf.go new file mode 100644 index 0000000..28452b8 --- /dev/null +++ b/internal/algos/bytebuf.go @@ -0,0 +1,53 @@ +package algos + +// ByteBuf 字节缓冲区 +type ByteBuf struct { + mem []byte + dataSize int + pos int +} + +// NewByteBuf 创建新的字节缓冲区 +func NewByteBuf(data []byte, size *int) *ByteBuf { + buf := &ByteBuf{ + pos: 0, + } + + if data != nil { + buf.mem = data + } + + if size != nil { + buf.dataSize = *size + } else if data != nil { + buf.dataSize = len(data) + } else { + panic("Either size or data must be provided") + } + + return buf +} + +// Data 获取数据 +func (b *ByteBuf) Data() []byte { + return b.mem +} + +// Size 获取大小 +func (b *ByteBuf) Size() int { + return b.dataSize +} + +// RemovePadding 移除填充 +func (b *ByteBuf) RemovePadding() []byte { + paddingSize := PKCS7DataLength(b.mem, b.dataSize, 16) + if paddingSize == 0 { + return b.mem[:b.dataSize] + } + b.dataSize = paddingSize + dst := make([]byte, b.dataSize) + copy(dst, b.mem[:b.dataSize]) + b.mem = dst + return b.mem +} + diff --git a/internal/algos/gorgon.go b/internal/algos/gorgon.go new file mode 100644 index 0000000..549d858 --- /dev/null +++ b/internal/algos/gorgon.go @@ -0,0 +1,137 @@ +package algos + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "strconv" +) + +// Gorgon Gorgon加密器 +type Gorgon struct { + unix int64 + params string + xSSStub string + cookies string +} + +// NewGorgon 创建新的Gorgon实例 +func NewGorgon(params string, unix int64, xSSStub string, cookies string) *Gorgon { + if xSSStub == "" { + xSSStub = "00000000000000000000000000000000" + } + return &Gorgon{ + unix: unix, + params: params, + xSSStub: xSSStub, + cookies: cookies, + } +} + +// hash MD5哈希 +func (g *Gorgon) hash(data string) string { + hasher := md5.New() + hasher.Write([]byte(data)) + return hex.EncodeToString(hasher.Sum(nil)) +} + +// getBaseString 获取基础字符串 +func (g *Gorgon) getBaseString() string { + baseStr := g.hash(g.params) + baseStr = baseStr + g.xSSStub + + if g.cookies != "" { + baseStr = baseStr + g.hash(g.cookies) + } else { + baseStr = baseStr + "00000000000000000000000000000000" + } + + return baseStr +} + +// rbitAlgorithm 位反转算法 +func (g *Gorgon) rbitAlgorithm(num int) int { + result := "" + tmpString := fmt.Sprintf("%08b", num) + + for i := 0; i < 8; i++ { + result = result + string(tmpString[7-i]) + } + + val, _ := strconv.ParseInt(result, 2, 64) + return int(val) +} + +// hexString 转十六进制字符串 +func (g *Gorgon) hexString(num int) string { + tmpString := fmt.Sprintf("%x", num) + if len(tmpString) < 2 { + tmpString = "0" + tmpString + } + return tmpString +} + +// reverse 反转 +func (g *Gorgon) reverse(num int) int { + tmpString := g.hexString(num) + val, _ := strconv.ParseInt(tmpString[1:]+tmpString[:1], 16, 64) + return int(val) +} + +// encrypt 加密 +func (g *Gorgon) encrypt(data string) map[string]string { + length := 0x14 + key := []int{ + 0xDF, 0x77, 0xB9, 0x40, 0xB9, 0x9B, 0x84, 0x83, + 0xD1, 0xB9, 0xCB, 0xD1, 0xF7, 0xC2, 0xB9, 0x85, + 0xC3, 0xD0, 0xFB, 0xC3, + } + + paramList := make([]int, 0) + for i := 0; i < 12; i += 4 { + temp := data[8*i : 8*(i+1)] + for j := 0; j < 4; j++ { + val, _ := strconv.ParseInt(temp[j*2:(j+1)*2], 16, 64) + paramList = append(paramList, int(val)) + } + } + + paramList = append(paramList, 0x0, 0x6, 0xB, 0x1C) + + H := int(g.unix) + paramList = append(paramList, (H&0xFF000000)>>24) + paramList = append(paramList, (H&0x00FF0000)>>16) + paramList = append(paramList, (H&0x0000FF00)>>8) + paramList = append(paramList, (H&0x000000FF)>>0) + + eorResultList := make([]int, len(paramList)) + for i, val := range paramList { + eorResultList[i] = val ^ key[i] + } + + for i := 0; i < length; i++ { + C := g.reverse(eorResultList[i]) + D := eorResultList[(i+1)%length] + E := C ^ D + F := g.rbitAlgorithm(E) + H := ((F ^ 0xFFFFFFFF) ^ length) & 0xFF + eorResultList[i] = H + } + + result := "" + for _, param := range eorResultList { + result += g.hexString(param) + } + + return map[string]string{ + "x-ss-req-ticket": strconv.FormatInt(g.unix*1000, 10), + "x-khronos": strconv.FormatInt(g.unix, 10), + "x-gorgon": fmt.Sprintf("0404b0d30000%s", result), + } +} + +// GetValue 获取加密值 +func (g *Gorgon) GetValue() map[string]string { + return g.encrypt(g.getBaseString()) +} + diff --git a/internal/algos/ladon.go b/internal/algos/ladon.go new file mode 100644 index 0000000..a60b631 --- /dev/null +++ b/internal/algos/ladon.go @@ -0,0 +1,121 @@ +package algos + +import ( + "crypto/md5" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "fmt" +) + +// validate 验证并限制在64位范围内 +func validate(num uint64) uint64 { + return num & 0xFFFFFFFFFFFFFFFF +} + +// ror 右循环移位 +func ror(value uint64, count int) uint64 { + nbits := 64 + count %= nbits + low := (value << (nbits - count)) + value = (value >> count) + value = value | low + return value +} + +// encryptLadonInput 加密Ladon输入 +func encryptLadonInput(hashTable []byte, inputData []byte) []byte { + data0 := binary.LittleEndian.Uint64(inputData[:8]) + data1 := binary.LittleEndian.Uint64(inputData[8:]) + + for i := 0; i < 0x22; i++ { + hash := binary.LittleEndian.Uint64(hashTable[i*8 : (i+1)*8]) + data1 = validate(hash ^ (data0 + ((data1 >> 8) | (data1 << (64 - 8))))) + data0 = validate(data1 ^ ((data0 >> 0x3D) | (data0 << (64 - 0x3D)))) + } + + outputData := make([]byte, 26) + binary.LittleEndian.PutUint64(outputData[:8], data0) + binary.LittleEndian.PutUint64(outputData[8:], data1) + + return outputData +} + +// encryptLadon 加密Ladon +func encryptLadon(md5Hex []byte, data []byte, size int) []byte { + hashTable := make([]byte, 272+16) + copy(hashTable[:32], md5Hex) + + temp := make([]uint64, 0) + for i := 0; i < 4; i++ { + temp = append(temp, binary.LittleEndian.Uint64(hashTable[i*8:(i+1)*8])) + } + + bufferB0 := temp[0] + bufferB8 := temp[1] + temp = temp[2:] + + for i := 0; i < 0x22; i++ { + x9 := bufferB0 + x8 := bufferB8 + x8 = validate(ror(x8, 8)) + x8 = validate(x8 + x9) + x8 = validate(x8 ^ uint64(i)) + temp = append(temp, x8) + x8 = validate(x8 ^ ror(x9, 61)) + binary.LittleEndian.PutUint64(hashTable[(i+1)*8:], x8) + bufferB0 = x8 + bufferB8 = temp[0] + temp = temp[1:] + } + + newSize := PaddingSize(size) + + input := make([]byte, newSize) + copy(input[:size], data) + PKCS7PadBuffer(input, size, newSize, 16) + + output := make([]byte, newSize) + for i := 0; i < newSize/16; i++ { + result := encryptLadonInput(hashTable, input[i*16:(i+1)*16]) + copy(output[i*16:(i+1)*16], result) + } + + return output +} + +// LadonEncrypt Ladon加密主函数 +func LadonEncrypt(khronos int64, lcID int, aid int, randomBytes []byte) string { + data := fmt.Sprintf("%d-%d-%d", khronos, lcID, aid) + + if randomBytes == nil { + randomBytes = make([]byte, 4) + rand.Read(randomBytes) + } + + keygen := append(randomBytes, []byte(fmt.Sprintf("%d", aid))...) + hasher := md5.New() + hasher.Write(keygen) + md5Hex := hex.EncodeToString(hasher.Sum(nil)) + + size := len(data) + newSize := PaddingSize(size) + + output := make([]byte, newSize+4) + copy(output[:4], randomBytes) + + encrypted := encryptLadon([]byte(md5Hex), []byte(data), size) + copy(output[4:], encrypted) + + return base64.StdEncoding.EncodeToString(output) +} + +// Ladon Ladon加密器 +type Ladon struct{} + +// Encrypt Ladon加密 +func (l *Ladon) Encrypt(xKhronos int64, lcID int, aid int) string { + return LadonEncrypt(xKhronos, lcID, aid, nil) +} + diff --git a/internal/algos/pkcs7.go b/internal/algos/pkcs7.go new file mode 100644 index 0000000..b32f1e3 --- /dev/null +++ b/internal/algos/pkcs7.go @@ -0,0 +1,78 @@ +package algos + +// PKCS7DataLength 获取PKCS7填充数据的实际长度 +func PKCS7DataLength(buffer []byte, bufferSize, modulus int) int { + if bufferSize%modulus != 0 || bufferSize < modulus { + return 0 + } + + paddingValue := buffer[bufferSize-1] + if paddingValue < 1 || int(paddingValue) > modulus { + return 0 + } + + if bufferSize < int(paddingValue)+1 { + return 0 + } + + count := 1 + bufferSize-- + + for i := count; i < int(paddingValue); i++ { + bufferSize-- + if buffer[bufferSize] != paddingValue { + return 0 + } + } + + return bufferSize +} + +// PKCS7PadBuffer 对缓冲区进行PKCS7填充 +func PKCS7PadBuffer(buffer []byte, dataLength, bufferSize, modulus int) int { + padByte := modulus - (dataLength % modulus) + if dataLength+padByte > bufferSize { + return -padByte + } + + for i := 0; i < padByte; i++ { + buffer[dataLength+i] = byte(padByte) + } + + return padByte +} + +// PaddingSize 计算填充后的大小 +func PaddingSize(size int) int { + mod := size % 16 + if mod > 0 { + return size + (16 - mod) + } + return size +} + +// RemovePKCS7Padding 移除PKCS7填充 +func RemovePKCS7Padding(data []byte) []byte { + length := len(data) + if length == 0 { + return data + } + + paddingLen := int(data[length-1]) + if paddingLen > length || paddingLen > 16 { + return data + } + + return data[:length-paddingLen] +} + +// AddPKCS7Padding 添加PKCS7填充 +func AddPKCS7Padding(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padText := make([]byte, padding) + for i := range padText { + padText[i] = byte(padding) + } + return append(data, padText...) +} + diff --git a/internal/algos/protobuf.go b/internal/algos/protobuf.go new file mode 100644 index 0000000..24d6540 --- /dev/null +++ b/internal/algos/protobuf.go @@ -0,0 +1,404 @@ +package algos + +import ( + "fmt" +) + +// ProtoFieldType 协议字段类型 +type ProtoFieldType int + +const ( + ProtoFieldTypeVARINT ProtoFieldType = 0 + ProtoFieldTypeINT64 ProtoFieldType = 1 + ProtoFieldTypeSTRING ProtoFieldType = 2 + ProtoFieldTypeGROUPSTART ProtoFieldType = 3 + ProtoFieldTypeGROUPEND ProtoFieldType = 4 + ProtoFieldTypeINT32 ProtoFieldType = 5 + ProtoFieldTypeERROR1 ProtoFieldType = 6 + ProtoFieldTypeERROR2 ProtoFieldType = 7 +) + +// ProtoField 协议字段 +type ProtoField struct { + Idx int + Type ProtoFieldType + Val interface{} +} + +// NewProtoField 创建新的协议字段 +func NewProtoField(idx int, fieldType ProtoFieldType, val interface{}) *ProtoField { + return &ProtoField{ + Idx: idx, + Type: fieldType, + Val: val, + } +} + +// IsAsciiStr 检查是否为ASCII字符串 +func (pf *ProtoField) IsAsciiStr() bool { + if data, ok := pf.Val.([]byte); ok { + for _, b := range data { + if b < 0x20 || b > 0x7E { + return false + } + } + return true + } + return false +} + +// ProtoReader 协议读取器 +type ProtoReader struct { + data []byte + pos int +} + +// NewProtoReader 创建新的协议读取器 +func NewProtoReader(data []byte) *ProtoReader { + return &ProtoReader{ + data: data, + pos: 0, + } +} + +// Seek 设置位置 +func (pr *ProtoReader) Seek(pos int) { + pr.pos = pos +} + +// IsRemain 检查是否还有剩余数据 +func (pr *ProtoReader) IsRemain(length int) bool { + return pr.pos+length <= len(pr.data) +} + +// Read0 读取一个字节 +func (pr *ProtoReader) Read0() byte { + if !pr.IsRemain(1) { + panic("buffer overflow") + } + ret := pr.data[pr.pos] + pr.pos++ + return ret & 0xFF +} + +// Read 读取指定长度的字节 +func (pr *ProtoReader) Read(length int) []byte { + if !pr.IsRemain(length) { + panic("buffer overflow") + } + ret := pr.data[pr.pos : pr.pos+length] + pr.pos += length + return ret +} + +// ReadInt32 读取32位整数 +func (pr *ProtoReader) ReadInt32() uint32 { + bytes := pr.Read(4) + return uint32(bytes[0]) | uint32(bytes[1])<<8 | uint32(bytes[2])<<16 | uint32(bytes[3])<<24 +} + +// ReadInt64 读取64位整数 +func (pr *ProtoReader) ReadInt64() uint64 { + bytes := pr.Read(8) + return uint64(bytes[0]) | uint64(bytes[1])<<8 | uint64(bytes[2])<<16 | uint64(bytes[3])<<24 | + uint64(bytes[4])<<32 | uint64(bytes[5])<<40 | uint64(bytes[6])<<48 | uint64(bytes[7])<<56 +} + +// ReadVarint 读取可变长度整数 +func (pr *ProtoReader) ReadVarint() uint64 { + vint := uint64(0) + n := 0 + for { + b := pr.Read0() + vint |= uint64(b&0x7F) << (7 * n) + if b < 0x80 { + break + } + n++ + } + return vint +} + +// ReadString 读取字符串 +func (pr *ProtoReader) ReadString() []byte { + length := pr.ReadVarint() + return pr.Read(int(length)) +} + +// ProtoWriter 协议写入器 +type ProtoWriter struct { + data []byte +} + +// NewProtoWriter 创建新的协议写入器 +func NewProtoWriter() *ProtoWriter { + return &ProtoWriter{ + data: make([]byte, 0), + } +} + +// Write0 写入一个字节 +func (pw *ProtoWriter) Write0(b byte) { + pw.data = append(pw.data, b&0xFF) +} + +// Write 写入字节数组 +func (pw *ProtoWriter) Write(bytes []byte) { + pw.data = append(pw.data, bytes...) +} + +// WriteInt32 写入32位整数 +func (pw *ProtoWriter) WriteInt32(val uint32) { + bs := []byte{ + byte(val & 0xFF), + byte((val >> 8) & 0xFF), + byte((val >> 16) & 0xFF), + byte((val >> 24) & 0xFF), + } + pw.Write(bs) +} + +// WriteInt64 写入64位整数 +func (pw *ProtoWriter) WriteInt64(val uint64) { + bs := []byte{ + byte(val & 0xFF), + byte((val >> 8) & 0xFF), + byte((val >> 16) & 0xFF), + byte((val >> 24) & 0xFF), + byte((val >> 32) & 0xFF), + byte((val >> 40) & 0xFF), + byte((val >> 48) & 0xFF), + byte((val >> 56) & 0xFF), + } + pw.Write(bs) +} + +// WriteVarint 写入可变长度整数 +func (pw *ProtoWriter) WriteVarint(vint uint64) { + vint = vint & 0xFFFFFFFF + for vint > 0x80 { + pw.Write0(byte((vint & 0x7F) | 0x80)) + vint >>= 7 + } + pw.Write0(byte(vint & 0x7F)) +} + +// WriteString 写入字符串 +func (pw *ProtoWriter) WriteString(bytes []byte) { + pw.WriteVarint(uint64(len(bytes))) + pw.Write(bytes) +} + +// ToBytes 转换为字节数组 +func (pw *ProtoWriter) ToBytes() []byte { + return pw.data +} + +// ProtoBuf 协议缓冲区 +type ProtoBuf struct { + fields []*ProtoField +} + +// NewProtoBuf 创建新的协议缓冲区 +func NewProtoBuf(data interface{}) *ProtoBuf { + pb := &ProtoBuf{ + fields: make([]*ProtoField, 0), + } + + if data != nil { + switch v := data.(type) { + case []byte: + if len(v) > 0 { + pb.parseBuf(v) + } + case map[int]interface{}: + if len(v) > 0 { + pb.parseDict(v) + } + default: + panic(fmt.Sprintf("unsupported type(%T) to protobuf", data)) + } + } + + return pb +} + +// parseBuf 解析字节数组 +func (pb *ProtoBuf) parseBuf(bytes []byte) { + reader := NewProtoReader(bytes) + for reader.IsRemain(1) { + key := reader.ReadVarint() + fieldType := ProtoFieldType(key & 0x7) + fieldIdx := int(key >> 3) + + if fieldIdx == 0 { + break + } + + switch fieldType { + case ProtoFieldTypeINT32: + pb.Put(NewProtoField(fieldIdx, fieldType, reader.ReadInt32())) + case ProtoFieldTypeINT64: + pb.Put(NewProtoField(fieldIdx, fieldType, reader.ReadInt64())) + case ProtoFieldTypeVARINT: + pb.Put(NewProtoField(fieldIdx, fieldType, reader.ReadVarint())) + case ProtoFieldTypeSTRING: + pb.Put(NewProtoField(fieldIdx, fieldType, reader.ReadString())) + default: + panic(fmt.Sprintf("parse protobuf error, unexpected field type: %d", fieldType)) + } + } +} + +// ToBuf 转换为字节数组 +func (pb *ProtoBuf) ToBuf() []byte { + writer := NewProtoWriter() + for _, field := range pb.fields { + key := uint64((field.Idx << 3) | int(field.Type&7)) + writer.WriteVarint(key) + + switch field.Type { + case ProtoFieldTypeINT32: + writer.WriteInt32(field.Val.(uint32)) + case ProtoFieldTypeINT64: + writer.WriteInt64(field.Val.(uint64)) + case ProtoFieldTypeVARINT: + writer.WriteVarint(field.Val.(uint64)) + case ProtoFieldTypeSTRING: + writer.WriteString(field.Val.([]byte)) + default: + panic(fmt.Sprintf("encode to protobuf error, unexpected field type: %d", field.Type)) + } + } + return writer.ToBytes() +} + +// Get 获取字段 +func (pb *ProtoBuf) Get(idx int) *ProtoField { + for _, field := range pb.fields { + if field.Idx == idx { + return field + } + } + return nil +} + +// GetList 获取字段列表 +func (pb *ProtoBuf) GetList(idx int) []*ProtoField { + result := make([]*ProtoField, 0) + for _, field := range pb.fields { + if field.Idx == idx { + result = append(result, field) + } + } + return result +} + +// GetInt 获取整数 +func (pb *ProtoBuf) GetInt(idx int) uint64 { + pf := pb.Get(idx) + if pf == nil { + return 0 + } + + switch pf.Type { + case ProtoFieldTypeINT32: + return uint64(pf.Val.(uint32)) + case ProtoFieldTypeINT64: + return pf.Val.(uint64) + case ProtoFieldTypeVARINT: + return pf.Val.(uint64) + default: + panic(fmt.Sprintf("getInt(%d) -> %d", idx, pf.Type)) + } +} + +// GetBytes 获取字节数组 +func (pb *ProtoBuf) GetBytes(idx int) []byte { + pf := pb.Get(idx) + if pf == nil { + return nil + } + + if pf.Type == ProtoFieldTypeSTRING { + return pf.Val.([]byte) + } + + panic(fmt.Sprintf("getBytes(%d) -> %d", idx, pf.Type)) +} + +// GetUTF8 获取UTF8字符串 +func (pb *ProtoBuf) GetUTF8(idx int) string { + bs := pb.GetBytes(idx) + if bs == nil { + return "" + } + return string(bs) +} + +// GetProtoBuf 获取嵌套的ProtoBuf +func (pb *ProtoBuf) GetProtoBuf(idx int) *ProtoBuf { + bs := pb.GetBytes(idx) + if bs == nil { + return nil + } + return NewProtoBuf(bs) +} + +// Put 添加字段 +func (pb *ProtoBuf) Put(field *ProtoField) { + pb.fields = append(pb.fields, field) +} + +// PutInt32 添加32位整数 +func (pb *ProtoBuf) PutInt32(idx int, val uint32) { + pb.Put(NewProtoField(idx, ProtoFieldTypeINT32, val)) +} + +// PutInt64 添加64位整数 +func (pb *ProtoBuf) PutInt64(idx int, val uint64) { + pb.Put(NewProtoField(idx, ProtoFieldTypeINT64, val)) +} + +// PutVarint 添加可变长度整数 +func (pb *ProtoBuf) PutVarint(idx int, val uint64) { + pb.Put(NewProtoField(idx, ProtoFieldTypeVARINT, val)) +} + +// PutBytes 添加字节数组 +func (pb *ProtoBuf) PutBytes(idx int, data []byte) { + pb.Put(NewProtoField(idx, ProtoFieldTypeSTRING, data)) +} + +// PutUTF8 添加UTF8字符串 +func (pb *ProtoBuf) PutUTF8(idx int, data string) { + pb.Put(NewProtoField(idx, ProtoFieldTypeSTRING, []byte(data))) +} + +// PutProtoBuf 添加嵌套的ProtoBuf +func (pb *ProtoBuf) PutProtoBuf(idx int, data *ProtoBuf) { + pb.Put(NewProtoField(idx, ProtoFieldTypeSTRING, data.ToBuf())) +} + +// parseDict 解析字典 +func (pb *ProtoBuf) parseDict(data map[int]interface{}) { + for k, v := range data { + switch val := v.(type) { + case int: + pb.PutVarint(k, uint64(val)) + case uint64: + pb.PutVarint(k, val) + case int64: + pb.PutVarint(k, uint64(val)) + case string: + pb.PutUTF8(k, val) + case []byte: + pb.PutBytes(k, val) + case map[int]interface{}: + pb.PutProtoBuf(k, NewProtoBuf(val)) + default: + panic(fmt.Sprintf("unsupported type(%T) to protobuf", v)) + } + } +} + diff --git a/internal/algos/signer.go b/internal/algos/signer.go new file mode 100644 index 0000000..53df54d --- /dev/null +++ b/internal/algos/signer.go @@ -0,0 +1,219 @@ +package algos + +import ( + "fmt" + "net/url" + "regexp" + "strconv" + "sync" + "time" +) + +// getTimestamp 获取时间戳(辅助函数) +func getTimestamp(unix *int64) int64 { + if unix == nil { + return time.Now().Unix() + } + return *unix +} + +// Sign2 签名方法2(Gorgon 或 8404) +func Sign2( + params string, + xSSStub string, + secDeviceID string, + cookie string, + aid int, + licenseID int, + sdkVersionStr string, + sdkVersion int, + platform int, + unix *int64, +) map[string]string { + timestamp := getTimestamp(unix) + + // 根据当前算法选择生成签名 + currentAlgo := GlobalAlgorithmSwitcher.GetCurrentAlgorithm() + if currentAlgo == "8404" { + algo := NewAlgo8404(params, timestamp, xSSStub) + return algo.GetValue() + } + + // 默认使用 Gorgon + gorgon := NewGorgon(params, timestamp, xSSStub, cookie) + return gorgon.GetValue() +} + +// Sign4 签名方法4(Gorgon + Ladon + Argus) +func Sign4( + params string, + xSSStub string, + secDeviceID string, + cookie string, + aid int, + licenseID int, + sdkVersionStr string, + sdkVersion int, + platform int, + unix *int64, +) map[string]string { + timestamp := getTimestamp(unix) + + // 根据当前算法选择生成基础签名 + var result map[string]string + currentAlgo := GlobalAlgorithmSwitcher.GetCurrentAlgorithm() + if currentAlgo == "8404" { + algo := NewAlgo8404(params, timestamp, xSSStub) + result = algo.GetValue() + } else { + // 默认使用 Gorgon + gorgon := NewGorgon(params, timestamp, xSSStub, cookie) + result = gorgon.GetValue() + } + + // 添加 Ladon 和 Argus 签名 + ladon := &Ladon{} + result["x-ladon"] = ladon.Encrypt(timestamp, licenseID, aid) + + argus := &Argus{} + result["x-argus"] = argus.GetSign( + params, + xSSStub, + timestamp, + aid, + licenseID, + platform, + secDeviceID, + sdkVersionStr, + sdkVersion, + ) + + return result +} + +// SignatureInterface 签名接口 +type SignatureInterface interface { + Sign(params, xSSStub, secDeviceID, cookie string, aid, licenseID int, sdkVersionStr string, sdkVersion, platform int, unix *int64) map[string]string +} + +// SignatureSwitcher 签名方法切换器 +type SignatureSwitcher struct { + mu sync.RWMutex + currentMethod string + methods map[string]SignatureInterface +} + +// NewSignatureSwitcher 创建签名切换器 +func NewSignatureSwitcher() *SignatureSwitcher { + switcher := &SignatureSwitcher{ + methods: make(map[string]SignatureInterface), + } + + // 注册所有签名方法 + switcher.methods["sign2"] = &Sign2Method{} + switcher.methods["sign4"] = &Sign4Method{} + + // 设置默认签名方法为sign2 + switcher.currentMethod = "sign2" + + return switcher +} + +// SwitchSignature 切换签名方法 +func (s *SignatureSwitcher) SwitchSignature(method string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.methods[method]; !exists { + return fmt.Errorf("不支持的签名方法: %s", method) + } + + s.currentMethod = method + return nil +} + +// GetCurrentMethod 获取当前签名方法 +func (s *SignatureSwitcher) GetCurrentMethod() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.currentMethod +} + +// Sign 使用当前签名方法进行签名 +func (s *SignatureSwitcher) Sign(urlStr string, headers map[string]string) map[string]string { + return SignFromURL(s.GetCurrentMethod(), urlStr, headers) +} + +// SignFromParams 使用当前签名方法进行签名 +func (s *SignatureSwitcher) SignFromParams(params, xSSStub, secDeviceID, cookie string, aid, licenseID int, sdkVersionStr string, sdkVersion, platform int, unix *int64) map[string]string { + s.mu.RLock() + method := s.methods[s.currentMethod] + s.mu.RUnlock() + + return method.Sign(params, xSSStub, secDeviceID, cookie, aid, licenseID, sdkVersionStr, sdkVersion, platform, unix) +} + +// Sign2Method Sign2签名方法实现 +type Sign2Method struct{} + +func (s *Sign2Method) Sign(params, xSSStub, secDeviceID, cookie string, aid, licenseID int, sdkVersionStr string, sdkVersion, platform int, unix *int64) map[string]string { + return Sign2(params, xSSStub, secDeviceID, cookie, aid, licenseID, sdkVersionStr, sdkVersion, platform, unix) +} + +// Sign4Method Sign4签名方法实现 +type Sign4Method struct{} + +func (s *Sign4Method) Sign(params, xSSStub, secDeviceID, cookie string, aid, licenseID int, sdkVersionStr string, sdkVersion, platform int, unix *int64) map[string]string { + return Sign4(params, xSSStub, secDeviceID, cookie, aid, licenseID, sdkVersionStr, sdkVersion, platform, unix) +} + +// GlobalSignatureSwitcher 全局签名切换器实例 +var GlobalSignatureSwitcher = NewSignatureSwitcher() + +// SignFromURL 从 URL 和 headers 中提取参数并生成签名 +func SignFromURL(method, urlStr string, headers map[string]string) map[string]string { + // 解析 URL + u, err := url.Parse(urlStr) + if err != nil { + return make(map[string]string) + } + + // 提取查询参数 + params := u.RawQuery + xSSStub := headers["x-ss-stub"] + cookie := headers["Cookie"] + + // 从 URL 中提取 device_id 和 aid + deviceIDRegex := regexp.MustCompile(`device_id=(\d+)`) + aidRegex := regexp.MustCompile(`aid=(\d+)`) + + deviceIDMatch := deviceIDRegex.FindStringSubmatch(urlStr) + aidMatch := aidRegex.FindStringSubmatch(urlStr) + + secDeviceID := "" + aid := 1967 + if len(deviceIDMatch) > 1 { + secDeviceID = deviceIDMatch[1] + } + if len(aidMatch) > 1 { + aid, _ = strconv.Atoi(aidMatch[1]) + } + + // 使用固定参数 + licenseID := 1611921764 + sdkVersionStr := "v04.06.01-ml-android" + sdkVersion := 135004672 + platform := 0 + + // 根据方法调用对应的签名函数 + if method == "sign4" { + return Sign4(params, xSSStub, secDeviceID, cookie, aid, licenseID, sdkVersionStr, sdkVersion, platform, nil) + } + return Sign2(params, xSSStub, secDeviceID, cookie, aid, licenseID, sdkVersionStr, sdkVersion, platform, nil) +} + +// Sign2Wrapper 硬编码使用 Sign2(从 URL 和 headers 生成签名) +func Sign2Wrapper(urlStr string, headers map[string]string) map[string]string { + return SignFromURL("sign2", urlStr, headers) +} + diff --git a/internal/algos/simon.go b/internal/algos/simon.go new file mode 100644 index 0000000..432ecc4 --- /dev/null +++ b/internal/algos/simon.go @@ -0,0 +1,85 @@ +package algos + +// getBit 获取指定位的值 +func getBit(val uint64, pos int) uint64 { + if val&(1<> (64 - n)) + return r & 0xFFFFFFFFFFFFFFFF +} + +// rotateRight64 64位右循环移位 +func rotateRight64(v uint64, n int) uint64 { + r := (v << (64 - n)) | (v >> n) + return r & 0xFFFFFFFFFFFFFFFF +} + +// keyExpansion 密钥扩展 +func keyExpansion(key []uint64) []uint64 { + expandedKey := make([]uint64, 72) + copy(expandedKey, key[:4]) + + var tmp uint64 + for i := 4; i < 72; i++ { + tmp = rotateRight64(expandedKey[i-1], 3) + tmp = tmp ^ expandedKey[i-3] + tmp = tmp ^ rotateRight64(tmp, 1) + expandedKey[i] = (^expandedKey[i-4]) ^ tmp ^ getBit(0x3DC94C3A046D678B, (i-4)%62) ^ 3 + } + return expandedKey +} + +// SimonEnc Simon加密 +func SimonEnc(pt []uint64, k []uint64, c int) []uint64 { + key := make([]uint64, 72) + copy(key[:4], k[:4]) + key = keyExpansion(key) + + xi := pt[0] + xi1 := pt[1] + + var tmp, f uint64 + for i := 0; i < 72; i++ { + tmp = xi1 + if c == 1 { + f = rotateLeft64(xi1, 1) + } else { + f = rotateLeft64(xi1, 1) & rotateLeft64(xi1, 8) + } + xi1 = xi ^ f ^ rotateLeft64(xi1, 2) ^ key[i] + xi = tmp + } + + return []uint64{xi, xi1} +} + +// SimonDec Simon解密 +func SimonDec(ct []uint64, k []uint64, c int) []uint64 { + key := make([]uint64, 72) + copy(key[:4], k[:4]) + key = keyExpansion(key) + + xi := ct[0] + xi1 := ct[1] + + var tmp, f uint64 + for i := 71; i >= 0; i-- { + tmp = xi + if c == 1 { + f = rotateLeft64(xi, 1) + } else { + f = rotateLeft64(xi, 1) & rotateLeft64(xi, 8) + } + xi = xi1 ^ f ^ rotateLeft64(xi, 2) ^ key[i] + xi1 = tmp + } + + return []uint64{xi, xi1} +} + diff --git a/internal/algos/sm3.go b/internal/algos/sm3.go new file mode 100644 index 0000000..42581e7 --- /dev/null +++ b/internal/algos/sm3.go @@ -0,0 +1,154 @@ +package algos + +// SM3 SM3哈希算法实现 +type SM3 struct { + IV []uint32 + TJ []uint32 +} + +// NewSM3 创建SM3实例 +func NewSM3() *SM3 { + return &SM3{ + IV: []uint32{1937774191, 1226093241, 388252375, 3666478592, 2842636476, 372324522, 3817729613, 2969243214}, + TJ: []uint32{ + 2043430169, 2043430169, 2043430169, 2043430169, 2043430169, 2043430169, 2043430169, 2043430169, + 2043430169, 2043430169, 2043430169, 2043430169, 2043430169, 2043430169, 2043430169, 2043430169, + 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, + 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, + 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, + 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, + 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, + 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, 2055708042, + }, + } +} + +// rotateLeft 循环左移 +func (sm3 *SM3) rotateLeft(a uint32, k int) uint32 { + k = k % 32 + return ((a << uint(k)) & 0xFFFFFFFF) | ((a & 0xFFFFFFFF) >> uint(32-k)) +} + +// FFJ FF函数 +func (sm3 *SM3) FFJ(X, Y, Z uint32, j int) uint32 { + if j >= 0 && j < 16 { + return X ^ Y ^ Z + } else if j >= 16 && j < 64 { + return (X & Y) | (X & Z) | (Y & Z) + } + return 0 +} + +// GGJ GG函数 +func (sm3 *SM3) GGJ(X, Y, Z uint32, j int) uint32 { + if j >= 0 && j < 16 { + return X ^ Y ^ Z + } else if j >= 16 && j < 64 { + return (X & Y) | ((^X) & Z) + } + return 0 +} + +// P0 压缩函数P0 +func (sm3 *SM3) P0(X uint32) uint32 { + return X ^ sm3.rotateLeft(X, 9) ^ sm3.rotateLeft(X, 17) +} + +// P1 压缩函数P1 +func (sm3 *SM3) P1(X uint32) uint32 { + return X ^ sm3.rotateLeft(X, 15) ^ sm3.rotateLeft(X, 23) +} + +// CF 压缩函数 +func (sm3 *SM3) CF(Vi []uint32, Bi []byte) []uint32 { + W := make([]uint32, 68) + for i := 0; i < 16; i++ { + weight := uint32(0x1000000) + data := uint32(0) + for k := i * 4; k < (i+1)*4; k++ { + data = data + uint32(Bi[k])*weight + weight = weight / 0x100 + } + W[i] = data + } + + for j := 16; j < 68; j++ { + W[j] = sm3.P1(W[j-16]^W[j-9]^sm3.rotateLeft(W[j-3], 15)) ^ sm3.rotateLeft(W[j-13], 7) ^ W[j-6] + } + + W1 := make([]uint32, 64) + for j := 0; j < 64; j++ { + W1[j] = W[j] ^ W[j+4] + } + + A, B, C, D, E, F, G, H := Vi[0], Vi[1], Vi[2], Vi[3], Vi[4], Vi[5], Vi[6], Vi[7] + + for j := 0; j < 64; j++ { + SS1 := sm3.rotateLeft((sm3.rotateLeft(A, 12)+E+sm3.rotateLeft(sm3.TJ[j], j%32))&0xFFFFFFFF, 7) + SS2 := SS1 ^ sm3.rotateLeft(A, 12) + TT1 := (sm3.FFJ(A, B, C, j) + D + SS2 + W1[j]) & 0xFFFFFFFF + TT2 := (sm3.GGJ(E, F, G, j) + H + SS1 + W[j]) & 0xFFFFFFFF + D = C + C = sm3.rotateLeft(B, 9) + B = A + A = TT1 + H = G + G = sm3.rotateLeft(F, 19) + F = E + E = sm3.P0(TT2) + } + + V := make([]uint32, 8) + V[0] = A ^ Vi[0] + V[1] = B ^ Vi[1] + V[2] = C ^ Vi[2] + V[3] = D ^ Vi[3] + V[4] = E ^ Vi[4] + V[5] = F ^ Vi[5] + V[6] = G ^ Vi[6] + V[7] = H ^ Vi[7] + + return V +} + +// Hash 计算SM3哈希 +func (sm3 *SM3) Hash(msg []byte) []byte { + msgLen := len(msg) + reserve1 := msgLen % 64 + paddingLen := 0 + if reserve1 > 55 { + paddingLen = 64 - reserve1 + 64 + } else { + paddingLen = 64 - reserve1 + } + + msgPadding := make([]byte, msgLen+paddingLen) + copy(msgPadding, msg) + msgPadding[msgLen] = 0x80 + + lengthBits := uint64(msgLen) * 8 + for i := 0; i < 8; i++ { + msgPadding[len(msgPadding)-1-i] = byte(lengthBits >> uint(8*i)) + } + + groupCount := len(msgPadding) / 64 + + V := make([]uint32, len(sm3.IV)) + copy(V, sm3.IV) + + for i := 0; i < groupCount; i++ { + B := msgPadding[i*64 : (i+1)*64] + V = sm3.CF(V, B) + } + + result := make([]byte, 32) + for i := 0; i < 8; i++ { + result[i*4] = byte((V[i] >> 24) & 0xFF) + result[i*4+1] = byte((V[i] >> 16) & 0xFF) + result[i*4+2] = byte((V[i] >> 8) & 0xFF) + result[i*4+3] = byte(V[i] & 0xFF) + } + + return result +} + diff --git a/internal/algos/switcher.go b/internal/algos/switcher.go new file mode 100644 index 0000000..aed0c8f --- /dev/null +++ b/internal/algos/switcher.go @@ -0,0 +1,159 @@ +package algos + +import ( + "fmt" + "strings" + "sync" + "time" +) + +// AlgorithmInterface 算法接口 +type AlgorithmInterface interface { + GetAlgorithmName() string + GenerateHeaders(url string, headers map[string]string) (map[string]string, error) +} + +// AlgorithmSwitcher 算法切换器 +type AlgorithmSwitcher struct { + mu sync.RWMutex + currentAlgorithm string + algorithms map[string]AlgorithmInterface +} + +// NewAlgorithmSwitcher 创建算法切换器 +func NewAlgorithmSwitcher() *AlgorithmSwitcher { + switcher := &AlgorithmSwitcher{ + algorithms: make(map[string]AlgorithmInterface), + } + + // 注册所有算法 + switcher.algorithms["gorgon"] = &GorgonAlgorithmAdapter{} + switcher.algorithms["8404"] = &Algo8404Adapter{} + + // 设置默认算法为gorgon + switcher.currentAlgorithm = "gorgon" + + return switcher +} + +// SwitchAlgorithm 切换算法 +func (s *AlgorithmSwitcher) SwitchAlgorithm(algorithmType string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.algorithms[algorithmType]; !exists { + return fmt.Errorf("不支持的算法类型: %s", algorithmType) + } + + s.currentAlgorithm = algorithmType + return nil +} + +// GetCurrentAlgorithm 获取当前算法名称 +func (s *AlgorithmSwitcher) GetCurrentAlgorithm() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.currentAlgorithm +} + +// GetAvailableAlgorithms 获取可用算法列表 +func (s *AlgorithmSwitcher) GetAvailableAlgorithms() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + algorithms := make([]string, 0, len(s.algorithms)) + for name := range s.algorithms { + algorithms = append(algorithms, name) + } + return algorithms +} + +// GetCurrentAlgorithmInstance 获取当前算法实例 +func (s *AlgorithmSwitcher) GetCurrentAlgorithmInstance() AlgorithmInterface { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.algorithms[s.currentAlgorithm] +} + +// GetAlgorithmInstance 获取指定算法实例 +func (s *AlgorithmSwitcher) GetAlgorithmInstance(algorithmType string) (AlgorithmInterface, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + algorithm, exists := s.algorithms[algorithmType] + if !exists { + return nil, fmt.Errorf("不支持的算法类型: %s", algorithmType) + } + + return algorithm, nil +} + +// GorgonAlgorithmAdapter Gorgon算法适配器(兼容旧版) +type GorgonAlgorithmAdapter struct{} + +func (g *GorgonAlgorithmAdapter) GetAlgorithmName() string { + return "Gorgon算法" +} + +func (g *GorgonAlgorithmAdapter) GenerateHeaders(url string, headers map[string]string) (map[string]string, error) { + // 使用Gorgon算法 + parts := strings.Split(url, "?") + if len(parts) < 2 { + return headers, fmt.Errorf("URL格式错误") + } + + xSSStub := headers["x-ss-stub"] + cookie := headers["Cookie"] + if cookie == "" { + cookie = headers["cookie"] + } + + gorgon := NewGorgon(parts[1], time.Now().Unix(), xSSStub, cookie) + result := gorgon.GetValue() + + finalHeaders := make(map[string]string) + for k, v := range headers { + finalHeaders[k] = v + } + + for k, v := range result { + finalHeaders[k] = v + } + + return finalHeaders, nil +} + +// Algo8404Adapter 8404算法适配器 +type Algo8404Adapter struct{} + +func (a *Algo8404Adapter) GetAlgorithmName() string { + return "8404算法" +} + +func (a *Algo8404Adapter) GenerateHeaders(url string, headers map[string]string) (map[string]string, error) { + // 使用8404算法 + parts := strings.Split(url, "?") + if len(parts) < 2 { + return headers, fmt.Errorf("URL格式错误") + } + + xSSStub := headers["x-ss-stub"] + algo := NewAlgo8404(parts[1], time.Now().Unix(), xSSStub) + result := algo.GetValue() + + finalHeaders := make(map[string]string) + for k, v := range headers { + finalHeaders[k] = v + } + + for k, v := range result { + finalHeaders[k] = v + } + + return finalHeaders, nil +} + +// GlobalAlgorithmSwitcher 全局算法切换器实例 +var GlobalAlgorithmSwitcher = NewAlgorithmSwitcher() + diff --git a/internal/common/http.go b/internal/common/http.go new file mode 100644 index 0000000..ac35ebb --- /dev/null +++ b/internal/common/http.go @@ -0,0 +1,192 @@ +package common + +import ( + "bytes" + "compress/zlib" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// HTTPClient HTTP客户端 +type HTTPClient struct { + client *http.Client + logger *Logger +} + +// NewHTTPClient 创建新的HTTP客户端 +func NewHTTPClient() *HTTPClient { + return &HTTPClient{ + client: &http.Client{ + Timeout: 30 * time.Second, + }, + logger: NewLogger("http_utils", "INFO", ""), + } +} + +// ClientResponse 客户端响应 +type ClientResponse struct { + Status int + Content []byte + Headers http.Header + Cookies []*http.Cookie + Text string +} + +// GetHeader 获取响应头(兼容方法) +func (r *ClientResponse) GetHeader(key string) string { + return r.Headers.Get(key) +} + +// JSON 解析JSON响应 +func (r *ClientResponse) JSON() (map[string]interface{}, error) { + var result map[string]interface{} + err := json.Unmarshal(r.Content, &result) + return result, err +} + +// Request HTTP请求 +func (h *HTTPClient) Request(url string, options map[string]interface{}) (*ClientResponse, error) { + // 获取请求方法 + method := "GET" + if m, ok := options["method"].(string); ok { + method = strings.ToUpper(m) + } + + // 获取请求头 + headers := make(map[string]string) + if h, ok := options["headers"].(map[string]string); ok { + headers = h + } + + // 设置默认User-Agent + if _, ok := headers["User-Agent"]; !ok { + headers["User-Agent"] = "okhttp/4.12.0" + } + + // 获取请求体 + var body io.Reader + if method == "POST" || method == "PUT" { + if b, ok := options["body"].(string); ok { + body = strings.NewReader(b) + } else if b, ok := options["body"].([]byte); ok { + body = bytes.NewReader(b) + } else if data, ok := options["data"].(string); ok { + body = strings.NewReader(data) + } else if data, ok := options["data"].([]byte); ok { + body = bytes.NewReader(data) + } + } + + // 创建请求 + req, err := http.NewRequest(method, url, body) + if err != nil { + h.logger.Error(fmt.Sprintf("Failed to create request: %v", err)) + return nil, err + } + + // 设置请求头 + for k, v := range headers { + req.Header.Set(k, v) + } + + // 打印请求信息 + // h.logger.Info(fmt.Sprintf("=== HTTP 请求 ===")) + // h.logger.Info(fmt.Sprintf("方法: %s", method)) + // h.logger.Info(fmt.Sprintf("URL: %s", url)) + // h.logger.Info(fmt.Sprintf("请求头:")) + // for k, v := range req.Header { + // h.logger.Info(fmt.Sprintf(" %s: %s", k, strings.Join(v, ", "))) + // } + + // 检查是否跳过SSL验证 + skipSSL := os.Getenv("FQWEB_SKIP_SSL_VERIFY") + if skipSSL != "" && h.client.Transport == nil { + // 配置跳过SSL验证 + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + h.client.Transport = transport + h.logger.Warning("已启用SSL验证跳过模式") + } + + // 发送请求 + resp, err := h.client.Do(req) + if err != nil { + h.logger.Error(fmt.Sprintf("HTTP Request error: %v", err)) + return nil, err + } + defer resp.Body.Close() + + // 读取响应体 + content, err := io.ReadAll(resp.Body) + if err != nil { + h.logger.Error(fmt.Sprintf("Failed to read response: %v", err)) + return nil, err + } + + // 处理zlib压缩 + if len(content) > 2 && (content[0] == 0x78 && (content[1] == 0x9C || content[1] == 0x01)) { + reader, err := zlib.NewReader(bytes.NewReader(content)) + if err == nil { + decompressed, err := io.ReadAll(reader) + reader.Close() + if err == nil { + content = decompressed + } + } + } + + + response := &ClientResponse{ + Status: resp.StatusCode, + Content: content, + Headers: resp.Header, + Cookies: resp.Cookies(), + Text: string(content), + } + + return response, nil +} + +// Get GET请求 +func (h *HTTPClient) Get(url string, headers map[string]string) (*ClientResponse, error) { + options := map[string]interface{}{ + "method": "GET", + "headers": headers, + } + return h.Request(url, options) +} + +// Post POST请求 +func (h *HTTPClient) Post(url string, data interface{}, headers map[string]string) (*ClientResponse, error) { + options := map[string]interface{}{ + "method": "POST", + "headers": headers, + "body": data, + } + return h.Request(url, options) +} + +// ConvertDictToFormString 将字典转换为表单字符串 +func ConvertDictToFormString(dict map[string]string) string { + parts := make([]string, 0, len(dict)) + for k, v := range dict { + parts = append(parts, fmt.Sprintf("%s=%s", k, v)) + } + return strings.Join(parts, "&") +} + +// DefaultHTTPClient 默认HTTP客户端 +var DefaultHTTPClient = NewHTTPClient() + +// Request 全局请求函数 +func Request(url string, options map[string]interface{}) (*ClientResponse, error) { + return DefaultHTTPClient.Request(url, options) +} + diff --git a/internal/common/logger.go b/internal/common/logger.go new file mode 100644 index 0000000..b0a09ce --- /dev/null +++ b/internal/common/logger.go @@ -0,0 +1,123 @@ +package common + +import ( + "fmt" + "log" + "strings" + "time" +) + +// LogLevel 日志级别 +type LogLevel int + +const ( + DEBUG LogLevel = iota + INFO + WARNING + ERROR + CRITICAL +) + +// Logger 日志器 +type Logger struct { + moduleName string + outputLevel LogLevel +} + +// NewLogger 创建新的日志器 +func NewLogger(moduleName string, outputLevel string, filename string) *Logger { + logger := &Logger{ + moduleName: moduleName, + } + + // 设置日志级别 + switch strings.ToUpper(outputLevel) { + case "DEBUG": + logger.outputLevel = DEBUG + case "INFO": + logger.outputLevel = INFO + case "WARNING": + logger.outputLevel = WARNING + case "ERROR": + logger.outputLevel = ERROR + case "CRITICAL": + logger.outputLevel = CRITICAL + default: + logger.outputLevel = INFO + } + + return logger +} + +// Debug 调试日志 +func (l *Logger) Debug(message string) { + if l.outputLevel <= DEBUG { + l.log("DEBUG", message) + } +} + +// Info 信息日志 +func (l *Logger) Info(message string) { + if l.outputLevel <= INFO { + l.log("INFO", message) + } +} + +// Warning 警告日志 +func (l *Logger) Warning(message string) { + if l.outputLevel <= WARNING { + l.log("WARNING", message) + } +} + +// Error 错误日志 +func (l *Logger) Error(message string) { + if l.outputLevel <= ERROR { + l.log("ERROR", message) + } +} + +// Critical 严重错误日志 +func (l *Logger) Critical(message string) { + if l.outputLevel <= CRITICAL { + l.log("CRITICAL", message) + } +} + +// log 内部日志方法 +func (l *Logger) log(level string, message string) { + timestamp := time.Now().Format("2006-01-02 15:04:05") + + logMessage := fmt.Sprintf("%s|[%s/%s]|%s", + timestamp, l.moduleName, level, message) + + // 控制台输出 + fmt.Println(logMessage) +} + +// Log 全局日志函数 +func Log(moduleName string) *Logger { + return NewLogger(moduleName, "INFO", "") +} + +// HighlightError 高亮错误 +func HighlightError(err interface{}) string { + return fmt.Sprintf("%v", err) +} + +// 全局日志实例 +var ( + defaultLogger = NewLogger("fanqie", "INFO", "") +) + +// LogPrint 打印日志 +func LogPrint(args ...interface{}) { + message := fmt.Sprint(args...) + defaultLogger.Info(message) +} + +func init() { + // 初始化日志系统 + log.SetFlags(log.LstdFlags | log.Lshortfile) +} + diff --git a/internal/common/utils.go b/internal/common/utils.go new file mode 100644 index 0000000..b90abba --- /dev/null +++ b/internal/common/utils.go @@ -0,0 +1,32 @@ +package common + +import ( + "crypto/md5" + "encoding/base64" + "encoding/hex" + "fmt" +) + +// Bytes2DataURI 将字节转换为 Data URI +func Bytes2DataURI(dataBytes []byte, mimeType string) string { + encoded := base64.StdEncoding.EncodeToString(dataBytes) + return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded) +} + +// CreateMD5 创建MD5哈希 +func CreateMD5(s interface{}) string { + var data []byte + switch v := s.(type) { + case string: + data = []byte(v) + case []byte: + data = v + default: + data = []byte(fmt.Sprintf("%v", v)) + } + + hasher := md5.New() + hasher.Write(data) + return hex.EncodeToString(hasher.Sum(nil)) +} + diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4a3ab31 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,234 @@ +package config + +import ( + "fmt" + "log" + "sync" + + "github.com/spf13/viper" +) + +// Config 应用配置结构 +type Config struct { + Server ServerConfig `mapstructure:"server"` + Fanqie FanqieConfig `mapstructure:"fanqie"` + Cache CacheConfig `mapstructure:"cache"` + DeviceKeyPool DeviceKeyPoolConfig `mapstructure:"device_key_pool"` +} + +// ServerConfig 服务器配置 +type ServerConfig struct { + Port string `mapstructure:"port"` + Host string `mapstructure:"host"` + AccessKey string `mapstructure:"access_key"` +} + +// FanqieConfig 番茄小说配置 +type FanqieConfig struct { + Algorithm string `mapstructure:"algorithm"` + Signature string `mapstructure:"signature"` // 签名方法: sign2 / sign4 + Content ContentConfig `mapstructure:"content"` + Manga MangaConfig `mapstructure:"manga"` +} + +// ContentConfig 内容处理配置 +type ContentConfig struct { + RemoveImages bool `mapstructure:"remove_images"` + RemoveImageDesc bool `mapstructure:"remove_image_desc"` + RemoveBlankLines bool `mapstructure:"remove_blank_lines"` + RemoveHTMLTags bool `mapstructure:"remove_html_tags"` + RemoveChapterTitle bool `mapstructure:"remove_chapter_title"` + AddParagraphIndent bool `mapstructure:"add_paragraph_indent"` + DoubleNewline bool `mapstructure:"double_newline"` // 段落间使用双换行 + EnableReview bool `mapstructure:"enable_review"` // 是否启用段评功能 +} + +// MangaConfig 漫画处理配置 +type MangaConfig struct { + OutputMode string `mapstructure:"output_mode"` // 输出模式: data_uri / base64 / url +} + +// CacheConfig 缓存配置 +type CacheConfig struct { + Enabled bool `mapstructure:"enabled"` + SQLitePath string `mapstructure:"sqlite_path"` + CleanupEnabled bool `mapstructure:"cleanup_enabled"` // 是否启用缓存清理 + CleanupSeconds int `mapstructure:"cleanup_seconds"` // 缓存清理时间(秒) +} + +// DeviceKeyPoolConfig 设备密钥池配置 +type DeviceKeyPoolConfig struct { + Enabled bool `mapstructure:"enabled"` // 是否启用设备密钥池 + InitialSize int `mapstructure:"initial_size"` // 初始化设备数量 + MinSize int `mapstructure:"min_size"` // 最小设备数量 + MaxSize int `mapstructure:"max_size"` // 最大设备数量 +} + +// ConfigManager 配置管理器 +type ConfigManager struct { + mu sync.RWMutex + config *Config + configPath string + callbacks []func(*Config) +} + +var ( + // AppConfig 全局配置实例 + AppConfig *Config + manager *ConfigManager +) + +// NewConfigManager 创建配置管理器 +func NewConfigManager() *ConfigManager { + return &ConfigManager{ + callbacks: make([]func(*Config), 0), + } +} + +// LoadConfig 加载配置文件 +func LoadConfig(configPath string) error { + // 初始化管理器 + if manager == nil { + manager = NewConfigManager() + manager.configPath = configPath + } + + viper.SetConfigFile(configPath) + viper.SetConfigType("yaml") + + // 设置默认值 + setDefaults() + + if err := viper.ReadInConfig(); err != nil { + return fmt.Errorf("读取配置文件失败: %w", err) + } + + newConfig := &Config{} + if err := viper.Unmarshal(newConfig); err != nil { + return fmt.Errorf("解析配置文件失败: %w", err) + } + + // 更新配置 + manager.mu.Lock() + manager.config = newConfig + AppConfig = newConfig + manager.mu.Unlock() + + return nil +} + +// setDefaults 设置默认值 +func setDefaults() { + // 服务器配置 + viper.SetDefault("server.port", "40180") + viper.SetDefault("server.host", "0.0.0.0") + + // 番茄小说配置 + viper.SetDefault("fanqie.algorithm", "gorgon") + viper.SetDefault("fanqie.signature", "sign2") + + // 内容处理配置 + viper.SetDefault("fanqie.content.remove_images", false) + viper.SetDefault("fanqie.content.remove_image_desc", false) + viper.SetDefault("fanqie.content.remove_blank_lines", true) + viper.SetDefault("fanqie.content.remove_html_tags", false) + viper.SetDefault("fanqie.content.remove_chapter_title", false) + viper.SetDefault("fanqie.content.add_paragraph_indent", false) + viper.SetDefault("fanqie.content.double_newline", true) + viper.SetDefault("fanqie.content.enable_review", true) + + // 漫画处理配置 + viper.SetDefault("fanqie.manga.output_mode", "url") + + // 缓存配置 + viper.SetDefault("cache.enabled", true) + viper.SetDefault("cache.sqlite_path", "./cache.db") + viper.SetDefault("cache.cleanup_enabled", false) + viper.SetDefault("cache.cleanup_seconds", 86400) + + // 设备密钥池配置 + viper.SetDefault("device_key_pool.enabled", true) + viper.SetDefault("device_key_pool.initial_size", 3) + viper.SetDefault("device_key_pool.min_size", 2) + viper.SetDefault("device_key_pool.max_size", 10) +} + +// GetConfig 获取配置实例 +func GetConfig() *Config { + if manager != nil { + manager.mu.RLock() + defer manager.mu.RUnlock() + if manager.config != nil { + return manager.config + } + } + + if AppConfig == nil { + log.Fatal("配置未初始化,请先调用 LoadConfig") + } + return AppConfig +} + +// ReloadConfig 重新加载配置文件 +func ReloadConfig() error { + if manager == nil { + return fmt.Errorf("配置管理器未初始化") + } + + manager.mu.RLock() + configPath := manager.configPath + manager.mu.RUnlock() + + if configPath == "" { + return fmt.Errorf("配置文件路径为空") + } + + // 重新读取配置文件 + viper.SetConfigFile(configPath) + if err := viper.ReadInConfig(); err != nil { + return fmt.Errorf("重新读取配置文件失败: %w", err) + } + + newConfig := &Config{} + if err := viper.Unmarshal(newConfig); err != nil { + return fmt.Errorf("重新解析配置文件失败: %w", err) + } + + // 更新配置 + manager.mu.Lock() + oldAlgorithm := manager.config.Fanqie.Algorithm + manager.config = newConfig + AppConfig = newConfig + callbacks := make([]func(*Config), len(manager.callbacks)) + copy(callbacks, manager.callbacks) + manager.mu.Unlock() + + // 通知配置更新 + for _, callback := range callbacks { + callback(newConfig) + } + + log.Printf("配置重新加载成功: %s", configPath) + if oldAlgorithm != newConfig.Fanqie.Algorithm { + log.Printf("算法已切换: %s -> %s", oldAlgorithm, newConfig.Fanqie.Algorithm) + } + return nil +} + +// RegisterConfigCallback 注册配置更新回调 +func RegisterConfigCallback(callback func(*Config)) { + if manager == nil { + log.Printf("警告: 配置管理器未初始化,无法注册回调") + return + } + + manager.mu.Lock() + defer manager.mu.Unlock() + manager.callbacks = append(manager.callbacks, callback) +} + +// GetConfigManager 获取配置管理器实例 +func GetConfigManager() *ConfigManager { + return manager +} + diff --git a/internal/config/watcher.go b/internal/config/watcher.go new file mode 100644 index 0000000..072ded4 --- /dev/null +++ b/internal/config/watcher.go @@ -0,0 +1,120 @@ +package config + +import ( + "log" + "path/filepath" + "time" + + "github.com/fsnotify/fsnotify" +) + +// ConfigWatcher 配置文件监控器 +type ConfigWatcher struct { + watcher *fsnotify.Watcher + configPath string + stopCh chan struct{} +} + +// NewConfigWatcher 创建配置文件监控器 +func NewConfigWatcher(configPath string) (*ConfigWatcher, error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + + // 转换为绝对路径 + absPath, err := filepath.Abs(configPath) + if err != nil { + return nil, err + } + + return &ConfigWatcher{ + watcher: watcher, + configPath: absPath, + stopCh: make(chan struct{}), + }, nil +} + +// Start 启动配置文件监控 +func (w *ConfigWatcher) Start() error { + // 监控配置文件所在目录 + configDir := filepath.Dir(w.configPath) + if err := w.watcher.Add(configDir); err != nil { + return err + } + + go w.watchLoop() + return nil +} + +// Stop 停止配置文件监控 +func (w *ConfigWatcher) Stop() { + close(w.stopCh) + if w.watcher != nil { + w.watcher.Close() + } + log.Println("配置文件监控已停止") +} + +// watchLoop 监控循环 +func (w *ConfigWatcher) watchLoop() { + // 防抖动:在短时间内多次文件变化只处理一次 + var debounceTimer *time.Timer + const debounceDelay = 500 * time.Millisecond + + for { + select { + case event, ok := <-w.watcher.Events: + if !ok { + return + } + + // 只处理配置文件的写入或创建事件 + if filepath.Base(event.Name) == filepath.Base(w.configPath) { + if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create { + // log.Printf("检测到配置文件变化: %s", event.Name) + + // 重置防抖动定时器 + if debounceTimer != nil { + debounceTimer.Stop() + } + + debounceTimer = time.AfterFunc(debounceDelay, func() { + if err := ReloadConfig(); err != nil { + log.Printf("重新加载配置失败: %v", err) + } else { + log.Println("✅ 配置热更新成功!") + } + }) + } + } + + case err, ok := <-w.watcher.Errors: + if !ok { + return + } + log.Printf("配置文件监控错误: %v", err) + + case <-w.stopCh: + if debounceTimer != nil { + debounceTimer.Stop() + } + return + } + } +} + +// StartConfigWatcher 启动配置文件监控(全局函数) +func StartConfigWatcher(configPath string) (*ConfigWatcher, error) { + watcher, err := NewConfigWatcher(configPath) + if err != nil { + return nil, err + } + + if err := watcher.Start(); err != nil { + return nil, err + } + + return watcher, nil +} + diff --git a/internal/fqapi/audio.go b/internal/fqapi/audio.go new file mode 100644 index 0000000..6089466 --- /dev/null +++ b/internal/fqapi/audio.go @@ -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 +} + diff --git a/internal/fqapi/audio_proxy.go b/internal/fqapi/audio_proxy.go new file mode 100644 index 0000000..4645914 --- /dev/null +++ b/internal/fqapi/audio_proxy.go @@ -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)) + } +} diff --git a/internal/fqapi/cache_manager.go b/internal/fqapi/cache_manager.go new file mode 100644 index 0000000..765a55e --- /dev/null +++ b/internal/fqapi/cache_manager.go @@ -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 +} \ No newline at end of file diff --git a/internal/fqapi/catalog.go b/internal/fqapi/catalog.go new file mode 100644 index 0000000..09bb93e --- /dev/null +++ b/internal/fqapi/catalog.go @@ -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("请求失败") +} + diff --git a/internal/fqapi/category_front.go b/internal/fqapi/category_front.go new file mode 100644 index 0000000..0138795 --- /dev/null +++ b/internal/fqapi/category_front.go @@ -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 +} + diff --git a/internal/fqapi/category_landing.go b/internal/fqapi/category_landing.go new file mode 100644 index 0000000..dee2fb0 --- /dev/null +++ b/internal/fqapi/category_landing.go @@ -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 +} + diff --git a/internal/fqapi/content.go b/internal/fqapi/content.go new file mode 100644 index 0000000..c75f004 --- /dev/null +++ b/internal/fqapi/content.go @@ -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, ``, "", -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("所有接口都失败") +} + diff --git a/internal/fqapi/content_parser.go b/internal/fqapi/content_parser.go new file mode 100644 index 0000000..da82eac --- /dev/null +++ b/internal/fqapi/content_parser.go @@ -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(`]*>`).ReplaceAllString(content, "") + return strings.TrimSpace(content) +} + +// removeChapterTitle 移除章节标题 +func (p *ContentParser) removeChapterTitle(content string) string { + // 移除 h1 和 h2 标签中的章节标题 + content = regexp.MustCompile(`(?si)]*class="[^"]*chapter[Tt]itle[^"]*"[^>]*>.*?`).ReplaceAllString(content, "") + // 同时移除 标签 + content = regexp.MustCompile(`(?si)<title>.*?`).ReplaceAllString(content, "") + return content +} + +// removeImageDesc 移除图片描述 +func (p *ContentParser) removeImageDesc(content string) string { + return regexp.MustCompile(`(?s)]*class="pictureDesc"[^>]*>.*?

`).ReplaceAllString(content, "") +} + +// removeImages 移除图片 +func (p *ContentParser) removeImages(content string) string { + return regexp.MustCompile(`(?s)]*data-fanqie-type="image"[^>]*>.*?`).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内容移除空的

标签 + content = regexp.MustCompile(`(?s)]*>\s*

`).ReplaceAllString(content, "") + // 移除多余的空白 + content = regexp.MustCompile(`\n\s*\n\s*\n`).ReplaceAllString(content, "\n\n") + } + + return content +} + diff --git a/internal/fqapi/content_utils.go b/internal/fqapi/content_utils.go new file mode 100644 index 0000000..be5bc55 --- /dev/null +++ b/internal/fqapi/content_utils.go @@ -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 +} + diff --git a/internal/fqapi/detail.go b/internal/fqapi/detail.go new file mode 100644 index 0000000..0732e70 --- /dev/null +++ b/internal/fqapi/detail.go @@ -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 +} + diff --git a/internal/fqapi/device_config.go b/internal/fqapi/device_config.go new file mode 100644 index 0000000..c4a0304 --- /dev/null +++ b/internal/fqapi/device_config.go @@ -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", + }, +} diff --git a/internal/fqapi/device_key_pool.go b/internal/fqapi/device_key_pool.go new file mode 100644 index 0000000..b1beb73 --- /dev/null +++ b/internal/fqapi/device_key_pool.go @@ -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 +} + diff --git a/internal/fqapi/device_register.go b/internal/fqapi/device_register.go new file mode 100644 index 0000000..d127a20 --- /dev/null +++ b/internal/fqapi/device_register.go @@ -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("设备注册失败") +} + diff --git a/internal/fqapi/dhcry.go b/internal/fqapi/dhcry.go new file mode 100644 index 0000000..10589a8 --- /dev/null +++ b/internal/fqapi/dhcry.go @@ -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 +} + diff --git a/internal/fqapi/emoji.go b/internal/fqapi/emoji.go new file mode 100644 index 0000000..3f5c756 --- /dev/null +++ b/internal/fqapi/emoji.go @@ -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(`%s`, url, displayName) + result = strings.ReplaceAll(result, displayName, imgTag) + } + + return result +} + diff --git a/internal/fqapi/gorgon_utils.go b/internal/fqapi/gorgon_utils.go new file mode 100644 index 0000000..0207359 --- /dev/null +++ b/internal/fqapi/gorgon_utils.go @@ -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) +} + diff --git a/internal/fqapi/item_summary.go b/internal/fqapi/item_summary.go new file mode 100644 index 0000000..083877a --- /dev/null +++ b/internal/fqapi/item_summary.go @@ -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 +} + diff --git a/internal/fqapi/manga.go b/internal/fqapi/manga.go new file mode 100644 index 0000000..20bd570 --- /dev/null +++ b/internal/fqapi/manga.go @@ -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(`图片%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" +} + diff --git a/internal/fqapi/manga_proxy.go b/internal/fqapi/manga_proxy.go new file mode 100644 index 0000000..13b5ab3 --- /dev/null +++ b/internal/fqapi/manga_proxy.go @@ -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) + } + } +} + diff --git a/internal/fqapi/media_utils.go b/internal/fqapi/media_utils.go new file mode 100644 index 0000000..415ed72 --- /dev/null +++ b/internal/fqapi/media_utils.go @@ -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 + } +} diff --git a/internal/fqapi/multi_content.go b/internal/fqapi/multi_content.go new file mode 100644 index 0000000..7753ab4 --- /dev/null +++ b/internal/fqapi/multi_content.go @@ -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) +} + diff --git a/internal/fqapi/multi_detail.go b/internal/fqapi/multi_detail.go new file mode 100644 index 0000000..d9ea9cf --- /dev/null +++ b/internal/fqapi/multi_detail.go @@ -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 +} + diff --git a/internal/fqapi/register_key.go b/internal/fqapi/register_key.go new file mode 100644 index 0000000..5831af0 --- /dev/null +++ b/internal/fqapi/register_key.go @@ -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()) +} + diff --git a/internal/fqapi/review.go b/internal/fqapi/review.go new file mode 100644 index 0000000..53eae5b --- /dev/null +++ b/internal/fqapi/review.go @@ -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, "

") || + strings.Contains(content, "标签) + // 因为标签需要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, "") || + strings.HasPrefix(trimmedLine, "标签,在

之前插入段评标记 + if strings.HasPrefix(trimmedLine, "

") && strings.HasSuffix(trimmedLine, "

") { + // 提取

和

之间的内容 + innerContent := trimmedLine[3 : len(trimmedLine)-4] + result.WriteString("

") + result.WriteString(innerContent) + + // 添加评论标记 + key := fmt.Sprintf("%d", paragraphIndex) + if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 { + commentTag := fmt.Sprintf(``, + baseURL, itemID, bookID, paragraphIndex, commentInfo.Count) + result.WriteString(commentTag) + } + + result.WriteString("

") + if !isLastLine { + result.WriteString("\n") + } + paragraphIndex++ + continue + } + + // 如果是HTML模式,包裹

标签 + if isHTMLContent { + result.WriteString("

") + result.WriteString(line) + + // 添加评论标记 + key := fmt.Sprintf("%d", paragraphIndex) + if commentInfo, exists := commentData[key]; exists && commentInfo.Count > 0 { + commentTag := fmt.Sprintf(``, + baseURL, itemID, bookID, paragraphIndex, commentInfo.Count) + result.WriteString(commentTag) + } + + result.WriteString("

") + 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(``, + 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 +} diff --git a/internal/fqapi/search.go b/internal/fqapi/search.go new file mode 100644 index 0000000..678d490 --- /dev/null +++ b/internal/fqapi/search.go @@ -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 +} + diff --git a/internal/fqapi/ttencrypt.go b/internal/fqapi/ttencrypt.go new file mode 100644 index 0000000..23fd07f --- /dev/null +++ b/internal/fqapi/ttencrypt.go @@ -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) +} + diff --git a/internal/fqapi/video.go b/internal/fqapi/video.go new file mode 100644 index 0000000..72d43b1 --- /dev/null +++ b/internal/fqapi/video.go @@ -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) +} + diff --git a/internal/handlers/book.go b/internal/handlers/book.go new file mode 100644 index 0000000..627fac0 --- /dev/null +++ b/internal/handlers/book.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "fanqie/internal/fqapi" + "fmt" + "net/http" + "strconv" +) + +// HandleSearch 搜索处理 +func HandleSearch(w http.ResponseWriter, r *http.Request) { + incrementTotal() + incrementSearch() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + query := r.URL.Query().Get("query") + if query == "" { + query = r.URL.Query().Get("key") + } + + offsetStr := r.URL.Query().Get("offset") + offset := 0 + if offsetStr != "" { + offset, _ = strconv.Atoi(offsetStr) + } + + tabTypeStr := r.URL.Query().Get("tab_type") + tabType := 3 + if tabTypeStr != "" { + tabType, _ = strconv.Atoi(tabTypeStr) + } + + result, err := fqapi.HandleBookSearch(query, offset, tabType) + if err != nil { + fmt.Fprintf(w, `{"code": -1, "msg": "搜索失败: %s"}`, err.Error()) + return + } + + fmt.Fprint(w, result) +} + +// HandleDetail 详情处理 +func HandleDetail(w http.ResponseWriter, r *http.Request) { + incrementTotal() + incrementDetail() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + bookID := r.URL.Query().Get("book_id") + + result, err := fqapi.HandleGetDetail(bookID) + if err != nil { + fmt.Fprintf(w, `{"code": -1, "msg": "获取详情失败: %s"}`, err.Error()) + return + } + + fmt.Fprint(w, result) +} + +// HandleCatalog 目录处理 +func HandleCatalog(w http.ResponseWriter, r *http.Request) { + incrementTotal() + incrementCatalog() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + bookID := r.URL.Query().Get("book_id") + + result, err := fqapi.HandleGetCatalog(bookID) + if err != nil { + fmt.Fprintf(w, `{"code": -1, "msg": "获取目录失败: %s"}`, err.Error()) + return + } + + fmt.Fprint(w, result) +} + +// HandleMultiDetail 批量详情处理 +func HandleMultiDetail(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + bookID := r.URL.Query().Get("book_id") + + result, err := fqapi.HandleGetMultiDetail(bookID) + if err != nil { + fmt.Fprintf(w, `{"code": -1, "msg": "批量获取详情失败: %s"}`, err.Error()) + return + } + + fmt.Fprint(w, result) +} + diff --git a/internal/handlers/category.go b/internal/handlers/category.go new file mode 100644 index 0000000..c941055 --- /dev/null +++ b/internal/handlers/category.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "fanqie/internal/fqapi" + "fmt" + "net/http" +) + +// HandleCategoryFront 分类列表处理 +func HandleCategoryFront(w http.ResponseWriter, r *http.Request) { + incrementTotal() + incrementCategory() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + tab := r.URL.Query().Get("tab") + + result, err := fqapi.HandleCategoryFront(tab) + if err != nil { + fmt.Fprintf(w, `{"code": -1, "msg": "获取分类列表失败: %s"}`, err.Error()) + return + } + + fmt.Fprint(w, result) +} + +// HandleCategoryLanding 分类详情处理 +func HandleCategoryLanding(w http.ResponseWriter, r *http.Request) { + incrementTotal() + incrementCategory() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + categoryID := r.URL.Query().Get("category_id") + offset := r.URL.Query().Get("offset") + wordNumber := r.URL.Query().Get("word_number") + bookStatus := r.URL.Query().Get("book_status") + sortBy := r.URL.Query().Get("sort_by") + + result, err := fqapi.HandleCategoryLanding(categoryID, offset, wordNumber, bookStatus, sortBy) + if err != nil { + fmt.Fprintf(w, `{"code": -1, "msg": "获取分类详情失败: %s"}`, err.Error()) + return + } + + fmt.Fprint(w, result) +} + diff --git a/internal/handlers/common.go b/internal/handlers/common.go new file mode 100644 index 0000000..4a100aa --- /dev/null +++ b/internal/handlers/common.go @@ -0,0 +1,174 @@ +package handlers + +import ( + "encoding/json" + "fanqie/internal/config" + "fanqie/internal/fqapi" + "fmt" + "net/http" + "sync/atomic" + "time" +) + +var ( + startTime = time.Now() + deviceHeader string + + // 请求统计 + totalRequests int64 + searchRequests int64 + detailRequests int64 + catalogRequests int64 + contentRequests int64 + mangaRequests int64 + audioRequests int64 + videoRequests int64 + categoryRequests int64 + + // 统计保存定时器 + statsSaveTicker *time.Ticker +) + +// 统计函数 +func incrementTotal() { atomic.AddInt64(&totalRequests, 1) } +func incrementSearch() { atomic.AddInt64(&searchRequests, 1) } +func incrementDetail() { atomic.AddInt64(&detailRequests, 1) } +func incrementCatalog() { atomic.AddInt64(&catalogRequests, 1) } +func incrementContent() { atomic.AddInt64(&contentRequests, 1) } +func incrementManga() { atomic.AddInt64(&mangaRequests, 1) } +func incrementAudio() { atomic.AddInt64(&audioRequests, 1) } +func incrementVideo() { atomic.AddInt64(&videoRequests, 1) } +func incrementCategory() { atomic.AddInt64(&categoryRequests, 1) } + +func getTotal() int64 { return atomic.LoadInt64(&totalRequests) } +func getSearch() int64 { return atomic.LoadInt64(&searchRequests) } +func getDetail() int64 { return atomic.LoadInt64(&detailRequests) } +func getCatalog() int64 { return atomic.LoadInt64(&catalogRequests) } +func getContent() int64 { return atomic.LoadInt64(&contentRequests) } +func getManga() int64 { return atomic.LoadInt64(&mangaRequests) } +func getAudio() int64 { return atomic.LoadInt64(&audioRequests) } +func getVideo() int64 { return atomic.LoadInt64(&videoRequests) } +func getCategory() int64 { return atomic.LoadInt64(&categoryRequests) } + +// SetDeviceHeader 设置设备header +func SetDeviceHeader(header string) { + deviceHeader = header +} + +// LoadStatistics 从数据库加载统计数据 +func LoadStatistics() { + stats, err := fqapi.LoadStatistics() + if err != nil { + fmt.Printf("加载统计数据失败: %v\n", err) + return + } + + atomic.StoreInt64(&totalRequests, stats["total"]) + atomic.StoreInt64(&searchRequests, stats["search"]) + atomic.StoreInt64(&detailRequests, stats["detail"]) + atomic.StoreInt64(&catalogRequests, stats["catalog"]) + atomic.StoreInt64(&contentRequests, stats["content"]) + atomic.StoreInt64(&mangaRequests, stats["manga"]) + atomic.StoreInt64(&audioRequests, stats["audio"]) + atomic.StoreInt64(&videoRequests, stats["video"]) + atomic.StoreInt64(&categoryRequests, stats["category"]) +} + +// SaveStatistics 保存统计数据到数据库 +func SaveStatistics() { + stats := map[string]int64{ + "total": atomic.LoadInt64(&totalRequests), + "search": atomic.LoadInt64(&searchRequests), + "detail": atomic.LoadInt64(&detailRequests), + "catalog": atomic.LoadInt64(&catalogRequests), + "content": atomic.LoadInt64(&contentRequests), + "manga": atomic.LoadInt64(&mangaRequests), + "audio": atomic.LoadInt64(&audioRequests), + "video": atomic.LoadInt64(&videoRequests), + "category": atomic.LoadInt64(&categoryRequests), + } + + if err := fqapi.SaveStatistics(stats); err != nil { + fmt.Printf("保存统计数据失败: %v\n", err) + } +} + +// StartStatisticsSaver 启动统计数据定期保存 +func StartStatisticsSaver() { + // 每30秒保存一次统计数据 + statsSaveTicker = time.NewTicker(30 * time.Second) + go func() { + for range statsSaveTicker.C { + SaveStatistics() + } + }() +} + +// StopStatisticsSaver 停止统计数据定期保存 +func StopStatisticsSaver() { + if statsSaveTicker != nil { + statsSaveTicker.Stop() + // 最后保存一次 + SaveStatistics() + } +} + +// RequireAccessKey 验证访问密钥中间件 +func RequireAccessKey(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cfg := config.GetConfig() + + // 如果未配置 access_key 或为空,则不启用验证 + if cfg == nil || cfg.Server.AccessKey == "" { + handler(w, r) + return + } + + // 从查询参数或 Header 中获取 key + key := r.URL.Query().Get("key") + if key == "" { + key = r.Header.Get("X-Access-Key") + } + + // 验证密钥 + if key != cfg.Server.AccessKey { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{"code": -1, "msg": "访问被拒绝:无效的访问密钥"}`) + return + } + + // 验证通过,继续处理请求 + handler(w, r) + } +} + +// HandleMain 主页处理 +func HandleMain(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + response := map[string]interface{}{ + "code": 0, + "message": "success", + "data": nil, + } + json.NewEncoder(w).Encode(response) +} + +// HandleSelfInfo 设备信息处理 +func HandleSelfInfo(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + // 获取设备池统计信息 + pool := fqapi.GetDeviceKeyPool() + poolStats := pool.GetStats() + + response := map[string]interface{}{ + "code": 0, + "message": "SUCCESS", + "data": map[string]interface{}{ + "device_pool_enabled": pool.IsEnabled(), + "device_pool_stats": poolStats, + }, + } + json.NewEncoder(w).Encode(response) +} diff --git a/internal/handlers/content.go b/internal/handlers/content.go new file mode 100644 index 0000000..a66aa13 --- /dev/null +++ b/internal/handlers/content.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "fanqie/internal/config" + "fanqie/internal/fqapi" + "fmt" + "net/http" +) + +// HandleContent 内容处理 +func HandleContent(w http.ResponseWriter, r *http.Request) { + incrementTotal() + incrementContent() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + // 从查询参数获取item_id + itemID := r.URL.Query().Get("item_id") + + // 获取URL参数(支持动态覆盖配置) + textMode := r.URL.Query().Get("text_mode") // 1=纯文本模式 + imageMode := r.URL.Query().Get("image_mode") // 0=移除图片, 1=保留图片 + reviewParam := r.URL.Query().Get("review") // 1=启用段评, 0=禁用段评 + + result, err := fqapi.HandleGetContent(itemID, textMode, imageMode) + if err != nil { + fmt.Fprintf(w, `{"code": -1, "msg": "获取内容失败: %s"}`, err.Error()) + return + } + + // 判断是否启用段评功能 + // 优先使用URL参数,如果没有则使用配置文件 + enableReview := false + if reviewParam == "1" { + enableReview = true + } else if reviewParam == "0" { + enableReview = false + } else { + // 没有URL参数时,使用配置文件 + cfg := config.GetConfig() + if cfg != nil { + enableReview = cfg.Fanqie.Content.EnableReview + } + } + + // 如果启用了段评功能,处理正文内容添加段评标记 + if enableReview { + result = addReviewToContent(result, itemID, r) + } + + fmt.Fprint(w, result) +} + +// HandleMultiContent 批量内容处理 +func HandleMultiContent(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + bookID := r.URL.Query().Get("book_id") + itemIDs := r.URL.Query().Get("item_ids") + textMode := r.URL.Query().Get("text_mode") + imageMode := r.URL.Query().Get("image_mode") + ignoreFailed := r.URL.Query().Get("ignore_failed") + + result, err := fqapi.HandleMultiContent(itemIDs, bookID, textMode, imageMode, ignoreFailed) + if err != nil { + fmt.Fprintf(w, `{"code": -1, "msg": "批量获取内容失败: %s"}`, err.Error()) + return + } + + fmt.Fprint(w, result) +} + +// HandleItemSummary 章节摘要处理 +func HandleItemSummary(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + bookID := r.URL.Query().Get("book_id") + itemIDs := r.URL.Query().Get("item_ids") + + result, err := fqapi.HandleItemSummary(itemIDs, bookID) + if err != nil { + fmt.Fprintf(w, `{"code": -1, "msg": "获取章节摘要失败: %s"}`, err.Error()) + return + } + + fmt.Fprint(w, result) +} + +// addReviewToContent 为正文内容添加段评标记 +func addReviewToContent(jsonResult, itemID string, r *http.Request) string { + // 解析 JSON 结果 + var resultData map[string]interface{} + if err := fqapi.ParseJSON([]byte(jsonResult), &resultData); err != nil { + return jsonResult + } + + // 获取 data 字段 + data, ok := resultData["data"].(map[string]interface{}) + if !ok { + return jsonResult + } + + // 获取 content 字段 + content, ok := data["content"].(string) + if !ok || content == "" { + return jsonResult + } + + // 从响应数据中提取 book_id (data.novel_data.book_id) + bookID := "" + if novelData, ok := data["novel_data"].(map[string]interface{}); ok { + if bookIDVal, ok := novelData["book_id"].(string); ok { + bookID = bookIDVal + } else if bookIDVal, ok := novelData["book_id"].(float64); ok { + bookID = fmt.Sprintf("%.0f", bookIDVal) + } + } + + // 获取基础 URL + cfg := config.GetConfig() + baseURL := "" + if cfg != nil { + host := cfg.Server.Host + if host == "0.0.0.0" { + host = "localhost" + } + port := cfg.Server.Port + scheme := "http" + + // 检查是否来自外部请求 + if r.Host != "" { + baseURL = scheme + "://" + r.Host + } else { + baseURL = scheme + "://" + host + ":" + port + } + } + + // 生成带段评标记的内容 + newContent, err := fqapi.GenerateParagraphsWithComments(itemID, bookID, content, baseURL) + if err != nil { + // 如果失败,返回原始内容 + return jsonResult + } + + // 替换 content 字段 + data["content"] = newContent + resultData["data"] = data + + // 重新序列化为 JSON + newJSON, err := fqapi.ToJSON(resultData) + if err != nil { + return jsonResult + } + + return newJSON +} + diff --git a/internal/handlers/manga_proxy.go b/internal/handlers/manga_proxy.go new file mode 100644 index 0000000..69067b9 --- /dev/null +++ b/internal/handlers/manga_proxy.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "fanqie/internal/fqapi" + "fmt" + "net/http" + "strconv" + "strings" +) + +// HandleMangaImageProxy 处理漫画图片代理请求 +func HandleMangaImageProxy(w http.ResponseWriter, r *http.Request) { + // 从URL路径提取参数: /manga/image/{itemID}/{index} + path := strings.TrimPrefix(r.URL.Path, "/manga/image/") + parts := strings.Split(path, "/") + + if len(parts) != 2 { + http.Error(w, "Invalid URL format. Expected: /manga/image/{itemID}/{index}", http.StatusBadRequest) + return + } + + itemID := parts[0] + index, err := strconv.Atoi(parts[1]) + if err != nil { + http.Error(w, "Invalid index parameter", http.StatusBadRequest) + return + } + + // 获取解密后的图片数据 + imageData, mimeType, err := fqapi.HandleMangaImageProxy(itemID, index) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get image: %s", err.Error()), http.StatusInternalServerError) + return + } + + // 设置响应头 + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Cache-Control", "public, max-age=3600") // 缓存1小时 + w.Header().Set("Content-Length", strconv.Itoa(len(imageData))) + + // 返回图片数据 + w.Write(imageData) +} + diff --git a/internal/handlers/media.go b/internal/handlers/media.go new file mode 100644 index 0000000..0008449 --- /dev/null +++ b/internal/handlers/media.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "encoding/json" + "fanqie/internal/fqapi" + "fmt" + "net/http" + "strings" +) + +// HandleAudio 音频处理 +func HandleAudio(w http.ResponseWriter, r *http.Request) { + incrementTotal() + incrementAudio() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + itemIDs := r.URL.Query().Get("item_ids") + toneID := r.URL.Query().Get("tone_id") + output := r.URL.Query().Get("output") + + result, err := fqapi.HandleAudio(itemIDs, toneID, output) + if err != nil { + fmt.Fprintf(w, `{"code": -1, "msg": "获取音频失败: %s"}`, err.Error()) + return + } + + fmt.Fprint(w, result) +} + +// HandleAudioProxy 音频代理处理 +func HandleAudioProxy(w http.ResponseWriter, r *http.Request) { + incrementTotal() + + // 从URL路径提取key: /audio/proxy/{key} + path := r.URL.Path + key := strings.TrimPrefix(path, "/audio/proxy/") + if key == "" || key == path { + http.Error(w, "缺少音频key", http.StatusBadRequest) + return + } + + // 调用代理处理函数 + data, contentType, err := fqapi.HandleAudioProxy(key) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // 设置正确的Content-Type + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } else { + w.Header().Set("Content-Type", "audio/mpeg") + } + + // 直接播放,不强制下载 + // 浏览器会根据Content-Type决定是播放还是下载 + w.Write(data) +} + +// HandleVideo 视频处理 +func HandleVideo(w http.ResponseWriter, r *http.Request) { + incrementTotal() + incrementVideo() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + itemIDs := r.URL.Query().Get("item_ids") + + result, err := fqapi.HandleVideo(itemIDs) + if err != nil { + fmt.Fprintf(w, `{"code": -1, "msg": "获取视频失败: %s"}`, err.Error()) + return + } + + fmt.Fprint(w, result) +} + +// HandleManga 漫画处理 +func HandleManga(w http.ResponseWriter, r *http.Request) { + incrementTotal() + incrementManga() + itemID := r.URL.Query().Get("item_ids") + if itemID == "" { + itemID = r.URL.Query().Get("item_id") + } + + if itemID == "" { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + fmt.Fprint(w, `{"code": -1, "msg": "缺少item_ids参数"}`) + return + } + + // 检查是否要返回HTML格式 + showHTML := r.URL.Query().Get("show_html") == "1" + + // 检查输出模式参数(优先级高于配置文件) + // 支持:output=url / output=proxy / output=data_uri + outputMode := r.URL.Query().Get("output") + + result, err := fqapi.HandleGetManga(itemID, showHTML, outputMode) + if err != nil { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + fmt.Fprintf(w, `{"code": -1, "msg": "获取漫画失败: %s"}`, err.Error()) + return + } + + if showHTML { + // 返回HTML格式 + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if contentMap, ok := result.(map[string]interface{}); ok { + if content, ok := contentMap["content"].(string); ok { + fmt.Fprint(w, content) + return + } + } + fmt.Fprint(w, `

未找到漫画内容

`) + } else { + // 返回JSON格式 + w.Header().Set("Content-Type", "application/json; charset=utf-8") + jsonData, err := json.Marshal(map[string]interface{}{ + "code": 0, + "msg": "success", + "data": result, + }) + if err != nil { + fmt.Fprintf(w, `{"code": -1, "msg": "JSON编码失败: %s"}`, err.Error()) + return + } + fmt.Fprint(w, string(jsonData)) + } +} + diff --git a/internal/handlers/review.go b/internal/handlers/review.go new file mode 100644 index 0000000..c59a4f6 --- /dev/null +++ b/internal/handlers/review.go @@ -0,0 +1,1281 @@ +package handlers + +import ( + "encoding/json" + "fanqie/internal/fqapi" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +// validateParams 验证必需参数 +func validateParams(r *http.Request, required ...string) (map[string]string, error) { + params := make(map[string]string) + for _, key := range required { + value := r.URL.Query().Get(key) + if value == "" { + return nil, fmt.Errorf("缺少 %s 参数", key) + } + params[key] = value + } + return params, nil +} + +// getOptionalParam 获取可选参数(带默认值) +func getOptionalParam(r *http.Request, key, defaultValue string) string { + value := r.URL.Query().Get(key) + if value == "" { + return defaultValue + } + return value +} + +// parseIntParam 解析整数参数 +func parseIntParam(value string, paramName string) (int, error) { + if value == "" { + return 0, nil + } + result, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("%s 参数格式错误", paramName) + } + return result, nil +} + +// handleReviewError 统一处理段评 API 错误 +func handleReviewError(w http.ResponseWriter, errType string, err error) { + status := http.StatusInternalServerError + msg := fmt.Sprintf("%s失败: %s", errType, err.Error()) + + if strings.Contains(err.Error(), "缺少") || strings.Contains(err.Error(), "格式错误") { + status = http.StatusBadRequest + } + + respondJSON(w, status, map[string]interface{}{ + "code": -1, + "msg": msg, + }) +} + +// handleReviewSuccess 统一处理成功响应 +func handleReviewSuccess(w http.ResponseWriter, data interface{}) { + respondJSON(w, http.StatusOK, map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": data, + }) +} + +// HandleChapterReview 处理章节评论请求(混合数据) +func HandleChapterReview(w http.ResponseWriter, r *http.Request) { + incrementTotal() + + // 验证必需参数 + params, err := validateParams(r, "item_id") + if err != nil { + handleReviewError(w, "参数验证", err) + return + } + + // 获取可选参数 + bookID := getOptionalParam(r, "book_id", "") + + // 调用 API + data, err := fqapi.GetChapterMixData(bookID, params["item_id"]) + if err != nil { + handleReviewError(w, "获取章节评论", err) + return + } + + handleReviewSuccess(w, data) +} + +// HandleReview 处理段落评论请求 +func HandleReview(w http.ResponseWriter, r *http.Request) { + incrementTotal() + + // 验证必需参数 + params, err := validateParams(r, "item_id") + if err != nil { + handleReviewError(w, "参数验证", err) + return + } + + // 获取可选参数 + bookID := getOptionalParam(r, "book_id", "") + commentID := getOptionalParam(r, "comment_id", "") + paraStr := getOptionalParam(r, "para", "0") + sortStr := getOptionalParam(r, "sort", "1") + offsetStr := getOptionalParam(r, "offset", "0") + countStr := getOptionalParam(r, "count", "20") + + // 解析整数参数 + para, err := parseIntParam(paraStr, "para") + if err != nil { + handleReviewError(w, "参数解析", err) + return + } + + sort, err := parseIntParam(sortStr, "sort") + if err != nil { + sort = 1 // 默认热门 + } + + offset, _ := parseIntParam(offsetStr, "offset") + count, _ := parseIntParam(countStr, "count") + if count <= 0 { + count = 20 + } + + // 调用 API + data, err := fqapi.GetParaReview(params["item_id"], bookID, commentID, para, sort, offset, count) + if err != nil { + handleReviewError(w, "获取段落评论", err) + return + } + + handleReviewSuccess(w, data) +} + +// HandleReviewHTML 处理段落评论 HTML 显示 +func HandleReviewHTML(w http.ResponseWriter, r *http.Request) { + incrementTotal() + + // 验证必需参数 + params, err := validateParams(r, "item_id", "para") + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "

%s

", err.Error()) + return + } + + // 解析整数参数 + para, err := parseIntParam(params["para"], "para") + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "

%s

", err.Error()) + return + } + + // 获取可选参数 + bookID := getOptionalParam(r, "book_id", "") + pageStr := getOptionalParam(r, "page", "1") + pageSizeStr := getOptionalParam(r, "page_size", "20") + sortStr := getOptionalParam(r, "sort", "1") + + // 解析分页参数 + page, _ := parseIntParam(pageStr, "page") + if page < 1 { + page = 1 + } + pageSize, _ := parseIntParam(pageSizeStr, "page_size") + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + // 解析排序参数 + sort, _ := parseIntParam(sortStr, "sort") + if sort != 1 && sort != 3 { + sort = 1 // 默认热门 + } + + // 计算 offset(基于页码) + offset := (page - 1) * pageSize + + // 调用 API + data, err := fqapi.GetParaReview(params["item_id"], bookID, "", para, sort, offset, pageSize) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "

获取评论失败: %s

", err.Error()) + return + } + + // 如果是 AJAX 请求,直接返回 JSON 数据 + if r.Header.Get("X-Requested-With") == "XMLHttpRequest" { + respondJSON(w, http.StatusOK, map[string]interface{}{ + "code": 0, + "data": data, + }) + return + } + + // 生成完整 HTML 页面(仅首次加载) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + html := generateReviewHTML(data, params["item_id"], bookID, para, page, pageSize, sort) + fmt.Fprint(w, html) +} + +// 在 init 函数中初始化表情管理器 +func init() { + // 初始化表情管理器 + em := fqapi.GetEmojiManager() + em.LoadEmojis() +} + + + +// replaceEmojiWithImages 将表情文本替换为图片标签 +func replaceEmojiWithImages(text string) string { + return fqapi.ReplaceEmojisInText(text) +} + +// parseCommentData 解析评论数据的通用函数 +type CommentInfo struct { + UserName string + UserAvatar string + IsAuthor bool + StickerImageURL string + CreateTime string + CommentText string + CommentID string + DiggCount int + ReplyCount int + ImageURL string + ImageLargeURL string +} + +func parseCommentData(comment map[string]interface{}) CommentInfo { + info := CommentInfo{ + UserName: "匿名用户", + } + + // 提取 comment_id + if cid, ok := comment["comment_id"].(string); ok { + info.CommentID = cid + } + + // 用户信息 + if userInfo, ok := comment["user_info"].(map[string]interface{}); ok { + if name, ok := userInfo["user_name"].(string); ok { + info.UserName = name + } + if avatar, ok := userInfo["user_avatar"].(string); ok { + info.UserAvatar = avatar + } + if author, ok := userInfo["is_author"].(bool); ok { + info.IsAuthor = author + } + } + + // 创建时间 + if timestamp, ok := comment["create_timestamp"].(float64); ok { + t := time.Unix(int64(timestamp), 0) + info.CreateTime = t.Format("2006-01-02 15:04") + } + + // 评论内容 + if text, ok := comment["text"].(string); ok { + info.CommentText = text + } + + // 提取图片 + if imageData, ok := comment["image_data"].([]interface{}); ok && len(imageData) > 0 { + if imgMap, ok := imageData[0].(map[string]interface{}); ok { + if url, ok := imgMap["web_uri"].(string); ok && url != "" { + info.ImageURL = url + } + if largeURL, ok := imgMap["expand_web_url"].(string); ok && largeURL != "" { + info.ImageLargeURL = largeURL + } else { + info.ImageLargeURL = info.ImageURL + } + } + } + + // 统计信息 + if count, ok := comment["digg_count"].(float64); ok { + info.DiggCount = int(count) + } else if count, ok := comment["digg_count"].(int); ok { + info.DiggCount = count + } + + // 回复数量 + if replyCountVal, ok := comment["reply_count"].(float64); ok { + info.ReplyCount = int(replyCountVal) + } else if replyCountVal, ok := comment["reply_count"].(int); ok { + info.ReplyCount = replyCountVal + } else if replyCnt, ok := comment["reply_cnt"].(float64); ok { + info.ReplyCount = int(replyCnt) + } else if replyCnt, ok := comment["reply_cnt"].(int); ok { + info.ReplyCount = replyCnt + } + + return info +} + +// generateReviewHTML 生成评论 HTML 页面 +func generateReviewHTML(data map[string]interface{}, itemID, bookID string, para, page, pageSize, sort int) string { + var html strings.Builder + + html.WriteString(` + + + + + 番茄段评 · 第 `) + html.WriteString(strconv.Itoa(para + 1)) + html.WriteString(` 段 + + + + + +
+

段落评论 · `) + + // 提取评论总数和是否有更多 + total := 0 + hasMore := false + nextOffset := 0 + + // 新接口返回 count 字段表示总数 + if countVal, ok := data["count"].(float64); ok { + total = int(countVal) + } else if countVal, ok := data["count"].(int); ok { + total = countVal + } + + // 检查是否有更多数据 + if hasMoreVal, ok := data["has_more"].(bool); ok { + hasMore = hasMoreVal + } else if hasMoreVal, ok := data["has_more"].(float64); ok { + hasMore = hasMoreVal > 0 + } else if hasMoreVal, ok := data["has_more"].(int); ok { + hasMore = hasMoreVal > 0 + } + + // 获取下一页的 offset + if nextOffsetVal, ok := data["next_offset"].(float64); ok { + nextOffset = int(nextOffsetVal) + } else if nextOffsetVal, ok := data["next_offset"].(int); ok { + nextOffset = nextOffsetVal + } + + html.WriteString(strconv.Itoa(total)) + html.WriteString(` 条

+ + + +
+
最热
+
最新
+
+
+`) + + // 解析评论数据 + comments := []map[string]interface{}{} + if commentsList, ok := data["comments"].([]interface{}); ok { + for _, item := range commentsList { + if comment, ok := item.(map[string]interface{}); ok { + comments = append(comments, comment) + } + } + } + + // 计算分页 + totalComments := len(comments) + startIdx := (page - 1) * pageSize + endIdx := startIdx + pageSize + if endIdx > totalComments { + endIdx = totalComments + } + + // 获取当前页的评论 + var pageComments []map[string]interface{} + if startIdx < totalComments { + pageComments = comments[startIdx:endIdx] + } + + html.WriteString(`
+`) + + if len(pageComments) == 0 { + html.WriteString(`
暂无评论
`) + } else { + for idx, comment := range pageComments { + info := parseCommentData(comment) + + // 生成 HTML + html.WriteString(`
+ +
`) + html.WriteString(replaceEmojiWithImages(htmlEscape(info.CommentText))) + html.WriteString(`
+`) + + if info.ImageURL != "" { + html.WriteString(` 评论图片 +`) + } + + html.WriteString(`
`) + html.WriteString(info.CreateTime) + html.WriteString(`
+
+ `) + html.WriteString(strconv.Itoa(info.DiggCount)) + html.WriteString(` + `) + html.WriteString(strconv.Itoa(info.ReplyCount)) + html.WriteString(` +
+
+
+`) + } + } + + // 如果初始加载就没有更多数据了,显示提示 + if !hasMore && len(pageComments) > 0 { + html.WriteString(` +
没有更多评论了
`) + } + + html.WriteString(` +
+ +
+ × + 图片预览 +
+ +
+ +
+ + + +`) + + return html.String() +} + +// htmlEscape HTML 转义 +func htmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} + +// generateEmojiMapJS 生成 JavaScript 格式的表情映射 +func generateEmojiMapJS() string { + em := fqapi.GetEmojiManager() + emojiMap := em.GetAllEmojis() + + var sb strings.Builder + sb.WriteString("{") + first := true + for emoji, url := range emojiMap { + if !first { + sb.WriteString(",") + } + first = false + // 转义特殊字符 + escapedEmoji := strings.ReplaceAll(emoji, "\\", "\\\\") + escapedEmoji = strings.ReplaceAll(escapedEmoji, "'", "\\'") + escapedURL := strings.ReplaceAll(url, "\\", "\\\\") + escapedURL = strings.ReplaceAll(escapedURL, "'", "\\'") + sb.WriteString(fmt.Sprintf("'%s':'%s'", escapedEmoji, escapedURL)) + } + sb.WriteString("}") + return sb.String() +} + +// respondJSON 响应 JSON +func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(data) +} + diff --git a/internal/handlers/status.go b/internal/handlers/status.go new file mode 100644 index 0000000..fb8f83a --- /dev/null +++ b/internal/handlers/status.go @@ -0,0 +1,1030 @@ +package handlers + +import ( + "encoding/json" + "fanqie/internal/algos" + "fanqie/internal/config" + "fanqie/internal/fqapi" + "fmt" + "net/http" + "time" +) + +// HandleVacuum 处理数据库压缩请求 +func HandleVacuum(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + // 只允许 POST 请求 + if r.Method != http.MethodPost { + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "仅支持 POST 请求", + }) + return + } + + // 执行 VACUUM + err := fqapi.VacuumDatabase() + if err != nil { + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "压缩失败: " + err.Error(), + }) + return + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "数据库压缩完成", + }) +} + +// HandleStatus 状态页面处理 +func HandleStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + uptime := time.Since(startTime) + + // 格式化运行时间 + days := int(uptime.Hours() / 24) + hours := int(uptime.Hours()) % 24 + minutes := int(uptime.Minutes()) % 60 + seconds := int(uptime.Seconds()) % 60 + var uptimeStr string + if days > 0 { + uptimeStr = fmt.Sprintf("%d天 %d小时 %d分钟 %d秒", days, hours, minutes, seconds) + } else if hours > 0 { + uptimeStr = fmt.Sprintf("%d小时 %d分钟 %d秒", hours, minutes, seconds) + } else if minutes > 0 { + uptimeStr = fmt.Sprintf("%d分钟 %d秒", minutes, seconds) + } else { + uptimeStr = fmt.Sprintf("%d秒", seconds) + } + + // 获取签名信息 + currentSign := algos.GlobalSignatureSwitcher.GetCurrentMethod() + + // 生成签名详情显示 + var signatureDetails string + if currentSign == "sign2" { + signatureDetails = "X-Gorgon + X-Khronos" + } else { + signatureDetails = "X-Gorgon + X-Ladon + X-Argus + X-Khronos" + } + + // 获取统计数据 + total := getTotal() + search := getSearch() + detail := getDetail() + catalog := getCatalog() + content := getContent() + manga := getManga() + audio := getAudio() + video := getVideo() + category := getCategory() + + // 获取设备密钥池状态 + poolStatus := getDeviceKeyPoolStatus() + + html := fmt.Sprintf(` + + + + + + 番茄小说API - 运行状态 + + + + +
+
+

番茄小说API

+

运行状态监控面板

+
+ +
+
+
● 运行状态
+
+ 服务器时间 + %s +
+
+ 运行时长 + %s +
+
+ 启动时间 + %s +
+
+ 签名方法 + %s +
+
+ 数据库大小 + %s +
+
+ 已用空间 + %s +
+
+ 空闲空间 + %s 立即压缩 +
+
+ +
+
◉ 设备密钥池
+
+ 池状态 + %s +
+
+ 总设备数 + %d +
+
+ 有效设备 + %d 可用 +
+
+ 无效设备 + %d +
+
+ 当前索引 + %d +
+
+ +
+
■ 请求统计
+
+ 总请求数 + %d +
+
+ 搜索请求 + %d +
+
+ 详情请求 + %d +
+
+ 目录请求 + %d +
+
+ 内容请求 + %d +
+
+ 漫画请求 + %d +
+
+ 音频请求 + %d +
+
+ 视频请求 + %d +
+
+ 分类请求 + %d +
+
+ +
+
◆ 缓存统计
+
+ 正文缓存数 + %d +
+
+ 正文缓存 + %s +
+
+ 自动清理 + %s +
+
+ 清理间隔 + %s +
+
+
+ +
+
◈ API 接口文档
+ + +
+
书籍相关
+ +
+
GET/search
+
◆ 搜索书籍
+
+
+ query + 必需 + 搜索关键字 +
+
+ offset + 可选 + 偏移量,默认0 +
+
+ tab_type + 可选 + 类型,默认3 +
+
+
+ +
+
GET/detail
+
◆ 获取书籍详情
+
+
+ book_id + 必需 + 书籍ID +
+
+
+ +
+
GET/catalog
+
◆ 获取书籍目录
+
+
+ book_id + 必需 + 书籍ID +
+
+
+ +
+
GET/multi-detail
+
◆ 批量获取书籍详情
+
+
+ book_id + 必需 + 书籍ID列表,逗号分隔 +
+
+
+
+ + +
+
内容相关
+ +
+
GET/content
+
◆ 获取章节正文内容
+
+
+ item_id + 必需 + 章节ID,支持路径参数或查询参数 +
+
+ text_mode + 可选 + 纯文本模式:1/true=纯文本,0/false=HTML +
+
+ image_mode + 可选 + 图片处理:1/true=保留图片,0/false=移除图片 +
+
+ review + 可选 + 段评功能:1=启用,0=禁用 +
+
+
+ +
+
GET/multi-content
+
◆ 批量获取多个章节正文
+
+
+ book_id + 必需 + 书籍ID +
+
+ item_ids + 必需 + 章节ID列表,多个ID用逗号分隔,如:123,456,789 +
+
+ text_mode + 可选 + 纯文本模式:1/true=纯文本,0/false=HTML +
+
+ image_mode + 可选 + 图片处理:1/true=保留图片,0/false=移除图片 +
+
+ ignore_failed + 可选 + 忽略失败章节:1/true=忽略,0/false=不忽略 +
+
+
+ +
+
GET/item-summary
+
◆ 获取章节摘要信息
+
+
+ book_id + 必需 + 书籍ID +
+
+ item_ids + 必需 + 章节ID列表,逗号分隔 +
+
+
+
+ + +
+
段评相关
+ +
+
GET/review
+
◆ 获取段落评论、回复
+
+
+ item_id + 必需 + 章节ID +
+
+ book_id + 必需 + 书籍ID +
+
+ para + 必需 + 段落索引(从0开始) +
+
+ comment_id + 可选 + 评论ID(获取回复时需要) +
+
+
+ +
+
GET/review_html
+
◆ 获取段落评论HTML页面
+
+
+ item_id + 必需 + 章节ID +
+
+ book_id + 必需 + 书籍ID +
+
+ para + 必需 + 段落索引(从0开始) +
+
+
+
+ + +
+
多媒体
+ +
+
GET/manga
+
◆ 获取漫画内容
+
+
+ item_ids + 必需 + 章节ID +
+
+ output + 可选 + 输出格式:url/proxy/data_uri,默认url +
+
+
+ +
+
GET/audio
+
◆ 获取音频信息
+
+
+ item_ids + 必需 + 章节ID +
+
+ tone_id + 可选 + 音色ID,默认1 +
+
+
+ +
+
GET/video
+
◆ 获取视频信息
+
+
+ item_ids + 必需 + 章节ID +
+
+
+
+ + +
+
分类
+ +
+
GET/category-front
+
◆ 获取分类首页
+
+
+ tab + 可选 + 标签页,默认0 +
+
+
+ +
+
GET/category-landing
+
◆ 获取分类详情页
+
+
+ category_id + 必需 + 分类ID +
+
+ offset + 可选 + 偏移量,默认0 +
+
+ limit + 可选 + 每页数量,默认20 +
+
+
+
+ + +
+
系统
+ +
+
GET/status
+
◆ 系统状态页面(当前页面)
+
+ +
+
GET/self-info
+
◆ 设备信息(包含敏感数据)
+
+
+
+
+ + +`, + time.Now().Format("2006-01-02 15:04:05"), + uptimeStr, + startTime.Format("2006-01-02 15:04:05"), + signatureDetails, + getDBTotalSize(), + getDBUsedSize(), + getDBFreeSize(), + poolStatus["status"], + poolStatus["total"], + poolStatus["valid"], + poolStatus["invalid"], + poolStatus["current_index"], + total, + search, + detail, + catalog, + content, + manga, + audio, + video, + category, + getDBCacheCount(), + getCacheStatus(), + getCleanupStatus(), + getCleanupInterval(), + ) + fmt.Fprint(w, html) +} + +// getCacheStatus 获取缓存状态 +func getCacheStatus() string { + cfg := config.GetConfig() + if cfg != nil && cfg.Cache.Enabled { + return "已启用" + } + return "已禁用" +} + +// getCleanupStatus 获取清理状态 +func getCleanupStatus() string { + cfg := config.GetConfig() + if cfg != nil && cfg.Cache.CleanupEnabled { + return "已启用" + } + return "已禁用" +} + +// getCleanupInterval 获取清理间隔 +func getCleanupInterval() string { + cfg := config.GetConfig() + if cfg != nil { + seconds := cfg.Cache.CleanupSeconds + if seconds == 0 { + return "不清理" + } else if seconds < 60 { + return fmt.Sprintf("%d秒", seconds) + } else if seconds < 3600 { + return fmt.Sprintf("%d分钟", seconds/60) + } else { + return fmt.Sprintf("%d小时", seconds/3600) + } + } + return "未知" +} + +// formatBytes 格式化字节大小 +func formatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +// getDBCacheCount 获取数据库缓存记录数 +func getDBCacheCount() int { + stats, err := fqapi.GetDatabaseStats() + if err != nil || stats == nil { + return 0 + } + if count, ok := stats["cache_count"].(int); ok { + return count + } + return 0 +} + +// getDBTotalSize 获取数据库总大小 +func getDBTotalSize() string { + stats, err := fqapi.GetDatabaseStats() + if err != nil || stats == nil { + return "未知" + } + if size, ok := stats["total_size_bytes"].(int64); ok { + return formatBytes(size) + } + return "未知" +} + +// getDBUsedSize 获取数据库已用空间 +func getDBUsedSize() string { + stats, err := fqapi.GetDatabaseStats() + if err != nil || stats == nil { + return "未知" + } + if size, ok := stats["used_size_bytes"].(int64); ok { + return formatBytes(size) + } + return "未知" +} + +// getDBFreeSize 获取数据库空闲空间(带颜色提示) +func getDBFreeSize() string { + stats, err := fqapi.GetDatabaseStats() + if err != nil || stats == nil { + return "未知" + } + freeSize, okFree := stats["free_size_bytes"].(int64) + totalSize, okTotal := stats["total_size_bytes"].(int64) + + if !okFree { + return "未知" + } + + if freeSize == 0 { + return "0 B" + } + + freeSizeStr := formatBytes(freeSize) + + // 如果有总大小,计算并显示百分比 + if okTotal && totalSize > 0 { + percentage := float64(freeSize) * 100 / float64(totalSize) + return fmt.Sprintf("%s (%.1f%%)", freeSizeStr, percentage) + } + + return freeSizeStr +} + +// getDBUsagePercentage 获取数据库使用率 +func getDBUsagePercentage() float64 { + stats, err := fqapi.GetDatabaseStats() + if err != nil || stats == nil { + return 0 + } + usedSize, okUsed := stats["used_size_bytes"].(int64) + totalSize, okTotal := stats["total_size_bytes"].(int64) + + if !okUsed || !okTotal || totalSize == 0 { + return 0 + } + + return float64(usedSize) * 100 / float64(totalSize) +} + +// getDeviceKeyPoolStatus 获取设备密钥池状态 +func getDeviceKeyPoolStatus() map[string]interface{} { + pool := fqapi.GetDeviceKeyPool() + + if !pool.IsEnabled() { + return map[string]interface{}{ + "status": "未启用", + "total": 0, + "valid": 0, + "invalid": 0, + "current_index": 0, + } + } + + stats := pool.GetStats() + return map[string]interface{}{ + "status": "已启用", + "total": stats["total"], + "valid": stats["valid"], + "invalid": stats["invalid"], + "current_index": stats["current_index"], + } +} +