- 账号列表页新增「导入账号」:两种方式(抖音账号信息/本地文件夹),复用 executeTask 任务链路提交 AI 任务 - 查看改写弹窗:展开源脚本按钮移入 AI脚本 标题右侧(间隔10px),弹窗最大宽度 1400→1600 - 短视频提示词生成 整体移入 短视频脚本创作/V1.0/subskill/mcn-video-prompt(与 mcn-dou-analysis 同构,138 文件 R100 保留历史) - 工作台 dsh-data/server 若干修复 + 记忆更新
324 lines
17 KiB
JavaScript
324 lines
17 KiB
JavaScript
// 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; }
|
||
|
||
// 收藏切换:可写连接单独打开(与只读 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;
|
||
|
||
/* ================= 账号 ================= */
|
||
|
||
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.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 = '' } = {}) {
|
||
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) {}
|
||
}
|
||
// 默认排序:综合得分倒序,同分按排名升序(用户点表头排序时走下方 sortBy/order 逻辑)
|
||
if (!order && (!sortBy || sortBy === 'rank')) {
|
||
items.sort((a, b) => {
|
||
const scA = num(a.comprehensiveScore), scB = num(b.comprehensiveScore);
|
||
if (scA !== scB) return scB - scA;
|
||
return num(a.rank) - num(b.rank);
|
||
});
|
||
} else {
|
||
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, setFavorite, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis,
|
||
listVideos, getVideoDetail, getVideoScript, listRewrites, listStoryboards, listRecentScripts,
|
||
getReviewDetail, getReviewFull, getStats, rankingMeta, getRanking, DSH_DB, RANKING_DIR,
|
||
};
|