Procházet zdrojové kódy

fetch booked-day net prices from Airbnb reservation payment data

For booked Airbnb dates, StayHostingDetailsQuery already returns the
host's actual earnings breakdown. Instead of deriving net from the list
price (which breaks when the commission rate changes), fetch-prices now
reads net directly from the PAYMENTINFO_SECTION and apportions total
host earnings across nights proportionally by per-night list weight.

Also fixes the stale StayHostingDetailsQuery hash (5d3e94e → 6da83b8).
ZiB před 4 měsíci
rodič
revize
96dd1fa02c

+ 8 - 1
room_cal/actions/fetch_prices.py

@@ -39,7 +39,10 @@ async def fetch_prices(
   if api is None:
     api = _ServiceAPI()
 
-  api_prices = await api.get_prices_range(start_date, end_date)
+  api_prices, booked_nets = await asyncio.gather(
+    api.get_prices_range(start_date, end_date),
+    api.get_booked_nets(start_date, end_date),
+  )
   if not api_prices:
     return FetchResult(data=[], changed=False)
 
@@ -71,6 +74,10 @@ async def fetch_prices(
     keys_to_try = [booked_svc] if booked_svc and not service else service_keys
     net = None
     for svc in keys_to_try:
+      svc_booked_net = (booked_nets.get(ds) or {}).get(svc)
+      if svc_booked_net is not None:
+        net = svc_booked_net
+        break
       list_price = service_prices.get(svc)
       if list_price is not None:
         net = list_to_net(svc, list_price)

+ 169 - 52
room_cal/service_api/_airbnb.py

@@ -5,7 +5,7 @@ import os
 import random
 import string
 import uuid
-from datetime import datetime
+from datetime import date, datetime, timedelta
 from zoneinfo import ZoneInfo
 import httpx
 
@@ -60,6 +60,54 @@ _HASHES = {
 _AUTH_HINT = 'refresh-creds'
 
 
+def _parse_price(s: str) -> float | None:
+  """Parse a formatted price string like '$143.56' → 143.56, or None on failure."""
+  try:
+    return float(s.replace('$', '').replace(',', '').strip())
+  except (ValueError, AttributeError):
+    return None
+
+
+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.
+
+  Uses proportional apportionment: net_i = total_host_net * (list_i / sum(list)).
+  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)
+  if not pay:
+    return {}
+  eg = (pay.get('sectionData') or {}).get('hostEarningsGroup', {})
+
+  total_net = _parse_price((eg.get('totalLineItem') or {}).get('formattedValue', ''))
+  if total_net is None:
+    return {}
+
+  nights_item = next(
+    (li for li in eg.get('lineItems', []) if (li.get('collapsibleLineItemGroup') or {}).get('collapsedLineItems')),
+    None,
+  )
+  if not nights_item:
+    return {}
+  weights = [
+    w for item in nights_item['collapsibleLineItemGroup']['collapsedLineItems']
+    if (w := _parse_price(item.get('formattedValue', ''))) is not None
+  ]
+
+  ci = date.fromisoformat(check_in)
+  co = date.fromisoformat(check_out)
+  nights = [(ci + timedelta(days=i)).isoformat() for i in range((co - ci).days)]
+
+  if not weights or len(nights) != len(weights):
+    return {}
+  total_weight = sum(weights)
+  if total_weight == 0:
+    return {}
+  return {d: round(total_net * w / total_weight) for d, w in zip(nights, weights)}
+
+
 class AirbnbAuthError(ServiceAuthError):
   pass
 
@@ -87,6 +135,8 @@ class _AirbnbClient:
     self._refresh_lock = asyncio.Lock()
     self._refresh_task: asyncio.Task | None = None
     self._http = httpx.AsyncClient(timeout=10.0)
+    self._detail_cache: dict[str, dict] = {}
+    self._booked_nets: dict[str, int] = {}
 
   @classmethod
   def from_env(cls) -> '_AirbnbClient':
@@ -104,6 +154,8 @@ class _AirbnbClient:
     self._cache.clear()
     self._fetched_months.clear()
     self._raw_months.clear()
+    self._detail_cache.clear()
+    self._booked_nets.clear()
 
   def _reload_from_env(self) -> None:
     env_file = data_home() / 'airbnb.env'
@@ -300,7 +352,18 @@ class _AirbnbClient:
               rooms_to_sell=None,
             )
         self._fetched_months.add((month, year))
-    return {d: info for d, info in self._cache.items() if date_from <= d <= date_to}
+    return {
+      d: DayInfo(
+        date=info.date,
+        price=info.price,
+        available=info.available,
+        booked=info.booked,
+        rooms_to_sell=info.rooms_to_sell,
+        booked_net=self._booked_nets.get(d) if info.booked else None,
+      )
+      for d, info in self._cache.items()
+      if date_from <= d <= date_to
+    }
 
   async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
     if is_stale('airbnb'):
@@ -320,60 +383,47 @@ class _AirbnbClient:
 
     unique_codes = set(booked.values())
 
-    def _utc_to_local_date(ts: str) -> str:
-      dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
-      return dt.astimezone(ZoneInfo(self._tz)).date().isoformat()
-
     _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:
-        try:
-          resp = await self._gql(
-            'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
-            {'viewerTimeZoneOffset': 0, 'requestSource': 'RESERVATION_TAB', 'confirmationCode': c},
-            f'https://www.airbnb.com/hosting/reservations/details/{c}',
-          )
-        except httpx.TimeoutException:
-          _log.warning('airbnb: StayHostingDetailsQuery timed out for %s — skipping details', c)
-          return c, None, None, None, None, None
-        sections = (
-          resp.get('data', {}).get('presentation', {})
-              .get('hostingDetails', {}).get('stayHostingDetails', {})
-              .get('rootPlacement', [])
-        )
-        whoscoming = next((s for s in sections if s.get('sectionId') == 'WHOSCOMING_SECTION'), None)
-        wc_groups = (whoscoming or {}).get('sectionData', {}).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', [])
-        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 = _utc_to_local_date(ci_ts) if ci_ts else None
-        co = _utc_to_local_date(co_ts) if co_ts else None
-        door_code = (ci_out_data.get('airkeyAction') or {}).get('guestPhoneNumberLastFourDigits')
-
-        # thread_id is in footerPlacement FLOATINGFOOTER_SECTION, not rootPlacement
-        det = resp.get('data', {}).get('presentation', {}).get('hostingDetails', {}).get('stayHostingDetails', {})
-        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', [])
-        thread_id = None
-        for btn in buttons:
-          action = btn.get('buttonAction') or {}
-          if action.get('__typename') == 'NavigateToMessageGuest':
-            url = action.get('url', '')
-            m = __import__('re').search(r'/thread/(\d+)', url)
-            if m:
-              thread_id = m.group(1)
-              break
-
-        return c, name, ci, co, door_code, thread_id
+        det = await self._fetch_one_detail(c)
+      sections = det.get('rootPlacement', [])
+
+      whoscoming = next((s for s in sections if s.get('sectionId') == 'WHOSCOMING_SECTION'), None)
+      wc_groups = (whoscoming or {}).get('sectionData', {}).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', [])
+      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
+      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', [])
+      thread_id = None
+      for btn in buttons:
+        action = btn.get('buttonAction') or {}
+        if action.get('__typename') == 'NavigateToMessageGuest':
+          url = action.get('url', '')
+          m = __import__('re').search(r'/thread/(\d+)', url)
+          if m:
+            thread_id = m.group(1)
+            break
+
+      if ci and co:
+        self._booked_nets.update(_parse_booked_nets(det, ci, co))
+
+      return c, name, ci, co, door_code, thread_id
 
     details_list = await asyncio.gather(*[fetch_details(c) for c in unique_codes])
     details = {c: (name, ci, co, door_code, thread_id) for c, name, ci, co, door_code, thread_id in details_list}
@@ -397,6 +447,50 @@ class _AirbnbClient:
       key=lambda r: r.check_in,
     )
 
