feat: 提交热榜评论分析工具 MVP 基线

This commit is contained in:
meijiali
2026-07-03 11:12:12 +08:00
parent 0231be6338
commit 9bad7b6e63
60 changed files with 4058 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Application package for the hot comments analysis tool."""
+41
View File
@@ -0,0 +1,41 @@
from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_env: str = "development"
database_url: str = "sqlite:///data/app.db"
tikhub_api_key: str = ""
tikhub_base_url: str = "https://api.tikhub.io"
ai_provider: str = "openai-compatible"
ai_base_url: str = ""
ai_api_key: str = ""
ai_model: str = ""
ai_batch_size: int = 20
ai_concurrency: int = 2
ai_max_retries: int = 3
ai_timeout_seconds: int = 30
http_timeout_seconds: int = 20
http_max_retries: int = 3
crawl_page_interval_seconds: float = 1.5
hot_limit_min: int = 1
hot_limit_max: int = 10
item_limit_per_hot_min: int = 1
item_limit_per_hot_max: int = 10
comment_limit_per_item_min: int = 10
comment_limit_per_item_max: int = 100
@field_validator("ai_concurrency", mode="after")
@classmethod
def cap_ai_concurrency(cls, value: int) -> int:
return min(value, 3)
@lru_cache
def get_settings() -> Settings:
return Settings()
+45
View File
@@ -0,0 +1,45 @@
from collections.abc import Generator
from pathlib import Path
from sqlalchemy import create_engine, event
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
class Base(DeclarativeBase):
pass
def create_sqlite_engine(database_url: str):
if database_url.startswith("sqlite:///") and database_url != "sqlite:///:memory:":
db_path = Path(database_url.removeprefix("sqlite:///"))
if db_path.parent != Path("."):
db_path.parent.mkdir(parents=True, exist_ok=True)
connect_args = {"check_same_thread": False, "timeout": 10}
engine = create_engine(database_url, connect_args=connect_args)
engine.dialect.connect_args = connect_args
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, _connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.close()
return engine
engine = create_sqlite_engine(get_settings().database_url)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def init_db() -> None:
import app.models # noqa: F401
Base.metadata.create_all(engine)
def get_db_session() -> Generator[Session]:
with SessionLocal() as session:
yield session
+161
View File
@@ -0,0 +1,161 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from urllib.parse import quote
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import SessionLocal, get_db_session, init_db
from app.models import Comment, ContentItem, Hotspot, Report
from app.schemas import CreateTaskRequest, CreateTaskResponse, TaskResponse
from app.services.export_service import export_hotspot_comments_csv, export_item_comments_csv, export_report_markdown
from app.services.task_service import (
RUNNING_TASK_MESSAGE,
create_task,
get_task,
has_running_task,
list_tasks,
recover_running_tasks,
)
from app.templating import templates
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
init_db()
with SessionLocal() as session:
recover_running_tasks(session)
yield
app = FastAPI(title="热榜评论分析工具", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/", response_class=HTMLResponse)
def index_page(request: Request, session: Session = Depends(get_db_session)) -> HTMLResponse:
return templates.TemplateResponse(
request,
"index.html",
{"tasks": list_tasks(session)},
)
@app.post("/api/tasks", response_model=CreateTaskResponse, status_code=status.HTTP_201_CREATED)
def create_task_api(
request: CreateTaskRequest,
session: Session = Depends(get_db_session),
) -> CreateTaskResponse:
if has_running_task(session):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=RUNNING_TASK_MESSAGE)
task = create_task(session, request)
return CreateTaskResponse(task_id=task.id, status=task.status)
@app.get("/api/tasks", response_model=list[TaskResponse])
def list_tasks_api(session: Session = Depends(get_db_session)) -> list[TaskResponse]:
return [TaskResponse.model_validate(task) for task in list_tasks(session)]
@app.get("/api/tasks/{task_id}", response_model=TaskResponse)
def get_task_api(task_id: str, session: Session = Depends(get_db_session)) -> TaskResponse:
task = get_task(session, task_id)
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在")
return TaskResponse.model_validate(task)
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
def task_detail_page(task_id: str, request: Request, session: Session = Depends(get_db_session)) -> HTMLResponse:
task = get_task(session, task_id)
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在")
hotspots = list(session.scalars(select(Hotspot).where(Hotspot.task_id == task.id).order_by(Hotspot.rank)))
return templates.TemplateResponse(request, "tasks/detail.html", {"task": task, "hotspots": hotspots})
@app.get("/hotspots/{hotspot_id}/report", response_class=HTMLResponse)
def hotspot_report_page(hotspot_id: str, request: Request, session: Session = Depends(get_db_session)) -> HTMLResponse:
hotspot = session.get(Hotspot, hotspot_id)
if hotspot is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="热点不存在")
task = get_task(session, hotspot.task_id)
report = session.scalar(select(Report).where(Report.hotspot_id == hotspot.id, Report.report_type == "hotspot"))
return templates.TemplateResponse(request, "hotspots/report.html", {"task": task, "hotspot": hotspot, "report": report})
@app.get("/items/{item_id}", response_class=HTMLResponse)
def item_detail_page(item_id: str, request: Request, session: Session = Depends(get_db_session)) -> HTMLResponse:
item = session.get(ContentItem, item_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="内容条目不存在")
hotspot = session.get(Hotspot, item.hotspot_id)
task = get_task(session, item.task_id)
report = session.scalar(select(Report).where(Report.content_item_id == item.id, Report.report_type == "item"))
comments = list(
session.scalars(
select(Comment)
.where(Comment.content_item_id == item.id)
.order_by(Comment.like_count.desc().nullslast(), Comment.comment_time.desc().nullslast())
.limit(100)
)
)
return templates.TemplateResponse(
request,
"items/detail.html",
{"task": task, "hotspot": hotspot, "item": item, "report": report, "comments": comments},
)
@app.get("/api/export/items/{item_id}/comments.csv")
def export_item_comments(item_id: str, session: Session = Depends(get_db_session)) -> Response:
try:
content, filename = export_item_comments_csv(session, item_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出数据不存在")
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@app.get("/api/export/hotspots/{hotspot_id}/comments.csv")
def export_hotspot_comments(hotspot_id: str, session: Session = Depends(get_db_session)) -> Response:
try:
content, filename = export_hotspot_comments_csv(session, hotspot_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出数据不存在")
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@app.get("/api/export/hotspots/{hotspot_id}.md")
def export_hotspot_markdown(hotspot_id: str, session: Session = Depends(get_db_session)) -> Response:
try:
content, filename = export_report_markdown(session, report_type="hotspot", hotspot_id=hotspot_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报告不存在")
return Response(content=content, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
@app.get("/api/export/items/{item_id}.md")
def export_item_markdown(item_id: str, session: Session = Depends(get_db_session)) -> Response:
try:
content, filename = export_report_markdown(session, report_type="item", item_id=item_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报告不存在")
return Response(content=content, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
+176
View File
@@ -0,0 +1,176 @@
import uuid
from datetime import UTC, datetime
from sqlalchemy import CheckConstraint, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def new_uuid() -> str:
return str(uuid.uuid4())
def utc_now() -> datetime:
return datetime.now(UTC)
class Task(Base):
__tablename__ = "tasks"
__table_args__ = (
CheckConstraint("status IN ('running', 'success', 'failed')", name="ck_tasks_status"),
CheckConstraint(
"analysis_status IN ('normal', 'insufficient')",
name="ck_tasks_analysis_status",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
platform: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running")
analysis_status: Mapped[str] = mapped_column(String(16), nullable=False, default="normal")
analysis_success_rate: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
hotspot_limit: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
item_limit_per_hotspot: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
comment_limit_per_item: Mapped[int] = mapped_column(Integer, nullable=False, default=50)
total_items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
processed_items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
successful_items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
failed_items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error_stage: Mapped[str | None] = mapped_column(String(64))
error_type: Mapped[str | None] = mapped_column(String(64))
error_message: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(nullable=False, default=utc_now)
started_at: Mapped[datetime | None] = mapped_column()
finished_at: Mapped[datetime | None] = mapped_column()
hotspots: Mapped[list["Hotspot"]] = relationship(back_populates="task")
content_items: Mapped[list["ContentItem"]] = relationship(back_populates="task")
comments: Mapped[list["Comment"]] = relationship(back_populates="task")
reports: Mapped[list["Report"]] = relationship(back_populates="task")
class Hotspot(Base):
__tablename__ = "hotspots"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
task_id: Mapped[str] = mapped_column(ForeignKey("tasks.id"), nullable=False)
platform: Mapped[str] = mapped_column(String(32), nullable=False)
source_hot_id: Mapped[str | None] = mapped_column(String(128))
rank: Mapped[int | None] = mapped_column(Integer)
title: Mapped[str] = mapped_column(Text, nullable=False)
heat_value: Mapped[str | None] = mapped_column(String(128))
raw_data: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(nullable=False, default=utc_now)
task: Mapped[Task] = relationship(back_populates="hotspots")
content_items: Mapped[list["ContentItem"]] = relationship(back_populates="hotspot")
comments: Mapped[list["Comment"]] = relationship(back_populates="hotspot")
reports: Mapped[list["Report"]] = relationship(back_populates="hotspot")
class ContentItem(Base):
__tablename__ = "content_items"
__table_args__ = (
Index("ix_content_items_task_hotspot", "task_id", "hotspot_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
task_id: Mapped[str] = mapped_column(ForeignKey("tasks.id"), nullable=False)
hotspot_id: Mapped[str] = mapped_column(ForeignKey("hotspots.id"), nullable=False)
platform: Mapped[str] = mapped_column(String(32), nullable=False)
source_item_id: Mapped[str] = mapped_column(String(128), nullable=False)
item_type: Mapped[str] = mapped_column(String(32), nullable=False)
title: Mapped[str | None] = mapped_column(Text)
summary: Mapped[str | None] = mapped_column(Text)
url: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
error_stage: Mapped[str | None] = mapped_column(String(64))
error_type: Mapped[str | None] = mapped_column(String(64))
error_message: Mapped[str | None] = mapped_column(Text)
raw_data: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(nullable=False, default=utc_now)
task: Mapped[Task] = relationship(back_populates="content_items")
hotspot: Mapped[Hotspot] = relationship(back_populates="content_items")
comments: Mapped[list["Comment"]] = relationship(back_populates="content_item")
reports: Mapped[list["Report"]] = relationship(back_populates="content_item")
class Comment(Base):
__tablename__ = "comments"
__table_args__ = (
Index("ix_comments_task_content_item", "task_id", "content_item_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
task_id: Mapped[str] = mapped_column(ForeignKey("tasks.id"), nullable=False)
hotspot_id: Mapped[str] = mapped_column(ForeignKey("hotspots.id"), nullable=False)
content_item_id: Mapped[str] = mapped_column(ForeignKey("content_items.id"), nullable=False)
platform: Mapped[str] = mapped_column(String(32), nullable=False)
source_comment_id: Mapped[str | None] = mapped_column(String(128))
content: Mapped[str] = mapped_column(Text, nullable=False)
author: Mapped[str | None] = mapped_column(Text)
like_count: Mapped[int | None] = mapped_column(Integer)
comment_time: Mapped[datetime | None] = mapped_column()
sentiment: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
labels: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
reason: Mapped[str | None] = mapped_column(Text)
ai_analysis_status: Mapped[str] = mapped_column(String(32), nullable=False, default="skipped")
raw_data: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
ai_raw_response: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(nullable=False, default=utc_now)
task: Mapped[Task] = relationship(back_populates="comments")
hotspot: Mapped[Hotspot] = relationship(back_populates="comments")
content_item: Mapped[ContentItem] = relationship(back_populates="comments")
class Report(Base):
__tablename__ = "reports"
__table_args__ = (
Index("ix_reports_task_report_type", "task_id", "report_type"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
task_id: Mapped[str] = mapped_column(ForeignKey("tasks.id"), nullable=False)
hotspot_id: Mapped[str | None] = mapped_column(ForeignKey("hotspots.id"))
content_item_id: Mapped[str | None] = mapped_column(ForeignKey("content_items.id"))
report_type: Mapped[str] = mapped_column(String(32), nullable=False)
title: Mapped[str] = mapped_column(Text, nullable=False)
data: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
markdown: Mapped[str] = mapped_column(Text, nullable=False, default="")
metrics_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
typical_comments_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
markdown_content: Mapped[str] = mapped_column(Text, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(nullable=False, default=utc_now)
task: Mapped[Task] = relationship(back_populates="reports")
hotspot: Mapped[Hotspot | None] = relationship(back_populates="reports")
content_item: Mapped[ContentItem | None] = relationship(back_populates="reports")
def create_report_record(
*,
task_id: str,
report_type: str,
title: str,
hotspot_id: str | None = None,
content_item_id: str | None = None,
data: str = "{}",
markdown: str = "",
) -> Report:
if report_type == "hotspot" and not hotspot_id:
raise ValueError("hotspot report requires hotspot_id")
if report_type == "content_item" and not content_item_id:
raise ValueError("content item report requires content_item_id")
return Report(
task_id=task_id,
report_type=report_type,
title=title,
hotspot_id=hotspot_id,
content_item_id=content_item_id,
data=data,
markdown=markdown,
)
+1
View File
@@ -0,0 +1 @@
+117
View File
@@ -0,0 +1,117 @@
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
import httpx
@dataclass
class HotspotData:
source_hot_id: str | None
title: str
rank: int | None = None
heat_value: str | None = None
raw_data: dict[str, Any] | None = None
@dataclass
class ContentItemData:
source_item_id: str
item_type: str
title: str | None = None
summary: str | None = None
url: str | None = None
raw_data: dict[str, Any] | None = None
@dataclass
class CommentData:
source_comment_id: str | None
content: str
author: str | None = None
like_count: int | None = None
comment_time: datetime | None = None
raw_data: dict[str, Any] | None = None
class PlatformAPIError(Exception):
def __init__(self, message: str, *, error_type: str, status_code: int | None = None):
super().__init__(message)
self.error_type = error_type
self.status_code = status_code
class TikHubClient:
def __init__(
self,
*,
base_url: str,
api_key: str,
timeout_seconds: int = 20,
max_retries: int = 3,
http_client: httpx.Client | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout_seconds = timeout_seconds
self.max_retries = max_retries
self._http_client = http_client or httpx.Client(timeout=timeout_seconds)
def get(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
return self._request("GET", path, params=params)
def post(self, path: str, *, json: dict[str, Any] | None = None) -> dict[str, Any]:
return self._request("POST", path, json=json)
def _request(self, method: str, path: str, **kwargs) -> dict[str, Any]:
url = f"{self.base_url}{path}"
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
last_status: int | None = None
for attempt in range(self.max_retries + 1):
try:
response = self._http_client.request(method, url, headers=headers, **kwargs)
except httpx.RequestError as exc:
if attempt >= self.max_retries:
raise PlatformAPIError("External API request failed", error_type="network_error") from exc
time.sleep(2**attempt)
continue
last_status = response.status_code
if response.status_code == 429:
if attempt >= self.max_retries:
raise PlatformAPIError(
"External API rate limited",
error_type="rate_limited",
status_code=response.status_code,
)
time.sleep(2**attempt)
continue
if response.is_error:
raise PlatformAPIError(
f"External API returned HTTP {response.status_code}",
error_type="api_error",
status_code=response.status_code,
)
return response.json()
raise PlatformAPIError("External API request failed", error_type="api_error", status_code=last_status)
def parse_timestamp(value: Any) -> datetime | None:
if value in (None, ""):
return None
try:
number = int(value)
except (TypeError, ValueError):
return None
if number > 10_000_000_000:
number = number // 1000
return datetime.fromtimestamp(number, tz=UTC)
def first_present(data: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in data and data[key] not in (None, ""):
return data[key]
return None
+99
View File
@@ -0,0 +1,99 @@
from typing import Any
from app.platforms.base import CommentData, ContentItemData, HotspotData, TikHubClient, first_present, parse_timestamp
class DouyinPlatform:
def __init__(self, client: TikHubClient | None) -> None:
self.client = client
def map_hotspots(self, payload: dict[str, Any], *, limit: int) -> list[HotspotData]:
data = payload.get("data", {})
items = data.get("word_list") or data.get("list") or data.get("item_list") or []
result = []
for index, item in enumerate(items[:limit], start=1):
result.append(
HotspotData(
source_hot_id=str(item.get("query_id")) if item.get("query_id") is not None else None,
title=str(first_present(item, "title", "sentence", "word") or ""),
rank=item.get("rank") or index,
heat_value=str(item.get("hot_score")) if item.get("hot_score") is not None else None,
raw_data=item,
)
)
return result
def map_items(self, payload: dict[str, Any], *, limit: int) -> list[ContentItemData]:
data = payload.get("data", [])
raw_items = data.get("business_data") or data.get("data") or data.get("items") or [] if isinstance(data, dict) else data
result = []
for item in raw_items[:limit]:
nested = item.get("data", item) if isinstance(item, dict) else {}
aweme = nested.get("aweme_info", nested) if isinstance(nested, dict) else {}
aweme_id = aweme.get("aweme_id")
if not aweme_id:
continue
result.append(
ContentItemData(
source_item_id=str(aweme_id),
item_type="video",
title=aweme.get("desc"),
summary=aweme.get("desc"),
url=aweme.get("share_url"),
raw_data=aweme,
)
)
return result
def map_comments(self, payload: dict[str, Any], *, limit: int) -> list[CommentData]:
comments = payload.get("comments") or payload.get("data", {}).get("comments") or []
result = []
for comment in comments[:limit]:
content = comment.get("text")
if not content:
continue
result.append(
CommentData(
source_comment_id=first_present(comment, "comment_id", "cid"),
content=str(content),
author=self._author_name(comment),
like_count=comment.get("digg_count"),
comment_time=parse_timestamp(first_present(comment, "create_time", "create_time_str")),
raw_data=comment,
)
)
return result
def fetch_hotspots(self, *, limit: int) -> list[HotspotData]:
payload = self.client.get(
"/api/v1/douyin/creator/fetch_creator_hot_spot_billboard",
params={"billboard_tag": 0, "hot_search_type": 1},
)
return self.map_hotspots(payload, limit=limit)
def search_items_by_hotspot(self, keyword: str, *, limit: int) -> list[ContentItemData]:
payload = self.client.post(
"/api/v1/douyin/search/fetch_video_search_v2",
json={
"keyword": keyword,
"cursor": 0,
"sort_type": "0",
"publish_time": "0",
"filter_duration": "0",
"content_type": "1",
"search_id": "",
"backtrace": "",
},
)
return self.map_items(payload, limit=limit)
def fetch_comments(self, source_item_id: str, *, limit: int) -> list[CommentData]:
payload = self.client.get(
"/api/v1/douyin/app/v3/fetch_video_comments",
params={"aweme_id": source_item_id, "cursor": 0, "count": 20},
)
return self.map_comments(payload, limit=limit)
def _author_name(self, comment: dict[str, Any]) -> str | None:
user = comment.get("user") or {}
return user.get("nickname") or user.get("name") if isinstance(user, dict) else None
+84
View File
@@ -0,0 +1,84 @@
from typing import Any
from app.platforms.base import CommentData, ContentItemData, HotspotData, TikHubClient, first_present, parse_timestamp
class XiaohongshuPlatform:
def __init__(self, client: TikHubClient | None) -> None:
self.client = client
def map_hotspots(self, payload: dict[str, Any], *, limit: int) -> list[HotspotData]:
items = payload.get("data", {}).get("data", {}).get("items", [])
hotspots = []
for index, item in enumerate(items[:limit], start=1):
hotspots.append(
HotspotData(
source_hot_id=str(item.get("id")) if item.get("id") is not None else None,
title=str(item.get("title") or ""),
rank=index,
heat_value=str(item.get("score")) if item.get("score") is not None else None,
raw_data=item,
)
)
return hotspots
def map_items(self, payload: dict[str, Any], *, limit: int) -> list[ContentItemData]:
raw_items = payload.get("data", {}).get("data", {}).get("items", [])
notes = [item.get("note", item) for item in raw_items]
notes.sort(key=lambda note: 0 if int(note.get("comments_count") or 0) > 0 else 1)
result = []
for note in notes[:limit]:
note_id = note.get("id")
if not note_id:
continue
result.append(
ContentItemData(
source_item_id=str(note_id),
item_type="note",
title=note.get("title"),
summary=note.get("desc"),
url=note.get("url"),
raw_data=note,
)
)
return result
def map_comments(self, payload: dict[str, Any], *, limit: int) -> list[CommentData]:
comments = payload.get("data", {}).get("data", {}).get("comments", [])
result = []
for comment in comments[:limit]:
content = first_present(comment, "content", "text")
if not content:
continue
result.append(
CommentData(
source_comment_id=first_present(comment, "comment_id", "id"),
content=str(content),
author=self._author_name(comment),
like_count=comment.get("like_count"),
comment_time=parse_timestamp(first_present(comment, "create_time", "create_time_str")),
raw_data=comment,
)
)
return result
def fetch_hotspots(self, *, limit: int) -> list[HotspotData]:
return self.map_hotspots(self.client.get("/api/v1/xiaohongshu/web_v2/fetch_hot_list"), limit=limit)
def search_items_by_hotspot(self, keyword: str, *, limit: int) -> list[ContentItemData]:
payload = self.client.get(
"/api/v1/xiaohongshu/app_v2/search_notes",
params={"keyword": keyword, "page": 1, "sort": "general", "note_type": 0},
)
return self.map_items(payload, limit=limit)
def fetch_comments(self, source_item_id: str, *, limit: int) -> list[CommentData]:
payload = self.client.get(
"/api/v1/xiaohongshu/app_v2/get_note_comments",
params={"note_id": source_item_id, "cursor": "", "index": 0, "pageArea": "UNFOLDED", "sort_strategy": "latest_v2"},
)
return self.map_comments(payload, limit=limit)
def _author_name(self, comment: dict[str, Any]) -> str | None:
user = comment.get("user_info") or comment.get("user") or {}
return user.get("nickname") or user.get("name") if isinstance(user, dict) else None
+4
View File
@@ -0,0 +1,4 @@
只返回 JSON Array,不输出 Markdown 或解释性自然语言。
请原样回填输入中的 comment_id,不得修改或生成新 ID。
sentiment 只能是 positive、negative、neutral、unknown。
labels 使用 1 到 3 个简短中文短语;无法判断时 labels 可为空数组。
+3
View File
@@ -0,0 +1,3 @@
根据输入的情绪分布、Top 标签和典型评论,生成事实性中文总结。
只输出纯文本,不使用 Markdown,不超过指定字数。
总结失败时由后端使用默认文案兜底。
+40
View File
@@ -0,0 +1,40 @@
from datetime import datetime
from typing import Literal
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
class CreateTaskRequest(BaseModel):
platform: Literal["xiaohongshu", "douyin"]
hotspot_limit: int = Field(default=5, ge=1, le=10)
item_limit_per_hotspot: int = Field(
default=5,
ge=1,
le=10,
validation_alias=AliasChoices("item_limit_per_hotspot", "item_limit"),
)
comment_limit_per_item: int = Field(default=50, ge=10, le=100)
class TaskResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
task_id: str = Field(validation_alias="id", serialization_alias="task_id")
platform: str
status: str
analysis_status: str
analysis_success_rate: float
hotspot_limit: int
item_limit_per_hotspot: int
comment_limit_per_item: int
total_items_count: int
processed_items_count: int
successful_items_count: int
failed_items_count: int
error_message: str | None
created_at: datetime
class CreateTaskResponse(BaseModel):
task_id: str
status: str
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
"""Service modules for task execution, crawling, AI analysis, reports, and export."""
+140
View File
@@ -0,0 +1,140 @@
import json
import time
from dataclasses import dataclass
from typing import Literal
import httpx
from pydantic import BaseModel, Field, ValidationError
Sentiment = Literal["positive", "negative", "neutral", "unknown"]
@dataclass(frozen=True)
class AIAnalysisResult:
comment_id: str
sentiment: str
labels: list[str]
reason: str = ""
ai_analysis_status: str = "success"
class AIAnalysisItem(BaseModel):
comment_id: str
sentiment: Sentiment
labels: list[str] = Field(default_factory=list, max_length=3)
reason: str = ""
def build_comment_prompt(comments: list[dict[str, str]]) -> str:
payload = [
{
"comment_id": str(comment["comment_id"]),
"content": str(comment.get("content", ""))[:150],
}
for comment in comments
]
return (
"只返回 JSON Array,不输出 Markdown 或解释性自然语言。"
"请原样回填输入中的 comment_id,不得修改或生成新 ID。"
"sentiment 只能是 positive、negative、neutral、unknownlabels 最多 3 个简短中文短语。\n"
f"{json.dumps(payload, ensure_ascii=False)}"
)
def parse_ai_comment_response(response_text: str, *, expected_comment_ids: set[str]) -> list[AIAnalysisResult]:
try:
raw = json.loads(response_text)
except json.JSONDecodeError as exc:
raise ValueError("AI response is not valid JSON") from exc
if not isinstance(raw, list):
raise ValueError("AI response must be a JSON Array")
results = []
for item in raw:
try:
parsed = AIAnalysisItem.model_validate(item)
except ValidationError as exc:
raise ValueError("AI response item does not match schema") from exc
if parsed.comment_id not in expected_comment_ids:
raise ValueError("AI response comment_id does not match input")
results.append(
AIAnalysisResult(
comment_id=parsed.comment_id,
sentiment=parsed.sentiment,
labels=parsed.labels,
reason=parsed.reason,
)
)
return results
def calculate_analysis_status(*, success_count: int, total_count: int) -> tuple[float, str]:
if total_count <= 0:
return 0.0, "insufficient"
rate = success_count / total_count
return rate, "normal" if rate >= 0.8 else "insufficient"
def analyze_comments_with_retry(
comments: list[dict[str, str]],
*,
requester,
max_retries: int = 3,
) -> list[AIAnalysisResult]:
expected_ids = {str(comment["comment_id"]) for comment in comments}
prompt = build_comment_prompt(comments)
for attempt in range(max_retries):
try:
return parse_ai_comment_response(requester(prompt), expected_comment_ids=expected_ids)
except Exception:
if attempt >= max_retries - 1:
break
time.sleep(2**attempt)
return [
AIAnalysisResult(
comment_id=comment_id,
sentiment="unknown",
labels=[],
reason="ai_parse_failed",
ai_analysis_status="failed",
)
for comment_id in expected_ids
]
class OpenAICompatibleAIClient:
def __init__(
self,
*,
base_url: str,
api_key: str,
model: str,
timeout_seconds: int = 30,
http_client: httpx.Client | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
self._http_client = http_client or httpx.Client(timeout=timeout_seconds)
def request(self, prompt: str) -> str:
endpoint = f"{self.base_url}/chat/completions" if self.base_url.endswith("/v1") else f"{self.base_url}/v1/chat/completions"
response = self._http_client.post(
endpoint,
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"model": self.model,
"messages": [
{
"role": "system",
"content": "只返回 JSON Array,不输出 Markdown 或解释性自然语言。",
},
{"role": "user", "content": prompt},
],
"temperature": 0,
},
)
response.raise_for_status()
payload = response.json()
return str(payload["choices"][0]["message"]["content"])
+104
View File
@@ -0,0 +1,104 @@
import csv
import io
import json
import re
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Comment, ContentItem, Hotspot, Report
def labels_to_text(labels_json: str | None) -> str:
try:
labels = json.loads(labels_json or "[]")
except json.JSONDecodeError:
return ""
return "".join(str(label) for label in labels) if isinstance(labels, list) else ""
def safe_csv_field(value) -> str:
text = "" if value is None else str(value)
text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
if text.startswith(("=", "+", "-", "@")):
return f"'{text}"
return text
def safe_filename(platform: str, task_id: str, hotspot_keyword: str, *, extension: str = "csv") -> str:
keyword = hotspot_keyword[:20] + ("..." if len(hotspot_keyword) > 20 else "")
name = f"{platform}_{task_id}_{keyword}"
name = re.sub(r'[/\\:*?"<>|]+', "_", name)
name = re.sub(r"_+", "_", name).strip("_")
return f"{name}.{extension}"
def export_item_comments_csv(session: Session, item_id: str) -> tuple[bytes, str]:
item = session.get(ContentItem, item_id)
if item is None:
raise ValueError("content item not found")
hotspot = session.get(Hotspot, item.hotspot_id)
comments = list(session.scalars(select(Comment).where(Comment.content_item_id == item.id)))
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["平台", "任务 ID", "热点 ID", "热点标题", "内容条目 ID", "内容条目标题", "评论 ID", "评论内容", "情绪倾向", "方向标签", "点赞数", "评论时间"])
for comment in comments:
writer.writerow(
[
item.platform,
item.task_id,
hotspot.id if hotspot else "",
hotspot.title if hotspot else "",
item.id,
item.title or "",
comment.source_comment_id or "",
safe_csv_field(comment.content),
comment.sentiment,
labels_to_text(comment.labels),
comment.like_count or 0,
comment.comment_time or "",
]
)
filename = safe_filename(item.platform, item.task_id, hotspot.title if hotspot else item.title or "comments")
return output.getvalue().encode("utf-8-sig"), filename
def export_hotspot_comments_csv(session: Session, hotspot_id: str) -> tuple[bytes, str]:
hotspot = session.get(Hotspot, hotspot_id)
if hotspot is None:
raise ValueError("hotspot not found")
comments = list(session.scalars(select(Comment).where(Comment.hotspot_id == hotspot.id)))
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["平台", "任务 ID", "热点 ID", "热点标题", "内容条目 ID", "内容条目标题", "评论 ID", "评论内容", "情绪倾向", "方向标签", "点赞数", "评论时间"])
for comment in comments:
item = session.get(ContentItem, comment.content_item_id)
writer.writerow(
[
comment.platform,
comment.task_id,
hotspot.id,
hotspot.title,
item.id if item else "",
item.title if item else "",
comment.source_comment_id or "",
safe_csv_field(comment.content),
comment.sentiment,
labels_to_text(comment.labels),
comment.like_count or 0,
comment.comment_time or "",
]
)
return output.getvalue().encode("utf-8-sig"), safe_filename(hotspot.platform, hotspot.task_id, hotspot.title)
def export_report_markdown(session: Session, *, report_type: str, hotspot_id: str | None = None, item_id: str | None = None) -> tuple[str, str]:
query = select(Report).where(Report.report_type == report_type)
if hotspot_id:
query = query.where(Report.hotspot_id == hotspot_id)
if item_id:
query = query.where(Report.content_item_id == item_id)
report = session.scalar(query)
if report is None:
raise ValueError("report not found")
return report.markdown_content or report.markdown, safe_filename("report", report.task_id, report.title, extension="md")
+162
View File
@@ -0,0 +1,162 @@
import json
from collections import Counter, defaultdict
from collections.abc import Callable
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import Comment, ContentItem, Hotspot, Report
DEFAULT_SUMMARY = "总结生成失败,请查看上方统计数据。"
def _labels(value: str | None) -> list[str]:
try:
parsed = json.loads(value or "[]")
except json.JSONDecodeError:
return []
return [str(label) for label in parsed] if isinstance(parsed, list) else []
def build_comment_metrics(comments: list[Comment]) -> dict:
sentiment_counts = Counter(comment.sentiment or "unknown" for comment in comments)
label_counts = Counter(label for comment in comments for label in _labels(comment.labels))
total = len(comments)
def sentiment_entry(name: str) -> dict:
count = sentiment_counts.get(name, 0)
return {"count": count, "pct": round((count / total * 100) if total else 0, 2)}
return {
"sample_count": total,
"sentiment": {
"positive": sentiment_entry("positive"),
"negative": sentiment_entry("negative"),
"neutral": sentiment_entry("neutral"),
"unknown": sentiment_entry("unknown"),
},
"top_labels": [{"name": name, "count": count} for name, count in label_counts.most_common(5)],
}
def select_typical_comments(comments: list[Comment], *, per_sentiment: int = 2) -> dict[str, list[dict]]:
buckets: dict[str, list[Comment]] = defaultdict(list)
for comment in comments:
buckets[comment.sentiment or "unknown"].append(comment)
result = {}
for sentiment, values in buckets.items():
sorted_values = sorted(values, key=lambda c: (c.like_count or 0, c.comment_time or c.created_at), reverse=True)
result[sentiment] = [
{"content": comment.content, "like_count": comment.like_count or 0, "comment_id": comment.source_comment_id}
for comment in sorted_values[:per_sentiment]
]
return result
def generate_item_report(
session: Session,
content_item_id: str,
*,
summary_provider: Callable[[dict, dict], str] | None = None,
) -> Report:
item = session.get(ContentItem, content_item_id)
if item is None:
raise ValueError("content item not found")
hotspot = session.get(Hotspot, item.hotspot_id)
comments = list(session.scalars(select(Comment).where(Comment.content_item_id == item.id)))
metrics = build_comment_metrics(comments)
typical = select_typical_comments(comments)
summary = _safe_summary(summary_provider, metrics, typical, limit=200)
markdown = _build_markdown(title=item.title or item.source_item_id, metrics=metrics, typical=typical, summary=summary, hotspot_title=hotspot.title if hotspot else "")
report = Report(
task_id=item.task_id,
hotspot_id=item.hotspot_id,
content_item_id=item.id,
report_type="item",
title=item.title or item.source_item_id,
data=json.dumps(metrics, ensure_ascii=False),
markdown=markdown,
metrics_json=json.dumps(metrics, ensure_ascii=False),
typical_comments_json=json.dumps(typical, ensure_ascii=False),
summary=summary,
markdown_content=markdown,
)
session.add(report)
session.commit()
session.refresh(report)
return report
def generate_hotspot_report(
session: Session,
hotspot_id: str,
*,
summary_provider: Callable[[dict, dict], str] | None = None,
) -> Report:
hotspot = session.get(Hotspot, hotspot_id)
if hotspot is None:
raise ValueError("hotspot not found")
comments = list(session.scalars(select(Comment).where(Comment.hotspot_id == hotspot.id)))
item_count = session.scalar(select(func.count(ContentItem.id)).where(ContentItem.hotspot_id == hotspot.id)) or 0
metrics = build_comment_metrics(comments)
metrics["item_count"] = item_count
typical = select_typical_comments(comments)
summary = _safe_summary(summary_provider, metrics, typical, limit=300)
markdown = _build_markdown(title=hotspot.title, metrics=metrics, typical=typical, summary=summary)
report = Report(
task_id=hotspot.task_id,
hotspot_id=hotspot.id,
content_item_id=None,
report_type="hotspot",
title=hotspot.title,
data=json.dumps(metrics, ensure_ascii=False),
markdown=markdown,
metrics_json=json.dumps(metrics, ensure_ascii=False),
typical_comments_json=json.dumps(typical, ensure_ascii=False),
summary=summary,
markdown_content=markdown,
)
session.add(report)
session.commit()
session.refresh(report)
return report
def _safe_summary(summary_provider, metrics: dict, typical: dict, *, limit: int) -> str:
if summary_provider is None:
return DEFAULT_SUMMARY
try:
summary = summary_provider(metrics, typical)
except Exception:
return DEFAULT_SUMMARY
return str(summary)[:limit]
def _build_markdown(*, title: str, metrics: dict, typical: dict, summary: str, hotspot_title: str = "") -> str:
lines = [
f"# {title}",
"",
]
if hotspot_title:
lines.extend([f"- 所属热点:{hotspot_title}", ""])
lines.extend(
[
f"- 样本评论数量:{metrics['sample_count']}",
"",
"## 情绪分布",
]
)
for name in ("positive", "neutral", "negative", "unknown"):
item = metrics["sentiment"][name]
lines.append(f"- {name}: {item['count']} ({item['pct']}%)")
lines.extend(["", "## Top 标签"])
for label in metrics["top_labels"]:
lines.append(f"- {label['name']}: {label['count']}")
lines.extend(["", "## 典型评论"])
for sentiment, comments in typical.items():
for comment in comments:
lines.append(f"- [{sentiment}] {comment['content']}")
lines.extend(["", "## 总结", summary])
return "\n".join(lines)
+243
View File
@@ -0,0 +1,243 @@
import inspect
import json
from concurrent.futures import ThreadPoolExecutor
from collections.abc import Callable
from sqlalchemy import func, select
from sqlalchemy.orm import Session, sessionmaker
from app.config import get_settings
from app.db import SessionLocal
from app.models import Comment, ContentItem, Hotspot, Task
from app.platforms.base import PlatformAPIError, TikHubClient
from app.platforms.douyin import DouyinPlatform
from app.platforms.xiaohongshu import XiaohongshuPlatform
from app.schemas import CreateTaskRequest
from app.services.ai_service import OpenAICompatibleAIClient, analyze_comments_with_retry, calculate_analysis_status
from app.services.report_service import generate_hotspot_report, generate_item_report
RUNNING_TASK_MESSAGE = "当前有正在运行的任务,请稍后再试"
RESTART_ERROR_MESSAGE = "系统重启,任务被中断"
task_executor = ThreadPoolExecutor(max_workers=1)
def has_running_task(session: Session) -> bool:
return session.scalar(select(Task.id).where(Task.status == "running").limit(1)) is not None
def recover_running_tasks(session: Session) -> int:
tasks = list(session.scalars(select(Task).where(Task.status == "running")))
for task in tasks:
task.status = "failed"
task.error_stage = "system"
task.error_type = "unexpected_restart"
task.error_message = RESTART_ERROR_MESSAGE
session.commit()
return len(tasks)
def build_platform(platform: str):
settings = get_settings()
client = TikHubClient(
base_url=settings.tikhub_base_url,
api_key=settings.tikhub_api_key,
timeout_seconds=settings.http_timeout_seconds,
max_retries=settings.http_max_retries,
)
if platform == "xiaohongshu":
return XiaohongshuPlatform(client)
if platform == "douyin":
return DouyinPlatform(client)
raise ValueError(f"Unsupported platform: {platform}")
def build_ai_requester():
settings = get_settings()
if settings.ai_base_url and settings.ai_api_key and settings.ai_model:
client = OpenAICompatibleAIClient(
base_url=settings.ai_base_url,
api_key=settings.ai_api_key,
model=settings.ai_model,
timeout_seconds=settings.ai_timeout_seconds,
)
return client.request
def fallback_requester(_prompt: str) -> str:
return "[]"
return fallback_requester
def create_task(session: Session, request: CreateTaskRequest, *, submit_background: bool = True) -> Task:
task = Task(
platform=request.platform,
status="running",
hotspot_limit=request.hotspot_limit,
item_limit_per_hotspot=request.item_limit_per_hotspot,
comment_limit_per_item=request.comment_limit_per_item,
total_items_count=0,
processed_items_count=0,
successful_items_count=0,
failed_items_count=0,
analysis_success_rate=0.0,
analysis_status="normal",
)
session.add(task)
session.commit()
session.refresh(task)
if submit_background:
task_executor.submit(run_task, task.id)
return task
def list_tasks(session: Session) -> list[Task]:
return list(session.scalars(select(Task).order_by(Task.created_at.desc())))
def get_task(session: Session, task_id: str) -> Task | None:
return session.get(Task, task_id)
def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionmaker = SessionLocal) -> None:
session = session_factory()
close_session = hasattr(session, "close")
try:
task = session.get(Task, task_id)
if task is None:
return
try:
platform = build_platform(task.platform)
try:
hotspots = platform.fetch_hotspots(limit=task.hotspot_limit)
except PlatformAPIError as exc:
_mark_task_failed(task, "crawl_hotspots", exc.error_type, str(exc))
session.commit()
return
except Exception as exc:
_mark_task_failed(task, "crawl_hotspots", "api_error", str(exc))
session.commit()
return
for hotspot_data in hotspots:
hotspot = Hotspot(
task_id=task.id,
platform=task.platform,
source_hot_id=hotspot_data.source_hot_id,
rank=hotspot_data.rank,
title=hotspot_data.title,
heat_value=hotspot_data.heat_value,
raw_data=json.dumps(hotspot_data.raw_data or {}, ensure_ascii=False),
)
session.add(hotspot)
session.flush()
try:
items = platform.search_items_by_hotspot(hotspot.title, limit=task.item_limit_per_hotspot)
except Exception as exc:
task.failed_items_count += task.item_limit_per_hotspot
task.error_stage = "crawl_items"
task.error_type = getattr(exc, "error_type", "api_error")
task.error_message = str(exc)
session.commit()
continue
task.total_items_count += len(items)
seen_item_ids: set[str] = set()
for item_data in items:
if item_data.source_item_id in seen_item_ids:
continue
seen_item_ids.add(item_data.source_item_id)
item = ContentItem(
task_id=task.id,
hotspot_id=hotspot.id,
platform=task.platform,
source_item_id=item_data.source_item_id,
item_type=item_data.item_type,
title=item_data.title,
summary=item_data.summary,
url=item_data.url,
status="pending",
raw_data=json.dumps(item_data.raw_data or {}, ensure_ascii=False),
)
session.add(item)
session.flush()
try:
comments = platform.fetch_comments(item.source_item_id, limit=task.comment_limit_per_item)
for comment_data in comments:
session.add(
Comment(
task_id=task.id,
hotspot_id=hotspot.id,
content_item_id=item.id,
platform=task.platform,
source_comment_id=comment_data.source_comment_id,
content=comment_data.content,
author=comment_data.author,
like_count=comment_data.like_count,
comment_time=comment_data.comment_time,
raw_data=json.dumps(comment_data.raw_data or {}, ensure_ascii=False),
)
)
session.flush()
item_comments = list(session.scalars(select(Comment).where(Comment.content_item_id == item.id)))
ai_results = analyze_comments_with_retry(
[{"comment_id": comment.id, "content": comment.content} for comment in item_comments],
requester=build_ai_requester(),
max_retries=get_settings().ai_max_retries,
)
results_by_comment_id = {result.comment_id: result for result in ai_results}
for comment in item_comments:
result = results_by_comment_id.get(comment.id)
if result is None:
comment.sentiment = "unknown"
comment.labels = "[]"
comment.reason = "ai_missing_result"
comment.ai_analysis_status = "failed"
continue
comment.sentiment = result.sentiment
comment.labels = json.dumps(result.labels, ensure_ascii=False)
comment.reason = result.reason
comment.ai_analysis_status = result.ai_analysis_status
item.status = "success"
task.successful_items_count += 1
generate_item_report(session, item.id)
except Exception as exc:
item.status = "failed"
item.error_stage = "crawl_comments"
item.error_type = getattr(exc, "error_type", "api_error")
item.error_message = str(exc)
task.failed_items_count += 1
task.error_stage = item.error_stage
task.error_type = item.error_type
task.error_message = item.error_message
finally:
task.processed_items_count += 1
session.commit()
if task.successful_items_count > 0:
total_comments = session.scalar(select(func.count(Comment.id)).where(Comment.task_id == task.id)) or 0
success_comments = sum(1 for comment in task.comments if comment.ai_analysis_status == "success")
task.analysis_success_rate, task.analysis_status = calculate_analysis_status(
success_count=success_comments,
total_count=total_comments,
)
for hotspot in task.hotspots:
generate_hotspot_report(session, hotspot.id)
task.status = "success"
else:
_mark_task_failed(task, task.error_stage or "crawl_items", task.error_type or "no_successful_items", task.error_message or "没有任何内容条目成功")
session.commit()
except Exception as exc:
_mark_task_failed(task, "system", "unexpected_error", str(exc))
session.commit()
finally:
if close_session:
session.close()
def _mark_task_failed(task: Task, stage: str, error_type: str, message: str) -> None:
task.status = "failed"
task.error_stage = stage
task.error_type = error_type
task.error_message = message
+1
View File
@@ -0,0 +1 @@
+7
View File
@@ -0,0 +1,7 @@
body {
background: #f8f9fa;
}
.card {
border-radius: 8px;
}
+93
View File
@@ -0,0 +1,93 @@
function readInt(id) {
return parseInt(document.getElementById(id).value, 10) || 0;
}
function updateScaleHint() {
const total = readInt("hotspot_limit") * readInt("item_limit_per_hotspot") * readInt("comment_limit_per_item");
const target = document.getElementById("scale-calc");
if (target) target.textContent = total.toLocaleString();
}
function validateRange(input) {
const value = readInt(input.id);
const min = parseInt(input.min, 10);
const max = parseInt(input.max, 10);
const valid = value >= min && value <= max;
input.classList.toggle("is-invalid", !valid);
return valid;
}
async function submitTask(event) {
event.preventDefault();
const btn = document.getElementById("submit-btn");
const errorBox = document.getElementById("form-error");
const inputs = Array.from(document.querySelectorAll(".scale-input"));
if (!inputs.every(validateRange)) {
errorBox.textContent = "参数超出范围,请检查配置。";
errorBox.classList.remove("d-none");
return;
}
btn.disabled = true;
btn.textContent = "提交中...";
errorBox.classList.add("d-none");
const payload = {
platform: document.getElementById("platform").value,
hotspot_limit: readInt("hotspot_limit"),
item_limit_per_hotspot: readInt("item_limit_per_hotspot"),
comment_limit_per_item: readInt("comment_limit_per_item"),
};
try {
const resp = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
const data = await resp.json();
window.location.href = `/tasks/${data.task_id}`;
return;
}
const data = await resp.json();
errorBox.textContent = resp.status === 422 ? "参数错误,请检查配置。" : (data.detail || "服务器错误,请稍后重试。");
errorBox.classList.remove("d-none");
} catch (_error) {
errorBox.textContent = "网络异常,请检查连接后重试。";
errorBox.classList.remove("d-none");
} finally {
btn.disabled = false;
btn.textContent = "开始抓取";
}
}
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".scale-input").forEach((input) => {
input.addEventListener("input", () => {
validateRange(input);
updateScaleHint();
});
});
updateScaleHint();
});
async function downloadExport(url, defaultFilename, event) {
if (event) event.preventDefault();
const resp = await fetch(url);
if (!resp.ok) {
alert("导出失败,请稍后重试。");
return;
}
const blob = await resp.blob();
const disposition = resp.headers.get("Content-Disposition") || "";
const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
const filename = match ? decodeURIComponent(match[1]) : defaultFilename;
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
}
+1
View File
@@ -0,0 +1 @@
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}热榜评论分析工具{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/app.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/app.js" defer></script>
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
<div class="container">
<a class="navbar-brand" href="/">热榜评论分析工具</a>
<div class="navbar-nav">
<a class="nav-link" href="/">任务列表</a>
</div>
</div>
</nav>
<main class="container py-4">
{% block breadcrumbs %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active" aria-current="page">首页</li>
</ol>
</nav>
{% endblock %}
{% block content %}{% endblock %}
</main>
<footer class="border-top py-3">
<div class="container text-muted small">内部演示工具 | 仅供学习参考</div>
</footer>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}{{ hotspot.title }} 汇总报告 - 热榜评论分析工具{% endblock %}
{% block breadcrumbs %}
<nav aria-label="breadcrumb"><ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">首页</a></li>
<li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">任务 #{{ task.id }}</a></li>
<li class="breadcrumb-item active">汇总报告</li>
</ol></nav>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">{{ hotspot.title }} 汇总报告</h1>
<div class="d-flex gap-2">
<a class="btn btn-outline-primary" href="/api/export/hotspots/{{ hotspot.id }}.md">导出 Markdown 报告</a>
<a class="btn btn-outline-secondary" href="/api/export/hotspots/{{ hotspot.id }}/comments.csv">导出热点评论 CSV</a>
</div>
</div>
{% if report %}
<section class="card"><div class="card-body">
<p class="lead">{{ report.summary }}</p>
<pre class="bg-light p-3">{{ report.markdown_content }}</pre>
</div></section>
{% else %}
<div class="alert alert-secondary">报告生成中,请稍候...</div>
{% endif %}
{% endblock %}
+66
View File
@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}任务列表 - 热榜评论分析工具{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">任务列表</h1>
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.href='/'">手动刷新</button>
</div>
<section class="card mb-4">
<div class="card-header">创建抓取任务</div>
<div class="card-body">
<div id="form-error" class="alert alert-danger d-none" role="alert"></div>
<form id="task-form" onsubmit="submitTask(event)">
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label" for="platform">平台</label>
<select class="form-select" id="platform" name="platform">
<option value="xiaohongshu">小红书</option>
<option value="douyin">抖音</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label" for="hotspot_limit">热点数</label>
<input class="form-control scale-input" id="hotspot_limit" name="hotspot_limit" type="number" min="1" max="10" value="5">
</div>
<div class="col-md-2">
<label class="form-label" for="item_limit_per_hotspot">每热点内容</label>
<input class="form-control scale-input" id="item_limit_per_hotspot" name="item_limit_per_hotspot" type="number" min="1" max="10" value="5">
</div>
<div class="col-md-2">
<label class="form-label" for="comment_limit_per_item">每内容评论</label>
<input class="form-control scale-input" id="comment_limit_per_item" name="comment_limit_per_item" type="number" min="10" max="100" value="50">
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100" id="submit-btn" type="submit">开始抓取</button>
</div>
</div>
<div class="form-text text-muted mt-2" id="scale-hint">
预计最多抓取:<strong id="scale-calc">1250</strong> 条评论(实际数量可能受平台返回数量、去重、失败、限流影响)
</div>
</form>
</div>
</section>
<section class="card">
<div class="card-header">历史任务</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>任务 ID</th>
<th>平台</th>
<th>创建时间</th>
<th>配置规模</th>
<th>进度</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="task-table-body">
{% include "partials/task_rows.html" %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}{{ item.title or item.source_item_id }} 详情 - 热榜评论分析工具{% endblock %}
{% block breadcrumbs %}
<nav aria-label="breadcrumb"><ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">首页</a></li>
<li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">任务 #{{ task.id }}</a></li>
<li class="breadcrumb-item"><a href="/hotspots/{{ hotspot.id }}/report">热点 #{{ hotspot.rank }}{{ hotspot.title }}</a></li>
<li class="breadcrumb-item active">{{ item.title or item.source_item_id }}</li>
</ol></nav>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">{{ item.title or item.source_item_id }}</h1>
<div class="d-flex gap-2">
<a class="btn btn-outline-primary" href="/api/export/items/{{ item.id }}.md">导出 Markdown 报告</a>
<a class="btn btn-outline-secondary" href="/api/export/items/{{ item.id }}/comments.csv">导出评论 CSV</a>
</div>
</div>
{% if report %}
<section class="card mb-4"><div class="card-body">
<p class="lead">{{ report.summary }}</p>
<pre class="bg-light p-3">{{ report.markdown_content }}</pre>
</div></section>
{% endif %}
<section class="card"><div class="card-header">评论明细</div><div class="table-responsive">
<table class="table mb-0"><thead><tr><th>评论内容</th><th>情绪</th><th>标签</th><th>点赞</th></tr></thead><tbody>
{% for comment in comments %}
<tr><td>{{ comment.content }}</td><td>{{ comment.sentiment }}</td><td>{% for label in comment.labels | from_json %}<span class="badge bg-secondary me-1">{{ label }}</span>{% else %}-{% endfor %}</td><td>{{ comment.like_count or 0 }}</td></tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted">暂无评论数据(该内容无评论或评论抓取为空)</td></tr>
{% endfor %}
</tbody></table>
</div></section>
{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
{% macro label_tags(labels_json) %}
{% for label in labels_json | from_json %}
<span class="badge bg-secondary me-1">{{ label }}</span>
{% else %}
<span class="text-muted">-</span>
{% endfor %}
{% endmacro %}
+10
View File
@@ -0,0 +1,10 @@
{% macro sentiment_badge(sentiment) %}
{% set config = {
"positive": ("正向", "bg-success"),
"neutral": ("中性", "bg-warning text-dark"),
"negative": ("负向", "bg-danger"),
"unknown": ("未知", "bg-secondary"),
} %}
{% set label, style = config.get(sentiment, ("未知", "bg-secondary")) %}
<span class="badge {{ style }}">{{ label }}</span>
{% endmacro %}
+4
View File
@@ -0,0 +1,4 @@
{% macro status_badge(status) %}
{% set label, style = status_badge_config(status) %}
<span class="badge {{ style }}">{{ label }}</span>
{% endmacro %}
+21
View File
@@ -0,0 +1,21 @@
{% for task in tasks %}
{% set status_label, status_class = status_badge_config(task.status) %}
<tr>
<td><code>{{ task.id }}</code></td>
<td>{{ task.platform | platform_label }}</td>
<td>{{ task.created_at }}</td>
<td><small class="text-muted">热点 {{ task.hotspot_limit }} / 内容 {{ task.item_limit_per_hotspot }} / 评论 {{ task.comment_limit_per_item }}</small></td>
<td>{{ task.successful_items_count }} / {{ task.total_items_count }}</td>
<td>
<span class="badge {{ status_class }}">{{ status_label }}</span>
{% if task.error_stage or task.error_type %}
<br><small class="text-muted">{{ task.error_stage }} / {{ task.error_type }}</small>
{% endif %}
</td>
<td><a class="btn btn-sm btn-outline-primary" href="/tasks/{{ task.id }}">查看</a></td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center text-muted py-4">还没有任何任务,请在上方创建第一个任务</td>
</tr>
{% endfor %}
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}任务 #{{ task.id }} - 热榜评论分析工具{% endblock %}
{% block breadcrumbs %}
<nav aria-label="breadcrumb"><ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">首页</a></li>
<li class="breadcrumb-item active">任务 #{{ task.id }}</li>
</ol></nav>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">任务 #{{ task.id }}</h1>
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.href='/'">手动刷新</button>
</div>
<section class="card mb-4"><div class="card-body">
{% set label, cls = status_badge_config(task.status) %}
<p>平台:{{ task.platform | platform_label }} <span class="badge {{ cls }}">{{ label }}</span></p>
<p>成功 {{ task.successful_items_count }} / 共 {{ task.total_items_count }} 条内容</p>
{% if task.error_message %}<div class="alert alert-danger">{{ task.error_stage }} / {{ task.error_type }}{{ task.error_message }}</div>{% endif %}
</div></section>
{% if task.status == "running" and not hotspots %}
<div class="text-center text-muted py-5"><div class="spinner-border text-warning mb-3"></div><p>正在抓取热点数据,请稍候...</p></div>
{% endif %}
<div class="accordion" id="hotspot-list">
{% for hotspot in hotspots %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button {% if not loop.first %}collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#hotspot-{{ hotspot.id }}">
热点 #{{ hotspot.rank }}{{ hotspot.title }}
</button>
</h2>
<div id="hotspot-{{ hotspot.id }}" class="accordion-collapse collapse {% if loop.first %}show{% endif %}" data-bs-parent="#hotspot-list">
<div class="accordion-body">
<a class="btn btn-sm btn-outline-primary mb-2" href="/hotspots/{{ hotspot.id }}/report">查看热点级汇总报告</a>
<ul class="list-group">
{% for item in hotspot.content_items %}
<li class="list-group-item d-flex justify-content-between">
<span>{{ item.title or item.summary or item.source_item_id }} <small class="text-muted">{{ item.status }}</small></span>
<a class="btn btn-sm btn-outline-secondary" href="/items/{{ item.id }}">查看详情</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
import json
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="app/templates")
def status_badge_config(status: str) -> tuple[str, str]:
config = {
"pending": ("等待中", "bg-secondary"),
"running": ("运行中", "bg-warning text-dark"),
"success": ("已完成", "bg-success"),
"failed": ("失败", "bg-danger"),
}
return config.get(status, ("未知", "bg-secondary"))
def platform_label(platform: str) -> str:
return {"xiaohongshu": "小红书", "douyin": "抖音"}.get(platform, platform)
def from_json_filter(value: str | None) -> list:
try:
parsed = json.loads(value) if value else []
except (TypeError, json.JSONDecodeError):
return []
return parsed if isinstance(parsed, list) else []
templates.env.globals["status_badge_config"] = status_badge_config
templates.env.filters["platform_label"] = platform_label
templates.env.filters["from_json"] = from_json_filter