_airbnb.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. import asyncio
  2. import base64
  3. import json
  4. import os
  5. import random
  6. import string
  7. import uuid
  8. from datetime import datetime
  9. from zoneinfo import ZoneInfo
  10. import httpx
  11. from dataclasses import dataclass
  12. import logging
  13. from ._scripts import refresh_airbnb_cookies
  14. from room_cal._utils import data_home, get_config, read_env
  15. from ._utils import month_bounds, months_in_range
  16. from .credentials import is_stale, mark_stale
  17. from .types import CalendarEvent, DayInfo, ServiceAuthError, Reservation
  18. _log = logging.getLogger(__name__)
  19. @dataclass
  20. class AirbnbMessage:
  21. id: str # global ID (base64)
  22. created_at_ms: str
  23. body: str | None # None for non-text messages (photos, system events, etc.)
  24. body_translated: str | None # Airbnb AI translation; None when not provided
  25. sender_account_id: str
  26. @dataclass
  27. class AirbnbThread:
  28. thread_id: str # numeric string, matches Reservation.thread_id
  29. messages: list[AirbnbMessage]
  30. @dataclass
  31. class AirbnbData:
  32. suggested_prices: dict[str, int] # date → Airbnb smart-pricing suggestion (USD)
  33. calendar_events: list[CalendarEvent] # holidays and local demand events
  34. _BASE = 'https://www.airbnb.com/api/v3'
  35. _API_KEY = 'd306zoyjsyarp7ifhu67rjxn52tv0t20'
  36. _CLIENT_VERSION = 'ef690fbd6464c4bd9f632ec18087662c4e4f05e1'
  37. _HASHES = {
  38. 'getDLSHostCalendar': '048e22675ffa53121628d2d07b519b9b71f5dab342a1ceb75aa72d8814a00ea7',
  39. 'EditPanelPricingSettingsMutation': 'e27fed4f08dd4ab4c0267c5d6c0a7e4920730deadf48f92feba9b0d08befafa4',
  40. 'EditPanelAvailabilityMutation': '2e056e7f98f8dc410a294c649adf719452980eb4d8ea95540fb1297af2637f67',
  41. 'StayHostingDetailsQuery': '1e81282ae4de44e05bc8b776456644efd4ebcd676451ce7f6c3c5757a1057e02',
  42. 'getNightlyPriceTips': '07538bf1a5afc8171f1e7443f0007d7f020142fc7ab60df065e7e1edd1a0b29f',
  43. 'HostCalendarListingEventsQuery': '8bfe5f5572a25fdaff5b5790604e1de3aec5f37731fc505684022d590d06e0a8',
  44. 'ViaductGetThreadAndDataQuery': 'c6aff32757966df45d73811219d4f3a57ae7cc9e047b39bb9ba396cd0c09defa',
  45. 'CreateBulkMessagesMutation': 'b0298188a93a73218fecdd512afb7b47c0df3112a4819c236ecc4c0c10c04bd2',
  46. }
  47. _AUTH_HINT = 'refresh-creds'
  48. class AirbnbAuthError(ServiceAuthError):
  49. pass
  50. def _encode_listing_id(listing_id: str) -> str:
  51. return base64.b64encode(f'StayListing:{listing_id}'.encode()).decode()
  52. def _trace_id() -> str:
  53. return ''.join(random.choices(string.ascii_lowercase + string.digits, k=28))
  54. class _AirbnbClient:
  55. def __init__(self, cookies: str, listing_id: str, tz: str = 'America/Los_Angeles'):
  56. self._cookies = cookies
  57. self._listing_id = listing_id
  58. self._global_id = _encode_listing_id(listing_id)
  59. self._tz = tz
  60. self._cache: dict[str, DayInfo] = {}
  61. self._fetched_months: set[tuple[int, int]] = set()
  62. self._raw_months: dict[tuple[int, int], list[dict]] = {}
  63. self._months_inflight: dict[tuple[int, int], asyncio.Task] = {}
  64. self._refresh_lock = asyncio.Lock()
  65. self._refresh_task: asyncio.Task | None = None
  66. @classmethod
  67. def from_env(cls) -> '_AirbnbClient':
  68. env_file = data_home() / 'airbnb.env'
  69. env = {**read_env(env_file), **os.environ}
  70. cookies = env.get('AIRBNB_COOKIES', '')
  71. if not cookies:
  72. raise ValueError(f'AIRBNB_COOKIES must be set in {env_file}')
  73. listing_id = get_config().services['airbnb'].room_id
  74. if not listing_id:
  75. raise ValueError('airbnb.room_id must be set in config.json')
  76. return cls(cookies=cookies, listing_id=listing_id)
  77. def clear_cache(self) -> None:
  78. self._cache.clear()
  79. self._fetched_months.clear()
  80. self._raw_months.clear()
  81. def _reload_from_env(self) -> None:
  82. env_file = data_home() / 'airbnb.env'
  83. env = {**read_env(env_file), **os.environ}
  84. self._cookies = env.get('AIRBNB_COOKIES', self._cookies)
  85. self.clear_cache()
  86. async def _auto_refresh(self) -> None:
  87. _log.warning('Airbnb session may be expired — launching headless Playwright')
  88. try:
  89. refresh_airbnb_cookies()
  90. self._reload_from_env()
  91. except ServiceAuthError:
  92. mark_stale('airbnb')
  93. raise
  94. async def _ensure_refreshed(self) -> None:
  95. async with self._refresh_lock:
  96. if self._refresh_task is None or self._refresh_task.done():
  97. self._refresh_task = asyncio.create_task(self._auto_refresh())
  98. task = self._refresh_task
  99. await task
  100. def _headers(self, referer: str, extra: dict | None = None) -> dict[str, str]:
  101. h = {
  102. 'Cookie': self._cookies,
  103. 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0',
  104. 'Accept': '*/*',
  105. 'Accept-Language': 'en-US,en;q=0.9',
  106. 'Content-Type': 'application/json',
  107. 'Referer': referer,
  108. 'Sec-Fetch-Mode': 'cors',
  109. 'Sec-Fetch-Dest': 'empty',
  110. 'Sec-Fetch-Site': 'same-origin',
  111. 'X-Airbnb-API-Key': _API_KEY,
  112. 'X-Airbnb-GraphQL-Platform': 'web',
  113. 'X-Airbnb-GraphQL-Platform-Client': 'minimalist-niobe',
  114. 'X-Airbnb-Supports-Airlock-V2': 'true',
  115. 'X-Niobe-Short-Circuited': 'true',
  116. 'X-Client-Version': _CLIENT_VERSION,
  117. 'X-CSRF-Token': '',
  118. 'X-CSRF-Without-Token': '1',
  119. 'x-airbnb-client-trace-id': _trace_id(),
  120. 'x-client-request-id': _trace_id(),
  121. 'x-airbnb-network-log-link': _trace_id(),
  122. }
  123. if extra:
  124. h.update(extra)
  125. return h
  126. def _check_auth(self, body: dict) -> None:
  127. if any(
  128. e.get('extensions', {}).get('response', {}).get('status') in (400, 401, 403)
  129. for e in body.get('errors', [])
  130. ):
  131. mark_stale('airbnb')
  132. raise AirbnbAuthError(f'Airbnb session expired — run: {_AUTH_HINT}')
  133. async def _gql(self, op: str, h: str, variables: dict, referer: str) -> dict:
  134. url = f'{_BASE}/{op}/{h}'
  135. params = {
  136. 'operationName': op,
  137. 'locale': 'en',
  138. 'currency': 'USD',
  139. 'variables': json.dumps(variables),
  140. 'extensions': json.dumps({'persistedQuery': {'version': 1, 'sha256Hash': h}}),
  141. }
  142. async def _attempt() -> httpx.Response:
  143. async with httpx.AsyncClient() as client:
  144. return await client.get(url, params=params, headers=self._headers(referer))
  145. try:
  146. r = await _attempt()
  147. except (httpx.TransportError, OSError, ExceptionGroup) as e:
  148. raise AirbnbAuthError(f'Airbnb connection failed — session may be expired, run: {_AUTH_HINT}') from e
  149. if r.status_code in (400, 401, 403):
  150. await self._ensure_refreshed()
  151. r = await _attempt()
  152. if r.status_code in (400, 401, 403):
  153. mark_stale('airbnb')
  154. raise AirbnbAuthError(f'Airbnb session expired (HTTP {r.status_code}) — run: {_AUTH_HINT}')
  155. r.raise_for_status()
  156. body = r.json()
  157. self._check_auth(body)
  158. return body
  159. async def _mutate(self, op: str, h: str, variables: dict, referer: str) -> dict:
  160. url = f'{_BASE}/{op}/{h}?operationName={op}&locale=en&currency=USD'
  161. payload = {'operationName': op, 'variables': variables,
  162. 'extensions': {'persistedQuery': {'version': 1, 'sha256Hash': h}}}
  163. async def _attempt() -> httpx.Response:
  164. async with httpx.AsyncClient() as client:
  165. return await client.post(
  166. url, json=payload,
  167. headers=self._headers(referer, {'Origin': 'https://www.airbnb.com'}),
  168. )
  169. try:
  170. r = await _attempt()
  171. except (httpx.TransportError, OSError, ExceptionGroup) as e:
  172. raise AirbnbAuthError(f'Airbnb connection failed — session may be expired, run: {_AUTH_HINT}') from e
  173. if r.status_code in (400, 401, 403):
  174. await self._ensure_refreshed()
  175. r = await _attempt()
  176. if r.status_code in (400, 401, 403):
  177. mark_stale('airbnb')
  178. raise AirbnbAuthError(f'Airbnb session expired (HTTP {r.status_code}) — run: {_AUTH_HINT}')
  179. r.raise_for_status()
  180. body = r.json()
  181. self._check_auth(body)
  182. return body
  183. async def _fetch_month_days(self, month: int, year: int) -> list[dict]:
  184. start, end = month_bounds(month, year)
  185. variables = {
  186. 'listingId': self._global_id,
  187. 'startDate': start,
  188. 'endDate': end,
  189. 'hostCalendarViewType': 'MONTH_VIEW',
  190. 'lensTypes': ['NOTE', 'PRICE', 'PROMOTION'],
  191. 'timeZone': self._tz,
  192. 'localizationContext': {'timeZone': self._tz, 'firstDayOfWeek': 0},
  193. 'hostCalendarSectionTypes': ['MAIN_VIEW'],
  194. 'readConsistency': 'EVENTUAL',
  195. }
  196. referer = f'https://www.airbnb.com/multicalendar/{self._listing_id}'
  197. def _extract_days(resp: dict) -> list[dict]:
  198. section = (
  199. resp.get('data', {}).get('presentation', {})
  200. .get('hostCalendar', {}).get('sections', {})
  201. .get('calendarGridViewSection')
  202. ) or {}
  203. return section.get('days', [])
  204. def _has_prices(days: list[dict]) -> bool:
  205. return any(d.get('priceData', {}).get('price') is not None for d in days)
  206. resp = await self._gql('getDLSHostCalendar', _HASHES['getDLSHostCalendar'], variables, referer)
  207. days = _extract_days(resp)
  208. if not days or not _has_prices(days):
  209. _log.warning('Airbnb: empty or priceless calendar for %d-%02d; retrying', year, month)
  210. await asyncio.sleep(1)
  211. resp = await self._gql('getDLSHostCalendar', _HASHES['getDLSHostCalendar'], variables, referer)
  212. days = _extract_days(resp)
  213. if not days or not _has_prices(days):
  214. _log.warning('Airbnb: still empty/priceless calendar for %d-%02d after retry', year, month)
  215. return days
  216. async def _get_month_days(self, month: int, year: int) -> list[dict]:
  217. key = (month, year)
  218. if key in self._raw_months:
  219. return self._raw_months[key]
  220. if key in self._months_inflight:
  221. return await self._months_inflight[key]
  222. task = asyncio.create_task(self._fetch_month_days(month, year))
  223. self._months_inflight[key] = task
  224. try:
  225. result = await task
  226. finally:
  227. self._months_inflight.pop(key, None)
  228. self._raw_months[key] = result
  229. return result
  230. async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
  231. if is_stale('airbnb'):
  232. raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
  233. month_list = months_in_range(date_from, date_to)
  234. all_days = await asyncio.gather(*[self._get_month_days(m, y) for m, y in month_list])
  235. for (month, year), days in zip(month_list, all_days):
  236. if (month, year) not in self._fetched_months:
  237. for day in days:
  238. d = day.get('day', '')
  239. if d:
  240. self._cache[d] = DayInfo(
  241. date=d,
  242. price=day.get('priceData', {}).get('price'),
  243. available=day.get('available', False),
  244. booked=day.get('reservationData') is not None,
  245. rooms_to_sell=None,
  246. )
  247. self._fetched_months.add((month, year))
  248. return {d: info for d, info in self._cache.items() if date_from <= d <= date_to}
  249. async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
  250. if is_stale('airbnb'):
  251. raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
  252. month_list = months_in_range(date_from, date_to)
  253. all_days = await asyncio.gather(*[self._get_month_days(m, y) for m, y in month_list])
  254. booked: dict[str, str] = {}
  255. for days in all_days:
  256. for day in days:
  257. d = day.get('day', '')
  258. res_data = day.get('reservationData')
  259. if res_data and date_from <= d <= date_to:
  260. booked[d] = res_data.get('confirmationCode', '')
  261. if not booked:
  262. return []
  263. unique_codes = set(booked.values())
  264. def _utc_to_local_date(ts: str) -> str:
  265. dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
  266. return dt.astimezone(ZoneInfo(self._tz)).date().isoformat()
  267. async def fetch_details(c: str) -> tuple[str, str | None, str | None, str | None, str | None, str | None]:
  268. resp = await self._gql(
  269. 'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
  270. {'viewerTimeZoneOffset': 0, 'requestSource': 'RESERVATION_TAB', 'confirmationCode': c},
  271. f'https://www.airbnb.com/hosting/reservations/details/{c}',
  272. )
  273. sections = (
  274. resp.get('data', {}).get('presentation', {})
  275. .get('hostingDetails', {}).get('stayHostingDetails', {})
  276. .get('rootPlacement', [])
  277. )
  278. facepile = next((s for s in sections if s.get('sectionId') == 'FACEPILE_SECTION'), None)
  279. guests = (facepile or {}).get('sectionData', {}).get('guests', [])
  280. name = guests[0].get('name') if guests else None
  281. checkinout = next((s for s in sections if s.get('sectionId') == 'CHECK_IN_CHECK_OUT_SECTION'), None)
  282. ci_out_data = (checkinout or {}).get('sectionData') or {}
  283. action = ci_out_data.get('checkInAction', {})
  284. ci_ts = action.get('checkInTime')
  285. co_ts = action.get('checkOutTime')
  286. ci = _utc_to_local_date(ci_ts) if ci_ts else None
  287. co = _utc_to_local_date(co_ts) if co_ts else None
  288. door_code = (ci_out_data.get('airkeyAction') or {}).get('guestPhoneNumberLastFourDigits')
  289. # thread_id is in footerPlacement FLOATINGFOOTER_SECTION, not rootPlacement
  290. details = resp.get('data', {}).get('presentation', {}).get('hostingDetails', {}).get('stayHostingDetails', {})
  291. footer_sections = details.get('footerPlacement', [])
  292. footer = next((s for s in footer_sections if s.get('sectionId') == 'FLOATINGFOOTER_SECTION'), None)
  293. buttons = ((footer or {}).get('sectionData') or {}).get('buttons', [])
  294. thread_id = None
  295. for btn in buttons:
  296. action = btn.get('buttonAction') or {}
  297. if action.get('__typename') == 'NavigateToMessageGuest':
  298. url = action.get('url', '')
  299. m = __import__('re').search(r'/thread/(\d+)', url)
  300. if m:
  301. thread_id = m.group(1)
  302. break
  303. return c, name, ci, co, door_code, thread_id
  304. details_list = await asyncio.gather(*[fetch_details(c) for c in unique_codes])
  305. details = {c: (name, ci, co, door_code, thread_id) for c, name, ci, co, door_code, thread_id in details_list}
  306. return sorted(
  307. [
  308. Reservation(
  309. service='airbnb',
  310. reservation_id=c,
  311. check_in=ci,
  312. check_out=co,
  313. guest_name=name,
  314. door_code=door_code,
  315. thread_id=thread_id,
  316. service_data={k: v for k, v in {'door_code': door_code, 'thread_id': thread_id}.items() if v is not None},
  317. chat_id=thread_id,
  318. )
  319. for c, (name, ci, co, door_code, thread_id) in details.items()
  320. if ci and co
  321. ],
  322. key=lambda r: r.check_in,
  323. )
  324. async def set_price(self, d: str, price: int) -> None:
  325. price = int(round(price))
  326. if is_stale('airbnb'):
  327. raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
  328. if (cached := self._cache.get(d)) and cached.price == price:
  329. return
  330. await self._mutate(
  331. 'EditPanelPricingSettingsMutation', _HASHES['EditPanelPricingSettingsMutation'],
  332. {'input': {
  333. 'listingId': self._listing_id,
  334. 'selectedDateRanges': [{'startDate': d, 'endDate': d}],
  335. 'nightlyPriceAmount': price,
  336. 'turnOnSmartPricing': False,
  337. 'isCalendarV2': True,
  338. }},
  339. f'https://www.airbnb.com/multicalendar/{self._listing_id}/edit-selected-dates/{d}/{d}/nightly-price',
  340. )
  341. self.clear_cache()
  342. _log.info('airbnb set_price date=%s price=$%d', d, price)
  343. async def set_availability(self, d: str, available: bool) -> None:
  344. if is_stale('airbnb'):
  345. raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
  346. if (cached := self._cache.get(d)) and cached.available == available:
  347. return
  348. body = await self._mutate(
  349. 'EditPanelAvailabilityMutation', _HASHES['EditPanelAvailabilityMutation'],
  350. {'input': {
  351. 'listingId': self._listing_id,
  352. 'selectedDateRanges': [{'startDate': d, 'endDate': d}],
  353. 'actionType': 'OPEN' if available else 'BLOCK',
  354. }, 'blockNotesEnabled': True},
  355. f'https://www.airbnb.com/multicalendar/{self._listing_id}/edit-selected-dates/{d}/{d}',
  356. )
  357. if errors := body.get('errors'):
  358. _log.error('airbnb set_availability date=%s errors=%s', d, errors)
  359. raise AirbnbAuthError(f'Airbnb set_availability failed for {d}: {errors[0].get("message", errors)}')
  360. self.clear_cache()
  361. _log.info('airbnb set_availability date=%s available=%s', d, available)
  362. def _global_thread_id(self, thread_id: str) -> str:
  363. return base64.b64encode(f'MessageThread:{thread_id}'.encode()).decode()
  364. def send_ready(self) -> bool:
  365. return True
  366. async def get_thread(self, thread_id: str) -> AirbnbThread:
  367. """Fetch a conversation thread and its messages by numeric thread ID."""
  368. if is_stale('airbnb'):
  369. raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
  370. resp = await self._gql(
  371. 'ViaductGetThreadAndDataQuery', _HASHES['ViaductGetThreadAndDataQuery'],
  372. {
  373. 'globalThreadId': self._global_thread_id(thread_id),
  374. 'numRequestedMessages': 50,
  375. 'getThreadState': True,
  376. 'getParticipants': True,
  377. 'getLastReads': True,
  378. 'getInboxFields': True,
  379. 'getInboxOnlyFields': False,
  380. 'getMessageFields': True,
  381. 'getThreadOnlyFields': True,
  382. 'forceUgcTranslation': False,
  383. 'isNovaLite': False,
  384. 'mockThreadIdentifier': None,
  385. 'mockMessageTestIdentifier': None,
  386. 'mockListFooterSlot': None,
  387. 'forceReturnAllReadReceipts': False,
  388. 'originType': 'USER_INBOX',
  389. 'skipOldMessagePreviewFields': False,
  390. },
  391. f'https://www.airbnb.com/messages/{thread_id}',
  392. )
  393. raw_messages = (
  394. resp.get('data', {}).get('threadData', {})
  395. .get('messageData', {}).get('messages', [])
  396. )
  397. messages = [
  398. AirbnbMessage(
  399. id=m['id'],
  400. created_at_ms=m['createdAtMs'],
  401. body=(((m.get('hydratedContent') or {}).get('content') or {}).get('body')),
  402. body_translated=(((m.get('hydratedContent') or {}).get('content') or {}).get('bodyTranslated')),
  403. sender_account_id=(m.get('account') or {}).get('accountId', ''),
  404. )
  405. for m in raw_messages
  406. ]
  407. return AirbnbThread(thread_id=thread_id, messages=messages)
  408. async def send_message(self, thread_id: str, body: str) -> None:
  409. """Send a text message to an existing thread."""
  410. if is_stale('airbnb'):
  411. raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
  412. await self._mutate(
  413. 'CreateBulkMessagesMutation', _HASHES['CreateBulkMessagesMutation'],
  414. {
  415. 'messageThreadId': thread_id,
  416. 'messages': [{
  417. 'content': {'textContent': {'body': body}},
  418. 'uniqueIdentifier': str(uuid.uuid4()),
  419. 'loggingSource': {'sourceType': '', 'sourceId': ''},
  420. 'loggingExtras': {'entries': []},
  421. }],
  422. 'actAs': 'PARTICIPANT',
  423. 'businessJustification': {'feature': 'USER_INBOX'},
  424. 'originType': 'USER_INBOX',
  425. 'senderPlatform': 'WEB',
  426. 'skipOldMessagePreviewFields': False,
  427. },
  428. f'https://www.airbnb.com/messages/{thread_id}',
  429. )
  430. async def get_suggested_prices(self, date_from: str, date_to: str) -> dict[str, int]:
  431. """Airbnb's per-day smart-pricing suggestions (USD). Only includes days where shouldShowTip=True."""
  432. if is_stale('airbnb'):
  433. raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
  434. resp = await self._gql(
  435. 'getNightlyPriceTips', _HASHES['getNightlyPriceTips'],
  436. {
  437. 'listingId': self._global_id,
  438. 'dateRanges': [{'startDate': date_from, 'endDate': date_to}],
  439. },
  440. f'https://www.airbnb.com/multicalendar/{self._listing_id}',
  441. )
  442. tips = (
  443. resp.get('data', {}).get('presentation', {})
  444. .get('pricingGuidance', {}).get('nightlyPriceTip', {})
  445. .get('nightlyPriceTip', {}).get('discretePriceTips', [])
  446. )
  447. return {
  448. t['date']: t['priceTip']['intValue']
  449. for t in tips
  450. if t.get('shouldShowTip') and date_from <= t['date'] <= date_to
  451. }
  452. async def get_service_data(self, date_from: str, date_to: str) -> AirbnbData:
  453. suggested_prices, calendar_events = await asyncio.gather(
  454. self.get_suggested_prices(date_from, date_to),
  455. self.get_calendar_events(),
  456. )
  457. return AirbnbData(suggested_prices=suggested_prices, calendar_events=calendar_events)
  458. async def probe(self) -> None:
  459. from datetime import date
  460. today = date.today().isoformat()
  461. await self.get_calendar(today, today)
  462. async def get_calendar_events(self) -> list[CalendarEvent]:
  463. """Local events and holidays Airbnb shows on the host calendar (holidays, major events, etc.)."""
  464. if is_stale('airbnb'):
  465. raise AirbnbAuthError(f'Airbnb credentials are stale — run: {_AUTH_HINT}')
  466. resp = await self._gql(
  467. 'HostCalendarListingEventsQuery', _HASHES['HostCalendarListingEventsQuery'],
  468. {'listingId': self._global_id},
  469. f'https://www.airbnb.com/multicalendar/{self._listing_id}',
  470. )
  471. events = (
  472. resp.get('data', {}).get('presentation', {})
  473. .get('hostCalendarListingEvents', {}).get('events', [])
  474. )
  475. return [
  476. CalendarEvent(
  477. start_date=e['startDate'],
  478. end_date=e['endDate'],
  479. name=e['displayName'],
  480. type=e['type'],
  481. event_id=e['eventId'],
  482. )
  483. for e in events
  484. ]