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加入黑名单,返回429(Too Many Requests)或其他自定义错误。我的脚本在快速循环中,很容易就触发了这个限制。
2.3 参数签名与动态令牌:增加请求成本
在进一步分析中,我发现这个天气网站的数据接口(通常用于异步加载更详细的数据,如逐小时预报)的URL并不是静态的。例如,获取北京天气详情的API可能长这样:https://某天气网站/api/weather?city=101010100&_=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/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', 'cache-control': 'max-age=0', 'sec-ch-ua': '"Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"', '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 = f'https://某天气网站/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 = f'https://某天气网站/api/weather?city=101010100&_={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=城市ID&t=时间戳&salt=盐值) sign_string = f'city={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 = f'https://某天气网站/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, proxies=proxies, timeout=10)代理使用要点:
- 来源:可以使用免费的代理IP池(不稳定、速度慢),或购买付费的代理服务(稳定、高速)。
- 轮换策略:准备一个代理IP列表,每次请求随机选取一个,或者失败后自动切换下一个。
- 验证代理:在使用一个代理前,先用它访问一个如
http://httpbin.org/ip这样的服务,检查代理是否有效且返回的是代理IP而非本机IP。 - 并发控制:即使使用多个代理,对同一个目标网站的并发请求数也要控制,否则服务器端仍可能从行为模式上识别出爬虫。
4. 数据解析与存储:从HTML到结构化数据
成功获取到HTML或JSON数据后,下一步就是从中提取出我们需要的结构化信息,比如城市名、温度、湿度、风向等。
4.1 解析HTML页面
如果数据在初始HTML中,我们使用BeautifulSoup:
from 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(strip=True) if city_name_tag else 'N/A' # 假设当前温度在一个id为`now-temp`的span标签里 temp_tag = soup.find('span', id='now-temp') temperature = temp_tag.get_text(strip=True) if temp_tag else 'N/A' # 更复杂的情况:数据可能在某个`<script>`标签的JavaScript变量中 # 需要结合正则表达式来提取 import re script_data = soup.find('script', text=re.compile('var data =')) if script_data: # 使用正则匹配出JSON字符串 match = re.search(r'var 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='', encoding='utf-8-sig') as f: writer = csv.DictWriter(f, fieldnames=fieldnames) 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', encoding='utf-8') as f: json.dump(all_data, f, ensure_ascii=False, indent=2)
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( total=3, # 总共重试次数(包括第一次请求) backoff_factor=1, # 重试等待时间增长因子 (等待时间 = backoff_factor * (2^(重试次数-1)) 秒) status_forcelist=[429, 500, 502, 503, 504], # 遇到这些状态码时重试 ) adapter = HTTPAdapter(max_retries=retry_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 问题:返回状态码403(Forbidden)
可能原因与排查:
- 请求头不完整或不正确:这是最常见的原因。仔细对比浏览器请求头,确保
User-Agent、Accept、Accept-Language、Referer(如果需要)等关键头部一致。特别是Referer,有些网站会检查请求是否来自其站内页面。 - Cookie问题:某些操作需要先访问首页获取初始Cookie,或者需要登录后的认证Cookie。使用
Session对象保持会话,或者手动从浏览器复制Cookie字符串添加到请求头中('Cookie': 'name=value; name2=value2')。 - 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', encoding='utf-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应用和安全策略有更深刻的认识。保持好奇,保持谨慎,在技术的边界内探索,这才是可持续的爬虫之道。