From 24a73b19c05d8ec2cc1e8ef48678e5365fce1a7d Mon Sep 17 00:00:00 2001 From: Longtail Date: Sat, 31 Oct 2026 18:13:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=AE=A1=E6=A0=B8=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=B5=81=E7=A8=8B=E4=B8=8E=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 12 ++- browser/auto-news.user.js | 130 +++++++++++++++++------------ browser/results.js | 81 ++++++++++-------- browser/runner.js | 47 +++++++---- build_browser.py | 2 +- static/index.html | 4 +- store.py | 31 +++++++ test/results-browser.test.cjs | 2 +- test/results-prompt.test.cjs | 22 +++++ test/runner.test.cjs | 36 ++++++-- test/test_queue.py | 47 +++++++++++ 流程检查与Git文件清单.md | 150 ---------------------------------- 12 files changed, 296 insertions(+), 268 deletions(-) create mode 100644 test/results-prompt.test.cjs delete mode 100644 流程检查与Git文件清单.md diff --git a/README.md b/README.md index 3148ec8..bdad4c2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 将新闻下载、文章打包、浏览器导入、排版与换号联动整合到本地控制台。 -**当前版本:0.2.0。已接通下载 → 入队 → 导入 → 排版 → 人工发表 → 审核中详情核对 → 受理记账 → 回首页 → 下一篇或换号。** 结果页需同时核对真实主题 ID、首帖作者、版块、最终提交标题和标题区域的审核中标志。图文检查有差异则保留已受理去重、暂停后续处理,不自动编辑或重发。尚无公开成功页样本,不能凭审核中标志消失认定已公开;完整真实发布与换号链路仍需小批验收。 +**当前版本:0.2.1。下载 → 入队 → 导入 → 排版 → 人工发表 → 网站审核提示 → 受理记账 → 回首页 → 下一篇或换号。** 暂时只以网站提示“新主题需要审核,您的帖子通过审核后才能显示”判定发表成功,状态仍记为已受理、审核中。原详情页标题、作者、版块与图文核对代码已注释保留,当前不执行。 ## 启动 @@ -35,9 +35,9 @@ uv run --no-project --python 3.12 --with-requirements requirements.txt python ap 3. 填写目标版块 `fid`、每账号最少/最多篇数和处理间隔。先核对历史发帖记录,再勾选确认并保存。版块和篇数不会替用户选择。 4. 登录论坛,建议只保留一个执行标签。点击控制台“开始 / 继续”。执行器识别账号后领取一篇任务,进入指定新帖页、导入、排版,停止在人工审核阶段。 5. 在论坛检查账号、版块、标题、图片位置、正文和来源,并亲手点击“发表帖子”。程序不调用发帖提交函数,不发送发帖请求,也不自动保存草稿。 -6. 人工点击时保存最终提交快照。快速提示页仅作为候选证据,等待详情页核对;审核中计入受理一次,公开数量另记。真实主题 ID 来自页面链接中的 `tid`,不将 `/thread/…html` 的整串数字当作 TID;首帖 PID 单独保存。 -7. 图文无差异且流程仍在运行时,先持久记账,再回首页;在设定间隔后处理下一篇。达到账号目标且仍有待处理文章时调用换号插件,确认新 UID 和插件空闲后创建下一批次。队列为空直接结束。 -8. 等待结果超过 45 秒仍无足够证据时暂停;结果不明可在原执行标签打开正确详情页进行被动核对。图片加载最多观察 15 秒,附件区图片、占位图、正文文字或图片顺序差异都会暂停后续处理。 +6. 人工点击时保存最终提交快照。观察网站提示“新主题需要审核,您的帖子通过审核后才能显示”即登记受理一次;暂不要求详情页标题、作者、版块、主题 ID 或图文匹配,不猜测帖子 ID。 +7. 先持久记账与去重,再回首页;在设定间隔后处理下一篇。达到账号目标且仍有待处理文章时调用换号插件,确认新 UID 和插件空闲后创建下一批次。队列为空直接结束;暂停或终止后仅记账,不自动继续。 +8. 等待超过 45 秒仍未观察到审核提示则暂停,不重复发表。仅有详情页“审核中”标志不再触发自动记账;旧的待核对任务需人工处理,不能凭此次规则改动推定成功。 9. 已有现场结果可在暂停后使用队列中的“补核已受理结果”导入观察 JSON,并人工确认文章关联。支持本项目 `local-records/publish-result-*.json` 结构,字段含 title、uid、fid、threadId、postId、url、status;补核须与已有人工提交快照匹配,不增加当前批次计数。历史补核不会自动检查发布后的图文完整性。 ## 终止、恢复与冲突 @@ -118,3 +118,7 @@ node test/browser.test.cjs 更新油猴中的 `browser/auto-news.user.js` 至 0.2.0,连接脚本沿用现有 0.1.1。更新本地代码后重启服务、重新配对并刷新论坛;不会自动继续任务。新增真实 TID 与 PID 分离、链接别名防重、被动结果恢复、图文差异暂停、历史结果补核,以及记账后回首页的持久步骤。旧受理记录如只有路径数字会保留原值并标记主题身份待核对,不猜测删除 URL 后缀。 换号过程中插件暂停、超过 120 秒未确认、或执行后超过 10 秒插件空闲但 UID 未变化,流程会暂停;请在插件内核对并恢复,不自动重复抽号。图片 HTTP 500 的服务端根因仍未解决。 + +## 0.2.1 临时审核提示规则 + +更新油猴中的 `browser/auto-news.user.js` 至 0.2.1,并重启本地服务、重新配对后刷新论坛页面。连接脚本无需更新。结果适配器和执行器中的旧详情页核对代码以行注释保留;后端旧核对接口继续保留供人工历史补核使用。自动受理使用当前任务的人工提交批次校验,审核提示可不含帖子链接,不创建虚构帖子 ID。 diff --git a/browser/auto-news.user.js b/browser/auto-news.user.js index d22b3c5..bdf58f3 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.2.0 +// @version 0.2.1 // @match https://www.dww11.com/* // @match https://dww11.com/* // @match https://house.dww11.com/* @@ -741,41 +741,52 @@ window.DWW11Formatter = Object.freeze({ 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}}; + const prompt=doc.querySelector('#messagetext, .messagetext'); + const text=clean(prompt?.textContent); + const message='新主题需要审核,您的帖子通过审核后才能显示'; + if(text.includes(message))return { + kind:'accepted_prompt',state:'accepted_pending_moderation', + evidence:{adapter:'moderation_prompt',message,observed_text:text,observed_url:url} + }; + if(/^(?:抱歉[,,]\s*)?(?:您填写的)?(?:帖子)?标题.{0,40}(?:超过|超出).{0,40}(?:字符|长度|限制)/.test(text))return {kind:'rejected',error:text}; + return {kind:'waiting',prompt:text}; + + // 暂停详情页核对:按用户要求注释保留,待恢复时启用。 + // 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}; }); @@ -789,7 +800,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, stopRevision=0, resultSeenAt=0; + let skipBusy=false, preparation=null, stopRevision=0 /*, resultSeenAt=0 */; const pending=new Map(); function rpc(route,body={}) { return new Promise((resolve,reject)=>{ @@ -886,7 +897,7 @@ window.DWW11Formatter = Object.freeze({ const snapshot=editorSnapshot(); const local={id:active.id,snapshot,clickedAt:Date.now(),observed:false};saveLocal(local); rpc('event',{id:active.id,state:'user_submitting',epoch:run?.epoch,snapshot}) - .then(()=>{const latest=loadLocal();if(latest?.clickedAt===local.clickedAt)saveLocal({...local,observed:true});}) + .then(()=>{const latest=loadLocal();if(latest?.clickedAt===local.clickedAt)saveLocal({...latest,observed:true});}) .catch(error=>status('提交记录待恢复:'+error.message)); active.state='user_submitting';status('已观察到人工点击,等待结果页核对。'); }catch(error){status('提交快照记录失败:'+error.message);} @@ -928,22 +939,24 @@ window.DWW11Formatter = Object.freeze({ } if(!['user_submitting','submit_unknown'].includes(job.state))return; const snapshot=job.snapshot||local?.snapshot||{}; - const result=window.DWW11Results?.inspect(document,location.href,snapshot); + const result=(local?.id===job.id&&local.promptResult)||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; - } - resultSeenAt ||= Date.now(); - if(result.evidence.integrity_issues.some(x=>x.includes('尚未加载'))&&Date.now()-resultSeenAt<15000) { - 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; + // } + // resultSeenAt ||= Date.now(); + // if(result.evidence.integrity_issues.some(x=>x.includes('尚未加载'))&&Date.now()-resultSeenAt<15000) { + // status('已看到审核中详情,等待正文图片加载后核对');return; + // } + if(result?.kind==='accepted_prompt') { const revision=stopRevision,epoch=run.epoch; - await rpc('accept',{id:job.id,...result}); + await rpc('accept',{id:job.id,attempt:job.attempt,...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()}); @@ -956,9 +969,9 @@ window.DWW11Formatter = Object.freeze({ 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(job.state==='user_submitting')await rpc('event',{id:job.id,state:'submit_unknown',error:result?.error||'未观察到网站新主题审核提示,请人工核对是否已受理'}); if(run.running)await rpc('stop'); - status('发布结果待核对;打开对应详情页可继续核对,不会重发'); + status('未观察到网站审核提示,已暂停;请人工核对,不会重发'); } async function switchAccount() { if(working||halted)return; @@ -1022,5 +1035,20 @@ window.DWW11Formatter = Object.freeze({ takeover.style.cssText='position:fixed;left:16px;top:16px;z-index:2147483646;padding:8px;background:#fff;border:1px solid #aab;border-radius:6px'; takeover.onclick=async()=>{try{await rpc('takeover');status('已接管;核对当前页面后在工作台继续');}catch(error){status(error.message);}}; document.body.append(takeover); + // 提示可能短暂出现后跳转;及时保存到本次任务,避免两秒轮询漏掉。 + function capturePrompt() { + const local=loadLocal(); + if(!local?.id||!local.snapshot||!local.clickedAt||local.promptResult)return false; + if(active&&(active.id!==local.id||!['user_submitting','submit_unknown'].includes(active.state)))return false; + const result=window.DWW11Results?.inspect(document,location.href,local.snapshot); + if(result?.kind!=='accepted_prompt')return false; + saveLocal({...local,promptResult:result}); + return true; + } + if(typeof MutationObserver!=='undefined')new MutationObserver(()=>{ + if(capturePrompt())void tick(); + }).observe(document.body,{childList:true,subtree:true,characterData:true}); + // 跳转后的提示页可能已经渲染完成,首次轮询前也捕获一次。 + capturePrompt(); setInterval(tick,2000);tick(); })(); diff --git a/browser/results.js b/browser/results.js index ce7c7bd..60bab8b 100644 --- a/browser/results.js +++ b/browser/results.js @@ -9,41 +9,52 @@ 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}}; + const prompt=doc.querySelector('#messagetext, .messagetext'); + const text=clean(prompt?.textContent); + const message='新主题需要审核,您的帖子通过审核后才能显示'; + if(text.includes(message))return { + kind:'accepted_prompt',state:'accepted_pending_moderation', + evidence:{adapter:'moderation_prompt',message,observed_text:text,observed_url:url} + }; + if(/^(?:抱歉[,,]\s*)?(?:您填写的)?(?:帖子)?标题.{0,40}(?:超过|超出).{0,40}(?:字符|长度|限制)/.test(text))return {kind:'rejected',error:text}; + return {kind:'waiting',prompt:text}; + + // 暂停详情页核对:按用户要求注释保留,待恢复时启用。 + // 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 6a7a98b..f4238e8 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, stopRevision=0, resultSeenAt=0; + let skipBusy=false, preparation=null, stopRevision=0 /*, resultSeenAt=0 */; const pending=new Map(); function rpc(route,body={}) { return new Promise((resolve,reject)=>{ @@ -104,7 +104,7 @@ const snapshot=editorSnapshot(); const local={id:active.id,snapshot,clickedAt:Date.now(),observed:false};saveLocal(local); rpc('event',{id:active.id,state:'user_submitting',epoch:run?.epoch,snapshot}) - .then(()=>{const latest=loadLocal();if(latest?.clickedAt===local.clickedAt)saveLocal({...local,observed:true});}) + .then(()=>{const latest=loadLocal();if(latest?.clickedAt===local.clickedAt)saveLocal({...latest,observed:true});}) .catch(error=>status('提交记录待恢复:'+error.message)); active.state='user_submitting';status('已观察到人工点击,等待结果页核对。'); }catch(error){status('提交快照记录失败:'+error.message);} @@ -146,22 +146,24 @@ } if(!['user_submitting','submit_unknown'].includes(job.state))return; const snapshot=job.snapshot||local?.snapshot||{}; - const result=window.DWW11Results?.inspect(document,location.href,snapshot); + const result=(local?.id===job.id&&local.promptResult)||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; - } - resultSeenAt ||= Date.now(); - if(result.evidence.integrity_issues.some(x=>x.includes('尚未加载'))&&Date.now()-resultSeenAt<15000) { - 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; + // } + // resultSeenAt ||= Date.now(); + // if(result.evidence.integrity_issues.some(x=>x.includes('尚未加载'))&&Date.now()-resultSeenAt<15000) { + // status('已看到审核中详情,等待正文图片加载后核对');return; + // } + if(result?.kind==='accepted_prompt') { const revision=stopRevision,epoch=run.epoch; - await rpc('accept',{id:job.id,...result}); + await rpc('accept',{id:job.id,attempt:job.attempt,...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()}); @@ -174,9 +176,9 @@ 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(job.state==='user_submitting')await rpc('event',{id:job.id,state:'submit_unknown',error:result?.error||'未观察到网站新主题审核提示,请人工核对是否已受理'}); if(run.running)await rpc('stop'); - status('发布结果待核对;打开对应详情页可继续核对,不会重发'); + status('未观察到网站审核提示,已暂停;请人工核对,不会重发'); } async function switchAccount() { if(working||halted)return; @@ -240,5 +242,20 @@ takeover.style.cssText='position:fixed;left:16px;top:16px;z-index:2147483646;padding:8px;background:#fff;border:1px solid #aab;border-radius:6px'; takeover.onclick=async()=>{try{await rpc('takeover');status('已接管;核对当前页面后在工作台继续');}catch(error){status(error.message);}}; document.body.append(takeover); + // 提示可能短暂出现后跳转;及时保存到本次任务,避免两秒轮询漏掉。 + function capturePrompt() { + const local=loadLocal(); + if(!local?.id||!local.snapshot||!local.clickedAt||local.promptResult)return false; + if(active&&(active.id!==local.id||!['user_submitting','submit_unknown'].includes(active.state)))return false; + const result=window.DWW11Results?.inspect(document,location.href,local.snapshot); + if(result?.kind!=='accepted_prompt')return false; + saveLocal({...local,promptResult:result}); + return true; + } + if(typeof MutationObserver!=='undefined')new MutationObserver(()=>{ + if(capturePrompt())void tick(); + }).observe(document.body,{childList:true,subtree:true,characterData:true}); + // 跳转后的提示页可能已经渲染完成,首次轮询前也捕获一次。 + capturePrompt(); setInterval(tick,2000);tick(); })(); diff --git a/build_browser.py b/build_browser.py index d4ef2fe..18e3251 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.2.0 +// @version 0.2.1 // @match https://www.dww11.com/* // @match https://dww11.com/* // @match https://house.dww11.com/* diff --git a/static/index.html b/static/index.html index 2201e66..f1eb14d 100644 --- a/static/index.html +++ b/static/index.html @@ -4,10 +4,10 @@
-
CONTENT WORKSPACE

