ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Playwright实战:自动化下载夸克网盘分享文件

Playwright实战:自动化下载夸克网盘分享文件

1. 为什么是 Playwright:绕开接口签名的一种思路

前一阵子我需要把一个分享目录里的几百个文件批量同步到本地,第一反应是直接用 requests 打开分享页、解析文件 ID、拼下载接口。结果半天就败下阵来:分享页的文件列表和下载凭证都带前端签名参数,凭证有效期又短,等我把列表爬完,前面的凭证早就失效了。反复补参数、追接口的状态,写起来像在解一道随时会变条件的方程。

后来我换了个思路:与其逆向接口,不如让浏览器自己去完成整套交互。签名、Cookie、凭证刷新这些麻烦事,浏览器内核全都处理掉了,我只需要像真人一样操作页面:打开链接、填提取码、点下载。Playwright 就是干这个最顺手的工具。

用 Playwright 而不是 Selenium,我主要看中三点。第一是自动等待机制,click()会等元素可交互后再点,省掉了一大堆time.sleep()。第二是下载事件处理得干净,expect_download()能精确捕获一次下载并拿到文件流,不像 Selenium 那样经常要自己去下载目录里找文件。第三是调试体验,脚本跑挂了可以直接切到有头模式看页面现场,也可以用 inspect 工具逐步检查元素。

如果你只是偶尔下一次两个文件,没必要上这套东西。但当需求变成“定期同步整个分享目录”“按文件名过滤下载”“批量归档”的时候,方案性价比就完全不同了。而且只要页面 UI 不变,脚本就能长期用;接口方案遇到前端一次改版可能就全废了。下图是我对两种方案的对比:

方案稳定性开发成本维护成本适用场景
直接请求接口低,接口字段常变中,需要逆向前端逻辑高,随时可能挂临时拉一次数据
Playwright 自动化高,UI 稳定则脚本稳中,熟悉 API 后上手快低,主要改选择器批量、周期、复杂交互
方案稳定性开发成本维护成本适用场景
直接请求接口低,接口字段常变中,需要逆向前端逻辑高,随时可能挂临时拉一次数据
Playwright 自动化高,UI 稳定则脚本稳中,熟悉 API 后上手快低,主要改选择器批量、周期、复杂交互

2. 登录态怎么“存”:storage_state 让扫码只发生一次

分享页面多数情况不需要登录也能预览下载,但登录之后更稳定,下载大文件、访问部分加密分享时也不容易被风控拦。所以我给脚本加了一套登录态持久化机制,保证扫码只扫一次,后续运行直接复用。

核心 API 是context.storage_state(path=...)。Playwright 会把当前上下文里的 Cookies、localStorage、sessionStorage 全部序列化成一个 JSON 文件;下次创建浏览器上下文时把这个 JSON 传回去,浏览器就自动恢复了登录状态,效果等同于用户带着上次登录的会话重新打开页面。

2.1 首次扫码与状态保存

先看创建上下文的代码。状态文件不存在时,我刻意把浏览器设置为有头模式,让扫码窗口正常弹出来:

import os import time from playwright.sync_api import sync_playwright STATE_FILE = "quark_state.json" SHARE_ID = "分享链接中的ID" EXTRACT_CODE = "1234" # 无提取码就留空 SAVE_DIR = "/path/to/your/download" def create_context(playwright): browser = playwright.chromium.launch(headless=False) context_options = { "accept_downloads": True, "viewport": {"width": 1440, "height": 900}, "user_agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ), } if os.path.exists(STATE_FILE): context_options["storage_state"] = STATE_FILE context = browser.new_context(**context_options) return browser, context

打开网盘首页后判断登录态。为了稳妥,我不用某个具体文字判断登录,而是探测“登录按钮”是否存在:存在说明未登录,不存在说明已带态。这个逻辑在页面改版时不容易误判。

def ensure_login(context, page): page.goto("https://pan.quark.cn/", wait_until="domcontentloaded") if page.locator("button:has-text('登录')").count() > 0: input("请在浏览器中完成扫码登录,成功后回到终端按回车...") # 多等两秒,等前端把最新 token 写入本地存储 page.wait_for_timeout(2000) context.storage_state(path=STATE_FILE) print("登录态已保存到", STATE_FILE) else: print("已处于登录态,无需重复扫码")

这里有两个细节值得展开。第一,扫码成功后的两三秒内,前端还会发一两个请求刷新用户信息和权限 token,如果立刻保存状态,很容易漏掉刚写入的某条关键数据。实测下来多等两秒再 dump,登录态的“寿命”明显更长。

第二,状态文件本质上是浏览器会话里所有敏感凭据的完整导出,绝不能当普通配置文件提交到仓库。你应该把它加进.gitignore,只在本地或受控服务器上保存。

2.2 状态文件复用时的注意点

状态文件不是永久有效的。网盘的会话通常几天后过期,或者账号在别处登录会把旧会话顶掉。所以每次脚本启动,我都重新走一次ensure_login,而不是拿到文件就直接用。判断未登录后,我会顺手把旧文件备份成带时间戳的路径,再触发重新扫码,避免写文件写到一半把原来的状态弄坏。

