1. 项目整体设计与选型思路1.1 目标网站与数据源选择先说点实在话。做爬虫最忌讳一上来就爬那种加密参数满天飞、登录墙横着走的网站。天气数据是公开信息结构化程度高更新频率稳定而且每个城市都有自己的独立标识天生就是练手的绝佳素材。我当年就是因为在出行APP上逐个手动查五个城市七天的天气翻来找去实在烦了才动了写爬虫的念头。这次选的目标网站是国内公开的天气信息站点每个城市都有对应的9位城市编码例如北京是101010100上海是101020100。整个站点同时提供了HTML页面和JSON数据接口。我最终选择了基于JSON接口来做数据抓取理由很简单接口返回的数据已经结构化好了省去了用正则满天飞找字段的麻烦。但为了照顾想看页面解析的朋友我也会在后面讲一下HTML解析的备选方案。选择这个网站还有个私心它有所有城市的列表页而且页面层级很清晰——从省份到市再到区县层级分明。这正好用来练习“列表页→详情页”的两级爬虫架构。这个结构在很多真实项目里都能遇到比如电商爬分类再爬商品论坛爬版块再爬帖子学会了这套以后换个目标站点思路完全一样。需要注意的是爬取公开天气数据仅供个人学习和研究用途。我在代码里故意把请求间隔调得偏大频率控制得很保守目的也是想给大家传递一个习惯爬虫要学会克制。你抓数据越快被封概率越高对方服务器压力越大得不偿失。1.2 技术栈选型requests BeautifulSoup 还是另辟蹊径这个项目我用的主力库是requestsjson模块。有人会问为什么不用Scrapy我的回答是对于这个量级的项目Scrapy是大炮打蚊子。2000多个城市的七日天气预报用requests加线程池十几秒就能跑完全国。Scrapy的学习曲线陡峭安装依赖多迁到分布式还得部署Redis不适合入门阶段。再用到concurrent.futures.ThreadPoolExecutor做并发下载用csv或pandas把结果落盘。整个项目用纯标准库加一个requests就能完成我甚至连BeautifulSoup都没用到因为走的是JSON接口。如果你非要用页面解析那么我建议搭配lxml解析器性能远高于默认的html.parser。关于Selenium别一上来就上它。Selenium这东西会起一个真实浏览器内存占几百MB速度还慢爬2000个城市得跑到天荒地老。做爬虫的顺序永远应该是先用抓包工具看有没有JSON接口有的话绝对优先走接口实在没有再考虑解析HTML最后才轮到渲染引擎。我见过太多新手一碰到难题就不管三七二十一塞个Selenium进来结果数据是抓到了一点但性能差到让人怀疑人生。1.3 项目结构预览写爬虫之前先规划好目录别什么都往一个文件里塞。我最后的项目结构是weather_spider/ ├── cities.py # 城市编码抓取与解析 ├── fetcher.py # 请求与重试逻辑 ├── parser.py # 天气数据解析 ├── storage.py # 数据存储CSV/Excel ├── main.py # 主流程入口 └── requirements.txt # 依赖清单这个结构虽然简单但每个模块职责清晰cities.py负责搞定城市列表fetcher.py只负责发请求parser.py负责做解析storage.py管存储。这样做的好处是什么就是你后来想换目标站点比如改成爬股票只需要改cities.py和parser.py请求和存储完全不用动。这就是分层的好处。2. 核心实现细节与踩坑实录2.1 获取全国城市编码表别傻傻地手工整理全国城市编码哪来的肯定不是你一个一个手工输进去的哪怕有现成的文本你也得知道这个编码体系长什么样。我刚开始是先找了一个开源的城市编码JSON文件但它不够新有些县级市已经变了。最后决定自己去爬。我去了站点的城市列表页结构大致是进入首页能看到所有省份的入口点进某个省份能看到该省所有地市的入口再往下一层是区县列表。每一层的链接URL里都包含着城市ID例如https://www.某某weather.com.cn/weather/101010100.shtml这个101010100就是城市编码。写代码时我理清了爬取步骤第一步请求省份列表页用正则直接把[a-z]\.html形式的链接和省份名抓出来第二步依次请求每个省份页面同样拿城市链接和城市名称第三步对每个城市页面再往下挖一级区县最后把所有三级的编码和名称存成cities.csv字段是city_code,province,city,district。这里我不放全部代码了关键片段如下import re 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://www.weather.com.cn/ } source_url https://www.weather.com.cn # 示例实际使用时替换为目标站点 def get_provinces(): resp requests.get(source_url /city/index.html, headersheaders, timeout10) resp.encoding utf-8 html resp.text # 提取各省份链接和名称 pattern re.compile(ra href(.*?)img[^]*(.*?)/a) provinces pattern.findall(html) return provinces[:30] # 省份数量有限取前30个实际爬的时候我发现城市名的清洗是个坑。有的省份下会出现“省直辖县级行政区划”这种兜底目录点进去是一大堆县还有的会带“市辖区”字样没有实际的天气编码过滤掉就好。另外有些名称里有类似nbsp的实体编码记得用html.unescape处理一下。2.2 七日天气预报的数据请求与字段解析城市列表拿到手接下来说说详情页数据怎么取。我采用的是站点的公开JSON接口URL格式大概是这样的https://www.某某weather.com.cn/weather_7d/101010100.html。返回的不是一个纯JSON而是一段带回调函数的文本形如var citydata { 城市: 北京, 天气: [...] };这在技术上叫JSONP目的是为了跨域调用。对爬虫来说我们要做的是删掉前面的var citydata 和末尾的分号剩下的部分再交给json.loads解析。解析代码示例import json def parse_weather(resp_text): # 去除JSONP包装 split_text resp_text.split(, 1)[1].strip() data json.loads(split_text.strip().rstrip(;)) # 实际上返回结果里通常有 forecast 或 data 字段 forecast data.get(data, []) or data.get(forecast, []) result [] for day in forecast: result.append({ date: day.get(date), high_temp: day.get(temp_l), low_temp: day.get(temp_h), weather: day.get(w_text), wind: day.get(wd), }) return result这里有个新手极易踩的坑接口里可能同时存在temp_l和temp_h你可以通过字段名猜出低温和高温但不同接口字段名不一样有的叫dayTemp有的叫nightTemp。建议先用一个城市测试一遍把返回的全部键名打印出来再用json.dumps(data, ensure_asciiFalse, indent2)仔细看确认哪个字段对应哪个值。别偷懒这一步值得花十分钟。另外编码问题很恶心。有些接口回的是GBK编码的文本你用resp.text直接拿会乱码。稳妥的做法是看响应头里的Content-Type如果有charsetgbk就手动设置resp.encoding gbk。我写了一个通用小函数自动检测编码import chardet def decode_response(resp): if resp.encoding.lower() in (utf-8, gbk): return resp.text detected chardet.detect(resp.content) return resp.content.decode(detected.get(encoding, utf-8), errorsreplace)chardet这个库很方便但也要注意它检测大文本时会有点慢所以只在不确定编码时才启用。2.3 请求伪装与反爬虫初探写爬虫的人迟早会遇到反爬。我第一次快速爬取时跑到第500个城市就遇到HTTP 403了明明前面200个都好好的。这说明站点在请求频率上做了限制。解决思路我梳理成了三层第一层是基本伪装。给每个请求带上完整的浏览请求头至少包括User-Agent、Accept、Accept-Language和Referer。Referer得和详情页来源一致不能空着也不能填一个不相干的网址。我见到有人喜欢用随机UA库结果随机到了几年前的IE UA反而更容易被识别。建议维护一个两三个较新UA的小池子轮换用即可。第二层是频率控制。在每次请求前随机等待0.2到1秒。别小看这几百毫秒它能把单位时间内的请求量摊薄到完全不触发风控的水平。我没用固定延迟因为固定延迟本身也会暴露机器行为的特征——每个请求之间都精确相等在站点日志里一眼就能看出来。用time.sleep(random.uniform(0.2, 1))就好。第三层是失败后的退让。如果返回了403不要立即重试建议把这个城市编号丢进一个“待重试”列表等整轮爬完后再用一个更慢的速度二次请求。如果第二次还是403就放弃记录到日志里。真实项目里抓不到的数据有时需要换个代理IP才能解决但入门阶段不必搞代理池那么重先学会减速最重要。这里还要提一个观念上的东西不要认为反爬是刁难。站点设置限流本质是保护自己服务器不被打挂。你作为爬虫开发者如果能主动控制请求速率大概率永远碰不到风控。我有个朋友一开始无脑并发30线程抓同一个小网站结果对方运维直接给他IP封了。后来他换了个角度每天只固定跑一次再也没出过问题。爬虫不是偷东西是礼貌地请求公开资源。3. 完整实操过程从单城市到全国城市批量抓取3.1 边写边测单城市爬虫跑通先别想着全国跑通一个城市再说。我习惯先写一个最简单的单城市脚本跑通后再往上堆功能。这样定位问题特别快不至于写了两百行代码一发运行全是红色异常根本不知道哪儿错了。拿北京编码101010100测试代码长这样import requests import json import time headers { User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Accept: application/json, text/javascript, */*; q0.01, Referer: https://www.weather.com.cn/weather/101010100.shtml, } url https://www.weather.com.cn/weather_7d/101010100.html resp requests.get(url, headersheaders, timeout5) resp.encoding utf-8 # 注意resp.text里面是JSONP格式 raw resp.text json_str raw.split(, 1)[1].strip().rstrip(;) data json.loads(json_str) # 观察结构 print(data.keys()) print(data.get(city)) for d in data.get(data, []): print(d)第一次运行完我把返回的内容打印出来眼睛都花了因为除了未来七天的数据接口还带了一堆实况、空气质量之类的东西。不过没关系打印出来之后就是纯信息筛选的事了。我第一次跑的时候一晚上都在调这个输出格式反复比对哪个字段是白天温度、哪个是夜间温度。忘了说有些接口里temp_h和temp_l含义可能和你想的相反以官网页面显示为准。我后来干脆对照网页上同一城市的显示值反向确认字段含义这种方法最可靠。单城市跑通后我又做了一步封装把parse_weather函数做成独立模块将来任何城市都可以复用。同时我还加了简单的日志打印比如[OK] 北京 2024-06-01 多云 25°C/16°C这种格式。看控制台刷起来心里特别有成就感。3.2 多线程批量抓取全国城市单城市跑通是热身真正的战役是全国。全国大概有2400多个县级行政区就算每个请求0.5秒串行也得20分钟。这个速度不是不能忍但遇到网络抖动重试一次就得再等体感很差。我用线程池一改并发数调到20实测下来全部跑完只用了一分多钟——有的请求快的0.3秒慢的也就1秒线程多了吞吐量直接起飞。核心代码非常短from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_one(city): code, name city try: weather_list fetch_city_weather(code, name) return (code, name, weather_list) except Exception as e: logging.warning(f抓取失败: {name}, 原因: {e}) return (code, name, None) with ThreadPoolExecutor(max_workers20) as executor: futures [executor.submit(fetch_one, c) for c in city_list] for future in as_completed(futures): code, name, weather future.result() if weather: save_one(code, name, weather)这里我得认真说一下并发数的选择。20是一个比较稳的值。别看到有线程就开心开到100你的带宽不够对端服务器也扛不住很多连接直接超时反而更慢。我在测试中对比过并发数2000城市耗时结果1约25分钟稳定无失败10约3分钟稳定极少量超时20约1分20秒稳定少量超时重试后成功50约40秒有10%请求超时/拒绝100约20秒大量超时还被短暂拦截数据很明显20是一个甜点。如果你想让别人觉得你很含蓄就用10如果是自己练手20完全OK。关键是别贪。还有就是线程写共享的CSV文件时要加锁。我一开始直接把2000个结果往同一个CSV里写结果最后发现行数不对有一些行被覆盖了。后来用了一个threading.Lock包裹写文件的部分问题解决。如果你用pandas的pd.concat最后统一写那就不用加锁但内存会稍微大一点2000条这个量级无所谓。3.3 数据落地与可视化数据落盘我做了两种格式CSV和Excel。CSV是通用格式可以无缝导入Excel、数据库、数据分析工具Excel的话给非程序员朋友展示更方便。CSV版很简单import csv def save_to_csv(all_data, filenameweather.csv): with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([城市编码, 省份, 城市, 日期, 天气, 最高温, 最低温, 风力]) for row in all_data: writer.writerow(row)注意要用utf-8-sig而不是utf-8否则生成的CSV用Excel打开时表头和正文会全乱码。这个坑我踩过那次熬到半夜以为是数据问题折腾半天发现只是编码的锅。Excel版我用的是pandasopenpyxlimport pandas as pd df pd.DataFrame(all_data, columns[city_code, province, city, date, weather, high_temp, low_temp, wind]) df.to_excel(weather_forecast.xlsx, indexFalse, sheet_name七日天气)pandas的强大之处是后续分析特别方便。比如你拿到了全国城市某一日的最高气温可以直接用df.groupby(province)[high_temp].max()看省份极值也可以筛选出所有当天降雨的城市列表。作为项目演示我用matplotlib画了几座主要城市的七日最高温折线图代码非常简单import matplotlib.pyplot as plt cities [北京, 上海, 广州, 成都] for city in cities: city_df df[(df[city] city)] plt.plot(city_df[date], city_df[high_temp], labelcity) plt.legend() plt.xticks(rotation45) plt.ylabel(最高温 (°C)) plt.tight_layout() plt.savefig(temp_trend.png)这个图一画出来整个项目看起来像个端正的技术作品了。其实做爬虫项目数据可视化不是必须的但它能帮你快速发现数据解析的问题。比如如果某个城市的日期顺序是乱的、或者温度忽高忽低像个锯齿山一定是数据解析或者拼接出了问题。可视化是爬虫项目最好的Debug工具。4. 常见问题排查与性能优化实录4.1 遇到HTTP 403/418被拦截从单次到分布式在我迭代过程中最常见的错误码就是403和418。403代表服务器拒绝请求418代表服务器认为你是机器人。出现这两个码基本就一个原因请求特征太明显。排查步骤我给个清单第一检查请求头。是不是没带完整User-Agent是不是没加Referer加完之后立刻重试。第二检查请求频率。连续请求间隔是否过短把这个城市放到后面再跑或者直接加随机延迟。第三检查Cookie。有些站点你在浏览器能够正常打开是因为已经种了Cookie爬虫不带Cookie可能被拒。这种情况用requests.Session()保持会话先访问一次首页拿到初始Cookie再请求详情页。第四检查IP。单IP短时间内大量请求被临时封了。此时没有代理池就别硬撑歇几分钟再跑。如果非要代理池建议研究一下requests的proxies参数配合免费代理源但免费代理稳定性极差入门阶段别跳这个坑。这些绝大多数情况下都能解决问题。实在解决不了还有最后一招把网站页面打开用浏览器自带开发者工具手动看一次完整请求对比你的代码里缺少了哪些关键参数。这个方法适合任何未知反爬场景屡试不爽。4.2 数据解析为空正则、JSON转义、动态加载爬到一半发现某些城市解析出来的数据是空的或者某些字段莫名其妙失踪了。这种情况多半不是你代码的问题而是数据源本身返回的内容形态不统一。比如有的城市没有空气质量数据接口里就直接没有那个字段有的城市未来第七天返回了空值不给你温度。这时候你的解析逻辑里要用.get(xxx, )而非[xxx]因为后者遇到缺失直接抛异常。还有JSONP的转义问题。某些城市接口返回的字符串里包含了特殊字符比如\这种直接json.loads会报错。解决办法是先把字符串里的\\替换成或者干脆用更宽松的方式提取。但注意这不是万能的核心原则是先打印原始响应文本看30秒再动手改解析逻辑。还有一类动态加载的页面。如果你发现用requests拿到的HTML里根本找不到天气数据那很可能是页面通过JavaScript异步请求了接口。这就要用抓包工具来找真正的数据接口了。我个人推荐的抓包工具是浏览器开发者工具的Network面板。你用浏览器打开目标页面Network里面可以看到页面发起的全部网络请求逐个看响应找到包含天气数据的那个请求然后直接模拟它。这一套操作是爬虫的基本功。4.3 单点失败导致整个循环崩溃异常重试机制写爬虫尤其是批量爬2000个城市最怕的是运行到一半因为一个城市超时导致整个程序退出。一开始我写得很天真没有try-except结果进程在500个城市处崩掉一切从头再来。后来我搞了个可靠的重试机制。我封装了一个fetch_with_retry函数import time from functools import wraps def retry(max_retries3, delay1): def decorator(func): wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except Exception as e: if attempt max_retries - 1: time.sleep(delay * (attempt 1)) else: raise return None return wrapper return decorator retry(max_retries3, delay2) def fetch_city_weather(code, name): # 原有逻辑 ...细节在于每次重试等待时间递增。第一次失败等2秒第二次失败等4秒第三次还失败就不等了直接记失败。这样既不会立刻重试导致连环超时也不会傻傻地重试3次都集中在同一秒内。另外重试逻辑一定要在外层不要把整个批量循环包在try里否则一个坏城市照样拖垮一切。正确做法是每个任务独立捕获异常保证一个城市挂了不影响其他城市。这也是线程池方案天然的优势每个submitted任务都是隔离的。4.4 性能提升并发、异步、速度对比表关于性能优化上面提到了并发现在补充异步方案。实际用下来爬虫场景下asyncio比ThreadPoolExecutor更快因为IO密集型的请求大部分时间都在等待网络响应异步单线程就能利用这段时间并发处理其他任务。但异步代码写起来复杂回调、任务队列、限流都得自己控制对新手不太友好。我建议的顺序是先用简单多线程把项目跑通数据没问题之后再去研究异步。如果你直接上手异步遇到解析问题又遇到并发问题两件事搅在一起排查会很痛苦。我最终给这个项目加了一个aiohttp异步版本代码量多了一些但并发性能整体比线程池再快30%左右。完整版代码就不放上来了核心思路是通过asyncio.Semaphore控制并发数避免一口气发出2000个请求。到最后整个项目稳定跑起来我实测的结果是串行约25分钟零失败线程池20并发约1分20秒重试两次异步20信号量约55秒无失败写到这里我想说句经验之谈爬虫项目的性能优化是没有穷尽的但入门阶段要懂得适可而止。在我个人看来能用简单方式解决就别为了炫技引入复杂方案。多得是花了三天把线程池改异步结果收益只有30秒还把代码维护性搞差了。这个天气爬虫做完之后我最大的体会是爬虫不是“复制粘贴网页”而是对整条数据链路的一遍遍梳理。从城市列表的清洗到接口字段的确认再到并发控制、异常恢复、数据落盘每一步都考验耐心。你把这套流程走完以后再碰到网站看上去很乱的爬虫任务心态会很稳。继续深挖的话还可以把这个脚本放到服务器上定时运行配合数据库做出历史天气统计甚至可以做一个简单的查询网页这些都是同一个逻辑的自然延伸。