Selenium模拟滚动加载:无限滚动页面爬虫实战

📅 发布时间:2026/10/9 20:57:45
Selenium模拟滚动加载:无限滚动页面爬虫实战
写这篇的时候我心里其实有点感触。前几节咱们还在跟 requests 和静态 HTML 打交道基本套路就是拿网页源码、XPath 抽数据、存进 Excel一条龙下来教程感特别强。可一旦你开始接触真实项目尤其是电商搜索页、招聘职位列表、各类信息流站点马上就会撞上一个让人抓狂的现象页面往下滚数据一批一批冒出来但地址栏的 URL 从头到尾没变过你用 requests 去请求翻来覆去拿到的只有最上面那几十条。这就是今天要解决的滚动加载场景也叫无限滚动。这类页面在实战里的占比高得惊人。十个带列表的站点少说七八个是这种交互方式——不是为了防爬虫纯粹是前端性能优化的常规操作数据分批次从服务器拉用户滚到底部再触发下一批。作为爬虫学习者这个坎绕不过去。所以这一节咱们用 Selenium 把整条路趟一遍模拟真实用户的滚屏操作把数据从 0 攒到 300 条并且带上一套完整的终止条件让程序说停就停不会傻乎乎滚到天荒地老也不会提前收网漏掉一半数据。这篇适合谁你刚把 requests XPath 静态采集练熟想进阶动态页面或者你之前写过滚轮爬虫结果一跑就停不下来、重复抓这篇可以直接当模板对照着改。1. 滚动加载的本质理解URL 没变为什么数据却在疯长1.1 很多新手理解偏了以为是反爬其实只是异步加载我见过不少初学者遇到滚动加载页面后的第一反应是网站是不是用了 iframe 藏数据或者我是不是该换 User-Agent 再试一次。其实都不用你先搞清楚滚动加载的原理后面所有设计都会顺理成章。滚动加载的核心是浏览器里跑了一段 JavaScript它一直在监听滚动条的位置。当检测到用户滚到离页面底部还有一定距离时就自动向后台发一个请求把下一批数据取回来再用 DOM 操作插进列表容器。整个过程中地址栏 URL 不变HTML 文档的原始源码也不变变化的是当前页面上已经有多少条数据。这里要记下一个关键结论你用 requests 拿到的原始 HTML 里只有首屏那二三十条记录后续数据根本不在 HTML 文档中。所以你在那个 HTML 里怎么写 XPath 都不可能找到后面的内容这不是 XPath 本身的问题而是数据压根没在响应里。1.2 数据到底藏在哪儿F12 Network 面板里的 XHR 线索如果你想走捷径打开浏览器的开发者工具切到 Network 面板手动滚动页面会在滚动发生的那一刻看到一个新的异步请求冒出来名字可能带 page、offset、last_id、cursor 之类的参数返回结果是一段 JSON又或者是一小片 HTML 片段。理论上你完全可以跳过浏览器直接模拟这个接口把参数从左调到右一次性拿回 300 条甚至 3000 条。那为什么我这一节还是讲 Selenium 滚屏而不是直接讲接口模拟因为动态接口这条路水很深参数签名、加密 token、请求头校验、埋点上报随便一个环节卡住零基础阶段的同学就可能折腾一整天。更何况这类接口改版频繁今天能用明天参数名一变就全废。相比之下用浏览器渲染框架模拟真人操作是容错率最高的兜底方案——网站给真人展示什么我们就抓什么不需要逆向任何加密逻辑。等你们后面把 HTTP 协议、抓包分析练熟了再回来走接口方案会更游刃有余但今天的任务先把动态页面这条路彻底走通。2. 渲染方案选型与 Selenium 4 环境初始化2.1 Playwright、Pyppeteer 都行为什么零基础先选 Selenium每次写滚动加载教程都会有人问现在不是流行 Playwright 吗不是还有 Pyppeteer 吗你为什么还用 Selenium答案是都行但适不适合零基础是另一回事。Playwright 性能确实好默认支持无头模式API 设计也现代Pyppeteer 更轻基于 Node 生态。但 Selenium 的 API 是三者里最适合衔接你已有知识体系的find_element就是find_elementXPath 怎么写Python 里就怎么写和你前几节用 requests XPath 的思维完全一致不需要额外理解异步、事件循环那套东西。更实际的一点是 Selenium 的社区积累太厚了你遇到任何奇怪报错基本都能在网上搜到现成答案。零基础阶段稳定性 炫技。2.2 三行命令装完依赖注意两个容易忽略的细节安装部分三条命令就够pip install selenium pip install webdriver-manager pip install pandas openpyxl然后初始化浏览器from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager import time import pandas as pd options Options() options.add_argument(--start-maximized) # 窗口最大化按桌面浏览器的形态渲染 driver webdriver.Chrome(serviceService(ChromeDriverManager().install()), optionsoptions) driver.implicitly_wait(5)有同学可能还记着老教程里手动下载 chromedriver、注意浏览器版本号对应的麻烦流程。Selenium 4 之后内置了 Selenium Manager配合 webdriver-manager 库驱动会自动根据你本机的 Chrome 版本下载匹配不用再手动折腾。你只要保证电脑里装了 Chrome上面这段代码大概率能直接跑通。这里有一个我实际踩过的细节--start-maximized不是可有可无的装饰参数。很多列表页的前端代码会根据可视区域高度来决定预加载多少数据窗口开得太小每次滚动触发加载的数据量就会明显缩水。最大化窗口是为了让页面按照正常桌面浏览器的形态渲染避免采集量的意外偏差。3. 模拟滚动的操作细节scrollTo、scrollBy 与滚动后的等待3.1 滚到哪里才算触发加载滚到底部与逐屏滚动的取舍模拟滚动核心就一行 JavaScriptdriver.execute_script(window.scrollTo(0, document.body.scrollHeight);)scrollTo是把滚动条定位到绝对位置第二个参数传document.body.scrollHeight也就是当前整个文档的高度等价于直接滚到最底部。滚动加载的触发钩子就是离底部足够近所以这是最直接的触发方式。另一种常见写法是driver.execute_script(window.scrollBy(0, window.innerHeight);)scrollBy是相对当前位置滚动一屏节奏更接近真人操作每次只滚一屏中间加载动画有充足时间跑完。缺点是如果列表项高度不固定你可能要滚很多次才能触底循环里的判断逻辑也会多写几行。我的实践经验初版脚本一律用scrollTo滚到底部简单、高效、不容易误判。如果遇到某些站点对连续快速触底有频率限制再切换成scrollBy搭配固定 sleep后面第 6 节会专门提到这个坑。初版不要追求花哨的滚动方式先把链路跑通。3.2 滚动之后别急着数数等待节奏和回顶动作滚动完最忌讳的事情就是立刻统计数量。浏览器的 JS 要发请求、服务器要返回、前端要渲染这一套流程再怎么快也要一两秒。你滚动完马上find_elements拿到的很可能还是上一轮的条数于是程序误判成没有新数据提前触发终止条件收工——结果只抓了 60 条就停了。代码里我习惯用这样的节奏driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) time.sleep(2.5) items driver.find_elements(By.XPATH, ITEM_XPATH) print(f当前已加载 {len(items)} 条)等 2.5 秒看起来有点笨但这正是稳定性优先的选择。真要去抢那 0.5 秒代价是偶尔拿不到完整数据debug 的时间远超省下来的时间。还有一个特别容易被忽略的动作先回到顶部。如果你之前手动拖动过页面或者浏览器恢复会话后停留在了中部直接在当前位置继续滚动是有风险的。滚动加载的数据是分批追加到列表末尾的如果从页面中部开始滚钩子的触发逻辑可能完全不工作。开始采集前先执行一次driver.execute_script(window.scrollTo(0, 0);) time.sleep(1)然后重新定位列表容器拿到最初始的条数再进入滚动循环。这个回顶动作看似多余实际能省掉很多莫名其妙的为什么滚动不加载的排查时间。4. 终止条件设计300 条数据说停就停的完整实现4.1 三层终止机制数量达标、轮次上限、连续无新增这一节是整个滚动加载采集器的灵魂。单写一个数量到达 300 就停远远不够为什么第一数据源不一定正好有 300 条。假设站点只有 180 条你永远等不到 300程序就会一直滚。如果不判断已经到底了它会滚到天荒地老直到触发网站的超时限制或者封 IP线程崩溃了还在那滚。第二网络偶尔抽风。某一轮滚动后数据其实已经到了但因为渲染延迟这一轮统计出来的条数和上一轮完全一样。如果写成条数相等就停止程序就会在前 200 条时误停明明后面还有 100 条。所以至少要看连续两轮无新增才算到底给页面一个缓冲机会。第三最容易被忽视的反向问题如果站点有 3000 条数据你只要 300程序就会哗啦啦全部滚动加载完——这 2700 条多余的流量不仅浪费数据量一大还容易触发访问频率限制。所以目标数量本身必须作为一个主动终止信号而不是傻等它自然见底。综合下来我常用的终止条件有三层数量条件当前已加载条数 300直接切片取前 300收工。轮次条件即使每轮都有新增滚动轮次超过上限比如 40 轮也强制停止防止逻辑死循环。无新增条件连续 N 轮统计到的条数完全一样视为已经触底收工。4.2 完整主循环代码与执行顺序解析把上面的逻辑合成一段可以直接跑的代码MAX_TARGET 300 MAX_ROUND 40 NO_CHANGE_LIMIT 2 ITEM_XPATH //li[contains(class, list-item)] prev_count 0 no_change_times 0 items [] for round_no in range(1, MAX_ROUND 1): driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) time.sleep(2.5) items driver.find_elements(By.XPATH, ITEM_XPATH) current_count len(items) print(f第 {round_no} 轮累计 {current_count} 条) # 条件一数量达标 if current_count MAX_TARGET: print(数量达标停止滚动) items items[:MAX_TARGET] break # 条件二连续无新增 if current_count prev_count: no_change_times 1 else: no_change_times 0 if no_change_times NO_CHANGE_LIMIT: print(连续两轮无新增判断已到底) break prev_count current_count else: # for 循环自然结束时触发即滚满 MAX_ROUND 轮 items driver.find_elements(By.XPATH, ITEM_XPATH) if len(items) MAX_TARGET: items items[:MAX_TARGET] print(达到滚动轮次上限停止) print(f采集结束最终条数{len(items)})很多人第一次看这段代码会绕晕的地方在于执行顺序。我再拆一遍先滚动、后等待、再统计这是第一步统计完成后做终止判断这是第二步如果未终止把当前条数记录到prev_count进入下一轮。尤其要注意无新增判断绝不能写在滚动之前。否则第一轮循环current_count和prev_count都是初始的 0直接判定相等break 立刻触发一条数据都抓不到。另外切片items[:MAX_TARGET]要在 break 之后执行保证拿到的 300 条是最前面的数据而不是滚动到底部后累积的 3000 条的尾部切片。4.3 打日志和增量保存给自己的脚本留后路滚动加载脚本最怕的就是跑了十分钟不知道它在干嘛黑盒一样。所以每轮 print 当前条数是最低要求。如果计划跑长任务把日志重定向到文件里python crawl_dynamic.py crawl.log 21排查问题的时候看日志比盯终端方便得多可以精确判断出是哪一轮开始没有新增。还有一个更实用的习惯别等全部采集完才写 Excel每一百条就增量保存一次不然跑到第二百条的时候网络断了重启以后前两百条的数据全没了那才叫欲哭无泪。5. XPath 字段提取与数据清洗从 DOM 到 Excel 的收尾细节5.1 相对 XPath 的.//前缀一个点引发的数据错位滚动加载页面的结构通用套路是外层一个容器里面是重复的列表项。先用一组比较稳的 XPath 定位列表项整体再对每一项提取字段rows [] for item in items: try: title item.find_element(By.XPATH, .//*[contains(class, title)]).text.strip() price item.find_element(By.XPATH, .//*[contains(class, price)]).text.strip() link item.find_element(By.XPATH, .//a).get_attribute(href) rows.append({标题: title, 价格: price, 链接: link}) except Exception: rows.append({标题: None, 价格: None, 链接: None}) continue注意这里 XPath 开头写的是.//而不是//。//是从整个文档根节点去搜很可能搜到页面上其他区域甚至隐藏节点.//表示从当前列表项这个元素继续向下找把搜索范围严格限定在 item 内部。这个点我强调过很多次因为它引发的坑太隐蔽了你提取到的标题和价格可能来自完全不同的两行最后拼出来一张张冠李戴的表格。排查大半天结果就是少写了一个点。另外很多列表项的 class 会动态追加比如classitem item-v2这时候用classitem是匹配不到的必须写成contains(class, item)这种形式。5.2 text()、string(.) 与嵌套标签的文本拼接搜索热词里出现的python xpath爬虫 text函数对应的其实就是这里最常见的困惑XPath 写//*[classtitle]/text()为什么只取到半个标题因为当 HTML 里标题文本被子标签包裹时div classtitle 限量版 span机械键盘/span 现货 /divtext()只能取到当前节点的直接文本节点也就是限量版 和 现货中间的机械键盘会丢。解决方案有两个在 Selenium 中直接用.text属性即item.find_element(By.XPATH, .//*[contains(class, title)]).text它会自动拼接所有嵌套子标签的可见文本比 text() 省心得多。如果想在 XPath 层面解决用string(.//*[contains(class, title)])这个函数会把整个子树的所有文本拼成一个字符串。文本两侧的空格和换行统一用.strip()处理。真实页面里很多字段是 ¥ 399.00 这种带多余空白的格式不清理的话后续写进 Excel 做数据透视时会很痛苦。5.3 DataFrame 写出 Excel 与常见脏数据清理字段提取完成后入库就是两步df pd.DataFrame(rows) df.to_excel(滚动加载采集结果.xlsx, indexFalse)如果表里混进了换行符和连续空格先做一次正则清洗import re df[标题] df[标题].apply(lambda s: re.sub(r\s, , str(s)) if s else s)写完之后一定用df.head()预检一遍。我在实际操作中遇到过的典型问题某几个列表项缺了价格节点find_element抛异常后被 except 兜住那一行数据里就只有 None。这种情况宁可留空也别让它整个崩掉。留空只是数据不完整崩掉则是前功尽弃。如果你打算把脚本留在服务器上跑这个取舍尤其重要。6. 滚动加载实战中一定会撞见的几个坑6.1 弹窗和 iframe奇怪取不到元素的头号嫌疑犯滚动过程中隔三差五会冒出来一个订阅更新下载 APP开启新版本之类的引导弹窗。弹窗遮住列表区域后Selenium 的点击操作经常会报element not interactable。滚屏前做一次检查是个低成本高收益的习惯try: close_btn driver.find_element(By.XPATH, //div[contains(class, modal)]//button[contains(text(), 关闭)]) close_btn.click() except Exception: pass还有一种情况比弹窗更隐蔽——iframe。如果你发现find_element怎么都找不到某个元素而用浏览器手工查看页面源码时元素明明就在优先怀疑它被嵌在 iframe 里。这时候需要先driver.switch_to.frame(...)切进子文档操作完再driver.switch_to.default_content()切回主文档。懒加载图片也是个坑。滚动时图片区域先显示灰色占位符列表项虽然已经插入 DOM 了但你去提取.text时可能拿到空串导致数据不完整。遇到这个场景要么在提取前多加一次短暂 sleep要么用scrollIntoView把列表项滚进可视区域等图片真正渲染后再提取。6.2 等待策略的选择sleep 与 WebDriverWait 怎么搭我之前在初始化代码里写了driver.implicitly_wait(5)这是全局兜底定位元素时如果元素在 5 秒内出现就立即返回否则最多等 5 秒。滚动加载场景下这个方式够用但不够聪明。更精准的是显式等待专门等某个条件成立例如WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.XPATH, ITEM_XPATH)) )但真的要在等待列表条数增加这个粒度上做显式等待代码复杂度会明显上升。所以我的默认策略是初版脚本用 sleep先把链路跑通确认哪里不稳定再把对应位置的 sleep 升级成 WebDriverWait不要一开始就把代码写得很重。滚动加载这种场景固定 sleep 搭好节奏稳定性其实已经足够。6.3 headless 模式与浏览器进程残留收尾工作也是技术活很多教程会让你给 options 加一个--headless说这样跑起来不弹窗口看起来专业又省资源。但我要泼一盆冷水无头模式下的浏览器行为不等于有头模式部分站点检测到 headless 特征后直接拒绝加载后续数据你的列表会永远停在第一屏。我的建议是初学阶段老老实实开窗口跑。等脚本稳定了再决定要不要加 headless。如果一定要无头优先使用 Chrome 的无头新模式参数--headlessnew它对现代站点的兼容性更好也不容易被识别。最后是最容易被新手忽略的事脚本跑完一定要调用driver.quit()最好用 try/finally 包住。不写的话Chrome 进程会残留在后台你多跑几次电脑内存就被慢慢吃光了。try: # 主循环、提取、保存逻辑 pass finally: driver.quit()说完收尾再说节奏控制。滚动加载采集的每一轮之间至少保证 2 秒以上的间隔这个速度对目标网站是一种礼貌对你自己也是一种保护。爬虫脚本本身没有善恶用法要对得起它。控制好频率遵守站点规则把你要的数据拿完就收手这比任何高级技巧都重要。