Skill: tandongtaotao/tiktok-hotspot-monitor (version 2.0.0)

--- name: tiktok-hotspot-monitor description: > TikTok US women's fashion hotspot monitor. Crawls video metadata via Apify (primary) or Playwright (backup), analyzes trends with heat/coverage scoring, generates static HTML reports. Supports cost-aware 5-window crawl strategy. type: skill version: 2.0.0 author: Claude repository: https://github.com/TanDongTaotao/tiktok-hotspot-monitor trigger: - "Crawl TikTok hotspot data for fashion trend analysis" - "Analyze TikTok video metadata and generate trend report" - "Monitor TikTok keywords/hashtags for emerging signals" - "User mentions: tiktok crawler / scraper / trend monitor" --- # TikTok Hotspot Monitor — Agent Skill ## 1. Task Boundary (Scope) ### Responsible For - Crawling TikTok video public metadata (keyword/hashtag/creator/music sources) via Apify cloud Actor (`clockworks/tiktok-scraper`) - Fallback crawling via Playwright browser automation with saved session - Offline deduplication, heat scoring, and trend analysis - Term extraction: content keywords and TikTok hashtags, with multi-bucket aging - Long-term term status based on current-snapshot age distribution, not only previous-snapshot overlap - Coverage scoring to surface "broadly appearing" signals vs "single viral" signals - Static HTML report generation with dark theme ### NOT Responsible For - Downloading video/audio files - Real-time streaming or WebSocket data - TikTok login or session management (must be pre-configured) - Sentiment analysis of comments - Cross-platform trend comparison - Automated social media posting - User authentication or authorization - Data persistence beyond local JSONL/JSON files ### Agent Addition Scope The agent MAY add new keyword/hashtag sources to the config. The agent MUST NOT modify crawl window weights or add new window types without user approval, as those affect Apify billing. --- ## 2. Input Schema ### 2.1 Main Config (`config/tiktok_hotspot_sources.json`) ```typescript interface CrawlerConfig { market: string; // default: "US" output: { base_dir: string; // default: "data/tiktok_hotspots" snapshots_dir: string; // default: "snapshots" logs_dir: string; // default: "logs" }; provider: { type: "apify" | "tiktok_mcp"; // default: "apify" actor_id?: string; // required if type=apify }; defaults: { limit: number; // default: 10, per-source limit }; sources: Array<{ type: "keyword" | "hashtag" | "creator" | "music"; value: string; limit?: number; // override defaults.limit enabled?: boolean; // default: true }>; apify?: { token_env?: string; // default: "APIFY_TOKEN" actor_id?: string; input: { defaults: Record<string, any>; per_source?: Record<string, any>; crawl_windows?: Record<string, CrawlWindow[]>; }; }; tiktok_mcp?: { command?: string; args?: string[]; timeout_seconds?: number; reject_simulated?: boolean; }; } interface CrawlWindow { name: string; label: string; weight: number; // allocation weight input: Record<string, any>; // searchSorting, searchDatePosted, etc. } ``` ### 2.2 CLI Arguments | Argument | Type | Default | Description | |----------|------|---------|-------------| | `--config` | Path | `config/tiktok_hotspot_sources.json` | Config file | | `--once` | Flag | - | Run single crawl | | `--schedule` | Flag | - | Run continuously | | `--max-sources` | int | None | Limit enabled sources | | `--snapshot` | Path | latest | JSONL snapshot for analysis | | `--previous-snapshot` | Path | auto | Previous snapshot for comparison | | `--top` | int | 10 | Items per ranked section | | `--report` | Path | latest | Analysis JSON for rendering | ### 2.3 Environment Variables | Variable | Required | Description | |----------|----------|-------------| | `APIFY_TOKEN` | For Apify mode | Apify API token | | `TIKTOK_PROXY` | For Playwright mode | Proxy URL | --- ## 3. Output Schema ### 3.1 Crawl Snapshot (JSONL, one record per line) ```typescript interface CrawlRecord { crawl_timestamp: string; // UTC ISO source_type: "keyword" | "hashtag" | "creator" | "music"; source_value: string; crawl_window: string; crawl_window_label: string; crawl_window_limit: number; video_id: string | null; webpage_url: string | null; title: string | null; description: string | null; uploader: string | null; uploader_id: string | null; view_count: number | null; like_count: number | null; comment_count: number | null; share_count: number | null; collect_count: number | null; hashtags: string[] | null; music: { id: string | null; track: string | null; artist: string | null; }; upload_date: string | null; // ISO date duration: number | null; is_ad: boolean | null; } ``` ### 3.2 Crawl Log (JSONL) ```typescript interface LogEntry { crawl_timestamp: string; source_type: string; source_value: string; crawl_window: string; crawl_window_limit: number; status: "success" | "failed"; record_count: number; error: string | null; } ``` Last entry is a `CrawlRoundSummary`: ```typescript interface CrawlRoundSummary { event: "crawl_round_summary"; crawl_timestamp: string; provider: string; enabled_source_count: number; crawl_window_count: number; planned_run_count: number; requested_total_limit: number; completed_run_count: number; failed_run_count: number; raw_record_count: number; unique_video_count: number; duplicate_rate: number; // 0.0 - 1.0 effective_unique_yield: number; // unique / requested windows: Record<string, WindowMetrics>; cost_model_note: string; } ``` ### 3.3 Analysis Report (JSON) ```typescript interface AnalysisReport { generated_at: string; snapshot_path: string; previous_snapshot_path: string | null; analysis_window: { current_snapshot_time: string; previous_snapshot_time: string | null; interval_hours: number | null; matched_previous_video_count: number; }; record_count: number; unique_video_count: number; source_counts: Record<string, number>; top_videos: VideoItem[]; top_rising_videos: VideoItem[]; recent_videos_by_age: AgeBucket<VideoItem>[]; recent_signals_by_age: SignalBucket[]; established_terms: TermItem[]; established_hashtags: TermItem[]; top_music: RankedItem[]; top_creators: RankedItem[]; crawl_metrics: CrawlRoundSummary | null; } ``` ### 3.4 HTML Report Self-contained static HTML file at `data/tiktok_hotspot_analysis/tiktok_hotspot_report_<timestamp>.html`. No external dependencies. Dark themed. Machine-readable data embedded as JSON in comments. --- ## 4. Tools ### 4.1 `crawl_tiktok_hotspots.py` — Metadata Crawler **When to call:** - User requests data collection - Need fresh snapshot for analysis - Smoke test / validation run **When NOT to call:** - User wants to view existing data only (use analyze instead) - No config changes made when config is invalid - Apify mode: APIFY_TOKEN not set (check env first) - MCP mode: session file missing (run `tiktok_login_save_session.py` first) **Provider switching:** Edit `config/tiktok_hotspot_sources.json` to switch between providers: ```json // Apify mode (default, full features) { "provider": { "type": "apify", "actor_id": "clockworks/tiktok-scraper" } } // Local MCP mode (limited, testing only) { "provider": { "type": "tiktok_mcp" } } ``` MCP mode requires: 1. `pip install playwright && playwright install chromium` 2. `python scripts/tiktok_login_save_session.py` (manual TikTok login) 3. Config `tiktok_mcp.args` pointing to `scripts/tiktok_search_mcp_adapter.py` **Implementation:** ```python # Provider dispatch if config.provider_type == "apify": # Requires APIFY_TOKEN in env # Each source × window → one Actor run # Supports all 4 source types elif config.provider_type == "tiktok_mcp": # Requires saved session file # Keyword/hashtag only, ~12 items per source ``` **Error states:** | Error | Recovery | |-------|----------| | Apify token missing | Check env, prompt user to set APIFY_TOKEN | | Actor run timeout | Retry with same config | | No videos found | Log as failed window, continue | | MCP session expired | Prompt re-login via tiktok_login_save_session.py | | Proxy unreachable | Skip proxy or switch to Apify | | Snapshot empty | Check sources config, ensure keywords are valid | **Retry policy:** - Network errors: retry up to 2 times with 5s backoff - Actor failures: no retry (Apify handles internally), log and continue - MCP browser crash: retry once ### 4.2 `analyze_tiktok_hotspots.py` — Offline Analyzer **When to call:** - After crawl completes - User has existing snapshot to analyze - Need updated report **Implementation steps:** 1. Load snapshot JSONL → validate each record has `video_id` 2. Deduplicate by `video_id` (keep highest heat score) 3. Compute per-video heat score 4. Bucket videos by upload age (1d/3d/7d/14d) 5. Extract content terms and hashtags 6. Compute cross-bucket novelty (new vs existing terms) 7. Compute coverage scores 8. Compare with previous snapshot for growth metrics 9. Output structured JSON ### 4.2.1 Long-term Term Status Long-term content terms and hashtags are **not** dropped when they are missing from the previous snapshot. A term enters the long-term section when its oldest matched video is older than 30 days. Its status is then computed from the current snapshot's video-age distribution: | Status | Condition | Meaning | |--------|-----------|---------| | `spreading` | newest video <= 7 days AND recent_7d_count / video_count >= 10% | Still actively spreading | | `mature_or_flat` | newest video <= 30 days but 7d ratio is too low | Existing signal, activity weakening | | `cooling` | newest video > 30 days | No recent new videos; cooling down | This avoids losing a long-term term simply because the previous crawl did not hit it, while also preventing one recent video among many old videos from falsely marking a term as spreading. ### 4.3 — HTML Report Generator **When to call:** - After analysis completes - User requests visual output **Output:** Valid HTML5, self-contained, no external CSS/JS. ### 4.4 `tiktok_login_save_session.py` — Session Setup (optional) **When to call:** - User wants to use local Playwright mode - Session file missing or expired --- ## 5. State Machine ``` IDLE │ ▼ CONFIG_LOAD ──invalid──▶ ERROR (report config issue) │ ▼ CRAWL_PLAN ├─ Build requests: enabled_sources × crawl_windows ├─ Compute: planned_run_count, requested_total_limit └─ Validate: at least 1 enabled source │ ▼ CRAWL_EXECUTE ──fail──▶ PARTIAL_COMPLETE (log failures, continue) │ │ ▼ ▼ SNAPSHOT_WRITTEN PARTIAL_SNAPSHOT │ │ └───────both────────────▶ │ ▼ ANALYZE ──empty_snapshot──▶ ERROR (no records to analyze) │ ▼ REPORT_GENERATE ──fail──▶ ERROR (corrupted analysis JSON) │ ▼ COMPLETE ``` State management is handled by the Python scripts via: - Exit codes: 0 (success), 1 (partial failure), 2 (config/input error) - Logs: per-run JSONL entries with status - Summary: `CrawlRoundSummary` as last log entry --- ## 6. Error Recovery ### 6.1 Crawl Phase | Failure Mode | Detection | Recovery | |-------------|-----------|----------| | Invalid config | `load_config()` raises `ValueError` | Report exact field, suggest fix | | No enabled sources | Config load check | Add at least one source | | Apify token missing | `os.environ.get()` returns empty | Message: "Set APIFY_TOKEN in .env" | | All sources fail | All log entries show `failed` | Check token, network, actor_id | | Some sources fail | Log shows mixed success/fail | Continue, report failed count | | Snapshot empty | 0 records written | Check source keywords/limits | | Disk full | `write()` raises `OSError` | Free disk space, retry | | MCP browser timeout | `asyncio.wait_for` raises | Fallback to fewer sources | | MCP session expired | Actor raises RuntimeError | Run `tiktok_login_save_session.py` | ### 6.2 Analyze Phase | Failure Mode | Detection | Recovery | |-------------|-----------|----------| | Snapshot missing | `FileNotFoundError` | Run crawl first | | Corrupted JSONL | `json.JSONDecodeError` | Check snapshot, re-crawl | | No video records | All lines lack `video_id` | Report empty snapshot | | Previous snapshot missing | `valid_snapshots()` empty | Run without comparison | | Division by zero | `video_count = 0` | Guard with `max(vc, 1)` | ### 6.3 Report Phase | Failure Mode | Detection | Recovery | |-------------|-----------|----------| | Analysis JSON missing | `FileNotFoundError` | Run analyze first | | Corrupted JSON | `json.JSONDecodeError` | Re-run analyze | | KeyError in template | `report.get(key)` missing | Graceful fallback to empty | | Encoding error | `UnicodeEncodeError` | Force UTF-8 output | --- ## 7. Planning Logic ### 7.1 Task Decomposition For a typical hotspot monitoring request, decompose as: ``` Step 1: Check existing data ├─ Is there a recent snapshot? (< 24h old) │ └─ Yes → skip crawl, go to Step 3 │ └─ No → continue to Step 2 │ Step 2: Crawl ├─ Validate APIFY_TOKEN exists ├─ Load config ├─ Run crawl (with timeout guard) └─ Verify snapshot has records │ Step 3: Analyze ├─ Auto-select latest snapshot ├─ Auto-select previous snapshot (if exists) ├─ Run analysis └─ Verify output JSON has all required fields │ Step 4: Generate report ├─ Render HTML from analysis JSON └─ Verify output is valid HTML ``` ### 7.2 Decision Tree ``` User: "check TikTok trends for summer dresses" Check: Does latest snapshot exist and have records? ├─ YES: Is it < 24h old? │ ├─ YES: Skip crawl, go to analyze │ └─ NO: Is user OK waiting 5-30 min for crawl? │ ├─ YES: Run crawl, then analyze │ └─ NO: Use existing snapshot, warn about staleness └─ NO: Must crawl first ├─ Is APIFY_TOKEN configured? │ ├─ YES: Use Apify provider │ └─ NO: Check MCP session │ ├─ EXISTS: Use MCP provider (limited data) │ └─ MISSING: Ask user to configure one └─ Run crawl ``` --- ## 8. Guardrails ### 8.1 Cost Limits | Guardrail | Value | Enforcement | |-----------|-------|-------------| | Max sources per crawl | 50 | Config validation | | Max limit per source | 500 | Config validation (`positive_int`) | | Max requested total | 5000 | Config validation (project-level) | | Max planned runs | 250 | 50 sources × 5 windows | | Apify mode | Required for > 200 records | MCP limited to ~12/source | | Report HTML size | < 5MB | Self-limiting (trim if exceeded) | ### 8.2 Time Limits | Operation | Timeout | Enforcement | |-----------|---------|-------------| | Single crawl run | 60 min | Bash timeout parameter | | Per-Apify Actor | No limit | Apify handles internally | | Per-MCP search | 120s | `tiktok_mcp.timeout_seconds` | | Analysis | 30s | Python processing (fast) | | Report render | 10s | Python processing (fast) | ### 8.3 Rate Limits - No concurrent Apify runs (sequentially dispatched) - MCP browser: one at a time (sequential per source) - Web fetching: 60s minimum between full re-crawls ### 8.4 Token / Credit Safety - Never commit `.env` to git - Never print API tokens in logs or console - `APIFY_TOKEN` read from environment only - MCP session file is local only --- ## 9. Evaluation Criteria ### 9.1 Crawl Success | Criterion | Passing | Warning | Failing | |-----------|---------|---------|---------| | Run completion | ≥ 90% runs succeed | 70-90% | < 70% | | Record count | ≥ 80% requested | 50-80% | < 50% | | Duplicate rate | < 25% | 25-40% | > 40% | | Failed windows | 0 | 1-3 | > 3 | | Unique videos | ≥ 50 | 20-50 | < 20 | ### 9.2 Analysis Success | Criterion | Passing | Failing | |-----------|---------|---------| | Snapshot has records | ≥ 10 unique videos | < 10 | | Dedup processed | All records checked | Missing video_id | | Term extraction | ≥ 1 content term found | 0 terms | | JSON output | All required fields present | Missing required fields | | Processing time | < 30s | > 60s | ### 9.3 Report Success | Criterion | Passing | Failing | |-----------|---------|---------| | Valid HTML | Closes `</html>` tag | Missing closing tag | | Metrics visible | ≥ 4 grid metrics shown | Empty grid | | Videos rendered | Top list non-empty | Empty list | | All sections present | 6+ sections | < 4 sections | ### 9.4 Decision: Proceed to Next Stage After a validation crawl (target ~500 records): ``` unique_yield = unique_videos / requested_total_limit if unique_yield >= 0.6 and duplicate_rate < 0.25: ✅ Proceed to pilot (2000 target) elif unique_yield >= 0.4: ⚠️ Proceed with caution, review source quality else: ❌ Block scaling, fix sources/windows first ``` --- ## 10. Composability ### 10.1 Output Consumption Other skills/agents consume analysis JSON via standard path: ```python # Example: Another agent reads analysis for downstream processing import json report = json.load(open("data/tiktok_hotspot_analysis/latest_analysis.json")) top_signals = [t["name"] for t in report.get("top_videos", [])[:5]] hot_terms = [t["name"] for t in report.get("established_terms", [])[:10]] ``` ### 10.2 Pipeline Integration ``` Data Source Agent └─► TikTok Hotspot Monitor Skill ├─► crawl → snapshot.jsonl │ └─► [External] Apify usage dashboard (cost tracking) ├─► analyze → analysis.json │ └─► [Downstream] Trend prediction / alerting └─► render → report.html └─► [Downstream] Static hosting / dashboard ``` ### 10.3 File-Based Contract All inter-skill communication is file-based: | Artifact | Format | Schema | Consumer | |----------|--------|--------|----------| | Snapshot | JSONL | CrawlRecord | Analysis, ML pipeline | | Analysis | JSON | AnalysisReport | Report, dashboards | | Log | JSONL | LogEntry / Summary | Monitoring, cost tracking | | Report | HTML | Self-contained | Human viewing | ### 10.4 Exit Codes ```python # Standard exit codes for script chaining 0: Success (all operations completed) 1: Partial success (some failures, usable results) 2: Configuration error (fix config before retry) ``` --- ## Appendix: Quick Reference ```bash # Full pipeline (one command each) python scripts/crawl_tiktok_hotspots.py --config config/tiktok_hotspot_sources.json --once python scripts/analyze_tiktok_hotspots.py python scripts/render_tiktok_hotspot_report.py # Smoke test (2 sources) python scripts/crawl_tiktok_hotspots.py --once --max-sources 2 # Validation run (500 records) python scripts/crawl_tiktok_hotspots.py --config config/_tiktok_hotspot_apify_500_config.json --once ``` **Apify Cost Note:** Verify actual charges at console.apify.com → Usage. Cost depends on Actor pricing, run count, compute duration, memory, proxy usage, retries, add-ons, and account plan — not only requested result count.

path: config/tiktok_hotspot_sources.json
content: {
  "market": "US",
  "output": {
    "base_dir": "data/tiktok_hotspots",
    "snapshots_dir": "snapshots",
    "logs_dir": "logs"
  },
  "schedule": {
    "enabled": false,
    "interval_minutes": 60
  },
  "provider": {
    "type": "apify",
    "actor_id": "clockworks/tiktok-scraper"
  },
  "tiktok_mcp": {
    "command": "python",
    "args": [
      "scripts/tiktok_search_mcp_adapter.py"
    ],
    "cwd": null,
    "timeout_seconds": 120,
    "reject_simulated": true,
    "env": {}
  },
  "apify": {
    "actor_id": "clockworks/tiktok-scraper",
    "token_env": "APIFY_TOKEN",
    "input": {
      "defaults": {
        "shouldDownloadVideos": false
      },
      "per_source": {
        "keyword": {
          "searchQueries": [
            "{source_value}"
          ],
          "searchSection": "/video",
          "searchSorting": "3",
          "searchDatePosted": "3",
          "resultsPerPage": "{limit}"
        },
        "hashtag": {
          "searchQueries": [
            "#{source_value_without_prefix}"
          ],
          "searchSection": "/video",
          "searchSorting": "3",
          "searchDatePosted": "3",
          "resultsPerPage": "{limit}"
        },
        "creator": {
          "profiles": [
            "{source_value_without_prefix}"
          ],
          "profileSorting": "latest",
          "oldestPostDateUnified": "30 days",
          "resultsPerPage": "{limit}"
        },
        "music": {
          "postURLs": [
            "{source_value}"
          ],
          "resultsPerPage": "{limit}"
        }
      },
      "crawl_windows": {
        "keyword": [
          {
            "name": "recent_1d_liked",
            "label": "近24小时高赞",
            "weight": 4,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "1"
            }
          },
          {
            "name": "recent_7d_liked",
            "label": "近7天高赞",
            "weight": 3,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "2"
            }
          },
          {
            "name": "recent_30d_liked",
            "label": "近30天高赞",
            "weight": 2,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "3"
            }
          },
          {
            "name": "evergreen_liked",
            "label": "长期高赞",
            "weight": 1,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "0"
            }
          },
          {
            "name": "recent_1d_latest",
            "label": "近24小时最新采样",
            "weight": 1,
            "input": {
              "searchSection": "/video",
              "searchSorting": "3",
              "searchDatePosted": "1"
            }
          }
        ],
        "hashtag": [
          {
            "name": "recent_1d_liked",
            "label": "近24小时高赞",
            "weight": 4,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "1"
            }
          },
          {
            "name": "recent_7d_liked",
            "label": "近7天高赞",
            "weight": 3,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "2"
            }
          },
          {
            "name": "recent_30d_liked",
            "label": "近30天高赞",
            "weight": 2,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "3"
            }
          },
          {
            "name": "evergreen_liked",
            "label": "长期高赞",
            "weight": 1,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "0"
            }
          },
          {
            "name": "recent_1d_latest",
            "label": "近24小时最新采样",
            "weight": 1,
            "input": {
              "searchSection": "/video",
              "searchSorting": "3",
              "searchDatePosted": "1"
            }
          }
        ]
      }
    }
  },
  "defaults": {
    "limit": 10
  },
  "sources": [
    {
      "type": "keyword",
      "value": "wedding guest dress",
      "limit": 10
    },
    {
      "type": "keyword",
      "value": "summer dresses",
      "limit": 10
    },
    {
      "type": "keyword",
      "value": "vacation outfits",
      "limit": 10
    },
    {
      "type": "keyword",
      "value": "brunch outfit",
      "limit": 10
    },
    {
      "type": "keyword",
      "value": "date night outfit",
      "limit": 10
    },
    {
      "type": "keyword",
      "value": "work outfits women",
      "limit": 10
    },
    {
      "type": "keyword",
      "value": "plus size fashion",
      "limit": 10
    },
    {
      "type": "keyword",
      "value": "amazon fashion finds",
      "limit": 10
    },
    {
      "type": "keyword",
      "value": "target finds fashion",
      "limit": 10
    },
    {
      "type": "keyword",
      "value": "try on haul women",
      "limit": 10
    },
    {
      "type": "creator",
      "value": "",
      "enabled": false,
      "limit": 10
    },
    {
      "type": "music",
      "value": "",
      "enabled": false,
      "limit": 10
    }
  ]
}


path: config/_tiktok_hotspot_apify_500_config.json
content: {
  "market": "US",
  "output": {
    "base_dir": "data/tiktok_hotspots_500",
    "snapshots_dir": "snapshots",
    "logs_dir": "logs"
  },
  "schedule": {
    "enabled": false,
    "interval_minutes": 60
  },
  "provider": {
    "type": "apify",
    "actor_id": "clockworks/tiktok-scraper"
  },
  "tiktok_mcp": {
    "command": "python",
    "args": [
      "-m",
      "tiktok_mcp_service.main"
    ],
    "cwd": null,
    "timeout_seconds": 120,
    "reject_simulated": true,
    "env": {}
  },
  "apify": {
    "actor_id": "clockworks/tiktok-scraper",
    "token_env": "APIFY_TOKEN",
    "input": {
      "defaults": {
        "shouldDownloadVideos": false
      },
      "per_source": {
        "keyword": {
          "searchQueries": [
            "{source_value}"
          ],
          "searchSection": "/video",
          "searchSorting": "3",
          "searchDatePosted": "3",
          "resultsPerPage": "{limit}"
        },
        "hashtag": {
          "searchQueries": [
            "#{source_value_without_prefix}"
          ],
          "searchSection": "/video",
          "searchSorting": "3",
          "searchDatePosted": "3",
          "resultsPerPage": "{limit}"
        },
        "creator": {
          "profiles": [
            "{source_value_without_prefix}"
          ],
          "profileSorting": "latest",
          "oldestPostDateUnified": "30 days",
          "resultsPerPage": "{limit}"
        },
        "music": {
          "postURLs": [
            "{source_value}"
          ],
          "resultsPerPage": "{limit}"
        }
      },
      "crawl_windows": {
        "keyword": [
          {
            "name": "recent_1d_liked",
            "label": "近24小时高赞",
            "weight": 4,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "1"
            }
          },
          {
            "name": "recent_7d_liked",
            "label": "近7天高赞",
            "weight": 3,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "2"
            }
          },
          {
            "name": "recent_30d_liked",
            "label": "近30天高赞",
            "weight": 2,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "3"
            }
          },
          {
            "name": "evergreen_liked",
            "label": "长期高赞",
            "weight": 1,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "0"
            }
          },
          {
            "name": "recent_1d_latest",
            "label": "近24小时最新采样",
            "weight": 1,
            "input": {
              "searchSection": "/video",
              "searchSorting": "3",
              "searchDatePosted": "1"
            }
          }
        ],
        "hashtag": [
          {
            "name": "recent_1d_liked",
            "label": "近24小时高赞",
            "weight": 4,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "1"
            }
          },
          {
            "name": "recent_7d_liked",
            "label": "近7天高赞",
            "weight": 3,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "2"
            }
          },
          {
            "name": "recent_30d_liked",
            "label": "近30天高赞",
            "weight": 2,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "3"
            }
          },
          {
            "name": "evergreen_liked",
            "label": "长期高赞",
            "weight": 1,
            "input": {
              "searchSection": "/video",
              "searchSorting": "1",
              "searchDatePosted": "0"
            }
          },
          {
            "name": "recent_1d_latest",
            "label": "近24小时最新采样",
            "weight": 1,
            "input": {
              "searchSection": "/video",
              "searchSorting": "3",
              "searchDatePosted": "1"
            }
          }
        ]
      }
    }
  },
  "defaults": {
    "limit": 50
  },
  "sources": [
    {
      "type": "keyword",
      "value": "wedding guest dress",
      "limit": 50
    },
    {
      "type": "keyword",
      "value": "summer dresses",
      "limit": 50
    },
    {
      "type": "keyword",
      "value": "vacation outfits",
      "limit": 50
    },
    {
      "type": "keyword",
      "value": "brunch outfit",
      "limit": 50
    },
    {
      "type": "keyword",
      "value": "date night outfit",
      "limit": 50
    },
    {
      "type": "keyword",
      "value": "work outfits women",
      "limit": 50
    },
    {
      "type": "keyword",
      "value": "plus size fashion",
      "limit": 50
    },
    {
      "type": "keyword",
      "value": "amazon fashion finds",
      "limit": 50
    },
    {
      "type": "keyword",
      "value": "target finds fashion",
      "limit": 50
    },
    {
      "type": "keyword",
      "value": "try on haul women",
      "limit": 50
    },
    {
      "type": "creator",
      "value": "",
      "enabled": false,
      "limit": 10
    },
    {
      "type": "music",
      "value": "",
      "enabled": false,
      "limit": 10
    }
  ]
}


