飞机失事历史数据抓取实战:从公开事故档案到可分析数据集
简介CrashData 是一份面向数据分析初学者与航空安全研究爱好者的 R 语言数据采集脚本资源用于从 planecrashinfo.com 逐年抓取飞机失事的历史记录解决手动整理事故数据繁琐、格式不统一的问题。压缩包内共 2 个文件包含 1 个 R 脚本与 1 个 md 说明文档整体体积约 1KB轻量易读脚本运行后会直接转储为 data.frame方便后续做趋势统计、可视化或建模分析。该资源已有 1422 人学习下载说明其在数据获取与爬虫练习场景中具有一定参考价值。读者可借此了解如何针对结构不规则的网页逐年提取字段、处理缺失与格式差异并快速获得一份可直接进入分析环节的事故数据集适合作为 R 语言网页抓取与数据清洗的练手案例。1. 飞机失事历史数据抓取从公开事故档案到可分析数据集做航空安全分析、事故率建模或者纯粹想拿真实世界数据练手的人迟早会碰到一个尴尬公开的事故记录散落在各种页面里格式不统一字段残缺想拿来做统计分析得先花大量时间清洗。CrashData 这个方向要解决的就是这件事——从公开的飞机失事历史数据页面把散落的事故记录结构化地抓下来整理成能直接喂给 pandas 或入库的格式。它适合三类人做数据工程练手的开发者、需要事故数据做可视化或建模的分析者、以及想研究航空安全趋势的研究人员。核心难点不在爬虫本身而在于页面结构不规整、字段边界模糊、历史数据存在缺失和矛盾。这篇笔记按「先搞清楚数据长什么样再动手抓最后处理坑」的顺序展开把每一步的参数和判断依据都写清楚。2. 先看懂数据源事故记录页面的结构长什么样2.1 页面组织方式与字段分布这类历史事故数据页面通常按年份或年代组织每个年份一个独立页面页面内以表格或列表形式罗列当年的事故条目。每条记录一般包含日期、机型、运营方、地点、死亡人数、事故简述等字段。但要注意不同年份的页面结构可能不一致——早期年份的记录往往更简略字段缺失严重甚至只有一句话描述较晚的年份字段更完整有时还会附带事故原因分类。我一般会先手动打开几个不同年代的页面对比字段的完整度和排列顺序。具体做法是取 1950 年代、1980 年代、2010 年代各一个页面把每条记录的文本结构抄下来对比。你会发现日期格式在不同年代可能不同有的用「Month Day, Year」有的只有年份死亡人数有时写在描述里而不是独立字段。这个前置观察决定了后面解析器的设计——不能假设所有页面用同一套规则。字段的语义边界也需要提前定义清楚。比如「死亡人数」这个字段有的页面区分机组和乘客有的只给总数有的把地面伤亡也算进去。如果不提前定义好你要抓的是哪个口径后面聚合统计时会出现同一事故被重复计数或者漏计的情况。我的做法是在解析前先列一张字段映射表把页面上的原始文本和你要输出的标准字段一一对应遇到歧义就标记为待定不要强行猜。2.2 确定抓取范围和输出格式抓取范围不是越大越好。全量抓取听起来很爽但如果你的分析目标只是近三十年的事故率趋势抓 1920 年代的数据既增加了解析负担又因为早期记录质量差而引入噪声。我一般会先明确分析目标再倒推需要哪些年份。如果目标是做全量数据集那就分年代批次抓每批单独验证。输出格式上CSV 是最省事的但事故描述里经常有逗号和换行直接写 CSV 容易串列。我的习惯是先输出成 JSON Lines每行一个 JSON 对象清洗完再转 CSV 或入库。JSON Lines 的好处是每条记录独立解析失败时不会影响其他记录排查起来也方便。字段命名用英文小写下划线比如date、aircraft_type、operator、location、fatalities、summary避免中文键名在不同系统间传递时出编码问题。提示在正式写爬虫之前先用浏览器开发者工具看一下页面的请求方式。如果是纯静态 HTML直接用 requests BeautifulSoup 就够了如果有动态加载才需要考虑其他方案。不要一上来就上重型工具。3. 动手抓取从请求到解析的完整实现3.1 请求层控制频率与重试请求层看着简单但这是最容易翻车的地方。公开数据页面通常没有反爬机制但如果你并发太高或者请求间隔太短对方服务器可能直接拒绝或者返回不完整内容。我的做法是单线程加固定间隔间隔设在 1 到 2 秒之间具体看页面响应速度。如果页面数量多可以加一个简单的重试机制失败后等 5 秒再试最多重试 3 次。import requests import time import json import os HEADERS { User-Agent: Mozilla/5.0 (compatible; DataResearchBot/1.0) } def fetch_page(url, retries3, delay2): 抓取单个页面带重试和间隔控制。 url: 目标页面地址 retries: 最大重试次数 delay: 每次请求后的等待秒数 for attempt in range(retries): try: resp requests.get(url, headersHEADERS, timeout15) if resp.status_code 200: return resp.text else: print(f状态码 {resp.status_code}第 {attempt1} 次尝试) except requests.RequestException as e: print(f请求异常: {e}第 {attempt1} 次尝试) time.sleep(delay * (attempt 1)) # 退避等待 return None这段代码的关键参数是timeout和退避策略。timeout15是防止某个请求卡死拖垮整个流程退避等待用delay * (attempt 1)而不是固定值是为了在连续失败时给服务器更多恢复时间。HEADERS里带一个说明用途的 User-Agent比伪装成浏览器更稳妥也符合公开数据抓取的基本礼貌。3.2 解析层用容错策略处理不规整记录解析层是整个流程里最需要耐心的部分。因为页面结构不统一不能用一个固定的选择器抓所有年份。我的策略是分层解析先定位到记录块再从记录块里逐字段提取每个字段提取失败时返回空值而不是抛异常。from bs4 import BeautifulSoup import re def parse_record(block_text): 从一条事故记录的文本块中提取字段。 返回字典缺失字段用 None 填充。 record { date: None, aircraft_type: None, operator: None, location: None, fatalities: None, summary: block_text.strip() } # 尝试提取日期匹配 Month Day, Year 或 Year 格式 date_match re.search( r(\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s\d{1,2},\s\d{4}\b|\b\d{4}\b), block_text ) if date_match: record[date] date_match.group(1) # 尝试提取死亡人数匹配 killed 或 fatalities 附近的数字 fat_match re.search(r(\d)\s*(?:people\s)?(?:killed|fatalities|deaths), block_text, re.I) if fat_match: record[fatalities] int(fat_match.group(1)) return record这里用正则而不是固定 CSS 选择器是因为文本块内部的字段顺序和标签结构在不同年份差异太大。正则的容错性更好匹配不到就返回 None不会中断流程。summary字段直接保留原始文本方便后续人工核对。死亡人数的正则里加了re.I忽略大小写因为页面里可能用Killed也可能用killed。3.3 存储层JSON Lines 落盘与断点续抓存储层要考虑两件事一是写入时不丢数据二是中断后能续抓。JSON Lines 天然适合这个场景每条记录一行追加写入不需要读取整个文件再重写。def save_records(records, output_path): 将记录追加写入 JSON Lines 文件。 records: 字典列表 output_path: 输出文件路径 with open(output_path, a, encodingutf-8) as f: for rec in records: f.write(json.dumps(rec, ensure_asciiFalse) \n) def load_done_years(progress_path): 读取已完成的年份列表用于断点续抓。 if os.path.exists(progress_path): with open(progress_path, r, encodingutf-8) as f: return set(json.load(f)) return set() def mark_year_done(year, progress_path): done load_done_years(progress_path) done.add(year) with open(progress_path, w, encodingutf-8) as f: json.dump(sorted(done), f)ensure_asciiFalse是为了让中文和特殊字符正常写入不转成 Unicode 转义。进度文件单独存一个年份集合每次抓完一年就更新下次启动时先读进度文件跳过已完成的年份。这个做法比在数据文件里检查重复要快得多也避免了重复抓取对服务器的压力。4. 数据清洗把原始记录变成可分析字段4.1 日期标准化与缺失值处理抓下来的日期字段格式五花八门有「June 12, 1985」这种完整格式也有只有「1985」的。做时间序列分析时只有年份的记录不能直接丢掉但也不能随便补一个月份。我的处理方式是拆成两个字段year必填month和day允许为空。这样在按年聚合时不会丢数据做月度分析时再过滤掉缺失月份的记录。from datetime import datetime def normalize_date(raw_date): 将原始日期文本标准化为 year/month/day 三个字段。 无法解析时 month 和 day 返回 None。 result {year: None, month: None, day: None} if not raw_date: return result # 尝试完整日期格式 for fmt in (%B %d, %Y, %b %d, %Y): try: dt datetime.strptime(raw_date, fmt) result[year] dt.year result[month] dt.month result[day] dt.day return result except ValueError: continue # 回退到只提取年份 year_match re.search(r\b(\d{4})\b, raw_date) if year_match: result[year] int(year_match.group(1)) return result%B匹配完整月份名%b匹配缩写。两个格式都试一遍是因为页面里两种写法都可能出现。回退逻辑只提取四位年份不猜月份避免引入错误信息。4.2 死亡人数口径统一与异常值标记死亡人数是最容易出问题的字段。有的记录写的是「all 128 aboard killed」有的写「128 killed」还有的写「128 fatalities including 3 on ground」。如果不区分口径直接取数字地面伤亡就会被混入机上伤亡。我的做法是在解析时保留原始描述清洗时单独标记口径。原始描述模式口径判断处理方式aboard / on board机上人员直接采用including X on ground含地面人员标记includes_groundTrueground / on the ground仅地面人员单独字段ground_fatalities无明确说明未知口径标记scope_unknownTrue异常值方面死亡人数超过 500 的记录需要人工复核因为历史上单次事故死亡人数超过这个数的极少可能是解析时把年份或其他数字误抓了。标记出来人工看一眼比自动过滤掉更安全。5. 避坑指南抓取历史事故数据时最容易翻车的五件事现象抓了几百条记录发现同一事故出现了两次。原因页面在改版或迁移时同一条记录可能同时存在于新旧两个页面或者年份页面里包含了跨年事故的重复条目。解决用「日期 机型 运营方」组合做去重键不要只用日期因为同一天可能有多起事故。现象死亡人数抓出来是 0但描述里明明写了有伤亡。原因正则匹配的是「数字 killed」的模式但页面可能写成「killed: 128」或者「128 persons killed」数字和关键词的顺序不固定。解决把正则改成双向匹配同时匹配「数字在前」和「数字在后」两种模式并且把killed、fatalities、deaths、died都纳入关键词。现象某些年份的页面抓下来是空的。原因页面 URL 规则在不同年代不一致有的年份用/year/1985有的用/1985.html。解决不要硬编码 URL 规则先抓取年份索引页从索引页里提取每个年份的实际链接再逐个抓取。现象JSON Lines 文件里出现乱码。原因写入时没有指定encodingutf-8或者页面本身的编码不是 UTF-8。解决请求返回后先检查resp.encoding如果和resp.apparent_encoding不一致手动设置成后者再取resp.text。现象抓取到一半程序崩了重启后从头开始。原因没有做断点续抓进度没有持久化。解决每完成一个年份就写一次进度文件启动时先读进度文件跳过已完成年份。进度文件用 JSON 存年份列表简单可靠。注意公开数据页面虽然通常没有严格的反爬措施但请求频率过高仍然可能导致 IP 被临时限制。间隔控制在 1 到 2 秒单次抓取量控制在几百个页面以内是比较稳妥的范围。6. 进阶技巧用校验规则保证数据集可信度数据集抓完不是终点能不能用才是关键。我一般会在清洗完之后跑一轮校验用几条硬规则筛出可疑记录。第一条规则是年份范围校验如果year小于 1900 或者大于当前年份直接标记为异常。第二条是死亡人数与描述的一致性校验如果fatalities大于 0 但summary里没有任何伤亡相关关键词标记为待复核。第三条是日期完整性校验如果month和day都有值但组合出来的日期在日历上不存在比如 2 月 30 日说明解析出错。def validate_record(rec): 对单条记录做基础校验返回问题列表。 issues [] current_year datetime.now().year if rec.get(year) and (rec[year] 1900 or rec[year] current_year): issues.append(年份超出合理范围) if rec.get(fatalities) and rec[fatalities] 0: summary (rec.get(summary) or ).lower() keywords [killed, fatalities, deaths, died, crash] if not any(kw in summary for kw in keywords): issues.append(死亡人数与描述不一致) if rec.get(month) and rec.get(day): try: datetime(rec[year], rec[month], rec[day]) except (ValueError, TypeError): issues.append(日期组合无效) return issues校验结果不直接删记录而是输出到一个单独的问题文件里人工过一遍再决定是修正还是丢弃。这样做的好处是保留了原始数据的可追溯性万一后面发现校验规则太严还能把误判的记录捞回来。另一个实用技巧是给数据集加一个source_url字段记录每条数据来自哪个页面。这样在后续分析中发现异常值时能快速定位到原始页面核对。这个字段在抓取阶段就写入不要等到清洗时再补因为清洗后记录和页面的对应关系可能已经断了。我自己的习惯是每抓完一个年代批次就随机抽 10 条记录手动和原始页面对一遍确认字段没有系统性偏移。这个动作花不了多少时间但能提前发现解析规则里的盲区。数据质量这件事事后补救的成本远高于抓取时多看一眼。希望帮到你。本文还有配套的精品资源点击获取