完整工具链:从爬取、解析到可视化12306城市数据的全流程实现

34 阅读8分钟

在数据驱动的时代,获取并理解公共数据已成为技术决策和商业分析的关键环节。12306作为中国铁路客运服务的核心系统,其背后庞大的城市站点数据不仅对旅行规划至关重要,更是观察中国城市化进程和交通网络布局的独特窗口。本文将带领您构建一个完整的技术工具链,从数据爬取、解析处理到最终的可视化呈现,全方位挖掘12306城市数据的价值。

技术架构与工具选型

在开始实现之前,我们先规划整个技术栈:

  • 数据获取层:使用 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">requests</font> 模块处理HTTP请求
  • 数据处理层:利用 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">json</font><font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">pandas</font> 进行数据清洗与结构化
  • 数据存储层:采用轻量级的SQLite数据库
  • 可视化层:基于 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">pyecharts</font> 创建交互式地理图表

这种分层架构确保了各模块的职责单一,同时保持了整个流程的连贯性和可维护性。

第一阶段:数据爬取 - 精准获取城市JSON

12306的城市数据通过一个特定的接口提供,我们需要模拟浏览器请求来获取这些数据。

import requests
import json
import re
from typing import Dict, Any

class CityDataCrawler:
    def __init__(self):
        self.session = requests.Session()
        # 设置请求头,模拟浏览器行为
        self.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',
            'Referer': 'https://www.12306.cn/index/'
        }
        
        # 代理配置
        self.proxyHost = "www.16yun.cn"
        self.proxyPort = "5445"
        self.proxyUser = "16QMSOML"
        self.proxyPass = "280651"
        
        # 设置代理
        self._setup_proxy()
    
    def _setup_proxy(self):
        """设置代理配置"""
        proxy_url = f"http://{self.proxyUser}:{self.proxyPass}@{self.proxyHost}:{self.proxyPort}"
        self.proxies = {
            'http': proxy_url,
            'https': proxy_url
        }
        
        # 为session设置代理
        self.session.proxies.update(self.proxies)
    
    def get_city_data(self) -> Dict[str, Any]:
        """
        获取12306城市数据
        返回字典格式的城市信息
        """
        try:
            # 12306城市数据接口
            url = "https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version=1.9270"
            
            # 使用代理发送请求
            response = self.session.get(
                url, 
                headers=self.headers, 
                timeout=10,
                verify=False  # 如果代理有SSL证书问题,可以暂时关闭验证
            )
            response.encoding = 'utf-8'
            
            if response.status_code == 200:
                return self._parse_raw_data(response.text)
            else:
                print(f"请求失败,状态码:{response.status_code}")
                return {}
                
        except requests.exceptions.ProxyError as e:
            print(f"代理连接错误:{e}")
            return {}
        except requests.exceptions.ConnectTimeout as e:
            print(f"连接超时:{e}")
            return {}
        except requests.exceptions.RequestException as e:
            print(f"请求异常:{e}")
            return {}
        except Exception as e:
            print(f"获取数据时发生错误:{e}")
            return {}
    
    def _parse_raw_data(self, raw_text: str) -> Dict[str, Any]:
        """
        解析原始数据,提取城市信息
        """
        # 使用正则表达式提取车站数据
        pattern = r"@[a-z]+\|([^\|]+)\|([a-z]+)\|([a-z]+)\|([a-z]+)\|([a-z]+)\|([0-9]+)\|([a-z]+)"
        matches = re.findall(pattern, raw_text)
        
        cities = {}
        for match in matches:
            city_data = {
                'name': match[0],  # 中文站名
                'code': match[1],  # 车站代码
                'pinyin': match[2],  # 拼音
                'abbr': match[3],   # 缩写
                'number': match[6]  # 编号
            }
            cities[city_data['code']] = city_data
        
        print(f"成功解析 {len(cities)} 个车站数据")
        return cities

    def test_proxy_connection(self):
        """测试代理连接是否正常"""
        test_url = "http://httpbin.org/ip"
        try:
            response = self.session.get(test_url, timeout=5)
            if response.status_code == 200:
                print("代理连接测试成功")
                print(f"当前IP信息:{response.text}")
                return True
            else:
                print("代理连接测试失败")
                return False
        except Exception as e:
            print(f"代理测试异常:{e}")
            return False

