Files
MediaCrawler/api/monitor/qrlogin.py
T
butubb 44cbe8e2aa
Deploy VitePress site to Pages / build (push) Waiting to run
Deploy VitePress site to Pages / Deploy (push) Blocked by required conditions
fix(monitor): 已登录时点「同步登录态为 Cookie」要干等 30 秒
顺序反了:原先先开页面、读二维码,读完才发现「已经登录、没有二维码」。而读二维码内部
会 wait_for_selector 等满 30 秒才放弃 —— 用户点一下按钮要干等半分钟,还白开一个标签页。
实测日志里就是 `Page.wait_for_selector: Timeout 30000ms exceeded`。

改成先问登录状态(那是一次接口调用,很快),已登录就直接返回,根本不碰页面。
新增测试守住这个顺序:已登录时 context.new_page 不得被调用。
2026-10-09 13:49:11 +08:00

426 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/qrlogin.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守对应平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""扫码登录,以及"现在到底登没登录"的查询。
**为什么需要这个模块**:服务器上 Chrome 跑在 Xvfb 里没有显示器,爬虫原本用
`show_qrcode`(PIL 的 `Image.show()`)弹窗展示二维码,那需要桌面看图程序,服务器上
没有。所以改成经 CDP 把二维码从页面里读出来交给 WebUI。
**为什么登录状态要能独立查询**:扫码会话是内存里的临时状态,进程一重启就没了
(部署、崩溃都算)。把"是否已登录"绑在它上面,就会出现"扫完了但界面没反应、
也不知道到底成没成"。所以状态查询是独立的、随时可调用的,二维码只是达成它的手段之一。
三个容易搞错的地方:
* **必须复用浏览器默认 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 os
import time
from typing import Any, Dict, Optional
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
# 登录状态查询的缓存时长。轮询时不必每次都去问浏览器。
STATE_CACHE_SECONDS = 5
STATUS_IDLE = "idle"
STATUS_WAITING = "waiting"
STATUS_SUCCESS = "success"
STATUS_EXPIRED = "expired"
STATUS_ERROR = "error"
# 只有小红书接了监控流程,所以扫码也只对它开放。给别的平台显示一个按不动的按钮
# 是在假装功能存在。
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"
}
# 这里本来有一个读 window.__INITIAL_STATE__ 的 JS 探针,**已删除,不要加回来**。
#
# 它是页面加载那一刻的快照:浏览器本来就登录着时它是对的,但扫码是加载**之后**才
# 登录的,快照不会翻转,检测于是永远等不到 —— 表现为"扫了码却一直停在二维码上"。
# 运营模块踩过同一个坑。现在的判据是拿 cookie 问后台接口,见 check_login_state。
_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:
"""浏览器 DevTools 端点。``MC_CDP_URL`` 优先,便于换主机而不用改代码。"""
return os.getenv("MC_CDP_URL") or f"http://127.0.0.1:{config.CDP_DEBUG_PORT}"
def _login_url(platform: str) -> str:
if platform == PLATFORM_XHS and getattr(config, "XHS_INTERNATIONAL", False):
return "https://www.rednote.com"
return LOGIN_URL[platform]
async def _ensure_context() -> Any:
"""连上浏览器并返回它的默认 context。"""
global _playwright
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:
context = await _ensure_context()
cookies = await context.cookies()
except Exception as exc:
result = {
"known": False,
"logged_in": False,
"nickname": None,
"error": f"{exc.__class__.__name__}: {exc}",
}
_state_cache = (now, result)
return result
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
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 = "请用手机扫描二维码"
self.image = ""
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
def elapsed(self) -> float:
return time.time() - self.started_at
async def refresh(self) -> None:
"""轮询一次,看扫码是否完成。"""
if self.status != STATUS_WAITING:
return
if self.elapsed > QR_TTL_SECONDS:
self.status = STATUS_EXPIRED
self.message = "二维码已超时,请重新获取"
return
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
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 {
"status": self.status,
"platform": self.platform,
"image": self.image,
"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 _read_qr(page: Any, platform: str) -> str:
"""把二维码从页面里取出来,必要时先点开登录框。"""
image = await utils.find_login_qrcode(page, selector=QR_SELECTOR[platform])
if image:
return image
# 登录框不一定自己弹出来。这是爬虫自身扫码流程里同款兜底。
await asyncio.sleep(0.5)
try:
await page.locator(LOGIN_BUTTON_SELECTOR[platform]).click(timeout=5000)
except Exception:
return ""
return await utils.find_login_qrcode(page, selector=QR_SELECTOR[platform])
async def _discard_current_locked() -> None:
global _current
_current = None
async def start(platform: str = PLATFORM_XHS) -> Dict[str, Any]:
"""在 CDP 浏览器里打开登录页,取回二维码。"""
global _current
if platform not in LOGIN_URL:
raise ValueError(f"平台 {platform} 尚未接入扫码登录(目前仅支持小红书)")
async with _lock:
await _discard_current_locked()
# **先问状态,再决定要不要开页面。** 顺序反过来是有代价的:读二维码内部会
# wait_for_selector 等满 30 秒才放弃,而已经登录时页面上根本没有二维码 ——
# 用户点一下按钮要干等半分钟,还白开一个标签页。
state = await check_login_state(force=True)
if state.get("logged_in"):
# 已经是登录状态时站点不显示二维码 —— 这本身就是成功,不是失败。
# 顺带把 cookie 取出来,让调用方可以存进库。
session = QrLoginSession(platform, None)
session.cookie = await _current_cookie()
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},无需扫码"
_current = session
return session.snapshot()
page = await _ensure_page(platform)
try:
await page.goto(
_login_url(platform), wait_until="domcontentloaded", timeout=45000
)
image = await _read_qr(page, platform)
except Exception as exc:
raise RuntimeError(f"打开登录页失败:{exc}") from exc
session = QrLoginSession(platform, page)
session.image = image
if not image:
session.status = STATUS_ERROR
session.message = "页面上没找到二维码,请确认站点结构没有变化"
_current = session
return session.snapshot()
async def status() -> Dict[str, Any]:
async with _lock:
if _current is None:
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 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()
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:
"""进程退出时断开连接。刻意不关那个标签页——它是操作者浏览器的一部分。"""
global _current
async with _lock:
_current = None
await _reset_playwright()