|
|
@@ -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()
|
|
|
|