接通审核中结果核对、受理记账与后续调度

This commit is contained in:
Longtail committed 2026-10-31 17:20:36 +08:00
1 parent d157c179c8
commit f0be4f5596
18 files changed
+510 -57

No files matched your search

+15 -6
View File
@@ -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 的服务端根因仍未解决。
+2
View File
@@ -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':
+99 -15
View File
@@ -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,请登录并确认账号元素');
+49
View File
@@ -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};
});
+48 -14
View File
@@ -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,请登录并确认账号元素');
+2 -2
View File
@@ -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]
+17 -1
View File
@@ -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;}
+2 -2
View File
@@ -7,7 +7,7 @@
<header id="overview"><div><div class="eyebrow">CONTENT WORKSPACE</div><h1>把内容准备好,把审核留给你。</h1><p>下载、导入、排版与账号切换,在一个工作台管理。</p></div><button id="stop" class="danger">终止流程</button></header>
<div id="notice" role="status" aria-live="polite" class="notice" hidden></div>
<section class="flow"><div><span>01</span>下载新闻</div><i>→</i><div><span>02</span>导入与排版</div><i>→</i><div class="highlight"><span>03</span>人工审核与发表</div><i>→</i><div><span>04</span>结果核对与换号</div></section>
<div class="pending-note"><b>当前接入进度</b> 下载、导入、排版与换号接口已整合;结果页自动核对等待页面 HTML。人工发表后会暂停,不自动计数或继续下一篇。</div>
<div class="pending-note"><b>当前接入进度</b> 已接通审核中结果核对、受理记账与后续调度。图文差异或结果证据不足时暂停;公开状态仍需独立证据。</div>
<section class="metrics"><article><small>队列文章</small><strong id="total">0</strong><span>完整文件检查后入队</span></article><article><small>等待你审核</small><strong id="review">0</strong><span>请在网站检查并亲手发表</span></article><article><small>明确受理</small><strong id="accepted">0</strong><span>审核中与已公开分别记录</span></article><article><small>当前账号批次</small><strong id="batch">—</strong><span id="account">尚未绑定账号</span></article></section>
<div class="workspace-grid">
<section id="source" class="card"><div class="section-head"><div><span class="eyebrow">SOURCE</span><h2>下载与文章</h2></div><span id="download-state" class="badge">等待开始</span></div>
@@ -21,6 +21,6 @@
<section class="runbar"><div><span id="run-light" class="dot gray"></span><b id="run-state">正在连接本地服务…</b><p id="run-reason">等待状态</p></div><div class="actions"><button id="start">开始 / 继续</button><button id="pause" class="secondary">暂停</button></div></section>
<section id="queue" class="card"><div class="section-head"><div><span class="eyebrow">ARTICLE QUEUE</span><h2>文章队列</h2></div><label class="file-button">导入历史去重记录<input id="history-file" type="file" accept=".json,application/json" hidden></label></div><div class="table-wrap"><table><thead><tr><th>文章</th><th>状态</th><th>账号 / 版块</th><th>结果与操作</th></tr></thead><tbody id="jobs"></tbody></table><div id="empty" class="empty"><span>+</span><b>从第一篇文章开始</b><p>下载新闻,或检查已有文章目录。准备好的文章会出现在这里。</p></div></div></section>
<section id="connection" class="card"><div class="section-head"><div><span class="eyebrow">CONNECTION</span><h2>连接你的浏览器</h2></div><span id="browser-state" class="badge">尚未连接</span></div><div class="connection-grid"><div><ol><li>在油猴中安装以下两个脚本,并停用原导入、排版脚本。</li><li>重新加载原目录中的换号插件,然后刷新网站。</li><li>在网站左下角展开连接设置,填入本地端口和连接码。</li></ol><div class="actions"><a class="button secondary" href="/browser/local-bridge.user.js">安装本地连接脚本</a><a class="button secondary" href="/browser/auto-news.user.js">安装导入排版脚本</a></div></div><div><label>本地端口<input id="port" readonly></label><button id="pair" class="secondary">显示本次连接码</button><input id="pair-code" type="password" readonly hidden aria-label="本次连接码"><small class="muted">服务重启后需要更新连接码。仅复制到你安装的本地连接脚本。</small><p id="clients" class="muted">没有在线执行标签</p></div></div></section>
<details class="card"><summary>下载日志</summary><pre id="logs">暂无日志</pre></details><footer>新闻工作台 · 本机运行 · v0.1 &nbsp; / &nbsp; 终止后不会自动恢复,已发出的请求可能仍已完成。</footer>
<details class="card"><summary>下载日志</summary><pre id="logs">暂无日志</pre></details><footer>新闻工作台 · 本机运行 · v0.2.0 &nbsp; / &nbsp; 终止后不会自动恢复,已发出的请求可能仍已完成。</footer>
</main><script src="/app.js"></script>
</body></html>
+75 -12
View File
@@ -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)
+4
View File
@@ -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 完成验证。
+2 -1
View File
@@ -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');
+41
View File
@@ -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 `<div class="mumucms_pt"><div class="z"><a href="https://www.dww11.com/forum.php?mod=forumdisplay&fid=90">版块</a></div></div>
<table><tr><td class="plc mumucms_ftit vwthd"><h1><span id="thread_subject">原创标题</span></h1>
<span class="xg1">${moderation?'(审核中)':''}<a href="https://www.dww11.com/forum.php?mod=viewthread&tid=236999">复制链接</a></span>
${duplicateTid?'<a href="https://www.dww11.com/forum.php?mod=viewthread&tid=999">冲突链接</a>':''}</td></tr></table>
<a id="mumucms_username" href="https://www.dww11.com/home.php?mod=space&uid=999">登录者</a>
<div id="post_238791"><table id="pid238791"><tr><td class="plc"><a id="postnum238791">楼主</a><div class="pti"><div class="authi">${author?'<a href="https://www.dww11.com/home.php?mod=space&uid=123">首帖作者</a>':''}</div></div>
<div><table><tr><td id="postmessage_238791">原创正文<img id="aimg_42" aid="42" src="${lazy?'https://www.dww11.com/static/image/common/none.gif':gif}" file="https://www.dww11.com/real.png"></td></tr></table></div></td></tr></table></div>
<div id="post_999"><div class="authi"><a href="https://www.dww11.com/home.php?mod=space&uid=456">回帖作者</a></div><div id="postmessage_999">回帖(审核中)</div></div>`;
}
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('<div id="messagetext">新主题需要审核,您的帖子通过审核后才能显示<a href="https://www.dww11.com/thread/23699911.html">继续</a></div>');
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('<div id="messagetext">抱歉,您填写的标题超过 80 个字符限制</div>');
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();}
});
+29 -2
View File
@@ -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();}
});
+19
View File
@@ -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()
+79 -2
View File
@@ -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):
+10
View File
@@ -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 根因。离线通过不等于以上实机链路通过。
@@ -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_<PID>` 的 `.plc .pti .authi`,首帖楼主链接为 `#postnum<PID>`;图片同时可能具有 `id=aimg_<AID>` 与 `aid=<AID>`。`static/image/common/none.gif` 是占位图,不能凭 naturalWidth 大于零宣称原图已显示。
已将第 12 节现场记录关联队列既有人工提交快照:TID 236999、PID 238791,记为受理待审核,阻止重发;历史补核没有增加当前批次计数。图文完整性保留待核对标记,流程暂停。任务库和原去重记录均先备份再更新,不改写原文章。
公开成功页仍无样本,审核标记缺失不能作为已公开证据。标题超限拒绝分支已做模拟测试,尚无实机错误样本;其余未知提示进入待核对。完整线上连续发布与真实账号换号仍需由用户亲手发表的小批验收。
+6
View File
@@ -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. 当前目标与确定规则
目标是:自动下载、选文、上传、填写、排版和校验,等待用户亲手发表,然后观察结果、记录数量,达到账号目标后换号,继续下一篇。