// 短视频工作台 - 数据访问层(只读复用业务数据目录) // 数据源:工作台目录 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, };