reverse-api-engineer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reverse_api/__init__.py +3 -0
- reverse_api/browser.py +419 -0
- reverse_api/cli.py +530 -0
- reverse_api/config.py +52 -0
- reverse_api/engineer.py +209 -0
- reverse_api/messages.py +83 -0
- reverse_api/session.py +71 -0
- reverse_api/tui.py +201 -0
- reverse_api/utils.py +112 -0
- reverse_api_engineer-0.1.0.dist-info/METADATA +212 -0
- reverse_api_engineer-0.1.0.dist-info/RECORD +14 -0
- reverse_api_engineer-0.1.0.dist-info/WHEEL +4 -0
- reverse_api_engineer-0.1.0.dist-info/entry_points.txt +2 -0
- reverse_api_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
reverse_api/__init__.py
ADDED
reverse_api/browser.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Browser management with Playwright for HAR recording."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page
|
|
11
|
+
from playwright_stealth import Stealth
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from .utils import get_har_dir, get_timestamp
|
|
15
|
+
from .tui import THEME_PRIMARY, THEME_DIM, THEME_SUCCESS
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
# Realistic Chrome user agents (updated for late 2024/2025)
|
|
20
|
+
USER_AGENTS = [
|
|
21
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
22
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
|
23
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
24
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Stealth JavaScript to inject - bypasses common detection methods
|
|
28
|
+
STEALTH_JS = """
|
|
29
|
+
// Override navigator.webdriver
|
|
30
|
+
Object.defineProperty(navigator, 'webdriver', {
|
|
31
|
+
get: () => undefined,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Override navigator.plugins to look like a real browser
|
|
35
|
+
Object.defineProperty(navigator, 'plugins', {
|
|
36
|
+
get: () => {
|
|
37
|
+
const plugins = [
|
|
38
|
+
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
|
39
|
+
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
|
|
40
|
+
{ name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
|
|
41
|
+
];
|
|
42
|
+
plugins.item = (index) => plugins[index];
|
|
43
|
+
plugins.namedItem = (name) => plugins.find(p => p.name === name) || null;
|
|
44
|
+
plugins.refresh = () => {};
|
|
45
|
+
return plugins;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Override navigator.languages
|
|
50
|
+
Object.defineProperty(navigator, 'languages', {
|
|
51
|
+
get: () => ['en-US', 'en'],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Override navigator.permissions.query for notifications
|
|
55
|
+
const originalQuery = window.navigator.permissions.query;
|
|
56
|
+
window.navigator.permissions.query = (parameters) => {
|
|
57
|
+
if (parameters.name === 'notifications') {
|
|
58
|
+
return Promise.resolve({ state: Notification.permission });
|
|
59
|
+
}
|
|
60
|
+
return originalQuery(parameters);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Remove automation-related properties from window
|
|
64
|
+
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array;
|
|
65
|
+
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Object;
|
|
66
|
+
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise;
|
|
67
|
+
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Proxy;
|
|
68
|
+
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol;
|
|
69
|
+
|
|
70
|
+
// Override chrome runtime to look authentic
|
|
71
|
+
if (!window.chrome) {
|
|
72
|
+
window.chrome = {};
|
|
73
|
+
}
|
|
74
|
+
window.chrome.runtime = {
|
|
75
|
+
PlatformOs: { MAC: 'mac', WIN: 'win', ANDROID: 'android', CROS: 'cros', LINUX: 'linux', OPENBSD: 'openbsd' },
|
|
76
|
+
PlatformArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64' },
|
|
77
|
+
PlatformNaclArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64' },
|
|
78
|
+
RequestUpdateCheckStatus: { THROTTLED: 'throttled', NO_UPDATE: 'no_update', UPDATE_AVAILABLE: 'update_available' },
|
|
79
|
+
OnInstalledReason: { INSTALL: 'install', UPDATE: 'update', CHROME_UPDATE: 'chrome_update', SHARED_MODULE_UPDATE: 'shared_module_update' },
|
|
80
|
+
OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Fix iframe contentWindow detection
|
|
84
|
+
const originalAttachShadow = Element.prototype.attachShadow;
|
|
85
|
+
Element.prototype.attachShadow = function(init) {
|
|
86
|
+
if (init && init.mode === 'closed') {
|
|
87
|
+
init.mode = 'open';
|
|
88
|
+
}
|
|
89
|
+
return originalAttachShadow.call(this, init);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Override WebGL vendor/renderer to look consistent
|
|
93
|
+
const getParameter = WebGLRenderingContext.prototype.getParameter;
|
|
94
|
+
WebGLRenderingContext.prototype.getParameter = function(parameter) {
|
|
95
|
+
if (parameter === 37445) { // UNMASKED_VENDOR_WEBGL
|
|
96
|
+
return 'Google Inc. (Apple)';
|
|
97
|
+
}
|
|
98
|
+
if (parameter === 37446) { // UNMASKED_RENDERER_WEBGL
|
|
99
|
+
return 'ANGLE (Apple, ANGLE Metal Renderer: Apple M1 Pro, Unspecified Version)';
|
|
100
|
+
}
|
|
101
|
+
return getParameter.call(this, parameter);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Do the same for WebGL2
|
|
105
|
+
const getParameter2 = WebGL2RenderingContext.prototype.getParameter;
|
|
106
|
+
WebGL2RenderingContext.prototype.getParameter = function(parameter) {
|
|
107
|
+
if (parameter === 37445) {
|
|
108
|
+
return 'Google Inc. (Apple)';
|
|
109
|
+
}
|
|
110
|
+
if (parameter === 37446) {
|
|
111
|
+
return 'ANGLE (Apple, ANGLE Metal Renderer: Apple M1 Pro, Unspecified Version)';
|
|
112
|
+
}
|
|
113
|
+
return getParameter2.call(this, parameter);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Override Permissions API
|
|
117
|
+
const originalPermissionsQuery = navigator.permissions.query;
|
|
118
|
+
navigator.permissions.query = function(permissionDesc) {
|
|
119
|
+
if (permissionDesc.name === 'notifications') {
|
|
120
|
+
return Promise.resolve({
|
|
121
|
+
state: 'prompt',
|
|
122
|
+
onchange: null
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return originalPermissionsQuery.call(navigator.permissions, permissionDesc);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Spoof hardwareConcurrency to a realistic value
|
|
129
|
+
Object.defineProperty(navigator, 'hardwareConcurrency', {
|
|
130
|
+
get: () => 8,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Spoof deviceMemory
|
|
134
|
+
Object.defineProperty(navigator, 'deviceMemory', {
|
|
135
|
+
get: () => 8,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Spoof connection info
|
|
139
|
+
if (navigator.connection) {
|
|
140
|
+
Object.defineProperty(navigator.connection, 'rtt', {
|
|
141
|
+
get: () => 50,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Hide automation in chrome.app
|
|
146
|
+
if (window.chrome && window.chrome.app) {
|
|
147
|
+
window.chrome.app.isInstalled = false;
|
|
148
|
+
window.chrome.app.InstallState = { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' };
|
|
149
|
+
window.chrome.app.RunningState = { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log('Stealth mode activated');
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# Default Chrome profile path on macOS
|
|
157
|
+
CHROME_USER_DATA_DIR = Path.home() / "Library/Application Support/Google/Chrome"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_chrome_profile_dir() -> Path | None:
|
|
161
|
+
"""Get Chrome user data directory if it exists."""
|
|
162
|
+
if CHROME_USER_DATA_DIR.exists():
|
|
163
|
+
return CHROME_USER_DATA_DIR
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ManualBrowser:
|
|
168
|
+
"""Manages a Playwright browser session with HAR recording.
|
|
169
|
+
|
|
170
|
+
Supports two modes:
|
|
171
|
+
- Real Chrome: Uses your actual Chrome browser with existing profile (best for stealth)
|
|
172
|
+
- Stealth Chromium: Falls back to Playwright's Chromium with stealth patches
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
run_id: str,
|
|
178
|
+
prompt: str,
|
|
179
|
+
output_dir: str | None = None,
|
|
180
|
+
use_real_chrome: bool = True, # New option to use real Chrome
|
|
181
|
+
):
|
|
182
|
+
self.run_id = run_id
|
|
183
|
+
self.prompt = prompt
|
|
184
|
+
self.output_dir = output_dir
|
|
185
|
+
self.use_real_chrome = use_real_chrome
|
|
186
|
+
self.har_dir = get_har_dir(run_id, output_dir)
|
|
187
|
+
self.har_path = self.har_dir / "recording.har"
|
|
188
|
+
self.metadata_path = self.har_dir / "metadata.json"
|
|
189
|
+
self._playwright = None
|
|
190
|
+
self._browser: Optional[Browser] = None
|
|
191
|
+
self._context: Optional[BrowserContext] = None
|
|
192
|
+
self._start_time: Optional[str] = None
|
|
193
|
+
self._user_agent = random.choice(USER_AGENTS)
|
|
194
|
+
self._using_persistent = False # Track if using persistent context
|
|
195
|
+
|
|
196
|
+
def _save_metadata(self, end_time: str) -> None:
|
|
197
|
+
"""Save run metadata to JSON file."""
|
|
198
|
+
metadata = {
|
|
199
|
+
"run_id": self.run_id,
|
|
200
|
+
"prompt": self.prompt,
|
|
201
|
+
"start_time": self._start_time,
|
|
202
|
+
"end_time": end_time,
|
|
203
|
+
"har_file": str(self.har_path),
|
|
204
|
+
}
|
|
205
|
+
with open(self.metadata_path, "w") as f:
|
|
206
|
+
json.dump(metadata, f, indent=2)
|
|
207
|
+
|
|
208
|
+
def _handle_signal(self, signum, frame) -> None:
|
|
209
|
+
"""Handle interrupt signals gracefully."""
|
|
210
|
+
console.print(f"\n\n [dim]terminating capture...[/dim]")
|
|
211
|
+
self.close()
|
|
212
|
+
sys.exit(0)
|
|
213
|
+
|
|
214
|
+
def _inject_stealth(self, page: Page) -> None:
|
|
215
|
+
"""Inject stealth scripts into page before any other scripts run."""
|
|
216
|
+
page.add_init_script(STEALTH_JS)
|
|
217
|
+
|
|
218
|
+
def _start_with_real_chrome(self, start_url: Optional[str] = None) -> Path:
|
|
219
|
+
"""Start using the real Chrome browser with user's profile."""
|
|
220
|
+
# We need to use a COPY of the profile to avoid locking issues
|
|
221
|
+
# Chrome locks its profile when running, so we can't use it directly
|
|
222
|
+
import shutil
|
|
223
|
+
import tempfile
|
|
224
|
+
|
|
225
|
+
chrome_profile = get_chrome_profile_dir()
|
|
226
|
+
if not chrome_profile:
|
|
227
|
+
console.print(f" [yellow]chrome profile not found, falling back to stealth mode[/yellow]")
|
|
228
|
+
return self._start_with_stealth_chromium(start_url)
|
|
229
|
+
|
|
230
|
+
# Create a temporary profile directory
|
|
231
|
+
temp_profile_dir = Path(tempfile.mkdtemp(prefix="chrome_profile_"))
|
|
232
|
+
|
|
233
|
+
console.print(f" [dim]using real chrome (profile copy)[/dim]")
|
|
234
|
+
console.print(f" [dim]note: close chrome if you have it open[/dim]")
|
|
235
|
+
console.print()
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
# Use launch_persistent_context with channel="chrome" to use real Chrome binary
|
|
239
|
+
# This gives us the real Chrome with a fresh profile that has all extensions/settings
|
|
240
|
+
self._context = self._playwright.chromium.launch_persistent_context(
|
|
241
|
+
user_data_dir=str(temp_profile_dir),
|
|
242
|
+
channel="chrome", # Use real Chrome binary
|
|
243
|
+
headless=False,
|
|
244
|
+
record_har_path=str(self.har_path),
|
|
245
|
+
record_har_content="attach",
|
|
246
|
+
no_viewport=True,
|
|
247
|
+
args=[
|
|
248
|
+
"--start-maximized",
|
|
249
|
+
"--disable-blink-features=AutomationControlled",
|
|
250
|
+
],
|
|
251
|
+
ignore_default_args=["--enable-automation", "--no-sandbox"],
|
|
252
|
+
)
|
|
253
|
+
self._using_persistent = True
|
|
254
|
+
|
|
255
|
+
# Get or create page
|
|
256
|
+
if self._context.pages:
|
|
257
|
+
page = self._context.pages[0]
|
|
258
|
+
else:
|
|
259
|
+
page = self._context.new_page()
|
|
260
|
+
|
|
261
|
+
if start_url:
|
|
262
|
+
page.goto(start_url, wait_until="domcontentloaded")
|
|
263
|
+
|
|
264
|
+
# Wait for browser to close
|
|
265
|
+
try:
|
|
266
|
+
while self._context.pages:
|
|
267
|
+
self._context.pages[0].wait_for_timeout(500)
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
return self.close()
|
|
272
|
+
|
|
273
|
+
finally:
|
|
274
|
+
# Clean up temp profile
|
|
275
|
+
try:
|
|
276
|
+
shutil.rmtree(temp_profile_dir, ignore_errors=True)
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
def _start_with_stealth_chromium(self, start_url: Optional[str] = None) -> Path:
|
|
281
|
+
"""Start using Playwright's Chromium with stealth patches."""
|
|
282
|
+
# Comprehensive stealth Chrome arguments
|
|
283
|
+
chrome_args = [
|
|
284
|
+
"--start-maximized",
|
|
285
|
+
"--disable-blink-features=AutomationControlled",
|
|
286
|
+
"--disable-features=IsolateOrigins,site-per-process",
|
|
287
|
+
"--disable-infobars",
|
|
288
|
+
"--disable-dev-shm-usage",
|
|
289
|
+
"--disable-background-networking",
|
|
290
|
+
"--disable-default-apps",
|
|
291
|
+
"--disable-extensions",
|
|
292
|
+
"--disable-sync",
|
|
293
|
+
"--disable-translate",
|
|
294
|
+
"--no-first-run",
|
|
295
|
+
"--no-default-browser-check",
|
|
296
|
+
"--no-service-autorun",
|
|
297
|
+
"--disable-backgrounding-occluded-windows",
|
|
298
|
+
"--disable-renderer-backgrounding",
|
|
299
|
+
"--disable-background-timer-throttling",
|
|
300
|
+
"--disable-ipc-flooding-protection",
|
|
301
|
+
"--disable-hang-monitor",
|
|
302
|
+
"--disable-prompt-on-repost",
|
|
303
|
+
"--disable-client-side-phishing-detection",
|
|
304
|
+
"--disable-webrtc-hw-encoding",
|
|
305
|
+
"--disable-webrtc-hw-decoding",
|
|
306
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
307
|
+
"--enable-features=NetworkService,NetworkServiceInProcess",
|
|
308
|
+
"--disable-component-update",
|
|
309
|
+
"--disable-domain-reliability",
|
|
310
|
+
"--disable-features=AutofillServerCommunication",
|
|
311
|
+
"--password-store=basic",
|
|
312
|
+
"--use-mock-keychain",
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
self._browser = self._playwright.chromium.launch(
|
|
316
|
+
headless=False,
|
|
317
|
+
args=chrome_args,
|
|
318
|
+
ignore_default_args=["--enable-automation", "--no-sandbox"],
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Create context with HAR recording and realistic settings
|
|
322
|
+
self._context = self._browser.new_context(
|
|
323
|
+
record_har_path=str(self.har_path),
|
|
324
|
+
record_har_content="attach",
|
|
325
|
+
no_viewport=True,
|
|
326
|
+
locale="en-US",
|
|
327
|
+
timezone_id="America/New_York",
|
|
328
|
+
user_agent=self._user_agent,
|
|
329
|
+
screen={"width": 1920, "height": 1080},
|
|
330
|
+
color_scheme="light",
|
|
331
|
+
reduced_motion="no-preference",
|
|
332
|
+
forced_colors="none",
|
|
333
|
+
extra_http_headers={
|
|
334
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
335
|
+
"sec-ch-ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
|
336
|
+
"sec-ch-ua-mobile": "?0",
|
|
337
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
338
|
+
},
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Apply playwright-stealth evasions
|
|
342
|
+
stealth = Stealth()
|
|
343
|
+
stealth.apply_stealth_sync(self._context)
|
|
344
|
+
|
|
345
|
+
# Add custom stealth init script
|
|
346
|
+
self._context.add_init_script(STEALTH_JS)
|
|
347
|
+
|
|
348
|
+
# Open initial page
|
|
349
|
+
page = self._context.new_page()
|
|
350
|
+
|
|
351
|
+
if start_url:
|
|
352
|
+
page.goto(start_url, wait_until="domcontentloaded")
|
|
353
|
+
|
|
354
|
+
# Wait for browser to close
|
|
355
|
+
try:
|
|
356
|
+
while self._context.pages:
|
|
357
|
+
self._context.pages[0].wait_for_timeout(500)
|
|
358
|
+
except Exception:
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
return self.close()
|
|
362
|
+
|
|
363
|
+
def start(self, start_url: Optional[str] = None) -> Path:
|
|
364
|
+
"""Start the browser with HAR recording enabled. Returns HAR path when done."""
|
|
365
|
+
self._start_time = get_timestamp()
|
|
366
|
+
|
|
367
|
+
# Set up signal handlers for graceful shutdown
|
|
368
|
+
signal.signal(signal.SIGINT, self._handle_signal)
|
|
369
|
+
signal.signal(signal.SIGTERM, self._handle_signal)
|
|
370
|
+
|
|
371
|
+
console.print(f" [dim]capture starting...[/dim]")
|
|
372
|
+
console.print(f" [dim]━[/dim] [white]{self.run_id}[/white]")
|
|
373
|
+
console.print(f" [dim]goal[/dim] [white]{self.prompt}[/white]")
|
|
374
|
+
console.print()
|
|
375
|
+
console.print(f" [dim]navigate and interact to record traffic[/dim]")
|
|
376
|
+
console.print(f" [dim]close browser or ctrl+c to finalize[/dim]")
|
|
377
|
+
console.print()
|
|
378
|
+
|
|
379
|
+
self._playwright = sync_playwright().start()
|
|
380
|
+
|
|
381
|
+
# Try real Chrome first, fall back to stealth Chromium
|
|
382
|
+
if self.use_real_chrome:
|
|
383
|
+
return self._start_with_real_chrome(start_url)
|
|
384
|
+
else:
|
|
385
|
+
return self._start_with_stealth_chromium(start_url)
|
|
386
|
+
|
|
387
|
+
def close(self) -> Path:
|
|
388
|
+
"""Close the browser and save HAR file. Returns HAR path."""
|
|
389
|
+
end_time = get_timestamp()
|
|
390
|
+
|
|
391
|
+
if self._context:
|
|
392
|
+
try:
|
|
393
|
+
self._context.close() # This saves the HAR file
|
|
394
|
+
except Exception:
|
|
395
|
+
pass
|
|
396
|
+
self._context = None
|
|
397
|
+
|
|
398
|
+
# Only close browser if not using persistent context
|
|
399
|
+
if self._browser and not self._using_persistent:
|
|
400
|
+
try:
|
|
401
|
+
self._browser.close()
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
self._browser = None
|
|
405
|
+
|
|
406
|
+
if self._playwright:
|
|
407
|
+
try:
|
|
408
|
+
self._playwright.stop()
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
self._playwright = None
|
|
412
|
+
|
|
413
|
+
# Save metadata
|
|
414
|
+
self._save_metadata(end_time)
|
|
415
|
+
|
|
416
|
+
console.print(f" [dim]capture saved[/dim]")
|
|
417
|
+
console.print(f" [dim]metadata synced[/dim]")
|
|
418
|
+
|
|
419
|
+
return self.har_path
|