从能跑到能扩展:一次12306爬虫的工程化重构实录

50 阅读14分钟

引言:从“能跑”到“能扩展”的进化之路

我最初接触爬虫时,用几十行 Python 代码实现了一个查询 12306 余票的脚本。它“能跑”,但随着需求增长,这个脚本的问题开始集中爆发——崩溃频繁、逻辑混乱、扩展困难。
于是我开始一次彻底的重构:从临时脚本走向工程化框架。这篇文章完整复盘这个过程,从技术选型、架构演进到测试与监控,展示一次系统性重构的真实价值。


一、原始实现的快速但脆弱

早期脚本功能单一、结构紧耦合,看似高效,实则脆弱:

response = requests.get(API_URL, headers=headers)
json_data = response.json()
for i in json_data.get('data').get('result'):
    index = i.split('|')
    print(f"车次: {index[3]}, 时间: {index[8]}-{index[9]}")

问题随之而来:

  • 稳定性差:网络波动即崩溃,缺乏重试与容错。
  • 可维护性低:硬编码、耦合、无文档。
  • 扩展性弱:单线程执行,输出无法定制。

这些问题不是单点缺陷,而是工程化缺失的系统性后果。


二、技术选型:从脚本到框架的抉择

在权衡后,我选择了 Scrapy 作为重构核心。原因有三:

  1. 工程化能力匹配:自带并发调度、失败重试、插件化结构;
  2. 生态成熟:社区活跃、文档完善;
  3. 长期成本低:可持续维护与扩展。
custom_settings = {
    'CONCURRENT_REQUESTS': 4,
    'RETRY_TIMES': 3,
    'AUTOTHROTTLE_ENABLED': True,
}

Scrapy 不只是“框架”,而是为“可维护性”设计的工程生态。


三、架构重构:分层、解耦、自动化

3.1 数据获取:从请求到调度

重构前:一次请求即全局风险。
重构后:Scrapy 分发异步请求,自动重试与错误分级处理。

def start_requests(self):
    for query in self.queries:
        yield scrapy.Request(
            url=self.build_api_url(query),
            callback=self.parse_ticket_data,
            errback=self.handle_error,
            dont_filter=True
        )

3.2 数据处理:管道化与抽象化

数据清洗、转换、验证分离成独立层。

class DataProcessingPipeline:
    def process_item(self, item, spider):
        if not self.validate_item(item):
            raise DropItem("数据验证失败")
        item = self.clean_data(item)
        return self.transform_data(item)

3.3 配置管理:集中式与环境化

class CrawlerConfig:
    ENV = os.getenv('CRAWLER_ENV', 'development')
    API_CONFIG = {'base_url': 'https://kyfw.12306.cn/otn/leftTicket/queryO', 'timeout': 30}

重构后的配置体系支持环境区分与快速切换。


四、核心设计理念:从“代码”到“系统”

  • 单一职责原则:模块化解耦。
  • 依赖倒置原则:组件通过接口交互。
  • 并发控制与异步化:利用 asyncio + Semaphore 提升吞吐。
async def crawl_concurrently(self, queries):
    tasks = [asyncio.create_task(self.process_query(q)) for q in queries]
    return await asyncio.gather(*tasks)

五、质量与运维:从“能跑”到“可观测”

5.1 测试体系构建

  • 依赖注入实现 Mock 测试;
  • CI 持续集成保证回归安全;
  • 集成测试验证真实组件协同。
service = TicketService(mock_client, mock_parser)
mock_client.get.assert_called_once()

5.2 可观测性提升

通过中间件收集运行指标:

def process_response(self, request, response, spider):
    duration = time.time() - request.meta['start_time']
    spider.crawler.stats.inc_value('total_response_time', duration)

监控维度涵盖 吞吐率 / 响应时长 / 错误率 / 内存占用


六、效果评估:重构不是重写,而是升维

指标项重构前重构后改进幅度
请求成功率72%98%+36%
处理速度(1000条)12.3s3.8s+324%
测试覆盖率20%85%+325%
故障恢复手动自动100%自动化

可维护性、扩展性和团队协作均得到质的提升。


