ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

滚动加载动态列表爬虫实战:XHR接口与Selenium采集完整指南

滚动加载动态列表爬虫实战:XHR接口与Selenium采集完整指南 滚动加载这种动态列表几乎是每个爬虫入门者都绕不开的坎。你学完了requests抓静态页面学会了XPath解析信心满满地打开一个真实网站结果发现页面上明明有几百条数据可你拿requests去请求返回的HTML里却只有前面二十条剩下的数据像凭空消失了一样。往下一滚内容又出来了你再一看浏览器地址栏根本没变。这时候很多人就懵了数据到底藏在哪里这个场景我太熟了今天就把动态列表的采集思路一次性讲透。我会从滚动加载的原理拆起然后给你两条可上手的路线一条是直接抓页面底层的XHR接口这是我最推荐的做法稳定高效另一条是用Selenium模拟浏览器滚动适合接口加密、参数看不懂时的兜底方案。目标很明确采集满300条数据并且带好终止条件让程序安全停下来绝不无限滚下去。零基础的朋友照着手敲就能跑通写过一两版爬虫的朋友建议重点看我关于终止条件的设计思路这一块最容易出问题。1. 动态列表滚动加载到底在加载什么1.1 先搞清楚为什么页面不一次性给全数据现在的网站早就不是十年前那种“服务端把HTML拼好浏览器拿过来直接渲染”的架构了。大量页面采用前后端分离服务器返回的HTML只是一个空壳框架真正的内容是由JavaScript在浏览器里发请求拿回来的。你去翻源代码看到的是一堆script标签和初始化脚本唯独看不到数据本身。滚动加载就是这种架构下最常见的交互模式页面先加载第一屏数据通常二十条左右当你把滚动条拖到底部附近前端脚本监听到scroll事件自动向后端发起下一次请求把下一页的数据拼到列表末尾。你感觉是“无限续杯”其实背后就是一次接一次的分页请求。对爬虫来说这意味着一个认知转变你直接抓页面地址是没用的你得找到那个被JavaScript反复调用的数据接口或者模拟出整个滚动的行为。这个转变很关键。我见过太多新手在HTML源码里翻来翻去找数据浪费时间不说还会得出“这网站反爬太强”的错误结论。其实人家前端做得越规范数据接口往往越清晰反而比老式站点更好抓。1.2 动手前先花两分钟判断页面类型拿到一个动态列表页面别急着写代码先做两个小判断。第一个它是“无限滚动”还是“加载更多按钮”。无限滚动是滚到边缘自动触发加载更多则需要你点一下按钮。这两种在Selenium方案里的操作路径完全不同一个靠滚动事件一个靠click。第二个判断更重要数据到底是哪个请求返回的打开浏览器开发者工具按F12切到Network标签勾选XHR类型过滤然后回到页面手动滚动一下。注意观察Network面板如果冒出一个名字类似getList、search、pagelist的请求恭喜你数据接口找到了。点开它看Preview或Response标签如果响应里正好是页面列表里那些内容核心问题就变成了“怎么用代码模拟这个请求”。这一步叫抓包是整个动态列表爬虫里性价比最高的一项技能。很多人上手就直接开写Selenium滚动笨重又慢根源就是跳过了这一步。我建议你养成习惯凡是遇到动态页面先抓包两分钟再决定方案这比什么都强。1.3 两条技术路线到底怎么选直接说结论优先找XHR接口用requests直接请求只有接口确实找不到、参数加密看不懂、或者必须要模拟登录态和浏览器完整行为时再上Selenium。我为什么这么偏爱接口方案拿我自己做项目的体感来说差距非常明显。接口方案一次请求就是几十毫秒返回的是干净整洁的JSON数据Selenium要启动一个完整浏览器加载几十个脚本和样式文件循环一次少说几秒钟。而且浏览器方案还怕页面改版前端工程师把某个class换个名字你的选择器就全白写了。对比维度接口方案requests浏览器方案Selenium请求速度毫秒级秒级且每次都要完整渲染页面稳定性只要参数正确基本不受前端改版影响DOM结构一改就可能失效反爬敏感性需要模拟请求头、Cookie更脆弱一点行为更接近真人但同样会被检测上手难度需要理解抓包、参数含义核心就是查找元素、模拟操作门槛偏低数据质量直接拿JSON字段整齐易清洗从DOM文本提取常有空格和噪音这张表不是让你只看结论而是要理解背后的取舍。如果你只是想最快跑通一个个人练习项目Selenium确实更友好但如果你想长期采集某个数据源接口方案几乎是唯一选择。我的建议是两个方案都实现一遍第一遍理解动态页面的行为逻辑第二遍追求工程效率。2. 核心细节拆解请求参数与终止条件2.1 数据接口里到底藏着哪些参数当你从Network里找到那个XHR请求先看它的Headers重点找Query String Parameters或Request Payload这里面就是分页的关键参数。最常见的组合是offset和limitoffset表示从第几条开始取limit表示本次取多少条。比如第一屏的请求是offset0limit20返回前20条滚动到底后前端发出offset20limit20再下次是offset40limit20。规律非常清晰循环里让offset每次加limit就行。另一种常见的是page和pagesize逻辑完全一样只是名字不同。还有一种游标方式接口不会让你自己算偏移量而是返回一个next_cursor或end_id字段你下一次请求原样带上这个值就行。这个反而更省心你都不用推导数学规律接口给什么就带什么。我强烈建议你在浏览器里先手动改参数验证一下自己的理解。比如把offset从0改成40回车发起请求看看返回的数据是不是正好接在前面40条后面。确认了再写代码这能避开一个很隐蔽的坑有些接口的offset语义不是“跳过的条数”而是“页数索引”一旦理解错误代码写出来就是永久重复采集。2.2 终止条件为什么是本项目的灵魂滚动加载类页面的最大陷阱就是它可能真的能无限滚下去。资讯流、商品流、内容推荐流后端数据管够你看到什么程度它就加载到什么程度。爬虫一旦跑起来如果不加终止条件它就会一直请求、一直滚最后内存撑爆、对方服务器压力骤增你辛辛苦苦写的脚本变成一场事故。本项目目标就是300条终止条件至少要设计成三层。第一层是数量终止每次抓到数据后统计当前已收集条数一旦等于或超过300立刻跳出循环。第二层是空数据终止某次请求返回的列表是空的说明后端已经没有更多内容了这时候也应该停。第三层是兜底终止连续几次请求或滚动之后已收集数量没有任何增长说明可能遇到了临时故障或重复数据同样要主动停下来。第三条很多教程不会写但它恰恰是工程里保命的。我第一次带项目时就有同学把数量条件的方向写反了程序一下子跑了几千次请求把目标站点搞得直接开始拒绝访问。从那以后我所有的采集代码里“连续无新增次数”这个计数器一定有阈值一般设在3到5次。这就像开车时的保险带平时感觉多余真出事的时候救命。2.3 请求频率和采集纪律必须写进代码里这一节字数不多但分量很重。很多新手拿到接口方案兴奋得不行写完一个for循环直接怼上去连sleep都不加几秒钟几百个请求出去对方服务器正常人都能看出这是机器行为轻则IP被临时封禁重则触发风控。我习惯在每次请求之间加至少1秒的延时必要时用随机值比如1秒到3秒之间随机取一个模拟真实用户的节奏。要明白一个底层事实我们请求的数据是人家的服务器资源采集行为本身就是在占用别人的带宽和计算力。合理控制频率、只取自己需要的范围、尊重网站声明的爬虫协议这是底线。在我写的每一份示例代码里延时都会留着这不是做样子是希望从你写第一行爬虫代码起就把这个习惯刻进去。技术本身是中性的但怎么用、用到什么程度才是真正区分一个开发者的东西。3. 实操实现方案A加方案B一次跑通300条3.1 方案A从XHR接口直接采集300条开始写代码前先把依赖装上。接口方案只需要requests一个库就能搞定算是全网最简单的爬虫环境pip install requests我假设你已经通过抓包找到了列表接口并且确认分页参数是offset和limit单页返回20条。下面这段代码的骨架是这样设计的定义一个列表collected存放已采集数据再定义一个集合collected_ids存放唯一标识用于去重然后进入循环每次请求后检查终止条件满了300就停返回空了连续两次也停。import requests import time import csv HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Referer: https://example.com/list, Accept: application/json, text/plain, */*, } BASE_URL https://example.com/api/get_list target 300 # 目标采集条数 offset 0 limit 20 # 单页条数按接口实际返回调整 collected [] collected_ids set() empty_times 0 # 连续空返回计数 while len(collected) target: params {offset: offset, limit: limit} try: resp requests.get(BASE_URL, paramsparams, headersHEADERS, timeout10) resp.raise_for_status() data resp.json() except Exception as e: print(f请求异常: {e}) break items data.get(data, {}).get(list, []) if not items: empty_times 1 if empty_times 2: print(连续两次返回空数据停止采集) break offset limit continue empty_times 0 for item in items: item_id item.get(id) if item_id not in collected_ids: collected_ids.add(item_id) collected.append(item) print(f当前累计 {len(collected)} / {target} 条请求 offset{offset}) offset limit time.sleep(1) if len(collected) target: collected collected[:target] print(f采集完成共 {len(collected)} 条) with open(list_data.csv, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerow([id, title, url]) for item in collected: writer.writerow([ item.get(id, ), item.get(title, ), item.get(url, ), ])代码逻辑不复杂但有三处我想单独强调一下。第一去重用的是item_id来判定不是列表长度。有些接口在滚动分页时如果后端数据恰好有增删offset方式可能把同一批数据返回两次不用唯一ID去重的话统计出来虚高最终保存的数据可能是重复的。第二empty_times这个计数器我做了两次机会因为接口偶尔返回空可能是网络抖动立刻放弃有点可惜给两次机会再停既不激进也不死磕。第三CSV保存用的编码是utf-8-sig这是给Excel看的Windows环境下如果写成utf-8你用Excel打开就是乱码这是老爬虫人用教训换来的经验。注意这个方案里我把请求头的Referer也带上了很多接口会校验这个字段。实战时一定要把浏览器里实际请求的Header完整复制过来不要只带User-Agent就开跑。3.2 方案BSelenium模拟真实滚动采集接口方案虽好但总有它搞不定的情况。有的接口参数有签名逻辑一个加密串把你卡死在门外有的数据根本不是标准接口返回而是页面脚本内部渲染还有的网站明确要求登录态Cookie随会话变化。这些时候Selenium就是那个“实在不行还能上”的备胎方案。先安装依赖pip install selenium webdriver-managerwebdriver-manager 会自动匹配和本机浏览器版本一致的驱动省去了手动下载chromedriver再配路径的痛苦。我第一次手动配webdriver的时候因为Chrome版本自动升级驱动全废报错信息又长又绕折腾了一下午。用这个库之后每次代码启动会自动处理省心太多。第一步打开目标列表页等待列表容器渲染出至少一条数据。第二步循环里做这样几件事统计当前列表元素总数达到300就break否则滚动到底部触发加载等几秒让新数据渲染再次统计数量跟上次对比连续3次无新增就判定到底主动收工。整套逻辑写下来就是import time from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC driver webdriver.Chrome() driver.get(https://example.com/list) target 300 item_selector .list-item last_count 0 no_change_round 0 while True: WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, item_selector)) ) items driver.find_elements(By.CSS_SELECTOR, item_selector) count len(items) print(f当前列表数量: {count}) if count target: print(已达目标300条停止滚动) break driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) # 如果页面是内部div滚动把下面这行替换上面的滚动语句 # container driver.find_element(By.CSS_SELECTOR, .list-wrapper) # driver.execute_script(arguments[0].scrollTop arguments[0].scrollHeight;, container) time.sleep(2) if count last_count: no_change_round 1 if no_change_round 3: print(连续三次滚动无新增停止) break else: no_change_round 0 last_count count results [] for item in items[:target]: title item.find_element(By.CSS_SELECTOR, .title).text href item.get_attribute(href) or item.find_element(By.CSS_SELECTOR, a).get_attribute(href) results.append({title: title, url: href}) driver.quit() print(f提取完成共 {len(results)} 条)注意代码里那个被注释掉的容器滚动用法这几乎是我调试Selenium滚动时遇到最多的坑。有些页面的滚动条不在浏览器窗口上而在中间某个div容器上。你对着window对象滚动半天页面毫无反应数据当然不会加载。怎么判断把鼠标指针移到列表区域滚动滚轮如果滚动的只是列表区域本身页面其他部分不动那它就是滚动容器。这时候就必须把scrollTo挂在容器元素上。Selenium方案在效率上完败给接口方案但它也有不可替代的价值你能亲眼看到浏览器在做什么。对于零基础学习者来说第一次跑通这个脚本时看着页面自己往下滚、数据自己往上长整个动态列表的运作方式一下就通了。我建议你真的跑一遍哪怕最终不采用这个方案这个过程也值得。3.3 300条数据的清洗与落盘方案不管用哪个方案收集到的原始数据基本都带噪音。Selenium方案尤其严重因为DOM里的文本经常带着首尾空格、换行符混合着标签里隐藏的其他内容。接口方案虽然数据格式好但必要字段也可能有空值或重复。清洗是绕不开的一步。我习惯把数据先丢进pandas做一轮整理。去掉完全重复的记录按实际业务字段去重把标题这种文本做strip和去换行处理最后统一落盘成CSVimport pandas as pd df pd.DataFrame(results) df.drop_duplicates(subset[title], inplaceTrue) df[title] df[title].str.strip().str.replace(\n, ) df.to_csv(scroll_list.csv, indexFalse, encodingutf-8-sig) print(f清洗后保留 {len(df)} 条)这里我想提一个容易被忽略的小细节终止条件设300条清洗完往往不到300条。因为我前面只保证了“采集到的元素数量到了300”并没有保证“清洗后有效数据有300条”。如果你项目硬性要求最终数据必须300条最省事的做法是设置一个安全余量比如让程序滚动采集到350条再停清洗完如果超过300条再截断到300。多出来的10%数据量成本可以忽略但能显著提高成功率。4. 常见问题与避坑实录4.1 滚动到底了但新数据就是不出来这个问题在论坛上出现的密度极高症状是execute_script滚动后页面看起来到了底部可列表数量纹丝不动。排查顺序我每次都按这个来先确认滚动对象对不对。如果是页面内div滚动你还对着window滚那当然没反应换成容器滚动基本就好。再确认滚动之后有没有等待新数据渲染。很多懒加载是滚动后才发请求请求回来还要时间渲染你滚动完立刻数DOM新数据还没生成数字自然不变。等新元素这件事我不用固定的time.sleep那要么不够要么浪费。更稳的是用WebDriverWait配合一个lambda判断等列表元素总数真正超过上一轮的数量WebDriverWait(driver, 10).until( lambda d: len(d.find_elements(By.CSS_SELECTOR, item_selector)) count )这段代码的意思是给浏览器最多10秒只要列表数量比之前多了就继续超时再报错。这样的等待是“等结果”而不是“等时间”在真实网络环境下稳定得多。4.2 每次统计出来的数量都有重复接口方案和Selenium方案都会遇到重复问题。接口方案最常见的原因是分页语义理解错比如offset当页数用或者limit设置和接口默认行为冲突导致两个请求返回了重叠的数据。Selenium方案则常发生在页面加载过慢时上一批元素还没被替换新数据又追加进来同一句话在DOM里出现两遍。解法统一靠唯一字段去重。商品类的用商品ID文章类的用标题加链接的组合就是前面代码里collected_ids的作用。多说一句去重字段一定要选有业务意义的字段别拿行号或随机数那只会让你以为去重了实际上什么都没去。4.3 终止条件失效程序一直跑不停这是我早期被真实教训过的地方值得单独分享。理论上数量条件写对了循环就会停但现实有各种花式意外。比如页面上有些节点是骨架屏它也算在列表选择器结果里但骨架屏数量根本不跟随滚动变化再比如接口失败时返回的是上一次成功请求的缓存数据这时items非空但内容不增长你的“空数据终止条件”永远触发不了程序就在原地打转。所以我现在写采集循环永远至少两层终止条件。第一层是目标数量到了直接停第二层是连续N轮无新增主动停。后者就是前面代码里的no_change_round计数器。这两层一起才能在各种边界情况里保证程序体面收尾。4.4 WebDriver和浏览器版本对不上Selenium新手报错里有一类问题出现频率极高错误提示长这样SessionNotCreatedException或者一句话里带着“This version of ChromeDriver only supports Chrome version XX”。原因很直白你手动下载的chromedriver和本机浏览器版本号对不上。Chrome经常自动升级升级完旧的驱动就废了。解决方案就是我前面推荐的webdriver-manager它会在运行时自动检查并下载匹配的驱动。生成驱动时用下面这种标准写法能免掉绝大多数版本焦虑from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager driver webdriver.Chrome(serviceService(ChromeDriverManager().install()))另外再提醒一句有些企业环境会限制驱动下载权限如果webdriver-manager报下载失败就需要找人帮你手动下载后放到本地路径再用Service指向它。这种情况不多见但真遇到时知道原理不至于慌。4.5 跑着跑着被限流了怎么办被限流的症状很典型。接口方案可能突然返回403或者验证码页面Selenium方案可能弹出一个需要点击的人机验证框。这时候第一件事不是去研究绕过姿势而是先停手反省我是不是请求太快了我是不是没有按真实用户节奏来应对策略就三条加大延时、降低并发、保持会话一致。接口方案里注意用同一个requests.Session实例发送所有请求这样服务端才能识别你的会话连续性每次新建Session反而容易触发风控。调延时的话从原来的1秒增加到3到5秒往往就有效。说实话绝大多数个人采集项目根本不需要最高速度稳一点、慢一点反而能跑得久。4.6 程序中断了如何断点续采爬虫跑到一半网络断开、电脑重启是再正常不过的事。如果你没有为这种情况预留方案就只能从头再采一遍浪费时间和对方的服务器资源。断点续采的通用思路很朴素先把已经采到的数据落盘下次启动时读回来用唯一ID集合初始化去重逻辑再从有意义的起始位置继续。以接口方案为例你可以在CSV里已经存了之前采的300条那下次启动时先读这些数据的ID初始化collected_ids同时把offset设置成已采数量对应的位置程序就能顺着往下走。这个习惯在个人练习项目里显得有点多余但一旦数据规模变大它省下的时间是按小时计的。结束语最后说点我在实践里的真实体会。动态列表滚动加载这个场景本质上就是一次“页面行为还原”。你选择了Selenium模拟滚动是在还原用户操作你选择了requests直接请求XHR接口是在还原网络请求。两条路殊途同归核心都是理解数据从服务器到浏览器的路径。我这个项目选择300条作为目标也是有意为之量不大哪怕采集逻辑有缺陷程序跑几分钟就能看到结果特别适合用来验证自己对终止条件的理解。等你把这套流程跑顺了再去面对几万条甚至几十万条的数据流只是把目标数和频率策略调一调的事。再给你一个扩展建议练完这个项目可以把终止条件里的“300”抽出来做成命令行参数让脚本变成可以传参的工具也可以把去重逻辑换成SQLite数据库存储让它具备面临更大数据的资格。滚动加载只是动态页面的入门形态学会了它你才算真正摸到了爬虫实操的大门。还是那句话所有技能都要用在正道上控制频率、只取所需、尊重服务条款这样才能走得更远。
返回列表