热点三榜数据接入定稿:全品类单文件(不传赛道一次调用)+ 三子目录落盘

- fetch_week_ranks.py 重写:28 赛道循环→全品类不传 type 每榜每期 1 次调用;hotContentRank source 修正为生产名「抖音每日点赞飙升榜」(GitHub 后缀版返回空);API 单次上限 TOP50(limit/page/offset 实测均无效)固化防再探测;成本铁律「批量付费调用前须先经用户确认」
- 落盘契约:抖音榜单/ 三子目录(账号周榜/视频热榜/点赞榜),hot/likes 全品类单文件/期 抖音周榜_{视频热榜|点赞榜}_{周一}.json;旧 28 赛道 56 文件归档 _archive_旧28赛道结构_20260903
- dsh-data.js:RANK_SUB 子目录映射 + rankFilePath + listRankFiles(子目录优先+根目录旧平铺回退) + collectCategories(week=文件名赛道、hot/likes=条目 category 并集)+ getRanking 赛道筛选(week 文件名级/hot likes 条目级)
- 前端三榜渲染(data-pages.js/app.js/style.css 承接前段)+ 文档同步(主 SKILL.md 落盘表/抓取器说明段)+ dsh 副本 diff=0 + 记忆日志
This commit is contained in:
maogeigei committed 2026-09-03 15:00:01 +08:00
1 parent 2fc8b7b3f6
commit 250d51c8c2
10 files changed
+553 -116

No files matched your search

