1. 项目概述与核心挑战
最近在做一个电商数据分析的项目,需要从eBay上抓取一批商品数据来做价格监控和趋势分析。一开始想着用Requests+BeautifulSoup这种经典组合应该能轻松搞定,结果刚跑起来没几分钟,IP就被限制了,页面返回的要么是验证码,要么就是各种奇怪的“人机验证”提示。这让我意识到,面对eBay这样的大型电商平台,传统的同步、简单请求的爬虫思路已经行不通了。它不仅有完善的动态加载机制(大量数据通过JavaScript异步请求获取),还有层层加码的反爬策略,包括但不限于请求频率检测、请求头校验、甚至对浏览器指纹的识别。
经过一番调研和尝试,我最终将技术栈锁定在了 Playwright 和 Asyncio 上。这个组合听起来可能有点“新”,但实测下来,它确实是应对现代复杂反爬场景的一把利器。简单来说,Playwright是一个由微软开源的浏览器自动化库,它不像Selenium那样依赖WebDriver,而是直接通过CDP协议与Chromium、Firefox、WebKit等浏览器内核通信,速度更快,功能也更强大。最关键的是,它能完美模拟真实用户的浏览器环境,生成难以被简单识别的“人类”指纹。而Asyncio是Python的异步IO框架,它能让我们用同步代码的写法,去执行高并发的异步操作。想象一下,你开一个浏览器窗口(一个Playwright实例)去访问页面,等待页面加载是“阻塞”的;但如果你用Asyncio同时控制几十个浏览器页面(Page)去并发抓取,效率的提升是指数级的。
这个项目的目标很明确:构建一个稳定、高效、能绕过eBay常见反爬机制的自动化数据抓取工具。它不仅要能拿到数据,还要拿得“优雅”,不給目标服务器造成过大压力,符合基本的网络礼仪(比如尊重
robots.txt
)。接下来,我会详细拆解整个实现过程,从环境搭建、核心逻辑到每一个避坑细节。
2. 技术选型与环境搭建
2.1 为什么是Playwright + Asyncio?
在动手之前,我们先深入聊聊为什么是这两个技术,而不是别的。市面上常见的爬虫方案大概有几类:
- Requests/httpx + 解析库(如BeautifulSoup, lxml) :这是最经典、最轻量的方案。优点是速度快、资源消耗小。但缺点在面对像eBay这样的网站时暴露无遗:无法执行JavaScript,拿不到动态渲染的内容;请求头和行为模式过于简单,极易被反爬系统识别为机器人。
- Selenium :老牌的浏览器自动化工具,可以完美解决JavaScript渲染问题。但它也有硬伤:速度慢(每个浏览器实例资源消耗大)、需要额外的WebDriver、在异步和高并发控制上比较笨重。
-
Pyppeteer / Playwright
:这两个都属于“无头浏览器”的现代自动化库。Pyppeteer是Puppeteer的Python版本,而Playwright可以看作是它的加强版和多浏览器支持版。我选择Playwright的主要原因有三点:
- 多浏览器支持 :一套API支持Chromium、Firefox和WebKit,测试和兼容性更有保障。
- 更强大的API :在元素选择、等待、网络拦截等方面提供了更直观易用的方法。
- 更好的异步支持 :Playwright的Python API从一开始就为异步(Async)设计,与Asyncio的结合是天衣无缝的。
而 Asyncio 的引入,是为了解决I/O密集型任务(如网络请求、文件读写)的并发瓶颈。爬虫99%的时间都在等待网络响应,使用同步代码意味着你发起一个请求后,整个程序就卡在那里等,直到收到回复才能处理下一个。Asyncio允许你在等待一个请求时,去处理另一个已经返回的请求,用单线程就能实现极高的并发效率,这对于需要抓取大量列表页和详情页的电商爬虫来说,是性能飞跃的关键。
2.2 基础环境配置
工欲善其事,必先利其器。首先确保你有一个可用的Python环境(3.7+)。我强烈建议使用虚拟环境来管理项目依赖,避免包冲突。
# 创建项目目录并进入
mkdir ebay-crawler && cd ebay-crawler
# 创建虚拟环境(这里使用venv,你也可以用conda)
python -m venv venv
# 激活虚拟环境
# Windows: venv\Scripts\activate
# macOS/Linux: source venv/bin/activate
接下来安装核心依赖。除了
playwright
,我们还会用到
asyncio
(Python内置)、
aiofiles
用于异步文件操作、
pandas
用于数据处理(非必须,但很方便)。
# 安装Playwright库
pip install playwright
# 安装Playwright所需的浏览器内核(这里选择Chromium,最常用)
playwright install chromium
# 安装其他辅助库
pip install aiofiles pandas
注意 :
playwright install命令会下载浏览器二进制文件,体积较大(约100-200MB),请确保网络通畅。如果下载慢,可以考虑配置环境变量PLAYWRIGHT_DOWNLOAD_HOST使用国内镜像源。
安装完成后,可以写一个简单的脚本来测试Playwright是否能正常工作:
import asyncio
from playwright.async_api import async_playwright
async def test_playwright():
async with async_playwright() as p:
# 启动浏览器,headless=True表示无头模式(不显示GUI)
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto('https://www.example.com')
print(await page.title())
await browser.close()
asyncio.run(test_playwright())
如果成功打印出“Example Domain”,那么恭喜你,环境搭建成功。
3. 核心爬虫架构设计
一个健壮的爬虫不应该是一堆线性脚本的堆砌,而应该有一个清晰的结构。我们的爬虫主要分为以下几个模块:
- 配置管理模块 :集中管理User-Agent、代理IP、请求延迟、重试次数等所有可配置项。
- 浏览器上下文管理模块 :负责创建和管理Playwright的BrowserContext。Context可以隔离cookie、缓存等,模拟不同的“会话”,对于需要多账号或隔离环境的场景很有用。
- 页面抓取与解析模块 :核心业务逻辑。负责导航到目标URL,执行必要的页面操作(如滚动、点击),等待元素加载,并提取结构化数据。
-
异步任务调度模块
:利用Asyncio的
asyncio.create_task和asyncio.gather等机制,高效地并发执行多个抓取任务。 - 数据存储模块 :将抓取到的数据异步保存到文件(如JSON、CSV)或数据库中。
- 反爬对抗与日志模块 :集成随机延迟、请求头轮换、代理IP池等策略,并记录详细的运行日志,便于监控和调试。
整个程序的流程可以概括为: 主函数创建一批异步抓取任务 -> 任务池并发执行 -> 每个任务独立管理自己的浏览器页面进行抓取和解析 -> 结果汇总并保存 。
4. 实战:eBay商品列表页抓取
eBay的商品搜索列表页(例如搜索“iphone 13”)是我们数据的主要入口。这个页面包含了商品标题、价格、链接等基础信息,也是我们获取详情页URL的来源。
4.1 分析页面结构与反爬策略
在写代码之前,一定要先用浏览器开发者工具(F12)手动分析目标页面。
- 网络请求 :查看“Network”标签,特别是XHR/Fetch请求,看看商品数据是直接嵌入在HTML中,还是通过额外的API接口获取。eBay的商品列表数据通常是首屏直接渲染在HTML里,但翻页或滚动加载更多时,会触发API请求。我们的策略是优先从初始HTML中解析,如果不行再考虑模拟API。
- 元素定位 :使用“检查”工具,查看商品卡片的HTML结构。找到包含标题、价格、链接的稳定CSS选择器。注意,eBay的类名可能含有随机字符串,要寻找那些语义化或结构稳定的父元素作为锚点。
-
反爬观察
:尝试快速刷新页面,或使用简单的Python脚本请求页面,观察是否会触发验证码。注意查看
robots.txt(https://www.ebay.com/robots.txt),尊重其中关于爬取频率和禁止目录的规定。
4.2 实现列表页抓取函数
下面是一个抓取eBay搜索列表页的核心函数。我们假设数据直接从初始HTML中加载。
import asyncio
import random
import logging
from typing import List, Dict
from urllib.parse import quote_plus
from playwright.async_api import Page, BrowserContext
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class EbayListCrawler:
def __init__(self, context: BrowserContext):
self.context = context
# 基础URL,关键词和页数通过参数传入
self.base_url = "https://www.ebay.com/sch/i.html"
# 一些常用的请求头,可以放到配置文件中
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
}
async def fetch_list_page(self, keyword: str, page_num: int = 1, max_retries: int = 3) -> List[Dict]:
"""
抓取指定关键词和页数的商品列表
"""
items = []
page: Page = None
try:
# 1. 创建新页面
page = await self.context.new_page()
# 2. 设置请求头(Playwright支持在上下文中设置,也可以在goto时设置)
await page.set_extra_http_headers(self.headers)
# 3. 构造URL
# eBay的搜索URL参数:_nkw是关键词,_pgn是页码
encoded_keyword = quote_plus(keyword)
url = f"{self.base_url}?_nkw={encoded_keyword}&_pgn={page_num}"
logger.info(f"正在抓取列表页: {url}")
# 4. 导航到页面,并等待主要内容加载完成
# `wait_until='networkidle'` 会等待页面网络活动变得很少时再继续,确保动态内容加载完毕
response = await page.goto(url, wait_until='networkidle', timeout=60000)
if not response or response.status != 200:
logger.warning(f"页面加载失败,状态码: {getattr(response, 'status', 'Unknown')}")
return items
# 5. 加入随机延迟,模拟人类浏览,避免请求过于规律
await asyncio.sleep(random.uniform(1.0, 3.0))
# 6. 模拟滚动,触发可能的懒加载(如果需要)
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await asyncio.sleep(random.uniform(0.5, 1.5)) # 等待滚动后内容加载
# 7. 定位并提取商品元素
# 使用更稳定的容器选择器,避免依赖易变的类名
# 这里的选择器需要根据eBay实际页面结构调整
item_selector = "li.s-item" # 这是一个示例,eBay的列表项通常有`s-item`类
await page.wait_for_selector(item_selector, state='attached', timeout=10000)
item_elements = await page.query_selector_all(item_selector)
logger.info(f"找到 {len(item_elements)} 个商品元素")
for item_element in item_elements:
try:
# 提取商品信息
title_elem = await item_element.query_selector(".s-item__title")
price_elem = await item_element.query_selector(".s-item__price")
link_elem = await item_element.query_selector("a.s-item__link")
title = await title_elem.inner_text() if title_elem else "N/A"
# 价格可能包含货币符号和范围,需要清洗
price_raw = await price_elem.inner_text() if price_elem else "N/A"
price = self._clean_price(price_raw)
link = await link_elem.get_attribute("href") if link_elem else "N/A"
item_id = link.split('/')[-1].split('?')[0] if link != "N/A" else "N/A" # 从链接提取商品ID
if title != "N/A" and link != "N/A": # 过滤无效条目
items.append({
'item_id': item_id,
'title': title.strip(),
'price': price,
'url': link,
'keyword': keyword,
'page_num': page_num
})
except Exception as e:
logger.debug(f"解析单个商品时出错: {e}")
continue # 跳过这个商品,继续下一个
except Exception as e:
logger.error(f"抓取列表页 {keyword} 第 {page_num} 页时发生错误: {e}")
# 这里可以加入重试逻辑
finally:
if page:
await page.close() # 非常重要!及时关闭页面释放资源
return items
@staticmethod
def _clean_price(price_str: str) -> str:
"""清洗价格字符串,移除货币符号和多余空格,只保留数字和点"""
import re
# 匹配数字、点和逗号(用于千分位)
match = re.search(r'[\d,]+\.?\d*', price_str.replace(',', ''))
return match.group(0) if match else "N/A"
关键点解析与避坑指南:
-
wait_until='networkidle':这个参数非常关键。'load'只等待HTML加载完成,而'networkidle'会等待至少500ms内没有超过2个网络连接,这对于等待JavaScript发起的API请求(如图片、商品数据)完成至关重要。 -
随机延迟 (
asyncio.sleep(random.uniform(...))) :这是最基本的“礼貌”爬虫行为。固定的延迟间隔是机器行为的明显特征。随机化能有效降低被识别风险。 -
页面关闭 (
finally块中的await page.close()) :Playwright的每个Page对象都会消耗内存。在异步并发环境中,如果不及时关闭已完成任务的页面,会导致内存泄漏,最终程序崩溃。 这是一个极易忽视但至关重要的细节。 -
选择器的稳定性
:避免使用包含长随机字符串的类名(如
class_=”s-item__title__123abc”)。优先使用部分匹配(css=a[class*=”s-item__link”])或基于DOM结构的稳定路径(如li.s-item > a)。最好定期检查选择器是否依然有效。 -
错误处理与重试
:网络请求和页面解析充满不确定性。必须用
try...except包裹核心逻辑,并对可恢复的错误(如超时、元素未找到)进行重试。上面的代码框架中留下了重试逻辑的插入点。
5. 深入详情页与异步并发调度
获取到商品链接列表后,下一步就是并发地抓取每个商品的详情页,获取更丰富的信息,如商品描述、卖家信息、物流政策、用户评价等。
5.1 详情页抓取实现
详情页的结构更复杂,信息更分散。我们需要更有耐心地定位和提取。
class EbayDetailCrawler:
def __init__(self, context: BrowserContext):
self.context = context
async def fetch_item_detail(self, item_url: str, item_id: str) -> Dict:
"""
抓取单个商品详情页信息
"""
detail_info = {'item_id': item_id, 'url': item_url}
page: Page = None
try:
page = await self.context.new_page()
# 可以设置更详细的请求头,模拟从列表页跳转过来的行为
await page.set_extra_http_headers({
'Referer': 'https://www.ebay.com/',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
})
logger.info(f"正在抓取详情页: {item_id}")
response = await page.goto(item_url, wait_until='networkidle', timeout=45000)
if response.status != 200:
logger.warning(f"详情页 {item_id} 加载失败,状态码: {response.status}")
return detail_info
await asyncio.sleep(random.uniform(2.0, 4.0)) # 详情页浏览时间更长
# --- 提取各种信息,这里需要根据实际页面调整选择器 ---
# 1. 商品描述 (可能在div#desc_div中,或者需要点击“描述”标签)
desc_selector = "div.d-item-desc" # 示例选择器
try:
# 有些描述是折叠的,需要点击展开
show_desc_button = await page.query_selector("button[aria-label*='description']")
if show_desc_button:
await show_desc_button.click()
await page.wait_for_selector(desc_selector, state='visible', timeout=5000)
description_elem = await page.wait_for_selector(desc_selector, timeout=10000)
detail_info['description'] = await description_elem.inner_text() if description_elem else "N/A"
except Exception as e:
logger.debug(f"获取描述失败 {item_id}: {e}")
detail_info['description'] = "N/A"
# 2. 卖家信息
seller_selector = "div.seller-persona a" # 示例
seller_elem = await page.query_selector(seller_selector)
detail_info['seller_name'] = await seller_elem.inner_text() if seller_elem else "N/A"
detail_info['seller_feedback'] = await page.get_attribute(".seller-feedback span", "innerText") or "N/A"
# 3. 物流信息
shipping_selector = "div.shipping span" # 示例
shipping_elem = await page.query_selector(shipping_selector)
detail_info['shipping_cost'] = await shipping_elem.inner_text() if shipping_elem else "N/A"
# 4. 商品规格 (通常在一个表格里)
spec_items = await page.query_selector_all("div.ux-layout-section--features table tr")
specs = {}
for row in spec_items:
cols = await row.query_selector_all("td")
if len(cols) >= 2:
key = await cols[0].inner_text()
value = await cols[1].inner_text()
specs[key.strip(': ')] = value.strip()
detail_info['specifications'] = specs
# 5. 图片URLs (可能有多张)
img_elements = await page.query_selector_all("div.ux-image-carousel img")
img_urls = []
for img in img_elements:
src = await img.get_attribute("src")
if src and src.startswith('http'):
# 有时是缩略图,可以尝试获取高清图URL(规律需分析)
img_urls.append(src)
detail_info['image_urls'] = img_urls
logger.info(f"成功抓取详情页: {item_id}")
except asyncio.TimeoutError:
logger.error(f"抓取详情页 {item_id} 超时")
except Exception as e:
logger.error(f"抓取详情页 {item_id} 时发生未知错误: {e}")
finally:
if page:
await page.close()
return detail_info
5.2 异步并发调度器
这是整个爬虫的“发动机”。我们需要一个管理器来并发执行大量的详情页抓取任务,同时控制并发度,避免对目标服务器造成DDoS攻击,也避免本地资源耗尽。
import aiofiles
import json
from asyncio import Semaphore
class CrawlerScheduler:
def __init__(self, max_concurrent_tasks: int = 10):
# 使用信号量(Semaphore)控制最大并发数
self.semaphore = Semaphore(max_concurrent_tasks)
self.detail_crawler = None # 将在主函数中初始化
async def crawl_detail_worker(self, item: Dict, output_file: str):
"""
一个工作协程,负责抓取一个商品详情
"""
async with self.semaphore: # 控制并发
# 每次抓取前等待一个随机短时间,进一步分散请求
await asyncio.sleep(random.uniform(0.1, 0.5))
detail_data = await self.detail_crawler.fetch_item_detail(item['url'], item['item_id'])
# 合并列表页基础信息
full_data = {**item, **detail_data}
# 异步保存到文件
await self._save_to_jsonl(full_data, output_file)
async def _save_to_jsonl(self, data: Dict, filepath: str):
"""异步追加数据到JSON Lines格式文件"""
async with aiofiles.open(filepath, 'a', encoding='utf-8') as f:
await f.write(json.dumps(data, ensure_ascii=False) + '\n')
async def schedule_detail_crawls(self, item_list: List[Dict], output_file: str):
"""
调度并发抓取所有商品详情
"""
logger.info(f"开始调度 {len(item_list)} 个详情页抓取任务,最大并发数: {self.semaphore._value}")
tasks = []
for item in item_list:
# 为每个商品创建一个异步任务
task = asyncio.create_task(self.crawl_detail_worker(item, output_file))
tasks.append(task)
# 等待所有任务完成,并收集结果(或异常)
results = await asyncio.gather(*tasks, return_exceptions=True)
# 检查是否有任务因异常失败
error_count = sum(1 for r in results if isinstance(r, Exception))
if error_count:
logger.warning(f"详情页抓取完成,有 {error_count} 个任务失败")
else:
logger.info("所有详情页抓取任务已完成")
并发控制的核心:信号量 (Semaphore)
asyncio.Semaphore(10)
创建了一个计数为10的信号量。
async with self.semaphore:
确保了同一时间最多只有10个
crawl_detail_worker
协程在执行
fetch_item_detail
这个I/O密集型操作。这既保护了目标网站,也防止我们自己的程序打开过多浏览器页面导致内存溢出。
6. 高级反爬策略与稳定性优化
到了这一步,一个基本的爬虫已经可以运行了。但要让它能7x24小时稳定运行,还需要添加更多“盔甲”。
6.1 代理IP池集成
使用住宅代理或高质量的数据中心代理是绕过IP封锁最有效的方法之一。我们可以创建一个简单的代理管理器。
class ProxyManager:
def __init__(self, proxy_list: List[str]):
self.proxy_list = proxy_list
self.current_index = 0
def get_proxy(self) -> str:
"""简单轮询获取代理"""
proxy = self.proxy_list[self.current_index]
self.current_index = (self.current_index + 1) % len(self.proxy_list)
return proxy
async def test_proxy(self, proxy_url: str) -> bool:
"""测试代理是否可用"""
try:
async with async_playwright() as p:
# 启动浏览器时指定代理
browser = await p.chromium.launch(
headless=True,
proxy={"server": proxy_url} # 格式如 "http://ip:port" 或 "socks5://ip:port"
)
page = await browser.new_page()
# 访问一个测试网站,如谷歌
response = await page.goto("https://www.google.com", timeout=15000)
await browser.close()
return response and response.status == 200
except Exception:
return False
在创建BrowserContext时使用代理:
proxy = proxy_manager.get_proxy()
browser_context = await browser.new_context(
proxy={"server": proxy},
viewport={'width': 1920, 'height': 1080}, # 设置一个常见的视口大小
user_agent='...' # 可以在这里覆盖User-Agent
)
6.2 请求头与浏览器指纹随机化
固定的User-Agent和浏览器特征也是检测点。Playwright可以轻松模拟不同设备。
import random
def get_random_user_agent():
"""从预定义的列表中随机返回一个User-Agent"""
agents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ...',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ...',
]
return random.choice(agents)
# 在创建上下文或页面时使用
context = await browser.new_context(
user_agent=get_random_user_agent(),
# 还可以随机化其他属性,如时区、地理位置(需浏览器支持)
# locale='en-US',
# timezone_id='America/Los_Angeles',
)
6.3 智能重试与熔断机制
网络请求失败是常态。我们需要一个健壮的重试逻辑。
async def fetch_with_retry(page, url, max_retries=3, backoff_factor=1.5):
"""带指数退避的重试函数"""
for attempt in range(max_retries):
try:
response = await page.goto(url, wait_until='networkidle', timeout=30000)
if response.status in [200, 404]: # 404也是有效的响应,不需要重试
return response
elif response.status in [429, 503]: # 遇到频率限制或服务不可用,必须重试
logger.warning(f"请求被限制(状态码{response.status}),第{attempt+1}次重试...")
else:
logger.error(f"请求失败,状态码{response.status}")
break # 其他错误可能重试无效
except (asyncio.TimeoutError, playwright._impl._api_types.Error) as e:
logger.warning(f"请求异常({e}),第{attempt+1}次重试...")
if attempt < max_retries - 1:
# 指数退避:等待时间随重试次数增加
wait_time = backoff_factor ** attempt + random.uniform(0, 1)
logger.info(f"等待 {wait_time:.2f} 秒后重试")
await asyncio.sleep(wait_time)
logger.error(f"URL {url} 重试{max_retries}次后仍失败")
return None
6.4 数据去重与增量抓取
对于长期运行的价格监控,我们只需要抓取新增或变价的商品。可以在存储时,以商品ID为主键进行去重,或者记录上次抓取的时间戳,只抓取最近更新的商品。
7. 完整流程整合与主函数
最后,我们把所有模块像拼积木一样组合起来,形成一个完整的、可配置的爬虫主程序。
import asyncio
import random
import logging
from typing import List
from playwright.async_api import async_playwright
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
async def main():
# 配置参数
KEYWORDS = ["iphone 13", "laptop"]
MAX_LIST_PAGES = 3 # 每个关键词抓取前3页列表
MAX_CONCURRENT_DETAILS = 8 # 详情页并发数
OUTPUT_FILE = "ebay_items.jsonl"
async with async_playwright() as p:
# 1. 启动浏览器(可配置headless=False进行调试)
browser = await p.chromium.launch(headless=True, slow_mo=100) # slow_mo可放慢操作,便于观察
# 2. 创建浏览器上下文(可在此处配置代理、UA等全局设置)
context = await browser.new_context(
viewport={'width': 1280, 'height': 720},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...'
)
# 3. 初始化爬虫组件
list_crawler = EbayListCrawler(context)
detail_crawler = EbayDetailCrawler(context)
scheduler = CrawlerScheduler(max_concurrent_tasks=MAX_CONCURRENT_DETAILS)
scheduler.detail_crawler = detail_crawler # 注入依赖
all_list_items = []
# 4. 抓取所有关键词的列表页
for keyword in KEYWORDS:
logger.info(f"开始处理关键词: '{keyword}'")
for page_num in range(1, MAX_LIST_PAGES + 1):
items = await list_crawler.fetch_list_page(keyword, page_num)
all_list_items.extend(items)
logger.info(f"关键词 '{keyword}' 第 {page_num} 页找到 {len(items)} 个商品")
# 列表页之间间隔稍长
await asyncio.sleep(random.uniform(3.0, 6.0))
logger.info(f"列表页抓取结束,共收集到 {len(all_list_items)} 个商品链接")
# 5. 去重(根据item_id)
unique_items = {}
for item in all_list_items:
unique_items[item['item_id']] = item
unique_item_list = list(unique_items.values())
logger.info(f"去重后剩余 {len(unique_item_list)} 个唯一商品")
# 6. 并发抓取详情页
await scheduler.schedule_detail_crawls(unique_item_list, OUTPUT_FILE)
# 7. 清理资源
await context.close()
await browser.close()
logger.info("所有抓取任务完成!")
if __name__ == "__main__":
asyncio.run(main())
8. 常见问题、排查技巧与优化建议
在实际运行中,你肯定会遇到各种各样的问题。这里记录了我踩过的一些坑和解决方案。
8.1 元素找不到或超时
-
问题
:
page.wait_for_selector超时,或者query_selector返回None。 -
排查
:
-
确认选择器
:用浏览器的开发者工具,在“元素”面板使用
Ctrl+F,输入你的CSS选择器,看是否能唯一匹配到目标元素。eBay的页面结构可能因地区、A/B测试、登录状态不同而变化。 -
检查页面是否加载完全
:确保在尝试选择元素前,已经使用了足够的等待(如
wait_until='networkidle')或等待了特定元素出现。 -
处理iframe
:有些内容(如部分描述)可能嵌套在
<iframe>里。你需要先定位到iframe,再切换到其内部文档进行查找:frame = await page.wait_for_selector('iframe.xxx'); content_frame = await frame.content_frame(); elem = await content_frame.query_selector(...)。 - 处理动态内容 :有些内容需要交互(如点击“查看更多”)才会加载。在提取前模拟这些点击操作。
-
确认选择器
:用浏览器的开发者工具,在“元素”面板使用
-
优化
:使用更宽松的选择器(如属性包含
*=),或组合多个选择器策略。增加超时时间,并做好异常处理,让程序能跳过当前失败的商品继续运行。
8.2 被屏蔽或弹出验证码
- 问题 :页面返回验证码,或提示“访问受限”。
-
排查
:
-
检查请求频率
:这是最常见的原因。立即大幅降低抓取速度,增加随机延迟的间隔。
MAX_CONCURRENT_DETAILS不要设置过高(建议5-15之间)。 -
检查代理IP质量
:免费代理或低质量代理IP很可能已被eBay封禁。使用
ProxyManager.test_proxy功能定期检测代理可用性。 -
检查浏览器指纹
:无头模式(
headless=True)容易被检测。可以尝试使用headless=False(会弹出可见浏览器窗口)或使用playwright.chromium.launch(headless=True, args=['--disable-blink-features=AutomationControlled'])来隐藏自动化特征。Playwright本身已经做了很多反检测处理,但并非绝对。
-
检查请求频率
:这是最常见的原因。立即大幅降低抓取速度,增加随机延迟的间隔。
-
应对
:
- 策略性暂停 :一旦检测到验证码,程序应记录下该任务,并暂停该IP或该会话一段时间(如半小时),然后换用新的上下文或代理重试。
-
使用更真实的模拟
:在创建上下文时,设置
viewport、user_agent、locale、timezone_id等,并启用ignore_https_errors=False(默认)以使用完整TLS栈。 - 考虑CAPTCHA解决服务 :对于商业级项目,可能需要集成如2Captcha、Anti-Captcha等服务,但这会增加复杂性和成本。
8.3 内存占用过高或程序崩溃
- 问题 :程序运行一段时间后内存持续增长,最终崩溃。
- 原因 :这是异步爬虫,特别是使用无头浏览器时,最容易出现的问题。根本原因是资源未正确释放。
-
解决
:
-
确保关闭Page
:每个
fetch函数中,必须在finally块或使用async with语句确保await page.close()被调用。 -
限制并发数
:通过
Semaphore严格控制同时打开的页面数量。 -
定期重启BrowserContext
:长时间运行后,浏览器上下文内可能会积累缓存和内存碎片。可以设定每抓取N个商品后,关闭当前
context并创建一个新的。 -
监控资源
:使用
psutil等库监控程序内存使用,达到阈值后主动清理或重启相关组件。
-
确保关闭Page
:每个
8.4 数据解析不准确或缺失
- 问题 :价格、标题等字段抓取到的是空值或错误信息。
-
排查
:
-
数据清洗逻辑
:检查
_clean_price这类函数是否能处理所有价格格式(如$1,234.56,$12.34 to $56.78,AU $12.34)。 - 页面结构变体 :eBay有不同的商品展示模板(如拍卖、一口价、二手、新品)。你的选择器可能需要兼容多种变体。写更健壮的提取代码,例如先尝试选择器A,如果没找到再尝试选择器B。
-
编码问题
:确保保存文件时使用
utf-8编码,以正确处理多语言字符。
-
数据清洗逻辑
:检查
- 优化 :将解析逻辑模块化,每个字段的提取写成一个独立函数,便于测试和维护。定期运行测试用例,对比抓取结果和手动查看的结果是否一致。
8.5 性能优化建议
- 复用BrowserContext,而非为每个任务创建新Browser :创建Browser实例开销巨大,而创建Page相对较小。整个主流程应只启动一个Browser,在其下创建多个Context或Page。
-
使用
page.route进行网络拦截 :如果你只需要文本数据,可以拦截图片、字体、CSS等不必要的请求,大幅提升页面加载速度和减少带宽消耗。async def route_handler(route): if route.request.resource_type in ["image", "stylesheet", "font"]: await route.abort() else: await route.continue_() await page.route("**/*", route_handler) - 批量保存数据 :不要每抓取一条数据就写一次文件。可以先将数据缓存在内存列表中,积累到一定数量(如100条)后,再批量异步写入文件,减少I/O操作。
- 分布式抓取 :如果数据量极大,单机性能成为瓶颈,可以考虑使用消息队列(如Redis + RQ或Celery)将抓取任务分发到多台机器上执行,每台机器运行独立的Playwright实例。这需要更复杂的架构设计。
构建一个用于生产环境的爬虫,就像是一场与目标网站反爬系统的持续博弈。没有一劳永逸的方案,核心在于
理解原理、保持灵活、尊重规则
。Playwright+Asyncio提供了强大的武器,但如何使用它,取决于你对目标网站的理解和工程化的细致程度。从简单的脚本开始,逐步添加错误处理、代理、并发控制、监控告警等模块,你的爬虫才会越来越健壮。最后再次强调,务必遵守
robots.txt
,合理控制请求频率,你的爬虫才能走得长远。



1220

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



