From d157c179c8389f7895fdd3a43152687e5b5290cc Mon Sep 17 00:00:00 2001 From: Longtail Date: Sat, 31 Oct 2026 17:08:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=87=E4=BB=BD=E7=8E=B0=E5=9C=BA=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E8=A1=A5=E5=BD=95=E3=80=81=E5=9B=BE=E7=89=87=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=B8=8E=E8=B7=B3=E8=BF=87=E6=96=87=E7=AB=A0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 ++++-- app.py | 2 +- browser/auto-news.user.js | 122 ++++++++++++++++++++++++++++---- browser/formatter.js | 4 +- browser/importer.js | 71 ++++++++++++++++--- browser/local-bridge.user.js | 8 ++- browser/runner.js | 47 ++++++++++-- build_browser.py | 2 +- static/app.js | 2 +- store.py | 18 +++++ test/README.md | 1 + test/bridge.test.cjs | 23 ++++++ test/browser.test.cjs | 6 +- test/importer.test.cjs | 43 ++++++++++- test/runner.test.cjs | 106 +++++++++++++++++++++++++++ test/test_controller.py | 13 ++++ test/test_queue.py | 57 +++++++++++++++ test/验证记录.md | 43 ++++++++++- 发布流程操作记录与自动化规范.md | 38 ++++++++++ 实施计划.md | 15 +++- 20 files changed, 597 insertions(+), 47 deletions(-) create mode 100644 test/runner.test.cjs diff --git a/README.md b/README.md index a1f62f0..3a0fe98 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 将新闻下载、文章打包、浏览器导入、排版与换号联动整合到本地控制台。 -**当前版本:0.1。可完成下载 → 入队 → 导入 → 排版 → 等待人工发表。结果页自动核对按用户决定暂留待接入;人工提交后会记录候选结果并暂停,不会自动计数、换号或处理下一篇。** 换号调度和计数接口已完成离线验证,完整在线链路仍需补齐结果页 HTML 后验证。 +**当前版本:0.1。可完成下载 → 入队 → 导入 → 排版 → 等待人工发表。结果页自动核对按用户决定暂留待接入;人工提交后会记录候选结果并暂停,不会自动计数、换号或处理下一篇。** 换号调度和计数接口已完成离线验证,本次已采集真实审核中结果页的关键结构;结果观察适配与完整在线链路验证仍待完成。 ## 启动 @@ -35,12 +35,13 @@ uv run --no-project --python 3.12 --with-requirements requirements.txt python ap 3. 填写目标版块 `fid`、每账号最少/最多篇数和处理间隔。先核对历史发帖记录,再勾选确认并保存。版块和篇数不会替用户选择。 4. 登录论坛,建议只保留一个执行标签。点击控制台“开始 / 继续”。执行器识别账号后领取一篇任务,进入指定新帖页、导入、排版,停止在人工审核阶段。 5. 在论坛检查账号、版块、标题、图片位置、正文和来源,并亲手点击“发表帖子”。程序不调用发帖提交函数,不发送发帖请求,也不自动保存草稿。 -6. 当前版本会记录人工点击和提交快照,在跳转后的候选页面或等待超时后标记“发布结果待核对”并暂停。**需要补充结果页 HTML 后,才能自动核对受理结果并接续下一篇。** +6. 当前版本会记录人工点击和提交快照,在跳转后的候选页面或等待超时后标记“发布结果待核对”并暂停。**已采集审核中页面资料,仍需完成结果观察适配,才能自动核对受理结果并接续下一篇。** 待补齐结果页自动核对后:审核中算网站已受理一次,但不算已确认公开;达到账号随机目标篇数且仍有待处理文章时,回首页调用插件换号,核对新 UID 与插件空闲状态后创建下一账号批次。没有待处理文章时直接结束,不为达到数量重发。 ## 终止、恢复与冲突 +- 网站左下角悬浮窗提供“跳过本文章”:将当前未提交文章标记为“已跳过”,保留队列记录、不计发表篇数,并打开新的发帖页继续导入排版下一篇。导入中会先取消并等待回滚;已上传附件可能仍保留在网站。最后一篇跳过后流程结束。已点击发表或结果不明的文章不能直接跳过,须先人工核对。已跳过文章不会自动重新入队,需要重试时可在暂停后通过队列“人工核对 / 处理”重新排队。 - 网站左下角常驻“终止流程”,点击后立即取消本标签可取消的导入,并通知其他安装脚本的标签和换号插件;持久状态禁止继续领取任务。 - 控制台“终止流程”还会停止下载,浏览器在下一次连接检查时停止(正常轮询约 2 秒)。网络断开时无法承诺固定延迟;网站按钮可立即停止本标签。已到达网站的图片、登录或发帖请求无法撤回。 - 暂停/终止后必须明确点击“开始 / 继续”。下载停止等待当前网络请求/单篇保存结束,不伪装成已经撤回在途请求。 @@ -77,7 +78,7 @@ downloads/ 本工具下载结果(本地) ```sh uv run --no-project --python 3.12 --with-requirements requirements.txt python -m unittest discover -s test -p 'test_*.py' -v -node --test test/importer.test.cjs test/bridge.test.cjs +node --test test/importer.test.cjs test/bridge.test.cjs test/runner.test.cjs python3 build_browser.py ``` @@ -92,6 +93,18 @@ node test/browser.test.cjs 浏览器回归拦截所有真实论坛请求,仅使用模拟 HTML。截图输出到 `test/artifacts/`。本轮离线通过不代表已在用户登录态的真实网站上完成上传、换号或发布。 -## 下一步需要的页面资料 +## 结果页资料与下一步 -按用户决定,等待结果页 HTML:帖子标题、首帖作者及 UID 链接、版块及 fid 链接、“审核中”提示、公开帖子与失败提示。不要提供 Cookie、密码或表单令牌。将据此完成 `runner.js` 的结果观察适配,调用已经实现的受理记录接口,联通后续计数和换号。 +2026-10-31 已观察一次真实提交:网站先提示“新主题需要审核,您的帖子通过审核后才能显示”,随后进入[帖子详情页](https://www.dww11.com/thread/23699911.html),标题旁显示“审核中”。标题、作者 UID 链接、体育休闲版块 `fid=90`、首帖容器及审核标记已采集。详细过程见[操作规范第 12 节](发布流程操作记录与自动化规范.md#12-2026-10-31实际提交与审核中页面观察)。 + +注意:该地址中的数字串为 `23699911`,页面链接实际使用 `tid=236999`,首帖 PID 为 `238791`。适配时必须区分访问地址与主题标识,不能直接把路径中的全部数字当作 `tid`。 + +下一步是据此完成 `runner.js` 的结果观察适配,核对受理接口的标识处理,再联通计数和换号。公开帖子、失败提示及审核后的访客可见性仍需补充验证。本次仅保存观察资料,未据此修改程序或补写队列计数;不需要 Cookie、密码或表单令牌。 + +## 0.1.1 图片修复 + +更新 `browser/auto-news.user.js`:修复实际编辑器 `aid` 属性识别、多行链接内图片漏导入,并增加正文图片加载与顺序检查。上传返回附件编号不再视为图片可显示;网站图片接口失败时明确停止。连接脚本及后端不需要为本次修复更新。现场发现的图片接口 HTTP 500 仍需进一步定位,不能由本次解析/校验修复宣称已解决。 + +## 0.1.2 跳过文章 + +保持现有控制器、SQLite 队列、连接桥和执行器的结构,新增 `skip` 浏览器接口及 `skipped` 队列状态。更新油猴中的 `browser/local-bridge.user.js`(0.1.1)和 `browser/auto-news.user.js`(0.1.2),重启本地工作台、重新配对并刷新论坛后使用。按钮位于网站左下角悬浮窗、“终止流程”上方。 diff --git a/app.py b/app.py index 2c0e5e9..88e39f0 100644 --- a/app.py +++ b/app.py @@ -100,7 +100,7 @@ class Controller: def bridge(self, route, body): methods = {'heartbeat': self.store.heartbeat, 'next': self.store.next, 'package': self.store.package, 'event': self.store.event, 'accept': self.store.accept, 'switch-begin': self.store.switch_begin, - 'takeover': self.store.takeover, 'current': self.store.current} + 'takeover': self.store.takeover, 'current': self.store.current, 'skip': self.store.skip} if route == 'stop': self.manager.stop.set() return self.store.control('stop') diff --git a/browser/auto-news.user.js b/browser/auto-news.user.js index b005c44..5bb18e7 100644 --- a/browser/auto-news.user.js +++ b/browser/auto-news.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name 新闻工作台 · 导入排版与流程 // @namespace local.dww11.auto-news -// @version 0.1.0 +// @version 0.1.2 // @match https://www.dww11.com/* // @match https://dww11.com/* // @match https://house.dww11.com/* @@ -18,7 +18,7 @@ else core.mount(root); })(typeof window === 'undefined' ? null : window, function () { 'use strict'; - const VERSION = '1.0.0'; + const VERSION = '1.0.1'; const MAX_IMAGE_SIZE = 20 * 1024 * 1024; const IMAGE_EXT = /\.(?:jpe?g|png|gif)$/i; @@ -49,10 +49,11 @@ const image = text.startsWith('![', start); if (!image && text[start] !== '[') return null; const left = start + (image ? 2 : 1); - let end = left; + let end = left, brackets = 1; for (; end < text.length; end++) { if (text[end] === '\\') { end++; continue; } - if (text[end] === ']') break; + if (text[end] === '[') brackets++; + if (text[end] === ']' && --brackets === 0) break; } if (text.slice(end, end + 2) !== '](') return null; let pos = end + 2, depth = 1, quoted = '', angle = false; @@ -110,7 +111,7 @@ let list = '', quote = false, code = false, fence = ''; const closeList = () => { if (list) output.push('[/list]'); list = ''; }; for (let index = 0; index < lines.length; index++) { - const line = lines[index]; + let line = lines[index]; const codeMark = line.match(/^\s*(`{3,}|~{3,})/); if (codeMark && (!code || codeMark[1][0] === fence)) { closeList(); @@ -118,6 +119,19 @@ code = !code; fence = codeMark[1][0]; continue; } if (code) { output.push(line.replace(/\[\/code\]/gi, '[/code]')); continue; } + // A linked image/caption may span multiple Markdown lines. Keep the complete + // construct together so its inner image is visited and uploaded exactly once. + const remainder = lines.slice(index).join('\n'); + for (let pos = 0; pos < line.length; pos++) { + const link = readLink(remainder, pos); + if (!link) continue; + if (link.end > line.length) { + const endLine = remainder.slice(0, link.end).split('\n').length - 1; + line = lines.slice(index, index + endLine + 1).join('\n'); + } + pos = link.end - 1; + } + index += line.split('\n').length - 1; const quoted = /^\s*>\s?/.test(line); if (quoted !== quote) { closeList(); output.push(quoted ? '[quote]' : '[/quote]'); quote = quoted; } const content = quoted ? line.replace(/^\s*>\s?/, '') : line; @@ -217,6 +231,43 @@ return value.replace(/"/g, ''); } + function attachmentId(image) { + // The live Discuz editor uses aid="attachimg_123", not necessarily an id. + for (const value of [image.getAttribute('aid'), image.id]) { + const match = String(value || '').match(/^attachimg_([1-9]\d*)$/); + if (match) return match[1]; + } + return null; + } + + async function verifyEditorImages(body, expected, signal) { + signal?.throwIfAborted(); + if (!body?.querySelectorAll) throw new Error('无法读取可视化编辑器图片'); + const images = Array.from(body.querySelectorAll('img')).filter(image => attachmentId(image)); + if (JSON.stringify(images.map(attachmentId)) !== JSON.stringify(expected.map(String))) { + throw new Error('正文配图数量或顺序不符,已停止;上传成功不代表正文插图成功。'); + } + await Promise.all(images.map(image => new Promise((resolve, reject) => { + let timer; + const finish = error => { + clearTimeout(timer); + image.removeEventListener('load', onLoad); + image.removeEventListener('error', onError); + signal?.removeEventListener('abort', onAbort); + error ? reject(error) : resolve(); + }; + const onLoad = () => image.naturalWidth > 0 ? finish() : onError(); + const onError = () => finish(new Error('网站附件图片无法显示(编号 ' + attachmentId(image) + ')。请核对附件缩略图及图片请求的 HTTP 错误;不会自动重传或发布。')); + const onAbort = () => finish(new Error('流程已终止')); + image.addEventListener('load', onLoad); + image.addEventListener('error', onError); + signal?.addEventListener('abort', onAbort, { once: true }); + timer = setTimeout(() => finish(new Error('等待正文图片加载超时(编号 ' + attachmentId(image) + ')')), 25000); + if (signal?.aborted) onAbort(); + else if (image.complete) onLoad(); + }))); + } + function mount(win) { const doc = win.document; const params = new URLSearchParams(win.location.search); @@ -423,7 +474,8 @@ setEditor(selected.title, bbcode); const raw = win.getEditorContents(); if (Number(win.wysiwyg) === 1) { - for (const id of new Set(aids.values())) if (!raw.includes('attachimg_' + id)) throw new Error('富文本图片转换未通过检查。'); + const expected = Array.from(bbcode.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g), match => match[1]); + await verifyEditorImages(doc.querySelector('#e_iframe')?.contentDocument?.body || win.editdoc?.body, expected, signal); } else if (raw !== bbcode) throw new Error('正文填写校验失败。'); if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。'); committed = true; @@ -436,7 +488,9 @@ const switcher = doc.querySelector('#e_switchercheck'); if (switcher) switcher.checked = !backup.mode; throw error; } - lastResult = { title: selected.title, images: Object.fromEntries(aids), completedAt: new Date().toISOString() }; + lastResult = { title: selected.title, images: Object.fromEntries(aids), + attachmentOrder: Array.from(bbcode.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g), match => match[1]), + completedAt: new Date().toISOString() }; notify('complete', `已填写标题、正文和 ${aids.size} 张配图。请检查排版,随后手动发表。`); return lastResult; } catch (error) { @@ -457,6 +511,7 @@ // Public v1 interface. Staging is read-only; importPrepared is the explicit upload/fill step. // No postMessage listener, localhost service, publishing endpoint or saved credentials. const api = Object.freeze({ version: VERSION, schemaVersion: 1, stageFiles, stagePackage, + attachmentId, verifyEditorImages, getState: summary, openPicker: () => { if (!busy) $('folder').click(); }, importPrepared, getBackup: () => savedBackup }); win.DWW11Importer = api; @@ -476,7 +531,7 @@ $('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json'); win.addEventListener('pagehide', releasePreviews, { once: true }); } - return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, mount }; + return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, attachmentId, verifyEditorImages, mount }; }); // Extracted from dww11-wechat-oneclick.user.js v1.0.9; package source is authoritative. @@ -660,12 +715,12 @@ window.DWW11Formatter = Object.freeze({ const footer = existingSource(body); const expected = `来源:${expectedSource},如有侵权,请联系删除。`; if (!footer || footer.text !== expected) throw new Error('文末来源与文章包不一致,请人工检查'); - const images = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src')); + const images = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':')); const backup = body.innerHTML; try { footer.remove(); formatBody(body, expectedSource, expected); - const after = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src')); + const after = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':')); if (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变'); if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常'); body.dispatchEvent(new Event('input', {bubbles:true})); @@ -684,6 +739,7 @@ window.DWW11Formatter = Object.freeze({ const owner=sessionStorage.getItem('auto-news-owner') || crypto.randomUUID(); sessionStorage.setItem('auto-news-owner',owner); let run=null, working=false, halted=true, aborter=null, active=null, tickBusy=false; + let skipBusy=false, preparation=null; const pending=new Map(); function rpc(route,body={}) { return new Promise((resolve,reject)=>{ @@ -725,7 +781,7 @@ window.DWW11Formatter = Object.freeze({ const body=document.querySelector('#e_iframe')?.contentDocument?.body; if(!body)throw new Error('找不到可视化编辑器'); return {title:document.querySelector('#subject')?.value || '', - images:[...body.querySelectorAll('img')].map(x=>({id:x.id,src:x.getAttribute('src')})), + images:[...body.querySelectorAll('img')].map(x=>({id:x.id,aid:x.getAttribute('aid'),src:x.getAttribute('src')})), // Snapshot only final submitted content; never includes hidden tokens or the full form. html:body.innerHTML.slice(0,500000), text:body.innerText.slice(0,100000)}; } @@ -733,6 +789,7 @@ window.DWW11Formatter = Object.freeze({ function loadLocal(){try{return JSON.parse(sessionStorage.getItem(SLOT)||'null');}catch{return null;}} async function event(state,extra={}) {return rpc('event',{id:active.id,state,epoch:run.epoch,...extra});} async function fail(error) { + if(skipBusy)return; status(error.message); if(active)try{await event('needs_attention',{error:error.message});}catch{} halted=true;aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop')); @@ -761,8 +818,9 @@ window.DWW11Formatter = Object.freeze({ window.DWW11Formatter.format(job.source);assertLive(epoch); const snapshot=editorSnapshot(); if(snapshot.title!==job.title)throw new Error('标题校验失败'); - const aids=new Set(Object.values(result.images)); - for(const aid of aids) if(!snapshot.images.some(x=>x.id==='attachimg_'+aid))throw new Error('配图附件缺失:'+aid); + await window.DWW11Importer.verifyEditorImages( + document.querySelector('#e_iframe').contentDocument.body, result.attachmentOrder, aborter.signal); + assertLive(epoch); await event('awaiting_user_review',{snapshot});assertLive(epoch); active={...job,state:'awaiting_user_review'};saveLocal({id:job.id}); status('已完成排版,请核对内容并亲手点击“发表帖子”。'); @@ -770,6 +828,9 @@ window.DWW11Formatter = Object.freeze({ } // Record only trusted user activation. Never replay a click, call submit(), or send a post request. document.addEventListener('click',event=>{ + if(skipBusy&&event.target.closest?.('#postsubmit')) { + event.preventDefault();event.stopImmediatePropagation();return; + } if(!event.isTrusted||!event.target.closest?.('#postsubmit')||active?.state!=='awaiting_user_review')return; try { const snapshot=editorSnapshot(); @@ -780,6 +841,35 @@ window.DWW11Formatter = Object.freeze({ active.state='user_submitting';status('已观察到人工点击,等待结果页核对。'); }catch(error){status('提交快照记录失败:'+error.message);} },true); + document.addEventListener('submit',event=>{ + if(skipBusy&&event.target.id==='postform') {event.preventDefault();event.stopImmediatePropagation();} + },true); + document.addEventListener('dww11:skip-article',async()=>{ + if(skipBusy)return; + if(!active||!run||run.owner!==owner) {status('当前标签没有可跳过的文章');return;} + if(!['preparing','importing','formatting','awaiting_user_review','needs_attention'].includes(active.state)) { + status('文章已提交或结果待核对,请先人工核对');return; + } + skipBusy=true;halted=true; + document.dispatchEvent(new CustomEvent('dww11:skip-busy',{detail:true})); + const jobId=active.id,epoch=run.epoch; + aborter?.abort(new Error('正在跳过当前文章')); + status('正在跳过当前文章…'); + try { + // Let cancelled imports finish their rollback before leaving the editor. + await preparation; + const result=await rpc('skip',{id:jobId,epoch}); + sessionStorage.removeItem(SLOT);active=null;run=result.run; + status('已跳过,正在打开下一篇文章的发帖页…'); + // Always load a fresh editor, including when the fid and URL are unchanged. + location.assign(`https://www.dww11.com/forum.php?mod=post&action=newthread&fid=${result.fid}`); + }catch(error){ + try{await rpc('stop');}catch{} + status('跳过未完成,流程已暂停:'+error.message); + skipBusy=false; + document.dispatchEvent(new CustomEvent('dww11:skip-busy',{detail:false})); + } + }); async function observeResult(job) { const local=loadLocal(); if(local?.id===job.id&&local.snapshot&&job.state==='awaiting_user_review') { @@ -807,9 +897,10 @@ window.DWW11Formatter = Object.freeze({ button.click();status('正在换号;等待插件完成并核对新账号'); } async function tick() { - if(tickBusy)return;tickBusy=true; + if(tickBusy||skipBusy)return;tickBusy=true; try { run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()}); + if(skipBusy)return; halted=!run.running; if(!run.running) {aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));} if(working)return; @@ -823,8 +914,9 @@ window.DWW11Formatter = Object.freeze({ status(run.reason || '流程已暂停');return; } const next=await rpc('next',{account:account(),extension:extension()});run=next.run; + if(skipBusy)return; if(next.job)active=next.job; - if(next.action==='prepare'||(next.action==='resume'&&next.job.state==='preparing'))void prepare(next.job); + if(next.action==='prepare'||(next.action==='resume'&&next.job.state==='preparing'))preparation=prepare(next.job); else if(next.action==='resume') { await observeResult(next.job); if(next.job.state==='awaiting_user_review') { diff --git a/browser/formatter.js b/browser/formatter.js index 46a7712..1a5e468 100644 --- a/browser/formatter.js +++ b/browser/formatter.js @@ -179,12 +179,12 @@ window.DWW11Formatter = Object.freeze({ const footer = existingSource(body); const expected = `来源:${expectedSource},如有侵权,请联系删除。`; if (!footer || footer.text !== expected) throw new Error('文末来源与文章包不一致,请人工检查'); - const images = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src')); + const images = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':')); const backup = body.innerHTML; try { footer.remove(); formatBody(body, expectedSource, expected); - const after = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src')); + const after = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':')); if (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变'); if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常'); body.dispatchEvent(new Event('input', {bubbles:true})); diff --git a/browser/importer.js b/browser/importer.js index 58515c1..2b9a4b0 100644 --- a/browser/importer.js +++ b/browser/importer.js @@ -1,7 +1,7 @@ // ==UserScript== // @name 广谈大湾网 · 本地文章目录导入 // @namespace local.dww11.article-importer -// @version 1.0.0 +// @version 1.0.1 // @description 选择整个文章目录,导入 Markdown 标题、正文与本地配图;保留人工发布与程序接口。 // @match https://www.dww11.com/forum.php* // @match https://dww11.com/forum.php* @@ -18,7 +18,7 @@ else core.mount(root); })(typeof window === 'undefined' ? null : window, function () { 'use strict'; - const VERSION = '1.0.0'; + const VERSION = '1.0.1'; const MAX_IMAGE_SIZE = 20 * 1024 * 1024; const IMAGE_EXT = /\.(?:jpe?g|png|gif)$/i; @@ -49,10 +49,11 @@ const image = text.startsWith('![', start); if (!image && text[start] !== '[') return null; const left = start + (image ? 2 : 1); - let end = left; + let end = left, brackets = 1; for (; end < text.length; end++) { if (text[end] === '\\') { end++; continue; } - if (text[end] === ']') break; + if (text[end] === '[') brackets++; + if (text[end] === ']' && --brackets === 0) break; } if (text.slice(end, end + 2) !== '](') return null; let pos = end + 2, depth = 1, quoted = '', angle = false; @@ -110,7 +111,7 @@ let list = '', quote = false, code = false, fence = ''; const closeList = () => { if (list) output.push('[/list]'); list = ''; }; for (let index = 0; index < lines.length; index++) { - const line = lines[index]; + let line = lines[index]; const codeMark = line.match(/^\s*(`{3,}|~{3,})/); if (codeMark && (!code || codeMark[1][0] === fence)) { closeList(); @@ -118,6 +119,19 @@ code = !code; fence = codeMark[1][0]; continue; } if (code) { output.push(line.replace(/\[\/code\]/gi, '[/code]')); continue; } + // A linked image/caption may span multiple Markdown lines. Keep the complete + // construct together so its inner image is visited and uploaded exactly once. + const remainder = lines.slice(index).join('\n'); + for (let pos = 0; pos < line.length; pos++) { + const link = readLink(remainder, pos); + if (!link) continue; + if (link.end > line.length) { + const endLine = remainder.slice(0, link.end).split('\n').length - 1; + line = lines.slice(index, index + endLine + 1).join('\n'); + } + pos = link.end - 1; + } + index += line.split('\n').length - 1; const quoted = /^\s*>\s?/.test(line); if (quoted !== quote) { closeList(); output.push(quoted ? '[quote]' : '[/quote]'); quote = quoted; } const content = quoted ? line.replace(/^\s*>\s?/, '') : line; @@ -217,6 +231,43 @@ return value.replace(/"/g, ''); } + function attachmentId(image) { + // The live Discuz editor uses aid="attachimg_123", not necessarily an id. + for (const value of [image.getAttribute('aid'), image.id]) { + const match = String(value || '').match(/^attachimg_([1-9]\d*)$/); + if (match) return match[1]; + } + return null; + } + + async function verifyEditorImages(body, expected, signal) { + signal?.throwIfAborted(); + if (!body?.querySelectorAll) throw new Error('无法读取可视化编辑器图片'); + const images = Array.from(body.querySelectorAll('img')).filter(image => attachmentId(image)); + if (JSON.stringify(images.map(attachmentId)) !== JSON.stringify(expected.map(String))) { + throw new Error('正文配图数量或顺序不符,已停止;上传成功不代表正文插图成功。'); + } + await Promise.all(images.map(image => new Promise((resolve, reject) => { + let timer; + const finish = error => { + clearTimeout(timer); + image.removeEventListener('load', onLoad); + image.removeEventListener('error', onError); + signal?.removeEventListener('abort', onAbort); + error ? reject(error) : resolve(); + }; + const onLoad = () => image.naturalWidth > 0 ? finish() : onError(); + const onError = () => finish(new Error('网站附件图片无法显示(编号 ' + attachmentId(image) + ')。请核对附件缩略图及图片请求的 HTTP 错误;不会自动重传或发布。')); + const onAbort = () => finish(new Error('流程已终止')); + image.addEventListener('load', onLoad); + image.addEventListener('error', onError); + signal?.addEventListener('abort', onAbort, { once: true }); + timer = setTimeout(() => finish(new Error('等待正文图片加载超时(编号 ' + attachmentId(image) + ')')), 25000); + if (signal?.aborted) onAbort(); + else if (image.complete) onLoad(); + }))); + } + function mount(win) { const doc = win.document; const params = new URLSearchParams(win.location.search); @@ -423,7 +474,8 @@ setEditor(selected.title, bbcode); const raw = win.getEditorContents(); if (Number(win.wysiwyg) === 1) { - for (const id of new Set(aids.values())) if (!raw.includes('attachimg_' + id)) throw new Error('富文本图片转换未通过检查。'); + const expected = Array.from(bbcode.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g), match => match[1]); + await verifyEditorImages(doc.querySelector('#e_iframe')?.contentDocument?.body || win.editdoc?.body, expected, signal); } else if (raw !== bbcode) throw new Error('正文填写校验失败。'); if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。'); committed = true; @@ -436,7 +488,9 @@ const switcher = doc.querySelector('#e_switchercheck'); if (switcher) switcher.checked = !backup.mode; throw error; } - lastResult = { title: selected.title, images: Object.fromEntries(aids), completedAt: new Date().toISOString() }; + lastResult = { title: selected.title, images: Object.fromEntries(aids), + attachmentOrder: Array.from(bbcode.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g), match => match[1]), + completedAt: new Date().toISOString() }; notify('complete', `已填写标题、正文和 ${aids.size} 张配图。请检查排版,随后手动发表。`); return lastResult; } catch (error) { @@ -457,6 +511,7 @@ // Public v1 interface. Staging is read-only; importPrepared is the explicit upload/fill step. // No postMessage listener, localhost service, publishing endpoint or saved credentials. const api = Object.freeze({ version: VERSION, schemaVersion: 1, stageFiles, stagePackage, + attachmentId, verifyEditorImages, getState: summary, openPicker: () => { if (!busy) $('folder').click(); }, importPrepared, getBackup: () => savedBackup }); win.DWW11Importer = api; @@ -476,5 +531,5 @@ $('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json'); win.addEventListener('pagehide', releasePreviews, { once: true }); } - return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, mount }; + return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, attachmentId, verifyEditorImages, mount }; }); diff --git a/browser/local-bridge.user.js b/browser/local-bridge.user.js index 71b6cec..3c8113f 100644 --- a/browser/local-bridge.user.js +++ b/browser/local-bridge.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name 新闻工作台 · 本地连接 // @namespace local.dww11.auto-news -// @version 0.1.0 +// @version 0.1.1 // @match https://www.dww11.com/* // @match https://dww11.com/* // @match https://house.dww11.com/* @@ -15,7 +15,7 @@ // ==/UserScript== (() => { 'use strict'; - const allowed = new Set(['heartbeat','next','package','event','accept','switch-begin','takeover','stop','current']); + const allowed = new Set(['heartbeat','next','package','event','accept','switch-begin','takeover','stop','current','skip']); let token = GM_getValue('pairToken',''), port = GM_getValue('pairPort',8770); let stopRevision = 0; const signalStop = () => { stopRevision++; document.dispatchEvent(new CustomEvent('dww11:auto-stop')); }; @@ -56,6 +56,10 @@ const shadow=host.attachShadow({mode:'closed'}); shadow.innerHTML='
新闻工作台 · 本地连接
连接设置
请启动本地工作台并配对
'; const $=id=>shadow.getElementById(id);$('port').value=port; + const skip=document.createElement('button');skip.id='skip';skip.type='button';skip.textContent='跳过本文章'; + $('halt').before(skip); + skip.onclick=()=>document.dispatchEvent(new CustomEvent('dww11:skip-article')); + document.addEventListener('dww11:skip-busy',event=>{skip.disabled=Boolean(event.detail);}); $('save').onclick=()=>{ const value=Number($('port').value);if(!Number.isInteger(value)||value<1||value>65535){$('status').textContent='端口无效';return;} if($('token').value.trim()) token=$('token').value.trim();port=value; diff --git a/browser/runner.js b/browser/runner.js index 4e5ce89..d7dc73b 100644 --- a/browser/runner.js +++ b/browser/runner.js @@ -7,6 +7,7 @@ const owner=sessionStorage.getItem('auto-news-owner') || crypto.randomUUID(); sessionStorage.setItem('auto-news-owner',owner); let run=null, working=false, halted=true, aborter=null, active=null, tickBusy=false; + let skipBusy=false, preparation=null; const pending=new Map(); function rpc(route,body={}) { return new Promise((resolve,reject)=>{ @@ -48,7 +49,7 @@ const body=document.querySelector('#e_iframe')?.contentDocument?.body; if(!body)throw new Error('找不到可视化编辑器'); return {title:document.querySelector('#subject')?.value || '', - images:[...body.querySelectorAll('img')].map(x=>({id:x.id,src:x.getAttribute('src')})), + images:[...body.querySelectorAll('img')].map(x=>({id:x.id,aid:x.getAttribute('aid'),src:x.getAttribute('src')})), // Snapshot only final submitted content; never includes hidden tokens or the full form. html:body.innerHTML.slice(0,500000), text:body.innerText.slice(0,100000)}; } @@ -56,6 +57,7 @@ function loadLocal(){try{return JSON.parse(sessionStorage.getItem(SLOT)||'null');}catch{return null;}} async function event(state,extra={}) {return rpc('event',{id:active.id,state,epoch:run.epoch,...extra});} async function fail(error) { + if(skipBusy)return; status(error.message); if(active)try{await event('needs_attention',{error:error.message});}catch{} halted=true;aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop')); @@ -84,8 +86,9 @@ window.DWW11Formatter.format(job.source);assertLive(epoch); const snapshot=editorSnapshot(); if(snapshot.title!==job.title)throw new Error('标题校验失败'); - const aids=new Set(Object.values(result.images)); - for(const aid of aids) if(!snapshot.images.some(x=>x.id==='attachimg_'+aid))throw new Error('配图附件缺失:'+aid); + await window.DWW11Importer.verifyEditorImages( + document.querySelector('#e_iframe').contentDocument.body, result.attachmentOrder, aborter.signal); + assertLive(epoch); await event('awaiting_user_review',{snapshot});assertLive(epoch); active={...job,state:'awaiting_user_review'};saveLocal({id:job.id}); status('已完成排版,请核对内容并亲手点击“发表帖子”。'); @@ -93,6 +96,9 @@ } // Record only trusted user activation. Never replay a click, call submit(), or send a post request. document.addEventListener('click',event=>{ + if(skipBusy&&event.target.closest?.('#postsubmit')) { + event.preventDefault();event.stopImmediatePropagation();return; + } if(!event.isTrusted||!event.target.closest?.('#postsubmit')||active?.state!=='awaiting_user_review')return; try { const snapshot=editorSnapshot(); @@ -103,6 +109,35 @@ active.state='user_submitting';status('已观察到人工点击,等待结果页核对。'); }catch(error){status('提交快照记录失败:'+error.message);} },true); + document.addEventListener('submit',event=>{ + if(skipBusy&&event.target.id==='postform') {event.preventDefault();event.stopImmediatePropagation();} + },true); + document.addEventListener('dww11:skip-article',async()=>{ + if(skipBusy)return; + if(!active||!run||run.owner!==owner) {status('当前标签没有可跳过的文章');return;} + if(!['preparing','importing','formatting','awaiting_user_review','needs_attention'].includes(active.state)) { + status('文章已提交或结果待核对,请先人工核对');return; + } + skipBusy=true;halted=true; + document.dispatchEvent(new CustomEvent('dww11:skip-busy',{detail:true})); + const jobId=active.id,epoch=run.epoch; + aborter?.abort(new Error('正在跳过当前文章')); + status('正在跳过当前文章…'); + try { + // Let cancelled imports finish their rollback before leaving the editor. + await preparation; + const result=await rpc('skip',{id:jobId,epoch}); + sessionStorage.removeItem(SLOT);active=null;run=result.run; + status('已跳过,正在打开下一篇文章的发帖页…'); + // Always load a fresh editor, including when the fid and URL are unchanged. + location.assign(`https://www.dww11.com/forum.php?mod=post&action=newthread&fid=${result.fid}`); + }catch(error){ + try{await rpc('stop');}catch{} + status('跳过未完成,流程已暂停:'+error.message); + skipBusy=false; + document.dispatchEvent(new CustomEvent('dww11:skip-busy',{detail:false})); + } + }); async function observeResult(job) { const local=loadLocal(); if(local?.id===job.id&&local.snapshot&&job.state==='awaiting_user_review') { @@ -130,9 +165,10 @@ button.click();status('正在换号;等待插件完成并核对新账号'); } async function tick() { - if(tickBusy)return;tickBusy=true; + if(tickBusy||skipBusy)return;tickBusy=true; try { run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()}); + if(skipBusy)return; halted=!run.running; if(!run.running) {aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));} if(working)return; @@ -146,8 +182,9 @@ status(run.reason || '流程已暂停');return; } const next=await rpc('next',{account:account(),extension:extension()});run=next.run; + if(skipBusy)return; if(next.job)active=next.job; - if(next.action==='prepare'||(next.action==='resume'&&next.job.state==='preparing'))void prepare(next.job); + if(next.action==='prepare'||(next.action==='resume'&&next.job.state==='preparing'))preparation=prepare(next.job); else if(next.action==='resume') { await observeResult(next.job); if(next.job.state==='awaiting_user_review') { diff --git a/build_browser.py b/build_browser.py index 6e9b75d..570a44d 100644 --- a/build_browser.py +++ b/build_browser.py @@ -4,7 +4,7 @@ ROOT = Path(__file__).resolve().parent header = '''// ==UserScript== // @name 新闻工作台 · 导入排版与流程 // @namespace local.dww11.auto-news -// @version 0.1.0 +// @version 0.1.2 // @match https://www.dww11.com/* // @match https://dww11.com/* // @match https://house.dww11.com/* diff --git a/static/app.js b/static/app.js index 9a05752..fa970b3 100644 --- a/static/app.js +++ b/static/app.js @@ -1,7 +1,7 @@ 'use strict'; const $=id=>document.getElementById(id); let token='',state=null,loadedKey='',refreshing=false; -const labels={queued:'待处理',preparing:'准备中',importing:'上传与导入',formatting:'排版中',awaiting_user_review:'等待人工审核',user_submitting:'已观察到人工提交',submit_unknown:'发布结果待核对',accepted_pending_moderation:'已受理 · 审核中',published:'已确认公开',needs_attention:'需要处理',cancelled:'已移出'}; +const labels={queued:'待处理',preparing:'准备中',importing:'上传与导入',formatting:'排版中',awaiting_user_review:'等待人工审核',user_submitting:'已观察到人工提交',submit_unknown:'发布结果待核对',accepted_pending_moderation:'已受理 · 审核中',published:'已确认公开',needs_attention:'需要处理',cancelled:'已移出',skipped:'已跳过'}; function notice(message){$('notice').hidden=!message;$('notice').textContent=message;} async function api(route,body={}) { const response=await fetch('/api/'+route,{method:'POST',headers:{'Content-Type':'application/json','X-Session-Token':token},body:JSON.stringify(body)}); diff --git a/store.py b/store.py index e4117c5..18da1bd 100644 --- a/store.py +++ b/store.py @@ -184,6 +184,24 @@ class Store: self._check_owner(data['owner']) return {'job': self._job(db, self.run['active']) if self.run['active'] else None, 'run': self.run} + def skip(self, data): + """Explicitly discard an unsubmitted task and continue the existing queue.""" + with self.lock, self.db() as db: + self._check_owner(data['owner']) + if data.get('epoch') != self.run['epoch']: + raise ValueError('流程状态已改变,请重新确认后跳过') + if self.run['active'] != data.get('id'): + raise ValueError('不是当前任务,不能跳过') + job = self._job(db, data['id']) + if job['state'] not in {'preparing', 'importing', 'formatting', 'awaiting_user_review', 'needs_attention'} or job.get('submitted_at'): + raise ValueError('文章已提交或结果待核对,不能直接跳过,请先人工核对') + job.update(state='skipped', error='', skipped_at=time.time()) + self.run.update(active=None, running=True, epoch=self.run['epoch'] + 1, + reason='已跳过当前文章,等待准备下一篇') + self._save(db, job) + self._persist(db) + return {'ok': True, 'fid': job['fid'], 'run': dict(self.run)} + def switch_begin(self, data): with self.lock, self.db() as db: self._check_owner(data['owner']) diff --git a/test/README.md b/test/README.md index e69355b..cac320e 100644 --- a/test/README.md +++ b/test/README.md @@ -3,6 +3,7 @@ - `history/`:从 auto_news_upload 根目录移入的历史截图和演示任务 JSON,保留原内容。不是自动测试,不能用作本次测试结论。 - `fixtures/article/`:自造 Markdown 和三张 1×1 PNG,其中两张内容相同,用于验证上传去重及插图顺序。 - `test_queue.py`:持久队列、去重、重启、终止、未知提交和按篇数换号。 +- `runner.test.cjs`:跳过后打开新编辑器、导入取消回滚等待、旧轮询响应、已提交保护及失败暂停。 - `test_controller.py`:本地接口权限、模拟下载到队列、静态页面路由。 - `test_download_core.py`:复制的下载器原有离线测试,针对合并项目中的下载器副本运行。 - `importer.test.cjs`:导入器回归与自动模式的停止/冲突保护。 diff --git a/test/bridge.test.cjs b/test/bridge.test.cjs index 0a4fba6..37e62a6 100644 --- a/test/bridge.test.cjs +++ b/test/bridge.test.cjs @@ -11,3 +11,26 @@ test('终止后较早的 heartbeat 响应不能恢复流程',async()=>{ assert.equal(responses.find(r=>r.id==='old').value.running,false);assert.equal(values.halted,true); requests[1].onload({status:200,responseText:JSON.stringify({running:false,epoch:2})});await stop; }); + +test('悬浮窗跳过按钮发送页面事件并防止忙碌时重复点击',()=>{ + const handlers=new Map(),elements=new Map(),emitted=[],requests=[]; + const element=()=>({style:{},before(child){elements.set(child.id,child);}}); + for(const id of ['halt','port','token','save','status'])elements.set(id,element()); + const doc={readyState:'complete',body:{append(){}},getElementById:()=>null, + createElement:()=>({...element(),attachShadow:()=>({set innerHTML(value){},getElementById:id=>elements.get(id)})}), + addEventListener(type,fn){if(!handlers.has(type))handlers.set(type,[]);handlers.get(type).push(fn);}, + dispatchEvent(event){emitted.push(event);for(const fn of handlers.get(event.type)||[])fn(event);} + }; + class Event{constructor(type,options={}){this.type=type;this.detail=options.detail;}} + vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../browser/local-bridge.user.js'),'utf8'),{ + document:doc,CustomEvent:Event,GM_getValue:(k,d)=>k==='pairToken'?'test-token':k==='halted'?false:d, + GM_setValue:()=>{},GM_addValueChangeListener:()=>{},GM_xmlhttpRequest:r=>requests.push(r) + }); + const skip=elements.get('skip');assert.equal(skip.textContent,'跳过本文章');skip.onclick(); + assert.equal(emitted.at(-1).type,'dww11:skip-article'); + doc.dispatchEvent(new Event('dww11:skip-busy',{detail:true}));assert.equal(skip.disabled,true); + doc.dispatchEvent(new Event('dww11:skip-busy',{detail:false}));assert.equal(skip.disabled,false); + doc.dispatchEvent(new Event('dww11:local-request',{detail:JSON.stringify({id:'skip-test',route:'skip',body:{id:'job'}})})); + assert.equal(requests[0].url,'http://127.0.0.1:8770/bridge/skip'); + requests[0].onload({status:200,responseText:'{"ok":true}'}); +}); diff --git a/test/browser.test.cjs b/test/browser.test.cjs index c8b01c9..feecb48 100644 --- a/test/browser.test.cjs +++ b/test/browser.test.cjs @@ -32,8 +32,8 @@ const output=path.join(__dirname,'artifacts');fs.mkdirSync(output,{recursive:tru document.querySelector('#postsubmit').addEventListener('click',()=>window.clicks++); window.DWW11Importer={stagePackage:async()=>({warnings:[]}),importPrepared:async()=>{ document.querySelector('#subject').value='测试标题'; - document.querySelector('#e_iframe').contentDocument.body.innerHTML='

