53 Commits
Author SHA1 Message Date
meijiali 23baf4fd5f Merge pull request #2 from feat/issue-1-pencil-ui-polish
合并工单 #1 的 Pencil UI 优化
2026-07-07 18:50:49 +08:00
meijiali 00633a5510 feat: 按 Pencil 设计重做页面 UI 2026-07-07 18:43:48 +08:00
meijiali c22556430e feat: 使用 Pencil 优化首页 UI 2026-07-07 17:27:23 +08:00
meijiali 74b4e606fa docs: 更新 AGENTS 开发约定 2026-07-07 17:07:35 +08:00
meijiali 7f93f6fd72 docs: 将 AGENTS 指南翻译为中文 2026-07-07 11:45:15 +08:00
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
meijiali c04b13b5ed docs: enforce project-wide TDD workflow 2026-07-02 14:16:20 +08:00
meijiali ace3804f74 docs: add agent development guide 2026-07-02 11:43:04 +08:00
meijiali dfcfb68667 docs: update feature summary analysis status 2026-07-01 20:37:25 +08:00
meijiali 7eea937f28 docs: add TDD and task planning documents 2026-07-01 20:31:11 +08:00
79 changed files with 13107 additions and 11 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/
+314
View File
@@ -0,0 +1,314 @@
# AGENTS.md
## 目的
本文件定义 AI 编码代理在本仓库中的工作方式。它是操作指南,不是产品需求文档。
不要在这里复述或替代产品规格。产品需求、UI 决策、技术决策和测试期望都放在 `docs/` 中。
## 信息源
开始实现前,按以下顺序阅读相关文档:
1. `docs/PRD.md`
2. `docs/RequirementsDoc.md`(如果仓库中存在;不存在则跳过)
3. `docs/FeatureSummary.md`
4. `docs/DevelopmentPlan.md`
5. `docs/Tasks.md`
6. `docs/TDD.md`
7. `docs/UIDesign.md`
8. `docs/API-Spike-Xiaohongshu.md`
9. `docs/API-Spike-Douyin.md`
如果存在 `docs/review-*.md` 文件,请在对应源文档之后阅读。
评审文件包含修正和澄清,可覆盖源文档中的模糊点。
例如,阅读 `Tasks.md` 后再阅读 `review-Tasks-kiro.md`
使用 `docs/DevelopmentPlan.md` 判断架构和技术选型。使用 `docs/Tasks.md` 判断实现顺序。使用 `docs/TDD.md` 判断测试策略。使用 `docs/UIDesign.md` 判断页面结构和 UI 行为。使用 API Spike 文档判断平台字段映射和外部 API 流程。
如果文档之间存在冲突,或提供的上下文不足以做决定,
不要猜测或编造。立即停止执行,概述冲突或信息缺口,引用相关文件,
并在实现前向用户确认。该规则适用于所有层级的冲突,包括架构、
实现细节、命名约定和行为预期。
当源文档对必需决策表述模糊或没有说明时,绝不要带着假设继续。
如果无法在上述文档中找到答案,并且你自己的知识也不确定,请明确说明:
"I don't have enough context to decide this. Please provide [specific document or clarification]."
不要用虚构行为填补空白。
## 项目约束
- 先构建轻量级 MVP。
- 优先保证可工作的端到端流程,而不是大量未完成的功能。
- 整个项目遵循 TDDTest-Driven Development,测试驱动开发)。对于每个功能、
bug 修复、数据转换、服务或行为变更,先编写相关的失败测试,
再实现让测试通过的最小代码,然后只在测试通过后进行重构。
如果某个任务确实不适合测试先行,先说明原因,并添加尽可能贴近变更的验证覆盖。
- 保持架构与当前计划一致:FastAPI、SQLite、SQLAlchemy、Jinja2 模板、简单 CSS 或 Bootstrap、原生 JavaScript,以及 Docker Compose。
- 除非用户明确改变范围,否则不要引入前端 SPA 框架、Redis、Celery、PostgreSQL、登录系统、定时任务或分布式 worker。
- 不要提交真实 API key、token、cookie 或私密凭据。
- 将 TikHub 和 AI 提供商视为必须在测试中 mock 的外部依赖。
- 后台任务运行在 ThreadPoolExecutor 线程中,而不是 async event loop 中。
后台任务中的所有外部 HTTP 调用都使用同步 `httpx.Client`
不要在后台任务函数中使用 `httpx.AsyncClient``await`
- 报告在任务完成后预生成,并存储在 `reports` 表中。
页面渲染和文件导出必须读取同一份预生成报告数据。
不要在页面路由处理器中即时计算统计信息。
- 任务执行使用 `ThreadPoolExecutor(max_workers=1)`。一次只能运行一个任务。
如果已经存在 `status=running` 的任务,创建新任务时返回 HTTP 400。
## MVP 纪律(为速度优化)
无论时间线如何,§Project Constraints 中的约束始终生效。
本节提供优先级指导,不代表可以跳过 `docs/Tasks.md` 中定义的 P0 功能。
当用户要求快速交付时,优化目标是尽早拿出可演示的切片。
但必须交付 `docs/Tasks.md` §7 中定义的全部 P0 任务(T01-T23)。
未经用户明确确认,不得延期任何 P0 功能。
只有在用户明确确认后才可以延期的项目:
- Playwright e2e 测试(仅 T23 的 Playwright 部分)
- P1 可选任务(docs/Tasks.md §9:自动轮询、JSON 调试面板、进度条)
- 高级 UI 打磨(面包屑、`<title>` 命名、进度动画)
未经用户明确确认绝不能延期的项目:
- 僵尸任务恢复(T05
- 429 指数退避(T07
- 评论分页(T10
- AI 重试和降级(T13
- 报告预生成(T14/T15/T16
- CSV/Markdown 导出(T20
- Docker Compose 打包(T22
延期打磨、规模化、认证、调度以及未列入 `docs/Tasks.md` P0 范围的功能。
如果不确定某项是否属于 P0,请查看 `docs/Tasks.md`,它是权威任务列表。
## 开发流程
对于每个任务:
1. 检查相关文档和现有代码。
2. 确定最小的有用实现切片。
3. 在业务逻辑之前编写测试,覆盖:
- 数据映射和字段转换(platforms -> models
- AI JSON Schema 校验和解析
- 报告统计计算
- 导出格式(CSV 结构、Markdown 结构)
- 状态机转换(task status、analysis status
- 错误降级路径(重试耗尽 -> fallback behavior
对于 UI 模板、路由处理器接线和配置设置,
在实现之前或同时编写测试,但绝不能在实现之后补写。
这是 `docs/TDD.md` §2.1 的硬性规则:"禁止先实现后补测试。"
4. 实现让测试通过的最小代码。
5. 运行聚焦的验证命令。
6. 汇报变更内容、已验证内容和剩余事项。
保持变更范围聚焦。避免无关重构。如果某个文件或设计问题阻塞当前请求,
提出服务于当前目标的最小修正方案。
### 项目记忆和对话经验
每次有价值的对话迭代后,代理都应考虑是否需要把经验保存在本 `AGENTS.md` 文件中,
以便未来代理更好地理解项目。例如:成功解决了反复出现的问题、
确认了模糊的项目约定、发现了可靠工作流,或澄清了代理之间应如何协作。
不要静默添加不确定或推测性的规则。如果经验存在歧义、可能改变产品行为,
或可能与信息源文档冲突,请在更新 `AGENTS.md` 前向用户确认。
新增内容应简洁且具备可操作性。`AGENTS.md` 应记录持久的代理工作规则,
而不是替代属于 `docs/` 的产品需求、实现规格或详细任务计划。
### 错误处理理念
这些原则定义于 `docs/DevelopmentPlan.md` §10 和 `docs/TDD.md` §13。
在所有实现中一致应用:
- 单个条目的失败不得导致整个任务崩溃。按每条评论或每个内容项隔离失败。
- 外部 API 失败(4xx/5xx)应使用指数退避重试(1s -> 2s -> 4s),
然后优雅降级,并将错误记录到数据库中。
- AI 分析失败应按评论记录为 `ai_analysis_status=failed`
并反映到 `analysis_success_rate` 中,不得作为任务级失败向外抛出。
- 页面渲染绝不能因为缺少报告数据而返回 HTTP 500。
使用默认文本或空状态 UI 替代。
- 每个处理单元(内容项、评论批次)都应独立提交到数据库。
不要在整个任务期间持有一个长事务。
## 开发约定
- 修复任何工单必须在独立的 git worktree 中进行;开工前先用 `git worktree add` 创建专属工作目录,避免污染主工作区、便于多工单并行。修 CI 配置(Dockerfile、Drone 流水线等)可以直接在主仓库改,因为 CI 改动要打 tag 才能触发构建。
- 处理任何工单必须先检查并使用适用的 Superpowers Skill;在分析、提问、制定计划或改代码前,至少先启用 `using-superpowers`,并按任务性质继续使用 `systematic-debugging``test-driven-development``using-git-worktrees``verification-before-completion` 等相关技能。若判断没有适用技能,必须简短说明原因后再继续。
- 新功能需求类工单必须先使用 Superpowers 的 `brainstorming` 技能帮助澄清目标、约束和方案,再进入计划或实现;缺陷类工单必须先使用 `systematic-debugging` 技能复现问题并分析 root cause,再开始修复,禁止在根因未明确时直接改代码。
- 实现或修复工单完成后,必须继续按 Superpowers 收尾流程执行验证、代码审查、PR/合并准备和工作区清理;通常应依次使用 `verification-before-completion``requesting-code-review``finishing-a-development-branch` 等适用技能,在完成这些流程前不得声称工单已结束。
如果当前环境不支持 `superpowers:*` 前缀,请手动应用同样的认知顺序:
brainstorm -> plan -> test-first -> implement -> debug -> verify。进入验证阶段前,删除占位注释、调试 print/console.log,以及无说明的注释掉代码块。
## 多代理规则
仅在任务彼此独立、且可由主代理评审和集成时使用多个代理。
适合并行的任务:
- 审查文档是否存在冲突。
- 调研一个平台 API 映射。
- 为一个服务起草测试。
- 根据 `docs/UIDesign.md` 审查 UI 行为。
- 功能完成后审查实现中的 bug。
- 实现两个独立的平台适配器(例如 T08 Xiaohongshu 和 T09 Douyin)。
- 构建两个不共享数据查询的页面模板(例如 T17 和 T18)。
- 一个代理实现导出服务(T20),另一个代理处理模板宏(T21)。
- 一个代理为模块 A 编写单元测试,另一个代理实现与 A 没有依赖关系的模块 B。
避免为以下事项使用并行代理:
- 同时编辑同一个核心文件。
- 做出相互竞争的架构决策。
- 在没有单一负责人的情况下修改共享数据模型。
- 在没有集成计划的情况下实现广泛的横切变更。
主代理仍然负责最终决策、集成、验证和 Git 提交。
## 测试和验证
遵循 `docs/TDD.md`
默认验证目标:
```bash
pytest tests/unit -q
pytest tests/integration -q
pytest tests/unit tests/integration -q
curl -f http://localhost:8000/health
```
在声称里程碑或任务完成前,运行覆盖率检查:
```bash
pytest tests/unit tests/integration --cov=app --cov-branch --cov-report=term-missing
```
以下模块的目标行覆盖率为 80% 或以上:
- `app/platforms/`(所有平台适配器)
- `app/services/ai_service.py`
- `app/services/report_service.py`
- `app/services/export_service.py`
如果这些模块的覆盖率低于 80%,先补充测试再继续。
开发期间使用聚焦命令,然后在完成前运行更广泛的验证。不要在单元测试中依赖真实 TikHub 或 AI API 调用。mock 外部 HTTP 调用和 AI 响应。
对于 UI 工作,在可行时通过手动或浏览器自动化验证渲染页面。
检查文本不重叠、核心操作可见,并且页面符合 `docs/UIDesign.md`
## Git 工作流
使用小而聚焦的提交。一个提交应代表一个清晰变更。
### 单人提交纪律
对于这个单人项目,在完成每个独立功能/任务后,运行相关测试,
并为该任务相关文件创建一个聚焦的 git commit。除非用户明确要求,否则不要 push。
不要提交无关文件。
"独立功能/任务" 指能够被单独理解、测试和回滚的最小有用变更。例如:
- 一个 `docs/Tasks.md` 任务,例如 T07 API 重试、T20 导出或 T22 Docker。
- 一个狭窄 bug 修复,例如修复 401 错误显示或 CSV 换行处理。
- 一个内聚的页面或路由改进,例如添加任务详情页。
- 一个仅测试变更,用于记录或锁定某个行为。
不要把无关变更混在一个提交中。例如,不要把 Docker 部署、UI 重设计、
AI 重试逻辑和文档编辑合并在一个提交里,除非它们确实都严格服务于同一任务。
本项目的提交消息必须用中文书写,同时保留标准前缀。示例:
- `feat: 接入真实 AI 评论分析`
- `fix: 修复评论分页停止条件`
- `test: 补充导出 CSV 注入防护测试`
- `docs: 记录单人项目提交规则`
### 当前单人执行模式
当前项目阶段是初始单人开发和流程练习。除非用户明确启用并行工作或 PR 工作流,
否则按依赖顺序串行执行任务:一次一个任务。
除 CI 配置修复外,每个工单都应先在主仓库外创建独立 git worktree,再在该 worktree 中创建或检出对应任务分支。分支命名建议继续使用
`feat/tXX-short-description``fix/tXX-short-description` 或工单系统约定名称。
任务通过所需检查后,在可行时为该任务创建一个聚焦提交。
如果单个提交难以评审或安全回滚,大型任务可以拆分为多个有意义的提交。
前一个任务未完成评审、验证,并合并到工作基线或被明确批准作为下一个分支基线前,
不要开始下一个任务。除非用户要求,否则不要打开 PR。
推荐提交前缀:
- `docs:` 文档变更
- `feat:` 新的用户可见功能
- `fix:` bug 修复
- `test:` 仅测试
- `chore:` 维护、工具或项目设置
提交前:
```bash
git status --short
git diff
git diff --check
```
只提交与当前任务相关的文件。不要回滚无关的用户变更。只有在提交已验证且用户希望更新远端分支时才 push。
### 分支与 Worktree 策略
工单开发默认不直接在主工作区或 `main` 上修改业务代码。开工前从当前 `main` 创建专属 git worktree,并在 worktree 中使用任务分支完成开发。
修 CI 配置(Dockerfile、Drone 流水线等)可以直接在主仓库改,因为 CI 改动要打 tag 才能触发构建。除该例外外,如需直接在主仓库改动,必须先得到用户明确确认。
多代理并行开发:每个代理必须在从当前 `main` 派生的独立 Git 分支和独立 worktree 上工作。
分支命名约定:`agent/<agent-id>/<task-id>`(例如 `agent/codex-1/T08`)。
只有主代理(或用户)可以合并回 `main`
合并前,该分支必须通过 §Testing And Verification 中定义的所有测试。
绝不能让两个代理在不同分支上同时编辑同一个文件。
如果任务依赖要求触碰同一文件,请串行处理。
## 安全规则
- 绝不要把密钥保存在源文件、文档、测试、fixture 或提交消息中。
- `.env.example` 只用于变量名。
- 仅在文档要求时保留原始外部 API 响应,并避免在 fixture 中包含私有用户数据。
- 在测试 fixture 中,将真实用户名、头像 URL、用户 ID 和 IP 地址替换为占位值
(例如 "test_user_001"、"https://example.com/avatar.png"、
"user_id_placeholder_001")。不要未经脱敏就把生产 API 响应直接复制到 fixture 文件中。
- 数据库中的 `raw_data` JSON 字段可能包含用户生成内容。
编写断言 `raw_data` 的测试时,只使用合成 fixture 数据。
- 如果对话中暴露了凭据,提醒用户轮换或删除它们。
- 除非用户明确要求,否则不要使用破坏性 Git 命令。
## 完成标准
只有满足以下条件,任务才算完成:
1. 请求的行为或文档已经存在。
2. 已运行相关测试或检查,或说明了无法运行的原因。
3. 工作范围限定在请求内。
4. 最终回复用通俗语言解释结果。
5. 清楚列出任何剩余风险或后续任务。
6. 如果完成的任务对应 `docs/Tasks.md` 中的复选框,将其标记为完成
(把 `- [ ]` 改为 `- [x]`)。
7. 最终回复必须明确说明哪些测试已运行并通过、哪些边界情况已验证或 mock,
以及当前实现的已知限制。
---
## 变更日志
| 日期 | 版本 | 变更 |
|---|---|---|
| 2025-07-10 | v1.0 | 初始版本 |
| 2025-07-10 | v1.1 | 基于双重评审合并后的修订(12 条指令):§Two-Day MVP Discipline 重写为 "MVP Discipline (Optimized for Speed)",不再是裁剪清单,并明确禁止未经用户确认延期 P0 功能;§Project Constraints 增加三项架构约束(后台线程中仅使用同步 httpx、预生成报告、单任务 executor 且冲突时返回 400);§Source Of Truth 增加评审文件纳入规则、冲突时绝对停止策略、防幻觉指令和 RequirementsDoc.md 存在性保护;§Development Workflow 中的 TDD 指令从 "when practical" 升级为按类别强制执行,并明确引用 TDD.md §2.1;新增 Error Handling Philosophy 小节;§Testing And Verification 增加覆盖率命令和 80% 目标;§Git Workflow 增加分支策略和强制多代理分支隔离;§Multi-Agent Rules 增加 4 个构建类并行任务示例;§Superpowers Workflow 增加环境兼容性说明和验证前清理规则;§Safety Rules 增加 raw_data fixture 脱敏指导;§Completion Standard 增加 Tasks.md 复选框同步要求和明确测试结果汇报要求。 |
| 2026-07-07 | v1.2 | 融入新的开发约定:工单默认使用独立 git worktree;处理工单前必须检查并使用适用的 Superpowers Skill;新功能先 brainstorming,缺陷先 systematic-debugging;完成后按 verification、code review、finishing branch 流程收尾。同步删除旧的直接在 main 上工作的单代理分支规则,避免与 worktree 约定冲突。 |
+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 @@
+1322
View File
File diff suppressed because it is too large Load Diff
+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 %}
+34
View File
@@ -0,0 +1,34 @@
{% 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 %}
{% if report %}
{% set metrics = report.metrics_json | from_json_object %}
{% set sentiment = metrics.get("sentiment", {}) %}
{% set positive_pct = sentiment.get("positive", {}).get("pct", 0) %}
<section class="report-reading-hero">
<div class="report-title-block">
<p class="section-kicker">HOTSPOT REPORT</p>
<h1>{{ hotspot.title }}</h1>
<p>{{ report.summary or "报告总结生成失败,请查看下方结构化统计。" }}</p>
</div>
<aside class="sentiment-summary-card">
<span>整体情绪</span>
<strong>正向 {{ positive_pct }}%</strong>
<p>基于 {{ metrics.get("sample_count", 0) }} 条评论样本生成,结论来自预生成报告数据。</p>
</aside>
</section>
{% 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 %}
+124
View File
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% set has_running_tasks = tasks | selectattr("status", "equalto", "running") | list | length > 0 %}
{% block title %}热榜评论雷达 - 热榜评论分析工具{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<section class="control-hero">
<div class="control-hero-copy">
<p class="section-kicker">COMMENT INTELLIGENCE</p>
<p class="control-subtitle">内容平台评论分析控制台</p>
<h1>用默认配置快速启动一次评论分析</h1>
<p>选择平台后直接开始抓取,系统会按 5 个热点、25 条内容、最多 1,250 条评论完成采集、AI 分析和报告生成。</p>
</div>
<section class="quick-launch-panel" id="create-task" aria-label="快速创建抓取任务">
<div class="panel-title-row">
<div>
<p class="section-kicker">采集配置</p>
<h2>默认抓取方案</h2>
</div>
<span class="soft-badge soft-badge-success">推荐</span>
</div>
<div id="form-error" class="alert alert-danger d-none" role="alert"></div>
<form id="task-form" onsubmit="submitTask(event)">
<div class="default-scale-grid" aria-label="默认任务规模">
<div class="scale-tile">
<strong>5</strong>
<span>热点</span>
</div>
<div class="scale-tile">
<strong>25</strong>
<span>内容</span>
</div>
<div class="scale-tile">
<strong id="scale-calc">1250</strong>
<span>评论上限</span>
</div>
</div>
<div class="quick-form-grid">
<div>
<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>
<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>
<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>
<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>
<button class="btn btn-primary quick-start-btn" id="submit-btn" type="submit">开始抓取</button>
</div>
<p class="scale-hint" id="scale-hint">实际数量可能受平台返回数量、去重、失败、限流影响。</p>
</form>
</section>
</section>
<section class="overview-grid" aria-label="任务指标概览">
<div class="overview-card">
<span>最近任务</span>
<strong>{{ tasks | length }}</strong>
<small>{% if has_running_tasks %}1 个任务运行中{% else %}暂无运行中任务{% endif %}</small>
</div>
<div class="overview-card">
<span>默认规模</span>
<strong>1,250</strong>
<small>5 热点 / 25 内容 / 评论上限</small>
</div>
<div class="overview-card">
<span>平台范围</span>
<strong>2</strong>
<small>小红书与抖音</small>
</div>
<div class="overview-card">
<span>任务策略</span>
<strong>1</strong>
<small>同一时间只运行一个任务</small>
</div>
</section>
<div class="dashboard-workspace">
<section class="task-history-panel" id="task-history">
<div class="panel-title-row">
<div>
<p class="section-kicker">任务历史</p>
<h2>最近任务</h2>
</div>
<span class="panel-note">{% if has_running_tasks %}自动更新中{% else %}卡片化展示,减少表格压迫感 · 运行中任务自动刷新{% endif %}</span>
</div>
<div class="task-card-list" {% if has_running_tasks %}data-task-list-auto-poll="true"{% endif %}>
{% include "partials/task_cards.html" %}
</div>
</section>
<aside class="task-status-sidepanel">
<section class="sidepanel-card">
<div class="sidepanel-icon"></div>
<h2>运行中任务</h2>
<p>当前最多只允许一个任务运行,避免外部 API 限流和结果混淆。</p>
<div class="stage-list">
<span><i class="stage-dot stage-dot-success"></i>热点抓取</span>
<span><i class="stage-dot stage-dot-success"></i>内容采集</span>
<span><i class="stage-dot stage-dot-active"></i>评论分页</span>
<span><i class="stage-dot"></i>AI 分析</span>
</div>
</section>
<section class="sidepanel-card sidepanel-card-blue">
<div class="sidepanel-icon"></div>
<h2>看板重点回到动作</h2>
<p>首页围绕“开始抓取、看状态、进详情”组织信息,弱化表格压迫感。</p>
</section>
</aside>
</div>
{% 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 %}
+117
View File
@@ -0,0 +1,117 @@
{% 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") %}
{% set positive = sentiment.get("positive", {"count": 0, "pct": 0}) %}
{% set neutral = sentiment.get("neutral", {"count": 0, "pct": 0}) %}
{% set negative = sentiment.get("negative", {"count": 0, "pct": 0}) %}
{% if task.analysis_status == "insufficient" %}
<div class="alert alert-warning">当前有效评论样本不足,AI 总结暂不可用。建议增加评论抓取数量后重新分析。</div>
{% endif %}
<div class="report-reading-layout">
<section class="report-main-column">
<section class="insight-card">
<h2>核心结论</h2>
<div class="insight-list">
<p><span></span>{{ report.summary or "总结生成失败,请优先查看情绪分布、Top 标签和典型评论。" }}</p>
<p><span></span>评论样本 {{ sample_count }} 条{% if item_count is not none %},关联内容 {{ item_count }} 条{% endif %}AI 成功率 {{ rate_percent(task.analysis_success_rate) }}%。</p>
{% if top_labels %}
<p><span></span>当前高频讨论方向集中在{% for label in top_labels[:3] %}{{ "、" if not loop.first }}{{ label.get("name") }}{% endfor %}。</p>
{% else %}
<p><span></span>暂无高频标签数据,建议结合评论明细人工复核。</p>
{% endif %}
</div>
</section>
<section class="representative-comments">
<div class="panel-title-row">
<div>
<p class="section-kicker">COMMENTS</p>
<h2>代表评论 · 典型评论</h2>
</div>
<span class="panel-note">按情绪和点赞综合筛选</span>
</div>
{% for key in ["positive", "neutral", "negative"] %}
<article class="quote-card">
<span class="soft-badge">{{ sentiment_label(key) }}</span>
{% for comment in typical.get(key, []) %}
<p>{{ comment.get("content") }}</p>
<small>点赞 {{ comment.get("like_count", 0) }}</small>
{% else %}
<p>暂无{{ sentiment_label(key) }}代表评论</p>
{% endfor %}
</article>
{% endfor %}
</section>
<section class="content-performance-card">
<h2>内容表现</h2>
<div class="performance-row">
<div>
<strong>评论样本</strong>
<span>用于本次报告统计的有效评论</span>
</div>
<em>{{ sample_count }} 条</em>
</div>
{% if item_count is not none %}
<div class="performance-row">
<div>
<strong>关联内容</strong>
<span>参与热点级聚合的内容条目</span>
</div>
<em>{{ item_count }} 条</em>
</div>
{% endif %}
<div class="performance-row">
<div>
<strong>高频标签</strong>
<span>用于定位主要讨论方向</span>
</div>
<em>{{ top_labels | length }} 个</em>
</div>
</section>
</section>
<aside class="report-side-column">
<section class="sentiment-sidepanel">
<h2>情绪分布</h2>
{% for key, item, class_name in [("positive", positive, "sentiment-positive"), ("neutral", neutral, "sentiment-neutral"), ("negative", negative, "sentiment-negative")] %}
<div class="sentiment-row">
<div class="progress-label-row">
<span>{{ sentiment_label(key) }}</span>
<span>{{ item.get("count", 0) }} 条({{ item.get("pct", 0) }}%</span>
</div>
<div class="progress 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 {{ class_name }}" style="width: {{ item.get("pct", 0) }}%"></div>
</div>
</div>
{% endfor %}
</section>
<section class="keyword-sidepanel">
<h2>Top 标签 / 关键词</h2>
{% if top_labels %}
<div class="keyword-cloud">
{% for label in top_labels %}
<span>{{ label.get("name") }} ({{ label.get("count", 0) }})</span>
{% endfor %}
</div>
{% else %}
<p class="text-muted mb-0">暂无标签数据</p>
{% endif %}
</section>
<section class="report-data-note">
<h2>同一份报告数据</h2>
<p>页面展示、Markdown 和 CSV 导出都应读取预生成报告,避免页面和文件结果不一致。</p>
<div class="ghost-actions" aria-label="报告数据说明">
<span>预生成报告</span>
<span>结构化统计</span>
</div>
</section>
</aside>
</div>
+44
View File
@@ -0,0 +1,44 @@
{% 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) %}
<article class="task-card">
<div class="task-card-number">
<strong>{{ task.display_id or loop.index }}</strong>
<span>{{ task.platform | platform_label }}</span>
</div>
<div class="task-card-body">
<div class="task-card-title-row">
<div>
<h3>{{ status_label }}</h3>
<p>{{ task.scale_label }} · {{ task.created_at_label }}</p>
</div>
<span class="badge {{ status_class }}">{{ status_label }}</span>
</div>
<div class="task-card-progress">
<div class="progress-label-row">
<span>已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
<span>{{ percent }}%</span>
</div>
<div class="progress task-progress" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar" style="width: {{ percent }}%"></div>
</div>
</div>
<div class="task-card-meta">
<span>成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</span>
<span>评论 {{ task.comments_count or 0 }}</span>
<span>报告 {{ task.reports_count or 0 }}</span>
<span>AI 成功率 {{ ai_percent }}%</span>
{% if task.analysis_status == "insufficient" %}
<span class="text-warning">AI 样本不足</span>
{% endif %}
</div>
{% if task.error_summary %}
<p class="task-card-error">{{ task.error_summary }}</p>
{% endif %}
</div>
<a class="btn btn-sm btn-outline-secondary task-card-action" href="/tasks/{{ task.id }}">查看</a>
</article>
{% else %}
<div class="empty-state text-muted py-5">还没有任何任务,请在上方创建第一个任务</div>
{% endfor %}
+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 %}
+112
View File
@@ -0,0 +1,112 @@
{% 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 %}
{% set label, cls = 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) %}
{% set target_comments = task.hotspot_limit * task.item_limit_per_hotspot * task.comment_limit_per_item %}
<section class="task-detail-hero" {% if task.status == "running" %}data-task-id="{{ task.id }}" data-auto-poll="true"{% endif %}>
<div class="task-detail-copy">
<p class="section-kicker">TASK DETAIL</p>
<h1>{{ task.platform | platform_label }}热点评论采集任务 {{ task.display_id }}</h1>
<p>创建时间 {{ task.created_at_label }},当前阶段 {{ task.current_stage_label or "等待启动" }}。任务执行过程会隔离单条内容失败,并保留失败原因用于复查。</p>
</div>
<aside class="task-state-card">
<span>当前状态</span>
<strong>{{ label }}</strong>
<p>已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容 · AI 成功率 {{ ai_percent }}% · 报告 {{ task.reports_count or 0 }} 份 · 失败内容 {{ task.failed_items_count }}</p>
</aside>
</section>
<section class="execution-timeline" aria-label="执行阶段">
{% for stage, desc, state in [
("创建任务", "参数校验完成", "done"),
("热点抓取", (task.hotspots_count or 0) ~ " 个热点入库", "done" if (task.hotspots_count or 0) > 0 or task.status == "success" else "pending"),
("内容采集", task.processed_items_count ~ " / " ~ task.total_items_count ~ " 条内容", "done" if task.processed_items_count >= task.total_items_count and task.total_items_count > 0 else "active"),
("评论分页", (task.comments_count or 0) ~ " 条评论", "done" if (task.comments_count or 0) > 0 else "pending"),
("AI 分析", "成功率 " ~ ai_percent ~ "%", "done" if ai_percent >= 80 else "active"),
("报告生成", (task.reports_count or 0) ~ " 份报告", "done" if (task.reports_count or 0) > 0 else "pending")
] %}
<div class="execution-step execution-step-{{ state }}">
<span class="execution-step-icon">{% if state == "done" %}✓{% elif state == "active" %}•{% else %}○{% endif %}</span>
<strong>{{ stage }}</strong>
<small>{{ desc }}</small>
</div>
{% endfor %}
</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="detail-workspace">
<section class="hotspot-card-list">
<div class="panel-title-row">
<div>
<p class="section-kicker">执行阶段</p>
<h2>热点结果</h2>
</div>
<span class="panel-note">按热度与评论完成度查看</span>
</div>
{% for hotspot in hotspots %}
<article class="hotspot-result-card">
<div class="hotspot-rank">{{ "%02d"|format(hotspot.rank or loop.index) }}</div>
<div class="hotspot-result-body">
<h3>热点 {{ hotspot.rank or loop.index }}{{ hotspot.title }}</h3>
<div class="hotspot-chip-row">
<span>{{ hotspot.content_items | length }} 内容</span>
<span>{{ hotspot.comments | length }} 评论</span>
<span>{{ task.platform | platform_label }}</span>
</div>
</div>
<a class="btn btn-sm btn-primary" href="/hotspots/{{ hotspot.id }}/report">查看报告</a>
</article>
{% else %}
<div class="empty-state text-muted py-5">暂无热点结果</div>
{% endfor %}
</section>
<aside class="detail-sidepanel">
<section class="sidepanel-card">
<h2>采集规模</h2>
<div class="stat-line"><span>目标上限</span><strong>{{ task.hotspot_limit }} 热点 × {{ task.item_limit_per_hotspot }} 内容 × {{ task.comment_limit_per_item }} 评论</strong></div>
<div class="stat-line"><span>实际结果</span><strong>实际评论 {{ task.comments_count or 0 }}</strong></div>
<div class="stat-line"><span>热点完成度</span><strong>已获取热点 {{ task.hotspots_count or 0 }} / 目标 {{ task.hotspot_limit }}</strong></div>
<div class="stat-line"><span>热点数</span><strong>{{ task.hotspot_limit }}</strong></div>
<div class="stat-line"><span>内容数</span><strong>{{ task.total_items_count }}</strong></div>
<div class="stat-line"><span>评论数</span><strong>{{ task.comments_count or 0 }}</strong></div>
<div class="stat-line"><span>报告数</span><strong>{{ task.reports_count or 0 }}</strong></div>
<div class="stat-line"><span>运行时长</span><strong>{{ task.running_duration_label or "刚刚" }}</strong></div>
<div class="stat-line"><span>最近进度</span><strong>{{ task.last_progress_ago_label or "暂无记录" }}</strong></div>
</section>
<section class="sidepanel-card sidepanel-card-danger">
<h2>错误会被隔离</h2>
<p>单条内容或评论失败不会让整个任务崩溃,失败原因会保留在对应结果和报告数据中。</p>
{% if task.error_summary %}<p class="task-card-error">{{ task.error_summary }}</p>{% endif %}
</section>
</aside>
</div>
{% if task.is_progress_stale %}
<div class="alert alert-warning mt-3">
超过 {{ task.stale_threshold_minutes }} 分钟没有进度更新,可能仍在等待外部接口或 AI 响应;如果长时间不恢复,请刷新状态或查看后端日志。
</div>
{% endif %}
{% if task.status == "success" and (task.comments_count or 0) < target_comments %}
<div class="alert alert-info mt-3">少于理论上限通常是内容本身评论不足或平台返回不足,不直接代表任务失败。若失败内容数大于 0,请结合失败原因判断。</div>
{% endif %}
{% if task.analysis_status == "insufficient" %}
<div class="alert alert-warning mt-3">AI 成功率 {{ ai_percent }}%,当前样本可能不足,报告结论请结合评论明细判断。</div>
{% endif %}
{% 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
```
+10 -11
View File
@@ -70,16 +70,14 @@ MVP 核心流程:
- 任务最终状态规则:
- 没有任何内容条目成功完成抓取和分析时,任务失败;
- 至少 1 条内容条目成功完成抓取和分析时,任务可标记成功,并展示失败内容条目数或错误摘要。
- AI 分析质量不扩展任务状态模型,使用独立字段记录:
- AI 分析质量不扩展任务状态模型,使用成功率字段记录:
- `analysis_success_rate`:任务内成功生成情绪分类和方向标签的评论占比;
- `analysis_status`:AI 分析质量状态,可取值为正常、分析不足;
-`analysis_success_rate < 80%` 时,任务状态仍可按内容条目处理结果标记成功,但 `analysis_status` 标记为分析不足。
- `analysis_success_rate < 80%` 时,任务状态仍按内容条目处理结果标记成功或失败,但页面须展示 AI 分析成功率不足提示。
- 任务数据模型须包含:
- `total_items_count`:任务内计划或实际纳入处理的内容条目总数;
- `processed_items_count`:已完成抓取与分析处理的内容条目数;
- `successful_items_count`:成功完成抓取与分析的内容条目数。
- `analysis_success_rate`
- `analysis_status`
- `analysis_success_rate`
核心验收:
@@ -212,7 +210,8 @@ fetch_creator_hot_spot_billboard
- 已获取内容条目数;
- `total_items_count`
- `processed_items_count`
- `successful_items_count`
- `successful_items_count`
- `analysis_success_rate`
- 热点数据:
- 平台;
- 任务 ID
@@ -272,7 +271,7 @@ fetch_creator_hot_spot_billboard
- 后端须对 LLM 输出做 JSON Schema 校验;
- 解析失败时触发重试,最多 N 次,N 由 DevelopmentPlan 结合所选 AI 服务确认,建议 3 次;
- AI 返回无法解析或缺失必填字段时,单条评论标记为未知、空标签或分析失败,不阻塞其他评论。
- 任务完成后统计 `analysis_success_rate`;低于 80% 时写入 `analysis_status = 分析不足`,但不改变任务成功 / 失败状态。
- 任务完成后统计 `analysis_success_rate`;低于 80% 时页面展示 AI 分析成功率不足提示,但不改变任务成功 / 失败状态。
核心验收:
@@ -365,7 +364,7 @@ fetch_creator_hot_spot_billboard
- 刷新任务列表按钮;
- 任务创建时间;
- 任务状态;
- AI 分析状态或分析成功率;
- AI 分析成功率或成功率不足提示
- 成功 X / 共 Y 条内容条目;
- 任务基础信息;
- 错误原因;
@@ -374,7 +373,7 @@ fetch_creator_hot_spot_billboard
- 平台;
- 抓取时间或任务标识;
- 任务状态和错误原因;
- AI 分析状态或分析成功率;
- AI 分析成功率或成功率不足提示
- 成功 X / 共 Y 条内容条目;
- 热点排名;
- 热点标题或摘要;
@@ -482,7 +481,7 @@ fetch_creator_hot_spot_billboard
- 尝试继续处理剩余热点或内容条目;
- AI 单条解析失败时,该评论标记为未知或分析失败;
- 用户输入非法抓取规模配置值时,前端提示具体字段错误并阻止提交;
- 不新增“部分成功”或“部分失败”任务状态;当 AI 结构化成功率不足但仍有可查看结果时,统一使用 `analysis_status` 标记分析不足
- 不新增“部分成功”或“部分失败”任务状态;当 AI 结构化成功率不足但仍有可查看结果时,页面展示 AI 分析成功率不足提示
核心验收:
@@ -604,7 +603,7 @@ MVP 完成时至少需要满足:
3. 系统可获取默认 Top 5 热点。
4. 系统可为每个热点拆分默认最多 5 条内容条目。
5. 系统可为每条内容条目抓取默认最多 50 条一级评论。
6. 任务内至少 80% 的评论成功生成情绪分类和方向标签,视为该验收项通过;低于此比例时任务状态仍遵循成功 / 失败规则,但 `analysis_status` 须标记为分析不足,并在页面展示提示。
6. 任务内至少 80% 的评论成功生成情绪分类和方向标签,视为该验收项通过;低于此比例时任务状态仍遵循成功 / 失败规则,但页面须展示 AI 分析成功率不足提示。
7. 系统可生成热点级汇总报告。
8. 系统可生成内容条目级分析报告。
9. 页面可查看任务列表、热点列表、热点级报告、内容条目详情和评论明细。
File diff suppressed because it is too large Load Diff
+914
View File
@@ -0,0 +1,914 @@
# TDD.md:小红书 / 抖音热榜评论抓取 + AI 分析报告工具
## 1. 文档信息
- 文档阶段:TDDTest-Driven Development,测试驱动开发计划)
- 需求依据:`docs/PRD.md``docs/FeatureSummary.md`
- 技术依据:`docs/DevelopmentPlan.md`
- UI 依据:`docs/UIDesign.md`
- 当前目标:定义 MVP 开发前必须先写的测试范围、测试顺序、Mock 策略、验收命令与端到端测试清单
- 测试原则:先写失败测试,再写最小实现;任何业务代码变更前必须先有对应测试
---
## 2. TDD 总原则
### 2.1 红绿重构流程
每个功能点按以下顺序执行:
1. **Red**:先写一个最小失败测试。
2. **Verify Red**:运行测试,确认失败原因是目标功能缺失,而不是测试代码错误。
3. **Green**:写最小实现让测试通过。
4. **Verify Green**:运行该测试和相关测试,确认通过。
5. **Refactor**:只在测试通过后清理代码。
禁止事项:
- 禁止先实现后补测试。
- 禁止为了让测试通过而删除关键断言。
- 禁止用真实 TikHub / AI API 作为单元测试依赖。
- 禁止在模板中硬编码中文状态文案,状态展示必须通过映射或 Macro 测试覆盖。
### 2.2 测试分层
| 层级 | 工具 | 目标 |
|---|---|---|
| 单元测试 | pytest | 字段映射、配置校验、分页、统计、导出、AI schema |
| 服务集成测试 | pytest + TestClient + mock httpx | 任务创建、后台流程、平台链路、报告生成 |
| 模板测试 | pytest + Jinja2 渲染 | 状态 Badge、空状态、导出按钮、标签渲染 |
| 端到端测试 | Playwright | 首页创建任务、查看报告、导出按钮、手动刷新 |
| Docker 验收 | docker compose + curl | 启动、`/health`、页面可访问 |
### 2.3 测试目录建议
```text
tests/
conftest.py
fixtures/
xhs_hot_list.json
xhs_search_notes.json
xhs_comments_page_1.json
xhs_comments_page_2_empty.json
douyin_hot_list.json
douyin_search_videos.json
douyin_comments_page_1.json
ai_comments_success.json
ai_comments_invalid_json.txt
unit/
test_config.py
test_models.py
test_xiaohongshu_mapping.py
test_douyin_mapping.py
test_comment_pagination.py
test_ai_schema.py
test_report_stats.py
test_export.py
test_template_filters.py
integration/
test_task_creation.py
test_task_flow_xiaohongshu.py
test_task_flow_douyin.py
test_failure_tolerance.py
test_routes.py
e2e/
test_mvp_flow.py
```
---
## 3. 测试环境与命令
### 3.1 推荐依赖
```text
pytest
pytest-cov
pytest-mock
respx
httpx
beautifulsoup4
playwright
```
说明:
- `respx` 用于 mock `httpx.Client` 外部 HTTP 调用。
- `beautifulsoup4` 用于断言 HTML 片段内容。
- Playwright 仅覆盖关键页面流程,不替代单元测试。
#### 工具使用原则
- `respx`:用于 mock `httpx.Client` 发出的外部 HTTP 请求(TikHub API、AI API)。
- `pytest-mock`:用于 mock Python 函数、类方法、`time.sleep` 调用和时间函数。
- 两者不混用于同一外部请求的 mock。即:若某个测试需要拦截一个 HTTP 请求,必须使用 `respx`,不得用 `pytest-mock.patch("httpx.Client.get")` 替代。
### 3.2 测试数据库隔离与环境变量
#### 测试数据库隔离策略
所有单元测试和集成测试统一使用内存数据库 `sqlite:///:memory:`,不依赖物理 `test.db` 文件,避免测试用例间状态污染(Flaky Tests)。
`tests/conftest.py` 中定义函数级别(`scope="function"`)的数据库 fixture
```python
@pytest.fixture(scope="function")
def db_session():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
session = Session(engine)
yield session
session.close()
Base.metadata.drop_all(engine)
```
每个测试用例运行前自动创建全部表结构,运行后自动销毁,确保测试在绝对隔离的环境中执行。
禁止在集成测试中复用同一 engine 实例跨用例写入数据而不做 teardown。
#### 环境变量
```text
APP_ENV=test
DATABASE_URL=sqlite:///:memory:
TIKHUB_API_KEY=test-token
TIKHUB_BASE_URL=https://api.tikhub.test
AI_PROVIDER=openai-compatible
AI_BASE_URL=https://ai.test
AI_API_KEY=test-ai-key
AI_MODEL=test-model
AI_BATCH_SIZE=20
AI_CONCURRENCY=2
AI_MAX_RETRIES=3
AI_TIMEOUT_SECONDS=30
```
### 3.3 常用命令
```bash
pytest tests/unit -q
pytest tests/integration -q
pytest tests/unit tests/integration \
--cov=app \
--cov-branch \
--cov-report=term-missing
pytest tests/e2e -q
docker compose up --build
curl -f http://localhost:8000/health
```
#### 覆盖率豁免配置
`pyproject.toml``[tool.coverage.report]` 节中添加以下配置,将模板层和静态资源排除在覆盖率统计之外:
```toml
[tool.coverage.report]
omit = [
"app/templates/*",
"app/static/*",
"tests/*",
]
```
全局覆盖率目标维持 80%`app/services/``app/platforms/` 下的核心域覆盖率不低于 90%(通过 CI 脚本或 code review 人工把关,不在 pytest 命令层面强制差异化)。
---
## 4. Mock 数据规范
### 4.1 小红书 Mock 样例
`xhs_hot_list.json` 必须覆盖:
- `data.data.items[]` 是真实热榜条目;
- 外层 `data.data.title` 不应被误用为热点标题;
- 热点字段包含 `id``title``score`
`xhs_search_notes.json` 必须覆盖:
- 至少 2 条 `comments_count > 0` 的笔记;
- 至少 1 条 `comments_count = 0` 的笔记;
- 字段包含 `note.id``note.title``note.desc``note.comments_count`
`xhs_comments_page_1.json` 必须覆盖:
- 评论 ID 同时存在 `comment_id``id` 时,优先 `comment_id`
- 评论正文可来自 `content``text`
- 包含 `like_count``create_time`
### 4.2 抖音 Mock 样例
`douyin_hot_list.json` 必须覆盖:
- 热点字段包含 `query_id``title``rank``hot_score`
`douyin_search_videos.json` 必须覆盖:
- 视频字段包含 `aweme_info.aweme_id``aweme_info.desc``aweme_info.author``aweme_info.statistics`
`douyin_comments_page_1.json` 必须覆盖:
- 评论 ID 同时存在 `comment_id``cid` 时,优先 `comment_id`
- 评论正文来自 `text`
- 包含 `digg_count``create_time`
### 4.3 AI Mock 样例
成功响应:
```json
[
{
"comment_id": "c1",
"sentiment": "positive",
"labels": ["价格实惠", "质量好"],
"reason": "评论表达了明确认可"
}
]
```
失败响应至少覆盖:
- 非 JSON 文本;
- JSON Object 而不是 JSON Array
- 缺失 `comment_id`
- `comment_id` 与输入不匹配;
- `sentiment` 不在枚举值内;
- `labels` 超过 3 个;
- 混入 Markdown 或自然语言前缀。
### 4.4 HTTP 429 Mock Fixture
`tests/conftest.py` 中提供可复用的 429 响应 fixture
```python
@pytest.fixture
def mock_429_response():
return httpx.Response(
status_code=429,
headers={"Retry-After": "1"},
json={"message": "Too Many Requests"},
)
```
§8.2(分页限流重试)和 §13.3(任务级限流容错)均使用此 fixture,不各自重复定义。
### 4.5 评论字段缺失 Mock 数据
`tests/fixtures/` 中新增以下两个文件:
- `xhs_comments_missing_fields.json`:小红书评论列表,其中 `like_count``create_time` 字段均不存在(非 null,而是 key 完全缺失)。
- `douyin_comments_missing_fields.json`:抖音评论列表,其中 `digg_count``create_time` 字段均不存在。
这两个 fixture 用于 §7.2 和 §7.5 的字段缺失测试用例。
### 4.6 AI 全情绪值 Mock 数据
`tests/fixtures/ai_comments_success.json` 中确保包含四种 sentiment 值各至少一条评论,用于统计逻辑(`analysis_success_rate`、情绪分布)的测试:
```json
[
{"comment_id": "c001", "sentiment": "positive", "labels": ["品质好"], "reason": ""},
{"comment_id": "c002", "sentiment": "negative", "labels": ["物流慢"], "reason": ""},
{"comment_id": "c003", "sentiment": "neutral", "labels": [], "reason": ""},
{"comment_id": "c004", "sentiment": "unknown", "labels": [], "reason": ""}
]
```
`ai_comments_success.json` 已有其他用途,新增 `ai_comments_all_sentiments.json` 作为统计测试专用 fixture。
---
## 5. 配置与数据模型测试
### 5.1 配置校验
测试文件:`tests/unit/test_config.py`
用例:
1. 默认配置读取成功。
2. `hot_limit` 范围为 110。
3. `item_limit_per_hot` 范围为 110。
4. `comment_limit_per_item` 范围为 10100。
5. `AI_CONCURRENCY` 默认值为 2。
6. `AI_MAX_RETRIES` 默认值为 3。
### 5.2 SQLite 初始化
测试文件:`tests/unit/test_models.py`
用例:
1. 应用启动时调用 `Base.metadata.create_all(engine)` 可创建所有表。
2. SQLite engine 配置包含 `check_same_thread=False``timeout=10`
3. 连接建立后启用 `PRAGMA journal_mode=WAL`
### 5.3 任务状态模型
测试文件:`tests/unit/test_models.py`
用例:
1. `Task.status` 仅允许 `running``success``failed`
2. 不允许写入 `partial_success``partial_failed`
3. `analysis_status` 允许 `normal``insufficient`
4. 任务 `status=success` 时,`analysis_success_rate < 0.8` 只更新 `analysis_status='insufficient'`,不改变 `status='success'`
5. 任务 `status=failed` 时,`analysis_success_rate` 字段写入实际计算值,`status` 保持 `'failed'` 不变,不因 AI 成功率判断被覆盖为其他值。
---
## 6. 任务创建与恢复测试
### 6.1 创建任务 API
测试文件:`tests/integration/test_task_creation.py`
用例:
1. `POST /api/tasks` 使用合法参数创建任务,返回 `task_id``status=running`
2. 创建任务保存平台和配置字段:
- `hot_limit`
- `item_limit_per_hot`
- `comment_limit_per_item`
3. `platform` 非法时返回 422。
4. 配置值越界时返回 422。
5. 同一平台可重复创建任务,任务彼此独立。
### 6.2 ThreadPoolExecutor 单任务约束
测试文件:`tests/unit/test_task_executor.py`
用例:
1. 任务执行器初始化为 `ThreadPoolExecutor(max_workers=1)`
2. 同时提交两个任务时,第二个任务必须等待第一个任务完成后才开始。
#### httpx 使用方式断言
测试文件:`tests/unit/test_task_executor.py`
用例:
1. 后台任务执行函数不是协程:使用 `inspect.iscoroutinefunction(run_task)` 断言返回 `False`,确保其在 `ThreadPoolExecutor` 中以同步方式运行。
2. 小红书平台客户端实例为 `httpx.Client` 同步类型:`assert isinstance(client._http_client, httpx.Client)`
3. 抖音平台客户端实例为 `httpx.Client` 同步类型,同上。
4. AI 服务客户端发出的请求使用 `httpx.Client`,不存在 `httpx.AsyncClient` 的实例化调用。
### 6.3 僵尸任务恢复
测试文件:`tests/integration/test_task_recovery.py`
用例:
1. 数据库存在 `status=running` 的任务。
2. 应用 lifespan 启动恢复逻辑执行后,该任务变为 `status=failed`
3. `error_stage=system`
4. `error_type=unexpected_restart`
5. `error_message=系统重启,任务被中断`
---
## 7. 平台抓取测试
### 7.1 小红书字段映射
测试文件:`tests/unit/test_xiaohongshu_mapping.py`
用例:
1. 热点标题从 `data.data.items[].title` 读取。
2. 不使用外层 `data.data.title` 作为热点标题。
3. 热点 `id` 映射到 `source_hot_id`
4. `score` 映射到 `heat_value`
5. 笔记 `note.id` 映射到 `source_item_id`
6. 优先选择 `comments_count > 0` 的笔记。
7. 有评论笔记不足目标数时,补充 `comments_count = 0` 的笔记。
8. 平台返回笔记总数不足目标数时,不标记任务失败。
#### 笔记搜索分页限制
测试文件:`tests/unit/test_xiaohongshu_mapping.py`
用例:
1. 搜索笔记接口只请求第一页(`page=1`),不发起后续翻页请求。
2. 首页返回 `comments_count > 0` 的笔记已满足目标数量时,不发起第二次搜索请求。
3. 首页返回结果不足目标数量时,执行降级策略(补充 `comments_count = 0` 的笔记),而非翻页搜索。
### 7.2 小红书评论字段映射
测试文件:`tests/unit/test_xiaohongshu_mapping.py`
用例:
1. `source_comment_id` 优先取 `data.get("comment_id")`
2. `comment_id` 不存在时回退到 `data.get("id")`
3. `content` 不存在时回退到 `text`
4. `like_count``create_time` 正确映射。
5. 原始评论 JSON 保存到 `raw_data`
6. `like_count` 字段缺失时,入库值为 `null`,不抛出异常,不阻断评论抓取流程。
7. `comment_time``create_time` / `create_time_str`)字段缺失时,入库值为 `null`,不抛出异常,不阻断评论抓取流程。
### 7.4 抖音字段映射
测试文件:`tests/unit/test_douyin_mapping.py`
用例:
1. `query_id` 映射到 `source_hot_id`
2. `title` 映射到热点标题。
3. `rank` 映射到热点排名。
4. `hot_score` 映射到 `heat_value`
5. `aweme_info.aweme_id` 映射到 `source_item_id`
6. `aweme_info.desc` 映射到标题或摘要。
### 7.5 抖音评论字段映射
测试文件:`tests/unit/test_douyin_mapping.py`
用例:
1. `source_comment_id` 优先取 `data.get("comment_id")`
2. `comment_id` 不存在时回退到 `data.get("cid")`
3. `text` 映射到 `content`
4. `digg_count` 映射到 `like_count`
5. `create_time` 映射到 `comment_time`
6. `digg_count` 字段缺失时,入库值为 `null`,不抛出异常,不阻断评论抓取流程。
7. `comment_time``create_time` / `create_time_str`)字段缺失时,入库值为 `null`,不抛出异常,不阻断评论抓取流程。
---
## 8. 评论分页、限流与去重测试
### 8.1 评论分页终止条件
测试文件:`tests/unit/test_comment_pagination.py`
用例:
1. 抓取评论数达到 `comment_limit_per_item` 后停止。
2. API 返回空评论列表后停止。
3. 达到最大翻页轮次 5 后停止。
4. API 未返回下一页游标时停止。
### 8.2 分页请求间隔
测试文件:`tests/unit/test_comment_pagination.py`
用例:
1. 每次评论分页请求之间调用 `time.sleep`,间隔值在 1.0 到 2.0 秒范围内(含边界值)。断言方式:`assert 1.0 <= mock_sleep.call_args[0][0] <= 2.0`
2. 收到 HTTP 429 后使用指数退避 1s → 2s → 4s。
3. 429 退避完成后恢复基础分页间隔。
备注:若后续将间隔提取为配置项 `CRAWL_PAGE_INTERVAL_SECONDS`,改为读取配置值后断言,不硬编码数值。
### 8.3 评论去重
测试文件:`tests/unit/test_comment_pagination.py`
用例:
1. 同一任务、同一内容条目、同一评论 ID 不重复入库。
2. 重复抓取时更新已有记录或跳过重复记录。
3. 不同任务下相同评论 ID 可分别保存。
4. 同一内容出现在不同热点下时,按 `task_id + hotspot_id + source_item_id` 保留重复内容条目。
---
## 9. AI 分析测试
### 9.1 Prompt 输入构造
测试文件:`tests/unit/test_ai_schema.py`
用例:
1. 输入给 LLM 的数据为 JSON Array。
2. 每条输入包含 `comment_id``content`
3. 单条评论内容超过 150 字符时截断为前 150 字符。
4. Prompt 明确要求原样回填 `comment_id`
### 9.2 AI 输出 Schema 校验
测试文件:`tests/unit/test_ai_schema.py`
用例:
1. 严格 JSON Array 响应校验通过。
2. 非 JSON 文本校验失败。
3. JSON Object 校验失败。
4. 缺失 `comment_id` 校验失败。
5. `comment_id` 与输入不匹配时该条评论失败。
6. `sentiment` 不在枚举值内校验失败。
7. `labels` 超过 3 个时校验失败。
8. 空标签数组允许通过,`sentiment` 可为 `unknown`
### 9.3 AI 重试与降级拆分
测试文件:`tests/unit/test_ai_schema.py`
用例:
1. 单批 AI 请求失败后最多重试 `AI_MAX_RETRIES=3` 次。
2. 同一批次连续 3 次整批解析失败(含重试)后,该批次所有评论的 `ai_analysis_status` 标记为 `'failed'`,任务继续处理下一批次,不抛出异常。
3. 当前批次失败不阻塞下一批次。
### 9.4 AI 并发粒度
测试文件:`tests/unit/test_ai_schema.py`
用例:
1. 同一任务内最多 2 个 AI 批量请求同时运行。
2. 并发粒度为跨内容条目。
3. 同一内容条目的多批评论串行处理。
### 9.5 AI 成功率与任务质量状态
测试文件:`tests/unit/test_ai_schema.py`
用例:
1. 成功评论数 / 总评论数 >= 80% 时,`analysis_status=normal`
2. 成功评论数 / 总评论数 < 80% 时,`analysis_status=insufficient`
3. `analysis_status=insufficient` 不改变任务 `status`
4. 没有任何内容条目成功时,任务仍为 `failed`
---
## 10. 报告生成测试
### 10.1 情绪与标签统计
测试文件:`tests/unit/test_report_stats.py`
用例:
1. 正向、负向、中性、未知评论数量正确。
2. 情绪占比保留合理精度。
3. 标签按字面值聚合。
4. Top 5 标签按数量降序。
5. 空标签不参与标签统计。
### 10.2 典型评论选取
测试文件:`tests/unit/test_report_stats.py`
用例:
1. 每种情绪选取 12 条典型评论。
2. 有点赞数字段时按点赞数降序。
3. 点赞数缺失时按抓取顺序。
4. 典型评论文本传给总结 AI 前截断为 150 字符。
### 10.3 内容条目级报告
测试文件:`tests/unit/test_report_stats.py`
用例:
1. 内容条目级报告包含样本评论数量。
2. 报告统计与评论结构化结果一致。
3. `report_type=item``hotspot_id``content_item_id` 均不为空。
4. 生成 `markdown_content` 并保存。
5. 总结 AI 失败时,summary 使用默认文本:`总结生成失败,请查看详细数据`
6. `report_type='item'` 时,创建的报告记录 `hotspot_id` 不为空,`content_item_id` 不为空;任一字段为空时 `report_service` 抛出明确异常。
7. `report_type='hotspot'` 时,创建的报告记录 `hotspot_id` 不为空,`content_item_id` 为空;若 `content_item_id` 非空则抛出明确异常。
### 10.4 热点级报告
测试文件:`tests/unit/test_report_stats.py`
用例:
1. 热点级报告聚合热点下所有内容条目评论。
2. 内容条目数量、样本数量、情绪数量与评论明细一致。
3. `report_type=hotspot``hotspot_id` 不为空,`content_item_id` 为空。
4. 生成 `markdown_content` 并保存。
5. 总结 AI 失败不阻断报告创建。
6. `report_type='item'` 时,创建的报告记录 `hotspot_id` 不为空,`content_item_id` 不为空;任一字段为空时 `report_service` 抛出明确异常。
7. `report_type='hotspot'` 时,创建的报告记录 `hotspot_id` 不为空,`content_item_id` 为空;若 `content_item_id` 非空则抛出明确异常。
### 10.5 报告总结 AI 请求
测试文件:`tests/unit/test_report_stats.py`
用例:
1. 内容条目级总结 AI 请求的输入包含统计数据摘要(正向 / 中性 / 负向各占比)和典型评论文本(每类至少 1 条)。
2. 热点级总结 AI 请求的输入包含聚合情绪分布统计、Top 5 标签及出现次数,以及典型评论文本。
3. 传入总结 AI 的单条评论文本截断为 150 字符,超出部分丢弃,不引发错误。
4. 总结 AI 请求不使用 JSON Schema 约束(`response_format` 不为 `json_object``json_schema`),输出期望为纯文本字符串。
5. 总结 AI 请求超时时,报告中 `summary` 字段写入默认文本 `总结生成失败,请查看上方统计数据`,报告记录正常创建,不抛出异常,不阻断后续报告生成流程。
6. 总结 AI 返回内容超过字数限制时执行截断:内容条目级总结超过 200 字时截断,热点级超过 300 字时截断。
---
## 11. 导出测试
### 11.1 CSV 导出
测试文件:`tests/unit/test_export.py`
用例:
1. CSV 使用 `UTF-8-SIG` 编码。
2. CSV 包含平台、任务 ID、热点、内容条目、评论 ID、评论内容、情绪、标签、点赞数、评论时间。
3. `labels` 入库为 JSON Array 字符串,导出时转换为中文逗号拼接。
4. 评论内容以 `=``+``-``@` 开头时添加单引号,防 CSV 公式注入。
5. 导出内容与页面展示数据源一致。
### 11.2 文件名安全处理
测试文件:`tests/unit/test_export.py`
用例:
1. 文件名格式为 `{platform}_{task_id}_{hotspot_keyword}.csv`
2. `hotspot_keyword` 超过 20 字符时截断。
3. `/``\``:``*``?``"``<``>``|` 替换为 `_`
4. 连续多个 `_` 合并为单个 `_`
### 11.3 Markdown 导出
测试文件:`tests/unit/test_export.py`
用例:
1. 热点级 Markdown 读取 `reports.markdown_content`
2. 内容条目级 Markdown 读取 `reports.markdown_content`
3. 导出 Markdown 与页面报告使用同一份数据。
4. 报告不存在时返回 404 或禁用按钮对应状态。
### 11.4 导出路由
测试文件:`tests/integration/test_routes.py`
用例:
1. `GET /api/export/items/{item_id}/comments.csv` 返回 CSV 文件流。
2. `GET /api/export/hotspots/{hotspot_id}/comments.csv` 返回热点下全部评论 CSV。
3. `GET /api/export/hotspots/{hotspot_id}.md` 返回热点 Markdown。
4. `GET /api/export/items/{item_id}.md` 返回内容条目 Markdown。
---
## 12. UI 与模板测试
### 12.1 状态展示
测试文件:`tests/unit/test_template_filters.py`
用例:
1. `running` 渲染为运行中 Badge。
2. `success` 渲染为已完成 Badge。
3. `failed` 渲染为失败 Badge。
4. 任务状态不测试 `partial_success``partial_failed`
5. `analysis_status=insufficient` 渲染 AI 分析不足提示。
### 12.2 表单与校验
测试文件:`tests/integration/test_routes.py`
用例:
1. 首页包含平台选择控件。
2. 首页包含 `hot_limit``item_limit_per_hot``comment_limit_per_item` 输入。
3. 首页展示默认预估规模 1250。
4. 422 错误可渲染到表单错误区域。
### 12.3 标签渲染
测试文件:`tests/unit/test_template_filters.py`
用例:
1. `from_json` filter 可解析 labels JSON Array 字符串。
2. 非法 JSON 返回空数组,不导致模板报错。
3. 标签逐个渲染为 Badge。
4. 空标签显示 `-`
### 12.4 空状态
测试文件:`tests/integration/test_routes.py`
用例:
1. 无任务时首页展示空状态。
2. 任务运行中且热点为空时展示抓取中 Spinner。
3. 评论为空时内容详情页展示暂无评论数据。
4. 报告尚未生成时展示报告生成中。
### 12.5 导出按钮状态
测试文件:`tests/integration/test_routes.py`
用例:
1. `task.status=running` 时导出按钮 disabled。
2. `task.status=failed` 且无成功内容条目时导出按钮 disabled。
3. `item.status=failed` 时内容条目 CSV 导出按钮 disabled。
4. 有报告和评论数据时导出按钮可点击。
---
## 13. 服务集成测试
### 13.1 小红书端到端服务流
测试文件:`tests/integration/test_task_flow_xiaohongshu.py`
流程:
1. Mock 小红书热榜接口。
2. Mock 小红书搜索笔记接口。
3. Mock 小红书评论分页接口。
4. Mock AI 评论分析接口。
5. 创建小红书任务。
6. 执行任务服务。
7. 断言任务 `status=success`
8. 断言热点、内容条目、评论、报告均入库。
9. 断言 raw_data 已保存。
### 13.2 抖音端到端服务流
测试文件:`tests/integration/test_task_flow_douyin.py`
流程:
1. Mock 抖音热点接口。
2. Mock 抖音视频搜索接口。
3. Mock 抖音评论分页接口。
4. Mock AI 评论分析接口。
5. 创建抖音任务。
6. 执行任务服务。
7. 断言任务 `status=success`
8. 断言热点、内容条目、评论、报告均入库。
### 13.3 单个内容条目失败但任务继续
测试文件:`tests/integration/test_failure_tolerance.py`
用例:
1. 一个内容条目评论接口返回 429 后超过最大重试。
2. 该内容条目标记 `failed`
3. 后续内容条目继续处理。
4. 至少一个内容条目成功时,任务最终 `status=success`
5. 任务展示失败内容条目数和错误摘要。
### 13.4 全局失败
测试文件:`tests/integration/test_failure_tolerance.py`
用例:
1. 热点接口失败时任务 `status=failed`
2. 所有内容条目均失败时任务 `status=failed`
3. 失败任务包含 `error_stage``error_type``error_message`
---
### 13.5 跨热点重复内容条目场景
测试文件:`tests/integration/test_failure_tolerance.py`
用例:
1. 同一 `source_item_id` 同时出现在两个不同热点的搜索结果中时,系统以不同 `hotspot_id` 分别创建两条 `content_items` 记录,断言数据库中存在 2 条该 `source_item_id` 的记录,各自归属于对应的 `hotspot_id`
2. 同一任务内,同一热点下相同 `source_item_id` 重复出现时,数据库中该热点下该 `source_item_id` 只保留 1 条记录(去重),不抛出异常,不阻断任务。
---
## 14. Playwright E2E 测试(P2,时间允许时实现)
当前 MVP 不实现本节内容。
原因:在 4 天单人开发周期内,Playwright 环境配置及 AI 长耗时任务的异步等待调优成本较高,性价比低于手工验收。
Day 4 端到端验收完全依赖 §15.2 手工验收清单,后者覆盖了所有 P0 用户流程。
后续版本实现 Playwright 时,须补充以下前提条件:
1. e2e 测试启动独立测试服务器实例(`APP_ENV=test`)。
2. 外部 API 调用由 `respx` 在进程内拦截,不发起真实网络请求。
3. 测试前后清理 SQLite 测试数据库(或使用内存数据库)。
4. 明确运行环境:若使用本地 uvicorn 服务,在 Docker 验收前执行;若在 Docker 环境中运行,须先完成 §15 Docker 验收。
---
## 15. Docker 与手工验收测试
### 15.1 Docker 启动
测试命令:
```bash
docker compose up --build
curl -f http://localhost:8000/health
```
验收:
- Docker Compose 可启动。
- `/health` 返回 200。
- 首页可访问。
- SQLite 文件生成在 `./data/app.db`
### 15.2 MVP 手工验收清单
1. 创建小红书默认任务。
2. 任务列表展示创建时间、状态、AI 分析状态、成功 X / 共 Y 条内容条目。
3. 刷新任务状态。
4. 查看热点列表。
5. 查看热点级报告。
6. 查看内容条目详情。
7. 查看评论明细。
8. 导出 CSV 评论明细。
9. 导出热点 Markdown 报告。
10. 导出内容条目 Markdown 报告。
11. 创建抖音默认任务并重复 210。
12. 输入非法配置,确认前端和后端均拒绝。
---
## 16. 覆盖率与发布门槛
### 16.1 最低覆盖要求
- 单元测试覆盖率建议不低于 80%。
- `platforms/``services/ai_service.py``services/report_service.py``services/export_service.py` 必须有关键行为测试。
- UI 模板不强制覆盖率数字,但关键状态和导出按钮必须有模板测试或 e2e 测试。
### 16.2 合并前必须通过
```bash
pytest tests/unit -q
pytest tests/integration -q
pytest tests/unit tests/integration \
--cov=app \
--cov-branch \
--cov-report=term-missing \
--cov-fail-under=80
```
# 覆盖率统计豁免 `templates/` 和 `static/` 目录,详见 `pyproject.toml` 配置。
# branch coverage 对任务状态机和 AI 成功率阈值判断有特殊检测价值。
如修改部署相关文件:
```bash
docker compose up --build
curl -f http://localhost:8000/health
```
---
## 17. 开发顺序建议
### Day 1 测试优先级
1. 配置校验测试。
2. 数据模型测试。
3. 任务创建 API 测试。
4. 僵尸任务恢复测试。
5. 首页模板基础渲染测试。
### Day 2 测试优先级
1. 小红书字段映射测试。
2. 小红书评论分页测试。
3. 小红书降级选择测试。
4. 抖音字段映射测试。
5. 429 退避与单条失败继续测试。
### Day 3 测试优先级
1. AI 输入构造测试。
2. AI 输出 schema 测试。
3. AI 重试测试。
4. `analysis_success_rate` 测试。
5. 报告统计与 Markdown 生成测试。
(依赖 Day 3 第 1-4 条 AI 分析测试完成后方可验证完整集成路径)
### Day 4 测试优先级
1. CSV / Markdown 导出测试。
2. 模板状态和空状态测试。
3. ~~Playwright E2E 测试~~(已降级为 P2,见 §14)——Day 4 端到端验收直接执行 §15.2 手工验收清单。
4. Docker 启动验收。
---
## 18. 变更日志
| 日期 | 版本 | 变更内容 |
|---|---|---|
| 2026-07-01 | v1.0 | 基于 PRD、FeatureSummary、DevelopmentPlan、UIDesign 生成 TDD 测试驱动开发计划,覆盖配置、数据模型、任务、平台抓取、评论分页、AI 分析、报告、导出、UI 模板、集成流程、Playwright 与 Docker 验收。 |
+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` 复选框。
+947
View File
@@ -0,0 +1,947 @@
# Tasks.md:小红书 / 抖音热榜评论抓取 + AI 分析报告工具
## 1. 文档信息
- 文档阶段:Tasks(开发任务拆解)
- 需求依据:`docs/PRD.md``docs/FeatureSummary.md`
- 技术依据:`docs/DevelopmentPlan.md`
- UI 依据:`docs/UIDesign.md`
- 测试依据:`docs/TDD.md`
- API Spike 依据:`docs/API-Spike-Xiaohongshu.md``docs/API-Spike-Douyin.md`
- 当前目标:将 MVP 拆解为 4 天内可执行、可测试、可验收的开发任务
---
## 2. 开发总原则
1. 遵循 TDD:先写失败测试,再写最小实现,再重构。
2. MVP 优先:先跑通主链路,再做 P1 优化。
3. 不使用真实 TikHub / AI API 作为单元测试依赖。
4. 外部 API、AI 响应、字段缺失、限流等场景必须通过 mock 覆盖。
5. 任务主状态只使用 `running` / `success` / `failed`AI 质量使用 `analysis_status``analysis_success_rate` 表示,不新增“部分失败”任务状态。
6. 页面展示和导出必须读取同一份结构化报告数据。
7. API Key、AI Key 不写入代码仓库。
---
## 3. 目标目录结构
```text
app/
main.py
config.py
db.py
models.py
schemas.py
services/
task_service.py
crawl_service.py
ai_service.py
report_service.py
export_service.py
platforms/
base.py
xiaohongshu.py
douyin.py
templates/
base.html
index.html
tasks/detail.html
hotspots/report.html
items/detail.html
partials/task_rows.html
macros/status_badge.html
macros/sentiment_badge.html
macros/label_tags.html
static/
app.css
app.js
prompts/
comment_analysis.txt
report_summary.txt
tests/
conftest.py
fixtures/
unit/
integration/
Dockerfile
docker-compose.yml
.env.example
pyproject.toml
```
---
## 4. 里程碑安排
| 日期 | 目标 | 验收结果 |
|---|---|---|
| Day 1 | 项目骨架、配置、数据库、任务创建、基础页面 | 可启动、可创建任务、任务列表可见 |
| Day 2 | 小红书 / 抖音抓取链路、字段映射、评论分页、容错 | mock 测试中两平台链路跑通 |
| Day 3 | AI 分析、报告生成、统计一致性 | 评论有情绪和标签,报告可生成 |
| Day 4 | 页面完善、导出、Docker、手工验收 | Docker 启动,双平台默认任务可演示 |
---
## 5. Day 1:项目骨架、数据模型、任务框架
### T01 初始化项目结构与依赖
**目标**:创建 FastAPI 单体项目骨架。
**涉及文件**
- 创建:`app/main.py`
- 创建:`app/config.py`
- 创建:`app/db.py`
- 创建:`app/models.py`
- 创建:`app/schemas.py`
- 创建:`pyproject.toml``requirements.txt`
- 创建:`.env.example`
**步骤**
- [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`
**验收**
- 本地可启动 FastAPI。
- `/health` 返回 200。
- 配置测试通过。
### T02 配置管理与环境变量
**目标**:集中管理 TikHub、AI、数据库、HTTP 超时和任务配置。
**涉及文件**
- 修改:`app/config.py`
- 修改:`.env.example`
- 测试:`tests/unit/test_config.py`
**配置项**
- `APP_ENV`
- `DATABASE_URL`
- `TIKHUB_API_KEY`
- `TIKHUB_BASE_URL`
- `AI_PROVIDER`
- `AI_BASE_URL`
- `AI_API_KEY`
- `AI_MODEL`
- `AI_BATCH_SIZE`
- `AI_CONCURRENCY`
- `AI_MAX_RETRIES`
- `AI_TIMEOUT_SECONDS`
- `HTTP_TIMEOUT_SECONDS`
- `HTTP_MAX_RETRIES`
- `CRAWL_PAGE_INTERVAL_SECONDS=1.5`:评论分页请求间隔秒数,支持浮点数,默认 1.5 秒
**步骤**
- [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` 中补充该配置项及注释说明。
**验收**
- 配置测试通过。
- `.env.example` 不包含真实 Key。
### T03 数据库初始化与模型
**目标**:实现 SQLite + SQLAlchemy 数据模型。
**涉及文件**
- 修改:`app/db.py`
- 修改:`app/models.py`
- 测试:`tests/unit/test_models.py`
**表结构**
- `tasks`
- `hotspots`
- `content_items`
- `comments`
- `reports`
**步骤**
- [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)` 联合索引。
**验收**
- 内存 SQLite 测试可创建并销毁全部表。
- `Task.status` 支持 `running` / `success` / `failed`
- `analysis_status=insufficient` 不改变 `Task.status`
### T04 任务创建 API 与单任务执行器
**目标**:用户可创建任务,后台任务框架可排队执行。
**涉及文件**
- 修改:`app/schemas.py`
- 创建:`app/services/task_service.py`
- 修改:`app/main.py`
- 测试:`tests/integration/test_task_creation.py`
- 测试:`tests/unit/test_task_executor.py`
**接口**
- `POST /api/tasks`
- `GET /api/tasks`
- `GET /api/tasks/{task_id}`
**步骤**
- [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] 实现任务列表查询。
**验收**
- 合法任务可创建。
- 非法配置返回 422。
- 任务执行器为单 worker。
### T05 僵尸任务恢复
**目标**:应用重启后,将遗留 running 任务标记失败。
**涉及文件**
- 修改:`app/main.py`
- 修改:`app/services/task_service.py`
- 测试:`tests/integration/test_task_recovery.py`
**步骤**
- [x] 写测试:数据库中存在 `status=running` 的任务。
- [x] 应用 lifespan 启动时执行恢复逻辑。
- [x] 将 running 任务更新为 failed。
- [x] 写入 `error_stage=system``error_type=unexpected_restart``error_message=系统重启,任务被中断`
**验收**
- 重启恢复测试通过。
### T06 首页 / 任务列表基础页面
**目标**:用户可在页面创建任务并查看任务列表。
**涉及文件**
- 创建:`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`
- 测试:`tests/integration/test_routes.py`
- 测试:`tests/unit/test_template_filters.py`
**步骤**
- [ ] 创建 `base.html`,包含 Bootstrap 5 CDN 和主布局。
- [ ] 创建首页任务表单:平台、热点数量、每热点内容数、每内容评论数。
- [ ] 添加规模预估提示。
- [ ] 添加任务列表:任务 ID、平台、创建时间、配置规模、进度、状态、操作。
- [ ] 实现异步 `fetch("/api/tasks")` 提交。
- [ ] 实现手动刷新按钮。
- [ ] 注册状态 Badge 渲染逻辑或 Macro。
- [ ] 测试首页无任务空状态。
- [ ] 测试状态文案不在模板中散落硬编码。
- [ ] 前端实时计算规模预估值:`hotspot_limit × item_limit_per_hotspot × comment_limit_per_item`,默认展示 1250(5×5×50)。
- [ ] 任意配置项输入值变化时立即更新预估值显示,无需点击提交。
- [ ] 写集成测试:首页默认预估规模展示为 1250。
- [ ]`base.html` 中实现面包屑组件,使用 Bootstrap `<nav aria-label="breadcrumb">`,通过 Jinja2 block 传入路径数据。
- [ ] `index.html` 面包屑:首页(不展示,或仅展示当前页标识)。
**验收**
- 首页可访问。
- 表单非法值前端阻止提交,后端也返回 422。
- 任务创建成功后前端跳转至 `/tasks/{new_task_id}`,使用户可立即看到任务初始 pending 状态。
---
## 6. Day 2:平台抓取链路
### T07 外部 API 基础客户端与重试
**目标**:封装 TikHub API 调用、超时、限流退避和错误类型。
**涉及文件**
- 创建:`app/platforms/base.py`
- 创建:`app/services/crawl_service.py`
- 测试:`tests/unit/test_comment_pagination.py`
- 测试:`tests/integration/test_failure_tolerance.py`
- 新建:`tests/fixtures/http_429_response.json`(模拟 429 响应体)
- 新建:`tests/conftest.py` 中补充 `mock_429_httpx_client` fixture
**步骤**
- [ ] 写 429 mock fixture。
- [ ] 写 HTTP 429 指数退避测试:1s → 2s → 4s。
- [ ] 写非 429 网络错误重试测试。
- [ ] 实现同步 `httpx.Client` 调用封装。
- [ ] 每次请求设置 20s 超时。
- [ ] 超过重试次数后返回结构化错误,不抛出到任务全局。
- [ ] 写测试:后台任务执行函数不是协程(`assert inspect.iscoroutinefunction(run_task) is False`)。
- [ ] 写测试:平台 HTTP 客户端实例类型为 `httpx.Client`,不为 `httpx.AsyncClient`
- [ ] 写测试:收到 HTTP 429 响应时,触发指数退避重试(1s → 2s → 4s),不抛出异常。
- [ ] 写测试:连续 3 次 429 重试后仍失败,抛出可被上层捕获的自定义异常。
**验收**
- 429 退避测试通过。
- API Key 不出现在日志中。
### T08 小红书热点、笔记、评论字段映射
**目标**:实现小红书最小抓取链路。
**涉及文件**
- 创建:`app/platforms/xiaohongshu.py`
- 测试:`tests/unit/test_xiaohongshu_mapping.py`
- Fixture`tests/fixtures/xhs_hot_list.json`
- Fixture`tests/fixtures/xhs_search_notes.json`
- Fixture`tests/fixtures/xhs_comments_page_1.json`
- Fixture`tests/fixtures/xhs_comments_page_2_empty.json`
- 新建:`tests/fixtures/xhs_comments_missing_fields.json`like_count / create_time 字段缺失的小红书评论样本)
**步骤**
- [ ] 写热榜字段映射测试,确保读取 `data.data.items[]`,不误用外层 `data.data.title`
- [ ] 写笔记筛选测试:优先 `comments_count > 0`
- [ ] 写兜底测试:有评论笔记不足时补充 `comments_count = 0`
- [ ] 写评论字段映射测试:`comment_id` 优先于 `id`
- [ ] 实现 `fetch_hotspots()`
- [ ] 实现 `search_items_by_hotspot()`
- [ ] 实现 `fetch_comments()`
- [ ] 保存 raw_data。
- [ ] 写测试:小红书评论数据中 like_count 字段缺失时,字段默认值为 0,不抛出异常。
- [ ] 写测试:小红书评论数据中 create_time 字段缺失时,字段默认值为 None,不抛出异常。
**验收**
- 小红书字段映射测试通过。
- 字段缺失不导致整批任务崩溃。
### T09 抖音热点、视频、评论字段映射
**目标**:实现抖音最小抓取链路。
**涉及文件**
- 创建:`app/platforms/douyin.py`
- 测试:`tests/unit/test_douyin_mapping.py`
- Fixture`tests/fixtures/douyin_hot_list.json`
- Fixture`tests/fixtures/douyin_search_videos.json`
- Fixture`tests/fixtures/douyin_comments_page_1.json`
- 新建:`tests/fixtures/douyin_comments_missing_fields.json`like_count / create_time 字段缺失的抖音评论样本)
**步骤**
- [ ] 写热点字段映射测试:`query_id``title``rank``hot_score`
- [ ] 写视频字段映射测试:`aweme_info.aweme_id``desc``author``statistics`
- [ ] 写评论字段映射测试:`comment_id` 优先于 `cid`
- [ ] 实现 `fetch_hotspots()`
- [ ] 实现 `search_items_by_hotspot()`
- [ ] 实现 `fetch_comments()`
- [ ] 保存 raw_data。
- [ ] 写测试:抖音评论数据中 like_count 字段缺失时,字段默认值为 0,不抛出异常。
- [ ] 写测试:抖音评论数据中 create_time 字段缺失时,字段默认值为 None,不抛出异常。
**验收**
- 抖音字段映射测试通过。
- 字段缺失不导致整批任务崩溃。
### T10 评论分页、间隔与去重
**目标**:按配置抓取一级评论,并处理分页、停止条件和去重。
**涉及文件**
- 修改:`app/platforms/xiaohongshu.py`
- 修改:`app/platforms/douyin.py`
- 修改:`app/services/crawl_service.py`
- 测试:`tests/unit/test_comment_pagination.py`
**步骤**
- [ ] 写分页停止测试:达到评论数上限停止。
- [ ] 写分页停止测试:API 返回空评论列表停止。
- [ ] 写分页停止测试:达到最大翻页轮次 5 停止。
- [ ] 写分页请求间隔测试:`time.sleep` 在 1.0 到 2.0 秒之间。
- [ ] 写去重测试:同一任务同一内容条目同一评论 ID 不重复。
- [ ] 实现小红书 cursor / index 推进。
- [ ] 实现抖音 cursor 推进。
- [ ] 实现去重逻辑。
**验收**
- 评论分页测试通过。
- 评论数不超过配置上限。
### T11 抓取任务主流程集成
**目标**:任务可串起热点、内容条目、评论抓取并入库。
**涉及文件**
- 修改:`app/services/task_service.py`
- 修改:`app/services/crawl_service.py`
- 测试:`tests/integration/test_task_flow_xiaohongshu.py`
- 测试:`tests/integration/test_task_flow_douyin.py`
- 测试:`tests/integration/test_failure_tolerance.py`
**步骤**
- [ ] 写小红书端到端服务流测试,mock 热榜、搜索、评论。
- [ ] 写抖音端到端服务流测试,mock 热点、搜索、评论。
- [ ] 写单个内容条目失败但任务继续测试。
- [ ] 写热点接口失败导致任务 failed 测试。
- [ ] 实现任务执行流程。
- [ ] 更新 `processed_items_count``successful_items_count``failed_items_count`
- [ ] 每处理完一个内容条目(抓取完成或失败)后立即执行 `session.commit()`,实时更新 `processed_items_count`,不等待整个任务完成后统一提交。
- [ ] 写测试:处理第 1 个内容条目后,数据库中 `processed_items_count` 已更新为 1,无需等待全部条目处理完成。
- [ ] 没有任何内容条目成功时任务 failed。
- [ ] 至少一个内容条目成功时任务 success。
- [ ] 写测试:同一 `source_item_id` 出现在两个不同热点的搜索结果中时,以不同 `hotspot_id` 分别入库,共产生 2 条 `content_items` 记录(符合 DevelopmentPlan §5.3 去重规则:`task_id + hotspot_id + source_item_id` 联合唯一)。
- [ ] 写测试:同一任务内,同一热点下相同 `source_item_id` 不重复入库(第二次插入被跳过,记录数仍为 1)。
**验收**
- 两个平台 mock 集成测试通过。
- 单条失败不阻断整批任务。
⏱ 时间盒约束(Day 2):若 T08/T09/T10 爬虫链路在 4 小时内无法完整处理所有异常字段,
立即启动降级方案:丢弃异常字段,保留 raw_data 和核心必需字段,推进至下一任务。
外部 API 字段畸变(缺失、层级变化)是 Day 2 最大的时间黑洞,不在此恋战。
---
## 7. Day 3AI 分析与报告生成
### T12 AI Prompt 与结构化输出校验
**目标**:实现评论级 AI 分析,强制 JSON Array,并用 schema 校验。
**涉及文件**
- 创建:`app/services/ai_service.py`
- 创建:`app/prompts/comment_analysis.txt`
- 测试:`tests/unit/test_ai_schema.py`
- Fixture`tests/fixtures/ai_comments_success.json`
- Fixture`tests/fixtures/ai_comments_invalid_json.txt`
- 新建:`tests/fixtures/ai_comments_all_sentiments.json`(包含 positive / neutral / negative / unknown 四种情绪值的评论样本,用于统计逻辑测试)
**步骤**
- [ ] 写 prompt 输入构造测试,包含 `comment_id` 和截断后的 `content`
- [ ] 写评论内容超过 150 字截断测试。
- [ ] 写 AI 输出 JSON Array 校验测试。
- [ ] 写 sentiment 枚举校验测试。
- [ ] 写 labels 最多 3 个测试。
- [ ]`comment_id` 不匹配时单条失败测试。
- [ ] 实现 prompt 构造。
- [ ] 实现 Pydantic schema 校验。
- [ ] 实现单条失败标记 `ai_analysis_status=failed`
- [ ] 写测试:情绪统计时,四种情绪值(positive / neutral / negative / unknown)均有输入数据时,统计结果各自独立计数,总数等于评论总数。
**验收**
- AI schema 单元测试通过。
### T13 AI 重试、成功率统计
**目标**:AI 失败不阻断任务,并计算 AI 分析质量。
**涉及文件**
- 修改:`app/services/ai_service.py`
- 修改:`app/services/task_service.py`
- 测试:`tests/unit/test_ai_schema.py`
**步骤**
- [ ] AI 批量分析重试策略(与 DevelopmentPlan §7.1 保持一致):batch size 固定为 20,不做动态缩减。
- [ ] 整批 JSON 解析失败时,整批重试,最多重试 3 次。
- [ ] 第 3 次重试仍失败,该批次全部评论标记 `ai_analysis_status=failed`,不阻断其他批次处理。
- [ ] 不实现 batch size 减半逻辑。
- [ ] 写 AI 请求最多重试 3 次测试。
- [ ] 写测试:整批 JSON 解析失败时,自动整批重试,最多 3 次。
- [ ] 写测试:第 3 次重试仍失败后,该批次全部评论 `ai_analysis_status` 标记为 failed。
- [ ] 写测试:一批次失败不影响其他批次的正常处理。
- [ ] 写测试:重试间隔符合指数退避(1s → 2s → 4s)。
- [ ] 写同一任务最多 2 个 AI 批量请求并发测试。
- [ ] 写同一内容条目多批评论串行测试。
- [ ]`analysis_success_rate >= 0.8``analysis_status=normal` 测试。
- [ ]`analysis_success_rate < 0.8``analysis_status=insufficient` 测试。
- [ ] 实现固定 batch size 的整批重试。
- [ ] 实现成功率统计。
**验收**
- AI 成功率低于 80% 时不改变任务 `status`
- 页面后续可读取 `analysis_status` 提示分析不足。
### T14 内容条目级报告生成
**目标**:为每条内容生成预生成报告。
**涉及文件**
- 创建:`app/services/report_service.py`
- 创建:`app/prompts/report_summary.txt`
- 测试:`tests/unit/test_report_stats.py`
**步骤**
- [ ] 写情绪统计测试。
- [ ] 写标签 Top 5 统计测试。
- [ ] 写典型评论选取测试:优先点赞数,缺失时按抓取顺序。
- [ ] 写内容条目级报告字段测试。
- [ ] 写总结 AI 失败时默认文案测试。
- [ ] 实现内容条目级报告生成。
- [ ] 保存 `metrics_json``typical_comments_json``summary``markdown_content`
- [ ] 写测试:内容条目级报告总结 AI 的输入包含统计摘要和典型评论文本(每条截断为 150 字符)。
- [ ] 写测试:报告总结 AI 使用纯文本输出,不使用 JSON Schema 约束。
- [ ] 写测试:总结文本超过 200 字时自动截断至 200 字。
- [ ] 写测试:总结 AI 调用超时或失败时,summary 字段使用默认文案“总结生成失败,请查看上方统计数据。”,不阻断报告创建流程。
- [ ] 创建 `app/prompts/report_summary.txt`,编写内容条目级报告总结的 Prompt 模板。
**验收**
- 内容条目级报告统计与评论结构化结果一致。
- 总结 AI 失败不阻断报告创建。
### T15 热点级报告生成
**目标**:聚合热点下所有内容条目,生成热点级报告。
**涉及文件**
- 修改:`app/services/report_service.py`
- 测试:`tests/unit/test_report_stats.py`
**步骤**
- [ ] 写热点级报告聚合测试。
- [ ] 写内容条目数量、样本数、情绪数量一致性测试。
- [ ] 写热点 Top 5 标签统计测试。
- [ ] 写热点 Markdown 生成测试。
- [ ] 实现热点级报告生成。
- [ ] 任务完成后先生成内容条目报告,再生成热点报告。
- [ ] 写测试:热点级报告总结文本超过 300 字时自动截断至 300 字。
- [ ] 写测试:热点总结 AI 超时或失败时,热点报告 summary 字段使用默认文案,不阻断热点报告创建流程。
- [ ] 写测试:热点总结 AI 的输入聚合了该热点下所有内容条目的统计摘要。
**验收**
- 热点级报告可生成。
- 页面展示和导出可读取同一份报告。
### T16 AI + 报告集成到任务流程
**目标**:任务完成后评论有情绪和标签,报告已预生成。
**涉及文件**
- 修改:`app/services/task_service.py`
- 修改:`app/services/ai_service.py`
- 修改:`app/services/report_service.py`
- 测试:`tests/integration/test_task_flow_xiaohongshu.py`
- 测试:`tests/integration/test_task_flow_douyin.py`
**步骤**
- [ ] 扩展小红书集成测试,断言评论有 sentiment、labels。
- [ ] 扩展抖音集成测试,断言 reports 入库。
- [ ] 扩展失败容错测试,AI 单批失败不阻断其他批次。
- [ ] 在任务流程中调用 AI 分析。
- [ ] 在任务流程中调用报告生成。
- [ ] 写入 `analysis_success_rate``analysis_status`
- [ ] 每完成一批 AI 分析(20 条评论处理完毕)后立即执行 `session.commit()`,不等待所有批次完成后统一提交。
- [ ] 写测试:第一批 AI 分析完成后,数据库中对应评论的 `ai_analysis_status` 已更新,不需等待全部批次完成。
**验收**
- 两个平台任务 mock 全流程通过。
⏱ 时间盒约束(Day 3):若 T12/T13 AI 链路的 JSON Schema 解析失败边缘 case 超过 2 小时仍未解决,
立即切换为纯文本输出降级方案,保存原始响应至 raw_data 字段,推进至报告生成任务。
Prompt 调优不在 MVP 关键路径上,允许以降级方案通过验收。
---
## 8. Day 4:页面、导出、Docker 验收
### T17 任务详情页
**目标**:展示任务概览、热点列表和内容条目入口。
**涉及文件**
- 创建:`app/templates/tasks/detail.html`
- 修改:`app/main.py`
- 测试:`tests/integration/test_routes.py`
- 测试:`tests/unit/test_template_filters.py`
**步骤**
- [ ] 写任务详情页路由测试。
- [ ] 写状态 Badge 渲染测试。
- [ ] 实现任务概览看板:任务 ID、平台、创建时间、耗时、状态、AI 分析状态、进度、错误信息。
- [ ] 实现热点手风琴列表。
- [ ] 默认展开 rank=1 的热点。
- [ ] 内容条目失败时展示失败原因。
- [ ] 任务 running 且热点为空时展示 Spinner。
- [ ] 渲染任务详情页面包屑路径:首页 › 任务 `#{task_id}`
- [ ] 验收:面包屑“首页”链接指向 `/`,可点击跳转。
**验收**
- 用户可从任务列表进入任务详情。
- 可进入热点报告和内容条目详情。
- 热点报告入口在 T18 完成前仅验证链接存在(href 属性非空),不验证报告页内容。
### T18 热点级报告页
**目标**:展示热点级聚合报告。
**涉及文件**
- 创建:`app/templates/hotspots/report.html`
- 修改:`app/main.py`
- 测试:`tests/integration/test_routes.py`
**步骤**
- [ ] 写热点报告页路由测试。
- [ ] 展示热点基础信息、内容条目数量、评论样本数。
- [ ] 展示情绪条数和百分比。
- [ ] 展示 Top 5 标签。
- [ ] 展示典型评论。
- [ ] 展示 AI 总结和分析不足 Alert。
- [ ] 添加 Markdown 导出按钮。
- [ ] 添加热点下全部评论 CSV 导出按钮。
- [ ] 渲染热点报告页面包屑路径:首页 › 任务 `#{task_id}` 热点 `#{rank}``{title}` 汇总报告。
- [ ] 各层级面包屑均为可点击链接,末级“汇总报告”为当前页,不可点击。
**验收**
- 热点报告页能读取预生成报告。
- 报告缺失时显示友好状态,不 500。
### T19 内容条目详情页与评论明细
**目标**:展示单条视频 / 笔记的报告和评论明细。
**涉及文件**
- 创建:`app/templates/items/detail.html`
- 修改:`app/main.py`
- 测试:`tests/integration/test_routes.py`
- 测试:`tests/unit/test_template_filters.py`
**步骤**
- [ ] 写内容条目详情页路由测试。
- [ ] 展示内容条目基础信息和原始内容链接。
- [ ] 展示内容条目级报告。
- [ ] 评论明细最多展示 100 条。
- [ ] 评论按点赞数降序、评论时间降序排序。
- [ ] 标签 JSON Array 渲染为多个标签块。
- [ ] 评论为空时展示空状态。
- [ ] P1:添加 `<details>` 原始 JSON 调试入口。
- [ ] 渲染内容条目详情页面包屑路径:首页 › 任务 `#{task_id}` 热点 `#{rank}``{title}` `{item_title}`
**验收**
- 用户可查看评论明细、情绪、标签。
- 评论为空不报错。
- 评论明细排序规则说明:按点赞数降序;点赞数相同或缺失时按评论时间降序。此规则由 UIDesign 补充定义,不在 DevelopmentPlan 原始范围内,为产品决策。
### T20 导出服务
**目标**:实现 CSV 和 Markdown 导出。
**涉及文件**
- 创建:`app/services/export_service.py`
- 修改:`app/main.py`
- 测试:`tests/unit/test_export.py`
- 测试:`tests/integration/test_routes.py`
**接口**
- `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`
- [ ] 写 CSV 字段测试。
- [ ] 写 labels 中文逗号拼接测试。
- [ ] 写 CSV 公式注入防护测试:`=``+``-``@` 开头加单引号。
- [ ] 写文件名安全处理测试。
- [ ] 写 Markdown 导出测试。
- [ ] 写入 CSV 时,将评论内容字段中的换行符(`\n``\r``\r\n`)替换为空格,防止换行符撕裂 CSV 行列结构。
- [ ] 写测试:评论内容包含换行符时,导出的 CSV 文件中该字段不包含换行符,行数与评论条数一致。
- [ ] 实现 CSV 导出。
- [ ] 实现 Markdown 导出。
- [ ] 写模板测试:`task.status=running` 时,导出按钮渲染结果中包含 `disabled` 属性。
- [ ] 写模板测试:`task.status=failed` 且无成功内容条目(`successful_items_count=0`)时,导出按钮渲染结果中包含 `disabled` 属性。
- [ ] 写模板测试:`item.status=crawl_failed` 时,内容条目详情页导出按钮渲染结果中包含 `disabled` 属性。
- [ ] 写模板测试:`task.status=success` 且有报告数据时,导出按钮渲染结果不包含 `disabled` 属性。
- [ ] 说明:以上测试验证的是“按钮在什么条件下变灰”的业务逻辑,不测试 CSS 颜色和视觉样式。
- [ ] 写测试:评论内容首字符为 `=` `+` `-` `@` 时,导出 CSV 中该字段首字符前添加单引号前缀。
**验收**
- CSV 可用 Excel 正常打开中文。
- Markdown 与页面报告使用同一份数据。
### T21 模板宏、过滤器与静态交互
**目标**:统一状态展示、情绪展示、标签展示和前端交互。
**涉及文件**
- 创建:`app/templates/macros/status_badge.html`
- 创建:`app/templates/macros/sentiment_badge.html`
- 创建:`app/templates/macros/label_tags.html`
- 修改:`app/main.py`
- 修改:`app/static/app.js`
- 修改:`app/static/app.css`
- 测试:`tests/unit/test_template_filters.py`
**步骤**
- [ ] 注册 `from_json` Jinja2 filter。
- [ ] 编写状态 Badge macro。
- [ ] 编写情绪 Badge macro。
- [ ] 编写标签列表 macro。
- [ ] 前端实现表单范围校验。
- [ ] 前端实现规模预估实时计算。
- [ ] 前端实现导出 Blob 下载。
- [ ] 严禁对外部平台内容使用 `|safe`
- [ ]`base.html` 中定义 `{% block title %}热榜评论分析工具{% endblock %}` 占位。
- [ ] 各页面模板按以下规范填充 title block
`index.html` → “任务列表 - 热榜评论分析工具”
`tasks/detail.html` → “任务 #{task.id} - 热榜评论分析工具”
`hotspots/report.html` → “{hotspot.title} 汇总报告 - 热榜评论分析工具”
`items/detail.html` → “{item.title} 详情 - 热榜评论分析工具”
- [ ] 写测试:各页面响应的 `<title>` 标签内容符合上述规范。
**验收**
- 模板测试通过。
- 状态文案集中管理。
### T22 Docker Compose 与部署
**目标**:实现一键启动。
**涉及文件**
- 创建:`Dockerfile`
- 创建:`docker-compose.yml`
- 修改:`.env.example`
**步骤**
- [ ] 编写 Dockerfile。
- [ ] 编写 docker-compose,包含 app 服务和 `./data:/app/data` 数据卷。
- [ ] 容器启动后执行数据库初始化。
- [ ] 暴露 `8000` 端口。
- [ ] 验证 SQLite 写入 `./data/app.db`
- [ ] Dockerfile 中添加非 root 用户:
`RUN adduser --disabled-password --gecos "" appuser`
`USER appuser`
- [ ] 确认 `./data` 挂载卷目录对 `appuser` 具有写入权限(可通过 chown 或目录权限设置实现)。
- [ ] 写测试:`docker-compose up` 后,容器内进程以非 root 用户运行(`whoami` 不返回 root)。
**验收命令**
```bash
docker compose up --build
curl -f http://localhost:8000/health
```
### T23 最终测试与手工验收
**目标**:完成 MVP 验收闭环。
**涉及文件**
- 修改:`README.md`(如项目已有;没有则可跳过)
- 修改:`docs/Tasks.md` 勾选完成项
**自动化测试命令**
```bash
pytest tests/unit -q
pytest tests/integration -q
pytest tests/unit tests/integration --cov=app --cov-branch --cov-report=term-missing
```
**手工验收清单**
- [ ] Docker Compose 启动成功。
- [ ] 首页可访问。
- [ ] 创建小红书默认任务。
- [ ] 刷新任务状态直到完成。
- [ ] 查看热点列表。
- [ ] 查看热点级报告。
- [ ] 查看内容条目详情。
- [ ] 查看评论明细。
- [ ] 导出评论 CSV。
- [ ] 导出热点 Markdown。
- [ ] 导出内容条目 Markdown。
- [ ] 创建抖音默认任务并重复上述流程。
- [ ] 非法配置值被前后端拦截。
- [ ] 任务失败时展示失败阶段和错误类型。
**验收**
- MVP 关键验收清单全部通过,或记录明确缺口与降级说明。
---
## 9. P1 可选任务
以下任务在 P0 完成后再做。
### P1-01 任务列表自动轮询
- [ ] 使用 HTMX 或原生 JS 每 5 秒刷新运行中任务。
- [ ] 所有任务完成后自动停止轮询。
- [ ] 保留手动刷新按钮。
- [ ] 将任务列表行(`<tr>` 集合)抽离为独立局部模板 `app/templates/partials/task_rows.html`
- [ ]`index.html``<tbody>` 中使用 `{% include "partials/task_rows.html" %}` 初始渲染。
- [ ] 配置 HTMX 属性:`hx-get="/partials/tasks"``hx-trigger="every 5s [条件]"``hx-target="#task-table-body"``hx-swap="innerHTML"`
- [ ] 所有任务状态均不为 running 时,动态移除 `hx-trigger` 属性,停止轮询,避免无效请求。
### P1-02 原始 JSON 调试入口
- [ ] 在内容条目详情页用 `<details>` 展示 raw_data。
- [ ] 不作为正式用户功能入口突出展示。
### P1-03 基础进度条
- [ ] 在任务列表和任务详情展示 Bootstrap Progress Bar。
- [ ] 数据来源只使用后端 `processed_items_count``total_items_count``successful_items_count`
---
## 10. 开发顺序依赖
```text
T01 项目骨架
→ T02 配置
→ T03 数据模型
→ T04 任务创建
→ T05 僵尸任务恢复
→ T06 首页
→ T07 API 客户端
→ T08 小红书链路
→ T09 抖音链路
→ T10 评论分页
→ T11 抓取任务集成
→ T12 AI Schema
→ T13 AI 重试与成功率
→ T14 内容条目报告
→ T15 热点报告
→ T16 AI + 报告任务集成
→ T17 任务详情
→ T18 热点报告页
→ T19 内容详情页
→ T20 导出
→ T21 模板与静态交互
→ T22 Docker
→ T23 最终验收
```
注:T08(小红书字段映射)与 T09(抖音字段映射)逻辑上彼此独立,均以 T07(API 客户端)为前置依赖,
可并行开发。当前顺序为单人开发推荐执行序,多人协作时可同步开展。
依赖关系图示:
```text
T07 API 客户端
├── T08 小红书链路 ─┐
└── T09 抖音链路 ─┴→ T10 评论分页 → T11 抓取集成
```
---
## 11. 风险与降级
| 风险 | 表现 | 降级策略 |
|---|---|---|
| TikHub 字段变化 | 字段为空或映射失败 | 保留 raw_data,字段映射使用多候选字段 |
| 平台 API 限流 | 任务变慢或部分内容失败 | 429 指数退避,超过重试后跳过当前条目 |
| AI 输出不稳定 | JSON 解析失败 | JSON Schema 校验、最多 3 次整批重试;失败批次标记 failed,不阻断其他批次 |
| AI 成本或耗时过高 | 任务执行时间过长 | 降低抓取规模或 AI batch size |
| SQLite 写入冲突 | 任务失败 | 单 worker + WAL + timeout |
| 容器重启 | running 任务卡住 | lifespan 中恢复为 failed |
| 页面轮询未做 | 用户不知道进度 | P0 手动刷新 + 创建时间兜底 |
---
## 12. 完成定义
当以下条件全部满足时,MVP 开发任务视为完成:
1. P0 任务 T01T23 完成。
2. 单元测试和集成测试通过。
3. Docker Compose 可启动。
4. 小红书默认任务可完成抓取、分析、报告和导出。
5. 抖音默认任务可完成抓取、分析、报告和导出。
6. 页面可查看任务、热点、热点报告、内容条目详情和评论明细。
7. 任务失败时展示失败阶段和错误类型。
8. 敏感 Key 未写入代码仓库。
---
## 变更日志
| 日期 | 版本 | 变更内容 |
|---|---|---|
| 2025-07-10 | v1.0 | 初始版本 |
| 2025-07-10 | v1.1 | 基于双审阅报告合并修订(共 23 条指令):T04 补充并发任务拦截逻辑(已有 running 任务时返回 400);T02 补充 CRAWL_PAGE_INTERVAL_SECONDS 配置项;T03 补充 3 张表的性能预留索引步骤;T06 修正任务创建成功后跳转行为(删除“或”,明确跳转至 /tasks/{id}),补充规模预估实时计算步骤,补充面包屑导航实现步骤;T07 补充 httpx 同步实例类型测试和 429 退避测试,声明 http_429_response.json fixtureT08/T09 补充缺失字段容错测试和对应 Fixture 文件声明;T11 补充事务粒度约束(每条目 commit)和跨热点重复条目集成测试;T12 补充 ai_comments_all_sentiments.json fixture 和四情绪统计测试;T13 移除 batch 减半逻辑,改为纯“最多 3 次整批重试”与 DevelopmentPlan §7.1 保持一致;T14/T15 补充报告总结 AI 请求测试步骤(输入构造、截断、失败降级),补充 prompts/report_summary.txt 创建步骤;T16 补充批次级事务 commit 约束;T17/T18/T19 各补充面包屑渲染步骤,T17 验收注明热点报告入口仅验证链接存在;T19 补充评论排序规则来源说明;T20 补充 CSV 换行符替换逻辑和测试,将“按钮由模板控制”替换为 4 条具体业务逻辑测试(仅测 disabled 条件,不测视觉样式),补充公式注入测试;T21 补充 4 个页面的 <title> 命名规范实现步骤;T22 补充 Dockerfile 非 root 用户步骤;P1-01 补充 partials/task_rows.html 抽离和轮询停止逻辑;§10 补充 T08/T09 并行关系图示;Day 2/Day 3 补充时间盒约束策略说明。 |
+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"}
+797
View File
@@ -0,0 +1,797 @@
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_uses_pencil_control_console_layout_for_ui_polish():
with make_test_client() as (client, _engine):
response = client.get("/")
assert response.status_code == 200
soup = BeautifulSoup(response.text, "html.parser")
visible_text = soup.get_text(" ")
hero = soup.select_one(".control-hero")
assert hero is not None
assert "用默认配置快速启动一次评论分析" in visible_text
assert "内容平台评论分析控制台" in visible_text
assert soup.select_one(".quick-launch-panel") is not None
assert soup.select_one(".default-scale-grid") is not None
assert soup.select_one(".overview-grid") is not None
assert soup.select_one(".task-card-list") is not None
assert soup.select_one(".task-status-sidepanel") is not None
assert soup.select_one("table.app-table.data-grid-table") is None
assert "卡片化展示,减少表格压迫感" in visible_text
assert "运行中任务自动刷新" in visible_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_detail_page_uses_pencil_execution_view_layout():
with make_test_client() as (client, engine):
seed_result_data(engine)
response = client.get("/tasks/task-result")
assert response.status_code == 200
soup = BeautifulSoup(response.text, "html.parser")
visible_text = soup.get_text(" ")
assert soup.select_one(".task-detail-hero") is not None
assert soup.select_one(".execution-timeline") is not None
assert soup.select_one(".hotspot-card-list") is not None
assert soup.select_one(".detail-sidepanel") is not None
assert "TASK DETAIL" in visible_text
assert "执行阶段" in visible_text
assert "热点结果" in visible_text
assert "错误会被隔离" in visible_text
assert "查看报告" in visible_text
def test_hotspot_report_page_uses_pencil_analysis_reading_layout():
with make_test_client() as (client, engine):
seed_result_data(engine)
response = client.get("/hotspots/hot-result/report")
assert response.status_code == 200
soup = BeautifulSoup(response.text, "html.parser")
visible_text = soup.get_text(" ")
assert soup.select_one(".report-reading-hero") is not None
assert soup.select_one(".report-reading-layout") is not None
assert soup.select_one(".insight-card") is not None
assert soup.select_one(".sentiment-sidepanel") is not None
assert "HOTSPOT REPORT" in visible_text
assert "核心结论" in visible_text
assert "代表评论" in visible_text
assert "内容表现" in visible_text
assert "同一份报告数据" in visible_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" },
]