1. 项目概述与核心价值
最近在帮一个做图书数据分析的朋友抓取京东图书新书榜的数据,他不仅需要书名、作者这些基础信息,还特别强调要拿到实时的动态价格,并且榜单是分页的。这听起来就是个典型的动态网页爬虫需求,页面数据很可能是通过JavaScript异步加载的。如果用传统的requests库配合BeautifulSoup,大概率会扑空,因为初始HTML里根本没有价格和后续分页的数据。我第一时间就想到了Playwright这个现代浏览器自动化工具。它不像Selenium那样需要额外安装浏览器驱动,而且对动态内容的处理能力堪称一流,尤其擅长应对像京东这样的大型电商网站复杂的交互逻辑。这个项目,本质上就是利用Playwright模拟真实用户浏览行为,攻克动态渲染、价格获取和分页遍历这三个核心难点,最终稳定、高效地抓取到结构化的榜单数据。
对于刚接触爬虫或者被动态网站困扰的朋友来说,这个实战案例非常有价值。它跳过了简单的静态页面抓取,直接切入当前爬虫领域最主流的挑战:如何与一个由JavaScript驱动的现代Web应用进行交互并提取数据。通过这个项目,你不仅能学会Playwright的基本操作,更能掌握一套处理动态内容、解析复杂页面结构、设计稳健爬取逻辑的完整方法论。无论你是想监控商品价格、分析市场趋势,还是学习高级爬虫技术,这都是一个绝佳的练手项目。整个过程我会拆解得非常细,从环境搭建、页面分析到代码编写和错误处理,确保即使你是Python新手,跟着步骤也能跑通。
2. 环境准备与Playwright基础
工欲善其事,必先利其器。我们首先得把Playwright这个“利器”准备好。它和Selenium最大的不同在于其“一体化”的设计理念。Playwright由微软开发,它为Chromium、Firefox和WebKit三大浏览器引擎提供了统一的高层API,并且自带浏览器二进制文件,无需你手动管理驱动,开箱即用。
2.1 安装Playwright
安装过程非常简单。打开你的命令行终端(CMD、PowerShell或终端),使用pip进行安装。我强烈建议在一个独立的虚拟环境中进行,以避免包依赖冲突。如果你使用conda,可以先创建一个新环境。
# 使用pip安装playwright库 pip install playwright # 安装Playwright所需的浏览器二进制文件(Chromium, Firefox, WebKit) playwright install chromium这里我选择安装chromium,因为它最常用,性能也足够。playwright install命令会自动下载对应操作系统的、与库版本匹配的浏览器,省去了很多麻烦。安装完成后,你可以通过一个简单的脚本来测试是否成功。
import asyncio from playwright.async_api import async_playwright async def main(): async with async_playwright() as p: # 启动一个Chromium浏览器实例,headless=False表示有界面,方便调试 browser = await p.chromium.launch(headless=False) page = await browser.new_page() await page.goto('https://www.jd.com') print(await page.title()) await browser.close() asyncio.run(main())如果运行后能打印出“京东(JD.COM)-正品低价、品质保障、配送及时、轻松购物!”这样的标题,说明环境一切正常。注意,Playwright支持同步和异步两种API。在这个项目中,为了代码的清晰和现代性,我将使用异步API(async/await)。异步操作在处理网络请求等待时效率更高,更符合爬虫这类I/O密集型任务的特点。
2.2 理解Playwright的核心概念
在开始写爬虫之前,有必要快速过一下Playwright的几个核心对象,这能帮你更好地理解后续的代码:
- Browser: 代表一个浏览器实例。你可以把它想象成一个完整的、可以打开多个窗口的浏览器程序。我们通过
launch()方法启动它。 - Context: 浏览器上下文。它类似于一个独立的隐身会话,拥有独立的cookie、本地存储和缓存。在一个
Browser实例下可以创建多个互不干扰的Context,这在需要模拟多个用户或者隔离会话时非常有用。在这个项目中,我们暂时用不到太复杂的上下文管理。 - Page: 页面。这是我们将要操作的主要对象,对应浏览器中的一个标签页。绝大部分操作,如跳转网址、点击元素、输入文本、提取数据,都是在
Page对象上完成的。 - Locator: 定位器。这是
Playwright中用于查找和操作页面元素的强大工具。它比直接使用page.query_selector()更现代、更强大,支持链式调用,并且具有自动等待元素可用的智能特性。我们将主要使用page.locator()来定位目标元素。
理解了这些,我们就可以开始分析目标页面,设计我们的爬取策略了。
3. 目标页面分析与爬取策略设计
我们的目标是京东图书的“新书榜”。首先,我们需要手动打开浏览器,访问这个页面,观察它的结构和行为。你可以直接访问https://book.jd.com/booktop/0-0-0.html?category=1713-0-0-0-10001-1这个链接。注意,京东的URL结构可能会变化,如果这个链接失效,你可以直接在京东图书频道找到“新书榜”的入口。
3.1 页面结构观察与数据定位
打开页面后,按下F12打开开发者工具,进入“元素”(Elements)面板。我们需要找到榜单列表的HTML结构。
- 列表容器: 滚动页面,观察榜单区域。通常,这类列表会被包裹在一个具有特定
id或class的<div>中。经过检查,我发现榜单列表位于一个id="plist"的<ul>元素内。这是一个非常清晰的信号。 - 单条商品信息: 在
#plist下面,每一个<li>标签就对应一本图书。我们需要从每个<li>中提取:书名、作者/出版社、价格、以及可能的折扣信息。 - 动态价格: 这是关键点。你会发现,页面初始加载时,价格区域可能显示为“加载中...”或者是一个占位符。当你把鼠标悬停在某个商品上,或者滚动页面时,价格才会显示出来。这说明价格是通过JavaScript异步请求接口获取并动态填充的。我们不能直接从初始HTML中提取,必须等待价格加载完成。
- 分页机制: 翻到页面底部,可以看到分页栏。京东的分页通常是点击“下一页”按钮,或者直接点击页码。我们需要让
Playwright模拟这个点击动作,并等待下一页内容加载完成。
注意: 在分析页面时,务必遵守网站的
robots.txt协议(通常位于网站根目录,如https://www.jd.com/robots.txt)。虽然robots.txt是君子协议,不具有强制约束力,但作为负责任的爬虫开发者,我们应该尊重网站的爬取规则,避免对服务器造成过大压力。我们的爬虫应设置合理的请求间隔(如每次操作后等待1-3秒),模拟人类浏览速度,并且最好在访问量较低的时段(例如凌晨)运行。
3.2 爬取策略设计
基于以上分析,我们的爬虫流程可以设计如下:
- 启动浏览器并导航: 使用
Playwright启动一个浏览器,打开新书榜第一页。 - 等待页面稳定: 等待列表容器(
#plist)出现在DOM中,确保主要内容已加载。 - 处理动态价格: 由于价格是动态加载的,我们需要一个策略来触发或等待价格显示。一个可靠的方法是模拟用户行为,例如将鼠标移动到每个商品图片上,或者直接等待一段时间让前端脚本执行完毕。更精准的方法是监听网络请求,找到获取价格的接口,但京东的接口通常有反爬措施。这里我们采用折中方案:先滚动页面到底部,再滚动回顶部,触发懒加载和价格渲染,然后等待一个固定时间(如2秒)。
- 提取当前页数据: 使用定位器找到所有商品项,遍历每一项,提取书名、作者、价格等信息。
- 处理分页: 定位“下一页”按钮,检查其是否可点击(即不是灰色或不存在)。如果可点击,则点击它,然后等待新页面加载(等待列表容器更新),然后重复步骤3和4。
- 循环与终止: 持续翻页,直到“下一页”按钮变为不可用状态(到达最后一页)。
- 数据存储: 将每一页抓取的数据追加到一个列表或直接写入文件(如CSV或JSON)。
这个策略兼顾了成功率和代码的简洁性。接下来,我们就开始一步步实现它。
4. 核心代码实现与分步解析
我们将把整个爬虫写在一个Python脚本中。我会将代码分成几个函数,让结构更清晰。我们主要使用asyncio和playwright.async_api。
4.1 初始化与页面导航
首先,我们编写主函数和初始化代码。
import asyncio import csv from playwright.async_api import async_playwright async def main(): # 使用async上下文管理器启动Playwright async with async_playwright() as p: # 启动浏览器。headless=False方便调试,看到浏览器操作。正式运行可设为True。 browser = await p.chromium.launch(headless=False, slow_mo=100) # slow_mo让动作变慢,便于观察 # 创建一个新的浏览器上下文和页面 context = await browser.new_context() page = await context.new_page() # 目标URL - 京东图书新书榜 url = 'https://book.jd.com/booktop/0-0-0.html?category=1713-0-0-0-10001-1' # 导航到目标页面,并等待网络状态至少到‘domcontentloaded’ await page.goto(url, wait_until='domcontentloaded') print(f"已访问: {url}") # 在这里调用后续的爬取函数 all_books = await crawl_book_list(page) # 爬取结束后,关闭浏览器 await browser.close() # 保存数据 save_to_csv(all_books, 'jd_new_books.csv') print(f"数据已保存,共抓取{len(all_books)}条记录。") if __name__ == '__main__': asyncio.run(main())代码解析:
p.chromium.launch(headless=False, slow_mo=100):headless=False让浏览器窗口可见,对于调试和确认页面加载是否正确至关重要。slow_mo=100表示每个Playwright操作(点击、输入等)都会放慢100毫秒,让你能看清发生了什么。wait_until='domcontentloaded': 这是page.goto()的一个选项,表示导航完成的标准是HTML文档被完全加载和解析(DOMContentLoaded事件触发),但不一定等待所有样式、图片和子框架加载。对于爬虫来说,这通常比默认的'load'(等待所有资源加载)更快。
4.2 等待页面加载与触发动态内容
接下来,我们实现一个函数来等待列表加载,并触发动态价格的渲染。
async def wait_for_dynamic_content(page): """ 等待商品列表加载,并触发动态价格渲染。 """ print("正在等待商品列表加载...") # 1. 等待列表容器ul#plist出现在DOM中 await page.wait_for_selector('ul#plist', state='attached', timeout=10000) # 2. 为了确保动态内容(如价格)加载,模拟用户滚动行为。 # 先滚动到页面底部,触发可能的懒加载 await page.evaluate('window.scrollTo(0, document.body.scrollHeight)') await asyncio.sleep(1) # 等待滚动后可能的异步加载 # 再滚动回顶部,方便后续操作(非必须) await page.evaluate('window.scrollTo(0, 0)') # 3. 额外等待一段时间,让前端JS完成价格数据的填充。 # 这个时间需要根据网络速度和页面复杂度调整。2-3秒通常足够。 print("等待动态价格渲染...") await asyncio.sleep(2500) # 等待2.5秒 # 也可以尝试更精准的等待:等待价格元素出现。但价格元素的类名可能不固定。 # 例如,尝试等待一个包含价格的span出现(这里只是示例,实际选择器需分析页面) # try: # await page.wait_for_selector('li.gl-item .p-price span.price', state='visible', timeout=5000) # except: # print("价格元素未在指定时间内出现,继续执行。")关键点与避坑指南:
page.wait_for_selector: 这是Playwright的核心等待函数。state='attached'表示元素存在于DOM中即可,state='visible'要求元素在页面上可见。对于列表容器,attached通常就够了。page.evaluate(): 用于在浏览器页面上下文中执行JavaScript代码。这里我们用它来执行滚动操作。asyncio.sleep(): 这是一个“强制等待”,是一种简单但不够精确的方法。在复杂的动态页面中,有时不得不使用它来给前端脚本足够的执行时间。最佳实践是尽量使用wait_for_selector、wait_for_function等智能等待来替代sleep,但在无法定位到确定元素时,sleep可以作为备选。务必谨慎设置等待时间,太短可能导致数据没加载完,太长则降低效率。- 动态价格的处理: 这里采用“滚动+固定等待”的组合策略。更高级的做法是监听网络请求(
page.on(‘request’)/page.on(‘response’)),拦截获取价格的XHR或Fetch请求,直接从响应中提取数据。但这需要分析网络面板,且接口可能有加密参数,难度较大。对于入门和大多数场景,滚动触发+适当等待是性价比最高的方案。
4.3 解析单页商品数据
现在,我们来实现解析当前页面所有图书信息的函数。这是数据提取的核心。
async def parse_current_page(page): """ 解析当前页面上的所有图书信息。 返回一个字典列表,每个字典代表一本书。 """ books = [] # 使用locator定位所有商品列表项。京东新书榜的列表项通常是li.gl-item # 更稳妥的方法是先找到列表容器ul#plist,再找其下的li子元素 list_container = page.locator('ul#plist') # count()方法可以获取匹配到的元素数量 item_count = await list_container.locator('li.gl-item').count() print(f"当前页面找到 {item_count} 个商品项。") if item_count == 0: print("警告:未找到商品列表项,页面结构可能已变化!") return books # 遍历每一个商品项 for i in range(item_count): book_item = list_container.locator('li.gl-item').nth(i) book_info = {} try: # 1. 提取书名 (通常在class包含‘p-name’的div里的a标签或em标签) name_element = book_item.locator('div.p-name a, div.p-name em').first if await name_element.count() > 0: book_info['title'] = (await name_element.text_content()).strip() else: book_info['title'] = 'N/A' # 2. 提取作者/出版社信息 (通常在class包含‘p-bookdetails’的div里) author_element = book_item.locator('span.p-bi-name, div.p-bookdetails span.author').first if await author_element.count() > 0: book_info['author_publisher'] = (await author_element.text_content()).strip() else: book_info['author_publisher'] = 'N/A' # 3. 提取价格 (这是动态加载的,class可能为‘p-price’下的‘price’或‘J-p-xxx’) # 京东价格可能有多个span,例如原价和现价。我们取第一个(通常是现价) price_element = book_item.locator('div.p-price strong i, div.p-price span.price').first if await price_element.count() > 0: price_text = (await price_element.text_content()).strip() # 清理价格字符串,只保留数字和小数点 import re price_clean = re.search(r'[\d\.]+', price_text) book_info['price'] = price_clean.group() if price_clean else price_text else: book_info['price'] = 'N/A (可能未加载)' # 4. 提取商品链接 (可选) link_element = book_item.locator('div.p-img a').first if await link_element.count() > 0: book_info['link'] = await link_element.get_attribute('href') # 补全为完整URL if book_info['link'] and book_info['link'].startswith('//'): book_info['link'] = 'https:' + book_info['link'] elif book_info['link'] and book_info['link'].startswith('/'): book_info['link'] = 'https://item.jd.com' + book_info['link'] else: book_info['link'] = 'N/A' except Exception as e: print(f"解析第{i+1}个商品时出错: {e}") # 即使某个商品解析失败,也继续处理下一个 continue books.append(book_info) # 可以打印进度,但不要太频繁 if (i+1) % 10 == 0: print(f" 已解析 {i+1}/{item_count} 个商品...") return books代码解析与实操心得:
- 使用
Locator链式调用:page.locator(‘ul#plist’).locator(‘li.gl-item’)比page.locator(‘ul#plist li.gl-item’)在某些情况下更清晰,也更容易处理动态列表。 .nth(i): 用于从一组定位器中按索引选取特定的元素。.count(): 异步方法,用于获取匹配定位器的元素数量。非常重要,在遍历前先获取数量,避免在循环中因DOM更新导致问题。.text_content()和.get_attribute(): 分别用于获取元素的文本内容和属性值。注意,text_content()会获取元素及其所有后代元素的文本,并合并成一个字符串。- 异常处理: 用
try...except包裹每个商品的解析逻辑至关重要。网页结构可能微调,某个元素的定位器可能失效。不能让一个商品的解析失败导致整个程序崩溃。记录错误并继续是最佳实践。 - 价格清洗: 价格文本可能包含货币符号(如
¥)、空格或其他字符。使用正则表达式re.search(r'[\d\.]+', price_text)可以提取出纯数字和小数点部分,得到干净的价格数值。 - 选择器的健壮性: 我提供了多个备选的选择器(用逗号分隔),例如
‘div.p-name a, div.p-name em’。这是因为京东的页面结构在不同模块或不同时间可能有细微差别。使用.first可以取第一个匹配到的元素。在实际项目中,你需要经常检查页面元素,并准备备用选择器。
4.4 处理分页逻辑
分页是爬虫的另一个核心。我们需要找到“下一页”按钮,点击它,并等待新内容加载。
async def go_to_next_page(page): """ 尝试点击‘下一页’按钮,并等待新页面内容加载。 返回True表示成功翻页,False表示已是最后一页。 """ # 定位‘下一页’按钮。京东的分页按钮class通常是‘pn-next’ next_button = page.locator('a.pn-next') # 检查按钮是否存在且未被禁用(例如,没有‘disabled’类或属性) if await next_button.count() == 0: print(“未找到‘下一页’按钮,可能已是最后一页。”) return False # 检查按钮是否可点击(通过判断其是否可见且未被禁用) is_disabled = await next_button.get_attribute('disabled') or await next_button.get_attribute('aria-disabled') is_visible = await next_button.is_visible() if is_disabled or not is_visible: print(“‘下一页’按钮不可点击,已到达最后一页。”) return False print(“正在翻到下一页...”) # 点击下一页按钮 await next_button.click() # 等待页面导航完成和新内容加载 # 这里我们等待列表容器更新。一个技巧是等待旧的列表项消失或新的列表项出现。 # 更简单的方法是等待页面网络基本空闲,并等待列表容器重新稳定。 await page.wait_for_load_state('networkidle') # 等待网络基本空闲 # 再次等待列表容器出现,确保新页面的内容已加载 try: await page.wait_for_selector('ul#plist', state='attached', timeout=8000) except Exception as e: print(f“等待新页面列表超时或出错: {e}”) # 即使超时,也可能已经加载了,可以尝试继续解析 pass # 翻页后,同样需要触发动态内容加载 await wait_for_dynamic_content(page) return True关键点与避坑指南:
- 分页按钮的状态判断: 不能只检查元素是否存在。在最后一页时,“下一页”按钮可能仍然存在,但被添加了
disabled属性或aria-disabled属性,或者其href是javascript:void(0)。我们需要综合判断其是否可交互。 - 等待策略:
page.wait_for_load_state(‘networkidle’)会等待页面网络活动变得很少(通常500ms内没有超过2个网络请求),这对于单页应用(SPA)或异步加载内容的页面非常有用。但是要注意,有些页面可能始终有后台请求(如心跳包、广告),导致networkidle永远达不到。因此,我们结合了wait_for_selector来等待特定的内容元素出现,这样更可靠。 - 点击后的等待: 点击“下一页”后,页面可能不会发生完整的导航(即URL不变,内容通过AJAX替换)。
wait_for_load_state(‘networkidle’)和wait_for_selector的组合能很好地应对这两种情况。 - 超时处理: 为
wait_for_selector设置一个合理的timeout(如8秒)。如果超时,可能页面加载异常,但我们用try...except捕获异常并打印日志,而不是让程序崩溃,有时页面可能已经加载了部分内容,爬虫还可以尝试继续。
4.5 整合主爬取流程
现在,我们把上述所有函数整合到主爬取函数crawl_book_list中。
async def crawl_book_list(page, max_pages=10): """ 主爬取函数,负责协调等待、解析和翻页。 :param page: Playwright页面对象 :param max_pages: 最大爬取页数,防止意外无限循环 :return: 所有爬取到的图书列表 """ all_books = [] current_page = 1 # 初始页面:等待并解析 await wait_for_dynamic_content(page) first_page_books = await parse_current_page(page) all_books.extend(first_page_books) print(f“第{current_page}页抓取完成,累计{len(all_books)}条记录。”) # 循环翻页 while current_page < max_pages: next_page_success = await go_to_next_page(page) if not next_page_success: print(“已到达最后一页,爬取结束。”) break current_page += 1 # 翻页后,parse_current_page会基于新的页面内容解析 current_page_books = await parse_current_page(page) if not current_page_books: # 如果解析不到数据,可能页面有问题 print(f“警告:第{current_page}页未解析到数据,可能页面加载失败或结构变化。”) # 可以选择重试或退出 break all_books.extend(current_page_books) print(f“第{current_page}页抓取完成,累计{len(all_books)}条记录。”) # 礼貌性延迟,减轻服务器压力,模拟人类操作 await asyncio.sleep(2) if current_page >= max_pages: print(f“已达到最大爬取页数限制({max_pages}页),爬取结束。”) return all_books设计思路:
- 参数
max_pages: 这是一个安全阀,防止因分页逻辑错误(比如“下一页”按钮一直可用)导致无限循环。你可以根据实际需要调整,比如只想爬前5页。 - 循环逻辑: 采用
while循环,每次循环尝试翻页。翻页成功则解析新页面数据,失败(返回False)则退出循环。 - 延迟
asyncio.sleep(2): 在每次翻页后主动等待2秒。这是非常重要的道德和技术考量。过于频繁的请求会对目标服务器造成压力,可能触发反爬机制(如IP被封)。添加延迟是模拟人类浏览速度,是友好爬虫的体现。
4.6 数据存储
最后,我们将抓取到的数据保存到CSV文件中,方便后续分析。
def save_to_csv(books_data, filename): """ 将图书数据列表保存到CSV文件。 """ if not books_data: print(“没有数据可保存。”) return # 定义CSV文件的列头 fieldnames = ['title', 'author_publisher', 'price', 'link'] try: with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile: # utf-8-sig支持Excel中文 writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() for book in books_data: writer.writerow(book) print(f“数据成功保存到 {filename}”) except Exception as e: print(f“保存CSV文件时出错: {e}”)使用utf-8-sig编码可以确保在Windows系统下用Excel打开CSV文件时,中文字符能正常显示。
5. 常见问题排查与进阶优化
即使代码看起来完美,在实际运行中你仍可能会遇到各种问题。下面是我在多次实战中总结的一些常见坑点和解决方案。
5.1 元素定位失败
问题: 控制台报错TimeoutError: Timeout 10000ms exceeded waiting for selector “ul#plist”,或者解析时count()为0。
排查步骤:
- 确认页面是否成功加载: 在
headless=False模式下,观察浏览器窗口是否打开了正确的页面,页面内容是否完整显示。可能是网络问题或网站反爬(如弹出验证码)。 - 检查选择器是否过时: 网站前端经常改版。用浏览器的开发者工具(F12)重新检查榜单区域的HTML结构。
id="plist"可能已经变了。你需要更新代码中的选择器,使其匹配当前页面的结构。 - 增加等待时间或使用更宽松的等待条件: 如果网络慢,可以增加
wait_for_selector的timeout值(如30000毫秒)。或者,如果列表容器不是ul而是div,调整选择器。 - 处理页面弹窗或遮罩: 有时网站会有登录弹窗、广告遮罩层,它们会阻挡对底层内容的操作。你可以在代码开头尝试关闭它们:
# 尝试关闭可能的弹窗 (示例,选择器需根据实际情况调整) close_btn = page.locator('div.popup-close, a.btn-close') if await close_btn.count() > 0: await close_btn.click() await asyncio.sleep(1)
5.2 动态价格始终抓取不到
问题: 价格字段总是显示“N/A (可能未加载)”。
解决方案:
- 增强触发逻辑: 修改
wait_for_dynamic_content函数。除了滚动,可以尝试将鼠标移动到每个商品图片上,这更能模拟真实用户行为,触发价格请求。# 在wait_for_dynamic_content函数内,滚动后添加 items = page.locator('li.gl-item') count = await items.count() for i in range(min(count, 10)): # 只模拟前10个,避免操作过多 await items.nth(i).hover() await asyncio.sleep(0.1) # 短暂间隔 await asyncio.sleep(1) # 等待hover触发的请求完成 - 直接监听网络请求(进阶): 这是最精准的方法。在开发者工具的“网络”(Network)面板中,筛选XHR/Fetch请求,当你看到价格出现时,寻找包含价格数据的请求。然后使用
Playwright拦截该请求。
这种方法需要较强的逆向工程能力,但一旦成功,爬取效率和稳定性会极大提升。# 在page.goto之前,设置请求监听 async def handle_response(response): if 'api.m.jd.com' in response.url and 'wareBusiness' in response.url: # 示例关键词 try: data = await response.json() # 从data中提取价格,并存储到一个全局字典,key可以用商品SKU print(f“拦截到价格数据: {data}”) except: pass page.on('response', handle_response)
5.3 反爬虫机制应对
问题: IP被封锁、请求被重定向到验证页面、返回空白页面或错误数据。
应对策略:
- 降低请求频率: 这是我们一直在做的。翻页间隔(
asyncio.sleep)可以随机化,比如await asyncio.sleep(random.uniform(1, 3)),使其行为更不像机器人。 - 使用浏览器上下文模拟真实用户:
Playwright的browser.new_context()可以设置用户代理(User-Agent)、视口大小、地理位置等。使用常见的UA,并设置合理的视口。context = await browser.new_context( user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...', viewport={'width': 1920, 'height': 1080} ) - 处理验证码: 如果遇到验证码,自动化解决非常困难。可以考虑:
- 手动处理: 在
headless=False模式下,如果弹出验证码,程序暂停,你手动完成验证后,代码继续执行。这需要复杂的交互逻辑。 - 使用第三方打码平台: 商业项目可能会接入打码API,但会增加成本和复杂度。
- 规避: 最好的方式是让爬虫行为足够“像人”,避免触发验证码。如果频繁触发,可能需要更换IP或暂停爬取一段时间。
- 手动处理: 在
- 代理IP: 对于大规模爬取,使用代理IP池是必须的。
Playwright启动浏览器时可以指定代理服务器。browser = await p.chromium.launch( headless=True, proxy={ 'server': 'http://your-proxy-server:port', # 如果需要认证 'username': 'user', 'password': 'pass' } )
5.4 程序稳定性与健壮性
问题: 爬虫运行一段时间后崩溃,或数据缺失严重。
加固措施:
- 全面的异常捕获: 像我们在
parse_current_page里做的那样,在可能出错的代码块(特别是网络请求、元素定位、数据解析)周围使用try...except。记录错误日志,但不要轻易让整个程序停止。 - 重试机制: 对于关键操作(如
page.goto,click),可以封装一个重试函数。async def retry_operation(operation, max_retries=3, delay=2): for attempt in range(max_retries): try: return await operation() except Exception as e: print(f“操作失败,第{attempt+1}次重试。错误: {e}”) if attempt < max_retries - 1: await asyncio.sleep(delay) else: raise e # 重试次数用尽,抛出异常 # 使用示例 await retry_operation(lambda: page.goto(url, wait_until='domcontentloaded')) - 定期保存状态: 如果爬取数据量很大,不要等到全部结束才保存。可以每爬完一页或每N条记录就追加写入文件一次。这样即使程序中途崩溃,已经抓取的数据也不会丢失。
- 监控与日志: 使用Python的
logging模块替代print,将运行信息、错误信息输出到文件,便于后期排查问题。
这个项目从环境搭建到完整代码实现,详细展示了如何使用Playwright应对一个具有动态内容和分页的现代电商网站。代码中包含了大量的错误处理和实践经验,直接复制运行成功的概率很高。但请记住,网页是变化的,最核心的技能不是记住这段代码,而是学会使用开发者工具分析页面、设计健壮的选择器和等待逻辑、以及优雅地处理各种异常情况。在实际使用时,请务必根据目标网站的最新结构调整选择器,并始终遵守robots.txt和网站的服务条款,做一个负责任的数据获取者。