// 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; // 批量删除: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); } } /* ================= 账号 ================= */ 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; // 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 条 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, batchDelete, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis, listVideos, getVideoDetail, getVideoScript, listRewrites, listStoryboards, listRecentScripts, getReviewDetail, getReviewFull, getStats, rankingMeta, getRanking, DSH_DB, RANKING_DIR, };