Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
918dd62e21 | ||
|
|
1d24cbf841 | ||
|
|
9d212b54be | ||
|
|
c406364d14 | ||
|
|
89dcf49186 | ||
|
|
78cc5df73d | ||
|
|
da69819b56 | ||
|
|
7371c45fb4 | ||
|
|
bac5304481 | ||
|
|
1f68cee99c | ||
|
|
aec3604de9 | ||
|
|
0b186fa9f8 | ||
|
|
07ce3d4fcd | ||
|
|
4102ecc4e3 | ||
|
|
b70b840551 | ||
|
|
2dc8ca1b5b | ||
|
|
9244f360be | ||
|
|
61bcb2ef86 | ||
|
|
d6f0f7c1b0 | ||
|
|
f91d2272d8 | ||
|
|
4dc6878f45 | ||
|
|
08d66656a9 | ||
|
|
9935f67080 | ||
|
|
867fd39419 | ||
|
|
a8f5eeaff5 | ||
|
|
f8fddf1eb3 | ||
|
|
b14834e806 | ||
|
|
11f129c4aa | ||
|
|
89c93b5051 | ||
|
|
accfaeffaf | ||
|
|
46037a217b | ||
|
|
eedb1db4da | ||
|
|
82d103757b | ||
|
|
6a2ced0a07 | ||
|
|
f756eacf39 | ||
|
|
b9291f9011 | ||
|
|
0a3477cc44 | ||
|
|
5c34ce75b6 | ||
|
|
c612afd217 | ||
|
|
9bad7b6e63 | ||
|
|
0231be6338 | ||
|
|
c04b13b5ed | ||
|
|
ace3804f74 | ||
|
|
dfcfb68667 | ||
|
|
7eea937f28 |
@@ -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
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.DS_Store
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.env
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
data/corrupt-backups/
|
||||
@@ -0,0 +1,348 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Purpose
|
||||
|
||||
This file defines how AI coding agents should work in this repository. It is an operating guide, not a product requirements document.
|
||||
|
||||
Do not restate or replace the product specs here. Product requirements, UI decisions, technical decisions, and test expectations live in `docs/`.
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
Before implementation, read the relevant docs in this order:
|
||||
|
||||
1. `docs/PRD.md`
|
||||
2. `docs/RequirementsDoc.md` (if it exists in the repository; skip if absent)
|
||||
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`
|
||||
|
||||
If `docs/review-*.md` files exist, read them after the corresponding source document.
|
||||
Review files contain corrections and clarifications that override ambiguous points
|
||||
in the source document. For example, read `review-Tasks-kiro.md` after `Tasks.md`.
|
||||
|
||||
Use `docs/DevelopmentPlan.md` for architecture and technology choices. Use `docs/Tasks.md` for implementation sequencing. Use `docs/TDD.md` for test strategy. Use `docs/UIDesign.md` for page structure and UI behavior. Use the API Spike docs for platform field mapping and external API flow.
|
||||
|
||||
If documents conflict or if the provided context is insufficient to make a decision,
|
||||
DO NOT guess or hallucinate. Stop execution immediately, summarize the conflict or
|
||||
information gap, cite the files involved, and ask the user for confirmation before
|
||||
implementing. This applies to all levels of conflict -- architecture, implementation
|
||||
details, naming conventions, and behavioral expectations alike.
|
||||
Never proceed with an assumption when the source documents are ambiguous or silent
|
||||
on a required decision.
|
||||
|
||||
If you cannot find the answer in the listed documents and your own knowledge is
|
||||
uncertain, state explicitly: "I don't have enough context to decide this. Please
|
||||
provide [specific document or clarification]." Never fill gaps with invented behavior.
|
||||
|
||||
## Project Constraints
|
||||
|
||||
- Build a lightweight MVP first.
|
||||
- Prefer a working end-to-end flow over broad incomplete features.
|
||||
- The entire project follows TDD (Test-Driven Development). For every feature,
|
||||
bug fix, data transformation, service, or behavior change, write the relevant
|
||||
failing test first, implement the smallest code to pass it, then refactor only
|
||||
after tests pass. If a task cannot reasonably be test-first, state the reason
|
||||
before implementation and add verification coverage as close to the change as possible.
|
||||
- Keep the architecture aligned with the current plan: FastAPI, SQLite, SQLAlchemy, Jinja2 templates, simple CSS or Bootstrap, native JavaScript, and Docker Compose.
|
||||
- Do not introduce a frontend SPA framework, Redis, Celery, PostgreSQL, login system, scheduled jobs, or distributed workers unless the user explicitly changes the scope.
|
||||
- Do not commit real API keys, tokens, cookies, or private credentials.
|
||||
- Treat TikHub and AI providers as external dependencies that must be mocked in tests.
|
||||
- Background tasks run in a ThreadPoolExecutor thread, not in the async event loop.
|
||||
Use synchronous `httpx.Client` for all external HTTP calls in background tasks.
|
||||
Do not use `httpx.AsyncClient` or `await` in background task functions.
|
||||
- Reports are pre-generated after task completion and stored in the `reports` table.
|
||||
Page rendering and file exports must read from the same pre-generated report data.
|
||||
Do not compute statistics on-the-fly in page route handlers.
|
||||
- Task execution uses `ThreadPoolExecutor(max_workers=1)`. Only one task can run at
|
||||
a time. If a task with `status=running` already exists, reject new task creation
|
||||
with HTTP 400.
|
||||
|
||||
## MVP Discipline (Optimized for Speed)
|
||||
|
||||
The constraints in §Project Constraints are always active regardless of timeline.
|
||||
This section provides prioritization guidance, not permission to skip P0 features
|
||||
defined in `docs/Tasks.md`.
|
||||
|
||||
When the user asks for fast delivery, optimize for the earliest demonstrable slice.
|
||||
But you MUST deliver all P0 tasks defined in `docs/Tasks.md` §7 (T01-T23).
|
||||
Do NOT defer any P0 feature without explicit user confirmation.
|
||||
|
||||
Items that may be deferred only with explicit user confirmation:
|
||||
|
||||
- Playwright e2e tests (T23 Playwright portion only)
|
||||
- P1 optional tasks (docs/Tasks.md §9: auto-polling, JSON debug panel, progress bar)
|
||||
- Advanced UI polish (breadcrumbs, `<title>` naming, progress animations)
|
||||
|
||||
Items that must NOT be deferred without explicit user confirmation:
|
||||
|
||||
- Zombie task recovery (T05)
|
||||
- 429 exponential backoff (T07)
|
||||
- Comment pagination (T10)
|
||||
- AI retry and degradation (T13)
|
||||
- Report pre-generation (T14/T15/T16)
|
||||
- CSV/Markdown export (T20)
|
||||
- Docker Compose packaging (T22)
|
||||
|
||||
Defer polish, scale, authentication, scheduling, and features not listed in
|
||||
`docs/Tasks.md` P0 scope. When in doubt about whether something is P0,
|
||||
check `docs/Tasks.md` -- it is the authoritative task list.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
For each task:
|
||||
|
||||
1. Inspect the relevant docs and existing code.
|
||||
2. Identify the smallest useful implementation slice.
|
||||
3. Write tests BEFORE business logic for:
|
||||
- Data mapping and field transformation (platforms -> models)
|
||||
- AI JSON Schema validation and parsing
|
||||
- Report statistics calculation
|
||||
- Export formatting (CSV structure, Markdown structure)
|
||||
- State machine transitions (task status, analysis status)
|
||||
- Error degradation paths (retry exhaustion -> fallback behavior)
|
||||
For UI templates, route handler wiring, and configuration setup,
|
||||
write tests before or alongside implementation -- but never after.
|
||||
This is a hard rule from `docs/TDD.md` §2.1: "禁止先实现后补测试。"
|
||||
4. Implement the minimum code needed to pass the tests.
|
||||
5. Run focused verification commands.
|
||||
6. Report what changed, what was verified, and what remains.
|
||||
|
||||
Keep changes scoped. Avoid unrelated refactors. If a file or design issue blocks the requested work, propose the smallest corrective change that serves the current goal.
|
||||
|
||||
### Project Memory And Conversation Learnings
|
||||
|
||||
After any valuable conversation iteration, agents should consider whether the
|
||||
learning should be preserved in this `AGENTS.md` file so future agents understand
|
||||
the project better. Examples include successfully resolving a recurring problem,
|
||||
confirming an ambiguous project convention, discovering a reliable workflow, or
|
||||
clarifying how agents should coordinate work in this repository.
|
||||
|
||||
Do not silently add uncertain or speculative rules. If the learning is ambiguous,
|
||||
could change product behavior, or might conflict with the source documents, ask the
|
||||
user for confirmation before updating `AGENTS.md`.
|
||||
|
||||
Keep additions concise and operational. `AGENTS.md` should capture durable agent
|
||||
working rules, not replace product requirements, implementation specs, or detailed
|
||||
task plans that belong in `docs/`.
|
||||
|
||||
### Error Handling Philosophy
|
||||
|
||||
These principles are defined in `docs/DevelopmentPlan.md` §10 and `docs/TDD.md` §13.
|
||||
Apply them consistently across all implementations:
|
||||
|
||||
- A single item failure must not crash the entire task. Isolate failures at the
|
||||
per-comment or per-content-item level.
|
||||
- External API failures (4xx/5xx) should be retried with exponential backoff
|
||||
(1s -> 2s -> 4s), then gracefully degraded with error recorded in the database.
|
||||
- AI analysis failures should be recorded per-comment via `ai_analysis_status=failed`
|
||||
and reflected in `analysis_success_rate`, not propagated as task-level failures.
|
||||
- Page rendering must never return HTTP 500 due to missing report data.
|
||||
Use default text or empty-state UI instead.
|
||||
- Each processing unit (content item, comment batch) should commit to the database
|
||||
independently. Do not hold a single transaction open for the entire task duration.
|
||||
|
||||
## Superpowers Workflow
|
||||
|
||||
Superpowers are structured thinking modes available in certain AI coding environments
|
||||
(e.g., Codex). If the current environment does not support `superpowers:*` prefixes,
|
||||
apply the same cognitive sequence manually: brainstorm -> plan -> test-first -> implement
|
||||
-> debug -> verify.
|
||||
|
||||
When superpowers skills are available, use them as the development process layer:
|
||||
|
||||
- Use `superpowers:brainstorming` before unclear feature design, scope decisions, or behavior changes.
|
||||
- Use `superpowers:writing-plans` before substantial implementation.
|
||||
- Use `superpowers:test-driven-development` for business logic, data mapping, parsing, reporting, and bug fixes when practical.
|
||||
|
||||
Before moving from implementation to the verification step:
|
||||
|
||||
- Remove all placeholder comments (e.g., `# TODO: implement this`, `# FIXME`).
|
||||
- Remove all debugging print/console.log statements.
|
||||
- Ensure no commented-out code blocks remain unless they serve as documentation
|
||||
for a deliberate design decision (annotated with a reason).
|
||||
|
||||
- Use `superpowers:systematic-debugging` before fixing failing behavior or unexpected test results.
|
||||
- Use `superpowers:verification-before-completion` before claiming work is complete.
|
||||
- Use `superpowers:requesting-code-review` for large changes or milestone completion.
|
||||
|
||||
If the skills are unavailable, follow the same principles manually: clarify scope, write a short plan, test first where practical, debug from evidence, verify before completion, and keep commits focused.
|
||||
|
||||
## Multi-Agent Rules
|
||||
|
||||
Use multiple agents only for independent work that can be reviewed and integrated by the main agent.
|
||||
|
||||
Good parallel tasks:
|
||||
|
||||
- Review docs for conflicts.
|
||||
- Investigate one platform API mapping.
|
||||
- Draft tests for one service.
|
||||
- Review UI behavior against `docs/UIDesign.md`.
|
||||
- Review implementation for bugs after a feature is complete.
|
||||
- Implementing two independent platform adapters (e.g., T08 Xiaohongshu and T09 Douyin).
|
||||
- Building two page templates that do not share data queries (e.g., T17 and T18).
|
||||
- Implementing export service (T20) while another agent works on template macros (T21).
|
||||
- Writing unit tests for module A while another agent implements module B that has no
|
||||
dependency on A.
|
||||
|
||||
Avoid parallel agents for:
|
||||
|
||||
- Editing the same core file at the same time.
|
||||
- Making competing architecture decisions.
|
||||
- Changing shared data models without one owner.
|
||||
- Implementing broad cross-cutting changes without an integration plan.
|
||||
|
||||
The main agent remains responsible for final decisions, integration, verification, and Git commits.
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
Follow `docs/TDD.md`.
|
||||
|
||||
Default verification targets:
|
||||
|
||||
```bash
|
||||
pytest tests/unit -q
|
||||
pytest tests/integration -q
|
||||
pytest tests/unit tests/integration -q
|
||||
curl -f http://localhost:8000/health
|
||||
```
|
||||
|
||||
Before claiming a milestone or task complete, run the coverage check:
|
||||
|
||||
```bash
|
||||
pytest tests/unit tests/integration --cov=app --cov-branch --cov-report=term-missing
|
||||
```
|
||||
|
||||
Target line coverage: 80% or above for the following modules:
|
||||
|
||||
- `app/platforms/` (all platform adapters)
|
||||
- `app/services/ai_service.py`
|
||||
- `app/services/report_service.py`
|
||||
- `app/services/export_service.py`
|
||||
|
||||
If coverage drops below 80% for these modules, add tests before proceeding.
|
||||
|
||||
Use focused commands while developing, then run broader verification before completion. Do not rely on real TikHub or AI API calls for unit tests. Mock external HTTP calls and AI responses.
|
||||
|
||||
For UI work, verify rendered pages manually or with browser automation when practical. Check that text does not overlap, core actions are visible, and the page follows `docs/UIDesign.md`.
|
||||
|
||||
## Git Workflow
|
||||
|
||||
Use small, focused commits. One commit should represent one clear change.
|
||||
|
||||
### Solo Commit Discipline
|
||||
|
||||
For this solo project, after completing each independent feature/task, run the
|
||||
relevant tests and create one focused git commit for the files related to that
|
||||
task. Do not push unless the user explicitly asks. Do not commit unrelated
|
||||
files.
|
||||
|
||||
An "independent feature/task" means the smallest useful change that can be
|
||||
understood, tested, and reverted on its own. Examples:
|
||||
|
||||
- One `docs/Tasks.md` task such as T07 API retry, T20 export, or T22 Docker.
|
||||
- One narrow bug fix, such as fixing 401 error display or CSV newline handling.
|
||||
- One cohesive page or route improvement, such as adding the task detail page.
|
||||
- One test-only change that documents or locks down one behavior.
|
||||
|
||||
Do not mix unrelated changes in one commit. For example, do not combine Docker
|
||||
deployment, UI redesign, AI retry logic, and documentation edits unless they are
|
||||
strictly required to complete one same task.
|
||||
|
||||
Commit messages for this project must be written in Chinese while keeping the
|
||||
standard prefix. Examples:
|
||||
|
||||
- `feat: 接入真实 AI 评论分析`
|
||||
- `fix: 修复评论分页停止条件`
|
||||
- `test: 补充导出 CSV 注入防护测试`
|
||||
- `docs: 记录单人项目提交规则`
|
||||
|
||||
### Current Solo Execution Mode
|
||||
|
||||
The current project phase is initial solo development and process practice. Unless
|
||||
the user explicitly enables parallel work or PR workflow, execute tasks
|
||||
sequentially: one task at a time, in dependency order.
|
||||
|
||||
For this phase, use one task branch per `docs/Tasks.md` task, named
|
||||
`feat/tXX-short-description` (for example, `feat/t01-project-skeleton`). After the
|
||||
task passes its required checks, create one focused commit for that task when
|
||||
practical. Large tasks may be split into multiple meaningful commits only when a
|
||||
single commit would be hard to review or safely revert.
|
||||
|
||||
Do not start the next task until the previous task has been reviewed, verified,
|
||||
and either merged into the working baseline or explicitly approved as the base for
|
||||
the next branch. Do not open a PR unless the user asks for one.
|
||||
|
||||
Recommended commit prefixes:
|
||||
|
||||
- `docs:` documentation changes
|
||||
- `feat:` new user-visible functionality
|
||||
- `fix:` bug fixes
|
||||
- `test:` tests only
|
||||
- `chore:` maintenance, tooling, or project setup
|
||||
|
||||
Before committing:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Commit only files related to the current task. Do not revert unrelated user changes. Push only after the commit is verified and the user wants the branch updated remotely.
|
||||
|
||||
### Branch Strategy
|
||||
|
||||
For single-agent development: work directly on `main` unless the user specifies
|
||||
a different branch.
|
||||
|
||||
For multi-agent parallel development: each agent MUST operate on a separate Git
|
||||
branch derived from the current `main`. Branch naming convention:
|
||||
`agent/<agent-id>/<task-id>` (e.g., `agent/codex-1/T08`).
|
||||
|
||||
Only the primary agent (or the user) may perform merges back to `main`.
|
||||
Before merging, the branch must pass all tests defined in §Testing And Verification.
|
||||
|
||||
Never have two agents editing the same file on different branches simultaneously.
|
||||
If task dependencies require touching the same file, serialize the work.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Never store secrets in source files, docs, tests, fixtures, or commit messages.
|
||||
- Use `.env.example` for variable names only.
|
||||
- Keep raw external API responses only where the docs require them and avoid including private user data in fixtures.
|
||||
- In test fixtures, replace real usernames, avatar URLs, user IDs, and IP addresses
|
||||
with placeholder values (e.g., "test_user_001", "https://example.com/avatar.png",
|
||||
"user_id_placeholder_001"). Do not copy production API responses directly into
|
||||
fixture files without sanitization.
|
||||
- The `raw_data` JSON field in the database may contain user-generated content.
|
||||
When writing tests that assert on `raw_data`, use synthetic fixture data only.
|
||||
- If credentials are exposed during the conversation, remind the user to rotate or delete them.
|
||||
- Do not use destructive Git commands unless the user explicitly asks for them.
|
||||
|
||||
## Completion Standard
|
||||
|
||||
A task is complete only when:
|
||||
|
||||
1. The requested behavior or document exists.
|
||||
2. Relevant tests or checks have been run, or the reason they could not be run is stated.
|
||||
3. The work is scoped to the request.
|
||||
4. The final response explains the result in plain language.
|
||||
5. Any remaining risks or follow-up tasks are clearly named.
|
||||
6. If the completed task corresponds to a checkbox in `docs/Tasks.md`, mark it
|
||||
as done (change `- [ ]` to `- [x]`).
|
||||
7. The final response MUST explicitly state which tests were run and passed,
|
||||
which edge cases were verified or mocked, and any known limitations of the
|
||||
current implementation.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Version | Changes |
|
||||
|---|---|---|
|
||||
| 2025-07-10 | v1.0 | Initial version |
|
||||
| 2025-07-10 | v1.1 | Post-review revision based on dual review merge (12 instructions): §Two-Day MVP Discipline rewritten as "MVP Discipline (Optimized for Speed)" -- no longer a cutting list, explicitly prohibits deferring P0 features without user confirmation; §Project Constraints gains three architecture constraints (httpx sync-only in background threads, pre-generated reports, single-task executor with 400 rejection); §Source Of Truth gains review-file inclusion, absolute-stop conflict resolution policy, anti-hallucination directive, and RequirementsDoc.md existence guard; §Development Workflow TDD instruction upgraded from "when practical" to mandatory-by-category with explicit TDD.md §2.1 citation; Error Handling Philosophy subsection added; §Testing And Verification gains coverage command and 80% target; §Git Workflow gains branch strategy with mandatory multi-agent branch isolation; §Multi-Agent Rules gains 4 construction-type parallel task examples; §Superpowers Workflow gains environment compatibility note and pre-verification cleanup rule; §Safety Rules gains raw_data sanitization guidance for fixtures; §Completion Standard gains Tasks.md checkbox sync requirement and explicit test-result reporting requirement. |
|
||||
+22
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Application package for the hot comments analysis tool."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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:
|
||||
raise PlatformAPIError(
|
||||
f"External API returned HTTP {response.status_code}",
|
||||
error_type="api_error",
|
||||
status_code=response.status_code,
|
||||
)
|
||||
return response.json()
|
||||
|
||||
raise PlatformAPIError("External API request failed", error_type="api_error", status_code=last_status)
|
||||
|
||||
|
||||
def parse_timestamp(value: Any) -> datetime | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if number > 10_000_000_000:
|
||||
number = number // 1000
|
||||
return datetime.fromtimestamp(number, tz=UTC)
|
||||
|
||||
|
||||
def first_present(data: dict[str, Any], *keys: str) -> Any:
|
||||
for key in keys:
|
||||
if key in data and data[key] not in (None, ""):
|
||||
return data[key]
|
||||
return None
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
只返回 JSON Array,不输出 Markdown 或解释性自然语言。
|
||||
请原样回填输入中的 comment_id,不得修改或生成新 ID。
|
||||
sentiment 只能是 positive、negative、neutral、unknown。
|
||||
labels 使用 1 到 3 个简短中文短语;无法判断时 labels 可为空数组。
|
||||
@@ -0,0 +1,3 @@
|
||||
根据输入的情绪分布、Top 标签和典型评论,生成事实性中文总结。
|
||||
只输出纯文本,不使用 Markdown,不超过指定字数。
|
||||
总结失败时由后端使用默认文案兜底。
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Service modules for task execution, crawling, AI analysis, reports, and export."""
|
||||
@@ -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、unknown;labels 最多 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"])
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
body {
|
||||
background:
|
||||
linear-gradient(180deg, #f4f7fb 0%, #eef3f8 42%, #f8fafc 100%);
|
||||
color: #182235;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-navbar {
|
||||
background: rgba(255, 255, 255, 0.94) !important;
|
||||
border-bottom: 1px solid #dfe7f1;
|
||||
box-shadow: 0 10px 30px rgba(19, 34, 56, 0.04);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #526173;
|
||||
}
|
||||
|
||||
.tool-card,
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dbe4ef;
|
||||
box-shadow: 0 16px 42px rgba(31, 42, 68, 0.07);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header,
|
||||
.section-heading {
|
||||
background: linear-gradient(180deg, #ffffff, #f8fafc);
|
||||
border-bottom: 1px solid #e4ebf3;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-heading h2,
|
||||
.page-heading h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-kicker,
|
||||
.eyebrow {
|
||||
color: #0e766e;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
margin: 0 0 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-band {
|
||||
align-items: flex-end;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(12, 22, 40, 0.92), rgba(29, 80, 108, 0.78)),
|
||||
url("https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1600&q=80");
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 24px 60px rgba(15, 36, 62, 0.18);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: 300px;
|
||||
padding: 42px;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.hero-copy p:not(.eyebrow) {
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
font-size: 18px;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-box {
|
||||
background: linear-gradient(180deg, #f9fbfd, #f3f7fb);
|
||||
border: 1px solid #e3e9f2;
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.metric-box strong {
|
||||
color: #132033;
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.app-table thead th {
|
||||
background: #f8fafc;
|
||||
color: #536174;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-table tbody tr {
|
||||
border-color: #e8eef5;
|
||||
}
|
||||
|
||||
.app-table tbody tr:hover {
|
||||
background: #f7fbff;
|
||||
}
|
||||
|
||||
.task-number {
|
||||
align-items: center;
|
||||
background: #e7f0ff;
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 999px;
|
||||
color: #0b5ed7;
|
||||
display: inline-flex;
|
||||
font-weight: 700;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.task-overview {
|
||||
border-top: 3px solid #0d6efd;
|
||||
}
|
||||
|
||||
.hotspot-item {
|
||||
border-color: #e3eaf2;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #dfe5ef;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.status-panel-danger {
|
||||
border-color: #f1b8b8;
|
||||
}
|
||||
|
||||
.task-progress {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.report-progress {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-band {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
min-height: 360px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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" 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 %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ hotspot.title }} 汇总报告 - 热榜评论分析工具{% endblock %}
|
||||
{% block breadcrumbs %}
|
||||
<nav aria-label="breadcrumb"><ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">首页</a></li>
|
||||
<li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">任务 {{ task.display_id }}</a></li>
|
||||
<li class="breadcrumb-item active">汇总报告</li>
|
||||
</ol></nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 mb-0">{{ hotspot.title }} 汇总报告</h1>
|
||||
</div>
|
||||
{% if report %}
|
||||
{% include "partials/report_panel.html" %}
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<div class="spinner-border text-warning mb-3" role="status"></div>
|
||||
<p>报告生成中,请稍候...</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
{% set has_running_tasks = tasks | selectattr("status", "equalto", "running") | list | length > 0 %}
|
||||
{% block title %}热榜评论雷达 - 热榜评论分析工具{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero-band mb-4">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Hot Comment Radar</p>
|
||||
<h1>热榜评论雷达</h1>
|
||||
<p>从小红书和抖音热点出发,抓取真实评论,生成 AI 情绪、标签和可导出的分析报告。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tool-card mb-4" id="create-task">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">采集配置</p>
|
||||
<h2>创建抓取任务</h2>
|
||||
</div>
|
||||
<span class="text-muted small">默认规模 5 × 5 × 50</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="form-error" class="alert alert-danger d-none" role="alert"></div>
|
||||
<form id="task-form" onsubmit="submitTask(event)">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="platform">平台</label>
|
||||
<select class="form-select" id="platform" name="platform">
|
||||
<option value="xiaohongshu">小红书</option>
|
||||
<option value="douyin">抖音</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="hotspot_limit">热点数</label>
|
||||
<input class="form-control scale-input" id="hotspot_limit" name="hotspot_limit" type="number" min="1" max="10" value="5">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="item_limit_per_hotspot">每热点内容</label>
|
||||
<input class="form-control scale-input" id="item_limit_per_hotspot" name="item_limit_per_hotspot" type="number" min="1" max="10" value="5">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="comment_limit_per_item">每内容评论</label>
|
||||
<input class="form-control scale-input" id="comment_limit_per_item" name="comment_limit_per_item" type="number" min="10" max="100" value="50">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-primary w-100" id="submit-btn" type="submit">开始抓取</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text text-muted mt-2" id="scale-hint">
|
||||
预计最多抓取:<strong id="scale-calc">1250</strong> 条评论(实际数量可能受平台返回数量、去重、失败、限流影响)
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tool-card" id="task-history">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">任务历史</p>
|
||||
<h2>最近任务</h2>
|
||||
</div>
|
||||
{% if has_running_tasks %}<span class="badge text-bg-warning">自动更新中</span>{% endif %}
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table app-table align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务 ID</th>
|
||||
<th>平台</th>
|
||||
<th>创建时间</th>
|
||||
<th>配置规模</th>
|
||||
<th>进度</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="task-table-body" {% if has_running_tasks %}data-task-list-auto-poll="true"{% endif %}>
|
||||
{% include "partials/task_rows.html" %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% if has_running_tasks %}
|
||||
<script>document.addEventListener("DOMContentLoaded", () => pollTaskListStatus());</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% 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="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 mb-0">{{ item.title or item.source_item_id }}</h1>
|
||||
</div>
|
||||
{% if report %}
|
||||
{% include "partials/report_panel.html" %}
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<div class="spinner-border text-warning mb-3" role="status"></div>
|
||||
<p>报告生成中,请稍候...</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<section class="card"><div class="card-header">评论明细</div><div class="table-responsive">
|
||||
<table class="table mb-0"><thead><tr><th>评论内容</th><th>情绪</th><th>标签</th><th>点赞</th></tr></thead><tbody>
|
||||
{% for comment in comments %}
|
||||
<tr><td>{{ comment.content }}</td><td>{{ comment.sentiment }}</td><td>{% for label in comment.labels | from_json %}<span class="badge bg-secondary me-1">{{ label }}</span>{% else %}-{% endfor %}</td><td>{{ comment.like_count or 0 }}</td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="text-center text-muted">暂无评论数据(该内容无评论或评论抓取为空)</td></tr>
|
||||
{% endfor %}
|
||||
</tbody></table>
|
||||
</div></section>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,4 @@
|
||||
{% macro status_badge(status) %}
|
||||
{% set label, style = status_badge_config(status) %}
|
||||
<span class="badge {{ style }}">{{ label }}</span>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,91 @@
|
||||
{% set metrics = report.metrics_json | from_json_object %}
|
||||
{% set typical = report.typical_comments_json | from_json_object %}
|
||||
{% set sentiment = metrics.get("sentiment", {}) %}
|
||||
{% set top_labels = metrics.get("top_labels", []) %}
|
||||
{% set sample_count = metrics.get("sample_count", 0) %}
|
||||
{% set item_count = metrics.get("item_count") %}
|
||||
|
||||
<section class="card mb-4">
|
||||
<div class="card-body">
|
||||
{% if task.analysis_status == "insufficient" %}
|
||||
<div class="alert alert-warning">当前有效评论样本不足,AI 总结暂不可用。建议增加评论抓取数量后重新分析。</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="text-muted small">评论样本</div>
|
||||
<div class="h4 mb-0">{{ sample_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if item_count is not none %}
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="text-muted small">关联内容</div>
|
||||
<div class="h4 mb-0">{{ item_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="text-muted small">AI 成功率</div>
|
||||
<div class="h4 mb-0">{{ rate_percent(task.analysis_success_rate) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 bg-info-subtle border border-info-subtle rounded mb-4">
|
||||
<div class="fw-semibold mb-2">AI 总结</div>
|
||||
<p class="mb-0">{{ report.summary or "总结生成失败,请查看上方统计数据。" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-6">
|
||||
<h2 class="h5">情绪分布</h2>
|
||||
{% for key, bar_class in [("positive", "bg-success"), ("neutral", "bg-secondary"), ("negative", "bg-danger"), ("unknown", "bg-warning")] %}
|
||||
{% set item = sentiment.get(key, {"count": 0, "pct": 0}) %}
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span>{{ sentiment_label(key) }}</span>
|
||||
<span class="text-muted">{{ item.get("count", 0) }} 条({{ item.get("pct", 0) }}%)</span>
|
||||
</div>
|
||||
<div class="progress mb-3 report-progress" role="progressbar" aria-label="{{ sentiment_label(key) }}情绪占比" aria-valuenow="{{ item.get("pct", 0) }}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar {{ bar_class }}" style="width: {{ item.get("pct", 0) }}%"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<h2 class="h5">Top 标签</h2>
|
||||
{% if top_labels %}
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for label in top_labels %}
|
||||
<span class="badge text-bg-light border">{{ label.get("name") }} ({{ label.get("count", 0) }})</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">暂无标签数据</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card mb-4">
|
||||
<div class="card-header">典型评论</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{% for key in ["positive", "neutral", "negative"] %}
|
||||
<div class="col-md-4">
|
||||
<h3 class="h6">{{ sentiment_label(key) }}</h3>
|
||||
{% for comment in typical.get(key, []) %}
|
||||
<div class="border rounded p-2 mb-2">
|
||||
<p class="mb-1">{{ comment.get("content") }}</p>
|
||||
<small class="text-muted">点赞 {{ comment.get("like_count", 0) }}</small>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted small mb-0">暂无{{ sentiment_label(key) }}代表评论</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,42 @@
|
||||
{% 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>{{ task.platform | platform_label }}</td>
|
||||
<td>{{ task.created_at_label }}</td>
|
||||
<td><small class="text-muted">{{ task.scale_label }}</small></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="small text-muted mt-1">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</div>
|
||||
<div class="small text-muted mt-1">阶段:{{ task.current_stage_label or "等待启动" }}</div>
|
||||
<div class="small text-muted mt-1">评论 {{ task.comments_count or 0 }} / 报告 {{ task.reports_count or 0 }}</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-primary" href="/tasks/{{ task.id }}">查看</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted py-4">还没有任何任务,请在上方创建第一个任务</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}任务 {{ task.display_id }} - 热榜评论分析工具{% endblock %}
|
||||
{% block breadcrumbs %}
|
||||
<nav aria-label="breadcrumb"><ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">首页</a></li>
|
||||
<li class="breadcrumb-item active">任务 {{ task.display_id }}</li>
|
||||
</ol></nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading mb-3">
|
||||
<div>
|
||||
<p class="section-kicker">任务详情</p>
|
||||
<h1>任务 {{ task.display_id }}</h1>
|
||||
</div>
|
||||
{% set label, cls = status_badge_config(task.status) %}
|
||||
<span class="badge {{ cls }}">{{ label }}</span>
|
||||
</div>
|
||||
<section class="tool-card task-overview mb-4" {% if task.status == "running" %}data-task-id="{{ task.id }}" data-auto-poll="true"{% endif %}><div class="card-body">
|
||||
{% set percent = progress_percent(task.processed_items_count, task.total_items_count) %}
|
||||
{% set ai_percent = rate_percent(task.analysis_success_rate) %}
|
||||
{% set target_comments = task.hotspot_limit * task.item_limit_per_hotspot * task.comment_limit_per_item %}
|
||||
<div class="d-flex flex-wrap justify-content-between gap-3 mb-3">
|
||||
<p class="mb-0">平台:{{ task.platform | platform_label }}</p>
|
||||
<p class="mb-0 text-muted">创建时间 {{ task.created_at_label }} · 阶段 {{ task.current_stage_label or "等待启动" }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2">
|
||||
<span>已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
|
||||
<span class="text-muted">{{ percent }}%</span>
|
||||
</div>
|
||||
<div class="progress task-progress mt-2" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar" style="width: {{ percent }}%"></div>
|
||||
</div>
|
||||
<div class="small text-muted mt-2">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="metric-box">
|
||||
<div class="text-muted small">目标上限</div>
|
||||
<strong>{{ task.hotspot_limit }} 热点 × {{ task.item_limit_per_hotspot }} 内容 × {{ task.comment_limit_per_item }} 评论</strong>
|
||||
<div class="text-muted small">最多 {{ target_comments }} 条评论</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="metric-box">
|
||||
<div class="text-muted small">实际结果</div>
|
||||
<strong>实际评论 {{ task.comments_count or 0 }}</strong>
|
||||
<div class="text-muted small">已获取热点 {{ task.hotspots_count or 0 }} / 目标 {{ task.hotspot_limit }} · 报告 {{ task.reports_count or 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="metric-box">
|
||||
<div class="text-muted small">最近进度</div>
|
||||
<strong>{{ task.last_progress_ago_label or "暂无记录" }}</strong>
|
||||
<div class="text-muted small">运行时长 {{ task.running_duration_label or "刚刚" }} · 评论 {{ task.comments_count or 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if task.status == "running" %}
|
||||
<div class="alert alert-secondary">
|
||||
小规模通常较快,默认规模可能需要数分钟,取决于 TikHub 与 AI 响应速度。
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if task.is_progress_stale %}
|
||||
<div class="alert alert-warning">
|
||||
超过 {{ task.stale_threshold_minutes }} 分钟没有进度更新,可能仍在等待外部接口或 AI 响应;如果长时间不恢复,请刷新状态或查看后端日志。
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if task.status == "success" and (task.comments_count or 0) < target_comments %}
|
||||
<div class="alert alert-info">少于理论上限通常是内容本身评论不足或平台返回不足,不直接代表任务失败。若失败内容数大于 0,请结合失败原因判断。</div>
|
||||
{% endif %}
|
||||
<div class="mb-2">
|
||||
<span class="{% if task.analysis_status == 'insufficient' %}text-warning{% else %}text-muted{% endif %}">AI 成功率 {{ ai_percent }}%</span>
|
||||
{% if task.analysis_status == "insufficient" %}
|
||||
<span class="badge text-bg-warning ms-1">AI 样本不足</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if task.error_summary %}<div class="alert alert-danger">{{ task.error_summary }}</div>{% endif %}
|
||||
</div></section>
|
||||
{% if task.status == "running" and not hotspots %}
|
||||
<div class="text-center text-muted py-5"><div class="spinner-border text-warning mb-3"></div><p>正在抓取热点数据,请稍候...</p></div>
|
||||
{% endif %}
|
||||
<div class="accordion" id="hotspot-list">
|
||||
{% for hotspot in hotspots %}
|
||||
<div class="accordion-item hotspot-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button {% if not loop.first %}collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#hotspot-{{ hotspot.id }}">
|
||||
热点 {{ hotspot.rank }}:{{ hotspot.title }}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="hotspot-{{ hotspot.id }}" class="accordion-collapse collapse {% if loop.first %}show{% endif %}" data-bs-parent="#hotspot-list">
|
||||
<div class="accordion-body">
|
||||
<a class="btn btn-sm btn-outline-primary mb-2" href="/hotspots/{{ hotspot.id }}/report">查看热点级汇总报告</a>
|
||||
<ul class="list-group">
|
||||
{% for item in hotspot.content_items %}
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<span>{{ item.title or item.summary or item.source_item_id }} <small class="text-muted">{{ item.status }}</small></span>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/items/{{ item.id }}">查看详情</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if task.status == "running" %}
|
||||
<script>document.addEventListener("DOMContentLoaded", () => pollTaskDetailStatus("{{ task.id }}"));</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
name: hot-comments-tool
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,861 @@
|
||||
# CodexPrompts.md:T01-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、labels,reports 已入库。
|
||||
- 写入 `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 任务指令
|
||||
|
||||
## 任务
|
||||
完成 T22:Docker 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 或用户私密数据。
|
||||
@@ -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
@@ -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
@@ -0,0 +1,914 @@
|
||||
# TDD.md:小红书 / 抖音热榜评论抓取 + AI 分析报告工具
|
||||
|
||||
## 1. 文档信息
|
||||
|
||||
- 文档阶段:TDD(Test-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` 范围为 1–10。
|
||||
3. `item_limit_per_hot` 范围为 1–10。
|
||||
4. `comment_limit_per_item` 范围为 10–100。
|
||||
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. 每种情绪选取 1–2 条典型评论。
|
||||
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. 创建抖音默认任务并重复 2–10。
|
||||
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 验收。 |
|
||||
@@ -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_service,T06 聚焦模板路由,需一个人最终合并 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 注册 lifespan,T06 注册页面路由。
|
||||
- `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 4:AI 与报告
|
||||
|
||||
| 可并行子组 | 任务 | 负责人边界 |
|
||||
|---|---|---|
|
||||
| 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/T09,T10/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
@@ -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 3:AI 分析与报告生成
|
||||
|
||||
### 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 任务 T01–T23 完成。
|
||||
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 fixture;T08/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 补充时间盒约束策略说明。 |
|
||||
@@ -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。
|
||||
@@ -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/*",
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Test package."""
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests."""
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,736 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.models import Comment, ContentItem, Hotspot, Report, Task
|
||||
from tests.helpers import make_test_client
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
|
||||
def test_index_page_renders_task_form_empty_state_and_default_scale():
|
||||
with make_test_client() as (client, _engine):
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "热榜评论分析工具" in response.text
|
||||
assert 'name="platform"' in response.text
|
||||
assert 'name="hotspot_limit"' in response.text
|
||||
assert 'name="item_limit_per_hotspot"' in response.text
|
||||
assert 'name="comment_limit_per_item"' in response.text
|
||||
assert "1250" in response.text
|
||||
assert "手动刷新" not in response.text
|
||||
assert 'onclick="window.location.href=\'/\'"' not in response.text
|
||||
assert 'href="#create-task"' not in response.text
|
||||
assert 'href="#task-history"' not in response.text
|
||||
assert "还没有任何任务" in response.text
|
||||
|
||||
|
||||
def test_index_page_lists_existing_tasks():
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id="task-visible",
|
||||
platform="douyin",
|
||||
status="running",
|
||||
hotspot_limit=3,
|
||||
item_limit_per_hotspot=4,
|
||||
comment_limit_per_item=50,
|
||||
total_items_count=4,
|
||||
processed_items_count=2,
|
||||
successful_items_count=1,
|
||||
failed_items_count=1,
|
||||
analysis_success_rate=0.75,
|
||||
analysis_status="insufficient",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "task-visible" in response.text
|
||||
assert "抖音" in response.text
|
||||
assert "运行中" in response.text
|
||||
assert "已处理 2 / 共 4 条内容" in response.text
|
||||
assert "成功 1 / 失败 1" in response.text
|
||||
assert 'style="width: 50%"' in response.text
|
||||
assert "AI 成功率 75%" in response.text
|
||||
assert "AI 样本不足" in response.text
|
||||
assert 'data-task-list-auto-poll="true"' in response.text
|
||||
assert "pollTaskListStatus" in response.text
|
||||
assert "DOMContentLoaded" in response.text
|
||||
|
||||
|
||||
def test_index_page_uses_short_task_numbers_compact_fields_and_friendly_error_copy():
|
||||
first_id = "11111111-1111-4111-8111-111111111111"
|
||||
second_id = "22222222-2222-4222-8222-222222222222"
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id=first_id,
|
||||
platform="xiaohongshu",
|
||||
status="failed",
|
||||
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
|
||||
hotspot_limit=5,
|
||||
item_limit_per_hotspot=5,
|
||||
comment_limit_per_item=50,
|
||||
error_stage="system",
|
||||
error_type="unexpected_restart",
|
||||
error_message="系统重启,任务被中断",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Task(
|
||||
id=second_id,
|
||||
platform="douyin",
|
||||
status="success",
|
||||
created_at=datetime(2026, 7, 3, 10, 35, tzinfo=UTC),
|
||||
hotspot_limit=1,
|
||||
item_limit_per_hotspot=1,
|
||||
comment_limit_per_item=10,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
visible_text = BeautifulSoup(response.text, "html.parser").get_text(" ")
|
||||
assert " 1 " in f" {visible_text} "
|
||||
assert " 2 " in f" {visible_text} "
|
||||
assert "#" not in visible_text
|
||||
assert first_id not in visible_text
|
||||
assert second_id not in visible_text
|
||||
assert "2026-07-03 18:30" in visible_text
|
||||
assert "2026-07-03 18:35" in visible_text
|
||||
assert "Demo 数据" not in visible_text
|
||||
assert "5热点 × 5内容 × 50评论" in visible_text
|
||||
assert "1热点 × 1内容 × 10评论" in visible_text
|
||||
assert "小红书 · 5热点" not in visible_text
|
||||
assert "抖音 · 1热点" not in visible_text
|
||||
assert "系统重启,任务被中断" in visible_text
|
||||
assert "system / unexpected_restart" not in visible_text
|
||||
assert "unexpected_restart" not in visible_text
|
||||
assert "内部演示工具 | 仅供学习参考" not in visible_text
|
||||
|
||||
|
||||
def test_index_page_does_not_auto_poll_without_running_tasks():
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id="task-done",
|
||||
platform="xiaohongshu",
|
||||
status="success",
|
||||
total_items_count=1,
|
||||
processed_items_count=1,
|
||||
successful_items_count=1,
|
||||
analysis_success_rate=1.0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "task-done" in response.text
|
||||
assert 'data-task-list-auto-poll="true"' not in response.text
|
||||
assert "pollTaskListStatus" not in response.text
|
||||
|
||||
|
||||
def test_running_task_detail_page_auto_polls_current_task():
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id="task-running",
|
||||
platform="xiaohongshu",
|
||||
status="running",
|
||||
hotspot_limit=1,
|
||||
item_limit_per_hotspot=1,
|
||||
comment_limit_per_item=10,
|
||||
total_items_count=2,
|
||||
processed_items_count=1,
|
||||
successful_items_count=1,
|
||||
failed_items_count=0,
|
||||
analysis_success_rate=1.0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/tasks/task-running")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-task-id="task-running"' in response.text
|
||||
assert "pollTaskDetailStatus" in response.text
|
||||
assert "手动刷新" not in response.text
|
||||
assert 'onclick="window.location.reload()"' not in response.text
|
||||
assert "已处理 1 / 共 2 条内容" in response.text
|
||||
assert "AI 成功率 100%" in response.text
|
||||
|
||||
|
||||
def test_task_detail_uses_short_number_title_and_hides_full_uuid():
|
||||
task_id = "33333333-3333-4333-8333-333333333333"
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id=task_id,
|
||||
platform="xiaohongshu",
|
||||
status="running",
|
||||
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
|
||||
hotspot_limit=5,
|
||||
item_limit_per_hotspot=5,
|
||||
comment_limit_per_item=50,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get(f"/tasks/{task_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
visible_text = BeautifulSoup(response.text, "html.parser").get_text(" ")
|
||||
assert "<title>任务 1 - 热榜评论分析工具</title>" in response.text
|
||||
assert "任务 1" in visible_text
|
||||
assert "完整 ID" not in visible_text
|
||||
assert task_id not in visible_text
|
||||
assert "任务 #33333333-3333-4333-8333-333333333333" not in visible_text
|
||||
assert "#" not in visible_text
|
||||
assert "创建时间 2026-07-03 18:30" in visible_text
|
||||
assert "Demo 数据" not in visible_text
|
||||
|
||||
|
||||
def test_demo_task_flag_is_kept_but_demo_copy_is_hidden_from_pages():
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id="demo-task-hidden-copy",
|
||||
platform="xiaohongshu",
|
||||
status="success",
|
||||
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
|
||||
hotspot_limit=1,
|
||||
item_limit_per_hotspot=1,
|
||||
comment_limit_per_item=10,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
index_response = client.get("/")
|
||||
detail_response = client.get("/tasks/demo-task-hidden-copy")
|
||||
api_response = client.get("/api/tasks/demo-task-hidden-copy")
|
||||
|
||||
assert index_response.status_code == 200
|
||||
assert detail_response.status_code == 200
|
||||
assert api_response.status_code == 200
|
||||
assert api_response.json()["is_demo"] is True
|
||||
assert "Demo 数据" not in BeautifulSoup(index_response.text, "html.parser").get_text(" ")
|
||||
assert "Demo 数据" not in BeautifulSoup(detail_response.text, "html.parser").get_text(" ")
|
||||
|
||||
|
||||
def test_running_task_detail_page_keeps_polling_after_hotspots_exist():
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
task = Task(
|
||||
id="task-running-with-hotspot",
|
||||
platform="xiaohongshu",
|
||||
status="running",
|
||||
total_items_count=2,
|
||||
processed_items_count=1,
|
||||
successful_items_count=1,
|
||||
analysis_success_rate=1.0,
|
||||
)
|
||||
session.add(task)
|
||||
session.add(
|
||||
Hotspot(
|
||||
id="hot-running",
|
||||
task_id=task.id,
|
||||
platform="xiaohongshu",
|
||||
title="运行中的热点",
|
||||
rank=1,
|
||||
raw_data="{}",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/tasks/task-running-with-hotspot")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "运行中的热点" in response.text
|
||||
assert "正在抓取热点数据" not in response.text
|
||||
assert "pollTaskDetailStatus" in response.text
|
||||
|
||||
|
||||
def test_running_task_detail_page_shows_stage_runtime_and_stale_progress_warning(monkeypatch):
|
||||
now = datetime(2026, 7, 3, 10, 30, tzinfo=UTC)
|
||||
monkeypatch.setattr("app.services.task_service.utc_now", lambda: now)
|
||||
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
task = Task(
|
||||
id="task-stale",
|
||||
platform="xiaohongshu",
|
||||
status="running",
|
||||
current_stage="ai_analysis",
|
||||
created_at=now - timedelta(hours=2, minutes=5),
|
||||
last_progress_at=now - timedelta(minutes=12),
|
||||
hotspot_limit=5,
|
||||
item_limit_per_hotspot=5,
|
||||
comment_limit_per_item=50,
|
||||
total_items_count=10,
|
||||
processed_items_count=6,
|
||||
successful_items_count=6,
|
||||
failed_items_count=0,
|
||||
analysis_success_rate=0.6,
|
||||
analysis_status="insufficient",
|
||||
)
|
||||
session.add(task)
|
||||
hotspot = Hotspot(id="hot-stale", task_id=task.id, platform="xiaohongshu", title="运行热点", rank=1, raw_data="{}")
|
||||
session.add(hotspot)
|
||||
item = ContentItem(
|
||||
id="item-stale",
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
platform="xiaohongshu",
|
||||
source_item_id="note-stale",
|
||||
item_type="note",
|
||||
title="运行内容",
|
||||
status="pending",
|
||||
raw_data="{}",
|
||||
)
|
||||
session.add(item)
|
||||
for index in range(3):
|
||||
session.add(
|
||||
Comment(
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
content_item_id=item.id,
|
||||
platform="xiaohongshu",
|
||||
source_comment_id=f"c{index}",
|
||||
content=f"评论 {index}",
|
||||
raw_data="{}",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/tasks/task-stale")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "阶段 AI 分析中" in response.text
|
||||
assert "运行时长" in response.text
|
||||
assert "2小时5分钟" in response.text
|
||||
assert "最近进度" in response.text
|
||||
assert "12分钟前" in response.text
|
||||
assert "已获取热点 1 / 目标 5" in response.text
|
||||
assert "评论 3" in response.text
|
||||
assert "超过 10 分钟没有进度更新" in response.text
|
||||
assert "可能仍在等待外部接口或 AI 响应" in response.text
|
||||
|
||||
|
||||
def test_failed_task_detail_page_shows_failure_reason_without_loading_copy():
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id="task-failed",
|
||||
platform="douyin",
|
||||
status="failed",
|
||||
error_stage="recover",
|
||||
error_type="interrupted",
|
||||
error_message="系统重启,任务被中断",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/tasks/task-failed")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "失败" in response.text
|
||||
assert "系统重启,任务被中断" in response.text
|
||||
assert "recover / interrupted" not in response.text
|
||||
assert "正在抓取热点数据" not in response.text
|
||||
assert "pollTaskDetailStatus" not in response.text
|
||||
|
||||
|
||||
def seed_result_data(engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
task = Task(
|
||||
id="task-result",
|
||||
platform="douyin",
|
||||
status="success",
|
||||
total_items_count=1,
|
||||
successful_items_count=1,
|
||||
analysis_success_rate=1.0,
|
||||
)
|
||||
session.add(task)
|
||||
hotspot = Hotspot(id="hot-result", task_id=task.id, platform="douyin", title="热点标题", rank=1, raw_data="{}")
|
||||
session.add(hotspot)
|
||||
item = ContentItem(
|
||||
id="item-result",
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
platform="douyin",
|
||||
source_item_id="v1",
|
||||
item_type="video",
|
||||
title="视频标题",
|
||||
status="success",
|
||||
raw_data="{}",
|
||||
)
|
||||
session.add(item)
|
||||
session.add(
|
||||
Comment(
|
||||
id="comment-result",
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
content_item_id=item.id,
|
||||
platform="douyin",
|
||||
source_comment_id="c1",
|
||||
content="评论内容",
|
||||
sentiment="positive",
|
||||
labels='["认可"]',
|
||||
like_count=3,
|
||||
raw_data="{}",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Report(
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
report_type="hotspot",
|
||||
title="热点标题",
|
||||
metrics_json='{"sample_count":1,"item_count":1,"sentiment":{"positive":{"count":1,"pct":100},"neutral":{"count":0,"pct":0},"negative":{"count":0,"pct":0},"unknown":{"count":0,"pct":0}},"top_labels":[{"name":"认可","count":1}]}',
|
||||
typical_comments_json='{"positive":[{"content":"评论内容","like_count":3}],"neutral":[],"negative":[]}',
|
||||
summary="热点总结",
|
||||
markdown_content="# 热点标题\n\n热点总结",
|
||||
data="{}",
|
||||
markdown="# 热点标题\n\n热点总结",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Report(
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
content_item_id=item.id,
|
||||
report_type="item",
|
||||
title="视频标题",
|
||||
metrics_json='{"sample_count":1,"sentiment":{"positive":{"count":1,"pct":100},"neutral":{"count":0,"pct":0},"negative":{"count":0,"pct":0},"unknown":{"count":0,"pct":0}},"top_labels":[{"name":"认可","count":1}]}',
|
||||
typical_comments_json='{"positive":[{"content":"评论内容","like_count":3}],"neutral":[],"negative":[]}',
|
||||
summary="内容总结",
|
||||
markdown_content="# 视频标题\n\n内容总结",
|
||||
data="{}",
|
||||
markdown="# 视频标题\n\n内容总结",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_result_pages_render_seeded_data():
|
||||
with make_test_client() as (client, engine):
|
||||
seed_result_data(engine)
|
||||
|
||||
task_response = client.get("/tasks/task-result")
|
||||
hotspot_response = client.get("/hotspots/hot-result/report")
|
||||
item_response = client.get("/items/item-result")
|
||||
|
||||
assert task_response.status_code == 200
|
||||
assert "热点标题" in task_response.text
|
||||
assert "热点 1:热点标题" in BeautifulSoup(task_response.text, "html.parser").get_text(" ")
|
||||
assert 'onclick="window.location.reload()"' not in task_response.text
|
||||
assert "手动刷新" not in task_response.text
|
||||
assert hotspot_response.status_code == 200
|
||||
assert "热点总结" in hotspot_response.text
|
||||
assert "downloadExport(" not in hotspot_response.text
|
||||
assert "/api/export/" not in hotspot_response.text
|
||||
assert "导出 Markdown" not in hotspot_response.text
|
||||
assert "导出热点评论 CSV" not in hotspot_response.text
|
||||
assert "评论样本" in hotspot_response.text
|
||||
assert "关联内容" in hotspot_response.text
|
||||
assert "正向" in hotspot_response.text
|
||||
assert "1 条(100%)" in hotspot_response.text
|
||||
assert "认可 (1)" in hotspot_response.text
|
||||
assert "典型评论" in hotspot_response.text
|
||||
assert "评论内容" in hotspot_response.text
|
||||
assert "<pre" not in hotspot_response.text
|
||||
assert item_response.status_code == 200
|
||||
assert "内容总结" in item_response.text
|
||||
item_visible_text = BeautifulSoup(item_response.text, "html.parser").get_text(" ")
|
||||
assert "热点 1:热点标题" in item_visible_text
|
||||
assert "#" not in item_visible_text
|
||||
assert "downloadExport(" not in item_response.text
|
||||
assert "/api/export/" not in item_response.text
|
||||
assert "导出 Markdown" not in item_response.text
|
||||
assert "导出评论 CSV" not in item_response.text
|
||||
assert "评论样本" in item_response.text
|
||||
assert "Top 标签" in item_response.text
|
||||
assert "评论内容" in item_response.text
|
||||
|
||||
|
||||
def test_report_pages_render_empty_state_when_report_missing():
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
task = Task(id="task-no-report", platform="xiaohongshu", status="running")
|
||||
session.add(task)
|
||||
hotspot = Hotspot(id="hot-no-report", task_id=task.id, platform="xiaohongshu", title="缺报告热点", rank=1, raw_data="{}")
|
||||
session.add(hotspot)
|
||||
session.add(
|
||||
ContentItem(
|
||||
id="item-no-report",
|
||||
task_id=task.id,
|
||||
hotspot_id=hotspot.id,
|
||||
platform="xiaohongshu",
|
||||
source_item_id="n1",
|
||||
item_type="note",
|
||||
title="缺报告内容",
|
||||
status="pending",
|
||||
raw_data="{}",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
hotspot_response = client.get("/hotspots/hot-no-report/report")
|
||||
item_response = client.get("/items/item-no-report")
|
||||
|
||||
assert hotspot_response.status_code == 200
|
||||
assert "报告生成中,请稍候" in hotspot_response.text
|
||||
assert 'title="报告尚未生成"' not in hotspot_response.text
|
||||
assert "Markdown 报告将在分析完成后开放下载" not in hotspot_response.text
|
||||
assert "downloadExport(" not in hotspot_response.text
|
||||
assert "/api/export/" not in hotspot_response.text
|
||||
assert item_response.status_code == 200
|
||||
assert "报告生成中,请稍候" in item_response.text
|
||||
assert 'title="报告尚未生成"' not in item_response.text
|
||||
assert "Markdown 报告将在分析完成后开放下载" not in item_response.text
|
||||
assert "downloadExport(" not in item_response.text
|
||||
assert "/api/export/" not in item_response.text
|
||||
|
||||
|
||||
def test_report_pages_warn_when_ai_sample_is_insufficient():
|
||||
with make_test_client() as (client, engine):
|
||||
seed_result_data(engine)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
task = session.get(Task, "task-result")
|
||||
task.analysis_status = "insufficient"
|
||||
task.analysis_success_rate = 0.5
|
||||
session.commit()
|
||||
|
||||
hotspot_response = client.get("/hotspots/hot-result/report")
|
||||
item_response = client.get("/items/item-result")
|
||||
|
||||
assert hotspot_response.status_code == 200
|
||||
assert "当前有效评论样本不足" in hotspot_response.text
|
||||
assert item_response.status_code == 200
|
||||
assert "当前有效评论样本不足" in item_response.text
|
||||
|
||||
|
||||
def test_export_routes_are_removed():
|
||||
with make_test_client() as (client, engine):
|
||||
seed_result_data(engine)
|
||||
|
||||
csv_response = client.get("/api/export/items/item-result/comments.csv")
|
||||
hotspot_csv_response = client.get("/api/export/hotspots/hot-result/comments.csv")
|
||||
hotspot_md = client.get("/api/export/hotspots/hot-result.md")
|
||||
item_md = client.get("/api/export/items/item-result.md")
|
||||
|
||||
assert csv_response.status_code == 404
|
||||
assert hotspot_csv_response.status_code == 404
|
||||
assert hotspot_md.status_code == 404
|
||||
assert item_md.status_code == 404
|
||||
|
||||
|
||||
def test_api_tasks_returns_service_unavailable_when_database_has_io_error(monkeypatch):
|
||||
def broken_list_tasks(_session):
|
||||
raise OperationalError("SELECT 1", {}, Exception("disk I/O error"))
|
||||
|
||||
monkeypatch.setattr("app.main.list_tasks", broken_list_tasks)
|
||||
|
||||
with make_test_client() as (client, _engine):
|
||||
response = client.get("/api/tasks")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "数据库暂时不可用,请稍后重试或联系维护者恢复数据。"
|
||||
|
||||
|
||||
def test_index_page_shows_database_error_state_when_database_has_io_error(monkeypatch):
|
||||
def broken_list_tasks(_session):
|
||||
raise OperationalError("SELECT 1", {}, Exception("disk I/O error"))
|
||||
|
||||
monkeypatch.setattr("app.main.list_tasks", broken_list_tasks)
|
||||
|
||||
with make_test_client() as (client, _engine):
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "数据库暂时不可用" in response.text
|
||||
assert "请先保留 data 目录" in response.text
|
||||
|
||||
|
||||
def test_task_detail_page_shows_database_error_state_when_database_has_io_error(monkeypatch):
|
||||
def broken_get_task(_session, _task_id):
|
||||
raise OperationalError("SELECT 1", {}, Exception("disk I/O error"))
|
||||
|
||||
monkeypatch.setattr("app.main.get_task", broken_get_task)
|
||||
|
||||
with make_test_client() as (client, _engine):
|
||||
response = client.get("/tasks/task-io-error")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "数据库暂时不可用" in response.text
|
||||
assert "请先保留 data 目录" in response.text
|
||||
|
||||
|
||||
def test_hotspot_report_page_shows_database_error_state_when_database_has_io_error(monkeypatch):
|
||||
def broken_get_task(_session, _task_id):
|
||||
raise OperationalError("SELECT 1", {}, Exception("disk I/O error"))
|
||||
|
||||
monkeypatch.setattr("app.main.get_task", broken_get_task)
|
||||
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(Task(id="task-hot-io", platform="xiaohongshu", status="success"))
|
||||
session.add(Hotspot(id="hot-io", task_id="task-hot-io", platform="xiaohongshu", title="热点", rank=1, raw_data="{}"))
|
||||
session.commit()
|
||||
|
||||
response = client.get("/hotspots/hot-io/report")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "数据库暂时不可用" in response.text
|
||||
|
||||
|
||||
def test_item_detail_page_shows_database_error_state_when_database_has_io_error(monkeypatch):
|
||||
def broken_get_task(_session, _task_id):
|
||||
raise OperationalError("SELECT 1", {}, Exception("disk I/O error"))
|
||||
|
||||
monkeypatch.setattr("app.main.get_task", broken_get_task)
|
||||
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(Task(id="task-item-io", platform="xiaohongshu", status="success"))
|
||||
session.add(Hotspot(id="hot-item-io", task_id="task-item-io", platform="xiaohongshu", title="热点", rank=1, raw_data="{}"))
|
||||
session.add(
|
||||
ContentItem(
|
||||
id="item-io",
|
||||
task_id="task-item-io",
|
||||
hotspot_id="hot-item-io",
|
||||
platform="xiaohongshu",
|
||||
source_item_id="item-io-source",
|
||||
item_type="note",
|
||||
status="success",
|
||||
raw_data="{}",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/items/item-io")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "数据库暂时不可用" in response.text
|
||||
|
||||
|
||||
|
||||
def test_missing_html_pages_render_friendly_not_found_page():
|
||||
with make_test_client() as (client, _engine):
|
||||
task_response = client.get("/tasks/missing-task")
|
||||
hotspot_response = client.get("/hotspots/missing-hotspot/report")
|
||||
item_response = client.get("/items/missing-item")
|
||||
|
||||
assert task_response.status_code == 404
|
||||
assert "任务不存在" in task_response.text
|
||||
assert "Internal Server Error" not in task_response.text
|
||||
assert hotspot_response.status_code == 404
|
||||
assert "热点不存在" in hotspot_response.text
|
||||
assert "Internal Server Error" not in hotspot_response.text
|
||||
assert item_response.status_code == 404
|
||||
assert "内容条目不存在" in item_response.text
|
||||
assert "Internal Server Error" not in item_response.text
|
||||
|
||||
|
||||
def test_task_api_includes_progress_counts_stage_and_demo_flag():
|
||||
with make_test_client() as (client, engine):
|
||||
seed_result_data(engine)
|
||||
|
||||
response = client.get("/api/tasks/task-result")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["current_stage"] in (None, "success")
|
||||
assert data["current_stage_label"] == "已完成"
|
||||
assert data["comments_count"] == 1
|
||||
assert data["reports_count"] == 2
|
||||
assert data["is_demo"] is False
|
||||
assert data["last_progress_at"] is not None
|
||||
|
||||
|
||||
def test_task_detail_page_explains_target_and_actual_counts():
|
||||
with make_test_client() as (client, engine):
|
||||
seed_result_data(engine)
|
||||
|
||||
response = client.get("/tasks/task-result")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "目标上限" in response.text
|
||||
assert "实际结果" in response.text
|
||||
assert "实际评论 1" in response.text
|
||||
assert "少于理论上限通常是内容本身评论不足或平台返回不足" in response.text
|
||||
|
||||
|
||||
def test_task_api_includes_runtime_and_stale_progress_fields(monkeypatch):
|
||||
now = datetime(2026, 7, 3, 10, 30, tzinfo=UTC)
|
||||
monkeypatch.setattr("app.services.task_service.utc_now", lambda: now)
|
||||
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id="task-api-stale",
|
||||
platform="douyin",
|
||||
status="running",
|
||||
current_stage="crawl_comments",
|
||||
created_at=now - timedelta(minutes=45),
|
||||
last_progress_at=now - timedelta(minutes=11),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/api/tasks/task-api-stale")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["current_stage_label"] == "抓取评论中"
|
||||
assert data["running_seconds"] == 2700
|
||||
assert data["seconds_since_last_progress"] == 660
|
||||
assert data["running_duration_label"] == "45分钟"
|
||||
assert data["last_progress_ago_label"] == "11分钟前"
|
||||
assert data["is_progress_stale"] is True
|
||||
assert data["stale_threshold_minutes"] == 10
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests."""
|
||||
@@ -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"
|
||||
@@ -0,0 +1,67 @@
|
||||
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_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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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") == "未知"
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user