Ver código fonte

airbnb: dedupe detail parsing; hoist detail-fetch semaphore to the client

- _detail_section helper replaces four inline next(...) section lookups;
  _detail_checkin_checkout replaces the check-in/check-out extraction that
  was copy-pasted between get_reservations and get_booked_nets.
- The Semaphore(10) cap moves from per-call locals into _fetch_one_detail
  (one per client, created in __init__), so concurrent get_reservations /
  get_booked_nets calls share one cap instead of each bringing their own.
ZiB 3 meses atrás
pai
commit
979c0f7bd0
1 arquivos alterados com 34 adições e 39 exclusões
  1. 34 39
      room_cal/service_api/_airbnb.py

+ 34 - 39
room_cal/service_api/_airbnb.py

@@ -68,6 +68,12 @@ def _parse_price(s: str) -> float | None:
     return None
 
 
+def _detail_section(det: dict, section_id: str, placement: str = 'rootPlacement') -> dict:
+  """Find a section by ID in a stayHostingDetails placement; {} when absent."""
+  sections = det.get(placement, [])
+  return next((s for s in sections if s.get('sectionId') == section_id), None) or {}
+
+
 def _parse_booked_nets(det: dict, check_in: str, check_out: str) -> dict[str, int]:
   """Return {YYYY-MM-DD: net_dollars} for each night of a reservation.
 
@@ -75,8 +81,7 @@ def _parse_booked_nets(det: dict, check_in: str, check_out: str) -> dict[str, in
   Reads PAYMENTINFO_SECTION from stayHostingDetails (rootPlacement).
   Returns {} if the section is missing or the data is malformed.
   """
-  sections = det.get('rootPlacement', [])
-  pay = next((s for s in sections if s.get('sectionId') == 'PAYMENTINFO_SECTION'), None)
+  pay = _detail_section(det, 'PAYMENTINFO_SECTION')
   if not pay:
     return {}
   eg = (pay.get('sectionData') or {}).get('hostEarningsGroup', {})
@@ -144,6 +149,8 @@ class _AirbnbClient(ServiceClientBase):
       log=_log,
     )
     self._details: TTLCache[dict] = TTLCache()
+    # per-client cap on concurrent detail fetches; tuned against the live API
+    self._detail_sem = asyncio.Semaphore(10)
     self._http = httpx.AsyncClient(timeout=10.0)
     self._booked_nets: dict[str, int] = {}
 
@@ -311,33 +318,21 @@ class _AirbnbClient(ServiceClientBase):
 
     unique_codes = set(booked.values())
 
-    _sem = asyncio.Semaphore(10)
-
     async def fetch_details(c: str) -> tuple[str, str | None, str | None, str | None, str | None, str | None]:
-      async with _sem:
-        det = await self._fetch_one_detail(c)
-      sections = det.get('rootPlacement', [])
+      det = await self._fetch_one_detail(c)
 
-      whoscoming = next((s for s in sections if s.get('sectionId') == 'WHOSCOMING_SECTION'), None)
-      wc_groups = (whoscoming or {}).get('sectionData', {}).get('reservationGroups', [])
+      wc_groups = (_detail_section(det, 'WHOSCOMING_SECTION').get('sectionData') or {}).get('reservationGroups', [])
       wc_guests = wc_groups[0].get('guests', []) if wc_groups else []
-      facepile = next((s for s in sections if s.get('sectionId') == 'FACEPILE_SECTION'), None)
-      fp_guests = (facepile or {}).get('sectionData', {}).get('guests', [])
+      fp_guests = (_detail_section(det, 'FACEPILE_SECTION').get('sectionData') or {}).get('guests', [])
       name = (wc_guests[0].get('name') if wc_guests else None) or (fp_guests[0].get('name') if fp_guests else None)
 
