有个项目要做近十年气温趋势分析,结果发现公开的历史天气API要么收费、要么数据不全。我瞟了一眼某天气网站,发现人家前端直接渲染表格,心想这不就几条XPath的事?结果写了200行代码,被反爬按在地上摩擦了三小时。
最后搞出来的这个带界面的小工具,单线程稳定采集,日均抓取3万条记录,今天就把设计思路和那些报错经验全倒出来。
⚠️ 本文仅用于技术学习,请尊重目标网站robots.txt,控制请求频率,勿用于商业或恶意爬取。
一、为什么不用现成库?非要自己写界面?
市面上有 requests + BeautifulSoup 的组合拳,也有 scrapy 框架,但大多数历史天气数据源有两大痛点:
- 动态加载:很多表格是JS渲染,直接请求返回空壳HTML。
- 频率封禁:每分钟超过20次请求,IP就被拉黑半小时。
我最初用 selenium 无头浏览器硬钢,结果内存占用飙到1.2G,跑一天就崩。后来换了 requests + execjs 逆向解密参数 + 随机User-Agent,才算找到平衡点。
界面方面,tkinter 虽然丑,但胜在无依赖,打包成exe也就15MB。业务场景很简单:输入城市代码和年份区间,点一下“开始采集”,进度条走完,CSV直接存桌面。
二、架构拆解:三层设计,让采集不“裸奔”
我把它分成三层:
- 界面层(View):
tkinter做输入框、按钮、进度条和日志文本框。 - 调度层(Controller):控制线程启停,维护请求间隔,记录失败重试。
- 采集层(Fetcher):构造签名参数、发送请求、解析HTML、落盘CSV。
用文字画个简单流向:
用户输入 → 校验参数 → 启动采集线程 → 循环年份/月份 → 构造带签名的URL →
请求 → 解析表格 → 写入CSV → 更新进度条 → 结束。
关键设计决策:把网络IO和UI刷新分开。如果直接在主线程里请求,界面会“冻住”直到完成。我用 threading 跑采集,然后通过 queue.Queue 把日志和进度传回主线程,这样界面始终流畅。
三、实战代码:请求层如何绕过“签名校验”?
目标网站对每个历史请求都要求一个 token 参数,该token由 city_code + date + 盐值 经过MD5后取前16位生成。我通过F12查看网络请求,发现这个算法被硬编码在一个公共JS文件里。
于是直接用 execjs 调用该JS函数,省去复写算法的麻烦。代码如下:
import execjs
import hashlib
import time
def generate_token(city_code, date_str):
# 假设从本地读取解密后的JS函数
with open("get_token.js", "r", encoding="utf-8") as f:
js_code = f.read()
ctx = execjs.compile(js_code)
# JS函数签名: function generateToken(city, date) { ... }
token = ctx.call("generateToken", city_code, date_str)
return token
def build_url(city, year, month):
date_str = f"{year}-{month:02d}"
token = generate_token(city, date_str)
base = "https://example-weather.com/history"
return f"{base}?city={city}&month={date_str}&token={token}"
运行示例:build_url("101010100", 2025, 7) 得到类似:https://example-weather.com/history?city=101010100&month=2025-07&token=a3f8c2d9e1b4
踩坑警告:这个token有时效性,超过5分钟就失效。我的办法是在每次请求前实时生成,而不是批量生成后复用。否则你会收到一堆
403,还以为IP被封了。
四、解析与存储:遇到“空白单元格”别慌
返回的HTML是一个标准表格,<tr> 对应每天,<td> 包含最高温、最低温、天气状况、风力等。我用 lxml 的 etree 做解析,速度比 BeautifulSoup 快3倍左右。
但有个坑:有些日期没有数据(比如站点维护),网页会显示“—”而不是数字。此时如果直接 int() 转换,程序就炸了。我的处理逻辑是:
from lxml import etree
def parse_row(td_list):
# td_list 是长度固定为6的列表
try:
high = int(td_list[1].text.strip()) if td_list[1].text.strip().isdigit() else None
low = int(td_list[2].text.strip()) if td_list[2].text.strip().isdigit() else None
except AttributeError:
high = low = None
# 天气状况和风力直接用字符串,保留原始值
weather = td_list[3].text.strip() or "未知"
wind = td_list[4].text.strip() or "无数据"
return {"high": high, "low": low, "weather": weather, "wind": wind}
然后每解析完一个月的数据,就追加写到CSV。这里我犯过一个低级错误:频繁打开关闭文件导致速度慢。后来改为每10条批量flush一次,采集效率提升40%。
五、界面交互与进度反馈(核心代码片段)
界面部分采用 grid 布局,左侧是配置区,右侧是实时日志。关键逻辑在“开始采集”的回调里:
import tkinter as tk
from tkinter import ttk, scrolledtext
import threading
import queue
class WeatherApp:
def __init__(self, root):
self.root = root
self.log_queue = queue.Queue()
self.progress_var = tk.IntVar()
self.setup_ui()
self.check_queue() # 定期检查队列更新界面
def start_collect(self):
city = self.city_entry.get().strip()
start_year = int(self.year_start.get())
end_year = int(self.year_end.get())
# 参数校验省略...
self.progress_var.set(0)
# 启动采集线程
t = threading.Thread(target=self.collect_worker,
args=(city, start_year, end_year), daemon=True)
t.start()
def collect_worker(self, city, start, end):
total_months = (end - start + 1) * 12
processed = 0
for year in range(start, end + 1):
for month in range(1, 13):
try:
# 调用前面定义的build_url和parse逻辑
data = fetch_month_data(city, year, month)
save_to_csv(data, city, year, month)
self.log_queue.put(f"✅ {year}-{month:02d} 采集成功")
except Exception as e:
self.log_queue.put(f"❌ {year}-{month:02d} 失败: {str(e)}")
processed += 1
self.log_queue.put(('progress', processed / total_months * 100))
time.sleep(1.5) # 控制频率,避免被封
self.log_queue.put("🎉 全部采集完成!")
界面上用 ttk.Progressbar 绑定 progress_var,日志用 ScrolledText 实时显示。这里有个细节:self.log_queue.put(('progress', value)) 我用元组区分日志和进度,然后在主线程的 check_queue 中根据类型分别更新。
六、性能优化与反爬对抗:我交的学费
最初我开了10个线程并发,结果3分钟后所有请求返回 429 Too Many Requests。后来我强制单线程 + 随机休眠 1.2~2.5秒,稳定跑完一整年数据(12个月)耗时约 18秒,完全可以接受。
另外,网站会检测 Referer 和 Accept-Language 头。我伪造了完整头信息:
headers = {
"User-Agent": random.choice(UA_LIST),
"Referer": "https://example-weather.com/",
"Accept-Language": "zh-CN,zh;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
}
并每请求5次更换一次代理(我用的是免费代理池,虽然稳定性差,但足够分散风险)。
性能对比表(采集同一年12个月数据):
| 策略 | 耗时 | 失败请求数 | 备注 |
|---|---|---|---|
| 10线程无延迟 | 4.2s | 8次(全部429) | 不可用 |
| 3线程+1s延迟 | 12.8s | 2次(超时重试成功) | 可用但CPU偏高 |
| 单线程+随机1.5s | 18.3s | 0次 | 稳定,推荐 |
七、打包与后续优化方向
用 pyinstaller 打包成单文件:
pyinstaller -F -w --add-data "get_token.js;." weather_gui.py
-w 去掉控制台黑框,--add-data 把JS文件一起打包。
后续如果数据量大,可以接入 SQLite 代替CSV,并增加“断点续传”功能——记录已采集的月份,避免重复拉取。另外,可以加上图形化的温度曲线预览,直接显示在界面右侧,这样用户采集完就能看趋势,不用再开Excel。
八、说点实在的:这个工具的价值和局限
这个采集器帮我完成了项目所需的近10年数据,最终分析结果与气象局公开报告误差在±0.5℃以内,说明数据质量可靠。但局限也很明显:
- 依赖目标网站的HTML结构,一旦改版,解析就废。
- 只支持单城市单线程,批量城市需要串行或额外设计。
- 没有处理网络波动重试机制,未来可加入指数退避。
如果你也需要采集历史天气,建议先花10分钟手动查看目标网站的请求逻辑,不要一上来就写解析。还有就是,尊重数据源,别把人家的服务器搞崩了。
最后,整个代码我已经放在内部仓库,后续会整理成开源项目。有需要的朋友可以留言或私信,我发你精简版。
希望这篇实战记录能让你少踩一些我踩过的坑。下次聊点别的——比如怎么把采集到的数据用 Pandas 做时序异常检测,那又是另一个故事了。


