feat(monitor): 扫码同时存一份 Cookie,并把登录判定换成权威判据
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

用户问「现在是不是扫码就能自动获取 cookie」—— 不是,而且这正是那个面板显得没用的根源:
它只干了半件事,扫码**只写浏览器 profile,完全不提取 cookie**(qrlogin.py 里连一行
取 cookie 的代码都没有)。于是:
- CDP 开着时任务能用(复用 profile),但 Cookie 面板始终显示「未配置」
- CDP 一关,任务立刻断,因为库里那份 cookie 从来没被填过

现在扫码把两件事一起做了:写 profile(CDP 用)+ 存一份到库(Cookie 注入用)。
两种机制同时填上,开关怎么切都不断。cookie 只在内存里从 qrlogin 传到路由,不进响应体。

同时修掉一个同类 bug:监控侧的登录判定还在用页面里的 window.__INITIAL_STATE__,
而那是**页面加载那一刻的快照** —— 浏览器本来就登录着时它是对的,但扫码是加载之后
才登录的,快照不会翻转,表现为「扫了码却一直停在二维码上」。运营模块踩过同一个坑,
当时只修了那一处。现在两边统一为:拿 cookie 问后台接口「我是谁」。顺带不再需要页面导航,
检测变轻了。

前端:已登录时按钮原先被我藏起来了,面板于是变成一块只能看、不能操作的区域 ——
用户的原话是「没用」。现在两种状态都给按钮,含义不同:未登录=取二维码,
已登录=把当前登录态同步成 Cookie。

