
Douyin Subscribe
- 217 installs
- 316 repo stars
- Updated August 4, 2026
- redfox-data/redfox-community
Helps with ai & agent building tasks during AI-assisted development.
About
douyin-subscribe is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- douyin-subscribe
- AI & Agent Building
- AI-coding skill
Douyin Subscribe by the numbers
- 217 all-time installs (skills.sh)
- +14 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #2,765 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/redfox-data/redfox-community --skill douyin-subscribeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 217 |
|---|---|
| repo stars | ★ 316 |
| Last updated | August 4, 2026 |
| Repository | redfox-data/redfox-community ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
抖音账号订阅追踪
📝 简介
通过抖音号订阅竞对、同类和关注账号(最多 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. 判断账号状态:
- 接口返回 `type=1`(未收录):
- 直接告知用户「当前暂未找到该账号信息,可能是数据覆盖范围有限所致」
- 询问用户:「我们将尽快更新数据,通常10分钟内可查,特殊情况下需1天。是否订阅明日推送?」
- 用户回答「订阅」→ 静默执行 add 订阅
- 用户未确认 → 不订阅
- 接口返回正常数据 / 无作品但账号存在:
- 静默执行 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 表格
- 标题列使用
title字段值,超长标题截断为前30字+... - 数值列使用
format_number后的可读格式(如 10.8w、2.3k) - 发布时间截取到
MM-DD HH:mm - 按分享数降序排列
格式三:总结统计(必须附加在所有表格之后)
### 对上述作品的总结
> 1. **突出的账号表现**:...
> 2. **高频事件 TOP5**:...输出完整性清单
每次订阅/拉取,对话中必须包含以下全部内容:
- [ ] 账号概览表(含总览信息)
- [ ] 每个账号一个 Markdown 作品详情表
- [ ] 无更新账号提示(如有)
- [ ] 未收录账号提示 + 询问是否订阅明日推送(如有)
- [ ] 总结统计分析
- [ ] 仅当有作品数据时:自动打开 HTML 报告预览(preview_url)+ HTML 报告文件已生成说明(附文件路径)
抖音号要求
接口仅支持通过抖音号(accountId)查询,订阅时必须提供抖音号。支持 unique_id、short_id、uid 任一格式。
重要: 若用户只输入账号名称(如"李佳琦""老高與小茉")而未提供抖音号,必须提示:
"抖音账号名称存在多个重名情况,请提供准确的抖音号以便精准订阅。"
并附上获取抖音号的示例图供用户参考:

数据规则
- 每个自动化任务最多 20 个账号(超限时 Agent 自动创建新任务,详见「账号管理规则」)
- 默认查前一天数据(T-1):用户未指定日期时,fetch 默认查询昨日作品;若昨日无数据则自动回溯近 7 天(T-7 至 T-1);若仍无数据则直接告知用户
- 用户指定日期不回溯:若用户明确指定了日期或日期范围,查不到数据时直接告知用户,不自动回溯
- 支持日期范围查询:用户可指定起止日期(
--date-start YYYY-MM-DD --date-end YYYY-MM-DD)拉取历史作品 - 每账号最多返回 10 条作品
- 排序方式:账号名升序 → 同账号内分享数降序
- 无数据提示:若某个账号在指定时间范围内无更新作品,脚本会输出「账号名:该时间段内无更新作品」,Agent 需将此信息原样转达给用户
- 未收录账号提示:若接口返回
type=1表示账号未被数据库收录,脚本会输出引导信息:「当前暂未找到该账号信息,可能是数据覆盖范围有限所致。我们将尽快更新数据,通常10分钟内可查,特殊情况下需1天,您可订阅明日推送。」Agent 需原样转达 - 接口不支持多账号同时查询,脚本自动拆分后逐账号调用并整合数据
输出表格结构(脚本终端格式,仅供参考)
脚本终端输出原始格式如下,但 Agent 对话中必须使用「对话输出格式」中的 Markdown 表格重新排版:
▸ Fish688688(粉丝: 249w+)
──────────────────────────────────────────────────────────────
作品标题 收藏数 评论数 分享数 点赞数 发布时间
──────────────────────────────────────────────────────────────
今晚图省事... 10.8w 2.8k 10.3w 19.3w 05-21 21:40作品标题使用 title 字段,链接使用 workUrl 字段。Markdown 输出中标题自动渲染为可点击超链接:[标题](workUrl)。
总结统计
表格展示完毕后,必须严格按以下格式附加总结:
对上述作品的总结:
1. 突出的账号表现:指出数据最亮眼的账号及其代表作品
2. 高频事件 TOP5:提炼所有作品中提到次数最多的 5 个事件/话题,并归纳每个事件对应的不同观点(不足 5 个可提炼的,按实际数量呈现,禁止虚构)
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_KEYis provided by RedFoxHub (https://redfox.hk).- Please visit RedFoxHub to register an account and obtain your
REDFOX_API_KEY. - Configure the
REDFOX_API_KEYenvironment 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 |
---
抖音账号订阅追踪 / 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 个账号批量订阅,日报自动推送 |
| 历史回溯 | 内容分析师 | 「查一下上周这些账号的作品数据」 | 支持自定义日期范围查询历史数据 |
---
#!/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 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/dyData/searchWorkList"
ENV_KEY = "REDFOX_API_KEY"
SOURCE = "抖音账号订阅-GitHub"
SUBSCRIPTIONS_FILE = Path.home() / ".qoder" / "douyin_subscriptions.json"
MAX_SUBSCRIPTIONS = 20
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()
# ─── 数据获取 ──────────────────────────────────────────────────────────────────────
def fetch_account_works(session, account_id, date_str=None, date_start=None, date_end=None):
"""获取单个抖音账号的作品列表 — 仅通过 accountId 查询"""
payload = {
"accountId": account_id,
"accountName": "",
"offset": 0,
"sortType": "_2",
"publishTimeStart": "",
"publishTimeEnd": "",
"source": SOURCE,
}
if date_start and date_end:
payload["publishTimeStart"] = f"{date_start} 00:00:00"
payload["publishTimeEnd"] = f"{date_end} 23:59:59"
elif date_str:
payload["publishTimeStart"] = f"{date_str} 00:00:00"
payload["publishTimeEnd"] = f"{date_str} 23:59:59"
try:
resp = session.post(API_URL, json=payload, timeout=20)
result = resp.json()
except requests.exceptions.Timeout:
warn(f"请求超时: 抖音号 {account_id}")
return []
except Exception as e:
warn(f"请求失败: 抖音号 {account_id}: {e}")
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:
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}")
return []
data_raw = result.get("data", {})
if not data_raw:
return []
# type=1 表示该账号未在数据库中收录
if isinstance(data_raw, dict) and data_raw.get("type") == 1:
return None
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 = []
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, not_found_accounts)"""
all_works = []
empty_accounts = []
not_found_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}[\u2192]{RESET} 拉取: {name} ({aid}) ({i}/{total})", end="", flush=True)
works = fetch_account_works(session, aid, date_str, date_start, date_end)
if works is None:
# 账号未在数据库中收录
not_found_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, not_found_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, not_found_accounts=None):
"""生成 HTML 报告文件,返回 html_file_path"""
not_found_accounts = not_found_accounts or []
if not works and not empty_accounts and not not_found_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'
html_file = out_dir / f'douyin_report_{today}.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
if len(title) > 50:
title_display = title[:48] + ".."
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 not_found_accounts:
html += '<div class="category-section">\n'
for name in not_found_accounts:
html += (f'<div class="empty-notice" style="border-left-color:#e67e22;">'
f'ℹ️ <strong>{name}</strong>:当前暂未找到该账号信息,可能是数据覆盖范围有限所致。'
f'我们将尽快更新数据,通常10分钟内可查,特殊情况下需1天,您可订阅明日推送。</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(not_found_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 len(title_safe) > 40:
title_safe = title_safe[:38] + ".."
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 ''
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",
"X-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)
# 默认查前一天(T-1)
if not user_specified_date:
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
date_str = yesterday
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()
works, empty_accounts, not_found_accounts = fetch_all_works(
session, subscriptions,
date_str or None,
date_start or None,
date_end or None
)
# 默认查询无数据时,自动回溯近 7 天(仅当用户未指定日期时,且不包含未收录账号)
if not works and not user_specified_date:
info(f"{date_str} 无更新作品,自动回溯近 7 天...")
print()
fallback_start = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
fallback_end = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
date_label = f"(回溯日期范围: {fallback_start} 至 {fallback_end})"
step(f"从 {len(subscriptions)} 个抖音账号拉取作品{date_label}...")
print()
works, empty_accounts, not_found_accounts = fetch_all_works(
session, subscriptions,
None,
fallback_start,
fallback_end
)
date_str = ""
date_start = fallback_start
date_end = fallback_end
if not works and not empty_accounts and not not_found_accounts:
warn("未获取到任何作品,可能是账号暂无数据或 API 暂时不可用")
sys.exit(1)
if works:
info(f"拉取完成: 共 {len(works)} 条作品")
# ── 未收录账号提示 ──
NOT_FOUND_MSG = (
"当前暂未找到该账号信息,可能是数据覆盖范围有限所致。\n"
"不过别担心,我们将尽快为您更新数据,通常10分钟内可查,"
"特殊情况下需1天,您可订阅明日推送。"
)
# ── 输出(终端 / 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,
not_found_accounts=not_found_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")
info(f"HTML 报告已生成: {html_file}")
# ── 输出 Markdown 表格(带链接,供 Agent 对话展示)──
print_markdown_table(works)
for name in empty_accounts:
warn(f"「{name}」该时间段内无更新作品")
for name in not_found_accounts:
print(f"\n**{name}**:{NOT_FOUND_MSG}")
# ── 输出纯文本总结(供 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}」该时间段内无更新作品")
for name in not_found_accounts:
print(f"\n**{name}**:{NOT_FOUND_MSG}")
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}**:该时间段内无更新作品")
for name in not_found_accounts:
print(f"\n**{name}**:{NOT_FOUND_MSG}")
print(f"\n订阅账号: {len(subscriptions)} 个 | 作品总数: {len(works)} 条")
else:
if works:
print_terminal_table(works)
for name in empty_accounts:
warn(f"「{name}」该时间段内无更新作品")
for name in not_found_accounts:
warn(f"「{name}」{NOT_FOUND_MSG}")
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()