Files
mcn-short-video/project/短视频脚本创作/V1.0/mcn-workshop/dsh-data.js
T
maogeigei f77735b206 工作台:目录改名 mcn-workshop + 任务进度可见 + 会话归属配置化
1. 目录 mcn-work-shop -> mcn-workshop(空间分组名取会话 cwd 的目录名,只改字符串无效,必须真改名)
2. 全仓替换 mcn-work-shop -> mcn-workshop:37 文件 121 处;历史日志按沿革句规矩保留当时目录名
3. 任务进度可见:/api/run/status 产出 已运行时长/工具调用/最近动作/停滞判定(读会话日志尾部),前端新增右下角常驻面板,四处任务入口接入
4. 会话归属目录配置化:新增 config.sessionCwd(留空=工作台自身目录),现指向 D://AI技能//mcn-workshop,使任务显示为命名分组而非「未分组任务」
5. 修前端轮询静默缺陷:连续 3 次查询失败即提示服务断开(原逻辑静默空转到 15 分钟超时,用户零感知)
2026-10-08 13:09:06 +08:00

702 lines
37 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-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,
};