Playwright vs Selenium:动态网页爬虫实战,高效采集餐厅点评数据

发布时间:2026/7/6 18:26:50
Playwright vs Selenium:动态网页爬虫实战,高效采集餐厅点评数据 1. 项目概述为什么餐厅点评数据值得一“爬”最近在做一个本地生活服务的数据分析项目核心需求是获取各大平台上餐厅的用户点评数据。你可能也发现了无论是大众点评、美团还是其他垂直平台这些点评数据都蕴含着巨大的商业价值——从口味分析、服务评价到价格敏感度每一句用户评论都是市场最真实的反馈。但问题是这些数据散落在网页上手动复制粘贴效率太低而平台通常不会提供官方的、批量的数据导出接口。这时候爬虫技术就成了我们获取这些“数据金矿”的核心工具。我最初尝试用传统的requestsBeautifulSoup组合但很快就碰壁了。现在的餐厅详情页用户评论、星级评分、推荐菜这些关键信息几乎全是前端JavaScript动态渲染出来的。你直接请求网页源码看到的只是一个空壳框架真正的数据需要通过执行页面里的JS代码等它向后台发起Ajax请求拿到数据后再填充到页面上。这就是我们常说的“动态网页”也是传统静态爬虫的噩梦。为了解决这个问题我深入对比和实战了两种主流的浏览器自动化工具老牌的Selenium和 后起之秀Playwright。我的目标很明确不仅要能稳定、高效地采集到数据还要考虑代码的维护成本、运行速度以及对目标网站造成的压力。毕竟我们做数据采集讲究的是“细水长流”而不是“暴力强拆”把人家服务器搞垮了自己也拿不到数据还可能有法律风险。这篇文章我就把自己从环境搭建、核心代码编写、到反爬应对、性能优化这一整套实战经验毫无保留地分享出来。无论你是刚入门爬虫的新手还是想寻找更优动态爬虫方案的老手相信都能从中找到可以直接“抄作业”的代码和思路。2. 工具选型Selenium vs Playwright我为什么最终选择了后者在动态爬虫领域Selenium 可以说是“开山鼻祖”拥有最广泛的社区和资料。而 Playwright 是微软在2020年推出的新秀号称是为现代Web应用而生的自动化测试框架。在餐厅点评数据采集这个具体场景下我对两者进行了全方位的对比。2.1 核心原理与架构差异Selenium的核心是WebDriver。它通过一个浏览器驱动如ChromeDriver作为中间桥梁接收我们Python脚本发来的指令如“点击这个按钮”、“获取那个元素的文本”然后驱动真实的浏览器Chrome, Firefox等去执行。你可以把它理解成一个“远程遥控器”。它的优势是兼容性极好支持几乎所有主流浏览器且经过多年发展生态非常成熟。Playwright则走了一条不同的路。它直接为 ChromiumChrome/Edge内核、Firefox和WebKitSafari内核三大浏览器引擎提供了高层API。它不需要一个独立的Driver进程而是通过CDPChrome DevTools Protocol等浏览器自带的调试协议与浏览器通信。这就好比它直接拿到了浏览器的“后台管理权限”沟通更直接效率也更高。2.2 实战性能与稳定性对比在采集餐厅列表页通常有几十上百条餐厅卡片每个卡片需要点击进入详情页再爬评论时性能差距非常明显。我用同样的逻辑打开列表页-循环获取每个餐厅链接-打开新标签页访问详情页-采集数据-关闭标签页分别用Selenium和Playwright写了两套脚本在相同的网络环境和硬件下跑分。启动速度Playwright启动一个无头浏览器实例比Selenium快近一倍。这对于需要频繁启停浏览器的任务比如分布式爬虫是巨大优势。执行速度在元素定位和操作上Playwright的API执行效率更高。例如用page.locator(‘.review-text’).all_text_contents()一次性获取所有评论文本比Selenium需要用find_elements再循环获取要快。稳定性这是让我最终倒向Playwright的关键。Selenium在等待动态元素加载时有时即使使用了WebDriverWait也会因为页面复杂的JS执行或网络波动而出现元素找不到的异常。Playwright内置了更智能的自动等待Auto-waiting机制。它对大多数操作如click,fill,text_content都内置了等待直到元素可操作、可见、稳定后才执行大大减少了需要手动编写等待逻辑的情况代码更健壮异常更少。2.3 反爬对抗能力餐厅点评平台的反爬措施一般包括检测WebDriver特征、验证鼠标移动轨迹、频繁访问限制等。Selenium早期的Selenium容易被检测因为它的window.navigator.webdriver属性在自动化环境下为true。虽然可以通过add_argument(‘–disable-blink-featuresAutomationControlled’)等参数进行一些伪装但道高一尺魔高一丈很多平台的反爬系统已经能识别更复杂的特征。PlaywrightPlaywright在这方面做了大量工作。它在启动浏览器时会尽力模拟真实用户环境使得navigator.webdriver为undefined或false并且可以更自然地模拟鼠标移动和键盘输入通过mouse.move()和keyboard.type()的精细控制使得自动化脚本的行为更像真人从而更难被识别。注意没有任何一种工具能保证100%不被反爬。我们的策略应该是“尽可能像真人”并配合合理的请求频率、IP代理池如果需要等综合手段。Playwright给了我们一个更高的起点。2.4 代码简洁性与可维护性Playwright的API设计非常现代和简洁。举个例子我们要处理一个弹窗登录Selenium 写法:from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC try: # 显式等待弹窗出现 login_frame WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, “loginIframe”)) ) driver.switch_to.frame(login_frame) # 切换到iframe driver.find_element(By.ID, “username”).send_keys(“your_username”) driver.find_element(By.ID, “password”).send_keys(“your_password”) driver.find_element(By.ID, “submitBtn”).click() driver.switch_to.default_content() # 切回主文档 except TimeoutException: print(“登录弹窗未出现”)Playwright 写法:# Playwright 会自动等待元素出现处理iframe也更优雅 login_frame page.frame_locator(“#loginIframe”) await login_frame.locator(“#username”).fill(“your_username”) await login_frame.locator(“#password”).fill(“your_password”) await login_frame.locator(“#submitBtn”).click() # 无需显式切换回主文档操作上下文清晰Playwright的frame_locator方法让处理嵌套iframe变得异常简单。而且它的异步APIasync/await天生适合处理大量并发的页面操作这在爬取多个餐厅详情页时能极大提升效率。我的结论是对于像餐厅点评数据采集这类需要处理大量动态交互、且对稳定性和效率有要求的项目Playwright 是更优的选择。它写起来更爽跑起来更快也更不容易被“踢出门”。当然Selenium在需要兼容老旧企业级应用或特定浏览器版本时仍有其不可替代的价值。3. 环境搭建与核心库安装避坑指南工欲善其事必先利其器。搭建一个干净、稳定的Python爬虫环境是第一步也是避免后续各种诡异问题的关键。3.1 Python环境与虚拟环境强烈建议使用Python 3.8 及以上版本。Playwright对Python 3.7的支持已经停止新特性也多在更高版本上。第一步创建独立的虚拟环境。这能保证你的项目依赖不会和系统或其他项目的Python包冲突。# 使用 venv (Python 3.3 内置) python -m venv venv_restaurant_spider # 激活虚拟环境 # Windows: venv_restaurant_spider\Scripts\activate # macOS/Linux: source venv_restaurant_spider/bin/activate激活后你的命令行提示符前会出现(venv_restaurant_spider)字样。3.2 安装PlaywrightPlaywright的安装分为两部分Python客户端库和浏览器二进制文件。# 安装Playwright的Python库 pip install playwright # 安装Playwright所需的浏览器Chromium, Firefox, WebKit。这一步会下载浏览器时间稍长。 playwright install chromium # 通常安装Chromium就够了它最常用也最稳定。 # 如果你想也安装Firefox和WebKit用于测试可以运行 playwright install实操心得playwright install命令默认会安装三个浏览器下载量较大约1GB。如果你确定只用Chromium就只安装它节省时间和磁盘空间。这些浏览器是Playwright专门打包的版本与你自己安装的Chrome是独立的保证了环境的一致性。3.3 可选但推荐的辅助库虽然Playwright本身就能完成解析工作locatortext_content或inner_html但搭配一个专业的HTML解析库会让数据提取更灵活。我推荐parsel它语法类似Scrapy的Selector支持XPath和CSS选择器非常强大。pip install parsel pandas # parsel用于解析pandas用于最后的数据整理和存储3.4 常见安装问题排查问题playwright install下载极慢或失败。原因网络连接问题尤其是从国外下载浏览器二进制文件。解决可以尝试设置环境变量使用国内镜像加速如果镜像源提供了Playwright的浏览器文件。更通用的方法是使用代理这里指的网络代理非技术工具但需注意公司或学校网络策略。最稳妥的方法是耐心重试或寻找离线的浏览器包进行手动配置Playwright官方文档有相关说明。问题运行脚本时报错提示找不到浏览器。原因浏览器没有安装成功或者安装路径未被正确识别。解决首先确认虚拟环境已激活然后运行playwright install chromium重新安装。安装成功后可以通过playwright --version检查。问题在Docker或某些Linux服务器上运行失败。原因浏览器运行需要一些系统依赖库。解决Playwright提供了一个命令来安装这些依赖。# Ubuntu/Debian sudo apt-get update sudo apt-get install -y libwoff1 libopus0 libwebp6 libwebpdemux2 libenchant-2-2 libgudev-1.0-0 libsecret-1-0 libhyphen0 libgdk-pixbuf2.0-0 libegl1 libgles2 libevent-2.1-7 # 或者使用Playwright自带的脚本更推荐 playwright install-deps环境搭好我们就有了挖掘数据的“矿机”和“钻头”。接下来就是设计如何操作这台矿机高效且文明地采矿了。4. 爬虫核心逻辑设计与数据提取策略采集餐厅点评数据通常是一个“由面到点层层深入”的过程。我们不能一上来就盲目地抓而是要先规划好路径和策略。4.1 目标网站结构与采集路径分析以典型的点评网站为例数据通常呈树状结构城市/商圈列表页确定地理范围。餐厅列表页获取目标区域内所有餐厅的基本信息名称、ID、链接。餐厅详情页核心目标页包含地址、电话、人均消费、评分、推荐菜等。点评列表页核心目标数据包含用户评分、评论内容、发布时间、点赞数等。通常分页加载。高效的采集路径是广度优先先快速抓取大量餐厅的链接和基础信息从列表页获取存入一个待抓取队列如CSV文件或数据库。深度抓取然后从这个队列中并发或顺序地访问每个餐厅的详情页和点评页获取完整数据。这样做的好处是即使中途在某个详情页采集失败也不会影响其他餐厅列表的获取任务可以断点续传。4.2 Playwright 异步并发模型实战Playwright 的异步API是其性能优势的关键。我们可以利用asyncio和asyncio.gather来并发处理多个页面这对于抓取成百上千个独立的餐厅详情页来说速度提升是数量级的。下面是一个并发抓取餐厅详情页的核心框架import asyncio from playwright.async_api import async_playwright import pandas as pd async def fetch_restaurant_detail(page, url, restaurant_id): 抓取单个餐厅详情页的异步函数 try: await page.goto(url, timeout60000) # 设置超时 # 等待关键元素出现确保页面加载完成 await page.wait_for_selector(‘.basic-info’, timeout10000) # 提取数据 name await page.locator(‘.shop-name’).text_content() address await page.locator(‘.address’).text_content() avg_price await page.locator(‘.price’).text_content() # ... 提取其他字段 return { ‘id’: restaurant_id, ‘name’: name.strip() if name else ‘’, ‘address’: address.strip() if address else ‘’, ‘avg_price’: avg_price.strip() if avg_price else ‘’, ‘url’: url } except Exception as e: print(f”抓取 {url} 失败: {e}”) return None # 返回None主函数里过滤掉 async def main(): # 假设我们已经有了一个餐厅链接列表 restaurant_list [ {‘id’: 1, ‘url’: ‘https://www.example.com/restaurant/1’}, {‘id’: 2, ‘url’: ‘https://www.example.com/restaurant/2’}, # ... 更多餐厅 ] async with async_playwright() as p: # 启动一个浏览器实例建议使用 headlessTrue 在生产环境 browser await p.chromium.launch(headlessFalse, slow_mo100) # slow_mo 可放慢操作便于调试 context await browser.new_context( viewport{‘width’: 1920, ‘height’: 1080}, user_agent‘Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 …’ # 设置UA ) tasks [] # 为每个餐厅创建一个抓取任务 for rest in restaurant_list: # 为每个任务创建独立的页面避免状态污染 page await context.new_page() task fetch_restaurant_detail(page, rest[‘url’], rest[‘id’]) tasks.append(task) # 并发执行所有任务 print(f”开始并发抓取 {len(tasks)} 个餐厅详情…”) results await asyncio.gather(*tasks, return_exceptionsTrue) await browser.close() # 处理结果 successful_data [r for r in results if r is not None and not isinstance(r, Exception)] failed_count len(results) - len(successful_data) print(f”抓取完成。成功: {len(successful_data)}, 失败: {failed_count}”) # 保存到CSV df pd.DataFrame(successful_data) df.to_csv(‘restaurant_details.csv’, indexFalse, encoding‘utf-8-sig’) if __name__ ‘__main__’: asyncio.run(main())代码解读与注意事项独立页面Page每个抓取任务使用await context.new_page()创建一个新的页面对象。这比在同一个页面上反复跳转 (page.goto) 更干净因为每个页面都是独立的会话和Cookie状态互不干扰。并发控制asyncio.gather会同时启动所有协程。如果餐厅数量巨大比如上万一次性并发所有任务可能会导致内存溢出或被目标网站封IP。必须引入并发控制例如使用asyncio.Semaphore来限制同时运行的任务数量。semaphore asyncio.Semaphore(10) # 限制最大并发数为10 async def fetch_with_semaphore(page, url, rid): async with semaphore: return await fetch_restaurant_detail(page, url, rid) # 然后将任务替换为 fetch_with_semaphore错误处理asyncio.gather(*tasks, return_exceptionsTrue)会将异常作为结果返回而不是直接抛出导致整个程序崩溃。这样我们可以后续统一处理成功和失败的任务。资源清理async with语句确保浏览器和上下文会被正确关闭。即使任务中途出错资源也会被释放。4.3 点评数据的分页加载与滚动加载处理点评列表通常是分页的或者更常见的是“滚动加载更多”无限滚动。Playwright 处理这两种情况都很方便。方案一处理传统分页async def fetch_reviews_by_pagination(page, base_url): all_reviews [] page_num 1 while True: # 构造分页URL例如 .../review/p{page_num} current_url f”{base_url}/review/p{page_num}” await page.goto(current_url) # 检查是否还有评论比如是否存在“暂无评论”的提示 if await page.locator(‘.no-reviews’).count() 0: break # 提取当前页评论 reviews await page.locator(‘.review-item’).all_text_contents() all_reviews.extend(reviews) # 检查是否有下一页按钮 next_button page.locator(‘a.next-page’) if await next_button.count() 0 or await next_button.get_attribute(‘disabled’): break # 点击下一页 await next_button.click() await page.wait_for_load_state(‘networkidle’) # 等待网络空闲 page_num 1 await asyncio.sleep(2) # 礼貌性等待避免请求过快 return all_reviews方案二处理滚动加载更常见async def fetch_reviews_by_scroll(page, detail_url, max_reviews200): await page.goto(detail_url) all_reviews [] last_height await page.evaluate(‘document.body.scrollHeight’) review_selector ‘.review-item’ while len(all_reviews) max_reviews: # 1. 滚动到底部 await page.evaluate(‘window.scrollTo(0, document.body.scrollHeight)’) await page.wait_for_timeout(3000) # 等待新内容加载时间可根据网络调整 # 2. 尝试获取新加载出来的评论 current_reviews await page.locator(review_selector).all_text_contents() # 3. 判断是否已加载到底内容高度不再变化或出现了“没有更多了”的提示 new_height await page.evaluate(‘document.body.scrollHeight’) if new_height last_height: # 再检查一下是否有加载完成的提示 if await page.locator(‘.loading-finished’).count() 0: break # 可能只是网络慢再等一次 await page.wait_for_timeout(5000) new_height await page.evaluate(‘document.body.scrollHeight’) if new_height last_height: break last_height new_height # 去重并添加新评论 new_items [r for r in current_reviews if r not in all_reviews] if not new_items: # 没有新内容了 break all_reviews.extend(new_items) print(f”已加载 {len(all_reviews)} 条评论”) await asyncio.sleep(1) # 控制滚动频率 return all_reviews[:max_reviews] # 返回不超过最大限制的评论核心技巧处理滚动加载时关键在于可靠地检测何时加载完成。不能只依赖固定的滚动次数或超时。上述代码采用了“高度对比法”结合“内容去重法”并预留了二次检查的机制在实践中非常稳健。page.wait_for_timeout()是显式等待用于给动态内容加载留出时间networkidle状态有时在无限滚动中并不准确。5. 高级技巧应对反爬、提升稳定性与数据清洗掌握了基本抓取方法后要让爬虫长期稳定运行还需要一些“内功心法”。5.1 请求头与浏览器指纹伪装即使使用Playwright设置合理的请求头User-Agent, Accept-Language等和浏览器上下文参数也能进一步降低被识别的风险。async def create_stealth_context(browser): # 随机选择一个常见的User-Agent user_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 …’, # … 可以准备更多 ] import random ua random.choice(user_agents) context await browser.new_context( viewport{‘width’: 1920, ‘height’: 1080}, user_agentua, locale‘zh-CN’, # 设置语言地区 timezone_id‘Asia/Shanghai’, # 可以忽略HTTPS错误某些测试环境可能需要 ignore_https_errorsFalse, # 设置额外的HTTP头 extra_http_headers{ ‘Accept’: ‘text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8’, ‘Accept-Language’: ‘zh-CN,zh;q0.9,en;q0.8’, ‘Accept-Encoding’: ‘gzip, deflate, br’, ‘DNT’: ‘1’, # Do Not Track ‘Upgrade-Insecure-Requests’: ‘1’, } ) # 还可以设置Cookie如果需要模拟登录状态 # await context.add_cookies([{‘name’: ‘sessionid’, ‘value’: ‘xxx’, ‘domain’: ‘.example.com’, ‘path’: ‘/’}]) return context5.2 智能等待与超时策略网络不稳定或目标网站响应慢是常态。合理的等待策略是爬虫健壮性的基石。page.wait_for_selector: 等待特定元素出现。这是最推荐的方式因为它直接与页面内容挂钩。page.wait_for_load_state(‘networkidle’): 等待网络空闲约500ms内无网络请求。适用于初始页面加载。page.wait_for_timeout(milliseconds): 固定时间等待。慎用只在明确知道需要等待固定时间如动画完成或作为兜底方案时使用。page.wait_for_function: 等待一个JavaScript表达式返回真值。功能强大可以等待任何自定义条件。最佳实践组合try: # 先等待页面主要框架加载完成 await page.goto(url, wait_until‘domcontentloaded’) # 不等待所有资源更快 # 然后等待我们关心的关键数据容器出现 await page.wait_for_selector(‘.review-list-container’, timeout15000) # 如果数据是异步加载的再等待一个代表加载完成的标志比如某个class的变化 await page.wait_for_function(“”” () { const container document.querySelector(‘.review-list-container’); return container !container.classList.contains(‘loading’); } “””, timeout10000) except TimeoutError: print(f”等待元素超时: {url}”) # 可以在这里进行重试或记录错误5.3 数据清洗与存储爬下来的原始数据往往是脏的包含多余的空格、HTML标签、特殊字符等。import re from parsel import Selector def clean_review_text(raw_html): 清洗评论HTML提取纯文本并处理换行、空格 if not raw_html: return “” # 1. 使用parsel解析 sel Selector(textraw_html) # 移除脚本、样式标签 sel.remove_namespaces() for tag in sel.xpath(‘//script | //style’): tag.drop() # 获取文本并用换行符连接块级元素 text sel.xpath(‘normalize-space(.)’).get() # 2. 正则处理合并多个空格和换行 text re.sub(r‘\s’, ‘ ‘, text).strip() # 3. 处理常见的HTML实体如果parsel没处理干净 import html text html.unescape(text) return text def parse_review_item(review_element_handle): 从一个点评元素句柄中提取结构化数据 # 假设 review_element_handle 是一个 Playwright 的 ElementHandle # 我们可以用 evaluate 方法在这个元素的上下文中执行JS data await review_element_handle.evaluate(“””(element) { const user element.querySelector(‘.user-name’)?.innerText.trim(); const rating element.querySelector(‘.rating’)?.getAttribute(‘class’).match(/star-(\d)/)?.[1]; const text element.querySelector(‘.review-text’)?.innerHTML; // 保留HTML用于清洗 const time element.querySelector(‘.time’)?.getAttribute(‘title’) || element.querySelector(‘.time’)?.innerText; const useful element.querySelector(‘.useful-count’)?.innerText; return { user, rating, text, time, useful }; }“””) # 清洗评论正文 data[‘cleaned_text’] clean_review_text(data.get(‘text’)) del data[‘text’] # 移除原始HTML return data存储建议小规模/调试CSV文件 (pandas.to_csv) 最方便。中大规模/结构化SQLite或MySQL数据库。为每类数据餐厅信息、点评建立单独的表并建立索引如餐厅ID、点评时间方便后续查询分析。增量更新在数据库表中增加crawl_time字段记录抓取时间。下次抓取时可以只抓取新出现的点评通过比较点评ID或发布时间。6. 实战中遇到的典型问题与解决方案实录在长达数周的实战抓取中我遇到了各种各样的问题。这里把最有代表性的几个“坑”和我的填坑方法记录下来。6.1 问题一页面元素偶尔定位不到但手动刷新又正常现象脚本运行时page.locator(‘.review-list’)有时会超时提示找不到元素。但手动用浏览器打开同一个链接元素明明存在。排查检查选择器是否正确。用浏览器的开发者工具确认.review-list在目标页面中唯一且稳定。在超时后用page.screenshot(path‘debug.png’)截屏发现页面是空白的或者停留在上一个页面。检查网络请求。在脚本中加入page.on(‘request’, lambda req: print(req.url))和page.on(‘response’, lambda res: print(res.status, res.url))监听发现目标页面的主文档请求返回了403 Forbidden或404。根因IP或会话被临时限制。网站检测到短时间内来自同一IP的规律性请求触发了风控。解决方案降低请求频率在关键操作如翻页、打开新详情页后增加随机延迟await asyncio.sleep(random.uniform(2, 5))。使用代理IP轮询为Playwright的BrowserContext设置代理。注意需要找可靠的HTTP/HTTPS代理服务。context await browser.new_context( proxy{‘server’: ‘http://your-proxy-ip:port’} )模拟更真实的行为在访问间隙随机滚动页面、移动鼠标page.mouse.move(x, y)。维护会话状态如果网站需要登录尽量复用同一个已登录的Context而不是每次创建新匿名会话。6.2 问题二无限滚动页面有时会漏掉中间的部分评论现象使用滚动加载逻辑最终获取的评论数比实际页面显示的少。排查对比抓取结果和手动滚动看到的内容发现丢失的评论集中在某几次滚动之后。分析代码逻辑发现是“高度对比法”在快速滚动时失效。因为滚动触发加载需要时间如果wait_for_timeout(3000)时间不够新内容还没加载完脚本就误判为高度没变化结束了循环。解决方案采用“内容锚点对比法”代替单纯的高度对比。async def fetch_reviews_by_scroll_improved(page, max_reviews300): all_reviews [] last_count 0 stable_count 0 # 连续次数没有新内容的计数器 while len(all_reviews) max_reviews and stable_count 3: # 连续3次无新内容才停止 await page.evaluate(‘window.scrollTo(0, document.body.scrollHeight)’) # 等待一个代表“新评论项”的元素出现而不是固定时间 try: # 假设新加载的评论会有特定的loading类加载完成后消失 await page.wait_for_selector(‘.review-item:not(.loading)’, timeout10000, state‘attached’) except: pass # 可能没有loading态忽略 # 获取当前所有评论的“唯一标识”比如每条评论的ID current_review_ids await page.evaluate(“””() { const items Array.from(document.querySelectorAll(‘.review-item’)); return items.map(item item.getAttribute(‘data-review-id’)).filter(id id); }“””) if len(current_review_ids) last_count: stable_count 1 print(f”滚动后评论数未增加 ({stable_count}/3)”) if stable_count 3: break else: stable_count 0 # 有新内容重置计数器 print(f”发现新评论总数: {len(current_review_ids)}”) last_count len(current_review_ids) # 根据ID去提取详细内容… await asyncio.sleep(random.uniform(1, 2)) # 随机间隔 # 最后一次性提取所有评论内容 review_elements await page.locator(‘.review-item’).all() for elem in review_elements: review_data await parse_review_item(elem) # 复用之前的解析函数 all_reviews.append(review_data) return all_reviews[:max_reviews]这个方法的核心是监测内容数量的变化并设置一个“稳定阈值”只有当连续几次滚动都无法加载新内容时才认为加载完毕比监测高度更可靠。6.3 问题三数据中包含大量乱码或特殊字符现象保存到CSV或数据库中的中文文本出现乱码或者包含\u2028行分隔符等不可见字符导致后续处理出错。解决方案编码统一确保从网页提取文本时Playwright使用正确的编码。Playwright默认会处理编码但为了保险可以在page.goto()前后检查page.content()的编码。保存CSV时指定encoding‘utf-8-sig’带BOM的UTF-8兼容性最好。字符清洗在数据清洗函数中加入对特殊Unicode字符的处理。def clean_text(text): if not text: return “” # 替换各种换行符和空白字符为普通空格或换行 import re # 替换 Unicode 行/段分隔符 text re.sub(r‘[\u2028\u2029]’, ‘\n’, text) # 替换零宽空格等 text re.sub(r‘[\u200b\u200c\u200d\ufeff]’, ‘’, text) # 合并多余空白 text re.sub(r‘\s’, ‘ ‘, text).strip() return text数据库设置如果存入MySQL确保数据库、表和连接都使用utf8mb4字符集以支持所有Emoji表情。6.4 问题四脚本运行一段时间后内存占用越来越高最终崩溃现象长时间运行并发爬虫后Python进程内存持续增长。排查使用内存分析工具如tracemalloc发现主要是Playwright的Page对象和JS执行上下文没有被及时释放。解决方案严格管理Page对象的生命周期。async def fetch_one(page, url): try: # … 抓取逻辑 … return data finally: # 无论成功与否都关闭这个页面 await page.close() # 在主函数中确保每个任务完成后其对应的Page被关闭。 # 使用 asyncio.create_task 和 asyncio.wait 可以更精细地控制。 tasks [asyncio.create_task(fetch_one(await context.new_page(), url)) for url in urls] done, pending await asyncio.wait(tasks, return_whenasyncio.ALL_COMPLETED) # 清理pending的任务如果有 for task in pending: task.cancel()同时定期重启Browser Context也有助于释放累积的内存碎片。可以设定每抓取500个页面后关闭当前Context并新建一个。爬虫开发是一个不断与目标网站“斗智斗勇”的过程没有一劳永逸的方案。核心原则是尊重网站规则遵守robots.txt控制速率模拟真人行为做好错误处理和日志记录。通过Playwright我们拥有了更强大的模拟能力但随之而来的也是更重的责任。希望这篇从实战中总结出的经验能帮助你高效、合规地获取所需数据为你的数据分析项目打下坚实的基础。记住技术是工具如何使用它取决于你的智慧和操守。