diff --git a/README.md b/README.md index 3a0fe98..a01dfeb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 将新闻下载、文章打包、浏览器导入、排版与换号联动整合到本地控制台。 -**当前版本:0.1。可完成下载 → 入队 → 导入 → 排版 → 等待人工发表。结果页自动核对按用户决定暂留待接入;人工提交后会记录候选结果并暂停,不会自动计数、换号或处理下一篇。** 换号调度和计数接口已完成离线验证,本次已采集真实审核中结果页的关键结构;结果观察适配与完整在线链路验证仍待完成。 +**当前版本:0.2.0。已接通下载 → 入队 → 导入 → 排版 → 人工发表 → 审核中详情核对 → 受理记账 → 回首页 → 下一篇或换号。** 结果页需同时核对真实主题 ID、首帖作者、版块、最终提交标题和标题区域的审核中标志。图文检查有差异则保留已受理去重、暂停后续处理,不自动编辑或重发。尚无公开成功页样本,不能凭审核中标志消失认定已公开;完整真实发布与换号链路仍需小批验收。 ## 启动 @@ -20,7 +20,7 @@ uv run --no-project --python 3.12 --with-requirements requirements.txt python ap 1. 在油猴安装控制台“浏览器连接”区域的两个脚本: - `browser/local-bridge.user.js`:本地连接、配对与常驻终止按钮。 - - `browser/auto-news.user.js`:导入、排版和任务执行。它由 `importer.js`、`formatter.js`、`runner.js` 构建。 + - `browser/auto-news.user.js`:导入、排版和任务执行。它由 `importer.js`、`formatter.js`、`results.js`、`runner.js` 构建。 2. 停用旧的独立导入与排版油猴脚本,避免同一编辑器重复挂载。 3. 换号插件仍由用户安装,位于项目根目录的 `dww11-account-switch-browser-extension/`,没有复制到本目录。本次在其 `content.js` 中增加了不含凭据的状态属性及终止事件监听。请在浏览器扩展管理页重新加载**这个原安装目录**,再刷新网站。旧 ZIP 未重新打包,不包含本次修改。 4. 在控制台点击“显示本次连接码”。在论坛左下角“新闻工作台 · 本地连接”展开设置,填写端口和连接码并保存。 @@ -35,9 +35,10 @@ uv run --no-project --python 3.12 --with-requirements requirements.txt python ap 3. 填写目标版块 `fid`、每账号最少/最多篇数和处理间隔。先核对历史发帖记录,再勾选确认并保存。版块和篇数不会替用户选择。 4. 登录论坛,建议只保留一个执行标签。点击控制台“开始 / 继续”。执行器识别账号后领取一篇任务,进入指定新帖页、导入、排版,停止在人工审核阶段。 5. 在论坛检查账号、版块、标题、图片位置、正文和来源,并亲手点击“发表帖子”。程序不调用发帖提交函数,不发送发帖请求,也不自动保存草稿。 -6. 当前版本会记录人工点击和提交快照,在跳转后的候选页面或等待超时后标记“发布结果待核对”并暂停。**已采集审核中页面资料,仍需完成结果观察适配,才能自动核对受理结果并接续下一篇。** - -待补齐结果页自动核对后:审核中算网站已受理一次,但不算已确认公开;达到账号随机目标篇数且仍有待处理文章时,回首页调用插件换号,核对新 UID 与插件空闲状态后创建下一账号批次。没有待处理文章时直接结束,不为达到数量重发。 +6. 人工点击时保存最终提交快照。快速提示页仅作为候选证据,等待详情页核对;审核中计入受理一次,公开数量另记。真实主题 ID 来自页面链接中的 `tid`,不将 `/thread/…html` 的整串数字当作 TID;首帖 PID 单独保存。 +7. 图文无差异且流程仍在运行时,先持久记账,再回首页;在设定间隔后处理下一篇。达到账号目标且仍有待处理文章时调用换号插件,确认新 UID 和插件空闲后创建下一批次。队列为空直接结束。 +8. 等待结果超过 45 秒仍无足够证据时暂停;结果不明可在原执行标签打开正确详情页进行被动核对。图片加载最多观察 15 秒,附件区图片、占位图、正文文字或图片顺序差异都会暂停后续处理。 +9. 已有现场结果可在暂停后使用队列中的“补核已受理结果”导入观察 JSON,并人工确认文章关联。支持本项目 `local-records/publish-result-*.json` 结构,字段含 title、uid、fid、threadId、postId、url、status;补核须与已有人工提交快照匹配,不增加当前批次计数。历史补核不会自动检查发布后的图文完整性。 ## 终止、恢复与冲突 @@ -79,6 +80,8 @@ 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 test/runner.test.cjs +# 另需 Playwright 与 Chrome,网络请求全部阻断 +node --test test/results-browser.test.cjs python3 build_browser.py ``` @@ -99,7 +102,7 @@ node test/browser.test.cjs 注意:该地址中的数字串为 `23699911`,页面链接实际使用 `tid=236999`,首帖 PID 为 `238791`。适配时必须区分访问地址与主题标识,不能直接把路径中的全部数字当作 `tid`。 -下一步是据此完成 `runner.js` 的结果观察适配,核对受理接口的标识处理,再联通计数和换号。公开帖子、失败提示及审核后的访客可见性仍需补充验证。本次仅保存观察资料,未据此修改程序或补写队列计数;不需要 Cookie、密码或表单令牌。 +本次已完成审核中结果适配,并只读核对现有帖子的作者、版块和延迟加载图片结构。仍需公开成功页、更多明确失败提示及其他版块样本,未知结果保守暂停;当前明确拒绝分支只支持标题超限提示。不要提供 Cookie、密码或表单令牌。 ## 0.1.1 图片修复 @@ -108,3 +111,9 @@ node test/browser.test.cjs ## 0.1.2 跳过文章 保持现有控制器、SQLite 队列、连接桥和执行器的结构,新增 `skip` 浏览器接口及 `skipped` 队列状态。更新油猴中的 `browser/local-bridge.user.js`(0.1.1)和 `browser/auto-news.user.js`(0.1.2),重启本地工作台、重新配对并刷新论坛后使用。按钮位于网站左下角悬浮窗、“终止流程”上方。 + +## 0.2.0 结果核对与接续 + +更新油猴中的 `browser/auto-news.user.js` 至 0.2.0,连接脚本沿用现有 0.1.1。更新本地代码后重启服务、重新配对并刷新论坛;不会自动继续任务。新增真实 TID 与 PID 分离、链接别名防重、被动结果恢复、图文差异暂停、历史结果补核,以及记账后回首页的持久步骤。旧受理记录如只有路径数字会保留原值并标记主题身份待核对,不猜测删除 URL 后缀。 + +换号过程中插件暂停、超过 120 秒未确认、或执行后超过 10 秒插件空闲但 UID 未变化,流程会暂停;请在插件内核对并恢复,不自动重复抽号。图片 HTTP 500 的服务端根因仍未解决。 diff --git a/app.py b/app.py index 88e39f0..82b073e 100644 --- a/app.py +++ b/app.py @@ -87,6 +87,8 @@ class Controller: if route == 'stop': self.manager.stop.set() return self.store.control(route) + if route == 'reconcile': + return self.store.reconcile(body) if route == 'resolve': return self.store.resolve(body) if route == 'history': diff --git a/browser/auto-news.user.js b/browser/auto-news.user.js index 5bb18e7..d22b3c5 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.2 +// @version 0.2.0 // @match https://www.dww11.com/* // @match https://dww11.com/* // @match https://house.dww11.com/* @@ -730,6 +730,56 @@ window.DWW11Formatter = Object.freeze({ }); })(); +/* Read-only result adapter. Login state and URLs alone are never acceptance evidence. */ +(function(root,factory){ + const api=factory(); + if(typeof module==='object'&&module.exports)module.exports=api; + else root.DWW11Results=api; +})(typeof window==='undefined'?null:window,()=>{ + 'use strict'; + const clean=value=>String(value||'').replace(/\s+/g,' ').trim(); + function parameter(href,key,base){try{return new URL(href,base).searchParams.get(key);}catch{return null;}} + function aid(image){return String(image.getAttribute('aid')||image.id||'').match(/(?:attachimg_|aimg_)?(\d+)$/)?.[1]||null;} + function inspect(doc,url,snapshot={}) { + const titleNode=doc.querySelector('#thread_subject'); + if(!titleNode) { + const prompt=doc.querySelector('#messagetext, .messagetext'); + const text=clean(prompt?.textContent); + if(/^(?:抱歉[,,]\s*)?(?:您填写的)?(?:帖子)?标题.{0,40}(?:超过|超出).{0,40}(?:字符|长度|限制)/.test(text))return {kind:'rejected',error:text}; + return {kind:'waiting',prompt:text,candidate:prompt?.querySelector('a[href]')?.href||''}; + } + const region=titleNode.closest('td')||titleNode.closest('h1')?.parentElement; + if(!region)return {kind:'incomplete',error:'缺少标题状态区域'}; + const ids=new Set([...region.querySelectorAll('a[href]')].filter(a=>parameter(a.href,'mod',url)==='viewthread').map(a=>parameter(a.href,'tid',url)).filter(x=>/^\d+$/.test(x))); + if(ids.size!==1)return {kind:'incomplete',error:'详情页真实主题编号缺失或冲突'}; + const thread_id=[...ids][0],body=doc.querySelector('[id^="postmessage_"]'); + const pid=body?.id.match(/^postmessage_(\d+)$/)?.[1]; + const post=pid&&doc.querySelector('#post_'+pid); + if(!post||clean(post.querySelector('#postnum'+pid)?.textContent)!=='楼主')return {kind:'incomplete',error:'无法确认当前正文属于首帖'}; + const authors=post?[...post.querySelectorAll('.plc .pti .authi a[href], .pls .authi a[href]')]:[]; + const uids=new Set(authors.map(a=>parameter(a.href,'uid',url)).filter(x=>/^\d+$/.test(x))); + const fids=new Set([...doc.querySelectorAll('#pt a[href], .mumucms_pt a[href]')].filter(a=>parameter(a.href,'mod',url)==='forumdisplay').map(a=>parameter(a.href,'fid',url)).filter(x=>/^\d+$/.test(x))); + if(!body||!pid||uids.size!==1||fids.size!==1)return {kind:'incomplete',error:'首帖作者、正文或版块证据不足'}; + const moderation=[...region.querySelectorAll('span.xg1')].some(n=>/\(\s*审核中\s*\)|(\s*审核中\s*)/.test(n.textContent)); + // A public-page sample is not available yet. Absence of moderation is not proof of publication. + if(!moderation)return {kind:'incomplete',error:'尚无可验证的公开或审核中状态'}; + const allImages=[...body.querySelectorAll('img')]; + const images=allImages.filter(img=>aid(img)).map(img=>({aid:aid(img),src:img.getAttribute('src'),loaded:img.complete&&img.naturalWidth>0&&!/\/none\.gif(?:[?#]|$)/.test(img.src||img.getAttribute('src')||''),file:img.getAttribute('file')||img.getAttribute('zoomfile')})); + const expected=(snapshot.images||[]).map(img=>String(img.aid||img.id||'').match(/(?:attachimg_|aimg_)?(\d+)$/)?.[1]).filter(Boolean); + const issues=[]; + if(allImages.some(img=>!aid(img)))issues.push('正文存在无法关联附件的图片'); + if(expected.length!==(snapshot.images||[]).length)issues.push('提交快照存在无法识别附件编号的图片'); + if(JSON.stringify(expected)!==JSON.stringify(images.map(img=>img.aid)))issues.push('首帖正文图片引用或顺序与提交快照不一致'); + if(images.some(img=>!img.loaded))issues.push('结果页正文图片尚未加载或加载失败'); + if(snapshot.text&&clean(body.innerText||body.textContent)!==clean(snapshot.text))issues.push('结果正文与最终提交快照存在文字差异'); + const attachments=post?[...post.querySelectorAll('.pattl img, .attm img')].map(img=>({aid:aid(img),src:img.getAttribute('src')})):[]; + if(attachments.length)issues.push('结果页存在附件区图片,请核对正文位置'); + return {kind:'accepted',thread_id,first_post_pid:pid,post_url:url,title:clean(titleNode.textContent),author_uid:[...uids][0],fid:Number([...fids][0]),state:'accepted_pending_moderation', + evidence:{adapter:1,thread_id,first_post_pid:pid,title:clean(titleNode.textContent),author_uid:[...uids][0],fid:Number([...fids][0]),moderation:true,images,attachments,integrity_issues:issues}}; + } + return {inspect,clean}; +}); + /* Page-side orchestrator. This file never clicks or submits the publication form. */ (() => { 'use strict'; @@ -739,7 +789,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; + let skipBusy=false, preparation=null, stopRevision=0, resultSeenAt=0; const pending=new Map(); function rpc(route,body={}) { return new Promise((resolve,reject)=>{ @@ -756,7 +806,7 @@ window.DWW11Formatter = Object.freeze({ data.error?waiter.reject(new Error(data.error)):waiter.resolve(data.value); }); const status=message=>document.dispatchEvent(new CustomEvent('dww11:connection-status',{detail:message})); - document.addEventListener('dww11:auto-stop',()=>{halted=true;aborter?.abort(new Error('流程已终止'));}); + document.addEventListener('dww11:auto-stop',()=>{stopRevision++;halted=true;aborter?.abort(new Error('流程已终止'));}); function account() { const element=document.querySelector('#mumucms_username'); if(!element)return null; @@ -874,16 +924,41 @@ window.DWW11Formatter = Object.freeze({ const local=loadLocal(); if(local?.id===job.id&&local.snapshot&&job.state==='awaiting_user_review') { await rpc('event',{id:job.id,state:'user_submitting',epoch:run.epoch,snapshot:local.snapshot}); - job.state='user_submitting'; + job.state='user_submitting';job.snapshot=local.snapshot; } - if(job.state==='user_submitting') { - // Result selectors intentionally await user-provided HTML; URL alone is not proof. - if(!editorPage(job) || (local?.clickedAt && Date.now()-local.clickedAt>45000)) { - await rpc('event',{id:job.id,state:'submit_unknown',error:'等待结果页 HTML 适配;候选页面:'+location.href}); - await rpc('stop'); - status('已记录候选结果页;尚不能自动核对。流程已暂停,请补充结果页 HTML。'); + if(!['user_submitting','submit_unknown'].includes(job.state))return; + const snapshot=job.snapshot||local?.snapshot||{}; + const result=window.DWW11Results?.inspect(document,location.href,snapshot); + if(result?.kind==='rejected'&&job.state==='user_submitting') { + await rpc('event',{id:job.id,state:'rejected',error:result.error}); + status('网站明确拒绝本次提交,已暂停;核对并处理稿件后仍需亲手发表');return; + } + if(result?.kind==='accepted') { + if(result.author_uid!==job.account.uid||result.fid!==job.fid||result.title!==window.DWW11Results.clean(snapshot.title)) { + if(job.state==='user_submitting')await rpc('event',{id:job.id,state:'submit_unknown',error:'结果页标题、首帖作者或版块与提交任务不符'}); + await rpc('stop');status('结果不匹配,已暂停,请人工核对');return; } - }else if(job.state==='submit_unknown')status('发布结果待核对;不会重发、计数或换号。'); + resultSeenAt ||= Date.now(); + if(result.evidence.integrity_issues.some(x=>x.includes('尚未加载'))&&Date.now()-resultSeenAt<15000) { + status('已看到审核中详情,等待正文图片加载后核对');return; + } + const revision=stopRevision,epoch=run.epoch; + await rpc('accept',{id:job.id,...result}); + sessionStorage.removeItem(SLOT);active=null; + // Re-read persistent control after accounting; Stop must suppress navigation and subsequent jobs. + run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()}); + halted=!run.running; + if(!halted&&revision===stopRevision&&run.epoch===epoch)location.assign('https://www.dww11.com/'); + else status('受理结果已保存;流程已暂停,请核对后明确继续'); + return; + } + const clickedAt=local?.clickedAt||Number(job.submitted_at||0)*1000; + if(clickedAt&&Date.now()-clickedAt<45000) { + status(result?.prompt||result?.error||'等待网站提交结果,不会重复发表');return; + } + if(job.state==='user_submitting')await rpc('event',{id:job.id,state:'submit_unknown',error:result?.error||'提交结果证据不足,请打开对应帖子详情后核对'}); + if(run.running)await rpc('stop'); + status('发布结果待核对;打开对应详情页可继续核对,不会重发'); } async function switchAccount() { if(working||halted)return; @@ -891,9 +966,10 @@ window.DWW11Formatter = Object.freeze({ location.assign('https://www.dww11.com/');return; } const ext=extension(),button=document.querySelector('#dww11-extension-next'); - if(!ext.ready||!ext.idle||!button||button.disabled) {status('请安装并重新加载带联动接口的换号插件');return;} - await rpc('switch-begin'); - if(halted||!run?.running)return; + if(!ext.ready||!ext.idle||!button||button.disabled) {await rpc('stop');status('换号插件未就绪,流程已暂停,请检查插件后继续');return;} + const revision=stopRevision,epoch=run.epoch; + await rpc('switch-begin',{epoch}); + if(halted||!run?.running||revision!==stopRevision)return; button.click();status('正在换号;等待插件完成并核对新账号'); } async function tick() { @@ -913,7 +989,13 @@ window.DWW11Formatter = Object.freeze({ if(run.active && run.owner===owner) {const saved=await rpc('current');if(saved.job) {active=saved.job;await observeResult(saved.job);}} status(run.reason || '流程已暂停');return; } - const next=await rpc('next',{account:account(),extension:extension()});run=next.run; + if(run.active&&run.owner===owner) { + const saved=await rpc('current'); + if(saved.job&&['user_submitting','submit_unknown'].includes(saved.job.state)) {active=saved.job;await observeResult(saved.job);return;} + } + const revision=stopRevision,epoch=run.epoch; + const next=await rpc('next',{account:account(),extension:extension(),url:location.href});run=next.run; + if(revision!==stopRevision||run.epoch!==epoch)return; if(skipBusy)return; if(next.job)active=next.job; if(next.action==='prepare'||(next.action==='resume'&&next.job.state==='preparing'))preparation=prepare(next.job); @@ -925,6 +1007,8 @@ window.DWW11Formatter = Object.freeze({ } else if(['importing','formatting'].includes(next.job.state))await fail(new Error('导入阶段发生中断,请检查编辑器和已上传图片后处理任务')); else if(next.job.state==='needs_attention')status(next.job.error); + }else if(next.action==='home') { + if(!halted&&run.running)location.assign('https://www.dww11.com/'); }else if(next.action==='switch')await switchAccount(); else if(next.action==='switch_wait')status('等待换号结果;若插件已暂停,请人工处理后继续'); else if(next.action==='login')status('未能读取当前账号 UID,请登录并确认账号元素'); diff --git a/browser/results.js b/browser/results.js new file mode 100644 index 0000000..ce7c7bd --- /dev/null +++ b/browser/results.js @@ -0,0 +1,49 @@ +/* Read-only result adapter. Login state and URLs alone are never acceptance evidence. */ +(function(root,factory){ + const api=factory(); + if(typeof module==='object'&&module.exports)module.exports=api; + else root.DWW11Results=api; +})(typeof window==='undefined'?null:window,()=>{ + 'use strict'; + const clean=value=>String(value||'').replace(/\s+/g,' ').trim(); + function parameter(href,key,base){try{return new URL(href,base).searchParams.get(key);}catch{return null;}} + function aid(image){return String(image.getAttribute('aid')||image.id||'').match(/(?:attachimg_|aimg_)?(\d+)$/)?.[1]||null;} + function inspect(doc,url,snapshot={}) { + const titleNode=doc.querySelector('#thread_subject'); + if(!titleNode) { + const prompt=doc.querySelector('#messagetext, .messagetext'); + const text=clean(prompt?.textContent); + if(/^(?:抱歉[,,]\s*)?(?:您填写的)?(?:帖子)?标题.{0,40}(?:超过|超出).{0,40}(?:字符|长度|限制)/.test(text))return {kind:'rejected',error:text}; + return {kind:'waiting',prompt:text,candidate:prompt?.querySelector('a[href]')?.href||''}; + } + const region=titleNode.closest('td')||titleNode.closest('h1')?.parentElement; + if(!region)return {kind:'incomplete',error:'缺少标题状态区域'}; + const ids=new Set([...region.querySelectorAll('a[href]')].filter(a=>parameter(a.href,'mod',url)==='viewthread').map(a=>parameter(a.href,'tid',url)).filter(x=>/^\d+$/.test(x))); + if(ids.size!==1)return {kind:'incomplete',error:'详情页真实主题编号缺失或冲突'}; + const thread_id=[...ids][0],body=doc.querySelector('[id^="postmessage_"]'); + const pid=body?.id.match(/^postmessage_(\d+)$/)?.[1]; + const post=pid&&doc.querySelector('#post_'+pid); + if(!post||clean(post.querySelector('#postnum'+pid)?.textContent)!=='楼主')return {kind:'incomplete',error:'无法确认当前正文属于首帖'}; + const authors=post?[...post.querySelectorAll('.plc .pti .authi a[href], .pls .authi a[href]')]:[]; + const uids=new Set(authors.map(a=>parameter(a.href,'uid',url)).filter(x=>/^\d+$/.test(x))); + const fids=new Set([...doc.querySelectorAll('#pt a[href], .mumucms_pt a[href]')].filter(a=>parameter(a.href,'mod',url)==='forumdisplay').map(a=>parameter(a.href,'fid',url)).filter(x=>/^\d+$/.test(x))); + if(!body||!pid||uids.size!==1||fids.size!==1)return {kind:'incomplete',error:'首帖作者、正文或版块证据不足'}; + const moderation=[...region.querySelectorAll('span.xg1')].some(n=>/\(\s*审核中\s*\)|(\s*审核中\s*)/.test(n.textContent)); + // A public-page sample is not available yet. Absence of moderation is not proof of publication. + if(!moderation)return {kind:'incomplete',error:'尚无可验证的公开或审核中状态'}; + const allImages=[...body.querySelectorAll('img')]; + const images=allImages.filter(img=>aid(img)).map(img=>({aid:aid(img),src:img.getAttribute('src'),loaded:img.complete&&img.naturalWidth>0&&!/\/none\.gif(?:[?#]|$)/.test(img.src||img.getAttribute('src')||''),file:img.getAttribute('file')||img.getAttribute('zoomfile')})); + const expected=(snapshot.images||[]).map(img=>String(img.aid||img.id||'').match(/(?:attachimg_|aimg_)?(\d+)$/)?.[1]).filter(Boolean); + const issues=[]; + if(allImages.some(img=>!aid(img)))issues.push('正文存在无法关联附件的图片'); + if(expected.length!==(snapshot.images||[]).length)issues.push('提交快照存在无法识别附件编号的图片'); + if(JSON.stringify(expected)!==JSON.stringify(images.map(img=>img.aid)))issues.push('首帖正文图片引用或顺序与提交快照不一致'); + if(images.some(img=>!img.loaded))issues.push('结果页正文图片尚未加载或加载失败'); + if(snapshot.text&&clean(body.innerText||body.textContent)!==clean(snapshot.text))issues.push('结果正文与最终提交快照存在文字差异'); + const attachments=post?[...post.querySelectorAll('.pattl img, .attm img')].map(img=>({aid:aid(img),src:img.getAttribute('src')})):[]; + if(attachments.length)issues.push('结果页存在附件区图片,请核对正文位置'); + return {kind:'accepted',thread_id,first_post_pid:pid,post_url:url,title:clean(titleNode.textContent),author_uid:[...uids][0],fid:Number([...fids][0]),state:'accepted_pending_moderation', + evidence:{adapter:1,thread_id,first_post_pid:pid,title:clean(titleNode.textContent),author_uid:[...uids][0],fid:Number([...fids][0]),moderation:true,images,attachments,integrity_issues:issues}}; + } + return {inspect,clean}; +}); diff --git a/browser/runner.js b/browser/runner.js index d7dc73b..6a7a98b 100644 --- a/browser/runner.js +++ b/browser/runner.js @@ -7,7 +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; + let skipBusy=false, preparation=null, stopRevision=0, resultSeenAt=0; const pending=new Map(); function rpc(route,body={}) { return new Promise((resolve,reject)=>{ @@ -24,7 +24,7 @@ data.error?waiter.reject(new Error(data.error)):waiter.resolve(data.value); }); const status=message=>document.dispatchEvent(new CustomEvent('dww11:connection-status',{detail:message})); - document.addEventListener('dww11:auto-stop',()=>{halted=true;aborter?.abort(new Error('流程已终止'));}); + document.addEventListener('dww11:auto-stop',()=>{stopRevision++;halted=true;aborter?.abort(new Error('流程已终止'));}); function account() { const element=document.querySelector('#mumucms_username'); if(!element)return null; @@ -142,16 +142,41 @@ const local=loadLocal(); if(local?.id===job.id&&local.snapshot&&job.state==='awaiting_user_review') { await rpc('event',{id:job.id,state:'user_submitting',epoch:run.epoch,snapshot:local.snapshot}); - job.state='user_submitting'; + job.state='user_submitting';job.snapshot=local.snapshot; } - if(job.state==='user_submitting') { - // Result selectors intentionally await user-provided HTML; URL alone is not proof. - if(!editorPage(job) || (local?.clickedAt && Date.now()-local.clickedAt>45000)) { - await rpc('event',{id:job.id,state:'submit_unknown',error:'等待结果页 HTML 适配;候选页面:'+location.href}); - await rpc('stop'); - status('已记录候选结果页;尚不能自动核对。流程已暂停,请补充结果页 HTML。'); + if(!['user_submitting','submit_unknown'].includes(job.state))return; + const snapshot=job.snapshot||local?.snapshot||{}; + const result=window.DWW11Results?.inspect(document,location.href,snapshot); + if(result?.kind==='rejected'&&job.state==='user_submitting') { + await rpc('event',{id:job.id,state:'rejected',error:result.error}); + status('网站明确拒绝本次提交,已暂停;核对并处理稿件后仍需亲手发表');return; + } + if(result?.kind==='accepted') { + if(result.author_uid!==job.account.uid||result.fid!==job.fid||result.title!==window.DWW11Results.clean(snapshot.title)) { + if(job.state==='user_submitting')await rpc('event',{id:job.id,state:'submit_unknown',error:'结果页标题、首帖作者或版块与提交任务不符'}); + await rpc('stop');status('结果不匹配,已暂停,请人工核对');return; } - }else if(job.state==='submit_unknown')status('发布结果待核对;不会重发、计数或换号。'); + resultSeenAt ||= Date.now(); + if(result.evidence.integrity_issues.some(x=>x.includes('尚未加载'))&&Date.now()-resultSeenAt<15000) { + status('已看到审核中详情,等待正文图片加载后核对');return; + } + const revision=stopRevision,epoch=run.epoch; + await rpc('accept',{id:job.id,...result}); + sessionStorage.removeItem(SLOT);active=null; + // Re-read persistent control after accounting; Stop must suppress navigation and subsequent jobs. + run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()}); + halted=!run.running; + if(!halted&&revision===stopRevision&&run.epoch===epoch)location.assign('https://www.dww11.com/'); + else status('受理结果已保存;流程已暂停,请核对后明确继续'); + return; + } + const clickedAt=local?.clickedAt||Number(job.submitted_at||0)*1000; + if(clickedAt&&Date.now()-clickedAt<45000) { + status(result?.prompt||result?.error||'等待网站提交结果,不会重复发表');return; + } + if(job.state==='user_submitting')await rpc('event',{id:job.id,state:'submit_unknown',error:result?.error||'提交结果证据不足,请打开对应帖子详情后核对'}); + if(run.running)await rpc('stop'); + status('发布结果待核对;打开对应详情页可继续核对,不会重发'); } async function switchAccount() { if(working||halted)return; @@ -159,9 +184,10 @@ location.assign('https://www.dww11.com/');return; } const ext=extension(),button=document.querySelector('#dww11-extension-next'); - if(!ext.ready||!ext.idle||!button||button.disabled) {status('请安装并重新加载带联动接口的换号插件');return;} - await rpc('switch-begin'); - if(halted||!run?.running)return; + if(!ext.ready||!ext.idle||!button||button.disabled) {await rpc('stop');status('换号插件未就绪,流程已暂停,请检查插件后继续');return;} + const revision=stopRevision,epoch=run.epoch; + await rpc('switch-begin',{epoch}); + if(halted||!run?.running||revision!==stopRevision)return; button.click();status('正在换号;等待插件完成并核对新账号'); } async function tick() { @@ -181,7 +207,13 @@ if(run.active && run.owner===owner) {const saved=await rpc('current');if(saved.job) {active=saved.job;await observeResult(saved.job);}} status(run.reason || '流程已暂停');return; } - const next=await rpc('next',{account:account(),extension:extension()});run=next.run; + if(run.active&&run.owner===owner) { + const saved=await rpc('current'); + if(saved.job&&['user_submitting','submit_unknown'].includes(saved.job.state)) {active=saved.job;await observeResult(saved.job);return;} + } + const revision=stopRevision,epoch=run.epoch; + const next=await rpc('next',{account:account(),extension:extension(),url:location.href});run=next.run; + if(revision!==stopRevision||run.epoch!==epoch)return; if(skipBusy)return; if(next.job)active=next.job; if(next.action==='prepare'||(next.action==='resume'&&next.job.state==='preparing'))preparation=prepare(next.job); @@ -193,6 +225,8 @@ } else if(['importing','formatting'].includes(next.job.state))await fail(new Error('导入阶段发生中断,请检查编辑器和已上传图片后处理任务')); else if(next.job.state==='needs_attention')status(next.job.error); + }else if(next.action==='home') { + if(!halted&&run.running)location.assign('https://www.dww11.com/'); }else if(next.action==='switch')await switchAccount(); else if(next.action==='switch_wait')status('等待换号结果;若插件已暂停,请人工处理后继续'); else if(next.action==='login')status('未能读取当前账号 UID,请登录并确认账号元素'); diff --git a/build_browser.py b/build_browser.py index 570a44d..d4ef2fe 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.2 +// @version 0.2.0 // @match https://www.dww11.com/* // @match https://dww11.com/* // @match https://house.dww11.com/* @@ -14,7 +14,7 @@ header = '''// ==UserScript== // ==/UserScript== ''' parts = [] -for name in ('importer.js', 'formatter.js', 'runner.js'): +for name in ('importer.js', 'formatter.js', 'results.js', 'runner.js'): source = (ROOT / 'browser' / name).read_text() if '// ==/UserScript==' in source: source = source.split('// ==/UserScript==', 1)[1] diff --git a/static/app.js b/static/app.js index fa970b3..74923f8 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:'已移出',skipped:'已跳过'}; +const labels={queued:'待处理',preparing:'准备中',importing:'上传与导入',formatting:'排版中',awaiting_user_review:'等待人工审核',user_submitting:'已观察到人工提交',submit_unknown:'发布结果待核对',accepted_pending_moderation:'已受理 · 审核中',published:'已确认公开',needs_attention:'需要处理',cancelled:'已移出',skipped:'已跳过',rejected:'网站明确拒绝'}; 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)}); @@ -19,8 +19,24 @@ function render(data){ $('jobs').replaceChildren(...jobs.map(job=>{ const tr=document.createElement('tr'),title=document.createElement('td');title.append(text('div',job.title),text('small',`${job.image_count} 张配图 · ${job.source}`));if(job.original_title!==job.title)title.append(text('small','已缩短标题,原题:'+job.original_title)); const status=document.createElement('td');status.append(text('span',labels[job.state]||job.state,'badge'));if(job.error)status.append(text('small',job.error)); + for(const issue of job.integrity_issues||[])status.append(text('small',issue)); const account=text('td',job.account?`${job.account.name} / ${job.fid}`:'待领取');const result=document.createElement('td'); if(job.post_url){const link=text('a','查看帖子');link.href=job.post_url;link.target='_blank';link.rel='noopener';result.append(link);} + if(['user_submitting','submit_unknown'].includes(job.state)) { + const input=document.createElement('input');input.type='file';input.accept='.json,application/json';input.hidden=true; + const reconcile=text('button','补核已受理结果');reconcile.className='secondary';reconcile.onclick=()=>input.click(); + input.onchange=async()=>{ + try { + if(state.run.running)throw new Error('请先暂停流程'); + const file=input.files[0];if(!file)return;if(file.size>2*1024*1024)throw new Error('记录文件过大'); + const record=JSON.parse(await file.text()); + if(!confirm(`确认该观察记录属于“${job.title}”?补核会阻止重发,不增加当前批次计数。`))return; + await api('reconcile',{id:job.id,article_key:job.article_key,confirmed:true,thread_id:String(record.threadId||record.thread_id||''),first_post_pid:String(record.postId||record.first_post_pid||''),post_url:record.url||record.post_url,title:record.title,author_uid:String(record.uid||record.author_uid||''),fid:Number(record.fid),state:record.status||record.state,evidence:{thread_id:String(record.threadId||record.thread_id||''),prompt:record.observedPrompt||'',historical:true,integrity_issues:['历史补核尚未完成发布后图文完整性检查']}}); + notice('历史受理已登记,未增加当前批次计数');await refresh(); + }catch(error){notice(error.message);}finally{input.value='';} + }; + result.append(reconcile,input); + } if(!['published','accepted_pending_moderation'].includes(job.state)){ const retry=text('button','人工核对 / 处理');retry.className='secondary';retry.onclick=async()=>{ if(state.run.running){notice('请先暂停流程,再处理任务');return;} diff --git a/static/index.html b/static/index.html index ce25dae..9c6d6bb 100644 --- a/static/index.html +++ b/static/index.html @@ -7,7 +7,7 @@
CONTENT WORKSPACE