path: examples/report.html
content: <!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>TikTok 热点分析报告</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Doto:wght@400..700&family=Space+Grotesk:wght@300;400;500;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
  <style>
    :root {
      --black:#000; --surface:#111; --surface-raised:#1A1A1A; --border:#222; --border-visible:#333;
      --text-disabled:#666; --text-secondary:#999; --text-primary:#E8E8E8; --text-display:#FFF;
      --accent:#D71921; --success:#4A9E5C; --warning:#D4A843;
      --space-sm:8px; --space-md:16px; --space-lg:24px; --space-xl:32px; --space-2xl:48px; --space-3xl:64px;
    }
    * { box-sizing:border-box; }
    body {
      margin:0; background:var(--black); color:var(--text-primary); font-family:'Space Grotesk', system-ui, sans-serif;
      background-image:radial-gradient(circle, var(--border) .5px, transparent .5px); background-size:12px 12px;
    }
    a { color:var(--text-display); text-decoration:none; }
    .page { max-width:1440px; margin:0 auto; padding:40px clamp(20px,4vw,56px) 72px; }
    header { display:grid; grid-template-columns:1.2fr .8fr; gap:var(--space-2xl); align-items:end; min-height:360px; }
    .eyebrow,.section-label,.metric-label,.source,.status,th,.stat-row span,.chip,.meta {
      font-family:'Space Mono', monospace; font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--text-secondary);
    }
    h1 { margin:12px 0 0; font-family:'Doto','Space Mono',monospace; font-size:clamp(54px,8vw,112px); line-height:.9; letter-spacing:-.04em; color:var(--text-display); font-weight:700; }
    .hero-subtitle { max-width:680px; margin-top:var(--space-lg); color:var(--text-secondary); font-size:18px; line-height:1.45; }
    .hero-metric { border-left:2px solid var(--accent); padding-left:var(--space-lg); }
    .hero-number { font-family:'Space Mono',monospace; font-size:clamp(44px,6vw,84px); line-height:1; color:var(--text-display); letter-spacing:-.05em; }
    .hero-label { margin-top:var(--space-sm); font-family:'Space Mono',monospace; font-size:12px; letter-spacing:.08em; color:var(--accent); text-transform:uppercase; }
    .grid-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--border); border:1px solid var(--border); margin:var(--space-3xl) 0; }
    .metric { background:var(--black); padding:var(--space-lg); min-height:132px; display:flex; flex-direction:column; justify-content:space-between; }
    .metric-value { font-family:'Space Mono',monospace; color:var(--text-display); font-size:32px; letter-spacing:-.04em; }
    .metric-value span { margin-left:4px; font-size:11px; color:var(--text-secondary); letter-spacing:.08em; }
    .section-head { display:flex; justify-content:space-between; gap:var(--space-lg); align-items:end; margin:var(--space-3xl) 0 var(--space-lg); }
    .section-head h2 { margin:0; color:var(--text-display); font-size:32px; line-height:1.1; letter-spacing:-.02em; }
    .section-head p { margin:0; max-width:460px; color:var(--text-secondary); line-height:1.5; }
    .video-stack { display:grid; gap:var(--space-md); }
    .video-card { display:grid; grid-template-columns:72px 1fr 360px; gap:var(--space-lg); padding:var(--space-lg); background:rgba(17,17,17,.92); border:1px solid var(--border); border-radius:16px; }
    .bucket-panel { margin-top:var(--space-lg); }
    .rank { font-family:'Space Mono',monospace; color:var(--text-disabled); font-size:28px; letter-spacing:-.05em; }
    .video-topline { display:flex; flex-wrap:wrap; gap:var(--space-sm); margin-bottom:var(--space-md); }
    .status { border:1px solid var(--border-visible); border-radius:999px; padding:3px 9px; display:inline-flex; align-items:center; white-space:nowrap; }
    .status-hot { color:var(--accent); border-color:var(--accent); }
    .status-warm { color:var(--warning); border-color:var(--warning); }
    .status-muted { color:var(--text-disabled); }
    .status-unknown { color:var(--text-secondary); }
    .video-main { min-width:0; }
    h3 { margin:0 0 var(--space-md); font-size:22px; line-height:1.25; color:var(--text-display); font-weight:500; overflow-wrap:anywhere; word-break:break-word; }
    .chip-row { display:flex; flex-wrap:wrap; gap:var(--space-sm); margin-top:var(--space-lg); }
    .chip { border:1px solid var(--border-visible); border-radius:999px; padding:4px 10px; color:var(--text-secondary); }
    .muted { color:var(--text-disabled); border-color:var(--border); }
    .video-data { display:grid; align-content:start; border-top:1px solid var(--border); }
    .stat-row { display:flex; justify-content:space-between; gap:var(--space-md); padding:12px 0; border-bottom:1px solid var(--border); }
    .stat-row strong { font-family:'Space Mono',monospace; color:var(--text-primary); font-size:13px; text-align:right; font-weight:400; max-width:210px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
    .stat-row.emphasis strong { color:var(--accent); font-size:18px; }
    .lower-grid { display:grid; grid-template-columns:1fr; gap:var(--space-lg); margin-top:var(--space-lg); }
    .wide-panel { margin-top:var(--space-lg); }
    .panel { background:rgba(17,17,17,.92); border:1px solid var(--border); border-radius:16px; padding:var(--space-lg); overflow:auto; }
    .panel-note { margin:10px 0 20px; max-width:860px; color:var(--text-secondary); line-height:1.5; }
    .signal-bucket { margin-top:var(--space-lg); overflow:visible; }
    .signal-columns { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:var(--space-lg); margin-top:var(--space-md); }
    .signal-group { display:grid; gap:var(--space-md); }
    .signal-list { display:grid; gap:10px; }
    .signal-card { display:grid; grid-template-columns:40px minmax(0,1fr); gap:var(--space-md); align-items:start; padding:14px 0; border-top:1px solid var(--border); }
    .signal-card:first-child { border-top:1px solid var(--border-visible); }
    .signal-rank { font-family:'Space Mono',monospace; color:var(--text-disabled); font-size:16px; }
    .signal-main { min-width:0; }
    .signal-name { color:var(--text-display); font-size:18px; line-height:1.25; overflow-wrap:anywhere; word-break:break-word; }
    .signal-age { margin-top:4px; font-family:'Space Mono',monospace; color:var(--text-secondary); font-size:11px; letter-spacing:.04em; }
    .signal-metrics { grid-column:2; display:flex; flex-wrap:wrap; justify-content:flex-start; gap:8px; margin-top:8px; }
    .signal-metrics span { border:1px solid var(--border-visible); border-radius:999px; padding:4px 9px; font-family:'Space Mono',monospace; color:var(--text-secondary); font-size:11px; white-space:nowrap; }
    .novelty-badge { border-radius:999px; padding:2px 8px; font-size:10px; font-family:'Space Mono',monospace; letter-spacing:.06em; margin-left:8px; vertical-align:middle; display:inline-block; }
    .novelty-badge.first-seen { background:var(--accent); color:#FFF; border:1px solid var(--accent); }
    .signal-empty { padding:var(--space-lg); }
    table { width:100%; border-collapse:collapse; min-width:760px; }
    th { text-align:left; padding:0 12px 12px; border-bottom:1px solid var(--border-visible); font-weight:400; }
    td { padding:14px 12px; border-bottom:1px solid var(--border); color:var(--text-primary); }
    td:nth-child(2) { max-width:420px; overflow-wrap:anywhere; word-break:break-word; }
    td:first-child, td:nth-child(n+3) { font-family:'Space Mono',monospace; color:var(--text-secondary); }
    td:nth-child(n+3), th:nth-child(n+3) { text-align:right; }
    .empty { border:1px solid var(--border-visible); border-radius:16px; padding:var(--space-2xl); color:var(--text-secondary); font-family:'Space Mono',monospace; font-size:12px; letter-spacing:.04em; }
    footer { margin-top:var(--space-3xl); padding-top:var(--space-lg); border-top:1px solid var(--border); color:var(--text-disabled); font-family:'Space Mono',monospace; font-size:11px; letter-spacing:.06em; text-transform:uppercase; }
    @media (max-width:1100px) { header,.video-card,.lower-grid,.signal-columns { grid-template-columns:1fr; } .video-data { margin-top:var(--space-md); } }
    @media (max-width:760px) { .grid-metrics { grid-template-columns:1fr 1fr; } .page { padding-top:24px; } header { min-height:auto; } .signal-card { grid-template-columns:36px 1fr; } .signal-metrics { grid-column:2; justify-content:flex-start; } }
  </style>
</head>
<body>
  <main class="page">
    <header>
      <div>
        <div class="eyebrow">TIKTOK 热点分析 / 美区女装</div>
        <h1>热点<br>此刻</h1>
        <p class="hero-subtitle">这是一份静态热点报告，用发布时间分桶识别新近热点，用长期热词区块观察老词是否仍在扩散。</p>
      </div>
      <div class="hero-metric">
        <div class="hero-number">19.2M</div>
        <div class="hero-label">累计播放最高</div>
        <p class="meta">sakeena097 / 视频年龄 838.64 小时 / 两周以前</p>
      </div>
    </header>

    <section class="grid-metrics">
      
    <div class="metric">
      <div class="metric-label">有效视频数</div>
      <div class="metric-value">406<span></span></div>
    </div>
    
      
    <div class="metric">
      <div class="metric-label">前序命中数</div>
      <div class="metric-value">8<span></span></div>
    </div>
    
      
    <div class="metric">
      <div class="metric-label">分析窗口</div>
      <div class="metric-value">166.09<span>小时</span></div>
    </div>
    
      
    <div class="metric">
      <div class="metric-label">重复率</div>
      <div class="metric-value">18.1%<span></span></div>
    </div>
    
      
    <div class="metric">
      <div class="metric-label">计划运行数</div>
      <div class="metric-value">50<span></span></div>
    </div>
    
      
    <div class="metric">
      <div class="metric-label">失败窗口</div>
      <div class="metric-value">1<span></span></div>
    </div>
    
      
    <div class="metric">
      <div class="metric-label">请求总量</div>
      <div class="metric-value">500<span></span></div>
    </div>
    
      
    <div class="metric">
      <div class="metric-label">数据来源</div>
      <div class="metric-value">关键词 406<span></span></div>
    </div>
    
    </section>
    <p class="panel-note" style="margin:-24px 0 24px;font-size:11px;">Apify cost depends on Actor pricing, planned runs, returned results, compute duration, memory, proxy usage, retries, add-ons, and account plan; verify exact cost in the Apify usage dashboard.</p>

    <section>
      <div class="section-head">
        <div>
          <div class="section-label">新近视频 / 发布时间分桶</div>
          <h2>按发布时间分级的热点视频</h2>
        </div>
        <p>这里不依赖前后两轮是否抓到同一条视频，而是直接按视频发布时间分成近一天、三天、七天和两周，每个分级最多展示 5 条热视频。</p>
      </div>
      
            <section class="bucket-panel">
              <div class="section-label">近一天 / 最多 5 条</div>
              <div class="video-stack">
    <article class="video-card">
      <div class="rank">01</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / try on haul women</span>
          <span class="status status-hot">近一天</span>
        </div>
        <h3>Here’s my cooking essentials haul from @Amazon.eg Dont miss out on the Amazon Mega Sale! Shop deals and Save up to 50% From the 12th of May to the 18th of May\ud83d\ude0d #مهرجان_عروض_امازون
#AmazonMegaSale</h3>
        <a href="https://www.tiktok.com/@palactapus/video/7636753343192272149" target="_blank" rel="noreferrer">@palactapus</a>
        <div class="chip-row"><span class="chip">#مهرجان_عروض_امازون</span><span class="chip">#amazonmegasale</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>1.5M</strong></div>
        <div class="stat-row "><span>热度分</span><strong>1.6M</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>15.62 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>0.2%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - palactapus — Palactapus</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">02</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / vacation outfits</span>
          <span class="status status-hot">近一天</span>
        </div>
        <h3>the fashion here is no joke😍 #modestfashion #fashion #outfits #honeymoon #japan </h3>
        <a href="https://www.tiktok.com/@frishtaxx/video/7636776847493156118" target="_blank" rel="noreferrer">@frishtaxx</a>
        <div class="chip-row"><span class="chip">#modestfashion</span><span class="chip">#fashion</span><span class="chip">#outfits</span><span class="chip">#honeymoon</span><span class="chip">#japan</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>224.9K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>481.3K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>14.1 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>9.5%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - playgrndseries — playgrndseries</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">03</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / plus size fashion</span>
          <span class="status status-hot">近一天</span>
        </div>
        <h3>Dubai Princess Plus Size Beauty Slaying Ethnic Fashion 💃✨❤️ #Dubaiprincess #Lifestyle #Husbandwife #Shaikhamahra #Fashion </h3>
        <a href="https://www.tiktok.com/@girlstop52/video/7636624162181729550" target="_blank" rel="noreferrer">@girlstop52</a>
        <div class="chip-row"><span class="chip">#dubaiprincess</span><span class="chip">#lifestyle</span><span class="chip">#husbandwife</span><span class="chip">#shaikhamahra</span><span class="chip">#fashion</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>371.8K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>410.9K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>23.97 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>0.9%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - girlstop52 — girlstop</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">04</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / vacation outfits</span>
          <span class="status status-hot">近一天</span>
        </div>
        <h3>Me 🫶🏼 Euro summer #eurosummer #Chanel #outfitinspo #Pucci #summeroutfit </h3>
        <a href="https://www.tiktok.com/@chanelmcclarty/video/7636694053790272786" target="_blank" rel="noreferrer">@chanelmcclarty</a>
        <div class="chip-row"><span class="chip">#eurosummer</span><span class="chip">#chanel</span><span class="chip">#outfitinspo</span><span class="chip">#pucci</span><span class="chip">#summeroutfit</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>69.7K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>267.8K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>19.46 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>22.8%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>Yamore by MoBlack Benja Franc Fala — Deep House Bible</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">05</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / work outfits women</span>
          <span class="status status-hot">近一天</span>
        </div>
        <h3>They found that attractive women… get treated differently. \ud83e\udd14
Not just in dating —
in opportunities, attention, and perception.

But here’s what most people miss…
Attraction isn’t just looks.
It’s energy. It’s confidence. It’s standards.
Because the most “attractive” women
aren’t chasing validation…
they become it.

They walk with certainty.
They speak with intention.
They don’t beg — they choose. \ud83d\udc51
And that’s what sets them apart.
No insecurity. No overgiving. Just self-worth. \ud83d\udd25
Because real attraction isn’t built in the mirror…
it’s built in the mind.

So tell me… are you becoming or just comparing? \ud83e\udd14

\ud83d\udce9 Ready to level up your confidence and presence? DM me — let’s talk.

\ud83d\udcf8 Credit: @jayshetty
| DM for credit or removal 
| Credit goes to respective owner

#Confidence #FeminineEnergy #SelfWorth #Attraction #MindsetShift     

attractive women mindset, confidence building, feminine energy, self worth, attraction psychology, high value woman, personal growth, mindset shift, inner confidence, self improvement, glow up mindset</h3>
        <a href="https://www.tiktok.com/@ark.is.ark/video/7636662582039858454" target="_blank" rel="noreferrer">@ark.is.ark</a>
        <div class="chip-row"><span class="chip">#confidence</span><span class="chip">#feminineenergy</span><span class="chip">#selfworth</span><span class="chip">#attraction</span><span class="chip">#mindsetshift</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>128.8K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>259.3K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>21.49 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>8.0%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - ark.is.ark — Ark</strong></div>
      </div>
    </article>
    </div>
            </section>
            
            <section class="bucket-panel">
              <div class="section-label">近三天 / 最多 5 条</div>
              <div class="video-stack">
    <article class="video-card">
      <div class="rank">01</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / wedding guest dress</span>
          <span class="status status-hot">近三天</span>
        </div>
        <h3>Not me fighting for my life trying to get this dress on for the 2nd part of my friend’s wedding 😭 #grwm #weddingguestdress #weddingguest #weddingoutfit #westernwedding </h3>
        <a href="https://www.tiktok.com/@lizzymwong/video/7636538548878871839" target="_blank" rel="noreferrer">@lizzymwong</a>
        <div class="chip-row"><span class="chip">#grwm</span><span class="chip">#weddingguestdress</span><span class="chip">#weddingguest</span><span class="chip">#weddingoutfit</span><span class="chip">#westernwedding</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>772.2K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>1.9M</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>29.51 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>14.6%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - lizzymwong — Lizzy Wong</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">02</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / summer dresses</span>
          <span class="status status-hot">近三天</span>
        </div>
        <h3>this on my summer holiday&gt;😍 #mypartnerratesmyoutfit #mypartnerratesasummerdress #mypartnerhelpingmefindholidaydresses #haul #OOTD </h3>
        <a href="https://www.tiktok.com/@livpreston/video/7636091654566432022" target="_blank" rel="noreferrer">@livpreston</a>
        <div class="chip-row"><span class="chip">#mypartnerratesmyoutfit</span><span class="chip">#mypartnerratesasummerdress</span><span class="chip">#mypartnerhelpingmefindholidaydresses</span><span class="chip">#haul</span><span class="chip">#ootd</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>774.9K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>1.4M</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>58.42 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>6.6%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - livpreston — Olivia Preston</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">03</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / wedding guest dress</span>
          <span class="status status-hot">近三天</span>
        </div>
        <h3>UPDATE THE INVITE SAYS “morning dress or lounge suit” and the bride loves the blue dress with the coat I got!
Things to note:
- bride told me only old people will be fully dressed to code \u0026 definitely not to wear a hat lol
- bride is from the US (PA) groom is from the UK — her family will likely be dressed on the more casual side.
- wedding is 12-5 in a church with a reception in his family’s garden at their cottage 
- bride is incredibly stylish and used to work for @Lingua Franca as one of their day one employees 
- I have already decided to buy a tan colored cashmere wrap no matter the dress choice for the church. 

@STAUD @Cara Cara NYC @BadgleyMischka #FashionAdvice #weddingguest #ukwedding </h3>
        <a href="https://www.tiktok.com/@mackinstyle/video/7636154604387912973" target="_blank" rel="noreferrer">@mackinstyle</a>
        <div class="chip-row"><span class="chip">#FashionAdvice</span><span class="chip">#weddingguest</span><span class="chip">#ukwedding</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>747.0K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>1.3M</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>54.34 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>6.4%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>Orange Afternoon — Bossa Nova Terrace</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">04</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / summer dresses</span>
          <span class="status status-hot">近三天</span>
        </div>
        <h3>H\u0026M New In Summer Dresses \ud83e\udd0d☀️✨

How gorgeous are these summer dresses from @H\u0026M the black fitted dress is unreal and so comfortable, the blue embroidered is so pretty for holiday and a denim dress is so perfect to wear with flats in the summer \ud83e\udef6✨

All linked on my @LTK link in bio \ud83e\udd0d

#hm #hmhaul #fashiontiktok #summerdress #SummerFashion </h3>
        <a href="https://www.tiktok.com/@lucinda.fashion/video/7636119470532087062" target="_blank" rel="noreferrer">@lucinda.fashion</a>
        <div class="chip-row"><span class="chip">#hm</span><span class="chip">#hmhaul</span><span class="chip">#fashiontiktok</span><span class="chip">#summerdress</span><span class="chip">#summerfashion</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>398.4K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>649.2K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>56.62 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>3.9%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>Beauty And A Beat — Justin Bieber</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">05</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / vacation outfits</span>
          <span class="status status-hot">近三天</span>
        </div>
        <h3>Stop ✋ scrolling most requested tour or vacation outfits page suggestion ⭐️🎀#creatorsearchinsights #explorepage #fyp </h3>
        <a href="https://www.tiktok.com/@strawcherry07/video/7636004418864893200" target="_blank" rel="noreferrer">@strawcherry07</a>
        <div class="chip-row"><span class="chip">#creatorsearchinsights</span><span class="chip">#explorepage</span><span class="chip">#fyp</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>259.2K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>527.1K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>64.05 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>7.0%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - strawcherry07 — 🍓Strawcherry🍒</strong></div>
      </div>
    </article>
    </div>
            </section>
            
            <section class="bucket-panel">
              <div class="section-label">近七天 / 最多 5 条</div>
              <div class="video-stack">
    <article class="video-card">
      <div class="rank">01</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / try on haul women</span>
          <span class="status status-warm">近七天</span>
        </div>
        <h3>@alo aloversary try on haul! if you&#x27;ve been eyeing anything from ALO, now is the perfect time to shop while they&#x27;re doing 30% off sitewide!</h3>
        <a href="https://www.tiktok.com/@talia.hannosh/video/7635287825738485022" target="_blank" rel="noreferrer">@talia.hannosh</a>
        <div class="chip-row"><span class="chip muted">暂无标签</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>9.1M</strong></div>
        <div class="stat-row "><span>热度分</span><strong>9.4M</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>108.01 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>0.2%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - talia.hannosh — talia 🧸</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">02</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / target finds fashion</span>
          <span class="status status-warm">近七天</span>
        </div>
        <h3>New lace cami \ud83d\ude0d
@target 
#target #targetstyle #targetfinds #apparel </h3>
        <a href="https://www.tiktok.com/@findssbyyjess/video/7634705036005739790" target="_blank" rel="noreferrer">@findssbyyjess</a>
        <div class="chip-row"><span class="chip">#target</span><span class="chip">#targetstyle</span><span class="chip">#targetfinds</span><span class="chip">#apparel</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>335.0K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>797.4K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>148.09 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>10.6%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>Kitten — Not For Radio</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">03</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / summer dresses</span>
          <span class="status status-warm">近七天</span>
        </div>
        <h3>cute summer dress ˖°𓇼🌊</h3>
        <a href="https://www.tiktok.com/@jenbarangan/video/7634878430466690311" target="_blank" rel="noreferrer">@jenbarangan</a>
        <div class="chip-row"><span class="chip muted">暂无标签</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>452.3K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>767.7K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>136.88 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>5.8%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - jenbarangan — Jen Barangan-David</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">04</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / brunch outfit</span>
          <span class="status status-warm">近七天</span>
        </div>
        <h3>sunday outfit🦥🤙🏼 | ig: monaaleuu #fürdich #girly #sundayvibes #brunchfit #outfitoftheday </h3>
        <a href="https://www.tiktok.com/@monaaleuu/video/7635762933477182753" target="_blank" rel="noreferrer">@monaaleuu</a>
        <div class="chip-row"><span class="chip">#fürdich</span><span class="chip">#girly</span><span class="chip">#sundayvibes</span><span class="chip">#brunchfit</span><span class="chip">#outfitoftheday</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>264.0K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>767.4K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>79.68 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>16.1%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - drewqdnd — drew</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">05</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / vacation outfits</span>
          <span class="status status-warm">近七天</span>
        </div>
        <h3>SHEIN is getting to good.\ud83c\udf68\ud83e\udee7\ud83e\uddd8\ud83c\udffd‍♀️Download the SHEIN App to check out the amazing store Avantive and unlock the latest fashion trends anytime, anywhere! Search \u0026 follow Avantive on SHEIN and use; 4SPUSsophienelsonnnnn034 to enjoy exclusive discounts! ✨
@sheinofficial @shein_us @SHEIN US 
#SHEINtrends #ad
#sheinhaul #summersheinhaul #vacationoutfits </h3>
        <a href="https://www.tiktok.com/@sophienelsonnnnn/video/7634700999801769246" target="_blank" rel="noreferrer">@sophienelsonnnnn</a>
        <div class="chip-row"><span class="chip">#sheintrends</span><span class="chip">#ad</span><span class="chip">#sheinhaul</span><span class="chip">#summersheinhaul</span><span class="chip">#vacationoutfits</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>234.6K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>549.3K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>148.36 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>10.2%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - timeshareexits — Timeshare Cancellation Experts</strong></div>
      </div>
    </article>
    </div>
            </section>
            
            <section class="bucket-panel">
              <div class="section-label">近两周 / 最多 5 条</div>
              <div class="video-stack">
    <article class="video-card">
      <div class="rank">01</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / wedding guest dress</span>
          <span class="status status-warm">近两周</span>
        </div>
        <h3>summer wedding guest dresses 🩵✨ comment your faveee 🫶🏼 #weddingguest #weddingguestdress #summerdress #occasionwear #babyboo </h3>
        <a href="https://www.tiktok.com/@abbiexhooper/video/7632024805646060803" target="_blank" rel="noreferrer">@abbiexhooper</a>
        <div class="chip-row"><span class="chip">#weddingguest</span><span class="chip">#weddingguestdress</span><span class="chip">#summerdress</span><span class="chip">#occasionwear</span><span class="chip">#babyboo</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>1.2M</strong></div>
        <div class="stat-row "><span>热度分</span><strong>2.7M</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>321.44 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>10.4%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - coolazzshamon673 — CoolAzzShamon673</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">02</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / date night outfit</span>
          <span class="status status-warm">近两周</span>
        </div>
        <h3>Who wants to be my dinner date? #date #OOTD #fypシ #xy </h3>
        <a href="https://www.tiktok.com/@purpl3_rat/video/7632812812426939661" target="_blank" rel="noreferrer">@purpl3_rat</a>
        <div class="chip-row"><span class="chip">#date</span><span class="chip">#ootd</span><span class="chip">#fypシ</span><span class="chip">#xy</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>176.4K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>763.0K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>270.47 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>25.2%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - suhofiles — suhofiles</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">03</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / try on haul women</span>
          <span class="status status-warm">近两周</span>
        </div>
        <h3>mi lugar favorito #haul #tryon #ross </h3>
        <a href="https://www.tiktok.com/@rzmelanie/video/7632357852417363214" target="_blank" rel="noreferrer">@rzmelanie</a>
        <div class="chip-row"><span class="chip">#haul</span><span class="chip">#tryon</span><span class="chip">#ross</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>283.5K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>709.5K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>299.89 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>12.2%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - nic_ttrx — 𝕹𝖎𝖈𝖈</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">04</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / amazon fashion finds</span>
          <span class="status status-warm">近两周</span>
        </div>
        <h3>Link in my bio to AMAZON #viral #Summer #gadget #amazon #product </h3>
        <a href="https://www.tiktok.com/@amazproducts601/video/7632594033289825558" target="_blank" rel="noreferrer">@amazproducts601</a>
        <div class="chip-row"><span class="chip">#viral</span><span class="chip">#summer</span><span class="chip">#gadget</span><span class="chip">#amazon</span><span class="chip">#product</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>541.3K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>673.3K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>284.63 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>1.2%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>MONTAGEM TOMADA (Slowed) — MXZI</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">05</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / try on haul women</span>
          <span class="status status-warm">近两周</span>
        </div>
        <h3>Brandy had STEPPED UP their game lately?? they’re clearing targeting the chic, cotton wearing, zen, yoga baddies\ud83e\uddd8\ud83c\udffb‍♀️

#brandymelvillehaul #tryonhaul #yogaaesthetic #yogawear #cottonclothing </h3>
        <a href="https://www.tiktok.com/@fairyjadyn/video/7633527626375777567" target="_blank" rel="noreferrer">@fairyjadyn</a>
        <div class="chip-row"><span class="chip">#brandymelvillehaul</span><span class="chip">#tryonhaul</span><span class="chip">#yogaaesthetic</span><span class="chip">#yogawear</span><span class="chip">#cottonclothing</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>263.6K</strong></div>
        <div class="stat-row "><span>热度分</span><strong>672.2K</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>224.24 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>12.5%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>Chopin Nocturne No. 2 Piano Mono — moshimo sound design</strong></div>
      </div>
    </article>
    </div>
            </section>
            
    </section>

    <section>
      <div class="section-head">
        <div>
          <div class="section-label">累计热度 / 总量排行</div>
          <h2>累计热视频</h2>
        </div>
        <p>这一部分按总播放和互动加权排序，可能包含早就已经很热的长尾视频，需要结合视频年龄和时效状态一起判断。</p>
      </div>
      <div class="video-stack">
    <article class="video-card">
      <div class="rank">01</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / plus size fashion</span>
          <span class="status status-muted">两周以前</span>
        </div>
        <h3>Dubai Princess Plus Size Beauty Slaying Ethnic Fashion💃✨❤️ #ranikumari #princess #Lifestyle #fashion #beautifulgirl </h3>
        <a href="https://www.tiktok.com/@sakeena097/video/7624027786172255519" target="_blank" rel="noreferrer">@sakeena097</a>
        <div class="chip-row"><span class="chip">#ranikumari</span><span class="chip">#princess</span><span class="chip">#lifestyle</span><span class="chip">#fashion</span><span class="chip">#beautifulgirl</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>19.2M</strong></div>
        <div class="stat-row "><span>热度分</span><strong>24.3M</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>838.64 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>1.9%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - sakeena097 — Sakeena ❤️</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">02</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / brunch outfit</span>
          <span class="status status-muted">两周以前</span>
        </div>
        <h3>outfit of the day 🫐 mom wanted me to wear blue for dads birthday brunch so here we go ✨ #OOTD #outfitoftheday #outfitinspo #outfitideas #brunch #falloutfits #fallfashion #autumnoutfits #autumn </h3>
        <a href="https://www.tiktok.com/@lisa.rdna/video/7428560924841233696" target="_blank" rel="noreferrer">@lisa.rdna</a>
        <div class="chip-row"><span class="chip">#ootd</span><span class="chip">#outfitoftheday</span><span class="chip">#outfitinspo</span><span class="chip">#outfitideas</span><span class="chip">#brunch</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>6.2M</strong></div>
        <div class="stat-row "><span>热度分</span><strong>19.7M</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>13480.5 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>17.9%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - augustc0ast — 𝑖𝑠𝑎𝑏𝑒𝑙𝑙𝑎 ˚୨୧⋆</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">03</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / amazon fashion finds</span>
          <span class="status status-muted">两周以前</span>
        </div>
        <h3>PART 23 of my amazon addiction !!! #amazonfinds #amazonmusthaves #amazonfashion #amazon LINKS ARE IN MY BIO</h3>
        <a href="https://www.tiktok.com/@annamoraan/video/6844234291535154438" target="_blank" rel="noreferrer">@annamoraan</a>
        <div class="chip-row"><span class="chip">#amazonfinds</span><span class="chip">#amazonmusthaves</span><span class="chip">#amazonfashion</span><span class="chip">#amazon</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>4.6M</strong></div>
        <div class="stat-row "><span>热度分</span><strong>19.0M</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>51271.94 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>29.1%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - annamoraan — Anna</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">04</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / vacation outfits</span>
          <span class="status status-muted">两周以前</span>
        </div>
        <h3>Duo vacation fits🌺💖👭🏻ig:9anei #fyp #summervibes #outfitideas </h3>
        <a href="https://www.tiktok.com/@9anei/video/7560300279988571404" target="_blank" rel="noreferrer">@9anei</a>
        <div class="chip-row"><span class="chip">#fyp</span><span class="chip">#summervibes</span><span class="chip">#outfitideas</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>6.5M</strong></div>
        <div class="stat-row "><span>热度分</span><strong>18.8M</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>4960.24 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>14.9%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - 9anei — Sabrina ☆*</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">05</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / vacation outfits</span>
          <span class="status status-muted">两周以前</span>
        </div>
        <h3>girl thing #fyp #vacation #vacationoutfits #travel </h3>
        <a href="https://www.tiktok.com/@celine_jea/video/7527743868662467862" target="_blank" rel="noreferrer">@celine_jea</a>
        <div class="chip-row"><span class="chip">#fyp</span><span class="chip">#vacation</span><span class="chip">#vacationoutfits</span><span class="chip">#travel</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>5.7M</strong></div>
        <div class="stat-row "><span>热度分</span><strong>16.5M</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>7065.83 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>15.9%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>original sound - wyn_cutie — wyn_cutie</strong></div>
      </div>
    </article>
    
    <article class="video-card">
      <div class="rank">06</div>
      <div class="video-main">
        <div class="video-topline">
          <span class="source">关键词 / vacation outfits</span>
          <span class="status status-muted">两周以前</span>
        </div>
        <h3>✨✨ #shein #fyp #sheinhaul </h3>
        <a href="https://www.tiktok.com/@marjoriekiim/video/7534816800945704198" target="_blank" rel="noreferrer">@marjoriekiim</a>
        <div class="chip-row"><span class="chip">#shein</span><span class="chip">#fyp</span><span class="chip">#sheinhaul</span></div>
      </div>
      <div class="video-data">
        <div class="stat-row emphasis"><span>总播放</span><strong>7.5M</strong></div>
        <div class="stat-row "><span>热度分</span><strong>15.1M</strong></div>
        <div class="stat-row "><span>视频年龄</span><strong>6608.39 小时</strong></div>
        <div class="stat-row "><span>互动率</span><strong>6.9%</strong></div>
        <div class="stat-row "><span>音乐</span><strong>Doo wop — Speed_songz.1</strong></div>
      </div>
    </article>
    </div>
    </section>

    <section>
      <div class="section-head">
        <div>
          <div class="section-label">新晋趋势信号 / 发布时间分桶</div>
          <h2>新晋关键词与 TikTok 标签</h2>
        </div>
        <p>按一天、三天、七天、两周和一个月分桶；每个时间段分热度排行和覆盖排行两列展示。带有<span style="color:var(--accent)">新见</span>标记的词在该时间段出现，但在更长时间段（如近七天 / 近两周 / 近一个月）的样本中未出现，代表它可能是本轮新冒出的信号。覆盖分（video_count × log(1 + avg_score_per_video)）用于衡量一个词出现在多少视频中且有基础互动。</p>
      </div>
      
            <section class="panel signal-bucket">
              <div class="section-label">近一天 / 趋势信号</div>
              <div class="signal-columns">
                
    <div class="signal-group">
      <div class="section-label">内容关键词 · 热度排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">here</div>
        <div class="signal-age">最早 15.6 小时 / 最新 4.6 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 4</span>
        <span>播放 1.5M</span>
        <span>热度 1.6M</span>
        <span>覆盖 52</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">the amazon mega sale<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 15.6 小时 / 最新 15.6 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 1.5M</span>
        <span>热度 1.6M</span>
        <span>覆盖 14</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">dont miss out<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 15.6 小时 / 最新 15.6 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 1.5M</span>
        <span>热度 1.6M</span>
        <span>覆盖 14</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">shop deals and save<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 15.6 小时 / 最新 15.6 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 1.5M</span>
        <span>热度 1.6M</span>
        <span>覆盖 14</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">may<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 15.6 小时 / 最新 15.6 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 1.5M</span>
        <span>热度 1.6M</span>
        <span>覆盖 14</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">内容关键词 · 覆盖排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">what</div>
        <div class="signal-age">最早 14.9 小时 / 最新 1.0 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 8</span>
        <span>播放 18.8K</span>
        <span>热度 35.5K</span>
        <span>覆盖 67</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">here</div>
        <div class="signal-age">最早 15.6 小时 / 最新 4.6 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 4</span>
        <span>播放 1.5M</span>
        <span>热度 1.6M</span>
        <span>覆盖 52</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">bio</div>
        <div class="signal-age">最早 19.9 小时 / 最新 0.5 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 6</span>
        <span>播放 13.5K</span>
        <span>热度 22.4K</span>
        <span>覆盖 49</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">all</div>
        <div class="signal-age">最早 22.6 小时 / 最新 4.2 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 4</span>
        <span>播放 62.7K</span>
        <span>热度 150.4K</span>
        <span>覆盖 42</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">haul</div>
        <div class="signal-age">最早 22.0 小时 / 最新 0.9 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 4</span>
        <span>播放 21.1K</span>
        <span>热度 42.2K</span>
        <span>覆盖 37</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">TikTok 标签 · 热度排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">amazonmegasale<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 15.6 小时 / 最新 15.6 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 1.5M</span>
        <span>热度 1.6M</span>
        <span>覆盖 14</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">مهرجان_عروض_امازون<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 15.6 小时 / 最新 15.6 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 1.5M</span>
        <span>热度 1.6M</span>
        <span>覆盖 14</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">outfits</div>
        <div class="signal-age">最早 14.1 小时 / 最新 7.9 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 246.7K</span>
        <span>热度 513.3K</span>
        <span>覆盖 36</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">honeymoon<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 14.1 小时 / 最新 14.1 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 224.9K</span>
        <span>热度 481.3K</span>
        <span>覆盖 13</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">japan<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 14.1 小时 / 最新 14.1 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 224.9K</span>
        <span>热度 481.3K</span>
        <span>覆盖 13</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">TikTok 标签 · 覆盖排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">ootd</div>
        <div class="signal-age">最早 18.5 小时 / 最新 1.0 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 26</span>
        <span>播放 58.5K</span>
        <span>热度 112.5K</span>
        <span>覆盖 218</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">outfitinspo</div>
        <div class="signal-age">最早 20.2 小时 / 最新 3.0 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 19</span>
        <span>播放 88.9K</span>
        <span>热度 306.7K</span>
        <span>覆盖 184</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">haul</div>
        <div class="signal-age">最早 20.4 小时 / 最新 0.5 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 16</span>
        <span>播放 90.0K</span>
        <span>热度 176.9K</span>
        <span>覆盖 149</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">weddingguestdress</div>
        <div class="signal-age">最早 22.6 小时 / 最新 1.4 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 14</span>
        <span>播放 28.8K</span>
        <span>热度 57.8K</span>
        <span>覆盖 117</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">outfit</div>
        <div class="signal-age">最早 19.0 小时 / 最新 3.7 小时</div>
      </div>
      <div class="signal-metrics">
        <span>视频 14</span>
        <span>播放 12.8K</span>
        <span>热度 45.1K</span>
        <span>覆盖 113</span>
      </div>
    </article>
    </div>
    </div>
    
              </div>
            </section>
            
            <section class="panel signal-bucket">
              <div class="section-label">近三天 / 趋势信号</div>
              <div class="signal-columns">
                
    <div class="signal-group">
      <div class="section-label">内容关键词 · 热度排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">wedding<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 2.3 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 1.5M</span>
        <span>热度 3.2M</span>
        <span>覆盖 29</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">part<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 1.4 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 784.9K</span>
        <span>热度 1.9M</span>
        <span>覆盖 28</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">fighting for<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 1.2 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 772.2K</span>
        <span>热度 1.9M</span>
        <span>覆盖 14</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">life trying<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 1.2 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 772.2K</span>
        <span>热度 1.9M</span>
        <span>覆盖 14</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">friend<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 1.2 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 772.2K</span>
        <span>热度 1.9M</span>
        <span>覆盖 14</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">内容关键词 · 覆盖排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">bio</div>
        <div class="signal-age">最早 2.4 天 / 最新 1.4 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 472.7K</span>
        <span>热度 845.6K</span>
        <span>覆盖 38</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">wedding<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 2.3 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 1.5M</span>
        <span>热度 3.2M</span>
        <span>覆盖 29</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">part<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 1.4 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 784.9K</span>
        <span>热度 1.9M</span>
        <span>覆盖 28</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">all linked<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 2.4 天 / 最新 2.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 411.4K</span>
        <span>热度 676.0K</span>
        <span>覆盖 25</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">perfect</div>
        <div class="signal-age">最早 2.4 天 / 最新 1.1 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 405.2K</span>
        <span>热度 659.3K</span>
        <span>覆盖 25</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">TikTok 标签 · 热度排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">weddingguest</div>
        <div class="signal-age">最早 2.3 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 1.6M</span>
        <span>热度 3.3M</span>
        <span>覆盖 42</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">weddingguestdress</div>
        <div class="signal-age">最早 1.6 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 4</span>
        <span>播放 908.7K</span>
        <span>热度 2.1M</span>
        <span>覆盖 53</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">grwm</div>
        <div class="signal-age">最早 2.5 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 788.4K</span>
        <span>热度 1.9M</span>
        <span>覆盖 40</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">westernwedding<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 1.2 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 772.2K</span>
        <span>热度 1.9M</span>
        <span>覆盖 14</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">weddingoutfit</div>
        <div class="signal-age">最早 1.2 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 772.2K</span>
        <span>热度 1.9M</span>
        <span>覆盖 14</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">TikTok 标签 · 覆盖排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">targetfinds</div>
        <div class="signal-age">最早 2.8 天 / 最新 1.5 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 7</span>
        <span>播放 312.0K</span>
        <span>热度 666.3K</span>
        <span>覆盖 80</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">ootd</div>
        <div class="signal-age">最早 2.5 天 / 最新 1.1 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 6</span>
        <span>播放 812.7K</span>
        <span>热度 1.5M</span>
        <span>覆盖 75</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">amazonfashion</div>
        <div class="signal-age">最早 2.8 天 / 最新 1.1 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 6</span>
        <span>播放 202.4K</span>
        <span>热度 368.8K</span>
        <span>覆盖 66</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">weddingguestdress</div>
        <div class="signal-age">最早 1.6 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 4</span>
        <span>播放 908.7K</span>
        <span>热度 2.1M</span>
        <span>覆盖 53</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">haul</div>
        <div class="signal-age">最早 2.4 天 / 最新 1.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 4</span>
        <span>播放 841.8K</span>
        <span>热度 1.6M</span>
        <span>覆盖 52</span>
      </div>
    </article>
    </div>
    </div>
    
              </div>
            </section>
            
            <section class="panel signal-bucket">
              <div class="section-label">近七天 / 趋势信号</div>
              <div class="signal-columns">
                
    <div class="signal-group">
      <div class="section-label">内容关键词 · 热度排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">the perfect time<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 4.5 天 / 最新 4.5 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 9.1M</span>
        <span>热度 9.4M</span>
        <span>覆盖 16</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">off sitewide<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 4.5 天 / 最新 4.5 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 9.1M</span>
        <span>热度 9.4M</span>
        <span>覆盖 16</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">haul<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 4.5 天 / 最新 4.5 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 9.1M</span>
        <span>热度 9.4M</span>
        <span>覆盖 16</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">you&#x27;ve been eyeing anything<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 4.5 天 / 最新 4.5 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 9.1M</span>
        <span>热度 9.4M</span>
        <span>覆盖 16</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">aloversary try<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 4.5 天 / 最新 4.5 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 9.1M</span>
        <span>热度 9.4M</span>
        <span>覆盖 16</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">内容关键词 · 覆盖排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">ltk<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 6.8 天 / 最新 4.5 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 4</span>
        <span>播放 386.1K</span>
        <span>热度 594.6K</span>
        <span>覆盖 48</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">bio</div>
        <div class="signal-age">最早 6.5 天 / 最新 4.6 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 4</span>
        <span>播放 145.5K</span>
        <span>热度 214.3K</span>
        <span>覆盖 44</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">all<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 6.3 天 / 最新 3.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 95.6K</span>
        <span>热度 151.9K</span>
        <span>覆盖 32</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">here<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 3.3 天 / 最新 3.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 84.2K</span>
        <span>热度 478.0K</span>
        <span>覆盖 25</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">the top<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 6.3 天 / 最新 3.5 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 155.7K</span>
        <span>热度 219.2K</span>
        <span>覆盖 23</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">TikTok 标签 · 热度排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">targetfinds</div>
        <div class="signal-age">最早 6.8 天 / 最新 3.6 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 9</span>
        <span>播放 907.2K</span>
        <span>热度 1.8M</span>
        <span>覆盖 110</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">amazonfashion</div>
        <div class="signal-age">最早 6.8 天 / 最新 3.6 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 5</span>
        <span>播放 608.5K</span>
        <span>热度 955.1K</span>
        <span>覆盖 61</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">targetstyle<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 6.2 天 / 最新 5.4 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 416.2K</span>
        <span>热度 921.7K</span>
        <span>覆盖 26</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">target</div>
        <div class="signal-age">最早 6.4 天 / 最新 6.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 385.4K</span>
        <span>热度 885.4K</span>
        <span>覆盖 26</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">sheinhaul<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 6.2 天 / 最新 4.6 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 309.3K</span>
        <span>热度 798.7K</span>
        <span>覆盖 26</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">TikTok 标签 · 覆盖排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">targetfinds</div>
        <div class="signal-age">最早 6.8 天 / 最新 3.6 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 9</span>
        <span>播放 907.2K</span>
        <span>热度 1.8M</span>
        <span>覆盖 110</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">outfitinspo</div>
        <div class="signal-age">最早 6.3 天 / 最新 3.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 9</span>
        <span>播放 130.9K</span>
        <span>热度 312.0K</span>
        <span>覆盖 94</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">ootd</div>
        <div class="signal-age">最早 6.3 天 / 最新 3.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 8</span>
        <span>播放 188.0K</span>
        <span>热度 365.8K</span>
        <span>覆盖 86</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">amazonfashion</div>
        <div class="signal-age">最早 6.8 天 / 最新 3.6 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 5</span>
        <span>播放 608.5K</span>
        <span>热度 955.1K</span>
        <span>覆盖 61</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">vacationoutfits<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 6.3 天 / 最新 4.2 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 5</span>
        <span>播放 280.0K</span>
        <span>热度 618.8K</span>
        <span>覆盖 59</span>
      </div>
    </article>
    </div>
    </div>
    
              </div>
            </section>
            
            <section class="panel signal-bucket">
              <div class="section-label">近两周 / 趋势信号</div>
              <div class="signal-columns">
                
    <div class="signal-group">
      <div class="section-label">内容关键词 · 热度排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">comment your faveee<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 13.4 天 / 最新 13.4 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 1.2M</span>
        <span>热度 2.7M</span>
        <span>覆盖 15</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">summer wedding guest dresses<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 13.4 天 / 最新 13.4 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 1.2M</span>
        <span>热度 2.7M</span>
        <span>覆盖 15</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">bio<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 11.9 天 / 最新 8.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 624.8K</span>
        <span>热度 819.9K</span>
        <span>覆盖 26</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">link<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 11.9 天 / 最新 8.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 624.8K</span>
        <span>热度 819.9K</span>
        <span>覆盖 26</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">summer</div>
        <div class="signal-age">最早 10.4 天 / 最新 8.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 587.6K</span>
        <span>热度 787.0K</span>
        <span>覆盖 26</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">内容关键词 · 覆盖排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">bio<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 11.9 天 / 最新 8.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 624.8K</span>
        <span>热度 819.9K</span>
        <span>覆盖 26</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">link<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 11.9 天 / 最新 8.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 624.8K</span>
        <span>热度 819.9K</span>
        <span>覆盖 26</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">summer</div>
        <div class="signal-age">最早 10.4 天 / 最新 8.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 587.6K</span>
        <span>热度 787.0K</span>
        <span>覆盖 26</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">try<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 9.3 天 / 最新 7.1 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 125.7K</span>
        <span>热度 264.3K</span>
        <span>覆盖 24</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">comment your faveee<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 13.4 天 / 最新 13.4 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 1.2M</span>
        <span>热度 2.7M</span>
        <span>覆盖 15</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">TikTok 标签 · 热度排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">weddingguestdress</div>
        <div class="signal-age">最早 13.4 天 / 最新 10.6 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 1.7M</span>
        <span>热度 3.7M</span>
        <span>覆盖 42</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">weddingguest<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 13.4 天 / 最新 13.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 1.5M</span>
        <span>热度 3.3M</span>
        <span>覆盖 29</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">babyboo<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 13.4 天 / 最新 13.4 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 1.2M</span>
        <span>热度 2.7M</span>
        <span>覆盖 15</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">summerdress</div>
        <div class="signal-age">最早 13.4 天 / 最新 13.4 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 1.2M</span>
        <span>热度 2.7M</span>
        <span>覆盖 15</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">occasionwear<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 13.4 天 / 最新 13.4 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 1.2M</span>
        <span>热度 2.7M</span>
        <span>覆盖 15</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">TikTok 标签 · 覆盖排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">ootd</div>
        <div class="signal-age">最早 11.3 天 / 最新 7.5 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 4</span>
        <span>播放 429.4K</span>
        <span>热度 1.3M</span>
        <span>覆盖 51</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">weddingguestdress</div>
        <div class="signal-age">最早 13.4 天 / 最新 10.6 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 1.7M</span>
        <span>热度 3.7M</span>
        <span>覆盖 42</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">haul<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 13.3 天 / 最新 9.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 693.3K</span>
        <span>热度 1.5M</span>
        <span>覆盖 39</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">target</div>
        <div class="signal-age">最早 11.5 天 / 最新 7.1 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 336.9K</span>
        <span>热度 758.0K</span>
        <span>覆盖 37</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">targetfinds</div>
        <div class="signal-age">最早 11.5 天 / 最新 7.1 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 336.9K</span>
        <span>热度 758.0K</span>
        <span>覆盖 37</span>
      </div>
    </article>
    </div>
    </div>
    
              </div>
            </section>
            
            <section class="panel signal-bucket">
              <div class="section-label">近一个月 / 趋势信号</div>
              <div class="signal-columns">
                
    <div class="signal-group">
      <div class="section-label">内容关键词 · 热度排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">dubai princess plus size<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 29.0 天 / 最新 22.8 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 10.3M</span>
        <span>热度 14.2M</span>
        <span>覆盖 46</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">beauty slaying ethnic fashion<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 28.7 天 / 最新 22.8 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 8.4M</span>
        <span>热度 12.0M</span>
        <span>覆盖 31</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">this dress<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 18.5 天 / 最新 18.5 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 2.3M</span>
        <span>热度 6.9M</span>
        <span>覆盖 16</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">outfitters<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 26.3 天 / 最新 16.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 1.1M</span>
        <span>热度 3.2M</span>
        <span>覆盖 29</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">happy friday<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 26.3 天 / 最新 26.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 847.7K</span>
        <span>热度 2.7M</span>
        <span>覆盖 15</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">内容关键词 · 覆盖排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">dubai princess plus size<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 29.0 天 / 最新 22.8 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 10.3M</span>
        <span>热度 14.2M</span>
        <span>覆盖 46</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">what<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 27.9 天 / 最新 15.7 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 125.9K</span>
        <span>热度 327.2K</span>
        <span>覆盖 35</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">beauty slaying ethnic fashion<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 28.7 天 / 最新 22.8 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 8.4M</span>
        <span>热度 12.0M</span>
        <span>覆盖 31</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">outfitters<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 26.3 天 / 最新 16.3 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 1.1M</span>
        <span>热度 3.2M</span>
        <span>覆盖 29</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">summer<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 25.5 天 / 最新 14.0 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 1.3M</span>
        <span>热度 1.7M</span>
        <span>覆盖 27</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">TikTok 标签 · 热度排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">beautifulgirl<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 29.0 天 / 最新 22.8 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 10.3M</span>
        <span>热度 14.2M</span>
        <span>覆盖 46</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">weddingguestdress<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 29.6 天 / 最新 14.8 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 5</span>
        <span>播放 3.7M</span>
        <span>热度 9.7M</span>
        <span>覆盖 72</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">dubaiprincess<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 29.0 天 / 最新 22.8 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 2</span>
        <span>播放 6.2M</span>
        <span>热度 9.1M</span>
        <span>覆盖 31</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">maxidress<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 18.5 天 / 最新 18.5 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 2.3M</span>
        <span>热度 6.9M</span>
        <span>覆盖 16</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">datenightoutfit<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 18.5 天 / 最新 18.5 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 1</span>
        <span>播放 2.3M</span>
        <span>热度 6.9M</span>
        <span>覆盖 16</span>
      </div>
    </article>
    </div>
    </div>
    
                
    <div class="signal-group">
      <div class="section-label">TikTok 标签 · 覆盖排行</div>
      <div class="signal-list">
    <article class="signal-card">
      <div class="signal-rank">01</div>
      <div class="signal-main">
        <div class="signal-name">ootd<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 27.1 天 / 最新 14.9 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 6</span>
        <span>播放 1.8M</span>
        <span>热度 3.7M</span>
        <span>覆盖 80</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">02</div>
      <div class="signal-main">
        <div class="signal-name">weddingguestdress<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 29.6 天 / 最新 14.8 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 5</span>
        <span>播放 3.7M</span>
        <span>热度 9.7M</span>
        <span>覆盖 72</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">03</div>
      <div class="signal-main">
        <div class="signal-name">targetfinds<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 29.8 天 / 最新 15.8 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 5</span>
        <span>播放 1.9M</span>
        <span>热度 4.8M</span>
        <span>覆盖 69</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">04</div>
      <div class="signal-main">
        <div class="signal-name">beautifulgirl<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 29.0 天 / 最新 22.8 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 10.3M</span>
        <span>热度 14.2M</span>
        <span>覆盖 46</span>
      </div>
    </article>
    
    <article class="signal-card">
      <div class="signal-rank">05</div>
      <div class="signal-main">
        <div class="signal-name">streetstyleluxe<span class="novelty-badge first-seen">新见</span></div>
        <div class="signal-age">最早 29.8 天 / 最新 15.8 天</div>
      </div>
      <div class="signal-metrics">
        <span>视频 3</span>
        <span>播放 1.7M</span>
        <span>热度 4.1M</span>
        <span>覆盖 42</span>
      </div>
    </article>
    </div>
    </div>
    
              </div>
            </section>
            
    </section>

    <section>
      <div class="section-head">
        <div>
          <div class="section-label">长期热词 / 扩散监控</div>
          <h2>长期热门关键词</h2>
        </div>
        <p>长期热词与新近关键词分开：只有长期热词才看“是否仍在扩散”，新近词优先看出现时间和短期热度。</p>
      </div>
      
    <section class="panel wide-panel">
      <div class="section-label">长期热门内容关键词 / 扩散监控</div>
      <p class="panel-note">这里只放最早命中的样本视频已经超过一个月的长期信号。扩散状态不再依赖前序快照是否命中，而是看当前快照内的视频年龄分布：近 7 天视频占比达到 10% 且最新视频在 7 天内才算“正在扩散”；最新视频在 7-30 天内为“成熟或持平”；最新视频超过 30 天为“已冷却”。覆盖分（video_count × log(1 + avg_score_per_video)）用于衡量一个词出现在多少视频中且每条都有基础互动。</p>
      <table>
        <thead><tr><th>#</th><th>名称</th><th>扩散状态</th><th>视频数</th><th>样本视频数</th><th>近24h视频</th><th>近7d视频</th><th>覆盖分</th><th>热度分</th><th>最早视频年龄</th><th>最新视频年龄</th></tr></thead>
        <tbody>
            <tr>
              <td>01</td>
              <td>dubai princess plus size</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>8</td>
              <td>8</td>
              <td>2</td>
              <td>3</td>
              <td>123</td>
              <td>39.2M</td>
              <td>34.9 天</td>
              <td>21.8 小时</td>
            </tr>
            
            <tr>
              <td>02</td>
              <td>beauty slaying ethnic fashion</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>6</td>
              <td>6</td>
              <td>1</td>
              <td>2</td>
              <td>94</td>
              <td>36.9M</td>
              <td>34.9 天</td>
              <td>24.0 小时</td>
            </tr>
            
            <tr>
              <td>03</td>
              <td>here</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>7</td>
              <td>7</td>
              <td>4</td>
              <td>6</td>
              <td>105</td>
              <td>21.7M</td>
              <td>561.7 天</td>
              <td>4.6 小时</td>
            </tr>
            
            <tr>
              <td>04</td>
              <td>part</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>5</td>
              <td>5</td>
              <td>2</td>
              <td>4</td>
              <td>76</td>
              <td>21.0M</td>
              <td>2136.3 天</td>
              <td>1.3 小时</td>
            </tr>
            
            <tr>
              <td>05</td>
              <td>bio</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>16</td>
              <td>16</td>
              <td>6</td>
              <td>13</td>
              <td>225</td>
              <td>20.9M</td>
              <td>2136.3 天</td>
              <td>0.5 小时</td>
            </tr>
            
            <tr>
              <td>06</td>
              <td>outfit</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>7</td>
              <td>7</td>
              <td>4</td>
              <td>5</td>
              <td>104</td>
              <td>20.8M</td>
              <td>765.4 天</td>
              <td>1.9 小时</td>
            </tr>
            
            <tr>
              <td>07</td>
              <td>the day</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>4</td>
              <td>4</td>
              <td>1</td>
              <td>2</td>
              <td>62</td>
              <td>20.7M</td>
              <td>765.4 天</td>
              <td>10.3 小时</td>
            </tr>
            
            <tr>
              <td>08</td>
              <td>mom wanted</td>
              <td><span class="status status-warm">已冷却</span></td>
              <td>1</td>
              <td>1</td>
              <td>0</td>
              <td>0</td>
              <td>17</td>
              <td>19.7M</td>
              <td>561.7 天</td>
              <td>561.7 天</td>
            </tr>
            </tbody>
      </table>
    </section>
    
      
    <section class="panel wide-panel">
      <div class="section-label">长期热门 TikTok 标签 / 扩散监控</div>
      <p class="panel-note">这里只放最早命中的样本视频已经超过一个月的长期信号。扩散状态不再依赖前序快照是否命中，而是看当前快照内的视频年龄分布：近 7 天视频占比达到 10% 且最新视频在 7 天内才算“正在扩散”；最新视频在 7-30 天内为“成熟或持平”；最新视频超过 30 天为“已冷却”。覆盖分（video_count × log(1 + avg_score_per_video)）用于衡量一个词出现在多少视频中且每条都有基础互动。</p>
      <table>
        <thead><tr><th>#</th><th>名称</th><th>扩散状态</th><th>视频数</th><th>样本视频数</th><th>近24h视频</th><th>近7d视频</th><th>覆盖分</th><th>热度分</th><th>最早视频年龄</th><th>最新视频年龄</th></tr></thead>
        <tbody>
            <tr>
              <td>01</td>
              <td>outfitideas</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>21</td>
              <td>21</td>
              <td>8</td>
              <td>11</td>
              <td>309</td>
              <td>51.2M</td>
              <td>765.4 天</td>
              <td>0.5 小时</td>
            </tr>
            
            <tr>
              <td>02</td>
              <td>weddingguestdress</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>33</td>
              <td>33</td>
              <td>14</td>
              <td>21</td>
              <td>469</td>
              <td>49.2M</td>
              <td>1042.2 天</td>
              <td>1.4 小时</td>
            </tr>
            
            <tr>
              <td>03</td>
              <td>beautifulgirl</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>7</td>
              <td>7</td>
              <td>1</td>
              <td>2</td>
              <td>109</td>
              <td>38.8M</td>
              <td>34.9 天</td>
              <td>21.8 小时</td>
            </tr>
            
            <tr>
              <td>04</td>
              <td>amazonfinds</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>24</td>
              <td>24</td>
              <td>14</td>
              <td>20</td>
              <td>343</td>
              <td>38.7M</td>
              <td>2263.5 天</td>
              <td>0.5 小时</td>
            </tr>
            
            <tr>
              <td>05</td>
              <td>outfitinspo</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>41</td>
              <td>41</td>
              <td>19</td>
              <td>32</td>
              <td>563</td>
              <td>37.5M</td>
              <td>765.4 天</td>
              <td>3.0 小时</td>
            </tr>
            
            <tr>
              <td>06</td>
              <td>amazon</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>10</td>
              <td>10</td>
              <td>5</td>
              <td>6</td>
              <td>150</td>
              <td>33.2M</td>
              <td>2263.5 天</td>
              <td>0.5 小时</td>
            </tr>
            
            <tr>
              <td>07</td>
              <td>ootd</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>54</td>
              <td>54</td>
              <td>26</td>
              <td>40</td>
              <td>717</td>
              <td>31.4M</td>
              <td>765.4 天</td>
              <td>1.0 小时</td>
            </tr>
            
            <tr>
              <td>08</td>
              <td>amazonfashion</td>
              <td><span class="status status-warm">正在扩散</span></td>
              <td>17</td>
              <td>17</td>
              <td>1</td>
              <td>12</td>
              <td>245</td>
              <td>31.1M</td>
              <td>2136.3 天</td>
              <td>5.8 小时</td>
            </tr>
            </tbody>
      </table>
    </section>
    
    </section>

    <section class="lower-grid">
      
    <section class="panel">
      <div class="section-label">热门音乐</div>
      <p class="panel-note">热度分 = 使用该音乐的样本视频热度分累加。单条视频热度分按播放量、点赞、评论、分享和收藏加权计算，因此这里更偏累计热度，不等同于当前增速。</p>
      <table>
        <thead><tr><th>#</th><th>名称</th><th>视频数</th><th>播放量</th><th>热度分</th></tr></thead>
        <tbody>
            <tr>
              <td>01</td>
              <td>original sound - sakeena097</td>
              <td>1</td>
              <td>19.2M</td>
              <td>24.3M</td>
            </tr>
            
            <tr>
              <td>02</td>
              <td>original sound - augustc0ast</td>
              <td>1</td>
              <td>6.2M</td>
              <td>19.7M</td>
            </tr>
            
            <tr>
              <td>03</td>
              <td>original sound - annamoraan</td>
              <td>1</td>
              <td>4.6M</td>
              <td>19.0M</td>
            </tr>
            
            <tr>
              <td>04</td>
              <td>original sound - 9anei</td>
              <td>1</td>
              <td>6.5M</td>
              <td>18.8M</td>
            </tr>
            
            <tr>
              <td>05</td>
              <td>original sound - wyn_cutie</td>
              <td>1</td>
              <td>5.7M</td>
              <td>16.5M</td>
            </tr>
            
            <tr>
              <td>06</td>
              <td>Doo wop</td>
              <td>1</td>
              <td>7.5M</td>
              <td>15.1M</td>
            </tr>
            
            <tr>
              <td>07</td>
              <td>original sound - itsmilla.com</td>
              <td>1</td>
              <td>7.1M</td>
              <td>10.6M</td>
            </tr>
            
            <tr>
              <td>08</td>
              <td>Tadow</td>
              <td>1</td>
              <td>4.1M</td>
              <td>10.5M</td>
            </tr>
            </tbody>
      </table>
    </section>
    
      
    <section class="panel">
      <div class="section-label">热门达人</div>
      <p class="panel-note">热度分 = 该达人在样本中命中的视频热度分累加。单条视频热度分按播放量、点赞、评论、分享和收藏加权计算，因此这里更偏累计热度，不等同于当前增速。</p>
      <table>
        <thead><tr><th>#</th><th>名称</th><th>视频数</th><th>播放量</th><th>热度分</th></tr></thead>
        <tbody>
            <tr>
              <td>01</td>
              <td>sakeena097</td>
              <td>1</td>
              <td>19.2M</td>
              <td>24.3M</td>
            </tr>
            
            <tr>
              <td>02</td>
              <td>lisa.rdna</td>
              <td>1</td>
              <td>6.2M</td>
              <td>19.7M</td>
            </tr>
            
            <tr>
              <td>03</td>
              <td>annamoraan</td>
              <td>1</td>
              <td>4.6M</td>
              <td>19.0M</td>
            </tr>
            
            <tr>
              <td>04</td>
              <td>9anei</td>
              <td>1</td>
              <td>6.5M</td>
              <td>18.8M</td>
            </tr>
            
            <tr>
              <td>05</td>
              <td>celine_jea</td>
              <td>1</td>
              <td>5.7M</td>
              <td>16.5M</td>
            </tr>
            
            <tr>
              <td>06</td>
              <td>marjoriekiim</td>
              <td>1</td>
              <td>7.5M</td>
              <td>15.1M</td>
            </tr>
            
            <tr>
              <td>07</td>
              <td>itsmilla.com</td>
              <td>1</td>
              <td>7.1M</td>
              <td>10.6M</td>
            </tr>
            
            <tr>
              <td>08</td>
              <td>shandarogers_</td>
              <td>1</td>
              <td>4.1M</td>
              <td>10.5M</td>
            </tr>
            </tbody>
      </table>
    </section>
    
    </section>

    <footer>
      生成时间 2026-05-07T08:35:48.282567+00:00 / 当前快照 2026-05-07T04:00:49+00:00 / 前序快照 2026-04-30T05:55:13+00:00
    </footer>
  </main>
</body>
</html>


path: metadata.json
content: {
  "name": "tiktok-hotspot-monitor",
  "version": "2.0.0",
  "description": "TikTok US women's fashion hotspot monitor — crawl, analyze, and generate HTML trend reports with cost-aware multi-window strategy",
  "repository": "https://github.com/TanDongTaotao/tiktok-hotspot-monitor",
  "author": "Claude",
  "tags": ["tiktok", "crawler", "scraper", "trend", "fashion", "analysis", "apify", "hotspot"],
  "languages": ["python"],
  "entry": "scripts/crawl_tiktok_hotspots.py",
  "config": "config/tiktok_hotspot_sources.json",
  "requirements": {
    "apify_mode": ["apify-client"],
    "local_mode": ["playwright"]
  },
  "env_vars": {
    "required": ["APIFY_TOKEN"],
    "optional": ["TIKTOK_PROXY"]
  },
  "providers": {
    "primary": "apify",
    "backup": "tiktok_mcp"
  },
  "capabilities": [
    "crawl_tiktok_video_metadata",
    "multi_window_most_liked_first",
    "hotspot_analysis",
    "html_report_generation",
    "cost_aware_crawl_strategy",
    "coverage_scoring",
    "cross_bucket_novelty_detection",
    "staged_validation_workflow"
  ],
  "pipeline": [
    {"step": "crawl", "script": "scripts/crawl_tiktok_hotspots.py", "output": "snapshot.jsonl"},
    {"step": "analyze", "script": "scripts/analyze_tiktok_hotspots.py", "output": "analysis.json"},
    {"step": "render", "script": "scripts/render_tiktok_hotspot_report.py", "output": "report.html"}
  ],
  "states": ["IDLE", "CONFIG_LOAD", "CRAWL_PLAN", "CRAWL_EXECUTE", "SNAPSHOT_WRITTEN", "ANALYZE", "REPORT_GENERATE", "COMPLETE", "ERROR"],
  "guardrails": {
    "max_sources": 50,
    "max_limit_per_source": 500,
    "max_requested_total": 5000,
    "max_planned_runs": 250,
    "crawl_timeout_minutes": 60,
    "retry_network": 2,
    "min_unique_videos": 10
  }
}


path: readme-en.md
content: <p align="center">
  <a href="README.md">中文</a> | <a href="readme-en.md">English</a>
</p>

# TikTok Hotspot Monitor Skill

TikTok hotspot monitoring Skill. Crawls TikTok video metadata via Apify Actor or Playwright local browser, performs offline trend analysis, and generates static HTML reports.

Source repository: https://github.com/TanDongTaotao/tiktok-hotspot-monitor

This Skill ships with a **US women's fashion keyword set** as a default example. You can customize it with any keywords, hashtags, creators, or music sources to monitor any domain you care about.

## Architecture

```
┌─────────────────────────────────────────────────────┐
│                   Agent Skill                        │
│  ┌─────────┐   ┌──────────┐   ┌──────────────────┐  │
│  │  Crawl   │──▶│ Analyze  │──▶│  HTML Report     │  │
│  │ Apify /  │   │ heat +   │   │  dark theme      │  │
│  │  MCP     │   │ coverage │   │                  │  │
│  └─────────┘   └──────────┘   └──────────────────┘  │
│        │              │                │             │
│        ▼              ▼                ▼             │
│  snapshot.jsonl  analysis.json    report.html        │
└─────────────────────────────────────────────────────┘
```

## 10-Layer Structure

This Skill follows the mature Agent Skill framework:

| Layer | Description | Location |
|-------|-------------|----------|
| 1. Scope | What it does / doesn't | `skill.md §1` |
| 2. Input Schema | Config, CLI, env formats | `skill.md §2` |
| 3. Output Schema | JSONL, JSON, HTML contracts | `skill.md §3` |
| 4. Tools | Script call rules & prohibitions | `skill.md §4` |
| 5. State Machine | Crawl→Analyze→Report flow | `skill.md §5` |
| 6. Error Recovery | Per-failure recovery steps | `skill.md §6` |
| 7. Planning | Task decomposition & decisions | `skill.md §7` |
| 8. Guardrails | Cost, time, rate, security limits | `skill.md §8` |
| 9. Evaluation | Success/failure thresholds | `skill.md §9` |
| 10. Composability | File contracts for chaining | `skill.md §10` |

## Quick Start

### Apify Mode (Primary)

```bash
# 1. Install dependencies
pip install apify-client

# 2. Set Apify API Token
echo "APIFY_TOKEN=your_token_here" > .env

# 3. Crawl (10 sources × window weights)
python scripts/crawl_tiktok_hotspots.py --once

# 4. Analyze
python scripts/analyze_tiktok_hotspots.py

# 5. Generate report
python scripts/render_tiktok_hotspot_report.py
```

### Local Crawl (Playwright MCP Backup)

When no Apify token is available or for small-scale testing:

```bash
# 1. Install dependencies
pip install playwright
playwright install chromium

# 2. Save TikTok session (manual QR login required)
python scripts/tiktok_login_save_session.py

# 3. Switch config provider type to "tiktok_mcp"
# Edit config/tiktok_hotspot_sources.json

# 4. Local crawl (~12 items per keyword, TikTok web limit)
python scripts/crawl_tiktok_hotspots.py --once --max-sources 2
```

**MCP limitations:**
- ~12 items per keyword (TikTok web page cap)
- Only supports `keyword` and `hashtag` source types
- Requires pre-saved TikTok login session
- No window weight allocation
- Best for small validation; use Apify for > 200 records

## File Structure

```
skills/tiktok-hotspot-monitor/
├── skill.md               # Agent instructions (10 layers)
├── README.md              # Chinese documentation
├── readme-en.md           # English documentation (this file)
├── metadata.json          # Machine-readable metadata
├── LICENSE                # MIT license
├── requirements.txt       # Python dependencies
├── examples/
│   ├── report.html                    # Sample report
│   ├── report_preview.png             # Preview screenshot
│   ├── report_top.png                 # Top section screenshot
│   ├── report_mid.png                 # Middle section screenshot
│   └── report_bottom.png              # Bottom section screenshot
├── scripts/
│   ├── crawl_tiktok_hotspots.py          # Main crawler
│   ├── analyze_tiktok_hotspots.py        # Offline analyzer
│   ├── render_tiktok_hotspot_report.py   # HTML report generator
│   ├── tiktok_search_mcp_adapter.py      # Playwright adapter
│   └── tiktok_login_save_session.py      # Session setup
└── config/
    ├── tiktok_hotspot_sources.json       # Main config
    ├── _tiktok_hotspot_apify_500_config.json  # 500-record validation config
    └── .env.example                      # Env template
```

## Report Preview

![Report Overview](examples/report_top.png)
![Signal Analysis](examples/report_mid.png)
![Long-term Terms & Rankings](examples/report_bottom.png)

Full sample report: [examples/report.html](examples/report.html)

## Output Directory

All outputs go under the project `data/` directory:

```
data/tiktok_hotspots/
  snapshots/tiktok_hotspots_<ts>.jsonl    # Raw records
  logs/tiktok_hotspots_<ts>.jsonl         # Run logs + round summary
data/tiktok_hotspot_analysis/
  tiktok_hotspot_analysis_<ts>.json       # Analysis results
  tiktok_hotspot_report_<ts>.html         # Dark-themed report
```

## Integration

Other agents consume outputs via file paths:

```python
import json
report = json.load(open("data/tiktok_hotspot_analysis/latest_analysis.json"))
top_signals = report["top_videos"][:5]
```

## Version History

- 1.0.0: Initial release. Apify Actor crawling (5-window Most-liked-first strategy), Playwright MCP local validation, offline analysis (heat score / coverage score / novelty detection), static HTML report generation.

## License

MIT License. See [LICENSE](LICENSE) for details.


path: README.md
content: <p align="center">
  <a href="README.md">中文</a> | <a href="readme-en.md">English</a>
</p>

# TikTok Hotspot Monitor Skill

TikTok 热点监控 Skill。通过 Apify Actor 或 Playwright 本地抓取 TikTok 视频 metadata，离线分析热点信号并生成静态 HTML 报告。

源仓库：https://github.com/TanDongTaotao/tiktok-hotspot-monitor

本 Skill 默认配置了一套**美区女装关键词**作为示例，你也可以自定义任意关键词、话题标签、达人、音乐源，监控任何领域的热点。

## 架构

```
┌─────────────────────────────────────────────────────┐
│                   Agent Skill                        │
│  ┌─────────┐   ┌──────────┐   ┌──────────────────┐  │
│  │  抓取   │──▶│  分析    │──▶│  HTML 报告       │  │
│  │ Apify / │   │ 热度 +   │   │  静态深色主题    │  │
│  │  MCP    │   │ 覆盖度   │   │                  │  │
│  └─────────┘   └──────────┘   └──────────────────┘  │
│        │              │                │             │
│        ▼              ▼                ▼             │
│  snapshot.jsonl  analysis.json    report.html        │
└─────────────────────────────────────────────────────┘
```

## 10 层结构

本 Skill 遵循成熟 Agent Skill 框架设计：

| 层 | 说明 | 实现位置 |
|----|------|----------|
| 1. 任务边界 | 负责什么 / 不负责什么 | `skill.md §1` |
| 2. 输入契约 | 配置、CLI、环境变量格式 | `skill.md §2` |
| 3. 输出契约 | JSONL、JSON、HTML 格式定义 | `skill.md §3` |
| 4. 工具定义 | 脚本调用规则与禁止场景 | `skill.md §4` |
| 5. 状态机 | 抓取→分析→报告流转 | `skill.md §5` |
| 6. 错误恢复 | 每种失败的处理步骤 | `skill.md §6` |
| 7. 规划逻辑 | 任务拆解与决策路径 | `skill.md §7` |
| 8. 约束系统 | 成本/时间/频率/安全限制 | `skill.md §8` |
| 9. 评估机制 | 各阶段成功/失败阈值 | `skill.md §9` |
| 10. 可组合性 | 文件级契约与其他 Skill 集成 | `skill.md §10` |

## 快速开始

### Apify 模式（主力）

```bash
# 1. 安装依赖
pip install apify-client

# 2. 设置 Apify API Token
echo "APIFY_TOKEN=你的token" > .env

# 3. 抓取（10 源 × 窗口权重分配）
python scripts/crawl_tiktok_hotspots.py --once

# 4. 分析
python scripts/analyze_tiktok_hotspots.py

# 5. 生成报告
python scripts/render_tiktok_hotspot_report.py
```

### 本地抓取（Playwright MCP 备用模式）

当 Apify Token 不可用或只想做小规模测试时，可以使用本地 Playwright 模式：

```bash
# 1. 安装依赖
pip install playwright
playwright install chromium

# 2. 保存 TikTok 登录态（需要手动扫码登录）
python scripts/tiktok_login_save_session.py

# 3. 切换配置中的 provider 类型
# 编辑 config/tiktok_hotspot_sources.json，将 provider.type 改为 "tiktok_mcp"
# 确保 tiktok_mcp.args 指向正确的适配器脚本

# 4. 本地抓取（每关键词约 12 条，TikTok 网页搜索上限）
python scripts/crawl_tiktok_hotspots.py --once --max-sources 2
```

**MCP 配置示例：**
```json
{
  "provider": {
    "type": "tiktok_mcp"
  },
  "tiktok_mcp": {
    "command": "python",
    "args": ["scripts/tiktok_search_mcp_adapter.py"],
    "timeout_seconds": 120,
    "reject_simulated": true,
    "env": {}
  }
}
```

**MCP 模式限制：**
- 每关键词最多返回 12 条视频（TikTok 网页端限制）
- 仅支持 `keyword` 和 `hashtag` 源类型，不支持 `creator` 和 `music`
- 需预先保存 TikTok 登录态（session 有效期不定）
- 无窗口权重分配（每次搜索直接使用完整 limit）
- 适合小规模验证，>200 条建议使用 Apify 模式

## 文件结构

```
skills/tiktok-hotspot-monitor/
├── skill.md               # Agent 指令（10 层完整定义）
├── README.md              # 本文档
├── metadata.json          # 机器可读元数据
├── requirements.txt       # Python 依赖
├── examples/
│   ├── report.html                    # 完整示例报告
│   ├── report_preview.png             # 概览截图
│   ├── report_top.png                 # 顶部截图
│   ├── report_mid.png                 # 中部截图
│   └── report_bottom.png              # 底部截图
├── scripts/
│   ├── crawl_tiktok_hotspots.py          # 主爬虫
│   ├── analyze_tiktok_hotspots.py        # 离线分析器
│   ├── render_tiktok_hotspot_report.py   # HTML 报告生成
│   ├── tiktok_search_mcp_adapter.py      # Playwright 本地适配器
│   └── tiktok_login_save_session.py      # 登录态保存
└── config/
    ├── tiktok_hotspot_sources.json       # 主配置
    ├── _tiktok_hotspot_apify_500_config.json  # 500 条验证配置
    └── .env.example                      # 环境变量模板
```

## 报告预览

![报告概览](examples/report_top.png)
![信号分析](examples/report_mid.png)
![长期热词与排行](examples/report_bottom.png)

完整示例报告：[examples/report.html](examples/report.html)

## 输出目录

所有输出在项目 `data/` 目录下：

```
data/tiktok_hotspots/
  snapshots/tiktok_hotspots_<时间戳>.jsonl    # 原始记录
  logs/tiktok_hotspots_<时间戳>.jsonl         # 运行日志 + 轮次摘要
data/tiktok_hotspot_analysis/
  tiktok_hotspot_analysis_<时间戳>.json       # 分析结果
  tiktok_hotspot_report_<时间戳>.html         # 深色主题报告
```

## 集成方式

其他 Agent 通过文件路径消费输出：

```python
import json
report = json.load(open("data/tiktok_hotspot_analysis/latest_analysis.json"))
top_signals = report["top_videos"][:5]
```

## 版本记录

- 1.0.0：初始版本。支持 Apify Actor 抓取（含 5 窗口高赞优先策略）和 Playwright MCP 本地验证，离线分析（热度分/覆盖分/新词检测），静态 HTML 报告生成。

## 开源协议

MIT License。详见项目根目录 [LICENSE](LICENSE) 文件。


path: requirements.txt
content: apify-client>=1.0.0
playwright>=1.40.0


path: scripts/analyze_tiktok_hotspots.py
content: from __future__ import annotations

import argparse
import json
import math
import re
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SNAPSHOT_GLOB = "data/tiktok_hotspots*/snapshots/*.jsonl"
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "data" / "tiktok_hotspot_analysis"
TEXT_TERM_PATTERN = re.compile(r"(?<![#＠@])\b([a-zA-Z][a-zA-Z0-9_&'-]{2,}(?:\s+[a-zA-Z][a-zA-Z0-9_&'-]{2,}){0,3})")
STOP_TERMS = {
    "and",
    "are",
    "but",
    "for",
    "from",
    "get",
    "got",
    "how",
    "not",
    "the",
    "this",
    "that",
    "with",
    "you",
    "your",
    "tiktok",
    "video",
    "videos",
    "fyp",
    "viral",
    "follow",
    "like",
    "comment",
    "share",
    "show",
    "girl",
    "girls",
    "woman",
    "women",
    "fashion",
}

RECENT_VIDEO_BUCKETS = [
    {"key": "within_1d", "label": "近一天", "min_hours": 0, "max_hours": 24},
    {"key": "within_3d", "label": "近三天", "min_hours": 24, "max_hours": 72},
    {"key": "within_7d", "label": "近七天", "min_hours": 72, "max_hours": 168},
    {"key": "within_14d", "label": "近两周", "min_hours": 168, "max_hours": 336},
]
RECENT_TERM_BUCKETS = [
    *RECENT_VIDEO_BUCKETS,
    {"key": "within_30d", "label": "近一个月", "min_hours": 336, "max_hours": 720},
]
RECENT_TERM_MAX_HOURS = 720
ESTABLISHED_ACTIVE_RATIO = 0.1  # 7d内视频占比 >= 10%才算扩散


def utc_timestamp() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Analyze TikTok hotspot metadata snapshots.")
    parser.add_argument("--snapshot", type=Path, default=None, help="JSONL snapshot to analyze. Defaults to latest non-empty snapshot.")
    parser.add_argument("--previous-snapshot", type=Path, default=None, help="Earlier JSONL snapshot for growth comparison. Defaults to latest non-empty snapshot before --snapshot.")
    parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
    parser.add_argument("--top", type=int, default=10, help="Number of ranked items per section.")
    parser.add_argument("--download", action="store_true", help="Download videos after analysis.")
    return parser.parse_args()


def resolve_path(path: Path) -> Path:
    return path if path.is_absolute() else PROJECT_ROOT / path


def non_empty_snapshots() -> list[Path]:
    return [path for path in PROJECT_ROOT.glob(DEFAULT_SNAPSHOT_GLOB) if path.stat().st_size > 0]


def has_video_records(path: Path) -> bool:
    with path.open("r", encoding="utf-8") as file:
        for line in file:
            try:
                item = json.loads(line)
            except json.JSONDecodeError:
                continue
            if isinstance(item, dict) and item.get("video_id"):
                return True
    return False


def valid_snapshots() -> list[Path]:
    return [path for path in non_empty_snapshots() if has_video_records(path)]


def latest_snapshot() -> Path:
    paths = sorted(valid_snapshots(), key=lambda path: path.stat().st_mtime, reverse=True)
    if paths:
        return paths[0]
    raise ValueError("No non-empty TikTok hotspot snapshot found")


def previous_snapshot_for(snapshot_path: Path) -> Path | None:
    current = snapshot_path.resolve()
    candidates = [path for path in valid_snapshots() if path.resolve() != current and path.stat().st_mtime < snapshot_path.stat().st_mtime]
    if not candidates:
        return None
    return sorted(candidates, key=lambda path: path.stat().st_mtime, reverse=True)[0]


def crawl_log_for_snapshot(snapshot_path: Path) -> Path | None:
    output_base = snapshot_path.parent.parent
    log_path = output_base / "logs" / snapshot_path.name
    return log_path if log_path.exists() else None


def load_crawl_metrics(snapshot_path: Path) -> dict[str, Any] | None:
    log_path = crawl_log_for_snapshot(snapshot_path)
    if not log_path:
        return None
    summary = None
    window_counts: dict[str, dict[str, Any]] = {}
    with log_path.open("r", encoding="utf-8") as file:
        for line in file:
            try:
                item = json.loads(line)
            except json.JSONDecodeError:
                continue
            if not isinstance(item, dict):
                continue
            if item.get("event") == "crawl_round_summary":
                summary = item
                continue
            window = str(item.get("crawl_window") or "default")
            bucket = window_counts.setdefault(
                window,
                {
                    "label": item.get("crawl_window_label") or window,
                    "planned_run_count": 0,
                    "completed_run_count": 0,
                    "failed_run_count": 0,
                    "requested_limit": 0,
                    "raw_record_count": 0,
                },
            )
            bucket["planned_run_count"] += 1
            bucket["requested_limit"] += int(number(item.get("crawl_window_limit")))
            bucket["raw_record_count"] += int(number(item.get("record_count")))
            bucket["failed_run_count" if item.get("status") == "failed" else "completed_run_count"] += 1
    if summary:
        summary.setdefault("windows", window_counts)
        summary["log_path"] = str(log_path)
        return summary
    if not window_counts:
        return None
    raw_count = sum(item["raw_record_count"] for item in window_counts.values())
    return {
        "log_path": str(log_path),
        "enabled_source_count": None,
        "crawl_window_count": len(window_counts),
        "planned_run_count": sum(item["planned_run_count"] for item in window_counts.values()),
        "completed_run_count": sum(item["completed_run_count"] for item in window_counts.values()),
        "failed_run_count": sum(item["failed_run_count"] for item in window_counts.values()),
        "requested_total_limit": sum(item["requested_limit"] for item in window_counts.values()),
        "raw_record_count": raw_count,
        "windows": window_counts,
        "cost_model_note": "Apify cost depends on Actor pricing, planned runs, returned results, compute duration, memory, proxy usage, retries, add-ons, and account plan; verify exact cost in the Apify usage dashboard.",
    }


def load_jsonl(path: Path) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    with path.open("r", encoding="utf-8") as file:
        for line_number, line in enumerate(file, start=1):
            stripped = line.strip()
            if not stripped:
                continue
            try:
                item = json.loads(stripped)
            except json.JSONDecodeError as exc:
                raise ValueError(f"Invalid JSON on line {line_number}: {exc}") from exc
            if isinstance(item, dict) and item.get("video_id"):
                rows.append(item)
    if not rows:
        raise ValueError(f"Snapshot has no video records: {path}")
    return rows


def number(value: Any) -> float:
    if isinstance(value, bool) or value is None:
        return 0.0
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str):
        try:
            return float(value.replace(",", ""))
        except ValueError:
            return 0.0
    return 0.0


def parse_datetime(value: Any) -> datetime | None:
    if isinstance(value, (int, float)):
        return datetime.fromtimestamp(float(value), tz=timezone.utc)
    if not isinstance(value, str) or not value.strip():
        return None
    text = value.strip()
    for fmt in ["%Y%m%dT%H%M%SZ", "%Y%m%dT%H%M%S%z"]:
        try:
            parsed = datetime.strptime(text, fmt)
            return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
        except ValueError:
            pass
    try:
        parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
    except ValueError:
        return None
    return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)