# 执行爬取
if __name__ == "__main__":
    crawler = CityDataCrawler()
    
    # 测试代理连接
    print("正在测试代理连接...")
    if crawler.test_proxy_connection():
        print("代理连接正常,开始爬取数据...")
        city_data = crawler.get_city_data()
        if city_data:
            print(f"数据爬取完成!共获取 {len(city_data)} 个城市数据")
            # 打印前5个城市作为示例
            for i, (code, city) in enumerate(list(city_data.items())[:5]):
                print(f"{i+1}. {city['name']} - 代码: {code}")
        else:
            print("数据爬取失败!")
    else:
        print("代理连接失败,请检查代理配置")

第二阶段:数据解析与存储 - 构建结构化数据体系

获得原始数据后,我们需要进行数据清洗、结构化,并存储到数据库中以便后续分析。

import pandas as pd
import sqlite3
from datetime import datetime

class DataProcessor:
    def __init__(self, city_data: Dict[str, Any]):
        self.city_data = city_data
        self.df = None
    
    def create_dataframe(self):
        """将城市数据转换为DataFrame"""
        records = []
        for code, info in self.city_data.items():
            records.append({
                'station_code': code,
                'station_name': info['name'],
                'pinyin': info['pinyin'],
                'abbreviation': info['abbr'],
                'station_number': info['number']
            })
        
        self.df = pd.DataFrame(records)
        print(f"创建DataFrame成功,共 {len(self.df)} 条记录")
        
        # 数据质量检查
        self._data_quality_check()
    
    def _data_quality_check(self):
        """执行数据质量检查"""
        print("\n=== 数据质量报告 ===")
        print(f"总记录数: {len(self.df)}")
        print(f"空值统计:")
        print(self.df.isnull().sum())
        print(f"重复车站数: {self.df.duplicated('station_code').sum()}")
        print("====================\n")
    
    def save_to_sqlite(self, db_path: str = "12306_cities.db"):
        """保存数据到SQLite数据库"""
        try:
            conn = sqlite3.connect(db_path)
            
            # 创建数据表
            create_table_sql = """
            CREATE TABLE IF NOT EXISTS railway_stations (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                station_code TEXT UNIQUE NOT NULL,
                station_name TEXT NOT NULL,
                pinyin TEXT,
                abbreviation TEXT,
                station_number TEXT,
                created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
            """
            conn.execute(create_table_sql)
            
            # 保存数据
            self.df.to_sql('railway_stations', conn, if_exists='replace', index=False)
            
            conn.commit()
            conn.close()
            print(f"数据已保存到数据库: {db_path}")
            
        except Exception as e:
            print(f"数据库操作失败: {e}")
    
    def get_regional_statistics(self):
        """生成区域统计信息"""
        if self.df is not None:
            # 简单的统计分析
            stats = {
                'total_stations': len(self.df),
                'stations_per_province': self._count_stations_by_province()
            }
            return stats
        return {}
    
    def _count_stations_by_province(self):
        """按省份统计车站数量(简化版本)"""
        # 在实际应用中,这里需要更复杂的地理编码逻辑
        province_keywords = ['北京', '上海', '广州', '深圳', '杭州', '南京', '武汉', '成都']
        province_count = {}
        
        for keyword in province_keywords:
            count = self.df[self.df['station_name'].str.contains(keyword)].shape[0]
            if count > 0:
                province_count[keyword] = count
        
        return province_count

# 数据处理流程
processor = DataProcessor(city_data)
processor.create_dataframe()
processor.save_to_sqlite()

# 输出统计信息
stats = processor.get_regional_statistics()
print("区域统计信息:", stats)

第三阶段:数据可视化 - 让数据说话

数据只有通过可视化才能真正展现其价值。我们将使用pyecharts创建交互式的地理分布图。

from pyecharts import options as opts
from pyecharts.charts import Map, Bar, Page
from pyecharts.globals import ThemeType
import random

