Python爬虫实战:采集城市热门景点数据并导出CSV
你有没有遇到过这种需求朋友突然丢来一句话“帮我抓一下全国热门城市的热门景点要有评分、有地址最好能导出Excel”。看起来很简单但真的动手做起来涉及请求、解析、清洗、导出四个环节每一环都可能翻车。这篇文章就围绕“输入一个城市名自动采集该城市的热门景点清单景点名称、大众评分星级、详细地理地址并附带CSV导出”这个实战需求完整还原从零到一的过程包括技术选型、页面结构分析、爬虫代码实现、评星字段的坑、中文编码处理以及实测中最容易遇到的拦截与反爬问题。适合刚入门Python爬虫、想做一个完整小项目的读者也适合有一定经验但想抄作业快速交付的人。先说结论这个需求完全可以用requestsBeautifulSoup的轻量组合实现不需要上 Scrapy 或 Selenium。整个脚本大概200行左右就能跑通核心难点不在爬而在“页面结构会变”“字段格式不统一”“请求太频繁会被拦”这三点。我会把每一步的取舍逻辑讲清楚代码直接给你照着改就能用。1. 先想清楚数据从哪来目标网站的信息结构与合规边界1.1 选网站的三个硬标准实现“输入城市名→获取热门景点清单”这个需求第一步不是写代码而是选一个合适的数据源。这个选择直接决定后面80%的代码难度。我以前用过的目标源主要有三类综合OTA平台的景点频道、本地生活平台的景点栏目、以及政府或景区官方的公开数据。实际体验下来综合OTA平台的景点页是最合适的原因是它天然满足三个硬标准第一信息结构化程度高景点名称、评分、地址都在同一个列表页里不用跳转详情页就能一次性拿到第二页面是服务端渲染的HTML不需要动态渲染用普通HTTP请求就能拿到完整数据第三评分数据来自真实用户评价的聚合符合“大众评分”这个需求场景比自己去各平台拼凑要靠谱得多。这里我要强调一个容易忽略的点地址字段一定要选列表页直接展示出来的。有些平台列表页只显示“xx区xx街道”详情页才有完整地址如果你在列表页拿不到后面就得额外发请求去逐个抓详情页请求量直接翻好几倍被封的风险也指数级上升。所以选数据源的时候先用浏览器打开目标城市的景点列表页手动检查这三列信息是不是都在同一屏里。1.2 先看HTML结构再写代码确定字段定位策略选定目标后不要急着写代码先打开开发者工具F12分析页面结构。这一步省下来的时间比后面Debug省下来的时间多得多。我一般按这个顺序检查先用元素选择器点一下景点名称看它包在什么标签里有没有特定的class或>pip install requests beautifulsoup4如果你用的是Anaconda建议在虚拟环境里装避免污染基础环境。此外为了方便后续把脚本打包给其他人用我会顺便建议安装pandas来辅助CSV导出虽然标准库csv也能实现但pandas处理中文编码和类型转换更省心pip install pandas不过这里有个取舍问题pandas是重依赖安装包体积大。如果你的目标机器是精简的云服务器用标准库csv就够了。本文最终代码我会用标准库csv来写确保脚本在任何装了Python的机器上都能直接运行不额外增加依赖。2.3 请求头伪装少数几个能直接影响成功率的技术细节关于请求头headers很多人刚入门时完全不设置直接requests.get(url)然后遇到403就一脸懵。我的习惯是所有爬虫请求至少带上User-Agent、Accept、Accept-Language三个字段其中User-Agent必须伪装成真实浏览器版本否则很多站点会直接拒绝。headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, }这里有个容易被忽略的细节User-Agent里的浏览器版本要和目标网站的兼容性矩阵匹配。比如网站如果明确不再支持旧版Chrome那你的UA写成Chrome 80可能就会触发风控。我的做法是每过一段时间搜索一下“最新Chrome版本号”保持UA里的版本号相对较新同时操作系统字段写成Windows NT 10.0或Macintosh都可以别写成Linux或手机端除非你确定目标站点对手机端有专门的低反爬策略。3. 核心采集逻辑从城市名到景点清单的完整链路3.1 城市名的URL编码不能让中文裸奔在网址里输入的城市名是中文但URL里不能直接放中文虽然部分网站做了兼容但规范的做法是编码。requests库有个方便之处如果你直接传params参数它内部会自动做URL编码。但如果你要把城市名拼进URL路径里就得自己用urllib.parse.quote编码。from urllib.parse import quote city_name 丽江 encoded_city quote(city_name) print(encoded_city) # %E4%B8%BD%E6%B1%9F实际开发中我更推荐用params方式传参因为这样代码更清晰参数多了也不容易拼错base_url https://example.com/poi/search params { keyword: city_name, page: 1, } resp requests.get(base_url, paramsparams, headersheaders, timeout10)我遇到过一种情况网站对城市名用的是拼音而不是中文比如lijiang或者内部的城市ID比如100120。这种就需要提前维护一个“城市名→城市代码”的映射表。我的建议是如果目标站只接受城市ID最好的办法是先去手动搜索一次该城市从URL里找到对应ID然后存到一个JSON文件里脚本启动时加载。前期花一点时间把几十个主要城市的映射配齐后面再跑批量采集就很顺畅了。3.2 发送列表页请求超时、状态码与编码处理网络请求这件事看起来就是一行requests.get实际里面藏着不少细节。以我多年的经验一个稳健的请求函数至少要有这么几步设置超时防止线程卡死、校验状态码200才继续、处理编码避免中文乱码、休眠控制降低请求频率。import time import requests def fetch_page(url, paramsNone, retries3): for attempt in range(retries): try: resp requests.get(url, paramsparams, headersheaders, timeout10) if resp.status_code 200: resp.encoding resp.apparent_encoding return resp.text elif resp.status_code in (403, 418): print(f触发反爬状态码: {resp.status_code}) time.sleep(5) continue else: print(f请求失败状态码: {resp.status_code}) time.sleep(2) continue except requests.exceptions.RequestException as e: print(f请求异常: {e}, 重试 {attempt 1}/{retries}) time.sleep(2) return None关于编码这是一个特别值得说的问题。国内网站大多数是UTF-8但也有一部分还是GBK/GB2312编码。resp.apparent_encoding是requests根据字节内容猜出来的编码对中文网站命中率很高。如果你不显式设置requests会用HTTP头里的Content-Type来解码而有些网站返回的Content-Type并不准确结果就是解析出来的HTML全是乱码BeautifulSoup 再怎么选选择器也没用。所以我在函数里加了resp.encoding resp.apparent_encoding这一行宁可多花几十毫秒去探测编码也不能接受乱码。retries重试机制也是必需品。网络请求总有意外超时尤其是目标服务器在高峰期响应慢或者你的网络到目标服务器的链路存在波动。设置3次重试每次间隔2~5秒能显著提高整体成功率。注意重试时不要用固定间隔稍微加一点随机性避免形成明显的访问规律。3.3 用 BeautifulSoup 解析CSS选择器的定位逻辑拿到HTML文本后就到了BeautifulSoup的主场。我习惯用lxml作为后端解析器速度比默认的html.parser快不少。如果你没装lxml先执行pip install lxml。from bs4 import BeautifulSoup soup BeautifulSoup(html_text, lxml) poi_items soup.select(.poi-list .poi-item)这里的.poi-list .poi-item是CSS选择器意思是查找所有class为poi-item的元素且它的祖先元素里有class为poi-list的元素。用类名定位时要注意class属性可以包含多个值选择器的匹配是包含关系所以.poi-item能匹配到div classpoi-item active这一点很有用。拿到列表中的所有景点卡片后再对每一个卡片单独提取字段。提取字段有两种方式select_one返回第一个匹配元素适合取单个节点的文本或属性select返回所有匹配元素的列表适合处理多个同类节点。poi_list [] for item in poi_items: name_elem item.select_one(.poi-name) score_elem item.select_one(.poi-score) addr_elem item.select_one(.poi-address) if not name_elem or not score_elem or not addr_elem: continue name name_elem.get_text(stripTrue) score score_elem.get_text(stripTrue) address addr_elem.get_text(stripTrue) poi_list.append({ 景点名称: name, 评分: score, 地址: address, })这里有几个细节需要专门说明第一get_text(stripTrue)会把节点内所有文本拼接起来并去掉首尾空白但如果节点内部有子标签比如地址里包含一个span标注“距市中心3.2km”get_text会把子标签的文本也拼进来。这时候需要先移除不需要的子节点或者改用只取contents里的文本片段。第二为什么不直接取.get_text()然后手动strip因为stripTrue同时处理了换行和空格省去后续清洗的麻烦。但要注意如果文本内部有多个空格或换行stripTrue并不会压缩内部的空白这个我们在数据清洗阶段再处理。第三if not name_elem or not score_elem or not addr_elem: continue这行是防止某些卡片缺字段导致后面处理报错。爬虫的容错性很重要宁可丢一条脏数据也不要让整个脚本中断。3.4 翻页与城市级别的采集循环单个城市的热门景点往往不止一页所以还需要翻页逻辑。我的设计思路是先请求第一页解析出景点数量如果总数量足够覆盖需求比如30条就停止翻页如果不够就继续请求第二页、第三页直到没有下一页或达到最大页数限制。def crawl_city(city_name, max_pages3): all_pois [] for page in range(1, max_pages 1): params { keyword: city_name, page: page, } html_text fetch_page(list_url, paramsparams) if not html_text: break soup BeautifulSoup(html_text, lxml) page_pois parse_poi_list(soup) if not page_pois: break all_pois.extend(page_pois) print(f第{page}页解析到 {len(page_pois)} 个景点累计 {len(all_pois)} 个) time.sleep(random.uniform(2, 4)) return all_pois随机休眠时间我通常设置在2~4秒之间。别小看这个随机性固定2秒和2~4秒随机在风控的“行为画像”里是两种完全不同的访问模式。固定间隔反而容易被打上“脚本操作”标签随机间隔更接近真人浏览行为。这里我为max_pages设置了默认值3是因为对一个城市的热门景点来说3页通常已经覆盖了绝大多数值得看的景点每个城市的热门景点总量本来也就是几十个。如果你需要采集所有景点可以把max_pages调到10以上但建议同时把休眠时间拉长到3~5秒并且加一个total_wait的最大运行时间限制防止脚本无限跑下去。3.5 完整示例代码一个可以在命令行直接运行的版本把所有逻辑串起来我给你一个完整的版本。这个版本做了两件事接收命令行传参的城市名抓取热门景点输出CSV文件。import csv import random import time import argparse from urllib.parse import quote from urllib.parse import urlencode import requests from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } BASE_URL https://example.com/poi/search # 替换为目标网站的实际列表页URL def fetch_page(params, retries3): for attempt in range(retries): try: resp requests.get(BASE_URL, paramsparams, headersHEADERS, timeout10) if resp.status_code 200: resp.encoding resp.apparent_encoding return resp.text elif resp.status_code in (403, 418): print(f触发反爬状态码: {resp.status_code}尝试重试...) time.sleep(5) else: time.sleep(2) except requests.exceptions.RequestException as e: print(f请求异常: {e} (第{attempt 1}次)) time.sleep(2) return None def parse_poi_list(html_text): soup BeautifulSoup(html_text, lxml) poi_items soup.select(.poi-item) results [] for item in poi_items: name_elem item.select_one(.poi-name) score_elem item.select_one(.poi-score) addr_elem item.select_one(.poi-address) if not (name_elem and score_elem and addr_elem): continue results.append({ 景点名称: name_elem.get_text(stripTrue), 评分: score_elem.get_text(stripTrue), 地址: addr_elem.get_text(stripTrue), }) return results def crawl_city(city_name, max_pages3): all_pois [] for page in range(1, max_pages 1): params {keyword: city_name, page: page} html_text fetch_page(params) if not html_text: print(未获取到页面内容停止翻页) break page_pois parse_poi_list(html_text) if not page_pois: print(当前页无有效景点数据停止翻页) break all_pois.extend(page_pois) print(f第{page}页解析到 {len(page_pois)} 个景点累计 {len(all_pois)} 个) time.sleep(random.uniform(2, 4)) return all_pois def save_to_csv(pois, filename): fieldnames [景点名称, 评分, 地址] with open(filename, w, encodingutf-8-sig, newline) as f: writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(pois) print(f已保存 {len(pois)} 条数据到 {filename}) if __name__ __main__: parser argparse.ArgumentParser(description采集指定城市热门景点) parser.add_argument(city, typestr, help城市名例如成都) parser.add_argument(--max-pages, typeint, default3, help最大翻页数量默认3页) args parser.parse_args() pois crawl_city(args.city, max_pagesargs.max_pages) if pois: save_to_csv(pois, f{args.city}_热门景点.csv) else: print(未采集到任何景点数据)运行方式python city_poi.py 成都 python city_poi.py 成都 --max-pages 5这个版本已经具备完整的采集、解析、导出闭环。但这里面用到的选择器和BASE_URL是示意性的你实际使用时必须替换成你自己锁定目标网站的真实URL和CSS选择器。这个替换过程一定要在浏览器开发者工具里现查现写不能凭感觉蒙。4. 数据清洗与CSV导出评星字段的陷阱和中文乱码的解法4.1 评星数据的三种形态与统一处理策略星级评分的原始数据在不同网站上有不同形态这个坑我深有体会。最常见的三种形态是文本型如“4.8分”、数字型如4.8可能带有aria-label或>import re def parse_score_text(text): if not text: return match re.search(r(\d(?:\.\d)?), text) if not match: return score float(match.group(1)) if % in text: score score / 20.0 # 95% 对应 4.75分 return round(score, 1)这个转换逻辑背后的思路是不论原始数据是“4.8分”“4.8”还是“96%”最终都归一到0~5的浮点数。注意百分比转换时要除以20因为100%对应满分5分这是OTA平台通用的折算方式。如果你要保留星级展示比如4.8分显示为4星半还需要再做一次映射def score_to_star(score): if score 4.5: return ★★★★★ elif score 3.5: return ★★★★ elif score 2.5: return ★★★ else: return ★★不过这句话要放在CSV字段里往往会因为星号字符让Excel的单元格宽度异常我一般建议保留数值即可星级符号仅用于展示文案。4.2 地址清洗把“xx区xx街道xx路”从混合文本里捞出来地址字段的脏数据问题比评分更隐蔽。很多网站的列表页地址会带上额外的标签比如“距市中心5.2km”“距高铁站3.1km”之类或者把“查看地图”这种操作按钮的文本也拼到同一行里。这就导致get_text(stripTrue)拿到的地址是“XX区XX街道XX路 距市中心5.2km 查看地图”需要清洗。我总结了一个三级清洗策略def clean_address(raw_addr): if not raw_addr: return # 1. 去掉常见距离/操作文本 addr re.sub(r距\S?(\d(?:\.\d)?km|公里), , raw_addr) addr re.sub(r查看地图|地图|导航|电话|预定|预订, , addr) # 2. 压缩空白字符 addr re.sub(r\s, , addr) # 3. 去掉首尾的地标前缀 addr re.sub(r^(位于|地处|坐落于), , addr) return addr.strip()这个策略可能无法处理所有情况但能覆盖绝大多数OTA站点列表页地址乱七八糟的干扰文本。因为你最后要的是“具体的地理地址”所以只要把“距xx地标xx公里”这种相对位置信息去掉保留完整的行政区划道路门牌号就行。需要提醒的是有些网站的地址是放在两级节点里的比如容器.poi-item的>with open(filename, w, encodingutf-8-sig, newline) as f: writer csv.DictWriter(f, fieldnames[景点名称, 评分, 地址]) writer.writeheader() writer.writerows(pois)第二个坑是newline。如果这个参数不写在Windows上写出的CSV会多出空行这是CSV模块和Windows换行符冲突导致的。加上newline就能避免。第三个坑是字段本身包含逗号或换行。比如景点名称是“XX度假区含温泉、高尔夫”这种或者地址里有逗号。标准库的csv模块会自动处理字段转义用引号包裹含逗号的字段所以你不需要手动替换但要有这个意识——出现转义是正常行为别误判为数据问题。4.4 去重的必要性同一景点在不同关键词下重复出现如果你连续采集多个城市或者在同一个城市采集了多页可能会出现重复数据。比如某些平台有“热门景点”和“全部景点”两个tab同一条景点可能同时出现在两个榜单里再比如排名靠前的景点出现在第1页也在第2页的“精选推荐”里出现。去重的逻辑很简单以“景点名称地址”作为唯一键。虽然不同景点理论上可能重名但重名同地址的概率极低。def deduplicate(pois): seen set() unique_pois [] for poi in pois: key f{poi[景点名称]}|{poi[地址]} if key in seen: continue seen.add(key) unique_pois.append(poi) return unique_pois这个去重逻辑有个细节如果地址因为解析问题有细微差别比如一个带“市”一个不带“市”可以通过先标准化地址再生成key的方式提升准确率。标准化做法就是把“市”“区”“省”这些行政区划词去掉或者统一为同一形式。5. 反爬规避与请求节奏控制实测中踩过的那些坑5.1 请求头只是第一道门槛IP频率才是真正的雷区运行爬虫的前几次你可能很顺利第一页、第二页都返回200。但当你翻到第三页、第四页或者连续换几个城市快速采集时突然就出现403或者418这时候第一反应不要是“网上说加个UA就行”而是应该意识到你已经触发了网站的IP频率限制。IP频率限制是大多数OTA平台的核心风控策略它不看你的User-Agent伪装得多好只看你单位时间内来自同一个IP的请求量。这里有个实际参考值我测试过不少平台比较安全的是“单IP请求间隔不小于2秒单IP连续请求总量控制在每小时100次以内”。超出这个阈值大概率触发验证码或封禁。如果你只是采集几个热门城市每个城市3页总共10个城市每个页面间隔2~4秒总请求量大约30~40次这在安全范围内不需要额外处理。但如果你要采集50个城市甚至更多单IP完全不够用这时就需要考虑代理IP池。不过代理池的质量参差不齐免费代理的可用率和稳定性都很感人付费代理又要考虑成本对个人学习项目来说性价比不高。我给大多数读者的建议是别贪。按需采集单次控制在3~5个城市每页都加随机休眠采集完一轮后等待10分钟以上再采集下一轮。这不仅是对自己IP的保护也是对目标站点的友好。5.2 状态码418不是你的代码有Bug而是“我是机器人”被认出来了HTTP 418这个状态码第一次遇到的人很容易懵。它不是标准的HTTP错误而是“Im a teapot”但在反爬场景里它几乎成了“网站已经识别出你是爬虫”的通用暗号。遇到418你加UA、加Cookie、改Accept都没用因为网站可能已经通过TLS指纹识别或浏览器特征识别锁定了你的客户端。TLS指纹这东西比较复杂requests库用的是Python自己的TLS栈和Chrome的TLS指纹特征差异很大。有些安全等级高的站点即使你的UA和请求头都完美伪装也能直接识别出你是python-requests。解决办法是使用curl_cffi这类库来模拟Chrome的TLS指纹pip install curl_cffifrom curl_cffi import requests as cffi_requests resp cffi_requests.get(url, headersheaders, impersonatechrome)impersonatechrome是curl_cffi的便捷参数它会用Chrome的TLS指纹方式发起请求可以让这类检测直接失效。不过话说回来大部分出行旅游类平台对单次少量请求并不会上TLS指纹检测如果遇到418我的排查顺序是先检查UA——再查请求频率——最后才考虑TLS指纹。别一上来就换库容易把简单问题复杂化。5.3 守护正常业务的技术底线日志记录与失败恢复长时间跑爬虫迟早会遇到请求失败或解析异常。我第一次做城市级采集的时候跑了一个多小时中途因为一个城市的页面结构略有不同导致脚本直接抛异常退出前面的数据全丢了。从那以后我养的每个爬虫都会加两个机制日志记录和失败恢复。日志记录可以用最简单的logging模块每次请求和每次解析都记录一条方便事后排查是哪个城市、哪个页面出了问题。import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.FileHandler(crawl.log, encodingutf-8), logging.StreamHandler() ] )失败恢复的核心思路是“增量存储”每解析完一个页面就把数据追加写入CSV一次而不是等全部采集完成后再一次性写入。这样即使中途断了已经采集到的数据都在重启后可以跳过已有景点继续采集。def append_to_csv(path, pois): file_exists os.path.isfile(path) with open(path, a, encodingutf-8-sig, newline) as f: writer csv.DictWriter(f, fieldnames[景点名称, 评分, 地址]) if not file_exists: writer.writeheader() writer.writerows(pois)这个“边抓边存”的思路也方便你在采集过程中随时打开CSV查看成果不用等全部跑完。如果你需要采集上百个景点强烈建议改成这种方式。5.4 被验证码拦截时的务实选择换时间段还是换策略验证码是爬虫的终极拦路虎。如果你已经验证过了UA、频率、TLS指纹还是弹出验证码那我的建议很务实停下来换个时间段采集。OTA平台的风控模型通常有高峰和低谷时段晚上10点到早上8点之间的风控阈值相对放宽一些。这不是什么官方数据是很多爬虫从业者的共同经验不一定科学但实测有效。如果换了时间段还是不行那就需要重新审视你的采集策略了。比如同一城市减少翻页数、同一时间只采集一个城市、把请求间隔拉到5秒以上。这些“降速”动作效果往往比重试更好的UA更明显。爬虫的本质是“模拟真人访问”真人一天访问几十个页面是很正常的你非要一分钟访问几十个网站不拦你拦谁。6. 把脚本升级成命令行小工具参数解析、异常兜底和可扩展性6.1 用 argparse 接收城市名和可选参数前面的示例代码里其实已经加入了argparse这里单独说一下设计思路。命令行工具的目标是让使用者不需要打开代码改变量而是通过参数控制行为。除了城市名这个必选参数我还预留了--max-pages来控制翻页数、--output来指定输出文件名、--delay来控制请求间隔。parser.add_argument(city, typestr, help城市名称) parser.add_argument(--max-pages, typeint, default3, help最大翻页页数) parser.add_argument(--output, typestr, defaultNone, help输出CSV文件名) parser.add_argument(--delay, typefloat, default3.0, help请求延迟秒数) args parser.parse_args()使用--delay时注意是在crawl_city函数里用time.sleep(random.uniform(args.delay * 0.8, args.delay * 1.2))的方式仍然保留随机性而不是固定延迟。6.2 异常兜底不是所有页面都长一个样即使是最完善的解析代码也会遇到页面结构意外变化的情况。比如某天网站改版.poi-score变成了.poi-score-v2你的代码就会在parse_poi_list里静默丢弃所有数据最终生成一个只有表头的CSV。因此我更推荐在解析函数里对这个情况做显式检查if not poi_items: print(未找到景点列表节点页面结构可能已变化请检查CSS选择器)这行提示虽然简单但能在你无头苍蝇一样调试时节省大量时间。另一个兜底是用try...except包住单个景点的字段解析避免因为某一条数据格式异常导致整个线程退出。for item in poi_items: try: name item.select_one(.poi-name).get_text(stripTrue) score item.select_one(.poi-score).get_text(stripTrue) address item.select_one(.poi-address).get_text(stripTrue) except AttributeError: continueAttributeError是爬虫里最常见的异常来源当select_one返回None时调用.get_text就会触发它。用try...except包住是最直接的防御方式但要注意如果大量数据连续报错说明选择器整体失效应该优先检查页面结构而不是靠异常吞掉问题。6.3 扩展方向批量城市、多字段、数据可视化这个工具完成之后很自然的扩展方向有三个。第一个是支持批量城市采集。把城市名列表放进cities.txt脚本逐行读取逐个城市采集中间加5~10秒大间隔。整体思路不复杂就是在外层再套一个循环。python city_poi.py 成都 python city_poi.py 重庆 python city_poi.py 西安或者用一个shell循环for city in 成都 重庆 西安 丽江 大理; do python city_poi.py $city --max-pages 3 sleep 10 done第二个是增加更多字段。比如在详情页追加“景点简介”“用户评价数”“游玩建议时长”等信息。这时候单靠列表页就不够了需要进入详情页采集可以把详情页URL也解析出来然后分两阶段采集第一阶段先拿列表第二阶段逐个进详情页拿补充信息。这个阶段要更注意频率控制因为详情页的请求量通常是列表页的10倍以上。第三个是数据可视化。采集完CSV后用pandas读取配合matplotlib或pyecharts做“城市热门景点评分分布图”“景点评分Top10条形图”这种呈现方式比单纯一张表格更有说服力。如果你想把结果放到地图上可以使用folium结合经纬度信息绘制景点分布地图。按我个人的经验爬虫这个领域最值钱的不是“能爬到数据”这种结果而是“你明知道可能遇到什么问题还提前安排了应对方案”的工程思维。这个城市景点采集脚本虽然简单但里面包含了请求、解析、清洗、导出、反爬、容错这六块完整的能力把这套思路吃透以后遇到任何“给我抓点数据”的需求你都能在半小时内跑出第一版结果。最后分享一个我在维护类似工具时一直在用的习惯每次上线前手动跑一遍目标网站确认页面结构和选择器还生效。网站的改版频率远比你想的高很多爬虫失效不是因为代码过期而是因为页面结构变了。把这个检查放进你的日常清单里能少踩很多没必要的坑。