dww11速发 v0.2.0

By Amethyst Eralbrunia Sys & ChatGPT

+
CONTENT WORKSPACE

dww11速发 v0.2.0

By Longtail Amethyst Eralbrunia & ChatGPT

01下载新闻
→
02导入与排版
→
03人工审核与发表
→
04结果核对与换号
-
当前接入进度 已接通审核中结果核对、受理记账与后续调度。图文差异或结果证据不足时暂停;公开状态仍需独立证据。
+
当前接入进度 当前以网站“新主题需要审核,您的帖子通过审核后才能显示”提示登记发表成功(已受理、审核中),并接续后续流程。详情页核对暂时停用。
队列文章0完整文件检查后入队
等待你审核0请在网站检查并亲手发表
明确受理0审核中与已公开分别记录
当前账号批次—尚未绑定账号
SOURCE

下载与文章

等待开始
diff --git a/store.py b/store.py index 44663c3..6f6046b 100644 --- a/store.py +++ b/store.py @@ -305,6 +305,10 @@ class Store: return {'ok': True, 'duplicate': True} if self.run['active'] != job['id'] or job['state'] not in SUBMITTED: raise ValueError('只有已观察到人工提交的任务才能登记结果') + if isinstance(data.get('evidence'), dict) and data['evidence'].get('adapter') == 'moderation_prompt': + if not count_batch: + raise ValueError('审核提示只用于当前人工提交任务,不能补核历史结果') + return self._accept_prompt(db, job, data) url = urlsplit(str(data.get('post_url', ''))) match = re.fullmatch(r'/thread/(\d+)\.html', url.path) query = parse_qs(url.query) @@ -347,6 +351,33 @@ class Store: self._persist(db) return {'ok': True, 'run': dict(self.run)} + def _accept_prompt(self, db, job, data): + """Temporary acceptance rule: the forum's explicit moderation prompt only.""" + evidence = data['evidence'] + message = '新主题需要审核,您的帖子通过审核后才能显示' + text = ' '.join(str(evidence.get('observed_text', '')).split()) + url = urlsplit(str(evidence.get('observed_url', ''))) + if (data.get('state') != 'accepted_pending_moderation' + or evidence.get('message') != message or message not in text): + raise ValueError('缺少网站新主题审核提示') + if url.scheme != 'https' or url.netloc not in ('www.dww11.com', 'dww11.com'): + raise ValueError('审核提示必须来自论坛网站') + if not job.get('attempt') or data.get('attempt') != job['attempt']: + raise ValueError('审核提示与当前提交批次不一致') + # 未核对详情页,不虚构主题 ID、首帖 ID 或帖子地址。 + job.update(state='accepted_pending_moderation', error='', result=evidence, + integrity_issues=[], historical_reconciliation=False, + acceptance_basis='moderation_prompt') + 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, return_home=True, + not_before=time.time() + self.run['config']['interval']) + self._save(db, job) + self._persist(db) + return {'ok': True, 'run': dict(self.run)} + def resolve(self, data): """Explicit local UI resolution, never an automatic retry of an unknown submission.""" with self.lock, self.db() as db: diff --git a/test/results-browser.test.cjs b/test/results-browser.test.cjs index 619a5df..8d41506 100644 --- a/test/results-browser.test.cjs +++ b/test/results-browser.test.cjs @@ -15,7 +15,7 @@ function html({moderation=true,author=true,duplicateTid=false,lazy=false}={}) {
原创正文
回帖(审核中)
`; } -test('结果页适配:真实主题、首帖范围、状态、图文与延迟加载',async()=>{ +test.skip('暂时停用详情页核对;结果页适配:真实主题、首帖范围、状态、图文与延迟加载',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()); diff --git a/test/results-prompt.test.cjs b/test/results-prompt.test.cjs new file mode 100644 index 0000000..8a7421c --- /dev/null +++ b/test/results-prompt.test.cjs @@ -0,0 +1,22 @@ +'use strict'; +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const {inspect}=require('../browser/results.js'); +const message='新主题需要审核,您的帖子通过审核后才能显示'; +function doc(text,selector='#messagetext') { + return {querySelector:s=>s==='#messagetext, .messagetext'?{textContent:text}:null}; +} +test('完整审核提示即成功,不依赖详情页字段或跳转链接',()=>{ + const result=inspect(doc(message+',现在将转入首页'),'https://www.dww11.com/forum.php'); + assert.equal(result.kind,'accepted_prompt'); + assert.equal(result.state,'accepted_pending_moderation'); + assert.equal(result.evidence.message,message); + assert.equal(result.thread_id,undefined); +}); +test('只有审核中、缺失提示或相似提示不能判成功',()=>{ + for(const text of ['', '(审核中)', '新主题需要审核', '您的帖子通过审核后才能显示']) + assert.equal(inspect(doc(text),'https://www.dww11.com/').kind,'waiting'); +}); +test('明确标题拒绝继续暂停',()=>{ + assert.equal(inspect(doc('抱歉,您填写的标题超过 80 个字符限制'),'https://www.dww11.com/forum.php').kind,'rejected'); +}); diff --git a/test/runner.test.cjs b/test/runner.test.cjs index c7c951b..ff03b10 100644 --- a/test/runner.test.cjs +++ b/test/runner.test.cjs @@ -11,7 +11,7 @@ function fixture({holdImport=false,holdHeartbeat=false,rejectSkip=false,result=n 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; + const subject={value:''};let tick,finishImport,finishHeartbeat,mutation,rolledBack=false; class Event {constructor(type,options={}){this.type=type;Object.assign(this,options);}} const doc={ body:{append(){}},createElement:()=>({style:{}}), @@ -33,7 +33,7 @@ function fixture({holdImport=false,holdHeartbeat=false,rejectSkip=false,result=n 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==='event'){job.state=body.state;if(body.state==='user_submitting'){job.attempt='attempt-1';job.snapshot=body.snapshot;}} if(route==='skip') { if(rejectSkip){reply(id,null,'状态已改变');return;} job.state='skipped';run.active=null;run.epoch++;value={ok:true,fid:125,run}; @@ -44,7 +44,7 @@ function fixture({holdImport=false,holdHeartbeat=false,rejectSkip=false,result=n reply(id,value); }); let sequence=0; - const context={document:doc,CustomEvent:Event,Node:{TEXT_NODE:3},URL,AbortController, + const context={MutationObserver:class{constructor(fn){mutation=fn;}observe(){}},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);} @@ -56,7 +56,7 @@ function fixture({holdImport=false,holdHeartbeat=false,rejectSkip=false,result=n 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);}}; + mutate:()=>mutation(),setResult:value=>{result=value;},close:()=>{for(const t of timers)clearTimeout(t);}}; } test('跳过待审文章后打开全新编辑器,不再领取或提交旧稿',async()=>{ @@ -107,21 +107,28 @@ test('跳过请求失败时暂停且保留当前稿件',async()=>{ }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:[]}}; +const acceptedResult={kind:'accepted_prompt',state:'accepted_pending_moderation',evidence:{adapter:'moderation_prompt',message:'新主题需要审核,您的帖子通过审核后才能显示',observed_text:'新主题需要审核,您的帖子通过审核后才能显示',observed_url:'https://www.dww11.com/forum.php?topicsubmit=yes'}}; 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.equal(f.calls.find(x=>x.route==='accept').body.attempt,'attempt-1'); 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{ +test('审核提示无需详情页标题、作者、版块和图文信息即可记账',async()=>{ + const f=fixture({result:{kind:'accepted_prompt',state:'accepted_pending_moderation',evidence:acceptedResult.evidence}});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,[]); + assert.equal(f.calls.filter(x=>x.route==='accept').length,1); + assert.equal(f.run.running,true);assert.deepEqual(f.navigations,['https://www.dww11.com/']); + }finally{f.close();} +}); +test('详情页审核中标志不再单独触发记账',async()=>{ + const f=fixture({result:{kind:'accepted',author_uid:'123',title:'标题'}});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.deepEqual(f.navigations,[]); }finally{f.close();} }); @@ -131,3 +138,14 @@ test('明确标题拒绝只记录并暂停,不修稿或重试发表',async()=> assert.equal(f.job.state,'rejected');assert.equal(f.calls.some(x=>x.route==='accept'),false);assert.deepEqual(f.navigations,[]); }finally{f.close();} }); + +test('短暂审核提示在 DOM 变化时缓存,提示消失后仍可持久记账',async()=>{ + const f=fixture({result:acceptedResult});try{ + await flush();f.mutate();await flush(); + assert.equal(f.calls.some(x=>x.route==='accept'),false,'人工提交前不观察成功'); + f.doc.dispatchEvent(new f.Event('click',{isTrusted:true,target:{closest:()=>true}}));await flush(); + f.mutate();f.setResult({kind:'waiting'});await flush();await f.tick(); + assert.equal(f.calls.filter(x=>x.route==='accept').length,1); + assert.deepEqual(f.navigations,['https://www.dww11.com/']); + }finally{f.close();} +}); diff --git a/test/test_queue.py b/test/test_queue.py index 908172d..beb411a 100644 --- a/test/test_queue.py +++ b/test/test_queue.py @@ -44,6 +44,53 @@ class QueueTests(unittest.TestCase): return self.store.accept(dict(owner='tab-a', id=job['id'], post_url='https://www.dww11.com/thread/12345.html', 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 prompt_data(self, job): + current = self.store.state()['jobs'][0] + message = '新主题需要审核,您的帖子通过审核后才能显示' + return dict(owner='tab-a', id=job['id'], attempt=current.get('attempt'), + state='accepted_pending_moderation', evidence=dict(adapter='moderation_prompt', + message=message, observed_text=message + ',现在将转入首页', + observed_url='https://www.dww11.com/forum.php?topicsubmit=yes')) + + def test_prompt_accept_counts_once_and_persists_dedup(self): + job = self.begin() + self.submit(job) + data = self.prompt_data(job) + self.store.accept(data) + self.assertTrue(self.store.accept(data)['duplicate']) + state = self.store.state() + self.assertEqual(state['run']['batch']['count'], 1) + self.assertIsNone(state['run']['active']) + self.assertTrue(state['run']['return_home']) + self.assertTrue(state['run']['running']) + saved = state['jobs'][0] + self.assertEqual(saved['state'], 'accepted_pending_moderation') + self.assertEqual(saved.get('post_url'), '') + self.assertNotIn('thread_id', saved) + restarted = Store(self.root / 'queue.sqlite3') + self.assertIn('已受理', restarted.enqueue(self.article)) + + def test_prompt_requires_manual_submission_and_current_attempt(self): + job = self.begin() + with self.assertRaisesRegex(ValueError, '人工提交'): + self.store.accept(self.prompt_data(job)) + self.submit(job) + data = self.prompt_data(job) + for changed in ({'attempt': 'old'}, {'evidence': {**data['evidence'], 'observed_text': '(审核中)'}}, + {'evidence': {**data['evidence'], 'observed_url': 'https://example.com/'}}): + with self.assertRaises(ValueError): + self.store.accept({**data, **changed}) + self.assertEqual(self.store.run['batch']['count'], 0) + + def test_prompt_after_stop_records_without_resuming(self): + job = self.begin() + self.submit(job) + data = self.prompt_data(job) + self.store.control('stop') + self.store.accept(data) + self.assertFalse(self.store.run['running']) + self.assertEqual(self.store.run['batch']['count'], 1) + def test_stable_identity_and_original_preserved(self): before = self.md.read_bytes() item = inspect_article(self.article) diff --git a/流程检查与Git文件清单.md b/流程检查与Git文件清单.md deleted file mode 100644 index d01f8d5..0000000 --- a/流程检查与Git文件清单.md +++ /dev/null @@ -1,150 +0,0 @@ -# dww11 自动化流程检查与 Git 文件清单 - -检查日期:2026-10-31(北京时间)。本轮仅检查本地说明、源码和离线测试;没有访问网站、消耗下载 API 额度、登录、上传或发表,最初检查时未执行 Git 操作。用户随后已确认仓库范围并授权本地初始化和首次提交;下面保留检查发现,并补充本次整理结果。 - -## 一、检查结论 - -当前是可以人工衔接的组件集合,尚不是已实现的端到端自动流程。 - -```text -探数 Key → 下载 Markdown 和图片 - → 手工选择文章目录,或运行打包器生成文章包 - → 已登录的论坛新帖页面导入并上传图片 - → 点击独立“一键排版”并核对标题、正文、配图和来源 - → 用户审核并亲手点击发表 - → 核对受理/审核结果,登记去重,再决定下一篇或换号 -``` - -发表与再次尝试发表均由用户亲手点击,这是现有流程规范要求。下载器、打包器、导入器、排版器和换号扩展之间没有完整的任务调度连接。 - -## 二、逐环节检查 - -| 环节 | 主要文件 | 当前能力 | 缺口或注意事项 | -| --- | --- | --- | --- | -| 下载 | `tanshuapi_news_downloader/app.py`、`article_writer.py`、`static/` | 频道、分页、正文图片落盘、下载历史去重、进度和停止 | 下载历史仅保存指纹与目录,没有供发布队列可靠消费的完整文章清单和完成标记;停止需要等待当前请求/保存结束 | -| 打包 | `dww11_make_package.py` | 将目录转换为 v1 Markdown/base64 图片包;可直接作为 Python 函数调用 | 不会自动把文章送进浏览器;仅收集 JPG/JPEG/PNG/GIF,没有完整的 Markdown 引用预检或整包大小上限 | -| 导入 | `dww11-article-importer.user.js` | 选目录或文章包、预览、按正文引用上传、附件登记、填写校验与失败回滚 | 标题超过 80 个 Unicode 码点直接拒绝;没有对外取消接口;非空编辑器确认仍使用模态弹窗 | -| 排版 | `dww11-wechat-oneclick.user.js` v1.0.9 | 独立“一键排版”,识别文末来源并重建尾注;无来源时要求补充 | 没有公开可等待/取消的排版接口,仍有 prompt/alert;复杂尾注、重复执行和发布后的图片位置仍需浏览器回归 | -| 发布 | `auto_news_upload/` 两份流程文档和两份记录 JSON | 已有单篇人工操作的历史记录 | 本目录没有完整发布执行程序、持久任务库、自动受理计数、全量历史对账或统一终止功能 | -| 换号 | `dww11-extension/` v3.0.4 | CSV 导入、扩展 IndexedDB 帐号库、抽号与登录、暂停恢复 | 未与文章任务的 min/max、受理计数和统一终止联动;扩展内部路由不能直接视作油猴接口 | - -### 优先处理的问题 - -1. **图片格式衔接不一致。** 下载器可保存 WebP/AVIF/SVG,打包器和导入器只支持 JPG/JPEG/PNG/GIF。打包器会跳过不支持格式,导入时可能报缺图;应在打包/入队前给出明确错误,或另外实现保留原文件的转码。 -2. **长标题衔接未实现。** 规范要求保留原题并派生截断标题,现导入器却在编译时直接拒绝超过 80 字的标题。需在导入前生成并展示提交标题,站点真实计数方式仍需实测。 -3. **缺少可靠调度和统一终止。** 下载完成不等于可以发布;目录存在也不等于完整保存。现有模块停止行为不贯通,不能直接启动无人看管的批量链路。 -4. **发布去重仍是手工记录。** 下载去重不能替代跨帐号、跨版块的发布去重。需要持久队列、未决结果核对、唯一受理登记及恢复逻辑。 -5. **历史样本的图片位置有异常。** 原记录显示 26 张图片中正文 21 张、附件区 5 张,总数相同不能证明位置正确。应按附件 ID 对照最终待提交快照与结果页。 -6. **旧路径已整理。** 原导入测试与打包说明引用了搬迁前目录;首次提交准备时改用 `tests/fixtures/article/` 的自造 Markdown 和 PNG,消除 legacy 依赖。 -7. **扩展测试资料缺失。** 扩展 README 提到 `tests/core.test.mjs`、`tests/browser-integration.mjs`、`tests/browser-restart.mjs` 和 `测试说明.md`,当前项目中未找到,不能把文档中的历史测试声明当成本轮复验结果。 - -历史记录中的主题 23697411 已被网站受理并显示审核中,必须保留其本地去重证据。本轮没有核实现在是否已经公开。 - -## 三、运行时需要帐号或凭据的文件 - -下表区分“程序需要凭据才能运行”和“源码里保存真实凭据”。程序需要登录不代表源码不能进入 Git。 - -| 文件 | 所需帐号/凭据 | 使用和保存方式 | Git 建议 | -| --- | --- | --- | --- | -| `tanshuapi_news_downloader/app.py` | 探数 API Key | 调用新闻 API;Key 从页面传入,在运行内存使用 | 源码可纳入;不写入真实 Key | -| `tanshuapi_news_downloader/static/app.js`、`static/index.html` | 用户填写探数 API Key | 页面输入和内存变量,经本机会话接口传给服务;源码未配置固定 Key | 可纳入 | -| `dww11-article-importer.user.js` | 已登录的大湾网帐号及页面上传凭据 | 从当前页面读取上传参数,使用当前登录会话上传图片;无须写入密码 | 可纳入源码 | -| `dww11-wechat-oneclick.user.js` | 发布页面的登录会话 | 独立排版入口在当前编辑器操作;微信 URL 入口属于另一条采集路径 | 可纳入源码 | -| `dww11-extension/manager.js`、`manager.html`、`core.js` | 帐号 CSV | 选择、解析、确认导入帐号和密码 | 可纳入程序及虚拟模板,排除真实 CSV | -| `dww11-extension/db.js`、`service.js` | 扩展帐号库 | 明文密码保存在浏览器扩展来源的 IndexedDB;列表返回时去除密码 | 可纳入程序;排除浏览器资料及数据库备份 | -| `dww11-extension/background.js`、`content.js` | 当前抽取的帐号和密码 | 扩展消息传递,并填入目标网站登录表单 | 可纳入程序 | -| `today-jobs.user.js` | 可访问招聘管理列表的登录帐号/权限 | 使用当前页面和同源会话读取职位 | 辅助功能,按需纳入 | -| `dww-today-threads.user.js` | 论坛后台管理权限与页面 formhash | 使用当前管理会话执行统计查询 | 辅助功能,按需纳入 | -| `legacy/acc_switch_lite_python_legacy/server.py`、两个 lite 目录的 `import_excel.py` 与换号油猴脚本 | 旧版帐号文件、Excel 和网站登录 | 旧版实现;与当前扩展分开 | 不属于当前主链路;归档时排除真实数据 | -| `legacy/acc_switch_heavy_sqlite_legacy/init_db.py`、`import_excel.py` | 旧版帐号 Excel/数据库 | 可生成并导入保存密码的 `accounts.db` | 不属于当前主链路;数据库不纳入 | - -`dww11_make_package.py` 仅处理本地文件,不需要帐号、API Key 或网站 Cookie。 - -## 四、已经存在的帐号数据文件:排除真实版本 - -| 文件 | 本轮确认 | 建议 | -| --- | --- | --- | -| `账号密码-去重可导入.csv` | 表头外 1152 条数据行、3 列;为真实帐号导入文件 | 本地保留,不纳入 Git | -| `legacy/acc_switch_lite/accounts.json` | 169 条帐号记录,169 个非空 password 字段 | 本地保留,不纳入 Git | -| `legacy/acc_switch_lite_python_legacy/accounts.json` | 169 条帐号记录,169 个非空 password 字段 | 本地保留,不纳入 Git | - -以上仅统计结构和数量,不展示用户名、密码或密钥。没有执行帐号有效性验证,也没有核对 CSV 行与 JSON 记录之间的对应关系。 - -当前扩展运行帐号库在浏览器内,不在 `dww11-extension/` 源码目录。不要把浏览器配置目录、Cookie 导出文件或 IndexedDB 备份作为源码提交。`example.csv` 是示例模板,需保持为虚拟凭据。 - -## 五、拟纳入 Git 的文件清单 - -### A. 当前主链路源码与说明 - -```text -dww11_make_package.py -dww11-article-importer.user.js -dww11-wechat-oneclick.user.js -本地文章导入脚本使用说明.md -tanshuapi_news_downloader/app.py -tanshuapi_news_downloader/article_writer.py -tanshuapi_news_downloader/requirements.txt -tanshuapi_news_downloader/start.command -tanshuapi_news_downloader/README.md -tanshuapi_news_downloader/.gitignore -tanshuapi_news_downloader/static/index.html -tanshuapi_news_downloader/static/app.js -tanshuapi_news_downloader/static/style.css -tanshuapi_news_downloader/tests/test_downloader.py -dww11-extension/manifest.json -dww11-extension/background.js -dww11-extension/content.js -dww11-extension/core.js -dww11-extension/db.js -dww11-extension/service.js -dww11-extension/manager.html -dww11-extension/manager.js -dww11-extension/manager.css -dww11-extension/example.csv -dww11-extension/README.md -auto_news_upload/实施计划.md -auto_news_upload/发布流程操作记录与自动化规范.md -auto_news_upload/流程检查与Git文件清单.md -``` - -两份流程文档的帐号名称、UID 和电脑绝对路径已匿名化,原版保存在被排除的 `auto_news_upload/local-records/`。历史帖子编号与链接保留为受理证据说明。本文不含密码,但也引用了本地敏感文件名和数量,可按仓库公开范围进一步精简。 - -### B. 修复或检查后再决定 - -- `test-dww11-importer.cjs`:已改用仓库内自造测试稿与三张 PNG,一并纳入首次提交。 -- `auto_news_upload/演示任务记录.json`、`发布去重记录.json`:属于真实运行状态。建议保留本地,另提供匿名结构模板;不因 Git 排除而删除原去重记录。 -- `dww11-extension-3.0.4.zip`:本轮逐文件比对,与当前扩展目录的 11 个文件一致。可作为发行附件;日常优先跟踪源码,避免双份维护。 -- `today-jobs.user.js`、`dww-today-threads.user.js`:辅助工具,不是四步主流程的必需文件,按你的范围选择。 -- `.wechat-script-test/`:本地排版验证页、源码副本、截图和结果,需整理为可复现测试后再选择纳入。 -- `legacy/`:默认不进入主链路首批提交;若保留历史实现,必须逐项排除帐号数据、虚拟环境、缓存及下载文章。 -- `credits-video/`:滚动字幕视频工具,与本次流程无直接关系,默认不纳入本批。 - -### C. 建议排除的本地产物 - -真实帐号 CSV/JSON、下载文章及图片、下载历史数据库、真实发布记录与截图、文章包 `*.dww11.json`、浏览器配置和 Cookie、`.venv/`、`__pycache__/`、`*.pyc`、`.DS_Store`、视频输出和临时诊断文件。 - -首次提交准备时已增加根目录 `.gitignore`,覆盖真实帐号、运行记录、下载目录及本批范围之外的组件;按白名单添加源码和说明。 - -## 六、本轮验证结果 - -| 检查 | 结果 | -| --- | --- | -| Git 状态 | 当前目录不是 Git 仓库;本轮没有新建仓库 | -| 原始导入测试 `node --test test-dww11-importer.cjs` | 8 项中 3 项通过、5 项因旧素材目录不存在失败 | -| 只在内存中修正素材路径后执行同一测试 | 8/8 通过,覆盖打包与直接导入一致、缺图/长标题拦截、上传失败保留原文、富文本丢图回滚、无发表请求;磁盘测试文件未修改 | -| 下载器单元测试 | 默认 Python 3.15 与 Python 3.12 均因缺少 `requests` 无法加载测试;未安装依赖,不能认定通过或认定下载逻辑失败 | -| JavaScript 语法 | 6 个扩展 JS 按 ES 模块检查通过;4 个根目录油猴脚本检查通过 | -| Python 语法 | 打包器、下载器 app 与保存器均通过语法解析 | -| 扩展 ZIP | 11 个文件与源码目录逐一一致 | -| 在线验收 | 本轮未执行;历史文档中的单篇演示不等于当前版本全链路验收 | - -## 七、首次提交整理范围 - -用户已授权新建本地仓库并完成 initial commit,范围为总流程文档、当前换号扩展、新闻下载器、文章打包/导入/排版及相关说明与测试。 - -- 增加根目录 README 与 `.gitignore`。 -- 导入测试改用项目自有测试稿及三张程序生成的 PNG,修正打包说明路径。 -- 原始流程说明备份到本地排除目录,再匿名化仓库文档中的帐号名称、UID 与电脑路径。 -- 真实帐号、下载历史、演示 JSON、截图及原始说明均保留本地,不纳入提交。 -- 下载器测试仍需先安装依赖,扩展缺失的测试文件尚未补齐;不以首次提交表示全流程已经完成。 -- 本次只建立本地版本历史,未配置远程仓库或推送。