diff --git a/api/monitor/qrlogin.py b/api/monitor/qrlogin.py index cef70d1..145844e 100644 --- a/api/monitor/qrlogin.py +++ b/api/monitor/qrlogin.py @@ -8,7 +8,7 @@ # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 -# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 2. 使用时应遵守对应平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 @@ -16,32 +16,29 @@ # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 -"""Show a login QR code to the operator through the WebUI. +"""扫码登录,以及"现在到底登没登录"的查询。 -Why this module exists: on a server deployment Chrome runs under Xvfb, so there -is no display to look at, and the helper the crawler normally uses to present the -QR (`show_qrcode` in `tools/crawler_util.py`) calls PIL's ``Image.show()`` -- it -needs a desktop image viewer that such a machine does not have, so the code would -go nowhere and the operator would be stuck. Instead the QR is read straight out -of the page over CDP and handed to the WebUI, which renders it as an ````. +**为什么需要这个模块**:服务器上 Chrome 跑在 Xvfb 里没有显示器,爬虫原本用 +`show_qrcode`(PIL 的 `Image.show()`)弹窗展示二维码,那需要桌面看图程序,服务器上 +没有。所以改成经 CDP 把二维码从页面里读出来交给 WebUI。 -Three details that are easy to get wrong: +**为什么登录状态要能独立查询**:扫码会话是内存里的临时状态,进程一重启就没了 +(部署、崩溃都算)。把"是否已登录"绑在它上面,就会出现"扫完了但界面没反应、 +也不知道到底成没成"。所以状态查询是独立的、随时可调用的,二维码只是达成它的手段之一。 -* **Reuse the browser's default context.** ``browser.new_context()`` would give - an incognito-like profile, so the scan would land in a cookie jar the crawler - never reads and every run would still look logged out. The real profile -- the - one the crawler attaches to -- is ``browser.contexts[0]``. -* **Never call ``browser.close()``.** On a CDP connection that tears down the - operator's own Chrome and takes every unrelated tab with it. Only the page this - module opened is closed, and the Playwright client is stopped to drop the - socket. -* **A scan is not a login until the cookie changes.** The page can be showing a - QR for an account that is in fact already signed in, so success is decided by - ``web_session`` appearing or changing against the value captured at start, - never by anything the page displays. +三个容易搞错的地方: + +* **必须复用浏览器默认 context**。`browser.new_context()` 会造出一个无痕式的 profile, + 扫了也白扫——爬虫读不到那份 cookie。真正的 profile 在 `browser.contexts[0]`。 +* **绝不能调 `browser.close()`**。对 CDP 连接而言那会关掉操作者自己的 Chrome, + 连带所有无关标签页。只能关本模块自己开的那一个。 +* **不能靠 `web_session` 判断登录**。实测:一个全新的空 profile 首次访问小红书就会 + 被发一个 `web_session`,所以"有这个 cookie"什么都证明不了。可信信号是页面自己的 + `__INITIAL_STATE__.user.loggedIn`。 """ import asyncio +import json import os import time from typing import Any, Dict, Optional @@ -52,10 +49,12 @@ from tools import utils from .platforms import PLATFORM_XHS -# How long a QR stays valid before the session is written off. The platform -# rotates the code well before this; the limit exists so an abandoned attempt -# cannot pin a browser tab open indefinitely. -QR_TTL_SECONDS = 180 +# 二维码有效期。平台自己会更早轮换;这个上限只是为了让一次被放弃的尝试不会 +# 永久占着一个标签页。 +QR_TTL_SECONDS = 300 + +# 登录状态查询的缓存时长。轮询时不必每次都去问浏览器。 +STATE_CACHE_SECONDS = 5 STATUS_IDLE = "idle" STATUS_WAITING = "waiting" @@ -63,35 +62,56 @@ STATUS_SUCCESS = "success" STATUS_EXPIRED = "expired" STATUS_ERROR = "error" -# Only xhs is wired: it is the only platform whose monitor pipeline works, and -# pretending otherwise would offer the operator a button that cannot succeed. +# 只有小红书接了监控流程,所以扫码也只对它开放。给别的平台显示一个按不动的按钮 +# 是在假装功能存在。 LOGIN_URL: Dict[str, str] = {PLATFORM_XHS: "https://www.xiaohongshu.com"} +EXPLORE_URL: Dict[str, str] = {PLATFORM_XHS: "https://www.xiaohongshu.com/explore"} QR_SELECTOR: Dict[str, str] = {PLATFORM_XHS: "xpath=//img[@class='qrcode-img']"} LOGIN_BUTTON_SELECTOR: Dict[str, str] = { PLATFORM_XHS: "xpath=//*[@id='app']/div[1]/div[2]/div[1]/ul/div[1]/button" } -SESSION_COOKIE: Dict[str, str] = {PLATFORM_XHS: "web_session"} -_IDLE_SNAPSHOT: Dict[str, Any] = { - "status": STATUS_IDLE, - "platform": None, - "image": "", - "message": "", - "elapsed": 0, - "expires_in": 0, +# 页面自己报告的登录态。 +# +# `user.loggedIn` 是 Vue 的响应式引用,直接读会得到一个对象(这正是最初探针读到 +# "[object Object]" 的原因),必须取 `.value`。返回字符串而不是对象,因为 +# `__INITIAL_STATE__` 里有循环引用,`JSON.stringify` 整个结构会抛 +# "Converting circular structure to JSON"。 +LOGIN_STATE_PROBE = """ +() => { + try { + const user = (window.__INITIAL_STATE__ || {}).user; + if (!user) return JSON.stringify({ known: false }); + let loggedIn = user.loggedIn; + if (loggedIn && typeof loggedIn === 'object' && 'value' in loggedIn) loggedIn = loggedIn.value; + let info = null; + try { info = user.userInfo || null; } catch (e) { info = null; } + const text = (v) => (v === null || v === undefined ? null : String(v)); + return JSON.stringify({ + known: true, + loggedIn: Boolean(loggedIn), + nickname: info ? text(info.nickname) : null + }); + } catch (e) { + return JSON.stringify({ known: false, error: String(e) }); + } } +""" _lock = asyncio.Lock() _current: Optional["QrLoginSession"] = None +# 常驻的 Playwright 客户端和本模块自己的标签页。长期持有是有意的:状态查询要能 +# 随时回答,而每次都新建一个标签页会在操作者的浏览器里堆垃圾。 +_playwright: Any = None +_page: Any = None + +# (时间戳, 结果),避免轮询时反复问浏览器。 +_state_cache: Optional[tuple[float, Dict[str, Any]]] = None + def _cdp_url() -> str: - """Where to reach the browser's DevTools endpoint. - - ``MC_CDP_URL`` wins so a deployment can point at another host without a code - change; otherwise the port comes from the same config the crawler itself - reads, so the two can never drift apart. - """ + """浏览器 DevTools 端点。``MC_CDP_URL`` 优先,便于换主机而不用改代码。""" return os.getenv("MC_CDP_URL") or f"http://127.0.0.1:{config.CDP_DEBUG_PORT}" @@ -101,47 +121,163 @@ def _login_url(platform: str) -> str: return LOGIN_URL[platform] -class QrLoginSession: - """One live QR-login attempt against the CDP browser.""" +async def _ensure_context() -> Any: + """连上浏览器并返回它的默认 context。""" + global _playwright - def __init__(self, platform: str, playwright: Any, page: Any, baseline: str) -> None: + if _playwright is None: + _playwright = await async_playwright().start() + try: + browser = await _playwright.chromium.connect_over_cdp(_cdp_url(), timeout=15000) + except Exception as exc: + await _reset_playwright() + raise RuntimeError( + f"连接浏览器失败({_cdp_url()})。请确认服务器上的 Chrome 以 " + f"--remote-debugging-port 启动。原始错误:{exc}" + ) from exc + + if not browser.contexts: + raise RuntimeError( + "浏览器没有可用上下文。CDP 已连上,但读不到 profile —— " + "请确认 Chrome 不是以无痕模式启动的。" + ) + # contexts[0] 就是真实 profile,用它,不要 new_context()。 + return browser.contexts[0] + + +async def _ensure_page(platform: str = PLATFORM_XHS, reload: bool = False) -> Any: + """本模块在操作者浏览器里的那一个标签页,复用而不是反复新建。 + + 若已有一个停在目标站点的标签页就认领它——进程重启后页柄会丢,但标签页还在, + 认领可以避免在浏览器里留下一堆没人关的孤儿页。 + """ + global _page + + context = await _ensure_context() + + if _page is not None: + try: + if _page.is_closed(): + _page = None + except Exception: + _page = None + + if _page is None: + for candidate in context.pages: + try: + if "xiaohongshu.com" in candidate.url or "rednote.com" in candidate.url: + _page = candidate + break + except Exception: + continue + + if _page is None: + _page = await context.new_page() + + try: + url = _page.url + except Exception: + url = "" + + if reload or "xiaohongshu.com" not in url and "rednote.com" not in url: + await _page.goto( + EXPLORE_URL.get(platform, EXPLORE_URL[PLATFORM_XHS]), + wait_until="domcontentloaded", + timeout=45000, + ) + + return _page + + +async def check_login_state(force: bool = False) -> Dict[str, Any]: + """问浏览器:现在登录了吗? + + ``force`` 会先重新加载页面。SPA 的状态会随登录实时更新,所以轮询时不必重载; + 但若登录态是在别处失效的,页面上的副本可能是陈旧的,重新检测就该重载。 + """ + global _state_cache + + now = time.time() + if not force and _state_cache is not None: + cached_at, cached = _state_cache + if now - cached_at < STATE_CACHE_SECONDS: + return cached + + try: + page = await _ensure_page(reload=force) + raw = await page.evaluate(LOGIN_STATE_PROBE) + parsed = json.loads(raw) if isinstance(raw, str) else {"known": False} + except Exception as exc: + result = { + "known": False, + "logged_in": False, + "nickname": None, + "error": f"{exc.__class__.__name__}: {exc}", + } + _state_cache = (now, result) + return result + + result = { + "known": bool(parsed.get("known")), + "logged_in": bool(parsed.get("loggedIn")), + "nickname": parsed.get("nickname"), + } + _state_cache = (now, result) + return result + + +async def _reset_playwright() -> None: + global _playwright, _page + _page = None + if _playwright is not None: + try: + await _playwright.stop() + except Exception: + pass + _playwright = None + + +class QrLoginSession: + """一次进行中的扫码尝试。""" + + def __init__(self, platform: str, page: Any) -> None: self.platform = platform self.status = STATUS_WAITING - self.message = "请用小红书 App 扫描二维码" + self.message = "请用手机扫描二维码" self.image = "" self.started_at = time.time() - self._playwright = playwright + self.logged_in = False + self.nickname: Optional[str] = None self._page = page - # The `web_session` value present *before* the scan. An account already - # signed in has a non-empty baseline, which is why success is "changed", - # not merely "present". - self._baseline = baseline @property def elapsed(self) -> float: return time.time() - self.started_at async def refresh(self) -> None: - """Poll the browser once for a completed scan.""" + """轮询一次,看扫码是否完成。""" if self.status != STATUS_WAITING: return if self.elapsed > QR_TTL_SECONDS: self.status = STATUS_EXPIRED self.message = "二维码已超时,请重新获取" return - try: - cookies = await self._page.context.cookies() - except Exception: - # The operator may have closed the tab we opened. - self.status = STATUS_ERROR - self.message = "二维码所在页面已被关闭,请重新获取" - return - token = {c["name"]: c["value"] for c in cookies}.get( - SESSION_COOKIE[self.platform], "" - ) - if token and token != self._baseline: + + state = await check_login_state() + if state.get("logged_in"): + self.logged_in = True + self.nickname = state.get("nickname") self.status = STATUS_SUCCESS - self.message = "登录成功,登录态已写入浏览器 profile" + who = f"({self.nickname})" if self.nickname else "" + self.message = f"登录成功{who},登录态已写入浏览器 profile" + return + + try: + if self._page.is_closed(): + self.status = STATUS_ERROR + self.message = "二维码所在页面已被关闭,请重新获取" + except Exception: + pass def snapshot(self) -> Dict[str, Any]: return { @@ -151,27 +287,17 @@ class QrLoginSession: "message": self.message, "elapsed": int(self.elapsed), "expires_in": max(0, int(QR_TTL_SECONDS - self.elapsed)), + "logged_in": self.logged_in, + "nickname": self.nickname, } - async def close(self) -> None: - """Drop our page and the Playwright client, leaving Chrome untouched.""" - try: - await self._page.close() - except Exception: - pass - try: - await self._playwright.stop() - except Exception: - pass - async def _read_qr(page: Any, platform: str) -> str: - """Pull the QR image out of the page, opening the login dialog if needed.""" + """把二维码从页面里取出来,必要时先点开登录框。""" image = await utils.find_login_qrcode(page, selector=QR_SELECTOR[platform]) if image: return image - # The dialog does not always open on its own. This is the same fallback the - # crawler's own QR flow performs before giving up. + # 登录框不一定自己弹出来。这是爬虫自身扫码流程里同款兜底。 await asyncio.sleep(0.5) try: await page.locator(LOGIN_BUTTON_SELECTOR[platform]).click(timeout=5000) @@ -180,68 +306,45 @@ async def _read_qr(page: Any, platform: str) -> str: return await utils.find_login_qrcode(page, selector=QR_SELECTOR[platform]) -async def _reset_locked() -> None: +async def _discard_current_locked() -> None: global _current - if _current is not None: - await _current.close() - _current = None + _current = None async def start(platform: str = PLATFORM_XHS) -> Dict[str, Any]: - """Open a login page in the CDP browser and return its QR code.""" + """在 CDP 浏览器里打开登录页,取回二维码。""" + global _current + if platform not in LOGIN_URL: raise ValueError(f"平台 {platform} 尚未接入扫码登录(目前仅支持小红书)") async with _lock: - await _reset_locked() + await _discard_current_locked() - playwright = await async_playwright().start() + page = await _ensure_page(platform) try: - browser = await playwright.chromium.connect_over_cdp(_cdp_url(), timeout=15000) - except Exception as exc: - await playwright.stop() - raise RuntimeError( - f"连接浏览器失败({_cdp_url()})。请确认服务器上的 Chrome 以 " - f"--remote-debugging-port 启动。原始错误:{exc}" - ) from exc - - if not browser.contexts: - await playwright.stop() - raise RuntimeError( - "浏览器没有可用上下文。CDP 已连上,但读不到 profile —— " - "请确认 Chrome 不是以无痕模式启动的。" + await page.goto( + _login_url(platform), wait_until="domcontentloaded", timeout=45000 ) - - # contexts[0] is the real profile. See the module docstring. - context = browser.contexts[0] - page = await context.new_page() - try: - await page.goto(_login_url(platform), wait_until="domcontentloaded", timeout=30000) image = await _read_qr(page, platform) - cookies = await context.cookies() except Exception as exc: - try: - await page.close() - except Exception: - pass - await playwright.stop() raise RuntimeError(f"打开登录页失败:{exc}") from exc - baseline = {c["name"]: c["value"] for c in cookies}.get( - SESSION_COOKIE[platform], "" - ) - - session = QrLoginSession(platform, playwright, page, baseline) + session = QrLoginSession(platform, page) session.image = image - if not image: - if baseline: - session.status = STATUS_SUCCESS - session.message = "浏览器已经是登录状态,无需扫码" - else: - session.status = STATUS_ERROR - session.message = "页面上没找到二维码,请确认站点结构没有变化" - global _current + state = await check_login_state() + if state.get("logged_in"): + # 已经是登录状态时站点不显示二维码——这本身就是成功,不是失败。 + session.status = STATUS_SUCCESS + session.logged_in = True + session.nickname = state.get("nickname") + who = f"({session.nickname})" if session.nickname else "" + session.message = f"浏览器已经是登录状态{who},无需扫码" + elif not image: + session.status = STATUS_ERROR + session.message = "页面上没找到二维码,请确认站点结构没有变化" + _current = session return session.snapshot() @@ -249,20 +352,40 @@ async def start(platform: str = PLATFORM_XHS) -> Dict[str, Any]: async def status() -> Dict[str, Any]: async with _lock: if _current is None: - return dict(_IDLE_SNAPSHOT) + state = await check_login_state() + return { + "status": STATUS_IDLE, + "platform": None, + "image": "", + "message": "", + "elapsed": 0, + "expires_in": 0, + "logged_in": bool(state.get("logged_in")), + "nickname": state.get("nickname"), + } await _current.refresh() return _current.snapshot() async def cancel() -> Dict[str, Any]: async with _lock: - await _reset_locked() - snapshot = dict(_IDLE_SNAPSHOT) - snapshot["message"] = "已取消" - return snapshot + await _discard_current_locked() + state = await check_login_state() + return { + "status": STATUS_IDLE, + "platform": None, + "image": "", + "message": "已取消", + "elapsed": 0, + "expires_in": 0, + "logged_in": bool(state.get("logged_in")), + "nickname": state.get("nickname"), + } async def shutdown() -> None: - """Release the browser tab at application shutdown.""" + """进程退出时断开连接。刻意不关那个标签页——它是操作者浏览器的一部分。""" + global _current async with _lock: - await _reset_locked() + _current = None + await _reset_playwright() diff --git a/api/routers/monitor.py b/api/routers/monitor.py index 3b283ac..79592c8 100644 --- a/api/routers/monitor.py +++ b/api/routers/monitor.py @@ -280,6 +280,20 @@ async def cancel_qr_login(): return await qrlogin.cancel() +@router.get("/login/state") +async def get_login_state(force: bool = Query(default=False)): + """Ask the browser itself whether it is signed in. + + Deliberately separate from the QR session above. That session is in-memory and + dies with the process -- a redeploy is enough -- so "am I logged in?" must not + hinge on it, or a successful scan looks like nothing happened. + + ``force`` reloads the page first, for when the login may have lapsed somewhere + else and the page's copy of the state is stale. + """ + return await qrlogin.check_login_state(force=force) + + # --------------------------------------------------------------------------- # Report # --------------------------------------------------------------------------- diff --git a/tests/test_qrlogin.py b/tests/test_qrlogin.py index 7f287a9..e561d1c 100644 --- a/tests/test_qrlogin.py +++ b/tests/test_qrlogin.py @@ -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() diff --git a/webui/src/components/monitor/NoteTrendChart.tsx b/webui/src/components/monitor/NoteTrendChart.tsx index 1dd4be8..2a64776 100644 --- a/webui/src/components/monitor/NoteTrendChart.tsx +++ b/webui/src/components/monitor/NoteTrendChart.tsx @@ -19,20 +19,53 @@ const PAD_RIGHT = 16 const PAD_TOP = 14 const PAD_BOTTOM = 22 -/** - * 把纵轴上界向上取到一个好读的数(1 / 1.2 / 1.5 / 2 / …)。 - * - * 纵轴刻意从 0 起。原先按 [最小值, 最大值] 自适应,于是 491 -> 506 这点变化被撑满 - * 整个图高、看着像暴涨;而且上下两个刻度就是 "506" 和 "491" 两个几乎一样的数, - * 没有 0 做参照,根本读不出量级。 - */ -const NICE_STEPS = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10] +const TICK_COUNT = 3 -function niceMax(value: number): number { +/** 把步长收敛到 1 / 2 / 5 × 10ⁿ,刻度才会落在好读的数上。 */ +function niceNumber(value: number, round: boolean): number { if (value <= 0) return 1 - const magnitude = 10 ** Math.floor(Math.log10(value)) - const step = NICE_STEPS.find((candidate) => value / magnitude <= candidate) ?? 10 - return step * magnitude + const exponent = Math.floor(Math.log10(value)) + const fraction = value / 10 ** exponent + let nice: number + if (round) { + nice = fraction < 1.5 ? 1 : fraction < 3 ? 2 : fraction < 7 ? 5 : 10 + } else { + nice = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10 + } + return nice * 10 ** exponent +} + +/** + * 给折线图选一个纵轴范围。 + * + * **刻意不从 0 起。** 柱状图用「长度」编码数值,基线不为 0 比例就是错的;折线图用 + * 「位置」编码,轴只需要如实框住数据 —— 这正是让 491 → 506 这段变化看得见的原因, + * 否则它会贴着 0–600 的底边变成一条直线。 + * + * 代价是纵轴不再是 0,所以刻度必须落在左侧栏里、显示真实数值,让读图的人随时知道 + * 范围是多少。这一点在上一轮已经修好。 + */ +function niceAxis(values: number[]): { min: number; max: number; ticks: number[] } { + const dataMin = Math.min(...values) + const dataMax = Math.max(...values) + + // 全平的一组数没有跨度可缩放,给它一个名义区间,让线落在图中而不是贴边。 + const span = dataMax - dataMin || Math.max(1, Math.abs(dataMax) * 0.05 || 1) + + const step = niceNumber(span / (TICK_COUNT - 1), true) + let min = Math.floor(dataMin / step) * step + let max = Math.ceil(dataMax / step) * step + if (min === max) { + // 取整后塌成一点会除零,撑开一档。 + min -= step + max += step + } + + const ticks: number[] = [] + for (let value = min; value <= max + step / 2; value += step) { + ticks.push(Math.round(value)) + } + return { min, max, ticks } } interface NoteTrendChartProps { @@ -81,22 +114,24 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp if (points.length < 2 || width <= 0) return null const values = points.map((point) => point[metric] as number) - const axisMax = niceMax(Math.max(...values)) + const { min, max, ticks } = niceAxis(values) const innerW = Math.max(1, width - PAD_LEFT - PAD_RIGHT) const innerH = VIEW_H - PAD_TOP - PAD_BOTTOM + const yFor = (value: number) => + PAD_TOP + innerH * (1 - (value - min) / (max - min)) const xy = points.map((point, index) => { const value = point[metric] as number return { x: PAD_LEFT + (index / (points.length - 1)) * innerW, - y: PAD_TOP + innerH - (value / axisMax) * innerH, + y: yFor(value), value, point, } }) - return { xy, axisMax, innerW, innerH } + return { xy, ticks, yFor, innerW, innerH } }, [points, metric, width]) if (isLoading) { @@ -162,10 +197,10 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp > {/* 刻度线画在 0 / 中值 / 上界上,标签就贴在对应位置 —— 不再浮在图面上, 也就不会出现两个数挤在一起读成一个数的情况。 */} - {[0, 0.5, 1].map((ratio) => { - const y = PAD_TOP + geometry.innerH * ratio + {geometry.ticks.map((tick) => { + const y = geometry.yFor(tick) return ( - + - {formatCount(Math.round(geometry.axisMax * (1 - ratio)))} + {formatCount(tick)} ) @@ -256,7 +291,7 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp )}

