☰
Python爬虫:Selenium模拟点击加载更多,批量提取动态列表PDF链接
2026/10/8 15:17:41 网站建设 项目流程

先交代一下背景。上周一个前同事丢给我一个网址,说他们整理行业资料时碰到一个页面,列表默认只显示一小部分,要点两次按钮才会把后半段内容追加出来,PDF链接全藏在里面,手工点得手酸,想让我写个Python脚本把列表里所有PDF链接一次性抠出来。我打开一看,典型的动态加载列表:第一批数据是页面加载时渲染出来的,剩下的要配合点击才能塞进DOM。这种场景在爬虫实战里真的太常见了——很多人一开始用requests去拿,发现页面源码里连PDF的影子都没有,就开始怀疑人生。今天这篇就把从"看见列表"到"抓到全部PDF链接"的完整链路拆开,代码、思路、翻车记录都放出来,适合刚学会requests和BeautifulSoup、一遇到动态页面就卡壳的朋友。

其实这个任务的核心就三件事:想办法让列表把内容吐全,定位所有PDF链接,再把它们清点干净。围绕这三件事,下面一步步来。

1. 这类"点两次才出全"的列表页,到底难在哪

1.1 三种最常见的列表交互形态

先说"列表点击2次"的实际场景长什么样。按我遇到过的项目,大致有三种形态。第一种是"加载更多"按钮:列表底部放一个按钮,点一次追加一批,标题里说的"要点2次",很可能就是按钮连续点两次,或者每点一次追加几条,要点满两次才能把PDF链接全部塞进页面。第二种是分页器:列表被拆成多页,按"下一页"去翻,本质上和点击等价。第三种是两级列表:先点一个分类或年份标签,子列表才展开,展开后再点"加载全部",最终才会出现PDF条目。不管哪种,最终结果都一样——你想要的PDF链接不在初始HTML里,必须通过交互触发异步加载后才出现在页面上。

这里我想多说一句,很多人看到"点击2次"会觉得这是个小事,直接写两遍button.click()不就完了。但真正麻烦的不是点这两下,而是点击之后要等多久、怎么确认列表确实加载完了、以及加载出来的链接到底藏在哪些标签里。这几个问题不解决,脚本要么抓到一半就停,要么抓回来一堆无效链接。

1.2 动态渲染为什么让requests直接失效

很多人跑完第一步就卡住了,原因是requests.get()拿到的响应只是服务器返回的第一版HTML。现在很多列表页都是前端框架渲染的,页面加载后JS脚本会再向后台要数据,拿到JSON之后再拼成列表渲染进DOM。也就是说,你用requests拿到的那份HTML里,列表区域要么是空的,要么只有第一屏的几条占位数据。即便里面有第一屏记录,PDF链接也远没到"全部"的程度。这时候再往下写正则匹配,匹配出来的结果必然缺胳膊少腿。

这里有个非常反直觉的点:浏览器地址栏里明明是同一个URL,你手工打开看到的PDF链接数量,和你用requests拿到HTML里解析出来的PDF链接数量,完全不一样。原因就在于一个经过了浏览器的JS执行,另一个没有。想通这一点,解决方案就清晰了:要么自己去还原那些JS请求(找接口),要么让浏览器替你去执行那些JS(自动化点击)。两种路线的代码风格完全不同,选错了就是事倍功半。

1.3 技术路线:先找接口,再考虑模拟点击

在动手写代码之前,我一般先打开开发者工具的Network面板,手动在页面上点一次按钮,然后看多了哪些XHR请求。如果能找到一个返回PDF链接的JSON接口,那就走requests纯请求路线,速度最快,代码也少。但实际项目里常有三种情况让接口方案破产:接口参数里带了动态token、返回的PDF地址做了加密、或者链接是前端本地拼出来的。这时候才轮到Selenium上场,用真实浏览器去点击、去等待、去拿渲染完成的DOM。