正文第一段

正文第二段

来源:测试来源,如有侵权,请联系删除。

'; - return {images:{'a.gif':'1'}}; + document.querySelector('#e_iframe').contentDocument.body.innerHTML='

正文第一段

正文第二段

来源:测试来源,如有侵权,请联系删除。

'; + return {images:{'a.gif':'1'},attachmentOrder:['1']}; }}; document.addEventListener('dww11:local-request',event=>{ const {id,route,body}=JSON.parse(event.detail);let value={}; @@ -46,6 +46,8 @@ const output=path.join(__dirname,'artifacts');fs.mkdirSync(output,{recursive:tru document.dispatchEvent(new CustomEvent('dww11:local-response',{detail:JSON.stringify({id,value})})); }); }); + const importerCore=require('../browser/importer.js'); + await mock.addScriptTag({content:'(()=>{'+importerCore.attachmentId.toString()+';'+importerCore.verifyEditorImages.toString()+';Object.assign(window.DWW11Importer,{attachmentId,verifyEditorImages});})();'}); await mock.addScriptTag({path:path.join(ROOT,'browser/formatter.js')}); await mock.addScriptTag({path:path.join(ROOT,'browser/runner.js')}); await mock.waitForFunction(()=>window.job.state==='awaiting_user_review'); diff --git a/test/importer.test.cjs b/test/importer.test.cjs index c192efb..a306026 100644 --- a/test/importer.test.cjs +++ b/test/importer.test.cjs @@ -73,7 +73,7 @@ test('服务器返回错误、登录 HTML 或混入脚本时不猜测附件编 }); // Small DOM harness exercises the public API and site adapter without installing browser software. -function harness({ failUpload = false, loseImages = false, existing = false } = {}) { +function harness({ failUpload = false, loseImages = false, brokenImages = false, existing = false } = {}) { const ids = new Map(), callbacks = new Map(), requests = []; class Element { constructor(id='') { this.id=id; this.style={}; this.value=''; this.disabled=false; this.checked=false; this.hidden=false; this.listeners=new Map(); this.children=[]; } @@ -87,7 +87,14 @@ function harness({ failUpload = false, loseImages = false, existing = false } = removeEventListener(name) { this.listeners.delete(name); } dispatchEvent() {} click() { this.onclick?.(); } - querySelectorAll() { return []; } + querySelectorAll(selector) { + if (selector !== 'img') return []; + return [...this.innerHTML.matchAll(/]*>/g)].map(m => ({ + id:'', complete:true, naturalWidth:brokenImages ? 0 : 100, + getAttribute:name=>name==='aid'?m[1]:null, + addEventListener(){},removeEventListener(){} + })); + } } for (const id of ['subject', 'e_textarea', 'e_switchercheck', 'postform']) ids.set(id, new Element(id)); ids.get('subject').value=existing ? '原标题' : ''; @@ -170,3 +177,35 @@ test('自动导入:上传中终止不会填写正文或继续下一张', async await assert.rejects(h.api.importPrepared({automatic:true,signal:c.signal}),/stopped/); assert.equal(h.requests.length,1);assert.equal(h.ids.get('subject').value,''); }); + +test('真实站点 aid 属性:上传成功但图片响应失败不得报告完成', async () => { + const h=harness({brokenImages:true});await h.api.stageFiles(articleFiles()); + await assert.rejects(h.api.importPrepared({automatic:true}),/网站附件图片无法显示/); + assert.equal(h.ids.get('subject').value,'');assert.equal(h.api.getState().result,null); +}); +test('链接内图片、多行图文链接均参与上传并按原位置渲染', () => { + const md='# 标题\n\n[![宣传](image004.jpg)\n\n### 宣传标题\n\n说明](https://example.com/promo)\n\n![](image001.jpg)'; + const article=core.compile(md,'a.md',new Map([['image004.jpg',new Blob(['x'])],['image001.jpg',new Blob(['y'])]])); + assert.deepEqual([...article.images.keys()],['image004.jpg','image001.jpg']); + const rendered=article.render(new Map([['image004.jpg','104'],['image001.jpg','101']])); + assert.match(rendered,/\[url=https:\/\/example.com\/promo\]\[attachimg\]104\[\/attachimg\]/); + assert.deepEqual([...rendered.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g)].map(x=>x[1]),['104','101']); + assert.equal(rendered.includes('!['),false); +}); +test('图片重复引用必须保留每个位置,不能只校验去重后的编号', async () => { + const img={id:'',getAttribute:n=>n==='aid'?'attachimg_12':null,complete:true,naturalWidth:100,addEventListener(){},removeEventListener(){}}; + await core.verifyEditorImages({querySelectorAll:()=>[img,img]},['12','12']); + await assert.rejects(core.verifyEditorImages({querySelectorAll:()=>[img]},['12','12']),/数量或顺序/); +}); + +test('正文图片尚在加载时等待 load;终止后释放等待', async () => { + const make=()=>{const events=new Map();return {events,id:'',complete:false,naturalWidth:0,getAttribute:n=>n==='aid'?'attachimg_8':null,addEventListener:(n,f)=>events.set(n,f),removeEventListener:n=>events.delete(n)};}; + const image=make();let finished=false; + const loaded=core.verifyEditorImages({querySelectorAll:()=>[image]},['8']).then(()=>finished=true); + await Promise.resolve();assert.equal(finished,false); + image.complete=true;image.naturalWidth=100;image.events.get('load')();await loaded; + assert.equal(image.events.size,0); + const pending=make(),controller=new AbortController(); + const cancelled=core.verifyEditorImages({querySelectorAll:()=>[pending]},['8'],controller.signal); + controller.abort();await assert.rejects(cancelled,/终止/);assert.equal(pending.events.size,0); +}); diff --git a/test/runner.test.cjs b/test/runner.test.cjs new file mode 100644 index 0000000..c5a14fe --- /dev/null +++ b/test/runner.test.cjs @@ -0,0 +1,106 @@ +'use strict'; +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const path=require('node:path'); +const vm=require('node:vm'); +const flush=async()=>{for(let i=0;i<12;i++)await new Promise(resolve=>setImmediate(resolve));}; + +function fixture({holdImport=false,holdHeartbeat=false,rejectSkip=false}={}) { + const handlers=new Map(),calls=[],navigations=[],storage=new Map(),timers=new Set(); + const job={id:'first',state:'preparing',title:'标题',source:'来源',fid:125,account:{uid:'123',name:'账号'}}; + const run={running:true,epoch:1,active:job.id,owner:'tab-a'}; + const body={innerHTML:'正文',innerText:'正文',querySelectorAll:()=>[]}; + const subject={value:''};let tick,finishImport,finishHeartbeat,rolledBack=false; + class Event {constructor(type,options={}){this.type=type;Object.assign(this,options);}} + const doc={ + body:{append(){}},createElement:()=>({style:{}}), + querySelector(selector){ + if(selector==='#mumucms_username')return {childNodes:[{nodeType:3,textContent:'账号'}],href:'https://www.dww11.com/home.php?uid=123',querySelectorAll:()=>[]}; + if(selector==='#e_iframe')return {contentDocument:{body}}; + if(selector==='#subject')return subject; + return null; + }, + addEventListener(type,fn){if(!handlers.has(type))handlers.set(type,[]);handlers.get(type).push(fn);}, + dispatchEvent(event){for(const fn of handlers.get(event.type)||[])fn(event);} + }; + const reply=(id,value,error)=>doc.dispatchEvent(new Event('dww11:local-response',{detail:JSON.stringify({id,value,error})})); + doc.addEventListener('dww11:local-request',event=>{ + const {id,route,body}=JSON.parse(event.detail);calls.push({route,body}); + if(route==='heartbeat'&&holdHeartbeat&&calls.filter(x=>x.route==='heartbeat').length>1) { + finishHeartbeat=()=>reply(id,run);return; + } + let value={}; + if(route==='heartbeat')value=run; + if(route==='next')value={action:job.state==='preparing'?'prepare':'resume',job,run}; + if(route==='event')job.state=body.state; + if(route==='skip') { + if(rejectSkip){reply(id,null,'状态已改变');return;} + job.state='skipped';run.active=null;run.epoch++;value={ok:true,fid:125,run}; + } + if(route==='stop')run.running=false; + reply(id,value); + }); + let sequence=0; + const context={document:doc,CustomEvent:Event,Node:{TEXT_NODE:3},URL,AbortController, + crypto:{randomUUID:()=>sequence++===0?'tab-a':`request-${sequence}`},sessionStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)}, + location:{href:'https://www.dww11.com/forum.php?mod=post&action=newthread&fid=125',assign:url=>navigations.push(url)}, + setInterval:fn=>{tick=fn;},setTimeout:(fn,ms)=>{const t=setTimeout(fn,ms);timers.add(t);return t;},clearTimeout:t=>{clearTimeout(t);timers.delete(t);} + }; + context.window={DWW11Importer:{stagePackage:async()=>({}),importPrepared:async({signal})=>{ + if(holdImport)await new Promise((resolve,reject)=>{finishImport=()=>{rolledBack=true;reject(signal.reason);};signal.addEventListener('abort',()=>{}, {once:true});}); + signal.throwIfAborted();subject.value='标题';return {attachmentOrder:[]}; + },verifyEditorImages:async()=>{}},DWW11Formatter:{format(){}}}; + vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../browser/runner.js'),'utf8'),context); + return {calls,navigations,job,run,storage,doc,Event,tick:()=>tick(),skip:()=>doc.dispatchEvent(new Event('dww11:skip-article')), + finishImport:()=>finishImport(),finishHeartbeat:()=>finishHeartbeat(),rolledBack:()=>rolledBack, + close:()=>{for(const t of timers)clearTimeout(t);}}; +} + +test('跳过待审文章后打开全新编辑器,不再领取或提交旧稿',async()=>{ + const f=fixture();try{ + await flush();assert.equal(f.job.state,'awaiting_user_review'); + f.skip();f.skip();await flush(); + assert.equal(f.calls.filter(x=>x.route==='skip').length,1); + assert.equal(f.job.state,'skipped');assert.equal(f.storage.has('auto-news-current'),false); + assert.deepEqual(f.navigations,['https://www.dww11.com/forum.php?mod=post&action=newthread&fid=125']); + const count=f.calls.length;await f.tick();assert.equal(f.calls.length,count); + assert.equal(f.calls.some(x=>x.route==='accept'),false); + }finally{f.close();} +}); + +test('导入中跳过等待取消回滚,旧回调不会把任务改成异常',async()=>{ + const f=fixture({holdImport:true});try{ + await flush();assert.equal(f.job.state,'importing');f.skip();await flush(); + assert.equal(f.calls.some(x=>x.route==='skip'),false);assert.deepEqual(f.navigations,[]); + f.finishImport();await flush(); + assert.equal(f.rolledBack(),true);assert.equal(f.job.state,'skipped'); + assert.equal(f.calls.some(x=>x.body.state==='needs_attention'),false); + assert.equal(f.navigations.length,1); + }finally{f.close();} +}); + +test('跳过时忽略较早的轮询响应',async()=>{ + const f=fixture({holdHeartbeat:true});try{ + await flush();const pending=f.tick();await flush();f.skip();await flush(); + const before=f.calls.filter(x=>x.route==='next').length; + f.finishHeartbeat();await pending; + assert.equal(f.calls.filter(x=>x.route==='next').length,before); + }finally{f.close();} +}); + +test('人工发表后的任务不能直接跳过',async()=>{ + const f=fixture();try{ + await flush();f.doc.dispatchEvent(new f.Event('click',{isTrusted:true,target:{closest:()=>true}})); + await flush();assert.equal(f.job.state,'user_submitting');f.skip();await flush(); + assert.equal(f.calls.some(x=>x.route==='skip'),false);assert.deepEqual(f.navigations,[]); + }finally{f.close();} +}); + +test('跳过请求失败时暂停且保留当前稿件',async()=>{ + const f=fixture({rejectSkip:true});try{ + await flush();f.skip();await flush(); + assert.equal(f.run.running,false);assert.equal(f.job.state,'awaiting_user_review');assert.deepEqual(f.navigations,[]); + assert.equal(f.storage.has('auto-news-current'),true); + }finally{f.close();} +}); diff --git a/test/test_controller.py b/test/test_controller.py index f43d03d..7457e9e 100644 --- a/test/test_controller.py +++ b/test/test_controller.py @@ -70,6 +70,19 @@ class ControllerTests(unittest.TestCase): self.controller.scan(self.controller.manager.root) self.assertEqual(len(self.controller.store.state()['jobs']), 1) + def test_bridge_skip_continues_queue(self): + store = self.controller.store + store.enqueue(ROOT / 'test/fixtures/article') + store.configure(dict(fid=125, minimum=1, maximum=1, interval=0, history_checked=True)) + store.control('start') + job = store.next(dict(owner='tab-a', account=dict(uid='123', name='测试账号')))['job'] + code, body = self.request('/bridge/skip', dict(owner='tab-a', id=job['id'], epoch=store.run['epoch']), + token=self.controller.bridge_token) + self.assertEqual(code, 200) + self.assertTrue(json.loads(body)['ok']) + self.assertEqual(store.state()['jobs'][0]['state'], 'skipped') + self.assertIsNone(store.run['active']) + if __name__ == '__main__': unittest.main() diff --git a/test/test_queue.py b/test/test_queue.py index 298fa1b..1239c24 100644 --- a/test/test_queue.py +++ b/test/test_queue.py @@ -144,6 +144,63 @@ class QueueTests(unittest.TestCase): self.assertEqual(self.store.next(self.owner)['action'], 'idle') self.assertIsNone(self.store.run['switch']) + def test_skip_preserves_history_and_continues_next_without_counting(self): + job = self.begin() + other = self.root / 'other' + shutil.copytree(self.article, other) + (other / 'a.md').write_text(self.md.read_text().replace('正文内容', '下一篇内容')) + self.store.enqueue(other) + self.transition(job, 'importing') + self.transition(job, 'formatting') + self.transition(job, 'awaiting_user_review') + result = self.store.skip(dict(owner='tab-a', id=job['id'], epoch=self.store.run['epoch'])) + self.assertEqual(result['fid'], 125) + self.assertEqual(self.store.run['batch']['count'], 0) + self.assertEqual(self.store.state()['history_count'], 0) + self.assertEqual(next(j for j in self.store.state()['jobs'] if j['id'] == job['id'])['state'], 'skipped') + self.assertIn('已在队列', self.store.enqueue(self.article)) + self.assertEqual(self.store.next(self.owner)['action'], 'prepare') + self.assertNotEqual(self.store.run['active'], job['id']) + restarted = Store(self.root / 'queue.sqlite3') + self.assertEqual(next(j for j in restarted.state()['jobs'] if j['id'] == job['id'])['state'], 'skipped') + + def test_skip_last_article_finishes_queue(self): + job = self.begin() + self.store.skip(dict(owner='tab-a', id=job['id'], epoch=self.store.run['epoch'])) + self.assertEqual(self.store.next(self.owner)['action'], 'idle') + self.assertFalse(self.store.run['running']) + self.assertIsNone(self.store.run['switch']) + + def test_skip_rejects_other_tab_stale_request_and_duplicate(self): + job = self.begin() + data = dict(owner='tab-a', id=job['id'], epoch=self.store.run['epoch']) + with self.assertRaisesRegex(ValueError, '另一个'): + self.store.skip({**data, 'owner': 'tab-b'}) + self.store.control('stop') + with self.assertRaisesRegex(ValueError, '状态已改变'): + self.store.skip(data) + data['epoch'] = self.store.run['epoch'] + self.store.skip(data) + with self.assertRaises(ValueError): + self.store.skip(data) + + def test_skip_resumes_unsubmitted_error_but_rejects_submitted_errors(self): + job = self.begin() + self.transition(job, 'needs_attention', error='上传失败') + self.store.skip(dict(owner='tab-a', id=job['id'], epoch=self.store.run['epoch'])) + self.assertTrue(self.store.run['running']) + self.assertEqual(self.store.next(self.owner)['action'], 'idle') + + def test_submitted_article_cannot_be_skipped(self): + job = self.begin() + self.submit(job) + for state in ('user_submitting', 'submit_unknown', 'needs_attention'): + if state != 'user_submitting': + self.transition(job, state) + with self.assertRaisesRegex(ValueError, '已提交'): + self.store.skip(dict(owner='tab-a', id=job['id'], epoch=self.store.run['epoch'])) + self.assertEqual(self.store.run['active'], job['id']) + def test_bad_result_does_not_increment(self): job = self.begin();self.submit(job) with self.assertRaises(ValueError): diff --git a/test/验证记录.md b/test/验证记录.md index 598e334..b89c806 100644 --- a/test/验证记录.md +++ b/test/验证记录.md @@ -1,6 +1,6 @@ # 本轮验证记录 -日期:2026-10-31。测试均未使用真实账号,未向新闻 API 请求内容,未向论坛上传或发布。 +日期:2026-10-31。下表所列离线测试均未使用真实账号,未向新闻 API 请求内容,未向论坛上传或发布。后续真实现场操作另按小节记录。 | 范围 | 结果 | | --- | --- | @@ -19,4 +19,43 @@ 测试中曾发现模拟网页未声明 UTF-8,导致中文登录入口无法匹配;已修正测试响应编码并重新通过。真实网页编码未由此假定。 -尚未验证:用户浏览器中油猴本地通信权限、真实网站当前的导入与附件转换、真实换号账号链路、结果页受理/审核/公开状态。按用户决定,结果页 HTML 待提供,自动核对暂不实现;因此当前不宣称全流程批量处理已完成在线验收。 +上述离线验证结束时尚未验证:用户浏览器中油猴本地通信权限、真实网站当前的导入与附件转换、真实换号账号链路、结果页受理/审核/公开状态。当时结果页 HTML 待提供,自动核对暂不实现;后续现场观察见下文,仍不代表全流程批量处理已完成在线验收。 + +## 2026-10-31:实际图片故障排查与 0.1.1 修复 + +只读检查实际发帖页面,没有点击发表、重新上传、刷新或改写用户正文。 + +- 正文已有 3 个图片元素,实际标识为 `aid="attachimg_…"`;原 runner 错查 `id`,已修正为共享附件验证函数。 +- 这 3 张图及网站附件缩略图的 `naturalWidth` 均为 0;Chrome 开发者工具记录图片接口 HTTP 500。未获取具体错误响应或服务端日志,不能认定其底层原因。 +- 原导入器只验证 HTML 中包含附件编号,不能证明图片可显示。现在校验附件序列(含重复引用),并等待图片加载;加载失败会报错,且不提示完成或继续排版。 +- 原 Markdown 解析器不能处理包含嵌套图片的多行链接。本次实际文章 4 张本地 JPG 原先只识别 3 张;修复后离线编译识别 4 张,输出 4 个正文附件引用,不残留 `![`。 +- 排版前后对比现同时包含图片的 `id`、`aid` 和 `src`。 +- 本轮导入与连接测试 16/16 通过,覆盖 aid 属性、图片错误响应、异步加载/终止、多行嵌套图文、重复图片位置;构建脚本已重新生成 0.1.1 合并油猴文件,语法检查通过。 + +需要在油猴更新 `browser/auto-news.user.js` 后才会应用代码修复。此更新不会解决尚未查明的图片接口 HTTP 500;未宣称现场图片显示已恢复。 + +## 2026-10-31:跳过当前文章 + +- 悬浮窗新增“跳过本文章”,沿用连接桥和持久队列,记录 `skipped` 状态、不计发表数、不自动重新入队。 +- 执行器取消并等待当前导入退出后登记跳过,打开新的发帖页继续;提交中、结果不明以及有提交历史的异常任务拒绝直接跳过。 +- Python 离线测试 33/33 通过,包括浏览器接口、跳过后继续、队列耗尽、重复/跨标签/过期请求、提交状态保护与重启持久化。 +- Node.js 20 脚本测试 22/22 通过,包括悬浮窗事件、导入取消回滚等待、旧轮询竞态及请求失败暂停。两个油猴脚本语法检查通过,已生成 0.1.2 合并脚本。 +- 本轮未操作真实论坛或账号;未执行 Playwright 浏览器回归,页面脚本使用模拟文档和连接响应验证。 + +## 2026-10-31:真实提交提示与审核中详情页观察 + +本节为真实登录态下的现场观察,与前述离线测试分开。用户明确授权助手点击一次“发表帖子”以捕捉快速跳转;未改写待发表内容、重新上传或重复提交。本次不属于自动执行器全流程验收。 + +| 范围 | 观察结果 | +| --- | --- | +| 提交前 | 标题“湖人只有一个卢卡·开拓者的四大金刚”,体育休闲 `fid=90` | +| 提示页 | 明确显示“新主题需要审核,您的帖子通过审核后才能显示” | +| 跳转 | 提示页指向并自动进入 `https://www.dww11.com/thread/23699911.html`,本次未自动回首页 | +| 详情页 | 标题、作者及版块信息可核对,标题旁显示“(审核中)” | +| 主题与首帖 | 页面链接实际 `tid=236999`;首帖 PID `238791`,容器 `#postmessage_238791` | +| 受理结论 | 本次已明确受理 1 篇,已确认公开 0 篇;不能重发 | +| 保存资料 | `../local-records/publish-result-23699911.json` 与 `../local-records/publish-result-23699911.png` | + +已保存详情页关键结构与审核状态截图;提示页文字和跳转链接已捕捉,跳转前的容器 HTML 未保存成功。文件名按访问地址命名,不能将 `23699911` 直接当作真实主题 ID。账号名称与 UID 仅保留在本地排除目录,正文档继续匿名化。 + +本次未验证公开后的访客可见性、失败/超限提示、结果自动识别、受理接口自动记账、重复计数保护或真实换号链路;未补写运行队列与去重库。已有现场资料不等于结果适配已经实现。详见[操作规范第 12 节](../发布流程操作记录与自动化规范.md#12-2026-10-31实际提交与审核中页面观察)。 diff --git a/发布流程操作记录与自动化规范.md b/发布流程操作记录与自动化规范.md index b94dd29..2e214d3 100644 --- a/发布流程操作记录与自动化规范.md +++ b/发布流程操作记录与自动化规范.md @@ -165,3 +165,41 @@ downloaded → queued → importing → formatting → awaiting_user_review - 刷新、重复读取或后来审核通过时,都用站点+主题 ID 保证受理数量只计一次。 - 本次没有出现标题超限,相关处理仍是待验证需求。 - 完整历史对账、常驻终止按钮、随机数量和自动换号仍属后续实现及验收范围。 + +## 12. 2026-10-31:实际提交与审核中页面观察 + +本节补录一次新的现场操作,与第 2、10、11 节的历史演示分别记录。用户本次明确要求由助手点击“发表帖子”并捕捉快速跳转提示;助手仅点击一次,未修改待发表标题或正文、未重新上传、未重复提交。本次授权仅适用于这一次现场操作,不改变工作台默认等待用户亲手发表的规则。 + +### 提交与结果 + +| 项目 | 观察结果 | +| --- | --- | +| 页面显示的发表时间 | 2026-10-31 17:02:54(北京时间,来自结果页时间节点) | +| 文章标题 | 湖人只有一个卢卡·开拓者的四大金刚 | +| 当前账号与作者 | 示例用户,UID <已匿名>;真实账号信息保存在本地观察记录中 | +| 目标版块 | 体育休闲,`fid=90` | +| 提交前页面 | `https://www.dww11.com/forum.php?mod=post&action=newthread&fid=90` | +| 提交后提示页 | `forum.php?mod=post&action=newthread&fid=90&extra=&topicsubmit=yes` | +| 网站原文提示 | 新主题需要审核,您的帖子通过审核后才能显示 | +| 提示页跳转链接 | `https://www.dww11.com/thread/23699911.html` | +| 实际跳转 | 提示页 → 帖子详情页;本次未观察到自动回首页 | +| 详情页状态 | 标题旁明确显示“(审核中)” | +| 实际主题参数 | 页面“复制链接”及相邻主题链接使用 `tid=236999` | +| 首帖标识 | PID `238791`;正文容器为 `#postmessage_238791` | +| 受理结论 | 本次已明确受理 1 篇;本次已确认公开 0 篇 | +| 后续动作 | 未再次发表、未换号;未据此补写运行队列、受理计数或发布去重库 | + +### 页面适配依据 + +| 数据 | 现场依据 | 适配注意事项 | +| --- | --- | --- | +| 提交提示 | 可访问性树中的 `messagetext` 容器及其提示文字、跳转链接 | 已捕捉文字与链接,但跳转前未成功保存该容器 HTML;结果选择器仍需验证 | +| 帖子标题 | `#thread_subject`,位于 `h1.ts` 内 | 与最终提交标题核对,不能只用浏览器页签标题 | +| 审核状态 | 标题所在 `td.plc.mumucms_ftit.vwthd` 内的 `span.xg1` 包含“(审核中)” | 限定标题区域,避免把正文中出现的同名文字当作状态 | +| 主题标识 | 标题区域“复制链接”的 `mod=viewthread&tid=236999` | 地址路径为 `23699911.html`,不能直接将全部数字解析为实际主题 ID | +| 首帖与作者 | `#postmessage_238791`;现场作者链接含 `mod=space&uid=<已匿名>` | 动态提取 PID、UID,并限定首帖作者区域;页面顶部登录账号不能单独证明作者身份 | +| 目标版块 | 现场版块链接含 `mod=forumdisplay&fid=90` | 与任务版块核对,不只匹配“体育休闲”文字 | + +网站已受理待审核,不能将该篇重新入队或为追求公开状态再次提交。本次没有验证审核通过后的访客可见性、失败提示、超长标题错误、自动记账和换号链路;看到登录态详情页不代表已公开。 + +本地资料保存在排除目录 `local-records/`:`publish-result-23699911.json` 保存观察摘要、标题区域 HTML 与作者/版块链接,`publish-result-23699911.png` 保存标题旁“审核中”的截图。文件名沿用访问地址的数字串,不代表实际 `tid`。本节是文档补录,不等于运行去重库已登记;后续对账需关联原文章身份,并统一主题标识的处理方式。 diff --git a/实施计划.md b/实施计划.md index 60d719a..9b5c582 100644 --- a/实施计划.md +++ b/实施计划.md @@ -4,7 +4,9 @@ 更新日期:2026-10-31(北京时间)。本版整体替换早期自动发帖设想,依据当前源码、[发布流程操作记录与自动化规范](发布流程操作记录与自动化规范.md)、[演示任务记录](演示任务记录.json)和[发布去重记录](发布去重记录.json)重新梳理。 -本次进行了本地源码与记录核对,未重新操作线上页面、下载新闻、上传图片或发表帖子。下文“演示已验证”引用既有操作记录;“源码已有”不等于最新版本已在线上重新验收。 +原计划编写时仅进行了本地源码与记录核对,未重新操作线上页面、下载新闻、上传图片或发表帖子。下文“演示已验证”引用既有操作记录;“源码已有”不等于最新版本已在线上重新验收。 + +> 后续现场补录:2026-10-31 已采集一次真实提交提示与审核中详情页,见第 11 节。第 1~10 节保留原计划语境,组件当前能力以 README 和源码为准;本次补录不代表结果观察功能已经接通。 ## 1. 当前目标与确定规则 @@ -176,3 +178,14 @@ downloaded → queued → importing → formatting → awaiting_user_review 新实现仍放在 `auto_news_upload/`,后续规划为 `service/`、`browser/`、`tests/`、`data/` 和启动说明;当前这些实现尚未建立。保留原下载目录、原历史库及现有演示记录。复用源码时记录版本,明确安装时启用的脚本组合,避免重复工具争用编辑器。 本次仅重写本实施计划,未修改业务脚本和历史记录。历史操作规范用于追溯当时实机行为;当前组件能力以本轮核对的源码为准,人工发表、去重、终止和换号规则继续遵循操作规范。 + +## 11. 2026-10-31:结果页现场资料补充 + +用户明确授权助手点击一次“发表帖子”并捕捉提示。网站返回“新主题需要审核,您的帖子通过审核后才能显示”,随后自动进入 `/thread/23699911.html`;详情页标题为“湖人只有一个卢卡·开拓者的四大金刚”,版块为体育休闲 `fid=90`,标题旁显示“(审核中)”。本次确认受理 1 篇,未确认公开,不重复提交或换号。完整过程与页面依据见[操作规范第 12 节](发布流程操作记录与自动化规范.md#12-2026-10-31实际提交与审核中页面观察)。 + +- 已取得标题 `#thread_subject`、标题区域审核标记、作者 UID 链接、版块 fid 链接与首帖正文容器 `#postmessage_238791` 的现场依据;账号信息在可共享文档中继续匿名化。 +- 页面真实主题参数为 `tid=236999`,访问路径中的数字串为 `23699911`,首帖 PID 为 `238791`。结果解析和去重须统一标识规则,不能直接把路径数字串当作真实 `tid`,也不能据此批量改写历史记录。 +- 提示页的文字与跳转链接已捕捉,但未保存跳转前的容器 HTML。详情页关键 HTML 与截图已保存到 `local-records/publish-result-23699911.json`、`local-records/publish-result-23699911.png`。 +- 下一步完成结果观察适配、受理接口标识核对与重复计数验证,再进行后续计数/换号联调;公开、失败和审核后的访客可见性仍待验证。 + +本次仅同步观察记录,未修改业务代码、运行队列或去重库;这次授权代点不扩展为后续自动发表权限,也不替代正式工作流的人工发表边界。