
简介一套基于Python的携程旅游网站数据采集项目源码面向高校毕业设计、课程设计及期末大作业场景适合计算机、数据科学与大数据、人工智能等相关专业学生及入门爬虫的开发者参考。项目实现景点信息与用户评论两类数据爬取代码结构清晰包含景点爬取与评论爬取两个主模块便于理解Requests请求、页面解析、配置管理与数据落盘等常见爬虫流程。压缩包共12个文件包含4个Python脚本、txt/ini配置与说明、md项目文档、依赖清单及内层压缩包整体仅22KB轻量易部署目录结构清晰便于按需取用。目前已有796人浏览学习项目经本地运行测试通过答辩评审平均分较高。下载后不仅可直接运行还可基于源码修改扩展配合项目说明文档快速上手用于学习爬虫技术或完成课设/毕设演示。1. 作为 Python 大作业把携程景点数据和评论数据做成一个完整爬虫项目作为 Python 大作业“基于 python 爬取携程旅游网站旅游景点数据及评论数据”是一个性价比很高的综合题目它不是一个单页爬虫而是把接口定位、会话保持、分页处理、字段清洗、数据存储串起来的小工程。携程的景点页是动态渲染的直接用 requests 抓 HTML 往往拿不到景点列表真正数据藏在浏览器 Network 面板里的 JSON 接口中评论数据又是多页接口伴随着限流、重复字段和编码问题。这篇文章按“分析接口、采集景点、采集评论、校验打包”的顺序给出一套可复现的方案适合有 Python 基础、会用 requests 和 BeautifulSoup、想完成一个可交付项目的读者。看完之后你可以按同样的思路搭出自己的大作业结构而不是只抄一段请求代码。2. 先读携程页面结构再写爬虫接口定位、反爬边界和数据字段2.1 动态页面不是没有接口而是接口在 Network 面板里打开一个城市景点页例如you.ctrip.com/sight/shanghai2/直接用 requests 去拿会得到一个以框架代码为主的 HTML。你在其中搜索“外滩”“东方明珠”这类景点名往往一个字都搜不到。原因是页面内容由前端脚本在浏览器里动态渲染服务端只返回骨架和初始化脚本真实数据是在页面加载完成后由页面脚本发起 XHR 请求拿到的。所以做这个爬虫第一步不是对着 HTML 写解析器而是先回答“数据到底从哪个请求来”。做法不复杂在浏览器打开景点城市页按 F12 打开开发者工具切到 Network 面板筛选 XHR 或 Fetch再刷新页面。逐个看每个请求的响应只要返回 JSON 里出现景点名称、poiId、commentCount 这些字段就说明找对了。接着右键这个请求选择 Copy as cURL再转成 requests 写法。浏览器会帮你把当时的请求头、query 参数、Cookie 完整整理出来比自己手工构造准确得多。我一般会把请求先复制为 cURL在命令行执行一次确认能返回同样的 JSON再写代码。这样能提前筛掉“代码看着没问题但请求头和浏览器不一致”这一类错误。调试阶段不要一上来就写完整工程先保存两份原始响应用json.loads看看嵌套结构再决定解析路径。常见结构是一层data包着list景点字段就在 list 的每个元素里。2.2 从页面元素反推字段建立景点数据字段表拿到接口 JSON 之后先建立字段表。页面里能看到的景点名、评分、点评数、地址、标签在接口里可能有不同的字段名评分字段可能叫 score也可能叫 avgScore评论数可能是 commentCount也可能是 commentTotal。先按照页面展示和接口返回做一张对应表写代码时字段映射都从这张表来。页面显示常见接口字段类型说明景点名称namestring列表页和详情页应保持一致评分score 或 avgScorefloat接口间命名不一致需要归一化点评数commentCountint排序和后续统计都靠它地址addressstring有的接口在详情页才返回标签tagslist落库时用“景点唯一标识poiIdint评论接口的入参也是去重键字段名以实际抓包返回为准上表给出的是常见命名。确定字段表后把请求头单独抽成一个常量不要散落在每个函数里。下面这个片段是大多数携程爬虫项目会用到的请求头写法import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Referer: https://you.ctrip.com/, Accept: application/json, text/plain, */*, } def save_raw_page(url, filenamedebug.html): resp requests.get(url, headersHEADERS, timeout10) with open(filename, w, encodingutf-8) as fh: fh.write(resp.text) print(resp.status_code, len(resp.text)) save_raw_page(https://you.ctrip.com/sight/shanghai2/)这段代码把景点城市页的响应保存到本地文件主要用来调试。User-Agent 不能缺失很多接口默认不处理没有浏览器标识的请求Referer 表示请求来源部分接口校验来源地址Accept 让服务端更倾向返回 JSON。timeout 设为 10 秒避免某个请求卡死整个程序。保存出来的 debug.html 如果搜不到景点名说明这是动态页面需要回到 Network 面板继续找接口。这里还要提醒一点字段映射看着不起眼但景点数据和评论数据最终要落到同一个结果目录评论表的外键靠 poiId 关联。如果解析阶段字段不统一后面用 pandas 合并两张表时会因为list、None、字符串混在一起而反复返工。先花十分钟把字段类型定好后面写代码会顺很多。2.3 大作业场景下的反爬边界携程的反爬不算激进但也不是没有。常见表现是缺 User-Agent 时返回 403短时间内请求频率过高时接口开始超时或返回空列表少数接口要求先访问一次页面在 Cookie 里取得凭证后才肯返回数据。这些都属于可控范围遇到验证码和强制登录的情况答案不是硬碰而是停下来。作为 python 大作业比较稳妥的采集策略是把范围限制在一个城市景点数量控制在几百个每次请求后 sleep 13 秒不做无休止的重试最大爬取页数写成配置参数。如果看到验证码或滑块直接结束程序等一段时间再跑不要尝试破解。课程项目展示的是你会用 Python 完成数据抓取和存储不是你能对抗风控工程结构清楚比堆数据量更得分。Cookie 的处理也在这个阶段想好。建议全程使用requests.Session先让 Session 访问一次城市景点首页把服务端下发的 Cookie 保存下来再带着同一个 Session 请求接口。这样比手动复制 Cookie 字符串更省心长时间运行也不会因为 Cookie 过期立刻失败。3. 用 requests 把旅游景点数据解析成结构化行3.1 项目结构先按采集、解析、存储三层拆分不少大作业把抓取、解析、写文件全写在一个文件里。这样演示很直观但数据和采集逻辑混在一起调试时越往后越难受。常见做法是把代码拆成采集层、解析层、存储层采集层负责请求和容错解析层负责把 JSON 转换成统一字典存储层负责写 CSV、Excel 或 SQLite。目录结构如下ctrip_project/ ├── main.py ├── config.py ├── requirements.txt ├── spiders/ │ ├── __init__.py │ ├── sight.py │ └── comment.py ├── storage/ │ ├── __init__.py │ └── saver.py └── output/config.py 放城市代号、接口地址模板、最大页数、sleep 间隔等参数spiders 下按景点和评论拆成两个模块storage/saver.py 只提供保存函数main.py 只做调度里面不出现具体解析逻辑。目录拆得细并不会让代码变长反而把调试范围缩小。比如评论接口返回 403 时只需要去spiders/comment.py里查 headers 和 session不用在整个文件里翻。我还会在 spiders 模块顶部加一段 logging 配置用日志打印“已抓第几页、返回多少条”比散落的 print 清晰得多。3.2 用 requests 定位 JSON 列表接口并解析景点字段下面先写一个最小的函数验证能拿到景点列表。接口地址以你在 Network 面板抓到的那条为准下面这个SIGHT_LIST_API是示例模板import time import requests SIGHT_LIST_API https://you.ctrip.com/sight/{city}/sightlist def fetch_sight_list(session, city, page): url SIGHT_LIST_API.format(citycity) params {page: page} resp session.get(url, paramsparams, timeout10) if resp.status_code ! 200: return [] payload resp.json() items payload.get(data, {}).get(list, []) time.sleep(1) return itemsfetch_sight_list的目的很简单给定城市代号和页码返回本页景点数据列表失败时统一返回空列表避免上层反复判断状态码。params里 page 是页码参数有些接口叫 pageNo以实际抓包为准。time.sleep(1)写在函数里让翻页循环在两次请求之间自然停顿如果并发统一控制这里也可以去掉。{city}占位符用于传入城市代号比如 shanghai2。解析字段时先把每一项整理成统一字典而不是直接追加原始 JSON这样写 CSV 时字段顺序可控def parse_sight(item): tags item.get(tags) or [] tag_text if isinstance(tags, list): tag_text |.join([t.get(name, ) for t in tags]) return { poiId: item.get(poiId), name: item.get(name), score: item.get(score), commentCount: item.get(commentCount), address: item.get(address), tags: tag_text, }parse_sight把接口返回字段转换成表结构里的一行。tags 在接口里通常是对象数组直接存 CSV 会变成一长串字符串所以用|拼接。每个字段顺序固定后Excel 打开时列位置也固定。score可能缺失读取后统一检查即可。输出字段来源说明poiId接口列表项主键也是评论外键name接口列表项景点名称score接口列表项评分可能为空commentCount接口列表项评论数可能带单位address接口列表项地址tags接口列表项拼接后的标签字符串如果某些旧版页面把景点卡片直接渲染在 HTML 里也可以补一个 BeautifulSoup 分支from bs4 import BeautifulSoup def parse_sight_html(html): soup BeautifulSoup(html, html.parser) rows [] for card in soup.select(.sight_item): name_node card.select_one(.name a) if not name_node: continue rows.append({ name: name_node.get_text(stripTrue), # 实际 class 名称以页面源码为准 }) return rows不过要提醒一句bs4 只适合内容已经渲染在 HTML 里的场景。如果页面靠脚本动态填充soup 拿不到卡片节点最后还是走接口方案。“用 bs4 爬动态页面”是常见误区不是不能用而是要用对地方。3.3 翻页循环、去重并写入 CSV列表接口通常需要翻页到没有更多数据为止。可以用最大页数限制也可以根据返回条数判断某一页返回空列表就提前 break。同时要去重用 poiId 做主键。import pandas as pd def run_sight_collector(city, max_pages5): session requests.Session() session.headers.update(HEADERS) all_rows {} for page in range(1, max_pages 1): items fetch_sight_list(session, city, page) if not items: break for item in items: parsed parse_sight(item) pid parsed[poiId] if pid and pid not in all_rows: all_rows[pid] parsed df pd.DataFrame(all_rows.values()) df.to_csv(foutput/{city}_sights.csv, indexFalse, encodingutf-8-sig) return len(df)翻页从 page1 开始到 max_pages 结束接口返回空说明没有更多数据直接退出。去重通过字典的 key 完成同一个 poiId 只保留第一次出现的记录这样即使列表接口和详情接口字段重合也不会重复入库。pandas 写 CSV 时用utf-8-sigExcel 直接打开不会出现中文乱码。函数返回len(df)方便 main.py 打印本次抓取数量。这一层还会遇到一个细节接口返回里 None、空字符串和0都可能表示缺省值建议在 parse 层统一处理。尤其注意评论数这类字段有的接口返回568条直接转 int 会报错要先用正则提取数字。4. 评论数据的分页抓取与线程池并发提速4.1 用返回字段判断分页终止不写死页数评论数据和景点列表的区别在于它一定分页而且页数是动态的。有些接口返回 totalPage有些只返回 hasMore。如果代码里写“最多抓 50 页”评分高的景点可能没抓完就停了冷门景点又浪费请求。正确做法是把终止条件交给接口返回字段。先实现单页请求函数COMMENT_API https://you.ctrip.com/fe/api/comment/{poi_id} def fetch_comment_page(session, poi_id, page_no, page_size20): params { p: page_no, size: page_size, poiId: poi_id, } resp session.get(COMMENT_API.format(poi_idpoi_id), paramsparams, timeout10) if resp.status_code ! 200: return [], False data resp.json().get(data, {}) or {} comment_list data.get(list, []) or [] has_more bool(data.get(hasMore, False)) return comment_list, has_more这个函数只负责拉一页评论返回两个值本页评论列表、是否还有下一页。params里的 p 和 size 控制页码和每页数量有的接口写成 pageNo 和 pageSize以实际抓包为准。hasMore 是接口给出的分页标记如果返回里没有这个字段就改成解析 totalPage用page_no totalPage判断。单景点评论的完整抓取可以封装成def crawl_comments_by_poi(session, poi_id, max_pages20): page_no 1 result [] while page_no max_pages: comments, has_more fetch_comment_page(session, poi_id, page_no) if not comments: break result.extend(comments) if not has_more: break page_no 1 time.sleep(0.8) return poi_id, resultmax_pages是保险丝防止接口 hasMore 一直为 True导致单个景点抓到几十页正常情况由 hasMore 提前退出。每次翻页 sleep 0.8 秒单个景点 20 页也只多等十几秒。评论字段里常出现昵称、城市、内容、回复时间有时还有嵌套的 user 对象。写入前要拍平嵌套比如从user.nickname取值否则 pandas 建 DataFrame 时会多出很多层级。去重时最好用评论 id 作为唯一键而不是用“用户 内容”因为同一用户可能对同一景点多次补充评论。4.2 用 ThreadPoolExecutor 并发抓取多个景点的评论并发在大作业里容易被想复杂。有人一听到并发就考虑 asyncio 和 aiohttp但这里只是网络等待密集用线程池就够了。常见做法是ThreadPoolExecutorfrom concurrent.futures import ThreadPoolExecutor, as_completed import random def run_comment_collector(poi_ids, max_workers8): session requests.Session() session.headers.update(HEADERS) collected {} def worker(poi_id): time.sleep(random.uniform(0.3, 1.0)) return crawl_comments_by_poi(session, poi_id) with ThreadPoolExecutor(max_workersmax_workers) as pool: futures [pool.submit(worker, pid) for pid in poi_ids] for future in as_completed(futures): pid, comments future.result() collected[pid] comments return collectedThreadPoolExecutor对 requests 是合适的因为请求耗时大多在网络等待不占 CPU线程池能同时等待多个响应。max_workers8表示同时最多 8 个请求在途如果被限流降到 4 或 2。每个 worker 加一个随机 sleep是为了让各线程请求时间错开减少同时打上去的概率。这里所有线程共用一个 Sessionrequests.Session跨线程共享不能说绝对线程安全但实际请求场景通常没问题还能复用连接池。如果追求更严格隔离可以每线程建独立 Session连接开销会大一些。as_completed返回的顺序和传入的 poi_ids 顺序不一致因为它是按完成时间返回。如果最后要求 CSV 按景点顺序排列在collected配齐后再按原始列表重排一次即可。4.3 限流状态码和指数退避重试评论抓取的请求量比景点列表大更容易遇到 403 和超时。给接口请求包一层带重试的safe_get统一处理临时失败def safe_get(session, url, paramsNone, retries3): wait 1 for attempt in range(retries): try: resp session.get(url, paramsparams, timeout8) if resp.status_code 200: return resp.json() if resp.status_code in (403, 429): time.sleep(wait) except requests.RequestException: pass wait * 2 return {}safe_get把请求、状态判断、重试全部收拢起来。遇到 403 或 429 时等退避时间1 秒、2 秒、4 秒逐步扩大网络异常也走同一条重试路径。retries默认 3 次最多额外等待约 7 秒。调用方只需要判断返回值是否为空字典。状态码含义处理方式200正常解析 JSON 并返回403请求被拒绝退避后重试并检查请求头404景点或接口不存在写入日志后跳过不重试429请求过多退避后重试同时降低并发500 及以上服务端异常退避后重试仍失败则跳过重试消耗完还拿不到数据就把当前景点和页码写进失败日志最后单独跑一轮补齐。不要在一个线程里无限重试否则整个任务会被有限的线程拖死。5. 大作业 zip 的最后一步校验、README 与提交检查5.1 用 pandas 做数量和缺失校验抓完数据先别急着打包跑一遍校验确认结果可用。import pandas as pd sights pd.read_csv(output/shanghai2_sights.csv) comments pd.read_csv(output/shanghai2_comments.csv) print(景点数量:, len(sights)) print(景点名缺失:, int(sights[name].isna().sum())) print(评论总量:, len(comments)) dup comments.duplicated(subset[poiId, content], keepFalse) print(疑似重复评论:, int(dup.sum()))景点数量用来判断翻页是否提前结束缺失项能发现解析字段没对齐重复检查用 poiId 加 content 做联合维度如果重复比例过高说明线程收集时有重叠需要回查去重逻辑。校验通过后可以把输出结果说明写进 README比如“本次采集上海市区景点 xx 个评论 xx 条时间为某个日期”。5.2 把运行入口和参数写进 READMEREADME 写三件事环境版本、运行命令、输出文件说明。requirements.txt 固定 requests 和 pandas 的大版本即可不用锁到修订号。main.py 最好支持命令行参数而不是改代码才能切换城市。提交前实际执行一次python main.py --city shanghai2 --pages 5 --max-workers 4命令跑完output 目录应该出现景点 CSV 和评论 CSV。做到这一步说明整个项目是可复现的而不是只能在作者电脑上运行。5.3 提交前的五项检查检查项通过标准新环境可运行安装 requirements.txt 后能直接运行接口地址不硬编码写在 config.py并注明抓包来源输出文件正常CSV 用 Excel 打开不乱码请求节奏可控有 sleep 和重试上限字段完整景点表能通过 poiId 关联评论表最后打包时zip 里放代码、requirements.txt、README 和一个小的数据样例即可不需要放 output 下的大型结果和本地虚拟环境目录。按python main.py --city shanghai2 --pages 5 --max-workers 4完整跑一遍确认 output 目录生成两份文件后这个 Python 大作业 zip 就可以提交了。本文还有配套的精品资源点击获取