错误处理与运维监控:让自动化脚本永不静默失败(完结篇)

第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节完结):

本文首发于稀土掘金。全系列完结,感谢阅读。

相关推荐
shuoyuanAI1 小时前
张家口专业的aigeo优化品牌找哪家
大数据·人工智能·python
甜包AI笔记1 小时前
Loop Engineer:让 AI 在工程闭环里自动推进任务
人工智能
killerbasd1 小时前
总结 7。10
人工智能·算法·机器学习
GlobalInfo1 小时前
行业报告解读:可穿戴电子织物市场扩张背后,有哪些新机遇?
人工智能·物联网·microsoft
林间码客1 小时前
RAG系统评估指南:从入门到实践
人工智能·算法·机器学习
菊链诗腾1 小时前
部署大模型8GB显存128K大模型调优实战
人工智能·调优·本地大模型
龙虾PRO2 小时前
AI 人才赛道新风口:非传统产品经理如何入局 AI 产品落地赛道
人工智能·产品经理
ylscode2 小时前
Muse Spark 1.1正式发布:Meta首款付费API背后的智能体野心
人工智能
lkshop2 小时前
GEO 技术入门:大模型如何检索、理解并引用你的内容
人工智能·geo·蓝空geo