feat(p0): add favorite folder sync and sqlite data foundation
Implement P0 of the reaction dashboard: config loading with hot reload, SQLite migrations with backups, single-writer queue, read-only Bilibili fav client, folder import/diff sync, invalid-resource handling, blacklist seed, scheduler, and JSON redacted logging. Includes offline unit and transport-level contract tests; all existing and new tests pass.
This commit is contained in:
commit
e2cdf997af
39 files changed
+8575
No files matched your search
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B 站 Cookie 与请求头工具。
|
||||
|
||||
从 ``favsort.py`` 抽取,供现有排序工具和看板共用,避免出现两套不一致的
|
||||
身份 Cookie / 设备指纹处理逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
IDENTITY_KEYS = {
|
||||
"sessdata",
|
||||
"bili_jct",
|
||||
"dedeuserid",
|
||||
"dedeuserid__ckmd5",
|
||||
"bili_ticket",
|
||||
"bili_ticket_expires",
|
||||
"sid",
|
||||
"b_lsid",
|
||||
}
|
||||
|
||||
DROP_STALE = {"sid", "b_lsid"} # 会话级字段,刷新时应丢弃旧值
|
||||
|
||||
|
||||
def strip_identity(cookie: str) -> str:
|
||||
"""剥离账号身份类 cookie,保留 buvid3 / buvid4 / _uuid 等设备指纹。
|
||||
|
||||
实测结论:
|
||||
- ids:完全无 cookie 也能读(连私密收藏夹都返回全量);
|
||||
- infos:不需要账号身份,但缺设备指纹 cookie 会被 HTTP 412 拦;
|
||||
- sort:必须带完整 cookie + csrf,不能走这里。
|
||||
"""
|
||||
kept = []
|
||||
for part in (cookie or "").split(";"):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
key = part.split("=", 1)[0].strip().lower()
|
||||
if key in IDENTITY_KEYS:
|
||||
continue
|
||||
kept.append(part)
|
||||
return "; ".join(kept)
|
||||
|
||||
|
||||
def make_headers(cfg: dict, auth: bool = False) -> dict[str, str]:
|
||||
"""构造 B 站 Web 请求头;``auth=False`` 时去掉账号身份 cookie。"""
|
||||
return {
|
||||
"accept": "*/*",
|
||||
"accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"cookie": cfg["cookie"] if auth else strip_identity(cfg["cookie"]),
|
||||
"dnt": "1",
|
||||
"origin": "https://www.bilibili.com",
|
||||
"referer": "https://www.bilibili.com/",
|
||||
"sec-ch-ua": '"Google Chrome";v="155", "Chromium";v="155", "Not(A:Brand";v="24"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-site",
|
||||
"user-agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def parse_cookie(cookie: str) -> list[tuple[str, str]]:
|
||||
"""把 cookie 字符串解析成 [(key, value)],保留原顺序。"""
|
||||
pairs = []
|
||||
for part in (cookie or "").split(";"):
|
||||
part = part.strip()
|
||||
if not part or "=" not in part:
|
||||
continue
|
||||
k, v = part.split("=", 1)
|
||||
pairs.append((k.strip(), v.strip()))
|
||||
return pairs
|
||||
|
||||
|
||||
def cookie_value(cookie: str, name: str) -> str:
|
||||
name = name.lower()
|
||||
for k, v in parse_cookie(cookie):
|
||||
if k.lower() == name:
|
||||
return v
|
||||
return ""
|
||||
|
||||
|
||||
def cookies_from_set_cookie(headers) -> dict[str, str]:
|
||||
"""从 Set-Cookie 头里取 name=value(忽略 Path/Domain/Expires 等属性)。"""
|
||||
pairs = {}
|
||||
for h in headers or []:
|
||||
first = h.split(";", 1)[0].strip()
|
||||
if "=" not in first:
|
||||
continue
|
||||
k, v = first.split("=", 1)
|
||||
pairs[k.strip()] = v.strip()
|
||||
return pairs
|
||||
|
||||
|
||||
def merge_cookie(old: str, new_pairs: dict[str, str]) -> str:
|
||||
"""只替换身份类 cookie,其它字段(设备指纹等)原样保留。
|
||||
|
||||
新值里没有的身份字段不会被删除(例如服务端没下发新的 sid 时保留旧值),
|
||||
只有 DROP_STALE 里的会话字段会在刷新时清掉。
|
||||
"""
|
||||
new_l = {
|
||||
k.lower(): (k, v)
|
||||
for k, v in new_pairs.items()
|
||||
if k.lower() in IDENTITY_KEYS
|
||||
}
|
||||
out, seen = [], set()
|
||||
for k, v in parse_cookie(old):
|
||||
kl = k.lower()
|
||||
if kl in new_l:
|
||||
nk, nv = new_l[kl]
|
||||
out.append((nk, nv))
|
||||
seen.add(kl)
|
||||
elif kl in DROP_STALE:
|
||||
continue
|
||||
else:
|
||||
out.append((k, v))
|
||||
seen.add(kl)
|
||||
for kl, (k, v) in new_l.items():
|
||||
if kl not in seen:
|
||||
out.append((k, v))
|
||||
return "; ".join(f"{k}={v}" for k, v in out)
|
||||
|
||||
|
||||
def gen_uuid() -> str:
|
||||
"""生成 B 站风格的 _uuid(前端本来就是本地生成的,服务端不下发)。"""
|
||||
suffix = "".join(random.choices("0123456789ABCDEF", k=7))
|
||||
return f"{str(uuid.uuid4()).upper()}{suffix}infoc"
|
||||
|
||||
|
||||
def sessdata_expiry(cookie: str) -> int | None:
|
||||
"""SESSDATA 形如 <值>%2C<过期时间戳>%2C<校验>,取中间的时间戳。"""
|
||||
value = urllib.parse.unquote(cookie_value(cookie, "SESSDATA"))
|
||||
parts = value.split(",")
|
||||
if len(parts) >= 2 and parts[1].isdigit():
|
||||
return int(parts[1])
|
||||
return None
|
||||
Reference in new issue
Block a user