把内容准备好,把审核留给你。

下载、导入、排版与账号切换,在一个工作台管理。

01下载新闻
→
02导入与排版
→
03人工审核与发表
→
04结果核对与换号
-
当前接入进度 下载、导入、排版与换号接口已整合;结果页自动核对等待页面 HTML。人工发表后会暂停,不自动计数或继续下一篇。
+
当前接入进度 已接通审核中结果核对、受理记账与后续调度。图文差异或结果证据不足时暂停;公开状态仍需独立证据。
队列文章0完整文件检查后入队
等待你审核0请在网站检查并亲手发表
明确受理0审核中与已公开分别记录
当前账号批次—尚未绑定账号
SOURCE

下载与文章

等待开始
@@ -21,6 +21,6 @@
正在连接本地服务…

等待状态

ARTICLE QUEUE

文章队列

文章状态账号 / 版块结果与操作
+从第一篇文章开始

下载新闻,或检查已有文章目录。准备好的文章会出现在这里。

CONNECTION

连接你的浏览器

尚未连接
  1. 在油猴中安装以下两个脚本,并停用原导入、排版脚本。
  2. 重新加载原目录中的换号插件,然后刷新网站。
  3. 在网站左下角展开连接设置,填入本地端口和连接码。
服务重启后需要更新连接码。仅复制到你安装的本地连接脚本。

