先给结论:动态页面本质上就两种列表加载方式,一种是滚动到底触发加载,一种是点按钮触发下一页。这两种形态占掉日常所见动态列表的九成以上。你只要把这两套模板练熟,往后碰到任何需要交互才能翻页的网站,基本都是往这两个方向上套,区别只是选择器不同、等待条件不同而已。这篇笔记我按自己的实战习惯来写,不是帮你把 Playwright 文档再抄一遍,而是把处理动态列表时真正用得上的套路捋出来:怎么判断页面加载完了、怎么避免重复提取、怎么判断已经翻到最后一页、什么时候该放弃 Playwright 去直接抓接口,这些才是动态爬虫真正花时间的地方。1. 动态列表的根本认知:页面不是死的1.1 为什么 requests 拿不到你要的数据很多新手会先遇到一个诡异的现象:用 requests 请求一个列表页,返回的 HTML 里什么都有,唯独没有列表数据。这不是你写错了,而是页面本身就是空壳子——数据是页面加载完成后,由 JavaScript 再向后端发请求,拿到响应后渲染到 DOM 里的。服务端返回的 HTML 只有一个框架和一堆 script 标签。在爬虫的语境里,这套机制叫动态渲染。网站这么做的原因很多,有的是为了前后端分离方便维护,有的是为了用户体验更好,也有的是为了增加自动化抓取的难度。但你要记住一个本质结论:只要是动态页面,你看到的列表数据一定不在最初的响应里,必须在浏览器里等一等、动一动、数据才出现。这就解释了为什么要上 Playwright。requests 相当于你站在店门口往里喊,问老板菜单上有什么,老板没理你,因为菜单是店员(JavaScript)在店铺里用投影仪(DOM 渲染)放出来的。Playwright 不一样,它是直接把一个真人(无头浏览器)派进店里坐下,菜单放出来他就看得见,点哪桌的菜也清楚。1.2 动态列表的两种典型形态我接触过的动态列表,九成以上跑不出这两种形态:滚动加载:也叫无限滚动。页面先渲染第一批数据,你滚到接近底部时,页面检测到滚动事件,自动向接口请求下一批,插入到列表末尾,于是你继续滚、它继续加载,永远没有尽头,直到所有数据加载完或者后端起限制。点击翻页:页面渲染当前页数据,底部有按钮——可能是一排数字分页(1、2、3、4),也可能是一个“加载更多”“下一页”按钮。必须点击按钮,浏览器才会请求并渲染新的数据。注意,点击翻页里有个变种,就是按钮不换内容也不关掉,而是每次点击都在底部追加一批数据,这种看起来和滚动加载很像,但触发方式是点击。这里的关键区别是触发方式不同。滚动加载靠监听 scroll 事件触发网络请求,点击翻页靠用户主动点击或键盘操作触发。你的爬虫代码必须模拟对应的动作,页面才肯继续喂数据。理解了这一点,后面所有代码逻辑就顺理成章了:滚动加载的套路是“模拟滚动→等待渲染→提取数据→判断是否到底”,点击翻页的套路是“点击按钮→等待渲染→提取数据→判断是否到最后一页”。通用套路的核心,就是把这四个步骤用循环串起来。2. 准备工作:Playwright 环境与最小可用模板2.1 安装与启动先说环境。Playwright 目前的稳定用法仍然是同步 API 配合 with 语句,代码结构清晰,调试也直观。安装就一句话:pip install playwright装完还需要装浏览器内核:playwright install chromium如果你的运行环境是服务器,可能还需要手动装一些系统依赖库,官方命令是playwright install-deps,建议在 Linux 服务器上部署前提前跑一遍。我第一次在纯净容器里跑就吃了这个亏,浏览器起不来,报错信息里全是缺 so 文件。启动浏览器的结构非常固定,以后所有脚本基本都长这样:from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("https://目标站点列表页") # ... 你的操作逻辑 browser.close()这里headless=False是带界面运行,调试阶段强烈建议打开看真实效果。确认逻辑没问题后再改成headless=True,在服务器上无头跑。带界面跑还有一个好处:你能直观看到滚动加载时页面的变化节奏,有利于后面调整等待策略。2.2 三个必须提前掌握的核心操作写动态列表爬虫之前,先把这三个操作刻进脑子里,后面全在用:1. 等待元素出现page.wait_for_selector("css选择器或text选择器", timeout=10000)这个方法会阻塞直到元素出现,如果超时会抛异常。它的意义在于,动态加载是需要时间的,你提取数据前必须确保目标元素已经在 DOM 里了。10 秒只是常用默认值,实际跑的时候我会根据网络状况调整到 15 秒或 5 秒。2. 执行 JS 滚动page.evaluate("window.scrollTo(0, document.body.scrollHeight)")这一行把页面滚到底部。对无限滚动页面来说,这个动作会触发加载逻辑。有些网站监听的是某个容器元素的 scroll 事件而不是 window,此时把document.body替换成对应容器的选择器即可:page.evaluate("document.querySelector('.列表容器选择器').scrollTo(0, 100000)")3. 提取数据items = page.locator("列表项选择器").all() for item in items: title = item.locator("标题选择器").inner_text()Playwright 的 locator 体系比 Selenium 更严密,支持 CSS、text、xpath 等。实际项目里 CSS 选择器最常用,text=选择器在处理“下一页”这种文字按钮时特别好用。2.3 一个最小示例:先跑通再谈套路我不建议直接上完整项目,先写个 20 行的最小示例验证环境没问题:from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("https://example.com/list") # 找一个真实动态列表页 page.wait_for_selector(".list-item") count = page.locator(".list-item").count() print(f"当前页面加载了 {count} 条数据") browser.close()这段代码的核心作用有两点:一是验证 Playwright 环境没问题,二是验证页面上列表项的选择器是否正确。选择器对了,真实项目里至少省掉一半的排查时间。3. 滚动加载:无限列表的通用处理套路3.1 套路的核心:循环滚、等到位、再提取、看终止滚动加载页面的处理,本质上是一个四步循环:滚动到底部触发加载等待新数据渲染完成提取这批新增数据或全量重提判断是否已经滚到没有新数据的程度如果你对循环还不太敏感,我拆开解释一遍。第一步的滚动动作必须真实发生,页面才会向接口发请求;第二步的等待最关键,很多人漏数据就是因为滚完立刻提取,这时候 DOM 还没来得及更新;第三步的提取有两种策略,后面细说;第四步是滚动加载的难点,页面没有明确的“页数”概念,你必须通过某种信号判断“加载完了”。下面给一个通用模板,适配多数无限滚动列表:from playwright.sync_api import sync_playwright import time def scroll_and_extract(url, item_selector, max_scrolls=50): """通用滚动加载提取 item_selector: 列表项选择器 max_scrolls: 最大滚动次数,防止死循环 """ results = [] with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() # 可选:拦截图片,加速加载 page.route("**/*.png", lambda route: route.abort()) page.route("**/*.jpg", lambda route: route.abort()) page.goto(url, timeout=60000) page.wait_for_selector(item_selector, timeout=10000) last_height = 0 scroll_count = 0 while scroll_count < max_scrolls: # 1. 滚动到底部 page.evaluate("window.scrollTo(0, document.body.scrollHeight)") scroll_count += 1 # 2. 等待页面高度变化或新元素出现 try: page.wait_for_function( "() => document.body.scrollHeight > arguments[0]", last_height, timeout=5000 ) except Exception: # 高度不再变化,认为到底了 break # 3. 提取当前所有项 items = page.locator(item_selector).all() titles = [item.inner_text() for item in items] results = titles # 全量覆盖,简单粗暴 # 4. 更新高度,进入下一轮 last_height = page.evaluate("document.body.scrollHeight") time.sleep(1) browser.close() return results这段代码就是整套滚动加载的核心骨架。接下来我把几个细节展开讲。3.2 滚动距离与速度不是随意选的滚动加载不是滚得越深越好。很多页面每次滚动只加载一批数据,你一步滚到底部,看到的效果是页面疯狂“跳动”——因为滚动事件触发得太快,请求可能只发出去一部分。我在实际项目里踩过这个坑:一次性滚到底,结果只拿到前两三批数据,后面的数据页面根本没来得及请求。所以实际写的时候,我会把滚动改成渐进式:def scroll_gradually(page, step=800, delay=0.5): """每次滚动固定步长,给页面缓冲时间""" current = 0 max_height = page.evaluate("document.body.scrollHeight") while current < max_height: current += step page.evaluate(f"window.scrollTo(0, {current})") time.sleep(delay) max_height = page.evaluate("document.body.scrollHeight")步长选 800 是因为在很多屏幕尺寸下,800 像素大约是视口高度的 80%,不会触发浏览器的“快滚到底”加速行为,也不会因为步子太小导致循环次数太多。delay 选 0.5 秒是给网络请求留时间。注意这俩参数不是定死的,页面加载快就用大步长、短延迟,加载慢就反过来。另一种做法是滚动一步、等一批数据出现、再滚下一步,这种最稳,但速度慢。我通常用在要求高完整性的场景,比如爬全量商品信息。3.3 终止条件的本质判断滚动加载最大的坑是“不知道什么时候结束”。前面模板里我用的是比较页面高度——当高度不再变化认为到底。这个办法对大多数页面有效,但有几个例外:页面底部有推荐区块、页脚,滚动不会再加载新数据,但高度仍然存在。此时你会停不下来,一直循环直到 max_scrolls 超限。部分页面的滚动容器不是 document.body,而是某个内嵌 div,此时document.body.scrollHeight永远是同一个值,判断失效。针对第一种情况,我会检查最后两次滚动之间,列表项的个数是否还在增加。如果连续滚动多次,数量不再变化,就认为到底:prev_count = 0 stable_rounds = 0 while scroll_count < max_scrolls: page.evaluate("window.scrollTo(0, document.body.scrollHeight)") time.sleep(1) current_count = page.locator(item_selector).count() if current_count == prev_count: stable_rounds += 1 if stable_rounds >= 3: break else: stable_rounds = 0 prev_count = current_count连续三次数量没变化,基本可以断定到底了。这个阈值我调过很多次,三次是比较可靠的平衡点——网络慢的时候偶尔一两次没加载出来,三次还没变化那大概率是真没了。针对第二种内嵌容器的情况,改动也只是把滚动目标和高度判断都换成容器元素:page.evaluate("document.querySelector('.feed-list').scrollTo(0, 1000000)")3.4 数据提取的两种策略滚动加载场景下,数据提取有两个流派,各有适用场景:全量覆盖式:每次滚动后重新提取页面所有列表项,直接覆盖结果列表。优点是逻辑简单,不会重复,因为每次都是全量;缺点是如果单轮提取耗时较长,滚动后页面可能又插入了新数据,最后会有轻微遗漏。适合列表项数量不夸张(几百条内)的场景。增量追加式:每次都重新提取,然后按某个唯一标识(比如标题、链接)去重后追加到结果。优点是速度快,适合上万条数据的场景;缺点是去重逻辑要写好,否则容易重复。实际操作中我多数用增量追加,因为滚动加载的列表动辄几十甚至上百次请求,全量覆盖的时间成本太高。示例:seen = set() all_data = [] while ...: # 滚动、等待 items = page.locator(item_selector).all() for item in items: title = item.locator(".title").inner_text() if title in seen: continue seen.add(title) all_data.append(title)这里用标题作为唯一标识,前提是标题不会重复。如果列表有重复标题的可能,改用详情页链接或者数据 ID 更稳妥。4. 点击翻页:分页与“加载更多”的通用套路4.1 分页按钮的套路:点、等、提、判点击翻页比滚动加载的终止条件更直观,因为页面给了你“最后一页”的信号。但操作步骤本身仍然要写对。通用的分页翻页模板长这样:def paginate_click(url, item_selector, next_btn_selector, max_pages=10): """通用点击翻页提取 next_btn_selector: 下一页按钮的选择器 """ all_data = [] with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto(url, timeout=60000) page.wait_for_selector(item_selector, timeout=10000) for page_num in range(1, max_pages + 1): print(f"正在提取第 {page_num} 页") # 1. 提取当前页数据 items = page.locator(item_selector).all() for item in items: all_data.append(item.inner_text()) # 2. 判断下一页按钮是否存在且可点击 next_btn = page.locator(next_btn_selector) if next_btn.count() == 0 or not next_btn.is_enabled(): print("没有下一页按钮了,结束") break # 3. 点击下一页 next_btn.click() # 4. 等待页面刷新(等待旧元素消失或新元素出现) try: page.wait_for_selector(item_selector, timeout=10000) except Exception: print(f"第 {page_num} 页点击后没有加载出新元素") break # 可选:等待 URL 变化或页码变化 time.sleep(0.5) browser.close() return all_data这段代码有几个地方需要特别注意,我逐个说一下。第一,提取数据和点击操作的顺序不能反。必须先提取当前页数据再点击下一页,否则你会漏掉当前页。这个顺序听起来是废话,但我见过有人把点击写在提取前面的脚本,一跑就翻到第二页才开始提取,第一页数据直接丢了。第二,点击后不能立刻提取,必须等待页面刷新。这里有一个判断刷新完成的技巧——等待当前页的某个标志性元素消失,或者等待页码数字变化。可以结合wait_for_selector的state参数:# 等待旧元素进入 detached 状态(从 DOM 中移除) try: page.wait_for_selector(".pagination .current", state="detached", timeout=10000) except Exception: pass # 如果当前页码元素没有消失,可能页面是局部刷新有的站点翻页是整页跳转,整个 DOM 都会变化,此时上面的代码用来等待旧 DOM 卸载,它消失说明新页面已经在渲染了。有的站点是局部刷新,只换列表区域,分页按钮不变,这段时间等待条件要换成“列表区域内容变化”才行。第三,页码不是数字而是“加载更多”按钮时,点击后按钮不会消失,数据会继续追加。这种情况的终止条件不能靠按钮消失判断,要靠数据数量是否变化。4.2 “加载更多”按钮:披着点击外衣的滚动加载很多站点没有数字分页,只有一个“加载更多”按钮,点击一次加载一批新数据,按钮还在原地。这种形态我习惯把它当作滚动加载来处理,只不过触发动作从滚动变为点击。模板如下:def click_load_more(url, item_selector, load_more_selector, max_clicks=50): all_data = [] with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto(url, timeout=60000) page.wait_for_selector(item_selector, timeout=10000) prev_count = 0 stable_rounds = 0 for i in range(max_clicks): # 提取当前数据 items = page.locator(item_selector).all() current_count = len(items) # 判断是否还在变 if current_count == prev_count: stable_rounds += 1 if stable_rounds >= 2: print("加载更多已到头") break else: stable_rounds = 0 prev_count = current_count # 汇总数据 for item in items: all_data.append(item.inner_text()) # 点击加载更多 btn = page.locator(load_more_selector) if btn.count() == 0 or not btn.is_visible(): print("加载更多按钮不存在或不可见") break btn.click() # 等待新数据渲染 time.sleep(1) browser.close() return all_data注意这里用“连续两轮数量不变”作为终止条件,因为按钮点击后数据是追加的,只能在数量上做文章。有些页面加载更多到最后一页后按钮会被移除,这时判断btn.count() == 0会直接跳出;如果按钮还在但点击无效,就是数量不变的情况。4.3 点击翻页时最容易踩的坑点击翻页里最容易翻车的场景有三个,我单独拎出来:坑一:按钮明明定位到了,click 却没有任何反应。原因通常是按钮被另一个元素遮挡,或者是按钮绑定的事件在某个初始化完成后才生效。解决方法有两个:一是改用btn.click(force=True)强制点击,二是用 JavaScript 直接触发:page.evaluate("document.querySelector('按钮选择器').click()")前者适合遮挡不严重的情况,后者属于绕过式触发,能解决大部分“假点击”。坑二:点击后页面会打开新标签页。有些页面的“下一页”是target="_blank"外链,直接点击会开新标签,Playwright 默认不会自动切换过去。处理方式是监听新页面事件:with page.expect_popup() as popup_info: next_btn.click() new_page = popup_info.value new_page.wait_for_load_state() # 然后在 new_page 上操作不过说实话,遇到这种页面我通常先看看 URL 规律——很多外链下一页其实就是个分页 URL,直接拼参数用 requests 反而更简单。坑三:点击翻页时登录态丢了。某些站点的分页接口需要登录凭证,页面跳转时 cookie 或 token 校验不过去。排查方式是观察点击翻页后的响应状态,用 Playwright 的 network 事件:page.on("response", lambda resp: print(resp.url, resp.status))如果点击后看到某个接口返回 401 或 403,大概率就是登录态问题。解决方式是把登录流程前置,先登录成功后再进列表页,而不是直接访问列表页。5. 机制与选型:什么时候该用 Playwright,什么时候该绕开5.1 两种加载方式的对比速查用表格给你整理一个对比,方便以后遇到页面时直接判断用哪套模板:对比维度滚动加载点击翻页(分页按钮)点击翻页(加载更多)触发方式滚动到特定位置点击数字/下一页按钮点击加载更多按钮终止条件滚动高度不再变化/列表项数量连续不变下一页按钮消失/禁用按钮消失/数量连续不变提取速度较慢,受滚动节奏限制较快,翻页间隔可控较慢,取决于加载速度稳定性中,容易滚过头高,分页逻辑清晰中,按钮状态判断需要小心常用等待策略wait_for_function 高度变化等待旧元素 detached等待列表项数量增加数据完整性高(理论上可滚到底)高(页数明确)高(直到按钮消失)我个人的经验是,遇到滚动加载先看接口,遇到点击翻页优先用 Playwright 硬跑。原因很简单:滚动加载往往背后是一个连续翻页的接口,接口参数通常就 page 或 offset 两个数字,构造起来很容易;而点击翻页虽然也有接口,但不少后端校验了请求头、Referer、加密参数,直接用 requests 反而不如 Playwright 模拟点击稳定。这不是绝对的,但作为快速选型的参考很有效。5.2 什么时候应该放弃 Playwright 转抓接口Playwright 不是银弹,在两种场景下我会主动放弃它:第一个场景是接口结构极其简单时。打开开发者工具,滚动列表,如果发现每一次滚动都是同一个接口,URL 参数里就一个 page 数字或者一个 offset 偏移量,且响应是干净的 JSON,直接用 requests 循环请求接口,速度至少是 Playwright 的十倍,还不用处理浏览器资源。这时候硬用 Playwright 属于给自己加戏。判断方式很简单:滚动页面看 Network 面板,如果请求 URL 规律清晰、没有特殊加密参数,先用 requests 试试。能通就直接抓接口,不折腾。第二个场景是页面极其复杂,滚动加载还嵌套了多级 iframe、Shadow DOM。这种页面用 Playwright 写选择器会非常痛苦,一步步穿透层级不仅代码冗长,而且页面一改版就崩。相比之下,直接分析接口往往更省事。不过注意,如果接口响应是加密文本或者需要特殊签名,那就还是老老实实回归 Playwright,至少它不跟你玩加密那一套。5.3 Playwright 与 Selenium 的选型差异既然你已经学到 Playwright 了,顺便把和 Selenium 的差异说清楚。两者都能做动态页面爬取,但体验差距很明显:安装体验:Playwright 一条命令装完能跑,自带浏览器内核;Selenium 需要额外下载对应版本驱动,还得手动配路径,版本一不匹配就报错。API 设计:Playwright 的 locator 体系明显更现代,支持自动等待;Selenium 的 find_element 系列写完经常要手动 sleep 等渲染。调试能力:Playwright 自带 codegen 录制功能和 trace 回放,定位选择器时可以直接录制操作生成代码,这在 Selenium 里没有对应级别的工具。稳定性:Playwright 的 auto-waiting 机制很大程度上避免了元素未出现的竞态问题。不过 Selenium 在一点上仍有优势:老项目生态成熟,网上资料多,部分浏览器自动化场景(如既有系统兼容)只能用它。新项目我基本只用 Playwright。6. 常见问题与排查技巧实录最后集中整理我在实际项目中踩过的坑和排查方法,按出现频率排序,遇到问题直接来对号入座。6.1 元素定位到了,点击却没有反应现象:代码执行到btn.click()不报错,程序也不终止,但页面就是没有任何变化。排查步骤:在带界面模式下看按钮是否真的被点击了(有没有高亮或涟漪效果)。检查按钮是否被遮罩层遮挡,用btn.is_visible()验证。查看 Network 面板,确认点击后是否发出了请求。解决方案:先试force=True,再试 JS 点击。如果 JS 点击也没有反应,有两种可能——按钮绑定的监听事件还没完成初始化,等待时间加长;或者按钮不是真的翻页按钮,而是某个弹出层的触发点。常见于页面里其实有两个“下一页”按钮,一个隐藏在弹窗里。定位时选择器写得要具体,把父容器也带上。6.2 wait_for_selector 超时,页面一直卡在加载状态现象:wait_for_selector抛 TimeoutError,但页面肉眼可见已经渲染了部分数据。原因:选择器写得和实际 DOM 结构不匹配,或者目标元素渲染完成后又被页面 JavaScript 篡改了(比如先渲染数据再套一个壳子)。解决方案:换选择器,优先用稳定的 class 名或 data 属性。不要用那些带数字后缀的动态 class(很多框架生成的类名每次刷新都会变)。另外可以先用page.content()打印当前 HTML,肉眼确认目标元素真实存在后,再据此改选择器。6.3 提取到的数据重复现象:循环跑完后,结果里有大量一模一样的条目。原因:增量追加时去重逻辑失效,或者滚动/点击发生的速度太快,页面没及时加载新数据,导致同一批数据被提取了多次。解决方案:先用标题加链接一起作为唯一键,如果是纯文本列表没有链接,用“序号 + 标题”组合去重。另外检查一下循环里的等待时间,太短的话加长到 1 秒以上。6.4 无头模式下页面加载速度异常慢现象:headless=True 时,原本带界面跑得好好的页面,无头模式下一片空白,或者等了很久才渲染。原因:部分网站检测到无头浏览器的指纹特征会返回降级页面。这不是 Playwright 的问题,是目标站点识别了 navigator.webdriver 之类的特征。解决方案:加启动参数规避,常见做法是去掉部分 webdriver 特征:browser = p.chromium.launch( headless=True, args=[ "--disable-blink-features=AutomationControlled", "--disable-dev-shm-usage", ] )加--disable-dev-shm-usage能解决一部分容器环境下渲染内存不足的问题。如果仍然不行,退一步用 headless=False 在后台跑。6.5 页面弹窗、广告盖住了按钮现象:页面正常加载,但弹了一个新用户引导的遮罩层,click()报错说元素不可点击,因为它被遮挡了。解决方案:先关弹窗再点按钮,弹窗关闭按钮通常可以强杀:try: page.locator(".close-btn").click(timeout=3000) except Exception: pass如果弹窗没有显式关闭按钮,可以直接按 ESC 键,部分弹窗支持键盘关闭。再不行就用page.evaluate把遮罩层的 display 设为 none。page.evaluate("document.querySelector('.mask').style.display = 'none'")6.6 翻页后页码没变,实际跳到了别的页现象:点击 “下一页”按钮后,URL 变了但页码数字没变,或者提取到的数据是上一页的。原因:页面的翻页是按表单提交实现的,点击按钮后其实发了一次 POST 请求,页面 URL 不会变,DOM 局部刷新。此时不能用“等待旧元素消失”来判断刷新完毕,因为分页区域可能根本没变化。解决方案:改用“等待列表区域第一个条目变化”的方式。实现可以用wait_for_function:page.wait_for_function( """(old_title) => { const first = document.querySelector('.list-item .title'); return first && first.innerText !== old_title; }""", first_title_at_current_page, timeout=10000 )实践中这个判断往往比等待“某个元素消失”更可靠,因为局部刷新的页面里,很多元素是复用的,不会消失。6.7 网络请求被目标站点的频率限制拦截现象:滚动加载跑到中途,某一次滚动后页面不再加载新数据,重启浏览器发现前几十次都很正常,后面无论如何都不出数据。原因:触发频率限制,目标站点对单位时间内的列表接口请求数做了限制,短时间内高频滚动就会被封。解决方案:控制滚动速度,每两次滚动之间增加随机间隔。时间用 0.8 到 1.5 秒之间的随机浮点数,避免请求节奏规律化,也让行为更接近真人浏览。另外,可以在请求中加入 Cookie 保持会话统一,而不是每次重新开启浏览器,否则身份特征变化更容易触发风控。实际项目中我的取舍习惯这篇写到这里已经很长了,最后再说一点工作流层面的个人习惯。我接到一个动态列表需求时,不是上来就写 Playwright 脚本,而是先花五分钟干三件事:打开开发者工具看 Network 里有没有直出的 JSON 接口;观察页面是滚到底加载还是点按钮才加载;确认有没有明显的分页参数规律。这三件事决定了整个方案是走接口直取还是走浏览器自动化。如果接口直取可行,我绝不会为了“显得高级”而硬套 Playwright;如果接口加密严重,Playwright 就是最省心的兜底手段。这两种思维要并存,而不是一味迷信某个工具。另外,Playwright 脚本写完后,我建议你花点时间把循环里的 sleep 优化成 wait_for 系列方法,让等待时间自适应页面加载速度。这样做的好处有两个——脚本更稳,不至于因为某次网络慢而误判终止;速度也更优,页面快的时候不等多余时间,页面慢的时候不提前抢跑。动态页面难的不是技术本身,而是对“页面什么时候算加载完”的判断。这个感觉只能靠多看多跑积累,没有捷径。你把滚动加载和点击翻页这两套模板多跑几个真实网站,自然就摸到规律了。
先给结论:动态页面本质上就两种列表加载方式,一种是滚动到底触发加载,一种是点按钮触发下一页。这两种形态占掉日常所见动态列表的九成以上。你只要把这两套模板练熟,往后碰到任何需要交互才能翻页的网站,基本都是往这两个方向上套,区别只是选择器不同、等待条件不同而已。这篇笔记我按自己的实战习惯来写,不是帮你把 Playwright 文档再抄一遍,而是把处理动态列表时真正用得上的套路捋出来:怎么判断页面加载完了、怎么避免重复提取、怎么判断已经翻到最后一页、什么时候该放弃 Playwright 去直接抓接口,这些才是动态爬虫真正花时间的地方。1. 动态列表的根本认知:页面不是死的1.1 为什么 requests 拿不到你要的数据很多新手会先遇到一个诡异的现象:用 requests 请求一个列表页,返回的 HTML 里什么都有,唯独没有列表数据。这不是你写错了,而是页面本身就是空壳子——数据是页面加载完成后,由 JavaScript 再向后端发请求,拿到响应后渲染到 DOM 里的。服务端返回的 HTML 只有一个框架和一堆 script 标签。在爬虫的语境里,这套机制叫动态渲染。网站这么做的原因很多,有的是为了前后端分离方便维护,有的是为了用户体验更好,也有的是为了增加自动化抓取的难度。但你要记住一个本质结论:只要是动态页面,你看到的列表数据一定不在最初的响应里,必须在浏览器里等一等、动一动、数据才出现。这就解释了为什么要上 Playwright。requests 相当于你站在店门口往里喊,问老板菜单上有什么,老板没理你,因为菜单是店员(JavaScript)在店铺里用投影仪(DOM 渲染)放出来的。Playwright 不一样,它是直接把一个真人(无头浏览器)派进店里坐下,菜单放出来他就看得见,点哪桌的菜也清楚。1.2 动态列表的两种典型形态我接触过的动态列表,九成以上跑不出这两种形态:滚动加载:也叫无限滚动。页面先渲染第一批数据,你滚到接近底部时,页面检测到滚动事件,自动向接口请求下一批,插入到列表末尾,于是你继续滚、它继续加载,永远没有尽头,直到所有数据加载完或者后端起限制。点击翻页:页面渲染当前页数据,底部有按钮——可能是一排数字分页(1、2、3、4),也可能是一个“加载更多”“下一页”按钮。必须点击按钮,浏览器才会请求并渲染新的数据。注意,点击翻页里有个变种,就是按钮不换内容也不关掉,而是每次点击都在底部追加一批数据,这种看起来和滚动加载很像,但触发方式是点击。这里的关键区别是触发方式不同。滚动加载靠监听 scroll 事件触发网络请求,点击翻页靠用户主动点击或键盘操作触发。你的爬虫代码必须模拟对应的动作,页面才肯继续喂数据。理解了这一点,后面所有代码逻辑就顺理成章了:滚动加载的套路是“模拟滚动→等待渲染→提取数据→判断是否到底”,点击翻页的套路是“点击按钮→等待渲染→提取数据→判断是否到最后一页”。通用套路的核心,就是把这四个步骤用循环串起来。2. 准备工作:Playwright 环境与最小可用模板2.1 安装与启动先说环境。Playwright 目前的稳定用法仍然是同步 API 配合 with 语句,代码结构清晰,调试也直观。安装就一句话:pip install playwright装完还需要装浏览器内核:playwright install chromium如果你的运行环境是服务器,可能还需要手动装一些系统依赖库,官方命令是playwright install-deps,建议在 Linux 服务器上部署前提前跑一遍。我第一次在纯净容器里跑就吃了这个亏,浏览器起不来,报错信息里全是缺 so 文件。启动浏览器的结构非常固定,以后所有脚本基本都长这样:from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("https://目标站点列表页") # ... 你的操作逻辑 browser.close()这里headless=False是带界面运行,调试阶段强烈建议打开看真实效果。确认逻辑没问题后再改成headless=True,在服务器上无头跑。带界面跑还有一个好处:你能直观看到滚动加载时页面的变化节奏,有利于后面调整等待策略。2.2 三个必须提前掌握的核心操作写动态列表爬虫之前,先把这三个操作刻进脑子里,后面全在用:1. 等待元素出现page.wait_for_selector("css选择器或text选择器", timeout=10000)这个方法会阻塞直到元素出现,如果超时会抛异常。它的意义在于,动态加载是需要时间的,你提取数据前必须确保目标元素已经在 DOM 里了。10 秒只是常用默认值,实际跑的时候我会根据网络状况调整到 15 秒或 5 秒。2. 执行 JS 滚动page.evaluate("window.scrollTo(0, document.body.scrollHeight)")这一行把页面滚到底部。对无限滚动页面来说,这个动作会触发加载逻辑。有些网站监听的是某个容器元素的 scroll 事件而不是 window,此时把document.body替换成对应容器的选择器即可:page.evaluate("document.querySelector('.列表容器选择器').scrollTo(0, 100000)")3. 提取数据items = page.locator("列表项选择器").all() for item in items: title = item.locator("标题选择器").inner_text()Playwright 的 locator 体系比 Selenium 更严密,支持 CSS、text、xpath 等。实际项目里 CSS 选择器最常用,text=选择器在处理“下一页”这种文字按钮时特别好用。2.3 一个最小示例:先跑通再谈套路我不建议直接上完整项目,先写个 20 行的最小示例验证环境没问题:from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("https://example.com/list") # 找一个真实动态列表页 page.wait_for_selector(".list-item") count = page.locator(".list-item").count() print(f"当前页面加载了 {count} 条数据") browser.close()这段代码的核心作用有两点:一是验证 Playwright 环境没问题,二是验证页面上列表项的选择器是否正确。选择器对了,真实项目里至少省掉一半的排查时间。3. 滚动加载:无限列表的通用处理套路3.1 套路的核心:循环滚、等到位、再提取、看终止滚动加载页面的处理,本质上是一个四步循环:滚动到底部触发加载等待新数据渲染完成提取这批新增数据或全量重提判断是否已经滚到没有新数据的程度如果你对循环还不太敏感,我拆开解释一遍。第一步的滚动动作必须真实发生,页面才会向接口发请求;第二步的等待最关键,很多人漏数据就是因为滚完立刻提取,这时候 DOM 还没来得及更新;第三步的提取有两种策略,后面细说;第四步是滚动加载的难点,页面没有明确的“页数”概念,你必须通过某种信号判断“加载完了”。下面给一个通用模板,适配多数无限滚动列表:from playwright.sync_api import sync_playwright import time def scroll_and_extract(url, item_selector, max_scrolls=50): """通用滚动加载提取 item_selector: 列表项选择器 max_scrolls: 最大滚动次数,防止死循环 """ results = [] with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() # 可选:拦截图片,加速加载 page.route("**/*.png", lambda route: route.abort()) page.route("**/*.jpg", lambda route: route.abort()) page.goto(url, timeout=60000) page.wait_for_selector(item_selector, timeout=10000) last_height = 0 scroll_count = 0 while scroll_count < max_scrolls: # 1. 滚动到底部 page.evaluate("window.scrollTo(0, document.body.scrollHeight)") scroll_count += 1 # 2. 等待页面高度变化或新元素出现 try: page.wait_for_function( "() => document.body.scrollHeight > arguments[0]", last_height, timeout=5000 ) except Exception: # 高度不再变化,认为到底了 break # 3. 提取当前所有项 items = page.locator(item_selector).all() titles = [item.inner_text() for item in items] results = titles # 全量覆盖,简单粗暴 # 4. 更新高度,进入下一轮 last_height = page.evaluate("document.body.scrollHeight") time.sleep(1) browser.close() return results这段代码就是整套滚动加载的核心骨架。接下来我把几个细节展开讲。3.2 滚动距离与速度不是随意选的滚动加载不是滚得越深越好。很多页面每次滚动只加载一批数据,你一步滚到底部,看到的效果是页面疯狂“跳动”——因为滚动事件触发得太快,请求可能只发出去一部分。我在实际项目里踩过这个坑:一次性滚到底,结果只拿到前两三批数据,后面的数据页面根本没来得及请求。所以实际写的时候,我会把滚动改成渐进式:def scroll_gradually(page, step=800, delay=0.5): """每次滚动固定步长,给页面缓冲时间""" current = 0 max_height = page.evaluate("document.body.scrollHeight") while current < max_height: current += step page.evaluate(f"window.scrollTo(0, {current})") time.sleep(delay) max_height = page.evaluate("document.body.scrollHeight")步长选 800 是因为在很多屏幕尺寸下,800 像素大约是视口高度的 80%,不会触发浏览器的“快滚到底”加速行为,也不会因为步子太小导致循环次数太多。delay 选 0.5 秒是给网络请求留时间。注意这俩参数不是定死的,页面加载快就用大步长、短延迟,加载慢就反过来。另一种做法是滚动一步、等一批数据出现、再滚下一步,这种最稳,但速度慢。我通常用在要求高完整性的场景,比如爬全量商品信息。3.3 终止条件的本质判断滚动加载最大的坑是“不知道什么时候结束”。前面模板里我用的是比较页面高度——当高度不再变化认为到底。这个办法对大多数页面有效,但有几个例外:页面底部有推荐区块、页脚,滚动不会再加载新数据,但高度仍然存在。此时你会停不下来,一直循环直到 max_scrolls 超限。部分页面的滚动容器不是 document.body,而是某个内嵌 div,此时document.body.scrollHeight永远是同一个值,判断失效。针对第一种情况,我会检查最后两次滚动之间,列表项的个数是否还在增加。如果连续滚动多次,数量不再变化,就认为到底:prev_count = 0 stable_rounds = 0 while scroll_count < max_scrolls: page.evaluate("window.scrollTo(0, document.body.scrollHeight)") time.sleep(1) current_count = page.locator(item_selector).count() if current_count == prev_count: stable_rounds += 1 if stable_rounds >= 3: break else: stable_rounds = 0 prev_count = current_count连续三次数量没变化,基本可以断定到底了。这个阈值我调过很多次,三次是比较可靠的平衡点——网络慢的时候偶尔一两次没加载出来,三次还没变化那大概率是真没了。针对第二种内嵌容器的情况,改动也只是把滚动目标和高度判断都换成容器元素:page.evaluate("document.querySelector('.feed-list').scrollTo(0, 1000000)")3.4 数据提取的两种策略滚动加载场景下,数据提取有两个流派,各有适用场景:全量覆盖式:每次滚动后重新提取页面所有列表项,直接覆盖结果列表。优点是逻辑简单,不会重复,因为每次都是全量;缺点是如果单轮提取耗时较长,滚动后页面可能又插入了新数据,最后会有轻微遗漏。适合列表项数量不夸张(几百条内)的场景。增量追加式:每次都重新提取,然后按某个唯一标识(比如标题、链接)去重后追加到结果。优点是速度快,适合上万条数据的场景;缺点是去重逻辑要写好,否则容易重复。实际操作中我多数用增量追加,因为滚动加载的列表动辄几十甚至上百次请求,全量覆盖的时间成本太高。示例:seen = set() all_data = [] while ...: # 滚动、等待 items = page.locator(item_selector).all() for item in items: title = item.locator(".title").inner_text() if title in seen: continue seen.add(title) all_data.append(title)这里用标题作为唯一标识,前提是标题不会重复。如果列表有重复标题的可能,改用详情页链接或者数据 ID 更稳妥。4. 点击翻页:分页与“加载更多”的通用套路4.1 分页按钮的套路:点、等、提、判点击翻页比滚动加载的终止条件更直观,因为页面给了你“最后一页”的信号。但操作步骤本身仍然要写对。通用的分页翻页模板长这样:def paginate_click(url, item_selector, next_btn_selector, max_pages=10): """通用点击翻页提取 next_btn_selector: 下一页按钮的选择器 """ all_data = [] with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto(url, timeout=60000) page.wait_for_selector(item_selector, timeout=10000) for page_num in range(1, max_pages + 1): print(f"正在提取第 {page_num} 页") # 1. 提取当前页数据 items = page.locator(item_selector).all() for item in items: all_data.append(item.inner_text()) # 2. 判断下一页按钮是否存在且可点击 next_btn = page.locator(next_btn_selector) if next_btn.count() == 0 or not next_btn.is_enabled(): print("没有下一页按钮了,结束") break # 3. 点击下一页 next_btn.click() # 4. 等待页面刷新(等待旧元素消失或新元素出现) try: page.wait_for_selector(item_selector, timeout=10000) except Exception: print(f"第 {page_num} 页点击后没有加载出新元素") break # 可选:等待 URL 变化或页码变化 time.sleep(0.5) browser.close() return all_data这段代码有几个地方需要特别注意,我逐个说一下。第一,提取数据和点击操作的顺序不能反。必须先提取当前页数据再点击下一页,否则你会漏掉当前页。这个顺序听起来是废话,但我见过有人把点击写在提取前面的脚本,一跑就翻到第二页才开始提取,第一页数据直接丢了。第二,点击后不能立刻提取,必须等待页面刷新。这里有一个判断刷新完成的技巧——等待当前页的某个标志性元素消失,或者等待页码数字变化。可以结合wait_for_selector的state参数:# 等待旧元素进入 detached 状态(从 DOM 中移除) try: page.wait_for_selector(".pagination .current", state="detached", timeout=10000) except Exception: pass # 如果当前页码元素没有消失,可能页面是局部刷新有的站点翻页是整页跳转,整个 DOM 都会变化,此时上面的代码用来等待旧 DOM 卸载,它消失说明新页面已经在渲染了。有的站点是局部刷新,只换列表区域,分页按钮不变,这段时间等待条件要换成“列表区域内容变化”才行。第三,页码不是数字而是“加载更多”按钮时,点击后按钮不会消失,数据会继续追加。这种情况的终止条件不能靠按钮消失判断,要靠数据数量是否变化。4.2 “加载更多”按钮:披着点击外衣的滚动加载很多站点没有数字分页,只有一个“加载更多”按钮,点击一次加载一批新数据,按钮还在原地。这种形态我习惯把它当作滚动加载来处理,只不过触发动作从滚动变为点击。模板如下:def click_load_more(url, item_selector, load_more_selector, max_clicks=50): all_data = [] with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto(url, timeout=60000) page.wait_for_selector(item_selector, timeout=10000) prev_count = 0 stable_rounds = 0 for i in range(max_clicks): # 提取当前数据 items = page.locator(item_selector).all() current_count = len(items) # 判断是否还在变 if current_count == prev_count: stable_rounds += 1 if stable_rounds >= 2: print("加载更多已到头") break else: stable_rounds = 0 prev_count = current_count # 汇总数据 for item in items: all_data.append(item.inner_text()) # 点击加载更多 btn = page.locator(load_more_selector) if btn.count() == 0 or not btn.is_visible(): print("加载更多按钮不存在或不可见") break btn.click() # 等待新数据渲染 time.sleep(1) browser.close() return all_data注意这里用“连续两轮数量不变”作为终止条件,因为按钮点击后数据是追加的,只能在数量上做文章。有些页面加载更多到最后一页后按钮会被移除,这时判断btn.count() == 0会直接跳出;如果按钮还在但点击无效,就是数量不变的情况。4.3 点击翻页时最容易踩的坑点击翻页里最容易翻车的场景有三个,我单独拎出来:坑一:按钮明明定位到了,click 却没有任何反应。原因通常是按钮被另一个元素遮挡,或者是按钮绑定的事件在某个初始化完成后才生效。解决方法有两个:一是改用btn.click(force=True)强制点击,二是用 JavaScript 直接触发:page.evaluate("document.querySelector('按钮选择器').click()")前者适合遮挡不严重的情况,后者属于绕过式触发,能解决大部分“假点击”。坑二:点击后页面会打开新标签页。有些页面的“下一页”是target="_blank"外链,直接点击会开新标签,Playwright 默认不会自动切换过去。处理方式是监听新页面事件:with page.expect_popup() as popup_info: next_btn.click() new_page = popup_info.value new_page.wait_for_load_state() # 然后在 new_page 上操作不过说实话,遇到这种页面我通常先看看 URL 规律——很多外链下一页其实就是个分页 URL,直接拼参数用 requests 反而更简单。坑三:点击翻页时登录态丢了。某些站点的分页接口需要登录凭证,页面跳转时 cookie 或 token 校验不过去。排查方式是观察点击翻页后的响应状态,用 Playwright 的 network 事件:page.on("response", lambda resp: print(resp.url, resp.status))如果点击后看到某个接口返回 401 或 403,大概率就是登录态问题。解决方式是把登录流程前置,先登录成功后再进列表页,而不是直接访问列表页。5. 机制与选型:什么时候该用 Playwright,什么时候该绕开5.1 两种加载方式的对比速查用表格给你整理一个对比,方便以后遇到页面时直接判断用哪套模板:对比维度滚动加载点击翻页(分页按钮)点击翻页(加载更多)触发方式滚动到特定位置点击数字/下一页按钮点击加载更多按钮终止条件滚动高度不再变化/列表项数量连续不变下一页按钮消失/禁用按钮消失/数量连续不变提取速度较慢,受滚动节奏限制较快,翻页间隔可控较慢,取决于加载速度稳定性中,容易滚过头高,分页逻辑清晰中,按钮状态判断需要小心常用等待策略wait_for_function 高度变化等待旧元素 detached等待列表项数量增加数据完整性高(理论上可滚到底)高(页数明确)高(直到按钮消失)我个人的经验是,遇到滚动加载先看接口,遇到点击翻页优先用 Playwright 硬跑。原因很简单:滚动加载往往背后是一个连续翻页的接口,接口参数通常就 page 或 offset 两个数字,构造起来很容易;而点击翻页虽然也有接口,但不少后端校验了请求头、Referer、加密参数,直接用 requests 反而不如 Playwright 模拟点击稳定。这不是绝对的,但作为快速选型的参考很有效。5.2 什么时候应该放弃 Playwright 转抓接口Playwright 不是银弹,在两种场景下我会主动放弃它:第一个场景是接口结构极其简单时。打开开发者工具,滚动列表,如果发现每一次滚动都是同一个接口,URL 参数里就一个 page 数字或者一个 offset 偏移量,且响应是干净的 JSON,直接用 requests 循环请求接口,速度至少是 Playwright 的十倍,还不用处理浏览器资源。这时候硬用 Playwright 属于给自己加戏。判断方式很简单:滚动页面看 Network 面板,如果请求 URL 规律清晰、没有特殊加密参数,先用 requests 试试。能通就直接抓接口,不折腾。第二个场景是页面极其复杂,滚动加载还嵌套了多级 iframe、Shadow DOM。这种页面用 Playwright 写选择器会非常痛苦,一步步穿透层级不仅代码冗长,而且页面一改版就崩。相比之下,直接分析接口往往更省事。不过注意,如果接口响应是加密文本或者需要特殊签名,那就还是老老实实回归 Playwright,至少它不跟你玩加密那一套。5.3 Playwright 与 Selenium 的选型差异既然你已经学到 Playwright 了,顺便把和 Selenium 的差异说清楚。两者都能做动态页面爬取,但体验差距很明显:安装体验:Playwright 一条命令装完能跑,自带浏览器内核;Selenium 需要额外下载对应版本驱动,还得手动配路径,版本一不匹配就报错。API 设计:Playwright 的 locator 体系明显更现代,支持自动等待;Selenium 的 find_element 系列写完经常要手动 sleep 等渲染。调试能力:Playwright 自带 codegen 录制功能和 trace 回放,定位选择器时可以直接录制操作生成代码,这在 Selenium 里没有对应级别的工具。稳定性:Playwright 的 auto-waiting 机制很大程度上避免了元素未出现的竞态问题。不过 Selenium 在一点上仍有优势:老项目生态成熟,网上资料多,部分浏览器自动化场景(如既有系统兼容)只能用它。新项目我基本只用 Playwright。6. 常见问题与排查技巧实录最后集中整理我在实际项目中踩过的坑和排查方法,按出现频率排序,遇到问题直接来对号入座。6.1 元素定位到了,点击却没有反应现象:代码执行到btn.click()不报错,程序也不终止,但页面就是没有任何变化。排查步骤:在带界面模式下看按钮是否真的被点击了(有没有高亮或涟漪效果)。检查按钮是否被遮罩层遮挡,用btn.is_visible()验证。查看 Network 面板,确认点击后是否发出了请求。解决方案:先试force=True,再试 JS 点击。如果 JS 点击也没有反应,有两种可能——按钮绑定的监听事件还没完成初始化,等待时间加长;或者按钮不是真的翻页按钮,而是某个弹出层的触发点。常见于页面里其实有两个“下一页”按钮,一个隐藏在弹窗里。定位时选择器写得要具体,把父容器也带上。6.2 wait_for_selector 超时,页面一直卡在加载状态现象:wait_for_selector抛 TimeoutError,但页面肉眼可见已经渲染了部分数据。原因:选择器写得和实际 DOM 结构不匹配,或者目标元素渲染完成后又被页面 JavaScript 篡改了(比如先渲染数据再套一个壳子)。解决方案:换选择器,优先用稳定的 class 名或 data 属性。不要用那些带数字后缀的动态 class(很多框架生成的类名每次刷新都会变)。另外可以先用page.content()打印当前 HTML,肉眼确认目标元素真实存在后,再据此改选择器。6.3 提取到的数据重复现象:循环跑完后,结果里有大量一模一样的条目。原因:增量追加时去重逻辑失效,或者滚动/点击发生的速度太快,页面没及时加载新数据,导致同一批数据被提取了多次。解决方案:先用标题加链接一起作为唯一键,如果是纯文本列表没有链接,用“序号 + 标题”组合去重。另外检查一下循环里的等待时间,太短的话加长到 1 秒以上。6.4 无头模式下页面加载速度异常慢现象:headless=True 时,原本带界面跑得好好的页面,无头模式下一片空白,或者等了很久才渲染。原因:部分网站检测到无头浏览器的指纹特征会返回降级页面。这不是 Playwright 的问题,是目标站点识别了 navigator.webdriver 之类的特征。解决方案:加启动参数规避,常见做法是去掉部分 webdriver 特征:browser = p.chromium.launch( headless=True, args=[ "--disable-blink-features=AutomationControlled", "--disable-dev-shm-usage", ] )加--disable-dev-shm-usage能解决一部分容器环境下渲染内存不足的问题。如果仍然不行,退一步用 headless=False 在后台跑。6.5 页面弹窗、广告盖住了按钮现象:页面正常加载,但弹了一个新用户引导的遮罩层,click()报错说元素不可点击,因为它被遮挡了。解决方案:先关弹窗再点按钮,弹窗关闭按钮通常可以强杀:try: page.locator(".close-btn").click(timeout=3000) except Exception: pass如果弹窗没有显式关闭按钮,可以直接按 ESC 键,部分弹窗支持键盘关闭。再不行就用page.evaluate把遮罩层的 display 设为 none。page.evaluate("document.querySelector('.mask').style.display = 'none'")6.6 翻页后页码没变,实际跳到了别的页现象:点击 “下一页”按钮后,URL 变了但页码数字没变,或者提取到的数据是上一页的。原因:页面的翻页是按表单提交实现的,点击按钮后其实发了一次 POST 请求,页面 URL 不会变,DOM 局部刷新。此时不能用“等待旧元素消失”来判断刷新完毕,因为分页区域可能根本没变化。解决方案:改用“等待列表区域第一个条目变化”的方式。实现可以用wait_for_function:page.wait_for_function( """(old_title) => { const first = document.querySelector('.list-item .title'); return first && first.innerText !== old_title; }""", first_title_at_current_page, timeout=10000 )实践中这个判断往往比等待“某个元素消失”更可靠,因为局部刷新的页面里,很多元素是复用的,不会消失。6.7 网络请求被目标站点的频率限制拦截现象:滚动加载跑到中途,某一次滚动后页面不再加载新数据,重启浏览器发现前几十次都很正常,后面无论如何都不出数据。原因:触发频率限制,目标站点对单位时间内的列表接口请求数做了限制,短时间内高频滚动就会被封。解决方案:控制滚动速度,每两次滚动之间增加随机间隔。时间用 0.8 到 1.5 秒之间的随机浮点数,避免请求节奏规律化,也让行为更接近真人浏览。另外,可以在请求中加入 Cookie 保持会话统一,而不是每次重新开启浏览器,否则身份特征变化更容易触发风控。实际项目中我的取舍习惯这篇写到这里已经很长了,最后再说一点工作流层面的个人习惯。我接到一个动态列表需求时,不是上来就写 Playwright 脚本,而是先花五分钟干三件事:打开开发者工具看 Network 里有没有直出的 JSON 接口;观察页面是滚到底加载还是点按钮才加载;确认有没有明显的分页参数规律。这三件事决定了整个方案是走接口直取还是走浏览器自动化。如果接口直取可行,我绝不会为了“显得高级”而硬套 Playwright;如果接口加密严重,Playwright 就是最省心的兜底手段。这两种思维要并存,而不是一味迷信某个工具。另外,Playwright 脚本写完后,我建议你花点时间把循环里的 sleep 优化成 wait_for 系列方法,让等待时间自适应页面加载速度。这样做的好处有两个——脚本更稳,不至于因为某次网络慢而误判终止;速度也更优,页面快的时候不等多余时间,页面慢的时候不提前抢跑。动态页面难的不是技术本身,而是对“页面什么时候算加载完”的判断。这个感觉只能靠多看多跑积累,没有捷径。你把滚动加载和点击翻页这两套模板多跑几个真实网站,自然就摸到规律了。