har_audit.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  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. from pathlib import Path
  15. sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
  16. from room_cal.service_api._airbnb import DRIFT_MANIFEST as _AIRBNB_MANIFEST
  17. from room_cal.service_api._booking import DRIFT_MANIFEST as _BOOKING_MANIFEST
  18. # Each client module owns its DRIFT_MANIFEST — imported directly from
  19. # _airbnb.py / _booking.py, so there is no separate mirror to drift out of
  20. # sync. A new service gets drift-checking by adding its own import above.
  21. _AIRBNB_DOMAIN = _AIRBNB_MANIFEST['domain']
  22. _AIRBNB_API_KEY = _AIRBNB_MANIFEST['api_key']
  23. _AIRBNB_CLIENT_VERSION = _AIRBNB_MANIFEST['client_version']
  24. _AIRBNB_OPS: dict[str, dict] = _AIRBNB_MANIFEST['ops']
  25. _BOOKING_DOMAIN = _BOOKING_MANIFEST['domain']
  26. _BOOKING_ENDPOINTS: dict[str, dict] = _BOOKING_MANIFEST['endpoints']
  27. # ── HAR helpers ────────────────────────────────────────────────────────────
  28. def _req_headers(entry: dict) -> dict[str, str]:
  29. return {h['name'].lower(): h['value'] for h in entry['request']['headers']}
  30. def _req_body(entry: dict) -> dict | None:
  31. text = (entry['request'].get('postData') or {}).get('text', '')
  32. try:
  33. return json.loads(text) if text else None
  34. except (json.JSONDecodeError, ValueError):
  35. return None
  36. def _short(h: str) -> str:
  37. """Truncate a hash/token for display: first 16 chars + ellipsis."""
  38. return h[:16] + '...' if len(h) > 20 else h
  39. # ── Airbnb ─────────────────────────────────────────────────────────────────
  40. def _collect_airbnb(entries: list[dict]) -> dict[str, list[dict]]:
  41. """Return {operation_name: [entry, ...]} for Airbnb /api/v3 calls."""
  42. found: dict[str, list[dict]] = {}
  43. for e in entries:
  44. url = e['request']['url']
  45. m = re.search(r'/api/v3/([A-Za-z]+)/([0-9a-f]{64})', url)
  46. if m:
  47. op = m.group(1)
  48. found.setdefault(op, []).append(e)
  49. return found
  50. def _audit_airbnb(entries: list[dict], har_name: str) -> bool:
  51. found = _collect_airbnb(entries)
  52. if not found:
  53. return False
  54. print(f'\n{"═" * 70}')
  55. print(f' Airbnb | {har_name}')
  56. print(f'{"═" * 70}')
  57. # Operations
  58. print('\n Operations:\n')
  59. col = max(len(op) for op in _AIRBNB_OPS) + 2
  60. all_ok = True
  61. for op, spec in _AIRBNB_OPS.items():
  62. if op not in found:
  63. print(f' — {op:<{col}} not captured')
  64. print(f' {"":>{col}} → {spec["hint"]}')
  65. continue
  66. entry = found[op][0]
  67. url = entry['request']['url']
  68. m = re.search(r'/api/v3/[A-Za-z]+/([0-9a-f]{64})', url)
  69. har_hash = m.group(1) if m else '?'
  70. expected = spec['hash']
  71. if har_hash == expected:
  72. print(f' ✓ {op:<{col}} hash matches')
  73. else:
  74. all_ok = False
  75. print(f' ✗ {op:<{col}} STALE hash')
  76. print(f' {"client:":<{col+5}} {expected}')
  77. print(f' {"HAR:":<{col+5}} {har_hash} ← update _HASHES[\'{op}\']')
  78. # Global headers (from any /api/v3 call)
  79. sample_entry = next(iter(found.values()))[0]
  80. hdrs = _req_headers(sample_entry)
  81. print('\n Global headers:\n')
  82. hcol = max(len('X-Airbnb-API-Key'), len('X-Client-Version')) + 2
  83. api_key = hdrs.get('x-airbnb-api-key', '')
  84. if api_key == _AIRBNB_API_KEY:
  85. print(f' ✓ {"X-Airbnb-API-Key":<{hcol}} matches')
  86. else:
  87. all_ok = False
  88. print(f' ✗ {"X-Airbnb-API-Key":<{hcol}} MISMATCH')
  89. print(f' client: {_AIRBNB_API_KEY}')
  90. print(f' HAR: {api_key} ← update _API_KEY')
  91. cv = hdrs.get('x-client-version', '')
  92. if cv == _AIRBNB_CLIENT_VERSION:
  93. print(f' ✓ {"X-Client-Version":<{hcol}} matches')
  94. else:
  95. all_ok = False
  96. print(f' ✗ {"X-Client-Version":<{hcol}} stale')
  97. print(f' client: {_AIRBNB_CLIENT_VERSION}')
  98. print(f' HAR: {cv} ← update _CLIENT_VERSION')
  99. missing = [op for op in _AIRBNB_OPS if op not in found]
  100. if not missing:
  101. print('\n All operations captured.')
  102. else:
  103. print(f'\n {len(missing)} operation(s) not captured — to get full coverage:')
  104. printed_hints: set[str] = set()
  105. for op in missing:
  106. hint = _AIRBNB_OPS[op]['hint']
  107. if hint not in printed_hints:
  108. print(f' • {hint}')
  109. printed_hints.add(hint)
  110. return True
  111. # ── Booking.com ────────────────────────────────────────────────────────────
  112. def _collect_booking(entries: list[dict]) -> dict[str, list[dict]]:
  113. """Return {endpoint_key: [entry, ...]} for Booking.com API calls."""
  114. found: dict[str, list[dict]] = {}
  115. for e in entries:
  116. url = e['request']['url']
  117. if _BOOKING_DOMAIN not in url:
  118. continue
  119. body = _req_body(e)
  120. body_op = (body or {}).get('operationName')
  121. for key, spec in _BOOKING_ENDPOINTS.items():
  122. if spec['url_fragment'] not in url:
  123. continue
  124. op_name = spec.get('operation_name')
  125. if op_name and body_op != op_name:
  126. continue
  127. found.setdefault(key, []).append(e)
  128. return found
  129. def _audit_booking(entries: list[dict], har_name: str) -> bool:
  130. found = _collect_booking(entries)
  131. if not found:
  132. return False
  133. print(f'\n{"═" * 70}')
  134. print(f' Booking.com | {har_name}')
  135. print(f'{"═" * 70}')
  136. print('\n Endpoints:\n')
  137. col = max(len(k) for k in _BOOKING_ENDPOINTS) + 2
  138. all_ok = True
  139. for key, spec in _BOOKING_ENDPOINTS.items():
  140. if key not in found:
  141. print(f' — {key:<{col}} not captured')
  142. print(f' {"":>{col}} → {spec["hint"]}')
  143. continue
  144. entry = found[key][0]
  145. hdrs = _req_headers(entry)
  146. expected_headers = spec.get('headers', {})
  147. mismatches: list[tuple[str, str, str]] = []
  148. for hname, hexpected in expected_headers.items():
  149. hactual = hdrs.get(hname, '')
  150. if hactual != hexpected:
  151. mismatches.append((hname, hexpected, hactual))
  152. if not mismatches:
  153. print(f' ✓ {key:<{col}} found')
  154. else:
  155. all_ok = False
  156. print(f' ✗ {key:<{col}} found with stale headers')
  157. src = spec.get('source', 'the client headers')
  158. for hname, hexpected, hactual in mismatches:
  159. print(f' {hname}')
  160. print(f' client: {hexpected}')
  161. print(f' HAR: {hactual} ← update {src}')
  162. missing = [k for k in _BOOKING_ENDPOINTS if k not in found]
  163. if not missing:
  164. print('\n All endpoints captured.')
  165. else:
  166. print(f'\n {len(missing)} endpoint(s) not captured — to get full coverage:')
  167. printed_hints: set[str] = set()
  168. for key in missing:
  169. hint = _BOOKING_ENDPOINTS[key]['hint']
  170. if hint not in printed_hints:
  171. print(f' • {hint}')
  172. printed_hints.add(hint)
  173. return True
  174. # ── Entry point ────────────────────────────────────────────────────────────
  175. def audit_har(path: Path) -> None:
  176. try:
  177. har = json.loads(path.read_text(encoding='utf-8'))
  178. except Exception as e:
  179. print(f'Error reading {path}: {e}', file=sys.stderr)
  180. return
  181. entries = har.get('log', {}).get('entries', [])
  182. if not entries:
  183. print(f'No entries in {path.name}', file=sys.stderr)
  184. return
  185. # Detect which platforms appear in this HAR
  186. domains = {
  187. e['request']['url'].split('/')[2]
  188. for e in entries
  189. if e['request']['url'].startswith('http')
  190. }
  191. name = path.name
  192. found_any = False
  193. if _AIRBNB_DOMAIN in domains:
  194. found_any |= _audit_airbnb(entries, name)
  195. if _BOOKING_DOMAIN in domains:
  196. found_any |= _audit_booking(entries, name)
  197. if not found_any:
  198. known = f'{_AIRBNB_DOMAIN}, {_BOOKING_DOMAIN}'
  199. print(f'\n{path.name}: no recognised API calls (expected domains: {known})')
  200. def main() -> None:
  201. paths = sys.argv[1:]
  202. if not paths:
  203. print(__doc__)
  204. sys.exit(1)
  205. for p in paths:
  206. audit_har(Path(p))
  207. print()
  208. if __name__ == '__main__':
  209. main()