Skill: if530770/dy-hot-trend (version 1.0.2)

--- name: douyin-hot-trend description: > 抖音热榜神器 — 可以实时追踪抖音热点、挖掘爆款规律，并支持历史热榜数据回溯。使用时只需输入“查抖音热榜”“抖音热搜”“今日热榜”“近7天热榜”“历史热榜”等触发词，或订阅热榜推送，即可快速获取当前及过往的热门话题，帮助内容创作者和运营者高效蹭热点、找选题。 --- # 🔥 抖音热榜 ## 这是什么 一个帮你搞定抖音热点全流程的小助手。 **能做什么：** - 📡 实时获取抖音热榜数据，每小时自动更新 - 📅 查询近7天、近30天历史热榜，回溯热点轨迹 - 🧠 从创作者视角拆解爆款规律，给出可落地的选题和标题公式 - 📊 生成紫色极简风 HTML 可视化页面，支持导出 PDF - 🔔 可订阅每日/每小时热榜推送，不错过任何一个流量密码 ## 什么时候用它 | 场景 | 举例 | | ---------------------- | ------------------------------ | | 想知道今天抖音在火什么 | "帮我看看抖音热榜" | | 追踪某个话题的历史热度 | "查一下这周的热榜变化" | | 写内容前找选题灵感 | "最近什么话题最热？" | | 研究爆款标题的套路 | "分析下最近热榜标题有什么规律" | | 定期追踪热点做运营 | "每天早上给我推送热榜" | ## 怎么用 ``` 查今日抖音热榜 查近7天热榜 看看4月1日的热榜 分析热榜标题规律 帮我订阅每天早上9点的热榜推送 ``` ## 快速开始 > **⚠️ 触发本技能时，必须先读取以下文件获取完整执行流程：** > > 📄 **[assets/core-workflow.md](assets/core-workflow.md)** 读取方式：用 `read_file` 工具读取 `assets/core-workflow.md`（相对于本技能根目录），然后严格按照其中定义的步骤依次执行。 ## 注意事项 - 🌐 需要网络连接，请确保网络通畅 - ⏰ 历史热榜最长查询30天，超出范围会报错 - 📌 分析结论全部基于真实数据，不编造，给出的建议都是可执行的 - 💾 订阅偏好需要你明确回复确认才会记录 - 📄 HTML 和 PDF 生成是自动执行的，不需要你额外操作 ## 资源一览 | 资源 | 路径 | | -------------------- | --------------------------------------- | | 核心执行流程（必读） | `assets/core-workflow.md` | | 数据获取脚本 | `scripts/hotspot_fetcher.py` | | HTML 生成脚本 | `scripts/gen_douyin_hot_html.py` | | HTML 模板 | `assets/douyin_hot_trend_template.html` |

path: _skillhub_meta.json
content: name: douyin-hot-trend
installedAt: 1777261886760
source: import

path: scripts/gen_douyin_hot_html.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
抖音热榜HTML生成器

从API获取实时热榜数据，生成可独立打开的HTML页面。
用法：python gen_douyin_hot_html.py [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--days N]
输出：douyin_hot_trend.html（与脚本同目录）

