备份现场结果补录、图片修复与跳过文章功能
This commit is contained in:
1 parent
1e6504b4a2
commit
d157c179c8
20 files changed
+597
-47
No files matched your search
@@ -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`、每账号最少/最多篇数和处理间隔。先核对历史发帖记录,再勾选确认并保存。版块和篇数不会替用户选择。
|
3. 填写目标版块 `fid`、每账号最少/最多篇数和处理间隔。先核对历史发帖记录,再勾选确认并保存。版块和篇数不会替用户选择。
|
||||||
4. 登录论坛,建议只保留一个执行标签。点击控制台“开始 / 继续”。执行器识别账号后领取一篇任务,进入指定新帖页、导入、排版,停止在人工审核阶段。
|
4. 登录论坛,建议只保留一个执行标签。点击控制台“开始 / 继续”。执行器识别账号后领取一篇任务,进入指定新帖页、导入、排版,停止在人工审核阶段。
|
||||||
5. 在论坛检查账号、版块、标题、图片位置、正文和来源,并亲手点击“发表帖子”。程序不调用发帖提交函数,不发送发帖请求,也不自动保存草稿。
|
5. 在论坛检查账号、版块、标题、图片位置、正文和来源,并亲手点击“发表帖子”。程序不调用发帖提交函数,不发送发帖请求,也不自动保存草稿。
|
||||||
6. 当前版本会记录人工点击和提交快照,在跳转后的候选页面或等待超时后标记“发布结果待核对”并暂停。**需要补充结果页 HTML 后,才能自动核对受理结果并接续下一篇。**
|
6. 当前版本会记录人工点击和提交快照,在跳转后的候选页面或等待超时后标记“发布结果待核对”并暂停。**已采集审核中页面资料,仍需完成结果观察适配,才能自动核对受理结果并接续下一篇。**
|
||||||
|
|
||||||
待补齐结果页自动核对后:审核中算网站已受理一次,但不算已确认公开;达到账号随机目标篇数且仍有待处理文章时,回首页调用插件换号,核对新 UID 与插件空闲状态后创建下一账号批次。没有待处理文章时直接结束,不为达到数量重发。
|
待补齐结果页自动核对后:审核中算网站已受理一次,但不算已确认公开;达到账号随机目标篇数且仍有待处理文章时,回首页调用插件换号,核对新 UID 与插件空闲状态后创建下一账号批次。没有待处理文章时直接结束,不为达到数量重发。
|
||||||
|
|
||||||
## 终止、恢复与冲突
|
## 终止、恢复与冲突
|
||||||
|
|
||||||
|
- 网站左下角悬浮窗提供“跳过本文章”:将当前未提交文章标记为“已跳过”,保留队列记录、不计发表篇数,并打开新的发帖页继续导入排版下一篇。导入中会先取消并等待回滚;已上传附件可能仍保留在网站。最后一篇跳过后流程结束。已点击发表或结果不明的文章不能直接跳过,须先人工核对。已跳过文章不会自动重新入队,需要重试时可在暂停后通过队列“人工核对 / 处理”重新排队。
|
||||||
- 网站左下角常驻“终止流程”,点击后立即取消本标签可取消的导入,并通知其他安装脚本的标签和换号插件;持久状态禁止继续领取任务。
|
- 网站左下角常驻“终止流程”,点击后立即取消本标签可取消的导入,并通知其他安装脚本的标签和换号插件;持久状态禁止继续领取任务。
|
||||||
- 控制台“终止流程”还会停止下载,浏览器在下一次连接检查时停止(正常轮询约 2 秒)。网络断开时无法承诺固定延迟;网站按钮可立即停止本标签。已到达网站的图片、登录或发帖请求无法撤回。
|
- 控制台“终止流程”还会停止下载,浏览器在下一次连接检查时停止(正常轮询约 2 秒)。网络断开时无法承诺固定延迟;网站按钮可立即停止本标签。已到达网站的图片、登录或发帖请求无法撤回。
|
||||||
- 暂停/终止后必须明确点击“开始 / 继续”。下载停止等待当前网络请求/单篇保存结束,不伪装成已经撤回在途请求。
|
- 暂停/终止后必须明确点击“开始 / 继续”。下载停止等待当前网络请求/单篇保存结束,不伪装成已经撤回在途请求。
|
||||||
@@ -77,7 +78,7 @@ downloads/ 本工具下载结果(本地)
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv run --no-project --python 3.12 --with-requirements requirements.txt python -m unittest discover -s test -p 'test_*.py' -v
|
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
|
python3 build_browser.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -92,6 +93,18 @@ node test/browser.test.cjs
|
|||||||
|
|
||||||
浏览器回归拦截所有真实论坛请求,仅使用模拟 HTML。截图输出到 `test/artifacts/`。本轮离线通过不代表已在用户登录态的真实网站上完成上传、换号或发布。
|
浏览器回归拦截所有真实论坛请求,仅使用模拟 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),重启本地工作台、重新配对并刷新论坛后使用。按钮位于网站左下角悬浮窗、“终止流程”上方。
|
||||||
@@ -100,7 +100,7 @@ class Controller:
|
|||||||
def bridge(self, route, body):
|
def bridge(self, route, body):
|
||||||
methods = {'heartbeat': self.store.heartbeat, 'next': self.store.next, 'package': self.store.package,
|
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,
|
'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':
|
if route == 'stop':
|
||||||
self.manager.stop.set()
|
self.manager.stop.set()
|
||||||
return self.store.control('stop')
|
return self.store.control('stop')
|
||||||
|
|||||||
+107
-15
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name 新闻工作台 · 导入排版与流程
|
// @name 新闻工作台 · 导入排版与流程
|
||||||
// @namespace local.dww11.auto-news
|
// @namespace local.dww11.auto-news
|
||||||
// @version 0.1.0
|
// @version 0.1.2
|
||||||
// @match https://www.dww11.com/*
|
// @match https://www.dww11.com/*
|
||||||
// @match https://dww11.com/*
|
// @match https://dww11.com/*
|
||||||
// @match https://house.dww11.com/*
|
// @match https://house.dww11.com/*
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
else core.mount(root);
|
else core.mount(root);
|
||||||
})(typeof window === 'undefined' ? null : window, function () {
|
})(typeof window === 'undefined' ? null : window, function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
const VERSION = '1.0.0';
|
const VERSION = '1.0.1';
|
||||||
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
||||||
const IMAGE_EXT = /\.(?:jpe?g|png|gif)$/i;
|
const IMAGE_EXT = /\.(?:jpe?g|png|gif)$/i;
|
||||||
|
|
||||||
@@ -49,10 +49,11 @@
|
|||||||
const image = text.startsWith('![', start);
|
const image = text.startsWith('![', start);
|
||||||
if (!image && text[start] !== '[') return null;
|
if (!image && text[start] !== '[') return null;
|
||||||
const left = start + (image ? 2 : 1);
|
const left = start + (image ? 2 : 1);
|
||||||
let end = left;
|
let end = left, brackets = 1;
|
||||||
for (; end < text.length; end++) {
|
for (; end < text.length; end++) {
|
||||||
if (text[end] === '\\') { end++; continue; }
|
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;
|
if (text.slice(end, end + 2) !== '](') return null;
|
||||||
let pos = end + 2, depth = 1, quoted = '', angle = false;
|
let pos = end + 2, depth = 1, quoted = '', angle = false;
|
||||||
@@ -110,7 +111,7 @@
|
|||||||
let list = '', quote = false, code = false, fence = '';
|
let list = '', quote = false, code = false, fence = '';
|
||||||
const closeList = () => { if (list) output.push('[/list]'); list = ''; };
|
const closeList = () => { if (list) output.push('[/list]'); list = ''; };
|
||||||
for (let index = 0; index < lines.length; index++) {
|
for (let index = 0; index < lines.length; index++) {
|
||||||
const line = lines[index];
|
let line = lines[index];
|
||||||
const codeMark = line.match(/^\s*(`{3,}|~{3,})/);
|
const codeMark = line.match(/^\s*(`{3,}|~{3,})/);
|
||||||
if (codeMark && (!code || codeMark[1][0] === fence)) {
|
if (codeMark && (!code || codeMark[1][0] === fence)) {
|
||||||
closeList();
|
closeList();
|
||||||
@@ -118,6 +119,19 @@
|
|||||||
code = !code; fence = codeMark[1][0]; continue;
|
code = !code; fence = codeMark[1][0]; continue;
|
||||||
}
|
}
|
||||||
if (code) { output.push(line.replace(/\[\/code\]/gi, '[/code]')); 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);
|
const quoted = /^\s*>\s?/.test(line);
|
||||||
if (quoted !== quote) { closeList(); output.push(quoted ? '[quote]' : '[/quote]'); quote = quoted; }
|
if (quoted !== quote) { closeList(); output.push(quoted ? '[quote]' : '[/quote]'); quote = quoted; }
|
||||||
const content = quoted ? line.replace(/^\s*>\s?/, '') : line;
|
const content = quoted ? line.replace(/^\s*>\s?/, '') : line;
|
||||||
@@ -217,6 +231,43 @@
|
|||||||
return value.replace(/"/g, '');
|
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) {
|
function mount(win) {
|
||||||
const doc = win.document;
|
const doc = win.document;
|
||||||
const params = new URLSearchParams(win.location.search);
|
const params = new URLSearchParams(win.location.search);
|
||||||
@@ -423,7 +474,8 @@
|
|||||||
setEditor(selected.title, bbcode);
|
setEditor(selected.title, bbcode);
|
||||||
const raw = win.getEditorContents();
|
const raw = win.getEditorContents();
|
||||||
if (Number(win.wysiwyg) === 1) {
|
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('正文填写校验失败。');
|
} else if (raw !== bbcode) throw new Error('正文填写校验失败。');
|
||||||
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
|
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
|
||||||
committed = true;
|
committed = true;
|
||||||
@@ -436,7 +488,9 @@
|
|||||||
const switcher = doc.querySelector('#e_switchercheck'); if (switcher) switcher.checked = !backup.mode;
|
const switcher = doc.querySelector('#e_switchercheck'); if (switcher) switcher.checked = !backup.mode;
|
||||||
throw error;
|
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} 张配图。请检查排版,随后手动发表。`);
|
notify('complete', `已填写标题、正文和 ${aids.size} 张配图。请检查排版,随后手动发表。`);
|
||||||
return lastResult;
|
return lastResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -457,6 +511,7 @@
|
|||||||
// Public v1 interface. Staging is read-only; importPrepared is the explicit upload/fill step.
|
// Public v1 interface. Staging is read-only; importPrepared is the explicit upload/fill step.
|
||||||
// No postMessage listener, localhost service, publishing endpoint or saved credentials.
|
// No postMessage listener, localhost service, publishing endpoint or saved credentials.
|
||||||
const api = Object.freeze({ version: VERSION, schemaVersion: 1, stageFiles, stagePackage,
|
const api = Object.freeze({ version: VERSION, schemaVersion: 1, stageFiles, stagePackage,
|
||||||
|
attachmentId, verifyEditorImages,
|
||||||
getState: summary, openPicker: () => { if (!busy) $('folder').click(); }, importPrepared,
|
getState: summary, openPicker: () => { if (!busy) $('folder').click(); }, importPrepared,
|
||||||
getBackup: () => savedBackup });
|
getBackup: () => savedBackup });
|
||||||
win.DWW11Importer = api;
|
win.DWW11Importer = api;
|
||||||
@@ -476,7 +531,7 @@
|
|||||||
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
|
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
|
||||||
win.addEventListener('pagehide', releasePreviews, { once: true });
|
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.
|
// 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 footer = existingSource(body);
|
||||||
const expected = `来源:${expectedSource},如有侵权,请联系删除。`;
|
const expected = `来源:${expectedSource},如有侵权,请联系删除。`;
|
||||||
if (!footer || footer.text !== expected) throw new Error('文末来源与文章包不一致,请人工检查');
|
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;
|
const backup = body.innerHTML;
|
||||||
try {
|
try {
|
||||||
footer.remove();
|
footer.remove();
|
||||||
formatBody(body, expectedSource, expected);
|
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 (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变');
|
||||||
if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常');
|
if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常');
|
||||||
body.dispatchEvent(new Event('input', {bubbles:true}));
|
body.dispatchEvent(new Event('input', {bubbles:true}));
|
||||||
@@ -684,6 +739,7 @@ window.DWW11Formatter = Object.freeze({
|
|||||||
const owner=sessionStorage.getItem('auto-news-owner') || crypto.randomUUID();
|
const owner=sessionStorage.getItem('auto-news-owner') || crypto.randomUUID();
|
||||||
sessionStorage.setItem('auto-news-owner',owner);
|
sessionStorage.setItem('auto-news-owner',owner);
|
||||||
let run=null, working=false, halted=true, aborter=null, active=null, tickBusy=false;
|
let run=null, working=false, halted=true, aborter=null, active=null, tickBusy=false;
|
||||||
|
let skipBusy=false, preparation=null;
|
||||||
const pending=new Map();
|
const pending=new Map();
|
||||||
function rpc(route,body={}) {
|
function rpc(route,body={}) {
|
||||||
return new Promise((resolve,reject)=>{
|
return new Promise((resolve,reject)=>{
|
||||||
@@ -725,7 +781,7 @@ window.DWW11Formatter = Object.freeze({
|
|||||||
const body=document.querySelector('#e_iframe')?.contentDocument?.body;
|
const body=document.querySelector('#e_iframe')?.contentDocument?.body;
|
||||||
if(!body)throw new Error('找不到可视化编辑器');
|
if(!body)throw new Error('找不到可视化编辑器');
|
||||||
return {title:document.querySelector('#subject')?.value || '',
|
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.
|
// 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)};
|
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;}}
|
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 event(state,extra={}) {return rpc('event',{id:active.id,state,epoch:run.epoch,...extra});}
|
||||||
async function fail(error) {
|
async function fail(error) {
|
||||||
|
if(skipBusy)return;
|
||||||
status(error.message);
|
status(error.message);
|
||||||
if(active)try{await event('needs_attention',{error:error.message});}catch{}
|
if(active)try{await event('needs_attention',{error:error.message});}catch{}
|
||||||
halted=true;aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));
|
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);
|
window.DWW11Formatter.format(job.source);assertLive(epoch);
|
||||||
const snapshot=editorSnapshot();
|
const snapshot=editorSnapshot();
|
||||||
if(snapshot.title!==job.title)throw new Error('标题校验失败');
|
if(snapshot.title!==job.title)throw new Error('标题校验失败');
|
||||||
const aids=new Set(Object.values(result.images));
|
await window.DWW11Importer.verifyEditorImages(
|
||||||
for(const aid of aids) if(!snapshot.images.some(x=>x.id==='attachimg_'+aid))throw new Error('配图附件缺失:'+aid);
|
document.querySelector('#e_iframe').contentDocument.body, result.attachmentOrder, aborter.signal);
|
||||||
|
assertLive(epoch);
|
||||||
await event('awaiting_user_review',{snapshot});assertLive(epoch);
|
await event('awaiting_user_review',{snapshot});assertLive(epoch);
|
||||||
active={...job,state:'awaiting_user_review'};saveLocal({id:job.id});
|
active={...job,state:'awaiting_user_review'};saveLocal({id:job.id});
|
||||||
status('已完成排版,请核对内容并亲手点击“发表帖子”。');
|
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.
|
// Record only trusted user activation. Never replay a click, call submit(), or send a post request.
|
||||||
document.addEventListener('click',event=>{
|
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;
|
if(!event.isTrusted||!event.target.closest?.('#postsubmit')||active?.state!=='awaiting_user_review')return;
|
||||||
try {
|
try {
|
||||||
const snapshot=editorSnapshot();
|
const snapshot=editorSnapshot();
|
||||||
@@ -780,6 +841,35 @@ window.DWW11Formatter = Object.freeze({
|
|||||||
active.state='user_submitting';status('已观察到人工点击,等待结果页核对。');
|
active.state='user_submitting';status('已观察到人工点击,等待结果页核对。');
|
||||||
}catch(error){status('提交快照记录失败:'+error.message);}
|
}catch(error){status('提交快照记录失败:'+error.message);}
|
||||||
},true);
|
},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) {
|
async function observeResult(job) {
|
||||||
const local=loadLocal();
|
const local=loadLocal();
|
||||||
if(local?.id===job.id&&local.snapshot&&job.state==='awaiting_user_review') {
|
if(local?.id===job.id&&local.snapshot&&job.state==='awaiting_user_review') {
|
||||||
@@ -807,9 +897,10 @@ window.DWW11Formatter = Object.freeze({
|
|||||||
button.click();status('正在换号;等待插件完成并核对新账号');
|
button.click();status('正在换号;等待插件完成并核对新账号');
|
||||||
}
|
}
|
||||||
async function tick() {
|
async function tick() {
|
||||||
if(tickBusy)return;tickBusy=true;
|
if(tickBusy||skipBusy)return;tickBusy=true;
|
||||||
try {
|
try {
|
||||||
run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()});
|
run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()});
|
||||||
|
if(skipBusy)return;
|
||||||
halted=!run.running;
|
halted=!run.running;
|
||||||
if(!run.running) {aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));}
|
if(!run.running) {aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));}
|
||||||
if(working)return;
|
if(working)return;
|
||||||
@@ -823,8 +914,9 @@ window.DWW11Formatter = Object.freeze({
|
|||||||
status(run.reason || '流程已暂停');return;
|
status(run.reason || '流程已暂停');return;
|
||||||
}
|
}
|
||||||
const next=await rpc('next',{account:account(),extension:extension()});run=next.run;
|
const next=await rpc('next',{account:account(),extension:extension()});run=next.run;
|
||||||
|
if(skipBusy)return;
|
||||||
if(next.job)active=next.job;
|
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') {
|
else if(next.action==='resume') {
|
||||||
await observeResult(next.job);
|
await observeResult(next.job);
|
||||||
if(next.job.state==='awaiting_user_review') {
|
if(next.job.state==='awaiting_user_review') {
|
||||||
|
|||||||
@@ -179,12 +179,12 @@ window.DWW11Formatter = Object.freeze({
|
|||||||
const footer = existingSource(body);
|
const footer = existingSource(body);
|
||||||
const expected = `来源:${expectedSource},如有侵权,请联系删除。`;
|
const expected = `来源:${expectedSource},如有侵权,请联系删除。`;
|
||||||
if (!footer || footer.text !== expected) throw new Error('文末来源与文章包不一致,请人工检查');
|
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;
|
const backup = body.innerHTML;
|
||||||
try {
|
try {
|
||||||
footer.remove();
|
footer.remove();
|
||||||
formatBody(body, expectedSource, expected);
|
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 (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变');
|
||||||
if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常');
|
if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常');
|
||||||
body.dispatchEvent(new Event('input', {bubbles:true}));
|
body.dispatchEvent(new Event('input', {bubbles:true}));
|
||||||
|
|||||||
+63
-8
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name 广谈大湾网 · 本地文章目录导入
|
// @name 广谈大湾网 · 本地文章目录导入
|
||||||
// @namespace local.dww11.article-importer
|
// @namespace local.dww11.article-importer
|
||||||
// @version 1.0.0
|
// @version 1.0.1
|
||||||
// @description 选择整个文章目录,导入 Markdown 标题、正文与本地配图;保留人工发布与程序接口。
|
// @description 选择整个文章目录,导入 Markdown 标题、正文与本地配图;保留人工发布与程序接口。
|
||||||
// @match https://www.dww11.com/forum.php*
|
// @match https://www.dww11.com/forum.php*
|
||||||
// @match https://dww11.com/forum.php*
|
// @match https://dww11.com/forum.php*
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
else core.mount(root);
|
else core.mount(root);
|
||||||
})(typeof window === 'undefined' ? null : window, function () {
|
})(typeof window === 'undefined' ? null : window, function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
const VERSION = '1.0.0';
|
const VERSION = '1.0.1';
|
||||||
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
||||||
const IMAGE_EXT = /\.(?:jpe?g|png|gif)$/i;
|
const IMAGE_EXT = /\.(?:jpe?g|png|gif)$/i;
|
||||||
|
|
||||||
@@ -49,10 +49,11 @@
|
|||||||
const image = text.startsWith('![', start);
|
const image = text.startsWith('![', start);
|
||||||
if (!image && text[start] !== '[') return null;
|
if (!image && text[start] !== '[') return null;
|
||||||
const left = start + (image ? 2 : 1);
|
const left = start + (image ? 2 : 1);
|
||||||
let end = left;
|
let end = left, brackets = 1;
|
||||||
for (; end < text.length; end++) {
|
for (; end < text.length; end++) {
|
||||||
if (text[end] === '\\') { end++; continue; }
|
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;
|
if (text.slice(end, end + 2) !== '](') return null;
|
||||||
let pos = end + 2, depth = 1, quoted = '', angle = false;
|
let pos = end + 2, depth = 1, quoted = '', angle = false;
|
||||||
@@ -110,7 +111,7 @@
|
|||||||
let list = '', quote = false, code = false, fence = '';
|
let list = '', quote = false, code = false, fence = '';
|
||||||
const closeList = () => { if (list) output.push('[/list]'); list = ''; };
|
const closeList = () => { if (list) output.push('[/list]'); list = ''; };
|
||||||
for (let index = 0; index < lines.length; index++) {
|
for (let index = 0; index < lines.length; index++) {
|
||||||
const line = lines[index];
|
let line = lines[index];
|
||||||
const codeMark = line.match(/^\s*(`{3,}|~{3,})/);
|
const codeMark = line.match(/^\s*(`{3,}|~{3,})/);
|
||||||
if (codeMark && (!code || codeMark[1][0] === fence)) {
|
if (codeMark && (!code || codeMark[1][0] === fence)) {
|
||||||
closeList();
|
closeList();
|
||||||
@@ -118,6 +119,19 @@
|
|||||||
code = !code; fence = codeMark[1][0]; continue;
|
code = !code; fence = codeMark[1][0]; continue;
|
||||||
}
|
}
|
||||||
if (code) { output.push(line.replace(/\[\/code\]/gi, '[/code]')); 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);
|
const quoted = /^\s*>\s?/.test(line);
|
||||||
if (quoted !== quote) { closeList(); output.push(quoted ? '[quote]' : '[/quote]'); quote = quoted; }
|
if (quoted !== quote) { closeList(); output.push(quoted ? '[quote]' : '[/quote]'); quote = quoted; }
|
||||||
const content = quoted ? line.replace(/^\s*>\s?/, '') : line;
|
const content = quoted ? line.replace(/^\s*>\s?/, '') : line;
|
||||||
@@ -217,6 +231,43 @@
|
|||||||
return value.replace(/"/g, '');
|
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) {
|
function mount(win) {
|
||||||
const doc = win.document;
|
const doc = win.document;
|
||||||
const params = new URLSearchParams(win.location.search);
|
const params = new URLSearchParams(win.location.search);
|
||||||
@@ -423,7 +474,8 @@
|
|||||||
setEditor(selected.title, bbcode);
|
setEditor(selected.title, bbcode);
|
||||||
const raw = win.getEditorContents();
|
const raw = win.getEditorContents();
|
||||||
if (Number(win.wysiwyg) === 1) {
|
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('正文填写校验失败。');
|
} else if (raw !== bbcode) throw new Error('正文填写校验失败。');
|
||||||
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
|
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
|
||||||
committed = true;
|
committed = true;
|
||||||
@@ -436,7 +488,9 @@
|
|||||||
const switcher = doc.querySelector('#e_switchercheck'); if (switcher) switcher.checked = !backup.mode;
|
const switcher = doc.querySelector('#e_switchercheck'); if (switcher) switcher.checked = !backup.mode;
|
||||||
throw error;
|
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} 张配图。请检查排版,随后手动发表。`);
|
notify('complete', `已填写标题、正文和 ${aids.size} 张配图。请检查排版,随后手动发表。`);
|
||||||
return lastResult;
|
return lastResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -457,6 +511,7 @@
|
|||||||
// Public v1 interface. Staging is read-only; importPrepared is the explicit upload/fill step.
|
// Public v1 interface. Staging is read-only; importPrepared is the explicit upload/fill step.
|
||||||
// No postMessage listener, localhost service, publishing endpoint or saved credentials.
|
// No postMessage listener, localhost service, publishing endpoint or saved credentials.
|
||||||
const api = Object.freeze({ version: VERSION, schemaVersion: 1, stageFiles, stagePackage,
|
const api = Object.freeze({ version: VERSION, schemaVersion: 1, stageFiles, stagePackage,
|
||||||
|
attachmentId, verifyEditorImages,
|
||||||
getState: summary, openPicker: () => { if (!busy) $('folder').click(); }, importPrepared,
|
getState: summary, openPicker: () => { if (!busy) $('folder').click(); }, importPrepared,
|
||||||
getBackup: () => savedBackup });
|
getBackup: () => savedBackup });
|
||||||
win.DWW11Importer = api;
|
win.DWW11Importer = api;
|
||||||
@@ -476,5 +531,5 @@
|
|||||||
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
|
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
|
||||||
win.addEventListener('pagehide', releasePreviews, { once: true });
|
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 };
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name 新闻工作台 · 本地连接
|
// @name 新闻工作台 · 本地连接
|
||||||
// @namespace local.dww11.auto-news
|
// @namespace local.dww11.auto-news
|
||||||
// @version 0.1.0
|
// @version 0.1.1
|
||||||
// @match https://www.dww11.com/*
|
// @match https://www.dww11.com/*
|
||||||
// @match https://dww11.com/*
|
// @match https://dww11.com/*
|
||||||
// @match https://house.dww11.com/*
|
// @match https://house.dww11.com/*
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
// ==/UserScript==
|
// ==/UserScript==
|
||||||
(() => {
|
(() => {
|
||||||
'use strict';
|
'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 token = GM_getValue('pairToken',''), port = GM_getValue('pairPort',8770);
|
||||||
let stopRevision = 0;
|
let stopRevision = 0;
|
||||||
const signalStop = () => { stopRevision++; document.dispatchEvent(new CustomEvent('dww11:auto-stop')); };
|
const signalStop = () => { stopRevision++; document.dispatchEvent(new CustomEvent('dww11:auto-stop')); };
|
||||||
@@ -56,6 +56,10 @@
|
|||||||
const shadow=host.attachShadow({mode:'closed'});
|
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>';
|
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 $=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=()=>{
|
$('save').onclick=()=>{
|
||||||
const value=Number($('port').value);if(!Number.isInteger(value)||value<1||value>65535){$('status').textContent='端口无效';return;}
|
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;
|
if($('token').value.trim()) token=$('token').value.trim();port=value;
|
||||||
|
|||||||
+42
-5
@@ -7,6 +7,7 @@
|
|||||||
const owner=sessionStorage.getItem('auto-news-owner') || crypto.randomUUID();
|
const owner=sessionStorage.getItem('auto-news-owner') || crypto.randomUUID();
|
||||||
sessionStorage.setItem('auto-news-owner',owner);
|
sessionStorage.setItem('auto-news-owner',owner);
|
||||||
let run=null, working=false, halted=true, aborter=null, active=null, tickBusy=false;
|
let run=null, working=false, halted=true, aborter=null, active=null, tickBusy=false;
|
||||||
|
let skipBusy=false, preparation=null;
|
||||||
const pending=new Map();
|
const pending=new Map();
|
||||||
function rpc(route,body={}) {
|
function rpc(route,body={}) {
|
||||||
return new Promise((resolve,reject)=>{
|
return new Promise((resolve,reject)=>{
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
const body=document.querySelector('#e_iframe')?.contentDocument?.body;
|
const body=document.querySelector('#e_iframe')?.contentDocument?.body;
|
||||||
if(!body)throw new Error('找不到可视化编辑器');
|
if(!body)throw new Error('找不到可视化编辑器');
|
||||||
return {title:document.querySelector('#subject')?.value || '',
|
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.
|
// 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)};
|
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;}}
|
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 event(state,extra={}) {return rpc('event',{id:active.id,state,epoch:run.epoch,...extra});}
|
||||||
async function fail(error) {
|
async function fail(error) {
|
||||||
|
if(skipBusy)return;
|
||||||
status(error.message);
|
status(error.message);
|
||||||
if(active)try{await event('needs_attention',{error:error.message});}catch{}
|
if(active)try{await event('needs_attention',{error:error.message});}catch{}
|
||||||
halted=true;aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));
|
halted=true;aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));
|
||||||
@@ -84,8 +86,9 @@
|
|||||||
window.DWW11Formatter.format(job.source);assertLive(epoch);
|
window.DWW11Formatter.format(job.source);assertLive(epoch);
|
||||||
const snapshot=editorSnapshot();
|
const snapshot=editorSnapshot();
|
||||||
if(snapshot.title!==job.title)throw new Error('标题校验失败');
|
if(snapshot.title!==job.title)throw new Error('标题校验失败');
|
||||||
const aids=new Set(Object.values(result.images));
|
await window.DWW11Importer.verifyEditorImages(
|
||||||
for(const aid of aids) if(!snapshot.images.some(x=>x.id==='attachimg_'+aid))throw new Error('配图附件缺失:'+aid);
|
document.querySelector('#e_iframe').contentDocument.body, result.attachmentOrder, aborter.signal);
|
||||||
|
assertLive(epoch);
|
||||||
await event('awaiting_user_review',{snapshot});assertLive(epoch);
|
await event('awaiting_user_review',{snapshot});assertLive(epoch);
|
||||||
active={...job,state:'awaiting_user_review'};saveLocal({id:job.id});
|
active={...job,state:'awaiting_user_review'};saveLocal({id:job.id});
|
||||||
status('已完成排版,请核对内容并亲手点击“发表帖子”。');
|
status('已完成排版,请核对内容并亲手点击“发表帖子”。');
|
||||||
@@ -93,6 +96,9 @@
|
|||||||
}
|
}
|
||||||
// Record only trusted user activation. Never replay a click, call submit(), or send a post request.
|
// Record only trusted user activation. Never replay a click, call submit(), or send a post request.
|
||||||
document.addEventListener('click',event=>{
|
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;
|
if(!event.isTrusted||!event.target.closest?.('#postsubmit')||active?.state!=='awaiting_user_review')return;
|
||||||
try {
|
try {
|
||||||
const snapshot=editorSnapshot();
|
const snapshot=editorSnapshot();
|
||||||
@@ -103,6 +109,35 @@
|
|||||||
active.state='user_submitting';status('已观察到人工点击,等待结果页核对。');
|
active.state='user_submitting';status('已观察到人工点击,等待结果页核对。');
|
||||||
}catch(error){status('提交快照记录失败:'+error.message);}
|
}catch(error){status('提交快照记录失败:'+error.message);}
|
||||||
},true);
|
},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) {
|
async function observeResult(job) {
|
||||||
const local=loadLocal();
|
const local=loadLocal();
|
||||||
if(local?.id===job.id&&local.snapshot&&job.state==='awaiting_user_review') {
|
if(local?.id===job.id&&local.snapshot&&job.state==='awaiting_user_review') {
|
||||||
@@ -130,9 +165,10 @@
|
|||||||
button.click();status('正在换号;等待插件完成并核对新账号');
|
button.click();status('正在换号;等待插件完成并核对新账号');
|
||||||
}
|
}
|
||||||
async function tick() {
|
async function tick() {
|
||||||
if(tickBusy)return;tickBusy=true;
|
if(tickBusy||skipBusy)return;tickBusy=true;
|
||||||
try {
|
try {
|
||||||
run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()});
|
run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()});
|
||||||
|
if(skipBusy)return;
|
||||||
halted=!run.running;
|
halted=!run.running;
|
||||||
if(!run.running) {aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));}
|
if(!run.running) {aborter?.abort();document.dispatchEvent(new CustomEvent('dww11:auto-stop'));}
|
||||||
if(working)return;
|
if(working)return;
|
||||||
@@ -146,8 +182,9 @@
|
|||||||
status(run.reason || '流程已暂停');return;
|
status(run.reason || '流程已暂停');return;
|
||||||
}
|
}
|
||||||
const next=await rpc('next',{account:account(),extension:extension()});run=next.run;
|
const next=await rpc('next',{account:account(),extension:extension()});run=next.run;
|
||||||
|
if(skipBusy)return;
|
||||||
if(next.job)active=next.job;
|
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') {
|
else if(next.action==='resume') {
|
||||||
await observeResult(next.job);
|
await observeResult(next.job);
|
||||||
if(next.job.state==='awaiting_user_review') {
|
if(next.job.state==='awaiting_user_review') {
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ ROOT = Path(__file__).resolve().parent
|
|||||||
header = '''// ==UserScript==
|
header = '''// ==UserScript==
|
||||||
// @name 新闻工作台 · 导入排版与流程
|
// @name 新闻工作台 · 导入排版与流程
|
||||||
// @namespace local.dww11.auto-news
|
// @namespace local.dww11.auto-news
|
||||||
// @version 0.1.0
|
// @version 0.1.2
|
||||||
// @match https://www.dww11.com/*
|
// @match https://www.dww11.com/*
|
||||||
// @match https://dww11.com/*
|
// @match https://dww11.com/*
|
||||||
// @match https://house.dww11.com/*
|
// @match https://house.dww11.com/*
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const $=id=>document.getElementById(id);
|
const $=id=>document.getElementById(id);
|
||||||
let token='',state=null,loadedKey='',refreshing=false;
|
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;}
|
function notice(message){$('notice').hidden=!message;$('notice').textContent=message;}
|
||||||
async function api(route,body={}) {
|
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)});
|
const response=await fetch('/api/'+route,{method:'POST',headers:{'Content-Type':'application/json','X-Session-Token':token},body:JSON.stringify(body)});
|
||||||
|
|||||||
@@ -184,6 +184,24 @@ class Store:
|
|||||||
self._check_owner(data['owner'])
|
self._check_owner(data['owner'])
|
||||||
return {'job': self._job(db, self.run['active']) if self.run['active'] else None, 'run': self.run}
|
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):
|
def switch_begin(self, data):
|
||||||
with self.lock, self.db() as db:
|
with self.lock, self.db() as db:
|
||||||
self._check_owner(data['owner'])
|
self._check_owner(data['owner'])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
- `history/`:从 auto_news_upload 根目录移入的历史截图和演示任务 JSON,保留原内容。不是自动测试,不能用作本次测试结论。
|
- `history/`:从 auto_news_upload 根目录移入的历史截图和演示任务 JSON,保留原内容。不是自动测试,不能用作本次测试结论。
|
||||||
- `fixtures/article/`:自造 Markdown 和三张 1×1 PNG,其中两张内容相同,用于验证上传去重及插图顺序。
|
- `fixtures/article/`:自造 Markdown 和三张 1×1 PNG,其中两张内容相同,用于验证上传去重及插图顺序。
|
||||||
- `test_queue.py`:持久队列、去重、重启、终止、未知提交和按篇数换号。
|
- `test_queue.py`:持久队列、去重、重启、终止、未知提交和按篇数换号。
|
||||||
|
- `runner.test.cjs`:跳过后打开新编辑器、导入取消回滚等待、旧轮询响应、已提交保护及失败暂停。
|
||||||
- `test_controller.py`:本地接口权限、模拟下载到队列、静态页面路由。
|
- `test_controller.py`:本地接口权限、模拟下载到队列、静态页面路由。
|
||||||
- `test_download_core.py`:复制的下载器原有离线测试,针对合并项目中的下载器副本运行。
|
- `test_download_core.py`:复制的下载器原有离线测试,针对合并项目中的下载器副本运行。
|
||||||
- `importer.test.cjs`:导入器回归与自动模式的停止/冲突保护。
|
- `importer.test.cjs`:导入器回归与自动模式的停止/冲突保护。
|
||||||
|
|||||||
@@ -11,3 +11,26 @@ test('终止后较早的 heartbeat 响应不能恢复流程',async()=>{
|
|||||||
assert.equal(responses.find(r=>r.id==='old').value.running,false);assert.equal(values.halted,true);
|
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;
|
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}'});
|
||||||
|
});
|
||||||
@@ -32,8 +32,8 @@ const output=path.join(__dirname,'artifacts');fs.mkdirSync(output,{recursive:tru
|
|||||||
document.querySelector('#postsubmit').addEventListener('click',()=>window.clicks++);
|
document.querySelector('#postsubmit').addEventListener('click',()=>window.clicks++);
|
||||||
window.DWW11Importer={stagePackage:async()=>({warnings:[]}),importPrepared:async()=>{
|
window.DWW11Importer={stagePackage:async()=>({warnings:[]}),importPrepared:async()=>{
|
||||||
document.querySelector('#subject').value='测试标题';
|
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>';
|
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'}};
|
return {images:{'a.gif':'1'},attachmentOrder:['1']};
|
||||||
}};
|
}};
|
||||||
document.addEventListener('dww11:local-request',event=>{
|
document.addEventListener('dww11:local-request',event=>{
|
||||||
const {id,route,body}=JSON.parse(event.detail);let value={};
|
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})}));
|
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/formatter.js')});
|
||||||
await mock.addScriptTag({path:path.join(ROOT,'browser/runner.js')});
|
await mock.addScriptTag({path:path.join(ROOT,'browser/runner.js')});
|
||||||
await mock.waitForFunction(()=>window.job.state==='awaiting_user_review');
|
await mock.waitForFunction(()=>window.job.state==='awaiting_user_review');
|
||||||
|
|||||||
+41
-2
@@ -73,7 +73,7 @@ test('服务器返回错误、登录 HTML 或混入脚本时不猜测附件编
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Small DOM harness exercises the public API and site adapter without installing browser software.
|
// 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 = [];
|
const ids = new Map(), callbacks = new Map(), requests = [];
|
||||||
class Element {
|
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=[]; }
|
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); }
|
removeEventListener(name) { this.listeners.delete(name); }
|
||||||
dispatchEvent() {}
|
dispatchEvent() {}
|
||||||
click() { this.onclick?.(); }
|
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));
|
for (const id of ['subject', 'e_textarea', 'e_switchercheck', 'postform']) ids.set(id, new Element(id));
|
||||||
ids.get('subject').value=existing ? '原标题' : '';
|
ids.get('subject').value=existing ? '原标题' : '';
|
||||||
@@ -170,3 +177,35 @@ test('自动导入:上传中终止不会填写正文或继续下一张', async
|
|||||||
await assert.rejects(h.api.importPrepared({automatic:true,signal:c.signal}),/stopped/);
|
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,'');
|
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[\n\n### 宣传标题\n\n说明](https://example.com/promo)\n\n';
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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();}
|
||||||
|
});
|
||||||
@@ -70,6 +70,19 @@ class ControllerTests(unittest.TestCase):
|
|||||||
self.controller.scan(self.controller.manager.root)
|
self.controller.scan(self.controller.manager.root)
|
||||||
self.assertEqual(len(self.controller.store.state()['jobs']), 1)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -144,6 +144,63 @@ class QueueTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.store.next(self.owner)['action'], 'idle')
|
self.assertEqual(self.store.next(self.owner)['action'], 'idle')
|
||||||
self.assertIsNone(self.store.run['switch'])
|
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):
|
def test_bad_result_does_not_increment(self):
|
||||||
job = self.begin();self.submit(job)
|
job = self.begin();self.submit(job)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
|
|||||||
+41
-2
@@ -1,6 +1,6 @@
|
|||||||
# 本轮验证记录
|
# 本轮验证记录
|
||||||
|
|
||||||
日期:2026-10-31。测试均未使用真实账号,未向新闻 API 请求内容,未向论坛上传或发布。
|
日期:2026-10-31。下表所列离线测试均未使用真实账号,未向新闻 API 请求内容,未向论坛上传或发布。后续真实现场操作另按小节记录。
|
||||||
|
|
||||||
| 范围 | 结果 |
|
| 范围 | 结果 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -19,4 +19,43 @@
|
|||||||
|
|
||||||
测试中曾发现模拟网页未声明 UTF-8,导致中文登录入口无法匹配;已修正测试响应编码并重新通过。真实网页编码未由此假定。
|
测试中曾发现模拟网页未声明 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 个正文附件引用,不残留 `。
|
||||||
@@ -165,3 +165,41 @@ downloaded → queued → importing → formatting → awaiting_user_review
|
|||||||
- 刷新、重复读取或后来审核通过时,都用站点+主题 ID 保证受理数量只计一次。
|
- 刷新、重复读取或后来审核通过时,都用站点+主题 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`。本节是文档补录,不等于运行去重库已登记;后续对账需关联原文章身份,并统一主题标识的处理方式。
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
更新日期:2026-10-31(北京时间)。本版整体替换早期自动发帖设想,依据当前源码、[发布流程操作记录与自动化规范](发布流程操作记录与自动化规范.md)、[演示任务记录](演示任务记录.json)和[发布去重记录](发布去重记录.json)重新梳理。
|
更新日期:2026-10-31(北京时间)。本版整体替换早期自动发帖设想,依据当前源码、[发布流程操作记录与自动化规范](发布流程操作记录与自动化规范.md)、[演示任务记录](演示任务记录.json)和[发布去重记录](发布去重记录.json)重新梳理。
|
||||||
|
|
||||||
本次进行了本地源码与记录核对,未重新操作线上页面、下载新闻、上传图片或发表帖子。下文“演示已验证”引用既有操作记录;“源码已有”不等于最新版本已在线上重新验收。
|
原计划编写时仅进行了本地源码与记录核对,未重新操作线上页面、下载新闻、上传图片或发表帖子。下文“演示已验证”引用既有操作记录;“源码已有”不等于最新版本已在线上重新验收。
|
||||||
|
|
||||||
|
> 后续现场补录:2026-10-31 已采集一次真实提交提示与审核中详情页,见第 11 节。第 1~10 节保留原计划语境,组件当前能力以 README 和源码为准;本次补录不代表结果观察功能已经接通。
|
||||||
|
|
||||||
## 1. 当前目标与确定规则
|
## 1. 当前目标与确定规则
|
||||||
|
|
||||||
@@ -176,3 +178,14 @@ downloaded → queued → importing → formatting → awaiting_user_review
|
|||||||
新实现仍放在 `auto_news_upload/`,后续规划为 `service/`、`browser/`、`tests/`、`data/` 和启动说明;当前这些实现尚未建立。保留原下载目录、原历史库及现有演示记录。复用源码时记录版本,明确安装时启用的脚本组合,避免重复工具争用编辑器。
|
新实现仍放在 `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`。
|
||||||
|
- 下一步完成结果观察适配、受理接口标识核对与重复计数验证,再进行后续计数/换号联调;公开、失败和审核后的访客可见性仍待验证。
|
||||||
|
|
||||||
|
本次仅同步观察记录,未修改业务代码、运行队列或去重库;这次授权代点不扩展为后续自动发表权限,也不替代正式工作流的人工发表边界。
|
||||||
Reference in new issue
Block a user