侧边栏壁纸
博主头像
泡泡吐puber 博主等级

在这里,吐个有趣的泡泡🫧

  • 累计撰写 13 篇文章
  • 累计创建 7 个标签
  • 累计收到 23 条评论

目 录CONTENT

文章目录

Python实战:三步构建一个稳定、高效的携程游记爬虫(多线程+面向对象)

泡泡吐puber
2025-09-24 / 0 评论 / 0 点赞 / 19 阅读 / 0 字 / 正在检测是否收录...

前言

在数据驱动的今天,网络上蕴藏着海量有价值的信息。作为中国领先的在线旅游平台,携程的游记频道包含了大量关于旅行目的地、行程规划、人均花费的真实数据。如果我们能将这些数据结构化地抓取下来,就可以进行有趣的数据分析,比如分析某个城市最受欢迎的景点、不同季节的旅行成本变化等等。

本文旨在提供一个清晰、完整且高度可复用的爬虫解决方案。我们将以爬取“景德镇”的旅行游记为例,最终实现一个可以轻松扩展到任何城市的自动化数据采集器。

核心技术栈

  • HTTP请求: requests
  • HTML解析: lxml
  • 并发处理: multiprocessing.dummy (线程池)
  • 反爬策略: fake_useragent (随机User-Agent)
  • 数据存储: openpyxl (操作Excel)
  • 代码结构: 面向对象编程 (OOP)

爬虫设计思路:三步走的策略

一个稳健的爬虫项目,通常可以分为三个核心部分:数据获取数据解析数据存储。我们的代码也将围绕这三个步骤展开,并将它们封装在一个 CtripTravelScraper 类中,以实现高度模块化。

  1. 数据获取 (_make_request): 负责与网站服务器打交道。它需要足够“聪明”和“礼貌”,能够模拟浏览器行为,处理网络异常,并在失败时自动重试。
  2. 数据解析 (_parse_list_page, _parse_detail_page): 获取到HTML源码后,我们需要像侦探一样,从中筛选并提取出我们关心的信息,如标题、作者、花费、游玩地点等。
  3. 数据存储 (save_data_to_excel): 将解析出的非结构化数据,整理成规范的格式,并永久保存在Excel文件中,方便后续使用。

代码详解

第一步:封装一个强大的请求模块

网络请求是爬虫的生命线。为了应对携程可能的反爬策略和网络波动,我们的 _make_request 方法做了三件事:

  1. 随机User-Agent: 通过 fake_useragent 库,我们的爬虫在每次请求时都会伪装成一个不同的、真实的浏览器,大大降低了被识别为机器人的风险。
  2. 设置Referer: 模拟用户是从携程主站点击进入游记列表的,增加了请求的“可信度”。
  3. 异常处理与重试: 使用 try...except 块捕获所有可能的 RequestException,并在请求失败时,自动进行多次重试,保证了爬虫的稳定性。
# headers中的关键设置
headers = {
    'cookie': '',  # 【关键】请务必填入您自己的有效cookie
    'referer': 'https://you.ctrip.com',
    'User-Agent': UserAgent().random  # 每次请求都使用随机UA
}

# ...
# 重试逻辑
for i in range(attempt_limit):
    try:
        response = requests.get(url, headers=headers, timeout=self.request_timeout)
        response.raise_for_status() # 状态码非200则抛出异常
        return response.text
    except RequestException as e:
        print(f"URL: {url} 请求失败 (第 {i+1} 次尝试): {e}")
        if i < attempt_limit - 1:
            time.sleep(5) # 等待5秒后重试

注意cookie 是应对当前网站反爬最关键的环节。请务必在浏览器中登录你的携程账号,并通过开发者工具(F12)获取你的cookie,并粘贴到代码中。

第二步:精准解析,提取数据

携程的游记数据分散在列表页详情页。我们采用“先抓列表,再补全详情”的策略。

  • 解析列表页 (_parse_list_page):

    我们使用 lxml 的 xpath 语法来定位页面元素。XPath就像是HTML的“寻宝图”,可以让我们精确地找到每一个标题、链接和作者信息。我们发现,大部分关键信息(天数、花费、日期)都集中在一个元数据块中,因此我们先将整个块的文本提取出来,再用正则表达式 (re.search) 分别匹配,这种方法比依赖固定的HTML结构更稳健。

  • 解析详情页 (_parse_detail_page):

    在获取到每篇游记的独立链接后,我们再次发起请求,进入详情页。在这里,我们主要抓取游记正文摘要和其中提到的游玩地点 (POI)。

