소스 검색

service_api: refresh-dispatch cleanup + har_audit manifest inversion

Python: run_refresh_script/make_refresh_cli in _scripts.py collapse
each browser client's refresh_credentials into a 2-line staticmethod
(Airbnb passes extra_env, Booking passes its browser lock) and the
three uv-run CLI entry points into one-line factory calls.

TypeScript: shared/refresh-entry.ts's runRefreshEntry/refreshAndOutput
give single-service scripts and refresh-all.ts the same core, so they
cannot diverge; each per-service entry file's main() collapses to one
line.

har_audit.py now imports DRIFT_MANIFEST from _airbnb.py/_booking.py
instead of maintaining a hand-copied mirror of hashes and header
values — the "commit both files together" drift hazard is gone.
ZiB 3 달 전
부모
커밋
69d1339400

+ 49 - 14
room_cal/service_api/_airbnb.py

@@ -12,7 +12,7 @@ from dataclasses import dataclass
 
 import logging
 
-from ._scripts import _assert_not_async, _npm_env, _ts_dir, _ensure_installed
+from ._scripts import run_refresh_script
 from room_cal._utils import get_config
 from ._base import ServiceClientBase, _AUTH_HINT
 from ._month_cache import MonthCache, TTLCache
@@ -170,19 +170,8 @@ class _AirbnbClient(ServiceClientBase):
 
   @staticmethod
   def refresh_credentials() -> None:
-    _assert_not_async('refresh_credentials')
-    ts_dir = _ts_dir()
-    _ensure_installed(ts_dir)
-    import subprocess
-    _log.info('airbnb: starting credential refresh')
-    env = _npm_env()
-    env['HEADLESS'] = '1'
-    try:
-      subprocess.run(['npm', 'run', 'refresh-airbnb'], cwd=ts_dir, env=env, check=True)
-    except subprocess.CalledProcessError as e:
-      _log.error('airbnb: credential refresh failed (exit %d)', e.returncode)
-      raise ServiceAuthError(f'Airbnb session expired — re-run without HEADLESS to re-authenticate (exit {e.returncode})') from None
-    _log.info('airbnb: credential refresh complete')
+    run_refresh_script('refresh-airbnb', service_name='Airbnb',
+                       auth_error_cls=AirbnbAuthError, extra_env={'HEADLESS': '1'})
 
   def _headers(self, referer: str, extra: dict | None = None) -> dict[str, str]:
     h = {
@@ -632,3 +621,49 @@ class _AirbnbClient(ServiceClientBase):
       )
       for e in events
     ]
+
+
+# ── har_audit.py drift manifest ─────────────────────────────────────────────
+# Auditable identifiers for scripts/har_audit.py. References _API_KEY,
+# _CLIENT_VERSION, _HASHES above rather than duplicating literals — this is
+# the only copy.
+
+DRIFT_MANIFEST = {
+  'domain': 'www.airbnb.com',
+  'api_key': _API_KEY,
+  'client_version': _CLIENT_VERSION,
+  'ops': {
+    'getDLSHostCalendar': {
+      'hash': _HASHES['getDLSHostCalendar'],
+      'hint': 'load the multicalendar page',
+    },
+    'EditPanelPricingSettingsMutation': {
+      'hash': _HASHES['EditPanelPricingSettingsMutation'],
+      'hint': 'change a price on any date in the multicalendar',
+    },
+    'EditPanelAvailabilityMutation': {
+      'hash': _HASHES['EditPanelAvailabilityMutation'],
+      'hint': 'block or unblock a date in the multicalendar',
+    },
+    'StayHostingDetailsQuery': {
+      'hash': _HASHES['StayHostingDetailsQuery'],
+      'hint': 'open any reservation detail page',
+    },
+    'getNightlyPriceTips': {
+      'hash': _HASHES['getNightlyPriceTips'],
+      'hint': 'load the multicalendar page (fires automatically)',
+    },
+    'HostCalendarListingEventsQuery': {
+      'hash': _HASHES['HostCalendarListingEventsQuery'],
+      'hint': 'load the multicalendar page (fires automatically)',
+    },
+    'ViaductGetThreadAndDataQuery': {
+      'hash': _HASHES['ViaductGetThreadAndDataQuery'],
+      'hint': 'open any guest message thread',
+    },
+    'CreateBulkMessagesMutation': {
+      'hash': _HASHES['CreateBulkMessagesMutation'],
+      'hint': 'send a message in any thread',
+    },
+  },
+}

