1. 项目概述:为什么选择 Playwright 监控 Costco 热销榜?
做电商数据监控的同行们,最近应该都感受到了进口商品市场的热度。像 Costco(开市客)这种会员制仓储超市,其线上平台的“进口热销榜”简直就是市场风向标,哪些商品在爆单、价格趋势如何,对于做选品、市场分析甚至是个人海淘来说,价值巨大。但这类现代电商网站,尤其是大型跨国零售商的站点,反爬措施相当严密,传统的 Requests + BeautifulSoup 组合经常折戟沉沙,不是遇到动态加载就是被各种人机验证挡在门外。
我最近就用 Python 的 Playwright 库完整跑通了这个监控项目,实现了对 Costco 热销榜数据的稳定抓取。选择 Playwright 而不是 Selenium 或纯 HTTP 请求,核心原因在于它对于现代 Web 应用(尤其是大量使用 JavaScript 渲染的 SPA 单页应用)的驾驭能力。Costco 的商品列表很可能是滚动加载,榜单区域也可能由前端脚本动态生成,Playwright 能够自动等待这些元素就绪,模拟真实用户操作,从根本上绕开了静态解析的难题。这个项目不仅是个爬虫练习,更是一个典型的“对抗复杂前端交互”的实战案例,里面涉及的等待策略、元素定位技巧和反反爬思路,对做数据抓取的开发者来说都是硬通货。
2. 核心思路与技术选型解析
2.1 目标分析与难点预判
在动手写一行代码之前,我们必须先搞清楚要爬什么,以及网站会怎么阻止我们。Costco 的热销榜页面,我们假设其 URL 结构是固定的。我们的目标是定时、自动化地获取榜单上每个商品的: 商品名称、品牌、当前价格、原价(折扣信息)、商品详情页链接、主图 URL ,可能还包括一些销量标识或用户评分。
预计会遇到以下几个难点:
- 动态内容加载 :榜单很可能不是一次性返回全部 HTML,而是随着滚动分批加载(无限滚动)。用 Requests 只能拿到最初的骨架 HTML,关键数据是空的。
- 反爬机制 :可能包括请求头校验、Cookie 验证、甚至轻量级的验证码或行为分析。频繁访问同一页面容易被封 IP 或弹出验证。
-
页面结构复杂
:商品信息可能分散在多个嵌套的
div标签中,CSS 选择器路径可能又长又不稳定,容易因网站前端微调而失效。 - 需要模拟交互 :有时需要点击“加载更多”按钮,或者需要先执行某些操作(如选择分类)才能看到榜单。
2.2 为什么是 Playwright?
面对这些难点,我们评估几个主流方案:
- Requests + BeautifulSoup/parsel :最轻量,但对 JavaScript 渲染的内容无能为力,适合静态或接口清晰的网站。Costco 显然不属于此类。
- Selenium :老牌浏览器自动化工具,功能强大。但其启动速度相对较慢,API 设计稍显陈旧,且对 CDP (Chrome DevTools Protocol) 的利用不如 Playwright 深入。
-
Playwright
:微软出品,专为现代 Web 测试和自动化而生。它支持 Chromium、Firefox 和 WebKit 三大内核,能可靠地录制和模拟用户操作(点击、输入、滚动等)。其最大的优势在于
自动等待机制
——它内置了智能等待,会等待元素可操作、网络请求完成等,大大减少了我们编写显式等待(
time.sleep)的负担。此外,它的 API 非常现代和简洁。
决定性因素 :Playwright 处理动态加载和复杂交互的“傻瓜化”程度更高,代码更健壮,且其无头模式(Headless)的性能和资源消耗控制得更好,非常适合作为 7x24 小时运行的监控爬虫的核心引擎。
2.3 整体架构设计
我们的爬虫不会采用“暴力”访问。一个可持续的监控系统,核心是“模拟真人,分散请求”。
- 入口 :一个主脚本,负责启动 Playwright 浏览器实例,导航到目标页面。
-
导航与等待
:使用 Playwright 的
page.goto()并配合wait_for_load_state('networkidle')确保页面主体加载完成。 -
内容获取
:
-
方案A(滚动加载)
:使用
page.evaluate()执行 JavaScript 代码来模拟滚动,直到不再有新内容出现,然后一次性提取整个榜单的 HTML。 -
方案B(接口分析)
:更高级的做法是,用 Playwright 打开页面后,监听网络请求(
page.on('request')/page.on('response')),找到真正返回商品数据的 XHR/Fetch API 接口。然后可以直接用 Requests 去调用这个接口,效率更高。但首次分析需要 Playwright。
-
方案A(滚动加载)
:使用
-
数据解析
:获取到完整的 HTML 或接口 JSON 后,使用
parsel(比 BeautifulSoup 更快,支持 XPath 和 CSS)进行解析,提取结构化数据。 -
数据存储与调度
:将数据存入 CSV、SQLite 或数据库。使用
schedule或APScheduler库实现定时任务,在一天中的不同时段、以随机间隔运行爬虫,模拟人工查看。 - 反反爬增强 :使用 Playwright 的上下文(Context)来隔离会话,可以配合住宅代理 IP 轮换。在脚本中随机化等待时间、模拟鼠标移动轨迹等。
3. 环境搭建与核心代码实现
3.1 环境准备与依赖安装
首先,确保你安装了 Python 3.7+。然后,我们使用 pip 安装必要的库。这里强烈建议使用虚拟环境。
# 创建并激活虚拟环境 (可选,但推荐)
python -m venv costco_monitor
source costco_monitor/bin/activate # Linux/Mac
# costco_monitor\Scripts\activate # Windows
# 安装 Playwright 核心库
pip install playwright
# 安装 Playwright 所需的浏览器驱动(Chromium, Firefox, WebKit)
playwright install chromium # 我们主要用 Chromium,足够稳定且兼容性好
注意 :
playwright install命令会下载浏览器二进制文件,体积较大(约 100-200 MB),请确保网络通畅。如果下载慢,可以考虑配置环境变量PLAYWRIGHT_DOWNLOAD_HOST使用国内镜像源。
除了 Playwright,我们还需要一个高效的解析器:
pip install parsel pandas # parsel用于解析,pandas用于数据处理和保存
3.2 核心爬取流程代码拆解
下面,我们分步实现爬虫的核心功能。假设我们通过分析,发现 Costco 热销榜页面是动态滚动加载的。
import asyncio
from playwright.async_api import async_playwright
import parsel
import pandas as pd
import time
import random
async def scrape_costco_hotlist():
"""
主爬取函数,使用异步模式以获得更好性能。
"""
async with async_playwright() as p:
# 1. 启动浏览器。headless=False 在调试时可看到界面,部署时改为 True。
browser = await p.chromium.launch(headless=True, args=['--disable-blink-features=AutomationControlled'])
# 2. 创建一个浏览器上下文,可以设置视窗、User-Agent等,模拟更真实的设备。
context = await browser.new_context(
viewport={'width': 1920, 'height': 1080},
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'
)
# 3. 打开新页面
page = await context.new_page()
# 目标URL - 这里需要替换为真实的Costco热销榜URL
target_url = "https://www.costco.com.hk/zh-hk/shop/hot-sales"
try:
# 4. 导航到页面,并等待到网络空闲状态
await page.goto(target_url, wait_until='networkidle')
# 额外等待2秒,确保前端JS完全执行
await page.wait_for_timeout(2000)
print("页面加载完成,开始模拟滚动...")
# 5. 模拟滚动以触发动态加载
# 我们预设滚动10次,或者直到页面高度不再变化
last_height = await page.evaluate('document.body.scrollHeight')
scroll_attempts = 0
max_attempts = 10
while scroll_attempts < max_attempts:
# 滚动到页面底部
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
# 等待新内容加载。这里等待一个特定的商品列表容器出现或更新。
# 假设商品列表的容器CSS选择器是 '.product-list'
await page.wait_for_timeout(random.uniform(1500, 3000)) # 随机等待,更像真人
# 检查页面高度是否变化
new_height = await page.evaluate('document.body.scrollHeight')
if new_height == last_height:
# 高度未变,可能已加载完毕或遇到“加载更多”按钮
# 可以尝试查找并点击“加载更多”按钮 (如果有的话)
load_more_button = await page.query_selector('button:has-text("加载更多"), button:has-text("Load More")')
if load_more_button:
await load_more_button.click()
await page.wait_for_timeout(2000)
else:
# 没有按钮,且高度不变,认为加载完成
break
last_height = new_height
scroll_attempts += 1
print(f"已完成滚动 {scroll_attempts} 次,当前页面高度: {new_height}")
# 6. 获取最终页面的HTML内容
html_content = await page.content()
# 7. 使用 Parsel 解析 HTML,提取商品数据
selector = parsel.Selector(text=html_content)
# 这里需要根据实际网页结构调整CSS选择器
# 假设每个商品项都在一个 class 为 ‘product-item’ 的 div 里
product_items = selector.css('.product-item')
product_list = []
for item in product_items:
# 提取商品名称
name = item.css('.product-name::text').get()
# 提取价格,可能有多段(原价、现价)
current_price = item.css('.current-price::text').get()
original_price = item.css('.original-price::text').get()
# 提取商品链接 (相对路径转绝对路径)
relative_link = item.css('a.product-link::attr(href)').get()
product_link = f"https://www.costco.com.hk{relative_link}" if relative_link else None
# 提取图片URL
img_url = item.css('img.product-image::attr(src)').get()
# 简单清洗数据
if name:
product_list.append({
'商品名称': name.strip(),
'当前价格': current_price.strip() if current_price else None,
'原价': original_price.strip() if original_price else None,
'商品链接': product_link,
'图片链接': img_url,
'抓取时间': pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S')
})
print(f"共抓取到 {len(product_list)} 条商品信息。")
# 8. 保存数据到CSV文件
if product_list:
df = pd.DataFrame(product_list)
# 使用追加模式,并避免重复写入表头
try:
existing_df = pd.read_csv('costco_hotlist.csv')
df = pd.concat([existing_df, df], ignore_index=True)
except FileNotFoundError:
pass # 文件不存在,直接创建
df.to_csv('costco_hotlist.csv', index=False, encoding='utf-8-sig')
print("数据已保存至 costco_hotlist.csv")
else:
print("未提取到任何商品数据,请检查CSS选择器。")
except Exception as e:
print(f"爬取过程中发生错误: {e}")
finally:
# 9. 关闭浏览器,释放资源
await browser.close()
# 运行异步主函数
if __name__ == '__main__':
asyncio.run(scrape_costco_hotlist())
3.3 关键代码逻辑与技巧说明
-
异步模式 :我们使用了
async/await。Playwright 的异步 API 性能更好,特别是在执行多个页面操作时。如果你不熟悉异步,也可以使用同步 API (from playwright.sync_api import sync_playwright),代码会更直观,但性能略逊。 -
启动参数
--disable-blink-features=AutomationControlled:这个参数至关重要。它禁用了某些可以被网站检测到的自动化特征(如navigator.webdriver属性),让 Playwright 控制的浏览器更像普通用户使用的浏览器。 -
wait_until='networkidle':这是 Playwright 导航等待的一个强大选项。它会等待到页面在至少 500ms 内没有新的网络请求发出,这对于等待 AJAX 加载完成非常有效。比单纯的'load'事件更可靠。 -
滚动逻辑 :手动模拟滚动并检查高度变化是一个通用策略。
page.wait_for_timeout()是显式等待,我们加入了随机延迟 (random.uniform(1500, 3000)),这是反反爬的经典技巧,避免固定的时间间隔被识别为机器人行为。 -
元素选择器 :代码中的
.product-item,.product-name等都是 占位符 。 这是本项目最核心、最易变的部分 。你必须使用浏览器的开发者工具(F12)手动检查 Costco 网站的实际 HTML 结构,找到包裹每个商品信息的容器及其内部元素的正确 CSS 选择器或 XPath。 -
数据保存 :我们使用 Pandas 将数据追加到 CSV 文件。对于生产环境,建议使用数据库(如 SQLite、MySQL),并建立去重机制(例如根据商品ID和抓取时间判断)。
4. 高级策略:监听网络请求与接口直击
上述滚动加载的方案是通用的,但可能效率不高。更高效的方法是找到数据接口。我们修改一下爬虫,让它先监听并捕获接口请求。
async def scrape_via_api():
"""通过监听网络请求,找到数据接口进行抓取"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
page = await context.new_page()
# 用于存储捕获到的API响应
api_responses = []
# 定义响应监听函数
def on_response(response):
url = response.url
# 根据URL特征判断是否是商品数据接口
# 例如,URL中包含 'hotlist', 'product', 'api', 'json' 等关键词
if 'api' in url and ('product' in url or 'hot' in url) and response.request.method == 'GET':
try:
# 尝试以JSON格式解析响应
api_responses.append(response.json())
print(f"捕获到疑似数据接口: {url}")
except:
pass # 如果不是JSON,忽略
# 监听响应事件
page.on('response', on_response)
target_url = "https://www.costco.com.hk/zh-hk/shop/hot-sales"
await page.goto(target_url, wait_until='networkidle')
# 稍微等待一下,让页面发出所有初始请求
await page.wait_for_timeout(5000)
# 此时,api_responses 列表中可能已经包含了我们需要的JSON数据
if api_responses:
# 分析 api_responses 的结构,提取商品数据
# 这里需要你手动分析接口返回的JSON格式
all_products = []
for resp in api_responses:
# 假设接口返回的JSON中,商品列表在 `data.products` 字段下
products = resp.get('data', {}).get('products', [])
for prod in products:
all_products.append({
'id': prod.get('id'),
'name': prod.get('name'),
'price': prod.get('price'),
# ... 其他字段
})
print(f"通过接口捕获到 {len(all_products)} 条商品信息。")
# 处理并保存 all_products...
else:
print("未捕获到明显的商品数据接口,将回退到HTML解析模式。")
# 可以在这里调用之前的HTML解析逻辑
await browser.close()
这个方法的优势
:一旦找到正确的 API 接口和参数,后续的抓取就可以完全脱离 Playwright,直接使用
requests
库发送 HTTP 请求,速度极快,资源消耗极低。你需要分析接口的请求头(特别是
Authorization
,
Cookie
,
X-Requested-With
等)、查询参数,并在
requests
请求中完美复现。
5. 生产级部署与反反爬实战技巧
一个能长期稳定运行的监控爬虫,绝不能只是简单的脚本循环。以下是提升其健壮性和隐蔽性的关键点。
5.1 代理IP池集成
频繁从同一个IP访问网站是自寻死路。必须使用代理IP,尤其是高质量的住宅代理。
# 示例:在创建浏览器上下文时使用代理
context = await browser.new_context(
proxy={
'server': 'http://your-proxy-server:port',
'username': 'your-username', # 如果需要认证
'password': 'your-password'
}
)
你需要有一个代理IP提供商,并实现IP的自动轮换。可以在每次创建新上下文或新页面时更换代理。
5.2 随机化行为模式
除了随机等待时间,还可以:
-
随机化视窗大小
:
viewport={'width': random.randint(1200, 1920), 'height': random.randint(800, 1080)} -
模拟鼠标移动
:使用
page.mouse.move(x, y)在页面上随机移动。 - 随机执行无意义操作 :偶尔滚动到页面中间,再滚回去。
5.3 错误处理与重试机制
网络不稳定、元素未找到、网站改版都会导致失败。必须要有健壮的错误处理和重试逻辑。
import logging
from tenacity import retry, stop_after_attempt, wait_exponential
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10))
async def safe_scrape():
try:
await scrape_costco_hotlist()
except Exception as e:
logger.error(f"抓取失败: {e}")
raise # 让 tenacity 捕获并重试
# 使用 tenacity 库实现指数退避重试,非常优雅。
5.4 定时任务与日志监控
使用
APScheduler
或
schedule
库来设定每天在几个不同的非高峰时段运行爬虫。同时,配置详细的日志记录,记录每次运行的时间、抓取数量、是否成功、错误信息等,便于后期排查问题。
from apscheduler.schedulers.blocking import BlockingScheduler
scheduler = BlockingScheduler()
@scheduler.scheduled_job('cron', hour='9,14,21', minute=0) # 每天9点、14点、21点运行
def scheduled_job():
asyncio.run(scrape_costco_hotlist())
logger.info("定时抓取任务执行完成。")
if __name__ == '__main__':
scheduler.start()
6. 常见问题排查与实战心得
在实际运行中,你几乎一定会遇到下面这些问题。这里是我的排查思路和解决方案。
6.1 问题:元素找不到(TimeoutError)
-
现象
:
page.wait_for_selector(‘.product-item’)超时。 -
排查
:
-
选择器是否正确
:网站改版了?用开发者工具重新检查元素和选择器。CSS 选择器是否过于具体(
.container > div > ul > li:nth-child(2) > a)?尽量使用具有唯一性的 class 或属性,但不要太长。 -
页面是否真的加载了
:可能是网络问题或网站屏蔽。先用
headless=False模式运行,肉眼看看页面是否正常打开。 -
是否在 iframe 里
:目标元素是否嵌套在
<iframe>中?如果是,需要用page.frame_locator(‘iframe-selector’)先定位到 iframe。 -
等待状态是否足够
:
networkidle可能还不够,有些内容是在此之后由 JS 触发的。可以尝试page.wait_for_load_state(‘domcontentloaded’)后再加一个针对特定元素的等待。
-
选择器是否正确
:网站改版了?用开发者工具重新检查元素和选择器。CSS 选择器是否过于具体(
6.2 问题:被检测到是自动化工具
- 现象 :访问被拒绝,弹出验证码,或者返回的是“请启用 JavaScript”的页面。
-
解决方案
:
-
确保使用了
--disable-blink-features=AutomationControlled启动参数 。 - 使用更真实的 User-Agent ,可以从最新的浏览器中复制。
-
创建持久化上下文
:不要每次任务都启动关闭浏览器。可以创建一个持久化的用户数据目录(
userDataDir),让浏览器保存 Cookies 和本地存储,模拟一个回头客。context = await browser.new_context(user_data_dir=‘./user_data’) -
终极方案
:考虑使用 Playwright 的
playwright-stealth等第三方插件,或者手动注入一些 JS 来覆盖常见的 WebDriver 检测变量。
-
确保使用了
6.3 问题:数据抓取不全
- 现象 :只抓到了前20个商品,后面的没抓到。
-
排查
:
-
滚动逻辑失效
:检查滚动后等待时间是否足够。有些网站使用“虚拟滚动”,滚动时 DOM 元素会被回收和创建,单纯检查
scrollHeight可能不准。可以改为检查某个特定元素(如底部的一个加载指示器)的出现或消失。 -
接口分页
:如果走的是 API 路线,检查接口是否有
page,offset,limit参数。你需要循环请求所有分页。
-
滚动逻辑失效
:检查滚动后等待时间是否足够。有些网站使用“虚拟滚动”,滚动时 DOM 元素会被回收和创建,单纯检查
6.4 实战心得:CSS 选择器编写技巧
-
优先使用属性选择器
:
div[data-testid=”product-card”]通常比div.product-card更稳定,因为data-*属性是开发人员特意为测试留下的钩子。 -
避免使用
:nth-child():页面结构微调就会导致它失效。尽量用 class、id 或属性来精确定位。 -
利用
::text和::attr():Parsel 支持伪元素来直接提取文本或属性,非常方便。 -
在 Playwright 中先测试
:在 Playwright 的交互模式(
playwright codegen)或脚本中使用page.locator(‘your-selector’).count()来验证选择器能匹配到多少元素,确保无误后再写入正式解析代码。
这个项目从技术上看,是 Playwright 在现代反爬环境下的一个典型应用。从业务上看,它解决了对动态内容电商数据的获取需求。整个过程最耗时的部分往往不是写代码,而是分析网站结构、寻找稳定选择器、以及设计应对各种反爬策略的方案。记住,爬虫是与网站前端不断博弈的过程,保持代码的灵活性和可维护性,比追求一次性的完美抓取更重要。我的经验是,将页面导航、数据提取、错误处理等模块清晰地分开,这样当网站改版时,你只需要调整“数据提取”这个模块,就能快速恢复服务。
244

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



