Python爬虫实战:从12306官网获取火车站三字码对照表
1. 项目缘起为什么需要火车站三字码如果你曾经尝试过通过12306的接口或者一些第三方票务平台来查询火车票你大概率会遇到一个东西火车站三字码。比如北京是BJP上海是SHH广州是GZQ。这些看起来有点奇怪的字母组合就是铁路系统内部用来唯一标识一个车站的代码。对于写程序自动处理火车票相关业务来说这个三字码是绕不开的关键参数。你不可能在请求接口时直接发送“北京南站”这样的中文站名服务器认的是BJP。那么问题来了我们怎么系统地获取这份完整的映射关系呢12306官网并没有提供一个官方的、格式友好的车站代码列表下载。你可能会在一些技术博客或者GitHub项目里找到别人爬下来整理好的station_name.js文件但数据可能过时或者格式不符合你的需求。自己动手丰衣足食。通过Python爬虫直接从12306官网获取这份数据不仅是最直接、最可靠的方式也是一个非常经典的、适合练手的爬虫实战项目。它涵盖了静态页面分析、数据提取、清洗和持久化存储的全流程没有复杂的反爬机制却非常实用。2. 目标分析与技术选型用最简单的工具做最有效的事我们的目标很明确从12306官方网站上抓取所有火车站的中文名与其对应的三字码并保存成一份结构化的数据比如JSON或CSV方便后续程序调用。首先我们得找到数据在哪。打开12306官网www.12306.cn在查询车票的页面你会发现出发站和到达站的输入框。当你点击输入框时会下拉出一个包含所有车站的列表。这个列表的数据并不是在HTML页面里写死的而是通过一个JavaScript文件动态加载的。通过浏览器的开发者工具F12在“网络”Network选项卡中筛选JS文件很容易就能找到一个名为station_name.js或者类似名称的文件。这个文件就是我们数据的源头。技术选型上我们追求简洁高效请求库requests。这是Python生态中公认的、用于发送HTTP请求的瑞士军刀。它简单、直观、功能强大足以应对我们这个静态资源获取的任务。相比于urllib它的API更加友好。数据解析re正则表达式。由于目标数据station_name.js本身并不是标准的JSON或HTML而是一段特定格式的JavaScript字符串例如bjb|北京北|VAP|beijingbei|bjb|0使用正则表达式来匹配和提取我们需要的字段站名和三字码是最直接、最灵活的方法。虽然对于复杂的HTML解析推荐BeautifulSoup或lxml但在这里正则表达式是更合适的工具。数据存储内置json模块或csv模块。我们将提取出的数据转化为Python字典列表然后根据喜好可以轻松地保存为JSON文件便于程序读取或CSV文件便于用Excel查看。为什么不选用更“重型”的框架如Scrapy因为这是一个目标单一、页面结构简单的任务用requestsre的组合足以优雅地解决无需引入框架的复杂度。记住一个原则用最合适的工具而不是最强大的工具。3. 实战步骤详解从找到数据到保存数据接下来我们一步步拆解整个爬取过程。我会把每个步骤的意图和可能遇到的细节都讲清楚。3.1 定位并分析数据源打开12306官网访问https://www.12306.cn。打开开发者工具按F12切换到“网络” (Network)选项卡。记得勾选“保留日志” (Preserve log)以防页面跳转时请求记录被清除。触发车站列表加载点击出发地或目的地的输入框。此时开发者工具的网络面板中会刷出一系列新的请求。寻找目标文件在这些请求中仔细查找一个以station为关键词的JS文件。它的完整URL可能类似于https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version1.12345。这个station_version参数是版本号会变化但文件基本路径是稳定的。查看响应内容点击这个JS请求在“响应” (Response) 或“预览” (Preview) 标签页里你就能看到数据的真面目。它通常是一大段文本格式如下var station_names ‘bjb|北京北|VAP|beijingbei|bjb|0bjd|北京东|BOP|beijingdong|bjd|1bji|北京|BJP|beijing|bj|2bjn|北京南|VNP|beijingnan|bjn|3bjx|北京西|BXP|beijingxi|bjx|4bjy|北京北|VAP|beijingbei|bjy|5...’;可以看到每个车站的信息以“”符号开头不同字段之间用“|”管道符分隔。注意12306的静态资源域名可能是kyfw.12306.cn主站是www.12306.cn。爬取时直接请求kyfw.12306.cn下的资源地址即可这是完全正常的资源访问不涉及任何敏感操作。3.2 编写爬虫代码现在我们开始写代码。我会逐块解释。import requests import re import json import csv def fetch_station_data(): 从12306获取火车站数据JS文件并解析出车站名和三字码。 # 目标URL。这个URL可能会随版本更新但基本模式不变。 # 如果上述URL失效请按3.1步骤重新从官网获取最新URL。 url ‘https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version1.12345‘ # 设置请求头模拟浏览器访问这是一个良好的爬虫习惯。 headers { ‘User-Agent‘: ‘Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36‘ } print(‘正在请求数据...‘) try: response requests.get(url, headersheaders, timeout10) # 检查请求是否成功 response.raise_for_status() # 12306的这个接口返回的编码是UTF-8 response.encoding ‘utf-8‘ js_content response.text print(‘数据请求成功‘) except requests.exceptions.RequestException as e: print(f‘请求失败: {e}‘) return None # 使用正则表达式匹配数据部分。 # 模式解释匹配 “” 开头然后捕获“非|字符”三字码 # 再匹配“|”再捕获“非|字符”中文站名直到下一个“”或字符串结束。 # 这里我们只关心第一个三字码和第二个中文名捕获组。 pattern r‘([^|])\|([^|])\|‘ stations re.findall(pattern, js_content) # re.findall 返回的是元组列表例如 [(‘bjb‘, ‘北京北‘), (‘bjd‘, ‘北京东‘), ...] # 但注意根据我们看到的格式第一个字段似乎是电报码小写拼音第二个才是中文名。 # 然而我们仔细看原始数据‘bjb|北京北|VAP|beijingbei|bjb|0‘ # 按‘|‘分割后索引0是‘bjb‘可能是简码索引1是‘北京北‘索引2是‘VAP‘这才是三字码。 # 所以我们需要调整正则表达式捕获索引1中文名和索引2三字码。 print(‘正在解析数据...‘) # 修正后的正则表达式匹配三个我们需要的字段简码、中文名、三字码 # 模式([^|])\|([^|])\|([^|]) 匹配 xxx|yyy|zzz 的结构并捕获xxx, yyy, zzz pattern_corrected r‘([^|])\|([^|])\|([^|])\|‘ stations_corrected re.findall(pattern_corrected, js_content) # stations_corrected 现在是 [(‘bjb‘, ‘北京北‘, ‘VAP‘), (‘bjd‘, ‘北京东‘, ‘BOP‘), ...] # 但我们只需要 中文名 和 三字码。我们将其转换为字典列表键名更清晰。 station_list [] for item in stations_corrected: # item[0] 是简码如bjb item[1]是中文名 item[2]是三字码 station_list.append({ ‘name‘: item[1], # 中文站名 ‘code‘: item[2], # 三字码 ‘telecode‘: item[0] # 电报码/简码也保留下来可能有用 }) print(f‘共解析出 {len(station_list)} 个车站信息。‘) return station_list代码要点解析请求头User-Agent这是为了让自己看起来更像一个普通的浏览器避免被一些简单的反爬策略拦截。虽然12306这个接口可能不检查但加上是好习惯。异常处理网络请求总是可能失败的。用try...except包裹起来并使用raise_for_status()在HTTP状态码非200时抛出异常能让程序更健壮。正则表达式修正这是本项目第一个关键点。一定要仔细核对原始数据的格式。我第一版正则就抓错了字段把电报码当成了三字码。通过观察bjb|北京北|VAP|...我们明确需要的三字码在第三个位置VAP。所以正则模式要能精准捕获前三个字段。数据转换将元组列表转换成字典列表这样每个车站的信息都有了明确的键name,code后续使用和保存都更方便可读性也更强。3.3 数据清洗与保存获取到的数据可能包含一些我们不需要的车站比如某些乘降所或者存在重复。但通常12306提供的这个列表已经是比较干净的运营车站列表。我们直接保存即可。这里提供两种保存方式。def save_to_json(station_list, filename‘stations.json‘): 将车站列表保存为JSON文件。 with open(filename, ‘w‘, encoding‘utf-8‘) as f: # ensure_asciiFalse 确保中文正常显示indent2 让文件有缩进更易读 json.dump(station_list, f, ensure_asciiFalse, indent2) print(f‘数据已保存至 {filename}‘) def save_to_csv(station_list, filename‘stations.csv‘): 将车站列表保存为CSV文件。 # 定义CSV文件的列头 fieldnames [‘name‘, ‘code‘, ‘telecode‘] with open(filename, ‘w‘, newline‘‘, encoding‘utf-8-sig‘) as f: # utf-8-sig 解决Excel打开中文乱码 writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(station_list) print(f‘数据已保存至 {filename}‘) def main(): 主函数 stations fetch_station_data() if stations: # 保存为JSON便于程序读取 save_to_json(stations) # 保存为CSV便于人工查阅 save_to_csv(stations) # 打印前10条数据看看效果 print(‘\n前10个车站信息‘) for s in stations[:10]: print(f{s[‘name‘]} - {s[‘code‘]} (电报码{s[‘telecode‘]})) else: print(‘未能获取数据程序退出。‘) if __name__ ‘__main__‘: main()运行这段代码你会在当前目录下得到stations.json和stations.csv两个文件。用文本编辑器或Excel打开看看一份完整的中国火车站三字码对照表就到手了。4. 关键细节与常见踩坑点在实际操作中有几个细节如果不注意很容易导致爬虫失败或者数据错误。4.1 URL的动态版本号最大的一个坑就是数据源URL的版本号会变。我们代码里写的station_version1.12345只是一个例子。12306更新车站数据如新站开通、旧站改名时这个版本号就会递增。如果你的某天发现爬虫突然获取不到数据或者数据是旧的第一件事就是去12306官网按照3.1的步骤重新抓取一次那个station_name.js文件的真实URL。一个更健壮的做法是我们可以尝试从12306官网的HTML里动态解析出这个JS文件的链接。通常这个链接会在某个script标签的src属性里。但这需要额外的HTML解析步骤对于这个简单项目手动更新一次URL也完全可以接受。你可以把URL作为一个配置项放在代码开头方便修改。4.2 正则表达式的精确性正如我们在代码中经历的正则表达式写错一个分组抓到的就是完全不同的数据。务必、务必、务必在写好正则后先用一小段样本数据测试一下。比如sample ‘bjb|北京北|VAP|beijingbei|bjb|0bjd|北京东|BOP|beijingdong|bjd|1‘ test_pattern r‘([^|])\|([^|])\|([^|])\|‘ result re.findall(test_pattern, sample) print(result) # 应该输出 [(‘bjb‘, ‘北京北‘, ‘VAP‘), (‘bjd‘, ‘北京东‘, ‘BOP‘)]确保输出的结果和你预期的一致再进行全量数据的抓取。4.3 编码问题虽然12306的这个接口通常返回UTF-8编码但明确指定response.encoding ‘utf-8‘是一个好习惯可以避免在一些环境下出现乱码。保存文件时JSON使用utf-8CSV为了在Windows Excel中正常打开使用utf-8-sig带BOM的UTF-8是更稳妥的选择。4.4 网络请求的稳健性我们设置了timeout10意味着如果10秒内没有收到响应就抛出超时异常。在网络状况不佳时这能防止程序无限期挂起。此外可以考虑增加重试机制使用requests.adapters.HTTPAdapter和urllib3.util.Retry来配置遇到网络错误或5xx状态码时的重试策略这对于生产环境下的爬虫很重要。5. 数据扩展与应用场景拿到这份干净的车站三字码数据后它的用处可不仅仅是填充一个下拉框。这里分享几个我实际用到的或者看到过的扩展场景1. 构建车站查询工具或API将JSON数据加载到内存中可以快速实现站名到三字码、三字码到站名的双向查询。比如做一个简单的命令行工具def find_station_by_name(name_keyword, station_list): return [s for s in station_list if name_keyword in s[‘name‘]] def find_station_by_code(code, station_list): return next((s for s in station_list if s[‘code‘] code.upper()), None)2. 数据清洗与关联如果你手头有别的数据源例如历史票务数据、列车时刻表但里面的车站信息是杂乱的中文名甚至可能有错别字或简称如“北京”、“北京站”、“Beijing”。你可以用这份权威的三字码列表作为“清洗字典”通过模糊匹配如使用fuzzywuzzy库将杂乱的车站名标准化为统一的三字码为后续的数据分析打下基础。3. 结合地理信息单纯的三字码和站名信息还不够“立体”。你可以进一步爬取或购买车站的地理坐标经纬度数据然后与这份列表关联。这样每个车站就拥有了“位置”属性。基于此你可以实现车站间距离计算用于估算票价或旅行时间虽然不精确但有参考价值。可视化地图将所有车站在地图上打点直观展示铁路网络密度。智能推荐用户输入一个城市推荐周边可用的火车站例如输入“苏州”可推荐“苏州站”、“苏州北站”、“苏州园区站”等。4. 监测数据更新由于车站数据并非一成不变你可以将本次爬取的数据与本地存储的上一版本进行对比比较code或name集合的差异自动检测出新开通的车站、更名的车站甚至是被关闭的车站。这可以作为一个定时任务让你的数据始终保持最新。6. 进阶思考从爬虫到可持续的数据管道我们写的这个脚本是一次性的。但实际应用中数据需要更新。如何将它变成一个可持续的、自动化的数据管道思路一脚本 定时任务Cron / Task Scheduler这是最简单的方法。将完整的Python脚本部署到服务器上然后使用Linux的Cron Job或者Windows的任务计划程序设定每周或每月执行一次。脚本执行后可以将新的数据文件覆盖旧的或者按日期存档。你可以在脚本中加入简单的对比逻辑如果发现数据有变化就发送一封邮件或一个钉钉/企业微信消息通知自己。思路二封装为微服务API如果你需要一个随时可查询的在线服务可以用Flask或FastAPI等框架将数据加载到内存并提供RESTful API接口。例如GET /stations获取所有车站列表。GET /station/code/code根据三字码查询车站详情。GET /station/name/keyword根据站名关键词模糊查询。 同时在服务内部可以再启动一个后台线程或定时任务定期从12306拉取最新数据并更新内存中的数据源。这样你的其他应用就可以通过调用这个API来获取准确的车站信息而无需各自维护一份可能过时的数据。思路三集成到数据仓库在更复杂的数据平台中这份车站数据可能只是维度表的一部分。你可以使用Apache Airflow这样的工作流调度工具将爬虫脚本定义为一个PythonOperator任务。这个任务定期运行爬取数据后进行清洗、去重、格式化然后写入到数据库如MySQL、PostgreSQL或数据湖如HDFS、S3中指定的表里。下游的BI报表、数据分析任务都从这张统一的表中读取数据保证了数据的一致性。无论采用哪种方式核心都是将“一次性”的爬虫动作转变为“可调度、可监控、可复用”的数据生产环节。这才是爬虫技能在真实业务场景中的价值体现。最后再强调一个非常重要的点尊重数据源合规使用。我们爬取的是12306公开用于页面功能的数据频率很低且用于个人学习或内部数据建设这通常是合理的。但切记不要高频、并发地请求以免对对方服务器造成不必要的压力这也是一个合格开发者的基本素养。我们的代码里已经通过单次请求、添加请求头、设置超时等方式尽可能地做到了友好访问。