From b3e719f3858f8639dbad627ab106bb21aed7c8e8 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Fri, 4 Sep 2026 17:57:15 +0800 Subject: [PATCH] =?UTF-8?q?R64-R70:=20=E5=B7=A5=E4=BD=9C=E5=8F=B0=E8=BF=AD?= =?UTF-8?q?=E4=BB=A3=E2=80=94=E8=A7=86=E9=A2=91=E8=A7=A3=E6=9E=90=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E7=8A=B6=E6=80=81=E8=B7=A8=E5=88=B7=E6=96=B0/?= =?UTF-8?q?=E6=89=BE=E4=B8=8D=E5=88=B0MCP=E6=A0=B9=E5=9B=A0=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D/=E5=85=A8=E9=87=8F=E6=8E=92=E6=9F=A5=E9=9C=80MCP?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=8C=82connector/=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=BC=BA=E5=88=B6=E9=87=8D=E6=96=B0=E8=8E=B7?= =?UTF-8?q?=E5=8F=96/=E8=87=AA=E5=AE=9A=E4=B9=89=E9=80=89=E9=A2=98?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=E6=94=B9=E5=90=8D=E5=8E=9F=E5=88=9B=E9=80=89?= =?UTF-8?q?=E9=A2=98=E5=88=9B=E4=BD=9C+=E7=B3=BB=E5=88=97UI=E5=BE=AE?= =?UTF-8?q?=E8=B0=83(cdp-verify=E5=9B=9E=E5=BD=92=E8=84=9A=E6=9C=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V1.0/mcn-work-shop/cdp-verify-r64-acc.mjs | 44 ++++++ .../V1.0/mcn-work-shop/cdp-verify-r64-del.mjs | 53 +++++++ .../V1.0/mcn-work-shop/cdp-verify-r64.mjs | 81 ++++++++++ .../V1.0/mcn-work-shop/cdp-verify-r65.mjs | 61 ++++++++ .../V1.0/mcn-work-shop/cdp-verify-r67.mjs | 37 +++++ .../V1.0/mcn-work-shop/cdp-verify-r68.mjs | 62 ++++++++ .../V1.0/mcn-work-shop/cdp-verify-r68b.mjs | 70 +++++++++ .../V1.0/mcn-work-shop/cdp-verify-r69.mjs | 140 ++++++++++++++++++ .../V1.0/mcn-work-shop/cdp-verify-r70.mjs | 58 ++++++++ .../V1.0/mcn-work-shop/dsh-data.js | 12 +- .../V1.0/mcn-work-shop/public/app.js | 26 +++- .../V1.0/mcn-work-shop/public/data-pages.js | 107 +++++++++---- .../V1.0/mcn-work-shop/public/help-guide.js | 4 +- .../V1.0/mcn-work-shop/public/index.html | 8 +- .../V1.0/mcn-work-shop/public/style.css | 14 +- .../V1.0/mcn-work-shop/server.js | 25 +++- .../V1.0/mcn-work-shop/start.bat | 8 +- 17 files changed, 745 insertions(+), 65 deletions(-) create mode 100644 project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r64-acc.mjs create mode 100644 project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r64-del.mjs create mode 100644 project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r64.mjs create mode 100644 project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r65.mjs create mode 100644 project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r67.mjs create mode 100644 project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r68.mjs create mode 100644 project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r68b.mjs create mode 100644 project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r69.mjs create mode 100644 project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r70.mjs diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r64-acc.mjs b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r64-acc.mjs new file mode 100644 index 0000000..6447cdb --- /dev/null +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r64-acc.mjs @@ -0,0 +1,44 @@ +// 09-04 R64.2 验证:账号列表「刷新」(更新)按钮去文字留图标 +const CDP = 9223; +const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json()); +const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page'); +if (!page) { console.log('ERR: no page'); process.exit(1); } +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pending = new Map(); +const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); +ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } }; +await new Promise((r) => (ws.onopen = r)); +const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); +await send('Network.setCacheDisabled', { cacheDisabled: true }); +await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false }); +const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; }; +const fail = (m) => { console.log('FAIL@' + m); process.exit(1); }; +const fs = await import('node:fs'); + +await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/accounts` }); +if (!(await waitFor(`location.hash === '#/accounts' && !!document.querySelector('#accUpdate') && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904n'))`, 15000))) fail('accounts route'); +await sleep(500); + +const r = await ev(`(() => { + const b = document.querySelector('#accUpdate'); + if (!b) return { err: 'no #accUpdate' }; + const text = (b.textContent || '').trim(); + const svg = b.querySelector('svg'); + const rect = b.getBoundingClientRect(); + return { text, hasSvg: !!svg, svgViewBox: svg ? svg.getAttribute('viewBox') : null, w: Math.round(rect.width), h: Math.round(rect.height), title: b.getAttribute('title') }; +})()`); +console.log(JSON.stringify(r, null, 2)); + +await ev(`document.querySelector('#accUpdate').scrollIntoView({ block: 'center' })`); await sleep(200); +// 截图按钮所在 toolbar +const rect = await ev(`(() => { const el = document.querySelector('.acc-toolbar'); const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`); +if (rect && !rect.__err) { + const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 4), y: Math.max(0, rect.y - 4), width: Math.min(rect.w + 8, 1200), height: Math.min(rect.h + 8, 800), scale: 1 } }); + fs.writeFileSync(`tmp/r64_acc_toolbar.png`, Buffer.from(shot.data, 'base64')); +} +const ok = r && !r.__err && r.text === '' && r.hasSvg; +console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ==='); +ws.close(); +process.exit(ok ? 0 : 1); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r64-del.mjs b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r64-del.mjs new file mode 100644 index 0000000..e0abb51 --- /dev/null +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r64-del.mjs @@ -0,0 +1,53 @@ +// 09-04 R64.3 验证:AI 脚本弹窗「删除」按钮去文字留图标 +const CDP = 9223; +const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json()); +const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page'); +if (!page) { console.log('ERR: no page'); process.exit(1); } +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pending = new Map(); +const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); +ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } }; +await new Promise((r) => (ws.onopen = r)); +const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); +await send('Network.setCacheDisabled', { cacheDisabled: true }); +await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false }); +const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; }; +const fail = (m) => { console.log('FAIL@' + m); process.exit(1); }; +const fs = await import('node:fs'); + +await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/rewrites` }); +if (!(await waitFor(`location.hash === '#/rewrites' && !!document.querySelector('#view .rw-tabs') && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904o'))`, 15000))) fail('rewrites route'); +await sleep(600); + +// 打开弹窗:优先 ref tab 行 data-detail,其次切 custom tab 行 data-cview +let opened = await ev(`(() => { const b = document.querySelector('[data-detail], [data-cview], [data-rewrite]'); if (!b) return false; b.click(); return true; })()`); +if (!opened) { + // 尝试切到 custom tab + await ev(`[...document.querySelectorAll('#view .rw-tabs [data-tab]')].find((t) => t.textContent.includes('自定义选题'))?.click()`); + await sleep(800); + opened = await ev(`(() => { const b = document.querySelector('[data-cview]'); if (!b) return false; b.click(); return true; })()`); +} +if (!opened) { console.log('SKIP: no script row to open'); process.exit(2); } +if (!(await waitFor(`!!document.querySelector('.rw-modal')`, 8000))) fail('modal open'); +await sleep(700); + +const r = await ev(`(() => { + const m = document.querySelector('.rw-modal'); + if (!m) return { err: 'no .rw-modal' }; + const d = m.querySelector('[data-del-script]'); + if (!d) return { err: 'no [data-del-script]' }; + return { text: (d.textContent || '').trim(), hasSvg: !!d.querySelector('svg'), title: d.getAttribute('title'), footHtml: (m.querySelector('.rw-sec-foot')?.textContent || '').trim().slice(0, 40) }; +})()`); +console.log(JSON.stringify(r, null, 2)); + +const rect = await ev(`(() => { const el = document.querySelector('[data-del-script]'); const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`); +if (rect && !rect.__err) { + const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 140), y: Math.max(0, rect.y - 30), width: 300, height: rect.h + 60, scale: 1 } }); + fs.writeFileSync(`tmp/r64_del_btn.png`, Buffer.from(shot.data, 'base64')); +} +const ok = r && !r.__err && r.text === '' && r.hasSvg; +console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ==='); +ws.close(); +process.exit(ok ? 0 : 1); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r64.mjs b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r64.mjs new file mode 100644 index 0000000..64b0fd6 --- /dev/null +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r64.mjs @@ -0,0 +1,81 @@ +// 09-04 R64 验证:内容定位首板块与头部大标题框间距加大(y 起点 22→34) +// 断言:① 头部框(y30 h170)底 → 第一张整宽米色卡顶 gap = 64(旧值 52)② 「内容定位」标题存在 ③ 原 R59 断言保持(白底/gap≥20/不越界/含内容结构) +const CDP = 9223; +const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json()); +const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page'); +if (!page) { console.log('ERR: no page'); process.exit(1); } +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pending = new Map(); +const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); +ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } }; +await new Promise((r) => (ws.onopen = r)); +const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); +await send('Network.setCacheDisabled', { cacheDisabled: true }); +await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false }); +const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; }; +const fail = (m) => { console.log('FAIL@' + m); process.exit(1); }; +const fs = await import('node:fs'); + +const AUDIT = `(() => { + const svg = document.querySelector('.vk-view svg'); + if (!svg) return { err: 'no svg in .vk-view' }; + const vbH = svg.viewBox.baseVal.height; + const rects = [...svg.querySelectorAll('rect')].map((r) => ({ x: Math.round(r.x.baseVal.value * 10) / 10, y: Math.round(r.y.baseVal.value * 10) / 10, w: Math.round(r.width.baseVal.value), h: Math.round(r.height.baseVal.value), fill: r.getAttribute('fill') })); + const texts = [...svg.querySelectorAll('text')].map((t) => ({ x: +t.getAttribute('x'), y: +t.getAttribute('y'), size: +t.getAttribute('font-size'), wt: t.getAttribute('font-weight'), txt: (t.textContent || '').slice(0, 42) })); + const head = rects.find((r) => r.w === 520 && r.h === 170); /* 头部大标题框 */ + const headBottom = head ? head.y + head.h : null; + const cards = rects.filter((r) => (r.fill || '').startsWith('#F') && r.w > 550 && r.h > 40); /* 整宽内容卡(w>550 排除 520 头部标题框) */ + const firstCard = cards[0]; + const titleYs = texts.filter((t) => t.txt.startsWith('▍')).map((t) => ({ t: t.txt.slice(1), y: t.y })); + const firstTitle = titleYs[0]; /* 应为「内容定位」 */ + const audits = cards.map((r) => { + const inT = texts.filter((t) => t.y > r.y + 2 && t.y < r.y + r.h && t.x >= r.x - 1 && t.x <= r.x + r.w + 1).sort((a, b) => a.y - b.y); + const first = inT[0]; + return { cardTop: r.y, h: r.h, firstTxt: first ? first.txt : '', gap: first ? Math.round((first.y - r.y) * 10) / 10 : null }; + }); + const structCard = cards[1]; + const cardByTitle = {}; + cards.forEach((r) => { const t = titleYs.find((x) => x.y < r.y && r.y - x.y < 60); if (t) cardByTitle[t.t] = r; }); + const secCard = cardByTitle['内容结构']; + const secBoard = secCard ? texts.filter((t) => t.y > secCard.y && t.y < secCard.y + secCard.h && (t.wt || '') === '700' && t.x > secCard.x && t.x < secCard.x + 120).map((t) => t.txt) : []; + const bgStops = [...svg.querySelectorAll('#bgGrad stop')].map((s) => s.getAttribute('stop-color')); + const maxTextY = Math.max(...texts.map((t) => t.y)); + return { vbH, headBottom, firstCardTop: firstCard ? firstCard.y : null, headGap: (headBottom != null && firstCard) ? Math.round((firstCard.y - headBottom) * 10) / 10 : null, firstTitle: firstTitle ? { t: firstTitle.t, y: firstTitle.y, dt: Math.round((firstTitle.y - headBottom) * 10) / 10 } : null, titles: titleYs.map((x) => x.t), audits, secBoard, bgStops, over: maxTextY > vbH - 4 }; +})()`; + +const VIDS = [93, 190]; +const out = {}; +for (const id of VIDS) { + await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/video/${id}` }); + if (!(await waitFor(`location.hash === '#/video/${id}' && !!document.querySelector('#view .video-grid') && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904m'))`, 15000))) fail(`v${id} route`); + await sleep(400); + await ev(`[...document.querySelectorAll('#view .tabs .tab')].find((t) => t.textContent.trim() === '视频拆解').click()`); + const hasCard = await waitFor(`!!document.querySelector('#view .vk-view svg')`, 12000); + if (!hasCard) { out['v' + id] = { __err: 'no keycard svg' }; continue; } + await sleep(500); + const audit = await ev(AUDIT); + out['v' + id] = audit; + await ev(`document.querySelector('.vk-view svg').scrollIntoView({ block: 'start' })`); await sleep(250); + const rect = await ev(`(() => { const r = document.querySelector('.vk-view svg').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`); + if (rect && !rect.__err && rect.w > 100) { + const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 4), y: Math.max(0, rect.y - 4), width: Math.min(rect.w + 8, 1200), height: Math.min(rect.h + 8, 4000), scale: 1 } }); + fs.writeFileSync(`tmp/r64_gap_v${id}.png`, Buffer.from(shot.data, 'base64')); + out['v' + id].shot = `tmp/r64_gap_v${id}.png`; + } +} + +console.log(JSON.stringify(out, null, 1)); +const v93 = out.v93, v190 = out.v190; +const ok = v93 && !v93.__err && v190 && !v190.__err + && v93.headGap === 64 && v190.headGap === 64 + && v93.firstTitle && v93.firstTitle.t === '内容定位' && v190.firstTitle && v190.firstTitle.t === '内容定位' + && v93.bgStops && v93.bgStops.every((c) => c === '#FFFFFF') && v190.bgStops && v190.bgStops.every((c) => c === '#FFFFFF') + && v93.titles.includes('内容结构') && v190.titles.includes('内容结构') + && v93.secBoard && v93.secBoard.some((b) => b.includes('开场钩子')) && v190.secBoard && v190.secBoard.some((b) => b.includes('开场钩子')) + && v93.audits.every((a) => a.gap >= 20) && v190.audits.every((a) => a.gap >= 20) + && !v93.over && !v190.over; +console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ==='); +ws.close(); +process.exit(ok ? 0 : 1); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r65.mjs b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r65.mjs new file mode 100644 index 0000000..c7c7c66 --- /dev/null +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r65.mjs @@ -0,0 +1,61 @@ +// 09-04 R65 验证:视频解析任务「执行中」状态跨刷新保持 + 终态自动恢复 +// 模拟:先写入 wk_video_task_93(不存在的 taskId)→ 打开视频详情 → 应显示「执行中」 +// → watch 轮询 /api/run/status → notfound → 清理 localStorage + load 恢复可点按钮 +const CDP = 9223; +const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json()); +const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page'); +if (!page) { console.log('ERR: no page'); process.exit(1); } +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pending = new Map(); +const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); +ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } }; +await new Promise((r) => (ws.onopen = r)); +const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); +await send('Network.setCacheDisabled', { cacheDisabled: true }); +await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false }); +const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; }; +const fail = (m) => { console.log('FAIL@' + m); process.exit(1); }; +const fs = await import('node:fs'); + +// 1) 注入伪造执行中记录后打开视频 93 +await ev(`localStorage.setItem('wk_video_task_93', JSON.stringify({ taskId: 'nonexist-test-1', ts: Date.now() }))`); +await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/video/93` }); +if (!(await waitFor(`location.hash === '#/video/93' && !!document.querySelector('#view .video-grid') && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904p'))`, 15000))) fail('v93 route'); +await sleep(600); + +// 2) 断言:出现「执行中」禁用按钮 +const running1 = await ev(`(() => { + const b = document.querySelector('.tab-tools .btn-busy, .empty-state .btn-busy'); + const rec = localStorage.getItem('wk_video_task_93'); + return { busy: !!b, txt: b ? (b.textContent || '').trim() : null, disabled: b ? b.disabled : null, recKept: !!rec, v93Running: location.hash }; +})()`); +console.log('STEP1 running state:', JSON.stringify(running1)); + +// 3) 等 watch 轮询(间隔 5s)走到 notfound → 清理 + load 恢复 +const recovered = await waitFor(`!localStorage.getItem('wk_video_task_93') && !!document.querySelector('[data-parse]') && !document.querySelector('.tab-tools .btn-busy, .empty-state .btn-busy')`, 25000); +console.log('STEP2 recovered:', recovered); +const state2 = await ev(`(() => { + const b = document.querySelector('[data-parse]'); + return { parseBtn: b ? (b.textContent || '').trim() : null, disabled: b ? b.disabled : null, rec: localStorage.getItem('wk_video_task_93') }; +})()`); +console.log('STEP2 final:', JSON.stringify(state2)); + +// 4) 清理测试残留并截图执行中形态(重注入后快速抓拍) +await ev(`localStorage.setItem('wk_video_task_93', JSON.stringify({ taskId: 'nonexist-test-2', ts: Date.now() }))`); +await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/video/93` }); +await waitFor(`!!document.querySelector('.tab-tools .btn-busy, .empty-state .btn-busy')`, 10000); +await sleep(400); +const rect = await ev(`(() => { const el = document.querySelector('.tab-tools .btn-busy, .empty-state .btn-busy'); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`); +if (rect) { + const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 160), y: Math.max(0, rect.y - 12), width: rect.w + 300, height: rect.h + 24, scale: 1 } }); + fs.writeFileSync('tmp/r65_running_btn.png', Buffer.from(shot.data, 'base64')); +} +// 等 notfound 清理 +await waitFor(`!localStorage.getItem('wk_video_task_93')`, 20000); + +const ok = running1 && running1.busy && running1.txt && running1.txt.includes('执行中') && running1.disabled === true && recovered && state2 && state2.rec === null && state2.parseBtn; +console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ==='); +ws.close(); +process.exit(ok ? 0 : 1); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r67.mjs b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r67.mjs new file mode 100644 index 0000000..16d684f --- /dev/null +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r67.mjs @@ -0,0 +1,37 @@ +// R67 冒烟:新版本资源加载 + MYAI_CONNECTORS 可达 + 无 console error +const CDP = 9223; +const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json()); +const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page'); +if (!page) { console.log('ERR: no page'); process.exit(1); } +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pending = new Map(); +const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); +ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } }; +await new Promise((r) => (ws.onopen = r)); +const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; return r.result.value; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); +await send('Network.setCacheDisabled', { cacheDisabled: true }); +await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false }); +const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; }; + +const errors = []; +const onExc = (e) => { try { const m = JSON.parse(e.data); if (m.method === 'Runtime.exceptionThrown') errors.push((m.params.exceptionDetails?.exception?.description || m.params.exceptionDetails?.text || '').slice(0, 200)); } catch (x) {} }; +ws.onmessage = ((orig) => (e) => { onExc(e); orig(e); })(ws.onmessage); + +await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/accounts` }); +if (!(await waitFor(`location.hash === '#/accounts' && !!document.querySelector('#accUpdate') && [...document.scripts].some((s) => (s.src || '').includes('app.js?v=20260904i')) && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904r'))`, 15000))) { console.log('FAIL: version resources not loaded'); process.exit(1); } +await sleep(600); +const r = await ev(`(() => { + // 常量经全局词法环境可达(跨 script);模拟一次 executeTask 入参构造不抛错 + let connOk = false, fnOk = false; + try { connOk = (typeof MYAI_CONNECTORS !== 'undefined') && Array.isArray(MYAI_CONNECTORS) && MYAI_CONNECTORS[0] === 'custom-mcp:myai-mcp-production'; } catch (e) {} + try { fnOk = typeof executeTask === 'function' && typeof confirmVideoParse === 'function'; } catch (e) {} + return { connOk, fnOk, title: document.title, hasUpdate: !!document.querySelector('#accUpdate') }; +})()`); +console.log(JSON.stringify(r, null, 2)); +console.log('console/page errors:', errors.length ? JSON.stringify(errors.slice(0, 3)) : 'none'); +const ok = r && !r.__err && r.connOk && r.fnOk && errors.length === 0; +console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ==='); +ws.close(); +process.exit(ok ? 0 : 1); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r68.mjs b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r68.mjs new file mode 100644 index 0000000..80bd38d --- /dev/null +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r68.mjs @@ -0,0 +1,62 @@ +// R68 验证:账号信息 / 视频信息属性内容宽度 180px +const CDP = 9223; +const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json()); +const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page'); +if (!page) { console.log('ERR: no page'); process.exit(1); } +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pending = new Map(); +const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); +ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } }; +await new Promise((r) => (ws.onopen = r)); +const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; return r.result.value; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); +await send('Network.setCacheDisabled', { cacheDisabled: true }); +await send('Emulation.setDeviceMetricsOverride', { width: 900, height: 1100, deviceScaleFactor: 1, mobile: false }); +const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; }; +const fs = await import('node:fs'); + +const out = {}; +// 1) 视频详情 +await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/video/93` }); +if (!(await waitFor(`location.hash === '#/video/93' && !!document.querySelector('#view .video-grid') && [...document.styleSheets].some((s) => (s.href || '').includes('style.css?v=20260904i'))`, 15000))) { console.log('FAIL video route'); process.exit(1); } +await sleep(500); +const vw = await ev(`(() => { + const items = [...document.querySelectorAll('.video-info .video-grid .info-item:not(.info-wide)')]; + if (!items.length) return { err: 'no items' }; + const widths = items.slice(0, 4).map((el) => Math.round(el.getBoundingClientRect().width)); + const body = document.querySelector('.detail-body') || document.querySelector('main'); + const over = items.some((el) => el.getBoundingClientRect().right > (body?.getBoundingClientRect().right || 9999)); + return { widths, over, n: items.length }; +})()`); +out.video = vw; + +// 2) 账号详情(取第一个账号) +const accId = await ev(`fetch('/api/dsh/accounts?page=1&pageSize=1').then(r=>r.json()).then(d => d.items && d.items[0] ? d.items[0].id : null).catch(()=>null)`); +if (accId) { + await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/account/${accId}` }); + if (!(await waitFor(`location.hash === '#/account/${accId}' && !!document.querySelector('.account-info .account-grid')`, 15000))) { console.log('FAIL account route'); process.exit(1); } + await sleep(500); + const aw = await ev(`(() => { + const items = [...document.querySelectorAll('.account-info .account-grid .info-item')]; + if (!items.length) return { err: 'no items' }; + const widths = items.slice(0, 4).map((el) => Math.round(el.getBoundingClientRect().width)); + const body = document.querySelector('.detail-body') || document.querySelector('main'); + const over = items.some((el) => el.getBoundingClientRect().right > (body?.getBoundingClientRect().right || 9999)); + return { widths, over, n: items.length, acc: '#' + location.hash }; + })()`); + out.account = aw; + // 截图账号信息卡 + const rect = await ev(`(() => { const el = document.querySelector('.account-info'); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`); + if (rect) { + const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 6), y: Math.max(0, rect.y - 6), width: Math.min(rect.w + 12, 1200), height: Math.min(rect.h + 12, 900), scale: 1 } }); + fs.writeFileSync('tmp/r68_account_info.png', Buffer.from(shot.data, 'base64')); + } +} else { out.account = { err: 'no account in db' }; } + +console.log(JSON.stringify(out, null, 1)); +const ok = out.video && !out.video.err && out.video.widths.length && out.video.widths.every((w) => w === 180) && !out.video.over + && out.account && !out.account.err && out.account.widths.length && out.account.widths.every((w) => w === 180) && !out.account.over; +console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ==='); +ws.close(); +process.exit(ok ? 0 : 1); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r68b.mjs b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r68b.mjs new file mode 100644 index 0000000..09f4489 --- /dev/null +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r68b.mjs @@ -0,0 +1,70 @@ +// R68b 验证:视频关键信息卡 板块标题→内容卡间距 30→22(标题与卡更靠近),原断言保持 +const CDP = 9223; +const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json()); +const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page'); +if (!page) { console.log('ERR: no page'); process.exit(1); } +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pending = new Map(); +const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); +ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } }; +await new Promise((r) => (ws.onopen = r)); +const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; return r.result.value; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); +await send('Network.setCacheDisabled', { cacheDisabled: true }); +await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false }); +const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; }; +const fail = (m) => { console.log('FAIL@' + m); process.exit(1); }; +const fs = await import('node:fs'); + +const AUDIT = `(() => { + const svg = document.querySelector('.vk-view svg'); + if (!svg) return { err: 'no svg' }; + const vbH = svg.viewBox.baseVal.height; + const rects = [...svg.querySelectorAll('rect')].map((r) => ({ x: Math.round(r.x.baseVal.value * 10) / 10, y: Math.round(r.y.baseVal.value * 10) / 10, w: Math.round(r.width.baseVal.value), h: Math.round(r.height.baseVal.value), fill: r.getAttribute('fill') })); + const texts = [...svg.querySelectorAll('text')].map((t) => ({ x: +t.getAttribute('x'), y: +t.getAttribute('y'), size: +t.getAttribute('font-size'), wt: t.getAttribute('font-weight'), txt: (t.textContent || '').slice(0, 42) })); + const cards = rects.filter((r) => (r.fill || '').startsWith('#F') && r.w > 550 && r.h > 40); + const titleYs = texts.filter((t) => t.txt.startsWith('▍')).map((t) => ({ t: t.txt.slice(1), y: t.y })); + const cardByTitle = {}; + cards.forEach((r) => { const t = titleYs.find((x) => x.y < r.y && r.y - x.y < 60); if (t) cardByTitle[t.t] = r; }); + const gaps = Object.entries(cardByTitle).map(([t, r]) => ({ t, gap: Math.round((r.y - titleYs.find((x) => x.t === t).y) * 10) / 10 })); + const audits = cards.map((r) => { + const inT = texts.filter((t) => t.y > r.y + 2 && t.y < r.y + r.h && t.x >= r.x - 1 && t.x <= r.x + r.w + 1).sort((a, b) => a.y - b.y); + const first = inT[0]; + return { firstTxt: first ? first.txt : '', gap: first ? Math.round((first.y - r.y) * 10) / 10 : null }; + }); + const secCard = cardByTitle['内容结构']; + const secBoard = secCard ? texts.filter((t) => t.y > secCard.y && t.y < secCard.y + secCard.h && (t.wt || '') === '700' && t.x > secCard.x && t.x < secCard.x + 120).map((t) => t.txt) : []; + const bgStops = [...svg.querySelectorAll('#bgGrad stop')].map((s) => s.getAttribute('stop-color')); + const maxTextY = Math.max(...texts.map((t) => t.y)); + return { vbH, gaps, audits, secBoard, bgStops, over: maxTextY > vbH - 4 }; +})()`; + +const VIDS = [93, 190]; +const out = {}; +for (const id of VIDS) { + await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/video/${id}` }); + if (!(await waitFor(`location.hash === '#/video/${id}' && !!document.querySelector('#view .video-grid') && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904s'))`, 15000))) fail(`v${id} route`); + await sleep(400); + await ev(`[...document.querySelectorAll('#view .tabs .tab')].find((t) => t.textContent.trim() === '视频拆解').click()`); + if (!(await waitFor(`!!document.querySelector('#view .vk-view svg')`, 12000))) { out['v' + id] = { __err: 'no svg' }; continue; } + await sleep(500); + out['v' + id] = await ev(AUDIT); + await ev(`document.querySelector('.vk-view svg').scrollIntoView({ block: 'start' })`); await sleep(250); + const rect = await ev(`(() => { const r = document.querySelector('.vk-view svg').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`); + if (rect && !rect.__err) { + const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 4), y: Math.max(0, rect.y - 4), width: Math.min(rect.w + 8, 1200), height: Math.min(rect.h + 8, 2600), scale: 1 } }); + fs.writeFileSync(`tmp/r68b_v${id}.png`, Buffer.from(shot.data, 'base64')); + } +} +console.log(JSON.stringify(out, null, 1)); +const v93 = out.v93, v190 = out.v190; +const ok = v93 && !v93.__err && v190 && !v190.__err + && v93.gaps.length && v93.gaps.every((g) => g.gap === 22) && v190.gaps.length && v190.gaps.every((g) => g.gap === 22) + && v93.secBoard.some((b) => b.includes('开场钩子')) && v190.secBoard.some((b) => b.includes('开场钩子')) + && v93.bgStops.every((c) => c === '#FFFFFF') && v190.bgStops.every((c) => c === '#FFFFFF') + && v93.audits.every((a) => a.gap >= 20) && v190.audits.every((a) => a.gap >= 20) + && !v93.over && !v190.over; +console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ==='); +ws.close(); +process.exit(ok ? 0 : 1); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r69.mjs b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r69.mjs new file mode 100644 index 0000000..db1fcaf --- /dev/null +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r69.mjs @@ -0,0 +1,140 @@ +// R69 验证:三类「刷新」任务的 prompt 均注入「强制重新获取并覆盖」语义 +// ①视频详情 刷新 → prompt 含 VIDEO_REFRESH_OVERRIDE 覆盖刷新段(禁判断/禁复用旧结果/防截断覆盖) +// ②榜单 冷却窗口内「仍要更新」(force) → prompt 追加 覆盖刷新段(忽略幂等跳过) +// ③榜单 常规「确认更新」(非 force) → prompt 不含 force 覆盖段(幂等铁律保留) +// 实现:页面内拦截 fetch——捕获 POST /api/run 与 /api/dsh/ranking-update 的 body,伪造响应使流程走完但不真建任务 +const CDP = 9223; +const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json()); +const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page'); +if (!page) { console.log('ERR: no page'); process.exit(1); } +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pending = new Map(); +const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); +ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } }; +await new Promise((r) => (ws.onopen = r)); +const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); +await send('Network.setCacheDisabled', { cacheDisabled: true }); +await send('Emulation.setDeviceMetricsOverride', { width: 900, height: 1100, deviceScaleFactor: 1, mobile: false }); +const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; }; +const fail = (m) => { console.log('FAIL@' + m); process.exit(1); }; + +const V = '20260904t'; +const loaded = `[...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=${V}')) && typeof VIDEO_REFRESH_OVERRIDE === 'string'`; + +// ---- 注入 fetch 拦截:捕获 run/ranking-update 请求体,伪造响应(不真建任务)---- +const INJECT = `(() => { + window.__r69 = { runs: [], rankPost: [], videoPrompt: null, rankGetMock: 1 }; + const realFetch = window.fetch.bind(window); + const json = (obj, ok = true) => ({ ok, json: async () => obj }); + window.fetch = async (url, opts) => { + const u = String(url); const m = (opts && opts.method) || 'GET'; + if (u.includes('/api/run/status')) return json({ ok: true, id: 'probe', state: 'notfound' }); + if (u.endsWith('/api/run') && m.toUpperCase() === 'POST') { + let body = ''; try { body = JSON.parse((opts.body) || '{}'); } catch (e) {} + if (body && body.prompt) window.__r69.videoPrompt = body.prompt; + if (body && body.name && String(body.name).startsWith('更新账号·')) window.__r69.accPrompt = body.prompt; + window.__r69.runs.push({ name: body.name || '', skills: body.skills || [], connectors: body.connectorIds || null, prompt: (body.prompt || '').slice(0, 2000) }); + return json({ ok: true, id: 'probe-run' }); + } + if (u.endsWith('/api/dsh/ranking-update') && m.toUpperCase() === 'GET') { + const recent = window.__r69.rankGetMock === 1; + return json({ ok: true, exists: recent, files: [], lastUpdateAt: recent ? Date.now() - 60000 : null, lastUpdateDate: recent ? '2026-09-04' : '', lastUpdateAgeMin: recent ? 1 : null, cooldownMin: 30, task: null }); + } + if (u.endsWith('/api/dsh/ranking-update') && m.toUpperCase() === 'POST') { + let body = {}; try { body = JSON.parse((opts.body) || '{}'); } catch (e) {} + window.__r69.rankPost.push({ force: !!body.force, prompt: (body.prompt || '').slice(0, 2000) }); + return json({ ok: true, id: 'probe-rank' }); + } + return realFetch(url, opts); + }; + return 'injected'; +})()`; + +// ---- ① 视频详情 刷新 prompt ---- +await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/video/93` }); +if (!(await waitFor(`location.hash === '#/video/93' && !!document.querySelector('#view .video-grid') && ${loaded}`, 15000))) fail('video93 route+load'); +await sleep(300); +await ev(`localStorage.removeItem('wk_video_task_93')`); // 清残留执行中记录,确保刷新按钮可点 +await ev(INJECT); +await ev(`[...document.querySelectorAll('#view .tabs .tab')].find((t) => t.textContent.trim() === '视频拆解').click()`); +if (!(await waitFor(`!!document.querySelector('#view [data-parse="refresh"]')`, 10000))) fail('v93 refresh btn'); +await ev(`document.querySelector('#view [data-parse="refresh"]').click()`); +if (!(await waitFor(`!!document.querySelector('.modal-mask [data-ok]')`, 8000))) fail('refresh modal'); +await ev(`document.querySelector('.modal-mask [data-ok]').click()`); +if (!(await waitFor(`!!window.__r69.videoPrompt`, 8000))) fail('video prompt captured'); +const vp = await ev(`window.__r69.videoPrompt`); +const vOk = typeof vp === 'string' && vp.includes('【覆盖刷新模式(用户点击「刷新」触发,非首次解析)】') + && vp.includes('强制重新获取数据并覆盖') && vp.includes('禁止判断「是否需更新」') + && vp.includes('严禁用不完整/截断结果覆盖旧的完整版本') && vp.includes('POST /api/import/account') + && vp.includes('mcn-dou-analysis'); +console.log('[1] video refresh prompt = SKILL_HINT + override:', vOk ? 'PASS' : 'FAIL'); +if (!vOk) { console.log(String(vp).slice(0, 900)); process.exit(1); } +// 同时验证 connectors 挂载未回退 +const vc = await ev(`window.__r69.runs[0] ? JSON.stringify(window.__r69.runs[0].connectors) : 'none'`); +console.log('[1b] video refresh connectors:', vc === '["custom-mcp:myai-mcp-production"]' ? 'PASS' : 'FAIL ' + vc); + +// ---- ② 榜单更新 prompt:force(冷却窗口内「仍要更新」) ---- +await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/ranking` }); +if (!(await waitFor(`location.hash.startsWith('#/ranking') && ${loaded}`, 15000))) fail('ranking route'); +await sleep(400); +await ev(INJECT); // 重置捕获(rankGetMock 默认 1 = 近期已更新 → 冷却 → force 分支) +await ev(`(() => { const b = document.querySelector('#rankUpdate'); if (!b) return 'no-btn'; b.click(); return 'ok'; })()`); +if (!(await waitFor(`!!document.querySelector('.modal-mask [data-ok]')`, 8000))) fail('rank modal'); +const okTxt1 = await ev(`(document.querySelector('.modal-mask [data-ok]') || {}).textContent`); +if (!String(okTxt1).includes('仍要更新')) { console.log('FAIL: expected force btn (仍要更新), got', JSON.stringify(okTxt1)); process.exit(1); } +console.log('[2] cooldown → 仍要更新 (force):', 'PASS'); +await ev(`document.querySelector('.modal-mask [data-ok]').click()`); +if (!(await waitFor(`window.__r69.rankPost.length >= 1`, 8000))) fail('rank POST captured'); +await sleep(200); +const rp1 = await ev(`window.__r69.rankPost[0]`); +const fOk = rp1 && rp1.force === true && typeof rp1.prompt === 'string' + && rp1.prompt.includes('【覆盖刷新模式(09-04 R69:用户已确认强制刷新)】') + && rp1.prompt.includes('重新调用 API 抓取最新数据并覆盖写入同名文件'); +console.log('[3] force refresh prompt override:', fOk ? 'PASS' : 'FAIL'); +if (!fOk) { console.log(JSON.stringify(rp1)); process.exit(1); } + +// ---- ③ 榜单更新 prompt:非 force(无近期落盘 → 「确认更新」)---- +await ev(`window.__r69.rankGetMock = 0; window.__r69.rankPost = [];`); // 下次 GET=无近期落盘 → 非冷却 → 普通更新 +// ② 完成触发 router() 整页重建:重试点击直到弹窗出现(新 #rankUpdate 渲染绑定完成) +const t0 = Date.now(); +while (Date.now() - t0 < 12000) { + const r = await ev(`(() => { const b = document.querySelector('#rankUpdate'); if (!b) return 'no-btn'; b.click(); return 'ok'; })()`); + if (await ev(`!!document.querySelector('.modal-mask [data-ok]')`)) break; + await sleep(400); +} +if (!(await ev(`!!document.querySelector('.modal-mask [data-ok]')`))) fail('rank modal2'); +const okTxt2 = await ev(`(document.querySelector('.modal-mask [data-ok]') || {}).textContent`); +if (!String(okTxt2).includes('确认更新')) { console.log('FAIL: expected 确认更新, got', JSON.stringify(okTxt2)); process.exit(1); } +await ev(`document.querySelector('.modal-mask [data-ok]').click()`); +if (!(await waitFor(`window.__r69.rankPost.length >= 1`, 8000))) fail('rank normal POST captured'); +await sleep(200); +const rp2 = await ev(`window.__r69.rankPost[0]`); +const nOk = rp2 && rp2.force === false && typeof rp2.prompt === 'string' + && !rp2.prompt.includes('用户已确认强制刷新') && rp2.prompt.includes('查询幂等铁律'); +console.log('[4] normal refresh keeps idempotency (no force override):', nOk ? 'PASS' : 'FAIL'); +if (!nOk) { console.log(JSON.stringify(rp2)); process.exit(1); } + +// ---- ④ 账号列表「更新关注账号」 prompt:含 覆盖刷新模式 ---- +await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/accounts` }); +if (!(await waitFor(`location.hash.startsWith('#/accounts') && !!document.querySelector('#view #accUpdate') && ${loaded}`, 15000))) fail('accounts route'); +await sleep(400); +await ev(INJECT); // 重置捕获(accPrompt 按 name 前缀 更新账号· 捕获) +await ev(`document.querySelector('#view #accUpdate').click()`); +if (!(await waitFor(`!!document.querySelector('.modal-mask h3') && (document.querySelector('.modal-mask h3').textContent || '').includes('更新关注账号')`, 8000))) fail('acc modal'); +await ev(`document.querySelector('.modal-mask [data-ok]').click()`); +if (!(await waitFor(`!!window.__r69.accPrompt`, 10000))) fail('acc prompt captured'); +const ap = await ev(`window.__r69.accPrompt`); +const aOk = typeof ap === 'string' && ap.includes('【覆盖刷新模式(09-04 R69)】') + && ap.includes('强制重新抓取并覆盖') && ap.includes('不要因库中已有该账号记录') + && ap.includes('覆盖更新该账号登记数据与视频列表记录'); +console.log('[5] account refresh prompt override:', aOk ? 'PASS' : 'FAIL'); +if (!aOk) { console.log(String(ap).slice(0, 900)); process.exit(1); } + +console.log('\n=== PASS ==='); +// 清理:整页重载丢弃 fetch 拦截(页面若留覆盖会 mock 掉真实任务提交),回到首页 +await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}` }); +await waitFor(`location.hash === '' || location.hash === '#/'`, 8000); +ws.close(); +process.exit(0); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r70.mjs b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r70.mjs new file mode 100644 index 0000000..152d05c --- /dev/null +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r70.mjs @@ -0,0 +1,58 @@ +// R70 验证:AI写脚本页「自定义选题创作」→「原创选题创作」+ 空态句末「→ 去账号列表」点击跳转 #/accounts +const CDP = 9223; +const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json()); +const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page'); +if (!page) { console.log('ERR: no page'); process.exit(1); } +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pending = new Map(); +const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); +ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } }; +await new Promise((r) => (ws.onopen = r)); +const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); +await send('Network.setCacheDisabled', { cacheDisabled: true }); +const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; }; +const fail = (m) => { console.log('FAIL@' + m); process.exit(1); }; + +await send('Page.navigate', { url: `http://127.0.0.1:8900/?t=${Date.now()}#/rewrites` }); +if (!(await waitFor(`location.hash === '#/rewrites' && !!document.querySelector('#view .rw-tabs') && [...document.scripts].some((s) => (s.src || '').includes('data-pages.js?v=20260904u'))`, 15000))) fail('rewrites route+load'); +await sleep(400); + +// ① tab 显示名 +const tabNames = await ev(`[...document.querySelectorAll('#view .rw-tabs [data-tab]')].map((b) => b.textContent.replace(/\\d+$/, '').trim())`); +console.log('[1] tabs:', JSON.stringify(tabNames)); +if (!tabNames.some((t) => t === '原创选题创作')) { console.log('FAIL: tab 未改名'); process.exit(1); } +if (tabNames.some((t) => t.includes('自定义选题'))) { console.log('FAIL: 旧名残留'); process.exit(1); } +console.log('[1] tab 改名 原创选题创作: PASS'); + +// ② 切到 custom tab 看空态 + 箭头 +await ev(`document.querySelector('#view .rw-tabs [data-tab="custom"]').click()`); +if (!(await waitFor(`document.querySelector('#view .rw-tabs [data-tab="custom"]').classList.contains('tab-active')`, 8000))) fail('custom tab active'); +await sleep(600); +const emptyState = await ev(`(() => { + const td = document.querySelector('#view .rw-tabs [data-tab="custom"]').closest('.page').querySelector('.empty'); + if (!td) return 'NO-EMPTY'; + const a = td.querySelector('a.link[href="#/accounts"]'); + return { txt: td.textContent.trim().slice(0, 120), hasArrow: !!a, href: a ? a.getAttribute('href') : '', linkText: a ? a.textContent.trim() : '' }; +})()`); +console.log('[2] custom 空态:', JSON.stringify(emptyState)); +if (!emptyState || emptyState === 'NO-EMPTY') { + // 有数据时不显示空态——查 note 文案确认命名 + 从 note 验证即可 + const note = await ev(`document.querySelector('#view .rw-note').textContent`); + console.log('[2b] custom note:', note.slice(0, 100)); + if (!note.includes('原创选题创作')) { console.log('FAIL: note 未改名'); process.exit(1); } + console.log('[2b] note 改名: PASS (custom 有数据,无空态,跳转逻辑待空态场景)'); +} else { + if (!emptyState.txt.includes('原创选题脚本')) { console.log('FAIL: 空态文案未改', emptyState.txt); process.exit(1); } + if (!emptyState.hasArrow || emptyState.href !== '#/accounts' || !emptyState.linkText.includes('去账号列表')) { console.log('FAIL: 箭头跳转缺失'); process.exit(1); } + console.log('[2] 空态文案 + → 去账号列表链接: PASS'); + // ③ 点击跳转 + await ev(`document.querySelector('#view .empty a.link[href="#/accounts"]').click()`); + if (!(await waitFor(`location.hash === '#/accounts' && !!document.querySelector('#view #accUpdate')`, 10000))) fail('jump to accounts'); + console.log('[3] 点击 → 跳转账号列表: PASS'); +} + +console.log('\n=== PASS ==='); +ws.close(); +process.exit(0); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js b/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js index 2d68c3b..a3d349d 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js @@ -527,8 +527,8 @@ function getRanking({ board = 'week', date = '', category = '', sortBy = '', ord return { dates, categories, category, date: selDate, total, page: Math.max(1, Number(page) || 1), pageSize: limit, items: pageItems }; } -/* ================= AI写脚本:自定义选题(无视频载体)================= */ -/* 09-02 v25:账号列表「AI创作」(自定义选题,无原视频)入库的脚本 = video_id 空。此处逐条列出该类脚本 */ +/* ================= AI写脚本:原创选题(无视频载体)================= */ +/* 09-02 v25:账号列表「AI创作」(原创选题,无原视频)入库的脚本 = video_id 空。此处逐条列出该类脚本 */ function listCustomScripts({ page = 1, pageSize = 20, search = '', days = '', account = '' } = {}) { const d = getDb(); if (!d) return { total: 0, page: 1, pageSize, items: [] }; @@ -550,16 +550,16 @@ function listCustomScripts({ page = 1, pageSize = 20, search = '', days = '', ac return { total, page: Math.max(1, Number(page) || 1), pageSize: limit, items }; } -/* 自定义选题脚本全文(查看弹窗用,避免列表全量携带大文本) */ +/* 原创选题脚本全文(查看弹窗用,避免列表全量携带大文本) */ function getCustomScript(id) { const d = getDb(); 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) throw new Error('脚本不存在或非自定义选题脚本'); + if (!row || row.video_id) throw new Error('脚本不存在或非原创选题脚本'); return row; } -/* 写库:AI创作任务完成后回调保存脚本(自定义选题 videoId 省略/0 → video_id=NULL;参考选题传 videoId) +/* 写库:AI创作任务完成后回调保存脚本(原创选题 videoId 省略/0 → video_id=NULL;参考选题传 videoId) 可写连接单独打开(与只读 getDb 隔离),与 setFavorite 同模式 */ function saveScript({ videoId, awemeId, accountId, title, scriptText }) { if (!fs.existsSync(DSH_DB)) throw new Error('数据库不可用'); @@ -577,7 +577,7 @@ function saveScript({ videoId, awemeId, accountId, title, scriptText }) { } } -/* 09-02 v25:AI写脚本 三 tab 徽标计数——按 有原视频(参考选题)/无原视频(自定义选题) 拆分; +/* 09-02 v25:AI写脚本 三 tab 徽标计数——按 有原视频(参考选题)/无原视频(原创选题) 拆分; 口径与账号列表「AI脚本」列一致(status=done 且脚本非空);account 传账号名时按账号统计 */ function getRewriteStats(account = '') { const d = getDb(); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js b/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js index 91639e2..1745672 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js @@ -26,6 +26,11 @@ const EXT_ICON = { md: 'file-text', json: 'file-code', xlsx: 'table', csv: 'tabl 定位方式跨环境:优先用 Skill 工具按技能名加载;技能位于用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/(部署到用户环境时同此标准位),不写死机器绝对路径 */ const SKILL_HINT_ACCOUNT = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。严格按技能工作流执行(获取账号信息 → 视频列表抓取 → 登记达人账号表 15 列规范),遵守技能红线:TOP6 筛选、≥60 条停止、账号身份标识(sec_uid/抖音号/地址)必须由浏览器获取、禁止从数据库回填。'; +/* 09-04 R67:业务任务统一挂载的连接器 configId(myai-mcp-production 自定义 MCP)。 + 宿主 register(connectorIds) 只注入列出的连接器;凡任务可能调用 MCP(视频解析/对标拆解/功能一兜底数据源等)必须挂载, + 否则后台会话无 MCP 工具(「找不到 MCP」根因,见 R66)。复盘/分镜/榜单等纯脚本任务不挂。 */ +const MYAI_CONNECTORS = ['custom-mcp:myai-mcp-production']; + // 任务完成后自动刷新当前数据页(无需手动刷新即可看到最新数据) function refreshCurrentPage() { const view = $('#view'); @@ -36,6 +41,8 @@ function refreshCurrentPage() { } // 提交任务 + 按钮执行中状态 + 轮询恢复(供 confirmSaveAccount/confirmRun 确认后调用) +// opts.onSubmit(taskId):提交成功拿到任务 id 后回调(调用方可持久化「执行中」状态,供页面刷新后恢复,见视频解析) +// opts.onFinish(s):轮询结束(done/error/notfound/review/超时 s=null)后回调,restore 之前调用 async function executeTask(opts) { const btn = opts.btn; const oldText = btn ? btn.innerHTML : ''; @@ -43,15 +50,19 @@ async function executeTask(opts) { if (btn) { btn.disabled = true; btn.innerHTML = '执行中…'; btn.classList.add('btn-busy'); } let taskId = null; let finished = false; + let s = null; + let ended = false; + const finish = (st) => { if (ended) return; ended = true; if (opts.onFinish) { try { opts.onFinish(st); } catch (e) {} } }; /* 终态清理须在 refresh 之前,避免刷新渲染读到陈旧执行中记录 */ try { const d = await api('/api/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: opts.prompt, name: opts.label || '工作台任务', skills: opts.skills }), + body: JSON.stringify({ prompt: opts.prompt, name: opts.label || '工作台任务', skills: opts.skills, connectorIds: opts.connectorIds }), }); if (d.error) throw new Error(d.error); taskId = d.id; showToast('✅ 任务已提交,请在左侧会话栏查看执行'); + if (opts.onSubmit) { try { opts.onSubmit(taskId); } catch (e) {} } } catch (e) { showToast('❌ 任务提交失败: ' + e.message); restore(); @@ -61,15 +72,15 @@ async function executeTask(opts) { const deadline = Date.now() + 30 * 60 * 1000; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 5000)); - let s = null; try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; } - if (!s || s.state === 'notfound') { finished = true; break; } - if (s.state === 'done') { finished = true; showToast('✅ 任务执行完成,页面已自动刷新'); refreshCurrentPage(); break; } - if (s.state === 'error') { finished = true; showToast('❌ 任务执行失败' + (s.error ? ':' + s.error : '')); break; } - if (s.state === 'review') { finished = true; showToast('⏸ 任务待确认,请到左侧会话栏确认执行'); break; } + if (!s || s.state === 'notfound') { finished = true; finish(s || null); break; } + if (s.state === 'done') { finished = true; finish(s); showToast('✅ 任务执行完成,页面已自动刷新'); refreshCurrentPage(); break; } + if (s.state === 'error') { finished = true; finish(s); showToast('❌ 任务执行失败' + (s.error ? ':' + s.error : '')); break; } + if (s.state === 'review') { finished = true; finish(s); showToast('⏸ 任务待确认,请到左侧会话栏确认执行'); break; } // queued / pending / running → 继续等待 } restore(); + if (!ended) finish(null); if (!finished) showToast('⏳ 任务仍在执行,请到左侧会话栏查看进度'); } @@ -105,6 +116,7 @@ function confirmSaveAccount(name, url, btn) { label: '保存指令', btn, skills: ['mcn-dou-analysis'], + connectorIds: MYAI_CONNECTORS, /* R67:功能一浏览器不可用时 MCP 账号库兜底 */ }); } @@ -139,7 +151,7 @@ function openImportAccounts(btn) { const parts = [name ? `账号名称「${name}」` : '', url ? `账号地址「${url}」` : ''].filter(Boolean).join(','); const prompt = `${SKILL_HINT_ACCOUNT}\n请帮我把抖音账号 ${parts} 添加:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地`; close(); - executeTask({ prompt, label: '添加指令', btn, skills: ['mcn-dou-analysis'] }); + executeTask({ prompt, label: '添加指令', btn, skills: ['mcn-dou-analysis'], connectorIds: MYAI_CONNECTORS }); }); } diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js index 19d7018..701fab7 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js @@ -19,7 +19,7 @@ const SKILL_HINT_REVIEW = '请调用 Skill 工具加载「脚本复盘」技能 const SKILL_HINT_VIDEO_PARSE = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为技能功能五「解析视频」:对单条抖音视频做 MCP 内容解析。按 references/feature/05_解析视频.md 流程执行:① 调用 MCP 工具 upload_douyin_video 提交视频链接(share_text=该视频抖音链接)获取 detailId;② 等待解析完成(新视频约 3-5 分钟,用 sleep 等待后查询,content 已出而 analysis 未出时稍后重试);③ 用 short_video_detail 查询解析结果 content(视频脚本/分镜头表)与 analysis(视频拆解/结构化JSON);④ 按 references/解析结果格式规范.md 用 scripts/json_tool.py 将 content.json 与 analysis.json 保存到产出目录对应视频文件夹;⑤ 解析结果入库:工作台环境(本机 http://localhost:8900)产物落盘后调用 POST /api/import/account(body {"account":"{达人昵称}"})同步入库,接口不可用则跳过不影响保存。完成后汇报 detailId、content/analysis 是否齐备与保存路径。技能边界:只做该单视频解析,不抓取账号信息、不提炼账号设定、不创作/改写脚本。'; /* 09-02 v25:AI创作任务完成后的写库回调指令——否则脚本只落会话/产出目录,工作台「AI写脚本」列表看不到。 参考选题创作(有原视频,如视频行 AI创作):传 videoId/awemeId → rewrite_log.video_id 关联原视频; - 自定义选题创作(无原视频,如账号列表 AI创作 弹窗):不传 videoId → video_id 留空,归「自定义选题创作」tab。 + 原创选题创作(无原视频,如账号列表 AI创作 弹窗):不传 videoId → video_id 留空,归「原创选题创作」tab。 保存走本地服务 /api/dsh/script-save:server 自行解析 mcn-plugin.db 路径(跨开发机/dsh 环境最可靠),勿让 agent 猜库路径 */ /* 榜单数据更新技能声明(09-03 新增 09-04 四榜扩展:热点数据页「更新榜单」按钮 → 后台任务按 mcn-data-insight 落盘规范抓取四榜最新一期。 范围与调用次数已在点击确认弹窗中向用户明示并获授权(账号日榜 全品类 1 次 + 账号周榜 5 赛道 + 视频热榜/点赞榜,约 8 次红狐 API); @@ -40,7 +40,7 @@ const saveHintOf = (ctx) => { lines.push(` "scriptText": "(完整脚本全文)"`); return `\n\n【任务完成后必须执行】将完整脚本保存到工作台数据库(工作台与本任务同机运行,服务地址 http://localhost:8900):\n` + `1. 用 fetch 发起 POST http://localhost:8900/api/dsh/script-save;body 用 JSON.stringify 构造(scriptText 为完整脚本全文,注意转义,不要手拼 JSON):\n{\n${lines.join('\n')}\n}\n` - + (ref ? '(参考选题创作:脚本归入「参考选题创作」列表,关联该原视频)\n' : '(自定义选题创作:不传 videoId,脚本归入「自定义选题创作」列表)\n') + + (ref ? '(参考选题创作:脚本归入「参考选题创作」列表,关联该原视频)\n' : '(原创选题创作:不传 videoId,脚本归入「原创选题创作」列表)\n') + `2. 响应含 ok:true 即已入库;失败重试一次,仍失败则在回复中说明原因。\n完成后回复:脚本标题 + 保存结果。`; }; @@ -502,7 +502,7 @@ async function confirmAiScript(accountName, btn) { rm(); const prompt = `${SKILL_HINT_CREATE}\n${buildCreatePrompt(accountName, w, personaBlock(), pHas, attach.text)}${saveHintOf({ accountId: accId })}`; close(); - executeTask({ prompt, label: '创作指令', btn, skills: ['短视频工作台'] }); + executeTask({ prompt, label: '创作指令', btn, skills: ['短视频工作台'], connectorIds: MYAI_CONNECTORS }); }); document.body.appendChild(ck); }); @@ -709,7 +709,7 @@ async function updateRankingData(view, btn) { mask.innerHTML = `
@@ -885,7 +889,7 @@ function updateFollowedAccounts(btn) { for (let i = 0; i < names.length; i++) { const name = names[i]; if (btn) btn.innerHTML = `更新中 ${i + 1}/${names.length}…`; - const prompt = `${SKILL_HINT_ACCOUNT}\n请更新抖音账号「${name}」的数据:该账号已在本工作台账号库中登记,属数据更新场景(非新增)。通过抖音网页重新获取该账号最新的账号信息(粉丝/获赞/作品数/简介等)与最新视频列表,并覆盖更新该账号的登记数据与视频列表记录。账号身份标识(sec_uid/抖音号/抖音地址)一律以本次浏览器抓取到的为准,禁止沿用库中旧值回填。`; + const prompt = `${SKILL_HINT_ACCOUNT}\n请更新抖音账号「${name}」的数据:该账号已在本工作台账号库中登记,属数据更新场景(非新增)。通过抖音网页重新获取该账号最新的账号信息(粉丝/获赞/作品数/简介等)与最新视频列表,并覆盖更新该账号的登记数据与视频列表记录。账号身份标识(sec_uid/抖音号/抖音地址)一律以本次浏览器抓取到的为准,禁止沿用库中旧值回填。\n【覆盖刷新模式(09-04 R69)】本次是用户主动「更新」操作 = 强制重新抓取并覆盖:不要因库中已有该账号记录而判断「数据已较新/无需更新」而跳过或缩短流程,禁止复用旧的抓取结果——必须重新走完整抓取流程,用本次结果整体覆盖更新该账号登记数据与视频列表记录。`; try { const r = await api('/api/run', { method: 'POST', @@ -1100,6 +1104,7 @@ async function renderAccountDetail(view, id) { prompt: basePrompt + saveHintOf({ videoId: r.id, awemeId: r.aweme, accountId: id }), label: '创作指令', skills: ['短视频工作台'], + connectorIds: MYAI_CONNECTORS, /* R67: 未解析视频先 MCP 解析分支 */ btn: b, }); })); @@ -1111,6 +1116,7 @@ async function renderAccountDetail(view, id) { prompt: `${SKILL_HINT_EXTRACT}\n请为达人「${acc.account_name}」提炼账号设定(生成人设卡)。`, label: '提炼指令', skills: ['mcn-dou-analysis'], + connectorIds: MYAI_CONNECTORS, /* R67:功能三缺存量解析时会现场 MCP 解析 */ btn: exBtn, })); // 09-04:数据分析 tab 空态「账号数据分析」按钮 → mcn-dou-analysis 功能四(近4个月数据 逐条拆解+汇总分析) @@ -1122,6 +1128,7 @@ async function renderAccountDetail(view, id) { prompt: `${SKILL_HINT_ANALYZE}\n请为达人「${acc.account_name}」进行账号数据分析(功能四)。`, label: '分析指令', skills: ['mcn-dou-analysis'], + connectorIds: MYAI_CONNECTORS, /* R67:功能四需补齐无解析视频的 MCP 解析 */ btn: anaBtn, })); view._reload = load; // 静默自动刷新(保留当前 tab/翻页) @@ -1318,14 +1325,14 @@ function renderVideoKeySvg(analysis, vt, acc) { T(340, headY0 + 162, `${acc ? acc + ' · ' : ''}赛道:${D.track || '未知'}`, 9.5, P.headDim, null, 'middle'); /* —— 逐模块排版 —— */ - let y = headY0 + headH + 22; /* 头部框下起点 */ + let y = headY0 + headH + 34; /* 头部框下起点(09-04:首板块与头部大标题框间距加大 22→34,避免「内容定位」标题贴框底) */ const vPadLR = 16, vPadT = 15, vPadB = 14; /* 卡内左右/上/下留白(F6:文字禁止贴靠卡边) */ const insetTop = (s) => Math.round(s * 0.9); /* 字形顶 → 基线(中文 glyph 顶在基线之上约 0.9em) */ const kvU = (size) => X((CW - vPadLR * 2 - 6) / (size + 1.1)); /* 卡内正文每行最多全角字数 */ D.mods.forEach((mod, mi) => { if (mi > 0) y += 14; T(LX, y, '▍' + mod.title, 14, P.secTitle, 700); - y += 30; + y += 22; /* 板块标题 → 内容卡顶(09-04:30→22,标题与卡更靠近) */ if (mod.kind === 'kv') { /* kv 卡:label 独占行(主题粗体)→ 值折行 ≤2 行;游标自卡顶 vPadT 起步 → 首行文字顶与卡顶留白 vPadT */ const rows = mod.rows.filter(([, v]) => String(v).trim()); @@ -1420,6 +1427,7 @@ async function renderVideoDetail(view, id) { const videoId = Number(id); let tab = 'script'; let vkMode = 'card'; /* R59:拆解 tab 默认展示「视频关键信息卡」,可切换「拆解全文」文案版 */ + let watchStarted = false; /* R65:本页实例已挂恢复轮询(刷新后执行中状态接管),避免重复轮询 */ const d = await api('/api/dsh/video-detail?videoId=' + videoId).catch((e) => ({ ok: false, error: e.message })); if (!d.ok) { view.innerHTML = `
${toolbarBack()}
${esc(d.error || '视频不存在')}
`; bindBack(view); return; } const v = d.video, analysis = d.analysis, parse = d.parse || {}; @@ -1431,8 +1439,12 @@ async function renderVideoDetail(view, id) { const scriptMd = parse.content || null; const deconstructMd = parse.analysis ? analysisToMd(parse.analysis) : null; const keyCardHtml = renderVideoKeySvg(parse.analysis, v.video_title, v.account_name); /* R59:关键信息卡(SVG 生成器,拆解维度自动抽取,无则空 → 纯全文) */ + /* R65:该视频存在未完成解析任务(页面刷新后恢复)→ 按钮渲染「执行中」禁用,轮询到终态才恢复 */ + const runningRec = readVideoTaskRec(videoId); /* 解析/刷新按钮(09-03 R28):未解析=空态「解析」;已解析=内容右上角「刷新」(同 F5 解析任务,幂等覆盖) */ - const parseBtn = (mode) => ``; + const parseBtn = (mode) => runningRec + ? `` + : ``; const emptyAct = (icon, title, desc) => `
${svgIcon(icon, 40)}
${title}
${desc}
${parseBtn('parse')}
`; const tabBtns = [['script', '视频脚本'], ['deconstruct', '视频拆解']].map(([k, label]) => ``).join(''); /* R59:拆解 tab 内容区 = 关键信息卡(默认)/ 拆解全文 切换视图(同账号设定卡片交互:卡/文切换钮,无 AI、纯前端抽取) */ @@ -1482,12 +1494,49 @@ async function renderVideoDetail(view, id) { if (tv) tv.classList.toggle('vs-hidden', vkMode !== 'text'); })); view._reload = load; // 静默自动刷新(保留当前 tab) + /* R65:页面刷新后接管未完成任务状态——按钮已渲染执行中,这里挂终态轮询(每页实例只挂一次),终态后清理记录并 load 恢复按钮 */ + if (runningRec && !watchStarted) { watchStarted = true; watchVideoParseTask(videoId, runningRec.taskId, load); } } load(); } /* 视频详情「解析/刷新」确认弹窗(09-03 R28):走 confirmRun + executeTask 单任务链路, - prompt=技能功能五 F5(SKILL_HINT_VIDEO_PARSE),任务完成 executeTask 自动刷新本页显示新内容 */ + prompt=技能功能五 F5(SKILL_HINT_VIDEO_PARSE),任务完成 executeTask 自动刷新本页显示新内容。 + 09-04 R65:任务 id 写 localStorage(wk_video_task_)——页面刷新后按钮仍显示「执行中」并恢复轮询,任务真正结束才恢复可点 */ +const VIDEO_TASK_KEY = (videoId) => 'wk_video_task_' + videoId; +function readVideoTaskRec(videoId) { + try { + const r = JSON.parse(localStorage.getItem(VIDEO_TASK_KEY(videoId)) || 'null'); + if (r && r.taskId && Date.now() - (r.ts || 0) < 35 * 60 * 1000) return r; /* 超 35 分钟视为陈旧,清理 */ + if (r) localStorage.removeItem(VIDEO_TASK_KEY(videoId)); + } catch (e) {} + return null; +} +/* 刷新后恢复轮询:跟随该视频解析任务直到终态 → 清理记录 → 提示并刷新本页按钮态 */ +async function watchVideoParseTask(videoId, taskId, reload) { + let s = null; + const deadline = Date.now() + 35 * 60 * 1000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 5000)); + try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; } + if (!s || s.state === 'notfound' || s.state === 'done' || s.state === 'error' || s.state === 'review') break; + } + try { localStorage.removeItem(VIDEO_TASK_KEY(videoId)); } catch (e) {} + if (s && s.state === 'done') showToast('✅ 视频解析完成,已自动刷新本页'); + else if (s && s.state === 'error') showToast('❌ 视频解析失败' + (s.error ? ':' + s.error : '') + '(任务已结束,可重新点击解析)'); + else if (s && s.state === 'review') showToast('⏸ 任务待确认,请到左侧会话栏确认执行'); + else if (!s) showToast('⏳ 任务仍在执行,请到左侧会话栏查看进度'); + if (typeof reload === 'function') reload(); +} +/* 09-04 R69:视频「刷新」= 强制重新获取覆盖(追加进刷新 prompt 的覆盖语义段)。 + 旧实现刷新与解析共用同一条 prompt(SKILL_HINT_VIDEO_PARSE 无覆盖语义)——模型可能"先判断是否要更新"、 + 复用旧记录、甚至用截断/不完整结果覆盖旧完整版本(已发生一次质量事故)。刷新模式必须追加本段: + 明确 重新抓取 + 整体覆盖 + 防截断兜底 */ +const VIDEO_REFRESH_OVERRIDE = '\n\n【覆盖刷新模式(用户点击「刷新」触发,非首次解析)】该视频已有历史解析记录,本次操作语义 = 强制重新获取数据并覆盖:\n' + + '1. 禁止判断「是否需更新」、禁止复用/保留旧解析结果——必须重新走完整解析流程(重新 upload_douyin_video 提交链接 → 等待 → short_video_detail 重新查询 content 与 analysis);\n' + + '2. 用本次全新抓取结果整体覆盖:content.json(视频脚本)与 analysis.json(视频拆解)均覆盖写入旧文件/旧记录,不保留、不合并任何旧版本内容;\n' + + '3. 完整性兜底:若查询结果疑似截断/字段缺失/解析未完成,先等待重试直到拿到完整结果再覆盖保存——严禁用不完整/截断结果覆盖旧的完整版本;\n' + + '4. 覆盖保存后照常调用 POST /api/import/account(body {"account":"{达人昵称}"})覆盖入库。'; function confirmVideoParse(btn, v, mode) { const isRefresh = mode === 'refresh'; confirmRun({ @@ -1497,19 +1546,22 @@ function confirmVideoParse(btn, v, mode) { : '将对该视频做 MCP 内容解析,产出「视频脚本」(分镜头表)与「视频拆解」(结构化分析),约需 3-5 分钟,完成后自动刷新。', target: cleanTitle(v.video_title) || '视频', url: v.video_url || '', - prompt: `${SKILL_HINT_VIDEO_PARSE}\n待解析视频链接:${v.video_url || ''}\n视频标题:${v.video_title || ''}\n所属达人:${v.account_name || ''}`, + prompt: `${SKILL_HINT_VIDEO_PARSE}\n待解析视频链接:${v.video_url || ''}\n视频标题:${v.video_title || ''}\n所属达人:${v.account_name || ''}${isRefresh ? VIDEO_REFRESH_OVERRIDE : ''}`, label: '解析指令', btn, skills: ['mcn-dou-analysis'], + connectorIds: MYAI_CONNECTORS, /* R66/R67:任务会话按 configId 注入连接器,否则后台会话无 MCP 工具 */ + onSubmit: (taskId) => { try { localStorage.setItem(VIDEO_TASK_KEY(v.id), JSON.stringify({ taskId, ts: Date.now() })); } catch (e) {} }, + onFinish: () => { try { localStorage.removeItem(VIDEO_TASK_KEY(v.id)); } catch (e) {} }, }); } /* ================= AI写脚本 ================= */ -async function renderRewrites(view, initAccount) { /* v22:initAccount=账号列表 AI脚本列跳转进入,预筛该账号;09-02 v25:AI写脚本=三类脚本中心(参考选题创作/自定义选题创作/ABC融合创作) */ +async function renderRewrites(view, initAccount) { /* v22:initAccount=账号列表 AI脚本列跳转进入,预筛该账号;09-02 v25:AI写脚本=三类脚本中心(参考选题创作/原创选题创作/ABC融合创作) */ try { initAccount = decodeURIComponent(initAccount || ''); } catch (e) { initAccount = ''; } /* hash 路径段 param 未解码,中文账号名需还原(v22 fix) */ /* tab 说明: ref 参考选题创作——以视频行为选题来源(有原视频):基于「原视频」创作,脚本保存后挂在该视频下(rewrite_log.video_id>0) - custom 自定义选题创作——无原视频:账号列表/详情「AI创作」弹窗等触发,video_id 留空,脚本保存后自动入库归入本列表 + custom 原创选题创作——无原视频:账号列表/详情「AI创作」弹窗等触发,video_id 留空,脚本保存后自动入库归入本列表 abc ABC 融合创作——将同账号多条视频的爆款元素融合成一条新脚本(功能待实现,占位) */ const state = { tab: 'ref', @@ -1538,8 +1590,8 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号 : '参考选题创作:以库中视频为选题来源(关注账号优先 · 按发布时间倒序),行尾「AI创作」= 基于该视频创作新脚本(完成后自动保存并挂到该视频下)') : (state.tab === 'custom' ? (state.scope - ? `账号「${esc(state.scope)}」· 自定义选题创作:无原视频的脚本(来自账号列表「AI创作」弹窗等),保存后自动入库显示在这里` - : '自定义选题创作:无原视频的脚本(来自账号列表 / 账号详情「AI创作」弹窗等),保存后自动入库显示在这里') + ? `账号「${esc(state.scope)}」· 原创选题创作:无原视频的脚本(来自账号列表「AI创作」弹窗等),保存后自动入库显示在这里` + : '原创选题创作:无原视频的脚本(来自账号列表 / 账号详情「AI创作」弹窗等),保存后自动入库显示在这里') : 'ABC 融合创作:将同账号多条视频的爆款元素融合成一条全新脚本。功能开发中,敬请期待。'); /* —— ref tab:视频行(参考选题载体) —— */ const refRows = items.map((v) => ` @@ -1585,7 +1637,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号 } else if (state.tab === 'custom') { bodyHtml = `
- ${customRows || ``} + ${customRows || ``}
账号脚本标题字数创建时间操作
暂无自定义选题脚本${state.scope ? '(该账号暂无无视频脚本;有原视频的脚本请切到「参考选题创作」tab 查看)' : '——可从账号列表「AI创作」弹窗创作,任务完成自动入库后显示在这里'}
暂无原创选题脚本${state.scope ? '(该账号暂无无视频脚本;有原视频的脚本请切到「参考选题创作」tab 查看)' : '——可从账号列表「AI创作」弹窗创作,任务完成自动入库后显示在这里 → 去账号列表'}
`; } else { bodyHtml = `
@@ -1606,7 +1658,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
- +
${toolbarCtrl}
@@ -1640,7 +1692,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号 if (days) days.addEventListener('change', (e) => { state.custom.days = e.target.value; state.custom.page = 1; load(); }); const clear = view.querySelector('[data-scope-clear]'); if (clear) clear.addEventListener('click', () => { state.scope = ''; state.ref.search = ''; state.ref.page = 1; state.custom.page = 1; reloadAll(); }); - /* R58:自定义选题「查看」并入统一 AI 脚本弹窗(openScriptModal rewriteId 分支单条展示),获得删除按钮/诊断面板能力;原 openCustomScriptView 简单弹窗移除 */ + /* R58:原创选题「查看」并入统一 AI 脚本弹窗(openScriptModal rewriteId 分支单条展示),获得删除按钮/诊断面板能力;原 openCustomScriptView 简单弹窗移除 */ view.querySelectorAll('[data-cview]').forEach((b) => b.addEventListener('click', () => openScriptModal({ rewriteId: Number(b.dataset.cview) }))); } } @@ -1677,6 +1729,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号 : `${SKILL_HINT_CREATE}\n请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}\n该视频尚未解析,请先解析这个抖音视频并做拆解分析(${r.url ? '视频地址:' + r.url + '\n' : ''}视频标题:《${r.title}》),再基于账号设定与提炼出的选题,创作完整故事脚本。${saveHint}`, label: '创作指令', skills: ['短视频工作台'], + connectorIds: MYAI_CONNECTORS, /* R67:未解析视频先 MCP 解析分支需要 myai */ btn: b, }); } @@ -1688,7 +1741,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号 load(); } - /* 启动:拉取徽标计数;账号级进入(账号列表「AI脚本」列)时若该账号存在自定义选题脚本 → 默认落在「自定义选题创作」tab(否则「参考选题创作」) */ + /* 启动:拉取徽标计数;账号级进入(账号列表「AI脚本」列)时若该账号存在原创选题脚本 → 默认落在「原创选题创作」tab(否则「参考选题创作」) */ { const st = await api('/api/dsh/rewrite-stats?account=' + encodeURIComponent(state.scope)).catch(() => ({ videoScripts: 0, customScripts: 0 })); state.stats = st; @@ -1829,8 +1882,8 @@ async function renderReviews(view) { 布局同 AI写脚本查看弹窗(rw-stack 横向列):源脚本(可折叠,有原视频时) | AI脚本(版本切换) | 右侧列 右侧列头部 tab:脚本诊断(默认) | 分镜提示词 —— 诊断内容 = 当前版本 AI 脚本最新复盘(script_review type=ai 优先;compare 取 AI 视角总分并带源对比分) - —— 分镜内容 = 该视频下 storyboard_log 中属当前版本的记录 + 生成/重新生成入口(仅参考选题脚本;自定义选题无视频载体不支持) - 数据源:/api/dsh/video-script + /api/dsh/video-detail(参考选题)/ /api/dsh/custom-script(自定义选题) + —— 分镜内容 = 该视频下 storyboard_log 中属当前版本的记录 + 生成/重新生成入口(仅参考选题脚本;原创选题无视频载体不支持) + —— 数据源:/api/dsh/video-script + /api/dsh/video-detail(参考选题)/ /api/dsh/custom-script(原创选题) + /api/dsh/review-full?rewriteId=(当前版本诊断)+ /api/dsh/storyboards?videoId=(分镜) */ const DIAG_DIMS = ['创意看点', '设定承接', '框架节奏', '爆款开场', '叙事逻辑', '极致事件', '格式规范性', '台词口语化', '内容合规', '教程完整链', '其他']; /* R46/R47 维度术语改名(09-03):钩子设置→爆款开场、共鸣和共情→创意和爆点(R46,排设定承接前)→创意看点(R47)。 @@ -1899,7 +1952,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) { rewrites = (vs && vs.rewrites) || []; source = (vs && vs.source) || null; if (vd && vd.video) { metaTitle = vd.video.video_title || ''; metaAcc = vd.video.account_name || ''; } - } else if (rewriteId) { /* 自定义选题(video_id 空):单条脚本;custom-script 拒绝则回退全量表定位(该脚本实际挂在视频下) */ + } else if (rewriteId) { /* 原创选题(video_id 空):单条脚本;custom-script 拒绝则回退全量表定位(该脚本实际挂在视频下) */ const cs = await api('/api/dsh/custom-script?id=' + rewriteId).catch(() => ({ ok: false, error: 1 })); if (cs && !cs.error && cs.script) { rewrites = [{ id: cs.script.id, script_text: cs.script.script_text, created_time: cs.script.created_time }]; @@ -1936,7 +1989,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) { confirmRun({ title: hasSource ? '复盘评分' : 'AI脚本复盘', desc, prompt, label: '复盘指令', skills: ['脚本复盘'], btn }); } finally { delete btn.dataset.lock; } }; - /* 生成 / 重新生成分镜(仅参考选题脚本;自定义选题无视频载体不提供) */ + /* 生成 / 重新生成分镜(仅参考选题脚本;原创选题无视频载体不提供) */ const fireGenSb = (btn, regenerate) => { confirmRun({ title: regenerate ? '重新生成分镜' : '生成分镜', @@ -1997,7 +2050,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) { const aiSecHtml = (c) => `
AI脚本${srcToggleHtml()}${sideToggleHtml()}
${renderMarkdown(c.script_text || '(暂无内容)')}
${delScriptBtnHtml(c)}${rewrites.length > 1 ? verCtrlInner('v', verIdx, rewrites.length, '版本', c.created_time) : ''}
`; /* R58:删除当前脚本(左下角浅红按钮)——同时删除其诊断复盘与分镜提示词;确认后执行 API,多版本就地移除当前版并刷新,单版本关弹窗并重建页面 */ - const delScriptBtnHtml = (c) => ``; + const delScriptBtnHtml = (c) => ``; const fireDel = async (btn) => { if (btn.dataset.lock) return; btn.dataset.lock = '1'; try { @@ -2059,7 +2112,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) { sideBody = '' /* R51:空态提示清空——不再显示「(暂无分镜提示词)」 */; sideFoot = `
`; } else { - sideBody = '
该脚本为自定义选题(无关联视频),暂不支持分镜提示词
'; + sideBody = '
该脚本为原创选题(无关联视频),暂不支持分镜提示词
'; sideFoot = ''; } return { sideBody, sideFoot }; diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/help-guide.js b/project/短视频脚本创作/V1.0/mcn-work-shop/public/help-guide.js index 1a9f57c..f32e5a6 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/help-guide.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/help-guide.js @@ -117,9 +117,9 @@ }, { key: 'rewrites', icon: 'pen', title: 'AI写脚本', route: '#/rewrites', - desc: '脚本中心:参考选题创作(有原视频)/ 自定义选题创作(无原视频)/ ABC 融合创作', + desc: '脚本中心:参考选题创作(有原视频)/ 原创选题创作(无原视频)/ ABC 融合创作', steps: [ - { sel: '.rw-tabs [data-tab="custom"]', mode: 'click', title: '自定义选题创作', text: 'AI写脚本 分三种创作入口,先看「自定义选题创作」:无原视频的脚本(来自账号列表/详情 AI创作弹窗)都收在这里,带徽标计数。' + '\n' + '👉 点「自定义选题创作」tab。' }, + { sel: '.rw-tabs [data-tab="custom"]', mode: 'click', title: '原创选题创作', text: 'AI写脚本 分三种创作入口,先看「原创选题创作」:无原视频的脚本(来自账号列表/详情 AI创作弹窗)都收在这里,带徽标计数。' + '\n' + '👉 点「原创选题创作」tab。' }, { sel: '.rw-tabs [data-tab="ref"]', mode: 'click', title: '参考选题创作', text: '「参考选题创作」:以库中视频为选题来源(关注账号优先、按发布时间倒序),每行视频可「AI创作」新脚本,完成后自动挂到该视频下。' + '\n' + '👉 点回「参考选题创作」。' }, { sel: '#rwSearch', mode: 'input', emptyOk: true, title: '搜索视频', text: '按视频标题 / 账号名搜索候选视频,配时间范围与「已解析/未解析」状态过滤。' + '\n' + '👉 输入关键词回车试试,或直接下一步。' }, { sel: '.rw-tabs [data-tab="abc"]', mode: 'info', title: 'ABC 融合创作', text: '「ABC融合创作」:将同账号多条视频的爆款元素融合成一条全新脚本(功能开发中,暂为占位说明页)。' }, diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/index.html b/project/短视频脚本创作/V1.0/mcn-work-shop/public/index.html index 581d037..0334d87 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/index.html +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/index.html @@ -4,7 +4,7 @@ 短视频工作台 - + @@ -41,8 +41,8 @@
- - - + + + diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css b/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css index 40f2926..b595910 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css @@ -520,8 +520,8 @@ td.td-title { max-width: 240px; } 把 4 列轨道撑成等宽 232.5px、每格右侧大片留白——故将其移出共享网格(见 .account-info > .info-item),8→7 字段列轨贴内容收缩, 值与值之间视觉距离恢复为「内容宽+80px」,与视频详情属性区观感一致 */ .account-info .account-grid { display: grid; grid-template-columns: repeat(4, minmax(min-content, max-content)); grid-template-areas: 'a b c .' 'd e f g'; gap: 10px 0; justify-content: start; } -.account-info .account-grid .info-item { width: 140px; } -/* 账号信息属性内容统一宽度(09-03 R48 终版):所有属性值固定 width 140px,四列严格等宽;超宽单行省略号 + hover 看全量; +.account-info .account-grid .info-item { width: 180px; } +/* 账号信息属性内容统一宽度(09-03 R48 终版,09-04 宽度 140→180):所有属性值固定 width 180px,四列严格等宽;超宽单行省略号 + hover 看全量; 横向间隔 0(用户定:内容统一宽度即可,不要其他间隔) */ .account-info .account-grid .info-item .info-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* 账号定位整行(R45):作为网格容器的直接子项平级输出,占满卡片宽度,与网格保持纵向间距 */ @@ -535,10 +535,10 @@ td.td-title { max-width: 240px; } /* 视频详情信息卡:标题独立一行(大字号),下方按行分组 */ /* 视频详情字段对齐(09-02 终版):8 字段同处一个 grid 容器(2 行 × 4 列),列轨道跨行共享——第 i 列严格对齐(分享↔时长、收藏↔发布时间);列宽贴合内容(min-content~max-content,非均分);横向间隔 80px 与账号信息一致。之前用两个独立 .info-row 容器导致跨行不对齐 */ .video-info .video-grid { display: grid; grid-template-columns: repeat(4, minmax(min-content, max-content)); gap: 10px 0; justify-content: start; } -.video-info .video-grid .info-item { width: 140px; } +.video-info .video-grid .info-item { width: 180px; } .video-info .video-grid .info-item.info-wide { grid-column: 1 / -1; width: auto; } -/* 视频详情属性内容统一宽度(09-03 R48):与账号详情同款——8 字段固定 width 140px、横向间隔 0;超宽单行省略号 + hover 看全量; - 标签(info-wide)占整行不受限宽保持换行;原视频ID vid-limited 独立 max-width 220 已被统一 140 覆盖,移除冗余规则 */ +/* 视频详情属性内容统一宽度(09-03 R48,09-04 宽度 140→180):与账号详情同款——8 字段固定 width 180px、横向间隔 0;超宽单行省略号 + hover 看全量; + 标签(info-wide)占整行不受限宽保持换行;原视频ID vid-limited 独立 max-width 220 已被统一 180 覆盖,移除冗余规则 */ .video-info .video-grid .info-item:not(.info-wide) .info-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* ================= R59 视频关键信息卡(拆解 tab 默认视图,卡/文切换) ================= */ @@ -694,7 +694,7 @@ td.td-title { max-width: 240px; } .cb-cell { width: 36px; text-align: center; } .cb-cell input { width: 15px; height: 15px; cursor: pointer; vertical-align: middle; accent-color: var(--primary); } -/* ---- AI写脚本 三 tab(09-02 v25:参考选题创作 / 自定义选题创作 / ABC融合创作) ---- */ +/* ---- AI写脚本 三 tab(09-02 v25:参考选题创作 / 原创选题创作 / ABC融合创作) ---- */ /* 标题行(返回|标题)下方独立一行:左=tab 切换,右=搜索/时间/状态筛选(tab 与筛选同一行,均不与标题/返回同行) */ .rw-tabs { display: flex; align-items: center; gap: 2px; border-bottom: 1px solid var(--border); margin: -12px 0 12px; padding: 0 4px; } .rw-tabs .tab { font-size: 15px; padding: 9px 14px; } @@ -711,7 +711,7 @@ td.td-title { max-width: 240px; } .rw-chip > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rw-chip button { border: none; background: none; color: inherit; cursor: pointer; font-size: 15px; line-height: 1; padding: 0 3px; border-radius: 50%; } .rw-chip button:hover { opacity: .7; } -/* 自定义选题脚本 查看弹窗 */ +/* 原创选题脚本 查看弹窗 */ .cw-modal { width: min(980px, calc(100% - 80px)); height: calc(100vh - 120px); display: flex; flex-direction: column; overflow: hidden; padding: 0; } .cw-modal .modal-title-row { flex: none; padding: 14px 18px; border-bottom: 1px solid var(--border); margin: 0; } .cw-meta { flex: none; padding: 8px 18px; border-bottom: 1px dashed var(--border); color: var(--dim); font-size: 13px; } diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/server.js b/project/短视频脚本创作/V1.0/mcn-work-shop/server.js index 22d1c4d..8968d20 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/server.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/server.js @@ -288,7 +288,10 @@ function wbDb() { // 创建一次性后台任务(08-31 实测约定:scheduled_at 与 next_run_at 均取 +5s 未来,调度器按 next_run_at 扫描拾取; // 模型跟随用户最近活跃手动会话选择,兜底 'auto')——逻辑与 /api/run 完全一致,供更新榜单等新入口复用 -function createOnceAutomation(name, prompt, skillsArr) { +// 09-04 connectorIdsArr:任务会话要用的「用户连接器」configId 列表(如 ['custom-mcp:myai-mcp-production'])—— +// 宿主会话 register(connectorIds) 只注入列出的连接器,空数组=不注入任何连接器(后台任务「找不到 MCP」根因), +// 依赖 MCP 的任务(视频解析等)必须显式挂载;纯脚本类任务(榜单更新)保持 [] 即可 +function createOnceAutomation(name, prompt, skillsArr, connectorIdsArr) { const db = wbDb(); try { const now = Date.now(); @@ -304,7 +307,7 @@ function createOnceAutomation(name, prompt, skillsArr) { const modelId = (modelRow && modelRow.model) ? modelRow.model : 'auto'; db.prepare(`INSERT INTO automations (id,name,prompt,status,schedule_type,scheduled_at,next_run_at,rrule,cwds,created_at,updated_at,skills_json,connector_ids_json,model_id,permission_mode,owner_user_id,owner_status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) - .run(id, name, prompt, 'ACTIVE', 'once', scheduledAt, nextRunAt, '', JSON.stringify([cwd]), now, now, JSON.stringify(Array.isArray(skillsArr) ? skillsArr : []), '[]', modelId, 'fullAccess', ownerUserId, 'confirmed'); + .run(id, name, prompt, 'ACTIVE', 'once', scheduledAt, nextRunAt, '', JSON.stringify([cwd]), now, now, JSON.stringify(Array.isArray(skillsArr) ? skillsArr : []), JSON.stringify(Array.isArray(connectorIdsArr) ? connectorIdsArr : []), modelId, 'fullAccess', ownerUserId, 'confirmed'); return { id }; } finally { db.close(); @@ -553,20 +556,20 @@ const server = http.createServer((req, res) => { if (dshGuard()) return; return dshOk(200, dsh.listRewrites(qInt('videoId', 0))); } - if (p === '/api/dsh/custom-scripts') { /* 09-02 v25:自定义选题(无视频载体)脚本列表 */ + if (p === '/api/dsh/custom-scripts') { /* 09-02 v25:原创选题(无视频载体)脚本列表 */ if (dshGuard()) return; return dshOk(200, dsh.listCustomScripts({ page: qInt('page', 1), pageSize: qInt('pageSize', 20), search: String(q.get('search') || ''), days: String(q.get('days') || ''), account: String(q.get('account') || '') })); } - if (p === '/api/dsh/rewrite-stats') { /* 09-02 v25:AI写脚本 tab 徽标计数(参考选题/自定义选题),可传 account 按账号统计 */ + if (p === '/api/dsh/rewrite-stats') { /* 09-02 v25:AI写脚本 tab 徽标计数(参考选题/原创选题),可传 account 按账号统计 */ if (dshGuard()) return; return dshOk(200, dsh.getRewriteStats(String(q.get('account') || ''))); } - if (p === '/api/dsh/custom-script') { /* 自定义选题脚本全文(查看弹窗) */ + if (p === '/api/dsh/custom-script') { /* 原创选题脚本全文(查看弹窗) */ if (dshGuard()) return; try { return dshOk(200, { script: dsh.getCustomScript(qInt('id', 0)) }); } catch (e) { return sendErr(404, e.message); } } - if (p === '/api/dsh/script-save' && req.method === 'POST') { /* AI创作任务完成回调写库(参考选题传 videoId;自定义选题省略→video_id 空) */ + if (p === '/api/dsh/script-save' && req.method === 'POST') { /* AI创作任务完成回调写库(参考选题传 videoId;原创选题省略→video_id 空) */ if (dshGuard()) return; let body = ''; req.on('data', (c) => { body += c; if (body.length > 2e6) req.destroy(); }); @@ -654,8 +657,14 @@ const server = http.createServer((req, res) => { req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); }); req.on('end', () => { try { - const { prompt, name, skills } = JSON.parse(body || '{}'); + const { prompt, name, skills, connectorIds } = JSON.parse(body || '{}'); if (!prompt || typeof prompt !== 'string' || !prompt.trim()) return sendErr(400, 'prompt 不能为空'); + // 09-04 连接器级挂载:connector_ids_json 填「用户连接器」configId 数组(如 custom-mcp:myai-mcp-production)。 + // 宿主会话 register(connectorIds) 白名单注入;空数组=不注入任何连接器 → 依赖 MCP 的任务会「找不到 MCP」。 + // 仅放行合法 configId 形态(connector:/custom-mcp:/裸名),其余丢弃 + const connArr = Array.isArray(connectorIds) + ? connectorIds.filter((c) => typeof c === 'string' && /^[A-Za-z0-9:_-]{1,120}$/.test(c.trim())).map((c) => c.trim()) + : []; // 09-01 参数级技能挂载:skills_json 是 automations 表原生字段(default '[]'), // 客户端调度器创建会话时按该字段挂载技能;前端传技能名数组(如 ['短视频工作台'])即完成任务级技能绑定 // 09-03 模型跟随用户选择:原 model_id 钉死 'deepseek-v4-flash'(绕过用户模型选择,全任务跑 flash)。 @@ -696,7 +705,7 @@ const server = http.createServer((req, res) => { const modelId = (modelRow && modelRow.model) ? modelRow.model : 'auto'; db.prepare(`INSERT INTO automations (id,name,prompt,status,schedule_type,scheduled_at,next_run_at,rrule,cwds,created_at,updated_at,skills_json,connector_ids_json,model_id,permission_mode,owner_user_id,owner_status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) - .run(id, name || '工作台任务', finalPrompt, 'ACTIVE', 'once', scheduledAt, nextRunAt, '', JSON.stringify([cwd]), now, now, JSON.stringify(skillsArr), '[]', modelId, 'fullAccess', ownerUserId, 'confirmed'); + .run(id, name || '工作台任务', finalPrompt, 'ACTIVE', 'once', scheduledAt, nextRunAt, '', JSON.stringify([cwd]), now, now, JSON.stringify(skillsArr), JSON.stringify(connArr), modelId, 'fullAccess', ownerUserId, 'confirmed'); db.close(); log('已提交 AI 任务: ' + id + (name ? ' (' + name + ')' : '')); return sendJSON(200, { ok: true, id, message: '任务已提交,请在左侧会话栏查看执行' }); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/start.bat b/project/短视频脚本创作/V1.0/mcn-work-shop/start.bat index c6a04c0..5366362 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/start.bat +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/start.bat @@ -1,6 +1,6 @@ @echo off -rem MCN工作台 启动脚本(Windows 双击运行) -rem 优先使用 WorkBuddy managed node,回退系统 node +rem MCN¹¤×÷̨ Æô¶¯½ű¾£¨Windows ˫»÷ÔËÐУ© +rem ÓÅÏÈʹÓà WorkBuddy managed node£¬»ØÍËϵͳ node setlocal cd /d "%~dp0" @@ -14,12 +14,12 @@ if %errorlevel%==0 ( goto run ) -echo [错误] 未找到 Node.js,请安装 Node 或配置 managed node 路径 +echo [´íÎó] δÕҵ½ Node.js£¬Ç밲װ Node »òÅäÖà managed node ·¾¶ pause exit /b 1 :run -echo 正在启动 MCN工作台... +echo ÕýÔÚÆô¶¯ MCN¹¤×÷̨... start "" "%NODE_EXE%" server.js 8900 timeout /t 1 /nobreak >nul start http://localhost:8900