#!/usr/bin/env python3 """Async passive XHR/fetch capture using Playwright's async API. This script mirrors the sync version but uses async/await to avoid blocking the event loop. It attaches listeners before navigating and prints a small heartbeat that shows how many responses we've captured so you can see activity live. Usage: python scrapers/capture_passive_playwright_async.py --output-dir xhr_captured_async --continuous """ import argparse import asyncio import json import mimetypes import hashlib import os from datetime import datetime, timezone from pathlib import Path from dotenv import load_dotenv load_dotenv() try: from playwright.async_api import async_playwright except Exception: raise def sanitize(s: str) -> str: out = [] for ch in s: if ch.isalnum() or ch in ('-', '_', '.'): out.append(ch) else: out.append('_') name = ''.join(out) if len(name) > 180: name = name[:120] + '_' + hashlib.sha1(s.encode('utf-8')).hexdigest()[:8] return name def guess_ext(ctype: str) -> str: if not ctype: return 'bin' mt = ctype.split(';')[0].strip() ext = mimetypes.guess_extension(mt) if ext: return ext.lstrip('.') if mt.startswith('image/'): return mt.split('/')[1] if mt == 'application/json': return 'json' if mt == 'text/html': return 'html' if mt.startswith('text/'): return 'txt' return 'bin' def now_ts(): return datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') def save_binary(path: Path, data: bytes): path.parent.mkdir(parents=True, exist_ok=True) with open(path, 'wb') as f: f.write(data) def save_text(path: Path, text: str): path.parent.mkdir(parents=True, exist_ok=True) with open(path, 'w', encoding='utf-8') as f: f.write(text) def try_pretty_json(s: str): try: parsed = json.loads(s) return json.dumps(parsed, ensure_ascii=False, indent=2) except Exception: return None async def run(args): in_memory_index = {} # runtime/shared state for handlers output = Path(args.output_dir) output.mkdir(parents=True, exist_ok=True) index_path = output / 'capture_index.jsonl' capture_count = 0 capture_count_lock = asyncio.Lock() async def heartbeat(): while True: try: print(f"{now_ts()}\tHEARTBEAT\tcaptured={capture_count}") except Exception: pass await asyncio.sleep(5) async with async_playwright() as p: browser_type = p.chromium # Launch persistent context if args.channel: try: context = await browser_type.launch_persistent_context(user_data_dir=args.profile, headless=args.headless, channel=args.channel) except Exception: print(f'Launch with channel {args.channel} failed; falling back to bundled Chromium') context = await browser_type.launch_persistent_context(user_data_dir=args.profile, headless=args.headless) else: context = await browser_type.launch_persistent_context(user_data_dir=args.profile, headless=args.headless) page = await context.new_page() async def attempt_autologin(page, username: str, password: str, post_login_selector: str, wait_after: float = 1.0): """Async form-based autologin similar to save_page_snapshots.attempt_autologin.""" if not username or not password: return False # selectors user_selectors = ['input[name="username"]', 'input.usernameSelector', 'input[type="email"]', 'input[name*="email" i]', 'input[name*="user" i]', 'input[type="text"]'] pass_selectors = ['input[type="password"]', 'input[name="password"]', 'input.passwordSelector'] user_sel = None for sel in user_selectors: try: el = await page.query_selector(sel) if el: user_sel = sel break except Exception: continue pass_sel = None for sel in pass_selectors: try: el = await page.query_selector(sel) if el: pass_sel = sel break except Exception: continue if not user_sel or not pass_sel: try: with open(output / '_debug_events.log', 'a', encoding='utf-8') as dbg: dbg.write(f"{now_ts()}\tAUTOLOGIN\tselectors_missing\n") except Exception: pass return False try: await page.fill(user_sel, username) await asyncio.sleep(0.1) await page.fill(pass_sel, password) await asyncio.sleep(0.1) # try submit submit_selectors = ['button[type=submit]', 'input[type=submit]', 'button:has-text("Sign in")', 'button:has-text("Sign In")', 'button:has-text("Log in")'] clicked = False for s in submit_selectors: try: btn = await page.query_selector(s) if btn: await btn.click() clicked = True break except Exception: continue if not clicked: try: await page.press(pass_sel, 'Enter') except Exception: pass # wait for post-login selector try: if post_login_selector: await page.wait_for_selector(post_login_selector, timeout=10000) except Exception: pass await asyncio.sleep(wait_after) return True except Exception: try: with open(output / '_debug_events.log', 'a', encoding='utf-8') as dbg: dbg.write(f"{now_ts()}\tAUTOLOGIN\tfailed_exception\n") except Exception: pass return False async def on_response(resp): nonlocal capture_count try: req = resp.request rtype = (req.resource_type or '').lower() # write quick debug line try: with open(output / '_debug_events.log', 'a', encoding='utf-8') as dbg: dbg.write(f"{now_ts()}\tRESP\t{resp.status}\t{rtype}\t{req.url}\n") except Exception: pass # determine response content-type early so we can allow images resp_headers = dict(resp.headers) ctype = (resp_headers.get('content-type') or '').lower() allow = False if not args.capture_types: allow = True elif rtype in args.capture_types: allow = True # Heuristic: always capture known document endpoints if '/document/content/' in req.url or '/document/summary/' in req.url: allow = True # Always capture images regardless of capture_types if ctype.startswith('image/'): allow = True if not allow: try: with open(output / '_debug_events.log', 'a', encoding='utf-8') as dbg: dbg.write(f"{now_ts()}\tSKIP\t{rtype}\t{req.url}\t{ctype}\n") except Exception: pass return url = req.url ts = now_ts() safe = sanitize(url.replace('https://', '').replace('http://', '')) h = hashlib.sha1((url + ts).encode('utf-8')).hexdigest()[:8] body_path = None excerpt = None try: if 'application/json' in ctype or ctype.startswith('text/') or 'javascript' in ctype: try: txt = await resp.text() except asyncio.CancelledError: return except Exception as e: if 'Target page, context or browser has been closed' in str(e): return raise # try pretty JSON try: parsed = json.loads(txt) pretty = json.dumps(parsed, ensure_ascii=False, indent=2) content_bytes = pretty.encode('utf-8') excerpt = pretty[:800] use_text = pretty ext = 'json' except Exception: content_bytes = txt.encode('utf-8') excerpt = txt[:800] use_text = txt ext = 'txt' # deterministic filename based on content hash (sha1) content_sha1 = hashlib.sha1(content_bytes).hexdigest() fname = f"{safe}_{content_sha1}.{ext}" body_path = str(output / fname) # if file already exists, don't rewrite body; just update meta/index if not os.path.exists(body_path): save_text(output / fname, use_text) try: print(f"{now_ts()}\tSAVED\t{body_path}") except Exception: pass else: try: data = await resp.body() except asyncio.CancelledError: return except Exception as e: if 'Target page, context or browser has been closed' in str(e): return raise ext = guess_ext(ctype) # Put images into an images/ subdirectory, other binaries into assets/ if ctype.startswith('image/'): subdir = output / 'images' subdir.mkdir(parents=True, exist_ok=True) content_sha1 = hashlib.sha1(data).hexdigest() fname = f"{safe}_{content_sha1}.{ext}" body_path = str(subdir / fname) if not os.path.exists(body_path): save_binary(subdir / fname, data) try: print(f"{now_ts()}\tSAVED\t{body_path}") except Exception: pass else: subdir = output / 'assets' subdir.mkdir(parents=True, exist_ok=True) content_sha1 = hashlib.sha1(data).hexdigest() fname = f"{safe}_{content_sha1}.{ext}" body_path = str(subdir / fname) if not os.path.exists(body_path): save_binary(subdir / fname, data) try: print(f"{now_ts()}\tSAVED\t{body_path}") except Exception: pass except Exception: # final fallback: try binary try: data = await resp.body() ext = guess_ext(ctype) if ctype.startswith('image/'): # Fallback path for images: same stable naming without timestamp subdir = output / 'images' subdir.mkdir(parents=True, exist_ok=True) fname = f"{safe}_{h}.{ext}" body_path = str(subdir / fname) save_binary(subdir / fname, data) try: print(f"{now_ts()}\tSAVED\t{body_path}") except Exception: pass else: subdir = output / 'assets' subdir.mkdir(parents=True, exist_ok=True) fname = f"{safe}_{h}_{ts}.{ext}" body_path = str(subdir / fname) save_binary(subdir / fname, data) try: print(f"{now_ts()}\tSAVED\t{body_path}") except Exception: pass except Exception: # skip if still failing return # compute content hash for dedupe content_hash = None try: if 'txt' in locals() and isinstance(txt, str): content_hash = hashlib.sha256(txt.encode('utf-8')).hexdigest() elif 'pretty' in locals() and isinstance(pretty, str): content_hash = hashlib.sha256(pretty.encode('utf-8')).hexdigest() elif 'data' in locals() and isinstance(data, (bytes, bytearray)): content_hash = hashlib.sha256(data).hexdigest() except Exception: content_hash = None # prepare the meta filename we'll write for this response (canonical by content when available) meta_base = content_hash or h new_meta_name = f"{safe}_{meta_base}.meta.json" new_meta_abs = os.path.abspath(str(output / new_meta_name)) # If older responses exist with the same content, handle them according to dedupe policy try: matches = in_memory_index.get(content_hash, []) if content_hash else [] for j in matches: body = j.get('body_file') metaf = j.get('meta_file') try: if body and os.path.exists(body) and os.path.abspath(body) != os.path.abspath(body_path): if args.dry_run: print(f"DRYRUN: would remove old body {body}") else: if args.dedupe_policy == 'delete': os.remove(body) elif args.dedupe_policy == 'move': dupdir = output / 'duplicates' dupdir.mkdir(parents=True, exist_ok=True) try: os.replace(body, dupdir / os.path.basename(body)) except Exception: os.remove(body) elif args.dedupe_policy == 'symlink': # replace old body with symlink to canonical try: os.remove(body) os.symlink(os.path.abspath(body_path), body) except Exception: pass try: with open(output / '_debug_events.log', 'a', encoding='utf-8') as dbg: dbg.write(f"{now_ts()}\tDEDUP_BODY\t{body}\n") except Exception: pass except Exception: pass try: if metaf and os.path.exists(metaf) and os.path.abspath(metaf) != new_meta_abs: if args.dry_run: print(f"DRYRUN: would remove old meta {metaf}") else: if args.dedupe_policy == 'delete': os.remove(metaf) elif args.dedupe_policy == 'move': dupdir = output / 'duplicates' dupdir.mkdir(parents=True, exist_ok=True) try: os.replace(metaf, dupdir / os.path.basename(metaf)) except Exception: os.remove(metaf) elif args.dedupe_policy == 'symlink': try: os.remove(metaf) os.symlink(new_meta_abs, metaf) except Exception: pass try: with open(output / '_debug_events.log', 'a', encoding='utf-8') as dbg: dbg.write(f"{now_ts()}\tDEDUP_META\t{metaf}\n") except Exception: pass except Exception: pass except Exception: pass meta = { 'url': url, 'resource_type': rtype, 'status': resp.status, 'timestamp': ts, 'response_headers': resp_headers, 'response_body_file': body_path, 'response_excerpt': excerpt, 'content_hash': content_hash, } # write canonical meta (update if exists) save_text(output / new_meta_name, json.dumps(meta, ensure_ascii=False, indent=2)) try: entry = {'url': url, 'resource_type': rtype, 'timestamp': ts, 'body_file': body_path, 'meta_file': str(output / new_meta_name), 'excerpt': excerpt, 'content_hash': content_hash} with open(index_path, 'a', encoding='utf-8') as idx: idx.write(json.dumps(entry, ensure_ascii=False) + '\n') # update in-memory index if content_hash: in_memory_index.setdefault(content_hash, []).append(entry) except Exception: pass async with capture_count_lock: capture_count += 1 except Exception: # swallow unexpected exceptions from handler so event loop stays healthy try: with open(output / '_debug_events.log', 'a', encoding='utf-8') as dbg: dbg.write(f"{now_ts()}\tRESP_HANDLER_ERROR\n") except Exception: pass # helpers to attach to page def attach_page_listeners(pg): pg.on('response', lambda r: asyncio.create_task(on_response(r))) pg.on('request', lambda req: open(output / '_debug_events.log', 'a', encoding='utf-8').write(f"{now_ts()}\tREQUEST\t{req.method}\t{req.url}\n")) pg.on('requestfinished', lambda req: open(output / '_debug_events.log', 'a', encoding='utf-8').write(f"{now_ts()}\tREQUEST_FINISHED\t{req.method}\t{req.url}\n")) # attach listeners to existing pages for pg in context.pages: attach_page_listeners(pg) # attach to future pages context.on('page', lambda new_page: attach_page_listeners(new_page)) # context-level response and request listeners context.on('response', lambda r: asyncio.create_task(on_response(r))) context.on('request', lambda req: open(output / '_debug_events.log', 'a', encoding='utf-8').write(f"{now_ts()}\tCTX_REQUEST\t{req.method}\t{req.url}\n")) # Start heartbeat hb_task = asyncio.create_task(heartbeat()) # Navigate after listeners attached try: await page.goto(args.url) except Exception: pass # attempt autologin (form-based) if credentials provided uname = args.username or os.getenv('STATDX_USERNAME') pwd = args.password or os.getenv('STATDX_PASSWORD') if uname and pwd: try: ok = await attempt_autologin(page, uname, pwd, args.post_login_selector) try: with open(output / '_debug_events.log', 'a', encoding='utf-8') as dbg: dbg.write(f"{now_ts()}\tAUTOLOGIN_RESULT\t{ok}\n") except Exception: pass except Exception: pass # If interactive, let user log in; otherwise start capture immediately if not args.no_prompt: print('When you have logged in in the opened browser, press Enter here to continue and capture...') try: await asyncio.get_event_loop().run_in_executor(None, input) except Exception: pass print(f'Starting async passive capture (types: {args.capture_types})') try: if args.continuous: # keep running until KeyboardInterrupt while True: await asyncio.sleep(1) else: await asyncio.sleep(5) except KeyboardInterrupt: print('Interrupted; closing...') finally: hb_task.cancel() try: await context.close() except Exception: pass def parse_args(): parser = argparse.ArgumentParser() parser.add_argument('--url', default='https://app.statdx.com/') parser.add_argument('--profile', default='playwright_profile') parser.add_argument('--output-dir', default='xhr_captured_async') parser.add_argument('--headless', action='store_true') parser.add_argument('--continuous', action='store_true') parser.add_argument('--no-prompt', action='store_true') 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 that indicates a successful login') parser.add_argument('--channel', default=os.getenv('PLAYWRIGHT_CHROME_CHANNEL', 'chrome')) parser.add_argument('--capture-types', default='xhr,fetch,document,other') parser.add_argument('--dedupe-policy', default='delete', choices=['delete', 'move', 'keep', 'symlink'], help="What to do with older duplicate files: delete/move/keep/symlink") parser.add_argument('--dry-run', action='store_true', help='If set, show dedupe actions without performing them') return parser.parse_args() if __name__ == '__main__': args = parse_args() # normalize capture types to a set args.capture_types = {t.strip().lower() for t in args.capture_types.split(',') if t.strip()} try: asyncio.run(run(args)) except KeyboardInterrupt: pass