def snapshot_time(records: list[dict[str, Any]], path: Path) -> datetime:
    times = [parsed for record in records if (parsed := parse_datetime(record.get("crawl_timestamp")))]
    if times:
        return max(times)
    return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)


def hours_between(start: datetime | None, end: datetime | None) -> float | None:
    if not start or not end:
        return None
    return max((end - start).total_seconds() / 3600, 0.0)


def hot_score(record: dict[str, Any]) -> float:
    views = number(record.get("view_count"))
    likes = number(record.get("like_count"))
    comments = number(record.get("comment_count"))
    shares = number(record.get("share_count", record.get("repost_count")))
    collects = number(record.get("collect_count"))
    return views + likes * 10 + comments * 25 + shares * 35 + collects * 20


def engagement_rate(record: dict[str, Any]) -> float | None:
    views = number(record.get("view_count"))
    if views <= 0:
        return None
    interactions = (
        number(record.get("like_count"))
        + number(record.get("comment_count"))
        + number(record.get("share_count", record.get("repost_count")))
        + number(record.get("collect_count"))
    )
    return interactions / views


def growth_metrics(record: dict[str, Any], previous: dict[str, Any] | None, current_time: datetime, previous_time: datetime | None) -> dict[str, Any]:
    upload_time = parse_datetime(record.get("upload_date") or record.get("timestamp"))
    video_age_hours = hours_between(upload_time, current_time)
    freshness_score = None if video_age_hours is None else round(max(0.0, 1.0 - video_age_hours / (14 * 24)), 4)
    interval_hours = hours_between(previous_time, current_time)
    age_bucket = age_bucket_for(video_age_hours, RECENT_VIDEO_BUCKETS)

    metrics: dict[str, Any] = {
        "previous_seen": previous is not None,
        "video_age_hours": None if video_age_hours is None else round(video_age_hours, 2),
        "age_bucket": age_bucket,
        "age_bucket_label": age_bucket_label(age_bucket, RECENT_VIDEO_BUCKETS),
        "freshness_score": freshness_score,
        "interval_hours": None if interval_hours is None else round(interval_hours, 2),
        "views_delta": None,
        "likes_delta": None,
        "comments_delta": None,
        "shares_delta": None,
        "collects_delta": None,
        "growth_score": None,
        "growth_score_per_hour": None,
        "trend_score": None,
        "timeliness_label": "age_unknown" if video_age_hours is None else (age_bucket or "older_than_14d"),
    }
    if previous is None or not interval_hours or interval_hours <= 0:
        return metrics

    views_delta = number(record.get("view_count")) - number(previous.get("view_count"))
    likes_delta = number(record.get("like_count")) - number(previous.get("like_count"))
    comments_delta = number(record.get("comment_count")) - number(previous.get("comment_count"))
    shares_delta = number(record.get("share_count", record.get("repost_count"))) - number(previous.get("share_count", previous.get("repost_count")))
    collects_delta = number(record.get("collect_count")) - number(previous.get("collect_count"))
    score = views_delta + likes_delta * 10 + comments_delta * 25 + shares_delta * 35 + collects_delta * 20
    score_per_hour = score / interval_hours
    trend_score = score_per_hour * (1 + (freshness_score or 0.0))

    metrics.update(
        {
            "views_delta": int(views_delta),
            "likes_delta": int(likes_delta),
            "comments_delta": int(comments_delta),
            "shares_delta": int(shares_delta),
            "collects_delta": int(collects_delta),
            "growth_score": round(score, 2),
            "growth_score_per_hour": round(score_per_hour, 2),
            "trend_score": round(trend_score, 2),
        }
    )
    return metrics



