拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python爬虫实战:破解天气网站反爬机制,构建稳健数据采集方案

1. 项目概述当爬虫撞上“铜墙铁壁”那天我像往常一样打开编辑器准备写一个简单的脚本从某个知名的公共天气网站上抓取几个城市的温度数据。这听起来是个再基础不过的任务对吧用requests发个请求用BeautifulSoup解析一下HTML数据就手到擒来了。我甚至没怎么仔细看请求头随手写了几行代码信心满满地按下了运行键。然而接下来的事情让我这个老手也愣了几秒——返回的不是我期待的HTML而是一串冷冰冰的JSON里面赫然写着“访问过于频繁请稍后再试”。得被反爬了而且是被一个看似“人畜无害”的天气网站给拦在了门外。这就是所谓的“被天气网反爬的一天”。它不是一个高深莫测的黑客攻防战而是一个非常典型、每天都在无数爬虫初学者和开发者身上重演的实战场景。网站并没有用多么复杂的加密或验证码它只是用一些基础的、却非常有效的防护策略就把那些“不讲武德”的简单爬虫给识别并拒之门外了。这个项目就是记录我如何从这次“翻车”开始一步步拆解这个天气网站的反爬机制并最终成功稳定获取数据的过程。整个过程几乎涵盖了入门级爬虫会遇到的所有经典反爬类型请求头校验、频率限制、参数签名甚至还有一点动态渲染的“影子”。如果你也遇到过“浏览器能打开爬虫代码就是拿不到数据”的窘境或者你的爬虫总是跑一会儿就被封IP、返回一些奇怪的错误码那么这次经历或许能给你提供一个完整的排查思路和解决方案模板。我们不止要写出能跑的爬虫更要写出“像人一样访问”的、健壮的爬虫。2. 反爬策略深度拆解天气网如何识别“非人类”一开始的失败恰恰是最好的学习入口。我并没有急着去搜索“如何绕过XX反爬”而是决定先当一回“侦探”仔细分析一下网站到底是怎么发现我不是一个真正的浏览器的。这个过程是爬虫实战中最有价值的部分。2.1 请求头Headers的“身份证”检验我的第一个脚本可能和很多人写的第一段爬虫代码一样简陋import requests url https://某天气网站/city/101010100.html # 以北京城市代码为例 response requests.get(url) print(response.text[:500]) # 打印前500字符看看返回的结果是一段错误信息而不是网页正文。我立刻打开浏览器的开发者工具F12切换到“网络”(Network)标签清空记录后重新访问了一遍同一个天气页面。然后我把浏览器发送的请求头和我脚本中requests自动生成的请求头进行了一次详细的对比。关键差异立刻浮现User-Agent: 我的脚本使用的是python-requests/2.28.1这种默认UA。而浏览器的UA是类似Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ... Chrome/114.0.0.0 Safari/537.36这样复杂的字符串。网站后端很容易通过判断UA是否包含主流浏览器标识来过滤掉脚本请求。Accept-Encoding: 浏览器通常会声明支持gzip, deflate, br等多种压缩格式而requests默认可能不发送此头部或值不同。服务器可能对不支持压缩的客户端采取不同处理策略。其他头部如Accept接受的内容类型、Accept-Language、Connection是否为长连接、Upgrade-Insecure-Requests等。这些头部共同构成了一个“合法浏览器”的访问指纹。缺少它们或值异常都可能成为被识别的特征。注意仅仅添加一个User-Agent往往是不够的。现代反爬系统会进行“头部完整性”检查。一个看起来像浏览器的请求如果缺少了Accept、Accept-Language等常见头部依然会显得很可疑。最稳妥的办法是直接从浏览器开发者工具里复制整个请求头字典应用到你的requests会话中。2.2 访问频率与IP限制最直接的防御解决了请求头问题后我的脚本成功获取了一次页面内容。但当我试图用一个循环快速抓取10个城市的数据时那个熟悉的“访问频繁”错误又出现了。这就是经典的基于IP的请求频率限制。天气网站作为一种公共服务其服务器资源是有限的。为了防止某个IP在短时间内发起大量请求无论是恶意爬虫还是程序错误影响正常用户的访问体验后台会设置阈值。例如同一IP每秒最多请求5次。同一IP每分钟最多请求30次。同一IP每小时最多请求200次。一旦超过阈值服务器就会暂时或永久地将该IP加入黑名单返回429Too Many Requests或其他自定义错误。我的脚本在快速循环中很容易就触发了这个限制。2.3 参数签名与动态令牌增加请求成本在进一步分析中我发现这个天气网站的数据接口通常用于异步加载更详细的数据如逐小时预报的URL并不是静态的。例如获取北京天气详情的API可能长这样https://某天气网站/api/weather?city101010100_1689753600000那个_1689753600000的参数是一个13位的时间戳毫秒级。我最初尝试用固定的时间戳发现第一次请求成功后续请求就失效了。这说明服务器会校验这个时间戳可能要求它是当前时间附近的一个有效值或者与另一个加密参数关联。更复杂的场景下可能会遇到参数签名。即客户端需要将请求参数如城市ID、时间戳按照服务器约定的密钥和算法如MD5, HMAC-SHA256计算出一个sign值并将其作为参数之一发送。服务器收到后用同样的算法验签不一致则拒绝请求。这大大增加了直接构造请求的难度因为你需要逆向出它的加密算法和密钥。2.4 动态内容与JavaScript渲染虽然这个天气网站的主页HTML是静态的但一些实时数据如当前温度、风速可能是通过JavaScript在页面加载后再调用API动态填充的。如果你只用requests获取初始HTML然后用BeautifulSoup解析会发现这些关键数据是空的或者只有一个占位符。这就是所谓的“动态渲染”问题。数据并不存在于初始HTML源码中而是由浏览器执行JS后生成的。对付这种反爬要么去找到那个被调用的、返回纯数据的API接口通常藏在XHR或Fetch请求里要么就得动用像Selenium、Playwright这样的浏览器自动化工具模拟真实的浏览器环境来获取渲染后的完整页面。3. 实战对抗构建一个稳健的天气数据爬虫分析清楚了对手的招数我们就可以见招拆招构建一个能够稳定工作的爬虫了。我们的目标是模拟得足够像真人用户并且遵守“社交礼仪”不给目标服务器造成负担。3.1 第一步完美伪装请求头不要手动拼写直接从浏览器复制是最快最全的方法。在开发者工具的“网络”选项卡中找到对目标页面的请求右键点击 - 复制 - 复制为cURL (bash)。然后你可以利用在线工具或一些IDE插件将cURL命令转换为Pythonrequests代码。或者手动整理成字典import requests headers { authority: 某天气网站, accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8,application/signed-exchange;vb3;q0.7, accept-language: zh-CN,zh;q0.9,en;q0.8, cache-control: max-age0, sec-ch-ua: Not.A/Brand;v8, Chromium;v114, Google Chrome;v114, sec-ch-ua-mobile: ?0, sec-ch-ua-platform: Windows, sec-fetch-dest: document, sec-fetch-mode: navigate, sec-fetch-site: none, sec-fetch-user: ?1, upgrade-insecure-requests: 1, user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 } session requests.Session() # 使用会话可以自动管理cookies session.headers.update(headers) # 为会话设置默认头部 response session.get(https://某天气网站/city/101010100.html) print(response.status_code) # 此时大概率能成功获取到HTML内容了实操心得建立一个Session对象并更新其headers比每次请求单独设置headers更好。Session会自动处理连接复用和Cookie行为更接近浏览器。此外注意headers中的Host、Origin、Referer等字段在访问不同页面或提交表单时可能需要根据实际情况动态设置。3.2 第二步实现“人性化”的访问节奏对抗频率限制的核心策略是延迟和随机化。我们不能让程序毫秒不差地连续请求。import time import random def crawl_city_data(city_code_list): for city_code in city_code_list: url fhttps://某天气网站/city/{city_code}.html try: response session.get(url) if response.status_code 200: # 解析数据... parse_data(response.text) print(f成功抓取城市 {city_code}) else: print(f请求失败状态码{response.status_code}) except Exception as e: print(f请求发生异常{e}) # 关键在每次请求后增加延迟 # 基础延迟 随机延迟模拟人类阅读时间的不确定性 delay 2 random.uniform(0, 3) # 延迟在2-5秒之间 time.sleep(delay) # 更高级的策略每抓取N个页面后休息更长时间 # if (index 1) % 10 0: # print(已抓取10个城市休息20秒...) # time.sleep(20)参数计算与选择逻辑基础延迟根据网站响应速度和自身需求设定。对于天气这种更新不频繁的数据3-5秒是礼貌的间隔。太快如0.1秒易被封太慢如30秒效率低。随机延迟random.uniform(a, b)生成一个[a, b)区间的随机浮点数。加入随机性是为了避免程序呈现出固定的、机械的访问周期这是识别机器人的一个特征。批次休息长时间运行后模拟人类需要“休息”可以每抓取一定数量如20个后暂停较长时间如60秒。3.3 第三步处理动态参数与签名对于那个带时间戳_的参数解决方案很简单在每次请求时生成当前的时间戳。import time timestamp int(time.time() * 1000) # 获取当前毫秒级时间戳 api_url fhttps://某天气网站/api/weather?city101010100_{timestamp} response session.get(api_url)如果遇到更复杂的签名就需要进行JavaScript逆向工程。这通常是爬虫中最具挑战性的部分。定位加密代码在浏览器开发者工具的“源代码”(Sources)选项卡中搜索关键参数名如sign、token或使用“搜索”功能查找加密函数如encode、encrypt、md5、sha等。分析加密逻辑找到生成签名的JavaScript函数。通常它会将几个参数如城市ID、时间戳、一个固定盐值salt按特定顺序拼接成一个字符串然后进行MD5或SHA256哈希。Python复现在Python中使用hashlib库复现同样的逻辑。import hashlib import time def generate_sign(city_id, timestamp, salt某个从JS中找到的固定值): # 假设JS中的签名算法是 md5(city城市IDt时间戳salt盐值) sign_string fcity{city_id}t{timestamp}salt{salt} m hashlib.md5() m.update(sign_string.encode(utf-8)) return m.hexdigest() city_id 101010100 timestamp int(time.time() * 1000) sign generate_sign(city_id, timestamp) api_url fhttps://某天气网站/api/v3/weather?city{city_id}t{timestamp}sign{sign}注意事项盐值salt可能被混淆或隐藏在巨大的JS文件中寻找它需要耐心。有时盐值本身也可能是动态的需要从之前的某个接口响应或HTML页面中提取。如果算法过于复杂如涉及浏览器环境特有的window对象、CryptoJS库等可以考虑使用PyExecJS或Node.js子进程来直接执行那一段JS代码。3.4 第四步应对IP封锁与使用代理即使做好了伪装和延迟长期从同一个IP大量抓取仍然有被彻底封禁的风险。这时就需要使用代理IP。proxies { http: http://你的代理IP:端口, https: http://你的代理IP:端口, # 注意很多http代理也支持https但协议这里写http } # 在请求时传入proxies参数 response session.get(url, proxiesproxies, timeout10)代理使用要点来源可以使用免费的代理IP池不稳定、速度慢或购买付费的代理服务稳定、高速。轮换策略准备一个代理IP列表每次请求随机选取一个或者失败后自动切换下一个。验证代理在使用一个代理前先用它访问一个如http://httpbin.org/ip这样的服务检查代理是否有效且返回的是代理IP而非本机IP。并发控制即使使用多个代理对同一个目标网站的并发请求数也要控制否则服务器端仍可能从行为模式上识别出爬虫。4. 数据解析与存储从HTML到结构化数据成功获取到HTML或JSON数据后下一步就是从中提取出我们需要的结构化信息比如城市名、温度、湿度、风向等。4.1 解析HTML页面如果数据在初始HTML中我们使用BeautifulSoupfrom bs4 import BeautifulSoup def parse_html(html_text): soup BeautifulSoup(html_text, html.parser) # 假设城市名在一个class为city-name的h1标签里 city_name_tag soup.find(h1, class_city-name) city_name city_name_tag.get_text(stripTrue) if city_name_tag else N/A # 假设当前温度在一个id为now-temp的span标签里 temp_tag soup.find(span, idnow-temp) temperature temp_tag.get_text(stripTrue) if temp_tag else N/A # 更复杂的情况数据可能在某个script标签的JavaScript变量中 # 需要结合正则表达式来提取 import re script_data soup.find(script, textre.compile(var data )) if script_data: # 使用正则匹配出JSON字符串 match re.search(rvar data ({.*?});, script_data.string, re.DOTALL) if match: import json data_json json.loads(match.group(1)) # 从data_json中提取数据... return { city: city_name, temperature: temperature, # ... 其他字段 }解析技巧多用浏览器的“检查”功能右键点击页面上的元素选择“检查”可以精准定位到该元素在DOM树中的位置和其CSS选择器。如果页面结构复杂find_all配合属性选择attrs比单纯的字符串匹配更可靠。注意处理缺失数据的情况使用if tag:进行判断避免NoneType错误。4.2 解析JSON API接口如果数据来自API那通常更简单因为已经是结构化的JSON了。import json def parse_json(json_text): data json.loads(json_text) # 假设返回的JSON结构是{data: {city: 北京, realtime: {temp: 25, ...}}} city data.get(data, {}).get(city, N/A) temperature data.get(data, {}).get(realtime, {}).get(temp, N/A) # 处理可能存在的嵌套列表如未来7天预报 forecast_list data.get(data, {}).get(forecast, []) for day in forecast_list: date day.get(date) high_temp day.get(high) low_temp day.get(low) # ... 存储或处理 return { city: city, temperature: temperature, forecast: forecast_list }解析技巧使用.get(key, default)方法安全地访问字典避免因键不存在而抛出KeyError。在访问深层嵌套结构时可以使用data.get(a, {}).get(b, {})这样的链式调用但要注意如果中间某个键不存在且默认值不是字典后续的.get()会报错。更稳健的做法是写一个辅助函数或使用类似jmespath的库。4.3 数据存储选择对于爬取到的天气数据根据数据量和用途可以选择不同的存储方式CSV文件适合中小规模、结构规整的数据易于用Excel打开和分享。import csv fieldnames [城市, 温度, 湿度, 采集时间] with open(weather_data.csv, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) if f.tell() 0: # 如果是新文件写入表头 writer.writeheader() writer.writerow({城市: city, 温度: temp, 湿度: humidity, 采集时间: current_time})数据库如SQLite/MySQL适合数据量大、需要复杂查询或长期积累的场景。import sqlite3 conn sqlite3.connect(weather.db) c conn.cursor() # 创建表仅第一次运行需要 c.execute(CREATE TABLE IF NOT EXISTS weather (city TEXT, temperature REAL, humidity INTEGER, update_time TIMESTAMP)) # 插入数据 c.execute(INSERT INTO weather VALUES (?, ?, ?, ?), (city, float(temp), humidity, current_time)) conn.commit() conn.close()JSON文件适合保存原始的、嵌套结构复杂的数据便于后续其他程序读取。import json all_data [] # ... 爬取数据并添加到all_data列表 with open(weather_data.json, w, encodingutf-8) as f: json.dump(all_data, f, ensure_asciiFalse, indent2)5. 高级策略与异常处理让爬虫更健壮一个能在生产环境运行的爬虫绝不能是“一锤子买卖”。它需要能处理各种异常并从错误中恢复。5.1 会话Session与Cookie管理requests.Session()会自动处理同一会话内的Cookie。这对于需要登录或经历了某种初始化流程的网站至关重要。有些反爬机制会在你第一次访问时设置一个会话Cookie后续请求必须携带这个Cookie。session requests.Session() # 首次访问获取并保存Cookie session.get(https://某天气网站/) # 后续请求会自动携带Cookie response session.get(https://某天气网站/api/data)5.2 超时与重试机制网络是不稳定的。必须为请求设置超时并实现重试逻辑。import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry # 创建一个配置了重试策略的Session session requests.Session() retry_strategy Retry( total3, # 总共重试次数包括第一次请求 backoff_factor1, # 重试等待时间增长因子 (等待时间 backoff_factor * (2^(重试次数-1)) 秒) status_forcelist[429, 500, 502, 503, 504], # 遇到这些状态码时重试 ) adapter HTTPAdapter(max_retriesretry_strategy) session.mount(http://, adapter) session.mount(https://, adapter) try: # 设置单个请求的超时连接超时读取超时 response session.get(url, timeout(5, 10)) # 5秒连接超时10秒读取超时 except requests.exceptions.Timeout: print(f请求超时: {url}) except requests.exceptions.RequestException as e: print(f请求发生错误: {e})5.3 验证码识别与绕过如果触发了更严格的反爬可能会弹出验证码。对于简单的图形验证码可以尝试使用OCR库如pytesseract需要安装Tesseract-OCR引擎进行识别但成功率有限。import pytesseract from PIL import Image import requests from io import BytesIO # 1. 获取验证码图片假设验证码图片URL已知 captcha_url https://某网站/captcha.jpg img_response session.get(captcha_url) image Image.open(BytesIO(img_response.content)) # 2. 预处理图片灰度化、二值化、去噪以提高识别率 image image.convert(L) # 转为灰度 # ... 更多预处理步骤 # 3. 使用Tesseract识别 captcha_text pytesseract.image_to_string(image, config--psm 8).strip() print(f识别的验证码为: {captcha_text}) # 4. 将识别结果填入表单并提交重要提示验证码识别是一个猫鼠游戏且消耗计算资源。对于商业项目更常见的做法是1. 优化爬虫行为尽量避免触发验证码。2. 使用第三方打码平台人工或高精度识别服务。3. 如果网站提供尝试使用无验证码的API或数据源。5.4 分布式与增量爬取对于大规模数据抓取需要考虑分布式架构如使用Scrapy-Redis和增量爬取只抓取自上次以来更新过的数据。增量爬取思路为每条数据记录一个唯一的标识符如城市ID日期和更新时间。爬取前先查询本地已存储的最新数据的时间戳。只向网站请求比本地数据更新的内容。这通常需要网站API支持按时间范围查询。如果网站不支持则需全量抓取但在存储时通过唯一标识符去重。6. 常见问题排查与调试技巧实录在爬虫开发过程中你会遇到各种各样稀奇古怪的问题。这里记录了一些典型问题的排查思路。6.1 问题返回状态码403Forbidden可能原因与排查请求头不完整或不正确这是最常见的原因。仔细对比浏览器请求头确保User-Agent、Accept、Accept-Language、Referer如果需要等关键头部一致。特别是Referer有些网站会检查请求是否来自其站内页面。Cookie问题某些操作需要先访问首页获取初始Cookie或者需要登录后的认证Cookie。使用Session对象保持会话或者手动从浏览器复制Cookie字符串添加到请求头中Cookie: namevalue; name2value2。IP被暂时封锁停止请求等待一段时间如10分钟到1小时再试。检查是否触发了频率限制。6.2 问题能抓到HTML但找不到想要的数据可能原因与排查数据是动态加载的在“网络”选项卡中过滤XHR或Fetch请求查看页面加载后是否还有额外的数据请求。找到返回JSON或数据的真实API地址。数据在JavaScript变量中在HTML的script标签里搜索包含数据的变量名。使用正则表达式re模块提取JSON字符串。解析器或选择器错误确认你使用的CSS选择器或XPath路径是否正确。页面结构可能已经更新。使用浏览器的“检查”功能重新定位元素并注意其父级容器是否有iframe需要单独处理。6.3 问题程序运行一段时间后突然失败可能原因与排查IP被永久或长时间封禁表现为所有请求都返回错误如403、429、或直接连接被拒绝。解决方案是使用代理IP池进行轮换。网站更新了反爬策略原来有效的请求头或参数失效了。需要重新分析网络请求。资源耗尽如本地网络连接数过多、内存泄漏。确保在try...except...finally块中或使用with语句正确关闭响应连接。对于大规模爬取要控制并发数。触发了验证码检查返回的HTML内容中是否包含验证码图片或相关文字。需要引入验证码处理逻辑或降低请求频率。6.4 调试工具箱打印中间状态在关键步骤打印URL、状态码、响应内容的前几百个字符、请求头等这是最直接的调试方法。保存响应内容将出错的响应内容保存到文件方便离线分析。with open(error_page.html, w, encodingutf-8) as f: f.write(response.text)使用抓包工具如Fiddler、Charles可以拦截和修改所有HTTP/HTTPS流量比浏览器开发者工具更强大尤其适合分析移动端API。对比正常与异常请求将浏览器成功的请求cURL格式和你的脚本失败的请求可以用requests的response.request对象查看的每一个细节方法、URL、头、体进行逐行对比差异点往往就是问题所在。7. 法律与道德边界做一个负责任的爬虫开发者技术本身无罪但如何使用技术至关重要。在编写和运行爬虫之前请务必考虑以下几点尊重robots.txt访问网站根目录下的/robots.txt文件如https://某网站/robots.txt。这个文件指明了网站允许和禁止爬虫访问的路径。虽然它不是法律文件但遵守它是行业惯例和基本的网络礼仪。查看服务条款很多网站的用户协议中明确禁止了未经授权的数据抓取。违反条款可能导致法律风险。不要造成服务器过载这是最重要的原则。设置合理的请求延迟避免并发过高尤其是在对方服务器性能一般的情况下。你的爬虫不应该影响正常用户的访问体验。数据用途爬取的数据应用于个人学习、研究或合法的公共用途。未经许可不得将大量数据用于商业盈利特别是与对方网站产生直接竞争的情况。版权与隐私不要爬取受版权保护的明确声明不得转载的内容以及涉及个人隐私的信息。爬虫是一把强大的瑞士军刀它能帮你高效地收集信息但同时也要求你具备更高的责任感和对规则的敬畏。从“被反爬”到“优雅地获取数据”这个过程不仅是技术的提升更是对网络空间规则理解的加深。每一次与反爬机制的“过招”都让你对HTTP协议、Web应用和安全策略有更深刻的认识。保持好奇保持谨慎在技术的边界内探索这才是可持续的爬虫之道。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门