简介:一套面向去哪网的旅游景点爬虫设计源码,基于Python实现,定位明确,适合Python爬虫初学者、旅游数据分析者以及需要批量获取景点信息的开发者。压缩包共40个文件、约1.56MB,主体包括2个Python脚本负责请求与解析,29个xlsx文件存放国内外多省市及国家的景点数据,5个xml文件用于配置与数据描述,另有说明文档、项目配置和编译缓存文件,配套说明便于快速导入开发环境。源码围绕去哪网抓取景点名称、位置、价格、用户评价等结构化信息,采集范围涵盖国内多省及日本、德国、美国、法国等目的地,可直接运行生成Excel表格,也可作为学习Requests、BeautifulSoup等爬虫库用法和数据清洗落地的教学案例。已有349人学习/下载,对于想快速搭建旅游数据采集工具、研究站点爬取策略或入门爬虫项目的读者都有实用参考价值。

相关服务:日本CN2服务器租用

1. 去哪网景点爬虫:一个能落地的数据样本

去哪网景点页把评分、点评数、地址散落在不同区块,手动逐条复制效率太低。这个项目用 Python 写了一个针对去哪网景点列表页的爬虫,通过 requests 抓取页面、BeautifulSoup 解析 HTML,再按城市和国家把结果写进 29 个 xlsx 文件。拿到它之后,你能直接看到一次完整爬虫的数据闭环,也可以用同一套逻辑生成旅游分析用的数据集。适合刚学完 Python 基础语法、想找一个不依赖 Scrapy 的小型爬虫项目练手的人,也适合做旅游数据统计、城市热门景点对比的从业者。真正值得细读的不是那几行抓取代码,而是它如何处理网页改版、字段缺失、重复抓取这些常见问题。

2. 拆解 get.py:requests 抓取与 BeautifulSoup 解析

2.1 先去哪网页面结构

去哪网的景点列表页 URL 带有 keyword 参数,比如 https://piao.qunar.com/ticket/list.htm?keyword=北京 。对爬虫来说,这意味着只需要替换 keyword 就能循环抓取多个城市,不需要处理复杂的翻页 token。要理解网络爬虫原理,先得看清楚这里的分工:requests 负责把页面 HTML 拿下来,BeautifulSoup 负责把 HTML 转成可查询的树,两者之间用响应的 text 字符串连接。

请求时至少要带两样东西:User-Agent 和 Referer。User-Agent 用来声明客户端类型,Referer 告诉服务器页面来源,缺了 Referer,很多站点会直接返回 403。常见做法是维护一个模块级 HEADERS 字典,避免在每个请求里重复写。Cookie 可以先不处理,等出现登录跳转时再从浏览器复制,复制后要放在这个字典里一起发送。

除了 keyword 参数,列表页 URL 通常还带 region、from 等追踪参数。手动测试时,先从浏览器复制完整 URL,再用 params 字典逐项拆开,而不是把一长串 URL 直接拼进代码。这样切换城市时只需要改 keyword 一个值,其他参数保持不变,请求格式也更接近真实浏览器。

2.2 请求函数为什么不直接用 requests.get

直接调 requests.get 遇到网络抖动会抛异常,一旦异常没有捕获,整个城市循环就会中断。更稳妥的方式是包一层带重试的 fetch_html ,把 timeout、编码、重试都收进去。

import requests
from time import sleep

# 请求头尽量贴近真实浏览器,缺少 Referer 容易被拦截
HEADERS = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 Chrome/125.0 Safari/537.36",
    "Referer": "https://piao.qunar.com/ticket/list.htm",
    "Accept-Language": "zh-CN,zh;q=0.9",
}

def fetch_html(session, url, params=None, retry=3):
    for i in range(retry):
        try:
            resp = session.get(url, params=params, headers=HEADERS, timeout=10)
            if resp.status_code == 200:
                resp.encoding = resp.apparent_encoding or "utf-8"
                return resp.text
            sleep(1 + i)
        except requests.RequestException:
            sleep(1 + i)
    return None

这里有几个参数值得记住: timeout=10 防止某个请求长时间挂起; resp.apparent_encoding 用字符探测替代固定编码,应对中文乱码; retry=3 表示最多重试三次,重试间隔分别是 1 秒、2 秒、3 秒。如果三次都失败,函数返回 None,调用方可以根据 None 跳过这个城市,而不是直接崩溃。

