|
@@ -68,6 +68,12 @@ def _parse_price(s: str) -> float | None:
|
|
|
return 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]:
|
|
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.
|
|
"""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).
|
|
Reads PAYMENTINFO_SECTION from stayHostingDetails (rootPlacement).
|
|
|
Returns {} if the section is missing or the data is malformed.
|
|
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:
|
|
if not pay:
|
|
|
return {}
|
|
return {}
|
|
|
eg = (pay.get('sectionData') or {}).get('hostEarningsGroup', {})
|
|
eg = (pay.get('sectionData') or {}).get('hostEarningsGroup', {})
|
|
@@ -144,6 +149,8 @@ class _AirbnbClient(ServiceClientBase):
|
|
|
log=_log,
|
|
log=_log,
|
|
|
)
|
|
)
|
|
|
self._details: TTLCache[dict] = TTLCache()
|
|
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._http = httpx.AsyncClient(timeout=10.0)
|
|
|
self._booked_nets: dict[str, int] = {}
|
|
self._booked_nets: dict[str, int] = {}
|
|
|
|
|
|
|
@@ -311,33 +318,21 @@ class _AirbnbClient(ServiceClientBase):
|
|
|
|
|
|
|
|
unique_codes = set(booked.values())
|
|
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 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 []
|
|
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)
|
|
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')
|
|
door_code = (ci_out_data.get('airkeyAction') or {}).get('guestPhoneNumberLastFourDigits')
|
|
|
|
|
|
|
|
# thread_id is in footerPlacement FLOATINGFOOTER_SECTION, not rootPlacement
|
|
# 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
|
|
thread_id = None
|
|
|
for btn in buttons:
|
|
for btn in buttons:
|
|
|
action = btn.get('buttonAction') or {}
|
|
action = btn.get('buttonAction') or {}
|
|
@@ -399,20 +394,10 @@ class _AirbnbClient(ServiceClientBase):
|
|
|
if not booked_codes:
|
|
if not booked_codes:
|
|
|
return {}
|
|
return {}
|
|
|
|
|
|
|
|
- _sem = asyncio.Semaphore(10)
|
|
|
|
|
-
|
|
|
|
|
async def fetch_nets(code: str) -> None:
|
|
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))
|
|
self._booked_nets.update(_parse_booked_nets(det, ci, co))
|
|
|
|
|
|
|
|
await asyncio.gather(*[fetch_nets(code) for code in set(booked_codes.values())])
|
|
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'))
|
|
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
|
|
return dt.astimezone(ZoneInfo(self._tz)).date().isoformat()
|
|
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:
|
|
async def _fetch_one_detail(self, code: str) -> dict:
|
|
|
if (cached := self._details.get(code)) is not None:
|
|
if (cached := self._details.get(code)) is not None:
|
|
|
return cached
|
|
return cached
|
|
|
try:
|
|
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:
|
|
except httpx.TimeoutException:
|
|
|
_log.warning('airbnb: StayHostingDetailsQuery timed out for %s — skipping', code)
|
|
_log.warning('airbnb: StayHostingDetailsQuery timed out for %s — skipping', code)
|
|
|
return {}
|
|
return {}
|