第5节提到一个教训:Cookie 失效后脚本静默失败了一整周,没人知道。这一节彻底解决这个问题------从异常捕获到日志体系到失败通知,打造一套脚本出事你一定知道的可观测性方案。全系列完结篇。
1. 静默失败:自动化的头号杀手
先看一个真实事故时间线:
bash
06-16 Cookie 过期
↓
06-17 脚本运行,被重定向到登录页
↓ 拿不到数据,正常退出(exit code 0)
06-18 同上
↓
...
06-22 你偶然看了一眼执行记录
↓ 发现整整一周没有任何数据
根因:脚本没有报错------它"成功"地运行完了,只是什么也没干。exit code 0 不代表任务完成,只代表进程没崩。
2. 异常分层捕获策略
2.1 三层异常模型
python
class AutomationError(Exception):
"""自动化基础异常"""
pass
class LoginError(AutomationError):
"""登录态失效"""
pass
class PageError(AutomationError):
"""页面操作失败"""
pass
class DataError(AutomationError):
"""数据异常:拿到的数据不符合预期"""
pass
2.2 分层捕获
python
async def run_course(page, course_id):
try:
activities = await get_activities(page, course_id)
# 关键:数据校验
if not activities:
raise DataError(f"课程 {course_id} 活动列表为空")
await browse_course(page, activities)
await grade_course(page, activities)
except LoginError:
# 登录问题:终止整个任务,通知用户
logger.error("登录态失效,需手动登录")
raise # 向上传播
except PageError as e:
# 页面问题:跳过这门课,继续下一门
logger.warning(f"课程 {course_id} 页面异常: {e}")
stats.add_skip(course_id)
return # 不 raise,继续下一门
except DataError as e:
# 数据异常:记录但继续
logger.warning(f"课程 {course_id} 数据异常: {e}")
stats.add_data_error(course_id)
return
except Exception as e:
# 未知异常:记录完整堆栈,继续
logger.error(f"课程 {course_id} 未知异常: {e}", exc_info=True)
stats.add_unknown_error(course_id)
return
核心原则:登录问题终止全局,单课程问题跳过继续,未知问题记录堆栈但不崩。
3. 数据校验:最容易被忽略的防线
静默失败的根因是没有校验数据。脚本拿到了空列表,却没有判断这是"正常无数据"还是"异常无数据"。
python
# ❌ 危险:不校验
async def browse_course(page, activities):
for act in activities:
await visit(page, act.url)
# 如果 activities 是空列表,这里什么都不做
# 但调用方以为"浏览成功了"
# ✅ 安全:校验
async def browse_course(page, activities, course_name):
if not activities:
raise DataError(
f"{course_name}: 活动列表为空,"
f"可能是 Cookie 失效或页面未加载"
)
visited = 0
for act in activities:
try:
await visit(page, act.url)
visited += 1
except Exception as e:
logger.warning(f" 跳过 {act.title}: {e}")
# 关键:校验实际访问数
if visited == 0:
raise DataError(f"{course_name}: 0 页浏览成功")
return visited
4. 日志体系设计
4.1 双写日志:文件 + 控制台
python
import logging
from pathlib import Path
class Logger:
def __init__(self, name, log_dir="logs"):
Path(log_dir).mkdir(exist_ok=True)
self.logger = logging.getLogger(name)
self.logger.setLevel(logging.DEBUG)
# 文件:记录一切
fh = logging.FileHandler(
f"{log_dir}/{name}_{datetime.now():%Y%m%d}.log",
encoding="utf-8"
)
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s"
))
# 控制台:只看重要信息
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
ch.setFormatter(logging.Formatter(
"%(asctime)s %(message)s", "%H:%M:%S"
))
self.logger.addHandler(fh)
self.logger.addHandler(ch)
def info(self, msg): self.logger.info(msg)
def warning(self, msg): self.logger.warning(msg)
def error(self, msg, exc_info=False):
self.logger.error(msg, exc_info=exc_info)
4.2 结构化执行报告
每次运行结束输出一份 JSON 报告:
python
class Stats:
def __init__(self):
self.start_time = datetime.now()
self.courses_total = 0
self.courses_ok = 0
self.courses_skip = 0
self.pages_browsed = 0
self.essays_graded = 0
self.forum_replied = 0
self.errors = []
def report(self):
duration = (datetime.now() - self.start_time).total_seconds()
return {
"date": self.start_time.strftime("%Y-%m-%d"),
"duration_sec": int(duration),
"summary": {
"courses": f"{self.courses_ok}/{self.courses_total} ok",
"browsed": self.pages_browsed,
"graded": self.essays_graded,
"replied": self.forum_replied
},
"skipped": self.courses_skip,
"errors": self.errors[-5:], # 最近5个错误
"success": self.courses_ok > 0 and len(self.errors) == 0
}
4.3 日志文件轮转
python
from logging.handlers import TimedRotatingFileHandler
# 每天一个文件,保留 30 天
handler = TimedRotatingFileHandler(
"logs/ouchn.log",
when="midnight",
interval=1,
backupCount=30,
encoding="utf-8"
)
5. 重试机制
网络请求和页面操作天然不稳定,重试是必须的:
python
import asyncio
from functools import wraps
def retry(max_attempts=3, delay=2, backoff=2, exceptions=(Exception,)):
"""
max_attempts: 最大重试次数
delay: 首次重试延迟(秒)
backoff: 退避倍数(每次延迟 *= backoff)
exceptions: 哪些异常需要重试
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
last_error = None
current_delay = delay
for attempt in range(1, max_attempts + 1):
try:
return await func(*args, **kwargs)
except exceptions as e:
last_error = e
if attempt < max_attempts:
logger.warning(
f"{func.__name__} 第{attempt}次失败: {e},"
f"{current_delay}秒后重试"
)
await asyncio.sleep(current_delay)
current_delay *= backoff
else:
logger.error(
f"{func.__name__} {max_attempts}次全部失败"
)
raise last_error
return wrapper
return decorator
# 使用
@retry(max_attempts=3, delay=2, exceptions=(PageError, TimeoutError))
async def get_activities(page, course_id):
...
重试策略选择:
| 场景 | 策略 | 原因 |
|---|---|---|
| 网络超时 | 重试 3 次,指数退避 | 临时网络波动 |
| Cookie 失效 | 不重试,直接通知 | 重试也没用 |
| 元素未找到 | 重试 2 次,固定 5 秒 | 页面加载慢 |
| 数据为空 | 不重试,标记异常 | 可能是逻辑问题 |
6. 失败通知机制
脚本出事了,你得知道。两种通知方式:
6.1 控制台输出(给定时任务用)
WorkBuddy 定时任务执行后,AI 会读取控制台输出。所以关键状态必须 print:
python
def print_summary(stats):
report = stats.report()
# 成功
if report["success"]:
print(f"✅ 今日完成:浏览 {report['summary']['browsed']} 页,"
f"批改 {report['summary']['graded']} 份")
else:
# 失败:用显眼标记
print(f"❌ 任务异常!成功 {report['summary']['courses']}")
if report["errors"]:
print("错误详情:")
for err in report["errors"]:
print(f" - {err}")
AI 看到 ❌ 就知道出了问题,会在任务执行记录里标注。
6.2 乐享知识库日志(持久化)
把每日报告写入乐享,形成可追溯的历史:
arduino
"把今天的 OUCN 执行报告写到乐享知识库"
↓
AI 调用 lexiang MCP → 创建/追加到日志页面
乐享日志页面:
07-06 ✅ 浏览 96 页,批改 62 份
07-05 ✅ 浏览 93 页,批改 58 份
07-04 ❌ Cookie 失效
07-03 ✅ 浏览 96 页,批改 61 份
7. 健康检查与自愈
7.1 启动前健康检查
python
async def health_check(page):
"""执行前检查环境是否正常"""
checks = []
# 检查1:浏览器连接
try:
title = page.title()
checks.append(("浏览器连接", True, ""))
except:
checks.append(("浏览器连接", False, "无法连接 9222"))
return checks
# 检查2:登录态
page.goto(PLATFORM_URL)
page.wait_for_timeout(3000)
if "login" in page.url.lower():
checks.append(("登录态", False, "Cookie 失效"))
else:
checks.append(("登录态", True, ""))
# 检查3:页面可达
try:
resp = page.goto(f"{PLATFORM_URL}/api/health")
checks.append(("服务器", resp.status == 200, f"HTTP {resp.status}"))
except:
checks.append(("服务器", False, "无法访问"))
return checks
# 使用
checks = await health_check(page)
all_ok = all(c[1] for c in checks)
if not all_ok:
for name, ok, msg in checks:
status = "✅" if ok else "❌"
print(f" {status} {name}: {msg}")
# 登录态失败:等待用户登录
if not checks[1][1]:
print("请在浏览器中登录,完成后按回车...")
input()
save_cookies(context)
else:
sys.exit(1)
7.2 运行中自愈
python
async def ensure_page(browser, context):
"""页面被关闭时自动恢复"""
try:
# 测试当前 page 是否可用
_ = page.title()
return page
except:
logger.warning("页面已关闭,重新创建...")
# 尝试复用已有标签页
if context.pages:
return context.pages[-1]
# 都没有就新建
return context.new_page()
8. 踩坑总结
| 坑 | 现象 | 解决 |
|---|---|---|
| 静默失败 | exit code 0 但没数据 | 数据校验 + 空列表检测 |
| 日志太多 | 看不过来 | 分级:文件 DEBUG,控制台 INFO |
| 重试雪崩 | 重试叠加导致请求爆炸 | 指数退避 + 最大重试数 |
| 通知淹没 | 每次都通知,变成噪音 | 只在失败时通知,成功静默 |
| 日志撑爆磁盘 | 30 天后几个 GB | TimedRotatingFileHandler + backupCount |
小结
| 学会的 | 要点 |
|---|---|
| 静默失败根因 | 不校验数据,exit 0 ≠ 成功 |
| 异常分层 | 登录问题终止全局,单课问题跳过继续 |
| 数据校验 | 空列表 = 异常,必须 raise |
| 日志体系 | 文件 DEBUG + 控制台 INFO + JSON 报告 |
| 重试策略 | 指数退避,区分"可重试"和"不可重试" |
| 失败通知 | 控制台 ❌ 标记 + 乐享持久化日志 |
| 健康检查 | 启动前检查连接/登录/服务器 |
| 自愈机制 | 页面关闭自动恢复 |
全系列总结
八节课,从零到一搭建了一套完整的浏览器自动化体系:
| 节次 | 主题 | 核心能力 |
|---|---|---|
| 第1节 | WorkBuddy Skill 入门 | SKILL.md 结构、创建流程 |
| 第2节 | CDP 浏览器操控原理 | Playwright 连接调试端口、复用登录态 |
| 第3节 | 云端共享与多终端部署 | 乐享分发、venv 隔离、一键部署 |
| 第4节 | 完整项目实战 | OUCN 自动化:需求→架构→开发→踩坑 |
| 第5节 | 登录态持久化 | Cookie 保存/加载/失效检测/验证码兜底 |
| 第6节 | Angular 页面自动化进阶 | scope 注入、Select2、$apply()、表单提交 |
| 第7节 | 定时任务与自动化调度 | RRULE 规则、多任务编排、有效期控制 |
| 第8节 | 错误处理与运维监控 | 异常分层、日志体系、重试、通知、自愈 |
技术路线回顾
markdown
第1-2节:基础能力(写 Skill + 操控浏览器)
↓
第3节:分发能力(云端共享、多终端部署)
↓
第4节:综合实战(完整项目走一遍)
↓
第5-6节:深入难点(登录态 + Angular 操控)
↓
第7-8节:运维保障(定时调度 + 错误处理)
下一步
这套体系不限于 OUCN 平台。同样的模式可以应用到:
- 任何需要登录的网站自动化(用第5节的 Cookie 管理)
- 任何 Angular/React/Vue 页面操控(用第6节的 scope 注入思路)
- 任何定时批量任务(用第7节的调度系统)
- 任何需要可靠运行的脚本(用第8节的可观测性方案)
关键不是记住每行代码,而是理解背后的设计思路:复用已有状态、走框架的数据通道、分层处理异常、让失败可见。
📺 系列文章(全8节完结):
- 第1节:WorkBuddy Skill 入门
- 第2节:CDP 浏览器操控原理
- 第3节:云端共享与多终端部署
- 第4节:WorkBuddy 自动化实战
- 第5节:登录态持久化
- 第6节:Angular 页面自动化进阶
- 第7节:定时任务与自动化调度
- 第8节:错误处理与运维监控(本文)
本文首发于稀土掘金。全系列完结,感谢阅读。