用户问「现在是不是扫码就能自动获取 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」「只能取一次」两例。
254 lines
8.9 KiB
Python
254 lines
8.9 KiB
Python
# -*- 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()
|