diff --git a/.workbuddy/memory/2026-09-03.md b/.workbuddy/memory/2026-09-03.md index 240f879..fdd5a6a 100644 --- a/.workbuddy/memory/2026-09-03.md +++ b/.workbuddy/memory/2026-09-03.md @@ -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)无需改动 diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js b/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js index fb1aec1..83e913d 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js @@ -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 }; }); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js b/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js index e96b1fd..c3482ee 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js @@ -442,9 +442,12 @@ async function loadRecentScripts() { } catch (e) { el.innerHTML = '' + esc(e.message) + ''; } } -/* ================= 首页 账号周榜 预览(前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 = '' + _rankPreview.categories.map((c) => ``).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(); } }; } } +// 首页各榜列模板:head → 表头行;row(r) → 数据行。wk-num 数值(万显示);标题/作者吃剩余宽 +const _PREVIEW_COLS = { + week: { + gridCls: '', + head: `排名赛道账号综合得分新增粉丝新增点赞新增评论新增分享功能`, + row: (r) => `${r.rank ?? '-'} + ${esc(r.category || '-')} + ${r.inDb && r.dbId ? `${esc(r.accountName)}` : (r.profileUrl ? `${esc(r.accountName)}` : esc(r.accountName))} + ${fmt(r.comprehensiveScore)} + ${esc(r.newFans ?? '-')} + ${esc(r.newLikes ?? '-')} + ${esc(r.newComments ?? '-')} + ${esc(r.newShares ?? '-')} + ${r.inDb && r.dbId ? `查看` : ``}`, + }, + hot: { + // likesRank 周窗口作品榜 + gridCls: ' rk-video', + head: `排名赛道作品标题作者点赞评论分享收藏`, + row: (r) => `${r.rank ?? '-'} + ${esc(r.category || '-')} + ${r.workUrl ? `${esc(r.title || '-')}` : esc(r.title || '-')} + ${esc(r.accountName || '-')} + ${fmtWan(r.likeCount)} + ${fmtWan(r.commentCount)} + ${fmtWan(r.shareCount)} + ${fmtWan(r.collectCount)}`, + }, + likes: { + // hotContentRank 七日点赞飙升 + gridCls: ' rk-video', + head: `排名赛道作品标题作者新增点赞新增收藏新增评论新增分享`, + row: (r) => `${r.rank ?? '-'} + ${esc(r.category || '-')} + ${r.share_url ? `${esc(r.aweme_desc || '-')}` : esc(r.aweme_desc || '-')} + ${esc(r.user_nickname || '-')} + ${fmtWan(r.add_digg_count)} + ${fmtWan(r.add_collect_count)} + ${fmtWan(r.add_comment_count)} + ${fmtWan(r.add_share_count)}`, + }, +}; async function loadRankingPreview() { const listEl = $('#rankPreviewList'); if (!listEl) return; + const ph = $('#rankPlaceholder'); try { - if (!_rankPreview.dates.length) { - const m = await api('/api/dsh/ranking?page=1&pageSize=1'); + 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(); } - const d = await api('/api/dsh/ranking?page=1&pageSize=' + _rankPreview.pageSize + '&date=' + encodeURIComponent(_rankPreview.date) + '&category=' + encodeURIComponent(_rankPreview.category)); + 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 head = '
排名赛道账号综合得分新增粉丝新增点赞新增评论新增分享功能
'; - listEl.innerHTML = items.length ? head + items.map((r) => `
- ${r.rank ?? '-'} - ${esc(r.category || '-')} - ${r.inDb && r.dbId ? `${esc(r.accountName)}` : (r.profileUrl ? `${esc(r.accountName)}` : esc(r.accountName))} - ${r.comprehensiveScore ?? '-'} - ${esc(r.newFans ?? '-')} - ${esc(r.newLikes ?? '-')} - ${esc(r.newComments ?? '-')} - ${esc(r.newShares ?? '-')} - ${r.inDb && r.dbId ? `查看` : ``} -
`).join('') : '暂无周榜数据'; + const col = _PREVIEW_COLS[board]; + listEl.innerHTML = items.length ? `
${col.head}
` + items.map((r) => `
${col.row(r)}
`).join('') : '暂无周榜数据'; listEl.querySelectorAll('[data-rk-import]').forEach((b) => b.addEventListener('click', () => { confirmSaveAccount(b.dataset.rkName, b.dataset.rkUrl || '', b); })); } catch (e) { listEl.innerHTML = '' + esc(e.message) + ''; } } +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 = '加载中…'; } + 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(); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js index 8f14118..9ee2563 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js @@ -501,101 +501,156 @@ 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 = `
${toolbarBack()}热点数据
${esc(meta.error || '加载失败')}
`; - 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 = () => `
- ${RANK_TABS.map(([k, label]) => ``).join('')} + ${RANK_TABS.map(([k, label]) => ``).join('')}
`; - const toolbar = (withFilters) => ` + const toolbar = (s, withFilters) => `
${toolbarBack()} 热点数据 ${withFilters ? `
- ${esc(state.date)} + ${esc(s.date)}
` : ''}
`; + // 标题列统一截断(作品标题很长):悬停 title 看全文 + const titleTd = (text, url) => `${url ? `${esc(text)}` : esc(text)}`; + // 数值列(过万转万显示,右对齐) + const numTd = (v) => `${fmtWan(v)}`; + // 可排序数值字段(表头窄列宽 + 点击排序)——非数值列不参与排序 + 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']); + + // 三榜列模板:head(s, sortable) → 表头;row(r) → 行单元格 + const COLS = { + week: { + span: 10, empty: '暂无周榜数据', defKey: 'comprehensiveScore', + head: (s, srt) => `${srt('rank', '排名', true)}赛道账号${srt('comprehensiveScore', '综合得分')}${srt('followers', '粉丝')}${srt('newFans', '新增粉丝')}${srt('newLikes', '新增点赞')}${srt('newComments', '新增评论')}${srt('newShares', '新增分享')}功能`, + row: (r) => ` + ${r.rank ?? '-'} + ${esc(r.category || '-')} + ${r.profileUrl ? `${esc(r.accountName)}` : esc(r.accountName)} + ${fmt(r.comprehensiveScore)} + ${fmt(r.followers)} + ${fmt(r.newFans)} + ${fmt(r.newLikes)} + ${fmt(r.newComments)} + ${fmt(r.newShares)} + ${r.inDb ? `查看` : ``} + `, + }, + hot: { + // likesRank 周窗口作品榜:标题/作者/赛道/点赞/评论/分享/收藏/发布时间 + span: 9, empty: '暂无视频热榜数据', defKey: 'likeCount', + head: (s, srt) => `${srt('rank', '排名', true)}作品标题作者赛道${srt('likeCount', '点赞')}${srt('commentCount', '评论')}${srt('shareCount', '分享')}${srt('collectCount', '收藏')}发布时间`, + row: (r) => ` + ${r.rank ?? '-'} + ${titleTd(r.title || '-', r.workUrl)} + ${esc(r.accountName || '-')} + ${esc(r.category || '-')} + ${numTd(r.likeCount)}${numTd(r.commentCount)}${numTd(r.shareCount)}${numTd(r.collectCount)} + ${fmtTime(r.publishTime)} + `, + }, + likes: { + // hotContentRank 七日点赞飙升:作品/作者/赛道/新增点赞/收藏/评论/分享/发布时间 + span: 9, empty: '暂无点赞榜数据', defKey: 'add_digg_count', + head: (s, srt) => `${srt('rank', '排名', true)}作品标题作者赛道${srt('add_digg_count', '新增点赞')}${srt('add_collect_count', '新增收藏')}${srt('add_comment_count', '新增评论')}${srt('add_share_count', '新增分享')}发布时间`, + row: (r) => ` + ${r.rank ?? '-'} + ${titleTd(r.aweme_desc || '-', r.share_url)} + ${esc(r.user_nickname || '-')} + ${esc(r.category || '-')} + ${numTd(r.add_digg_count)}${numTd(r.add_collect_count)}${numTd(r.add_comment_count)}${numTd(r.add_share_count)} + ${fmtTime(r.create_time_str)} + `, + }, + }; + 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(); + 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 = `
${toolbarBack()}热点数据
${esc(m.error || '加载失败')}
`; 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() { - if (state.tab !== 'week') { - // 其余榜单与首页一致:建设中占位(无本地数据文件,数据获取技能落盘后即可接入) - view.innerHTML = `
${toolbar(false)}${tabs()}
「${esc(tabLabel())}」建设中,敬请期待
`; - bindShell(); return; - } + 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 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) => `${label}${state.sortBy === key ? (state.order === 'asc' ? ' ↑' : ' ↓') : ''}`; - const rows = (d.items || []).map((r) => ` - ${r.rank ?? '-'} - ${esc(r.category || '-')} - ${r.profileUrl ? `${esc(r.accountName)}` : esc(r.accountName)} - ${fmt(r.comprehensiveScore)} - ${fmt(r.followers)} - ${fmt(r.newFans)} - ${fmt(r.newLikes)} - ${fmt(r.newComments)} - ${fmt(r.newShares)} - ${r.inDb ? `查看` : ``} - `).join(''); + const m = await loadMeta(); + if (!m) return; + if (!s.dates.length) { + view.innerHTML = `
${toolbar(s, false)}${tabs()}
「${esc(tabLabel())}」暂无本地数据文件,先运行 mcn-data-insight 抓取技能落盘
`; + bindShell(); return; + } + const d = await api(apiUrl).catch((e) => ({ ok: false, error: e.message })); + if (!d.ok) { view.innerHTML = `
${toolbar(s, true)}${tabs()}
${esc(d.error || '加载失败')}
`; bindShell(); return; } + const di = s.dates.indexOf(s.date); + const curKey = s.sortBy || col.defKey; + const srt = (key, label) => `${label}${curKey === key ? (s.order === 'asc' ? ' ↑' : ' ↓') : ''}`; + const rows = (d.items || []).map((r) => col.row(r)).join(''); view.innerHTML = `
- ${toolbar(true)}${tabs()} + ${toolbar(s, true)}${tabs()}
- - - ${sortable('rank', '排名')}${sortable('comprehensiveScore', '综合得分')}${sortable('followers', '粉丝')}${sortable('newFans', '新增粉丝')}${sortable('newLikes', '新增点赞')}${sortable('newComments', '新增评论')}${sortable('newShares', '新增分享')} - - ${rows || ''} +
赛道账号功能
暂无周榜数据
+ ${col.head(s, srt)} + ${rows || ``}
${col.empty}
- ${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; render(); })} + ${pagerHtml(d.total, d.page, s.pageSize, (p) => { s.page = p; render(); })}
`; 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); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css b/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css index 451fd54..08b55e6 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css @@ -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; } diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/server.js b/project/短视频脚本创作/V1.0/mcn-work-shop/server.js index 5b459cd..4f64656 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/server.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/server.js @@ -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); } } diff --git a/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/SKILL.md b/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/SKILL.md index ef8e4db..c129df9 100644 --- a/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/SKILL.md +++ b/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/SKILL.md @@ -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 钱) diff --git a/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/scripts/fetch_week_ranks.py b/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/scripts/fetch_week_ranks.py new file mode 100644 index 0000000..ff48743 --- /dev/null +++ b/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/scripts/fetch_week_ranks.py @@ -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() diff --git a/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/subskills/douyin-content-surge/SKILL.md b/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/subskills/douyin-content-surge/SKILL.md index 8357721..db20843 100644 --- a/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/subskills/douyin-content-surge/SKILL.md +++ b/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/subskills/douyin-content-surge/SKILL.md @@ -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 diff --git a/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/subskills/douyin-daily-hot/SKILL.md b/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/subskills/douyin-daily-hot/SKILL.md index 07e659c..d94c5e8 100644 --- a/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/subskills/douyin-daily-hot/SKILL.md +++ b/project/短视频脚本创作/V1.0/subskills/mcn-data-insight/subskills/douyin-daily-hot/SKILL.md @@ -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` 统一落盘,本技能不做周粒度输出。 ## 执行流程