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:
admin committed 2026-09-10 22:31:45 +08:00
commit 856e632807
880 files changed
+225426

No files matched your search

File diff suppressed because it is too large. Load diff
+87
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
File diff suppressed because it is too large. Load diff
+61
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}