历史是一条奔流不息的长河,而数字化的今天,我们终于可以用 Python 爬虫把这条河里的浪花一朵朵捞起来,变成结构化的数据表格。这个“历史事件时间线数据爬取”项目,听起来像是历史系学生的作业,但真正动手做一遍,你会发现它几乎涵盖了爬虫入门到进阶的所有核心知识点:请求发送、HTML 解析、翻页处理、动态加载识别、数据清洗、存储与增量更新。我更愿意把它当成一个练手项目,爬什么不重要,重要的是通过历史数据这个载体,把 Python 爬虫的完整链路跑通。这篇文章我会从项目设计、环境准备、核心实现到问题排查,一步步拆解,适合刚学完 Python 基础、想找一个真实项目练手的读者,也适合想系统梳理爬虫技术栈的开发者参考。
1. 项目整体设计与思路拆解
1.1 爬虫项目的本质:把网页变成结构化数据
很多人一听到爬虫,就想到“爬取整个互联网”这种宏大叙事,实际上日常 90% 的爬虫任务都简单得多:找到一个包含目标数据的网页,把里面有价值的信息提取出来,存成 CSV、JSON 或者数据库。历史事件时间线爬虫就是这么个典型场景。
我们需要做的事情可以拆成四步:第一,找到可靠的历史事件数据源,比如按年份排列的中外历史大事记页面;第二,分析页面结构,确定事件条目在 HTML 中的位置;第三,批量发送请求并解析,把“公元前221年,秦始皇统一六国”这种半结构化文本变成时间、事件、朝代、人物、地区等字段;第四,清洗并存储,最终形成一份可以检索、排序、统计的历史知识库。
这个项目的独特之处在于时间字段的处理。普通爬虫处理数字或者字符串容易,但历史时间线里充满“公元前”“公元”“约”“初”“末”这类模糊表达,清洗环节的难度一下子拉高了。这也是我推荐大家用历史数据练手的原因——它能逼着你认真思考数据规范化的问题,而不是抓下来就完事。
1.2 技术选型:为什么用 requests + BeautifulSoup,什么时候需要 Selenium
技术选型没有标准答案,只有最合适的方案。我这个项目的主干用了 requests 加 BeautifulSoup,原因很直接:目标页面是服务器直接渲染出来的静态 HTML,数据都老老实实躺在源代码里,用 requests 一把梭就行,又快又省资源。
至于网上铺天盖地讲“bs4 爬取动态页面”的教程,那是针对页面内容由 JavaScript 动态加载的情况。比如豆瓣电影 Top250,虽然数据也在初始 HTML 里出现过,但很多仿站或数据聚合页面其实是前端异步渲染的,直接 requests 拿不到内容,这时候就要考虑两条路:一是打开浏览器开发者工具,找到真正的数据接口(很多是 JSON 格式),直接请求接口;二是实在找不到接口,或者页面交互太复杂,再上 Selenium 模拟浏览器操作。
就历史时间线网站来说,我见过不少用 Table 标签排版的老式页面,纯静态,非常适合练手。但也有一些做得比较现代的站点,用 Vue/React 渲染列表,连翻页都是前端路由切换——这种情况就必须会抓接口或上 Selenium。所以我的建议是:先分析再动手,能静态就静态,能接口就接口,Selenium 永远是兜底手段,不是首选。
1.3 目标网站分析与数据字段规划
动手写代码之前,先花半小时把目标网站摸清楚。我习惯从三个角度分析:第一,内容结构是否稳定,是否分页、分章节;第二,URL 有没有规律,比如?page=1、/year/2023/这种;第三,数据字段有哪些,能不能直接映射成表格列。
对于历史时间线项目,我规划的字段是:event_id(唯一ID,方便去重)、year(年份,存整数,如果是公元前就存负数)、era(原始时间文本,比如“公元前221年”)、event(事件描述)、dynasty(所属朝代)、location(发生地点,可选)、person(关键人物,可选)、source_url(数据来源链接,方便追溯)。这些字段已经足够支撑后续的排序、筛选和简单的历史知识问答。
为什么一定要保留era原始字段?因为爬下来的时间表达五花八门,正则清洗时难免丢失信息,留一个原始文本字段等于留了后悔药,后续发现问题还能重新处理。这也是我做了这么多年爬虫的一个经验:清洗过的字段要留,原始字段也要留,永远不要只存加工后的结果。
2. 环境准备与基础工具配置
2.1 Python 环境安装:别看基础,踩坑的人真不少
这个项目对 Python 版本没有硬性要求,3.8 以上都能跑,但我建议直接装最新的稳定版。Windows 用户去官网下载安装包时,一定要记得勾选“Add Python to PATH”,这一步漏了,后面在命令行敲python就会提示“不是内部或外部命令”。Mac 用户可以用 Homebrew 装,也可以官网下载 pkg 包。Linux 用户一般系统自带 Python3,但版本可能偏旧,建议用apt install python3-pip先把 pip 装上。
装好之后打开终端(Windows 用 PowerShell 或 CMD),输入python --version和pip --version确认环境正常。这里我要特别提醒:网上很多“Python 安装教程”会教你直接pip install全局安装第三方库,我强烈建议先创建虚拟环境。虚拟环境就像给你的项目单独开了一个小房间,不会把系统全局环境搞乱,也不用担心不同项目之间依赖冲突。用python -m venv venv创建,Windows 下激活命令是venv\Scripts\activate,Mac/Linux 是source venv/bin/activate。
2.2 安装依赖库:requests、BeautifulSoup4、pandas 一个不能少
激活虚拟环境后,用 pip 安装以下库:
pip install requests beautifulsoup4 lxml pandasrequests 负责发送 HTTP 请求,beautifulsoup4 负责解析 HTML,lxml 是解析器,比 Python 自带的 html.parser 快很多,pandas 用来做数据清洗和存储,虽然不是必须,但处理表格数据确实方便。另外建议装一个fake_useragent库,它可以随机生成浏览器 User-Agent,可以有效降低被反爬虫识别为脚本的概率:
pip install fake_useragent安装完成后可以用一行命令验证:
import requests from bs4 import BeautifulSoup import pandas as pd print("环境OK")我在这个项目里还装了openpyxl,因为最后要把数据存成 Excel 做展示。如果你只存 CSV,可以不装。总之依赖越精简越好,爬虫项目的原则是能不用框架就不用框架,Scrapy 虽然强大,但为了一个几千条数据的页面去上分布式爬虫,纯属杀鸡用牛刀。
2.3 请求头、Cookie 与基本的反爬意识
爬虫的本质是模拟浏览器访问,服务器区分正常用户和爬虫,主要靠请求头里的信息。最基本的必须带上User-Agent,表明你是哪个浏览器;其次是Referer,告诉服务器你从哪个页面跳转过来;某些站点还需要Cookie来维持登录态或会话。
我在代码里习惯这样组织请求头:
from fake_useragent import UserAgent ua = UserAgent() headers = { "User-Agent": ua.random, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }fake_useragent 每次调用ua.random会随机生成一个浏览器标识,让请求看起来像不同用户在访问。不过要注意,有些网站会校验 User-Agent 的合法性,如果遇到一直返回 403,可以手动写死一个真实的 UA 字符串再试。
关于反爬,我的经验是:尊重网站的 robots.txt,控制请求频率,不要把自己的 IP 玩进黑名单。历史数据这种公开信息,只要访问频率合理,一般不会触发风控。但如果对方明确设置了反爬策略,那就需要增加延时、使用代理池等方式解决,我在后面问题排查部分再展开讲。
3. 历史事件时间线爬虫核心实现
3.1 静态页面爬取:requests + BeautifulSoup 提取事件条目
假设我们已经找到目标页面,结构大概是这样的(简化示例):
<div class="timeline"> <div class="event-item"> <span class="year">公元前221年</span> <span class="desc">秦始皇统一六国,建立秦朝</span> </div> <div class="event-item"> <span class="year">公元前202年</span> <span class="desc">西汉建立</span> </div> </div>第一步,用 requests 获取页面源码:
import requests from bs4 import BeautifulSoup from fake_useragent import UserAgent url = "https://example.com/history/timeline" ua = UserAgent() headers = {"User-Agent": ua.random} resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding # 解决中文乱码,后面详细讲 soup = BeautifulSoup(resp.text, "lxml")第二步,通过选择器定位事件条目。BeautifulSoup 支持 CSS 选择器,比 find_all 直观得多。比如上面这个结构,我可以这样提取:
items = soup.select("div.event-item") for item in items: year_ele = item.select_one("span.year") desc_ele = item.select_one("span.desc") if year_ele and desc_ele: era = year_ele.get_text(strip=True) event = desc_ele.get_text(strip=True) print(era, event)这里有个细节:get_text(strip=True)会把标签里的换行和多余空格去掉,得到干净的文本。如果页面里的年份和事件是分多个标签嵌套的,比如年份里还有<a>链接,那get_text也能把内部所有文本拼出来,非常好用。
3.2 处理翻页与时间线跨度:URL 规律与循环采集
大多数历史时间线站点会按年份分页,比如/history/2023、/history/2024。遇到这种情况,只需要把年份作为变量,循环拼接 URL 就行。但也有些站点用的是“加载更多”按钮,每次点击返回一页 JSON 数据,这时候需要找到接口。
假设 URL 规律是https://example.com/history/{year},年份从公元前 3000 年到公元 2026 年,我们可以这样写循环:
import time for year in range(-3000, 2027): if year < 0: url = f"https://example.com/history/BC{abs(year)}" else: url = f"https://example.com/history/AD{year}" resp = requests.get(url, headers=headers, timeout=10) soup = BeautifulSoup(resp.text, "lxml") # 解析当前页的事件 # parse_events(soup) time.sleep(1) # 控制频率,避免给服务器造成压力注意两点:第一,负数年份转 URL 时要考虑站点自己的规则,有的用BC221,有的用-221,一定要先手动访问一页确认;第二,time.sleep(1)是必须的,哪怕数据量很大也要加,这是爬虫的基本礼仪,也是保护自己 IP 的方式。如果对方网站没有明确禁止爬取,每秒一个请求是相对安全的频率。
3.3 动态加载内容:用浏览器开发者工具找接口,selenium 兜底
如果你的目标站点是动态渲染的,直接requests.get拿到的 HTML 里没有数据,那怎么办?第一步永远是打开浏览器(Chrome 或 Edge),按 F12 进入开发者工具,切到 Network(网络)面板,刷新页面,观察 XHR/Fetch 请求。
很多动态页面其实是前端调后端接口,接口返回 JSON ,里面就有你要的全部数据。找到那个返回 JSON 的接口后,直接请求它,解析 JSON 比解析 HTML 还爽。比如接口返回可能是:
{ "code": 0, "data": { "list": [ {"year": "公元前221年", "event": "秦始皇统一六国"}, ... ] } }那直接用requests.get(api_url).json()就行,根本不需要 BeautifulSoup。这也是我反复强调“能接口就接口”的原因,JSON 结构清晰,还省去了解析 HTML 的麻烦。
但如果接口加密、参数复杂,或者页面必须滚动才能加载,那就只能上 Selenium 了。Selenium 会启动一个真实的浏览器,自动执行 JavaScript,拿到的就是完整的渲染后页面。使用 Selenium 需要安装selenium库和对应的浏览器驱动,代码示例:
from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--headless=new") # 无头模式,不弹出浏览器窗口 driver = webdriver.Chrome(options=options) driver.get("https://example.com/history") html = driver.page_source soup = BeautifulSoup(html, "lxml") # 后续解析逻辑一样 driver.quit()用 Selenium 的时候,如果是“加载更多”按钮,可以用driver.find_element(...).click()触发,也可以模拟滚动到底部。但注意 Selenium 性能开销大,如果一万个页面都用它跑,速度会非常感人,所以能接口就接口,Selenium 只用来攻坚个别无法静态获取的页面。
3.4 数据清洗与结构化:把“公元前221年”转成可排序的时间字段
花大力气爬下来的数据,如果不做清洗就是一堆乱糟糟的字符串,比如“公元前221年”“约1600年”“五代十国时期”“汉武帝时期”等,排序和统计根本无从谈起。所以清洗是让项目有价值的关键一步。
我的清洗思路是先在era字段里保留原始文本,再写一个解析函数,把类似“公元前221年”转成整数-221:
import re def parse_year(era): """ 将中文历史时间文本转换为整数年份,公元前为负数。 示例: "公元前221年" -> -221 "公元2026年" -> 2026 "约1600年" -> 1600 "1644年" -> 1644 无法识别返回 None """ if not era: return None text = era.strip() # 处理“公元前” match_bc = re.search(r"公元前\s*(\d{1,6})\s*年", text) if match_bc: return -int(match_bc.group(1)) # 处理“公元”前缀,如“公元2026年” match_ad = re.search(r"公元\s*(\d{1,6})\s*年", text) if match_ad: return int(match_ad.group(1)) # 处理裸年份,如“221年” match_year = re.search(r"(\d{1,6})\s*年", text) if match_year: return int(match_year.group(1)) # 处理“约”或“左右”等模糊词,兜底提取数字 match_num = re.search(r"-?\d{1,6}", text) if match_num: return int(match_num.group()) return None这个函数不复杂,但覆盖了绝大多数情况。测试几个例子:
print(parse_year("公元前221年")) # -221 print(parse_year("公元2026年")) # 2026 print(parse_year("约1600年")) # 1600 print(parse_year("1644年")) # 1644 print(parse_year("五代十国时期")) # None对于None的结果,我会单独标记出来,后续人工核对。历史文本的模糊性没办法用正则完全解决,但保留原始文本后,至少可以追查。
朝代信息的提取也类似。很多事件描述里会提到“秦朝”“唐朝”,可以维护一个朝代关键词映射表,用简单的包含匹配来打标签。这个操作不追求 100% 准确,能做到 80% 就已经能支撑后续很多分析了。
4. 数据存储与后续分析
4.1 保存为 CSV / JSON,建立本地历史知识库
清洗完成后的数据,最直接的就是存成 CSV 或者 JSON。CSV 用 Excel 就能打开,方便人工浏览;JSON 适合程序读取,后续做 API 或者数据分析都很顺手。用 pandas 可以一行代码写 CSV:
import pandas as pd data = [] # 每个元素是包含字段的字典 for item in all_items: data.append({ "event_id": item["id"], "era": item["era"], "year": item["year_int"], "event": item["event"], "dynasty": item["dynasty"], "location": item["location"], "person": item["person"], "source_url": item["source_url"], }) df = pd.DataFrame(data) df.to_csv("history_timeline.csv", index=False, encoding="utf-8-sig")这里有一个新手特别容易踩的坑:to_csv默认用 UTF-8 编码,但 Excel 打开 UTF-8 的 CSV 会乱码。解决办法是编码参数用utf-8-sig,它会在文件开头加一个 BOM 头,Excel 就能正确识别。如果你只是想用 pandas 继续分析,用encoding="utf-8"就行。
同时也可以存一份 JSON:
df.to_json("history_timeline.json", orient="records", force_ascii=False)force_ascii=False很重要,否则中文会被转成\u开头的 Unicode 转义序列,可读性极差。
4.2 简单可视化与检索:按时期/关键词筛选
有了结构化的 DataFrame,检索就变得很简单。比如我想看看唐朝发生过哪些事件:
tang_dynasty = df[df["dynasty"] == "唐朝"] print(tang_dynasty)我想找到所有包含“战争”的事件:
wars = df[df["event"].str.contains("战争")] print(wars)如果想把公元前的事件单独拎出来:
bc_events = df[df["year"] < 0].sort_values("year")这些操作在 Excel 里也能做,但用 pandas 的筛选、统计、切片更为灵活。当数据量达到几千上万条时,pandas 的优势就非常明显。我还会顺手生成一个简单的年份分布直方图,看看哪个时期的历史事件记录最密集——这其实已经有点历史大数据分析的意思了。
4.3 增量爬取与更新策略
历史事件数据一直在增长,明年你可能会重新跑一遍爬虫,获得 2027 年的新事件。但重新爬全量数据浪费资源,所以增量爬取是必须考虑的。
最简单的方案:在存储表里加一个source_url字段,每次爬取前,把已经存在的 URL 加载到一个集合里,新数据进来时先判断 URL 是否已经存在,存在就跳过。这个方案也叫“基于 URL 去重”,实现简单,效果也很好:
import pandas as pd try: old_df = pd.read_csv("history_timeline.csv") existing_urls = set(old_df["source_url"].tolist()) except FileNotFoundError: existing_urls = set() new_records = [] for url in all_urls: if url in existing_urls: continue item = crawl_page(url) if item: new_records.append(item)当然更专业的做法是用 SQLite 或 MySQL 存储,利用数据库的唯一索引去重。但对于这种个人项目,CSV + 集合去重已经足够了。关键是把更新逻辑想清楚,别每次都全量覆盖,否则后续维护成本很高。
5. 常见问题与排查技巧实录
5.1 编码问题:中文乱码怎么治
爬中文网页最常遇到的就是乱码。解决方案分两步:第一,在resp对象上检查编码:
print(resp.encoding) # 可能是 ISO-8859-1、GBK、UTF-8第二,设置正确的编码。最保险的方式是用resp.apparent_encoding,它是 requests 根据页面内容自动推测的编码,大多数时候都很准:
resp.encoding = resp.apparent_encoding但也有例外,比如某些页面声明了 GB2312,但实际内容包含 GBK 扩展字符,apparent_encoding可能会推测错。这时候可以手动指定:
resp.encoding = "gbk"如果你用的是requests.get(...).text,中文已经乱码了,还可以通过解码原始字节救回来:
raw = resp.content # 原始字节 text = raw.decode("gbk", errors="ignore")错误处理参数errors="ignore"会跳过无法解码的字节,避免程序崩溃。这类问题多试几次就能总结出规律:老站点用 GBK 多,新站点基本都是 UTF-8。
5.2 反爬虫机制:User-Agent、频率限制、IP封锁应对
反爬是爬虫绕不开的话题。历史时间线网站的反爬一般不会太严格,但我也遇到过几个典型的坑。第一种是返回 403 Forbidden,这通常说明 User-Agent 被识别了,或者请求频率太高。先试试换一个浏览器 UA,再增加延时;如果还是 403,可以补一个Referer头,模拟从首页点进来的正常路径:
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", "Referer": "https://example.com/", }第二种是正常返回但内容里没有数据,比如弹出一个验证码页面。这就要考虑更高级的应对策略,比如使用代理 IP、打码平台,或者干脆降低爬取强度。我个人的原则是:如果目标网站明确通过验证码拦截,那就停止爬取,转用官方 API 或手动整理,遵守网站的意愿比拿数据更重要。
第三种是请求频率导致 IP 被封,表现是一段时间内所有请求都超时或返回 403。解决方式是加随机延时,避免固定间隔:
import time import random time.sleep(random.uniform(0.5, 1.5))这个随机延时可以让请求模式更像人的行为,而不是精确到毫秒的机器行为。
5.3 定位不到元素?先看是不是 iframe 或动态渲染
用 BeautifulSoup 解析时,经常出现select_one返回None的情况。新手容易懵,其实原因不外乎三种:选择器写错、内容在 iframe 里、内容是动态加载的。
如果是 iframe,比如有些页面把时间线表格嵌在子页面里,主页面只有<iframe src="content.html">。这时候需要先拿到 iframe 的src,再单独请求这个地址:
iframe = soup.select_one("iframe") iframe_url = iframe["src"] # 继续请求 iframe_url 解析内容如果是动态渲染,参考我前面说的,去 Network 里找接口,或者用 Selenium 渲染后再解析。
还有一种隐蔽情况:页面里确实有数据,但被<script>包在 JavaScript 变量里,比如:
<script> window.historyData = [{"year": "公元前221年", "event": "秦始皇统一六国"}]; </script>这时候可以用正则匹配提取window.historyData = ...;,再 json.loads 解析。这种情况在不少老站点也常见,多一种思路就多一条路。
5.4 数据缺失与异常处理:让爬虫更健壮
爬虫跑着跑着突然中断是常态,原因可能是网络超时、服务器返回 5xx、某个页面结构特殊导致解析出错。所以代码里一定要有异常处理和重试机制。最基本的是用try...except包裹请求:
import time def fetch_html(url, headers, retries=3): for i in range(retries): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: resp.encoding = resp.apparent_encoding return resp.text else: print(f"状态码异常: {resp.status_code}, 重试 {i+1}/{retries}") except requests.RequestException as e: print(f"请求异常: {e}, 重试 {i+1}/{retries}") time.sleep(1) return None如果某个页面解析时出现AttributeError(就是找不到某个元素),可以把对应的 URL 记录到错误日志里,跳过当前页,继续跑后面的。不要因为一个脏数据就中断整个任务,实时输出进度也能帮你掌握跑批情况:
success_count = 0 fail_count = 0 for url in urls: html = fetch_html(url, headers) if html is None: fail_count += 1 continue # 解析逻辑 ... success_count += 1 if success_count % 100 == 0: print(f"已完成 {success_count} 页,失败 {fail_count} 页")这种“先跑通再完善”的思路特别重要。我第一版爬虫只写了核心逻辑,跑一半崩了,但已经有了大量数据,后面加上异常处理和断点续爬,才逐渐变得稳定。
6. 踩坑体会与扩展建议
6.1 我的几个经验教训
这个项目让我重新打磨了不少爬虫细节,有几个印象特别深的点。第一,永远不要过分相信页面结构的稳定性。历史类网站改版不算频繁,但只要改版,CSS 类名和 DOM 结构可能全变,选择器全部失效。所以我在代码里尽量用语义化的选择器(比如div.event-item),而不是用div:nth-child(3)这种绝对位置,改版后还能容错一些。
第二,时间清洗真的没有想象中简单。你以为所有年份都是“公元前/公元”开头,实际上数据里还有“约在公元前 256 年”、“春秋时期(公元前770年—公元前476年)”、“明洪武元年(1368年)”这种带括号、带区间的表达。如果要精确到年份,我是先用正则提取括号内的第一个年份,取不到再用模糊匹配。如果目标是做年份区间统计,那还要设计起止年份两个字段,复杂度上一个台阶。
第三,保存数据时一定要记录source_url。有一次我发现数据里混入了重复事件,一查是同一条历史在不同页面出现了两遍,如果没有来源 URL,我根本没法定向排查是哪两个页面带来的重复。有了 URL,我可以快速写脚本按 URL 去重,保留第一个,问题几分钟就解决。
6.2 让这个项目更有意思的扩展方向
如果你跑通了这个基础版爬虫,后面可以玩的花样还有很多。比如做一个简单的 Flask Web 应用,把历史数据做成 API,支持按年份范围查询、按朝代筛选、按关键词搜索;也可以结合jieba分词对事件描述做词频统计,看看哪个历史时期“战争”“改革”“灾害”出现得最多;甚至可以把年份分布用pyecharts做成动态时间轴,让“历史的长河在指尖流淌”这个标题真正视觉化。
更实际一点的扩展,是给爬虫加一个计划任务,每天定时运行一次,自动抓取新增的历史事件并追加到数据库。这个过程会涉及请求调度、数据校验、日志记录等工程化技能,对找工作面试也有帮助。如果你现在正刷“boss直聘python数据爬取”相关岗位,把这样一个带数据清洗、增量更新、异常处理的项目写进简历,绝对比只写“会使用 requests/BeautifulSoup”有说服力得多。
我个人在实际操作中的一点体会是:爬虫项目的核心从来不是“爬”,而是“整理”。历史数据尤其如此,同一个事件在不同的记载里可能年份不一样,事件描述详略也不同。这个项目做下来,你对数据质量的理解会比只会爬豆瓣电影 Top250 的开发者深得多。最后再分享一个小技巧:如果目标站点提供了robots.txt,花一分钟去看一眼,这既是对站长的尊重,也能让你提前知道哪些路径不允许爬取。我的原则很简单——数据是为了学习和研究,不是用来糟蹋别人服务器的。守住这个底线,爬虫这条路才能走得远。