feat(monitor): 扫码同时存一份 Cookie,并把登录判定换成权威判据
用户问「现在是不是扫码就能自动获取 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:
+62
-34
@@ -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
@@ -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
@@ -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()
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user