账号分析产物入库闭环:工作台入库接口+选题读视频解析
- server.js 新增 POST /api/import/account:扫描账号产出目录直写 mcn-plugin.db(视频分析→analysis/source、账号设定→persona、账号数据分析→analysis、短视频表格.xlsx→videos upsert) - 新增 scripts/export_video_map.py:python 读 xlsx 13列输出 JSON 权威 aweme_id 映射 - /api/ai/topics 增加读最新3条视频解析 JOIN account_videos 拼入 prompt,选题基于真实视频内容 - 修复 aweme_id 匹配:normTitle 删全角标点(xlsx「!」「”」vs 文件夹无标点,4/6 失败根因)+下划线转空格;入库前清 null 脏数据 - mcn-dou-analysis SKILL.md:功能五补充工作台入库方式+注意事项第6条匹配红线 - 验证:俊希 6 analysis+6 source+137 videos 入库,JOIN 全关联,选题端到端返回贴合视频解析
This commit is contained in:
1 parent
4e29ddb881
commit
92c93cf66a
5 files changed
+228
-5
No files matched your search
@@ -34,3 +34,11 @@
|
|||||||
- **8899 被小米占用(新坑)**:`MiPCAudio.exe`(小米 PC 管家音频共享服务)系统级占用 0.0.0.0:8899 且杀后自动复活(taskkill 无效)→ server.js 绑定 127.0.0.1:8899 不生效、curl/内置浏览器全 000
|
- **8899 被小米占用(新坑)**:`MiPCAudio.exe`(小米 PC 管家音频共享服务)系统级占用 0.0.0.0:8899 且杀后自动复活(taskkill 无效)→ server.js 绑定 127.0.0.1:8899 不生效、curl/内置浏览器全 000
|
||||||
- **修复**:server.js 支持 `node server.js [端口]` 传参(PORT_BASE=argv[2]||8899),改用 8900 启动,HTTP 200 验证通过,内置浏览器已打开
|
- **修复**:server.js 支持 `node server.js [端口]` 传参(PORT_BASE=argv[2]||8899),改用 8900 启动,HTTP 200 验证通过,内置浏览器已打开
|
||||||
- **bash `&` 后台进程会被会话回收**:必须用 run_in_background=true 方式启动长驻服务,普通 `&` + 重定向会在命令返回后被清理
|
- **bash `&` 后台进程会被会话回收**:必须用 run_in_background=true 方式启动长驻服务,普通 `&` + 重定向会在命令返回后被清理
|
||||||
|
|
||||||
|
## 09-01 分析产物入库闭环:工作台入库接口 + 选题读视频解析(根因→修复→验证)
|
||||||
|
- **根因链(用户连续追问查实)**:①账号分析技能产出只落盘文件(视频分析/),流程无入库步骤;②入库机制只在 dsh 插件(扫描「视频对标」目录+解析任务导入),技能与插件两套流程从未接通;③开发机无 dsh 接口 3080/3081;④库中俊希 persona/account_analysis/video_analysis/video_source 全 0 → 选题接口 /api/ai/topics 唯一真实输入=hot_accounts.content(账号定位文本 1517 字),「基于视频」是假象
|
||||||
|
- **方案(用户批准+方向纠正:入库必须实现在工作台,dsh 插件仅参考,直写工作台库副本)**:server.js 新增 `POST /api/import/account`(扫描账号产出目录直写 mcn-plugin.db:视频分析/*/analysis.json→account_video_analysis、content.json→account_video_source、账号设定.md→account_persona、账号数据分析.md→account_analysis、短视频表格.xlsx→account_videos upsert 补列表)+ 新增 scripts/export_video_map.py(python 读 xlsx 13列出 JSON 权威映射);/api/ai/topics 增加读最新 3 条视频解析 JOIN account_videos 拼入 prompt【该账号最近视频解析】
|
||||||
|
- **aweme_id 匹配 bug 两轮**:①首版包含匹配太宽松→多条互配覆盖(同 aweme_id 5删1);②精确/包含仍 4/6 失败——**根因=xlsx 标题带全角标点「!」「”」(过生日vlog!/“爸王餐”vlog!)而文件夹名无标点,normTitle split('#') 后双向 includes 失败**。修复:normTitle 删全角半角标点+下划线转空格+空格归一+trim;入库前清 `aweme_id IS NULL AND video_id IS NULL` 脏数据。修复后 6/6 命中(含 7649305562131270921 等)
|
||||||
|
- **验证闭环**:入库返回 xlsx_rows=137/video_analysis=6/video_source=6/videos=137/persona=1(去重);JOIN 俊希 analysis 6 条+source 6 条、null 脏数据 0;选题接口 query 已含 3 条真实视频解析(过生日 33916 字等),端到端 Dify 返回 3 选题全部基于解析内容基因(嘴甜夸赞/播音腔/家庭结构符号)
|
||||||
|
- **技能文档同步**:mcn-dou-analysis SKILL.md 功能五「可选写库」补充工作台入库方式 + 注意事项第 6 条记录标题匹配红线(全角标点坑)
|
||||||
|
- 服务 PID 27548→KKQ7LW(8900,修复后重启);git 待提交
|
||||||
Binary file not shown.
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
export_video_map.py - 短视频表格.xlsx → 视频映射 JSON(工作台入库接口配套,09-01 新增)
|
||||||
|
|
||||||
|
背景:短视频表格.xlsx(功能二产出,13列:序号/视频ID/视频标题/...)包含视频的权威 aweme_id,
|
||||||
|
工作台 server.js 入库接口用它匹配 视频分析/ 目录下的解析文件,避免仅靠 account_videos 表标题模糊匹配。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python export_video_map.py <短视频表格.xlsx>
|
||||||
|
→ stdout 输出 JSON 数组:[{"aweme_id": "...", "title": "...", "url": "...",
|
||||||
|
"like_count": 123, "comment_count": 4, "share_count": 5, "collect_count": 6,
|
||||||
|
"play_count": 7, "duration": 8, "publish_time": "...", "tags": "..."}]
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("usage: export_video_map.py <短视频表格.xlsx>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
xlsx = sys.argv[1]
|
||||||
|
try:
|
||||||
|
import openpyxl
|
||||||
|
except ImportError:
|
||||||
|
print("[]", file=sys.stderr)
|
||||||
|
print("[error] openpyxl 不可用", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(xlsx, read_only=True, data_only=True)
|
||||||
|
ws = wb.active
|
||||||
|
out = []
|
||||||
|
for row in ws.iter_rows(values_only=True):
|
||||||
|
if not row or len(row) < 3:
|
||||||
|
continue
|
||||||
|
aweme_id, title = row[1], row[2]
|
||||||
|
if aweme_id is None or not title:
|
||||||
|
continue
|
||||||
|
rec = {
|
||||||
|
"aweme_id": str(aweme_id).strip(),
|
||||||
|
"title": str(title).strip(),
|
||||||
|
"url": str(row[3] or "").strip(),
|
||||||
|
"like_count": row[4],
|
||||||
|
"comment_count": row[6],
|
||||||
|
"share_count": row[7],
|
||||||
|
"collect_count": row[8],
|
||||||
|
"play_count": row[9],
|
||||||
|
"duration": row[10],
|
||||||
|
"publish_time": str(row[11] or "").strip(),
|
||||||
|
"tags": str(row[12] or "").strip(),
|
||||||
|
}
|
||||||
|
out.append(rec)
|
||||||
|
wb.close()
|
||||||
|
print(json.dumps(out, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -7,6 +7,7 @@ const http = require('http');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
const { URL } = require('url');
|
const { URL } = require('url');
|
||||||
const dsh = require('./dsh-data');
|
const dsh = require('./dsh-data');
|
||||||
|
|
||||||
@@ -57,6 +58,129 @@ function resolveSafe(rawPath) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 分析产物入库(09-01 新增):扫描账号产出目录 → 写工作台库 mcn-plugin.db ----
|
||||||
|
// 背景:账号分析技能产出只落盘文件系统未入库(俊希解析/人设卡全缺),工作台选题等读不到视频内容。
|
||||||
|
// 实现参照 dsh-plugin-mcn lib/imports.js 的 analyses 入库逻辑(dsh 接口 3080/3081 不可用时直写库)。
|
||||||
|
function resolveAccountDir(name, root) {
|
||||||
|
const safeName = String(name || '').replace(/[\\/:*?"<>|]/g, '_').trim();
|
||||||
|
if (!safeName) return null;
|
||||||
|
const roots = root ? [root] : allowedRoots();
|
||||||
|
for (const r of roots) {
|
||||||
|
const dir = path.join(r, safeName);
|
||||||
|
try { if (fs.statSync(dir).isDirectory()) return dir; } catch (e) { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function importAccountIntoDb(db, account, dir) {
|
||||||
|
const stats = { video_analysis: 0, video_source: 0, persona: 0, account_analysis: 0, videos_upserted: 0, skipped: [] };
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const insAnalysis = db.prepare(`INSERT INTO account_video_analysis (video_id, aweme_id, analysis_time, content_json, summary) VALUES (?,?,?,?,?)`);
|
||||||
|
const insSource = db.prepare(`INSERT INTO account_video_source (video_id, aweme_id, analysis_time, content_json, summary) VALUES (?,?,?,?,?)`);
|
||||||
|
const insPersona = db.prepare(`INSERT INTO account_persona (account_id, analysis_time, content_json, summary) VALUES (?,?,?,?)`);
|
||||||
|
const insAccAna = db.prepare(`INSERT INTO account_analysis (account_id, analysis_time, content_json, summary) VALUES (?,?,?,?)`);
|
||||||
|
const acc = db.prepare('SELECT id FROM hot_accounts WHERE account_name=? LIMIT 1').get(account);
|
||||||
|
const accountId = acc ? acc.id : null;
|
||||||
|
// 清理历史误插脏数据:09-01 匹配缺陷(标点未规范化)产生 aweme_id=null 且 video_id=null 的无意义记录,重跑入库前先清
|
||||||
|
db.prepare("DELETE FROM account_video_analysis WHERE aweme_id IS NULL AND video_id IS NULL").run();
|
||||||
|
db.prepare("DELETE FROM account_video_source WHERE aweme_id IS NULL AND video_id IS NULL").run();
|
||||||
|
// 规范化标题:去 # 标签 → 删全角/半角标点(xlsx 标题带「!」「“”」而文件夹名无,09-01 匹配失败根因)→ 下划线转空格(对齐文件夹 _标签 结构)→ 空格归一
|
||||||
|
const normTitle = (s) => String(s || '').split('#')[0].replace(/[!!“”‘’"'`,.…。,、;::;·]/g, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
// 0) aweme_id 权威映射:短视频表格.xlsx(子进程 python,最高优先级)→ account_videos 表(兜底)
|
||||||
|
const xlsxList = readXlsxMap(dir);
|
||||||
|
const xlsxByNorm = {};
|
||||||
|
for (const r of xlsxList) { if (r.aweme_id && r.title) xlsxByNorm[normTitle(r.title)] = r.aweme_id; }
|
||||||
|
const titleRows = db.prepare('SELECT aweme_id, video_title FROM account_videos WHERE video_title IS NOT NULL').all();
|
||||||
|
const dbByNorm = {};
|
||||||
|
for (const r of titleRows) { if (r.aweme_id && r.video_title) dbByNorm[normTitle(r.video_title)] = String(r.aweme_id); }
|
||||||
|
// 匹配:精确优先;包含匹配要求双方标题 ≥10 字且按标题长度降序(最具体优先,防短标题误配)
|
||||||
|
const matchAweme = (folder) => {
|
||||||
|
const base = normTitle(folder);
|
||||||
|
if (!base) return null;
|
||||||
|
if (xlsxByNorm[base]) return xlsxByNorm[base];
|
||||||
|
if (dbByNorm[base]) return dbByNorm[base];
|
||||||
|
const cands = [];
|
||||||
|
for (const [t, aw] of Object.entries(xlsxByNorm)) {
|
||||||
|
if (base.length >= 10 && t.length >= 10 && (base.includes(t) || t.includes(base))) cands.push([t.length, aw]);
|
||||||
|
}
|
||||||
|
if (!cands.length) {
|
||||||
|
for (const [t, aw] of Object.entries(dbByNorm)) {
|
||||||
|
if (base.length >= 10 && t.length >= 10 && (base.includes(t) || t.includes(base))) cands.push([t.length, aw]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cands.sort((a, b) => b[0] - a[0]);
|
||||||
|
return cands.length ? cands[0][1] : null;
|
||||||
|
};
|
||||||
|
// 0.5) 视频列表入库:短视频表格.xlsx → account_videos(按 account_id+aweme_id 去重,补上工作台视频列表数据)
|
||||||
|
if (accountId && xlsxList.length) {
|
||||||
|
const insVideo = db.prepare(`INSERT INTO account_videos (account_id, aweme_id, video_title, video_url, like_count, comment_count, share_count, collect_count, play_count, duration, publish_time, tags, collected_time) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`);
|
||||||
|
for (const v of xlsxList) {
|
||||||
|
const ex = db.prepare('SELECT id FROM account_videos WHERE account_id=? AND aweme_id=? LIMIT 1').get(accountId, v.aweme_id);
|
||||||
|
if (!ex) {
|
||||||
|
insVideo.run(accountId, v.aweme_id, v.title, v.url || null, v.like_count ?? null, v.comment_count ?? null, v.share_count ?? null, v.collect_count ?? null, v.play_count ?? null, v.duration ?? null, v.publish_time || null, v.tags || null, now);
|
||||||
|
stats.videos_upserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 1) 视频分析目录(功能三 Step5 产出;功能五产出在 视频对标/ 目录,由 dsh 导入机制处理)
|
||||||
|
const vaDir = path.join(dir, '视频分析');
|
||||||
|
if (fs.existsSync(vaDir)) {
|
||||||
|
for (const folder of fs.readdirSync(vaDir)) {
|
||||||
|
const fdir = path.join(vaDir, folder);
|
||||||
|
let st; try { st = fs.statSync(fdir); } catch (e) { continue; }
|
||||||
|
if (!st.isDirectory()) continue;
|
||||||
|
const awemeId = matchAweme(folder);
|
||||||
|
const anPath = path.join(fdir, 'analysis.json');
|
||||||
|
if (fs.existsSync(anPath)) {
|
||||||
|
const content = fs.readFileSync(anPath, 'utf8');
|
||||||
|
if (awemeId) db.prepare('DELETE FROM account_video_analysis WHERE aweme_id=?').run(awemeId);
|
||||||
|
insAnalysis.run(null, awemeId, now, content, content.slice(0, 500));
|
||||||
|
stats.video_analysis++;
|
||||||
|
}
|
||||||
|
const coPath = path.join(fdir, 'content.json');
|
||||||
|
if (fs.existsSync(coPath)) {
|
||||||
|
const content = fs.readFileSync(coPath, 'utf8');
|
||||||
|
if (awemeId) db.prepare('DELETE FROM account_video_source WHERE aweme_id=?').run(awemeId);
|
||||||
|
insSource.run(null, awemeId, now, content, '');
|
||||||
|
stats.video_source++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) 人设卡 + 账号分析(去重,不删旧)
|
||||||
|
if (accountId) {
|
||||||
|
const personaPath = path.join(dir, account + '账号设定.md');
|
||||||
|
if (fs.existsSync(personaPath)) {
|
||||||
|
const content = fs.readFileSync(personaPath, 'utf8');
|
||||||
|
const dup = db.prepare('SELECT id FROM account_persona WHERE account_id=? AND content_json=? LIMIT 1').get(accountId, content);
|
||||||
|
if (!dup) { insPersona.run(accountId, now, content, content.slice(0, 200)); stats.persona++; }
|
||||||
|
else stats.skipped.push('persona(重复)');
|
||||||
|
}
|
||||||
|
const anaPath = path.join(dir, account + '账号数据分析.md');
|
||||||
|
if (fs.existsSync(anaPath)) {
|
||||||
|
const content = fs.readFileSync(anaPath, 'utf8');
|
||||||
|
const dup = db.prepare('SELECT id FROM account_analysis WHERE account_id=? AND content_json=? LIMIT 1').get(accountId, content);
|
||||||
|
if (!dup) { insAccAna.run(accountId, now, content, content.slice(0, 200)); stats.account_analysis++; }
|
||||||
|
else stats.skipped.push('account_analysis(重复)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { account, account_id: accountId, dir, xlsx_rows: xlsxList.length, title_map_size: Object.keys(dbByNorm).length, ...stats };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读短视频表格.xlsx → 视频映射数组(子进程调 python export_video_map.py,openpyxl 读 xlsx;失败返回 [])
|
||||||
|
function readXlsxMap(dir) {
|
||||||
|
const xlsxPath = path.join(dir, '短视频表格.xlsx');
|
||||||
|
if (!fs.existsSync(xlsxPath)) return [];
|
||||||
|
const py = process.env.PYTHON || 'python';
|
||||||
|
try {
|
||||||
|
const out = execFileSync(py, [path.join(ROOT, 'scripts', 'export_video_map.py'), xlsxPath], { encoding: 'utf8', timeout: 20000, windowsHide: true });
|
||||||
|
const arr = JSON.parse(out);
|
||||||
|
return Array.isArray(arr) ? arr : [];
|
||||||
|
} catch (e) {
|
||||||
|
log('xlsx 视频映射读取失败(用表内匹配兜底): ' + e.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 查找账号设定卡 SVG ----
|
// ---- 查找账号设定卡 SVG ----
|
||||||
// 产出规范:{产出根}/{达人昵称}/{达人昵称}账号设定卡.svg(功能六产出,与账号设定.md 同目录)
|
// 产出规范:{产出根}/{达人昵称}/{达人昵称}账号设定卡.svg(功能六产出,与账号设定.md 同目录)
|
||||||
// 查找顺序:规范路径 → 根下直挂 → 账号目录内扫描(防目录名/账号名不一致)
|
// 查找顺序:规范路径 → 根下直挂 → 账号目录内扫描(防目录名/账号名不一致)
|
||||||
@@ -438,8 +562,8 @@ const server = http.createServer((req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { accountName } = JSON.parse(body || '{}');
|
const { accountName } = JSON.parse(body || '{}');
|
||||||
if (!accountName || typeof accountName !== 'string') return sendErr(400, '缺少账号名');
|
if (!accountName || typeof accountName !== 'string') return sendErr(400, '缺少账号名');
|
||||||
// 1) 查账号定位 + 人设摘要(工作台库)
|
// 1) 查账号定位 + 人设摘要 + 最新视频解析(工作台库)
|
||||||
let contentText = '', personaText = '';
|
let contentText = '', personaText = '', videoRefText = '';
|
||||||
try {
|
try {
|
||||||
const { DatabaseSync } = require('node:sqlite');
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db'), { readOnly: true });
|
const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db'), { readOnly: true });
|
||||||
@@ -448,6 +572,13 @@ const server = http.createServer((req, res) => {
|
|||||||
contentText = String(acc.content || '').slice(0, 500);
|
contentText = String(acc.content || '').slice(0, 500);
|
||||||
const p = db.prepare('SELECT content_json, summary FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1').get(acc.id);
|
const p = db.prepare('SELECT content_json, summary FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1').get(acc.id);
|
||||||
if (p) personaText = String(p.summary || p.content_json || '').slice(0, 500);
|
if (p) personaText = String(p.summary || p.content_json || '').slice(0, 500);
|
||||||
|
// 09-01:读该账号最新视频解析(account_video_analysis 按 aweme_id 关联),让选题基于真实视频内容
|
||||||
|
try {
|
||||||
|
const vrows = db.prepare(
|
||||||
|
'SELECT va.content_json AS c FROM account_video_analysis va JOIN account_videos v ON v.aweme_id = va.aweme_id WHERE v.account_id=? ORDER BY va.analysis_time DESC LIMIT 3'
|
||||||
|
).all(acc.id);
|
||||||
|
if (vrows.length) videoRefText = vrows.map((x) => String(x.c || '')).join('\n---\n').slice(0, 3000);
|
||||||
|
} catch (e) { /* 视频解析缺失不阻塞 */ }
|
||||||
}
|
}
|
||||||
db.close();
|
db.close();
|
||||||
} catch (e) { /* 库不可用不阻塞,仍可生成 */ }
|
} catch (e) { /* 库不可用不阻塞,仍可生成 */ }
|
||||||
@@ -462,10 +593,11 @@ const server = http.createServer((req, res) => {
|
|||||||
} catch (e) { /* 脚本不存在则走 env */ }
|
} catch (e) { /* 脚本不存在则走 env */ }
|
||||||
}
|
}
|
||||||
if (!apiKey) return sendErr(500, '未配置 DIFY_MCN_CYLG_KEY(环境变量或技能脚本内置 KEY)');
|
if (!apiKey) return sendErr(500, '未配置 DIFY_MCN_CYLG_KEY(环境变量或技能脚本内置 KEY)');
|
||||||
// 3) 构造选题 query(参考 references/创作流程/5_生成短视频选题.md 方法论)
|
// 3) 构造选题 query(参考 references/创作流程/5_生成短视频选题.md 方法论;09-01 追加视频解析参考)
|
||||||
const query = '你是短视频选题策划师,请为达人「' + accountName + '」结合账号设定生成 3 个爆款选题方案。\n\n'
|
const query = '你是短视频选题策划师,请为达人「' + accountName + '」结合账号设定与最新视频内容生成 3 个爆款选题方案。\n\n'
|
||||||
+ '【达人信息】\n' + (contentText || '(无账号定位信息)') + '\n'
|
+ '【达人信息】\n' + (contentText || '(无账号定位信息)') + '\n'
|
||||||
+ (personaText ? '【人设摘要】\n' + personaText + '\n' : '')
|
+ (personaText ? '【人设摘要】\n' + personaText + '\n' : '')
|
||||||
|
+ (videoRefText ? '【该账号最近视频解析(选题须参考的真实视频内容)】\n' + videoRefText + '\n' : '')
|
||||||
+ '\n要求:\n'
|
+ '\n要求:\n'
|
||||||
+ '1. 选题要跳出账号核心行为框架,但保留账号结构性符号(固定机制/口头禅等)\n'
|
+ '1. 选题要跳出账号核心行为框架,但保留账号结构性符号(固定机制/口头禅等)\n'
|
||||||
+ '2. 每个选题一句话说清主题+切入角度,标注目标体感(如治愈感/爽感/获得感)\n'
|
+ '2. 每个选题一句话说清主题+切入角度,标注目标体感(如治愈感/爽感/获得感)\n'
|
||||||
@@ -495,6 +627,29 @@ const server = http.createServer((req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分析产物同步入库(09-01 新增):扫描账号产出目录 → 写工作台库 mcn-plugin.db
|
||||||
|
if (p === '/api/import/account' && req.method === 'POST') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { account, root } = JSON.parse(body || '{}');
|
||||||
|
if (!account || typeof account !== 'string') return sendErr(400, '缺少账号名');
|
||||||
|
const accountDir = resolveAccountDir(account, root);
|
||||||
|
if (!accountDir) return sendErr(404, '账号产出目录不存在: ' + account);
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db'));
|
||||||
|
const res = importAccountIntoDb(db, account, accountDir);
|
||||||
|
db.close();
|
||||||
|
log('分析产物入库: ' + account + ' → ' + JSON.stringify(res));
|
||||||
|
return dshOk(200, res);
|
||||||
|
} catch (e) {
|
||||||
|
return sendErr(500, '入库失败: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 静态文件 ----------
|
// ---------- 静态文件 ----------
|
||||||
let filePath = path.join(PUBLIC_DIR, p === '/' ? 'index.html' : p);
|
let filePath = path.join(PUBLIC_DIR, p === '/' ? 'index.html' : p);
|
||||||
// 防穿越
|
// 防穿越
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ description: "MCN 达人爆款视频解析与人设卡生成技能:达人账
|
|||||||
核心流程概要:
|
核心流程概要:
|
||||||
- **触发**:账号详情视频列表「解析视频」按钮 / 用户指定单条视频
|
- **触发**:账号详情视频列表「解析视频」按钮 / 用户指定单条视频
|
||||||
- **流程**:`upload_douyin_video` 提交(3-5 分钟等待)→ `short_video_detail` 查询(content + analysis)→ `scripts/json_tool.py` 保存到 `{产出根目录}/{达人昵称}/视频对标/{视频文件夹}/`
|
- **流程**:`upload_douyin_video` 提交(3-5 分钟等待)→ `short_video_detail` 查询(content + analysis)→ `scripts/json_tool.py` 保存到 `{产出根目录}/{达人昵称}/视频对标/{视频文件夹}/`
|
||||||
- **可选写库**:环境有 MCN 插件接口时补录 `account_video_source` 并提取选题
|
- **可选写库**:① 环境有 MCN 插件接口(dsh)时补录 `account_video_source` 并提取选题;② **工作台环境(mcn-work-shop,本机 `http://localhost:8900`)**:功能三/五解析产物落盘后调用 `POST /api/import/account`(body `{"account":"{达人昵称}"}`)同步入库——扫描账号产出目录写工作台库 `mcn-plugin.db`(`视频分析/*/analysis.json`→`account_video_analysis`、`content.json`→`account_video_source`、`{达人昵称}账号设定.md`→`account_persona`、`{达人昵称}账号数据分析.md`→`account_analysis`、`短视频表格.xlsx`→`account_videos` 补视频列表),供工作台选题(`/api/ai/topics`)基于真实视频解析内容生成。入库实现见工作台 `server.js`(09-01 新增)
|
||||||
|
|
||||||
## 八、功能六:生成账号设定卡片
|
## 八、功能六:生成账号设定卡片
|
||||||
|
|
||||||
@@ -180,3 +180,4 @@ description: "MCN 达人爆款视频解析与人设卡生成技能:达人账
|
|||||||
3. **人设卡必须基于视频解析结果生成**:不能仅凭账号简介或通用知识编写,每条内容基因和心智模型都必须有视频证据支撑。
|
3. **人设卡必须基于视频解析结果生成**:不能仅凭账号简介或通用知识编写,每条内容基因和心智模型都必须有视频证据支撑。
|
||||||
4. **Excel文件占用**:如达人账号表.xlsx被占用无法直接写入,先保存为临时文件再 `mv -f` 替换。
|
4. **Excel文件占用**:如达人账号表.xlsx被占用无法直接写入,先保存为临时文件再 `mv -f` 替换。
|
||||||
5. **Python脚本临时文件**:写到本技能根目录下的 `tmp/` 目录,完成后清理。
|
5. **Python脚本临时文件**:写到本技能根目录下的 `tmp/` 目录,完成后清理。
|
||||||
|
6. **产物入库标题匹配(红线)**:工作台入库接口按标题匹配 aweme_id,权威映射 = `短视频表格.xlsx`(第2列视频ID),匹配前标题规范化必须删除全角/半角标点(`!`、`“”` 等——xlsx 标题带「!」而解析文件夹名无,未规范化时 4/6 匹配失败并产生 `aweme_id=null` 脏数据,09-01 已修复)。入库接口幂等:同视频重新解析删旧保新,历史 `aweme_id=null AND video_id=null` 脏数据自动清理。
|
||||||
Reference in new issue
Block a user