+  async def get_booked_nets(self, date_from: str, date_to: str) -> dict[str, int | None]:
+    """Return {date: net_dollars} for Airbnb-booked dates in range.
+
+    Fetches StayHostingDetailsQuery for each reservation and apportions total
+    host earnings proportionally across nights by per-night list price weight.
+    Results are cached in _booked_nets; repeated calls (e.g. after get_reservations)
+    hit the detail cache and make no additional network calls.
+    """
+    if is_stale('airbnb'):
+      raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
+    month_list = months_in_range(date_from, date_to)
+    all_days = await asyncio.gather(*[self._get_month_days(m, y) for m, y in month_list])
+
+    booked_codes: dict[str, str] = {}  # date → confirmation code
+    for days in all_days:
+      for day in days:
+        d = day.get('day', '')
+        if d and date_from <= d <= date_to:
+          code = (day.get('reservationData') or {}).get('confirmationCode', '')
+          if code:
+            booked_codes[d] = code
+
+    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)
+        self._booked_nets.update(_parse_booked_nets(det, ci, co))
+
+    await asyncio.gather(*[fetch_nets(code) for code in set(booked_codes.values())])
+    return {d: self._booked_nets.get(d) for d in booked_codes}
+
   async def set_price(self, d: str, price: int) -> None:
     price = int(round(price))
     if is_stale('airbnb'):
