diff --git a/README.md b/README.md
index a1f62f0..3a0fe98 100644
--- a/README.md
+++ b/README.md
@@ -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),重启本地工作台、重新配对并刷新论坛后使用。按钮位于网站左下角悬浮窗、“终止流程”上方。
diff --git a/app.py b/app.py
index 2c0e5e9..88e39f0 100644
--- a/app.py
+++ b/app.py
@@ -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')
diff --git a/browser/auto-news.user.js b/browser/auto-news.user.js
index b005c44..5bb18e7 100644
--- a/browser/auto-news.user.js
+++ b/browser/auto-news.user.js
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 新闻工作台 · 导入排版与流程
// @namespace local.dww11.auto-news
-// @version 0.1.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') {
diff --git a/browser/formatter.js b/browser/formatter.js
index 46a7712..1a5e468 100644
--- a/browser/formatter.js
+++ b/browser/formatter.js
@@ -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}));
diff --git a/browser/importer.js b/browser/importer.js
index 58515c1..2b9a4b0 100644
--- a/browser/importer.js
+++ b/browser/importer.js
@@ -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 };
});
diff --git a/browser/local-bridge.user.js b/browser/local-bridge.user.js
index 71b6cec..3c8113f 100644
--- a/browser/local-bridge.user.js
+++ b/browser/local-bridge.user.js
@@ -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='连接设置
正文第一段
正文第二段
来源:测试来源,如有侵权,请联系删除。
'; - return {images:{'a.gif':'1'}}; + document.querySelector('#e_iframe').contentDocument.body.innerHTML='正文第一段
正文第二段
来源:测试来源,如有侵权,请联系删除。
'; + 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'); diff --git a/test/importer.test.cjs b/test/importer.test.cjs index c192efb..a306026 100644 --- a/test/importer.test.cjs +++ b/test/importer.test.cjs @@ -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(/