import time def backup_expired_state(): if os.path.exists(STATE_FILE): backup = f"quark_state_expired_{int(time.time())}.json" os.rename(STATE_FILE, backup) print("旧登录态已备份为", backup)

3. 提取码与分享链接:两种入口一起处理

分享链接的形态通常是下面这两种:

# 无提取码 https://pan.quark.cn/s/AbCdEf # 带提取码 https://pan.quark.cn/s/AbCdEf#/list/share?pwd=1234

注意提取码不是普通查询参数,它挂在#后面的 hash 片段里。这个片段不会出现在发往服务器的 HTTP 请求里,纯粹是给前端 JavaScript 读取用的。浏览器加载完页面后,前端脚本再根据 hash 中的pwd参数去请求真实的文件列表接口。

明白这一点,就有了两条处理路径。一条是把带pwd的完整 URL 交给 Playwright 打开。理论上页面加载完会自动读取 hash 并拉取列表,但实测偶尔会出现前端没解析 hash 的情况,画面停在空白或提取码输入框。另一条是等页面出现提取码输入框后,用 Playwright 模拟人工输入和确认。我两条都做,互为兜底。

def open_share_page(page, share_id: str, pwd: str = ""): url = f"https://pan.quark.cn/s/{share_id}#/list/share" if pwd: url += f"?pwd={pwd}" page.goto(url, wait_until="domcontentloaded") # 兜底:如果出现提取码输入框,手动填写 pwd_input = page.locator( "input[placeholder*='提取码'], input[placeholder*='请输入提取码']" ).first if pwd_input.count() > 0: pwd_input.fill(pwd) page.locator( "button:has-text('确认'), button:has-text('提取文件')" ).first.click() page.wait_for_timeout(2000) page.wait_for_selector("文件列表根元素的选择器", timeout=30000) print("分享页加载完成")

选择器会随前端版本更新而变化,所以我在脚本里给同一种元素写了多个候选表达式,用逗号分隔传给locator(),Playwright 会按顺序命中第一个可用元素。这一招在写任何“以页面自动化为主”的工具时都适用,能显著降低页面改版带来的维护成本。

还有一个反直觉的细节:如果页面弹出了提取码输入框,并且输入框里已经有值——这种诡异情况我遇到过,前端把 hash 中的pwd自动回填到了输入框,但没触发确认请求。此时你只需要找确认按钮点击,不要重复fill()。重复输入会触发前端的校验事件,反而容易把原本自动回填的值弄脏。

如果提取码是错的,页面通常会提示“提取码错误”,输入框被清空。脚本里我把这个提示文案当作错误分支处理,记录日志后跳过当前分享链接,而不是无脑重试。反复提交错误提取码可能触发临时风控,会把整个脚本锁死一段时间。

4. 下载链路:定位文件、触发按钮、落到本地

文件列表加载出来后,就要把目标文件找出来,再触发下载。

4.1 海量文件场景下的滚动加载

分享目录就几个文件时,列表一次渲染完,直接定位文件名就行。但文件一多,前端基本都会改成懒加载,只有滚动到附近才渲染对应行。我的做法是逐屏滚动,每滚一次就检查目标文件名是否出现:

def find_file(page, name: str): for _ in range(30): matched = page.locator(f"text={name}") if matched.count() > 0: return matched.first page.evaluate("window.scrollBy(0, 1000)") page.wait_for_timeout(600) raise TimeoutError(f"在列表中找不到文件:{name}")

这里有一个新手容易踩的坑:page.locator()本身不会去页面里找元素,它只是创建一个懒加载的定位器。真正触发查询的是.count()、.click()这些动作。所以我用.count()判断是否出现,比直接调.click()再捕获异常干净得多。

4.2 悬停后出现的下载按钮

文件行平时只显示文件名、大小、日期,下载按钮是鼠标悬停到该行时才出现的。Playwright 的.hover()能模拟悬停,但按钮浮现需要一点渲染时间,所以点击前先wait_for_selector等它出现:

import os from urllib.parse import unquote def download_file(page, save_dir: str, name: str): target = find_file(page, name) target.hover() page.wait_for_selector("text=下载", timeout=5000) with page.expect_download(timeout=120000) as dl_info: target.locator("text=下载").click() download = dl_info.value failure = download.failure() if failure: raise RuntimeError(f"下载失败: {failure}") filename = download.suggested_filename or name filename = unquote(filename) save_path = os.path.join(save_dir, filename) download.save_as(save_path) print(f"文件已保存:{save_path}")

target.locator("text=下载")表示只在目标文件行内部查找“下载”按钮,不会误点其他悬浮工具条里的同名字段。这一点在页面布局复杂时会救命,早期版本我就是因为用了全局的"text=下载",经常点到不相干的行。

4.3 文件名解码与文件夹打包

download.suggested_filename一般来自服务器返回的content-disposition头,比手工拼接可靠。但偶尔会遇到 URL 编码的字符串,所以保存前我做了一次unquote()解码。

