fix: 扫码登录状态可独立查询 + 趋势图改回自适应纵轴
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

【登录反馈】实测这台浏览器 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:
2026-10-07 15:42:58 +08:00
parent e608b51210
commit 6eff6fcc83
8 changed files with 578 additions and 270 deletions
+252 -129
View File
@@ -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 ``<img>``.
**为什么需要这个模块**:服务器上 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()
+14
View File
@@ -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
# ---------------------------------------------------------------------------
+115 -77
View File
@@ -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()
+55 -20
View File
@@ -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 (
<g key={ratio}>
<g key={tick}>
<line
x1={PAD_LEFT}
x2={width - PAD_RIGHT}
@@ -183,7 +218,7 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp
fill="rgb(var(--cyber-text-muted))"
style={{ fontFamily: 'ui-monospace, SFMono-Regular, monospace' }}
>
{formatCount(Math.round(geometry.axisMax * (1 - ratio)))}
{formatCount(tick)}
</text>
</g>
)
@@ -256,7 +291,7 @@ export function NoteTrendChart({ noteId, taskId, noteTitle }: NoteTrendChartProp
)}
<p className="text-[10px] font-mono text-cyber-text-muted">
共 {points.length} 个数据点,每轮采集记录一次快照 · 纵轴自 0 起
共 {points.length} 个数据点,每轮采集记录一次快照 · 纵轴范围见左侧刻度
</p>
</div>
)
+90 -44
View File
@@ -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 (
<div className="rounded-lg glass-panel float-panel p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<QrCode className="w-4 h-4 text-cyber-neon-cyan" />
<span className="font-mono text-xs tracking-wider text-cyber-text-primary">
{label}扫码登录
<div className="flex items-center gap-2 min-w-0">
<KeyRound className="w-4 h-4 text-cyber-neon-cyan flex-shrink-0" />
<span className="font-mono text-xs tracking-wider text-cyber-text-primary flex-shrink-0">
{label}登录态
</span>
{status === 'waiting' && (
<Badge variant="warning" className="text-[10px]">
等待扫码
</Badge>
)}
{status === 'success' && (
{loggedIn ? (
<Badge variant="success" className="text-[10px]">
已登录
</Badge>
) : (
<Badge variant="warning" className="text-[10px]">
未登录
</Badge>
)}
{loggedIn && nickname && (
<span className="truncate text-[10px] font-mono text-cyber-text-secondary">
{nickname}
</span>
)}
</div>
{status === 'waiting' && (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => {
setPolling(false)
cancel.mutate()
}}
>
<X className="w-3 h-3" />
</Button>
)}
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => recheck.mutate()}
title="重新加载页面并检测登录态"
>
<RefreshCw className={`w-3 h-3 mr-1 ${recheck.isPending ? 'animate-spin' : ''}`} />
重新检测
</Button>
</div>
{/* 读不到状态时要说清楚是"读不到",而不是悄悄显示成"未登录" */}
{login?.known === false && (
<p className="flex items-start gap-2 text-[10px] font-mono text-cyber-neon-orange leading-relaxed">
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
读不到浏览器状态{login.error ? `:${login.error}` : ''}。
请确认那台 Chrome 正常、且已打开小红书页面。
</p>
)}
{status === 'waiting' && state?.image && (
<div className="flex items-start gap-4">
{/* The code is a data: URL straight from the page, so nothing is
@@ -94,12 +128,23 @@ export function QrLoginPanel() {
<div className="space-y-2 pt-1">
<p className="text-[11px] font-mono text-cyber-text-secondary leading-relaxed">
用<span className="text-cyber-neon-cyan">{label} App</span>扫码。
扫码成功后登录态会写入服务器上那台 Chrome 的 profile,
之后定时监控无需再登录。
扫完这个面板会自动变成「已登录」,无需手动刷新。
</p>
<p className="text-[11px] font-mono text-cyber-text-muted">
剩余 <span className="text-cyber-neon-cyan">{state.expires_in}</span> 秒
</p>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => {
setPolling(false)
cancel.mutate()
}}
>
<X className="w-3 h-3 mr-1" />
取消
</Button>
</div>
</div>
)}
@@ -134,19 +179,20 @@ export function QrLoginPanel() {
{status === 'idle' && (
<div className="space-y-2">
<p className="text-[11px] font-mono text-cyber-text-muted leading-relaxed">
走 CDP 接管服务器上已开启远程调试的 Chrome,把二维码取回来显示在这里。
需要先在「系统设置」里打开
<span className="text-cyber-neon-cyan">接管已有 Chrome(CDP)</span>,
并确保那台 Chrome 正以 <span className="text-cyber-neon-cyan">9222</span> 端口运行。
{loggedIn
? '这台浏览器已是登录状态,定时监控会直接复用它的 profile,无需再扫码。'
: '经 CDP 接管服务器上已开启远程调试的 Chrome,把二维码取回来显示在这里。需要先在「系统设置」里打开 接管已有 Chrome(CDP),并确保那台 Chrome 正以 9222 端口运行。'}
</p>
<Button size="sm" disabled={busy} onClick={begin}>
{start.isPending ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<QrCode className="w-3 h-3 mr-1" />
)}
获取二维码
</Button>
{!loggedIn && (
<Button size="sm" disabled={busy} onClick={begin}>
{start.isPending ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<QrCode className="w-3 h-3 mr-1" />
)}
获取二维码
</Button>
)}
</div>
)}
</div>
+28
View File
@@ -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() {
+4
View File
@@ -10,6 +10,7 @@ import type {
MonitorOverview,
MonitorRun,
MonitorTask,
LoginState,
PlatformCapability,
QrLoginState,
ReportResult,
@@ -263,6 +264,9 @@ export const monitorApi = {
api.post<QrLoginState>('/monitor/login/qr', null, { params: { platform } }),
getQrLogin: () => api.get<QrLoginState>('/monitor/login/qr'),
cancelQrLogin: () => api.delete<QrLoginState>('/monitor/login/qr'),
/** `force` reloads the page first, for a stale-looking state. */
getLoginState: (force = false) =>
api.get<LoginState>('/monitor/login/state', { params: { force } }),
getWebhook: () => api.get<WebhookStatus>('/monitor/webhook'),
setWebhook: (url: string) => api.post('/monitor/webhook', { url }),
+20
View File
@@ -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 {