From c03ab60aac595ccc0c1a3984c69608dc59025196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 18 Sep 2026 15:48:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(bili):=20=E7=99=BB=E5=BD=95=E6=80=81?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E6=94=B9=E4=B8=BA=E6=A0=A1=E9=AA=8C=20cookie?= =?UTF-8?q?=20=E6=9C=89=E6=95=88=E6=80=A7=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E9=99=8D=E7=BA=A7=E5=88=B0=20480P?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_login_state 原先只判断 SESSDATA 是否存在。过期会话的 SESSDATA 会一直 留在浏览器里,于是 pong 报"账号未登录"后进入的登录流程会被立刻判成成功: 既不等待扫码,又把这份死 cookie 灌给 API client。 后果是隐蔽的:详情和评论两个接口不要求登录,照常爬到;而 playurl 依据 Cookie 决定清晰度,未登录状态会静默封顶在 480P,不报任何错。 - login.py: 抽出 is_login_cookie_refreshed(),要求 SESSDATA 被扫码换发成新值 才算登录成功;进入登录流程前记录旧值,残留死 cookie 时输出告警 - core.py: 登录流程结束后补一次 pong 校验,仍失败则明确报错退出, 不再带着未登录状态跑完全程 - 新增 tests/test_bilibili_login_state.py 覆盖上述分支 --- media_platform/bilibili/core.py | 10 +++++++ media_platform/bilibili/login.py | 30 ++++++++++++++++--- tests/test_bilibili_login_state.py | 46 ++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 tests/test_bilibili_login_state.py diff --git a/media_platform/bilibili/core.py b/media_platform/bilibili/core.py index a2c4068..b34cfde 100644 --- a/media_platform/bilibili/core.py +++ b/media_platform/bilibili/core.py @@ -24,6 +24,7 @@ import asyncio import os +import sys # import random # Removed as we now use fixed config.CRAWLER_MAX_SLEEP_SEC intervals from asyncio import Task from typing import Dict, List, Optional, Tuple, Union @@ -113,6 +114,15 @@ class BilibiliCrawler(AbstractCrawler): browser_context=self.browser_context, urls=self.cookie_urls, ) + # 登录流程结束后再校验一次,宁可在这里失败也不要带着未登录状态跑完全程: + # B 站的清晰度由 playurl 依据 Cookie 决定,死会话不会报错, + # 只会把带 get_media 的视频静默限制在 480P + if not await self.bili_client.pong(): + utils.logger.error( + "[BilibiliCrawler.start] 登录流程结束后仍未登录:请确认扫码已完成、" + "或 config.COOKIES 是否有效。已终止,否则视频会被静默限制在 480P。" + ) + sys.exit(1) crawler_type_var.set(config.CRAWLER_TYPE) if config.CRAWLER_TYPE == "search": diff --git a/media_platform/bilibili/login.py b/media_platform/bilibili/login.py index bb19af5..13e31a1 100644 --- a/media_platform/bilibili/login.py +++ b/media_platform/bilibili/login.py @@ -48,6 +48,18 @@ LOGIN_ENTRY_SELECTORS = [ ] +def is_login_cookie_refreshed(cookie_dict: dict, sessdata_before_login: str) -> bool: + """判断浏览器里的 SESSDATA 是否已经是扫码换发后的新值。 + + 过期的 SESSDATA 同样会留在浏览器里,所以"cookie 存在"不等于"已登录"。 + 只判断存在会把死会话判成登录成功:既跳过扫码,又把这份死 cookie 灌给 + API client —— B 站 playurl 依据 Cookie 决定清晰度,结果是详情和评论照常拿到 + (这两个接口不要求登录),视频却被静默限制在 480P。 + """ + sessdata = cookie_dict.get("SESSDATA", "") + return bool(sessdata) and sessdata != sessdata_before_login + + class BilibiliLogin(AbstractLogin): def __init__(self, login_type: str, @@ -61,6 +73,8 @@ class BilibiliLogin(AbstractLogin): self.context_page = context_page self.login_phone = login_phone self.cookie_str = cookie_str + # 进入登录流程前浏览器里已有的 SESSDATA,用于区分"cookie 存在"与"cookie 有效" + self._sessdata_before_login: str = "" async def begin(self): """Start login bilibili""" @@ -79,19 +93,27 @@ class BilibiliLogin(AbstractLogin): async def check_login_state(self) -> bool: """ Check if the current login status is successful and return True otherwise return False - retry decorator will retry 20 times if the return value is False, and the retry interval is 1 second + retry decorator will retry 600 times if the return value is False, and the retry interval is 1 second if max retry times reached, raise RetryError """ current_cookie = await self.browser_context.cookies() _, cookie_dict = utils.convert_cookies(current_cookie) - if cookie_dict.get("SESSDATA", "") or cookie_dict.get("DedeUserID"): - return True - return False + return is_login_cookie_refreshed(cookie_dict, self._sessdata_before_login) async def login_by_qrcode(self): """login bilibili website and keep webdriver login state""" utils.logger.info("[BilibiliLogin.login_by_qrcode] Begin login bilibili by qrcode ...") + # 记下扫码前的 SESSDATA:扫码成功后它会被换发成新值, + # check_login_state 靠这个差值判断"真的登录了"而不是"只剩一份过期 cookie" + _, cookie_dict = utils.convert_cookies(await self.browser_context.cookies()) + self._sessdata_before_login = cookie_dict.get("SESSDATA", "") + if self._sessdata_before_login: + utils.logger.warning( + "[BilibiliLogin.login_by_qrcode] 浏览器中残留了 SESSDATA,但接口校验为未登录," + "该会话已失效;本次必须重新扫码换发新 cookie 才会继续 ..." + ) + # click login button login_entry_selector = ", ".join(LOGIN_ENTRY_SELECTORS) try: diff --git a/tests/test_bilibili_login_state.py b/tests/test_bilibili_login_state.py new file mode 100644 index 0000000..9c8fe24 --- /dev/null +++ b/tests/test_bilibili_login_state.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_bilibili_login_state.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# + +"""B 站登录态判定的回归测试。 + +回归背景:``check_login_state`` 曾经只判断 SESSDATA **是否存在**。当浏览器里 +残留一份过期 SESSDATA 时(B 站不会主动清掉它),流程会被判成"登录成功": +既跳过扫码,又把这份死 cookie 灌给 API client。后果是详情和评论照常爬到 +(这两个接口不要求登录),但 playurl 依据 Cookie 定清晰度,视频被静默限制在 480P。 + +这里只测判定逻辑本身,不启浏览器、不发网络请求。 +""" + +from __future__ import annotations + +from media_platform.bilibili.login import is_login_cookie_refreshed + +STALE = "sessdata-remaining-from-expired-session" +FRESH = "sessdata-reissued-by-qrcode-scan" + + +def test_stale_sessdata_is_not_treated_as_logged_in() -> None: + """残留的过期 SESSDATA 不能算登录成功(本次回归的核心)""" + assert is_login_cookie_refreshed({"SESSDATA": STALE}, STALE) is False + + +def test_absent_sessdata_is_not_logged_in() -> None: + """完全没有 SESSDATA 时未登录;只有 DedeUserID 不足以证明会话有效""" + assert is_login_cookie_refreshed({}, "") is False + assert is_login_cookie_refreshed({"DedeUserID": "434377496"}, "") is False + + +def test_refreshed_sessdata_is_logged_in() -> None: + """扫码换发成新值之后才放行""" + assert is_login_cookie_refreshed({"SESSDATA": FRESH}, STALE) is True + + +def test_sessdata_appearing_from_nothing_is_logged_in() -> None: + """本来就没有 SESSDATA(真未登录)时,扫码写进来的任意值都算成功""" + assert is_login_cookie_refreshed({"SESSDATA": FRESH}, "") is True