2.3 解析景点卡片:选择器与容错

去哪网的景点卡片通常集中在 div.sight_item 里,名称、评分、点评数分别在 a.name 、 span.score strong 、 span.comment_count strong 。这里的 class 命名比较直白,但不同时期页面会有调整,所以解析时不要假设字段一定存在。

from bs4 import BeautifulSoup

def parse_spot_items(html):
    soup = BeautifulSoup(html, "html.parser")
    items = []
    for card in soup.select("div.sight_item"):
        name = card.select_one("a.name")
        grade = card.select_one("span.score strong")
        comment_num = card.select_one("span.comment_count strong")
        items.append({
            "name": name.get_text(strip=True) if name else "",
            "grade": grade.get_text(strip=True) if grade else "",
            "comment_num": int(comment_num.get_text(strip=True)) if comment_num and comment_num.get_text(strip=True).isdigit() else 0,
        })
    return items

解析的原则是:取不到就用空字符串或 0 兜底,不要让程序中断。 comment_num 要先判断 isdigit() ,因为页面有时把“1.2万”写成文本,直接 int 会抛异常。这个容错习惯,是爬虫工程师知识里比较基础但很重要的一条。

如果页面改版导致 sight_item 消失,第一阶段不要急着改选择器,先把 HTML 打印出来看结构。下面的表格是常用的定位方式,可以作为排查起点。

数据项 建议选择器 说明
景点卡片 div.sight_item 列表页每个景点的最外层容器
景点名称 a.name 可能有多级,取第一个
评分 span.score strong 数字型文本,保留两位小数
点评数 span.comment_count strong 有时是“条点评”格式,需要清洗

2.4 为什么不用 Scrapy 或分布式爬虫

这个项目规模小,几十个城市同步请求也就是几分钟的事,用 requests 加 BeautifulSoup 足够。Scrapy 的优势在于中间件、Pipeline、分布式调度,但这需要额外学习 Item、Loader 等概念,调试成本对入门者偏高。先用手写方式把请求、解析、落盘三个环节跑通,之后再迁移到 Scrapy 也容易,因为解析函数可以直接复用。只有当天级别数据量到几万条,或者需要多台机器共同抓取时,才值得讨论分布式爬虫的设计。

3. xlsx 落地与 app.py 调度:29 个文件是怎么来的

3.1 选择 Excel 而不是 CSV

项目里每个城市或国家对应一个 xlsx,说明作者特意选了 Excel 格式。相比 CSV,xlsx 可以直接用 Excel/WPS 打开,表格样式、sheet 结构都能保存,非技术人员查看起来没有编码障碍。用 pandas 写 xlsx 也很简单,但要注意引擎问题:写入单个文件用 engine="openpyxl" ,追加 sheet 时才不会覆盖已有内容。

3.2 字段设计参考

爬虫不仅要抓数据,还要设计好存储字段。字段太少,后续做分析时缺上下文;字段太多,页面一改就到处报错。以下是这个项目里常见字段的一个合理设计:

字段名 含义 示例值 抓取来源
name 景点名称 故宫 列表页 a.name
grade 评分 4.7 列表页 span.score strong
comment_num 点评数 81234 列表页 span.comment_count strong
city 所属城市 北京 抓取时传入的 keyword
url 景点地址或链接 /ticket/49.html 卡片 a 的 href

加上 city 字段后,多个文件合并时不会丢失城市信息; url 字段为后续爬详情页留了入口。如果爬虫只存名称和评分,后面做城市对比时还得重新抓一遍,这是新手最容易漏掉的一步。

3.3 用 pandas 写 xlsx 的坑

直接写入 DataFrame 时,要处理空数据和目录不存在两个问题。空数据会导致只生成表头,后续合并时会被当成有效记录;目录不存在则直接抛 FileNotFoundError。

import pandas as pd
from pathlib import Path

def save_to_excel(items, filepath):
    df = pd.DataFrame(items)
    if df.empty:
        return False
    # 目录必须存在,openpyxl 不会自动创建
    Path(filepath).parent.mkdir(parents=True, exist_ok=True)
    with pd.ExcelWriter(filepath, engine="openpyxl") as writer:
        df.to_excel(writer, index=False, sheet_name="scenic")
    return True