+ 64 - 17
room_cal/service_api/_booking.py

@@ -9,7 +9,7 @@ from urllib.parse import parse_qs, urlparse
 
 import httpx
 
-from ._scripts import _assert_not_async, _booking_lock, _npm_env, _ts_dir, _ensure_installed
+from ._scripts import _booking_lock, run_refresh_script
 from room_cal._utils import get_config
 from ._base import ServiceClientBase, _AUTH_HINT
 from ._month_cache import MonthCache
@@ -28,6 +28,12 @@ def _is_past(d: str) -> bool:
 _BASE = 'https://admin.booking.com'
 _SENDER_LABELS: dict[str, str] = {'GUEST': 'Guest', 'PARTNER': 'Host'}
 
+# Sync with scripts/har_audit.py via DRIFT_MANIFEST below — these are the only
+# copies; the manifest references them rather than duplicating the literals.
+_GQL_CLIENT_VERSION = 'EQSXMHMY'
+_GQL_TIMEOUT_MS = '15000'
+_MSG_CLIENT_VERSION = 'ABJaNSUc'
+
 _UPDATE_MUTATION = """mutation updateRoomDateMutation($input: PartnerAvailabilityUpdateInput) {
   partnerAvailabilityMutations {
     partnerAvailabilityUpdate(input: $input) {
@@ -255,18 +261,8 @@ class _BookingClient(ServiceClientBase):
 
   @staticmethod
   def refresh_credentials() -> None:
-    _assert_not_async('refresh_credentials')
-    import subprocess
-    ts_dir = _ts_dir()
-    _ensure_installed(ts_dir)
-    _log.info('booking: starting credential refresh')
-    with _booking_lock:
-      try:
-        subprocess.run(['npm', 'run', 'refresh-booking'], cwd=ts_dir, env=_npm_env(), check=True)
-      except subprocess.CalledProcessError as e:
-        _log.error('booking: credential refresh failed (exit %d)', e.returncode)
-        raise ServiceAuthError(f'Booking.com credential refresh failed (exit {e.returncode})') from None
-    _log.info('booking: credential refresh complete')
+    run_refresh_script('refresh-booking', service_name='Booking.com',
+                       auth_error_cls=BookingAuthError, lock=_booking_lock)
 
   def _actor(self) -> dict:
     return {'actorId': str(self._hotel_id), 'actorType': 'HOTEL'}
@@ -329,9 +325,9 @@ class _BookingClient(ServiceClientBase):
       'X-Booking-Pageview-ID': _page_view_id(),
       'x-booking-csrf-token': 'undefined',
       'apollographql-client-name': 'b-partner-availability-extranet-mfe',
-      'apollographql-client-version': 'EQSXMHMY',
-      'x-envoy-expected-rq-timeout-ms': '15000',
-      'x-booking-timeout-budget-ms': '15000',
+      'apollographql-client-version': _GQL_CLIENT_VERSION,
+      'x-envoy-expected-rq-timeout-ms': _GQL_TIMEOUT_MS,
+      'x-booking-timeout-budget-ms': _GQL_TIMEOUT_MS,
       'X-Booking-Topic': 'capla_browser_b-partner-availability-extranet-mfe',
       'Cookie': self._cookies,
     }
@@ -353,7 +349,7 @@ class _BookingClient(ServiceClientBase):
       'x-booking-csrf-token': 'undefined',
       'X-Booking-Topic': 'capla_browser_b-partner-messaging-extranet-mfe',
       'apollographql-client-name': 'b-partner-messaging-extranet-mfe',
-      'apollographql-client-version': 'ABJaNSUc',
+      'apollographql-client-version': _MSG_CLIENT_VERSION,
       'Cookie': self._cookies,
     }
 
@@ -708,3 +704,54 @@ class _BookingClient(ServiceClientBase):
                    issue['date'], issue['actual_rts'], issue['expected_rts'])
       await self.set_rooms_to_sell(issue['date'], issue['expected_rts'])
     return issues
+
+
+# ── har_audit.py drift manifest ─────────────────────────────────────────────
+# Auditable identifiers for scripts/har_audit.py. References the constants
+# above rather than duplicating literals — this is the only copy.
+
+DRIFT_MANIFEST = {
+  'domain': 'admin.booking.com',
+  'endpoints': {
+    'inventory/fetch': {
+      'url_fragment': 'fresa/extranet/inventory/fetch',
+      'hint': 'load the monthly calendar',
+    },
+    'inventory/update': {
+      'url_fragment': 'fresa/extranet/inventory/update',
+      'hint': 'change a price on any date',
+    },
+    'get_reservations': {
+      'url_fragment': 'fresa/extranet/calendar/get_reservations',
+      'hint': 'load the reservations list',
+    },
+    'updateRoomDateMutation': {
+      'url_fragment': 'dml/graphql.json',
+      'operation_name': 'updateRoomDateMutation',
+      'hint': 'block or unblock a date on the calendar',
+      'headers': {
+        'apollographql-client-version': _GQL_CLIENT_VERSION,
+        'x-envoy-expected-rq-timeout-ms': _GQL_TIMEOUT_MS,
+      },
+      'source': '_gql_headers()',
+    },
+    'conversationMessages': {
+      'url_fragment': 'dml/graphql.json',
+      'operation_name': 'conversationMessages',
+      'hint': 'open any guest message thread',
+      'headers': {
+        'apollographql-client-version': _MSG_CLIENT_VERSION,
+      },
+      'source': '_msg_headers()',
+    },
+    'postFreeTextMessage': {
+      'url_fragment': 'dml/graphql.json',
+      'operation_name': 'postFreeTextMessage',
+      'hint': 'send a message to a guest',
+      'headers': {
+        'apollographql-client-version': _MSG_CLIENT_VERSION,
+      },
+      'source': '_msg_headers()',
+    },
+  },
+}

+ 55 - 19
room_cal/service_api/_scripts.py

@@ -4,9 +4,11 @@ import os
 import subprocess
 import sys
 import threading
+from collections.abc import Callable
 from pathlib import Path
 
 from room_cal._utils import get_config, internal_token
+from .types import ServiceAuthError
 
 _log = logging.getLogger(__name__)
 
@@ -56,24 +58,58 @@ def _assert_not_async(fn_name: str) -> None:
 _booking_lock = threading.Lock()
 
 
-def refresh_airbnb_cli() -> None:
-  """CLI entry point for `uv run refresh-airbnb`."""
+def run_refresh_script(
+  npm_script: str,
+  *,
+  service_name: str,
+  auth_error_cls: type[ServiceAuthError] = ServiceAuthError,
+  lock: threading.Lock | None = None,
+  extra_env: dict[str, str] | None = None,
+) -> None:
+  """Run a credential-refresh npm script, standardizing bookkeeping across all clients.
+
+  Wraps `_assert_not_async` + `_ensure_installed` + subprocess + the standard
+  log lines and ServiceAuthError-on-failure. If `lock` is given (e.g. Booking's
+  browser lock) it is held for the duration of the subprocess call.
+  """
+  _assert_not_async('refresh_credentials')
   ts_dir = _ts_dir()
   _ensure_installed(ts_dir)
-  result = subprocess.run(['npm', 'run', 'refresh-airbnb', '--'] + sys.argv[1:], cwd=ts_dir, env=_npm_env())
-  sys.exit(result.returncode)
-
-
-def refresh_booking_cli() -> None:
-  """CLI entry point for `uv run refresh-booking`."""
-  ts_dir = _ts_dir()
-  _ensure_installed(ts_dir)
-  result = subprocess.run(['npm', 'run', 'refresh-booking', '--'] + sys.argv[1:], cwd=ts_dir, env=_npm_env())
-  sys.exit(result.returncode)
-
-
-def refresh_cookies() -> None:
-  ts_dir = _ts_dir()
-  _ensure_installed(ts_dir)
-  result = subprocess.run(['npm', 'run', 'refresh-all', '--'] + sys.argv[1:], cwd=ts_dir, env=_npm_env())
-  sys.exit(result.returncode)
+  _log.info('%s: starting credential refresh', service_name)
+  env = _npm_env()
+  if extra_env:
+    env.update(extra_env)
+
+  def _run() -> None:
+    try:
+      subprocess.run(['npm', 'run', npm_script], cwd=ts_dir, env=env, check=True)
+    except subprocess.CalledProcessError as e:
+      _log.error('%s: credential refresh failed (exit %d)', service_name, e.returncode)
+      raise auth_error_cls(f'{service_name} credential refresh failed (exit {e.returncode})') from None
+
+  if lock is not None:
+    with lock:
+      _run()
+  else:
+    _run()
+  _log.info('%s: credential refresh complete', service_name)
+
+
+def make_refresh_cli(npm_script: str) -> Callable[[], None]:
+  """Build a `uv run refresh-<x>` entry point that runs a single npm refresh script."""
+  def cli() -> None:
+    ts_dir = _ts_dir()
+    _ensure_installed(ts_dir)
+    result = subprocess.run(['npm', 'run', npm_script, '--'] + sys.argv[1:], cwd=ts_dir, env=_npm_env())
+    sys.exit(result.returncode)
+  return cli
+
+
+refresh_airbnb_cli = make_refresh_cli('refresh-airbnb')
+"""CLI entry point for `uv run refresh-airbnb`."""
+
+refresh_booking_cli = make_refresh_cli('refresh-booking')
+"""CLI entry point for `uv run refresh-booking`."""
+
+refresh_cookies = make_refresh_cli('refresh-all')
+"""CLI entry point for `uv run refresh-creds`."""

+ 3 - 10
room_cal/service_api/typescript/airbnb/refresh-cookies.ts

@@ -2,9 +2,9 @@ import * as fs from 'fs';
 import * as path from 'path';
 import * as os from 'os';
 import type { Page } from 'playwright';
-import { filterCookies, parseOutputMode, parseUserDriven } from '../shared/cookie-utils';
-import { handleOutput } from '../shared/api-client';
+import { filterCookies } from '../shared/cookie-utils';
 import { openPersistentContext, profileDir } from '../shared/browser';
+import { runRefreshEntry } from '../shared/refresh-entry';
 import type { CredentialsPayload } from '../shared/types';
 
 const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
@@ -185,13 +185,6 @@ export async function refresh(mode: string, userDriven = false): Promise<Credent
   return { AIRBNB_COOKIES: cookieString };
 }
 
-async function main(): Promise<void> {
-  const mode = parseOutputMode(process.argv);
-  const userDriven = parseUserDriven(process.argv);
-  const credentials = await refresh(mode, userDriven);
-  await handleOutput('airbnb', credentials, mode, ENV_PATH);
-}
-
 if (require.main === module) {
-  main().catch(err => { console.error(err.message); process.exit(1); });
+  runRefreshEntry('airbnb', refresh).catch(err => { console.error(err.message); process.exit(1); });
 }

+ 4 - 10
room_cal/service_api/typescript/booking/refresh-cookies.ts

@@ -2,9 +2,10 @@ import * as fs from 'fs';
 import * as path from 'path';
 import * as os from 'os';
 import type { BrowserContext, Page } from 'playwright';
-import { filterCookies, parseOutputMode, parseUserDriven } from '../shared/cookie-utils';
-import { handleOutput, fetchSmsCode, clearSmsCode } from '../shared/api-client';
+import { filterCookies } from '../shared/cookie-utils';
+import { fetchSmsCode, clearSmsCode } from '../shared/api-client';
 import { openPersistentContext, profileDir } from '../shared/browser';
+import { runRefreshEntry } from '../shared/refresh-entry';
 import type { CredentialsPayload } from '../shared/types';
 
 const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
@@ -373,13 +374,6 @@ export async function refresh(mode: string, userDriven = false): Promise<Credent
   return credentials;
 }
 
-async function main(): Promise<void> {
-  const mode = parseOutputMode(process.argv);
-  const userDriven = parseUserDriven(process.argv);
-  const credentials = await refresh(mode, userDriven);
-  await handleOutput('booking', credentials, mode, ENV_PATH);
-}
-
 if (require.main === module) {
-  main().catch(err => { console.error(err.message); process.exit(1); });
+  runRefreshEntry('booking', refresh).catch(err => { console.error(err.message); process.exit(1); });
 }

+ 3 - 16
room_cal/service_api/typescript/scripts/refresh-all.ts

@@ -1,28 +1,15 @@
 import { refresh as refreshAirbnb } from '../airbnb/refresh-cookies';
 import { refresh as refreshBooking } from '../booking/refresh-cookies';
-import { handleOutput } from '../shared/api-client';
+import { refreshAndOutput } from '../shared/refresh-entry';
 import { parseOutputMode, parseUserDriven } from '../shared/cookie-utils';
-import type { CredentialsPayload } from '../shared/types';
-import * as path from 'path';
-import * as os from 'os';
 
-const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
 const mode = parseOutputMode(process.argv);
 const userDriven = parseUserDriven(process.argv);
 
-async function refreshService(
-  name: string,
-  refreshFn: () => Promise<CredentialsPayload>,
-  envPath: string,
-): Promise<void> {
-  const creds = await refreshFn();
-  await handleOutput(name, creds, mode, envPath);
-}
-
 async function main(): Promise<void> {
   const results = await Promise.allSettled([
-    refreshService('airbnb', () => refreshAirbnb(mode, userDriven), path.join(DATA_HOME, 'airbnb.env')),
-    refreshService('booking', () => refreshBooking(mode, userDriven), path.join(DATA_HOME, 'booking.env')),
+    refreshAndOutput('airbnb', refreshAirbnb, mode, userDriven),
+    refreshAndOutput('booking', refreshBooking, mode, userDriven),
   ]);
   const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
   if (mode !== 'stdout') {

+ 34 - 0
room_cal/service_api/typescript/shared/refresh-entry.ts

@@ -0,0 +1,34 @@
+import * as path from 'path';
+import * as os from 'os';
+import { handleOutput } from './api-client';
+import { parseOutputMode, parseUserDriven } from './cookie-utils';
+import type { CredentialsPayload, OutputMode } from './types';
+
+export function dataHome(): string {
+  return process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+}
+
+export function envPathFor(service: string): string {
+  return path.join(dataHome(), `${service}.env`);
+}
+
+export type RefreshFn = (mode: OutputMode, userDriven: boolean) => Promise<CredentialsPayload>;
+
+/** Runs one refresh and writes credentials via handleOutput. Shared core for both
+ * single-service entry points and refresh-all, so the two paths cannot diverge. */
+export async function refreshAndOutput(
+  service: string,
+  refreshFn: RefreshFn,
+  mode: OutputMode,
+  userDriven: boolean,
+): Promise<void> {
+  const credentials = await refreshFn(mode, userDriven);
+  await handleOutput(service, credentials, mode, envPathFor(service));
+}
+
+/** CLI entry point for a single-service refresh script (`uv run refresh-<service>`). */
+export async function runRefreshEntry(service: string, refreshFn: RefreshFn): Promise<void> {
+  const mode = parseOutputMode(process.argv);
+  const userDriven = parseUserDriven(process.argv);
+  await refreshAndOutput(service, refreshFn, mode, userDriven);
+}

+ 17 - 87
scripts/har_audit.py

@@ -17,92 +17,22 @@ import re
 import sys
 from pathlib import Path
 
-# ── Airbnb manifest ────────────────────────────────────────────────────────
-# Sync with _airbnb.py: _API_KEY, _CLIENT_VERSION, _HASHES
-
-_AIRBNB_DOMAIN = 'www.airbnb.com'
-_AIRBNB_API_KEY = 'd306zoyjsyarp7ifhu67rjxn52tv0t20'
-_AIRBNB_CLIENT_VERSION = '4e93a4878415297ab95d22665c666b6602dcfcbb'
-
-_AIRBNB_OPS: dict[str, dict] = {
-  'getDLSHostCalendar': {
-    'hash': '048e22675ffa53121628d2d07b519b9b71f5dab342a1ceb75aa72d8814a00ea7',
-    'hint': 'load the multicalendar page',
-  },
-  'EditPanelPricingSettingsMutation': {
-    'hash': 'e27fed4f08dd4ab4c0267c5d6c0a7e4920730deadf48f92feba9b0d08befafa4',
-    'hint': 'change a price on any date in the multicalendar',
-  },
-  'EditPanelAvailabilityMutation': {
-    'hash': '2e056e7f98f8dc410a294c649adf719452980eb4d8ea95540fb1297af2637f67',
-    'hint': 'block or unblock a date in the multicalendar',
-  },
-  'StayHostingDetailsQuery': {
-    'hash': '6da83b8bc9ae1b434f13938a55435118347b0e8ceb838e04c1feb84ecb587e6d',
-    'hint': 'open any reservation detail page',
-  },
-  'getNightlyPriceTips': {
-    'hash': '07538bf1a5afc8171f1e7443f0007d7f020142fc7ab60df065e7e1edd1a0b29f',
-    'hint': 'load the multicalendar page (fires automatically)',
-  },
-  'HostCalendarListingEventsQuery': {
-    'hash': '8bfe5f5572a25fdaff5b5790604e1de3aec5f37731fc505684022d590d06e0a8',
-    'hint': 'load the multicalendar page (fires automatically)',
-  },
-  'ViaductGetThreadAndDataQuery': {
-    'hash': 'dd7478b376ad0a9709c7201b7e43cf402df011ec9d2fe4c1dff2ac418bcd9fb3',
-    'hint': 'open any guest message thread',
-  },
-  'CreateBulkMessagesMutation': {
-    'hash': '94ac2c4bd07edace539dbf2b9665d9030b6dee479db345ba8a8bbc234b3bbfa3',
-    'hint': 'send a message in any thread',
-  },
-}
-
-# ── Booking.com manifest ───────────────────────────────────────────────────
-# Sync with _booking.py: _gql_headers(), _msg_headers()
-
-_BOOKING_DOMAIN = 'admin.booking.com'
-
-_BOOKING_ENDPOINTS: dict[str, dict] = {
-  'inventory/fetch': {
-    'url_fragment': 'fresa/extranet/inventory/fetch',
-    'hint': 'load the monthly calendar',
-  },
-  'inventory/update': {
-    'url_fragment': 'fresa/extranet/inventory/update',
-    'hint': 'change a price on any date',
-  },
-  'get_reservations': {
-    'url_fragment': 'fresa/extranet/calendar/get_reservations',
-    'hint': 'load the reservations list',
-  },
-  'updateRoomDateMutation': {
-    'url_fragment': 'dml/graphql.json',
-    'operation_name': 'updateRoomDateMutation',
-    'hint': 'block or unblock a date on the calendar',
-    'headers': {
-      'apollographql-client-version': 'EQSXMHMY',
-      'x-envoy-expected-rq-timeout-ms': '15000',
-    },
-  },
-  'conversationMessages': {
-    'url_fragment': 'dml/graphql.json',
-    'operation_name': 'conversationMessages',
-    'hint': 'open any guest message thread',
-    'headers': {
-      'apollographql-client-version': 'ABJaNSUc',
-    },
-  },
-  'postFreeTextMessage': {
-    'url_fragment': 'dml/graphql.json',
-    'operation_name': 'postFreeTextMessage',
-    'hint': 'send a message to a guest',
-    'headers': {
-      'apollographql-client-version': 'ABJaNSUc',
-    },
-  },
-}
+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 ────────────────────────────────────────────────────────────
@@ -265,10 +195,10 @@ def _audit_booking(entries: list[dict], har_name: str) -> bool:
     else:
       all_ok = False
       print(f'    ✗  {key:<{col}} found with stale headers')
+      src = spec.get('source', 'the client headers')
       for hname, hexpected, hactual in mismatches:
         print(f'       {hname}')
         print(f'         client: {hexpected}')
-        src = '_gql_headers()' if 'availability' in key.lower() or 'room' in key.lower() else '_msg_headers()'
         print(f'         HAR:    {hactual}   ← update {src}')
 
   missing = [k for k in _BOOKING_ENDPOINTS if k not in found]