def age_bucket_for(age_hours: float | None, buckets: list[dict[str, Any]]) -> str | None:
    if age_hours is None:
        return None
    for bucket in buckets:
        if float(bucket["min_hours"]) <= age_hours <= float(bucket["max_hours"]):
            return str(bucket["key"])
    return None


def age_bucket_label(key: str | None, buckets: list[dict[str, Any]]) -> str | None:
    for bucket in buckets:
        if bucket["key"] == key:
            return str(bucket["label"])
    return None


def video_age_hours(record: dict[str, Any], current_time: datetime) -> float | None:
    return hours_between(parse_datetime(record.get("upload_date") or record.get("timestamp")), current_time)


def rank_recent_videos_by_age(records: list[dict[str, Any]], previous_by_id: dict[str, dict[str, Any]], current_time: datetime, previous_time: datetime | None, limit: int = 10) -> list[dict[str, Any]]:
    grouped: list[dict[str, Any]] = []
    for bucket in RECENT_VIDEO_BUCKETS:
        bucket_records = [
            record
            for record in records
            if (age := video_age_hours(record, current_time)) is not None
            and float(bucket["min_hours"]) <= age <= float(bucket["max_hours"])
        ]
        top_records = sorted(bucket_records, key=hot_score, reverse=True)[:limit]
        grouped.append(
            {
                "key": bucket["key"],
                "label": bucket["label"],
                "min_hours": bucket["min_hours"],
                "max_hours": bucket["max_hours"],
                "items": [short_video(record, previous_by_id.get(str(record.get("video_id"))), current_time, previous_time) for record in top_records],
            }
        )
    return grouped


