_airbnb.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652
  1. import asyncio
  2. import base64
  3. import json
  4. import random
  5. import re
  6. import string
  7. import uuid
  8. from datetime import date, datetime, timedelta
  9. from zoneinfo import ZoneInfo
  10. import httpx
  11. from dataclasses import dataclass
  12. import logging
  13. from ._scripts import run_refresh_script
  14. from room_cal._utils import get_config
  15. from ._base import ServiceClientBase
  16. from ._month_cache import MonthCache, TTLCache
  17. from ._registry import register_service
  18. from ._utils import month_bounds, months_in_range
  19. from .types import CalendarEvent, DayInfo, ServiceAuthError, Reservation
  20. _log = logging.getLogger(__name__)
  21. @dataclass
  22. class AirbnbMessage:
  23. id: str # global ID (base64)
  24. created_at_ms: str
  25. body: str | None # None for non-text messages (photos, system events, etc.)
  26. body_translated: str | None # Airbnb AI translation; None when not provided
  27. sender_account_id: str
  28. sender_name: str | None = None # resolved from thread participants; None only if resolution failed
  29. @dataclass
  30. class AirbnbThread:
  31. thread_id: str # numeric string, matches Reservation.thread_id
  32. messages: list[AirbnbMessage]
  33. @dataclass
  34. class AirbnbData:
  35. suggested_prices: dict[str, int] # date → Airbnb smart-pricing suggestion (USD)
  36. calendar_events: list[CalendarEvent] # holidays and local demand events
  37. _BASE = 'https://www.airbnb.com/api/v3'
  38. _API_KEY = 'd306zoyjsyarp7ifhu67rjxn52tv0t20'
  39. _CLIENT_VERSION = '4e93a4878415297ab95d22665c666b6602dcfcbb'
  40. _HASHES = {
  41. 'getDLSHostCalendar': '048e22675ffa53121628d2d07b519b9b71f5dab342a1ceb75aa72d8814a00ea7',
  42. 'EditPanelPricingSettingsMutation': 'e27fed4f08dd4ab4c0267c5d6c0a7e4920730deadf48f92feba9b0d08befafa4',
  43. 'EditPanelAvailabilityMutation': '2e056e7f98f8dc410a294c649adf719452980eb4d8ea95540fb1297af2637f67',
  44. 'StayHostingDetailsQuery': '6da83b8bc9ae1b434f13938a55435118347b0e8ceb838e04c1feb84ecb587e6d',
  45. 'getNightlyPriceTips': '07538bf1a5afc8171f1e7443f0007d7f020142fc7ab60df065e7e1edd1a0b29f',
  46. 'HostCalendarListingEventsQuery': '8bfe5f5572a25fdaff5b5790604e1de3aec5f37731fc505684022d590d06e0a8',
  47. 'ViaductGetThreadAndDataQuery': 'dd7478b376ad0a9709c7201b7e43cf402df011ec9d2fe4c1dff2ac418bcd9fb3',
  48. 'CreateBulkMessagesMutation': '94ac2c4bd07edace539dbf2b9665d9030b6dee479db345ba8a8bbc234b3bbfa3',
  49. }
  50. def _parse_price(s: str) -> float | None:
  51. """Parse a formatted price string like '$143.56' → 143.56, or None on failure."""
  52. try:
  53. return float(s.replace('$', '').replace(',', '').strip())
  54. except (ValueError, AttributeError):
  55. return None
  56. def _parse_booked_nets(det: dict, check_in: str, check_out: str) -> dict[str, int]:
  57. """Return {YYYY-MM-DD: net_dollars} for each night of a reservation.
  58. Uses proportional apportionment: net_i = total_host_net * (list_i / sum(list)).
  59. Reads PAYMENTINFO_SECTION from stayHostingDetails (rootPlacement).
  60. Returns {} if the section is missing or the data is malformed.
  61. """
  62. sections = det.get('rootPlacement', [])
  63. pay = next((s for s in sections if s.get('sectionId') == 'PAYMENTINFO_SECTION'), None)
  64. if not pay:
  65. return {}
  66. eg = (pay.get('sectionData') or {}).get('hostEarningsGroup', {})
  67. total_net = _parse_price((eg.get('totalLineItem') or {}).get('formattedValue', ''))
  68. if total_net is None:
  69. return {}
  70. nights_item = next(
  71. (li for li in eg.get('lineItems', []) if (li.get('collapsibleLineItemGroup') or {}).get('collapsedLineItems')),
  72. None,
  73. )
  74. if not nights_item:
  75. return {}
  76. weights = [
  77. w for item in nights_item['collapsibleLineItemGroup']['collapsedLineItems']
  78. if (w := _parse_price(item.get('formattedValue', ''))) is not None
  79. ]
  80. ci = date.fromisoformat(check_in)
  81. co = date.fromisoformat(check_out)
  82. nights = [(ci + timedelta(days=i)).isoformat() for i in range((co - ci).days)]
  83. if not weights or len(nights) != len(weights):
  84. return {}
  85. total_weight = sum(weights)
  86. if total_weight == 0:
  87. return {}
  88. return {d: round(total_net * w / total_weight) for d, w in zip(nights, weights)}
  89. class AirbnbAuthError(ServiceAuthError):
  90. pass
  91. def _encode_listing_id(listing_id: str) -> str:
  92. return base64.b64encode(f'StayListing:{listing_id}'.encode()).decode()
  93. def _trace_id() -> str:
  94. return ''.join(random.choices(string.ascii_lowercase + string.digits, k=28))
  95. @register_service('airbnb')
  96. class _AirbnbClient(ServiceClientBase):
  97. service_name = 'AirBnB'
  98. auth_error_cls = AirbnbAuthError
  99. def __init__(self, cookies: str, listing_id: str, tz: str = 'America/Los_Angeles'):
  100. super().__init__()
  101. self._cookies = cookies
  102. self._listing_id = listing_id
  103. self._global_id = _encode_listing_id(listing_id)
  104. self._tz = tz
  105. # Tracks months where we've seen at least one booked day; survives clear_cache() so we can
  106. # detect when a freshly-fetched month drops from N booked days to 0 (stale replica signal).
  107. self._had_bookings: set[tuple[int, int]] = set()
  108. self._months: MonthCache[list[dict]] = MonthCache(
  109. lambda m, y: self._fetch_month_days(m, y),
  110. suspect=self._suspect_days,
  111. revalidate=self._revalidate_month,
  112. on_fetch=self._note_bookings,
  113. on_evict=self._evict_derived,
  114. label='Airbnb',
  115. log=_log,
  116. )
  117. self._details: TTLCache[dict] = TTLCache()
  118. self._http = httpx.AsyncClient(timeout=10.0)
  119. self._booked_nets: dict[str, int] = {}
  120. @classmethod
  121. def from_env(cls) -> '_AirbnbClient':
  122. env = cls._read_env()
  123. cls._require_env(env, 'AIRBNB_COOKIES')
  124. listing_id = get_config().services['airbnb'].room_id
  125. if not listing_id:
  126. raise ValueError('airbnb.room_id must be set in config.json')
  127. return cls(cookies=env['AIRBNB_COOKIES'], listing_id=listing_id)
  128. def clear_cache(self) -> None:
  129. super().clear_cache()
  130. self._details.clear()
  131. self._booked_nets.clear()
  132. def _apply_env(self, env: dict[str, str]) -> None:
  133. self._cookies = env.get('AIRBNB_COOKIES', self._cookies)
  134. @staticmethod
  135. def refresh_credentials() -> None:
  136. run_refresh_script('refresh-airbnb', service_name='Airbnb',
  137. auth_error_cls=AirbnbAuthError, extra_env={'HEADLESS': '1'})
  138. def _headers(self, referer: str, extra: dict | None = None) -> dict[str, str]:
  139. h = {
  140. 'Cookie': self._cookies,
  141. 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0',
  142. 'Accept': '*/*',
  143. 'Accept-Language': 'en-US,en;q=0.9',
  144. 'Content-Type': 'application/json',
  145. 'Referer': referer,
  146. 'Sec-Fetch-Mode': 'cors',
  147. 'Sec-Fetch-Dest': 'empty',
  148. 'Sec-Fetch-Site': 'same-origin',
  149. 'X-Airbnb-API-Key': _API_KEY,
  150. 'X-Airbnb-GraphQL-Platform': 'web',
  151. 'X-Airbnb-GraphQL-Platform-Client': 'minimalist-niobe',
  152. 'X-Airbnb-Supports-Airlock-V2': 'true',
  153. 'X-Niobe-Short-Circuited': 'true',
  154. 'X-Client-Version': _CLIENT_VERSION,
  155. 'X-CSRF-Token': '',
  156. 'X-CSRF-Without-Token': '1',
  157. 'x-airbnb-client-trace-id': _trace_id(),
  158. 'x-client-request-id': _trace_id(),
  159. 'x-airbnb-network-log-link': _trace_id(),
  160. }
  161. if extra:
  162. h.update(extra)
  163. return h
  164. def _auth_shaped(self, r: httpx.Response) -> bool:
  165. # Airbnb also signals session expiry as GQL errors on HTTP 200; route those
  166. # through the base's refresh → retry-once → stale sequence too.
  167. if super()._auth_shaped(r):
  168. return True
  169. if r.status_code != 200:
  170. return False
  171. try:
  172. body = r.json()
  173. except ValueError:
  174. return False
  175. return any(
  176. e.get('extensions', {}).get('response', {}).get('status') in (400, 401, 403)
  177. for e in body.get('errors', [])
  178. )
  179. async def _gql(self, op: str, h: str, variables: dict, referer: str) -> dict:
  180. url = f'{_BASE}/{op}/{h}'
  181. params = {
  182. 'operationName': op,
  183. 'locale': 'en',
  184. 'currency': 'USD',
  185. 'variables': json.dumps(variables),
  186. 'extensions': json.dumps({'persistedQuery': {'version': 1, 'sha256Hash': h}}),
  187. }
  188. async def _attempt() -> httpx.Response:
  189. return await self._http.get(url, params=params, headers=self._headers(referer))
  190. r = await self._request(_attempt, op=op)
  191. return r.json()
  192. async def _mutate(self, op: str, h: str, variables: dict, referer: str) -> dict:
  193. url = f'{_BASE}/{op}/{h}?operationName={op}&locale=en&currency=USD'
  194. payload = {'operationName': op, 'variables': variables,
  195. 'extensions': {'persistedQuery': {'version': 1, 'sha256Hash': h}}}
  196. async def _attempt() -> httpx.Response:
  197. return await self._http.post(
  198. url, json=payload,
  199. headers=self._headers(referer, {'Origin': 'https://www.airbnb.com'}),
  200. )
  201. r = await self._request(_attempt, op=op)
  202. return r.json()
  203. async def _fetch_month_days(self, month: int, year: int) -> list[dict]:
  204. start, end = month_bounds(month, year)
  205. variables = {
  206. 'listingId': self._global_id,
  207. 'startDate': start,
  208. 'endDate': end,
  209. 'hostCalendarViewType': 'MONTH_VIEW',
  210. 'lensTypes': ['NOTE', 'PRICE', 'PROMOTION'],
  211. 'timeZone': self._tz,
  212. 'localizationContext': {'timeZone': self._tz, 'firstDayOfWeek': 0},
  213. 'hostCalendarSectionTypes': ['MAIN_VIEW'],
  214. 'readConsistency': 'EVENTUAL',
  215. }
  216. referer = f'https://www.airbnb.com/multicalendar/{self._listing_id}'
  217. resp = await self._gql('getDLSHostCalendar', _HASHES['getDLSHostCalendar'], variables, referer)
  218. section = (
  219. resp.get('data', {}).get('presentation', {})
  220. .get('hostCalendar', {}).get('sections', {})
  221. .get('calendarGridViewSection')
  222. ) or {}
  223. return section.get('days', [])
  224. def _suspect_days(self, key: tuple[int, int], days: list[dict]) -> bool:
  225. return not days or not any(d.get('priceData', {}).get('price') is not None for d in days)
  226. def _revalidate_month(self, key: tuple[int, int], cached: list[dict]) -> bool:
  227. # If we've seen bookings for this month before but the cache now shows none,
  228. # the data is likely from a stale eventual-consistency replica — force a refetch.
  229. if key in self._had_bookings and not any(d.get('reservationData') for d in cached):
  230. month, year = key
  231. _log.warning(
  232. 'Airbnb: %d-%02d cache shows 0 booked days but month had bookings; forcing refetch',
  233. year, month,
  234. )
  235. return False
  236. return True
  237. def _note_bookings(self, key: tuple[int, int], result: list[dict], revalidation_failed: bool) -> None:
  238. if any(d.get('reservationData') for d in result):
  239. self._had_bookings.add(key)
  240. elif revalidation_failed:
  241. # Forced refetch confirmed no bookings — stop guarding this month
  242. self._had_bookings.discard(key)
  243. def _month_day_infos(self, days: list[dict]) -> dict[str, DayInfo]:
  244. return {
  245. d: DayInfo(
  246. date=d,
  247. price=day.get('priceData', {}).get('price'),
  248. available=day.get('available', False),
  249. booked=day.get('reservationData') is not None,
  250. )
  251. for day in days
  252. if (d := day.get('day', ''))
  253. }
  254. async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
  255. self._check_fresh()
  256. month_list = months_in_range(date_from, date_to)
  257. all_days = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
  258. booked: dict[str, str] = {}
  259. for days in all_days:
  260. for day in days:
  261. d = day.get('day', '')
  262. res_data = day.get('reservationData')
  263. if res_data and date_from <= d <= date_to:
  264. booked[d] = res_data.get('confirmationCode', '')
  265. if not booked:
  266. return []
  267. unique_codes = set(booked.values())
  268. _sem = asyncio.Semaphore(10)
  269. async def fetch_details(c: str) -> tuple[str, str | None, str | None, str | None, str | None, str | None]:
  270. async with _sem:
  271. det = await self._fetch_one_detail(c)
  272. sections = det.get('rootPlacement', [])
  273. whoscoming = next((s for s in sections if s.get('sectionId') == 'WHOSCOMING_SECTION'), None)
  274. wc_groups = (whoscoming or {}).get('sectionData', {}).get('reservationGroups', [])
  275. wc_guests = wc_groups[0].get('guests', []) if wc_groups else []
  276. facepile = next((s for s in sections if s.get('sectionId') == 'FACEPILE_SECTION'), None)
  277. fp_guests = (facepile or {}).get('sectionData', {}).get('guests', [])
  278. name = (wc_guests[0].get('name') if wc_guests else None) or (fp_guests[0].get('name') if fp_guests else None)
  279. checkinout = next((s for s in sections if s.get('sectionId') == 'CHECK_IN_CHECK_OUT_SECTION'), None)
  280. ci_out_data = (checkinout or {}).get('sectionData') or {}
  281. action = ci_out_data.get('checkInAction', {})
  282. ci_ts = action.get('checkInTime')
  283. co_ts = action.get('checkOutTime')
  284. ci = self._to_local_date(ci_ts) if ci_ts else None
  285. co = self._to_local_date(co_ts) if co_ts else None
  286. door_code = (ci_out_data.get('airkeyAction') or {}).get('guestPhoneNumberLastFourDigits')
  287. # thread_id is in footerPlacement FLOATINGFOOTER_SECTION, not rootPlacement
  288. footer_sections = det.get('footerPlacement', [])
  289. footer = next((s for s in footer_sections if s.get('sectionId') == 'FLOATINGFOOTER_SECTION'), None)
  290. buttons = ((footer or {}).get('sectionData') or {}).get('buttons', [])
  291. thread_id = None
  292. for btn in buttons:
  293. action = btn.get('buttonAction') or {}
  294. if action.get('__typename') == 'NavigateToMessageGuest':
  295. url = action.get('url', '')
  296. m = re.search(r'/thread/(\d+)', url)
  297. if m:
  298. thread_id = m.group(1)
  299. break
  300. if ci and co:
  301. self._booked_nets.update(_parse_booked_nets(det, ci, co))
  302. return c, name, ci, co, door_code, thread_id
  303. details_list = await asyncio.gather(*[fetch_details(c) for c in unique_codes])
  304. details = {c: (name, ci, co, door_code, thread_id) for c, name, ci, co, door_code, thread_id in details_list}
  305. return sorted(
  306. [
  307. Reservation(
  308. service='airbnb',
  309. reservation_id=c,
  310. check_in=ci,
  311. check_out=co,
  312. guest_name=name,
  313. door_code=door_code,
  314. thread_id=thread_id,
  315. service_data={k: v for k, v in {'door_code': door_code, 'thread_id': thread_id}.items() if v is not None},
  316. chat_id=thread_id,
  317. )
  318. for c, (name, ci, co, door_code, thread_id) in details.items()
  319. if ci and co
  320. ],
  321. key=lambda r: r.check_in,
  322. )
  323. async def get_booked_nets(self, date_from: str, date_to: str) -> dict[str, int | None]:
  324. """Return {date: net_dollars} for Airbnb-booked dates in range.
  325. Fetches StayHostingDetailsQuery for each reservation and apportions total
  326. host earnings proportionally across nights by per-night list price weight.
  327. Results are cached in _booked_nets; repeated calls (e.g. after get_reservations)
  328. hit the detail cache and make no additional network calls.
  329. """
  330. self._check_fresh()
  331. month_list = months_in_range(date_from, date_to)
  332. all_days = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
  333. booked_codes: dict[str, str] = {} # date → confirmation code
  334. for days in all_days:
  335. for day in days:
  336. d = day.get('day', '')
  337. if d and date_from <= d <= date_to:
  338. code = (day.get('reservationData') or {}).get('confirmationCode', '')
  339. if code:
  340. booked_codes[d] = code
  341. if not booked_codes:
  342. return {}
  343. _sem = asyncio.Semaphore(10)
  344. async def fetch_nets(code: str) -> None:
  345. async with _sem:
  346. det = await self._fetch_one_detail(code)
  347. sections = det.get('rootPlacement', [])
  348. checkinout = next((s for s in sections if s.get('sectionId') == 'CHECK_IN_CHECK_OUT_SECTION'), None)
  349. ci_out_data = (checkinout or {}).get('sectionData') or {}
  350. action = ci_out_data.get('checkInAction', {})
  351. ci_ts = action.get('checkInTime')
  352. co_ts = action.get('checkOutTime')
  353. if ci_ts and co_ts:
  354. ci = self._to_local_date(ci_ts)
  355. co = self._to_local_date(co_ts)
  356. self._booked_nets.update(_parse_booked_nets(det, ci, co))
  357. await asyncio.gather(*[fetch_nets(code) for code in set(booked_codes.values())])
  358. return {d: self._booked_nets.get(d) for d in booked_codes} # int | None per date
  359. async def set_price(self, d: str, price: int) -> None:
  360. price = int(round(price))
  361. self._check_fresh()
  362. if (cached := self._cache.get(d)) and cached.price == price:
  363. return
  364. await self._mutate(
  365. 'EditPanelPricingSettingsMutation', _HASHES['EditPanelPricingSettingsMutation'],
  366. {'input': {
  367. 'listingId': self._listing_id,
  368. 'selectedDateRanges': [{'startDate': d, 'endDate': d}],
  369. 'nightlyPriceAmount': price,
  370. 'turnOnSmartPricing': False,
  371. 'isCalendarV2': True,
  372. }},
  373. f'https://www.airbnb.com/multicalendar/{self._listing_id}/edit-selected-dates/{d}/{d}/nightly-price',
  374. )
  375. self.clear_cache()
  376. _log.info('airbnb set_price date=%s price=$%d', d, price)
  377. async def set_availability(self, d: str, available: bool) -> None:
  378. self._check_fresh()
  379. if (cached := self._cache.get(d)) and cached.available == available:
  380. return
  381. body = await self._mutate(
  382. 'EditPanelAvailabilityMutation', _HASHES['EditPanelAvailabilityMutation'],
  383. {'input': {
  384. 'listingId': self._listing_id,
  385. 'selectedDateRanges': [{'startDate': d, 'endDate': d}],
  386. 'actionType': 'OPEN' if available else 'BLOCK',
  387. }, 'blockNotesEnabled': True},
  388. f'https://www.airbnb.com/multicalendar/{self._listing_id}/edit-selected-dates/{d}/{d}',
  389. )
  390. if errors := body.get('errors'):
  391. _log.error('airbnb set_availability date=%s errors=%s', d, errors)
  392. raise AirbnbAuthError(f'Airbnb set_availability failed for {d}: {errors[0].get("message", errors)}')
  393. self.clear_cache()
  394. _log.info('airbnb set_availability date=%s available=%s', d, available)
  395. def _to_local_date(self, ts: str) -> str:
  396. dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
  397. return dt.astimezone(ZoneInfo(self._tz)).date().isoformat()
  398. async def _fetch_one_detail(self, code: str) -> dict:
  399. if (cached := self._details.get(code)) is not None:
  400. return cached
  401. try:
  402. resp = await self._gql(
  403. 'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
  404. {'viewerTimeZoneOffset': 0, 'requestSource': 'RESERVATION_TAB', 'confirmationCode': code},
  405. f'https://www.airbnb.com/hosting/reservations/details/{code}',
  406. )
  407. except httpx.TimeoutException:
  408. _log.warning('airbnb: StayHostingDetailsQuery timed out for %s — skipping', code)
  409. return {}
  410. det = (
  411. resp.get('data', {}).get('presentation', {})
  412. .get('hostingDetails', {}).get('stayHostingDetails', {})
  413. )
  414. self._details.set(code, det)
  415. return det
  416. def _global_thread_id(self, thread_id: str) -> str:
  417. return base64.b64encode(f'MessageThread:{thread_id}'.encode()).decode()
  418. async def get_thread(self, thread_id: str) -> AirbnbThread:
  419. """Fetch a conversation thread and its messages by numeric thread ID."""
  420. self._check_fresh()
  421. resp = await self._gql(
  422. 'ViaductGetThreadAndDataQuery', _HASHES['ViaductGetThreadAndDataQuery'],
  423. {
  424. 'globalThreadId': self._global_thread_id(thread_id),
  425. 'numRequestedMessages': 50,
  426. 'getThreadState': True,
  427. 'getParticipants': True,
  428. 'getLastReads': True,
  429. 'getInboxFields': True,
  430. 'getInboxOnlyFields': False,
  431. 'getMessageFields': True,
  432. 'getThreadOnlyFields': True,
  433. 'forceUgcTranslation': False,
  434. 'isNovaLite': False,
  435. 'mockThreadIdentifier': None,
  436. 'mockMessageTestIdentifier': None,
  437. 'mockListFooterSlot': None,
  438. 'forceReturnAllReadReceipts': False,
  439. 'originType': 'USER_INBOX',
  440. 'skipOldMessagePreviewFields': False,
  441. },
  442. f'https://www.airbnb.com/messages/{thread_id}',
  443. )
  444. thread_data = resp.get('data', {}).get('threadData', {})
  445. participants: dict[str, str] = {}
  446. for edge in thread_data.get('participants', {}).get('edges', []):
  447. node = edge.get('node', {})
  448. account_id = node.get('accountId', '')
  449. name = (node.get('enrichedParticipantInfo') or {}).get('name', '')
  450. if account_id and name:
  451. participants[account_id] = name
  452. messages = []
  453. for m in thread_data.get('messageData', {}).get('messages', []):
  454. aid = (m.get('account') or {}).get('accountId', '')
  455. messages.append(AirbnbMessage(
  456. id=m['id'],
  457. created_at_ms=m['createdAtMs'],
  458. body=(((m.get('hydratedContent') or {}).get('content') or {}).get('body')),
  459. body_translated=(((m.get('hydratedContent') or {}).get('content') or {}).get('bodyTranslated')),
  460. sender_account_id=aid,
  461. sender_name=participants.get(aid, 'Airbnb'),
  462. ))
  463. return AirbnbThread(thread_id=thread_id, messages=messages)
  464. async def send_message(self, thread_id: str, body: str) -> None:
  465. """Send a text message to an existing thread."""
  466. self._check_fresh()
  467. await self._mutate(
  468. 'CreateBulkMessagesMutation', _HASHES['CreateBulkMessagesMutation'],
  469. {
  470. 'messageThreadId': thread_id,
  471. 'messages': [{
  472. 'content': {'textContent': {'body': body}},
  473. 'uniqueIdentifier': str(uuid.uuid4()),
  474. 'loggingSource': {'sourceType': '', 'sourceId': ''},
  475. 'loggingExtras': {'entries': []},
  476. }],
  477. 'actAs': 'PARTICIPANT',
  478. 'businessJustification': {'feature': 'USER_INBOX'},
  479. 'originType': 'USER_INBOX',
  480. 'senderPlatform': 'WEB',
  481. 'skipOldMessagePreviewFields': False,
  482. },
  483. f'https://www.airbnb.com/messages/{thread_id}',
  484. )
  485. async def get_suggested_prices(self, date_from: str, date_to: str) -> dict[str, int]:
  486. """Airbnb's per-day smart-pricing suggestions (USD). Only includes days where shouldShowTip=True."""
  487. self._check_fresh()
  488. resp = await self._gql(
  489. 'getNightlyPriceTips', _HASHES['getNightlyPriceTips'],
  490. {
  491. 'listingId': self._global_id,
  492. 'dateRanges': [{'startDate': date_from, 'endDate': date_to}],
  493. },
  494. f'https://www.airbnb.com/multicalendar/{self._listing_id}',
  495. )
  496. tips = (
  497. resp.get('data', {}).get('presentation', {})
  498. .get('pricingGuidance', {}).get('nightlyPriceTip', {})
  499. .get('nightlyPriceTip', {}).get('discretePriceTips', [])
  500. )
  501. return {
  502. t['date']: t['priceTip']['intValue']
  503. for t in tips
  504. if t.get('shouldShowTip') and date_from <= t['date'] <= date_to
  505. }
  506. async def get_service_data(self, date_from: str, date_to: str) -> AirbnbData:
  507. suggested_prices, calendar_events = await asyncio.gather(
  508. self.get_suggested_prices(date_from, date_to),
  509. self.get_calendar_events(),
  510. )
  511. return AirbnbData(suggested_prices=suggested_prices, calendar_events=calendar_events)
  512. async def get_calendar_events(self) -> list[CalendarEvent]:
  513. """Local events and holidays Airbnb shows on the host calendar (holidays, major events, etc.)."""
  514. self._check_fresh()
  515. resp = await self._gql(
  516. 'HostCalendarListingEventsQuery', _HASHES['HostCalendarListingEventsQuery'],
  517. {'listingId': self._global_id},
  518. f'https://www.airbnb.com/multicalendar/{self._listing_id}',
  519. )
  520. events = (
  521. resp.get('data', {}).get('presentation', {})
  522. .get('hostCalendarListingEvents', {}).get('events', [])
  523. )
  524. return [
  525. CalendarEvent(
  526. start_date=e['startDate'],
  527. end_date=e['endDate'],
  528. name=e['displayName'],
  529. type=e['type'],
  530. event_id=e['eventId'],
  531. )
  532. for e in events
  533. ]
  534. # ── har_audit.py drift manifest ─────────────────────────────────────────────
  535. # Auditable identifiers for scripts/har_audit.py. References _API_KEY,
  536. # _CLIENT_VERSION, _HASHES above rather than duplicating literals — this is
  537. # the only copy.
  538. DRIFT_MANIFEST = {
  539. 'domain': 'www.airbnb.com',
  540. 'api_key': _API_KEY,
  541. 'client_version': _CLIENT_VERSION,
  542. 'ops': {
  543. 'getDLSHostCalendar': {
  544. 'hash': _HASHES['getDLSHostCalendar'],
  545. 'hint': 'load the multicalendar page',
  546. },
  547. 'EditPanelPricingSettingsMutation': {
  548. 'hash': _HASHES['EditPanelPricingSettingsMutation'],
  549. 'hint': 'change a price on any date in the multicalendar',
  550. },
  551. 'EditPanelAvailabilityMutation': {
  552. 'hash': _HASHES['EditPanelAvailabilityMutation'],
  553. 'hint': 'block or unblock a date in the multicalendar',
  554. },
  555. 'StayHostingDetailsQuery': {
  556. 'hash': _HASHES['StayHostingDetailsQuery'],
  557. 'hint': 'open any reservation detail page',
  558. },
  559. 'getNightlyPriceTips': {
  560. 'hash': _HASHES['getNightlyPriceTips'],
  561. 'hint': 'load the multicalendar page (fires automatically)',
  562. },
  563. 'HostCalendarListingEventsQuery': {
  564. 'hash': _HASHES['HostCalendarListingEventsQuery'],
  565. 'hint': 'load the multicalendar page (fires automatically)',
  566. },
  567. 'ViaductGetThreadAndDataQuery': {
  568. 'hash': _HASHES['ViaductGetThreadAndDataQuery'],
  569. 'hint': 'open any guest message thread',
  570. },
  571. 'CreateBulkMessagesMutation': {
  572. 'hash': _HASHES['CreateBulkMessagesMutation'],
  573. 'hint': 'send a message in any thread',
  574. },
  575. },
  576. }