Files
xhs_crawler_claude/app/web.py
T

481 lines
13 KiB
Python
Raw Normal View History

2026-01-12 18:58:23 +08:00
"""web.py - FastAPI 后端
目标:
- 提供基本的 Web UI + API(启动/停止采集、导入用户、登录/验证、日志、SSE)
- 单账号登录态长期复用(storage_state 保存)
- txt 导入:支持“数字小红书号”自动解析为 profile_id(并缓存,低频,减少风控)
- 移除无用/冗余的调试/自更新接口,代码保持清晰可维护
2026-01-12 12:04:05 +08:00
"""
2026-01-12 18:58:23 +08:00
from __future__ import annotations
2026-01-12 12:04:05 +08:00
import asyncio
import json
import os
2026-01-12 18:58:23 +08:00
from dataclasses import dataclass
from typing import List, Optional, Dict, Any
2026-01-12 12:04:05 +08:00
from fastapi import FastAPI, HTTPException, BackgroundTasks
2026-01-12 18:58:23 +08:00
from fastapi.responses import HTMLResponse, StreamingResponse, PlainTextResponse
2026-01-12 12:04:05 +08:00
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.requests import Request
2026-01-12 15:49:09 +08:00
from fastapi.middleware.cors import CORSMiddleware
2026-01-12 18:58:23 +08:00
from pydantic import BaseModel
from playwright.async_api import async_playwright, Browser
2026-01-12 12:04:05 +08:00
from app import config
2026-01-12 18:58:23 +08:00
from app.log_manager import LogManager
2026-01-12 12:04:05 +08:00
from app.scraper import XiaoHongShuScraper
2026-01-12 18:58:23 +08:00
from app import auth_manager
from app import user_resolver
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
# =========================
# App State
# =========================
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
@dataclass
class AppState:
is_running: bool = False
scraper: Optional[XiaoHongShuScraper] = None
event_queue: Optional[asyncio.Queue] = None
playwright: Any = None
browser: Optional[Browser] = None
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
app_state = AppState(event_queue=asyncio.Queue())
log_manager = LogManager()
# =========================
# FastAPI init
# =========================
app = FastAPI(title="小红书数据采集器", version="1.1.0")
2026-01-12 12:04:05 +08:00
2026-01-12 15:49:09 +08:00
app.add_middleware(
CORSMiddleware,
2026-01-12 18:58:23 +08:00
allow_origins=["*"],
2026-01-12 15:49:09 +08:00
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
2026-01-12 18:58:23 +08:00
expose_headers=["*"],
2026-01-12 15:49:09 +08:00
)
2026-01-12 18:58:23 +08:00
# 静态资源 + 模板
app.mount("/static", StaticFiles(directory=os.path.join(config.BASE_DIR, "static")), name="static")
templates = Jinja2Templates(directory=os.path.join(config.BASE_DIR, "templates"))
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
@app.on_event("startup")
async def on_startup():
2026-01-12 19:07:00 +08:00
"""
只做轻量初始化,不在启动时强制拉起浏览器。
原因:
- 很多环境首次运行未执行 `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)")
2026-01-12 15:21:52 +08:00
2026-01-12 18:58:23 +08:00
@app.on_event("shutdown")
async def on_shutdown():
# 安全关闭
try:
if app_state.browser:
await app_state.browser.close()
finally:
app_state.browser = None
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
try:
if app_state.playwright:
await app_state.playwright.stop()
finally:
app_state.playwright = None
2026-01-12 12:04:05 +08:00
2026-01-12 18:19:24 +08:00
2026-01-12 18:58:23 +08:00
# =========================
# Models
# =========================
2026-01-12 18:19:24 +08:00
2026-01-12 18:58:23 +08:00
class StartRequest(BaseModel):
mode: str = "single" # single / multi
max_threads: int = 3
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
class StatusResponse(BaseModel):
running: bool
mode: Optional[str] = None
user_count: int = 0
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
class UsersResponse(BaseModel):
users: List[str]
count: int
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
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] = []
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
class LoginStatusResponse(BaseModel):
logged_in: bool
login_time: Optional[str] = None
last_verified_time: Optional[str] = None
message: Optional[str] = None
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
# =========================
# Pages
# =========================
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
# =========================
# Helpers
# =========================
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
def _users_file() -> str:
return os.path.join(config.DATA_DIR, "users.txt")
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
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
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
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)
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
def require_browser() -> Browser:
if app_state.browser is None:
raise HTTPException(status_code=500, detail="Browser not ready")
return app_state.browser
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
# =========================
# Status / Logs
# =========================
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
@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()),
2026-01-12 12:04:05 +08:00
)
2026-01-12 18:58:23 +08:00
@app.post("/api/logs/clear")
async def clear_logs():
log_manager.clear()
return {"success": True}
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
@app.get("/api/logs")
async def get_logs(limit: int = 200):
return {"logs": log_manager.get_latest(limit=limit)}
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
# =========================
# Users
# =========================
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
@app.get("/api/users", response_model=UsersResponse)
async def get_users():
users = read_users_txt()
return UsersResponse(users=users, count=len(users))
2026-01-12 12:04:05 +08:00
2026-01-12 14:59:27 +08:00
2026-01-12 18:58:23 +08:00
@app.post("/api/users")
async def add_user(user_id: str):
user_id = (user_id or "").strip()
2026-01-12 14:59:27 +08:00
if not user_id:
2026-01-12 18:58:23 +08:00
raise HTTPException(status_code=400, detail="user_id 不能为空")
2026-01-12 14:59:27 +08:00
2026-01-12 18:58:23 +08:00
users = read_users_txt()
if user_id in users:
return {"success": True, "message": "已存在"}
2026-01-12 14:59:27 +08:00
2026-01-12 18:58:23 +08:00
users.append(user_id)
write_users_txt(users)
return {"success": True}
2026-01-12 14:59:27 +08:00
@app.delete("/api/users/{user_id}")
2026-01-12 18:58:23 +08:00
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}
2026-01-12 14:59:27 +08:00
@app.delete("/api/users")
2026-01-12 18:58:23 +08:00
async def delete_all_users():
write_users_txt([])
return {"success": True}
2026-01-12 14:59:27 +08:00
2026-01-12 18:58:23 +08:00
@app.get("/api/users/export/txt")
async def export_users_txt():
return PlainTextResponse("\\n".join(read_users_txt()))
2026-01-12 14:59:27 +08:00
2026-01-12 18:58:23 +08:00
@app.get("/api/users/import/txt", response_model=ImportResponse)
2026-01-12 14:59:27 +08:00
async def import_users_from_txt():
2026-01-12 18:58:23 +08:00
"""从 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 = require_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,
2026-01-12 14:59:27 +08:00
)
2026-01-12 18:58:23 +08:00
# =========================
# Login
# =========================
2026-01-12 14:59:27 +08:00
@app.get("/api/login/status", response_model=LoginStatusResponse)
async def get_login_status():
2026-01-12 18:58:23 +08:00
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,
)
2026-01-12 14:59:27 +08:00
@app.post("/api/login/qrcode")
2026-01-12 18:58:23 +08:00
async def open_login():
"""兼容旧前端:返回 login_url,同时在服务端打开 Playwright 登录页。
2026-01-12 15:21:52 +08:00
2026-01-12 18:58:23 +08:00
说明:前端 window.open 打开的页面不会影响服务端 Playwright 的登录态。
正确使用方式:在部署环境(Docker + VNC)里打开 VNC 浏览器扫码登录。
"""
browser = require_browser()
await auth_manager.open_login_page(browser)
2026-01-12 15:59:45 +08:00
return {
2026-01-12 18:58:23 +08:00
"login_url": config.LOGIN_URL,
"hint": "已在服务端打开登录页。请在 VNC/有界面环境里扫码登录,然后点击“验证登录”。"
2026-01-12 15:21:52 +08:00
}
2026-01-12 15:59:45 +08:00
@app.post("/api/login/verify")
async def verify_login():
2026-01-12 18:58:23 +08:00
browser = require_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"),
}
2026-01-12 14:59:27 +08:00
@app.post("/api/logout")
async def logout():
2026-01-12 18:58:23 +08:00
auth_manager.clear_login_state()
return {"success": True}
2026-01-12 14:59:27 +08:00
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
# =========================
# Scrape control
# =========================
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
@app.post("/api/start")
async def start_scraping(request: StartRequest, background_tasks: BackgroundTasks):
if app_state.is_running:
raise HTTPException(status_code=400, detail="采集任务正在运行中")
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
users = read_users_txt()
if not users:
raise HTTPException(status_code=400, detail="用户列表为空,请先导入 users.txt")
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
# 启动前校验登录态(单账号)
browser = require_browser()
ok, msg = await auth_manager.verify_login(browser)
if not ok:
raise HTTPException(status_code=400, detail=f"未登录或登录失效:{msg}")
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
app_state.is_running = True
app_state.scraper = XiaoHongShuScraper(app_state.event_queue, log_manager, browser)
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
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
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
background_tasks.add_task(run_scraper)
return {"success": True}
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
@app.post("/api/stop")
async def stop_scraping():
if not app_state.is_running or not app_state.scraper:
return {"success": True, "message": "未在运行"}
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
await app_state.scraper.stop()
app_state.is_running = False
return {"success": True}
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
# =========================
# Data
# =========================
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
@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="数据不存在")
2026-01-12 12:04:05 +08:00
try:
2026-01-12 18:58:23 +08:00
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
2026-01-12 12:04:05 +08:00
except Exception as e:
2026-01-12 18:58:23 +08:00
raise HTTPException(status_code=500, detail=str(e))
2026-01-12 12:04:05 +08:00
2026-01-12 16:46:38 +08:00
2026-01-12 18:58:23 +08:00
# =========================
# SSE events
# =========================
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
@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"
2026-01-12 12:04:05 +08:00
2026-01-12 18:58:23 +08:00
return StreamingResponse(event_generator(), media_type="text/event-stream")