七、经验沉淀:何时重构与如何取舍

重构的触发信号

  • 代码修改风险过高;
  • 新功能开发效率急剧下降;
  • 系统缺乏可观测性或监控。

避免过度工程化

技术不是炫技,而是服务业务。框架选择需权衡团队能力与项目复杂度。


八、总结:重构的核心是思维升级

重构不是把旧代码重写一遍,而是一次系统思维的觉醒

  • 从“写代码”到“设计系统”;
  • 从“功能交付”到“架构治理”;
  • 从“个人效率”到“团队资产化”。

优秀的系统不是一开始就完美的,而是在不断重构中进化出来的。
重构不是终点,而是持续改进的起点。


重构前后 完整的代码

完整代码 ···

重构前:简单但脆弱的脚本

import json
import random
import requests
from prettytable import PrettyTable
from train_info import TrainInfo

# 定义API URL,此处的URL为12306查询票务信息的接口地址
API_URL = "https://kyfw.12306.cn/otn/leftTicket/queryO?leftTicketDTO.train_date=2024-12-19&leftTicketDTO.from_station=BJP&leftTicketDTO.to_station=SHH&purpose_codes=ADULT"

# 读取 city.json 文件,该文件包含了城市名与对应车站代码的映射
with open('city.json', 'r', encoding='utf-8') as f:
    city_data = json.load(f)
    # 获取用户输入的出发城市和目的地
    fromStation = input('请输入出发的城市:')
    toStation = input("请输入目的地:")
    # goDateTime = input("请输入出发时间")

# 动态生成API URL,根据用户输入的城市和日期
train_date = "2024-12-19"  # 查询日期
from_station = city_data[fromStation]  # 出发站代码
to_station = city_data[toStation]  # 到达站代码
# print(f"出发站代码:{from_station},到达站代码:{to_station}")

# 定义 User-Agent 列表,用于模拟不同的浏览器请求
user_agents = [
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36',
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36',
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.TG 短信轰炸接口.15 (KHTML, like Gecko) Version/14.TG 短信轰炸接口.2 Safari/605.TG 短信轰炸接口.15',
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36',
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36'
]
# 构造请求头部,包括随机选择一个User-Agent和Cookie信息
headers = {
    'User-Agent': random.choice(user_agents),
    'Cookie': '_uab_collina=173449375088168679444499; JSESSIONID=60CD7C21677293CF3570C9946BE42019; guidesStatus=off; highContrastMode=defaltMode; cursorStatus=off; _jc_save_fromStation=%u5317%u4EAC%2CBJP; _jc_save_toStation=%u4E0A%u6D77%2CSHH; _jc_save_wfdc_flag=dc; _jc_save_fromDate=2024-12-19; _jc_save_toDate=2024-12-19; route=6f50b51faa11b987e576cdb301e545c4; BIGipServerotn=871367178.38945.0000; _jc_save_fromStation=%u5317%u4EAC%2CBJP; _jc_save_toStation=%u4E0A%u6D77%2CSHH; _jc_save_wfdc_flag=dc; BIGipServerotn=1658388746.50210.0000; BIGipServerpassport=921174282.50215.0000; route=6f50b51faa11b987e576cdb301e545c4; _jc_save_fromDate=2024-12-19; _jc_save_toDate=2024-12-19'
}

# 发起GET请求,获取票务信息
response = requests.get(url=API_URL, headers=headers)

try:
    # 解析JSON数据,并使用PrettyTable进行格式化输出
    json_data = response.json()
    tb = PrettyTable()
    tb.field_names = ['序号', '车次', '出发时间', '到达时间', '耗时', '特等座', '一等座', '二等座', '软卧', '硬卧',
                      '软座', '硬座', '无座', '商务座', '一等卧', '二等卧', '高级软卧']
    page = 1
    result = json_data.get('data').get('result')
    for i in result:
        index = i.split('|')
        # 创建TrainInfo对象,用于存储和处理列车信息
        train_info = TrainInfo(
            train_number=index[3],  # 车次编号
            departure_time=index[8],  # 出发时间
            time_of_arrival=index[9],  # 到达时间
            time_consuming=index[10],  # 运行时间
            premier_class=index[32],  # 商务座
            first_class_seat=index[31],  # 一等座
            second_class=index[30],  # 二等座
            soft_sleeper=index[23],  # 软卧
            hard_sleeper=index[28],  # 硬卧
            soft_seat=index[33],  # 软座
            hard_seat=index[29],  # 硬座
            without_seat=index[26],  # 无座
            business_class=index[35],  # 商务座
            first_class_sleeping=index[34],  # 一等卧
            second_class_bedroom=index[36],  # 二等卧
            superior_soft_sleeper=index[37]  # 高级软卧
        )

        # 将列车信息添加到表格中
        tb.add_row([page, train_info.train_number, train_info.departure_time, train_info.time_of_arrival,
                    train_info.time_consuming, train_info.premier_class, train_info.first_class_seat,
                    train_info.second_class, train_info.soft_sleeper, train_info.hard_sleeper, train_info.soft_seat,
                    train_info.hard_seat, train_info.without_seat, train_info.business_class,
                    train_info.first_class_sleeping,
                    train_info.second_class_bedroom, train_info.superior_soft_sleeper
                    ])
        page += 1
    # 打印格式化后的列车信息表格
    print(tb)
except ValueError as e:
    print(f"Failed to decode JSON: {e}")
except IndexError as e:
    print(f"Index error: {e}")

重构后:工程化的Scrapy爬虫

import scrapy
import json
import logging
import colorlog
import random
from urllib.parse import urlencode
from datetime import datetime
from scrapy.http import Request
from scrapy.exceptions import CloseSpider
from scrapy.crawler import CrawlerProcess
"""
@auth codervibe
彻底重写 12306 爬虫
"""

class TrainTicketItem(scrapy.Item):
    """定义车票数据结构 - Scrapy的Item系统

    用于封装12306车票查询结果中的各项信息,便于后续处理和存储。
    """

    train_number = scrapy.Field()  # 车次
    departure_time = scrapy.Field()  # 出发时间
    arrival_time = scrapy.Field()  # 到达时间
    duration = scrapy.Field()  # 耗时
    business_class = scrapy.Field()  # 商务座
    first_class = scrapy.Field()  # 一等座
    second_class = scrapy.Field()  # 二等座
    soft_sleeper = scrapy.Field()  # 软卧
    hard_sleeper = scrapy.Field()  # 硬卧
    soft_seat = scrapy.Field()  # 软座
    hard_seat = scrapy.Field()  # 硬座
    no_seat = scrapy.Field()  # 无座
    premier_seat = scrapy.Field()  # 特等座
    first_class_sleeper = scrapy.Field()  # 一等卧
    second_class_sleeper = scrapy.Field()  # 二等卧
    superior_soft_sleeper = scrapy.Field()  # 高级软卧
    from_station = scrapy.Field()  # 出发站
    to_station = scrapy.Field()  # 到达站
    query_date = scrapy.Field()  # 查询日期
    crawl_time = scrapy.Field()  # 爬取时间
    data_source = scrapy.Field()  # 数据来源标识