def short_video(record: dict[str, Any], previous: dict[str, Any] | None, current_time: datetime, previous_time: datetime | None) -> dict[str, Any]:
    return {
        "video_id": record.get("video_id"),
        "url": record.get("webpage_url"),
        "title": record.get("title"),
        "creator": record.get("uploader"),
        "source_type": record.get("source_type"),
        "source_value": record.get("source_value"),
        "upload_date": record.get("upload_date"),
        "view_count": int(number(record.get("view_count"))),
        "like_count": int(number(record.get("like_count"))),
        "comment_count": int(number(record.get("comment_count"))),
        "share_count": int(number(record.get("share_count", record.get("repost_count")))),
        "collect_count": int(number(record.get("collect_count"))),
        "engagement_rate": engagement_rate(record),
        "hot_score": round(hot_score(record), 2),
        "growth": growth_metrics(record, previous, current_time, previous_time),
        "hashtags": record.get("hashtags") or [],
        "music": record.get("music") or {},
    }


def analyze(records: list[dict[str, Any]], snapshot_path: Path, top: int, previous_records: list[dict[str, Any]] | None = None, previous_snapshot_path: Path | None = None) -> dict[str, Any]:
    unique_records = dedupe_records(records)
    previous_unique_records = dedupe_records(previous_records or [])
    previous_by_id = {str(record.get("video_id")): record for record in previous_unique_records}
    current_time = snapshot_time(records, snapshot_path)
    previous_time = snapshot_time(previous_records, previous_snapshot_path) if previous_records and previous_snapshot_path else None
    crawl_metrics = load_crawl_metrics(snapshot_path) or {}
    duplicate_rate = 0.0 if not records else round(1 - len(unique_records) / len(records), 4)
    crawl_metrics["raw_record_count"] = int(crawl_metrics.get("raw_record_count") or len(records))
    crawl_metrics["unique_video_count"] = len(unique_records)
    crawl_metrics["duplicate_rate"] = duplicate_rate
    if crawl_metrics.get("requested_total_limit"):
        crawl_metrics["effective_unique_yield"] = round(len(unique_records) / float(crawl_metrics["requested_total_limit"]), 4)
    rising_candidates = [record for record in unique_records if previous_by_id.get(str(record.get("video_id")))]
    top_videos = sorted(unique_records, key=hot_score, reverse=True)[:top]
    top_rising_videos = sorted(
        rising_candidates,
        key=lambda record: growth_metrics(record, previous_by_id.get(str(record.get("video_id"))), current_time, previous_time).get("trend_score") or 0,
        reverse=True,
    )[:top]
    report = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "snapshot_path": str(snapshot_path),
        "previous_snapshot_path": str(previous_snapshot_path) if previous_snapshot_path else None,
        "analysis_window": {
            "current_snapshot_time": current_time.isoformat(),
            "previous_snapshot_time": previous_time.isoformat() if previous_time else None,
            "interval_hours": hours_between(previous_time, current_time),
            "matched_previous_video_count": len(rising_candidates),
        },
        "record_count": len(records),
        "unique_video_count": len(unique_records),
        "source_counts": dict(Counter(record.get("source_type") for record in unique_records)),
        "top_videos": [short_video(record, previous_by_id.get(str(record.get("video_id"))), current_time, previous_time) for record in top_videos],
        "recent_videos_by_age": rank_recent_videos_by_age(unique_records, previous_by_id, current_time, previous_time, limit=top),
        "top_rising_videos": [short_video(record, previous_by_id.get(str(record.get("video_id"))), current_time, previous_time) for record in top_rising_videos],
        "top_hashtags": rank_hashtags(unique_records, previous_unique_records, current_time, top),
        "recent_signals_by_age": rank_recent_signals_by_age(unique_records, current_time, limit=top),
        "recent_terms_by_age": rank_recent_terms_by_age(unique_records, current_time),
        "recent_hashtags_by_age": rank_recent_hashtags_by_age(unique_records, current_time),
        "established_terms": rank_established_terms(unique_records, current_time, top),
        "established_hashtags": rank_established_hashtags(unique_records, current_time, top),
        "top_music": rank_music(unique_records, top),
        "top_creators": rank_creators(unique_records, top),
        "candidate_signals": build_candidate_signals(top_videos[:5]),
        "crawl_metrics": crawl_metrics,
    }
    return report


def dedupe_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
    by_id: dict[str, dict[str, Any]] = {}
    for record in records:
        video_id = str(record.get("video_id") or "")
        existing = by_id.get(video_id)
        if not existing or hot_score(record) > hot_score(existing):
            by_id[video_id] = record
    return list(by_id.values())


def normalize_term(value: Any) -> str:
    term = str(value or "").lower().strip().lstrip("#@＠")
    term = re.sub(r"\s+", " ", term)
    return term.strip(" .,;:!?()[]{}\"'")


def extract_hashtag_terms(record: dict[str, Any]) -> set[str]:
    terms: set[str] = set()
    for hashtag in record.get("hashtags") or []:
        term = normalize_term(hashtag)
        if is_useful_term(term):
            terms.add(term)
    return terms


def extract_content_terms(record: dict[str, Any]) -> set[str]:
    terms: set[str] = set()
    for field in ["title", "description"]:
        value = record.get(field)
        if not isinstance(value, str):
            continue
        for match in TEXT_TERM_PATTERN.finditer(value):
            term = normalize_term(match.group(1))
            if is_useful_term(term):
                terms.add(term)
    return terms


def extract_terms(record: dict[str, Any]) -> set[str]:
    return extract_content_terms(record) | extract_hashtag_terms(record)


def is_useful_term(term: str) -> bool:
    if len(term) < 3 or term in STOP_TERMS:
        return False
    words = term.split()
    if len(words) > 4:
        return False
    if all(word in STOP_TERMS for word in words):
        return False
    if re.fullmatch(r"u[a-f0-9]{3,}", term, re.IGNORECASE):
        return False
    if all(re.fullmatch(r"u[a-f0-9]{3,}", w, re.IGNORECASE) for w in words if len(w) > 2):
        return False
    return True


def rank_hashtags(records: list[dict[str, Any]], previous_records: list[dict[str, Any]], current_time: datetime, top: int) -> list[dict[str, Any]]:
    stats: dict[str, dict[str, Any]] = defaultdict(lambda: {"video_count": 0, "views": 0.0, "likes": 0.0, "comments": 0.0, "shares": 0.0, "score": 0.0, "example_urls": [], "newest_video_age_hours": None, "oldest_video_age_hours": None})
    previous_hashtags = set()
    for record in previous_records:
        for hashtag in record.get("hashtags") or []:
            key = normalize_term(hashtag)
            if key:
                previous_hashtags.add(key)

    for record in records:
        upload_time = parse_datetime(record.get("upload_date") or record.get("timestamp"))
        video_age_hours = hours_between(upload_time, current_time)
        for hashtag in record.get("hashtags") or []:
            key = normalize_term(hashtag)
            if not key:
                continue
            add_aggregate(stats[key], record)
            if video_age_hours is not None:
                newest = stats[key]["newest_video_age_hours"]
                oldest = stats[key]["oldest_video_age_hours"]
                stats[key]["newest_video_age_hours"] = video_age_hours if newest is None else min(newest, video_age_hours)
                stats[key]["oldest_video_age_hours"] = video_age_hours if oldest is None else max(oldest, video_age_hours)

    ranked = []
    for item in finalize_ranked(stats, len(stats)):
        oldest_age = item.get("oldest_video_age_hours")
        if oldest_age is not None and float(oldest_age) <= RECENT_TERM_MAX_HOURS:
            continue
        item["previous_seen"] = item["name"] in previous_hashtags
        item["timeliness_label"] = "established_still_hot" if item["previous_seen"] else "established_first_seen"
        for field in ["newest_video_age_hours", "oldest_video_age_hours"]:
            if item.get(field) is not None:
                item[field] = round(float(item[field]), 2)
        ranked.append(item)
    return ranked[:top]



def hashtag_timeliness_label(item: dict[str, Any], previous_seen: bool) -> str:
    oldest_age = item.get("oldest_video_age_hours")
    if previous_seen:
        return "established_still_hot"
    if oldest_age is None:
        return "new_without_upload_time"
    if oldest_age <= 24:
        return "new_within_1d"
    if oldest_age <= 72:
        return "new_within_3d"
    if oldest_age <= 168:
        return "new_within_7d"
    return "established_first_seen"


def empty_term_bucket() -> dict[str, Any]:
    return {"video_count": 0, "views": 0.0, "likes": 0.0, "comments": 0.0, "shares": 0.0, "score": 0.0, "example_urls": [], "newest_video_age_hours": None, "oldest_video_age_hours": None, "video_ids": set(), "video_ages_by_id": {}}


def term_stats(records: list[dict[str, Any]], current_time: datetime | None = None, extractor: Any = extract_terms) -> dict[str, dict[str, Any]]:
    stats: dict[str, dict[str, Any]] = defaultdict(empty_term_bucket)
    for record in records:
        video_id = str(record.get("video_id") or "")
        upload_time = parse_datetime(record.get("upload_date") or record.get("timestamp"))
        video_age_hours = hours_between(upload_time, current_time) if current_time else None
        for term in extractor(record):
            add_aggregate(stats[term], record)
            if video_id:
                stats[term]["video_ids"].add(video_id)
                if video_age_hours is not None:
                    stats[term]["video_ages_by_id"][video_id] = video_age_hours
            if video_age_hours is not None:
                newest = stats[term]["newest_video_age_hours"]
                oldest = stats[term]["oldest_video_age_hours"]
                stats[term]["newest_video_age_hours"] = video_age_hours if newest is None else min(newest, video_age_hours)
                stats[term]["oldest_video_age_hours"] = video_age_hours if oldest is None else max(oldest, video_age_hours)
    return stats


def recent_delta_counts(current: dict[str, Any], previous: dict[str, Any] | None) -> dict[str, int]:
    previous_video_ids = previous.get("video_ids", set()) if previous else set()
    current_video_ids = current.get("video_ids", set())
    new_video_ids = current_video_ids - previous_video_ids
    ages_by_id = current.get("video_ages_by_id") if isinstance(current.get("video_ages_by_id"), dict) else {}
    recent_24h = 0
    recent_7d = 0
    for video_id in new_video_ids:
        age = ages_by_id.get(video_id)
        if age is None:
            continue
        if float(age) <= 24:
            recent_24h += 1
        if float(age) <= 168:
            recent_7d += 1
    return {"sample_delta": len(new_video_ids), "recent_24h_delta": recent_24h, "recent_7d_delta": recent_7d}


def term_item(name: str, current: dict[str, Any], previous: dict[str, Any] | None = None) -> dict[str, Any]:
    previous_video_count = int(previous["video_count"]) if previous else 0
    previous_views = float(previous["views"]) if previous else 0.0
    previous_score = float(previous["score"]) if previous else 0.0
    video_count_delta = int(current["video_count"] - previous_video_count)
    views_delta = float(current["views"] - previous_views)
    score_delta = float(current["score"] - previous_score)
    deltas = recent_delta_counts(current, previous)
    sample_video_delta = deltas["sample_delta"]
    recent_24h_video_delta = deltas["recent_24h_delta"]
    recent_7d_video_delta = deltas["recent_7d_delta"]
    if previous is None:
        status = "new_emerging" if current["video_count"] >= 2 else "single_signal"
    elif score_delta > 0 and recent_7d_video_delta > 0:
        status = "spreading"
    else:
        status = "mature_or_flat"
    emergence_score = max(score_delta, 0.0) + max(recent_7d_video_delta, 0) * 100_000 + (0 if previous else current["score"] * 0.25)
    avg_spv = current["score"] / max(int(current["video_count"]), 1)
    coverage_score = int(current["video_count"]) * math.log1p(avg_spv)
    return {
        "name": name,
        "status": status,
        "video_count": int(current["video_count"]),
        "previous_video_count": previous_video_count,
        "video_count_delta": video_count_delta,
        "sample_video_delta": sample_video_delta,
        "recent_24h_video_delta": recent_24h_video_delta,
        "recent_7d_video_delta": recent_7d_video_delta,
        "views": round(current["views"], 2),
        "previous_views": round(previous_views, 2),
        "views_delta": round(views_delta, 2),
        "score": round(current["score"], 2),
        "coverage_score": round(coverage_score, 2),
        "score_delta": round(score_delta, 2),
        "emergence_score": round(emergence_score, 2),
        "newest_video_age_hours": None if current.get("newest_video_age_hours") is None else round(float(current["newest_video_age_hours"]), 2),
        "oldest_video_age_hours": None if current.get("oldest_video_age_hours") is None else round(float(current["oldest_video_age_hours"]), 2),
        "example_urls": current["example_urls"],
    }


def aggregate_terms_by_age(records: list[dict[str, Any]], current_time: datetime, extractor: Any, limit: int) -> list[dict[str, Any]]:
    bucket_stats_list: list[dict[str, dict[str, Any]]] = []
    for _bucket in RECENT_TERM_BUCKETS:
        bucket_stats_list.append(
            defaultdict(lambda: {"video_count": 0, "views": 0.0, "likes": 0.0, "comments": 0.0, "shares": 0.0, "score": 0.0, "example_urls": [], "newest_video_age_hours": None, "oldest_video_age_hours": None})
        )

    for record in records:
        age = video_age_hours(record, current_time)
        if age is None:
            continue
        for idx, bucket in enumerate(RECENT_TERM_BUCKETS):
            if age < float(bucket["min_hours"]) or age > float(bucket["max_hours"]):
                continue
            for term in extractor(record):
                stats = bucket_stats_list[idx][term]
                add_aggregate(stats, record)
                newest = stats["newest_video_age_hours"]
                oldest = stats["oldest_video_age_hours"]
                stats["newest_video_age_hours"] = age if newest is None else min(newest, age)
                stats["oldest_video_age_hours"] = age if oldest is None else max(oldest, age)

    bucket_term_names: list[set[str]] = [set(stats.keys()) for stats in bucket_stats_list]

    grouped: list[dict[str, Any]] = []
    for idx, bucket in enumerate(RECENT_TERM_BUCKETS):
        older_term_names: set[str] = set()
        for j in range(idx + 1, len(bucket_term_names)):
            older_term_names |= bucket_term_names[j]

        items = [term_item(name, values) for name, values in bucket_stats_list[idx].items()]
        for item in items:
            item["novelty"] = "first_seen" if item["name"] not in older_term_names else "seen_in_older_buckets"
        heat_ranked = sorted(items, key=lambda item: (item["score"], item["video_count"]), reverse=True)[:limit]
        coverage_ranked = sorted(items, key=lambda item: (item.get("coverage_score", 0), item["video_count"]), reverse=True)[:limit]
        grouped.append(
            {
                "key": bucket["key"],
                "label": bucket["label"],
                "min_hours": bucket["min_hours"],
                "max_hours": bucket["max_hours"],
                "items": heat_ranked,
                "coverage_items": coverage_ranked,
            }
        )
    return grouped


def rank_recent_signals_by_age(records: list[dict[str, Any]], current_time: datetime, limit: int = 10) -> list[dict[str, Any]]:
    content_groups = aggregate_terms_by_age(records, current_time, extract_content_terms, limit)
    hashtag_groups = aggregate_terms_by_age(records, current_time, extract_hashtag_terms, limit)
    groups: list[dict[str, Any]] = []
    for content_group, hashtag_group in zip(content_groups, hashtag_groups):
        groups.append(
            {
                "key": content_group["key"],
                "label": content_group["label"],
                "min_hours": content_group["min_hours"],
                "max_hours": content_group["max_hours"],
                "content_terms": content_group["items"],
                "content_terms_coverage": content_group["coverage_items"],
                "hashtags": hashtag_group["items"],
                "hashtags_coverage": hashtag_group["coverage_items"],
            }
        )
    return groups


def rank_recent_terms_by_age(records: list[dict[str, Any]], current_time: datetime, limit: int = 5) -> list[dict[str, Any]]:
    return aggregate_terms_by_age(records, current_time, extract_content_terms, limit)


def rank_recent_hashtags_by_age(records: list[dict[str, Any]], current_time: datetime, limit: int = 5) -> list[dict[str, Any]]:
    return aggregate_terms_by_age(records, current_time, extract_hashtag_terms, limit)


