Files
dsh_mcn_agent/profiles/web/node_modules/dsh-plugin-mcn/lib/db.js
T
admin 856e632807 baseline: dsh 工作区基线快照(2026-09-10,全新历史起点)
本提交为 dsh_mcn_workshop 仓库的首个提交(旧仓 dsh_MCNProject 已停用),
完整固化 C:/Users/Administrator/.dsh 当前磁盘状态。

1) 临时数据清理:移出 sessions/(81M)、logs/、skill-backup-20260906/(14M)、
   scripts/__pycache__、storages/workspace.json.bak —— 合计 94M(.dsh 162M → 68M)
2) 凭据脱敏:.credentials.yaml(含明文 DEEPSEEK_API_KEY)移出 git 跟踪并加入
   .gitignore;mcn-data-insight/scripts/setup_redfox_key.py 中真实 key 示例改为占位符
3) .gitignore 增补 logs/、skill-backup-*/ 规则
4) 技能结构以当前磁盘状态为准:skills/ = mcn-short-video / impeccable / taste-skill /
   dsh-multi-user-migration;旧技能目录(mcn-dou-analysis、storyboard-prompt、
   short-video-script、browser-harness、mcn-data-insight)与
   profiles/web/node_modules/dsh-vision-router 本地已不存在,本提交记为删除
2026-09-10 22:31:45 +08:00

