fix: 扫码登录状态可独立查询 + 趋势图改回自适应纵轴
【登录反馈】实测这台浏览器 loggedIn=true,其实早就登录成功了;看不到反馈是判据和设计的问题: 1. 判据不可靠。原先靠 web_session 的值变化判断——对照组显示:一个全新的空 profile 首次访问小红书就会被发一个 web_session,所以「有这个 cookie」什么都证明不了。可信信号是页面自己的 __INITIAL_STATE__.user.loggedIn,但它是 Vue 响应式引用,必须 .value 解包(这就是前面探针读到 [object Object] 和 None 的原因)。 2. 状态绑死在临时会话上。扫码会话是内存状态,进程一重启就没(部署、崩溃都算),面板于是悄悄退回初始态——一次成功扫码看起来像什么都没发生。 改法不是让会话活得久,而是把「登没登录」变成随时可查、与会话无关: - 新增 GET /api/monitor/login/state,直接问浏览器要答案,带 5 秒缓存;force=true 先重载页面再读,用于状态陈旧 - qrlogin 改为常驻 Playwright 客户端 + 复用同一个标签页,并在重启后认领浏览器里已存在的 xhs 标签页,避免堆孤儿页 - 把「读不到状态」与「未登录」分开——前者显示具体错误,不再悄悄显示成未登录 - 面板顶部常驻显示登录态与昵称,带「重新检测」按钮;扫码成功后自动翻转 【趋势图】上一轮改过头了。dataviz 规范里没有「折线图必须从 0 起」这条——基线相关的条文全是讲柱状图的(柱状图用长度编码数值,不从 0 起比例就是错的;折线图用位置编码,轴只需如实框住数据)。改回自适应,保留上一轮修好的左侧刻度栏让范围始终可见;步长收敛到 1/2/5×10ⁿ,全平序列撑开一档避免除零。
This commit is contained in:
+115
-77
@@ -1,15 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for CDP-driven QR login.
|
||||
"""Tests for CDP-driven QR login and the login-state check.
|
||||
|
||||
The behaviour worth pinning down is not "does it call Playwright" but the two
|
||||
decisions that silently produce a logged-out crawler if they regress:
|
||||
Two things are worth pinning down here, because both silently produce a
|
||||
logged-out crawler when they regress:
|
||||
|
||||
* the QR must be read from the browser's **default** context, because a new
|
||||
context is an incognito-like profile whose cookies the crawler never sees;
|
||||
* success must be decided by ``web_session`` *changing*, not merely existing --
|
||||
an account already signed in has a value before the operator ever scans.
|
||||
* the QR must be read from the browser's **default** context, since a new context
|
||||
is an incognito-like profile whose cookies the crawler never sees;
|
||||
* "am I logged in?" must be answered by the browser's profile, **not** by the
|
||||
in-memory scan session -- that session dies on any restart, so a successful scan
|
||||
would otherwise look like nothing happened.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -20,24 +22,34 @@ from api.monitor import qrlogin
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_module_state():
|
||||
"""Each test starts from "nothing on screen" and leaves it that way."""
|
||||
qrlogin._current = None
|
||||
for attribute in ("_current", "_page", "_playwright", "_state_cache"):
|
||||
setattr(qrlogin, attribute, None)
|
||||
yield
|
||||
qrlogin._current = None
|
||||
for attribute in ("_current", "_page", "_playwright", "_state_cache"):
|
||||
setattr(qrlogin, attribute, None)
|
||||
|
||||
|
||||
def _fake_stack(qr_image: str = "data:image/png;base64,AAAA", cookies=None):
|
||||
def _probe_result(logged_in: bool, nickname=None, known: bool = True) -> str:
|
||||
return json.dumps({"known": known, "loggedIn": logged_in, "nickname": nickname})
|
||||
|
||||
|
||||
def _fake_stack(logged_in: bool = False, nickname=None, qr: str = "data:image/png;base64,AAAA"):
|
||||
"""A Playwright/Chrome stand-in wired the way the real one behaves."""
|
||||
context = MagicMock()
|
||||
context.cookies = AsyncMock(return_value=list(cookies or []))
|
||||
page = MagicMock()
|
||||
page.url = "https://www.xiaohongshu.com/explore"
|
||||
page.is_closed = MagicMock(return_value=False)
|
||||
page.goto = AsyncMock()
|
||||
page.close = AsyncMock()
|
||||
page.evaluate = AsyncMock(return_value=_probe_result(logged_in, nickname))
|
||||
|
||||
context = MagicMock()
|
||||
context.pages = []
|
||||
context.new_page = AsyncMock(return_value=page)
|
||||
|
||||
browser = MagicMock()
|
||||
browser.contexts = [context]
|
||||
# Reaching for a fresh context is the bug this guards against, so make it
|
||||
# blow up loudly rather than quietly returning a throwaway profile.
|
||||
# Reaching for a fresh context is the bug this guards against, so make it blow
|
||||
# up loudly rather than quietly handing back a throwaway profile.
|
||||
browser.new_context = AsyncMock(
|
||||
side_effect=AssertionError("must reuse browser.contexts[0], not a new context")
|
||||
)
|
||||
@@ -49,22 +61,24 @@ def _fake_stack(qr_image: str = "data:image/png;base64,AAAA", cookies=None):
|
||||
manager = MagicMock()
|
||||
manager.start = AsyncMock(return_value=playwright)
|
||||
|
||||
return manager, playwright, browser, context, page
|
||||
return manager, playwright, browser, context, page, qr
|
||||
|
||||
|
||||
def _patch(monkeypatch, manager, qr_image="data:image/png;base64,AAAA"):
|
||||
def _patch(monkeypatch, manager, qr="data:image/png;base64,AAAA"):
|
||||
monkeypatch.setattr(qrlogin, "async_playwright", lambda: manager)
|
||||
monkeypatch.setattr(
|
||||
qrlogin.utils, "find_login_qrcode", AsyncMock(return_value=qr_image)
|
||||
)
|
||||
monkeypatch.setattr(qrlogin.utils, "find_login_qrcode", AsyncMock(return_value=qr))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_before_any_session():
|
||||
async def test_idle_reports_the_browsers_login_state(monkeypatch):
|
||||
manager, _pw, _browser, _context, _page, _qr = _fake_stack(logged_in=True, nickname="小明")
|
||||
_patch(monkeypatch, manager)
|
||||
|
||||
snapshot = await qrlogin.status()
|
||||
|
||||
assert snapshot["status"] == qrlogin.STATUS_IDLE
|
||||
assert snapshot["image"] == ""
|
||||
assert snapshot["logged_in"] is True
|
||||
assert snapshot["nickname"] == "小明"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -76,7 +90,7 @@ async def test_unwired_platform_is_rejected():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_returns_the_qr_and_reuses_the_default_context(monkeypatch):
|
||||
manager, playwright, browser, context, _page = _fake_stack()
|
||||
manager, _pw, browser, context, _page, _qr = _fake_stack()
|
||||
_patch(monkeypatch, manager)
|
||||
|
||||
snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS)
|
||||
@@ -85,106 +99,130 @@ async def test_start_returns_the_qr_and_reuses_the_default_context(monkeypatch):
|
||||
assert snapshot["image"] == "data:image/png;base64,AAAA"
|
||||
context.new_page.assert_awaited_once()
|
||||
browser.new_context.assert_not_called()
|
||||
playwright.chromium.connect_over_cdp.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_requires_the_cookie_to_change():
|
||||
session = qrlogin.QrLoginSession(
|
||||
qrlogin.PLATFORM_XHS,
|
||||
MagicMock(),
|
||||
_page_with_cookies([{"name": "web_session", "value": "before"}]),
|
||||
baseline="before",
|
||||
)
|
||||
async def test_start_does_not_open_a_second_tab(monkeypatch):
|
||||
"""An open xhs tab is adopted, so restarts do not litter the browser."""
|
||||
manager, _pw, _browser, context, page, _qr = _fake_stack()
|
||||
context.pages = [page]
|
||||
_patch(monkeypatch, manager)
|
||||
|
||||
await session.refresh()
|
||||
assert session.status == qrlogin.STATUS_WAITING
|
||||
await qrlogin.start(qrlogin.PLATFORM_XHS)
|
||||
|
||||
session._page = _page_with_cookies([{"name": "web_session", "value": "after"}])
|
||||
await session.refresh()
|
||||
assert session.status == qrlogin.STATUS_SUCCESS
|
||||
context.new_page.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_already_signed_in_profile_needs_no_scan(monkeypatch):
|
||||
"""No QR on the page plus a session cookie means someone is already logged in."""
|
||||
manager, _playwright, _browser, _context, _page = _fake_stack(
|
||||
qr_image="", cookies=[{"name": "web_session", "value": "existing"}]
|
||||
"""No QR on the page plus a signed-in profile is success, not failure."""
|
||||
manager, _pw, _browser, _context, _page, _qr = _fake_stack(
|
||||
logged_in=True, nickname="老王"
|
||||
)
|
||||
_patch(monkeypatch, manager, qr_image="")
|
||||
_patch(monkeypatch, manager, qr="")
|
||||
|
||||
snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS)
|
||||
|
||||
assert snapshot["status"] == qrlogin.STATUS_SUCCESS
|
||||
assert "登录" in snapshot["message"]
|
||||
assert snapshot["nickname"] == "老王"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_qr_and_no_session_is_an_error(monkeypatch):
|
||||
manager, _playwright, _browser, _context, _page = _fake_stack(qr_image="", cookies=[])
|
||||
_patch(monkeypatch, manager, qr_image="")
|
||||
async def test_no_qr_and_not_signed_in_is_an_error(monkeypatch):
|
||||
manager, _pw, _browser, _context, _page, _qr = _fake_stack(logged_in=False)
|
||||
_patch(monkeypatch, manager, qr="")
|
||||
|
||||
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, _pw, _browser, _context, page, _qr = _fake_stack(logged_in=False)
|
||||
_patch(monkeypatch, manager)
|
||||
await qrlogin.start(qrlogin.PLATFORM_XHS)
|
||||
|
||||
# The operator scans: the page now reports a signed-in profile.
|
||||
page.evaluate = AsyncMock(return_value=_probe_result(True, "小红"))
|
||||
qrlogin._state_cache = None # the 5s cache would otherwise hide the change
|
||||
|
||||
snapshot = await qrlogin.status()
|
||||
|
||||
assert snapshot["status"] == qrlogin.STATUS_SUCCESS
|
||||
assert snapshot["nickname"] == "小红"
|
||||
assert "登录成功" in snapshot["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_expires(monkeypatch):
|
||||
session = qrlogin.QrLoginSession(
|
||||
qrlogin.PLATFORM_XHS, MagicMock(), _page_with_cookies([]), baseline=""
|
||||
)
|
||||
session.started_at -= qrlogin.QR_TTL_SECONDS + 1
|
||||
manager, _pw, _browser, _context, _page, _qr = _fake_stack()
|
||||
_patch(monkeypatch, manager)
|
||||
await qrlogin.start(qrlogin.PLATFORM_XHS)
|
||||
|
||||
await session.refresh()
|
||||
qrlogin._current.started_at -= qrlogin.QR_TTL_SECONDS + 1
|
||||
snapshot = await qrlogin.status()
|
||||
|
||||
assert session.status == qrlogin.STATUS_EXPIRED
|
||||
assert snapshot["status"] == qrlogin.STATUS_EXPIRED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closed_tab_is_reported_rather_than_crashing():
|
||||
page = MagicMock()
|
||||
page.context.cookies = AsyncMock(side_effect=RuntimeError("Target closed"))
|
||||
session = qrlogin.QrLoginSession(qrlogin.PLATFORM_XHS, MagicMock(), page, baseline="")
|
||||
async def test_check_login_state_reports_when_the_page_cannot_answer(monkeypatch):
|
||||
"""An unreachable browser must say so, not quietly report "not logged in"."""
|
||||
manager, _pw, _browser, _context, page, _qr = _fake_stack()
|
||||
page.evaluate = AsyncMock(side_effect=RuntimeError("Target closed"))
|
||||
_patch(monkeypatch, manager)
|
||||
|
||||
await session.refresh()
|
||||
state = await qrlogin.check_login_state()
|
||||
|
||||
assert session.status == qrlogin.STATUS_ERROR
|
||||
assert state["known"] is False
|
||||
assert state["logged_in"] is False
|
||||
assert "Target closed" in state["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreachable_browser_is_reported(monkeypatch):
|
||||
"""A server whose Chrome is not listening must say so, not 500 anonymously."""
|
||||
playwright = MagicMock()
|
||||
playwright.chromium.connect_over_cdp = AsyncMock(
|
||||
side_effect=OSError("Connection refused")
|
||||
)
|
||||
playwright.stop = AsyncMock()
|
||||
manager = MagicMock()
|
||||
manager.start = AsyncMock(return_value=playwright)
|
||||
monkeypatch.setattr(qrlogin, "async_playwright", lambda: manager)
|
||||
async def test_check_login_state_is_cached(monkeypatch):
|
||||
manager, _pw, _browser, _context, page, _qr = _fake_stack(logged_in=True)
|
||||
_patch(monkeypatch, manager)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await qrlogin.start(qrlogin.PLATFORM_XHS)
|
||||
await qrlogin.check_login_state()
|
||||
await qrlogin.check_login_state()
|
||||
|
||||
assert "连接浏览器失败" in str(excinfo.value)
|
||||
playwright.stop.assert_awaited_once()
|
||||
page.evaluate.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_closes_the_tab_and_resets(monkeypatch):
|
||||
manager, _playwright, _browser, _context, page = _fake_stack()
|
||||
async def test_force_bypasses_the_cache(monkeypatch):
|
||||
manager, _pw, _browser, _context, page, _qr = _fake_stack(logged_in=True)
|
||||
_patch(monkeypatch, manager)
|
||||
|
||||
await qrlogin.check_login_state()
|
||||
await qrlogin.check_login_state(force=True)
|
||||
|
||||
assert page.evaluate.await_count == 2
|
||||
page.goto.assert_awaited() # force reloads before reading
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_resets_without_closing_the_operators_tab(monkeypatch):
|
||||
manager, _pw, _browser, _context, page, _qr = _fake_stack()
|
||||
_patch(monkeypatch, manager)
|
||||
await qrlogin.start(qrlogin.PLATFORM_XHS)
|
||||
|
||||
snapshot = await qrlogin.cancel()
|
||||
|
||||
assert snapshot["status"] == qrlogin.STATUS_IDLE
|
||||
page.close.assert_awaited_once()
|
||||
assert (await qrlogin.status())["status"] == qrlogin.STATUS_IDLE
|
||||
# The tab belongs to the operator's browser and is reused, not closed.
|
||||
page.close.assert_not_called()
|
||||
|
||||
|
||||
def _page_with_cookies(cookies):
|
||||
page = MagicMock()
|
||||
page.context.cookies = AsyncMock(return_value=list(cookies))
|
||||
return page
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_does_not_close_the_tab(monkeypatch):
|
||||
manager, playwright, _browser, _context, page, _qr = _fake_stack()
|
||||
_patch(monkeypatch, manager)
|
||||
await qrlogin.start(qrlogin.PLATFORM_XHS)
|
||||
|
||||
await qrlogin.shutdown()
|
||||
|
||||
page.close.assert_not_called()
|
||||
playwright.stop.assert_awaited()
|
||||
|
||||
Reference in New Issue
Block a user