接通审核中结果核对、受理记账与后续调度
This commit is contained in:
1 parent
d157c179c8
commit
f0be4f5596
18 files changed
+510
-57
No files matched your search
@@ -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 的服务端根因仍未解决。
|
||||
@@ -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
@@ -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,请登录并确认账号元素');
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 / 终止后不会自动恢复,已发出的请求可能仍已完成。</footer>
|
||||
<details class="card"><summary>下载日志</summary><pre id="logs">暂无日志</pre></details><footer>新闻工作台 · 本机运行 · v0.2.0 / 终止后不会自动恢复,已发出的请求可能仍已完成。</footer>
|
||||
</main><script src="/app.js"></script>
|
||||
</body></html>
|
||||
@@ -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)
|
||||
|
||||
@@ -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 完成验证。
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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();}
|
||||
});
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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,记为受理待审核,阻止重发;历史补核没有增加当前批次计数。图文完整性保留待核对标记,流程暂停。任务库和原去重记录均先备份再更新,不改写原文章。
|
||||
|
||||
公开成功页仍无样本,审核标记缺失不能作为已公开证据。标题超限拒绝分支已做模拟测试,尚无实机错误样本;其余未知提示进入待核对。完整线上连续发布与真实账号换号仍需由用户亲手发表的小批验收。
|
||||
@@ -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. 当前目标与确定规则
|
||||
|
||||
目标是:自动下载、选文、上传、填写、排版和校验,等待用户亲手发表,然后观察结果、记录数量,达到账号目标后换号,继续下一篇。
|
||||
|
||||
Reference in new issue
Block a user