def rank_established_terms(records: list[dict[str, Any]], current_time: datetime, top: int) -> list[dict[str, Any]]:
    current_stats = term_stats(records, current_time, extract_content_terms)
    ranked = []
    for name, current in current_stats.items():
        ages = [float(a) for a in (current.get("video_ages_by_id") or {}).values() if a is not None]
        if not ages:
            continue
        oldest = max(ages)
        newest = min(ages)
        if oldest <= RECENT_TERM_MAX_HOURS:
            continue
        recent_24h = sum(1 for a in ages if a <= 24)
        recent_7d = sum(1 for a in ages if a <= 168)
        vc = int(current["video_count"])
        status = "cooling"
        if newest <= 168 and (recent_7d / max(vc, 1)) >= ESTABLISHED_ACTIVE_RATIO:
            status = "spreading"
        elif newest <= 720:
            status = "mature_or_flat"
        avg_spv = current["score"] / max(vc, 1)
        coverage_score = int(current["video_count"]) * math.log1p(avg_spv)
        ranked.append({
            "name": name, "status": status,
            "video_count": int(current["video_count"]),
            "sample_video_delta": int(current["video_count"]),
            "recent_24h_video_delta": recent_24h, "recent_7d_video_delta": recent_7d,
            "score": round(current["score"], 2), "score_delta": round(current["score"], 2),
            "coverage_score": round(coverage_score, 2),
            "oldest_video_age_hours": round(oldest, 2), "newest_video_age_hours": round(newest, 2),
            "example_urls": current["example_urls"],
        })
    return sorted(ranked, key=lambda item: (item["score_delta"], item["score"], item["video_count"]), reverse=True)[:top]


def rank_established_hashtags(records: list[dict[str, Any]], current_time: datetime, top: int) -> list[dict[str, Any]]:
    current_stats = term_stats(records, current_time, extract_hashtag_terms)
    ranked = []
    for name, current in current_stats.items():
        ages = [float(a) for a in (current.get("video_ages_by_id") or {}).values() if a is not None]
        if not ages:
            continue
        oldest = max(ages)
        newest = min(ages)
        if oldest <= RECENT_TERM_MAX_HOURS:
            continue
        recent_24h = sum(1 for a in ages if a <= 24)
        recent_7d = sum(1 for a in ages if a <= 168)
        vc = int(current["video_count"])
        status = "cooling"
        if newest <= 168 and (recent_7d / max(vc, 1)) >= ESTABLISHED_ACTIVE_RATIO:
            status = "spreading"
        elif newest <= 720:
            status = "mature_or_flat"
        avg_spv = current["score"] / max(vc, 1)
        coverage_score = int(current["video_count"]) * math.log1p(avg_spv)
        ranked.append({
            "name": name, "status": status,
            "video_count": int(current["video_count"]),
            "sample_video_delta": int(current["video_count"]),
            "recent_24h_video_delta": recent_24h, "recent_7d_video_delta": recent_7d,
            "score": round(current["score"], 2), "score_delta": round(current["score"], 2),
            "coverage_score": round(coverage_score, 2),
            "oldest_video_age_hours": round(oldest, 2), "newest_video_age_hours": round(newest, 2),
            "example_urls": current["example_urls"],
        })
    return sorted(ranked, key=lambda item: (item["score_delta"], item["score"], item["video_count"]), reverse=True)[:top]

def rank_music(records: list[dict[str, Any]], top: int) -> list[dict[str, Any]]:
    stats: dict[str, dict[str, Any]] = defaultdict(lambda: {"video_count": 0, "views": 0.0, "likes": 0.0, "comments": 0.0, "shares": 0.0, "score": 0.0, "example_urls": [], "artist": None, "music_id": None})
    for record in records:
        music = record.get("music") if isinstance(record.get("music"), dict) else {}
        track = music.get("track") or music.get("id")
        if not track:
            continue
        key = str(track)
        add_aggregate(stats[key], record)
        stats[key]["artist"] = stats[key]["artist"] or music.get("artist")
        stats[key]["music_id"] = stats[key]["music_id"] or music.get("id")
    return finalize_ranked(stats, top)


def rank_creators(records: list[dict[str, Any]], top: int) -> list[dict[str, Any]]:
    stats: dict[str, dict[str, Any]] = defaultdict(lambda: {"video_count": 0, "views": 0.0, "likes": 0.0, "comments": 0.0, "shares": 0.0, "score": 0.0, "example_urls": [], "follower_count": None})
    for record in records:
        creator = record.get("uploader")
        if not creator:
            continue
        key = str(creator)
        add_aggregate(stats[key], record)
        stats[key]["follower_count"] = stats[key]["follower_count"] or record.get("uploader_follower_count")
    return finalize_ranked(stats, top)


def add_aggregate(bucket: dict[str, Any], record: dict[str, Any]) -> None:
    bucket["video_count"] += 1
    bucket["views"] += number(record.get("view_count"))
    bucket["likes"] += number(record.get("like_count"))
    bucket["comments"] += number(record.get("comment_count"))
    bucket["shares"] += number(record.get("share_count", record.get("repost_count")))
    bucket["score"] += hot_score(record)
    if record.get("webpage_url") and len(bucket["example_urls"]) < 3:
        bucket["example_urls"].append(record.get("webpage_url"))


def finalize_ranked(stats: dict[str, dict[str, Any]], top: int) -> list[dict[str, Any]]:
    ranked = []
    for name, values in stats.items():
        item = {"name": name, **values}
        for field in ["views", "likes", "comments", "shares", "score"]:
            item[field] = round(item[field], 2)
        vc = max(item.get("video_count", 0), 1)
        item["avg_score_per_video"] = round(item["score"] / vc, 2)
        item["coverage_score"] = round(vc * math.log1p(item["avg_score_per_video"]), 2)
        ranked.append(item)
    return sorted(ranked, key=lambda item: (item["score"], item["video_count"]), reverse=True)[:top]


def build_candidate_signals(records: list[dict[str, Any]]) -> list[str]:
    signals = []
    for record in records:
        title = str(record.get("title") or "").strip()
        creator = record.get("uploader") or "unknown"
        views = int(number(record.get("view_count")))
        likes = int(number(record.get("like_count")))
        url = record.get("webpage_url")
        if title:
            title = title[:120] + ("..." if len(title) > 120 else "")
        signals.append(f"{creator}: {views:,} views, {likes:,} likes — {title} ({url})")
    return signals


def write_report(report: dict[str, Any], output_dir: Path) -> Path:
    output_dir = output_dir if output_dir.is_absolute() else PROJECT_ROOT / output_dir
    output_dir.mkdir(parents=True, exist_ok=True)
    path = output_dir / f"tiktok_hotspot_analysis_{utc_timestamp()}.json"
    with path.open("w", encoding="utf-8") as file:
        json.dump(report, file, ensure_ascii=False, indent=2)
        file.write("\n")
    return path


def print_summary(report: dict[str, Any], output_path: Path) -> None:
    print(f"report: {output_path}")
    print(f"snapshot: {report['snapshot_path']}")
    print(f"previous_snapshot: {report['previous_snapshot_path']}")
    print(f"records: {report['record_count']} unique_videos: {report['unique_video_count']}")
    print(f"matched_previous_videos: {report['analysis_window']['matched_previous_video_count']}")
    print("\nTop videos by cumulative heat:")
    for index, item in enumerate(report["top_videos"][:5], start=1):
        label = item["growth"]["timeliness_label"]
        age = item["growth"]["video_age_hours"]
        print(f"{index}. {item['creator']} | views={item['view_count']:,} likes={item['like_count']:,} score={item['hot_score']:,.0f} age_hours={age} label={label}")
        print(f"   {item['url']}")
    print("\nTop rising videos:")
    if not report["top_rising_videos"]:
        print("No overlapping videos found in the previous snapshot; run the same monitored sources again to compute growth.")
    for index, item in enumerate(report["top_rising_videos"][:5], start=1):
        growth = item["growth"]
        print(f"{index}. {item['creator']} | views_delta={growth['views_delta']:,} trend_score={growth['trend_score']:,.0f} label={growth['timeliness_label']}")
        print(f"   {item['url']}")
    print("\nTop hashtags:")
    for index, item in enumerate(report["top_hashtags"][:5], start=1):
        print(f"{index}. #{item['name']} | videos={item['video_count']} views={int(item['views']):,} score={item['score']:,.0f}")
    print("\nRecent terms by age:")
    for group in report["recent_terms_by_age"]:
        print(f"{group['label']}:")
        for index, item in enumerate(group["items"][:5], start=1):
            print(f"  {index}. {item['name']} | videos={item['video_count']} views={int(item['views']):,} score={item['score']:,.0f}")
    print("\nEstablished terms:")
    for index, item in enumerate(report["established_terms"][:5], start=1):
        print(f"{index}. {item['name']} | status={item['status']} videos={item['video_count']} delta={item['video_count_delta']} views_delta={int(item['views_delta']):,} score={item['score']:,.0f}")


def main() -> int:
    args = parse_args()
    try:
        snapshot_path = resolve_path(args.snapshot) if args.snapshot else latest_snapshot()
        records = load_jsonl(snapshot_path)
        previous_snapshot_path = resolve_path(args.previous_snapshot) if args.previous_snapshot else previous_snapshot_for(snapshot_path)
        previous_records = load_jsonl(previous_snapshot_path) if previous_snapshot_path else None
        report = analyze(records, snapshot_path, args.top, previous_records, previous_snapshot_path)
        output_path = write_report(report, args.output_dir)
    except ValueError as exc:
        print(f"analysis error: {exc}")
        return 2
    print_summary(report, output_path)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/crawl_tiktok_hotspots.py
content: from __future__ import annotations

import argparse
import asyncio
import json
import os
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONFIG_PATH = PROJECT_ROOT / "config" / "tiktok_hotspot_sources.json"
SOURCE_TYPES = {"keyword", "hashtag", "creator", "music"}
PROVIDER_TYPES = {"tiktok_mcp", "apify"}


@dataclass(frozen=True)
class CrawlWindow:
    name: str
    label: str
    weight: int
    input_overrides: dict[str, Any]


@dataclass(frozen=True)
class Source:
    type: str
    value: str
    limit: int
    enabled: bool = True


@dataclass(frozen=True)
class CrawlRequest:
    source: Source
    window: CrawlWindow | None
    limit: int


@dataclass(frozen=True)
class CrawlerConfig:
    market: str
    output_base_dir: Path
    snapshots_dir: Path
    logs_dir: Path
    schedule_enabled: bool
    interval_minutes: int
    provider_type: str
    tiktok_mcp_command: str
    tiktok_mcp_args: list[str]
    tiktok_mcp_cwd: Path | None
    tiktok_mcp_timeout_seconds: int
    tiktok_mcp_reject_simulated: bool
    tiktok_mcp_env: dict[str, str]
    apify_actor_id: str | None
    apify_token_env: str
    apify_input_defaults: dict[str, Any]
    apify_input_templates: dict[str, Any]
    apify_crawl_windows: dict[str, list[CrawlWindow]]
    sources: list[Source]


def utc_timestamp() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def load_dotenv(path: Path = PROJECT_ROOT / ".env") -> None:
    if not path.exists():
        return

    with path.open("r", encoding="utf-8") as file:
        for line in file:
            stripped = line.strip()
            if not stripped or stripped.startswith("#") or "=" not in stripped:
                continue
            key, value = stripped.split("=", 1)
            key = key.strip()
            if key and key not in os.environ:
                os.environ[key] = value.strip().strip('"').strip("'")


def load_config(path: Path) -> CrawlerConfig:
    with path.open("r", encoding="utf-8") as file:
        raw = json.load(file)

    output = raw.get("output", {})
    schedule = raw.get("schedule", {})
    provider = raw.get("provider", {})
    tiktok_mcp = raw.get("tiktok_mcp", {})
    apify = raw.get("apify", {})
    apify_input = apify.get("input", {})
    apify_input_defaults = as_mapping(apify_input.get("defaults", {}), "apify.input.defaults")
    apify_input_templates = as_mapping(apify_input.get("per_source", {}), "apify.input.per_source")
    apify_crawl_windows = parse_crawl_windows(apify_input.get("crawl_windows", {}))
    defaults = raw.get("defaults", {})

    base_dir = resolve_project_path(output.get("base_dir", "data/tiktok_hotspots"))
    snapshots_dir = resolve_output_path(base_dir, output.get("snapshots_dir", "snapshots"))
    logs_dir = resolve_output_path(base_dir, output.get("logs_dir", "logs"))

    provider_type = str(provider.get("type", "tiktok_mcp")).strip().lower()
    if provider_type not in PROVIDER_TYPES:
        raise ValueError(f"provider.type must be one of {sorted(PROVIDER_TYPES)}")

    default_limit = positive_int(defaults.get("limit", 10), "defaults.limit")
    interval_minutes = positive_int(schedule.get("interval_minutes", 60), "schedule.interval_minutes")
    tiktok_mcp_timeout_seconds = positive_int(tiktok_mcp.get("timeout_seconds", 120), "tiktok_mcp.timeout_seconds")
    tiktok_mcp_command = optional_string(tiktok_mcp.get("command")) or "python"
    tiktok_mcp_args = as_string_list(tiktok_mcp.get("args", ["-m", "tiktok_mcp_service.main"]), "tiktok_mcp.args")
    tiktok_mcp_cwd = optional_path(tiktok_mcp.get("cwd"))
    tiktok_mcp_env = {key: str(value) for key, value in as_mapping(tiktok_mcp.get("env", {}), "tiktok_mcp.env").items()}

    apify_actor_id = optional_string(provider.get("actor_id")) or optional_string(apify.get("actor_id"))
    if provider_type == "apify" and not apify_actor_id:
        raise ValueError("apify.actor_id is required when provider.type is apify")

    sources = [parse_source(item, default_limit, index) for index, item in enumerate(raw.get("sources", []), start=1)]
    enabled_sources = [source for source in sources if source.enabled]
    if not enabled_sources:
        raise ValueError("At least one enabled source is required")

    return CrawlerConfig(
        market=str(raw.get("market", "US")),
        output_base_dir=base_dir,
        snapshots_dir=snapshots_dir,
        logs_dir=logs_dir,
        schedule_enabled=bool(schedule.get("enabled", False)),
        interval_minutes=interval_minutes,
        provider_type=provider_type,
        tiktok_mcp_command=tiktok_mcp_command,
        tiktok_mcp_args=tiktok_mcp_args,
        tiktok_mcp_cwd=tiktok_mcp_cwd,
        tiktok_mcp_timeout_seconds=tiktok_mcp_timeout_seconds,
        tiktok_mcp_reject_simulated=bool(tiktok_mcp.get("reject_simulated", True)),
        tiktok_mcp_env=tiktok_mcp_env,
        apify_actor_id=apify_actor_id,
        apify_token_env=str(apify.get("token_env", "APIFY_TOKEN")),
        apify_input_defaults=apify_input_defaults,
        apify_input_templates=apify_input_templates,
        apify_crawl_windows=apify_crawl_windows,
        sources=sources,
    )


def parse_source(raw: dict[str, Any], default_limit: int, index: int) -> Source:
    source_type = str(raw.get("type", "")).strip().lower()
    if source_type not in SOURCE_TYPES:
        raise ValueError(f"sources[{index}].type must be one of {sorted(SOURCE_TYPES)}")

    enabled = bool(raw.get("enabled", True))
    value = str(raw.get("value", "")).strip()
    if enabled and not value:
        raise ValueError(f"sources[{index}].value is required when enabled")

    return Source(
        type=source_type,
        value=value,
        limit=positive_int(raw.get("limit", default_limit), f"sources[{index}].limit"),
        enabled=enabled,
    )



def parse_crawl_windows(raw: Any) -> dict[str, list[CrawlWindow]]:
    if raw is None:
        return {}
    if not isinstance(raw, dict):
        raise ValueError("apify.input.crawl_windows must be an object")

    windows_by_type: dict[str, list[CrawlWindow]] = {}
    for source_type, items in raw.items():
        source_type = str(source_type).strip().lower()
        if source_type not in SOURCE_TYPES and source_type != "default":
            raise ValueError(f"apify.input.crawl_windows.{source_type} must target a known source type or default")
        if not isinstance(items, list):
            raise ValueError(f"apify.input.crawl_windows.{source_type} must be a list")
        windows_by_type[source_type] = [parse_crawl_window(item, source_type, index) for index, item in enumerate(items, start=1)]
    return windows_by_type


def parse_crawl_window(raw: Any, source_type: str, index: int) -> CrawlWindow:
    if not isinstance(raw, dict):
        raise ValueError(f"apify.input.crawl_windows.{source_type}[{index}] must be an object")
    name = optional_string(raw.get("name"))
    if not name:
        raise ValueError(f"apify.input.crawl_windows.{source_type}[{index}].name is required")
    return CrawlWindow(
        name=name,
        label=optional_string(raw.get("label")) or name,
        weight=positive_int(raw.get("weight", 1), f"apify.input.crawl_windows.{source_type}[{index}].weight"),
        input_overrides=as_mapping(raw.get("input", {}), f"apify.input.crawl_windows.{source_type}[{index}].input"),
    )


def positive_int(value: Any, name: str) -> int:
    try:
        parsed = int(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"{name} must be a positive integer") from exc
    if parsed <= 0:
        raise ValueError(f"{name} must be a positive integer")
    return parsed


def optional_string(value: Any) -> str | None:
    if value is None:
        return None
    parsed = str(value).strip()
    return parsed or None



def as_mapping(value: Any, name: str) -> dict[str, Any]:
    if value is None:
        return {}
    if not isinstance(value, dict):
        raise ValueError(f"{name} must be an object")
    return value


def as_string_list(value: Any, name: str) -> list[str]:
    if not isinstance(value, list):
        raise ValueError(f"{name} must be a list")
    return [str(item) for item in value]


def optional_path(value: Any) -> Path | None:
    parsed = optional_string(value)
    return resolve_project_path(parsed) if parsed else None


def resolve_project_path(value: str) -> Path:
    path = Path(value)
    return path if path.is_absolute() else PROJECT_ROOT / path


def resolve_output_path(base_dir: Path, value: str) -> Path:
    path = Path(value)
    return path if path.is_absolute() else base_dir / path


def build_target(source: Source) -> str:
    if source.type == "keyword":
        return source.value
    if source.type == "hashtag":
        return f"#{source.value.lstrip('#')}"
    if source.type == "creator":
        handle = source.value.lstrip("@")
        return f"https://www.tiktok.com/@{handle}"
    return source.value


def fetch_source(config: CrawlerConfig, request: CrawlRequest) -> tuple[list[dict[str, Any]], str | None]:
    if config.provider_type == "apify":
        return fetch_source_apify(config, request)
    return fetch_source_tiktok_mcp(config, request)


def build_tiktok_mcp_search_term(source: Source) -> str:
    if source.type == "keyword":
        return source.value
    if source.type == "hashtag":
        return f"#{source.value.lstrip('#')}"
    raise ValueError(f"tiktok_mcp provider only supports keyword and hashtag sources, got {source.type}")


def fetch_source_tiktok_mcp(config: CrawlerConfig, request: CrawlRequest) -> tuple[list[dict[str, Any]], str | None]:
    try:
        records, response = asyncio.run(call_tiktok_mcp_search(config, request))
    except ImportError:
        return [], "mcp is not installed; run python -m pip install -r requirements.txt"
    except ValueError as exc:
        return [], str(exc)
    except Exception as exc:
        return [], f"TikTok MCP search failed: {exc}"

    transformations = response.get("transformations") if isinstance(response, dict) else None
    if transformations and config.tiktok_mcp_reject_simulated:
        return [], "TikTok MCP returned simulated results; rejected by tiktok_mcp.reject_simulated"

    errors = response.get("errors") if isinstance(response, dict) else None
    if errors:
        return records, json.dumps(errors, ensure_ascii=False)
    return records, None


async def call_tiktok_mcp_search(config: CrawlerConfig, request: CrawlRequest) -> tuple[list[dict[str, Any]], dict[str, Any]]:
    from mcp import ClientSession, StdioServerParameters
    from mcp.client.stdio import stdio_client

    search_term = build_tiktok_mcp_search_term(request.source)
    server_params = StdioServerParameters(
        command=config.tiktok_mcp_command,
        args=config.tiktok_mcp_args,
        env={**os.environ, **config.tiktok_mcp_env},
        cwd=config.tiktok_mcp_cwd,
        encoding_error_handler="replace",
    )

    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await asyncio.wait_for(session.initialize(), timeout=config.tiktok_mcp_timeout_seconds)
            result = await asyncio.wait_for(
                session.call_tool("search_videos", arguments={"search_terms": [search_term], "count": request.limit}),
                timeout=config.tiktok_mcp_timeout_seconds,
            )

    response = parse_mcp_tool_response(result)
    term_records = response.get("results", {}).get(search_term, [])
    if not isinstance(term_records, list):
        raise ValueError("TikTok MCP returned an invalid results payload")
    return [normalize_tiktok_mcp_raw_record(item, search_term) for item in term_records if isinstance(item, dict)], response


def parse_mcp_tool_response(result: Any) -> dict[str, Any]:
    structured = getattr(result, "structuredContent", None)
    if isinstance(structured, dict):
        return structured

    content = getattr(result, "content", [])
    for item in content:
        item_type = getattr(item, "type", None)
        item_text = getattr(item, "text", None)
        if item_type == "text" and item_text:
            parsed = json.loads(item_text)
            if isinstance(parsed, dict):
                return parsed
    raise ValueError("TikTok MCP did not return JSON content")


def normalize_tiktok_mcp_raw_record(raw: dict[str, Any], search_term: str) -> dict[str, Any]:
    normalized = dict(raw)
    stats = raw.get("stats") if isinstance(raw.get("stats"), dict) else {}
    normalized["search_query"] = search_term
    normalized["webpage_url"] = raw.get("url")
    normalized["title"] = raw.get("description")
    normalized["view_count"] = parse_optional_int(stats.get("views"))
    normalized["like_count"] = parse_optional_int(stats.get("likes"))
    normalized["share_count"] = parse_optional_int(stats.get("shares"))
    normalized["comment_count"] = parse_optional_int(stats.get("comments"))
    normalized["uploader"] = raw.get("author")
    normalized["video_id"] = extract_tiktok_video_id(raw.get("url"))
    return normalized


def extract_tiktok_video_id(url: Any) -> str | None:
    if not isinstance(url, str):
        return None
    marker = "/video/"
    if marker not in url:
        return None
    return url.split(marker, 1)[1].split("?", 1)[0].strip("/") or None


def parse_optional_int(value: Any) -> int | None:
    if value in (None, ""):
        return None
    try:
        return int(str(value).replace(",", ""))
    except ValueError:
        return None


def fetch_source_apify(config: CrawlerConfig, request: CrawlRequest) -> tuple[list[dict[str, Any]], str | None]:
    [REDACTED_SECRET]))
    if not [REDACTED_SECRET] [], f"Apify token environment variable is not set: {config.apify_token_env}"

    try:
        from apify_client import ApifyClient
    except ImportError:
        return [], "apify-client is not installed; run python -m pip install -r requirements.txt"

    try:
        run_input = build_apify_run_input(config, request)
    except ValueError as exc:
        return [], str(exc)

    try:
        client = ApifyClient(token)
        actor_run = client.actor(config.apify_actor_id).call(run_input=run_input)
        dataset_id = actor_run.get("defaultDatasetId") if actor_run else None
        if not dataset_id:
            return [], "Apify actor run did not return a defaultDatasetId"
        items = client.dataset(dataset_id).list_items().items
    except Exception as exc:
        return [], f"Apify actor failed: {exc}"

    records = [item for item in items if isinstance(item, dict) and not item.get("error") and not item.get("errorCode")]
    errors = [item for item in items if isinstance(item, dict) and (item.get("error") or item.get("errorCode"))]
    if errors:
        message = first_value(errors[0], ["error", "errorCode"]) or "Apify dataset contains error items"
        return records, str(message)
    return records, None


def build_apify_run_input(config: CrawlerConfig, request: CrawlRequest) -> dict[str, Any]:
    source = request.source
    run_input = render_template(config.apify_input_defaults, config, request)
    if not isinstance(run_input, dict):
        raise ValueError("apify.input.defaults must render to an object")

    source_template = config.apify_input_templates.get(source.type) or config.apify_input_templates.get("default")
    if source_template is None:
        source_input = {
            "source_type": source.type,
            "source_value": source.value,
            "source_target": build_target(source),
            "limit": request.limit,
            "market": config.market,
        }
    else:
        source_input = render_template(source_template, config, request)
        if not isinstance(source_input, dict):
            raise ValueError(f"apify.input.per_source.{source.type} must render to an object")

    window_input: dict[str, Any] = {}
    if request.window is not None:
        rendered_window = render_template(request.window.input_overrides, config, request)
        if not isinstance(rendered_window, dict):
            raise ValueError(f"apify.input.crawl_windows.{source.type}.{request.window.name}.input must render to an object")
        window_input = rendered_window

    return {**run_input, **source_input, **window_input}


def render_template(value: Any, config: CrawlerConfig, request: CrawlRequest) -> Any:
    source = request.source
    context = {
        "market": config.market,
        "source_type": source.type,
        "source_value": source.value,
        "source_value_without_prefix": source.value.lstrip("#@"),
        "source_target": build_target(source),
        "limit": request.limit,
        "source_limit": source.limit,
        "crawl_window": request.window.name if request.window else "default",
        "crawl_window_label": request.window.label if request.window else "Default",
    }
    if isinstance(value, str):
        if value == "{limit}":
            return request.limit
        if value == "{source_limit}":
            return source.limit
        return value.format_map(context)
    if isinstance(value, list):
        return [render_template(item, config, request) for item in value]
    if isinstance(value, dict):
        return {key: render_template(item, config, request) for key, item in value.items()}
    return value