样式特性：
- 紫色系极简风格（#6c5ce7 / #a29bfe）
- 卡片式表格（border-collapse: separate，每行独立圆角白卡）
- TOP3 奖牌徽章 + 对应色竖线边框
- 4+ 序号深灰小字
- 热度值纯紫色 #6c5ce7
- 整行点击跳转（location.href，兼容 file:// 协议）
- 导出 PDF 功能（仅截取内容区，不含按钮栏，单页 A4 自适应缩放）
- 页面最大宽度 750px
"""

import json
import sys
import os
import socket
import ssl
from datetime import datetime, timedelta


def fetch_douyin_hotspot(start_date=None, end_date=None, days=None):
    """获取抖音热榜数据"""
    host = "onetotenvip.com"
    ip = "8.154.41.7"
    path = "/story/hotSpot/getListByPlatform"

    base_params = "platform=2&source=%E6%8A%96%E9%9F%B3%E7%83%AD%E6%A6%9C"

    date_range = ""
    query_type = "实时"

    if days:
        today = datetime.now().date()
        end_date_obj = today
        start_date_obj = today - timedelta(days=days)
        start_date = start_date_obj.strftime("%Y-%m-%d")
        end_date = end_date_obj.strftime("%Y-%m-%d")
        query_type = f"近{days}天"

    if start_date and end_date:
        date_range = f"&startDate={start_date}&endDate={end_date}"
        query_type = f"{start_date} 至 {end_date}"

    params = base_params + date_range

    http_request = (
        "GET {}?{} HTTP/1.1\r\n"
        "Host: {}\r\n"
        "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\r\n"
        "Accept: application/json, text/plain, */*\r\n"
        "Connection: close\r\n"
        "\r\n"
    ).format(path, params, host)

    raw_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    raw_socket.settimeout(30)
    raw_socket.connect((ip, 443))

    context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    context.check_hostname = False
    context.verify_mode = ssl.CERT_NONE

    ssl_socket = context.wrap_socket(raw_socket, server_hostname=None)
    ssl_socket.send(http_request.encode('utf-8'))

    response = b""
    while True:
        try:
            chunk = ssl_socket.recv(8192)
            if not chunk:
                break
            response += chunk
        except socket.timeout:
            break

    ssl_socket.close()
    raw_socket.close()

    response_text = response.decode('utf-8', errors='ignore')

    if "\r\n\r\n" in response_text:
        _, body = response_text.split("\r\n\r\n", 1)
    elif "\n\n" in response_text:
        _, body = response_text.split("\n\n", 1)
    else:
        body = response_text

    api_response = json.loads(body)

    if isinstance(api_response, dict):
        data = api_response.get("data", api_response.get("list", []))
    elif isinstance(api_response, list):
        data = api_response
    else:
        data = []

    # 处理数据：去除标题中的所有空格（半角空格、全角空格、制表符、换行符等）
    for item in data:
        if 'title' in item and item['title']:
            # 去除半角空格、全角空格、制表符、换行符等所有空白字符
            item['title'] = ''.join(item['title'].split())
        if 'word' in item and item['word']:
            item['word'] = ''.join(item['word'].split())

    return {
        "fetch_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "query_type": query_type,
        "hot_list": data
    }


def generate_desc(title, hot_count):
    """根据标题和热度值生成核心内容摘要（30-80字）"""
    import re

    desc = title
    hot_str = f"热度{hot_count}"

    # 核心内容生成规则 - 基于标题关键词匹配
    if any(k in title for k in ['春天', '春日', '花开', '樱花', '踏青']):
        return f"春季相关话题持续升温，用户分享春日生活美好瞬间与旅行记录，展现春日美景与生活方式，引发大量互动讨论，当前{hot_str}"
    elif any(k in title for k in ['裙摆', '穿搭', '时尚', '美妆', '妆容', '造型']):
        return f"时尚穿搭类话题火爆，博主分享穿搭技巧与造型灵感，年轻用户积极参与模仿创作，带动相关话题持续走高，当前{hot_str}"
    elif any(k in title for k in ['海洋', '科技', '技术', '突破', '创新', '研发']):
        return f"硬核科技/工业成就引发全民关注，展现中国技术实力与发展成果，网友纷纷点赞转发表达民族自豪感，当前{hot_str}"
    elif any(k in title for k in ['骑马', '公主', '古装', '汉服', 'cosplay']):
        return f"古风/角色扮演类内容走红，创作者通过特色造型吸引关注，用户参与度高，评论区互动热烈，当前{hot_str}"
    elif any(k in title for k in ['对镜', '自拍', '拍照', '镜头', '摄影']):
        return f"摄影/自拍技巧类内容广受欢迎，创作者分享实用拍摄方法，帮助普通用户提升出片质量，传播度极高，当前{hot_str}"
    elif any(k in title for k in ['出游', '旅行', '旅游', '景点', '打卡']):
        return f"旅游出行话题热度攀升，各地景点迎来游客高峰，用户分享旅行攻略与见闻，激发更多人规划行程，当前{hot_str}"
    elif any(k in title for k in ['赖清德', '弹劾', '政治', '政策', '政府', '官员']):
        return f"时政类重大事件引发全网热议，各平台讨论量激增，用户密切关注事态发展，相关分析解读视频获得高播放，当前{hot_str}"
    elif any(k in title for k in ['房价', '楼市', '房贷', '购房', '经济', 'GDP', '股市', 'A股']):
        return f"财经/民生话题牵动大众神经，专业机构与个人投资者高度关注市场动态，各类解读分析内容刷屏，当前{hot_str}"
    elif any(k in title for k in ['电影', '剧集', '开播', '定档', '综艺', '选秀', '歌手']):
        return f"影视娱乐类话题霸榜，新作品/节目上线引发追剧热潮，明星动态与剧情讨论占据热搜前列，当前{hot_str}"
    elif any(k in title for k in ['游戏', '电竞', '比赛', '战队', '选手']):
        return f"游戏/电竞赛事话题火热，职业赛事精彩操作被广泛传播，玩家社区讨论氛围活跃，相关二创内容爆发式增长，当前{hot_str}"
    elif any(k in title for k in ['美食', '做饭', '食谱', '奶茶', '探店', '餐厅']):
        return f"美食类内容持续吸睛，创作者推荐美食做法与探店体验，激发用户尝试欲望与打卡热情，评论区求教程留言众多，当前{hot_str}"
    elif any(k in title for k in ['萌宠', '猫咪', '狗狗', '动物', '可爱']):
        return f"宠物/动物类治愈系内容广受喜爱，萌宠日常视频轻松获取百万播放，用户在评论区晒出自家毛孩子照片，互动率极高，当前{hot_str}"
    elif any(k in title for k in ['健身', '减肥', '运动', '瑜伽', '瘦身']):
        return f"健康运动话题受关注度提升，健身达人分享训练计划与饮食建议，激励大批用户开启锻炼模式，跟练打卡成风潮，当前{hot_str}"
    elif any(k in title for k in ['教育', '高考', '考研', '学校', '老师', '家长']):
        return f"教育相关话题引发家长群体强烈共鸣，升学政策与学习方法的讨论热度居高不下，干货分享型内容获大量收藏，当前{hot_str}"
    elif any(k in title for k in ['恋爱', '感情', '婚姻', '分手', '相亲']):
        return f"情感婚恋话题触动年轻人共鸣，真实故事分享与情感分析视频引发深度讨论，用户在评论区倾诉经历寻求建议，当前{hot_str}"
    elif any(k in title for k in ['职场', '工资', '面试', '辞职', '老板', '打工']):
        return f"职场话题直击打工人痛点，薪资待遇、工作体验等议题引发广泛共鸣，职场经验分享内容获高收藏转发，当前{hot_str}"
    else:
        # 兜底：基于标题长度智能扩展
        return f"该话题在抖音平台引发广泛关注与讨论，大量创作者围绕此主题产出优质内容，用户互动活跃，相关视频播放量持续增长，当前{hot_str}"


def generate_html(result, top_n=20):
    """生成HTML页面 - 紫色极简风格

    Args:
        result: 热榜数据结果
        top_n: 显示条数，默认20，可设为50

    重要：只传递实际需要展示的数据到HTML，确保统计数据与展示数据一致
    """
    hot_list = result["hot_list"]
    fetch_time = result["fetch_time"]
    query_type = result["query_type"]

    # 限制显示条数 - 必须先截取，确保统计数据准确
    top_n = min(top_n, 50)  # 最大支持TOP50
    # ⚠️ 关键：只保留实际需要展示的数据，确保HTML中统计数据与展示数据一致
    hot_list = hot_list[:top_n]

    def fmt_hot_value(n):
        """格式化热度值 - 保持与表格显示一致"""
        n = int(n or 0)
        if n >= 100000000:
            return f"{n / 100000000:.1f}亿"
        if n >= 10000:
            # 保留一位小数，如 1109.6w
            return f"{n / 10000:.1f}w"
        return str(n)

    # 为每条数据生成核心内容摘要（如果API没有返回desc字段）
    for item in hot_list:
        if not item.get('desc') and not item.get('excerpt'):
            title = item.get('title', '') or item.get('word', '')
            hot_count = item.get('hotCount', '') or item.get('hotValue', '0')
            item['_genDesc'] = generate_desc(title, fmt_hot_value(hot_count))

    js_data = json.dumps(hot_list, ensure_ascii=False, indent=2)

    if query_type == "实时":
        page_title = "抖音实时热榜"
    else:
        page_title = f"抖音热榜（{query_type}）"

    html = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{page_title}</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
    <style>
        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
        body {{
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', sans-serif;
            background: #f0f0f5;
            color: #333;
            line-height: 1.6;
        }}

        /* ===== 页面容器 - 最大宽度750px ===== */
        .page-wrap {{
            max-width: 750px;
            margin: 0 auto;
            padding: 16px 16px 32px;
        }}

        /* ===== 导出按钮栏 - 不参与PDF导出 ===== */
        .export-bar {{
            position: sticky;
            top: 0;
            z-index: 100;
            display: flex;
            justify-content: flex-end;
            padding: 10px 0 12px;
            margin-bottom: 4px;
        }}
        .btn-export-pdf {{
            background: #fff;
            color: #6c5ce7;
            border: 1.5px solid #6c5ce7;
            border-radius: 20px;
            padding: 7px 22px;
            font-size: 13px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.2s ease;
        }}
        .btn-export-pdf:hover {{ background: #f8f6ff; transform: translateY(-1px); }}
        .btn-export-pdf:active {{ transform: translateY(0); }}

        /* ===== PDF内容区域 ===== */
        .pdf-content {{ background: transparent; }}

        /* ===== 头部卡片 - 紫色渐变 ===== */
        .hot-title-wrap {{
            background: linear-gradient(135deg, #6c5ce7, #a29bfe);
            border-radius: 14px;
            padding: 24px 24px 20px;
            margin-bottom: 18px;
            color: #fff;
            text-align: center;
        }}
        .hot-title-wrap h1 {{
            font-size: 22px; font-weight: 800;
            letter-spacing: 0.5px;
        }}
        .hot-update-time {{
            margin-top: 4px;
            font-size: 12.5px;
            opacity: 0.85;
        }}

        /* 统计卡片区 */
        .stats-row {{
            display: flex;
            justify-content: center;
            gap: 16px;
            margin-top: 16px;
        }}
        .stat-card {{
            background: rgba(255,255,255,0.18);
            backdrop-filter: blur(10px);
            -webkit-backdrop-filter: blur(10px);
            border-radius: 11px;
            padding: 11px 20px;
            min-width: 95px;
            text-align: center;
        }}
        .stat-num {{ font-size: 21px; font-weight: 800; }}
        .stat-label {{ font-size: 11.5px; opacity: 0.85; margin-top: 2px; }}

        /* ===== 表格区域 ===== */
        .table-area {{ background: transparent; }}

        /* 卡片式表格 */
        .hot-table {{
            width: 100%;
            border-collapse: separate;
            border-spacing: 0 6px;
        }}
        .hot-table thead {{ display: none; }}

        .hot-table tbody tr {{
            background: #fff;
            border-radius: 10px;
            transition: all 0.2s ease;
            cursor: pointer;
        }}
        .hot-table tbody tr:hover {{
            transform: translateY(-1.5px);
            box-shadow: 0 6px 20px rgba(108,92,231,0.15);
        }}

        .hot-table td {{
            padding: 13px 14px;
            vertical-align: middle;
            border: none;
        }}
        .hot-table tr td:first-child {{ border-radius: 10px 0 0 10px; }}
        .hot-table tr td:last-child {{ border-radius: 0 10px 10px 0; }}

        /* 排名序号 */
        .rank-cell {{ width: 58px; text-align: center; }}

        /* TOP3 奖牌徽章 */
        .rank-badge {{
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 31px; height: 31px;
            border-radius: 50%;
            font-size: 14px;
            font-weight: 800;
            position: relative;
        }}
        /* TOP1 紫红 */
        .rank-badge.top1 {{
            background: linear-gradient(135deg, #e056a0, #a855f7);
            color: #fff;
            box-shadow: 0 2px 8px rgba(168,85,247,0.35);
        }}
        /* TOP2 蓝紫 */
        .rank-badge.top2 {{
            background: linear-gradient(135deg, #6c5ce7, #74b9ff);
            color: #fff;
            box-shadow: 0 2px 8px rgba(108,92,231,0.30);
        }}
        /* TOP3 浅紫 */
        .rank-badge.top3 {{
            background: linear-gradient(135deg, #a29bfe, #dfe6e9);
            color: #6c5ce7;
            box-shadow: 0 2px 8px rgba(162,155,254,0.35);
        }}
        /* 4+ 序号 */
        .rank-normal {{
            font-size: 13px;
            font-weight: 700;
            color: #555;
        }}

        /* TOP1-3 行左侧色条 + 背景色 */
        .hot-table tbody tr.row-top1 {{ background: linear-gradient(90deg, rgba(168,85,247,0.07), #fff 6%); }}
        .hot-table tbody tr.row-top2 {{ background: linear-gradient(90deg, rgba(108,92,231,0.07), #fff 6%); }}
        .hot-table tbody tr.row-top3 {{ background: linear-gradient(90deg, rgba(162,155,254,0.09), #fff 6%); }}

        .hot-table tbody tr.row-top1 > td:first-child {{ border-left: 3.5px solid #a855f7; }}
        .hot-table tbody tr.row-top2 > td:first-child {{ border-left: 3.5px solid #6c5ce7; }}
        .hot-table tbody tr.row-top3 > td:first-child {{ border-left: 3.5px solid #a29bfe; }}

        /* 标题描述 */
        .info-cell {{}}
        .topic-title-link {{
            text-decoration: none;
            display: block;
        }}
        .topic-title-link:hover .topic-title {{
            color: #6c5ce7;
        }}
        .topic-title {{
            font-size: 14.5px;
            font-weight: 650;
            color: #222;
            line-height: 1.45;
            word-break: break-all;
            transition: color 0.2s ease;
        }}
        .topic-desc {{
            font-size: 12.5px;
            color: #888;
            margin-top: 3px;
            line-height: 1.4;
            word-break: break-all;
        }}

        /* 热度值 - 纯紫色 */
        .heat-cell {{ text-align: right; white-space: nowrap; width: 110px; }}
        .heat-value {{
            font-size: 14px;
            font-weight: 800;
            color: #6c5ce7;
        }}

        /* 标签 */
        .tag {{
            display: inline-block;
            font-size: 11px;
            font-weight: 600;
            padding: 2px 9px;
            border-radius: 10px;
            margin-right: 4px;
            vertical-align: middle;
        }}
        .tag-hot {{ background: rgba(231,76,60,0.08); color: #e74c3c; }}
        .tag-new {{ background: rgba(52,152,219,0.08); color: #3498db; }}
        .tag-hot-rising {{ background: rgba(230,126,34,0.1); color: #e67e22; }}
        .tag-descend {{ background: rgba(149,165,166,0.1); color: #7f8c8d; }}
        .tag-steady {{ background: rgba(39,174,96,0.1); color: #27ae60; }}

        /* 时间标签 */
        .time-tag {{
            font-size: 11.5px;
            color: #999;
            white-space: nowrap;
        }}

        /* 底部说明 */
        .footer-note {{
            text-align: center;
            font-size: 11.5px;
            color: #bbb;
            margin-top: 20px;
            padding: 10px 0;
        }}

        @media (max-width: 480px) {{
            .page-wrap {{ padding: 10px 8px 24px; }}
            .hot-title-wrap {{ padding: 18px 16px 16px; }}
            .stats-row {{ gap: 8px; }}
            .stat-card {{ padding: 8px 14px; min-width: 75px; }}
            .stat-num {{ font-size: 18px; }}
            .hot-table td {{ padding: 10px 10px; }}
            .topic-title {{ font-size: 13.5px; }}
            .heat-value {{ font-size: 15px; }}
        }}
    </style>
</head>
<body>

<div class="page-wrap">
    <!-- 导出按钮（不包含在PDF内） -->
    <div class="export-bar">
        <button class="btn-export-pdf" onclick="exportPdf()">导出 PDF</button>
    </div>

    <!-- PDF内容区域 -->
    <div class="pdf-content" id="pdfContent">

        <!-- 头部 -->
        <div class="hot-title-wrap">
            <h1>🔥 {page_title}</h1>
            <div class="hot-update-time">更新时间：{fetch_time}</div>
            <div class="stats-row">
                <div class="stat-card">
                    <div class="stat-num" id="totalCount">--</div>
                    <div class="stat-label">话题总数</div>
                </div>
                <div class="stat-card">
                    <div class="stat-num" id="maxHeat">--</div>
                    <div class="stat-label">最高热度</div>
                </div>
                <div class="stat-card">
                    <div class="stat-num" id="avgHeat">--</div>
                    <div class="stat-label">平均热度</div>
                </div>
            </div>
        </div>

        <!-- 表格 -->
        <div class="table-area">
            <table class="hot-table">
                <thead>
                    <tr><th>排名</th><th>话题信息</th><th>热度值</th></tr>
                </thead>
                <tbody id="hotTableBody"></tbody>
            </table>
        </div>

        <div class="footer-note">数据来源：抖音 · 仅供参考</div>
    </div><!-- /pdf-content -->
</div><!-- /page-wrap -->

<script>
// 行点击跳转 - 使用 location.href 避免 file:// 下 window.open 被拦截
(function() {{
    function bindRowClick() {{
        var rows = document.querySelectorAll('.hot-table tbody tr[data-href]');
        rows.forEach(function(row) {{
            row.style.cursor = 'pointer';
            row.addEventListener('click', function(e) {{
                if (e.target.closest('button, a, .btn-export-pdf')) return;
                var url = this.getAttribute('data-href');
                if (url) window.location.href = url;
            }});
        }});
    }}
    if (document.readyState === 'loading') {{
        document.addEventListener('DOMContentLoaded', bindRowClick);
    }} else {{
        bindRowClick();
    }}
}})();

// 渲染热榜数据
(function() {{
    var RAW = {js_data};

    // 统计
    var totalCount = RAW.length;
    var maxH = 0, sumH = 0;
    for (var i = 0; i < RAW.length; i++) {{
        var h = parseInt(RAW[i].hotCount || RAW[i].hotValue || 0);
        if (h > maxH) maxH = h;
        sumH += h;
    }}
    var avgH = totalCount > 0 ? sumH / totalCount : 0;

    function fmtHot(n) {{
        n = parseInt(n || 0);
        if (n >= 100000000) return (n / 100000000).toFixed(1) + '亿';
        if (n >= 10000) return (n / 10000).toFixed(1) + 'w';  // 保留一位小数，如 1109.6w
        return n.toString();
    }}

    // 更新统计
    document.getElementById('totalCount').textContent = totalCount;
    document.getElementById('maxHeat').textContent = fmtHot(maxH);
    document.getElementById('avgHeat').textContent = fmtHot(Math.round(avgH));

    // 保持API返回的原始排名顺序（不重新排序）
    // ⚠️ 数据一致性要求：必须使用API返回的原始数据，禁止修改排名、热度、链接
    // RAW.sort(function(a, b) {{ return (a.index || 999) - (b.index || 999); }});

    // 渲染表格 - 显示TOP{top_n}
    // ⚠️ 每条数据的URL必须是API返回的真实链接，禁止伪造或替换
    var html = '';
    for (var i = 0; i < RAW.length && i < {top_n}; i++) {{
        var d = RAW[i];
        var rank = i + 1;
        var title = d.title || d.word || '--';
        var heatVal = d.hotCount || d.hotValue || 0;
        var desc = d.desc || d.excerpt || d._genDesc || '';
        // 链接必须使用API返回的真实URL
        var url = d.url || d.schemeUrl || '#';

        // TOP3 样式类
        var rowCls = '', badgeHtml = '', tagHtml = '';

        if (rank === 1) {{
            rowCls = 'row-top1';
            badgeHtml = '<span class="rank-badge top1">🥇</span>';
            tagHtml = '<span class="tag tag-new">新</span>';
        }} else if (rank === 2) {{
            rowCls = 'row-top2';
            badgeHtml = '<span class="rank-badge top2">🥈</span>';
            tagHtml = '<span class="tag tag-hot">热</span>';
        }} else if (rank === 3) {{
            rowCls = 'row-top3';
            badgeHtml = '<span class="rank-badge top3">🥉</span>';
            tagHtml = '<span class="tag tag-hot-rising">升</span>';
        }} else {{
            badgeHtml = '<span class="rank-normal">' + rank + '</span>';
        }}

        html += '<tr class="' + rowCls + '" data-href="' + url + '">'
            + '<td class="rank-cell">' + badgeHtml + '</td>'
            + '<td class="info-cell">'
            + '<a class="topic-title-link" href="' + url + '" target="_blank" onclick="event.stopPropagation()"><div class="topic-title">' + title + '</div></a>'
            + '<div class="topic-desc">' + desc + '</div>'
            + '</td><td class="heat-cell">'
            + '<div class="heat-value">' + fmtHot(heatVal) + '</div>';
        if (tagHtml) {{
            html += '<div style="margin-top:3px">' + tagHtml + '</div>';
        }}
        html += '</td></tr>';
    }}

    document.getElementById('hotTableBody').innerHTML = html;

    // 重新绑定行点击事件（动态插入后）
    var rows = document.querySelectorAll('.hot-table tbody tr[data-href]');
    rows.forEach(function(row) {{
        row.addEventListener('click', function(e) {{
            if (e.target.closest('button, a, .btn-export-pdf')) return;
            var u = this.getAttribute('data-href');
            if (u && u !== '#') window.location.href = u;
        }});
    }});
}})();

// 导出PDF - 只截取 pdfContent 区域，单页自适应A4，支持链接跳转
function exportPdf() {{
    var btn = document.querySelector('.btn-export-pdf');
    btn.textContent = '生成中...';
    btn.style.pointerEvents = 'none';

    var target = document.getElementById('pdfContent');

    // 收集所有链接信息 - 链接必须是API返回的真实URL
    var links = [];
    var rows = document.querySelectorAll('.hot-table tbody tr[data-href]');

    rows.forEach(function(row, idx) {{
        var url = row.getAttribute('data-href');
        if (url && url !== '#') {{
            links.push({{
                url: url,
                top: row.offsetTop,
                height: row.offsetHeight,
                idx: idx
            }});
        }}
    }});

    html2canvas(target, {{
        scale: 2,
        useCORS: true,
        backgroundColor: '#f0f0f5',
        logging: false,
        windowWidth: target.scrollWidth,
        windowHeight: target.scrollHeight
    }}).then(function(canvas) {{
        var imgData = canvas.toDataURL('image/png');

        var pdf = new jspdf.jsPDF('p', 'mm', 'a4');
        var pdfW = pdf.internal.pageSize.getWidth();
        var pdfH = pdf.internal.pageSize.getHeight();
        var margin = 10;
        var contentW = pdfW - margin * 2;
        var contentH = pdfH - margin * 2;

        var imgW = contentW;
        var imgH = (canvas.height * imgW) / canvas.width;

        // 计算缩放比例
        var scaleX = imgW / target.scrollWidth;
        var scaleY = imgH / target.scrollHeight;

        // 计算内容在PDF中的位置
        var imgX, imgY;
        if (imgH > contentH) {{
            imgH = contentH;
            imgW = (canvas.width * imgH) / canvas.height;
            scaleX = imgW / target.scrollWidth;
            scaleY = imgH / target.scrollHeight;
            imgX = (contentW - imgW) / 2 + margin;
            imgY = margin;
        }} else {{
            imgX = margin;
            imgY = margin;
        }}

        // 添加图片
        pdf.addImage(imgData, 'PNG', imgX, imgY, imgW, imgH);

        // 添加可点击链接注解（覆盖整行）
        // 注意：PDF阅读器对链接注解的支持不一，但这是PDF规范的标准方式
        links.forEach(function(link) {{
            // 转换坐标：HTML像素 -> PDF毫米
            var pdfY = imgY + (link.top * scaleY);
            var pdfH = Math.max(link.height * scaleY, 3); // 最小高度3mm确保可点击
            var pdfX = imgX;
            var pdfW = imgW;

            // 使用 link 方法添加链接注解
            // 这是PDF规范的标准方式，大多数现代PDF阅读器支持
            pdf.link(pdfX, pdfY, pdfW, pdfH, {{ url: link.url }});
        }});

        var now = new Date();
        var dateStr = now.getFullYear() +
            String(now.getMonth()+1).padStart(2,'0') +
            String(now.getDate()).padStart(2,'0') +
            '_' +
            String(now.getHours()).padStart(2,'0') +
            String(now.getMinutes()).padStart(2,'0');

        pdf.save('{page_title}_' + dateStr + '.pdf');

        btn.textContent = '导出 PDF';
        btn.style.pointerEvents = '';

        // 提示用户PDF链接功能
        console.log('PDF已生成，包含' + links.length + '个可点击链接');
    }}).catch(function(err) {{
        alert('PDF 生成失败：' + err.message);
        btn.textContent = '导出 PDF';
        btn.style.pointerEvents = '';
    }});
}}
</script>

</body>
</html>'''
    return html


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description='生成抖音热榜HTML页面')
    parser.add_argument('--start-date', type=str, help='开始日期，格式 YYYY-MM-DD')
    parser.add_argument('--end-date', type=str, help='结束日期，格式 YYYY-MM-DD')
    parser.add_argument('--days', type=int, help='查询天数')
    parser.add_argument('--output', type=str, help='输出文件路径')
    parser.add_argument('--top', type=int, default=20, help='显示条数，默认20，可设为50')

    args = parser.parse_args()

    print("正在获取抖音热榜数据...", file=sys.stderr)
    result = fetch_douyin_hotspot(
        start_date=args.start_date,
        end_date=args.end_date,
        days=args.days
    )

    html = generate_html(result, top_n=args.top)

    if args.output:
        output_path = args.output
    else:
        output_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "douyin_hot_trend.html")

    with open(output_path, 'w', encoding='utf-8') as f:
        f.write(html)

    print(f"✅ 已生成：{output_path}", file=sys.stderr)
    print(f"📊 共 {len(result['hot_list'])} 条热榜数据，展示TOP{args.top}", file=sys.stderr)


path: scripts/hotspot_fetcher.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
抖音热榜数据获取脚本

功能：调用热榜API获取抖音实时热点数据
接口：https://onetotenvip.com/story/hotSpot/getListByPlatform
参数：platform=2, source=抖音热榜神器-ClawHub, startDate, endDate（可选）
方法：GET

特殊处理：未启用SNI（Server Name Indication）以适配特定SSL握手场景
"""

