一句话总结 :自动化脚本的幂等设计核心是”可重入”——无论脚本在哪一步崩溃、重试多少次,最终状态都一样。实现方式:运行前扫描已完成的进度 → 只处理未完成的部分 → 每完成一步就持久化记录进度。
目录
问题场景
幂等设计的核心思想
实现方式一:本地状态追踪
实现方式二:页面状态校验
组合方案:双重校验
异常处理与重试策略
总结
问题场景 需要自动批量上传 100 个章节到某小说平台。每个章节的操作流程是:
1 2 3 打开章节编辑器 → 填写标题 → 填写内容 → 点击下一步 → 处理错别字提示 → 选择检测模式 → 确认发布 → 验证发布成功 → 返回章节列表
整个流程需要 30~60 分钟。期间可能因为网络波动、安全软件拦截、浏览器异常等原因中断。
如果第 57 章上传到一半时崩溃,重新运行脚本应该:
不重复 :已成功发布的第 1~56 章不再重新上传
不遗漏 :第 57 章继续上传(如果是第 57 章已发布但记录没保存,就跳过)
自恢复 :中间状态丢失时能从最后已知状态继续
这就是幂等设计要解决的问题。
幂等设计的核心思想 幂等性(Idempotency)在 HTTP 中的定义是:一次和多次请求的效果相同。对于自动化脚本,幂等意味着:
无论脚本在哪一步失败、被中断多少次、重新运行多少次,最终达到的状态都是一样的。
实现这一目标的三步策略:
1 2 3 1. 扫描 → 启动时检查"当前已完成什么" 2. 过滤 → 跳过已完成的,只处理未完成的 3. 记录 → 每完成一项就持久化记录进度
实现方式一:本地状态追踪 持久化记录 用一个 JSON 文件追踪已处理的项目:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import osimport jsonRECORD_FILE = 'progress.json' def load_progress (): """加载已完成的进度""" if os.path.exists(RECORD_FILE): with open (RECORD_FILE, 'r' , encoding='utf-8' ) as f: return json.load(f) return {'published' : [], 'failed' : []} def save_progress (progress ): """持久化保存进度""" with open (RECORD_FILE, 'w' , encoding='utf-8' ) as f: json.dump(progress, f, ensure_ascii=False , indent=2 ) def mark_published (progress, chapter_id ): """标记章节为已发布""" if chapter_id not in progress['published' ]: progress['published' ].append(chapter_id) save_progress(progress) def mark_failed (progress, chapter_id, error ): """标记章节为发布失败""" progress['failed' ].append({ 'id' : chapter_id, 'error' : str (error), 'time' : time.strftime('%Y-%m-%d %H:%M:%S' ) }) save_progress(progress)
启动时扫描 1 2 3 4 5 6 7 8 9 10 11 12 13 14 def get_pending_chapters (all_chapters, progress ): """获取待处理的章节列表""" published = set (progress['published' ]) failed_ids = {f['id' ] for f in progress['failed' ]} pending = [] for ch in all_chapters: if ch['id' ] in published: continue if ch['id' ] in failed_ids: continue pending.append(ch) return pending
实现方式二:页面状态校验 记录文件可能丢失或被误删。更可靠的方式是从页面当前状态 来验证:
从管理页面获取已发布列表 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 def get_published_chapters (ws ): """登录到作者后台,扫描管理页面获取已发布的章节编号""" cmd(ws, 'Page.navigate' , {'url' : 'https://mouplatform.com/author/chapters' }) time.sleep(3 ) chapters = js(ws, """ (() => { const items = document.querySelectorAll('.chapter-item'); return Array.from(items).map(item => { const num = item.querySelector('.chapter-num'); const status = item.querySelector('.chapter-status'); return { id: parseInt(num?.innerText || '0'), status: status?.innerText?.trim() || '' }; }).filter(ch => ch.status === '已发布' || ch.status === 'Published'); })() """ ) return {ch['id' ] for ch in chapters}
合并使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def get_pending_list (ws, all_chapters ): """双重验证:本地记录 + 页面校验""" progress = load_progress() local_published = set (progress['published' ]) page_published = get_published_chapters(ws) already_published = local_published | page_published progress['published' ] = list (already_published) save_progress(progress) return [ch for ch in all_chapters if ch['id' ] not in already_published]
组合方案:双重校验 最可靠的方案是组合使用本地记录和页面校验:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 def run_upload_flow (ws, all_chapters ): """完整的幂等发布流程""" pending = get_pending_list(ws, all_chapters) print (f'总共 {len (all_chapters)} 章,待处理 {len (pending)} 章' ) if not pending: print ('全部章节已发布完毕!' ) return progress = load_progress() for idx, ch in enumerate (pending): print (f'[{idx+1 } /{len (pending)} ] 处理第 {ch["id" ]} 章...' ) try : success = process_one_chapter(ws, ch) if success: mark_published(progress, ch['id' ]) print (f' ✅ 第 {ch["id" ]} 章发布成功' ) else : mark_failed(progress, ch['id' ], '返回状态异常' ) print (f' ❌ 第 {ch["id" ]} 章发布失败(跳过)' ) except (ConnectionAbortedError, ConnectionResetError) as e: print (f' ⚡ 连接断开,正在重连...' ) ws = reconnect_ws(ws) try : success = process_one_chapter(ws, ch) if success: mark_published(progress, ch['id' ]) print (f' ✅ 第 {ch["id" ]} 章发布成功(重试后)' ) else : mark_failed(progress, ch['id' ], '重试后仍失败' ) except Exception as e2: mark_failed(progress, ch['id' ], f'重试异常: {e2} ' ) print (f' ❌ 第 {ch["id" ]} 章重试失败' ) except Exception as e: mark_failed(progress, ch['id' ], str (e)) traceback.print_exc() time.sleep(1 ) final_progress = load_progress() print (f'\n发布完成!' ) print (f' 成功: {len (final_progress["published" ])} 章' ) print (f' 失败: {len (final_progress["failed" ])} 章' )
异常处理与重试策略 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def process_with_retry (ws, func, *args, max_retries=3 ): """带重试的操作执行""" for attempt in range (max_retries): try : result = func(ws, *args) if result: return result except ConnectionError as e: if attempt < max_retries - 1 : ws = reconnect_ws(ws) time.sleep(2 ** attempt) continue raise except Exception as e: raise return None
幂等操作清单 每个操作应该是可重入的 :
操作
是否幂等
说明
点击”下一步”按钮
❌
再次点击可能提交两次
填写 input 框
✅
多次填写效果相同
选择 radio
✅
多次选择效果相同
点击”确认发布”
❌
可能导致重复发布
页面导航
✅
多次导航到同一 URL 效果相同
读取页面数据
✅
只读操作自然幂等
写 JSON 记录文件
✅
覆盖写而非追加写
非幂等操作的防护 :在执行非幂等操作前,先校验是否已经执行过。
总结
幂等设计不是事后补丁 ,而应该在设计脚本之初就考虑进去
三重保障 :本地记录 + 页面校验 + 异常重试,层层兜底
非幂等操作要加防护 :执行前先校验状态,确认需要执行再执行
连接错误单独特判 :WebSocket 断线是高频问题,重连后重试当前操作
记录文件要即时保存 :每完成一项就写盘,而不是等全部完成再批量写
总结 :幂等设计让自动化脚本从”脆弱的一次性脚本”升级为”健壮的长期服务”。核心很朴素——运行前看”已经做了什么”而不是假设”什么都没做”。配合持久化记录和页面状态双重校验,即使面对网络波动、浏览器崩溃、安全软件拦截,脚本也能稳定运行到最后一刻。