602 lines
24 KiB
Python
602 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import ctypes
|
|
import ctypes.wintypes
|
|
import hashlib
|
|
import http.cookiejar
|
|
import http.cookies
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import time
|
|
import urllib.parse
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
|
|
_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
|
|
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDLgd2OAkcGVtoE3ThUREbio0Eg
|
|
Uc/prcajMKXvkCKFCWhJYJcLkcM2DKKcSeFpD/j6Boy538YXnR6VhcuUJOhH2x71
|
|
nzPjfdTcqMz7djHum0qSZA0AyCBDABUqCrfNgCiJ00Ra7GmRj+YCK1NJEuewlb40
|
|
JNrRuoEUXpabUzGB8QIDAQAB
|
|
-----END PUBLIC KEY-----"""
|
|
_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
|
_QR_GENERATE_URL = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate"
|
|
_QR_POLL_URL = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll"
|
|
_QR_HEADERS = {
|
|
"Referer": "https://www.bilibili.com/",
|
|
"Origin": "https://www.bilibili.com",
|
|
}
|
|
|
|
|
|
class _DataBlob(ctypes.Structure):
|
|
_fields_ = [("cbData", ctypes.wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
|
|
|
|
|
|
def _blob(data: bytes) -> tuple[_DataBlob, Any]:
|
|
buffer = ctypes.create_string_buffer(data)
|
|
return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte))), buffer
|
|
|
|
|
|
def _dpapi_encrypt(value: str) -> str:
|
|
if os.name != "nt":
|
|
raise RuntimeError("B站刷新令牌安全存储仅支持 Windows DPAPI")
|
|
source, source_buffer = _blob(value.encode("utf-8"))
|
|
entropy, entropy_buffer = _blob(b"Live-streaming:bilibili-refresh-token:v1")
|
|
output = _DataBlob()
|
|
ok = ctypes.windll.crypt32.CryptProtectData(
|
|
ctypes.byref(source), None, ctypes.byref(entropy), None, None, 0,
|
|
ctypes.byref(output),
|
|
)
|
|
_ = source_buffer, entropy_buffer
|
|
if not ok:
|
|
raise ctypes.WinError()
|
|
try:
|
|
encrypted = ctypes.string_at(output.pbData, output.cbData)
|
|
return base64.b64encode(encrypted).decode("ascii")
|
|
finally:
|
|
ctypes.windll.kernel32.LocalFree(output.pbData)
|
|
|
|
|
|
def _dpapi_decrypt(value: str) -> str:
|
|
if os.name != "nt":
|
|
raise RuntimeError("B站刷新令牌安全存储仅支持 Windows DPAPI")
|
|
source, source_buffer = _blob(base64.b64decode(value))
|
|
entropy, entropy_buffer = _blob(b"Live-streaming:bilibili-refresh-token:v1")
|
|
output = _DataBlob()
|
|
ok = ctypes.windll.crypt32.CryptUnprotectData(
|
|
ctypes.byref(source), None, ctypes.byref(entropy), None, None, 0,
|
|
ctypes.byref(output),
|
|
)
|
|
_ = source_buffer, entropy_buffer
|
|
if not ok:
|
|
raise ctypes.WinError()
|
|
try:
|
|
return ctypes.string_at(output.pbData, output.cbData).decode("utf-8")
|
|
finally:
|
|
ctypes.windll.kernel32.LocalFree(output.pbData)
|
|
|
|
|
|
class BilibiliCredentialStore:
|
|
def __init__(self, path: str | Path):
|
|
self.path = Path(path)
|
|
|
|
def save_refresh_token(self, refresh_token: str) -> None:
|
|
token = str(refresh_token or "").strip()
|
|
if not token:
|
|
raise ValueError("refresh_token 不能为空")
|
|
payload = {
|
|
"version": 1,
|
|
"provider": "windows_dpapi_current_user",
|
|
"refresh_token_protected": _dpapi_encrypt(token),
|
|
}
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
with open(tmp, "w", encoding="utf-8", newline="\n") as handle:
|
|
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
handle.write("\n")
|
|
tmp.replace(self.path)
|
|
|
|
def load_refresh_token(self) -> str:
|
|
if not self.path.exists():
|
|
return ""
|
|
with open(self.path, "r", encoding="utf-8") as handle:
|
|
payload = json.load(handle)
|
|
protected = str(payload.get("refresh_token_protected") or "")
|
|
return _dpapi_decrypt(protected) if protected else ""
|
|
|
|
def is_configured(self) -> bool:
|
|
if not self.path.exists():
|
|
return False
|
|
try:
|
|
with open(self.path, "r", encoding="utf-8") as handle:
|
|
payload = json.load(handle)
|
|
return bool(str(payload.get("refresh_token_protected") or ""))
|
|
except (OSError, ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
def _read_der_length(data: bytes, offset: int) -> tuple[int, int]:
|
|
first = data[offset]
|
|
offset += 1
|
|
if first < 0x80:
|
|
return first, offset
|
|
count = first & 0x7F
|
|
return int.from_bytes(data[offset:offset + count], "big"), offset + count
|
|
|
|
|
|
def _read_der_tlv(data: bytes, offset: int, expected_tag: int | None = None) -> tuple[int, bytes, int]:
|
|
tag = data[offset]
|
|
if expected_tag is not None and tag != expected_tag:
|
|
raise ValueError(f"DER tag 不匹配: expected={expected_tag:#x}, actual={tag:#x}")
|
|
length, content_offset = _read_der_length(data, offset + 1)
|
|
end = content_offset + length
|
|
return tag, data[content_offset:end], end
|
|
|
|
|
|
def _public_numbers() -> tuple[int, int]:
|
|
body = "".join(line for line in _PUBLIC_KEY_PEM.splitlines() if not line.startswith("-----"))
|
|
der = base64.b64decode(body)
|
|
_, spki, _ = _read_der_tlv(der, 0, 0x30)
|
|
_, _, offset = _read_der_tlv(spki, 0, 0x30)
|
|
_, bit_string, _ = _read_der_tlv(spki, offset, 0x03)
|
|
_, rsa_key, _ = _read_der_tlv(bit_string[1:], 0, 0x30)
|
|
_, modulus_bytes, rsa_offset = _read_der_tlv(rsa_key, 0, 0x02)
|
|
_, exponent_bytes, _ = _read_der_tlv(rsa_key, rsa_offset, 0x02)
|
|
return int.from_bytes(modulus_bytes, "big"), int.from_bytes(exponent_bytes, "big")
|
|
|
|
|
|
def _mgf1(seed: bytes, length: int) -> bytes:
|
|
result = bytearray()
|
|
counter = 0
|
|
while len(result) < length:
|
|
result.extend(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest())
|
|
counter += 1
|
|
return bytes(result[:length])
|
|
|
|
|
|
def _rsa_oaep_sha256_encrypt(message: bytes) -> str:
|
|
modulus, exponent = _public_numbers()
|
|
key_size = (modulus.bit_length() + 7) // 8
|
|
digest_size = hashlib.sha256().digest_size
|
|
if len(message) > key_size - 2 * digest_size - 2:
|
|
raise ValueError("待加密内容过长")
|
|
label_hash = hashlib.sha256(b"").digest()
|
|
padding = b"\x00" * (key_size - len(message) - 2 * digest_size - 2)
|
|
data_block = label_hash + padding + b"\x01" + message
|
|
seed = secrets.token_bytes(digest_size)
|
|
data_mask = _mgf1(seed, key_size - digest_size - 1)
|
|
masked_data = bytes(left ^ right for left, right in zip(data_block, data_mask))
|
|
seed_mask = _mgf1(masked_data, digest_size)
|
|
masked_seed = bytes(left ^ right for left, right in zip(seed, seed_mask))
|
|
encoded = b"\x00" + masked_seed + masked_data
|
|
encrypted = pow(int.from_bytes(encoded, "big"), exponent, modulus)
|
|
return encrypted.to_bytes(key_size, "big").hex()
|
|
|
|
|
|
def _parse_cookie(cookie_text: str) -> dict[str, str]:
|
|
parsed = http.cookies.SimpleCookie()
|
|
parsed.load(str(cookie_text or "").replace("; ", ";"))
|
|
return {name: morsel.value for name, morsel in parsed.items()}
|
|
|
|
|
|
def _cookie_header(values: dict[str, str]) -> str:
|
|
return "; ".join(f"{name}={value}" for name, value in values.items() if value)
|
|
|
|
|
|
def _request_json(url: str, *, cookie: str = "", data: dict[str, str] | None = None,
|
|
opener: urllib.request.OpenerDirector | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
retries: int = 0) -> tuple[dict, Any]:
|
|
body = urllib.parse.urlencode(data).encode("utf-8") if data is not None else None
|
|
request = urllib.request.Request(url, data=body, method="POST" if body is not None else "GET")
|
|
request.add_header("User-Agent", _USER_AGENT)
|
|
for name, value in (headers or {}).items():
|
|
request.add_header(name, value)
|
|
if cookie:
|
|
request.add_header("Cookie", cookie)
|
|
if body is not None:
|
|
request.add_header("Content-Type", "application/x-www-form-urlencoded")
|
|
retry_count = max(0, int(retries))
|
|
for attempt in range(retry_count + 1):
|
|
try:
|
|
response = (opener or urllib.request.build_opener()).open(request, timeout=15)
|
|
return json.loads(response.read().decode("utf-8")), response
|
|
except urllib.error.HTTPError:
|
|
raise
|
|
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as exc:
|
|
if attempt >= retry_count:
|
|
raise RuntimeError("连接B站登录服务失败,请稍后重试") from exc
|
|
time.sleep(0.4 * (attempt + 1))
|
|
|
|
raise AssertionError("unreachable")
|
|
|
|
|
|
def _seed_cookie_jar(jar: http.cookiejar.CookieJar, values: dict[str, str]) -> None:
|
|
for name, value in values.items():
|
|
jar.set_cookie(http.cookiejar.Cookie(
|
|
version=0, name=name, value=value, port=None, port_specified=False,
|
|
domain=".bilibili.com", domain_specified=True, domain_initial_dot=True,
|
|
path="/", path_specified=True, secure=False, expires=None, discard=True,
|
|
comment=None, comment_url=None, rest={}, rfc2109=False,
|
|
))
|
|
|
|
|
|
def _jar_values(jar: http.cookiejar.CookieJar) -> dict[str, str]:
|
|
return {cookie.name: cookie.value for cookie in jar}
|
|
|
|
|
|
def _login_url_cookie_values(url: str) -> dict[str, str]:
|
|
values: dict[str, str] = {}
|
|
query = urllib.parse.urlparse(str(url or "")).query
|
|
for item in query.split("&"):
|
|
raw_name, separator, raw_value = item.partition("=")
|
|
if not separator:
|
|
continue
|
|
name = urllib.parse.unquote_plus(raw_name)
|
|
if name in {"SESSDATA", "bili_jct", "DedeUserID", "DedeUserID__ckMd5", "sid", "buvid3"}:
|
|
values[name] = raw_value
|
|
return values
|
|
|
|
|
|
def _render_qr_png(content: str) -> bytes:
|
|
try:
|
|
import qrcode
|
|
from qrcode.constants import ERROR_CORRECT_M
|
|
except ImportError as exc:
|
|
raise RuntimeError("缺少 qrcode 依赖,请重新安装 requirements.txt") from exc
|
|
|
|
qr = qrcode.QRCode(
|
|
version=None,
|
|
error_correction=ERROR_CORRECT_M,
|
|
box_size=8,
|
|
border=3,
|
|
)
|
|
qr.add_data(content)
|
|
qr.make(fit=True)
|
|
image = qr.make_image(fill_color="black", back_color="white")
|
|
output = io.BytesIO()
|
|
image.save(output, format="PNG")
|
|
return output.getvalue()
|
|
|
|
|
|
class BilibiliQrLogin:
|
|
"""服务端持有二维码密钥和 CookieJar,前端只获取二维码图片与状态。"""
|
|
|
|
_STATUS_MESSAGES = {
|
|
"idle": "尚未开始扫码登录",
|
|
"awaiting_scan": "请使用哔哩哔哩客户端扫码",
|
|
"awaiting_confirm": "已扫码,请在手机上确认登录",
|
|
"completed": "登录成功,Cookie 与刷新凭据已更新",
|
|
"expired": "二维码已过期,请重新生成",
|
|
"failed": "扫码登录失败",
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
credential_store: BilibiliCredentialStore,
|
|
update_cookie: Callable[[dict[str, str]], None],
|
|
logger: logging.Logger,
|
|
on_logged_in: Callable[[], Any] | None = None,
|
|
ttl_seconds: int = 180,
|
|
):
|
|
self.credential_store = credential_store
|
|
self.update_cookie = update_cookie
|
|
self.logger = logger
|
|
self.on_logged_in = on_logged_in
|
|
self.ttl_seconds = max(60, int(ttl_seconds))
|
|
self._lock = asyncio.Lock()
|
|
self._session: dict[str, Any] | None = None
|
|
|
|
def _snapshot(self) -> dict[str, Any]:
|
|
session = self._session or {}
|
|
state = str(session.get("state") or "idle")
|
|
expires_at = float(session.get("expires_at") or 0)
|
|
expires_in = max(0, int(expires_at - time.time())) if expires_at else 0
|
|
return {
|
|
"success": True,
|
|
"state": state,
|
|
"message": str(session.get("message") or self._STATUS_MESSAGES.get(state, "")),
|
|
"expires_in": expires_in,
|
|
"has_qr_image": state in {"awaiting_scan", "awaiting_confirm"} and expires_in > 0,
|
|
"credential_configured": self.credential_store.is_configured(),
|
|
"account": session.get("account"),
|
|
}
|
|
|
|
def snapshot(self) -> dict[str, Any]:
|
|
return self._snapshot()
|
|
|
|
def _start_sync(self) -> dict[str, Any]:
|
|
jar = http.cookiejar.CookieJar()
|
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
payload, _ = _request_json(
|
|
_QR_GENERATE_URL,
|
|
opener=opener,
|
|
headers=_QR_HEADERS,
|
|
retries=2,
|
|
)
|
|
if payload.get("code") != 0:
|
|
raise RuntimeError(f"B站二维码申请失败 code={payload.get('code')}")
|
|
data = payload.get("data") or {}
|
|
qr_url = str(data.get("url") or "").strip()
|
|
qr_key = str(data.get("qrcode_key") or "").strip()
|
|
if not qr_url or not qr_key:
|
|
raise RuntimeError("B站二维码响应缺少必要字段")
|
|
now = time.time()
|
|
return {
|
|
"state": "awaiting_scan",
|
|
"message": self._STATUS_MESSAGES["awaiting_scan"],
|
|
"created_at": now,
|
|
"expires_at": now + self.ttl_seconds,
|
|
"qr_url": qr_url,
|
|
"qr_key": qr_key,
|
|
"jar": jar,
|
|
"opener": opener,
|
|
"account": None,
|
|
}
|
|
|
|
async def start(self) -> dict[str, Any]:
|
|
async with self._lock:
|
|
try:
|
|
self._session = await asyncio.to_thread(self._start_sync)
|
|
except Exception as exc:
|
|
self._session = {
|
|
"state": "failed",
|
|
"message": str(exc) or type(exc).__name__,
|
|
"expires_at": 0,
|
|
}
|
|
self.logger.warning("[B站扫码登录] 二维码申请失败: %s", type(exc).__name__)
|
|
return self._snapshot()
|
|
self.logger.info("[B站扫码登录] 二维码已生成,等待扫码")
|
|
return self._snapshot()
|
|
|
|
def _poll_sync(self, session: dict[str, Any]) -> dict[str, Any]:
|
|
url = _QR_POLL_URL + "?" + urllib.parse.urlencode({"qrcode_key": session["qr_key"]})
|
|
payload, _ = _request_json(
|
|
url,
|
|
opener=session["opener"],
|
|
headers=_QR_HEADERS,
|
|
retries=2,
|
|
)
|
|
if payload.get("code") != 0:
|
|
return {"state": "failed", "message": f"B站扫码状态查询失败 code={payload.get('code')}"}
|
|
|
|
data = payload.get("data") or {}
|
|
status_code = int(data.get("code") or 0)
|
|
if status_code == 86101:
|
|
return {"state": "awaiting_scan", "message": self._STATUS_MESSAGES["awaiting_scan"]}
|
|
if status_code == 86090:
|
|
return {"state": "awaiting_confirm", "message": self._STATUS_MESSAGES["awaiting_confirm"]}
|
|
if status_code == 86038:
|
|
return {"state": "expired", "message": self._STATUS_MESSAGES["expired"]}
|
|
if status_code != 0:
|
|
return {"state": "failed", "message": str(data.get("message") or f"扫码失败 code={status_code}")}
|
|
|
|
refresh_token = str(data.get("refresh_token") or "").strip()
|
|
if not refresh_token:
|
|
return {"state": "failed", "message": "扫码成功响应缺少 refresh_token"}
|
|
cookie_values = _jar_values(session["jar"])
|
|
for name, value in _login_url_cookie_values(str(data.get("url") or "")).items():
|
|
cookie_values.setdefault(name, value)
|
|
if not cookie_values.get("SESSDATA") or not cookie_values.get("bili_jct"):
|
|
return {"state": "failed", "message": "扫码成功但登录 Cookie 不完整"}
|
|
|
|
cookie = _cookie_header(cookie_values)
|
|
nav, _ = _request_json(
|
|
"https://api.bilibili.com/x/web-interface/nav",
|
|
cookie=cookie,
|
|
headers={"Referer": "https://www.bilibili.com/"},
|
|
retries=2,
|
|
)
|
|
nav_data = nav.get("data") or {}
|
|
if nav.get("code") != 0 or not bool(nav_data.get("isLogin")):
|
|
return {"state": "failed", "message": "扫码 Cookie 登录验证失败"}
|
|
|
|
self.credential_store.save_refresh_token(refresh_token)
|
|
self.update_cookie(cookie_values)
|
|
return {
|
|
"state": "completed",
|
|
"message": self._STATUS_MESSAGES["completed"],
|
|
"account": {
|
|
"mid": str(nav_data.get("mid") or ""),
|
|
"uname": str(nav_data.get("uname") or "B站账号"),
|
|
},
|
|
}
|
|
|
|
async def poll(self) -> dict[str, Any]:
|
|
callback_needed = False
|
|
async with self._lock:
|
|
if not self._session:
|
|
return self._snapshot()
|
|
state = str(self._session.get("state") or "idle")
|
|
if state in {"completed", "expired", "failed"}:
|
|
return self._snapshot()
|
|
if time.time() >= float(self._session.get("expires_at") or 0):
|
|
self._session.update(state="expired", message=self._STATUS_MESSAGES["expired"])
|
|
return self._snapshot()
|
|
try:
|
|
result = await asyncio.to_thread(self._poll_sync, self._session)
|
|
except Exception as exc:
|
|
self.logger.warning("[B站扫码登录] 状态查询异常: %s", type(exc).__name__)
|
|
self._session.update(
|
|
state="failed",
|
|
message=str(exc) or f"扫码状态查询异常: {type(exc).__name__}",
|
|
)
|
|
return self._snapshot()
|
|
previous_state = state
|
|
self._session.update(result)
|
|
callback_needed = previous_state != "completed" and result.get("state") == "completed"
|
|
snapshot = self._snapshot()
|
|
|
|
if callback_needed:
|
|
self.logger.info("[B站扫码登录] 登录成功,Cookie 与刷新凭据已更新")
|
|
if self.on_logged_in:
|
|
callback_result = self.on_logged_in()
|
|
if asyncio.iscoroutine(callback_result):
|
|
await callback_result
|
|
return snapshot
|
|
|
|
async def qr_png(self) -> bytes:
|
|
async with self._lock:
|
|
if not self._session or self._session.get("state") not in {"awaiting_scan", "awaiting_confirm"}:
|
|
raise RuntimeError("当前没有可用的登录二维码")
|
|
if time.time() >= float(self._session.get("expires_at") or 0):
|
|
self._session.update(state="expired", message=self._STATUS_MESSAGES["expired"])
|
|
raise RuntimeError("登录二维码已过期")
|
|
content = str(self._session.get("qr_url") or "")
|
|
return await asyncio.to_thread(_render_qr_png, content)
|
|
|
|
|
|
class BilibiliCookieRefresher:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
credential_store: BilibiliCredentialStore,
|
|
get_cookie: Callable[[], str],
|
|
update_cookie: Callable[[dict[str, str]], None],
|
|
logger: logging.Logger,
|
|
check_interval_seconds: int = 6 * 60 * 60,
|
|
on_refreshed: Callable[[], Any] | None = None,
|
|
is_enabled: Callable[[], bool] | None = None,
|
|
get_check_interval_seconds: Callable[[], int] | None = None,
|
|
):
|
|
self.credential_store = credential_store
|
|
self.get_cookie = get_cookie
|
|
self.update_cookie = update_cookie
|
|
self.logger = logger
|
|
self.check_interval_seconds = max(3600, int(check_interval_seconds))
|
|
self.on_refreshed = on_refreshed
|
|
self.is_enabled = is_enabled or (lambda: True)
|
|
self.get_check_interval_seconds = get_check_interval_seconds
|
|
self._stop = False
|
|
self._wake = asyncio.Event()
|
|
|
|
def stop(self) -> None:
|
|
self._stop = True
|
|
self._wake.set()
|
|
|
|
def wake(self) -> None:
|
|
self._wake.set()
|
|
|
|
def _current_interval(self) -> int:
|
|
if not self.get_check_interval_seconds:
|
|
return self.check_interval_seconds
|
|
try:
|
|
return max(3600, int(self.get_check_interval_seconds()))
|
|
except (TypeError, ValueError):
|
|
return self.check_interval_seconds
|
|
|
|
def _check_and_refresh_sync(self) -> dict[str, Any]:
|
|
refresh_token = self.credential_store.load_refresh_token()
|
|
if not refresh_token:
|
|
return {"status": "disabled", "message": "未配置刷新令牌"}
|
|
current_cookie = self.get_cookie()
|
|
current_values = _parse_cookie(current_cookie)
|
|
csrf = current_values.get("bili_jct", "")
|
|
if not current_values.get("SESSDATA") or not csrf:
|
|
return {"status": "failed", "message": "当前 Cookie 缺少 SESSDATA 或 bili_jct"}
|
|
|
|
info, _ = _request_json(
|
|
"https://passport.bilibili.com/x/passport-login/web/cookie/info?" +
|
|
urllib.parse.urlencode({"csrf": csrf}),
|
|
cookie=current_cookie,
|
|
retries=2,
|
|
)
|
|
if info.get("code") != 0:
|
|
return {"status": "failed", "message": f"登录状态检查失败 code={info.get('code')}"}
|
|
if not bool((info.get("data") or {}).get("refresh")):
|
|
return {"status": "valid", "message": "Cookie 当前无需刷新"}
|
|
|
|
timestamp = str((info.get("data") or {}).get("timestamp") or "")
|
|
correspond_path = _rsa_oaep_sha256_encrypt(f"refresh_{timestamp}".encode("utf-8"))
|
|
request = urllib.request.Request(
|
|
f"https://www.bilibili.com/correspond/1/{correspond_path}",
|
|
headers={"User-Agent": _USER_AGENT, "Cookie": current_cookie},
|
|
)
|
|
html = urllib.request.urlopen(request, timeout=15).read().decode("utf-8", errors="replace")
|
|
match = __import__("re").search(r'<div\s+id=["\']1-name["\']>([^<]+)</div>', html)
|
|
if not match:
|
|
return {"status": "failed", "message": "未获取到 refresh_csrf"}
|
|
refresh_csrf = match.group(1).strip()
|
|
|
|
old_refresh_token = refresh_token
|
|
jar = http.cookiejar.CookieJar()
|
|
_seed_cookie_jar(jar, current_values)
|
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
refreshed, _ = _request_json(
|
|
"https://passport.bilibili.com/x/passport-login/web/cookie/refresh",
|
|
data={
|
|
"csrf": csrf,
|
|
"refresh_csrf": refresh_csrf,
|
|
"source": "main_web",
|
|
"refresh_token": old_refresh_token,
|
|
},
|
|
opener=opener,
|
|
)
|
|
if refreshed.get("code") != 0:
|
|
return {"status": "failed", "message": f"Cookie 刷新失败 code={refreshed.get('code')}"}
|
|
new_refresh_token = str((refreshed.get("data") or {}).get("refresh_token") or "")
|
|
if not new_refresh_token:
|
|
return {"status": "failed", "message": "刷新响应缺少新 refresh_token"}
|
|
new_values = dict(current_values)
|
|
new_values.update(_jar_values(jar))
|
|
new_csrf = new_values.get("bili_jct", "")
|
|
new_cookie = _cookie_header(new_values)
|
|
|
|
confirmed, _ = _request_json(
|
|
"https://passport.bilibili.com/x/passport-login/web/confirm/refresh",
|
|
cookie=new_cookie,
|
|
data={"csrf": new_csrf, "refresh_token": old_refresh_token},
|
|
)
|
|
if confirmed.get("code") != 0:
|
|
return {"status": "failed", "message": f"刷新确认失败 code={confirmed.get('code')}"}
|
|
|
|
nav, _ = _request_json(
|
|
"https://api.bilibili.com/x/web-interface/nav",
|
|
cookie=new_cookie,
|
|
retries=2,
|
|
)
|
|
if nav.get("code") != 0 or not bool((nav.get("data") or {}).get("isLogin")):
|
|
return {"status": "failed", "message": "新 Cookie 登录验证失败"}
|
|
self.credential_store.save_refresh_token(new_refresh_token)
|
|
self.update_cookie(new_values)
|
|
return {"status": "refreshed", "message": "Cookie 已刷新并验证"}
|
|
|
|
async def check_once(self) -> dict[str, Any]:
|
|
try:
|
|
result = await asyncio.to_thread(self._check_and_refresh_sync)
|
|
except Exception as exc:
|
|
self.logger.warning("[B站凭据] 自动检查异常: %s", type(exc).__name__)
|
|
return {"status": "failed", "message": type(exc).__name__}
|
|
status = result.get("status")
|
|
if status == "refreshed":
|
|
self.logger.info("[B站凭据] Cookie 已自动续期并完成登录验证")
|
|
if self.on_refreshed:
|
|
callback_result = self.on_refreshed()
|
|
if asyncio.iscoroutine(callback_result):
|
|
await callback_result
|
|
elif status == "valid":
|
|
self.logger.info("[B站凭据] Cookie 有效,当前无需续期")
|
|
elif status == "disabled":
|
|
self.logger.warning("[B站凭据] 自动续期未启用:未配置刷新令牌")
|
|
else:
|
|
self.logger.warning("[B站凭据] 自动续期失败:%s", result.get("message", "未知错误"))
|
|
return result
|
|
|
|
async def run(self) -> None:
|
|
while not self._stop:
|
|
if self.is_enabled():
|
|
await self.check_once()
|
|
try:
|
|
await asyncio.wait_for(self._wake.wait(), timeout=self._current_interval())
|
|
self._wake.clear()
|
|
except asyncio.TimeoutError:
|
|
continue
|