两种方案的取舍用一句话概括:能找接口就找接口,找不到再用Selenium兜底,别一上来就把Selenium当默认选项,那玩意儿比纯requests慢十倍不止。给你一张对照表,方便判断自己该走哪条路:

方案速度适用场景反爬压力
requests直接请求JSON接口快接口易找、参数不加密低,但需要伪造请求头
Selenium模拟点击慢接口加密、纯前端渲染中,容易被识别自动化
Playwright模拟中等和Selenium类似,等待机制更强中,用法更现代

2. 页面摸底与运行环境准备

2.1 用Network面板确认PDF链接藏在哪个请求里

开始写任何代码之前,先花五分钟做摸底。打开目标页面,按F12进入开发者工具,切到Network标签页,勾选Fetch/XHR,然后手动触发一次"加载更多"或"下一页"。这时候观察新增的请求,看它的Response是不是一坨JSON,里面有没有pdfUrl、fileUrl、href之类的字段。如果有,恭喜你,接口方案成立,后面只需要用requests模拟这个GET请求并循环拿数据就行。

如果请求里返回的是HTML片段,或者压根没有新的XHR请求——数据一次性藏在页面脚本变量里了——那就转Selenium。这一步看着简单,但能帮你省掉少则半小时、多则一下午的时间。我每次接爬虫任务都会先做这个,因为模拟点击看起来很爽,实际跑起来慢、容易被验证码拦、还不好调试,能不走就不走。另外,看接口的时候顺便看一眼请求头里有没有带token、sign这类参数,有的话就趁早打消接口方案的念头。

2.2 一包搞定Selenium驱动和解析库

环境准备其实没什么花头,Python 3.8以上就行。安装我用到的库:

pip install selenium beautifulsoup4 requests

这里解释一下为什么只装这三个。Selenium负责模拟点击和拿渲染后的页面源码;BeautifulSoup负责从源码里精准提取PDF链接;requests负责最后验证链接能不能打开。如果你用的Selenium是4.6以上版本,浏览器驱动它会自动通过Selenium Manager下载,不用再单独装webdriver-manager,也不用自己下载chromedriver放到Path里。这个细节很多人不知道,还在按老教程手动配驱动路径,实际上新版本已经把这步省掉了。

2.3 写爬虫前的合规自查

聊两句题外话。默认你处理的是公开可访问的资料页面,采集之前花几秒钟看一眼网站的robots.txt和用户协议,确认没有明确禁止抓取,然后在实现里控制一下点击频率,别让列表接口每秒被打几十次。这个习惯在实战中能帮你少惹很多麻烦,后面第5章你会看到,频率太快反而会触发反爬验证,得不偿失。

3. Selenium实现"点击2次"加载列表并抓取全部PDF链接

3.1 初始化浏览器与显式等待策略

现在进入正题。初始化浏览器的时候,我会关掉自动化特征提示,让窗口最大化,代码长这样:

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 from bs4 import BeautifulSoup from urllib.parse import urljoin import re import time import random import requests def init_driver(): options = webdriver.ChromeOptions() options.add_argument("--start-maximized") options.add_experimental_option("excludeSwitches", ["enable-automation"]) return webdriver.Chrome(options=options)

注意这里做了两件事。第一件是窗口最大化,因为很多"加载更多"按钮在页面底部,窗口太小的时候元素在可视区域之外,点击行为会变得不可靠。第二件是去掉那条"Chrome正在受自动测试软件控制"的黄条,虽然原理上不改变行为,但在一些有webdriver探测的站点上能减少被识别出来的概率。这一步我没有用无头模式,原因很简单:调试阶段你需要亲眼看到浏览器干了什么,等脚本稳定之后再决定要不要headless。

3.2 循环点击按钮直到列表不再增长

回到标题里的核心动作"点击2次"。如果确认只有两次点击,直接写个for循环点两次就够了:

