600 lines
32 KiB
JavaScript
600 lines
32 KiB
JavaScript
// 短视频工作台 - 数据访问层(只读复用业务数据目录)
|
||
// 数据源:工作台目录 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);
|
||
}
|
||
}
|
||
|
||
// 删除单条 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 };
|
||
}
|
||
|
||
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: '账号周榜', 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));
|
||
}
|
||
|
||
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/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] : ''; };
|
||
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, 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,
|
||
};
|