1. 项目概述与核心应用场景
1.1 这类爬虫解决的到底是个什么问题
先说结论:ctwp_spider 这个项目,本质上是一套基于 Selenium 的浏览器自动化方案,专门用来处理那些“需要真实浏览器行为才能完成的下载站资源采集”场景。我最初做这个项目,是因为手头有一批历史资料散落在某个网盘平台上,平台页面是典型的动态渲染结构,接口签名复杂,而且必须在登录态下才能拿到真实下载地址。用常规的 requests 直接拉页面只能拿到一堆空壳 HTML,真正有用的数据都在 JS 执行完之后的 DOM 里。
这类网盘有一个通病:页面结构不复杂,但交互链路长。用户需要经历“登录 → 找到资源 → 进入详情页 → 等待倒计时 → 点击下载 → 弹出真实链接”这一整套流程。而且每个环节都可能插入验证码、行为校验、时间锁等机制。如果只写一个脚本去模拟 HTTP 请求,等于要把整个前端交互逻辑和签名算法全部逆向一遍,开发成本极高,维护成本更高——对方改一次前端,你的脚本就废一次。
而 Selenium 的思路是“我不跟你玩协议,我直接帮你开一个浏览器”。它模拟的是真实用户的完整操作路径,把浏览器当成一个人来用。平台再怎么做动态渲染、再怎么做 JS 加密,对浏览器自动化来说基本是透明的,因为它看到的是什么,脚本操作的就是什么。这是 ctwp_spider 能够稳定跑起来的根本原因。
1.2 为什么非要用 Selenium 而不是其他方案
很多人一听说爬虫就想到 Scrapy 结合 requests,确实在大规模纯数据采集场景下,Scrapy 的并发和去重能力比 Selenium 强得多。但在网盘这种场景下,Scrapy 有几个致命短板:
第一,登录态维持麻烦。网盘系统的 Session 通常绑定浏览器指纹、User-Agent、Cookie 生命周期等多重信息。用 requests 模拟登录不是不行,但每次都要处理验证码,还要手动维护 Cookie 池,一旦过期就要重新走一遍登录流程,很不优雅。Selenium 可以天然复用浏览器 Profile,登录一次之后把 Cookie 持久化,后续启动直接注入,省掉很多重复劳动。
第二,动态渲染问题。现在的主流网盘页面基本都是 Vue 或 React 框架,数据接口虽然是异步的,但关键的下载链接往往还需要再次携带签名和时间戳去请求,且这些签名是在前端计算出来的。用纯 HTTP 请求很难拿全。Selenium 直接加载真实浏览器引擎,页面渲染完成后的 DOM 就是最终结果,不需要关心前端逻辑怎么实现。
第三,验证码与行为校验。Selenium 配合真实浏览器内核,可以模拟鼠标移动、键盘输入、点击节奏,甚至能用真实摄像头或打码平台来做验证码识别。这一点后面我会专门展开讲。
当然,选择 Selenium 不代表完全不写网络请求。在 ctwp_spider 里,我采用的是“混合模式”:Selenium 负责所有需要真实浏览器交互的环节,比如登录、点击、等待倒计时;而一旦拿到了直链,文件下载本身我改用 requests 或迅雷等下载器完成,这样既能保证交互环节稳定,又不浪费 Selenium 在下载大文件时的低效等待。
2. 环境准备与关键依赖安装
2.1 环境版本组合
在动手写代码之前,环境版本是最容易踩坑的地方。Selenium 对版本兼容性非常敏感,尤其是 Chrome 浏览器和 ChromeDriver 的版本必须严格对应,否则启动时直接报 session not created 错误。我目前在实际项目中使用的组合是:
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.10+ | 3.8 以下不建议,某些语法和 typing 特性不受支持 |
| Selenium | 4.15+ | 4.x 版本 API 变化较大,老代码需要改造 |
| Chrome | 120+ | 各版本对应需匹配 ChromeDriver |
| ChromeDriver | 与 Chrome 完全一致 | 手工下载或用 WebDriver Manager 自动管理 |
| 操作系统 | Windows 10/11 | Linux 服务器上也能跑,但验证码处理更麻烦 |
在 Selenium 4.x 时代,官方推荐使用webdriver-manager来自动处理驱动程序版本问题,不需要再手动去下载 ChromeDriver。只需要简单配置一下,启动时它会自动检测本机 Chrome 版本,然后下载对应的驱动到本地缓存。这个工具对新手非常友好。
一个常见误区是盲目追求最新版本。我在测试中发现,Selenium 4.20 之后某些旧定位策略的默认行为有调整,如果你是从 3.x 时期迁移过来的代码,可能会出现兼容性问题。所以如果是生产环境长期运行,建议锁定版本,至少在核心代码跑通后不要轻易升级。
2.2 WebDriver 配置要点
Selenium 启动浏览器时有几个配置项直接影响爬虫稳定性,这里逐一说明。
pip install selenium webdriver-manager然后看一段基础配置代码:
from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager options = Options() options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False) options.add_argument("--disable-blink-features=AutomationControlled") options.add_argument("--user-data-dir=C:/selenium_profile") # 持久化登录态 options.add_argument("--start-maximized") options.add_argument("--lang=zh-CN") driver = webdriver.Chrome( service=Service(ChromeDriverManager().install()), options=options )参数说明:
excludeSwitches和disable-blink-features是为了隐藏“浏览器正受自动化控制”的提示。虽然这个提示不影响大多数场景,但某些平台的 JS 会检查navigator.webdriver属性,一旦发现是自动化环境就拒绝加载资源。user-data-dir是保存浏览器用户数据目录。这个参数非常关键,它能让 Cookie、LocalStorage 持久化,下次启动直接复用登录态,省去二次扫码登录。start-maximized避免某些页面在非标准窗口尺寸下响应式布局导致元素定位偏移。
注意:
user-data-dir指向的目录不要包含中文路径,某些版本的 Chrome 在中文目录下会出现用户数据加载失败,启动白屏。
2.3 工程目录结构
一个可维护的爬虫项目,代码结构设计要比脚本本身重要得多。我常用的是下面这个布局:
ctwp_spider/ ├── main.py # 主入口 ├── config.py # 全局配置 ├── core/ │ ├── browser.py # WebDriver 初始化与销毁 │ ├── login.py # 登录流程 │ ├── parser.py # 页面解析 │ ├── downloader.py # 下载调度 │ └── utils.py # 工具函数 ├── data/ │ ├── cookies.json # 登录态快照 │ └── tasks.txt # 待处理链接清单 └── logs/ └── runtime.log # 运行时日志这样拆的好处是:登录、解析、下载互相解耦。如果某个环节出了问题,可以单独调试,不用每次都被整条链路拖累。而且后续如果要扩展成多线程或分布式,只要改 downloader 部分即可,其他模块可以直接复用。
3. 核心实现拆解与代码实战
3.1 登录态管理与 Cookie 注入的实现
网盘类平台对登录态的管控通常比较严格,主要体现在两方面:一是 Session 过期时间短,二是登录行为本身有验证码保护。因此登录这一环节需要设计成“尽量少触发、触发则自动处理”的模式。
我的做法是先检查本地是否有合法的 Cookie 快照,如果有就直接注入,不发任何请求去试探;如果没有则启动手动登录模式。
import json import time from selenium.webdriver.common.by import By LOGIN_URL = "https://example.com/login" # 仅示例,替换为实际地址 def save_cookies(driver, path="data/cookies.json"): cookies = driver.get_cookies() with open(path, "w", encoding="utf-8") as f: json.dump(cookies, f, ensure_ascii=False, indent=2) def load_cookies(driver, path="data/cookies.json"): try: with open(path, "r", encoding="utf-8") as f: cookies = json.load(f) for cookie in cookies: driver.add_cookie(cookie) return True except FileNotFoundError: return False def manual_login(driver): driver.get(LOGIN_URL) input("请在打开的浏览器中完成登录,完成后按回车继续...") save_cookies(driver)有人可能会问:为什么不用自动化直接输入账号密码?我的经验是,现在平台对自动填表的识别非常敏感,一旦检测到自动填充行为,会直接触发滑块验证码,处理起来比手动登录更麻烦。手动登录一次,之后无限复用登录态,性价比最高。
Cookie 注入之后还有几个细节需要注意:
- Cookie 中存在
expiry字段的,注入时不要手动修改。如果 Cookie 已过期,add_cookie 会静默失败,需要捕获异常并重新登录。 - 某些平台会把一部分状态放在 LocalStorage 里,
get_cookies拿不到。此时需要额外执行 JS 来导出 LocalStorage,保存到本地,下次再注入。代码实现类似:
def save_local_storage(driver, path="data/localstorage.json"): storage = driver.execute_script("return window.localStorage;") with open(path, "w", encoding="utf-8") as f: json.dump(storage, f, ensure_ascii=False, indent=2) def load_local_storage(driver, path="data/localstorage.json"): with open(path, "r", encoding="utf-8") as f: storage = json.load(f) for key, value in storage.items(): driver.execute_script(f"window.localStorage.setItem('{key}', '{value}');")3.2 页面解析与下载链接提取
页面解析是整个项目中最“碎”的部分。网盘页面通常由多个异步模块拼装而成,比如文件列表是一个接口、下载按钮的可用状态是另一个接口、真实链接又是第三个接口。前端框架的组件往往有相同的 class 名,直接按 class 定位很容易定位错。
我的策略是:优先用 XPath 结合文本内容定位,并且在定位之前先等待关键元素出现。
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By def wait_and_click(driver, xpath, timeout=15): elem = WebDriverWait(driver, timeout).until( EC.element_to_be_clickable((By.XPATH, xpath)) ) elem.click() return elem def extract_download_page_links(driver, task_url): driver.get(task_url) # 等待文件列表区域渲染完成 WebDriverWait(driver, 20).until( EC.presence_of_element_located((By.XPATH, "//div[contains(@class,'file-item')]")) ) items = driver.find_elements(By.XPATH, "//div[contains(@class,'file-item')]//a[contains(@href,'/s/')]") return [item.get_attribute("href") for item in items]这里有一个非常实用的经验:不要用find_elements一次拿完全部元素再遍历,因为异步渲染过程中后加载的元素可能还没出现在 DOM 里。更好的方式是循环等待“元素数量不再增长”再开始提取:
def wait_for_stable_element_count(driver, xpath, expected=None, timeout=30): count = -1 start = time.time() while time.time() - start < timeout: current = len(driver.find_elements(By.XPATH, xpath)) if current == count and (expected is None or current == expected): return current count = current time.sleep(1) return count这个方法的逻辑是:页面在加载时,元素数量会不断变化;当连续两次读取到的数量一致时,基本可以断定渲染完成了。这比单纯固定 sleep 几秒钟要高效、准确得多。
另外我强烈建议对每一类链接的提取都封装成独立的函数,入参是 driver,出参是结构化数据。不要在一个函数里又做跳转又做解析又做下载,那样后期维护就是一场灾难。
3.3 下载触发与文件保存
拿到真实下载链接之后,接下来的问题是怎么把文件稳定地下载到本地。这里有两种方案,我分别说明它们的适用场景。
方案一:直接用 Selenium 点击下载,然后等待浏览器自动下载完成。这种方式的优点是简单,不需要额外处理链接有效性;缺点是下载大文件时 WebDriver 进程会一直阻塞,而且浏览器默认的下载行为会受弹窗、保存路径、多文件重名等因素干扰。
方案二:把链接提取出来后交给 requests 或者其他下载工具。我的做法是:
import requests def download_file(url, local_path, proxies=None): headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": url.split("/")[0] + "//" + url.split("/")[2], } resp = requests.get(url, headers=headers, stream=True, timeout=30) total = int(resp.headers.get("Content-Length", 0)) done = 0 with open(local_path, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk) done += len(chunk) # 这里可以打印进度 return local_path这里面有个容易被忽略的细节:Referer 字段。很多网盘的下载直链做了 Referer 校验,如果你直接用 requests 下载而不带 Referer,服务器会返回 403。这个 Referer 一般就是资源详情页的 URL,而不一定是下载链接本身的域名,所以要自己维护好来源页面地址。
如果文件特别大(几个 GB 甚至几十 GB),Selenium 的方案就不太合适了。这种情况下建议只把直链输出到文件,然后交给成熟的下载工具去跑。ctwp_spider 中我在下载环节做了一个判断:文件小于 500MB 用 requests,大于 500MB 则输出链接到download_tasks.txt,由外部工具处理。这样既保证小资源自动完成,又不耽误大资源并行下载。
3.4 反爬应对与操作节流
网盘平台对自动化操作的容忍度很低,但它主要检测的是“操作行为模式”,而不是单纯的请求频率。我踩过很多坑之后总结了三个核心点:
第一,操作间隔必须带随机性。固定 sleep 2 秒这种写法,在平台看来就是异常信号。正确的做法是生成一个均匀分布范围内的随机延时。
import random def random_sleep(mean=1.5, spread=0.8): time.sleep(max(0, random.uniform(mean - spread, mean + spread)))当然,这个延时不能太夸张,否则爬取效率会变得非常低。我通常把均值控制在 1~2 秒之间,具体视单个任务页面的加载速度动态调整。
第二,不要频繁刷新同一页面。如果某个任务解析失败,先重试一次;再失败就记录到失败队列,等整个任务跑完之后统一回退,而不是原地死循环重试。否则很容易触发访问频率限制,导致 IP 或账号被临时封禁。
第三,滚动加载要模仿真实用户。网盘的资源列表如果是无限滚动模式,你不能一次性execute_script("window.scrollTo(0, document.body.scrollHeight)")滚到底。正确做法是分多次滚动,每次滚一屏,中间加随机延时,并且偶尔上下滚动一下,模拟人在浏览内容。
def human_scroll(driver, target_height=None): current = 0 viewport = driver.execute_script("return window.innerHeight") scroll_step = int(viewport * 0.7) if target_height is None: target_height = driver.execute_script("return document.body.scrollHeight") while current < target_height: current += scroll_step driver.execute_script(f"window.scrollTo(0, {current});") random_sleep(0.8, 0.4) # 模拟偶尔回看 if random.random() < 0.2: driver.execute_script(f"window.scrollTo(0, {current - scroll_step * 0.6});") random_sleep(0.5, 0.3)这种模拟方式在交互层面更接近真人。虽然没法彻底规避所有检测,但可以把触发风险降到最低。
4. 常见问题与排查实录
4.1 ElementNotInteractableException:元素看得见但点不了
这是 Selenium 使用过程中最经典的问题。报错的原因通常是元素被遮挡、元素不可编辑或元素尚未完成渲染。在网盘场景里,最常见的就是“下载按钮”在倒计时期间不可点击,或者被一个半透明遮罩层挡住。
排查思路:先看元素是否可见,再看元素位置是否有遮挡。
elem = driver.find_element(By.XPATH, "//button[contains(text(),'下载')]") # 判断是否可见 print(elem.is_displayed(), elem.is_enabled()) # 查看元素位置和尺寸 print(elem.location, elem.size) # 如果有遮挡,可尝试强制点击 driver.execute_script("arguments[0].click();", elem)我个人倾向于用element_to_be_clickable配合显式等待来解决这类问题,因为强制点击虽然能绕过遮挡,但也会绕过一些必要的交互约束,导致后续环节状态不同步。比如倒计时还没结束就点击,平台会拒绝生成下载链接。
4.2 StaleElementReferenceException:上一秒还在下一秒就失效
页面在异步刷新或跳转后,之前拿到的元素引用已经不再挂在 DOM 上,再操作就会报这个异常。这在网盘页面里很常见,尤其是从文件列表页跳转到详情页的过程中,列表页的元素全部被销毁。
解决办法是“用时再查”,即每次操作前重新查找元素,而不是复用之前的引用:
def safe_click(driver, xpath, retries=3): for i in range(retries): try: elem = WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.XPATH, xpath)) ) elem.click() return True except Exception as e: if i == retries - 1: raise e random_sleep(1, 0.5) return False另一个有用的小技巧:在页面跳转之前,记录当前窗口句柄;跳转完成后确认句柄是否变化,如果没变化但报 StaleElement,说明是同一页面内的组件刷新,可以继续用原有逻辑处理;如果句柄变了,则需要重新初始化上下文。
4.3 验证码识别的取舍
网盘平台的验证码类型一般是两种:滑块和图形点选。滑块验证码在 Selenium 场景下的处理思路是拖拽到缺口位置。
from selenium.webdriver.common.action_chains import ActionChains def slide_to_captcha(driver): slider = driver.find_element(By.XPATH, "//div[contains(@class,'slide-btn')]") # 先算出缺口位置,这里简化处理 target_x = 180 ActionChains(driver).click_and_hold(slider).pause(0.2).move_by_offset(target_x, 0).pause(0.3).release().perform()但这个方案成功率不算高,因为现在的滑块验证码已经加入了轨迹检测,纯匀速拖动反而会被判定为机器操作。我一般采取保守策略:优先走手动登录流程,即第一次登录时人工完成验证,后续靠 Cookie 维持。只有当 Cookie 失效且无法手动介入时,才尝试自动化滑块方案。
图形点选验证码更复杂,需要识别文字和图片坐标,这个一般建议接入第三方打码平台。不过打码平台是一把双刃剑,用得好省时间,用得不好账号可能被标记异常。我的观点是:能用手动就别用自动,能用 Cookie 复用就别频繁触发验证。
4.4 headless 模式与反爬检测
很多人喜欢用 headless(无头)模式跑爬虫,认为这样更节省资源、更隐蔽。但在网盘场景下,headless 模式的劣势非常明显:
- 某些平台会检查
navigator.webdriver、window.chrome等对象,headless 环境更容易暴露。 - 无头模式下 canvas 指纹和 WebGL 渲染结果与真实浏览器不同,可能触发风控。
- 调试困难,页面渲染成什么样完全看不到。
我的建议是:除非你明确知道目标平台不检测这些指标,否则日常开发和调试用有头模式,等到稳定了再去考虑 headless。如果服务器上没有显示器,可以用 Xvfb 虚拟屏幕,这样既能保持有头模式的渲染能力,又不需要真实物理屏幕。
4.5 常见问题与排查速查表
| 报错信息 | 出现原因 | 处理方案 |
|---|---|---|
session not created | ChromeDriver 与 Chrome 版本不匹配 | 升级/降级 ChromeDriver,用 webdriver-manager 自动管理 |
ElementClickInterceptedException | 目标元素被其他元素遮挡 | 显式等待元素可点击,或滚动到元素可见区域后再点击 |
TimeoutException | 页面加载或元素出现超时 | 调整定位条件、延长 timeout,或检查是否触发了验证码 |
NoSuchElementException | 元素未找到 | 确认 XPath 是否正确,确认页面是否已切换 iframe |
ConnectionResetError | 网络被重置或触发风控 | 降低操作频率,更换用户代理,切换网络出口设备 |
| 下载链接 403 | Referer 校验未通过 | 补全请求头中的 Referer 为详情页地址 |
5. 实用经验与进阶扩展建议
5.1 从爬虫框架角度重写
ctwp_spider 跑通之后,如果你想把它变成更通用的工具,不能只满足于“能跑”。我后来做了一次大规模重构,把核心逻辑抽象成三个基类:任务基类、解析器基类和下载器基类。任务基类负责管理 URL 队列和去重;解析器基类只提供一个parse(driver) -> list[dict]的接口,具体页面怎么解析由子类实现;下载器基类则统一管理保存路径和重试机制。
这样改造的好处是可扩展性。同样的框架,可以接入其他网盘平台,只要新增一个解析器子类,把前端选择器和交互流程写好,剩下的任务调度和下载逻辑完全复用。
另外,如果你对并发有要求,可以引入多线程来处理下载任务。但注意:Selenium 的 WebDriver 实例不是线程安全的,多个线程不能共用一个 driver。正确做法是每个线程创建独立的 driver,同时把 WebDriver 的初始化收敛到一个连接池管理器中,控制总并发数。
from concurrent.futures import ThreadPoolExecutor def worker(task_url): driver = create_driver() # 每个线程独立 driver try: links = extract_download_page_links(driver, task_url) return links finally: driver.quit() with ThreadPoolExecutor(max_workers=3) as executor: results = executor.map(worker, task_urls)并发数并不是越大越好。我实测下来,单机 3~4 个并发浏览器实例已经接近系统资源瓶颈,开太多反而会因为内存不足导致浏览器崩溃。
5.2 运行监控与日志设计
爬虫跑久了之后,最怕的不是报错,而是“没报错但任务也没推进”。为了排查这种情况,日志设计必须清晰。我的策略是每个任务记录三条日志:开始、成功或失败、耗时。并且在失败时把所有关键信息 dump 到日志文件,包括当前页面 URL、操作到哪一步、页面的截图。
import logging logger = logging.getLogger("ctwp") def task_with_log(func): def wrapper(*args, **kwargs): task_name = args[0] logger.info(f"[START] {task_name}") try: result = func(*args, **kwargs) logger.info(f"[SUCCESS] {task_name}") return result except Exception as e: logger.error(f"[FAIL] {task_name}, error={e}") raise return wrapper截图是排查问题最直观的方式。在异常处理中加一行driver.save_screenshot("logs/error.png"),下次回看时一眼就能定位问题出在哪个页面。
另外,长期运行的项目还要定期清理日志文件和临时下载文件,避免磁盘满了导致整体异常。
5.3 合规与边界意识
这是必须单独拿出来说的一点。爬虫项目虽然技术上有趣,但使用边界一定要清楚。
我在做这个项目时的原则是:只处理自己拥有或有明确授权的资源链接,不批量爬取他人私有内容;不对目标平台发起高频请求,避免影响平台正常服务。如果你要把类似技术用到其他场景上,请先确认目标平台的用户协议和服务条款,在合规范围内使用。代码本身是中性的,但用的人要有边界感。
注意:本项目及博文中展示的所有代码均为技术学习与个人正当用途设计的示例,请勿用于任何违规采集、盗链、侵犯他人权益的用途。
6. 写在最后的几点实在话
做了这么久的 Selenium 爬虫项目,我最大的感受是:真正难的从来不是写代码,而是处理那些“说不清道不明”的边界情况。Selenium 赋予了我们操作浏览器的能力,但每个平台的页面结构、交互设计和风控策略都不一样,没有一套代码能通吃所有场景。ctwp_spider 之所以能稳定跑下来,靠的不是某一次漂亮的代码设计,而是反复调试中积累起来的细节——哪个环节要等多久、哪个按钮要用什么方式点击、哪些操作要随机化、哪些页面要特殊处理——这些都是文档里不会写的。
最后再分享一个小技巧:如果遇到某个页面怎么定位都定位不准,可以试试在浏览器里手动执行一下元素选择器,确认路径没问题后再搬到代码里。很多时候,你以为的 XPath 和你实际看到的 HTML 结构根本不是一回事,先在 DevTools 里验证一遍能省很多排查时间。
这个项目后续还可以扩展的方向很多,比如接入消息推送(任务完成时通知到你手机上),比如做一个简化的 Web 管理界面下发任务、查看日志,或者把直链进一步提交给第三方离线下载服务做分发。就看你自己实际需求是什么了。