
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(headlessFalse) 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_untildomcontentloaded) if page.locator(button:has-text(登录)).count() 0: input(请在浏览器中完成扫码登录成功后回到终端按回车...) # 多等两秒等前端把最新 token 写入本地存储 page.wait_for_timeout(2000) context.storage_state(pathSTATE_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 fquark_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?pwd1234注意提取码不是普通查询参数它挂在#后面的 hash 片段里。这个片段不会出现在发往服务器的 HTTP 请求里纯粹是给前端 JavaScript 读取用的。浏览器加载完页面后前端脚本再根据 hash 中的pwd参数去请求真实的文件列表接口。明白这一点就有了两条处理路径。一条是把带pwd的完整 URL 交给 Playwright 打开。理论上页面加载完会自动读取 hash 并拉取列表但实测偶尔会出现前端没解析 hash 的情况画面停在空白或提取码输入框。另一条是等页面出现提取码输入框后用 Playwright 模拟人工输入和确认。我两条都做互为兜底。def open_share_page(page, share_id: str, pwd: str ): url fhttps://pan.quark.cn/s/{share_id}#/list/share if pwd: url f?pwd{pwd} page.goto(url, wait_untildomcontentloaded) # 兜底如果出现提取码输入框手动填写 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(文件列表根元素的选择器, timeout30000) print(分享页加载完成)选择器会随前端版本更新而变化所以我在脚本里给同一种元素写了多个候选表达式用逗号分隔传给locator()Playwright 会按顺序命中第一个可用元素。这一招在写任何“以页面自动化为主”的工具时都适用能显著降低页面改版带来的维护成本。还有一个反直觉的细节如果页面弹出了提取码输入框并且输入框里已经有值——这种诡异情况我遇到过前端把 hash 中的pwd自动回填到了输入框但没触发确认请求。此时你只需要找确认按钮点击不要重复fill()。重复输入会触发前端的校验事件反而容易把原本自动回填的值弄脏。如果提取码是错的页面通常会提示“提取码错误”输入框被清空。脚本里我把这个提示文案当作错误分支处理记录日志后跳过当前分享链接而不是无脑重试。反复提交错误提取码可能触发临时风控会把整个脚本锁死一段时间。4. 下载链路定位文件、触发按钮、落到本地文件列表加载出来后就要把目标文件找出来再触发下载。4.1 海量文件场景下的滚动加载分享目录就几个文件时列表一次渲染完直接定位文件名就行。但文件一多前端基本都会改成懒加载只有滚动到附近才渲染对应行。我的做法是逐屏滚动每滚一次就检查目标文件名是否出现def find_file(page, name: str): for _ in range(30): matched page.locator(ftext{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下载, timeout5000) with page.expect_download(timeout120000) 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_untildomcontentloaded) if page.locator(button:has-text(登录)).count() 0: backup_expired_state() input(检测到登录态失效请扫码登录完成后按回车...) page.wait_for_timeout(2000) context.storage_state(pathSTATE_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最后再分享一个我自己一直在用的技巧维护一个本地文件索引清单每次同步前先扫描本地已存在的文件只下载缺失或大小不匹配的那部分。一开始这样做只是为了省流量后来发现它对增量同步的稳定性帮助极大——下载任务中途断了重新跑一遍脚本只会补没完成的文件不会重复下载几百个已有文件。如果你需要的是定期同步某个分享目录这个思路可以直接抄过去用。