feat: Implement passive XHR/fetch capture using Playwright
- Added `capture_passive_playwright.py` for synchronous passive capture of XHR/fetch responses. - Added `capture_passive_playwright_async.py` for asynchronous passive capture using Playwright's async API. - Introduced `save_page_snapshots.py` to save full page HTML snapshots with deduplication. - Removed obsolete CSRF token capture JSON file.
This commit is contained in:
+106
-29
@@ -88,6 +88,8 @@ def main():
|
||||
parser.add_argument("--headless", action="store_true", help="Run headless")
|
||||
parser.add_argument("--capture-wait", type=int, default=8, help="Seconds to wait after reload to capture XHRs")
|
||||
parser.add_argument("--keep-open", action="store_true", help="Keep browser open after capture")
|
||||
parser.add_argument("--continuous", action="store_true", help="Continue capturing requests as you browse until interrupted")
|
||||
parser.add_argument("--no-prompt", action="store_true", help="Do not prompt before starting capture (useful for automated runs)")
|
||||
parser.add_argument("--username", help="STATdx username (or use STATDX_USERNAME env var)")
|
||||
parser.add_argument("--password", help="STATdx password (or use STATDX_PASSWORD env var)")
|
||||
parser.add_argument("--post-login-selector", default="#ds-app", help="Selector to wait for after login")
|
||||
@@ -101,23 +103,31 @@ def main():
|
||||
with sync_playwright() as p:
|
||||
# try to use installed Chrome first
|
||||
try:
|
||||
browser = p.chromium.launch_persistent_context(user_data_dir=args.profile, headless=args.headless, channel="chrome")
|
||||
page = browser.pages[0] if browser.pages else browser.new_page()
|
||||
context = p.chromium.launch_persistent_context(user_data_dir=args.profile, headless=args.headless, channel="chrome")
|
||||
page = context.pages[0] if context.pages else context.new_page()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to launch Chrome channel: {}. Falling back to chromium bundle.", e)
|
||||
browser = p.chromium.launch_persistent_context(user_data_dir=args.profile, headless=args.headless)
|
||||
page = browser.pages[0] if browser.pages else browser.new_page()
|
||||
context = p.chromium.launch_persistent_context(user_data_dir=args.profile, headless=args.headless)
|
||||
page = context.pages[0] if context.pages else context.new_page()
|
||||
|
||||
# attach CDP session to the page
|
||||
try:
|
||||
session = browser.new_cdp_session(page)
|
||||
session = context.new_cdp_session(page)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create CDP session: {}", e)
|
||||
browser.close()
|
||||
try:
|
||||
context.close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logger.info("Enabling Network domain on CDP")
|
||||
session.send("Network.enable")
|
||||
# Disable browser cache to force network requests (helps capture cached items)
|
||||
try:
|
||||
session.send("Network.setCacheDisabled", {"cacheDisabled": True})
|
||||
except Exception:
|
||||
logger.debug("Could not disable cache via CDP; continuing without cache disable")
|
||||
|
||||
seen = set()
|
||||
|
||||
@@ -137,7 +147,9 @@ def main():
|
||||
return
|
||||
seen.add(key)
|
||||
|
||||
# attempt to get body
|
||||
# attempt to get body; sometimes responseReceived doesn't allow
|
||||
# getResponseBody immediately, so we'll try but also rely on
|
||||
# a loadingFinished handler below.
|
||||
body = None
|
||||
base64_encoded = False
|
||||
try:
|
||||
@@ -146,7 +158,7 @@ def main():
|
||||
body = result.get("body")
|
||||
base64_encoded = bool(result.get("base64Encoded"))
|
||||
except Exception as e:
|
||||
logger.debug("Network.getResponseBody failed for {}: {}", url, e)
|
||||
logger.debug("Network.getResponseBody failed (responseReceived) for {}: {}", url, e)
|
||||
|
||||
# fallback: if no body, skip writing but still store metadata
|
||||
# build filenames
|
||||
@@ -196,7 +208,54 @@ def main():
|
||||
# subscribe to CDP event
|
||||
session.on("Network.responseReceived", on_response_received)
|
||||
|
||||
logger.info("Opening page: {}. Please log in if needed, then press ENTER to start capture.", args.url)
|
||||
# loadingFinished is a good fallback: some responses only have bodies
|
||||
# available after loadingFinished. We'll attempt to fetch body there
|
||||
# if not previously saved for the same requestId.
|
||||
def on_loading_finished(params):
|
||||
try:
|
||||
requestId = params.get("requestId")
|
||||
# call the same body retrieval logic via a small wrapper
|
||||
# create a fake params dict to reuse code
|
||||
fake = {"requestId": requestId, "response": {}}
|
||||
# attempt to get the body now
|
||||
try:
|
||||
result = session.send("Network.getResponseBody", {"requestId": requestId})
|
||||
except Exception:
|
||||
result = None
|
||||
if result:
|
||||
# build a minimal params for saving using response info from CDP
|
||||
resp = result
|
||||
# We don't have the URL here; try to fetch it via CDP - getRequestPostData isn't available.
|
||||
# Instead, reuse on_response_received's behavior by calling getResponseBody and writing
|
||||
# files directly here when possible.
|
||||
try:
|
||||
body = result.get("body")
|
||||
base64_encoded = bool(result.get("base64Encoded"))
|
||||
# fetch additional response info via Network.getResponseBody? we already have body.
|
||||
# write a minimal metadata file
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
safe = sanitize_fn(requestId)
|
||||
h = hashlib.sha1((requestId + str(time.time())).encode("utf-8")).hexdigest()[:8]
|
||||
ext = "bin"
|
||||
try:
|
||||
data = base64.b64decode(body) if base64_encoded else body.encode("utf-8")
|
||||
body_filename = f"{safe}_{h}_{ts}.{ext}"
|
||||
write_file(out_dir / body_filename, data)
|
||||
meta = {"requestId": requestId, "timestamp": ts, "response_body_file": str(out_dir / body_filename)}
|
||||
meta_filename = f"{safe}_{h}_{ts}.json"
|
||||
with open(out_dir / meta_filename, "w", encoding="utf-8") as mf:
|
||||
json.dump(meta, mf, ensure_ascii=False, indent=2)
|
||||
logger.info("Saved body via loadingFinished: {}", body_filename)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Error in loadingFinished handler")
|
||||
|
||||
session.on("Network.loadingFinished", on_loading_finished)
|
||||
|
||||
logger.info("Opening page: {}.", args.url)
|
||||
page.goto(args.url)
|
||||
|
||||
# Automatic login if credentials provided
|
||||
@@ -256,28 +315,46 @@ def main():
|
||||
except Exception:
|
||||
logger.exception('Automatic login attempt failed')
|
||||
|
||||
input("After logging in and navigating to the page you want to capture, press Enter to begin capture...\n")
|
||||
# Start capture: either prompt the user or begin immediately depending on flags
|
||||
try:
|
||||
if not args.no_prompt and not args.continuous:
|
||||
try:
|
||||
input("After logging in and navigating to the page you want to capture, press Enter to begin capture...\n")
|
||||
except Exception:
|
||||
# non-interactive; proceed
|
||||
pass
|
||||
|
||||
logger.info("Reloading page to trigger XHRs and network activity")
|
||||
page.reload()
|
||||
|
||||
# wait a bit to let XHRs fire and be processed by CDP
|
||||
wait = args.capture_wait
|
||||
logger.info("Waiting {} seconds to collect responses...", wait)
|
||||
time.sleep(wait)
|
||||
|
||||
logger.info("Capture pass complete. Metadata and bodies written to {}", out_dir)
|
||||
|
||||
if args.keep_open:
|
||||
logger.info("Keeping browser open. Close manually when done.")
|
||||
logger.info("Reloading page to trigger XHRs and network activity")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received interrupt, closing.")
|
||||
browser.close()
|
||||
else:
|
||||
browser.close()
|
||||
page.reload()
|
||||
except Exception:
|
||||
logger.exception('Failed to reload page; continuing capture')
|
||||
|
||||
# wait a bit to let XHRs fire and be processed by CDP
|
||||
wait = args.capture_wait
|
||||
logger.info("Waiting {} seconds to collect initial responses...", wait)
|
||||
time.sleep(wait)
|
||||
|
||||
logger.info("Initial capture pass complete. Metadata and bodies written to {}", out_dir)
|
||||
|
||||
if args.continuous:
|
||||
logger.info("Continuous capture enabled: saving responses as you browse. Press Ctrl+C to stop.")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Interrupted by user; closing browser and exiting")
|
||||
else:
|
||||
logger.info("One-shot capture complete.")
|
||||
|
||||
finally:
|
||||
try:
|
||||
context.close()
|
||||
except Exception:
|
||||
try:
|
||||
context.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user