RPA 浏览器文件下载稳定化工程实践:从原生弹窗到无头下载与断点续传

RPA 浏览器文件下载稳定化工程实践:从原生弹窗到无头下载与断点续传

技术主题:RPA 技术(机器人流程自动化)
内容方向:关键技术点讲解(核心原理、实现逻辑、技术难点解析)

引言

浏览器文件下载是 RPA 的“高频坑点”:页面点击后弹出原生保存对话框,脚本卡住;无头模式下载失败;Blob URL/重定向导致文件名错乱;容器里路径不可写;下载中断无法续传。本文基于实际落地,给出一套“优先规避原生弹窗 → 浏览器内下载 → 会话直连 → 断点续传”的工程化方案,并提供完整的 Python 代码骨架与调试清单。

一、问题画像与复现

  • 现象:

    • 点击下载按钮,出现系统级“另存为”对话框,自动化无法操作或偶发失败;
    • Headless 模式下不产生文件;
    • 经重定向/Blob URL 下载,最终文件名与期望不一致;
    • 容器环境没有桌面/权限受限,默认下载目录不可写;
    • 网络抖动导致下载中断,无法从断点续传;
    • 并发下载产生重名覆盖或脏文件。
  • 复现要点:

    • 页面触发 Content-Disposition: attachment 或前端 a[download]
    • 文件较大,模拟中断(限速/断网重连),观察失败与重试行为;
    • 切换 Headed/Headless、切换容器/本地,观察差异。

二、总体方案

  1. 优先规避原生弹窗:利用浏览器下载事件与指定下载目录,避免系统“另存为”。
  2. 无头下载:在上下文层开启下载接收,并监听 download 事件保存到指定路径。
  3. 会话直连:能直接拿接口链接的场景,复用 Cookie/Token 用 HTTP 客户端直连下载(更稳)。
  4. Blob/重定向:通过 download.suggested_filename 与响应头兜底文件名,统一命名策略。
  5. 幂等与并发:对同一 URL/内容做去重与重名避免,生成稳定文件名;
  6. 断点续传:若服务器支持 Range,按 ETag/Last-Modified 做分片续传;
  7. 观测与回放:记录下载事件、文件大小、耗时、失败原因与重试次数。

三、关键代码(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
# python
import asyncio
from pathlib import Path
from playwright.async_api import async_playwright

DOWNLOAD_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

# 用法示例:
# asyncio.run(browser_download("https://example.com/report", "button.download"))

要点:

  • accept_downloads=True + page.expect_download() 捕获下载事件;
  • 通过 download.save_as() 将文件保存到自定义目录,避免原生对话框;
  • 重名安全处理,保证并发下载不覆盖。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# python
import requests
from http.cookiejar import CookieJar

async def session_direct_download(page, file_url: str, target: Path) -> Path:
# 从浏览器上下文提取 cookie,复用到 requests 会话
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
# python
import hashlib
import os
import requests
from urllib.parse import urlparse

def stable_name_from_url(url: str, content_disposition: str | None = None) -> str:
# 优先使用服务端文件名,其次使用 URL path,最后 hash 兜底
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,把“登录态”无缝带到直连下载;
  • 观测:埋点与快照(开始/完成/失败),构建回放能力;
  • 异常策略:重试次数、超时、熔断与人工兜底(通知/人工介入)。

总结

下载看似“点个按钮就行”,但在自动化里牵涉到系统对话框、浏览器策略、鉴权、命名与稳定性。将下载流程工程化:先避免原生弹窗,使用浏览器下载事件与目录控制;可行时走会话直连,提升可控性;为大文件加入续传与幂等命名;配套完整观测与异常策略。这样才能把“偶发失败”的下载场景,打造成稳定、可回放、可观测的生产级能力。