备份现场结果补录、图片修复与跳过文章功能

This commit is contained in:
Longtail committed 2026-10-31 17:08:49 +08:00
1 parent 1e6504b4a2
commit d157c179c8
20 files changed
+597 -47

No files matched your search

+18 -5
View File
@@ -2,7 +2,7 @@
将新闻下载、文章打包、浏览器导入、排版与换号联动整合到本地控制台。
**当前版本:0.1。可完成下载 → 入队 → 导入 → 排版 → 等待人工发表。结果页自动核对按用户决定暂留待接入;人工提交后会记录候选结果并暂停,不会自动计数、换号或处理下一篇。** 换号调度和计数接口已完成离线验证,完整在线链路仍需补齐结果页 HTML 后验证。
**当前版本:0.1。可完成下载 → 入队 → 导入 → 排版 → 等待人工发表。结果页自动核对按用户决定暂留待接入;人工提交后会记录候选结果并暂停,不会自动计数、换号或处理下一篇。** 换号调度和计数接口已完成离线验证,本次已采集真实审核中结果页的关键结构;结果观察适配与完整在线链路验证仍待完成。
## 启动
@@ -35,12 +35,13 @@ uv run --no-project --python 3.12 --with-requirements requirements.txt python ap
3. 填写目标版块 `fid`、每账号最少/最多篇数和处理间隔。先核对历史发帖记录,再勾选确认并保存。版块和篇数不会替用户选择。
4. 登录论坛,建议只保留一个执行标签。点击控制台“开始 / 继续”。执行器识别账号后领取一篇任务,进入指定新帖页、导入、排版,停止在人工审核阶段。
5. 在论坛检查账号、版块、标题、图片位置、正文和来源,并亲手点击“发表帖子”。程序不调用发帖提交函数,不发送发帖请求,也不自动保存草稿。
6. 当前版本会记录人工点击和提交快照,在跳转后的候选页面或等待超时后标记“发布结果待核对”并暂停。**需要补充结果页 HTML 后,才能自动核对受理结果并接续下一篇。**
6. 当前版本会记录人工点击和提交快照,在跳转后的候选页面或等待超时后标记“发布结果待核对”并暂停。**已采集审核中页面资料,仍需完成结果观察适配,才能自动核对受理结果并接续下一篇。**
待补齐结果页自动核对后:审核中算网站已受理一次,但不算已确认公开;达到账号随机目标篇数且仍有待处理文章时,回首页调用插件换号,核对新 UID 与插件空闲状态后创建下一账号批次。没有待处理文章时直接结束,不为达到数量重发。
## 终止、恢复与冲突
- 网站左下角悬浮窗提供“跳过本文章”:将当前未提交文章标记为“已跳过”,保留队列记录、不计发表篇数,并打开新的发帖页继续导入排版下一篇。导入中会先取消并等待回滚;已上传附件可能仍保留在网站。最后一篇跳过后流程结束。已点击发表或结果不明的文章不能直接跳过,须先人工核对。已跳过文章不会自动重新入队,需要重试时可在暂停后通过队列“人工核对 / 处理”重新排队。
- 网站左下角常驻“终止流程”,点击后立即取消本标签可取消的导入,并通知其他安装脚本的标签和换号插件;持久状态禁止继续领取任务。
- 控制台“终止流程”还会停止下载,浏览器在下一次连接检查时停止(正常轮询约 2 秒)。网络断开时无法承诺固定延迟;网站按钮可立即停止本标签。已到达网站的图片、登录或发帖请求无法撤回。
- 暂停/终止后必须明确点击“开始 / 继续”。下载停止等待当前网络请求/单篇保存结束,不伪装成已经撤回在途请求。
@@ -77,7 +78,7 @@ downloads/ 本工具下载结果(本地)
```sh
uv run --no-project --python 3.12 --with-requirements requirements.txt python -m unittest discover -s test -p 'test_*.py' -v
node --test test/importer.test.cjs test/bridge.test.cjs
node --test test/importer.test.cjs test/bridge.test.cjs test/runner.test.cjs
python3 build_browser.py
```
@@ -92,6 +93,18 @@ node test/browser.test.cjs
浏览器回归拦截所有真实论坛请求,仅使用模拟 HTML。截图输出到 `test/artifacts/`。本轮离线通过不代表已在用户登录态的真实网站上完成上传、换号或发布。
## 下一步需要的页面资料
## 结果页资料与下一步
按用户决定,等待结果页 HTML:帖子标题、首帖作者及 UID 链接、版块及 fid 链接、“审核中”提示、公开帖子与失败提示。不要提供 Cookie、密码或表单令牌。将据此完成 `runner.js` 的结果观察适配,调用已经实现的受理记录接口,联通后续计数和换号。
2026-10-31 已观察一次真实提交:网站先提示“新主题需要审核,您的帖子通过审核后才能显示”,随后进入[帖子详情页](https://www.dww11.com/thread/23699911.html),标题旁显示“审核中”。标题、作者 UID 链接、体育休闲版块 `fid=90`、首帖容器及审核标记已采集。详细过程见[操作规范第 12 节](发布流程操作记录与自动化规范.md#12-2026-10-31实际提交与审核中页面观察)。
注意:该地址中的数字串为 `23699911`,页面链接实际使用 `tid=236999`,首帖 PID 为 `238791`。适配时必须区分访问地址与主题标识,不能直接把路径中的全部数字当作 `tid`。
下一步是据此完成 `runner.js` 的结果观察适配,核对受理接口的标识处理,再联通计数和换号。公开帖子、失败提示及审核后的访客可见性仍需补充验证。本次仅保存观察资料,未据此修改程序或补写队列计数;不需要 Cookie、密码或表单令牌。
## 0.1.1 图片修复
更新 `browser/auto-news.user.js`:修复实际编辑器 `aid` 属性识别、多行链接内图片漏导入,并增加正文图片加载与顺序检查。上传返回附件编号不再视为图片可显示;网站图片接口失败时明确停止。连接脚本及后端不需要为本次修复更新。现场发现的图片接口 HTTP 500 仍需进一步定位,不能由本次解析/校验修复宣称已解决。
## 0.1.2 跳过文章
保持现有控制器、SQLite 队列、连接桥和执行器的结构,新增 `skip` 浏览器接口及 `skipped` 队列状态。更新油猴中的 `browser/local-bridge.user.js`(0.1.1)和 `browser/auto-news.user.js`(0.1.2),重启本地工作台、重新配对并刷新论坛后使用。按钮位于网站左下角悬浮窗、“终止流程”上方。
+1 -1
View File
@@ -100,7 +100,7 @@ class Controller:
def bridge(self, route, body):
methods = {'heartbeat': self.store.heartbeat, 'next': self.store.next, 'package': self.store.package,
'event': self.store.event, 'accept': self.store.accept, 'switch-begin': self.store.switch_begin,
'takeover': self.store.takeover, 'current': self.store.current}
'takeover': self.store.takeover, 'current': self.store.current, 'skip': self.store.skip}
if route == 'stop':
self.manager.stop.set()
return self.store.control('stop')
+107 -15
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 新闻工作台 · 导入排版与流程
// @namespace local.dww11.auto-news
// @version 0.1.0
// @version 0.1.2
// @match https://www.dww11.com/*
// @match https://dww11.com/*
// @match https://house.dww11.com/*
@@ -18,7 +18,7 @@
else core.mount(root);
})(typeof window === 'undefined' ? null : window, function () {
'use strict';
const VERSION = '1.0.0';
const VERSION = '1.0.1';
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
const IMAGE_EXT = /\.(?:jpe?g|png|gif)$/i;
@@ -49,10 +49,11 @@
const image = text.startsWith('![', start);
if (!image && text[start] !== '[') return null;
const left = start + (image ? 2 : 1);
let end = left;
let end = left, brackets = 1;
for (; end < text.length; end++) {
if (text[end] === '\\') { end++; continue; }
if (text[end] === ']') break;
if (text[end] === '[') brackets++;
if (text[end] === ']' && --brackets === 0) break;
}
if (text.slice(end, end + 2) !== '](') return null;
let pos = end + 2, depth = 1, quoted = '', angle = false;
@@ -110,7 +111,7 @@
let list = '', quote = false, code = false, fence = '';
const closeList = () => { if (list) output.push('[/list]'); list = ''; };
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
let line = lines[index];
const codeMark = line.match(/^\s*(`{3,}|~{3,})/);
if (codeMark && (!code || codeMark[1][0] === fence)) {
closeList();
@@ -118,6 +119,19 @@
code = !code; fence = codeMark[1][0]; continue;
}
if (code) { output.push(line.replace(/\[\/code\]/gi, '[/code]')); continue; }
// A linked image/caption may span multiple Markdown lines. Keep the complete
// construct together so its inner image is visited and uploaded exactly once.
const remainder = lines.slice(index).join('\n');
for (let pos = 0; pos < line.length; pos++) {
const link = readLink(remainder, pos);
if (!link) continue;
if (link.end > line.length) {
const endLine = remainder.slice(0, link.end).split('\n').length - 1;
line = lines.slice(index, index + endLine + 1).join('\n');
}
pos = link.end - 1;
}
index += line.split('\n').length - 1;
const quoted = /^\s*>\s?/.test(line);
if (quoted !== quote) { closeList(); output.push(quoted ? '[quote]' : '[/quote]'); quote = quoted; }
const content = quoted ? line.replace(/^\s*>\s?/, '') : line;
@@ -217,6 +231,43 @@
return value.replace(/"/g, '');
}
function attachmentId(image) {
// The live Discuz editor uses aid="attachimg_123", not necessarily an id.
for (const value of [image.getAttribute('aid'), image.id]) {
const match = String(value || '').match(/^attachimg_([1-9]\d*)$/);
if (match) return match[1];
}
return null;
}
async function verifyEditorImages(body, expected, signal) {
signal?.throwIfAborted();
if (!body?.querySelectorAll) throw new Error('无法读取可视化编辑器图片');
const images = Array.from(body.querySelectorAll('img')).filter(image => attachmentId(image));
if (JSON.stringify(images.map(attachmentId)) !== JSON.stringify(expected.map(String))) {
throw new Error('正文配图数量或顺序不符,已停止;上传成功不代表正文插图成功。');
}
await Promise.all(images.map(image => new Promise((resolve, reject) => {
let timer;
const finish = error => {
clearTimeout(timer);
image.removeEventListener('load', onLoad);
image.removeEventListener('error', onError);
signal?.removeEventListener('abort', onAbort);
error ? reject(error) : resolve();
};
const onLoad = () => image.naturalWidth > 0 ? finish() : onError();
const onError = () => finish(new Error('网站附件图片无法显示(编号 ' + attachmentId(image) + ')。请核对附件缩略图及图片请求的 HTTP 错误;不会自动重传或发布。'));
const onAbort = () => finish(new Error('流程已终止'));
image.addEventListener('load', onLoad);
image.addEventListener('error', onError);
signal?.addEventListener('abort', onAbort, { once: true });
timer = setTimeout(() => finish(new Error('等待正文图片加载超时(编号 ' + attachmentId(image) + ')')), 25000);
if (signal?.aborted) onAbort();
else if (image.complete) onLoad();
})));
}
function mount(win) {
const doc = win.document;
const params = new URLSearchParams(win.location.search);
@@ -423,7 +474,8 @@
setEditor(selected.title, bbcode);
const raw = win.getEditorContents();
if (Number(win.wysiwyg) === 1) {
for (const id of new Set(aids.values())) if (!raw.includes('attachimg_' + id)) throw new Error('富文本图片转换未通过检查。');
const expected = Array.from(bbcode.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g), match => match[1]);
await verifyEditorImages(doc.querySelector('#e_iframe')?.contentDocument?.body || win.editdoc?.body, expected, signal);
} else if (raw !== bbcode) throw new Error('正文填写校验失败。');
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
committed = true;
@@ -436,7 +488,9 @@
const switcher = doc.querySelector('#e_switchercheck'); if (switcher) switcher.checked = !backup.mode;
throw error;
}
lastResult = { title: selected.title, images: Object.fromEntries(aids), completedAt: new Date().toISOString() };
lastResult = { title: selected.title, images: Object.fromEntries(aids),
attachmentOrder: Array.from(bbcode.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g), match => match[1]),
completedAt: new Date().toISOString() };
notify('complete', `已填写标题、正文和 ${aids.size} 张配图。请检查排版,随后手动发表。`);
return lastResult;
} catch (error) {
@@ -457,6 +511,7 @@
// Public v1 interface. Staging is read-only; importPrepared is the explicit upload/fill step.
// No postMessage listener, localhost service, publishing endpoint or saved credentials.
const api = Object.freeze({ version: VERSION, schemaVersion: 1, stageFiles, stagePackage,
attachmentId, verifyEditorImages,
getState: summary, openPicker: () => { if (!busy) $('folder').click(); }, importPrepared,
getBackup: () => savedBackup });
win.DWW11Importer = api;
@@ -476,7 +531,7 @@
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
win.addEventListener('pagehide', releasePreviews, { once: true });
}
return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, mount };
return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, attachmentId, verifyEditorImages, mount };
});
// Extracted from dww11-wechat-oneclick.user.js v1.0.9; package source is authoritative.
@@ -660,12 +715,12 @@ window.DWW11Formatter = Object.freeze({
const footer = existingSource(body);
const expected = `来源:${expectedSource},如有侵权,请联系删除。`;
if (!footer || footer.text !== expected) throw new Error('文末来源与文章包不一致,请人工检查');
const images = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src'));
const images = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':'));
const backup = body.innerHTML;
try {
footer.remove();
formatBody(body, expectedSource, expected);
const after = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src'));
const after = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':'));
if (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变');
if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常');
body.dispatchEvent(new Event('input', {bubbles:true}));
@@ -684,6 +739,7 @@ window.DWW11Formatter = Object.freeze({
const owner=sessionStorage.getItem('auto-news-owner') || crypto.randomUUID();
sessionStorage.setItem('auto-news-owner',owner);
let run=null, working=false, halted=true, aborter=null, active=null, tickBusy=false;
let skipBusy=false, preparation=null;
const pending=new Map();
function rpc(route,body={}) {
return new Promise((resolve,reject)=>{
@@ -725,7 +781,7 @@ window.DWW11Formatter = Object.freeze({
const body=document.querySelector('#e_iframe')?.contentDocument?.body;
if(!body)throw new Error('找不到可视化编辑器');
return {title:document.querySelector('#subject')?.value || '',
images:[...body.querySelectorAll('img')].map(x=>({id:x.id,src:x.getAttribute('src')})),
images:[...body.querySelectorAll('img')].map(x=>({id:x.id,aid:x.getAttribute('aid'),src:x.getAttribute('src')})),
// Snapshot only final submitted content; never includes hidden tokens or the full form.
html:body.innerHTML.slice(0,500000), text:body.innerText.slice(0,100000)};
}
@@ -733,6 +789,7 @@ window.DWW11Formatter = Object.freeze({
function loadLocal(){try{return JSON.parse(sessionStorage.getItem(SLOT)||'null');}catch{return null;}}
async function event(state,extra={}) {return rpc('event',{id:active.id,state,epoch:run.epoch,...extra});}
async function fail(error) {
if(skipBusy)return;
status(error.message);
if(active)try{await event('needs_attention',{error:error.message});}catch{}
halted=true;aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));
@@ -761,8 +818,9 @@ window.DWW11Formatter = Object.freeze({
window.DWW11Formatter.format(job.source);assertLive(epoch);
const snapshot=editorSnapshot();
if(snapshot.title!==job.title)throw new Error('标题校验失败');
const aids=new Set(Object.values(result.images));
for(const aid of aids) if(!snapshot.images.some(x=>x.id==='attachimg_'+aid))throw new Error('配图附件缺失:'+aid);
await window.DWW11Importer.verifyEditorImages(
document.querySelector('#e_iframe').contentDocument.body, result.attachmentOrder, aborter.signal);
assertLive(epoch);
await event('awaiting_user_review',{snapshot});assertLive(epoch);
active={...job,state:'awaiting_user_review'};saveLocal({id:job.id});
status('已完成排版,请核对内容并亲手点击“发表帖子”。');
@@ -770,6 +828,9 @@ window.DWW11Formatter = Object.freeze({
}
// Record only trusted user activation. Never replay a click, call submit(), or send a post request.
document.addEventListener('click',event=>{
if(skipBusy&&event.target.closest?.('#postsubmit')) {
event.preventDefault();event.stopImmediatePropagation();return;
}
if(!event.isTrusted||!event.target.closest?.('#postsubmit')||active?.state!=='awaiting_user_review')return;
try {
const snapshot=editorSnapshot();
@@ -780,6 +841,35 @@ window.DWW11Formatter = Object.freeze({
active.state='user_submitting';status('已观察到人工点击,等待结果页核对。');
}catch(error){status('提交快照记录失败:'+error.message);}
},true);
document.addEventListener('submit',event=>{
if(skipBusy&&event.target.id==='postform') {event.preventDefault();event.stopImmediatePropagation();}
},true);
document.addEventListener('dww11:skip-article',async()=>{
if(skipBusy)return;
if(!active||!run||run.owner!==owner) {status('当前标签没有可跳过的文章');return;}
if(!['preparing','importing','formatting','awaiting_user_review','needs_attention'].includes(active.state)) {
status('文章已提交或结果待核对,请先人工核对');return;
}
skipBusy=true;halted=true;
document.dispatchEvent(new CustomEvent('dww11:skip-busy',{detail:true}));
const jobId=active.id,epoch=run.epoch;
aborter?.abort(new Error('正在跳过当前文章'));
status('正在跳过当前文章…');
try {
// Let cancelled imports finish their rollback before leaving the editor.
await preparation;
const result=await rpc('skip',{id:jobId,epoch});
sessionStorage.removeItem(SLOT);active=null;run=result.run;
status('已跳过,正在打开下一篇文章的发帖页…');
// Always load a fresh editor, including when the fid and URL are unchanged.
location.assign(`https://www.dww11.com/forum.php?mod=post&action=newthread&fid=${result.fid}`);
}catch(error){
try{await rpc('stop');}catch{}
status('跳过未完成,流程已暂停:'+error.message);
skipBusy=false;
document.dispatchEvent(new CustomEvent('dww11:skip-busy',{detail:false}));
}
});
async function observeResult(job) {
const local=loadLocal();
if(local?.id===job.id&&local.snapshot&&job.state==='awaiting_user_review') {
@@ -807,9 +897,10 @@ window.DWW11Formatter = Object.freeze({
button.click();status('正在换号;等待插件完成并核对新账号');
}
async function tick() {
if(tickBusy)return;tickBusy=true;
if(tickBusy||skipBusy)return;tickBusy=true;
try {
run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()});
if(skipBusy)return;
halted=!run.running;
if(!run.running) {aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));}
if(working)return;
@@ -823,8 +914,9 @@ window.DWW11Formatter = Object.freeze({
status(run.reason || '流程已暂停');return;
}
const next=await rpc('next',{account:account(),extension:extension()});run=next.run;
if(skipBusy)return;
if(next.job)active=next.job;
if(next.action==='prepare'||(next.action==='resume'&&next.job.state==='preparing'))void prepare(next.job);
if(next.action==='prepare'||(next.action==='resume'&&next.job.state==='preparing'))preparation=prepare(next.job);
else if(next.action==='resume') {
await observeResult(next.job);
if(next.job.state==='awaiting_user_review') {
+2 -2
View File
@@ -179,12 +179,12 @@ window.DWW11Formatter = Object.freeze({
const footer = existingSource(body);
const expected = `来源:${expectedSource},如有侵权,请联系删除。`;
if (!footer || footer.text !== expected) throw new Error('文末来源与文章包不一致,请人工检查');
const images = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src'));
const images = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':'));
const backup = body.innerHTML;
try {
footer.remove();
formatBody(body, expectedSource, expected);
const after = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src'));
const after = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':'));
if (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变');
if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常');
body.dispatchEvent(new Event('input', {bubbles:true}));
+63 -8
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 广谈大湾网 · 本地文章目录导入
// @namespace local.dww11.article-importer
// @version 1.0.0
// @version 1.0.1
// @description 选择整个文章目录,导入 Markdown 标题、正文与本地配图;保留人工发布与程序接口。
// @match https://www.dww11.com/forum.php*
// @match https://dww11.com/forum.php*
@@ -18,7 +18,7 @@
else core.mount(root);
})(typeof window === 'undefined' ? null : window, function () {
'use strict';
const VERSION = '1.0.0';
const VERSION = '1.0.1';
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
const IMAGE_EXT = /\.(?:jpe?g|png|gif)$/i;
@@ -49,10 +49,11 @@
const image = text.startsWith('![', start);
if (!image && text[start] !== '[') return null;
const left = start + (image ? 2 : 1);
let end = left;
let end = left, brackets = 1;
for (; end < text.length; end++) {
if (text[end] === '\\') { end++; continue; }
if (text[end] === ']') break;
if (text[end] === '[') brackets++;
if (text[end] === ']' && --brackets === 0) break;
}
if (text.slice(end, end + 2) !== '](') return null;
let pos = end + 2, depth = 1, quoted = '', angle = false;
@@ -110,7 +111,7 @@
let list = '', quote = false, code = false, fence = '';
const closeList = () => { if (list) output.push('[/list]'); list = ''; };
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
let line = lines[index];
const codeMark = line.match(/^\s*(`{3,}|~{3,})/);
if (codeMark && (!code || codeMark[1][0] === fence)) {
closeList();
@@ -118,6 +119,19 @@
code = !code; fence = codeMark[1][0]; continue;
}
if (code) { output.push(line.replace(/\[\/code\]/gi, '[/code]')); continue; }
// A linked image/caption may span multiple Markdown lines. Keep the complete
// construct together so its inner image is visited and uploaded exactly once.
const remainder = lines.slice(index).join('\n');
for (let pos = 0; pos < line.length; pos++) {
const link = readLink(remainder, pos);
if (!link) continue;
if (link.end > line.length) {
const endLine = remainder.slice(0, link.end).split('\n').length - 1;
line = lines.slice(index, index + endLine + 1).join('\n');
}
pos = link.end - 1;
}
index += line.split('\n').length - 1;
const quoted = /^\s*>\s?/.test(line);
if (quoted !== quote) { closeList(); output.push(quoted ? '[quote]' : '[/quote]'); quote = quoted; }
const content = quoted ? line.replace(/^\s*>\s?/, '') : line;
@@ -217,6 +231,43 @@
return value.replace(/"/g, '');
}
function attachmentId(image) {
// The live Discuz editor uses aid="attachimg_123", not necessarily an id.
for (const value of [image.getAttribute('aid'), image.id]) {
const match = String(value || '').match(/^attachimg_([1-9]\d*)$/);
if (match) return match[1];
}
return null;
}
async function verifyEditorImages(body, expected, signal) {
signal?.throwIfAborted();
if (!body?.querySelectorAll) throw new Error('无法读取可视化编辑器图片');
const images = Array.from(body.querySelectorAll('img')).filter(image => attachmentId(image));
if (JSON.stringify(images.map(attachmentId)) !== JSON.stringify(expected.map(String))) {
throw new Error('正文配图数量或顺序不符,已停止;上传成功不代表正文插图成功。');
}
await Promise.all(images.map(image => new Promise((resolve, reject) => {
let timer;
const finish = error => {
clearTimeout(timer);
image.removeEventListener('load', onLoad);
image.removeEventListener('error', onError);
signal?.removeEventListener('abort', onAbort);
error ? reject(error) : resolve();
};
const onLoad = () => image.naturalWidth > 0 ? finish() : onError();
const onError = () => finish(new Error('网站附件图片无法显示(编号 ' + attachmentId(image) + ')。请核对附件缩略图及图片请求的 HTTP 错误;不会自动重传或发布。'));
const onAbort = () => finish(new Error('流程已终止'));
image.addEventListener('load', onLoad);
image.addEventListener('error', onError);
signal?.addEventListener('abort', onAbort, { once: true });
timer = setTimeout(() => finish(new Error('等待正文图片加载超时(编号 ' + attachmentId(image) + ')')), 25000);
if (signal?.aborted) onAbort();
else if (image.complete) onLoad();
})));
}
function mount(win) {
const doc = win.document;
const params = new URLSearchParams(win.location.search);
@@ -423,7 +474,8 @@
setEditor(selected.title, bbcode);
const raw = win.getEditorContents();
if (Number(win.wysiwyg) === 1) {
for (const id of new Set(aids.values())) if (!raw.includes('attachimg_' + id)) throw new Error('富文本图片转换未通过检查。');
const expected = Array.from(bbcode.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g), match => match[1]);
await verifyEditorImages(doc.querySelector('#e_iframe')?.contentDocument?.body || win.editdoc?.body, expected, signal);
} else if (raw !== bbcode) throw new Error('正文填写校验失败。');
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
committed = true;
@@ -436,7 +488,9 @@
const switcher = doc.querySelector('#e_switchercheck'); if (switcher) switcher.checked = !backup.mode;
throw error;
}
lastResult = { title: selected.title, images: Object.fromEntries(aids), completedAt: new Date().toISOString() };
lastResult = { title: selected.title, images: Object.fromEntries(aids),
attachmentOrder: Array.from(bbcode.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g), match => match[1]),
completedAt: new Date().toISOString() };
notify('complete', `已填写标题、正文和 ${aids.size} 张配图。请检查排版,随后手动发表。`);
return lastResult;
} catch (error) {
@@ -457,6 +511,7 @@
// Public v1 interface. Staging is read-only; importPrepared is the explicit upload/fill step.
// No postMessage listener, localhost service, publishing endpoint or saved credentials.
const api = Object.freeze({ version: VERSION, schemaVersion: 1, stageFiles, stagePackage,
attachmentId, verifyEditorImages,
getState: summary, openPicker: () => { if (!busy) $('folder').click(); }, importPrepared,
getBackup: () => savedBackup });
win.DWW11Importer = api;
@@ -476,5 +531,5 @@
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
win.addEventListener('pagehide', releasePreviews, { once: true });
}
return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, mount };
return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, attachmentId, verifyEditorImages, mount };
});
+6 -2
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 新闻工作台 · 本地连接
// @namespace local.dww11.auto-news
// @version 0.1.0
// @version 0.1.1
// @match https://www.dww11.com/*
// @match https://dww11.com/*
// @match https://house.dww11.com/*
@@ -15,7 +15,7 @@
// ==/UserScript==
(() => {
'use strict';
const allowed = new Set(['heartbeat','next','package','event','accept','switch-begin','takeover','stop','current']);
const allowed = new Set(['heartbeat','next','package','event','accept','switch-begin','takeover','stop','current','skip']);
let token = GM_getValue('pairToken',''), port = GM_getValue('pairPort',8770);
let stopRevision = 0;
const signalStop = () => { stopRevision++; document.dispatchEvent(new CustomEvent('dww11:auto-stop')); };
@@ -56,6 +56,10 @@
const shadow=host.attachShadow({mode:'closed'});
shadow.innerHTML='<style>section{font:13px/1.6 system-ui;background:white;color:#24364a;border:1px solid #ccd7e0;border-radius:10px;padding:12px;width:250px;box-shadow:0 3px 20px #0002}button,input{font:inherit;box-sizing:border-box;padding:7px;margin:4px 0;width:100%}button{cursor:pointer}#halt{background:#ad302d;color:white;border:0;border-radius:6px}small{display:block}</style><section><strong>新闻工作台 · 本地连接</strong><button id="halt">终止流程</button><details><summary>连接设置</summary><label>本地端口<input id="port" type="number" min="1" max="65535"></label><label>连接码<input id="token" type="password" autocomplete="off"></label><button id="save">保存连接设置</button></details><small id="status">请启动本地工作台并配对</small></section>';
const $=id=>shadow.getElementById(id);$('port').value=port;
const skip=document.createElement('button');skip.id='skip';skip.type='button';skip.textContent='跳过本文章';
$('halt').before(skip);
skip.onclick=()=>document.dispatchEvent(new CustomEvent('dww11:skip-article'));
document.addEventListener('dww11:skip-busy',event=>{skip.disabled=Boolean(event.detail);});
$('save').onclick=()=>{
const value=Number($('port').value);if(!Number.isInteger(value)||value<1||value>65535){$('status').textContent='端口无效';return;}
if($('token').value.trim()) token=$('token').value.trim();port=value;
+42 -5
View File
@@ -7,6 +7,7 @@
const owner=sessionStorage.getItem('auto-news-owner') || crypto.randomUUID();
sessionStorage.setItem('auto-news-owner',owner);
let run=null, working=false, halted=true, aborter=null, active=null, tickBusy=false;
let skipBusy=false, preparation=null;
const pending=new Map();
function rpc(route,body={}) {
return new Promise((resolve,reject)=>{
@@ -48,7 +49,7 @@
const body=document.querySelector('#e_iframe')?.contentDocument?.body;
if(!body)throw new Error('找不到可视化编辑器');
return {title:document.querySelector('#subject')?.value || '',
images:[...body.querySelectorAll('img')].map(x=>({id:x.id,src:x.getAttribute('src')})),
images:[...body.querySelectorAll('img')].map(x=>({id:x.id,aid:x.getAttribute('aid'),src:x.getAttribute('src')})),
// Snapshot only final submitted content; never includes hidden tokens or the full form.
html:body.innerHTML.slice(0,500000), text:body.innerText.slice(0,100000)};
}
@@ -56,6 +57,7 @@
function loadLocal(){try{return JSON.parse(sessionStorage.getItem(SLOT)||'null');}catch{return null;}}
async function event(state,extra={}) {return rpc('event',{id:active.id,state,epoch:run.epoch,...extra});}
async function fail(error) {
if(skipBusy)return;
status(error.message);
if(active)try{await event('needs_attention',{error:error.message});}catch{}
halted=true;aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));
@@ -84,8 +86,9 @@
window.DWW11Formatter.format(job.source);assertLive(epoch);
const snapshot=editorSnapshot();
if(snapshot.title!==job.title)throw new Error('标题校验失败');
const aids=new Set(Object.values(result.images));
for(const aid of aids) if(!snapshot.images.some(x=>x.id==='attachimg_'+aid))throw new Error('配图附件缺失:'+aid);
await window.DWW11Importer.verifyEditorImages(
document.querySelector('#e_iframe').contentDocument.body, result.attachmentOrder, aborter.signal);
assertLive(epoch);
await event('awaiting_user_review',{snapshot});assertLive(epoch);
active={...job,state:'awaiting_user_review'};saveLocal({id:job.id});
status('已完成排版,请核对内容并亲手点击“发表帖子”。');
@@ -93,6 +96,9 @@
}
// Record only trusted user activation. Never replay a click, call submit(), or send a post request.
document.addEventListener('click',event=>{
if(skipBusy&&event.target.closest?.('#postsubmit')) {
event.preventDefault();event.stopImmediatePropagation();return;
}
if(!event.isTrusted||!event.target.closest?.('#postsubmit')||active?.state!=='awaiting_user_review')return;
try {
const snapshot=editorSnapshot();
@@ -103,6 +109,35 @@
active.state='user_submitting';status('已观察到人工点击,等待结果页核对。');
}catch(error){status('提交快照记录失败:'+error.message);}
},true);
document.addEventListener('submit',event=>{
if(skipBusy&&event.target.id==='postform') {event.preventDefault();event.stopImmediatePropagation();}
},true);
document.addEventListener('dww11:skip-article',async()=>{
if(skipBusy)return;
if(!active||!run||run.owner!==owner) {status('当前标签没有可跳过的文章');return;}
if(!['preparing','importing','formatting','awaiting_user_review','needs_attention'].includes(active.state)) {
status('文章已提交或结果待核对,请先人工核对');return;
}
skipBusy=true;halted=true;
document.dispatchEvent(new CustomEvent('dww11:skip-busy',{detail:true}));
const jobId=active.id,epoch=run.epoch;
aborter?.abort(new Error('正在跳过当前文章'));
status('正在跳过当前文章…');
try {
// Let cancelled imports finish their rollback before leaving the editor.
await preparation;
const result=await rpc('skip',{id:jobId,epoch});
sessionStorage.removeItem(SLOT);active=null;run=result.run;
status('已跳过,正在打开下一篇文章的发帖页…');
// Always load a fresh editor, including when the fid and URL are unchanged.
location.assign(`https://www.dww11.com/forum.php?mod=post&action=newthread&fid=${result.fid}`);
}catch(error){
try{await rpc('stop');}catch{}
status('跳过未完成,流程已暂停:'+error.message);
skipBusy=false;
document.dispatchEvent(new CustomEvent('dww11:skip-busy',{detail:false}));
}
});
async function observeResult(job) {
const local=loadLocal();
if(local?.id===job.id&&local.snapshot&&job.state==='awaiting_user_review') {
@@ -130,9 +165,10 @@
button.click();status('正在换号;等待插件完成并核对新账号');
}
async function tick() {
if(tickBusy)return;tickBusy=true;
if(tickBusy||skipBusy)return;tickBusy=true;
try {
run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()});
if(skipBusy)return;
halted=!run.running;
if(!run.running) {aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));}
if(working)return;
@@ -146,8 +182,9 @@
status(run.reason || '流程已暂停');return;
}
const next=await rpc('next',{account:account(),extension:extension()});run=next.run;
if(skipBusy)return;
if(next.job)active=next.job;
if(next.action==='prepare'||(next.action==='resume'&&next.job.state==='preparing'))void prepare(next.job);
if(next.action==='prepare'||(next.action==='resume'&&next.job.state==='preparing'))preparation=prepare(next.job);
else if(next.action==='resume') {
await observeResult(next.job);
if(next.job.state==='awaiting_user_review') {
+1 -1
View File
@@ -4,7 +4,7 @@ ROOT = Path(__file__).resolve().parent
header = '''// ==UserScript==
// @name 新闻工作台 · 导入排版与流程
// @namespace local.dww11.auto-news
// @version 0.1.0
// @version 0.1.2
// @match https://www.dww11.com/*
// @match https://dww11.com/*
// @match https://house.dww11.com/*
+1 -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:'已移出'};
const labels={queued:'待处理',preparing:'准备中',importing:'上传与导入',formatting:'排版中',awaiting_user_review:'等待人工审核',user_submitting:'已观察到人工提交',submit_unknown:'发布结果待核对',accepted_pending_moderation:'已受理 · 审核中',published:'已确认公开',needs_attention:'需要处理',cancelled:'已移出',skipped:'已跳过'};
function notice(message){$('notice').hidden=!message;$('notice').textContent=message;}
async function api(route,body={}) {
const response=await fetch('/api/'+route,{method:'POST',headers:{'Content-Type':'application/json','X-Session-Token':token},body:JSON.stringify(body)});
+18
View File
@@ -184,6 +184,24 @@ class Store:
self._check_owner(data['owner'])
return {'job': self._job(db, self.run['active']) if self.run['active'] else None, 'run': self.run}
def skip(self, data):
"""Explicitly discard an unsubmitted task and continue the existing queue."""
with self.lock, self.db() as db:
self._check_owner(data['owner'])
if data.get('epoch') != self.run['epoch']:
raise ValueError('流程状态已改变,请重新确认后跳过')
if self.run['active'] != data.get('id'):
raise ValueError('不是当前任务,不能跳过')
job = self._job(db, data['id'])
if job['state'] not in {'preparing', 'importing', 'formatting', 'awaiting_user_review', 'needs_attention'} or job.get('submitted_at'):
raise ValueError('文章已提交或结果待核对,不能直接跳过,请先人工核对')
job.update(state='skipped', error='', skipped_at=time.time())
self.run.update(active=None, running=True, epoch=self.run['epoch'] + 1,
reason='已跳过当前文章,等待准备下一篇')
self._save(db, job)
self._persist(db)
return {'ok': True, 'fid': job['fid'], 'run': dict(self.run)}
def switch_begin(self, data):
with self.lock, self.db() as db:
self._check_owner(data['owner'])
+1
View File
@@ -3,6 +3,7 @@
- `history/`:从 auto_news_upload 根目录移入的历史截图和演示任务 JSON,保留原内容。不是自动测试,不能用作本次测试结论。
- `fixtures/article/`:自造 Markdown 和三张 1×1 PNG,其中两张内容相同,用于验证上传去重及插图顺序。
- `test_queue.py`:持久队列、去重、重启、终止、未知提交和按篇数换号。
- `runner.test.cjs`:跳过后打开新编辑器、导入取消回滚等待、旧轮询响应、已提交保护及失败暂停。
- `test_controller.py`:本地接口权限、模拟下载到队列、静态页面路由。
- `test_download_core.py`:复制的下载器原有离线测试,针对合并项目中的下载器副本运行。
- `importer.test.cjs`:导入器回归与自动模式的停止/冲突保护。
+23
View File
@@ -11,3 +11,26 @@ test('终止后较早的 heartbeat 响应不能恢复流程',async()=>{
assert.equal(responses.find(r=>r.id==='old').value.running,false);assert.equal(values.halted,true);
requests[1].onload({status:200,responseText:JSON.stringify({running:false,epoch:2})});await stop;
});
test('悬浮窗跳过按钮发送页面事件并防止忙碌时重复点击',()=>{
const handlers=new Map(),elements=new Map(),emitted=[],requests=[];
const element=()=>({style:{},before(child){elements.set(child.id,child);}});
for(const id of ['halt','port','token','save','status'])elements.set(id,element());
const doc={readyState:'complete',body:{append(){}},getElementById:()=>null,
createElement:()=>({...element(),attachShadow:()=>({set innerHTML(value){},getElementById:id=>elements.get(id)})}),
addEventListener(type,fn){if(!handlers.has(type))handlers.set(type,[]);handlers.get(type).push(fn);},
dispatchEvent(event){emitted.push(event);for(const fn of handlers.get(event.type)||[])fn(event);}
};
class Event{constructor(type,options={}){this.type=type;this.detail=options.detail;}}
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../browser/local-bridge.user.js'),'utf8'),{
document:doc,CustomEvent:Event,GM_getValue:(k,d)=>k==='pairToken'?'test-token':k==='halted'?false:d,
GM_setValue:()=>{},GM_addValueChangeListener:()=>{},GM_xmlhttpRequest:r=>requests.push(r)
});
const skip=elements.get('skip');assert.equal(skip.textContent,'跳过本文章');skip.onclick();
assert.equal(emitted.at(-1).type,'dww11:skip-article');
doc.dispatchEvent(new Event('dww11:skip-busy',{detail:true}));assert.equal(skip.disabled,true);
doc.dispatchEvent(new Event('dww11:skip-busy',{detail:false}));assert.equal(skip.disabled,false);
doc.dispatchEvent(new Event('dww11:local-request',{detail:JSON.stringify({id:'skip-test',route:'skip',body:{id:'job'}})}));
assert.equal(requests[0].url,'http://127.0.0.1:8770/bridge/skip');
requests[0].onload({status:200,responseText:'{"ok":true}'});
});
+4 -2
View File
@@ -32,8 +32,8 @@ const output=path.join(__dirname,'artifacts');fs.mkdirSync(output,{recursive:tru
document.querySelector('#postsubmit').addEventListener('click',()=>window.clicks++);
window.DWW11Importer={stagePackage:async()=>({warnings:[]}),importPrepared:async()=>{
document.querySelector('#subject').value='测试标题';
document.querySelector('#e_iframe').contentDocument.body.innerHTML='<p>正文第一段</p><img id="attachimg_1" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"><p>正文第二段</p><p>来源:测试来源,如有侵权,请联系删除。</p>';
return {images:{'a.gif':'1'}};
document.querySelector('#e_iframe').contentDocument.body.innerHTML='<p>正文第一段</p><img aid="attachimg_1" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"><p>正文第二段</p><p>来源:测试来源,如有侵权,请联系删除。</p>';
return {images:{'a.gif':'1'},attachmentOrder:['1']};
}};
document.addEventListener('dww11:local-request',event=>{
const {id,route,body}=JSON.parse(event.detail);let value={};
@@ -46,6 +46,8 @@ const output=path.join(__dirname,'artifacts');fs.mkdirSync(output,{recursive:tru
document.dispatchEvent(new CustomEvent('dww11:local-response',{detail:JSON.stringify({id,value})}));
});
});
const importerCore=require('../browser/importer.js');
await mock.addScriptTag({content:'(()=>{'+importerCore.attachmentId.toString()+';'+importerCore.verifyEditorImages.toString()+';Object.assign(window.DWW11Importer,{attachmentId,verifyEditorImages});})();'});
await mock.addScriptTag({path:path.join(ROOT,'browser/formatter.js')});
await mock.addScriptTag({path:path.join(ROOT,'browser/runner.js')});
await mock.waitForFunction(()=>window.job.state==='awaiting_user_review');
+41 -2
View File
@@ -73,7 +73,7 @@ test('服务器返回错误、登录 HTML 或混入脚本时不猜测附件编
});
// Small DOM harness exercises the public API and site adapter without installing browser software.
function harness({ failUpload = false, loseImages = false, existing = false } = {}) {
function harness({ failUpload = false, loseImages = false, brokenImages = false, existing = false } = {}) {
const ids = new Map(), callbacks = new Map(), requests = [];
class Element {
constructor(id='') { this.id=id; this.style={}; this.value=''; this.disabled=false; this.checked=false; this.hidden=false; this.listeners=new Map(); this.children=[]; }
@@ -87,7 +87,14 @@ function harness({ failUpload = false, loseImages = false, existing = false } =
removeEventListener(name) { this.listeners.delete(name); }
dispatchEvent() {}
click() { this.onclick?.(); }
querySelectorAll() { return []; }
querySelectorAll(selector) {
if (selector !== 'img') return [];
return [...this.innerHTML.matchAll(/<img aid="([^"]+)"[^>]*>/g)].map(m => ({
id:'', complete:true, naturalWidth:brokenImages ? 0 : 100,
getAttribute:name=>name==='aid'?m[1]:null,
addEventListener(){},removeEventListener(){}
}));
}
}
for (const id of ['subject', 'e_textarea', 'e_switchercheck', 'postform']) ids.set(id, new Element(id));
ids.get('subject').value=existing ? '原标题' : '';
@@ -170,3 +177,35 @@ test('自动导入:上传中终止不会填写正文或继续下一张', async
await assert.rejects(h.api.importPrepared({automatic:true,signal:c.signal}),/stopped/);
assert.equal(h.requests.length,1);assert.equal(h.ids.get('subject').value,'');
});
test('真实站点 aid 属性:上传成功但图片响应失败不得报告完成', async () => {
const h=harness({brokenImages:true});await h.api.stageFiles(articleFiles());
await assert.rejects(h.api.importPrepared({automatic:true}),/网站附件图片无法显示/);
assert.equal(h.ids.get('subject').value,'');assert.equal(h.api.getState().result,null);
});
test('链接内图片、多行图文链接均参与上传并按原位置渲染', () => {
const md='# 标题\n\n[![宣传](image004.jpg)\n\n### 宣传标题\n\n说明](https://example.com/promo)\n\n![](image001.jpg)';
const article=core.compile(md,'a.md',new Map([['image004.jpg',new Blob(['x'])],['image001.jpg',new Blob(['y'])]]));
assert.deepEqual([...article.images.keys()],['image004.jpg','image001.jpg']);
const rendered=article.render(new Map([['image004.jpg','104'],['image001.jpg','101']]));
assert.match(rendered,/\[url=https:\/\/example.com\/promo\]\[attachimg\]104\[\/attachimg\]/);
assert.deepEqual([...rendered.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g)].map(x=>x[1]),['104','101']);
assert.equal(rendered.includes('!['),false);
});
test('图片重复引用必须保留每个位置,不能只校验去重后的编号', async () => {
const img={id:'',getAttribute:n=>n==='aid'?'attachimg_12':null,complete:true,naturalWidth:100,addEventListener(){},removeEventListener(){}};
await core.verifyEditorImages({querySelectorAll:()=>[img,img]},['12','12']);
await assert.rejects(core.verifyEditorImages({querySelectorAll:()=>[img]},['12','12']),/数量或顺序/);
});
test('正文图片尚在加载时等待 load;终止后释放等待', async () => {
const make=()=>{const events=new Map();return {events,id:'',complete:false,naturalWidth:0,getAttribute:n=>n==='aid'?'attachimg_8':null,addEventListener:(n,f)=>events.set(n,f),removeEventListener:n=>events.delete(n)};};
const image=make();let finished=false;
const loaded=core.verifyEditorImages({querySelectorAll:()=>[image]},['8']).then(()=>finished=true);
await Promise.resolve();assert.equal(finished,false);
image.complete=true;image.naturalWidth=100;image.events.get('load')();await loaded;
assert.equal(image.events.size,0);
const pending=make(),controller=new AbortController();
const cancelled=core.verifyEditorImages({querySelectorAll:()=>[pending]},['8'],controller.signal);
controller.abort();await assert.rejects(cancelled,/终止/);assert.equal(pending.events.size,0);
});
+106
View File
@@ -0,0 +1,106 @@
'use strict';
const {test}=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const vm=require('node:vm');
const flush=async()=>{for(let i=0;i<12;i++)await new Promise(resolve=>setImmediate(resolve));};
function fixture({holdImport=false,holdHeartbeat=false,rejectSkip=false}={}) {
const handlers=new Map(),calls=[],navigations=[],storage=new Map(),timers=new Set();
const job={id:'first',state:'preparing',title:'标题',source:'来源',fid:125,account:{uid:'123',name:'账号'}};
const run={running:true,epoch:1,active:job.id,owner:'tab-a'};
const body={innerHTML:'正文',innerText:'正文',querySelectorAll:()=>[]};
const subject={value:''};let tick,finishImport,finishHeartbeat,rolledBack=false;
class Event {constructor(type,options={}){this.type=type;Object.assign(this,options);}}
const doc={
body:{append(){}},createElement:()=>({style:{}}),
querySelector(selector){
if(selector==='#mumucms_username')return {childNodes:[{nodeType:3,textContent:'账号'}],href:'https://www.dww11.com/home.php?uid=123',querySelectorAll:()=>[]};
if(selector==='#e_iframe')return {contentDocument:{body}};
if(selector==='#subject')return subject;
return null;
},
addEventListener(type,fn){if(!handlers.has(type))handlers.set(type,[]);handlers.get(type).push(fn);},
dispatchEvent(event){for(const fn of handlers.get(event.type)||[])fn(event);}
};
const reply=(id,value,error)=>doc.dispatchEvent(new Event('dww11:local-response',{detail:JSON.stringify({id,value,error})}));
doc.addEventListener('dww11:local-request',event=>{
const {id,route,body}=JSON.parse(event.detail);calls.push({route,body});
if(route==='heartbeat'&&holdHeartbeat&&calls.filter(x=>x.route==='heartbeat').length>1) {
finishHeartbeat=()=>reply(id,run);return;
}
let value={};
if(route==='heartbeat')value=run;
if(route==='next')value={action:job.state==='preparing'?'prepare':'resume',job,run};
if(route==='event')job.state=body.state;
if(route==='skip') {
if(rejectSkip){reply(id,null,'状态已改变');return;}
job.state='skipped';run.active=null;run.epoch++;value={ok:true,fid:125,run};
}
if(route==='stop')run.running=false;
reply(id,value);
});
let sequence=0;
const context={document:doc,CustomEvent:Event,Node:{TEXT_NODE:3},URL,AbortController,
crypto:{randomUUID:()=>sequence++===0?'tab-a':`request-${sequence}`},sessionStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)},
location:{href:'https://www.dww11.com/forum.php?mod=post&action=newthread&fid=125',assign:url=>navigations.push(url)},
setInterval:fn=>{tick=fn;},setTimeout:(fn,ms)=>{const t=setTimeout(fn,ms);timers.add(t);return t;},clearTimeout:t=>{clearTimeout(t);timers.delete(t);}
};
context.window={DWW11Importer:{stagePackage:async()=>({}),importPrepared:async({signal})=>{
if(holdImport)await new Promise((resolve,reject)=>{finishImport=()=>{rolledBack=true;reject(signal.reason);};signal.addEventListener('abort',()=>{}, {once:true});});
signal.throwIfAborted();subject.value='标题';return {attachmentOrder:[]};
},verifyEditorImages:async()=>{}},DWW11Formatter:{format(){}}};
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../browser/runner.js'),'utf8'),context);
return {calls,navigations,job,run,storage,doc,Event,tick:()=>tick(),skip:()=>doc.dispatchEvent(new Event('dww11:skip-article')),
finishImport:()=>finishImport(),finishHeartbeat:()=>finishHeartbeat(),rolledBack:()=>rolledBack,
close:()=>{for(const t of timers)clearTimeout(t);}};
}
test('跳过待审文章后打开全新编辑器,不再领取或提交旧稿',async()=>{
const f=fixture();try{
await flush();assert.equal(f.job.state,'awaiting_user_review');
f.skip();f.skip();await flush();
assert.equal(f.calls.filter(x=>x.route==='skip').length,1);
assert.equal(f.job.state,'skipped');assert.equal(f.storage.has('auto-news-current'),false);
assert.deepEqual(f.navigations,['https://www.dww11.com/forum.php?mod=post&action=newthread&fid=125']);
const count=f.calls.length;await f.tick();assert.equal(f.calls.length,count);
assert.equal(f.calls.some(x=>x.route==='accept'),false);
}finally{f.close();}
});
test('导入中跳过等待取消回滚,旧回调不会把任务改成异常',async()=>{
const f=fixture({holdImport:true});try{
await flush();assert.equal(f.job.state,'importing');f.skip();await flush();
assert.equal(f.calls.some(x=>x.route==='skip'),false);assert.deepEqual(f.navigations,[]);
f.finishImport();await flush();
assert.equal(f.rolledBack(),true);assert.equal(f.job.state,'skipped');
assert.equal(f.calls.some(x=>x.body.state==='needs_attention'),false);
assert.equal(f.navigations.length,1);
}finally{f.close();}
});
test('跳过时忽略较早的轮询响应',async()=>{
const f=fixture({holdHeartbeat:true});try{
await flush();const pending=f.tick();await flush();f.skip();await flush();
const before=f.calls.filter(x=>x.route==='next').length;
f.finishHeartbeat();await pending;
assert.equal(f.calls.filter(x=>x.route==='next').length,before);
}finally{f.close();}
});
test('人工发表后的任务不能直接跳过',async()=>{
const f=fixture();try{
await flush();f.doc.dispatchEvent(new f.Event('click',{isTrusted:true,target:{closest:()=>true}}));
await flush();assert.equal(f.job.state,'user_submitting');f.skip();await flush();
assert.equal(f.calls.some(x=>x.route==='skip'),false);assert.deepEqual(f.navigations,[]);
}finally{f.close();}
});
test('跳过请求失败时暂停且保留当前稿件',async()=>{
const f=fixture({rejectSkip:true});try{
await flush();f.skip();await flush();
assert.equal(f.run.running,false);assert.equal(f.job.state,'awaiting_user_review');assert.deepEqual(f.navigations,[]);
assert.equal(f.storage.has('auto-news-current'),true);
}finally{f.close();}
});
+13
View File
@@ -70,6 +70,19 @@ class ControllerTests(unittest.TestCase):
self.controller.scan(self.controller.manager.root)
self.assertEqual(len(self.controller.store.state()['jobs']), 1)
def test_bridge_skip_continues_queue(self):
store = self.controller.store
store.enqueue(ROOT / 'test/fixtures/article')
store.configure(dict(fid=125, minimum=1, maximum=1, interval=0, history_checked=True))
store.control('start')
job = store.next(dict(owner='tab-a', account=dict(uid='123', name='测试账号')))['job']
code, body = self.request('/bridge/skip', dict(owner='tab-a', id=job['id'], epoch=store.run['epoch']),
token=self.controller.bridge_token)
self.assertEqual(code, 200)
self.assertTrue(json.loads(body)['ok'])
self.assertEqual(store.state()['jobs'][0]['state'], 'skipped')
self.assertIsNone(store.run['active'])
if __name__ == '__main__':
unittest.main()
+57
View File
@@ -144,6 +144,63 @@ class QueueTests(unittest.TestCase):
self.assertEqual(self.store.next(self.owner)['action'], 'idle')
self.assertIsNone(self.store.run['switch'])
def test_skip_preserves_history_and_continues_next_without_counting(self):
job = self.begin()
other = self.root / 'other'
shutil.copytree(self.article, other)
(other / 'a.md').write_text(self.md.read_text().replace('正文内容', '下一篇内容'))
self.store.enqueue(other)
self.transition(job, 'importing')
self.transition(job, 'formatting')
self.transition(job, 'awaiting_user_review')
result = self.store.skip(dict(owner='tab-a', id=job['id'], epoch=self.store.run['epoch']))
self.assertEqual(result['fid'], 125)
self.assertEqual(self.store.run['batch']['count'], 0)
self.assertEqual(self.store.state()['history_count'], 0)
self.assertEqual(next(j for j in self.store.state()['jobs'] if j['id'] == job['id'])['state'], 'skipped')
self.assertIn('已在队列', self.store.enqueue(self.article))
self.assertEqual(self.store.next(self.owner)['action'], 'prepare')
self.assertNotEqual(self.store.run['active'], job['id'])
restarted = Store(self.root / 'queue.sqlite3')
self.assertEqual(next(j for j in restarted.state()['jobs'] if j['id'] == job['id'])['state'], 'skipped')
def test_skip_last_article_finishes_queue(self):
job = self.begin()
self.store.skip(dict(owner='tab-a', id=job['id'], epoch=self.store.run['epoch']))
self.assertEqual(self.store.next(self.owner)['action'], 'idle')
self.assertFalse(self.store.run['running'])
self.assertIsNone(self.store.run['switch'])
def test_skip_rejects_other_tab_stale_request_and_duplicate(self):
job = self.begin()
data = dict(owner='tab-a', id=job['id'], epoch=self.store.run['epoch'])
with self.assertRaisesRegex(ValueError, '另一个'):
self.store.skip({**data, 'owner': 'tab-b'})
self.store.control('stop')
with self.assertRaisesRegex(ValueError, '状态已改变'):
self.store.skip(data)
data['epoch'] = self.store.run['epoch']
self.store.skip(data)
with self.assertRaises(ValueError):
self.store.skip(data)
def test_skip_resumes_unsubmitted_error_but_rejects_submitted_errors(self):
job = self.begin()
self.transition(job, 'needs_attention', error='上传失败')
self.store.skip(dict(owner='tab-a', id=job['id'], epoch=self.store.run['epoch']))
self.assertTrue(self.store.run['running'])
self.assertEqual(self.store.next(self.owner)['action'], 'idle')
def test_submitted_article_cannot_be_skipped(self):
job = self.begin()
self.submit(job)
for state in ('user_submitting', 'submit_unknown', 'needs_attention'):
if state != 'user_submitting':
self.transition(job, state)
with self.assertRaisesRegex(ValueError, '已提交'):
self.store.skip(dict(owner='tab-a', id=job['id'], epoch=self.store.run['epoch']))
self.assertEqual(self.store.run['active'], job['id'])
def test_bad_result_does_not_increment(self):
job = self.begin();self.submit(job)
with self.assertRaises(ValueError):
+41 -2
View File
@@ -1,6 +1,6 @@
# 本轮验证记录
日期:2026-10-31。测试均未使用真实账号,未向新闻 API 请求内容,未向论坛上传或发布。
日期:2026-10-31。下表所列离线测试均未使用真实账号,未向新闻 API 请求内容,未向论坛上传或发布。后续真实现场操作另按小节记录。
| 范围 | 结果 |
| --- | --- |
@@ -19,4 +19,43 @@
测试中曾发现模拟网页未声明 UTF-8,导致中文登录入口无法匹配;已修正测试响应编码并重新通过。真实网页编码未由此假定。
尚未验证:用户浏览器中油猴本地通信权限、真实网站当前的导入与附件转换、真实换号账号链路、结果页受理/审核/公开状态。按用户决定,结果页 HTML 待提供,自动核对暂不实现;因此当前不宣称全流程批量处理已完成在线验收。
上述离线验证结束时尚未验证:用户浏览器中油猴本地通信权限、真实网站当前的导入与附件转换、真实换号账号链路、结果页受理/审核/公开状态。当时结果页 HTML 待提供,自动核对暂不实现;后续现场观察见下文,仍不代表全流程批量处理已完成在线验收。
## 2026-10-31:实际图片故障排查与 0.1.1 修复
只读检查实际发帖页面,没有点击发表、重新上传、刷新或改写用户正文。
- 正文已有 3 个图片元素,实际标识为 `aid="attachimg_…"`;原 runner 错查 `id`,已修正为共享附件验证函数。
- 这 3 张图及网站附件缩略图的 `naturalWidth` 均为 0;Chrome 开发者工具记录图片接口 HTTP 500。未获取具体错误响应或服务端日志,不能认定其底层原因。
- 原导入器只验证 HTML 中包含附件编号,不能证明图片可显示。现在校验附件序列(含重复引用),并等待图片加载;加载失败会报错,且不提示完成或继续排版。
- 原 Markdown 解析器不能处理包含嵌套图片的多行链接。本次实际文章 4 张本地 JPG 原先只识别 3 张;修复后离线编译识别 4 张,输出 4 个正文附件引用,不残留 `![`。
- 排版前后对比现同时包含图片的 `id`、`aid` 和 `src`。
- 本轮导入与连接测试 16/16 通过,覆盖 aid 属性、图片错误响应、异步加载/终止、多行嵌套图文、重复图片位置;构建脚本已重新生成 0.1.1 合并油猴文件,语法检查通过。
需要在油猴更新 `browser/auto-news.user.js` 后才会应用代码修复。此更新不会解决尚未查明的图片接口 HTTP 500;未宣称现场图片显示已恢复。
## 2026-10-31:跳过当前文章
- 悬浮窗新增“跳过本文章”,沿用连接桥和持久队列,记录 `skipped` 状态、不计发表数、不自动重新入队。
- 执行器取消并等待当前导入退出后登记跳过,打开新的发帖页继续;提交中、结果不明以及有提交历史的异常任务拒绝直接跳过。
- Python 离线测试 33/33 通过,包括浏览器接口、跳过后继续、队列耗尽、重复/跨标签/过期请求、提交状态保护与重启持久化。
- Node.js 20 脚本测试 22/22 通过,包括悬浮窗事件、导入取消回滚等待、旧轮询竞态及请求失败暂停。两个油猴脚本语法检查通过,已生成 0.1.2 合并脚本。
- 本轮未操作真实论坛或账号;未执行 Playwright 浏览器回归,页面脚本使用模拟文档和连接响应验证。
## 2026-10-31:真实提交提示与审核中详情页观察
本节为真实登录态下的现场观察,与前述离线测试分开。用户明确授权助手点击一次“发表帖子”以捕捉快速跳转;未改写待发表内容、重新上传或重复提交。本次不属于自动执行器全流程验收。
| 范围 | 观察结果 |
| --- | --- |
| 提交前 | 标题“湖人只有一个卢卡·开拓者的四大金刚”,体育休闲 `fid=90` |
| 提示页 | 明确显示“新主题需要审核,您的帖子通过审核后才能显示” |
| 跳转 | 提示页指向并自动进入 `https://www.dww11.com/thread/23699911.html`,本次未自动回首页 |
| 详情页 | 标题、作者及版块信息可核对,标题旁显示“(审核中)” |
| 主题与首帖 | 页面链接实际 `tid=236999`;首帖 PID `238791`,容器 `#postmessage_238791` |
| 受理结论 | 本次已明确受理 1 篇,已确认公开 0 篇;不能重发 |
| 保存资料 | `../local-records/publish-result-23699911.json` 与 `../local-records/publish-result-23699911.png` |
已保存详情页关键结构与审核状态截图;提示页文字和跳转链接已捕捉,跳转前的容器 HTML 未保存成功。文件名按访问地址命名,不能将 `23699911` 直接当作真实主题 ID。账号名称与 UID 仅保留在本地排除目录,正文档继续匿名化。
本次未验证公开后的访客可见性、失败/超限提示、结果自动识别、受理接口自动记账、重复计数保护或真实换号链路;未补写运行队列与去重库。已有现场资料不等于结果适配已经实现。详见[操作规范第 12 节](../发布流程操作记录与自动化规范.md#12-2026-10-31实际提交与审核中页面观察)。
@@ -165,3 +165,41 @@ downloaded → queued → importing → formatting → awaiting_user_review
- 刷新、重复读取或后来审核通过时,都用站点+主题 ID 保证受理数量只计一次。
- 本次没有出现标题超限,相关处理仍是待验证需求。
- 完整历史对账、常驻终止按钮、随机数量和自动换号仍属后续实现及验收范围。
## 12. 2026-10-31:实际提交与审核中页面观察
本节补录一次新的现场操作,与第 2、10、11 节的历史演示分别记录。用户本次明确要求由助手点击“发表帖子”并捕捉快速跳转提示;助手仅点击一次,未修改待发表标题或正文、未重新上传、未重复提交。本次授权仅适用于这一次现场操作,不改变工作台默认等待用户亲手发表的规则。
### 提交与结果
| 项目 | 观察结果 |
| --- | --- |
| 页面显示的发表时间 | 2026-10-31 17:02:54(北京时间,来自结果页时间节点) |
| 文章标题 | 湖人只有一个卢卡·开拓者的四大金刚 |
| 当前账号与作者 | 示例用户,UID <已匿名>;真实账号信息保存在本地观察记录中 |
| 目标版块 | 体育休闲,`fid=90` |
| 提交前页面 | `https://www.dww11.com/forum.php?mod=post&action=newthread&fid=90` |
| 提交后提示页 | `forum.php?mod=post&action=newthread&fid=90&extra=&topicsubmit=yes` |
| 网站原文提示 | 新主题需要审核,您的帖子通过审核后才能显示 |
| 提示页跳转链接 | `https://www.dww11.com/thread/23699911.html` |
| 实际跳转 | 提示页 → 帖子详情页;本次未观察到自动回首页 |
| 详情页状态 | 标题旁明确显示“(审核中)” |
| 实际主题参数 | 页面“复制链接”及相邻主题链接使用 `tid=236999` |
| 首帖标识 | PID `238791`;正文容器为 `#postmessage_238791` |
| 受理结论 | 本次已明确受理 1 篇;本次已确认公开 0 篇 |
| 后续动作 | 未再次发表、未换号;未据此补写运行队列、受理计数或发布去重库 |
### 页面适配依据
| 数据 | 现场依据 | 适配注意事项 |
| --- | --- | --- |
| 提交提示 | 可访问性树中的 `messagetext` 容器及其提示文字、跳转链接 | 已捕捉文字与链接,但跳转前未成功保存该容器 HTML;结果选择器仍需验证 |
| 帖子标题 | `#thread_subject`,位于 `h1.ts` 内 | 与最终提交标题核对,不能只用浏览器页签标题 |
| 审核状态 | 标题所在 `td.plc.mumucms_ftit.vwthd` 内的 `span.xg1` 包含“(审核中)” | 限定标题区域,避免把正文中出现的同名文字当作状态 |
| 主题标识 | 标题区域“复制链接”的 `mod=viewthread&tid=236999` | 地址路径为 `23699911.html`,不能直接将全部数字解析为实际主题 ID |
| 首帖与作者 | `#postmessage_238791`;现场作者链接含 `mod=space&uid=<已匿名>` | 动态提取 PID、UID,并限定首帖作者区域;页面顶部登录账号不能单独证明作者身份 |
| 目标版块 | 现场版块链接含 `mod=forumdisplay&fid=90` | 与任务版块核对,不只匹配“体育休闲”文字 |
网站已受理待审核,不能将该篇重新入队或为追求公开状态再次提交。本次没有验证审核通过后的访客可见性、失败提示、超长标题错误、自动记账和换号链路;看到登录态详情页不代表已公开。
本地资料保存在排除目录 `local-records/`:`publish-result-23699911.json` 保存观察摘要、标题区域 HTML 与作者/版块链接,`publish-result-23699911.png` 保存标题旁“审核中”的截图。文件名沿用访问地址的数字串,不代表实际 `tid`。本节是文档补录,不等于运行去重库已登记;后续对账需关联原文章身份,并统一主题标识的处理方式。
+14 -1
View File
@@ -4,7 +4,9 @@
更新日期:2026-10-31(北京时间)。本版整体替换早期自动发帖设想,依据当前源码、[发布流程操作记录与自动化规范](发布流程操作记录与自动化规范.md)、[演示任务记录](演示任务记录.json)和[发布去重记录](发布去重记录.json)重新梳理。
本次进行了本地源码与记录核对,未重新操作线上页面、下载新闻、上传图片或发表帖子。下文“演示已验证”引用既有操作记录;“源码已有”不等于最新版本已在线上重新验收。
原计划编写时仅进行了本地源码与记录核对,未重新操作线上页面、下载新闻、上传图片或发表帖子。下文“演示已验证”引用既有操作记录;“源码已有”不等于最新版本已在线上重新验收。
> 后续现场补录:2026-10-31 已采集一次真实提交提示与审核中详情页,见第 11 节。第 1~10 节保留原计划语境,组件当前能力以 README 和源码为准;本次补录不代表结果观察功能已经接通。
## 1. 当前目标与确定规则
@@ -176,3 +178,14 @@ downloaded → queued → importing → formatting → awaiting_user_review
新实现仍放在 `auto_news_upload/`,后续规划为 `service/`、`browser/`、`tests/`、`data/` 和启动说明;当前这些实现尚未建立。保留原下载目录、原历史库及现有演示记录。复用源码时记录版本,明确安装时启用的脚本组合,避免重复工具争用编辑器。
本次仅重写本实施计划,未修改业务脚本和历史记录。历史操作规范用于追溯当时实机行为;当前组件能力以本轮核对的源码为准,人工发表、去重、终止和换号规则继续遵循操作规范。
## 11. 2026-10-31:结果页现场资料补充
用户明确授权助手点击一次“发表帖子”并捕捉提示。网站返回“新主题需要审核,您的帖子通过审核后才能显示”,随后自动进入 `/thread/23699911.html`;详情页标题为“湖人只有一个卢卡·开拓者的四大金刚”,版块为体育休闲 `fid=90`,标题旁显示“(审核中)”。本次确认受理 1 篇,未确认公开,不重复提交或换号。完整过程与页面依据见[操作规范第 12 节](发布流程操作记录与自动化规范.md#12-2026-10-31实际提交与审核中页面观察)。
- 已取得标题 `#thread_subject`、标题区域审核标记、作者 UID 链接、版块 fid 链接与首帖正文容器 `#postmessage_238791` 的现场依据;账号信息在可共享文档中继续匿名化。
- 页面真实主题参数为 `tid=236999`,访问路径中的数字串为 `23699911`,首帖 PID 为 `238791`。结果解析和去重须统一标识规则,不能直接把路径数字串当作真实 `tid`,也不能据此批量改写历史记录。
- 提示页的文字与跳转链接已捕捉,但未保存跳转前的容器 HTML。详情页关键 HTML 与截图已保存到 `local-records/publish-result-23699911.json`、`local-records/publish-result-23699911.png`。
- 下一步完成结果观察适配、受理接口标识核对与重复计数验证,再进行后续计数/换号联调;公开、失败和审核后的访客可见性仍待验证。
本次仅同步观察记录,未修改业务代码、运行队列或去重库;这次授权代点不扩展为后续自动发表权限,也不替代正式工作流的人工发表边界。