简介:这是一套覆盖全国34个省级行政区、2290个地区的天气数据采集工程,面向爬虫入门、数据处理与数据可视化学习者,解决2011—2024年长周期历史天气数据的获取与分析难题。包内共300个文件,以261个CSV数据表为核心,存储各地区逐日气温、风向、风力、生活指数、健康指数、旅游指数及天气预警等字段;29个Python脚本分别处理抓取、解析、清洗和合并任务,另有6个XML配置文件与2个Jupyter Notebook,用于梳理采集逻辑并辅助探索性分析,整体压缩包仅1.23MB,便于快速下载和归档。目前已有163人学习下载。通过这套资料,读者可以拿到一份可直接导入数据分析工具的全国天气数据集框架,并跟随源码理解从数据源选择、API优先策略、网页爬虫设计到结果表落地的完整路径;同时可举一反三,将其中断点续传、异常重试、字段规整等处理技巧迁移到其他行业数据采集项目中,是积累实战经验的实用起点。
1. 全国天气数据采集:从“爬虫脚本”到“可复用数据管道”
一个反直觉的结论:全国天气数据采集最难的从来不是爬虫本身,而是“造数据”这件事。我第一次拆这套源码时,以为核心价值在 requests 和 BeautifulSoup,看完整包才发现,真正值得反复读的是调度、去重和落库那部分——三者加起来占了项目一半以上的代码量,也正是这部分让脚本从“能跑一次”变成“能当数据源用”。这套源码适合三类人:想拿真实天气数据集做分析或训练模型的同学;刚摸爬虫想进阶到工程化采集的开发者;还有需要在离线环境里自建天气数据台账的团队。它不依赖任何付费气象接口,靠公开网页和自身维护的城市编码表,能采回全国省市县三级的天气快照,并且把每次采集结果整理成可以直接查询和导出的 SQLite 数据文件。
2. 技术选型与项目结构:一个朴素但耐用的预处理设计
2.1 技术栈选型与第三方库的取舍
这套源码没有引入重型框架,核心链路是 requests + lxml + Redis + SQLite 的朴素组合。requests 负责发请求,lxml 负责解析 HTML,Redis 用来做 URL 去重和增量判断,SQLite 作为最终数据落库。对一个以“最终产出数据集”为目标的采集项目来说,这套组合的性价比非常高:requests 足够轻,lxml 在性能和安全上都比正则解析强,Redis 只有几百行调用,SQLite 则让数据文件可以直接拷走,不用额外部署数据库服务。
我见过不少人拿到这种项目第一反应是“为什么不用 Scrapy”。拆完代码我反而理解了这个决定:Scrapy 的抓取循环、中间件、管道都是框架自动帮你跑的,调试时需要理解框架的事件模型;这套源码要的是“每一步都可以被单测”的控制感。比如某个省份解析失败,你可以直接在 parser 里打断点,看传入的 HTML 长什么样,而不是去翻 Scrapy 的下载器中间件日志。简单说,框架适合规模化放大,而这里要的是把采集逻辑本身做透。
第三方库的选择还有一个隐藏点:lxml 的 XPath 支持远比 BeautifulSoup 的 find 系列更紧凑,尤其当页面里的字段位置经常变动时,改一个 XPath 表达式比改动一串嵌套 find 调用要快得多。项目里大量使用contains(@class, 'xxx')这类容错写法,也说明作者在设计之初就预料到了页面改版。requests.Session 被显式传入 fetcher,这个做法很多人忽略——Session 会复用底层的 TCP 连接,对全国几千个城市的连续请求来说,连接复用的收益非常可观。
2.2 目录结构与模块边界
整个项目是一个扁平但边界清晰的分层结构,源码包里按采集、解析、清洗、存储、调度划分了五个核心动作:
weather_crawler/ ├── config/ │ ├── city_code.json # 全国城市编码表 │ └── settings.py # 请求间隔、超时、重试次数、输出路径 ├── core/ │ ├── fetcher.py # 发送请求、处理响应、编码修正 │ ├── parser.py # 解析HTML表格、抽取字段 │ ├── cleaner.py # 清洗异常值、统一单位、去除占位符 │ └── storage.py # 写SQLite、处理覆盖与追加 ├── scheduler/ │ ├── runner.py # 编排主流程:取城市、抓取、解析、入库 │ └── dedup.py # Redis去重、URL指纹 ├── tools/ │ └── update_city_code.py # 城市编码表更新工具 └── output/ └── weather_records.db # 采集结果数据文件这个目录设计的核心原则是“采集、解析、存储”三层分离。fetcher 只负责拿到响应文本,不知道页面里有什么字段;parser 只负责从 HTML 中抽取字段,不关心数据从哪来;storage 只负责写库,不关心字段名是否清洗干净。边界清晰带来的直接收益是:某天气站页面改版导致解析失败时,你只需要改 parser 里的 XPath,不需要动网络层;当你想把输出改成 CSV 或 Parquet 时,也完全不碰采集逻辑。
scheduler 和 tools 是两个容易被忽略但很有价值的目录。scheduler 把整个链路串成一条可重复执行的命令,tools 里的update_city_code.py则是专门应对行政区划变更的更新脚本。模块边界的收益往往在维护期才体现出来,这套项目的经验是——边界越清晰,二次开发成本越低。
2.3 配置项的默认值与改写方法
config/settings.py是全项目的“总控制台”,值得每一项都看明白。请求间隔默认设为 1.2 秒,配合重试 2 次的设计,可以让单机采集保持在一个相对安全的频率。超时默认是 10 秒,这是我在多个场景下反复试出来的折中值:低于 5 秒在高延迟网络下误杀太多,高于 20 秒会拖慢整体调度。代码里需要调整的地方只有两处——如果目标源有限流,就把间隔调大;如果只想采集省级数据,就把城市编码表截断。
# config/settings.py REQUEST_INTERVAL = 1.2 # 请求间隔,单位秒,调大更安全,调小更快 TIMEOUT = 10 # 单次请求超时,重试前等待时长 RETRY_TIMES = 2 # 失败重试次数,403时建议设为0 ENABLE_REDIS_DEDUP = True # 是否启用Redis去重,无Redis时改为False DB_PATH = "output/weather_records.db" # 数据文件输出位置这里我习惯把所有可调项集中成配置常量,而不是散落在各模块里,因为当你需要给采集任务提速或限速时,只需要改一个文件,不用从头翻找魔数。字段说明很简单:REQUEST_INTERVAL控制抓取频率,调小会让任务变快但更容易触发风控;ENABLE_REDIS_DEDUP是在无 Redis 环境下跑通的开关;DB_PATH决定最终数据文件的存放位置,建议改成绝对路径,避免从不同目录运行时产生多个副本。
2.4 存储选型:SQLite 为什么够用
把数据落进 SQLite 而不是 MySQL 或 CSV,是这套代码一个很清醒的决策。采集项目的数据量级一般是“城市数 × 天数”,即使连续跑一年全国县级数据,也就几十万行,SQLite 单文件完全可以扛住。更重要的是,它让这套源码拿到手就能直接用:不需要安装数据库服务,不需要配置账号密码,跑完脚本当场就能用 sqlite3 查询结果。
如果换成 MySQL,第一件事就是建库建账号,这对只想拿数据做分析的读者来说是一个极大的劝退点。CSV 虽然更轻,但没有类型约束和索引,做增量更新时又会遇到“重复写入 vs 手工标记”的两难。SQLite 刚好卡在中间:有 SQL 能力,又有单文件便携性,几乎是为这类数据集项目量身定做的。项目还把建表语句直接放在 storage.py 的初始化函数里,正好呼应了“拿到手就跑”的设计目标。
3. 核心模块拆解:一个请求从进入到入库的完整链路
3.1 数据源分析:公开页面里也有结构化信息
正式写爬虫之前,第一件事是“看源码结构”。这套采集器锁定的数据源是公开天气页面,页面里通常是一张字段固定的表格:城市、日期、最高气温、最低气温、天气现象、风力风向。不同站点的页面结构差异很大,有些直接把数据写在 table 的 class 里,有些则需要从多个 div 里拼接字段。
项目作者的应对方式是先把 HTML 保存下来做静态分析,确认每一项数据对应的 XPath,再开始写解析器。我在拆这个项目时复现了同样的做法:用浏览器开发者工具选中表格节点,复制 XPath,再对照源码里的 parser 做字段坐标映射。这套流程虽然没有写成文档,但 parser 里的注释已经足够当说明书看,每个字段旁边都标了“对应页面第几列”,这在后期维护时能省下大量排查时间。
3.2 fetcher:请求头伪装与状态码处理
fetcher.py 是整个采集链路的第一环,也是反爬博弈的主战场。源码里请求头伪装做得很完整:User-Agent 使用接近真实浏览器的完整字符串,Accept-Language 固定为中文,避免拿到默认语言页而不是实际数据页。函数接收外部传入的 session 对象而不是自己创建连接,这是为了让多个页面共享 TCP 连接池。
# core/fetcher.py import requests from config.settings import TIMEOUT, RETRY_TIMES HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch(url: str, session: requests.Session) -> str: for attempt in range(1, RETRY_TIMES + 1): try: resp = session.get(url, headers=HEADERS, timeout=TIMEOUT) if resp.status_code == 200: resp.encoding = "utf-8" # 显式指定编码,避免自动识别翻车 return resp.text elif resp.status_code in (401, 403): print(f"[warn] 第{attempt}次请求被拒绝: {url}") else: print(f"[warn] 非预期状态码 {resp.status_code}: {url}") except requests.RequestException as e: print(f"[error] 请求异常: {e}, 重试 {attempt}/{RETRY_TIMES}") return ""逻辑说明:代码先构造了一套贴近真实浏览器的 HTTP 头,其中 User-Agent 是必须的,很多站点会直接拒绝无 UA 的请求。循环里的三分支覆盖了三种典型情况:200 直接返回文本;403 说明 IP 或 UA 被识别,打印警告后继续重试;网络异常则捕获后进入下一次循环。最后返回空字符串表示失败,调度器会据此跳过本轮采集。
参数方面,RETRY_TIMES=2是一个平衡值,重试太多会在风控触发时浪费大量时间。resp.encoding = "utf-8"这一行特别值得注意:requests 库有时会把中文页面自动识别成其他编码,导致后续解析全部乱码,显式指定编码是从根本上绕过这个不确定性的做法。
3.3 parser:XPath 解析与字段坐标定义
parser 的核心任务是从 HTML 表格中抽取所需列。源码里使用的是 lxml 的 etree 模块,先把 HTML 转成 Element 对象,再通过 XPath 定位到字段所在节点。项目里大量使用contains(@class, 'weather-table')而不是精确匹配 class 名,这是因为某些站点会在 class 名后面附加随机编号,精确匹配会漏数据。
# core/parser.py from lxml import etree def parse_weather(html: str) -> dict: root = etree.HTML(html) rows = root.xpath("//table[contains(@class, 'weather-table')]//tr") data = {} for row in rows[1:]: # 跳过头部的字段名称行 cells = row.xpath(".//td/text()") if len(cells) >= 5: city = cells[0].strip() data[city] = { "high_temp": cells[1].strip(), "low_temp": cells[2].strip(), "weather": cells[3].strip(), "wind": cells[4].strip(), } return data逻辑说明:rows[1:]跳过了表头,避免把字段名当数据解析。len(cells) >= 5是一个防御性判断,避免某些空行或广告行混入结果。最后的字典嵌套结构方便后续按城市名索引,同时也天然适合转成 JSON 或写 SQLite。这里的字段坐标依赖页面结构稳定,一旦发布方调整了表格列顺序,就需要重新定位 XPath。
参数里最值得关注的是strip()方法的使用。网页文本经常携带全角空格、不换行空格等隐形字符,不清理会导致入库后有脏数据。项目把这层清理单独放到了 cleaner 里而不是 parser 中,好处是 parser 里的逻辑可以保持纯粹,只在最终输出前做一次统一清洗。
3.4 cleaner:异常值修补与单位统一
采集到的文本不能直接入库,cleaner 负责处理两类典型问题。第一类是各种“无数据”占位符,比如“--”“—”“null”“暂无”;第二类是单位混用,温度列里可能带“℃”后缀,风力列可能带“级”字。如果把这些原样写进 SQLite,后续做数值比较时就会因为类型是字符串而出问题。
# core/cleaner.py import re def clean_field(value: str) -> str: value = value.replace("\u3000", "").replace("\xa0", "").strip() if value in ("--", "—", "null", "暂无"): return "" m = re.search(r"(-?\d+)", value) return m.group(1) if m else ""逻辑说明:第一步去除全角空格和不换行空格,这两个字符是网页数据里最常见的隐形污染源。第二步把各种“无数据”占位符统一成空字符串,避免数据库里存一堆不同写法。最后一步用正则抽取第一个带符号整数,把“最高气温23℃”这类含后缀的字符串清洗成“23”,方便后续直接参与数值计算。
这段代码看似简单,但对分析的帮助是实打实的。比如你要统计全国最高气温分布,如果字段里混着“23℃”“23”“23.0”,排序和聚合结果都会失真。清洗层的核心价值,就是把“给人看”的网页文本变成“给程序分析”的干净字段。
4. 从采集到落地:调度、去重与增量更新的工程化细节
4.1 调度策略:单机串行的稳定性优先
调度器采用的是单机串行模式,整体思路是“宁可慢一点,不能错一门”。runner.py 维护一个城市列表,逐个执行“抓取 → 解析 → 清洗 → 存储”,每次循环结束后 sleep 固定间隔。这个设计天然避免了多线程并发的频率惩罚,也让任务随时可以中断而不产生脏数据。
# scheduler/runner.py import time from config.settings import REQUEST_INTERVAL from core.fetcher import fetch from core.parser import parse_weather from core.cleaner import clean_field from core.storage import save_record def run(city_list: list[str], mode: str = "daily"): session = create_session() for city in city_list: url = build_url(city) html = fetch(url, session) if not html: continue parsed = parse_weather(html) for name, fields in parsed.items(): cleaned = {k: clean_field(v) for k, v in fields.items()} save_record(name, cleaned, mode) time.sleep(REQUEST_INTERVAL) # 限速核心,不能省略逻辑说明:fetch返回空字符串时直接continue,空 HTML 不会进入解析逻辑,避免后面抛出一堆异常。mode参数决定写入方式:daily模式只覆盖当天记录,第二天重跑时更新同一批数据;full模式则追加历史,适合积累长期天气数据集。create_session()封装了 Session 创建过程,让整个调度周期内复用同一个连接池。
调度参数里,city_list的顺序会影响整体耗时,因为编码表按行政区域排列,相邻城市通常在同一网络区域,连接复用的效果更明显。如果城市列表特别大,建议先切片分批跑,避免长时间占用内存持有 session。REQUEST_INTERVAL是这段代码里最值得调优的参数:设成 0.5 秒可能在半小时内被限流,设成 3 秒则牺牲速度换稳定,具体要看目标站的容忍度。
4.2 去重设计:让“重复采集”变成“跳过采集”
全国天气数据采集的一大痛点是周期重复:同一天的数据可能因脚本重跑、断点续采而重复入库。项目用 Redis 的 SET 记录已采集过的 URL 指纹,每次fetch前先判断指纹是否存在,存在就直接跳过。这个设计把重复请求挡在了网络层之前,减少的不只是存储占用,还有目标站的请求压力。
# scheduler/dedup.py import hashlib import redis r = redis.Redis(host="localhost", port=6379, db=0) def url_fingerprint(url: str) -> str: return hashlib.md5(url.encode("utf-8")).hexdigest() def is_fetched(url: str) -> bool: fp = url_fingerprint(url) if r.sismember("weather:fetched_urls", fp): return True r.sadd("weather:fetched_urls", fp) return False逻辑说明:MD5 把完整 URL 压缩成固定长度的十六进制串,与直接存储 URL 相比,在千万级请求下能显著节省 Redis 内存。sismember判断指纹是否已存在,存在则返回 True 表示跳过;不存在则写入并返回 False,调度器据此继续采集。
这里有一个工程经验值得记录:指纹集合的 key 命名是“项目前缀:用途:模块”结构,weather:fetched_urls中weather是项目前缀,fetched_urls标识用途。这样当数据量超过 Redis 内存上限时,可以单独清理这个 key 而不影响其他业务数据。如果你不想依赖 Redis,也可以把判断逻辑换成查询 SQLite 上的唯一索引,效果等价但速度会慢一些。
4.3 存储落库:表结构设计与导出路径
storage.py 的表设计遵循一个“能直接分析”的原则:城市、日期、温度、天气现象、风力都单独成列,日期用YYYY-MM-DD格式而非时间戳字符串,方便 SQL 直接按天聚合。INSERT OR REPLACE是存储层的核心语句,它依赖表上的唯一索引判断记录是否存在,存在则整体替换,不存在则正常插入。
# core/storage.py import sqlite3 from config.settings import DB_PATH def init_db(): conn = sqlite3.connect(DB_PATH) conn.execute(""" CREATE TABLE IF NOT EXISTS weather_daily ( city TEXT NOT NULL, date TEXT NOT NULL, high_temp TEXT, low_temp TEXT, weather TEXT, wind TEXT, UNIQUE(city, date) ) """) conn.commit() conn.close() def save_record(city: str, fields: dict, mode: str): conn = sqlite3.connect(DB_PATH) conn.execute(""" INSERT OR REPLACE INTO weather_daily (city, date, high_temp, low_temp, weather, wind) VALUES (?, ?, ?, ?, ?, ?) """, (city, fields["date"], fields["high_temp"], fields["low_temp"], fields["weather"], fields["wind"])) conn.commit() conn.close()逻辑说明:UNIQUE(city, date)建的是联合唯一索引,用来保证同一城市同一天只有一条记录。INSERT OR REPLACE利用这个索引做覆盖写,让daily模式天然变成“更新当天数据”,不需要额外写删除逻辑。字段顺序必须与建表语句严格对齐,否则 SQLite 会按位置错误写入。
导出方面,SQLite 本身就提供了最方便的路径。跑完采集后直接执行一行 SQL 就能拿到 CSV:
sqlite3 -header -csv output/weather_records.db \ "SELECT * FROM weather_daily WHERE date = '2025-06-01';" > weather_20250601.csv这条命令从数据文件里筛选某一天的全量记录并导出为带表头的 CSV,已经是 pandas 可以直接读取的格式。如果你需要长期保留历史数据用于趋势分析,建议每月做一次导出归档,再清理主库。
4.4 失败恢复:断点续采与日志回看
全国几千个城市的采集任务不可能一次跑完,失败恢复能力决定这个项目能不能真正落地。runner.py 里没有用复杂的任务队列,而是依靠日志输出和 Redis 去重共同实现断点续采。具体逻辑是:每个城市处理完后,去重集合里就已经存在对应的 URL 指纹,下次重跑时这些城市会被自动跳过,只有失败的部分会重新请求。
我把这个机制称为“日志即断点”。运行任务时把print输出重定向到日志文件,重跑时先grep看最后处理的城市是哪一个,就能判断从哪里断的。如果你想把断点续采做得更精准,可以给每个城市单独记录状态:pending/success/failed,失败的城市进入重试队列。但就这套源码的定位而言,Redis 去重已经覆盖了 90% 的重复场景,多一个状态表反而是维护负担。
5. 天气爬虫常见问题排查:从 403 到字段乱码的踩坑记录
5.1 画面:抓取到一半开始大量返回 403
跑全国县级天气时,最常遇到的情况是前两百个城市正常,第两百个开始连续 403,最后程序卡在重试循环里死磕。现象是请求频率超过了目标源的单 IP 滑动窗口限制,前面短间隔的请求触发了风控。
解决分两层。应急层:把REQUEST_INTERVAL从 1.2 秒调到 3 秒以上,同时把重试策略改成遇到 403 直接跳过而不是重试,因为重试只会让风控越来越严重。长效层:在 fetcher 里接入代理池接口,按权重轮换出口 IP,或者干脆切换一个备选数据源。对单机应用我更倾向于前者——调低采集频率换取稳定,毕竟这个项目的重点是把数据采集做成数据集,而不是挑战目标源的安全策略。
5.2 画面:入库后的城市名与编码表对不上
采集结果里出现城市名对不上编码表,比如库里存的是“某州”,业务方习惯叫“某市”,实际是行政区划调整过,而项目里的city_code.json没有同步。原因是编码表过期,不是爬虫逻辑出错。
解决方法是跑一遍tools/update_city_code.py,它会从公开统计页面拉取最新行政区划并重新生成编码表。如果没有同步接口,只能人工对照。说到底,城市编码表是天气爬虫里最容易过期的部分,建议每次发版前都强制跑一次更新脚本,我后来养成的习惯是每个季度手动核对一次增量。
5.3 画面:SQLite 表数据重复膨胀,去重失效
同一天同一城市出现了两条记录,查了 Redis 发现连接在某一次请求时断掉了,is_fetched函数的异常处理直接返回 False,导致后面的请求全部重新抓取入库。原因是单层去重失效,Redis 不可用时没有兜底方案。
解决方式是在存储层加一道保险:给表建UNIQUE(city, date)联合索引。这样即使 Redis 完全宕机,INSERT OR REPLACE也会按唯一索引覆盖旧数据,不会产生重复记录。这是典型的血泪经验——任何一层去重都可能失效,所以要在最底层的存储处做最终兜底。
5.4 现象:中文全部变成“锟斤拷”
页面返回时 requests 自动把响应编码猜成 ISO-8859-1,导致中文乱码。原因是响应头里的 charset 声明缺失,requests 的自动判断逻辑就会出错。实际修复很简单:在 fetch 里显式设置resp.encoding = "utf-8",或者拿到文本后手动做一次 decode,从根本上绕过自动判断的不确定性。
resp = requests.get(url, headers=HEADERS, timeout=TIMEOUT) resp.encoding = "utf-8" # 显式指定,不依赖响应头声明 return resp.text这段代码与前面 fetcher 中的写法一致,关键是不依赖响应头的 charset 声明,直接按项目使用场景里最常见的编码来定。如果遇到极端场景是 GBK 页面,再按情况调整即可。编码问题是爬虫里最隐蔽的坑,因为它不报错,只让数据看起来像乱码。
5.5 现象:解析出来的字段全是空值
某次采集跑完后,入库记录里城市名正常,但温度、风力字段全是空字符串。原因是目标站改版,表格的列顺序从“高低温、天气、风力”换成了“天气、风力、高低温”,parser 按旧坐标取列,自然拿不到正确内容。
排查思路是先看原始 HTML:把 fetcher 返回的文本存成文件,对比 XPath 命中的节点内容,就能看出列顺序是否变化。解决方式是把 parser 里的字段坐标改成按表头文字匹配,先读出表头行,再根据表头定位列索引。这个改动会让解析器更健壮,也是我在后续二次开发中优先建议做的一步。
6. 二次开发:把“当天天气数据集”改造成“按城市订阅的监控告警”
面向二次开发,这个项目的最佳切入点是把全量批量采集改造成“只关注特定城市的订阅式采集”。具体做法是新增一个 watchlist 模块,在 runner 主循环里对每个城市做一次订阅过滤:
# 在 run() 主循环中插入订阅过滤逻辑 WATCHLIST = ["city_a", "city_b"] # 改成自己关心的城市编码 def should_collect(city_code: str) -> bool: return city_code in WATCHLIST改造的价值在于:原来是“采全国,回来再筛选”,现在是“只采订阅城市”,请求量、存储量和反爬压力都直降一个量级。我一般会把 WATCHLIST 放到配置文件里用 JSON 读入,方便业务人员自行增删城市,不用改代码。如果团队有多个人维护,这个设计还能避免误改源码里的编码列表。
除了订阅过滤,另一个值得动刀的地方是数据输出格式。项目默认落 SQLite,但监控告警场景下需要方便对接消息推送。我习惯在 storage.py 里加一个export_latest_to_json函数,把最新一天的数据整理成 JSON 数组,并排除空字段记录。这样后续无论是写日志、推群消息还是做可视化,都能直接消费同一份数据。
回归验证时有两个小技巧。一是用一个固定的小城市列表跑一次全流程,然后人工核对几个城市的气温字段,确认与目标页面显示一致;二是对同一城市连续抓两次,验证daily模式确实覆盖写而非新增记录。如果改造过程中动了 parser,还要额外验证字段坐标:把原始 HTML 保存一份,用改动前后两版 parser 分别解析,对比字段值是否完全一致。我从这个项目里学到的习惯是:每一次改造结束,都要先跑一个最小数据集,确认链路没断,再放大到全量。希望帮到你。
本文还有配套的精品资源,点击获取