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:
+252
-129
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user