Skip to content

Commit 4745799

Browse files
author
bugbase-automation
committed
Add networkidle exclusion to bundled driver via ts-morph at build time
patchright-python ships a vanilla driver (stealth is Python-layer), so it does NOT consume the driver-repo's ts-morph-patched driver. To get the networkidle captcha/analytics/heartbeat exclusion into the python package, patch the bundled coreBundle.js (_inflightRequestStarted/_inflightRequestFinished) with ts-morph AST after the wheels are built, then fix each wheel's RECORD hash so it stays valid. - patch_bundled_networkidle.mjs: ts-morph transform (finds the method, reads the bundler-renamed param, inserts after the _isFavicon early-return; idempotent; hard-fails if the anchor is gone) - patch_wheels_networkidle.py: runs it over dist/*.whl and rewrites RECORD - build_release.yml: Setup Node + npm install + run the patcher before upload
1 parent 0c18d51 commit 4745799

4 files changed

Lines changed: 148 additions & 6 deletions

File tree

.github/workflows/build_release.yml

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,20 @@ jobs:
5252
PLAYWRIGHT_TARGET_WHEEL=$wheel uv build --wheel
5353
done
5454
55+
# patchright-python bundles a vanilla driver (its stealth is Python-layer), so
56+
# the driver-level networkidle exclusion has to be added to the bundled
57+
# coreBundle.js here at build time - via ts-morph AST (no string replace, no
58+
# marker), then the wheel RECORD is fixed so the wheel stays install-valid.
59+
- name: Setup Node
60+
uses: actions/setup-node@v4
61+
with:
62+
node-version: '22'
63+
64+
- name: Patch networkidle into bundled driver (ts-morph)
65+
run: |
66+
npm install
67+
python3 patch_wheels_networkidle.py playwright-python/dist
68+
5569
- name: Upload wheel artifacts
5670
uses: actions/upload-artifact@v4
5771
with:
@@ -77,12 +91,12 @@ jobs:
7791
body: |
7892
Custom patchright build based on playwright-python ${{ github.event.inputs.playwright_version }}.
7993
80-
The networkidle captcha/analytics/heartbeat exclusion is applied at the
81-
**driver level** (ts-morph `framesPatch.ts`) in the bugbasesecurity
82-
patchright driver fork, which this package downloads instead of the
83-
upstream driver. No post-extraction JS patching in this package.
84-
85-
Driver: https://github.com/bugbasesecurity/patchright/releases/tag/v${{ github.event.inputs.patchright_release }}
94+
Stealth is upstream patchright's Python-layer patching (unchanged). This
95+
fork additionally excludes captcha/analytics/heartbeat requests from
96+
`networkidle` by patching the bundled driver's `coreBundle.js`
97+
(`_inflightRequestStarted`/`_inflightRequestFinished`) via **ts-morph AST**
98+
at build time - no string replace, no marker comment - and fixing the
99+
wheel RECORD. Same exclusion list as the driver-repo `framesPatch.ts`.
86100
87101
Install: `pip install patchright@https://github.com/bugbasesecurity/patchright-python/releases/download/v${{ github.event.inputs.patchright_release }}/patchright-${{ github.event.inputs.patchright_release }}-py3-none-manylinux1_x86_64.whl`
88102
files: dist/*.whl

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "patchright-python-networkidle-patch",
3+
"private": true,
4+
"type": "module",
5+
"description": "ts-morph tooling to add the networkidle exclusion to the bundled driver at build time",
6+
"dependencies": { "ts-morph": "^24.0.0" }
7+
}

patch_bundled_networkidle.mjs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// AST patch (ts-morph) of the BUNDLED patchright driver (coreBundle.js) to exclude
2+
// captcha/analytics/heartbeat requests from networkidle. patchright-python ships a
3+
// vanilla driver bundle + Python-layer stealth, so networkidle (a driver-level concept)
4+
// must be added here at build time. This mirrors driver_patches/framesPatch.ts but runs
5+
// against the already-bundled coreBundle.js: it finds the _inflightRequestStarted /
6+
// _inflightRequestFinished methods, reads the real (bundler-renamed) param, and inserts
7+
// the exclusion right after the existing `if (<param>._isFavicon) return;` early-return.
8+
// No marker comment; hard-fails if an anchor is missing so upstream drift is caught.
9+
import { Project, SyntaxKind } from "ts-morph";
10+
11+
const EXCLUDED = [
12+
"challenges.cloudflare.com", "google.com/recaptcha", "www.gstatic.com/recaptcha",
13+
"hcaptcha.com", "api.funcaptcha.com", "client-api.arkoselabs.com",
14+
"google-analytics.com", "googletagmanager.com", "analytics.google.com",
15+
"hotjar.com", "fullstory.com", "logrocket.com", "mouseflow.com", "clarity.ms",
16+
"browser-intake-datadoghq.com", "sentry.io", "newrelic.com", "nr-data.net",
17+
"forter.com", "/heartbeat", "/keepalive", "/keep-alive", "/beacon",
18+
];
19+
20+
const target = process.argv[2];
21+
if (!target) { console.error("usage: node patch_bundled_networkidle.mjs <coreBundle.js>"); process.exit(2); }
22+
23+
const project = new Project({ compilerOptions: { allowJs: true } });
24+
const sf = project.addSourceFileAtPath(target);
25+
26+
function patchMethod(name) {
27+
const methods = sf.getDescendantsOfKind(SyntaxKind.MethodDeclaration)
28+
.filter((m) => m.getName() === name);
29+
let patched = 0;
30+
for (const m of methods) {
31+
const body = m.getBody();
32+
if (!body || body.getKind() !== SyntaxKind.Block) continue;
33+
const stmts = body.getStatements();
34+
const favIdx = stmts.findIndex((s) => s.getText().includes("._isFavicon"));
35+
if (favIdx === -1) continue; // not the frame-manager method
36+
if (body.getText().includes("some((p) => _reqUrl.includes(p))") ||
37+
body.getText().includes("challenges.cloudflare.com")) { patched++; continue; } // idempotent
38+
const param = m.getParameters()[0].getName();
39+
body.insertStatements(favIdx + 1, [
40+
`const _reqUrl = ${param}.url();`,
41+
`if (${JSON.stringify(EXCLUDED)}.some((p) => _reqUrl.includes(p))) return;`,
42+
]);
43+
patched++;
44+
}
45+
if (patched === 0) throw new Error(`networkidle patch: no '${name}' method with an _isFavicon anchor found — driver structure changed`);
46+
return patched;
47+
}
48+
49+
const a = patchMethod("_inflightRequestStarted");
50+
const b = patchMethod("_inflightRequestFinished");
51+
sf.saveSync();
52+
console.log(`patched _inflightRequestStarted x${a}, _inflightRequestFinished x${b}`);

patch_wheels_networkidle.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Apply the networkidle ts-morph patch to coreBundle.js inside built patchright
2+
wheels, then fix the wheel RECORD so the wheel stays install-valid.
3+
4+
patchright-python bundles a vanilla driver (its stealth is Python-layer), so the
5+
driver-level networkidle exclusion has to be added to the bundled driver at build
6+
time. We do it with ts-morph (patch_bundled_networkidle.mjs) rather than a string
7+
replace, and rewrite RECORD so pip/uv accept the modified wheel.
8+
9+
Usage: python patch_wheels_networkidle.py <wheel-or-dir> [more...]
10+
"""
11+
import base64
12+
import hashlib
13+
import subprocess
14+
import sys
15+
import tempfile
16+
import zipfile
17+
from pathlib import Path
18+
19+
CORE = "patchright/driver/package/lib/coreBundle.js"
20+
MJS = Path(__file__).with_name("patch_bundled_networkidle.mjs")
21+
22+
23+
def record_hash(data: bytes) -> str:
24+
digest = hashlib.sha256(data).digest()
25+
return "sha256=" + base64.urlsafe_b64encode(digest).decode().rstrip("=")
26+
27+
28+
def patch_wheel(wheel: Path) -> None:
29+
with zipfile.ZipFile(wheel) as z:
30+
names = z.namelist()
31+
if CORE not in names:
32+
raise SystemExit(f"{wheel.name}: {CORE} not found in wheel")
33+
contents = {n: z.read(n) for n in names}
34+
35+
with tempfile.TemporaryDirectory() as td:
36+
core_path = Path(td) / "coreBundle.js"
37+
core_path.write_bytes(contents[CORE])
38+
subprocess.run(["node", str(MJS), str(core_path)], check=True)
39+
patched = core_path.read_bytes()
40+
contents[CORE] = patched
41+
42+
record_name = next(n for n in contents if n.endswith(".dist-info/RECORD"))
43+
new_line = f"{CORE},{record_hash(patched)},{len(patched)}"
44+
rows = []
45+
for line in contents[record_name].decode().splitlines():
46+
rows.append(new_line if line.startswith(CORE + ",") else line)
47+
contents[record_name] = ("\n".join(rows) + "\n").encode()
48+
49+
tmp = wheel.with_suffix(".whl.tmp")
50+
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
51+
for name, data in contents.items():
52+
z.writestr(name, data)
53+
tmp.replace(wheel)
54+
print(f"patched networkidle into {wheel.name}")
55+
56+
57+
def main() -> None:
58+
targets: list[Path] = []
59+
for arg in sys.argv[1:]:
60+
p = Path(arg)
61+
targets.extend(sorted(p.glob("*.whl")) if p.is_dir() else [p])
62+
if not targets:
63+
raise SystemExit("no wheels given")
64+
for wheel in targets:
65+
patch_wheel(wheel)
66+
67+
68+
if __name__ == "__main__":
69+
main()

0 commit comments

Comments
 (0)