如果下载目标是整个文件夹,页面会先发起打包任务,出现“正在生成压缩包”之类的进度提示,之后才真正产生下载事件。Playwright 的expect_download会一直阻塞等待下载事件发生,所以这类场景要把超时时间拉长到两三分钟。生成的文件名也可能与目录名不完全一致,可能是系统随机拼出来的压缩包名,不要指望它等于你点击的目录名。

4.4 批量下载的节奏控制

批量下载时尽量别让页面在短时间内触发几十个下载任务。浏览器的并发下载数是有上限的,太多任务积压会让浏览器直接拦住后续下载。我每下载完一个文件就在循环里等 3 到 5 秒,整体耗时增加一点,但稳定度提升非常明显。实测下来,批量模式下“多个文件同时卡住”的场景,大多不是网盘限速,而是浏览器下载队列爆了。

5. 生产级兜底:会话失效、超时重试与无人值守

本地手动跑通脚本只是第一步,真正难的是让它变成能反复执行、出错能自救的工具。

5.1 会话失效的检测与人工介入

登录态保存到文件不代表一劳永逸。我遇到的实际情况是:会话可能几天后就失效,账号在别处登录也会把旧会话顶掉。如果脚本不检查直接下载,往往要等到下载事件超时才意识到问题。

所以我在每次下载前都先确认会话有效,发现登录页跳转就进入重新登录流程。这个流程我做成半人工方式:二维码截图存到本地,人工扫码后脚本继续。虽然不能全自动,但比每次手动打开浏览器强太多。我还养成了一个习惯——重新登录前先把旧状态文件备份成带时间戳的名字,等确认登录成功后再覆盖正式文件,避免状态文件写一半损坏。

def ensure_valid_session(page, context): page.goto("https://pan.quark.cn/", wait_until="domcontentloaded") if page.locator("button:has-text('登录')").count() > 0: backup_expired_state() input("检测到登录态失效,请扫码登录,完成后按回车...") page.wait_for_timeout(2000) context.storage_state(path=STATE_FILE) page.reload()

5.2 超时重试与临时风控

分享页偶尔响应很慢,或者接口临时限流。我观察到的典型场景是:第一次点击下载后长时间没有产生下载事件,页面也没有报错,完全卡住。

针对这种情况,我封装了一个简单的重试装饰器,把打开分享页和下载文件包起来:

def retry(times: int = 3, interval: int = 10): def decorator(func): def wrapper(*args, **kwargs): for i in range(times): try: return func(*args, **kwargs) except Exception as e: print(f"第 {i + 1} 次尝试失败:{e}") time.sleep(interval) raise RuntimeError("重试次数耗尽") return wrapper return decorator

重试时的关键不是原地再点一次下载,而是先刷新页面、重新走一遍开页逻辑,把整个页面恢复到一个干净状态。否则残留的弹窗和异常状态会让第二次点击继续失败。

还有一个真实经验:如果某次下载触发了风控,接下来几个文件大概率都会卡住。我遇到这种情况就停止整个任务,等 10 到 15 分钟再重新拉全量列表,而不是继续往下试。硬顶着下载大概率只会被拦得更久。

5.3 headless 模式下的行为差异

脚本要丢到服务器上跑时,浏览器不能每次弹窗口。Playwright 支持 headless 模式,但有两个注意事项必须先处理。

第一,首次扫码保存状态必须在有头模式下完成。无头模式看不到页面,也没法扫码。我通常本地有头模式跑一次拿到状态文件,再传到服务器上用无头模式跑。

第二,无头模式对页面滚动行为的表现有差异。我在无头模式下遇到过mouse.wheel()滚动不生效,后来统一改用page.evaluate("window.scrollBy(0, 1000)")直接操作原生滚动,搭配前面find_file里的轮询逻辑,两种模式都稳定。

5.4 下载完整性校验

下载完成只代表 Playwright 没报错,不代表文件是完好的。我会额外做一层大小校验,把本地文件大小和页面展示的期望值比对,不一致的删掉重下。这样能避免一个半截文件混进归档目录,后续同步时还以为它已经存在。

夸克网盘页面显示的文件大小是带单位的文本,比如“12.5 MB”,转成字节数再比对,允许 5% 的误差,因为前端展示时做了四舍五入,严格相等比较容易误杀正常文件。

def parse_size(text: str) -> float: value, unit = text.split(" ") value = float(value) if unit == "KB": return value * 1024 if unit == "MB": return value * 1024 * 1024 if unit == "GB": return value * 1024 * 1024 * 1024 return value def verify_size(save_path: str, expected_size_text: str | None) -> bool: if not expected_size_text: return True expected = parse_size(expected_size_text) actual = os.path.getsize(save_path) return abs(actual - expected) / expected < 0.05

最后再分享一个我自己一直在用的技巧:维护一个本地文件索引清单,每次同步前先扫描本地已存在的文件,只下载缺失或大小不匹配的那部分。一开始这样做只是为了省流量,后来发现它对增量同步的稳定性帮助极大——下载任务中途断了,重新跑一遍脚本只会补没完成的文件,不会重复下载几百个已有文件。如果你需要的是定期同步某个分享目录,这个思路可以直接抄过去用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进