@@ -94,17 +94,27 @@ export REDFOX_API_KEY="ak_xxx..."
| ①③ 用户环境 / 开发机(本机无 `.dsh`) | `C:\Users\maidou\Desktop\MCNSkill项目\三方数据\红狐数据\抖音榜单\` |
| ② dsh 环境(本机有 `.dsh`) | `D:\dshworkspace\抖音榜单\`(兼容工作台 RANKING_DIR 现有读取) |
### 文件命名(按工作台榜单 tab 对齐,09-03 截图实证)
### 目录结构(09-03 起分目录管理,按工作台 tab 对齐)
`{榜名}_{赛道}_{数据日期}.json`——所有榜单一律此格式,按榜名区分:
`抖音榜单/` 下按三榜分三个子目录,避免长期平铺堆积;根目录旧平铺文件兼容(工作台读取端回退扫描),新产物一律入子目录:
| 工作台 tab | 类别 | 文件名(榜名部分) | 状态 |
```
抖音榜单/
账号周榜/ 抖音周榜_{赛道}_{周一}.json ← 账号影响力周榜(每赛道一文件 × 多期)
视频热榜/ 抖音周榜_视频热榜_{周一}.json ← 作品维周聚合 TOP50(全品类单文件/期)
点赞榜/ 抖音周榜_点赞榜_{周一}.json ← 作品维七日点赞飙升 TOP50(全品类单文件/期)
```
| 工作台 tab | 类别 | 落盘(子目录 + 文件名) | 状态 |
|---|---|---|---|
| 1 账号周榜 | 账号影响力周榜 | `抖音周榜_{赛道}_{日期}.json` | ✅ 已实现(旧名保留,dsh 正则兼容) |
| 2 视频热榜 | 作品维热榜 | `抖音视频热榜_{赛道}_{日期}.json` | ❌ 预留 |
| 3 点赞榜 | 作品维点赞榜 | `抖音点赞榜_{赛道}_{日期}.json` | ❌ 预留 |
| 4 涨粉榜 | 账号维涨粉榜 | `抖音涨粉榜_{赛道}_{日期}.json` | ✅ 已实现(2026-09-03 补技能 douyin-rise-ranking) |
| 扩展 | 账号日榜/月榜、七日飙升榜、AI信息源等 | 同构命名追加 | ❌ 预留 |
| 1 账号周榜 | 账号影响力周榜 | `账号周榜/抖音周榜_{赛道}_{周一}.json` | ✅ 已实现(douyin-top-account 抓取) |
| 2 视频热榜 | 作品维周聚合作品榜 | `视频热榜/抖音周榜_视频热榜_{周一}.json` | ✅ 已实现(fetch_week_ranks.py,全品类 TOP50) |
| 3 点赞榜 | 作品维七日点赞飙升 | `点赞榜/抖音周榜_点赞榜_{周一}.json` | ✅ 已实现(fetch_week_ranks.py,全品类 TOP50) |
| 4 涨粉榜 | 账号维涨粉榜 | 平铺 `抖音涨粉榜_{赛道}_{日期}.json` | ✅ 已实现(douyin-rise-ranking 子技能命名,未接入工作台 tab) |
> **周粒度抓取器(工作台 视频热榜/点赞榜 数据源,09-03 重构定稿)**:`scripts/fetch_week_ranks.py` —— **不传赛道(type)= 全品类,每榜每期仅 1 次 API 调用**(禁止逐赛道/逐日循环)。hot = likesRank 传 周一~周日(服务端周聚合);likes = hotContentRank **source 必须为「抖音每日点赞飙升榜」生产名**(写「抖音七日点赞飙升榜-GitHub」返回空,09-03 实测修正),startTime 传周日(七日口径)。**★API 单次上限 TOP50**(limit=100 / page=2 / offset=50 均被忽略,09-03 实测),无 51-100 名获取途径,勿再探测。list 条目补 `rank`;顶层 `category:"全部"`(实际赛道在 list[].category,展示端按条目赛道筛选/下拉去重)。用法:`python scripts/fetch_week_ranks.py --board both [--date 2026-08-24] [--force]`。落盘目录解析同工作台(env `MCN_RANKING_DIR` → `.dsh` 部署 `D:\dshworkspace\抖音榜单` → 桌面 `三方数据\红狐数据\抖音榜单`)。**成本铁律(09-03 固化)**:涉及多赛道/多日/多窗口循环的付费调用前,必须先向用户确认调用次数与范围,禁止未经确认直接批量调用。
> 子技能 douyin-daily-hot(视频热榜)/ douyin-content-surge(点赞榜)日粒度归档沿用各自 SKILL.md 命名;**工作台周粒度文件一律 fetch_week_ranks.py 落盘对应子目录**,避免日/周口径混入。douyin-weekly-surge(官方七日口径)落盘 `抖音七日飙升榜_…` 命名保留,未接入工作台 tab。
### 查询幂等铁律(防重复花 API 钱)
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
工作台热点数据:视频热榜 / 点赞榜 周粒度抓取器(不传赛道=全品类,一次调用,禁止逐赛道/逐日循环)
- 视频热榜(board=hot) : likesRank 传 周一~周日 区间,服务端返回该周全品类聚合作品榜 TOP50(一次调用)
- 点赞榜(board=likes) : hotContentRank 传 截至周日 startTime,返回七日窗口全品类作品榜 TOP50(一次调用)
★ 2026-09-03 实测修正:hotContentRank 的 source 必须为「抖音每日点赞飙升榜」(生产名,无 -GitHub 后缀),
写「抖音七日点赞飙升榜-GitHub」会返回空;不带 type 即全品类(50 条,条目 category 齐全)
★ API 单次上限 TOP50:limit=100 / page=2 / offset=50 均被忽略(2026-09-03 实测),无 51-100 名途径,勿再探测
【成本约定 2026-09-03】本脚本每榜每周仅 1 次 API 调用;任何涉及按赛道/多日/多窗口循环的批量调用
必须先向用户确认调用次数与范围,禁止未经确认直接执行付费调用(教训固化)。
落盘(RANKING_DIR 三子目录结构,与账号周榜分目录管理):
视频热榜/抖音周榜_视频热榜_{周标号}.json (周标号 = 该周周一,工作台按文件期数切换)
点赞榜/ 抖音周榜_点赞榜_{周标号}.json
账号周榜(week)由 douyin-top-account 技能落盘到 账号周榜/ 子目录,本脚本不涉及。
用法:
python fetch_week_ranks.py --board both [--date 2026-08-24] [--force]
--board hot|likes|both 默认 both
--date 周标号(周一日期),默认自动取上一个完整周的周一
--force 覆盖已存在文件(默认命中已落盘文件跳过,免重复调用)
环境变量:
REDFOX_API_KEY API 密钥(必填)
MCN_RANKING_DIR 显式覆盖落盘目录(可选)
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime, date, timedelta
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
LIKES_API = "https://redfox.hk/story/api/dy/search/likesRank"
SURGE_API = "https://redfox.hk/story/api/dy/search/hotContentRank"
# 子目录(与 mcn-work-shop/dsh-data.js rankSubDir 对应)
SUB_DIR = {"hot": "视频热榜", "likes": "点赞榜"}
# 文件名前缀(与账号周榜 抖音周榜_{赛道}_{周一}.json 区分)
FILE_PREFIX = {"hot": "抖音周榜_视频热榜", "likes": "抖音周榜_点赞榜"}
# source:hot=likesRank 生产源;likes=hotContentRank 生产源(★无 -GitHub 后缀,2026-09-03 实测修正)
SOURCE = {"hot": "抖音每日热门作品榜-GitHub", "likes": "抖音每日点赞飙升榜"}
# 顶层 category 标记:全品类单文件(条目自带 category,展示端按条目赛道筛选)
CATEGORY_ALL = "全部"
def resolve_ranking_dir():
"""与 mcn-work-shop/dsh-data.js resolveRankingDir 同款:env → .dsh 部署 → 桌面 抖音榜单"""
env = os.environ.get("MCN_RANKING_DIR", "").strip()
if env:
return env
here = os.path.abspath(__file__)
if ".dsh" in here.replace("\\", "/"):
return r"D:\dshworkspace\抖音榜单"
home = os.path.expanduser("~")
rel = os.path.join("MCNSkill项目", "三方数据", "红狐数据", "抖音榜单")
candidates = [
os.path.join(home, "Desktop", rel),
os.path.join(home, "OneDrive", "Desktop", rel),
os.path.join(home, "OneDrive", "桌面", rel),
]
for p in candidates:
if os.path.isdir(p):
return p
return candidates[0]
RANKING_DIR = resolve_ranking_dir()
def get_api_key():
key = os.environ.get("REDFOX_API_KEY", "").strip()
if not key:
print("❌ 未设置 REDFOX_API_KEY 环境变量")
sys.exit(1)
return key
def call_api(url, body, retries=2):
api_key = get_api_key()
req = Request(url, data=json.dumps(body).encode("utf-8"), method="POST")
req.add_header("X-API-KEY", api_key)
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
last = None
for i in range(retries + 1):
try:
with urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
if data.get("code") == 2000:
return data.get("data", [])
print(f" ⚠ API code={data.get('code')} msg={data.get('msg', '')}")
return []
except HTTPError as e:
last = f"HTTP {e.code}"
if e.code in (429, 500, 502, 503):
time.sleep(2 * (i + 1))
continue
break
except URLError as e:
last = f"net {e.reason}"
time.sleep(2 * (i + 1))
except Exception as e:
last = str(e)[:120]
break
print(f" ❌ 调用失败: {last}")
return []
def as_list(data):
if isinstance(data, list):
return data
if isinstance(data, dict):
for k in ("list", "items", "dailyRank", "weeklyRank"):
v = data.get(k)
if isinstance(v, list):
return v
return []
def fetch_hot_week(monday, sunday):
"""视频热榜周窗口(全品类,不传 type):likesRank 周一~周日,服务端周聚合一次调用"""
body = {
"source": SOURCE["hot"],
"startTime": str(monday),
"endTime": str(sunday),
}
return as_list(call_api(LIKES_API, body))
def fetch_likes_week(sunday):
"""点赞榜周窗口(全品类,不传 type):hotContentRank 截至周日,服务端七日窗口一次调用"""
body = {
"source": SOURCE["likes"],
"startTime": str(sunday),
}
return as_list(call_api(SURGE_API, body))
def save_board_file(board, monday, sunday, items):
sub = os.path.join(RANKING_DIR, SUB_DIR[board])
fname = f"{FILE_PREFIX[board]}_{monday}.json"
fpath = os.path.join(sub, fname)
doc = {
"period": "week",
"board": "video_hot" if board == "hot" else "like_surge",
"date": str(monday), # 周标号(周一),与账号周榜一致,工作台按此切换
"dateStart": str(monday),
"dateEnd": str(sunday),
"category": CATEGORY_ALL, # 全品类:条目自带 category,展示端按条目赛道筛选
"total": len(items),
"fetched_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"list": [{**it, "rank": i + 1} for i, it in enumerate(items)],
}
os.makedirs(sub, exist_ok=True)
with open(fpath, "w", encoding="utf-8") as f:
json.dump(doc, f, ensure_ascii=False, indent=2)
return fpath, len(items)
def last_full_week(today=None):
"""上一个完整周的周一/周日"""
today = today or date.today()
this_monday = today - timedelta(days=today.weekday())
last_monday = this_monday - timedelta(days=7)
return last_monday, last_monday + timedelta(days=6)
def _file_valid(fpath):
"""文件存在且为合法非空榜单 JSON 才算命中(防中断/写坏残留被误判跳过)"""
try:
if os.path.getsize(fpath) < 200:
return False
with open(fpath, "r", encoding="utf-8") as f:
d = json.load(f)
return isinstance(d, dict) and isinstance(d.get("list"), list) and len(d["list"]) > 0
except Exception:
return False
def run_board(board, monday, sunday, force):
label = "视频热榜" if board == "hot" else "点赞榜"
sub = os.path.join(RANKING_DIR, SUB_DIR[board])
fname = f"{FILE_PREFIX[board]}_{monday}.json"
fpath = os.path.join(sub, fname)
if os.path.exists(fpath) and not force and _file_valid(fpath):
print(f"=== {label} 周榜(全品类)已存在跳过: {os.path.join(SUB_DIR[board], fname)} ===")
return
print(f"=== {label} 周榜(全品类,不传赛道 1 次调用,周 {monday} ~ {sunday})===")
items = fetch_hot_week(monday, sunday) if board == "hot" else fetch_likes_week(sunday)
if not items:
print(" ❌ 空数据(检查 source 是否为生产名 / 该周是否有数据)")
return
saved, n = save_board_file(board, monday, sunday, items)
cats = sorted({(it.get("category") or "?").strip() for it in items})
top = (items[0].get("title") or items[0].get("aweme_desc") or "-")[:20]
print(f" ✅ {n} 条 → {os.path.join(SUB_DIR[board], fname)} top1: {top}")
print(f" 赛道覆盖({len(cats)}): {'/'.join(cats)}")
def main():
ap = argparse.ArgumentParser(description="视频热榜/点赞榜 周粒度全品类抓取(不传赛道,一次调用周窗口,禁止逐赛道/逐日循环)")
ap.add_argument("--board", choices=["hot", "likes", "both"], default="both")
ap.add_argument("--date", help="周标号(周一 YYYY-MM-DD),默认上一个完整周")
ap.add_argument("--force", action="store_true", help="覆盖已存在文件")
args = ap.parse_args()
if args.date:
monday = datetime.strptime(args.date, "%Y-%m-%d").date()
sunday = monday + timedelta(days=6)
else:
monday, sunday = last_full_week()
print(f"落盘目录: {RANKING_DIR}({SUB_DIR['hot']}/{SUB_DIR['likes']} 子目录)")
print(f"周窗口: {monday} ~ {sunday}(文件周标号 {monday})")
if args.board in ("hot", "both"):
run_board("hot", monday, sunday, args.force)
if args.board in ("likes", "both"):
run_board("likes", monday, sunday, args.force)
print("\n全部完成 ✅(本次共 2 次 API 调用上限:hot 1 + likes 1)")
if __name__ == "__main__":
main()
@@ -50,6 +50,6 @@ description: 抖音每日点赞飙升榜查询工具。日度收录全平台抖
## 安全约束
- **API Key**:仅通过 `os.getenv("REDFOX_API_KEY")` 获取,禁止读取系统文件
- **文件操作**:对话回复仅输出 Markdown 表格(不暴露原始 JSON);**榜单数据落盘到业务数据目录**(白名单例外,详则见主 [SKILL.md](../../SKILL.md)「榜单数据落盘规范」)—— `抖音点赞榜_{赛道}_{数据日期}.json`,目标 `桌面\MCNSkill项目\三方数据\红狐数据\抖音榜单\`(①③环境)或 `D:\dshworkspace\抖音榜单\`(②dsh 环境)。**查询前先 `ls` 本地缓存命中免 API**(同榜名+赛道+数据日期全机器只调一次 API)
- **文件操作**:对话回复仅输出 Markdown 表格(不暴露原始 JSON);**榜单数据落盘到业务数据目录**(白名单例外,详则见主 [SKILL.md](../../SKILL.md)「榜单数据落盘规范」)——日粒度归档命名 `抖音点赞榜_{赛道}_{数据日期}.json`,目标 `桌面\MCNSkill项目\三方数据\红狐数据\抖音榜单\`(①③环境)或 `D:\dshworkspace\抖音榜单\`(②dsh 环境)。**查询前先 `ls` 本地缓存命中免 API**(同榜名+赛道+数据日期全机器只调一次 API)。工作台「点赞榜」周粒度文件(`抖音周榜_点赞榜_{赛道}_{周一}.json`)由主技能 `scripts/fetch_week_ranks.py` 统一落盘,本技能不做周粒度输出。
- **网络请求**:脚本使用 `urllib.request`(标准库),禁止调用外部脚本或 subprocess
- **输出内容**:仅输出 Markdown 表格格式榜单,不暴露原始 JSON
@@ -10,7 +10,7 @@ description: 抖音每日最热作品榜,日度收录全平台抖音作品,
1. **路径沙箱**:文件操作限定在技能根目录内
2. **API Key**:仅 `os.getenv("REDFOX_API_KEY")`,禁止 .env/dotenv/配置文件读取
3. **无系统命令**:不调用 subprocess/eval/exec/system()
4. **输出限定**:仅 stdout(Markdown 表格对话回复);**榜单数据同时落盘到业务数据目录**(白名单例外,详则见主 [SKILL.md](../../SKILL.md) 「榜单数据落盘规范」)——命名:`抖音视频热榜_{赛道}_{数据日期}.json`,目标 `桌面\MCNSkill项目\三方数据\红狐数据\抖音榜单\`(①③环境)或 `D:\dshworkspace\抖音榜单\`(②dsh 环境)。**查询前先 `ls` 本地缓存命中免 API**(同榜名+赛道+数据日期全机器只调一次 API)
4. **输出限定**:仅 stdout(Markdown 表格对话回复);**榜单数据同时落盘到业务数据目录**(白名单例外,详则见主 [SKILL.md](../../SKILL.md) 「榜单数据落盘规范」)——日粒度归档命名:`抖音视频热榜_{赛道}_{数据日期}.json`,目标 `桌面\MCNSkill项目\三方数据\红狐数据\抖音榜单\`(①③环境)或 `D:\dshworkspace\抖音榜单\`(②dsh 环境)。**查询前先 `ls` 本地缓存命中免 API**(同榜名+赛道+数据日期全机器只调一次 API)。工作台「视频热榜」周粒度文件(`抖音周榜_视频热榜_{赛道}_{周一}.json`)由主技能 `scripts/fetch_week_ranks.py` 统一落盘,本技能不做周粒度输出。
## 执行流程