Compare commits
7
Commits
1e6504b4a2
...
7ab0bc3485
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ab0bc3485
|
||
|
|
24a73b19c0
|
||
|
|
d2f58ba687
|
||
|
|
4f864e01db
|
||
|
|
77012b93c6
|
||
|
|
f0be4f5596 | ||
|
|
d157c179c8 |
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
将新闻下载、文章打包、浏览器导入、排版与换号联动整合到本地控制台。
|
将新闻下载、文章打包、浏览器导入、排版与换号联动整合到本地控制台。
|
||||||
|
|
||||||
**当前版本:0.1。可完成下载 → 入队 → 导入 → 排版 → 等待人工发表。结果页自动核对按用户决定暂留待接入;人工提交后会记录候选结果并暂停,不会自动计数、换号或处理下一篇。** 换号调度和计数接口已完成离线验证,完整在线链路仍需补齐结果页 HTML 后验证。
|
**当前版本:0.2.1。下载 → 入队 → 导入 → 排版 → 人工发表 → 网站审核提示 → 受理记账 → 回首页 → 下一篇或换号。** 暂时只以网站提示“新主题需要审核,您的帖子通过审核后才能显示”判定发表成功,状态仍记为已受理、审核中。原详情页标题、作者、版块与图文核对代码已注释保留,当前不执行。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
@@ -20,7 +20,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 +35,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 +69,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 +80,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 +97,34 @@ 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。
|
||||||
|
|
||||||
|
## 标题自动截断
|
||||||
|
|
||||||
|
自动队列、手动目录和文章包导入均应用上述规则;已截断的标题不会重复添加省略号。已有队列在领取未开始的任务时更新标题,已导入、待审核或已提交任务保持现场标题。
|
||||||
|
|
||||||
|
应用更新:重启本地服务,并在油猴重新安装 `browser/auto-news.user.js`。原始 Markdown 不改写。
|
||||||
@@ -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,7 +104,7 @@ class Controller:
|
|||||||
def bridge(self, route, body):
|
def bridge(self, route, body):
|
||||||
methods = {'heartbeat': self.store.heartbeat, 'next': self.store.next, 'package': self.store.package,
|
methods = {'heartbeat': self.store.heartbeat, 'next': self.store.next, 'package': self.store.package,
|
||||||
'event': self.store.event, 'accept': self.store.accept, 'switch-begin': self.store.switch_begin,
|
'event': self.store.event, 'accept': self.store.accept, 'switch-begin': self.store.switch_begin,
|
||||||
'takeover': self.store.takeover, 'current': self.store.current}
|
'takeover': self.store.takeover, 'current': self.store.current, 'skip': self.store.skip}
|
||||||
if route == 'stop':
|
if route == 'stop':
|
||||||
self.manager.stop.set()
|
self.manager.stop.set()
|
||||||
return self.store.control('stop')
|
return self.store.control('stop')
|
||||||
|
|||||||
+7
-4
@@ -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 + '...'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+255
-32
@@ -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.1
|
||||||
// @match https://www.dww11.com/*
|
// @match https://www.dww11.com/*
|
||||||
// @match https://dww11.com/*
|
// @match https://dww11.com/*
|
||||||
// @match https://house.dww11.com/*
|
// @match https://house.dww11.com/*
|
||||||
@@ -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);
|
||||||
@@ -423,7 +493,8 @@
|
|||||||
setEditor(selected.title, bbcode);
|
setEditor(selected.title, bbcode);
|
||||||
const raw = win.getEditorContents();
|
const raw = win.getEditorContents();
|
||||||
if (Number(win.wysiwyg) === 1) {
|
if (Number(win.wysiwyg) === 1) {
|
||||||
for (const id of new Set(aids.values())) if (!raw.includes('attachimg_' + id)) throw new Error('富文本图片转换未通过检查。');
|
const expected = Array.from(bbcode.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g), match => match[1]);
|
||||||
|
await verifyEditorImages(doc.querySelector('#e_iframe')?.contentDocument?.body || win.editdoc?.body, expected, signal);
|
||||||
} else if (raw !== bbcode) throw new Error('正文填写校验失败。');
|
} else if (raw !== bbcode) throw new Error('正文填写校验失败。');
|
||||||
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
|
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
|
||||||
committed = true;
|
committed = true;
|
||||||
@@ -436,7 +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.
|
||||||
@@ -660,12 +734,12 @@ window.DWW11Formatter = Object.freeze({
|
|||||||
const footer = existingSource(body);
|
const footer = existingSource(body);
|
||||||
const expected = `来源:${expectedSource},如有侵权,请联系删除。`;
|
const expected = `来源:${expectedSource},如有侵权,请联系删除。`;
|
||||||
if (!footer || footer.text !== expected) throw new Error('文末来源与文章包不一致,请人工检查');
|
if (!footer || footer.text !== expected) throw new Error('文末来源与文章包不一致,请人工检查');
|
||||||
const images = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src'));
|
const images = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':'));
|
||||||
const backup = body.innerHTML;
|
const backup = body.innerHTML;
|
||||||
try {
|
try {
|
||||||
footer.remove();
|
footer.remove();
|
||||||
formatBody(body, expectedSource, expected);
|
formatBody(body, expectedSource, expected);
|
||||||
const after = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src'));
|
const after = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':'));
|
||||||
if (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变');
|
if (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变');
|
||||||
if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常');
|
if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常');
|
||||||
body.dispatchEvent(new Event('input', {bubbles:true}));
|
body.dispatchEvent(new Event('input', {bubbles:true}));
|
||||||
@@ -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'));
|
||||||
@@ -761,8 +898,9 @@ window.DWW11Formatter = Object.freeze({
|
|||||||
window.DWW11Formatter.format(job.source);assertLive(epoch);
|
window.DWW11Formatter.format(job.source);assertLive(epoch);
|
||||||
const snapshot=editorSnapshot();
|
const snapshot=editorSnapshot();
|
||||||
if(snapshot.title!==job.title)throw new Error('标题校验失败');
|
if(snapshot.title!==job.title)throw new Error('标题校验失败');
|
||||||
const aids=new Set(Object.values(result.images));
|
await window.DWW11Importer.verifyEditorImages(
|
||||||
for(const aid of aids) if(!snapshot.images.some(x=>x.id==='attachimg_'+aid))throw new Error('配图附件缺失:'+aid);
|
document.querySelector('#e_iframe').contentDocument.body, result.attachmentOrder, aborter.signal);
|
||||||
|
assertLive(epoch);
|
||||||
await event('awaiting_user_review',{snapshot});assertLive(epoch);
|
await event('awaiting_user_review',{snapshot});assertLive(epoch);
|
||||||
active={...job,state:'awaiting_user_review'};saveLocal({id:job.id});
|
active={...job,state:'awaiting_user_review'};saveLocal({id:job.id});
|
||||||
status('已完成排版,请核对内容并亲手点击“发表帖子”。');
|
status('已完成排版,请核对内容并亲手点击“发表帖子”。');
|
||||||
@@ -770,30 +908,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 +998,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 +1021,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 +1039,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 +1054,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();
|
||||||
})();
|
})();
|
||||||
@@ -179,12 +179,12 @@ window.DWW11Formatter = Object.freeze({
|
|||||||
const footer = existingSource(body);
|
const footer = existingSource(body);
|
||||||
const expected = `来源:${expectedSource},如有侵权,请联系删除。`;
|
const expected = `来源:${expectedSource},如有侵权,请联系删除。`;
|
||||||
if (!footer || footer.text !== expected) throw new Error('文末来源与文章包不一致,请人工检查');
|
if (!footer || footer.text !== expected) throw new Error('文末来源与文章包不一致,请人工检查');
|
||||||
const images = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src'));
|
const images = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':'));
|
||||||
const backup = body.innerHTML;
|
const backup = body.innerHTML;
|
||||||
try {
|
try {
|
||||||
footer.remove();
|
footer.remove();
|
||||||
formatBody(body, expectedSource, expected);
|
formatBody(body, expectedSource, expected);
|
||||||
const after = [...body.querySelectorAll('img')].map(x => x.id + ':' + x.getAttribute('src'));
|
const after = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':'));
|
||||||
if (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变');
|
if (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变');
|
||||||
if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常');
|
if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常');
|
||||||
body.dispatchEvent(new Event('input', {bubbles:true}));
|
body.dispatchEvent(new Event('input', {bubbles:true}));
|
||||||
|
|||||||
+84
-10
@@ -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);
|
||||||
@@ -423,7 +493,8 @@
|
|||||||
setEditor(selected.title, bbcode);
|
setEditor(selected.title, bbcode);
|
||||||
const raw = win.getEditorContents();
|
const raw = win.getEditorContents();
|
||||||
if (Number(win.wysiwyg) === 1) {
|
if (Number(win.wysiwyg) === 1) {
|
||||||
for (const id of new Set(aids.values())) if (!raw.includes('attachimg_' + id)) throw new Error('富文本图片转换未通过检查。');
|
const expected = Array.from(bbcode.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g), match => match[1]);
|
||||||
|
await verifyEditorImages(doc.querySelector('#e_iframe')?.contentDocument?.body || win.editdoc?.body, expected, signal);
|
||||||
} else if (raw !== bbcode) throw new Error('正文填写校验失败。');
|
} else if (raw !== bbcode) throw new Error('正文填写校验失败。');
|
||||||
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
|
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
|
||||||
committed = true;
|
committed = true;
|
||||||
@@ -436,7 +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 };
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name 新闻工作台 · 本地连接
|
// @name 新闻工作台 · 本地连接
|
||||||
// @namespace local.dww11.auto-news
|
// @namespace local.dww11.auto-news
|
||||||
// @version 0.1.0
|
// @version 0.1.1
|
||||||
// @match https://www.dww11.com/*
|
// @match https://www.dww11.com/*
|
||||||
// @match https://dww11.com/*
|
// @match https://dww11.com/*
|
||||||
// @match https://house.dww11.com/*
|
// @match https://house.dww11.com/*
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
// ==/UserScript==
|
// ==/UserScript==
|
||||||
(() => {
|
(() => {
|
||||||
'use strict';
|
'use strict';
|
||||||
const allowed = new Set(['heartbeat','next','package','event','accept','switch-begin','takeover','stop','current']);
|
const allowed = new Set(['heartbeat','next','package','event','accept','switch-begin','takeover','stop','current','skip']);
|
||||||
let token = GM_getValue('pairToken',''), port = GM_getValue('pairPort',8770);
|
let token = GM_getValue('pairToken',''), port = GM_getValue('pairPort',8770);
|
||||||
let stopRevision = 0;
|
let stopRevision = 0;
|
||||||
const signalStop = () => { stopRevision++; document.dispatchEvent(new CustomEvent('dww11:auto-stop')); };
|
const signalStop = () => { stopRevision++; document.dispatchEvent(new CustomEvent('dww11:auto-stop')); };
|
||||||
@@ -56,6 +56,10 @@
|
|||||||
const shadow=host.attachShadow({mode:'closed'});
|
const shadow=host.attachShadow({mode:'closed'});
|
||||||
shadow.innerHTML='<style>section{font:13px/1.6 system-ui;background:white;color:#24364a;border:1px solid #ccd7e0;border-radius:10px;padding:12px;width:250px;box-shadow:0 3px 20px #0002}button,input{font:inherit;box-sizing:border-box;padding:7px;margin:4px 0;width:100%}button{cursor:pointer}#halt{background:#ad302d;color:white;border:0;border-radius:6px}small{display:block}</style><section><strong>新闻工作台 · 本地连接</strong><button id="halt">终止流程</button><details><summary>连接设置</summary><label>本地端口<input id="port" type="number" min="1" max="65535"></label><label>连接码<input id="token" type="password" autocomplete="off"></label><button id="save">保存连接设置</button></details><small id="status">请启动本地工作台并配对</small></section>';
|
shadow.innerHTML='<style>section{font:13px/1.6 system-ui;background:white;color:#24364a;border:1px solid #ccd7e0;border-radius:10px;padding:12px;width:250px;box-shadow:0 3px 20px #0002}button,input{font:inherit;box-sizing:border-box;padding:7px;margin:4px 0;width:100%}button{cursor:pointer}#halt{background:#ad302d;color:white;border:0;border-radius:6px}small{display:block}</style><section><strong>新闻工作台 · 本地连接</strong><button id="halt">终止流程</button><details><summary>连接设置</summary><label>本地端口<input id="port" type="number" min="1" max="65535"></label><label>连接码<input id="token" type="password" autocomplete="off"></label><button id="save">保存连接设置</button></details><small id="status">请启动本地工作台并配对</small></section>';
|
||||||
const $=id=>shadow.getElementById(id);$('port').value=port;
|
const $=id=>shadow.getElementById(id);$('port').value=port;
|
||||||
|
const skip=document.createElement('button');skip.id='skip';skip.type='button';skip.textContent='跳过本文章';
|
||||||
|
$('halt').before(skip);
|
||||||
|
skip.onclick=()=>document.dispatchEvent(new CustomEvent('dww11:skip-article'));
|
||||||
|
document.addEventListener('dww11:skip-busy',event=>{skip.disabled=Boolean(event.detail);});
|
||||||
$('save').onclick=()=>{
|
$('save').onclick=()=>{
|
||||||
const value=Number($('port').value);if(!Number.isInteger(value)||value<1||value>65535){$('status').textContent='端口无效';return;}
|
const value=Number($('port').value);if(!Number.isInteger(value)||value<1||value>65535){$('status').textContent='端口无效';return;}
|
||||||
if($('token').value.trim()) token=$('token').value.trim();port=value;
|
if($('token').value.trim()) token=$('token').value.trim();port=value;
|
||||||
|
|||||||
@@ -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};
|
||||||
|
});
|
||||||
+108
-20
@@ -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'));
|
||||||
@@ -84,8 +86,9 @@
|
|||||||
window.DWW11Formatter.format(job.source);assertLive(epoch);
|
window.DWW11Formatter.format(job.source);assertLive(epoch);
|
||||||
const snapshot=editorSnapshot();
|
const snapshot=editorSnapshot();
|
||||||
if(snapshot.title!==job.title)throw new Error('标题校验失败');
|
if(snapshot.title!==job.title)throw new Error('标题校验失败');
|
||||||
const aids=new Set(Object.values(result.images));
|
await window.DWW11Importer.verifyEditorImages(
|
||||||
for(const aid of aids) if(!snapshot.images.some(x=>x.id==='attachimg_'+aid))throw new Error('配图附件缺失:'+aid);
|
document.querySelector('#e_iframe').contentDocument.body, result.attachmentOrder, aborter.signal);
|
||||||
|
assertLive(epoch);
|
||||||
await event('awaiting_user_review',{snapshot});assertLive(epoch);
|
await event('awaiting_user_review',{snapshot});assertLive(epoch);
|
||||||
active={...job,state:'awaiting_user_review'};saveLocal({id:job.id});
|
active={...job,state:'awaiting_user_review'};saveLocal({id:job.id});
|
||||||
status('已完成排版,请核对内容并亲手点击“发表帖子”。');
|
status('已完成排版,请核对内容并亲手点击“发表帖子”。');
|
||||||
@@ -93,30 +96,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 +186,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 +209,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 +227,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 +242,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
@@ -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.1
|
||||||
// @match https://www.dww11.com/*
|
// @match https://www.dww11.com/*
|
||||||
// @match https://dww11.com/*
|
// @match https://dww11.com/*
|
||||||
// @match https://house.dww11.com/*
|
// @match https://house.dww11.com/*
|
||||||
@@ -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
@@ -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
@@ -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 & 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 / 终止后不会自动恢复,已发出的请求可能仍已完成。</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
@@ -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}
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -151,6 +169,12 @@ class Store:
|
|||||||
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:
|
||||||
|
ext = data.get('extension', {})
|
||||||
|
elapsed = time.time() - switch.get('clicked_at', switch['created_at'])
|
||||||
|
if ext.get('paused') or (switch['state'] == 'clicked' and elapsed > 10 and ext.get('idle')) or elapsed > 120:
|
||||||
|
self.run.update(running=False, reason='换号已暂停、超时或账号未改变,请检查插件并人工恢复,禁止重复抽号')
|
||||||
|
self._persist(db)
|
||||||
|
return dict(action='idle', run=self.run)
|
||||||
return dict(action='switch' if switch['state'] == 'pending' else 'switch_wait', run=self.run)
|
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 +182,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 +204,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 +216,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 +268,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 +278,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 +306,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']]
|
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
|
if count_batch:
|
||||||
self.run.update(active=None, not_before=time.time() + self.run['config']['interval'])
|
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._save(db, job)
|
||||||
self._persist(db)
|
self._persist(db)
|
||||||
return {'ok': True}
|
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']]
|
||||||
|
for key in keys:
|
||||||
|
db.execute('INSERT OR REPLACE INTO ledger VALUES(?,?)', (key, json.dumps(job, ensure_ascii=False)))
|
||||||
|
self.run['batch']['count'] += 1
|
||||||
|
self.run.update(active=None, return_home=True,
|
||||||
|
not_before=time.time() + self.run['config']['interval'])
|
||||||
|
self._save(db, job)
|
||||||
|
self._persist(db)
|
||||||
|
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 +390,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']:
|
||||||
|
|||||||
@@ -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 完成验证。
|
||||||
@@ -11,3 +11,26 @@ test('终止后较早的 heartbeat 响应不能恢复流程',async()=>{
|
|||||||
assert.equal(responses.find(r=>r.id==='old').value.running,false);assert.equal(values.halted,true);
|
assert.equal(responses.find(r=>r.id==='old').value.running,false);assert.equal(values.halted,true);
|
||||||
requests[1].onload({status:200,responseText:JSON.stringify({running:false,epoch:2})});await stop;
|
requests[1].onload({status:200,responseText:JSON.stringify({running:false,epoch:2})});await stop;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('悬浮窗跳过按钮发送页面事件并防止忙碌时重复点击',()=>{
|
||||||
|
const handlers=new Map(),elements=new Map(),emitted=[],requests=[];
|
||||||
|
const element=()=>({style:{},before(child){elements.set(child.id,child);}});
|
||||||
|
for(const id of ['halt','port','token','save','status'])elements.set(id,element());
|
||||||
|
const doc={readyState:'complete',body:{append(){}},getElementById:()=>null,
|
||||||
|
createElement:()=>({...element(),attachShadow:()=>({set innerHTML(value){},getElementById:id=>elements.get(id)})}),
|
||||||
|
addEventListener(type,fn){if(!handlers.has(type))handlers.set(type,[]);handlers.get(type).push(fn);},
|
||||||
|
dispatchEvent(event){emitted.push(event);for(const fn of handlers.get(event.type)||[])fn(event);}
|
||||||
|
};
|
||||||
|
class Event{constructor(type,options={}){this.type=type;this.detail=options.detail;}}
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../browser/local-bridge.user.js'),'utf8'),{
|
||||||
|
document:doc,CustomEvent:Event,GM_getValue:(k,d)=>k==='pairToken'?'test-token':k==='halted'?false:d,
|
||||||
|
GM_setValue:()=>{},GM_addValueChangeListener:()=>{},GM_xmlhttpRequest:r=>requests.push(r)
|
||||||
|
});
|
||||||
|
const skip=elements.get('skip');assert.equal(skip.textContent,'跳过本文章');skip.onclick();
|
||||||
|
assert.equal(emitted.at(-1).type,'dww11:skip-article');
|
||||||
|
doc.dispatchEvent(new Event('dww11:skip-busy',{detail:true}));assert.equal(skip.disabled,true);
|
||||||
|
doc.dispatchEvent(new Event('dww11:skip-busy',{detail:false}));assert.equal(skip.disabled,false);
|
||||||
|
doc.dispatchEvent(new Event('dww11:local-request',{detail:JSON.stringify({id:'skip-test',route:'skip',body:{id:'job'}})}));
|
||||||
|
assert.equal(requests[0].url,'http://127.0.0.1:8770/bridge/skip');
|
||||||
|
requests[0].onload({status:200,responseText:'{"ok":true}'});
|
||||||
|
});
|
||||||
@@ -32,8 +32,8 @@ const output=path.join(__dirname,'artifacts');fs.mkdirSync(output,{recursive:tru
|
|||||||
document.querySelector('#postsubmit').addEventListener('click',()=>window.clicks++);
|
document.querySelector('#postsubmit').addEventListener('click',()=>window.clicks++);
|
||||||
window.DWW11Importer={stagePackage:async()=>({warnings:[]}),importPrepared:async()=>{
|
window.DWW11Importer={stagePackage:async()=>({warnings:[]}),importPrepared:async()=>{
|
||||||
document.querySelector('#subject').value='测试标题';
|
document.querySelector('#subject').value='测试标题';
|
||||||
document.querySelector('#e_iframe').contentDocument.body.innerHTML='<p>正文第一段</p><img id="attachimg_1" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"><p>正文第二段</p><p>来源:测试来源,如有侵权,请联系删除。</p>';
|
document.querySelector('#e_iframe').contentDocument.body.innerHTML='<p>正文第一段</p><img aid="attachimg_1" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"><p>正文第二段</p><p>来源:测试来源,如有侵权,请联系删除。</p>';
|
||||||
return {images:{'a.gif':'1'}};
|
return {images:{'a.gif':'1'},attachmentOrder:['1']};
|
||||||
}};
|
}};
|
||||||
document.addEventListener('dww11:local-request',event=>{
|
document.addEventListener('dww11:local-request',event=>{
|
||||||
const {id,route,body}=JSON.parse(event.detail);let value={};
|
const {id,route,body}=JSON.parse(event.detail);let value={};
|
||||||
@@ -46,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');
|
||||||
|
|||||||
+78
-4
@@ -29,12 +29,11 @@ test('测试文章:标题、3 张配图、插入顺序、来源与段落完整
|
|||||||
assert.equal(output, originalBody);
|
assert.equal(output, originalBody);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('缺图、远程图片、超出目录、超大图片与超长标题在上传前被拒绝', () => {
|
test('缺图、远程图片、超出目录与超大图片在上传前被拒绝', () => {
|
||||||
assert.throws(() => core.compile('# 标题\n\n', 'a.md', new Map()), /找不到配图/);
|
assert.throws(() => core.compile('# 标题\n\n', 'a.md', new Map()), /找不到配图/);
|
||||||
assert.throws(() => core.compile('# 标题\n\n', 'a.md', new Map()), /目录内/);
|
assert.throws(() => core.compile('# 标题\n\n', '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', 'a.md', new Map([['big.png', {size:20*1024*1024}]])), /20 MB/);
|
assert.throws(() => core.compile('# 标题\n\n', '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,71 @@ test('自动导入:上传中终止不会填写正文或继续下一张', async
|
|||||||
await assert.rejects(h.api.importPrepared({automatic:true,signal:c.signal}),/stopped/);
|
await assert.rejects(h.api.importPrepared({automatic:true,signal:c.signal}),/stopped/);
|
||||||
assert.equal(h.requests.length,1);assert.equal(h.ids.get('subject').value,'');
|
assert.equal(h.requests.length,1);assert.equal(h.ids.get('subject').value,'');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('真实站点 aid 属性:上传成功但图片响应失败不得报告完成', async () => {
|
||||||
|
const h=harness({brokenImages:true});await h.api.stageFiles(articleFiles());
|
||||||
|
await assert.rejects(h.api.importPrepared({automatic:true}),/网站附件图片无法显示/);
|
||||||
|
assert.equal(h.ids.get('subject').value,'');assert.equal(h.api.getState().result,null);
|
||||||
|
});
|
||||||
|
test('链接内图片、多行图文链接均参与上传并按原位置渲染', () => {
|
||||||
|
const md='# 标题\n\n[\n\n### 宣传标题\n\n说明](https://example.com/promo)\n\n';
|
||||||
|
const article=core.compile(md,'a.md',new Map([['image004.jpg',new Blob(['x'])],['image001.jpg',new Blob(['y'])]]));
|
||||||
|
assert.deepEqual([...article.images.keys()],['image004.jpg','image001.jpg']);
|
||||||
|
const rendered=article.render(new Map([['image004.jpg','104'],['image001.jpg','101']]));
|
||||||
|
assert.match(rendered,/\[url=https:\/\/example.com\/promo\]\[attachimg\]104\[\/attachimg\]/);
|
||||||
|
assert.deepEqual([...rendered.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g)].map(x=>x[1]),['104','101']);
|
||||||
|
assert.equal(rendered.includes('!['),false);
|
||||||
|
});
|
||||||
|
test('图片重复引用必须保留每个位置,不能只校验去重后的编号', async () => {
|
||||||
|
const img={id:'',getAttribute:n=>n==='aid'?'attachimg_12':null,complete:true,naturalWidth:100,addEventListener(){},removeEventListener(){}};
|
||||||
|
await core.verifyEditorImages({querySelectorAll:()=>[img,img]},['12','12']);
|
||||||
|
await assert.rejects(core.verifyEditorImages({querySelectorAll:()=>[img]},['12','12']),/数量或顺序/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('正文图片尚在加载时等待 load;终止后释放等待', async () => {
|
||||||
|
const make=()=>{const events=new Map();return {events,id:'',complete:false,naturalWidth:0,getAttribute:n=>n==='aid'?'attachimg_8':null,addEventListener:(n,f)=>events.set(n,f),removeEventListener:n=>events.delete(n)};};
|
||||||
|
const image=make();let finished=false;
|
||||||
|
const loaded=core.verifyEditorImages({querySelectorAll:()=>[image]},['8']).then(()=>finished=true);
|
||||||
|
await Promise.resolve();assert.equal(finished,false);
|
||||||
|
image.complete=true;image.naturalWidth=100;image.events.get('load')();await loaded;
|
||||||
|
assert.equal(image.events.size,0);
|
||||||
|
const pending=make(),controller=new AbortController();
|
||||||
|
const cancelled=core.verifyEditorImages({querySelectorAll:()=>[pending]},['8'],controller.signal);
|
||||||
|
controller.abort();await assert.rejects(cancelled,/终止/);assert.equal(pending.events.size,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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;});
|
||||||
@@ -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();}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
'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})=>{
|
||||||
|
if(holdImport)await new Promise((resolve,reject)=>{finishImport=()=>{rolledBack=true;reject(signal.reason);};signal.addEventListener('abort',()=>{}, {once:true});});
|
||||||
|
signal.throwIfAborted();subject.value='标题';return {attachmentOrder:[]};
|
||||||
|
},verifyEditorImages:async()=>{}},DWW11Formatter:{format(){}}};
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../browser/runner.js'),'utf8'),context);
|
||||||
|
return {calls,navigations,job,run,storage,doc,Event,tick:()=>tick(),skip:()=>doc.dispatchEvent(new Event('dww11:skip-article')),
|
||||||
|
finishImport:()=>finishImport(),finishHeartbeat:()=>finishHeartbeat(),rolledBack:()=>rolledBack,
|
||||||
|
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();}
|
||||||
|
});
|
||||||
@@ -70,6 +70,51 @@ class ControllerTests(unittest.TestCase):
|
|||||||
self.controller.scan(self.controller.manager.root)
|
self.controller.scan(self.controller.manager.root)
|
||||||
self.assertEqual(len(self.controller.store.state()['jobs']), 1)
|
self.assertEqual(len(self.controller.store.state()['jobs']), 1)
|
||||||
|
|
||||||
|
def test_bridge_skip_continues_queue(self):
|
||||||
|
store = self.controller.store
|
||||||
|
store.enqueue(ROOT / 'test/fixtures/article')
|
||||||
|
store.configure(dict(fid=125, minimum=1, maximum=1, interval=0, history_checked=True))
|
||||||
|
store.control('start')
|
||||||
|
job = store.next(dict(owner='tab-a', account=dict(uid='123', name='测试账号')))['job']
|
||||||
|
code, body = self.request('/bridge/skip', dict(owner='tab-a', id=job['id'], epoch=store.run['epoch']),
|
||||||
|
token=self.controller.bridge_token)
|
||||||
|
self.assertEqual(code, 200)
|
||||||
|
self.assertTrue(json.loads(body)['ok'])
|
||||||
|
self.assertEqual(store.state()['jobs'][0]['state'], 'skipped')
|
||||||
|
self.assertIsNone(store.run['active'])
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
+251
-3
@@ -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={'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,190 @@ 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 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
@@ -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 个正文附件引用,不残留 `。
|
||||||
|
|
||||||
|
|
||||||
|
## 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,记为受理待审核,阻止重发;历史补核没有增加当前批次计数。图文完整性保留待核对标记,流程暂停。任务库和原去重记录均先备份再更新,不改写原文章。
|
||||||
|
|
||||||
|
公开成功页仍无样本,审核标记缺失不能作为已公开证据。标题超限拒绝分支已做模拟测试,尚无实机错误样本;其余未知提示进入待核对。完整线上连续发布与真实账号换号仍需由用户亲手发表的小批验收。
|
||||||
@@ -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
@@ -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、截图及原始说明均保留本地,不纳入提交。
|
|
||||||
- 下载器测试仍需先安装依赖,扩展缺失的测试文件尚未补齐;不以首次提交表示全流程已经完成。
|
|
||||||
- 本次只建立本地版本历史,未配置远程仓库或推送。
|
|
||||||
Reference in new issue
Block a user