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 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()
|
||||
|
||||
Reference in New Issue
Block a user