Files
mcn-short-video/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js
T
maogeigei d3f98dda77 feat(MCN工作台): 数据功能页+图标扁平化+多项UI优化
- 工作台大版本升级:hash 路由 + 6 数据功能页(周榜/热门账号/账号详情/视频详情/AI写脚本/AI写作复盘),只读复用 dsh 库副本 + 抖音榜单 JSON
- 数据库隔离:复制 dsh 库到 mcn-work-shop/,与 dsh 原库各访问各的(已 gitignore)
- 图标全面扁平化:Lucide 线性 SVG,功能图标亮色区分,文字标题去图标
- 首页重构:功能入口卡 + AI写剧本快捷卡片(2列/8个/5行预览) + 独立帮助页 + header 精简
- 新增设计技能 impeccable + taste-skill(生成网页时参考)
- 修复:fmtTime 跨 script 全局冲突(P0)、弹窗关闭、外链新标签打开等
2026-08-27 12:33:57 +08:00

303 lines
16 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工作台 - 数据访问层(只读复用 dsh 环境数据)
// 数据源:工作台目录 mcn-plugin.db(dsh 库副本,node:sqlite 只读)+ D:\dshworkspace\抖音榜单\*.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');
// 工作台自有数据库副本(与 dsh 原库隔离)
const DSH_DB = path.join(__dirname, 'mcn-plugin.db');
// 周榜目录:默认 dsh 工作区;可用环境变量 MCN_RANKING_DIR 覆盖
const RANKING_DIR = process.env.MCN_RANKING_DIR || 'D:\\dshworkspace\\抖音榜单';
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; }
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
const fmt = (v) => (v == null || v === '') ? null : v;
/* ================= 账号 ================= */
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 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.id ASC 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 = '' } = {}) {
const d = getDb();
if (!d) return { total: 0, page: 1, pageSize, items: [] };
const where = [`v.id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY publish_time DESC, id DESC) rn FROM account_videos WHERE like_count >= 300000 AND comment_count >= 10000) t WHERE rn <= 10)`];
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})`);
}
const w = '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,
SUM(v.like_count) OVER (PARTITION BY v.account_id) AS acc_likes,
(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 acc_likes 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;
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: c(`SELECT COUNT(*) c FROM script_review WHERE status='done'`),
writingScore: c(`SELECT COUNT(*) c FROM script_review WHERE type='ai' AND status='done'`),
};
}
// 最近 AI 写剧本(首页快捷卡片):status=done 且有内容,按 id 倒序取前 N 条
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
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);
}
/* ================= 周榜 ================= */
function rankingMeta() {
const empty = { dates: [], categories: [] };
if (!fs.existsSync(RANKING_DIR)) return empty;
const files = fs.readdirSync(RANKING_DIR).filter((f) => /^抖音周榜_.+_\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().reverse();
if (!files.length) return empty;
const dates = [...new Set(files.map((f) => f.match(/_(\d{4}-\d{2}-\d{2})\.json$/)[1]))];
const categories = [...new Set(files.map((f) => { const m = f.match(/^抖音周榜_(.+)_\d{4}-\d{2}-\d{2}\.json$/); return m ? m[1] : null; }).filter(Boolean))];
return { dates, categories };
}
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({ date = '', category = '', sortBy = 'rank', order = '', page = 1, pageSize = 20 } = {}) {
const empty = { dates: [], categories: [], category: '', date: '', total: 0, items: [] };
if (!fs.existsSync(RANKING_DIR)) return empty;
const files = fs.readdirSync(RANKING_DIR).filter((f) => /^抖音周榜_.+_\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().reverse();
if (!files.length) return empty;
const dates = [...new Set(files.map((f) => f.match(/_(\d{4}-\d{2}-\d{2})\.json$/)[1]))];
const categories = [...new Set(files.map((f) => { const m = f.match(/^抖音周榜_(.+)_\d{4}-\d{2}-\d{2}\.json$/); return m ? m[1] : null; }).filter(Boolean))];
const selDate = date || dates[0];
const selFiles = files.filter((f) => f.includes(selDate) && (!category || f.includes('_' + category + '_')));
let items = [];
for (const f of selFiles) {
try { items = items.concat(JSON.parse(fs.readFileSync(path.join(RANKING_DIR, f), 'utf8')).list || []); } catch (e) {}
}
const orderDir = order === 'asc' ? 'asc' : (order === 'desc' ? 'desc' : (sortBy === 'rank' ? 'asc' : 'desc'));
items.sort((a, b) => {
const va = sortBy === 'rank' ? num(a.rank) : sortBy === 'comprehensiveScore' ? num(a.comprehensiveScore) : parseNum(a[sortBy]);
const vb = sortBy === 'rank' ? num(b.rank) : sortBy === 'comprehensiveScore' ? num(b.comprehensiveScore) : parseNum(b[sortBy]);
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) => {
let hit = null;
if (d) {
const sec = secOf(row.profileUrl);
hit = sec
? d.prepare(`SELECT id FROM hot_accounts WHERE sec_uid=? OR account_name=? LIMIT 1`).get(sec, row.accountName)
: d.prepare(`SELECT id FROM hot_accounts WHERE account_name=? LIMIT 1`).get(row.accountName);
}
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 };
}
module.exports = {
dbAvailable, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis,
listVideos, getVideoDetail, getVideoScript, listRewrites, listStoryboards, listRecentScripts,
getReviewDetail, getReviewFull, getStats, rankingMeta, getRanking, DSH_DB, RANKING_DIR,
};