for _ in range(2): try: load_more = WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.ID, "load-more")) ) driver.execute_script("arguments[0].click();", load_more) time.sleep(2) except Exception: break

但实际项目里我更推荐写成"点到不再增长"的版本。原因很简单:有些列表的点击次数不是固定的,可能这次点两次就完了,下次列表数据更多,要点三次、四次。固定写死两次,迟早要返工。通用版本的核心思路是——先记录当前列表里PDF链接的数量,点一次,再数一遍,如果数量变多了就继续点,直到数量不再变化或者按钮消失。对应代码:

def expand_list(driver, pdf_item_selector="a[href$='.pdf'], a[href*='download']"): previous = 0 while True: items = driver.find_elements(By.CSS_SELECTOR, pdf_item_selector) current = len(items) if current == previous: break previous = current try: load_btn = driver.find_element(By.ID, "load-more") if not load_btn.is_enabled(): break driver.execute_script("arguments[0].click();", load_btn) time.sleep(2 + random.random()) except Exception: break return previous

这里有个容易踩的细节:停止条件用"数量没变化",而不是"按钮点不动"。因为很多列表在最后一次点击后会把"加载更多"按钮移除或隐藏,单纯判断按钮存在会漏掉最后一次已经加载出来的数据;反过来,用数量变化做哨兵,能确保退出循环时所有PDF链接都已经在DOM里。sleep(2 + random.random())是我习惯的默认间隔,比固定sleep更接近人工节奏,网络慢的站点可以把这个数字加到3。至于点击按钮为什么用execute_script而不是直接load_btn.click(),是因为很多页面的按钮上有遮罩层、动画或者半透明元素,普通click偶尔报"element not clickable",JS强制触发点击事件反而稳定。

3.3 在列表容器内精准提取PDF链接

点击流程跑完,页面上就有了完整列表。提取链接时我特别建议一个做法:先定位到列表容器,再在容器里找链接,而不是全页面正则搜索。原因是很多页面的头部、侧边栏、页脚也有一堆PDF相关链接,全页正则会把它们混进来,后面清理工作量变大。下面这个函数就是按"容器优先、全页兜底"的思路写的:

def extract_pdf_links(driver, container_selector="#file-list"): soup = BeautifulSoup(driver.page_source, "html.parser") container = soup.select_one(container_selector) if container is None: container = soup links = [] for a in container.find_all("a", href=True): href = a["href"].strip() if re.search(r"\.pdf($|\?)", href, re.I): links.append(urljoin(driver.current_url, href)) elif "download" in href.lower(): links.append(urljoin(driver.current_url, href)) return list(dict.fromkeys(links))

这里有两个细节。第一个是urljoin,列表里的href很多是相对路径,比如"/uploads/pdf/001.pdf",不拼上当前页面URL,后面根本没法请求。第二个是匹配条件里同时考虑了".pdf"结尾和包含"download"两种情况。实际站点里很多PDF链接长这样:/download?id=123,它不以.pdf结尾但确实是下载地址;如果你只匹扩展名,这些链接就会漏掉,列表数量看着少一截。dict.fromkeys(links)这行用字典去重还能保留原始顺序,比set去重更可控。

3.4 完整可运行代码

把上面的函数串起来,一个能跑通的最小示例就是下面这样:

def main(): driver = init_driver() target = "https://example.com/resource/list" driver.get(target) try: WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, "#file-list")) ) except Exception: print("列表容器没有加载出来,检查选择器或网址") driver.quit() return total = expand_list(driver) print(f"列表最终加载出 {total} 个条目") pdf_links = extract_pdf_links(driver) print(f"提取到 {len(pdf_links)} 个PDF链接") with open("pdf_links.txt", "w", encoding="utf-8") as f: for link in pdf_links: f.write(link + "\n") driver.quit() if __name__ == "__main__": main()

