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 本地已不存在,本提交记为删除
This commit is contained in:
commit
856e632807
880 files changed
+225426
No files matched your search
+3046
File diff suppressed because it is too large.
Load diff
+87
@@ -0,0 +1,87 @@
|
||||
// dsh-plugin-mcn 配置常量(拆分自 index.js,纯常量无业务逻辑)
|
||||
import { homedir } from "node:os";
|
||||
import { join, isAbsolute, resolve } from "node:path";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
|
||||
const DB_PATH = join(homedir(), ".dsh", "mcn-plugin.db");
|
||||
// 数据预览统计缓存(60s TTL,避免每次从数据库刷新)
|
||||
const STATS_TTL = 60 * 1000;
|
||||
// 创作专用会话:固定命名,已存在则复用
|
||||
const CREATIVE_SESSION_ID = "dsh_short-video-script";
|
||||
// 创作 agent 的模型配置(与 web profile 默认一致,避免 {{model}} 变量无值)
|
||||
const CREATIVE_PROVIDER = "deepseek-official";
|
||||
const CREATIVE_MODEL = "deepseek-v4-flash";
|
||||
const MCP_PACKAGE = "myai-mcp";
|
||||
const MCP_ENV = {
|
||||
MYAI_ENV: "prod",
|
||||
MYAI_API_BASE_URL: "https://maiya-trans.youmanvideo.com/api",
|
||||
MYAI_FEISHU_APP_ID: "cli_a84d0a47f93e1013",
|
||||
MYAI_VOD_URL_PREFIX: "https://maiya-ai-fileup.youmanvideo.com",
|
||||
MYAI_VOD_SPACE_NAME: "maiya-ai",
|
||||
};
|
||||
const NPX_CMD = process.platform === "win32"
|
||||
? '"D:\\Program Files\\nodejs\\npx.cmd"'
|
||||
: "npx";
|
||||
|
||||
function resolveCreativeCwd() {
|
||||
try {
|
||||
const raw = readFileSync(join(homedir(), ".dsh", "storages", "workspace.json"), "utf8");
|
||||
const data = JSON.parse(raw);
|
||||
const workspaces = data?.tables?.workspaces;
|
||||
if (workspaces) {
|
||||
const candidates = Object.values(workspaces)
|
||||
.filter((ws) => typeof ws?.path === "string" && existsSync(ws.path))
|
||||
.sort((a, b) => String(a.path).split(/[\\/]/).length - String(b.path).split(/[\\/]/).length);
|
||||
if (candidates.length > 0) return candidates[0].path;
|
||||
}
|
||||
} catch (e) { /* fall through */ }
|
||||
return "D:\\dshworkspace";
|
||||
}
|
||||
|
||||
/** 并发浏览器任务隔离:调试 Chrome 端口池(需预启动对应实例),按会话 id 稳定分配。
|
||||
* 导入/刷新等浏览器类任务各自使用独立端口,避免多个 AI 会话抢同一 Chrome。 */
|
||||
const CHROME_PORTS = [9223, 9224, 9225];
|
||||
|
||||
const IMPORT_PREFIX = "dsh_import-";
|
||||
const REFRESH_PREFIX = "dsh_refresh-";
|
||||
const VIDEO_ANALYZE_PREFIX = "dsh_video-analyze-";
|
||||
const REWRITE_PREFIX = "dsh_rewrite-";
|
||||
const PERSONA_PREFIX = "dsh_persona-";
|
||||
const STORYBOARD_PREFIX = "dsh_storyboard-";
|
||||
const REVIEW_PREFIX = "dsh_review-";
|
||||
// 账号数据分析会话前缀(V1.0 同步:账号详情「数据分析」tab 任务,每账号独立会话,归「解析任务」分组)
|
||||
const ACCT_ANALYSIS_PREFIX = "dsh_acctana-";
|
||||
const VIDEO_ANALYZE_MAX_CONCURRENT = 8;
|
||||
const REWRITE_MAX_CONCURRENT = 8;
|
||||
const STORYBOARD_MAX_CONCURRENT = 8;
|
||||
const REVIEW_MAX_CONCURRENT = 4;
|
||||
const MAX_UNGROUPED = 10;
|
||||
const SESSION_ACTIVE_WINDOW_MS = 15 * 60 * 1000;
|
||||
// MCN 任务会话自动删除:最后活动时间距今超过该时长(24h)且不在运行中的 dsh_* 会话,物理删除会话记录
|
||||
const SESSION_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// ---- 榜单更新任务(同步 V1.0 工作台 09-03 机制:30min 防重复更新窗口,可 force 跳过;红狐 API 按次计费)----
|
||||
const RANK_UPDATE_COOLDOWN_MS = 30 * 60 * 1000;
|
||||
// 榜单更新专用会话(固定命名,与创作会话同模式:已存在复用、followup 触发)
|
||||
const RANK_SESSION_ID = "dsh_rank-update";
|
||||
// 榜单更新元数据(跨重启持久化:lastAt/running/lastOk/lastError)
|
||||
const RANK_META_FILE = join(homedir(), ".dsh", "mcn-rank-update.json");
|
||||
// 榜单更新任务超时兜底:提交超过该时长仍标记 running → 视为中断(自愈置回空闲)
|
||||
const RANK_TASK_MAX_MS = 60 * 60 * 1000;
|
||||
|
||||
/** 赛道标准枚举(28 个标准,其中「化妆美容」统一为「美妆」+ 剧情 1 个用户指定保留,共 29 个;权威定义见 dsh-plugin-dev/references/赛道标准枚举.md 与技能 SKILL.md) */
|
||||
const STANDARD_TRACKS = [
|
||||
"个人才艺", "生活vlog", "财富理财", "二次元", "居家装修", "学习教育", "小剧场", "数码科技",
|
||||
"旅行", "美食", "美妆", "动物", "亲子", "汽车", "情感", "三农", "健康医学", "潮流风尚",
|
||||
"舞蹈才艺", "颜值造型", "人文", "音乐", "影视", "身体锻炼", "体育", "明星娱乐", "游戏", "其他",
|
||||
"剧情",
|
||||
];
|
||||
|
||||
export {
|
||||
DB_PATH, STATS_TTL, CREATIVE_SESSION_ID, CREATIVE_PROVIDER, CREATIVE_MODEL,
|
||||
MCP_PACKAGE, MCP_ENV, NPX_CMD, CHROME_PORTS,
|
||||
IMPORT_PREFIX, REFRESH_PREFIX, VIDEO_ANALYZE_PREFIX, REWRITE_PREFIX, PERSONA_PREFIX, STORYBOARD_PREFIX, REVIEW_PREFIX, ACCT_ANALYSIS_PREFIX,
|
||||
VIDEO_ANALYZE_MAX_CONCURRENT, REWRITE_MAX_CONCURRENT, STORYBOARD_MAX_CONCURRENT, REVIEW_MAX_CONCURRENT, MAX_UNGROUPED,
|
||||
SESSION_ACTIVE_WINDOW_MS, SESSION_RETENTION_MS, STANDARD_TRACKS, resolveCreativeCwd,
|
||||
RANK_UPDATE_COOLDOWN_MS, RANK_SESSION_ID, RANK_META_FILE, RANK_TASK_MAX_MS,
|
||||
};
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// dsh-plugin-mcn 数据访问层(拆分自 index.js)
|
||||
import { initDb } from "./db.js";
|
||||
import { callMcpTool } from "./mcp.js";
|
||||
import { STANDARD_TRACKS } from "./config.js";
|
||||
|
||||
async function ensureData() {
|
||||
const d = initDb();
|
||||
const row = d.prepare("SELECT COUNT(*) AS c FROM hot_accounts").get();
|
||||
return { cached: row.c > 0, count: row.c };
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
const d = initDb();
|
||||
const result = await callMcpTool("list_hot_accounts", {});
|
||||
// 返回结构:{ structured: {total, accounts: [...]} | null, text: "..." }
|
||||
let accounts = null;
|
||||
if (result?.structured && Array.isArray(result.structured.accounts)) {
|
||||
accounts = result.structured.accounts;
|
||||
}
|
||||
if (!accounts) {
|
||||
throw new Error("MCP 返回缺少结构化账号数组(可能未登录或服务异常),无法入库");
|
||||
}
|
||||
const insert = d.prepare(`INSERT OR REPLACE INTO hot_accounts
|
||||
(id, account_name, content, track, tags, masterpiece, created_by, updated_by, created_time, updated_time, sort, del_flag, account_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'signed')`);
|
||||
d.exec("BEGIN");
|
||||
try {
|
||||
for (const a of accounts) {
|
||||
insert.run(
|
||||
a.id, a.accountName ?? "", a.content ?? "", a.track ?? "",
|
||||
a.tags ?? "", a.masterpiece ?? "", a.createdBy ?? "", a.updatedBy ?? "",
|
||||
a.createdTime ?? "", a.updatedTime ?? "", a.sort ?? 0, a.delFlag ?? 0
|
||||
);
|
||||
}
|
||||
d.exec("COMMIT");
|
||||
} catch (e) {
|
||||
d.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
return { cached: false, count: accounts.length };
|
||||
}
|
||||
|
||||
function queryAccounts({ page = 1, pageSize = 20, search = "", track = "", type = "all", sortBy = "id", order = "asc" }) {
|
||||
const d = initDb();
|
||||
const where = [];
|
||||
const params = [];
|
||||
// 软删账号不展示(同步 V1.0 工作台:批量删除=del_flag=1)
|
||||
where.push("(del_flag IS NULL OR del_flag <> 1)");
|
||||
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);
|
||||
}
|
||||
// 账号类型筛选:all / signed(签约)/ external(外部)
|
||||
if (type === "signed" || type === "external") {
|
||||
where.push("account_type = ?");
|
||||
params.push(type);
|
||||
}
|
||||
const whereSql = where.length ? "WHERE " + where.join(" AND ") : "";
|
||||
const allowedSort = ["id", "account_name", "track", "sort"];
|
||||
const sortCol = allowedSort.includes(sortBy) ? sortBy : "id";
|
||||
const orderSql = order === "desc" ? "DESC" : "ASC";
|
||||
const total = d.prepare(`SELECT COUNT(*) AS c FROM hot_accounts ${whereSql}`).get(...params).c;
|
||||
const limit = Math.max(1, Number(pageSize) || 20);
|
||||
const offset = (Math.max(1, Number(page) || 1) - 1) * limit;
|
||||
// 排序:收藏的账号排最前,收藏/未收藏各自内部按粉丝数降序(followers 支持 "万"/"亿" 格式解析为数值)
|
||||
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`;
|
||||
// 附带视频数(account_videos 关联统计,供前端「视频数」列展示)与 AI脚本数(挂该账号视频的 ref 脚本 + video_id 空的原创脚本,同步 V1.0 账号列表「AI脚本」列口径)
|
||||
const rows = 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.status='done' AND r.script_text IS NOT NULL AND r.script_text <> '' AND ((r.video_id > 0 AND r.video_id IN (SELECT id FROM account_videos v2 WHERE v2.account_id = h.id)) OR ((r.video_id IS NULL OR r.video_id = 0) AND r.account_id = h.id))) 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: rows };
|
||||
}
|
||||
|
||||
function listTracks() {
|
||||
const d = initDb();
|
||||
const dbTracks = d.prepare(`SELECT DISTINCT track FROM hot_accounts WHERE track IS NOT NULL AND track != '' ORDER BY track`).all().map((r) => r.track);
|
||||
// 标准 28 个 + 库内非标值并集(保证旧数据可筛选)
|
||||
const extras = dbTracks.filter((t) => !STANDARD_TRACKS.includes(t));
|
||||
return STANDARD_TRACKS.concat(extras);
|
||||
}
|
||||
|
||||
function queryAccountVideos(accountId, page = 1, pageSize = 20) {
|
||||
const d = initDb();
|
||||
const acc = d.prepare(`SELECT * FROM hot_accounts WHERE id=? AND (del_flag IS NULL OR del_flag <> 1)`).get(accountId);
|
||||
if (!acc) throw new Error("账号不存在");
|
||||
const total = d.prepare(`SELECT COUNT(*) AS 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.id ASC LIMIT ? OFFSET ?
|
||||
`).all(accountId, limit, offset);
|
||||
return { account: acc, total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
|
||||
}
|
||||
|
||||
export { ensureData, refreshData, queryAccounts, listTracks, queryAccountVideos };
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
// 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 };
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
// dsh-plugin-mcn HTTP 路由工具(拆分自 index.js)
|
||||
|
||||
function parseQuery(url) {
|
||||
const u = new URL(url, "http://localhost");
|
||||
const out = {};
|
||||
for (const [k, v] of u.searchParams) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
// 收集 Buffer 后一次性按 UTF-8 解码:逐 chunk 直接拼字符串会独立解码各 chunk,
|
||||
// 多字节字符横跨 chunk 边界时被损坏为 U+FFFD(大体积 data 导入必现)
|
||||
const chunks = [];
|
||||
req.on("data", (c) => { chunks.push(c); });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const data = Buffer.concat(chunks).toString("utf8");
|
||||
resolvePromise(data ? JSON.parse(data) : {});
|
||||
} catch (e) {
|
||||
reject(new Error("请求体 JSON 解析失败:请以 UTF-8 发送 JSON。若用 PowerShell 调用,勿用 Invoke-RestMethod 直接传含中文的 -Body 字符串(Windows PowerShell 5.1 会按 GBK 编码导致此错误),请改用 curl.exe --data-binary @文件 或 [System.Text.Encoding]::UTF8.GetBytes() 字节体"));
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
export { parseQuery, json, readBody };
|
||||
+592
@@ -0,0 +1,592 @@
|
||||
// dsh-plugin-mcn 外部账号导入/读取(拆分自 index.js)
|
||||
import { join } from "node:path";
|
||||
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
||||
import XLSX from "xlsx";
|
||||
import { initDb } from "./db.js";
|
||||
import { num, normTitle, extractTopic, titleDistance, findVideoByFolder, resolveAccountDirs, resolveAccountDir } from "./video-tools.js";
|
||||
|
||||
function isInternalAccount(d, name, douyinId) {
|
||||
if (douyinId) {
|
||||
const byDid = d.prepare(`SELECT id, account_name, douyin_id FROM hot_accounts WHERE account_type='signed' AND douyin_id=?`).get(String(douyinId));
|
||||
if (byDid) return byDid;
|
||||
}
|
||||
if (name) {
|
||||
const byName = d.prepare(`SELECT id, account_name, douyin_id FROM hot_accounts WHERE account_type='signed' AND account_name=?`).get(name);
|
||||
if (byName) return byName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function upsertExternalAccount(d, info, sourceDir) {
|
||||
// 兼容两种字段名:网页采集路径用 nickname(账号信息.json),data 模式导入用 account_name(payload)
|
||||
const name = info.nickname || info.account_name || "";
|
||||
const did = info.douyin_id != null ? String(info.douyin_id) : null;
|
||||
// 结合 MCP:同名/同抖音号命中内部签约账号 → 提示(不阻断,供调用方/用户识别)
|
||||
const signed = isInternalAccount(d, name, did);
|
||||
if (signed) {
|
||||
console.warn(`[dsh-plugin-mcn] 账号「${name || did}」命中内部签约账号(signed id=${signed.id}),可能非外部达人,请确认`);
|
||||
}
|
||||
const existing = did
|
||||
? d.prepare(`SELECT id FROM hot_accounts WHERE del_flag=0 AND douyin_id=?`).get(did)
|
||||
: d.prepare(`SELECT id FROM hot_accounts WHERE del_flag=0 AND account_name=?`).get(name);
|
||||
const vals = {
|
||||
account_name: name,
|
||||
content: info.content || info.bio || "",
|
||||
track: info.track || null,
|
||||
douyin_id: did,
|
||||
sec_uid: info.sec_uid != null ? String(info.sec_uid) : null,
|
||||
followers: info.followers != null ? String(info.followers) : null,
|
||||
total_likes: info.total_likes != null ? String(info.total_likes) : null,
|
||||
works_count: info.works_count != null ? Number(info.works_count) : null,
|
||||
ip_location: info.ip_location || null,
|
||||
location: info.location || null,
|
||||
age: info.age != null ? Number(info.age) : null,
|
||||
bio: info.bio || null,
|
||||
source_dir: sourceDir || null,
|
||||
};
|
||||
if (existing) {
|
||||
d.prepare(`UPDATE hot_accounts SET account_name=?, content=?, track=?, douyin_id=?, sec_uid=?, followers=?, total_likes=?, works_count=?, ip_location=?, location=?, age=?, bio=?, source_dir=? WHERE id=?`)
|
||||
.run(vals.account_name, vals.content, vals.track, vals.douyin_id, vals.sec_uid, vals.followers, vals.total_likes, vals.works_count, vals.ip_location, vals.location, vals.age, vals.bio, vals.source_dir, existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
const r = d.prepare(`INSERT INTO hot_accounts (account_name, content, account_type, track, douyin_id, sec_uid, followers, total_likes, works_count, ip_location, location, age, bio, source_dir) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(vals.account_name, vals.content, "external", vals.track, vals.douyin_id, vals.sec_uid, vals.followers, vals.total_likes, vals.works_count, vals.ip_location, vals.location, vals.age, vals.bio, vals.source_dir);
|
||||
return Number(r.lastInsertRowid);
|
||||
}
|
||||
|
||||
function importVideosFromXlsx(d, xlsxPath, accountId, sourceDir) {
|
||||
const wb = XLSX.readFile(xlsxPath);
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });
|
||||
if (!rows.length) return 0;
|
||||
const header = rows[0].map((h) => String(h ?? "").trim().toLowerCase());
|
||||
// 列名别名匹配(外部表格表头可能用不同写法)
|
||||
const findCol = (...aliases) => header.findIndex((h) => aliases.includes(h));
|
||||
const iId = findCol("视频id", "aweme_id", "视频id(显示)"), iTitle = findCol("视频标题", "标题", "video_title"), iUrl = findCol("视频地址", "视频链接", "video_url"),
|
||||
iLike = findCol("点赞数", "点赞", "like_count"), iLikeD = findCol("点赞(显示)", "点赞显示"),
|
||||
iCmt = findCol("评论数", "评论", "comment_count"), iShare = findCol("分享数", "分享", "share_count"),
|
||||
iCollect = findCol("收藏数", "收藏", "collect_count"), iPlay = findCol("播放量", "播放", "play_count"),
|
||||
iDur = findCol("视频时长", "时长", "duration"), iPub = findCol("发布时间", "时间", "publish_time"),
|
||||
iTags = findCol("标签", "tags");
|
||||
// upsert:保留行 id(REPLACE 会删旧插新导致 id 漂移,拆解分析 video_id 关联断裂)
|
||||
const ins = d.prepare(`INSERT INTO account_videos
|
||||
(account_id, aweme_id, video_title, video_url, like_count, like_display, comment_count, share_count, collect_count, play_count, duration, publish_time, tags, collected_time, source_dir)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(account_id, aweme_id) DO UPDATE SET
|
||||
video_title=excluded.video_title, video_url=excluded.video_url,
|
||||
like_count=excluded.like_count, like_display=excluded.like_display,
|
||||
comment_count=excluded.comment_count, share_count=excluded.share_count,
|
||||
collect_count=excluded.collect_count, play_count=excluded.play_count,
|
||||
duration=excluded.duration, publish_time=excluded.publish_time,
|
||||
tags=excluded.tags, collected_time=excluded.collected_time, source_dir=excluded.source_dir`);
|
||||
const now = new Date().toISOString();
|
||||
let n = 0;
|
||||
for (let r = 1; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
if (!row || row[iId] == null) continue;
|
||||
ins.run(accountId, String(row[iId]),
|
||||
row[iTitle] != null ? String(row[iTitle]) : "",
|
||||
row[iUrl] != null ? String(row[iUrl]) : "",
|
||||
num(row[iLike]), row[iLikeD] != null ? String(row[iLikeD]) : "",
|
||||
num(row[iCmt]), num(row[iShare]), num(row[iCollect]), num(row[iPlay]),
|
||||
row[iDur] != null ? String(row[iDur]) : "",
|
||||
row[iPub] != null ? String(row[iPub]) : "",
|
||||
row[iTags] != null ? String(row[iTags]) : "",
|
||||
now, sourceDir);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function readVideoSource(videoId) {
|
||||
const d = initDb();
|
||||
const v = d.prepare(`
|
||||
SELECT v.*, h.source_dir AS acc_dir, h.account_name AS acc_name FROM account_videos v
|
||||
LEFT JOIN hot_accounts h ON h.id = v.account_id WHERE v.id=?
|
||||
`).get(videoId);
|
||||
if (!v) return { found: false };
|
||||
// 优先查库(account_video_source,按视频ID或自增 id 双键关联)
|
||||
const row = d.prepare(
|
||||
`SELECT content_json AS content, analysis_time FROM account_video_source WHERE video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?) ORDER BY id DESC LIMIT 1`
|
||||
).get(videoId, v.aweme_id);
|
||||
if (row && row.content) return { found: true, content: row.content, fileName: "db" };
|
||||
const vt = String(v.video_title || "").trim();
|
||||
const vn = vt ? normTitle(vt) : "";
|
||||
const match = (title, dirName) => {
|
||||
const t = String(title || "").trim();
|
||||
if (!t) return false;
|
||||
if (vt && t === vt) return true;
|
||||
const tn = normTitle(t);
|
||||
if (tn && vn && tn === vn) return true;
|
||||
if (tn && vn && tn.length >= 6 && vn.length >= 6 && (tn.includes(vn) || vn.includes(tn))) return true;
|
||||
if (tn && vn && vn.length >= 6) {
|
||||
const ratio = 1 - titleDistance(tn, vn) / Math.max(tn.length, vn.length, 1);
|
||||
if (ratio > 0.8) return true;
|
||||
}
|
||||
const first = String(dirName || "").split(/[__]/)[0].trim();
|
||||
if (first && normTitle(first) && normTitle(first) === vn) return true;
|
||||
return false;
|
||||
};
|
||||
const bases = [...resolveAccountDirs({ source_dir: v.acc_dir, account_name: v.acc_name }), v.source_dir]
|
||||
.filter((x) => typeof x === "string" && x);
|
||||
for (const base of bases) {
|
||||
const vsDir = join(base, "视频对标");
|
||||
if (!existsSync(vsDir)) continue;
|
||||
for (const entry of readdirSync(vsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const vDir = join(vsDir, entry.name);
|
||||
for (const jf of ["原视频解析.json", "content.json"]) {
|
||||
const jp = join(vDir, jf);
|
||||
if (!existsSync(jp)) continue;
|
||||
try {
|
||||
const jd = JSON.parse(readFileSync(jp, "utf8"));
|
||||
if (match(jd.title, entry.name)) {
|
||||
const content = readFileSync(jp, "utf8");
|
||||
// 补录进库(幂等:同视频+同内容去重;带 aweme_id 业务主键;同时补 analysis_json)
|
||||
try {
|
||||
const dup = d.prepare(`SELECT id FROM account_video_source WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(videoId, v.aweme_id, content);
|
||||
const ap = join(vDir, "analysis.json");
|
||||
let analysisJson = null;
|
||||
if (existsSync(ap)) {
|
||||
try { analysisJson = readFileSync(ap, "utf8"); } catch (e) { /* analysis 读取失败忽略 */ }
|
||||
}
|
||||
if (!dup) {
|
||||
d.prepare(`INSERT INTO account_video_source (video_id, aweme_id, analysis_time, content_json, summary, analysis_json) VALUES (?,?,?,?,?,?)`)
|
||||
.run(videoId, v.aweme_id, statSync(jp).mtime.toISOString(), content, "", analysisJson);
|
||||
} else if (analysisJson) {
|
||||
d.prepare(`UPDATE account_video_source SET analysis_json=? WHERE id=?`).run(analysisJson, dup.id);
|
||||
}
|
||||
// 选题提取:json 的 content 字段中「# 选题」章节 → account_videos.topic
|
||||
const topic = extractTopic(jd.content);
|
||||
if (topic) d.prepare(`UPDATE account_videos SET topic=? WHERE id=?`).run(topic, videoId);
|
||||
} catch (e) { /* 补录失败不影响本次返回 */ }
|
||||
return { found: true, content, fileName: jf };
|
||||
}
|
||||
} catch (e) { /* 单个 json 解析失败不影响后续匹配 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
function readVideoParse(videoId) {
|
||||
const d = initDb();
|
||||
const v = d.prepare(`
|
||||
SELECT v.*, h.source_dir AS acc_dir, h.account_name AS acc_name FROM account_videos v
|
||||
LEFT JOIN hot_accounts h ON h.id = v.account_id WHERE v.id=?
|
||||
`).get(videoId);
|
||||
if (!v) return { found: false };
|
||||
const vt = String(v.video_title || "").trim();
|
||||
const vn = vt ? normTitle(vt) : "";
|
||||
const match = (title, dirName) => {
|
||||
const t = String(title || "").trim();
|
||||
if (!t) return false;
|
||||
if (vt && t === vt) return true;
|
||||
const tn = normTitle(t);
|
||||
if (tn && vn && tn === vn) return true;
|
||||
if (tn && vn && tn.length >= 6 && vn.length >= 6 && (tn.includes(vn) || vn.includes(tn))) return true;
|
||||
if (tn && vn && vn.length >= 6) {
|
||||
const ratio = 1 - titleDistance(tn, vn) / Math.max(tn.length, vn.length, 1);
|
||||
if (ratio > 0.8) return true;
|
||||
}
|
||||
const first = String(dirName || "").split(/[__]/)[0].trim();
|
||||
if (first && normTitle(first) && normTitle(first) === vn) return true;
|
||||
return false;
|
||||
};
|
||||
const bases = [...resolveAccountDirs({ source_dir: v.acc_dir, account_name: v.acc_name }), v.source_dir]
|
||||
.filter((x) => typeof x === "string" && x);
|
||||
for (const base of bases) {
|
||||
const vsDir = join(base, "视频对标");
|
||||
if (!existsSync(vsDir)) continue;
|
||||
for (const entry of readdirSync(vsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const vDir = join(vsDir, entry.name);
|
||||
const jp = join(vDir, "content.json");
|
||||
if (!existsSync(jp)) continue;
|
||||
try {
|
||||
const jd = JSON.parse(readFileSync(jp, "utf8"));
|
||||
if (match(jd.title, entry.name)) {
|
||||
const out = { found: true, content: typeof jd.content === "string" ? jd.content : null, analysis: null, fileName: "content.json" };
|
||||
const ap = join(vDir, "analysis.json");
|
||||
if (existsSync(ap)) {
|
||||
try { out.analysis = JSON.parse(readFileSync(ap, "utf8")); } catch (e) { /* analysis 解析失败不阻塞 */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} catch (e) { /* 单个 json 解析失败不影响后续匹配 */ }
|
||||
}
|
||||
}
|
||||
// 文件未命中时回退查库(content_json = content.json 全文取 content 字段;analysis_json 为 analysis.json 全文)
|
||||
const row = 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(videoId, v.aweme_id);
|
||||
if (row && row.content_json) {
|
||||
try {
|
||||
const jd = JSON.parse(row.content_json);
|
||||
if (jd && typeof jd.content === "string") {
|
||||
let analysis = null;
|
||||
if (row.analysis_json) {
|
||||
try { analysis = JSON.parse(row.analysis_json); } catch (e) { /* analysis 解析失败忽略 */ }
|
||||
}
|
||||
return { found: true, content: jd.content, analysis, fileName: "db" };
|
||||
}
|
||||
} catch (e) { /* 库内解析失败返回未找到 */ }
|
||||
}
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
function backfillVideoSource() {
|
||||
const d = initDb();
|
||||
let added = 0;
|
||||
const accounts = d.prepare(`SELECT id, account_name, source_dir FROM hot_accounts WHERE del_flag=0`).all();
|
||||
for (const acc of accounts) {
|
||||
for (const base of resolveAccountDirs(acc)) {
|
||||
const vsDir = join(base, "视频对标");
|
||||
if (!existsSync(vsDir)) continue;
|
||||
for (const entry of readdirSync(vsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const vDir = join(vsDir, entry.name);
|
||||
const vid = findVideoByFolder(d, acc.id, vDir, entry.name);
|
||||
// analysis.json 补录(与 content 同视频关联;幂等)
|
||||
const ap = join(vDir, "analysis.json");
|
||||
let analysisJson = null;
|
||||
if (existsSync(ap)) {
|
||||
try { analysisJson = readFileSync(ap, "utf8"); } catch (e) { /* 读取失败忽略 */ }
|
||||
}
|
||||
for (const jf of ["原视频解析.json", "content.json"]) {
|
||||
const jp = join(vDir, jf);
|
||||
if (!existsSync(jp)) continue;
|
||||
try {
|
||||
const content = readFileSync(jp, "utf8");
|
||||
// 去重:用 IS 运算符,video_id/aweme_id 为 NULL 时也能正确去重(避免匹配不到视频时每次重复插入)
|
||||
const dup = d.prepare(`SELECT id FROM account_video_source WHERE (video_id IS ? OR aweme_id IS ?) AND content_json=?`).get(vid ? vid.id : null, vid ? vid.aweme_id : null, content);
|
||||
if (!dup) {
|
||||
d.prepare(`INSERT INTO account_video_source (video_id, aweme_id, analysis_time, content_json, summary, analysis_json) VALUES (?,?,?,?,?,?)`)
|
||||
.run(vid ? vid.id : null, vid ? vid.aweme_id : null, statSync(jp).mtime.toISOString(), content, "", analysisJson);
|
||||
added++;
|
||||
} else if (analysisJson && !(d.prepare(`SELECT analysis_json FROM account_video_source WHERE id=?`).get(dup.id)?.analysis_json)) {
|
||||
d.prepare(`UPDATE account_video_source SET analysis_json=? WHERE id=?`).run(analysisJson, dup.id);
|
||||
}
|
||||
// 选题提取:json 的 content 字段中「# 选题」章节 → account_videos.topic
|
||||
if (vid) {
|
||||
try {
|
||||
const jd = JSON.parse(content);
|
||||
const topic = extractTopic(jd.content);
|
||||
if (topic) d.prepare(`UPDATE account_videos SET topic=? WHERE id=?`).run(topic, vid.id);
|
||||
} catch (e) { /* 单个 json 解析失败不影响 */ }
|
||||
}
|
||||
} catch (e) { /* 单个 json 失败不影响后续 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
function importExternalDir(dir) {
|
||||
const d = initDb();
|
||||
let accounts = 0, videos = 0, analyses = 0;
|
||||
if (!existsSync(dir)) throw new Error(`目录不存在: ${dir}`);
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const accountDir = join(dir, entry.name);
|
||||
let accountId = null;
|
||||
const infoFile = join(accountDir, "账号信息.json");
|
||||
if (existsSync(infoFile)) {
|
||||
try {
|
||||
const info = JSON.parse(readFileSync(infoFile, "utf8"));
|
||||
accountId = upsertExternalAccount(d, info, accountDir);
|
||||
accounts++;
|
||||
} catch (e) {
|
||||
console.warn(`[dsh-plugin-mcn] 账号信息解析失败 ${infoFile}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (accountId == null) continue;
|
||||
// 视频列表(xlsx)
|
||||
for (const f of readdirSync(accountDir)) {
|
||||
if (/\.xlsx?$/i.test(f)) {
|
||||
videos += importVideosFromXlsx(d, join(accountDir, f), accountId, accountDir);
|
||||
}
|
||||
}
|
||||
// 账号设定/账号数据分析 md → account_persona / account_analysis
|
||||
// 文件名可能带账号名前缀(如「王微斯账号设定.md」),用包含匹配;同账号+同内容去重
|
||||
for (const md of readdirSync(accountDir)) {
|
||||
const isPersona = /账号设定.*\.md$/i.test(md);
|
||||
const isAnalysis = /账号数据分析.*\.md$/i.test(md);
|
||||
if (!isPersona && !isAnalysis) continue;
|
||||
const p = join(accountDir, md);
|
||||
const content = readFileSync(p, "utf8");
|
||||
const time = statSync(p).mtime.toISOString();
|
||||
if (isPersona) {
|
||||
const dup = d.prepare(`SELECT id FROM account_persona WHERE account_id=? AND content_json=?`).get(accountId, content);
|
||||
if (!dup) {
|
||||
d.prepare(`INSERT INTO account_persona (account_id, analysis_time, content_json, summary) VALUES (?,?,?,?)`).run(accountId, time, content, "");
|
||||
analyses++;
|
||||
}
|
||||
} else {
|
||||
const dup = d.prepare(`SELECT id FROM account_analysis WHERE account_id=? AND content_json=?`).get(accountId, content);
|
||||
if (!dup) {
|
||||
d.prepare(`INSERT INTO account_analysis (account_id, analysis_time, content_json, summary) VALUES (?,?,?,?)`).run(accountId, time, content, "");
|
||||
analyses++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 视频对标/<视频名>/*拆解分析.md → account_video_analysis(同视频+同内容去重)
|
||||
const vsDir = join(accountDir, "视频对标");
|
||||
if (existsSync(vsDir)) {
|
||||
for (const v of readdirSync(vsDir, { withFileTypes: true })) {
|
||||
if (!v.isDirectory()) continue;
|
||||
const vDir = join(vsDir, v.name);
|
||||
for (const mdf of readdirSync(vDir)) {
|
||||
if (!/拆解分析.*\.md$/i.test(mdf)) continue;
|
||||
const mp = join(vDir, mdf);
|
||||
const content = readFileSync(mp, "utf8");
|
||||
const time = statSync(mp).mtime.toISOString();
|
||||
const vid = findVideoByFolder(d, accountId, vDir, v.name);
|
||||
const awemeId = vid ? vid.aweme_id ?? null : null;
|
||||
const dup = d.prepare(`SELECT id FROM account_video_analysis WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(vid ? vid.id : null, awemeId, content);
|
||||
if (!dup) {
|
||||
d.prepare(`INSERT INTO account_video_analysis (video_id, aweme_id, analysis_time, content_json, summary) VALUES (?,?,?,?,?)`)
|
||||
.run(vid ? vid.id : null, awemeId, time, content, "");
|
||||
analyses++;
|
||||
}
|
||||
}
|
||||
// 原视频解析 json → account_video_source(优先原视频解析.json,无则 content.json;同视频+同内容去重)
|
||||
for (const jf of ["原视频解析.json", "content.json"]) {
|
||||
const jp = join(vDir, jf);
|
||||
if (!existsSync(jp)) continue;
|
||||
try {
|
||||
const content = readFileSync(jp, "utf8");
|
||||
const time = statSync(jp).mtime.toISOString();
|
||||
const vid = findVideoByFolder(d, accountId, vDir, v.name);
|
||||
const dup = d.prepare(`SELECT id FROM account_video_source WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(vid ? vid.id : null, vid ? vid.aweme_id : null, content);
|
||||
if (!dup) {
|
||||
d.prepare(`INSERT INTO account_video_source (video_id, aweme_id, analysis_time, content_json, summary) VALUES (?,?,?,?,?)`)
|
||||
.run(vid ? vid.id : null, vid ? vid.aweme_id : null, time, content, "");
|
||||
analyses++;
|
||||
}
|
||||
// 选题提取:json 的 content 字段中「# 选题」章节,单独存 account_videos.topic(对标创作选题素材)
|
||||
if (vid) {
|
||||
try {
|
||||
const jd = JSON.parse(content);
|
||||
const topic = extractTopic(jd.content);
|
||||
if (topic) {
|
||||
d.prepare(`UPDATE account_videos SET topic=? WHERE id=?`).run(topic, vid.id);
|
||||
}
|
||||
} catch (e) { /* 单个 json 解析失败不影响 */ }
|
||||
}
|
||||
} catch (e) { /* 单个 json 解析失败不影响后续导入 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { accounts, videos, analyses };
|
||||
}
|
||||
|
||||
function importExternalData(d, data) {
|
||||
let accounts = 0, videos = 0, analyses = 0;
|
||||
const accCache = {};
|
||||
const findAccount = (name) => {
|
||||
if (!name) return null;
|
||||
if (accCache[name]) return accCache[name];
|
||||
const row = d.prepare(`SELECT id FROM hot_accounts WHERE del_flag=0 AND account_name=?`).get(name);
|
||||
accCache[name] = row ? row.id : null;
|
||||
return accCache[name];
|
||||
};
|
||||
const now = new Date().toISOString();
|
||||
// 账号
|
||||
for (const a of data.accounts || []) {
|
||||
const id = upsertExternalAccount(d, a, a.source_dir || "");
|
||||
if (a.account_name) accCache[a.account_name] = id;
|
||||
accounts++;
|
||||
}
|
||||
// 视频
|
||||
// upsert:保留行 id(REPLACE 会删旧插新导致 id 漂移,拆解分析 video_id 关联断裂)
|
||||
const insV = d.prepare(`INSERT INTO account_videos
|
||||
(account_id, aweme_id, video_title, video_url, like_count, like_display, comment_count, share_count, collect_count, play_count, duration, publish_time, tags, collected_time, source_dir, topic)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(account_id, aweme_id) DO UPDATE SET
|
||||
video_title=excluded.video_title, video_url=excluded.video_url,
|
||||
like_count=excluded.like_count, like_display=excluded.like_display,
|
||||
comment_count=excluded.comment_count, share_count=excluded.share_count,
|
||||
collect_count=excluded.collect_count, play_count=excluded.play_count,
|
||||
duration=excluded.duration, publish_time=excluded.publish_time,
|
||||
tags=excluded.tags, topic=excluded.topic,
|
||||
collected_time=excluded.collected_time, source_dir=excluded.source_dir`);
|
||||
for (const v of data.videos || []) {
|
||||
const accId = v.account_id != null ? Number(v.account_id) : findAccount(v.account_name);
|
||||
if (!accId || v.aweme_id == null) continue;
|
||||
const tags = Array.isArray(v.tags) ? v.tags.join(",") : (v.tags != null ? String(v.tags) : "");
|
||||
// 选题:优先显式 topic 字段;未提供时尝试从 source 解析提取(兼容 AI 直接传解析 content)
|
||||
let topic = v.topic != null ? String(v.topic) : "";
|
||||
if (!topic && (v.source_content || v.source_json)) {
|
||||
try {
|
||||
const jd = typeof v.source_json === "string" ? JSON.parse(v.source_json) : v.source_json;
|
||||
topic = extractTopic(jd?.content || v.source_content);
|
||||
} catch (e) { /* 提取失败回退空 */ }
|
||||
}
|
||||
insV.run(accId, String(v.aweme_id),
|
||||
v.video_title != null ? String(v.video_title) : "",
|
||||
v.video_url != null ? String(v.video_url) : "",
|
||||
num(v.like_count), v.like_display != null ? String(v.like_display) : "",
|
||||
num(v.comment_count), num(v.share_count), num(v.collect_count), num(v.play_count),
|
||||
v.duration != null ? String(v.duration) : "",
|
||||
v.publish_time != null ? String(v.publish_time) : "",
|
||||
tags, now, v.source_dir || "", topic);
|
||||
videos++;
|
||||
}
|
||||
// 分析(persona / account / video,内容去重)
|
||||
for (const an of data.analyses || []) {
|
||||
const accId = an.account_id != null ? Number(an.account_id) : findAccount(an.account_name);
|
||||
if (!accId || !an.content) continue;
|
||||
const time = an.analysis_time || now;
|
||||
const summary = an.summary || "";
|
||||
if (an.type === "persona") {
|
||||
const dup = d.prepare(`SELECT id FROM account_persona WHERE account_id=? AND content_json=?`).get(accId, an.content);
|
||||
if (!dup) { d.prepare(`INSERT INTO account_persona (account_id, analysis_time, content_json, summary) VALUES (?,?,?,?)`).run(accId, time, an.content, summary); analyses++; }
|
||||
} else if (an.type === "account") {
|
||||
const dup = d.prepare(`SELECT id FROM account_analysis WHERE account_id=? AND content_json=?`).get(accId, an.content);
|
||||
if (!dup) { d.prepare(`INSERT INTO account_analysis (account_id, analysis_time, content_json, summary) VALUES (?,?,?,?)`).run(accId, time, an.content, summary); analyses++; }
|
||||
} else if (an.type === "video") {
|
||||
let vid = null;
|
||||
if (an.video_aweme_id != null) {
|
||||
vid = d.prepare(`SELECT id FROM account_videos WHERE account_id=? AND aweme_id=?`).get(accId, String(an.video_aweme_id));
|
||||
}
|
||||
const dup = d.prepare(`SELECT id FROM account_video_analysis WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(vid ? vid.id : null, an.video_aweme_id != null ? String(an.video_aweme_id) : null, an.content);
|
||||
if (!dup) {
|
||||
// 删旧保新:同一视频只保留最新一份解析(重新解析后旧版本删除)
|
||||
d.prepare(`DELETE FROM account_video_analysis WHERE video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)`).run(vid ? vid.id : null, an.video_aweme_id != null ? String(an.video_aweme_id) : null);
|
||||
d.prepare(`INSERT INTO account_video_analysis (video_id, aweme_id, analysis_time, content_json, summary) VALUES (?,?,?,?,?)`).run(vid ? vid.id : null, an.video_aweme_id != null ? String(an.video_aweme_id) : null, time, an.content, summary);
|
||||
analyses++;
|
||||
}
|
||||
} else if (an.type === "source") {
|
||||
// 原视频解析(原视频解析.json / content.json 内容)
|
||||
let vid = null;
|
||||
if (an.video_aweme_id != null) {
|
||||
vid = d.prepare(`SELECT id, aweme_id FROM account_videos WHERE account_id=? AND aweme_id=?`).get(accId, String(an.video_aweme_id));
|
||||
}
|
||||
const dup = d.prepare(`SELECT id FROM account_video_source WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(vid ? vid.id : null, vid ? vid.aweme_id : null, an.content);
|
||||
if (!dup) {
|
||||
// 删旧保新:同一视频只保留最新一份源脚本解析(重新解析后旧版本删除)
|
||||
d.prepare(`DELETE FROM account_video_source WHERE video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)`).run(vid ? vid.id : null, vid ? vid.aweme_id : null);
|
||||
d.prepare(`INSERT INTO account_video_source (video_id, aweme_id, analysis_time, content_json, summary) VALUES (?,?,?,?,?)`).run(vid ? vid.id : null, vid ? vid.aweme_id : null, time, an.content, summary);
|
||||
analyses++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { accounts, videos, analyses };
|
||||
}
|
||||
|
||||
function previewExternalDir(dir) {
|
||||
const d = initDb();
|
||||
const result = {
|
||||
accounts: { new: [], update: [], same: [] },
|
||||
videos: { new: [], same: [] },
|
||||
analyses: { new: [], duplicate: [] },
|
||||
accountsTotal: 0, videosTotal: 0, analysesTotal: 0,
|
||||
};
|
||||
if (!existsSync(dir)) throw new Error(`目录不存在: ${dir}`);
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const accountDir = join(dir, entry.name);
|
||||
let accountId = null;
|
||||
let hasAccountInfo = false;
|
||||
const infoFile = join(accountDir, "账号信息.json");
|
||||
if (existsSync(infoFile)) {
|
||||
try {
|
||||
const info = JSON.parse(readFileSync(infoFile, "utf8"));
|
||||
const name = info.nickname || entry.name;
|
||||
const did = info.douyin_id != null ? String(info.douyin_id) : null;
|
||||
const existing = did
|
||||
? d.prepare(`SELECT id FROM hot_accounts WHERE del_flag=0 AND douyin_id=?`).get(did)
|
||||
: d.prepare(`SELECT id FROM hot_accounts WHERE del_flag=0 AND account_name=?`).get(name);
|
||||
if (!existing) {
|
||||
result.accounts.new.push(name);
|
||||
} else {
|
||||
accountId = existing.id;
|
||||
const row = d.prepare(`SELECT bio, followers FROM hot_accounts WHERE id=?`).get(existing.id);
|
||||
const changed = (row?.bio || "") !== (info.bio || "")
|
||||
|| (row?.followers || "") !== String(info.followers ?? "");
|
||||
if (changed) result.accounts.update.push(name);
|
||||
else result.accounts.same.push(name);
|
||||
}
|
||||
hasAccountInfo = true;
|
||||
result.accountsTotal++;
|
||||
} catch (e) {
|
||||
console.warn(`[dsh-plugin-mcn] 账号信息解析失败 ${infoFile}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
// 无账号信息的目录不参与比对;新增账号(accountId 为 null)仍扫描视频/分析,全部标记为新增
|
||||
if (!hasAccountInfo) continue;
|
||||
// 视频
|
||||
for (const f of readdirSync(accountDir)) {
|
||||
if (!/\.xlsx?$/i.test(f)) continue;
|
||||
const wb = XLSX.readFile(join(accountDir, f));
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });
|
||||
if (!rows.length) continue;
|
||||
const header = rows[0].map((h) => String(h ?? "").trim().toLowerCase());
|
||||
const iId = header.findIndex((h) => ["视频id", "aweme_id", "视频id(显示)"].includes(h));
|
||||
for (let r = 1; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
if (!row || row[iId] == null) continue;
|
||||
const aweme = String(row[iId]);
|
||||
const ex = accountId != null
|
||||
? d.prepare(`SELECT id FROM account_videos WHERE account_id=? AND aweme_id=?`).get(accountId, aweme)
|
||||
: undefined;
|
||||
if (!ex) result.videos.new.push(aweme);
|
||||
else result.videos.same.push(aweme);
|
||||
result.videosTotal++;
|
||||
}
|
||||
}
|
||||
// md 分析(文件名可带账号名前缀,内容存在=重复)
|
||||
const checkMd = (mdPattern, table) => {
|
||||
for (const f of readdirSync(accountDir)) {
|
||||
if (!mdPattern.test(f)) continue;
|
||||
const p = join(accountDir, f);
|
||||
const content = readFileSync(p, "utf8");
|
||||
const ex = d.prepare(`SELECT id FROM ${table} WHERE account_id=? AND content_json=?`).get(accountId, content);
|
||||
if (!ex) result.analyses.new.push(f);
|
||||
else result.analyses.duplicate.push(f);
|
||||
result.analysesTotal++;
|
||||
}
|
||||
};
|
||||
checkMd(/账号设定.*\.md$/i, "account_persona");
|
||||
checkMd(/账号数据分析.*\.md$/i, "account_analysis");
|
||||
// 视频拆解
|
||||
const vsDir = join(accountDir, "视频对标");
|
||||
if (existsSync(vsDir)) {
|
||||
for (const v of readdirSync(vsDir, { withFileTypes: true })) {
|
||||
if (!v.isDirectory()) continue;
|
||||
const vDir = join(vsDir, v.name);
|
||||
for (const mdf of readdirSync(vDir)) {
|
||||
if (!/拆解分析.*\.md$/i.test(mdf)) continue;
|
||||
const content = readFileSync(join(vDir, mdf), "utf8");
|
||||
const vid = findVideoByFolder(d, accountId, vDir, v.name);
|
||||
const ex = d.prepare(`SELECT id FROM account_video_analysis WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(vid ? vid.id : null, vid ? vid.aweme_id : null, content);
|
||||
if (!ex) result.analyses.new.push(v.name);
|
||||
else result.analyses.duplicate.push(v.name);
|
||||
result.analysesTotal++;
|
||||
}
|
||||
// 原视频解析 json(优先原视频解析.json,无则 content.json)
|
||||
for (const jf of ["原视频解析.json", "content.json"]) {
|
||||
const jp = join(vDir, jf);
|
||||
if (!existsSync(jp)) continue;
|
||||
const content = readFileSync(jp, "utf8");
|
||||
const vid = findVideoByFolder(d, accountId, vDir, v.name);
|
||||
const ex = d.prepare(`SELECT id FROM account_video_source WHERE (video_id=? OR (aweme_id IS NOT NULL AND aweme_id=?)) AND content_json=?`).get(vid ? vid.id : null, vid ? vid.aweme_id : null, content);
|
||||
if (!ex) result.analyses.new.push(v.name + " / " + jf);
|
||||
else result.analyses.duplicate.push(v.name + " / " + jf);
|
||||
result.analysesTotal++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export {
|
||||
isInternalAccount, upsertExternalAccount, importVideosFromXlsx,
|
||||
readVideoSource, readVideoParse, backfillVideoSource,
|
||||
importExternalDir, importExternalData, previewExternalDir,
|
||||
};
|
||||
+3096
File diff suppressed because it is too large.
Load diff
+61
@@ -0,0 +1,61 @@
|
||||
// dsh-plugin-mcn MCP stdio 客户端(复用已验证方案;拆分自 index.js)
|
||||
import { homedir } from "node:os";
|
||||
import { join, isAbsolute, resolve } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { NPX_CMD, MCP_PACKAGE, MCP_ENV } from "./config.js";
|
||||
|
||||
function dshHome() {
|
||||
const configured = process.env.DSH_HOME?.trim();
|
||||
if (!configured) return join(homedir(), ".dsh");
|
||||
return isAbsolute(configured) ? configured : resolve(configured);
|
||||
}
|
||||
|
||||
function callMcpTool(toolName, args = {}) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const reqs = [
|
||||
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "dsh-plugin-mcn", version: "0.1.0" } } }),
|
||||
JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
||||
JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: toolName, arguments: args } }),
|
||||
].join("\n") + "\n";
|
||||
|
||||
const child = spawn(NPX_CMD, ["-y", MCP_PACKAGE], {
|
||||
env: { ...process.env, ...MCP_ENV },
|
||||
shell: true,
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
});
|
||||
let stdout = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) { settled = true; child.kill(); reject(new Error("MCP 调用超时")); }
|
||||
}, 120000);
|
||||
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
|
||||
child.on("close", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
// 找到 id=2 的响应
|
||||
const lines = stdout.split("\n");
|
||||
for (const line of lines) {
|
||||
if (!line.includes('"id":2')) continue;
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.result?.isError) { reject(new Error(msg.result.content?.[0]?.text || "MCP 工具报错")); return; }
|
||||
// 优先 structuredContent(结构化数据);无则回退 text(格式化文本)
|
||||
const sc = msg.result?.structuredContent;
|
||||
const text = msg.result?.content?.[0]?.text ?? "";
|
||||
resolvePromise({ structured: sc ?? null, text });
|
||||
return;
|
||||
}
|
||||
reject(new Error("未找到 MCP 工具响应"));
|
||||
} catch (e) {
|
||||
reject(new Error("解析 MCP 响应失败: " + e.message));
|
||||
}
|
||||
});
|
||||
child.stdin.write(reqs);
|
||||
child.stdin.end();
|
||||
child.on("error", (e) => { if (!settled) { settled = true; clearTimeout(timer); reject(new Error("MCP 进程启动失败: " + e.message)); } });
|
||||
});
|
||||
}
|
||||
|
||||
export { dshHome, callMcpTool };
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// dsh-plugin-mcn 视频/账号匹配工具(纯函数,拆分自 index.js)
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
|
||||
function normTitle(s) {
|
||||
return String(s || "").toLowerCase().replace(/[\s#,。!?、,.!?·\-_~~::"“”()()【】]+/g, "");
|
||||
}
|
||||
|
||||
function extractTopic(content) {
|
||||
const s = String(content || "");
|
||||
if (!s.trim()) return "";
|
||||
const lines = s.replace(/\r\n/g, "\n").split("\n");
|
||||
let start = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const t = lines[i].trim();
|
||||
if (/^#{1,4}\s*选题\s*$/.test(t)) { start = i + 1; break; }
|
||||
}
|
||||
if (start < 0) return "";
|
||||
const out = [];
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const t = lines[i].trim();
|
||||
if (/^#{1,6}\s/.test(t) && !/^#{1,6}\s*选题\s*$/.test(t)) break;
|
||||
if (t) out.push(lines[i].trim());
|
||||
}
|
||||
return out.join("\n").trim();
|
||||
}
|
||||
|
||||
function titleDistance(a, b) {
|
||||
const m = a.length, n = b.length;
|
||||
if (!m) return n;
|
||||
if (!n) return m;
|
||||
let prev = new Array(n + 1), curr = new Array(n + 1);
|
||||
for (let j = 0; j <= n; j++) prev[j] = j;
|
||||
for (let i = 1; i <= m; i++) {
|
||||
curr[0] = i;
|
||||
for (let j = 1; j <= n; j++) {
|
||||
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||
}
|
||||
[prev, curr] = [curr, prev];
|
||||
}
|
||||
return prev[n];
|
||||
}
|
||||
|
||||
function findVideoByFolder(d, accountId, vDir, vName) {
|
||||
const rows = d.prepare(`SELECT id, aweme_id, video_title FROM account_videos WHERE account_id=?`).all(accountId);
|
||||
const pick = (title) => {
|
||||
const t = String(title || "").trim();
|
||||
if (!t) return undefined;
|
||||
const exact = rows.find((r) => String(r.video_title || "") === t);
|
||||
if (exact) return exact;
|
||||
const tn = normTitle(t);
|
||||
if (!tn) return undefined;
|
||||
let best = null;
|
||||
for (const r of rows) {
|
||||
const cn = normTitle(r.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 = { id: r.id, aweme_id: r.aweme_id, len: tn.length };
|
||||
}
|
||||
}
|
||||
if (best) return best;
|
||||
let bestSim = null;
|
||||
for (const r of rows) {
|
||||
const cn = normTitle(r.video_title);
|
||||
if (!cn) continue;
|
||||
const ratio = 1 - titleDistance(tn, cn) / Math.max(tn.length, cn.length, 1);
|
||||
if (ratio > 0.8 && (!bestSim || ratio > bestSim.ratio)) bestSim = { id: r.id, aweme_id: r.aweme_id, ratio };
|
||||
}
|
||||
return bestSim ? bestSim : undefined;
|
||||
};
|
||||
for (const jf of ["原视频解析.json", "content.json"]) {
|
||||
const jp = join(vDir, jf);
|
||||
if (!existsSync(jp)) continue;
|
||||
try {
|
||||
const jd = JSON.parse(readFileSync(jp, "utf8"));
|
||||
const hit = pick(jd.title);
|
||||
if (hit) return hit;
|
||||
} catch (e) { /* 单个 json 解析失败不影响后续匹配 */ }
|
||||
}
|
||||
const first = String(vName || "").split("_")[0].trim();
|
||||
if (first) {
|
||||
const hit = pick(first);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveAccountDirs(acc) {
|
||||
const out = [];
|
||||
if (acc && typeof acc.source_dir === "string" && acc.source_dir && existsSync(acc.source_dir)) out.push(acc.source_dir);
|
||||
const legacy = acc && acc.account_name ? join(homedir(), "Desktop", "账号对标", acc.account_name) : null;
|
||||
if (legacy && existsSync(legacy)) out.push(legacy);
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveAccountDir(acc) {
|
||||
return resolveAccountDirs(acc)[0] || null;
|
||||
}
|
||||
|
||||
/** 数值化(非法输入返回 0) */
|
||||
function num(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
export { num, normTitle, extractTopic, titleDistance, findVideoByFolder, resolveAccountDirs, resolveAccountDir };
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
// dsh-plugin-mcn 工作台数据访问(V1.0 同步增量):榜单四榜解析 / 原创选题脚本 / 批量删除连坐
|
||||
// 语义同步自 V1.0 mcn-work-shop(dsh-data.js / server.js 09-03~09-04 定稿),纯函数便于离线冒烟测试。
|
||||
// 约定:db 参数由调用方注入(node:sqlite DatabaseSync);目录参数显式传入(榜单目录由路由解析)。
|
||||
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/* ================= 榜单目录与文件族(09-03 分目录管理;根目录旧平铺兼容) =================
|
||||
* 抖音榜单/
|
||||
* 账号周榜/抖音周榜_{赛道}_{周一}.json → week(每赛道一期一文件,douyin-top-account 落盘)
|
||||
* 账号日榜/抖音账号日榜_{日期}.json → day(全品类单文件,fetch_rank.py --period day 落盘)
|
||||
* 视频热榜/抖音周榜_视频热榜_{周一}.json → hot(全品类单文件,fetch_week_ranks.py --board both 落盘)
|
||||
* 点赞榜/ 抖音周榜_点赞榜_{周一}.json → likes(同上)
|
||||
* 全品类单文件无赛道段:赛道在 list[].category,赛道下拉/筛选按条目 category 匹配。
|
||||
*/
|
||||
export const RANK_SUB = { week: "账号周榜", day: "账号日榜", hot: "视频热榜", likes: "点赞榜" };
|
||||
export 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$/,
|
||||
};
|
||||
// 工作台所需赛道白名单(与账号周榜 douyin-top-account 保留 5 赛道同口径;账号榜 type=「生活vlog」、
|
||||
// 作品榜条目 category=「生活记录」→ 并集放行);生效范围 day/hot/likes;week 文件名赛道天然受限。
|
||||
export const RANK_KEEP = ["生活记录", "生活vlog", "小剧场", "亲子", "美食", "旅行"];
|
||||
const RANK_KEEP_SET = new Set(RANK_KEEP);
|
||||
// 默认排序键随榜(同分按排名升序)
|
||||
export const RANK_DEF_KEY = {
|
||||
week: "comprehensiveScore",
|
||||
day: "comprehensiveScore",
|
||||
hot: "likeCount",
|
||||
likes: "add_digg_count",
|
||||
};
|
||||
|
||||
export 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 num(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
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] };
|
||||
}
|
||||
|
||||
/** 枚举某榜全部数据文件(新子目录结构 + 根目录旧平铺兼容),按日期倒序 */
|
||||
export function listRankFiles(dir, board) {
|
||||
if (!dir || !existsSync(dir)) return [];
|
||||
const out = [];
|
||||
const subDir = join(dir, RANK_SUB[board]);
|
||||
if (existsSync(subDir)) {
|
||||
for (const f of readdirSync(subDir)) {
|
||||
const p = parseRankFileIn(board, RANK_SUB[board], f);
|
||||
if (p) out.push(p);
|
||||
}
|
||||
}
|
||||
for (const f of readdirSync(dir)) {
|
||||
const p = parseRankFileIn(board, "", f);
|
||||
if (p) out.push(p);
|
||||
}
|
||||
return out.sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
|
||||
function rankFilePath(dir, sub, name) {
|
||||
return sub ? join(dir, sub, name) : join(dir, name);
|
||||
}
|
||||
|
||||
/** 赛道下拉:白名单内且当期数据实际出现者,按 RANK_KEEP 顺序输出;week=文件名赛道,其余=条目 category 并集 */
|
||||
export function collectRankCategories(dir, 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(readFileSync(rankFilePath(dir, 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));
|
||||
}
|
||||
|
||||
/** 榜单新鲜度:递归扫描目录全部 .json 的最新 mtime(=最近一次成功落盘时刻) */
|
||||
export function rankingFreshness(dir) {
|
||||
const out = { exists: !!dir && existsSync(dir), latestMtime: 0, latestFile: "", latestDate: "", files: 0 };
|
||||
if (!out.exists) return out;
|
||||
const walk = (d) => {
|
||||
let entries = [];
|
||||
try { entries = readdirSync(d, { withFileTypes: true }); } catch (e) { return; }
|
||||
for (const en of entries) {
|
||||
const full = join(d, en.name);
|
||||
if (en.isDirectory()) walk(full);
|
||||
else if (/\.json$/i.test(en.name)) {
|
||||
out.files++;
|
||||
try {
|
||||
const st = statSync(full);
|
||||
if (st.mtimeMs > out.latestMtime) { out.latestMtime = st.mtimeMs; out.latestFile = full; }
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
const m = String(out.latestFile).match(/(\d{4}-\d{2}-\d{2})\.json$/);
|
||||
if (m) out.latestDate = m[1];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 四榜查询(week=账号周榜 / day=账号日榜 / hot=视频热榜 / likes=点赞榜) */
|
||||
export function getRanking({ dir, board = "week", date = "", category = "", sortBy = "", order = "", page = 1, pageSize = 20, db = null } = {}) {
|
||||
const empty = { dates: [], categories: [], category: "", date: "", total: 0, items: [] };
|
||||
const files = listRankFiles(dir, board);
|
||||
if (!files.length) return empty;
|
||||
const dates = [...new Set(files.map((f) => f.date))];
|
||||
const categories = collectRankCategories(dir, board, files);
|
||||
const selDate = date || dates[0];
|
||||
// week:按文件名赛道过滤(每赛道一文件);全品类单文件:赛道过滤在条目层
|
||||
const selFiles = files.filter((f) => f.date === selDate && (board !== "week" || !category || f.category === category));
|
||||
let items = [];
|
||||
for (const pf of selFiles) {
|
||||
try {
|
||||
const d = JSON.parse(readFileSync(rankFilePath(dir, pf.dir, pf.name), "utf8"));
|
||||
items = items.concat(d.list || []);
|
||||
} catch (e) { /* 单个文件损坏跳过 */ }
|
||||
}
|
||||
// 全品类单文件(day/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);
|
||||
}
|
||||
}
|
||||
const defKey = RANK_DEF_KEY[board];
|
||||
if (!order && (!sortBy || sortBy === defKey)) {
|
||||
items.sort((a, b) => {
|
||||
const va = num(a[defKey]), vb = num(b[defKey]);
|
||||
if (va !== vb) return vb - va;
|
||||
return num(a.rank) - num(b.rank);
|
||||
});
|
||||
} else {
|
||||
const key = sortBy || defKey;
|
||||
const dir2 = 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 dir2 === "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 secOf = (url) => { const m = String(url || "").match(/\/user\/([^/?#]+)/); return m ? m[1] : ""; };
|
||||
let lookup = null;
|
||||
if (db) {
|
||||
try { lookup = db.prepare(`SELECT id FROM hot_accounts WHERE (sec_uid = ? OR account_name = ?) AND (del_flag IS NULL OR del_flag <> 1) LIMIT 1`); } catch (e) { lookup = null; }
|
||||
}
|
||||
const pageItems = items.slice(offset, offset + limit).map((row) => {
|
||||
let hit = null;
|
||||
const sec = row.profileUrl ? secOf(row.profileUrl) : "";
|
||||
if (lookup) {
|
||||
try { hit = sec ? lookup.get(sec, row.accountName) : null; } catch (e) { 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 };
|
||||
}
|
||||
|
||||
/* ================= 原创选题(无视频载体)脚本与统计(同步 V1.0 v25:video_id 空 = 原创) ================= */
|
||||
function daysAgoIso(n) {
|
||||
try {
|
||||
return new Date(Date.now() - Number(n) * 86400000).toISOString();
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function listCustomScripts(d, { page = 1, pageSize = 20, search = "", days = "", account = "" } = {}) {
|
||||
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) { const cutoff = daysAgoIso(n); if (cutoff) { where.push("r.created_time >= ?"); params.push(cutoff); } }
|
||||
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.gen_method, 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 };
|
||||
}
|
||||
|
||||
/** 原创选题脚本全文(查看弹窗用,避免列表携带大文本);非原创返回 null */
|
||||
export function getCustomScriptRow(d, id) {
|
||||
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) return null;
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 三 tab 徽标计数(与账号列表「AI脚本」列同口径:done 且脚本非空);account=账号名时按账号统计 */
|
||||
export function countRewriteStats(d, account = "") {
|
||||
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 IS NOT NULL 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 };
|
||||
}
|
||||
|
||||
/* ================= 批量删除(连坐,同步 V1.0 09-04):账号=软删+连坐;视频/脚本=物理删视频行+连坐 =================
|
||||
* kind:accounts(软删账号 + 连坐 persona/analysis/videos 及其 source/analysis/rewrite/复盘/分镜 + 原创脚本)
|
||||
* videos | rewrites(删视频行 + 同连坐;「rewrites」为 V1.0 AI写脚本页批量删除口径=删视频实体)
|
||||
* 返回 { deleted, rewriteIds[], storyboardIds[] }(rewriteIds/storyboardIds 供路由同步清理会话目录,防 id 复用污染)
|
||||
*/
|
||||
export function batchDeleteCascade(d, kind, ids) {
|
||||
const clean = (Array.isArray(ids) ? ids : []).map(Number).filter((x) => Number.isFinite(x) && x > 0);
|
||||
if (!d || clean.length === 0) return { deleted: 0, rewriteIds: [], storyboardIds: [] };
|
||||
const q = clean.map(() => "?").join(",");
|
||||
const rewriteIds = [];
|
||||
const storyboardIds = [];
|
||||
/** 删除单条脚本及关联(ai/compare 复盘 + 分镜;源视频级复盘由调用方按 video 处理) */
|
||||
const cascadeRewrite = (rwId) => {
|
||||
rewriteIds.push(rwId);
|
||||
try { d.prepare(`DELETE FROM script_review WHERE rewrite_id=? AND type IN ('ai','compare')`).run(rwId); } catch (e) {}
|
||||
try {
|
||||
const sbs = d.prepare(`SELECT id FROM storyboard_log WHERE rewrite_id=?`).all(rwId).map((x) => x.id);
|
||||
storyboardIds.push(...sbs);
|
||||
d.prepare(`DELETE FROM storyboard_log WHERE rewrite_id=?`).run(rwId);
|
||||
} catch (e) {}
|
||||
try { d.prepare(`DELETE FROM rewrite_log WHERE id=?`).run(rwId); } catch (e) {}
|
||||
};
|
||||
/** 深删视频行(按 id/aweme_id 双键清 source/analysis + 该视频全部脚本连坐 + 源视频级复盘) */
|
||||
const deepVideos = (rows) => {
|
||||
for (const v of rows) {
|
||||
try {
|
||||
if (v.aweme_id) {
|
||||
d.prepare(`DELETE FROM account_video_source WHERE aweme_id=?`).run(v.aweme_id);
|
||||
d.prepare(`DELETE FROM account_video_analysis WHERE aweme_id=?`).run(v.aweme_id);
|
||||
}
|
||||
d.prepare(`DELETE FROM account_video_source WHERE video_id=?`).run(v.id);
|
||||
d.prepare(`DELETE FROM account_video_analysis WHERE video_id=?`).run(v.id);
|
||||
d.prepare(`DELETE FROM script_review WHERE type='source' AND video_id=?`).run(v.id);
|
||||
} catch (e) {}
|
||||
try {
|
||||
const rws = d.prepare(`SELECT id FROM rewrite_log WHERE video_id=?`).all(v.id);
|
||||
for (const r of rws) cascadeRewrite(r.id);
|
||||
} catch (e) {}
|
||||
try { d.prepare(`DELETE FROM account_videos WHERE id=?`).run(v.id); } catch (e) {}
|
||||
}
|
||||
};
|
||||
let deleted = 0;
|
||||
try {
|
||||
d.exec("BEGIN");
|
||||
if (kind === "accounts") {
|
||||
const accs = clean;
|
||||
for (const accId of accs) {
|
||||
try {
|
||||
d.prepare(`DELETE FROM account_persona WHERE account_id=?`).run(accId);
|
||||
d.prepare(`DELETE FROM account_analysis WHERE account_id=?`).run(accId);
|
||||
} catch (e) {}
|
||||
const videos = d.prepare(`SELECT id, aweme_id FROM account_videos WHERE account_id=?`).all(accId);
|
||||
deepVideos(videos);
|
||||
// 原创选题脚本(无视频载体,account_id 直挂账号)
|
||||
try {
|
||||
const originals = d.prepare(`SELECT id FROM rewrite_log WHERE account_id=? AND (video_id IS NULL OR video_id = 0)`).all(accId);
|
||||
for (const r of originals) cascadeRewrite(r.id);
|
||||
} catch (e) {}
|
||||
const r = d.prepare(`UPDATE hot_accounts SET del_flag=1 WHERE id=? AND (del_flag IS NULL OR del_flag <> 1)`).run(accId);
|
||||
deleted += Number(r.changes);
|
||||
}
|
||||
} else {
|
||||
// kind=videos | rewrites:删视频行(AI写脚本页 ref 列表批量=删除整行视频载体)
|
||||
const rows = d.prepare(`SELECT id, aweme_id FROM account_videos WHERE id IN (${q})`).all(...clean);
|
||||
deepVideos(rows);
|
||||
deleted += rows.length;
|
||||
}
|
||||
d.exec("COMMIT");
|
||||
} catch (e) {
|
||||
try { d.exec("ROLLBACK"); } catch (e2) {}
|
||||
throw e;
|
||||
}
|
||||
return { deleted, rewriteIds, storyboardIds };
|
||||
}
|
||||
Reference in new issue
Block a user