class TrainTicketSpider(scrapy.Spider):
    """12306车票查询爬虫 - Scrapy版本

    通过调用12306官方API获取指定日期、出发地和目的地之间的列车余票信息,
    并将结果结构化后输出为JSON格式文件。

    Attributes:
        name (str): 爬虫名称,用于Scrapy识别
        custom_settings (dict): 爬虫特定配置项
    """

    name = "12306_ticket"

    # Scrapy框架配置
    custom_settings = {
        'CONCURRENT_REQUESTS': 4,  # 并发请求数
        'DOWNLOAD_DELAY': 1,  # 下载延迟(秒)
        'AUTOTHROTTLE_ENABLED': True,  # 自动限速
        'AUTOTHROTTLE_START_DELAY': 1,  # 初始延迟
        'AUTOTHROTTLE_MAX_DELAY': 10,  # 最大延迟
        'RETRY_TIMES': 3,  # 重试次数
        'RETRY_HTTP_CODES': [500, 502, 503, 504, 522, 524, 408, 429],
        'COOKIES_ENABLED': True,  # 启用Cookies
        'ROBOTSTXT_OBEY': False,  # 不遵守robots.txt
        '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',
        'DEFAULT_REQUEST_HEADERS': {
            'Accept': 'application/json, text/javascript, */*; q=0.01',
            'Accept-Encoding': 'gzip, deflate, br',
            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
            'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
            'X-Requested-With': 'XMLHttpRequest',
        },
        'ITEM_PIPELINES': {
            '__main__.DuplicatesPipeline': 100,
            '__main__.ValidationPipeline': 200,
            '__main__.JsonWriterPipeline': 300,
        },
        'FEED_FORMAT': 'json',
        'FEED_URI': 'ticket_results_%(time)s.json',
    }

    _station_codes_cache = None  # 缓存车站代码

    def __init__(self, from_city='北京', to_city='上海', query_date=None, *args, **kwargs):
        """初始化爬虫参数

        Args:
            from_city (str): 出发城市,默认为'北京'
            to_city (str): 到达城市,默认为'上海'
            query_date (str): 查询日期,格式为'YYYY-MM-DD',默认为当天日期
            *args: 其他位置参数
            **kwargs: 其他关键字参数
        """
        super().__init__(*args, **kwargs)
        self.from_city = from_city
        self.to_city = to_city
        self.query_date = query_date or datetime.now().strftime('%Y-%m-%d')
        self.station_codes = self.load_station_codes()

    def load_station_codes(self):
        """加载车站代码映射

        从city.json文件中读取城市与车站代码的映射关系。如果文件不存在,
        则使用内置的常见城市代码作为备选方案。

        Returns:
            dict: 城市名到车站代码的映射字典
        """
        if self._station_codes_cache is not None:
            return self._station_codes_cache

        try:
            with open('city.json', 'r', encoding='utf-8') as f:
                self._station_codes_cache = json.load(f)
        except FileNotFoundError:
            self.logger.error("车站代码文件city.json未找到")
            # 提供一些常用车站代码作为fallback
            self._station_codes_cache = {
                '北京': 'BJP', '上海': 'SHH', '广州': 'GZQ',
                '深圳': 'SZQ', '杭州': 'HZH', '南京': 'NJH',
                '武汉': 'WHN', '成都': 'CDW', '重庆': 'CQW'
            }
        return self._station_codes_cache

    def build_api_url(self, from_code, to_code):
        """构建API请求URL"""
        base_url = "https://kyfw.12306.cn/otn/leftTicket/queryO"
        params = {
            'leftTicketDTO.train_date': self.query_date,
            'leftTicketDTO.from_station': from_code,
            'leftTicketDTO.to_station': to_code,
            'purpose_codes': 'ADULT'
        }
        return f"{base_url}?{urlencode(params)}"

    def start_requests(self):
        """生成起始请求 - Scrapy框架自动调度

        根据输入的城市和日期参数构建12306 API查询URL,并发起请求。
        如果城市代码未找到,则抛出异常终止爬虫。
        """
        from_code = self.station_codes.get(self.from_city)
        to_code = self.station_codes.get(self.to_city)

        if not from_code:
            raise CloseSpider(f"出发城市代码未找到: {self.from_city}")
        if not to_code:
            raise CloseSpider(f"到达城市代码未找到: {self.to_city}")

        url = self.build_api_url(from_code, to_code)

        self.logger.info(f"开始查询: {self.from_city}({from_code}) → {self.to_city}({to_code}) 日期: {self.query_date}")

        yield Request(
            url=url,
            callback=self.parse_ticket_data,
            errback=self.handle_error,
            meta={
                'from_city': self.from_city,
                'to_city': self.to_city,
                'from_code': from_code,
                'to_code': to_code,
                'query_date': self.query_date
            }
        )

    def parse_ticket_data(self, response):
        """解析车票数据 - 框架自动处理重试和异常

        处理12306 API返回的JSON响应,提取所有列车信息并逐个解析。

        Args:
            response (scrapy.http.Response): HTTP响应对象
        """
        if not response.text:
            self.logger.warning("响应内容为空")
            return

        try:
            data = json.loads(response.text)

            # 检查API返回状态
            if not data.get('status', True):
                error_msg = data.get('messages', ['未知错误'])[0]
                self.logger.error(f"12306 API返回错误: {error_msg}")
                return

            result_data = data.get('data', {})
            train_list = result_data.get('result', [])

            self.logger.info(f"获取到 {len(train_list)} 个车次信息")

            for train_data in train_list:
                item = self.parse_single_train(train_data, response.meta)
                if item:
                    yield item

        except json.JSONDecodeError as e:
            self.logger.error(f"JSON解析错误: {e}, 响应内容: {response.text[:200]}")
        except Exception as e:
            self.logger.error(f"解析过程错误: {e}")

    def parse_single_train(self, raw_data, meta):
        """解析单个车次信息

        将原始字符串数据分割并映射到TrainTicketItem字段中。

        Args:
            raw_data (str): 以'|'分隔的原始车次数据字符串
            meta (dict): 请求元数据,包含城市信息等

        Returns:
            TrainTicketItem or None: 解析后的车票数据项,若数据不完整则返回None
        """
        fields = raw_data.split('|')

        # 数据完整性检查
        if len(fields) < 38:
            self.logger.warning(f"数据字段不足,期望38个,实际{len(fields)}个")
            return None

        if not fields[3]:  # 车次号为空
            return None

        def safe_get(index, default='--'):
            return fields[index] if index < len(fields) and fields[index] else default

        item = TrainTicketItem()

        # 基础车次信息
        item['train_number'] = fields[3]
        item['departure_time'] = fields[8]
        item['arrival_time'] = fields[9]
        item['duration'] = fields[10]

        # 座位类型信息
        item['business_class'] = safe_get(32)
        item['first_class'] = safe_get(31)
        item['second_class'] = safe_get(30)
        item['soft_sleeper'] = safe_get(23)
        item['hard_sleeper'] = safe_get(28)
        item['soft_seat'] = safe_get(33)
        item['hard_seat'] = safe_get(29)
        item['no_seat'] = safe_get(26)
        item['premier_seat'] = safe_get(25)
        item['first_class_sleeper'] = safe_get(34)
        item['second_class_sleeper'] = safe_get(36)
        item['superior_soft_sleeper'] = safe_get(37)

        # 元数据
        item['from_station'] = meta['from_city']
        item['to_station'] = meta['to_city']
        item['query_date'] = meta['query_date']
        item['crawl_time'] = datetime.now().isoformat()
        item['data_source'] = '12306_official'

        return item

    def handle_error(self, failure):
        """错误处理回调 - Scrapy框架提供

        记录请求失败时的错误信息。

        Args:
            failure (twisted.python.failure.Failure): 错误对象
        """
        self.logger.error(f"请求失败: {failure.value}")


