From fd9474d0e1146bd14cc584a7cadc40a040a936a4 Mon Sep 17 00:00:00 2001 From: Longtail Date: Sun, 1 Nov 2026 15:11:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=96=B0=E9=97=BB=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E6=8D=A2=E5=8F=B7=E8=81=94=E5=8A=A8=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 16 +++++++++- app.py | 7 +++-- browser/auto-news.user.js | 21 +++++++------ browser/formatter.js | 4 +-- browser/importer.js | 6 ++-- browser/local-bridge.user.js | 33 ++++++++++++++++++-- browser/runner.js | 9 +++--- build_browser.py | 2 +- store.py | 12 ++++++-- test/bridge.test.cjs | 21 ++++++++++++- test/extension-bridge.test.cjs | 56 ++++++++++++++++++++++++++++++++++ test/importer.test.cjs | 5 +++ test/runner.test.cjs | 5 +-- test/test_controller.py | 10 ++++++ test/test_queue.py | 30 +++++++++++++++++- 15 files changed, 203 insertions(+), 34 deletions(-) create mode 100644 test/extension-bridge.test.cjs diff --git a/README.md b/README.md index 6be0d60..61079fe 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,16 @@ # 新闻工作台 +## 2026-11-01 换号联动修复 + +已定位启动即提示“换号已暂停、超时或账号未改变”的两个原因:换号扩展缺少 `data-auto-news-state` 联动状态,且后端将尚未点击的旧 `pending` 换号任务也按 120 秒超时处理。现已补齐扩展生命周期状态及终止事件监听;待点击任务不再按登录超时处理,缺少联动接口时给出明确提示。 + +应用修复需要重启本地服务,在扩展管理页重新加载原安装目录 `dww11-account-switch-browser-extension/` 中的扩展,再刷新论坛。不要卸载重装、清空账号库或删除任务库。原来已手动换到新账号时,接口就绪后会核对新 UID 和插件空闲状态,再建立新批次,不会为了恢复旧任务再次抽号。此次未改动运行数据库或真实账号,尚需重新加载后的实机验证。 + +离线回归:`python3 -m unittest discover -s test -p test_queue.py -v`;`node test/extension-bridge.test.cjs`。本机旧版 Node 的 `node --test` 汇总模式解析中文测试名称失败,可逐个直接执行 `.test.cjs` 文件。 + 将新闻下载、文章打包、浏览器导入、排版与换号联动整合到本地控制台。 -**当前版本:0.2.1。下载 → 入队 → 导入 → 排版 → 人工发表 → 网站审核提示 → 受理记账 → 回首页 → 下一篇或换号。** 暂时只以网站提示“新主题需要审核,您的帖子通过审核后才能显示”判定发表成功,状态仍记为已受理、审核中。原详情页标题、作者、版块与图文核对代码已注释保留,当前不执行。 +**当前版本:0.2.2。下载 → 入队 → 导入 → 排版 → 人工发表 → 网站审核提示 → 受理记账 → 回首页 → 下一篇或换号。** 暂时只以网站提示“新主题需要审核,您的帖子通过审核后才能显示”判定发表成功,状态仍记为已受理、审核中。原详情页标题、作者、版块与图文核对代码已注释保留,当前不执行。 ## 启动 @@ -123,6 +131,12 @@ node test/browser.test.cjs 更新油猴中的 `browser/auto-news.user.js` 至 0.2.1,并重启本地服务、重新配对后刷新论坛页面。连接脚本无需更新。结果适配器和执行器中的旧详情页核对代码以行注释保留;后端旧核对接口继续保留供人工历史补核使用。自动受理使用当前任务的人工提交批次校验,审核提示可不含帖子链接,不创建虚构帖子 ID。 +## 0.2.2 悬浮窗控制与跳过图片检查 + +自动流程不再等待编辑器图片加载,也不检查排版前后图片标识和顺序。原检查函数保留,独立手动导入仍沿用默认检查。图片上传与本地文件格式限制继续用于构建可导入的文章。人工发表后只认网站指定审核提示,持久记账后回首页,按已配置间隔继续下一篇或换号。 + +论坛悬浮窗新增“开始 / 继续”和“暂停”,与工作台控制相同。开始前须在工作台保存处理规则;暂停会立即取消本标签可取消的操作,并通知本地服务与其他标签。更新两个油猴脚本:导入排版 0.2.2、本地连接 0.1.2;重启本地服务并重新配对。 + ## 标题自动截断 自动队列、手动目录和文章包导入均应用上述规则;已截断的标题不会重复添加省略号。已有队列在领取未开始的任务时更新标题,已导入、待审核或已提交任务保持现场标题。 diff --git a/app.py b/app.py index fabfbf1..05fdeb7 100644 --- a/app.py +++ b/app.py @@ -105,9 +105,10 @@ class Controller: methods = {'heartbeat': self.store.heartbeat, 'next': self.store.next, 'package': self.store.package, 'event': self.store.event, 'accept': self.store.accept, 'switch-begin': self.store.switch_begin, 'takeover': self.store.takeover, 'current': self.store.current, 'skip': self.store.skip} - if route == 'stop': - self.manager.stop.set() - return self.store.control('stop') + if route in ('start', 'pause', 'stop'): + if route == 'stop': + self.manager.stop.set() + return self.store.control(route) if route not in methods: raise ValueError('未知浏览器操作') return methods[route](body) diff --git a/browser/auto-news.user.js b/browser/auto-news.user.js index 0246558..ed8254f 100644 --- a/browser/auto-news.user.js +++ b/browser/auto-news.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name 新闻工作台 · 导入排版与流程 // @namespace local.dww11.auto-news -// @version 0.2.1 +// @version 0.2.2 // @match https://www.dww11.com/* // @match https://dww11.com/* // @match https://house.dww11.com/* @@ -439,7 +439,7 @@ if (switcher) switcher.checked = Number(win.wysiwyg) === 0; } async function importPrepared(options = {}) { - const { signal, automatic = false } = options; + const { signal, automatic = false, verifyImages = true } = options; signal?.throwIfAborted(); if (busy) throw new Error('正在导入,请勿重复点击。'); if (!article) throw new Error('请先选择文章目录或文章包。'); @@ -492,10 +492,10 @@ signal?.throwIfAborted(); setEditor(selected.title, bbcode); const raw = win.getEditorContents(); - if (Number(win.wysiwyg) === 1) { + if (Number(win.wysiwyg) === 1 && verifyImages) { 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 (Number(win.wysiwyg) !== 1 && raw !== bbcode) throw new Error('正文填写校验失败。'); if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。'); committed = true; } catch (error) { @@ -727,7 +727,7 @@ const cleanText = value => String(value || '').replace(/\s+/g, ' ').trim(); } window.DWW11Formatter = Object.freeze({ - format(expectedSource) { + format(expectedSource, {verifyImages = true} = {}) { if (Number(window.wysiwyg) !== 1) throw new Error('请切换到可视化编辑器后排版'); const body = document.querySelector('#e_iframe')?.contentDocument?.body; if (!body) throw new Error('编辑器未就绪'); @@ -740,7 +740,7 @@ window.DWW11Formatter = Object.freeze({ footer.remove(); formatBody(body, expectedSource, expected); const after = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':')); - if (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变'); + if (verifyImages && JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变'); if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常'); body.dispatchEvent(new Event('input', {bubbles:true})); return {images: after, source: expected}; @@ -892,14 +892,15 @@ window.DWW11Formatter = Object.freeze({ const prepared=await window.DWW11Importer.stagePackage(pack);assertLive(epoch); if(prepared.warnings?.length)throw new Error('文章需要人工处理:'+prepared.warnings.join(';')); await event('importing');assertLive(epoch);status('上传图片并填写文章…'); - const result=await window.DWW11Importer.importPrepared({automatic:true,signal:aborter.signal});assertLive(epoch); + const result=await window.DWW11Importer.importPrepared({automatic:true,verifyImages:false,signal:aborter.signal});assertLive(epoch); if(result?.cancelled)throw new Error('导入已取消'); await event('formatting');assertLive(epoch); - window.DWW11Formatter.format(job.source);assertLive(epoch); + window.DWW11Formatter.format(job.source,{verifyImages:false});assertLive(epoch); const snapshot=editorSnapshot(); if(snapshot.title!==job.title)throw new Error('标题校验失败'); - await window.DWW11Importer.verifyEditorImages( - document.querySelector('#e_iframe').contentDocument.body, result.attachmentOrder, aborter.signal); + // 按用户要求暂停图片检查;保留原调用以便恢复。 + // await window.DWW11Importer.verifyEditorImages( + // document.querySelector('#e_iframe').contentDocument.body, result.attachmentOrder, aborter.signal); assertLive(epoch); await event('awaiting_user_review',{snapshot});assertLive(epoch); active={...job,state:'awaiting_user_review'};saveLocal({id:job.id}); diff --git a/browser/formatter.js b/browser/formatter.js index 1a5e468..2690be8 100644 --- a/browser/formatter.js +++ b/browser/formatter.js @@ -172,7 +172,7 @@ const cleanText = value => String(value || '').replace(/\s+/g, ' ').trim(); } window.DWW11Formatter = Object.freeze({ - format(expectedSource) { + format(expectedSource, {verifyImages = true} = {}) { if (Number(window.wysiwyg) !== 1) throw new Error('请切换到可视化编辑器后排版'); const body = document.querySelector('#e_iframe')?.contentDocument?.body; if (!body) throw new Error('编辑器未就绪'); @@ -185,7 +185,7 @@ window.DWW11Formatter = Object.freeze({ footer.remove(); formatBody(body, expectedSource, expected); const after = [...body.querySelectorAll('img')].map(x => [x.id, x.getAttribute('aid'), x.getAttribute('src')].join(':')); - if (JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变'); + if (verifyImages && JSON.stringify(images) !== JSON.stringify(after)) throw new Error('排版后图片标识或顺序改变'); if (body.querySelectorAll('[data-dww11-wx-source="1"]').length !== 1) throw new Error('来源尾注数量异常'); body.dispatchEvent(new Event('input', {bubbles:true})); return {images: after, source: expected}; diff --git a/browser/importer.js b/browser/importer.js index 7582b9e..be34146 100644 --- a/browser/importer.js +++ b/browser/importer.js @@ -439,7 +439,7 @@ if (switcher) switcher.checked = Number(win.wysiwyg) === 0; } async function importPrepared(options = {}) { - const { signal, automatic = false } = options; + const { signal, automatic = false, verifyImages = true } = options; signal?.throwIfAborted(); if (busy) throw new Error('正在导入,请勿重复点击。'); if (!article) throw new Error('请先选择文章目录或文章包。'); @@ -492,10 +492,10 @@ signal?.throwIfAborted(); setEditor(selected.title, bbcode); const raw = win.getEditorContents(); - if (Number(win.wysiwyg) === 1) { + if (Number(win.wysiwyg) === 1 && verifyImages) { 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 (Number(win.wysiwyg) !== 1 && raw !== bbcode) throw new Error('正文填写校验失败。'); if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。'); committed = true; } catch (error) { diff --git a/browser/local-bridge.user.js b/browser/local-bridge.user.js index 3c8113f..ed392d3 100644 --- a/browser/local-bridge.user.js +++ b/browser/local-bridge.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name 新闻工作台 · 本地连接 // @namespace local.dww11.auto-news -// @version 0.1.1 +// @version 0.1.2 // @match https://www.dww11.com/* // @match https://dww11.com/* // @match https://house.dww11.com/* @@ -15,7 +15,7 @@ // ==/UserScript== (() => { 'use strict'; - const allowed = new Set(['heartbeat','next','package','event','accept','switch-begin','takeover','stop','current','skip']); + const allowed = new Set(['heartbeat','next','package','event','accept','switch-begin','takeover','start','pause','stop','current','skip']); let token = GM_getValue('pairToken',''), port = GM_getValue('pairPort',8770); let stopRevision = 0; const signalStop = () => { stopRevision++; document.dispatchEvent(new CustomEvent('dww11:auto-stop')); }; @@ -35,13 +35,14 @@ try {message=JSON.parse(event.detail);} catch {return;} if (!allowed.has(message.route) || typeof message.id !== 'string') return; try { - if (message.route==='stop') { GM_setValue('halted',true); signalStop(); } + if (message.route==='stop'||message.route==='pause') { GM_setValue('halted',true); signalStop(); } const revision = stopRevision; let value=await request(message.route,message.body || {}); if (message.route==='heartbeat') { if (revision !== stopRevision) value = {...value, running:false, reason:'终止后忽略旧连接响应'}; GM_setValue('halted',!value.running); if (!value.running) signalStop(); + document.dispatchEvent(new CustomEvent('dww11:run-state',{detail:Boolean(value.running)})); } document.dispatchEvent(new CustomEvent('dww11:local-response',{detail:JSON.stringify({id:message.id,value})})); } catch(error) { @@ -56,6 +57,32 @@ const shadow=host.attachShadow({mode:'closed'}); shadow.innerHTML='
新闻工作台 · 本地连接
连接设置
请启动本地工作台并配对
'; const $=id=>shadow.getElementById(id);$('port').value=port; + const start=document.createElement('button');start.id='start';start.type='button';start.textContent='开始 / 继续'; + const pause=document.createElement('button');pause.id='pause';pause.type='button';pause.textContent='暂停'; + $('halt').before(start);$('halt').before(pause); + let starting=false; + start.onclick=async()=>{ + if(starting||start.disabled)return; + starting=true;start.disabled=true; + const revision=stopRevision; + try { + await request('start',{}); + if(revision!==stopRevision) { + await request('pause',{}); + $('status').textContent='已暂停;忽略较早的开始响应'; + }else { + GM_setValue('halted',false); + $('status').textContent='已开始;等待执行标签继续流程'; + } + }catch(error){$('status').textContent='开始失败:'+error.message;} + finally{starting=false;start.disabled=false;} + }; + pause.onclick=async()=>{ + GM_setValue('halted',true);signalStop(); + $('status').textContent='已暂停;在途请求仍可能完成'; + try {await request('pause',{});}catch(error){$('status').textContent+='。'+error.message;} + }; + document.addEventListener('dww11:run-state',event=>{start.disabled=starting||Boolean(event.detail);}); const skip=document.createElement('button');skip.id='skip';skip.type='button';skip.textContent='跳过本文章'; $('halt').before(skip); skip.onclick=()=>document.dispatchEvent(new CustomEvent('dww11:skip-article')); diff --git a/browser/runner.js b/browser/runner.js index f4238e8..b4d2b37 100644 --- a/browser/runner.js +++ b/browser/runner.js @@ -80,14 +80,15 @@ const prepared=await window.DWW11Importer.stagePackage(pack);assertLive(epoch); if(prepared.warnings?.length)throw new Error('文章需要人工处理:'+prepared.warnings.join(';')); await event('importing');assertLive(epoch);status('上传图片并填写文章…'); - const result=await window.DWW11Importer.importPrepared({automatic:true,signal:aborter.signal});assertLive(epoch); + const result=await window.DWW11Importer.importPrepared({automatic:true,verifyImages:false,signal:aborter.signal});assertLive(epoch); if(result?.cancelled)throw new Error('导入已取消'); await event('formatting');assertLive(epoch); - window.DWW11Formatter.format(job.source);assertLive(epoch); + window.DWW11Formatter.format(job.source,{verifyImages:false});assertLive(epoch); const snapshot=editorSnapshot(); if(snapshot.title!==job.title)throw new Error('标题校验失败'); - await window.DWW11Importer.verifyEditorImages( - document.querySelector('#e_iframe').contentDocument.body, result.attachmentOrder, aborter.signal); + // 按用户要求暂停图片检查;保留原调用以便恢复。 + // await window.DWW11Importer.verifyEditorImages( + // document.querySelector('#e_iframe').contentDocument.body, result.attachmentOrder, aborter.signal); assertLive(epoch); await event('awaiting_user_review',{snapshot});assertLive(epoch); active={...job,state:'awaiting_user_review'};saveLocal({id:job.id}); diff --git a/build_browser.py b/build_browser.py index 18e3251..dfdc299 100644 --- a/build_browser.py +++ b/build_browser.py @@ -4,7 +4,7 @@ ROOT = Path(__file__).resolve().parent header = '''// ==UserScript== // @name 新闻工作台 · 导入排版与流程 // @namespace local.dww11.auto-news -// @version 0.2.1 +// @version 0.2.2 // @match https://www.dww11.com/* // @match https://dww11.com/* // @match https://house.dww11.com/* diff --git a/store.py b/store.py index b1d2261..c3a86d0 100644 --- a/store.py +++ b/store.py @@ -165,13 +165,19 @@ class Store: self.run['owner'] = owner if self.run['switch']: switch = self.run['switch'] - if account['uid'] != switch['from'] and data.get('extension', {}).get('idle') is True: + ext = data.get('extension') or {} + if ext.get('ready') is not True: + self.run.update(running=False, reason='换号插件联动接口未就绪,请在原安装目录更新并重新加载插件、刷新网站后继续;未重新抽号') + self._persist(db) + return dict(action='idle', run=self.run) + if account['uid'] != switch['from'] and ext.get('idle') is True: self.run.update(batch=self._batch(account), switch=None) self._persist(db) else: - ext = data.get('extension', {}) + # Pending means no click was issued. Time spent stopped, or + # waiting for the plugin, is not a failed login attempt. elapsed = time.time() - switch.get('clicked_at', switch['created_at']) - if ext.get('paused') or (switch['state'] == 'clicked' and elapsed > 10 and ext.get('idle')) or elapsed > 120: + if ext.get('paused') or (switch['state'] == 'clicked' and (elapsed > 120 or (elapsed > 10 and ext.get('idle')))): self.run.update(running=False, reason='换号已暂停、超时或账号未改变,请检查插件并人工恢复,禁止重复抽号') self._persist(db) return dict(action='idle', run=self.run) diff --git a/test/bridge.test.cjs b/test/bridge.test.cjs index 37e62a6..73f80c4 100644 --- a/test/bridge.test.cjs +++ b/test/bridge.test.cjs @@ -12,7 +12,7 @@ test('终止后较早的 heartbeat 响应不能恢复流程',async()=>{ requests[1].onload({status:200,responseText:JSON.stringify({running:false,epoch:2})});await stop; }); -test('悬浮窗跳过按钮发送页面事件并防止忙碌时重复点击',()=>{ +test('悬浮窗开始、暂停、跳过与开始响应竞态',async()=>{ const handlers=new Map(),elements=new Map(),emitted=[],requests=[]; const element=()=>({style:{},before(child){elements.set(child.id,child);}}); for(const id of ['halt','port','token','save','status'])elements.set(id,element()); @@ -33,4 +33,23 @@ test('悬浮窗跳过按钮发送页面事件并防止忙碌时重复点击',()= doc.dispatchEvent(new Event('dww11:local-request',{detail:JSON.stringify({id:'skip-test',route:'skip',body:{id:'job'}})})); assert.equal(requests[0].url,'http://127.0.0.1:8770/bridge/skip'); requests[0].onload({status:200,responseText:'{"ok":true}'}); + const start=elements.get('start'),pause=elements.get('pause'); + assert.equal(start.textContent,'开始 / 继续');assert.equal(pause.textContent,'暂停'); + let started=start.onclick();assert.equal(start.disabled,true); + assert.ok(requests.at(-1).url.endsWith('/bridge/start')); + requests.at(-1).onload({status:200,responseText:'{"running":true}'});await started; + doc.dispatchEvent(new Event('dww11:run-state',{detail:true}));assert.equal(start.disabled,true); + const paused=pause.onclick();assert.equal(emitted.at(-1).type,'dww11:auto-stop'); + assert.ok(requests.at(-1).url.endsWith('/bridge/pause')); + requests.at(-1).onload({status:200,responseText:'{"running":false}'});await paused; + doc.dispatchEvent(new Event('dww11:run-state',{detail:false}));assert.equal(start.disabled,false); + // Pause while Start is still in flight: a late Start must be followed by Pause. + started=start.onclick();const lateStart=requests.at(-1); + const pausing=pause.onclick();requests.at(-1).onload({status:200,responseText:'{"running":false}'});await pausing; + lateStart.onload({status:200,responseText:'{"running":true}'}); + await new Promise(resolve=>setImmediate(resolve)); + assert.ok(requests.at(-1).url.endsWith('/bridge/pause')); + requests.at(-1).onload({status:200,responseText:'{"running":false}'});await started; + assert.match(elements.get('status').textContent,/已暂停/); + }); diff --git a/test/extension-bridge.test.cjs b/test/extension-bridge.test.cjs new file mode 100644 index 0000000..325e35b --- /dev/null +++ b/test/extension-bridge.test.cjs @@ -0,0 +1,56 @@ +'use strict'; +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const path=require('node:path'); +const vm=require('node:vm'); +const flush=async()=>{for(let i=0;i<12;i++)await new Promise(resolve=>setImmediate(resolve));}; + +function fixture({holdStart=false,holdResume=false,flow=null,helloError=false}={}) { + const elements=[],handlers=new Map(),routes=[]; + let finishStart,finishResume; + function element(){ + const attrs=new Map(); + const el={style:{},hidden:false,disabled:false,children:[], + append(...nodes){this.children.push(...nodes);},prepend(){},before(){},remove(){}, + addEventListener(type,fn){this[type]=fn;},removeAttribute(key){attrs.delete(key);}, + setAttribute(key,value){attrs.set(key,value);},getAttribute:key=>attrs.get(key)??null, + attachShadow:()=>element()}; + elements.push(el);return el; + } + const doc={body:element(),createElement:element,querySelector:()=>null,querySelectorAll:()=>[], + addEventListener(type,fn){handlers.set(type,fn);}}; + const context={document:doc,location:{origin:'https://www.dww11.com',hostname:'www.dww11.com',href:'https://www.dww11.com/'}, + URL,Node:{TEXT_NODE:3},MutationObserver:class{observe(){}},setTimeout, + chrome:{runtime:{sendMessage:async({route})=>{ + routes.push(route); + if(route==='/hello')return helloError?{ok:false,error:'unavailable'}:{ok:true,data:{flow,total:1}}; + if(route==='/start'&&holdStart)return new Promise(resolve=>{finishStart=()=>resolve({ok:true,data:{flow:{id:'test',phase:'logout',paused:false}}});}); + if(route==='/resume'&&holdResume)return new Promise(resolve=>{finishResume=()=>resolve({ok:true,data:{flow:{id:'test',phase:'logout',paused:false}}});}); + if(route==='/suspend')return {ok:true,data:{}}; + throw new Error('Unexpected route '+route); + }}}}; + vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../../dww11-account-switch-browser-extension/content.js'),'utf8'),context); + return {routes,state:()=>JSON.parse(elements.find(e=>e.id==='dww11-extension-panel').getAttribute('data-auto-news-state')), + click:async(text)=>{elements.find(e=>e.textContent===text).click();await flush();}, + stop:()=>handlers.get('dww11:auto-stop')(),finishStart:()=>finishStart(),finishResume:()=>finishResume()}; +} + +test('插件公开生命周期状态,不包含账号和凭据',async()=>{ + const f=fixture();await flush(); + assert.deepEqual(f.state(),{ready:true,idle:true,paused:false}); + const failed=fixture({helloError:true});await flush(); + assert.deepEqual(failed.state(),{ready:false,idle:false,paused:false}); +}); +test('抽号响应返回前终止,保留该次抽号并暂停,不执行登录',async()=>{ + const f=fixture({holdStart:true});await flush();await f.click('登陆下一帐号'); + assert.equal(f.state().idle,false);f.stop();f.finishStart();await flush(); + assert.deepEqual(f.routes,['/hello','/start','/suspend']); + assert.deepEqual(f.state(),{ready:true,idle:false,paused:true}); +}); +test('恢复响应返回前终止,不执行登录或再次抽号',async()=>{ + const f=fixture({holdResume:true,flow:{id:'test',paused:true,phase:'logout'}});await flush(); + await f.click('继续未完成流程');f.stop();f.finishResume();await flush(); + assert.deepEqual(f.routes,['/hello','/resume','/suspend']); + assert.equal(f.state().paused,true); +}); diff --git a/test/importer.test.cjs b/test/importer.test.cjs index 2ddcdcf..d668b75 100644 --- a/test/importer.test.cjs +++ b/test/importer.test.cjs @@ -182,6 +182,11 @@ test('真实站点 aid 属性:上传成功但图片响应失败不得报告完 await assert.rejects(h.api.importPrepared({automatic:true}),/网站附件图片无法显示/); assert.equal(h.ids.get('subject').value,'');assert.equal(h.api.getState().result,null); }); +test('自动流程关闭图片检查后,不等待或拒绝无法显示的图片', async () => { + const h=harness({brokenImages:true});await h.api.stageFiles(articleFiles()); + const result=await h.api.importPrepared({automatic:true,verifyImages:false}); + assert.ok(result.attachmentOrder.length);assert.ok(h.ids.get('subject').value); +}); test('链接内图片、多行图文链接均参与上传并按原位置渲染', () => { const md='# 标题\n\n[![宣传](image004.jpg)\n\n### 宣传标题\n\n说明](https://example.com/promo)\n\n![](image001.jpg)'; const article=core.compile(md,'a.md',new Map([['image004.jpg',new Blob(['x'])],['image001.jpg',new Blob(['y'])]])); diff --git a/test/runner.test.cjs b/test/runner.test.cjs index ff03b10..4edb8a9 100644 --- a/test/runner.test.cjs +++ b/test/runner.test.cjs @@ -49,10 +49,11 @@ function fixture({holdImport=false,holdHeartbeat=false,rejectSkip=false,result=n 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})=>{ + context.window={DWW11Results:result?{inspect:()=>result,clean:v=>String(v||'').trim()}:null,DWW11Importer:{stagePackage:async()=>({}),importPrepared:async({signal,verifyImages})=>{ + assert.equal(verifyImages,false); if(holdImport)await new Promise((resolve,reject)=>{finishImport=()=>{rolledBack=true;reject(signal.reason);};signal.addEventListener('abort',()=>{}, {once:true});}); signal.throwIfAborted();subject.value='标题';return {attachmentOrder:[]}; - },verifyEditorImages:async()=>{}},DWW11Formatter:{format(){}}}; + },verifyEditorImages:async()=>{throw new Error("自动流程不应检查图片");}},DWW11Formatter:{format(source,options){assert.equal(options.verifyImages,false);}}}; vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../browser/runner.js'),'utf8'),context); return {calls,navigations,job,run,storage,doc,Event,tick:()=>tick(),skip:()=>doc.dispatchEvent(new Event('dww11:skip-article')), finishImport:()=>finishImport(),finishHeartbeat:()=>finishHeartbeat(),rolledBack:()=>rolledBack, diff --git a/test/test_controller.py b/test/test_controller.py index 851a550..26cd334 100644 --- a/test/test_controller.py +++ b/test/test_controller.py @@ -70,6 +70,16 @@ class ControllerTests(unittest.TestCase): self.controller.scan(self.controller.manager.root) self.assertEqual(len(self.controller.store.state()['jobs']), 1) + def test_bridge_start_and_pause(self): + store = self.controller.store + with self.assertRaises(ValueError): + self.controller.bridge('start', {}) + store.configure(dict(fid=95, minimum=1, maximum=1, interval=0, history_checked=True)) + for action, expected in [('start', True), ('pause', False), ('start', True)]: + code, body = self.request('/bridge/' + action, {}, token=self.controller.bridge_token) + self.assertEqual(code, 200) + self.assertEqual(json.loads(body)['running'], expected) + def test_bridge_skip_continues_queue(self): store = self.controller.store store.enqueue(ROOT / 'test/fixtures/article') diff --git a/test/test_queue.py b/test/test_queue.py index 5cfad54..db3716d 100644 --- a/test/test_queue.py +++ b/test/test_queue.py @@ -22,7 +22,7 @@ class QueueTests(unittest.TestCase): self.md.write_text('# 测试新闻\n\n正文内容。\n\n来源:测试来源,如有侵权,请联系删除。\n') self.store = Store(self.root / 'queue.sqlite3') 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}, url='https://www.dww11.com/') + self.owner = dict(owner='tab-a', account=dict(uid='123', name='测试账号'), extension={'ready': True, 'idle': True}, url='https://www.dww11.com/') def tearDown(self): self.tmp.cleanup() @@ -321,6 +321,34 @@ class QueueTests(unittest.TestCase): self.assertFalse(self.store.run['running']) self.assertEqual(self.store.run['switch']['state'], 'clicked') + def pending_switch(self): + import time + job = self.begin(); self.submit(job); self.accept(job) + other = self.root / 'other'; shutil.copytree(self.article, other) + (other / 'a.md').write_text(self.md.read_text().replace('正文内容', '另一篇内容')) + self.store.enqueue(other) + self.assertEqual(self.store.next(self.owner)['action'], 'switch') + self.store.run['switch']['created_at'] = time.time() - 3600 + + def test_old_pending_switch_does_not_timeout_before_click(self): + self.pending_switch() + self.store.control('pause'); self.store.control('start') + self.assertEqual(self.store.next(self.owner)['action'], 'switch') + self.assertTrue(self.store.run['running']) + self.assertNotIn('clicked_at', self.store.run['switch']) + + def test_missing_bridge_explains_problem_and_preserves_pending_switch(self): + self.pending_switch() + changed = {**self.owner, 'account': {'uid': '456', 'name': '手动换号后的账号'}, 'extension': {}} + self.assertEqual(self.store.next(changed)['action'], 'idle') + self.assertIn('联动接口未就绪', self.store.run['reason']) + self.assertEqual(self.store.run['switch']['state'], 'pending') + self.assertEqual(self.store.run['batch']['count'], 1) + self.store.control('start') + self.assertEqual(self.store.next({**changed, 'extension': {'ready': True, 'idle': True}})['action'], 'prepare') + self.assertIsNone(self.store.run['switch']) + self.assertEqual(self.store.run['batch']['count'], 0) + def test_rejected_submission_requires_manual_resolution(self): job = self.begin(); self.submit(job) self.transition(job, 'rejected', error='标题超限')