简介:一套面向去哪网的旅游景点爬虫设计源码,基于Python实现,定位明确,适合Python爬虫初学者、旅游数据分析者以及需要批量获取景点信息的开发者。压缩包共40个文件、约1.56MB,主体包括2个Python脚本负责请求与解析,29个xlsx文件存放国内外多省市及国家的景点数据,5个xml文件用于配置与数据描述,另有说明文档、项目配置和编译缓存文件,配套说明便于快速导入开发环境。源码围绕去哪网抓取景点名称、位置、价格、用户评价等结构化信息,采集范围涵盖国内多省及日本、德国、美国、法国等目的地,可直接运行生成Excel表格,也可作为学习Requests、BeautifulSoup等爬虫库用法和数据清洗落地的教学案例。已有349人学习/下载,对于想快速搭建旅游数据采集工具、研究站点爬取策略或入门爬虫项目的读者都有实用参考价值。
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 从零跑起项目的完整步骤
拿到源码后,先不要急着改代码,按以下步骤走:
- 确认本机 Python 版本在 3.8 以上,执行
python --version。 - 安装依赖:
pip install requests beautifulsoup4 pandas openpyxl。 - 用浏览器打开去哪网景点列表页,复制页面里的 Cookie 到 HEADERS(如果需要登录)。
- 运行
python app.py,观察每个城市打印的条数。 - 检查生成的
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可以直接作为后续分析的底表。
本文还有配套的精品资源,点击获取