Files
2026-01-12 19:42:43 +08:00

506 lines
14 KiB
Python
Raw Permalink 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.
"""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")