在使用 Google Antigravity IDE 或其他基于 Playwright 的浏览器自动化工具时,遇到一个困扰的问题:下载的文件被自动重命名为 UUID 格式,并保存到临时目录,这导致博主在下载东西时无法自动化,反而降低了效率。本文深入分析这个问题的原因,并提供详细的解决方案。

问题现象

当使用 Antigravity IDE 控制 Chrome 浏览器下载文件时,出现以下异常行为:

  1. 文件名变成 UUID:如 f6746de9-95f6-4c06-b636-3ac7c2fb73bc,而非原始文件名
  2. 保存位置异常:文件被保存到 %LOCALAPPDATA%\Temp\playwright-artifacts-xxxxx 临时目录
  3. 每次会话独立:每次启动浏览器都会创建新的临时目录

问题原因分析

Playwright 的下载拦截机制

Playwright 是一个强大的浏览器自动化框架,被许多 IDE 和测试工具采用(如 Google Antigravity)。它默认会拦截所有下载操作,这是其核心设计特性。

通过分析 Playwright 源码 crBrowser.ts,可以找到问题的根源:

1
2
3
4
5
6
7
8
9
// CRBrowserContext._initialize() 中的代码
if (this._options.acceptDownloads !== 'internal-browser-default') {
  promises.push(this._browser._session.send('Browser.setDownloadBehavior', {
    behavior: this._options.acceptDownloads === 'accept' ? 'allowAndName' : 'deny',
    browserContextId: this._browserContextId,
    downloadPath: this._browser.options.downloadsPath,
    eventsEnabled: true,
  }));
}

CDP Browser.setDownloadBehavior 参数解析

Playwright 使用 Chrome DevTools Protocol (CDP) 的 Browser.setDownloadBehavior 命令来控制下载行为:

behavior 值 行为描述 文件名 downloadPath
deny 拒绝所有下载 N/A 不需要
allow 允许下载,使用原始文件名 原始名 必须指定
allowAndName 允许下载,使用 GUID 命名 UUID 必须指定
default 浏览器默认行为 原始名 可省略

关键区别

  • allow:虽然保留原始文件名,但必须指定 downloadPath,文件仍会保存到指定目录
  • default:使用浏览器原生下载行为,可以省略 downloadPath,文件保存到浏览器默认下载目录

问题根源:Playwright 硬编码使用了 allowAndName 模式,这是有意设计,目的是:

  • 防止多次下载时的文件名冲突
  • 确保自动化测试的隔离性
  • 便于 Playwright 追踪和管理下载

解决方案

修改 Playwright 源码

找到并修改 Playwright 核心文件:

文件路径

1
%LOCALAPPDATA%\ms-playwright-go\1.50.1\package\lib\server\chromium\crBrowser.js

修改内容:将 allowAndName 改为 default,并移除 downloadPath 参数

1
2
3
4
5
- behavior: this._options.acceptDownloads === 'accept' ? 'allowAndName' : 'deny',
- browserContextId: this._browserContextId,
- downloadPath: this._browser.options.downloadsPath,
+ behavior: this._options.acceptDownloads === 'accept' ? 'default' : 'deny',
+ browserContextId: this._browserContextId,

说明:使用 default 行为(浏览器默认行为),Playwright 将不再拦截下载,文件会直接保存到浏览器配置的默认下载目录。

[!WARNING] 千万不要使用 allow 且不指定路径:CDP 协议规定 behavior: 'allow' 时必须提供 downloadPath。如果移除 downloadPath 但仍使用 'allow',会导致 CDP 命令参数校验失败,进而导致 Playwright 无法启动浏览器(报错或卡在 invoke 阶段)。

重要提示:修改后需要重启 Antigravity IDE(或相关应用程序)才能生效。验证步骤:

  1. 关闭所有由 Antigravity 控制的 Chrome 浏览器窗口
  2. 完全退出 Antigravity IDE
  3. 重新启动 Antigravity
  4. 测试下载功能

相关 GitHub Issues

Issue 描述 状态
#32692 请求支持用户指定的下载文件名 Open
#7464 UUID 文件名是否是预期行为 Closed
#2058 允许使用自然浏览器文件名 Closed

不知道修改哪个路径的 crBrowser.js?

有时候,需要从 Claude Code 通过 playwright 来访问 Chrome,不清楚用到了哪个路径下的 crBrowser.js 文件,则可以通过下述步骤找到生效的 crBrowser.js 文件,然后对它定制。

