RPA 浏览器文件下载稳定化工程实践:从原生弹窗到无头下载与断点续传
技术主题:RPA 技术(机器人流程自动化) 内容方向:关键技术点讲解(核心原理、实现逻辑、技术难点解析)
引言 浏览器文件下载是 RPA 的“高频坑点”:页面点击后弹出原生保存对话框,脚本卡住;无头模式下载失败;Blob URL/重定向导致文件名错乱;容器里路径不可写;下载中断无法续传。本文基于实际落地,给出一套“优先规避原生弹窗 → 浏览器内下载 → 会话直连 → 断点续传”的工程化方案,并提供完整的 Python 代码骨架与调试清单。
一、问题画像与复现
现象:
点击下载按钮,出现系统级“另存为”对话框,自动化无法操作或偶发失败;
Headless 模式下不产生文件;
经重定向/Blob URL 下载,最终文件名与期望不一致;
容器环境没有桌面/权限受限,默认下载目录不可写;
网络抖动导致下载中断,无法从断点续传;
并发下载产生重名覆盖或脏文件。
复现要点:
页面触发 Content-Disposition: attachment
或前端 a[download]
;
文件较大,模拟中断(限速/断网重连),观察失败与重试行为;
切换 Headed/Headless、切换容器/本地,观察差异。
二、总体方案
优先规避原生弹窗:利用浏览器下载事件与指定下载目录,避免系统“另存为”。
无头下载:在上下文层开启下载接收,并监听 download
事件保存到指定路径。
会话直连:能直接拿接口链接的场景,复用 Cookie/Token 用 HTTP 客户端直连下载(更稳)。
Blob/重定向:通过 download.suggested_filename
与响应头兜底文件名,统一命名策略。
幂等与并发:对同一 URL/内容做去重与重名避免,生成稳定文件名;
断点续传:若服务器支持 Range,按 ETag/Last-Modified 做分片续传;
观测与回放:记录下载事件、文件大小、耗时、失败原因与重试次数。
三、关键代码(Python + Playwright)
依赖:playwright、requests(会话直连与续传)、pathlib
1)浏览器内下载(避免原生对话框) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 import asynciofrom pathlib import Pathfrom playwright.async_api import async_playwrightDOWNLOAD_DIR = Path("/tmp/downloads" ).resolve() DOWNLOAD_DIR.mkdir(parents=True , exist_ok=True ) async def browser_download (url: str , selector: str ) -> Path: async with async_playwright() as p: browser = await p.chromium.launch(headless=True ) context = await browser.new_context(accept_downloads=True ) page = await context.new_page() await page.goto(url, wait_until="networkidle" ) async with page.expect_download() as dl_info: await page.click(selector) download = await dl_info.value suggested = download.suggested_filename target = DOWNLOAD_DIR / suggested i = 1 while target.exists(): target = DOWNLOAD_DIR / f"{target.stem} ({i} ){target.suffix} " i += 1 await download.save_as(str (target)) path = await download.path() print ("downloaded:" , target.name, "temp:" , path) await context.close() await browser.close() return target
要点:
accept_downloads=True
+ page.expect_download()
捕获下载事件;
通过 download.save_as()
将文件保存到自定义目录,避免原生对话框;
重名安全处理,保证并发下载不覆盖。
2)会话直连下载(复用 Cookie/Token) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import requestsfrom http.cookiejar import CookieJarasync def session_direct_download (page, file_url: str , target: Path ) -> Path: cookies = await page.context.cookies() jar = requests.cookies.RequestsCookieJar() for c in cookies: jar.set (c["name" ], c["value" ], domain=c.get("domain" ), path=c.get("path" , "/" )) with requests.Session() as s: s.cookies = jar s.headers.update({"User-Agent" : await page.evaluate("() => navigator.userAgent" )}) with s.get(file_url, stream=True , timeout=(3 , 30 )) as r: r.raise_for_status() target.parent.mkdir(parents=True , exist_ok=True ) with open (target, "wb" ) as f: for chunk in r.iter_content(chunk_size=1024 * 256 ): if chunk: f.write(chunk) return target
适用:下载链接可从页面拿到且鉴权基于 Cookie/Token 的场景。优势:避开浏览器自身的下载实现与对话框差异,提升稳定性与可控性。
3)断点续传与幂等命名(基于 ETag/URL) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import hashlibimport osimport requestsfrom urllib.parse import urlparsedef stable_name_from_url (url: str , content_disposition: str | None = None ) -> str : if content_disposition and "filename" in content_disposition: name = content_disposition.split("filename=" )[-1 ].strip('"' ) return name path = urlparse(url).path if path and path != "/" : return Path(path).name return hashlib.sha1(url.encode()).hexdigest() + ".bin" def resume_download (url: str , target: Path, headers: dict | None = None ): headers = dict (headers or {}) pos = target.stat().st_size if target.exists() else 0 if pos > 0 : headers["Range" ] = f"bytes={pos} -" with requests.get(url, headers=headers, stream=True , timeout=(3 , 30 )) as r: if r.status_code == 206 or (r.status_code == 200 and pos == 0 ): mode = "ab" if r.status_code == 206 else "wb" with open (target, mode) as f: for chunk in r.iter_content(chunk_size=1024 * 256 ): if chunk: f.write(chunk) else : r.raise_for_status() return target
要点:
利用 Range: bytes=pos-
实现续传(对端需支持 206 Partial Content);
命名优先用 Content-Disposition
,否则 URL path 或 hash;
对于动态内容(不支持续传),失败后应整体重下,并限制重试次数与总时长。
四、调试与观测
本地/容器差异:容器内确保下载目录可写,映射卷;
Headless/Headed:优先 Headless 并开启 accept_downloads
,对差异行为加入分支;
大文件/断网:限速与断网注入,验证续传逻辑(断点处是否继续);
指标与日志:记录下载时长、大小、速率、失败码/原因、续传次数、最终文件名;
安全与合规:对文件名做清洗(去除路径穿越),对来源域名做白名单;
并发:为下载设置并发上限与队列,避免瞬时压垮下游。
五、落地清单
统一下载网关:优先会话直连,其次浏览器内下载,最后才考虑系统级对话框兜底;
目录与命名:固定下载目录,稳定命名,避免重名覆盖;
断点续传:对大文件/不稳定网络启用 Range 续传与限速;
鉴权与会话:从浏览器复用 Cookie/Token,把“登录态”无缝带到直连下载;
观测:埋点与快照(开始/完成/失败),构建回放能力;
异常策略:重试次数、超时、熔断与人工兜底(通知/人工介入)。
总结 下载看似“点个按钮就行”,但在自动化里牵涉到系统对话框、浏览器策略、鉴权、命名与稳定性。将下载流程工程化:先避免原生弹窗,使用浏览器下载事件与目录控制;可行时走会话直连,提升可控性;为大文件加入续传与幂等命名;配套完整观测与异常策略。这样才能把“偶发失败”的下载场景,打造成稳定、可回放、可观测的生产级能力。