import json
import sys
import socket
import ssl
from datetime import datetime, timedelta
from urllib.parse import quote


def fetch_douyin_hotspot(start_date=None, end_date=None, days=None):
    """
    获取抖音热榜数据

    使用原生socket+ssl实现，未发送SNI以适配特定网络环境

    Args:
        start_date: 开始日期，格式 YYYY-MM-DD
        end_date: 结束日期，格式 YYYY-MM-DD
        days: 查询天数，如7表示近7天，30表示近30天

    Returns:
        None (结果直接打印到标准输出)
    """
    host = "onetotenvip.com"
    ip = "8.154.41.7"
    path = "/story/hotSpot/getListByPlatform"

    # 构建基础参数（source 固定为“抖音热榜神器 -SkillHub”）
    source = quote("抖音热榜神器-ClawHub")
    base_params = f"platform=2&source={source}"

    # 处理日期参数
    date_range = ""
    query_type = "实时"

    if days:
        # 根据天数计算日期范围
        # 日期范围是 [start_date, end_date)，end_date 为今天
        today = datetime.now().date()
        end_date_obj = today
        start_date_obj = today - timedelta(days=days)
        start_date = start_date_obj.strftime("%Y-%m-%d")
        end_date = end_date_obj.strftime("%Y-%m-%d")
        query_type = f"近{days}天"

    if start_date and end_date:
        date_range = f"&startDate={start_date}&endDate={end_date}"
        query_type = f"{start_date} 至 {end_date}"

    params = base_params + date_range

    http_request = (
        "GET {}?{} HTTP/1.1\r\n"
        "Host: {}\r\n"
        "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0\r\n"
        "Accept: application/json, text/plain, */*\r\n"
        "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n"
        "Connection: close\r\n"
        "\r\n"
    ).format(path, params, host)

    try:
        raw_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        raw_socket.settimeout(30)
        raw_socket.connect((ip, 443))

        context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE

        ssl_socket = context.wrap_socket(raw_socket, server_hostname=None)
        ssl_socket.send(http_request.encode('utf-8'))

        response = b""
        while True:
            try:
                chunk = ssl_socket.recv(8192)
                if not chunk:
                    break
                response += chunk
            except socket.timeout:
                break

        ssl_socket.close()
        raw_socket.close()

        response_text = response.decode('utf-8', errors='ignore')

        # 分离响应头和响应体
        if "\r\n\r\n" in response_text:
            _, body = response_text.split("\r\n\r\n", 1)
        elif "\n\n" in response_text:
            _, body = response_text.split("\n\n", 1)
        else:
            body = response_text

        # 解析JSON
        api_response = json.loads(body)

        # 处理不同的响应格式
        if isinstance(api_response, dict):
            # 格式1: {"code":2000, "data":[...]}
            if "data" in api_response:
                data = api_response["data"]
            elif "list" in api_response:
                data = api_response["list"]
            else:
                data = []
        elif isinstance(api_response, list):
            # 格式2: 直接是数组 [...]
            data = api_response
        else:
            data = []

        # 提取并格式化热榜数据
        if isinstance(data, list):
            # 获取当前时间
            now = datetime.now()
            fetch_time = now.strftime("%Y-%m-%d %H:00")

            result = {
                "fetch_time": fetch_time,
                "query_type": query_type,
                "start_date": start_date,
                "end_date": end_date,
                "hot_list": []
            }
            for item in data:
                # 处理标题：去除所有空格（半角空格、全角空格、制表符、换行符等）
                title = item.get("title", "")
                if title:
                    title = ''.join(title.split())
                result["hot_list"].append({
                    "index": item.get("index"),
                    "title": title,
                    "hotCount": item.get("hotCount", ""),
                    "url": item.get("url", "")
                })
            print(json.dumps(result, ensure_ascii=False, indent=2))
        else:
            print(json.dumps([], ensure_ascii=False))

    except json.JSONDecodeError as e:
        error_msg = {"error": f"JSON解析失败: {str(e)}"}
        print(json.dumps(error_msg, ensure_ascii=False))
        sys.exit(1)
    except ssl.SSLError as e:
        error_msg = {"error": f"SSL错误: {str(e)}"}
        print(json.dumps(error_msg, ensure_ascii=False))
        sys.exit(1)
    except Exception as e:
        error_msg = {"error": f"错误: {str(e)}"}
        print(json.dumps(error_msg, ensure_ascii=False))
        sys.exit(1)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description='获取抖音热榜数据')
    parser.add_argument('--start-date', type=str, help='开始日期，格式 YYYY-MM-DD')
    parser.add_argument('--end-date', type=str, help='结束日期，格式 YYYY-MM-DD')
    parser.add_argument('--days', type=int, help='查询天数，如7表示近7天，30表示近30天')

    args = parser.parse_args()

    fetch_douyin_hotspot(
        start_date=args.start_date,
        end_date=args.end_date,
        days=args.days
    )