先打开 Everything,搜索 crBrowser.js 路径:

image-20260416182014517

打开 Process Monitor,点击Filter,然后将刚才出现的路径全部加进来,监控它们: image-20260416181954393

image-20260416182053561

然后模拟实际的操作:打开 Antigravity 里的 Chrome,并打开 Antigravity 里的 Claude Code 或者自带的 AI Chat,下发指令让 AI 打开 chrome

这时可以在 Process Monitor 里看到:访问了哪些路径的 crBrowser.js 文件。 image-20260416182100074

然后就可以逐个修改逐个验证,不行再修改另一个;或者一次性全部修改完即可。

一键批量修改:Python 脚本(Windows 11)

如果你和博主一样懒,不想手动逐个找 crBrowser.js 文件并 vim 进去改,可以用下面的 Python 脚本一键搞定:借助 Everything 的 es.exe 全盘检索 → 自动备份 → 正则替换 → 干跑 / 实跑双模式

前置条件

  1. 已安装 Everything 并保持后台运行(脚本通过 IPC 与之通信,不需要重新建索引)
  2. es.exe 可用,默认路径为:

    1
    
    %LOCALAPPDATA%\Microsoft\WindowsApps\es.exe
    

    如果你装到了别处(例如 C:\Tools\Everything\es.exe),运行脚本时通过 --es 显式指定即可。

  3. Python 3.8+,标准库即可,无第三方依赖。

脚本内容

将下面的脚本保存为 C:\Users\<你的用户名>\Documents\patch_crbrowser_download.py

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Patch Playwright crBrowser.js download behavior on Windows 11.

