| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652 |
- import asyncio
- import base64
- import json
- import random
- import re
- import string
- import uuid
- from datetime import date, datetime, timedelta
- from zoneinfo import ZoneInfo
- import httpx
- from dataclasses import dataclass
- import logging
- from ._scripts import run_refresh_script
- from room_cal._utils import get_config
- from ._base import ServiceClientBase
- from ._month_cache import MonthCache, TTLCache
- from ._registry import register_service
- from ._utils import month_bounds, months_in_range
- from .types import CalendarEvent, DayInfo, ServiceAuthError, Reservation
- _log = logging.getLogger(__name__)
- @dataclass
- class AirbnbMessage:
- id: str # global ID (base64)
- created_at_ms: str
- body: str | None # None for non-text messages (photos, system events, etc.)
- body_translated: str | None # Airbnb AI translation; None when not provided
- sender_account_id: str
- sender_name: str | None = None # resolved from thread participants; None only if resolution failed
- @dataclass
- class AirbnbThread:
- thread_id: str # numeric string, matches Reservation.thread_id
- messages: list[AirbnbMessage]
- @dataclass
- class AirbnbData:
- suggested_prices: dict[str, int] # date → Airbnb smart-pricing suggestion (USD)
- calendar_events: list[CalendarEvent] # holidays and local demand events
- _BASE = 'https://www.airbnb.com/api/v3'
- _API_KEY = 'd306zoyjsyarp7ifhu67rjxn52tv0t20'
- _CLIENT_VERSION = '4e93a4878415297ab95d22665c666b6602dcfcbb'
- _HASHES = {
- 'getDLSHostCalendar': '048e22675ffa53121628d2d07b519b9b71f5dab342a1ceb75aa72d8814a00ea7',
- 'EditPanelPricingSettingsMutation': 'e27fed4f08dd4ab4c0267c5d6c0a7e4920730deadf48f92feba9b0d08befafa4',
- 'EditPanelAvailabilityMutation': '2e056e7f98f8dc410a294c649adf719452980eb4d8ea95540fb1297af2637f67',
- 'StayHostingDetailsQuery': '6da83b8bc9ae1b434f13938a55435118347b0e8ceb838e04c1feb84ecb587e6d',
- 'getNightlyPriceTips': '07538bf1a5afc8171f1e7443f0007d7f020142fc7ab60df065e7e1edd1a0b29f',
- 'HostCalendarListingEventsQuery': '8bfe5f5572a25fdaff5b5790604e1de3aec5f37731fc505684022d590d06e0a8',
- 'ViaductGetThreadAndDataQuery': 'dd7478b376ad0a9709c7201b7e43cf402df011ec9d2fe4c1dff2ac418bcd9fb3',
- 'CreateBulkMessagesMutation': '94ac2c4bd07edace539dbf2b9665d9030b6dee479db345ba8a8bbc234b3bbfa3',
- }
- 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
- def _encode_listing_id(listing_id: str) -> str:
- return base64.b64encode(f'StayListing:{listing_id}'.encode()).decode()
- def _trace_id() -> str:
- return ''.join(random.choices(string.ascii_lowercase + string.digits, k=28))
- @register_service('airbnb')
- class _AirbnbClient(ServiceClientBase):
- service_name = 'AirBnB'
- auth_error_cls = AirbnbAuthError
- def __init__(self, cookies: str, listing_id: str, tz: str = 'America/Los_Angeles'):
- super().__init__()
- self._cookies = cookies
- self._listing_id = listing_id
- self._global_id = _encode_listing_id(listing_id)
- self._tz = tz
- # Tracks months where we've seen at least one booked day; survives clear_cache() so we can
- # detect when a freshly-fetched month drops from N booked days to 0 (stale replica signal).
- self._had_bookings: set[tuple[int, int]] = set()
- self._months: MonthCache[list[dict]] = MonthCache(
- lambda m, y: self._fetch_month_days(m, y),
- suspect=self._suspect_days,
- revalidate=self._revalidate_month,
- on_fetch=self._note_bookings,
- on_evict=self._evict_derived,
- label='Airbnb',
- log=_log,
- )
- self._details: TTLCache[dict] = TTLCache()
- self._http = httpx.AsyncClient(timeout=10.0)
- self._booked_nets: dict[str, int] = {}
- @classmethod
- def from_env(cls) -> '_AirbnbClient':
- env = cls._read_env()
- cls._require_env(env, 'AIRBNB_COOKIES')
- listing_id = get_config().services['airbnb'].room_id
- if not listing_id:
- raise ValueError('airbnb.room_id must be set in config.json')
- return cls(cookies=env['AIRBNB_COOKIES'], listing_id=listing_id)
- def clear_cache(self) -> None:
- super().clear_cache()
- self._details.clear()
- self._booked_nets.clear()
- def _apply_env(self, env: dict[str, str]) -> None:
- self._cookies = env.get('AIRBNB_COOKIES', self._cookies)
- @staticmethod
- def refresh_credentials() -> None:
- 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 = {
- 'Cookie': self._cookies,
- 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0',
- 'Accept': '*/*',
- 'Accept-Language': 'en-US,en;q=0.9',
- 'Content-Type': 'application/json',
- 'Referer': referer,
- 'Sec-Fetch-Mode': 'cors',
- 'Sec-Fetch-Dest': 'empty',
- 'Sec-Fetch-Site': 'same-origin',
- 'X-Airbnb-API-Key': _API_KEY,
- 'X-Airbnb-GraphQL-Platform': 'web',
- 'X-Airbnb-GraphQL-Platform-Client': 'minimalist-niobe',
- 'X-Airbnb-Supports-Airlock-V2': 'true',
- 'X-Niobe-Short-Circuited': 'true',
- 'X-Client-Version': _CLIENT_VERSION,
- 'X-CSRF-Token': '',
- 'X-CSRF-Without-Token': '1',
- 'x-airbnb-client-trace-id': _trace_id(),
- 'x-client-request-id': _trace_id(),
- 'x-airbnb-network-log-link': _trace_id(),
- }
- if extra:
- h.update(extra)
- return h
- def _auth_shaped(self, r: httpx.Response) -> bool:
- # Airbnb also signals session expiry as GQL errors on HTTP 200; route those
- # through the base's refresh → retry-once → stale sequence too.
- if super()._auth_shaped(r):
- return True
- if r.status_code != 200:
- return False
- try:
- body = r.json()
- except ValueError:
- return False
- return any(
- e.get('extensions', {}).get('response', {}).get('status') in (400, 401, 403)
- for e in body.get('errors', [])
- )
- async def _gql(self, op: str, h: str, variables: dict, referer: str) -> dict:
- url = f'{_BASE}/{op}/{h}'
- params = {
- 'operationName': op,
- 'locale': 'en',
- 'currency': 'USD',
- 'variables': json.dumps(variables),
- 'extensions': json.dumps({'persistedQuery': {'version': 1, 'sha256Hash': h}}),
- }
- async def _attempt() -> httpx.Response:
- return await self._http.get(url, params=params, headers=self._headers(referer))
- r = await self._request(_attempt, op=op)
- return r.json()
- async def _mutate(self, op: str, h: str, variables: dict, referer: str) -> dict:
- url = f'{_BASE}/{op}/{h}?operationName={op}&locale=en¤cy=USD'
- payload = {'operationName': op, 'variables': variables,
- 'extensions': {'persistedQuery': {'version': 1, 'sha256Hash': h}}}
- async def _attempt() -> httpx.Response:
- return await self._http.post(
- url, json=payload,
- headers=self._headers(referer, {'Origin': 'https://www.airbnb.com'}),
- )
- r = await self._request(_attempt, op=op)
- return r.json()
- async def _fetch_month_days(self, month: int, year: int) -> list[dict]:
- start, end = month_bounds(month, year)
- variables = {
- 'listingId': self._global_id,
- 'startDate': start,
- 'endDate': end,
- 'hostCalendarViewType': 'MONTH_VIEW',
- 'lensTypes': ['NOTE', 'PRICE', 'PROMOTION'],
- 'timeZone': self._tz,
- 'localizationContext': {'timeZone': self._tz, 'firstDayOfWeek': 0},
- 'hostCalendarSectionTypes': ['MAIN_VIEW'],
- 'readConsistency': 'EVENTUAL',
- }
- referer = f'https://www.airbnb.com/multicalendar/{self._listing_id}'
- resp = await self._gql('getDLSHostCalendar', _HASHES['getDLSHostCalendar'], variables, referer)
- section = (
- resp.get('data', {}).get('presentation', {})
- .get('hostCalendar', {}).get('sections', {})
- .get('calendarGridViewSection')
- ) or {}
- return section.get('days', [])
- def _suspect_days(self, key: tuple[int, int], days: list[dict]) -> bool:
- return not days or not any(d.get('priceData', {}).get('price') is not None for d in days)
- def _revalidate_month(self, key: tuple[int, int], cached: list[dict]) -> bool:
- # If we've seen bookings for this month before but the cache now shows none,
- # the data is likely from a stale eventual-consistency replica — force a refetch.
- if key in self._had_bookings and not any(d.get('reservationData') for d in cached):
- month, year = key
- _log.warning(
- 'Airbnb: %d-%02d cache shows 0 booked days but month had bookings; forcing refetch',
- year, month,
- )
- return False
- return True
- def _note_bookings(self, key: tuple[int, int], result: list[dict], revalidation_failed: bool) -> None:
- if any(d.get('reservationData') for d in result):
- self._had_bookings.add(key)
- elif revalidation_failed:
- # Forced refetch confirmed no bookings — stop guarding this month
- self._had_bookings.discard(key)
- def _month_day_infos(self, days: list[dict]) -> dict[str, DayInfo]:
- return {
- d: DayInfo(
- date=d,
- price=day.get('priceData', {}).get('price'),
- available=day.get('available', False),
- booked=day.get('reservationData') is not None,
- )
- for day in days
- if (d := day.get('day', ''))
- }
- async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
- self._check_fresh()
- month_list = months_in_range(date_from, date_to)
- all_days = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
- booked: dict[str, str] = {}
- for days in all_days:
- for day in days:
- d = day.get('day', '')
- res_data = day.get('reservationData')
- if res_data and date_from <= d <= date_to:
- booked[d] = res_data.get('confirmationCode', '')
- if not booked:
- return []
- 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', [])
- 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 = 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}
- return sorted(
- [
- Reservation(
- service='airbnb',
- reservation_id=c,
- check_in=ci,
- check_out=co,
- guest_name=name,
- door_code=door_code,
- thread_id=thread_id,
- service_data={k: v for k, v in {'door_code': door_code, 'thread_id': thread_id}.items() if v is not None},
- chat_id=thread_id,
- )
- for c, (name, ci, co, door_code, thread_id) in details.items()
- if ci and co
- ],
- 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.
- """
- self._check_fresh()
- month_list = months_in_range(date_from, date_to)
- all_days = await asyncio.gather(*[self._months.get(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} # int | None per date
- async def set_price(self, d: str, price: int) -> None:
- price = int(round(price))
- self._check_fresh()
- if (cached := self._cache.get(d)) and cached.price == price:
- return
- await self._mutate(
- 'EditPanelPricingSettingsMutation', _HASHES['EditPanelPricingSettingsMutation'],
- {'input': {
- 'listingId': self._listing_id,
- 'selectedDateRanges': [{'startDate': d, 'endDate': d}],
- 'nightlyPriceAmount': price,
- 'turnOnSmartPricing': False,
- 'isCalendarV2': True,
- }},
- f'https://www.airbnb.com/multicalendar/{self._listing_id}/edit-selected-dates/{d}/{d}/nightly-price',
- )
- self.clear_cache()
- _log.info('airbnb set_price date=%s price=$%d', d, price)
- async def set_availability(self, d: str, available: bool) -> None:
- self._check_fresh()
- if (cached := self._cache.get(d)) and cached.available == available:
- return
- body = await self._mutate(
- 'EditPanelAvailabilityMutation', _HASHES['EditPanelAvailabilityMutation'],
- {'input': {
- 'listingId': self._listing_id,
- 'selectedDateRanges': [{'startDate': d, 'endDate': d}],
- 'actionType': 'OPEN' if available else 'BLOCK',
- }, 'blockNotesEnabled': True},
- f'https://www.airbnb.com/multicalendar/{self._listing_id}/edit-selected-dates/{d}/{d}',
- )
- if errors := body.get('errors'):
- _log.error('airbnb set_availability date=%s errors=%s', d, errors)
- raise AirbnbAuthError(f'Airbnb set_availability failed for {d}: {errors[0].get("message", errors)}')
- 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 (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}',
- )
- 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._details.set(code, det)
- return det
- def _global_thread_id(self, thread_id: str) -> str:
- return base64.b64encode(f'MessageThread:{thread_id}'.encode()).decode()
- async def get_thread(self, thread_id: str) -> AirbnbThread:
- """Fetch a conversation thread and its messages by numeric thread ID."""
- self._check_fresh()
- resp = await self._gql(
- 'ViaductGetThreadAndDataQuery', _HASHES['ViaductGetThreadAndDataQuery'],
- {
- 'globalThreadId': self._global_thread_id(thread_id),
- 'numRequestedMessages': 50,
- 'getThreadState': True,
- 'getParticipants': True,
- 'getLastReads': True,
- 'getInboxFields': True,
- 'getInboxOnlyFields': False,
- 'getMessageFields': True,
- 'getThreadOnlyFields': True,
- 'forceUgcTranslation': False,
- 'isNovaLite': False,
- 'mockThreadIdentifier': None,
- 'mockMessageTestIdentifier': None,
- 'mockListFooterSlot': None,
- 'forceReturnAllReadReceipts': False,
- 'originType': 'USER_INBOX',
- 'skipOldMessagePreviewFields': False,
- },
- f'https://www.airbnb.com/messages/{thread_id}',
- )
- thread_data = resp.get('data', {}).get('threadData', {})
- participants: dict[str, str] = {}
- for edge in thread_data.get('participants', {}).get('edges', []):
- node = edge.get('node', {})
- account_id = node.get('accountId', '')
- name = (node.get('enrichedParticipantInfo') or {}).get('name', '')
- if account_id and name:
- participants[account_id] = name
- messages = []
- for m in thread_data.get('messageData', {}).get('messages', []):
- aid = (m.get('account') or {}).get('accountId', '')
- messages.append(AirbnbMessage(
- id=m['id'],
- created_at_ms=m['createdAtMs'],
- body=(((m.get('hydratedContent') or {}).get('content') or {}).get('body')),
- body_translated=(((m.get('hydratedContent') or {}).get('content') or {}).get('bodyTranslated')),
- sender_account_id=aid,
- sender_name=participants.get(aid, 'Airbnb'),
- ))
- return AirbnbThread(thread_id=thread_id, messages=messages)
- async def send_message(self, thread_id: str, body: str) -> None:
- """Send a text message to an existing thread."""
- self._check_fresh()
- await self._mutate(
- 'CreateBulkMessagesMutation', _HASHES['CreateBulkMessagesMutation'],
- {
- 'messageThreadId': thread_id,
- 'messages': [{
- 'content': {'textContent': {'body': body}},
- 'uniqueIdentifier': str(uuid.uuid4()),
- 'loggingSource': {'sourceType': '', 'sourceId': ''},
- 'loggingExtras': {'entries': []},
- }],
- 'actAs': 'PARTICIPANT',
- 'businessJustification': {'feature': 'USER_INBOX'},
- 'originType': 'USER_INBOX',
- 'senderPlatform': 'WEB',
- 'skipOldMessagePreviewFields': False,
- },
- f'https://www.airbnb.com/messages/{thread_id}',
- )
- async def get_suggested_prices(self, date_from: str, date_to: str) -> dict[str, int]:
- """Airbnb's per-day smart-pricing suggestions (USD). Only includes days where shouldShowTip=True."""
- self._check_fresh()
- resp = await self._gql(
- 'getNightlyPriceTips', _HASHES['getNightlyPriceTips'],
- {
- 'listingId': self._global_id,
- 'dateRanges': [{'startDate': date_from, 'endDate': date_to}],
- },
- f'https://www.airbnb.com/multicalendar/{self._listing_id}',
- )
- tips = (
- resp.get('data', {}).get('presentation', {})
- .get('pricingGuidance', {}).get('nightlyPriceTip', {})
- .get('nightlyPriceTip', {}).get('discretePriceTips', [])
- )
- return {
- t['date']: t['priceTip']['intValue']
- for t in tips
- if t.get('shouldShowTip') and date_from <= t['date'] <= date_to
- }
- async def get_service_data(self, date_from: str, date_to: str) -> AirbnbData:
- suggested_prices, calendar_events = await asyncio.gather(
- self.get_suggested_prices(date_from, date_to),
- self.get_calendar_events(),
- )
- return AirbnbData(suggested_prices=suggested_prices, calendar_events=calendar_events)
- async def get_calendar_events(self) -> list[CalendarEvent]:
- """Local events and holidays Airbnb shows on the host calendar (holidays, major events, etc.)."""
- self._check_fresh()
- resp = await self._gql(
- 'HostCalendarListingEventsQuery', _HASHES['HostCalendarListingEventsQuery'],
- {'listingId': self._global_id},
- f'https://www.airbnb.com/multicalendar/{self._listing_id}',
- )
- events = (
- resp.get('data', {}).get('presentation', {})
- .get('hostCalendarListingEvents', {}).get('events', [])
- )
- return [
- CalendarEvent(
- start_date=e['startDate'],
- end_date=e['endDate'],
- name=e['displayName'],
- type=e['type'],
- event_id=e['eventId'],
- )
- 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',
- },
- },
- }
|