44 Commits
Author SHA1 Message Date
meijiali 75fcfe42da merge: 合并 shadcn 风格 UI 优化 2026-07-06 17:14:12 +08:00
meijiali d25fbaa198 feat: 优化现有页面为 shadcn 风格 2026-07-06 17:07:31 +08:00
meijiali 44d459fc50 fix: 重试临时外部 API 错误 2026-07-06 16:09:08 +08:00
meijiali 918dd62e21 fix: 修复 SQLite 连接复用导致数据库不可用 2026-07-06 15:18:58 +08:00
meijiali 1d24cbf841 merge: 合并 UI 优化分支 2026-07-06 15:02:12 +08:00
meijiali 9d212b54be fix: 隐藏 Demo 标识并修正时间展示 2026-07-06 14:57:59 +08:00
meijiali c406364d14 fix: 移除首页顶部快捷入口 2026-07-06 14:51:40 +08:00
meijiali 89dcf49186 fix: 精简任务配置规模展示 2026-07-06 14:47:26 +08:00
meijiali 78cc5df73d feat: 优化任务页面 UI 展示 2026-07-06 14:41:13 +08:00
meijiali da69819b56 fix: 修复 Docker 首页模板加载失败 2026-07-03 21:14:49 +08:00
meijiali 7371c45fb4 Merge branch 'codex/fix-running-task-timeout-from-current' 2026-07-03 20:11:17 +08:00
meijiali bac5304481 fix: 优化 TikHub 鉴权失败提示 2026-07-03 19:43:08 +08:00
meijiali 1f68cee99c merge: 合并移除导出功能分支
# Conflicts:
#	tests/integration/test_routes.py
2026-07-03 19:28:59 +08:00
meijiali aec3604de9 fix: 修复任务长期运行中卡住 2026-07-03 19:18:13 +08:00
meijiali 0b186fa9f8 feat: 移除 CSV 和 Markdown 导出 2026-07-03 19:14:11 +08:00
meijiali 07ce3d4fcd fix: 去除页面编号井号显示 2026-07-03 19:12:08 +08:00
meijiali 4102ecc4e3 chore: 忽略数据库备份目录 2026-07-03 18:38:58 +08:00
meijiali b70b840551 docs: 补充公网部署确认清单 2026-07-03 18:30:09 +08:00
meijiali 2dc8ca1b5b feat: 优化页面信息展示 2026-07-03 18:20:20 +08:00
meijiali 9244f360be feat: 增强任务进度透明化 2026-07-03 18:14:58 +08:00
meijiali 61bcb2ef86 fix: 优化报告未生成时的导出提示 2026-07-03 18:02:01 +08:00
meijiali d6f0f7c1b0 test: 补充 WO-11 页面错误兜底测试 2026-07-03 17:59:13 +08:00
meijiali f91d2272d8 docs: 记录 WO-18 running 排查结论 2026-07-03 17:40:02 +08:00
meijiali 4dc6878f45 docs: 整理 MVP-2 用户反馈工单 2026-07-03 17:17:16 +08:00
meijiali 08d66656a9 docs: 补充验收中新发现问题工单 2026-07-03 17:01:42 +08:00
meijiali 9935f67080 feat: 完善 MVP-2 演示和进度体验 2026-07-03 16:57:39 +08:00
meijiali 867fd39419 release: 标记 MVP 演示候选版本 2026-07-03 15:35:51 +08:00
meijiali a8f5eeaff5 docs: 补充本地验收和部署说明 2026-07-03 15:33:49 +08:00
meijiali f8fddf1eb3 chore: 整理 Docker 启动与部署配置 2026-07-03 15:31:41 +08:00
meijiali b14834e806 feat: 优化导出文件质量 2026-07-03 15:29:55 +08:00
meijiali 11f129c4aa fix: 增强 SQLite 数据库恢复与异常提示 2026-07-03 15:26:32 +08:00
meijiali 89c93b5051 fix: 完善失败重试与跳过边界 2026-07-03 15:19:50 +08:00
meijiali accfaeffaf test: 记录双平台默认规模真实验收 2026-07-03 15:12:52 +08:00
meijiali 46037a217b test: 记录双平台小规模真实验收 2026-07-03 14:53:54 +08:00
meijiali eedb1db4da feat: 打磨报告页和内容详情页 2026-07-03 14:45:22 +08:00
meijiali 82d103757b feat: 优化任务状态与进度体验 2026-07-03 14:32:46 +08:00
meijiali 6a2ced0a07 docs: 合并 MVP 工单审阅决策 2026-07-03 14:12:43 +08:00
meijiali f756eacf39 docs: 新增 MVP 工单推进计划 2026-07-03 13:50:03 +08:00
meijiali b9291f9011 fix: 增加任务详情自动刷新 2026-07-03 12:35:35 +08:00
meijiali 0a3477cc44 fix: 修复报告摘要生成并稳定默认端口 2026-07-03 12:20:59 +08:00
meijiali 5c34ce75b6 test: 修复小红书流程测试不稳定 2026-07-03 11:14:31 +08:00
meijiali c612afd217 docs: 同步任务执行文档 2026-07-03 11:12:34 +08:00
meijiali 9bad7b6e63 feat: 提交热榜评论分析工具 MVP 基线 2026-07-03 11:12:12 +08:00
meijiali 0231be6338 docs: 记录单人项目提交规则 2026-07-03 11:11:26 +08:00
76 changed files with 10083 additions and 37 deletions
Vendored
BIN
View File
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
APP_ENV=development
DATABASE_URL=sqlite:///./data/app.db
TIKHUB_API_KEY=
TIKHUB_BASE_URL=https://api.tikhub.io
AI_PROVIDER=openai-compatible
AI_BASE_URL=
AI_API_KEY=
AI_MODEL=
AI_BATCH_SIZE=20
AI_CONCURRENCY=2
AI_MAX_RETRIES=3
AI_TIMEOUT_SECONDS=30
HTTP_TIMEOUT_SECONDS=20
HTTP_MAX_RETRIES=3
# 评论分页请求间隔秒数,支持浮点数,默认 1.5 秒。
CRAWL_PAGE_INTERVAL_SECONDS=1.5
+11
View File
@@ -0,0 +1,11 @@
.venv/
__pycache__/
*.py[cod]
.DS_Store
.pytest_cache/
.coverage
htmlcov/
.env
data/*.db
data/*.db-*
data/corrupt-backups/
+59
View File
@@ -113,6 +113,22 @@ For each task:
Keep changes scoped. Avoid unrelated refactors. If a file or design issue blocks the requested work, propose the smallest corrective change that serves the current goal.
### Project Memory And Conversation Learnings
After any valuable conversation iteration, agents should consider whether the
learning should be preserved in this `AGENTS.md` file so future agents understand
the project better. Examples include successfully resolving a recurring problem,
confirming an ambiguous project convention, discovering a reliable workflow, or
clarifying how agents should coordinate work in this repository.
Do not silently add uncertain or speculative rules. If the learning is ambiguous,
could change product behavior, or might conflict with the source documents, ask the
user for confirmation before updating `AGENTS.md`.
Keep additions concise and operational. `AGENTS.md` should capture durable agent
working rules, not replace product requirements, implementation specs, or detailed
task plans that belong in `docs/`.
### Error Handling Philosophy
These principles are defined in `docs/DevelopmentPlan.md` §10 and `docs/TDD.md` §13.
@@ -217,6 +233,49 @@ For UI work, verify rendered pages manually or with browser automation when prac
Use small, focused commits. One commit should represent one clear change.
### Solo Commit Discipline
For this solo project, after completing each independent feature/task, run the
relevant tests and create one focused git commit for the files related to that
task. Do not push unless the user explicitly asks. Do not commit unrelated
files.
An "independent feature/task" means the smallest useful change that can be
understood, tested, and reverted on its own. Examples:
- One `docs/Tasks.md` task such as T07 API retry, T20 export, or T22 Docker.
- One narrow bug fix, such as fixing 401 error display or CSV newline handling.
- One cohesive page or route improvement, such as adding the task detail page.
- One test-only change that documents or locks down one behavior.
Do not mix unrelated changes in one commit. For example, do not combine Docker
deployment, UI redesign, AI retry logic, and documentation edits unless they are
strictly required to complete one same task.
Commit messages for this project must be written in Chinese while keeping the
standard prefix. Examples:
- `feat: 接入真实 AI 评论分析`
- `fix: 修复评论分页停止条件`
- `test: 补充导出 CSV 注入防护测试`
- `docs: 记录单人项目提交规则`
### Current Solo Execution Mode
The current project phase is initial solo development and process practice. Unless
the user explicitly enables parallel work or PR workflow, execute tasks
sequentially: one task at a time, in dependency order.
For this phase, use one task branch per `docs/Tasks.md` task, named
`feat/tXX-short-description` (for example, `feat/t01-project-skeleton`). After the
task passes its required checks, create one focused commit for that task when
practical. Large tasks may be split into multiple meaningful commits only when a
single commit would be hard to review or safely revert.
Do not start the next task until the previous task has been reviewed, verified,
and either merged into the working baseline or explicitly approved as the base for
the next branch. Do not open a PR unless the user asks for one.
Recommended commit prefixes:
- `docs:` documentation changes
+22
View File
@@ -0,0 +1,22 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN adduser --disabled-password --gecos "" appuser
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY .env.example ./.env.example
RUN mkdir -p /app/data && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+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()
+121
View File
@@ -0,0 +1,121 @@
from collections.abc import Generator
from pathlib import Path
from shutil import copy2
from datetime import UTC, datetime
from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from sqlalchemy.pool import NullPool
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_kwargs = {"connect_args": connect_args}
if database_url.startswith("sqlite:///") and database_url != "sqlite:///:memory:":
engine_kwargs["poolclass"] = NullPool
engine = create_engine(database_url, **engine_kwargs)
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
def sqlite_file_path(target_engine: Engine = None) -> Path | None:
target_engine = target_engine or engine
if target_engine.url.get_backend_name() != "sqlite":
return None
database = target_engine.url.database
if not database or database == ":memory:":
return None
return Path(database)
engine = create_sqlite_engine(get_settings().database_url)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def check_database_integrity(target_engine: Engine = engine) -> str:
raw_connection = target_engine.raw_connection()
try:
cursor = raw_connection.driver_connection.cursor()
try:
cursor.execute("PRAGMA integrity_check")
result = cursor.fetchone()
finally:
cursor.close()
finally:
raw_connection.close()
status = result[0] if result else "missing_result"
if status != "ok":
raise RuntimeError(f"SQLite integrity check failed: {status}")
return status
def checkpoint_sqlite_wal(target_engine: Engine = engine) -> bool:
if str(target_engine.url) == "sqlite:///:memory:":
return False
if target_engine.url.get_backend_name() != "sqlite":
return False
with target_engine.begin() as connection:
connection.exec_driver_sql("PRAGMA wal_checkpoint(TRUNCATE)")
return True
def ensure_sqlite_schema_compat(target_engine: Engine = engine) -> None:
if target_engine.url.get_backend_name() != "sqlite":
return
with target_engine.begin() as connection:
task_columns = {row[1] for row in connection.exec_driver_sql("PRAGMA table_info(tasks)").all()}
if "current_stage" not in task_columns:
connection.exec_driver_sql("ALTER TABLE tasks ADD COLUMN current_stage VARCHAR(64)")
if "last_progress_at" not in task_columns:
connection.exec_driver_sql("ALTER TABLE tasks ADD COLUMN last_progress_at DATETIME")
def backup_sqlite_files(target_engine: Engine = engine) -> list[Path]:
db_path = sqlite_file_path(target_engine)
if db_path is None:
return []
backup_dir = db_path.parent / "corrupt-backups"
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
copied: list[Path] = []
for source in [db_path, db_path.with_name(f"{db_path.name}-wal"), db_path.with_name(f"{db_path.name}-shm")]:
if not source.exists():
continue
target = backup_dir / f"{source.name}.{timestamp}.bak"
copy2(source, target)
copied.append(target)
return copied
def init_db() -> None:
import app.models # noqa: F401
Base.metadata.create_all(engine)
ensure_sqlite_schema_compat(engine)
check_database_integrity(engine)
checkpoint_sqlite_wal(engine)
def get_db_session() -> Generator[Session]:
with SessionLocal() as session:
yield session
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
import json
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import SessionLocal, init_db
from app.models import Comment, ContentItem, Hotspot, Report, Task, utc_now
FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "demo_seed.json"
def seed_demo_data(session: Session, fixture_path: Path = FIXTURE_PATH) -> str:
data = json.loads(fixture_path.read_text(encoding="utf-8"))
task_data = data["task"]
task_id = task_data["id"]
existing = session.scalar(select(Task.id).where(Task.id == task_id))
if existing:
return task_id
task = Task(
id=task_id,
platform=task_data["platform"],
status="success",
current_stage="success",
last_progress_at=utc_now(),
hotspot_limit=task_data["hotspot_limit"],
item_limit_per_hotspot=task_data["item_limit_per_hotspot"],
comment_limit_per_item=task_data["comment_limit_per_item"],
total_items_count=len(data["items"]),
processed_items_count=len(data["items"]),
successful_items_count=len(data["items"]),
failed_items_count=0,
analysis_success_rate=1.0,
analysis_status="normal",
)
session.add(task)
for hotspot_data in data["hotspots"]:
session.add(
Hotspot(
id=hotspot_data["id"],
task_id=task_id,
platform=task.platform,
rank=hotspot_data["rank"],
title=hotspot_data["title"],
heat_value=hotspot_data.get("heat_value"),
source_hot_id=None,
raw_data="{}",
)
)
for item_data in data["items"]:
session.add(
ContentItem(
id=item_data["id"],
task_id=task_id,
hotspot_id=item_data["hotspot_id"],
platform=task.platform,
source_item_id=f"demo-item-{item_data['id']}",
item_type=item_data["item_type"],
title=item_data["title"],
summary=item_data.get("summary"),
url=None,
status="success",
raw_data="{}",
)
)
for comment_data in data["comments"]:
session.add(
Comment(
id=comment_data["id"],
task_id=task_id,
hotspot_id=comment_data["hotspot_id"],
content_item_id=comment_data["content_item_id"],
platform=task.platform,
source_comment_id=None,
content=comment_data["content"],
author=None,
like_count=comment_data.get("like_count", 0),
sentiment=comment_data["sentiment"],
labels=json.dumps(comment_data["labels"], ensure_ascii=False),
reason=comment_data.get("reason"),
ai_analysis_status="success",
raw_data="{}",
ai_raw_response=None,
)
)
for report_data in data["reports"]:
session.add(
Report(
task_id=task_id,
hotspot_id=report_data.get("hotspot_id"),
content_item_id=report_data.get("content_item_id"),
report_type=report_data["report_type"],
title=report_data["title"],
metrics_json=json.dumps(report_data["metrics"], ensure_ascii=False),
typical_comments_json=json.dumps(report_data["typical_comments"], ensure_ascii=False),
summary=report_data["summary"],
markdown_content=report_data["markdown_content"],
data="{}",
markdown=report_data["markdown_content"],
)
)
session.commit()
return task_id
def main() -> None:
init_db()
with SessionLocal() as session:
task_id = seed_demo_data(session)
print(f"Seeded demo task: {task_id}")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
{
"task": {
"id": "demo-douyin-20260703",
"platform": "douyin",
"hotspot_limit": 1,
"item_limit_per_hotspot": 2,
"comment_limit_per_item": 10
},
"hotspots": [
{
"id": "demo-hotspot-1",
"rank": 1,
"title": "演示热点:夏季新品讨论",
"heat_value": "demo"
}
],
"items": [
{
"id": "demo-item-1",
"hotspot_id": "demo-hotspot-1",
"item_type": "video",
"title": "新品开箱体验",
"summary": "演示用脱敏内容条目"
},
{
"id": "demo-item-2",
"hotspot_id": "demo-hotspot-1",
"item_type": "video",
"title": "用户上手反馈",
"summary": "演示用脱敏内容条目"
}
],
"comments": [
{
"id": "demo-comment-1",
"hotspot_id": "demo-hotspot-1",
"content_item_id": "demo-item-1",
"content": "这个颜色很清爽,夏天用看起来挺舒服。",
"sentiment": "positive",
"labels": ["外观种草", "季节场景"],
"reason": "用户表达了对外观和使用场景的认可。",
"like_count": 36
},
{
"id": "demo-comment-2",
"hotspot_id": "demo-hotspot-1",
"content_item_id": "demo-item-1",
"content": "价格如果能再低一点就好了,现在有点观望。",
"sentiment": "neutral",
"labels": ["价格观望", "购买决策"],
"reason": "用户没有否定产品,但对价格仍有顾虑。",
"like_count": 21
},
{
"id": "demo-comment-3",
"hotspot_id": "demo-hotspot-1",
"content_item_id": "demo-item-2",
"content": "看完真实上手比广告图可信,想看看长期使用反馈。",
"sentiment": "positive",
"labels": ["真实体验", "长期反馈"],
"reason": "用户认可真实体验内容,但仍希望补充长期反馈。",
"like_count": 18
},
{
"id": "demo-comment-4",
"hotspot_id": "demo-hotspot-1",
"content_item_id": "demo-item-2",
"content": "评论里好多人问链接,说明种草效果还是挺明显的。",
"sentiment": "positive",
"labels": ["求购买链接", "种草效果"],
"reason": "用户从评论行为判断内容有转化潜力。",
"like_count": 12
}
],
"reports": [
{
"report_type": "hotspot",
"hotspot_id": "demo-hotspot-1",
"title": "演示热点:夏季新品讨论",
"summary": "该热点评论以正向反馈为主,用户主要关注外观、真实体验、价格和购买链接。价格仍是部分用户从种草到购买之间的关键阻力。",
"metrics": {
"sample_count": 4,
"item_count": 2,
"sentiment": {
"positive": {"count": 3, "pct": 75},
"neutral": {"count": 1, "pct": 25},
"negative": {"count": 0, "pct": 0},
"unknown": {"count": 0, "pct": 0}
},
"top_labels": [
{"name": "外观种草", "count": 1},
{"name": "价格观望", "count": 1},
{"name": "真实体验", "count": 1},
{"name": "求购买链接", "count": 1}
]
},
"typical_comments": {
"positive": [{"content": "这个颜色很清爽,夏天用看起来挺舒服。", "like_count": 36}],
"neutral": [{"content": "价格如果能再低一点就好了,现在有点观望。", "like_count": 21}],
"negative": []
},
"markdown_content": "# 演示热点:夏季新品讨论\n\n## 总结\n\n该热点评论以正向反馈为主,用户主要关注外观、真实体验、价格和购买链接。价格仍是部分用户从种草到购买之间的关键阻力。"
},
{
"report_type": "item",
"hotspot_id": "demo-hotspot-1",
"content_item_id": "demo-item-1",
"title": "新品开箱体验",
"summary": "该内容主要激发了外观和季节场景兴趣,同时价格仍影响部分用户的购买决策。",
"metrics": {
"sample_count": 2,
"sentiment": {
"positive": {"count": 1, "pct": 50},
"neutral": {"count": 1, "pct": 50},
"negative": {"count": 0, "pct": 0},
"unknown": {"count": 0, "pct": 0}
},
"top_labels": [
{"name": "外观种草", "count": 1},
{"name": "价格观望", "count": 1}
]
},
"typical_comments": {
"positive": [{"content": "这个颜色很清爽,夏天用看起来挺舒服。", "like_count": 36}],
"neutral": [{"content": "价格如果能再低一点就好了,现在有点观望。", "like_count": 21}],
"negative": []
},
"markdown_content": "# 新品开箱体验\n\n## 总结\n\n该内容主要激发了外观和季节场景兴趣,同时价格仍影响部分用户的购买决策。"
},
{
"report_type": "item",
"hotspot_id": "demo-hotspot-1",
"content_item_id": "demo-item-2",
"title": "用户上手反馈",
"summary": "该内容的评论更关注真实体验和后续转化,用户希望看到长期反馈,也表现出明显的购买链接需求。",
"metrics": {
"sample_count": 2,
"sentiment": {
"positive": {"count": 2, "pct": 100},
"neutral": {"count": 0, "pct": 0},
"negative": {"count": 0, "pct": 0},
"unknown": {"count": 0, "pct": 0}
},
"top_labels": [
{"name": "真实体验", "count": 1},
{"name": "求购买链接", "count": 1}
]
},
"typical_comments": {
"positive": [{"content": "看完真实上手比广告图可信,想看看长期使用反馈。", "like_count": 18}],
"neutral": [],
"negative": []
},
"markdown_content": "# 用户上手反馈\n\n## 总结\n\n该内容的评论更关注真实体验和后续转化,用户希望看到长期反馈,也表现出明显的购买链接需求。"
}
]
}
+147
View File
@@ -0,0 +1,147 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import logging
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import SessionLocal, backup_sqlite_files, get_db_session, init_db
from app.models import Comment, ContentItem, Hotspot, Report
from app.schemas import CreateTaskRequest, CreateTaskResponse, TaskResponse
from app.services.task_service import (
RUNNING_TASK_MESSAGE,
create_task,
get_task,
has_running_task,
list_tasks,
recover_running_tasks,
recover_stale_running_tasks,
)
from app.templating import templates
logger = logging.getLogger(__name__)
DATABASE_UNAVAILABLE_MESSAGE = "数据库暂时不可用,请稍后重试或联系维护者恢复数据。"
def handle_database_error(request: Request, exc: SQLAlchemyError):
try:
backup_sqlite_files()
except Exception:
logger.exception("Failed to back up SQLite files after database error")
if request.url.path.startswith("/api/"):
return JSONResponse(status_code=503, content={"detail": DATABASE_UNAVAILABLE_MESSAGE})
return templates.TemplateResponse(
request,
"errors/database_unavailable.html",
{"message": DATABASE_UNAVAILABLE_MESSAGE, "detail": str(exc)},
status_code=503,
)
@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.exception_handler(OperationalError)
def operational_error_handler(request: Request, exc: OperationalError):
return handle_database_error(request, exc)
@app.exception_handler(SQLAlchemyError)
def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError):
return handle_database_error(request, exc)
@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:
recover_stale_running_tasks(session)
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},
)
+178
View File
@@ -0,0 +1,178 @@
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)
current_stage: Mapped[str | None] = mapped_column(String(64))
last_progress_at: Mapped[datetime | None] = mapped_column()
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 @@
+144
View File
@@ -0,0 +1,144 @@
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.status_code == 401:
raise PlatformAPIError(
"TikHub 鉴权失败,请检查 TIKHUB_API_KEY 是否有效",
error_type="auth_error",
status_code=response.status_code,
)
if response.is_error:
if attempt >= self.max_retries:
raise PlatformAPIError(
self._format_api_error(response),
error_type="api_error",
status_code=response.status_code,
)
time.sleep(2**attempt)
continue
return response.json()
raise PlatformAPIError("External API request failed", error_type="api_error", status_code=last_status)
def _format_api_error(self, response: httpx.Response) -> str:
message = f"External API returned HTTP {response.status_code}"
detail = self._response_error_detail(response)
return f"{message}: {detail}" if detail else message
def _response_error_detail(self, response: httpx.Response) -> str | None:
try:
payload = response.json()
except ValueError:
text = response.text.strip()
return text[:200] if text else None
if isinstance(payload, dict):
for key in ("message_zh", "message", "error", "detail"):
value = payload.get(key)
if value:
return str(value)[:200]
return None
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
+111
View File
@@ -0,0 +1,111 @@
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]:
comments: list[CommentData] = []
cursor = 0
page_size = min(20, limit)
while len(comments) < limit:
payload = self.client.get(
"/api/v1/douyin/app/v3/fetch_video_comments",
params={"aweme_id": source_item_id, "cursor": cursor, "count": page_size},
)
page_comments = self.map_comments(payload, limit=limit - len(comments))
comments.extend(page_comments)
data = payload.get("data", {})
next_cursor = data.get("cursor") or payload.get("cursor")
has_more = data.get("has_more", payload.get("has_more", 0))
if not page_comments or not has_more or next_cursor in (None, cursor):
break
cursor = next_cursor
return comments[: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,不超过指定字数。
总结失败时由后端使用默认文案兜底。
+58
View File
@@ -0,0 +1,58 @@
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
current_stage: str | None = None
current_stage_label: str | None = None
last_progress_at: datetime | None = None
display_number: int = 0
display_id: str = ""
created_at_label: str = ""
scale_label: str = ""
error_summary: str | None = None
running_seconds: int = 0
seconds_since_last_progress: int | None = None
running_duration_label: str = "刚刚"
last_progress_ago_label: str = "暂无记录"
is_progress_stale: bool = False
stale_threshold_minutes: int = 10
hotspots_count: int = 0
comments_count: int = 0
reports_count: int = 0
is_demo: bool = False
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."""
+198
View File
@@ -0,0 +1,198 @@
import json
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError
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 build_report_summary_prompt(metrics: dict, typical: dict, *, word_limit: int) -> str:
sentiment = metrics.get("sentiment", {})
sentiment_lines = []
for name in ("positive", "neutral", "negative", "unknown"):
entry = sentiment.get(name, {})
sentiment_lines.append(f"- {name}: {entry.get('count', 0)} ({entry.get('pct', 0)}%)")
label_lines = [
f"- {label.get('name', '')}: {label.get('count', 0)}"
for label in metrics.get("top_labels", [])
if label.get("name")
]
if not label_lines:
label_lines = ["- 暂无"]
typical_lines = []
for sentiment_name, comments in typical.items():
for comment in comments:
typical_lines.append(f"- [{sentiment_name}] {str(comment.get('content', ''))[:150]}")
if not typical_lines:
typical_lines = ["- 暂无"]
item_count_line = ""
if "item_count" in metrics:
item_count_line = f"内容条目数量:{metrics.get('item_count', 0)}\n"
return (
f"只返回一段中文总结,不使用 Markdown,不超过 {word_limit} 字。\n"
"总结必须基于以下统计数据和典型评论,不要编造未提供的信息。\n"
f"{item_count_line}"
f"样本评论数量:{metrics.get('sample_count', 0)}\n"
"情绪分布:\n"
f"{chr(10).join(sentiment_lines)}\n"
"Top 标签:\n"
f"{chr(10).join(label_lines)}\n"
"典型评论:\n"
f"{chr(10).join(typical_lines)}"
)
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,
timeout_seconds: float | None = None,
) -> list[AIAnalysisResult]:
expected_ids = {str(comment["comment_id"]) for comment in comments}
prompt = build_comment_prompt(comments)
for attempt in range(max_retries):
try:
response_text = _request_with_timeout(requester, prompt, timeout_seconds=timeout_seconds)
return parse_ai_comment_response(response_text, 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
]
def _request_with_timeout(requester, prompt: str, *, timeout_seconds: float | None) -> str:
if timeout_seconds is None or timeout_seconds <= 0:
return requester(prompt)
executor = ThreadPoolExecutor(max_workers=1)
future = executor.submit(requester, prompt)
try:
return future.result(timeout=timeout_seconds)
except TimeoutError as exc:
future.cancel()
raise TimeoutError("AI request timed out") from exc
finally:
executor.shutdown(wait=False, cancel_futures=True)
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, *, system_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": system_prompt,
},
{"role": "user", "content": prompt},
],
"temperature": 0,
},
)
response.raise_for_status()
payload = response.json()
return str(payload["choices"][0]["message"]["content"])
+167
View File
@@ -0,0 +1,167 @@
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, word_limit=limit)
except TypeError:
try:
summary = summary_provider(metrics, typical)
except Exception:
return DEFAULT_SUMMARY
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)
+436
View File
@@ -0,0 +1,436 @@
import inspect
import json
from concurrent.futures import ThreadPoolExecutor
from collections.abc import Callable
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
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, Report, Task, utc_now
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, build_report_summary_prompt, calculate_analysis_status
from app.services.report_service import generate_hotspot_report, generate_item_report
RUNNING_TASK_MESSAGE = "当前有正在运行的任务,请稍后再试"
RESTART_ERROR_MESSAGE = "系统重启,任务被中断"
STALE_PROGRESS_THRESHOLD_SECONDS = 10 * 60
STALE_PROGRESS_ERROR_TYPE = "stale_progress_timeout"
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
task_executor = ThreadPoolExecutor(max_workers=1)
STAGE_LABELS = {
"queued": "等待启动",
"crawl_hotspots": "获取热点中",
"search_items": "搜索内容中",
"crawl_comments": "抓取评论中",
"ai_analysis": "AI 分析中",
"generate_reports": "生成报告中",
"success": "已完成",
"failed": "失败",
}
PLATFORM_LABELS = {
"xiaohongshu": "小红书",
"douyin": "抖音",
}
def has_running_task(session: Session) -> bool:
return session.scalar(select(Task.id).where(Task.status == "running").limit(1)) is not None
def recover_stale_running_tasks(session: Session) -> int:
now = ensure_utc_datetime(utc_now())
tasks = list(session.scalars(select(Task).where(Task.status == "running")))
recovered = 0
for task in tasks:
last_progress_at = ensure_utc_datetime(task.last_progress_at or task.started_at or task.created_at)
if last_progress_at is None or now is None:
continue
seconds_since_progress = max(0, int((now - last_progress_at).total_seconds()))
if seconds_since_progress < STALE_PROGRESS_THRESHOLD_SECONDS:
continue
_mark_task_failed(
task,
"system",
STALE_PROGRESS_ERROR_TYPE,
f"任务超过 {STALE_PROGRESS_THRESHOLD_SECONDS // 60} 分钟没有进度更新,已自动标记失败",
)
task.finished_at = task.finished_at or utc_now()
recovered += 1
if recovered:
session.commit()
return recovered
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_client() -> OpenAICompatibleAIClient | None:
settings = get_settings()
if settings.ai_base_url and settings.ai_api_key and settings.ai_model:
return OpenAICompatibleAIClient(
base_url=settings.ai_base_url,
api_key=settings.ai_api_key,
model=settings.ai_model,
timeout_seconds=settings.ai_timeout_seconds,
)
return None
def build_ai_requester(client: OpenAICompatibleAIClient | None = None):
client = client if client is not None else build_ai_client()
if client is not None:
return lambda prompt: client.request(
prompt,
system_prompt="只返回 JSON Array,不输出 Markdown 或解释性自然语言。",
)
def fallback_requester(_prompt: str) -> str:
return "[]"
return fallback_requester
def build_report_summary_provider(client: OpenAICompatibleAIClient | None):
if client is None:
return None
def summarize_report(metrics: dict, typical: dict, *, word_limit: int = 200) -> str:
return client.request(
build_report_summary_prompt(metrics, typical, word_limit=word_limit),
system_prompt="只返回一段中文总结,不使用 Markdown。",
)
return summarize_report
def build_ai_dependencies():
client = build_ai_client()
requester = build_ai_requester(client)
return requester, build_report_summary_provider(client)
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",
current_stage="queued",
last_progress_at=utc_now(),
)
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]:
tasks = list(session.scalars(select(Task).order_by(Task.created_at.desc())))
for task in tasks:
hydrate_task_progress(session, task)
return tasks
def get_task(session: Session, task_id: str) -> Task | None:
task = session.get(Task, task_id)
if task is not None:
hydrate_task_progress(session, task)
return task
def hydrate_task_progress(session: Session, task: Task) -> Task:
display_number = calculate_task_display_number(session, task)
task.display_number = display_number
task.display_id = str(display_number)
task.created_at_label = format_datetime_minute(task.created_at)
task.scale_label = f"{task.hotspot_limit}热点 × {task.item_limit_per_hotspot}内容 × {task.comment_limit_per_item}评论"
task.error_summary = build_task_error_summary(task)
task.hotspots_count = session.scalar(select(func.count(Hotspot.id)).where(Hotspot.task_id == task.id)) or 0
task.comments_count = session.scalar(select(func.count(Comment.id)).where(Comment.task_id == task.id)) or 0
task.reports_count = session.scalar(select(func.count(Report.id)).where(Report.task_id == task.id)) or 0
task.is_demo = task.id.startswith("demo-")
task.last_progress_at = task.last_progress_at or task.created_at
stage_code = task.current_stage
if not stage_code and task.status == "success":
stage_code = "success"
elif not stage_code and task.status == "failed":
stage_code = "failed"
elif not stage_code and task.status == "running":
stage_code = "queued"
task.current_stage_label = STAGE_LABELS.get(stage_code or "", stage_code or "等待启动")
now = ensure_utc_datetime(utc_now())
running_start = ensure_utc_datetime(task.started_at or task.created_at)
running_end = ensure_utc_datetime(task.finished_at) or now
last_progress_at = ensure_utc_datetime(task.last_progress_at)
running_seconds = max(0, int((running_end - running_start).total_seconds()))
seconds_since_progress = max(0, int((now - last_progress_at).total_seconds())) if last_progress_at else None
task.running_seconds = running_seconds
task.seconds_since_last_progress = seconds_since_progress
task.running_duration_label = format_duration_zh(running_seconds)
task.last_progress_ago_label = f"{format_duration_zh(seconds_since_progress)}" if seconds_since_progress is not None else "暂无记录"
task.stale_threshold_minutes = STALE_PROGRESS_THRESHOLD_SECONDS // 60
task.is_progress_stale = (
task.status == "running"
and seconds_since_progress is not None
and seconds_since_progress >= STALE_PROGRESS_THRESHOLD_SECONDS
)
return task
def calculate_task_display_number(session: Session, task: Task) -> int:
task_ids = list(session.scalars(select(Task.id).order_by(Task.created_at, Task.id)))
try:
return task_ids.index(task.id) + 1
except ValueError:
return len(task_ids) + 1
def format_datetime_minute(value: datetime | None) -> str:
value = ensure_utc_datetime(value)
return value.astimezone(DISPLAY_TIMEZONE).strftime("%Y-%m-%d %H:%M") if value else ""
def build_task_error_summary(task: Task) -> str | None:
if task.error_message:
return task.error_message
if task.error_stage == "system" and task.error_type == "unexpected_restart":
return RESTART_ERROR_MESSAGE
if task.error_stage or task.error_type:
return "任务失败,请查看后端日志"
return None
def format_duration_zh(total_seconds: int | None) -> str:
if total_seconds is None:
return "暂无记录"
total_seconds = max(0, int(total_seconds))
minutes = total_seconds // 60
hours = minutes // 60
days = hours // 24
if days > 0:
remaining_hours = hours % 24
return f"{days}{remaining_hours}小时" if remaining_hours else f"{days}"
if hours > 0:
remaining_minutes = minutes % 60
return f"{hours}小时{remaining_minutes}分钟" if remaining_minutes else f"{hours}小时"
if minutes > 0:
return f"{minutes}分钟"
return "刚刚"
def ensure_utc_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def update_task_stage(task: Task, stage: str) -> None:
task.current_stage = stage
task.last_progress_at = utc_now()
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
task.started_at = task.started_at or utc_now()
session.commit()
try:
platform = build_platform(task.platform)
ai_requester, report_summary_provider = build_ai_dependencies()
try:
update_task_stage(task, "crawl_hotspots")
session.commit()
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:
update_task_stage(task, "search_items")
session.commit()
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:
update_task_stage(task, "crawl_comments")
session.commit()
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)))
update_task_stage(task, "ai_analysis")
session.commit()
ai_results = analyze_comments_with_retry(
[{"comment_id": comment.id, "content": comment.content} for comment in item_comments],
requester=ai_requester,
max_retries=get_settings().ai_max_retries,
timeout_seconds=get_settings().ai_timeout_seconds,
)
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
update_task_stage(task, "generate_reports")
generate_item_report(session, item.id, summary_provider=report_summary_provider)
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:
update_task_stage(task, "generate_reports")
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, summary_provider=report_summary_provider)
task.status = "success"
update_task_stage(task, "success")
else:
_mark_task_failed(task, task.error_stage or "crawl_items", task.error_type or "no_successful_items", task.error_message or "没有任何内容条目成功")
task.finished_at = task.finished_at or utc_now()
session.commit()
except Exception as exc:
_mark_task_failed(task, "system", "unexpected_error", str(exc))
task.finished_at = task.finished_at or utc_now()
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"
update_task_stage(task, "failed")
task.error_stage = stage
task.error_type = error_type
task.error_message = message
+1
View File
@@ -0,0 +1 @@
+537
View File
@@ -0,0 +1,537 @@
:root {
--background: #f8fafc;
--foreground: #0f172a;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--card: #ffffff;
--card-foreground: #0f172a;
--border: #e2e8f0;
--input: #cbd5e1;
--primary: #0f172a;
--primary-foreground: #ffffff;
--secondary: #f1f5f9;
--secondary-foreground: #0f172a;
--accent: #f8fafc;
--accent-foreground: #0f172a;
--destructive: #dc2626;
--success: #16a34a;
--warning: #d97706;
--ring: rgba(15, 23, 42, 0.18);
--radius: 8px;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
min-height: 100vh;
}
a {
color: inherit;
}
.container {
max-width: 1180px;
}
.app-navbar {
background: rgba(255, 255, 255, 0.92) !important;
border-bottom: 1px solid var(--border);
backdrop-filter: blur(14px);
}
.app-navbar .container {
min-height: 56px;
}
.navbar-brand {
color: var(--foreground);
font-size: 15px;
letter-spacing: 0;
}
.nav-link {
border-radius: 6px;
color: var(--muted-foreground);
font-size: 14px;
padding: 6px 10px;
}
.nav-link:hover {
background: var(--secondary);
color: var(--foreground);
}
.breadcrumb {
color: var(--muted-foreground);
font-size: 13px;
margin-bottom: 20px;
}
.breadcrumb a {
color: var(--muted-foreground);
text-decoration: none;
}
.breadcrumb a:hover {
color: var(--foreground);
}
.tool-card,
.card,
.accordion-item {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
color: var(--card-foreground);
overflow: hidden;
}
.card-body {
padding: 20px;
}
.card-header,
.section-heading {
background: var(--card);
border-bottom: 1px solid var(--border);
padding: 16px 20px;
}
.section-heading {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
}
.section-heading h2,
.page-heading h1,
.page-title {
color: var(--foreground);
font-size: 22px;
font-weight: 650;
letter-spacing: 0;
line-height: 1.25;
margin: 0;
}
.page-heading {
align-items: flex-start;
display: flex;
gap: 16px;
justify-content: space-between;
margin-bottom: 18px;
}
.section-kicker,
.eyebrow {
color: var(--muted-foreground);
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
margin: 0 0 6px;
text-transform: uppercase;
}
.hero-band {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
color: var(--foreground);
padding: 30px;
}
.hero-shell {
align-items: flex-end;
display: flex;
gap: 28px;
justify-content: space-between;
}
.hero-copy {
max-width: 700px;
}
.hero-copy h1 {
color: var(--foreground);
font-size: 34px;
font-weight: 700;
letter-spacing: 0;
line-height: 1.15;
margin: 0 0 12px;
}
.hero-copy p:not(.eyebrow) {
color: var(--muted-foreground);
font-size: 15px;
line-height: 1.75;
margin: 0;
}
.hero-metrics {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(84px, 1fr));
min-width: 320px;
}
.hero-metric {
background: var(--muted);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
}
.hero-metric strong {
display: block;
font-size: 18px;
line-height: 1.2;
}
.hero-metric span {
color: var(--muted-foreground);
display: block;
font-size: 12px;
margin-top: 4px;
}
.btn {
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.btn-primary {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
}
.btn-primary:hover,
.btn-primary:focus {
background: #1e293b;
border-color: #1e293b;
}
.btn-outline-primary,
.btn-outline-secondary {
background: var(--card);
border-color: var(--border);
color: var(--foreground);
}
.btn-outline-primary:hover,
.btn-outline-secondary:hover {
background: var(--secondary);
border-color: var(--border);
color: var(--foreground);
}
.form-label {
color: var(--foreground);
font-size: 13px;
font-weight: 600;
}
.form-control,
.form-select {
border-color: var(--input);
border-radius: 6px;
color: var(--foreground);
font-size: 14px;
min-height: 40px;
}
.form-control:focus,
.form-select:focus {
border-color: var(--foreground);
box-shadow: 0 0 0 3px var(--ring);
}
.form-text {
color: var(--muted-foreground) !important;
}
.badge {
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
padding: 5px 9px;
}
.bg-success,
.text-bg-success {
background-color: #dcfce7 !important;
color: #166534 !important;
}
.bg-danger,
.text-bg-danger {
background-color: #fee2e2 !important;
color: #991b1b !important;
}
.bg-warning,
.text-bg-warning {
background-color: #fef3c7 !important;
color: #92400e !important;
}
.bg-secondary,
.text-bg-secondary {
background-color: var(--secondary) !important;
color: var(--secondary-foreground) !important;
}
.text-bg-light {
background: var(--secondary) !important;
color: var(--foreground) !important;
}
.alert {
border-radius: var(--radius);
font-size: 14px;
margin-bottom: 16px;
}
.alert-info {
background: #eff6ff;
border-color: #bfdbfe;
color: #1e3a8a;
}
.alert-warning {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
.alert-danger {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
.alert-secondary {
background: var(--secondary);
border-color: var(--border);
color: var(--foreground);
}
.metric-box,
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
height: 100%;
padding: 16px;
}
.metric-box strong,
.stat-card strong {
color: var(--foreground);
display: block;
font-size: 18px;
font-weight: 650;
line-height: 1.35;
margin: 4px 0;
}
.app-table {
color: var(--foreground);
min-width: 760px;
}
.app-table thead th {
background: var(--muted);
border-bottom: 1px solid var(--border);
color: var(--muted-foreground);
font-size: 12px;
font-weight: 650;
height: 42px;
letter-spacing: 0;
text-transform: none;
white-space: nowrap;
}
.app-table td {
border-color: var(--border);
padding-bottom: 14px;
padding-top: 14px;
vertical-align: middle;
}
.app-table tbody tr:hover {
background: var(--accent);
}
.task-number {
align-items: center;
background: var(--secondary);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--foreground);
display: inline-flex;
font-weight: 650;
height: 30px;
justify-content: center;
min-width: 30px;
padding: 0 9px;
}
.task-progress {
background: var(--secondary);
border-radius: 999px;
height: 8px;
}
.progress-bar {
background-color: var(--primary);
}
.report-progress {
background: var(--secondary);
height: 8px;
}
.task-overview {
border-top: 0;
}
.progress-summary {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.hotspot-item {
border-color: var(--border);
margin-bottom: 10px;
}
.accordion-button {
background: var(--card);
color: var(--foreground);
font-size: 15px;
font-weight: 600;
gap: 10px;
padding: 16px 18px;
}
.accordion-button:not(.collapsed) {
background: var(--muted);
box-shadow: none;
color: var(--foreground);
}
.accordion-button:focus {
border-color: var(--border);
box-shadow: 0 0 0 3px var(--ring);
}
.content-item-row {
align-items: center;
display: flex;
gap: 14px;
justify-content: space-between;
}
.content-item-title {
color: var(--foreground);
display: block;
font-weight: 600;
line-height: 1.45;
overflow-wrap: anywhere;
}
.content-item-meta {
color: var(--muted-foreground);
display: block;
font-size: 12px;
margin-top: 3px;
overflow-wrap: anywhere;
}
.report-summary {
background: var(--muted);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.comment-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
}
.empty-state,
.status-panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
text-align: center;
}
.status-panel-danger {
border-color: #fecaca;
}
@media (max-width: 992px) {
.hero-shell {
align-items: flex-start;
flex-direction: column;
}
.hero-metrics {
min-width: 0;
width: 100%;
}
}
@media (max-width: 768px) {
main.container {
padding-left: 14px;
padding-right: 14px;
}
.hero-band {
padding: 22px;
}
.hero-copy h1 {
font-size: 28px;
}
.hero-metrics {
grid-template-columns: 1fr;
}
.section-heading,
.page-heading,
.content-item-row {
align-items: flex-start;
flex-direction: column;
}
.card-body {
padding: 16px;
}
.content-item-row .btn {
width: 100%;
}
.accordion-button {
align-items: flex-start;
}
}
+113
View File
@@ -0,0 +1,113 @@
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();
});
function pollTaskDetailStatus(taskId) {
if (!taskId) return;
const intervalMs = 3000;
const poll = async () => {
try {
const resp = await fetch(`/api/tasks/${taskId}`, { cache: "no-store" });
if (!resp.ok) return;
const task = await resp.json();
if (task.status !== "running") {
window.location.reload();
return;
}
} catch (_error) {
return;
}
window.setTimeout(poll, intervalMs);
};
window.setTimeout(poll, intervalMs);
}
function pollTaskListStatus() {
const tableBody = document.querySelector("[data-task-list-auto-poll='true']");
if (!tableBody) return;
const intervalMs = 5000;
const poll = async () => {
try {
const resp = await fetch("/api/tasks", { cache: "no-store" });
if (!resp.ok) return;
const tasks = await resp.json();
const hasRunningTask = tasks.some((task) => task.status === "running");
window.location.reload();
if (!hasRunningTask) return;
} catch (_error) {
return;
}
window.setTimeout(poll, intervalMs);
};
window.setTimeout(poll, intervalMs);
}
+1
View File
@@ -0,0 +1 @@
+32
View File
@@ -0,0 +1,32 @@
<!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?v=shadcn-ui-refresh" 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 app-navbar">
<div class="container">
<a class="navbar-brand fw-semibold" 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>
</body>
</html>
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}数据库暂时不可用 - 热榜评论分析工具{% endblock %}
{% block content %}
<section class="status-panel status-panel-danger">
<div>
<p class="eyebrow mb-2">数据恢复保护</p>
<h1 class="h3 mb-3">数据库暂时不可用</h1>
<p class="mb-3">{{ message }}</p>
<p class="mb-0 text-muted">请先保留 data 目录,不要删除 app.db、app.db-wal 或 app.db-shm。系统会优先备份现有数据库文件,再进行诊断和恢复。</p>
</div>
</section>
{% if detail %}
<details class="mt-3">
<summary class="text-muted">查看技术细节</summary>
<pre class="mt-2 small">{{ detail }}</pre>
</details>
{% endif %}
{% endblock %}
+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.display_id }}</a></li>
<li class="breadcrumb-item active">汇总报告</li>
</ol></nav>
{% endblock %}
{% block content %}
<div class="page-heading">
<div>
<p class="section-kicker">热点报告</p>
<h1>{{ hotspot.title }} 汇总报告</h1>
</div>
<span class="badge text-bg-light border">热点 #{{ hotspot.rank }}</span>
</div>
{% if report %}
{% include "partials/report_panel.html" %}
{% else %}
<div class="empty-state text-muted py-5">
<div class="spinner-border text-warning mb-3" role="status"></div>
<p class="mb-0">报告生成中,请稍候...</p>
</div>
{% endif %}
{% endblock %}
+101
View File
@@ -0,0 +1,101 @@
{% extends "base.html" %}
{% set has_running_tasks = tasks | selectattr("status", "equalto", "running") | list | length > 0 %}
{% block title %}热榜评论雷达 - 热榜评论分析工具{% endblock %}
{% block content %}
<section class="hero-band mb-4">
<div class="hero-shell">
<div class="hero-copy">
<p class="eyebrow">Hot Comment Radar</p>
<h1>热榜评论雷达</h1>
<p>从小红书和抖音热点出发,抓取真实评论,生成 AI 情绪、标签和可导出的分析报告。</p>
</div>
<div class="hero-metrics" aria-label="默认任务规模">
<div class="hero-metric">
<strong>5</strong>
<span>默认热点</span>
</div>
<div class="hero-metric">
<strong>25</strong>
<span>默认内容</span>
</div>
<div class="hero-metric">
<strong>1,250</strong>
<span>评论上限</span>
</div>
</div>
</div>
</section>
<section class="tool-card mb-4" id="create-task">
<div class="section-heading">
<div>
<p class="section-kicker">采集配置</p>
<h2>创建抓取任务</h2>
</div>
<span class="badge text-bg-light border">默认规模 5 × 5 × 50</span>
</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="tool-card" id="task-history">
<div class="section-heading">
<div>
<p class="section-kicker">任务历史</p>
<h2>最近任务</h2>
</div>
{% if has_running_tasks %}<span class="badge text-bg-warning">自动更新中</span>{% endif %}
</div>
<div class="table-responsive">
<table class="table app-table 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" {% if has_running_tasks %}data-task-list-auto-poll="true"{% endif %}>
{% include "partials/task_rows.html" %}
</tbody>
</table>
</div>
</section>
{% if has_running_tasks %}
<script>document.addEventListener("DOMContentLoaded", () => pollTaskListStatus());</script>
{% endif %}
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
{% 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.display_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="page-heading">
<div>
<p class="section-kicker">内容详情</p>
<h1>{{ item.title or item.source_item_id }}</h1>
</div>
<span class="badge text-bg-light border">{{ item.status }}</span>
</div>
{% if report %}
{% include "partials/report_panel.html" %}
{% else %}
<div class="empty-state text-muted py-5">
<div class="spinner-border text-warning mb-3" role="status"></div>
<p class="mb-0">报告生成中,请稍候...</p>
</div>
{% endif %}
<section class="card"><div class="card-header">评论明细</div><div class="table-responsive">
<table class="table app-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><span class="badge text-bg-light border">{{ comment.sentiment }}</span></td><td>{% for label in comment.labels | from_json %}<span class="badge text-bg-light border me-1">{{ label }}</span>{% else %}<span class="text-muted">-</span>{% 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 %}
+91
View File
@@ -0,0 +1,91 @@
{% set metrics = report.metrics_json | from_json_object %}
{% set typical = report.typical_comments_json | from_json_object %}
{% set sentiment = metrics.get("sentiment", {}) %}
{% set top_labels = metrics.get("top_labels", []) %}
{% set sample_count = metrics.get("sample_count", 0) %}
{% set item_count = metrics.get("item_count") %}
<section class="card mb-4">
<div class="card-body">
{% if task.analysis_status == "insufficient" %}
<div class="alert alert-warning">当前有效评论样本不足,AI 总结暂不可用。建议增加评论抓取数量后重新分析。</div>
{% endif %}
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="text-muted small">评论样本</div>
<div class="h4 mb-0">{{ sample_count }}</div>
</div>
</div>
{% if item_count is not none %}
<div class="col-md-3">
<div class="stat-card">
<div class="text-muted small">关联内容</div>
<div class="h4 mb-0">{{ item_count }}</div>
</div>
</div>
{% endif %}
<div class="col-md-3">
<div class="stat-card">
<div class="text-muted small">AI 成功率</div>
<div class="h4 mb-0">{{ rate_percent(task.analysis_success_rate) }}%</div>
</div>
</div>
</div>
<div class="report-summary mb-4">
<div class="fw-semibold mb-2">AI 总结</div>
<p class="mb-0">{{ report.summary or "总结生成失败,请查看上方统计数据。" }}</p>
</div>
<div class="row g-4">
<div class="col-lg-6">
<h2 class="h5">情绪分布</h2>
{% for key, bar_class in [("positive", "bg-success"), ("neutral", "bg-secondary"), ("negative", "bg-danger"), ("unknown", "bg-warning")] %}
{% set item = sentiment.get(key, {"count": 0, "pct": 0}) %}
<div class="d-flex justify-content-between mb-1">
<span>{{ sentiment_label(key) }}</span>
<span class="text-muted">{{ item.get("count", 0) }} 条({{ item.get("pct", 0) }}%</span>
</div>
<div class="progress mb-3 report-progress" role="progressbar" aria-label="{{ sentiment_label(key) }}情绪占比" aria-valuenow="{{ item.get("pct", 0) }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar {{ bar_class }}" style="width: {{ item.get("pct", 0) }}%"></div>
</div>
{% endfor %}
</div>
<div class="col-lg-6">
<h2 class="h5">Top 标签</h2>
{% if top_labels %}
<div class="d-flex flex-wrap gap-2">
{% for label in top_labels %}
<span class="badge text-bg-light border">{{ label.get("name") }} ({{ label.get("count", 0) }})</span>
{% endfor %}
</div>
{% else %}
<p class="text-muted mb-0">暂无标签数据</p>
{% endif %}
</div>
</div>
</div>
</section>
<section class="card mb-4">
<div class="card-header">典型评论</div>
<div class="card-body">
<div class="row g-3">
{% for key in ["positive", "neutral", "negative"] %}
<div class="col-md-4">
<h3 class="h6">{{ sentiment_label(key) }}</h3>
{% for comment in typical.get(key, []) %}
<div class="comment-card mb-2">
<p class="mb-1">{{ comment.get("content") }}</p>
<small class="text-muted">点赞 {{ comment.get("like_count", 0) }}</small>
</div>
{% else %}
<p class="text-muted small mb-0">暂无{{ sentiment_label(key) }}代表评论</p>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</section>
+45
View File
@@ -0,0 +1,45 @@
{% for task in tasks %}
{% set status_label, status_class = status_badge_config(task.status) %}
{% set percent = progress_percent(task.processed_items_count, task.total_items_count) %}
{% set ai_percent = rate_percent(task.analysis_success_rate) %}
<tr>
<td>
<span class="task-number">{{ task.display_id or loop.index }}</span>
</td>
<td><span class="badge text-bg-light border">{{ task.platform | platform_label }}</span></td>
<td><span class="text-muted small">{{ task.created_at_label }}</span></td>
<td><span class="text-muted small">{{ task.scale_label }}</span></td>
<td class="task-progress-cell">
<div class="d-flex justify-content-between align-items-center gap-2">
<span class="small">已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
<span class="small text-muted">{{ percent }}%</span>
</div>
<div class="progress task-progress mt-1" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar" style="width: {{ percent }}%"></div>
</div>
<div class="progress-summary mt-2">
<span class="small text-muted">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</span>
<span class="small text-muted">评论 {{ task.comments_count or 0 }}</span>
<span class="small text-muted">报告 {{ task.reports_count or 0 }}</span>
</div>
<div class="small text-muted mt-1">阶段:{{ task.current_stage_label or "等待启动" }}</div>
<div class="small {% if task.analysis_status == 'insufficient' %}text-warning{% else %}text-muted{% endif %}">
AI 成功率 {{ ai_percent }}%
{% if task.analysis_status == "insufficient" %}
<span class="badge text-bg-warning ms-1">AI 样本不足</span>
{% endif %}
</div>
</td>
<td>
<span class="badge {{ status_class }}">{{ status_label }}</span>
{% if task.error_summary %}
<br><small class="text-danger">{{ task.error_summary }}</small>
{% endif %}
</td>
<td><a class="btn btn-sm btn-outline-secondary" href="/tasks/{{ task.id }}">查看</a></td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center text-muted py-4">还没有任何任务,请在上方创建第一个任务</td>
</tr>
{% endfor %}
+124
View File
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}任务 {{ task.display_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.display_id }}</li>
</ol></nav>
{% endblock %}
{% block content %}
<div class="page-heading mb-3">
<div>
<p class="section-kicker">任务详情</p>
<h1>任务 {{ task.display_id }}</h1>
</div>
{% set label, cls = status_badge_config(task.status) %}
<span class="badge {{ cls }}">{{ label }}</span>
</div>
<section class="tool-card task-overview mb-4" {% if task.status == "running" %}data-task-id="{{ task.id }}" data-auto-poll="true"{% endif %}>
<div class="card-body">
{% set percent = progress_percent(task.processed_items_count, task.total_items_count) %}
{% set ai_percent = rate_percent(task.analysis_success_rate) %}
{% set target_comments = task.hotspot_limit * task.item_limit_per_hotspot * task.comment_limit_per_item %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<span class="badge text-bg-light border">{{ task.platform | platform_label }}</span>
<span class="badge text-bg-light border ms-1">{{ task.current_stage_label or "等待启动" }}</span>
</div>
<p class="mb-0 text-muted small">创建时间 {{ task.created_at_label }} · 阶段 {{ task.current_stage_label or "等待启动" }}</p>
</div>
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center gap-2">
<span>已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
<span class="text-muted">{{ percent }}%</span>
</div>
<div class="progress task-progress mt-2" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar" style="width: {{ percent }}%"></div>
</div>
<div class="progress-summary mt-2">
<span class="small text-muted">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</span>
<span class="small text-muted">AI 成功率 {{ ai_percent }}%</span>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<div class="metric-box">
<div class="text-muted small">目标上限</div>
<strong>{{ task.hotspot_limit }} 热点 × {{ task.item_limit_per_hotspot }} 内容 × {{ task.comment_limit_per_item }} 评论</strong>
<div class="text-muted small">最多 {{ target_comments }} 条评论</div>
</div>
</div>
<div class="col-md-4">
<div class="metric-box">
<div class="text-muted small">实际结果</div>
<strong>实际评论 {{ task.comments_count or 0 }}</strong>
<div class="text-muted small">已获取热点 {{ task.hotspots_count or 0 }} / 目标 {{ task.hotspot_limit }} · 报告 {{ task.reports_count or 0 }}</div>
</div>
</div>
<div class="col-md-4">
<div class="metric-box">
<div class="text-muted small">最近进度</div>
<strong>{{ task.last_progress_ago_label or "暂无记录" }}</strong>
<div class="text-muted small">运行时长 {{ task.running_duration_label or "刚刚" }} · 评论 {{ task.comments_count or 0 }}</div>
</div>
</div>
</div>
{% if task.status == "running" %}
<div class="alert alert-secondary">
小规模通常较快,默认规模可能需要数分钟,取决于 TikHub 与 AI 响应速度。
</div>
{% endif %}
{% if task.is_progress_stale %}
<div class="alert alert-warning">
超过 {{ task.stale_threshold_minutes }} 分钟没有进度更新,可能仍在等待外部接口或 AI 响应;如果长时间不恢复,请刷新状态或查看后端日志。
</div>
{% endif %}
{% if task.status == "success" and (task.comments_count or 0) < target_comments %}
<div class="alert alert-info">少于理论上限通常是内容本身评论不足或平台返回不足,不直接代表任务失败。若失败内容数大于 0,请结合失败原因判断。</div>
{% endif %}
{% if task.analysis_status == "insufficient" %}
<div class="alert alert-warning">AI 成功率 {{ ai_percent }}%,当前样本可能不足,报告结论请结合评论明细判断。</div>
{% endif %}
{% if task.error_summary %}<div class="alert alert-danger">{{ task.error_summary }}</div>{% endif %}
</div>
</section>
{% if task.status == "running" and not hotspots %}
<div class="empty-state text-muted py-5"><div class="spinner-border text-warning mb-3"></div><p class="mb-0">正在抓取热点数据,请稍候...</p></div>
{% endif %}
<div class="accordion" id="hotspot-list">
{% for hotspot in hotspots %}
<div class="accordion-item hotspot-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 }}">
<span class="badge text-bg-light border">#{{ hotspot.rank }}</span>
<span>热点 {{ hotspot.rank }}{{ hotspot.title }}</span>
</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">
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
<span class="text-muted small">内容条目 {{ hotspot.content_items | length }}</span>
<a class="btn btn-sm btn-outline-primary" href="/hotspots/{{ hotspot.id }}/report">查看汇总报告</a>
</div>
<ul class="list-group list-group-flush">
{% for item in hotspot.content_items %}
<li class="list-group-item px-0">
<div class="content-item-row">
<span>
<span class="content-item-title">{{ item.title or item.summary or item.source_item_id }}</span>
<span class="content-item-meta">状态 {{ item.status }} · ID {{ item.source_item_id }}</span>
</span>
<a class="btn btn-sm btn-outline-secondary" href="/items/{{ item.id }}">查看详情</a>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endfor %}
</div>
{% if task.status == "running" %}
<script>document.addEventListener("DOMContentLoaded", () => pollTaskDetailStatus("{{ task.id }}"));</script>
{% endif %}
{% endblock %}
+66
View File
@@ -0,0 +1,66 @@
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 []
def from_json_object_filter(value: str | None) -> dict:
try:
parsed = json.loads(value) if value else {}
except (TypeError, json.JSONDecodeError):
return {}
return parsed if isinstance(parsed, dict) else {}
def progress_percent(processed_count: int, total_count: int) -> int:
if total_count <= 0:
return 0
return min(100, max(0, round((processed_count / total_count) * 100)))
def rate_percent(rate: float | None) -> int:
if rate is None:
return 0
return min(100, max(0, round(rate * 100)))
def sentiment_label(sentiment: str) -> str:
return {
"positive": "正向",
"neutral": "中性",
"negative": "负向",
"unknown": "未知",
}.get(sentiment, sentiment)
templates.env.globals["status_badge_config"] = status_badge_config
templates.env.globals["progress_percent"] = progress_percent
templates.env.globals["rate_percent"] = rate_percent
templates.env.globals["sentiment_label"] = sentiment_label
templates.env.filters["platform_label"] = platform_label
templates.env.filters["from_json"] = from_json_filter
templates.env.filters["from_json_object"] = from_json_object_filter
+11
View File
@@ -0,0 +1,11 @@
name: hot-comments-tool
services:
app:
build: .
ports:
- "8000:8000"
env_file:
- .env
volumes:
- ./data:/app/data
BIN
View File
Binary file not shown.
+861
View File
@@ -0,0 +1,861 @@
# CodexPrompts.mdT01-T23 最小发送单元
## 使用说明
本文为每个 P0 Task 提供可复制发送给 Codex 的最小任务指令。
依据文件:
- `AGENTS.md`(当前仓库未发现 `docs/AGENTS.md`,规则文件位于仓库根目录)
- `docs/Tasks.md`
- `docs/TaskDependency.md`
- `docs/TDD.md`
全局执行口径:
- 严格 TDD:先写失败测试,再写最小实现,再重构。
- 不使用真实 TikHub / AI API;所有外部依赖必须 mock。
- 后台任务使用 `ThreadPoolExecutor(max_workers=1)` 和同步 `httpx.Client`
- T13 固定 `AI_BATCH_SIZE=20`,不实现 batch size 减半。
- 报告总结失败默认文案统一为:`总结生成失败,请查看上方统计数据。`
- 当前处于初始单人开发和流程练习阶段,默认一次只发送一个 Task,不启用并行。
- 每个 Task 使用一个 `feat/tXX-short-description` 分支;完成验证后优先创建一个 focused commit。
- 不创建 PR,除非用户明确要求。
- 若未来启用并行,涉及 `docs/Tasks.md` 勾选项时由主协调者统一勾选,避免文档冲突。
## 当前推荐执行顺序
```text
T01 -> 审查/验证/commit
T02 -> 审查/验证/commit
T03 -> 审查/验证/commit
...
T23 -> 最终验收
```
当前阶段不要提前并行发送 T05/T06、T08/T09、T18/T19。等串行流程跑顺后,再由用户明确切换到并行模式。
## 未来并行发送参考
| 分组 | 任务 | 发送方式 |
|---|---|---|
| Group 1 | T01 -> T02 -> T03 -> T04 | 串行 |
| Group 2 | T05 + T06 | 可小心并行,需合并 `app/main.py` |
| Group 3 | T07 -> (T08 + T09) -> T10 -> T11 | T08/T09 可并行 |
| Group 4 | T12 -> T13 -> T14 -> T15 -> T16 | 基本串行 |
| Group 5 | T17 -> (T18 + T19 + T20 service 部分) -> T21 | T18/T19 可并行,T20 service 可并行 |
| Group 6 | T22 -> T23 | 串行;T22 可与页面收尾低冲突并行 |
---
### T01 任务指令
## 任务
完成 T01:初始化项目结构与依赖。
## 前置
前置任务:无。
并行发送:不可并行。T01-T04 是基础骨架,必须串行。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T01 部分
- `docs/TDD.md` §2、§3、§5.1
- `docs/DevelopmentPlan.md` §2、§4
## 分支
在分支 `feat/t01-project-skeleton` 上开发。
## 输出要求
- 创建 FastAPI 单体项目基础目录:`app/``app/services/``app/platforms/``app/templates/``app/static/``tests/`
- 创建基础文件:`app/main.py``app/config.py``app/db.py``app/models.py``app/schemas.py`、依赖文件、`.env.example`
- 实现 `/health`,返回 `{ "status": "ok" }`
- 先写 `tests/unit/test_config.py``/health` 集成测试,再实现代码。
- 运行并通过:`pytest tests/unit tests/integration -q`
## 边界
- 只做 T01,不实现任务 API、数据库模型细节或页面。
- 不提交真实 key、token、cookie。
-`pyproject.toml``requirements.txt` 二选一,按现有仓库风格;若没有风格,优先 `pyproject.toml`
---
### T02 任务指令
## 任务
完成 T02:配置管理与环境变量。
## 前置
前置任务:T01。
并行发送:不可并行。需等待 T01 项目骨架完成。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T02 部分
- `docs/TDD.md` §3.2、§5.1
- `docs/DevelopmentPlan.md` §2.1、§12.1
## 分支
在分支 `feat/t02-config-env` 上开发。
## 输出要求
- 修改 `app/config.py`,集中管理 TikHub、AI、数据库、HTTP 超时和任务配置。
- 修改 `.env.example`,只保留变量名和示例空值,不包含真实 key。
- 先写配置默认值和环境变量覆盖测试。
- 覆盖 `AI_CONCURRENCY=2`,硬上限不超过 3`AI_MAX_RETRIES=3``CRAWL_PAGE_INTERVAL_SECONDS` 默认 1.5 且可从环境变量读取。
- 运行并通过:`pytest tests/unit/test_config.py -q`
## 边界
- 只做配置,不实现数据库模型、任务 API、外部 HTTP client。
- 不引入超出计划的配置系统或服务发现。
---
### T03 任务指令
## 任务
完成 T03:数据库初始化与模型。
## 前置
前置任务:T01、T02。
并行发送:不可并行。模型是后续所有任务的共享基础。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T03 部分
- `docs/TDD.md` §3.2、§5.2、§5.3
- `docs/DevelopmentPlan.md` §5
## 分支
在分支 `feat/t03-database-models` 上开发。
## 输出要求
- 修改 `app/db.py``app/models.py`
- 建立 SQLite + SQLAlchemy Base、engine、session。
- 启用 `check_same_thread=False``timeout=10`、WAL。
- 定义 `tasks``hotspots``content_items``comments``reports` 表。
- 实现任务状态、AI 分析状态、进度字段、报告字段和建议索引。
- 先写 `tests/unit/test_models.py`,断言所有表可创建、状态规则正确、`analysis_status=insufficient` 不改变 `Task.status`
- 运行并通过:`pytest tests/unit/test_models.py -q`
## 边界
- 只做模型和数据库初始化,不实现任务创建 API 或抓取流程。
- 不引入 Alembic。
---
### T04 任务指令
## 任务
完成 T04:任务创建 API 与单任务执行器。
## 前置
前置任务:T03。
并行发送:不可并行。该任务会修改核心 API、schema 和 task service。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T04 部分
- `docs/TDD.md` §6.1、§6.2
- `docs/DevelopmentPlan.md` §2.1、§9.3
## 分支
在分支 `feat/t04-task-api-executor` 上开发。
## 输出要求
- 修改 `app/schemas.py``app/main.py`
- 创建 `app/services/task_service.py`
- 实现 `POST /api/tasks``GET /api/tasks``GET /api/tasks/{task_id}`
- 实现 `ThreadPoolExecutor(max_workers=1)`
- 当已有 `status=running` 任务时,`POST /api/tasks` 返回 HTTP 400,响应体为 `{"detail": "当前有正在运行的任务,请稍后再试"}`,且不创建新任务。
- 先写 `tests/integration/test_task_creation.py``tests/unit/test_task_executor.py`
- 运行并通过:`pytest tests/integration/test_task_creation.py tests/unit/test_task_executor.py -q`
## 边界
- 只做任务创建、查询和执行器框架,不实现真实抓取、AI、报告。
- 不新增任务状态,任务主状态仅 `running` / `success` / `failed`
---
### T05 任务指令
## 任务
完成 T05:僵尸任务恢复。
## 前置
前置任务:T04。
当前发送方式:串行发送。未来可与 T06 小心并行,但会共同修改 `app/main.py`,需主协调者合并。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T05 部分
- `docs/TDD.md` §6.3
- `docs/DevelopmentPlan.md` §4.3
## 分支
在分支 `feat/t05-task-recovery` 上开发。
## 输出要求
- 修改 `app/services/task_service.py``app/main.py`
- 应用 lifespan 启动时,将遗留 `status=running` 的任务标记为 `failed`
- 写入 `error_stage=system``error_type=unexpected_restart``error_message=系统重启,任务被中断`
- 先写 `tests/integration/test_task_recovery.py`
- 运行并通过:`pytest tests/integration/test_task_recovery.py -q`
## 边界
- 只做启动恢复,不实现任务取消、补跑或复杂恢复。
- 若与 T06 并行,不要重构无关 route 结构。
---
### T06 任务指令
## 任务
完成 T06:首页 / 任务列表基础页面。
## 前置
前置任务:T04。
当前发送方式:串行发送。未来可与 T05 小心并行,但会共同修改 `app/main.py`,需主协调者合并。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T06 部分
- `docs/TDD.md` §12.1、§12.2、§12.4
- `docs/UIDesign.md`
## 分支
在分支 `feat/t06-index-task-list` 上开发。
## 输出要求
- 创建 `app/templates/base.html``app/templates/index.html``app/templates/partials/task_rows.html`
- 创建 `app/static/app.css``app/static/app.js`
- 修改 `app/main.py`,提供首页页面 route。
- 首页包含任务表单、任务列表、手动刷新、默认规模预估 1250。
- 任务创建成功后前端跳转至 `/tasks/{new_task_id}`
- 实现基础面包屑 block。
- 先写 `tests/integration/test_routes.py``tests/unit/test_template_filters.py` 中相关测试。
- 运行并通过:`pytest tests/integration/test_routes.py tests/unit/test_template_filters.py -q`
## 边界
- 只做首页和任务列表基础,不做任务详情、报告页、导出服务。
- 状态文案不要散落硬编码,优先集中映射或 macro。
---
### T07 任务指令
## 任务
完成 T07:外部 API 基础客户端与重试。
## 前置
前置任务:T02。
并行发送:不可并行。T08/T09 依赖本任务完成。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T07 部分
- `docs/TDD.md` §4.4、§6.2、§8.2、§13.3
- `docs/DevelopmentPlan.md` §6.1
## 分支
在分支 `feat/t07-api-client-retry` 上开发。
## 输出要求
- 创建 `app/platforms/base.py``app/services/crawl_service.py`
- 封装同步 `httpx.Client` 调用、20s 超时、429 指数退避 1s -> 2s -> 4s、非 429 网络错误重试。
- 超过重试次数后返回或抛出可被上层捕获的结构化错误,不泄露 API key。
- 新增 `tests/fixtures/http_429_response.json` 或等价 fixture。
-`tests/conftest.py` 补充 429/httpx mock fixture。
- 先写 `tests/unit/test_comment_pagination.py``tests/integration/test_failure_tolerance.py` 中相关测试。
- 运行并通过:`pytest tests/unit/test_comment_pagination.py tests/integration/test_failure_tolerance.py -q`
## 边界
- 只做通用客户端和重试基础,不实现小红书/抖音字段映射。
- 禁止使用 `httpx.AsyncClient`
---
### T08 任务指令
## 任务
完成 T08:小红书热点、笔记、评论字段映射。
## 前置
前置任务:T07。
当前发送方式:串行发送。未来可与 T09 并行发送;若并行,绝对不要修改 `app/platforms/douyin.py`
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T08 部分
- `docs/TDD.md` §4.1、§4.5、§7.1、§7.2
- `docs/API-Spike-Xiaohongshu.md`
- `docs/DevelopmentPlan.md` §6.2
## 分支
在分支 `feat/t08-xiaohongshu-mapping` 上开发。
## 输出要求
- 创建 `app/platforms/xiaohongshu.py`
- 创建或补齐 fixtures`xhs_hot_list.json``xhs_search_notes.json``xhs_comments_page_1.json``xhs_comments_page_2_empty.json``xhs_comments_missing_fields.json`
- 实现 `fetch_hotspots()``search_items_by_hotspot()``fetch_comments()` 的最小字段映射。
- 热点读取 `data.data.items[]`,不误用外层 `data.data.title`
- 笔记优先选择 `comments_count > 0`,不足时补充 `comments_count = 0`
- 评论 ID 优先 `comment_id`,回退 `id`;保存 raw_data。
- 字段缺失不导致整批任务崩溃。
- 先写并通过:`pytest tests/unit/test_xiaohongshu_mapping.py -q`
## 边界
- 只做小红书字段映射和最小抓取链路,不做分页通用停止条件和去重。
- 不发起真实 TikHub 请求。
---
### T09 任务指令
## 任务
完成 T09:抖音热点、视频、评论字段映射。
## 前置
前置任务:T07。
当前发送方式:串行发送。未来可与 T08 并行发送;若并行,绝对不要修改 `app/platforms/xiaohongshu.py`
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T09 部分
- `docs/TDD.md` §4.2、§4.5、§7.4、§7.5
- `docs/API-Spike-Douyin.md`
- `docs/DevelopmentPlan.md` §6.3
## 分支
在分支 `feat/t09-douyin-mapping` 上开发。
## 输出要求
- 创建 `app/platforms/douyin.py`
- 创建或补齐 fixtures`douyin_hot_list.json``douyin_search_videos.json``douyin_comments_page_1.json``douyin_comments_missing_fields.json`
- 实现 `fetch_hotspots()``search_items_by_hotspot()``fetch_comments()` 的最小字段映射。
- 热点映射 `query_id``title``rank``hot_score`
- 视频映射 `aweme_info.aweme_id``desc``author``statistics`
- 评论 ID 优先 `comment_id`,回退 `cid`;保存 raw_data。
- 字段缺失不导致整批任务崩溃。
- 先写并通过:`pytest tests/unit/test_douyin_mapping.py -q`
## 边界
- 只做抖音字段映射和最小抓取链路,不做分页通用停止条件和去重。
- 不发起真实 TikHub 请求。
---
### T10 任务指令
## 任务
完成 T10:评论分页、间隔与去重。
## 前置
前置任务:T08、T09、T02。
并行发送:不可并行。T10 会同时修改两个平台 adapter 和 `crawl_service.py`
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T10 部分
- `docs/TDD.md` §8.1、§8.2、§8.3
- `docs/DevelopmentPlan.md` §6.4
## 分支
在分支 `feat/t10-comment-pagination` 上开发。
## 输出要求
- 修改 `app/platforms/xiaohongshu.py``app/platforms/douyin.py``app/services/crawl_service.py`
- 实现分页停止条件:达到评论数上限、空列表、最大 5 页、无下一页游标、连续失败超过重试次数。
- 实现小红书 cursor / index 推进;抖音 cursor 推进。
- 每次分页请求之间使用 1-2 秒基础间隔,默认读取 `CRAWL_PAGE_INTERVAL_SECONDS=1.5`
- 实现同一任务同一内容条目同一评论 ID 去重。
- 先写并通过:`pytest tests/unit/test_comment_pagination.py -q`
## 边界
- 只做评论分页、间隔、去重,不做任务主流程集成。
- 不改变 T08/T09 已验证的字段映射语义。
---
### T11 任务指令
## 任务
完成 T11:抓取任务主流程集成。
## 前置
前置任务:T04、T07、T08、T09、T10。
并行发送:不可并行。该任务集中修改任务主流程。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T11 部分
- `docs/TDD.md` §13.1、§13.2、§13.3、§13.4、§13.5
- `docs/DevelopmentPlan.md` §4.3、§13
## 分支
在分支 `feat/t11-crawl-task-flow` 上开发。
## 输出要求
- 修改 `app/services/task_service.py``app/services/crawl_service.py`
- 串起热点、内容条目、评论抓取并入库。
- 更新 `processed_items_count``successful_items_count``failed_items_count`
- 每处理完一个内容条目立即 `session.commit()`
- 单个内容条目失败不阻断整批;热点接口失败导致任务 failed。
- 无任何内容条目成功时任务 failed;至少一个内容条目成功时任务 success。
- 覆盖跨热点重复 `source_item_id` 保留、同一热点重复去重。
- 先写并通过:`pytest tests/integration/test_task_flow_xiaohongshu.py tests/integration/test_task_flow_douyin.py tests/integration/test_failure_tolerance.py -q`
## 边界
- 只做抓取主流程,不接入 AI 分析和报告生成。
- 不引入多 worker 或异步任务系统。
---
### T12 任务指令
## 任务
完成 T12:AI Prompt 与结构化输出校验。
## 前置
前置任务:T03、T02。
当前发送方式:串行发送。不建议与 T13 并行;未来最多仅并行准备 T14 报告统计测试数据,但不要实现 T14。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T12 部分
- `docs/TDD.md` §4.3、§4.6、§9.1、§9.2
- `docs/DevelopmentPlan.md` §7.1、§7.2
## 分支
在分支 `feat/t12-ai-schema` 上开发。
## 输出要求
- 创建 `app/services/ai_service.py`
- 创建 `app/prompts/comment_analysis.txt`
- 创建或补齐 fixtures`ai_comments_success.json``ai_comments_invalid_json.txt``ai_comments_all_sentiments.json`
- 实现 prompt 构造,输入包含 `comment_id` 和截断至 150 字的 `content`
- AI 输出必须是 JSON Array,并用 Pydantic/schema 校验。
- 校验 sentiment 枚举、labels 最多 3 个、comment_id 必须匹配输入。
- 单条失败标记 `ai_analysis_status=failed`
- 先写并通过:`pytest tests/unit/test_ai_schema.py -q`
## 边界
- 只做 prompt 和 schema 校验,不做 AI 重试并发和任务流程接入。
- 不调用真实 AI 服务。
---
### T13 任务指令
## 任务
完成 T13:AI 重试、成功率统计。
## 前置
前置任务:T12、T04。
并行发送:不可并行。不要与 T16 同时修改 `task_service.py`
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T13 部分
- `docs/TDD.md` §9.3、§9.4、§9.5
- `docs/TaskDependency.md` §2.1
- `docs/DevelopmentPlan.md` §7.3、§7.4(若与 Tasks 冲突,以本指令和 Tasks 为准)
## 分支
在分支 `feat/t13-ai-retry-quality` 上开发。
## 输出要求
- 修改 `app/services/ai_service.py` 和必要的 `app/services/task_service.py` 质量状态逻辑。
- 固定 batch size 为 20,不做动态缩减,不实现 batch size 减半。
- 整批 JSON 解析失败时整批重试,最多 3 次。
- 第 3 次仍失败时,该批次全部评论标记 `ai_analysis_status=failed`,不阻断其他批次。
- 重试间隔符合 1s -> 2s -> 4s。
- 同一任务最多 2 个 AI 批量请求并发;同一内容条目多批评论串行。
- 实现 `analysis_success_rate``analysis_status`>= 0.8 为 `normal`< 0.8 为 `insufficient`,不改变任务主状态。
- 先写并通过:`pytest tests/unit/test_ai_schema.py -q`
## 边界
- 不接入完整任务流程;T16 负责集成。
- 不使用 `httpx.AsyncClient`
---
### T14 任务指令
## 任务
完成 T14:内容条目级报告生成。
## 前置
前置任务:T12、T13、T03。
并行发送:不建议与 T15 并行。T14/T15 都修改 `report_service.py`
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T14 部分
- `docs/TDD.md` §10.1、§10.2、§10.3、§10.5
- `docs/TaskDependency.md` §2.2
- `docs/DevelopmentPlan.md` §8.1、§8.3
## 分支
在分支 `feat/t14-item-report` 上开发。
## 输出要求
- 创建 `app/services/report_service.py`
- 创建 `app/prompts/report_summary.txt`
- 实现内容条目级报告生成,保存 `metrics_json``typical_comments_json``summary``markdown_content`
- 统计情绪、Top 5 标签、典型评论;典型评论优先点赞数,缺失时按抓取顺序。
- 总结 AI 输入包含统计摘要和典型评论文本,每条评论截断至 150 字。
- 总结 AI 使用纯文本输出,不使用 JSON Schema。
- 内容条目总结超过 200 字时截断。
- 总结 AI 超时或失败时使用默认文案:`总结生成失败,请查看上方统计数据。`
- 先写并通过:`pytest tests/unit/test_report_stats.py -q`
## 边界
- 只做内容条目级报告,不做热点级报告和任务流程接入。
- 不在页面 route 中临时计算统计。
---
### T15 任务指令
## 任务
完成 T15:热点级报告生成。
## 前置
前置任务:T14。
并行发送:不建议并行。T15 扩展 T14 的同一 `report_service.py`
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T15 部分
- `docs/TDD.md` §10.1、§10.2、§10.4、§10.5
- `docs/TaskDependency.md` §2.2
- `docs/DevelopmentPlan.md` §8.2、§8.3
## 分支
在分支 `feat/t15-hotspot-report` 上开发。
## 输出要求
- 修改 `app/services/report_service.py`
- 实现热点级报告生成,聚合热点下所有内容条目。
- 统计内容条目数量、评论样本数、情绪分布、Top 5 标签、典型评论。
- 生成热点级 Markdown,并保存到 `reports`
- 任务完成后报告生成顺序为:先内容条目报告,再热点报告。
- 热点总结超过 300 字时截断。
- 总结 AI 超时或失败时使用默认文案:`总结生成失败,请查看上方统计数据。`
- 先写并通过:`pytest tests/unit/test_report_stats.py -q`
## 边界
- 只做热点级报告,不接入任务主流程。
- 页面展示和导出必须后续读取预生成报告,不在 route 中即时计算。
---
### T16 任务指令
## 任务
完成 T16:AI + 报告集成到任务流程。
## 前置
前置任务:T11、T13、T14、T15。
并行发送:不可并行。该任务是抓取、AI、报告的主集成点。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T16 部分
- `docs/TDD.md` §13.1、§13.2、§13.3、§9.5、§10
- `docs/DevelopmentPlan.md` §4.3、§7、§8、§13
## 分支
在分支 `feat/t16-ai-report-flow` 上开发。
## 输出要求
- 修改 `app/services/task_service.py``app/services/ai_service.py``app/services/report_service.py`
- 在任务流程中调用 AI 分析和报告生成。
- 任务完成后评论有 sentiment、labelsreports 已入库。
- 写入 `analysis_success_rate``analysis_status`
- 每完成一批 AI 分析(20 条评论)后立即 `session.commit()`
- AI 单批失败不阻断其他批次。
- 扩展并通过:`pytest tests/integration/test_task_flow_xiaohongshu.py tests/integration/test_task_flow_douyin.py tests/integration/test_failure_tolerance.py -q`
## 边界
- 不做页面、导出、Docker。
- 不改变 T13 固定 batch size 策略。
---
### T17 任务指令
## 任务
完成 T17:任务详情页。
## 前置
前置任务:T06、T11;建议 T16 完成后再做以展示 AI/报告状态。
并行发送:T17 是 T18/T19 的前置,不建议并行。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T17 部分
- `docs/TDD.md` §12.1、§12.4
- `docs/UIDesign.md`
## 分支
在分支 `feat/t17-task-detail-page` 上开发。
## 输出要求
- 创建 `app/templates/tasks/detail.html`
- 修改 `app/main.py` 增加任务详情页 route。
- 展示任务概览:任务 ID、平台、创建时间、耗时、状态、AI 分析状态、进度、错误信息。
- 实现热点手风琴列表,默认展开 rank=1。
- 内容条目失败时展示失败原因。
- 任务 running 且热点为空时展示 Spinner。
- 面包屑:首页 -> 任务 `#{task_id}`,首页链接指向 `/`
- 热点报告入口在 T18 前仅需 href 非空。
- 先写并通过:`pytest tests/integration/test_routes.py tests/unit/test_template_filters.py -q`
## 边界
- 只做任务详情页,不实现热点报告页和内容条目详情页。
- 不在模板中做业务统计计算。
---
### T18 任务指令
## 任务
完成 T18:热点级报告页。
## 前置
前置任务:T15、T17。
当前发送方式:串行发送。未来可与 T19 并行,但二者都会修改 `app/main.py``tests/integration/test_routes.py`,需主协调者合并。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T18 部分
- `docs/TDD.md` §12.1、§12.4、§11.3、§11.4
- `docs/UIDesign.md`
## 分支
在分支 `feat/t18-hotspot-report-page` 上开发。
## 输出要求
- 创建 `app/templates/hotspots/report.html`
- 修改 `app/main.py` 增加热点报告页 route。
- 页面读取预生成热点报告。
- 展示热点基础信息、内容条目数量、评论样本数、情绪分布、Top 5 标签、典型评论、AI 总结和分析不足 Alert。
- 添加 Markdown 导出按钮和热点下全部评论 CSV 导出按钮。
- 报告缺失时显示友好状态,不返回 HTTP 500。
- 实现面包屑:首页 -> 任务 -> 热点 -> 汇总报告。
- 先写并通过:`pytest tests/integration/test_routes.py -q`
## 边界
- 只做热点报告页,不实现内容条目详情页。
- 页面不得即时计算报告统计,必须读取 reports 预生成数据。
---
### T19 任务指令
## 任务
完成 T19:内容条目详情页与评论明细。
## 前置
前置任务:T14、T17。
当前发送方式:串行发送。未来可与 T18 并行,但二者都会修改 `app/main.py``tests/integration/test_routes.py`,需主协调者合并。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T19 部分
- `docs/TDD.md` §12.3、§12.4、§12.5
- `docs/UIDesign.md`
## 分支
在分支 `feat/t19-item-detail-page` 上开发。
## 输出要求
- 创建 `app/templates/items/detail.html`
- 修改 `app/main.py` 增加内容条目详情页 route。
- 展示内容条目基础信息、原始内容链接、内容条目级报告。
- 评论明细最多展示 100 条。
- 评论排序:点赞数降序;点赞数相同或缺失时评论时间降序。
- labels JSON Array 渲染为多个标签块。
- 评论为空时展示空状态。
- 实现面包屑:首页 -> 任务 -> 热点 -> 内容条目。
- 先写并通过:`pytest tests/integration/test_routes.py tests/unit/test_template_filters.py -q`
## 边界
- P1 原始 JSON `<details>` 调试入口不是 P0,除非用户明确要求,不要实现。
- 不实现热点报告页或导出服务。
---
### T20 任务指令
## 任务
完成 T20:导出服务。
## 前置
前置任务:T14、T15、T18、T19。服务层可在 T14/T15 后先做,但 route 和按钮状态需等页面完成。
当前发送方式:串行发送。未来 T20 service/unit-test 部分可与 T18/T19 页面并行;完整 T20 不建议与 T21 并行。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T20 部分
- `docs/TDD.md` §11、§12.5
- `docs/DevelopmentPlan.md` §11
## 分支
在分支 `feat/t20-export-service` 上开发。
## 输出要求
- 创建 `app/services/export_service.py`
- 修改 `app/main.py` 增加导出 routes
- `GET /api/export/items/{item_id}/comments.csv`
- `GET /api/export/hotspots/{hotspot_id}/comments.csv`
- `GET /api/export/items/{item_id}.md`
- `GET /api/export/hotspots/{hotspot_id}.md`
- CSV 使用 `UTF-8-SIG`,字段符合 TDD §11.1。
- labels JSON Array 导出为中文逗号拼接。
- 防 CSV 公式注入:`=``+``-``@` 开头加单引号。
- 评论内容中的换行符替换为空格。
- 文件名安全处理符合 TDD §11.2。
- Markdown 直接读取 `reports.markdown_content`
- 补充导出按钮 disabled 条件测试。
- 先写并通过:`pytest tests/unit/test_export.py tests/integration/test_routes.py -q`
## 边界
- 不做 Excel 导出或 JSON 正式导出。
- 不重新计算报告 Markdown。
---
### T21 任务指令
## 任务
完成 T21:模板宏、过滤器与静态交互。
## 前置
前置任务:T06、T17、T18、T19、T20。
并行发送:不建议并行。T21 是页面共享层收敛任务。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T21 部分
- `docs/TDD.md` §12
- `docs/UIDesign.md`
## 分支
在分支 `feat/t21-template-macros-js` 上开发。
## 输出要求
- 创建 macro`status_badge.html``sentiment_badge.html``label_tags.html`
- 注册 `from_json` Jinja2 filter。
- 修改 `app/static/app.js``app/static/app.css`,实现表单范围校验、规模预估实时计算、导出 Blob 下载。
- 严禁对外部平台内容使用 `|safe`
-`base.html` 中定义 title block。
- 各页面 title 符合 Tasks T21 规范。
- 状态文案集中管理。
- 先写并通过:`pytest tests/unit/test_template_filters.py tests/integration/test_routes.py -q`
## 边界
- 只做共享模板、filter、静态交互收敛,不新增页面功能。
- 不实现 P1 自动轮询或进度条,除非用户明确要求。
---
### T22 任务指令
## 任务
完成 T22Docker Compose 与部署。
## 前置
前置任务:T01、T02、T03;建议 T16/T21 后执行以便完整验收。
当前发送方式:串行发送。未来可与页面 polish 收尾低冲突并行,但会修改 `.env.example`,需避开 T02/T21 的同文件修改。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T22 部分
- `docs/TDD.md` §15.1、§16.2
- `docs/DevelopmentPlan.md` §12.2
## 分支
在分支 `feat/t22-docker-compose` 上开发。
## 输出要求
- 创建 `Dockerfile`
- 创建 `docker-compose.yml`,包含 app 服务和 `./data:/app/data` 数据卷。
- 暴露 8000 端口。
- 容器启动后可初始化数据库。
- SQLite 写入 `./data/app.db`
- Dockerfile 使用非 root 用户 `appuser`
- 确认挂载卷目录对 `appuser` 可写。
- 写测试或验证:容器内进程 `whoami` 不返回 root。
- 运行并通过:
- `docker compose up --build`
- `curl -f http://localhost:8000/health`
## 边界
- 只做 Docker Compose 单服务部署。
- 不引入 PostgreSQL、Redis、Celery、Nginx 或登录系统。
---
### T23 任务指令
## 任务
完成 T23:最终测试与手工验收。
## 前置
前置任务:T01-T22。
并行发送:不可并行。T23 是最终验收和文档收口任务。
请先读取:
- `AGENTS.md`
- `docs/Tasks.md` 中 T23 部分
- `docs/TDD.md` §15.2、§16.1、§16.2
- `docs/TaskDependency.md`
- `README.md`(如存在)
## 分支
在分支 `feat/t23-final-acceptance` 上开发。
## 输出要求
- 运行并通过:
- `pytest tests/unit -q`
- `pytest tests/integration -q`
- `pytest tests/unit tests/integration --cov=app --cov-branch --cov-report=term-missing`
- 若覆盖率低于目标,补测试后再继续。
- 运行 Docker 验收:
- `docker compose up --build`
- `curl -f http://localhost:8000/health`
- 按 T23 手工验收清单验证小红书和抖音默认任务流程。
- 如项目已有 `README.md`,补充启动和验收说明。
- 只有在对应任务真实完成且验证通过后,才更新 `docs/Tasks.md` 勾选项。
- 输出最终验收报告:通过项、失败项、已知限制、已运行命令。
## 边界
- 不新增 P1/P2 功能。
- 不为了通过验收而删除关键测试或降低断言。
- 不提交真实 API key 或用户私密数据。
+199
View File
@@ -0,0 +1,199 @@
# Docker 启动与部署说明
## 固定入口
本项目固定使用 Docker Compose 项目名 `hot-comments-tool`,服务入口固定为:
```bash
http://localhost:8000
```
启动或重建:
```bash
docker compose up -d --build
curl -f http://localhost:8000/health
```
查看容器:
```bash
docker compose ps
```
正常情况下只需要看到一个应用容器:`hot-comments-tool-app-1`
## 端口占用排查
如果 `8000` 被占用,先确认是不是本项目容器:
```bash
lsof -nP -iTCP:8000 -sTCP:LISTEN
docker compose ps
```
如果是旧的同项目容器,执行:
```bash
docker compose down
docker compose up -d --build
```
不要临时改到 `8001`,否则浏览器验收和文档记录会混乱。
## 环境变量
Docker Compose 读取 `.env`,不要把真实 `.env` 提交到 Git。
首次启动前可以从示例文件复制:
```bash
cp .env.example .env
```
必须配置:
```text
TIKHUB_API_KEY=
AI_BASE_URL=
AI_API_KEY=
AI_MODEL=
```
## 数据目录
SQLite 数据固定挂载整个目录:
```yaml
./data:/app/data
```
不要只挂载单个 `app.db` 文件。SQLite WAL 模式会生成:
```text
data/app.db
data/app.db-wal
data/app.db-shm
```
这三个文件必须在同一个挂载目录内。
## 重置本地验收数据库
仅在确认不需要保留历史任务后执行:
```bash
docker compose down
rm -f data/app.db data/app.db-wal data/app.db-shm
docker compose up -d --build
curl -f http://localhost:8000/health
```
重置后历史任务会消失,首页任务列表会从空状态开始。
## 验收命令
```bash
.venv/bin/python -m pytest tests/unit tests/integration -q
docker compose up -d --build
curl -f http://localhost:8000/health
```
## 公网部署决策清单
上线前必须确认以下决策,未经确认不要直接开放公网:
1. 部署方式:云服务器 Docker Compose、PaaS 平台,还是临时内网穿透演示。
2. 访问控制:是否需要访问密码 / 简单登录,或只在可信网络内演示。
3. 成本控制:公网用户是否允许直接消耗真实 TikHub 和 AI Key。
4. Demo 数据:公网环境使用脱敏 Demo 数据,还是允许展示真实抓取结果。
5. 数据生命周期:SQLite 数据是否需要持久保留,以及如何备份 / 重置。
当前建议:在上述问题确认前,只做本地 Docker 验收和部署准备,不把端口直接暴露到公网。
## 公网云服务器部署候选方案
如果确认采用云服务器 Docker Compose,可按以下步骤执行。
上线前准备:
```bash
git pull
cp .env.example .env
```
`.env` 中填写真实 Key
```text
TIKHUB_API_KEY=
AI_BASE_URL=
AI_API_KEY=
AI_MODEL=
```
启动:
```bash
docker compose up -d --build
curl -f http://127.0.0.1:8000/health
```
如果服务器安全组直接开放端口,公网入口为:
```text
http://服务器公网 IP:8000
```
也可以用 Nginx 反向代理到本机 `127.0.0.1:8000`
注意:
- 第一版公网不加访问控制,任何知道地址的人都可以访问页面。
- 创建任务会消耗真实 TikHub 和 AI Key。
- 系统仍保持同一时间只允许一个 running 任务,避免多人同时触发造成成本和稳定性问题。
## 初始化 Demo 数据
公网演示建议先初始化脱敏 Demo 数据:
```bash
docker compose exec app python -m app.demo_seed
```
Demo 数据特点:
- 使用脱敏真实感评论文本。
- 不保存作者昵称、平台原始评论 ID、原始内容 URL 或 raw sensitive data。
- 可以和新创建的真实任务同时出现在任务列表中。
## 数据库异常备份与恢复
如果页面出现数据库不可用提示,先不要删除 `data` 目录。
系统会优先备份现有 SQLite 文件到:
```text
data/corrupt-backups/
```
手动诊断:
```bash
docker compose exec app python - <<'PY'
from app.db import engine
from sqlalchemy import text
with engine.connect() as c:
print(c.execute(text("PRAGMA integrity_check")).fetchall())
print(c.exec_driver_sql("PRAGMA wal_checkpoint(TRUNCATE)").fetchall())
PY
```
如果需要重新初始化演示数据:
```bash
docker compose down
mv data/app.db data/corrupt-backups/app.db.manual.bak
rm -f data/app.db-wal data/app.db-shm
docker compose up -d --build
docker compose exec app python -m app.demo_seed
```
File diff suppressed because it is too large Load Diff
+371
View File
@@ -0,0 +1,371 @@
# TaskDependency.md:任务依赖关系与执行方案
## 1. 文档依据
本分析依据:
- `docs/Tasks.md`
- `docs/DevelopmentPlan.md`
未发现 `docs/review-*.md` 文件。
保存位置确认:本文保存为 `docs/TaskDependency.md`。原因是它属于开发任务执行计划的补充文档,文件名能直接表达用途,且不覆盖现有 source-of-truth 文档。
## 2. 重要冲突与执行口径
### 2.0 当前执行模式:初始阶段先串行
当前项目处于初始单人开发和流程练习阶段。默认执行策略为:
```text
一个 Task -> 一个分支 -> 完成并验证 -> 一个 focused commit -> 审查通过后再进入下一个 Task
```
暂不启用并行开发,暂不创建 PR,除非用户明确要求切换到并行或 PR 工作流。
分支命名沿用 `docs/CodexPrompts.md` 中的 `feat/tXX-short-description`。每个 Task
完成后,优先创建一个能用一句话说明的 commit;若 Task 很大,允许拆成多个有意义、
可测试、可回滚的小 commit,但当前练习阶段优先保持“一 Task 一 commit”。
本文后续并行方案保留为未来提速参考,不是当前默认执行方式。
### 2.1 T13 AI batch 降级策略存在文档冲突
- `docs/Tasks.md` T13 明确要求:`batch size 固定为 20,不做动态缩减`,并写明 `不实现 batch size 减半逻辑`
- `docs/DevelopmentPlan.md` §7.3 与 §16 仍写有:第 3 次重试时 `batch_size` 减半。
执行建议:后续实现前必须由用户确认以哪个文档为准。若按 `AGENTS.md` 的 source-of-truth 顺序,`docs/Tasks.md` 在执行 sequencing 上更具体,且其变更日志明确说明 T13 已移除减半逻辑,因此本文的依赖和并行计划按 T13「固定 batch size、最多 3 次整批重试」建模,但不替代用户确认。
### 2.2 报告总结失败默认文案存在轻微差异
- `docs/Tasks.md` T14 要求默认文案为:`总结生成失败,请查看上方统计数据。`
- `docs/DevelopmentPlan.md` §8.1/§8.2 要求默认文案为:`总结生成失败,请查看详细数据`
执行建议:实现 T14/T15 前需确认统一文案,避免测试和页面展示不一致。
## 3. 依赖关系图(文字版)
### 3.1 主链路依赖
```text
T01 初始化项目结构与依赖
-> T02 配置管理与环境变量
-> T03 数据库初始化与模型
-> T04 任务创建 API 与单任务执行器
-> T05 僵尸任务恢复
-> T06 首页 / 任务列表基础页面
-> T07 外部 API 基础客户端与重试
-> (T08 小红书字段映射 || T09 抖音字段映射)
-> T10 评论分页、间隔与去重
-> T11 抓取任务主流程集成
-> T12 AI Prompt 与结构化输出校验
-> T13 AI 重试、成功率统计
-> T14 内容条目级报告生成
-> T15 热点级报告生成
-> T16 AI + 报告集成到任务流程
-> T17 任务详情页
-> T18 热点级报告页
-> T19 内容条目详情页与评论明细
-> T20 导出服务
-> T21 模板宏、过滤器与静态交互
-> T22 Docker Compose 与部署
-> T23 最终测试与手工验收
```
### 3.2 平台抓取并行依赖
```text
T07 API 客户端与重试
-> T08 小红书字段映射 --\
-> T10 评论分页、间隔与去重 -> T11 抓取任务主流程集成
-> T09 抖音字段映射 ----/
```
### 3.3 页面与导出依赖
```text
T06 首页基础页面
-> T17 任务详情页
T14 内容条目级报告生成 -> T19 内容条目详情页
T15 热点级报告生成 -> T18 热点级报告页
T15 热点级报告生成 -> T20 Markdown 导出
T11 抓取任务集成 -> T20 CSV 导出
T17/T18/T19/T20
-> T21 模板宏、过滤器与静态交互
```
说明:T21 可在 T17-T20 之前先做 macro/filter 的底座,但它会修改多个页面共享文件。为了降低多人文件冲突,建议放到页面任务之后做统一收敛,或指定一个模板负责人先完成共享 macro,再串行接入页面。
### 3.4 P1 可选任务依赖
```text
P1-01 任务列表自动轮询
depends on: T06, T21 的 app.js / partials 基础
P1-02 原始 JSON 调试入口
depends on: T19
P1-03 基础进度条
depends on: T04/T11 进度字段, T06/T17 页面
```
## 4. 任务级前置依赖清单
| 任务 | 必须前置 | 主要原因 |
|---|---|---|
| T01 初始化项目结构与依赖 | 无 | 建立项目、依赖、测试和健康检查基础 |
| T02 配置管理与环境变量 | T01 | 修改 `app/config.py``.env.example` 和配置测试 |
| T03 数据库初始化与模型 | T01, T02 | 数据库 URL 与 SQLAlchemy 初始化依赖配置基础 |
| T04 任务创建 API 与单任务执行器 | T03 | 需要 `tasks` 表、schema、session 和 FastAPI 应用 |
| T05 僵尸任务恢复 | T04 | 依赖任务状态模型、task_service 和应用 lifespan |
| T06 首页 / 任务列表基础页面 | T04 | 页面创建任务和列表展示依赖任务 API |
| T07 外部 API 基础客户端与重试 | T02 | 依赖 TikHub base URL、API key、HTTP timeout/retry 配置 |
| T08 小红书字段映射 | T07 | 依赖通用同步 HTTP client 与错误抽象 |
| T09 抖音字段映射 | T07 | 依赖通用同步 HTTP client 与错误抽象 |
| T10 评论分页、间隔与去重 | T08, T09, T02 | 同时修改两平台分页推进,依赖分页间隔配置 |
| T11 抓取任务主流程集成 | T04, T07, T08, T09, T10 | 串起任务生命周期、平台抓取、入库和容错 |
| T12 AI Prompt 与结构化输出校验 | T03, T02 | 依赖 comments 模型字段、AI 配置和 prompt 目录 |
| T13 AI 重试、成功率统计 | T12, T04 | 依赖 AI schema 校验与 tasks 分析状态字段 |
| T14 内容条目级报告生成 | T12, T13, T03 | 依赖已分析评论、reports 表和报告 prompt |
| T15 热点级报告生成 | T14 | 聚合内容条目级统计,并扩展同一 report_service |
| T16 AI + 报告集成到任务流程 | T11, T13, T14, T15 | 将抓取、AI、报告接入完整任务流程 |
| T17 任务详情页 | T06, T11 | 需要任务进度、热点、内容条目和失败信息 |
| T18 热点级报告页 | T15, T17 | 需要预生成热点报告和任务详情入口 |
| T19 内容条目详情页与评论明细 | T14, T17 | 需要预生成 item 报告、评论 AI 字段和任务详情入口 |
| T20 导出服务 | T14, T15, T18, T19 | Markdown 读取 reports;CSV 读取评论;按钮状态依赖页面模板 |
| T21 模板宏、过滤器与静态交互 | T06, T17, T18, T19, T20 | 收敛各页面状态、情绪、标签、标题、导出交互 |
| T22 Docker Compose 与部署 | T01, T02, T03;建议 T16/T21 后 | 依赖应用可启动、配置齐全、数据库初始化可用;完整验收依赖主功能基本完成 |
| T23 最终测试与手工验收 | T01-T22 | MVP 闭环验收 |
## 5. 可并行执行的任务
并行判断原则:
1. 前置依赖已满足。
2. 任务之间不修改同一核心文件。
3. 若测试文件共享,如 `tests/integration/test_routes.py``tests/unit/test_report_stats.py`,并行时必须提前拆分测试文件或指定一个人负责合并。
### 5.1 强推荐并行
| 并行任务 | 前置条件 | 不冲突理由 |
|---|---|---|
| T08 小红书字段映射 + T09 抖音字段映射 | T07 完成 | 分别修改 `app/platforms/xiaohongshu.py``app/platforms/douyin.py`,测试和 fixtures 独立 |
| T20 导出服务的纯 service/unit-test 部分 + T18/T19 页面模板草稿 | T14, T15 完成 | `export_service.py` 和导出单元测试不触碰页面模板;路由与按钮接入需后续串行 |
| T22 Docker Compose + 页面 polish 收尾 | T01-T03 基础稳定,应用可启动 | Docker 文件与页面模板/CSS/JS 基本独立;`.env.example` 修改需避开 T02 |
### 5.2 可并行但需要合并纪律
| 并行任务 | 前置条件 | 合并纪律 |
|---|---|---|
| T05 僵尸任务恢复 + T06 首页基础页面 | T04 完成 | 都会修改 `app/main.py`T05 聚焦 lifespan/task_serviceT06 聚焦模板路由,需一个人最终合并 main.py |
| T12 AI schema + T14 报告统计测试设计 | T03 完成 | T14 实现依赖 T12/T13,但报告统计测试数据和期望可先写;注意不提前假定 T13 冲突策略 |
| T17 任务详情页 + T20 导出服务测试设计 | T14/T15 基础模型稳定 | T20 的按钮 disabled 测试依赖页面模板,服务层 CSV/Markdown 测试可先行 |
| T18 热点报告页 + T19 内容条目详情页 | T17, T14, T15 完成 | 模板文件不同,但都改 `app/main.py``tests/integration/test_routes.py`,需路由合并约定 |
| T20 导出服务完整任务 + T21 macro/filter 预研 | T14, T15 完成 | T20 route/按钮和 T21 filter/static 会共享 `app/main.py`、模板和静态文件,需拆分边界 |
| P1-01 自动轮询 + P1-03 进度条 | P0 页面完成 | 都会修改 `index.html``task_rows.html``app.js``app.css`,建议同一前端负责人处理 |
### 5.3 不建议并行
| 任务组合 | 原因 |
|---|---|
| T01/T02/T03/T04 | 都修改核心骨架、配置、模型、main.py,依赖强且文件重叠多 |
| T10 与 T08/T09 | T10 会修改两平台分页实现,容易覆盖字段映射阶段改动 |
| T11 与 T04/T10 | T11 集成任务流程依赖任务创建和分页完成,且会集中修改 `task_service.py``crawl_service.py` |
| T13 与 T16 | 都修改 AI 调用接入和 `task_service.py`,应先完成 T13 单元能力,再接入 T16 |
| T14 与 T15 | 都集中修改 `report_service.py``test_report_stats.py`,建议串行;多人时可先约定接口后由同一人合并 |
| T17/T18/T19/T21 同时落地 | 共享 `main.py``test_routes.py``test_template_filters.py``base.html` 和 CSS/JS,冲突概率高 |
## 6. 推荐并行分组方案
### Group 0:文档冲突确认与执行口径冻结
| 内容 | 任务 |
|---|---|
| 目标 | 确认 T13 是否固定 batch size;确认报告总结失败默认文案 |
| 前置 | 无 |
| 输出 | 明确实现口径,可写入 `docs/Tasks.md` 或新 review 文档 |
| 文件冲突风险 | 低;若修改 docs,则只改相关文档 |
### Group 1:项目基础串行启动
| 顺序 | 任务 | 说明 |
|---|---|---|
| 1 | T01 | 项目结构、依赖、`/health` |
| 2 | T02 | 配置和 `.env.example` |
| 3 | T03 | 数据库和模型 |
| 4 | T04 | 任务 API 和单 worker 执行器 |
文件冲突风险:
- 高度串行,不建议多人同时做。
- 主要冲突文件:`app/main.py``app/config.py``app/db.py``app/models.py``app/schemas.py``tests/conftest.py`
### Group 2:任务恢复与首页基础
| 可并行子组 | 任务 | 负责人边界 |
|---|---|---|
| 2A | T05 僵尸任务恢复 | `task_service.py` 恢复函数、lifespan 测试 |
| 2B | T06 首页 / 任务列表基础页面 | templates/static、首页 route、任务列表测试 |
组内顺序:
```text
T04 -> (T05 || T06) -> 合并 app/main.py 与路由测试
```
文件冲突风险:
- `app/main.py`T05 注册 lifespanT06 注册页面路由。
- `tests/integration/test_routes.py``tests/integration/test_task_recovery.py` 应分文件,降低冲突。
- `tests/unit/test_template_filters.py` 后续 T21 还会继续修改。
### Group 3:平台抓取并行
| 可并行子组 | 任务 | 负责人边界 |
|---|---|---|
| 3A | T07 外部 API 基础客户端与重试 | 先串行完成,作为平台公共依赖 |
| 3B | T08 小红书字段映射 | `xiaohongshu.py`、xhs fixtures、xhs mapping tests |
| 3C | T09 抖音字段映射 | `douyin.py`、douyin fixtures、douyin mapping tests |
| 3D | T10 评论分页、间隔与去重 | T08/T09 合并后串行完成 |
| 3E | T11 抓取任务主流程集成 | T10 后串行完成 |
组内顺序:
```text
T07 -> (T08 || T09) -> T10 -> T11
```
文件冲突风险:
- T08/T09 冲突低。
- T10 会同时修改 `app/platforms/xiaohongshu.py``app/platforms/douyin.py``app/services/crawl_service.py`,必须等 T08/T09 合并。
- T11 修改 `app/services/task_service.py``app/services/crawl_service.py`,不要和 T10 并行改同一文件。
### Group 4AI 与报告
| 可并行子组 | 任务 | 负责人边界 |
|---|---|---|
| 4A | T12 AI Prompt 与结构化输出校验 | `ai_service.py` 初版、comment prompt、AI fixtures |
| 4B | T13 AI 重试、成功率统计 | T12 后串行扩展 `ai_service.py` 和 task analysis 字段 |
| 4C | T14 内容条目级报告生成 | T13 后实现 `report_service.py` 初版 |
| 4D | T15 热点级报告生成 | T14 后扩展同一 `report_service.py` |
| 4E | T16 AI + 报告集成到任务流程 | T11/T13/T14/T15 后串行接入 |
组内顺序:
```text
T12 -> T13 -> T14 -> T15
T11 ----------------------\
-> T16
```
文件冲突风险:
- T12/T13 都改 `app/services/ai_service.py``tests/unit/test_ai_schema.py`,不建议并行。
- T14/T15 都改 `app/services/report_service.py``tests/unit/test_report_stats.py`,不建议并行。
- T16 同时改 `task_service.py``ai_service.py``report_service.py` 和两平台集成测试,必须在前面服务稳定后做。
### Group 5:页面与导出
| 可并行子组 | 任务 | 负责人边界 |
|---|---|---|
| 5A | T17 任务详情页 | `tasks/detail.html`、任务详情 route |
| 5B | T18 热点级报告页 | `hotspots/report.html`、热点报告 route |
| 5C | T19 内容条目详情页 | `items/detail.html`、评论明细 route |
| 5D | T20 导出服务 | `export_service.py`、export routes、导出测试 |
| 5E | T21 模板宏、过滤器与静态交互 | 共享 macro/static/title/filter 收敛 |
推荐顺序:
```text
T16 -> T17
T17 + T15 -> T18
T17 + T14 -> T19
T14 + T15 -> T20
(T17/T18/T19/T20) -> T21
```
可并行执行:
- T18 与 T19 可并行,但需避免同时大改 `app/main.py`
- T20 服务层可与 T18/T19 页面并行;模板按钮状态接入建议等页面模板稳定后统一做。
- T21 建议作为页面组最后的收敛任务,统一抽 macro、filter、title 和 JS 行为。
文件冲突风险:
- `app/main.py`T17/T18/T19/T20 都会添加路由。
- `tests/integration/test_routes.py`T17/T18/T19/T20 都会扩展。
- `tests/unit/test_template_filters.py`T17/T19/T20/T21 都可能修改。
- `app/templates/base.html``app/static/app.js``app/static/app.css`T06/T20/T21 共享。
### Group 6:部署与最终验收
| 顺序 | 任务 | 说明 |
|---|---|---|
| 1 | T22 Docker Compose 与部署 | 应用主体完成后做部署闭环 |
| 2 | T23 最终测试与手工验收 | 覆盖单元、集成、coverage、Docker、双平台手工流程 |
文件冲突风险:
- T22 主要改 `Dockerfile``docker-compose.yml``.env.example`,与业务代码冲突低。
- `.env.example` 已由 T02 修改,T22 修改前需读取最新文件。
- T23 会修改 `README.md``docs/Tasks.md` 勾选完成项,只能在真实完成并验证后执行。
## 7. 总体执行顺序建议
```text
Group 0 冲突确认
-> Group 1 基础串行启动
-> Group 2 恢复与首页
-> Group 3 平台抓取
-> Group 4 AI 与报告
-> Group 5 页面与导出
-> Group 6 部署与验收
```
若多人协作,最有价值的并行窗口是:
1. T08 与 T09。
2. T18 与 T19。
3. T20 服务层与 T18/T19 页面层。
4. T22 与页面 polish 收尾。
最应避免的并行窗口是:
1. T01-T04 基础骨架。
2. T10/T11 抓取集成。
3. T13/T16 AI 接入。
4. T14/T15 报告服务。
5. T21 共享模板收敛。
## 8. 每组文件冲突风险汇总
| 分组 | 冲突等级 | 高风险文件 | 风险说明 | 建议 |
|---|---|---|---|---|
| Group 1 基础 | 高 | `app/main.py`, `app/config.py`, `app/models.py`, `app/schemas.py`, `tests/conftest.py` | 基础文件会被连续扩展,接口和模型尚未稳定 | 串行执行 |
| Group 2 恢复与首页 | 中 | `app/main.py`, `tests/integration/test_routes.py`, `tests/unit/test_template_filters.py` | lifespan、页面 route、模板测试可能同时变动 | 分清 route/lifespan 修改边界,最终统一合并 |
| Group 3 平台抓取 | 中 | `app/services/crawl_service.py`, `app/platforms/xiaohongshu.py`, `app/platforms/douyin.py`, `tests/unit/test_comment_pagination.py` | T08/T09 低冲突;T10/T11 高耦合 | 只并行 T08/T09T10/T11 串行 |
| Group 4 AI 与报告 | 高 | `app/services/ai_service.py`, `app/services/report_service.py`, `app/services/task_service.py`, `tests/unit/test_ai_schema.py`, `tests/unit/test_report_stats.py` | 服务能力和集成接入依赖强 | T12-T16 基本串行 |
| Group 5 页面与导出 | 高 | `app/main.py`, `tests/integration/test_routes.py`, `tests/unit/test_template_filters.py`, `app/templates/base.html`, `app/static/app.js`, `app/static/app.css` | 多页面和导出按钮都触碰共享模板与路由 | 服务层和模板层拆人,route 合并由一人负责 |
| Group 6 部署验收 | 低到中 | `.env.example`, `docs/Tasks.md`, `README.md` | T22 与 T02 共享 env 示例;T23 会勾选任务 | T22 读取最新 env;T23 只在验证后勾选 |
## 9. 多代理分工建议
如使用多代理并行,建议每个代理使用独立分支,且不要让两个代理同时编辑同一文件。
推荐分支示例:
- `agent/codex-1/T08-xiaohongshu`
- `agent/codex-2/T09-douyin`
- `agent/codex-3/T18-hotspot-report-page`
- `agent/codex-4/T19-item-detail-page`
- `agent/codex-5/T20-export-service`
主代理保留职责:
1. 冻结文档冲突口径。
2. 合并共享文件:`app/main.py``task_service.py``report_service.py`、共享测试文件。
3. 运行完整验证。
4. 更新 `docs/Tasks.md` 复选框。
+37 -37
View File
@@ -101,13 +101,13 @@ pyproject.toml
**步骤**
- [ ] 创建 `app/``app/services/``app/platforms/``app/templates/``app/static/``tests/` 目录。
- [ ] 添加 FastAPI、SQLAlchemy、Pydantic、httpx、Jinja2、pytest、respx、beautifulsoup4 等依赖。
- [ ]`app/main.py` 中创建 FastAPI 应用。
- [ ] 实现 `/health`,返回 `{ "status": "ok" }`
- [ ] 编写 `tests/unit/test_config.py`,覆盖默认配置和环境变量读取。
- [ ] 编写 `/health` 集成测试。
- [ ] 运行 `pytest tests/unit tests/integration -q`
- [x] 创建 `app/``app/services/``app/platforms/``app/templates/``app/static/``tests/` 目录。
- [x] 添加 FastAPI、SQLAlchemy、Pydantic、httpx、Jinja2、pytest、respx、beautifulsoup4 等依赖。
- [x]`app/main.py` 中创建 FastAPI 应用。
- [x] 实现 `/health`,返回 `{ "status": "ok" }`
- [x] 编写 `tests/unit/test_config.py`,覆盖默认配置和环境变量读取。
- [x] 编写 `/health` 集成测试。
- [x] 运行 `pytest tests/unit tests/integration -q`
**验收**
@@ -145,13 +145,13 @@ pyproject.toml
**步骤**
- [ ] 先写配置默认值测试。
- [ ] 实现 Pydantic Settings 或等价配置类。
- [ ] 校验 `AI_CONCURRENCY` 默认值为 2,硬上限不超过 3。
- [ ] 校验 `AI_MAX_RETRIES` 默认值为 3。
- [ ] 补充 `.env.example`
- [ ] 写测试:`CRAWL_PAGE_INTERVAL_SECONDS` 可从环境变量读取,默认值为 1.5。
- [ ]`.env.example` 中补充该配置项及注释说明。
- [x] 先写配置默认值测试。
- [x] 实现 Pydantic Settings 或等价配置类。
- [x] 校验 `AI_CONCURRENCY` 默认值为 2,硬上限不超过 3。
- [x] 校验 `AI_MAX_RETRIES` 默认值为 3。
- [x] 补充 `.env.example`
- [x] 写测试:`CRAWL_PAGE_INTERVAL_SECONDS` 可从环境变量读取,默认值为 1.5。
- [x]`.env.example` 中补充该配置项及注释说明。
**验收**
@@ -178,15 +178,15 @@ pyproject.toml
**步骤**
- [ ]`tests/unit/test_models.py`,断言所有表可创建。
- [ ] 实现 SQLAlchemy Base、engine、session。
- [ ] SQLite 启用 `check_same_thread=False``timeout=10`、WAL。
- [ ] 实现 `Base.metadata.create_all(engine)` 启动初始化。
- [ ] 实现 `tasks.analysis_status``analysis_success_rate`、进度字段。
- [ ] 实现 reports 表业务约束在应用层校验所需字段。
- [ ] 【性能预留索引,不影响功能验收】在 comments 表上添加 `(task_id, content_item_id)` 联合索引。
- [ ] 【性能预留索引,不影响功能验收】在 content_items 表上添加 `(task_id, hotspot_id)` 联合索引。
- [ ] 【性能预留索引,不影响功能验收】在 reports 表上添加 `(task_id, report_type)` 联合索引。
- [x]`tests/unit/test_models.py`,断言所有表可创建。
- [x] 实现 SQLAlchemy Base、engine、session。
- [x] SQLite 启用 `check_same_thread=False``timeout=10`、WAL。
- [x] 实现 `Base.metadata.create_all(engine)` 启动初始化。
- [x] 实现 `tasks.analysis_status``analysis_success_rate`、进度字段。
- [x] 实现 reports 表业务约束在应用层校验所需字段。
- [x] 【性能预留索引,不影响功能验收】在 comments 表上添加 `(task_id, content_item_id)` 联合索引。
- [x] 【性能预留索引,不影响功能验收】在 content_items 表上添加 `(task_id, hotspot_id)` 联合索引。
- [x] 【性能预留索引,不影响功能验收】在 reports 表上添加 `(task_id, report_type)` 联合索引。
**验收**
@@ -214,16 +214,16 @@ pyproject.toml
**步骤**
- [ ] 写创建任务 API 测试,合法参数返回 `task_id``status`
- [ ] 写非法参数测试:平台非法、热点数量越界、内容条目数越界、评论数越界。
- [ ] 实现 `CreateTaskRequest` schema。
- [ ] 实现 `ThreadPoolExecutor(max_workers=1)`
- [ ]`CreateTaskRequest` 处理逻辑中,查询当前是否存在 `status=running` 的任务;若存在,直接返回 HTTP 400,响应体为 `{"detail": "当前有正在运行的任务,请稍后再试"}`,不创建新任务。
- [ ] 写测试:当已有 `status=running` 任务时,`POST /api/tasks` 返回 400。
- [ ] 写测试:当无 running 任务时,`POST /api/tasks` 正常创建并返回 200/201。
- [ ] 创建任务时保存平台、配置规模、创建时间、状态和进度字段。
- [ ] 同一平台重复创建任务时生成独立任务。
- [ ] 实现任务列表查询。
- [x] 写创建任务 API 测试,合法参数返回 `task_id``status`
- [x] 写非法参数测试:平台非法、热点数量越界、内容条目数越界、评论数越界。
- [x] 实现 `CreateTaskRequest` schema。
- [x] 实现 `ThreadPoolExecutor(max_workers=1)`
- [x]`CreateTaskRequest` 处理逻辑中,查询当前是否存在 `status=running` 的任务;若存在,直接返回 HTTP 400,响应体为 `{"detail": "当前有正在运行的任务,请稍后再试"}`,不创建新任务。
- [x] 写测试:当已有 `status=running` 任务时,`POST /api/tasks` 返回 400。
- [x] 写测试:当无 running 任务时,`POST /api/tasks` 正常创建并返回 200/201。
- [x] 创建任务时保存平台、配置规模、创建时间、状态和进度字段。
- [x] 同一平台重复创建任务时生成独立任务。
- [x] 实现任务列表查询。
**验收**
@@ -243,10 +243,10 @@ pyproject.toml
**步骤**
- [ ] 写测试:数据库中存在 `status=running` 的任务。
- [ ] 应用 lifespan 启动时执行恢复逻辑。
- [ ] 将 running 任务更新为 failed。
- [ ] 写入 `error_stage=system``error_type=unexpected_restart``error_message=系统重启,任务被中断`
- [x] 写测试:数据库中存在 `status=running` 的任务。
- [x] 应用 lifespan 启动时执行恢复逻辑。
- [x] 将 running 任务更新为 failed。
- [x] 写入 `error_stage=system``error_type=unexpected_restart``error_message=系统重启,任务被中断`
**验收**
+230
View File
@@ -0,0 +1,230 @@
# 用户操作与验收说明
## 1. 启动服务
首次使用先准备环境变量:
```bash
cp .env.example .env
```
打开 `.env`,填写:
```text
TIKHUB_API_KEY=
AI_BASE_URL=
AI_API_KEY=
AI_MODEL=
```
启动:
```bash
docker compose up -d --build
curl -f http://localhost:8000/health
```
浏览器打开:
```text
http://localhost:8000
```
## 2. 创建任务
首页选择平台:
- 小红书
- 抖音
可调整抓取规模:
- 热点数:默认 5
- 每热点内容:默认 5
- 每内容评论:默认 50
轻量验收建议使用:
```text
1 热点 × 1 内容 × 10 评论
```
默认规模验收使用:
```text
5 热点 × 5 内容 × 50 评论
```
点击“开始抓取”后会进入任务详情页。
## 3. 查看任务状态
任务状态包括:
- 运行中
- 已完成
- 失败
运行中任务会自动刷新。页面会展示:
- 已处理 / 总内容数
- 成功 / 失败内容数
- AI 成功率
- AI 样本不足提示
- 失败阶段、错误类型和错误信息
如果任务刚开始且还没有热点,会看到“正在抓取热点数据,请稍候...”。
## 4. 查看报告
任务完成后,在任务详情页可以进入:
- 热点级汇总报告
- 内容详情页
报告页面会展示:
- AI 总结
- 评论样本数
- 情绪分布
- Top 标签
- 典型评论
- 评论明细
如果报告尚未生成,页面会显示“报告生成中,请稍候...”,不会返回 500。
## 5. 文件导出
当前版本已关闭 CSV / Markdown 文件下载入口和接口。页面仍可直接查看:
- 热点级汇总报告
- 内容条目级报告
- 评论明细
## 6. 小规模验收
分别创建两个任务:
```text
小红书:1 × 1 × 10
抖音:1 × 1 × 10
```
通过标准:
- 任务最终为“已完成”。
- 至少有热点、内容、评论、报告。
- AI 成功率正常,或页面有明确“AI 样本不足”提示。
- 能打开热点报告、内容详情页。
- 页面不展示 CSV / Markdown 下载入口。
## 7. 默认规模验收
分别创建两个任务:
```text
小红书:5 × 5 × 50
抖音:5 × 5 × 50
```
注意:真实平台接口返回数量可能低于理论上限 1250 条评论。验收时重点判断:
- 任务是否完成。
- 是否有明确失败原因。
- 实际热点、内容、评论、报告数量。
- 页面和导出是否可用。
## 8. 常见问题
### 8000 端口被占用
先确认是否是本项目容器:
```bash
docker compose ps
lsof -nP -iTCP:8000 -sTCP:LISTEN
```
如果是旧容器:
```bash
docker compose down
docker compose up -d --build
```
不要临时改成 8001。
### API Key 缺失
如果任务失败或没有真实数据,检查 `.env`
```text
TIKHUB_API_KEY=
AI_BASE_URL=
AI_API_KEY=
AI_MODEL=
```
### 任务长期 running
先打开任务详情页看进度。如果长时间没有变化:
```bash
docker compose logs --tail=120 app
```
重启服务后,遗留 running 任务会被标记为失败,并显示“系统重启,任务被中断”。
### 评论数量少于请求上限
这通常是平台真实返回不足,不一定是代码失败。以任务状态、成功内容数、报告生成情况和错误信息为准。
### 报告摘要失败
页面会显示默认文案,但统计、标签、典型评论和导出仍应可用。
### 重置本地数据库
仅在不需要保留历史任务时执行:
```bash
docker compose down
rm -f data/app.db data/app.db-wal data/app.db-shm
docker compose up -d --build
curl -f http://localhost:8000/health
```
### 公网云服务器部署
公网部署前先确认部署方式、访问密码、是否允许消耗真实 TikHub 和 AI Key。未经确认不要直接开放公网。
如果确认使用云服务器 Docker Compose,步骤与本地类似:
```bash
git pull
cp .env.example .env
docker compose up -d --build
curl -f http://127.0.0.1:8000/health
```
公网演示建议先初始化脱敏 Demo 数据:
```bash
docker compose exec app python -m app.demo_seed
```
如果页面出现数据库不可用提示,系统会尽量把 SQLite 文件备份到:
```text
data/corrupt-backups
```
## 9. 推荐提交节奏
`docs/MVP-WorkOrders.md` 的工单推进:
```text
完成一个工单 → 跑测试 → Docker 健康检查 → 网页验收 → commit
```
运行数据目录 `data/` 不提交到 Git。
+42
View File
@@ -0,0 +1,42 @@
[project]
name = "hot-comments-analysis-tool"
version = "0.1.0"
description = "FastAPI MVP for Xiaohongshu and Douyin hot topic comment analysis."
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4>=4.12.3",
"fastapi>=0.115.0",
"httpx>=0.27.0",
"jinja2>=3.1.4",
"pydantic>=2.8.0",
"pydantic-settings>=2.4.0",
"sqlalchemy>=2.0.32",
"uvicorn[standard]>=0.30.6",
]
[dependency-groups]
dev = [
"pytest>=8.3.2",
"pytest-cov>=5.0.0",
"pytest-mock>=3.14.0",
"respx>=0.21.1",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
[tool.setuptools.packages.find]
include = ["app*"]
exclude = ["data*", "docs*", "tests*"]
[tool.coverage.run]
branch = true
source = ["app"]
[tool.coverage.report]
omit = [
"app/templates/*",
"app/static/*",
"tests/*",
]
+8
View File
@@ -0,0 +1,8 @@
beautifulsoup4>=4.12.3
fastapi>=0.115.0
httpx>=0.27.0
jinja2>=3.1.4
pydantic>=2.8.0
pydantic-settings>=2.4.0
sqlalchemy>=2.0.32
uvicorn[standard]>=0.30.6
+1
View File
@@ -0,0 +1 @@
"""Test package."""
+1
View File
@@ -0,0 +1 @@
+31
View File
@@ -0,0 +1,31 @@
from collections.abc import Iterator
from contextlib import contextmanager
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.db import Base, get_db_session
from app.main import app
@contextmanager
def make_test_client() -> Iterator[tuple[TestClient, object]]:
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
def override_db_session():
with Session(engine) as session:
yield session
app.dependency_overrides[get_db_session] = override_db_session
try:
yield TestClient(app), engine
finally:
app.dependency_overrides.clear()
engine.dispose()
+1
View File
@@ -0,0 +1 @@
"""Integration tests."""
+149
View File
@@ -0,0 +1,149 @@
from sqlalchemy.orm import Session
from app.models import ContentItem, Task
from app.platforms.base import PlatformAPIError
from app.schemas import CreateTaskRequest
from app.services.task_service import create_task, run_task
from tests.helpers import make_test_client
class FailingHotspotPlatform:
def fetch_hotspots(self, *, limit):
raise PlatformAPIError("hotspot failed", error_type="api_error", status_code=500)
class OneFailedOneSuccessfulItemPlatform:
def fetch_hotspots(self, *, limit):
from app.platforms.base import HotspotData
return [HotspotData(source_hot_id="h1", title="热点一", rank=1)]
def search_items_by_hotspot(self, keyword, *, limit):
from app.platforms.base import ContentItemData
return [
ContentItemData(source_item_id="bad-item", item_type="video", title="失败内容"),
ContentItemData(source_item_id="good-item", item_type="video", title="成功内容"),
]
def fetch_comments(self, source_item_id, *, limit):
from app.platforms.base import CommentData
if source_item_id == "bad-item":
raise PlatformAPIError("comments failed", error_type="rate_limited", status_code=429)
return [CommentData(source_comment_id="c1", content="继续成功")]
class AllItemsFailPlatform:
def fetch_hotspots(self, *, limit):
from app.platforms.base import HotspotData
return [HotspotData(source_hot_id="h1", title="热点一", rank=1)]
def search_items_by_hotspot(self, keyword, *, limit):
from app.platforms.base import ContentItemData
return [
ContentItemData(source_item_id="bad-one", item_type="video", title="失败内容一"),
ContentItemData(source_item_id="bad-two", item_type="video", title="失败内容二"),
]
def fetch_comments(self, source_item_id, *, limit):
raise PlatformAPIError("comments exhausted", error_type="rate_limited", status_code=429)
def test_hotspot_failure_marks_task_failed(monkeypatch):
with make_test_client() as (_client, engine):
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FailingHotspotPlatform())
with Session(engine) as session:
task = create_task(
session,
CreateTaskRequest(platform="douyin", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
submit_background=False,
)
task_id = task.id
run_task(task_id, session_factory=lambda: session)
task = session.get(type(task), task_id)
assert task.status == "failed"
assert task.error_stage == "crawl_hotspots"
assert task.error_type == "api_error"
def test_unexpected_task_exception_marks_task_failed(monkeypatch):
def raise_unexpected_error(_platform):
raise RuntimeError("boom")
with make_test_client() as (_client, engine):
monkeypatch.setattr("app.services.task_service.build_platform", raise_unexpected_error)
with Session(engine) as session:
task = create_task(
session,
CreateTaskRequest(platform="douyin", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
submit_background=False,
)
task_id = task.id
run_task(task_id, session_factory=lambda: session)
task = session.get(type(task), task_id)
assert task.status == "failed"
assert task.error_stage == "system"
assert task.error_type == "unexpected_error"
assert task.error_message == "boom"
def test_failed_content_item_is_recorded_and_following_item_continues(monkeypatch):
with make_test_client() as (_client, engine):
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: OneFailedOneSuccessfulItemPlatform())
monkeypatch.setattr("app.services.task_service.build_ai_dependencies", lambda: (lambda _prompt: "[]", None))
monkeypatch.setattr("app.services.ai_service.time.sleep", lambda _seconds: None)
with Session(engine) as session:
task = create_task(
session,
CreateTaskRequest(platform="douyin", hotspot_limit=1, item_limit_per_hotspot=2, comment_limit_per_item=10),
submit_background=False,
)
task_id = task.id
run_task(task_id, session_factory=lambda: session)
persisted = session.get(Task, task_id)
failed_item = session.query(ContentItem).filter_by(task_id=task_id, source_item_id="bad-item").one()
successful_item = session.query(ContentItem).filter_by(task_id=task_id, source_item_id="good-item").one()
assert persisted.status == "success"
assert persisted.total_items_count == 2
assert persisted.processed_items_count == 2
assert persisted.successful_items_count == 1
assert persisted.failed_items_count == 1
assert failed_item.status == "failed"
assert failed_item.error_stage == "crawl_comments"
assert failed_item.error_type == "rate_limited"
assert successful_item.status == "success"
def test_task_fails_with_visible_reason_when_all_content_items_fail(monkeypatch):
with make_test_client() as (_client, engine):
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: AllItemsFailPlatform())
with Session(engine) as session:
task = create_task(
session,
CreateTaskRequest(platform="douyin", hotspot_limit=1, item_limit_per_hotspot=2, comment_limit_per_item=10),
submit_background=False,
)
task_id = task.id
run_task(task_id, session_factory=lambda: session)
persisted = session.get(Task, task_id)
failed_items = session.query(ContentItem).filter_by(task_id=task_id, status="failed").all()
assert persisted.status == "failed"
assert persisted.total_items_count == 2
assert persisted.processed_items_count == 2
assert persisted.successful_items_count == 0
assert persisted.failed_items_count == 2
assert persisted.error_stage == "crawl_comments"
assert persisted.error_type == "rate_limited"
assert persisted.error_message == "comments exhausted"
assert len(failed_items) == 2
+12
View File
@@ -0,0 +1,12 @@
from fastapi.testclient import TestClient
from app.main import app
def test_health_returns_ok():
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
+736
View File
@@ -0,0 +1,736 @@
from datetime import UTC, datetime, timedelta
from bs4 import BeautifulSoup
from app.models import Comment, ContentItem, Hotspot, Report, Task
from tests.helpers import make_test_client
from sqlalchemy.exc import OperationalError
def test_index_page_renders_task_form_empty_state_and_default_scale():
with make_test_client() as (client, _engine):
response = client.get("/")
assert response.status_code == 200
assert "热榜评论分析工具" in response.text
assert 'name="platform"' in response.text
assert 'name="hotspot_limit"' in response.text
assert 'name="item_limit_per_hotspot"' in response.text
assert 'name="comment_limit_per_item"' in response.text
assert "1250" in response.text
assert "手动刷新" not in response.text
assert 'onclick="window.location.href=\'/\'"' not in response.text
assert 'href="#create-task"' not in response.text
assert 'href="#task-history"' not in response.text
assert "还没有任何任务" in response.text
def test_index_page_lists_existing_tasks():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(
Task(
id="task-visible",
platform="douyin",
status="running",
hotspot_limit=3,
item_limit_per_hotspot=4,
comment_limit_per_item=50,
total_items_count=4,
processed_items_count=2,
successful_items_count=1,
failed_items_count=1,
analysis_success_rate=0.75,
analysis_status="insufficient",
)
)
session.commit()
response = client.get("/")
assert response.status_code == 200
assert "task-visible" in response.text
assert "抖音" in response.text
assert "运行中" in response.text
assert "已处理 2 / 共 4 条内容" in response.text
assert "成功 1 / 失败 1" in response.text
assert 'style="width: 50%"' in response.text
assert "AI 成功率 75%" in response.text
assert "AI 样本不足" in response.text
assert 'data-task-list-auto-poll="true"' in response.text
assert "pollTaskListStatus" in response.text
assert "DOMContentLoaded" in response.text
def test_index_page_uses_short_task_numbers_compact_fields_and_friendly_error_copy():
first_id = "11111111-1111-4111-8111-111111111111"
second_id = "22222222-2222-4222-8222-222222222222"
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(
Task(
id=first_id,
platform="xiaohongshu",
status="failed",
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
hotspot_limit=5,
item_limit_per_hotspot=5,
comment_limit_per_item=50,
error_stage="system",
error_type="unexpected_restart",
error_message="系统重启,任务被中断",
)
)
session.add(
Task(
id=second_id,
platform="douyin",
status="success",
created_at=datetime(2026, 7, 3, 10, 35, tzinfo=UTC),
hotspot_limit=1,
item_limit_per_hotspot=1,
comment_limit_per_item=10,
)
)
session.commit()
response = client.get("/")
assert response.status_code == 200
visible_text = BeautifulSoup(response.text, "html.parser").get_text(" ")
assert " 1 " in f" {visible_text} "
assert " 2 " in f" {visible_text} "
assert "#" not in visible_text
assert first_id not in visible_text
assert second_id not in visible_text
assert "2026-07-03 18:30" in visible_text
assert "2026-07-03 18:35" in visible_text
assert "Demo 数据" not in visible_text
assert "5热点 × 5内容 × 50评论" in visible_text
assert "1热点 × 1内容 × 10评论" in visible_text
assert "小红书 · 5热点" not in visible_text
assert "抖音 · 1热点" not in visible_text
assert "系统重启,任务被中断" in visible_text
assert "system / unexpected_restart" not in visible_text
assert "unexpected_restart" not in visible_text
assert "内部演示工具 | 仅供学习参考" not in visible_text
def test_index_page_does_not_auto_poll_without_running_tasks():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(
Task(
id="task-done",
platform="xiaohongshu",
status="success",
total_items_count=1,
processed_items_count=1,
successful_items_count=1,
analysis_success_rate=1.0,
)
)
session.commit()
response = client.get("/")
assert response.status_code == 200
assert "task-done" in response.text
assert 'data-task-list-auto-poll="true"' not in response.text
assert "pollTaskListStatus" not in response.text
def test_running_task_detail_page_auto_polls_current_task():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(
Task(
id="task-running",
platform="xiaohongshu",
status="running",
hotspot_limit=1,
item_limit_per_hotspot=1,
comment_limit_per_item=10,
total_items_count=2,
processed_items_count=1,
successful_items_count=1,
failed_items_count=0,
analysis_success_rate=1.0,
)
)
session.commit()
response = client.get("/tasks/task-running")
assert response.status_code == 200
assert 'data-task-id="task-running"' in response.text
assert "pollTaskDetailStatus" in response.text
assert "手动刷新" not in response.text
assert 'onclick="window.location.reload()"' not in response.text
assert "已处理 1 / 共 2 条内容" in response.text
assert "AI 成功率 100%" in response.text
def test_task_detail_uses_short_number_title_and_hides_full_uuid():
task_id = "33333333-3333-4333-8333-333333333333"
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(
Task(
id=task_id,
platform="xiaohongshu",
status="running",
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
hotspot_limit=5,
item_limit_per_hotspot=5,
comment_limit_per_item=50,
)
)
session.commit()
response = client.get(f"/tasks/{task_id}")
assert response.status_code == 200
visible_text = BeautifulSoup(response.text, "html.parser").get_text(" ")
assert "<title>任务 1 - 热榜评论分析工具</title>" in response.text
assert "任务 1" in visible_text
assert "完整 ID" not in visible_text
assert task_id not in visible_text
assert "任务 #33333333-3333-4333-8333-333333333333" not in visible_text
assert "#" not in visible_text
assert "创建时间 2026-07-03 18:30" in visible_text
assert "Demo 数据" not in visible_text
def test_demo_task_flag_is_kept_but_demo_copy_is_hidden_from_pages():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(
Task(
id="demo-task-hidden-copy",
platform="xiaohongshu",
status="success",
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
hotspot_limit=1,
item_limit_per_hotspot=1,
comment_limit_per_item=10,
)
)
session.commit()
index_response = client.get("/")
detail_response = client.get("/tasks/demo-task-hidden-copy")
api_response = client.get("/api/tasks/demo-task-hidden-copy")
assert index_response.status_code == 200
assert detail_response.status_code == 200
assert api_response.status_code == 200
assert api_response.json()["is_demo"] is True
assert "Demo 数据" not in BeautifulSoup(index_response.text, "html.parser").get_text(" ")
assert "Demo 数据" not in BeautifulSoup(detail_response.text, "html.parser").get_text(" ")
def test_running_task_detail_page_keeps_polling_after_hotspots_exist():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
task = Task(
id="task-running-with-hotspot",
platform="xiaohongshu",
status="running",
total_items_count=2,
processed_items_count=1,
successful_items_count=1,
analysis_success_rate=1.0,
)
session.add(task)
session.add(
Hotspot(
id="hot-running",
task_id=task.id,
platform="xiaohongshu",
title="运行中的热点",
rank=1,
raw_data="{}",
)
)
session.commit()
response = client.get("/tasks/task-running-with-hotspot")
assert response.status_code == 200
assert "运行中的热点" in response.text
assert "正在抓取热点数据" not in response.text
assert "pollTaskDetailStatus" in response.text
def test_running_task_detail_page_shows_stage_runtime_and_stale_progress_warning(monkeypatch):
now = datetime(2026, 7, 3, 10, 30, tzinfo=UTC)
monkeypatch.setattr("app.services.task_service.utc_now", lambda: now)
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
task = Task(
id="task-stale",
platform="xiaohongshu",
status="running",
current_stage="ai_analysis",
created_at=now - timedelta(hours=2, minutes=5),
last_progress_at=now - timedelta(minutes=12),
hotspot_limit=5,
item_limit_per_hotspot=5,
comment_limit_per_item=50,
total_items_count=10,
processed_items_count=6,
successful_items_count=6,
failed_items_count=0,
analysis_success_rate=0.6,
analysis_status="insufficient",
)
session.add(task)
hotspot = Hotspot(id="hot-stale", task_id=task.id, platform="xiaohongshu", title="运行热点", rank=1, raw_data="{}")
session.add(hotspot)
item = ContentItem(
id="item-stale",
task_id=task.id,
hotspot_id=hotspot.id,
platform="xiaohongshu",
source_item_id="note-stale",
item_type="note",
title="运行内容",
status="pending",
raw_data="{}",
)
session.add(item)
for index in range(3):
session.add(
Comment(
task_id=task.id,
hotspot_id=hotspot.id,
content_item_id=item.id,
platform="xiaohongshu",
source_comment_id=f"c{index}",
content=f"评论 {index}",
raw_data="{}",
)
)
session.commit()
response = client.get("/tasks/task-stale")
assert response.status_code == 200
assert "阶段 AI 分析中" in response.text
assert "运行时长" in response.text
assert "2小时5分钟" in response.text
assert "最近进度" in response.text
assert "12分钟前" in response.text
assert "已获取热点 1 / 目标 5" in response.text
assert "评论 3" in response.text
assert "超过 10 分钟没有进度更新" in response.text
assert "可能仍在等待外部接口或 AI 响应" in response.text
def test_failed_task_detail_page_shows_failure_reason_without_loading_copy():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(
Task(
id="task-failed",
platform="douyin",
status="failed",
error_stage="recover",
error_type="interrupted",
error_message="系统重启,任务被中断",
)
)
session.commit()
response = client.get("/tasks/task-failed")
assert response.status_code == 200
assert "失败" in response.text
assert "系统重启,任务被中断" in response.text
assert "recover / interrupted" not in response.text
assert "正在抓取热点数据" not in response.text
assert "pollTaskDetailStatus" not in response.text
def seed_result_data(engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
task = Task(
id="task-result",
platform="douyin",
status="success",
total_items_count=1,
successful_items_count=1,
analysis_success_rate=1.0,
)
session.add(task)
hotspot = Hotspot(id="hot-result", task_id=task.id, platform="douyin", title="热点标题", rank=1, raw_data="{}")
session.add(hotspot)
item = ContentItem(
id="item-result",
task_id=task.id,
hotspot_id=hotspot.id,
platform="douyin",
source_item_id="v1",
item_type="video",
title="视频标题",
status="success",
raw_data="{}",
)
session.add(item)
session.add(
Comment(
id="comment-result",
task_id=task.id,
hotspot_id=hotspot.id,
content_item_id=item.id,
platform="douyin",
source_comment_id="c1",
content="评论内容",
sentiment="positive",
labels='["认可"]',
like_count=3,
raw_data="{}",
)
)
session.add(
Report(
task_id=task.id,
hotspot_id=hotspot.id,
report_type="hotspot",
title="热点标题",
metrics_json='{"sample_count":1,"item_count":1,"sentiment":{"positive":{"count":1,"pct":100},"neutral":{"count":0,"pct":0},"negative":{"count":0,"pct":0},"unknown":{"count":0,"pct":0}},"top_labels":[{"name":"认可","count":1}]}',
typical_comments_json='{"positive":[{"content":"评论内容","like_count":3}],"neutral":[],"negative":[]}',
summary="热点总结",
markdown_content="# 热点标题\n\n热点总结",
data="{}",
markdown="# 热点标题\n\n热点总结",
)
)
session.add(
Report(
task_id=task.id,
hotspot_id=hotspot.id,
content_item_id=item.id,
report_type="item",
title="视频标题",
metrics_json='{"sample_count":1,"sentiment":{"positive":{"count":1,"pct":100},"neutral":{"count":0,"pct":0},"negative":{"count":0,"pct":0},"unknown":{"count":0,"pct":0}},"top_labels":[{"name":"认可","count":1}]}',
typical_comments_json='{"positive":[{"content":"评论内容","like_count":3}],"neutral":[],"negative":[]}',
summary="内容总结",
markdown_content="# 视频标题\n\n内容总结",
data="{}",
markdown="# 视频标题\n\n内容总结",
)
)
session.commit()
def test_result_pages_render_seeded_data():
with make_test_client() as (client, engine):
seed_result_data(engine)
task_response = client.get("/tasks/task-result")
hotspot_response = client.get("/hotspots/hot-result/report")
item_response = client.get("/items/item-result")
assert task_response.status_code == 200
assert "热点标题" in task_response.text
assert "热点 1:热点标题" in BeautifulSoup(task_response.text, "html.parser").get_text(" ")
assert 'onclick="window.location.reload()"' not in task_response.text
assert "手动刷新" not in task_response.text
assert hotspot_response.status_code == 200
assert "热点总结" in hotspot_response.text
assert "downloadExport(" not in hotspot_response.text
assert "/api/export/" not in hotspot_response.text
assert "导出 Markdown" not in hotspot_response.text
assert "导出热点评论 CSV" not in hotspot_response.text
assert "评论样本" in hotspot_response.text
assert "关联内容" in hotspot_response.text
assert "正向" in hotspot_response.text
assert "1 条(100%" in hotspot_response.text
assert "认可 (1)" in hotspot_response.text
assert "典型评论" in hotspot_response.text
assert "评论内容" in hotspot_response.text
assert "<pre" not in hotspot_response.text
assert item_response.status_code == 200
assert "内容总结" in item_response.text
item_visible_text = BeautifulSoup(item_response.text, "html.parser").get_text(" ")
assert "热点 1:热点标题" in item_visible_text
assert "#" not in item_visible_text
assert "downloadExport(" not in item_response.text
assert "/api/export/" not in item_response.text
assert "导出 Markdown" not in item_response.text
assert "导出评论 CSV" not in item_response.text
assert "评论样本" in item_response.text
assert "Top 标签" in item_response.text
assert "评论内容" in item_response.text
def test_report_pages_render_empty_state_when_report_missing():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
task = Task(id="task-no-report", platform="xiaohongshu", status="running")
session.add(task)
hotspot = Hotspot(id="hot-no-report", task_id=task.id, platform="xiaohongshu", title="缺报告热点", rank=1, raw_data="{}")
session.add(hotspot)
session.add(
ContentItem(
id="item-no-report",
task_id=task.id,
hotspot_id=hotspot.id,
platform="xiaohongshu",
source_item_id="n1",
item_type="note",
title="缺报告内容",
status="pending",
raw_data="{}",
)
)
session.commit()
hotspot_response = client.get("/hotspots/hot-no-report/report")
item_response = client.get("/items/item-no-report")
assert hotspot_response.status_code == 200
assert "报告生成中,请稍候" in hotspot_response.text
assert 'title="报告尚未生成"' not in hotspot_response.text
assert "Markdown 报告将在分析完成后开放下载" not in hotspot_response.text
assert "downloadExport(" not in hotspot_response.text
assert "/api/export/" not in hotspot_response.text
assert item_response.status_code == 200
assert "报告生成中,请稍候" in item_response.text
assert 'title="报告尚未生成"' not in item_response.text
assert "Markdown 报告将在分析完成后开放下载" not in item_response.text
assert "downloadExport(" not in item_response.text
assert "/api/export/" not in item_response.text
def test_report_pages_warn_when_ai_sample_is_insufficient():
with make_test_client() as (client, engine):
seed_result_data(engine)
from sqlalchemy.orm import Session
with Session(engine) as session:
task = session.get(Task, "task-result")
task.analysis_status = "insufficient"
task.analysis_success_rate = 0.5
session.commit()
hotspot_response = client.get("/hotspots/hot-result/report")
item_response = client.get("/items/item-result")
assert hotspot_response.status_code == 200
assert "当前有效评论样本不足" in hotspot_response.text
assert item_response.status_code == 200
assert "当前有效评论样本不足" in item_response.text
def test_export_routes_are_removed():
with make_test_client() as (client, engine):
seed_result_data(engine)
csv_response = client.get("/api/export/items/item-result/comments.csv")
hotspot_csv_response = client.get("/api/export/hotspots/hot-result/comments.csv")
hotspot_md = client.get("/api/export/hotspots/hot-result.md")
item_md = client.get("/api/export/items/item-result.md")
assert csv_response.status_code == 404
assert hotspot_csv_response.status_code == 404
assert hotspot_md.status_code == 404
assert item_md.status_code == 404
def test_api_tasks_returns_service_unavailable_when_database_has_io_error(monkeypatch):
def broken_list_tasks(_session):
raise OperationalError("SELECT 1", {}, Exception("disk I/O error"))
monkeypatch.setattr("app.main.list_tasks", broken_list_tasks)
with make_test_client() as (client, _engine):
response = client.get("/api/tasks")
assert response.status_code == 503
assert response.json()["detail"] == "数据库暂时不可用,请稍后重试或联系维护者恢复数据。"
def test_index_page_shows_database_error_state_when_database_has_io_error(monkeypatch):
def broken_list_tasks(_session):
raise OperationalError("SELECT 1", {}, Exception("disk I/O error"))
monkeypatch.setattr("app.main.list_tasks", broken_list_tasks)
with make_test_client() as (client, _engine):
response = client.get("/")
assert response.status_code == 503
assert "数据库暂时不可用" in response.text
assert "请先保留 data 目录" in response.text
def test_task_detail_page_shows_database_error_state_when_database_has_io_error(monkeypatch):
def broken_get_task(_session, _task_id):
raise OperationalError("SELECT 1", {}, Exception("disk I/O error"))
monkeypatch.setattr("app.main.get_task", broken_get_task)
with make_test_client() as (client, _engine):
response = client.get("/tasks/task-io-error")
assert response.status_code == 503
assert "数据库暂时不可用" in response.text
assert "请先保留 data 目录" in response.text
def test_hotspot_report_page_shows_database_error_state_when_database_has_io_error(monkeypatch):
def broken_get_task(_session, _task_id):
raise OperationalError("SELECT 1", {}, Exception("disk I/O error"))
monkeypatch.setattr("app.main.get_task", broken_get_task)
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(Task(id="task-hot-io", platform="xiaohongshu", status="success"))
session.add(Hotspot(id="hot-io", task_id="task-hot-io", platform="xiaohongshu", title="热点", rank=1, raw_data="{}"))
session.commit()
response = client.get("/hotspots/hot-io/report")
assert response.status_code == 503
assert "数据库暂时不可用" in response.text
def test_item_detail_page_shows_database_error_state_when_database_has_io_error(monkeypatch):
def broken_get_task(_session, _task_id):
raise OperationalError("SELECT 1", {}, Exception("disk I/O error"))
monkeypatch.setattr("app.main.get_task", broken_get_task)
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(Task(id="task-item-io", platform="xiaohongshu", status="success"))
session.add(Hotspot(id="hot-item-io", task_id="task-item-io", platform="xiaohongshu", title="热点", rank=1, raw_data="{}"))
session.add(
ContentItem(
id="item-io",
task_id="task-item-io",
hotspot_id="hot-item-io",
platform="xiaohongshu",
source_item_id="item-io-source",
item_type="note",
status="success",
raw_data="{}",
)
)
session.commit()
response = client.get("/items/item-io")
assert response.status_code == 503
assert "数据库暂时不可用" in response.text
def test_missing_html_pages_render_friendly_not_found_page():
with make_test_client() as (client, _engine):
task_response = client.get("/tasks/missing-task")
hotspot_response = client.get("/hotspots/missing-hotspot/report")
item_response = client.get("/items/missing-item")
assert task_response.status_code == 404
assert "任务不存在" in task_response.text
assert "Internal Server Error" not in task_response.text
assert hotspot_response.status_code == 404
assert "热点不存在" in hotspot_response.text
assert "Internal Server Error" not in hotspot_response.text
assert item_response.status_code == 404
assert "内容条目不存在" in item_response.text
assert "Internal Server Error" not in item_response.text
def test_task_api_includes_progress_counts_stage_and_demo_flag():
with make_test_client() as (client, engine):
seed_result_data(engine)
response = client.get("/api/tasks/task-result")
assert response.status_code == 200
data = response.json()
assert data["current_stage"] in (None, "success")
assert data["current_stage_label"] == "已完成"
assert data["comments_count"] == 1
assert data["reports_count"] == 2
assert data["is_demo"] is False
assert data["last_progress_at"] is not None
def test_task_detail_page_explains_target_and_actual_counts():
with make_test_client() as (client, engine):
seed_result_data(engine)
response = client.get("/tasks/task-result")
assert response.status_code == 200
assert "目标上限" in response.text
assert "实际结果" in response.text
assert "实际评论 1" in response.text
assert "少于理论上限通常是内容本身评论不足或平台返回不足" in response.text
def test_task_api_includes_runtime_and_stale_progress_fields(monkeypatch):
now = datetime(2026, 7, 3, 10, 30, tzinfo=UTC)
monkeypatch.setattr("app.services.task_service.utc_now", lambda: now)
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(
Task(
id="task-api-stale",
platform="douyin",
status="running",
current_stage="crawl_comments",
created_at=now - timedelta(minutes=45),
last_progress_at=now - timedelta(minutes=11),
)
)
session.commit()
response = client.get("/api/tasks/task-api-stale")
assert response.status_code == 200
data = response.json()
assert data["current_stage_label"] == "抓取评论中"
assert data["running_seconds"] == 2700
assert data["seconds_since_last_progress"] == 660
assert data["running_duration_label"] == "45分钟"
assert data["last_progress_ago_label"] == "11分钟前"
assert data["is_progress_stale"] is True
assert data["stale_threshold_minutes"] == 10
+162
View File
@@ -0,0 +1,162 @@
from datetime import UTC, datetime, timedelta
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.db import Base, get_db_session
from app.main import app
from app.models import Task
def make_test_client():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
def override_db_session():
from sqlalchemy.orm import Session
with Session(engine) as session:
yield session
app.dependency_overrides[get_db_session] = override_db_session
return TestClient(app), engine
def test_create_task_returns_task_id_and_running_status():
client, engine = make_test_client()
try:
response = client.post(
"/api/tasks",
json={
"platform": "xiaohongshu",
"hotspot_limit": 5,
"item_limit_per_hotspot": 5,
"comment_limit_per_item": 50,
},
)
assert response.status_code == 201
data = response.json()
assert data["task_id"]
assert data["status"] == "running"
finally:
engine.dispose()
app.dependency_overrides.clear()
def test_create_task_rejects_invalid_platform_and_limits():
client, engine = make_test_client()
try:
response = client.post(
"/api/tasks",
json={
"platform": "weibo",
"hotspot_limit": 11,
"item_limit_per_hotspot": 0,
"comment_limit_per_item": 101,
},
)
assert response.status_code == 422
finally:
engine.dispose()
app.dependency_overrides.clear()
def test_create_task_returns_400_when_running_task_exists():
client, engine = make_test_client()
try:
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(Task(platform="douyin", status="running"))
session.commit()
response = client.post(
"/api/tasks",
json={
"platform": "douyin",
"hotspot_limit": 5,
"item_limit_per_hotspot": 5,
"comment_limit_per_item": 50,
},
)
assert response.status_code == 400
assert response.json() == {"detail": "当前有正在运行的任务,请稍后再试"}
finally:
engine.dispose()
app.dependency_overrides.clear()
def test_create_task_recovers_stale_running_task_before_creating_new_one(monkeypatch):
client, engine = make_test_client()
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
monkeypatch.setattr("app.services.task_service.utc_now", lambda: now)
monkeypatch.setattr("app.services.task_service.task_executor.submit", lambda *_args, **_kwargs: None)
try:
with Session(engine) as session:
session.add(
Task(
id="stale-running",
platform="douyin",
status="running",
current_stage="ai_analysis",
last_progress_at=now - timedelta(minutes=11),
)
)
session.commit()
response = client.post(
"/api/tasks",
json={
"platform": "douyin",
"hotspot_limit": 5,
"item_limit_per_hotspot": 5,
"comment_limit_per_item": 50,
},
)
with Session(engine) as session:
stale_task = session.get(Task, "stale-running")
assert response.status_code == 201
assert stale_task.status == "failed"
assert stale_task.error_stage == "system"
assert stale_task.error_type == "stale_progress_timeout"
assert "超过 10 分钟没有进度更新" in stale_task.error_message
finally:
engine.dispose()
app.dependency_overrides.clear()
def test_task_list_and_detail_return_created_tasks():
client, engine = make_test_client()
try:
created = client.post(
"/api/tasks",
json={
"platform": "douyin",
"hotspot_limit": 3,
"item_limit_per_hotspot": 4,
"comment_limit_per_item": 30,
},
).json()
list_response = client.get("/api/tasks")
detail_response = client.get(f"/api/tasks/{created['task_id']}")
assert list_response.status_code == 200
assert list_response.json()[0]["task_id"] == created["task_id"]
assert detail_response.status_code == 200
assert detail_response.json()["platform"] == "douyin"
assert detail_response.json()["hotspot_limit"] == 3
finally:
engine.dispose()
app.dependency_overrides.clear()
@@ -0,0 +1,39 @@
from sqlalchemy.orm import Session
from app.models import Comment, Task
from app.platforms.base import CommentData, ContentItemData, HotspotData
from app.schemas import CreateTaskRequest
from app.services.task_service import create_task, run_task
from tests.helpers import make_test_client
class FakeDouyinPlatform:
def fetch_hotspots(self, *, limit):
return [HotspotData(source_hot_id="d1", title="抖音热点", rank=1, heat_value="200", raw_data={})]
def search_items_by_hotspot(self, keyword, *, limit):
return [ContentItemData(source_item_id="v1", item_type="video", title="视频", raw_data={})]
def fetch_comments(self, source_item_id, *, limit):
return [CommentData(source_comment_id="dc1", content="抖音评论", raw_data={})]
def test_douyin_task_flow_persists_comments(monkeypatch):
with make_test_client() as (_client, engine):
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeDouyinPlatform())
with Session(engine) as session:
task = create_task(
session,
CreateTaskRequest(platform="douyin", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
submit_background=False,
)
task_id = task.id
with Session(engine) as session:
run_task(task_id, session_factory=lambda: session)
task = session.get(Task, task_id)
comment = session.query(Comment).filter_by(task_id=task_id).one()
assert task.status == "success"
assert comment.content == "抖音评论"
@@ -0,0 +1,191 @@
import json
import time
from types import SimpleNamespace
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Comment, ContentItem, Hotspot, Report, Task
from app.schemas import CreateTaskRequest
from app.services.report_service import DEFAULT_SUMMARY
from app.services.task_service import create_task, run_task
from tests.helpers import make_test_client
class FakeXhsPlatform:
def fetch_hotspots(self, *, limit):
from app.platforms.base import HotspotData
return [HotspotData(source_hot_id="h1", title="热点一", rank=1, heat_value="100", raw_data={"id": "h1"})]
def search_items_by_hotspot(self, keyword, *, limit):
from app.platforms.base import ContentItemData
return [
ContentItemData(source_item_id="n1", item_type="note", title=f"{keyword} 笔记", raw_data={"id": "n1"})
]
def fetch_comments(self, source_item_id, *, limit):
from app.platforms.base import CommentData
return [CommentData(source_comment_id="c1", content="评论一", like_count=5, raw_data={"id": "c1"})]
ai_prompts: list[str] = []
def successful_ai_response(prompt: str) -> str:
ai_prompts.append(prompt)
comments = json.loads(prompt[prompt.index("[") :])
comment_id = comments[0]["comment_id"]
return json.dumps(
[{"comment_id": comment_id, "sentiment": "positive", "labels": ["认可"], "reason": "喜欢"}],
ensure_ascii=False,
)
def test_xiaohongshu_task_flow_persists_hotspots_items_and_comments(monkeypatch):
ai_prompts.clear()
with make_test_client() as (_client, engine):
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
summary_provider = lambda metrics, typical, *, word_limit=200: (
ai_prompts.append(f"只返回一段中文总结 word_limit={word_limit} sample={metrics['sample_count']}")
or "这是一段真实 AI 报告摘要。"
)
monkeypatch.setattr("app.services.task_service.build_ai_dependencies", lambda: (successful_ai_response, summary_provider))
with Session(engine) as session:
task = create_task(
session,
CreateTaskRequest(platform="xiaohongshu", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
submit_background=False,
)
task_id = task.id
with Session(engine) as session:
run_task(task_id, session_factory=lambda: session)
persisted = session.get(Task, task_id)
assert persisted.status == "success"
assert persisted.processed_items_count == 1
assert persisted.successful_items_count == 1
assert persisted.analysis_status == "normal"
assert session.scalar(select(Hotspot).where(Hotspot.task_id == task_id)).title == "热点一"
assert session.scalar(select(ContentItem).where(ContentItem.task_id == task_id)).source_item_id == "n1"
comment = session.scalar(select(Comment).where(Comment.task_id == task_id))
assert comment.content == "评论一"
assert comment.ai_analysis_status == "success"
assert comment.sentiment == "positive"
assert comment.labels == '["认可"]'
assert comment.reason == "喜欢"
item_report = session.scalar(select(Report).where(Report.task_id == task_id, Report.report_type == "item"))
hotspot_report = session.scalar(select(Report).where(Report.task_id == task_id, Report.report_type == "hotspot"))
assert item_report is not None
assert hotspot_report is not None
assert item_report.summary == "这是一段真实 AI 报告摘要。"
assert hotspot_report.summary == "这是一段真实 AI 报告摘要。"
assert DEFAULT_SUMMARY not in item_report.markdown_content
assert DEFAULT_SUMMARY not in hotspot_report.markdown_content
assert any("只返回一段中文总结" in prompt for prompt in ai_prompts)
def test_xiaohongshu_task_flow_marks_comments_failed_when_ai_parse_fails(monkeypatch):
with make_test_client() as (_client, engine):
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
monkeypatch.setattr("app.services.task_service.build_ai_dependencies", lambda: (lambda _prompt: "not-json", None))
monkeypatch.setattr("app.services.ai_service.time.sleep", lambda _seconds: None)
with Session(engine) as session:
task = create_task(
session,
CreateTaskRequest(platform="xiaohongshu", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
submit_background=False,
)
task_id = task.id
with Session(engine) as session:
run_task(task_id, session_factory=lambda: session)
persisted = session.get(Task, task_id)
comment = session.scalar(select(Comment).where(Comment.task_id == task_id))
assert persisted.status == "success"
assert persisted.analysis_status == "insufficient"
assert persisted.analysis_success_rate == 0.0
assert comment.ai_analysis_status == "failed"
assert comment.sentiment == "unknown"
assert comment.labels == "[]"
assert comment.reason == "ai_parse_failed"
def test_xiaohongshu_task_flow_keeps_item_success_when_ai_request_raises(monkeypatch):
with make_test_client() as (_client, engine):
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
monkeypatch.setattr("app.services.ai_service.time.sleep", lambda _seconds: None)
def failing_requester(_prompt):
raise RuntimeError("ai unauthorized")
monkeypatch.setattr("app.services.task_service.build_ai_dependencies", lambda: (failing_requester, None))
with Session(engine) as session:
task = create_task(
session,
CreateTaskRequest(platform="xiaohongshu", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
submit_background=False,
)
task_id = task.id
with Session(engine) as session:
run_task(task_id, session_factory=lambda: session)
persisted = session.get(Task, task_id)
comment = session.scalar(select(Comment).where(Comment.task_id == task_id))
assert persisted.status == "success"
assert persisted.successful_items_count == 1
assert persisted.failed_items_count == 0
assert persisted.analysis_status == "insufficient"
assert persisted.analysis_success_rate == 0.0
assert comment.ai_analysis_status == "failed"
assert comment.reason == "ai_parse_failed"
def test_xiaohongshu_task_flow_times_out_stalled_ai_request(monkeypatch):
with make_test_client() as (_client, engine):
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
monkeypatch.setattr(
"app.services.task_service.get_settings",
lambda: SimpleNamespace(ai_max_retries=1, ai_timeout_seconds=0.01),
)
def stalled_requester(prompt):
time.sleep(0.2)
comments = json.loads(prompt[prompt.index("[") :])
return json.dumps(
[{"comment_id": comments[0]["comment_id"], "sentiment": "positive", "labels": ["超时后不应采用"], "reason": "late"}],
ensure_ascii=False,
)
monkeypatch.setattr("app.services.task_service.build_ai_dependencies", lambda: (stalled_requester, None))
with Session(engine) as session:
task = create_task(
session,
CreateTaskRequest(platform="xiaohongshu", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
submit_background=False,
)
task_id = task.id
with Session(engine) as session:
run_task(task_id, session_factory=lambda: session)
persisted = session.get(Task, task_id)
comment = session.scalar(select(Comment).where(Comment.task_id == task_id))
assert persisted.status == "success"
assert persisted.finished_at is not None
assert persisted.analysis_status == "insufficient"
assert comment.ai_analysis_status == "failed"
assert comment.sentiment == "unknown"
assert comment.reason == "ai_parse_failed"
+40
View File
@@ -0,0 +1,40 @@
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db import Base
from app.main import app
from app.models import Task
def test_lifespan_marks_running_tasks_failed_after_restart(monkeypatch):
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base.metadata.create_all(engine)
with TestingSessionLocal() as session:
session.add(Task(platform="xiaohongshu", status="running"))
session.commit()
monkeypatch.setattr("app.main.SessionLocal", TestingSessionLocal, raising=False)
monkeypatch.setattr("app.main.init_db", lambda: None)
try:
with TestClient(app):
pass
with Session(engine) as session:
task = session.scalar(select(Task))
assert task is not None
assert task.status == "failed"
assert task.error_stage == "system"
assert task.error_type == "unexpected_restart"
assert task.error_message == "系统重启,任务被中断"
finally:
engine.dispose()
+1
View File
@@ -0,0 +1 @@
"""Unit tests."""
+171
View File
@@ -0,0 +1,171 @@
import pytest
import httpx
from app.services.ai_service import (
AIAnalysisResult,
OpenAICompatibleAIClient,
analyze_comments_with_retry,
build_report_summary_prompt,
build_comment_prompt,
calculate_analysis_status,
parse_ai_comment_response,
)
from app.services.task_service import build_report_summary_provider
def test_build_comment_prompt_contains_ids_and_truncates_content():
prompt = build_comment_prompt([{"comment_id": "c1", "content": "" * 200}])
assert "c1" in prompt
assert "请原样回填输入中的 comment_id" in prompt
assert "" * 150 in prompt
assert "" * 151 not in prompt
def test_build_report_summary_prompt_contains_stats_labels_and_truncated_typical_comments():
prompt = build_report_summary_prompt(
{
"sample_count": 2,
"sentiment": {
"positive": {"count": 1, "pct": 50.0},
"neutral": {"count": 0, "pct": 0.0},
"negative": {"count": 1, "pct": 50.0},
"unknown": {"count": 0, "pct": 0.0},
},
"top_labels": [{"name": "价格争议", "count": 2}],
},
{"positive": [{"content": "" * 200}], "negative": [{"content": "太贵"}]},
word_limit=200,
)
assert "只返回一段中文总结" in prompt
assert "样本评论数量:2" in prompt
assert "positive: 1 (50.0%)" in prompt
assert "价格争议: 2" in prompt
assert "" * 150 in prompt
assert "" * 151 not in prompt
assert "JSON Array" not in prompt
def test_report_summary_provider_is_disabled_without_real_ai_client():
assert build_report_summary_provider(client=None) is None
def test_parse_ai_comment_response_validates_array_sentiment_labels_and_ids():
result = parse_ai_comment_response(
'[{"comment_id":"c1","sentiment":"positive","labels":["质量好"],"reason":"认可"}]',
expected_comment_ids={"c1"},
)
assert result == [AIAnalysisResult(comment_id="c1", sentiment="positive", labels=["质量好"], reason="认可")]
@pytest.mark.parametrize(
"payload",
[
"not-json",
'{"comment_id":"c1"}',
'[{"comment_id":"missing","sentiment":"positive","labels":[],"reason":""}]',
'[{"comment_id":"c1","sentiment":"happy","labels":[],"reason":""}]',
'[{"comment_id":"c1","sentiment":"positive","labels":["a","b","c","d"],"reason":""}]',
],
)
def test_parse_ai_comment_response_rejects_invalid_payloads(payload):
with pytest.raises(ValueError):
parse_ai_comment_response(payload, expected_comment_ids={"c1"})
def test_calculate_analysis_status_uses_eighty_percent_threshold():
assert calculate_analysis_status(success_count=8, total_count=10) == (0.8, "normal")
assert calculate_analysis_status(success_count=7, total_count=10) == (0.7, "insufficient")
assert calculate_analysis_status(success_count=0, total_count=0) == (0.0, "insufficient")
def test_analyze_comments_with_retry_marks_batch_failed_after_three_parse_failures(monkeypatch):
sleeps = []
monkeypatch.setattr("app.services.ai_service.time.sleep", sleeps.append)
results = analyze_comments_with_retry(
[{"comment_id": "c1", "content": "内容"}],
requester=lambda _prompt: "not-json",
max_retries=3,
)
assert results[0].comment_id == "c1"
assert results[0].sentiment == "unknown"
assert results[0].ai_analysis_status == "failed"
assert results[0].reason == "ai_parse_failed"
assert sleeps == [1, 2]
def test_openai_compatible_client_posts_chat_completion_and_returns_message_content():
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["authorization"] = request.headers.get("authorization")
captured["body"] = request.read().decode("utf-8")
return httpx.Response(
200,
json={"choices": [{"message": {"content": '[{"comment_id":"c1","sentiment":"positive","labels":["认可"],"reason":"喜欢"}]'}}]},
)
http_client = httpx.Client(transport=httpx.MockTransport(handler))
client = OpenAICompatibleAIClient(
base_url="https://ai.example.com",
api_key="test-ai-key",
model="test-model",
http_client=http_client,
)
result = client.request("prompt text")
assert result == '[{"comment_id":"c1","sentiment":"positive","labels":["认可"],"reason":"喜欢"}]'
assert captured["url"] == "https://ai.example.com/v1/chat/completions"
assert captured["authorization"] == "Bearer test-ai-key"
assert '"model":"test-model"' in captured["body"]
assert "prompt text" in captured["body"]
assert "请严格遵循用户指令输出。" in captured["body"]
def test_openai_compatible_client_accepts_custom_system_prompt():
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = request.read().decode("utf-8")
return httpx.Response(200, json={"choices": [{"message": {"content": "报告摘要"}}]})
http_client = httpx.Client(transport=httpx.MockTransport(handler))
client = OpenAICompatibleAIClient(
base_url="https://ai.example.com",
api_key="test-ai-key",
model="test-model",
http_client=http_client,
)
assert client.request("总结 prompt", system_prompt="只返回一段中文总结,不使用 Markdown。") == "报告摘要"
assert "只返回一段中文总结" in captured["body"]
assert "JSON Array" not in captured["body"]
def test_openai_compatible_client_accepts_base_url_that_already_includes_v1():
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(
200,
json={"choices": [{"message": {"content": "[]"}}]},
)
http_client = httpx.Client(transport=httpx.MockTransport(handler))
client = OpenAICompatibleAIClient(
base_url="https://ai.example.com/v1",
api_key="test-ai-key",
model="test-model",
http_client=http_client,
)
client.request("prompt text")
assert captured["url"] == "https://ai.example.com/v1/chat/completions"
+122
View File
@@ -0,0 +1,122 @@
import httpx
import pytest
from app.platforms.base import PlatformAPIError, TikHubClient
class SequenceTransport:
def __init__(self, responses):
self.responses = list(responses)
self.requests = []
def __call__(self, request: httpx.Request) -> httpx.Response:
self.requests.append(request)
response = self.responses.pop(0)
response.request = request
return response
def test_tikhub_client_retries_429_with_exponential_backoff(monkeypatch):
sleeps = []
monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
transport = SequenceTransport(
[
httpx.Response(429, json={"message": "Too Many Requests"}),
httpx.Response(429, json={"message": "Too Many Requests"}),
httpx.Response(200, json={"ok": True}),
]
)
http_client = httpx.Client(transport=httpx.MockTransport(transport))
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
result = client.get("/demo")
assert result == {"ok": True}
assert sleeps == [1, 2]
assert len(transport.requests) == 3
assert transport.requests[0].headers["Authorization"] == "Bearer secret-token"
def test_tikhub_client_raises_structured_error_after_retries(monkeypatch):
sleeps = []
monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
transport = SequenceTransport([httpx.Response(429, json={"message": "Too Many Requests"}) for _ in range(4)])
http_client = httpx.Client(transport=httpx.MockTransport(transport))
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
with pytest.raises(PlatformAPIError) as exc_info:
client.get("/demo")
assert exc_info.value.error_type == "rate_limited"
assert exc_info.value.status_code == 429
assert "secret-token" not in str(exc_info.value)
assert sleeps == [1, 2, 4]
def test_tikhub_client_retries_transient_api_error(monkeypatch):
sleeps = []
monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
transport = SequenceTransport(
[
httpx.Response(400, json={"message_zh": "临时请求失败"}),
httpx.Response(200, json={"ok": True}),
]
)
http_client = httpx.Client(transport=httpx.MockTransport(transport))
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
result = client.get("/demo")
assert result == {"ok": True}
assert sleeps == [1]
assert len(transport.requests) == 2
def test_tikhub_client_includes_response_summary_after_api_error_retries(monkeypatch):
sleeps = []
monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
transport = SequenceTransport(
[httpx.Response(400, json={"message_zh": "笔记评论暂不可用"}) for _ in range(4)]
)
http_client = httpx.Client(transport=httpx.MockTransport(transport))
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
with pytest.raises(PlatformAPIError) as exc_info:
client.get("/demo")
assert exc_info.value.error_type == "api_error"
assert exc_info.value.status_code == 400
assert "External API returned HTTP 400" in str(exc_info.value)
assert "笔记评论暂不可用" in str(exc_info.value)
assert "secret-token" not in str(exc_info.value)
assert sleeps == [1, 2, 4]
def test_tikhub_client_includes_text_response_summary_after_api_error_retries(monkeypatch):
sleeps = []
monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
transport = SequenceTransport([httpx.Response(502, text="upstream temporary failure") for _ in range(4)])
http_client = httpx.Client(transport=httpx.MockTransport(transport))
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
with pytest.raises(PlatformAPIError) as exc_info:
client.get("/demo")
assert exc_info.value.error_type == "api_error"
assert exc_info.value.status_code == 502
assert str(exc_info.value) == "External API returned HTTP 502: upstream temporary failure"
assert sleeps == [1, 2, 4]
def test_tikhub_client_reports_401_as_auth_error_without_leaking_token():
transport = SequenceTransport([httpx.Response(401, json={"message": "Unauthorized"})])
http_client = httpx.Client(transport=httpx.MockTransport(transport))
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
with pytest.raises(PlatformAPIError) as exc_info:
client.get("/demo")
assert exc_info.value.error_type == "auth_error"
assert exc_info.value.status_code == 401
assert str(exc_info.value) == "TikHub 鉴权失败,请检查 TIKHUB_API_KEY 是否有效"
assert "secret-token" not in str(exc_info.value)
+76
View File
@@ -0,0 +1,76 @@
from app.config import Settings
def test_settings_use_t01_defaults(monkeypatch):
monkeypatch.delenv("APP_ENV", raising=False)
monkeypatch.delenv("DATABASE_URL", raising=False)
monkeypatch.delenv("TIKHUB_BASE_URL", raising=False)
monkeypatch.delenv("AI_PROVIDER", raising=False)
settings = Settings(_env_file=None)
assert settings.app_env == "development"
assert settings.database_url == "sqlite:///data/app.db"
assert settings.tikhub_base_url == "https://api.tikhub.io"
assert settings.ai_provider == "openai-compatible"
def test_settings_read_environment_overrides(monkeypatch):
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
monkeypatch.setenv("TIKHUB_API_KEY", "test-token")
monkeypatch.setenv("AI_API_KEY", "test-ai-key")
settings = Settings(_env_file=None)
assert settings.app_env == "test"
assert settings.database_url == "sqlite:///:memory:"
assert settings.tikhub_api_key == "test-token"
assert settings.ai_api_key == "test-ai-key"
def test_settings_include_t02_defaults(monkeypatch):
monkeypatch.delenv("AI_BATCH_SIZE", raising=False)
monkeypatch.delenv("AI_CONCURRENCY", raising=False)
monkeypatch.delenv("AI_MAX_RETRIES", raising=False)
monkeypatch.delenv("AI_TIMEOUT_SECONDS", raising=False)
monkeypatch.delenv("HTTP_TIMEOUT_SECONDS", raising=False)
monkeypatch.delenv("HTTP_MAX_RETRIES", raising=False)
monkeypatch.delenv("CRAWL_PAGE_INTERVAL_SECONDS", raising=False)
settings = Settings(_env_file=None)
assert settings.ai_batch_size == 20
assert settings.ai_concurrency == 2
assert settings.ai_max_retries == 3
assert settings.ai_timeout_seconds == 30
assert settings.http_timeout_seconds == 20
assert settings.http_max_retries == 3
assert settings.crawl_page_interval_seconds == 1.5
def test_settings_read_crawl_page_interval_from_environment(monkeypatch):
monkeypatch.setenv("CRAWL_PAGE_INTERVAL_SECONDS", "0.25")
settings = Settings(_env_file=None)
assert settings.crawl_page_interval_seconds == 0.25
def test_ai_concurrency_has_hard_upper_bound(monkeypatch):
monkeypatch.setenv("AI_CONCURRENCY", "4")
settings = Settings(_env_file=None)
assert settings.ai_concurrency == 3
def test_task_limit_ranges_are_available_on_settings():
settings = Settings(_env_file=None)
assert settings.hot_limit_min == 1
assert settings.hot_limit_max == 10
assert settings.item_limit_per_hot_min == 1
assert settings.item_limit_per_hot_max == 10
assert settings.comment_limit_per_item_min == 10
assert settings.comment_limit_per_item_max == 100
+91
View File
@@ -0,0 +1,91 @@
import pytest
from sqlalchemy.pool import NullPool
from app.db import check_database_integrity, checkpoint_sqlite_wal, create_sqlite_engine, ensure_sqlite_schema_compat
def test_check_database_integrity_returns_ok_for_valid_sqlite_database():
engine = create_sqlite_engine("sqlite:///:memory:")
try:
assert check_database_integrity(engine) == "ok"
finally:
engine.dispose()
def test_file_sqlite_engine_does_not_reuse_connections_after_operational_errors(tmp_path):
engine = create_sqlite_engine(f"sqlite:///{tmp_path / 'app.db'}")
try:
assert isinstance(engine.pool, NullPool)
finally:
engine.dispose()
def test_check_database_integrity_raises_when_sqlite_reports_problem(monkeypatch):
class FakeCursor:
def execute(self, _sql):
return None
def fetchone(self):
return ("database disk image is malformed",)
def close(self):
return None
class FakeConnection:
def cursor(self):
return FakeCursor()
class FakeRawConnection:
driver_connection = FakeConnection()
def close(self):
return None
class FakeEngine:
def raw_connection(self):
return FakeRawConnection()
with pytest.raises(RuntimeError, match="SQLite integrity check failed"):
check_database_integrity(FakeEngine())
def test_checkpoint_sqlite_wal_truncates_wal_for_file_database(tmp_path):
db_path = tmp_path / "app.db"
engine = create_sqlite_engine(f"sqlite:///{db_path}")
try:
with engine.begin() as connection:
connection.exec_driver_sql("CREATE TABLE demo (id INTEGER PRIMARY KEY, name TEXT)")
connection.exec_driver_sql("INSERT INTO demo (name) VALUES ('ok')")
assert checkpoint_sqlite_wal(engine) is True
with engine.connect() as connection:
rows = connection.exec_driver_sql("SELECT name FROM demo").all()
assert rows == [("ok",)]
finally:
engine.dispose()
def test_checkpoint_sqlite_wal_skips_in_memory_database():
engine = create_sqlite_engine("sqlite:///:memory:")
try:
assert checkpoint_sqlite_wal(engine) is False
finally:
engine.dispose()
def test_ensure_sqlite_schema_compat_adds_progress_columns_to_existing_tasks_table(tmp_path):
db_path = tmp_path / "legacy.db"
engine = create_sqlite_engine(f"sqlite:///{db_path}")
try:
with engine.begin() as connection:
connection.exec_driver_sql("CREATE TABLE tasks (id VARCHAR(36) PRIMARY KEY, platform VARCHAR(32) NOT NULL)")
ensure_sqlite_schema_compat(engine)
with engine.connect() as connection:
columns = {row[1] for row in connection.exec_driver_sql("PRAGMA table_info(tasks)").all()}
assert "current_stage" in columns
assert "last_progress_at" in columns
finally:
engine.dispose()
+27
View File
@@ -0,0 +1,27 @@
from sqlalchemy.orm import Session
from app.demo_seed import seed_demo_data
from app.models import Comment, ContentItem, Task
from tests.helpers import make_test_client
def test_seed_demo_data_is_idempotent_and_removes_sensitive_fields():
with make_test_client() as (_client, engine):
with Session(engine) as session:
task_id = seed_demo_data(session)
second_task_id = seed_demo_data(session)
task = session.get(Task, task_id)
comments = session.query(Comment).all()
items = session.query(ContentItem).all()
assert second_task_id == task_id
assert task is not None
assert task.id.startswith("demo-")
assert task.status == "success"
assert comments
assert all(comment.author is None for comment in comments)
assert all(comment.source_comment_id is None for comment in comments)
assert all(comment.raw_data == "{}" for comment in comments)
assert all(item.url is None for item in items)
assert all(item.raw_data == "{}" for item in items)
+47
View File
@@ -0,0 +1,47 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_docker_compose_reads_real_env_file_not_example():
compose_content = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
assert "- .env\n" in compose_content
assert "- .env.example" not in compose_content
def test_docker_compose_uses_fixed_project_name_for_stable_port_owner():
compose_content = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
assert "name: hot-comments-tool" in compose_content
assert '"8000:8000"' in compose_content or "- 8000:8000" in compose_content
def test_docker_compose_mounts_data_directory_for_sqlite_wal_files():
compose_content = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
assert "./data:/app/data" in compose_content
assert "app.db:/app/data/app.db" not in compose_content
def test_real_env_file_is_gitignored():
gitignore_lines = (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
assert ".env" in gitignore_lines
def test_dockerfile_does_not_bootstrap_uv_from_runtime_pip():
dockerfile_content = (ROOT / "Dockerfile").read_text(encoding="utf-8")
assert "pip install --no-cache-dir uv" not in dockerfile_content
assert "uv pip install" not in dockerfile_content
def test_dockerfile_installs_project_without_build_isolation():
dockerfile_content = (ROOT / "Dockerfile").read_text(encoding="utf-8")
assert "COPY requirements.txt ./" in dockerfile_content
assert "pip install --no-cache-dir -r requirements.txt" in dockerfile_content
assert "pip install --no-cache-dir ." not in dockerfile_content
assert "pip install --no-cache-dir --no-build-isolation ." not in dockerfile_content
+41
View File
@@ -0,0 +1,41 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_user_guide_covers_startup_acceptance_no_exports_and_troubleshooting():
content = (ROOT / "docs" / "UserGuide.md").read_text(encoding="utf-8")
for required in [
"docker compose up -d --build",
"http://localhost:8000",
"1 × 1 × 10",
"5 × 5 × 50",
"文件导出",
"已关闭 CSV / Markdown 文件下载入口和接口",
"页面不展示 CSV / Markdown 下载入口",
"8000 端口被占用",
"API Key 缺失",
"任务长期 running",
"重置本地数据库",
"公网云服务器部署",
"python -m app.demo_seed",
"data/corrupt-backups",
]:
assert required in content
def test_deployment_doc_requires_public_access_decisions_before_going_online():
content = (ROOT / "docs" / "Deployment.md").read_text(encoding="utf-8")
for required in [
"上线前必须确认",
"部署方式",
"访问密码",
"真实 TikHub 和 AI Key",
"未经确认不要直接开放公网",
]:
assert required in content
assert "第一版公网演示使用云服务器 + Docker Compose,不增加登录或密码" not in content
+156
View File
@@ -0,0 +1,156 @@
from app.platforms.douyin import DouyinPlatform
def test_douyin_maps_hotspots():
platform = DouyinPlatform(client=None)
payload = {
"data": {
"word_list": [
{"query_id": "q1", "title": "热点", "rank": 2, "hot_score": "888"}
]
}
}
hotspots = platform.map_hotspots(payload, limit=5)
assert hotspots[0].source_hot_id == "q1"
assert hotspots[0].title == "热点"
assert hotspots[0].rank == 2
assert hotspots[0].heat_value == "888"
def test_douyin_maps_hotspots_from_real_item_list_shape():
platform = DouyinPlatform(client=None)
payload = {
"data": {
"item_list": [
{"query_id": "q-real", "sentence": "真实热点", "rank": 1, "hot_score": 999}
]
}
}
hotspots = platform.map_hotspots(payload, limit=5)
assert hotspots[0].source_hot_id == "q-real"
assert hotspots[0].title == "真实热点"
assert hotspots[0].rank == 1
assert hotspots[0].heat_value == "999"
def test_douyin_maps_videos():
platform = DouyinPlatform(client=None)
payload = {
"data": [
{
"aweme_info": {
"aweme_id": "aweme-1",
"desc": "视频描述",
"author": {"nickname": "作者"},
"statistics": {"comment_count": 10},
}
}
]
}
items = platform.map_items(payload, limit=5)
assert items[0].source_item_id == "aweme-1"
assert items[0].title == "视频描述"
assert items[0].item_type == "video"
def test_douyin_maps_videos_from_real_business_data_shape():
platform = DouyinPlatform(client=None)
payload = {
"data": {
"business_data": [
{
"data": {
"aweme_info": {
"aweme_id": "aweme-real",
"desc": "真实视频描述",
"share_url": "https://example.com/video",
}
}
}
]
}
}
items = platform.map_items(payload, limit=5)
assert items[0].source_item_id == "aweme-real"
assert items[0].title == "真实视频描述"
assert items[0].url == "https://example.com/video"
def test_douyin_maps_comment_id_and_missing_optional_fields():
platform = DouyinPlatform(client=None)
payload = {
"comments": [
{"comment_id": "preferred", "cid": "fallback", "text": "评论"},
{"cid": "fallback-only", "text": "评论2", "digg_count": 9},
]
}
comments = platform.map_comments(payload, limit=10)
assert comments[0].source_comment_id == "preferred"
assert comments[0].content == "评论"
assert comments[0].like_count is None
assert comments[0].comment_time is None
assert comments[1].source_comment_id == "fallback-only"
assert comments[1].like_count == 9
def test_douyin_maps_comments_when_user_is_none():
platform = DouyinPlatform(client=None)
payload = {"data": {"comments": [{"cid": "c-none-user", "text": "评论", "user": None}]}}
comments = platform.map_comments(payload, limit=10)
assert comments[0].source_comment_id == "c-none-user"
assert comments[0].author is None
def test_douyin_maps_null_comments_as_empty_list():
platform = DouyinPlatform(client=None)
payload = {"data": {"comments": None}}
assert platform.map_comments(payload, limit=10) == []
class RecordingDouyinClient:
def __init__(self):
self.requests = []
def get(self, path, *, params=None):
self.requests.append((path, params))
cursor = params["cursor"]
if cursor == 0:
return {
"data": {
"comments": [{"cid": f"c-{index}", "text": f"评论 {index}"} for index in range(20)],
"cursor": 20,
"has_more": 1,
}
}
return {
"data": {
"comments": [{"cid": f"c-{index}", "text": f"评论 {index}"} for index in range(20, 55)],
"cursor": 55,
"has_more": 0,
}
}
def test_douyin_fetch_comments_paginates_until_limit():
client = RecordingDouyinClient()
platform = DouyinPlatform(client=client)
comments = platform.fetch_comments("aweme-1", limit=50)
assert len(comments) == 50
assert comments[0].source_comment_id == "c-0"
assert comments[-1].source_comment_id == "c-49"
assert [request[1]["cursor"] for request in client.requests] == [0, 20]
+88
View File
@@ -0,0 +1,88 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.db import Base, create_sqlite_engine
from app.models import Comment, ContentItem, Hotspot, Report, Task, create_report_record
def test_all_t03_tables_can_be_created_in_memory_sqlite():
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
try:
Base.metadata.create_all(engine)
assert set(Base.metadata.tables) >= {
"tasks",
"hotspots",
"content_items",
"comments",
"reports",
}
finally:
engine.dispose()
def test_sqlite_engine_uses_required_connection_options():
engine = create_sqlite_engine("sqlite:///data/app.db")
try:
assert engine.url.database == "data/app.db"
assert engine.dialect.connect_args["check_same_thread"] is False
assert engine.dialect.connect_args["timeout"] == 10
finally:
engine.dispose()
def test_task_status_rejects_partial_status_values():
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
try:
Base.metadata.create_all(engine)
with Session(engine) as session:
session.add(Task(platform="douyin", status="partial_success"))
with pytest.raises(IntegrityError):
session.commit()
finally:
engine.dispose()
def test_analysis_status_insufficient_does_not_change_task_status():
task = Task(
platform="xiaohongshu",
status="success",
analysis_status="insufficient",
analysis_success_rate=0.5,
)
assert task.status == "success"
assert task.analysis_status == "insufficient"
def test_models_define_relationships_and_indexes():
assert any(index.name == "ix_comments_task_content_item" for index in Comment.__table__.indexes)
assert any(index.name == "ix_content_items_task_hotspot" for index in ContentItem.__table__.indexes)
assert any(index.name == "ix_reports_task_report_type" for index in Report.__table__.indexes)
assert Hotspot.task.property.mapper.class_ is Task
assert ContentItem.hotspot.property.mapper.class_ is Hotspot
assert Comment.content_item.property.mapper.class_ is ContentItem
assert Report.task.property.mapper.class_ is Task
def test_report_record_application_constraint_requires_matching_owner_id():
with pytest.raises(ValueError, match="hotspot_id"):
create_report_record(task_id="task-1", report_type="hotspot", title="热点报告")
with pytest.raises(ValueError, match="content_item_id"):
create_report_record(task_id="task-1", report_type="content_item", title="内容报告")
report = create_report_record(
task_id="task-1",
report_type="hotspot",
title="热点报告",
hotspot_id="hot-1",
)
assert report.hotspot_id == "hot-1"
+128
View File
@@ -0,0 +1,128 @@
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.db import Base
from app.models import Comment, ContentItem, Hotspot, Task
from app.services.report_service import DEFAULT_SUMMARY, build_comment_metrics, generate_hotspot_report, generate_item_report
def test_build_comment_metrics_counts_sentiments_and_top_labels():
comments = [
Comment(content="", sentiment="positive", labels='["质量好", "价格好"]', like_count=5),
Comment(content="", sentiment="negative", labels='["质量好"]', like_count=2),
Comment(content="一般", sentiment="neutral", labels="[]", like_count=1),
Comment(content="未知", sentiment="unknown", labels="[]", like_count=0),
]
metrics = build_comment_metrics(comments)
assert metrics["sample_count"] == 4
assert metrics["sentiment"]["positive"]["count"] == 1
assert metrics["sentiment"]["negative"]["count"] == 1
assert metrics["sentiment"]["neutral"]["count"] == 1
assert metrics["sentiment"]["unknown"]["count"] == 1
assert metrics["top_labels"][0] == {"name": "质量好", "count": 2}
def test_generate_item_report_persists_markdown_and_default_summary():
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
try:
with Session(engine) as session:
task = Task(platform="douyin", status="success")
session.add(task)
session.flush()
hotspot = Hotspot(task_id=task.id, platform="douyin", title="热点", raw_data="{}")
session.add(hotspot)
session.flush()
item = ContentItem(
task_id=task.id,
hotspot_id=hotspot.id,
platform="douyin",
source_item_id="v1",
item_type="video",
title="视频",
status="success",
raw_data="{}",
)
session.add(item)
session.flush()
session.add(
Comment(
task_id=task.id,
hotspot_id=hotspot.id,
content_item_id=item.id,
platform="douyin",
source_comment_id="c1",
content="好评",
sentiment="positive",
labels='["认可"]',
like_count=10,
comment_time=datetime(2026, 1, 1, tzinfo=UTC),
raw_data="{}",
)
)
session.commit()
report = generate_item_report(session, item.id, summary_provider=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("ai failed")))
assert report.report_type == "item"
assert report.content_item_id == item.id
assert "总结生成失败,请查看上方统计数据。" in report.markdown_content
assert "样本评论数量" in report.markdown_content
finally:
engine.dispose()
def test_generate_hotspot_report_persists_default_summary_when_ai_summary_fails():
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
try:
with Session(engine) as session:
task = Task(platform="xiaohongshu", status="success")
session.add(task)
session.flush()
hotspot = Hotspot(task_id=task.id, platform="xiaohongshu", title="热点", raw_data="{}")
session.add(hotspot)
session.flush()
item = ContentItem(
task_id=task.id,
hotspot_id=hotspot.id,
platform="xiaohongshu",
source_item_id="n1",
item_type="note",
title="笔记",
status="success",
raw_data="{}",
)
session.add(item)
session.flush()
session.add(
Comment(
task_id=task.id,
hotspot_id=hotspot.id,
content_item_id=item.id,
platform="xiaohongshu",
source_comment_id="c1",
content="好评",
sentiment="positive",
labels='["认可"]',
raw_data="{}",
)
)
session.commit()
report = generate_hotspot_report(
session,
hotspot.id,
summary_provider=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("summary failed")),
)
assert report.report_type == "hotspot"
assert report.hotspot_id == hotspot.id
assert report.summary == DEFAULT_SUMMARY
assert DEFAULT_SUMMARY in report.markdown_content
finally:
engine.dispose()
+22
View File
@@ -0,0 +1,22 @@
from concurrent.futures import ThreadPoolExecutor
from app.services import task_service
from app.config import Settings
def test_task_executor_is_single_worker_thread_pool():
assert isinstance(task_service.task_executor, ThreadPoolExecutor)
assert task_service.task_executor._max_workers == 1
def test_run_task_entrypoint_is_synchronous_function():
assert task_service.inspect.iscoroutinefunction(task_service.run_task) is False
def test_ai_dependencies_do_not_generate_report_summary_provider_without_ai_config(monkeypatch):
monkeypatch.setattr("app.services.task_service.get_settings", lambda: Settings(_env_file=None))
requester, summary_provider = task_service.build_ai_dependencies()
assert requester("prompt") == "[]"
assert summary_provider is None
+38
View File
@@ -0,0 +1,38 @@
from app.templating import from_json_filter, from_json_object_filter, progress_percent, rate_percent, sentiment_label, status_badge_config
def test_status_badge_config_centralizes_task_status_copy():
assert status_badge_config("running") == ("运行中", "bg-warning text-dark")
assert status_badge_config("success") == ("已完成", "bg-success")
assert status_badge_config("failed") == ("失败", "bg-danger")
assert status_badge_config("unknown") == ("未知", "bg-secondary")
def test_from_json_filter_returns_empty_list_for_invalid_json():
assert from_json_filter('["认可"]') == ["认可"]
assert from_json_filter("bad-json") == []
def test_from_json_object_filter_returns_empty_dict_for_invalid_json():
assert from_json_object_filter('{"sample_count": 1}') == {"sample_count": 1}
assert from_json_object_filter("[]") == {}
assert from_json_object_filter("bad-json") == {}
def test_progress_percent_clamps_invalid_and_overflow_values():
assert progress_percent(0, 0) == 0
assert progress_percent(1, 2) == 50
assert progress_percent(5, 2) == 100
def test_rate_percent_formats_ai_success_rate():
assert rate_percent(None) == 0
assert rate_percent(0.754) == 75
assert rate_percent(2.0) == 100
def test_sentiment_label_maps_report_copy():
assert sentiment_label("positive") == "正向"
assert sentiment_label("neutral") == "中性"
assert sentiment_label("negative") == "负向"
assert sentiment_label("unknown") == "未知"
+65
View File
@@ -0,0 +1,65 @@
from app.platforms.xiaohongshu import XiaohongshuPlatform
def test_xiaohongshu_maps_hotspots_from_items_not_outer_title():
platform = XiaohongshuPlatform(client=None)
payload = {
"data": {
"data": {
"title": "搜索发现",
"items": [
{"id": "hot-1", "title": "真实热点", "score": "999", "rank_change": 1}
],
}
}
}
hotspots = platform.map_hotspots(payload, limit=5)
assert hotspots[0].source_hot_id == "hot-1"
assert hotspots[0].title == "真实热点"
assert hotspots[0].heat_value == "999"
def test_xiaohongshu_prefers_notes_with_comments_and_falls_back_to_zero_comment_notes():
platform = XiaohongshuPlatform(client=None)
payload = {
"data": {
"data": {
"items": [
{"note": {"id": "n0", "title": "无评论", "desc": "", "comments_count": 0}},
{"note": {"id": "n1", "title": "有评论1", "desc": "d1", "comments_count": 3}},
{"note": {"id": "n2", "title": "有评论2", "desc": "d2", "comments_count": 1}},
]
}
}
}
items = platform.map_items(payload, limit=3)
assert [item.source_item_id for item in items] == ["n1", "n2", "n0"]
assert all(item.item_type == "note" for item in items)
def test_xiaohongshu_maps_comment_id_content_and_missing_optional_fields():
platform = XiaohongshuPlatform(client=None)
payload = {
"data": {
"data": {
"comments": [
{"comment_id": "preferred", "id": "fallback", "content": "正文"},
{"id": "fallback-only", "text": "文本字段", "like_count": 7},
]
}
}
}
comments = platform.map_comments(payload, limit=10)
assert comments[0].source_comment_id == "preferred"
assert comments[0].content == "正文"
assert comments[0].like_count is None
assert comments[0].comment_time is None
assert comments[1].source_comment_id == "fallback-only"
assert comments[1].content == "文本字段"
assert comments[1].like_count == 7
Generated
+937
View File
@@ -0,0 +1,937 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.14.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" },
{ url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" },
{ url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" },
{ url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" },
{ url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" },
{ url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" },
{ url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" },
{ url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" },
{ url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" },
{ url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" },
{ url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" },
{ url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" },
{ url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" },
{ url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" },
{ url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" },
{ url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" },
{ url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" },
{ url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" },
{ url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" },
{ url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" },
{ url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" },
{ url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" },
{ url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" },
{ url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" },
{ url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" },
{ url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" },
{ url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" },
{ url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" },
{ url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" },
{ url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" },
{ url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" },
{ url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" },
{ url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" },
{ url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" },
{ url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" },
{ url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" },
{ url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" },
{ url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" },
{ url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" },
{ url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" },
{ url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" },
{ url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" },
{ url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" },
{ url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" },
{ url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" },
{ url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" },
{ url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" },
{ url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" },
{ url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" },
{ url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" },
{ url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" },
{ url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" },
]
[[package]]
name = "fastapi"
version = "0.139.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" },
]
[[package]]
name = "greenlet"
version = "3.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
{ url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
{ url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
{ url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
{ url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
{ url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
{ url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
{ url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
{ url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
{ url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
{ url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
{ url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" },
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
{ url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
{ url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
{ url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
{ url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
{ url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" },
{ url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
{ url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
{ url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
{ url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
{ url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
{ url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
{ url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
{ url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
{ url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
{ url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
{ url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
{ url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
{ url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" },
{ url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
{ url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
{ url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
{ url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
{ url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
{ url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
{ url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
{ url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "hot-comments-analysis-tool"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-mock" },
{ name = "respx" },
]
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.12.3" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "jinja2", specifier = ">=3.1.4" },
{ name = "pydantic", specifier = ">=2.8.0" },
{ name = "pydantic-settings", specifier = ">=2.4.0" },
{ name = "sqlalchemy", specifier = ">=2.0.32" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.6" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.3.2" },
{ name = "pytest-cov", specifier = ">=5.0.0" },
{ name = "pytest-mock", specifier = ">=3.14.0" },
{ name = "respx", specifier = ">=0.21.1" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "respx"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.51"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" },
{ url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" },
{ url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" },
{ url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" },
{ url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" },
{ url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" },
{ url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" },
{ url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" },
{ url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" },
{ url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" },
{ url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" },
{ url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" },
{ url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" },
{ url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" },
{ url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" },
{ url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" },
{ url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" },
{ url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" },
{ url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" },
{ url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" },
{ url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" },
{ url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
]
[[package]]
name = "starlette"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.49.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]