简介:基于Python的旅游景点爬虫(去哪网)设计源码是一份面向爬虫初学者、旅游数据分析及毕业设计场景的实战项目。它以去哪网为目标站点,演示了景区信息采集、清洗与结构化存储的完整流程,覆盖国内多省市及亚洲、欧洲、美洲等区域的旅游数据组织方式。压缩包共40个文件,含2个Python主程序、29个xlsx景点数据文件、5个xml配置/数据文件及pyc、license、说明文档等,整体约1.56MB,目录按“大洲—国家/省份—城市”维度划分,便于检索与复用。目前已有349人学习或下载,适合希望快速上手爬虫项目、理解数据落盘与Excel/XML存储方案的读者。借助源码中的get.py与app.py,可掌握请求发送、页面解析、多线程调度及数据导出等关键技能,为后续扩展其他旅游平台爬虫提供直接参考。
1. 基于Python的旅游景点爬虫(去哪网)设计源码:先用最小脚本验证可行
拿到“基于Python的旅游景点爬虫(去哪网)设计源码”这个标题,多数人第一反应是“又要写一份爬虫了”,但真正动手的人会很快意识到,它真正的难点不在采集本身,而在去哪网页面的结构变化、反爬机制、数据清洗与去重策略。结合最近关于“网络爬虫原理”“requests爬虫”“爬虫并发设计到底哪个好”等讨论,我想把这条路线拆成四个连贯步骤:环境与最小可用代码、页面解析与字段设计、并发提速与代理方案、以及最后的落地验证。适合的人群很明确:已经能写简单脚本、但没系统做过“景点数据收集”这类完整任务的Python开发者和数据从业者,全文给出的命令和代码都需要在你的本地环境实际跑一遍,而不是看个逻辑就完事。
2. 爬虫选型与请求头伪装:把 requests 的默认行为改到能用的状态
2.1 为什么不选 Scrapy,而先选 requests 加 BeautifulSoup
“基于Python的旅游景点爬虫(去哪网)设计源码”这类项目在技术选型上有一个常见的分歧:有人一上来用 Scrapy,有人坚持用 requests 手工写。我一般会先选 requests,理由是它的调试链路短、出错点少,适合把“去哪网”某个城市榜单页的采集逻辑先跑通,再考虑要不要迁到 Scrapy 做分布式。
从工程角度看,Scrapy 的优势是并发调度和中间件体系,但它对新手最大的负担是“一切皆对象”,items、pipelines、middlewares 三层结构在项目只有十个字段时,属于维护成本的浪费。requests 加 BeautifulSoup 的组合,足够覆盖普通景点页、列表页和详情页,当你需要把采集能力横向扩展时,再按 Scrapy 的 Item 结构把现有解析代码搬过去,迁移成本也不高。注意这里有一个关键点:标题说的是“设计源码”,潜台词是代码结构要能给别人看、能在仓库里维护,所以函数拆分和配置外置,比单纯“能抓到数据”更重要。
2.2 用 requests 建立带重试的会话,用 fake_useragent 控制请求头
去哪网的页面通常对“无 UA 请求”和“高频请求”比较敏感,所以请求头伪装不是可选项,而是第一道门槛。常见做法是使用 fake_useragent 库随机生成 User-Agent,同时把 Accept-Language 固定为 zh-CN,避免默认的英文 UA 直接暴露非人类行为。
# 002_quna_request_headers.py import requests from fake_useragent import UserAgent from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() ua = UserAgent() session.headers.update({ "User-Agent": ua.random, "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate", "Connection": "keep-alive", }) retry = Retry( total=3, connect=3, read=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504], ) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) session.mount("https://", adapter) headers = session.headers print(headers["User-Agent"], headers["Accept-Language"])这段代码里,Retry 的 backoff_factor 是 0.5,含义是第一次重试等待 0.5 秒,第二次等待 1 秒,第三次 2 秒,避免一次性把请求打满导致 IP 被短时封禁。status_forcelist 里放 500 和 502 之类服务端错误,是因为这类状态码在“网页系统偶发过载”时重试成功率较高;而 403 这类反爬状态码千万别放进去,否则会反复用同一特征请求,加重被封风险。
2.3 探测页面结构:先抓一次 HTML 再写解析,别盲写选择器
写解析代码之前,我需要先确认目标页面实际返回的内容是什么样的。这一步看起来多余,但能帮你省掉大量的“解析不到数据”调试时间。关键点是:去哪网的很多列表页数据是服务端渲染的 HTML 字符串,少量接口是 JSON 接口,如果在 HTML 里找不到景点名称,就要去 Network 面板里找 XHR 请求。
curl -s -H "User-Agent: Mozilla/5.0" \ "https://piao.qunar.com/ticket/list.htm?keyword=北京®ion=&from=mpl_search_suggest" \ -o beijing_page.html wc -c beijing_page.html grep -o "景点名称" beijing_page.html | head -5如果 grep 没有输出,说明页面内容是异步接口渲染的,此时应该打开浏览器的开发者工具,哥几个筛选网络请求,把接口的 URL 复制下来,在 Python 脚本里模拟。这样做的意义是:你是在跟“网站实际提供的数据形态”合作,而不是跟“我以为的页面结构”合作。
3. 去哪网景点列表页解析与字段设计:把非结构化 HTML 变成规整数据
3.1 选择器的三种候选方案:BeautifulSoup、lxml、正则表达式
“去哪网旅游景点爬虫”的数据解析环节,核心问题不是“用什么库”,而是“页面结构变了你怎么快速调整”。BeautifulSoup 的优点是容错性高,即便 HTML 标签闭合不规范也能解析,适合快速试用;lxml 的 XPath 在字段多、层级深的时候比 CSS 选择器更好调试,因为它能准确定位到父节点再做条件过滤;正则表达式只适合提取像“景点 ID”这种高度规律的子串,不建议用它做整个列表页的解析。
我在这个项目中的选型是 lxml 加 XPath,理由有三:第一,去哪网的列表项结构是反复嵌套的 div,XPath 可以用 contains 做模糊匹配,减少对 class 名精确值的依赖;第二,XPath 可以直接用索引定位“搜索结果列表中的第几个”,这在页面结构调整时非常好用;第三,lxml 的解析速度在数据量变大时仍然稳定,不需要切到 Scrapy 的 Selector 层去补课。
3.2 列表页解析:用 XPath 提取景点名、简介、评分、地址
下面这段代码的目标是解析“去哪网景点列表页”中的核心字段,字段名与解析方式均与真实页面常见结构对应,但 class 名称已经做了脱敏处理,你在实际应用时要先从第一步的探测结果里替换成真实值。
# 003_parse_list_page.py from lxml import html import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" }) resp = session.get("https://piao.qunar.com/ticket/list.htm?keyword=北京") tree = html.fromstring(resp.text) items = tree.xpath('//div[contains(@class, "sight_item")]') print("解析到景点数量:", len(items)) for item in items[:3]: name = item.xpath('.//a[contains(@class, "name")]/text()') score = item.xpath('.//span[contains(@class, "score")]/text()') address = item.xpath('.//span[contains(@class, "address")]/text()') print(name, score, address)XPath 里contains(@class, "sight_item")是模糊匹配策略,只要 class 属性里包含这个子串就能命中,比精确等号更抗页面微调。这里的name字段如果返回空列表,大概率是景点名称并不在<a>标签的 text 里,而是在 title 属性里,这时把表达式改成.//a[contains(@class, "name")]/@title即可。
3.3 详情页数据结构设计:用字典加时间戳降低重复抓取成本
列表页数据只是“搜索结果的骨架”,完整项目还需要进入详情页去获取营业时间、建议游玩时长、优待政策等信息。这个阶段的设计建议是:不要直接往数据库写,先在内存里构造一个统一的 dict,把所有字段集中管理,后面再决定写 CSV 还是入库。
def build_sight_record(item_element): record = { "name": item_element.xpath('.//a[contains(@class, "name")]/text_or_title()'), "score": item_element.xpath('.//span[contains(@class, "score")]/text()'), "address": item_element.xpath('.//span[contains(@class, "address")]/text()'), "crawl_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } return record这里把crawl_time放进每条记录里,是为了后面用(景点名, 地址)作为组合主键做增量更新。注意:crawl_time 记录的是本次爬取时间,而不是景点的某个属性,后续做数据分析和定时抓取时,可以通过它过滤出“最近 7 天内的数据”作为增量集。
4. 去哪网爬虫的并发设计、IP 代理与 JSON 接口适配:从单线程到工程化
4.1 先处理列表页的分页规律,再考虑并发
去哪网景点搜索页的分页参数通常是page或p,第一页到第二页、第三页之间是单纯递增关系。用 requests 做并发前,先把分页 URL 构造函数写好:
def build_list_url(keyword, page): base_url = "https://piao.qunar.com/ticket/list.htm" params = { "keyword": keyword, "region": "", "from": "mpl_search_suggest", "page": page, } return base_url, params这个函数的意义在于:它是后续并发模块的统一入口,多线程、多进程或者异步协程全部复用同一个 URL 构造逻辑,避免在并发场景下每个线程各写一套地址拼接,导致参数不一致。注意:当页面总数量超过一定数值后,部分页面会要求登录或滑块验证,这是正常现象,不是代码 bug。
4.2 爬虫并发设计:requests 加 ThreadPoolExecutor 的折中方案
最近关于“爬虫并发设计到底哪个好”的争论集中在 asyncio 和 ThreadPoolExecutor 的选择上。针对去哪网这种 I/O 密集型任务、且绝大部分时间在等待网络响应的场景,我选择 ThreadPoolExecutor,因为 requests 是阻塞式库,异步化改造成本高,而线程池可以在不改变现有解析代码的前提下,把请求并发提升 5 到 10 倍。
# 004_concurrent_crawler.py from concurrent.futures import ThreadPoolExecutor, as_completed import requests def fetch_one(city_name, page): session = requests.Session() session.headers.update({"User-Agent": "Mozilla/5.0"}) url = f"https://piao.qunar.com/ticket/list.htm?keyword={city_name}&page={page}" resp = session.get(url, timeout=10) return resp city_list = ["北京", "上海", "成都"] with ThreadPoolExecutor(max_workers=5) as executor: futures = [] for city in city_list: for page in range(1, 3): futures.append(executor.submit(fetch_one, city, page)) for future in as_completed(futures): resp = future.result() print(resp.status_code, resp.url)max_workers 的取值在 5 到 8 之间比较合理。设小了,速度没有明显提升;设大了,比如 20 以上,会把本地网络带宽打满,同时更容易触发反爬。这里的timeout=10必须在生产代码里写,因为线程池任务如果一直挂起,as_completed 循环永远等不到结果,整个主程序会卡死。
4.3 IP 代理与请求频率控制:让你的爬虫不那么显眼
去哪网的反爬强度在三线旅游网站里处于中等水平,但“中等”意味着必须控制节奏,不能猛打。这个阶段常见做法是维护一个代理池,每次请求随机抽取一个代理 IP,并在请求之间加入随机的 sleep 时间。代理池的来源可以是付费代理服务商提供的 API 接口,也可以是自建代理验证脚本。
# 005_proxy_and_sleep.py import time import random proxies = [ {"http": "http://111.222.111.222:8080"}, {"http": "http://123.123.123.123:3128"}, ] def fetch_with_proxy(url): proxy = random.choice(proxies) session = requests.Session() session.headers.update({"User-Agent": "Mozilla/5.0"}) response = session.get(url, proxies=proxy, timeout=8) time.sleep(random.uniform(1, 3)) return responserandom.uniform(1, 3)的意思是每次请求之间随机等待 1 到 3 秒,比固定 sleep(2) 更自然,因为真实用户浏览页面的时间不会是绝对均匀的。代理 IP 在使用前最好做一次联通性测试,否则大量代理不可用的状态会让你的错误率陡增。
4.4 去哪儿网 JSON 接口的适配:比 HTML 解析更稳的数据源
去哪网部分页面(尤其是移动端页面和搜索建议接口)返回的是 JSON 结构,比 HTML 列表页更稳定、更利于字段抽取。使用 JSON 接口是很多老爬虫工程师的经验:数据字段是结构化 key-value,不需要在 HTML class 名变化后重写 XPath。我的做法是先用抓包工具找到接口 URL,再在代码里调用一次并检查返回结构。
import requests url = "https://piao.qunar.com/ticket/detailLight.json" params = { "id": "144087", "from": "mobile", } resp = requests.get(url, params=params, headers={"User-Agent": "Mozilla/5.0"}) data = resp.json() print(type(data), data.keys()) if data.get("data"): item_info = data["data"] print(item_info.get("sightName"), item_info.get("price"))JSON 接口的好处是字段名直观,比如sightName、price、address一目了然;坏处是接口参数和数据格式可能不定期调整。所以源码设计里,必须把 JSON 解析和 HTML 解析拆成两个独立模块,当其中一个失效时,另一个还能继续跑,并提供切换开关。
5. 数据落地与去重策略:CSV、SQLite 与增量抓取
5.1 CSV 是最快速的数据交付方式
去哪网景点数据量在几百到几千条之间时,CSV 是最合适的落地格式,方便直接丢给 Excel 或 pandas 分析。写入时需要注意编码问题,Windows 下 Excel 打开 CSV 默认使用 GBK 编码,所以写 CSV 时最好用encoding="utf-8-sig",这样文件头部会加上 BOM,Excel 能正确识别。追加写入时,用 a 模式,并且先把表头判断的逻辑写好,避免文件里出现多个表头行。
# 006_save_to_csv.py import csv fieldnames = ["name", "score", "address", "crawl_time"] def save_records_to_csv(records, file_path): file_exists = os.path.exists(file_path) with open(file_path, "a", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) if not file_exists: writer.writeheader() writer.writerows(records)这里的file_exists检查作用很关键:每次采集完成后的记录,如果文件不存在就写表头,如果文件存在就直接追加,不做全量重写。这样做的好处是采集中断后重跑,历史数据不会丢,只要做一次“重复记录去重”就行。
5.2 SQLite 与按景点名加地址去重
当数据量增长到几千条,且你要做多次增量采集时,CSV 的去重逻辑会变得很笨拙。建议换成 SQLite,它不需要额外安装数据库服务,Python 内置 sqlite3 模块直接可用,适合单机数据量在百万以内的项目。
# 007_save_to_sqlite.py import sqlite3 conn = sqlite3.connect("qunar_attractions.db") cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS attractions ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, score TEXT, address TEXT, crawl_time TEXT, UNIQUE(name, address) ) """) def insert_record(record): cursor.execute(""" INSERT OR REPLACE INTO attractions (name, score, address, crawl_time) VALUES (:name, :score, :address, :crawl_time) """, record) conn.commit()这里用UNIQUE(name, address)来保证同一个景点的名称和地址组合唯一,INSERT OR REPLACE的意思是:如果该组合已经存在,就用新数据替换旧数据,同时更新 crawl_time。这个做法的效果是“数据只进不出”,运行十次也不会产生重复行,适合定时巡检场景。
5.3 增量抓取的字段设计:用最近更新时间做合并
增量抓取的设计不复杂:每次抓取前,先从库里查一下最近一次max(crawl_time),然后只抓取列表中“发布时间”或“更新时间”晚于该时间的记录。对于去哪网景点数据而言,如果 API 没有明确提供“更新时间”字段,就退而求其次:每次全量抓列表页,但详情页的内容只在“价格或评分变化”时才更新。
这个策略在企业项目里叫“变更捕获”,它比“定时全量更新”省资源,也比“完全不更新”更可控。具体实现时,在 SQLite 中加一个last_seen_time字段,每次抓取后把当前时间写入,下次比较时就能区分“新数据”和“已有数据”。
6. 用 Selenium 兜底验证与源码梳理技巧
6.1 什么时候必须从 requests 切换到 Selenium
去哪网的正常反爬强度和页面复杂度,requests 基本能覆盖,但如果你连续抓取多城市景点数据,或者访问了需要 JS 动态渲染的“景点详情地图”部分,requests 就会遇到空标签或未渲染出的字段。常见做法是:先用 requests 跑一条城市数据,用 XPath 输出字段,如果发现字段全为空,就使用 Selenium 加载真实浏览器环境来兜底验证。这不是一上来就放弃 requests,而是把它作为“最后一公里的验证工具”。
# 008_selenium_verify.py from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--headless") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") driver = webdriver.Chrome(options=options) driver.get("https://piao.qunar.com/ticket/list.htm?keyword=北京") time.sleep(3) items = driver.find_elements("xpath", '//div[contains(@class, "sight_item")]') print(len(items)) driver.quit()这段代码的核心不是“能用 Selenium 抓数据”,而是“验证 requests 解析不到的数据是否来源于 JS 渲染”。如果 Selenium 能解析到,说明你的 requests 请求缺少了必要的参数或 Cookie;如果 Selenium 也解析不到,说明页面结构变了,需要回到浏览器开发者工具重新找接口。使用 Selenium 时,建议把页面截图或当前 URL 输出保存下来,方便后续核对。
6.2 源码文件模块怎么划分
一套完整的去哪网爬虫“设计源码”,推荐按模块文件拆分,哪怕本地先跑通,也建议保持这种分层,方便后续迁移到定时任务或 Scrapy:
qunar_crawler/ ├── config.py ├── request_handler.py ├── parser.py ├── storage.py ├── scheduler.py └── main.pyconfig.py 放每个城市的 URL 模板、请求头、重试次数、sleep 范围;request_handler.py 负责 requests 会话、重试和代理;parser.py 专职 XPath 和 JSON 响应解析;storage.py 封装 CSV 和 SQLite 的写入逻辑;scheduler.py 负责并发调度和抓取频率控制;main.py 把以上流程组装起来。这个结构的好处是:去哪网页面结构一旦调整,只需要改 parser.py 和 config.py,其他模块原封不动。这也符合标题里“设计源码”隐含的诉求——不是写完就扔的一次性脚本,而是能持续崩、持续修的工程小模块。
本文还有配套的精品资源,点击获取