# -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # 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()