Files

172 lines
5.7 KiB
Python
Raw Permalink Normal View History

2026-01-12 18:58:23 +08:00
"""auth_manager.py
登录态管理(单账号)
- 使用 Playwright storage_state 保存到 data/auth_state.json
- 额外保存元数据到 data/auth_meta.json(登录时间/最后验证时间等)
- 提供 verify_login / get_login_status 等方法
设计目标:
1) 不依赖“文件存在”判断登录,而是用 Playwright 打开页面做轻量验证
2) 单账号长期复用:只要不掉线,就一直用
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional, Tuple
from playwright.async_api import Browser, BrowserContext, TimeoutError as PlaywrightTimeoutError
from app import config
AUTH_META_FILE = os.path.join(config.DATA_DIR, "auth_meta.json")
@dataclass
class LoginStatus:
logged_in: bool
login_time: Optional[str] = None
last_verified_time: Optional[str] = None
message: Optional[str] = None
def _utc_iso() -> str:
# 使用本地时间 ISO 字符串,方便 UI 展示
return datetime.now().isoformat(timespec="seconds")
def load_auth_meta() -> dict:
if not os.path.exists(AUTH_META_FILE):
return {}
try:
with open(AUTH_META_FILE, "r", encoding="utf-8") as f:
return json.load(f) or {}
except Exception:
return {}
def save_auth_meta(meta: dict) -> None:
tmp = AUTH_META_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
os.replace(tmp, AUTH_META_FILE)
async def _new_context(browser: Browser) -> BrowserContext:
"""基于已保存的 storage_state 创建 context。"""
if os.path.exists(config.AUTH_STATE_FILE):
return await browser.new_context(storage_state=config.AUTH_STATE_FILE)
return await browser.new_context()
async def open_login_page(browser: Browser) -> None:
"""打开登录页面(通常需要用户在 VNC/有界面环境里扫码)。"""
context = await _new_context(browser)
page = await context.new_page()
await page.goto(config.LOGIN_URL, wait_until="domcontentloaded")
# 不关闭页面:让用户扫码
# 注意:context/page 生命周期由浏览器进程维护;任务结束时统一关闭 browser 即可
async def verify_login(browser: Browser, timeout_sec: int = 20) -> Tuple[bool, str]:
"""验证当前 storage_state 是否仍然有效。
策略(尽量稳,避免依赖某个易变 selector):
1) 打开首页;
2) 若跳转到 /login 或出现明显登录按钮/文案,则判定未登录;
3) 若未跳登录页,并且存在一定数量 cookie,则判定登录有效(启发式)。
"""
if not os.path.exists(config.AUTH_STATE_FILE):
return False, "未找到登录态文件,请先登录"
context = await _new_context(browser)
page = await context.new_page()
try:
await page.goto(config.XIAOHONGSHU_BASE_URL + "/", wait_until="domcontentloaded", timeout=timeout_sec * 1000)
await page.wait_for_timeout(800)
url = page.url or ""
if "/login" in url:
return False, "已跳转到登录页(登录态可能已失效)"
# 启发式:页面上是否出现明显“登录”入口
login_text_hits = await page.locator("text=登录").count()
if login_text_hits > 0:
# 有些已登录页面也会出现“登录”字样(比如弹窗),再做一次 cookie 辅助判断
cookies = await context.cookies()
if len(cookies) < 3:
return False, "检测到登录入口且 cookie 数量过少,疑似未登录"
cookies = await context.cookies()
if len(cookies) < 3:
return False, "cookie 数量过少,疑似未登录"
# 通过:写入 meta
meta = load_auth_meta()
if not meta.get("login_time"):
meta["login_time"] = _utc_iso()
meta["last_verified_time"] = _utc_iso()
save_auth_meta(meta)
# 刷新保存一次 state(让 cookie 续期信息尽可能落盘)
try:
await context.storage_state(path=config.AUTH_STATE_FILE)
except Exception:
# 不要因为保存失败影响验证结果
pass
return True, "ok"
except PlaywrightTimeoutError:
return False, "验证登录超时(网络或风控),请稍后重试"
except Exception as e:
return False, f"验证登录异常: {e}"
finally:
try:
await page.close()
except Exception:
pass
try:
await context.close()
except Exception:
pass
async def get_login_status(browser: Optional[Browser]) -> LoginStatus:
"""获取登录状态(如果 browser 可用则做轻量验证,否则仅返回本地 meta 状态)。"""
meta = load_auth_meta()
if not os.path.exists(config.AUTH_STATE_FILE):
return LoginStatus(logged_in=False, message="未登录")
if browser is None:
return LoginStatus(
logged_in=True if meta.get("login_time") else True,
login_time=meta.get("login_time"),
last_verified_time=meta.get("last_verified_time"),
message="仅本地状态(未进行在线验证)"
)
ok, msg = await verify_login(browser)
meta = load_auth_meta()
return LoginStatus(
logged_in=ok,
login_time=meta.get("login_time"),
last_verified_time=meta.get("last_verified_time"),
message=None if ok else msg
)
def clear_login_state() -> None:
"""清理登录态。"""
for p in [config.AUTH_STATE_FILE, AUTH_META_FILE]:
try:
if os.path.exists(p):
os.remove(p)
except Exception:
pass