def normalize_record(raw: dict[str, Any], request: CrawlRequest, crawl_timestamp: str) -> dict[str, Any]:
    source = request.source
    return {
        "crawl_timestamp": crawl_timestamp,
        "source_type": source.type,
        "source_value": source.value,
        "crawl_window": request.window.name if request.window else "default",
        "crawl_window_label": request.window.label if request.window else "Default",
        "crawl_window_limit": request.limit,
        "video_id": first_value(raw, ["id", "video_id", "videoId", "awemeId", "itemInfo.itemStruct.id"]),
        "webpage_url": first_value(raw, ["webpage_url", "url", "webVideoUrl", "shareUrl", "itemInfo.itemStruct.shareInfo.shareUrl"]),
        "title": first_value(raw, ["title", "description", "desc", "text"]),
        "description": first_value(raw, ["description", "desc", "text", "title"]),
        "text_language": first_value(raw, ["textLanguage", "text_language", "descLanguage"]),
        "uploader": first_value(raw, ["uploader", "authorMeta.name", "authorMeta.nickName", "author.nickname", "author.uniqueId"]),
        "uploader_id": first_value(raw, ["uploader_id", "authorMeta.id", "authorMeta.profileUrl", "author.id", "author.uniqueId"]),
        "uploader_profile_url": first_value(raw, ["authorMeta.profileUrl", "author.profileUrl", "author.url"]),
        "uploader_follower_count": first_value(raw, ["authorMeta.fans", "authorMeta.followerCount", "author.followerCount", "author.stats.followerCount"]),
        "uploader_like_count": first_value(raw, ["authorMeta.heart", "authorMeta.heartCount", "author.heartCount", "author.stats.heartCount"]),
        "upload_date": first_value(raw, ["upload_date", "createTimeISO", "createTime"]),
        "timestamp": first_value(raw, ["timestamp", "createTime", "createTimestamp"]),
        "duration": first_value(raw, ["duration", "videoMeta.duration", "video.duration"]),
        "view_count": first_value(raw, ["view_count", "playCount", "stats.playCount", "itemInfo.itemStruct.stats.playCount"]),
        "like_count": first_value(raw, ["like_count", "diggCount", "stats.diggCount", "itemInfo.itemStruct.stats.diggCount"]),
        "comment_count": first_value(raw, ["comment_count", "commentCount", "stats.commentCount", "itemInfo.itemStruct.stats.commentCount"]),
        "share_count": first_value(raw, ["share_count", "shareCount", "stats.shareCount", "itemInfo.itemStruct.stats.shareCount"]),
        "repost_count": first_value(raw, ["repost_count", "repostCount", "stats.repostCount"]),
        "collect_count": first_value(raw, ["collect_count", "collectCount", "stats.collectCount", "itemInfo.itemStruct.stats.collectCount"]),
        "hashtags": extract_hashtags(raw),
        "mentions": first_value(raw, ["mentions", "detailedMentions"]),
        "is_ad": first_value(raw, ["isAd", "is_ad"]),
        "is_slideshow": first_value(raw, ["isSlideshow", "is_slideshow"]),
        "is_pinned": first_value(raw, ["isPinned", "is_pinned"]),
        "search_query": first_value(raw, ["searchQuery", "search_query"]),
        "cover_url": first_value(raw, ["videoMeta.coverUrl", "video.cover", "coverUrl"]),
        "media_urls": first_value(raw, ["mediaUrls", "media_urls"]),
        "music": extract_music(raw),
        "raw_metadata": raw,
    }


def extract_hashtags(raw: dict[str, Any]) -> list[str] | None:
    hashtags = first_value(raw, ["hashtags", "challenges", "itemInfo.itemStruct.challenges"])
    if not isinstance(hashtags, list):
        return None

    names: list[str] = []
    for item in hashtags:
        if isinstance(item, str) and item:
            names.append(item.lstrip("#"))
        elif isinstance(item, dict):
            name = first_value(item, ["name", "title", "hashtagName"])
            if name:
                names.append(str(name).lstrip("#"))
    return names


def extract_music(raw: dict[str, Any]) -> dict[str, Any]:
    return {
        "id": first_value(raw, ["musicMeta.musicId", "music.id", "musicInfo.music.id"]),
        "track": first_value(raw, ["track", "musicMeta.musicName", "music.title", "musicInfo.music.title"]),
        "artist": first_value(raw, ["artist", "musicMeta.musicAuthor", "music.authorName", "musicInfo.music.authorName"]),
        "album": first_value(raw, ["album", "music.album"]),
        "is_original": first_value(raw, ["musicMeta.musicOriginal", "music.original", "musicInfo.music.original"]),
        "play_url": first_value(raw, ["musicMeta.playUrl", "music.playUrl", "musicInfo.music.playUrl"]),
        "cover_url": first_value(raw, ["musicMeta.coverMediumUrl", "music.coverMediumUrl", "musicInfo.music.coverMediumUrl"]),
    }


def first_value(raw: dict[str, Any], paths: list[str]) -> Any:
    for path in paths:
        value = nested_value(raw, path)
        if value not in (None, ""):
            return value
    return None


def nested_value(raw: dict[str, Any], path: str) -> Any:
    value: Any = raw
    for part in path.split("."):
        if not isinstance(value, dict) or part not in value:
            return None
        value = value[part]
    return value



def crawl_windows_for(config: CrawlerConfig, source: Source) -> list[CrawlWindow]:
    return config.apify_crawl_windows.get(source.type) or config.apify_crawl_windows.get("default") or []


def build_crawl_requests(config: CrawlerConfig, max_sources: int | None = None) -> list[CrawlRequest]:
    enabled_sources = [source for source in config.sources if source.enabled]
    if max_sources is not None:
        enabled_sources = enabled_sources[:max_sources]

    requests: list[CrawlRequest] = []
    for source in enabled_sources:
        windows = crawl_windows_for(config, source)
        if not windows or config.provider_type != "apify":
            requests.append(CrawlRequest(source=source, window=None, limit=source.limit))
            continue
        for window, limit in split_limit_by_weight(source.limit, windows):
            requests.append(CrawlRequest(source=source, window=window, limit=limit))
    return requests


def split_limit_by_weight(limit: int, windows: list[CrawlWindow]) -> list[tuple[CrawlWindow, int]]:
    if limit < len(windows):
        selected = sorted(enumerate(windows), key=lambda item: (-item[1].weight, item[0]))[:limit]
        windows = [window for _, window in sorted(selected, key=lambda item: item[0])]

    total_weight = sum(window.weight for window in windows)
    allocations: list[tuple[CrawlWindow, int, float]] = []
    allocated = 0
    for window in windows:
        exact = limit * window.weight / total_weight
        window_limit = max(1, int(exact))
        allocations.append((window, window_limit, exact - int(exact)))
        allocated += window_limit

    while allocated < limit:
        index = max(range(len(allocations)), key=lambda item: allocations[item][2])
        window, window_limit, remainder = allocations[index]
        allocations[index] = (window, window_limit + 1, remainder)
        allocated += 1

    while allocated > limit and len(allocations) > 1:
        candidates = [index for index, (_, window_limit, _) in enumerate(allocations) if window_limit > 1]
        if not candidates:
            break
        index = min(candidates, key=lambda item: allocations[item][2])
        window, window_limit, remainder = allocations[index]
        allocations[index] = (window, window_limit - 1, remainder)
        allocated -= 1

    return [(window, window_limit) for window, window_limit, _ in allocations]


def request_key(request: CrawlRequest) -> tuple[str, str]:
    return (request.source.type, request.source.value)


def request_window_name(request: CrawlRequest) -> str:
    return request.window.name if request.window else "default"


def request_window_label(request: CrawlRequest) -> str:
    return request.window.label if request.window else "Default"


def crawl_plan_metrics(requests: list[CrawlRequest]) -> dict[str, Any]:
    return {
        "enabled_source_count": len({request_key(request) for request in requests}),
        "crawl_window_count": len({request_window_name(request) for request in requests}),
        "planned_run_count": len(requests),
        "requested_total_limit": sum(request.limit for request in requests),
    }


def run_once(config: CrawlerConfig, max_sources: int | None = None) -> tuple[Path, Path, dict[str, Any]]:
    crawl_timestamp = utc_timestamp()
    config.snapshots_dir.mkdir(parents=True, exist_ok=True)
    config.logs_dir.mkdir(parents=True, exist_ok=True)

    snapshot_path = config.snapshots_dir / f"tiktok_hotspots_{crawl_timestamp}.jsonl"
    log_path = config.logs_dir / f"tiktok_hotspots_{crawl_timestamp}.jsonl"
    requests = build_crawl_requests(config, max_sources)
    summary: dict[str, Any] = {
        "crawl_timestamp": crawl_timestamp,
        "event": "crawl_round_summary",
        "provider": config.provider_type,
        **crawl_plan_metrics(requests),
        "completed_run_count": 0,
        "failed_run_count": 0,
        "raw_record_count": 0,
        "cost_model_note": "Apify cost depends on Actor pricing, planned runs, returned results, compute duration, memory, proxy usage, retries, add-ons, and account plan; verify exact cost in the Apify usage dashboard.",
        "windows": {},
    }

    with snapshot_path.open("w", encoding="utf-8") as snapshot_file, log_path.open("w", encoding="utf-8") as log_file:
        for request in requests:
            source = request.source
            started_at = datetime.now(timezone.utc).isoformat()
            raw_records, error = fetch_source(config, request)
            normalized_records = [normalize_record(raw, request, crawl_timestamp) for raw in raw_records]
            for record in normalized_records:
                snapshot_file.write(json.dumps(record, ensure_ascii=False) + "\n")
            window_name = request_window_name(request)
            window_summary = summary["windows"].setdefault(
                window_name,
                {
                    "label": request_window_label(request),
                    "planned_run_count": 0,
                    "completed_run_count": 0,
                    "failed_run_count": 0,
                    "requested_limit": 0,
                    "raw_record_count": 0,
                },
            )
            summary["raw_record_count"] += len(normalized_records)
            summary["failed_run_count" if error else "completed_run_count"] += 1
            window_summary["planned_run_count"] += 1
            window_summary["requested_limit"] += request.limit
            window_summary["raw_record_count"] += len(normalized_records)
            window_summary["failed_run_count" if error else "completed_run_count"] += 1
            log_entry = {
                "crawl_timestamp": crawl_timestamp,
                "started_at": started_at,
                "finished_at": datetime.now(timezone.utc).isoformat(),
                "provider": config.provider_type,
                "source_type": source.type,
                "source_value": source.value,
                "source_limit": source.limit,
                "crawl_window": window_name,
                "crawl_window_label": request_window_label(request),
                "crawl_window_limit": request.limit,
                "status": "failed" if error else "success",
                "record_count": len(normalized_records),
                "error": error,
            }
            log_file.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
            log_file.flush()
            snapshot_file.flush()

        log_file.write(json.dumps(summary, ensure_ascii=False) + "\n")

    video_ids = set()
    for line in snapshot_path.read_text(encoding="utf-8").splitlines():
        try:
            record = json.loads(line)
        except json.JSONDecodeError:
            continue
        video_id = record.get("video_id") if isinstance(record, dict) else None
        if video_id:
            video_ids.add(str(video_id))
    summary["unique_video_count"] = len(video_ids)
    summary["duplicate_rate"] = 0.0 if not summary["raw_record_count"] else round(1 - len(video_ids) / summary["raw_record_count"], 4)

    lines = log_path.read_text(encoding="utf-8").splitlines()
    lines[-1] = json.dumps(summary, ensure_ascii=False)
    log_path.write_text("\n".join(lines) + "\n", encoding="utf-8")

    return snapshot_path, log_path, summary



def run_schedule(config: CrawlerConfig) -> None:
    while True:
        snapshot_path, log_path, summary = run_once(config)
        print(
            f"crawl complete: records={summary['raw_record_count']} failed_runs={summary['failed_run_count']}/{summary['planned_run_count']} "
            f"snapshot={snapshot_path} log={log_path}",
            flush=True,
        )
        time.sleep(config.interval_minutes * 60)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Crawl TikTok hotspot candidate metadata snapshots.")
    parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_PATH)
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument("--once", action="store_true", help="Run one crawl and exit.")
    mode.add_argument("--schedule", action="store_true", help="Run continuously with the configured interval.")
    parser.add_argument("--max-sources", type=int, default=None, help="Limit enabled sources for smoke tests.")
    return parser.parse_args()


def main() -> int:
    load_dotenv()
    args = parse_args()
    try:
        config = load_config(args.config)
        if args.schedule:
            run_schedule(config)
            return 0
        snapshot_path, log_path, summary = run_once(config, args.max_sources)
    except ValueError as exc:
        print(f"configuration error: {exc}", file=sys.stderr)
        return 2

    print(f"snapshot: {snapshot_path}")
    print(f"log: {log_path}")
    print(f"records: {summary['raw_record_count']}")
    print(f"unique_videos: {summary.get('unique_video_count', 'unknown')}")
    print(f"planned_runs: {summary['planned_run_count']}")
    print(f"completed_runs: {summary['completed_run_count']}")
    print(f"failed_runs: {summary['failed_run_count']}")
    print(f"requested_total_limit: {summary['requested_total_limit']}")
    return 1 if summary["planned_run_count"] and summary["failed_run_count"] == summary["planned_run_count"] else 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/download_tiktok_videos.py
content: from __future__ import annotations

import argparse
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_ANALYSIS_GLOB = "data/tiktok_hotspot_analysis/tiktok_hotspot_analysis_*.json"
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "data" / "tiktok_videos"

def _read_crawl_timestamp(analysis_path: Path) -> str:
    try:
        report = json.loads(analysis_path.read_text(encoding="utf-8"))
        cm = report.get("crawl_metrics") or {}
        ts = cm.get("crawl_timestamp")
        if ts:
            return str(ts)
    except Exception:
        pass
    return ""

AGE_BUCKET_LABELS = {
    "within_1d": "近一天",
    "within_3d": "近三天",
    "within_7d": "近七天",
    "within_14d": "近两周",
    "older_than_14d": "两周以前",
    "age_unknown": "发布时间未知",
}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Download hotspot videos organized by time buckets.")
    parser.add_argument("--analysis", type=Path, default=None, help="Analysis JSON. Defaults to latest.")
    parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
    parser.add_argument("--limit", type=int, default=10, help="Max videos per bucket (default: 10)")
    parser.add_argument("--dry-run", action="store_true", help="Print what would be downloaded without downloading")
    return parser.parse_args()


def resolve_path(path: Path) -> Path:
    return path if path.is_absolute() else PROJECT_ROOT / path


def latest_analysis() -> Path:
    paths = sorted(PROJECT_ROOT.glob(DEFAULT_ANALYSIS_GLOB), key=lambda p: p.stat().st_mtime, reverse=True)
    if not paths:
        raise ValueError("No analysis JSON found")
    return paths[0]


def load_json(path: Path) -> dict[str, Any]:
    with path.open("r", encoding="utf-8") as f:
        return json.load(f)


def sanitize_filename(name: str) -> str:
    safe = "".join(c if c.isalnum() or c in "._- " else "_" for c in name)
    return safe.strip() or "video"


def short_number(value: Any) -> str:
    if value is None:
        return "—"
    try:
        number = float(value)
    except (TypeError, ValueError):
        return str(value)
    if number >= 1_000_000_000:
        return f"{number / 1_000_000_000:.1f}B"
    if number >= 1_000_000:
        return f"{number / 1_000_000:.1f}M"
    if number >= 1_000:
        return f"{number / 1_000:.1f}K"
    return f"{number:,.0f}"


def write_metadata_md(video: dict[str, Any], md_path: Path) -> None:
    growth = video.get("growth") if isinstance(video.get("growth"), dict) else {}
    music = video.get("music") if isinstance(video.get("music"), dict) else {}
    hashtags = video.get("hashtags") or []

    lines = [
        "---",
        f"video_id: {video.get('video_id', '')}",
        f"url: {video.get('url', '')}",
        f"creator: {video.get('creator', video.get('uploader', ''))}",
        f"title: {video.get('title', '')}",
        f"views: {short_number(video.get('view_count'))}",
        f"likes: {short_number(video.get('like_count'))}",
        f"comments: {short_number(video.get('comment_count'))}",
        f"shares: {short_number(video.get('share_count'))}",
        f"hot_score: {short_number(video.get('hot_score'))}",
        f"engagement_rate: {video.get('engagement_rate', '')}",
        f"video_age_hours: {growth.get('video_age_hours', '')}",
        f"timeliness_label: {growth.get('timeliness_label', '')}",
        f"source_type: {video.get('source_type', '')}",
        f"source_value: {video.get('source_value', '')}",
        f"crawl_timestamp: {video.get('crawl_timestamp', '')}",
        f"downloaded_at: {datetime.now(timezone.utc).isoformat()}",
        "---",
        "",
        f"# {video.get('title', '未命名视频')}",
        "",
        f"**创作者**: @{video.get('creator', video.get('uploader', ''))}",
        f"**播放量**: {short_number(video.get('view_count'))}",
        f"**点赞**: {short_number(video.get('like_count'))}",
        f"**评论**: {short_number(video.get('comment_count'))}",
        f"**分享**: {short_number(video.get('share_count'))}",
        f"**热度分**: {short_number(video.get('hot_score'))}",
        f"**互动率**: {video.get('engagement_rate', '')}",
        f"**视频年龄**: {growth.get('video_age_hours', '')} 小时",
        f"**时效标签**: {growth.get('timeliness_label', '')}",
        "",
    ]
    if hashtags:
        lines.append("**标签**: " + " ".join(f"#{t}" for t in hashtags))
        lines.append("")
    if music.get("track") or music.get("artist"):
        lines.append(f"**音乐**: {music.get('track', '—')} — {music.get('artist', '—')}")
        lines.append("")
    lines.append(f"[在 TikTok 上打开]({video.get('url', '')})")
    lines.append("")

    md_path.write_text("\n".join(lines), encoding="utf-8")


def download_video(url: str, output_path: Path, dry_run: bool = False) -> bool:
    if dry_run:
        return True

    output_path.parent.mkdir(parents=True, exist_ok=True)
    tmp_tmpl = output_path.with_suffix(".%(ext)s")
    cmd = [
        sys.executable, "-m", "yt_dlp",
        "-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
        "--no-playlist",
        "--no-warnings",
        "--no-part",  # avoid .part files
        "-o", str(tmp_tmpl),
        url,
    ]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
    except subprocess.TimeoutExpired:
        print(f"  timeout: {url}")
        return False
    except Exception as exc:
        print(f"  error: {exc}")
        return False

    # Check for the expected .mp4 output
    if output_path.exists() and output_path.stat().st_size > 0:
        return True

    # Check other extensions
    for ext in [".mp4", ".webm", ".mkv"]:
        candidate = output_path.with_suffix(ext)
        if candidate.exists() and candidate.stat().st_size > 0:
            if ext != ".mp4":
                candidate.rename(output_path)
            return True

    # Check for .mp3 (audio only fallback) — treat as failed
    mp3 = output_path.with_suffix(".mp3")
    if mp3.exists():
        mp3.unlink()

    if result.stderr:
        err = result.stderr.strip()
        if not any(kw in err for kw in ["has no subtitles", "Writing video metadata"]):
            print(f"  yt-dlp: {err[:200]}")
    return False


def collect_videos(report: dict[str, Any], max_per_bucket: int) -> list[tuple[str, str, dict[str, Any]]]:
    collected: list[tuple[str, str, dict[str, Any]]] = []
    for group in report.get("recent_videos_by_age") or []:
        bucket_key = group.get("key", "unknown")
        bucket_label = group.get("label", AGE_BUCKET_LABELS.get(bucket_key, "未知"))
        for video in (group.get("items") or [])[:max_per_bucket]:
            collected.append((bucket_key, bucket_label, video))
    for video in (report.get("top_videos") or [])[:max_per_bucket]:
        collected.append(("top_cumulative", "累计热视频", video))
    return collected


def main() -> int:
    args = parse_args()
    try:
        report_path = resolve_path(args.analysis) if args.analysis else latest_analysis()
        report = load_json(report_path)
    except ValueError as exc:
        print(f"error: {exc}")
        return 2

    videos = collect_videos(report, args.limit)
    if not videos:
        print("no videos found in analysis report")
        return 1

    output_dir_root = resolve_path(args.output_dir)
    output_dir_root.mkdir(parents=True, exist_ok=True)
    existing_ids = set()
    for ts_dir in output_dir_root.iterdir():
        if not ts_dir.is_dir():
            continue
        for f in list(ts_dir.rglob("*.mp4")) + list(ts_dir.rglob("*.md")):
            parts = f.stem.split("_")
            if len(parts) >= 2 and parts[-1].isdigit():
                existing_ids.add(parts[-1])
    if existing_ids:
        print(f"already downloaded: {len(existing_ids)} unique videos")
    
    output_dir = output_dir_root
    parent_ts = _read_crawl_timestamp(report_path)
    if parent_ts:
        output_dir = output_dir / parent_ts
        print(f"parent timestamp: {parent_ts}")
    downloaded = 0
    skipped = 0
    failed = 0

    for bucket_key, bucket_label, video in videos:
        bucket_dir = output_dir / bucket_key
        bucket_dir.mkdir(parents=True, exist_ok=True)

        creator = sanitize_filename(str(video.get("creator", video.get("uploader", "unknown"))))
        video_id = str(video.get("video_id", ""))
        url = str(video.get("url", "") or video.get("webpage_url", ""))
        if not url or not video_id:
            continue

        stem = f"{creator}_{video_id}"
        mp4_path = bucket_dir / f"{stem}.mp4"
        md_path = bucket_dir / f"{stem}.md"

        # Always write / update the metadata .md
        if not args.dry_run:
            write_metadata_md(video, md_path)

        if video_id in existing_ids:
            skipped += 1
            continue

        if mp4_path.exists() and mp4_path.stat().st_size > 0:
            skipped += 1
            continue

        action = "[DRY-RUN]" if args.dry_run else "Downloading"
        print(f"{action} [{bucket_label}] {creator} ({video_id})")
        if args.dry_run:
            skipped += 1
            continue

        ok = download_video(url, mp4_path)
        if ok:
            downloaded += 1
        else:
            failed += 1

    total = downloaded + skipped
    print(f"\ndone: {downloaded} new, {skipped} already exist, {failed} failed (total {total})")
    if not args.dry_run:
        print(f"videos: {output_dir}")
    return 1 if failed > 0 else 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/render_tiktok_hotspot_report.py
content: from __future__ import annotations

import argparse
import html
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_REPORT_GLOB = "data/tiktok_hotspot_analysis/tiktok_hotspot_analysis_*.json"
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "data" / "tiktok_hotspot_analysis"


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Render a TikTok hotspot analysis JSON report as static HTML.")
    parser.add_argument("--report", type=Path, default=None, help="Analysis JSON report. Defaults to the latest report.")
    parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
    return parser.parse_args()


def resolve_path(path: Path) -> Path:
    return path if path.is_absolute() else PROJECT_ROOT / path


def latest_report() -> Path:
    paths = sorted(PROJECT_ROOT.glob(DEFAULT_REPORT_GLOB), key=lambda path: path.stat().st_mtime, reverse=True)
    if not paths:
        raise ValueError("No TikTok hotspot analysis report found")
    return paths[0]


def load_report(path: Path) -> dict[str, Any]:
    with path.open("r", encoding="utf-8") as file:
        report = json.load(file)
    if not isinstance(report, dict):
        raise ValueError(f"Report is not a JSON object: {path}")
    return report


def utc_timestamp() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def text(value: Any) -> str:
    if value is None:
        return "—"
    return html.escape(str(value))


def short_number(value: Any) -> str:
    if value is None:
        return "—"
    try:
        number = float(value)
    except (TypeError, ValueError):
        return text(value)
    abs_value = abs(number)
    if abs_value >= 1_000_000_000:
        return f"{number / 1_000_000_000:.1f}B"
    if abs_value >= 1_000_000:
        return f"{number / 1_000_000:.1f}M"
    if abs_value >= 1_000:
        return f"{number / 1_000:.1f}K"
    return f"{number:,.0f}"


def percent(value: Any) -> str:
    if value is None:
        return "—"
    try:
        return f"{float(value) * 100:.1f}%"
    except (TypeError, ValueError):
        return text(value)


def metric(label: str, value: Any, unit: str = "") -> str:
    return f"""
    <div class=\"metric\">
      <div class=\"metric-label\">{html.escape(label)}</div>
      <div class=\"metric-value\">{text(value)}<span>{html.escape(unit)}</span></div>
    </div>
    """


def stat(label: str, value: Any, class_name: str = "") -> str:
    return f"<div class=\"stat-row {class_name}\"><span>{html.escape(label)}</span><strong>{text(value)}</strong></div>"


def chips(values: list[Any], limit: int = 5) -> str:
    items = values[:limit]
    if not items:
        return "<span class=\"chip muted\">暂无标签</span>"
    return "".join(f"<span class=\"chip\">#{text(item)}</span>" for item in items)


def timeliness_label_text(label: str | None) -> str:
    return {
        "within_1d": "近一天",
        "within_3d": "近三天",
        "within_7d": "近七天",
        "within_14d": "近两周",
        "older_than_14d": "两周以前",
        "age_unknown": "发布时间未知",
        "fresh_rising": "新近起量",
        "rising_existing_video": "存量回升",
        "flat_or_declining": "持平或回落",
        "not_seen_in_previous_snapshot": "前序未命中",
    }.get(label or "", "状态未知")


def label_class(label: str | None) -> str:
    return {
        "within_1d": "status-hot",
        "within_3d": "status-hot",
        "within_7d": "status-warm",
        "within_14d": "status-warm",
        "older_than_14d": "status-muted",
        "age_unknown": "status-unknown",
        "fresh_rising": "status-hot",
        "rising_existing_video": "status-warm",
        "flat_or_declining": "status-muted",
        "not_seen_in_previous_snapshot": "status-unknown",
    }.get(label or "", "status-unknown")


def emerging_status_text(status: str | None) -> str:
    return {
        "new_emerging": "新冒出",
        "spreading": "正在扩散",
        "single_signal": "单点信号",
        "cooling": "已冷却",
        "mature_or_flat": "成熟或持平",
    }.get(status or "", "状态未知")


def hashtag_timeliness_text(label: str | None) -> str:
    return {
        "established_still_hot": "早已出现仍火",
        "new_within_7d": "近一周冒出",
        "new_within_3d": "近三天冒出",
        "new_within_1d": "一天内冒出",
        "established_first_seen": "本轮首次命中",
        "new_without_upload_time": "新标签待确认",
    }.get(label or "", "状态未知")


def hashtag_status_class(label: str | None) -> str:
    return {
        "new_within_1d": "status-hot",
        "new_within_3d": "status-hot",
        "new_within_7d": "status-warm",
        "established_still_hot": "status-warm",
        "established_first_seen": "status-unknown",
        "new_without_upload_time": "status-unknown",
    }.get(label or "", "status-unknown")


def age_hours(value: Any) -> str:
    if value is None:
        return "—"
    try:
        hours = float(value)
    except (TypeError, ValueError):
        return text(value)
    if hours < 24:
        return f"{hours:.1f} 小时"
    return f"{hours / 24:.1f} 天"


def source_type_text(source_type: Any) -> str:
    return {
        "keyword": "关键词",
        "hashtag": "话题标签",
        "creator": "达人",
        "music": "音乐",
    }.get(str(source_type or ""), text(source_type))