path: assets/core-workflow.md
content: # 抖音热榜 — 核心执行流程

## 操作步骤

### 1. 获取热榜数据

#### 1.1 实时热榜（默认）

```bash
python scripts/hotspot_fetcher.py
```

#### 1.2 历史热榜查询

支持查询近7天、近30天的历史热榜数据：

```bash
# 查询昨日热榜（假设今天是2026-04-16）
python scripts/hotspot_fetcher.py --start-date 2026-04-15 --end-date 2026-04-16

# 查询近7天热榜（假设今天是2026-04-16）
python scripts/hotspot_fetcher.py --start-date 2026-04-09 --end-date 2026-04-16

# 查询近30天热榜（假设今天是2026-04-16）
python scripts/hotspot_fetcher.py --start-date 2026-03-17 --end-date 2026-04-16

# 查询4月1日热榜
python scripts/hotspot_fetcher.py --start-date 2026-04-01 --end-date 2026-04-02
```

**日期范围规则**：

- 日期范围是 **[start_date, end_date)** 左闭右开区间
- 例如：`start-date 2026-04-01 --end-date 2026-04-02` 查询的是4月1日当天的数据
- 例如：`start-date 2026-04-09 --end-date 2026-04-16` 查询的是4月9日至4月15日共7天的数据

