做爬虫练习,很多人一上来就想着去抓电商、抓社交平台,结果不是被验证码卡住,就是被反爬拦在门外。我个人的经验是,想练好Python爬虫的基本功,百科类静态页面是最好的起步场地——尤其像地铁线路概况这种纯粹的结构化内容,页面里清清楚楚写着线路名称、起止站点、车站数量、里程还有首末班车时间,非常适合做数据提取练习。前段时间我刚好完整跑了一遍这个流程,从分析百科页面的DOM结构,到用requests把HTML拉下来,再用BeautifulSoup定位信息框,最后清洗成一份规整的CSV文件。这篇文章把整个过程的思路、代码和坑位都记录下来,给同样想入门爬虫的朋友做个参考,也方便后面自己复用。适合有一定Python基础、用过requests但没做过完整项目的人;如果你完全零基础,建议先去补一下HTML里table、tr、th、td这几个基础标签,再看本文会顺畅很多。
1. 为什么要拿百科静态页面练爬虫
1.1 静态页面比动态页面友好太多
动态网页的数据是JS异步加载出来的,用requests拿到的往往是一副空壳,里面只有一堆script标签,真正的内容要等浏览器渲染之后才能看到。想抓这种页面,要么上Selenium或者Playwright这类浏览器自动化工具,要么去分析XHR接口,对新手来说每一步都是门槛。百科类静态页面就不一样了,它的文字内容几乎全部由服务端直接输出,网页源码里就躺着你想要的信息,不需要等任何JS执行。一个简单的类比:动态页面是上了锁的保险柜,钥匙在浏览器手里;静态页面则是没锁的文件柜,HTML源码就是抽屉里的文件清单。练爬虫的第一步,就是学会在文件清单里精准找到自己需要的那一格。
1.2 地铁数据是完美的结构化练习对象
地铁线路概况这类词条,信息密度高、字段相对固定:线路名称、起点站、终点站、车站数量、线路里程、首末班车时间,基本就是一个键值对的集合。在百科页面上,这些字段往往集中在一个叫“信息框”的表格里,左侧是字段名,右侧是值,用th和td成对出现。抓下来之后直接放进字典,再转成CSV,每一步都很直观。而且一个城市有多条地铁线路,你可以在程序里做一个循环,分别请求1号线、2号线、3号线的词条页面,练习批量抓取;不同线路的描述格式又有细微差异,比如有的写“27座”,有的写“共27站”,里程有的是“20.5千米”,有的是“约20.5公里”,这逼着你写清洗逻辑。这正是真实爬虫项目里最让人头疼的“脏数据”问题,但难度又在可控范围内,非常适合作为进阶训练。
1.3 练技术的同时守住边界
爬虫最大的问题不是技术,而是分寸。百科站点允许正常访问公开词条,但仍有robots协议和访问频率的要求。练习时应当设置合理的请求间隔,不要并发轰炸,不要抓取非公开或受保护的内容,更不要拿数据去做商业用途。这篇文章里的代码都以学习为目的,目标页面是公开的百科词条,读者自己运行的时候也要注意保持克制。我自己的习惯是每抓一个页面间隔至少一到两秒,并且只在非高峰时间段跑批量任务。技术本身没有对错,用得好是工具,用得失控就是麻烦。
2. 拆解目标页:URL规律、页面结构、数据字段落点
2.1 找到URL的拼装规律
在写代码之前,先手工打开目标百科站点,找到“某市地铁1号线”的词条,看一眼浏览器地址栏里的URL长什么样。通常百科词条页面的URL是有规律的:主域名后面跟一个固定的路径前缀,再跟词条名。词条名一般就是中文的“某市地铁1号线”,在URL里可能会显示成百分号编码,也可能保持中文原样,这些细节requests库会自动处理,我们不需要自己编码。重要的是确认前缀路径,比如是/wiki/还是/index.php?title=。不同的百科架构有不同规则,这一步千万别拍脑袋,打开浏览器实际看一眼最靠谱。
为了演示方便,我假设目标站点的URL格式是这样的:
https://encyclopedia.example/wiki/某市地铁1号线其中“某市地铁1号线”就是词条名。后续要抓2号线,只要把词条名里的数字换成2就行。所以我在代码里会维护一个线路列表,循环拼接出完整的URL。这样可以避免一个一个手写URL,也方便以后扩展到更多线路。
2.2 信息框表格的DOM结构
百科类页面的右侧通常有一个灰色的半透明表格,叫“信息框”(infobox),所有地铁线路的概括性参数都集中在这个表里。用浏览器的开发者工具(F12)选中这个表格,会看到它一般有一个固定的class名,比如infobox。表格内部是一行一行的键值对:每个tr里放着一个th(字段名)和一个td(字段值)。我模拟一下简化后的HTML结构,方便你理解后面代码的定位逻辑:
<table class="infobox"> <tr> <th>线路名称</th> <td>某市地铁1号线</td> </tr> <tr> <th>起点站</th> <td>示例起点站</td> </tr> <tr> <th>终点站</th> <td>示例终点站</td> </tr> <tr> <th>车站数量</th> <td>27座</td> </tr> <tr> <th>线路里程</th> <td>20.5千米</td> </tr> <tr> <th>首班车时间</th> <td>06:00</td> </tr> <tr> <th>末班车时间</th> <td>23:00</td> </tr> </table>实际的百科页面里,信息框里可能还夹杂着“轨道类型”“车辆编组”“最高速度”“开通时间”等字段,这些不是我们需要的,解析时可以直接忽略。我们要做的是把整个信息框里所有的“字段名->值”对都读出来,放进一个字典,后面再按字段名映射到结构化数据。
2.3 需要提取的字段清单
在动手写解析代码之前,先把需求清单列清楚,免得做到一半发现漏字段。我最终需要的信息就这七项:
| 字段 | 信息框中常见列名 | 示例值 |
|---|---|---|
| 线路名称 | 线路名称 | 某市地铁1号线 |
| 起点站 | 起点站 / 起讫站(前半段) | 示例起点站 |
| 终点站 | 终点站 / 起讫站(后半段) | 示例终点站 |
| 车站数量 | 车站数量 / 车站数 | 27座 |
| 里程 | 线路长度 / 里程 / 营业里程 | 20.5千米 |
| 首班车时间 | 首班车 / 首车时间 | 06:00 |
| 末班车时间 | 末班车 / 末车时间 | 23:00 |
注意,起点站和终点站有时会合并成一个字段,叫“起讫站”,中间用“-”或“—”分隔,比如“示例起点站—示例终点站”,这时就需要在清洗阶段把这个字符串拆成两半。还有的页面把首班车和末班车时间合并成一个字段,比如“运营时间:06:00-23:00”,同样需要拆分。这些差异不遇到几次是不会真正有体感的,所以我建议先抓一个页面,把原始数据打印出来,看看字段格式长什么样,再写清洗规则。
3. 环境准备与工具选型
3.1 安装依赖
这次实战只需要三个Python库:requests负责发HTTP请求,BeautifulSoup负责解析HTML,lxml作为BeautifulSoup的解析器。lxml的解析速度比Python内置的html.parser快不少,而且在处理复杂的table结构时更不容易出错,所以强烈建议安装。安装命令一行搞定:
pip install requests beautifulsoup4 lxml如果你用的是虚拟环境,确保已经激活。我习惯在项目目录下建一个.venv虚拟环境,防止依赖污染系统Python。装完之后可以用一行命令验证:
python -c "import requests, bs4, lxml; print('ok')"如果输出ok,说明环境没问题,可以继续往下走。
3.2 为什么选requests + BeautifulSoup,而不是Scrapy
我知道有人会问:做爬虫为什么不直接用Scrapy?Scrapy确实很强大,自带并发调度、中间件、管道,但它的学习曲线比requests + BeautifulSoup陡得多,而且对于一个只需要抓十几个静态页面的小任务来说,属于杀鸡用牛刀。requests + BeautifulSoup的组合足够轻量,代码量少,每一步都透明,出错容易定位。等以后需要大规模抓取、调度、断点续爬时,再迁移到Scrapy也不迟,那时你已经理解了爬虫的底层逻辑,迁移成本会低很多。
另外,我特意没有用Selenium,理由前面说过:静态页面根本不需要浏览器渲染,用Selenium反而会多消耗系统资源,还要维护WebDriver版本兼容,得不偿失。选型的原则永远是:工具为任务服务,而不是为了看起来高级。这个项目用最简单的组合就能完成,那就不要加戏。
3.3 设置请求头与超时
很多网站会检查请求的User-Agent,如果看到Python默认的python-requests/x.x,可能会直接返回403。为了模拟真实浏览器访问,我会在请求头里带上一个常见的Chrome UA。同时设置timeout参数,防止某个页面长时间无响应卡住整个程序。这两点虽然简单,但对程序的稳定性和礼貌性都很重要,建议从一开始就养成习惯。
import requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36" } def fetch_page(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.textresp.raise_for_status()如果状态码不是200,会直接抛出异常,方便我们第一时间发现请求失败。resp.encoding = resp.apparent_encoding是防止中文乱码的关键,后面踩坑部分会再细说。
4. 抓取与解析:从发起请求到结构化字典
4.1 写一个简单的页面请求函数
上面的fetch_page函数已经够用了,但实际运行时网络请求可能会临时失败,比如DNS解析抖动、连接被重置。为了提升鲁棒性,我习惯加一个简单的重试机制:如果请求失败,最多重试3次,每次间隔2秒。这种处理在“批量抓取多条线路”的场景里特别有用——你不想因为某一次网络抖动,导致整批数据中断。
import time def fetch_page(url, retries=3): for attempt in range(retries): try: resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text except Exception as e: print(f"[重试 {attempt+1}/{retries}] 请求失败: {e}") time.sleep(2) raise RuntimeError(f"页面请求多次失败: {url}")如果三次重试都失败,就直接抛出异常,让主程序决定是终止还是跳过这条线路。这个模式几乎适用于所有小规模爬虫任务,你以后写别的东西也可以直接复用。
4.2 定位信息框并提取键值对
拿到HTML文本之后,先用BeautifulSoup初始化一个解析对象,然后找到table标签里class为infobox的那个元素。如果页面结构变了一点,找不到信息框,就直接返回空字典,不要强行执行后面的逻辑,否则会报一堆莫名其妙的错误。
from bs4 import BeautifulSoup def parse_infobox(html): soup = BeautifulSoup(html, "lxml") table = soup.find("table", class_="infobox") if not table: print("警告: 页面中没有找到信息框") return {} data = {} for row in table.find_all("tr"): th = row.find("th") td = row.find("td") if th and td: key = th.get_text(strip=True) value = td.get_text(strip=True) data[key] = value return data这段代码的核心逻辑就是遍历信息框里的每一行,只要发现有th和td,就取一组成键值对。这里有个小技巧:get_text(strip=True)会去掉文本前后的空白字符,包括空格和换行,省得我们手动清理。但要注意,如果一个td里既有文字又有链接,比如“官网:[1]”,get_text会把链接的锚文本也拼进来,这种噪音字段我们一开始就不需要,靠后面的字段映射过滤掉就行。
4.3 把字典映射成规整记录
parse_infobox返回的是以中文列名为键的字典,比如{"线路名称": "某市地铁1号线", "起点站": "示例起点站", ...}。接下来要做的是把这个不稳定的原始字典转换成结构固定的记录,缺失的字段给默认值,这样导出CSV时才不会错列。
def normalize_record(raw): def safe_get(*keys): for k in keys: if k in raw and raw[k]: return raw[k] return "" record = { "线路名称": safe_get("线路名称", "线路"), "起点站": safe_get("起点站", "起讫站"), "终点站": safe_get("终点站"), "车站数量": safe_get("车站数量", "车站数"), "里程": safe_get("里程", "线路长度", "营业里程"), "首班车时间": safe_get("首班车时间", "首班车", "首车"), "末班车时间": safe_get("末班车时间", "末班车", "末车"), } return recordsafe_get这个函数很有用,它接受多个可能的字段名,按顺序找第一个非空的值。因为不同词条里字段名可能不一样,比如有的写“线路长度”,有的写“里程”,有的写“营业里程”。统一通过safe_get兼容,代码会清爽很多,也不会因为某个字段名差异就漏数据。
5. 不规整数据怎么处理:首末班车时间、站数、里程的坑
5.1 首末班车时间的多种写法
这是整个项目里最需要耐心的部分。我去看了好几个城市的地铁词条,发现“首班车时间”和“末班车时间”至少有三种写法:
- 分成两个字段:
首班车时间和末班车时间,各是一个时间点。 - 合并成一个字段:
运营时间或首末班车时间,值是06:00-23:00,中间用短横线分隔。 - 只写了“运营时间”但不是一个标准的24小时制,甚至出现“6:00-11:00”这种没有补零的格式。
针对合并字段的情况,我得写一个解析函数,把字符串拆成两个时间点。如果拆失败,就返回空字符串,宁可缺失也不要给错数据。
import re def split_time_range(value): if not value: return "", "" # 匹配 HH:MM 或 H:MM 的格式,中间用 -、—、~ 分隔 m = re.match(r"\s*(\d{1,2}:\d{2})\s*[-—~至]\s*(\d{1,2}:\d{2})\s*", value) if m: return m.group(1), m.group(2) # 如果不是范围格式,可能是单个时间点,同时作为首末班处理 return value, value如果合并字段里只有单个时间点,比如“运营时间:06:00”,我会让首班和末班都取这个值,同时打印一条警告,方便后续人工核验。这种“程序能跑但数据要人看”的情况,在真实项目中非常常见。
5.2 车站数量可能是“共27站”,也可能是裸数字
车站数量的原始值可能长这样:
27座共27站27车站数量:27个
处理思路很简单:先用正则匹配后面带“座/站/个”的量词,提取第一个数字;如果匹配不到,就从头提取第一个数字。如果连数字都没有,就返回None,让CSV留空。
def parse_station_count(value): if not value: return None m = re.search(r"(\d+)\s*[座站个]", value) if m: return int(m.group(1)) m = re.search(r"(\d+)", value) return int(m.group(1)) if m else None这个函数返回整数,方便后续做排序、统计等操作。如果你只是想展示原文,“27座”可能更直观,但作为数据表,还是以数字为佳。
5.3 里程的单位与括号信息
里程字段常见的写法有:
20.5千米20.5公里20.5km约20.5公里20.5千米(不含支线)
括号里的说明信息我们不要,直接用正则取出带小数点的数字,再统一转成float。
def parse_length(value): if not value: return None # 去掉括号及其内容 value_clean = re.sub(r"[((].*?[))]", "", value) m = re.search(r"(\d+(?:\.\d+)?)", value_clean) if m: return float(m.group(1)) return None这里用re.sub先去掉括号内容,避免正则把括号里的数字也匹配出来。比如“20.5千米(不含支线3.2公里)”如果不去括号,第二个数字3.2就会被当成里程,清洗结果就会出错。吃了一次亏之后,我现在遇到这种字段第一反应就是先剥括号。
5.4 起讫站合并的分拆逻辑
如果字段是“起讫站”,值可能是“示例起点站—示例终点站”,也可能里面带“开往”两个字,比如“开往示例终点站方向”。我写了一个简单但通用的分拆:
def split_stations(value): if not value: return "", "" value = re.sub(r"\s*[开往].*", "", value) # 按常见分隔符拆分 parts = re.split(r"[-—~至]", value) if len(parts) >= 2: return parts[0].strip(), parts[1].strip() return value.strip(), ""严格来说,这种写法不能处理所有情况,但对于这个练习项目已经够用。你如果真的要扩展成多城市通用爬虫,这里最好维护一个更完整的分隔符列表,甚至针对特定词条做人工规则。
6. CSV导出与工程化封装
6.1 用csv模块写出带表头的文件
Python标准库的csv模块足够应付这个需求,不需要引入pandas。唯一值得注意的是编码:Windows上Excel打开UTF-8编码的CSV会乱码,解决方法是写文件时用utf-8-sig编码,也就是在文件开头加上BOM标记。我用newline=""来避免写出的CSV出现多余空行。
import csv def export_to_csv(records, filename="metro_lines.csv"): fieldnames = ["线路名称", "起点站", "终点站", "车站数量", "里程", "首班车时间", "末班车时间"] with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(records) print(f"已导出 {len(records)} 条记录到 {filename}")导出之后,我建议用Excel或者文本编辑器打开看一眼,确认中文没有乱码、列没有错位。CSV看起来很基础,但它是最通用的数据交换格式,后续导入数据库、做数据分析都很方便。
6.2 多线路批量抓取的主循环
实现批量抓取时,我会用一个字典来维护城市和线路编号。这里演示只抓一个城市的三条线路,每个请求之间睡上两秒,克制一点。
def main(): city_lines = { "某市": ["1号线", "2号线", "3号线"], } all_records = [] for city, lines in city_lines.items(): for line in lines: title = f"{city}地铁{line}" url = f"https://encyclopedia.example/wiki/{title}" print(f"正在抓取: {title}") try: html = fetch_page(url) raw = parse_infobox(html) record = normalize_record(raw) # 对原始字段做清洗 record["车站数量"] = parse_station_count(raw.get("车站数量", "")) or parse_station_count(raw.get("车站数", "")) record["里程"] = parse_length(raw.get("里程", "")) or parse_length(raw.get("线路长度", "")) start_raw = record["起点站"] end_raw = record["终点站"] if not end_raw and raw.get("起讫站"): start_raw, end_raw = split_stations(raw["起讫站"]) record["起点站"] = start_raw record["终点站"] = end_raw # 首末班时间合并字段 if raw.get("运营时间"): t1, t2 = split_time_range(raw["运营时间"]) record["首班车时间"] = t1 record["末班车时间"] = t2 elif raw.get("首末班车时间"): t1, t2 = split_time_range(raw["首末班车时间"]) record["首班车时间"] = t1 record["末班车时间"] = t2 all_records.append(record) except Exception as e: print(f"抓取 {title} 失败: {e}") continue time.sleep(2) export_to_csv(all_records) if __name__ == "__main__": main()主循环里先确认原始字段是否存在,再决定要不要用备选字段,最后统一清洗。有些清洗逻辑我在normalize_record里已经做了,但为了保持代码清晰,我把单独的清洗步骤放在主循环里,方便调试时按顺序打印每一步的值。实际项目里,更建议把这些清洗函数全部塞进一个Cleaner类里,抽成单元测试,但今天这个小项目不需要过度设计。
6.3 运行结果与人工核验
跑完上面的代码,得到的CSV可能会长这样:
| 线路名称 | 起点站 | 终点站 | 车站数量 | 里程 | 首班车时间 | 末班车时间 |
|---|---|---|---|---|---|---|
| 某市地铁1号线 | 示例起点站 | 示例终点站 | 27 | 20.5 | 06:00 | 23:00 |
| 某市地铁2号线 | 示例起点站2 | 示例终点站2 | 25 | 18.8 | 06:10 | 22:50 |
| 某市地铁3号线 | 示例起点站3 | 示例终点站3 | 30 | 24.3 | 06:00 | 22:40 |
车站数量和里程已经转成了数字,时间也统一成了HH:MM格式。但请注意,这份数据里线路名称、站名都是我虚构的演示值,你要跑真实任务的话,需要把词条名和字段名一一对应。跑完别急着用,随机抽两条线路,打开原页面人工核对一下,确认字段对应关系没问题再往下走。这种“程序生成数据+人工抽检”的习惯,能帮你避免绝大多数的数据质量事故。
7. 踩过的坑和防反爬经验
7.1 不同词条的DOM结构差异
这是最隐蔽的坑。我原以为所有地铁词条都共用同一个模板,但实际抓取时发现,有些词条的“信息框”用的是class="infobox",有些却是class="metro-info",甚至有的没有class,只能靠位置判断。如果脚本只写死了infobox,换一个城市就会漏数据。
解决办法是提高容错率:优先查class=infobox,查不到就退回去找带有“线路名称”这个文字的th,然后向上找到它所在的表格。虽然这会让代码稍微复杂一点,但通用性会明显提升。如果你只是想快速抓一两个词条,手写选择器也无妨,但心里要清楚这段代码换页面可能就废了。
7.2 中文乱码的坑
我在第一次尝试时,直接用resp.text,结果中文显示成乱码。原因是requests会根据响应头判断编码,但某些站点没有明确声明编码,或者声明错误,导致resp.text用错了编码。解决办法是设置resp.encoding = resp.apparent_encoding,让requests根据HTML内容去猜编码。大部分情况下,apparent_encoding能把UTF-8页面猜对。如果遇到个别页面猜错,再手动指定resp.encoding = "utf-8"。这个坑排一次,以后碰到乱码就知道该检查什么了。
7.3 礼貌爬虫与重试策略
虽然百科类页面没有强反爬,但我不建议你开多线程疯狂请求。爬虫的目标是拿到数据,不是把服务器打挂。我的策略很简单:
- 每次请求之间至少间隔2秒。
- 对同一个站点的请求数量限制在每分钟不超过15个。
- 如果收到403或429,立即停止并等待一段时间。
另外,robots.txt里通常会写明哪些路径允许爬取,虽然它没有法律强制力,但尊重它是行业共识。练习时看一下目标站点的robots.txt,其实也能学到不少东西。我这次演示的域名encyclopedia.example是保留域名,主要用于示例场景,真实抓取时请替换成你实际要练习的、允许访问的百科站点。
7.4 部分页面需要备选解析路径
有些词条不把“车站数量”放在信息框里,而是放在正文的第一个段落,比如写成“本线共设车站27座”。这种情况下,信息框里就没有这个字段。我一般在parse_infobox拿不到车站数量时,会再去正文的所有段落里搜索“共设车站”“车站数量”这类关键词,用正则提取数字。这个备选路径可以写成:
def parse_station_count_from_body(soup): for p in soup.find_all("p"): text = p.get_text() m = re.search(r"共?设?\s*(\d+)\s*[座站]", text) if m: return int(m.group(1)) return None需要说明,这个方法比较粗糙,如果正文里既有车站数量又有别的数字,可能会匹配错。稳妥的做法是限定在包含“车站”字样的段落里搜索,并且优先找带有“共”“座”“站”等量词的位置。这种备选解析逻辑属于“锦上添花”,有则用之,没有就默认返回None,不要让它干扰主流程。
8. 这一套代码还能扩展做什么
抓完一个城市的地铁线路之后,你的工具箱里已经躺着好几样可复用的能力:批量请求、DOM定位、正则清洗、CSV导出。这些能力完全可以迁移到别的场景。比如,把城市列表换成一个字典,就能抓多个城市的线路,不过不同城市的词条命名规范可能不一样,有的叫“某市轨道交通1号线”,有的叫“某地铁1号线”,需要在worddict里维护别名。又比如,把导出的CSV用pandas读进来,可以做里程排序、站点数分布、运营时间分析,看看哪条线路最“勤劳”。甚至可以结合地图库,把线路起点和终点对应的地理坐标标出来,画一个简单的线路网络图。
我个人跑完这一套下来最深的体会是:爬虫的价值不在“抓到”那一刻,而是在“清洗”和“验证”这些脏活累活里。你能把不可控的HTML变成可控的表格,才算真正掌握了定位、解析、容错的组合拳。下一步不妨把目标换成别的领域,比如图书馆的书目、博物馆的展品,思路都是一样的。希望这份代码能帮你少走一点我看过的弯路。