diff --git a/api/monitor/qrlogin.py b/api/monitor/qrlogin.py index 145844e..81ca128 100644 --- a/api/monitor/qrlogin.py +++ b/api/monitor/qrlogin.py @@ -38,7 +38,6 @@ """ import asyncio -import json import os import time from typing import Any, Dict, Optional @@ -47,8 +46,14 @@ import config from playwright.async_api import async_playwright from tools import utils +from ..creator.client import CreatorApiError, CreatorClient from .platforms import PLATFORM_XHS + +def _cookie_string(cookies) -> str: + """把 CDP 拿到的 cookie 列表拼成请求头用的字符串。""" + return "; ".join(f"{c['name']}={c['value']}" for c in cookies) + # 二维码有效期。平台自己会更早轮换;这个上限只是为了让一次被放弃的尝试不会 # 永久占着一个标签页。 QR_TTL_SECONDS = 300 @@ -71,32 +76,11 @@ LOGIN_BUTTON_SELECTOR: Dict[str, str] = { PLATFORM_XHS: "xpath=//*[@id='app']/div[1]/div[2]/div[1]/ul/div[1]/button" } -# 页面自己报告的登录态。 +# 这里本来有一个读 window.__INITIAL_STATE__ 的 JS 探针,**已删除,不要加回来**。 # -# `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) }); - } -} -""" +# 它是页面加载那一刻的快照:浏览器本来就登录着时它是对的,但扫码是加载**之后**才 +# 登录的,快照不会翻转,检测于是永远等不到 —— 表现为"扫了码却一直停在二维码上"。 +# 运营模块踩过同一个坑。现在的判据是拿 cookie 问后台接口,见 check_login_state。 _lock = asyncio.Lock() _current: Optional["QrLoginSession"] = None @@ -204,9 +188,8 @@ async def check_login_state(force: bool = False) -> Dict[str, Any]: 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} + context = await _ensure_context() + cookies = await context.cookies() except Exception as exc: result = { "known": False, @@ -217,15 +200,38 @@ async def check_login_state(force: bool = False) -> Dict[str, Any]: _state_cache = (now, result) return result - result = { - "known": bool(parsed.get("known")), - "logged_in": bool(parsed.get("loggedIn")), - "nickname": parsed.get("nickname"), - } + cookie = _cookie_string(cookies) + + # 判据不再是页面里的 window.__INITIAL_STATE__ —— 那是**页面加载那一刻的快照**: + # 浏览器已登录时它是对的,但扫码是加载**之后**才登录的,快照不会翻转,检测就永远 + # 等不到(运营模块踩过同一个坑)。改成拿 cookie 问后台「我是谁」,那是权威的: + # 实测游客也会被发一个 web_session,所以「有这个 cookie」什么都证明不了, + # 后台认了才算。 + try: + info = await CreatorClient(cookie).fetch_user_info() + except CreatorApiError: + result = {"known": True, "logged_in": False, "nickname": None} + else: + result = { + "known": True, + "logged_in": bool(info.get("user_id")), + "nickname": info.get("nickname"), + } + _state_cache = (now, result) return result +async def _current_cookie() -> str: + """默认 profile 当前的小红书 cookie 串。 + + 扫码面板要的不只是「登录了吗」,而是**把登录态拿出来存一份** —— 存进库之后, + 即使 CDP 关掉、任务改用 --cookies_file 注入,也照样能跑。 + """ + context = await _ensure_context() + return _cookie_string(await context.cookies()) + + async def _reset_playwright() -> None: global _playwright, _page _page = None @@ -248,6 +254,9 @@ class QrLoginSession: self.started_at = time.time() self.logged_in = False self.nickname: Optional[str] = None + # 登录成功后从默认 profile 取出来的 cookie,供调用方存库。 + self.cookie: str = "" + self.cookie_taken = False self._page = page @property @@ -265,6 +274,7 @@ class QrLoginSession: state = await check_login_state() if state.get("logged_in"): + self.cookie = await _current_cookie() self.logged_in = True self.nickname = state.get("nickname") self.status = STATUS_SUCCESS @@ -336,6 +346,8 @@ async def start(platform: str = PLATFORM_XHS) -> Dict[str, Any]: state = await check_login_state() if state.get("logged_in"): # 已经是登录状态时站点不显示二维码——这本身就是成功,不是失败。 + # 顺带把 cookie 取出来,让调用方可以存进库。 + session.cookie = await _current_cookie() session.status = STATUS_SUCCESS session.logged_in = True session.nickname = state.get("nickname") @@ -367,6 +379,22 @@ async def status() -> Dict[str, Any]: return _current.snapshot() +async def take_cookie() -> Optional[str]: + """取走已登录会话的 cookie,且只给一次。 + + 由路由层在落库时调用。**cookie 不进响应体** —— 它是凭证,前端没有理由看到它。 + + 这里**刻意不结束会话**(与运营模块不同):那里取完即拆,因为临时上下文用完就该丢; + 这里的浏览器 profile 是长期存在的,面板还该继续显示「已登录」。所以只标记已取过, + 让重复轮询拿不到第二份、也就不会反复写库。 + """ + async with _lock: + if _current is None or _current.status != STATUS_SUCCESS or _current.cookie_taken: + return None + _current.cookie_taken = True + return _current.cookie + + async def cancel() -> Dict[str, Any]: async with _lock: await _discard_current_locked() diff --git a/api/routers/monitor.py b/api/routers/monitor.py index 656bcf4..08c671e 100644 --- a/api/routers/monitor.py +++ b/api/routers/monitor.py @@ -300,8 +300,23 @@ async def start_qr_login(platform: str = Query(default=PLATFORM_XHS)): @router.get("/login/qr") async def get_qr_login(): - """Poll the live session: waiting -> success / expired / error.""" - return await qrlogin.status() + """Poll the live session: waiting -> success / expired / error. + + 扫码成功时**把 cookie 一并存进库**。扫码本来只写浏览器 profile,那只够 CDP 模式用; + 存一份之后,CDP 关掉、任务改用 --cookies_file 注入也照样能跑 —— 两种机制同时填上, + 开关怎么切都不会断。 + """ + snapshot = await qrlogin.status() + + if snapshot["status"] == qrlogin.STATUS_SUCCESS: + cookie = await qrlogin.take_cookie() + if cookie: + async with get_session() as session: + await set_cookie(session, cookie) + snapshot["cookie_saved"] = True + snapshot["message"] = f"{snapshot['message']};登录态已同时存入 Cookie" + + return snapshot @router.delete("/login/qr") diff --git a/tests/test_qrlogin.py b/tests/test_qrlogin.py index e561d1c..0f6c463 100644 --- a/tests/test_qrlogin.py +++ b/tests/test_qrlogin.py @@ -1,27 +1,25 @@ # -*- coding: utf-8 -*- -"""Tests for CDP-driven QR login and the login-state check. +"""监控侧扫码登录与登录态检测。 -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, 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. +* 二维码必须从浏览器**默认 context** 里读 —— 新建 context 是无痕式的 profile, + 扫了也白扫,爬虫读不到那份 cookie; +* 「登录了吗」不能用页面里的 `window.__INITIAL_STATE__`。那是**页面加载那一刻的快照**: + 浏览器本来就登录着时它是对的,但扫码是加载**之后**才登录的,快照不会翻转, + 于是扫完码界面会一直停在二维码上。判据改成拿 cookie 问后台接口。 """ -import json from unittest.mock import AsyncMock, MagicMock import pytest +from api.creator.client import CreatorApiError from api.monitor import qrlogin @pytest.fixture(autouse=True) def _reset_module_state(): - """Each test starts from "nothing on screen" and leaves it that way.""" for attribute in ("_current", "_page", "_playwright", "_state_cache"): setattr(qrlogin, attribute, None) yield @@ -29,27 +27,28 @@ def _reset_module_state(): setattr(qrlogin, attribute, None) -def _probe_result(logged_in: bool, nickname=None, known: bool = True) -> str: - return json.dumps({"known": known, "loggedIn": logged_in, "nickname": nickname}) +XHS_COOKIES = [ + {"name": "a1", "value": "an-a1-value"}, + {"name": "web_session", "value": "a-session"}, +] -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.""" +def _fake_stack(cookies=None, qr="data:image/png;base64,AAAA"): + """Chrome/Playwright 替身,行为与真实的一致。""" page = MagicMock() page.url = "https://www.xiaohongshu.com/explore" page.is_closed = MagicMock(return_value=False) page.goto = AsyncMock() page.close = AsyncMock() - page.evaluate = AsyncMock(return_value=_probe_result(logged_in, nickname)) context = MagicMock() context.pages = [] + context.cookies = AsyncMock(return_value=list(cookies if cookies is not None else XHS_COOKIES)) context.new_page = AsyncMock(return_value=page) browser = MagicMock() browser.contexts = [context] - # 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. + # 去新建 context 正是这里要防的 bug,所以让它直接炸,而不是悄悄返回一个无痕 profile。 browser.new_context = AsyncMock( side_effect=AssertionError("must reuse browser.contexts[0], not a new context") ) @@ -64,15 +63,31 @@ def _fake_stack(logged_in: bool = False, nickname=None, qr: str = "data:image/pn return manager, playwright, browser, context, page, qr -def _patch(monkeypatch, manager, qr="data:image/png;base64,AAAA"): +def _patch(monkeypatch, manager, qr="data:image/png;base64,AAAA", resolver=None): + """``resolver(cookie)`` 返回账号信息 dict,或抛 CreatorApiError。""" + if resolver is None: + resolver = lambda _cookie: {"user_id": "u1", "nickname": "小明"} # noqa: E731 + + class _Client: + def __init__(self, cookie, **kwargs): + self.cookie = cookie + + async def fetch_user_info(self): + return resolver(self.cookie) + monkeypatch.setattr(qrlogin, "async_playwright", lambda: manager) + monkeypatch.setattr(qrlogin, "CreatorClient", _Client) monkeypatch.setattr(qrlogin.utils, "find_login_qrcode", AsyncMock(return_value=qr)) +def _signed_out(_cookie): + raise CreatorApiError("登录态无效或已过期", status=401) + + @pytest.mark.asyncio async def test_idle_reports_the_browsers_login_state(monkeypatch): - manager, _pw, _browser, _context, _page, _qr = _fake_stack(logged_in=True, nickname="小明") - _patch(monkeypatch, manager) + manager, *_ = _fake_stack() + _patch(monkeypatch, manager, resolver=lambda _c: {"user_id": "u1", "nickname": "小明"}) snapshot = await qrlogin.status() @@ -83,15 +98,15 @@ async def test_idle_reports_the_browsers_login_state(monkeypatch): @pytest.mark.asyncio async def test_unwired_platform_is_rejected(): - """Only xhs is wired; anything else must fail loudly, not show a dead button.""" + """只有小红书接了扫码;别的平台必须直接报错,而不是给个按不动的按钮。""" with pytest.raises(ValueError): await qrlogin.start("dy") @pytest.mark.asyncio -async def test_start_returns_the_qr_and_reuses_the_default_context(monkeypatch): +async def test_start_reads_the_qr_from_the_default_context(monkeypatch): manager, _pw, browser, context, _page, _qr = _fake_stack() - _patch(monkeypatch, manager) + _patch(monkeypatch, manager, resolver=_signed_out) snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS) @@ -103,10 +118,10 @@ async def test_start_returns_the_qr_and_reuses_the_default_context(monkeypatch): @pytest.mark.asyncio async def test_start_does_not_open_a_second_tab(monkeypatch): - """An open xhs tab is adopted, so restarts do not litter the browser.""" + """已有的 xhs 标签页会被认领,所以重启不会在浏览器里堆孤儿页。""" manager, _pw, _browser, context, page, _qr = _fake_stack() context.pages = [page] - _patch(monkeypatch, manager) + _patch(monkeypatch, manager, resolver=_signed_out) await qrlogin.start(qrlogin.PLATFORM_XHS) @@ -115,11 +130,9 @@ async def test_start_does_not_open_a_second_tab(monkeypatch): @pytest.mark.asyncio async def test_an_already_signed_in_profile_needs_no_scan(monkeypatch): - """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="") + """没二维码但 profile 已登录 —— 这是成功,不是失败。""" + manager, *_rest = _fake_stack() + _patch(monkeypatch, manager, qr="", resolver=lambda _c: {"user_id": "u9", "nickname": "老王"}) snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS) @@ -129,8 +142,8 @@ async def test_an_already_signed_in_profile_needs_no_scan(monkeypatch): @pytest.mark.asyncio 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="") + manager, *_rest = _fake_stack() + _patch(monkeypatch, manager, qr="", resolver=_signed_out) snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS) @@ -139,25 +152,50 @@ async def test_no_qr_and_not_signed_in_is_an_error(monkeypatch): @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) + """**这个判据是重点**:扫码是页面加载之后才发生的,所以不能用页面快照来判断。""" + manager, *_rest = _fake_stack() + signed_in = {"value": False} - # 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 + def resolver(cookie): + assert "a1=an-a1-value" in cookie # 判据必须真的用 cookie 去问 + if not signed_in["value"]: + raise CreatorApiError("登录态无效或已过期", status=401) + return {"user_id": "u1", "nickname": "小红"} + + _patch(monkeypatch, manager, resolver=resolver) + await qrlogin.start(qrlogin.PLATFORM_XHS) + assert qrlogin._current.status == qrlogin.STATUS_WAITING + + # 操作者扫了码 + signed_in["value"] = True + qrlogin._state_cache = None # 5 秒缓存否则会遮住这次变化 snapshot = await qrlogin.status() assert snapshot["status"] == qrlogin.STATUS_SUCCESS assert snapshot["nickname"] == "小红" - assert "登录成功" in snapshot["message"] + + +@pytest.mark.asyncio +async def test_the_successful_session_hands_over_a_cookie(monkeypatch): + """扫码不该只写浏览器 profile —— 还要能把 cookie 交出来存库, + 否则关掉 CDP 就断了。""" + manager, *_rest = _fake_stack() + _patch(monkeypatch, manager, resolver=lambda _c: {"user_id": "u1", "nickname": "小明"}) + + await qrlogin.start(qrlogin.PLATFORM_XHS) + cookie = await qrlogin.take_cookie() + + assert cookie is not None + assert "a1=an-a1-value" in cookie + # 只能取一次,否则每次轮询都会重复写库 + assert await qrlogin.take_cookie() is None @pytest.mark.asyncio async def test_session_expires(monkeypatch): - manager, _pw, _browser, _context, _page, _qr = _fake_stack() - _patch(monkeypatch, manager) + manager, *_rest = _fake_stack() + _patch(monkeypatch, manager, resolver=_signed_out) await qrlogin.start(qrlogin.PLATFORM_XHS) qrlogin._current.started_at -= qrlogin.QR_TTL_SECONDS + 1 @@ -167,10 +205,10 @@ async def test_session_expires(monkeypatch): @pytest.mark.asyncio -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")) +async def test_check_login_state_reports_when_the_browser_cannot_answer(monkeypatch): + """连不上浏览器时要说出来,不能悄悄报成「未登录」。""" + manager, _pw, _browser, context, _page, _qr = _fake_stack() + context.cookies = AsyncMock(side_effect=RuntimeError("Target closed")) _patch(monkeypatch, manager) state = await qrlogin.check_login_state() @@ -182,47 +220,34 @@ async def test_check_login_state_reports_when_the_page_cannot_answer(monkeypatch @pytest.mark.asyncio async def test_check_login_state_is_cached(monkeypatch): - manager, _pw, _browser, _context, page, _qr = _fake_stack(logged_in=True) - _patch(monkeypatch, manager) + manager, _pw, _browser, context, _page, _qr = _fake_stack() + _patch(monkeypatch, manager, resolver=lambda _c: {"user_id": "u1", "nickname": "x"}) await qrlogin.check_login_state() await qrlogin.check_login_state() - page.evaluate.assert_awaited_once() + context.cookies.assert_awaited_once() @pytest.mark.asyncio async def test_force_bypasses_the_cache(monkeypatch): - manager, _pw, _browser, _context, page, _qr = _fake_stack(logged_in=True) - _patch(monkeypatch, manager) + manager, _pw, _browser, context, _page, _qr = _fake_stack() + _patch(monkeypatch, manager, resolver=lambda _c: {"user_id": "u1", "nickname": "x"}) await qrlogin.check_login_state() await qrlogin.check_login_state(force=True) - assert page.evaluate.await_count == 2 - page.goto.assert_awaited() # force reloads before reading + assert context.cookies.await_count == 2 @pytest.mark.asyncio -async def test_cancel_resets_without_closing_the_operators_tab(monkeypatch): +async def test_cancel_keeps_the_operators_tab(monkeypatch): + """与运营模块不同:那里的上下文是临时的、用完即弃;这里的标签页属于操作者的浏览器。""" manager, _pw, _browser, _context, page, _qr = _fake_stack() - _patch(monkeypatch, manager) + _patch(monkeypatch, manager, resolver=_signed_out) await qrlogin.start(qrlogin.PLATFORM_XHS) snapshot = await qrlogin.cancel() assert snapshot["status"] == qrlogin.STATUS_IDLE - # The tab belongs to the operator's browser and is reused, not closed. page.close.assert_not_called() - - -@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/QrLoginPanel.tsx b/webui/src/components/monitor/QrLoginPanel.tsx index fda0e81..c3c0b2e 100644 --- a/webui/src/components/monitor/QrLoginPanel.tsx +++ b/webui/src/components/monitor/QrLoginPanel.tsx @@ -217,19 +217,20 @@ export function QrLoginPanel() {
{loggedIn - ? '这台浏览器已是登录状态,定时监控会直接复用它的 profile,无需再扫码。' + ? '这台浏览器已是登录状态。CDP 模式下定时任务直接复用它;点下面的按钮可以把这份登录态也存成 Cookie —— 那样即使关掉 CDP、任务改用 Cookie 注入也照样能跑。' : '经 CDP 接管服务器上已开启远程调试的 Chrome,把二维码取回来显示在这里。需要先在「系统设置」里打开 接管已有 Chrome(CDP),并确保那台 Chrome 正以 9222 端口运行。'}
- {!loggedIn && ( - - )} + {/* 已登录时**也要给按钮**。原先这里把按钮藏了,于是面板变成一块只能看、 + 不能操作的区域 —— 用户的原话是「没用」。两种状态下点击是同一个动作, + 只是含义不同:没登录就是取二维码,已登录就是把当前登录态同步成 Cookie。 */} +