**参数说明**：

- `--start-date`：开始日期（包含），格式 YYYY-MM-DD
- `--end-date`：结束日期（不包含），格式 YYYY-MM-DD
- `--days`：查询天数，自动计算日期范围（end_date为今天）
- **最长查询范围：30天**

#### 1.3 智能体判断逻辑

根据用户意图自动选择查询方式（假设今天日期为T）：

- "今日热榜" / "最新热榜" / "热榜" → 实时热榜（不传日期参数）
- "昨日热榜" / "昨天热榜" → `--start-date T-1 --end-date T`
- "近7天热榜" / "一周热榜" → `--start-date T-7 --end-date T`
- "近30天热榜" / "本月热榜" → `--start-date T-30 --end-date T`
- "X月X日热榜" → `--start-date X月X日 --end-date X月X日的下一天`

**示例**（假设今天是2026-04-16）：

| 用户表达     | start_date | end_date   | 查询范围             |
| ------------ | ---------- | ---------- | -------------------- |
| "昨日热榜"   | 2026-04-15 | 2026-04-16 | 4月15日当天          |
| "近7天热榜"  | 2026-04-09 | 2026-04-16 | 4月9日至4月15日共7天 |
| "4月1日热榜" | 2026-04-01 | 2026-04-02 | 4月1日当天           |