调用时传入列表字典,函数返回 True 或 False。返回 False 时,调度层可以打印“城市抓取为空”,而不是生成一个空文件。这里用 Path(filepath).parent.mkdir 创建目录,即使 filepath 直接写 beijing_info.xlsx , .parent 是空路径, mkdir 也不会报错,因为 exist_ok=True 。

3.4 app.py 的调度逻辑

get.py 负责抓取和解析,app.py 更像是调度入口:遍历城市配置,循环调用 get.py 的函数,把结果写成 xlsx。常见做法是维护一个城市映射表,这样要加城市,只需新增一行。

CITY_KEYWORDS = {
    "beijing": "北京",
    "chengdu": "成都",
    "hongkong": "香港",
}

def main():
    for city, keyword in CITY_KEYWORDS.items():
        html = fetch_html(session, BASE_URL, params={"keyword": keyword})
        items = parse_spot_items(html) if html else []
        print(f"{city}: {len(items)} 条")
        save_to_excel(items, f"{city}_info.xlsx")

这里的 session 建议在 main 顶层创建一次,传给每个调用函数。requests 的 Session 会自动保存 Cookie,连续请求同一个域名时能减少重复握手。 print 输出条数看似简单,但能帮助快速判断哪个城市抓取异常。如果某个城市返回 0,先检查 keyword 是否传对了,requests 的 params 会自动做 URL 编码,不需要手动 quote。

3.5 项目里 xml、pyc 和 city_info 是什么

项目根目录出现大量 xml,比如 .idea/vcs.xml 、 .idea/workspace.xml ,这些是 PyCharm 工程配置,和爬虫逻辑没有关系。 get.pyc 是 get.py 编译后的字节码文件,Python 运行时会根据源码自动生成,可以删除。真正需要关心的是 city_info 这个目录名,它大概率保存城市配置或临时抓取结果。运行前应该先打开 city_info 看有没有 readme,里面可能写着 URL 参数模板和抓取间隔建议。

4. 运行调试与并发控制:让爬虫更可控

4.1 从零跑起项目的完整步骤

拿到源码后,先不要急着改代码,按以下步骤走:

  1. 确认本机 Python 版本在 3.8 以上,执行 python --version 。
  2. 安装依赖: pip install requests beautifulsoup4 pandas openpyxl 。
  3. 用浏览器打开去哪网景点列表页,复制页面里的 Cookie 到 HEADERS(如果需要登录)。
  4. 运行 python app.py ,观察每个城市打印的条数。
  5. 检查生成的 beijing_info.xlsx ,确认名称、评分、点评数几列有值。
python --version
pip install requests beautifulsoup4 pandas openpyxl
python app.py
ls -lh *_info.xlsx

如果网络波动导致 pip 安装慢,可以加清华镜像参数。运行后如果某个城市没有生成 xlsx,优先看它打印的是 0 还是异常堆栈。0 条说明解析或请求有问题,异常堆栈说明代码中断了。

4.2 常见异常与 403 处理

爬虫运行中遇到最多的是 403 和解析为空。403 通常是请求头不被接受,解析为空通常是页面结构变化。下面的表格可以帮你快速定位:

异常现象 常见原因 处理动作
requests.exceptions.ConnectTimeout 目标服务器响应慢 加大 timeout 到 20,重试
HTTP 403 缺少 Referer/Cookie 补全请求头,刷新 Cookie
解析结果全为空 页面改版,选择器失效 打印 HTML 前 500 字,确认新结构
抓取条数突然减半 翻页逻辑或懒加载 检查是否有 Ajax 接口

调试时不要反复请求线上页面,可以先保存一次 HTML 到本地,再用 BeautifulSoup 离线调试。常见做法是写一个临时脚本,把页面文本写入 log.html,然后用编辑器查找关键词。

4.3 限速与重试:比并发更重要

去哪网对高频请求比较敏感,连续短时间请求很容易触发风控。最常见的处理是每个请求之间随机睡 1 到 3 秒,让流量看起来更像人工操作。

from random import uniform
import time