# 示例:使用XPath定位所有游记项目
articles = tree.xpath('//a[@class="journal-item cf"]')

# 示例:使用正则表达式从文本块中提取花费
cost = re.search(r'¥(\d+)/人', meta_text)
avg_cost_person = cost.group(1) if cost else 'N/A'

第三步:多线程加速与数据持久化

顺序地一页一页爬取、一篇一篇处理,效率会非常低下。这里我们引入了multiprocessing.dummy库,它提供了一个与多进程库接口完全相同的线程池

  • 并发执行 (run方法):

    我们创建一个线程池,并将所有需要爬取的页码(例如2到100页)作为一个任务列表,交给线程池的 pool.map() 方法。map 方法会自动为每个页码分配一个线程去执行我们的 scrape_single_page 函数。这样一来,原本需要数小时的任务,可能几分钟就能完成。

with Pool(self.thread_pool_size) as pool:
    # map会自动处理任务分发和结果回收
    results_from_all_pages = pool.map(self.scrape_single_page, page_numbers_to_scrape)

# 将所有线程返回的结果合并
for page_result in results_from_all_pages:
    self.scraped_data.extend(page_result)
  • 保存到Excel (save_data_to_excel):

    爬取到的数据最终需要一个好的归宿。我们使用 openpyxl 库来创建一个Excel工作簿。代码会自动创建表头,遍历我们收集到的所有数据,并逐行写入。最后,我们还贴心地加入了一个自动调整列宽的功能,让最终生成的表格既美观又易于阅读。

如何使用这个爬虫?

我们的代码被设计为“开箱即用”,只需简单四步即可爬取任意城市的数据。

步骤1:安装依赖库

首先,确保你的Python环境中安装了所有必需的库。

pip install requests lxml openpyxl fake-useragent

步骤2:获取你的个人Cookie

这是应对简单反爬虫机制关键的一步。

  1. 在Chrome或Edge浏览器中,访问并登录 https://you.ctrip.com
  2. F12 键打开“开发者工具”。
  3. 切换到“网络 (Network)”标签页。
  4. 刷新一下页面,在请求列表中随便点击一个,然后在右侧找到“请求标头 (Request Headers)”,向下滚动找到 cookie 字段,并复制它完整的长字符串值

步骤3:确定目标城市的参数 (city_pinyin 和 city_code)

image-20250924174802419

要爬取不同的城市,你需要找到它在携程网址中的专属代号。

  1. 打开携程旅行网:https://you.ctrip.com
  2. 在顶部的搜索框中,输入您想爬取的城市,例如 “北京”,然后搜索。
  3. 点击进入北京的游记列表页。
  4. 关键一步:观察浏览器地址栏的URL。 您会看到类似这样的地址: https://you.ctrip.com/travels/beijing1/
  5. 从URL中提取参数
    • beijing1 中,beijing 就是 city_pinyin1 就是 city_code
    • 同理,如果您搜索“成都”,它的URL是 .../travels/chengdu104/,那么 city_pinyin 就是 chengducity_code 就是 104

步骤4:配置并运行脚本

现在,你可以配置并运行下面提供的完整代码了。

  1. 修改Cookie: 将步骤2中复制的Cookie字符串,粘贴到 _make_request 方法内 headers 字典的 cookie 字段中。
  2. 修改城市和页码: 在代码最下方的 if __name__ == "__main__": 部分,根据步骤3找到的参数,修改 city_pinyincity_code。同时,你也可以自由设置 start_page (起始页) 和 end_page (结束页)。

例如,要爬取北京的第1到5页游记:

# 找到代码最后的部分,进行如下修改
scraper = CtripTravelScraper(
    city_pinyin='beijing', 
    city_code=1, 
    start_page=1, 
    end_page=5
)
scraper.run()
  1. 运行脚本:

    python your_script_name.py
    

    稍等片刻,一个名为 携程旅行见闻.xlsx 的文件就会出现在你的项目目录中,里面包含了所有抓取到的数据!

总结

至此,我们不仅构建了一个功能强大的携程游记爬虫,更重要的是,我们学习了一套完整的爬虫项目设计思想:从面向对象的结构设计,到多线程的效率优化,再到反爬策略和数据存储的实践。希望这篇文章能为你打开数据抓取世界的大门,并为你未来的学习提供有价值的参考。

