ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Python自动采集状态页事故归档,助力SLA评估与稳定性分析

用Python自动采集状态页事故归档,助力SLA评估与稳定性分析 最近在整理一份供应商稳定性SLA评估报告需要把过去一年多各家软件服务状态页上记录的历史事故归档全部汇总出来。一开始我打算纯手工复制粘贴翻了两个页面就放弃了——事故多、字段杂、时间还全是UTC格式手指头都点麻了。于是干脆写了套Python采集脚本把状态页上的事故归档自动拉下来清洗入库。这套方案不复杂放在这里完整拆一遍希望对同样要跟状态页数据打交道的人有帮助。状态页这东西很多普通用户一辈子都不会点开但做运维、做采购评估、做竞品监控的技术人迟早会跟它碰面。它本质上是一个软件的官方事故公栏服务出问题时上面会写发生了什么、影响范围多大、什么时候开始、什么时候解决。把这些历史事故归档采集下来做结构化分析可以回答很多有意思的问题哪家服务一个季度挂了多久、故障恢复速度怎么样、冠名严重停机事件多不多、是不是总在深夜发版本炸掉。本文的采集方案主要适合四类读者需要做供应商技术评估的技术负责人做服务监控平台的数据工程师写竞品分析报告的行业分析师以及所有想用脚本替代复制粘贴的Python入门者。整个采集过程我按侦察–准备–采集–清洗–入库–调度这条线走下来把每一步为什么这么做也讲清楚源码片段都是直接可用的你照着改个域名就能跑。1. 我为什么需要状态页事故归档需求场景与数据价值1.1 状态页和事故归档到底记录了什么软件状态页Status Page是服务商向外部公开的系统运行状况公示页面。正常情况下它显示各组件正常运行一旦出现网络抖动、数据库故障、发布回滚、第三方依赖挂掉等情况状态页上就会出现一条事故记录Incident。一条完整的事故归档记录通常包含以下几类信息事故标题一般一句话描述问题比如API服务响应延迟升高当前状态常见枚举值是Investigating、Identified、Monitoring、Resolved分别代表调查中、已定位、监控中、已解决影响范围一条事故会挂在某个组件下比如API、Web端、支付模块、存储服务影响级别大部分状态页按严重程度分成Major、Minor、Critical几个档位时间线包括事故创建时间、持续更新时间、解决时间更新记录每次运维人员同步进展都会生成一条短文本串联起来就是完整的处理日志。这些字段单独看没什么但聚合成历史数据集后价值立刻就出来了。比如计算月度可用性不能只看宣传的SLA数字真实事故归档才能还原某天系统是不是真的抖过再比如分析故障恢复时长只要拿resolved_at减去created_at就有结果再按月份分组一年内服务商处置效率的变化趋势一目了然。1.2 采集这些数据能解决哪些实际问题我最直接的场景是供应商评估。要跟一家SaaS服务商签合作协议对方PPT上写着99.99%可用性但状态页里三个月内Critical事故有三条这就是很好的谈判素材。另一个场景是技术选型对比同样做对象存储、做消息队列的几家厂商把一年的事故归档拉到同一个表里按加权公式打分比销售的口径客观得多。还有一类场景是内部系统联动。如果你已经在写监控脚本可以在状态页出现新事故时自动抓取并推送到企业微信群或者钉钉群这样即使自己的监控指标没报警也能第一时间知道云厂商或核心供应商正在出问题避免捧着工单瞎排查。讲回采集本身。这类页面的抓取难度其实不高但有两个难点需要正视一是目标页面形态差异大有的开放了公开JSON API有的只给HTML页面二是历史事故大多需要翻页直接请求首页只能拿到最近几条。接下来先讲侦察阶段怎么把这两件事摸清楚。2. 先侦察再动手摸清目标状态页的底层形态写爬虫最忌讳不看页面就直接写代码。我建议第一步先花20分钟把要采集的状态页打开按以下三个维度做侦察。侦察结果决定了后面整套代码怎么写。2.1 三大类状态页形态特征根据我观察到的现象市面上绝大多数软件状态页可以归为三类第一类第三方托管状态页典型代表是使用Statuspage、Instatus、BetterStack这类SaaS产品搭建的页面。它们的域名通常带有厂商特征比如xxxx.statuspage.io、status.xxx.com。这类页面的好消息是内部数据基本都有公开JSON接口甚至官方就提供了API文档字段结构非常规整。第二类自研状态页很多大厂不走第三方托管而是自己写页面。这类页面HTML结构五花八门但共性规律是事故列表通常放在一个带incident、event、history关键词的DOM容器里标题、时间、状态信息分布在固定class名的元素中。抓这类页面需要靠页面解析没法完全依赖JSON。第三类混合形态页面本身是自研的但背后悄悄调自己的JSON接口页面只是把接口数据渲染进去。这种最容易被忽略也可能最规整。怎么发现用开发者工具看Network面板就行。2.2 用浏览器开发者工具快速判断数据出口我给一个比较稳的侦察流程不用记太多照着走一遍就够打开目标状态页按F12进入开发者工具切到Network网络面板勾选Fetch/XHR过滤器按F5刷新页面观察网络请求列表里有没有返回JSON格式数据包的请求有的话点开看响应内容是不是事故列表数据是的话直接在浏览器地址栏访问这个接口地址看看裸数据质量如果Network面板里全是HTML文档请求没有XHR说明页面要么服务端渲染要么数据嵌在HTML里这类就要走HTML解析。另外还有一个更快的方法进入状态页的History历史事故页面在地址栏里尝试拼常见的API路径比如/api/v2/incidents.json、/api/incidents、/incidents.json。如果返回了JSON而不是404说明官方数据出口就摆在这。这个方法不是百分之百有效但值得先试毕竟能省很多解析成本。提示地址栏直接访问心形状态页API时不要开任何浏览器翻译插件某些代理类插件会改写响应头导致你看不见真实数据。侦察阶段的产出是一张简单的记录表目标名称、状态页URL、API URL如果有、事故列表所在DOM选择器如果需要解析HTML、分页参数格式。有了这张表写代码前心里就有底了。3. 采集前的工程准备环境与依赖先说环境。Python版本建议直接用3.10及以上太老的版本在某些类型标注和语法上不够友好。没有Python环境的读者去官网下载安装包安装时记得勾选Add Python to PATH不然后续在终端里敲python会提示找不到命令。3.1 基础依赖库说明这次采集用到的主要库不超过五个我按作用拆开说requests发送HTTP请求获取页面或接口数据。它是Python生态里最主流的HTTP库API简洁够用httpx如果追求连接复用和异步可以用它替代requests。本次代码以requests为主因为更通用beautifulsoup4解析HTML文档定位事故列表节点pandas做数据清洗和CSV落地处理字段缺失、时间格式转换都很方便APScheduler做定时增量采集任务调度进阶部分才用。依赖安装直接全部装到同一个虚拟环境里。创建虚拟环境的习惯建议从入门就养成避免后面不同项目之间的包版本互踩python -m venv statuspage_env source statuspage_env/bin/activate # Windows下用 statuspage_env\Scripts\activate pip install requests beautifulsoup4 pandas apscheduler3.2 请求会话Session的用法与理由写采集脚本有一个非常容易被忽略的细节不要一上来就用requests.get裸请求多次建议先构建一个requests.Session()会话对象。原因有两个第一Session默认启用连接池会复用底层的TCP连接对同一域名的多次请求速度明显提升第二Session可以在实例上统一设置请求头、超时时间、重试策略代码维护起来干净很多。我习惯在工程目录下用一个config.py放公共配置项包括目标URL、请求头、请求间隔、输出文件路径等。这样不同脚本共享配置时不用到处改硬编码值。下面是一份精简的配置示例# config.py BASE_URL https://status.example.com API_URL https://status.example.com/api/v2/incidents.json OUTPUT_DIR ./data HEADERS { User-Agent: Mozilla/5.0 (compatible; IncidentCollector/1.0; https://example.com/bot) } REQUEST_TIMEOUT 15 RETRY_TIMES 3 RETRY_BACKOFF 2 # 重试间隔基数单位秒 REQUEST_INTERVAL 1.5 # 每次请求之间的最小间隔这里User-Agent我写了一个带标识的字符串不要完全照搬浏览器的UA去伪装。主动标明自己是采集脚本配合合理的访问频率反而更不容易被源站风控误伤。这里面有一个尺度的把握采集频率过高、伪装得太激进更容易触发封禁。4. 核心采集实现从API到HTML兜底的完整代码工程准备做完就可以进入核心代码部分了。为了兼顾两类状态页形态我把采集逻辑拆成两层优先走公开JSON APIAPI没找到或返回空数据时自动降级到HTML解析。这样实际执行时容错性很强。4.1 第一版直接抓官方JSON接口如果侦察阶段发现了JSON接口最省事的方式就是直接请求它。以Statuspage风格接口为例请求/api/v2/incidents.json会返回一个包含事故列表的JSON对象。一个典型的响应结构长这样字段名在不同版本略有差异但大同小异{ page: {id: xxx, name: Example Status}, incidents: [ { id: abc123, name: API服务延迟升高, status: resolved, impact: major, created_at: 2024-06-12T02:13:00Z, updated_at: 2024-06-12T05:42:00Z, resolved_at: 2024-06-12T05:42:00Z, incident_updates: [ {body: 我们正在调查API延迟升高问题, updated_at: 2024-06-12T02:13:00Z} ] } ] }第一版采集脚本可以直接这样写import requests import json import time from config import API_URL, HEADERS, REQUEST_TIMEOUT, REQUEST_INTERVAL def fetch_incidents_from_api(api_url): 尝试从JSON API获取事故列表 incidents [] params {page: 1, per_page: 100} with requests.Session() as session: while True: resp session.get(api_url, headersHEADERS, paramsparams, timeoutREQUEST_TIMEOUT) resp.raise_for_status() data resp.json() items data.get(incidents, []) if not items: break incidents.extend(items) # 如果当前页返回数量少于每页上限说明已经到最后一页 if len(items) params[per_page]: break params[page] 1 time.sleep(REQUEST_INTERVAL) return incidents这里有几个细节要特别强调分页判断我使用了当前页返回数量少于请求的每页数量就结束这个逻辑而不是依赖源站返回的page_count或者total字段原因是不同状态页API对这些字段的定义不一致靠数量判断最通用。per_page设成100配合分页请求既保证单次数据量够大又不至于把源站压太狠。每次翻页后强制间隔1.5秒这是对源站的基本尊重也避免自己瞬间打一串请求被边缘风控。一个常见的坑是某些API接口虽然返回了JSON但只返回最近的20条或50条更早的事故要换历史接口或者参数。遇到这种情况我的建议是结合HTML历史页做深度翻页兜底也就是接下来讲的方案B。4.2 第二版HTML解析作为兜底当页面没有JSON接口或者JSON接口只能拿到最近数据时就需要回到HTML本身。以自研状态页为例历史事故通常是一个列表结构每条事故可能长这样div classincident-item div classincident-titleAPI服务延迟升高/div div classincident-statusresolved/div div classincident-date2024-06-12 02:13:00 UTC/div div classincident-update我们正在调查API延迟升高问题/div /div用BeautifulSoup解析这种结构很直接from bs4 import BeautifulSoup import requests from config import BASE_URL, HEADERS, REQUEST_TIMEOUT def parse_incident_from_html(html): soup BeautifulSoup(html, html.parser) rows soup.select(.incident-item) parsed [] for row in rows: title row.select_one(.incident-title) status row.select_one(.incident-status) date row.select_one(.incident-date) update row.select_one(.incident-update) parsed.append({ name: title.text.strip() if title else , status: status.text.strip() if status else , created_at: date.text.strip() if date else , update: update.text.strip() if update else }) return parsed def fetch_incidents_from_html(history_url): all_incidents [] for page in range(1, 50): # 先限制50页实际使用按分页逻辑break resp requests.get( f{history_url}?page{page}, headersHEADERS, timeoutREQUEST_TIMEOUT ) if resp.status_code ! 200: break items parse_incident_from_html(resp.text) if not items: break all_incidents.extend(items) return all_incidents这里选择器必须根据实际页面调整我给的是通用示例。实际操作中我建议先用开发者工具把某一条事故记录的HTML结构完整展开确认标题、状态、时间分别落在哪个节点上再写选择器。避免凭感觉写.incident-xxx这种class名不同站点命名风格千差万别很容易扑空。判断HTML页面还有没有下一页除了看当前页有没有数据还可以观察历史页面底部的分页控件。常见的分页URL形态有三种?page2、/?page2、/page/2。分别适配即可。4.3 把两类来源统一成标准数据结构无论从JSON还是HTML采集最终落到本地分析时我都建议统一成同一个Python字典结构字段名固定后续清洗时就不用写两套逻辑。我常用的标准结构如下normalized { incident_id: ..., title: ..., component: ..., impact_level: ..., status: ..., created_at: ..., updated_at: ..., resolved_at: ..., updates_text: ... }把两类数据源映射到同一结构的方法不复杂写个normalize_incident()函数就行def normalize_incident(raw): return { incident_id: raw.get(id, raw.get(incident_id, )), title: raw.get(name, raw.get(title, )), status: raw.get(status, ), impact_level: raw.get(impact, ), created_at: raw.get(created_at, ), updated_at: raw.get(updated_at, ), resolved_at: raw.get(resolved_at, ), updates_text: || .join( u.get(body, ) for u in raw.get(incident_updates, []) ) }为什么要做这层统一因为后面数据分析和入库都要基于稳定字段如果你今天先抓Statuspage风格的接口明天换一个自研页面字段名不一样清洗代码就得重写。多做一次归一化后面省大力气。5. 数据落地CSV入库与SQLite双方案采集到的数据如果只是print到屏幕上那跟没采一样。我习惯把标准化之后的数据立刻落到本地持久化这里给出两个主流方案按数据量和下游用途二选一。5.1 数据量不大时用pandas写CSV如果只是结合几十家服务商、一年几百条事故CSV足够好。好处是可以用Excel直接打开丢给任何人去筛选排序都没有障碍。用pandas写CSV代码非常短import pandas as pd from config import OUTPUT_DIR import os def save_to_csv(incidents, filenameincidents.csv): df pd.DataFrame(incidents) os.makedirs(OUTPUT_DIR, exist_okTrue) filepath os.path.join(OUTPUT_DIR, filename) df.to_csv(filepath, indexFalse, encodingutf-8-sig) print(f[INFO] saved {len(df)} rows to {filepath}) return filepath这里encodingutf-8-sig值得单独说一句。不加-sig直接用utf-8生成的文件在Windows上用Excel打开会发现中文全部乱码加了个BOM头之后Excel就能正确识别。这个细节看起来不起眼但确实是我踩过坑之后才记住的。5.2 需要做历史增量时用SQLite当采集的源站不止一个而且你需要每天定时增量采集、和昨天的数据做去重比对时SQLite明显更合适。SQLite是单文件数据库不需要安装额外的数据库服务Python标准库直接支持非常适合爬虫数据落地。建表结构如下import sqlite3 def init_db(db_pathstatuspage.db): conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS incidents ( incident_id TEXT PRIMARY KEY, title TEXT, status TEXT, impact_level TEXT, created_at TEXT, updated_at TEXT, resolved_at TEXT, updates_text TEXT, fetched_at TEXT ) ) conn.commit() conn.close()把incident_id设为主键是同一条事故不会重复入库的关键。每次采集执行INSERT OR REPLACE如果同一条事故已经存在就用新数据覆盖如果不存在就插入新行。这样可以非常自然地完成增量更新不需要额外写大量比对逻辑。写入函数def save_to_sqlite(incidents, db_pathstatuspage.db): conn sqlite3.connect(db_path) for item in incidents: conn.execute( INSERT OR REPLACE INTO incidents (incident_id, title, status, impact_level, created_at, updated_at, resolved_at, updates_text, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) , ( item.get(incident_id, ), item.get(title, ), item.get(status, ), item.get(impact_level, ), item.get(created_at, ), item.get(updated_at, ), item.get(resolved_at, ), item.get(updates_text, ), item.get(fetched_at, ) )) conn.commit() conn.close() print(f[INFO] upserted {len(incidents)} rows into {db_path})这个结构下数据后续做查询分析非常顺手。比如要按月份统计事故数量一行SQL就能跑出来SELECT strftime(%Y-%m, created_at) AS month, COUNT(*) AS cnt FROM incidents GROUP BY month ORDER BY month;提示如果你采集的目标状态页提供了完整的历史归档接口记得把事故归档里每一轮incident_update也单独存一张表用incident_id关联。顺畅了以后做故障复盘文本分析时这段文字记录比结构化字段更有价值。6. 踩坑实录请求403、KeyError与时区乱码我这套脚本跑通之后又陆续遇到过几个问题花了些时间才排查完。这几个坑几乎可以覆盖大部分状态页采集中的异常场景写下来供参考。6.1 部分状态页会对高频请求返回403有段时间因为测试分页逻辑失误脚本对同一域名以每秒3-4次的频率连打了十几分钟之后所有请求都返回403 Forbidden。第一反应以为是对方封了我的IP后来把请求频率降到每1.5秒一次并重新设置了带爬虫标识的User-Agent等了几分钟再跑恢复正常。这个案例的教训有两层。第一采集节奏一开始就该规划好不要在高频试探之后才考虑礼貌第二遇到403不要立刻想怎么伪装绕过先想自己有没有违反基本的访问礼节。对公开状态页来说正常频率下是不会轻易封正常访问的。遇到偶发403时我给代码加了一个简单的退避重试机制重试间隔逐步拉长import time from config import RETRY_TIMES, RETRY_BACKOFF def get_with_retry(session, url, headersHEADERS, timeout15, retriesRETRY_TIMES): for attempt in range(retries): try: resp session.get(url, headersheaders, timeouttimeout) if resp.status_code 200: return resp elif resp.status_code in (403, 429): wait_time RETRY_BACKOFF * (2 ** attempt) print(f[WARN] status {resp.status_code}, retry in {wait_time}s) time.sleep(wait_time) else: resp.raise_for_status() except requests.exceptions.Timeout: continue return None6.2 不同源站的字段缺失导致的KeyError状态页API虽然字段大体相似但经常出现个别字段缺失。比如有些状态页没有resolved_at因为解决时间还没定再比如某些自研页面会把组件名称嵌在标题里而不是单独字段。如果你写得比较偷懒直接raw[resolved_at]就会抛KeyError中断整个任务。解决办法就是我在归一化函数里用raw.get(resolved_at, )代替下标取值并在后续处理中把空字符串当作暂无数据处理。这类防御式编码在爬虫场景里非常值得养成习惯——外站数据永远不会规规矩矩地服从你的预期。6.3 事故时间全是UTC本地统计分析非常别扭状态页API返回的时间几乎都是ISO 8601格式的UTC时间比如2024-06-12T02:13:00Z。直接用这个字符串做时间排序没问题但要做按本地时区聚合统计时就得转换。我的做法是清洗阶段统一转成东八区时间并打算保留原始UTC字段。具体转换用Python标准库from datetime import datetime, timezone, timedelta def convert_utc_to_cst(utc_str): if not utc_str: return try: dt_utc datetime.fromisoformat(utc_str.replace(Z, 00:00)) dt_cst dt_utc timedelta(hours8) return dt_cst.strftime(%Y-%m-%d %H:%M:%S) except ValueError: return utc_str这里有个需要注意的点datetime.fromisoformat在某些老版本的Python里对Z结尾的解析支持不完整所以先把Z替换成00:00再解析兼容性好很多。7. 从跑通到长期可用定时调度与增量采集一次性把历史事故归档采集完只是第一步。状态页每天都在更新几天之后就可能出现新事故。要让这套采集真正长期发挥作用需要让它自动定时跑、自动去重入库、并能提醒你有新事故出现。7.1 用APScheduler做定时任务APScheduler是Python生态里比较成熟的定时调度库。我们可以在采集脚本外层包一层调度逻辑每天早上8点自动执行一次采集from apscheduler.schedulers.blocking import BlockingScheduler import datetime def job(): incidents fetch_incidents_from_api(API_URL) if incidents: normalized [normalize_incident(x) for x in incidents] save_to_sqlite(normalized) print(f[INFO] {datetime.datetime.now()} job done, got {len(incidents)} incidents) def main(): scheduler BlockingScheduler() scheduler.add_job(job, cron, hour8, minute0) scheduler.start() if __name__ __main__: main()如果你不想长期开着一个终端可以直接用系统自带的任务计划程序。比如Linux下用crontabWindows下用任务计划程序效果一样看个人习惯。7.2 新的事故归档怎么主动感知定时采集解决了数据更新的问题但你不想每次自己跑到数据库里查。我建议在采集函数里对比一下当天新增的行数如果超过0就触发一个推送通知。推送方式可以根据团队工具选企业微信机器人最简单带上key之后POST一段文本就行Server酱、钉钉机器人同理。下面给一个用requests推送企业微信机器人的示例def push_wecom(text, webhook_key): webhook_url fhttps://qyapi.weixin.qq.com/cgi-bin/webhook/send?key{webhook_key} payload {msgtype: text, text: {content: text}} requests.post(webhook_url, jsonpayload, timeout10)配合采集函数改造一下就能做到每天自动抓发现新事故自动提示。实际运行了一段时间后你会发现这类自动推送比人工盯状态页可靠得多毕竟人不可能24小时刷新页面。7.3 关于采集频率、合规与度的把握最后聊一下边界意识。状态页是公开信息采集公开的事故存档用于统计分析是合理的但合理不意味着可以无限索取。我自己在长期运行这套采集方案时一直坚持三个原则请求频率克制定单目标站点请求间隔不低于1.5秒不设高并发只采集公开可见的数据不尝试绕开登录、验证码、访问限制尊重目标网站的robots.txt如果里面明确了不允许采集的路径就主动避开。遵守这套规则并不会损失太多数据反而能保证采集任务长期稳定运行。因为一旦因为高频请求被源站封禁你这套自动化系统就彻底白搭了修复成本远高于老老实实放慢速度。关于后续还能怎么扩展我说一个自己的思路采集到多家服务商的事故归档后可以按component维度把影响范围标准化汇总出一张服务商故障影响矩阵。再进一步把各家的created_at和resolved_at做差值就能得到每个故障的处理时长中位数这是一份很有参考意义的稳定性横向对比报告。当然这只是这套数据的一个用途具体怎么分析完全看你手头的业务问题长什么样。
返回列表