-      checkinout = next((s for s in sections if s.get('sectionId') == 'CHECK_IN_CHECK_OUT_SECTION'), None)
-      ci_out_data = (checkinout or {}).get('sectionData') or {}
-      action = ci_out_data.get('checkInAction', {})
-      ci_ts = action.get('checkInTime')
-      co_ts = action.get('checkOutTime')
-      ci = self._to_local_date(ci_ts) if ci_ts else None
-      co = self._to_local_date(co_ts) if co_ts else None
+      ci, co = self._detail_checkin_checkout(det)
+      ci_out_data = _detail_section(det, 'CHECK_IN_CHECK_OUT_SECTION').get('sectionData') or {}
       door_code = (ci_out_data.get('airkeyAction') or {}).get('guestPhoneNumberLastFourDigits')
 
       # thread_id is in footerPlacement FLOATINGFOOTER_SECTION, not rootPlacement
-      footer_sections = det.get('footerPlacement', [])
-      footer = next((s for s in footer_sections if s.get('sectionId') == 'FLOATINGFOOTER_SECTION'), None)
-      buttons = ((footer or {}).get('sectionData') or {}).get('buttons', [])
+      footer = _detail_section(det, 'FLOATINGFOOTER_SECTION', 'footerPlacement')
+      buttons = (footer.get('sectionData') or {}).get('buttons', [])
       thread_id = None
       for btn in buttons:
         action = btn.get('buttonAction') or {}
@@ -399,20 +394,10 @@ class _AirbnbClient(ServiceClientBase):
     if not booked_codes:
       return {}
 
-    _sem = asyncio.Semaphore(10)
-
     async def fetch_nets(code: str) -> None:
-      async with _sem:
-        det = await self._fetch_one_detail(code)
-      sections = det.get('rootPlacement', [])
-      checkinout = next((s for s in sections if s.get('sectionId') == 'CHECK_IN_CHECK_OUT_SECTION'), None)
-      ci_out_data = (checkinout or {}).get('sectionData') or {}
-      action = ci_out_data.get('checkInAction', {})
-      ci_ts = action.get('checkInTime')
-      co_ts = action.get('checkOutTime')
-      if ci_ts and co_ts:
-        ci = self._to_local_date(ci_ts)
-        co = self._to_local_date(co_ts)
+      det = await self._fetch_one_detail(code)
+      ci, co = self._detail_checkin_checkout(det)
+      if ci and co:
         self._booked_nets.update(_parse_booked_nets(det, ci, co))
 
     await asyncio.gather(*[fetch_nets(code) for code in set(booked_codes.values())])
@@ -460,15 +445,25 @@ class _AirbnbClient(ServiceClientBase):
     dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
     return dt.astimezone(ZoneInfo(self._tz)).date().isoformat()
 
+  def _detail_checkin_checkout(self, det: dict) -> tuple[str | None, str | None]:
+    """Local check-in/check-out dates from a detail's CHECK_IN_CHECK_OUT_SECTION."""
+    data = _detail_section(det, 'CHECK_IN_CHECK_OUT_SECTION').get('sectionData') or {}
+    action = data.get('checkInAction', {})
+    ci_ts = action.get('checkInTime')
+    co_ts = action.get('checkOutTime')
+    return (self._to_local_date(ci_ts) if ci_ts else None,
+            self._to_local_date(co_ts) if co_ts else None)
+
   async def _fetch_one_detail(self, code: str) -> dict:
     if (cached := self._details.get(code)) is not None:
       return cached
     try:
-      resp = await self._gql(
-        'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
-        {'viewerTimeZoneOffset': 0, 'requestSource': 'RESERVATION_TAB', 'confirmationCode': code},
-        f'https://www.airbnb.com/hosting/reservations/details/{code}',
-      )
+      async with self._detail_sem:
+        resp = await self._gql(
+          'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
+          {'viewerTimeZoneOffset': 0, 'requestSource': 'RESERVATION_TAB', 'confirmationCode': code},
+          f'https://www.airbnb.com/hosting/reservations/details/{code}',
+        )
     except httpx.TimeoutException:
       _log.warning('airbnb: StayHostingDetailsQuery timed out for %s — skipping', code)
       return {}