def fetch_with_limit(session, url, params):
    time.sleep(uniform(1, 3))  # 随机延时,避免规律性请求
    try:
        resp = session.get(url, params=params, headers=HEADERS, timeout=10)
        if resp.status_code == 200:
            return resp.text
        elif resp.status_code in (403, 429):
            # 被限流时退避更久,这里做一次重试
            time.sleep(uniform(3, 6))
            resp = session.get(url, params=params, headers=HEADERS, timeout=10)
            if resp.status_code == 200:
                return resp.text
    except requests.RequestException:
        pass
    return None

注意递归重试要设置最大次数,否则极端情况下会无限递归。上面用显式第二次请求替代递归,逻辑更清楚。403 和 429 都表示当前频率太高,此时退避时间至少 3 秒,不要使用固定间隔, uniform(3, 6) 能避免心跳式规律。

提示:调试阶段把 fetch_with_limit 里的 timeout 改成 20,能减少因为慢响应造成的异常误判。

4.4 要不要上并发:线程池保守用法

对于几十个城市的规模,同步抓取通常只需要几分钟,没必要引入并发。如果把城市扩展到几百个,可以用 concurrent.futures.ThreadPoolExecutor ,但 worker 数不要超过 3,并且每个 worker 内部仍然要保留随机延时。

from concurrent.futures import ThreadPoolExecutor

def run_city(item):
    city, keyword = item
    html = fetch_html(session, BASE_URL, params={"keyword": keyword})
    items = parse_spot_items(html) if html else []
    save_to_excel(items, f"{city}_info.xlsx")
    return city, len(items)

with ThreadPoolExecutor(max_workers=3) as pool:
    for city, count in pool.map(run_city, CITY_KEYWORDS.items()):
        print(f"{city}: {count} 条")

max_workers=3 是保守值,并发太高会放大被限流的概率。如果以后需要更高吞吐,可以换用 asyncio 加 aiohttp 做协程并发,但 requests 是同步库,改成协程需要把请求函数整体替换。多进程在这个场景下收益不大,反而会占用更多内存。

5. 数据校验与增量更新:把 29 个 xlsx 变成一张总表

5.1 合并前先做完整性检查

项目里的 xlsx 是按城市或国家拆分的,分析时最好合并成一张总表。合并前要先检查每个文件的列名和行数,防止某次抓取失败导致缺失列。

from pathlib import Path
import pandas as pd

def inspect_all():
    for p in sorted(Path(".").glob("*_info.xlsx")):
        df = pd.read_excel(p)
        print(f"{p.name}: {df.shape[0]} 行, {list(df.columns)}")

如果某个文件列名比其他文件少,说明那次抓取时解析函数返回了不同的字典键,需要回到 parse_spot_items 里统一字段。 df.shape[0] 是行数,列名列表则用来快速发现字段缺失。

注意:合并前先确认所有文件的列名一致,不一致时先把列名映射成同一套再 concat。

5.2 按城市和景点名去重

多次抓取会产生重复数据,评分和点评数会变化。增量更新的技巧是以“城市 + 景点名”为唯一键,保留最后一次抓取的结果。

df_all = pd.concat(
    [pd.read_excel(p) for p in Path(".").glob("*_info.xlsx")],
    ignore_index=True
)
df_all.drop_duplicates(subset=["city", "name"], keep="last", inplace=True)

keep="last" 能保证重复数据中保留最后一行,前提是文件按时间顺序生成。如果项目没有写入抓取时间,建议在爬虫里增加一个 crawl_date 字段,每次写入时带当天日期,这样去重和后续分析都有依据。

5.3 用数量分布定位异常城市

合并后,按城市统计景点数量能直观发现哪个城市抓取异常。比如北京、上海正常有上千条,某个城市只有几十条,那大概率是抓取中途出错。

city_counts = df_all.groupby("city").size().sort_values(ascending=False)
print(city_counts)
city_counts.to_csv("city_counts.csv", encoding="utf-8-sig")

sort_values(ascending=False) 让数据量大的城市排在前面,只打印前几行就能看出大致分布。保存 CSV 时用 utf-8-sig 编码,Excel 打开不会乱码, city_counts.csv 可以直接作为后续分析的底表。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif