Claude Skill

douyin-subscribe

抖音账号订阅追踪 — 通过抖音号订阅账号(最多20个),Agent 每日 9:00 自动拉取并生成 HTML 报告。账号 ID 直接内置于自动化命令中,无需文件存储。支持多抖音号批量订阅,自动生成精美 HTML 作品报告,终端/Markdown 表格按账号依次展示作品标题、收藏、评论、分享、点赞、发布时间等数据。当用户订阅抖音账号、追踪抖音作品更新、监控抖音竞品账号时使用。触发词:抖音订阅、抖音账号订阅、抖音订阅追踪、抖音作品订阅、抖音账号监控、抖音作品追踪、抖音每日推送、抖音日报。

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download redfox-data-redfox-community-skills_douyin-subscribe-5e7b435.zip · 25 KB
Part of redfox-data/redfox-community — 66 skills

Install

skills CLI npx skills add https://github.com/redfox-data/redfox-community/tree/main/skills/douyin-subscribe
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install redfox-data-redfox-community@llmmart
Git git clone https://github.com/redfox-data/redfox-community.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole redfox-data/redfox-community collection as a plugin from our marketplace. Git is the plain clone.

README

抖音账号订阅追踪 / douyin-subscribe


简介

通过抖音号订阅竞对、同类和关注账号,每天自动获取最新作品数据,生成可视化报告,轻松掌握抖音动态。

核心价值

  • 一键订阅:发送抖音号即可订阅,无需复杂配置,最多支持 20 个账号
  • 每日自动推送:每天早上 9:00 自动拉取订阅账号的最新作品,无需手动操作
  • 可视化报告:自动生成精美 HTML 报告,支持预览和分享,数据一目了然
  • 全维度数据:收藏数、评论数、分享数、点赞数、发布时间,关键指标全覆盖

适用对象

  • 📊 内容运营 — 追踪竞品账号动态,及时发现爆款内容趋势
  • 🎬 短视频创作者 — 监控同赛道头部账号,获取创作灵感与数据参考
  • 🏢 品牌 / MCN — 批量管理关注账号,每日自动收到作品日报

功能特性

核心功能

  • 抖音号直接订阅:通过抖音号订阅账号,简单透明
  • 每日自动拉取:订阅后每天 9:00 自动获取最新作品数据
  • HTML 可视化报告:自动生成精美报告文件,含数据总结与爆款 TOP5
  • 作品超链接:标题和账号名均可点击跳转详情页
  • 智能日期策略:默认查前一天,无数据自动回溯近 7 天,也支持自定义日期范围
  • 多账号分组展示:按账号依次分组,同账号内按分享数降序排列

密钥获取与安全说明

  • 本技能需要使用环境变量:REDFOX_API_KEY。
  • REDFOX_API_KEY 由 红狐 hub (https://redfox.hk)提供。
  • 请前往 红狐 hub 注册账号,获取 REDFOX_API_KEY。
  • 配置设备环境变量 REDFOX_API_KEY 后使用本技能。
  • 在提供密钥前,请先确认密钥来源、可用范围、有效期及是否支持重置/撤销。
  • 禁止在代码、提示词、日志或输出文件中硬编码/明文暴露密钥。

使用指南

直接用自然语言说出你想订阅的抖音号即可,无需记忆命令。

常用说法速查

意图 示例话术 效果
订阅账号 「订阅 Fish688688」 自动验证并订阅,展示最新作品数据
批量订阅 「订阅 abc123, def456, ghi789」 一次订阅多个账号
查看特定日期 「查一下 2026-06-01 到 2026-06-05 的作品」 按日期范围拉取历史作品
取消订阅 「取消订阅 Fish688688」 从订阅列表中移除该账号

输出示例

订阅成功后,你将收到:

  • Markdown 作品表格:按账号分组展示,含收藏数、评论数、分享数、点赞数
  • HTML 可视化报告:自动保存至 report/ 目录,含数据总结与爆款 TOP5
  • 总结统计:突出账号表现 + 高频话题 TOP5

使用场景

场景 角色 示例问法 收益
竞品监控 内容运营 「订阅竞品的抖音号,每天看他们的作品」 每日自动获取竞品动态,不错过爆款
赛道追踪 短视频创作者 「订阅这几个头部账号,追踪他们的内容」 及时掌握赛道趋势,获取创作灵感
批量管理 MCN / 品牌 「把这些账号都订阅上,统一管理」 最多 20 个账号批量订阅,日报自动推送
历史回溯 内容分析师 「查一下上周这些账号的作品数据」 支持自定义日期范围查询历史数据

Skill manifest

抖音账号订阅追踪

📝 简介

通过抖音号订阅竞对、同类和关注账号(最多 20 个),自动抓取最新作品。账号 ID 直接传入命令行参数,不依赖任何本地文件存储,自动化任务中硬编码所有已订阅的抖音号。


✨ 功能特性

功能模块 能力描述 核心价值
账号直传 通过 --accounts 参数直接传入抖音号,无需文件存储 简单透明,命令即文档
每日自动推送 自动化任务内置账号 ID,每天早上 9:00 自动执行 定时获取,无需手动操作
HTML 报告 fetch 时自动生成精美 HTML 报告文件,支持预览和分享 可视化浏览,一目了然
作品数据 收藏数、评论数、分享数、点赞数、发布时间 全维度数据,一目了然
按账号展示 终端表格按账号依次分组展示 清晰直观,便于对比
日期回溯 支持指定历史日期查询 追溯往期作品数据

🔑 鉴权

获取 API Key

请前往 红狐hub 获取API KEY

配置 API Key

方案1: 以OpenClaw为例,将REDFOX_API_KEY添加到~/.openclaw/openclaw.json中:

{ "env": { "REDFOX_API_KEY": "ak_xxxx..." } }

方案2: 终端配置

export REDFOX_API_KEY="ak_xxxx..."

⚙️ 使用方式

拉取作品(核心命令)

# 基础用法:直接传入抖音号列表
python3 "$SKILL_PATH/scripts/subscribe.py" fetch --accounts "YuZhouXiaoLi1220,Fish688688"

# 生成 HTML 报告(自动保存到 report/ 目录)
python3 "$SKILL_PATH/scripts/subscribe.py" fetch --accounts "YuZhouXiaoLi1220,Fish688688" --html

# 指定 HTML 报告输出路径
python3 "$SKILL_PATH/scripts/subscribe.py" fetch --accounts "YuZhouXiaoLi1220,Fish688688" --html --html-path ~/Desktop/report.html

# Markdown 格式输出(供 Agent 展示)
python3 "$SKILL_PATH/scripts/subscribe.py" fetch --accounts "YuZhouXiaoLi1220,Fish688688" --markdown

# 指定单日日期
python3 "$SKILL_PATH/scripts/subscribe.py" fetch --accounts "YuZhouXiaoLi1220,Fish688688" --html --date 2026-06-01

# 指定日期范围
python3 "$SKILL_PATH/scripts/subscribe.py" fetch --accounts "YuZhouXiaoLi1220,Fish688688" --html --date-start 2026-06-01 --date-end 2026-06-05

参数说明

命令 参数 说明
fetch --accounts 抖音号列表(必填或核心参数),逗号分隔,如 "id1,id2,id3"
--date 指定单日日期 YYYY-MM-DD
--date-start 指定日期范围开始 YYYY-MM-DD
--date-end 指定日期范围结束 YYYY-MM-DD
--markdown 输出 Markdown 格式表格
--html 生成 HTML 报告文件(保存到 report/ 目录)
--html-path 自定义 HTML 报告输出路径
全局 --api-key 指定 API Key

依赖

依赖 安装命令
requests pip3 install requests

🤖 每日自动推送

用户订阅账号后,Agent 会创建一个每日定时任务,每天早上 9:00 自动执行。任务命令中直接包含所有已订阅的抖音号 ID:

# 自动化任务中的实际命令(账号已内置)
python3 subscribe.py fetch --accounts "YuZhouXiaoLi1220,Fish688688" --html

工作原理

  1. 用户发送抖音号 → Agent 将其写入自动化命令的 --accounts 参数
  2. 每天 9:00 定时触发 → 命令自带账号 ID → 直接调 API 拉数据
  3. 自动生成 HTML 报告 → 保存至 report/ 目录
  4. 不依赖任何本地 JSON 文件

🔧 账号管理规则(Agent 必须遵守)

核心原则:所有订阅状态 = 自动化命令中的 --accounts 参数。无文件存储。

订阅新账号流程

当用户提供新的抖音号时,Agent 必须按以下步骤操作:

Step 1:查询现有自动化任务

使用 automation_update mode=list 获取所有「抖音订阅」相关的自动化任务,读取每个任务的 --accounts 参数中的账号列表。

Step 2:判断是否追加 / 新建

条件 操作
现有账号数 + 新账号数 ≤ 20 追加到现有自动化任务的 --accounts 参数(去重后用逗号拼接)
现有账号数 + 新账号数 > 20 创建新的自动化任务,将超出部分放入新任务的 --accounts
用户明确要求取消某账号 从对应自动化任务的 --accounts 中移除该 ID

Step 3:更新/创建自动化任务

  • 追加场景:调用 automation_update mode=update,修改现有任务的 prompt 字段中的 --accounts 值
  • 新建场景:调用 automation_update mode=create,新建一个独立的每日定时任务(同样 9:00 执行),名称可加序号如「抖音订阅作品日报 #2」

Step 4:立即拉取一次

无论追加还是新建,都必须立即执行一次 fetch(带 --html)展示最新结果给用户。

多任务示例

假设已有 18 个账号,用户又提供了 5 个新账号:

现有任务 #1: --accounts "id1,id2,...,id18" (18个)
新增 5 个: id19,id20,id21,id22,id23

→ 任务 #1 更新为: --accounts "id1,...,id18,id19,id20" (20个 ✅)
→ 新建任务 #2: --accounts "id21,id22,id23" (3个 ✅)

每个独立任务都会在每天 9:00 各自执行,各自生成 HTML 报告。

取消订阅

从对应任务的 --accounts 中移除该 ID 即可。如果移除后某个任务剩余 0 个账号,则删除该自动化任务。


📋 交互规范

订阅流程

静默执行原则:所有中间步骤(fetch 验证、add 订阅、automation_update)均不得向用户输出任何过程性提示(如「正在验证账号」「账号验证通过」「自动订阅中」等)。仅向用户展示最终结果。

  1. 用户发送抖音号 → Agent 静默执行 fetch --accounts "抖音号" --html --markdown 验证账号
  2. 判断账号状态(规则 1):
    • 接口返回正常数据:
      • 静默执行 add 订阅(见下一步)
    • 近 30 天无数据,自动回溯半年后仍无数据:
      • 告知用户:「抱歉您订阅的“xxx”账号近半年都未发布过作品,如果需要数据核查可联系工作人员邮箱 redfoxdata@proton.me 处理」
      • 询问用户:「是否订阅明日推送?」
      • 用户回答「订阅」→ 静默执行 add 订阅
      • 用户未确认 → 不订阅
  3. 静默查询现有自动化任务的 --accounts 列表,判断追加 or 新建:
    • 现有账号数 + 新账号数 ≤ 20 → 追加到现有任务的 --accounts
    • 现有账号数 + 新账号数 > 20 → 新建一个自动化任务放溢出部分
  4. 静默更新/创建自动化任务
  5. 若 fetch 返回了作品数据:
    • 自动打开 HTML 报告预览(preview_url)
    • 对话中展示 Markdown 作品表格
    • 提示:「如需查看特定时间段的作品,可以告诉我,如“查一下 2026-06-01 到 2026-06-05 的作品”」
  6. 若 fetch 无作品数据(账号存在但时段内无更新):仅告知用户无更新,不生成 HTML 报告
  7. 输出顺序:先按账号名升序分组,同账号内按分享数降序排列
  8. 若某个账号在指定时间范围内无更新作品,直接告知用户:「账号名:该时间段内无更新作品」

重要:仅当 fetch 返回实际作品数据时,才需要三输出——Markdown 表格 + HTML 报告预览 + HTML 报告文件。无作品数据时不生成 HTML 报告。

对话输出格式(订阅/拉取时必须遵循)

Agent 执行 fetch 后,必须在对话回复中按以下格式输出数据(不是只依赖脚本终端输出):

格式一:账号概览表

## 📊 订阅报告

| 项目 | 值 |
|------|-----|
| 新订阅 | `新账号名`(粉丝量) |
| 当前总账号 | N 个 |
| 本次拉取作品 | N 条 |
| 自动化任务 | 已更新 ✅ 每天 9:00 自动执行 |

### 账号概览

| 账号 | 粉丝量 | 领域 | 代表作数据 |
|------|--------|------|-----------|
| AccountA | xxx | 领域 | 🏆 代表作:收藏x / 点赞x / 分享x |

格式二:每个账号的详细作品表(Markdown)

### ▸ AccountName(粉丝: xxx)

| 作品标题 | 收藏数 | 评论数 | 分享数 | 点赞数 | 发布时间 |
|---------|--------|--------|--------|--------|---------|
| 标题1 | 10.8w | 2.8k | 10.3w | 19.3w | 06-08 |

要求:

  • 每个账号一个子标题 + 一个完整 Markdown 表格
  • 标题列使用 API 返回的 content 字段完整原文(脚本内部映射为 title),不截断
  • 数值列使用 format_number 后的可读格式(如 10.8w、2.3k)
  • 发布时间截取到 MM-DD HH:mm
  • 按分享数降序排列

格式三:总结统计(必须附加在所有表格之后)

### 对上述作品的总结

> 1. **突出的账号表现**:...
> 2. **高频事件 TOP5**:...

输出完整性清单

每次订阅/拉取,对话中必须包含以下全部内容:

  • 账号概览表(含总览信息)
  • 每个账号一个 Markdown 作品详情表
  • 无更新账号提示(如有)
  • 总结统计分析
  • 规则 2 适用:已订阅账号昨日无数据时,结尾附加邮箱核查提示
  • 规则 3 适用:被频率限制拒绝的账号提示
  • 仅当有作品数据时:自动打开 HTML 报告预览(preview_url)+ HTML 报告文件已生成说明(附文件路径)

抖音号要求

接口通过 uniqueName(抖音展示ID)查询,脚本将用户提供的抖音号统一按 uniqueName 传入,userId 和 shortId 固定为空字符串。订阅时必须提供抖音号。

重要: 若用户只输入账号名称(如"李佳琦""老高與小茉")而未提供抖音号,必须提示:

"抖音账号名称存在多个重名情况,请提供准确的抖音号以便精准订阅。"

并附上获取抖音号的示例图供用户参考:

获取抖音号示例

数据规则

  • 每个自动化任务最多 20 个账号(超限时 Agent 自动创建新任务,详见「账号管理规则」)
  • 规则 1:未订阅账号(首次验证查询):
    • 默认查询近 30 天数据
    • 若近 30 天无数据,自动回溯近半年(180 天)
    • 若近半年仍无数据,告知用户:「抱歉您订阅的“xxx”账号近半年都未发布过作品,如果需要数据核查可联系工作人员邮箱 redfoxdata@proton.me 处理」
  • 规则 2:已订阅账号(日常拉取):
    • 默认查前一天数据(T-1)
    • 若昨日无数据,告知用户:「昨日该账号暂未发布作品」
    • 结尾固定提示:「如果需要数据核查可联系工作人员邮箱 redfoxdata@proton.me 处理」
  • 规则 3:失败阈值保护:
    • 同一抖音号在 6 小时内累计 3 次 API 调用失败后,后续请求将被拒绝
    • 拒绝时提示:「当前账号订阅已超过失败阈值,请联系客服邮箱 redfoxdata@proton.me 处理」
    • 距上次失败超过 6 小时 / 同一账号查询成功,计数归零,恢复正常调用
  • 用户指定日期不回溯:若用户明确指定了日期或日期范围,查不到数据时直接告知用户,不自动回溯
  • 支持日期范围查询:用户可指定起止日期(--date-start YYYY-MM-DD --date-end YYYY-MM-DD)拉取历史作品
  • 每账号最多返回 10 条作品
  • 排序方式:账号名升序 → 同账号内分享数降序
  • 无数据提示:若某个账号在指定时间范围内无更新作品,脚本会输出「账号名:该时间段内无更新作品」,Agent 需将此信息原样转达给用户
  • 接口不支持多账号同时查询,脚本自动拆分后逐账号调用并整合数据

输出表格结构(脚本终端格式,仅供参考)

脚本终端输出原始格式如下,但 Agent 对话中必须使用「对话输出格式」中的 Markdown 表格重新排版:

▸ Fish688688(粉丝: 249w+)
──────────────────────────────────────────────────────────────
  作品标题                          收藏数  评论数  分享数  点赞数  发布时间
──────────────────────────────────────────────────────────────
  今晚图省事...                    10.8w   2.8k   10.3w  19.3w  05-21 21:40

作品标题使用 API content 字段(脚本内部映射为 title),链接使用 opusUrl 字段(脚本内部映射为 workUrl)。Markdown 输出中标题自动渲染为可点击超链接:[标题](workUrl)。

注意:当前广域库 API 不返回 followerCount(粉丝数),账号概览中粉丝量显示为 -。

总结统计

表格展示完毕后,必须严格按以下格式附加总结:

对上述作品的总结:

  1. 突出的账号表现:指出数据最亮眼的账号及其代表作品
  2. 高频事件 TOP5:提炼所有作品中提到次数最多的 5 个事件/话题,并归纳每个事件对应的不同观点(不足 5 个可提炼的,按实际数量呈现,禁止虚构)
Files (redfox-community)
  • references
    • test_api_params.json 1.1 KB
      {
        "test_cases": [
          {
            "name": "罗永浩(uniqueName)",
            "payload": {
              "uniqueName": "luoyonghao",
              "pageNum": 1,
              "pageSize": 10
            }
          },
          {
            "name": "罗永浩(userId)",
            "payload": {
              "userId": "3822358551859599",
              "pageNum": 1,
              "pageSize": 10
            }
          },
          {
            "name": "纯数字抖音号(userId模式)",
            "payload": {
              "userId": "3822358551859599",
              "pageNum": 1,
              "pageSize": 10
            }
          }
        ],
        "api": {
          "url": "https://redfox.hk/story/api/dy/data/listWorkByAccount",
          "method": "POST",
          "headers": {
            "REDFOX_API_KEY": "ak_xxx",
            "Content-Type": "application/json"
          }
        },
        "field_mapping": {
          "旧→新": {
            "title": "content",
            "workUrl": "opusUrl",
            "accountName": "authorName",
            "secUid": "authorSecUid",
            "followerCount": "(缺失)"
          },
          "新API新增字段": [
            "videoId", "videoType", "duration", "coverUrl",
            "tagList", "imageUrlList", "authorUid",
            "authorShortId", "authorUniqueId", "authorAvatarUrl"
          ]
        }
      }
      
  • scripts
    • subscribe.py 54.4 KB
      #!/usr/bin/env python3
      """
      抖音账号订阅追踪 — 订阅抖音账号,每日自动推送最新作品
      ========================================================
      订阅关注的抖音账号,自动抓取最新作品数据,表格化展示。
      
      Usage:
          python3 subscribe.py add "5437662" --name "李佳琦" --category "竞对账号"
          python3 subscribe.py add "5437662,13232311,3234242"
          python3 subscribe.py remove "5437662"
          python3 subscribe.py list
          python3 subscribe.py fetch
          python3 subscribe.py fetch --date 2026-06-01
      """
      
      import argparse
      import json
      import os
      import re
      import sys
      import time
      from collections import Counter, defaultdict
      from datetime import datetime, timedelta
      from pathlib import Path
      
      try:
          import requests
          HAS_REQUESTS = True
      except ImportError:
          HAS_REQUESTS = False
      
      # ─── 配置 ─────────────────────────────────────────────────────────────────────────
      API_URL = "https://redfox.hk/story/api/dy/data/listWorkByAccount"
      ENV_KEY = "REDFOX_API_KEY"
      
      SUBSCRIPTIONS_FILE = Path.home() / ".qoder" / "douyin_subscriptions.json"
      MAX_SUBSCRIPTIONS = 20
      
      # 失败计数文件(规则3:同一参数 6h 内 3 次失败后拒绝调用)
      FAILURES_FILE = Path.home() / ".qoder" / "douyin_subscribe_failures.json"
      SUPPORT_EMAIL = "redfoxdata@proton.me"
      RATE_LIMIT_MAX_FAILURES = 3
      RATE_LIMIT_WINDOW_HOURS = 6
      
      DEFAULT_CATEGORIES = ["竞对账号", "同类账号", "关注账号"]
      
      # ─── 终端颜色 ──────────────────────────────────────────────────────────────────────
      GREEN = "\033[92m"
      YELLOW = "\033[93m"
      RED = "\033[91m"
      CYAN = "\033[96m"
      BOLD = "\033[1m"
      RESET = "\033[0m"
      
      
      def info(msg):
          print(f"{GREEN}[✓]{RESET} {msg}")
      
      
      def warn(msg):
          print(f"{YELLOW}[!]{RESET} {msg}")
      
      
      def error(msg):
          print(f"{RED}[✗]{RESET} {msg}")
      
      
      def step(msg):
          print(f"{CYAN}[→]{RESET} {msg}")
      
      
      # ─── API Key 管理 ──────────────────────────────────────────────────────────────────
      def get_api_key(cli_key=None):
          """Get API key: CLI arg > env var. 必须配置,否则报错退出。"""
          if cli_key:
              return cli_key
          env_key = os.environ.get(ENV_KEY)
          if env_key:
              return env_key
          error("未找到 REDFOX_API_KEY,请先配置 API Key:")
          print(f"  {CYAN}方案1(推荐):{RESET}export REDFOX_API_KEY=ak_你的密钥")
          print(f"  {CYAN}方案2:{RESET}命令行参数 --api-key ak_你的密钥")
          print(f"  {CYAN}获取地址:{RESET}https://redfox.hk/settings/api-keys?source=github")
          sys.exit(1)
      
      
      # ─── 订阅数据管理 ──────────────────────────────────────────────────────────────────
      def load_subscriptions():
          """加载订阅列表"""
          if not SUBSCRIPTIONS_FILE.exists():
              return []
          try:
              data = json.loads(SUBSCRIPTIONS_FILE.read_text(encoding="utf-8"))
              return data.get("subscriptions", [])
          except (json.JSONDecodeError, OSError):
              return []
      
      
      def save_subscriptions(subscriptions):
          """保存订阅列表"""
          SUBSCRIPTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
          data = {"subscriptions": subscriptions, "updatedAt": datetime.now().isoformat()}
          SUBSCRIPTIONS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
      
      
      def find_subscription(identifier):
          """查找某个订阅 — 按 accountId 查找"""
          subs = load_subscriptions()
          for sub in subs:
              if sub["accountId"] == identifier:
                  return sub
          return None
      
      
      def add_subscriptions(account_ids, account_name="", category="关注账号"):
          """添加订阅 — 支持逗号分隔的多个抖音号,拆分后逐个添加"""
          ids = [aid.strip() for aid in account_ids.split(",") if aid.strip()]
          if not ids:
              error("请提供至少一个有效的抖音号")
              return False
      
          subs = load_subscriptions()
          added_count = 0
      
          for aid in ids:
              if len(subs) >= MAX_SUBSCRIPTIONS:
                  error(f"已达订阅上限({MAX_SUBSCRIPTIONS} 个),请先取消一些订阅后再添加")
                  break
      
              existing = find_subscription(aid)
              if existing:
                  warn(f"已订阅过抖音号「{aid}」({existing.get('accountName', '')}),跳过")
                  continue
      
              subs.append({
                  "accountId": aid,
                  "accountName": account_name or aid,
                  "category": category,
                  "subscribedAt": datetime.now().isoformat(),
              })
              added_count += 1
              info(f"已订阅抖音号「{aid}」{f'— {account_name}' if account_name else ''} — 分类: {category}")
      
          if added_count > 0:
              save_subscriptions(subs)
              info(f"本次新增 {added_count} 个订阅,当前共 {len(subs)}/{MAX_SUBSCRIPTIONS} 个")
          else:
              warn("本次未新增任何订阅")
      
          return added_count > 0
      
      
      def remove_subscription(identifier):
          """移除订阅 — 按 accountId 移除"""
          subs = load_subscriptions()
          target = None
          for i, sub in enumerate(subs):
              if sub["accountId"] == identifier:
                  target = subs.pop(i)
                  break
      
          if not target:
              warn(f"未找到抖音号「{identifier}」的订阅")
              return False
      
          save_subscriptions(subs)
          name = target.get("accountName", "")
          name_info = f"({name})" if name and name != identifier else ""
          info(f"已取消订阅「{identifier}」{name_info}")
          info(f"当前共订阅 {len(subs)}/{MAX_SUBSCRIPTIONS} 个抖音账号")
          return True
      
      
      def list_subscriptions():
          """列出所有订阅"""
          subs = load_subscriptions()
          if not subs:
              warn("当前没有订阅任何抖音账号")
              print(f"\n  使用 '{CYAN}add{RESET}' 命令添加订阅:")
              print(f"  python3 subscribe.py add \"<抖音号>\" --name \"<账号名>\"")
              print(f"  python3 subscribe.py add \"<抖音号1>,<抖音号2>\"  # 支持多个")
              return
      
          groups = defaultdict(list)
          for sub in subs:
              groups[sub.get("category", "关注账号")].append(sub)
      
          print(f"\n{BOLD}当前订阅 ({len(subs)}/{MAX_SUBSCRIPTIONS}):{RESET}\n")
      
          for cat in DEFAULT_CATEGORIES + ["其他"]:
              items = groups.pop(cat, [])
              if not items:
                  if cat in DEFAULT_CATEGORIES:
                      groups.pop(cat, None)
                  continue
      
              cat_color = {"竞对账号": RED, "同类账号": YELLOW, "关注账号": CYAN}.get(cat, CYAN)
              print(f"  {cat_color}{BOLD}▸ {cat}{RESET}")
              for item in items:
                  subscribed_at = item.get("subscribedAt", "")[:10] if item.get("subscribedAt") else ""
                  display_line = f"    {item['accountName']}  (抖音号: {item['accountId']})"
                  if subscribed_at:
                      display_line += f"  订阅于 {subscribed_at}"
                  print(display_line)
      
          for cat, items in groups.items():
              if not items:
                  continue
              print(f"  {CYAN}▸ {cat}{RESET}")
              for item in items:
                  print(f"    {item['accountName']}  (抖音号: {item['accountId']})")
      
          print()
      
      
      # ─── 数据获取 ──────────────────────────────────────────────────────────────────────
      # ─── 失败计数 / 频率限制(规则 3)───────────────────────────────────────────────
      def _load_failures():
          """加载失败记录"""
          if FAILURES_FILE.exists():
              try:
                  return json.loads(FAILURES_FILE.read_text(encoding="utf-8"))
              except Exception:
                  pass
          return {}
      
      
      def _save_failures(failures):
          """保存失败记录"""
          FAILURES_FILE.parent.mkdir(parents=True, exist_ok=True)
          FAILURES_FILE.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding="utf-8")
      
      
      def _check_rate_limit(account_id):
          """检查是否超过失败阈值(6h 内 3 次失败),返回 (blocked: bool, message: str)"""
          failures = _load_failures()
          key = account_id.strip().lower()
          record = failures.get(key)
          if not record:
              return False, ""
      
          last_fail_time = record.get("lastFailTime", 0)
          fail_count = record.get("count", 0)
      
          # 距上次失败超过 6 小时,计数归零
          if time.time() - last_fail_time > RATE_LIMIT_WINDOW_HOURS * 3600:
              del failures[key]
              _save_failures(failures)
              return False, ""
      
          # 6h 内失败 >= 3 次,拒绝调用
          if fail_count >= RATE_LIMIT_MAX_FAILURES:
              return True, f"当前账号「{account_id}」订阅已超过失败阈值,请联系客服邮箱 {SUPPORT_EMAIL} 处理"
      
          return False, ""
      
      
      def _record_failure(account_id):
          """记录一次失败"""
          failures = _load_failures()
          key = account_id.strip().lower()
          record = failures.get(key, {"count": 0, "lastFailTime": 0})
      
          # 距上次失败超过 6 小时,重置计数
          if time.time() - record.get("lastFailTime", 0) > RATE_LIMIT_WINDOW_HOURS * 3600:
              record = {"count": 0, "lastFailTime": 0}
      
          record["count"] += 1
          record["lastFailTime"] = time.time()
          failures[key] = record
          _save_failures(failures)
      
      
      def _record_success(account_id):
          """成功后计数归零"""
          failures = _load_failures()
          key = account_id.strip().lower()
          if key in failures:
              del failures[key]
              _save_failures(failures)
      
      
      def _filter_works_by_date(works, date_str=None, date_start=None, date_end=None):
          """客户端按发布时间过滤作品(新 API 不支持服务端日期过滤)"""
          if not date_str and not (date_start and date_end):
              return works
      
          filtered = []
          for w in works:
              pub = w.get("publishTime") or ""
              if not pub:
                  continue
              pub_date = pub[:10]  # YYYY-MM-DD
              if date_start and date_end:
                  if date_start <= pub_date <= date_end:
                      filtered.append(w)
              elif date_str:
                  if pub_date == date_str:
                      filtered.append(w)
          return filtered
      
      
      def _map_work_fields(work):
          """将新 API 字段映射为脚本内部统一字段名,保持下游逻辑不变"""
          work["title"] = work.get("content") or work.get("title") or ""
          work["workUrl"] = work.get("opusUrl") or work.get("workUrl") or ""
          work["accountName"] = work.get("authorName") or work.get("accountName") or ""
          work["secUid"] = work.get("authorSecUid") or work.get("secUid") or ""
          work["followerCount"] = work.get("authorFansCount") or work.get("followerCount")
          return work
      
      
      def fetch_account_works(session, account_id, date_str=None, date_start=None, date_end=None):
          """获取单个抖音账号的作品列表 — 通过广域库 API(listWorkByAccount)"""
          if not account_id:
              warn("无效的抖音号: 空")
              return []
      
          # 规则3:检查频率限制
          blocked, block_msg = _check_rate_limit(account_id)
          if blocked:
              warn(block_msg)
              return "__rate_limited__"
      
          payload = {
              "uniqueName": account_id,
              "userId": "",
              "shortId": "",
              "pageNum": 1,
              "pageSize": 10,
              "source": "抖音账号订阅追踪"
          }
      
          try:
              resp = session.post(API_URL, json=payload, timeout=20)
              result = resp.json()
          except requests.exceptions.Timeout:
              warn(f"请求超时: 抖音号 {account_id}")
              _record_failure(account_id)
              return []
          except Exception as e:
              warn(f"请求失败: 抖音号 {account_id}: {e}")
              _record_failure(account_id)
              return []
      
          code = result.get("code")
          if code == 3108:
              warn("触发频率限制,等待 5s...")
              time.sleep(5)
              try:
                  resp = session.post(API_URL, json=payload, timeout=20)
                  result = resp.json()
                  code = result.get("code")
              except Exception:
                  _record_failure(account_id)
                  return []
      
          if code not in (200, 2000):
              if code in (3106, 3107):
                  error(f"API Key 错误 (code {code}): {result.get('msg', '')}")
              elif code:
                  warn(f"API 返回错误 (code {code}): {result.get('msg', '')} — 抖音号 {account_id}")
              _record_failure(account_id)
              return []
      
          # 规则3:查询成功,计数归零
          _record_success(account_id)
      
          data_raw = result.get("data", {})
          if not data_raw:
              return []
      
          if isinstance(data_raw, list):
              works = data_raw
          elif isinstance(data_raw, dict):
              works = data_raw.get("list") or data_raw.get("articles") or data_raw.get("records") or []
          else:
              works = []
      
          # 映射新 API 字段为统一字段名
          for work in works:
              _map_work_fields(work)
      
          # 客户端日期过滤(新 API 不支持服务端日期过滤)
          if date_str or (date_start and date_end):
              works = _filter_works_by_date(works, date_str, date_start, date_end)
      
          for work in works[:10]:
              work["_accountId"] = account_id
      
          return works[:10]
      
      
      def fetch_all_works(session, subscriptions, date_str=None, date_start=None, date_end=None):
          """拉取所有订阅账号的作品 — 返回 (works, empty_accounts, rate_limited_accounts)"""
          all_works = []
          empty_accounts = []
          rate_limited_accounts = []
          total = len(subscriptions)
      
          for i, sub in enumerate(subscriptions, 1):
              aid = sub.get("accountId", "")
              name = sub.get("accountName", aid)
              if not aid:
                  warn(f"跳过无抖音号的订阅: {name}")
                  continue
      
              print(f"\r  {CYAN}[→]{RESET} 拉取: {name} ({aid}) ({i}/{total})", end="", flush=True)
      
              works = fetch_account_works(session, aid, date_str, date_start, date_end)
              if works == "__rate_limited__":
                  # 规则3:被频率限制拒绝
                  rate_limited_accounts.append(name)
              elif works:
                  # 优先使用 API 返回的真实 accountName(而非传入的 ID)
                  real_name = works[0].get("accountName") or name
                  for work in works:
                      work["_accountName"] = real_name
                      work["_category"] = sub.get("category", "关注账号")
                  all_works.extend(works)
              else:
                  empty_accounts.append(name)
      
              if i < total:
                  time.sleep(0.3)
      
          print()
          return all_works, empty_accounts, rate_limited_accounts
      
      
      # ─── 数字格式化 ────────────────────────────────────────────────────────────────────
      def format_number(n):
          """格式化数字: 1234 -> 1.2k, 12345 -> 1.2w"""
          if n is None:
              return "0"
          try:
              n = int(n)
          except (ValueError, TypeError):
              return str(n)
          if n >= 10000:
              return f"{n / 10000:.1f}w"
          if n >= 1000:
              return f"{n / 1000:.1f}k"
          return str(n)
      
      
      def format_fans(n):
          """格式化粉丝数"""
          if n is None:
              return "-"
          try:
              n = int(n)
          except (ValueError, TypeError):
              return str(n)
          if n >= 10000:
              return f"{n / 10000:.0f}w+"
          return str(n)
      
      
      # ─── CJK 宽度工具 ─────────────────────────────────────────────────────────────────
      def _display_width(text):
          """计算字符串在终端中的显示宽度(CJK 字符占 2 格)"""
          width = 0
          for ch in str(text):
              cp = ord(ch)
              if (0x4E00 <= cp <= 0x9FFF or 0x3400 <= cp <= 0x4DBF or
                  0x20000 <= cp <= 0x2A6DF or 0x2A700 <= cp <= 0x2B73F or
                  0x2B740 <= cp <= 0x2B81F or 0x2B820 <= cp <= 0x2CEAF or
                  0xF900 <= cp <= 0xFAFF or 0x2F800 <= cp <= 0x2FA1F or
                  0x3000 <= cp <= 0x303F or 0xFF01 <= cp <= 0xFF60 or
                  0xFFE0 <= cp <= 0xFFE6):
                  width += 2
              else:
                  width += 1
          return width
      
      
      def _pad(text, width):
          """按显示宽度右填充空格"""
          text = str(text)
          return text + ' ' * max(0, width - _display_width(text))
      
      
      def _rpad(text, width):
          """按显示宽度左填充空格(右对齐)"""
          text = str(text)
          return ' ' * max(0, width - _display_width(text)) + text
      
      
      def _truncate(text, max_width):
          """按显示宽度截断,超出加 .."""
          text = str(text)
          if _display_width(text) <= max_width:
              return text
          result = ''
          w = 0
          for ch in text:
              cw = 2 if ord(ch) > 127 else 1
              if w + cw + 2 > max_width:
                  break
              result += ch
              w += cw
          return result + '..'
      
      
      # ─── 终端表格展示 ──────────────────────────────────────────────────────────────────
      def print_terminal_table(works):
          """在终端打印作品表格 — 按账号依次展示"""
          if not works:
              warn("没有获取到任何作品")
              return
      
          # 按分类 → 账号分组
          cat_groups = defaultdict(list)
          for work in works:
              cat = work.get("_category", "关注账号")
              if cat not in DEFAULT_CATEGORIES:
                  cat = "关注账号"
              cat_groups[cat].append(work)
      
          cat_colors = {"竞对账号": RED, "同类账号": YELLOW, "关注账号": CYAN}
          cat_icons = {"竞对账号": "⚔", "同类账号": "◎", "关注账号": "★"}
      
          # 列宽定义(终端显示宽度)
          W_TITLE = 36
          W_NUM = 8
          W_TIME = 16
          SEP_WIDTH = W_TITLE + W_NUM * 4 + W_TIME
          SEP = '─' * SEP_WIDTH
      
          total_shown = 0
      
          for cat in DEFAULT_CATEGORIES:
              arts = cat_groups.get(cat, [])
              if not arts:
                  continue
      
              # 按账号分组
              account_groups = defaultdict(list)
              for art in arts:
                  account_key = art.get("_accountName") or art.get("authorName") or art.get("_accountId", "未知")
                  account_groups[account_key].append(art)
      
              cat_color = cat_colors.get(cat, CYAN)
              cat_icon = cat_icons.get(cat, "●")
              print(f"\n  {cat_color}{BOLD}{cat_icon} {cat}{RESET} — {len(arts)} 条作品")
      
              for account_name, account_works in sorted(account_groups.items(), key=lambda x: x[0]):
                  # 按分享数降序
                  account_works.sort(key=lambda a: -(int(a.get("shareCount", 0) or 0)))
      
                  fans = format_fans(account_works[0].get("followerCount"))
                  print(f"\n  {BOLD}▸ {account_name}(粉丝: {fans}){RESET}")
                  print(f"  {YELLOW}{SEP}{RESET}")
      
                  header = (f"  {_pad('作品标题', W_TITLE)}"
                            f"{_rpad('收藏数', W_NUM)}{_rpad('评论数', W_NUM)}"
                            f"{_rpad('分享数', W_NUM)}{_rpad('点赞数', W_NUM)}"
                            f"  {_pad('发布时间', W_TIME)}")
                  print(f"  {YELLOW}{header}{RESET}")
                  print(f"  {YELLOW}{SEP}{RESET}")
      
                  for work in account_works:
                      title = work.get("title") or "无标题"
                      title_d = _truncate(title, W_TITLE)
      
                      collects = format_number(work.get("collectCount"))
                      comments = format_number(work.get("commentCount"))
                      shares = format_number(work.get("shareCount"))
                      likes = format_number(work.get("likeCount"))
      
                      pub_time = work.get("publishTime") or "-"
                      if len(pub_time) > 16:
                          pub_time = pub_time[:16]
                      pub_short = pub_time[5:16] if len(pub_time) >= 16 else pub_time
      
                      row = (f"  {_pad(title_d, W_TITLE)}"
                             f"{_rpad(collects, W_NUM)}{_rpad(comments, W_NUM)}"
                             f"{_rpad(shares, W_NUM)}{_rpad(likes, W_NUM)}"
                             f"  {_pad(pub_short, W_TIME)}")
                      print(row)
                      total_shown += 1
      
          remaining = len(works) - total_shown
          if remaining > 0:
              print(f"\n  {YELLOW}... 还有 {remaining} 条作品未展示{RESET}")
      
          print()
      
      
      # ─── Markdown 表格输出 ─────────────────────────────────────────────────────────────
      # ─── 数据总结生成 ────────────────────────────────────────────────────────────────
      # 常见中文停用词(用于话题提取时过滤)
      _STOP_WORDS_CN = set([
          "这是", "不是", "一个", "没有", "可以", "这个", "那个", "什么", "怎么",
          "为什么", "一样", "大家", "你们", "他们", "我们", "自己", "今天", "明天",
          "昨天", "已经", "还是", "就是", "如果", "因为", "所以", "但是", "虽然",
          "不过", "一下", "一点", "很多", "非常", "比较", "真的", "现在", "不要",
          "也是", "只是", "这种", "那种", "这些", "那些", "一定", "这么", "那么",
          "吗", "呢", "吧", "啊", "哦", "嗯", "呀", "哈",
          "@", "#", " ", ",", "。", "!", "?", "、", ":", ";", "\n", "\r",
          "...", "..", "~", "~", "|", "|", "/", "\\", "(", ")", "(", ")",
          "🔥", "😭", "✨", "🤜", "🤛", "👍", "❤", "💪",
      ])
      # 常见无意义后缀(话题中的垃圾词)
      _TOPIC_NOISE = set([
          "四大名著", "青年创作者成长计划", "抖音商城", "抖音美食", "抖音",
          "西游记", "水浒传",
      ])
      
      def _extract_phrases(title, min_len=2, max_len=6):
          """从标题中提取候选中文短语(连续中文字符序列)"""
          phrases = []
          buf = ""
          for ch in title:
              if '\u4e00' <= ch <= '\u9fff':
                  buf += ch
              else:
                  if len(buf) >= min_len:
                      for l in range(min_len, min(max_len + 1, len(buf) + 1)):
                          for i in range(len(buf) - l + 1):
                              sub = buf[i:i + l]
                              if sub not in _STOP_WORDS_CN and sub not in _TOPIC_NOISE:
                                  phrases.append(sub)
                  buf = ""
          if len(buf) >= min_len:
              for l in range(min_len, min(max_len + 1, len(buf) + 1)):
                  for i in range(len(buf) - l + 1):
                      sub = buf[i:i + l]
                      if sub not in _STOP_WORDS_CN and sub not in _TOPIC_NOISE:
                          phrases.append(sub)
          return phrases
      
      
      def _group_topics(phrase_counter):
          """合并语义相近的话题:长短语优先,去重子串"""
          # 按频率降序,同频时长短语优先
          items = sorted(phrase_counter.items(), key=lambda x: (-x[1], -len(x[0])))
          result = []
          for phrase, count in items:
              if count < 2:
                  continue
              # 检查是否为已采纳短语的子串(长短语优先)
              is_sub = False
              for accepted, _ in result:
                  if phrase in accepted:
                      is_sub = True
                      break
              if is_sub:
                  continue
              # 检查是否为已采纳短语的父串(替换短短语)
              expanded = False
              for i, (accepted, ac_count) in enumerate(result):
                  if accepted in phrase:
                      result[i] = (phrase, count + ac_count)
                      expanded = True
                      break
              if expanded:
                  continue
              result.append((phrase, count))
              if len(result) >= 5:
                  break
          return result
      
      
      def generate_summary(works):
          """根据作品数据生成 HTML + 纯文本双版本总结"""
          if not works:
              return "", ""
      
          # 1. 按账号聚合统计
          account_stats = defaultdict(lambda: {"total_likes": 0, "total_shares": 0, "total_collects": 0, "count": 0, "best_title": "", "best_likes": 0})
          for w in works:
              name = w.get("_accountName") or "未知"
              likes = int(w.get("likeCount", 0) or 0)
              shares = int(w.get("shareCount", 0) or 0)
              collects = int(w.get("collectCount", 0) or 0)
              s = account_stats[name]
              s["total_likes"] += likes
              s["total_shares"] += shares
              s["total_collects"] += collects
              s["count"] += 1
              if likes > s["best_likes"]:
                  s["best_likes"] = likes
                  s["best_title"] = (w.get("title") or "无标题")[:40]
      
          # 2. 突出的账号表现(按总点赞数降序 TOP3)
          top_accounts = sorted(account_stats.items(), key=lambda x: -x[1]["total_likes"])[:3]
      
          # 3. 爆款作品 TOP5(按综合互动量降序)
          for w in works:
              w["_engagement"] = (int(w.get("likeCount", 0) or 0) +
                                  int(w.get("shareCount", 0) or 0) * 3 +
                                  int(w.get("collectCount", 0) or 0) * 2 +
                                  int(w.get("commentCount", 0) or 0) * 4)
          top_works = sorted(works, key=lambda x: -x["_engagement"])[:5]
      
          # 4. 高频话题提取
          phrase_counter = Counter()
          for w in works:
              title = w.get("title", "")
              for phrase in _extract_phrases(title):
                  phrase_counter[phrase] += 1
          top_topics = _group_topics(phrase_counter)[:5]
      
          # ── 生成 HTML 版本 ──
          html = '<div class="summary-block">\n'
          html += '  <h3>📈 对上述作品的总结</h3>\n'
      
          # 突出的账号表现
          html += '  <div class="highlight"><strong>突出的账号表现:</strong></div>\n'
          html += '  <ul class="top-list">\n'
          for i, (name, s) in enumerate(top_accounts, 1):
              html += f'    <li><span><span class="rank">#{i}</span>{name} · 代表作「{s["best_title"]}」</span><span class="metric">点赞 {format_number(s["best_likes"])} · 总分享 {format_number(s["total_shares"])} · {s["count"]}条作品</span></li>\n'
          html += '  </ul>\n'
      
          # 爆款作品 TOP5
          html += '  <div class="highlight"><strong>爆款作品 TOP5(综合互动量):</strong></div>\n'
          html += '  <ul class="top-list">\n'
          for i, w in enumerate(top_works, 1):
              title = (w.get("title") or "无标题")[:35]
              account = w.get("_accountName") or "未知"
              likes = format_number(w.get("likeCount"))
              shares = format_number(w.get("shareCount"))
              html += f'    <li><span><span class="rank">#{i}</span>{title} <small style="color:#999">— {account}</small></span><span class="metric">点赞 {likes} · 分享 {shares}</span></li>\n'
          html += '  </ul>\n'
      
          # 高频话题 TOP5
          if top_topics:
              html += '  <div class="highlight"><strong>高频话题 TOP5:</strong></div>\n'
              html += '  <ul class="top-list">\n'
              for i, (topic, count) in enumerate(top_topics, 1):
                  html += f'    <li><span><span class="rank">#{i}</span>{topic}</span><span class="metric">出现 {count} 次</span></li>\n'
              html += '  </ul>\n'
      
          html += '</div>\n'
      
          # ── 生成纯文本版本(Markdown 格式,供对话展示)──
          text = ""
          text += "\n> 1. **突出的账号表现**:\n"
          for i, (name, s) in enumerate(top_accounts, 1):
              text += f">    - #{i} **{name}** · 代表作「{s['best_title']}」- 点赞 {format_number(s['best_likes'])} · 总分享 {format_number(s['total_shares'])} · {s['count']}条作品\n"
      
          text += ">\n> 2. **爆款作品 TOP5(综合互动量)**:\n"
          for i, w in enumerate(top_works, 1):
              title = (w.get("title") or "无标题")[:35]
              account = w.get("_accountName") or "未知"
              likes = format_number(w.get("likeCount"))
              shares = format_number(w.get("shareCount"))
              text += f">    - #{i} 「{title}」— {account} · 点赞 {likes} · 分享 {shares}\n"
      
          if top_topics:
              text += ">\n> 3. **高频话题 TOP5**:\n"
              for i, (topic, count) in enumerate(top_topics, 1):
                  text += f">    - #{i} **{topic}**(出现 {count} 次)\n"
      
          return html, text
      
      
      # ─── HTML 报告生成 ─────────────────────────────────────────────────────────────────
      def generate_html_report(works, subscriptions, date_label, empty_accounts, output_path=None, rate_limited_accounts=None):
          """生成 HTML 报告文件,返回 html_file_path"""
          rate_limited_accounts = rate_limited_accounts or []
          if not works and not empty_accounts and not rate_limited_accounts:
              return None
      
          today = datetime.now().strftime('%Y-%m-%d')
      
          # 确定输出路径
          if output_path:
              out_dir = Path(output_path).parent
              html_file = Path(output_path)
          else:
              # 优先使用 SKILL_PATH/report/,其次用脚本所在目录/report/
              skill_dir = os.environ.get("SKILL_PATH", "")
              if skill_dir:
                  out_dir = Path(skill_dir) / 'report'
              else:
                  out_dir = Path(__file__).resolve().parent.parent / 'report'
              # 从作品数据提取账号名称,用于文件名
              account_names = list(dict.fromkeys(
                  w.get("accountName") or w.get("_account_id", "unknown") for w in works
              ))
              if len(account_names) == 1:
                  name_part = account_names[0]
              elif len(account_names) > 1:
                  name_part = "_".join(account_names[:3])
                  if len(account_names) > 3:
                      name_part += f"_等{len(account_names)}账号"
              else:
                  name_part = "抖音订阅"
              # 文件名安全处理:替换非法字符
              safe_name = re.sub(r'[\\/:*?"<>|]', '_', name_part)
              html_file = out_dir / f'{safe_name}_{today}_report.html'
          out_dir.mkdir(parents=True, exist_ok=True)
      
          # 分组数据
          cat_groups = defaultdict(list)
          for work in works:
              cat = work.get("_category", "关注账号")
              if cat not in DEFAULT_CATEGORIES:
                  cat = "关注账号"
              cat_groups[cat].append(work)
      
          cat_colors = {
              "竞对账号": ("#e74c3c", "#fadbd8"),
              "同类账号": ("#f39c12", "#fdebd0"),
              "关注账号": ("#3498db", "#d6eaf8"),
          }
      
          # 构建 HTML
          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>抖音账号作品报告 — {datetime.now().strftime('%Y-%m-%d')}</title>
      <style>
        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
        body {{
          font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
            'Microsoft YaHei', sans-serif;
          background: #f5f6fa; color: #2c3e50; line-height: 1.6;
        }}
        .container {{ max-width: 1100px; margin: 0 auto; padding: 20px; }}
        .header {{
          background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
          color: white; padding: 32px 28px; border-radius: 12px; margin-bottom: 24px;
          text-align: center;
        }}
        .header h1 {{ font-size: 24px; margin-bottom: 6px; }}
        .header .subtitle {{ font-size: 14px; opacity: 0.75; }}
        .stats-bar {{
          display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px;
        }}
        .stat-card {{
          flex: 1; min-width: 140px; background: white; border-radius: 10px;
          padding: 16px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06);
          text-align: center;
        }}
        .stat-card .number {{ font-size: 28px; font-weight: 700; }}
        .stat-card .label {{ font-size: 13px; color: #7f8c8d; margin-top: 4px; }}
        .stat-card.works .number {{ color: #3498db; }}
        .stat-card.accounts .number {{ color: #2ecc71; }}
        .stat-card.empty .number {{ color: #e74c3c; }}
        .category-section {{ margin-bottom: 28px; }}
        .category-title {{
          font-size: 18px; font-weight: 700; padding: 8px 16px;
          border-radius: 8px 8px 0 0; display: inline-block;
        }}
        .account-block {{
          background: white; border-radius: 10px; margin-bottom: 16px;
          box-shadow: 0 1px 4px rgba(0,0,0,.06); overflow: hidden;
        }}
        .account-header {{
          padding: 14px 20px; border-bottom: 1px solid #eee;
          display: flex; align-items: center; justify-content: space-between;
          flex-wrap: wrap; gap: 8px;
        }}
        .account-name {{ font-size: 16px; font-weight: 700; }}
        .account-fans {{ font-size: 13px; color: #7f8c8d; }}
        .account-fans strong {{ color: #e74c3c; }}
        .table-wrap {{ overflow-x: auto; padding: 0 20px 16px; }}
        table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
        th {{
          background: #f8f9fa; padding: 10px 12px; text-align: left;
          font-weight: 600; color: #555; border-bottom: 2px solid #dee2e6;
          white-space: nowrap;
        }}
        th.right {{ text-align: right; }}
        td {{
          padding: 10px 12px; border-bottom: 1px solid #f1f3f5;
          vertical-align: top;
        }}
        td.right {{ text-align: right; font-variant-numeric: tabular-nums; }}
        tr:hover {{ background: #f8f9ff; }}
        td.title {{ max-width: 360px; }}
        td.title a {{
          color: #2c3e50; text-decoration: none; font-weight: 500;
        }}
        td.title a:hover {{ color: #3498db; text-decoration: underline; }}
        .empty-notice {{
          background: #fff3cd; border-left: 4px solid #f39c12;
          padding: 12px 20px; border-radius: 6px; margin-bottom: 16px;
          font-size: 14px; color: #856404;
        }}
        .summary-block {{
          background: white; border-radius: 10px; margin-bottom: 16px;
          box-shadow: 0 1px 4px rgba(0,0,0,.06); padding: 20px 24px;
        }}
        .summary-block h3 {{
          margin: 0 0 12px; font-size: 16px; color: #2c3e50;
          border-bottom: 2px solid #3498db; padding-bottom: 8px; display: inline-block;
        }}
        .summary-block .highlight {{
          background: #eaf6ff; border-left: 4px solid #3498db;
          padding: 10px 16px; border-radius: 6px; margin: 10px 0;
          font-size: 14px; color: #2c3e50; line-height: 1.6;
        }}
        .summary-block .top-list {{
          list-style: none; padding: 0; margin: 8px 0;
        }}
        .summary-block .top-list li {{
          padding: 8px 12px; border-bottom: 1px solid #f1f3f5;
          font-size: 14px; display: flex; justify-content: space-between;
        }}
        .summary-block .top-list li:last-child {{ border-bottom: none; }}
        .summary-block .rank {{ color: #e74c3c; font-weight: 700; margin-right: 8px; }}
        .summary-block .metric {{ color: #7f8c8d; font-size: 13px; }}
        .footer {{
          text-align: center; padding: 20px; color: #999; font-size: 13px;
          border-top: 1px solid #e8e8e8; margin-top: 20px;
        }}
        .badge {{
          display: inline-block; padding: 2px 8px; border-radius: 4px;
          font-size: 12px; font-weight: 600;
        }}
        @media (max-width: 768px) {{
          .container {{ padding: 12px; }}
          .header {{ padding: 20px 16px; }}
          .stats-bar {{ gap: 8px; }}
          .stat-card {{ min-width: 100px; padding: 12px; }}
          .stat-card .number {{ font-size: 22px; }}
          table {{ font-size: 12px; }}
          th, td {{ padding: 8px 6px; }}
          td.title {{ max-width: 180px; }}
        }}
      </style>
      </head>
      <body>
      <div class="container">
      
      <div class="header">
        <h1>📊 抖音账号作品报告</h1>
        <div class="subtitle">生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}  |  {date_label}</div>
      </div>
      
      <div class="stats-bar">
        <div class="stat-card accounts">
          <div class="number">{len(subscriptions)}</div>
          <div class="label">订阅账号</div>
        </div>
        <div class="stat-card works">
          <div class="number">{len(works)}</div>
          <div class="label">作品总数</div>
        </div>
        <div class="stat-card empty">
          <div class="number">{len(empty_accounts)}</div>
          <div class="label">无更新账号</div>
        </div>
      </div>
      """
      
          # 分类展示
          for cat in DEFAULT_CATEGORIES:
              arts = cat_groups.get(cat, [])
              if not arts:
                  continue
      
              cat_color, cat_bg = cat_colors.get(cat, ("#3498db", "#d6eaf8"))
              cat_icons = {"竞对账号": "⚔️", "同类账号": "◎", "关注账号": "⭐"}
      
              # 按账号分组
              account_groups = defaultdict(list)
              for art in arts:
                  account_key = art.get("_accountName") or art.get("authorName") or art.get("_accountId", "未知")
                  account_groups[account_key].append(art)
      
              html += f"""
      <div class="category-section">
        <div class="category-title" style="background:{cat_bg};color:{cat_color};">
          {cat_icons.get(cat, '●')} {cat} — {len(arts)} 条作品
        </div>
      """
      
              for account_name, account_works in sorted(account_groups.items(), key=lambda x: x[0]):
                  account_works.sort(key=lambda a: -(int(a.get("shareCount", 0) or 0)))
                  fans = format_fans(account_works[0].get("followerCount"))
                  sec_uid = account_works[0].get("secUid") or ""
                  account_link = f"https://www.douyin.com/user/{sec_uid}" if sec_uid else ""
      
                  name_html = f'<a href="{account_link}" target="_blank" style="color:inherit;text-decoration:none;">{account_name}</a>' if account_link else account_name
      
                  html += f"""
        <div class="account-block">
          <div class="account-header">
            <span class="account-name">📌 {name_html}</span>
            <span class="account-fans">粉丝: <strong>{fans}</strong> | 作品: {len(account_works)} 条</span>
          </div>
          <div class="table-wrap">
            <table>
              <thead>
                <tr>
                  <th>作品标题</th>
                  <th class="right">收藏数</th>
                  <th class="right">评论数</th>
                  <th class="right">分享数</th>
                  <th class="right">点赞数</th>
                  <th>发布时间</th>
                </tr>
              </thead>
              <tbody>
      """
                  for work in account_works:
                      title = work.get("title") or "无标题"
                      video_url = work.get("workUrl") or work.get("videoUrl", "#")
                      collects = format_number(work.get("collectCount"))
                      comments = format_number(work.get("commentCount"))
                      shares = format_number(work.get("shareCount"))
                      likes = format_number(work.get("likeCount"))
                      pub_time = work.get("publishTime") or "-"
                      if len(pub_time) > 16:
                          pub_time = pub_time[:16]
      
                      title_display = title.replace("\n", " ")
      
                      html += f"""          <tr>
                  <td class="title"><a href="{video_url}" target="_blank">{title_display}</a></td>
                  <td class="right">{collects}</td>
                  <td class="right">{comments}</td>
                  <td class="right">{shares}</td>
                  <td class="right">{likes}</td>
                  <td>{pub_time}</td>
                </tr>
      """
                  html += """        </tbody>
            </table>
          </div>
        </div>
      """
      
          # 无更新账号
          if empty_accounts:
              html += '<div class="category-section">\n'
              for name in empty_accounts:
                  html += f'<div class="empty-notice">⚠️ <strong>{name}</strong>:该时间段内无更新作品</div>\n'
              html += '</div>\n'
      
          # 被频率限制的账号
          if rate_limited_accounts:
              html += '<div class="category-section">\n'
              for name in rate_limited_accounts:
                  html += (f'<div class="empty-notice" style="border-left-color:#e67e22;">'
                           f'⚠️ <strong>{name}</strong>:该账号订阅已超过失败阈值,请联系客服邮箱处理。</div>\n')
              html += '</div>\n'
      
          # ── 数据总结区块(占位,脚本结束时替换为实际内容)──
          if works:
              html += '\n<!-- SUMMARY_PLACEHOLDER -->\n'
      
          html += f"""
      <div class="footer">
        订阅账号: {len(subscriptions)} 个 | 作品总数: {len(works)} 条 | 无更新: {len(empty_accounts)} 个 | 限流: {len(rate_limited_accounts)} 个<br>
        由抖音账号订阅追踪自动生成 | redfox.hk
      </div>
      
      </div>
      </body>
      </html>"""
      
          html_file.write_text(html, encoding="utf-8")
          return str(html_file)
      
      
      def print_markdown_table(works):
          """输出纯 Markdown 表格,供 Agent 直接展示给用户"""
          if not works:
              print("没有获取到任何作品")
              return
      
          cat_groups = defaultdict(list)
          for work in works:
              cat = work.get("_category", "关注账号")
              if cat not in DEFAULT_CATEGORIES:
                  cat = "关注账号"
              cat_groups[cat].append(work)
      
          for cat in DEFAULT_CATEGORIES:
              arts = cat_groups.get(cat, [])
              if not arts:
                  continue
      
              account_groups = defaultdict(list)
              for art in arts:
                  account_key = art.get("_accountName") or art.get("authorName") or art.get("_accountId", "未知")
                  account_groups[account_key].append(art)
      
              print(f"\n### {cat}({len(arts)} 条作品)")
      
              for account_name, account_works in sorted(account_groups.items(), key=lambda x: x[0]):
                  account_works.sort(key=lambda a: -(int(a.get("shareCount", 0) or 0)))
                  fans = format_fans(account_works[0].get("followerCount"))
                  sec_uid = account_works[0].get("secUid") or ""
                  account_link = f"https://www.douyin.com/user/{sec_uid}" if sec_uid else ""
      
                  if account_link:
                      print(f"\n**[{account_name}]({account_link})**(粉丝: {fans})")
                  else:
                      print(f"\n**{account_name}**(粉丝: {fans})")
                  print()
                  print("| 作品标题 | 收藏数 | 评论数 | 分享数 | 点赞数 | 发布时间 |")
                  print("|----------|--------|--------|--------|--------|----------|")
      
                  for work in account_works:
                      title = work.get("title") or "无标题"
                      work_url = work.get("workUrl") or work.get("videoUrl") or ""
                      title_safe = title.replace("|", "\\|").replace("\n", " ")
                      if work_url:
                          title_d = f"[{title_safe}]({work_url})"
                      else:
                          title_d = title_safe
      
                      collects = format_number(work.get("collectCount"))
                      comments = format_number(work.get("commentCount"))
                      shares = format_number(work.get("shareCount"))
                      likes = format_number(work.get("likeCount"))
      
                      pub_time = work.get("publishTime") or "-"
                      if len(pub_time) > 16:
                          pub_time = pub_time[:16]
                      pub_short = pub_time[5:16] if len(pub_time) >= 16 else pub_time
      
                      print(f"| {title_d} | {collects} | {comments} | {shares} | {likes} | {pub_short} |")
      
          print()
      
      
      
      # ─── 主流程 ────────────────────────────────────────────────────────────────────────
      def main():
          parser = argparse.ArgumentParser(
              description="抖音账号订阅追踪 — 订阅抖音账号,追踪最新作品",
              formatter_class=argparse.RawDescriptionHelpFormatter,
              epilog="""
      Examples:
        python3 subscribe.py add "5437662" --name "李佳琦" --category "竞对账号"
        python3 subscribe.py add "5437662,13232311,3234242"
        python3 subscribe.py remove "5437662"
        python3 subscribe.py list
        python3 subscribe.py fetch
        python3 subscribe.py fetch --date 2026-06-01
              """,
          )
      
          subparsers = parser.add_subparsers(dest="command", help="可用命令")
      
          # ── add 子命令 ──
          add_parser = subparsers.add_parser("add", help="添加订阅(需提供抖音号)")
          add_parser.add_argument("account_ids",
                                  help="抖音号,支持逗号分隔多个(如 5437662,13232311)")
          add_parser.add_argument("--name", dest="account_name", default="",
                                  help="账号名(可选,仅用于显示)")
          add_parser.add_argument("--category", default="关注账号",
                                  choices=DEFAULT_CATEGORIES + ["其他"],
                                  help="分类标签(默认: 关注账号)")
      
          # ── remove 子命令 ──
          remove_parser = subparsers.add_parser("remove", help="取消订阅")
          remove_parser.add_argument("account_id", help="抖音号")
      
          # ── list 子命令 ──
          subparsers.add_parser("list", help="列出所有订阅")
      
          # ── fetch 子命令 ──
          fetch_parser = subparsers.add_parser("fetch", help="拉取最新作品")
          fetch_parser.add_argument("--accounts", default="",
                                    help="抖音号列表,逗号分隔(如 YuZhouXiaoLi1220,Fish688688)")
          fetch_parser.add_argument("--date", default="",
                                    help="指定单日日期 YYYY-MM-DD(默认: 最新数据)")
          fetch_parser.add_argument("--date-start", default="",
                                    help="日期范围开始 YYYY-MM-DD")
          fetch_parser.add_argument("--date-end", default="",
                                    help="日期范围结束 YYYY-MM-DD")
          fetch_parser.add_argument("--markdown", action="store_true",
                                    help="输出 Markdown 格式表格(供 Agent 展示)")
          fetch_parser.add_argument("--html", action="store_true",
                                    help="生成 HTML 报告文件")
          fetch_parser.add_argument("--html-path", default="",
                                    help="HTML 报告输出路径")
      
          # ── 全局参数 ──
          parser.add_argument("--api-key", help="API Key")
          args = parser.parse_args()
      
          # ── Banner ──
          banner = f"""{CYAN}{BOLD}
        ╔══════════════════════════════════════╗
        ║     抖音账号订阅 · 作品追踪           ║
        ║     竞对 · 同类 · 关注 · 一网打尽     ║
        ╚══════════════════════════════════════╝{RESET}
      """
          print(banner)
      
          # ── 检查依赖 ──
          if not HAS_REQUESTS:
              error("缺少 requests 库,请安装: pip3 install requests")
              sys.exit(1)
      
          # ── 分发命令 ──
          if args.command == "add":
              add_subscriptions(args.account_ids, args.account_name, args.category)
              return
      
          if args.command == "remove":
              remove_subscription(args.account_id)
              return
      
          if args.command == "list":
              list_subscriptions()
              return
      
          if args.command == "fetch":
              # ── 获取账号列表:优先 --accounts 参数,其次 JSON 文件 ──
              accounts_arg = getattr(args, 'accounts', '') or ''
              is_adhoc_query = bool(accounts_arg)  # 区分规则1/2的关键标志
      
              if accounts_arg:
                  # 直接从命令行参数构建订阅列表(无需文件存储)
                  raw_ids = [aid.strip() for aid in accounts_arg.split(",") if aid.strip()]
                  subscriptions = [
                      {"accountId": aid, "accountName": aid, "category": "关注账号"}
                      for aid in raw_ids
                  ]
              else:
                  subscriptions = load_subscriptions()
      
              if not subscriptions:
                  error("未指定任何抖音账号,请使用 --accounts 参数传入抖音号")
                  print(f"\n  示例: {CYAN}python3 subscribe.py fetch --accounts \"YuZhouXiaoLi1220,Fish688688\" --html{RESET}")
                  sys.exit(1)
      
              api_key = get_api_key(cli_key=args.api_key)
      
              session = requests.Session()
              session.verify = True
              session.headers.update({
                  "Content-Type": "application/json",
                  "REDFOX_API_KEY": api_key,
              })
      
              date_str = args.date or ""
              date_start = getattr(args, 'date_start', '') or ''
              date_end = getattr(args, 'date_end', '') or ''
              user_specified_date = bool(date_str or date_start or date_end)
      
              # ── 规则 1 & 2:根据是否已订阅,决定默认查询策略 ──
              if not user_specified_date:
                  if is_adhoc_query:
                      # 规则1:未订阅账号,默认查近 30 天
                      fallback_start = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
                      fallback_end = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
                      date_label = f"(近 30 天: {fallback_start} 至 {fallback_end})"
                  else:
                      # 规则2:已订阅账号,默认查 T-1
                      yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
                      date_str = yesterday
                      date_label = f"(日期: {date_str})"
              else:
                  if date_start and date_end:
                      date_label = f"(日期范围: {date_start} 至 {date_end})"
                  elif date_str:
                      date_label = f"(日期: {date_str})"
                  else:
                      date_label = "(最新数据)"
      
              step(f"从 {len(subscriptions)} 个抖音账号拉取作品{date_label}...")
              print()
      
              if is_adhoc_query and not user_specified_date and not rate_limited_accounts:
                  # 规则1:查近 30 天
                  works, empty_accounts, rate_limited_accounts = fetch_all_works(
                      session, subscriptions, None, fallback_start, fallback_end
                  )
              else:
                  works, empty_accounts, rate_limited_accounts = fetch_all_works(
                      session, subscriptions,
                      date_str or None,
                      date_start or None,
                      date_end or None
                  )
      
              # ── 规则 1:近 30 天无数据,自动回溯近半年 ──
              if is_adhoc_query and not works and not user_specified_date and not rate_limited_accounts:
                  info("近 30 天无更新作品,自动回溯近半年...")
                  print()
                  half_year_start = (datetime.now() - timedelta(days=180)).strftime('%Y-%m-%d')
                  half_year_end = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
                  date_label = f"(回溯半年: {half_year_start} 至 {half_year_end})"
                  step(f"从 {len(subscriptions)} 个抖音账号拉取作品{date_label}...")
                  print()
                  works, empty_accounts, rate_limited_accounts = fetch_all_works(
                      session, subscriptions, None, half_year_start, half_year_end
                  )
                  date_str = ""
                  date_start = half_year_start
                  date_end = half_year_end
      
              # ── 规则 2:已订阅账号 T-1 无数据时的提示 ──
              if not is_adhoc_query and not works and not user_specified_date and not rate_limited_accounts:
                  for name in empty_accounts:
                      print(f"\n  {YELLOW}[!]{RESET} 「{name}」昨日暂未发布作品")
                  print(f"\n  {CYAN}💡 如需数据核查可联系工作人员邮箱 {SUPPORT_EMAIL} 处理{RESET}")
      
              # ── 规则 3:被频率限制拒绝的账号提示 ──
              for name in rate_limited_accounts:
                  print(f"\n  {RED}[✗]{RESET} 「{name}」当前账号订阅已超过失败阈值,请联系客服邮箱 {SUPPORT_EMAIL} 处理")
      
              if not works and not empty_accounts and not rate_limited_accounts:
                  warn("未获取到任何作品,可能是账号暂无数据或 API 暂时不可用")
                  sys.exit(1)
      
              # ── 规则 1:未订阅账号近半年无数据的最终提示 ──
              if is_adhoc_query and not works and not rate_limited_accounts:
                  for name in empty_accounts:
                      print(f"\n  {YELLOW}[!]{RESET} 抱歉您订阅的「{name}」账号近半年都未发布过作品")
                  print(f"\n  {CYAN}💡 如果需要数据核查可联系工作人员邮箱 {SUPPORT_EMAIL} 处理{RESET}")
      
              if works:
                  info(f"拉取完成: 共 {len(works)} 条作品")
      
              # ── 未收录账号提示 ──
              NOT_FOUND_MSG = "未查询到相关账号:当前 Skill 仅收录热门账号。如需定制数据,可邮件联系红狐数据咨询:redfoxdata@proton.me"
      
              # ── 输出(终端 / Markdown / HTML) ──
              want_html = getattr(args, 'html', False)
              html_path_arg = getattr(args, 'html_path', '') or ''
      
              # 仅当有作品数据时才生成 HTML 报告
              if want_html and works:
                  html_file = generate_html_report(
                      works, subscriptions, date_label, empty_accounts,
                      output_path=html_path_arg or None,
                      rate_limited_accounts=rate_limited_accounts
                  )
                  if html_file:
                      # ── 生成总结并嵌入 HTML(替换占位符)──
                      summary_html, summary_text = generate_summary(works)
                      raw = Path(html_file).read_text(encoding="utf-8")
                      raw = raw.replace("<!-- SUMMARY_PLACEHOLDER -->", summary_html)
                      Path(html_file).write_text(raw, encoding="utf-8")
                      # ── 自动打开 HTML 报告 ──
                      try:
                          import subprocess
                          subprocess.Popen(["open", str(html_file)])
                      except Exception:
                          pass
                      info(f"HTML 报告已生成: {html_file}")
                      # ── 输出 Markdown 表格(带链接,供 Agent 对话展示)──
                      print_markdown_table(works)
                      for name in empty_accounts:
                          warn(f"「{name}」该时间段内无更新作品")
      
                      # ── 输出纯文本总结(供 Agent 对话展示,与 HTML 内容一致)──
                      if summary_text:
                          print(f"\n{GREEN}=== 作品总结(与 HTML 报告一致)==={RESET}")
                          print(summary_text)
              elif want_html and not works:
                  info("无作品数据,跳过 HTML 报告生成")
                  # 仍然输出提示信息
                  for name in empty_accounts:
                      warn(f"「{name}」该时间段内无更新作品")
      
              elif getattr(args, 'markdown', False):
                  if works:
                      print_markdown_table(works)
                      # ── 输出纯文本总结 ──
                      _, summary_text = generate_summary(works)
                      if summary_text:
                          print(f"\n### 对上述作品的总结")
                          print(summary_text)
                  for name in empty_accounts:
                      print(f"\n**{name}**:该时间段内无更新作品")
      
                  print(f"\n订阅账号: {len(subscriptions)} 个 | 作品总数: {len(works)} 条")
              else:
                  if works:
                      print_terminal_table(works)
                  for name in empty_accounts:
                      warn(f"「{name}」该时间段内无更新作品")
      
                  print(f"\n{GREEN}{BOLD}✓ 完成!{RESET}")
                  print(f"  订阅账号: {len(subscriptions)} 个")
                  print(f"  作品总数: {len(works)} 条")
              return
      
          # ── 无命令 ──
          parser.print_help()
          print(f"\n{CYAN}快速开始:{RESET}")
          print(f"  add <抖音号>      — 添加订阅(支持逗号分隔多个)")
          print(f"  remove <抖音号>   — 取消订阅")
          print(f"  list              — 查看订阅")
          print(f"  fetch             — 拉取作品")
      
      
      if __name__ == "__main__":
          main()
      
  • README.en.md 4.1 KB
    # Douyin Account Subscription Tracker / douyin-subscribe
    
    ---
    
    ## Introduction
    
    Subscribe to competitor, peer, and follow accounts via Douyin ID. Automatically fetch the latest content data every day, generate visual reports, and stay on top of Douyin trends effortlessly.
    
    **Core Value**
    
    - **One-click Subscribe**: Send a Douyin ID to subscribe — no complex setup, supports up to 20 accounts
    - **Daily Auto-push**: Automatically fetches the latest content from subscribed accounts every day at 9:00 AM — no manual effort required
    - **Visual Reports**: Auto-generates polished HTML reports with preview and sharing support — data at a glance
    - **Full-spectrum Data**: Favorites, comments, shares, likes, and publish time — all key metrics covered
    
    **Ideal For**
    
    - 📊 **Content Operators** — Track competitor account activity and spot trending content in real time
    - 🎬 **Short-video Creators** — Monitor top accounts in your niche for inspiration and data-driven insights
    - 🏢 **Brands / MCNs** — Batch-manage followed accounts and receive daily content digests automatically
    
    ---
    
    ## Features
    
    ### Core Features
    
    - **Direct Douyin ID Subscription**: Subscribe using Douyin IDs — simple and transparent
    - **Daily Auto-fetch**: Automatically retrieves the latest content data every day at 9:00 AM after subscribing
    - **HTML Visual Reports**: Auto-generates polished report files with data summaries and Top 5 trending content
    - **Clickable Links**: Both titles and account names link directly to detail pages
    - **Smart Date Strategy**: Defaults to the previous day; auto-fallback to the last 7 days if no data; also supports custom date ranges
    - **Grouped Multi-account Display**: Grouped by account name, sorted by share count in descending order within each group
    
    ---
    
    ## API Key Acquisition & Security
    
    - This skill requires the environment variable: `REDFOX_API_KEY`.
    - `REDFOX_API_KEY` is provided by [RedFoxHub](https://redfox.hk/settings/api-keys?source=github) (`https://redfox.hk`).
    - Please visit [RedFoxHub](https://redfox.hk?source=github) to register an account and obtain your `REDFOX_API_KEY`.
    - Configure the `REDFOX_API_KEY` environment variable on your device before using this skill.
    - Before providing your key, please verify its source, applicable scope, validity period, and whether reset/revocation is supported.
    - Do not hardcode or expose your key in plaintext in code, prompts, logs, or output files.
    
    ---
    
    ## Usage Guide
    
    Simply tell us the Douyin ID you want to subscribe to in natural language — no commands to memorize.
    
    ### Quick Reference
    
    | Intent | Example | Result |
    | ------ | ------- | ------ |
    | Subscribe | "Subscribe to Fish688688" | Auto-validates and subscribes, displays latest content |
    | Batch Subscribe | "Subscribe to abc123, def456, ghi789" | Subscribes to multiple accounts at once |
    | View Specific Dates | "Show me content from 2026-06-01 to 2026-06-05" | Fetches historical content by date range |
    | Unsubscribe | "Unsubscribe Fish688688" | Removes the account from your subscription list |
    
    ### Output Example
    
    After subscribing, you will receive:
    
    - **Markdown Content Table**: Grouped by account, showing favorites, comments, shares, and likes
    - **HTML Visual Report**: Auto-saved to the `report/` directory, including data summaries and Top 5 trending content
    - **Summary Statistics**: Standout account performance + Top 5 trending topics
    
    ---
    
    ## Use Cases
    
    | Scenario | Role | Example Request | Benefit |
    | -------- | ---- | --------------- | ------- |
    | Competitor Monitoring | Content Operator | "Subscribe to competitor Douyin IDs and check their content daily" | Get daily competitor updates automatically, never miss trending content |
    | Niche Tracking | Short-video Creator | "Subscribe to these top accounts and track their content" | Stay current with niche trends and gain creative inspiration |
    | Batch Management | MCN / Brand | "Subscribe to all these accounts and manage them together" | Batch subscribe up to 20 accounts with automatic daily digests |
    | Historical Review | Content Analyst | "Show me last week's content data for these accounts" | Query historical data with custom date ranges |
    
    ---
    
  • README.md 3.5 KB
    # 抖音账号订阅追踪 / douyin-subscribe
    
    ---
    
    ## 简介
    
    通过抖音号订阅竞对、同类和关注账号,每天自动获取最新作品数据,生成可视化报告,轻松掌握抖音动态。
    
    **核心价值**
    
    - **一键订阅**:发送抖音号即可订阅,无需复杂配置,最多支持 20 个账号
    - **每日自动推送**:每天早上 9:00 自动拉取订阅账号的最新作品,无需手动操作
    - **可视化报告**:自动生成精美 HTML 报告,支持预览和分享,数据一目了然
    - **全维度数据**:收藏数、评论数、分享数、点赞数、发布时间,关键指标全覆盖
    
    **适用对象**
    
    - 📊 **内容运营** — 追踪竞品账号动态,及时发现爆款内容趋势
    - 🎬 **短视频创作者** — 监控同赛道头部账号,获取创作灵感与数据参考
    - 🏢 **品牌 / MCN** — 批量管理关注账号,每日自动收到作品日报
    
    ---
    
    ## 功能特性
    
    ### 核心功能
    
    - **抖音号直接订阅**:通过抖音号订阅账号,简单透明
    - **每日自动拉取**:订阅后每天 9:00 自动获取最新作品数据
    - **HTML 可视化报告**:自动生成精美报告文件,含数据总结与爆款 TOP5
    - **作品超链接**:标题和账号名均可点击跳转详情页
    - **智能日期策略**:默认查前一天,无数据自动回溯近 7 天,也支持自定义日期范围
    - **多账号分组展示**:按账号依次分组,同账号内按分享数降序排列
    
    ---
    
    ## 密钥获取与安全说明
    
    - 本技能需要使用环境变量:`REDFOX_API_KEY`。
    - `REDFOX_API_KEY` 由 [红狐 hub](https://redfox.hk/settings/api-keys?source=github) (`https://redfox.hk`)提供。
    - 请前往 [红狐 hub](https://redfox.hk?source=github) 注册账号,获取 `REDFOX_API_KEY`。
    - 配置设备环境变量 `REDFOX_API_KEY` 后使用本技能。
    - 在提供密钥前,请先确认密钥来源、可用范围、有效期及是否支持重置/撤销。
    - 禁止在代码、提示词、日志或输出文件中硬编码/明文暴露密钥。
    
    ---
    
    ## 使用指南
    
    直接用自然语言说出你想订阅的抖音号即可,无需记忆命令。
    
    ### 常用说法速查
    
    | 意图 | 示例话术 | 效果 |
    | ---- | -------- | ---- |
    | 订阅账号 | 「订阅 Fish688688」 | 自动验证并订阅,展示最新作品数据 |
    | 批量订阅 | 「订阅 abc123, def456, ghi789」 | 一次订阅多个账号 |
    | 查看特定日期 | 「查一下 2026-06-01 到 2026-06-05 的作品」 | 按日期范围拉取历史作品 |
    | 取消订阅 | 「取消订阅 Fish688688」 | 从订阅列表中移除该账号 |
    
    ### 输出示例
    
    订阅成功后,你将收到:
    
    - **Markdown 作品表格**:按账号分组展示,含收藏数、评论数、分享数、点赞数
    - **HTML 可视化报告**:自动保存至 `report/` 目录,含数据总结与爆款 TOP5
    - **总结统计**:突出账号表现 + 高频话题 TOP5
    
    ---
    
    ## 使用场景
    
    | 场景 | 角色 | 示例问法 | 收益 |
    | ---- | ---- | -------- | ---- |
    | 竞品监控 | 内容运营 | 「订阅竞品的抖音号,每天看他们的作品」 | 每日自动获取竞品动态,不错过爆款 |
    | 赛道追踪 | 短视频创作者 | 「订阅这几个头部账号,追踪他们的内容」 | 及时掌握赛道趋势,获取创作灵感 |
    | 批量管理 | MCN / 品牌 | 「把这些账号都订阅上,统一管理」 | 最多 20 个账号批量订阅,日报自动推送 |
    | 历史回溯 | 内容分析师 | 「查一下上周这些账号的作品数据」 | 支持自定义日期范围查询历史数据 |
    
    ---
    
  • SKILL.md 14.4 KB
    ---
    name: douyin-subscribe
    display_name: 抖音账号订阅追踪
    display_name_en: Douyin Account Subscribe
    description: 抖音账号订阅追踪 — 通过抖音号订阅账号(最多20个),Agent 每日 9:00 自动拉取并生成 HTML 报告。账号 ID 直接内置于自动化命令中,无需文件存储。支持多抖音号批量订阅,自动生成精美 HTML 作品报告,终端/Markdown 表格按账号依次展示作品标题、收藏、评论、分享、点赞、发布时间等数据。当用户订阅抖音账号、追踪抖音作品更新、监控抖音竞品账号时使用。触发词:抖音订阅、抖音账号订阅、抖音订阅追踪、抖音作品订阅、抖音账号监控、抖音作品追踪、抖音每日推送、抖音日报。
    description_zh: 抖音账号订阅追踪工具,订阅后每日9点自动拉取最新作品并生成HTML报告。
    description_en: Subscribe to Douyin accounts and get their latest works pulled automatically at 9:00 every day with a polished HTML report.
    category: data
    version: 1.1.0
    author: 红狐数据
    ---
    
    # 抖音账号订阅追踪
    
    ## 📝 简介
    
    通过抖音号订阅竞对、同类和关注账号(最多 20 个),自动抓取最新作品。**账号 ID 直接传入命令行参数,不依赖任何本地文件存储**,自动化任务中硬编码所有已订阅的抖音号。
    
    ---
    
    ## ✨ 功能特性
    
    | 功能模块 | 能力描述 | 核心价值 |
    |---------|---------|---------|
    | 账号直传 | 通过 `--accounts` 参数直接传入抖音号,无需文件存储 | 简单透明,命令即文档 |
    | 每日自动推送 | 自动化任务内置账号 ID,每天早上 9:00 自动执行 | 定时获取,无需手动操作 |
    | HTML 报告 | fetch 时自动生成精美 HTML 报告文件,支持预览和分享 | 可视化浏览,一目了然 |
    | 作品数据 | 收藏数、评论数、分享数、点赞数、发布时间 | 全维度数据,一目了然 |
    | 按账号展示 | 终端表格按账号依次分组展示 | 清晰直观,便于对比 |
    | 日期回溯 | 支持指定历史日期查询 | 追溯往期作品数据 |
    
    ---
    
    ## 🔑 鉴权
    
    ### 获取 API Key
    
    请前往 [红狐hub](https://redfox.hk/settings/api-keys?source=github) 获取API KEY
    
    ### 配置 API Key
    
    方案1: 以OpenClaw为例,将REDFOX_API_KEY添加到~/.openclaw/openclaw.json中:
    
    ```bash
    { "env": { "REDFOX_API_KEY": "ak_xxxx..." } }
    ```
    
    方案2: 终端配置
    
    ```bash
    export REDFOX_API_KEY="ak_xxxx..."
    ```
    
    ---
    
    ## ⚙️ 使用方式
    
    ### 拉取作品(核心命令)
    
    ```bash
    # 基础用法:直接传入抖音号列表
    python3 "$SKILL_PATH/scripts/subscribe.py" fetch --accounts "YuZhouXiaoLi1220,Fish688688"
    
    # 生成 HTML 报告(自动保存到 report/ 目录)
    python3 "$SKILL_PATH/scripts/subscribe.py" fetch --accounts "YuZhouXiaoLi1220,Fish688688" --html
    
    # 指定 HTML 报告输出路径
    python3 "$SKILL_PATH/scripts/subscribe.py" fetch --accounts "YuZhouXiaoLi1220,Fish688688" --html --html-path ~/Desktop/report.html
    
    # Markdown 格式输出(供 Agent 展示)
    python3 "$SKILL_PATH/scripts/subscribe.py" fetch --accounts "YuZhouXiaoLi1220,Fish688688" --markdown
    
    # 指定单日日期
    python3 "$SKILL_PATH/scripts/subscribe.py" fetch --accounts "YuZhouXiaoLi1220,Fish688688" --html --date 2026-06-01
    
    # 指定日期范围
    python3 "$SKILL_PATH/scripts/subscribe.py" fetch --accounts "YuZhouXiaoLi1220,Fish688688" --html --date-start 2026-06-01 --date-end 2026-06-05
    ```
    
    ### 参数说明
    
    | 命令 | 参数 | 说明 |
    |------|------|------|
    | `fetch` | **`--accounts`** | **抖音号列表(必填或核心参数),逗号分隔,如 `"id1,id2,id3"`** |
    | | `--date` | 指定单日日期 YYYY-MM-DD |
    | | `--date-start` | 指定日期范围开始 YYYY-MM-DD |
    | | `--date-end` | 指定日期范围结束 YYYY-MM-DD |
    | | `--markdown` | 输出 Markdown 格式表格 |
    | | `--html` | 生成 HTML 报告文件(保存到 report/ 目录) |
    | | `--html-path` | 自定义 HTML 报告输出路径 |
    | 全局 | `--api-key` | 指定 API Key |
    
    ### 依赖
    
    | 依赖 | 安装命令 |
    |------|----------|
    | `requests` | `pip3 install requests` |
    
    ---
    
    ## 🤖 每日自动推送
    
    用户订阅账号后,Agent 会创建一个**每日定时任务**,**每天早上 9:00** 自动执行。任务命令中**直接包含所有已订阅的抖音号 ID**:
    
    ```bash
    # 自动化任务中的实际命令(账号已内置)
    python3 subscribe.py fetch --accounts "YuZhouXiaoLi1220,Fish688688" --html
    ```
    
    ### 工作原理
    
    1. 用户发送抖音号 → Agent 将其写入自动化命令的 `--accounts` 参数
    2. 每天 9:00 定时触发 → 命令自带账号 ID → 直接调 API 拉数据
    3. 自动生成 HTML 报告 → 保存至 `report/` 目录
    4. **不依赖任何本地 JSON 文件**
    
    ---
    
    ## 🔧 账号管理规则(Agent 必须遵守)
    
    > **核心原则:所有订阅状态 = 自动化命令中的 `--accounts` 参数。无文件存储。**
    
    ### 订阅新账号流程
    
    当用户提供新的抖音号时,Agent **必须**按以下步骤操作:
    
    #### Step 1:查询现有自动化任务
    
    使用 `automation_update mode=list` 获取所有「抖音订阅」相关的自动化任务,读取每个任务的 `--accounts` 参数中的账号列表。
    
    #### Step 2:判断是否追加 / 新建
    
    | 条件 | 操作 |
    |------|------|
    | 现有账号数 + 新账号数 **≤ 20** | **追加到现有自动化任务的 `--accounts` 参数**(去重后用逗号拼接) |
    | 现有账号数 + 新账号数 **> 20** | **创建新的自动化任务**,将超出部分放入新任务的 `--accounts` |
    | 用户明确要求取消某账号 | 从对应自动化任务的 `--accounts` 中移除该 ID |
    
    #### Step 3:更新/创建自动化任务
    
    - **追加场景**:调用 `automation_update mode=update`,修改现有任务的 `prompt` 字段中的 `--accounts` 值
    - **新建场景**:调用 `automation_update mode=create`,新建一个独立的每日定时任务(同样 9:00 执行),名称可加序号如「抖音订阅作品日报 #2」
    
    #### Step 4:立即拉取一次
    
    无论追加还是新建,**都必须立即执行一次 fetch**(带 `--html`)展示最新结果给用户。
    
    ### 多任务示例
    
    假设已有 18 个账号,用户又提供了 5 个新账号:
    
    ```
    现有任务 #1: --accounts "id1,id2,...,id18" (18个)
    新增 5 个: id19,id20,id21,id22,id23
    
    → 任务 #1 更新为: --accounts "id1,...,id18,id19,id20" (20个 ✅)
    → 新建任务 #2: --accounts "id21,id22,id23" (3个 ✅)
    ```
    
    每个独立任务都会在每天 9:00 各自执行,各自生成 HTML 报告。
    
    ### 取消订阅
    
    从对应任务的 `--accounts` 中移除该 ID 即可。如果移除后某个任务剩余 0 个账号,则删除该自动化任务。
    
    ---
    
    ## 📋 交互规范
    
    ### 订阅流程
    
    > **静默执行原则:所有中间步骤(fetch 验证、add 订阅、automation_update)均不得向用户输出任何过程性提示(如「正在验证账号」「账号验证通过」「自动订阅中」等)。仅向用户展示最终结果。**
    
    1. **用户发送抖音号** → Agent **静默执行 `fetch --accounts "抖音号" --html --markdown`** 验证账号
    2. **判断账号状态**(规则 1):
       - **接口返回正常数据**:
         - **静默执行** add 订阅(见下一步)
       - **近 30 天无数据,自动回溯半年后仍无数据**:
         - 告知用户:「抱歉您订阅的“xxx”账号近半年都未发布过作品,如果需要数据核查可联系工作人员邮箱 redfoxdata@proton.me 处理」
         - **询问用户**:「是否订阅明日推送?」
         - 用户回答「订阅」→ **静默执行** add 订阅
         - 用户未确认 → 不订阅
    3. **静默查询现有自动化任务**的 `--accounts` 列表,判断追加 or 新建:
       - 现有账号数 + 新账号数 ≤ 20 → 追加到现有任务的 `--accounts`
       - 现有账号数 + 新账号数 > 20 → 新建一个自动化任务放溢出部分
    4. **静默更新/创建自动化任务**
    5. **若 fetch 返回了作品数据**:
       - 自动打开 HTML 报告预览(`preview_url`)
       - 对话中展示 Markdown 作品表格
       - 提示:「如需查看特定时间段的作品,可以告诉我,如“查一下 2026-06-01 到 2026-06-05 的作品”」
    6. **若 fetch 无作品数据**(账号存在但时段内无更新):仅告知用户无更新,不生成 HTML 报告
    7. 输出顺序:**先按账号名升序**分组,同账号内按**分享数降序**排列
    8. 若某个账号在指定时间范围内无更新作品,直接告知用户:「**账号名**:该时间段内无更新作品」
    
    > **重要:仅当 fetch 返回实际作品数据时,才需要三输出——Markdown 表格 + HTML 报告预览 + HTML 报告文件。无作品数据时不生成 HTML 报告。**
    
    ### 对话输出格式(订阅/拉取时必须遵循)
    
    Agent 执行 fetch 后,**必须在对话回复中按以下格式输出数据**(不是只依赖脚本终端输出):
    
    #### 格式一:账号概览表
    
    ```markdown
    ## 📊 订阅报告
    
    | 项目 | 值 |
    |------|-----|
    | 新订阅 | `新账号名`(粉丝量) |
    | 当前总账号 | N 个 |
    | 本次拉取作品 | N 条 |
    | 自动化任务 | 已更新 ✅ 每天 9:00 自动执行 |
    
    ### 账号概览
    
    | 账号 | 粉丝量 | 领域 | 代表作数据 |
    |------|--------|------|-----------|
    | AccountA | xxx | 领域 | 🏆 代表作:收藏x / 点赞x / 分享x |
    ```
    
    #### 格式二:每个账号的详细作品表(Markdown)
    
    ```markdown
    ### ▸ AccountName(粉丝: xxx)
    
    | 作品标题 | 收藏数 | 评论数 | 分享数 | 点赞数 | 发布时间 |
    |---------|--------|--------|--------|--------|---------|
    | 标题1 | 10.8w | 2.8k | 10.3w | 19.3w | 06-08 |
    ```
    
    要求:
    - 每个账号一个子标题 + 一个完整 Markdown 表格
    - 标题列使用 API 返回的 `content` 字段完整原文(脚本内部映射为 `title`),不截断
    - 数值列使用 `format_number` 后的可读格式(如 10.8w、2.3k)
    - 发布时间截取到 `MM-DD HH:mm`
    - 按**分享数降序**排列
    
    #### 格式三:总结统计(必须附加在所有表格之后)
    
    ```markdown
    ### 对上述作品的总结
    
    > 1. **突出的账号表现**:...
    > 2. **高频事件 TOP5**:...
    ```
    
    #### 输出完整性清单
    
    每次订阅/拉取,对话中**必须包含**以下全部内容:
    
    - [ ] 账号概览表(含总览信息)
    - [ ] 每个账号一个 Markdown 作品详情表
    - [ ] 无更新账号提示(如有)
    - [ ] 总结统计分析
    - [ ] **规则 2 适用**:已订阅账号昨日无数据时,结尾附加邮箱核查提示
    - [ ] **规则 3 适用**:被频率限制拒绝的账号提示
    - [ ] **仅当有作品数据时**:自动打开 HTML 报告预览(preview_url)+ HTML 报告文件已生成说明(附文件路径)
    
    ### 抖音号要求
    
    接口通过 `uniqueName`(抖音展示ID)查询,脚本将用户提供的抖音号统一按 `uniqueName` 传入,`userId` 和 `shortId` 固定为空字符串。订阅时**必须提供抖音号**。
    
    **重要:** 若用户只输入账号名称(如"李佳琦""老高與小茉")而未提供抖音号,必须提示:
    > "抖音账号名称存在多个重名情况,请提供准确的**抖音号**以便精准订阅。"
    
    并附上获取抖音号的示例图供用户参考:
    
    ![获取抖音号示例](https://lyy.redfox.hk/page/ljq.png)
    
    ### 数据规则
    
    - **每个自动化任务最多 20 个账号**(超限时 Agent 自动创建新任务,详见「账号管理规则」)
    - **规则 1:未订阅账号(首次验证查询)**:
      - 默认查询近 30 天数据
      - 若近 30 天无数据,自动回溯近半年(180 天)
      - 若近半年仍无数据,告知用户:「抱歉您订阅的“xxx”账号近半年都未发布过作品,如果需要数据核查可联系工作人员邮箱 redfoxdata@proton.me 处理」
    - **规则 2:已订阅账号(日常拉取)**:
      - 默认查前一天数据(T-1)
      - 若昨日无数据,告知用户:「昨日该账号暂未发布作品」
      - 结尾固定提示:「如果需要数据核查可联系工作人员邮箱 redfoxdata@proton.me 处理」
    - **规则 3:失败阈值保护**:
      - 同一抖音号在 6 小时内累计 3 次 API 调用失败后,后续请求将被拒绝
      - 拒绝时提示:「当前账号订阅已超过失败阈值,请联系客服邮箱 redfoxdata@proton.me 处理」
      - 距上次失败超过 6 小时 / 同一账号查询成功,计数归零,恢复正常调用
    - **用户指定日期不回溯**:若用户明确指定了日期或日期范围,查不到数据时直接告知用户,不自动回溯
    - **支持日期范围查询**:用户可指定起止日期(`--date-start YYYY-MM-DD --date-end YYYY-MM-DD`)拉取历史作品
    - 每账号最多返回 **10 条**作品
    - 排序方式:账号名升序 → 同账号内**分享数降序**
    - **无数据提示**:若某个账号在指定时间范围内无更新作品,脚本会输出「**账号名**:该时间段内无更新作品」,Agent 需将此信息原样转达给用户
    - 接口不支持多账号同时查询,脚本自动拆分后逐账号调用并整合数据
    
    ### 输出表格结构(脚本终端格式,仅供参考)
    
    脚本终端输出原始格式如下,但 **Agent 对话中必须使用「对话输出格式」中的 Markdown 表格重新排版**:
    
    ```
    ▸ Fish688688(粉丝: 249w+)
    ──────────────────────────────────────────────────────────────
      作品标题                          收藏数  评论数  分享数  点赞数  发布时间
    ──────────────────────────────────────────────────────────────
      今晚图省事...                    10.8w   2.8k   10.3w  19.3w  05-21 21:40
    ```
    
    作品标题使用 API `content` 字段(脚本内部映射为 `title`),链接使用 `opusUrl` 字段(脚本内部映射为 `workUrl`)。Markdown 输出中标题自动渲染为可点击超链接:`[标题](workUrl)`。
    
    > **注意**:当前广域库 API 不返回 `followerCount`(粉丝数),账号概览中粉丝量显示为 `-`。
    
    ### 总结统计
    
    表格展示完毕后,必须严格按以下格式附加总结:
    
    > 对上述作品的总结:
    > 1. **突出的账号表现**:指出数据最亮眼的账号及其代表作品
    > 2. **高频事件 TOP5**:提炼所有作品中提到次数最多的 5 个事件/话题,并归纳每个事件对应的不同观点(不足 5 个可提炼的,按实际数量呈现,禁止虚构)
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related