Files
mcn-short-video/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js
T

573 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MCN热点雷达 - 数据访问层(只读复用业务数据目录)
// 数据源:工作台目录 mcn-plugin.db(dsh 库副本,node:sqlite 只读)+ 三方数据目录 抖音榜单/*.json
// 与 dsh 原库(~/.dsh/mcn-plugin.db)隔离:两边各访问各的,互不影响
// 页面逻辑参照 dsh-plugin-mcn 重新开发,仅复用数据库 schema 与榜单文件格式
'use strict';
const { DatabaseSync } = require('node:sqlite');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
// 工作台自有数据库副本(与 dsh 原库隔离)
const DSH_DB = path.join(__dirname, 'mcn-plugin.db');
// 榜单目录解析(09-03 定稿:跟随 mcn-data-insight 榜单落盘规范的数据源分层)
// 优先级:① 环境变量 MCN_RANKING_DIR 显式覆盖 → ② dsh 部署(技能在 ~/.dsh 下)→ D:\dshworkspace\抖音榜单
// → ③ 开发机/用户环境(①③):桌面 MCNSkill项目\三方数据\红狐数据\抖音榜单(运行时解析真实桌面,兼容 OneDrive 重定向)
function resolveRankingDir() {
if (process.env.MCN_RANKING_DIR) return process.env.MCN_RANKING_DIR;
const isDshDeploy = __dirname.includes('.dsh');
if (isDshDeploy) return 'D:\\dshworkspace\\抖音榜单';
const home = os.homedir();
const rel = path.join('MCNSkill项目', '三方数据', '红狐数据', '抖音榜单');
const candidates = [
path.join(home, 'Desktop', rel),
path.join(home, 'OneDrive', 'Desktop', rel),
path.join(home, 'OneDrive', '桌面', rel),
];
return candidates.find((p) => fs.existsSync(p)) || candidates[0];
}
const RANKING_DIR = resolveRankingDir();
let _db = null;
let _dbTried = false;
function getDb() {
if (_db) return _db;
if (_dbTried) return null;
_dbTried = true;
try {
if (!fs.existsSync(DSH_DB)) return null;
_db = new DatabaseSync(DSH_DB, { readOnly: true });
} catch (e) { _db = null; }
return _db;
}
function dbAvailable() { return getDb() !== null; }
// 收藏切换:可写连接单独打开(与只读 getDb 隔离),操作完即关闭
function setFavorite(accountId, fav) {
if (!fs.existsSync(DSH_DB)) throw new Error('数据库不可用');
const w = new DatabaseSync(DSH_DB);
try {
const r = w.prepare(`UPDATE hot_accounts SET favorite=? WHERE id=?`).run(fav ? 1 : 0, Number(accountId));
if (r.changes === 0) throw new Error('账号不存在');
} finally {
w.close();
}
}
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
const fmt = (v) => (v == null || v === '') ? null : v;
// 批量删除:kind=accounts(软删账号+连坐清理)/ videos | rewrites(删视频行+关联)
// ids: 主键 id 数组;返回 { deleted: 实际删除条数 }
// 连坐范围:账号 → persona/analysis/videos(含 source/analysis/rewrite_log);视频 → source/analysis/rewrite_log
// 注意 source/analysis 存在 video_id=null 的行(俊希等历史数据),须同时按 aweme_id 双键删
function batchDelete(kind, ids) {
if (!fs.existsSync(DSH_DB)) throw new Error('数据库不可用');
const clean = (Array.isArray(ids) ? ids : []).map(Number).filter((n) => Number.isFinite(n) && n > 0);
if (clean.length === 0) return { deleted: 0 };
const q = clean.map(() => '?').join(',');
const w = new DatabaseSync(DSH_DB);
try {
if (kind === 'accounts') {
w.prepare(`DELETE FROM account_persona WHERE account_id IN (${q})`).run(...clean);
w.prepare(`DELETE FROM account_analysis WHERE account_id IN (${q})`).run(...clean);
deleteVideosDeep(w, w.prepare(`SELECT id, aweme_id FROM account_videos WHERE account_id IN (${q})`).all(...clean));
w.prepare(`DELETE FROM account_videos WHERE account_id IN (${q})`).run(...clean);
w.prepare(`DELETE FROM rewrite_log WHERE account_id IN (${q})`).run(...clean);
const r = w.prepare(`UPDATE hot_accounts SET del_flag=1 WHERE id IN (${q}) AND del_flag=0`).run(...clean);
return { deleted: Number(r.changes) };
}
// videos | rewrites:删视频行 + 关联解析/脚本(按 video_id 与 aweme_id 双键)
deleteVideosDeep(w, w.prepare(`SELECT id, aweme_id FROM account_videos WHERE id IN (${q})`).all(...clean));
const r = w.prepare(`DELETE FROM account_videos WHERE id IN (${q})`).run(...clean);
return { deleted: Number(r.changes) };
} finally {
w.close();
}
}
function deleteVideosDeep(w, vids) {
if (!vids.length) return;
const ids = vids.map((v) => v.id);
const awemes = vids.map((v) => v.aweme_id).filter(Boolean);
const qi = ids.map(() => '?').join(',');
w.prepare(`DELETE FROM account_video_source WHERE video_id IN (${qi})`).run(...ids);
w.prepare(`DELETE FROM account_video_analysis WHERE video_id IN (${qi})`).run(...ids);
w.prepare(`DELETE FROM rewrite_log WHERE video_id IN (${qi})`).run(...ids);
if (awemes.length) {
const qa = awemes.map(() => '?').join(',');
w.prepare(`DELETE FROM account_video_source WHERE aweme_id IN (${qa})`).run(...awemes);
w.prepare(`DELETE FROM account_video_analysis WHERE aweme_id IN (${qa})`).run(...awemes);
w.prepare(`DELETE FROM rewrite_log WHERE aweme_id IN (${qa})`).run(...awemes);
}
}
/* ================= 账号 ================= */
function listTracks() {
const d = getDb(); if (!d) return [];
const rows = d.prepare(`SELECT DISTINCT track FROM hot_accounts WHERE track IS NOT NULL AND track != '' AND del_flag=0 ORDER BY track`).all();
return rows.map((r) => r.track);
}
function listAccounts({ page = 1, pageSize = 20, search = '', track = '', type = 'all' } = {}) {
const d = getDb();
if (!d) return { total: 0, page: 1, pageSize, items: [] };
const where = ['del_flag=0'];
const params = [];
if (search) {
where.push('(account_name LIKE ? OR content LIKE ? OR track LIKE ?)');
const like = `%${search}%`; params.push(like, like, like);
}
if (track) { where.push('track = ?'); params.push(track); }
if (type === 'signed' || type === 'external') { where.push('account_type = ?'); params.push(type); }
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
const total = d.prepare(`SELECT COUNT(*) c FROM hot_accounts ${whereSql}`).get(...params).c;
const limit = Math.max(1, Math.min(Number(pageSize) || 20, 100));
const offset = (Math.max(1, Number(page) || 1) - 1) * limit;
const followersExpr = `CASE WHEN h.followers LIKE '%亿%' THEN CAST(REPLACE(REPLACE(h.followers,'亿',''),'万','') AS REAL) * 100000000 WHEN h.followers LIKE '%万%' THEN CAST(REPLACE(h.followers,'万','') AS REAL) * 10000 ELSE CAST(h.followers AS REAL) END`;
const orderBySql = `h.favorite DESC, ${followersExpr} DESC, h.id ASC`;
const items = d.prepare(`SELECT h.*, (SELECT COUNT(*) FROM account_videos v WHERE v.account_id = h.id) AS video_count,
(SELECT COUNT(*) FROM rewrite_log r WHERE r.account_id = h.id AND r.status='done' AND r.script_text IS NOT NULL AND r.script_text <> '') AS rewrite_count
FROM hot_accounts h ${whereSql} ORDER BY ${orderBySql} LIMIT ? OFFSET ?`).all(...params, limit, offset);
return { total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
}
function listAccountVideos(accountId, page = 1, pageSize = 20) {
const d = getDb(); if (!d) throw new Error('数据库不可用');
const acc = d.prepare(`SELECT * FROM hot_accounts WHERE id=?`).get(accountId);
if (!acc) throw new Error('账号不存在');
const total = d.prepare(`SELECT COUNT(*) c FROM account_videos WHERE account_id=?`).get(accountId).c;
const limit = Math.max(1, Math.min(Number(pageSize) || 20, 100));
const offset = (Math.max(1, Number(page) || 1) - 1) * limit;
const items = d.prepare(`
SELECT v.*,
(SELECT COUNT(*) FROM account_video_source s WHERE s.video_id=v.id OR s.aweme_id=v.aweme_id) AS has_source,
(SELECT COUNT(*) FROM account_video_analysis a WHERE a.video_id=v.id OR a.aweme_id=v.aweme_id) AS has_analysis
FROM account_videos v WHERE v.account_id=? ORDER BY v.publish_time IS NULL, v.publish_time DESC, v.id DESC LIMIT ? OFFSET ?
`).all(accountId, limit, offset);
return { account: acc, total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
}
function getPersona(accountId) {
const d = getDb(); if (!d) return null;
return d.prepare(`SELECT content_json AS content, analysis_time FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1`).get(accountId) || null;
}
function getAccountAnalysis(accountId) {
const d = getDb(); if (!d) return null;
return d.prepare(`SELECT content_json AS content, analysis_time FROM account_analysis WHERE account_id=? ORDER BY id DESC LIMIT 1`).get(accountId) || null;
}
/* ================= 视频 ================= */
function listVideos({ page = 1, pageSize = 20, search = '', parsed = '', days = '' } = {}) {
const d = getDb();
if (!d) return { total: 0, page: 1, pageSize, items: [] };
// 09-01 用户要求:分页展示全部视频(不再限制每账号 30W+/1W+ 取 10 条)
const where = [];
const params = [];
if (search) { where.push('(v.video_title LIKE ? OR h.account_name LIKE ?)'); params.push(`%${search}%`, `%${search}%`); }
if (parsed === '1' || parsed === '0') {
const cond = `(v.id IN (SELECT video_id FROM account_video_source) OR v.id IN (SELECT video_id FROM account_video_analysis))`;
where.push(parsed === '1' ? cond : `NOT (${cond})`);
}
// 发布时间筛选:days>0 时取近 N 天(默认近一个月由前端传 30;localtime 避免 UTC 边界差一天)
const n = Number(days);
if (n > 0) { where.push(`v.publish_time >= date('now', 'localtime', ?)`); params.push(`-${n} days`); }
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
const total = d.prepare(`SELECT COUNT(*) c FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.account_id ${w}`).get(...params).c;
const limit = Math.max(1, Math.min(Number(pageSize) || 20, 100));
const offset = (Math.max(1, Number(page) || 1) - 1) * limit;
const items = d.prepare(`
SELECT v.*, h.account_name,
(SELECT COUNT(*) FROM account_video_source s WHERE s.video_id=v.id OR s.aweme_id=v.aweme_id) AS has_source,
(SELECT COUNT(*) FROM account_video_analysis a WHERE a.video_id=v.id OR a.aweme_id=v.aweme_id) AS has_analysis,
(SELECT COUNT(*) FROM rewrite_log r WHERE r.video_id=v.id AND r.status='done' AND r.script_text IS NOT NULL AND r.script_text <> '') AS rewrite_count
FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.account_id
${w} ORDER BY h.favorite DESC, v.publish_time DESC, v.id DESC LIMIT ? OFFSET ?
`).all(...params, limit, offset);
return { total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
}
function getVideoDetail({ videoId, awemeId }) {
const d = getDb(); if (!d) throw new Error('数据库不可用');
let video = null;
if (videoId) {
video = d.prepare(`SELECT v.*, h.account_name FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.account_id WHERE v.id=?`).get(videoId);
} else if (awemeId) {
video = d.prepare(`SELECT v.*, h.account_name FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.account_id WHERE v.aweme_id=?`).get(String(awemeId));
}
if (!video) throw new Error('视频不存在');
const analysis = d.prepare(`SELECT content_json AS content, summary, analysis_time FROM account_video_analysis WHERE aweme_id=? OR video_id=? ORDER BY id DESC LIMIT 1`).get(String(video.aweme_id), video.id) || null;
// MCP 解析内容:account_video_source.content_json(content.json 全文)+ analysis_json(analysis.json 全文)
let parse = { found: false, content: null, analysis: null };
const src = d.prepare(`SELECT content_json, analysis_json FROM account_video_source WHERE video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?) ORDER BY id DESC LIMIT 1`).get(video.id, String(video.aweme_id));
if (src && src.content_json) {
try {
const jd = JSON.parse(src.content_json);
parse.content = typeof jd.content === 'string' ? jd.content : null;
parse.found = true;
if (src.analysis_json) {
try { parse.analysis = JSON.parse(src.analysis_json); } catch (e) {}
}
} catch (e) { parse.content = src.content_json; parse.found = true; }
}
return { video, analysis, parse };
}
function getVideoScript(videoId) {
const d = getDb(); if (!d) throw new Error('数据库不可用');
let source = null;
const src = d.prepare(`SELECT content_json FROM account_video_source WHERE video_id=? OR aweme_id=(SELECT aweme_id FROM account_videos WHERE id=?) ORDER BY id DESC LIMIT 1`).get(videoId, videoId);
if (src && src.content_json) {
try {
const j = JSON.parse(src.content_json);
const c = String(j.content || j.script || src.content_json);
const idx = c.indexOf('# 视频脚本');
source = idx >= 0 ? c.slice(idx) : c;
} catch (e) { source = src.content_json; }
}
const rewrites = d.prepare(`SELECT id, script_text, created_time FROM rewrite_log WHERE video_id=? AND script_text IS NOT NULL AND script_text <> '' ORDER BY id DESC`).all(videoId);
return { source, rewrites };
}
/* ================= 改写 / 分镜 / 复盘 ================= */
function listRewrites(videoId) {
const d = getDb(); if (!d) return { items: [] };
const rows = videoId > 0
? d.prepare(`SELECT id, video_id, status, title, created_time, updated_time, length(script_text) AS script_len FROM rewrite_log WHERE video_id=? ORDER BY id DESC`).all(videoId)
: d.prepare(`SELECT id, video_id, status, title, created_time, updated_time, length(script_text) AS script_len FROM rewrite_log ORDER BY id DESC LIMIT 200`).all();
const revCols = `id, type, rewrite_id, video_id, dims_json, total, verdict, status, created_time, updated_time`;
const aiRev = new Map(), cmpRev = new Map(), srcRev = new Map();
const ids = rows.map((r) => r.id);
const vidIds = [...new Set(rows.map((r) => r.video_id).filter((v) => v > 0))];
if (ids.length) {
for (const rv of d.prepare(`SELECT ${revCols} FROM script_review WHERE type IN ('ai','compare') AND rewrite_id IN (${ids.map(() => '?').join(',')}) ORDER BY id DESC`).all(...ids)) {
if (rv.type === 'ai') { if (!aiRev.has(rv.rewrite_id)) aiRev.set(rv.rewrite_id, rv); }
else if (!cmpRev.has(rv.rewrite_id)) cmpRev.set(rv.rewrite_id, rv);
}
}
if (vidIds.length) {
for (const rv of d.prepare(`SELECT ${revCols} FROM script_review WHERE type='source' AND video_id IN (${vidIds.map(() => '?').join(',')}) ORDER BY id DESC`).all(...vidIds)) {
if (!srcRev.has(rv.video_id)) srcRev.set(rv.video_id, rv);
}
}
const runningKeys = new Set();
for (const rr of d.prepare(`SELECT type, rewrite_id, video_id, status FROM script_review WHERE status='running'`).all()) {
runningKeys.add(rr.type === 'source' ? 'source:' + rr.video_id : rr.type + ':' + rr.rewrite_id);
}
for (const it of rows) {
it.review_ai = aiRev.get(it.id) || null;
it.review_compare = cmpRev.get(it.id) || null;
it.review_source = (it.video_id && srcRev.get(it.video_id)) || null;
it.review_running = runningKeys.has('ai:' + it.id) || runningKeys.has('compare:' + it.id) || (it.video_id && runningKeys.has('source:' + it.video_id));
}
return { items: rows };
}
function listStoryboards(videoId) {
const d = getDb(); if (!d) return [];
return d.prepare(`SELECT id, video_id, rewrite_id, script_text, status, created_time, updated_time FROM storyboard_log WHERE video_id=? ORDER BY id DESC`).all(videoId);
}
function getReviewDetail(id) {
const d = getDb(); if (!d) return null;
return d.prepare(`SELECT * FROM script_review WHERE id=?`).get(id) || null;
}
function getReviewFull(rewriteId) {
const d = getDb(); if (!d) return null;
const rw = d.prepare(`SELECT video_id FROM rewrite_log WHERE id=?`).get(rewriteId);
const videoId = rw ? rw.video_id : 0;
const source = videoId ? d.prepare(`SELECT * FROM script_review WHERE type='source' AND video_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(videoId) : null;
const ai = d.prepare(`SELECT * FROM script_review WHERE type='ai' AND rewrite_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(rewriteId);
const compare = d.prepare(`SELECT * FROM script_review WHERE type='compare' AND rewrite_id=? AND status='done' ORDER BY id DESC LIMIT 1`).get(rewriteId);
return { source: source || null, ai: ai || null, compare: compare || null };
}
function getStats() {
const d = getDb(); if (!d) return null;
const c = (sql) => d.prepare(sql).get().c;
// AI写作复盘 = 已完成的 AI 脚本复盘数(script_review type='ai')
let reviews = 0;
try { reviews = c(`SELECT COUNT(*) c FROM script_review WHERE type='ai' AND status='done'`); } catch (e) { /* 统计失败不影响其他 */ }
// AI脚本评分 = 最新 20 条 AI 复盘总分的平均(保留 1 位小数;无评分返回 null)
let writingScore = null;
try {
const rows = d.prepare(`SELECT total FROM script_review WHERE type='ai' AND status='done' AND total IS NOT NULL AND total <> '' ORDER BY updated_time DESC, id DESC LIMIT 20`).all();
const nums = rows.map((r) => Number(r.total)).filter((v) => Number.isFinite(v));
if (nums.length > 0) writingScore = Math.round((nums.reduce((s, v) => s + v, 0) / nums.length) * 10) / 10;
} catch (e) { /* 评分统计失败不影响其他统计 */ }
return {
accounts: c(`SELECT COUNT(*) c FROM hot_accounts WHERE del_flag=0`),
analyses: c(`SELECT COUNT(*) c FROM account_video_analysis`),
rewrites: c(`SELECT COUNT(*) c FROM rewrite_log WHERE status='done' AND script_text IS NOT NULL AND script_text <> ''`),
reviews,
writingScore,
};
}
// 最近 AI 写剧本(首页快捷卡片):status=done 且有内容,按 id 倒序取前 N 条;
// score = 该脚本最新一条已完成 AI 复盘(script_review type='ai')的总分,无复盘则为 null
function listRecentScripts(limit = 4) {
const d = getDb(); if (!d) return [];
const n = Math.max(1, Math.min(Number(limit) || 4, 20));
return d.prepare(`
SELECT r.id, r.title, r.script_text, r.created_time, r.video_id, r.account_id,
h.account_name, v.video_title,
(SELECT sr.total FROM script_review sr
WHERE sr.type='ai' AND sr.rewrite_id = r.id AND sr.status='done'
AND sr.total IS NOT NULL AND sr.total <> ''
ORDER BY sr.id DESC LIMIT 1) AS score
FROM rewrite_log r
LEFT JOIN hot_accounts h ON h.id = r.account_id
LEFT JOIN account_videos v ON v.id = r.video_id
WHERE r.status='done' AND r.script_text IS NOT NULL AND r.script_text <> ''
ORDER BY r.id DESC LIMIT ?
`).all(n);
}
/* ================= 周榜(三榜族:week 账号周榜 / hot 视频热榜 / likes 点赞榜) ================= */
// ============ 榜单目录结构(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$/,
};
// 工作台所需赛道白名单(09-03 定:与账号周榜 douyin-top-account 保留 5 赛道同口径)
// 口径差异:账号榜 type=「生活vlog」;作品榜条目 category=「生活记录」——并集同时放行。
// 生效范围:hot/likes(全品类文件)赛道下拉与列表(含「全部」)只保留白名单条目;week 文件名赛道天然在白名单内。
const RANK_KEEP = ['生活记录', '生活vlog', '小剧场', '亲子', '美食', '旅行'];
const RANK_KEEP_SET = new Set(RANK_KEEP);
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));
}
// 赛道下拉:白名单内且当期数据实际出现者,按 RANK_KEEP 顺序输出
// week:文件名赛道;hot/likes:条目 category 并集
function collectCategories(board, files) {
const found = new Set();
if (board === 'week') {
for (const f of files) found.add(f.category);
} else {
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) found.add(c); }
} catch (e) {}
}
}
return RANK_KEEP.filter((c) => found.has(c));
}
function rankingMeta(board = 'week') {
const empty = { dates: [], categories: [] };
const files = listRankFiles(board);
if (!files.length) return empty;
const dates = [...new Set(files.map((f) => f.date))];
return { dates, categories: collectCategories(board, files) };
}
// 榜单数据新鲜度(更新防重依据,09-03 新增):递归扫描 RANKING_DIR(三榜子目录 + 根目录旧平铺)全部 .json,
// 返回最新落盘文件的 mtime(=最近一次成功抓取完成时刻,任务落盘即刷新)
function rankingFreshness() {
const out = { exists: fs.existsSync(RANKING_DIR), latestMtime: 0, latestFile: '', latestDate: '', files: 0 };
if (!out.exists) return out;
const walk = (dir) => {
let entries = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
for (const en of entries) {
const full = path.join(dir, en.name);
if (en.isDirectory()) walk(full);
else if (/\.json$/i.test(en.name)) {
out.files++;
try {
const st = fs.statSync(full);
if (st.mtimeMs > out.latestMtime) { out.latestMtime = st.mtimeMs; out.latestFile = full; }
} catch (e) {}
}
}
};
walk(RANKING_DIR);
const m = String(out.latestFile).match(/(\d{4}-\d{2}-\d{2})\.json$/);
if (m) out.latestDate = m[1];
return out;
}
function parseNum(v) {
const s = String(v ?? '');
const m = s.match(/^([\d.]+)\s*(w|万)?$/i);
if (!m) return 0;
let n = parseFloat(m[1]);
if (m[2]) n *= 10000;
return n;
}
function getRanking({ board = 'week', date = '', category = '', sortBy = '', order = '', page = 1, pageSize = 20 } = {}) {
const empty = { dates: [], categories: [], category: '', date: '', total: 0, items: [] };
const files = listRankFiles(board);
if (!files.length) return empty;
const dates = [...new Set(files.map((f) => f.date))];
const categories = collectCategories(board, files);
const selDate = date || dates[0];
// week:按文件名赛道过滤(每赛道一文件);hot/likes 全品类单文件:赛道过滤在条目层
const selFiles = files.filter((f) => f.date === selDate && (board !== 'week' || !category || f.category === category));
let items = [];
for (const pf of selFiles) {
try { items = items.concat(JSON.parse(fs.readFileSync(rankFilePath(pf.dir, pf.name), 'utf8')).list || []); } catch (e) {}
}
// 全品类单文件(hot/likes):①白名单过滤(含「全部」也只保留所需赛道条目)②按请求赛道条目过滤
if (board !== 'week') {
items = items.filter((it) => RANK_KEEP_SET.has(String(it.category || '').trim()));
if (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 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 key = sortBy || DEF_KEY;
const orderDir = order === 'asc' ? 'asc' : (order === 'desc' ? 'desc' : (key === 'rank' ? 'asc' : 'desc'));
items.sort((a, b) => {
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;
});
}
const total = items.length;
const limit = Math.max(1, Math.min(Number(pageSize) || 20, 100));
const offset = (Math.max(1, Number(page) || 1) - 1) * limit;
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 && 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 };
});
return { dates, categories, category, date: selDate, total, page: Math.max(1, Number(page) || 1), pageSize: limit, items: pageItems };
}
/* ================= AI写脚本:自定义选题(无视频载体)================= */
/* 09-02 v25:账号列表「AI创作」(自定义选题,无原视频)入库的脚本 = video_id 空。此处逐条列出该类脚本 */
function listCustomScripts({ page = 1, pageSize = 20, search = '', days = '', account = '' } = {}) {
const d = getDb();
if (!d) return { total: 0, page: 1, pageSize, items: [] };
const where = [`(r.video_id IS NULL OR r.video_id = 0)`, `r.status='done'`, `r.script_text IS NOT NULL`, `r.script_text <> ''`];
const params = [];
if (search) { where.push('(r.title LIKE ? OR h.account_name LIKE ? OR r.topic LIKE ?)'); params.push(`%${search}%`, `%${search}%`, `%${search}%`); }
if (account) { where.push('h.account_name = ?'); params.push(account); }
const n = Number(days);
if (n > 0) { where.push(`r.created_time >= date('now', 'localtime', ?)`); params.push(`-${n} days`); }
const w = 'WHERE ' + where.join(' AND ');
const total = d.prepare(`SELECT COUNT(*) c FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id ${w}`).get(...params).c;
const limit = Math.max(1, Math.min(Number(pageSize) || 20, 100));
const offset = (Math.max(1, Number(page) || 1) - 1) * limit;
const items = d.prepare(`
SELECT r.id, r.video_id, r.account_id, r.title, r.topic, r.status, r.created_time, h.account_name,
length(r.script_text) AS script_len
FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id
${w} ORDER BY r.id DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
return { total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
}
/* 自定义选题脚本全文(查看弹窗用,避免列表全量携带大文本) */
function getCustomScript(id) {
const d = getDb(); if (!d) throw new Error('数据库不可用');
const row = d.prepare(`SELECT r.id, r.video_id, r.account_id, r.title, r.topic, r.script_text, r.status, r.created_time, r.updated_time, h.account_name
FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id WHERE r.id=?`).get(Number(id));
if (!row || row.video_id) throw new Error('脚本不存在或非自定义选题脚本');
return row;
}
/* 写库:AI创作任务完成后回调保存脚本(自定义选题 videoId 省略/0 → video_id=NULL;参考选题传 videoId)
可写连接单独打开(与只读 getDb 隔离),与 setFavorite 同模式 */
function saveScript({ videoId, awemeId, accountId, title, scriptText }) {
if (!fs.existsSync(DSH_DB)) throw new Error('数据库不可用');
const text = String(scriptText || '').trim();
if (!text) throw new Error('脚本内容为空');
const w = new DatabaseSync(DSH_DB);
try {
const vid = Number(videoId) > 0 ? Number(videoId) : null;
const r = w.prepare(`INSERT INTO rewrite_log (video_id, aweme_id, account_id, script_text, title, status, created_time)
VALUES (?, ?, ?, ?, ?, 'done', datetime('now','localtime'))`)
.run(vid, awemeId ? String(awemeId) : null, Number(accountId) > 0 ? Number(accountId) : null, text, String(title || '').trim());
return { id: Number(r.lastInsertRowid) };
} finally {
w.close();
}
}
/* 09-02 v25:AI写脚本 三 tab 徽标计数——按 有原视频(参考选题)/无原视频(自定义选题) 拆分;
口径与账号列表「AI脚本」列一致(status=done 且脚本非空);account 传账号名时按账号统计 */
function getRewriteStats(account = '') {
const d = getDb();
if (!d) return { videoScripts: 0, customScripts: 0 };
const conds = [`r.status='done'`, `r.script_text IS NOT NULL`, `r.script_text <> ''`];
const params = [];
if (account) { conds.push('h.account_name = ?'); params.push(account); }
const base = `FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id WHERE ${conds.join(' AND ')}`;
const videoScripts = d.prepare(`SELECT COUNT(*) c ${base} AND r.video_id > 0`).get(...params).c;
const customScripts = d.prepare(`SELECT COUNT(*) c ${base} AND (r.video_id IS NULL OR r.video_id = 0)`).get(...params).c;
return { videoScripts, customScripts };
}
module.exports = {
dbAvailable, setFavorite, batchDelete, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis,
listVideos, getVideoDetail, getVideoScript, listRewrites, listStoryboards, listRecentScripts,
getReviewDetail, getReviewFull, getStats, rankingMeta, getRanking, rankingFreshness, DSH_DB, RANKING_DIR,
listCustomScripts, getCustomScript, saveScript, getRewriteStats,
};