2026-09-04 09:59:31 +08:00
// 短视频工作台 - 数据访问层(只读复用业务数据目录)
2026-09-03 11:54:25 +08:00
// 数据源:工作台目录 mcn-plugin.db( dsh 库副本,node:sqlite 只读)+ 三方数据目录 抖音榜单/*.json
2026-08-27 12:33:57 +08:00
// 与 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' );
2026-09-03 11:54:25 +08:00
const os = require ( 'node:os' );
2026-08-27 12:33:57 +08:00
// 工作台自有数据库副本(与 dsh 原库隔离)
const DSH_DB = path . join ( __dirname , 'mcn-plugin.db' );
2026-09-03 11:54:25 +08:00
// 榜单目录解析(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 ();
2026-08-27 12:33:57 +08:00
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 ; }
2026-08-28 18:54:01 +08:00
// 收藏切换:可写连接单独打开(与只读 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 ();
}
}
2026-08-27 12:33:57 +08:00
const num = ( v ) => { const n = Number ( v ); return Number . isFinite ( n ) ? n : 0 ; };
const fmt = ( v ) => ( v == null || v === '' ) ? null : v ;
2026-09-01 14:31:46 +08:00
// 批量删除: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 );
}
}
2026-09-04 12:08:47 +08:00
// 删除单条 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 ();
}
}
2026-08-27 12:33:57 +08:00
/* ================= 账号 ================= */
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` ;
2026-09-02 18:59:02 +08:00
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 );
2026-08-27 12:33:57 +08:00
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
2026-08-28 18:54:01 +08:00
FROM account_videos v WHERE v.account_id=? ORDER BY v.publish_time IS NULL, v.publish_time DESC, v.id DESC LIMIT ? OFFSET ?
2026-08-27 12:33:57 +08:00
` ). 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 ;
}
/* ================= 视频 ================= */
2026-09-01 18:33:20 +08:00
function listVideos ({ page = 1 , pageSize = 20 , search = '' , parsed = '' , days = '' } = {}) {
2026-08-27 12:33:57 +08:00
const d = getDb ();
if ( ! d ) return { total : 0 , page : 1 , pageSize , items : [] };
2026-09-01 18:33:20 +08:00
// 09-01 用户要求:分页展示全部视频(不再限制每账号 30W+/1W+ 取 10 条)
const where = [];
2026-08-27 12:33:57 +08:00
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 } )` );
}
2026-09-01 18:33:20 +08:00
// 发布时间筛选: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 ' ) : '' ;
2026-08-27 12:33:57 +08:00
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
2026-09-01 18:33:20 +08:00
${ w } ORDER BY h.favorite DESC, v.publish_time DESC, v.id DESC LIMIT ? OFFSET ?
2026-08-27 12:33:57 +08:00
` ). 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 ;
2026-08-31 15:27:54 +08:00
// 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 ) { /* 统计失败不影响其他 */ }
2026-09-01 18:33:20 +08:00
// AI脚本评分 = 最新 20 条 AI 复盘总分的平均(保留 1 位小数;无评分返回 null)
2026-08-31 15:27:54 +08:00
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 ) { /* 评分统计失败不影响其他统计 */ }
2026-08-27 12:33:57 +08:00
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 <> ''` ),
2026-08-31 15:27:54 +08:00
reviews ,
writingScore ,
2026-08-27 12:33:57 +08:00
};
}
2026-09-03 15:30:33 +08:00
// 最近 AI 写剧本(首页快捷卡片):status=done 且有内容,按 id 倒序取前 N 条;
// score = 该脚本最新一条已完成 AI 复盘(script_review type='ai')的总分,无复盘则为 null
2026-08-27 12:33:57 +08:00
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,
2026-09-03 15:30:33 +08:00
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
2026-08-27 12:33:57 +08:00
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 );
}
2026-09-03 15:00:01 +08:00
/* ================= 周榜(三榜族:week 账号周榜 / hot 视频热榜 / likes 点赞榜) ================= */
2026-08-27 12:33:57 +08:00
2026-09-03 15:00:01 +08:00
// ============ 榜单目录结构(09-03 分目录管理)============
// 抖音榜单/
// 账号周榜/抖音周榜_{赛道}_{周一}.json → week(每赛道一期一文件,douyin-top-account 任务落盘)
// 视频热榜/抖音周榜_视频热榜_{周一}.json → hot(全品类单文件,fetch_week_ranks.py 落盘,不传赛道一次调用)
// 点赞榜/ 抖音周榜_点赞榜_{周一}.json → likes(全品类单文件,同上)
// 根目录旧平铺文件仍兼容读取(任务旧产物)。
// 全品类单文件(hot/likes)无赛道段:category 顶层标记「全部」,实际赛道在 list[].category,
// 赛道下拉 = 各期文件条目 category 并集,赛道筛选按条目 category 匹配。
2026-09-04 12:08:47 +08:00
const RANK_SUB = { week : '账号周榜' , day : '账号日榜' , hot : '视频热榜' , likes : '点赞榜' };
2026-09-03 15:00:01 +08:00
const RANK_FILE_RE = {
week : /^抖音周榜_(.+)_(\d{4}-\d{2}-\d{2})\.json$/ ,
2026-09-04 12:08:47 +08:00
day : /^抖音账号日榜_(\d{4}-\d{2}-\d{2})\.json$/ ,
2026-09-03 15:00:01 +08:00
hot : /^抖音周榜_视频热榜_(\d{4}-\d{2}-\d{2})\.json$/ ,
likes : /^抖音周榜_点赞榜_(\d{4}-\d{2}-\d{2})\.json$/ ,
};
2026-09-03 15:04:14 +08:00
// 工作台所需赛道白名单(09-03 定:与账号周榜 douyin-top-account 保留 5 赛道同口径)
// 口径差异:账号榜 type=「生活vlog」;作品榜条目 category=「生活记录」——并集同时放行。
2026-09-04 12:08:47 +08:00
// 生效范围:day/hot/likes(全品类文件)赛道下拉与列表(含「全部」)只保留白名单条目;week 文件名赛道天然在白名单内。
2026-09-03 15:04:14 +08:00
const RANK_KEEP = [ '生活记录' , '生活vlog' , '小剧场' , '亲子' , '美食' , '旅行' ];
const RANK_KEEP_SET = new Set ( RANK_KEEP );
2026-09-03 15:00:01 +08:00
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 ));
}
2026-09-03 15:04:14 +08:00
// 赛道下拉:白名单内且当期数据实际出现者,按 RANK_KEEP 顺序输出
// week:文件名赛道;hot/likes:条目 category 并集
2026-09-03 15:00:01 +08:00
function collectCategories ( board , files ) {
2026-09-03 15:04:14 +08:00
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 ) {}
}
2026-09-03 15:00:01 +08:00
}
2026-09-03 15:04:14 +08:00
return RANK_KEEP . filter (( c ) => found . has ( c ));
2026-09-03 15:00:01 +08:00
}
function rankingMeta ( board = 'week' ) {
2026-08-27 12:33:57 +08:00
const empty = { dates : [], categories : [] };
2026-09-03 15:00:01 +08:00
const files = listRankFiles ( board );
2026-08-27 12:33:57 +08:00
if ( ! files . length ) return empty ;
2026-09-03 15:00:01 +08:00
const dates = [... new Set ( files . map (( f ) => f . date ))];
return { dates , categories : collectCategories ( board , files ) };
2026-08-27 12:33:57 +08:00
}
2026-09-03 17:06:19 +08:00
// 榜单数据新鲜度(更新防重依据,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 ;
}
2026-08-27 12:33:57 +08:00
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 ;
}
2026-09-03 15:00:01 +08:00
function getRanking ({ board = 'week' , date = '' , category = '' , sortBy = '' , order = '' , page = 1 , pageSize = 20 } = {}) {
2026-08-27 12:33:57 +08:00
const empty = { dates : [], categories : [], category : '' , date : '' , total : 0 , items : [] };
2026-09-03 15:00:01 +08:00
const files = listRankFiles ( board );
2026-08-27 12:33:57 +08:00
if ( ! files . length ) return empty ;
2026-09-03 15:00:01 +08:00
const dates = [... new Set ( files . map (( f ) => f . date ))];
const categories = collectCategories ( board , files );
2026-08-27 12:33:57 +08:00
const selDate = date || dates [ 0 ];
2026-09-03 15:00:01 +08:00
// week:按文件名赛道过滤(每赛道一文件);hot/likes 全品类单文件:赛道过滤在条目层
const selFiles = files . filter (( f ) => f . date === selDate && ( board !== 'week' || ! category || f . category === category ));
2026-08-27 12:33:57 +08:00
let items = [];
2026-09-03 15:00:01 +08:00
for ( const pf of selFiles ) {
try { items = items . concat ( JSON . parse ( fs . readFileSync ( rankFilePath ( pf . dir , pf . name ), 'utf8' )). list || []); } catch ( e ) {}
2026-08-27 12:33:57 +08:00
}
2026-09-03 15:04:14 +08:00
// 全品类单文件(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 );
}
2026-09-03 15:00:01 +08:00
}
2026-09-04 12:08:47 +08:00
// 默认排序键随榜:week/day(账号榜)=综合得分 / hot=点赞 / likes=新增点赞(同分按排名升序)
const DEF_KEY = ( board === 'week' || board === 'day' ) ? 'comprehensiveScore' : board === 'hot' ? 'likeCount' : 'add_digg_count' ;
2026-09-03 15:00:01 +08:00
if ( ! order && ( ! sortBy || sortBy === DEF_KEY )) {
2026-08-27 20:43:39 +08:00
items . sort (( a , b ) => {
2026-09-03 15:00:01 +08:00
const va = num ( a [ DEF_KEY ]), vb = num ( b [ DEF_KEY ]);
if ( va !== vb ) return vb - va ;
2026-08-27 20:43:39 +08:00
return num ( a . rank ) - num ( b . rank );
});
} else {
2026-09-03 15:00:01 +08:00
const key = sortBy || DEF_KEY ;
const orderDir = order === 'asc' ? 'asc' : ( order === 'desc' ? 'desc' : ( key === 'rank' ? 'asc' : 'desc' ));
2026-08-27 20:43:39 +08:00
items . sort (( a , b ) => {
2026-09-03 15:00:01 +08:00
const va = key === 'rank' ? num ( a . rank ) : parseNum ( a [ key ]);
const vb = key === 'rank' ? num ( b . rank ) : parseNum ( b [ key ]);
2026-08-27 20:43:39 +08:00
return orderDir === 'asc' ? va - vb : vb - va ;
});
}
2026-08-27 12:33:57 +08:00
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 ) => {
2026-09-03 15:00:01 +08:00
// 账号维度(week,含 profileUrl)才做库内命中关联;作品维度不关联账号表
const sec = row . profileUrl ? secOf ( row . profileUrl ) : '' ;
2026-08-27 12:33:57 +08:00
let hit = null ;
2026-09-03 15:00:01 +08:00
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 ; // 作品榜条目不查账号表
2026-08-27 12:33:57 +08:00
}
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 };
}
2026-09-04 17:57:15 +08:00
/* ================= AI写脚本:原创选题(无视频载体)================= */
/* 09-02 v25:账号列表「AI创作」(原创选题,无原视频)入库的脚本 = video_id 空。此处逐条列出该类脚本 */
2026-09-02 19:32:19 +08:00
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 };
}
2026-09-04 17:57:15 +08:00
/* 原创选题脚本全文(查看弹窗用,避免列表全量携带大文本) */
2026-09-02 19:32:19 +08:00
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 ));
2026-09-04 17:57:15 +08:00
if ( ! row || row . video_id ) throw new Error ( '脚本不存在或非原创选题脚本' );
2026-09-02 19:32:19 +08:00
return row ;
}
2026-09-04 17:57:15 +08:00
/* 写库:AI创作任务完成后回调保存脚本(原创选题 videoId 省略/0 → video_id=NULL;参考选题传 videoId)
2026-09-02 19:32:19 +08:00
可写连接单独打开(与只读 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 ();
}
}
2026-09-04 17:57:15 +08:00
/* 09-02 v25: AI写脚本 三 tab 徽标计数——按 有原视频(参考选题)/无原视频(原创选题) 拆分;
2026-09-02 19:32:19 +08:00
口径与账号列表「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 };
}
2026-08-27 12:33:57 +08:00
module . exports = {
2026-09-04 12:08:47 +08:00
dbAvailable , setFavorite , batchDelete , deleteScript , listTracks , listAccounts , listAccountVideos , getPersona , getAccountAnalysis ,
2026-08-27 12:33:57 +08:00
listVideos , getVideoDetail , getVideoScript , listRewrites , listStoryboards , listRecentScripts ,
2026-09-03 17:06:19 +08:00
getReviewDetail , getReviewFull , getStats , rankingMeta , getRanking , rankingFreshness , DSH_DB , RANKING_DIR ,
2026-09-02 19:32:19 +08:00
listCustomScripts , getCustomScript , saveScript , getRewriteStats ,
2026-08-27 12:33:57 +08:00
};