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