Files
MediaCrawler/tests/test_qrlogin.py
T
butubb de9ff58371
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 扫码同时存一份 Cookie,并把登录判定换成权威判据
用户问「现在是不是扫码就能自动获取 cookie」—— 不是,而且这正是那个面板显得没用的根源:
它只干了半件事,扫码**只写浏览器 profile,完全不提取 cookie**(qrlogin.py 里连一行
取 cookie 的代码都没有)。于是:
- CDP 开着时任务能用(复用 profile),但 Cookie 面板始终显示「未配置」
- CDP 一关,任务立刻断,因为库里那份 cookie 从来没被填过

现在扫码把两件事一起做了:写 profile(CDP 用)+ 存一份到库(Cookie 注入用)。
两种机制同时填上,开关怎么切都不断。cookie 只在内存里从 qrlogin 传到路由,不进响应体。

同时修掉一个同类 bug:监控侧的登录判定还在用页面里的 window.__INITIAL_STATE__,
而那是**页面加载那一刻的快照** —— 浏览器本来就登录着时它是对的,但扫码是加载之后
才登录的,快照不会翻转,表现为「扫了码却一直停在二维码上」。运营模块踩过同一个坑,
当时只修了那一处。现在两边统一为:拿 cookie 问后台接口「我是谁」。顺带不再需要页面导航,
检测变轻了。

前端:已登录时按钮原先被我藏起来了,面板于是变成一块只能看、不能操作的区域 ——
用户的原话是「没用」。现在两种状态都给按钮,含义不同:未登录=取二维码,
已登录=把当前登录态同步成 Cookie。

测试:tests/test_qrlogin.py 重写(stub 从页面探针换成后台接口),新增「成功会话必须
交出 cookie」「只能取一次」两例。
2026-10-09 13:46:48 +08:00