实测下来这套代码在绝大多数"加载更多"型列表上都成立。改动量主要在两个选择器:列表容器#file-list和加载按钮#load-more,照页面的实际class或id改成对应值,剩下逻辑基本不用动。如果你打开的页面套了iframe,记得在定位元素之前先driver.switch_to.frame(...),这个坑我在第5章详细说。

4. 抓完链接之后的清理与验证

4.1 相对路径补全和去重排序

Selenium解析出来的链接,不要直接交付,先做一轮清理。第一步补全相对路径,提取函数里已经用urljoin处理了。第二步去重,这里说个坑:同一个PDF在列表里可能多次出现,href一模一样;也可能带不同的query参数,比如?from=index和?from=list,肉眼看着是同一个文件,但字符串完全不同。这时先按URL去重,如果还嫌不够,就按去掉query后的路径再合并一次:

def dedup(links): seen = set() result = [] for link in links: path = link.split("?", 1)[0] if path not in seen: seen.add(path) result.append(link) return result

这个办法用一点点精度换来了更高的整洁度。比如同一个文件从两个入口进列表,query参数不同但PDF本身是同一个,合并掉更符合"列表所有PDF"的目标。

4.2 用最小请求验证链接是否真的能打开

拿到URL列表之后,建议再花几秒钟验证一波。最简单的方案是发HEAD请求看状态码和Content-Type,但实战里很多下载服务器不支持HEAD,或者对HEAD返回405。所以我通常用GET加stream=True,只读前几个字节就断开:

def verify_pdf(url): headers = {"User-Agent": "Mozilla/5.0"} try: resp = requests.get(url, headers=headers, stream=True, timeout=10) if resp.status_code != 200: return False first = resp.raw.read(5) return first == b"%PDF-" except Exception: return False finally: resp.close()

这里用到的技巧是:PDF文件的开头5个字节一定是%PDF-,所以只要判断前五个字节就能确认它是真PDF,不用把整个文件下载下来。对几百个链接来说,这个验证每条约几十毫秒,一次性就能把失效链接筛出去。顺便说一下,requests.get的stream=True如果没设置,遇到大文件会整个下载到内存,几百个PDF可能直接拖垮脚本,这个参数很重要。

4.3 导出结果

导出格式我觉得txt就够用,每行一个链接,后面再写批量下载脚本时直接按行读取。如果想要更友好的交付,也可以改成Excel,把文件名、大小、链接放在不同列。这个看交付对象,给程序员朋友txt最方便,给非技术同事Excel更贴心。最终交付之前我会再跑一遍统计,确认链接数量能和列表条目数对上,这个数字对不上一定有问题,宁可回头看代码也别直接交出去。

5. 实战翻车记录:点击无效、超时漏链接的排查全过程

5.1 按钮点击了但列表纹丝不动:iframe与遮挡

我印象最深的一次翻车,是脚本不报错、按钮也"点"了,但列表数量一直没变化。当时第一反应是sleep不够,把2秒加到5秒,没用。然后怀疑是按钮定位错了,用find_element确认能定位到,还打印了按钮的text,也没错。最后打开页面本身的Elements面板,才意识到整个列表嵌在一个iframe里——列表容器、加载按钮都不在顶层文档中,Selenium默认只能操作顶层文档的元素,必须先进iframe再操作:

iframe = driver.find_element(By.CSS_SELECTOR, "iframe#list-frame") driver.switch_to.frame(iframe) # 操作完列表后,如果需要操作顶层元素,记得切回去 driver.switch_to.default_content()

这类问题的排查思路值得说一下:先确认元素能定位,再确认点击真的触发到了,最后确认数据有没有进入DOM。一步步验证下来,几乎都能锁定问题出在哪一环。还有一种高发情况是按钮被固定定位的遮罩盖住,普通click报"element not clickable",这时用execute_script("arguments[0].click();", button)强制触发点击事件就能绕过去,核心代码里统一用了这种方式,也有这个考虑。

5.2 等待超时:别把隐式和显式等待混着用