# 管道处理类 - 通常放在单独的pipelines.py文件中
class DuplicatesPipeline:
    """去重管道 - 防止重复数据

    使用车次号、出发时间和查询日期的组合作为唯一键进行去重。
    """

    def __init__(self):
        self.seen_trains = set()

    def process_item(self, item, spider):
        """处理数据项,去除重复项

        Args:
            item (TrainTicketItem): 待处理的数据项
            spider (TrainTicketSpider): 当前运行的爬虫实例

        Returns:
            TrainTicketItem: 若非重复项则返回原数据项

        Raises:
            scrapy.exceptions.DropItem: 若为重复项则抛出丢弃异常
        """
        train_key = f"{item['train_number']}-{item['departure_time']}-{item['query_date']}"
        if train_key in self.seen_trains:
            raise scrapy.exceptions.DropItem(f"重复车次: {train_key}")
        self.seen_trains.add(train_key)
        return item


class ValidationPipeline:
    """数据验证管道

    对爬取的数据进行基本验证和清洗,确保数据质量。
    """

    def process_item(self, item, spider):
        """验证并清洗数据项

        Args:
            item (TrainTicketItem): 待处理的数据项
            spider (TrainTicketSpider): 当前运行的爬虫实例

        Returns:
            TrainTicketItem: 清洗后的数据项

        Raises:
            scrapy.exceptions.DropItem: 若验证失败则抛出丢弃异常
        """
        # 验证必要字段
        if not item.get('train_number'):
            raise scrapy.exceptions.DropItem("车次号不能为空")

        if not item.get('departure_time') or not item.get('arrival_time'):
            raise scrapy.exceptions.DropItem("出发/到达时间不能为空")

        # 数据清洗
        for field in ['business_class', 'first_class', 'second_class']:
            if item.get(field) in ['', '无', '--']:
                item[field] = '无'

        return item


