| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308 |
- """Audit HAR files for service_api client drift.
- Filters captured browser traffic down to the API calls we care about, compares
- identifiers (GQL hashes, version headers) against current client constants, and
- lists any operations that weren't captured — with a hint for what action
- triggers each one.
- Usage:
- uv run python scripts/har_audit.py path/to/*.har
- One or more HAR files may be passed. Platform is detected from request domains;
- a single HAR may contain both platforms.
- """
- import json
- import re
- import sys
- import urllib.parse
- from pathlib import Path
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
- from room_cal.service_api._airbnb import DRIFT_MANIFEST as _AIRBNB_MANIFEST
- from room_cal.service_api._booking import DRIFT_MANIFEST as _BOOKING_MANIFEST
- # Each client module owns its DRIFT_MANIFEST — imported directly from
- # _airbnb.py / _booking.py, so there is no separate mirror to drift out of
- # sync. A new service gets drift-checking by adding its own import above.
- _AIRBNB_DOMAIN = _AIRBNB_MANIFEST['domain']
- _AIRBNB_API_KEY = _AIRBNB_MANIFEST['api_key']
- _AIRBNB_CLIENT_VERSION = _AIRBNB_MANIFEST['client_version']
- _AIRBNB_OPS: dict[str, dict] = _AIRBNB_MANIFEST['ops']
- _BOOKING_DOMAIN = _BOOKING_MANIFEST['domain']
- _BOOKING_ENDPOINTS: dict[str, dict] = _BOOKING_MANIFEST['endpoints']
- # ── HAR helpers ────────────────────────────────────────────────────────────
- def _req_headers(entry: dict) -> dict[str, str]:
- return {h['name'].lower(): h['value'] for h in entry['request']['headers']}
- def _req_body(entry: dict) -> dict | None:
- text = (entry['request'].get('postData') or {}).get('text', '')
- try:
- return json.loads(text) if text else None
- except (json.JSONDecodeError, ValueError):
- return None
- def _req_variables(entry: dict) -> dict | None:
- """The GQL `variables` object for a request, whether sent as a GET query param or in a POST body."""
- if (body := _req_body(entry)) is not None:
- return body.get('variables')
- qs = urllib.parse.urlparse(entry['request']['url']).query
- params = urllib.parse.parse_qs(qs)
- raw = params.get('variables', [None])[0]
- if raw is None:
- return None
- try:
- return json.loads(raw)
- except (json.JSONDecodeError, ValueError):
- return None
- def _short(h: str) -> str:
- """Truncate a hash/token for display: first 16 chars + ellipsis."""
- return h[:16] + '...' if len(h) > 20 else h
- # ── Airbnb ─────────────────────────────────────────────────────────────────
- def _collect_airbnb(entries: list[dict]) -> dict[str, list[dict]]:
- """Return {operation_name: [entry, ...]} for Airbnb /api/v3 calls."""
- found: dict[str, list[dict]] = {}
- for e in entries:
- url = e['request']['url']
- m = re.search(r'/api/v3/([A-Za-z]+)/([0-9a-f]{64})', url)
- if m:
- op = m.group(1)
- found.setdefault(op, []).append(e)
- return found
- def _audit_airbnb(entries: list[dict], har_name: str) -> bool:
- found = _collect_airbnb(entries)
- if not found:
- return False
- print(f'\n{"═" * 70}')
- print(f' Airbnb | {har_name}')
- print(f'{"═" * 70}')
- # Operations
- print('\n Operations:\n')
- col = max(len(op) for op in _AIRBNB_OPS) + 2
- all_ok = True
- for op, spec in _AIRBNB_OPS.items():
- if op not in found:
- print(f' — {op:<{col}} not captured')
- print(f' {"":>{col}} → {spec["hint"]}')
- continue
- entry = found[op][0]
- url = entry['request']['url']
- m = re.search(r'/api/v3/[A-Za-z]+/([0-9a-f]{64})', url)
- har_hash = m.group(1) if m else '?'
- expected = spec['hash']
- if har_hash == expected:
- print(f' ✓ {op:<{col}} hash matches')
- else:
- all_ok = False
- print(f' ✗ {op:<{col}} STALE hash')
- print(f' {"client:":<{col+5}} {expected}')
- print(f' {"HAR:":<{col+5}} {har_hash} ← update _HASHES[\'{op}\']')
- expected_keys = spec.get('var_keys')
- if expected_keys is not None:
- # Union keys across every captured call of this op, not just the first —
- # the real app sends different variable shapes for the same op depending
- # on which page/action triggered it (e.g. initial calendar load vs.
- # month-by-month paging), so a single entry isn't representative.
- har_key_sets = [
- set(v.keys()) for e in found[op]
- if isinstance(v := _req_variables(e), dict)
- ]
- if not har_key_sets:
- all_ok = False
- print(f' ✗ {op:<{col}} variables not found in any HAR entry')
- else:
- har_keys = set().union(*har_key_sets)
- missing = expected_keys - har_keys # client sends, no HAR call does — likely dropped
- extra = har_keys - expected_keys # some HAR call has, client doesn't — likely added/renamed
- if missing or extra:
- all_ok = False
- print(f' ✗ {op:<{col}} variable key drift')
- if missing:
- print(f' {"client sends, HAR lacks:":<{col+5}} {sorted(missing)} ← dropped/renamed; remove or fix in client')
- if extra:
- print(f' {"HAR has, client lacks:":<{col+5}} {sorted(extra)} ← new/renamed field; add to client')
- else:
- print(f' ✓ {op:<{col}} variable keys match')
- # Global headers (from any /api/v3 call)
- sample_entry = next(iter(found.values()))[0]
- hdrs = _req_headers(sample_entry)
- print('\n Global headers:\n')
- hcol = max(len('X-Airbnb-API-Key'), len('X-Client-Version')) + 2
- api_key = hdrs.get('x-airbnb-api-key', '')
- if api_key == _AIRBNB_API_KEY:
- print(f' ✓ {"X-Airbnb-API-Key":<{hcol}} matches')
- else:
- all_ok = False
- print(f' ✗ {"X-Airbnb-API-Key":<{hcol}} MISMATCH')
- print(f' client: {_AIRBNB_API_KEY}')
- print(f' HAR: {api_key} ← update _API_KEY')
- cv = hdrs.get('x-client-version', '')
- if cv == _AIRBNB_CLIENT_VERSION:
- print(f' ✓ {"X-Client-Version":<{hcol}} matches')
- else:
- all_ok = False
- print(f' ✗ {"X-Client-Version":<{hcol}} stale')
- print(f' client: {_AIRBNB_CLIENT_VERSION}')
- print(f' HAR: {cv} ← update _CLIENT_VERSION')
- missing = [op for op in _AIRBNB_OPS if op not in found]
- if not missing:
- print('\n All operations captured.')
- else:
- print(f'\n {len(missing)} operation(s) not captured — to get full coverage:')
- printed_hints: set[str] = set()
- for op in missing:
- hint = _AIRBNB_OPS[op]['hint']
- if hint not in printed_hints:
- print(f' • {hint}')
- printed_hints.add(hint)
- return True
- # ── Booking.com ────────────────────────────────────────────────────────────
- def _collect_booking(entries: list[dict]) -> dict[str, list[dict]]:
- """Return {endpoint_key: [entry, ...]} for Booking.com API calls."""
- found: dict[str, list[dict]] = {}
- for e in entries:
- url = e['request']['url']
- if _BOOKING_DOMAIN not in url:
- continue
- body = _req_body(e)
- body_op = (body or {}).get('operationName')
- for key, spec in _BOOKING_ENDPOINTS.items():
- if spec['url_fragment'] not in url:
- continue
- op_name = spec.get('operation_name')
- if op_name and body_op != op_name:
- continue
- found.setdefault(key, []).append(e)
- return found
- def _audit_booking(entries: list[dict], har_name: str) -> bool:
- found = _collect_booking(entries)
- if not found:
- return False
- print(f'\n{"═" * 70}')
- print(f' Booking.com | {har_name}')
- print(f'{"═" * 70}')
- print('\n Endpoints:\n')
- col = max(len(k) for k in _BOOKING_ENDPOINTS) + 2
- all_ok = True
- for key, spec in _BOOKING_ENDPOINTS.items():
- if key not in found:
- print(f' — {key:<{col}} not captured')
- print(f' {"":>{col}} → {spec["hint"]}')
- continue
- # Check every captured call of this endpoint, not just the first — the
- # same endpoint can show more than one header value within one HAR (e.g.
- # a stale value from a frontend bundle reload mid-capture), so a mismatch
- # only means something if the client's value never shows up at all.
- expected_headers = spec.get('headers', {})
- all_hdrs = [_req_headers(e) for e in found[key]]
- mismatches: list[tuple[str, str, list[str]]] = []
- for hname, hexpected in expected_headers.items():
- observed = [h.get(hname, '') for h in all_hdrs]
- if hexpected not in observed:
- mismatches.append((hname, hexpected, sorted(set(observed))))
- if not mismatches:
- print(f' ✓ {key:<{col}} found')
- else:
- all_ok = False
- print(f' ✗ {key:<{col}} found with stale headers')
- src = spec.get('source', 'the client headers')
- for hname, hexpected, observed in mismatches:
- print(f' {hname}')
- print(f' client: {hexpected}')
- print(f' HAR saw: {observed} ← update {src}')
- missing = [k for k in _BOOKING_ENDPOINTS if k not in found]
- if not missing:
- print('\n All endpoints captured.')
- else:
- print(f'\n {len(missing)} endpoint(s) not captured — to get full coverage:')
- printed_hints: set[str] = set()
- for key in missing:
- hint = _BOOKING_ENDPOINTS[key]['hint']
- if hint not in printed_hints:
- print(f' • {hint}')
- printed_hints.add(hint)
- return True
- # ── Entry point ────────────────────────────────────────────────────────────
- def audit_har(path: Path) -> None:
- try:
- har = json.loads(path.read_text(encoding='utf-8'))
- except Exception as e:
- print(f'Error reading {path}: {e}', file=sys.stderr)
- return
- entries = har.get('log', {}).get('entries', [])
- if not entries:
- print(f'No entries in {path.name}', file=sys.stderr)
- return
- # Detect which platforms appear in this HAR
- domains = {
- e['request']['url'].split('/')[2]
- for e in entries
- if e['request']['url'].startswith('http')
- }
- name = path.name
- found_any = False
- if _AIRBNB_DOMAIN in domains:
- found_any |= _audit_airbnb(entries, name)
- if _BOOKING_DOMAIN in domains:
- found_any |= _audit_booking(entries, name)
- if not found_any:
- known = f'{_AIRBNB_DOMAIN}, {_BOOKING_DOMAIN}'
- print(f'\n{path.name}: no recognised API calls (expected domains: {known})')
- def main() -> None:
- paths = sys.argv[1:]
- if not paths:
- print(__doc__)
- sys.exit(1)
- for p in paths:
- audit_har(Path(p))
- print()
- if __name__ == '__main__':
- main()
|