### 2. 展示热榜数据表格

智能体将脚本返回的JSON数据转换为表格形式展示：

**标题格式**：

```
抖音实时热榜（2026-04-14 16:00 每小时更新）
```

或历史热榜：

```
抖音历史热榜（2026-04-14 至 2026-04-15）
```

**表格格式**（默认展示TOP20数据，每条数据必须严格按规则输出）：

| 排名  | 热度值 | 话题                              | 核心内容                                                                           |
| ----- | ------ | --------------------------------- | ---------------------------------------------------------------------------------- |
| TOP01 | 902.4w | [用万能旅行拍照姿势美美出片](URL) | 旅行博主分享多种万能拍照姿势，帮助女生在旅行中拍出好看的照片，解决拍照不自然的问题 |
| TOP02 | 892.6w | [耗时三年拍下古诗词里的中国](URL) | 摄影师历时三年走遍中国，用镜头还原古诗词中的意境美景，展现传统文化之美             |
| TOP03 | 875.8w | [我拍到了海鸥雨](URL)             | 摄影爱好者在海边偶遇海鸥群飞，拍下壮观的海鸥雨画面，记录大自然的神奇瞬间           |
| TOP04 | 853.2w | [超日常美食教程速来get](URL)      | 美食博主分享简单易学的日常美食制作教程，教大家快速上手做出美味家常菜               |
| TOP05 | 843.7w | [定格这一刻的日照金山](URL)       | 旅行者在日照金山时刻拍下震撼美景，记录下日照金山的壮丽景色和难忘瞬间               |
| TOP06 | 832.1w | [你可以永远相信赛里木湖](URL)     | 旅游达人推荐新疆赛里木湖景点，展示湖泊的绝美风光，被称为大西洋最后一滴眼泪         |
| TOP07 | 818.7w | [拼豆上也可以作画了](URL)         | 手工达人用拼豆创作精美画作，将像素艺术与手工结合，展示创意手工制作过程             |
| TOP08 | 796.4w | [我的家庭旅行更像是打副本](URL)   | 博主分享带家人旅行的有趣经历，将家庭旅行比作游戏打副本，记录旅途中的欢乐时光       |
| TOP09 | 785.4w | [原来古诗词里的河南真的存在](URL) | 文化旅行博主探访河南古诗词中的景点，发现诗中描绘的美景真实存在，感受文化魅力       |
| TOP10 | 771.0w | [蒸出了奶香爆米花馒头](URL)       | 美食创作者创新制作奶香爆米花馒头，分享独特的面食做法，展示创意美食成果             |
| TOP11 | 758.6w | [话题标题](URL)                   | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整                     |
| TOP12 | 745.2w | [话题标题](URL)                   | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整                     |
| TOP13 | 732.8w | [话题标题](URL)                   | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整                     |
| TOP14 | 718.5w | [话题标题](URL)                   | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整                     |
| TOP15 | 705.3w | [话题标题](URL)                   | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整                     |
| TOP16 | 692.7w | [话题标题](URL)                   | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整                     |
| TOP17 | 680.1w | [话题标题](URL)                   | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整                     |
| TOP18 | 668.9w | [话题标题](URL)                   | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整                     |
| TOP19 | 655.4w | [话题标题](URL)                   | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整                     |
| TOP20 | 642.8w | [话题标题](URL)                   | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整                     |

