first commit
This commit is contained in:
@@ -0,0 +1,601 @@
|
||||
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
|
||||
Reference in New Issue
Block a user