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

701 lines
37 KiB
JavaScript
Raw Normal View History

// 短视频工作台 - 数据访问层(只读复用业务数据目录)
// 数据源:工作台目录 mcn-plugin.db(node:sqlite 只读)+ 三方数据目录 抖音榜单/*.json
// 09-10:dsh 侧已整套下线(~/.dsh/skills/、~/.dsh/mcn-plugin.db、D:\dshworkspace 均不存在),
// 工作台 DB 为唯一读写副本,不再有「与 dsh 原库隔离」的双库关系
// 页面逻辑参照 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');
// 读取工作台 config.json(load-config.js 唯一入口 + 默认值唯一来源,配置收敛)
const { loadConfig } = require('./load-config');
const CFG = loadConfig();
// 工作台自有数据库副本(唯一读写副本)
const DSH_DB = path.join(__dirname, CFG.dbName);
// 榜单目录解析(09-03 定稿)
// 优先级:① 环境变量 MCN_RANKING_DIR 显式覆盖 → ② 桌面/<CFG.businessDataDir>(config.json,运行时解析真实桌面,兼容 OneDrive 重定向)
function resolveRankingDir() {
if (process.env.MCN_RANKING_DIR) return process.env.MCN_RANKING_DIR;
const home = os.homedir();
const rel = path.join(...CFG.businessDataDir);
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);
}
}
// 删除单条 AI 脚本(R58 弹窗「删除」):连坐该脚本的诊断复盘(script_review ai/compare)与分镜提示词(storyboard_log)。
// 若删除后其视频下已无剩余脚本 → 视频级 source 复盘与该视频游离分镜(rewrite_id 空)一并清理,避免孤儿数据。
function deleteScript(rewriteId) {
const id = Number(rewriteId);
if (!(id > 0)) throw new Error('脚本 ID 无效');
if (!fs.existsSync(DSH_DB)) throw new Error('数据库不可用');
const w = new DatabaseSync(DSH_DB);
try {
const row = w.prepare(`SELECT id, video_id, aweme_id FROM rewrite_log WHERE id=?`).get(id);
if (!row) return { deleted: 0 };
w.prepare(`DELETE FROM script_review WHERE rewrite_id=?`).run(id); /* ai/compare 诊断 */
w.prepare(`DELETE FROM storyboard_log WHERE rewrite_id=?`).run(id); /* 该脚本分镜 */
if (row.video_id) {
const remain = w.prepare(`SELECT COUNT(*) c FROM rewrite_log WHERE video_id=? AND id<>?`).get(row.video_id, id).c;
if (remain === 0) {
w.prepare(`DELETE FROM script_review WHERE type='source' AND video_id=?`).run(row.video_id);
w.prepare(`DELETE FROM storyboard_log WHERE video_id=? AND (rewrite_id IS NULL OR rewrite_id=0)`).run(row.video_id);
}
}
const r = w.prepare(`DELETE FROM rewrite_log WHERE id=?`).run(id);
return { deleted: Number(r.changes) };
} finally {
w.close();
}
}
/* ================= 账号 ================= */
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 };
}
/* 09-10:原创选题(video_id 为空)原本查不到分镜 → 分镜 tab 无生成入口。
现支持按 rewrite_id 查询,使无视频载体的脚本也能挂分镜(storyboard_log 本就有 rewrite_id 列)。 */
function listStoryboards(videoId, rewriteId) {
const d = getDb(); if (!d) return [];
const rid = Number(rewriteId);
if (Number.isFinite(rid) && rid > 0 && !(Number(videoId) > 0)) {
return d.prepare(`SELECT id, video_id, rewrite_id, script_text, status, created_time, updated_time FROM storyboard_log WHERE rewrite_id=? ORDER BY id DESC`).all(rid);
}
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: '账号周榜', day: '账号日榜', hot: '视频热榜', likes: '点赞榜' };
const RANK_FILE_RE = {
week: /^抖音周榜_(.+)_(\d{4}-\d{2}-\d{2})\.json$/,
day: /^抖音账号日榜_(\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=「生活记录」——并集同时放行。
// 生效范围:day/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));
}
// 期次真实区间:{ date: {start, end} }(周榜=周一~周日;日榜 start=end=当日)
// 09-10:UI 改显示「08-31 ~ 09-06」而非裸期号 2026-08-31——期号是周一,只看期号会误以为仅 8/31 单日。
// 同一 date 可能有多个文件(week 每赛道一个),取首个即可(区间相同)。
function collectRanges(board, files) {
const out = {};
const seen = new Set();
for (const pf of files) {
if (seen.has(pf.date)) continue;
seen.add(pf.date);
try {
const d = JSON.parse(fs.readFileSync(rankFilePath(pf.dir, pf.name), 'utf8'));
const s = String(d.dateStart || d.date || '').trim();
if (s) out[pf.date] = { start: s, end: String(d.dateEnd || d.date || s).trim() };
} catch (e) {}
}
return out;
}
function rankingMeta(board = 'week') {
const empty = { dates: [], categories: [], ranges: {} };
const files = listRankFiles(board);
if (!files.length) return empty;
const dates = [...new Set(files.map((f) => f.date))];
return { dates, categories: collectCategories(board, files), ranges: collectRanges(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: [], ranges: {}, 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/day(账号榜)=综合得分 / hot=点赞 / likes=新增点赞(同分按排名升序)
const DEF_KEY = (board === 'week' || board === 'day') ? '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] : ''; };
/* 库内命中关联(四榜统一,「功能」列 查看/关注 的判据):
账号榜(week/day):profileUrl 有 sec_uid → 优先按 sec_uid 匹配,回退 account_name;
作品榜(hot/likes):条目无 profileUrl,作者键是 user_nickname(不是 accountName)→ 按 account_name 名匹配。
⚠️ 作品榜条目里没有作者主页地址(只有 user_nickname / author_user_id,后者是数字 uid ≠ sec_uid),
所以只能走账号名;命中并非精确保证,故仅在 del_flag=0 内匹配、同名单取已关注优先。 */
const pageItems = items.slice(offset, offset + limit).map((row) => {
const sec = row.profileUrl ? secOf(row.profileUrl) : '';
const name = String(row.accountName || row.user_nickname || '').trim();
let hit = null;
if (d && (sec || name)) {
const conds = [], args = [];
if (sec) { conds.push('sec_uid = ?'); args.push(sec); }
if (name) { conds.push('account_name = ?'); args.push(name); }
hit = d.prepare(`SELECT id FROM hot_accounts WHERE del_flag = 0 AND (${conds.join(' OR ')})
ORDER BY (favorite = 1) DESC, id ASC LIMIT 1`).get(...args);
}
return { ...row, inDb: !!hit, dbId: hit ? hit.id : null };
});
return { dates, categories, ranges: collectRanges(board, files), 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 同模式 */
/* ===== 选题列表(09-16 新增)=====
背景:AI创作弹窗打磨出的「选题」(8 字段需求 + 时长)原先只活在浏览器草稿与任务 prompt 里,
脚本生成后无处追溯,也无法按选题回看对应脚本。
现在:① 提交创作时先落一条 topic_log(status=pending,即使脚本没生成也不会丢);
② 任务完成回调 /api/dsh/script-save 时带上 topicId → saveScript 回填 rewrite_id 并置 done;
③ 账号详情「选题列表」tab 按账号查看选题及对应脚本。 */
const TOPIC_DDL = `CREATE TABLE IF NOT EXISTS topic_log (
id INTEGER PRIMARY KEY,
account_id INTEGER,
title TEXT,
req_json TEXT,
rewrite_id INTEGER,
status TEXT DEFAULT 'pending',
created_time TEXT,
updated_time TEXT)`;
function saveTopic({ id, accountId, title, req, dur }) {
if (!fs.existsSync(DSH_DB)) throw new Error('数据库不可用');
const w = new DatabaseSync(DSH_DB);
try {
w.prepare(TOPIC_DDL).run();
const t = String(title || '').trim() || '(未命名选题)';
const json = JSON.stringify({ req: req || [], dur: String(dur || '') });
// 带 id = 编辑已有选题(未出脚本时才允许编辑,前端已按状态约束):就地更新,不新增
if (Number(id) > 0) {
w.prepare(`UPDATE topic_log SET title=?, req_json=?, updated_time=datetime('now','localtime') WHERE id=?`)
.run(t, json, Number(id));
return { id: Number(id), updated: true };
}
const r = w.prepare(`INSERT INTO topic_log (account_id, title, req_json, status, created_time)
VALUES (?, ?, ?, 'pending', datetime('now','localtime'))`)
.run(Number(accountId) > 0 ? Number(accountId) : null, t, json);
return { id: Number(r.lastInsertRowid) };
} finally { w.close(); }
}
function listTopics(accountId) {
const d = getDb();
if (!d) return [];
try {
return d.prepare(`SELECT t.id, t.account_id, t.title, t.req_json, t.rewrite_id, t.status, t.created_time, t.updated_time,
r.title AS script_title, length(COALESCE(r.script_text,'')) AS script_len
FROM topic_log t LEFT JOIN rewrite_log r ON r.id = t.rewrite_id
WHERE t.account_id = ? ORDER BY t.id DESC`).all(Number(accountId));
} catch (e) { return []; } // 表尚未创建(从未提交过创作)→ 空列表
}
function getTopicScript(topicId) {
const d = getDb();
if (!d) throw new Error('数据库不可用');
const row = d.prepare(`SELECT t.id, t.title, t.req_json, t.rewrite_id, t.status, t.created_time,
r.title AS script_title, r.script_text, r.created_time AS script_time, r.video_id
FROM topic_log t LEFT JOIN rewrite_log r ON r.id = t.rewrite_id WHERE t.id = ?`).get(Number(topicId));
if (!row) throw new Error('选题不存在');
return row;
}
/* 写库:AI创作任务完成后回调保存脚本(原创选题 videoId 省略/0 → video_id=NULL;参考选题传 videoId)
可写连接单独打开(与只读 getDb 隔离),与 setFavorite 同模式 */
function saveScript({ videoId, awemeId, accountId, title, scriptText, topicId }) {
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());
const newId = Number(r.lastInsertRowid);
// 09-16:任务带 topicId 回来 → 回填选题的关联脚本(失败不影响脚本入库)
if (Number(topicId) > 0) {
try {
w.prepare(TOPIC_DDL).run();
w.prepare(`UPDATE topic_log SET rewrite_id=?, status='done', updated_time=datetime('now','localtime') WHERE id=?`)
.run(newId, Number(topicId));
} catch (e) { /* 忽略 */ }
}
return { id: newId };
} 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, deleteScript, 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,
saveTopic, listTopics, getTopicScript,
};