class JsonWriterPipeline:
    """JSON写入管道

    将处理后的数据项按JSON格式写入文件,每个爬虫运行生成一个独立文件。
    """

    def open_spider(self, spider):
        """爬虫启动时创建输出文件

        Args:
            spider (TrainTicketSpider): 当前运行的爬虫实例
        """
        import os
        if not os.path.exists('output'):
            os.makedirs('output')
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        self.filename = f'output/tickets_{timestamp}.json'
        self.file = open(self.filename, 'w', encoding='utf-8')
        self.file.write('[\n')
        self.first_item = True

    def close_spider(self, spider):
        """爬虫关闭时完成文件写入

        Args:
            spider (TrainTicketSpider): 当前运行的爬虫实例
        """
        self.file.write('\n]')
        self.file.close()
        spider.logger.info(f"数据已保存到: {self.filename}")

    def process_item(self, item, spider):
        """处理单个数据项并写入文件

        Args:
            item (TrainTicketItem): 待写入的数据项
            spider (TrainTicketSpider): 当前运行的爬虫实例

        Returns:
            TrainTicketItem: 返回原数据项以便后续管道处理
        """
        if not self.first_item:
            self.file.write(',\n')
        else:
            self.first_item = False

        line = json.dumps(dict(item), ensure_ascii=False, indent=2)
        self.file.write(line)
        return item


# 在文件顶部,导入语句之后添加
def setup_colored_logs():
    """设置彩色日志"""
    formatter = colorlog.ColoredFormatter(
        "%(log_color)s%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt=None,
        reset=True,
        log_colors={
            'DEBUG':    'cyan',
            'INFO':     'green',      # 将INFO级别设置为绿色
            'WARNING':  'yellow',
            'ERROR':    'red',
            'CRITICAL': 'red,bg_white',
        },
        secondary_log_colors={},
        style='%'
    )

    handler = logging.StreamHandler()
    handler.setFormatter(formatter)

    logger = logging.getLogger()
    logger.handlers.clear()
    logger.addHandler(handler)
    logger.setLevel(logging.INFO)


def run_crawler():
    """运行爬虫的便捷函数 - 支持运行时输入参数"""
    # 设置彩色日志
    setup_colored_logs()

    # 获取用户输入
    print("#" * 55)
    # 居中显示"请输入查询信息"
    prompt_text = "请输入查询信息"
    padding = (50 - len(prompt_text)) // 2
    print("#" + (" " * padding + prompt_text+ " " * padding) +"#")
    print("#" * 55)

    from_city = input("出发城市: ").strip()
    to_city = input("到达城市: ").strip()

    # 可选:获取查询日期
    query_date_input = input("请输入查询日期(YYYY-MM-DD,直接回车使用今天): ").strip()
    query_date = query_date_input if query_date_input else None

    # 验证输入
    if not from_city or not to_city:
        print("错误:出发城市和到达城市不能为空")
        return

    # 创建爬虫进程
    process = CrawlerProcess(TrainTicketSpider.custom_settings)

    # 启动爬虫
    process.crawl(TrainTicketSpider,
                  from_city=from_city,
                  to_city=to_city,
                  query_date=query_date)
    process.start()



# 运行示例
if __name__ == "__main__":
    # 注意:在脚本中直接运行Scrapy需要特殊配置
    # 通常通过命令行运行: scrapy crawl 12306_ticket -a from_city=北京 -a to_city=上海

    print("Scrapy重写完成!")
    print("使用说明:")
    print("1. 将代码保存为 train_ticket_spider.py")
    print("2. 确保存在 city.json 文件")


    # 运行交互式爬虫
    run_crawler()