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 = `
将重新抓取热点数据四榜最新一期并落盘:账号日榜(全品类单次,不传赛道)+ 账号周榜(5 赛道)+ 视频热榜 + 点赞榜,约 8 次红狐 API 调用(幂等铁律:同榜名+赛道+数据日期已存在则跳过)。后台任务执行需数分钟,完成后自动刷新榜单。
+将重新抓取热点数据四榜最新一期并落盘:账号日榜(全品类单次,不传赛道)+ 账号周榜(5 赛道)+ 视频热榜 + 点赞榜,约 8 次红狐 API 调用${inCooldown ? '(强制刷新:同名文件已存在也重新抓取并覆盖写入)' : '(幂等铁律:同榜名+赛道+数据日期已存在则跳过)'}。后台任务执行需数分钟,完成后自动刷新榜单。
${st.lastUpdateAt ? `⚠️ ${st.lastUpdateAgeMin} 分钟前刚更新过(防重复窗口 ${st.cooldownMin} 分钟),数据仍较新;确需强制刷新请点「仍要更新」。
` : ''}| 账号 | 脚本标题 | 字数 | 创建时间 | 操作 |
|---|---|---|---|---|
| 暂无自定义选题脚本${state.scope ? '(该账号暂无无视频脚本;有原视频的脚本请切到「参考选题创作」tab 查看)' : '——可从账号列表「AI创作」弹窗创作,任务完成自动入库后显示在这里'} | ||||
| 暂无原创选题脚本${state.scope ? '(该账号暂无无视频脚本;有原视频的脚本请切到「参考选题创作」tab 查看)' : '——可从账号列表「AI创作」弹窗创作,任务完成自动入库后显示在这里 → 去账号列表'} | ||||