def video_card(video: dict[str, Any], index: int, mode: str) -> str:
    growth = video.get("growth") if isinstance(video.get("growth"), dict) else {}
    music = video.get("music") if isinstance(video.get("music"), dict) else {}
    label = growth.get("timeliness_label")
    is_rising = mode == "rising"
    primary_value = short_number(growth.get("views_delta")) if is_rising else short_number(video.get("view_count"))
    primary_label = "新增播放" if is_rising else "总播放"
    score = short_number(growth.get("trend_score")) if is_rising else short_number(video.get("hot_score"))
    score_label = "趋势分" if is_rising else "热度分"
    href = html.escape(str(video.get("url") or "#"), quote=True)
    return f"""
    <a href="{href}" target="_blank" rel="noreferrer" class="video-card-link">
    <article class=\"video-card\">
      <div class=\"rank\">{index:02d}</div>
      <div class=\"video-main\">
        <div class=\"video-topline\">
          <span class=\"source\">{source_type_text(video.get('source_type'))} / {text(video.get('source_value'))}</span>
          <span class=\"status {label_class(label)}\">{timeliness_label_text(label)}</span>
        </div>
        <h3>{text(video.get('title'))}</h3>
        <span class="creator">@{text(video.get('creator'))}</span>
        <div class=\"chip-row\">{chips(video.get('hashtags') or [])}</div>
      </div>
      <div class=\"video-data\">
        {stat(primary_label, primary_value, 'emphasis')}
        {stat(score_label, score)}
        {stat('视频年龄', f"{growth.get('video_age_hours')} 小时" if growth.get('video_age_hours') is not None else '—')}
        {stat('互动率', percent(video.get('engagement_rate')))}
        {stat('音乐', f"{music.get('track') or '—'} — {music.get('artist') or '—'}")}
      </div>
    </article>
    </a>
    """


def ranked_list(title: str, items: list[dict[str, Any]], value_label: str = "热度分") -> str:
    rows = []
    descriptions = {
        "热门音乐": "热度分 = 使用该音乐的样本视频热度分累加。单条视频热度分按播放量、点赞、评论、分享和收藏加权计算，因此这里更偏累计热度，不等同于当前增速。",
        "热门达人": "热度分 = 该达人在样本中命中的视频热度分累加。单条视频热度分按播放量、点赞、评论、分享和收藏加权计算，因此这里更偏累计热度，不等同于当前增速。",
    }
    description = descriptions.get(title)
    description_html = f'<p class="panel-note">{html.escape(description)}</p>' if description else ""
    for index, item in enumerate(items[:8], start=1):
        rows.append(
            f"""
            <tr>
              <td>{index:02d}</td>
              <td>{text(item.get('name'))}</td>
              <td>{short_number(item.get('video_count'))}</td>
              <td>{short_number(item.get('views'))}</td>
              <td>{short_number(item.get('score'))}</td>
            </tr>
            """
        )
    return f"""
    <section class=\"panel\">
      <div class=\"section-label\">{html.escape(title)}</div>
      {description_html}
      <table>
        <thead><tr><th>#</th><th>名称</th><th>视频数</th><th>播放量</th><th>{html.escape(value_label)}</th></tr></thead>
        <tbody>{''.join(rows)}</tbody>
      </table>
    </section>
    """


def hashtag_timeliness_table(items: list[dict[str, Any]]) -> str:
    rows = []
    for index, item in enumerate(items[:8], start=1):
        label = item.get("timeliness_label")
        rows.append(
            f"""
            <tr>
              <td>{index:02d}</td>
              <td>{text(item.get('name'))}</td>
              <td><span class=\"status {hashtag_status_class(label)}\">{hashtag_timeliness_text(label)}</span></td>
              <td>{short_number(item.get('video_count'))}</td>
              <td>{short_number(item.get('views'))}</td>
              <td>{age_hours(item.get('oldest_video_age_hours'))}</td>
              <td>{age_hours(item.get('newest_video_age_hours'))}</td>
            </tr>
            """
        )
    if not rows:
        rows.append('<tr><td colspan="7">暂无标签信号</td></tr>')
    return f"""
    <section class=\"panel\">
      <div class=\"section-label\">长期热门标签 / 时效性</div>
      <table>
        <thead><tr><th>#</th><th>标签</th><th>状态</th><th>视频数</th><th>播放量</th><th>最早视频年龄</th><th>最新视频年龄</th></tr></thead>
        <tbody>{''.join(rows)}</tbody>
      </table>
    </section>
    """
def recent_video_groups(groups: list[dict[str, Any]]) -> str:
    sections = []
    for group in groups:
        items = group.get("items") if isinstance(group.get("items"), list) else []
        cards = "".join(video_card(video, index, "cumulative") for index, video in enumerate(items[:5], start=1))
        if not cards:
            cards = "<div class=\"empty\">这个时间段暂无命中热视频。</div>"
        sections.append(
            f"""
            <section class=\"bucket-panel\">
              <div class=\"section-label\">{text(group.get('label'))} / 最多 5 条</div>
              <div class=\"video-stack\">{cards}</div>
            </section>
            """
        )
    return "".join(sections)


def novelty_badge(novelty: str | None) -> str:
    if novelty == "first_seen":
        return '<span class="novelty-badge first-seen">新见</span>'
    return ""


def signal_card(item: dict[str, Any], index: int) -> str:
    return f"""
    <article class="signal-card">
      <div class="signal-rank">{index:02d}</div>
      <div class="signal-main">
        <div class="signal-name">{text(item.get('name'))}{novelty_badge(item.get('novelty'))}</div>
        <div class="signal-age">最早 {age_hours(item.get('oldest_video_age_hours'))} / 最新 {age_hours(item.get('newest_video_age_hours'))}</div>
      </div>
      <div class="signal-metrics">
        <span>视频 {short_number(item.get('video_count'))}</span>
        <span>播放 {short_number(item.get('views'))}</span>
        <span>热度 {short_number(item.get('score'))}</span>
        <span>覆盖 {short_number(item.get('coverage_score'))}</span>
      </div>
    </article>
    """


def signal_list(title: str, items: list[dict[str, Any]], empty_text: str) -> str:
    cards = "".join(signal_card(item, index) for index, item in enumerate(items[:5], start=1))
    if not cards:
        cards = f'<div class="empty signal-empty">{html.escape(empty_text)}</div>'
    return f"""
    <div class="signal-group">
      <div class="section-label">{html.escape(title)}</div>
      <div class="signal-list">{cards}</div>
    </div>
    """


def recent_signals_groups(groups: list[dict[str, Any]]) -> str:
    sections = []
    for group in groups:
        heat_items = group.get("content_terms") if isinstance(group.get("content_terms"), list) else []
        cov_items = group.get("content_terms_coverage") if isinstance(group.get("content_terms_coverage"), list) else []
        ht_heat = group.get("hashtags") if isinstance(group.get("hashtags"), list) else []
        ht_cov = group.get("hashtags_coverage") if isinstance(group.get("hashtags_coverage"), list) else []
        sections.append(
            f"""
            <section class="panel signal-bucket">
              <div class="section-label">{text(group.get('label'))} / 趋势信号</div>
              <div class="signal-columns">
                {signal_list('内容关键词 · 热度排行', heat_items, '这个时间段暂无新近内容关键词')}
                {signal_list('内容关键词 · 覆盖排行', cov_items, '这个时间段暂无新近内容关键词')}
                {signal_list('TikTok 标签 · 热度排行', ht_heat, '这个时间段暂无新近 TikTok 标签')}
                {signal_list('TikTok 标签 · 覆盖排行', ht_cov, '这个时间段暂无新近 TikTok 标签')}
              </div>
            </section>
            """
        )
    return "".join(sections)


def established_terms_table(items: list[dict[str, Any]], title: str = "长期热门关键词 / 扩散监控", empty_text: str = "暂无长期热词扩散信号") -> str:
    rows = []
    for index, item in enumerate(items[:8], start=1):
        rows.append(
            f"""
            <tr>
              <td>{index:02d}</td>
              <td>{text(item.get('name'))}</td>
              <td><span class=\"status status-warm\">{emerging_status_text(item.get('status'))}</span></td>
              <td>{short_number(item.get('video_count'))}</td>
              <td>{short_number(item.get('sample_video_delta'))}</td>
              <td>{short_number(item.get('recent_24h_video_delta'))}</td>
              <td>{short_number(item.get('recent_7d_video_delta'))}</td>
              <td>{short_number(item.get('coverage_score'))}</td>
              <td>{short_number(item.get('score'))}</td>
              <td>{age_hours(item.get('oldest_video_age_hours'))}</td>
              <td>{age_hours(item.get('newest_video_age_hours'))}</td>
            </tr>
            """
        )
    if not rows:
        rows.append(f'<tr><td colspan="11">{html.escape(empty_text)}</td></tr>')
    return f"""
    <section class=\"panel wide-panel\">
      <div class=\"section-label\">{html.escape(title)}</div>
      <p class=\"panel-note\">这里只放最早命中的样本视频已经超过一个月的长期信号。扩散状态不再依赖前序快照是否命中，而是看当前快照内的视频年龄分布：近 7 天视频占比达到 10% 且最新视频在 7 天内才算“正在扩散”；最新视频在 7-30 天内为“成熟或持平”；最新视频超过 30 天为“已冷却”。覆盖分（video_count × log(1 + avg_score_per_video)）用于衡量一个词出现在多少视频中且每条都有基础互动。</p>
      <table>
        <thead><tr><th>#</th><th>名称</th><th>扩散状态</th><th>视频数</th><th>样本视频数</th><th>近24h视频</th><th>近7d视频</th><th>覆盖分</th><th>热度分</th><th>最早视频年龄</th><th>最新视频年龄</th></tr></thead>
        <tbody>{''.join(rows)}</tbody>
      </table>
    </section>
    """



def render_html(report: dict[str, Any]) -> str:
    top_videos = report.get("top_videos") or []
    hero = top_videos[0] if top_videos else {}
    hero_growth = hero.get("growth") if isinstance(hero.get("growth"), dict) else {}
    window = report.get("analysis_window") if isinstance(report.get("analysis_window"), dict) else {}
    cm = report.get("crawl_metrics") or {}
    source_counts = report.get("source_counts") if isinstance(report.get("source_counts"), dict) else {}
    source_summary = " / ".join(f"{source_type_text(key)} {value}" for key, value in source_counts.items()) or "暂无来源"
    recent_video_cards = recent_video_groups(report.get("recent_videos_by_age") or [])
    recent_signals = recent_signals_groups(report.get("recent_signals_by_age") or [])
    established_terms = established_terms_table(report.get("established_terms") or [], title="长期热门内容关键词 / 扩散监控")
    established_hashtags = established_terms_table(report.get("established_hashtags") or [], title="长期热门 TikTok 标签 / 扩散监控", empty_text="暂无长期热门标签扩散信号")
    cumulative_cards = "".join(video_card(video, index, "cumulative") for index, video in enumerate(top_videos[:6], start=1))

    return f"""<!doctype html>
<html lang=\"zh-CN\">
<head>
  <meta charset=\"utf-8\">
  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
  <title>TikTok 热点分析报告</title>
  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">
  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>
  <link href=\"https://fonts.googleapis.com/css2?family=Doto:wght@400..700&family=Space+Grotesk:wght@300;400;500;700&family=Space+Mono:wght@400;700&display=swap\" rel=\"stylesheet\">
  <style>
    :root {{
      --black:#000; --surface:#111; --surface-raised:#1A1A1A; --border:#222; --border-visible:#333;
      --text-disabled:#666; --text-secondary:#999; --text-primary:#E8E8E8; --text-display:#FFF;
      --accent:#D71921; --success:#4A9E5C; --warning:#D4A843;
      --space-sm:8px; --space-md:16px; --space-lg:24px; --space-xl:32px; --space-2xl:48px; --space-3xl:64px;
    }}
    * {{ box-sizing:border-box; }}
    body {{
      margin:0; background:var(--black); color:var(--text-primary); font-family:'Space Grotesk', system-ui, sans-serif;
      background-image:radial-gradient(circle, var(--border) .5px, transparent .5px); background-size:12px 12px;
    }}
    a {{ color:var(--text-display); text-decoration:none; }}
    .page {{ max-width:1440px; margin:0 auto; padding:40px clamp(20px,4vw,56px) 72px; }}
    header {{ display:grid; grid-template-columns:1.2fr .8fr; gap:var(--space-2xl); align-items:end; min-height:360px; }}
    .eyebrow,.section-label,.metric-label,.source,.status,th,.stat-row span,.chip,.meta {{
      font-family:'Space Mono', monospace; font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--text-secondary);
    }}
    h1 {{ margin:12px 0 0; font-family:'Doto','Space Mono',monospace; font-size:clamp(54px,8vw,112px); line-height:.9; letter-spacing:-.04em; color:var(--text-display); font-weight:700; }}
    .hero-subtitle {{ max-width:680px; margin-top:var(--space-lg); color:var(--text-secondary); font-size:18px; line-height:1.45; }}
    .hero-metric {{ border-left:2px solid var(--accent); padding-left:var(--space-lg); }}
    .hero-number {{ font-family:'Space Mono',monospace; font-size:clamp(44px,6vw,84px); line-height:1; color:var(--text-display); letter-spacing:-.05em; }}
    .hero-label {{ margin-top:var(--space-sm); font-family:'Space Mono',monospace; font-size:12px; letter-spacing:.08em; color:var(--accent); text-transform:uppercase; }}
    .grid-metrics {{ display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--border); border:1px solid var(--border); margin:var(--space-3xl) 0; }}
    .metric {{ background:var(--black); padding:var(--space-lg); min-height:132px; display:flex; flex-direction:column; justify-content:space-between; }}
    .metric-value {{ font-family:'Space Mono',monospace; color:var(--text-display); font-size:32px; letter-spacing:-.04em; }}
    .metric-value span {{ margin-left:4px; font-size:11px; color:var(--text-secondary); letter-spacing:.08em; }}
    .section-head {{ display:flex; justify-content:space-between; gap:var(--space-lg); align-items:end; margin:var(--space-3xl) 0 var(--space-lg); }}
    .section-head h2 {{ margin:0; color:var(--text-display); font-size:32px; line-height:1.1; letter-spacing:-.02em; }}
    .section-head p {{ margin:0; max-width:460px; color:var(--text-secondary); line-height:1.5; }}
    .video-stack {{ display:grid; gap:var(--space-md); }}
    .video-card {{ display:grid; grid-template-columns:72px 1fr 360px; gap:var(--space-lg); padding:var(--space-lg); background:rgba(17,17,17,.92); border:1px solid var(--border); border-radius:16px; }}
    .bucket-panel {{ margin-top:var(--space-lg); }}
    .rank {{ font-family:'Space Mono',monospace; color:var(--text-disabled); font-size:28px; letter-spacing:-.05em; }}
    .video-topline {{ display:flex; flex-wrap:wrap; gap:var(--space-sm); margin-bottom:var(--space-md); }}
    .status {{ border:1px solid var(--border-visible); border-radius:999px; padding:3px 9px; display:inline-flex; align-items:center; white-space:nowrap; }}
    .status-hot {{ color:var(--accent); border-color:var(--accent); }}
    .status-warm {{ color:var(--warning); border-color:var(--warning); }}
    .status-muted {{ color:var(--text-disabled); }}
    .status-unknown {{ color:var(--text-secondary); }}
    .video-main {{ min-width:0; }}
    h3 {{ margin:0 0 var(--space-md); font-size:22px; line-height:1.25; color:var(--text-display); font-weight:500; overflow-wrap:anywhere; word-break:break-word; }}
    .chip-row {{ display:flex; flex-wrap:wrap; gap:var(--space-sm); margin-top:var(--space-lg); }}
    .chip {{ border:1px solid var(--border-visible); border-radius:999px; padding:4px 10px; color:var(--text-secondary); }}
    .muted {{ color:var(--text-disabled); border-color:var(--border); }}
    .video-data {{ display:grid; align-content:start; border-top:1px solid var(--border); }}
    .stat-row {{ display:flex; justify-content:space-between; gap:var(--space-md); padding:12px 0; border-bottom:1px solid var(--border); }}
    .stat-row strong {{ font-family:'Space Mono',monospace; color:var(--text-primary); font-size:13px; text-align:right; font-weight:400; max-width:210px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }}
    .stat-row.emphasis strong {{ color:var(--accent); font-size:18px; }}
    .lower-grid {{ display:grid; grid-template-columns:1fr; gap:var(--space-lg); margin-top:var(--space-lg); }}
    .wide-panel {{ margin-top:var(--space-lg); }}
    .panel {{ background:rgba(17,17,17,.92); border:1px solid var(--border); border-radius:16px; padding:var(--space-lg); overflow:auto; }}
    .panel-note {{ margin:10px 0 20px; max-width:860px; color:var(--text-secondary); line-height:1.5; }}
    .signal-bucket {{ margin-top:var(--space-lg); overflow:visible; }}
    .signal-columns {{ display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:var(--space-lg); margin-top:var(--space-md); }}
    .signal-group {{ display:grid; gap:var(--space-md); }}
    .signal-list {{ display:grid; gap:10px; }}
    .signal-card {{ display:grid; grid-template-columns:40px minmax(0,1fr); gap:var(--space-md); align-items:start; padding:14px 0; border-top:1px solid var(--border); }}
    .signal-card:first-child {{ border-top:1px solid var(--border-visible); }}
    .video-card-link {{ display:block; color:inherit; text-decoration:none; }}
    .video-card-link:hover article {{ border-color:var(--accent); }}
    .signal-rank {{ font-family:'Space Mono',monospace; color:var(--text-disabled); font-size:16px; }}
    .signal-main {{ min-width:0; }}
    .signal-name {{ color:var(--text-display); font-size:18px; line-height:1.25; overflow-wrap:anywhere; word-break:break-word; }}
    .signal-age {{ margin-top:4px; font-family:'Space Mono',monospace; color:var(--text-secondary); font-size:11px; letter-spacing:.04em; }}
    .signal-metrics {{ grid-column:2; display:flex; flex-wrap:wrap; justify-content:flex-start; gap:8px; margin-top:8px; }}
    .signal-metrics span {{ border:1px solid var(--border-visible); border-radius:999px; padding:4px 9px; font-family:'Space Mono',monospace; color:var(--text-secondary); font-size:11px; white-space:nowrap; }}
    .novelty-badge {{ border-radius:999px; padding:2px 8px; font-size:10px; font-family:'Space Mono',monospace; letter-spacing:.06em; margin-left:8px; vertical-align:middle; display:inline-block; }}
    .novelty-badge.first-seen {{ background:var(--accent); color:#FFF; border:1px solid var(--accent); }}
    .signal-empty {{ padding:var(--space-lg); }}
    table {{ width:100%; border-collapse:collapse; min-width:760px; }}
    th {{ text-align:left; padding:0 12px 12px; border-bottom:1px solid var(--border-visible); font-weight:400; }}
    td {{ padding:14px 12px; border-bottom:1px solid var(--border); color:var(--text-primary); }}
    td:nth-child(2) {{ max-width:420px; overflow-wrap:anywhere; word-break:break-word; }}
    td:first-child, td:nth-child(n+3) {{ font-family:'Space Mono',monospace; color:var(--text-secondary); }}
    td:nth-child(n+3), th:nth-child(n+3) {{ text-align:right; }}
    .empty {{ border:1px solid var(--border-visible); border-radius:16px; padding:var(--space-2xl); color:var(--text-secondary); font-family:'Space Mono',monospace; font-size:12px; letter-spacing:.04em; }}
    footer {{ margin-top:var(--space-3xl); padding-top:var(--space-lg); border-top:1px solid var(--border); color:var(--text-disabled); font-family:'Space Mono',monospace; font-size:11px; letter-spacing:.06em; text-transform:uppercase; }}
    @media (max-width:1100px) {{ header,.video-card,.lower-grid,.signal-columns {{ grid-template-columns:1fr; }} .video-data {{ margin-top:var(--space-md); }} }}
    @media (max-width:760px) {{ .grid-metrics {{ grid-template-columns:1fr 1fr; }} .page {{ padding-top:24px; }} header {{ min-height:auto; }} .signal-card {{ grid-template-columns:36px 1fr; }} .signal-metrics {{ grid-column:2; justify-content:flex-start; }} }}
  </style>
</head>
<body>
  <main class=\"page\">
    <header>
      <div>
        <div class=\"eyebrow\">TIKTOK 热点分析 / 美区女装</div>
        <h1>热点<br>此刻</h1>
        <p class=\"hero-subtitle\">这是一份静态热点报告，用发布时间分桶识别新近热点，用长期热词区块观察老词是否仍在扩散。</p>
      </div>
      <div class=\"hero-metric\">
        <div class=\"hero-number\">{short_number(hero.get('view_count'))}</div>
        <div class=\"hero-label\">累计播放最高</div>
        <p class=\"meta\">{text(hero.get('creator'))} / 视频年龄 {text(hero_growth.get('video_age_hours'))} 小时 / {timeliness_label_text(hero_growth.get('timeliness_label'))}</p>
      </div>
    </header>

    <section class=\"grid-metrics\">
      {metric('有效视频数', short_number(report.get('unique_video_count')))}
      {metric('前序命中数', short_number(window.get('matched_previous_video_count')))}
      {metric('分析窗口', f"{float(window.get('interval_hours') or 0):.2f}", '小时')}
      {metric('重复率', percent(cm.get('duplicate_rate')))}
      {metric('计划运行数', short_number(cm.get('planned_run_count')))}
      {metric('失败窗口', short_number(cm.get('failed_run_count')))}
      {metric('请求总量', short_number(cm.get('requested_total_limit')))}
      {metric('数据来源', source_summary)}
    </section>
    {f'<p class="panel-note" style="margin:-24px 0 24px;font-size:11px;">{html.escape(cm.get("cost_model_note", ""))}</p>' if cm.get("cost_model_note") else ''}

    <section>
      <div class=\"section-head\">
        <div>
          <div class=\"section-label\">新近视频 / 发布时间分桶</div>
          <h2>按发布时间分级的热点视频</h2>
        </div>
        <p>这里不依赖前后两轮是否抓到同一条视频，而是直接按视频发布时间分成近一天、三天、七天和两周，每个分级最多展示 5 条热视频。</p>
      </div>
      {recent_video_cards}
    </section>

    <section>
      <div class=\"section-head\">
        <div>
          <div class=\"section-label\">累计热度 / 总量排行</div>
          <h2>累计热视频</h2>
        </div>
        <p>这一部分按总播放和互动加权排序，可能包含早就已经很热的长尾视频，需要结合视频年龄和时效状态一起判断。</p>
      </div>
      <div class=\"video-stack\">{cumulative_cards}</div>
    </section>

    <section>
      <div class=\"section-head\">
        <div>
          <div class=\"section-label\">新晋趋势信号 / 发布时间分桶</div>
          <h2>新晋关键词与 TikTok 标签</h2>
        </div>
        <p>按一天、三天、七天、两周和一个月分桶；每个时间段分热度排行和覆盖排行两列展示。带有<span style=\"color:var(--accent)\">新见</span>标记的词在该时间段出现，但在更长时间段（如近七天 / 近两周 / 近一个月）的样本中未出现，代表它可能是本轮新冒出的信号。覆盖分（video_count × log(1 + avg_score_per_video)）用于衡量一个词出现在多少视频中且有基础互动。</p>
      </div>
      {recent_signals}
    </section>

    <section>
      <div class=\"section-head\">
        <div>
          <div class=\"section-label\">长期热词 / 扩散监控</div>
          <h2>长期热门关键词</h2>
        </div>
        <p>长期热词与新近关键词分开：只有长期热词才看“是否仍在扩散”，新近词优先看出现时间和短期热度。</p>
      </div>
      {established_terms}
      {established_hashtags}
    </section>

    <section class=\"lower-grid\">
      {ranked_list('热门音乐', report.get('top_music') or [])}
      {ranked_list('热门达人', report.get('top_creators') or [])}
    </section>

    <footer>
      生成时间 {text(report.get('generated_at'))} / 当前快照 {text(window.get('current_snapshot_time'))} / 前序快照 {text(window.get('previous_snapshot_time'))}
    </footer>
  </main>
</body>
</html>
"""


def write_html(content: str, output_dir: Path) -> Path:
    output_dir = output_dir if output_dir.is_absolute() else PROJECT_ROOT / output_dir
    output_dir.mkdir(parents=True, exist_ok=True)
    path = output_dir / f"tiktok_hotspot_report_{utc_timestamp()}.html"
    path.write_text(content, encoding="utf-8")
    return path


def main() -> int:
    args = parse_args()
    try:
        report_path = resolve_path(args.report) if args.report else latest_report()
        report = load_report(report_path)
        output_path = write_html(render_html(report), args.output_dir)
    except ValueError as exc:
        print(f"render error: {exc}")
        return 2
    print(f"html_report: {output_path}")
    print(f"source_report: {report_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/tiktok_login_save_session.py
content: import asyncio
import json
import sys
from pathlib import Path

from playwright.async_api import async_playwright

SESSION_FILE = Path(__file__).resolve().parents[1] / "data" / "tiktok_session.json"


async def main():
    print("Opening TikTok login page...", flush=True)
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=False)
        context = await browser.new_context(
            viewport={"width": 1280, "height": 800},
            locale="en-US",
        )
        page = await context.new_page()

        await page.goto("https://www.tiktok.com/login", timeout=60000, wait_until="networkidle")
        print("=" * 60, flush=True)
        print("请在弹出的浏览器窗口中登录 TikTok。", flush=True)
        print("登录成功后，脚本会自动检测并保存 session。", flush=True)
        print("如不想继续，关闭浏览器窗口即可退出。", flush=True)
        print("=" * 60, flush=True)

        # Wait for navigation away from /login page (indicates login success)
        logged_in = False
        for _ in range(300):  # 5 minutes max
            current = page.url
            if "/login" not in current and "tiktok.com" in current:
                logged_in = True
                break
            await asyncio.sleep(1)
        else:
            print("登录超时。如果已登录，按 Ctrl+C 手动保存。", flush=True)

        if not logged_in:
            await browser.close()
            print("未检测到登录，退出。", flush=True)
            return

        await asyncio.sleep(3)  # Let cookies/state settle

        # Save storage state
        state = await context.storage_state()
        SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
        SESSION_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
        print(f"✅ 登录态已保存到: {SESSION_FILE}", flush=True)
        print(f"Cookies: {len(state.get('cookies', []))} 个", flush=True)

        # Quick verification
        await page.goto("https://www.tiktok.com/search/video?q=test", timeout=30000, wait_until="domcontentloaded")
        await asyncio.sleep(3)
        title = await page.title()
        print(f"搜索页标题: {title}", flush=True)
        if "Log in" not in title:
            print("✅ 登录状态有效！可关闭浏览器窗口。", flush=True)
        else:
            print("⚠️ 登录可能未成功，请重试", flush=True)

        await browser.close()


if __name__ == "__main__":
    asyncio.run(main())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)