254 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""监控侧扫码登录与登录态检测。
两个要点在这里被钉死:
* 二维码必须从浏览器**默认 context** 里读 —— 新建 context 是无痕式的 profile,
扫了也白扫,爬虫读不到那份 cookie;
* 「登录了吗」不能用页面里的 `window.__INITIAL_STATE__`。那是**页面加载那一刻的快照**:
浏览器本来就登录着时它是对的,但扫码是加载**之后**才登录的,快照不会翻转,
于是扫完码界面会一直停在二维码上。判据改成拿 cookie 问后台接口。
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from api.creator.client import CreatorApiError
from api.monitor import qrlogin
@pytest.fixture(autouse=True)
def _reset_module_state():
for attribute in ("_current", "_page", "_playwright", "_state_cache"):
setattr(qrlogin, attribute, None)
yield
for attribute in ("_current", "_page", "_playwright", "_state_cache"):
setattr(qrlogin, attribute, None)
XHS_COOKIES = [
{"name": "a1", "value": "an-a1-value"},
{"name": "web_session", "value": "a-session"},
]
def _fake_stack(cookies=None, qr="data:image/png;base64,AAAA"):
"""Chrome/Playwright 替身,行为与真实的一致。"""
page = MagicMock()
page.url = "https://www.xiaohongshu.com/explore"
page.is_closed = MagicMock(return_value=False)
page.goto = AsyncMock()
page.close = AsyncMock()
context = MagicMock()
context.pages = []
context.cookies = AsyncMock(return_value=list(cookies if cookies is not None else XHS_COOKIES))
context.new_page = AsyncMock(return_value=page)
browser = MagicMock()
browser.contexts = [context]
# 去新建 context 正是这里要防的 bug,所以让它直接炸,而不是悄悄返回一个无痕 profile。
browser.new_context = AsyncMock(
side_effect=AssertionError("must reuse browser.contexts[0], not a new context")
)
playwright = MagicMock()
playwright.chromium.connect_over_cdp = AsyncMock(return_value=browser)
playwright.stop = AsyncMock()
manager = MagicMock()
manager.start = AsyncMock(return_value=playwright)
return manager, playwright, browser, context, page, qr
def _patch(monkeypatch, manager, qr="data:image/png;base64,AAAA", resolver=None):
"""``resolver(cookie)`` 返回账号信息 dict,或抛 CreatorApiError。"""
if resolver is None:
resolver = lambda _cookie: {"user_id": "u1", "nickname": "小明"} # noqa: E731
class _Client:
def __init__(self, cookie, **kwargs):
self.cookie = cookie
async def fetch_user_info(self):
return resolver(self.cookie)
monkeypatch.setattr(qrlogin, "async_playwright", lambda: manager)
monkeypatch.setattr(qrlogin, "CreatorClient", _Client)
monkeypatch.setattr(qrlogin.utils, "find_login_qrcode", AsyncMock(return_value=qr))
def _signed_out(_cookie):
raise CreatorApiError("登录态无效或已过期", status=401)
@pytest.mark.asyncio
async def test_idle_reports_the_browsers_login_state(monkeypatch):
manager, *_ = _fake_stack()
_patch(monkeypatch, manager, resolver=lambda _c: {"user_id": "u1", "nickname": "小明"})
snapshot = await qrlogin.status()
assert snapshot["status"] == qrlogin.STATUS_IDLE
assert snapshot["logged_in"] is True
assert snapshot["nickname"] == "小明"
@pytest.mark.asyncio
async def test_unwired_platform_is_rejected():
"""只有小红书接了扫码;别的平台必须直接报错,而不是给个按不动的按钮。"""
with pytest.raises(ValueError):
await qrlogin.start("dy")
@pytest.mark.asyncio
async def test_start_reads_the_qr_from_the_default_context(monkeypatch):
manager, _pw, browser, context, _page, _qr = _fake_stack()
_patch(monkeypatch, manager, resolver=_signed_out)
snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS)
assert snapshot["status"] == qrlogin.STATUS_WAITING
assert snapshot["image"] == "data:image/png;base64,AAAA"
context.new_page.assert_awaited_once()
browser.new_context.assert_not_called()
@pytest.mark.asyncio
async def test_start_does_not_open_a_second_tab(monkeypatch):
"""已有的 xhs 标签页会被认领,所以重启不会在浏览器里堆孤儿页。"""
manager, _pw, _browser, context, page, _qr = _fake_stack()
context.pages = [page]
_patch(monkeypatch, manager, resolver=_signed_out)
await qrlogin.start(qrlogin.PLATFORM_XHS)
context.new_page.assert_not_called()
@pytest.mark.asyncio
async def test_an_already_signed_in_profile_needs_no_scan(monkeypatch):
"""没二维码但 profile 已登录 —— 这是成功,不是失败。"""
manager, *_rest = _fake_stack()
_patch(monkeypatch, manager, qr="", resolver=lambda _c: {"user_id": "u9", "nickname": "老王"})
snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS)
assert snapshot["status"] == qrlogin.STATUS_SUCCESS
assert snapshot["nickname"] == "老王"
@pytest.mark.asyncio
async def test_no_qr_and_not_signed_in_is_an_error(monkeypatch):
manager, *_rest = _fake_stack()
_patch(monkeypatch, manager, qr="", resolver=_signed_out)
snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS)
assert snapshot["status"] == qrlogin.STATUS_ERROR
@pytest.mark.asyncio
async def test_a_completed_scan_flips_the_session_to_success(monkeypatch):
"""**这个判据是重点**:扫码是页面加载之后才发生的,所以不能用页面快照来判断。"""
manager, *_rest = _fake_stack()
signed_in = {"value": False}
def resolver(cookie):
assert "a1=an-a1-value" in cookie # 判据必须真的用 cookie 去问
if not signed_in["value"]:
raise CreatorApiError("登录态无效或已过期", status=401)
return {"user_id": "u1", "nickname": "小红"}
_patch(monkeypatch, manager, resolver=resolver)
await qrlogin.start(qrlogin.PLATFORM_XHS)
assert qrlogin._current.status == qrlogin.STATUS_WAITING
# 操作者扫了码
signed_in["value"] = True
qrlogin._state_cache = None # 5 秒缓存否则会遮住这次变化
snapshot = await qrlogin.status()
assert snapshot["status"] == qrlogin.STATUS_SUCCESS
assert snapshot["nickname"] == "小红"
@pytest.mark.asyncio
async def test_the_successful_session_hands_over_a_cookie(monkeypatch):
"""扫码不该只写浏览器 profile —— 还要能把 cookie 交出来存库,
否则关掉 CDP 就断了。"""
manager, *_rest = _fake_stack()
_patch(monkeypatch, manager, resolver=lambda _c: {"user_id": "u1", "nickname": "小明"})
await qrlogin.start(qrlogin.PLATFORM_XHS)
cookie = await qrlogin.take_cookie()
assert cookie is not None
assert "a1=an-a1-value" in cookie
# 只能取一次,否则每次轮询都会重复写库
assert await qrlogin.take_cookie() is None
@pytest.mark.asyncio
async def test_session_expires(monkeypatch):
manager, *_rest = _fake_stack()
_patch(monkeypatch, manager, resolver=_signed_out)
await qrlogin.start(qrlogin.PLATFORM_XHS)
qrlogin._current.started_at -= qrlogin.QR_TTL_SECONDS + 1
snapshot = await qrlogin.status()
assert snapshot["status"] == qrlogin.STATUS_EXPIRED
@pytest.mark.asyncio
async def test_check_login_state_reports_when_the_browser_cannot_answer(monkeypatch):
"""连不上浏览器时要说出来,不能悄悄报成「未登录」。"""
manager, _pw, _browser, context, _page, _qr = _fake_stack()
context.cookies = AsyncMock(side_effect=RuntimeError("Target closed"))
_patch(monkeypatch, manager)
state = await qrlogin.check_login_state()
assert state["known"] is False
assert state["logged_in"] is False
assert "Target closed" in state["error"]
@pytest.mark.asyncio
async def test_check_login_state_is_cached(monkeypatch):
manager, _pw, _browser, context, _page, _qr = _fake_stack()
_patch(monkeypatch, manager, resolver=lambda _c: {"user_id": "u1", "nickname": "x"})
await qrlogin.check_login_state()
await qrlogin.check_login_state()
context.cookies.assert_awaited_once()
@pytest.mark.asyncio
async def test_force_bypasses_the_cache(monkeypatch):
manager, _pw, _browser, context, _page, _qr = _fake_stack()
_patch(monkeypatch, manager, resolver=lambda _c: {"user_id": "u1", "nickname": "x"})
await qrlogin.check_login_state()
await qrlogin.check_login_state(force=True)
assert context.cookies.await_count == 2
@pytest.mark.asyncio
async def test_cancel_keeps_the_operators_tab(monkeypatch):
"""与运营模块不同:那里的上下文是临时的、用完即弃;这里的标签页属于操作者的浏览器。"""
manager, _pw, _browser, _context, page, _qr = _fake_stack()
_patch(monkeypatch, manager, resolver=_signed_out)
await qrlogin.start(qrlogin.PLATFORM_XHS)
snapshot = await qrlogin.cancel()
assert snapshot["status"] == qrlogin.STATUS_IDLE
page.close.assert_not_called()