feat(auth): separate read and write bilibili accounts
This commit is contained in:
1 parent
5c2ab7b187
commit
178ab64b18
17 files changed
+660
-696
No files matched your search
+9
-7
@@ -71,11 +71,12 @@ uv run python --version
|
|||||||
# 查看现有收藏夹工具帮助
|
# 查看现有收藏夹工具帮助
|
||||||
uv run bili-fav help
|
uv run bili-fav help
|
||||||
|
|
||||||
# 扫码登录并原子更新 config.json
|
# 扫码登录并原子更新指定账号
|
||||||
uv run bili-fav auth
|
uv run python app.py auth --account read
|
||||||
|
uv run python app.py auth --account write
|
||||||
|
|
||||||
# 检查现有 B 站登录状态
|
# 检查两套 B 站登录状态
|
||||||
uv run bili-fav auth --check
|
uv run python app.py auth-check --account all
|
||||||
|
|
||||||
# 执行当前无网络依赖的测试
|
# 执行当前无网络依赖的测试
|
||||||
uv run python tests/test_auth.py
|
uv run python tests/test_auth.py
|
||||||
@@ -200,7 +201,8 @@ PRAGMA synchronous = NORMAL;
|
|||||||
│ ├── logging_json.py # 脱敏 JSON 日志
|
│ ├── logging_json.py # 脱敏 JSON 日志
|
||||||
│ ├── bilibili/
|
│ ├── bilibili/
|
||||||
│ │ ├── client.py # HTTP、WBI、重试与限速
|
│ │ ├── client.py # HTTP、WBI、重试与限速
|
||||||
│ │ ├── auth.py # 从 favsort 抽取的扫码授权能力
|
│ │ ├── auth.py # Cookie、设备指纹与请求头工具
|
||||||
|
│ │ ├── auth_flow.py # app.py auth 的扫码与原子写回流程
|
||||||
│ │ └── favorite.py # 加入收藏夹与排序定位
|
│ │ └── favorite.py # 加入收藏夹与排序定位
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ ├── discovery.py
|
│ │ ├── discovery.py
|
||||||
@@ -224,7 +226,7 @@ PRAGMA synchronous = NORMAL;
|
|||||||
└── data/ # 本地运行期数据,默认不提交
|
└── data/ # 本地运行期数据,默认不提交
|
||||||
```
|
```
|
||||||
|
|
||||||
`favsort.py` 继续保持可独立运行。P0 实施时优先抽取其通用的 Cookie、请求头、扫码授权和收藏夹 API 逻辑;不要直接复制出第二套不一致的身份与请求实现。
|
`favsort.py` 继续保持可独立运行,但只操作 `bilibili.write`;扫码授权统一由 `app.py auth --account read|write` 提供。Cookie、设备指纹与请求头逻辑必须共用 `reaction_board.bilibili.auth`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -275,7 +277,7 @@ PRAGMA synchronous = NORMAL;
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `folders` 中的年度夹由人工创建,系统只读;
|
- `folders` 中的年度夹由人工创建,系统只读;
|
||||||
- 兼容现有 `favsort.py` 的扁平 `mid` / `csrf` / `cookie` 写法;存在 `bilibili` 嵌套对象时以嵌套值为准;
|
- 配置必须为 `bilibili.read` / `bilibili.write` 双账号结构;不兼容顶层扁平 `mid` / `csrf` / `cookie`;
|
||||||
- P0 已实现的命令行:`python app.py init`、`python app.py sync [--full]`、`python app.py status`。
|
- P0 已实现的命令行:`python app.py init`、`python app.py sync [--full]`、`python app.py status`。
|
||||||
|
|
||||||
### 5.2 安全规则- Windows 本地开发时应限制 `config.json` 的文件访问权限;部署到 Linux 时权限必须为 `600`;
|
### 5.2 安全规则- Windows 本地开发时应限制 `config.json` 的文件访问权限;部署到 Linux 时权限必须为 `600`;
|
||||||
|
|||||||
@@ -97,8 +97,13 @@ uv run python app.py snapshot
|
|||||||
# 忽略当日完成标记,重新采集
|
# 忽略当日完成标记,重新采集
|
||||||
uv run python app.py snapshot --force
|
uv run python app.py snapshot --force
|
||||||
|
|
||||||
# 检查 Cookie 登录态
|
# 分别为只读和写入账号扫码授权(默认 read)
|
||||||
uv run python app.py auth-check
|
uv run python app.py auth
|
||||||
|
uv run python app.py auth --account write
|
||||||
|
|
||||||
|
# 检查指定账号或两套账号的登录态
|
||||||
|
uv run python app.py auth-check --account read
|
||||||
|
uv run python app.py auth-check --account all
|
||||||
```
|
```
|
||||||
|
|
||||||
- `snapshot` 对 `active` / `unfaved` 视频逐条请求 `view`,每条请求间隔 1.0~1.6 秒;
|
- `snapshot` 对 `active` / `unfaved` 视频逐条请求 `view`,每条请求间隔 1.0~1.6 秒;
|
||||||
@@ -108,7 +113,7 @@ uv run python app.py auth-check
|
|||||||
- 粉丝数写入 `up_stat`,失败同样写 `ok=0`;
|
- 粉丝数写入 `up_stat`,失败同样写 `ok=0`;
|
||||||
- 当日全部视频与粉丝采集成功后写入完成标记;服务启动时若当天未完成会自动补跑;
|
- 当日全部视频与粉丝采集成功后写入完成标记;服务启动时若当天未完成会自动补跑;
|
||||||
- 每次采集后生成 `data/backup/app-YYYYMMDD.db`,按 `storage.backup_keep_days` 清理旧日备份;
|
- 每次采集后生成 `data/backup/app-YYYYMMDD.db`,按 `storage.backup_keep_days` 清理旧日备份;
|
||||||
- Cookie 失效只告警,不阻塞匿名 `view` 采集;`auth-check` 可单独检查。
|
- Cookie 失效只告警,不阻塞匿名 `view` 采集;P0/P1 只使用 `bilibili.read`。
|
||||||
|
|
||||||
首次真实全量采集结果:795 条视频全部成功、0 失败、0 移除,耗时约 19 分钟;粉丝 `follower=2767270`;日备份为 `data/backup/app-20261031.db`。
|
首次真实全量采集结果:795 条视频全部成功、0 失败、0 移除,耗时约 19 分钟;粉丝 `follower=2767270`;日备份为 `data/backup/app-20261031.db`。
|
||||||
|
|
||||||
@@ -120,9 +125,20 @@ uv run python app.py auth-check
|
|||||||
Copy-Item config.example.json config.json
|
Copy-Item config.example.json config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`config.json` 已被 `.gitignore` 忽略,**不得提交**。其中:
|
`config.json` 已被 `.gitignore` 忽略,**不得提交**。配置必须使用双账号格式:
|
||||||
|
|
||||||
- `mid` / `cookie` / `csrf`:B 站账号 UID、完整 Cookie 与 `bili_jct`(兼容 `favsort.py` 的扁平写法;也支持 `bilibili` 嵌套对象);
|
```json
|
||||||
|
{
|
||||||
|
"bilibili": {
|
||||||
|
"read": { "mid": 0, "cookie": "...", "csrf": "..." },
|
||||||
|
"write": { "mid": 0, "cookie": "...", "csrf": "..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `bilibili.read`:年度收藏夹同步、视频数据快照、粉丝数与其他只读查询使用;
|
||||||
|
- `bilibili.write`:后续加入目标收藏夹、回读验证与现有 `favsort.py` 排序使用;
|
||||||
|
- 两个账号的 Cookie、CSRF 与设备指纹严格隔离;顶层 `mid` / `cookie` / `csrf` **不再支持**;
|
||||||
- `overrides`:可选,为代发或联合投稿视频指定归属 UP;
|
- `overrides`:可选,为代发或联合投稿视频指定归属 UP;
|
||||||
- `folders`:年度收藏夹配置(`year` + `media_id` + `title`),系统不自动建夹;
|
- `folders`:年度收藏夹配置(`year` + `media_id` + `title`),系统不自动建夹;
|
||||||
- `up_blacklist_seed`:首次启动写入数据库的 UP 黑名单种子;
|
- `up_blacklist_seed`:首次启动写入数据库的 UP 黑名单种子;
|
||||||
@@ -140,9 +156,9 @@ Copy-Item config.example.json config.json
|
|||||||
# 查看全部命令;无需 config.json
|
# 查看全部命令;无需 config.json
|
||||||
uv run bili-fav help
|
uv run bili-fav help
|
||||||
|
|
||||||
# 扫码登录,更新 config.json 中失效的身份 Cookie
|
# 为写账号扫码登录与检查状态(认证入口统一在 app.py)
|
||||||
uv run bili-fav auth
|
uv run python app.py auth --account write
|
||||||
uv run bili-fav auth --check
|
uv run python app.py auth-check --account write
|
||||||
|
|
||||||
# 列出本人创建的收藏夹
|
# 列出本人创建的收藏夹
|
||||||
uv run bili-fav folders
|
uv run bili-fav folders
|
||||||
@@ -172,38 +188,39 @@ uv run bili-fav verify <media_id>
|
|||||||
|
|
||||||
## 扫码授权
|
## 扫码授权
|
||||||
|
|
||||||
当接口返回 `-101` 或 Cookie 即将过期时,运行:
|
当接口返回 `-101` 或 Cookie 即将过期时,使用统一认证入口:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
uv run bili-fav auth
|
uv run python app.py auth --account read
|
||||||
uv run bili-fav auth --check
|
uv run python app.py auth --account write
|
||||||
|
uv run python app.py auth-check --account all
|
||||||
```
|
```
|
||||||
|
|
||||||
授权流程在本地生成二维码图片并等待手机客户端扫码确认。成功后:
|
授权流程在本地生成二维码图片并等待手机客户端扫码确认。成功后:
|
||||||
|
|
||||||
- 更新 `SESSDATA`、`bili_jct`、`DedeUserID`、`sid` 等身份字段;
|
- 更新 `SESSDATA`、`bili_jct`、`DedeUserID`、`sid` 等身份字段;
|
||||||
- 保留 `buvid3`、`buvid4`、`_uuid`、`buvid_fp` 等设备指纹;
|
- 保留 `buvid3`、`buvid4`、`_uuid`、`buvid_fp` 等设备指纹;
|
||||||
- 将 `csrf` 同步为新的 `bili_jct`;
|
- 将选中账号的 `csrf` 同步为新的 `bili_jct`,并从 `nav` 回写该账号实际 mid;
|
||||||
- 原子写入 `config.json`,并保留 `config.json.bak`;
|
- 原子写入 `config.json`,并保留 `config.json.bak`;
|
||||||
- 通过 `x/web-interface/nav` 验证新登录态,失败时自动回滚。
|
- 通过 `x/web-interface/nav` 验证新登录态,失败时自动回滚;不会修改另一个账号。
|
||||||
|
|
||||||
常用选项:
|
常用选项:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
uv run bili-fav auth --no-browser # 只生成二维码图片并输出路径
|
uv run python app.py auth --account write --no-browser
|
||||||
uv run bili-fav auth --timeout 180 # 修改扫码等待时间(秒)
|
uv run python app.py auth --account read --timeout 180
|
||||||
```
|
```
|
||||||
|
|
||||||
## B 站访问与安全约束
|
## B 站访问与安全约束
|
||||||
|
|
||||||
| 接口 | 当前用途 | 身份 Cookie |
|
| 接口 | 当前用途 | 身份 Cookie |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `fav/resource/ids` | 收藏夹资源 ID | 不需要账号身份;可匿名读取。 |
|
| `fav/resource/ids` / `infos` | 年度收藏夹同步与排序计划 | `read` 用于 P0;`favsort.py` 计划使用 `write`。 |
|
||||||
| `fav/resource/infos` | 收藏夹元数据 | 不需要账号身份,但需要设备指纹,完全无 Cookie 时可能返回 HTTP 412。 |
|
| `view` / `relation/stat` / `nav` | P1 快照与 read 登录态 | `read`。 |
|
||||||
| `fav/folder/created/list-all` | 列出自建收藏夹 | 需要完整登录 Cookie。 |
|
| `fav/folder/created/list-all` | 列出目标自建收藏夹 | `write`。 |
|
||||||
| `fav/resource/sort` | 收藏夹排序 | 写操作;需要完整 Cookie 和 `csrf`。 |
|
| `fav/resource/deal` / `sort` | 加入收藏夹、收藏夹排序 | `write`,需要完整 Cookie 和 CSRF。 |
|
||||||
|
|
||||||
现有工具对 `ids` / `infos` 默认剥离账号身份 Cookie,仅保留设备指纹;如果只读请求失败,才使用完整 Cookie 重试。所有写操作使用完整 Cookie 与 CSRF。
|
只读客户端对 `ids` / `infos` 默认剥离 `read` 身份 Cookie,仅保留该账号设备指纹;如果失败才使用完整 `read` Cookie 重试。写操作一律使用完整 `write` Cookie 与 CSRF。
|
||||||
|
|
||||||
`x/v3/fav/resource/sort` 的实测语义为:
|
`x/v3/fav/resource/sort` 的实测语义为:
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""看板服务入口。
|
"""看板服务入口。
|
||||||
|
|
||||||
P1 提供 ``init`` / ``sync`` / ``snapshot`` / ``auth-check`` / ``run`` / ``status``
|
P1 提供 ``init`` / ``sync`` / ``snapshot`` / ``auth`` / ``auth-check`` / ``run`` / ``status``
|
||||||
子命令;后续阶段会在同一入口启动本地 Web 服务。
|
子命令;后续阶段会在同一入口启动本地 Web 服务。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -1,7 +1,16 @@
|
|||||||
{
|
{
|
||||||
|
"bilibili": {
|
||||||
|
"read": {
|
||||||
"mid": 0,
|
"mid": 0,
|
||||||
"csrf": "从 cookie 中的 bili_jct 取值",
|
"csrf": "从 read 账号 cookie 中的 bili_jct 取值",
|
||||||
"cookie": "粘贴浏览器请求头里的完整 Cookie(需含 SESSDATA、bili_jct、DedeUserID 等)",
|
"cookie": "只读账号的完整 Cookie(需含 SESSDATA、bili_jct、DedeUserID 等)"
|
||||||
|
},
|
||||||
|
"write": {
|
||||||
|
"mid": 0,
|
||||||
|
"csrf": "从 write 账号 cookie 中的 bili_jct 取值",
|
||||||
|
"cookie": "写入账号的完整 Cookie(用于加入收藏夹和排序)"
|
||||||
|
}
|
||||||
|
},
|
||||||
"overrides": {},
|
"overrides": {},
|
||||||
"sleep": {
|
"sleep": {
|
||||||
"min": 2.5,
|
"min": 2.5,
|
||||||
|
|||||||
+21
-324
@@ -10,7 +10,6 @@ bili-fav / favsort —— B 站收藏夹批量重排序工具
|
|||||||
|
|
||||||
指令
|
指令
|
||||||
python favsort.py help [子命令]
|
python favsort.py help [子命令]
|
||||||
python favsort.py auth [--check] [--no-browser] [--timeout SEC]
|
|
||||||
python favsort.py folders
|
python favsort.py folders
|
||||||
python favsort.py plan <media_id> [--folder-mid N] [-v]
|
python favsort.py plan <media_id> [--folder-mid N] [-v]
|
||||||
python favsort.py show <media_id>
|
python favsort.py show <media_id>
|
||||||
@@ -18,55 +17,28 @@ bili-fav / favsort —— B 站收藏夹批量重排序工具
|
|||||||
python favsort.py run <media_id> [--verify-every N]
|
python favsort.py run <media_id> [--verify-every N]
|
||||||
python favsort.py verify <media_id>
|
python favsort.py verify <media_id>
|
||||||
|
|
||||||
鉴权
|
本工具只处理写账号所属收藏夹的排序。扫码授权统一使用
|
||||||
auth 会申请扫码登录,在本地生成二维码图片(vendor/qrcodegen.py,纯 Python、MIT,
|
``python app.py auth --account write``;所有 API 请求均使用 ``bilibili.write``。
|
||||||
无需 pip 安装),用默认看图工具打开给你扫;拿到新的身份 cookie 后只替换
|
|
||||||
SESSDATA / bili_jct / DedeUserID 等身份字段,buvid3 / buvid4 / _uuid / buvid_fp
|
|
||||||
等设备指纹一律保留。只有设备 cookie 缺失时才用公开的 finger/spi 接口补,
|
|
||||||
拿不到的(如 buvid_fp)保持原样。
|
|
||||||
ids / infos / folders 为只读请求,默认剥离账号身份 cookie;sort 为写操作,
|
|
||||||
始终使用完整 cookie + csrf。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import shutil
|
|
||||||
import struct
|
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import webbrowser
|
|
||||||
import zlib
|
|
||||||
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
BASE = "https://api.bilibili.com"
|
BASE = "https://api.bilibili.com"
|
||||||
PASSPORT = "https://passport.bilibili.com"
|
|
||||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
DATA_DIR = os.path.join(ROOT, "data")
|
DATA_DIR = os.path.join(ROOT, "data")
|
||||||
CONFIG_PATH = os.path.join(ROOT, "config.json")
|
CONFIG_PATH = os.path.join(ROOT, "config.json")
|
||||||
CONFIG_BACKUP = CONFIG_PATH + ".bak"
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(ROOT, "vendor"))
|
|
||||||
try:
|
|
||||||
import qrcodegen # noqa: E402 纯 Python,MIT,见 vendor/qrcodegen.py
|
|
||||||
except ImportError: # pragma: no cover
|
|
||||||
qrcodegen = None
|
|
||||||
|
|
||||||
from reaction_board.bilibili.auth import ( # noqa: E402
|
from reaction_board.bilibili.auth import ( # noqa: E402
|
||||||
IDENTITY_KEYS,
|
|
||||||
cookie_value,
|
|
||||||
cookies_from_set_cookie,
|
|
||||||
gen_uuid,
|
|
||||||
make_headers,
|
make_headers,
|
||||||
merge_cookie,
|
|
||||||
parse_cookie,
|
|
||||||
sessdata_expiry,
|
|
||||||
strip_identity,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
CHUNK = 20 # infos 接口单次 resources 数量(实测 20 稳定)
|
CHUNK = 20 # infos 接口单次 resources 数量(实测 20 稳定)
|
||||||
@@ -75,18 +47,11 @@ API = {
|
|||||||
"infos": "{base}/x/v3/fav/resource/infos?resources={res}&platform=web&folder_mid={fmid}&folder_id={mid}",
|
"infos": "{base}/x/v3/fav/resource/infos?resources={res}&platform=web&folder_mid={fmid}&folder_id={mid}",
|
||||||
"sort": "{base}/x/v3/fav/resource/sort",
|
"sort": "{base}/x/v3/fav/resource/sort",
|
||||||
"folders": "{base}/x/v3/fav/folder/created/list-all?up_mid={mid}&jsonp=jsonp",
|
"folders": "{base}/x/v3/fav/folder/created/list-all?up_mid={mid}&jsonp=jsonp",
|
||||||
"nav": "{base}/x/web-interface/nav",
|
|
||||||
"spi": "{base}/x/frontend/finger/spi",
|
|
||||||
"qr_generate": "{passport}/x/passport-login/web/qrcode/generate?source=main-fe-header",
|
|
||||||
"qr_poll": "{passport}/x/passport-login/web/qrcode/poll?qrcode_key={key}",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------- config
|
# ---------------------------------------------------------------- config
|
||||||
|
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
"mid": 0,
|
|
||||||
"csrf": "",
|
|
||||||
"cookie": "",
|
|
||||||
"overrides": {},
|
"overrides": {},
|
||||||
"sleep": {
|
"sleep": {
|
||||||
"min": 2.5,
|
"min": 2.5,
|
||||||
@@ -107,13 +72,24 @@ def load_config(require_auth=True):
|
|||||||
f"请复制 config.example.json 为 config.json 并填入 cookie / csrf / mid。"
|
f"请复制 config.example.json 为 config.json 并填入 cookie / csrf / mid。"
|
||||||
)
|
)
|
||||||
with open(CONFIG_PATH, encoding="utf-8") as f:
|
with open(CONFIG_PATH, encoding="utf-8") as f:
|
||||||
cfg = json.load(f)
|
raw = json.load(f)
|
||||||
|
bilibili = raw.get("bilibili")
|
||||||
|
if not isinstance(bilibili, dict) or not isinstance(bilibili.get("write"), dict):
|
||||||
|
raise SystemExit("config.json 必须配置 bilibili.write(扫码请运行 python app.py auth --account write)")
|
||||||
|
write = bilibili["write"]
|
||||||
|
cfg = {
|
||||||
|
"mid": write.get("mid", 0),
|
||||||
|
"csrf": write.get("csrf", ""),
|
||||||
|
"cookie": write.get("cookie", ""),
|
||||||
|
"overrides": raw.get("overrides", {}),
|
||||||
|
"sleep": raw.get("sleep", {}),
|
||||||
|
}
|
||||||
for k, v in DEFAULT_CONFIG.items():
|
for k, v in DEFAULT_CONFIG.items():
|
||||||
cfg.setdefault(k, v)
|
cfg.setdefault(k, v)
|
||||||
for k, v in DEFAULT_CONFIG["sleep"].items():
|
for k, v in DEFAULT_CONFIG["sleep"].items():
|
||||||
cfg["sleep"].setdefault(k, v)
|
cfg["sleep"].setdefault(k, v)
|
||||||
if require_auth and (not cfg["cookie"] or not cfg["csrf"]):
|
if require_auth and (not cfg["cookie"] or not cfg["csrf"]):
|
||||||
raise SystemExit("config.json 中的 cookie / csrf 不能为空(可先运行 python favsort.py auth)")
|
raise SystemExit("config.json 中的 bilibili.write.cookie / csrf 不能为空(可先运行 python app.py auth --account write)")
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -152,95 +128,6 @@ def post_form(cfg, url, data):
|
|||||||
return json.loads(r.read().decode("utf-8", "replace"))
|
return json.loads(r.read().decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- cookies
|
|
||||||
|
|
||||||
def ensure_device_cookies(cfg):
|
|
||||||
"""补齐缺失的设备 cookie;已有的一律不动(设备指纹换新反而更容易触发风控)。"""
|
|
||||||
pairs = parse_cookie(cfg["cookie"])
|
|
||||||
lower = {k.lower() for k, _ in pairs}
|
|
||||||
added = []
|
|
||||||
|
|
||||||
if "buvid3" not in lower or "buvid4" not in lower:
|
|
||||||
try:
|
|
||||||
r = get_json(cfg, API["spi"].format(base=BASE), auth=True)
|
|
||||||
data = r.get("data") or {}
|
|
||||||
except Exception: # noqa: BLE001 补不到就算了,保留原有数据
|
|
||||||
data = {}
|
|
||||||
if data.get("b_3") and "buvid3" not in lower:
|
|
||||||
pairs.append(("buvid3", data["b_3"]))
|
|
||||||
lower.add("buvid3")
|
|
||||||
added.append("buvid3")
|
|
||||||
if data.get("b_4") and "buvid4" not in lower:
|
|
||||||
pairs.append(("buvid4", data["b_4"]))
|
|
||||||
lower.add("buvid4")
|
|
||||||
added.append("buvid4")
|
|
||||||
|
|
||||||
if "_uuid" not in lower:
|
|
||||||
pairs.append(("_uuid", gen_uuid()))
|
|
||||||
lower.add("_uuid")
|
|
||||||
added.append("_uuid")
|
|
||||||
|
|
||||||
if "b_nut" not in lower and "buvid3" in added:
|
|
||||||
pairs.append(("b_nut", str(int(time.time()))))
|
|
||||||
added.append("b_nut")
|
|
||||||
|
|
||||||
cfg["cookie"] = "; ".join(f"{k}={v}" for k, v in pairs)
|
|
||||||
return added
|
|
||||||
|
|
||||||
|
|
||||||
def save_config(cfg):
|
|
||||||
"""原子写入 config.json,并留一份 .bak。"""
|
|
||||||
if os.path.exists(CONFIG_PATH):
|
|
||||||
shutil.copy2(CONFIG_PATH, CONFIG_BACKUP)
|
|
||||||
tmp = CONFIG_PATH + ".tmp"
|
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
|
||||||
f.write("\n")
|
|
||||||
os.replace(tmp, CONFIG_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- qr
|
|
||||||
|
|
||||||
def write_qr_png(text, path, scale=8, border=4):
|
|
||||||
"""把文本编码成 QR 并写成 8bit 灰度 PNG(不依赖任何图像库)。"""
|
|
||||||
if qrcodegen is None:
|
|
||||||
raise RuntimeError("缺少 vendor/qrcodegen.py")
|
|
||||||
qr = qrcodegen.QrCode.encode_text(text, qrcodegen.QrCode.Ecc.MEDIUM)
|
|
||||||
size = qr.get_size()
|
|
||||||
dim = (size + border * 2) * scale
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for y in range(dim):
|
|
||||||
my = y // scale - border
|
|
||||||
row = bytearray([0]) # PNG 行过滤类型 0
|
|
||||||
for x in range(dim):
|
|
||||||
mx = x // scale - border
|
|
||||||
dark = 0 <= mx < size and 0 <= my < size and qr.get_module(mx, my)
|
|
||||||
row.append(0 if dark else 255)
|
|
||||||
rows.append(bytes(row))
|
|
||||||
raw = b"".join(rows)
|
|
||||||
|
|
||||||
def chunk(tag, data):
|
|
||||||
return (struct.pack(">I", len(data)) + tag + data
|
|
||||||
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
|
|
||||||
|
|
||||||
png = b"\x89PNG\r\n\x1a\n"
|
|
||||||
png += chunk(b"IHDR", struct.pack(">IIBBBBB", dim, dim, 8, 0, 0, 0, 0))
|
|
||||||
png += chunk(b"IDAT", zlib.compress(raw, 9))
|
|
||||||
png += chunk(b"IEND", b"")
|
|
||||||
with open(path, "wb") as f:
|
|
||||||
f.write(png)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def open_path(path):
|
|
||||||
"""用系统默认程序打开文件(Windows 用 startfile,其它平台用 webbrowser)。"""
|
|
||||||
if os.name == "nt":
|
|
||||||
os.startfile(path) # type: ignore[attr-defined]
|
|
||||||
return True
|
|
||||||
return webbrowser.open("file://" + path)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_ids(cfg, media_id):
|
def fetch_ids(cfg, media_id):
|
||||||
r = get_json(cfg, API["ids"].format(base=BASE, mid=media_id))
|
r = get_json(cfg, API["ids"].format(base=BASE, mid=media_id))
|
||||||
if r.get("code") != 0:
|
if r.get("code") != 0:
|
||||||
@@ -419,185 +306,6 @@ def load_plan(media_id):
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- commands
|
# ---------------------------------------------------------------- commands
|
||||||
|
|
||||||
def poll_qr(cfg, qrcode_key):
|
|
||||||
"""轮询扫码状态,同时拿到本轮的 Set-Cookie。"""
|
|
||||||
url = API["qr_poll"].format(passport=PASSPORT, key=urllib.parse.quote(qrcode_key))
|
|
||||||
req = urllib.request.Request(url, headers=make_headers(cfg, auth=False))
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as r:
|
|
||||||
payload = json.loads(r.read().decode("utf-8", "replace"))
|
|
||||||
return payload, (r.headers.get_all("Set-Cookie") or [])
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_nav(cfg):
|
|
||||||
return get_json(cfg, API["nav"].format(base=BASE), auth=True)
|
|
||||||
|
|
||||||
|
|
||||||
def auth_check(cfg):
|
|
||||||
"""只读:检查当前 cookie 是否有效、SESSDATA 何时到期、设备 cookie 是否齐全。"""
|
|
||||||
device = [k for k in ("buvid3", "buvid4", "_uuid", "buvid_fp")
|
|
||||||
if cookie_value(cfg["cookie"], k)]
|
|
||||||
print(f"[auth] 设备 cookie: {', '.join(device) if device else '(缺失)'}")
|
|
||||||
if not device:
|
|
||||||
print("[auth] 提示:缺少 buvid3/buvid4 等设备指纹,infos 接口可能返回 412")
|
|
||||||
|
|
||||||
exp = sessdata_expiry(cfg["cookie"])
|
|
||||||
if exp:
|
|
||||||
days = (exp - time.time()) / 86400
|
|
||||||
print(f"[auth] SESSDATA 到期: {time.strftime('%Y-%m-%d', time.localtime(exp))}"
|
|
||||||
f"(剩余 {days:.0f} 天)")
|
|
||||||
|
|
||||||
if not cfg["cookie"]:
|
|
||||||
print("[auth] 未登录:config.json 里没有 cookie")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
r = fetch_nav(cfg)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
print(f"[auth] 检查失败: {e}")
|
|
||||||
return 1
|
|
||||||
data = r.get("data") or {}
|
|
||||||
if r.get("code") != 0 or not data.get("isLogin"):
|
|
||||||
print(f"[auth] 登录已失效 ❌ code={r.get('code')} {r.get('message')}")
|
|
||||||
print("[auth] 运行 `python favsort.py auth` 扫码重新登录")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
print(f"[auth] 已登录 ✅ mid={data.get('mid')} uname={data.get('uname')}")
|
|
||||||
if exp and exp < time.time():
|
|
||||||
print("[auth] 警告:SESSDATA 时间戳已过期但接口仍返回登录态,建议尽快更新")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def finish_auth(cfg, pairs):
|
|
||||||
"""合并新身份 cookie -> 补设备 cookie -> 写配置 -> 校验,失败回滚。"""
|
|
||||||
old_cookie = cfg["cookie"]
|
|
||||||
merged = merge_cookie(old_cookie, pairs)
|
|
||||||
if not cookie_value(merged, "SESSDATA") or not cookie_value(merged, "bili_jct"):
|
|
||||||
print("[auth] 合并后缺少 SESSDATA / bili_jct,中止")
|
|
||||||
return 2
|
|
||||||
|
|
||||||
cfg["cookie"] = merged
|
|
||||||
cfg["csrf"] = cookie_value(merged, "bili_jct")
|
|
||||||
updated = sorted(k for k in pairs if k.lower() in IDENTITY_KEYS)
|
|
||||||
print(f"[auth] 登录成功,更新身份字段: {', '.join(updated)}")
|
|
||||||
|
|
||||||
added = ensure_device_cookies(cfg)
|
|
||||||
if added:
|
|
||||||
print(f"[auth] 补齐缺失的设备字段: {', '.join(added)}")
|
|
||||||
kept = [k for k in ("buvid3", "buvid4", "_uuid", "buvid_fp")
|
|
||||||
if cookie_value(cfg["cookie"], k)]
|
|
||||||
print(f"[auth] 保留设备指纹: {', '.join(kept) if kept else '(无)'}")
|
|
||||||
if not cookie_value(cfg["cookie"], "buvid3") or not cookie_value(cfg["cookie"], "buvid4"):
|
|
||||||
print("[auth] 警告:buvid3/buvid4 仍缺失,infos 接口可能返回 412")
|
|
||||||
|
|
||||||
new_mid = cookie_value(cfg["cookie"], "DedeUserID")
|
|
||||||
if new_mid and new_mid.isdigit() and int(cfg.get("mid") or 0) != int(new_mid):
|
|
||||||
print(f"[auth] 注意:登录账号 mid={new_mid},与 config 里的 mid={cfg.get('mid')} 不同,"
|
|
||||||
f"已同步为 {new_mid}")
|
|
||||||
cfg["mid"] = int(new_mid)
|
|
||||||
|
|
||||||
exp = sessdata_expiry(cfg["cookie"])
|
|
||||||
if exp:
|
|
||||||
print(f"[auth] 新 SESSDATA 到期: "
|
|
||||||
f"{time.strftime('%Y-%m-%d', time.localtime(exp))}")
|
|
||||||
|
|
||||||
save_config(cfg)
|
|
||||||
print(f"[auth] 已写入 {CONFIG_PATH}(备份 {os.path.basename(CONFIG_BACKUP)})")
|
|
||||||
|
|
||||||
try:
|
|
||||||
r = fetch_nav(cfg)
|
|
||||||
ok = r.get("code") == 0 and (r.get("data") or {}).get("isLogin")
|
|
||||||
uname = (r.get("data") or {}).get("uname")
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
ok, uname = False, str(e)
|
|
||||||
|
|
||||||
if not ok:
|
|
||||||
if os.path.exists(CONFIG_BACKUP):
|
|
||||||
shutil.copy2(CONFIG_BACKUP, CONFIG_PATH)
|
|
||||||
print("[auth] 登录校验失败,已回滚 config.json")
|
|
||||||
else:
|
|
||||||
print("[auth] 登录校验失败(没有备份可回滚)")
|
|
||||||
return 4
|
|
||||||
|
|
||||||
print(f"[auth] 校验通过 ✅ uname={uname}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_auth(cfg, args):
|
|
||||||
if args.check:
|
|
||||||
return auth_check(cfg)
|
|
||||||
|
|
||||||
print("[auth] 申请扫码登录 ...")
|
|
||||||
gen = get_json(cfg, API["qr_generate"].format(passport=PASSPORT), auth=False)
|
|
||||||
data = gen.get("data") or {}
|
|
||||||
qrcode_key, url = data.get("qrcode_key"), data.get("url")
|
|
||||||
if gen.get("code") != 0 or not qrcode_key or not url:
|
|
||||||
print(f"[auth] 申请二维码失败: code={gen.get('code')} {gen.get('message')}")
|
|
||||||
return 2
|
|
||||||
|
|
||||||
qr_path = os.path.join(tempfile.gettempdir(), f"bili-fav-login-{qrcode_key[:8]}.png")
|
|
||||||
try:
|
|
||||||
write_qr_png(url, qr_path)
|
|
||||||
print(f"[auth] 二维码已生成: {qr_path}")
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
print(f"[auth] 本地生成二维码失败: {e}")
|
|
||||||
print(f"[auth] 请手动处理这个链接: {url}")
|
|
||||||
else:
|
|
||||||
if not args.no_browser:
|
|
||||||
try:
|
|
||||||
if open_path(qr_path):
|
|
||||||
print("[auth] 已用默认看图工具打开二维码")
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
print(f"[auth] 打开图片失败: {e},请手动打开上面的路径")
|
|
||||||
|
|
||||||
print(f"[auth] 请在 {args.timeout} 秒内用 B 站客户端扫码并确认(二维码约 180 秒有效)")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
deadline = time.time() + args.timeout
|
|
||||||
last = None
|
|
||||||
while time.time() < deadline:
|
|
||||||
try:
|
|
||||||
payload, set_cookies = poll_qr(cfg, qrcode_key)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
print(f"[auth] 轮询异常: {e}")
|
|
||||||
time.sleep(3)
|
|
||||||
continue
|
|
||||||
|
|
||||||
data = payload.get("data") or {}
|
|
||||||
state = data.get("code")
|
|
||||||
if state is None:
|
|
||||||
print(f"[auth] 轮询失败: code={payload.get('code')} {payload.get('message')}")
|
|
||||||
return 2
|
|
||||||
|
|
||||||
if state == 0:
|
|
||||||
pairs = cookies_from_set_cookie(set_cookies)
|
|
||||||
# 老版本会把 cookie 放在跨域 URL 的 query 里,作为兜底保留
|
|
||||||
if data.get("url"):
|
|
||||||
for part in urllib.parse.urlparse(data["url"]).query.split("&"):
|
|
||||||
k, _, v = part.partition("=")
|
|
||||||
if k and v:
|
|
||||||
pairs.setdefault(k, v)
|
|
||||||
got = "; ".join(f"{k}={v}" for k, v in pairs.items())
|
|
||||||
if not cookie_value(got, "SESSDATA") or not cookie_value(got, "bili_jct"):
|
|
||||||
print("[auth] 扫码成功,但响应里没有 SESSDATA / bili_jct,无法继续")
|
|
||||||
return 2
|
|
||||||
return finish_auth(cfg, pairs)
|
|
||||||
|
|
||||||
if state == 86038:
|
|
||||||
print("[auth] 二维码已过期,请重新运行 auth")
|
|
||||||
return 3
|
|
||||||
|
|
||||||
if state != last:
|
|
||||||
msg = data.get("message") or {
|
|
||||||
86101: "未扫码",
|
|
||||||
86090: "已扫码,等待确认",
|
|
||||||
}.get(state, f"状态 {state}")
|
|
||||||
print(f"[auth] {msg} ...")
|
|
||||||
last = state
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
print("[auth] 等待超时,二维码已失效,请重新运行 auth")
|
|
||||||
return 3
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_folders(cfg, args):
|
def cmd_folders(cfg, args):
|
||||||
# 注意:list-all 是"列出我自己的收藏夹",匿名调用只会返回公开收藏夹(实测 11 -> 3),
|
# 注意:list-all 是"列出我自己的收藏夹",匿名调用只会返回公开收藏夹(实测 11 -> 3),
|
||||||
@@ -823,8 +531,7 @@ def cmd_verify(cfg, args):
|
|||||||
# ---------------------------------------------------------------- main
|
# ---------------------------------------------------------------- main
|
||||||
|
|
||||||
COMMAND_HELP = {
|
COMMAND_HELP = {
|
||||||
"auth": "扫码登录并更新 config.json 里失效的身份 cookie(保留设备指纹)",
|
"folders": "列出写账号(bilibili.write.mid)的自建收藏夹及其 media_id",
|
||||||
"folders": "列出自己(config.mid)的自建收藏夹及其 media_id",
|
|
||||||
"plan": "拉取收藏夹数据并生成排序计划(只读,不改动收藏夹)",
|
"plan": "拉取收藏夹数据并生成排序计划(只读,不改动收藏夹)",
|
||||||
"show": "查看已生成的计划与完整移动脚本(离线)",
|
"show": "查看已生成的计划与完整移动脚本(离线)",
|
||||||
"replan": "离线重算移动脚本(不联网,不改目标顺序)",
|
"replan": "离线重算移动脚本(不联网,不改目标顺序)",
|
||||||
@@ -844,18 +551,10 @@ def build_parser():
|
|||||||
|
|
||||||
sub.add_parser("folders", help=COMMAND_HELP["folders"])
|
sub.add_parser("folders", help=COMMAND_HELP["folders"])
|
||||||
|
|
||||||
pa = sub.add_parser("auth", help=COMMAND_HELP["auth"])
|
|
||||||
pa.add_argument("--check", action="store_true",
|
|
||||||
help="只检查当前登录状态与到期时间,不修改配置")
|
|
||||||
pa.add_argument("--no-browser", action="store_true",
|
|
||||||
help="不自动打开二维码图片,只打印图片路径")
|
|
||||||
pa.add_argument("--timeout", type=int, default=180, metavar="SEC",
|
|
||||||
help="等待扫码的超时时间(默认 180 秒,与二维码有效期一致)")
|
|
||||||
|
|
||||||
pp = sub.add_parser("plan", help=COMMAND_HELP["plan"])
|
pp = sub.add_parser("plan", help=COMMAND_HELP["plan"])
|
||||||
pp.add_argument("media_id", type=int, help="收藏夹 media_id(数字,见 folders 输出)")
|
pp.add_argument("media_id", type=int, help="收藏夹 media_id(数字,见 folders 输出)")
|
||||||
pp.add_argument("--folder-mid", type=int, default=None, metavar="N",
|
pp.add_argument("--folder-mid", type=int, default=None, metavar="N",
|
||||||
help="收藏夹归属用户 mid(默认取 config.json 的 mid)")
|
help="收藏夹归属用户 mid(默认取 bilibili.write.mid)")
|
||||||
pp.add_argument("--verbose", "-v", action="store_true", help="打印完整移动脚本")
|
pp.add_argument("--verbose", "-v", action="store_true", help="打印完整移动脚本")
|
||||||
|
|
||||||
ps = sub.add_parser("show", help=COMMAND_HELP["show"])
|
ps = sub.add_parser("show", help=COMMAND_HELP["show"])
|
||||||
@@ -907,14 +606,13 @@ def print_help(parser, sub, topic=None):
|
|||||||
else:
|
else:
|
||||||
print(f" {a.dest.upper():<26} {a.help or ''}")
|
print(f" {a.dest.upper():<26} {a.help or ''}")
|
||||||
print("\n示例:")
|
print("\n示例:")
|
||||||
print(" python favsort.py auth")
|
print(" python app.py auth --account write")
|
||||||
print(" python favsort.py auth --check")
|
|
||||||
print(" python favsort.py folders")
|
print(" python favsort.py folders")
|
||||||
print(" python favsort.py plan 3700023351 -v")
|
print(" python favsort.py plan 3700023351 -v")
|
||||||
print(" python favsort.py run 3700023351 --verify-every 25")
|
print(" python favsort.py run 3700023351 --verify-every 25")
|
||||||
print(" python favsort.py verify 3700023351")
|
print(" python favsort.py verify 3700023351")
|
||||||
print("\n鉴权:auth 扫码后只更新身份 cookie,保留设备指纹;"
|
print("\n鉴权:使用 python app.py auth --account write;"
|
||||||
"只读请求自动去掉账号身份;run 的 sort 仍使用完整 cookie + csrf。")
|
"本工具的所有请求使用 bilibili.write,run 的 sort 使用完整 Cookie + csrf。")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -925,11 +623,10 @@ def main():
|
|||||||
if args.cmd in (None, "help"):
|
if args.cmd in (None, "help"):
|
||||||
return print_help(p, sub, getattr(args, "topic", None))
|
return print_help(p, sub, getattr(args, "topic", None))
|
||||||
|
|
||||||
cfg = load_config(require_auth=args.cmd != "auth")
|
cfg = load_config(require_auth=True)
|
||||||
ensure_data_dir()
|
ensure_data_dir()
|
||||||
|
|
||||||
handler = {
|
handler = {
|
||||||
"auth": cmd_auth,
|
|
||||||
"folders": cmd_folders,
|
"folders": cmd_folders,
|
||||||
"plan": cmd_plan,
|
"plan": cmd_plan,
|
||||||
"show": cmd_show,
|
"show": cmd_show,
|
||||||
|
|||||||
+11
-3
@@ -90,10 +90,17 @@
|
|||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"bilibili": {
|
"bilibili": {
|
||||||
|
"read": {
|
||||||
"cookie": "SESSDATA=...; bili_jct=...; buvid3=...; ...",
|
"cookie": "SESSDATA=...; bili_jct=...; buvid3=...; ...",
|
||||||
"csrf": "<bili_jct>",
|
"csrf": "<read bili_jct>",
|
||||||
"mid": 28802351
|
"mid": 28802351
|
||||||
},
|
},
|
||||||
|
"write": {
|
||||||
|
"cookie": "SESSDATA=...; bili_jct=...; buvid3=...; ...",
|
||||||
|
"csrf": "<write bili_jct>",
|
||||||
|
"mid": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"bot_token": "123456:ABC...",
|
"bot_token": "123456:ABC...",
|
||||||
"chat_id": "-1001234567890"
|
"chat_id": "-1001234567890"
|
||||||
@@ -126,7 +133,8 @@
|
|||||||
- **黑名单本身放数据库**(`up_blacklist` 表),因为要从通知里动态拉黑;配置文件只做初始种子;
|
- **黑名单本身放数据库**(`up_blacklist` 表),因为要从通知里动态拉黑;配置文件只做初始种子;
|
||||||
- 日志里**永不打印 token / cookie**,启动时只打印"已配置/未配置";
|
- 日志里**永不打印 token / cookie**,启动时只打印"已配置/未配置";
|
||||||
- 容器里把配置目录挂成**可写卷**(`-v ./config:/config`,权限 600),因为 `auth` 扫码续期后要回写 cookie;
|
- 容器里把配置目录挂成**可写卷**(`-v ./config:/config`,权限 600),因为 `auth` 扫码续期后要回写 cookie;
|
||||||
- `python app.py auth` 复用 `bili-fav` 的扫码流程,只替换身份字段、保留设备指纹;
|
- `python app.py auth --account read|write` 对指定账号扫码续期,只替换该账号身份字段、保留该账号设备指纹;
|
||||||
|
- `read` 只用于年度收藏夹同步、快照和查询;`write` 只用于目标收藏夹、`deal` 和 `sort` 写操作;不支持顶层扁平凭据;
|
||||||
- 可选的 `ENV` 覆盖(`TG_BOT_TOKEN` 等)留给 CI/临时调试,不作为主路径。
|
- 可选的 `ENV` 覆盖(`TG_BOT_TOKEN` 等)留给 CI/临时调试,不作为主路径。
|
||||||
|
|
||||||
### 1.6 初始黑名单(已核对昵称,实测 2026-10-31)
|
### 1.6 初始黑名单(已核对昵称,实测 2026-10-31)
|
||||||
@@ -725,7 +733,7 @@ storage:SQLite WAL,单写线程
|
|||||||
| 风控 `-352/-412` | 采集中断 | 低(实测未见) | 限速 1.2s + 抖动 + 指数退避 + 告警 |
|
| 风控 `-352/-412` | 采集中断 | 低(实测未见) | 限速 1.2s + 抖动 + 指数退避 + 告警 |
|
||||||
| 跨年切换遗漏 | 新视频进错夹 | 低 | 按 pubdate 选夹 + 1 月 1 日提醒 |
|
| 跨年切换遗漏 | 新视频进错夹 | 低 | 按 pubdate 选夹 + 1 月 1 日提醒 |
|
||||||
| SQLite 并发 | 写冲突 | 低 | WAL + 单写线程 |
|
| SQLite 并发 | 写冲突 | 低 | WAL + 单写线程 |
|
||||||
| cookie 过期 | 收藏夹相关接口失败 | 中 | 复用 `bili-fav auth` 扫码续期;`view`/搜索匿名可用作降级 |
|
| cookie 过期 | 收藏夹相关接口失败 | 中 | 使用 `python app.py auth --account read|write` 扫码续期;`view`/搜索匿名可用作降级 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""扫码授权流程;仅由 ``python app.py auth`` 调用。
|
||||||
|
|
||||||
|
认证结果只写入指定的 ``bilibili.read`` 或 ``bilibili.write`` 段,两个账号的
|
||||||
|
身份及设备指纹 Cookie 绝不互相覆盖。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import webbrowser
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
|
from .auth import (
|
||||||
|
IDENTITY_KEYS,
|
||||||
|
cookie_value,
|
||||||
|
cookies_from_set_cookie,
|
||||||
|
gen_uuid,
|
||||||
|
make_headers,
|
||||||
|
merge_cookie,
|
||||||
|
parse_cookie,
|
||||||
|
sessdata_expiry,
|
||||||
|
)
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
VENDOR = ROOT / "vendor"
|
||||||
|
if str(VENDOR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(VENDOR))
|
||||||
|
import qrcodegen # noqa: E402
|
||||||
|
|
||||||
|
BASE = "https://api.bilibili.com"
|
||||||
|
PASSPORT = "https://passport.bilibili.com"
|
||||||
|
|
||||||
|
|
||||||
|
def write_qr_png(text: str, path: str | Path, scale: int = 8, border: int = 4) -> Path:
|
||||||
|
"""将登录链接编码为不依赖图像库的 8-bit 灰度 QR PNG。"""
|
||||||
|
qr = qrcodegen.QrCode.encode_text(text, qrcodegen.QrCode.Ecc.MEDIUM)
|
||||||
|
size = qr.get_size()
|
||||||
|
dim = (size + border * 2) * scale
|
||||||
|
rows: list[bytes] = []
|
||||||
|
for y in range(dim):
|
||||||
|
my = y // scale - border
|
||||||
|
row = bytearray([0])
|
||||||
|
for x in range(dim):
|
||||||
|
mx = x // scale - border
|
||||||
|
dark = 0 <= mx < size and 0 <= my < size and qr.get_module(mx, my)
|
||||||
|
row.append(0 if dark else 255)
|
||||||
|
rows.append(bytes(row))
|
||||||
|
|
||||||
|
def chunk(tag: bytes, data: bytes) -> bytes:
|
||||||
|
return struct.pack(">I", len(data)) + tag + data + struct.pack(
|
||||||
|
">I", zlib.crc32(tag + data) & 0xFFFFFFFF
|
||||||
|
)
|
||||||
|
|
||||||
|
output = Path(path)
|
||||||
|
output.write_bytes(
|
||||||
|
b"\x89PNG\r\n\x1a\n"
|
||||||
|
+ chunk(b"IHDR", struct.pack(">IIBBBBB", dim, dim, 8, 0, 0, 0, 0))
|
||||||
|
+ chunk(b"IDAT", zlib.compress(b"".join(rows), 9))
|
||||||
|
+ chunk(b"IEND", b"")
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def open_path(path: Path) -> bool:
|
||||||
|
if os.name == "nt":
|
||||||
|
os.startfile(path) # type: ignore[attr-defined]
|
||||||
|
return True
|
||||||
|
return webbrowser.open(path.as_uri())
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_device_cookies(
|
||||||
|
credentials: dict[str, Any], get_json: Callable[[str, bool], Mapping[str, Any]]
|
||||||
|
) -> list[str]:
|
||||||
|
"""仅为当前账号补齐缺失设备字段,已有指纹不改动。"""
|
||||||
|
pairs = parse_cookie(str(credentials.get("cookie") or ""))
|
||||||
|
lower = {key.lower() for key, _ in pairs}
|
||||||
|
added: list[str] = []
|
||||||
|
if "buvid3" not in lower or "buvid4" not in lower:
|
||||||
|
try:
|
||||||
|
data = get_json(f"{BASE}/x/frontend/finger/spi", True).get("data") or {}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
data = {}
|
||||||
|
if data.get("b_3") and "buvid3" not in lower:
|
||||||
|
pairs.append(("buvid3", str(data["b_3"])))
|
||||||
|
lower.add("buvid3")
|
||||||
|
added.append("buvid3")
|
||||||
|
if data.get("b_4") and "buvid4" not in lower:
|
||||||
|
pairs.append(("buvid4", str(data["b_4"])))
|
||||||
|
lower.add("buvid4")
|
||||||
|
added.append("buvid4")
|
||||||
|
if "_uuid" not in lower:
|
||||||
|
pairs.append(("_uuid", gen_uuid()))
|
||||||
|
lower.add("_uuid")
|
||||||
|
added.append("_uuid")
|
||||||
|
if "b_nut" not in lower and "buvid3" in added:
|
||||||
|
pairs.append(("b_nut", str(int(time.time()))))
|
||||||
|
credentials["cookie"] = "; ".join(f"{key}={value}" for key, value in pairs)
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
def __init__(self, config_path: str | Path, *, account: str, logger=None) -> None:
|
||||||
|
if account not in {"read", "write"}:
|
||||||
|
raise ValueError(f"未知账号角色: {account}")
|
||||||
|
self.path = Path(config_path)
|
||||||
|
self.account = account
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backup_path(self) -> Path:
|
||||||
|
return self.path.with_name(self.path.name + ".bak")
|
||||||
|
|
||||||
|
def _load_raw(self) -> dict[str, Any]:
|
||||||
|
with self.path.open(encoding="utf-8") as fh:
|
||||||
|
raw = json.load(fh)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError("配置文件顶层必须是对象")
|
||||||
|
section = raw.get("bilibili")
|
||||||
|
if not isinstance(section, dict) or not isinstance(section.get(self.account), dict):
|
||||||
|
raise ValueError(f"缺少 bilibili.{self.account} 账号配置")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _request(credentials: Mapping[str, Any], url: str, *, auth: bool) -> tuple[dict[str, Any], list[str]]:
|
||||||
|
req = urllib.request.Request(url, headers=make_headers(dict(credentials), auth=auth))
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as response:
|
||||||
|
return (
|
||||||
|
json.loads(response.read().decode("utf-8", "replace")),
|
||||||
|
response.headers.get_all("Set-Cookie") or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_json(self, credentials: Mapping[str, Any], url: str, auth: bool) -> Mapping[str, Any]:
|
||||||
|
return self._request(credentials, url, auth=auth)[0]
|
||||||
|
|
||||||
|
def _write_raw(self, raw: Mapping[str, Any]) -> None:
|
||||||
|
tmp = self.path.with_name(self.path.name + ".tmp")
|
||||||
|
with tmp.open("w", encoding="utf-8") as fh:
|
||||||
|
json.dump(raw, fh, ensure_ascii=False, indent=2)
|
||||||
|
fh.write("\n")
|
||||||
|
os.replace(tmp, self.path)
|
||||||
|
|
||||||
|
def finish(self, raw: dict[str, Any], pairs: Mapping[str, str]) -> int:
|
||||||
|
"""写入单个账号、验证 nav;验证失败还原整个原配置文件。"""
|
||||||
|
credentials = raw["bilibili"][self.account]
|
||||||
|
old_cookie = str(credentials.get("cookie") or "")
|
||||||
|
merged = merge_cookie(old_cookie, dict(pairs))
|
||||||
|
if not cookie_value(merged, "SESSDATA") or not cookie_value(merged, "bili_jct"):
|
||||||
|
print("[auth] 合并后缺少 SESSDATA / bili_jct,中止")
|
||||||
|
return 2
|
||||||
|
credentials["cookie"] = merged
|
||||||
|
credentials["csrf"] = cookie_value(merged, "bili_jct")
|
||||||
|
added = ensure_device_cookies(
|
||||||
|
credentials,
|
||||||
|
lambda url, auth: self._get_json(credentials, url, auth),
|
||||||
|
)
|
||||||
|
new_mid = cookie_value(str(credentials["cookie"]), "DedeUserID")
|
||||||
|
if new_mid.isdigit():
|
||||||
|
credentials["mid"] = int(new_mid)
|
||||||
|
|
||||||
|
shutil.copy2(self.path, self.backup_path)
|
||||||
|
self._write_raw(raw)
|
||||||
|
try:
|
||||||
|
nav = self._get_json(credentials, f"{BASE}/x/web-interface/nav", True)
|
||||||
|
data = nav.get("data") or {}
|
||||||
|
ok = nav.get("code") == 0 and bool(data.get("isLogin"))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
data, ok = {"uname": str(exc)}, False
|
||||||
|
if not ok:
|
||||||
|
shutil.copy2(self.backup_path, self.path)
|
||||||
|
print(f"[auth] {self.account} 登录校验失败,已回滚 config.json")
|
||||||
|
return 4
|
||||||
|
|
||||||
|
if isinstance(data.get("mid"), int):
|
||||||
|
credentials["mid"] = data["mid"]
|
||||||
|
self._write_raw(raw)
|
||||||
|
updated = sorted(key for key in pairs if key.lower() in IDENTITY_KEYS)
|
||||||
|
kept = [key for key in ("buvid3", "buvid4", "_uuid", "buvid_fp")
|
||||||
|
if cookie_value(str(credentials["cookie"]), key)]
|
||||||
|
print(f"[auth] {self.account} 已更新身份字段: {', '.join(updated)}")
|
||||||
|
if added:
|
||||||
|
print(f"[auth] {self.account} 补齐设备字段: {', '.join(added)}")
|
||||||
|
print(f"[auth] {self.account} 保留设备指纹: {', '.join(kept) if kept else '(无)'}")
|
||||||
|
expiry = sessdata_expiry(str(credentials["cookie"]))
|
||||||
|
if expiry:
|
||||||
|
print(f"[auth] 新 SESSDATA 到期: {time.strftime('%Y-%m-%d', time.localtime(expiry))}")
|
||||||
|
print(f"[auth] {self.account} 校验通过 ✅ mid={credentials['mid']} uname={data.get('uname')}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def run(self, *, timeout: int, no_browser: bool) -> int:
|
||||||
|
raw = self._load_raw()
|
||||||
|
credentials = raw["bilibili"][self.account]
|
||||||
|
print(f"[auth] 为 {self.account} 账号申请扫码登录 ...")
|
||||||
|
generated, _ = self._request(
|
||||||
|
credentials, f"{PASSPORT}/x/passport-login/web/qrcode/generate?source=main-fe-header", auth=False
|
||||||
|
)
|
||||||
|
data = generated.get("data") or {}
|
||||||
|
key, login_url = data.get("qrcode_key"), data.get("url")
|
||||||
|
if generated.get("code") != 0 or not key or not login_url:
|
||||||
|
print(f"[auth] 申请二维码失败: code={generated.get('code')} {generated.get('message')}")
|
||||||
|
return 2
|
||||||
|
qr_path = Path(tempfile.gettempdir()) / f"bili-fav-{self.account}-{key[:8]}.png"
|
||||||
|
write_qr_png(str(login_url), qr_path)
|
||||||
|
print(f"[auth] 二维码已生成: {qr_path}")
|
||||||
|
if not no_browser:
|
||||||
|
try:
|
||||||
|
open_path(qr_path)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"[auth] 打开图片失败: {exc},请手动打开二维码")
|
||||||
|
deadline, last = time.time() + timeout, None
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
payload, set_cookies = self._request(
|
||||||
|
credentials,
|
||||||
|
f"{PASSPORT}/x/passport-login/web/qrcode/poll?qrcode_key={urllib.parse.quote(str(key))}",
|
||||||
|
auth=False,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"[auth] 轮询异常: {exc}")
|
||||||
|
time.sleep(3)
|
||||||
|
continue
|
||||||
|
poll = payload.get("data") or {}
|
||||||
|
state = poll.get("code")
|
||||||
|
if state == 0:
|
||||||
|
pairs = cookies_from_set_cookie(set_cookies)
|
||||||
|
for part in urllib.parse.urlparse(str(poll.get("url") or "")).query.split("&"):
|
||||||
|
key_name, _, value = part.partition("=")
|
||||||
|
if key_name and value:
|
||||||
|
pairs.setdefault(key_name, value)
|
||||||
|
return self.finish(raw, pairs)
|
||||||
|
if state == 86038:
|
||||||
|
print("[auth] 二维码已过期,请重新运行 auth")
|
||||||
|
return 3
|
||||||
|
if state != last:
|
||||||
|
print(f"[auth] {(poll.get('message') or {86101: '未扫码', 86090: '已扫码,等待确认'}.get(state, f'状态 {state}'))} ...")
|
||||||
|
last = state
|
||||||
|
time.sleep(2)
|
||||||
|
print("[auth] 等待超时,二维码已失效,请重新运行 auth")
|
||||||
|
return 3
|
||||||
+48
-20
@@ -19,6 +19,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
from .bilibili.auth_flow import AuthService
|
||||||
from .bilibili.client import BilibiliClient, Pacer
|
from .bilibili.client import BilibiliClient, Pacer
|
||||||
from .config import ConfigError, ConfigStore
|
from .config import ConfigError, ConfigStore
|
||||||
from .database import Database
|
from .database import Database
|
||||||
@@ -58,7 +59,15 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
snapshot.add_argument(
|
snapshot.add_argument(
|
||||||
"--force", action="store_true", help="忽略当日完成标记,重新采集"
|
"--force", action="store_true", help="忽略当日完成标记,重新采集"
|
||||||
)
|
)
|
||||||
sub.add_parser("auth-check", parents=[common], help="检查 Cookie 登录态")
|
auth = sub.add_parser("auth", parents=[common], help="扫码授权并更新指定账号 Cookie")
|
||||||
|
auth.add_argument("--account", choices=("read", "write"), default="read",
|
||||||
|
help="要授权的账号角色(默认 read)")
|
||||||
|
auth.add_argument("--no-browser", action="store_true", help="不自动打开二维码图片")
|
||||||
|
auth.add_argument("--timeout", type=int, default=180, metavar="SEC",
|
||||||
|
help="等待扫码的超时时间(默认 180 秒)")
|
||||||
|
auth_check = sub.add_parser("auth-check", parents=[common], help="检查账号登录态")
|
||||||
|
auth_check.add_argument("--account", choices=("read", "write", "all"), default="read",
|
||||||
|
help="要检查的账号角色(默认 read)")
|
||||||
run = sub.add_parser("run", parents=[common], help="按配置周期常驻运行同步与快照")
|
run = sub.add_parser("run", parents=[common], help="按配置周期常驻运行同步与快照")
|
||||||
run.add_argument(
|
run.add_argument(
|
||||||
"--once", action="store_true", help="只执行一次到期任务后退出"
|
"--once", action="store_true", help="只执行一次到期任务后退出"
|
||||||
@@ -82,11 +91,11 @@ def _print(payload) -> None:
|
|||||||
print(json.dumps(payload, ensure_ascii=False, indent=2), flush=True)
|
print(json.dumps(payload, ensure_ascii=False, indent=2), flush=True)
|
||||||
|
|
||||||
|
|
||||||
def _client(cfg, logger: JsonLogger, *, pacer=None, retries: int = 3) -> BilibiliClient:
|
def _client(credentials, logger: JsonLogger, *, pacer=None, retries: int = 3) -> BilibiliClient:
|
||||||
return BilibiliClient(
|
return BilibiliClient(
|
||||||
cookie=cfg.bilibili.cookie,
|
cookie=credentials.cookie,
|
||||||
csrf=cfg.bilibili.csrf,
|
csrf=credentials.csrf,
|
||||||
mid=cfg.bilibili.mid,
|
mid=credentials.mid,
|
||||||
pacer=pacer,
|
pacer=pacer,
|
||||||
retries=retries,
|
retries=retries,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
@@ -120,7 +129,7 @@ def cmd_sync(args, logger: JsonLogger) -> int:
|
|||||||
db = _open_db(cfg, logger)
|
db = _open_db(cfg, logger)
|
||||||
try:
|
try:
|
||||||
repo = Repository(db)
|
repo = Repository(db)
|
||||||
client = _client(cfg, logger)
|
client = _client(cfg.bilibili.read, logger)
|
||||||
service = FolderSyncService(store, repo, client, logger=logger)
|
service = FolderSyncService(store, repo, client, logger=logger)
|
||||||
summary = service.run(full_refresh=args.full)
|
summary = service.run(full_refresh=args.full)
|
||||||
payload = summary.as_dict()
|
payload = summary.as_dict()
|
||||||
@@ -139,7 +148,7 @@ def cmd_snapshot(args, logger: JsonLogger) -> int:
|
|||||||
try:
|
try:
|
||||||
repo = Repository(db)
|
repo = Repository(db)
|
||||||
client = _client(
|
client = _client(
|
||||||
cfg, logger, pacer=Pacer(1.0, 1.6), retries=1
|
cfg.bilibili.read, logger, pacer=Pacer(1.0, 1.6), retries=1
|
||||||
)
|
)
|
||||||
service = SnapshotService(store, repo, client, logger=logger)
|
service = SnapshotService(store, repo, client, logger=logger)
|
||||||
summary = service.run_if_needed(force=args.force)
|
summary = service.run_if_needed(force=args.force)
|
||||||
@@ -153,23 +162,40 @@ def cmd_snapshot(args, logger: JsonLogger) -> int:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def cmd_auth_check(args, logger: JsonLogger) -> int:
|
def cmd_auth(args, logger: JsonLogger) -> int:
|
||||||
store = ConfigStore(args.config, require_auth=False)
|
if args.timeout < 1:
|
||||||
cfg = store.get()
|
raise ConfigError("auth --timeout 必须至少为 1 秒")
|
||||||
if not cfg.bilibili.cookie:
|
return AuthService(Path(args.config), account=args.account, logger=logger).run(
|
||||||
_print({"ok": False, "is_login": False, "message": "未配置 cookie"})
|
timeout=args.timeout, no_browser=args.no_browser
|
||||||
return 1
|
)
|
||||||
client = _client(cfg, logger, retries=1)
|
|
||||||
state = client.check_login()
|
|
||||||
_print({
|
def _auth_check(cfg, account: str, logger: JsonLogger) -> dict[str, object]:
|
||||||
|
credentials = getattr(cfg.bilibili, account)
|
||||||
|
if not credentials.cookie:
|
||||||
|
return {"account": account, "ok": False, "is_login": False, "message": "未配置 cookie"}
|
||||||
|
state = _client(credentials, logger, retries=1).check_login()
|
||||||
|
return {
|
||||||
|
"account": account,
|
||||||
"ok": state.is_login,
|
"ok": state.is_login,
|
||||||
"is_login": state.is_login,
|
"is_login": state.is_login,
|
||||||
"mid": state.mid,
|
"mid": state.mid,
|
||||||
"uname": state.uname,
|
"uname": state.uname,
|
||||||
"code": state.code,
|
"code": state.code,
|
||||||
"message": state.message,
|
"message": state.message,
|
||||||
})
|
}
|
||||||
return 0 if state.is_login else 1
|
|
||||||
|
|
||||||
|
def cmd_auth_check(args, logger: JsonLogger) -> int:
|
||||||
|
store = ConfigStore(args.config, require_auth=False)
|
||||||
|
cfg = store.get()
|
||||||
|
if args.account != "all":
|
||||||
|
result = _auth_check(cfg, args.account, logger)
|
||||||
|
_print(result)
|
||||||
|
return 0 if result["ok"] else 1
|
||||||
|
results = [_auth_check(cfg, account, logger) for account in ("read", "write")]
|
||||||
|
_print({"ok": all(result["ok"] for result in results), "accounts": results})
|
||||||
|
return 0 if all(result["ok"] for result in results) else 1
|
||||||
|
|
||||||
|
|
||||||
def cmd_run(args, logger: JsonLogger) -> int:
|
def cmd_run(args, logger: JsonLogger) -> int:
|
||||||
@@ -179,9 +205,9 @@ def cmd_run(args, logger: JsonLogger) -> int:
|
|||||||
last: dict[str, object] = {}
|
last: dict[str, object] = {}
|
||||||
try:
|
try:
|
||||||
repo = Repository(db)
|
repo = Repository(db)
|
||||||
folder_client = _client(cfg, logger)
|
folder_client = _client(cfg.bilibili.read, logger)
|
||||||
folder_service = FolderSyncService(store, repo, folder_client, logger=logger)
|
folder_service = FolderSyncService(store, repo, folder_client, logger=logger)
|
||||||
snapshot_client = _client(cfg, logger, pacer=Pacer(1.0, 1.6), retries=1)
|
snapshot_client = _client(cfg.bilibili.read, logger, pacer=Pacer(1.0, 1.6), retries=1)
|
||||||
snapshot_service = SnapshotService(store, repo, snapshot_client, logger=logger)
|
snapshot_service = SnapshotService(store, repo, snapshot_client, logger=logger)
|
||||||
|
|
||||||
def run_sync():
|
def run_sync():
|
||||||
@@ -284,6 +310,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
return cmd_sync(args, logger)
|
return cmd_sync(args, logger)
|
||||||
if args.command == "snapshot":
|
if args.command == "snapshot":
|
||||||
return cmd_snapshot(args, logger)
|
return cmd_snapshot(args, logger)
|
||||||
|
if args.command == "auth":
|
||||||
|
return cmd_auth(args, logger)
|
||||||
if args.command == "auth-check":
|
if args.command == "auth-check":
|
||||||
return cmd_auth_check(args, logger)
|
return cmd_auth_check(args, logger)
|
||||||
if args.command == "run":
|
if args.command == "run":
|
||||||
|
|||||||
+38
-31
@@ -1,13 +1,8 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""运行期配置加载、校验与热加载。
|
"""运行期配置加载、校验与热加载。
|
||||||
|
|
||||||
支持两种写法:
|
B 站凭据必须采用 ``bilibili.read`` / ``bilibili.write`` 双账号格式。敏感值只
|
||||||
|
保存在配置对象中,日志输出必须先经过 ``logging_json`` 脱敏。
|
||||||
* 新格式:``{"bilibili": {"mid": ..., "cookie": ...}, ...}``;
|
|
||||||
* 旧 ``favsort.py`` 格式:顶层 ``mid`` / ``cookie`` / ``csrf``。
|
|
||||||
|
|
||||||
旧的扁平字段仍然被读取,因此 ``bili-fav auth`` 写回的 config.json 无需迁移即可
|
|
||||||
被看板使用。敏感值只保存在配置对象中,日志输出必须先经过 ``logging_json`` 脱敏。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -43,7 +38,10 @@ DEFAULT_HEAT_WEIGHTS: dict[str, float] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict[str, Any] = {
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"bilibili": {"mid": 0, "csrf": "", "cookie": ""},
|
"bilibili": {
|
||||||
|
"read": {"mid": 0, "csrf": "", "cookie": ""},
|
||||||
|
"write": {"mid": 0, "csrf": "", "cookie": ""},
|
||||||
|
},
|
||||||
"telegram": {"enabled": False, "bot_token": "", "chat_id": 0},
|
"telegram": {"enabled": False, "bot_token": "", "chat_id": 0},
|
||||||
"web": {
|
"web": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
@@ -73,9 +71,12 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
|
|
||||||
# 环境变量只作为 CI / 临时调试的覆盖入口,不作为主路径。
|
# 环境变量只作为 CI / 临时调试的覆盖入口,不作为主路径。
|
||||||
ENV_OVERRIDES: dict[str, tuple[tuple[str, ...], type]] = {
|
ENV_OVERRIDES: dict[str, tuple[tuple[str, ...], type]] = {
|
||||||
"BILI_COOKIE": (("bilibili", "cookie"), str),
|
"BILI_READ_COOKIE": (("bilibili", "read", "cookie"), str),
|
||||||
"BILI_CSRF": (("bilibili", "csrf"), str),
|
"BILI_READ_CSRF": (("bilibili", "read", "csrf"), str),
|
||||||
"BILI_MID": (("bilibili", "mid"), int),
|
"BILI_READ_MID": (("bilibili", "read", "mid"), int),
|
||||||
|
"BILI_WRITE_COOKIE": (("bilibili", "write", "cookie"), str),
|
||||||
|
"BILI_WRITE_CSRF": (("bilibili", "write", "csrf"), str),
|
||||||
|
"BILI_WRITE_MID": (("bilibili", "write", "mid"), int),
|
||||||
"TG_BOT_TOKEN": (("telegram", "bot_token"), str),
|
"TG_BOT_TOKEN": (("telegram", "bot_token"), str),
|
||||||
"TG_CHAT_ID": (("telegram", "chat_id"), int),
|
"TG_CHAT_ID": (("telegram", "chat_id"), int),
|
||||||
"DASHBOARD_AUTH_TOKEN": (("web", "auth_token"), str),
|
"DASHBOARD_AUTH_TOKEN": (("web", "auth_token"), str),
|
||||||
@@ -91,12 +92,18 @@ class ConfigError(ValueError):
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BilibiliConfig:
|
class BilibiliCredentials:
|
||||||
mid: int
|
mid: int
|
||||||
csrf: str
|
csrf: str
|
||||||
cookie: str
|
cookie: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BilibiliConfig:
|
||||||
|
read: BilibiliCredentials
|
||||||
|
write: BilibiliCredentials
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TelegramConfig:
|
class TelegramConfig:
|
||||||
enabled: bool
|
enabled: bool
|
||||||
@@ -234,18 +241,6 @@ def _as_str_list(value: Any, name: str) -> list[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _legacy_to_current(raw: Mapping[str, Any]) -> dict[str, Any]:
|
|
||||||
"""把 favsort 的扁平字段映射到新格式,不修改调用方对象。"""
|
|
||||||
data = copy.deepcopy(dict(raw))
|
|
||||||
if "bilibili" not in data:
|
|
||||||
data["bilibili"] = {
|
|
||||||
"mid": raw.get("mid", 0),
|
|
||||||
"csrf": raw.get("csrf", ""),
|
|
||||||
"cookie": raw.get("cookie", ""),
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_folders(value: Any) -> tuple[FolderConfig, ...]:
|
def _validate_folders(value: Any) -> tuple[FolderConfig, ...]:
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
raise ConfigError("folders 必须是数组")
|
raise ConfigError("folders 必须是数组")
|
||||||
@@ -273,12 +268,18 @@ def _build_config(data: Mapping[str, Any], path: Path) -> AppConfig:
|
|||||||
b = data["bilibili"]
|
b = data["bilibili"]
|
||||||
if not isinstance(b, Mapping):
|
if not isinstance(b, Mapping):
|
||||||
raise ConfigError("bilibili 必须是对象")
|
raise ConfigError("bilibili 必须是对象")
|
||||||
bilibili = BilibiliConfig(
|
def credentials(account: str) -> BilibiliCredentials:
|
||||||
mid=_as_int(b.get("mid", 0), "bilibili.mid", minimum=0),
|
value = b.get(account)
|
||||||
csrf=_as_str(b.get("csrf", "") or "", "bilibili.csrf"),
|
if not isinstance(value, Mapping):
|
||||||
cookie=_as_str(b.get("cookie", "") or "", "bilibili.cookie"),
|
raise ConfigError(f"bilibili.{account} 必须是对象")
|
||||||
|
return BilibiliCredentials(
|
||||||
|
mid=_as_int(value.get("mid", 0), f"bilibili.{account}.mid", minimum=0),
|
||||||
|
csrf=_as_str(value.get("csrf", "") or "", f"bilibili.{account}.csrf"),
|
||||||
|
cookie=_as_str(value.get("cookie", "") or "", f"bilibili.{account}.cookie"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
bilibili = BilibiliConfig(read=credentials("read"), write=credentials("write"))
|
||||||
|
|
||||||
tg = data["telegram"]
|
tg = data["telegram"]
|
||||||
if not isinstance(tg, Mapping):
|
if not isinstance(tg, Mapping):
|
||||||
raise ConfigError("telegram 必须是对象")
|
raise ConfigError("telegram 必须是对象")
|
||||||
@@ -431,8 +432,14 @@ def load_config(
|
|||||||
raise ConfigError(f"无法读取配置文件: {config_path}: {exc}") from exc
|
raise ConfigError(f"无法读取配置文件: {config_path}: {exc}") from exc
|
||||||
if not isinstance(raw, Mapping):
|
if not isinstance(raw, Mapping):
|
||||||
raise ConfigError("配置文件顶层必须是 JSON 对象")
|
raise ConfigError("配置文件顶层必须是 JSON 对象")
|
||||||
|
raw_bilibili = raw.get("bilibili")
|
||||||
|
if not isinstance(raw_bilibili, Mapping):
|
||||||
|
raise ConfigError("缺少 bilibili.read / bilibili.write 双账号配置")
|
||||||
|
for account in ("read", "write"):
|
||||||
|
if account not in raw_bilibili:
|
||||||
|
raise ConfigError(f"缺少 bilibili.{account} 账号配置")
|
||||||
|
|
||||||
data = _deep_merge(DEFAULT_CONFIG, _legacy_to_current(raw))
|
data = _deep_merge(DEFAULT_CONFIG, raw)
|
||||||
|
|
||||||
env_map = os.environ if env is None else env
|
env_map = os.environ if env is None else env
|
||||||
for env_name, (target, caster) in ENV_OVERRIDES.items():
|
for env_name, (target, caster) in ENV_OVERRIDES.items():
|
||||||
@@ -447,9 +454,9 @@ def load_config(
|
|||||||
_set_path(data, target, value)
|
_set_path(data, target, value)
|
||||||
|
|
||||||
cfg = _build_config(data, config_path)
|
cfg = _build_config(data, config_path)
|
||||||
if require_auth and (not cfg.bilibili.cookie or not cfg.bilibili.csrf):
|
if require_auth and (not cfg.bilibili.read.cookie or not cfg.bilibili.read.csrf):
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
"config.json 中的 cookie / csrf 不能为空(可先运行 bili-fav auth)"
|
"config.json 中的 bilibili.read.cookie / csrf 不能为空(可先运行 python app.py auth)"
|
||||||
)
|
)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ class FolderSyncService:
|
|||||||
infos = self.client.fav_infos(
|
infos = self.client.fav_infos(
|
||||||
folder.media_id,
|
folder.media_id,
|
||||||
sorted(targets),
|
sorted(targets),
|
||||||
folder_mid=cfg.bilibili.mid,
|
folder_mid=cfg.bilibili.read.mid,
|
||||||
)
|
)
|
||||||
for aid, resource in infos.items.items():
|
for aid, resource in infos.items.items():
|
||||||
if aid not in remote:
|
if aid not in remote:
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ class SnapshotService:
|
|||||||
# ------------------------------------------------------------ internals
|
# ------------------------------------------------------------ internals
|
||||||
|
|
||||||
def _check_auth(self, cfg: AppConfig, now: int) -> tuple[bool, str]:
|
def _check_auth(self, cfg: AppConfig, now: int) -> tuple[bool, str]:
|
||||||
if not cfg.bilibili.cookie:
|
if not cfg.bilibili.read.cookie:
|
||||||
self.repo.kv_set("auth:last_check", str(now))
|
self.repo.kv_set("auth:last_check", str(now))
|
||||||
self.repo.kv_set("auth:ok", "0")
|
self.repo.kv_set("auth:ok", "0")
|
||||||
self.repo.kv_set("auth:message", "未配置 cookie")
|
self.repo.kv_set("auth:message", "未配置 cookie")
|
||||||
|
|||||||
+61
-143
@@ -1,170 +1,88 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""auth 相关纯函数自测(不联网): python tests/test_auth.py"""
|
"""双账号 auth 纯函数与写回隔离测试(不联网): uv run python tests/test_auth.py"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
import favsort as f # noqa: E402
|
from reaction_board.bilibili.auth import ( # noqa: E402
|
||||||
|
cookie_value,
|
||||||
OLD = (
|
cookies_from_set_cookie,
|
||||||
"buvid3=OLD-B3; buvid4=OLD-B4; _uuid=OLD-UUID; buvid_fp=OLD-FP; "
|
merge_cookie,
|
||||||
"b_nut=1700000000; SESSDATA=old%2C1700000000%2Cabc; bili_jct=oldcsrf; "
|
parse_cookie,
|
||||||
"DedeUserID=28802351; DedeUserID__ckMd5=oldmd5; sid=oldsid; b_lsid=OLDL; "
|
sessdata_expiry,
|
||||||
"bili_ticket=ticket; home_feed_column=5"
|
|
||||||
)
|
)
|
||||||
|
from reaction_board.bilibili.auth_flow import AuthService, write_qr_png # noqa: E402
|
||||||
|
|
||||||
|
READ_OLD = "buvid3=READ-B3; {{SECRET_i5bghhpt}}readcsrf; DedeUserID=11; SESSDATA=old%2C1700000000%2Ca"
|
||||||
|
WRITE_OLD = "buvid3=WRITE-B3; bili_jct=writecsrf; DedeUserID=22; SESSDATA=old%2C1700000000%2Cb"
|
||||||
|
NEW = {"SESSDATA": "new%2C1800000000%2Cxyz", "bili_jct": "newcsrf", "DedeUserID": "99"}
|
||||||
|
|
||||||
|
|
||||||
def test_parse_and_merge():
|
def raw_config():
|
||||||
pairs = f.parse_cookie(OLD)
|
return {
|
||||||
assert pairs[0] == ("buvid3", "OLD-B3")
|
"bilibili": {
|
||||||
assert f.cookie_value(OLD, "SESSDATA") == "old%2C1700000000%2Cabc"
|
"read": {"mid": 11, "csrf": "readcsrf", "cookie": READ_OLD},
|
||||||
|
"write": {"mid": 22, "csrf": "writecsrf", "cookie": WRITE_OLD},
|
||||||
new = {
|
},
|
||||||
"SESSDATA": "new%2C1800000000%2Cxyz",
|
"folders": [],
|
||||||
"bili_jct": "newcsrf",
|
|
||||||
"DedeUserID": "28802351",
|
|
||||||
"DedeUserID__ckMd5": "newmd5",
|
|
||||||
"sid": "newsid",
|
|
||||||
}
|
}
|
||||||
merged = f.merge_cookie(OLD, new)
|
|
||||||
# 身份字段被替换
|
|
||||||
assert f.cookie_value(merged, "SESSDATA") == "new%2C1800000000%2Cxyz"
|
|
||||||
assert f.cookie_value(merged, "bili_jct") == "newcsrf"
|
|
||||||
assert f.cookie_value(merged, "sid") == "newsid"
|
|
||||||
# 设备指纹原样保留
|
|
||||||
assert f.cookie_value(merged, "buvid3") == "OLD-B3"
|
|
||||||
assert f.cookie_value(merged, "buvid4") == "OLD-B4"
|
|
||||||
assert f.cookie_value(merged, "_uuid") == "OLD-UUID"
|
|
||||||
assert f.cookie_value(merged, "buvid_fp") == "OLD-FP"
|
|
||||||
assert f.cookie_value(merged, "b_nut") == "1700000000"
|
|
||||||
assert f.cookie_value(merged, "home_feed_column") == "5"
|
|
||||||
# 旧会话字段在没有新值时应被丢弃
|
|
||||||
assert f.cookie_value(merged, "b_lsid") == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_cookie_and_url_fallback():
|
def test_cookie_helpers():
|
||||||
headers = [
|
assert parse_cookie(READ_OLD)[0] == ("buvid3", "READ-B3")
|
||||||
"SESSDATA=abc%2C1800000000%2Cdef; Path=/; Domain=.bilibili.com; HttpOnly",
|
merged = merge_cookie(WRITE_OLD, NEW)
|
||||||
"bili_jct=csrf2; Domain=.bilibili.com",
|
assert cookie_value(merged, "SESSDATA") == "new%2C1800000000%2Cxyz"
|
||||||
"buvid3=SHOULD-NOT-OVERWRITE; Domain=.bilibili.com",
|
assert cookie_value(merged, "bili_jct") == "newcsrf"
|
||||||
]
|
assert cookie_value(merged, "buvid3") == "WRITE-B3"
|
||||||
got = f.cookies_from_set_cookie(headers)
|
assert sessdata_expiry(merged) == 1800000000
|
||||||
assert got["SESSDATA"] == "abc%2C1800000000%2Cdef"
|
headers = ["SESSDATA=x; Path=/", "bili_jct=y; Domain=.bilibili.com"]
|
||||||
assert got["bili_jct"] == "csrf2"
|
assert cookies_from_set_cookie(headers) == {"SESSDATA": "x", "bili_jct": "y"}
|
||||||
|
|
||||||
merged = f.merge_cookie(OLD, got)
|
|
||||||
assert f.cookie_value(merged, "buvid3") == "OLD-B3" # 设备字段不被 Set-Cookie 覆盖
|
|
||||||
|
|
||||||
|
|
||||||
def test_expiry():
|
def test_finish_updates_only_selected_account_and_rolls_back():
|
||||||
assert f.sessdata_expiry(OLD) == 1700000000
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
assert f.sessdata_expiry("SESSDATA=bad") is None
|
path = Path(tmp) / "config.json"
|
||||||
assert f.sessdata_expiry("") is None
|
original = raw_config()
|
||||||
|
path.write_text(json.dumps(original, ensure_ascii=False), encoding="utf-8")
|
||||||
|
service = AuthService(path, account="write")
|
||||||
|
|
||||||
|
service._get_json = lambda credentials, url, auth: { # type: ignore[method-assign]
|
||||||
|
"code": 0, "data": {"isLogin": True, "mid": 99, "uname": "writer"}
|
||||||
|
}
|
||||||
|
assert service.finish(service._load_raw(), NEW) == 0
|
||||||
|
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
assert saved["bilibili"]["read"] == original["bilibili"]["read"]
|
||||||
|
assert saved["bilibili"]["write"]["mid"] == 99
|
||||||
|
assert saved["bilibili"]["write"]["csrf"] == "newcsrf"
|
||||||
|
assert cookie_value(saved["bilibili"]["write"]["cookie"], "buvid3") == "WRITE-B3"
|
||||||
|
assert path.with_name("config.json.bak").exists()
|
||||||
|
|
||||||
def test_ensure_device_cookies():
|
before = path.read_text(encoding="utf-8")
|
||||||
called = {"n": 0}
|
service._get_json = lambda credentials, url, auth: { # type: ignore[method-assign]
|
||||||
|
"code": -101, "data": {"isLogin": False}
|
||||||
def fake_get_json(cfg, url, auth=False):
|
}
|
||||||
called["n"] += 1
|
assert service.finish(service._load_raw(), NEW) == 4
|
||||||
return {"code": 0, "data": {"b_3": "NEW-B3", "b_4": "NEW-B4"}}
|
assert path.read_text(encoding="utf-8") == before
|
||||||
|
|
||||||
orig = f.get_json
|
|
||||||
f.get_json = fake_get_json
|
|
||||||
try:
|
|
||||||
cfg = {"cookie": "SESSDATA=x; bili_jct=y"}
|
|
||||||
added = f.ensure_device_cookies(cfg)
|
|
||||||
assert "buvid3" in added and "buvid4" in added and "_uuid" in added
|
|
||||||
assert f.cookie_value(cfg["cookie"], "buvid3") == "NEW-B3"
|
|
||||||
assert f.cookie_value(cfg["cookie"], "buvid4") == "NEW-B4"
|
|
||||||
assert f.cookie_value(cfg["cookie"], "_uuid").endswith("infoc")
|
|
||||||
|
|
||||||
# 已有设备字段时不应调用 spi,也不应改动
|
|
||||||
called["n"] = 0
|
|
||||||
cfg2 = {"cookie": OLD}
|
|
||||||
added2 = f.ensure_device_cookies(cfg2)
|
|
||||||
assert added2 == [] and called["n"] == 0
|
|
||||||
assert cfg2["cookie"] == OLD
|
|
||||||
finally:
|
|
||||||
f.get_json = orig
|
|
||||||
|
|
||||||
|
|
||||||
def test_finish_auth_write_and_rollback():
|
|
||||||
import json
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
new = {"SESSDATA": "new%2C1800000000%2Cxyz", "bili_jct": "newcsrf",
|
|
||||||
"DedeUserID": "28802351", "DedeUserID__ckMd5": "newmd5"}
|
|
||||||
cfg = {"mid": 28802351, "csrf": "oldcsrf", "cookie": OLD,
|
|
||||||
"overrides": {}, "sleep": {}}
|
|
||||||
old_raw = json.dumps(cfg, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
orig = (f.CONFIG_PATH, f.CONFIG_BACKUP, f.fetch_nav, f.ensure_device_cookies)
|
|
||||||
|
|
||||||
def fake_nav_ok(c):
|
|
||||||
return {"code": 0, "data": {"isLogin": True, "uname": "tester", "mid": 28802351}}
|
|
||||||
|
|
||||||
def fake_nav_bad(c):
|
|
||||||
return {"code": -101, "message": "账号未登录", "data": {"isLogin": False}}
|
|
||||||
|
|
||||||
def fake_device(c):
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
|
||||||
path = os.path.join(d, "config.json")
|
|
||||||
f.CONFIG_PATH, f.CONFIG_BACKUP = path, path + ".bak"
|
|
||||||
f.ensure_device_cookies = fake_device
|
|
||||||
|
|
||||||
with open(path, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(old_raw)
|
|
||||||
|
|
||||||
# 成功:写入新身份字段,设备字段不变,并生成备份
|
|
||||||
f.fetch_nav = fake_nav_ok
|
|
||||||
assert f.finish_auth(cfg, dict(new)) == 0
|
|
||||||
with open(path, encoding="utf-8") as fh:
|
|
||||||
saved = json.load(fh)
|
|
||||||
assert "newcsrf" == saved["csrf"]
|
|
||||||
assert f.cookie_value(saved["cookie"], "buvid3") == "OLD-B3"
|
|
||||||
assert f.cookie_value(saved["cookie"], "SESSDATA") == "new%2C1800000000%2Cxyz"
|
|
||||||
assert os.path.exists(path + ".bak")
|
|
||||||
|
|
||||||
# 失败:用上一份备份回滚
|
|
||||||
before = open(path, encoding="utf-8").read()
|
|
||||||
cfg2 = dict(saved)
|
|
||||||
f.fetch_nav = fake_nav_bad
|
|
||||||
assert f.finish_auth(cfg2, {"SESSDATA": "x%2C1%2Cy", "bili_jct": "z"}) == 4
|
|
||||||
after = open(path, encoding="utf-8").read()
|
|
||||||
assert after == before
|
|
||||||
finally:
|
|
||||||
f.CONFIG_PATH, f.CONFIG_BACKUP, f.fetch_nav, f.ensure_device_cookies = orig
|
|
||||||
|
|
||||||
|
|
||||||
def test_qr_png():
|
def test_qr_png():
|
||||||
import tempfile
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "qr.png"
|
||||||
path = os.path.join(tempfile.gettempdir(), "favsort-test-qr.png")
|
write_qr_png("https://example.com/login?key=abc", path)
|
||||||
try:
|
assert path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n")
|
||||||
f.write_qr_png("https://example.com/login?key=abc&from=test", path)
|
|
||||||
with open(path, "rb") as fh:
|
|
||||||
data = fh.read()
|
|
||||||
assert data.startswith(b"\x89PNG\r\n\x1a\n")
|
|
||||||
assert len(data) > 500
|
|
||||||
finally:
|
|
||||||
if os.path.exists(path):
|
|
||||||
os.remove(path)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
test_parse_and_merge()
|
test_cookie_helpers()
|
||||||
test_set_cookie_and_url_fallback()
|
test_finish_updates_only_selected_account_and_rolls_back()
|
||||||
test_expiry()
|
|
||||||
test_ensure_device_cookies()
|
|
||||||
test_finish_auth_write_and_rollback()
|
|
||||||
test_qr_png()
|
test_qr_png()
|
||||||
print("OK: auth 纯函数测试全部通过")
|
print("OK: 双账号 auth Cookie 合并、写回隔离与回滚测试全部通过")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+44
-4
@@ -22,9 +22,10 @@ import reaction_board.cli as cli # noqa: E402
|
|||||||
def write_config(tmp, *, cookie="SESSDATA=x; buvid3=y", csrf="csrf"):
|
def write_config(tmp, *, cookie="SESSDATA=x; buvid3=y", csrf="csrf"):
|
||||||
path = Path(tmp) / "config.json"
|
path = Path(tmp) / "config.json"
|
||||||
path.write_text(json.dumps({
|
path.write_text(json.dumps({
|
||||||
"mid": 1,
|
"bilibili": {
|
||||||
"csrf": csrf,
|
"read": {"mid": 1, "csrf": csrf, "cookie": cookie},
|
||||||
"cookie": cookie,
|
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-cookie"},
|
||||||
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"database_path": str(Path(tmp) / "app.db"),
|
"database_path": str(Path(tmp) / "app.db"),
|
||||||
"backup_dir": str(Path(tmp) / "backup"),
|
"backup_dir": str(Path(tmp) / "backup"),
|
||||||
@@ -112,6 +113,37 @@ def test_auth_check_without_cookie():
|
|||||||
assert "未配置 cookie" in payload["message"]
|
assert "未配置 cookie" in payload["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_check_all_and_auth_dispatch():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = write_config(tmp, cookie="", csrf="")
|
||||||
|
code, output = run_cli(["auth-check", "--account", "all", "--config", str(path)])
|
||||||
|
assert code == 1
|
||||||
|
payload = json.loads(output)
|
||||||
|
assert payload["ok"] is False
|
||||||
|
assert [item["account"] for item in payload["accounts"]] == ["read", "write"]
|
||||||
|
|
||||||
|
original = cli.AuthService
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class FakeAuthService:
|
||||||
|
def __init__(self, path, *, account, logger):
|
||||||
|
calls.append((str(path), account))
|
||||||
|
|
||||||
|
def run(self, *, timeout, no_browser):
|
||||||
|
assert timeout == 9 and no_browser is True
|
||||||
|
return 7
|
||||||
|
|
||||||
|
cli.AuthService = FakeAuthService
|
||||||
|
try:
|
||||||
|
code, _ = run_cli([
|
||||||
|
"auth", "--account", "write", "--timeout", "9", "--no-browser", "--config", str(path),
|
||||||
|
])
|
||||||
|
finally:
|
||||||
|
cli.AuthService = original
|
||||||
|
assert code == 7
|
||||||
|
assert calls == [(str(path), "write")]
|
||||||
|
|
||||||
|
|
||||||
def test_run_once_offline():
|
def test_run_once_offline():
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
def check_login(self):
|
def check_login(self):
|
||||||
@@ -125,7 +157,13 @@ def test_run_once_offline():
|
|||||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
original = cli._client
|
original = cli._client
|
||||||
cli._client = lambda *args, **kwargs: FakeClient()
|
selected = []
|
||||||
|
|
||||||
|
def fake_client(credentials, *args, **kwargs):
|
||||||
|
selected.append(credentials)
|
||||||
|
return FakeClient()
|
||||||
|
|
||||||
|
cli._client = fake_client
|
||||||
try:
|
try:
|
||||||
code, output = run_cli(["run", "--once", "--config", str(path)])
|
code, output = run_cli(["run", "--once", "--config", str(path)])
|
||||||
finally:
|
finally:
|
||||||
@@ -133,6 +171,7 @@ def test_run_once_offline():
|
|||||||
assert code == 0
|
assert code == 0
|
||||||
assert '"name": "folder_sync"' in output
|
assert '"name": "folder_sync"' in output
|
||||||
assert '"name": "snapshot"' in output
|
assert '"name": "snapshot"' in output
|
||||||
|
assert selected and all(credentials.mid == 1 for credentials in selected)
|
||||||
|
|
||||||
|
|
||||||
def main_test():
|
def main_test():
|
||||||
@@ -141,6 +180,7 @@ def main_test():
|
|||||||
test_sync_requires_auth_without_network()
|
test_sync_requires_auth_without_network()
|
||||||
test_snapshot_offline_empty_db()
|
test_snapshot_offline_empty_db()
|
||||||
test_auth_check_without_cookie()
|
test_auth_check_without_cookie()
|
||||||
|
test_auth_check_all_and_auth_dispatch()
|
||||||
test_run_once_offline()
|
test_run_once_offline()
|
||||||
print("OK: CLI init/sync/snapshot/auth-check/run/status/错误路径测试全部通过")
|
print("OK: CLI init/sync/snapshot/auth-check/run/status/错误路径测试全部通过")
|
||||||
|
|
||||||
|
|||||||
+58
-133
@@ -1,5 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""配置加载 / 校验 / 热加载测试(不联网): uv run python tests/test_config.py"""
|
"""双账号配置加载 / 校验 / 热加载测试(不联网): uv run python tests/test_config.py"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -13,24 +13,22 @@ sys.stdout.reconfigure(encoding="utf-8")
|
|||||||
|
|
||||||
from reaction_board.config import ConfigError, ConfigStore, load_config # noqa: E402
|
from reaction_board.config import ConfigError, ConfigStore, load_config # noqa: E402
|
||||||
|
|
||||||
COOKIE = "SESSDATA=secret%2C1800000000%2Cabc; bili_jct=csrf-secret; buvid3=dev"
|
|
||||||
|
|
||||||
|
|
||||||
def write(path: Path, data) -> None:
|
def write(path: Path, data) -> None:
|
||||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def bump_mtime(path: Path) -> None:
|
def bump_mtime(path: Path) -> None:
|
||||||
"""保证 mtime 严格大于上一次,避免 Windows 时间精度导致热加载测试偶发失败。"""
|
|
||||||
target = max(path.stat().st_mtime_ns, time.time_ns()) + 1_000_000
|
target = max(path.stat().st_mtime_ns, time.time_ns()) + 1_000_000
|
||||||
os.utime(path, ns=(target, target))
|
os.utime(path, ns=(target, target))
|
||||||
|
|
||||||
|
|
||||||
def base_legacy() -> dict:
|
def base_config() -> dict:
|
||||||
return {
|
return {
|
||||||
"mid": 28802351,
|
"bilibili": {
|
||||||
"csrf": "csrf-secret",
|
"read": {"mid": 11, "csrf": "read-csrf", "cookie": "read-cookie"},
|
||||||
"cookie": COOKIE,
|
"write": {"mid": 22, "csrf": "write-csrf", "cookie": "write-cookie"},
|
||||||
|
},
|
||||||
"overrides": {"1": {"mid": 2, "name": "x"}},
|
"overrides": {"1": {"mid": 2, "name": "x"}},
|
||||||
"sleep": {"min": 1},
|
"sleep": {"min": 1},
|
||||||
"folders": [
|
"folders": [
|
||||||
@@ -42,168 +40,95 @@ def base_legacy() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_flat_mapping():
|
def test_dual_accounts_and_env_override():
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "config.json"
|
path = Path(tmp) / "config.json"
|
||||||
write(path, base_legacy())
|
write(path, base_config())
|
||||||
cfg = load_config(path, env={})
|
|
||||||
assert cfg.bilibili.mid == 28802351
|
|
||||||
assert cfg.bilibili.csrf == "csrf-secret"
|
|
||||||
assert cfg.bilibili.cookie == COOKIE
|
|
||||||
assert cfg.storage.database_path == Path(tmp) / "data" / "app.db"
|
|
||||||
assert cfg.storage.backup_dir == Path(tmp) / "data" / "backup"
|
|
||||||
assert cfg.schedule.snapshot_at == "00:01"
|
|
||||||
assert [f.media_id for f in cfg.folders] == [4451053851, 3700023351]
|
|
||||||
assert cfg.track_fans == (37754047,)
|
|
||||||
assert cfg.up_blacklist_seed == (1, 2) # 去重
|
|
||||||
assert cfg.raw["overrides"] # 旧字段仍保留给 favsort
|
|
||||||
assert set(cfg.heat_weights) == {
|
|
||||||
"view", "like", "coin", "favorite", "share", "reply", "danmaku"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_nested_and_env_override():
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
path = Path(tmp) / "config.json"
|
|
||||||
write(path, {
|
|
||||||
"bilibili": {"mid": 1, "csrf": "old", "cookie": "old-cookie"},
|
|
||||||
"storage": {"database_path": "var/app.db", "backup_dir": "var/bak"},
|
|
||||||
"folders": [],
|
|
||||||
})
|
|
||||||
env = {
|
env = {
|
||||||
"BILI_MID": "999",
|
"BILI_READ_MID": "101", "BILI_READ_CSRF": "env-read", "BILI_READ_COOKIE": "env-read-cookie",
|
||||||
"BILI_CSRF": "env-csrf",
|
"BILI_WRITE_MID": "202", "BILI_WRITE_CSRF": "env-write", "BILI_WRITE_COOKIE": "env-write-cookie",
|
||||||
"BILI_COOKIE": "env-cookie",
|
|
||||||
"TG_BOT_TOKEN": "123456:abcdefghijklmnopqrstuvwxyzABCDE",
|
|
||||||
"TG_CHAT_ID": "42",
|
|
||||||
"DASHBOARD_AUTH_TOKEN": "web-token",
|
|
||||||
"DATABASE_PATH": "custom.db",
|
"DATABASE_PATH": "custom.db",
|
||||||
}
|
}
|
||||||
cfg = load_config(path, env=env)
|
cfg = load_config(path, env=env)
|
||||||
assert cfg.bilibili.mid == 999
|
assert (cfg.bilibili.read.mid, cfg.bilibili.read.csrf, cfg.bilibili.read.cookie) == (101, "env-read", "env-read-cookie")
|
||||||
assert cfg.bilibili.csrf == "env-csrf"
|
assert (cfg.bilibili.write.mid, cfg.bilibili.write.csrf, cfg.bilibili.write.cookie) == (202, "env-write", "env-write-cookie")
|
||||||
assert cfg.bilibili.cookie == "env-cookie"
|
|
||||||
assert cfg.telegram.chat_id == 42
|
|
||||||
assert cfg.telegram.bot_token.startswith("123456:")
|
|
||||||
assert cfg.web.auth_token == "web-token"
|
|
||||||
assert cfg.storage.database_path == Path(tmp) / "custom.db"
|
assert cfg.storage.database_path == Path(tmp) / "custom.db"
|
||||||
# 备份目录仍按配置文件目录解析
|
assert cfg.up_blacklist_seed == (1, 2)
|
||||||
assert cfg.storage.backup_dir == Path(tmp) / "var" / "bak"
|
|
||||||
|
|
||||||
|
|
||||||
def test_absolute_paths_kept():
|
def test_flat_and_missing_account_rejected():
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
db = str(Path(tmp) / "abs" / "app.db")
|
|
||||||
path = Path(tmp) / "config.json"
|
|
||||||
write(path, {
|
|
||||||
"bilibili": {"mid": 1, "csrf": "c", "cookie": "k"},
|
|
||||||
"storage": {"database_path": db, "backup_dir": str(Path(tmp) / "b")},
|
|
||||||
"folders": [],
|
|
||||||
})
|
|
||||||
cfg = load_config(path, env={})
|
|
||||||
assert cfg.storage.database_path == Path(db)
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_configs():
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "config.json"
|
path = Path(tmp) / "config.json"
|
||||||
cases = [
|
write(path, {"mid": 1, "csrf": "x", "cookie": "y", "folders": []})
|
||||||
({"folders": [{"year": 2025, "media_id": 1},
|
|
||||||
{"year": 2026, "media_id": 1}]}, "media_id 重复"),
|
|
||||||
({"folders": [{"year": 2025, "media_id": 1},
|
|
||||||
{"year": 2025, "media_id": 2}]}, "year 重复"),
|
|
||||||
({"folders": [{"year": 2025, "media_id": 0}]}, "不能小于 1"),
|
|
||||||
({"schedule": {"snapshot_at": "24:00"}}, "snapshot_at"),
|
|
||||||
({"schedule": {"snapshot_at": "1:01"}}, "snapshot_at"),
|
|
||||||
({"notify": {"max_items_per_message": 0}}, "max_items_per_message"),
|
|
||||||
({"heat_weights": {"view": -1}}, "heat_weights.view"),
|
|
||||||
({"classify": {"require_title": ""}}, "require_title"),
|
|
||||||
]
|
|
||||||
base = {
|
|
||||||
"bilibili": {"mid": 1, "csrf": "c", "cookie": "k"},
|
|
||||||
"folders": [],
|
|
||||||
}
|
|
||||||
for patch, needle in cases:
|
|
||||||
data = json.loads(json.dumps(base))
|
|
||||||
data.update(patch)
|
|
||||||
write(path, data)
|
|
||||||
try:
|
try:
|
||||||
load_config(path, env={})
|
load_config(path, env={})
|
||||||
except ConfigError as exc:
|
except ConfigError as exc:
|
||||||
assert needle in str(exc), (needle, str(exc))
|
assert "bilibili.read" in str(exc)
|
||||||
else:
|
else:
|
||||||
raise AssertionError(f"应当失败: {patch}")
|
raise AssertionError("flat 格式必须拒绝")
|
||||||
|
write(path, {"bilibili": {"read": {}}, "folders": []})
|
||||||
|
try:
|
||||||
|
load_config(path, env={})
|
||||||
|
except ConfigError as exc:
|
||||||
|
assert "bilibili.write" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("缺少 write 必须拒绝")
|
||||||
|
|
||||||
|
|
||||||
def test_require_auth():
|
def test_validation_require_read_auth_and_hot_reload():
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "config.json"
|
path = Path(tmp) / "config.json"
|
||||||
write(path, {"bilibili": {"mid": 1, "csrf": "", "cookie": ""}, "folders": []})
|
data = base_config()
|
||||||
|
data["bilibili"]["read"].update(cookie="", csrf="")
|
||||||
|
write(path, data)
|
||||||
load_config(path, env={}, require_auth=False)
|
load_config(path, env={}, require_auth=False)
|
||||||
try:
|
try:
|
||||||
load_config(path, env={}, require_auth=True)
|
load_config(path, env={}, require_auth=True)
|
||||||
except ConfigError as exc:
|
except ConfigError as exc:
|
||||||
assert "cookie" in str(exc)
|
assert "bilibili.read.cookie" in str(exc)
|
||||||
else:
|
else:
|
||||||
raise AssertionError("require_auth 应拒绝空凭据")
|
raise AssertionError("read 空凭据必须拒绝")
|
||||||
|
|
||||||
|
write(path, base_config())
|
||||||
def test_env_int_invalid():
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
path = Path(tmp) / "config.json"
|
|
||||||
write(path, base_legacy())
|
|
||||||
try:
|
|
||||||
load_config(path, env={"BILI_MID": "abc"})
|
|
||||||
except ConfigError as exc:
|
|
||||||
assert "BILI_MID" in str(exc)
|
|
||||||
else:
|
|
||||||
raise AssertionError("非法整数环境变量应失败")
|
|
||||||
|
|
||||||
|
|
||||||
def test_hot_reload_keeps_last_good_config():
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
path = Path(tmp) / "config.json"
|
|
||||||
write(path, base_legacy())
|
|
||||||
store = ConfigStore(path, env={})
|
store = ConfigStore(path, env={})
|
||||||
assert store.get().folders[0].title == "2025"
|
data = base_config()
|
||||||
|
data["folders"][0]["title"] = "2025-new"
|
||||||
data = base_legacy()
|
|
||||||
data["folders"][0]["title"] = "2025-新"
|
|
||||||
write(path, data)
|
write(path, data)
|
||||||
bump_mtime(path)
|
bump_mtime(path)
|
||||||
assert store.get().folders[0].title == "2025-新"
|
assert store.get().folders[0].title == "2025-new"
|
||||||
assert store.last_error is None
|
path.write_text("{bad", encoding="utf-8")
|
||||||
|
|
||||||
path.write_text("{not json", encoding="utf-8")
|
|
||||||
bump_mtime(path)
|
bump_mtime(path)
|
||||||
assert store.get().folders[0].title == "2025-新"
|
assert store.get().folders[0].title == "2025-new"
|
||||||
assert store.last_error is not None
|
assert store.last_error
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_values():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "config.json"
|
||||||
|
data = base_config()
|
||||||
|
data["bilibili"]["write"]["mid"] = "bad"
|
||||||
write(path, data)
|
write(path, data)
|
||||||
bump_mtime(path)
|
|
||||||
assert store.get().folders[0].title == "2025-新"
|
|
||||||
assert store.last_error is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_file():
|
|
||||||
try:
|
try:
|
||||||
load_config(Path(tempfile.gettempdir()) / "no-such-bili-fav-config.json", env={})
|
load_config(path, env={})
|
||||||
except ConfigError as exc:
|
except ConfigError as exc:
|
||||||
assert "缺少配置文件" in str(exc)
|
assert "bilibili.write.mid" in str(exc)
|
||||||
else:
|
else:
|
||||||
raise AssertionError("缺失配置应失败")
|
raise AssertionError("非法 mid 必须拒绝")
|
||||||
|
write(path, base_config())
|
||||||
|
try:
|
||||||
|
load_config(path, env={"BILI_READ_MID": "bad"})
|
||||||
|
except ConfigError as exc:
|
||||||
|
assert "BILI_READ_MID" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("非法环境变量必须拒绝")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
test_legacy_flat_mapping()
|
test_dual_accounts_and_env_override()
|
||||||
test_nested_and_env_override()
|
test_flat_and_missing_account_rejected()
|
||||||
test_absolute_paths_kept()
|
test_validation_require_read_auth_and_hot_reload()
|
||||||
test_invalid_configs()
|
test_invalid_values()
|
||||||
test_require_auth()
|
print("OK: 双账号配置加载、拒绝 flat 格式、ENV 覆盖与热加载测试全部通过")
|
||||||
test_env_int_invalid()
|
|
||||||
test_hot_reload_keeps_last_good_config()
|
|
||||||
test_missing_file()
|
|
||||||
print("OK: 配置加载、校验与热加载测试全部通过")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""favsort 写账号配置隔离测试: uv run python tests/test_favsort_config.py"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
import favsort # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_loads_write_account_only():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "config.json"
|
||||||
|
path.write_text(json.dumps({
|
||||||
|
"bilibili": {
|
||||||
|
"read": {"mid": 11, "csrf": "read-csrf", "cookie": "read-cookie"},
|
||||||
|
"write": {"mid": 22, "csrf": "write-csrf", "cookie": "write-cookie"},
|
||||||
|
},
|
||||||
|
"overrides": {"1": {"mid": 2, "name": "UP"}},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
old = favsort.CONFIG_PATH
|
||||||
|
favsort.CONFIG_PATH = str(path)
|
||||||
|
try:
|
||||||
|
cfg = favsort.load_config()
|
||||||
|
finally:
|
||||||
|
favsort.CONFIG_PATH = old
|
||||||
|
assert cfg["mid"] == 22
|
||||||
|
assert cfg["csrf"] == "write-csrf"
|
||||||
|
assert cfg["cookie"] == "write-cookie"
|
||||||
|
assert cfg["overrides"] == {"1": {"mid": 2, "name": "UP"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_flat_config():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "config.json"
|
||||||
|
path.write_text(json.dumps({"mid": 1, "csrf": "x", "cookie": "y"}), encoding="utf-8")
|
||||||
|
old = favsort.CONFIG_PATH
|
||||||
|
favsort.CONFIG_PATH = str(path)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
favsort.load_config()
|
||||||
|
except SystemExit as exc:
|
||||||
|
assert "bilibili.write" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("flat config 必须拒绝")
|
||||||
|
finally:
|
||||||
|
favsort.CONFIG_PATH = old
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
test_loads_write_account_only()
|
||||||
|
test_rejects_flat_config()
|
||||||
|
print("OK: favsort 仅使用 bilibili.write 配置测试全部通过")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -74,9 +74,10 @@ class FakeFavTransport:
|
|||||||
|
|
||||||
def write_config(tmp, folders):
|
def write_config(tmp, folders):
|
||||||
data = {
|
data = {
|
||||||
"mid": 28802351,
|
"bilibili": {
|
||||||
"csrf": "csrf-secret",
|
"read": {"mid": 28802351, "csrf": "csrf-secret", "cookie": "SESSDATA=secret; bili_jct=csrf-secret; buvid3=device"},
|
||||||
"cookie": "SESSDATA=secret; bili_jct=csrf-secret; buvid3=device",
|
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-cookie"},
|
||||||
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"database_path": str(Path(tmp) / "app.db"),
|
"database_path": str(Path(tmp) / "app.db"),
|
||||||
"backup_dir": str(Path(tmp) / "backup"),
|
"backup_dir": str(Path(tmp) / "backup"),
|
||||||
|
|||||||
@@ -75,9 +75,10 @@ def make_service(tmp, transport, *, track_fans=(37754047,), logger=None,
|
|||||||
retries=3, cookie="SESSDATA=secret; buvid3=device"):
|
retries=3, cookie="SESSDATA=secret; buvid3=device"):
|
||||||
path = Path(tmp) / "config.json"
|
path = Path(tmp) / "config.json"
|
||||||
path.write_text(json.dumps({
|
path.write_text(json.dumps({
|
||||||
"mid": 28802351,
|
"bilibili": {
|
||||||
"csrf": "csrf",
|
"read": {"mid": 28802351, "csrf": "csrf", "cookie": cookie},
|
||||||
"cookie": cookie,
|
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-cookie"},
|
||||||
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"database_path": str(Path(tmp) / "app.db"),
|
"database_path": str(Path(tmp) / "app.db"),
|
||||||
"backup_dir": str(Path(tmp) / "backup"),
|
"backup_dir": str(Path(tmp) / "backup"),
|
||||||
|
|||||||
Reference in new issue
Block a user