- 共 {points.length} 个数据点,每轮采集记录一次快照 · 纵轴自 0 起 + 共 {points.length} 个数据点,每轮采集记录一次快照 · 纵轴范围见左侧刻度

) diff --git a/webui/src/components/monitor/QrLoginPanel.tsx b/webui/src/components/monitor/QrLoginPanel.tsx index 9bc7df6..92f465a 100644 --- a/webui/src/components/monitor/QrLoginPanel.tsx +++ b/webui/src/components/monitor/QrLoginPanel.tsx @@ -1,22 +1,38 @@ import { useEffect, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { AlertTriangle, CheckCircle2, Loader2, QrCode, RefreshCw, X } from 'lucide-react' +import { + AlertTriangle, + CheckCircle2, + KeyRound, + Loader2, + QrCode, + RefreshCw, + X, +} from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { useCancelQrLogin, useQrLoginStatus, useStartQrLogin } from '@/hooks/useMonitor' +import { + useCancelQrLogin, + useLoginState, + useQrLoginStatus, + useRecheckLogin, + useStartQrLogin, +} from '@/hooks/useMonitor' import { useCurrentPlatform } from '@/hooks/usePlatform' /** - * Scan-to-login for a host with no display. + * Scan-to-login, for a host with no display. * * The crawler's own QR flow prints the code into the terminal via PIL's * `Image.show()`, which needs a desktop image viewer. On a server Chrome runs * under Xvfb and there is no such viewer, so the backend reads the code out of - * that same browser over CDP and hands it here instead. + * that same browser over CDP and hands it here. * - * That browser is the one monitor runs attach to, which is the point: scanning - * once leaves the login in the profile that every later unattended run reuses. + * **The login state is shown first and independently of the scan.** It comes from + * the browser's own profile, so it stays true across a server restart — the QR + * session does not, and reporting "did it work?" from a value that a redeploy + * silently erases is how a successful scan ends up looking like nothing happened. */ export function QrLoginPanel() { const { capability, platform } = useCurrentPlatform() @@ -24,12 +40,19 @@ export function QrLoginPanel() { const [polling, setPolling] = useState(false) const { data: state } = useQrLoginStatus(polling) + const { data: login } = useLoginState(polling) const start = useStartQrLogin() const cancel = useCancelQrLogin() + const recheck = useRecheckLogin() const label = capability?.label ?? platform const status = state?.status ?? 'idle' + // The browser's own answer wins over the session's: the session is memory, the + // profile is not. + const loggedIn = login?.logged_in ?? state?.logged_in ?? false + const nickname = login?.nickname ?? state?.nickname ?? null + // Stop polling the moment the outcome is known, and refresh the cookie panel: // a completed scan is what makes it start reporting a healthy login. useEffect(() => { @@ -37,6 +60,7 @@ export function QrLoginPanel() { setPolling(false) if (status === 'success') { queryClient.invalidateQueries({ queryKey: ['monitorCookie'] }) + queryClient.invalidateQueries({ queryKey: ['monitorLoginState'] }) } }, [status, queryClient]) @@ -45,43 +69,53 @@ export function QrLoginPanel() { start.mutate() } - const busy = start.isPending || cancel.isPending + const busy = start.isPending || cancel.isPending || recheck.isPending return (
-
- - - {label}扫码登录 +
+ + + {label}登录态 - {status === 'waiting' && ( - - 等待扫码 - - )} - {status === 'success' && ( + {loggedIn ? ( 已登录 + ) : ( + + 未登录 + + )} + {loggedIn && nickname && ( + + {nickname} + )}
- {status === 'waiting' && ( - - )} +
+ {/* 读不到状态时要说清楚是"读不到",而不是悄悄显示成"未登录" */} + {login?.known === false && ( +

+ + 读不到浏览器状态{login.error ? `:${login.error}` : ''}。 + 请确认那台 Chrome 正常、且已打开小红书页面。 +

+ )} + {status === 'waiting' && state?.image && (
{/* The code is a data: URL straight from the page, so nothing is @@ -94,12 +128,23 @@ export function QrLoginPanel() {

用{label} App扫码。 - 扫码成功后登录态会写入服务器上那台 Chrome 的 profile, - 之后定时监控无需再登录。 + 扫完这个面板会自动变成「已登录」,无需手动刷新。

剩余 {state.expires_in} 秒

+
)} @@ -134,19 +179,20 @@ export function QrLoginPanel() { {status === 'idle' && (

- 走 CDP 接管服务器上已开启远程调试的 Chrome,把二维码取回来显示在这里。 - 需要先在「系统设置」里打开 - 接管已有 Chrome(CDP), - 并确保那台 Chrome 正以 9222 端口运行。 + {loggedIn + ? '这台浏览器已是登录状态,定时监控会直接复用它的 profile,无需再扫码。' + : '经 CDP 接管服务器上已开启远程调试的 Chrome,把二维码取回来显示在这里。需要先在「系统设置」里打开 接管已有 Chrome(CDP),并确保那台 Chrome 正以 9222 端口运行。'}

- + {!loggedIn && ( + + )}
)}
diff --git a/webui/src/hooks/useMonitor.ts b/webui/src/hooks/useMonitor.ts index 73e6b25..abbd54d 100644 --- a/webui/src/hooks/useMonitor.ts +++ b/webui/src/hooks/useMonitor.ts @@ -244,6 +244,34 @@ export function useCancelQrLogin() { }) } +/** + * Whether the browser is actually signed in. + * + * Tracked separately from the QR session above: that session lives in the + * server's memory and a restart erases it, while the profile it wrote to does + * not. Polled slowly when idle and briskly while a code is on screen. + */ +export function useLoginState(polling: boolean) { + return useQuery({ + queryKey: ['monitorLoginState'], + queryFn: async () => (await monitorApi.getLoginState()).data, + refetchInterval: polling ? 4000 : 30000, + refetchOnWindowFocus: false, + }) +} + +/** Re-ask after reloading the page, for when the state looks stale. */ +export function useRecheckLogin() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => monitorApi.getLoginState(true), + onSuccess: (response) => { + queryClient.setQueryData(['monitorLoginState'], response.data) + }, + onError: (error: Error) => toast.error(`检测失败:${error.message}`), + }) +} + // --- Settings ------------------------------------------------------------- export function useSettings() { diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 4bc2283..f74f8e7 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -10,6 +10,7 @@ import type { MonitorOverview, MonitorRun, MonitorTask, + LoginState, PlatformCapability, QrLoginState, ReportResult, @@ -263,6 +264,9 @@ export const monitorApi = { api.post('/monitor/login/qr', null, { params: { platform } }), getQrLogin: () => api.get('/monitor/login/qr'), cancelQrLogin: () => api.delete('/monitor/login/qr'), + /** `force` reloads the page first, for a stale-looking state. */ + getLoginState: (force = false) => + api.get('/monitor/login/state', { params: { force } }), getWebhook: () => api.get('/monitor/webhook'), setWebhook: (url: string) => api.post('/monitor/webhook', { url }), diff --git a/webui/src/types/monitor.ts b/webui/src/types/monitor.ts index 78e0074..d5647d9 100644 --- a/webui/src/types/monitor.ts +++ b/webui/src/types/monitor.ts @@ -196,6 +196,26 @@ export interface QrLoginState { elapsed: number /** Seconds left before the code is written off. */ expires_in: number + /** Whether the browser reports being signed in right now. */ + logged_in: boolean + nickname: string | null +} + +/** + * The browser's own answer to "am I signed in?". + * + * Asked of the page, not derived from the QR session -- that session lives in the + * server's memory and dies on a restart, so tying the answer to it makes a + * successful scan look like nothing happened. + * + * `known` is false when the page could not report (not loaded, browser + * unreachable); `logged_in` is then meaningless rather than false. + */ +export interface LoginState { + known: boolean + logged_in: boolean + nickname: string | null + error?: string } export interface MonitorOverview {