测试:tests/test_qrlogin.py 重写(stub 从页面探针换成后台接口),新增「成功会话必须
交出 cookie」「只能取一次」两例。
This commit is contained in:
2026-10-09 13:46:48 +08:00
parent f6ddc46d62
commit de9ff58371
5 changed files with 185 additions and 114 deletions
+62 -34
View File
@@ -38,7 +38,6 @@
""" """
import asyncio import asyncio
import json
import os import os
import time import time
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@@ -47,8 +46,14 @@ import config
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
from tools import utils from tools import utils
from ..creator.client import CreatorApiError, CreatorClient
from .platforms import PLATFORM_XHS 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 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" 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` 整个结构会抛 # 运营模块踩过同一个坑。现在的判据是拿 cookie 问后台接口,见 check_login_state。
# "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() _lock = asyncio.Lock()
_current: Optional["QrLoginSession"] = None _current: Optional["QrLoginSession"] = None
@@ -204,9 +188,8 @@ async def check_login_state(force: bool = False) -> Dict[str, Any]:
return cached return cached
try: try:
page = await _ensure_page(reload=force) context = await _ensure_context()
raw = await page.evaluate(LOGIN_STATE_PROBE) cookies = await context.cookies()
parsed = json.loads(raw) if isinstance(raw, str) else {"known": False}
except Exception as exc: except Exception as exc:
result = { result = {
"known": False, "known": False,
@@ -217,15 +200,38 @@ async def check_login_state(force: bool = False) -> Dict[str, Any]:
_state_cache = (now, result) _state_cache = (now, result)
return result return result
result = { cookie = _cookie_string(cookies)
"known": bool(parsed.get("known")),
"logged_in": bool(parsed.get("loggedIn")), # 判据不再是页面里的 window.__INITIAL_STATE__ —— 那是**页面加载那一刻的快照**:
"nickname": parsed.get("nickname"), # 浏览器已登录时它是对的,但扫码是加载**之后**才登录的,快照不会翻转,检测就永远
} # 等不到(运营模块踩过同一个坑)。改成拿 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) _state_cache = (now, result)
return 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: async def _reset_playwright() -> None:
global _playwright, _page global _playwright, _page
_page = None _page = None
@@ -248,6 +254,9 @@ class QrLoginSession:
self.started_at = time.time() self.started_at = time.time()
self.logged_in = False self.logged_in = False
self.nickname: Optional[str] = None self.nickname: Optional[str] = None
# 登录成功后从默认 profile 取出来的 cookie,供调用方存库。
self.cookie: str = ""
self.cookie_taken = False
self._page = page self._page = page
@property @property
@@ -265,6 +274,7 @@ class QrLoginSession:
state = await check_login_state() state = await check_login_state()
if state.get("logged_in"): if state.get("logged_in"):
self.cookie = await _current_cookie()
self.logged_in = True self.logged_in = True
self.nickname = state.get("nickname") self.nickname = state.get("nickname")
self.status = STATUS_SUCCESS self.status = STATUS_SUCCESS
@@ -336,6 +346,8 @@ async def start(platform: str = PLATFORM_XHS) -> Dict[str, Any]:
state = await check_login_state() state = await check_login_state()
if state.get("logged_in"): if state.get("logged_in"):
# 已经是登录状态时站点不显示二维码——这本身就是成功,不是失败。 # 已经是登录状态时站点不显示二维码——这本身就是成功,不是失败。
# 顺带把 cookie 取出来,让调用方可以存进库。
session.cookie = await _current_cookie()
session.status = STATUS_SUCCESS session.status = STATUS_SUCCESS
session.logged_in = True session.logged_in = True
session.nickname = state.get("nickname") session.nickname = state.get("nickname")
@@ -367,6 +379,22 @@ async def status() -> Dict[str, Any]:
return _current.snapshot() 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 def cancel() -> Dict[str, Any]:
async with _lock: async with _lock:
await _discard_current_locked() await _discard_current_locked()
+17 -2
View File
@@ -300,8 +300,23 @@ async def start_qr_login(platform: str = Query(default=PLATFORM_XHS)):
@router.get("/login/qr") @router.get("/login/qr")
async def get_qr_login(): async def get_qr_login():
"""Poll the live session: waiting -> success / expired / error.""" """Poll the live session: waiting -> success / expired / error.
return await qrlogin.status()
扫码成功时**把 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") @router.delete("/login/qr")
+92 -67
View File
@@ -1,27 +1,25 @@
# -*- coding: utf-8 -*- # -*- 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 * 二维码必须从浏览器**默认 context** 里读 —— 新建 context 是无痕式的 profile,
is an incognito-like profile whose cookies the crawler never sees; 扫了也白扫,爬虫读不到那份 cookie;
* "am I logged in?" must be answered by the browser's profile, **not** by the * 「登录了吗」不能用页面里的 `window.__INITIAL_STATE__`。那是**页面加载那一刻的快照**:
in-memory scan session -- that session dies on any restart, so a successful scan 浏览器本来就登录着时它是对的,但扫码是加载**之后**才登录的,快照不会翻转,
would otherwise look like nothing happened. 于是扫完码界面会一直停在二维码上。判据改成拿 cookie 问后台接口。
""" """
import json
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from api.creator.client import CreatorApiError
from api.monitor import qrlogin from api.monitor import qrlogin
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _reset_module_state(): def _reset_module_state():
"""Each test starts from "nothing on screen" and leaves it that way."""
for attribute in ("_current", "_page", "_playwright", "_state_cache"): for attribute in ("_current", "_page", "_playwright", "_state_cache"):
setattr(qrlogin, attribute, None) setattr(qrlogin, attribute, None)
yield yield
@@ -29,27 +27,28 @@ def _reset_module_state():
setattr(qrlogin, attribute, None) setattr(qrlogin, attribute, None)
def _probe_result(logged_in: bool, nickname=None, known: bool = True) -> str: XHS_COOKIES = [
return json.dumps({"known": known, "loggedIn": logged_in, "nickname": nickname}) {"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"): def _fake_stack(cookies=None, qr="data:image/png;base64,AAAA"):
"""A Playwright/Chrome stand-in wired the way the real one behaves.""" """Chrome/Playwright 替身,行为与真实的一致。"""
page = MagicMock() page = MagicMock()
page.url = "https://www.xiaohongshu.com/explore" page.url = "https://www.xiaohongshu.com/explore"
page.is_closed = MagicMock(return_value=False) page.is_closed = MagicMock(return_value=False)
page.goto = AsyncMock() page.goto = AsyncMock()
page.close = AsyncMock() page.close = AsyncMock()
page.evaluate = AsyncMock(return_value=_probe_result(logged_in, nickname))
context = MagicMock() context = MagicMock()
context.pages = [] context.pages = []
context.cookies = AsyncMock(return_value=list(cookies if cookies is not None else XHS_COOKIES))
context.new_page = AsyncMock(return_value=page) context.new_page = AsyncMock(return_value=page)
browser = MagicMock() browser = MagicMock()
browser.contexts = [context] browser.contexts = [context]
# Reaching for a fresh context is the bug this guards against, so make it blow # 去新建 context 正是这里要防的 bug,所以让它直接炸,而不是悄悄返回一个无痕 profile。
# up loudly rather than quietly handing back a throwaway profile.
browser.new_context = AsyncMock( browser.new_context = AsyncMock(
side_effect=AssertionError("must reuse browser.contexts[0], not a new context") 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 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, "async_playwright", lambda: manager)
monkeypatch.setattr(qrlogin, "CreatorClient", _Client)
monkeypatch.setattr(qrlogin.utils, "find_login_qrcode", AsyncMock(return_value=qr)) monkeypatch.setattr(qrlogin.utils, "find_login_qrcode", AsyncMock(return_value=qr))
def _signed_out(_cookie):
raise CreatorApiError("登录态无效或已过期", status=401)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_idle_reports_the_browsers_login_state(monkeypatch): async def test_idle_reports_the_browsers_login_state(monkeypatch):
manager, _pw, _browser, _context, _page, _qr = _fake_stack(logged_in=True, nickname="小明") manager, *_ = _fake_stack()
_patch(monkeypatch, manager) _patch(monkeypatch, manager, resolver=lambda _c: {"user_id": "u1", "nickname": "小明"})
snapshot = await qrlogin.status() snapshot = await qrlogin.status()
@@ -83,15 +98,15 @@ async def test_idle_reports_the_browsers_login_state(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unwired_platform_is_rejected(): 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): with pytest.raises(ValueError):
await qrlogin.start("dy") await qrlogin.start("dy")
@pytest.mark.asyncio @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() manager, _pw, browser, context, _page, _qr = _fake_stack()
_patch(monkeypatch, manager) _patch(monkeypatch, manager, resolver=_signed_out)
snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS) 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 @pytest.mark.asyncio
async def test_start_does_not_open_a_second_tab(monkeypatch): 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() manager, _pw, _browser, context, page, _qr = _fake_stack()
context.pages = [page] context.pages = [page]
_patch(monkeypatch, manager) _patch(monkeypatch, manager, resolver=_signed_out)
await qrlogin.start(qrlogin.PLATFORM_XHS) await qrlogin.start(qrlogin.PLATFORM_XHS)
@@ -115,11 +130,9 @@ async def test_start_does_not_open_a_second_tab(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_an_already_signed_in_profile_needs_no_scan(monkeypatch): 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.""" """没二维码但 profile 已登录 —— 这是成功,不是失败。"""
manager, _pw, _browser, _context, _page, _qr = _fake_stack( manager, *_rest = _fake_stack()
logged_in=True, nickname="老王" _patch(monkeypatch, manager, qr="", resolver=lambda _c: {"user_id": "u9", "nickname": "老王"})
)
_patch(monkeypatch, manager, qr="")
snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS) 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 @pytest.mark.asyncio
async def test_no_qr_and_not_signed_in_is_an_error(monkeypatch): async def test_no_qr_and_not_signed_in_is_an_error(monkeypatch):
manager, _pw, _browser, _context, _page, _qr = _fake_stack(logged_in=False) manager, *_rest = _fake_stack()
_patch(monkeypatch, manager, qr="") _patch(monkeypatch, manager, qr="", resolver=_signed_out)
snapshot = await qrlogin.start(qrlogin.PLATFORM_XHS) 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 @pytest.mark.asyncio
async def test_a_completed_scan_flips_the_session_to_success(monkeypatch): 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) manager, *_rest = _fake_stack()
await qrlogin.start(qrlogin.PLATFORM_XHS) signed_in = {"value": False}
# The operator scans: the page now reports a signed-in profile. def resolver(cookie):
page.evaluate = AsyncMock(return_value=_probe_result(True, "小红")) assert "a1=an-a1-value" in cookie # 判据必须真的用 cookie 去问
qrlogin._state_cache = None # the 5s cache would otherwise hide the change 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() snapshot = await qrlogin.status()
assert snapshot["status"] == qrlogin.STATUS_SUCCESS assert snapshot["status"] == qrlogin.STATUS_SUCCESS
assert snapshot["nickname"] == "小红" 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 @pytest.mark.asyncio
async def test_session_expires(monkeypatch): async def test_session_expires(monkeypatch):
manager, _pw, _browser, _context, _page, _qr = _fake_stack() manager, *_rest = _fake_stack()
_patch(monkeypatch, manager) _patch(monkeypatch, manager, resolver=_signed_out)
await qrlogin.start(qrlogin.PLATFORM_XHS) await qrlogin.start(qrlogin.PLATFORM_XHS)
qrlogin._current.started_at -= qrlogin.QR_TTL_SECONDS + 1 qrlogin._current.started_at -= qrlogin.QR_TTL_SECONDS + 1
@@ -167,10 +205,10 @@ async def test_session_expires(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_check_login_state_reports_when_the_page_cannot_answer(monkeypatch): async def test_check_login_state_reports_when_the_browser_cannot_answer(monkeypatch):
"""An unreachable browser must say so, not quietly report "not logged in".""" """连不上浏览器时要说出来,不能悄悄报成「未登录」。"""
manager, _pw, _browser, _context, page, _qr = _fake_stack() manager, _pw, _browser, context, _page, _qr = _fake_stack()
page.evaluate = AsyncMock(side_effect=RuntimeError("Target closed")) context.cookies = AsyncMock(side_effect=RuntimeError("Target closed"))
_patch(monkeypatch, manager) _patch(monkeypatch, manager)
state = await qrlogin.check_login_state() 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 @pytest.mark.asyncio
async def test_check_login_state_is_cached(monkeypatch): async def test_check_login_state_is_cached(monkeypatch):
manager, _pw, _browser, _context, page, _qr = _fake_stack(logged_in=True) manager, _pw, _browser, context, _page, _qr = _fake_stack()
_patch(monkeypatch, manager) _patch(monkeypatch, manager, resolver=lambda _c: {"user_id": "u1", "nickname": "x"})
await qrlogin.check_login_state() await qrlogin.check_login_state()
await qrlogin.check_login_state() await qrlogin.check_login_state()
page.evaluate.assert_awaited_once() context.cookies.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_force_bypasses_the_cache(monkeypatch): async def test_force_bypasses_the_cache(monkeypatch):
manager, _pw, _browser, _context, page, _qr = _fake_stack(logged_in=True) manager, _pw, _browser, context, _page, _qr = _fake_stack()
_patch(monkeypatch, manager) _patch(monkeypatch, manager, resolver=lambda _c: {"user_id": "u1", "nickname": "x"})
await qrlogin.check_login_state() await qrlogin.check_login_state()
await qrlogin.check_login_state(force=True) await qrlogin.check_login_state(force=True)
assert page.evaluate.await_count == 2 assert context.cookies.await_count == 2
page.goto.assert_awaited() # force reloads before reading
@pytest.mark.asyncio @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() manager, _pw, _browser, _context, page, _qr = _fake_stack()
_patch(monkeypatch, manager) _patch(monkeypatch, manager, resolver=_signed_out)
await qrlogin.start(qrlogin.PLATFORM_XHS) await qrlogin.start(qrlogin.PLATFORM_XHS)
snapshot = await qrlogin.cancel() snapshot = await qrlogin.cancel()
assert snapshot["status"] == qrlogin.STATUS_IDLE assert snapshot["status"] == qrlogin.STATUS_IDLE
# The tab belongs to the operator's browser and is reused, not closed.
page.close.assert_not_called() 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()
+12 -11
View File
@@ -217,19 +217,20 @@ export function QrLoginPanel() {
<div className="space-y-2"> <div className="space-y-2">
<p className="text-[11px] font-mono text-cyber-text-muted leading-relaxed"> <p className="text-[11px] font-mono text-cyber-text-muted leading-relaxed">
{loggedIn {loggedIn
? '这台浏览器已是登录状态,定时监控会直接复用它的 profile,无需再扫码。' ? '这台浏览器已是登录状态。CDP 模式下定时任务直接复用它;点下面的按钮可以把这份登录态也存成 Cookie —— 那样即使关掉 CDP、任务改用 Cookie 注入也照样能跑。'
: '经 CDP 接管服务器上已开启远程调试的 Chrome,把二维码取回来显示在这里。需要先在「系统设置」里打开 接管已有 Chrome(CDP),并确保那台 Chrome 正以 9222 端口运行。'} : '经 CDP 接管服务器上已开启远程调试的 Chrome,把二维码取回来显示在这里。需要先在「系统设置」里打开 接管已有 Chrome(CDP),并确保那台 Chrome 正以 9222 端口运行。'}
</p> </p>
{!loggedIn && ( {/* 已登录时**也要给按钮**。原先这里把按钮藏了,于是面板变成一块只能看、
<Button size="sm" disabled={busy} onClick={begin}> 不能操作的区域 —— 用户的原话是「没用」。两种状态下点击是同一个动作,
{start.isPending ? ( 只是含义不同:没登录就是取二维码,已登录就是把当前登录态同步成 Cookie。 */}
<Loader2 className="w-3 h-3 mr-1 animate-spin" /> <Button size="sm" disabled={busy} onClick={begin}>
) : ( {start.isPending ? (
<QrCode className="w-3 h-3 mr-1" /> <Loader2 className="w-3 h-3 mr-1 animate-spin" />
)} ) : (
获取二维码 <QrCode className="w-3 h-3 mr-1" />
</Button> )}
)} {loggedIn ? '同步登录态为 Cookie' : '获取二维码'}
</Button>
</div> </div>
)} )}
</div> </div>
+2
View File
@@ -217,6 +217,8 @@ export interface QrLoginState {
/** Whether the browser reports being signed in right now. */ /** Whether the browser reports being signed in right now. */
logged_in: boolean logged_in: boolean
nickname: string | null nickname: string | null
/** 登录态是否已同时存进库(供非 CDP 模式的定时任务使用)。 */
cookie_saved?: boolean
} }
/** /**