Compare commits

...
8 Commits
29 changed files with 1781 additions and 280 deletions

No files matched your search

+56 -9
View File
@@ -1,8 +1,16 @@
# 新闻工作台 # 新闻工作台
## 2026-11-01 换号联动修复
已定位启动即提示“换号已暂停、超时或账号未改变”的两个原因:换号扩展缺少 `data-auto-news-state` 联动状态,且后端将尚未点击的旧 `pending` 换号任务也按 120 秒超时处理。现已补齐扩展生命周期状态及终止事件监听;待点击任务不再按登录超时处理,缺少联动接口时给出明确提示。
应用修复需要重启本地服务,在扩展管理页重新加载原安装目录 `dww11-account-switch-browser-extension/` 中的扩展,再刷新论坛。不要卸载重装、清空账号库或删除任务库。原来已手动换到新账号时,接口就绪后会核对新 UID 和插件空闲状态,再建立新批次,不会为了恢复旧任务再次抽号。此次未改动运行数据库或真实账号,尚需重新加载后的实机验证。
离线回归:`python3 -m unittest discover -s test -p test_queue.py -v`;`node test/extension-bridge.test.cjs`。本机旧版 Node 的 `node --test` 汇总模式解析中文测试名称失败,可逐个直接执行 `.test.cjs` 文件。
将新闻下载、文章打包、浏览器导入、排版与换号联动整合到本地控制台。 将新闻下载、文章打包、浏览器导入、排版与换号联动整合到本地控制台。
**当前版本:0.1。可完成下载 → 入队 → 导入 → 排版 → 等待人工发表。结果页自动核对按用户决定暂留待接入;人工提交后会记录候选结果并暂停,不会自动计数、换号或处理下一篇。** 换号调度和计数接口已完成离线验证,完整在线链路仍需补齐结果页 HTML 后验证。 **当前版本:0.2.2。下载 → 入队 → 导入 → 排版 → 人工发表 → 网站审核提示 → 受理记账 → 回首页 → 下一篇或换号。** 暂时只以网站提示“新主题需要审核,您的帖子通过审核后才能显示”判定发表成功,状态仍记为已受理、审核中。原详情页标题、作者、版块与图文核对代码已注释保留,当前不执行。
## 启动 ## 启动
@@ -20,7 +28,7 @@ uv run --no-project --python 3.12 --with-requirements requirements.txt python ap
1. 在油猴安装控制台“浏览器连接”区域的两个脚本: 1. 在油猴安装控制台“浏览器连接”区域的两个脚本:
- `browser/local-bridge.user.js`:本地连接、配对与常驻终止按钮。 - `browser/local-bridge.user.js`:本地连接、配对与常驻终止按钮。
- `browser/auto-news.user.js`:导入、排版和任务执行。它由 `importer.js`、`formatter.js`、`runner.js` 构建。 - `browser/auto-news.user.js`:导入、排版和任务执行。它由 `importer.js`、`formatter.js`、`results.js`、`runner.js` 构建。
2. 停用旧的独立导入与排版油猴脚本,避免同一编辑器重复挂载。 2. 停用旧的独立导入与排版油猴脚本,避免同一编辑器重复挂载。
3. 换号插件仍由用户安装,位于项目根目录的 `dww11-account-switch-browser-extension/`,没有复制到本目录。本次在其 `content.js` 中增加了不含凭据的状态属性及终止事件监听。请在浏览器扩展管理页重新加载**这个原安装目录**,再刷新网站。旧 ZIP 未重新打包,不包含本次修改。 3. 换号插件仍由用户安装,位于项目根目录的 `dww11-account-switch-browser-extension/`,没有复制到本目录。本次在其 `content.js` 中增加了不含凭据的状态属性及终止事件监听。请在浏览器扩展管理页重新加载**这个原安装目录**,再刷新网站。旧 ZIP 未重新打包,不包含本次修改。
4. 在控制台点击“显示本次连接码”。在论坛左下角“新闻工作台 · 本地连接”展开设置,填写端口和连接码并保存。 4. 在控制台点击“显示本次连接码”。在论坛左下角“新闻工作台 · 本地连接”展开设置,填写端口和连接码并保存。
@@ -35,12 +43,15 @@ uv run --no-project --python 3.12 --with-requirements requirements.txt python ap
3. 填写目标版块 `fid`、每账号最少/最多篇数和处理间隔。先核对历史发帖记录,再勾选确认并保存。版块和篇数不会替用户选择。 3. 填写目标版块 `fid`、每账号最少/最多篇数和处理间隔。先核对历史发帖记录,再勾选确认并保存。版块和篇数不会替用户选择。
4. 登录论坛,建议只保留一个执行标签。点击控制台“开始 / 继续”。执行器识别账号后领取一篇任务,进入指定新帖页、导入、排版,停止在人工审核阶段。 4. 登录论坛,建议只保留一个执行标签。点击控制台“开始 / 继续”。执行器识别账号后领取一篇任务,进入指定新帖页、导入、排版,停止在人工审核阶段。
5. 在论坛检查账号、版块、标题、图片位置、正文和来源,并亲手点击“发表帖子”。程序不调用发帖提交函数,不发送发帖请求,也不自动保存草稿。 5. 在论坛检查账号、版块、标题、图片位置、正文和来源,并亲手点击“发表帖子”。程序不调用发帖提交函数,不发送发帖请求,也不自动保存草稿。
6. 当前版本会记录人工点击和提交快照,在跳转后的候选页面或等待超时后标记“发布结果待核对”并暂停。**需要补充结果页 HTML 后,才能自动核对受理结果并接续下一篇。** 6. 人工点击时保存最终提交快照。观察网站提示“新主题需要审核,您的帖子通过审核后才能显示”即登记受理一次;暂不要求详情页标题、作者、版块、主题 ID 或图文匹配,不猜测帖子 ID。
7. 先持久记账与去重,再回首页;在设定间隔后处理下一篇。达到账号目标且仍有待处理文章时调用换号插件,确认新 UID 和插件空闲后创建下一批次。队列为空直接结束;暂停或终止后仅记账,不自动继续。
待补齐结果页自动核对后:审核中算网站已受理一次,但不算已确认公开;达到账号随机目标篇数且仍有待处理文章时,回首页调用插件换号,核对新 UID 与插件空闲状态后创建下一账号批次。没有待处理文章时直接结束,不为达到数量重发。 8. 等待超过 45 秒仍未观察到审核提示则暂停,不重复发表。仅有详情页“审核中”标志不再触发自动记账;旧的待核对任务需人工处理,不能凭此次规则改动推定成功。
9. 已有现场结果可在暂停后使用队列中的“补核已受理结果”导入观察 JSON,并人工确认文章关联。支持本项目 `local-records/publish-result-*.json` 结构,字段含 title、uid、fid、threadId、postId、url、status;补核须与已有人工提交快照匹配,不增加当前批次计数。历史补核不会自动检查发布后的图文完整性。
## 终止、恢复与冲突 ## 终止、恢复与冲突
- 队列支持勾选单篇或全选可移除文章,然后点击“移除所选文章”。请先暂停流程;移除只删除队列记录,原始文章、图片、已受理去重记录和计数保留。当前任务或提交结果未核对的文章须先核对并处理;未受理文章移除后可通过扫描目录重新入队。
- 网站左下角悬浮窗提供“跳过本文章”:将当前未提交文章标记为“已跳过”,保留队列记录、不计发表篇数,并打开新的发帖页继续导入排版下一篇。导入中会先取消并等待回滚;已上传附件可能仍保留在网站。最后一篇跳过后流程结束。已点击发表或结果不明的文章不能直接跳过,须先人工核对。已跳过文章不会自动重新入队,需要重试时可在暂停后通过队列“人工核对 / 处理”重新排队。
- 网站左下角常驻“终止流程”,点击后立即取消本标签可取消的导入,并通知其他安装脚本的标签和换号插件;持久状态禁止继续领取任务。 - 网站左下角常驻“终止流程”,点击后立即取消本标签可取消的导入,并通知其他安装脚本的标签和换号插件;持久状态禁止继续领取任务。
- 控制台“终止流程”还会停止下载,浏览器在下一次连接检查时停止(正常轮询约 2 秒)。网络断开时无法承诺固定延迟;网站按钮可立即停止本标签。已到达网站的图片、登录或发帖请求无法撤回。 - 控制台“终止流程”还会停止下载,浏览器在下一次连接检查时停止(正常轮询约 2 秒)。网络断开时无法承诺固定延迟;网站按钮可立即停止本标签。已到达网站的图片、登录或发帖请求无法撤回。
- 暂停/终止后必须明确点击“开始 / 继续”。下载停止等待当前网络请求/单篇保存结束,不伪装成已经撤回在途请求。 - 暂停/终止后必须明确点击“开始 / 继续”。下载停止等待当前网络请求/单篇保存结束,不伪装成已经撤回在途请求。
@@ -66,7 +77,7 @@ downloads/ 本工具下载结果(本地)
- `test/history/` 保存从本目录移入的两张演示截图与演示任务 JSON。根目录其他测试文件未搬动。 - `test/history/` 保存从本目录移入的两张演示截图与演示任务 JSON。根目录其他测试文件未搬动。
- `发布去重记录.json` 是实际受理证据,继续留在原处,并在服务启动时迁入去重库。它不代表完整历史已对账。原文件可找到时还建立内容指纹;原文件丢失时仍保留原 Markdown 摘要。 - `发布去重记录.json` 是实际受理证据,继续留在原处,并在服务启动时迁入去重库。它不代表完整历史已对账。原文件可找到时还建立内容指纹;原文件丢失时仍保留原 Markdown 摘要。
- 原始下载文件不改写。长标题生成保守 UTF-16 长度不超过 80 的派生标题并加 `...`,原题仍展示在队列;网站真实计数规则尚需实测。 - 原始下载文件不改写。标题按 UTF-8 字节数计长:ASCII 为 1,中文和中文标点为 3,emoji 通常为 4。超过 75 时保留不超过 75 的完整前缀,再加 `...`,总长最多 78;原题仍保留。
- 排版仅处理与文章来源一致的末尾标准尾注,保留正文中的其他“来源”文字。图片顺序、标识改变会回滚排版。 - 排版仅处理与文章来源一致的末尾标准尾注,保留正文中的其他“来源”文字。图片顺序、标识改变会回滚排版。
- WebP/AVIF/SVG 当前明确拦截,需用户另存支持格式并更新 Markdown;没有静默丢图或自动转码。单图须小于 20 MiB,图片 base64 总大小不超过 48 MiB。 - WebP/AVIF/SVG 当前明确拦截,需用户另存支持格式并更新 Markdown;没有静默丢图或自动转码。单图须小于 20 MiB,图片 base64 总大小不超过 48 MiB。
- 本地库含文章内容快照和账号标识,不含账号密码、Cookie 或上传凭据。运行数据和历史截图已加入忽略规则。 - 本地库含文章内容快照和账号标识,不含账号密码、Cookie 或上传凭据。运行数据和历史截图已加入忽略规则。
@@ -77,7 +88,9 @@ 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
# 另需 Playwright 与 Chrome,网络请求全部阻断
node --test test/results-browser.test.cjs
python3 build_browser.py python3 build_browser.py
``` ```
@@ -92,6 +105,40 @@ 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`。
本次已完成审核中结果适配,并只读核对现有帖子的作者、版块和延迟加载图片结构。仍需公开成功页、更多明确失败提示及其他版块样本,未知结果保守暂停;当前明确拒绝分支只支持标题超限提示。不要提供 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),重启本地工作台、重新配对并刷新论坛后使用。按钮位于网站左下角悬浮窗、“终止流程”上方。
## 0.2.0 结果核对与接续
更新油猴中的 `browser/auto-news.user.js` 至 0.2.0,连接脚本沿用现有 0.1.1。更新本地代码后重启服务、重新配对并刷新论坛;不会自动继续任务。新增真实 TID 与 PID 分离、链接别名防重、被动结果恢复、图文差异暂停、历史结果补核,以及记账后回首页的持久步骤。旧受理记录如只有路径数字会保留原值并标记主题身份待核对,不猜测删除 URL 后缀。
换号过程中插件暂停、超过 120 秒未确认、或执行后超过 10 秒插件空闲但 UID 未变化,流程会暂停;请在插件内核对并恢复,不自动重复抽号。图片 HTTP 500 的服务端根因仍未解决。
## 0.2.1 临时审核提示规则
更新油猴中的 `browser/auto-news.user.js` 至 0.2.1,并重启本地服务、重新配对后刷新论坛页面。连接脚本无需更新。结果适配器和执行器中的旧详情页核对代码以行注释保留;后端旧核对接口继续保留供人工历史补核使用。自动受理使用当前任务的人工提交批次校验,审核提示可不含帖子链接,不创建虚构帖子 ID。
## 0.2.2 悬浮窗控制与跳过图片检查
自动流程不再等待编辑器图片加载,也不检查排版前后图片标识和顺序。原检查函数保留,独立手动导入仍沿用默认检查。图片上传与本地文件格式限制继续用于构建可导入的文章。人工发表后只认网站指定审核提示,持久记账后回首页,按已配置间隔继续下一篇或换号。
论坛悬浮窗新增“开始 / 继续”和“暂停”,与工作台控制相同。开始前须在工作台保存处理规则;暂停会立即取消本标签可取消的操作,并通知本地服务与其他标签。更新两个油猴脚本:导入排版 0.2.2、本地连接 0.1.2;重启本地服务并重新配对。
## 标题自动截断
自动队列、手动目录和文章包导入均应用上述规则;已截断的标题不会重复添加省略号。已有队列在领取未开始的任务时更新标题,已导入、待审核或已提交任务保持现场标题。
应用更新:重启本地服务,并在油猴重新安装 `browser/auto-news.user.js`。原始 Markdown 不改写。
+7 -2
View File
@@ -87,8 +87,12 @@ class Controller:
if route == 'stop': if route == 'stop':
self.manager.stop.set() self.manager.stop.set()
return self.store.control(route) return self.store.control(route)
if route == 'reconcile':
return self.store.reconcile(body)
if route == 'resolve': if route == 'resolve':
return self.store.resolve(body) return self.store.resolve(body)
if route == 'remove':
return self.store.remove(body)
if route == 'history': if route == 'history':
records = body.get('records') records = body.get('records')
if not isinstance(records, list): if not isinstance(records, list):
@@ -100,10 +104,11 @@ 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 in ('start', 'pause', 'stop'):
if route == 'stop': if route == 'stop':
self.manager.stop.set() self.manager.stop.set()
return self.store.control('stop') return self.store.control(route)
if route not in methods: if route not in methods:
raise ValueError('未知浏览器操作') raise ValueError('未知浏览器操作')
return methods[route](body) return methods[route](body)
+7 -4
View File
@@ -56,15 +56,18 @@ def validate_images(body, directory):
raise ValueError('配图为空或达到 20 MiB:' + target) raise ValueError('配图为空或达到 20 MiB:' + target)
def short_title(title, budget=80): def short_title(title, budget=75):
# Conservative UTF-16 budget: do not split astral characters / surrogate pairs. """Keep a UTF-8 prefix within budget, adding three ASCII dots if shortened."""
if len(title.encode('utf-16-le')) // 2 <= budget: if len(title.encode('utf-8')) <= budget or (title.endswith('...') and len(title.encode('utf-8')) <= budget + 3):
return title return title
out = '' out = ''
used = 0
for ch in title: for ch in title:
if len((out + ch).encode('utf-16-le')) // 2 > budget - 3: size = len(ch.encode('utf-8'))
if used + size > budget:
break break
out += ch out += ch
used += size
return out + '...' return out + '...'
+262 -38
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name 新闻工作台 · 导入排版与流程 // @name 新闻工作台 · 导入排版与流程
// @namespace local.dww11.auto-news // @namespace local.dww11.auto-news
// @version 0.1.0 // @version 0.2.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;
@@ -139,12 +153,31 @@
return output.join('\n').trim(); return output.join('\n').trim();
} }
function shortTitle(title, budget = 75) {
const size = ch => {
const code = ch.codePointAt(0);
return code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
};
// A generated prefix plus "..." (up to 78 bytes) is already safe.
const total = Array.from(title).reduce((sum, ch) => sum + size(ch), 0);
if (total <= budget || (title.endsWith('...') && total <= budget + 3)) return title;
let prefix = '', used = 0;
for (const ch of title) {
const bytes = size(ch);
if (used + bytes > budget) break;
prefix += ch;
used += bytes;
}
return prefix + '...';
}
function compile(markdown, mdPath, fileMap, titleOverride) { function compile(markdown, mdPath, fileMap, titleOverride) {
let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
const firstHeading = body.match(/^\s*#\s+([^\n]+)\n?/); const firstHeading = body.match(/^\s*#\s+([^\n]+)\n?/);
const title = String(titleOverride || firstHeading?.[1] || mdPath.split('/').pop().replace(/\.md$/i, '')).trim(); const originalTitle = String(titleOverride || firstHeading?.[1] || mdPath.split('/').pop().replace(/\.md$/i, '')).trim();
const title = shortTitle(originalTitle);
if (firstHeading) body = body.slice(firstHeading[0].length).trim(); if (firstHeading) body = body.slice(firstHeading[0].length).trim();
if (!title || Array.from(title).length > 80) throw new Error('标题为空或超过 80 字,请修改 Markdown 标题后重试。'); if (!title) throw new Error('标题为空,请修改 Markdown 标题后重试。');
if (!body.trim()) throw new Error('正文为空。'); if (!body.trim()) throw new Error('正文为空。');
const warnings = []; const warnings = [];
if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。'); if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。');
@@ -217,6 +250,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);
@@ -369,7 +439,7 @@
if (switcher) switcher.checked = Number(win.wysiwyg) === 0; if (switcher) switcher.checked = Number(win.wysiwyg) === 0;
} }
async function importPrepared(options = {}) { async function importPrepared(options = {}) {
const { signal, automatic = false } = options; const { signal, automatic = false, verifyImages = true } = options;
signal?.throwIfAborted(); signal?.throwIfAborted();
if (busy) throw new Error('正在导入,请勿重复点击。'); if (busy) throw new Error('正在导入,请勿重复点击。');
if (!article) throw new Error('请先选择文章目录或文章包。'); if (!article) throw new Error('请先选择文章目录或文章包。');
@@ -422,9 +492,10 @@
signal?.throwIfAborted(); signal?.throwIfAborted();
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 && verifyImages) {
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]);
} else if (raw !== bbcode) throw new Error('正文填写校验失败。'); await verifyEditorImages(doc.querySelector('#e_iframe')?.contentDocument?.body || win.editdoc?.body, expected, signal);
} else if (Number(win.wysiwyg) !== 1 && 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;
} catch (error) { } catch (error) {
@@ -436,7 +507,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 +530,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 +550,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, shortTitle, 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.
@@ -653,20 +727,20 @@ const cleanText = value => String(value || '').replace(/\s+/g, ' ').trim();
} }
window.DWW11Formatter = Object.freeze({ window.DWW11Formatter = Object.freeze({
format(expectedSource) { format(expectedSource, {verifyImages = true} = {}) {
if (Number(window.wysiwyg) !== 1) throw new Error('请切换到可视化编辑器后排版'); if (Number(window.wysiwyg) !== 1) throw new Error('请切换到可视化编辑器后排版');
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('编辑器未就绪');
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 (verifyImages && 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}));
return {images: after, source: expected}; return {images: after, source: expected};
@@ -675,6 +749,67 @@ window.DWW11Formatter = Object.freeze({
}); });
})(); })();
/* Read-only result adapter. Login state and URLs alone are never acceptance evidence. */
(function(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
else root.DWW11Results=api;
})(typeof window==='undefined'?null:window,()=>{
'use strict';
const clean=value=>String(value||'').replace(/\s+/g,' ').trim();
function parameter(href,key,base){try{return new URL(href,base).searchParams.get(key);}catch{return null;}}
function aid(image){return String(image.getAttribute('aid')||image.id||'').match(/(?:attachimg_|aimg_)?(\d+)$/)?.[1]||null;}
function inspect(doc,url,snapshot={}) {
const prompt=doc.querySelector('#messagetext, .messagetext');
const text=clean(prompt?.textContent);
const message='新主题需要审核,您的帖子通过审核后才能显示';
if(text.includes(message))return {
kind:'accepted_prompt',state:'accepted_pending_moderation',
evidence:{adapter:'moderation_prompt',message,observed_text:text,observed_url:url}
};
if(/^(?:抱歉[,,]\s*)?(?:您填写的)?(?:帖子)?标题.{0,40}(?:超过|超出).{0,40}(?:字符|长度|限制)/.test(text))return {kind:'rejected',error:text};
return {kind:'waiting',prompt:text};
// 暂停详情页核对:按用户要求注释保留,待恢复时启用。
// const titleNode=doc.querySelector('#thread_subject');
// if(!titleNode) {
// const prompt=doc.querySelector('#messagetext, .messagetext');
// const text=clean(prompt?.textContent);
// if(/^(?:抱歉[,,]\s*)?(?:您填写的)?(?:帖子)?标题.{0,40}(?:超过|超出).{0,40}(?:字符|长度|限制)/.test(text))return {kind:'rejected',error:text};
// return {kind:'waiting',prompt:text,candidate:prompt?.querySelector('a[href]')?.href||''};
// }
// const region=titleNode.closest('td')||titleNode.closest('h1')?.parentElement;
// if(!region)return {kind:'incomplete',error:'缺少标题状态区域'};
// const ids=new Set([...region.querySelectorAll('a[href]')].filter(a=>parameter(a.href,'mod',url)==='viewthread').map(a=>parameter(a.href,'tid',url)).filter(x=>/^\d+$/.test(x)));
// if(ids.size!==1)return {kind:'incomplete',error:'详情页真实主题编号缺失或冲突'};
// const thread_id=[...ids][0],body=doc.querySelector('[id^="postmessage_"]');
// const pid=body?.id.match(/^postmessage_(\d+)$/)?.[1];
// const post=pid&&doc.querySelector('#post_'+pid);
// if(!post||clean(post.querySelector('#postnum'+pid)?.textContent)!=='楼主')return {kind:'incomplete',error:'无法确认当前正文属于首帖'};
// const authors=post?[...post.querySelectorAll('.plc .pti .authi a[href], .pls .authi a[href]')]:[];
// const uids=new Set(authors.map(a=>parameter(a.href,'uid',url)).filter(x=>/^\d+$/.test(x)));
// const fids=new Set([...doc.querySelectorAll('#pt a[href], .mumucms_pt a[href]')].filter(a=>parameter(a.href,'mod',url)==='forumdisplay').map(a=>parameter(a.href,'fid',url)).filter(x=>/^\d+$/.test(x)));
// if(!body||!pid||uids.size!==1||fids.size!==1)return {kind:'incomplete',error:'首帖作者、正文或版块证据不足'};
// const moderation=[...region.querySelectorAll('span.xg1')].some(n=>/\(\s*审核中\s*\)|(\s*审核中\s*)/.test(n.textContent));
// // A public-page sample is not available yet. Absence of moderation is not proof of publication.
// if(!moderation)return {kind:'incomplete',error:'尚无可验证的公开或审核中状态'};
// const allImages=[...body.querySelectorAll('img')];
// const images=allImages.filter(img=>aid(img)).map(img=>({aid:aid(img),src:img.getAttribute('src'),loaded:img.complete&&img.naturalWidth>0&&!/\/none\.gif(?:[?#]|$)/.test(img.src||img.getAttribute('src')||''),file:img.getAttribute('file')||img.getAttribute('zoomfile')}));
// const expected=(snapshot.images||[]).map(img=>String(img.aid||img.id||'').match(/(?:attachimg_|aimg_)?(\d+)$/)?.[1]).filter(Boolean);
// const issues=[];
// if(allImages.some(img=>!aid(img)))issues.push('正文存在无法关联附件的图片');
// if(expected.length!==(snapshot.images||[]).length)issues.push('提交快照存在无法识别附件编号的图片');
// if(JSON.stringify(expected)!==JSON.stringify(images.map(img=>img.aid)))issues.push('首帖正文图片引用或顺序与提交快照不一致');
// if(images.some(img=>!img.loaded))issues.push('结果页正文图片尚未加载或加载失败');
// if(snapshot.text&&clean(body.innerText||body.textContent)!==clean(snapshot.text))issues.push('结果正文与最终提交快照存在文字差异');
// const attachments=post?[...post.querySelectorAll('.pattl img, .attm img')].map(img=>({aid:aid(img),src:img.getAttribute('src')})):[];
// if(attachments.length)issues.push('结果页存在附件区图片,请核对正文位置');
// return {kind:'accepted',thread_id,first_post_pid:pid,post_url:url,title:clean(titleNode.textContent),author_uid:[...uids][0],fid:Number([...fids][0]),state:'accepted_pending_moderation',
// evidence:{adapter:1,thread_id,first_post_pid:pid,title:clean(titleNode.textContent),author_uid:[...uids][0],fid:Number([...fids][0]),moderation:true,images,attachments,integrity_issues:issues}};
}
return {inspect,clean};
});
/* Page-side orchestrator. This file never clicks or submits the publication form. */ /* Page-side orchestrator. This file never clicks or submits the publication form. */
(() => { (() => {
'use strict'; 'use strict';
@@ -684,6 +819,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, stopRevision=0 /*, resultSeenAt=0 */;
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)=>{
@@ -700,7 +836,7 @@ window.DWW11Formatter = Object.freeze({
data.error?waiter.reject(new Error(data.error)):waiter.resolve(data.value); data.error?waiter.reject(new Error(data.error)):waiter.resolve(data.value);
}); });
const status=message=>document.dispatchEvent(new CustomEvent('dww11:connection-status',{detail:message})); const status=message=>document.dispatchEvent(new CustomEvent('dww11:connection-status',{detail:message}));
document.addEventListener('dww11:auto-stop',()=>{halted=true;aborter?.abort(new Error('流程已终止'));}); document.addEventListener('dww11:auto-stop',()=>{stopRevision++;halted=true;aborter?.abort(new Error('流程已终止'));});
function account() { function account() {
const element=document.querySelector('#mumucms_username'); const element=document.querySelector('#mumucms_username');
if(!element)return null; if(!element)return null;
@@ -725,7 +861,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 +869,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'));
@@ -755,14 +892,16 @@ window.DWW11Formatter = Object.freeze({
const prepared=await window.DWW11Importer.stagePackage(pack);assertLive(epoch); const prepared=await window.DWW11Importer.stagePackage(pack);assertLive(epoch);
if(prepared.warnings?.length)throw new Error('文章需要人工处理:'+prepared.warnings.join(';')); if(prepared.warnings?.length)throw new Error('文章需要人工处理:'+prepared.warnings.join(';'));
await event('importing');assertLive(epoch);status('上传图片并填写文章…'); await event('importing');assertLive(epoch);status('上传图片并填写文章…');
const result=await window.DWW11Importer.importPrepared({automatic:true,signal:aborter.signal});assertLive(epoch); const result=await window.DWW11Importer.importPrepared({automatic:true,verifyImages:false,signal:aborter.signal});assertLive(epoch);
if(result?.cancelled)throw new Error('导入已取消'); if(result?.cancelled)throw new Error('导入已取消');
await event('formatting');assertLive(epoch); await event('formatting');assertLive(epoch);
window.DWW11Formatter.format(job.source);assertLive(epoch); window.DWW11Formatter.format(job.source,{verifyImages:false});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)); // 按用户要求暂停图片检查;保留原调用以便恢复。
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); 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,30 +909,89 @@ 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();
const local={id:active.id,snapshot,clickedAt:Date.now(),observed:false};saveLocal(local); const local={id:active.id,snapshot,clickedAt:Date.now(),observed:false};saveLocal(local);
rpc('event',{id:active.id,state:'user_submitting',epoch:run?.epoch,snapshot}) rpc('event',{id:active.id,state:'user_submitting',epoch:run?.epoch,snapshot})
.then(()=>{const latest=loadLocal();if(latest?.clickedAt===local.clickedAt)saveLocal({...local,observed:true});}) .then(()=>{const latest=loadLocal();if(latest?.clickedAt===local.clickedAt)saveLocal({...latest,observed:true});})
.catch(error=>status('提交记录待恢复:'+error.message)); .catch(error=>status('提交记录待恢复:'+error.message));
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') {
await rpc('event',{id:job.id,state:'user_submitting',epoch:run.epoch,snapshot:local.snapshot}); await rpc('event',{id:job.id,state:'user_submitting',epoch:run.epoch,snapshot:local.snapshot});
job.state='user_submitting'; job.state='user_submitting';job.snapshot=local.snapshot;
} }
if(job.state==='user_submitting') { if(!['user_submitting','submit_unknown'].includes(job.state))return;
// Result selectors intentionally await user-provided HTML; URL alone is not proof. const snapshot=job.snapshot||local?.snapshot||{};
if(!editorPage(job) || (local?.clickedAt && Date.now()-local.clickedAt>45000)) { const result=(local?.id===job.id&&local.promptResult)||window.DWW11Results?.inspect(document,location.href,snapshot);
await rpc('event',{id:job.id,state:'submit_unknown',error:'等待结果页 HTML 适配;候选页面:'+location.href}); if(result?.kind==='rejected'&&job.state==='user_submitting') {
await rpc('stop'); await rpc('event',{id:job.id,state:'rejected',error:result.error});
status('已记录候选结果页;尚不能自动核对。流程已暂停,请补充结果页 HTML。'); status('网站明确拒绝本次提交,已暂停;核对并处理稿件后仍需亲手发表');return;
} }
}else if(job.state==='submit_unknown')status('发布结果待核对;不会重发、计数或换号。'); // 暂停详情页标题、作者、版块和图文核对;保留原代码。
// if(result?.kind==='accepted') {
// if(result.author_uid!==job.account.uid||result.fid!==job.fid||result.title!==window.DWW11Results.clean(snapshot.title)) {
// if(job.state==='user_submitting')await rpc('event',{id:job.id,state:'submit_unknown',error:'结果页标题、首帖作者或版块与提交任务不符'});
// await rpc('stop');status('结果不匹配,已暂停,请人工核对');return;
// }
// resultSeenAt ||= Date.now();
// if(result.evidence.integrity_issues.some(x=>x.includes('尚未加载'))&&Date.now()-resultSeenAt<15000) {
// status('已看到审核中详情,等待正文图片加载后核对');return;
// }
if(result?.kind==='accepted_prompt') {
const revision=stopRevision,epoch=run.epoch;
await rpc('accept',{id:job.id,attempt:job.attempt,...result});
sessionStorage.removeItem(SLOT);active=null;
// Re-read persistent control after accounting; Stop must suppress navigation and subsequent jobs.
run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()});
halted=!run.running;
if(!halted&&revision===stopRevision&&run.epoch===epoch)location.assign('https://www.dww11.com/');
else status('受理结果已保存;流程已暂停,请核对后明确继续');
return;
}
const clickedAt=local?.clickedAt||Number(job.submitted_at||0)*1000;
if(clickedAt&&Date.now()-clickedAt<45000) {
status(result?.prompt||result?.error||'等待网站提交结果,不会重复发表');return;
}
if(job.state==='user_submitting')await rpc('event',{id:job.id,state:'submit_unknown',error:result?.error||'未观察到网站新主题审核提示,请人工核对是否已受理'});
if(run.running)await rpc('stop');
status('未观察到网站审核提示,已暂停;请人工核对,不会重发');
} }
async function switchAccount() { async function switchAccount() {
if(working||halted)return; if(working||halted)return;
@@ -801,15 +999,17 @@ window.DWW11Formatter = Object.freeze({
location.assign('https://www.dww11.com/');return; location.assign('https://www.dww11.com/');return;
} }
const ext=extension(),button=document.querySelector('#dww11-extension-next'); const ext=extension(),button=document.querySelector('#dww11-extension-next');
if(!ext.ready||!ext.idle||!button||button.disabled) {status('请安装并重新加载带联动接口的换号插件');return;} if(!ext.ready||!ext.idle||!button||button.disabled) {await rpc('stop');status('换号插件未就绪,流程已暂停,请检查插件后继续');return;}
await rpc('switch-begin'); const revision=stopRevision,epoch=run.epoch;
if(halted||!run?.running)return; await rpc('switch-begin',{epoch});
if(halted||!run?.running||revision!==stopRevision)return;
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;
@@ -822,9 +1022,16 @@ window.DWW11Formatter = Object.freeze({
if(run.active && run.owner===owner) {const saved=await rpc('current');if(saved.job) {active=saved.job;await observeResult(saved.job);}} if(run.active && run.owner===owner) {const saved=await rpc('current');if(saved.job) {active=saved.job;await observeResult(saved.job);}}
status(run.reason || '流程已暂停');return; status(run.reason || '流程已暂停');return;
} }
const next=await rpc('next',{account:account(),extension:extension()});run=next.run; if(run.active&&run.owner===owner) {
const saved=await rpc('current');
if(saved.job&&['user_submitting','submit_unknown'].includes(saved.job.state)) {active=saved.job;await observeResult(saved.job);return;}
}
const revision=stopRevision,epoch=run.epoch;
const next=await rpc('next',{account:account(),extension:extension(),url:location.href});run=next.run;
if(revision!==stopRevision||run.epoch!==epoch)return;
if(skipBusy)return;
if(next.job)active=next.job; if(next.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') {
@@ -833,6 +1040,8 @@ window.DWW11Formatter = Object.freeze({
} }
else if(['importing','formatting'].includes(next.job.state))await fail(new Error('导入阶段发生中断,请检查编辑器和已上传图片后处理任务')); else if(['importing','formatting'].includes(next.job.state))await fail(new Error('导入阶段发生中断,请检查编辑器和已上传图片后处理任务'));
else if(next.job.state==='needs_attention')status(next.job.error); else if(next.job.state==='needs_attention')status(next.job.error);
}else if(next.action==='home') {
if(!halted&&run.running)location.assign('https://www.dww11.com/');
}else if(next.action==='switch')await switchAccount(); }else if(next.action==='switch')await switchAccount();
else if(next.action==='switch_wait')status('等待换号结果;若插件已暂停,请人工处理后继续'); else if(next.action==='switch_wait')status('等待换号结果;若插件已暂停,请人工处理后继续');
else if(next.action==='login')status('未能读取当前账号 UID,请登录并确认账号元素'); else if(next.action==='login')status('未能读取当前账号 UID,请登录并确认账号元素');
@@ -846,5 +1055,20 @@ window.DWW11Formatter = Object.freeze({
takeover.style.cssText='position:fixed;left:16px;top:16px;z-index:2147483646;padding:8px;background:#fff;border:1px solid #aab;border-radius:6px'; takeover.style.cssText='position:fixed;left:16px;top:16px;z-index:2147483646;padding:8px;background:#fff;border:1px solid #aab;border-radius:6px';
takeover.onclick=async()=>{try{await rpc('takeover');status('已接管;核对当前页面后在工作台继续');}catch(error){status(error.message);}}; takeover.onclick=async()=>{try{await rpc('takeover');status('已接管;核对当前页面后在工作台继续');}catch(error){status(error.message);}};
document.body.append(takeover); document.body.append(takeover);
// 提示可能短暂出现后跳转;及时保存到本次任务,避免两秒轮询漏掉。
function capturePrompt() {
const local=loadLocal();
if(!local?.id||!local.snapshot||!local.clickedAt||local.promptResult)return false;
if(active&&(active.id!==local.id||!['user_submitting','submit_unknown'].includes(active.state)))return false;
const result=window.DWW11Results?.inspect(document,location.href,local.snapshot);
if(result?.kind!=='accepted_prompt')return false;
saveLocal({...local,promptResult:result});
return true;
}
if(typeof MutationObserver!=='undefined')new MutationObserver(()=>{
if(capturePrompt())void tick();
}).observe(document.body,{childList:true,subtree:true,characterData:true});
// 跳转后的提示页可能已经渲染完成,首次轮询前也捕获一次。
capturePrompt();
setInterval(tick,2000);tick(); setInterval(tick,2000);tick();
})(); })();
+4 -4
View File
@@ -172,20 +172,20 @@ const cleanText = value => String(value || '').replace(/\s+/g, ' ').trim();
} }
window.DWW11Formatter = Object.freeze({ window.DWW11Formatter = Object.freeze({
format(expectedSource) { format(expectedSource, {verifyImages = true} = {}) {
if (Number(window.wysiwyg) !== 1) throw new Error('请切换到可视化编辑器后排版'); if (Number(window.wysiwyg) !== 1) throw new Error('请切换到可视化编辑器后排版');
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('编辑器未就绪');
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 (verifyImages && 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}));
return {images: after, source: expected}; return {images: after, source: expected};
+87 -13
View File
@@ -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;
@@ -139,12 +153,31 @@
return output.join('\n').trim(); return output.join('\n').trim();
} }
function shortTitle(title, budget = 75) {
const size = ch => {
const code = ch.codePointAt(0);
return code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
};
// A generated prefix plus "..." (up to 78 bytes) is already safe.
const total = Array.from(title).reduce((sum, ch) => sum + size(ch), 0);
if (total <= budget || (title.endsWith('...') && total <= budget + 3)) return title;
let prefix = '', used = 0;
for (const ch of title) {
const bytes = size(ch);
if (used + bytes > budget) break;
prefix += ch;
used += bytes;
}
return prefix + '...';
}
function compile(markdown, mdPath, fileMap, titleOverride) { function compile(markdown, mdPath, fileMap, titleOverride) {
let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
const firstHeading = body.match(/^\s*#\s+([^\n]+)\n?/); const firstHeading = body.match(/^\s*#\s+([^\n]+)\n?/);
const title = String(titleOverride || firstHeading?.[1] || mdPath.split('/').pop().replace(/\.md$/i, '')).trim(); const originalTitle = String(titleOverride || firstHeading?.[1] || mdPath.split('/').pop().replace(/\.md$/i, '')).trim();
const title = shortTitle(originalTitle);
if (firstHeading) body = body.slice(firstHeading[0].length).trim(); if (firstHeading) body = body.slice(firstHeading[0].length).trim();
if (!title || Array.from(title).length > 80) throw new Error('标题为空或超过 80 字,请修改 Markdown 标题后重试。'); if (!title) throw new Error('标题为空,请修改 Markdown 标题后重试。');
if (!body.trim()) throw new Error('正文为空。'); if (!body.trim()) throw new Error('正文为空。');
const warnings = []; const warnings = [];
if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。'); if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。');
@@ -217,6 +250,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);
@@ -369,7 +439,7 @@
if (switcher) switcher.checked = Number(win.wysiwyg) === 0; if (switcher) switcher.checked = Number(win.wysiwyg) === 0;
} }
async function importPrepared(options = {}) { async function importPrepared(options = {}) {
const { signal, automatic = false } = options; const { signal, automatic = false, verifyImages = true } = options;
signal?.throwIfAborted(); signal?.throwIfAborted();
if (busy) throw new Error('正在导入,请勿重复点击。'); if (busy) throw new Error('正在导入,请勿重复点击。');
if (!article) throw new Error('请先选择文章目录或文章包。'); if (!article) throw new Error('请先选择文章目录或文章包。');
@@ -422,9 +492,10 @@
signal?.throwIfAborted(); signal?.throwIfAborted();
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 && verifyImages) {
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]);
} else if (raw !== bbcode) throw new Error('正文填写校验失败。'); await verifyEditorImages(doc.querySelector('#e_iframe')?.contentDocument?.body || win.editdoc?.body, expected, signal);
} else if (Number(win.wysiwyg) !== 1 && 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;
} catch (error) { } catch (error) {
@@ -436,7 +507,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 +530,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 +550,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, shortTitle, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, attachmentId, verifyEditorImages, mount };
}); });
+34 -3
View File
@@ -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/*
@@ -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','start','pause','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')); };
@@ -35,13 +35,14 @@
try {message=JSON.parse(event.detail);} catch {return;} try {message=JSON.parse(event.detail);} catch {return;}
if (!allowed.has(message.route) || typeof message.id !== 'string') return; if (!allowed.has(message.route) || typeof message.id !== 'string') return;
try { try {
if (message.route==='stop') { GM_setValue('halted',true); signalStop(); } if (message.route==='stop'||message.route==='pause') { GM_setValue('halted',true); signalStop(); }
const revision = stopRevision; const revision = stopRevision;
let value=await request(message.route,message.body || {}); let value=await request(message.route,message.body || {});
if (message.route==='heartbeat') { if (message.route==='heartbeat') {
if (revision !== stopRevision) value = {...value, running:false, reason:'终止后忽略旧连接响应'}; if (revision !== stopRevision) value = {...value, running:false, reason:'终止后忽略旧连接响应'};
GM_setValue('halted',!value.running); GM_setValue('halted',!value.running);
if (!value.running) signalStop(); if (!value.running) signalStop();
document.dispatchEvent(new CustomEvent('dww11:run-state',{detail:Boolean(value.running)}));
} }
document.dispatchEvent(new CustomEvent('dww11:local-response',{detail:JSON.stringify({id:message.id,value})})); document.dispatchEvent(new CustomEvent('dww11:local-response',{detail:JSON.stringify({id:message.id,value})}));
} catch(error) { } catch(error) {
@@ -56,6 +57,36 @@
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 start=document.createElement('button');start.id='start';start.type='button';start.textContent='开始 / 继续';
const pause=document.createElement('button');pause.id='pause';pause.type='button';pause.textContent='暂停';
$('halt').before(start);$('halt').before(pause);
let starting=false;
start.onclick=async()=>{
if(starting||start.disabled)return;
starting=true;start.disabled=true;
const revision=stopRevision;
try {
await request('start',{});
if(revision!==stopRevision) {
await request('pause',{});
$('status').textContent='已暂停;忽略较早的开始响应';
}else {
GM_setValue('halted',false);
$('status').textContent='已开始;等待执行标签继续流程';
}
}catch(error){$('status').textContent='开始失败:'+error.message;}
finally{starting=false;start.disabled=false;}
};
pause.onclick=async()=>{
GM_setValue('halted',true);signalStop();
$('status').textContent='已暂停;在途请求仍可能完成';
try {await request('pause',{});}catch(error){$('status').textContent+='。'+error.message;}
};
document.addEventListener('dww11:run-state',event=>{start.disabled=starting||Boolean(event.detail);});
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;
+60
View File
@@ -0,0 +1,60 @@
/* Read-only result adapter. Login state and URLs alone are never acceptance evidence. */
(function(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
else root.DWW11Results=api;
})(typeof window==='undefined'?null:window,()=>{
'use strict';
const clean=value=>String(value||'').replace(/\s+/g,' ').trim();
function parameter(href,key,base){try{return new URL(href,base).searchParams.get(key);}catch{return null;}}
function aid(image){return String(image.getAttribute('aid')||image.id||'').match(/(?:attachimg_|aimg_)?(\d+)$/)?.[1]||null;}
function inspect(doc,url,snapshot={}) {
const prompt=doc.querySelector('#messagetext, .messagetext');
const text=clean(prompt?.textContent);
const message='新主题需要审核,您的帖子通过审核后才能显示';
if(text.includes(message))return {
kind:'accepted_prompt',state:'accepted_pending_moderation',
evidence:{adapter:'moderation_prompt',message,observed_text:text,observed_url:url}
};
if(/^(?:抱歉[,,]\s*)?(?:您填写的)?(?:帖子)?标题.{0,40}(?:超过|超出).{0,40}(?:字符|长度|限制)/.test(text))return {kind:'rejected',error:text};
return {kind:'waiting',prompt:text};
// 暂停详情页核对:按用户要求注释保留,待恢复时启用。
// const titleNode=doc.querySelector('#thread_subject');
// if(!titleNode) {
// const prompt=doc.querySelector('#messagetext, .messagetext');
// const text=clean(prompt?.textContent);
// if(/^(?:抱歉[,,]\s*)?(?:您填写的)?(?:帖子)?标题.{0,40}(?:超过|超出).{0,40}(?:字符|长度|限制)/.test(text))return {kind:'rejected',error:text};
// return {kind:'waiting',prompt:text,candidate:prompt?.querySelector('a[href]')?.href||''};
// }
// const region=titleNode.closest('td')||titleNode.closest('h1')?.parentElement;
// if(!region)return {kind:'incomplete',error:'缺少标题状态区域'};
// const ids=new Set([...region.querySelectorAll('a[href]')].filter(a=>parameter(a.href,'mod',url)==='viewthread').map(a=>parameter(a.href,'tid',url)).filter(x=>/^\d+$/.test(x)));
// if(ids.size!==1)return {kind:'incomplete',error:'详情页真实主题编号缺失或冲突'};
// const thread_id=[...ids][0],body=doc.querySelector('[id^="postmessage_"]');
// const pid=body?.id.match(/^postmessage_(\d+)$/)?.[1];
// const post=pid&&doc.querySelector('#post_'+pid);
// if(!post||clean(post.querySelector('#postnum'+pid)?.textContent)!=='楼主')return {kind:'incomplete',error:'无法确认当前正文属于首帖'};
// const authors=post?[...post.querySelectorAll('.plc .pti .authi a[href], .pls .authi a[href]')]:[];
// const uids=new Set(authors.map(a=>parameter(a.href,'uid',url)).filter(x=>/^\d+$/.test(x)));
// const fids=new Set([...doc.querySelectorAll('#pt a[href], .mumucms_pt a[href]')].filter(a=>parameter(a.href,'mod',url)==='forumdisplay').map(a=>parameter(a.href,'fid',url)).filter(x=>/^\d+$/.test(x)));
// if(!body||!pid||uids.size!==1||fids.size!==1)return {kind:'incomplete',error:'首帖作者、正文或版块证据不足'};
// const moderation=[...region.querySelectorAll('span.xg1')].some(n=>/\(\s*审核中\s*\)|(\s*审核中\s*)/.test(n.textContent));
// // A public-page sample is not available yet. Absence of moderation is not proof of publication.
// if(!moderation)return {kind:'incomplete',error:'尚无可验证的公开或审核中状态'};
// const allImages=[...body.querySelectorAll('img')];
// const images=allImages.filter(img=>aid(img)).map(img=>({aid:aid(img),src:img.getAttribute('src'),loaded:img.complete&&img.naturalWidth>0&&!/\/none\.gif(?:[?#]|$)/.test(img.src||img.getAttribute('src')||''),file:img.getAttribute('file')||img.getAttribute('zoomfile')}));
// const expected=(snapshot.images||[]).map(img=>String(img.aid||img.id||'').match(/(?:attachimg_|aimg_)?(\d+)$/)?.[1]).filter(Boolean);
// const issues=[];
// if(allImages.some(img=>!aid(img)))issues.push('正文存在无法关联附件的图片');
// if(expected.length!==(snapshot.images||[]).length)issues.push('提交快照存在无法识别附件编号的图片');
// if(JSON.stringify(expected)!==JSON.stringify(images.map(img=>img.aid)))issues.push('首帖正文图片引用或顺序与提交快照不一致');
// if(images.some(img=>!img.loaded))issues.push('结果页正文图片尚未加载或加载失败');
// if(snapshot.text&&clean(body.innerText||body.textContent)!==clean(snapshot.text))issues.push('结果正文与最终提交快照存在文字差异');
// const attachments=post?[...post.querySelectorAll('.pattl img, .attm img')].map(img=>({aid:aid(img),src:img.getAttribute('src')})):[];
// if(attachments.length)issues.push('结果页存在附件区图片,请核对正文位置');
// return {kind:'accepted',thread_id,first_post_pid:pid,post_url:url,title:clean(titleNode.textContent),author_uid:[...uids][0],fid:Number([...fids][0]),state:'accepted_pending_moderation',
// evidence:{adapter:1,thread_id,first_post_pid:pid,title:clean(titleNode.textContent),author_uid:[...uids][0],fid:Number([...fids][0]),moderation:true,images,attachments,integrity_issues:issues}};
}
return {inspect,clean};
});
+110 -21
View File
@@ -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, stopRevision=0 /*, resultSeenAt=0 */;
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)=>{
@@ -23,7 +24,7 @@
data.error?waiter.reject(new Error(data.error)):waiter.resolve(data.value); data.error?waiter.reject(new Error(data.error)):waiter.resolve(data.value);
}); });
const status=message=>document.dispatchEvent(new CustomEvent('dww11:connection-status',{detail:message})); const status=message=>document.dispatchEvent(new CustomEvent('dww11:connection-status',{detail:message}));
document.addEventListener('dww11:auto-stop',()=>{halted=true;aborter?.abort(new Error('流程已终止'));}); document.addEventListener('dww11:auto-stop',()=>{stopRevision++;halted=true;aborter?.abort(new Error('流程已终止'));});
function account() { function account() {
const element=document.querySelector('#mumucms_username'); const element=document.querySelector('#mumucms_username');
if(!element)return null; if(!element)return null;
@@ -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'));
@@ -78,14 +80,16 @@
const prepared=await window.DWW11Importer.stagePackage(pack);assertLive(epoch); const prepared=await window.DWW11Importer.stagePackage(pack);assertLive(epoch);
if(prepared.warnings?.length)throw new Error('文章需要人工处理:'+prepared.warnings.join(';')); if(prepared.warnings?.length)throw new Error('文章需要人工处理:'+prepared.warnings.join(';'));
await event('importing');assertLive(epoch);status('上传图片并填写文章…'); await event('importing');assertLive(epoch);status('上传图片并填写文章…');
const result=await window.DWW11Importer.importPrepared({automatic:true,signal:aborter.signal});assertLive(epoch); const result=await window.DWW11Importer.importPrepared({automatic:true,verifyImages:false,signal:aborter.signal});assertLive(epoch);
if(result?.cancelled)throw new Error('导入已取消'); if(result?.cancelled)throw new Error('导入已取消');
await event('formatting');assertLive(epoch); await event('formatting');assertLive(epoch);
window.DWW11Formatter.format(job.source);assertLive(epoch); window.DWW11Formatter.format(job.source,{verifyImages:false});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)); // 按用户要求暂停图片检查;保留原调用以便恢复。
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); 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,30 +97,89 @@
} }
// 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();
const local={id:active.id,snapshot,clickedAt:Date.now(),observed:false};saveLocal(local); const local={id:active.id,snapshot,clickedAt:Date.now(),observed:false};saveLocal(local);
rpc('event',{id:active.id,state:'user_submitting',epoch:run?.epoch,snapshot}) rpc('event',{id:active.id,state:'user_submitting',epoch:run?.epoch,snapshot})
.then(()=>{const latest=loadLocal();if(latest?.clickedAt===local.clickedAt)saveLocal({...local,observed:true});}) .then(()=>{const latest=loadLocal();if(latest?.clickedAt===local.clickedAt)saveLocal({...latest,observed:true});})
.catch(error=>status('提交记录待恢复:'+error.message)); .catch(error=>status('提交记录待恢复:'+error.message));
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') {
await rpc('event',{id:job.id,state:'user_submitting',epoch:run.epoch,snapshot:local.snapshot}); await rpc('event',{id:job.id,state:'user_submitting',epoch:run.epoch,snapshot:local.snapshot});
job.state='user_submitting'; job.state='user_submitting';job.snapshot=local.snapshot;
} }
if(job.state==='user_submitting') { if(!['user_submitting','submit_unknown'].includes(job.state))return;
// Result selectors intentionally await user-provided HTML; URL alone is not proof. const snapshot=job.snapshot||local?.snapshot||{};
if(!editorPage(job) || (local?.clickedAt && Date.now()-local.clickedAt>45000)) { const result=(local?.id===job.id&&local.promptResult)||window.DWW11Results?.inspect(document,location.href,snapshot);
await rpc('event',{id:job.id,state:'submit_unknown',error:'等待结果页 HTML 适配;候选页面:'+location.href}); if(result?.kind==='rejected'&&job.state==='user_submitting') {
await rpc('stop'); await rpc('event',{id:job.id,state:'rejected',error:result.error});
status('已记录候选结果页;尚不能自动核对。流程已暂停,请补充结果页 HTML。'); status('网站明确拒绝本次提交,已暂停;核对并处理稿件后仍需亲手发表');return;
} }
}else if(job.state==='submit_unknown')status('发布结果待核对;不会重发、计数或换号。'); // 暂停详情页标题、作者、版块和图文核对;保留原代码。
// if(result?.kind==='accepted') {
// if(result.author_uid!==job.account.uid||result.fid!==job.fid||result.title!==window.DWW11Results.clean(snapshot.title)) {
// if(job.state==='user_submitting')await rpc('event',{id:job.id,state:'submit_unknown',error:'结果页标题、首帖作者或版块与提交任务不符'});
// await rpc('stop');status('结果不匹配,已暂停,请人工核对');return;
// }
// resultSeenAt ||= Date.now();
// if(result.evidence.integrity_issues.some(x=>x.includes('尚未加载'))&&Date.now()-resultSeenAt<15000) {
// status('已看到审核中详情,等待正文图片加载后核对');return;
// }
if(result?.kind==='accepted_prompt') {
const revision=stopRevision,epoch=run.epoch;
await rpc('accept',{id:job.id,attempt:job.attempt,...result});
sessionStorage.removeItem(SLOT);active=null;
// Re-read persistent control after accounting; Stop must suppress navigation and subsequent jobs.
run=await rpc('heartbeat',{account:account(),url:location.href,extension:extension()});
halted=!run.running;
if(!halted&&revision===stopRevision&&run.epoch===epoch)location.assign('https://www.dww11.com/');
else status('受理结果已保存;流程已暂停,请核对后明确继续');
return;
}
const clickedAt=local?.clickedAt||Number(job.submitted_at||0)*1000;
if(clickedAt&&Date.now()-clickedAt<45000) {
status(result?.prompt||result?.error||'等待网站提交结果,不会重复发表');return;
}
if(job.state==='user_submitting')await rpc('event',{id:job.id,state:'submit_unknown',error:result?.error||'未观察到网站新主题审核提示,请人工核对是否已受理'});
if(run.running)await rpc('stop');
status('未观察到网站审核提示,已暂停;请人工核对,不会重发');
} }
async function switchAccount() { async function switchAccount() {
if(working||halted)return; if(working||halted)return;
@@ -124,15 +187,17 @@
location.assign('https://www.dww11.com/');return; location.assign('https://www.dww11.com/');return;
} }
const ext=extension(),button=document.querySelector('#dww11-extension-next'); const ext=extension(),button=document.querySelector('#dww11-extension-next');
if(!ext.ready||!ext.idle||!button||button.disabled) {status('请安装并重新加载带联动接口的换号插件');return;} if(!ext.ready||!ext.idle||!button||button.disabled) {await rpc('stop');status('换号插件未就绪,流程已暂停,请检查插件后继续');return;}
await rpc('switch-begin'); const revision=stopRevision,epoch=run.epoch;
if(halted||!run?.running)return; await rpc('switch-begin',{epoch});
if(halted||!run?.running||revision!==stopRevision)return;
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;
@@ -145,9 +210,16 @@
if(run.active && run.owner===owner) {const saved=await rpc('current');if(saved.job) {active=saved.job;await observeResult(saved.job);}} if(run.active && run.owner===owner) {const saved=await rpc('current');if(saved.job) {active=saved.job;await observeResult(saved.job);}}
status(run.reason || '流程已暂停');return; status(run.reason || '流程已暂停');return;
} }
const next=await rpc('next',{account:account(),extension:extension()});run=next.run; if(run.active&&run.owner===owner) {
const saved=await rpc('current');
if(saved.job&&['user_submitting','submit_unknown'].includes(saved.job.state)) {active=saved.job;await observeResult(saved.job);return;}
}
const revision=stopRevision,epoch=run.epoch;
const next=await rpc('next',{account:account(),extension:extension(),url:location.href});run=next.run;
if(revision!==stopRevision||run.epoch!==epoch)return;
if(skipBusy)return;
if(next.job)active=next.job; if(next.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') {
@@ -156,6 +228,8 @@
} }
else if(['importing','formatting'].includes(next.job.state))await fail(new Error('导入阶段发生中断,请检查编辑器和已上传图片后处理任务')); else if(['importing','formatting'].includes(next.job.state))await fail(new Error('导入阶段发生中断,请检查编辑器和已上传图片后处理任务'));
else if(next.job.state==='needs_attention')status(next.job.error); else if(next.job.state==='needs_attention')status(next.job.error);
}else if(next.action==='home') {
if(!halted&&run.running)location.assign('https://www.dww11.com/');
}else if(next.action==='switch')await switchAccount(); }else if(next.action==='switch')await switchAccount();
else if(next.action==='switch_wait')status('等待换号结果;若插件已暂停,请人工处理后继续'); else if(next.action==='switch_wait')status('等待换号结果;若插件已暂停,请人工处理后继续');
else if(next.action==='login')status('未能读取当前账号 UID,请登录并确认账号元素'); else if(next.action==='login')status('未能读取当前账号 UID,请登录并确认账号元素');
@@ -169,5 +243,20 @@
takeover.style.cssText='position:fixed;left:16px;top:16px;z-index:2147483646;padding:8px;background:#fff;border:1px solid #aab;border-radius:6px'; takeover.style.cssText='position:fixed;left:16px;top:16px;z-index:2147483646;padding:8px;background:#fff;border:1px solid #aab;border-radius:6px';
takeover.onclick=async()=>{try{await rpc('takeover');status('已接管;核对当前页面后在工作台继续');}catch(error){status(error.message);}}; takeover.onclick=async()=>{try{await rpc('takeover');status('已接管;核对当前页面后在工作台继续');}catch(error){status(error.message);}};
document.body.append(takeover); document.body.append(takeover);
// 提示可能短暂出现后跳转;及时保存到本次任务,避免两秒轮询漏掉。
function capturePrompt() {
const local=loadLocal();
if(!local?.id||!local.snapshot||!local.clickedAt||local.promptResult)return false;
if(active&&(active.id!==local.id||!['user_submitting','submit_unknown'].includes(active.state)))return false;
const result=window.DWW11Results?.inspect(document,location.href,local.snapshot);
if(result?.kind!=='accepted_prompt')return false;
saveLocal({...local,promptResult:result});
return true;
}
if(typeof MutationObserver!=='undefined')new MutationObserver(()=>{
if(capturePrompt())void tick();
}).observe(document.body,{childList:true,subtree:true,characterData:true});
// 跳转后的提示页可能已经渲染完成,首次轮询前也捕获一次。
capturePrompt();
setInterval(tick,2000);tick(); setInterval(tick,2000);tick();
})(); })();
+2 -2
View File
@@ -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.2.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/*
@@ -14,7 +14,7 @@ header = '''// ==UserScript==
// ==/UserScript== // ==/UserScript==
''' '''
parts = [] parts = []
for name in ('importer.js', 'formatter.js', 'runner.js'): for name in ('importer.js', 'formatter.js', 'results.js', 'runner.js'):
source = (ROOT / 'browser' / name).read_text() source = (ROOT / 'browser' / name).read_text()
if '// ==/UserScript==' in source: if '// ==/UserScript==' in source:
source = source.split('// ==/UserScript==', 1)[1] source = source.split('// ==/UserScript==', 1)[1]
+58 -2
View File
@@ -1,7 +1,25 @@
'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 selectedJobs=new Set();
let removing=false;
function removalBlocked(job,run){
if(job.id===run.active)return '当前任务请先通过“人工核对 / 处理”核对并移出';
if(['user_submitting','submit_unknown'].includes(job.state)||(job.submitted_at&&!['published','accepted_pending_moderation'].includes(job.state)))return '请先核对提交结果并处理';
return '';
}
function updateSelection(){
if(!state)return;
const eligible=state.jobs.filter(job=>!removalBlocked(job,state.run));
const eligibleIds=new Set(eligible.map(job=>job.id));
$('selected-count').textContent=`已选 ${selectedJobs.size} 篇`;
$('remove-selected').disabled=removing||state.run.running||!selectedJobs.size;
$('select-all').disabled=removing||!eligible.length;
$('select-all').checked=eligible.length>0&&eligible.every(job=>selectedJobs.has(job.id));
$('select-all').indeterminate=selectedJobs.size>0&&!$('select-all').checked;
document.querySelectorAll('.job-select').forEach(input=>{input.checked=selectedJobs.has(input.value);input.disabled=removing||!eligibleIds.has(input.value);});
}
const labels={queued:'待处理',preparing:'准备中',importing:'上传与导入',formatting:'排版中',awaiting_user_review:'等待人工审核',user_submitting:'已观察到人工提交',submit_unknown:'发布结果待核对',accepted_pending_moderation:'已受理 · 审核中',published:'已确认公开',needs_attention:'需要处理',cancelled:'已移出',skipped:'已跳过',rejected:'网站明确拒绝'};
function notice(message){$('notice').hidden=!message;$('notice').textContent=message;} 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)});
@@ -11,16 +29,38 @@ function action(id,fn){$(id).addEventListener('click',async()=>{const button=$(i
function text(tag,value,className){const e=document.createElement(tag);e.textContent=value;if(className)e.className=className;return e;} function text(tag,value,className){const e=document.createElement(tag);e.textContent=value;if(className)e.className=className;return e;}
function render(data){ function render(data){
state=data;const {jobs,run,download}=data; state=data;const {jobs,run,download}=data;
const eligibleIds=new Set(jobs.filter(job=>!removalBlocked(job,run)).map(job=>job.id));
for(const id of selectedJobs)if(!eligibleIds.has(id))selectedJobs.delete(id);
$('total').textContent=jobs.length;$('review').textContent=jobs.filter(j=>j.state==='awaiting_user_review').length; $('total').textContent=jobs.length;$('review').textContent=jobs.filter(j=>j.state==='awaiting_user_review').length;
$('accepted').textContent=jobs.filter(j=>['published','accepted_pending_moderation'].includes(j.state)).length; $('accepted').textContent=jobs.filter(j=>['published','accepted_pending_moderation'].includes(j.state)).length;
$('batch').textContent=run.batch?`${run.batch.count} / ${run.batch.target}`:'—';$('account').textContent=run.batch?run.batch.account.name:'尚未绑定账号'; $('batch').textContent=run.batch?`${run.batch.count} / ${run.batch.target}`:'—';$('account').textContent=run.batch?run.batch.account.name:'尚未绑定账号';
$('run-state').textContent=run.running?'流程运行中':'流程已暂停';$('run-reason').textContent=run.reason; $('run-state').textContent=run.running?'流程运行中':'流程已暂停';$('run-reason').textContent=run.reason;
$('run-light').classList.toggle('gray',!run.running);$('empty').hidden=jobs.length>0; $('run-light').classList.toggle('gray',!run.running);$('empty').hidden=jobs.length>0;
$('jobs').replaceChildren(...jobs.map(job=>{ $('jobs').replaceChildren(...jobs.map(job=>{
const selection=document.createElement('td'),checkbox=document.createElement('input');
checkbox.type='checkbox';checkbox.className='job-select';checkbox.value=job.id;
checkbox.setAttribute('aria-label','选择文章:'+job.title);checkbox.title=removalBlocked(job,run)||'选择此文章';
checkbox.onchange=()=>{checkbox.checked?selectedJobs.add(job.id):selectedJobs.delete(job.id);updateSelection();};selection.append(checkbox);
const tr=document.createElement('tr'),title=document.createElement('td');title.append(text('div',job.title),text('small',`${job.image_count} 张配图 · ${job.source}`));if(job.original_title!==job.title)title.append(text('small','已缩短标题,原题:'+job.original_title)); const tr=document.createElement('tr'),title=document.createElement('td');title.append(text('div',job.title),text('small',`${job.image_count} 张配图 · ${job.source}`));if(job.original_title!==job.title)title.append(text('small','已缩短标题,原题:'+job.original_title));
const status=document.createElement('td');status.append(text('span',labels[job.state]||job.state,'badge'));if(job.error)status.append(text('small',job.error)); const status=document.createElement('td');status.append(text('span',labels[job.state]||job.state,'badge'));if(job.error)status.append(text('small',job.error));
for(const issue of job.integrity_issues||[])status.append(text('small',issue));
const account=text('td',job.account?`${job.account.name} / ${job.fid}`:'待领取');const result=document.createElement('td'); const account=text('td',job.account?`${job.account.name} / ${job.fid}`:'待领取');const result=document.createElement('td');
if(job.post_url){const link=text('a','查看帖子');link.href=job.post_url;link.target='_blank';link.rel='noopener';result.append(link);} if(job.post_url){const link=text('a','查看帖子');link.href=job.post_url;link.target='_blank';link.rel='noopener';result.append(link);}
if(['user_submitting','submit_unknown'].includes(job.state)) {
const input=document.createElement('input');input.type='file';input.accept='.json,application/json';input.hidden=true;
const reconcile=text('button','补核已受理结果');reconcile.className='secondary';reconcile.onclick=()=>input.click();
input.onchange=async()=>{
try {
if(state.run.running)throw new Error('请先暂停流程');
const file=input.files[0];if(!file)return;if(file.size>2*1024*1024)throw new Error('记录文件过大');
const record=JSON.parse(await file.text());
if(!confirm(`确认该观察记录属于“${job.title}”?补核会阻止重发,不增加当前批次计数。`))return;
await api('reconcile',{id:job.id,article_key:job.article_key,confirmed:true,thread_id:String(record.threadId||record.thread_id||''),first_post_pid:String(record.postId||record.first_post_pid||''),post_url:record.url||record.post_url,title:record.title,author_uid:String(record.uid||record.author_uid||''),fid:Number(record.fid),state:record.status||record.state,evidence:{thread_id:String(record.threadId||record.thread_id||''),prompt:record.observedPrompt||'',historical:true,integrity_issues:['历史补核尚未完成发布后图文完整性检查']}});
notice('历史受理已登记,未增加当前批次计数');await refresh();
}catch(error){notice(error.message);}finally{input.value='';}
};
result.append(reconcile,input);
}
if(!['published','accepted_pending_moderation'].includes(job.state)){ if(!['published','accepted_pending_moderation'].includes(job.state)){
const retry=text('button','人工核对 / 处理');retry.className='secondary';retry.onclick=async()=>{ const retry=text('button','人工核对 / 处理');retry.className='secondary';retry.onclick=async()=>{
if(state.run.running){notice('请先暂停流程,再处理任务');return;} if(state.run.running){notice('请先暂停流程,再处理任务');return;}
@@ -29,8 +69,9 @@ function render(data){
try{await api('resolve',{id:job.id,confirmed_not_accepted:true,retry:again});await refresh();}catch(error){notice(error.message);} try{await api('resolve',{id:job.id,confirmed_not_accepted:true,retry:again});await refresh();}catch(error){notice(error.message);}
};result.append(retry); };result.append(retry);
} }
tr.append(title,status,account,result);return tr; tr.append(selection,title,status,account,result);return tr;
})); }));
updateSelection();
const dlLabels={idle:'等待开始',running:'下载中',completed:'下载完成',partial:'未达到目标',cancelled:'已停止',error:'下载出错'}; const dlLabels={idle:'等待开始',running:'下载中',completed:'下载完成',partial:'未达到目标',cancelled:'已停止',error:'下载出错'};
$('download-state').textContent=dlLabels[download.status]||download.status;$('download-progress').textContent=`已保存 ${download.saved}/${download.target} 篇 · 跳过 ${download.skipped} · 失败 ${download.failed}`; $('download-state').textContent=dlLabels[download.status]||download.status;$('download-progress').textContent=`已保存 ${download.saved}/${download.target} 篇 · 跳过 ${download.skipped} · 失败 ${download.failed}`;
$('progress').value=download.target?Math.floor(download.saved/download.target*100):0; $('progress').value=download.target?Math.floor(download.saved/download.target*100):0;
@@ -39,6 +80,21 @@ function render(data){
$('clients').textContent=data.clients.map(c=>`${c.account?.name||'未识别账号'} · 换号插件${c.extension?.ready?'已就绪':'未连接或需更新'}`).join('\n')||'没有在线执行标签'; $('clients').textContent=data.clients.map(c=>`${c.account?.name||'未识别账号'} · 换号插件${c.extension?.ready?'已就绪':'未连接或需更新'}`).join('\n')||'没有在线执行标签';
} }
async function refresh(){if(refreshing||!token)return;refreshing=true;try{render(await api('state'));}catch(error){notice(error.message);$('run-state').textContent='本地服务连接中断';}finally{refreshing=false;}} async function refresh(){if(refreshing||!token)return;refreshing=true;try{render(await api('state'));}catch(error){notice(error.message);$('run-state').textContent='本地服务连接中断';}finally{refreshing=false;}}
$('select-all').onchange=()=>{
const checked=$('select-all').checked;
for(const job of state?.jobs||[])if(!removalBlocked(job,state.run)){checked?selectedJobs.add(job.id):selectedJobs.delete(job.id);}
updateSelection();
};
$('remove-selected').onclick=async()=>{
if(removing||!selectedJobs.size)return;
if(state.run.running){notice('请先暂停流程,再移除文章');return;}
const ids=[...selectedJobs];
if(!confirm(`确定从文章队列移除所选的 ${ids.length} 篇文章?\n原始文章和图片文件、已受理去重记录会保留。未受理文章以后可通过扫描目录重新加入。`))return;
removing=true;updateSelection();
try{const result=await api('remove',{ids});for(const id of ids)selectedJobs.delete(id);notice(`已从队列移除 ${result.removed} 篇文章`);await refresh();}
catch(error){notice(error.message);}
finally{removing=false;updateSelection();}
};
action('pair',async()=>{const value=await api('pair');$('pair-code').hidden=false;$('pair-code').type='text';$('pair-code').value=value.token;$('pair-code').select();}); action('pair',async()=>{const value=await api('pair');$('pair-code').hidden=false;$('pair-code').type='text';$('pair-code').value=value.token;$('pair-code').select();});
action('channels',async()=>{const value=await api('channels',{key:$('key').value.trim()});loadedKey=$('key').value.trim();$('channel').replaceChildren(...value.channels.map(c=>new Option(c,c)));}); action('channels',async()=>{const value=await api('channels',{key:$('key').value.trim()});loadedKey=$('key').value.trim();$('channel').replaceChildren(...value.channels.map(c=>new Option(c,c)));});
$('key').oninput=()=>{loadedKey='';$('channel').replaceChildren(new Option('请重新获取频道',''));}; $('key').oninput=()=>{loadedKey='';$('channel').replaceChildren(new Option('请重新获取频道',''));};
+5 -5
View File
@@ -2,12 +2,12 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>新闻工作台</title><link rel="stylesheet" href="/style.css"></head> <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>新闻工作台</title><link rel="stylesheet" href="/style.css"></head>
<body> <body>
<aside><div class="brand"><span class="mark">闻</span><div>新闻工作台<small>本地内容工作流</small></div></div><nav><a href="#overview" class="selected">工作概览</a><a href="#source">下载与文章</a><a href="#settings">处理规则</a><a href="#queue">文章队列</a><a href="#connection">浏览器连接</a></nav><div class="aside-note"><span class="dot"></span> 数据保存在本机<p>文章自动准备与排版<br>发表由你亲手完成</p></div></aside> <aside><div class="brand"><div>新闻工作台</div></div><nav><a href="#overview" class="selected">工作概览</a><a href="#source">下载与文章</a><a href="#settings">处理规则</a><a href="#queue">文章队列</a><a href="#connection">浏览器连接</a></nav></aside>
<main> <main>
<header id="overview"><div><div class="eyebrow">CONTENT WORKSPACE</div><h1>把内容准备好,把审核留给你。</h1><p>下载、导入、排版与账号切换,在一个工作台管理。</p></div><button id="stop" class="danger">终止流程</button></header> <header id="overview"><div><div class="eyebrow">CONTENT WORKSPACE</div><h1>dww11速发 v0.2.0</h1><p>By Longtail Amethyst Eralbrunia &amp; ChatGPT</p></div><button id="stop" class="danger">终止流程</button></header>
<div id="notice" role="status" aria-live="polite" class="notice" hidden></div> <div id="notice" role="status" aria-live="polite" class="notice" hidden></div>
<section class="flow"><div><span>01</span>下载新闻</div><i>→</i><div><span>02</span>导入与排版</div><i>→</i><div class="highlight"><span>03</span>人工审核与发表</div><i>→</i><div><span>04</span>结果核对与换号</div></section> <section class="flow"><div><span>01</span>下载新闻</div><i>→</i><div><span>02</span>导入与排版</div><i>→</i><div class="highlight"><span>03</span>人工审核与发表</div><i>→</i><div><span>04</span>结果核对与换号</div></section>
<div class="pending-note"><b>当前接入进度</b> 下载、导入、排版与换号接口已整合;结果页自动核对等待页面 HTML。人工发表后会暂停,不自动计数或继续下一篇。</div> <div class="pending-note"><b>当前接入进度</b> 当前以网站“新主题需要审核,您的帖子通过审核后才能显示”提示登记发表成功(已受理、审核中),并接续后续流程。详情页核对暂时停用。</div>
<section class="metrics"><article><small>队列文章</small><strong id="total">0</strong><span>完整文件检查后入队</span></article><article><small>等待你审核</small><strong id="review">0</strong><span>请在网站检查并亲手发表</span></article><article><small>明确受理</small><strong id="accepted">0</strong><span>审核中与已公开分别记录</span></article><article><small>当前账号批次</small><strong id="batch">—</strong><span id="account">尚未绑定账号</span></article></section> <section class="metrics"><article><small>队列文章</small><strong id="total">0</strong><span>完整文件检查后入队</span></article><article><small>等待你审核</small><strong id="review">0</strong><span>请在网站检查并亲手发表</span></article><article><small>明确受理</small><strong id="accepted">0</strong><span>审核中与已公开分别记录</span></article><article><small>当前账号批次</small><strong id="batch">—</strong><span id="account">尚未绑定账号</span></article></section>
<div class="workspace-grid"> <div class="workspace-grid">
<section id="source" class="card"><div class="section-head"><div><span class="eyebrow">SOURCE</span><h2>下载与文章</h2></div><span id="download-state" class="badge">等待开始</span></div> <section id="source" class="card"><div class="section-head"><div><span class="eyebrow">SOURCE</span><h2>下载与文章</h2></div><span id="download-state" class="badge">等待开始</span></div>
@@ -19,8 +19,8 @@
<form id="config-form"><label>目标版块 ID(fid)<input id="fid" type="number" min="1" placeholder="从新帖页地址中的 fid 获取" required></label><div class="row"><label class="grow">每账号最少篇数<input id="minimum" type="number" min="1" max="1000" placeholder="最小值" required></label><label class="grow">每账号最多篇数<input id="maximum" type="number" min="1" max="1000" placeholder="最大值" required></label></div><label>两篇之间间隔(秒)<input id="interval" type="number" min="0" max="86400" placeholder="请输入间隔" required></label><label class="check"><input id="history-checked" type="checkbox" required><span>我已核对待处理文章与历史发布记录,避免重复发表。</span></label><button type="submit" class="secondary">保存处理规则</button></form><p class="muted">每个账号只抽取一次目标篇数;只有明确受理的文章才计数。历史演示去重记录会自动读取。</p> <form id="config-form"><label>目标版块 ID(fid)<input id="fid" type="number" min="1" placeholder="从新帖页地址中的 fid 获取" required></label><div class="row"><label class="grow">每账号最少篇数<input id="minimum" type="number" min="1" max="1000" placeholder="最小值" required></label><label class="grow">每账号最多篇数<input id="maximum" type="number" min="1" max="1000" placeholder="最大值" required></label></div><label>两篇之间间隔(秒)<input id="interval" type="number" min="0" max="86400" placeholder="请输入间隔" required></label><label class="check"><input id="history-checked" type="checkbox" required><span>我已核对待处理文章与历史发布记录,避免重复发表。</span></label><button type="submit" class="secondary">保存处理规则</button></form><p class="muted">每个账号只抽取一次目标篇数;只有明确受理的文章才计数。历史演示去重记录会自动读取。</p>
</section></div> </section></div>
<section class="runbar"><div><span id="run-light" class="dot gray"></span><b id="run-state">正在连接本地服务…</b><p id="run-reason">等待状态</p></div><div class="actions"><button id="start">开始 / 继续</button><button id="pause" class="secondary">暂停</button></div></section> <section class="runbar"><div><span id="run-light" class="dot gray"></span><b id="run-state">正在连接本地服务…</b><p id="run-reason">等待状态</p></div><div class="actions"><button id="start">开始 / 继续</button><button id="pause" class="secondary">暂停</button></div></section>
<section id="queue" class="card"><div class="section-head"><div><span class="eyebrow">ARTICLE QUEUE</span><h2>文章队列</h2></div><label class="file-button">导入历史去重记录<input id="history-file" type="file" accept=".json,application/json" hidden></label></div><div class="table-wrap"><table><thead><tr><th>文章</th><th>状态</th><th>账号 / 版块</th><th>结果与操作</th></tr></thead><tbody id="jobs"></tbody></table><div id="empty" class="empty"><span>+</span><b>从第一篇文章开始</b><p>下载新闻,或检查已有文章目录。准备好的文章会出现在这里。</p></div></div></section> <section id="queue" class="card"><div class="section-head"><div><span class="eyebrow">ARTICLE QUEUE</span><h2>文章队列</h2></div><label class="file-button">导入历史去重记录<input id="history-file" type="file" accept=".json,application/json" hidden></label></div><div class="queue-toolbar"><div class="actions"><button id="remove-selected" class="danger" disabled>移除所选文章</button><span id="selected-count" role="status" aria-live="polite">已选 0 篇</span></div><p class="muted">移除仅删除队列记录,保留原始文章、图片和已受理去重记录。运行中请先暂停;当前任务或提交结果未核对的文章,请先在“人工核对 / 处理”中处理。</p></div><div class="table-wrap"><table><thead><tr><th class="selection-cell"><input id="select-all" type="checkbox" aria-label="全选可移除文章"></th><th>文章</th><th>状态</th><th>账号 / 版块</th><th>结果与操作</th></tr></thead><tbody id="jobs"></tbody></table><div id="empty" class="empty"><span>+</span><b>从第一篇文章开始</b><p>下载新闻,或检查已有文章目录。准备好的文章会出现在这里。</p></div></div></section>
<section id="connection" class="card"><div class="section-head"><div><span class="eyebrow">CONNECTION</span><h2>连接你的浏览器</h2></div><span id="browser-state" class="badge">尚未连接</span></div><div class="connection-grid"><div><ol><li>在油猴中安装以下两个脚本,并停用原导入、排版脚本。</li><li>重新加载原目录中的换号插件,然后刷新网站。</li><li>在网站左下角展开连接设置,填入本地端口和连接码。</li></ol><div class="actions"><a class="button secondary" href="/browser/local-bridge.user.js">安装本地连接脚本</a><a class="button secondary" href="/browser/auto-news.user.js">安装导入排版脚本</a></div></div><div><label>本地端口<input id="port" readonly></label><button id="pair" class="secondary">显示本次连接码</button><input id="pair-code" type="password" readonly hidden aria-label="本次连接码"><small class="muted">服务重启后需要更新连接码。仅复制到你安装的本地连接脚本。</small><p id="clients" class="muted">没有在线执行标签</p></div></div></section> <section id="connection" class="card"><div class="section-head"><div><span class="eyebrow">CONNECTION</span><h2>连接你的浏览器</h2></div><span id="browser-state" class="badge">尚未连接</span></div><div class="connection-grid"><div><ol><li>在油猴中安装以下两个脚本,并停用原导入、排版脚本。</li><li>重新加载原目录中的换号插件,然后刷新网站。</li><li>在网站左下角展开连接设置,填入本地端口和连接码。</li></ol><div class="actions"><a class="button secondary" href="/browser/local-bridge.user.js">安装本地连接脚本</a><a class="button secondary" href="/browser/auto-news.user.js">安装导入排版脚本</a></div></div><div><label>本地端口<input id="port" readonly></label><button id="pair" class="secondary">显示本次连接码</button><input id="pair-code" type="password" readonly hidden aria-label="本次连接码"><small class="muted">服务重启后需要更新连接码。仅复制到你安装的本地连接脚本。</small><p id="clients" class="muted">没有在线执行标签</p></div></div></section>
<details class="card"><summary>下载日志</summary><pre id="logs">暂无日志</pre></details><footer>新闻工作台 · 本机运行 · v0.1 &nbsp; / &nbsp; 终止后不会自动恢复,已发出的请求可能仍已完成。</footer> <details class="card"><summary>下载日志</summary><pre id="logs">暂无日志</pre></details>
</main><script src="/app.js"></script> </main><script src="/app.js"></script>
</body></html> </body></html>
+3 -1
View File
@@ -1 +1,3 @@
:root{font-family:Inter,-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:#203b38;background:#f3f6f4;font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}aside{position:fixed;inset:0 auto 0 0;width:220px;background:#113e37;color:#f4fbf7;padding:32px 22px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;font-size:20px;font-weight:650;letter-spacing:.5px}.mark{display:grid;place-items:center;background:#d8efb9;color:#17483e;width:42px;height:42px;border-radius:13px}.brand small{display:block;font-size:11px;font-weight:400;color:#b3cdc5;margin-top:5px}nav{display:grid;gap:9px;margin-top:54px}nav a{color:#bad1c9;text-decoration:none;padding:12px 16px;border-radius:8px}nav a:hover,nav .selected{background:#28534a;color:#fff}.aside-note{margin-top:auto;color:#bad1c9;font-size:12px;line-height:1.9}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#a3db80;margin-right:7px}.dot.gray{background:#a1ada7}main{max-width:1660px;margin-left:220px;padding:42px 40px}header{display:flex;align-items:center;justify-content:space-between;gap:20px}.eyebrow{font-size:10px;letter-spacing:2px;color:#6b8980;font-weight:700}h1{font-size:28px;letter-spacing:-.6px;margin:12px 0}header p{color:#73857e;margin:0;font-size:13px}button,.button,.file-button{font:inherit;font-weight:550;border:1px solid #1c6554;border-radius:7px;background:#1c6554;color:white;padding:10px 15px;cursor:pointer;white-space:nowrap;text-decoration:none;display:inline-block}button:hover,.button:hover{filter:brightness(.94)}button:disabled{opacity:.45;cursor:wait}.secondary,.file-button{background:white;color:#36574d;border-color:#d4dfd7}.danger{background:#fff8f6;border-color:#e7c5bb;color:#a34d37}.flow{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:34px;background:white;border:1px solid #e2e9e2;padding:22px 25px;border-radius:12px;font-size:12px}.flow span{background:#edf3ef;color:#5f7d70;padding:5px 7px;border-radius:5px;font-size:10px;margin-right:8px}.flow i{color:#b1c0b6;font-style:normal}.flow .highlight{font-weight:650;color:#2e6947}.flow .highlight span{background:#e3efcc;color:#476635}.pending-note{background:#f8f2df;border:1px solid #eae0bd;color:#7b6938;padding:12px 16px;border-radius:8px;margin:16px 0;font-size:12px;line-height:1.8}.pending-note b{margin-right:10px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:22px 0}.metrics article{padding:20px 23px;background:white;border:1px solid #e0e8e0;border-radius:10px}.metrics small{font-size:12px;color:#6b7f73}.metrics strong{display:block;font-size:30px;font-weight:550;margin:12px 0 8px}.metrics span{font-size:11px;color:#8a988f}.workspace-grid{display:grid;grid-template-columns:1.1fr 1fr;gap:20px}.card{background:white;border:1px solid #e0e8e0;border-radius:12px;padding:24px;margin-bottom:20px;min-width:0}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:20px}h2{font-size:17px;margin:6px 0 0;font-weight:600}.badge{font-size:11px;border-radius:20px;background:#f0f5ef;color:#6c826d;padding:5px 10px}.row{display:flex;gap:12px;align-items:flex-end}.grow{flex:1;min-width:0}label{display:block;font-size:12px;color:#61756b;margin-bottom:14px}input,select{display:block;width:100%;font:inherit;color:#304a3f;padding:10px 12px;margin-top:7px;border:1px solid #dbe4dd;border-radius:6px;background:#fcfdfb;outline:none}input:focus,select:focus{border-color:#659981;box-shadow:0 0 0 3px #6599811a}input::placeholder{color:#a4afa8}.row button{margin-bottom:14px}.check{display:flex;align-items:flex-start;gap:8px;line-height:1.7}.check input{width:15px;height:15px;margin:2px 0 0;accent-color:#276b54}.muted{font-size:12px;line-height:1.8;color:#8b988e}.progress{margin:12px 0 20px}progress{appearance:none;width:100%;height:5px;border:0;border-radius:4px;display:block;background:#edf2ec}progress::-webkit-progress-bar{background:#edf2ec}progress::-webkit-progress-value{background:#76a77f;border-radius:4px}.progress small{display:block;color:#82917f;margin-top:8px;font-size:11px}summary{cursor:pointer;color:#617d6d;font-size:12px}details label{margin-top:12px}.runbar{display:flex;align-items:center;justify-content:space-between;border-radius:10px;background:#e5eee5;padding:19px 23px;margin-bottom:22px;gap:16px}.runbar b{font-weight:550}.runbar p{margin:7px 0 0 15px;font-size:12px;color:#758b76}.actions{display:flex;gap:10px;flex-wrap:wrap}table{border-collapse:collapse;width:100%;text-align:left;font-size:12px}th{color:#8a998f;font-weight:500;background:#f7f9f5;padding:12px}td{padding:16px 12px;border-bottom:1px solid #eef1eb;vertical-align:top;line-height:1.8}td:first-child{max-width:350px}td small{display:block;color:#91a093;font-size:11px}td button{font-size:11px;padding:4px 8px}td a{color:#1c6554}.table-wrap{overflow-x:auto}.empty{padding:44px 16px;text-align:center;color:#81927f}.empty span{display:block;foLine truncated :root{font-family:Inter,-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:#203b38;background:#f3f6f4;font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}aside{position:fixed;inset:0 auto 0 0;width:220px;background:#113e37;color:#f4fbf7;padding:32px 22px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;font-size:20px;font-weight:650;letter-spacing:.5px}.mark{display:grid;place-items:center;background:#d8efb9;color:#17483e;width:42px;height:42px;border-radius:13px}.brand small{display:block;font-size:11px;font-weight:400;color:#b3cdc5;margin-top:5px}nav{display:grid;gap:9px;margin-top:54px}nav a{color:#bad1c9;text-decoration:none;padding:12px 16px;border-radius:8px}nav a:hover,nav .selected{background:#28534a;color:#fff}.aside-note{margin-top:auto;color:#bad1c9;font-size:12px;line-height:1.9}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#a3db80;margin-right:7px}.dot.gray{background:#a1ada7}main{max-width:1660px;margin-left:220px;padding:42px 40px}header{display:flex;align-items:center;justify-content:space-between;gap:20px}.eyebrow{font-size:10px;letter-spacing:2px;color:#6b8980;font-weight:700}h1{font-size:28px;letter-spacing:-.6px;margin:12px 0}header p{color:#73857e;margin:0;font-size:13px}button,.button,.file-button{font:inherit;font-weight:550;border:1px solid #1c6554;border-radius:7px;background:#1c6554;color:white;padding:10px 15px;cursor:pointer;white-space:nowrap;text-decoration:none;display:inline-block}button:hover,.button:hover{filter:brightness(.94)}button:disabled{opacity:.45;cursor:wait}.secondary,.file-button{background:white;color:#36574d;border-color:#d4dfd7}.danger{background:#fff8f6;border-color:#e7c5bb;color:#a34d37}.flow{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:34px;background:white;border:1px solid #e2e9e2;padding:22px 25px;border-radius:12px;font-size:12px}.flow span{background:#edf3ef;color:#5f7d70;padding:5px 7px;border-radius:5px;font-size:10px;margin-right:8px}.flow i{color:#b1c0b6;font-style:normal}.flow .highlight{font-weight:650;color:#2e6947}.flow .highlight span{background:#e3efcc;color:#476635}.pending-note{background:#f8f2df;border:1px solid #eae0bd;color:#7b6938;padding:12px 16px;border-radius:8px;margin:16px 0;font-size:12px;line-height:1.8}.pending-note b{margin-right:10px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:22px 0}.metrics article{padding:20px 23px;background:white;border:1px solid #e0e8e0;border-radius:10px}.metrics small{font-size:12px;color:#6b7f73}.metrics strong{display:block;font-size:30px;font-weight:550;margin:12px 0 8px}.metrics span{font-size:11px;color:#8a988f}.workspace-grid{display:grid;grid-template-columns:1.1fr 1fr;gap:20px}.card{background:white;border:1px solid #e0e8e0;border-radius:12px;padding:24px;margin-bottom:20px;min-width:0}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:20px}h2{font-size:17px;margin:6px 0 0;font-weight:600}.badge{font-size:11px;border-radius:20px;background:#f0f5ef;color:#6c826d;padding:5px 10px}.row{display:flex;gap:12px;align-items:flex-end}.grow{flex:1;min-width:0}label{display:block;font-size:12px;color:#61756b;margin-bottom:14px}input,select{display:block;width:100%;font:inherit;color:#304a3f;padding:10px 12px;margin-top:7px;border:1px solid #dbe4dd;border-radius:6px;background:#fcfdfb;outline:none}input:focus,select:focus{border-color:#659981;box-shadow:0 0 0 3px #6599811a}input::placeholder{color:#a4afa8}.row button{margin-bottom:14px}.check{display:flex;align-items:flex-start;gap:8px;line-height:1.7}.check input{width:15px;height:15px;margin:2px 0 0;accent-color:#276b54}.muted{font-size:12px;line-height:1.8;color:#8b988e}.progress{margin:12px 0 20px}progress{appearance:none;width:100%;height:5px;border:0;border-radius:4px;display:block;background:#edf2ec}progress::-webkit-progress-bar{background:#edf2ec}progress::-webkit-progress-value{background:#76a77f;border-radius:4px}.progress small{display:block;color:#82917f;margin-top:8px;font-size:11px}summary{cursor:pointer;color:#617d6d;font-size:12px}details label{margin-top:12px}.runbar{display:flex;align-items:center;justify-content:space-between;border-radius:10px;background:#e5eee5;padding:19px 23px;margin-bottom:22px;gap:16px}.runbar b{font-weight:550}.runbar p{margin:7px 0 0 15px;font-size:12px;color:#758b76}.actions{display:flex;gap:10px;flex-wrap:wrap}table{border-collapse:collapse;width:100%;text-align:left;font-size:12px}th{color:#8a998f;font-weight:500;background:#f7f9f5;padding:12px}td{padding:16px 12px;border-bottom:1px solid #eef1eb;vertical-align:top;line-height:1.8}td:nth-child(2){max-width:350px}td small{display:block;color:#91a093;font-size:11px}td button{font-size:11px;padding:4px 8px}td a{color:#1c6554}.table-wrap{overflow-x:auto}.empty{padding:44px 16px;text-align:center;color:#81927f}.empty span{display:block;fLine truncated
.queue-toolbar .actions{align-items:center}.queue-toolbar #selected-count{font-size:12px;color:#61756b}.queue-toolbar .muted{margin:10px 0 16px}.selection-cell{width:42px}.selection-cell input,.job-select{width:16px;height:16px;margin:5px 0;accent-color:#276b54;cursor:pointer}.job-select:disabled{cursor:default;opacity:.45}.queue-toolbar button:disabled{cursor:default}
+151 -13
View File
@@ -1,14 +1,16 @@
"""Persistent serial queue. Browser submissions are always initiated by the user.""" """Persistent serial queue. Browser submissions are always initiated by the user."""
import json import json
import copy
import re
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
import secrets import secrets
import sqlite3 import sqlite3
import threading import threading
import time import time
from urllib.parse import urlsplit from urllib.parse import urlsplit, parse_qs
from articles import inspect_article from articles import inspect_article, short_title
ACCEPTED = {'published', 'accepted_pending_moderation'} ACCEPTED = {'published', 'accepted_pending_moderation'}
SUBMITTED = {'user_submitting', 'submit_unknown'} SUBMITTED = {'user_submitting', 'submit_unknown'}
@@ -25,10 +27,21 @@ class Store:
CREATE TABLE IF NOT EXISTS ledger(key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS ledger(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS settings(id INTEGER PRIMARY KEY CHECK(id=1), value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS settings(id INTEGER PRIMARY KEY CHECK(id=1), value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS posts(id TEXT PRIMARY KEY, job TEXT NOT NULL); CREATE TABLE IF NOT EXISTS posts(id TEXT PRIMARY KEY, job TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS post_aliases(url TEXT PRIMARY KEY, thread_id TEXT NOT NULL, job TEXT NOT NULL);
''') ''')
row = db.execute('SELECT value FROM settings WHERE id=1').fetchone() row = db.execute('SELECT value FROM settings WHERE id=1').fetchone()
self.run = json.loads(row[0]) if row else dict(running=False, epoch=0, config={}, batch=None, active=None, owner=None, switch=None, reason='请配置任务后开始') self.run = json.loads(row[0]) if row else dict(running=False, epoch=0, config={}, batch=None, active=None, owner=None, switch=None, reason='请配置任务后开始')
self.run.update(running=False, epoch=self.run['epoch'] + 1, reason='服务已启动,请明确点击开始/继续;未决任务不会重发') self.run.update(running=False, epoch=self.run['epoch'] + 1, reason='服务已启动,请明确点击开始/继续;未决任务不会重发')
for row in db.execute('SELECT value FROM jobs').fetchall():
job = json.loads(row[0])
if job['state'] in ACCEPTED and job.get('post_url'):
canonical = job.get('thread_id')
if not canonical:
job['legacy_url_id'] = job.get('post_id')
job['identity_needs_reconciliation'] = True
self._save(db, job)
db.execute('INSERT OR IGNORE INTO post_aliases VALUES(?,?,?)',
(job['post_url'], canonical or 'unknown', job['id']))
self._persist(db) self._persist(db)
self.clients = {} self.clients = {}
if ledger and Path(ledger).is_file(): if ledger and Path(ledger).is_file():
@@ -37,9 +50,14 @@ class Store:
@contextmanager @contextmanager
def db(self): def db(self):
connection = sqlite3.connect(self.path, timeout=20) connection = sqlite3.connect(self.path, timeout=20)
previous = copy.deepcopy(getattr(self, 'run', None))
try: try:
with connection: with connection:
yield connection yield connection
except Exception:
if previous is not None:
self.run = previous
raise
finally: finally:
connection.close() connection.close()
@@ -147,10 +165,22 @@ class Store:
self.run['owner'] = owner self.run['owner'] = owner
if self.run['switch']: if self.run['switch']:
switch = self.run['switch'] switch = self.run['switch']
if account['uid'] != switch['from'] and data.get('extension', {}).get('idle') is True: ext = data.get('extension') or {}
if ext.get('ready') is not True:
self.run.update(running=False, reason='换号插件联动接口未就绪,请在原安装目录更新并重新加载插件、刷新网站后继续;未重新抽号')
self._persist(db)
return dict(action='idle', run=self.run)
if account['uid'] != switch['from'] and ext.get('idle') is True:
self.run.update(batch=self._batch(account), switch=None) self.run.update(batch=self._batch(account), switch=None)
self._persist(db) self._persist(db)
else: else:
# Pending means no click was issued. Time spent stopped, or
# waiting for the plugin, is not a failed login attempt.
elapsed = time.time() - switch.get('clicked_at', switch['created_at'])
if ext.get('paused') or (switch['state'] == 'clicked' and (elapsed > 120 or (elapsed > 10 and ext.get('idle')))):
self.run.update(running=False, reason='换号已暂停、超时或账号未改变,请检查插件并人工恢复,禁止重复抽号')
self._persist(db)
return dict(action='idle', run=self.run)
return dict(action='switch' if switch['state'] == 'pending' else 'switch_wait', run=self.run) return dict(action='switch' if switch['state'] == 'pending' else 'switch_wait', run=self.run)
if self.run['batch'] is None: if self.run['batch'] is None:
self.run['batch'] = self._batch(account) self.run['batch'] = self._batch(account)
@@ -158,6 +188,13 @@ class Store:
self.run.update(running=False, reason='检测到账号变化,请先核对当前任务') self.run.update(running=False, reason='检测到账号变化,请先核对当前任务')
self._persist(db) self._persist(db)
return dict(action='idle', run=self.run) return dict(action='idle', run=self.run)
if self.run.get('return_home'):
url = urlsplit(str(data.get('url', '')))
if url.scheme == 'https' and url.netloc in ('www.dww11.com', 'dww11.com') and url.path == '/' and not url.query:
self.run['return_home'] = False
self._persist(db)
else:
return dict(action='home', run=self.run)
if self.run['active']: if self.run['active']:
job = self._job(db, self.run['active']) job = self._job(db, self.run['active'])
return dict(action='resume', job=job, run=self.run) return dict(action='resume', job=job, run=self.run)
@@ -173,6 +210,7 @@ class Store:
self.run['switch'] = dict(state='pending', **{'from': account['uid']}, created_at=time.time()) self.run['switch'] = dict(state='pending', **{'from': account['uid']}, created_at=time.time())
self._persist(db) self._persist(db)
return dict(action='switch', run=self.run) return dict(action='switch', run=self.run)
job['title'] = short_title(job.get('original_title', job['title']))
job.update(state='preparing', account=account, fid=self.run['config']['fid'], error='') job.update(state='preparing', account=account, fid=self.run['config']['fid'], error='')
self.run['active'] = job['id'] self.run['active'] = job['id']
self._save(db, job) self._save(db, job)
@@ -184,12 +222,32 @@ 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'])
if not self.run['running'] or not self.run['switch'] or self.run['switch']['state'] != 'pending': if not self.run['running'] or not self.run['switch'] or self.run['switch']['state'] != 'pending':
raise ValueError('换号状态已改变,禁止重复点击') raise ValueError('换号状态已改变,禁止重复点击')
self.run['switch']['state'] = 'clicked' if data.get('epoch', self.run['epoch']) != self.run['epoch']:
raise ValueError('换号请求已过期')
self.run['switch'].update(state='clicked', clicked_at=time.time())
self._persist(db) self._persist(db)
return {'ok': True} return {'ok': True}
@@ -216,7 +274,7 @@ class Store:
transitions = {'preparing': {'importing', 'needs_attention'}, 'importing': {'formatting', 'needs_attention'}, transitions = {'preparing': {'importing', 'needs_attention'}, 'importing': {'formatting', 'needs_attention'},
'formatting': {'awaiting_user_review', 'needs_attention'}, 'formatting': {'awaiting_user_review', 'needs_attention'},
'awaiting_user_review': {'user_submitting', 'needs_attention'}, 'awaiting_user_review': {'user_submitting', 'needs_attention'},
'user_submitting': {'submit_unknown', 'needs_attention'}, 'submit_unknown': {'needs_attention'}} 'user_submitting': {'submit_unknown', 'needs_attention', 'rejected'}, 'submit_unknown': {'needs_attention'}}
if target not in transitions.get(job['state'], set()): if target not in transitions.get(job['state'], set()):
raise ValueError('任务状态不允许此操作:' + job['state'] + ' → ' + target) raise ValueError('任务状态不允许此操作:' + job['state'] + ' → ' + target)
if target in ('importing', 'formatting', 'awaiting_user_review') and (not self.run['running'] or data.get('epoch') != self.run['epoch']): if target in ('importing', 'formatting', 'awaiting_user_review') and (not self.run['running'] or data.get('epoch') != self.run['epoch']):
@@ -226,13 +284,27 @@ class Store:
job.update(submitted_at=time.time(), snapshot=data.get('snapshot', {}), attempt=secrets.token_hex(8)) job.update(submitted_at=time.time(), snapshot=data.get('snapshot', {}), attempt=secrets.token_hex(8))
if target == 'awaiting_user_review': if target == 'awaiting_user_review':
job['prepared'] = data.get('snapshot', {}) job['prepared'] = data.get('snapshot', {})
if target == 'needs_attention': if target in ('needs_attention', 'rejected'):
self.run.update(running=False, reason=job['error'] or '当前任务需要处理') self.run.update(running=False, reason=job['error'] or '当前任务需要处理')
self._persist(db) self._persist(db)
self._save(db, job) self._save(db, job)
return {'ok': True} return {'ok': True}
def reconcile(self, data):
if data.get('confirmed') is not True:
raise ValueError('请先确认这是当前文章的已受理结果')
with self.lock, self.db() as db:
if self.run['running']:
raise ValueError('请先暂停流程再补核历史受理记录')
job = self._job(db, data['id'])
if data.get('article_key') != job['article_key']:
raise ValueError('文章身份不匹配')
return self._accept({**data, 'owner': self.run['owner']}, count_batch=False)
def accept(self, data): def accept(self, data):
return self._accept(data)
def _accept(self, data, count_batch=True):
with self.lock, self.db() as db: with self.lock, self.db() as db:
self._check_owner(data['owner']) self._check_owner(data['owner'])
job = self._job(db, data['id']) job = self._job(db, data['id'])
@@ -240,33 +312,78 @@ class Store:
return {'ok': True, 'duplicate': True} return {'ok': True, 'duplicate': True}
if self.run['active'] != job['id'] or job['state'] not in SUBMITTED: if self.run['active'] != job['id'] or job['state'] not in SUBMITTED:
raise ValueError('只有已观察到人工提交的任务才能登记结果') raise ValueError('只有已观察到人工提交的任务才能登记结果')
if isinstance(data.get('evidence'), dict) and data['evidence'].get('adapter') == 'moderation_prompt':
if not count_batch:
raise ValueError('审核提示只用于当前人工提交任务,不能补核历史结果')
return self._accept_prompt(db, job, data)
url = urlsplit(str(data.get('post_url', ''))) url = urlsplit(str(data.get('post_url', '')))
import re
match = re.fullmatch(r'/thread/(\d+)\.html', url.path) match = re.fullmatch(r'/thread/(\d+)\.html', url.path)
if url.scheme != 'https' or url.netloc not in ('www.dww11.com', 'dww11.com') or not match: query = parse_qs(url.query)
thread_id = str(data.get('thread_id', ''))
if not re.fullmatch(r'[1-9]\d*', thread_id):
raise ValueError('缺少详情页真实主题 ID,不能以地址数字代替')
if url.scheme != 'https' or url.netloc not in ('www.dww11.com', 'dww11.com') or not (match or (url.path == '/forum.php' and query.get('mod') == ['viewthread'] and query.get('tid') == [thread_id])):
raise ValueError('未识别到帖子详情页') raise ValueError('未识别到帖子详情页')
if data.get('author_uid') != job['account']['uid'] or data.get('fid') != job['fid']: if data.get('author_uid') != job['account']['uid'] or data.get('fid') != job['fid']:
raise ValueError('结果页作者或版块与任务不符,请人工核对') raise ValueError('结果页作者或版块与任务不符,请人工核对')
if data.get('title') != job.get('snapshot', {}).get('title'): if ' '.join(str(data.get('title', '')).split()) != ' '.join(str(job.get('snapshot', {}).get('title', '')).split()):
raise ValueError('结果页标题与人工提交快照不一致') raise ValueError('结果页标题与人工提交快照不一致')
if data.get('state') not in ACCEPTED: if data.get('state') not in ACCEPTED:
raise ValueError('需要明确的公开或审核中状态') raise ValueError('需要明确的公开或审核中状态')
if not data.get('evidence'): if not data.get('evidence'):
raise ValueError('缺少页面结果证据') raise ValueError('缺少页面结果证据')
post_id = match[1] evidence = data['evidence']
if not isinstance(evidence, dict) or str(evidence.get('thread_id')) != thread_id:
raise ValueError('主题 ID 与页面证据不一致')
alias = db.execute('SELECT thread_id, job FROM post_aliases WHERE url=?', (url.geturl(),)).fetchone()
if alias and (alias[0] != thread_id or alias[1] != job['id']):
raise ValueError('帖子链接已绑定其他记录')
post_id = thread_id
existing = db.execute('SELECT job FROM posts WHERE id=?', (post_id,)).fetchone() existing = db.execute('SELECT job FROM posts WHERE id=?', (post_id,)).fetchone()
if existing and existing[0] != job['id']: if existing and existing[0] != job['id']:
raise ValueError('帖子已绑定其他任务,不能重复计数') raise ValueError('帖子已绑定其他任务,不能重复计数')
db.execute('INSERT OR IGNORE INTO posts VALUES(?,?)', (post_id, job['id'])) db.execute('INSERT OR IGNORE INTO posts VALUES(?,?)', (post_id, job['id']))
job.update(state=data['state'], post_id=post_id, post_url=url.geturl(), result=data['evidence']) db.execute('INSERT OR IGNORE INTO post_aliases VALUES(?,?,?)', (url.geturl(), thread_id, job['id']))
job.update(state=data['state'], thread_id=thread_id, post_id=post_id, first_post_pid=data.get('first_post_pid'), post_url=url.geturl(), result=evidence, integrity_issues=evidence.get('integrity_issues', []))
keys = ['article:' + job['article_key'], 'md:' + job['markdown_sha256']] + ['source:' + str(k) for k in job['source_keys']]
for key in keys:
db.execute('INSERT OR REPLACE INTO ledger VALUES(?,?)', (key, json.dumps(job, ensure_ascii=False)))
if count_batch:
self.run['batch']['count'] += 1
job['historical_reconciliation'] = not count_batch
self.run.update(active=None, return_home=True, not_before=time.time() + self.run['config']['interval'])
if job['integrity_issues']:
self.run.update(running=False, reason='网站已受理,内容核对发现差异,请检查帖子后明确继续')
self._save(db, job)
self._persist(db)
return {'ok': True, 'run': dict(self.run)}
def _accept_prompt(self, db, job, data):
"""Temporary acceptance rule: the forum's explicit moderation prompt only."""
evidence = data['evidence']
message = '新主题需要审核,您的帖子通过审核后才能显示'
text = ' '.join(str(evidence.get('observed_text', '')).split())
url = urlsplit(str(evidence.get('observed_url', '')))
if (data.get('state') != 'accepted_pending_moderation'
or evidence.get('message') != message or message not in text):
raise ValueError('缺少网站新主题审核提示')
if url.scheme != 'https' or url.netloc not in ('www.dww11.com', 'dww11.com'):
raise ValueError('审核提示必须来自论坛网站')
if not job.get('attempt') or data.get('attempt') != job['attempt']:
raise ValueError('审核提示与当前提交批次不一致')
# 未核对详情页,不虚构主题 ID、首帖 ID 或帖子地址。
job.update(state='accepted_pending_moderation', error='', result=evidence,
integrity_issues=[], historical_reconciliation=False,
acceptance_basis='moderation_prompt')
keys = ['article:' + job['article_key'], 'md:' + job['markdown_sha256']] + ['source:' + str(k) for k in job['source_keys']] keys = ['article:' + job['article_key'], 'md:' + job['markdown_sha256']] + ['source:' + str(k) for k in job['source_keys']]
for key in keys: for key in keys:
db.execute('INSERT OR REPLACE INTO ledger VALUES(?,?)', (key, json.dumps(job, ensure_ascii=False))) db.execute('INSERT OR REPLACE INTO ledger VALUES(?,?)', (key, json.dumps(job, ensure_ascii=False)))
self.run['batch']['count'] += 1 self.run['batch']['count'] += 1
self.run.update(active=None, not_before=time.time() + self.run['config']['interval']) self.run.update(active=None, return_home=True,
not_before=time.time() + self.run['config']['interval'])
self._save(db, job) self._save(db, job)
self._persist(db) self._persist(db)
return {'ok': True} return {'ok': True, 'run': dict(self.run)}
def resolve(self, data): def resolve(self, data):
"""Explicit local UI resolution, never an automatic retry of an unknown submission.""" """Explicit local UI resolution, never an automatic retry of an unknown submission."""
@@ -279,12 +396,33 @@ class Store:
if data.get('confirmed_not_accepted') is not True: if data.get('confirmed_not_accepted') is not True:
raise ValueError('必须先人工核对网站未受理,并清理编辑器/草稿') raise ValueError('必须先人工核对网站未受理,并清理编辑器/草稿')
job.update(state='queued' if data.get('retry') else 'cancelled', error='', snapshot={}) job.update(state='queued' if data.get('retry') else 'cancelled', error='', snapshot={})
for key in ('submitted_at', 'attempt', 'prepared'):
job.pop(key, None)
if self.run['active'] == job['id']: if self.run['active'] == job['id']:
self.run.update(active=None, owner=None) self.run.update(active=None, owner=None)
self._save(db, job) self._save(db, job)
self._persist(db) self._persist(db)
return {'ok': True} return {'ok': True}
def remove(self, data):
"""Remove queue rows only; retain files, acceptance ledger and post bindings."""
ids = data.get('ids')
if not isinstance(ids, list) or not ids or not all(isinstance(id, str) and id for id in ids):
raise ValueError('请选择要移除的文章')
ids = list(dict.fromkeys(ids))
with self.lock, self.db() as db:
if self.run['running']:
raise ValueError('请先暂停流程,再移除文章')
# Validate the entire selection before deleting anything.
for id in ids:
job = self._job(db, id)
if id == self.run['active']:
raise ValueError('当前任务不能直接移除,请先通过“人工核对 / 处理”核对并移出')
if job['state'] in SUBMITTED or (job.get('submitted_at') and job['state'] not in ACCEPTED):
raise ValueError('文章提交结果尚未核对,请先核对并处理后再移除')
db.executemany('DELETE FROM jobs WHERE id=?', [(id,) for id in ids])
return {'ok': True, 'removed': len(ids)}
def takeover(self, data): def takeover(self, data):
with self.lock, self.db() as db: with self.lock, self.db() as db:
if self.run['running']: if self.run['running']:
+6
View File
@@ -3,10 +3,16 @@
- `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`:导入器回归与自动模式的停止/冲突保护。
- `browser.test.cjs`:本地页面、移动布局与模拟编辑器;从不请求真实论坛。 - `browser.test.cjs`:本地页面、移动布局与模拟编辑器;从不请求真实论坛。
- `queue-ui.test.cjs`:独立模拟队列的选择、全选、轮询保留选择、取消/失败、批量移除、运行与当前任务保护、空列表及手机布局。Node 环境可加载 Playwright 后运行 `node test/queue-ui.test.cjs`;不启动本地服务、不访问真实队列。
- `artifacts/`:本轮页面截图,属于生成产物。 - `artifacts/`:本轮页面截图,属于生成产物。
原始受理去重记录继续保存在 `../发布去重记录.json`,服务启动时自动导入;没有作为测试残留移走或删除。 原始受理去重记录继续保存在 `../发布去重记录.json`,服务启动时自动导入;没有作为测试残留移走或删除。
- `results-browser.test.cjs`:0.2.0 结果适配回归,使用临时 Chrome 与模拟真实结构;阻断全部网络,覆盖真实 TID/PID、首帖作者范围、标题审核标志、图片占位及明确标题拒绝。需要 Playwright。
- 新增 Python 回归覆盖补核 HTTP 鉴权、历史受理不计当前批次、返回首页步骤、旧标识保留、事务回滚、停止后的被动记账与换号未改变 UID。
- 建议使用 Node.js 24 LTS;本机旧 Node 20 的测试报告解析中文名称异常,使用已安装的 Node 24 完成验证。
+42
View File
@@ -11,3 +11,45 @@ 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('悬浮窗开始、暂停、跳过与开始响应竞态',async()=>{
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}'});
const start=elements.get('start'),pause=elements.get('pause');
assert.equal(start.textContent,'开始 / 继续');assert.equal(pause.textContent,'暂停');
let started=start.onclick();assert.equal(start.disabled,true);
assert.ok(requests.at(-1).url.endsWith('/bridge/start'));
requests.at(-1).onload({status:200,responseText:'{"running":true}'});await started;
doc.dispatchEvent(new Event('dww11:run-state',{detail:true}));assert.equal(start.disabled,true);
const paused=pause.onclick();assert.equal(emitted.at(-1).type,'dww11:auto-stop');
assert.ok(requests.at(-1).url.endsWith('/bridge/pause'));
requests.at(-1).onload({status:200,responseText:'{"running":false}'});await paused;
doc.dispatchEvent(new Event('dww11:run-state',{detail:false}));assert.equal(start.disabled,false);
// Pause while Start is still in flight: a late Start must be followed by Pause.
started=start.onclick();const lateStart=requests.at(-1);
const pausing=pause.onclick();requests.at(-1).onload({status:200,responseText:'{"running":false}'});await pausing;
lateStart.onload({status:200,responseText:'{"running":true}'});
await new Promise(resolve=>setImmediate(resolve));
assert.ok(requests.at(-1).url.endsWith('/bridge/pause'));
requests.at(-1).onload({status:200,responseText:'{"running":false}'});await started;
assert.match(elements.get('status').textContent,/已暂停/);
});
+6 -3
View File
@@ -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,7 +46,10 @@ 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/results.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');
await mock.waitForTimeout(2400); await mock.waitForTimeout(2400);
@@ -58,7 +61,7 @@ const output=path.join(__dirname,'artifacts');fs.mkdirSync(output,{recursive:tru
assert.equal(after,before,'repeated formatting should be idempotent'); assert.equal(after,before,'repeated formatting should be idempotent');
await mock.locator('#postsubmit').click(); await mock.locator('#postsubmit').click();
await mock.waitForFunction(()=>window.job.state==='user_submitting'); await mock.waitForFunction(()=>window.job.state==='user_submitting');
await mock.evaluate(()=>history.pushState({},'', '/thread/999.html')); await mock.evaluate(()=>{history.pushState({},'', '/thread/999.html');const saved=JSON.parse(sessionStorage.getItem('auto-news-current'));saved.clickedAt-=60000;sessionStorage.setItem('auto-news-current',JSON.stringify(saved));});
await mock.waitForFunction(()=>window.job.state==='submit_unknown'); await mock.waitForFunction(()=>window.job.state==='submit_unknown');
assert.equal(await mock.evaluate(()=>window.flow.running),false); assert.equal(await mock.evaluate(()=>window.flow.running),false);
assert.equal(await mock.evaluate(()=>window.events.some(e=>e.route==='accept')),false,'unknown page must not count accepted'); assert.equal(await mock.evaluate(()=>window.events.some(e=>e.route==='accept')),false,'unknown page must not count accepted');
+56
View File
@@ -0,0 +1,56 @@
'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({holdStart=false,holdResume=false,flow=null,helloError=false}={}) {
const elements=[],handlers=new Map(),routes=[];
let finishStart,finishResume;
function element(){
const attrs=new Map();
const el={style:{},hidden:false,disabled:false,children:[],
append(...nodes){this.children.push(...nodes);},prepend(){},before(){},remove(){},
addEventListener(type,fn){this[type]=fn;},removeAttribute(key){attrs.delete(key);},
setAttribute(key,value){attrs.set(key,value);},getAttribute:key=>attrs.get(key)??null,
attachShadow:()=>element()};
elements.push(el);return el;
}
const doc={body:element(),createElement:element,querySelector:()=>null,querySelectorAll:()=>[],
addEventListener(type,fn){handlers.set(type,fn);}};
const context={document:doc,location:{origin:'https://www.dww11.com',hostname:'www.dww11.com',href:'https://www.dww11.com/'},
URL,Node:{TEXT_NODE:3},MutationObserver:class{observe(){}},setTimeout,
chrome:{runtime:{sendMessage:async({route})=>{
routes.push(route);
if(route==='/hello')return helloError?{ok:false,error:'unavailable'}:{ok:true,data:{flow,total:1}};
if(route==='/start'&&holdStart)return new Promise(resolve=>{finishStart=()=>resolve({ok:true,data:{flow:{id:'test',phase:'logout',paused:false}}});});
if(route==='/resume'&&holdResume)return new Promise(resolve=>{finishResume=()=>resolve({ok:true,data:{flow:{id:'test',phase:'logout',paused:false}}});});
if(route==='/suspend')return {ok:true,data:{}};
throw new Error('Unexpected route '+route);
}}}};
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../../dww11-account-switch-browser-extension/content.js'),'utf8'),context);
return {routes,state:()=>JSON.parse(elements.find(e=>e.id==='dww11-extension-panel').getAttribute('data-auto-news-state')),
click:async(text)=>{elements.find(e=>e.textContent===text).click();await flush();},
stop:()=>handlers.get('dww11:auto-stop')(),finishStart:()=>finishStart(),finishResume:()=>finishResume()};
}
test('插件公开生命周期状态,不包含账号和凭据',async()=>{
const f=fixture();await flush();
assert.deepEqual(f.state(),{ready:true,idle:true,paused:false});
const failed=fixture({helloError:true});await flush();
assert.deepEqual(failed.state(),{ready:false,idle:false,paused:false});
});
test('抽号响应返回前终止,保留该次抽号并暂停,不执行登录',async()=>{
const f=fixture({holdStart:true});await flush();await f.click('登陆下一帐号');
assert.equal(f.state().idle,false);f.stop();f.finishStart();await flush();
assert.deepEqual(f.routes,['/hello','/start','/suspend']);
assert.deepEqual(f.state(),{ready:true,idle:false,paused:true});
});
test('恢复响应返回前终止,不执行登录或再次抽号',async()=>{
const f=fixture({holdResume:true,flow:{id:'test',paused:true,phase:'logout'}});await flush();
await f.click('继续未完成流程');f.stop();f.finishResume();await flush();
assert.deepEqual(f.routes,['/hello','/resume','/suspend']);
assert.equal(f.state().paused,true);
});
+83 -4
View File
@@ -29,12 +29,11 @@ test('测试文章:标题、3 张配图、插入顺序、来源与段落完整
assert.equal(output, originalBody); assert.equal(output, originalBody);
}); });
test('缺图、远程图片、超出目录、超大图片与超长标题在上传前被拒绝', () => { test('缺图、远程图片、超出目录与超大图片在上传前被拒绝', () => {
assert.throws(() => core.compile('# 标题\n\n![](missing.jpg)', 'a.md', new Map()), /找不到配图/); assert.throws(() => core.compile('# 标题\n\n![](missing.jpg)', 'a.md', new Map()), /找不到配图/);
assert.throws(() => core.compile('# 标题\n\n![](https://x/a.jpg)', 'a.md', new Map()), /目录内/); assert.throws(() => core.compile('# 标题\n\n![](https://x/a.jpg)', 'a.md', new Map()), /目录内/);
assert.throws(() => core.imagePath('a.md', '../secret.jpg'), /超出/); assert.throws(() => core.imagePath('a.md', '../secret.jpg'), /超出/);
assert.throws(() => core.imagePath('a.md', '%2fetc/passwd'), /相对路径/); assert.throws(() => core.imagePath('a.md', '%2fetc/passwd'), /相对路径/);
assert.throws(() => core.compile('# ' + '长'.repeat(81) + '\n正文', 'a.md', new Map()), /80/);
assert.throws(() => core.compile('# 标题\n\n![](big.png)', 'a.md', new Map([['big.png', {size:20*1024*1024}]])), /20 MB/); assert.throws(() => core.compile('# 标题\n\n![](big.png)', 'a.md', new Map([['big.png', {size:20*1024*1024}]])), /20 MB/);
}); });
@@ -73,7 +72,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 +86,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 +176,76 @@ 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('自动流程关闭图片检查后,不等待或拒绝无法显示的图片', async () => {
const h=harness({brokenImages:true});await h.api.stageFiles(articleFiles());
const result=await h.api.importPrepared({automatic:true,verifyImages:false});
assert.ok(result.attachmentOrder.length);assert.ok(h.ids.get('subject').value);
});
test('链接内图片、多行图文链接均参与上传并按原位置渲染', () => {
const md='# 标题\n\n[![宣传](image004.jpg)\n\n### 宣传标题\n\n说明](https://example.com/promo)\n\n![](image001.jpg)';
const article=core.compile(md,'a.md',new Map([['image004.jpg',new Blob(['x'])],['image001.jpg',new Blob(['y'])]]));
assert.deepEqual([...article.images.keys()],['image004.jpg','image001.jpg']);
const rendered=article.render(new Map([['image004.jpg','104'],['image001.jpg','101']]));
assert.match(rendered,/\[url=https:\/\/example.com\/promo\]\[attachimg\]104\[\/attachimg\]/);
assert.deepEqual([...rendered.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g)].map(x=>x[1]),['104','101']);
assert.equal(rendered.includes('!['),false);
});
test('图片重复引用必须保留每个位置,不能只校验去重后的编号', async () => {
const img={id:'',getAttribute:n=>n==='aid'?'attachimg_12':null,complete:true,naturalWidth:100,addEventListener(){},removeEventListener(){}};
await core.verifyEditorImages({querySelectorAll:()=>[img,img]},['12','12']);
await assert.rejects(core.verifyEditorImages({querySelectorAll:()=>[img]},['12','12']),/数量或顺序/);
});
test('正文图片尚在加载时等待 load;终止后释放等待', async () => {
const make=()=>{const events=new Map();return {events,id:'',complete:false,naturalWidth:0,getAttribute:n=>n==='aid'?'attachimg_8':null,addEventListener:(n,f)=>events.set(n,f),removeEventListener:n=>events.delete(n)};};
const image=make();let finished=false;
const loaded=core.verifyEditorImages({querySelectorAll:()=>[image]},['8']).then(()=>finished=true);
await Promise.resolve();assert.equal(finished,false);
image.complete=true;image.naturalWidth=100;image.events.get('load')();await loaded;
assert.equal(image.events.size,0);
const pending=make(),controller=new AbortController();
const cancelled=core.verifyEditorImages({querySelectorAll:()=>[pending]},['8'],controller.signal);
controller.abort();await assert.rejects(cancelled,/终止/);assert.equal(pending.events.size,0);
});
test('标题按 UTF-8 预算截断,目录和文章包保持一致且重复处理稳定', async () => {
const cases = [
['A'.repeat(75), 'A'.repeat(75)],
['A'.repeat(76), 'A'.repeat(75) + '...'],
['中'.repeat(25), '中'.repeat(25)],
['中'.repeat(26), '中'.repeat(25) + '...'],
['A中,'.repeat(12), 'A中,'.repeat(10) + 'A中...'],
['😀'.repeat(20), '😀'.repeat(18) + '...'],
];
for (const [title, expected] of cases) {
assert.equal(core.shortTitle(title), expected);
assert.equal(core.shortTitle(expected), expected);
assert.ok(Buffer.byteLength(expected) <= 78);
const markdown = '# ' + title + '\n正文';
const direct = core.compile(markdown, 'a.md', new Map());
const packaged = await core.preparePackage({schemaVersion:1, markdown, title, images:[]});
assert.equal(direct.title, expected);
assert.equal(packaged.title, expected);
const python = execFileSync('python3', ['-c', 'import sys; from articles import short_title; print(short_title(sys.argv[1]), end="")', title], {cwd:path.join(__dirname, '..'), encoding:'utf8'});
assert.equal(python, expected);
}
});
test('长标题预览、实际填写和返回结果一致', async () => {
const h = harness();
const title = '新闻,'.repeat(20);
await h.api.stagePackage({schemaVersion:1, markdown:'# ' + title + '\n正文', images:[]});
const expected = '新闻,'.repeat(8) + '新...';
assert.equal(h.api.getState().title, expected);
const result = await h.api.importPrepared();
assert.equal(h.ids.get('subject').value, expected);
assert.equal(result.title, expected);
assert.equal(h.requests.length, 0);
});
+73
View File
@@ -0,0 +1,73 @@
'use strict';
// Isolated UI regression: all requests use fixture data, never the user's queue.
const {chromium}=require('playwright');
const fs=require('node:fs');
const path=require('node:path');
const assert=require('node:assert/strict');
const ROOT=path.resolve(__dirname,'..');
(async()=>{
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try{
const page=await browser.newPage({viewport:{width:1440,height:1000}}),errors=[],requests=[];
page.on('pageerror',error=>errors.push(error.message));
const data={run:{running:false,active:'active',config:{},reason:'已暂停',batch:null},clients:[],
jobs:['a','b','active'].map(id=>({id,title:'测试文章 '+id,original_title:'测试文章 '+id,image_count:0,source:'测试来源',state:id==='active'?'awaiting_user_review':'queued'})),
download:{status:'idle',saved:0,target:0,skipped:0,failed:0,logs:[]}};
let failRemoval=false;
await page.route('**/*',async route=>{
const url=new URL(route.request().url());
if(url.hostname!=='queue.test')return route.abort();
if(url.pathname==='/api/session')return route.fulfill({json:{token:'fixture'}});
if(url.pathname==='/api/state')return route.fulfill({json:data});
if(url.pathname==='/api/remove'){
const body=route.request().postDataJSON();requests.push(body);
if(failRemoval)return route.fulfill({status:400,json:{error:'模拟移除失败'}});
data.jobs=data.jobs.filter(job=>!body.ids.includes(job.id));
return route.fulfill({json:{ok:true,removed:body.ids.length}});
}
const files={'/':'index.html','/app.js':'app.js','/style.css':'style.css'};
if(files[url.pathname])return route.fulfill({path:path.join(ROOT,'static',files[url.pathname])});
return route.abort();
});
await page.goto('http://queue.test/');
await page.waitForFunction(()=>document.querySelectorAll('.job-select').length===3);
const box=id=>page.locator(`.job-select[value="${id}"]`);
assert.equal(await page.locator('#remove-selected').isDisabled(),true);
assert.equal(await box('active').isDisabled(),true);
await box('a').check();
assert.equal(await page.locator('#select-all').evaluate(el=>el.indeterminate),true);
await page.evaluate(()=>refresh());
assert.equal(await box('a').isChecked(),true,'polling preserves selection');
page.once('dialog',dialog=>dialog.dismiss());
await page.locator('#remove-selected').click();
assert.equal(requests.length,0,'cancel does not delete');
failRemoval=true;
page.once('dialog',dialog=>dialog.accept());
await page.locator('#remove-selected').click();
await page.getByText('模拟移除失败',{exact:true}).waitFor();
assert.equal(await box('a').isChecked(),true,'failure preserves selection');
failRemoval=false;
page.once('dialog',dialog=>dialog.accept());
await page.locator('#remove-selected').click();
await page.waitForFunction(()=>document.querySelectorAll('.job-select').length===2);
assert.deepEqual(requests.at(-1),{ids:['a']});
assert.equal(await box('b').count(),1,'unselected article remains');
await page.locator('#select-all').check();
assert.equal(await box('b').isChecked(),true);
assert.equal(await box('active').isChecked(),false);
data.run.running=true;await page.evaluate(()=>refresh());
assert.equal(await page.locator('#remove-selected').isDisabled(),true,'running flow blocks removal');
data.run.running=false;data.run.active=null;await page.evaluate(()=>refresh());
await page.locator('#select-all').check();
await page.setViewportSize({width:390,height:844});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'mobile has no horizontal overflow');
page.once('dialog',dialog=>dialog.accept());
await page.locator('#remove-selected').click();
await page.locator('#empty').waitFor({state:'visible'});
assert.equal(await page.locator('#total').textContent(),'0');
assert.equal(await page.locator('#select-all').isDisabled(),true);
assert.equal(await page.locator('#remove-selected').isDisabled(),true);
assert.deepEqual(errors,[]);
console.log('PASS queue UI: selection, polling, cancellation, failure, selected deletion, protected task, running state, select all, empty state, mobile layout');
}finally{await browser.close();}
})().catch(error=>{console.error(error);process.exitCode=1;});
+41
View File
@@ -0,0 +1,41 @@
'use strict';
// Separate temporary browser; all network requests blocked. No user profile or forum access.
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {chromium}=require('playwright');
const path=require('node:path');
const gif='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
function html({moderation=true,author=true,duplicateTid=false,lazy=false}={}) {
return `<div class="mumucms_pt"><div class="z"><a href="https://www.dww11.com/forum.php?mod=forumdisplay&fid=90">版块</a></div></div>
<table><tr><td class="plc mumucms_ftit vwthd"><h1><span id="thread_subject">原创标题</span></h1>
<span class="xg1">${moderation?'(审核中)':''}<a href="https://www.dww11.com/forum.php?mod=viewthread&tid=236999">复制链接</a></span>
${duplicateTid?'<a href="https://www.dww11.com/forum.php?mod=viewthread&tid=999">冲突链接</a>':''}</td></tr></table>
<a id="mumucms_username" href="https://www.dww11.com/home.php?mod=space&uid=999">登录者</a>
<div id="post_238791"><table id="pid238791"><tr><td class="plc"><a id="postnum238791">楼主</a><div class="pti"><div class="authi">${author?'<a href="https://www.dww11.com/home.php?mod=space&uid=123">首帖作者</a>':''}</div></div>
<div><table><tr><td id="postmessage_238791">原创正文<img id="aimg_42" aid="42" src="${lazy?'https://www.dww11.com/static/image/common/none.gif':gif}" file="https://www.dww11.com/real.png"></td></tr></table></div></td></tr></table></div>
<div id="post_999"><div class="authi"><a href="https://www.dww11.com/home.php?mod=space&uid=456">回帖作者</a></div><div id="postmessage_999">回帖(审核中)</div></div>`;
}
test.skip('暂时停用详情页核对;结果页适配:真实主题、首帖范围、状态、图文与延迟加载',async()=>{
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try {
const page=await browser.newPage();await page.route('**/*',r=>r.abort());
async function inspect(options={},snapshot={title:'原创标题',text:'原创正文',images:[{aid:'attachimg_42'}]}) {
await page.setContent(html(options));await page.addScriptTag({path:path.resolve(__dirname,'../browser/results.js')});
return page.evaluate(snapshot=>window.DWW11Results.inspect(document,'https://www.dww11.com/thread/23699911.html',snapshot),snapshot);
}
const accepted=await inspect();assert.equal(accepted.kind,'accepted');assert.equal(accepted.thread_id,'236999');assert.equal(accepted.first_post_pid,'238791');assert.equal(accepted.author_uid,'123');assert.equal(accepted.fid,90);assert.deepEqual(accepted.evidence.integrity_issues,[]);
assert.equal((await inspect({author:false})).kind,'incomplete','top login/replies cannot supply the author');
assert.equal((await inspect({moderation:false})).kind,'incomplete','absence of moderation is not public proof');
assert.equal((await inspect({duplicateTid:true})).kind,'incomplete');
const lazy=await inspect({lazy:true});assert.equal(lazy.evidence.images[0].loaded,false);
assert.ok(lazy.evidence.integrity_issues.length);
const changed=await inspect({}, {text:'被修改的正文',images:[{aid:'attachimg_99'}]});assert.equal(changed.evidence.integrity_issues.length,2);
await page.setContent('<div id="messagetext">新主题需要审核,您的帖子通过审核后才能显示<a href="https://www.dww11.com/thread/23699911.html">继续</a></div>');
await page.addScriptTag({path:path.resolve(__dirname,'../browser/results.js')});
const prompt=await page.evaluate(()=>window.DWW11Results.inspect(document,'https://www.dww11.com/forum.php?topicsubmit=yes'));
assert.equal(prompt.kind,'waiting');assert.ok(prompt.prompt.includes('需要审核'));
await page.setContent('<div id="messagetext">抱歉,您填写的标题超过 80 个字符限制</div>');
await page.addScriptTag({path:path.resolve(__dirname,'../browser/results.js')});
assert.equal((await page.evaluate(()=>window.DWW11Results.inspect(document,'https://www.dww11.com/forum.php'))).kind,'rejected');
}finally{await browser.close();}
});
+22
View File
@@ -0,0 +1,22 @@
'use strict';
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {inspect}=require('../browser/results.js');
const message='新主题需要审核,您的帖子通过审核后才能显示';
function doc(text,selector='#messagetext') {
return {querySelector:s=>s==='#messagetext, .messagetext'?{textContent:text}:null};
}
test('完整审核提示即成功,不依赖详情页字段或跳转链接',()=>{
const result=inspect(doc(message+',现在将转入首页'),'https://www.dww11.com/forum.php');
assert.equal(result.kind,'accepted_prompt');
assert.equal(result.state,'accepted_pending_moderation');
assert.equal(result.evidence.message,message);
assert.equal(result.thread_id,undefined);
});
test('只有审核中、缺失提示或相似提示不能判成功',()=>{
for(const text of ['', '(审核中)', '新主题需要审核', '您的帖子通过审核后才能显示'])
assert.equal(inspect(doc(text),'https://www.dww11.com/').kind,'waiting');
});
test('明确标题拒绝继续暂停',()=>{
assert.equal(inspect(doc('抱歉,您填写的标题超过 80 个字符限制'),'https://www.dww11.com/forum.php').kind,'rejected');
});
+152
View File
@@ -0,0 +1,152 @@
'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,result=null,stopOnAccept=false}={}) {
const handlers=new Map(),calls=[],navigations=[],storage=new Map(),timers=new Set();
const job={id:'first',state:'preparing',title:'标题',source:'来源',fid:125,account:{uid:'123',name:'账号'}};
const run={running:true,epoch:1,active:job.id,owner:'tab-a'};
const body={innerHTML:'正文',innerText:'正文',querySelectorAll:()=>[]};
const subject={value:''};let tick,finishImport,finishHeartbeat,mutation,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(body.state==='user_submitting'){job.attempt='attempt-1';job.snapshot=body.snapshot;}}
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==='current')value={job,run};
if(route==='accept'){job.state=body.state;run.active=null;value={ok:true};if(stopOnAccept)run.running=false;}
if(route==='stop')run.running=false;
reply(id,value);
});
let sequence=0;
const context={MutationObserver:class{constructor(fn){mutation=fn;}observe(){}},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={DWW11Results:result?{inspect:()=>result,clean:v=>String(v||'').trim()}:null,DWW11Importer:{stagePackage:async()=>({}),importPrepared:async({signal,verifyImages})=>{
assert.equal(verifyImages,false);
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()=>{throw new Error("自动流程不应检查图片");}},DWW11Formatter:{format(source,options){assert.equal(options.verifyImages,false);}}};
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,
mutate:()=>mutation(),setResult:value=>{result=value;},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();}
});
const acceptedResult={kind:'accepted_prompt',state:'accepted_pending_moderation',evidence:{adapter:'moderation_prompt',message:'新主题需要审核,您的帖子通过审核后才能显示',observed_text:'新主题需要审核,您的帖子通过审核后才能显示',observed_url:'https://www.dww11.com/forum.php?topicsubmit=yes'}};
for(const stopOnAccept of [false,true])test(`受理后先持久记账;停止=${stopOnAccept} 时禁止回首页`,async()=>{
const f=fixture({result:acceptedResult,stopOnAccept});try{
await flush();f.doc.dispatchEvent(new f.Event('click',{isTrusted:true,target:{closest:()=>true}}));await flush();
await f.tick();await flush();
assert.equal(f.calls.filter(x=>x.route==='accept').length,1);
assert.equal(f.calls.find(x=>x.route==='accept').body.attempt,'attempt-1');
assert.deepEqual(f.navigations,stopOnAccept?[]:['https://www.dww11.com/']);
assert.equal(f.storage.has('auto-news-current'),false);
}finally{f.close();}
});
test('审核提示无需详情页标题、作者、版块和图文信息即可记账',async()=>{
const f=fixture({result:{kind:'accepted_prompt',state:'accepted_pending_moderation',evidence:acceptedResult.evidence}});try{
await flush();f.doc.dispatchEvent(new f.Event('click',{isTrusted:true,target:{closest:()=>true}}));await flush();await f.tick();
assert.equal(f.calls.filter(x=>x.route==='accept').length,1);
assert.equal(f.run.running,true);assert.deepEqual(f.navigations,['https://www.dww11.com/']);
}finally{f.close();}
});
test('详情页审核中标志不再单独触发记账',async()=>{
const f=fixture({result:{kind:'accepted',author_uid:'123',title:'标题'}});try{
await flush();f.doc.dispatchEvent(new f.Event('click',{isTrusted:true,target:{closest:()=>true}}));await flush();await f.tick();
assert.equal(f.calls.some(x=>x.route==='accept'),false);assert.deepEqual(f.navigations,[]);
}finally{f.close();}
});
test('明确标题拒绝只记录并暂停,不修稿或重试发表',async()=>{
const f=fixture({result:{kind:'rejected',error:'标题超过80字符'}});try{
await flush();f.doc.dispatchEvent(new f.Event('click',{isTrusted:true,target:{closest:()=>true}}));await flush();await f.tick();
assert.equal(f.job.state,'rejected');assert.equal(f.calls.some(x=>x.route==='accept'),false);assert.deepEqual(f.navigations,[]);
}finally{f.close();}
});
test('短暂审核提示在 DOM 变化时缓存,提示消失后仍可持久记账',async()=>{
const f=fixture({result:acceptedResult});try{
await flush();f.mutate();await flush();
assert.equal(f.calls.some(x=>x.route==='accept'),false,'人工提交前不观察成功');
f.doc.dispatchEvent(new f.Event('click',{isTrusted:true,target:{closest:()=>true}}));await flush();
f.mutate();f.setResult({kind:'waiting'});await flush();await f.tick();
assert.equal(f.calls.filter(x=>x.route==='accept').length,1);
assert.deepEqual(f.navigations,['https://www.dww11.com/']);
}finally{f.close();}
});
+55
View File
@@ -70,6 +70,61 @@ 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_start_and_pause(self):
store = self.controller.store
with self.assertRaises(ValueError):
self.controller.bridge('start', {})
store.configure(dict(fid=95, minimum=1, maximum=1, interval=0, history_checked=True))
for action, expected in [('start', True), ('pause', False), ('start', True)]:
code, body = self.request('/bridge/' + action, {}, token=self.controller.bridge_token)
self.assertEqual(code, 200)
self.assertEqual(json.loads(body)['running'], expected)
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'])
def test_ui_remove_selected_articles(self):
store = self.controller.store
store.enqueue(ROOT / 'test/fixtures/article')
ids = [job['id'] for job in store.state()['jobs']]
with self.assertRaises(HTTPError):
self.request('/api/remove', {'ids': ids}, token=self.controller.bridge_token)
with self.assertRaises(HTTPError):
self.request('/bridge/remove', {'ids': ids}, token=self.controller.bridge_token)
code, body = self.request('/api/remove', {'ids': ids}, token=self.controller.ui_token)
self.assertEqual(code, 200)
self.assertEqual(json.loads(body)['removed'], 1)
self.assertEqual(store.state()['jobs'], [])
def test_ui_historical_result_reconciliation(self):
store = self.controller.store
store.enqueue(ROOT / 'test/fixtures/article')
store.configure(dict(fid=125, minimum=1, maximum=1, interval=0, history_checked=True))
store.control('start')
job = store.next(dict(owner='tab-a', account=dict(uid='123', name='测试账号')))['job']
for state in ('importing','formatting','awaiting_user_review','user_submitting'):
store.event(dict(owner='tab-a',id=job['id'],state=state,epoch=store.run['epoch'],snapshot={'title':job['title']}))
store.control('stop')
data=dict(id=job['id'],article_key=job['article_key'],confirmed=True,thread_id='236999',first_post_pid='238791',
post_url='https://www.dww11.com/thread/23699911.html',title=job['title'],author_uid='123',fid=125,
state='accepted_pending_moderation',evidence={'thread_id':'236999'})
with self.assertRaises(HTTPError):
self.request('/api/reconcile',data,token=self.controller.bridge_token)
self.assertEqual(self.request('/api/reconcile',data,token=self.controller.ui_token)[0],200)
self.assertEqual(store.run['batch']['count'],0)
self.assertFalse(store.run['running'])
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+279 -3
View File
@@ -22,7 +22,7 @@ class QueueTests(unittest.TestCase):
self.md.write_text('# 测试新闻\n\n正文内容。\n\n来源:测试来源,如有侵权,请联系删除。\n') self.md.write_text('# 测试新闻\n\n正文内容。\n\n来源:测试来源,如有侵权,请联系删除。\n')
self.store = Store(self.root / 'queue.sqlite3') self.store = Store(self.root / 'queue.sqlite3')
self.store.configure(dict(fid=125, minimum=1, maximum=1, interval=0, history_checked=True)) self.store.configure(dict(fid=125, minimum=1, maximum=1, interval=0, history_checked=True))
self.owner = dict(owner='tab-a', account=dict(uid='123', name='测试账号'), extension={'idle': True}) self.owner = dict(owner='tab-a', account=dict(uid='123', name='测试账号'), extension={'ready': True, 'idle': True}, url='https://www.dww11.com/')
def tearDown(self): def tearDown(self):
self.tmp.cleanup() self.tmp.cleanup()
@@ -42,7 +42,54 @@ class QueueTests(unittest.TestCase):
def accept(self, job): def accept(self, job):
return self.store.accept(dict(owner='tab-a', id=job['id'], post_url='https://www.dww11.com/thread/12345.html', return self.store.accept(dict(owner='tab-a', id=job['id'], post_url='https://www.dww11.com/thread/12345.html',
author_uid='123', fid=125, title=job['title'], state='accepted_pending_moderation', evidence={'test': 'fixture only'})) thread_id='123', first_post_pid='456', author_uid='123', fid=125, title=job['title'], state='accepted_pending_moderation', evidence={'thread_id': '123', 'test': 'fixture only'}))
def prompt_data(self, job):
current = self.store.state()['jobs'][0]
message = '新主题需要审核,您的帖子通过审核后才能显示'
return dict(owner='tab-a', id=job['id'], attempt=current.get('attempt'),
state='accepted_pending_moderation', evidence=dict(adapter='moderation_prompt',
message=message, observed_text=message + ',现在将转入首页',
observed_url='https://www.dww11.com/forum.php?topicsubmit=yes'))
def test_prompt_accept_counts_once_and_persists_dedup(self):
job = self.begin()
self.submit(job)
data = self.prompt_data(job)
self.store.accept(data)
self.assertTrue(self.store.accept(data)['duplicate'])
state = self.store.state()
self.assertEqual(state['run']['batch']['count'], 1)
self.assertIsNone(state['run']['active'])
self.assertTrue(state['run']['return_home'])
self.assertTrue(state['run']['running'])
saved = state['jobs'][0]
self.assertEqual(saved['state'], 'accepted_pending_moderation')
self.assertEqual(saved.get('post_url'), '')
self.assertNotIn('thread_id', saved)
restarted = Store(self.root / 'queue.sqlite3')
self.assertIn('已受理', restarted.enqueue(self.article))
def test_prompt_requires_manual_submission_and_current_attempt(self):
job = self.begin()
with self.assertRaisesRegex(ValueError, '人工提交'):
self.store.accept(self.prompt_data(job))
self.submit(job)
data = self.prompt_data(job)
for changed in ({'attempt': 'old'}, {'evidence': {**data['evidence'], 'observed_text': '(审核中)'}},
{'evidence': {**data['evidence'], 'observed_url': 'https://example.com/'}}):
with self.assertRaises(ValueError):
self.store.accept({**data, **changed})
self.assertEqual(self.store.run['batch']['count'], 0)
def test_prompt_after_stop_records_without_resuming(self):
job = self.begin()
self.submit(job)
data = self.prompt_data(job)
self.store.control('stop')
self.store.accept(data)
self.assertFalse(self.store.run['running'])
self.assertEqual(self.store.run['batch']['count'], 1)
def test_stable_identity_and_original_preserved(self): def test_stable_identity_and_original_preserved(self):
before = self.md.read_bytes() before = self.md.read_bytes()
@@ -57,9 +104,26 @@ class QueueTests(unittest.TestCase):
def test_title_does_not_break_emoji(self): def test_title_does_not_break_emoji(self):
short = short_title('😀' * 50) short = short_title('😀' * 50)
self.assertTrue(short.endswith('...')) self.assertTrue(short.endswith('...'))
self.assertLessEqual(len(short.encode('utf-16-le')) // 2, 80) self.assertLessEqual(len(short.encode('utf-8')), 78)
self.assertEqual(short_title('短标题'), '短标题') self.assertEqual(short_title('短标题'), '短标题')
def test_existing_queued_title_updates_before_import_only(self):
original = '中' * 30
self.md.write_text('# ' + original + '\n\n正文。\n\n来源:测试,如有侵权,请联系删除。')
self.store.enqueue(self.article)
with self.store.db() as db:
job = self.store.state()['jobs'][0]
job['title'] = original # Simulate a queue created by the old version.
self.store._save(db, job)
self.store.control('start')
job = self.store.next(self.owner)['job']
self.assertEqual(job['title'], '中' * 25 + '...')
self.assertEqual(self.store.package(dict(owner='tab-a', id=job['id']))['title'], job['title'])
self.submit(job)
restarted = Store(self.root / 'queue.sqlite3')
self.assertEqual(restarted.state()['jobs'][0]['snapshot']['title'], job['title'])
self.assertEqual(restarted.state()['jobs'][0]['original_title'], original)
def test_unsupported_images_stop_before_queue(self): def test_unsupported_images_stop_before_queue(self):
(self.article / 'a.webp').write_bytes(b'webp') (self.article / 'a.webp').write_bytes(b'webp')
with self.assertRaisesRegex(ValueError, '暂不支持'): with self.assertRaisesRegex(ValueError, '暂不支持'):
@@ -144,6 +208,218 @@ 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_real_tid_and_return_home_gate(self):
job = self.begin(); self.submit(job); self.accept(job)
saved = self.store.state()['jobs'][0]
self.assertEqual(saved['thread_id'], '123')
self.assertEqual(saved['first_post_pid'], '456')
self.assertEqual(self.store.next({**self.owner, 'url': saved['post_url']})['action'], 'home')
self.assertEqual(self.store.next(self.owner)['action'], 'idle')
def test_integrity_issue_records_acceptance_but_stops(self):
job = self.begin(); self.submit(job)
self.store.accept(dict(owner='tab-a', id=job['id'], post_url='https://www.dww11.com/thread/12311.html',
thread_id='123', author_uid='123', fid=125, title=job['title'], state='accepted_pending_moderation',
evidence={'thread_id':'123','integrity_issues':['正文图片位置改变']}))
self.assertFalse(self.store.run['running'])
self.assertEqual(self.store.run['batch']['count'], 1)
self.assertIn('已受理', self.store.enqueue(self.article))
def test_historical_reconciliation_does_not_count_or_resume(self):
job = self.begin(); self.submit(job); self.store.control('stop')
data=dict(id=job['id'], article_key=job['article_key'], confirmed=True,
post_url='https://www.dww11.com/thread/12311.html',thread_id='123',author_uid='123',fid=125,
title=job['title'],state='accepted_pending_moderation',evidence={'thread_id':'123'})
self.store.reconcile(data); self.store.reconcile(data)
self.assertFalse(self.store.run['running'])
self.assertEqual(self.store.run['batch']['count'], 0)
self.assertIn('已受理', self.store.enqueue(self.article))
def test_transaction_rolls_back_in_memory_run(self):
import sqlite3
self.begin()
before = json.loads(json.dumps(self.store.run))
with self.assertRaises(sqlite3.IntegrityError):
with self.store.db() as db:
self.store.run['batch']['count'] = 999
db.execute("INSERT INTO posts VALUES(NULL,NULL)")
self.assertEqual(self.store.run, before)
def test_accept_after_stop_only_records_no_resumption(self):
job = self.begin(); self.submit(job); self.store.control('stop'); self.accept(job)
self.assertFalse(self.store.run['running'])
self.assertEqual(self.store.run['batch']['count'], 1)
self.assertEqual(self.store.next(self.owner)['action'], 'idle')
def test_switch_same_uid_and_idle_stops_without_reclick(self):
import time
job = self.begin(); self.submit(job); self.accept(job)
other = self.root / 'other'; shutil.copytree(self.article, other)
(other / 'a.md').write_text(self.md.read_text().replace('正文内容', '其他内容'))
self.store.enqueue(other)
self.assertEqual(self.store.next(self.owner)['action'], 'switch')
self.store.switch_begin({'owner':'tab-a'})
self.store.run['switch']['clicked_at'] = time.time() - 11
self.assertEqual(self.store.next(self.owner)['action'], 'idle')
self.assertFalse(self.store.run['running'])
self.assertEqual(self.store.run['switch']['state'], 'clicked')
def pending_switch(self):
import time
job = self.begin(); self.submit(job); self.accept(job)
other = self.root / 'other'; shutil.copytree(self.article, other)
(other / 'a.md').write_text(self.md.read_text().replace('正文内容', '另一篇内容'))
self.store.enqueue(other)
self.assertEqual(self.store.next(self.owner)['action'], 'switch')
self.store.run['switch']['created_at'] = time.time() - 3600
def test_old_pending_switch_does_not_timeout_before_click(self):
self.pending_switch()
self.store.control('pause'); self.store.control('start')
self.assertEqual(self.store.next(self.owner)['action'], 'switch')
self.assertTrue(self.store.run['running'])
self.assertNotIn('clicked_at', self.store.run['switch'])
def test_missing_bridge_explains_problem_and_preserves_pending_switch(self):
self.pending_switch()
changed = {**self.owner, 'account': {'uid': '456', 'name': '手动换号后的账号'}, 'extension': {}}
self.assertEqual(self.store.next(changed)['action'], 'idle')
self.assertIn('联动接口未就绪', self.store.run['reason'])
self.assertEqual(self.store.run['switch']['state'], 'pending')
self.assertEqual(self.store.run['batch']['count'], 1)
self.store.control('start')
self.assertEqual(self.store.next({**changed, 'extension': {'ready': True, 'idle': True}})['action'], 'prepare')
self.assertIsNone(self.store.run['switch'])
self.assertEqual(self.store.run['batch']['count'], 0)
def test_rejected_submission_requires_manual_resolution(self):
job = self.begin(); self.submit(job)
self.transition(job, 'rejected', error='标题超限')
self.assertFalse(self.store.run['running'])
self.assertEqual(self.store.run['batch']['count'], 0)
self.store.resolve(dict(id=job['id'],confirmed_not_accepted=True,retry=True))
saved = self.store.state()['jobs'][0]
self.assertNotIn('submitted_at', saved)
self.assertEqual(saved['state'], 'queued')
def test_legacy_identity_is_preserved_without_guessing_tid(self):
job = self.begin(); self.submit(job); self.accept(job)
with self.store.db() as db:
saved = self.store._job(db,job['id']); saved.pop('thread_id')
self.store._save(db,saved)
restarted = Store(self.root/'queue.sqlite3')
saved = restarted.state()['jobs'][0]
self.assertEqual(saved['legacy_url_id'],'123')
self.assertTrue(saved['identity_needs_reconciliation'])
self.assertFalse(restarted.run['running'])
def test_remove_selected_preserves_files_and_survives_restart(self):
self.store.enqueue(self.article)
other = self.root / 'other'
shutil.copytree(self.article, other)
(other / 'a.md').write_text(self.md.read_text().replace('正文内容', '另一篇内容'))
self.store.enqueue(other)
jobs = self.store.state()['jobs']
result = self.store.remove({'ids': [jobs[0]['id'], jobs[0]['id']]})
self.assertEqual(result['removed'], 1)
self.assertTrue(self.md.is_file())
restarted = Store(self.root / 'queue.sqlite3')
self.assertEqual([j['id'] for j in restarted.state()['jobs']], [jobs[1]['id']])
self.assertEqual(restarted.enqueue(self.article), '已入队')
self.assertEqual(restarted.remove({'ids': [j['id'] for j in restarted.state()['jobs']]})['removed'], 2)
self.assertEqual(restarted.state()['jobs'], [])
def test_remove_is_atomic_and_protects_current_task(self):
job = self.begin()
with self.assertRaisesRegex(ValueError, '暂停'):
self.store.remove({'ids': [job['id']]})
self.store.control('pause')
with self.assertRaisesRegex(ValueError, '当前任务'):
self.store.remove({'ids': [job['id']]})
self.store.resolve(dict(id=job['id'], confirmed_not_accepted=True, retry=False))
for ids in ([], 'invalid', [None], [job['id'], 'missing']):
with self.assertRaises(ValueError):
self.store.remove({'ids': ids})
self.assertEqual(len(self.store.state()['jobs']), 1)
self.store.remove({'ids': [job['id']]})
self.assertEqual(self.store.state()['jobs'], [])
def test_remove_accepted_retains_deduplication_and_counts(self):
job = self.begin(); self.submit(job); self.accept(job)
self.store.control('pause')
before = self.store.state()['history_count']
self.store.remove({'ids': [job['id']]})
self.assertEqual(self.store.state()['jobs'], [])
self.assertEqual(self.store.state()['history_count'], before)
self.assertEqual(self.store.run['batch']['count'], 1)
self.assertIn('已受理', self.store.enqueue(self.article))
with self.store.db() as db:
self.assertEqual(db.execute('SELECT count(*) FROM posts').fetchone()[0], 1)
def test_remove_unresolved_submission_is_blocked_even_without_active_pointer(self):
job = self.begin(); self.submit(job); self.store.control('pause')
self.store.run['active'] = None
with self.assertRaisesRegex(ValueError, '尚未核对'):
self.store.remove({'ids': [job['id']]})
self.assertEqual(len(self.store.state()['jobs']), 1)
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):
+51 -2
View File
@@ -1,6 +1,6 @@
# 本轮验证记录 # 本轮验证记录
日期:2026-10-31。测试均未使用真实账号,未向新闻 API 请求内容,未向论坛上传或发布。 日期:2026-10-31。下表所列离线测试均未使用真实账号,未向新闻 API 请求内容,未向论坛上传或发布。后续真实现场操作另按小节记录。
| 范围 | 结果 | | 范围 | 结果 |
| --- | --- | | --- | --- |
@@ -19,4 +19,53 @@
测试中曾发现模拟网页未声明 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 个正文附件引用,不残留 `![`。
- 排版前后对比现同时包含图片的 `id`、`aid` 和 `src`。
- 本轮导入与连接测试 16/16 通过,覆盖 aid 属性、图片错误响应、异步加载/终止、多行嵌套图文、重复图片位置;构建脚本已重新生成 0.1.1 合并油猴文件,语法检查通过。
需要在油猴更新 `browser/auto-news.user.js` 后才会应用代码修复。此更新不会解决尚未查明的图片接口 HTTP 500;未宣称现场图片显示已恢复。
## 2026-10-31:跳过当前文章
- 悬浮窗新增“跳过本文章”,沿用连接桥和持久队列,记录 `skipped` 状态、不计发表数、不自动重新入队。
- 执行器取消并等待当前导入退出后登记跳过,打开新的发帖页继续;提交中、结果不明以及有提交历史的异常任务拒绝直接跳过。
- Python 离线测试 33/33 通过,包括浏览器接口、跳过后继续、队列耗尽、重复/跨标签/过期请求、提交状态保护与重启持久化。
- Node.js 20 脚本测试 22/22 通过,包括悬浮窗事件、导入取消回滚等待、旧轮询竞态及请求失败暂停。两个油猴脚本语法检查通过,已生成 0.1.2 合并脚本。
- 本轮未操作真实论坛或账号;未执行 Playwright 浏览器回归,页面脚本使用模拟文档和连接响应验证。
## 2026-10-31:真实提交提示与审核中详情页观察
本节为真实登录态下的现场观察,与前述离线测试分开。用户明确授权助手点击一次“发表帖子”以捕捉快速跳转;未改写待发表内容、重新上传或重复提交。本次不属于自动执行器全流程验收。
| 范围 | 观察结果 |
| --- | --- |
| 提交前 | 标题“湖人只有一个卢卡·开拓者的四大金刚”,体育休闲 `fid=90` |
| 提示页 | 明确显示“新主题需要审核,您的帖子通过审核后才能显示” |
| 跳转 | 提示页指向并自动进入 `https://www.dww11.com/thread/23699911.html`,本次未自动回首页 |
| 详情页 | 标题、作者及版块信息可核对,标题旁显示“(审核中)” |
| 主题与首帖 | 页面链接实际 `tid=236999`;首帖 PID `238791`,容器 `#postmessage_238791` |
| 受理结论 | 本次已明确受理 1 篇,已确认公开 0 篇;不能重发 |
| 保存资料 | `../local-records/publish-result-23699911.json` 与 `../local-records/publish-result-23699911.png` |
已保存详情页关键结构与审核状态截图;提示页文字和跳转链接已捕捉,跳转前的容器 HTML 未保存成功。文件名按访问地址命名,不能将 `23699911` 直接当作真实主题 ID。账号名称与 UID 仅保留在本地排除目录,正文档继续匿名化。
本次未验证公开后的访客可见性、失败/超限提示、结果自动识别、受理接口自动记账、重复计数保护或真实换号链路;未补写运行队列与去重库。已有现场资料不等于结果适配已经实现。详见[操作规范第 12 节](../发布流程操作记录与自动化规范.md#12-2026-10-31实际提交与审核中页面观察)。
## 2026-10-31:0.2.0 结果核对与接续
- Python:42/42 通过,使用缓存的 Python 3.12 和依赖,临时数据库与模拟响应。
- JavaScript、连接桥、执行器、模拟结果页:27/27 通过,Node.js 24.22.1;临时 Chrome 阻断全部网络。
- 完整浏览器回归:控制台配置/启停、桌面/手机布局、模拟导入/排版、人工点击记录、未知结果暂停和换号中终止通过。截图位于 `test/artifacts/`。
- 只读核对实际已受理帖子结构,确认作者、版块和图片占位规则;没有在线上传、发表、编辑或换号。
- 已先备份正式 SQLite,再根据已有提交快照与现场观察记录补核 TID 236999 / PID 238791。批次计数保持 0、流程保持暂停、重复入队检查返回“已受理,跳过”。原 JSON 去重记录同步保留文章哈希与主题信息,仍在 Git 排除范围。
- 尚未验收:公开成功页、真实标题超限提示、完整线上下一篇与换号、图片 HTTP 500 根因。离线通过不等于以上实机链路通过。
@@ -165,3 +165,52 @@ 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`。本节是文档补录,不等于运行去重库已登记;后续对账需关联原文章身份,并统一主题标识的处理方式。
## 13. 0.2.0 实现与历史补核
已实现审核中详情适配:限定标题区域审核标志,结合真实 TID、首帖楼主标识、作者 UID、版块 fid 与最终提交标题核对。受理、去重和批次计数在同一事务登记;图文检查与受理状态分开,图片延迟占位、附件区或文字/顺序差异会保留受理去重并暂停。记账后仍运行时返回首页,再领取或换号;停止后仅允许被动记账。
现场只读核对了现有页面结构,没有点击发表、上传、改文或换号。当前模板面包屑容器为 `.mumucms_pt`,首帖作者位于 `#post_<PID>` 的 `.plc .pti .authi`,首帖楼主链接为 `#postnum<PID>`;图片同时可能具有 `id=aimg_<AID>` 与 `aid=<AID>`。`static/image/common/none.gif` 是占位图,不能凭 naturalWidth 大于零宣称原图已显示。
已将第 12 节现场记录关联队列既有人工提交快照:TID 236999、PID 238791,记为受理待审核,阻止重发;历史补核没有增加当前批次计数。图文完整性保留待核对标记,流程暂停。任务库和原去重记录均先备份再更新,不改写原文章。
公开成功页仍无样本,审核标记缺失不能作为已公开证据。标题超限拒绝分支已做模拟测试,尚无实机错误样本;其余未知提示进入待核对。完整线上连续发布与真实账号换号仍需由用户亲手发表的小批验收。
+20 -1
View File
@@ -4,7 +4,15 @@
更新日期:2026-10-31(北京时间)。本版整体替换早期自动发帖设想,依据当前源码、[发布流程操作记录与自动化规范](发布流程操作记录与自动化规范.md)、[演示任务记录](演示任务记录.json)和[发布去重记录](发布去重记录.json)重新梳理。 更新日期:2026-10-31(北京时间)。本版整体替换早期自动发帖设想,依据当前源码、[发布流程操作记录与自动化规范](发布流程操作记录与自动化规范.md)、[演示任务记录](演示任务记录.json)和[发布去重记录](发布去重记录.json)重新梳理。
本次进行了本地源码与记录核对,未重新操作线上页面、下载新闻、上传图片或发表帖子。下文“演示已验证”引用既有操作记录;“源码已有”不等于最新版本已在线上重新验收。 原计划编写时仅进行了本地源码与记录核对,未重新操作线上页面、下载新闻、上传图片或发表帖子。下文“演示已验证”引用既有操作记录;“源码已有”不等于最新版本已在线上重新验收。
> 后续现场补录:2026-10-31 已采集一次真实提交提示与审核中详情页,见第 11 节。第 1~10 节保留原计划语境,组件当前能力以 README 和源码为准;本次补录不代表结果观察功能已经接通。
## 当前实现更新:0.2.0
2026-10-31 已在源码接通审核中结果观察、真实 TID/PID 分离、受理记账、返回首页与后续调度;新增发布后图文差异暂停、历史结果补核及换号异常暂停。当前操作以 README 与源码为准,下文第 1~10 节为早期实施计划,不能再把其“尚未实现”描述当作当前状态。
已根据本地现场记录为主题 236999 / 首帖 238791 补登记去重,关联既有提交快照,未增加当前账号批次计数。正式任务库和原去重文件在本地排除目录另存了更新前备份。公开成功页、更多拒绝提示、图片 HTTP 500 根因及完整实机换号链路仍待验证。
## 1. 当前目标与确定规则 ## 1. 当前目标与确定规则
@@ -176,3 +184,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`。
- 下一步完成结果观察适配、受理接口标识核对与重复计数验证,再进行后续计数/换号联调;公开、失败和审核后的访客可见性仍待验证。
本次仅同步观察记录,未修改业务代码、运行队列或去重库;这次授权代点不扩展为后续自动发表权限,也不替代正式工作流的人工发表边界。
-150
View File
@@ -1,150 +0,0 @@
# dww11 自动化流程检查与 Git 文件清单
检查日期:2026-10-31(北京时间)。本轮仅检查本地说明、源码和离线测试;没有访问网站、消耗下载 API 额度、登录、上传或发表,最初检查时未执行 Git 操作。用户随后已确认仓库范围并授权本地初始化和首次提交;下面保留检查发现,并补充本次整理结果。
## 一、检查结论
当前是可以人工衔接的组件集合,尚不是已实现的端到端自动流程。
```text
探数 Key → 下载 Markdown 和图片
→ 手工选择文章目录,或运行打包器生成文章包
→ 已登录的论坛新帖页面导入并上传图片
→ 点击独立“一键排版”并核对标题、正文、配图和来源
→ 用户审核并亲手点击发表
→ 核对受理/审核结果,登记去重,再决定下一篇或换号
```
发表与再次尝试发表均由用户亲手点击,这是现有流程规范要求。下载器、打包器、导入器、排版器和换号扩展之间没有完整的任务调度连接。
## 二、逐环节检查
| 环节 | 主要文件 | 当前能力 | 缺口或注意事项 |
| --- | --- | --- | --- |
| 下载 | `tanshuapi_news_downloader/app.py`、`article_writer.py`、`static/` | 频道、分页、正文图片落盘、下载历史去重、进度和停止 | 下载历史仅保存指纹与目录,没有供发布队列可靠消费的完整文章清单和完成标记;停止需要等待当前请求/保存结束 |
| 打包 | `dww11_make_package.py` | 将目录转换为 v1 Markdown/base64 图片包;可直接作为 Python 函数调用 | 不会自动把文章送进浏览器;仅收集 JPG/JPEG/PNG/GIF,没有完整的 Markdown 引用预检或整包大小上限 |
| 导入 | `dww11-article-importer.user.js` | 选目录或文章包、预览、按正文引用上传、附件登记、填写校验与失败回滚 | 标题超过 80 个 Unicode 码点直接拒绝;没有对外取消接口;非空编辑器确认仍使用模态弹窗 |
| 排版 | `dww11-wechat-oneclick.user.js` v1.0.9 | 独立“一键排版”,识别文末来源并重建尾注;无来源时要求补充 | 没有公开可等待/取消的排版接口,仍有 prompt/alert;复杂尾注、重复执行和发布后的图片位置仍需浏览器回归 |
| 发布 | `auto_news_upload/` 两份流程文档和两份记录 JSON | 已有单篇人工操作的历史记录 | 本目录没有完整发布执行程序、持久任务库、自动受理计数、全量历史对账或统一终止功能 |
| 换号 | `dww11-extension/` v3.0.4 | CSV 导入、扩展 IndexedDB 帐号库、抽号与登录、暂停恢复 | 未与文章任务的 min/max、受理计数和统一终止联动;扩展内部路由不能直接视作油猴接口 |
### 优先处理的问题
1. **图片格式衔接不一致。** 下载器可保存 WebP/AVIF/SVG,打包器和导入器只支持 JPG/JPEG/PNG/GIF。打包器会跳过不支持格式,导入时可能报缺图;应在打包/入队前给出明确错误,或另外实现保留原文件的转码。
2. **长标题衔接未实现。** 规范要求保留原题并派生截断标题,现导入器却在编译时直接拒绝超过 80 字的标题。需在导入前生成并展示提交标题,站点真实计数方式仍需实测。
3. **缺少可靠调度和统一终止。** 下载完成不等于可以发布;目录存在也不等于完整保存。现有模块停止行为不贯通,不能直接启动无人看管的批量链路。
4. **发布去重仍是手工记录。** 下载去重不能替代跨帐号、跨版块的发布去重。需要持久队列、未决结果核对、唯一受理登记及恢复逻辑。
5. **历史样本的图片位置有异常。** 原记录显示 26 张图片中正文 21 张、附件区 5 张,总数相同不能证明位置正确。应按附件 ID 对照最终待提交快照与结果页。
6. **旧路径已整理。** 原导入测试与打包说明引用了搬迁前目录;首次提交准备时改用 `tests/fixtures/article/` 的自造 Markdown 和 PNG,消除 legacy 依赖。
7. **扩展测试资料缺失。** 扩展 README 提到 `tests/core.test.mjs`、`tests/browser-integration.mjs`、`tests/browser-restart.mjs` 和 `测试说明.md`,当前项目中未找到,不能把文档中的历史测试声明当成本轮复验结果。
历史记录中的主题 23697411 已被网站受理并显示审核中,必须保留其本地去重证据。本轮没有核实现在是否已经公开。
## 三、运行时需要帐号或凭据的文件
下表区分“程序需要凭据才能运行”和“源码里保存真实凭据”。程序需要登录不代表源码不能进入 Git。
| 文件 | 所需帐号/凭据 | 使用和保存方式 | Git 建议 |
| --- | --- | --- | --- |
| `tanshuapi_news_downloader/app.py` | 探数 API Key | 调用新闻 API;Key 从页面传入,在运行内存使用 | 源码可纳入;不写入真实 Key |
| `tanshuapi_news_downloader/static/app.js`、`static/index.html` | 用户填写探数 API Key | 页面输入和内存变量,经本机会话接口传给服务;源码未配置固定 Key | 可纳入 |
| `dww11-article-importer.user.js` | 已登录的大湾网帐号及页面上传凭据 | 从当前页面读取上传参数,使用当前登录会话上传图片;无须写入密码 | 可纳入源码 |
| `dww11-wechat-oneclick.user.js` | 发布页面的登录会话 | 独立排版入口在当前编辑器操作;微信 URL 入口属于另一条采集路径 | 可纳入源码 |
| `dww11-extension/manager.js`、`manager.html`、`core.js` | 帐号 CSV | 选择、解析、确认导入帐号和密码 | 可纳入程序及虚拟模板,排除真实 CSV |
| `dww11-extension/db.js`、`service.js` | 扩展帐号库 | 明文密码保存在浏览器扩展来源的 IndexedDB;列表返回时去除密码 | 可纳入程序;排除浏览器资料及数据库备份 |
| `dww11-extension/background.js`、`content.js` | 当前抽取的帐号和密码 | 扩展消息传递,并填入目标网站登录表单 | 可纳入程序 |
| `today-jobs.user.js` | 可访问招聘管理列表的登录帐号/权限 | 使用当前页面和同源会话读取职位 | 辅助功能,按需纳入 |
| `dww-today-threads.user.js` | 论坛后台管理权限与页面 formhash | 使用当前管理会话执行统计查询 | 辅助功能,按需纳入 |
| `legacy/acc_switch_lite_python_legacy/server.py`、两个 lite 目录的 `import_excel.py` 与换号油猴脚本 | 旧版帐号文件、Excel 和网站登录 | 旧版实现;与当前扩展分开 | 不属于当前主链路;归档时排除真实数据 |
| `legacy/acc_switch_heavy_sqlite_legacy/init_db.py`、`import_excel.py` | 旧版帐号 Excel/数据库 | 可生成并导入保存密码的 `accounts.db` | 不属于当前主链路;数据库不纳入 |
`dww11_make_package.py` 仅处理本地文件,不需要帐号、API Key 或网站 Cookie。
## 四、已经存在的帐号数据文件:排除真实版本
| 文件 | 本轮确认 | 建议 |
| --- | --- | --- |
| `账号密码-去重可导入.csv` | 表头外 1152 条数据行、3 列;为真实帐号导入文件 | 本地保留,不纳入 Git |
| `legacy/acc_switch_lite/accounts.json` | 169 条帐号记录,169 个非空 password 字段 | 本地保留,不纳入 Git |
| `legacy/acc_switch_lite_python_legacy/accounts.json` | 169 条帐号记录,169 个非空 password 字段 | 本地保留,不纳入 Git |
以上仅统计结构和数量,不展示用户名、密码或密钥。没有执行帐号有效性验证,也没有核对 CSV 行与 JSON 记录之间的对应关系。
当前扩展运行帐号库在浏览器内,不在 `dww11-extension/` 源码目录。不要把浏览器配置目录、Cookie 导出文件或 IndexedDB 备份作为源码提交。`example.csv` 是示例模板,需保持为虚拟凭据。
## 五、拟纳入 Git 的文件清单
### A. 当前主链路源码与说明
```text
dww11_make_package.py
dww11-article-importer.user.js
dww11-wechat-oneclick.user.js
本地文章导入脚本使用说明.md
tanshuapi_news_downloader/app.py
tanshuapi_news_downloader/article_writer.py
tanshuapi_news_downloader/requirements.txt
tanshuapi_news_downloader/start.command
tanshuapi_news_downloader/README.md
tanshuapi_news_downloader/.gitignore
tanshuapi_news_downloader/static/index.html
tanshuapi_news_downloader/static/app.js
tanshuapi_news_downloader/static/style.css
tanshuapi_news_downloader/tests/test_downloader.py
dww11-extension/manifest.json
dww11-extension/background.js
dww11-extension/content.js
dww11-extension/core.js
dww11-extension/db.js
dww11-extension/service.js
dww11-extension/manager.html
dww11-extension/manager.js
dww11-extension/manager.css
dww11-extension/example.csv
dww11-extension/README.md
auto_news_upload/实施计划.md
auto_news_upload/发布流程操作记录与自动化规范.md
auto_news_upload/流程检查与Git文件清单.md
```
两份流程文档的帐号名称、UID 和电脑绝对路径已匿名化,原版保存在被排除的 `auto_news_upload/local-records/`。历史帖子编号与链接保留为受理证据说明。本文不含密码,但也引用了本地敏感文件名和数量,可按仓库公开范围进一步精简。
### B. 修复或检查后再决定
- `test-dww11-importer.cjs`:已改用仓库内自造测试稿与三张 PNG,一并纳入首次提交。
- `auto_news_upload/演示任务记录.json`、`发布去重记录.json`:属于真实运行状态。建议保留本地,另提供匿名结构模板;不因 Git 排除而删除原去重记录。
- `dww11-extension-3.0.4.zip`:本轮逐文件比对,与当前扩展目录的 11 个文件一致。可作为发行附件;日常优先跟踪源码,避免双份维护。
- `today-jobs.user.js`、`dww-today-threads.user.js`:辅助工具,不是四步主流程的必需文件,按你的范围选择。
- `.wechat-script-test/`:本地排版验证页、源码副本、截图和结果,需整理为可复现测试后再选择纳入。
- `legacy/`:默认不进入主链路首批提交;若保留历史实现,必须逐项排除帐号数据、虚拟环境、缓存及下载文章。
- `credits-video/`:滚动字幕视频工具,与本次流程无直接关系,默认不纳入本批。
### C. 建议排除的本地产物
真实帐号 CSV/JSON、下载文章及图片、下载历史数据库、真实发布记录与截图、文章包 `*.dww11.json`、浏览器配置和 Cookie、`.venv/`、`__pycache__/`、`*.pyc`、`.DS_Store`、视频输出和临时诊断文件。
首次提交准备时已增加根目录 `.gitignore`,覆盖真实帐号、运行记录、下载目录及本批范围之外的组件;按白名单添加源码和说明。
## 六、本轮验证结果
| 检查 | 结果 |
| --- | --- |
| Git 状态 | 当前目录不是 Git 仓库;本轮没有新建仓库 |
| 原始导入测试 `node --test test-dww11-importer.cjs` | 8 项中 3 项通过、5 项因旧素材目录不存在失败 |
| 只在内存中修正素材路径后执行同一测试 | 8/8 通过,覆盖打包与直接导入一致、缺图/长标题拦截、上传失败保留原文、富文本丢图回滚、无发表请求;磁盘测试文件未修改 |
| 下载器单元测试 | 默认 Python 3.15 与 Python 3.12 均因缺少 `requests` 无法加载测试;未安装依赖,不能认定通过或认定下载逻辑失败 |
| JavaScript 语法 | 6 个扩展 JS 按 ES 模块检查通过;4 个根目录油猴脚本检查通过 |
| Python 语法 | 打包器、下载器 app 与保存器均通过语法解析 |
| 扩展 ZIP | 11 个文件与源码目录逐一一致 |
| 在线验收 | 本轮未执行;历史文档中的单篇演示不等于当前版本全链路验收 |
## 七、首次提交整理范围
用户已授权新建本地仓库并完成 initial commit,范围为总流程文档、当前换号扩展、新闻下载器、文章打包/导入/排版及相关说明与测试。
- 增加根目录 README 与 `.gitignore`。
- 导入测试改用项目自有测试稿及三张程序生成的 PNG,修正打包说明路径。
- 原始流程说明备份到本地排除目录,再匿名化仓库文档中的帐号名称、UID 与电脑路径。
- 真实帐号、下载历史、演示 JSON、截图及原始说明均保留本地,不纳入提交。
- 下载器测试仍需先安装依赖,扩展缺失的测试文件尚未补齐;不以首次提交表示全流程已经完成。
- 本次只建立本地版本历史,未配置远程仓库或推送。