Files
statdx/scrapers/statdx_live_capture.py
T
Ross 491fca2f42 Add Playwright-based scraping and login scripts for STATdx
- Implemented `statdx_play_chrome.py` to launch Chrome via Playwright, allowing for headful browsing and capturing XHR requests.
- Created `statdx_playwright.py` for headless scraping of the STATdx application, including automatic login and HTML saving.
- Developed `statdx_requests.py` for form-based login and scraping, providing a fallback for sites using traditional authentication.
- Added example HTML output from Playwright scraping to `topics_playwright.html`.
- Captured XHR request data in JSON format for analysis in `xhr_captured/app.statdx.com_csrf_token_8000d0ed_20251014T124122Z.json`.
2025-10-14 13:46:39 +01:00

287 lines
12 KiB
Python

#!/usr/bin/env python3
"""Live-capture browser: open a visible Chromium window, persist the profile, and save HTML/JSON responses
as you browse so you can "download content as you browse normally".
Usage:
- Install Playwright and browsers: python -m pip install playwright python-dotenv; python -m playwright install
- Run:
python scrapers/statdx_live_capture.py --output captured --domain app.statdx.com
What it does:
- Launches a persistent Chromium context (profile saved to .playwright_profile by default) so cookies/session persist.
- Opens a visible browser (headful). You can interact with it manually.
- Listens to network responses. When it sees a response whose content-type is HTML or JSON (or document resource),
it saves the response body to the `output` directory with a sanitized filename derived from the URL.
Notes / limitations:
- This saves HTTP responses as they arrive. It does not run playback for dynamic client-rendered content (but it
will save the HTML shell and any XHR/JSON responses used by the client).
- You may need to filter by domain to avoid saving lots of third-party assets.
- Respect terms of service and only use for accounts you own/have permission to access.
"""
import os
import argparse
import logging
import time
import threading
import sys
from datetime import datetime, timezone
import hashlib
from urllib.parse import urlparse
from dotenv import load_dotenv
import mimetypes
load_dotenv()
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def sanitize_filename(url: str) -> str:
"""Create a short filesystem-safe filename from a URL."""
parsed = urlparse(url)
# use host + path + query, but keep it short
base = parsed.netloc + parsed.path
if parsed.query:
base += "?" + parsed.query
# remove leading/trailing slashes
base = base.strip("/")
if not base:
base = parsed.netloc
# replace problematic chars
safe = "".join(c if c.isalnum() or c in ('-', '_', '.') else '_' for c in base)
# add a short hash to avoid collisions and trim length
h = hashlib.sha1(url.encode('utf-8')).hexdigest()[:8]
filename = f"{safe[:240]}_{h}"
return filename
def auto_scroll(page, step_ms: int = 50, step_px: int = 300):
"""Scroll the page to the bottom slowly to trigger lazy loading.
Note: executed in the sync Playwright context by calling page.evaluate.
"""
try:
page.evaluate(
"""
async (step_px, step_ms) => {
const wait = (ms) => new Promise(r => setTimeout(r, ms));
const doc = document.scrollingElement || document.documentElement;
let total = 0;
while (total < doc.scrollHeight) {
doc.scrollBy(0, step_px);
total += step_px;
await wait(step_ms);
}
}
""",
step_px,
step_ms,
)
except Exception:
logging.exception('auto_scroll failed')
def save_response(resp, out_dir: str):
try:
url = resp.url
headers = resp.headers
ctype = headers.get('content-type', '')
# Decide whether to save: HTML or JSON (or document)
save_as_text = False
save_as_binary = False
if 'text/html' in ctype or resp.request.resource_type == 'document':
save_as_text = True
ext = '.html'
elif 'application/json' in ctype or 'text/javascript' in ctype or 'application/javascript' in ctype:
save_as_text = True
ext = '.json'
elif ctype.startswith('text/'):
save_as_text = True
ext = '.txt'
else:
# treat as binary and save (images, fonts, video, etc.)
save_as_binary = True
ext = ''
if not (save_as_text or save_as_binary):
return False
filename_base = sanitize_filename(url)
out_path = os.path.join(out_dir, filename_base + ext)
logging.info('Saving %s -> %s (content-type: %s)', url, out_path, ctype.split(';')[0])
if save_as_text:
try:
body = resp.text()
except Exception:
# fallback to binary then decode
body = resp.body().decode('utf-8', errors='replace')
with open(out_path, 'w', encoding='utf-8') as fh:
fh.write(body)
else:
body = resp.body()
# try to guess extension from content-type
try:
ctype_main = ctype.split(';')[0]
ext_guess = mimetypes.guess_extension(ctype_main) or ''
except Exception:
ext_guess = ''
out_path = out_path + (ext_guess if ext_guess else '')
with open(out_path, 'wb') as fh:
fh.write(body)
return True
except Exception as e:
logging.exception('Failed to save response: %s', e)
return False
def save_page_snapshot(page, out_dir: str, reason: str = ''):
try:
url = page.url
ts = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
filename_base = sanitize_filename(url)
safe_reason = ''.join(c if c.isalnum() or c in ('-', '_') else '_' for c in reason)[:40]
out_path = os.path.join(out_dir, f"snapshot_{filename_base}_{ts}_{safe_reason}.html")
logging.info('Saving page snapshot: %s -> %s', url, out_path)
content = page.content()
with open(out_path, 'w', encoding='utf-8') as fh:
fh.write(content)
# Screenshots are disabled (not saving visual snapshots per user request)
return True
except Exception:
logging.exception('Failed to save page snapshot')
return False
def main():
parser = argparse.ArgumentParser(description='Live-capture browsing responses using Playwright')
parser.add_argument('--output', '-o', default='captured', help='Directory to save captured responses')
parser.add_argument('--profile', '-p', default='.playwright_profile', help='Persistent profile directory')
parser.add_argument('--domain', '-d', default=os.getenv('STATDX_DOMAIN', 'app.statdx.com'), help='Only capture responses whose URL contains this domain')
parser.add_argument('--headless', action='store_true', help='Run headless (no visible browser)')
parser.add_argument('--snapshot-delay', type=float, default=0.5, help='Seconds to wait after an XHR before saving a snapshot')
parser.add_argument('--devtools', action='store_true', help='Open browser with devtools panel')
parser.add_argument('--width', type=int, default=1280, help='Viewport width')
parser.add_argument('--height', type=int, default=1600, help='Viewport height')
parser.add_argument('--slowmo', type=int, default=0, help='Slow down Playwright actions (ms)')
parser.add_argument('--user-agent', dest='user_agent', help='Custom User-Agent header')
parser.add_argument('--wait-for-selector', dest='wait_for_selector', help='CSS selector to wait for after navigation')
parser.add_argument('--auto-scroll', action='store_true', help='Automatically scroll the page when snapshotting to trigger lazy loads')
parser.add_argument('--channel', default='chrome', help='Playwright browser channel to use (e.g. chrome). Set to empty to use bundled Chromium')
args = parser.parse_args()
os.makedirs(args.output, exist_ok=True)
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
# Use persistent context so cookies/localStorage persist between runs
browser_type = p.chromium
logging.info('Launching Chromium (headless=%s) with profile: %s, channel=%s', args.headless, args.profile, args.channel)
if args.channel:
context = browser_type.launch_persistent_context(user_data_dir=args.profile, headless=args.headless, channel=args.channel)
else:
context = browser_type.launch_persistent_context(user_data_dir=args.profile, headless=args.headless)
# Optionally you can set viewport, user agent, etc. here.
# Create a new page with the requested viewport
page = context.new_page()
try:
page.set_viewport_size({"width": args.width, "height": args.height})
except Exception:
# Older playwright versions may not support set_viewport_size on persistent contexts
pass
def handle_response(resp):
try:
if args.domain not in resp.url:
return
# save textual responses
saved = save_response(resp, args.output)
# If this response is a document or an XHR/fetch that likely carried data,
# also save a rendered page snapshot so client-side content is captured.
rtype = resp.request.resource_type
if saved and rtype in ("document", "xhr", "fetch"):
# page may not have finished rendering; give it a small chance to update
try:
time.sleep(args.snapshot_delay)
if args.auto_scroll:
try:
auto_scroll(page)
except Exception:
logging.exception('auto-scroll failed')
save_page_snapshot(page, args.output, reason=rtype)
except Exception:
logging.exception('snapshot after response failed')
except Exception:
logging.exception('Error in response handler')
page.on('response', handle_response)
# Log console messages and page errors to help diagnose missing content
def on_console(msg):
try:
logging.info('PAGE LOG [%s] %s', msg.type, msg.text)
except Exception:
logging.info('PAGE LOG: %s', msg)
def on_page_error(exc):
logging.error('PAGE ERROR: %s', exc)
page.on('console', on_console)
page.on('pageerror', on_page_error)
logging.info('Open the browser window and interact with the site. Captured responses will be saved to: %s', args.output)
logging.info('Press Ctrl+C here in the terminal to exit and close the browser (profile saved).')
logging.info('Type "s" and press Enter (or just press Enter) in this terminal to save a manual snapshot of the current page.')
# Navigate to the domain root so you can log in manually if needed
start_url = f'https://{args.domain}/'
page.goto(start_url)
# Optionally wait for a selector that indicates the page finished rendering
if args.wait_for_selector:
try:
logging.info('Waiting for selector: %s', args.wait_for_selector)
page.wait_for_selector(args.wait_for_selector, timeout=20000)
except Exception:
logging.exception('Waiting for selector timed out')
def stdin_watcher():
# Run in a background thread so the main thread can keep the browser running
logging.debug('stdin watcher started')
for line in sys.stdin:
cmd = line.strip().lower()
if cmd == '' or cmd == 's' or cmd == 'snapshot' or cmd == 'save':
logging.info('Manual snapshot requested via stdin')
try:
if args.auto_scroll:
try:
auto_scroll(page)
except Exception:
logging.exception('manual auto-scroll failed')
save_page_snapshot(page, args.output, reason='manual')
except Exception:
logging.exception('manual snapshot failed')
watcher = threading.Thread(target=stdin_watcher, daemon=True)
watcher.start()
try:
# Keep the script running while the user interacts with the headed browser
while True:
time.sleep(1)
except KeyboardInterrupt:
logging.info('Shutting down...')
finally:
context.close()
if __name__ == '__main__':
main()