**加载更多**：
表格下方显示可点击文案：

```
抖音实时热榜为你提供TOP50的数据，是否继续加载剩余30条？
```

- 用户点击确认后，**仅输出TOP21至TOP50的表格**（从TOP21开始，不重复输出TOP1-20）
- 用户拒绝则仅展示TOP20数据
- **用户确认加载更多后，必须生成新的HTML文件，HTML中展示完整的TOP50数据**

**加载更多表格示例**（用户确认后输出）：

| 排名  | 热度值 | 话题            | 核心内容                                                       |
| ----- | ------ | --------------- | -------------------------------------------------------------- |
| TOP21 | 632.5w | [话题标题](URL) | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整 |
| TOP22 | 628.1w | [话题标题](URL) | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整 |
| ...   | ...    | ...             | ...                                                            |
| TOP49 | 505.3w | [话题标题](URL) | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整 |
| TOP50 | 498.7w | [话题标题](URL) | 话题核心内容摘要，主要讲述什么人做了什么事，内容丰富且信息完整 |

**表格绘制规则（严格执行）**：

1. **排名**：
   - 默认查询：`TOP01` 到 `TOP20` 格式（双数字符）
   - 加载更多：`TOP21` 到 `TOP50` 格式（从TOP21开始，不重复输出TOP1-20）
2. **热度值**：
   - 格式：保留一位小数，单位统一为 `w`（小写）
   - 示例：`1109.6w`、`923.4w`、`85.2w`
   - HTML页面与表格显示必须完全一致
3. **话题**：使用Markdown链接格式 `[标题](URL)`，用户点击可直接跳转
4. **核心内容**：总结话题里的主要内容信息，主要讲的是什么人/事件/事情，控制字数不低于30字，不超过80字

### 3. 🔍 创作者洞察报告（核心功能）

展示完条形图后，**必须自动输出创作者视角的深度洞察**：

#### 3.1 📌 爆款选题机会

分析当前热榜内容，回答：

- **爆款公式拆解**：这些热点背后有什么共同规律？
  - 标题结构是什么？
  - 内容形式是什么？
  - 用户痛点是什么？
- **🔄 可迁移选题**：其他赛道创作者如何蹭这个热点？

输出格式：

```
📌 爆款选题机会

发现：[核心发现]
- 数据支撑：[具体数据]
- 核心公式：[拆解出的公式]

🔄 可迁移选题：
· 美妆赛道：[具体选题建议]
· 穿搭赛道：[具体选题建议]
· 其他赛道：[具体选题建议]
```

#### 3.2 ✏️ 标题套路破解

分析热榜标题，提炼：

- **爆款标题模式**：统计出现频率最高的标题类型
- **立即可用的标题公式**：给出填空式的标题模板
- **情绪触发词清单**：提取能触发用户行动冲动的高频词汇

输出格式：

```
✏️ 标题套路破解

爆款标题模式TOP3：
1. [类型名称]：[示例]
2. [类型名称]：[示例]
3. [类型名称]：[示例]

立即可用的标题公式：
· "用[方法]，[效果]" → 例：用万能旅行拍照姿势美美出片
· "耗时[时间]，拍下[成果]" → 例：耗时三年拍下古诗词里的中国
· "原来[认知]，真的存在" → 例：原来古诗词里的河南真的存在

情绪触发词：
[高频词列表]
```

#### 3.3 📈 趋势预判与行动建议

基于当前热榜数据，逐点给出前瞻性判断：

输出格式：

```
📈 趋势预判

一、[预判点1]
   [具体分析内容和依据]
   行动建议：[具体建议]

二、[预判点2]
   [具体分析内容和依据]
   行动建议：[具体建议]

三、[预判点3]
   [具体分析内容和依据]
   行动建议：[具体建议]
```

#### 3.4 分析原则

- 基于实际数据，不编造
- 给出可执行的行动建议，不泛泛而谈
- 从创作者视角出发，每条洞察都要回答"怎么用"
- 保持专业但易懂，避免术语堆砌

### 4. 🔔 询问订阅（关键步骤）

分析完成后，**必须主动询问用户**是否需要订阅：

```
🔔 订阅服务

是否需要订阅每日/每小时热榜推送？

1. 每小时推送 - 实时追踪热点变化（默认推送TOP50完整数据）
2. 每日推送 - 每天早/晚获取一次热榜汇总（默认推送TOP50完整数据）
3. 暂不需要 - 仅本次查询

请回复数字或"取消"，如有其他推送时间偏好请说明。
```

#### 订阅处理流程

- **用户选择1（每小时推送）**：记录偏好，后续每小时自动推送TOP50数据
- **用户选择2（每日推送）**：询问具体推送时间（早/晚），记录偏好，推送TOP50数据
- **用户选择3或"取消"**：结束本次交互，不记录
- **用户有其他偏好**：灵活记录并确认

**订阅数据输出规则**：

- 默认推送TOP50完整数据（无需用户确认加载更多）
- 如用户特别要求TOP20，则按用户要求执行

### 5. 📄 生成热榜HTML页面（必须立即执行）

**⚠️ 强制规则：HTML文件生成是必须执行的强制步骤，不等待用户回复，立即执行**

**⚠️ 禁止行为**：

- ❌ 禁止跳过HTML生成步骤
- ❌ 禁止等待用户回复后再生成HTML
- ❌ 禁止询问用户是否生成HTML
- ❌ 禁止在HTML生成前结束对话
- ❌ 禁止在表格、洞察、订阅询问之前生成HTML

**执行顺序（严格按序执行，HTML必须是最后一步，立即执行）**：
1️⃣ 获取数据 → 2️⃣ 展示表格 → 3️⃣ 创作者洞察 → 4️⃣ 询问订阅 → **5️⃣ 立即执行HTML生成（不等待用户回复）**

**触发场景**：
| 场景 | 是否生成HTML | 数据范围 | 文件命名 |
|-----|-------------|---------|---------|
| 查询实时热榜 | ✅ 必须立即生成 | TOP20（严格20条） | `douyin_hot_YYYYMMDD_HHMM.html` |
| 查询历史热榜 | ✅ 必须立即生成 | TOP20（严格20条） | `douyin_hot_YYYYMMDD_YYYYMMDD.html` |
| 用户加载更多 | ✅ 必须立即生成 | TOP50（严格50条） | `douyin_hot_top50_YYYYMMDD_HHMM.html` |

**⚠️ HTML数据展示规则（最高优先级）**：

- **默认展示TOP20**：查询实时/历史热榜时，HTML只展示TOP20数据
- **加载更多展示TOP50**：只有用户明确要求加载更多时，HTML才展示TOP50数据
- **数据条数严格控制**：HTML中的数据条数必须与展示条数完全一致
  - TOP20模式：HTML中只能有20条数据，话题总数显示20
  - TOP50模式：HTML中只能有50条数据，话题总数显示50
