
2026年给自己定了一个小目标用Python把几千条历史事件整理成一条干净的时间线。起因很朴素网上的历史资料太零散了今天刷到一项发明明天看到一部作品很难从整体上感受“历史的长河”到底是怎么流过来的。干脆自己写个爬虫把公开的历史事件数据抓下来清洗成结构化数据再按时间轴排好做成一个随时能查、能筛选、能画图的个人历史数据库。这篇文章就是整个项目的实战复盘覆盖了爬虫设计、页面解析、数据清洗、存储和可视化代码量不多但每个环节都踩到过坑。如果你有Python基础想练练爬虫和数据处理或者打算做类似的知识库、时间线、日历类工具这篇可以直接当作业抄。1. 项目整体设计为什么我要做一条历史时间线1.1 核心需求从“查历史”到“拥有自己的历史数据集”很多人手机上都有“历史上的今天”这类小工具但看多了你会发现一个问题它只给你当天的碎片信息没有连续性。今天看的是科学发明明天推的是娱乐八卦你不知道这些事在时间轴里处于什么位置也无法按类别检索。我想做的事情其实是一套完整的个人历史索引——把公元前后、各类领域的重要事件全部抓下来清洗后落到数据库里之后无论想查“18世纪出现过哪些重要发明”还是想看“某位科学家的人生轨迹”都能一条SQL跑出来。项目的产出不是“爬了几个页面”而是一个字段规范、日期标准、可增量更新的历史事件数据集。这就意味着代码只是最外层的东西真正的核心在字段设计和数据清洗。很多爬虫教程喜欢展示“一口气抓完一个网站”的快感但实际工作中数据质量比抓取速度重要得多。这个项目我宁可慢一点也要保证每条事件的时间、标题、分类、来源都能追根溯源。另外一点很关键采集边界要自己划清楚。这个项目我只采集科技、文化、艺术、科学史和公共生活中性较强的事件不碰有争议的历史话题也不碰政治敏感内容。爬虫本来就是公开数据整理没必要也不会去越界。1.2 技术选型轻量组合也能干活技术栈选得好不好直接决定开发节奏。这个项目我用的组合是requests BeautifulSoup4 lxml pandas SQLite可视化这边用的是pyecharts。选这套组合的原因很实际requests足够轻发请求、带请求头、处理超时和重试都很顺手。BeautifulSoup4加lxml解析HTML非常成熟代码可读性高适合快速迭代。pandas用来做数据清洗月底统计、去重、标准化日期这些事情它一网打尽。SQLite不需要额外部署服务一个文件就是一个库个人项目完全够用后续想迁移到MySQL也容易。有人会问这种场景不是应该直接上Scrapy吗我的看法是如果爬的是几十个站点、几百万条数据Scrapy的调度、中间件、管道确实香。但如果目标只是“抓一个站做一条干净的时间线”用Scrapy反而给自己增加工作量。框架的学习成本、调试成本、部署成本全部都会摊到项目进度里。工具不是越重越好越顺手越好。selenium这种浏览器自动化工具我也刻意没有用。目标站点如果是静态页面直接请求再解析就行没必要无谓消耗浏览器资源。如果页面真的涉及动态渲染我会先看有没有数据接口实在找不到接口再考虑上带渲染的工具这个思路后面详聊。1.3 数据源与字段规划先定好规矩再动手数据源选型我比较谨慎。优先选的是公开的科普类“历史事件”聚合站、人物传记站、科技史站点还有维基百科这类社区百科的“On This Day”内容。选择标准有三条一是内容相对中立来源清晰二是页面结构稳定便于用CSS选择器定位三是响应速度快不需要登录就能访问。动手写爬虫前我先把目标数据字段列了出来类似建表设计。这一步极其重要后续所有代码都是围绕这张表来写的。字段名类型示例说明event_dateTEXT1847-01-11标准化的公历日期清洗后的格式display_dateTEXT1847年1月11日保留原始写法方便展示titleTEXT约翰·史蒂文森出生事件短标题categoryTEXT科学/人物事件类别personTEXT约翰·史蒂文森关联人物locationTEXT美国发生地点或国家descriptionTEXT英国工程师……事件简述尽量控制长度source_urlTEXThttps://...来源链接用来复核crawled_atTEXT2026-02-10 20:00:00采集时间用于增量更新字段设计阶段容易犯的错是想得太少。比如日期看起来很简单但真实网页里会有“公元前221年”“约1480年”“19世纪40年代”这些模糊写法甚至还有农历。如果一开始只设计一个“日期”字段后面清洗会非常痛苦。所以我加了一个display_date字段专门保存原文真正用来排序和检索的是清洗后的event_date。这个“原始值保留 清洗值计算”的设计后面帮了我大忙。2. 核心细节解析请求策略与页面结构拆解2.1 请求策略怎么像一个正常用户很多新手爬虫被网站拦下来问题往往不在IP而在请求头。直接用requests.get(url)去访问服务器看到的是类似“Python-urllib/3.11”这样的标识一眼就知道是程序。所以我的原则是把请求伪装成浏览器的正常访问。具体做法是设置完整的请求头重点是User-Agent和Accept-Language。前者告诉网站“我是Chrome浏览器”后者告诉网站“我需要中文内容”。一段常用的请求头长这样HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, }除了请求头访问频率也要控制。爬虫写得好不好不看速度看稳不稳。我给自己定的规则是相邻两次请求间隔至少1秒顺手加一点随机抖动。比如time.sleep(random.uniform(1, 2))这样既不会给目标服务器造成压力也能降低被识别为自动访问的概率。超时和重试同样不能省。网络环境再稳也有偶发超时的可能。我会设置timeout10并且对连接失败或返回5xx的情况做最多3次重试每次重试间隔加倍。这个逻辑写成函数之后整个项目都极其稳定基本不需要人盯着。2.2 页面结构定位用开发者工具快速找到“数据位置”写解析代码之前必须先搞清楚目标页面长什么样。我的常规操作是打开浏览器按F12进入开发者工具然后用“Elements”面板去定位数据所在的HTML节点。以我爬过的某个历史事件聚合页为例页面上每天的事件列表是这样一个结构这里做成了简化示意方便复用思路ul classevent-list li classevent-item span classdate1847年1月11日/span a classtitle href/event/1024约翰·史蒂文森出生/a span classcategory科技/span /li ... /ul定位的核心就是“类名”。比较推荐先选择外层容器.event-list再在容器内部循环提取每个.event-item。这样的好处是即使整个列表区域发生变化最多只影响外层选择器内部解析逻辑还能复用。另一个实用技巧是先在开发者工具里用document.querySelectorAll(.event-item)验证选择器是否正确。如果能在浏览器控制台选出对应元素那用 BeautifulSoup 也能选出来两边语法虽然不同但选择器的核心表达式是一致的。这个前置验证能省掉很多来回调试时间。2.3 数据解析与清洗规则HTML到结构化的“翻译官”页面抓下来之后剩下的事情就是把HTML“翻译”成结构化的Python字典。我用 BeautifulSoup 做解析典型的解析代码是这样from bs4 import BeautifulSoup def parse_events(html_text: str) - list[dict]: soup BeautifulSoup(html_text, lxml) events [] for item in soup.select(.event-item): date_raw item.select_one(.date).get_text(stripTrue) title item.select_one(.title).get_text(stripTrue) href item.select_one(.title).get(href) category item.select_one(.category).get_text(stripTrue) events.append({ display_date: date_raw, title: title, category: category, source_url: href, }) return events这里有几个容易被坑的点。第一get_text()默认会保留空格和换行一定要加上stripTrue把首尾空白去掉。第二如果某个元素不存在select_one会返回None此时再调用get_text()就会报错。所以稳妥的做法是先判断title_node item.select_one(.title) title title_node.get_text(stripTrue) if title_node else 这也是我实际代码里常用的写法宁可字段为空也不能让程序中断。日期清洗是另一个大工程。网页里的日期格式五花八门有“1847年1月11日”“Jan 11, 1847”“公元前221年”还有“19世纪40年代”。我的策略是先用正则按顺序提取先处理“公元前”再处理标准年月日最后处理年份模糊的情况。比如import re def standardize_date(text: str) - str: if 公元前 in text: match re.search(r公元前(\d)年, text) if match: return f-{int(match.group(1)):04d}-01-01 match re.search(r(\d{4})年(\d{1,2})月(\d{1,2})日, text) if match: y, m, d map(int, match.groups()) return f{y:04d}-{m:02d}-{d:02d} match re.search(r(\d{4})年(\d{1,2})月, text) if match: y, m map(int, match.groups()) return f{y:04d}-{m:02d}-01 match re.search(r(\d{4})年, text) if match: return f{int(match.group(1)):04d}-01-01 return None“公元前”我处理成负数年份比如-0221-01-01这样在排序时能自然排在最前面SQLite也能直接按字符串排序。模糊年份统一补成“01-01”后面展示时用display_date保留原文即可。这里的原则是排序归排序展示归展示两条路不混淆。3. 实操过程写代码把历史大事件捡起来3.1 环境准备与工程结构先准备好运行环境。我用Python 3.10以上版本新建虚拟环境后安装依赖python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install requests beautifulsoup4 lxml pandas pyecharts工程结构我喜欢按职责拆分不要把所有代码堆在一个文件里。这次的项目长这样history_timeline/ |-- spider.py # 负责发送请求、解析页面 |-- cleaner.py # 负责日期标准化、去重、分类映射 |-- storage.py # 负责写CSV、写SQLite |-- visualize.py # 负责生成时间线可视化图表 |-- config.py # 存放请求头、目标URL、字段配置 |-- data/ # 存放SQLite数据库和CSV文件文件少但职责清楚后面加功能、修bug都容易定位。3.2 核心模块实现爬取、解析、清洗、入库先看spider.py。整个模块就是两个函数一个负责抓取页面一个负责解析页面。抓取页面时加上超时和重试机制解析页面时只做“HTML转Python字典”这件事不掺清洗逻辑。import time import random import requests from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } def fetch_page(url: str, retries: int 3) - str: for i in range(retries): try: resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding utf-8 return resp.text except requests.RequestException as e: print(f[失败] {url} 第{i1}次请求出错{e}) if i retries - 1: time.sleep(2 ** i) return def parse_events(html_text: str) - list[dict]: soup BeautifulSoup(html_text, lxml) items soup.select(.event-item) result [] for item in items: title_node item.select_one(.title) date_node item.select_one(.date) category_node item.select_one(.category) result.append({ display_date: date_node.get_text(stripTrue) if date_node else , title: title_node.get_text(stripTrue) if title_node else , category: category_node.get_text(stripTrue) if category_node else , source_url: title_node.get(href) if title_node else , }) return result清洗逻辑集中在cleaner.py。这里除了日期标准化还要处理重复数据和分类映射。分类映射的意思是不同页面可能叫“科技”“科学”“技术”实际上应该是同一类。我维护了一个映射表CATEGORY_MAP { 科技: 科技, 科学: 科技, 技术: 科技, 文学: 文化, 艺术: 文化, 音乐: 文化, 体育: 体育, 社会: 社会, }主流程写在一个main()里把抓取、解析、清洗、存储串起来。每个页面抓完之后“休息”1到2秒连续跑几百页也不会被封def main(): url https://example-history-site.org/events?page{} all_events [] for page in range(1, 50): html fetch_page(url.format(page)) if not html: continue raw_events parse_events(html) all_events.extend(raw_events) time.sleep(random.uniform(1, 2)) # 清洗 cleaned [clean_event(e) for e in all_events] # 去重 unique deduplicate(cleaned) # 入库 save_to_sqlite(unique)3.3 数据去重与字段兜底数据量一上来去重就变成刚需。同一个历史事件可能在不同日期页面里重复出现也可能是同一天被失败重试重复抓了。我的去重逻辑是把event_date title category拼成一个字符串用集合判断是否出现过def deduplicate(events: list[dict]) - list[dict]: seen set() result [] for e in events: key (e[event_date], e[title], e[category]) if key in seen: continue seen.add(key) result.append(e) return result用集合判断重复时间复杂度是O(1)即使有几万条数据也很快。这里有个经验去重不要只靠标题因为不同网站同一事件的标题可能略有差异但“日期标题类别”三个维度组合起来误伤概率会低很多。字段兜底也很重要。比如人物字段并不是所有事件都能提取到。我统一用person 未知填充而不是空字符串这样后续筛选可以用person ! 未知作为条件查询逻辑写起来更舒服。缺失的类别如果不在映射表里就归到“其他”类不能因为一个映射失败把整条记录丢掉。3.4 数据可视化让时间线真正“在指尖流淌”数据有了不做可视化总觉得差口气。这个项目我用pyecharts的Timeline组件把不同时间段的事件做成可翻滚的时间轴效果非常直观。先把数据按年份分组然后用柱状图展示每年的事件数量import pandas as pd from pyecharts.charts import Timeline, Bar from pyecharts import options as opts df pd.read_sql_query(SELECT event_date, title FROM events, sqlite3.connect(data/history.db)) df[year] df[event_date].str[:4].astype(int) tl Timeline() for year in sorted(df[year].unique()): sub df[df[year] year] bar ( Bar() .add_xaxis([year]) .add_yaxis(事件数量, [len(sub)]) .set_global_opts(title_optsopts.TitleOpts(titlef{year}年)) ) tl.add(bar, str(year)) tl.render(timeline.html)跑完之后用浏览器打开timeline.html左侧是年份拖动滑块就能看到不同年代的事件数量变化。后来我又加了一个日历热力图按“月-日”统计可以看到哪些日子历史上特别“热闹”也就是高频事件日。这个可视化部分看似是锦上添花其实对数据质量的验证帮助很大——如果某一年事件数量突然暴涨或归零往往意味着数据缺失或重复清洗没做好一眼就能看出来。4. 避坑指南我在实战中踩过的雷4.1 常见问题速查表爬虫项目跑起来之后问题基本集中在请求、解析、编码、数据质量四个方面。我整理了一张速查表遇到问题先对照现象再定位原因。现象可能原因解决思路请求返回403请求头缺失或被识别为脚本补全请求头轮换User-Agent页面中文乱码编码判断错误检查resp.encoding手动指定utf-8标题带一堆空格/换行get_text()未清理加stripTrue某个事件解析不到select_one返回None先判断node is not None再取文本同一事件重复入库多页面重复或重试用日期标题类别做键值去重请求偶尔超时网络波动或服务器慢设置超时 指数退避重试动态页面拿不到数据数据由JS渲染先找后端接口找不到再考虑渲染4.2 请求频率与反爬的平衡很多爬虫新手容易走两个极端要么疯狂请求导致被封要么小心翼翼到速度太慢。我的“中庸之道”是默认每秒一个请求随机加0到1秒抖动连续失败3次就暂停10秒观察。这样对普通信息展示型站点来说压力非常小绝大多数情况不会触发风控。面向大量页面时我还有一个“暂停策略”每爬50页主动停60秒。这相当于给服务器一个“喘息窗口”避免高峰期被误伤。如果你需要爬更多页甚至可以设置成每10页暂停5秒总之宁可慢不可封。项目跑一晚上的结果通常也足够用了。关于多线程和协程我也聊一下自己的看法。网速瓶颈不在这边的场景下单线程就已经能压满对目标站点的礼貌访问上限。多线程虽然能让爬取总量上去但如果不加限速更容易触发反爬机制最后得不偿失。所以我的建议是小项目单线程稳扎稳打等真有了百万级数据需求直接迁移到Scrapy加限速配置而不是自己折腾多线程。4.3 数据质量打磨几个容易被忽略的细节数据质量打磨是整个项目里花时间最长的一块。第一是日期的模糊度问题。很多历史事件根本没有精确到日的记载能写“约1600年”已经是极限更早的甚至只有世纪描述。我在清洗函数里遇到“约”这类字眼时会直接提取年份丢掉“约”字整体按年份归入同年数据。虽然严格来说不够精确但时间线展示的粒度并不需要精确到日年份就够了。第二是来源复核。我会把source_url原样存下来遇到可疑数据时打开链接人工复核。比如“发明家出生”和“发明家获得专利”可能是两条接近但在意义上完全不同的事件自动清洗无法判断只能靠人工抽查。这个步骤虽然不能自动化但它是数据可信度的最后一道保险。第三是清洗逻辑的幂等性。同一个清洗函数对同一条数据执行一次和执行一百次结果必须一模一样。我吃过亏的地方在于写了“去掉前两个字符”这种操作第二次执行就把数据搞坏了。后来全面改成“正则匹配 显式赋值”确保清洗函数是幂等的跑多少遍都不怕。4.4 动态页面的处理经验这个项目里我主要用的是静态页面但热词里大家都在问“bs4爬取动态页面”我也分享一套经验。动态页面最常见的特点是直接请求HTML拿不到事件列表因为列表是由JavaScript异步加载的。我的排查顺序是这样的先按F12打开Network面板刷新页面看XHR或Fetch请求里有没有返回JSON数据的接口。如果找到了说明数据本身是结构化接口可以直接模拟请求。只有接口找不到、内容又必须渲染之后才能拿到的情况下我才会考虑用浏览器自动化工具。BeautifulSoup本身静态解析就够了关键是“先接口、再JS、最后渲染”这个排查顺序能少走很多弯路。5. 增量更新与后续扩展5.1 定时抓取与增量入库历史事件数据虽然不会天天更新但来源站点可能修正旧条目也可能补充新事件所以增量更新是必要的。我的做法是在SQLite表里加一个crawled_at字段每次抓取前先查一次数据库里最大的crawled_at只去处理那些新出现或发生变化的数据。增量入库用INSERT OR REPLACE会很方便但前提是表里有唯一键。我把event_date title category设成唯一索引这样重复抓取时不会产生脏数据CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_date TEXT, display_date TEXT, title TEXT, category TEXT, person TEXT, location TEXT, description TEXT, source_url TEXT, crawled_at TEXT, UNIQUE(event_date, title, category) );配合定时任务比如每天凌晨2点跑一次脚本数据库就能保持新鲜。抓取频率不用高历史事件数据本来就不是高频变化的内容。5.2 从时间线到更多应用数据集建成之后它的价值就开始外溢了。我拿它做了一本个人用的“历史日历”每天自动展示对应日期的历史事件也可以按人物整理生平时间线写文章查资料时非常方便。如果你有教学需求还可以把时间线数据导入到笔记软件或思维导图工具里按主题重新组织。因为数据最终是标准的 SQLite 或 CSV 文件所以它跟任何生态都能对接。用 pandas 可以做统计分析比如“19世纪到底哪类事件占比最高”用 pyecharts 或 ECharts 可以画交互式时间轴用 Flask 搭个小站点还能把自己的“历史数据库”做成在线可查的页面。这个项目的上限完全取决于你想让它长多大。5.3 如果目标网站改版了怎么办最后聊一个所有爬虫都会遇到的问题目标网站改版。HTML的结构一变选择器就失效程序跑起来会静默返回空数据。我在这个项目里踩过类似坑后来养成了几个习惯。一是把CSS选择器全部集中到config.py不要散落在代码里。改版时只需要改配置文件不需要改动解析逻辑。二是给解析函数加基础校验比如解析完如果结果为空就直接报警不能在用户不知情的情况下产出空数据集。三是在数据库里记录“当日抓取总数”连续几天为0时提醒我手动检查页面结构。这些机制加上之后即使网站改版我也不会被蒙在鼓里。我个人在实际操作中的体会是爬虫项目里最值的投入不是写代码那部分而是“把各种意外情况都想好”的那部分。你对数据质量的尊重会在后面每一次使用数据时反馈给你。