@@ -437,6 +531,29 @@ class _AirbnbClient:
     self.clear_cache()
     _log.info('airbnb set_availability date=%s available=%s', d, available)
 
+  def _to_local_date(self, ts: str) -> str:
+    dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
+    return dt.astimezone(ZoneInfo(self._tz)).date().isoformat()
+
+  async def _fetch_one_detail(self, code: str) -> dict:
+    if code in self._detail_cache:
+      return self._detail_cache[code]
+    try:
+      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 {}
+    det = (
+      resp.get('data', {}).get('presentation', {})
+          .get('hostingDetails', {}).get('stayHostingDetails', {})
+    )
+    self._detail_cache[code] = det
+    return det
+
   def _global_thread_id(self, thread_id: str) -> str:
     return base64.b64encode(f'MessageThread:{thread_id}'.encode()).decode()
 

+ 21 - 0
room_cal/service_api/client.py

@@ -124,6 +124,27 @@ class ServiceAPI:
       }
     return result
 
+  async def get_booked_nets(self, start_date: date, end_date: date) -> dict[str, dict[str, int | None]]:
+    """Return {date_str: {service_key: net}} for booked dates where per-night net is available.
+
+    Only services that implement get_booked_nets contribute data; others are absent from the result.
+    Dispatch is via hasattr so new clients opt in by implementing the method.
+    """
+    ds_from = start_date.isoformat()
+    ds_to = end_date.isoformat()
+    per_service = await self._gather({
+      key: client.get_booked_nets(ds_from, ds_to)  # type: ignore[attr-defined]
+      for key, client in self._clients.items()
+      if hasattr(client, 'get_booked_nets')
+    })
+    result: dict[str, dict[str, int | None]] = {}
+    for key, nets in per_service.items():
+      if nets is None:
+        continue
+      for d, net in nets.items():  # type: ignore[union-attr]
+        result.setdefault(d, {})[key] = net
+    return result
+
   async def get_prices(self, d: date) -> dict[str, int | None]:
     """Return {service_key: price} for all services on a single date."""
     ds = d.isoformat()

+ 1 - 0
room_cal/service_api/types.py

@@ -12,6 +12,7 @@ class DayInfo:
   available: bool
   booked: bool
   rooms_to_sell: int | None  # Booking.com only; None for Airbnb
+  booked_net: int | None = None  # per-night net from reservation payment data; Airbnb only
 
 
 @dataclass