class DataVisualizer:
    def __init__(self, df):
        self.df = df
    
    def create_station_distribution_map(self):
        """创建车站分布地图"""
        # 模拟各省份车站数量(实际项目中应从准确的地理编码获取)
        province_data = [
            ("北京市", 15), ("天津市", 12), ("河北省", 45),
            ("山西省", 38), ("内蒙古自治区", 28), ("辽宁省", 42),
            ("吉林省", 35), ("黑龙江省", 40), ("上海市", 18),
            ("江苏省", 55), ("浙江省", 48), ("安徽省", 42),
            ("福建省", 38), ("江西省", 36), ("山东省", 52),
            ("河南省", 58), ("湖北省", 45), ("湖南省", 44),
            ("广东省", 62), ("广西壮族自治区", 40), ("海南省", 12),
            ("重庆市", 25), ("四川省", 65), ("贵州省", 35),
            ("云南省", 42), ("西藏自治区", 8), ("陕西省", 38),
            ("甘肃省", 32), ("青海省", 18), ("宁夏回族自治区", 12),
            ("新疆维吾尔自治区", 28), ("台湾省", 15)
        ]
        
        distribution_map = (
            Map(init_opts=opts.InitOpts(
                theme=ThemeType.ROMA,
                width="1200px",
                height="600px"
            ))
            .add(
                series_name="车站数量",
                data_pair=province_data,
                maptype="china",
                is_map_symbol_show=False,
            )
            .set_global_opts(
                title_opts=opts.TitleOpts(
                    title="12306全国铁路车站分布图",
                    subtitle="数据来源:12306官方",
                    pos_left="center"
                ),
                visualmap_opts=opts.VisualMapOpts(
                    min_=0,
                    max_=70,
                    is_calculable=True,
                    orient="horizontal",
                    pos_left="center",
                    pos_bottom="10%",
                    range_color=["#B0E0E6", "#1E90FF", "#0000CD"]
                ),
                legend_opts=opts.LegendOpts(is_show=False)
            )
            .set_series_opts(
                label_opts=opts.LabelOpts(is_show=True)
            )
        )
        
        return distribution_map
    
    def create_top_cities_chart(self):
        """创建主要城市车站数量柱状图"""
        # 基于实际数据生成主要城市统计
        major_cities = ['北京', '上海', '广州', '深圳', '杭州', '南京', '武汉', '成都', '西安', '重庆']
        city_counts = []
        
        for city in major_cities:
            count = len(self.df[self.df['station_name'].str.contains(city)])
            city_counts.append(count)
        
        bar_chart = (
            Bar(init_opts=opts.InitOpts(
                theme=ThemeType.ROMA,
                width="1000px",
                height="500px"
            ))
            .add_xaxis(major_cities)
            .add_yaxis(
                "车站数量",
                city_counts,
                itemstyle_opts=opts.ItemStyleOpts(color="#5470C6")
            )
            .set_global_opts(
                title_opts=opts.TitleOpts(
                    title="主要城市铁路车站数量统计",
                    pos_left="center"
                ),
                xaxis_opts=opts.AxisOpts(
                    axislabel_opts=opts.LabelOpts(rotate=45)
                ),
                yaxis_opts=opts.AxisOpts(
                    name="车站数量"
                )
            )
        )
        
        return bar_chart
    
    def generate_dashboard(self):
        """生成完整的数据仪表板"""
        page = Page(layout=Page.SimplePageLayout)
        
        # 添加各个图表
        page.add(
            self.create_station_distribution_map(),
            self.create_top_cities_chart()
        )
        
        # 渲染为HTML文件
        page.render("12306_cities_dashboard.html")
        print("可视化仪表板已生成: 12306_cities_dashboard.html")

# 执行可视化
visualizer = DataVisualizer(processor.df)
visualizer.generate_dashboard()

技术深度解析与业务价值

1. 反爬虫策略应对

在数据爬取阶段,我们采用了完整的请求头模拟,包括User-Agent和Referer,这对于绕过基础的反爬虫机制至关重要。在实际生产环境中,可能需要进一步处理IP轮换、验证码识别等复杂情况。

2. 数据质量保障

通过实现数据质量检查流程,我们能够及时发现数据缺失、重复等问题,确保后续分析的准确性。这是工业级数据管道不可或缺的环节。

3. 可视化技术选型

选择pyecharts而非matplotlib等传统库,是因为其出色的交互性和对中文的良好支持。用户可以通过悬停、缩放等操作与图表深度交互,获得更丰富的数据洞察。

应用场景与扩展方向

这个完整的工具链不仅限于技术演示,在实际业务中具有广泛的应用价值:

  • 城市规划分析:通过车站分布密度了解区域交通发展水平
  • 商业选址支持:为物流、零售等行业提供交通便利性参考
  • 旅游产品开发:基于铁路网络优化旅行路线规划
  • 投资决策辅助:分析交通基础设施建设的区域重点

总结

通过本文的完整实现,我们构建了一个从数据采集到价值呈现的端到端技术解决方案。这个工具链展示了现代数据工程的核心理念:自动化、结构化、可视化。每个技术环节都经过精心设计,既保证了功能的完整性,又为后续扩展留出了充足空间。