没有在线执行标签

-
下载日志
暂无日志
新闻工作台 · 本机运行 · v0.1   /   终止后不会自动恢复,已发出的请求可能仍已完成。
+
下载日志
暂无日志
新闻工作台 · 本机运行 · v0.2.0   /   终止后不会自动恢复,已发出的请求可能仍已完成。
diff --git a/store.py b/store.py index 18da1bd..fa9b634 100644 --- a/store.py +++ b/store.py @@ -1,12 +1,14 @@ """Persistent serial queue. Browser submissions are always initiated by the user.""" import json +import copy +import re from contextlib import contextmanager from pathlib import Path import secrets import sqlite3 import threading import time -from urllib.parse import urlsplit +from urllib.parse import urlsplit, parse_qs from articles import inspect_article @@ -25,10 +27,21 @@ class Store: CREATE TABLE IF NOT EXISTS ledger(key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS settings(id INTEGER PRIMARY KEY CHECK(id=1), value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS posts(id TEXT PRIMARY KEY, job TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS post_aliases(url TEXT PRIMARY KEY, thread_id TEXT NOT NULL, job TEXT NOT NULL); ''') row = db.execute('SELECT value FROM settings WHERE id=1').fetchone() self.run = json.loads(row[0]) if row else dict(running=False, epoch=0, config={}, batch=None, active=None, owner=None, switch=None, reason='请配置任务后开始') self.run.update(running=False, epoch=self.run['epoch'] + 1, reason='服务已启动,请明确点击开始/继续;未决任务不会重发') + for row in db.execute('SELECT value FROM jobs').fetchall(): + job = json.loads(row[0]) + if job['state'] in ACCEPTED and job.get('post_url'): + canonical = job.get('thread_id') + if not canonical: + job['legacy_url_id'] = job.get('post_id') + job['identity_needs_reconciliation'] = True + self._save(db, job) + db.execute('INSERT OR IGNORE INTO post_aliases VALUES(?,?,?)', + (job['post_url'], canonical or 'unknown', job['id'])) self._persist(db) self.clients = {} if ledger and Path(ledger).is_file(): @@ -37,9 +50,14 @@ class Store: @contextmanager def db(self): connection = sqlite3.connect(self.path, timeout=20) + previous = copy.deepcopy(getattr(self, 'run', None)) try: with connection: yield connection + except Exception: + if previous is not None: + self.run = previous + raise finally: connection.close() @@ -151,6 +169,12 @@ class Store: self.run.update(batch=self._batch(account), switch=None) self._persist(db) else: + ext = data.get('extension', {}) + elapsed = time.time() - switch.get('clicked_at', switch['created_at']) + if ext.get('paused') or (switch['state'] == 'clicked' and elapsed > 10 and ext.get('idle')) or elapsed > 120: + self.run.update(running=False, reason='换号已暂停、超时或账号未改变,请检查插件并人工恢复,禁止重复抽号') + self._persist(db) + return dict(action='idle', run=self.run) return dict(action='switch' if switch['state'] == 'pending' else 'switch_wait', run=self.run) if self.run['batch'] is None: self.run['batch'] = self._batch(account) @@ -158,6 +182,13 @@ class Store: self.run.update(running=False, reason='检测到账号变化,请先核对当前任务') self._persist(db) return dict(action='idle', run=self.run) + if self.run.get('return_home'): + url = urlsplit(str(data.get('url', ''))) + if url.scheme == 'https' and url.netloc in ('www.dww11.com', 'dww11.com') and url.path == '/' and not url.query: + self.run['return_home'] = False + self._persist(db) + else: + return dict(action='home', run=self.run) if self.run['active']: job = self._job(db, self.run['active']) return dict(action='resume', job=job, run=self.run) @@ -207,7 +238,9 @@ class Store: self._check_owner(data['owner']) if not self.run['running'] or not self.run['switch'] or self.run['switch']['state'] != 'pending': raise ValueError('换号状态已改变,禁止重复点击') - self.run['switch']['state'] = 'clicked' + if data.get('epoch', self.run['epoch']) != self.run['epoch']: + raise ValueError('换号请求已过期') + self.run['switch'].update(state='clicked', clicked_at=time.time()) self._persist(db) return {'ok': True} @@ -234,7 +267,7 @@ class Store: transitions = {'preparing': {'importing', 'needs_attention'}, 'importing': {'formatting', 'needs_attention'}, 'formatting': {'awaiting_user_review', 'needs_attention'}, 'awaiting_user_review': {'user_submitting', 'needs_attention'}, - 'user_submitting': {'submit_unknown', 'needs_attention'}, 'submit_unknown': {'needs_attention'}} + 'user_submitting': {'submit_unknown', 'needs_attention', 'rejected'}, 'submit_unknown': {'needs_attention'}} if target not in transitions.get(job['state'], set()): raise ValueError('任务状态不允许此操作:' + job['state'] + ' → ' + target) if target in ('importing', 'formatting', 'awaiting_user_review') and (not self.run['running'] or data.get('epoch') != self.run['epoch']): @@ -244,13 +277,27 @@ class Store: job.update(submitted_at=time.time(), snapshot=data.get('snapshot', {}), attempt=secrets.token_hex(8)) if target == 'awaiting_user_review': job['prepared'] = data.get('snapshot', {}) - if target == 'needs_attention': + if target in ('needs_attention', 'rejected'): self.run.update(running=False, reason=job['error'] or '当前任务需要处理') self._persist(db) self._save(db, job) return {'ok': True} + def reconcile(self, data): + if data.get('confirmed') is not True: + raise ValueError('请先确认这是当前文章的已受理结果') + with self.lock, self.db() as db: + if self.run['running']: + raise ValueError('请先暂停流程再补核历史受理记录') + job = self._job(db, data['id']) + if data.get('article_key') != job['article_key']: + raise ValueError('文章身份不匹配') + return self._accept({**data, 'owner': self.run['owner']}, count_batch=False) + def accept(self, data): + return self._accept(data) + + def _accept(self, data, count_batch=True): with self.lock, self.db() as db: self._check_owner(data['owner']) job = self._job(db, data['id']) @@ -259,32 +306,46 @@ class Store: if self.run['active'] != job['id'] or job['state'] not in SUBMITTED: raise ValueError('只有已观察到人工提交的任务才能登记结果') url = urlsplit(str(data.get('post_url', ''))) - import re match = re.fullmatch(r'/thread/(\d+)\.html', url.path) - if url.scheme != 'https' or url.netloc not in ('www.dww11.com', 'dww11.com') or not match: + query = parse_qs(url.query) + thread_id = str(data.get('thread_id', '')) + if not re.fullmatch(r'[1-9]\d*', thread_id): + raise ValueError('缺少详情页真实主题 ID,不能以地址数字代替') + if url.scheme != 'https' or url.netloc not in ('www.dww11.com', 'dww11.com') or not (match or (url.path == '/forum.php' and query.get('mod') == ['viewthread'] and query.get('tid') == [thread_id])): raise ValueError('未识别到帖子详情页') if data.get('author_uid') != job['account']['uid'] or data.get('fid') != job['fid']: raise ValueError('结果页作者或版块与任务不符,请人工核对') - if data.get('title') != job.get('snapshot', {}).get('title'): + if ' '.join(str(data.get('title', '')).split()) != ' '.join(str(job.get('snapshot', {}).get('title', '')).split()): raise ValueError('结果页标题与人工提交快照不一致') if data.get('state') not in ACCEPTED: raise ValueError('需要明确的公开或审核中状态') if not data.get('evidence'): raise ValueError('缺少页面结果证据') - post_id = match[1] + evidence = data['evidence'] + if not isinstance(evidence, dict) or str(evidence.get('thread_id')) != thread_id: + raise ValueError('主题 ID 与页面证据不一致') + alias = db.execute('SELECT thread_id, job FROM post_aliases WHERE url=?', (url.geturl(),)).fetchone() + if alias and (alias[0] != thread_id or alias[1] != job['id']): + raise ValueError('帖子链接已绑定其他记录') + post_id = thread_id existing = db.execute('SELECT job FROM posts WHERE id=?', (post_id,)).fetchone() if existing and existing[0] != job['id']: raise ValueError('帖子已绑定其他任务,不能重复计数') db.execute('INSERT OR IGNORE INTO posts VALUES(?,?)', (post_id, job['id'])) - job.update(state=data['state'], post_id=post_id, post_url=url.geturl(), result=data['evidence']) + db.execute('INSERT OR IGNORE INTO post_aliases VALUES(?,?,?)', (url.geturl(), thread_id, job['id'])) + job.update(state=data['state'], thread_id=thread_id, post_id=post_id, first_post_pid=data.get('first_post_pid'), post_url=url.geturl(), result=evidence, integrity_issues=evidence.get('integrity_issues', [])) keys = ['article:' + job['article_key'], 'md:' + job['markdown_sha256']] + ['source:' + str(k) for k in job['source_keys']] for key in keys: db.execute('INSERT OR REPLACE INTO ledger VALUES(?,?)', (key, json.dumps(job, ensure_ascii=False))) - self.run['batch']['count'] += 1 - self.run.update(active=None, not_before=time.time() + self.run['config']['interval']) + if count_batch: + self.run['batch']['count'] += 1 + job['historical_reconciliation'] = not count_batch + self.run.update(active=None, return_home=True, not_before=time.time() + self.run['config']['interval']) + if job['integrity_issues']: + self.run.update(running=False, reason='网站已受理,内容核对发现差异,请检查帖子后明确继续') self._save(db, job) self._persist(db) - return {'ok': True} + return {'ok': True, 'run': dict(self.run)} def resolve(self, data): """Explicit local UI resolution, never an automatic retry of an unknown submission.""" @@ -297,6 +358,8 @@ class Store: if data.get('confirmed_not_accepted') is not True: raise ValueError('必须先人工核对网站未受理,并清理编辑器/草稿') job.update(state='queued' if data.get('retry') else 'cancelled', error='', snapshot={}) + for key in ('submitted_at', 'attempt', 'prepared'): + job.pop(key, None) if self.run['active'] == job['id']: self.run.update(active=None, owner=None) self._save(db, job) diff --git a/test/README.md b/test/README.md index cac320e..d0e8b34 100644 --- a/test/README.md +++ b/test/README.md @@ -11,3 +11,7 @@ - `artifacts/`:本轮页面截图,属于生成产物。 原始受理去重记录继续保存在 `../发布去重记录.json`,服务启动时自动导入;没有作为测试残留移走或删除。 + +- `results-browser.test.cjs`:0.2.0 结果适配回归,使用临时 Chrome 与模拟真实结构;阻断全部网络,覆盖真实 TID/PID、首帖作者范围、标题审核标志、图片占位及明确标题拒绝。需要 Playwright。 +- 新增 Python 回归覆盖补核 HTTP 鉴权、历史受理不计当前批次、返回首页步骤、旧标识保留、事务回滚、停止后的被动记账与换号未改变 UID。 +- 建议使用 Node.js 24 LTS;本机旧 Node 20 的测试报告解析中文名称异常,使用已安装的 Node 24 完成验证。 diff --git a/test/browser.test.cjs b/test/browser.test.cjs index feecb48..ed9da48 100644 --- a/test/browser.test.cjs +++ b/test/browser.test.cjs @@ -49,6 +49,7 @@ const output=path.join(__dirname,'artifacts');fs.mkdirSync(output,{recursive:tru 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/results.js')}); await mock.addScriptTag({path:path.join(ROOT,'browser/runner.js')}); await mock.waitForFunction(()=>window.job.state==='awaiting_user_review'); await mock.waitForTimeout(2400); @@ -60,7 +61,7 @@ const output=path.join(__dirname,'artifacts');fs.mkdirSync(output,{recursive:tru assert.equal(after,before,'repeated formatting should be idempotent'); await mock.locator('#postsubmit').click(); await mock.waitForFunction(()=>window.job.state==='user_submitting'); - await mock.evaluate(()=>history.pushState({},'', '/thread/999.html')); + await mock.evaluate(()=>{history.pushState({},'', '/thread/999.html');const saved=JSON.parse(sessionStorage.getItem('auto-news-current'));saved.clickedAt-=60000;sessionStorage.setItem('auto-news-current',JSON.stringify(saved));}); await mock.waitForFunction(()=>window.job.state==='submit_unknown'); assert.equal(await mock.evaluate(()=>window.flow.running),false); assert.equal(await mock.evaluate(()=>window.events.some(e=>e.route==='accept')),false,'unknown page must not count accepted'); diff --git a/test/results-browser.test.cjs b/test/results-browser.test.cjs new file mode 100644 index 0000000..619a5df --- /dev/null +++ b/test/results-browser.test.cjs @@ -0,0 +1,41 @@ +'use strict'; +// Separate temporary browser; all network requests blocked. No user profile or forum access. +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const {chromium}=require('playwright'); +const path=require('node:path'); +const gif='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; +function html({moderation=true,author=true,duplicateTid=false,lazy=false}={}) { + return ` +

原创标题

+ ${moderation?'(审核中)':''}复制链接 + ${duplicateTid?'冲突链接':''}
+ 登录者 +
楼主
${author?'首帖作者':''}
+
原创正文
+
回帖(审核中)
`; +} +test('结果页适配:真实主题、首帖范围、状态、图文与延迟加载',async()=>{ + const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); + try { + const page=await browser.newPage();await page.route('**/*',r=>r.abort()); + async function inspect(options={},snapshot={title:'原创标题',text:'原创正文',images:[{aid:'attachimg_42'}]}) { + await page.setContent(html(options));await page.addScriptTag({path:path.resolve(__dirname,'../browser/results.js')}); + return page.evaluate(snapshot=>window.DWW11Results.inspect(document,'https://www.dww11.com/thread/23699911.html',snapshot),snapshot); + } + const accepted=await inspect();assert.equal(accepted.kind,'accepted');assert.equal(accepted.thread_id,'236999');assert.equal(accepted.first_post_pid,'238791');assert.equal(accepted.author_uid,'123');assert.equal(accepted.fid,90);assert.deepEqual(accepted.evidence.integrity_issues,[]); + assert.equal((await inspect({author:false})).kind,'incomplete','top login/replies cannot supply the author'); + assert.equal((await inspect({moderation:false})).kind,'incomplete','absence of moderation is not public proof'); + assert.equal((await inspect({duplicateTid:true})).kind,'incomplete'); + const lazy=await inspect({lazy:true});assert.equal(lazy.evidence.images[0].loaded,false); + assert.ok(lazy.evidence.integrity_issues.length); + const changed=await inspect({}, {text:'被修改的正文',images:[{aid:'attachimg_99'}]});assert.equal(changed.evidence.integrity_issues.length,2); + await page.setContent('
新主题需要审核,您的帖子通过审核后才能显示继续
'); + await page.addScriptTag({path:path.resolve(__dirname,'../browser/results.js')}); + const prompt=await page.evaluate(()=>window.DWW11Results.inspect(document,'https://www.dww11.com/forum.php?topicsubmit=yes')); + assert.equal(prompt.kind,'waiting');assert.ok(prompt.prompt.includes('需要审核')); + await page.setContent('
抱歉,您填写的标题超过 80 个字符限制
'); + await page.addScriptTag({path:path.resolve(__dirname,'../browser/results.js')}); + assert.equal((await page.evaluate(()=>window.DWW11Results.inspect(document,'https://www.dww11.com/forum.php'))).kind,'rejected'); + }finally{await browser.close();} +}); diff --git a/test/runner.test.cjs b/test/runner.test.cjs index c5a14fe..c7c951b 100644 --- a/test/runner.test.cjs +++ b/test/runner.test.cjs @@ -6,7 +6,7 @@ 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}={}) { +function fixture({holdImport=false,holdHeartbeat=false,rejectSkip=false,result=null,stopOnAccept=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'}; @@ -38,6 +38,8 @@ function fixture({holdImport=false,holdHeartbeat=false,rejectSkip=false}={}) { if(rejectSkip){reply(id,null,'状态已改变');return;} job.state='skipped';run.active=null;run.epoch++;value={ok:true,fid:125,run}; } + if(route==='current')value={job,run}; + if(route==='accept'){job.state=body.state;run.active=null;value={ok:true};if(stopOnAccept)run.running=false;} if(route==='stop')run.running=false; reply(id,value); }); @@ -47,7 +49,7 @@ function fixture({holdImport=false,holdHeartbeat=false,rejectSkip=false}={}) { 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})=>{ + context.window={DWW11Results:result?{inspect:()=>result,clean:v=>String(v||'').trim()}:null,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(){}}}; @@ -104,3 +106,28 @@ test('跳过请求失败时暂停且保留当前稿件',async()=>{ assert.equal(f.storage.has('auto-news-current'),true); }finally{f.close();} }); + +const acceptedResult={kind:'accepted',thread_id:'236999',first_post_pid:'238791',author_uid:'123',fid:125,title:'标题',state:'accepted_pending_moderation',post_url:'https://www.dww11.com/thread/23699911.html',evidence:{thread_id:'236999',integrity_issues:[]}}; +for(const stopOnAccept of [false,true])test(`受理后先持久记账;停止=${stopOnAccept} 时禁止回首页`,async()=>{ + const f=fixture({result:acceptedResult,stopOnAccept});try{ + await flush();f.doc.dispatchEvent(new f.Event('click',{isTrusted:true,target:{closest:()=>true}}));await flush(); + await f.tick();await flush(); + assert.equal(f.calls.filter(x=>x.route==='accept').length,1); + assert.equal(f.calls.find(x=>x.route==='accept').body.thread_id,'236999'); + assert.deepEqual(f.navigations,stopOnAccept?[]:['https://www.dww11.com/']); + assert.equal(f.storage.has('auto-news-current'),false); + }finally{f.close();} +}); +test('结果首帖作者不匹配时暂停且不记账',async()=>{ + const f=fixture({result:{...acceptedResult,author_uid:'999'}});try{ + await flush();f.doc.dispatchEvent(new f.Event('click',{isTrusted:true,target:{closest:()=>true}}));await flush();await f.tick(); + assert.equal(f.calls.some(x=>x.route==='accept'),false);assert.equal(f.run.running,false);assert.deepEqual(f.navigations,[]); + }finally{f.close();} +}); + +test('明确标题拒绝只记录并暂停,不修稿或重试发表',async()=>{ + const f=fixture({result:{kind:'rejected',error:'标题超过80字符'}});try{ + await flush();f.doc.dispatchEvent(new f.Event('click',{isTrusted:true,target:{closest:()=>true}}));await flush();await f.tick(); + assert.equal(f.job.state,'rejected');assert.equal(f.calls.some(x=>x.route==='accept'),false);assert.deepEqual(f.navigations,[]); + }finally{f.close();} +}); diff --git a/test/test_controller.py b/test/test_controller.py index 7457e9e..a0db856 100644 --- a/test/test_controller.py +++ b/test/test_controller.py @@ -84,5 +84,24 @@ class ControllerTests(unittest.TestCase): self.assertIsNone(store.run['active']) + def test_ui_historical_result_reconciliation(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'] + for state in ('importing','formatting','awaiting_user_review','user_submitting'): + store.event(dict(owner='tab-a',id=job['id'],state=state,epoch=store.run['epoch'],snapshot={'title':job['title']})) + store.control('stop') + data=dict(id=job['id'],article_key=job['article_key'],confirmed=True,thread_id='236999',first_post_pid='238791', + post_url='https://www.dww11.com/thread/23699911.html',title=job['title'],author_uid='123',fid=125, + state='accepted_pending_moderation',evidence={'thread_id':'236999'}) + with self.assertRaises(HTTPError): + self.request('/api/reconcile',data,token=self.controller.bridge_token) + self.assertEqual(self.request('/api/reconcile',data,token=self.controller.ui_token)[0],200) + self.assertEqual(store.run['batch']['count'],0) + self.assertFalse(store.run['running']) + + if __name__ == '__main__': unittest.main() diff --git a/test/test_queue.py b/test/test_queue.py index 1239c24..1e6f64e 100644 --- a/test/test_queue.py +++ b/test/test_queue.py @@ -22,7 +22,7 @@ class QueueTests(unittest.TestCase): self.md.write_text('# 测试新闻\n\n正文内容。\n\n来源:测试来源,如有侵权,请联系删除。\n') self.store = Store(self.root / 'queue.sqlite3') self.store.configure(dict(fid=125, minimum=1, maximum=1, interval=0, history_checked=True)) - self.owner = dict(owner='tab-a', account=dict(uid='123', name='测试账号'), extension={'idle': True}) + self.owner = dict(owner='tab-a', account=dict(uid='123', name='测试账号'), extension={'idle': True}, url='https://www.dww11.com/') def tearDown(self): self.tmp.cleanup() @@ -42,7 +42,7 @@ class QueueTests(unittest.TestCase): def accept(self, job): return self.store.accept(dict(owner='tab-a', id=job['id'], post_url='https://www.dww11.com/thread/12345.html', - author_uid='123', fid=125, title=job['title'], state='accepted_pending_moderation', evidence={'test': 'fixture only'})) + thread_id='123', first_post_pid='456', author_uid='123', fid=125, title=job['title'], state='accepted_pending_moderation', evidence={'thread_id': '123', 'test': 'fixture only'})) def test_stable_identity_and_original_preserved(self): before = self.md.read_bytes() @@ -201,6 +201,83 @@ class QueueTests(unittest.TestCase): 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_real_tid_and_return_home_gate(self): + job = self.begin(); self.submit(job); self.accept(job) + saved = self.store.state()['jobs'][0] + self.assertEqual(saved['thread_id'], '123') + self.assertEqual(saved['first_post_pid'], '456') + self.assertEqual(self.store.next({**self.owner, 'url': saved['post_url']})['action'], 'home') + self.assertEqual(self.store.next(self.owner)['action'], 'idle') + + def test_integrity_issue_records_acceptance_but_stops(self): + job = self.begin(); self.submit(job) + self.store.accept(dict(owner='tab-a', id=job['id'], post_url='https://www.dww11.com/thread/12311.html', + thread_id='123', author_uid='123', fid=125, title=job['title'], state='accepted_pending_moderation', + evidence={'thread_id':'123','integrity_issues':['正文图片位置改变']})) + self.assertFalse(self.store.run['running']) + self.assertEqual(self.store.run['batch']['count'], 1) + self.assertIn('已受理', self.store.enqueue(self.article)) + + def test_historical_reconciliation_does_not_count_or_resume(self): + job = self.begin(); self.submit(job); self.store.control('stop') + data=dict(id=job['id'], article_key=job['article_key'], confirmed=True, + post_url='https://www.dww11.com/thread/12311.html',thread_id='123',author_uid='123',fid=125, + title=job['title'],state='accepted_pending_moderation',evidence={'thread_id':'123'}) + self.store.reconcile(data); self.store.reconcile(data) + self.assertFalse(self.store.run['running']) + self.assertEqual(self.store.run['batch']['count'], 0) + self.assertIn('已受理', self.store.enqueue(self.article)) + + def test_transaction_rolls_back_in_memory_run(self): + import sqlite3 + self.begin() + before = json.loads(json.dumps(self.store.run)) + with self.assertRaises(sqlite3.IntegrityError): + with self.store.db() as db: + self.store.run['batch']['count'] = 999 + db.execute("INSERT INTO posts VALUES(NULL,NULL)") + self.assertEqual(self.store.run, before) + + def test_accept_after_stop_only_records_no_resumption(self): + job = self.begin(); self.submit(job); self.store.control('stop'); self.accept(job) + self.assertFalse(self.store.run['running']) + self.assertEqual(self.store.run['batch']['count'], 1) + self.assertEqual(self.store.next(self.owner)['action'], 'idle') + + def test_switch_same_uid_and_idle_stops_without_reclick(self): + import time + job = self.begin(); self.submit(job); self.accept(job) + other = self.root / 'other'; shutil.copytree(self.article, other) + (other / 'a.md').write_text(self.md.read_text().replace('正文内容', '其他内容')) + self.store.enqueue(other) + self.assertEqual(self.store.next(self.owner)['action'], 'switch') + self.store.switch_begin({'owner':'tab-a'}) + self.store.run['switch']['clicked_at'] = time.time() - 11 + self.assertEqual(self.store.next(self.owner)['action'], 'idle') + self.assertFalse(self.store.run['running']) + self.assertEqual(self.store.run['switch']['state'], 'clicked') + + def test_rejected_submission_requires_manual_resolution(self): + job = self.begin(); self.submit(job) + self.transition(job, 'rejected', error='标题超限') + self.assertFalse(self.store.run['running']) + self.assertEqual(self.store.run['batch']['count'], 0) + self.store.resolve(dict(id=job['id'],confirmed_not_accepted=True,retry=True)) + saved = self.store.state()['jobs'][0] + self.assertNotIn('submitted_at', saved) + self.assertEqual(saved['state'], 'queued') + + def test_legacy_identity_is_preserved_without_guessing_tid(self): + job = self.begin(); self.submit(job); self.accept(job) + with self.store.db() as db: + saved = self.store._job(db,job['id']); saved.pop('thread_id') + self.store._save(db,saved) + restarted = Store(self.root/'queue.sqlite3') + saved = restarted.state()['jobs'][0] + self.assertEqual(saved['legacy_url_id'],'123') + self.assertTrue(saved['identity_needs_reconciliation']) + self.assertFalse(restarted.run['running']) + 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 b89c806..db73470 100644 --- a/test/验证记录.md +++ b/test/验证记录.md @@ -59,3 +59,13 @@ 已保存详情页关键结构与审核状态截图;提示页文字和跳转链接已捕捉,跳转前的容器 HTML 未保存成功。文件名按访问地址命名,不能将 `23699911` 直接当作真实主题 ID。账号名称与 UID 仅保留在本地排除目录,正文档继续匿名化。 本次未验证公开后的访客可见性、失败/超限提示、结果自动识别、受理接口自动记账、重复计数保护或真实换号链路;未补写运行队列与去重库。已有现场资料不等于结果适配已经实现。详见[操作规范第 12 节](../发布流程操作记录与自动化规范.md#12-2026-10-31实际提交与审核中页面观察)。 + + +## 2026-10-31:0.2.0 结果核对与接续 + +- Python:42/42 通过,使用缓存的 Python 3.12 和依赖,临时数据库与模拟响应。 +- JavaScript、连接桥、执行器、模拟结果页:27/27 通过,Node.js 24.22.1;临时 Chrome 阻断全部网络。 +- 完整浏览器回归:控制台配置/启停、桌面/手机布局、模拟导入/排版、人工点击记录、未知结果暂停和换号中终止通过。截图位于 `test/artifacts/`。 +- 只读核对实际已受理帖子结构,确认作者、版块和图片占位规则;没有在线上传、发表、编辑或换号。 +- 已先备份正式 SQLite,再根据已有提交快照与现场观察记录补核 TID 236999 / PID 238791。批次计数保持 0、流程保持暂停、重复入队检查返回“已受理,跳过”。原 JSON 去重记录同步保留文章哈希与主题信息,仍在 Git 排除范围。 +- 尚未验收:公开成功页、真实标题超限提示、完整线上下一篇与换号、图片 HTTP 500 根因。离线通过不等于以上实机链路通过。 diff --git a/发布流程操作记录与自动化规范.md b/发布流程操作记录与自动化规范.md index 2e214d3..8662b85 100644 --- a/发布流程操作记录与自动化规范.md +++ b/发布流程操作记录与自动化规范.md @@ -203,3 +203,14 @@ downloaded → queued → importing → formatting → awaiting_user_review 网站已受理待审核,不能将该篇重新入队或为追求公开状态再次提交。本次没有验证审核通过后的访客可见性、失败提示、超长标题错误、自动记账和换号链路;看到登录态详情页不代表已公开。 本地资料保存在排除目录 `local-records/`:`publish-result-23699911.json` 保存观察摘要、标题区域 HTML 与作者/版块链接,`publish-result-23699911.png` 保存标题旁“审核中”的截图。文件名沿用访问地址的数字串,不代表实际 `tid`。本节是文档补录,不等于运行去重库已登记;后续对账需关联原文章身份,并统一主题标识的处理方式。 + + +## 13. 0.2.0 实现与历史补核 + +已实现审核中详情适配:限定标题区域审核标志,结合真实 TID、首帖楼主标识、作者 UID、版块 fid 与最终提交标题核对。受理、去重和批次计数在同一事务登记;图文检查与受理状态分开,图片延迟占位、附件区或文字/顺序差异会保留受理去重并暂停。记账后仍运行时返回首页,再领取或换号;停止后仅允许被动记账。 + +现场只读核对了现有页面结构,没有点击发表、上传、改文或换号。当前模板面包屑容器为 `.mumucms_pt`,首帖作者位于 `#post_` 的 `.plc .pti .authi`,首帖楼主链接为 `#postnum`;图片同时可能具有 `id=aimg_` 与 `aid=`。`static/image/common/none.gif` 是占位图,不能凭 naturalWidth 大于零宣称原图已显示。 + +已将第 12 节现场记录关联队列既有人工提交快照:TID 236999、PID 238791,记为受理待审核,阻止重发;历史补核没有增加当前批次计数。图文完整性保留待核对标记,流程暂停。任务库和原去重记录均先备份再更新,不改写原文章。 + +公开成功页仍无样本,审核标记缺失不能作为已公开证据。标题超限拒绝分支已做模拟测试,尚无实机错误样本;其余未知提示进入待核对。完整线上连续发布与真实账号换号仍需由用户亲手发表的小批验收。 diff --git a/实施计划.md b/实施计划.md index 9b5c582..9d8011f 100644 --- a/实施计划.md +++ b/实施计划.md @@ -8,6 +8,12 @@ > 后续现场补录:2026-10-31 已采集一次真实提交提示与审核中详情页,见第 11 节。第 1~10 节保留原计划语境,组件当前能力以 README 和源码为准;本次补录不代表结果观察功能已经接通。 +## 当前实现更新:0.2.0 + +2026-10-31 已在源码接通审核中结果观察、真实 TID/PID 分离、受理记账、返回首页与后续调度;新增发布后图文差异暂停、历史结果补核及换号异常暂停。当前操作以 README 与源码为准,下文第 1~10 节为早期实施计划,不能再把其“尚未实现”描述当作当前状态。 + +已根据本地现场记录为主题 236999 / 首帖 238791 补登记去重,关联既有提交快照,未增加当前账号批次计数。正式任务库和原去重文件在本地排除目录另存了更新前备份。公开成功页、更多拒绝提示、图片 HTTP 500 根因及完整实机换号链路仍待验证。 + ## 1. 当前目标与确定规则 目标是:自动下载、选文、上传、填写、排版和校验,等待用户亲手发表,然后观察结果、记录数量,达到账号目标后换号,继续下一篇。