news 2026/10/13 0:25:44

Python爬虫实战:宁波天气数据采集与存储全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python爬虫实战:宁波天气数据采集与存储全流程

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.text

User-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.text

apparent_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名变了、标签换了、数据挪到别的容器里了。

应对策略有三条:

  1. 选择器尽量用稳定的属性。优先用id,其次用有语义的class,避免用那种一看就是自动生成的随机class名(比如class="a3f2b1")。
  2. 加解析结果校验。如果解析出来0条记录,或者关键字段全为空,就触发告警。
  3. 保留原始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页面,把最新天气和历史趋势展示出来。这就从一个爬虫项目变成了一个完整的"数据采集+展示"应用。

每个方向的扩展都不复杂,但能让你的项目价值提升一个档次。关键是先把核心采集链路做稳,扩展功能都是在这个基础上叠加的。

我个人在实际操作中的体会是:爬虫项目最大的挑战从来不是"怎么写代码把数据抓下来",而是"怎么让它在各种意外情况下依然稳定运行"。网络会波动、页面会改版、服务器会重启、磁盘会满——这些才是真正消耗精力的地方。所以从第一个练习项目开始,就养成写日志、做校验、加异常处理的习惯,后面会省很多事。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 0:24:26

AHA优化CNN-LSTM-Attention:Matlab客流预测超参数调优实战

简介&#xff1a;该资源面向计算机、电子信息工程、数学等专业的大学生及算法初学者&#xff0c;提供一套基于人工蜂鸟优化算法AHA融合CNN、LSTM与Attention的客流量预测Matlab实现方案&#xff0c;可用于课程设计、期末大作业或毕业设计。压缩包共19个文件&#xff0c;约217KB…

作者头像 李华
网站建设 2026/10/13 0:23:42

RD-Agent 找不到 litellm 包元数据?把依赖源改到 TaoToken 的排查路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 0:21:36

Java集合框架核心:从Collection接口到List与Set实现原理与选型

1. 集合框架的起点&#xff1a;为什么必须先搞懂Collection接口很多同学刚开始学Java集合时&#xff0c;容易一头扎进ArrayList、HashMap的源码里&#xff0c;背了一堆方法名和底层结构&#xff0c;真到写代码的时候却不知道该选哪个。这个问题我见过太多次了&#xff0c;归根结…

作者头像 李华
网站建设 2026/10/13 0:17:24

软件测试面试高频题全解析:答题思路与避坑指南

面试这件事&#xff0c;说穿了就是一场“信息差”的较量。我做了十几年软件测试&#xff0c;也面试过几百个候选人&#xff0c;最大的感受是&#xff1a;大部分人挂在面试上&#xff0c;不是技术不行&#xff0c;而是根本不知道面试官手里的“软件测试面试题”在问什么、想听什…

作者头像 李华
网站建设 2026/10/13 0:17:14

电影票房影响因素分析与预测:Python数据分析与可视化实战

简介&#xff1a;这份基于Python的电影数据可视化及票房影响因素分析与预测项目&#xff0c;主要面向计算机相关专业正在准备毕业设计的学生&#xff0c;以及需要项目实战练习的学习者&#xff0c;也可直接用于课程设计或期末大作业。项目包含全部源码&#xff0c;经过严格调试…

作者头像 李华
网站建设 2026/10/13 0:11:45

趣博思AI的AIGC检测,为什么是“先看病再开药”的设计?

大家好&#xff0c;我是那个教你们写论文的教育博主。 今天聊一个很多人问过我的问题&#xff1a;AIGC检测到底在检测什么&#xff1f; 你可能已经遇到过这种情况&#xff1a;论文是自己写的&#xff0c;但查重过了&#xff0c;AIGC检测却亮了一片红。导师说“这段AI味儿太重”…

作者头像 李华