har_audit.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. """Audit HAR files for service_api client drift.
  2. Filters captured browser traffic down to the API calls we care about, compares
  3. identifiers (GQL hashes, version headers) against current client constants, and
  4. lists any operations that weren't captured — with a hint for what action
  5. triggers each one.
  6. Usage:
  7. uv run python scripts/har_audit.py path/to/*.har
  8. One or more HAR files may be passed. Platform is detected from request domains;
  9. a single HAR may contain both platforms.
  10. """
  11. import json
  12. import re
  13. import sys
  14. import urllib.parse
  15. from pathlib import Path
  16. sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
  17. from room_cal.service_api._airbnb import DRIFT_MANIFEST as _AIRBNB_MANIFEST
  18. from room_cal.service_api._booking import DRIFT_MANIFEST as _BOOKING_MANIFEST
  19. # Each client module owns its DRIFT_MANIFEST — imported directly from
  20. # _airbnb.py / _booking.py, so there is no separate mirror to drift out of
  21. # sync. A new service gets drift-checking by adding its own import above.
  22. _AIRBNB_DOMAIN = _AIRBNB_MANIFEST['domain']
  23. _AIRBNB_API_KEY = _AIRBNB_MANIFEST['api_key']
  24. _AIRBNB_CLIENT_VERSION = _AIRBNB_MANIFEST['client_version']
  25. _AIRBNB_OPS: dict[str, dict] = _AIRBNB_MANIFEST['ops']
  26. _BOOKING_DOMAIN = _BOOKING_MANIFEST['domain']
  27. _BOOKING_ENDPOINTS: dict[str, dict] = _BOOKING_MANIFEST['endpoints']
  28. # ── HAR helpers ────────────────────────────────────────────────────────────
  29. def _req_headers(entry: dict) -> dict[str, str]:
  30. return {h['name'].lower(): h['value'] for h in entry['request']['headers']}
  31. def _req_body(entry: dict) -> dict | None:
  32. text = (entry['request'].get('postData') or {}).get('text', '')
  33. try:
  34. return json.loads(text) if text else None
  35. except (json.JSONDecodeError, ValueError):
  36. return None
  37. def _req_variables(entry: dict) -> dict | None:
  38. """The GQL `variables` object for a request, whether sent as a GET query param or in a POST body."""
  39. if (body := _req_body(entry)) is not None:
  40. return body.get('variables')
  41. qs = urllib.parse.urlparse(entry['request']['url']).query
  42. params = urllib.parse.parse_qs(qs)
  43. raw = params.get('variables', [None])[0]
  44. if raw is None:
  45. return None
  46. try:
  47. return json.loads(raw)
  48. except (json.JSONDecodeError, ValueError):
  49. return None
  50. def _short(h: str) -> str:
  51. """Truncate a hash/token for display: first 16 chars + ellipsis."""
  52. return h[:16] + '...' if len(h) > 20 else h
  53. # ── Airbnb ─────────────────────────────────────────────────────────────────
  54. def _collect_airbnb(entries: list[dict]) -> dict[str, list[dict]]:
  55. """Return {operation_name: [entry, ...]} for Airbnb /api/v3 calls."""
  56. found: dict[str, list[dict]] = {}
  57. for e in entries:
  58. url = e['request']['url']
  59. m = re.search(r'/api/v3/([A-Za-z]+)/([0-9a-f]{64})', url)
  60. if m:
  61. op = m.group(1)
  62. found.setdefault(op, []).append(e)
  63. return found
  64. def _audit_airbnb(entries: list[dict], har_name: str) -> bool:
  65. found = _collect_airbnb(entries)
  66. if not found:
  67. return False
  68. print(f'\n{"═" * 70}')
  69. print(f' Airbnb | {har_name}')
  70. print(f'{"═" * 70}')
  71. # Operations
  72. print('\n Operations:\n')
  73. col = max(len(op) for op in _AIRBNB_OPS) + 2
  74. all_ok = True
  75. for op, spec in _AIRBNB_OPS.items():
  76. if op not in found:
  77. print(f' — {op:<{col}} not captured')
  78. print(f' {"":>{col}} → {spec["hint"]}')
  79. continue
  80. entry = found[op][0]
  81. url = entry['request']['url']
  82. m = re.search(r'/api/v3/[A-Za-z]+/([0-9a-f]{64})', url)
  83. har_hash = m.group(1) if m else '?'
  84. expected = spec['hash']
  85. if har_hash == expected:
  86. print(f' ✓ {op:<{col}} hash matches')
  87. else:
  88. all_ok = False
  89. print(f' ✗ {op:<{col}} STALE hash')
  90. print(f' {"client:":<{col+5}} {expected}')
  91. print(f' {"HAR:":<{col+5}} {har_hash} ← update _HASHES[\'{op}\']')
  92. expected_keys = spec.get('var_keys')
  93. if expected_keys is not None:
  94. # Union keys across every captured call of this op, not just the first —
  95. # the real app sends different variable shapes for the same op depending
  96. # on which page/action triggered it (e.g. initial calendar load vs.
  97. # month-by-month paging), so a single entry isn't representative.
  98. har_key_sets = [
  99. set(v.keys()) for e in found[op]
  100. if isinstance(v := _req_variables(e), dict)
  101. ]
  102. if not har_key_sets:
  103. all_ok = False
  104. print(f' ✗ {op:<{col}} variables not found in any HAR entry')
  105. else:
  106. har_keys = set().union(*har_key_sets)
  107. missing = expected_keys - har_keys # client sends, no HAR call does — likely dropped
  108. extra = har_keys - expected_keys # some HAR call has, client doesn't — likely added/renamed
  109. if missing or extra:
  110. all_ok = False
  111. print(f' ✗ {op:<{col}} variable key drift')
  112. if missing:
  113. print(f' {"client sends, HAR lacks:":<{col+5}} {sorted(missing)} ← dropped/renamed; remove or fix in client')
  114. if extra:
  115. print(f' {"HAR has, client lacks:":<{col+5}} {sorted(extra)} ← new/renamed field; add to client')
  116. else:
  117. print(f' ✓ {op:<{col}} variable keys match')
  118. # Global headers (from any /api/v3 call)
  119. sample_entry = next(iter(found.values()))[0]
  120. hdrs = _req_headers(sample_entry)
  121. print('\n Global headers:\n')
  122. hcol = max(len('X-Airbnb-API-Key'), len('X-Client-Version')) + 2
  123. api_key = hdrs.get('x-airbnb-api-key', '')
  124. if api_key == _AIRBNB_API_KEY:
  125. print(f' ✓ {"X-Airbnb-API-Key":<{hcol}} matches')
  126. else:
  127. all_ok = False
  128. print(f' ✗ {"X-Airbnb-API-Key":<{hcol}} MISMATCH')
  129. print(f' client: {_AIRBNB_API_KEY}')
  130. print(f' HAR: {api_key} ← update _API_KEY')
  131. cv = hdrs.get('x-client-version', '')
  132. if cv == _AIRBNB_CLIENT_VERSION:
  133. print(f' ✓ {"X-Client-Version":<{hcol}} matches')
  134. else:
  135. all_ok = False
  136. print(f' ✗ {"X-Client-Version":<{hcol}} stale')
  137. print(f' client: {_AIRBNB_CLIENT_VERSION}')
  138. print(f' HAR: {cv} ← update _CLIENT_VERSION')
  139. missing = [op for op in _AIRBNB_OPS if op not in found]
  140. if not missing:
  141. print('\n All operations captured.')
  142. else:
  143. print(f'\n {len(missing)} operation(s) not captured — to get full coverage:')
  144. printed_hints: set[str] = set()
  145. for op in missing:
  146. hint = _AIRBNB_OPS[op]['hint']
  147. if hint not in printed_hints:
  148. print(f' • {hint}')
  149. printed_hints.add(hint)
  150. return True
  151. # ── Booking.com ────────────────────────────────────────────────────────────
  152. def _collect_booking(entries: list[dict]) -> dict[str, list[dict]]:
  153. """Return {endpoint_key: [entry, ...]} for Booking.com API calls."""
  154. found: dict[str, list[dict]] = {}
  155. for e in entries:
  156. url = e['request']['url']
  157. if _BOOKING_DOMAIN not in url:
  158. continue
  159. body = _req_body(e)
  160. body_op = (body or {}).get('operationName')
  161. for key, spec in _BOOKING_ENDPOINTS.items():
  162. if spec['url_fragment'] not in url:
  163. continue
  164. op_name = spec.get('operation_name')
  165. if op_name and body_op != op_name:
  166. continue
  167. found.setdefault(key, []).append(e)
  168. return found
  169. def _audit_booking(entries: list[dict], har_name: str) -> bool:
  170. found = _collect_booking(entries)
  171. if not found:
  172. return False
  173. print(f'\n{"═" * 70}')
  174. print(f' Booking.com | {har_name}')
  175. print(f'{"═" * 70}')
  176. print('\n Endpoints:\n')
  177. col = max(len(k) for k in _BOOKING_ENDPOINTS) + 2
  178. all_ok = True
  179. for key, spec in _BOOKING_ENDPOINTS.items():
  180. if key not in found:
  181. print(f' — {key:<{col}} not captured')
  182. print(f' {"":>{col}} → {spec["hint"]}')
  183. continue
  184. # Check every captured call of this endpoint, not just the first — the
  185. # same endpoint can show more than one header value within one HAR (e.g.
  186. # a stale value from a frontend bundle reload mid-capture), so a mismatch
  187. # only means something if the client's value never shows up at all.
  188. expected_headers = spec.get('headers', {})
  189. all_hdrs = [_req_headers(e) for e in found[key]]
  190. mismatches: list[tuple[str, str, list[str]]] = []
  191. for hname, hexpected in expected_headers.items():
  192. observed = [h.get(hname, '') for h in all_hdrs]
  193. if hexpected not in observed:
  194. mismatches.append((hname, hexpected, sorted(set(observed))))
  195. if not mismatches:
  196. print(f' ✓ {key:<{col}} found')
  197. else:
  198. all_ok = False
  199. print(f' ✗ {key:<{col}} found with stale headers')
  200. src = spec.get('source', 'the client headers')
  201. for hname, hexpected, observed in mismatches:
  202. print(f' {hname}')
  203. print(f' client: {hexpected}')
  204. print(f' HAR saw: {observed} ← update {src}')
  205. missing = [k for k in _BOOKING_ENDPOINTS if k not in found]
  206. if not missing:
  207. print('\n All endpoints captured.')
  208. else:
  209. print(f'\n {len(missing)} endpoint(s) not captured — to get full coverage:')
  210. printed_hints: set[str] = set()
  211. for key in missing:
  212. hint = _BOOKING_ENDPOINTS[key]['hint']
  213. if hint not in printed_hints:
  214. print(f' • {hint}')
  215. printed_hints.add(hint)
  216. return True
  217. # ── Entry point ────────────────────────────────────────────────────────────
  218. def audit_har(path: Path) -> None:
  219. try:
  220. har = json.loads(path.read_text(encoding='utf-8'))
  221. except Exception as e:
  222. print(f'Error reading {path}: {e}', file=sys.stderr)
  223. return
  224. entries = har.get('log', {}).get('entries', [])
  225. if not entries:
  226. print(f'No entries in {path.name}', file=sys.stderr)
  227. return
  228. # Detect which platforms appear in this HAR
  229. domains = {
  230. e['request']['url'].split('/')[2]
  231. for e in entries
  232. if e['request']['url'].startswith('http')
  233. }
  234. name = path.name
  235. found_any = False
  236. if _AIRBNB_DOMAIN in domains:
  237. found_any |= _audit_airbnb(entries, name)
  238. if _BOOKING_DOMAIN in domains:
  239. found_any |= _audit_booking(entries, name)
  240. if not found_any:
  241. known = f'{_AIRBNB_DOMAIN}, {_BOOKING_DOMAIN}'
  242. print(f'\n{path.name}: no recognised API calls (expected domains: {known})')
  243. def main() -> None:
  244. paths = sys.argv[1:]
  245. if not paths:
  246. print(__doc__)
  247. sys.exit(1)
  248. for p in paths:
  249. audit_har(Path(p))
  250. print()
  251. if __name__ == '__main__':
  252. main()