
1. 从一个看起来毫无难度的天气页面说起很多人做爬虫练习第一反应就是找个新闻网站或者电商平台练手结果一上来就被反爬机制教做人。我的建议恰恰相反先从结构简单、数据规整的公共服务类页面入手把请求—解析—存储这条完整链路跑通再去啃硬骨头。宁波天气爬取就是一个非常理想的练手对象——页面结构清晰、数据字段固定、没有复杂的登录态和加密参数但它麻雀虽小五脏俱全足以覆盖一个合格爬虫工程师需要掌握的核心技能点。这篇文章要聊的就是怎么把宁波天气这个看似简单的目标做成一个能长期稳定运行、数据可追溯、异常可恢复的小型数据采集系统。不是那种跑一次就扔的玩具脚本而是你愿意把它挂在服务器上每天定时跑、跑完还能自动整理成表格的那种。适合谁看如果你已经写过Hello World级别的爬虫知道requests.get()是干嘛的但还没完整地思考过一个爬虫项目从需求到落地到底要经历哪些环节那这篇内容就是为你准备的。我会从目标拆解开始把天气数据采集这件事拆成几个独立又关联的模块然后逐个讲清楚每个模块的设计思路、踩过的坑、以及为什么最终选了某个方案而不是另一个。中间会穿插大量实际调试过程中的经验包括编码问题、字段缺失、时间格式混乱这些只有真正跑过才懂的细节。2. 目标拆解宁波天气数据到底要采什么2.1 明确数据字段与采集频率动手写代码之前先拿张纸把需求写清楚。宁波天气这个目标表面上是把天气爬下来但具体要哪些字段、多久采一次、存成什么格式这些决策会直接影响后面的技术选型。我给自己定的需求是这样的字段名说明示例值date日期2025-01-15weekday星期星期三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;q0.9, } def fetch_page(url): resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() return resp.textUser-Agent是必须带的不带的话很多网站会直接返回403或者一个空页面。Accept-Language设成中文是因为有些页面会根据语言返回不同版本的内容我们要确保拿到的是中文版。但这里要强调一个克制原则只加必要的请求头不要伪造一堆用不上的字段。有些人喜欢把Referer、Cookie、X-Forwarded-For全塞进去觉得越像浏览器越好。实际上对于公开的天气页面一个正常的User-Agent就够了。过度伪装反而可能触发某些风控逻辑。timeout10这个参数千万别省。我踩过的坑有一次网络波动请求卡在那里十几分钟没返回整个脚本就挂住了。加上超时最多等10秒就抛异常脚本能继续往下走。3.2 编码问题中文乱码的根源与解法请求回来之后第一个容易翻车的地方就是编码。resp.text的编码是requests根据HTTP响应头里的Content-Type猜的。如果响应头里没写编码或者写错了resp.text就会返回乱码。这时候你有两个选择# 方案一手动指定编码 resp.encoding utf-8 text resp.text # 方案二从内容里探测编码 text resp.content.decode(utf-8, errorsignore)我的做法是先看响应头再决定def fetch_page(url): resp requests.get(url, headersHEADERS, timeout10) 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, retries3): for i in range(retries): try: resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() return resp except RequestException as e: print(f第{i1}次请求失败: {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 classweather-info ul classday-list li classday-item span classdate2025-01-15/span span classweek星期三/span span classweather多云转晴/span span classtemp-high12℃/span span classtemp-low3℃/span span classwind东北风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(stripTrue) 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(stripTrue) 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_raw12℃也存temp_high12。这样万一清洗逻辑有问题还能回溯原始数据重新处理。5. 存储环节选对格式后续分析省一半力气5.1 CSV vs JSON vs SQLite按使用场景选存储格式的选择取决于你后续怎么用这份数据格式适合场景优点缺点CSV人工查看、Excel分析直观、通用不支持嵌套结构JSON程序读取、API对接结构灵活人工查看不友好SQLite长期积累、复杂查询支持SQL、单文件需要额外学习我的做法是两个都存每天采集完存一份CSV方便快速查看同时写入SQLite做长期积累。CSV按日期命名比如ningbo_weather_20250115.csvSQLite就一个文件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, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(records) def save_to_sqlite(records, db_pathweather.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下用crontabWindows下用任务计划程序。最轻量适合单机。Python调度库schedule、APScheduler。适合需要复杂调度逻辑的场景。容器化调度把脚本打包成Docker镜像用编排工具管理。适合多任务、多环境。作为练习项目我推荐先用crontab或系统计划任务简单直接。等你有多个爬虫需要管理了再考虑上调度框架。# crontab 示例每天早上7点执行 0 7 * * * /usr/bin/python3 /path/to/ningbo_weather.py /path/to/logs/weather.log 21把标准输出追加到日志文件21把错误输出也重定向过去。这样脚本跑了什么、有没有报错都能在日志里看到。6.2 日志记录出问题时能快速定位日志不要用print用logging模块import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.FileHandler(weather_crawler.log, encodingutf-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_emailexample.com msg[To] your_emailexample.com with smtplib.SMTP_SSL(smtp.example.com, 465) as server: server.login(your_emailexample.com, your_password) server.send_message(msg)当然邮件配置涉及具体服务商的参数这里只是示意。核心思路是当异常持续发生时主动通知你而不是等你某天想起来去看才发现数据已经断了一周。7. 那些只有真正跑过才会遇到的坑7.1 页面结构说变就变这是爬虫最头疼的问题。今天跑得好好的明天突然解析不到数据了。原因通常是目标网站改了HTML结构——class名变了、标签换了、数据挪到别的容器里了。应对策略有三条选择器尽量用稳定的属性。优先用id其次用有语义的class避免用那种一看就是自动生成的随机class名比如classa3f2b1。加解析结果校验。如果解析出来0条记录或者关键字段全为空就触发告警。保留原始HTML。每次采集把HTML存一份出问题时可以对比前后差异快速定位变化点。def save_raw_html(html, date_str): filepath fraw_html/{date_str}.html os.makedirs(raw_html, exist_okTrue) with open(filepath, w, encodingutf-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(hours8)) 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页面把最新天气和历史趋势展示出来。这就从一个爬虫项目变成了一个完整的数据采集展示应用。每个方向的扩展都不复杂但能让你的项目价值提升一个档次。关键是先把核心采集链路做稳扩展功能都是在这个基础上叠加的。我个人在实际操作中的体会是爬虫项目最大的挑战从来不是怎么写代码把数据抓下来而是怎么让它在各种意外情况下依然稳定运行。网络会波动、页面会改版、服务器会重启、磁盘会满——这些才是真正消耗精力的地方。所以从第一个练习项目开始就养成写日志、做校验、加异常处理的习惯后面会省很多事。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。