给 AI 助手搭了一套每天自动干活的管线之后,我最大的体会是:会报错的 bug 都是好 bug。
会报错的,堆栈一贴、退出码一看,十分钟能定位。真正让人抓狂的是那些"什么都不报、但结果全错"的坑——脚本跑完了,日志干净,返回码 0,你以为万事大吉,直到发现发出去的是一篇空文章、用户打开的还是三天前的旧页面。
下面这三个坑是我在真实的无人值守管线里踩出来的,每一个都发生在"看起来一切正常"的时候。
现象:改完页面生成脚本,重启本地服务,刷新浏览器——还是旧版本。用 netstat 一看,8015 端口稳稳地 LISTEN,服务"活着"。
根因:Python 标准库的 http.server.HTTPServer 继承自 socketserver.TCPServer,而它的类属性 allow_reuse_address = 1 会在 socket 上设置 SO_REUSEADDR。
在 Linux 上这个选项只影响 TIME_WAIT 状态的地址复用,没什么危害。但在 Windows 上它的语义完全不同:它允许两个进程同时绑定同一个端口。于是旧进程没死透的情况下,新进程 bind() 照样返回成功、不抛异常、不进 except —— 你重启了个寂寞,请求实际还是被先绑定的旧进程接走了。
验证:数一下有几个 PID 在听这个端口。
netstat -ano | findstr :8015如果出现两行 LISTENING、PID 不同,就是它。
修法:三件套,缺一不可。
from http.server import ThreadingHTTPServer
from urllib.request import urlopen
class Server(ThreadingHTTPServer):
allow_reuse_address = False # 关键:宁可 bind 失败,也不要双绑定
def already_running(url="http://127.0.0.1:8015/state", timeout=1.5):
"""启动前探活:已有服务在跑就直接退出,别再造一个。"""
try:
with urlopen(url, timeout=timeout) as r:
return r.status == 200
except Exception:
return False再补第三步:启动脚本里先按命令行特征精准清掉自己上一次的进程(下文坑三给了写法),然后才 bind。
经验:bind() 返回成功 ≠ 端口归你。在任何"重启本地服务"的场景里,探活 + 拒绝复用比"bind 成功了就接着跑"可靠得多。
现象:用 CDP 自动填某开发者社区的发文页。标题填上了、标签选上了、流程一路无异常,最后"发布成功"。点开文章一看——正文是空的。回头看字数计数器:0 / 50000。
根因:这个页面的正文编辑器是 Monaco。脚本里是这么写的:
monaco.editor.getModels()[0].setValue(md)问题在于,页面刚加载完时 Monaco 尚未完成初始化,getModels() 此刻返回的是空数组。如果这段代码被包在 Runtime.evaluate 里,异常只会出现在返回值的 exceptionDetails 字段中——而我当时的封装只取了 result.value,没检查 exceptionDetails。于是"下标越界"这种硬错误被彻底吞掉,脚本判断"已填入正文",继续往下点发布。
一个吞掉异常的封装 + 一个没就绪就调用的时机,等于一篇空文章。
修法:等就绪 + 写完回读,两道闸门。
def wait_monaco(cdp, timeout=20):
"""轮询等编辑器就绪,别赌它已经好了。"""
t0 = time.time()
while time.time() - t0 < timeout:
if cdp.eval("!!(window.monaco && monaco.editor.getModels().length)"):
return True
time.sleep(0.5)
return False
wait_monaco(cdp)
cdp.eval("monaco.editor.getModels()[0].setValue(%s)" % json.dumps(md))
# 关键:读回来验证,不足 90% 就中止,绝不硬发
after = cdp.eval("monaco.editor.getModels()[0].getValue().length")
if not isinstance(after, int) or after < len(md) * 0.9:
raise RuntimeError("正文写入校验失败,实际长度=%r" % after)改完之后,首次重跑当场就在这道校验上拦下来了——因为那次编辑器确实还没就绪。如果没有这道回读,它会安静地发出第二篇空文章。
同一类坑在 React 受控组件上也会遇到:标题输入框直接改 value 会被框架的 state 覆盖,必须走原生 setter 再手动派发 input 事件,否则页面显示有字、提交上去是空的。
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype, "value").set;
setter.call(el, text);
el.dispatchEvent(new Event("input", { bubbles: true }));经验:凡是"写进去"的操作,都要配一次"读回来"。写入没有回读校验,等于没有写入。
现象:脚本报 CDP 连接超时(recv timeout)。但任务管理器里明明有 msedge.exe,调试端口看起来也"在"。
根因:上一次异常退出的无头实例没清理干净,仍然霸占着调试端口和它那份 profile。当脚本用同一个 profile 目录再次拉起浏览器时,Edge 检测到"已有实例持有该 profile",就会把请求转给(或直接复用)那个旧实例,然后自己退出。
结果就是:脚本连上的是一个渲染进程早已死掉的僵尸。命令发出去,没有任何人回应,于是超时。
修法:按"命令行包含自己专属 profile 路径"这个特征精准杀进程。绝不能按名字全杀 msedge.exe——那会把用户正在用的浏览器一起干掉,这是自动化脚本最不能犯的错误。
powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='msedge.exe'\" | Where-Object { $_.CommandLine -like '*\.edge-profile-tc\*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"隔离策略同样重要:给自动化单独一个 profile 目录、单独一个调试端口。
msedge.exe --user-data-dir="C:\path\to\.edge-profile-tc" --remote-debugging-port=9222经验:调试端口只对首个持有该 profile 的实例生效。所以每次开跑前先清场,且清场必须用"自己的特征"(profile 路径、专属端口),而不是"进程名"。
说到底,这三个坑有个共同点:它们都在"看起来正常"的时候发生。有人盯着的时候,你会本能地看一眼页面、扫一眼字数;而无人值守时,唯一的裁判就是脚本自己写下的断言。
所以给自动化写代码,思路要反过来:不是"让流程跑通",而是假设每一步都可能悄悄失败,然后为每一步准备一个能证明它真的成功的证据。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。