Compare commits
8
Commits
a436bf58b7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6908b5441 | ||
|
|
4dee7a3c0c | ||
|
|
67aa9be3da | ||
|
|
cba14eae25 | ||
|
|
c3f8d64f0e | ||
|
|
9b9c501fa9 | ||
|
|
9ecbbbc6d8 | ||
|
|
9ba194ae97 |
@@ -2,7 +2,7 @@
|
||||
|
||||
统一接口服务。线上入口:`https://openapi.rrrosmontis.icu`
|
||||
|
||||
聚合层把 **pixiv 图片服务**、**bilibili 视频服务**与 **dufe 银杏签到码查询服务**收敛到单一对外入口。四个后端进程全部只监听 `127.0.0.1`,对外仅由 nginx 暴露 80/443——即"一个入口、零暴露端口"。
|
||||
聚合层把 **pixiv 图片服务**、**bilibili 视频服务**、**dufe 银杏签到码查询服务**与 **jm 漫画下载服务**收敛到单一对外入口。五个后端进程全部只监听 `127.0.0.1`,对外仅由 nginx 暴露 80/443——即"一个入口、零暴露端口"。
|
||||
|
||||
## 架构
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
├── /pixiv/get_image?id=xxx -> 127.0.0.1:9008 main.py 聚合层 -> 127.0.0.1:9007 pixiv
|
||||
├── /bilibili/get_video?bv=xxx -> 127.0.0.1:9008 main.py 聚合层 -> 127.0.0.1:9006 bilibili
|
||||
├── /dufe/ginkgostu_sign_in -> 127.0.0.1:9008 main.py 聚合层 -> 127.0.0.1:9005 dufe 签到码查询(GET|POST)
|
||||
├── /jm/get_comic?id=xxx -> 127.0.0.1:9008 main.py 聚合层 -> 127.0.0.1:9004 jm
|
||||
├── /img-master/ /img-original/ -> nginx 直接反代 i.pximg.net(无本地后端)
|
||||
└── / -> 127.0.0.1:9008(302 跳转文章页)
|
||||
```
|
||||
@@ -21,8 +22,9 @@
|
||||
|---|---|---|---|
|
||||
| openapi | `127.0.0.1:9008` | `source/main.py`(aiohttp) | 聚合层,纯转发,**唯一入口** |
|
||||
| pixiv | `127.0.0.1:9007` | `source/pixiv/pixiv.py`(Flask + waitress) | 图片代理 |
|
||||
| bilibili | `127.0.0.1:9006` | `source/bilibili/bilibili.py`(aiohttp + yt-dlp) | 视频代理 |
|
||||
| dufe 签到 | `127.0.0.1:9005` | `source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py`(Flask + waitress 16 线程) | 银杏(DUFE)签到码查询,`GET\|POST /dufe/ginkgostu_sign_in` |
|
||||
| bilibili | `127.0.0.1:9006` | `source/bilibili/bilibili.py`(aiohttp,官方 API 直取 + yt-dlp 后备) | 视频代理 |
|
||||
| dufe 签到 | `127.0.0.1:9005` | `source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py`(Flask + waitress 16 线程) | 银杏(DUFE)签到码查询,`GET\|POST /dufe/ginkgostu_sign_in`;`&mode=auto` 时拉取通知列表自动签到(与 bot ginkgo 同逻辑,连续重试,结果列表聚合返回) |
|
||||
| jm | `127.0.0.1:9004` | `source/jm/jm.py`(aiohttp + jmcomic) | 漫画下载打包(移植自 qqbot jm 模块),`GET /jm/get_comic?id=xxx[&range=起-止]` |
|
||||
| pixiv-token-refresh | — | `source/pixiv/refresh_token.py` | token 刷新守护(每 30–60 分钟随机) |
|
||||
| bilibili-refresh | — | `source/bilibili/refresh_cookie.py` | cookie 刷新守护(每 50–70 分钟随机) |
|
||||
|
||||
@@ -33,7 +35,7 @@
|
||||
|
||||
```
|
||||
source/
|
||||
├── main.py # 聚合层 (127.0.0.1:9008),四条路由:/ /pixiv/get_image /bilibili/get_video /dufe/ginkgostu_sign_in
|
||||
├── main.py # 聚合层 (127.0.0.1:9008),五条路由:/ /pixiv/get_image /bilibili/get_video /dufe/ginkgostu_sign_in /jm/get_comic
|
||||
├── pixiv/
|
||||
│ ├── pixiv.py # Pixiv 图片服务 (127.0.0.1:9007)
|
||||
│ ├── refresh_token.py # Pixiv OAuth token 刷新守护
|
||||
@@ -46,18 +48,24 @@ source/
|
||||
├── dufe/
|
||||
│ └── ginkgostu_sign_in/
|
||||
│ └── ginkgo_sign_in.py # 银杏(DUFE)签到码查询 (127.0.0.1:9005, GET|POST)
|
||||
├── jm/
|
||||
│ └── jm.py # JM 漫画下载打包服务 (127.0.0.1:9004)
|
||||
└── cache/ # 运行时生成,不入库
|
||||
├── pixiv/images # 图片缓存
|
||||
└── bilibili/ # 视频缓存
|
||||
├── bilibili/ # 视频缓存
|
||||
└── jm/ # jm 临时 ZIP(TTL 10 分钟,返回后删除)
|
||||
nginx/
|
||||
└── openapi.rrrosmontis.icu.conf # 统一入口(/pixiv/ /bilibili/ /img-master/ /img-original/)
|
||||
├── openapi.rrrosmontis.icu.conf # 统一入口(/pixiv/ /bilibili/ /img-master/ /img-original/,其余路径由 location / 兜底至聚合层;含按 IP 限流)
|
||||
├── 00-limits.conf # 限流 zone(limit_req_zone rate=10r/s、limit_conn_zone、429)——缺失会导致 nginx -t 失败
|
||||
└── 00-websocket.conf # $connection_upgrade map 定义——缺失会导致 nginx -t 失败
|
||||
systemd/
|
||||
├── openapi.service # 聚合层
|
||||
├── pixiv.service # Pixiv 图片服务(waitress --threads=8)
|
||||
├── bilibili.service # bilibili 视频服务
|
||||
├── bilibili-refresh.service # cookie 刷新守护
|
||||
├── pixiv-token-refresh.service # Pixiv token 刷新守护
|
||||
└── ginkgo_sign_in.service # 银杏(DUFE)签到码查询服务
|
||||
├── ginkgo_sign_in.service # 银杏(DUFE)签到码查询服务
|
||||
└── jm.service # JM 漫画下载打包服务
|
||||
docs/
|
||||
└── 部署与运维.md # 线上服务器的路径、缓存、迁移步骤与变更记录
|
||||
```
|
||||
@@ -77,7 +85,8 @@ IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
**bilibili(视频)**:以 **BV 号为键**缓存到 `source/cache/bilibili/{BV}.mp4`
|
||||
|
||||
- 命中即直接 `FileResponse`,**不再调用 yt-dlp**
|
||||
- 命中即直接 `FileResponse`,**不再下载**
|
||||
- 未命中时优先走 **官方 API 直取**(view → playurl → 媒体段;2026-10-22 起 hk 出口被页面 WAF 挑战,yt-dlp 页面提取失效),失败再回退 yt-dlp
|
||||
- 未命中才下载,且先落到缓存目录内的隐藏暂存目录 `.dl-XXXX`,成功后 `os.replace` 原子落盘
|
||||
- **无论成功 / 失败 / 超时都会 `shutil.rmtree(tmpdir)`** —— 早期实现只清理 cookie 临时文件、从不清理视频暂存目录,导致每次取视频都把完整视频永久留在 `/tmp`(无上限磁盘泄漏),已修复
|
||||
- 淘汰策略:单文件 `CACHE_TTL_SECONDS`(默认 7 天)未访问即过期;总量超过 `CACHE_MAX_BYTES`(默认 2 GiB)时按 mtime 从最旧开始删除
|
||||
@@ -90,14 +99,23 @@ curl -s 127.0.0.1:9006/bilibili/cache_info
|
||||
# {cache_dir, cache_max_bytes, cache_ttl_seconds, count, total_bytes, free_bytes, files[]}
|
||||
```
|
||||
|
||||
**jm(漫画)**:**非持久缓存**,与 bilibili 不同——ZIP 仅作临时产物
|
||||
|
||||
- 请求 `id`(+ 可选 `range=起-止`,单次 ≤30 章)→ jmcomic 下载到 `cache/jm/` 内隐藏暂存目录 `.dl-<id>-XXXX` → webp 统一转 jpg 打包 ZIP(`{标题}_{id}_ch{起}-{止}.zip`)→ HTTP 返回
|
||||
- 打包完成即 `rmtree` 图片暂存目录;ZIP 在返回后 `ZIP_TTL_SECONDS`(默认 600 秒)延时删除
|
||||
- TTL 窗口内重复请求同一 ZIP 直接复用并重新计时;每次请求附带 `_cache_sweep()` 兜底清理过期 ZIP(防进程重启残留)
|
||||
- 同一本子串行处理(per-album asyncio.Lock),并发请求同本返回 409
|
||||
- 运维接口:`curl -s 127.0.0.1:9004/jm/cache_info`
|
||||
|
||||
## 部署
|
||||
|
||||
1. **Python 环境**:各服务共用一个环境,需含 `flask`、`waitress`、`aiohttp`、`httpx`、`yt-dlp`
|
||||
1. **Python 环境**:各服务共用一个环境,需含 `flask`、`waitress`、`aiohttp`、`httpx`、`yt-dlp`、`jmcomic`、`pillow`;系统包另需 **ffmpeg**(yt-dlp 合流用,API 直取路径不需要)
|
||||
2. **凭据**:
|
||||
- 复制 `source/pixiv/tokens.template.json` 为 `source/pixiv/tokens.json`(`chmod 600`)并填入实际 pixiv token
|
||||
- 放置 `source/bilibili/cookies.txt`(`name=value` 格式),或运行 `qr_login.py` 扫码生成
|
||||
- jm 无需凭据,但要求部署机可直连源站(jmcomic 会自动更新 API 域名)
|
||||
3. **服务**:将 `systemd/*.service` 安装到 `/etc/systemd/system/`,然后
|
||||
`systemctl daemon-reload && systemctl enable --now openapi pixiv bilibili ginkgo_sign_in bilibili-refresh pixiv-token-refresh`
|
||||
`systemctl daemon-reload && systemctl enable --now openapi pixiv bilibili ginkgo_sign_in jm bilibili-refresh pixiv-token-refresh`
|
||||
4. **nginx**:将 `nginx/openapi.rrrosmontis.icu.conf` 放入 `/etc/nginx/conf.d/`(需存在 `$connection_upgrade` 的 map 定义),`nginx -t && systemctl reload nginx`
|
||||
5. **软链**(可选):`ln -s <部署根>/source/cache/pixiv/images /home/images`
|
||||
6. **定时任务**:无 cron/timer——两个刷新守护均为常驻循环(内部随机间隔),systemd `Restart=always` 兜底
|
||||
@@ -109,19 +127,25 @@ curl -k "https://openapi.rrrosmontis.icu/pixiv/get_image?id=147113746"
|
||||
curl -k "https://openapi.rrrosmontis.icu/bilibili/get_video?bv=BV16a41157Q4"
|
||||
curl -kI "https://openapi.rrrosmontis.icu/bilibili/get_video?bv=BV16a41157Q4" # 只看头部,省流量
|
||||
curl -k "https://openapi.rrrosmontis.icu/dufe/ginkgostu_sign_in" # 无参应返回 400 JSON
|
||||
curl -k "https://openapi.rrrosmontis.icu/jm/get_comic?id=350234" -o jm.zip # 返回 ZIP
|
||||
curl -s 127.0.0.1:9006/bilibili/cache_info
|
||||
curl -s 127.0.0.1:9004/jm/cache_info
|
||||
```
|
||||
|
||||
端口暴露自检(四个后端都应"不可达",仅 443/80 通):
|
||||
端口暴露自检(五个后端都应"不可达",仅 443/80 通):
|
||||
|
||||
```bash
|
||||
for p in 9005 9006 9007 9008; do timeout 3 bash -c "cat < /dev/null > /dev/tcp/<host>/$p" 2>/dev/null \
|
||||
for p in 9004 9005 9006 9007 9008; do timeout 3 bash -c "cat < /dev/null > /dev/tcp/<host>/$p" 2>/dev/null \
|
||||
&& echo "$p 可连通(异常)" || echo "$p 不可达(正常)"; done
|
||||
```
|
||||
|
||||
## 版本要点
|
||||
|
||||
- **2026-09-13**:新增 **dufe 银杏签到码查询服务**(`source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py`,Flask + waitress 16 线程,`127.0.0.1:9005`,端点 `GET|POST /dufe/ginkgostu_sign_in`),并在聚合层 `main.py` 注册该路由——为此新增 `proxy_forward()`(保留请求方法与请求体,原 `proxy()` 保持固定 GET 不变,零改动既有行为)
|
||||
- **2026-09-23**
|
||||
- dufe 签到服务新增 **`mode=auto`**:拉取通知列表(getMessageAll + busiMessage/list)→ 按 bot ginkgo 同款规则发现签到活动(互动消息 + 文本含"签到" + 可解析课程链接,按 signInId 去重)→ 逐个自动签到;**连续重试**(首次 + 最多 5 次,间隔 3s,对齐 bot `MAX_SIGNIN_ATTEMPTS=6 / SIGNIN_BACKOFF`;token 疑似失效时重登录一次立即重试,对齐 bot refresh 语义);**所有结果聚合为 `data.results` 列表**返回(每活动含 success/message/attempts/title/signInCode 等)
|
||||
- 非签到码签到逻辑与 bot ginkgo 比对后以 bot 为基准同步:移植限流防护体系(`check_response` / `is_rate_limited` / `safe_error` / `_LoginErrors`)、`parse_course_url` 的 courseId 放宽至 `[0-9A-Za-z]+`、`commit_sign_in` 的错误包装、`fetch_sign_in_info` 的 check_response 与外部登录态注入(`sess`/`token` 参数,auto 模式请求内复用同一登录态)
|
||||
- **2026-09-23**(早些):新增 **jm 漫画下载打包服务**(`source/jm/jm.py`,aiohttp + jmcomic,`127.0.0.1:9004`,端点 `GET /jm/get_comic?id=xxx[&range=起-止]`),移植自 qqbot 的 jm 模块(去掉 QQ/代理轮换/上传 cf 逻辑,改为 HTTP 直返 ZIP;hk 可直连源站无需代理)。ZIP 为临时产物:返回后 600 秒延时删除,TTL 窗口内同名请求直接复用。聚合层 `main.py` 注册路由(新增 `JM_BACKEND`,复用既有 `proxy()`),nginx 无需改动(`location /` 兜底)
|
||||
- **2026-09-13**:新增 **dufe 银杏签到码查询服务**(`source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py`,Flask + waitress 16 线程,`127.0.0.1:9005`,端点 `GET|POST /dufe/ginkgostu_sign_in`),并在聚合层 `main.py` 注册该路由——为此新增 `proxy_forward()`(保留请求方法与请求体,原 `proxy()` 保持固定 GET 不变,零改动既有行为)。同期修复该服务两处缺陷:base64 参数补 `urllib.parse.unquote`(兼容 iOS 快捷指令的 `%3D` 编码),`commit` 判定改为 fail-closed(须 `commit.success is True` 才算签到成功)
|
||||
- **2026-09-12**
|
||||
- 端口重排 `9888/9088/19990 → 9008/9007/9006`,全部仅绑 `127.0.0.1`;pixiv 由 `0.0.0.0:9088` 收口
|
||||
- 主入口 `main.py` 从 `source/openapi/` 上移到 `source/`,systemd `WorkingDirectory` 同步为 `source/`
|
||||
|
||||
+73
-12
@@ -11,7 +11,8 @@
|
||||
|---|---|
|
||||
| `/pixiv/get_image?id=xxx` | `127.0.0.1:9008`(main.py 聚合层)→ `127.0.0.1:9007`(pixiv) |
|
||||
| `/bilibili/get_video?bv=xxx` | `127.0.0.1:9008`(main.py 聚合层)→ `127.0.0.1:9006`(bilibili) |
|
||||
| `/dufe/ginkgostu_sign_in` | `127.0.0.1:9008`(main.py 聚合层)→ `127.0.0.1:9005`(dufe 签到码查询,支持 GET\|POST) |
|
||||
| `/dufe/ginkgostu_sign_in` | `127.0.0.1:9008`(main.py 聚合层)→ `127.0.0.1:9005`(dufe 签到码查询,支持 GET\|POST;`&mode=auto` 自动签到) |
|
||||
| `/jm/get_comic?id=xxx[&range=起-止]` | `127.0.0.1:9008`(main.py 聚合层)→ `127.0.0.1:9004`(jm 漫画下载打包) |
|
||||
| `/img-master/`、`/img-original/` | nginx 直接反代 `i.pximg.net`(无本地后端) |
|
||||
| `/` | `127.0.0.1:9008`(302 跳转文章页) |
|
||||
|
||||
@@ -21,14 +22,15 @@
|
||||
|
||||
## 进程与端口
|
||||
|
||||
四个服务**全部只绑 `127.0.0.1`**,对外仅 nginx 的 443/80。
|
||||
五个服务**全部只绑 `127.0.0.1`**,对外仅 nginx 的 443/80。
|
||||
|
||||
| systemd 单元 | 源码 | 监听 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `openapi.service` | `source/main.py` | `:9008` | 聚合层(aiohttp,纯转发,唯一入口) |
|
||||
| `pixiv.service` | `source/pixiv/pixiv.py` | `:9007` | Flask,由 waitress(8 线程)承载 |
|
||||
| `bilibili.service` | `source/bilibili/bilibili.py` | `:9006` | aiohttp + yt-dlp |
|
||||
| `bilibili.service` | `source/bilibili/bilibili.py` | `:9006` | aiohttp,官方 API 直取 + yt-dlp 后备 |
|
||||
| `ginkgo_sign_in.service` | `source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py` | `:9005` | 银杏(DUFE)签到码查询,Flask + waitress(16 线程),`GET\|POST` |
|
||||
| `jm.service` | `source/jm/jm.py` | `:9004` | JM 漫画下载打包,aiohttp + jmcomic |
|
||||
| `pixiv-token-refresh.service` | `source/pixiv/refresh_token.py` | — | 常驻刷新 Pixiv OAuth token |
|
||||
| `bilibili-refresh.service` | `source/bilibili/refresh_cookie.py` | — | 常驻刷新 bilibili cookie |
|
||||
|
||||
@@ -43,17 +45,20 @@
|
||||
│ │ ├── refresh_token.py token 刷新守护
|
||||
│ │ └── tokens.json Pixiv OAuth token(敏感,权限 600,不入库)
|
||||
│ ├── bilibili/
|
||||
│ │ ├── bilibili.py 视频代理(aiohttp + yt-dlp)
|
||||
│ │ ├── bilibili.py 视频代理(aiohttp,API 直取 + yt-dlp 后备)
|
||||
│ │ ├── cookies.txt bilibili cookie(敏感,不入库)
|
||||
│ │ ├── refresh_cookie.py cookie 刷新守护
|
||||
│ │ └── qr_login*.py 扫码登录工具(cookie 失效时人工重登用)
|
||||
│ ├── dufe/
|
||||
│ │ └── ginkgostu_sign_in/
|
||||
│ │ └── ginkgo_sign_in.py 银杏(DUFE)签到码查询服务(127.0.0.1:9005,GET|POST)
|
||||
│ ├── jm/
|
||||
│ │ └── jm.py JM 漫画下载打包服务(127.0.0.1:9004)
|
||||
│ └── cache/ 运行时缓存(不入库)
|
||||
│ ├── pixiv/images pixiv 图片缓存(代码中为 ../cache/pixiv/images)
|
||||
│ └── bilibili/ bilibili 视频缓存(上限 2 GiB / TTL 7 天)
|
||||
├── systemd/ 6 个单元文件的归档副本(与 /etc/systemd/system/ 保持一致)
|
||||
│ ├── bilibili/ bilibili 视频缓存(上限 2 GiB / TTL 7 天)
|
||||
│ └── jm/ jm 临时 ZIP(TTL 10 分钟,返回后删除)
|
||||
├── systemd/ 7 个单元文件的归档副本(与 /etc/systemd/system/ 保持一致)
|
||||
├── nginx/ 宝塔导出的历史存档,非生效路径
|
||||
├── cert/ SSL 证书
|
||||
├── env/ Python 环境(general + env_general.tar.gz 整包)
|
||||
@@ -69,33 +74,46 @@
|
||||
**bilibili(视频)**:以 BV 号为键缓存到 `source/cache/bilibili/{BV}.mp4`
|
||||
|
||||
- 命中即复用、不再下载
|
||||
- 未命中才调 yt-dlp,先下载到缓存目录内的隐藏暂存目录 `.dl-XXXX`,成功后原子 rename 落盘
|
||||
- 未命中时优先走 **官方 API 直取**(view → playurl → 媒体段,不受页面 WAF 影响);失败回退 yt-dlp,均写入缓存目录内的隐藏暂存目录 `.dl-XXXX`,成功后原子 rename 落盘
|
||||
- 无论成功 / 失败 / 超时都 `rmtree` 该暂存目录
|
||||
(2026-09-12 修复:原实现从不清理 tmpdir,每次取视频都会把完整视频永久留在 `/tmp`,属无上限磁盘泄漏)
|
||||
- 淘汰策略:单文件 7 天未访问过期 + 总量超过 2 GiB 时按 mtime 从最旧开始删
|
||||
- 运维接口:`curl -s 127.0.0.1:9006/bilibili/cache_info`
|
||||
|
||||
**jm(漫画)**:**非持久临时产物**,移植自 qqbot 的 jm 模块(bj `C:/call/bot/utility/jm.py`)
|
||||
|
||||
- `GET /jm/get_comic?id=xxx[&range=起-止]`:jmcomic 下载 → `cache/jm/` 内隐藏暂存目录 `.dl-<id>-XXXX` → webp 统一转 jpg 打包 ZIP(`{标题}_{id}_ch{起}-{止}.zip`)→ HTTP 直返
|
||||
- 单次上限 30 章(与 qqbot 一致,同时避免超过聚合层 300s 超时);总章节 ≤30 可省略 range 自动全量
|
||||
- 打包完成即 `rmtree` 图片暂存目录;ZIP 在返回后 600 秒(`ZIP_TTL_SECONDS`)延时删除
|
||||
- TTL 窗口内同名 ZIP 重复请求直接复用并 touch 重新计时;每次请求附带 `_cache_sweep()` 兜底清理过期 ZIP(防进程重启残留)
|
||||
- 同一本子串行处理(per-album asyncio.Lock),并发请求同本返回 409
|
||||
- hk 直连源站,无需代理(jmcomic 自动更新 API 域名);qqbot 侧的代理轮换 / 上传 cf / QQ 回复逻辑均未带入
|
||||
- 运维接口:`curl -s 127.0.0.1:9004/jm/cache_info`
|
||||
|
||||
## nginx
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `/etc/nginx/conf.d/openapi.rrrosmontis.icu.conf` | 统一入口站点(`proxy_pass` → `127.0.0.1:9008`) |
|
||||
| `/etc/nginx/conf.d/openapi.rrrosmontis.icu.conf` | 统一入口站点(`proxy_pass` → `127.0.0.1:9008`;含按 IP 限流,见下) |
|
||||
| `/etc/nginx/conf.d/00-limits.conf` | 限流 zone 定义(`perip` 连接数 / `perip_req` 请求速率)+ 429 状态码 |
|
||||
| `/etc/nginx/conf.d/00-websocket.conf` | `$connection_upgrade` map 定义(**必须存在**) |
|
||||
| `/home/openapi/nginx/**` | 宝塔导出的历史存档,非生效路径 |
|
||||
|
||||
**按 IP 限流(2026-09-23 启用,轻防滥用档)**:域名 DNS 直连本机(无 CDN),`$binary_remote_addr` 即真实客户端 IP,后端全绑 `127.0.0.1` 使 nginx 成为唯一入口、限流无死角。参数:`limit_req_zone ... rate=10r/s` + `burst=20 nodelay`,`limit_conn perip 10`;超出返回 **429**。验证:30 并发请求中部分被拒(429),桶恢复后正常。调整档位改 `/etc/nginx/conf.d/00-limits.conf`(速率)与站点 conf(burst/并发)后 `nginx -t && systemctl reload nginx`。
|
||||
|
||||
## Python 环境
|
||||
|
||||
- `/home/openapi/env/general` —— 三服务共用(flask / waitress / aiohttp / httpx / yt-dlp)
|
||||
- `/home/openapi/env/general` —— 五服务共用(flask / waitress / aiohttp / httpx / yt-dlp / jmcomic / pillow)
|
||||
- `/home/openapi/env/env_general.tar.gz` —— 环境整包(迁移用)
|
||||
- 注意:pixiv 现由 **waitress** 承载(`pip install waitress`,纯 Python 线程池),不再使用 Flask 开发服务器
|
||||
(开发服务器是 thread-per-request 无上限,受控线程池更适合长期运行)
|
||||
|
||||
## 迁移到新服务器
|
||||
|
||||
1. **环境**:解压 `env/env_general.tar.gz`,直接使用其 `bin/python`(依赖齐全,含 waitress)
|
||||
2. **源码**:恢复 `source/{main.py,pixiv/,bilibili/,dufe/}` 到 `/home/openapi/source/`
|
||||
1. **环境**:解压 `env/env_general.tar.gz`,直接使用其 `bin/python`(依赖齐全,含 waitress);若整包过旧需补 `pip install jmcomic`;另装系统包 `ffmpeg`(yt-dlp 合流)
|
||||
2. **源码**:恢复 `source/{main.py,pixiv/,bilibili/,dufe/,jm/}` 到 `/home/openapi/source/`
|
||||
3. **systemd**:恢复 `systemd/*.service` 到 `/etc/systemd/system/`,`daemon-reload` + `enable` + `start`
|
||||
4. **nginx**:套用 `conf.d/openapi.rrrosmontis.icu.conf`(并确保 `00-websocket.conf` 的 map 存在)
|
||||
4. **nginx**:先建缓存目录 `mkdir -p /var/cache/nginx`(站点 conf 的 `proxy_cache_path` 需要父目录存在,否则 `nginx -t` 报 mkdir 失败),再套用 `nginx/` 下三个文件到 `/etc/nginx/conf.d/`:`openapi.rrrosmontis.icu.conf` + `00-websocket.conf`(`$connection_upgrade` map,**必须**)+ `00-limits.conf`(限流 zone,**必须**,否则站点 conf 的 `limit_req zone=perip_req` 会使 `nginx -t` 失败),再 `nginx -t && systemctl reload nginx`
|
||||
5. **SSL**:恢复 `cert/<域名>/` 到 `/etc/nginx/cert/<域名>/`
|
||||
6. **凭据**:放回 `source/pixiv/tokens.json`(600) 与 `source/bilibili/cookies.txt`
|
||||
7. **软链**:`ln -s /home/openapi/source/cache/pixiv/images /home/images`
|
||||
@@ -104,6 +122,7 @@
|
||||
curl -k "https://openapi.rrrosmontis.icu/pixiv/get_image?id=xxx"
|
||||
curl -k "https://openapi.rrrosmontis.icu/bilibili/get_video?bv=BV16a41157Q4"
|
||||
curl -k "https://openapi.rrrosmontis.icu/dufe/ginkgostu_sign_in" # 无参应返回 400 JSON
|
||||
curl -k "https://openapi.rrrosmontis.icu/jm/get_comic?id=350234" -o jm.zip # 返回 ZIP
|
||||
```
|
||||
|
||||
## 日志
|
||||
@@ -111,17 +130,59 @@
|
||||
```
|
||||
/var/log/openapi.log /var/log/pixiv.log /var/log/bilibili.log
|
||||
/var/log/ginkgo_sign_in.log /var/log/bilibili_refresh.log /var/log/pixiv_token_refresh.log
|
||||
/var/log/jm.log
|
||||
```
|
||||
|
||||
缓存命中/落盘会在 `bilibili.log` 打印 `cache HIT / MISS / STORE`(已加 `flush`,实时可见)。
|
||||
|
||||
## 变更记录
|
||||
|
||||
**2026-10-22(服务器重装重建 + bilibili 出口 WAF 修复)**
|
||||
|
||||
- hk 重装为 Ubuntu 22.04,按本文件从零重建:nginx + acme.sh(Let's Encrypt ECC)+ Python venv + 7 个 systemd 单元 + 凭据恢复;补充恢复时的两个坑位:
|
||||
- `proxy_cache_path /var/cache/nginx/...` 要求父目录存在 → 迁移步骤补 `mkdir -p /var/cache/nginx`
|
||||
- `yt-dlp` 合流需要系统包 `ffmpeg`
|
||||
- **bilibili 修复**:hk 出口访问 `www.bilibili.com` 视频页被 WAF 下发 412/JS 挑战(浏览器 UA 触发,API 客户端 UA 可过),页面无 `__INITIAL_STATE__`,yt-dlp 页面提取必然失败;改为**官方 API 直取**(`x/web-interface/view` → `x/player/playurl` → 媒体段下载),yt-dlp 保留后备;实测公网 200/1.3MB/6.5s
|
||||
- jm/dufe/pixiv/openapi 重建后实测正常;证书由 acme.sh 自动续期(cron 每 6h 检查)
|
||||
|
||||
**2026-09-23(仓库与线上一致性核对 + 完整性补齐)**
|
||||
|
||||
- 逐文件 md5 核对:源码/main.py、systemd 全部 7 个单元、ginkgo/jm/bilibili/refresh 各模块、站点 nginx conf 均与仓库一致
|
||||
- **补录漂移**:`source/pixiv/pixiv.py` 线上版为 2026-09-17 的"图源错误码处理"增强(官方 API 404/401/403/429 分别返回明确错误、`fetch_illust_url` 返回 api_status、不再对确定性错误做无意义备用源重试),此前未入库;本次以线上版为准补录
|
||||
- **仓库补齐恢复依赖**:新增 `nginx/00-limits.conf`(限流 zone,站点 conf 引用 `perip_req`/`perip`,缺失会使 `nginx -t` 失败)与 `nginx/00-websocket.conf`(`$connection_upgrade` map,同样必须);迁移步骤第 4 步已同步
|
||||
- 凭据快照另存于本机备份目录(`tokens.json`、`cookies.txt`,0600),不入库
|
||||
|
||||
**2026-09-23(dufe mode=auto + bot 基准同步)**
|
||||
|
||||
- dufe 签到服务新增 **`&mode=auto`**(url 参数可省略):SSO 登录一次后复用登录态 → 拉取通知列表(`GET /student/message/getMessageAll` pageSize=20/readFlag=0 + `POST /student/busiMessage/list`)→ 按 bot ginkgo 同款规则发现活动(仅互动消息、文本含"签到"、jumpUrl 可解析为 `/course/detail/.../signin/<id>/<type>`,query 不入库,按 signInId 去重)→ 按时间序逐个自动签到
|
||||
- **连续重试**:每活动首次 + 最多 5 次重试、间隔 3 秒(`MAX_SIGNIN_ATTEMPTS=6` / `SIGNIN_BACKOFF=(3,3,3,3,3)`,对齐 bot);上游错误/登录错时重登录一次立即重试(对齐 bot `_call_sign_in_local` 的 refresh 语义);**除限流外任何未确认成功都消耗重试次数**(对齐 bot `complete_signin`);限流即中断本轮,已完成结果照常返回(`data.rate_limited=true`)
|
||||
- **结果聚合**:`data.results` 列表逐活动返回(activityKey/signInId/type/typeName/title/signInCode/status/success/message/attempts),另附 total/succeeded/failed/activities 计数;**`attempts` 为逐次完整记录列表**(每次尝试 `{"n", "success", "message"}`,relogin 后立即重试的那次带 `"relogin": true`;成功与重试用尽两种情况都携带全部尝试)
|
||||
- **与 bot ginkgo 非签到码签到逻辑比对后以 bot 为基准同步**:
|
||||
- 移植限流防护体系:`RateLimitedError` / `_RATE_MESSAGE` / `_RATE_CODES` / `is_rate_limited` / `_valid_retry_after` / `_LoginErrors` / `check_response` / `safe_error` / `_request`(原实现完全无限流检测)
|
||||
- `parse_course_url`:courseId 由 `\d+` 放宽为 `[0-9A-Za-z]+`,报错文案对齐
|
||||
- `commit_sign_in`:经 `_request` 包装(`check_response`),失败消息 "request to InTheSign failed: %s" % safe_error(e)(原为 "upstream request failed")
|
||||
- `fetch_sign_in_info`:getSignInInfo 响应过 `check_response`;新增 `sess`/`token` 参数注入(auto 模式请求内复用登录态,省去每活动一次 SSO);补齐 "Malformed getSignInInfo response" 非 dict 校验
|
||||
- 验证:免凭据单测(发现/过滤正反用例)+ 假凭据 401 线路测试 + bj 真账密**只读**联调(登录 OK,notices=20/busi=18,发现 12 个活动含 1 个普通签到,类型/标题解析正确);未做真实提交(避免与运行中的 bot 轮询交叉签到)
|
||||
|
||||
**2026-09-23(早些)**
|
||||
|
||||
- 新增 **jm 漫画下载打包服务**:`source/jm/jm.py` + `jm.service`(`127.0.0.1:9004`,aiohttp + jmcomic,端点 `GET /jm/get_comic?id=xxx[&range=起-止]`)
|
||||
- 移植自 qqbot 的 jm 模块(bj `C:/call/bot/utility/jm.py`):保留章节范围解析 / 文件名清洗 / webp→jpg 打包逻辑;去掉 QQ 回复、代理轮换(hk 可直连源站)、上传 cf 文件服务器三部分,改为 HTTP 直返 ZIP
|
||||
- ZIP 为临时产物:返回后 600 秒延时删除(`asyncio` 延时任务 + 每次请求 `_cache_sweep()` 兜底清理,防进程重启残留);TTL 窗口内同名请求直接复用
|
||||
- 单次上限 30 章(`MAX_CHAPTERS`,与 qqbot 一致;也避免超过聚合层 `proxy()` 的 300s 超时)
|
||||
- 在聚合层 `main.py` 注册该端点:新增 `JM_BACKEND`,复用既有 `proxy()`(固定 GET),零改动既有行为
|
||||
- nginx **无需改动**:`location /` 兜底已把 `/jm/...` 转发给 `127.0.0.1:9008`(与 dufe 相同)
|
||||
- `env/general` 新增依赖 `jmcomic`(2.7.7,`pip install jmcomic`)
|
||||
|
||||
**2026-09-13**
|
||||
|
||||
- 新增 **dufe 银杏签到码查询服务**:`source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py` + `ginkgo_sign_in.service`(`127.0.0.1:9005`,Flask + waitress 16 线程,端点 `GET|POST /dufe/ginkgostu_sign_in`)
|
||||
- 在聚合层 `main.py` 注册该端点:新增 `DUFE_BACKEND` 与 `proxy_forward()`(保留请求方法/请求体;原 `proxy()` 固定 GET 行为未改动)
|
||||
- nginx **无需改动**:`location /` 兜底已把 `/dufe/...` 转发给 `127.0.0.1:9008`
|
||||
- 修复 `ginkgo_sign_in.py` 两处缺陷:
|
||||
- `_b64_decode` 先做 `urllib.parse.unquote`(iOS 快捷指令会把 base64 的 `=` 编码成 `%3D`,旧实现直接 `binascii.Error`);顺序为先 unquote、再 `" "→"+"`
|
||||
- `commit` 判定由 fail-open 改为 fail-closed:须 `isinstance(dict)` 且 `success is True`,否则一律判失败并返回 `msg = "sign-in failed: <原因>"`
|
||||
- `parse_course_url` 报错文案改为 `URL is not a sign-in page (missing /signin/<signInId>/<type>): <url>`
|
||||
|
||||
**2026-09-12**
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
limit_conn_zone $binary_remote_addr zone=perip:10m;
|
||||
limit_conn_zone $server_name zone=perserver:10m;
|
||||
|
||||
# 2026-09-23 新增:按 IP 请求速率限制(轻防滥用档)
|
||||
# openapi 域名 DNS 直连本机(无 CDN),$binary_remote_addr 即真实客户端 IP
|
||||
limit_req_zone $binary_remote_addr zone=perip_req:10m rate=10r/s;
|
||||
limit_req_status 429;
|
||||
limit_conn_status 429;
|
||||
@@ -0,0 +1,4 @@
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
@@ -31,6 +31,10 @@ server {
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
|
||||
# ---- 按 IP 限流(轻防滥用档,2026-09-23):10r/s burst 20、并发连接 10,超出 429 ----
|
||||
limit_req zone=perip_req burst=20 nodelay;
|
||||
limit_conn perip 10;
|
||||
|
||||
resolver 8.8.8.8 1.1.1.1 ipv6=off valid=300s;
|
||||
|
||||
# ---- 图片反代 i.pximg.net(原 apixiv.bu4.ren 逻辑,路径前缀 /img-master/)----
|
||||
|
||||
+112
-34
@@ -14,6 +14,14 @@ from aiohttp import web
|
||||
COOKIE_FILE = str(Path(__file__).parent / "cookies.txt")
|
||||
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
||||
|
||||
# 2026-10-22: hk 出口被 bilibili WAF 下发 412 / JS 挑战页(浏览器 UA 触发),
|
||||
# 页面里没有可用的 __INITIAL_STATE__,yt-dlp 的页面提取必然失败。
|
||||
# 实测 API 客户端 UA(yt-dlp/x、curl/x)可通过,故主路径改为官方 API 直取;
|
||||
# yt-dlp 保留为后备(其他出口/未来 WAF 策略变化时仍可工作)。
|
||||
API_UA = "yt-dlp/2026.10.21"
|
||||
VIEW_API = "https://api.bilibili.com/x/web-interface/view"
|
||||
PLAYURL_API = "https://api.bilibili.com/x/player/playurl"
|
||||
|
||||
# ---------------- 本地缓存(与 pixiv 同级:source/cache/bilibili) ----------------
|
||||
CACHE_DIR = (Path(__file__).resolve().parent / "../cache/bilibili").resolve()
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -144,6 +152,60 @@ def decode_url(url_b64):
|
||||
return base64.b64decode(url_b64.encode()).decode()
|
||||
|
||||
|
||||
def _api_headers():
|
||||
return {"User-Agent": API_UA, "Referer": "https://www.bilibili.com/"}
|
||||
|
||||
|
||||
def _api_get(url, params, what="bilibili api"):
|
||||
import requests
|
||||
r = requests.get(url, params=params, headers=_api_headers(), timeout=30)
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
if not isinstance(j, dict) or j.get("code") != 0:
|
||||
raise RuntimeError("%s failed: code=%s message=%s" % (
|
||||
what, (j or {}).get("code"), (j or {}).get("message")))
|
||||
return j.get("data") or {}
|
||||
|
||||
|
||||
def _download_via_api(bvid, work_dir):
|
||||
"""官方 API 直取(view -> playurl -> 媒体段),返回落盘路径。
|
||||
|
||||
hk 出口被页面 WAF 挑战时 yt-dlp 无法工作,此路径只走 api.bilibili.com
|
||||
与媒体 CDN,实测不受该挑战影响。"""
|
||||
import requests
|
||||
|
||||
data = _api_get(VIEW_API, {"bvid": bvid}, "view")
|
||||
cid = data.get("cid")
|
||||
if not cid:
|
||||
raise RuntimeError("view api returned no cid")
|
||||
play = _api_get(PLAYURL_API, {
|
||||
"bvid": bvid, "cid": cid, "qn": 80, "fnval": 0, "fourk": 1,
|
||||
}, "playurl")
|
||||
durls = play.get("durl") or []
|
||||
if not durls:
|
||||
raise RuntimeError("playurl api returned no durl segments")
|
||||
|
||||
out = Path(work_dir) / f"{bvid}.mp4"
|
||||
try:
|
||||
with open(out, "wb") as f:
|
||||
for seg in durls:
|
||||
with requests.get(seg["url"], headers=_api_headers(),
|
||||
stream=True, timeout=120) as r:
|
||||
r.raise_for_status()
|
||||
for chunk in r.iter_content(chunk_size=256 * 1024):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
if out.stat().st_size == 0:
|
||||
raise RuntimeError("empty media file")
|
||||
return out
|
||||
except Exception:
|
||||
try:
|
||||
out.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
async def get_video(url_b64=None, bv=None):
|
||||
if bv:
|
||||
bvid = bv
|
||||
@@ -170,49 +232,65 @@ async def get_video(url_b64=None, bv=None):
|
||||
print(f"[bilibili] cache HIT {bvid} -> {cached.name} ({cached.stat().st_size} B)", flush=True)
|
||||
return _file_response(cached, bvid)
|
||||
|
||||
cookie_tmp = _prepare_cookie_file()
|
||||
if not cookie_tmp:
|
||||
return web.json_response({"error": "missing bilibili cookie"}, status=500)
|
||||
|
||||
print(f"[bilibili] cache MISS {bvid}, downloading...", flush=True)
|
||||
# 2) 下载到缓存目录内的隐藏临时目录(同盘,才能原子 rename 落盘)
|
||||
tmpdir = tempfile.mkdtemp(prefix=".dl-", dir=CACHE_DIR)
|
||||
output_template = os.path.join(tmpdir, "video.%(ext)s")
|
||||
cmd = [
|
||||
os.path.join(os.path.dirname(sys.executable), "yt-dlp"),
|
||||
"--cookies", cookie_tmp,
|
||||
"--add-header", f"User-Agent:{UA}",
|
||||
"--add-header", "Referer:https://www.bilibili.com/",
|
||||
"-f", "bestvideo*+bestaudio/best",
|
||||
"--merge-output-format", "mp4",
|
||||
"-o", output_template,
|
||||
f"https://www.bilibili.com/video/{bvid}",
|
||||
]
|
||||
cookie_tmp = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
# 2a) 主路径:官方 API 直取(绕开页面 WAF,不依赖 cookie/yt-dlp)
|
||||
video_path = None
|
||||
api_err = None
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return web.json_response({"error": "download timeout"}, status=504)
|
||||
video_path = await asyncio.to_thread(_download_via_api, bvid, tmpdir)
|
||||
print(f"[bilibili] api download OK {bvid}", flush=True)
|
||||
except Exception as e:
|
||||
api_err = e
|
||||
print(f"[bilibili] api download failed {bvid}: {e}; fallback yt-dlp", flush=True)
|
||||
|
||||
video_files = [p for p in Path(tmpdir).glob("*") if p.is_file()]
|
||||
if proc.returncode != 0 and not video_files:
|
||||
err_msg = stderr.decode()[-1000:] if stderr else "unknown error"
|
||||
return web.json_response(
|
||||
{"error": "download failed", "detail": err_msg},
|
||||
status=502,
|
||||
# 2b) 后备:yt-dlp(需要 cookie 文件)
|
||||
if video_path is None:
|
||||
cookie_tmp = _prepare_cookie_file()
|
||||
if not cookie_tmp:
|
||||
return web.json_response(
|
||||
{"error": "download failed", "detail": "api path failed and no cookie for yt-dlp fallback: %s" % api_err},
|
||||
status=502,
|
||||
)
|
||||
output_template = os.path.join(tmpdir, "video.%(ext)s")
|
||||
cmd = [
|
||||
os.path.join(os.path.dirname(sys.executable), "yt-dlp"),
|
||||
"--cookies", cookie_tmp,
|
||||
"--add-header", f"User-Agent:{UA}",
|
||||
"--add-header", "Referer:https://www.bilibili.com/",
|
||||
"-f", "bestvideo*+bestaudio/best",
|
||||
"--merge-output-format", "mp4",
|
||||
"-o", output_template,
|
||||
f"https://www.bilibili.com/video/{bvid}",
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return web.json_response({"error": "download timeout"}, status=504)
|
||||
|
||||
if not video_files:
|
||||
return web.json_response({"error": "no output file"}, status=502)
|
||||
video_files = [p for p in Path(tmpdir).glob("*") if p.is_file()]
|
||||
if proc.returncode != 0 and not video_files:
|
||||
err_msg = stderr.decode()[-1000:] if stderr else "unknown error"
|
||||
return web.json_response(
|
||||
{"error": "download failed", "detail": "api: %s | yt-dlp: %s" % (api_err, err_msg)},
|
||||
status=502,
|
||||
)
|
||||
|
||||
if not video_files:
|
||||
return web.json_response({"error": "no output file"}, status=502)
|
||||
|
||||
video_path = max(video_files, key=lambda p: p.stat().st_size)
|
||||
|
||||
video_path = max(video_files, key=lambda p: p.stat().st_size)
|
||||
ext = video_path.suffix.lower()
|
||||
final = CACHE_DIR / f"{bvid}{ext}"
|
||||
try:
|
||||
|
||||
@@ -6,16 +6,27 @@ Ginkgo (DUFE) sign-in code query service.
|
||||
Endpoint: GET|POST /dufe/ginkgostu_sign_in
|
||||
params: username, password(base64), url(base64)
|
||||
[commit=true] optional, default true: also POST the code back
|
||||
[mode=auto] optional: fetch the notification list, discover
|
||||
sign-in activities and auto sign-in each one
|
||||
(bot ginkgo parity, consecutive retries);
|
||||
results returned as an aggregated list.
|
||||
mode=auto 时 url 参数可省略。
|
||||
Listen: 127.0.0.1:9005 (WSGI: waitress)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import html as _html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from html.parser import HTMLParser
|
||||
|
||||
try:
|
||||
import requests
|
||||
@@ -42,6 +53,17 @@ GINKGO = "https://ginkgostu.dufe.edu.cn"
|
||||
SSO = "https://sso.dufe.edu.cn"
|
||||
CUSTOM_CODE = "kb_dconline"
|
||||
|
||||
NOTICE_PAGE = GINKGO + "/notice"
|
||||
SYSTEM_PAGE = GINKGO + "/notice/system"
|
||||
|
||||
PAGE_SIZE = 20 # 单类单次抓取条数(与 bot ginkgo 一致)
|
||||
# 自动签到重试参数(与 bot ginkgo 的 MAX_SIGNIN_ATTEMPTS / SIGNIN_BACKOFF 一致:
|
||||
# 首次 + 最多 5 次重试,每次间隔 3 秒)
|
||||
MAX_SIGNIN_ATTEMPTS = 6
|
||||
SIGNIN_BACKOFF = (3, 3, 3, 3, 3)
|
||||
|
||||
TZ8 = timezone(timedelta(hours=8))
|
||||
|
||||
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
|
||||
|
||||
@@ -64,13 +86,183 @@ class AuthError(Exception):
|
||||
|
||||
|
||||
class NoSignInCodeError(Exception):
|
||||
"""The sign-in activity exists but has no code configured."""
|
||||
"""The numeric-code activity exists but has no code configured."""
|
||||
|
||||
|
||||
class UnsupportedSigninType(Exception):
|
||||
"""The activity requires a flow this service cannot safely automate."""
|
||||
|
||||
|
||||
SIGNIN_TYPE_NAMES = {
|
||||
0: "二维码签到",
|
||||
1: "数字码签到",
|
||||
2: "普通签到",
|
||||
3: "直播签到",
|
||||
}
|
||||
|
||||
|
||||
def signin_type_label(value):
|
||||
try:
|
||||
signin_type = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return "未知签到类型"
|
||||
return SIGNIN_TYPE_NAMES.get(signin_type, "未知签到类型(%s)" % signin_type)
|
||||
|
||||
|
||||
class UpstreamError(Exception):
|
||||
"""Remote service returned an unexpected or erroneous response."""
|
||||
|
||||
|
||||
class RateLimitedError(Exception):
|
||||
"""An upstream explicitly refused requests due to throttling."""
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Rate-limit detection & credential-safe errors
|
||||
# (synced from bot ginkgostu.py 2026-09-23; bot is the baseline)
|
||||
# ------------------------------------------------------------------------------
|
||||
_RATE_MESSAGE = re.compile(
|
||||
r"too\s+many\s+requests|rate[\s_-]*limit(?:ed|ing|\s+exceeded)?|"
|
||||
r"request(?:s)?\s+throttl|throttl(?:ed|ing)|"
|
||||
r"(?:请求|访问|操作|登录|登陆|接口|提交).{0,12}(?:过于频繁|太频繁|频繁|频率.{0,6}(?:超|限制))|"
|
||||
r"(?:限流|请求次数.{0,8}(?:超限|超出|超过)|访问次数.{0,8}(?:超限|超出|超过))",
|
||||
re.I,
|
||||
)
|
||||
_RATE_CODES = {"429", "4290", "40029", "90029", "RATE_LIMIT", "RATE_LIMITED", "RATE_LIMIT_EXCEEDED",
|
||||
"TOO_MANY_REQUESTS", "REQUEST_TOO_FREQUENT", "THROTTLED",
|
||||
"THROTTLING", "FREQUENT_REQUESTS"}
|
||||
_FIELDS = ("code", "msg", "message", "error")
|
||||
|
||||
|
||||
def _status(value):
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def _explicit(value, depth=0):
|
||||
if depth > 3:
|
||||
return False
|
||||
if isinstance(value, dict):
|
||||
for key in _FIELDS:
|
||||
item = value.get(key)
|
||||
if key == "code" and isinstance(item, (str, int)):
|
||||
if str(item).strip().upper().replace("-", "_") in _RATE_CODES:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
if _explicit(item, depth + 1):
|
||||
return True
|
||||
elif isinstance(item, str) and _RATE_MESSAGE.search(item[:1024]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class _LoginErrors(HTMLParser):
|
||||
"""Extract only explicitly marked login error elements; ignore scripts/notices."""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.stack = []
|
||||
self.messages = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
marker = (attrs.get("id", "") + " " + attrs.get("class", "")).lower()
|
||||
explicit = bool(re.search(r"(?:^|[\s_-])(?:error|errors|errormsg|errormessage)(?:$|[\s_-])", marker))
|
||||
ignored = tag in ("script", "style", "template") or bool(self.stack and self.stack[-1][2])
|
||||
active = explicit or bool(self.stack and self.stack[-1][1])
|
||||
if tag not in ("input", "br", "img", "meta", "link", "hr", "wbr", "source"):
|
||||
self.stack.append((tag, active, ignored))
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
for index in range(len(self.stack) - 1, -1, -1):
|
||||
if self.stack[index][0] == tag:
|
||||
del self.stack[index:]
|
||||
break
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.stack and self.stack[-1][1] and not self.stack[-1][2]:
|
||||
self.messages.append(data)
|
||||
|
||||
|
||||
def is_rate_limited(status, payload=None, text=""):
|
||||
"""Check HTTP status and explicit error fields, never arbitrary payload data/HTML."""
|
||||
status = _status(status)
|
||||
if status == 429 or _explicit(payload):
|
||||
return True
|
||||
# Plain response bodies are evidence only on HTTP errors, and must be short.
|
||||
if status >= 400 and isinstance(text, str):
|
||||
plain = text.strip()
|
||||
if len(plain) <= 1024 and not re.search(r"<[^>]+>", plain):
|
||||
return bool(_RATE_MESSAGE.search(plain))
|
||||
return False
|
||||
|
||||
|
||||
def _valid_retry_after(value):
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return False
|
||||
value = value.strip()
|
||||
if re.fullmatch(r"[0-9]+", value):
|
||||
return True
|
||||
try:
|
||||
return parsedate_to_datetime(value).tzinfo is not None
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return False
|
||||
|
||||
|
||||
def check_response(response, what="upstream"):
|
||||
"""Return response unchanged, or raise RateLimitedError before caller parsing.
|
||||
|
||||
`what` is deliberately not interpolated: URLs and upstream bodies may contain
|
||||
credentials. Safe to use as a requests response hook through a small lambda.
|
||||
"""
|
||||
if response is None:
|
||||
return response
|
||||
status = _status(getattr(response, "status_code", 0))
|
||||
message = "上游请求过于频繁,请稍后重试"
|
||||
if status == 429:
|
||||
raise RateLimitedError(message)
|
||||
headers = getattr(response, "headers", {}) or {}
|
||||
retry_after = next((v for k, v in headers.items() if str(k).lower() == "retry-after"), None)
|
||||
if status >= 400 and _valid_retry_after(retry_after):
|
||||
raise RateLimitedError(message)
|
||||
try:
|
||||
payload = response.json()
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
payload = None
|
||||
text = getattr(response, "text", "") or ""
|
||||
if is_rate_limited(status, payload, text):
|
||||
raise RateLimitedError(message)
|
||||
# Login pages can return HTTP 200 with an explicit error element. Never scan
|
||||
# the whole successful HTML document, inline scripts, or announcement text.
|
||||
if payload is None and isinstance(text, str) and "<" in text:
|
||||
parser = _LoginErrors()
|
||||
try:
|
||||
parser.feed(text[:262144])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if _RATE_MESSAGE.search(" ".join(parser.messages)[:1024]):
|
||||
raise RateLimitedError(message)
|
||||
return response
|
||||
|
||||
|
||||
def safe_error(exception):
|
||||
"""Propagate explicit throttling, otherwise return a credential-free diagnostic.
|
||||
|
||||
Never stringify arbitrary exceptions: requests URLs, query strings, tokens,
|
||||
passwords and JSON decode snippets can all include secrets.
|
||||
"""
|
||||
if isinstance(exception, RateLimitedError):
|
||||
raise exception
|
||||
response = getattr(exception, "response", None)
|
||||
if response is not None:
|
||||
check_response(response, "request error")
|
||||
status = _status(getattr(response, "status_code", 0))
|
||||
if status:
|
||||
return "upstream HTTP %d" % status
|
||||
return "upstream request or response error (%s)" % type(exception).__name__
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Part 1: Pure-python AES-128
|
||||
# ==============================================================================
|
||||
@@ -246,11 +438,20 @@ def new_session():
|
||||
|
||||
|
||||
def parse_course_url(url):
|
||||
m = re.search(
|
||||
r"/course/detail/(?P<courseId>\d+)/(?P<batchNo>[^/]+)/"
|
||||
r"(?P<weekTimeId>\d+)/signin/(?P<signInId>\d+)/(?P<type>\d+)", url)
|
||||
if not m:
|
||||
raise ValueError("Cannot parse course URL: %s" % url)
|
||||
parts = urllib.parse.urlsplit(str(url or ""))
|
||||
m = re.fullmatch(
|
||||
r"/course/detail/(?P<courseId>[0-9A-Za-z]+)/(?P<batchNo>[^/]+)/"
|
||||
r"(?P<weekTimeId>\d+)/signin/(?P<signInId>\d+)/(?P<type>\d+)/?",
|
||||
parts.path)
|
||||
try:
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
port = -1
|
||||
if (parts.scheme != "https" or parts.username is not None or parts.password is not None
|
||||
or port not in (None, 443)
|
||||
or (parts.hostname or "").lower() != "ginkgostu.dufe.edu.cn" or not m):
|
||||
raise ValueError(
|
||||
"URL is not a sign-in page (missing /signin/<signInId>/<type>)")
|
||||
return m.groupdict()
|
||||
|
||||
|
||||
@@ -319,18 +520,46 @@ def exchange_token(sess, cfg, code):
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||
try:
|
||||
j = r.json()
|
||||
except ValueError as e:
|
||||
raise UpstreamError("authLogin returned non-JSON: %s" % e)
|
||||
if j.get("code") != "2000":
|
||||
raise AuthError("OAuth code exchange refused: %s"
|
||||
% json.dumps(j, ensure_ascii=False))
|
||||
except ValueError:
|
||||
raise UpstreamError("authLogin returned non-JSON") from None
|
||||
if not isinstance(j, dict) or j.get("code") != "2000":
|
||||
raise AuthError("OAuth code exchange refused")
|
||||
return j["data"]["token"]
|
||||
|
||||
|
||||
def login(username, password):
|
||||
"""Complete SSO login flow. Returns (sess, token).
|
||||
|
||||
auto 模式一次登录后复用同一 session/token 逐个活动签到,
|
||||
对齐 bot ginkgo 复用轮询缓存登录态的思路(请求内级)。"""
|
||||
sess = new_session()
|
||||
cfg = get_unify_config(sess)
|
||||
csrf, page_url = open_authorize(sess, cfg)
|
||||
slider = exploit_slider(sess, page_url)
|
||||
resp = submit_login(sess, page_url, csrf, username, password, slider, page_url)
|
||||
|
||||
loc = resp.headers.get("Location", "")
|
||||
m = re.search(r"[?&]code=([A-Za-z0-9_\-]+)", loc)
|
||||
if not m:
|
||||
raise AuthError(
|
||||
"Login failed: no OAuth code in redirect (HTTP %s)" % resp.status_code)
|
||||
token = exchange_token(sess, cfg, m.group(1))
|
||||
return sess, token
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Part 4: Sign-in API
|
||||
# ==============================================================================
|
||||
|
||||
def _request(sess, method, *args, **kwargs):
|
||||
"""Also guard fake sessions and HTTPError.response before any JSON parsing."""
|
||||
try:
|
||||
response = getattr(sess, method)(*args, **kwargs)
|
||||
except requests.RequestException as exc:
|
||||
raise UpstreamError(safe_error(exc)) from None
|
||||
return check_response(response, "signin")
|
||||
|
||||
|
||||
def api_headers(token, fingerprint=None):
|
||||
return {
|
||||
"User-Agent": UA,
|
||||
@@ -343,8 +572,8 @@ def api_headers(token, fingerprint=None):
|
||||
|
||||
|
||||
def call_api(sess, path, data, token):
|
||||
return sess.post(GINKGO + path, data=data, timeout=20,
|
||||
headers=api_headers(token))
|
||||
return _request(sess, "post", GINKGO + path, data=data, timeout=20,
|
||||
headers=api_headers(token))
|
||||
|
||||
|
||||
def commit_sign_in(sess, token, sign_in_id, batch_no, referer, code):
|
||||
@@ -366,14 +595,14 @@ def commit_sign_in(sess, token, sign_in_id, batch_no, referer, code):
|
||||
headers["Referer"] = referer
|
||||
|
||||
try:
|
||||
r = sess.post(
|
||||
r = _request(sess, "post",
|
||||
GINKGO + "/student/signIn/InTheSign",
|
||||
data={"signInId": str(sign_in_id), "code": str(code)},
|
||||
timeout=20,
|
||||
headers=headers,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
return False, "request to InTheSign failed: %s" % e, None
|
||||
except (requests.RequestException, UpstreamError) as e:
|
||||
return False, "request to InTheSign failed: %s" % safe_error(e), None
|
||||
|
||||
if r.status_code != 200:
|
||||
return False, "InTheSign returned HTTP %d" % r.status_code, None
|
||||
@@ -381,76 +610,372 @@ def commit_sign_in(sess, token, sign_in_id, batch_no, referer, code):
|
||||
try:
|
||||
j = r.json()
|
||||
except ValueError:
|
||||
return False, "InTheSign returned non-JSON: %s" % r.text[:200], None
|
||||
return False, "InTheSign returned non-JSON", None
|
||||
|
||||
if not isinstance(j, dict):
|
||||
return False, "Malformed InTheSign response", None
|
||||
api_code = str(j.get("code", ""))
|
||||
server_msg = (j.get("message") or j.get("msg") or "").strip()
|
||||
|
||||
if api_code == "2000":
|
||||
return True, server_msg or "accepted", j
|
||||
|
||||
if server_msg:
|
||||
reason = server_msg
|
||||
elif api_code:
|
||||
reason = "upstream returned code %s" % api_code
|
||||
else:
|
||||
reason = ("unrecognized upstream response: %s"
|
||||
% json.dumps(j, ensure_ascii=False)[:200])
|
||||
return False, reason, j
|
||||
return True, "accepted", j
|
||||
# Never return/log arbitrary upstream messages or bodies: they may contain secrets.
|
||||
return False, "InTheSign refused the sign-in request", j
|
||||
|
||||
|
||||
def fetch_sign_in_info(username, password, course_url, do_commit=True):
|
||||
def fetch_sign_in_info(username, password, course_url, do_commit=True,
|
||||
sess=None, token=None):
|
||||
route = parse_course_url(course_url)
|
||||
sign_in_id = route["signInId"]
|
||||
batch_no = route.get("batchNo")
|
||||
|
||||
sess = new_session()
|
||||
cfg = get_unify_config(sess)
|
||||
csrf, page_url = open_authorize(sess, cfg)
|
||||
slider = exploit_slider(sess, page_url)
|
||||
resp = submit_login(sess, page_url, csrf, username, password, slider, page_url)
|
||||
|
||||
loc = resp.headers.get("Location", "")
|
||||
m = re.search(r"[?&]code=([A-Za-z0-9_\-]+)", loc)
|
||||
if not m:
|
||||
raise AuthError(
|
||||
"Login failed: no OAuth code in redirect (HTTP %s)" % resp.status_code)
|
||||
auth_code = m.group(1)
|
||||
|
||||
token = exchange_token(sess, cfg, auth_code)
|
||||
if sess is None or not token:
|
||||
# 无外部登录态时自登录(保持向后兼容):直接复用上方 SSO 流程
|
||||
sess, token = login(username, password)
|
||||
# 否则:sess/token 由调用方提供(auto 模式请求内复用同一登录态),跳过 SSO 登录
|
||||
|
||||
r = call_api(sess, "/student/signIn/getSignInInfo",
|
||||
{"signInId": sign_in_id}, token)
|
||||
check_response(r, "getSignInInfo")
|
||||
try:
|
||||
j = r.json()
|
||||
except ValueError as e:
|
||||
raise UpstreamError("getSignInInfo returned non-JSON: %s" % e)
|
||||
except ValueError:
|
||||
raise UpstreamError("getSignInInfo returned non-JSON") from None
|
||||
|
||||
if not isinstance(j, dict):
|
||||
raise UpstreamError("Malformed getSignInInfo response")
|
||||
data = j.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
raise UpstreamError("Malformed getSignInInfo data")
|
||||
model = data.get("signInModel") or {}
|
||||
if not isinstance(model, dict):
|
||||
raise UpstreamError("Malformed getSignInInfo model")
|
||||
code = model.get("code")
|
||||
raw_type = model.get("type")
|
||||
if raw_type is None or raw_type == "":
|
||||
raw_type = route.get("type")
|
||||
try:
|
||||
signin_type = int(raw_type)
|
||||
except (TypeError, ValueError):
|
||||
raise UpstreamError("Malformed sign-in type") from None
|
||||
type_name = signin_type_label(signin_type)
|
||||
|
||||
if code is None or code == "":
|
||||
raise NoSignInCodeError(
|
||||
"Sign-in activity %s has no code configured" % sign_in_id)
|
||||
if signin_type == 1 and (code is None or code == ""):
|
||||
raise NoSignInCodeError("数字码签到未配置签到码")
|
||||
if signin_type == 2:
|
||||
code = ""
|
||||
|
||||
result = {
|
||||
"signInId": sign_in_id,
|
||||
"signInCode": code,
|
||||
"type": model.get("type"),
|
||||
"type": signin_type,
|
||||
"typeName": type_name,
|
||||
"title": model.get("title"),
|
||||
"status": data.get("signStatus"),
|
||||
}
|
||||
|
||||
if do_commit:
|
||||
ok, reason, _raw = commit_sign_in(
|
||||
sess, token, sign_in_id, batch_no, course_url, code
|
||||
)
|
||||
result["commit"] = {"success": ok, "message": reason}
|
||||
if signin_type not in (1, 2):
|
||||
result["commit"] = {
|
||||
"success": False,
|
||||
"message": "%s暂不支持自动签到,需特定流程" % type_name,
|
||||
}
|
||||
else:
|
||||
canonical_referer = GINKGO + urllib.parse.urlsplit(course_url).path
|
||||
ok, reason, _raw = commit_sign_in(
|
||||
sess, token, sign_in_id, batch_no, canonical_referer, code
|
||||
)
|
||||
result["commit"] = {"success": ok, "message": reason}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Part 4b: Auto sign-in (mode=auto)
|
||||
# 通知列表发现签到活动并逐个自动签到 —— 逻辑对齐 bot ginkgo 模块:
|
||||
# fetch_all(通知+互动消息)→ _validated_signin_item(busi + 文本含"签到" +
|
||||
# 可解析课程链接)→ 按 signInId 去重 → 每活动连续重试(首次+最多5次,间隔3s,
|
||||
# token 失效重登录一次)→ 结果聚合为列表返回。
|
||||
# ==============================================================================
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _html_to_text(raw):
|
||||
if not raw:
|
||||
return ""
|
||||
text = re.sub(r"(?i)<br\s*/?>", "\n", raw)
|
||||
text = re.sub(r"(?i)</(p|div|tr|li)>", "\n", text)
|
||||
text = _TAG_RE.sub("", text)
|
||||
text = _html.unescape(text)
|
||||
text = re.sub(r"[ \t\u3000]+", " ", text)
|
||||
text = re.sub(r"\n{2,}", "\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _parse_ts(value):
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return 0.0
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
m = re.search(r"([+-]\d{2})(\d{2})$", text)
|
||||
if m:
|
||||
text = text[:m.start()] + m.group(1) + ":" + m.group(2)
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.strptime(text[:19], "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return 0.0
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=TZ8)
|
||||
return dt.timestamp()
|
||||
|
||||
|
||||
def _normalize_notice(raw):
|
||||
title = (raw.get("title") or "").strip() or "(无标题)"
|
||||
ts = _parse_ts(raw.get("createTime"))
|
||||
return {
|
||||
"kind": "notice",
|
||||
"key": "N%s" % raw.get("id"),
|
||||
"id": str(raw.get("id") or ""),
|
||||
"title": title,
|
||||
"text": _html_to_text(raw.get("content") or ""),
|
||||
"ts": ts,
|
||||
"link": NOTICE_PAGE,
|
||||
"jump": "",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_busi(raw):
|
||||
text = _html_to_text(raw.get("content") or "")
|
||||
jump = (raw.get("jumpUrl") or "").strip()
|
||||
if jump.startswith("http"):
|
||||
link = jump
|
||||
elif jump.startswith("/"):
|
||||
link = GINKGO + jump
|
||||
else:
|
||||
link = SYSTEM_PAGE
|
||||
ts = _parse_ts(raw.get("createTime"))
|
||||
return {
|
||||
"kind": "busi",
|
||||
"key": "B%s" % (raw.get("uuid") or ""),
|
||||
"uuid": str(raw.get("uuid") or ""),
|
||||
"id": "",
|
||||
"title": text[:120] or "(无内容)",
|
||||
"text": text,
|
||||
"ts": ts,
|
||||
"link": link,
|
||||
"jump": jump,
|
||||
}
|
||||
|
||||
|
||||
def _api_headers(token):
|
||||
return {
|
||||
"User-Agent": UA,
|
||||
"Origin": GINKGO,
|
||||
"Referer": GINKGO + "/",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"token": token,
|
||||
"fingerprint": uuid.uuid4().hex,
|
||||
}
|
||||
|
||||
|
||||
def _call_json(sess, token, method, path, params=None, body=None, what="接口"):
|
||||
"""消息类 API 调用(对齐 bot _call):check_response + code 必须为 2000。"""
|
||||
headers = _api_headers(token)
|
||||
if method == "GET":
|
||||
r = sess.get(GINKGO + path, params=params, headers=headers, timeout=20)
|
||||
else:
|
||||
headers["Content-Type"] = "application/json"
|
||||
r = sess.post(GINKGO + path, data=json.dumps(body if body is not None else {}),
|
||||
headers=headers, timeout=20)
|
||||
check_response(r, what)
|
||||
try:
|
||||
payload = r.json()
|
||||
except ValueError:
|
||||
raise UpstreamError("%s returned non-JSON (HTTP %s)" % (what, r.status_code))
|
||||
if not isinstance(payload, dict):
|
||||
raise UpstreamError("%s JSON must be an object" % what)
|
||||
if is_rate_limited(None, payload):
|
||||
raise RateLimitedError("%s 触发频率限制" % what)
|
||||
if str(payload.get("code")) != "2000":
|
||||
raise UpstreamError("%s returned code=%s" % (what, payload.get("code")))
|
||||
return payload
|
||||
|
||||
|
||||
def fetch_message_all(sess, token, page_size=PAGE_SIZE):
|
||||
"""取两类消息(均为全部,含已读),返回 (notices, busi) 归一化列表。"""
|
||||
j1 = _call_json(sess, token, "GET", "/student/message/getMessageAll",
|
||||
params={"pageSize": page_size, "pageNo": 1, "readFlag": 0},
|
||||
what="通知列表")
|
||||
notices = [_normalize_notice(x) for x in ((j1.get("data") or {}).get("list") or [])]
|
||||
j2 = _call_json(sess, token, "POST", "/student/busiMessage/list", body={},
|
||||
what="互动消息列表")
|
||||
busi = [_normalize_busi(x) for x in (j2.get("data") or [])]
|
||||
return notices, busi
|
||||
|
||||
|
||||
def _validated_signin_item(item):
|
||||
"""Return an annotated copy only for a real interactive sign-in activity.
|
||||
|
||||
与 bot ginkgo 的 _validated_signin_item 同逻辑:仅互动消息、文本含"签到"、
|
||||
跳转链接可解析为课程签到路由。query 参数可能携带 token,不入库不回显。"""
|
||||
if (not isinstance(item, dict) or item.get("kind") != "busi"
|
||||
or "签到" not in str(item.get("text") or "")):
|
||||
return None
|
||||
link = str(item.get("link") or "").strip()
|
||||
try:
|
||||
route = parse_course_url(link)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return None
|
||||
row = dict(item)
|
||||
# Persist/display only the canonical path: query parameters may carry tokens
|
||||
# and must never reach receipts or push logs.
|
||||
row["link"] = GINKGO + urllib.parse.urlsplit(link).path
|
||||
# signInId is the stable activity identity(同一活动的多条消息去重)
|
||||
row["signin_activity_key"] = "S%s" % route["signInId"]
|
||||
try:
|
||||
row["signin_type"] = int(route["type"])
|
||||
except (TypeError, ValueError, KeyError):
|
||||
return None
|
||||
row["signin_type_name"] = signin_type_label(row["signin_type"])
|
||||
return row
|
||||
|
||||
|
||||
def _signin_one_with_retry(username, password, row, sess, token):
|
||||
"""对单个活动执行签到:失败连续重试(首次 + 最多 5 次,间隔 3s),
|
||||
token 疑似失效(上游错误/登录错)时重新登录一次后立即重试(对齐 bot refresh 语义)。
|
||||
|
||||
与 bot complete_signin 一致:除限流(外层中断)外,任何未确认成功都消耗重试次数;
|
||||
重试用尽记最终失败。entry["attempts"] 为逐次完整记录列表(每次的结果与原因,
|
||||
relogin 后立即重试的那次带 "relogin": true)。返回 (entry, sess, token)。"""
|
||||
link = row["link"]
|
||||
base = {
|
||||
"activityKey": row.get("signin_activity_key"),
|
||||
"signInId": parse_course_url(link).get("signInId"),
|
||||
"routeType": row.get("signin_type"),
|
||||
"routeTypeName": row.get("signin_type_name"),
|
||||
"link": link,
|
||||
}
|
||||
attempts = 0
|
||||
refreshed = False
|
||||
relogin_before_next = False # 上一次迭代发生了重登录,下一次尝试标记 relogin
|
||||
last_err = "unknown error"
|
||||
last_data = None
|
||||
attempt_log = [] # 逐次记录:{"n", "success", "message"[, "relogin"]}
|
||||
while attempts < MAX_SIGNIN_ATTEMPTS:
|
||||
attempts += 1
|
||||
record = {"n": attempts}
|
||||
if relogin_before_next:
|
||||
record["relogin"] = True
|
||||
relogin_before_next = False
|
||||
try:
|
||||
data = fetch_sign_in_info(username, password, link, do_commit=True,
|
||||
sess=sess, token=token)
|
||||
last_data = data
|
||||
commit = data.get("commit")
|
||||
if isinstance(commit, dict) and commit.get("success") is True:
|
||||
record["success"] = True
|
||||
record["message"] = "签到成功"
|
||||
attempt_log.append(record)
|
||||
entry = dict(base)
|
||||
entry.update({
|
||||
"type": data.get("type"),
|
||||
"typeName": data.get("typeName"),
|
||||
"title": data.get("title"),
|
||||
"signInCode": data.get("signInCode"),
|
||||
"status": data.get("status"),
|
||||
"success": True,
|
||||
"message": "签到成功",
|
||||
"attempts": attempt_log,
|
||||
})
|
||||
return entry, sess, token
|
||||
message = ((commit or {}).get("message")
|
||||
or "no commit result returned")
|
||||
last_err = str(message)
|
||||
record["success"] = False
|
||||
record["message"] = last_err
|
||||
except RateLimitedError:
|
||||
raise
|
||||
except (UpstreamError, AuthError) as e:
|
||||
# 疑似 token 失效:重登录一次后立即重试(不消耗退避间隔,对齐 bot)
|
||||
if not refreshed:
|
||||
refreshed = True
|
||||
try:
|
||||
sess, token = login(username, password)
|
||||
record["success"] = False
|
||||
record["message"] = str(e) or type(e).__name__
|
||||
attempt_log.append(record)
|
||||
relogin_before_next = True
|
||||
continue
|
||||
except Exception:
|
||||
pass # 重登录也失败:落入常规退避重试
|
||||
last_err = str(e) or type(e).__name__
|
||||
record["success"] = False
|
||||
record["message"] = last_err
|
||||
except NoSignInCodeError as e:
|
||||
last_err = str(e)
|
||||
record["success"] = False
|
||||
record["message"] = last_err
|
||||
except Exception as e:
|
||||
last_err = safe_error(e)
|
||||
record["success"] = False
|
||||
record["message"] = last_err
|
||||
attempt_log.append(record)
|
||||
if attempts < MAX_SIGNIN_ATTEMPTS:
|
||||
time.sleep(SIGNIN_BACKOFF[min(attempts - 1, len(SIGNIN_BACKOFF) - 1)])
|
||||
entry = dict(base)
|
||||
if isinstance(last_data, dict):
|
||||
entry.update({
|
||||
"type": last_data.get("type"),
|
||||
"typeName": last_data.get("typeName"),
|
||||
"title": last_data.get("title"),
|
||||
"signInCode": last_data.get("signInCode"),
|
||||
"status": last_data.get("status"),
|
||||
})
|
||||
entry.update({"success": False,
|
||||
"message": "签到失败(重试 %d 次): %s" % (attempts, last_err),
|
||||
"attempts": attempt_log})
|
||||
return entry, sess, token
|
||||
|
||||
|
||||
def auto_sign_in_all(username, password):
|
||||
"""mode=auto 主流程。返回聚合结果 dict(results 为列表)。"""
|
||||
sess, token = login(username, password)
|
||||
notices, busi = fetch_message_all(sess, token)
|
||||
|
||||
activities = {}
|
||||
for item in notices + busi:
|
||||
row = _validated_signin_item(item)
|
||||
if row:
|
||||
activities.setdefault(row["signin_activity_key"], row)
|
||||
|
||||
results = []
|
||||
rate_limited = False
|
||||
for key in sorted(activities, key=lambda k: activities[k].get("ts") or 0):
|
||||
row = activities[key]
|
||||
try:
|
||||
entry, sess, token = _signin_one_with_retry(
|
||||
username, password, row, sess, token)
|
||||
except RateLimitedError:
|
||||
# 对齐 bot:限流即中断本轮,已完成的结果照常返回
|
||||
rate_limited = True
|
||||
break
|
||||
results.append(entry)
|
||||
|
||||
succeeded = sum(1 for r in results if r.get("success"))
|
||||
return {
|
||||
"mode": "auto",
|
||||
"activities": len(activities),
|
||||
"total": len(results),
|
||||
"succeeded": succeeded,
|
||||
"failed": len(results) - succeeded,
|
||||
"rate_limited": rate_limited,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Part 5: HTTP service (WSGI app)
|
||||
# ==============================================================================
|
||||
@@ -462,13 +987,18 @@ def _b64_decode(value, field):
|
||||
"""
|
||||
Decode a base64 query/form parameter tolerantly.
|
||||
|
||||
Handles:
|
||||
* '+' turned into ' ' by application/x-www-form-urlencoded
|
||||
* trailing newline from `base64` CLI without -w0
|
||||
* both standard (+/) and urlsafe (-_) alphabets
|
||||
Handles, in order:
|
||||
* '%3D' / '%2B' / '%20' -- iOS Shortcuts and some HTTP clients
|
||||
URL-encode the base64 payload (notably the trailing '='), so we
|
||||
percent-decode first.
|
||||
* '+' turned into ' ' by application/x-www-form-urlencoded.
|
||||
* trailing newline from `base64` CLI without -w0.
|
||||
* both standard (+/) and urlsafe (-_) alphabets.
|
||||
"""
|
||||
v = (value or "").strip()
|
||||
v = v.replace(" ", "+").replace("\r", "").replace("\n", "")
|
||||
v = urllib.parse.unquote(value or "")
|
||||
v = v.strip()
|
||||
v = v.replace(" ", "+")
|
||||
v = v.replace("\r", "").replace("\n", "")
|
||||
if not v:
|
||||
raise ValueError("Empty base64 for '%s'" % field)
|
||||
try:
|
||||
@@ -489,51 +1019,96 @@ def ginkgostu_sign_in():
|
||||
url_b64 = args.get("url") or ""
|
||||
commit_arg = (args.get("commit") or "true").strip().lower()
|
||||
do_commit = commit_arg not in ("0", "false", "no")
|
||||
mode = (args.get("mode") or "").strip().lower()
|
||||
|
||||
if not (username and password_b64 and url_b64):
|
||||
if not (username and password_b64):
|
||||
return _error(400, 400,
|
||||
"Missing parameters: username, password(base64)")
|
||||
if mode != "auto" and not url_b64:
|
||||
return _error(400, 400,
|
||||
"Missing parameters: username, password(base64), url(base64)")
|
||||
|
||||
try:
|
||||
password = _b64_decode(password_b64, "password")
|
||||
course_url = _b64_decode(url_b64, "url")
|
||||
course_url = _b64_decode(url_b64, "url") if url_b64 else ""
|
||||
except ValueError as e:
|
||||
return _error(400, 400, str(e))
|
||||
|
||||
if mode == "auto":
|
||||
return _auto_response(username, password)
|
||||
|
||||
log.info("query start user=%s commit=%s", username[:2] + "***", do_commit)
|
||||
try:
|
||||
data = fetch_sign_in_info(username, password, course_url, do_commit=do_commit)
|
||||
commit_info = data.get("commit") or {}
|
||||
if do_commit and not commit_info.get("success", True):
|
||||
log.warning("sign-in commit failed user=%s reason=%s",
|
||||
username[:2] + "***", commit_info.get("message"))
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "sign-in failed: %s" % commit_info.get("message", "unknown reason"),
|
||||
"data": data,
|
||||
})
|
||||
log.info("query ok user=%s code=%s", username[:2] + "***", data.get("signInCode"))
|
||||
|
||||
if do_commit:
|
||||
# Fail-closed: only an explicit success=true counts as signed in.
|
||||
commit_info = data.get("commit")
|
||||
if not isinstance(commit_info, dict) or commit_info.get("success") is not True:
|
||||
reason = (commit_info.get("message") if isinstance(commit_info, dict) else None) or "no commit result returned"
|
||||
log.warning("sign-in commit failed user=%s reason=%s",
|
||||
username[:2] + "***", reason)
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "sign-in failed: %s" % reason,
|
||||
"data": data,
|
||||
})
|
||||
|
||||
log.info("query ok user=%s type=%s", username[:2] + "***", data.get("type"))
|
||||
return jsonify({"code": 200, "msg": "ok", "data": data})
|
||||
except AuthError as e:
|
||||
log.warning("auth failed: %s", e)
|
||||
return _error(401, 401, str(e))
|
||||
except NoSignInCodeError as e:
|
||||
log.info("no code: %s", e)
|
||||
return _error(404, 404, str(e))
|
||||
except UpstreamError as e:
|
||||
log.error("upstream error: %s", e)
|
||||
return _error(502, 502, str(e))
|
||||
except ValueError as e:
|
||||
log.warning("bad request: %s", e)
|
||||
return _error(400, 400, str(e))
|
||||
except requests.RequestException as e:
|
||||
log.error("upstream request failed: %s", e)
|
||||
except AuthError:
|
||||
log.warning("authentication failed user=%s", username[:2] + "***")
|
||||
return _error(401, 401, "Authentication failed")
|
||||
except NoSignInCodeError:
|
||||
log.info("numeric code missing user=%s", username[:2] + "***")
|
||||
return _error(404, 404, "数字码签到未配置签到码")
|
||||
except UpstreamError:
|
||||
log.error("upstream response error user=%s", username[:2] + "***")
|
||||
return _error(502, 502, "Upstream response error")
|
||||
except ValueError:
|
||||
log.warning("bad request user=%s", username[:2] + "***")
|
||||
return _error(400, 400, "Invalid request")
|
||||
except requests.RequestException:
|
||||
log.error("upstream request failed user=%s", username[:2] + "***")
|
||||
return _error(502, 502, "Upstream request failed")
|
||||
except Exception:
|
||||
log.exception("internal error")
|
||||
except Exception as exc:
|
||||
log.error("internal error type=%s", type(exc).__name__)
|
||||
return _error(500, 500, "Internal error")
|
||||
|
||||
|
||||
def _auto_response(username, password):
|
||||
"""mode=auto:发现 + 自动签到 + 聚合列表返回。"""
|
||||
log.info("auto sign-in start user=%s", username[:2] + "***")
|
||||
try:
|
||||
data = auto_sign_in_all(username, password)
|
||||
except AuthError:
|
||||
log.warning("auto sign-in authentication failed user=%s", username[:2] + "***")
|
||||
return _error(401, 401, "Authentication failed")
|
||||
except RateLimitedError:
|
||||
log.warning("auto sign-in rate limited user=%s", username[:2] + "***")
|
||||
return _error(429, 429, "Upstream rate limited, please retry later")
|
||||
except UpstreamError:
|
||||
log.error("auto sign-in upstream error user=%s", username[:2] + "***")
|
||||
return _error(502, 502, "Upstream response error")
|
||||
except requests.RequestException:
|
||||
log.error("auto sign-in upstream request failed user=%s", username[:2] + "***")
|
||||
return _error(502, 502, "Upstream request failed")
|
||||
except Exception as exc:
|
||||
log.error("auto sign-in internal error type=%s", type(exc).__name__)
|
||||
return _error(500, 500, "Internal error")
|
||||
log.info("auto sign-in done user=%s total=%s succeeded=%s rate_limited=%s",
|
||||
username[:2] + "***", data["total"], data["succeeded"],
|
||||
data["rate_limited"])
|
||||
if data["rate_limited"]:
|
||||
msg = ("partial: upstream rate limited, %d/%d activities processed"
|
||||
% (data["total"], data["activities"]))
|
||||
elif data["failed"]:
|
||||
msg = "ok with failures: %d/%d succeeded" % (data["succeeded"], data["total"])
|
||||
else:
|
||||
msg = "ok"
|
||||
return jsonify({"code": 200, "msg": msg, "data": data})
|
||||
|
||||
|
||||
@app.route("/healthz", methods=["GET"])
|
||||
def healthz():
|
||||
return jsonify({"code": 200, "msg": "ok", "data": None})
|
||||
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# jm.py — JM 漫画下载 API 模块
|
||||
# 流程: 源站下载 -> 本地临时目录打包 ZIP -> HTTP 返回 -> 延时删除
|
||||
# 移植自 qqbot 的 jm 模块(bj C:/call/bot/utility/jm.py),
|
||||
# 去掉 QQ 机器人 / 代理轮换 / 上传 cf 逻辑,改为与 pixiv/bilibili 一致的 HTTP 直返模式。
|
||||
# hk 服务器可直连源站,无需代理。
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
# ---------------- 本地临时缓存(与 pixiv/bilibili 同级:source/cache/jm) ----------------
|
||||
CACHE_DIR = (Path(__file__).resolve().parent / "../cache/jm").resolve()
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
MAX_CHAPTERS = 30 # 单次最多章节(与 qqbot 一致,同时避免超过聚合层 300s 超时)
|
||||
ZIP_TTL_SECONDS = 600 # ZIP 返回后保留 10 分钟再删除
|
||||
PORT = 9004
|
||||
|
||||
_image_exts = (".webp", ".jpg", ".png")
|
||||
|
||||
# 同一本子防并发(album_id -> asyncio.Lock)
|
||||
_locks = {}
|
||||
_locks_guard = asyncio.Lock()
|
||||
|
||||
|
||||
def _sanitize_filename(name):
|
||||
"""清理文件名非法字符,保留中英文/数字/常见符号"""
|
||||
cleaned = unicodedata.normalize("NFKC", name or "")
|
||||
result = []
|
||||
for ch in cleaned:
|
||||
if ch.isalnum() or ch in ("_", "-", ".", "(", ")", "[", "]", "(", ")", "【", "】"):
|
||||
result.append(ch)
|
||||
elif ch.isspace():
|
||||
result.append("_")
|
||||
else:
|
||||
result.append("-")
|
||||
s = "".join(result)
|
||||
while "__" in s:
|
||||
s = s.replace("__", "_")
|
||||
while "--" in s:
|
||||
s = s.replace("--", "-")
|
||||
return s[:80].strip("_-") or "unnamed"
|
||||
|
||||
|
||||
def _zip_files():
|
||||
return [f for f in CACHE_DIR.glob("*.zip") if f.is_file()]
|
||||
|
||||
|
||||
def _cache_sweep():
|
||||
"""兜底清理:进程重启后残留的过期 ZIP(mtime 超过 TTL 即删)"""
|
||||
now = time.time()
|
||||
for f in _zip_files():
|
||||
try:
|
||||
if now - f.stat().st_mtime > ZIP_TTL_SECONDS:
|
||||
f.unlink()
|
||||
print(f"[jm] sweep expired zip: {f.name}", flush=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def _delete_later(path, delay):
|
||||
"""返回响应后延时删除 ZIP"""
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
print(f"[jm] ttl delete: {Path(path).name}", flush=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class _ParamError(RuntimeError):
|
||||
"""参数类错误,返回 400"""
|
||||
|
||||
|
||||
def _resolve_album_plan(album_id, range_start, range_end):
|
||||
"""获取元数据并确定章节范围与 ZIP 文件名。
|
||||
返回 (title, episodes, range_start, range_end, zip_name),失败抛异常"""
|
||||
from jmcomic import JmModuleConfig
|
||||
|
||||
option = JmModuleConfig.option_class().default()
|
||||
client = option.build_jm_client()
|
||||
album = client.get_album_detail(int(album_id))
|
||||
title = getattr(album, "title", "") or str(album_id)
|
||||
episodes = getattr(album, "episode_list", []) or []
|
||||
total = len(episodes)
|
||||
if total == 0:
|
||||
raise RuntimeError("未找到任何章节")
|
||||
|
||||
if range_start is None:
|
||||
if total > MAX_CHAPTERS:
|
||||
raise _ParamError(
|
||||
f"共 {total} 章, 超过单次上限 {MAX_CHAPTERS} 章, 请用 range 参数分段下载 (如 range=1-{MAX_CHAPTERS})"
|
||||
)
|
||||
range_start, range_end = 1, total
|
||||
else:
|
||||
if range_start < 1 or range_end < range_start:
|
||||
raise _ParamError("章节范围不合法")
|
||||
if range_end - range_start + 1 > MAX_CHAPTERS:
|
||||
raise _ParamError(f"章节跨度超过 {MAX_CHAPTERS} 章, 请缩小范围")
|
||||
if range_start > total:
|
||||
raise _ParamError(f"起始章节超出总章节数 {total}")
|
||||
range_end = min(range_end, total)
|
||||
|
||||
zip_name = f"{_sanitize_filename(title)}_{album_id}_ch{range_start}-{range_end}.zip"
|
||||
return title, episodes, range_start, range_end, zip_name
|
||||
|
||||
|
||||
def _download_and_pack(album_id, plan):
|
||||
"""下载并打包 ZIP。成功返回 (zip_path, image_count, ok_ch, fail_ch),失败抛异常"""
|
||||
from jmcomic import JmModuleConfig
|
||||
from PIL import Image
|
||||
|
||||
title, episodes, range_start, range_end, zip_name = plan
|
||||
|
||||
work_dir = Path(tempfile.mkdtemp(prefix=f".dl-{album_id}-", dir=CACHE_DIR))
|
||||
try:
|
||||
# 逐章下载(hk 直连源站,无需代理)
|
||||
ok_count = 0
|
||||
fail_count = 0
|
||||
for idx in range(range_start - 1, range_end):
|
||||
photo_id = episodes[idx][0]
|
||||
try:
|
||||
dl_option = JmModuleConfig.option_class().construct({
|
||||
"dir_rule": {"rule": "Bd_Pid", "base_dir": str(work_dir)},
|
||||
"download": {"image": {"suffix": ".webp"}},
|
||||
})
|
||||
dler = JmModuleConfig.downloader_class()(dl_option)
|
||||
dler.download_photo(int(photo_id))
|
||||
ok_count += 1
|
||||
print(f"[jm] 章节下载完成: {album_id}/{photo_id}", flush=True)
|
||||
except Exception as e:
|
||||
fail_count += 1
|
||||
print(f"[jm] 章节下载失败: {album_id}/{photo_id}: {e}", flush=True)
|
||||
|
||||
# 收集图片
|
||||
images = []
|
||||
for ext in _image_exts:
|
||||
images.extend(sorted(work_dir.rglob(f"*{ext}")))
|
||||
if not images:
|
||||
raise RuntimeError("未下载到任何图片")
|
||||
|
||||
# 打包 ZIP (webp 统一转 jpg, 无密码)
|
||||
zip_path = CACHE_DIR / zip_name
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix=".pack-", dir=CACHE_DIR))
|
||||
try:
|
||||
packed = []
|
||||
for i, p in enumerate(images, 1):
|
||||
out = tmp_dir / f"{i:04d}.jpg"
|
||||
try:
|
||||
img = Image.open(str(p))
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
img.save(str(out), "JPEG", quality=95)
|
||||
except Exception:
|
||||
out = tmp_dir / f"{i:04d}{p.suffix.lower()}"
|
||||
shutil.copyfile(str(p), str(out))
|
||||
packed.append(out)
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for j in packed:
|
||||
zf.write(j, j.name)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
print(f"[jm] 打包完成: {zip_name} ({len(images)} 张, 成功 {ok_count} 章 / 失败 {fail_count} 章)", flush=True)
|
||||
return zip_path, len(images), ok_count, fail_count
|
||||
finally:
|
||||
# 无论成功/失败都回收图片临时目录
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _file_response(zip_path):
|
||||
return web.FileResponse(
|
||||
path=zip_path,
|
||||
headers={
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": f'attachment; filename="{zip_path.name}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def handle_jm(request):
|
||||
album_id = request.query.get("id", "").strip()
|
||||
if not album_id or not album_id.isdigit():
|
||||
return web.json_response({"error": "missing or invalid id parameter"}, status=400)
|
||||
|
||||
range_start = range_end = None
|
||||
range_str = request.query.get("range", "").strip()
|
||||
if range_str:
|
||||
import re
|
||||
m = re.fullmatch(r"(\d{1,4})-(\d{1,4})", range_str)
|
||||
if not m:
|
||||
return web.json_response(
|
||||
{"error": "invalid range format, expected: start-end (e.g. 1-30)"}, status=400
|
||||
)
|
||||
range_start, range_end = int(m.group(1)), int(m.group(2))
|
||||
|
||||
_cache_sweep()
|
||||
|
||||
# 同本子串行,防止并发重复下载
|
||||
async with _locks_guard:
|
||||
lock = _locks.setdefault(album_id, asyncio.Lock())
|
||||
if lock.locked():
|
||||
return web.json_response(
|
||||
{"error": "same album is being processed, please retry later"}, status=409
|
||||
)
|
||||
|
||||
async with lock:
|
||||
try:
|
||||
plan = await asyncio.to_thread(
|
||||
_resolve_album_plan, album_id, range_start, range_end
|
||||
)
|
||||
except _ParamError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
return web.json_response(
|
||||
{"error": "failed to fetch album info", "detail": str(e)[:200]}, status=502
|
||||
)
|
||||
|
||||
zip_name = plan[4]
|
||||
existing = CACHE_DIR / zip_name
|
||||
if existing.exists():
|
||||
# TTL 窗口内重复请求:直接复用并重新计时
|
||||
try:
|
||||
existing.touch()
|
||||
except OSError:
|
||||
pass
|
||||
print(f"[jm] reuse zip: {zip_name}", flush=True)
|
||||
asyncio.create_task(_delete_later(existing, ZIP_TTL_SECONDS))
|
||||
return _file_response(existing)
|
||||
|
||||
try:
|
||||
zip_path, img_count, ok_c, fail_c = await asyncio.to_thread(
|
||||
_download_and_pack, album_id, plan
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response(
|
||||
{"error": "download or pack failed", "detail": str(e)[:200]}, status=502
|
||||
)
|
||||
|
||||
asyncio.create_task(_delete_later(zip_path, ZIP_TTL_SECONDS))
|
||||
return _file_response(zip_path)
|
||||
|
||||
|
||||
async def handle_cache_info(request):
|
||||
files = []
|
||||
total = 0
|
||||
for f in _zip_files():
|
||||
try:
|
||||
st = f.stat()
|
||||
except OSError:
|
||||
continue
|
||||
files.append({"name": f.name, "size": st.st_size, "mtime": int(st.st_mtime)})
|
||||
total += st.st_size
|
||||
return web.json_response(
|
||||
{
|
||||
"cache_dir": str(CACHE_DIR),
|
||||
"zip_ttl_seconds": ZIP_TTL_SECONDS,
|
||||
"max_chapters": MAX_CHAPTERS,
|
||||
"count": len(files),
|
||||
"total_bytes": total,
|
||||
"free_bytes": shutil.disk_usage(CACHE_DIR).free,
|
||||
"files": sorted(files, key=lambda x: -x["size"]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/jm/get_comic", handle_jm)
|
||||
app.router.add_get("/jm/cache_info", handle_cache_info)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Cache directory: {CACHE_DIR}")
|
||||
web.run_app(app, host="127.0.0.1", port=PORT)
|
||||
@@ -4,6 +4,7 @@ from aiohttp import web, ClientSession, ClientTimeout
|
||||
PIXIV_BACKEND = "http://127.0.0.1:9007"
|
||||
BILIBILI_BACKEND = "http://127.0.0.1:9006"
|
||||
DUFE_BACKEND = "http://127.0.0.1:9005"
|
||||
JM_BACKEND = "http://127.0.0.1:9004"
|
||||
|
||||
|
||||
async def handle_pixiv(request):
|
||||
@@ -30,6 +31,14 @@ async def handle_dufe_sign_in(request):
|
||||
return await proxy_forward(request, target)
|
||||
|
||||
|
||||
async def handle_jm(request):
|
||||
qs = request.query_string
|
||||
target = f"{JM_BACKEND}/jm/get_comic"
|
||||
if qs:
|
||||
target += "?" + qs
|
||||
return await proxy(request, target)
|
||||
|
||||
|
||||
async def proxy(request, target):
|
||||
timeout = ClientTimeout(total=300)
|
||||
async with ClientSession(timeout=timeout) as session:
|
||||
@@ -92,6 +101,7 @@ app.router.add_get("/pixiv/get_image", handle_pixiv)
|
||||
app.router.add_get("/bilibili/get_video", handle_bilibili)
|
||||
app.router.add_get("/dufe/ginkgostu_sign_in", handle_dufe_sign_in)
|
||||
app.router.add_post("/dufe/ginkgostu_sign_in", handle_dufe_sign_in)
|
||||
app.router.add_get("/jm/get_comic", handle_jm)
|
||||
|
||||
if __name__ == "__main__":
|
||||
web.run_app(app, host="127.0.0.1", port=9008)
|
||||
|
||||
+25
-10
@@ -31,9 +31,11 @@ def get_access_token():
|
||||
|
||||
|
||||
def fetch_illust_url(pid, page):
|
||||
"""返回 (original_url, api_status)。api_status: 200 成功 / 404 作品不存在 /
|
||||
401|403 认证失败 / 429 限流 / 0 网络等临时错误 / -1 数据异常。"""
|
||||
access_token = get_access_token()
|
||||
if not access_token:
|
||||
return None
|
||||
return None, 403
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://{API_HOST}/v1/illust/detail",
|
||||
@@ -47,16 +49,17 @@ def fetch_illust_url(pid, page):
|
||||
verify=False
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return None, resp.status_code
|
||||
data = resp.json()
|
||||
if data.get("error"):
|
||||
return None
|
||||
return None, -1
|
||||
illust = data.get("illust")
|
||||
if not illust:
|
||||
return None
|
||||
return None, -1
|
||||
page_count = illust.get("page_count", 1)
|
||||
if page >= page_count:
|
||||
return None
|
||||
# 页码超出:作品存在但请求的分页不存在,按 404 语义处理
|
||||
return None, 404
|
||||
original_url = None
|
||||
if page_count > 1:
|
||||
meta_pages = illust.get("meta_pages")
|
||||
@@ -69,10 +72,12 @@ def fetch_illust_url(pid, page):
|
||||
if not original_url:
|
||||
original_url = illust.get("image_urls", {}).get("large")
|
||||
if not original_url:
|
||||
return None
|
||||
return original_url.replace("i.pximg.net", IMAGE_PROXY_DOMAIN)
|
||||
return None, -1
|
||||
return original_url.replace("i.pximg.net", IMAGE_PROXY_DOMAIN), 200
|
||||
except requests.RequestException:
|
||||
return None, 0 # 网络等临时错误:可继续尝试备用源
|
||||
except Exception:
|
||||
return None
|
||||
return None, -1
|
||||
|
||||
|
||||
def make_re_url(pid, page):
|
||||
@@ -146,9 +151,19 @@ def serve_image(path):
|
||||
return send_file(cached_file, mimetype=mime, max_age=3600)
|
||||
|
||||
urls_to_try = []
|
||||
official_url = fetch_illust_url(pid, page)
|
||||
official_url, api_status = fetch_illust_url(pid, page)
|
||||
if official_url:
|
||||
urls_to_try.append(official_url)
|
||||
# 明确的作品级错误直接返回,不再做无意义的备用源重试:
|
||||
# 404 = 作品不存在/已删除/分页不存在(官方 API 已裁决)
|
||||
# 401/403 = token 失效(refresh 由独立服务负责,这里明确报错)
|
||||
# 429 = pixiv API 限流(备用源同样是 pixiv 数据,重试无意义)
|
||||
if api_status == 404:
|
||||
return jsonify(error="作品不存在或已删除", code="not_found"), 404
|
||||
if api_status in (401, 403):
|
||||
return jsonify(error="pixiv 认证失败,token 可能失效", code="auth_error"), 502
|
||||
if api_status == 429:
|
||||
return jsonify(error="pixiv API 限流,请稍后再试", code="rate_limited"), 503
|
||||
urls_to_try.append(make_re_url(pid, page))
|
||||
urls_to_try.append(make_cat_url(pid, page))
|
||||
|
||||
@@ -157,7 +172,7 @@ def serve_image(path):
|
||||
if img_path:
|
||||
return send_file(img_path, mimetype=mime, max_age=3600)
|
||||
|
||||
return jsonify(error="Failed to fetch image from all sources"), 500
|
||||
return jsonify(error="所有图源均取图失败,请稍后再试", code="all_sources_failed"), 500
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=JM Comic Download API Service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Environment=PATH=/home/openapi/env/general/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
Type=simple
|
||||
WorkingDirectory=/home/openapi/source/jm
|
||||
ExecStart=/home/openapi/env/general/bin/python /home/openapi/source/jm/jm.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:/var/log/jm.log
|
||||
StandardError=append:/var/log/jm.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user