另一个高频翻车是WebDriverWait超时。明明人肉操作没问题,脚本却偶发报TimeoutException。排查几轮后发现,坑不在元素本身,而是我当时同时设置了driver.implicitly_wait(),代码里又写了WebDriverWait显式等待。两者混用会让查找逻辑变得难以预测,有时隐式等待还没结束,显式等待的条件已经判定失败,导致无谓的超时。后来的解决办法是统一只用显式等待,把隐式等待注释掉;同时把等待条件从presence_of_element_located换成element_to_be_clickable。很多"加载更多"按钮在页面上是可见的,但被遮罩覆盖或者处于disabled状态,presence条件只判断"存在",而element_to_be_clickable还会检查可见、可用、不被遮挡,更贴合点击场景。

5.3 漏抓链接:PDF不一定带.pdf扩展名

漏链接是最容易发生在"看着没问题"的时候。我有一次抓完统计,列表有60条,只抓到51个链接。排查时先把列表条目数打印出来核对,发现少的就是那些不带.pdf扩展名的下载链接。这种链接在href里长这样:/download?file_id=123&type=pdf,页面文字叫"下载PDF",但链接字符串里没有.pdf字样。解决思路有两个层面。第一层是解析时放宽条件,把包含download、file、attach等关键词的a标签也收集进来,再在验证阶段用文件头判断是否真PDF,这样既不会漏也不会混入假链接。第二层是直接去找后台接口的JSON字段,如果翻Network时看到接口返回里有pdfUrl字段,从源头抓最准。前者适合临时脚本,后者适合长期维护的采集任务。

5.4 反爬体感:别把列表点得太快

最后提一个很实际的体感问题。Selenium方案里,如果点击间隔固定且非常短,比如sleep(0.5)甚至不sleep,一些防御严格的站点会在几次点击后弹出验证码。第一次遇到时我还以为是脚本触发了什么逻辑错误,后来把点击间隔拉长到2到3秒,并且每次点击前随机加一个0.5到1秒的偏移量,就没再出现。实现上很简单,把time.sleep(2)换成time.sleep(2 + random.random())就行,点击节奏看起来更像人工。更关键的是遵守第2章提到的底线:只采公开数据、不并行大规模打列表接口、不做绕过登录验证的操作。这个意识比任何代码技巧都值钱。

6. 把脚本改成通用列表采集器的三个小建议

这套东西写完,稍微改一改就能复用到很多列表采集场景。我整理三个最常见的扩展方向。

第一个是把匹配扩展名从pdf换成其他类型。列表里要抓xlsx、docx、zip,只需要把extract_pdf_links里的正则改成re.search(r"\.(pdf|xlsx|zip|docx)($|\?)", href, re.I),再把验证函数里的文件头判断改成对应格式的magic bytes就行。第二个是把"加载更多"逻辑改成翻页逻辑。翻页时不是循环点同一个按钮,而是每次抓完当前页,点击"下一页"按钮,等URL变化或列表内容变化再继续,停止条件从"数量不再增长"改成"下一页按钮不存在或disabled"。第三个是改成定时增量采集,第一次跑全量,之后每天跑一次,把新出现的PDF链接和昨天的快照对比,增量写入数据库。这三个方向可以说是把同一个模板的最后一公里分别打通,扩展空间非常大。

做完这个项目,我把extract_pdf_links和expand_list这两个函数存成了模板,之后接任何列表类采集需求都是改选择器直接用。前几天另一个同事也遇到类似页面,拿过去跑了一下,在群里感叹说原来以为这种"点击后才有链接"的页面只能手工整理,没想到二十几行代码就搞定了。我回了一句:写爬虫这几年,我最怕的不是动态页面,而是不先开F12就开始写抓取逻辑。先把数据来源、链接形态、反爬尺度这三个问题搞明白,剩下的活基本就是把代码块拼起来。这篇就说这么多,代码拿去,改改选择器就能用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询