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

- 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
+556 -119

No files matched your search

+14
View File
@@ -141,3 +141,17 @@
- 改动 2 文件(纯静态,无需重启服务):`mcn-work-shop/public/app.js` 首页 rank-tabs 删 fans 按钮行(L329)+ 注释「3 个新榜」→「2 个」;`data-pages.js` RANK_TABS 删 `['fans','涨粉榜'],` + L507 注释去涨粉榜
- 首页 tab 点击 handler 为通用逻辑(week=列表/其余占位)无 fans 特判,删按钮即自动收敛;cdp-verify-*.mjs 里的 `fans` 是**周榜表格粉丝列**(rk-num nth-child(5))非涨粉榜 tab,保留未动;docs/AI会话任务输入输出对照.md 仅提入口未列 tab 无需改
- 验证:node --check 两文件通过 + 服务 8900 伺服 js `grep -c 涨粉榜`=0;CDP 9224/9223 无调试浏览器未做视觉验证
- 已 push 源仓库 0cd2c45..09dc431(fetch 无分叉,跟踪引用正常未丢失);dsh 仓库无工作台副本无需提交
## 热点数据三榜接入:视频热榜/点赞榜 周粒度真实数据(09-03 傍晚)
- **任务链**:①写周粒度抓取器 `mcn-data-insight/scripts/fetch_week_ranks.py`(fetch_week_ranks.py,--board hot|likes|both + --date 周标号 + --force;服务端周窗口一次调用=likesRank 传周一~周日 / hotContentRank 传周日单参官方七日口径,**禁止逐日累加**)②落盘 2026-08-24 完整周:hot 28 赛道 1396 条 + likes 28 赛道 1400 条 → 桌面 `三方数据\红狐数据\抖音榜单\抖音周榜_{视频热榜|点赞榜}_{赛道}_{周一}.json` ③后端 board-aware:dsh-data.js parseRankFile/listRankFiles/rankingMeta(board)/getRanking(board)(DEF_KEY 随榜 week=comprehensiveScore/hot=likeCount/likes=add_digg_count;作品榜条目不查账号表 inDb 恒 false)+ server.js `/api/dsh/ranking` 透传 board(默认 week 兼容)
- **前端改造(本段)**:`data-pages.js renderRanking` 重写为 board 驱动三榜渲染——每榜独立 state(日期/赛道/排序/翻页切 tab 保留)、COLS 列模板(week=账号表 10 列含关注功能列;hot=排名/作品标题(链 workUrl)/作者/赛道/点赞/评论/分享/收藏/发布时间;likes=同构但 add_digg/add_collect/add_comment/add_share + create_time_str)、SORT_NUM 排序字段集合、defKey 回显默认降序箭头、meta 每榜独立缓存;`app.js` 首页 rank-preview 同步三榜——_PREVIEW_COLS 三模板 + setRankBoard 切榜重拉 meta、rk-video grid(8 列)CSS;style.css 加 .rk-tl 标题截断 + .table.table-rank.rank-video 列宽规则
- **验证**:node --check 三文件通过;node 直调 getRanking 三榜真实数据(hot 1396/likes 1400/week 250、美食筛选=50 条单赛道);重启 8900 服务(旧 36532 → 新 29172,cdp 冒烟 9224 无调试浏览器);curl 静态资源 200;内联渲染模板喂真实条目无异常
- **文档同步**:mcn-data-insight 主 SKILL.md 落盘规范表「2 视频热榜/3 点赞榜」❌预留→✅已实现 + 补 fetch_week_ranks.py 说明段;douyin-daily-hot/douyin-content-surge SKILL.md 补「日粒度归档走自身命名,周粒度由 fetch_week_ranks.py 落盘」边界;dsh 副本 mcn-data-insight cp 同步 4 文件(主 SKILL.md + fetch_week_ranks.py + 2 子技能 SKILL.md)
## ★热点数据三榜修正:全品类单文件+三子目录(09-03 晚间,用户纠正全赛道误读)
- **用户纠正(重要)**:「全赛道获取所有数据」= **不传 type 一次调用拿全品类**,非按 28 赛道循环;批评「整这种浪费钱的操作前先问问」。教训三层固化:①抓取器 docstring + SKILL.md 成本铁律「多赛道/多日/多窗口付费调用前必须先向用户确认次数与范围」②本日志 ③失误复盘见上
- **API 实测结论(固化防再探测)**:likesRank 无 type=50 条全品类周榜(source 抖音每日热门作品榜-GitHub,周一~周日);hotContentRank **source 必须「抖音每日点赞飙升榜」生产名**(写「抖音七日点赞飙升榜-GitHub」返回空),无 type=50 条(startTime=周日七日口径);**单次上限 TOP50**(limit=100/page=2/offset=50 全被忽略)
- **新落盘契约**:`抖音榜单/` 三子目录 `账号周榜/`(抖音周榜_{赛道}_{周一}×多期,douyin-top-account 落盘)、`视频热榜/`(抖音周榜_视频热榜_{周一}.json 全品类单文件/期)、`点赞榜/`(抖音周榜_点赞榜_{周一}.json);旧 28 赛道 56 文件归档 `_archive_旧28赛道结构_20260903/`;顶层 category="全部",赛道在 list[].category
- **后端改造**:dsh-data.js RANK_SUB 子目录映射 + rankFilePath(dir,name) + parseRankFileIn/listRankFiles(子目录优先+根目录旧平铺回退)/collectCategories(week=文件名赛道;hot/likes=条目 category 并集)/getRanking 赛道过滤(week 文件名级、hot/likes 条目级);工作台实际读取桌面目录(源技能 __dirname 无 .dsh → 桌面 71→15+2 文件;D:/dshworkspace/抖音榜单 为空是 dsh 部署形态)
- **验证**:node --check ✓;node 直调 week 250/3期/5赛道 + hot 50/12赛道 + likes 50/14赛道;筛亲子 hot=2/likes=3;服务重启(29172→3912)curl 三榜 API 全通;前端赛道下拉动态(meta.categories)无需改动
@@ -326,16 +326,72 @@ function listRecentScripts(limit = 4) {
`).all(n);
}
/* ================= 周榜 ================= */
/* ================= 周榜(三榜族:week 账号周榜 / hot 视频热榜 / likes 点赞榜) ================= */
function rankingMeta() {
// ============ 榜单目录结构(09-03 分目录管理)============
// 抖音榜单/
// 账号周榜/抖音周榜_{赛道}_{周一}.json → week(每赛道一期一文件,douyin-top-account 任务落盘)
// 视频热榜/抖音周榜_视频热榜_{周一}.json → hot(全品类单文件,fetch_week_ranks.py 落盘,不传赛道一次调用)
// 点赞榜/ 抖音周榜_点赞榜_{周一}.json → likes(全品类单文件,同上)
// 根目录旧平铺文件仍兼容读取(任务旧产物)。
// 全品类单文件(hot/likes)无赛道段:category 顶层标记「全部」,实际赛道在 list[].category,
// 赛道下拉 = 各期文件条目 category 并集,赛道筛选按条目 category 匹配。
const RANK_SUB = { week: '账号周榜', hot: '视频热榜', likes: '点赞榜' };
const RANK_FILE_RE = {
week: /^抖音周榜_(.+)_(\d{4}-\d{2}-\d{2})\.json$/,
hot: /^抖音周榜_视频热榜_(\d{4}-\d{2}-\d{2})\.json$/,
likes: /^抖音周榜_点赞榜_(\d{4}-\d{2}-\d{2})\.json$/,
};
function rankFilePath(dir, name) {
return dir ? path.join(RANKING_DIR, dir, name) : path.join(RANKING_DIR, name);
}
function parseRankFileIn(board, dir, name) {
const m = String(name).match(RANK_FILE_RE[board]);
if (!m) return null;
if (board === 'week') return { board, dir, name, category: m[1], date: m[2] };
return { board, dir, name, category: '全部', date: m[1] };
}
function listRankFiles(board) {
if (!fs.existsSync(RANKING_DIR)) return [];
const out = [];
// ① 子目录(新结构,主路径)
const subDir = path.join(RANKING_DIR, RANK_SUB[board]);
if (fs.existsSync(subDir)) {
for (const f of fs.readdirSync(subDir)) {
const p = parseRankFileIn(board, RANK_SUB[board], f);
if (p) out.push(p);
}
}
// ② 根目录(旧平铺兼容)
for (const f of fs.readdirSync(RANKING_DIR)) {
const p = parseRankFileIn(board, '', f);
if (p) out.push(p);
}
return out.sort((a, b) => b.date.localeCompare(a.date));
}
// week:文件名赛道;hot/likes:条目 category 并集(「全部」文件读 list 去重)
function collectCategories(board, files) {
if (board === 'week') return [...new Set(files.map((f) => f.category))];
const set = new Set();
for (const pf of files) {
try {
const d = JSON.parse(fs.readFileSync(rankFilePath(pf.dir, pf.name), 'utf8'));
for (const it of (d.list || [])) { const c = String(it.category || '').trim(); if (c) set.add(c); }
} catch (e) {}
}
return [...set].sort();
}
function rankingMeta(board = 'week') {
const empty = { dates: [], categories: [] };
if (!fs.existsSync(RANKING_DIR)) return empty;
const files = fs.readdirSync(RANKING_DIR).filter((f) => /^抖音周榜_.+_\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().reverse();
const files = listRankFiles(board);
if (!files.length) return empty;
const dates = [...new Set(files.map((f) => f.match(/_(\d{4}-\d{2}-\d{2})\.json$/)[1]))];
const categories = [...new Set(files.map((f) => { const m = f.match(/^抖音周榜_(.+)_\d{4}-\d{2}-\d{2}\.json$/); return m ? m[1] : null; }).filter(Boolean))];
return { dates, categories };
const dates = [...new Set(files.map((f) => f.date))];
return { dates, categories: collectCategories(board, files) };
}
function parseNum(v) {
@@ -347,31 +403,36 @@ function parseNum(v) {
return n;
}
function getRanking({ date = '', category = '', sortBy = 'rank', order = '', page = 1, pageSize = 20 } = {}) {
function getRanking({ board = 'week', date = '', category = '', sortBy = '', order = '', page = 1, pageSize = 20 } = {}) {
const empty = { dates: [], categories: [], category: '', date: '', total: 0, items: [] };
if (!fs.existsSync(RANKING_DIR)) return empty;
const files = fs.readdirSync(RANKING_DIR).filter((f) => /^抖音周榜_.+_\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().reverse();
const files = listRankFiles(board);
if (!files.length) return empty;
const dates = [...new Set(files.map((f) => f.match(/_(\d{4}-\d{2}-\d{2})\.json$/)[1]))];
const categories = [...new Set(files.map((f) => { const m = f.match(/^抖音周榜_(.+)_\d{4}-\d{2}-\d{2}\.json$/); return m ? m[1] : null; }).filter(Boolean))];
const dates = [...new Set(files.map((f) => f.date))];
const categories = collectCategories(board, files);
const selDate = date || dates[0];
const selFiles = files.filter((f) => f.includes(selDate) && (!category || f.includes('_' + category + '_')));
// week:按文件名赛道过滤(每赛道一文件);hot/likes 全品类单文件:赛道过滤在条目层
const selFiles = files.filter((f) => f.date === selDate && (board !== 'week' || !category || f.category === category));
let items = [];
for (const f of selFiles) {
try { items = items.concat(JSON.parse(fs.readFileSync(path.join(RANKING_DIR, f), 'utf8')).list || []); } catch (e) {}
for (const pf of selFiles) {
try { items = items.concat(JSON.parse(fs.readFileSync(rankFilePath(pf.dir, pf.name), 'utf8')).list || []); } catch (e) {}
}
// 默认排序:综合得分倒序,同分按排名升序(用户点表头排序时走下方 sortBy/order 逻辑)
if (!order && (!sortBy || sortBy === 'rank')) {
if (board !== 'week' && category && category !== '全部') {
items = items.filter((it) => String(it.category || '').trim() === category);
}
// 默认排序键随榜:week=综合得分 / hot=点赞 / likes=新增点赞(同分按排名升序)
const DEF_KEY = board === 'week' ? 'comprehensiveScore' : board === 'hot' ? 'likeCount' : 'add_digg_count';
if (!order && (!sortBy || sortBy === DEF_KEY)) {
items.sort((a, b) => {
const scA = num(a.comprehensiveScore), scB = num(b.comprehensiveScore);
if (scA !== scB) return scB - scA;
const va = num(a[DEF_KEY]), vb = num(b[DEF_KEY]);
if (va !== vb) return vb - va;
return num(a.rank) - num(b.rank);
});
} else {
const orderDir = order === 'asc' ? 'asc' : (order === 'desc' ? 'desc' : (sortBy === 'rank' ? 'asc' : 'desc'));
const key = sortBy || DEF_KEY;
const orderDir = order === 'asc' ? 'asc' : (order === 'desc' ? 'desc' : (key === 'rank' ? 'asc' : 'desc'));
items.sort((a, b) => {
const va = sortBy === 'rank' ? num(a.rank) : sortBy === 'comprehensiveScore' ? num(a.comprehensiveScore) : parseNum(a[sortBy]);
const vb = sortBy === 'rank' ? num(b.rank) : sortBy === 'comprehensiveScore' ? num(b.comprehensiveScore) : parseNum(b[sortBy]);
const va = key === 'rank' ? num(a.rank) : parseNum(a[key]);
const vb = key === 'rank' ? num(b.rank) : parseNum(b[key]);
return orderDir === 'asc' ? va - vb : vb - va;
});
}
@@ -381,12 +442,13 @@ function getRanking({ date = '', category = '', sortBy = 'rank', order = '', pag
const d = getDb();
const secOf = (url) => { const m = String(url || '').match(/\/user\/([^/?#]+)/); return m ? m[1] : ''; };
const pageItems = items.slice(offset, offset + limit).map((row) => {
// 账号维度(week,含 profileUrl)才做库内命中关联;作品维度不关联账号表
const sec = row.profileUrl ? secOf(row.profileUrl) : '';
let hit = null;
if (d) {
const sec = secOf(row.profileUrl);
hit = sec
? d.prepare(`SELECT id FROM hot_accounts WHERE sec_uid=? OR account_name=? LIMIT 1`).get(sec, row.accountName)
: d.prepare(`SELECT id FROM hot_accounts WHERE account_name=? LIMIT 1`).get(row.accountName);
if (d && sec) {
hit = d.prepare(`SELECT id FROM hot_accounts WHERE sec_uid=? OR account_name=? LIMIT 1`).get(sec, row.accountName);
} else if (d && row.accountName && !row.profileUrl && row.workUrl) {
hit = null; // 作品榜条目不查账号表
}
return { ...row, inDb: !!hit, dbId: hit ? hit.id : null };
});
@@ -442,9 +442,12 @@ async function loadRecentScripts() {
} catch (e) { el.innerHTML = '<span class="dim">' + esc(e.message) + '</span>'; }
}
/* ================= 首页 账号周榜 预览(前10 + 赛道/日期筛选,无分页) ================= */
let _rankPreview = { page: 1, pageSize: 10, dates: [], categories: [], date: '', category: '' };
/* ================= 首页 榜单预览(账号周榜/视频热榜/点赞榜 三榜;前10 + 赛道/日期筛选,无分页/排序) ================= */
let _rankPreview = { board: 'week', pageSize: 10, dates: [], categories: [], date: '', category: '', _metaBoard: '' };
function renderRankTools() {
const has = _rankPreview.dates.length > 0;
const tools = $('#rankTools');
if (tools) tools.hidden = !has;
const catSel = $('#rankCat');
if (catSel) {
catSel.innerHTML = '<option value="">全部赛道</option>' + _rankPreview.categories.map((c) => `<option value="${esc(c)}" ${c === _rankPreview.category ? 'selected' : ''}>${esc(c)}</option>`).join('');
@@ -457,37 +460,97 @@ function renderRankTools() {
if (prevBtn) { prevBtn.disabled = di <= 0; prevBtn.onclick = () => { if (di > 0) { _rankPreview.date = _rankPreview.dates[di - 1]; renderRankTools(); loadRankingPreview(); } }; }
if (nextBtn) { nextBtn.disabled = di < 0 || di >= _rankPreview.dates.length - 1; nextBtn.onclick = () => { if (di >= 0 && di < _rankPreview.dates.length - 1) { _rankPreview.date = _rankPreview.dates[di + 1]; renderRankTools(); loadRankingPreview(); } }; }
}
async function loadRankingPreview() {
const listEl = $('#rankPreviewList');
if (!listEl) return;
try {
if (!_rankPreview.dates.length) {
const m = await api('/api/dsh/ranking?page=1&pageSize=1');
_rankPreview.dates = m.dates || [];
_rankPreview.categories = m.categories || [];
_rankPreview.date = m.date || (_rankPreview.dates[0] || '');
_rankPreview.category = m.category || '';
renderRankTools();
}
const d = await api('/api/dsh/ranking?page=1&pageSize=' + _rankPreview.pageSize + '&date=' + encodeURIComponent(_rankPreview.date) + '&category=' + encodeURIComponent(_rankPreview.category));
const items = d.items || [];
const head = '<div class="rank-row rank-head"><span class="rk-no">排名</span><span class="rk-cat">赛道</span><span class="rk-name">账号</span><span class="rk-score">综合得分</span><span class="rk-num">新增粉丝</span><span class="rk-num">新增点赞</span><span class="rk-num">新增评论</span><span class="rk-num">新增分享</span><span class="rk-act">功能</span></div>';
listEl.innerHTML = items.length ? head + items.map((r) => `<div class="rank-row">
<span class="rk-no">${r.rank ?? '-'}</span>
// 首页各榜列模板:head → 表头行;row(r) → 数据行。wk-num 数值(万显示);标题/作者吃剩余宽
const _PREVIEW_COLS = {
week: {
gridCls: '',
head: `<span class="rk-no">排名</span><span class="rk-cat">赛道</span><span class="rk-name">账号</span><span class="rk-score">综合得分</span><span class="rk-num">新增粉丝</span><span class="rk-num">新增点赞</span><span class="rk-num">新增评论</span><span class="rk-num">新增分享</span><span class="rk-act">功能</span>`,
row: (r) => `<span class="rk-no">${r.rank ?? '-'}</span>
<span class="rk-cat">${esc(r.category || '-')}</span>
<span class="rk-name">${r.inDb && r.dbId ? `<a class="link" href="#/account/${r.dbId}">${esc(r.accountName)}</a>` : (r.profileUrl ? `<a class="link" data-ext="${esc(r.profileUrl)}">${esc(r.accountName)}</a>` : esc(r.accountName))}</span>
<span class="rk-score">${r.comprehensiveScore ?? '-'}</span>
<span class="rk-score">${fmt(r.comprehensiveScore)}</span>
<span class="rk-num">${esc(r.newFans ?? '-')}</span>
<span class="rk-num">${esc(r.newLikes ?? '-')}</span>
<span class="rk-num">${esc(r.newComments ?? '-')}</span>
<span class="rk-num">${esc(r.newShares ?? '-')}</span>
<span class="rk-act">${r.inDb && r.dbId ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-rk-import data-rk-name="${esc(r.accountName)}" data-rk-url="${esc(r.profileUrl || '')}" title="关注该账号并加入账号列表">关注</button>`}</span>
</div>`).join('') : '<span class="dim" style="padding:12px">暂无周榜数据</span>';
<span class="rk-act">${r.inDb && r.dbId ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-rk-import data-rk-name="${esc(r.accountName)}" data-rk-url="${esc(r.profileUrl || '')}" title="关注该账号并加入账号列表">关注</button>`}</span>`,
},
hot: {
// likesRank 周窗口作品榜
gridCls: ' rk-video',
head: `<span class="rk-no">排名</span><span class="rk-cat">赛道</span><span class="rk-name">作品标题</span><span class="rk-author">作者</span><span class="rk-num">点赞</span><span class="rk-num">评论</span><span class="rk-num">分享</span><span class="rk-num">收藏</span>`,
row: (r) => `<span class="rk-no">${r.rank ?? '-'}</span>
<span class="rk-cat">${esc(r.category || '-')}</span>
<span class="rk-name" title="${esc(String(r.title || '').replace(/"/g, '&quot;'))}">${r.workUrl ? `<a class="title-plain" data-ext="${esc(r.workUrl)}" title="打开作品页">${esc(r.title || '-')}</a>` : esc(r.title || '-')}</span>
<span class="rk-author" title="${esc(String(r.accountName || '').replace(/"/g, '&quot;'))}">${esc(r.accountName || '-')}</span>
<span class="rk-num">${fmtWan(r.likeCount)}</span>
<span class="rk-num">${fmtWan(r.commentCount)}</span>
<span class="rk-num">${fmtWan(r.shareCount)}</span>
<span class="rk-num">${fmtWan(r.collectCount)}</span>`,
},
likes: {
// hotContentRank 七日点赞飙升
gridCls: ' rk-video',
head: `<span class="rk-no">排名</span><span class="rk-cat">赛道</span><span class="rk-name">作品标题</span><span class="rk-author">作者</span><span class="rk-num">新增点赞</span><span class="rk-num">新增收藏</span><span class="rk-num">新增评论</span><span class="rk-num">新增分享</span>`,
row: (r) => `<span class="rk-no">${r.rank ?? '-'}</span>
<span class="rk-cat">${esc(r.category || '-')}</span>
<span class="rk-name" title="${esc(String(r.aweme_desc || '').replace(/"/g, '&quot;'))}">${r.share_url ? `<a class="title-plain" data-ext="${esc(r.share_url)}" title="打开作品页">${esc(r.aweme_desc || '-')}</a>` : esc(r.aweme_desc || '-')}</span>
<span class="rk-author" title="${esc(String(r.user_nickname || '').replace(/"/g, '&quot;'))}">${esc(r.user_nickname || '-')}</span>
<span class="rk-num">${fmtWan(r.add_digg_count)}</span>
<span class="rk-num">${fmtWan(r.add_collect_count)}</span>
<span class="rk-num">${fmtWan(r.add_comment_count)}</span>
<span class="rk-num">${fmtWan(r.add_share_count)}</span>`,
},
};
async function loadRankingPreview() {
const listEl = $('#rankPreviewList');
if (!listEl) return;
const ph = $('#rankPlaceholder');
try {
const board = _rankPreview.board;
// meta 按榜独立拉取(board 变化或首次进入)
if (_rankPreview._metaBoard !== board) {
const m = await api('/api/dsh/ranking?board=' + board + '&page=1&pageSize=1');
_rankPreview.dates = m.dates || [];
_rankPreview.categories = m.categories || [];
_rankPreview.date = m.date || (_rankPreview.dates[0] || '');
_rankPreview.category = m.category || '';
_rankPreview._metaBoard = board;
renderRankTools();
}
if (!_rankPreview.dates.length) {
listEl.hidden = true;
if (ph) { ph.hidden = false; ph.textContent = '「' + ({ week: '账号周榜', hot: '视频热榜', likes: '点赞榜' }[board] || '') + '」暂无本地数据文件,先运行 mcn-data-insight 抓取技能落盘'; }
return;
}
if (ph) ph.hidden = true;
listEl.hidden = false;
const d = await api('/api/dsh/ranking?board=' + board + '&page=1&pageSize=' + _rankPreview.pageSize + '&date=' + encodeURIComponent(_rankPreview.date) + '&category=' + encodeURIComponent(_rankPreview.category));
const items = d.items || [];
const col = _PREVIEW_COLS[board];
listEl.innerHTML = items.length ? `<div class="rank-row rank-head${col.gridCls}">${col.head}</div>` + items.map((r) => `<div class="rank-row${col.gridCls}">${col.row(r)}</div>`).join('') : '<span class="dim" style="padding:12px">暂无周榜数据</span>';
listEl.querySelectorAll('[data-rk-import]').forEach((b) => b.addEventListener('click', () => {
confirmSaveAccount(b.dataset.rkName, b.dataset.rkUrl || '', b);
}));
} catch (e) { listEl.innerHTML = '<span class="dim" style="padding:12px">' + esc(e.message) + '</span>'; }
}
function setRankBoard(board, activeEl) {
if (_rankPreview.board === board && activeEl) return;
_rankPreview.board = board;
// meta 缓存按 board 失效 → 强制重拉该榜 dates/categories
_rankPreview._metaBoard = '';
_rankPreview.date = ''; _rankPreview.category = '';
if (activeEl) {
const root = activeEl.closest('.rank-tabs');
if (root) root.querySelectorAll('.rank-tab').forEach((x) => x.classList.toggle('active', x === activeEl));
}
const listEl = $('#rankPreviewList');
if (listEl) { listEl.hidden = true; listEl.innerHTML = '<span class="dim" style="padding:12px">加载中…</span>'; }
const ph = $('#rankPlaceholder');
if (ph) ph.hidden = true;
renderRankTools();
loadRankingPreview();
}
function openRecentScript(s) {
const title = cleanTitle(s.title || s.video_title) || 'AI 写剧本';
const mask = document.createElement('div'); mask.className = 'modal-mask';
@@ -661,15 +724,9 @@ async function router() {
updateCrumbs('');
view.innerHTML = renderHome();
view.querySelectorAll('.data-card').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.hash; }));
// 周榜 tab 切换:账号周榜显示列表+工具条,其余 2 个新榜先占位
// 榜单 tab 切换:week/hot/likes 各自按 board 拉数据渲染(meta 与数据均带 board 参数)
view.querySelectorAll('.rank-tab').forEach((t) => t.addEventListener('click', () => {
view.querySelectorAll('.rank-tab').forEach((x) => x.classList.toggle('active', x === t));
const week = t.dataset.rankTab === 'week';
view.querySelector('#rankTools').hidden = !week;
view.querySelector('#rankPreviewList').hidden = !week;
const ph = view.querySelector('#rankPlaceholder');
ph.hidden = week;
if (!week) ph.textContent = `「${t.textContent.trim()}」建设中,敬请期待`;
setRankBoard(t.dataset.rankTab, t);
}));
loadHomeStats();
loadRankingPreview();
@@ -501,64 +501,52 @@ async function confirmAiScript(accountName, btn) {
inputEl.focus();
}
/* ================= 周榜 ================= */
/* ================= 热点数据(#/ranking:账号周榜为主,多榜 tab 与首页 rank-preview 一致) ================= */
/* ================= 热点数据(#/ranking:账号周榜 / 视频热榜 / 点赞榜 三榜,board 参数驱动,与首页 rank-preview 同构) ================= */
async function renderRanking(view) {
// tab 与首页 rank-tabs 一致:账号周榜有本地数据文件;视频热榜/点赞榜暂占位(后端 RANKING_DIR 目前仅周榜 json)
// 数据源:RANKING_DIR 下周榜族 JSON(fetch_week_ranks.py 落盘)——账号周榜 抖音周榜_{赛道}_{周一}.json,
// 视频热榜 抖音周榜_视频热榜_{赛道}_{周一}.json,点赞榜 抖音周榜_点赞榜_{赛道}_{周一}.json;后端按 board 识别
const RANK_TABS = [
['week', '账号周榜'],
['hot', '视频热榜'],
['likes', '点赞榜'],
];
let state = { tab: 'week', date: '', category: '', sortBy: 'comprehensiveScore', order: 'desc', page: 1, pageSize: 20, dates: [], categories: [] };
const meta = await api('/api/dsh/ranking?pageSize=20').catch((e) => ({ ok: false, error: e.message }));
if (!meta.ok) {
view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}<span class="page-title">热点数据</span></div><div class="empty">${esc(meta.error || '加载失败')}</div></div>`;
bindBack(view); return;
}
state.dates = meta.dates || []; state.categories = meta.categories || []; state.date = meta.date || (state.dates[0] || ''); state.category = meta.category || '';
const tabLabel = () => { const t = RANK_TABS.find(([k]) => k === state.tab); return t ? t[1] : ''; };
const mkState = () => ({ date: '', category: '', sortBy: '', order: '', page: 1, pageSize: 20, dates: [], categories: [] });
// 每榜独立状态(切 tab 保留各自筛选/翻页/日期)
const st = { week: mkState(), hot: mkState(), likes: mkState() };
let tab = 'week';
const tabLabel = () => { const t = RANK_TABS.find(([k]) => k === tab); return t ? t[1] : ''; };
const tabs = () => `
<div class="rank-tabs" role="tablist">
${RANK_TABS.map(([k, label]) => `<button class="rank-tab${k === state.tab ? ' active' : ''}" data-rank-tab="${k}" role="tab">${label}</button>`).join('')}
${RANK_TABS.map(([k, label]) => `<button class="rank-tab${k === tab ? ' active' : ''}" data-rank-tab="${k}" role="tab">${label}</button>`).join('')}
</div>`;
const toolbar = (withFilters) => `
const toolbar = (s, withFilters) => `
<div class="page-toolbar">
${toolbarBack()}
<span class="page-title">热点数据</span>
${withFilters ? `<div class="toolbar-right">
<select id="rankCat" class="input-select" title="赛道筛选">
<option value="">全部赛道</option>
${state.categories.map((c) => `<option value="${esc(c)}" ${c === state.category ? 'selected' : ''}>${esc(c)}</option>`).join('')}
${s.categories.map((c) => `<option value="${esc(c)}" ${c === s.category ? 'selected' : ''}>${esc(c)}</option>`).join('')}
</select>
<button class="btn" id="rankPrev" title="上一期">◀</button>
<span class="pager-info" title="选择日期">${esc(state.date)}</span>
<span class="pager-info" title="选择日期">${esc(s.date)}</span>
<button class="btn" id="rankNext" title="下一期">▶</button>
</div>` : ''}
</div>`;
function bindShell() {
bindBack(view);
view.querySelectorAll('.rank-tab').forEach((t) => t.addEventListener('click', () => {
const k = t.dataset.rankTab;
if (k === state.tab) return;
state.tab = k; state.page = 1; render();
}));
}
// 标题列统一截断(作品标题很长):悬停 title 看全文
const titleTd = (text, url) => `<span class="rk-tl" title="${esc(String(text || '').replace(/"/g, '&quot;'))}">${url ? `<a class="link" data-ext="${esc(url)}">${esc(text)}</a>` : esc(text)}</span>`;
// 数值列(过万转万显示,右对齐)
const numTd = (v) => `<td class="num">${fmtWan(v)}</td>`;
// 可排序数值字段(表头窄列宽 + 点击排序)——非数值列不参与排序
const SORT_NUM = new Set(['rank', 'comprehensiveScore', 'followers', 'newFans', 'newLikes', 'newComments', 'newShares', 'likeCount', 'commentCount', 'shareCount', 'collectCount', 'add_digg_count', 'add_collect_count', 'add_comment_count', 'add_share_count']);
function render() {
if (state.tab !== 'week') {
// 其余榜单与首页一致:建设中占位(无本地数据文件,数据获取技能落盘后即可接入)
view.innerHTML = `<div class="page">${toolbar(false)}${tabs()}<div class="rank-placeholder">「${esc(tabLabel())}」建设中,敬请期待</div></div>`;
bindShell(); return;
}
(async () => {
const d = await api('/api/dsh/ranking?date=' + encodeURIComponent(state.date) + '&category=' + encodeURIComponent(state.category) + '&sortBy=' + state.sortBy + '&order=' + state.order + '&page=' + state.page + '&pageSize=' + state.pageSize);
const di = state.dates.indexOf(state.date);
const WIDE = new Set(['comprehensiveScore', 'followers', 'newFans', 'newLikes', 'newComments', 'newShares']); // 数值列(窄+右对齐)
const sortable = (key, label) => `<th class="sortable${WIDE.has(key) ? ' th-wide' : ''}" data-sort="${key}">${label}${state.sortBy === key ? (state.order === 'asc' ? ' ↑' : ' ↓') : ''}</th>`;
const rows = (d.items || []).map((r) => `<tr>
// 三榜列模板:head(s, sortable) → 表头;row(r) → 行单元格
const COLS = {
week: {
span: 10, empty: '暂无周榜数据', defKey: 'comprehensiveScore',
head: (s, srt) => `${srt('rank', '排名', true)}<th>赛道</th><th>账号</th>${srt('comprehensiveScore', '综合得分')}${srt('followers', '粉丝')}${srt('newFans', '新增粉丝')}${srt('newLikes', '新增点赞')}${srt('newComments', '新增评论')}${srt('newShares', '新增分享')}<th class="th-act">功能</th>`,
row: (r) => `<tr>
<td>${r.rank ?? '-'}</td>
<td>${esc(r.category || '-')}</td>
<td>${r.profileUrl ? `<a class="link" data-ext="${esc(r.profileUrl)}" title="在新标签打开账号主页">${esc(r.accountName)}</a>` : esc(r.accountName)}</td>
@@ -569,33 +557,100 @@ async function renderRanking(view) {
<td class="num">${fmt(r.newComments)}</td>
<td class="num">${fmt(r.newShares)}</td>
<td class="cell-act">${r.inDb ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-import='${esc(JSON.stringify(r))}'>关注</button>`}</td>
</tr>`).join('');
</tr>`,
},
hot: {
// likesRank 周窗口作品榜:标题/作者/赛道/点赞/评论/分享/收藏/发布时间
span: 9, empty: '暂无视频热榜数据', defKey: 'likeCount',
head: (s, srt) => `${srt('rank', '排名', true)}<th>作品标题</th><th>作者</th><th>赛道</th>${srt('likeCount', '点赞')}${srt('commentCount', '评论')}${srt('shareCount', '分享')}${srt('collectCount', '收藏')}<th>发布时间</th>`,
row: (r) => `<tr>
<td>${r.rank ?? '-'}</td>
<td class="cell-tl">${titleTd(r.title || '-', r.workUrl)}</td>
<td>${esc(r.accountName || '-')}</td>
<td>${esc(r.category || '-')}</td>
${numTd(r.likeCount)}${numTd(r.commentCount)}${numTd(r.shareCount)}${numTd(r.collectCount)}
<td class="num">${fmtTime(r.publishTime)}</td>
</tr>`,
},
likes: {
// hotContentRank 七日点赞飙升:作品/作者/赛道/新增点赞/收藏/评论/分享/发布时间
span: 9, empty: '暂无点赞榜数据', defKey: 'add_digg_count',
head: (s, srt) => `${srt('rank', '排名', true)}<th>作品标题</th><th>作者</th><th>赛道</th>${srt('add_digg_count', '新增点赞')}${srt('add_collect_count', '新增收藏')}${srt('add_comment_count', '新增评论')}${srt('add_share_count', '新增分享')}<th>发布时间</th>`,
row: (r) => `<tr>
<td>${r.rank ?? '-'}</td>
<td class="cell-tl">${titleTd(r.aweme_desc || '-', r.share_url)}</td>
<td>${esc(r.user_nickname || '-')}</td>
<td>${esc(r.category || '-')}</td>
${numTd(r.add_digg_count)}${numTd(r.add_collect_count)}${numTd(r.add_comment_count)}${numTd(r.add_share_count)}
<td class="num">${fmtTime(r.create_time_str)}</td>
</tr>`,
},
};
function bindShell() {
bindBack(view);
view.querySelectorAll('.rank-tab').forEach((t) => t.addEventListener('click', () => {
const k = t.dataset.rankTab;
if (k === tab) return;
tab = k; render();
}));
}
// meta 每榜独立拉取并缓存到 st:dates/categories 因榜而异(hot/likes 全赛道28;week 仅抓取的 5 赛道)
const metas = {};
async function loadMeta() {
if (metas[tab]) return metas[tab];
const m = await api('/api/dsh/ranking?board=' + tab + '&pageSize=20').catch((e) => ({ ok: false, error: e.message }));
if (!m.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}<span class="page-title">热点数据</span></div><div class="empty">${esc(m.error || '加载失败')}</div></div>`; bindBack(view); return null; }
metas[tab] = m;
const s = st[tab];
s.dates = m.dates || []; s.categories = m.categories || [];
if (!s.date) { s.date = m.date || (s.dates[0] || ''); s.category = m.category || ''; }
return m;
}
function render() {
const s = st[tab];
const col = COLS[tab];
const apiUrl = '/api/dsh/ranking?board=' + tab + '&date=' + encodeURIComponent(s.date) + '&category=' + encodeURIComponent(s.category) + '&sortBy=' + encodeURIComponent(s.sortBy) + '&order=' + s.order + '&page=' + s.page + '&pageSize=' + s.pageSize;
// 非 week 榜若本 tab 从未拉过 meta → 先拉(含首次进入),期间显示 loading
(async () => {
const m = await loadMeta();
if (!m) return;
if (!s.dates.length) {
view.innerHTML = `<div class="page">${toolbar(s, false)}${tabs()}<div class="empty">「${esc(tabLabel())}」暂无本地数据文件,先运行 mcn-data-insight 抓取技能落盘</div></div>`;
bindShell(); return;
}
const d = await api(apiUrl).catch((e) => ({ ok: false, error: e.message }));
if (!d.ok) { view.innerHTML = `<div class="page">${toolbar(s, true)}${tabs()}<div class="empty">${esc(d.error || '加载失败')}</div></div>`; bindShell(); return; }
const di = s.dates.indexOf(s.date);
const curKey = s.sortBy || col.defKey;
const srt = (key, label) => `<th class="sortable${SORT_NUM.has(key) ? ' th-wide' : ''}" data-sort="${key}">${label}${curKey === key ? (s.order === 'asc' ? ' ↑' : ' ↓') : ''}</th>`;
const rows = (d.items || []).map((r) => col.row(r)).join('');
view.innerHTML = `
<div class="page">
${toolbar(true)}${tabs()}
${toolbar(s, true)}${tabs()}
<div class="table-wrap">
<table class="table table-rank">
<thead><tr>
${sortable('rank', '排名')}<th>赛道</th><th>账号</th>${sortable('comprehensiveScore', '综合得分')}${sortable('followers', '粉丝')}${sortable('newFans', '新增粉丝')}${sortable('newLikes', '新增点赞')}${sortable('newComments', '新增评论')}${sortable('newShares', '新增分享')}<th class="th-act">功能</th>
</tr></thead>
<tbody>${rows || '<tr><td colspan="10" class="empty">暂无周榜数据</td></tr>'}</tbody>
<table class="table table-rank${tab === 'week' ? '' : ' rank-video'}">
<thead><tr>${col.head(s, srt)}</tr></thead>
<tbody>${rows || `<tr><td colspan="${col.span}" class="empty">${col.empty}</td></tr>`}</tbody>
</table>
</div>
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; render(); })}
${pagerHtml(d.total, d.page, s.pageSize, (p) => { s.page = p; render(); })}
</div>`;
bindShell();
view.querySelectorAll('.sortable').forEach((th) => th.addEventListener('click', () => {
const k = th.dataset.sort;
if (state.sortBy === k) state.order = state.order === 'asc' ? 'desc' : 'asc';
else { state.sortBy = k; state.order = k === 'rank' ? 'asc' : 'desc'; }
state.page = 1; render();
if (s.sortBy === k) s.order = s.order === 'asc' ? 'desc' : 'asc';
else { s.sortBy = k; s.order = k === 'rank' ? 'asc' : 'desc'; }
s.page = 1; render();
}));
const rankCat = view.querySelector('#rankCat');
if (rankCat) rankCat.addEventListener('change', (e) => { state.category = e.target.value; state.page = 1; render(); });
if (rankCat) rankCat.addEventListener('change', (e) => { s.category = e.target.value; s.page = 1; render(); });
const prev = view.querySelector('#rankPrev'), next = view.querySelector('#rankNext');
if (prev) { prev.disabled = di <= 0; prev.addEventListener('click', () => { const i = state.dates.indexOf(state.date) - 1; if (i >= 0) { state.date = state.dates[i]; state.page = 1; render(); } }); }
if (next) { next.disabled = di < 0 || di >= state.dates.length - 1; next.addEventListener('click', () => { const i = state.dates.indexOf(state.date) + 1; if (i < state.dates.length) { state.date = state.dates[i]; state.page = 1; render(); } }); }
bindPager(view, (p) => { state.page = p; render(); });
if (prev) { prev.disabled = di <= 0; prev.addEventListener('click', () => { const i = s.dates.indexOf(s.date) - 1; if (i >= 0) { s.date = s.dates[i]; s.page = 1; render(); } }); }
if (next) { next.disabled = di < 0 || di >= s.dates.length - 1; next.addEventListener('click', () => { const i = s.dates.indexOf(s.date) + 1; if (i < s.dates.length) { s.date = s.dates[i]; s.page = 1; render(); } }); }
bindPager(view, (p) => { s.page = p; render(); });
view.querySelectorAll('[data-import]').forEach((b) => b.addEventListener('click', () => {
const r = JSON.parse(b.dataset.import);
confirmSaveAccount(r.accountName, r.profileUrl || '', b);
@@ -454,6 +454,14 @@ main#view { height: calc(100vh - 55px); overflow: auto; } /* 55px=topbar 实际
.table.table-rank th:nth-child(1) { width: 40px; } /* 排名 */
.table.table-rank th:nth-child(2) { width: 90px; } /* 赛道 */
.table.table-rank th:nth-child(10) { width: 85px; } /* 功能 */
/* 视频热榜/点赞榜(rank-video):第2列=作品标题(长标题吃宽),第3作者/第4赛道/第9发布时间 */
.table.table-rank.rank-video th:nth-child(1) { width: 44px; }
.table.table-rank.rank-video th:nth-child(2) { width: 30%; } /* 作品标题:占比列宽,截断省略 */
.table.table-rank.rank-video th:nth-child(3) { width: 120px; } /* 作者 */
.table.table-rank.rank-video th:nth-child(4) { width: 88px; } /* 赛道 */
.table.table-rank.rank-video th:nth-child(9) { width: 140px; } /* 发布时间 */
.table.table-rank.rank-video td.cell-tl { max-width: 0; } /* 标题单元格允许被压缩(配合占位列宽省略) */
.table.table-rank.rank-video .rk-tl { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link { color: #4a90e2; cursor: pointer; text-decoration: none; }
.link:hover { text-decoration: underline; }
.link.strong { font-weight: 600; }
@@ -585,6 +593,9 @@ td.td-title { max-width: 240px; }
.rank-row { display: grid; grid-template-columns: 34px 84px minmax(80px, 1fr) 76px repeat(4, 76px) 72px; align-items: center; gap: 6px; padding: 6px 10px; font-size: 14px; border-bottom: 1px solid var(--border); }
.rank-row:last-child { border-bottom: none; }
.rank-head { background: var(--panel); color: var(--dim); font-weight: 600; font-size: 13px; }
/* 首页 视频热榜/点赞榜 预览(8 列:排名/赛道/作品标题/作者/4 数值)——标题与作者吃剩余宽 */
.rank-row.rk-video { grid-template-columns: 34px 76px minmax(140px, 1fr) minmax(80px, 1.1fr) repeat(4, 74px); }
.rank-row.rk-video .rk-author { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dim); }
.rk-no { color: var(--dim); font-weight: 600; }
.rk-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rk-cat { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -522,7 +522,7 @@ const server = http.createServer((req, res) => {
}
if (p === '/api/dsh/ranking') {
try {
const r = dsh.getRanking({ date: String(q.get('date') || ''), category: String(q.get('category') || ''), sortBy: String(q.get('sortBy') || 'rank'), order: String(q.get('order') || ''), page: qInt('page', 1), pageSize: qInt('pageSize', 20) });
const r = dsh.getRanking({ board: String(q.get('board') || 'week'), date: String(q.get('date') || ''), category: String(q.get('category') || ''), sortBy: String(q.get('sortBy') || ''), order: String(q.get('order') || ''), page: qInt('page', 1), pageSize: qInt('pageSize', 20) });
return dshOk(200, r);
} catch (e) { return sendErr(500, e.message); }
}
@@ -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` 统一落盘,本技能不做周粒度输出。
## 执行流程