DrissionPage实战避坑指南:从零构建高效自动化与数据采集方案
如果你正在寻找一个能同时驾驭浏览器自动化与高效网络请求的Python工具,那么DrissionPage很可能就是你期待已久的答案。这个库巧妙地将Selenium的浏览器控制能力与Requests的轻量级请求特性融为一体,让你在面对复杂网页交互和数据采集任务时,能够灵活切换策略,游刃有余。不过,任何强大的工具在初次接触时都可能遇到一些“坑”,这篇文章就是为你准备的实战指南。我会结合自己多次项目实践中的经验,带你从环境配置一路走到高级的多标签页管理,帮你避开那些常见的陷阱,快速上手并发挥出DrissionPage的真正威力。
1. 环境搭建与首次启动的常见陷阱
万事开头难,一个顺畅的起步能省去后面无数麻烦。DrissionPage的安装虽然简单,但第一次启动浏览器时,很多人都会卡在第一步。
1.1 安装与基础依赖检查
安装DrissionPage本身只需要一行命令,但确保你的Python环境是3.6或更高版本。我建议使用虚拟环境来管理依赖,避免与其他项目产生冲突。
# 创建并激活虚拟环境(以venv为例)
python -m venv drission_env
source drission_env/bin/activate # Linux/Mac
# 或
drission_env\Scripts\activate # Windows
# 安装DrissionPage
pip install DrissionPage
注意:如果你之前安装过旧版本,想要升级到最新版,可以使用
pip install DrissionPage --upgrade。但要注意,不同大版本之间API可能有变化,升级后最好测试一下现有代码。
安装完成后,不要急着写复杂的脚本。先来一个最简单的测试,看看基础环境是否正常:
from DrissionPage import ChromiumPage
page = ChromiumPage()
page.get('https://www.baidu.com')
print(f"页面标题: {page.title}")
如果这段代码能正常运行并打印出百度首页的标题,那么恭喜你,最基础的环境已经没问题了。但实际情况中,很多人会在这里遇到第一个坑——浏览器路径问题。
1.2 浏览器路径配置的两种策略
当看到BrowserNotFoundError或类似的错误时,不要慌张。这通常意味着DrissionPage没有在你的系统中自动找到Chrome或Edge浏览器。解决这个问题有两种思路,我根据不同的使用场景来分别说明。
场景一:固定开发环境,一劳永逸
如果你的开发环境相对固定,比如就在自己的笔记本电脑上工作,那么将浏览器路径永久配置到DrissionPage的配置文件中是最方便的选择。DrissionPage提供了一个命令行工具dp来完成这个配置:
# Windows系统,假设Chrome安装在默认位置
dp -p "C:\Program Files\Google\Chrome\Application\chrome.exe"
# Mac系统
dp -p "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
# Linux系统
dp -p "/usr/bin/google-chrome-stable"
执行这个命令后,DrissionPage会在用户目录下创建或更新配置文件,以后每次启动都会使用这个路径。你可以通过以下代码验证配置是否生效:
from DrissionPage import ChromiumOptions
# 查看当前配置的浏览器路径
co = ChromiumOptions()
print(f"当前浏览器路径: {co.browser_path}")
场景二:动态环境或临时测试
有时候你可能需要在不同的机器上运行代码,或者只是想临时测试一下某个功能,不想修改全局配置。这时候可以在代码中动态指定浏览器路径:
from DrissionPage import ChromiumPage, ChromiumOptions
# 创建配置对象并设置浏览器路径
co = ChromiumOptions().set_browser_path(r"D:\MyChrome\chrome.exe")
# 使用这个配置创建页面对象
page = ChromiumPage(co)
page.get('https://www.example.com')
这种方法的好处是灵活,不会影响其他项目或用户的配置。我个人的习惯是,在团队协作的项目中,将浏览器路径作为配置项从外部文件读取,这样每个开发者都可以根据自己的环境进行调整。
1.3 浏览器启动模式的选择与优化
DrissionPage默认会启动一个带界面的浏览器,这在调试阶段非常有用,你可以直观地看到自动化脚本的执行过程。但在生产环境或需要批量处理时,无头模式(Headless)是更好的选择,因为它不显示界面,节省系统资源,运行速度也更快。
from DrissionPage import ChromiumPage, ChromiumOptions
# 配置无头模式
co = ChromiumOptions().headless(True)
# 还可以设置其他常用选项
co.set_argument('--no-sandbox') # 在Linux服务器上运行时可能需要
co.set_argument('--disable-gpu') # 某些环境下禁用GPU加速
co.set_argument('--disable-dev-shm-usage') # 解决共享内存问题
page = ChromiumPage(co)
除了无头模式,你还可以根据需求配置其他启动参数:
| 配置项 | 方法 | 适用场景 |
|---|---|---|
| 无痕模式 | .incognito(True) | 每次启动都是全新会话,不读取历史记录和cookies |
| 访客模式 | .set_argument('--guest') | 类似无痕,但更彻底,不创建用户数据目录 |
| 指定用户数据目录 | .set_paths(user_data_path='path') | 保留登录状态、扩展程序等个性化设置 |
| 设置代理 | .set_proxy('http://proxy:port') | 需要通过代理访问网络 |
| 禁用图片加载 | .set_argument('--blink-settings=imagesEnabled=false') | 提高页面加载速度,减少流量消耗 |
我在实际项目中经常根据任务类型选择不同的配置组合。比如做数据采集时,我会使用无头+无痕模式,确保每次都是干净的会话;而在测试需要登录的网站自动化时,我会指定用户数据目录来保持登录状态。
2. 元素定位:从基础到高级的精准查找策略
元素定位是网页自动化的核心,也是新手最容易感到困惑的地方。DrissionPage提供了一套非常灵活且强大的定位语法,比传统的XPath和CSS选择器更加直观。
2.1 基础定位语法详解
让我们从一个简单的HTML示例开始,逐步理解各种定位方式:
<!-- 假设这是我们要操作的页面结构 -->
<div id="main-container">
<form class="login-form">
<input type="text" id="username" name="user" placeholder="请输入用户名">
<input type="password" id="password" name="pass">
<button type="submit" class="btn-primary">登录</button>
<a href="/forgot" class="link">忘记密码?</a>
</form>
<div class="news-list">
<h3>最新消息</h3>
<ul>
<li class="news-item" data-id="101">第一条新闻</li>
<li class="news-item" data-id="102">第二条新闻</li>
<li class="news-item special" data-id="103">特别推荐</li>
</ul>
</div>
</div>
按属性定位是最直接的方式:
# 按id定位 - 最精确的方式
username_input = page.ele('#username')
# 或者使用属性语法
username_input = page.ele('@id=username')
# 按class定位 - 注意class可能有多个值
login_form = page.ele('.login-form')
submit_button = page.ele('@class=btn-primary')
# 按其他属性定位
password_input = page.ele('@type=password')
forgot_link = page.ele('@href=/forgot')
按文本内容定位在处理动态内容时特别有用:
# 精确文本匹配
login_button = page.ele('text=登录')
forgot_text = page.ele('text=忘记密码?')
# 模糊文本匹配(包含)
special_news = page.ele('text:特别')
# 这也会匹配到"特别推荐"
# 文本开头匹配
latest_news = page.ele('text^=最新')
# 匹配以"最新"开头的元素
# 文本结尾匹配
password_label = page.ele('text$=密码')
# 匹配以"密码"结尾的元素
2.2 复杂条件组合与相对定位
实际项目中,页面结构往往比示例复杂得多。DrissionPage支持多种条件的组合查询,让你能精准定位到目标元素。
多条件"与"关系:当需要同时满足多个条件时
# 查找class为news-item且data-id为103的元素
special_item = page.ele('@@class=news-item@@data-id=103')
# 查找type为submit且class包含btn的元素
submit_btn = page.ele('@@type=submit@@class:btn')
多条件"或"关系:当满足任一条件即可时
# 查找id为username或name为user的输入框
user_input = page.ele('@|id=username@|name=user')
# 查找文本包含"新闻"或"消息"的元素
news_elements = page.eles('@|text:新闻@|text:消息')
链式查找与相对定位:这是DrissionPage的一大亮点
# 先找到表单,再在表单内查找元素
form = page.ele('.login-form')
username = form.ele('#username') # 在form元素内查找
# 更简洁的链式写法
password = page.ele('.login-form').ele('@type=password')
# 查找兄弟元素
first_news = page.ele('.news-item')
second_news = first_news.next('tag:li') # 下一个li兄弟元素
third_news = second_news.next('tag:li')
# 查找父元素
news_list = first_news.parent('tag:ul')
container = news_list.parent('tag:div')
2.3 处理动态加载与等待机制
现代网页大量使用JavaScript动态加载内容,如果元素还没加载出来就去操作,肯定会出错。DrissionPage内置了智能的等待机制,但了解其工作原理能帮你写出更健壮的代码。
内置等待:ele()和eles()方法默认会等待10秒
# 默认等待10秒,如果10秒内元素出现就返回,否则超时
try:
element = page.ele('#dynamic-content')
print("元素找到了!")
except TimeoutError:
print("元素在10秒内没有出现")
自定义等待时间:
from DrissionPage import ChromiumPage
page = ChromiumPage(timeout=30) # 设置全局超时时间为30秒
# 或者针对单个查找设置超时
element = page.ele('#slow-loading-element', timeout=15)
显式等待特定条件:有时候我们需要等待更复杂的条件
# 等待页面标题包含特定文本
page.wait.title_change(to='控制面板')
# 等待URL变化
page.wait.url_change(contains='dashboard')
# 等待元素显示出来
element = page.ele('#loading')
element.wait.displayed(timeout=20)
# 等待元素被删除(比如加载动画消失)
loading = page.ele('#loading-spinner')
loading.wait.deleted()
我在处理一个电商网站的数据采集时,遇到过商品列表分页加载的情况。页面不会一次性加载所有商品,而是滚动到底部时加载更多。这时候就需要结合滚动和等待:
# 滚动到页面底部触发加载
page.scroll.to_bottom()
# 等待新元素出现
page.wait.eles_loaded('tag:div@@class=product-item', timeout=5)
# 如果等待失败,可能已经加载完毕,继续处理
products = page.eles('tag:div@@class=product-item')
2.4 定位失败的原因分析与调试技巧
即使经验丰富的开发者,也难免遇到定位失败的情况。下面是一些常见问题及解决方法:
问题1:元素在iframe中
# 先定位到iframe元素
iframe = page.ele('tag:iframe@@id=content-frame')
# 在iframe内部查找元素
# 方法1:直接链式查找(DrissionPage会自动处理iframe切换)
inner_element = iframe.ele('.some-class')
# 方法2:获取iframe的页面对象
iframe_page = iframe.inner_page
element = iframe_page.ele('#target')
问题2:元素在shadow DOM中
# 对于开放的shadow-root
shadow_host = page.ele('#shadow-host')
shadow_root = shadow_host.shadow_root
shadow_element = shadow_root.ele('.inside-shadow')
# 对于闭合的shadow-root,可能需要执行JavaScript
js_script = """
return document.querySelector('#shadow-host').shadowRoot.querySelector('.target');
"""
element = page.run_js(js_script)
问题3:XPath定位的注意事项
虽然DrissionPage支持XPath,但XPath对页面结构变化非常敏感。我建议优先使用DrissionPage的自定义语法,只在必要时使用XPath:
# 不推荐 - 对结构变化敏感
elements = page.eles('xpath://div[@class="container"]/div[2]/span[3]')
# 推荐 - 更健壮的定位方式
elements = page.eles('tag:div@@class=container').ele('tag:div', index=1).eles('tag:span')[2]
调试技巧:当定位失败时,可以逐步缩小范围
# 1. 先确认页面是否加载完成
print(f"当前URL: {page.url}")
print(f"页面标题: {page.title}")
# 2. 查看页面HTML结构(调试时使用,生产环境慎用)
with open('debug.html', 'w', encoding='utf-8') as f:
f.write(page.html)
# 3. 尝试更宽泛的定位,逐步精确
all_divs = page.eles('tag:div')
print(f"找到{len(all_divs)}个div元素")
# 4. 使用浏览器开发者工具辅助
# 在代码中暂停,手动在浏览器中检查
import time
time.sleep(10) # 给你时间在浏览器中按F12检查
3. 多标签页管理与并发操作实战
多标签页操作是DrissionPage相比传统自动化工具的一大优势。你可以同时控制多个标签页,实现真正的并发操作,这在数据采集和自动化测试中非常有用。
3.1 标签页的基本操作
让我们从一个简单的多标签页示例开始:
from DrissionPage import ChromiumPage
import time
page = ChromiumPage()
# 打开第一个标签页访问百度
page.get('https://www.baidu.com')
print(f"标签页1标题: {page.title}")
# 新建标签页(会在后台打开,不会自动切换焦点)
page.new_tab('https://www.github.com')
print(f"当前标签页数量: {page.tabs_count}")
# 获取所有标签页ID
tab_ids = page.tab_ids
print(f"标签页IDs: {tab_ids}")
# 切换到第二个标签页
tab2 = page.get_tab(tab_ids[1])
tab2.get('https://www.python.org')
print(f"标签页2标题: {tab2.title}")
# 切回第一个标签页进行操作
tab1 = page.get_tab(tab_ids[0])
tab1.ele('#kw').input('DrissionPage')
tab1.ele('#su').click()
time.sleep(2)
print(f"标签页1新标题: {tab1.title}")
这个例子展示了标签页的基本操作,但在实际项目中,我们通常需要更精细的控制。
3.2 高级标签页管理策略
场景一:批量打开多个页面并收集数据
假设我们需要从多个新闻网站收集头条新闻:
from DrissionPage import ChromiumPage
from concurrent.futures import ThreadPoolExecutor
import threading
class NewsCollector:
def __init__(self):
self.page = ChromiumPage()
self.lock = threading.Lock()
self.results = {}
def collect_from_site(self, url, site_name):
"""在每个标签页中收集数据"""
# 新建标签页
tab = self.page.new_tab(url)
# 等待页面加载
tab.wait.doc_loaded()
# 根据网站结构提取数据
if site_name == 'news_site1':
title = tab.ele('tag:h1@@class=headline', timeout=5).text
elif site_name == 'news_site2':
title = tab.ele('tag:div@@id=main-title', timeout=5).text
else:
title = tab.title
# 关闭标签页
tab.close()
# 线程安全地保存结果
with self.lock:
self.results[site_name] = title
return title
def run(self, sites):
"""并发收集所有站点"""
with ThreadPoolExecutor(max_workers=3) as executor:
futures = []
for url, name in sites.items():
future = executor.submit(self.collect_from_site, url, name)
futures.append(future)
# 等待所有任务完成
for future in futures:
try:
future.result(timeout=30)
except Exception as e:
print(f"收集数据时出错: {e}")
return self.results
# 使用示例
collector = NewsCollector()
sites = {
'news_site1': 'https://news.site1.com',
'news_site2': 'https://news.site2.com',
'news_site3': 'https://news.site3.com'
}
results = collector.run(sites)
for site, title in results.items():
print(f"{site}: {title}")
场景二:保持登录状态的多标签页操作
有些操作需要在登录状态下进行,我们可以利用用户数据目录来保持会话:
from DrissionPage import ChromiumPage, ChromiumOptions
import json
class MultiTabManager:
def __init__(self, user_data_dir='./browser_data'):
# 配置使用特定的用户数据目录
co = ChromiumOptions().set_paths(
user_data_path=user_data_dir,
local_port=9222
)
self.page = ChromiumPage(co)
self.tabs = {} # 存储标签页对象
self.tab_configs = {} # 存储标签页配置
def create_tab_with_config(self, tab_id, url, config=None):
"""创建带有特定配置的标签页"""
if config is None:
config = {}
# 记录配置
self.tab_configs[tab_id] = {
'url': url,
'config': config,
'created_at': time.time()
}
# 创建新标签页
tab = self.page.new_tab(url)
self.tabs[tab_id] = tab
# 应用配置
if 'viewport' in config:
tab.set.window.size(*config['viewport'])
if 'cookies' in config:
for cookie in config['cookies']:
tab.cookies.set(cookie)
return tab
def execute_in_tab(self, tab_id, func, *args, **kwargs):
"""在指定标签页中执行函数"""
if tab_id not in self.tabs:
raise ValueError(f"标签页 {tab_id} 不存在")
tab = self.tabs[tab_id]
# 切换到该标签页
tab.set.activate()
# 执行函数
return func(tab, *args, **kwargs)
def close_tab(self, tab_id):
"""关闭指定标签页"""
if tab_id in self.tabs:
self.tabs[tab_id].close()
del self.tabs[tab_id]
del self.tab_configs[tab_id]
def save_state(self, filepath='tab_state.json'):
"""保存标签页状态(便于恢复)"""
state = {
'current_url': self.page.url,
'tabs': {}
}
for tab_id, tab in self.tabs.items():
state['tabs'][tab_id] = {
'url': tab.url,
'title': tab.title,
'config': self.tab_configs.get(tab_id, {})
}
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(state, f, ensure_ascii=False, indent=2)
def cleanup(self):
"""清理所有标签页"""
for tab_id in list(self.tabs.keys()):
self.close_tab(tab_id)
self.page.quit()
# 使用示例
manager = MultiTabManager()
# 创建多个标签页用于不同任务
manager.create_tab_with_config(
'dashboard',
'https://example.com/dashboard',
config={'viewport': (1920, 1080)}
)
manager.create_tab_with_config(
'monitor',
'https://example.com/monitor',
config={'viewport': (1200, 800)}
)
# 在各个标签页中执行任务
def check_alerts(tab):
"""检查监控告警"""
alerts = tab.eles('.alert-item')
return [alert.text for alert in alerts]
def get_stats(tab):
"""获取仪表板统计"""
stats = {}
stat_elements = tab.eles('.stat-card')
for elem in stat_elements:
key = elem.ele('.stat-label').text
value = elem.ele('.stat-value').text
stats[key] = value
return stats
# 并发执行(实际项目中可以用线程池)
alerts = manager.execute_in_tab('monitor', check_alerts)
stats = manager.execute_in_tab('dashboard', get_stats)
print(f"告警信息: {alerts}")
print(f"统计信息: {stats}")
# 保存状态以便下次恢复
manager.save_state()
# 清理
manager.cleanup()
3.3 标签页间的通信与数据同步
在多标签页应用中,经常需要在不同标签页之间传递数据或同步状态。DrissionPage虽然不直接提供标签页间通信的API,但我们可以通过一些技巧实现:
import json
import time
from DrissionPage import ChromiumPage
class TabCommunicator:
def __init__(self):
self.page = ChromiumPage()
self.shared_data = {}
def share_data_via_localstorage(self, key, value, target_tab_id=None):
"""通过localStorage共享数据"""
# 将数据存储到当前标签页的localStorage
js_code = f'localStorage.setItem("{key}", {json.dumps(value)})'
self.page.run_js(js_code)
# 如果指定了目标标签页,也同步过去
if target_tab_id:
target_tab = self.page.get_tab(target_tab_id)
target_tab.run_js(js_code)
def get_shared_data(self, key, tab_id=None):
"""从localStorage获取共享数据"""
if tab_id:
tab = self.page.get_tab(tab_id)
js_code = f'return localStorage.getItem("{key}")'
result = tab.run_js(js_code)
else:
js_code = f'return localStorage.getItem("{key}")'
result = self.page.run_js(js_code)
if result:
return json.loads(result)
return None
def broadcast_message(self, message_type, data):
"""向所有标签页广播消息"""
message = {
'type': message_type,
'data': data,
'timestamp': time.time()
}
js_code = f"""
// 创建自定义事件
const event = new CustomEvent('tabMessage', {{
detail: {json.dumps(message)}
}});
// 触发事件
window.dispatchEvent(event);
"""
# 向所有标签页广播
for tab_id in self.page.tab_ids:
try:
tab = self.page.get_tab(tab_id)
tab.run_js(js_code)
except:
continue # 某些标签页可能已关闭
def setup_message_listener(self, tab_id, callback):
"""设置消息监听器"""
tab = self.page.get_tab(tab_id)
# 注入监听代码
listener_js = """
window.addEventListener('tabMessage', function(event) {
// 将消息发送回Python端
window.tabMessageCallback(event.detail);
});
"""
tab.run_js(listener_js)
# 设置回调函数
def handle_message(detail):
callback(detail)
# 这里需要将回调函数暴露给JavaScript
# 实际实现可能需要更复杂的桥接机制
def coordinated_action(self, action_func, tab_ids=None):
"""协调多个标签页执行同步动作"""
if tab_ids is None:
tab_ids = self.page.tab_ids
results = {}
# 第一步:准备阶段
self.broadcast_message('prepare', {'action': 'start'})
time.sleep(1) # 给各标签页准备时间
# 第二步:执行阶段
for tab_id in tab_ids:
tab = self.page.get_tab(tab_id)
try:
result = action_func(tab)
results[tab_id] = {'status': 'success', 'result': result}
except Exception as e:
results[tab_id] = {'status': 'error', 'error': str(e)}
# 第三步:清理阶段
self.broadcast_message('cleanup', {'action': 'complete'})
return results
# 使用示例
communicator = TabCommunicator()
# 打开多个标签页
communicator.page.get('https://example.com/page1')
communicator.page.new_tab('https://example.com/page2')
communicator.page.new_tab('https://example.com/page3')
# 共享数据
communicator.share_data_via_localstorage('session_token', 'abc123xyz')
communicator.share_data_via_localstorage('user_prefs', {'theme': 'dark', 'lang': 'zh'})
# 在所有标签页中获取共享数据
for tab_id in communicator.page.tab_ids:
token = communicator.get_shared_data('session_token', tab_id)
prefs = communicator.get_shared_data('user_prefs', tab_id)
print(f"标签页 {tab_id}: token={token}, prefs={prefs}")
# 定义要在各标签页执行的动作
def collect_page_info(tab):
"""收集页面信息"""
info = {
'url': tab.url,
'title': tab.title,
'load_time': tab.run_js('return performance.timing.loadEventEnd - performance.timing.navigationStart'),
'element_count': len(tab.eles('tag:*'))
}
return info
# 协调执行
results = communicator.coordinated_action(collect_page_info)
for tab_id, result in results.items():
print(f"标签页 {tab_id}: {result}")
3.4 性能优化与资源管理
同时操作多个标签页会消耗大量系统资源,合理的资源管理至关重要:
import psutil
import time
from DrissionPage import ChromiumPage, ChromiumOptions
class ResourceAwareTabManager:
def __init__(self, max_tabs=10, memory_limit_mb=2048):
self.max_tabs = max_tabs
self.memory_limit = memory_limit_mb * 1024 * 1024 # 转换为字节
self.page = None
self.active_tabs = {}
self.tab_usage = {} # 跟踪标签页资源使用
def start(self):
"""启动浏览器,配置资源限制"""
co = ChromiumOptions()
# 限制浏览器资源使用
co.set_argument('--max-old-space-size=4096') # 限制JavaScript堆内存
co.set_argument('--disable-background-timer-throttling')
co.set_argument('--disable-backgrounding-occluded-windows')
co.set_argument('--disable-renderer-backgrounding')
self.page = ChromiumPage(co)
def check_system_resources(self):
"""检查系统资源使用情况"""
memory = psutil.virtual_memory()
cpu_percent = psutil.cpu_percent(interval=1)
return {
'memory_percent': memory.percent,
'memory_available': memory.available,
'cpu_percent': cpu_percent
}
def can_create_tab(self):
"""检查是否可以创建新标签页"""
if len(self.active_tabs) >= self.max_tabs:
return False, "达到最大标签页数量限制"
resources = self.check_system_resources()
if resources['memory_percent'] > 85:
return False, "内存使用率过高"
if resources['cpu_percent'] > 90:
return False, "CPU使用率过高"
return True, "可以创建新标签页"
def create_tab_with_limits(self, url, tab_id=None):
"""创建带有资源限制的标签页"""
can_create, reason = self.can_create_tab()
if not can_create:
raise RuntimeError(f"无法创建标签页: {reason}")
if tab_id is None:
tab_id = f"tab_{len(self.active_tabs) + 1}"
# 创建新标签页
tab = self.page.new_tab(url)
# 记录创建时间和初始状态
self.active_tabs[tab_id] = {
'tab': tab,
'created_at': time.time(),
'last_activity': time.time(),
'url': url
}
# 设置标签页特定的资源限制
self._apply_tab_limits(tab)
return tab_id, tab
def _apply_tab_limits(self, tab):
"""应用标签页资源限制"""
# 限制JavaScript执行时间
tab.set.timeouts(script=30) # 脚本超时30秒
# 禁用不必要的功能以节省资源
tab.run_js("""
// 禁用图片(需要时再加载)
const images = document.getElementsByTagName('img');
for (let img of images) {
img.loading = 'lazy';
}
// 禁用自动播放视频
const videos = document.getElementsByTagName('video');
for (let video of videos) {
video.autoplay = false;
}
""")
def monitor_and_cleanup(self):
"""监控并清理不活跃的标签页"""
current_time = time.time()
tabs_to_close = []
for tab_id, info in self.active_tabs.items():
# 检查标签页是否超时未活动(30分钟)
if current_time - info['last_activity'] > 1800:
tabs_to_close.append(tab_id)
continue
# 检查标签页内存使用(通过执行JavaScript估算)
try:
memory_estimate = info['tab'].run_js("""
if (window.performance && window.performance.memory) {
return window.performance.memory.usedJSHeapSize;
}
return 0;
""")
# 如果单个标签页使用超过200MB,考虑关闭
if memory_estimate > 200 * 1024 * 1024:
print(f"标签页 {tab_id} 内存使用过高: {memory_estimate / (1024*1024):.2f}MB")
tabs_to_close.append(tab_id)
except:
# 标签页可能已经崩溃或关闭
tabs_to_close.append(tab_id)
# 关闭选中的标签页
for tab_id in tabs_to_close:
self.close_tab(tab_id)
# 如果系统资源仍然紧张,关闭更多标签页
resources = self.check_system_resources()
if resources['memory_percent'] > 90:
# 按最近活动时间排序,关闭最老的标签页
sorted_tabs = sorted(
self.active_tabs.items(),
key=lambda x: x[1]['last_activity']
)
for tab_id, _ in sorted_tabs[:2]: # 关闭2个最老的
if tab_id not in tabs_to_close:
self.close_tab(tab_id)
break
def close_tab(self, tab_id):
"""关闭标签页并清理资源"""
if tab_id in self.active_tabs:
try:
self.active_tabs[tab_id]['tab'].close()
except:
pass # 标签页可能已经关闭
del self.active_tabs[tab_id]
if tab_id in self.tab_usage:
del self.tab_usage[tab_id]
def optimize_performance(self):
"""执行性能优化"""
# 清理浏览器缓存
for tab_id, info in self.active_tabs.items():
try:
info['tab'].run_js("""
// 清理不必要的定时器
const maxId = setTimeout(() => {}, 0);
for (let i = maxId; i > 0; i--) {
clearTimeout(i);
clearInterval(i);
}
// 触发垃圾回收(如果浏览器支持)
if (window.gc) {
window.gc();
}
""")
except:
pass
# 压缩页面资源
self.page.run_js("""
// 尝试压缩DOM(实际效果有限,但可以尝试)
if (document.body) {
document.body.style.willChange = 'auto';
setTimeout(() => {
document.body.style.willChange = '';
}, 100);
}
""")
def get_usage_report(self):
"""获取使用情况报告"""
resources = self.check_system_resources()
report = {
'system': {
'memory_used_percent': resources['memory_percent'],
'cpu_used_percent': resources['cpu_percent'],
'active_tabs': len(self.active_tabs),
'max_tabs': self.max_tabs
},
'tabs': {}
}
for tab_id, info in self.active_tabs.items():
report['tabs'][tab_id] = {
'url': info['url'],
'age_seconds': time.time() - info['created_at'],
'inactive_seconds': time.time() - info['last_activity'],
'status': 'active'
}
return report
# 使用示例
manager = ResourceAwareTabManager(max_tabs=5, memory_limit_mb=1024)
manager.start()
try:
# 创建多个标签页
tab1_id, tab1 = manager.create_tab_with_limits('https://example.com/data1')
tab2_id, tab2 = manager.create_tab_with_limits('https://example.com/data2')
# 定期监控和清理
import schedule
import threading
def monitor_task():
while True:
manager.monitor_and_cleanup()
report = manager.get_usage_report()
print(f"监控报告: {report['system']}")
time.sleep(300) # 每5分钟检查一次
# 在后台线程中运行监控
monitor_thread = threading.Thread(target=monitor_task, daemon=True)
monitor_thread.start()
# 主任务
def process_data(tab, url):
tab.get(url)
# ... 处理数据
return {'processed': True}
result1 = process_data(tab1, 'https://example.com/process')
result2 = process_data(tab2, 'https://example.com/analyze')
# 定期优化性能
schedule.every(30).minutes.do(manager.optimize_performance)
# 运行一段时间
time.sleep(3600) # 运行1小时
finally:
# 清理所有标签页
for tab_id in list(manager.active_tabs.keys()):
manager.close_tab(tab_id)
4. 实战案例:构建一个健壮的网页数据采集系统
现在让我们把这些知识整合起来,构建一个完整的、健壮的网页数据采集系统。这个系统将展示如何在实际项目中使用DrissionPage的各种高级功能。
4.1 系统架构设计
我们的数据采集系统需要处理以下需求:
- 支持多种网站结构
- 处理JavaScript渲染的内容
- 管理会话和cookies
- 错误处理和重试机制
- 并发采集提高效率
- 数据验证和清洗
import json
import time
import logging
import hashlib
from datetime import datetime
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, asdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from DrissionPage import WebPage, ChromiumOptions
from DrissionPage.common import Keys
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('crawler.log', encoding='utf-8'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
@dataclass
class CrawlResult:
"""爬取结果数据类"""
url: str
title: str
content: str
extracted_data: Dict[str, Any]
status: str # success, error, retry
error_message: Optional[str] = None
timestamp: str = None
content_hash: str = None
def __post_init__(self):
if self.timestamp is None:
self.timestamp = datetime.now().isoformat()
if self.content_hash is None and self.content:
self.content_hash = hashlib.md5(self.content.encode()).hexdigest()
class WebsiteConfig:
"""网站配置类"""
def __init__(self, name: str, config: Dict):
self.name = name
self.config = config
# 解析配置
self.login_required = config.get('login_required', False)
self.login_selectors = config.get('login_selectors', {})
self.data_selectors = config.get('data_selectors', {})
self.pagination = config.get('pagination', {})
self.wait_selectors = config.get('wait_selectors', [])
self.js_to_execute = config.get('js_to_execute', [])
self.request_headers = config.get('headers', {})
self.timeout = config.get('timeout', 30)
def get_selector(self, key: str, default=None):
"""获取选择器,支持多种格式"""
selector = self.data_selectors.get(key, default)
if isinstance(selector, dict):
# 支持多种选择器类型
return selector.get('selector', default)
return selector
class RobustWebCrawler:
"""健壮的网页爬虫"""
def __init__(self, config_path: str = 'website_configs.json'):
self.configs = self._load_configs(config_path)
self.sessions = {} # 存储不同网站的会话
self.results_cache = {} # 缓存结果
def _load_configs(self, config_path: str) -> Dict[str, WebsiteConfig]:
"""加载网站配置"""
try:
with open(config_path, 'r', encoding='utf-8') as f:
config_data = json.load(f)
configs = {}
for name, config in config_data.items():
configs[name] = WebsiteConfig(name, config)
logger.info(f"已加载 {len(configs)} 个网站配置")
return configs
except FileNotFoundError:
logger.warning(f"配置文件 {config_path} 不存在,使用空配置")
return {}
except json.JSONDecodeError as e:
logger.error(f"配置文件格式错误: {e}")
return {}
def _get_session(self, website_name: str) -> WebPage:
"""获取或创建网站会话"""
if website_name not in self.sessions:
config = self.configs.get(website_name)
# 创建浏览器选项
co = ChromiumOptions()
# 根据配置设置选项
if config:
if config.request_headers:
co.set_headers(config.request_headers)
# 设置超时
co.set_timeouts(
base=config.timeout,
page_load=config.timeout * 2,
script=config.timeout
)
# 创建WebPage会话(支持模式切换)
session = WebPage(addr_or_opts=co)
self.sessions[website_name] = session
# 如果需要登录,执行登录
if config and config.login_required:
self._login(session, config)
logger.info(f"为网站 {website_name} 创建新会话")
return self.sessions[website_name]
def _login(self, session: WebPage, config: WebsiteConfig):
"""执行登录流程"""
try:
logger.info(f"正在登录网站: {config.name}")
# 访问登录页面
login_url = config.config.get('login_url')
if not login_url:
logger.error(f"网站 {config.name} 需要登录但未配置登录URL")
return False
session.get(login_url)
# 等待登录表单加载
for selector in config.wait_selectors:
session.ele(selector, timeout=10)
# 填写登录表单
login_selectors = config.login_selectors
if 'username' in login_selectors:
session.ele(login_selectors['username']).input(
config.config.get('credentials', {}).get('username', '')
)
if 'password' in login_selectors:
session.ele(login_selectors['password']).input(
config.config.get('credentials', {}).get('password', '')
)
# 点击登录按钮
if 'submit' in login_selectors:
session.ele(login_selectors['submit']).click()
# 等待登录完成
time.sleep(3)
# 检查登录是否成功
success_selector = config.config.get('login_success_selector')
if success_selector:
try:
session.ele(success_selector, timeout=10)
logger.info(f"网站 {config.name} 登录成功")
return True
except:
logger.error(f"网站 {config.name} 登录失败")
return False
return True
except Exception as e:
logger.error(f"登录网站 {config.name} 时出错: {e}")
return False
def _extract_data(self, session: WebPage, config: WebsiteConfig) -> Dict[str, Any]:
"""提取页面数据"""
data = {}
for key, selector_config in config.data_selectors.items():
try:
if isinstance(selector_config, dict):
# 复杂选择器配置
selector = selector_config.get('selector')
selector_type = selector_config.get('type', 'text')
multiple = selector_config.get('multiple', False)
attribute = selector_config.get('attribute')
default = selector_config.get('default')
if not selector:
data[key] = default
continue
if multiple:
elements = session.eles(selector)
if selector_type == 'text':
values = [ele.text for ele in elements]
elif selector_type == 'attribute' and attribute:
values = [ele.attr(attribute) for ele in elements]
elif selector_type == 'html':
values = [ele.html for ele in elements]
else:
values = [ele.text for ele in elements]
data[key] = values
else:
element = session.ele(selector)
if selector_type == 'text':
value = element.text
elif selector_type == 'attribute' and attribute:
value = element.attr(attribute)
elif selector_type == 'html':
value = element.html
else:
value = element.text
data[key] = value
else:
# 简单选择器配置
element = session.ele(selector_config)
data[key] = element.text
except Exception as e:
logger.warning(f"提取数据字段 {key} 时出错: {e}")
data[key] = None
return data
def _handle_pagination(self, session: WebPage, config: WebsiteConfig) -> List[Dict[str, Any]]:
"""处理分页"""
all_data = []
pagination = config.pagination
if not pagination:
# 没有分页,直接提取当前页数据
data = self._extract_data(session, config)
if data:
all_data.append(data)
return all_data
max_pages = pagination.get('max_pages', 10)
next_selector = pagination.get('next_selector')
page_param = pagination.get('page_param')
base_url = pagination.get('base_url', session.url)
current_page = 1
while current_page <= max_pages:
try:
logger.info(f"正在处理第 {current_page} 页")
# 提取当前页数据
page_data = self._extract_data(session, config)
if page_data:
all_data.append(page_data)
# 检查是否有下一页
if next_selector:
# 通过"下一页"按钮翻页
try:
next_button = session.ele(next_selector, timeout=5)
if next_button and not next_button.attr('disabled'):
next_button.click()
# 等待页面加载
session.wait.doc_loaded()
current_page += 1
continue
else:
break # 没有下一页或按钮被禁用
except:
break # 找不到下一页按钮
elif page_param and base_url:
# 通过URL参数翻页
current_page += 1
next_url = f"{base_url}?{page_param}={current_page}"
session.get(next_url)
session.wait.doc_loaded()
else:
break # 没有配置翻页方式
except Exception as e:
logger.error(f"处理第 {current_page} 页时出错: {e}")
break
return all_data
def crawl(self, website_name: str, url: str, use_cache: bool = True) -> CrawlResult:
"""爬取单个页面"""
# 检查缓存
cache_key = f"{website_name}:{url}"
if use_cache and cache_key in self.results_cache:
cached_result = self.results_cache[cache_key]
# 检查缓存是否过期(1小时)
cache_time = datetime.fromisoformat(cached_result.timestamp)
if (datetime.now() - cache_time).total_seconds() < 3600:
logger.info(f"使用缓存结果: {url}")
return cached_result
config = self.configs.get(website_name)
session = self._get_session(website_name)
result = CrawlResult(
url=url,
title="",
content="",
extracted_data={},
status="pending"
)
try:
# 访问页面
logger.info(f"开始爬取: {url}")
session.get(url)
# 等待必要元素加载
if config and config.wait_selectors:
for selector in config.wait_selectors:
session.ele(selector, timeout=config.timeout)
# 执行JavaScript(如果需要)
if config and config.js_to_execute:
for js in config.js_to_execute:
session.run_js(js)
time.sleep(1) # 给JS执行时间
# 获取页面信息
result.title = session.title
result.content = session.html
# 提取数据
if config:
if config.pagination:
# 处理分页
all_page_data = self._handle_pagination(session, config)
result.extracted_data = {
'pages': len(all_page_data),
'data': all_page_data
}
else:
# 单页数据提取
result.extracted_data = self._extract_data(session, config)
result.status = "success"
logger.info(f"成功爬取: {url}")
except Exception as e:
result.status = "error"
result.error_message = str(e)
logger.error(f"爬取 {url} 时出错: {e}")
finally:
result.timestamp = datetime.now().isoformat()
if result.content:
result.content_hash = hashlib.md5(result.content.encode()).hexdigest()
# 缓存结果
if result.status == "success":
self.results_cache[cache_key] = result
return result
def crawl_multiple(self, tasks: List[Dict], max_workers: int = 3) -> List[CrawlResult]:
"""并发爬取多个页面"""
results = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# 提交所有任务
future_to_task = {}
for task in tasks:
website_name = task.get('website')
url = task.get('url')
use_cache = task.get('use_cache', True)
if not website_name or not url:
logger.warning(f"跳过无效任务: {task}")
continue
future = executor.submit(self.crawl, website_name, url, use_cache)
future_to_task[future] = task
# 收集结果
for future in as_completed(future_to_task):
task = future_to_task[future]
try:
result = future.result(timeout=300) # 5分钟超时
results.append(result)
except Exception as e:
logger.error(f"任务执行失败: {task}, 错误: {e}")
results.append(CrawlResult(
url=task.get('url', ''),
title="",
content="",
extracted_data={},
status="error",
error_message=str(e)
))
return results
def export_results(self, results: List[CrawlResult], format: str = 'json'):
"""导出结果"""
if format == 'json':
data = [asdict(r) for r in results]
filename = f"crawl_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
with open(filename, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"结果已导出到: {filename}")
return filename
elif format == 'csv':
import csv
filename = f"crawl_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
# 收集所有可能的字段
all_fields = set()
for result in results:
all_fields.update(result.extracted_data.keys())
fieldnames = ['url', 'title', 'status', 'timestamp'] + list(all_fields)
with open(filename, 'w', encoding='utf-8', newline='') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for result in results:
row = {
'url': result.url,
'title': result.title,
'status': result.status,
'timestamp': result.timestamp
}
row.update(result.extracted_data)
writer.writerow(row)
logger.info(f"结果已导出到: {filename}")
return filename
else:
logger.error(f"不支持的导出格式: {format}")
return None
def cleanup(self):
"""清理资源"""
for website_name, session in self.sessions.items():
try:
session.quit()
logger.info(f"已关闭网站 {website_name} 的会话")
except:
pass
self.sessions.clear()
self.results_cache.clear()
# 配置文件示例
SAMPLE_CONFIG = {
"news_site": {
"login_required": False,
"data_selectors": {
"title": {
"selector": "tag:h1",
"type": "text"
},
"articles": {
"selector": ".article-list .article-item",
"type": "text",
"multiple": True
},
"publish_date": {
"selector": ".publish-time",
"type": "attribute",
"attribute": "datetime"
},
"author": ".author-name"
},
"pagination": {
"next_selector": ".next-page",
"max_pages": 5
},
"wait_selectors": [".article-list"],
"timeout": 30
},
"ecommerce_site": {
"login_required": True,
"login_url": "https://example.com/login",
"login_selectors": {
"username": "#username",
"password": "#password",
"submit": "tag:button@@type=submit"
},
"credentials": {
"username": "your_username",
"password": "your_password"
},
"data_selectors": {
"product_name": ".product-title",
"price": ".price",
"description": ".product-description",
"images": {
"selector": ".product-image",
"type": "attribute",
"attribute": "src",
"multiple": True
}
},
"wait_selectors": [".product-details"],
"js_to_execute": [
"window.scrollTo(0, document.body.scrollHeight);",
"setTimeout(() => {}, 1000);"
]
}
}
# 使用示例
def main():
# 1. 保存示例配置
with open('website_configs.json', 'w', encoding='utf-8') as f:
json.dump(SAMPLE_CONFIG, f, indent=2)
# 2. 创建爬虫实例
crawler = RobustWebCrawler('website_configs.json')
try:
# 3. 定义爬取任务
tasks = [
{
'website': 'news_site',
'url': 'https://example-news.com/latest',
'use_cache': True
},
{
'website': 'ecommerce_site',
'url': 'https://example-store.com/product/123',
'use_cache': False
}
]
# 4. 执行爬取
logger.info("开始执行爬取任务...")
results = crawler.crawl_multiple(tasks, max_workers=2)
# 5. 分析结果
success_count = sum(1 for r in results if r.status == 'success')
error_count = sum(1 for r in results if r.status == 'error')
logger.info(f"爬取完成: 成功 {success_count} 个, 失败 {error_count} 个")
# 6. 导出结果
if success_count > 0:
export_file = crawler.export_results(results, format='json')
logger.info(f"结果已导出到: {export_file}")
# 打印成功的结果摘要
for result in results:
if result.status == 'success':
print(f"\nURL: {result.url}")
print(f"标题: {result.title}")
print(f"提取数据量: {len(result.extracted_data)} 个字段")
if 'articles' in result.extracted_data:
print(f"文章数量: {len(result.extracted_data['articles'])}")
# 7. 性能监控
print("\n=== 性能统计 ===")
for website_name, session in crawler.sessions.items():
# 可以添加更多的性能统计信息
print(f"网站 {website_name}: 会话已建立")
finally:
# 8. 清理资源
crawler.cleanup()
logger.info("爬虫已关闭")
if __name__ == "__main__":
main()
4.2 错误处理与重试机制
在实际的爬虫项目中,网络不稳定、网站反爬、页面结构变化等问题是家常便饭。一个健壮的爬虫必须有完善的错误处理和重试机制。
import random
import time
from functools import wraps
from datetime import datetime, timedelta
from DrissionPage.errors import ElementNotFoundError, BrowserConnectError
class RetryStrategy:
"""重试策略类"""
def __init__(self, max_retries=3, base_delay=1, max_delay=30,
retry_exceptions=None, exponential_backoff=True):
self.max_retries = max_retries
self.base_delay = base_delay
self.max_delay = max_delay
self.exponential_backoff = exponential_backoff
if retry_exceptions is None:
self.retry_exceptions = (
ElementNotFoundError,
BrowserConnectError,
TimeoutError,
ConnectionError,
Exception # 谨慎使用,可能会掩盖真正的问题
)
else:
self.retry_exceptions = retry_exceptions
self.retry_stats = {
'total_attempts': 0,
'successful_attempts': 0,
'failed_attempts': 0,
'retry_count': 0
}
def calculate_delay(self, attempt):
"""计算重试延迟"""
if self.exponential_backoff:
delay = min(self.base_delay * (2 ** attempt), self.max_delay)
else:
delay = self.base_delay
# 添加随机抖动,避免所有重试同时发生
jitter = random.uniform(0, delay * 0.1)
return delay + jitter
def should_retry(self, exception, attempt):
"""判断是否应该重试"""
if attempt >= self.max_retries:
return False
if isinstance(exception, self.retry_exceptions):
return True
return False
def record_attempt(self, success):
"""记录尝试结果"""
self.retry_stats['total_attempts'] += 1
if success:
self.retry_stats['successful_attempts'] += 1
else:
self.retry_stats['failed_attempts'] += 1
def get_stats(self):
"""获取统计信息"""
return self.retry_stats.copy()
def retry_on_failure(strategy=None):
"""重试装饰器"""
if strategy is None:
strategy = RetryStrategy()
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
attempt = 0
while attempt <= strategy.max_retries:
try:
if attempt > 0:
strategy.retry_count += 1
delay = strategy.calculate_delay(attempt - 1)
print(f"第 {attempt} 次重试,等待 {delay:.2f} 秒...")
time.sleep(delay)
result = func(*args, **kwargs)
strategy.record_attempt(True)
return result
except Exception as e:
last_exception = e
attempt += 1
if strategy.should_retry(e, attempt):
print(f"尝试 {attempt}/{strategy.max_retries} 失败: {e}")
continue
else:
strategy.record_attempt(False)
raise
# 所有重试都失败了
strategy.record_attempt(False)
raise last_exception
return wrapper
return decorator
class SmartCrawlerWithRetry:
"""带智能重试的爬虫"""
def __init__(self):
self.retry_strategy = RetryStrategy(
max_retries=3,
base_delay=2,
max_delay=60,
exponential_backoff=True
)
self.circuit_breaker = CircuitBreaker(
failure_threshold=5,
recovery_timeout=300 # 5分钟
)
@retry_on_failure()
def fetch_page_with_retry(self, url, session=None):
"""带重试的页面获取"""
if session is None:
from DrissionPage import WebPage
session = WebPage()
try:
# 使用断路器保护
if not self.circuit_breaker.allow_request(url):
raise CircuitBreakerOpenError(f"断路器已打开: {url}")
success = session.get(url, retry=0, timeout=30)
if not success:
raise ConnectionError(f"无法访问页面: {url}")
# 验证页面是否正常加载
self._validate_page(session)
return session
except Exception as e:
self.circuit_breaker.record_failure(url)
raise
def _validate_page(self, session):
"""验证页面是否正常加载"""
# 检查是否有错误页面
error_indicators = [
'error', '404', 'not found', '无法访问',
'access denied', 'forbidden', '服务器错误'
]
page_text = session.text.lower()
for indicator in error_indicators:
if indicator in page_text:
raise PageValidationError(f"页面包含错误指示: {indicator}")
# 检查是否有反爬机制
if 'please enable javascript' in page_text.lower():
raise AntiScrapingError("页面需要JavaScript")
# 检查页面结构是否完整
if len(session.html) < 100:
raise PageValidationError("页面内容过短")
def smart_crawl(self, url, extractors, max_depth=2):
"""智能爬取,支持深度爬取和自适应策略"""
results = []
visited = set()
def crawl_recursive(current_url, depth):
if depth > max_depth or current_url in visited:
return
visited.add(current_url)
try:
# 获取页面
session = self.fetch_page_with_retry(current_url)
# 提取数据
page_data = self._extract_with_adaptation(session, extractors)
page_data['url'] = current_url
page_data['depth'] = depth
results.append(page_data)
# 提取链接继续爬取
if depth < max_depth:
links = self._extract_links(session)
for link in links[:5]: # 限制每页只爬取5个链接
if link not in visited:
# 随机延迟,避免请求过于频繁
time.sleep(random.uniform(1, 3))
crawl_recursive(link, depth + 1)
except Exception as e:
print(f"爬取 {current_url} 失败: {e}")
# 根据错误类型调整策略
self._adapt_strategy(e)
crawl_recursive(url, 0)
return results
def _extract_with_adaptation(self, session, extractors):
"""自适应数据提取"""
data = {}
for key, extractor in extractors.items():
try:
# 尝试主要选择器
value = self._extract_with_selector(session, extractor['primary'])
data[key] = value
except ElementNotFoundError:
# 如果主要选择器失败,尝试备用选择器
if 'fallback' in extractor:
try:
value = self._extract_with_selector(session, extractor['fallback'])
data[key] = value
print(f"使用备用选择器提取 {key}")
except:
data[key] = None
else:
data[key] = None
# 记录选择器失败,用于后续优化
self._record_selector_failure(key, extractor['primary'])
return data
def _extract_with_selector(self, session, selector_config):
"""使用选择器提取数据"""
if isinstance(selector_config, str):
element = session.ele(selector_config, timeout=10)
return element.text
elif isinstance(selector_config, dict):
selector = selector_config.get('selector')
selector_type = selector_config.get('type', 'text')
multiple = selector_config.get('multiple', False)
if multiple:
elements = session.eles(selector, timeout=10)
if selector_type == 'text':
return [ele.text for ele in elements]
elif selector_type == 'attribute':
attr = selector_config.get('attribute', 'href')
return [ele.attr(attr) for ele in elements]
else:
return [ele.text for ele in elements]
else:
element = session.ele(selector, timeout=10)
if selector_type == 'text':
return element.text
elif selector_type == 'attribute':
attr = selector_config.get('attribute', 'href')
return element.attr(attr)
elif selector_type == 'html':
return element.html
else:
return element.text
raise ValueError(f"无效的选择器配置: {selector_config}")
def _extract_links(self, session):
"""提取页面中的链接"""
links = []
try:
link_elements = session.eles('tag:a')
for link in link_elements:
href = link.attr('href')
if href and href.startswith('http'):
links.append(href)
except:
pass
return links
def _adapt_strategy(self, error):
"""根据错误类型调整策略"""
if isinstance(error, (ElementNotFoundError, PageValidationError)):
# 页面结构可能已变化,增加等待时间
self.retry_strategy.base_delay = min(
self.retry_strategy.base_delay * 1.5,
self.retry_strategy.max_delay
)
print(f"调整基础延迟为: {self.retry_strategy.base_delay}秒")
elif isinstance(error, (BrowserConnectError, ConnectionError)):
# 网络问题,减少并发数或增加重试次数
self.retry_strategy.max_retries += 1
print(f"增加最大重试次数为: {self.retry_strategy.max_retries}")
def _record_selector_failure(self, key, selector):
"""记录选择器失败,用于机器学习优化"""
# 这里可以记录到数据库或文件,用于后续分析
with open('selector_failures.log', 'a', encoding='utf-8') as f:
timestamp = datetime.now().isoformat()
f.write(f"{timestamp},{key},{selector}\n")
class CircuitBreaker:
"""断路器模式,防止连续失败"""
def __init__(self, failure_threshold=5, recovery_timeout=60):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failures = {}
self.state = {} # 'closed', 'open', 'half-open'
self.last_failure_time = {}
def allow_request(self, service):
"""是否允许请求"""
if service not in self.state:
self.state[service] = 'closed'
self.failures[service] = 0
return True
if self.state[service] == 'open':
# 检查是否应该尝试恢复
if service in self.last_failure_time:
time_since_failure = time.time() - self.last_failure_time[service]
if time_since_failure > self.recovery_timeout:
self.state[service] = 'half-open'
return True
return False
return True
def record_failure(self, service):
"""记录失败"""
if service not in self.failures:
self.failures[service] = 0
self.failures[service] += 1
self.last_failure_time[service] = time.time()
if self.failures[service] >= self.failure_threshold:
self.state[service] = 'open'
print(f"断路器打开: {service}")
def record_success(self, service):
"""记录成功"""
self.failures[service] = 0
self.state[service] = 'closed'
# 自定义异常类
class PageValidationError(Exception):
pass
class AntiScrapingError(Exception):
pass
class CircuitBreakerOpenError(Exception):
pass
# 使用示例
def example_usage():
crawler = SmartCrawlerWithRetry()
# 定义数据提取器
extractors = {
'title': {
'primary': 'tag:h1',
'fallback': 'tag:h2'
},
'content': {
'primary': '.article-content',
'fallback': '.content'
},
'author': {
'primary': '.author-name',
'fallback': '.byline'
},
'publish_date': {
'primary': '.publish-time@@datetime',
'fallback': '.date'
},
'tags': {
'primary': {
'selector': '.tag-list .tag',
'type': 'text',
'multiple': True
},
'fallback': {
'selector': '.tags a',
'type': 'text',
'multiple': True
}
}
}
try:
# 爬取单个页面
session = crawler.fetch_page_with_retry('https://example.com/article/123')
data = crawler._extract_with_adaptation(session, extractors)
print(f"提取的数据: {data}")
# 深度爬取
results = crawler.smart_crawl(
'https://example.com/blog',
extractors,
max_depth=2
)
print(f"共爬取 {len(results)} 个页面")
# 查看重试统计
stats = crawler.retry_strategy.get_stats()
print(f"重试统计: {stats}")
except Exception as e:
print(f"爬取失败: {e}")
finally:
# 保存选择器失败记录
print("选择器失败记录已保存到 selector_failures.log")
# 在实际项目中,你可能会这样使用
class ProductionCrawler:
def __init__(self):
self.smart_crawler = SmartCrawlerWithRetry()
self.results_db = [] # 这里可以用数据库替换
def run_daily_crawl(self, start_urls):
"""每日爬取任务"""
all_results = []
for url in start_urls:
try:
print(f"开始爬取: {url}")
# 使用智能爬取,深度为3
results = self.smart_crawler.smart_crawl(
url=url,
extractors=self.get_extractors_for_url(url),
max_depth=3
)
all_results.extend(results)
# 保存到数据库
self.save_to_database(results)
# 生成报告
self.generate_report(results)
# 避免请求过于频繁
time.sleep(random.uniform(5, 10))
except CircuitBreakerOpenError:
print(f"断路器已打开,跳过: {url}")
continue
except Exception as e:
print(f"爬取 {url} 时发生严重错误: {e}")
# 发送警报
self.send_alert(f"爬取失败: {url}", str(e))
continue
return all_results
def get_extractors_for_url(self, url):
"""根据URL返回相应的提取器配置"""
# 这里可以根据URL模式返回不同的提取器
if 'news' in url:
return {
'title': {'primary': '.news-title'},
'content': {'primary': '.article-body'},
# ... 其他字段
}
elif 'blog' in url:
return {
395



被折叠的 条评论
为什么被折叠?