- Uses Everything es.exe to locate every crBrowser.js on disk.
- Replaces 'allowAndName' with 'default'.
- Removes the downloadPath line so the browser default download dir is used.
- Defaults to dry-run; pass --apply to actually modify files.
- Creates a timestamped .bak.<YYYYmmdd_HHMMSS> backup next to each patched file.
"""

import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Tuple


DEFAULT_ES_EXE = Path(
    os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WindowsApps\es.exe")
)
DEFAULT_TARGET = "crBrowser.js"

BEHAVIOR_PATTERN = re.compile(
    r"""^(\s*)behavior:\s*this\._options\.acceptDownloads\s*===\s*['"]accept['"]\s*\?\s*['"]allowAndName['"]\s*:\s*['"]deny['"],\s*$""",
    re.MULTILINE,
)
DOWNLOAD_PATH_PATTERN = re.compile(
    r"""^\s*downloadPath:\s*this\._browser\.options\.downloadsPath,\s*\r?\n?""",
    re.MULTILINE,
)


def read_text_guess(path: Path) -> Tuple[str, str]:
    raw = path.read_bytes()
    for encoding in ("utf-8-sig", "utf-8", "cp936"):
        try:
            return raw.decode(encoding), encoding
        except UnicodeDecodeError:
            continue
    raise UnicodeError("Cannot decode file: {}".format(path))


def write_text_keep_encoding(path: Path, text: str, encoding: str) -> None:
    path.write_text(text, encoding=encoding, newline="")


def make_backup(path: Path) -> Path:
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_path = path.with_name("{}.bak.{}".format(path.name, timestamp))
    shutil.copy2(path, backup_path)
    return backup_path


def run_everything_search(
    es_exe: Path,
    target_name: str,
    max_results: int,
    search_path: Optional[str] = None,
) -> List[Path]:
    if not es_exe.exists():
        raise FileNotFoundError("es.exe not found: {}".format(es_exe))

    tmp_path = None
    try:
        with tempfile.NamedTemporaryFile(
            suffix=".txt", delete=False, mode="w", encoding="utf-8",
        ) as tmp:
            tmp_path = Path(tmp.name)

        cmd = [str(es_exe), "-a-d", "-n", str(max_results),
               "-export-txt", str(tmp_path)]
        if search_path:
            cmd.extend(["-path", search_path])
        cmd.append(target_name)

        result = subprocess.run(
            cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
            text=True, encoding="utf-8", errors="replace",
        )
        if result.returncode != 0:
            raise RuntimeError(
                "Everything es.exe search failed.\n"
                "Return code: {}\nCommand: {}\nSTDOUT:\n{}\nSTDERR:\n{}".format(
                    result.returncode, " ".join(cmd),
                    result.stdout, result.stderr,
                )
            )

        content = tmp_path.read_text(encoding="utf-8-sig", errors="replace")
        paths = []
        for line in content.splitlines():
            line = line.strip().strip('"')
            if not line:
                continue
            paths.append(Path(line))
        return paths
    finally:
        if tmp_path is not None:
            try:
                tmp_path.unlink()
            except Exception:
                pass


def patch_crbrowser_content(text: str) -> Tuple[str, bool, bool]:
    new_text, behavior_count = BEHAVIOR_PATTERN.subn(
        r"\1behavior: this._options.acceptDownloads === \"accept\" ? \"default\" : \"deny\",",
        text,
    )
    new_text, download_path_count = DOWNLOAD_PATH_PATTERN.subn("", new_text)
    return new_text, behavior_count > 0, download_path_count > 0


def dedupe_paths(paths: List[Path]) -> List[Path]:
    seen, result = set(), []
    for path in paths:
        key = str(path).lower()
        if key in seen:
            continue
        seen.add(key)
        result.append(path)
    return result


def is_target_file(path: Path, target_name: str) -> bool:
    return path.name.lower() == target_name.lower()


def patch_one_file(
    path: Path, target_name: str,
    apply_changes: bool, create_backup: bool,
) -> Tuple[str, str]:
    if not path.exists():
        return "MISSING", "[MISSING] {}".format(path)
    if not path.is_file():
        return "SKIP", "[SKIP] Not a file: {}".format(path)
    if not is_target_file(path, target_name):
        return "SKIP", "[SKIP] Not target file: {}".format(path)

    text, encoding = read_text_guess(path)
    new_text, behavior_changed, download_path_removed = patch_crbrowser_content(text)
    if new_text == text:
        return "UNCHANGED", "[UNCHANGED] {}".format(path)

    lines = [
        "[CHANGE] {}".format(path),
        "         behavior allowAndName -> default : {}".format(behavior_changed),
        "         remove downloadPath line         : {}".format(download_path_removed),
    ]
    if apply_changes:
        if create_backup:
            backup_path = make_backup(path)
            lines.append("         backup: {}".format(backup_path))
        write_text_keep_encoding(path, new_text, encoding)
        lines.append("         status: patched")
    else:
        lines.append("         status: dry-run only")
    return "CHANGE", "\n".join(lines)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Search crBrowser.js by Everything es.exe and patch Playwright download behavior."
    )
    parser.add_argument("--es", default=str(DEFAULT_ES_EXE),
                        help="Path to es.exe. Default: {}".format(DEFAULT_ES_EXE))
    parser.add_argument("--target", default=DEFAULT_TARGET,
                        help="Target file name. Default: crBrowser.js")
    parser.add_argument("--path", default=None,
                        help=r"Optional search root, e.g. C:\Users\xxx\AppData\Local")
    parser.add_argument("--max-results", type=int, default=10000,
                        help="Max Everything search results. Default: 10000")
    parser.add_argument("--apply", action="store_true",
                        help="Actually modify files. Without this flag: dry-run only.")
    parser.add_argument("--no-backup", action="store_true",
                        help="Do not create timestamped backup files when applying.")
    args = parser.parse_args()

    es_exe = Path(os.path.expandvars(args.es))
    target_name = args.target
    create_backup = not args.no_backup

    print("[INFO] es.exe : {}".format(es_exe))
    print("[INFO] target : {}".format(target_name))
    if args.path:
        print("[INFO] path   : {}".format(args.path))
    print("[INFO] mode   : {}".format("APPLY" if args.apply else "DRY-RUN"))
    if args.apply:
        print("[INFO] backup : {}".format("enabled" if create_backup else "disabled"))
    print()

    try:
        found_paths = run_everything_search(
            es_exe=es_exe, target_name=target_name,
            max_results=args.max_results, search_path=args.path,
        )
    except Exception as exc:
        print("[ERROR] {}".format(exc), file=sys.stderr)
        print("\nPlease check:\n"
              "  1. Everything is running.\n"
              "  2. es.exe path is correct.\n"
              "  3. Current user can access matched files.\n"
              "  4. Everything index contains the target file.")
        return 2

    candidates = [p for p in dedupe_paths(found_paths)
                  if is_target_file(p, target_name)]
    print("[INFO] Found {} candidate files\n".format(len(candidates)))

    changed = unchanged = missing = skipped = failed = 0
    for path in candidates:
        try:
            status, message = patch_one_file(
                path=path, target_name=target_name,
                apply_changes=args.apply, create_backup=create_backup,
            )
            print(message); print()
            if status == "CHANGE": changed += 1
            elif status == "UNCHANGED": unchanged += 1
            elif status == "MISSING": missing += 1
            elif status == "SKIP": skipped += 1
        except PermissionError as exc:
            print("[FAILED] {}\n         PermissionError: {}\n"
                  "         Try running PowerShell or CMD as Administrator.\n".format(path, exc))
            failed += 1
        except Exception as exc:
            print("[FAILED] {}\n         {}: {}\n".format(path, type(exc).__name__, exc))
            failed += 1

    print("[SUMMARY]")
    print("  candidates : {}".format(len(candidates)))
    print("  changed    : {}".format(changed))
    print("  unchanged  : {}".format(unchanged))
    print("  missing    : {}".format(missing))
    print("  skipped    : {}".format(skipped))
    print("  failed     : {}".format(failed))

    if not args.apply:
        print("\n[NOTE] Dry-run only. No file was modified.")
        print("       Run again with --apply after reviewing the output.")
    return 0 if failed == 0 else 1


if __name__ == "__main__":
    raise SystemExit(main())

一键运行步骤

Step 1:干跑(Dry-run),先看影响范围,不会修改任何文件

1
python3 C:\Users\wanlinwang\Documents\patch_crbrowser_download.py

输出示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
[INFO] mode   : DRY-RUN
[INFO] Found 3 candidate files

[CHANGE] C:\Users\wanlinwang\AppData\Local\ms-playwright-go\1.50.1\package\lib\server\chromium\crBrowser.js
         behavior allowAndName -> default : True
         remove downloadPath line         : True
         status: dry-run only
...
[SUMMARY]
  candidates : 3
  changed    : 3
  unchanged  : 0
  ...

Step 2:确认无误后实跑(Apply),自动备份并修改

1
python3 C:\Users\wanlinwang\Documents\patch_crbrowser_download.py --apply

每个被修改的文件旁边会生成一个时间戳备份,格式为:

1
crBrowser.js.bak.20260510_154233

Step 3:重启 Antigravity / Claude Code,验证下载文件名恢复正常。

常用变体

  • 指定 es.exe 路径(没装在默认位置时):

    1
    
    python3 patch_crbrowser_download.py --es "C:\Tools\Everything\es.exe" --apply
    
  • 缩小搜索范围(只在某个目录下找,速度更快、影响更可控):

    1
    
    python3 patch_crbrowser_download.py --path "C:\Users\wanlinwang\AppData\Local" --apply
    
  • 不创建备份(不推荐,除非你已经全局做了快照):

    1
    
    python3 patch_crbrowser_download.py --apply --no-backup
    

万一改坏了:一键回滚

每个备份文件都带时间戳,直接拷回原文件名即可:

1
copy /Y "crBrowser.js.bak.20260510_154233" "crBrowser.js"

或者用 PowerShell 批量回滚某次修改:

1
2
3
4
5
Get-ChildItem -Recurse -Filter "crBrowser.js.bak.20260510_154233" |
  ForEach-Object {
    $orig = $_.FullName -replace '\.bak\.\d{8}_\d{6}$',''
    Copy-Item $_.FullName $orig -Force
  }

脚本设计说明

  • 正则只匹配硬编码的 'allowAndName' : 'deny' 这一行,对 Playwright 后续版本如果换了字符串或重构成函数,会落到 UNCHANGED 而不是误改。
  • 保留原文件编码utf-8-sig / utf-8 / cp936 自动嗅探),避免 BOM 与换行符被改坏。
  • 默认 dry-run:除非显式 --apply,永远不会动文件。
  • 去重路径(小写比对),防止 Everything 把同一个文件以不同大小写返回多次。

总结

Playwright 的下载拦截行为是有意设计的,目的是确保自动化测试的隔离性。对于 Antigravity 用户,需要修改 crBrowser.js

  1. allowAndName 改为 default
  2. 移除 downloadPath 参数

修改后关闭浏览器并重启 Antigravity 即可恢复正常下载行为,文件将保存到浏览器默认下载目录。


参考资料: