手撸历史天气采集器:我用Python绕过反爬踩了哪些坑?

有个项目要做近十年气温趋势分析,结果发现公开的历史天气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> 包含最高温、最低温、天气状况、风力等。我用 lxmletree 做解析,速度比 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秒,完全可以接受。

另外,网站会检测 RefererAccept-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 做时序异常检测,那又是另一个故事了。

分享到:

本文链接:https://www.biyeyuanma.cn/post/176.html

猜你喜欢

随机文章
热门标签
图片名称

服务热线

加我微信

加我微信