工作台迭代:技能挂载收口(F1/F2补mcn-dou-analysis)+文案改名(AI脚本评分/AI脚本)+周榜关注按钮+AI写脚本列表页全量分页/关注优先/时间倒序/标题跳详情/发布时间筛选

- 8处/api/run入口全部参数级技能挂载\n- listVideos全量625条分页+关注优先+发布时间倒序+days筛选\n- .gitignore忽略数据库备份文件
This commit is contained in:
maogeigei committed 2026-09-01 18:33:20 +08:00
1 parent f459abe2f2
commit e776bd2c7c
9 files changed
+111 -27

No files matched your search

@@ -68,6 +68,7 @@ for (const vid of VIDEO_IDS) {
const noPersonaNote = personaText ? '' : `\n该账号暂无账号设定,请先为达人「${ACCOUNT_NAME}」提取账号设定(生成人设卡),再基于账号设定与选题创作。`;
const prompt = `请调用 Skill 工具加载「短视频脚本创作」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频脚本创作/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。遵循 S1-S11 完整创作流程,最终输出 S9 标准脚本格式(按场次+对白组织,画面标注【景别|环境|光线|场景|场景元素】),禁止输出分镜头表格。
创作时必须逐条读取并严格执行 SKILL.md 的「开场钩子铁律」与【账号设定】的「内容规则」(如果…则…)——二者为硬约束,不得自由发挥、不得跳过,具体标准一律以 SKILL.md 与内容规则原文为准。
请为达人「${ACCOUNT_NAME}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}${block}${createTail}
【任务完成后必须执行】
@@ -144,28 +144,31 @@ function getAccountAnalysis(accountId) {
/* ================= 视频 ================= */
function listVideos({ page = 1, pageSize = 20, search = '', parsed = '' } = {}) {
function listVideos({ page = 1, pageSize = 20, search = '', parsed = '', days = '' } = {}) {
const d = getDb();
if (!d) return { total: 0, page: 1, pageSize, items: [] };
const where = [`v.id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY publish_time DESC, id DESC) rn FROM account_videos WHERE like_count >= 300000 AND comment_count >= 10000) t WHERE rn <= 10)`];
// 09-01 用户要求:分页展示全部视频(不再限制每账号 30W+/1W+ 取 10 条)
const where = [];
const params = [];
if (search) { where.push('(v.video_title LIKE ? OR h.account_name LIKE ?)'); params.push(`%${search}%`, `%${search}%`); }
if (parsed === '1' || parsed === '0') {
const cond = `(v.id IN (SELECT video_id FROM account_video_source) OR v.id IN (SELECT video_id FROM account_video_analysis))`;
where.push(parsed === '1' ? cond : `NOT (${cond})`);
}
const w = 'WHERE ' + where.join(' AND ');
// 发布时间筛选:days>0 时取近 N 天(默认近一个月由前端传 30;localtime 避免 UTC 边界差一天)
const n = Number(days);
if (n > 0) { where.push(`v.publish_time >= date('now', 'localtime', ?)`); params.push(`-${n} days`); }
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
const total = d.prepare(`SELECT COUNT(*) c FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.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 v.*, h.account_name,
SUM(v.like_count) OVER (PARTITION BY v.account_id) AS acc_likes,
(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,
(SELECT COUNT(*) FROM rewrite_log r WHERE r.video_id=v.id AND r.status='done' AND r.script_text IS NOT NULL AND r.script_text <> '') AS rewrite_count
FROM account_videos v LEFT JOIN hot_accounts h ON h.id=v.account_id
${w} ORDER BY acc_likes DESC, v.publish_time DESC, v.id DESC LIMIT ? OFFSET ?
${w} ORDER BY h.favorite DESC, v.publish_time DESC, v.id DESC LIMIT ? OFFSET ?
`).all(...params, limit, offset);
return { total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
}
@@ -273,7 +276,7 @@ function getStats() {
// AI写作复盘 = 已完成的 AI 脚本复盘数(script_review type='ai')
let reviews = 0;
try { reviews = c(`SELECT COUNT(*) c FROM script_review WHERE type='ai' AND status='done'`); } catch (e) { /* 统计失败不影响其他 */ }
// AI写作评分 = 最新 20 条 AI 复盘总分的平均(保留 1 位小数;无评分返回 null)
// AI脚本评分 = 最新 20 条 AI 复盘总分的平均(保留 1 位小数;无评分返回 null)
let writingScore = null;
try {
const rows = d.prepare(`SELECT total FROM script_review WHERE type='ai' AND status='done' AND total IS NOT NULL AND total <> '' ORDER BY updated_time DESC, id DESC LIMIT 20`).all();
@@ -1,11 +1,11 @@
/* poll-create-tasks.cjs — 轮询 6 个创作任务状态,全部完成/失败后退出 */
const TASKS = [
{ videoId: 309, id: 'automation-1788250646656' },
{ videoId: 313, id: 'automation-1788250649673' },
{ videoId: 315, id: 'automation-1788250652690' },
{ videoId: 316, id: 'automation-1788250655707' },
{ videoId: 318, id: 'automation-1788250658730' },
{ videoId: 320, id: 'automation-1788250661754' },
{ videoId: 309, id: 'automation-1788258518460' },
{ videoId: 313, id: 'automation-1788258521485' },
{ videoId: 315, id: 'automation-1788258524508' },
{ videoId: 316, id: 'automation-1788258527521' },
{ videoId: 318, id: 'automation-1788258530542' },
{ videoId: 320, id: 'automation-1788258533562' },
];
const API = 'http://localhost:8900/api/run/status?id=';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -17,8 +17,8 @@ const status = async (id) => {
} catch { return 'unknown'; }
};
console.log('START polling 6 review tasks:', new Date().toLocaleTimeString('zh-CN'));
(async () => {
const started = Date.now();
const t0 = Date.now();
while (Date.now() - started < 60 * 60 * 1000) {
let allDone = true;
for (const t of TASKS) {
@@ -37,3 +37,4 @@ while (Date.now() - started < 60 * 60 * 1000) {
}
console.log('TIMEOUT after 60min, last states:', JSON.stringify(state));
process.exit(1);
})();
@@ -22,6 +22,10 @@ const EXT_ICON = { md: 'file-text', json: 'file-code', xlsx: 'table', csv: 'tabl
流程:点击按钮 → 确认弹窗 → 提交 /api/run → 按钮变「执行中…」禁用
→ 轮询 /api/run/status 直到任务完成(done/error)→ 按钮恢复 */
/* 账号分析技能指令(09-01 修复:F1/F2 入口任务必须按「mcn-dou-analysis」技能执行,与提炼账号设定入口统一。
定位方式跨环境:优先用 Skill 工具按技能名加载;技能位于用户技能目录 ~/.workbuddy/skills/短视频脚本创作/subskill/mcn-dou-analysis/(部署到用户环境时同此标准位),不写死机器绝对路径 */
const SKILL_HINT_ACCOUNT = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频脚本创作/subskill/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。严格按技能工作流执行(获取账号信息 → 视频列表抓取 → 登记达人账号表 15 列规范),遵守技能红线:TOP6 筛选、≥60 条停止、账号身份标识(sec_uid/抖音号/地址)必须由浏览器获取、禁止从数据库回填。';
// 任务完成后自动刷新当前数据页(无需手动刷新即可看到最新数据)
function refreshCurrentPage() {
const view = $('#view');
@@ -97,9 +101,10 @@ function confirmSaveAccount(name, url, btn) {
desc: '确认分析该账号并添加到账号列表(账号信息 + 视频列表)?',
target: name,
url: url || '',
prompt: `请帮我把抖音账号「${name}」保存并分析(账号信息 + 视频列表):${url || ''}`,
prompt: `${SKILL_HINT_ACCOUNT}\n请帮我把抖音账号「${name}」保存并分析(账号信息 + 视频列表):${url || ''}`,
label: '保存指令',
btn,
skills: ['mcn-dou-analysis'],
});
}
@@ -132,9 +137,9 @@ function openImportAccounts(btn) {
const url = (mask.querySelector('[data-imp-url]').value || '').trim();
if (!name && !url) { showToast('请填写账号名称或账号地址'); return; }
const parts = [name ? `账号名称「${name}」` : '', url ? `账号地址「${url}」` : ''].filter(Boolean).join(',');
const prompt = `请帮我把抖音账号 ${parts} 添加:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地`;
const prompt = `${SKILL_HINT_ACCOUNT}\n请帮我把抖音账号 ${parts} 添加:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地`;
close();
executeTask({ prompt, label: '添加指令', btn });
executeTask({ prompt, label: '添加指令', btn, skills: ['mcn-dou-analysis'] });
});
}
@@ -292,7 +297,7 @@ function renderHome() {
const cards = [
{ icon: 'trending-up', title: '账号周榜', desc: '三方数据 · 赛道/日期筛选', hash: '#/ranking' },
{ icon: 'users', title: '账号列表', desc: '关注的账号 + 视频列表', hash: '#/accounts' },
{ icon: 'pen', title: 'AI写脚本', desc: 'AI生成脚本+分镜提示词', hash: '#/rewrites' },
{ icon: 'pen', title: 'AI写脚本', desc: 'AI脚本+分镜提示词', hash: '#/rewrites' },
{ icon: 'flask', title: 'AI脚本诊断', desc: '检查清单+复盘报告', hash: '#/reviews' },
];
return `
@@ -302,7 +307,7 @@ function renderHome() {
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">视频解析</span></div>
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">AI写脚本</span></div>
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">AI脚本诊断</span></div>
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">AI写作评分</span></div>
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">AI脚本评分</span></div>
</div>
<div class="data-cards">
${cards.map((c) => `<button class="data-card" data-hash="${c.hash}">
@@ -330,7 +335,7 @@ function renderHome() {
<div id="rankPlaceholder" class="rank-placeholder" hidden></div>
</div>
<div class="recent-scripts">
<div class="recent-head"><h3>AI生成脚本</h3><a class="link" href="#/rewrites">查看全部</a></div>
<div class="recent-head"><h3>AI脚本</h3><a class="link" href="#/rewrites">查看全部</a></div>
<div id="recentScriptsList" class="recent-list"><span class="dim">加载中…</span></div>
</div>
</div>`;
@@ -349,7 +354,7 @@ async function loadHomeStats() {
['视频解析', s.analyses],
['AI写脚本', s.rewrites],
['AI脚本诊断', s.reviews],
['AI写作评分', s.writingScore],
['AI脚本评分', s.writingScore],
];
el.innerHTML = items.map(([label, value]) =>
`<div class="home-stat-item"><span class="home-stat-value">${value ?? '–'}</span><span class="home-stat-label">${label}</span></div>`
@@ -4,7 +4,7 @@
/* AI创作技能指令(09-01 修复:任务必须按「短视频脚本创作」技能执行,禁止模型自由发挥导致分镜表格/TXT/MD 格式混杂。
定位方式跨环境:优先用 Skill 工具按技能名加载;技能位于用户技能目录 ~/.workbuddy/skills/短视频脚本创作/(部署到用户环境时同此标准位),不写死机器绝对路径 */
const SKILL_HINT_CREATE = '请调用 Skill 工具加载「短视频脚本创作」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频脚本创作/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。遵循 S1-S11 完整创作流程(选题→框架→大纲→情绪→结构→脚本),最终输出 S9 标准脚本格式(按场次+对白组织,画面标注【景别|环境|光线|场景|场景元素】),禁止输出分镜头表格、禁止偏离脚本格式。';
const SKILL_HINT_CREATE = '请调用 Skill 工具加载「短视频脚本创作」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频脚本创作/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。遵循 S1-S11 完整创作流程(选题→框架→大纲→情绪→结构→脚本),最终输出 S9 标准脚本格式(按场次+对白组织,画面标注【景别|环境|光线|场景|场景元素】),禁止输出分镜头表格、禁止偏离脚本格式。创作时必须逐条读取并严格执行 SKILL.md 的「开场钩子铁律」与【账号设定】的「内容规则」(如果…则…)——二者为硬约束,不得自由发挥、不得跳过,具体标准一律以 SKILL.md 与内容规则原文为准。';
const fmt = (v) => (v == null || v === '') ? '-' : String(v);
/* 过万数字以"万"为单位,保留一位小数(如 57141 → 5.7万);已是"x.x万"文本(like_display 抖音原生格式)直接透传(09-01 修复:旧实现 Number('7.8万')=NaN→'-') */
@@ -232,7 +232,7 @@ async function renderRanking(view) {
<td class="num">${fmt(r.newLikes)}</td>
<td class="num">${fmt(r.newComments)}</td>
<td class="num">${fmt(r.newShares)}</td>
<td class="cell-act">${r.inDb ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-import='${esc(JSON.stringify(r))}'>分析</button>`}</td>
<td class="cell-act">${r.inDb ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-import='${esc(JSON.stringify(r))}'>关注</button>`}</td>
</tr>`).join('');
view.innerHTML = `
<div class="page">
@@ -569,13 +569,13 @@ async function renderVideoDetail(view, id) {
/* ================= AI写脚本 ================= */
async function renderRewrites(view) {
let state = { search: '', parsed: '', page: 1, pageSize: 20, batch: false };
let state = { search: '', parsed: '', days: '30', page: 1, pageSize: 20, batch: false };
async function load() {
const d = await api('/api/dsh/videos?search=' + encodeURIComponent(state.search) + '&parsed=' + state.parsed + '&page=' + state.page + '&pageSize=' + state.pageSize);
const d = await api('/api/dsh/videos?search=' + encodeURIComponent(state.search) + '&parsed=' + state.parsed + '&days=' + state.days + '&page=' + state.page + '&pageSize=' + state.pageSize);
const rows = (d.items || []).map((v) => `<tr>
${state.batch ? `<td class="cb-cell"><input type="checkbox" class="batch-cb" data-id="${v.id}"></td>` : ''}
<td>${v.account_id ? `<a class="link" href="#/account/${v.account_id}" title="查看账号详情">${esc(v.account_name || '-')}</a>` : esc(v.account_name || '-')}</td>
<td class="cell-content">${v.video_url ? `<a class="title-plain" data-ext="${esc(v.video_url)}" title="在新标签打开原视频">${esc(cleanTitle(v.video_title) || '-')}</a>` : esc(cleanTitle(v.video_title) || '-')}</td>
<td class="cell-content td-title"><a class="link" href="#/video/${v.id}" title="查看视频详情">${esc(cleanTitle(v.video_title) || '-')}</a></td>
<td>${fmtWan(v.like_display || v.like_count)}</td>
<td>${fmtWan(v.comment_count)}</td>
<td>${fmtWan(v.share_count)}</td>
@@ -592,6 +592,13 @@ async function renderRewrites(view) {
<span class="page-title">AI写脚本</span>
<div class="toolbar-right" style="margin-right:10px">
<input id="rwSearch" class="input" placeholder="搜索视频标题 / 账号" value="${esc(state.search)}">
<select id="rwDays" class="input-select">
<option value="" ${state.days === '' ? 'selected' : ''}>全部时间</option>
<option value="30" ${state.days === '30' ? 'selected' : ''}>近一个月</option>
<option value="90" ${state.days === '90' ? 'selected' : ''}>近三个月</option>
<option value="180" ${state.days === '180' ? 'selected' : ''}>近半年</option>
<option value="365" ${state.days === '365' ? 'selected' : ''}>近一年</option>
</select>
<select id="rwParsed" class="input-select">
<option value="" ${state.parsed === '' ? 'selected' : ''}>全部状态</option>
<option value="1" ${state.parsed === '1' ? 'selected' : ''}>已解析</option>
@@ -599,7 +606,7 @@ async function renderRewrites(view) {
</select>
</div>
</div>
<div class="toolbar-note">每账号近期 30W+ 且评论 1W+ 取 10 条</div>
<div class="toolbar-note">全部视频分页展示 · 关注账号优先 · 按发布时间倒序</div>
<div class="table-wrap"><table class="table">
<thead><tr>${state.batch ? '<th class="cb-cell"><input type="checkbox" class="batch-cb-all"></th>' : ''}<th>账号</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>状态</th><th>AI脚本</th><th class="th-act">操作</th></tr></thead>
<tbody>${rows || `<tr><td colspan="${state.batch ? 11 : 10}" class="empty">暂无视频数据</td></tr>`}</tbody>
@@ -611,6 +618,7 @@ async function renderRewrites(view) {
</div>`;
bindBack(view);
view.querySelector('#rwSearch').addEventListener('keydown', (e) => { if (e.key === 'Enter') { state.search = e.target.value; state.page = 1; load(); } });
view.querySelector('#rwDays').addEventListener('change', (e) => { state.days = e.target.value; state.page = 1; load(); });
view.querySelector('#rwParsed').addEventListener('change', (e) => { state.parsed = e.target.value; state.page = 1; load(); });
bindPager(view, (p) => { state.page = p; load(); });
bindBatchBar(view, {
@@ -460,7 +460,7 @@ const server = http.createServer((req, res) => {
}
if (p === '/api/dsh/videos') {
if (dshGuard()) return;
return dshOk(200, dsh.listVideos({ page: qInt('page', 1), pageSize: qInt('pageSize', 20), search: String(q.get('search') || ''), parsed: String(q.get('parsed') || '') }));
return dshOk(200, dsh.listVideos({ page: qInt('page', 1), pageSize: qInt('pageSize', 20), search: String(q.get('search') || ''), parsed: String(q.get('parsed') || ''), days: String(q.get('days') || '') }));
}
if (p === '/api/dsh/video-detail') {
if (dshGuard()) return;