1. 从一个"看起来毫无难度"的天气页面说起
很多人做爬虫练习,第一反应就是找个新闻网站或者电商平台练手,结果一上来就被反爬机制教做人。我的建议恰恰相反:先从结构简单、数据规整的公共服务类页面入手,把"请求—解析—存储"这条完整链路跑通,再去啃硬骨头。宁波天气爬取就是一个非常理想的练手对象——页面结构清晰、数据字段固定、没有复杂的登录态和加密参数,但它麻雀虽小五脏俱全,足以覆盖一个合格爬虫工程师需要掌握的核心技能点。
这篇文章要聊的,就是怎么把"宁波天气"这个看似简单的目标,做成一个能长期稳定运行、数据可追溯、异常可恢复的小型数据采集系统。不是那种跑一次就扔的玩具脚本,而是你愿意把它挂在服务器上每天定时跑、跑完还能自动整理成表格的那种。适合谁看?如果你已经写过"Hello World"级别的爬虫,知道requests.get()是干嘛的,但还没完整地思考过"一个爬虫项目从需求到落地到底要经历哪些环节",那这篇内容就是为你准备的。
我会从目标拆解开始,把天气数据采集这件事拆成几个独立又关联的模块,然后逐个讲清楚每个模块的设计思路、踩过的坑、以及为什么最终选了某个方案而不是另一个。中间会穿插大量实际调试过程中的经验,包括编码问题、字段缺失、时间格式混乱这些"只有真正跑过才懂"的细节。
2. 目标拆解:宁波天气数据到底要采什么
2.1 明确数据字段与采集频率
动手写代码之前,先拿张纸把需求写清楚。宁波天气这个目标,表面上是"把天气爬下来",但具体要哪些字段、多久采一次、存成什么格式,这些决策会直接影响后面的技术选型。
我给自己定的需求是这样的:
| 字段名 | 说明 | 示例值 |
|---|---|---|
| date | 日期 | 2025-01-15 |
| weekday | 星期 | 星期三 |
| weather | 天气状况 | 多云转晴 |
| temp_high | 最高温 | 12℃ |
| temp_low | 最低温 | 3℃ |
| wind | 风向风力 | 东北风3级 |
| air_quality | 空气质量 | 良 |
采集频率定为每天一次,早上7点左右。为什么选这个时间?因为天气预报页面通常在凌晨更新当日数据,7点采集既能拿到最新预报,又不会和页面更新过程冲突。如果你要做历史数据分析,那可能需要每天多采几次,但作为练习项目,一天一次足够了。
提示:采集频率的设定要尊重目标网站的服务能力。天气类公共服务页面本身承载着大量正常用户访问,过于频繁的请求既无必要也不礼貌。一天一次到几次是完全合理的范围。
2.2 为什么选这个页面而不是API
有人会问:天气数据不是有现成的API吗,为什么要爬页面?这个问题问得好,值得展开说说。
API的优势是数据结构化、字段明确、调用稳定。但API通常需要申请密钥、有调用次数限制、部分功能还要付费。而爬取公开页面的优势在于:不需要密钥、数据展示形式就是最终用户看到的样子、适合练习完整的爬虫链路。
从学习角度讲,爬页面能让你接触到HTML解析、编码处理、动态内容判断这些API调用中不会遇到的问题。从实用角度讲,如果你只是想要一份宁波天气的日常记录,爬页面完全够用,而且不依赖任何第三方服务的授权。
当然,如果目标网站提供了公开的API接口,优先用API是更规范的做法。这里选择爬页面,核心目的是练习,这一点在项目定位上要清晰。
2.3 技术栈选型:为什么是requests + BeautifulSoup
技术选型这块,我对比了几种常见组合:
- requests + BeautifulSoup:上手快,解析静态HTML足够,调试直观。
- requests + lxml:解析速度更快,但XPath语法对新手不够友好。
- Scrapy:功能全,但为一个单页面爬虫引入整个框架属于杀鸡用牛刀。
- Selenium/Playwright:能处理动态渲染,但资源消耗大,启动慢。
最终选requests + BeautifulSoup,理由很实在:宁波天气页面是服务端渲染的静态HTML,用不上浏览器自动化;数据量小,性能不是瓶颈;BeautifulSoup的find和select方法写起来直观,出错了容易定位。
这里有个经验:不要因为某个工具"更高级"就无脑选它。工具是拿来解决问题的,不是拿来炫技的。一个每天跑一次、解析几十行HTML的脚本,用最朴素的方案反而最稳。
3. 请求环节:把页面内容完整拿回来
3.1 请求头的伪装与克制
直接上代码:
import requests 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-Language": "zh-CN,zh;q=0.9", } def fetch_page(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() return resp.textUser-Agent是必须带的,不带的话很多网站会直接返回403或者一个空页面。Accept-Language设成中文,是因为有些页面会根据语言返回不同版本的内容,我们要确保拿到的是中文版。
但这里要强调一个克制原则:只加必要的请求头,不要伪造一堆用不上的字段。有些人喜欢把Referer、Cookie、X-Forwarded-For全塞进去,觉得越像浏览器越好。实际上,对于公开的天气页面,一个正常的User-Agent就够了。过度伪装反而可能触发某些风控逻辑。
timeout=10这个参数千万别省。我踩过的坑:有一次网络波动,请求卡在那里十几分钟没返回,整个脚本就挂住了。加上超时,最多等10秒就抛异常,脚本能继续往下走。
3.2 编码问题:中文乱码的根源与解法
请求回来之后,第一个容易翻车的地方就是编码。
resp.text的编码是requests根据HTTP响应头里的Content-Type猜的。如果响应头里没写编码,或者写错了,resp.text就会返回乱码。这时候你有两个选择:
# 方案一:手动指定编码 resp.encoding = "utf-8" text = resp.text # 方案二:从内容里探测编码 text = resp.content.decode("utf-8", errors="ignore")我的做法是先看响应头,再决定:
def fetch_page(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() if resp.encoding is None or resp.encoding.lower() == "iso-8859-1": resp.encoding = resp.apparent_encoding return resp.textapparent_encoding是requests基于chardet库做的编码探测,准确率还不错。但要注意,它有时候会把GBK误判成其他编码,所以最稳妥的方式还是打开浏览器开发者工具,看响应头里的Content-Type字段,确认目标页面到底用的什么编码,然后写死。
提示:国内不少老网站还在用GBK或GB2312编码。如果你发现中文全是问号或者方块,八成就是编码没对上。别急着怀疑解析代码,先检查编码。
3.3 异常处理:网络请求不能裸奔
一个能长期运行的爬虫,请求环节必须包上完整的异常处理:
from requests.exceptions import RequestException def safe_fetch(url, retries=3): for i in range(retries): try: resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() return resp except RequestException as e: print(f"第{i+1}次请求失败: {e}") if i == retries - 1: raise time.sleep(2 ** i) return None这里用了指数退避:第一次失败等2秒,第二次等4秒,第三次等8秒。为什么不用固定间隔?因为如果目标服务器暂时过载,固定间隔的重试可能持续给它压力;指数退避给了服务器恢复的时间,也提高了重试成功的概率。
raise_for_status()会自动检查HTTP状态码,4xx和5xx都会抛异常。这样你就不用自己写if resp.status_code == 200了。
4. 解析环节:从HTML里精准提取天气字段
4.1 定位策略:先看结构再写选择器
拿到HTML之后,别急着写代码。先在浏览器里按F12,用元素检查工具看看目标数据的DOM结构。
假设宁波天气页面的结构大致是这样的(实际结构以你打开页面时为准):
<div class="weather-info"> <ul class="day-list"> <li class="day-item"> <span class="date">2025-01-15</span> <span class="week">星期三</span> <span class="weather">多云转晴</span> <span class="temp-high">12℃</span> <span class="temp-low">3℃</span> <span class="wind">东北风3级</span> </li> <!-- 更多日期... --> </ul> </div>定位策略的核心思路是:先找到包含所有日期数据的容器,再在容器内逐条提取。不要一上来就用全局选择器去抓每个字段,那样容易抓到页面其他位置的同名元素。
from bs4 import BeautifulSoup def parse_weather(html): soup = BeautifulSoup(html, "html.parser") container = soup.find("ul", class_="day-list") if not container: return [] results = [] for item in container.find_all("li", class_="day-item"): record = { "date": get_text(item, "span", "date"), "weekday": get_text(item, "span", "week"), "weather": get_text(item, "span", "weather"), "temp_high": get_text(item, "span", "temp-high"), "temp_low": get_text(item, "span", "temp-low"), "wind": get_text(item, "span", "wind"), } results.append(record) return results def get_text(parent, tag, class_name): el = parent.find(tag, class_=class_name) return el.get_text(strip=True) if el else ""4.2 容错设计:字段缺失时怎么办
实际页面不会永远完美。有时候某个字段的class名变了,有时候某个日期没有空气质量数据,有时候页面结构做了微调。如果代码里写死了item.find("span", class_="temp-high").get_text(),一旦这个元素不存在,就会抛AttributeError,整个解析流程中断。
我的做法是每个字段独立提取,缺失时返回空字符串,而不是让整个记录失败。这样即使某个字段抓不到,其他字段的数据还是能保存下来。后续分析时发现某个字段大量为空,再去排查是页面变了还是数据源本身就没有。
def get_text(parent, tag, class_name, default=""): el = parent.find(tag, class_=class_name) if el is None: return default text = el.get_text(strip=True) return text if text else default这个get_text函数看起来简单,但它是我所有爬虫项目里的标配工具函数。把容错逻辑封装在工具函数里,主流程代码就能保持干净,不用到处写if el is not None。
4.3 数据清洗:温度、风力、日期的标准化
原始抓下来的文本往往带着各种"杂质":
- 温度:"12℃" → 需要提取数字12
- 风力:"东北风3级" → 需要拆出风向"东北"和风力"3级"
- 日期:"2025-01-15" → 可能需要转成
datetime对象
import re from datetime import datetime def clean_temp(text): match = re.search(r"-?\d+", text) return int(match.group()) if match else None def clean_wind(text): match = re.search(r"([东南西北]+风)(\d+级)?", text) if match: return match.group(1), match.group(2) or "" return text, "" def clean_date(text): try: return datetime.strptime(text, "%Y-%m-%d").date() except ValueError: return None这里有个经验:清洗规则要基于实际数据的分布来定,不要凭想象写正则。我一开始以为温度格式很统一,结果发现有时候是"12℃",有时候是"12°C",有时候还带个"左右"。所以正则要写得宽松一点,先匹配到数字再说。
提示:清洗后的数据建议保留一份原始文本。比如温度字段,既存
temp_high_raw="12℃",也存temp_high=12。这样万一清洗逻辑有问题,还能回溯原始数据重新处理。
5. 存储环节:选对格式,后续分析省一半力气
5.1 CSV vs JSON vs SQLite:按使用场景选
存储格式的选择,取决于你后续怎么用这份数据:
| 格式 | 适合场景 | 优点 | 缺点 |
|---|---|---|---|
| CSV | 人工查看、Excel分析 | 直观、通用 | 不支持嵌套结构 |
| JSON | 程序读取、API对接 | 结构灵活 | 人工查看不友好 |
| SQLite | 长期积累、复杂查询 | 支持SQL、单文件 | 需要额外学习 |
我的做法是两个都存:每天采集完存一份CSV方便快速查看,同时写入SQLite做长期积累。CSV按日期命名,比如ningbo_weather_20250115.csv;SQLite就一个文件weather.db,所有历史数据都在里面。
import csv import sqlite3 def save_to_csv(records, filepath): if not records: return fieldnames = ["date", "weekday", "weather", "temp_high", "temp_low", "wind"] with open(filepath, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(records) def save_to_sqlite(records, db_path="weather.db"): conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS weather ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT, weekday TEXT, weather TEXT, temp_high INTEGER, temp_low INTEGER, wind TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(date) ) """) for r in records: cursor.execute(""" INSERT OR REPLACE INTO weather (date, weekday, weather, temp_high, temp_low, wind) VALUES (?, ?, ?, ?, ?, ?) """, (r["date"], r["weekday"], r["weather"], r["temp_high"], r["temp_low"], r["wind"])) conn.commit() conn.close()注意CSV的编码用了utf-8-sig,这个-sig是干嘛的?它会在文件开头加一个BOM标记,让Excel打开时能正确识别UTF-8编码。不加的话,Excel打开中文CSV经常乱码。这是我在实际使用中踩过的坑,写在这里帮你省时间。
SQLite那边用了UNIQUE(date)约束和INSERT OR REPLACE,这样同一天重复采集不会产生重复记录,而是覆盖更新。幂等性是数据采集系统的重要特性——不管跑多少次,结果应该是一致的。
5.2 数据校验:入库前的最后一道关
数据存进去之前,最好做一轮校验:
def validate_record(record): if not record.get("date"): return False if record.get("temp_high") is None and record.get("temp_low") is None: return False return True校验规则不用太复杂,核心是确保关键字段不为空。日期是主键,必须有;温度是核心数据,至少得有一个。其他字段缺失可以容忍,但这两个不行。
如果校验不通过,把这条记录单独记到一个failed_records.log里,方便后续排查。不要直接丢弃,也不要强行入库。
6. 调度与监控:让爬虫自己跑起来
6.1 定时任务的几种实现方式
脚本写好了,总不能每天手动运行。定时执行有几种方案:
- 系统计划任务:Linux下用
crontab,Windows下用"任务计划程序"。最轻量,适合单机。 - Python调度库:
schedule、APScheduler。适合需要复杂调度逻辑的场景。 - 容器化调度:把脚本打包成Docker镜像,用编排工具管理。适合多任务、多环境。
作为练习项目,我推荐先用crontab或系统计划任务,简单直接。等你有多个爬虫需要管理了,再考虑上调度框架。
# crontab 示例:每天早上7点执行 0 7 * * * /usr/bin/python3 /path/to/ningbo_weather.py >> /path/to/logs/weather.log 2>&1>>把标准输出追加到日志文件,2>&1把错误输出也重定向过去。这样脚本跑了什么、有没有报错,都能在日志里看到。
6.2 日志记录:出问题时能快速定位
日志不要用print,用logging模块:
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler("weather_crawler.log", encoding="utf-8"), logging.StreamHandler() ] ) logger = logging.getLogger(__name__)关键节点都打上日志:请求开始、请求成功、解析到多少条记录、入库成功多少条、有没有异常。这样出问题时,翻日志就能快速定位是哪一步出了问题。
我习惯在日志里记录每次采集的耗时:
import time start = time.time() # ... 采集流程 ... logger.info(f"本次采集完成,耗时{time.time()-start:.2f}秒,入库{len(records)}条")如果某天耗时突然从2秒变成30秒,说明网络或者目标网站可能有问题,值得关注。
6.3 失败告警:别等数据断了才发现
最简单的告警方式:连续失败N次后发邮件。用Python的smtplib就能实现:
import smtplib from email.mime.text import MIMEText def send_alert(subject, content): msg = MIMEText(content, "plain", "utf-8") msg["Subject"] = subject msg["From"] = "your_email@example.com" msg["To"] = "your_email@example.com" with smtplib.SMTP_SSL("smtp.example.com", 465) as server: server.login("your_email@example.com", "your_password") server.send_message(msg)当然,邮件配置涉及具体服务商的参数,这里只是示意。核心思路是:当异常持续发生时,主动通知你,而不是等你某天想起来去看才发现数据已经断了一周。
7. 那些只有真正跑过才会遇到的坑
7.1 页面结构说变就变
这是爬虫最头疼的问题。今天跑得好好的,明天突然解析不到数据了。原因通常是目标网站改了HTML结构——class名变了、标签换了、数据挪到别的容器里了。
应对策略有三条:
- 选择器尽量用稳定的属性。优先用
id,其次用有语义的class,避免用那种一看就是自动生成的随机class名(比如class="a3f2b1")。 - 加解析结果校验。如果解析出来0条记录,或者关键字段全为空,就触发告警。
- 保留原始HTML。每次采集把HTML存一份,出问题时可以对比前后差异,快速定位变化点。
def save_raw_html(html, date_str): filepath = f"raw_html/{date_str}.html" os.makedirs("raw_html", exist_ok=True) with open(filepath, "w", encoding="utf-8") as f: f.write(html)原始HTML不用长期保留,存个最近7天的就够了,占不了多少空间。
7.2 编码问题比你想象的更顽固
前面提过编码,这里再补充一个实际案例。有一次我抓下来的数据里,大部分中文正常,但"℃"这个符号变成了"鈩"——这是典型的UTF-8被当成GBK解码的结果。
排查过程:先看响应头编码,是utf-8;再看resp.text,乱码;改用resp.content.decode("utf-8"),正常了。原因是requests在某些情况下会错误地覆盖编码判断。
经验总结:当你确认页面是UTF-8编码但resp.text仍然乱码时,直接用resp.content.decode("utf-8"),绕过requests的自动编码判断。
7.3 时间格式的坑:时区与跨天
天气数据里的日期通常是目标网站所在时区的日期。如果你在服务器上跑脚本,服务器时区可能是UTC,和北京时间差8小时。早上7点(北京时间)跑脚本,服务器UTC时间是前一天23点,如果脚本里用datetime.now()取日期,就会取到前一天。
解法很简单:明确指定时区。
from datetime import datetime, timezone, timedelta CST = timezone(timedelta(hours=8)) today = datetime.now(CST).date()不要依赖服务器的本地时区设置,显式指定最稳妥。
7.4 重复采集与数据去重
如果你手动跑了一次脚本,定时任务又跑了一次,同一天的数据就会重复。前面SQLite用了UNIQUE(date)约束解决了入库去重,但CSV文件是按日期命名的,重复跑会覆盖,倒也不会产生重复文件。
但如果你是按时间戳命名CSV(比如weather_20250115_070001.csv),那就需要额外的去重逻辑。我的建议是按日期命名,覆盖写入,简单可靠。
8. 从练习项目到实用工具的扩展思路
这个宁波天气爬虫跑通之后,其实可以往几个方向扩展,让它从一个"练习"变成真正有用的工具。
方向一:多城市对比。把城市名做成配置项,同一套代码采集多个城市的天气,存到同一张表里,加个city字段区分。这样你就能做"哪个城市今天最热"这种对比分析。
方向二:历史数据分析。积累几个月的数据之后,可以画温度变化曲线、统计降雨天数、分析空气质量趋势。用pandas读SQLite数据,matplotlib画图,几十行代码就能出图。
方向三:异常天气提醒。在采集完成后加一段逻辑:如果明天最高温超过35℃或者最低温低于0℃,就发一条提醒。这个功能在夏天和冬天特别实用。
方向四:数据可视化面板。用Flask或FastAPI搭一个简单的Web页面,把最新天气和历史趋势展示出来。这就从一个爬虫项目变成了一个完整的"数据采集+展示"应用。
每个方向的扩展都不复杂,但能让你的项目价值提升一个档次。关键是先把核心采集链路做稳,扩展功能都是在这个基础上叠加的。
我个人在实际操作中的体会是:爬虫项目最大的挑战从来不是"怎么写代码把数据抓下来",而是"怎么让它在各种意外情况下依然稳定运行"。网络会波动、页面会改版、服务器会重启、磁盘会满——这些才是真正消耗精力的地方。所以从第一个练习项目开始,就养成写日志、做校验、加异常处理的习惯,后面会省很多事。