- **禁止自动扩展**：禁止在用户未要求时自动展示更多数据

**智能体必须立即执行（不等待用户回复）**：

```bash
# 实时热榜（默认只生成TOP20）
python scripts/gen_douyin_hot_html.py --output scripts/douyin_hot_20260417_1000.html

# 历史热榜（默认只生成TOP20）
python scripts/gen_douyin_hot_html.py --start-date 2026-04-15 --end-date 2026-04-16 --output scripts/douyin_hot_20260415_20260416.html

# 用户加载更多时才生成TOP50（使用--top 50参数）
python scripts/gen_douyin_hot_html.py --top 50 --output scripts/douyin_hot_top50_20260417_1000.html
```

**⚠️ 文件命名规则**：

- 实时热榜：`douyin_hot_YYYYMMDD_HHMM.html`（如 `douyin_hot_20260417_1000.html`）
- 历史热榜：`douyin_hot_起始日期_结束日期.html`（如 `douyin_hot_20260415_20260416.html`）
- 加载更多：`douyin_hot_top50_YYYYMMDD_HHMM.html`

**⚠️ 加载更多场景特殊处理**：
当用户确认加载更多（TOP21-TOP50）时：

1. **仅输出TOP21-TOP50的表格**（不重复输出TOP01-TOP20）
2. **立即生成新的HTML文件**，使用 `--top 50` 参数
3. HTML中必须包含完整的TOP50数据（TOP01-TOP50）

**⚠️ HTML文件输出要求（强制执行）**：

- **必须直接输出HTML文件内容**：生成HTML后，智能体必须在回复中直接输出完整的HTML文件内容
- **输出方式**：使用代码块直接展示HTML源码
  ```html
  <!DOCTYPE html>
  <html lang="zh-CN">
    ...（完整HTML内容）
  </html>
  ```
- **同时告知文件路径**：在HTML内容前后说明文件保存路径

  ````
  📄 已生成热榜HTML页面：
  文件路径：./scripts/douyin_hot_20260417_1000.html

  HTML内容：
  ```html
  [完整HTML代码]
  ````

  可直接用浏览器打开查看，或导出PDF保存。

  ```

  ```

- **禁止遗漏**：无论实时查询还是历史查询，都必须输出完整HTML内容
- **禁止截断**：必须输出完整的HTML文件，不得省略或截断

**⚠️ PDF文件输出要求（强制执行）**：

- **必须输出PDF文件**：智能体必须在回复中输出PDF文件
- **PDF生成方式**：
  1. 先生成HTML文件
  2. 使用浏览器工具或其他方式将HTML转换为PDF
  3. 输出PDF文件路径
- **PDF输出格式**：

  ```
  📄 已生成热榜PDF文件：
  文件路径：./scripts/douyin_hot_20260417_1000.pdf

  PDF内容支持点击跳转到对应话题页面。
  ```

- **禁止遗漏**：无论实时查询还是历史查询，都必须输出PDF文件

**⚠️ 数据一致性要求（严格执行，禁止数据对齐/修改）**：

- **数据来源唯一**：HTML中的数据必须完全来自脚本执行返回的API数据，禁止使用其他来源
- **禁止数据对齐**：API返回什么数据就输出什么数据，禁止为了"对齐"而修改任何字段
- **原始数据输出**：
  - 排名：按API返回的原始顺序，禁止重新排序
  - 热度值：按API返回的原始数值，禁止修改格式或数值
  - 话题标题：按API返回的原始标题，禁止修改或美化
  - 跳转链接：按API返回的原始URL，禁止替换或伪造
- **禁止任何修改**：智能体不得在生成HTML时修改、添加、删除、重排、美化任何数据
- **禁止编造内容**：核心内容摘要必须严格基于话题标题生成，禁止编造不存在的内容
- **一一对应**：HTML中的每一条数据必须与智能体输出的榜单表格完全一致

**HTML页面统计数据要求**：

- **话题总数**：必须是HTML页面中实际展示的榜单数据条数（TOP20或TOP50）
- **最高热度**：必须是HTML页面中展示的所有热度值的最大值
- **平均热度**：必须是HTML页面中展示的所有热度值的平均值
- **禁止伪造统计**：统计数据必须根据实际展示的榜单数据计算，禁止编造或使用其他数据

**HTML页面特性**（紫色极简卡片风格）：

- **数据一致性**：展示数据与API返回数据完全一致，不做任何修改
- **统计数据准确**：话题总数、最高热度、平均热度必须根据实际榜单数据计算
- **支持TOP20或TOP50数据**：默认TOP20，可通过 `--top 50` 显示完整TOP50
- **保持原生排名顺序**（禁止重新排序）
- **核心内容完整展示**（不使用省略号截断）
- **整行点击跳转**：链接为API返回的真实URL，点击直接跳转到对应话题页面
- **PDF链接跳转**：导出的PDF中每一行都支持点击跳转
  - 使用PDF规范的标准链接注解（Link Annotation）
  - 支持大多数现代PDF阅读器（Adobe Reader、Chrome PDF Viewer、Edge PDF等）
  - 点击任意榜单行即可跳转到对应的抖音话题页面
  - 链接URL与榜单数据完全一致，使用API返回的真实链接

### 6. 可选操作

- 用户可以点击标题直接跳转到对应热点页面
- 可根据热度值排序或筛选特定话题
- 可随时发送"取消订阅"停止推送

## 资源索引

- 数据获取脚本：见 [scripts/hotspot_fetcher.py](../scripts/hotspot_fetcher.py)（获取抖音热榜JSON数据）
- HTML生成脚本：见 [scripts/gen_douyin_hot_html.py](../scripts/gen_douyin_hot_html.py)（生成热榜可视化HTML页面）
- HTML模板文件：见 [assets/douyin_hot_trend_template.html](douyin_hot_trend_template.html)（HTML页面模板，支持快速生成）

## 模板使用说明

`douyin_hot_trend_template.html` 是一个独立的HTML模板文件，支持以下占位符快速生成：

| 占位符              | 说明         | 示例值              |
| ------------------- | ------------ | ------------------- |
| `{{PAGE_TITLE}}`    | 页面标题     | 抖音实时热榜        |
| `{{FETCH_TIME}}`    | 获取时间     | 2026-04-17 10:00:00 |
| `{{HOT_LIST_DATA}}` | 热榜数据JSON | `[{...}, {...}]`    |

**快速生成方式**：将占位符替换为实际数据即可生成完整HTML页面。

## 注意事项

- API调用需要网络连接，请确保网络通畅
- 热榜数据实时更新，每次调用获取最新数据
- 历史热榜最长查询范围为30天
- 如遇API调用失败，提示用户稍后重试
- 订阅偏好需要用户明确确认后记录
- 分析必须基于实际数据，给出可执行的行动建议


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