"""web.py - FastAPI 后端 目标: - 提供基本的 Web UI + API(启动/停止采集、导入用户、登录/验证、日志、SSE) - 单账号登录态长期复用(storage_state 保存) - txt 导入:支持“数字小红书号”自动解析为 profile_id(并缓存,低频,减少风控) - 移除无用/冗余的调试/自更新接口,代码保持清晰可维护 """ from __future__ import annotations import asyncio import json import os from dataclasses import dataclass from typing import List, Optional, Dict, Any from fastapi import FastAPI, HTTPException, BackgroundTasks from fastapi.responses import HTMLResponse, StreamingResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.requests import Request from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from playwright.async_api import async_playwright, Browser from app import config from app.log_manager import LogManager from app.scraper import XiaoHongShuScraper from app import auth_manager from app import user_resolver # ========================= # App State # ========================= @dataclass class AppState: is_running: bool = False scraper: Optional[XiaoHongShuScraper] = None event_queue: Optional[asyncio.Queue] = None playwright: Any = None browser: Optional[Browser] = None app_state = AppState(event_queue=asyncio.Queue()) log_manager = LogManager() # ========================= # FastAPI init # ========================= app = FastAPI(title="小红书数据采集器", version="1.1.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=["*"], ) # 静态资源 + 模板 app.mount("/static", StaticFiles(directory=os.path.join(config.BASE_DIR, "static")), name="static") templates = Jinja2Templates(directory=os.path.join(config.BASE_DIR, "templates")) @app.on_event("startup") async def on_startup(): """ 只做轻量初始化,不在启动时强制拉起浏览器。 原因: - 很多环境首次运行未执行 `playwright install chromium`,如果启动即 launch,会导致 Web 直接起不来(你看到的“打不开”)。 - 浏览器改为按需启动:第一次需要登录/解析/采集时再启动,失败也能在 UI 里看到明确错误。 """ app_state.playwright = None app_state.browser = None log_manager.append("INFO", "App started (browser will be launched lazily on demand)") @app.on_event("shutdown") async def on_shutdown(): # 安全关闭 try: if app_state.browser: await app_state.browser.close() finally: app_state.browser = None try: if app_state.playwright: await app_state.playwright.stop() finally: app_state.playwright = None # ========================= # Models # ========================= class StartRequest(BaseModel): mode: str = "single" # single / multi max_threads: int = 3 class StatusResponse(BaseModel): running: bool mode: Optional[str] = None user_count: int = 0 class UsersResponse(BaseModel): users: List[str] count: int class ImportResponse(BaseModel): users: List[str] count: int total_lines: int imported: int skipped: int resolved: int failed_resolve: int duplicates: int invalid: int message: str # 可选返回一些失败明细(避免过大,只给前 20 条) failed_examples: List[str] = [] class LoginStatusResponse(BaseModel): logged_in: bool login_time: Optional[str] = None last_verified_time: Optional[str] = None message: Optional[str] = None # ========================= # Pages # ========================= @app.get("/", response_class=HTMLResponse) async def index(request: Request): return templates.TemplateResponse("index.html", {"request": request}) # ========================= # Helpers # ========================= def _users_file() -> str: return os.path.join(config.DATA_DIR, "users.txt") def read_users_txt() -> List[str]: path = _users_file() if not os.path.exists(path): return [] users: List[str] = [] with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue users.append(line) # 去重但保持顺序 seen = set() out = [] for u in users: if u in seen: continue seen.add(u) out.append(u) return out def write_users_txt(users: List[str]) -> None: path = _users_file() tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: for u in users: f.write(u + "\n") os.replace(tmp, path) async def ensure_browser() -> Browser: """按需启动浏览器(懒启动),避免服务启动阶段因为未安装 Chromium 而直接崩溃。""" if app_state.browser is not None: return app_state.browser # 初始化 playwright if app_state.playwright is None: app_state.playwright = await async_playwright().start() try: # 这里不强制指定 channel,交给 Playwright 选择已安装的 Chromium app_state.browser = await app_state.playwright.chromium.launch( headless=True, args=[ "--no-sandbox", "--disable-dev-shm-usage", ], ) log_manager.append("INFO", "Browser launched (lazy init)") return app_state.browser except Exception as e: # 给出明确的修复提示 msg = ( f"Browser not ready: {e}. " "请先在运行环境执行:`playwright install chromium`。" ) log_manager.append("ERROR", msg) raise HTTPException(status_code=500, detail=msg) # ========================= # Status / Logs # ========================= @app.get("/api/status", response_model=StatusResponse) async def api_status(): return StatusResponse( running=app_state.is_running, mode=getattr(app_state.scraper, "mode", None) if app_state.scraper else None, user_count=len(read_users_txt()), ) @app.post("/api/logs/clear") async def clear_logs(): log_manager.clear() return {"success": True} @app.get("/api/logs") async def get_logs(limit: int = 200): return {"logs": log_manager.get_latest(limit=limit)} # ========================= # Users # ========================= @app.get("/api/users", response_model=UsersResponse) async def get_users(): users = read_users_txt() return UsersResponse(users=users, count=len(users)) @app.post("/api/users") async def add_user(user_id: str): user_id = (user_id or "").strip() if not user_id: raise HTTPException(status_code=400, detail="user_id 不能为空") users = read_users_txt() if user_id in users: return {"success": True, "message": "已存在"} users.append(user_id) write_users_txt(users) return {"success": True} @app.delete("/api/users/{user_id}") async def delete_user(user_id: str): users = read_users_txt() users = [u for u in users if u != user_id] write_users_txt(users) return {"success": True} @app.delete("/api/users") async def delete_all_users(): write_users_txt([]) return {"success": True} @app.get("/api/users/export/txt") async def export_users_txt(): return PlainTextResponse("\\n".join(read_users_txt())) @app.get("/api/users/import/txt", response_model=ImportResponse) async def import_users_from_txt(): """从 data/users.txt 读取并规范化: - 若是 profile_id / profile URL:直接收录 - 若是纯数字小红书号:通过搜索页解析成 profile_id(Playwright,低频、缓存) - 输出:users.txt 最终仅保存 profile_id(稳定可采集) """ path = _users_file() if not os.path.exists(path): raise HTTPException(status_code=400, detail="data/users.txt 不存在") with open(path, "r", encoding="utf-8") as f: raw_lines = [l.rstrip("\n") for l in f] total_lines = len(raw_lines) browser = await ensure_browser() imported_ids: List[str] = [] duplicates = 0 invalid = 0 skipped = 0 resolved = 0 failed_resolve = 0 failed_examples: List[str] = [] seen = set() # 第一轮:先把能直接解析的收集,数字 keyword 单独列表 keywords: List[tuple[str, str]] = [] # (keyword, raw_line) for line in raw_lines: kw, pid, _label = user_resolver.parse_line_to_keyword_or_profile(line) if pid: pid = pid.lower() if pid in seen: duplicates += 1 continue seen.add(pid) imported_ids.append(pid) continue if kw: keywords.append((kw, line)) else: # 空行/注释等视为 skipped;其它无法识别为 invalid if not line.strip() or line.strip().startswith("#") or line.strip().startswith("//"): skipped += 1 else: invalid += 1 if len(failed_examples) < 20: failed_examples.append(f"invalid: {line}") continue # 第二轮:逐个解析 keyword(单线程+慢一点,减少风控) for kw, raw in keywords: pid, msg = await user_resolver.resolve_keyword_to_profile_id(browser, kw, slow_mode=True) if not pid: failed_resolve += 1 if len(failed_examples) < 20: failed_examples.append(f"resolve_failed({kw}): {raw} ({msg})") continue pid = pid.lower() if pid in seen: duplicates += 1 continue seen.add(pid) imported_ids.append(pid) resolved += 1 # 每解析一个稍微间隔一下(更像真人) await asyncio.sleep(0.8) # 写回:只保存 profile_id write_users_txt(imported_ids) imported = len(imported_ids) message = f"导入完成:{imported} 个 profile_id(解析成功 {resolved})" log_manager.append("INFO", message) return ImportResponse( users=imported_ids, count=imported, total_lines=total_lines, imported=imported, skipped=skipped, resolved=resolved, failed_resolve=failed_resolve, duplicates=duplicates, invalid=invalid, message=message, failed_examples=failed_examples, ) # ========================= # Login # ========================= @app.get("/api/login/status", response_model=LoginStatusResponse) async def get_login_status(): status = await auth_manager.get_login_status(app_state.browser) return LoginStatusResponse( logged_in=status.logged_in, login_time=status.login_time, last_verified_time=status.last_verified_time, message=status.message, ) @app.post("/api/login/qrcode") async def open_login(): """兼容旧前端:返回 login_url,同时在服务端打开 Playwright 登录页。 说明:前端 window.open 打开的页面不会影响服务端 Playwright 的登录态。 正确使用方式:在部署环境(Docker + VNC)里打开 VNC 浏览器扫码登录。 """ browser = await ensure_browser() await auth_manager.open_login_page(browser) return { "login_url": config.LOGIN_URL, "hint": "已在服务端打开登录页。请在 VNC/有界面环境里扫码登录,然后点击“验证登录”。" } @app.post("/api/login/verify") async def verify_login(): browser = await ensure_browser() ok, msg = await auth_manager.verify_login(browser) meta = auth_manager.load_auth_meta() return { "success": ok, "message": None if ok else msg, "login_time": meta.get("login_time"), "expire_time": None, # 单账号长期复用:不强制本地过期时间 "last_verified_time": meta.get("last_verified_time"), } @app.post("/api/logout") async def logout(): auth_manager.clear_login_state() return {"success": True} # ========================= # Scrape control # ========================= @app.post("/api/start") async def start_scraping(request: StartRequest, background_tasks: BackgroundTasks): if app_state.is_running: raise HTTPException(status_code=400, detail="采集任务正在运行中") users = read_users_txt() if not users: raise HTTPException(status_code=400, detail="用户列表为空,请先导入 users.txt") # 启动前校验登录态(单账号) browser = await ensure_browser() ok, msg = await auth_manager.verify_login(browser) if not ok: raise HTTPException(status_code=400, detail=f"未登录或登录失效:{msg}") app_state.is_running = True app_state.scraper = XiaoHongShuScraper(app_state.event_queue, log_manager, browser) async def run_scraper(): try: await app_state.scraper.run(user_ids=users, mode=request.mode, max_threads=request.max_threads) finally: app_state.is_running = False background_tasks.add_task(run_scraper) return {"success": True} @app.post("/api/stop") async def stop_scraping(): if not app_state.is_running or not app_state.scraper: return {"success": True, "message": "未在运行"} await app_state.scraper.stop() app_state.is_running = False return {"success": True} # ========================= # Data # ========================= @app.get("/api/data/{user_id}") async def get_user_data(user_id: str): # 读取该用户数据文件(若存在) file_path = os.path.join(config.DATA_DIR, f"{user_id}.json") if not os.path.exists(file_path): raise HTTPException(status_code=404, detail="数据不存在") try: with open(file_path, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ========================= # SSE events # ========================= @app.get("/api/events") async def sse_events(): async def event_generator(): while True: event = await app_state.event_queue.get() yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" return StreamingResponse(event_generator(), media_type="text/event-stream")