本提交为 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 本地已不存在,本提交记为删除
300 lines
13 KiB
JavaScript
300 lines
13 KiB
JavaScript
// 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 };
|