300 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// dsh-plugin-mcn 本地 SQLite 缓存(node:sqlite,Node 24 内置;拆分自 index.js)
import { DatabaseSync } from "node:sqlite";
import { join } from "node:path";
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { DB_PATH } from "./config.js";
import { normTitle, titleDistance, findVideoByFolder, resolveAccountDirs } from "./video-tools.js";
let db;
/** 从 AI 写作脚本正文提取标题(兼容现有多种首行格式):
* - 去掉 markdown 标题标记(# 前缀)
* - 去掉「S9 故事脚本」等前缀
* - 优先取《…》内内容,否则用整行
* - 限制长度 80 字符,空则返回 null
*/
function extractScriptTitle(scriptText) {
if (typeof scriptText !== "string" || !scriptText.trim()) return null;
let line = scriptText.replace(/\r\n/g, "\n").split("\n").find((l) => l.trim()) || "";
let t = line.trim();
t = t.replace(/^#+\s*/, "").trim(); // 去 markdown 标题标记
t = t.replace(/^S\d+\s*(故事)?(脚|剧)?本\s*/, "").trim(); // 去「S9 故事脚本」等前缀
const m = t.match(/《([^》]+)》/);
if (m && m[1].trim()) t = m[1].trim();
t = t.replace(/^#+\s*/, "").trim();
return t ? t.slice(0, 80) : null;
}
function initDb() {
if (db) return db;
db = new DatabaseSync(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS hot_accounts (
id INTEGER PRIMARY KEY,
account_name TEXT NOT NULL,
content TEXT,
track TEXT,
tags TEXT,
masterpiece TEXT,
created_by TEXT,
updated_by TEXT,
created_time TEXT,
updated_time TEXT,
sort INTEGER DEFAULT 0,
del_flag INTEGER DEFAULT 0,
account_type TEXT DEFAULT 'signed',
favorite INTEGER DEFAULT 0
);
`);
// 存量数据库迁移:旧表可能缺 account_type 列(CREATE TABLE IF NOT EXISTS 不会为已存在表加列)
const cols = db.prepare(`PRAGMA table_info(hot_accounts)`).all().map((c) => c.name);
if (!cols.includes("account_type")) {
db.exec(`ALTER TABLE hot_accounts ADD COLUMN account_type TEXT DEFAULT 'signed'`);
}
if (!cols.includes("favorite")) {
db.exec(`ALTER TABLE hot_accounts ADD COLUMN favorite INTEGER DEFAULT 0`);
}
// 外部账号扩展列(幂等迁移)
const extCols = {
douyin_id: "TEXT",
sec_uid: "TEXT",
followers: "TEXT",
total_likes: "TEXT",
works_count: "INTEGER",
ip_location: "TEXT",
location: "TEXT",
age: "INTEGER",
bio: "TEXT",
source_dir: "TEXT",
};
for (const [name, type] of Object.entries(extCols)) {
if (!cols.includes(name)) {
db.exec(`ALTER TABLE hot_accounts ADD COLUMN ${name} ${type}`);
}
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_hot_accounts_name ON hot_accounts(account_name);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_hot_accounts_track ON hot_accounts(track);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_hot_accounts_type ON hot_accounts(account_type);`);
// 存量数据兼容:无 account_type 的按 signed 处理
try { db.exec(`UPDATE hot_accounts SET account_type='signed' WHERE account_type IS NULL OR account_type=''`); } catch (e) {}
// 外部账号关联表(见 Architecture/外部账号数据结构设计.md)
db.exec(`
CREATE TABLE IF NOT EXISTS account_videos (
id INTEGER PRIMARY KEY,
account_id INTEGER,
aweme_id TEXT,
video_title TEXT,
video_url TEXT,
like_count INTEGER DEFAULT 0,
like_display TEXT,
comment_count INTEGER DEFAULT 0,
share_count INTEGER DEFAULT 0,
collect_count INTEGER DEFAULT 0,
play_count INTEGER DEFAULT 0,
duration TEXT,
publish_time TEXT,
tags TEXT,
collected_time TEXT,
source_dir TEXT,
topic TEXT,
UNIQUE(account_id, aweme_id)
);
CREATE TABLE IF NOT EXISTS account_video_analysis (
id INTEGER PRIMARY KEY,
video_id INTEGER,
aweme_id TEXT,
analysis_time TEXT,
content_json TEXT,
summary TEXT
);
CREATE TABLE IF NOT EXISTS account_video_source (
id INTEGER PRIMARY KEY,
video_id INTEGER,
analysis_time TEXT,
content_json TEXT,
summary TEXT
);
CREATE TABLE IF NOT EXISTS account_persona (
id INTEGER PRIMARY KEY,
account_id INTEGER,
analysis_time TEXT,
content_json TEXT,
summary TEXT
);
CREATE TABLE IF NOT EXISTS account_analysis (
id INTEGER PRIMARY KEY,
account_id INTEGER,
analysis_time TEXT,
content_json TEXT,
summary TEXT
);
CREATE TABLE IF NOT EXISTS creative_log (
id INTEGER PRIMARY KEY,
created_time TEXT,
source TEXT
);
CREATE TABLE IF NOT EXISTS rewrite_log (
id INTEGER PRIMARY KEY,
video_id INTEGER,
aweme_id TEXT,
account_id INTEGER,
script_text TEXT,
topic TEXT,
status TEXT DEFAULT 'pending',
created_time TEXT,
updated_time TEXT
);
CREATE TABLE IF NOT EXISTS storyboard_log (
id INTEGER PRIMARY KEY,
video_id INTEGER,
rewrite_id INTEGER,
script_text TEXT,
status TEXT DEFAULT 'pending',
created_time TEXT,
updated_time TEXT
);
CREATE TABLE IF NOT EXISTS script_review (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
rewrite_id INTEGER,
video_id INTEGER,
dims_json TEXT,
total TEXT,
verdict TEXT,
issues_json TEXT,
content TEXT,
summary TEXT,
status TEXT DEFAULT 'pending',
created_time TEXT,
updated_time TEXT
);
CREATE INDEX IF NOT EXISTS idx_script_review_type ON script_review(type);
CREATE INDEX IF NOT EXISTS idx_script_review_rewrite ON script_review(rewrite_id);
CREATE INDEX IF NOT EXISTS idx_script_review_video ON script_review(video_id);
`);
// 存量迁移:老库 account_videos 缺 topic 列(选题,从源视频解析 json 提取)
try {
const tcols = db.prepare(`PRAGMA table_info(account_videos)`).all().map((c) => c.name);
if (!tcols.includes("topic")) db.exec(`ALTER TABLE account_videos ADD COLUMN topic TEXT`);
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量迁移:rewrite_log 缺 title 列(AI 写作脚本标题,从脚本首行提取)
try {
const rwcols = db.prepare(`PRAGMA table_info(rewrite_log)`).all().map((c) => c.name);
if (!rwcols.includes("title")) db.exec(`ALTER TABLE rewrite_log ADD COLUMN title TEXT`);
// 存量回填:仅 title 为空且脚本非空时,从脚本首行提取(幂等)
for (const row of db.prepare(`SELECT id, script_text FROM rewrite_log WHERE (title IS NULL OR title = '') AND script_text IS NOT NULL AND script_text <> ''`).all()) {
const title = extractScriptTitle(row.script_text);
if (title) db.prepare(`UPDATE rewrite_log SET title=? WHERE id=?`).run(title, row.id);
}
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量迁移:account_video_analysis 缺 aweme_id 列(拆解分析关联视频ID,业务主键)
try {
const acols = db.prepare(`PRAGMA table_info(account_video_analysis)`).all().map((c) => c.name);
if (!acols.includes("aweme_id")) db.exec(`ALTER TABLE account_video_analysis ADD COLUMN aweme_id TEXT`);
db.exec(`UPDATE account_video_analysis SET aweme_id = (SELECT aweme_id FROM account_videos WHERE id = account_video_analysis.video_id) WHERE aweme_id IS NULL AND video_id IS NOT NULL`);
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量迁移:account_video_source 缺 aweme_id 列(原视频解析关联视频ID,业务主键)
try {
const scols = db.prepare(`PRAGMA table_info(account_video_source)`).all().map((c) => c.name);
if (!scols.includes("aweme_id")) db.exec(`ALTER TABLE account_video_source ADD COLUMN aweme_id TEXT`);
if (!scols.includes("analysis_json")) db.exec(`ALTER TABLE account_video_source ADD COLUMN analysis_json TEXT`);
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量迁移:hot_accounts 缺 is_ai 列(是否AI账号,默认「否」,赛道字段后展示)
try {
const hcols = db.prepare(`PRAGMA table_info(hot_accounts)`).all().map((c) => c.name);
if (!hcols.includes("is_ai")) db.exec(`ALTER TABLE hot_accounts ADD COLUMN is_ai TEXT DEFAULT '否'`);
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量迁移:hot_accounts 缺 del_flag 列(软删标记,同步 V1.0 工作台:批量删除账号=软删+连坐清关联)
try {
const hdcols = db.prepare(`PRAGMA table_info(hot_accounts)`).all().map((c) => c.name);
if (!hdcols.includes("del_flag")) db.exec(`ALTER TABLE hot_accounts ADD COLUMN del_flag INTEGER DEFAULT 0`);
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量迁移:rewrite_log 缺 gen_method 列(生成方式:ref=参考选题创作 / new=原创选题创作 / abc=ABC融合创作;
// 同步 V1.0 工作台 v25 三 tab 口径;历史行按 video_id 是否为空回填)
try {
const gwcols = db.prepare(`PRAGMA table_info(rewrite_log)`).all().map((c) => c.name);
if (!gwcols.includes("gen_method")) db.exec(`ALTER TABLE rewrite_log ADD COLUMN gen_method TEXT DEFAULT 'ref'`);
db.exec(`UPDATE rewrite_log SET gen_method='new' WHERE (video_id IS NULL OR video_id = 0) AND (gen_method IS NULL OR gen_method = '')`);
db.exec(`UPDATE rewrite_log SET gen_method='ref' WHERE (video_id IS NOT NULL AND video_id > 0) AND (gen_method IS NULL OR gen_method = '')`);
} catch (e) { /* 迁移失败不阻塞 */ }
// 存量清洗:赛道值去掉「赛道」后缀(如「生活vlog赛道」→「生活vlog」,全库统一;幂等)
try {
db.exec(`UPDATE hot_accounts SET track = REPLACE(track, '赛道', '') WHERE track LIKE '%赛道%'`);
} catch (e) { /* 清洗失败不阻塞 */ }
// 存量修复:source 按 content_json 标题匹配当前 account_videos,回填 aweme_id 并修正错位 video_id
// (早期视频重建后自增 id 失效,旧记录指向已删除视频;aweme_id 为业务主键不受重建影响)
try { backfillSourceAwemeIds(db); } catch (e) { console.warn(`[dsh-plugin-mcn] source 回填失败: ${e.message}`); }
return db;
}
function backfillSourceAwemeIds(d) {
// 补 analysis_json(每次启动执行,不依赖 aweme_id 回填):文件系统里存在 analysis.json 的记录(按账号内标题匹配)
try {
for (const acc of d.prepare(`SELECT id, account_name, source_dir FROM hot_accounts`).all()) {
for (const dir of resolveAccountDirs(acc)) {
const vsDir = join(dir, "视频对标");
if (!existsSync(vsDir)) continue;
for (const e of readdirSync(vsDir, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
const vDir = join(vsDir, e.name);
const ap = join(vDir, "analysis.json");
if (!existsSync(ap)) continue;
const vd = findVideoByFolder(d, acc.id, vDir, e.name);
if (!vd) continue;
const row = d.prepare(`SELECT id FROM account_video_source WHERE aweme_id=? OR video_id=?`).get(vd.aweme_id, vd.id);
if (row) {
const aj = readFileSync(ap, "utf8");
d.prepare(`UPDATE account_video_source SET analysis_json=? WHERE id=?`).run(aj, row.id);
}
}
}
}
} catch (e) { console.warn(`[dsh-plugin-mcn] analysis_json 补录失败: ${e.message}`); }
const rows = d.prepare(`SELECT id, video_id, aweme_id, content_json FROM account_video_source`).all();
const missing = rows.filter((r) => !r.aweme_id);
if (missing.length === 0) return 0;
const all = d.prepare(`SELECT id, aweme_id, video_title FROM account_videos`).all();
const find = (title) => {
const t = String(title || "").trim();
if (!t) return null;
const exact = all.find((v) => String(v.video_title || "") === t);
if (exact) return exact;
const tn = normTitle(t);
if (!tn) return null;
let best = null;
for (const v of all) {
const cn = normTitle(v.video_title);
if (!cn || cn.length < 6 || tn.length < 6) continue;
if (tn.includes(cn) || cn.includes(tn)) {
if (!best || tn.length > best._len) { best = { ...v, _len: tn.length }; }
}
}
if (best) return best;
for (const v of all) {
const cn = normTitle(v.video_title);
if (!cn) continue;
const ratio = 1 - titleDistance(tn, cn) / Math.max(tn.length, cn.length, 1);
if (ratio > 0.8 && (!best || ratio > best._ratio)) { best = { ...v, _ratio: ratio }; }
}
return best;
};
let fixed = 0;
for (const r of missing) {
let title = null;
try {
const jd = JSON.parse(r.content_json);
title = jd && jd.title != null ? String(jd.title) : null;
} catch (e) { /* 单个解析失败跳过 */ }
const hit = title ? find(title) : null;
if (hit && hit.aweme_id) {
d.prepare(`UPDATE account_video_source SET aweme_id=?, video_id=? WHERE id=?`).run(hit.aweme_id, hit.id, r.id);
fixed++;
}
}
// 补 analysis_json 完成(已上移至函数开头执行,保证每次启动都补录)
if (fixed > 0) console.log(`[dsh-plugin-mcn] source 表回填 ${fixed}/${missing.length} 条 aweme_id(标题匹配修复错位)`);
return fixed;
}
export { initDb, backfillSourceAwemeIds, extractScriptTitle };