如果你有任何问题或改进建议,欢迎在评论区留言交流!


完整代码

下面是可以直接复制并运行的完整Python脚本。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
携程旅行见闻自动化采集器
通过面向对象与多线程技术,实现对指定目的地旅行见闻的高效抓取与数据持久化。
"""

import time
import requests
import openpyxl
import re
from lxml import etree
from multiprocessing.dummy import Pool
from requests.exceptions import RequestException
from fake_useragent import UserAgent

class CtripTravelScraper:
    """
    一个用于爬取携程游记数据的类。
    通过初始化配置,可以启动一个多线程爬虫任务,并将结果保存到Excel。
    """
    def __init__(self, city_pinyin, city_code, start_page=1, end_page=2, pool_size=3, retries=3, output_filename='携程旅行见闻.xlsx'):
        """
        初始化爬虫配置
        :param city_pinyin: 城市拼音 (例如: 'jingdezhen')
        :param city_code: 城市代码 (例如: 405)
        :param start_page: 起始页码
        :param end_page: 结束页码
        :param pool_size: 线程池大小
        :param retries: 请求失败重试次数
        :param output_filename: 输出文件名
        """
        self.base_url_template = f'https://you.ctrip.com/travels/{city_pinyin}{city_code}/t3-p{{}}.html'
        self.start_page = start_page
        self.end_page = end_page
        self.thread_pool_size = pool_size
        self.retry_attempts = retries
        self.request_timeout = 20
        self.output_file = output_filename
        self.scraped_data = [] # 用于线程安全地收集数据

    def _make_request(self, url, attempt_limit=None):
        """
        执行HTTP GET请求,内置重试和随机User-Agent机制。
        """
        if attempt_limit is None:
            attempt_limit = self.retry_attempts

        headers = {
            'cookie': '',  # 【关键】请务必填入您自己的有效cookie
            'referer': 'https://you.ctrip.com',
            'User-Agent': UserAgent().random  # 每次请求都使用随机UA
        }
        
        for i in range(attempt_limit):
            try:
                response = requests.get(url, headers=headers, timeout=self.request_timeout)
                response.raise_for_status() # 如果状态码不是200, 则引发HTTPError
                return response.text
            except RequestException as e:
                print(f"URL: {url} 请求失败 (第 {i+1} 次尝试): {e}")
                if i < attempt_limit - 1:
                    time.sleep(5) # 等待5秒后重试
        return None

    def _parse_detail_page(self, article_url, base_info):
        """
        解析游记的详情页面,提取正文内容、游玩地点等补充信息。
        """
        html_content = self._make_request(article_url)
        if not html_content:
            return base_info

        try:
            tree = etree.HTML(html_content)
            
            # 提取正文摘要
            content_nodes = tree.xpath('//div[@class="ctd_content"]//text()')
            full_content = ' '.join(text.strip() for text in content_nodes if text.strip())
            
            # 提取游玩过的地点
            poi_nodes = tree.xpath('//div[@class="ctd_content"]//a[contains(@class, "poi")]/text()')
            places_visited = '、'.join(place.strip() for place in poi_nodes if place.strip())
            
            # 更新基础信息字典
            base_info.update({
                'places_visited': places_visited,
                'content_summary': full_content[:500].strip() + '...' if len(full_content) > 500 else full_content.strip()
            })
        except Exception as e:
            print(f"解析详情页 {article_url} 出错: {e}")
        
        return base_info

    def _parse_list_page(self, html_content):
        """
        从列表页HTML中解析出各个游记的基础信息。
        """
        tree = etree.HTML(html_content)
        articles = tree.xpath('//a[@class="journal-item cf"]')
        
        parsed_articles = []
        for article_node in articles:
            try:
                title = article_node.xpath('.//dt[@class="ellipsis"]/text()')[0].strip()
                article_link = 'https://you.ctrip.com' + article_node.xpath('./@href')[0]
                
                # 解析元数据块
                meta_elements = article_node.xpath('.//dd[@class="item-extra"]/*')
                meta_text = ' '.join(el.text.strip() for el in meta_elements if el.text)

                days = re.search(r'(\d+)天', meta_text)
                travel_date = re.search(r'(\d{4}-\d{2}-\d{2})', meta_text)
                cost = re.search(r'¥(\d+)/人', meta_text)
                
                # 解析作者信息
                author_text = article_node.xpath('.//dd[@class="item-user"]/text()')[0].strip()
                author_match = re.search(r'(.+?)发表于', author_text)
                author = author_match.group(1).strip() if author_match else author_text
                
                article_data = {
                    'title': title,
                    'url': article_link,
                    'days_span': days.group(0) if days else 'N/A',
                    'publish_date': travel_date.group(0) if travel_date else 'N/A',
                    'avg_cost_person': cost.group(1) if cost else 'N/A',
                    'author': author
                }
                parsed_articles.append(article_data)
            except Exception as e:
                print(f"解析列表中的某个项目时出错: {e}")
                
        return parsed_articles

    def scrape_single_page(self, page_number):
        """
        负责抓取并处理单个页码的所有游记。
        """
        page_url = self.base_url_template.format(page_number)
        print(f"开始抓取第 {page_number} 页: {page_url}")
        
        list_html = self._make_request(page_url)
        if not list_html:
            print(f"未能获取第 {page_number} 页的内容,跳过。")
            return []
            
        articles_on_page = self._parse_list_page(list_html)
        print(f"第 {page_number} 页发现 {len(articles_on_page)} 篇游记。")
        
        page_results = []
        for article_base_info in articles_on_page:
            print(f"  -> 正在处理详情页: {article_base_info['title']}")
            detailed_info = self._parse_detail_page(article_base_info['url'], article_base_info)
            page_results.append(detailed_info)
            time.sleep(1.5) # 友好爬取,增加请求间隔
            
        return page_results

    def save_data_to_excel(self):
        """
        将收集到的所有数据写入Excel文件。
        """
        if not self.scraped_data:
            print("警告:没有收集到任何数据,无法生成Excel文件。")
            return

        workbook = openpyxl.Workbook()
        sheet = workbook.active
        sheet.title = "携程游记数据"

        # 定义并写入表头
        headers = [
            '标题', '链接', '旅行天数', '发布日期', '人均花费(元)', 
            '作者', '游玩地点', '内容摘要'
        ]
        sheet.append(headers)

        # 写入数据行
        for item in self.scraped_data:
            row_data = [
                item.get('title', ''),
                item.get('url', ''),
                item.get('days_span', ''),
                item.get('publish_date', ''),
                item.get('avg_cost_person', ''),
                item.get('author', ''),
                item.get('places_visited', ''),
                item.get('content_summary', '')
            ]
            sheet.append(row_data)

        # 自动调整列宽
        for column_cells in sheet.columns:
            max_len = 0
            column = column_cells[0].column_letter
            for cell in column_cells:
                try:
                    cell_len = len(str(cell.value))
                    if cell_len > max_len:
                        max_len = cell_len
                except:
                    pass
            adjusted_width = (max_len + 2) if max_len < 50 else 50
            sheet.column_dimensions[column].width = adjusted_width

        workbook.save(self.output_file)
        print(f"任务完成!数据已成功保存至: {self.output_file}")
        
    def run(self):
        """
        启动爬虫主程序。
        """
        start_time = time.time()
        print("="*50)
        print("携程旅行见闻采集器已启动")
        print(f"目标页码范围: 从 {self.start_page} 到 {self.end_page}")
        print(f"线程数: {self.thread_pool_size}")
        print("="*50)

        page_numbers_to_scrape = range(self.start_page, self.end_page + 1)
        
        with Pool(self.thread_pool_size) as pool:
            # map会阻塞,直到所有任务完成
            results_from_all_pages = pool.map(self.scrape_single_page, page_numbers_to_scrape)
        
        # 将嵌套列表展开
        for page_result in results_from_all_pages:
            self.scraped_data.extend(page_result)

        print(f"\n所有页面处理完毕,共收集到 {len(self.scraped_data)} 条数据。")
        self.save_data_to_excel()
        
        end_time = time.time()
        print(f"总耗时: {end_time - start_time:.2f} 秒。")

if __name__ == "__main__":
    # ========== 爬虫实例化与启动 ==========
    # 配置目标城市 (景德镇) 和爬取范围
    scraper = CtripTravelScraper(
        city_pinyin='jingdezhen', 
        city_code=405, 
        start_page=2, 
        end_page=3
    )
    scraper.run()

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区