瀏覽代碼

har_audit: diff GQL variable keys, not just the persisted-query hash

Updating a hash alone doesn't catch a variable schema change on the
same persisted query, which is exactly how last session's drift fix
missed includeCustomSettings/getWishlistData/skipOldMessagePreviewFields.

Each Airbnb op in DRIFT_MANIFEST now declares var_keys: the top-level
variable keys its client method currently sends. har_audit.py unions
the variables keys across every captured call of that op in the HAR
(the same op can show different shapes depending on which page/action
triggered it) and reports any added/removed/renamed key as drift,
alongside the existing hash check.

var_keys is hand-maintained, same as the hash constants it sits next
to — whoever updates a client method's variables is expected to update
this set in the same commit.
ZiB 5 天之前
父節點
當前提交
823194fe1a
共有 2 個文件被更改,包括 70 次插入 和 0 次删除
  1. 28 0
      room_cal/service_api/_airbnb.py
  2. 42 0
      scripts/har_audit.py

+ 28 - 0
room_cal/service_api/_airbnb.py

@@ -639,38 +639,66 @@ DRIFT_MANIFEST = {
   'domain': 'www.airbnb.com',
   'api_key': _API_KEY,
   'client_version': _CLIENT_VERSION,
+  # 'var_keys': the top-level keys of the `variables` object this op's client
+  # method currently sends. har_audit.py diffs this against the `variables`
+  # actually captured in the HAR so a schema change (renamed/added/removed
+  # field) is flagged even when the persisted-query hash itself still
+  # matches. Whoever updates a client method's variables must update this
+  # set in the same commit — there is no way to derive it automatically
+  # without parsing the method body, which we deliberately don't do.
   'ops': {
     'getDLSHostCalendar': {
       'hash': _HASHES['getDLSHostCalendar'],
       'hint': 'load the multicalendar page',
+      'var_keys': {
+        'listingId', 'startDate', 'endDate', 'hostCalendarViewType', 'lensTypes',
+        'timeZone', 'localizationContext', 'hostCalendarSectionTypes',
+        'includeCustomSettings', 'readConsistency',
+      },
     },
     'EditPanelPricingSettingsMutation': {
       'hash': _HASHES['EditPanelPricingSettingsMutation'],
       'hint': 'change a price on any date in the multicalendar',
+      'var_keys': {'input'},
     },
     'EditPanelAvailabilityMutation': {
       'hash': _HASHES['EditPanelAvailabilityMutation'],
       'hint': 'block or unblock a date in the multicalendar',
+      'var_keys': {'input', 'blockNotesEnabled'},
     },
     'StayHostingDetailsQuery': {
       'hash': _HASHES['StayHostingDetailsQuery'],
       'hint': 'open any reservation detail page',
+      'var_keys': {'viewerTimeZoneOffset', 'requestSource', 'confirmationCode'},
     },
     'getNightlyPriceTips': {
       'hash': _HASHES['getNightlyPriceTips'],
       'hint': 'load the multicalendar page (fires automatically)',
+      'var_keys': {'listingId', 'dateRanges'},
     },
     'HostCalendarListingEventsQuery': {
       'hash': _HASHES['HostCalendarListingEventsQuery'],
       'hint': 'load the multicalendar page (fires automatically)',
+      'var_keys': {'listingId'},
     },
     'ViaductGetThreadAndDataQuery': {
       'hash': _HASHES['ViaductGetThreadAndDataQuery'],
       'hint': 'open any guest message thread',
+      'var_keys': {
+        'globalThreadId', 'numRequestedMessages', 'getThreadState', 'getParticipants',
+        'getLastReads', 'getInboxFields', 'getInboxOnlyFields', 'getMessageFields',
+        'getThreadOnlyFields', 'forceUgcTranslation', 'isNovaLite', 'mockThreadIdentifier',
+        'mockMessageTestIdentifier', 'mockListFooterSlot', 'forceReturnAllReadReceipts',
+        'originType', 'getWishlistData',
+      },
     },
     'CreateBulkMessagesMutation': {
       'hash': _HASHES['CreateBulkMessagesMutation'],
       'hint': 'send a message in any thread',
+      'var_keys': {
+        'messageThreadId', 'messages', 'actAs', 'businessJustification',
+        'originType', 'senderPlatform',
+      },
     },
   },
 }

+ 42 - 0
scripts/har_audit.py

@@ -15,6 +15,7 @@ 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))
@@ -47,6 +48,20 @@ def _req_body(entry: dict) -> dict | 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
@@ -100,6 +115,33 @@ def _audit_airbnb(entries: list[dict], har_name: str) -> bool:
       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)