1"""Логика Airbnb: URL поиска, парсинг выдачи и детальной страницы."""
2
3from __future__ import annotations
4
5import base64
6import json
7import re
8from datetime import date, datetime, timezone
9from typing import Any
10from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
11
12from apify import Actor
13
14from .utils import (
15 FetchError,
16 calc_occupancy,
17 extract_json_from_script,
18 fetch_html,
19 fetch_json,
20 find_key,
21 first_value,
22 httpx,
23 iter_dicts,
24 parse_calendar,
25 parse_price,
26)
27
28BASE_URL = "https://www.airbnb.com"
29
30FALLBACK_API_KEY = "d306zoyjsyarp7ifhu67rjxn52tv0t20"
31
32CALENDAR_HASH = "8f08e03c7bd16fcad3c92a3592c19a8b559a0d0855a84028d1163d4733ed9ade"
33CALENDAR_MONTHS = 12
34HOST_RE = re.compile(r"^(?:Hosted by|Stay with)\s+(.+)$", re.IGNORECASE)
35API_KEY_RE = re.compile(r'"api_config"\s*:\s*\{\s*"key"\s*:\s*"([^"]+)"')
36NIGHTS_RE = re.compile(r"(\d+)\s+nights?\s+x\s+(.+)$", re.IGNORECASE)
37
38
39def build_search_url(location: str, cursor: str | None = None) -> str:
40 """Строит URL поиска по городу или дополняет готовый URL поиска."""
41 location = location.strip()
42 if location.startswith(("http://", "https://")):
43 parts = urlsplit(location)
44 query = dict(parse_qsl(parts.query, keep_blank_values=True))
45 else:
46 parts = urlsplit(f"{BASE_URL}/s/{quote(location)}/homes")
47 query = {"refinement_paths[]": "/homes"}
48
49
50 query.update({"locale": "en", "currency": "USD"})
51 if cursor:
52 query["cursor"] = cursor
53 else:
54 query.pop("cursor", None)
55 return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), ""))
56
57
58def decode_listing_id(raw: Any) -> str | None:
59 """Приводит id к числу: "123" или base64("DemandStayListing:123")."""
60 if raw is None:
61 return None
62 value = str(raw).strip()
63 if value.isdigit():
64 return value
65 try:
66 decoded = base64.b64decode(value + "=" * (-len(value) % 4)).decode("utf-8")
67 except (ValueError, UnicodeDecodeError):
68 return None
69 tail = decoded.rsplit(":", 1)[-1]
70 return tail if tail.isdigit() else None
71
72
73def _text(value: Any) -> str | None:
74 """Достаёт строку из простого или локализованного значения."""
75 if isinstance(value, str):
76 return value or None
77 if isinstance(value, dict):
78 for key in ("localizedStringWithTranslationPreference", "localizedString", "text"):
79 if isinstance(value.get(key), str):
80 return value[key]
81 return None
82
83
84def _extract_currency(text: str | None) -> str | None:
85 """Грубо вытаскивает код валюты из текста цены."""
86 if not text:
87 return None
88 for code in ("USD", "EUR", "GBP", "CHF", "RUB", "JPY", "CAD", "AUD"):
89 if code in text:
90 return code
91 for symbol, code in (("$", "USD"), ("€", "EUR"), ("£", "GBP"), ("₽", "RUB"), ("Fr.", "CHF")):
92 if symbol in text:
93 return code
94 return None
95
96
97def _parse_rating(item: dict[str, Any], listing: dict[str, Any]) -> tuple[float | None, int | None]:
98 """Рейтинг и число отзывов из "4.92 (123)" или a11y-подписи."""
99 text = str(
100 item.get("avgRatingLocalized")
101 or listing.get("avgRatingLocalized")
102 or item.get("avgRatingA11yLabel")
103 or ""
104 )
105 match = re.search(r"(\d+(?:[.,]\d+)?)\s*\((\d[\d,]*)\)", text)
106 if match:
107 return float(match.group(1).replace(",", ".")), int(match.group(2).replace(",", ""))
108 match = re.search(r"(\d+(?:\.\d+)?)\s*out of 5.*?(\d[\d,]*)\s+review", text, re.IGNORECASE)
109 if match:
110 return float(match.group(1)), int(match.group(2).replace(",", ""))
111
112 rating = listing.get("avgRating")
113 reviews = listing.get("reviewsCount")
114 return (
115 float(rating) if isinstance(rating, (int, float)) else None,
116 int(reviews) if isinstance(reviews, int) else None,
117 )
118
119
120def _parse_price_line(item: dict[str, Any]) -> tuple[float | None, str | None]:
121 """Цена за ночь и исходный текст цены из карточки поиска.
122
123 Новая структура Airbnb: per-night цена лежит в explanationData.priceDetails,
124 строка вида "5 nights x Fr. 148.05". primaryLine содержит общую сумму за все ночи.
125 """
126 display = item.get("structuredDisplayPrice") or {}
127 primary = display.get("primaryLine") or {}
128 explanation = display.get("explanationData") or {}
129
130
131 for group in explanation.get("priceDetails") or []:
132 if not isinstance(group, dict):
133 continue
134 for entry in group.get("items") or []:
135 if not isinstance(entry, dict):
136 continue
137 desc = entry.get("description") or ""
138 m = NIGHTS_RE.search(desc)
139 if m:
140 per_night_text = m.group(2).strip()
141 per_night = parse_price(per_night_text)
142 if per_night:
143 return per_night, desc
144
145
146 price_value = primary.get("discountedPrice") or primary.get("price")
147 qualifier = primary.get("qualifier") or ""
148 if price_value:
149 text = f"{price_value} {qualifier}".strip()
150 else:
151 text = primary.get("accessibilityLabel")
152 return parse_price(text), text
153
154
155def _parse_search_result(item: dict[str, Any]) -> dict[str, Any] | None:
156 """Преобразует одну карточку выдачи в запись объявления."""
157 listing = item.get("listing") or {}
158 demand = item.get("demandStayListing") or {}
159 listing_id = decode_listing_id(
160 listing.get("id") or demand.get("id") or item.get("propertyId") or item.get("listingId")
161 )
162 if not listing_id:
163 return None
164
165 coordinate = (
166 listing.get("coordinate")
167 or (demand.get("location") or {}).get("coordinate")
168 or {}
169 )
170 rating, reviews_count = _parse_rating(item, listing)
171 price, price_text = _parse_price_line(item)
172 title = _text(item.get("title")) or _text(listing.get("title"))
173
174 name = (
175 _text(listing.get("name"))
176 or _text((demand.get("description") or {}).get("name"))
177 or _text(item.get("nameLocalized"))
178 or _text(item.get("subtitle"))
179 or title
180 )
181
182 return {
183 "id": listing_id,
184 "name": name,
185 "url": f"{BASE_URL}/rooms/{listing_id}",
186 "price": price,
187 "price_text": price_text,
188 "currency": _extract_currency(price_text),
189 "coordinates": {
190 "lat": coordinate.get("latitude"),
191 "lng": coordinate.get("longitude"),
192 },
193 "rating": rating,
194 "reviews_count": reviews_count,
195 "host_name": None,
196 "room_type": listing.get("roomTypeCategory")
197 or listing.get("roomType")
198 or title,
199 }
200
201
202def parse_search(html: str) -> tuple[list[dict[str, Any]], str | None]:
203 """Парсит страницу поиска: список объявлений и курсор следующей страницы."""
204 data = extract_json_from_script(html)
205 if data is None:
206 Actor.log.warning("JSON состояния не найден на странице поиска")
207 return [], None
208
209 listings: dict[str, dict[str, Any]] = {}
210 for results in find_key(data, "searchResults"):
211 if not isinstance(results, list):
212 continue
213 for item in results:
214 if not isinstance(item, dict):
215 continue
216 parsed = _parse_search_result(item)
217 if parsed and parsed["id"] not in listings:
218 listings[parsed["id"]] = parsed
219
220 next_cursor: str | None = None
221 for pagination in find_key(data, "paginationInfo"):
222 if isinstance(pagination, dict) and pagination.get("nextPageCursor"):
223 next_cursor = pagination["nextPageCursor"]
224 break
225
226 return list(listings.values()), next_cursor
227
228
229def _extract_host_name(data: dict[str, Any]) -> str | None:
230 """Имя хоста из секций детальной страницы."""
231 host = first_value(data, "hostName")
232 if isinstance(host, str):
233 return host
234 for item in iter_dicts(data):
235 for key in ("title", "heading", "overviewTitle"):
236 value = item.get(key)
237 if isinstance(value, str):
238 match = HOST_RE.match(value.strip())
239 if match:
240 return match.group(1).strip()
241 for card in find_key(data, "cardData"):
242 if isinstance(card, dict) and isinstance(card.get("name"), str):
243 return card["name"]
244 return None
245
246
247def _extract_coordinates(data: dict[str, Any]) -> dict[str, float] | None:
248 """Координаты из секции локации детальной страницы."""
249 for item in iter_dicts(data):
250 lat, lng = item.get("lat"), item.get("lng")
251 if isinstance(lat, (int, float)) and isinstance(lng, (int, float)):
252 return {"lat": float(lat), "lng": float(lng)}
253 return None
254
255
256async def fetch_calendar(
257 client: httpx.AsyncClient,
258 listing_id: str,
259 api_key: str,
260 referer: str,
261 months: int = CALENDAR_MONTHS,
262) -> dict[str, Any]:
263 """Запрашивает календарь доступности через внутренний API Airbnb."""
264 today = date.today()
265 variables = {
266 "request": {
267 "count": months,
268 "listingId": listing_id,
269 "month": today.month,
270 "year": today.year,
271 }
272 }
273 extensions = {"persistedQuery": {"version": 1, "sha256Hash": CALENDAR_HASH}}
274 params = {
275 "operationName": "PdpAvailabilityCalendar",
276 "locale": "en",
277 "currency": "USD",
278 "variables": json.dumps(variables, separators=(",", ":")),
279 "extensions": json.dumps(extensions, separators=(",", ":")),
280 }
281 headers = {
282 "X-Airbnb-API-Key": api_key,
283 "Accept": "application/json",
284 "Content-Type": "application/json",
285 "Referer": referer,
286 }
287 payload = await fetch_json(
288 client,
289 f"{BASE_URL}/api/v3/PdpAvailabilityCalendar/{CALENDAR_HASH}",
290 params=params,
291 headers=headers,
292 )
293 if payload.get("errors"):
294 raise ValueError(f"API календаря вернул ошибку: {payload['errors']}")
295 return payload
296
297
298async def parse_listing(client: httpx.AsyncClient, listing: dict[str, Any]) -> dict[str, Any]:
299 """Дополняет объявление данными детальной страницы и метриками календаря."""
300 html = await fetch_html(client, listing["url"], params={"locale": "en", "currency": "USD"})
301 data = extract_json_from_script(html) or {}
302 result = dict(listing)
303
304 result["host_name"] = _extract_host_name(data) or listing.get("host_name")
305 if not result.get("room_type"):
306 result["room_type"] = first_value(data, "roomTypeCategory", "roomType")
307 if not (result.get("coordinates") or {}).get("lat"):
308 result["coordinates"] = _extract_coordinates(data) or result.get("coordinates")
309
310
311 days = parse_calendar(data, CALENDAR_MONTHS)
312 calendar_error: str | None = None
313 if not days:
314 key_match = API_KEY_RE.search(html)
315 api_key = key_match.group(1) if key_match else FALLBACK_API_KEY
316 try:
317 payload = await fetch_calendar(client, listing["id"], api_key, listing["url"])
318 days = parse_calendar(payload, CALENDAR_MONTHS)
319 except (FetchError, ValueError) as exc:
320 calendar_error = str(exc)
321 Actor.log.warning(f"Календарь {listing['id']} не получен: {exc}")
322
323 result.update(calc_occupancy(days, listing.get("price")))
324 result["calendar_days"] = len(days)
325 result["calendar_error"] = calendar_error
326 result["scraped_at"] = datetime.now(timezone.utc).isoformat()
327 return result