Retry 装饰器
网页爬虫本质上是不可预测的。网络故障、页面加载缓慢、元素出现和消失、触发速率限制以及意外出现的验证码。@retry 装饰器提供了一个经过实战测试的强大解决方案,能够优雅地处理这些不可避免的故障。
为什么使用 Retry 装饰器?
在生产环境的爬虫中,故障不是例外——而是常态。与其让整个爬虫任务因为临时的网络故障或缺失的元素而崩溃,retry 装饰器允许您:
- 自动恢复 临时性故障
- 实施复杂的重试策略 使用指数退避
- 在重试前执行恢复逻辑 (刷新页面、切换代理、重启浏览器)
- 保持业务逻辑清晰 不会被错误处理代码污染
快速开始
import asyncio
from pydoll.browser.chromium import Chrome
from pydoll.decorators import retry
from pydoll.exceptions import WaitElementTimeout, NetworkError
@retry(max_retries=3, exceptions=[WaitElementTimeout, NetworkError])
async def scrape_product_page(url: str):
async with Chrome() as browser:
tab = await browser.start()
await tab.go_to(url)
# 这可能因网络问题或加载缓慢而失败
product_title = await tab.find(class_name='product-title', timeout=5)
return await product_title.text
asyncio.run(scrape_product_page('https://example.com/product/123'))
如果 scrape_product_page 因 WaitElementTimeout 或 NetworkError 失败,它将自动重试最多 3 次才会放弃。
最佳实践:始终指定异常
关键最佳实践
始终 指定应该触发重试的异常。使用默认的 exceptions=Exception 会捕获 所有 异常,包括应该立即失败的代码错误。
错误(捕获所有内容,包括错误):
@retry(max_retries=3) # 不要这样做
async def scrape_data():
data = response['items'][0] # 如果 'items' 不存在,重试无济于事!
return data
正确(仅对预期的失败重试):
from pydoll.exceptions import ElementNotFound, WaitElementTimeout, NetworkError
@retry(
max_retries=3,
exceptions=[ElementNotFound, WaitElementTimeout, NetworkError]
)
async def scrape_data():
async with Chrome() as browser:
tab = await browser.start()
await tab.go_to('https://example.com')
return await tab.find(id='data-container', timeout=10)
通过指定异常,您可以确保:
- 逻辑错误快速失败 (拼写错误、错误的选择器、代码错误)
- 仅重试可恢复的错误 (网络问题、超时、缺失元素)
- 调试更容易 (您确切知道出了什么问题)
参数
max_retries
放弃前的最大重试次数。
from pydoll.exceptions import WaitElementTimeout
@retry(max_retries=5, exceptions=[WaitElementTimeout])
async def fetch_data():
# 总共将尝试 5 次
pass
exceptions
应该触发重试的异常类型。可以是单个异常或列表。
from pydoll.exceptions import (
ElementNotFound,
WaitElementTimeout,
NetworkError,
ElementNotInteractable
)
# 单个异常
@retry(exceptions=[WaitElementTimeout])
async def example1():
pass
# 多个异常
@retry(exceptions=[WaitElementTimeout, NetworkError, ElementNotFound, ElementNotInteractable])
async def example2():
pass
常见爬虫异常
对于使用 Pydoll 进行网页爬虫,您通常会希望重试:
WaitElementTimeout- 等待元素出现超时ElementNotFound- DOM 中不存在元素ElementNotVisible- 元素存在但不可见ElementNotInteractable- 元素无法接收交互NetworkError- 网络连接问题ConnectionFailed- 连接浏览器失败PageLoadTimeout- 页面加载超时ClickIntercepted- 点击被另一个元素拦截
delay
重试尝试之间的等待时间(以秒为单位)。
from pydoll.exceptions import WaitElementTimeout
@retry(max_retries=3, exceptions=[WaitElementTimeout], delay=2.0)
async def scrape_with_delay():
# 每次重试之间等待 2 秒
pass
exponential_backoff
当设置为 True 时,随着每次重试尝试,延迟会指数级增加。
from pydoll.exceptions import NetworkError
@retry(
max_retries=5,
exceptions=[NetworkError],
delay=1.0,
exponential_backoff=True
)
async def scrape_with_backoff():
# 尝试 1: 失败 → 等待 1 秒
# 尝试 2: 失败 → 等待 2 秒
# 尝试 3: 失败 → 等待 4 秒
# 尝试 4: 失败 → 等待 8 秒
# 尝试 5: 失败 → 抛出异常
pass
什么是指数退避?
指数退避是一种重试策略,尝试之间的等待时间呈指数级增长。与其每秒对服务器发起请求,不如逐渐给服务器更多恢复时间:
- 尝试 1:等待
delay秒(例如 1 秒) - 尝试 2:等待
delay * 2秒(例如 2 秒) - 尝试 3:等待
delay * 4秒(例如 4 秒) - 尝试 4:等待
delay * 8秒(例如 8 秒)
这在以下情况下特别有用:
- 处理 速率限制 (给服务器时间重置)
- 处理 临时服务器过载 (不要让情况变得更糟)
- 等待 加载缓慢的动态内容
- 避免 被检测为机器人 (看起来自然的重试模式)
on_retry
在每次失败尝试后、下次重试前执行的回调函数。必须是 async 函数。
from pydoll.exceptions import WaitElementTimeout
@retry(
max_retries=3,
exceptions=[WaitElementTimeout],
on_retry=my_recovery_function
)
async def scrape_data():
pass
回调可以是:
- 独立的 async 函数
- 类方法 (自动接收
self)
on_retry 回调:您的恢复机制
on_retry 回调是真正神奇的地方。这是您在下次重试尝试之前 恢复应用程序状态 的机会。
独立函数
import asyncio
from pydoll.decorators import retry
from pydoll.exceptions import WaitElementTimeout
async def log_retry():
print("重试尝试失败,下次尝试前等待...")
await asyncio.sleep(1)
@retry(max_retries=3, exceptions=[WaitElementTimeout], on_retry=log_retry)
async def scrape_page():
# 您的爬虫逻辑
pass
类方法
在类内部使用装饰器时,回调可以是类方法。它将自动接收 self 作为第一个参数。
import asyncio
from pydoll.decorators import retry
from pydoll.exceptions import WaitElementTimeout
class DataCollector:
def __init__(self):
self.retry_count = 0
# 重要:在装饰方法之前定义回调
async def log_retry(self):
self.retry_count += 1
print(f"尝试 {self.retry_count} 失败,正在重试...")
await asyncio.sleep(1)
@retry(
max_retries=3,
exceptions=[WaitElementTimeout],
on_retry=log_retry # 不需要 'self.' 前缀
)
async def fetch_data(self):
# 您的爬取逻辑
pass
方法定义顺序很重要
使用类方法的 on_retry 时,必须在类定义中的装饰方法之前定义回调方法。Python 在应用装饰器时需要知道回调。
错误(会失败):
class Scraper:
@retry(on_retry=handle_retry) # handle_retry 还不存在!
async def scrape(self):
pass
async def handle_retry(self): # 定义太晚
pass
正确:
实际应用案例
1. 页面刷新和状态恢复
这是 on_retry 最强大的用法:通过刷新页面并恢复应用程序状态来从故障中恢复。此示例演示了为什么 retry 装饰器对生产爬虫如此有价值。
from pydoll.browser.chromium import Chrome
from pydoll.decorators import retry
from pydoll.exceptions import ElementNotFound, WaitElementTimeout
from pydoll.constants import Key
import asyncio
class DataScraper:
def __init__(self):
self.browser = None
self.tab = None
self.current_page = 1
async def recover_from_failure(self):
"""刷新页面并在重试前恢复状态"""
print(f"恢复中... 刷新第 {self.current_page} 页")
if self.tab:
# 刷新页面以从陈旧元素或错误状态中恢复
await self.tab.refresh()
await asyncio.sleep(2) # 等待页面加载
# 恢复状态:导航回正确页面
if self.current_page > 1:
page_input = await self.tab.find(id='page-number')
await page_input.insert_text(str(self.current_page))
await self.tab.keyboard.press(Key.ENTER)
await asyncio.sleep(1)
@retry(
max_retries=3,
exceptions=[ElementNotFound, WaitElementTimeout],
on_retry=recover_from_failure,
delay=1.0
)
async def scrape_page_data(self):
"""从当前页面抓取数据"""
if not self.browser:
self.browser = Chrome()
self.tab = await self.browser.start()
await self.tab.go_to('https://example.com/data')
# 导航到特定页面
page_input = await self.tab.find(id='page-number')
await page_input.insert_text(str(self.current_page))
await self.tab.keyboard.press(Key.ENTER)
await asyncio.sleep(1)
# 抓取数据(如果元素变陈旧可能会失败)
items = await self.tab.find(class_name='data-item', find_all=True)
return [await item.text for item in items]
async def scrape_multiple_pages(self, start_page: int, end_page: int):
"""抓取多个页面,失败时自动重试"""
results = []
for page_num in range(start_page, end_page + 1):
self.current_page = page_num
data = await self.scrape_page_data()
results.extend(data)
return results
# 用法
async def main():
scraper = DataScraper()
try:
# 抓取第 1-10 页,失败时自动恢复
all_data = await scraper.scrape_multiple_pages(1, 10)
print(f"已抓取 {len(all_data)} 个项目")
finally:
if scraper.browser:
await scraper.browser.stop()
这为什么强大:
recover_from_failure()真正恢复状态:刷新并导航回来scrape_page_data()方法保持简洁,只专注于爬取逻辑- 如果元素变陈旧或消失,重试机制会自动处理恢复
- 浏览器通过
self.browser和self.tab在重试之间保持
2. 模态对话框恢复
有时模态框或遮罩层会意外出现并阻止自动化。关闭它并重试。
from pydoll.browser.chromium import Chrome
from pydoll.decorators import retry
from pydoll.exceptions import ElementNotFound
class ModalAwareScraper:
def __init__(self):
self.tab = None
async def close_modals(self):
"""在重试前关闭任何阻挡的模态框"""
print("检查阻挡的模态框...")
# 尝试查找并关闭常见模态框
modal_close = await self.tab.find(
class_name='modal-close',
timeout=2,
raise_exc=False
)
if modal_close:
print("找到模态框,关闭中...")
await modal_close.click()
await asyncio.sleep(0.5)
@retry(
max_retries=3,
exceptions=[ElementNotFound],
on_retry=close_modals,
delay=0.5
)
async def click_button(self, button_id: str):
button = await self.tab.find(id=button_id)
await button.click()
3. 浏览器重启和代理轮换
对于大型爬虫任务,失败后可能需要完全重启浏览器并切换代理。
import asyncio
from pydoll.browser.chromium import Chrome
from pydoll.browser.options import ChromiumOptions
from pydoll.decorators import retry
from pydoll.exceptions import NetworkError, PageLoadTimeout
class RobustScraper:
def __init__(self):
self.browser = None
self.tab = None
self.proxy_list = [
'proxy1.example.com:8080',
'proxy2.example.com:8080',
'proxy3.example.com:8080',
]
self.current_proxy_index = 0
async def restart_with_new_proxy(self):
"""使用不同代理重启浏览器"""
print("使用新代理重启浏览器...")
# 关闭当前浏览器
if self.browser:
await self.browser.stop()
await asyncio.sleep(2)
# 轮换到下一个代理
self.current_proxy_index = (self.current_proxy_index + 1) % len(self.proxy_list)
proxy = self.proxy_list[self.current_proxy_index]
print(f"使用代理: {proxy}")
# 使用新代理启动新浏览器
options = ChromiumOptions()
options.add_argument(f'--proxy-server={proxy}')
self.browser = Chrome(options=options)
self.tab = await self.browser.start()
@retry(
max_retries=3,
exceptions=[NetworkError, PageLoadTimeout],
on_retry=restart_with_new_proxy,
delay=5.0,
exponential_backoff=True
)
async def scrape_protected_site(self, url: str):
if not self.browser:
await self.restart_with_new_proxy()
await self.tab.go_to(url)
await asyncio.sleep(3)
# 您的爬虫逻辑
content = await self.tab.find(id='content')
return await content.text
4. 网络空闲检测与重试
等待所有网络活动完成,如果页面从未稳定则使用重试逻辑。
import asyncio
from pydoll.browser.chromium import Chrome
from pydoll.decorators import retry
from pydoll.exceptions import TimeoutException
class NetworkAwareScraper:
def __init__(self):
self.tab = None
async def reload_page(self):
"""如果网络从未稳定则重新加载页面"""
print("页面未稳定,重新加载...")
if self.tab:
await self.tab.refresh()
await asyncio.sleep(2)
@retry(
max_retries=2,
exceptions=[TimeoutException],
on_retry=reload_page,
delay=3.0
)
async def wait_for_page_ready(self):
"""等待所有网络请求完成"""
await self.tab.enable_network_events()
# 等待网络空闲(2 秒内无请求)
idle_time = 0
max_wait = 10
while idle_time < max_wait:
# 检查是否有正在进行的请求
# (实现取决于您的事件跟踪)
await asyncio.sleep(0.5)
idle_time += 0.5
if idle_time >= max_wait:
raise TimeoutException("网络从未稳定")
5. 验证码检测和恢复
检测验证码何时出现并采取适当行动。
import asyncio
from pydoll.browser.chromium import Chrome
from pydoll.decorators import retry
from pydoll.exceptions import ElementNotFound
class CaptchaScraper:
def __init__(self):
self.tab = None
self.captcha_count = 0
async def handle_captcha(self):
"""通过等待或切换策略处理验证码"""
self.captcha_count += 1
print(f"检测到验证码(计数:{self.captcha_count})")
if self.captcha_count > 2:
print("验证码过多,可能需要更改策略...")
# 可以在这里切换到不同的方法
# 尝试之间等待更长时间
await asyncio.sleep(30)
# 刷新页面
await self.tab.refresh()
await asyncio.sleep(5)
@retry(
max_retries=3,
exceptions=[ElementNotFound],
on_retry=handle_captcha,
delay=10.0,
exponential_backoff=True
)
async def scrape_protected_content(self, url: str):
if not self.tab:
browser = Chrome()
self.tab = await browser.start()
await self.tab.go_to(url)
# 检查验证码
captcha = await self.tab.find(
class_name='g-recaptcha',
timeout=2,
raise_exc=False
)
if captcha:
raise ElementNotFound("检测到验证码")
# 正常爬虫逻辑
content = await self.tab.find(class_name='article-content')
return await content.text
高级模式
组合多种恢复策略
import asyncio
from pydoll.browser.chromium import Chrome
from pydoll.decorators import retry
from pydoll.exceptions import ElementNotFound, WaitElementTimeout, NetworkError
class AdvancedScraper:
def __init__(self):
self.tab = None
self.attempt = 0
self.strategies = [
self.strategy_refresh,
self.strategy_clear_cache,
self.strategy_restart_browser,
]
async def strategy_refresh(self):
"""策略 1:简单刷新"""
print("策略 1:刷新页面")
await self.tab.refresh()
await asyncio.sleep(2)
async def strategy_clear_cache(self):
"""策略 2:清除缓存并刷新"""
print("策略 2:清除缓存")
await self.tab.execute_command('Network.clearBrowserCache')
await self.tab.refresh()
await asyncio.sleep(3)
async def strategy_restart_browser(self):
"""策略 3:完全重启浏览器"""
print("策略 3:重启浏览器")
if self.tab:
await self.tab._browser.stop()
browser = Chrome()
self.tab = await browser.start()
async def adaptive_recovery(self):
"""根据尝试次数尝试不同的恢复策略"""
strategy_index = min(self.attempt, len(self.strategies) - 1)
strategy = self.strategies[strategy_index]
print(f"尝试 {self.attempt + 1}:使用 {strategy.__name__}")
await strategy()
self.attempt += 1
@retry(
max_retries=3,
exceptions=[ElementNotFound, WaitElementTimeout, NetworkError],
on_retry=adaptive_recovery,
delay=2.0
)
async def scrape_with_adaptive_retry(self, url: str):
await self.tab.go_to(url)
return await self.tab.find(id='target-content')
特定失败的自定义异常
import asyncio
from pydoll.decorators import retry
from pydoll.exceptions import PydollException
class RateLimitError(PydollException):
"""检测到速率限制时引发"""
message = "API 速率限制已超出"
class APIScraper:
async def wait_for_rate_limit_reset(self):
"""被速率限制时等待更长时间"""
print("检测到速率限制,等待 60 秒...")
await asyncio.sleep(60)
@retry(
max_retries=5,
exceptions=[RateLimitError],
on_retry=wait_for_rate_limit_reset,
delay=10.0,
exponential_backoff=True
)
async def fetch_api_data(self, endpoint: str):
response = await self.tab.request.get(endpoint)
if response.status == 429: # 请求过多
raise RateLimitError("API 速率限制已超出")
return response.json()
最佳实践总结
- 始终明确指定异常 - 永不使用默认的
exceptions=Exception - 对外部服务使用指数退避 - 给服务器恢复时间
- 保持合理的重试次数 - 通常 3-5 次尝试就足够了
- 记录重试尝试 - 使用
on_retry记录发生的事情 - 在装饰方法之前定义回调 - 类定义中的顺序很重要
- 使回调异步 - 装饰器需要异步回调
- 在回调中恢复状态 - 使用
on_retry导航回原位置 - 考虑重试的成本 - 每次重试都会消耗时间和资源
- 与其他错误处理结合 - 重试不能替代 try/except 块
- 测试您的重试逻辑 - 确保恢复回调实际有效
了解更多
retry 装饰器是一个强大的工具,可以将脆弱的爬虫脚本转变为生产就绪的应用程序。通过将其与周到的恢复策略相结合,您可以构建能够优雅地处理真实网络混乱情况的爬虫。