热点三榜数据接入定稿:全品类单文件(不传赛道一次调用)+ 三子目录落盘
- 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:
1 parent
09dc431773
commit
faa0bd751f
10 files changed
+553
-116
No files matched your search
@@ -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(); } }; }
|
||||
}
|
||||
// 首页各榜列模板: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">${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>`,
|
||||
},
|
||||
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, '"'))}">${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, '"'))}">${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, '"'))}">${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, '"'))}">${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 {
|
||||
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 = '<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>
|
||||
<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-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>';
|
||||
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,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 = `<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>`;
|
||||
|
||||
// 标题列统一截断(作品标题很长):悬停 title 看全文
|
||||
const titleTd = (text, url) => `<span class="rk-tl" title="${esc(String(text || '').replace(/"/g, '"'))}">${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']);
|
||||
|
||||
// 三榜列模板: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>
|
||||
<td class="num score-cell">${fmt(r.comprehensiveScore)}</td>
|
||||
<td class="num">${fmt(r.followers)}</td>
|
||||
<td class="num">${fmt(r.newFans)}</td>
|
||||
<td class="num">${fmt(r.newLikes)}</td>
|
||||
<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>`,
|
||||
},
|
||||
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 === 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 = `<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() {
|
||||
if (state.tab !== 'week') {
|
||||
// 其余榜单与首页一致:建设中占位(无本地数据文件,数据获取技能落盘后即可接入)
|
||||
view.innerHTML = `<div class="page">${toolbar(false)}${tabs()}<div class="rank-placeholder">「${esc(tabLabel())}」建设中,敬请期待</div></div>`;
|
||||
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) => `<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>
|
||||
<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>
|
||||
<td class="num score-cell">${fmt(r.comprehensiveScore)}</td>
|
||||
<td class="num">${fmt(r.followers)}</td>
|
||||
<td class="num">${fmt(r.newFans)}</td>
|
||||
<td class="num">${fmt(r.newLikes)}</td>
|
||||
<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('');
|
||||
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); }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user