ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python爬虫:Selenium模拟点击加载更多,批量提取动态列表PDF链接

2026/10/8 15:17:42 拓冰建站 浏览量
Python爬虫:Selenium模拟点击加载更多,批量提取动态列表PDF链接 先交代一下背景。上周一个前同事丢给我一个网址说他们整理行业资料时碰到一个页面列表默认只显示一小部分要点两次按钮才会把后半段内容追加出来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(optionsoptions)注意这里做了两件事。第一件是窗口最大化因为很多加载更多按钮在页面底部窗口太小的时候元素在可视区域之外点击行为会变得不可靠。第二件是去掉那条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_selectora[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 clickableJS强制触发点击事件反而稳定。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, hrefTrue): 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?id123它不以.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, encodingutf-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参数比如?fromindex和?fromlist肉眼看着是同一个文件但字符串完全不同。这时先按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加streamTrue只读前几个字节就断开def verify_pdf(url): headers {User-Agent: Mozilla/5.0} try: resp requests.get(url, headersheaders, streamTrue, timeout10) 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的streamTrue如果没设置遇到大文件会整个下载到内存几百个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_id123typepdf页面文字叫下载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就开始写抓取逻辑。先把数据来源、链接形态、反爬尺度这三个问题搞明白剩下的活基本就是把代码块拼起来。这篇就说这么多代码拿去改改选择器就能用。