nedviga-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. nedviga/__init__.py +46 -0
  2. nedviga/__main__.py +6 -0
  3. nedviga/_wm.py +93 -0
  4. nedviga/adaptive_selectors.py +100 -0
  5. nedviga/auditlog.py +77 -0
  6. nedviga/auto.py +88 -0
  7. nedviga/bargain.py +183 -0
  8. nedviga/browser_native.py +242 -0
  9. nedviga/callsheet.py +63 -0
  10. nedviga/chains.py +83 -0
  11. nedviga/checklist.py +119 -0
  12. nedviga/cian_card.py +1371 -0
  13. nedviga/cian_live.py +812 -0
  14. nedviga/cli.py +3118 -0
  15. nedviga/compare.py +204 -0
  16. nedviga/data/legal_flags.json +146 -0
  17. nedviga/data/release_pubkey.asc +22 -0
  18. nedviga/deck.py +115 -0
  19. nedviga/dedup.py +242 -0
  20. nedviga/developer.py +169 -0
  21. nedviga/digest.py +205 -0
  22. nedviga/doctor.py +128 -0
  23. nedviga/dossier.py +303 -0
  24. nedviga/eco.py +81 -0
  25. nedviga/errors.py +22 -0
  26. nedviga/export_html.py +280 -0
  27. nedviga/export_pdf.py +96 -0
  28. nedviga/family_card.py +670 -0
  29. nedviga/feedback.py +79 -0
  30. nedviga/geo_routing.py +214 -0
  31. nedviga/graph.py +149 -0
  32. nedviga/legal_triage.py +105 -0
  33. nedviga/litprecheck.py +116 -0
  34. nedviga/market.py +102 -0
  35. nedviga/mcp_server.py +195 -0
  36. nedviga/metro_directory.json +9836 -0
  37. nedviga/metro_vestibules.py +134 -0
  38. nedviga/models.py +164 -0
  39. nedviga/mosdata.py +95 -0
  40. nedviga/net_guard.py +997 -0
  41. nedviga/noise.py +156 -0
  42. nedviga/ops.py +94 -0
  43. nedviga/outcomes.py +35 -0
  44. nedviga/pii.py +85 -0
  45. nedviga/pipeline.py +239 -0
  46. nedviga/poi.py +180 -0
  47. nedviga/price_guard.py +73 -0
  48. nedviga/profiles.py +131 -0
  49. nedviga/projects.py +112 -0
  50. nedviga/purge.py +116 -0
  51. nedviga/py.typed +0 -0
  52. nedviga/risk.py +127 -0
  53. nedviga/roles.py +124 -0
  54. nedviga/routing_cache.py +72 -0
  55. nedviga/rush.py +139 -0
  56. nedviga/scorefit.py +127 -0
  57. nedviga/sources.py +55 -0
  58. nedviga/sources_avito.py +138 -0
  59. nedviga/sources_domclick.py +122 -0
  60. nedviga/stale.py +83 -0
  61. nedviga/textsearch.py +141 -0
  62. nedviga/tour.py +180 -0
  63. nedviga/watch.py +234 -0
  64. nedviga/wizard.py +106 -0
  65. nedviga_cli-0.1.0.dist-info/METADATA +130 -0
  66. nedviga_cli-0.1.0.dist-info/RECORD +71 -0
  67. nedviga_cli-0.1.0.dist-info/WHEEL +5 -0
  68. nedviga_cli-0.1.0.dist-info/entry_points.txt +2 -0
  69. nedviga_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
  70. nedviga_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
  71. nedviga_cli-0.1.0.dist-info/top_level.txt +1 -0
nedviga/cian_card.py ADDED
@@ -0,0 +1,1371 @@
1
+ """Offline parser for a saved Cian.ru listing card HTML page.
2
+
3
+ The parser is intentionally fail-closed: missing values become ``None`` and
4
+ malformed/unparseable input raises :class:`ParseError`.
5
+
6
+ It supports two extraction strategies:
7
+
8
+ 1. The ``window._offersData`` JSON island when present.
9
+ 2. A broad set of DOM selectors using Cian's real ``data-name`` attributes as
10
+ fallback/alternative sources.
11
+
12
+ All numeric normalizers are locale-aware for the Russian format
13
+ (``54,2 м²``, ``16 900 000 ₽``, ``6/12 этаж``).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ from typing import Any
21
+
22
+ from bs4 import BeautifulSoup, Tag
23
+
24
+ from .models import (
25
+ Building,
26
+ CianClaimedMetro,
27
+ Geo,
28
+ Image,
29
+ ListingCard,
30
+ Parameters,
31
+ PriceHistoryPoint,
32
+ Pricing,
33
+ Seller,
34
+ )
35
+
36
+
37
+ class ParseError(Exception):
38
+ """Raised when input HTML is garbage or the card cannot be identified."""
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Text normalisation helpers
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ def _strip(text: str | None) -> str | None:
47
+ if text is None:
48
+ return None
49
+ text = text.replace("\xa0", " ").strip()
50
+ return text if text else None
51
+
52
+
53
+ def _normalize_number(text: str | None) -> float | None:
54
+ """Convert '54,2', '16 900 000', '311 808,12' etc. to float."""
55
+ text = _strip(text)
56
+ if not text:
57
+ return None
58
+ # Keep only ASCII digits, commas, periods and spaces.
59
+ cleaned = "".join(ch for ch in text if (ch.isascii() and ch.isdigit()) or ch in ",. ")
60
+ cleaned = cleaned.replace(" ", "").replace("\xa0", "")
61
+ if not cleaned:
62
+ return None
63
+ # Russian decimal separator is comma.
64
+ cleaned = cleaned.replace(",", ".")
65
+ # Guard against multiple dots: treat dots as thousands separators
66
+ # only when every group after a dot is exactly 3 digits; else invalid.
67
+ if cleaned.count(".") > 1:
68
+ parts = cleaned.split(".")
69
+ if all(len(p) == 3 and p.isdigit() for p in parts[1:]):
70
+ cleaned = "".join(parts)
71
+ else:
72
+ return None
73
+ try:
74
+ return float(cleaned)
75
+ except ValueError:
76
+ return None
77
+
78
+
79
+ def _normalize_int(text: str | None) -> int | None:
80
+ num = _normalize_number(text)
81
+ if num is None:
82
+ return None
83
+ return int(num)
84
+
85
+
86
+ def _normalize_price(text: str | None) -> float | None:
87
+ """Convert '16 900 000 ₽' to 16900000.0."""
88
+ return _normalize_number(text)
89
+
90
+
91
+ def _normalize_area(text: str | None) -> float | None:
92
+ """Convert '54,2 м²' to 54.2."""
93
+ return _normalize_number(text)
94
+
95
+
96
+ def _parse_floor(text: str | None) -> tuple[int | None, int | None]:
97
+ """Convert '6/12 этаж', '6 из 12', '6 этаж' to (floor, total)."""
98
+ text = _strip(text)
99
+ if not text:
100
+ return None, None
101
+ match = re.search(r"(\d+)\s*(?:/|из)\s*(\d+)", text)
102
+ if match:
103
+ return int(match.group(1)), int(match.group(2))
104
+ match = re.search(r"(\d+)\s*этаж", text)
105
+ if match:
106
+ return int(match.group(1)), None
107
+ match = re.search(r"^(\d+)$", text)
108
+ if match:
109
+ return int(match.group(1)), None
110
+ return None, None
111
+
112
+
113
+ def _parse_year(text: str | None) -> int | None:
114
+ text = _strip(text)
115
+ if not text:
116
+ return None
117
+ match = re.search(r"\b(19\d{2}|20\d{2})\b", text)
118
+ if match:
119
+ return int(match.group(1))
120
+ return None
121
+
122
+
123
+ def _parse_rooms(title: str | None, factoids: dict[str, str]) -> int | None:
124
+ """Extract room count from title or 'Комнаты' factoid.
125
+
126
+ Studio maps to ``0`` to keep the integer schema consistent.
127
+ """
128
+ candidates = []
129
+ if title:
130
+ candidates.append(title)
131
+ if "комнаты" in factoids:
132
+ candidates.append(factoids["комнаты"])
133
+ for text in candidates:
134
+ if not text:
135
+ continue
136
+ lower = text.lower()
137
+ if "студия" in lower or "студию" in lower:
138
+ return 0
139
+ match = re.search(r"(\d+)[\s\-]*комн", lower)
140
+ if match:
141
+ return int(match.group(1))
142
+ match = re.search(r"(\d+)\s*комнат", lower)
143
+ if match:
144
+ return int(match.group(1))
145
+ return None
146
+
147
+
148
+ def _parse_rooms_type(text: str | None) -> str | None:
149
+ text = _strip(text)
150
+ if not text:
151
+ return None
152
+ lower = text.lower()
153
+ if "изолирован" in lower:
154
+ return "isolated"
155
+ if "евро" in lower or "студи" in lower:
156
+ return "euro"
157
+ if "смеж" in lower:
158
+ return "adjacent"
159
+ if "свобод" in lower:
160
+ return "free"
161
+ return text
162
+
163
+
164
+ def _parse_renovation(text: str | None) -> str | None:
165
+ text = _strip(text)
166
+ if not text:
167
+ return None
168
+ lower = text.lower()
169
+ mapping = {
170
+ "косметический": "cosmetic",
171
+ "евроремонт": "euro",
172
+ "евро": "euro",
173
+ "дизайнерский": "design",
174
+ "без ремонта": "none",
175
+ "предчистовая": "pre_finnish",
176
+ "чистовая": "finnish",
177
+ "хорошее": "good",
178
+ }
179
+ for key, value in mapping.items():
180
+ if key in lower:
181
+ return value
182
+ if lower in ("unknown", ""):
183
+ return None
184
+ return lower
185
+
186
+
187
+ def _parse_building_type(text: str | None) -> str | None:
188
+ text = _strip(text)
189
+ if not text:
190
+ return None
191
+ lower = text.lower()
192
+ mapping = {
193
+ "панель": "panel",
194
+ "кирпич": "brick",
195
+ "монолит": "monolith",
196
+ "монолитно-кирпич": "monolith_brick",
197
+ "блочн": "block",
198
+ "сталин": "stalin",
199
+ "дерев": "wood",
200
+ }
201
+ for key, value in mapping.items():
202
+ if key in lower:
203
+ return value
204
+ return lower
205
+
206
+
207
+ def _parse_floor_material(text: str | None) -> str | None:
208
+ text = _strip(text)
209
+ if not text:
210
+ return None
211
+ lower = text.lower()
212
+ if "железобетон" in lower or "ж/б" in lower:
213
+ return "reinforced_concrete"
214
+ if "дерев" in lower:
215
+ return "wood"
216
+ if "монолит" in lower:
217
+ return "monolith"
218
+ return lower
219
+
220
+
221
+ def _parse_elevators(text: str | None) -> tuple[int | None, int | None]:
222
+ """Return (passenger, cargo) from strings like '1 пассажирский' or
223
+ '2 пассажирских и 1 грузовой'.
224
+ """
225
+ text = _strip(text)
226
+ if not text:
227
+ return None, None
228
+ lower = text.lower()
229
+
230
+ def _count(prefixes: list[str]) -> int:
231
+ for prefix in prefixes:
232
+ match = re.search(r"(\d+)\s*" + prefix, lower)
233
+ if match:
234
+ return int(match.group(1))
235
+ return 0
236
+
237
+ passenger = _count(["пассажирск"]) or 0
238
+ cargo = _count(["грузов"]) or 0
239
+
240
+ if passenger == 0 and cargo == 0:
241
+ match = re.search(r"(\d+)\s*лифт", lower)
242
+ if match:
243
+ passenger = int(match.group(1))
244
+
245
+ return (passenger if passenger else None, cargo if cargo else None)
246
+
247
+
248
+ def _parse_yes_no(text: str | None) -> bool | None:
249
+ text = _strip(text)
250
+ if not text:
251
+ return None
252
+ lower = text.lower()
253
+ if lower in ("есть", "да", "yes", "true"):
254
+ return True
255
+ if lower in ("нет", "no", "false", "отсутствует"):
256
+ return False
257
+ return None
258
+
259
+
260
+ def _parse_window_view(text: str | None) -> str | None:
261
+ text = _strip(text)
262
+ if not text:
263
+ return None
264
+ lower = text.lower()
265
+ if "двор" in lower:
266
+ return "yard"
267
+ if "улиц" in lower or "дорог" in lower:
268
+ return "street"
269
+ if "парк" in lower:
270
+ return "park"
271
+ if "лес" in lower or "зелен" in lower:
272
+ return "forest"
273
+ return lower
274
+
275
+
276
+ def _parse_bathroom(text: str | None) -> str | None:
277
+ text = _strip(text)
278
+ if not text:
279
+ return None
280
+ lower = text.lower()
281
+ if "раздельн" in lower:
282
+ return "раздельный"
283
+ if "совмещ" in lower:
284
+ return "совмещенный"
285
+ if "2" in lower or "два" in lower:
286
+ return "2 санузла"
287
+ return text
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # DOM helpers
292
+ # ---------------------------------------------------------------------------
293
+
294
+
295
+ def _find_one(soup: BeautifulSoup, selectors: list[str]) -> Tag | None:
296
+ """Try a list of CSS selectors and return the first matching Tag."""
297
+ for selector in selectors:
298
+ try:
299
+ tag = soup.select_one(selector)
300
+ except Exception:
301
+ continue
302
+ if isinstance(tag, Tag):
303
+ return tag
304
+ return None
305
+
306
+
307
+ def _find_text(soup: BeautifulSoup, selectors: list[str]) -> str | None:
308
+ tag = _find_one(soup, selectors)
309
+ if tag is None:
310
+ return None
311
+ return _strip(tag.get_text())
312
+
313
+
314
+ def _summary_pairs_from_container(soup: BeautifulSoup) -> dict[str, str]:
315
+ """Build {lowercase_title: value} from OfferSummaryInfoItem pairs.
316
+
317
+ Live newbuilding cards (2026-09) present parameters as title/value
318
+ ``<p>`` pairs inside ``[data-name='OfferSummaryInfoItem']``.
319
+ """
320
+ result: dict[str, str] = {}
321
+ for item in soup.select("[data-name='OfferSummaryInfoItem']"):
322
+ if not isinstance(item, Tag):
323
+ continue
324
+ texts = [p.get_text(strip=True) for p in item.select("p")]
325
+ texts = [t for t in texts if t]
326
+ if len(texts) >= 2:
327
+ result[texts[0].lower().replace("\xa0", " ")] = texts[1].replace("\xa0", " ")
328
+ return result
329
+
330
+
331
+ def _factoids_from_container(soup: BeautifulSoup) -> dict[str, str]:
332
+ result: dict[str, str] = {}
333
+ containers = soup.select("[data-name='OfferCardPageInfo']") or soup.select(
334
+ "[data-name='OfferFactoids']"
335
+ )
336
+ for container in containers:
337
+ if not isinstance(container, Tag):
338
+ continue
339
+ items = container.select("[data-name='OfferFactoidsItem']")
340
+ if not items:
341
+ items = [child for child in container.children if isinstance(child, Tag)]
342
+ for item in items:
343
+ if not isinstance(item, Tag):
344
+ continue
345
+ title_tag = item.select_one("[data-name='OfferFactoidsItemTitle']")
346
+ value_tag = item.select_one("[data-name='OfferFactoidsItemValue']")
347
+ if title_tag and value_tag:
348
+ title = _strip(title_tag.get_text())
349
+ value = _strip(value_tag.get_text())
350
+ if title and value:
351
+ result[title.lower()] = value
352
+ return result
353
+
354
+
355
+ def _house_info_from_container(soup: BeautifulSoup) -> dict[str, str]:
356
+ result: dict[str, str] = {}
357
+ containers = soup.select("[data-name='HouseInfo']")
358
+ for container in containers:
359
+ if not isinstance(container, Tag):
360
+ continue
361
+ items = container.select("[data-name='HouseInfoItem']")
362
+ for item in items:
363
+ if not isinstance(item, Tag):
364
+ continue
365
+ title_tag = item.select_one("[data-name='HouseInfoItemTitle']")
366
+ value_tag = item.select_one("[data-name='HouseInfoItemValue']")
367
+ if title_tag and value_tag:
368
+ title = _strip(title_tag.get_text())
369
+ value = _strip(value_tag.get_text())
370
+ if title and value:
371
+ result[title.lower()] = value
372
+ return result
373
+
374
+
375
+ def _extract_json_island(soup: BeautifulSoup) -> dict[str, Any]:
376
+ """Parse ``window._offersData`` JSON island if present."""
377
+ for script in soup.find_all("script"):
378
+ text = script.string or ""
379
+ if "window._offersData" not in text:
380
+ continue
381
+ start = text.find("window._offersData")
382
+ eq = text.find("=", start)
383
+ if eq == -1:
384
+ continue
385
+ json_text = text[eq + 1 :]
386
+ json_text = json_text.rstrip().rstrip(";")
387
+ try:
388
+ return json.loads(json_text)
389
+ except json.JSONDecodeError:
390
+ continue
391
+ return {}
392
+
393
+
394
+ def _extract_default_state(soup: BeautifulSoup) -> dict[str, Any]:
395
+ """Extract the ``defaultState.offerData`` blob from the embedded
396
+ ``window._cianConfig['frontend-offer-card']`` config script.
397
+
398
+ Live cards (2026-09) carry the full offer payload here:
399
+ price, areas, floor, building, geo/coordinates, phones, photos,
400
+ priceChanges. The old ``window._offersData`` island is gone.
401
+ """
402
+ for script in soup.find_all("script"):
403
+ text = script.string or ""
404
+ if '"key":"defaultState"' not in text and "'key':'defaultState'" not in text:
405
+ continue
406
+ start = text.find('"key":"defaultState"')
407
+ if start == -1:
408
+ start = text.find("'key':'defaultState'")
409
+ value_marker = text.find('"value":', start)
410
+ if value_marker == -1:
411
+ continue
412
+ try:
413
+ data, _ = json.JSONDecoder().raw_decode(text[value_marker + len('"value":'):])
414
+ except (json.JSONDecodeError, ValueError):
415
+ continue
416
+ offer_data = data.get("offerData") if isinstance(data, dict) else None
417
+ if isinstance(offer_data, dict):
418
+ return offer_data
419
+ return {}
420
+
421
+
422
+ def _get_nested(data: dict[str, Any], *keys: str, default: Any = None) -> Any:
423
+ for key in keys:
424
+ if not isinstance(data, dict):
425
+ return default
426
+ data = data.get(key, default)
427
+ if data is None:
428
+ return default
429
+ return data
430
+
431
+
432
+ # ---------------------------------------------------------------------------
433
+ # Field extractors
434
+ # ---------------------------------------------------------------------------
435
+
436
+
437
+ def _extract_id(soup: BeautifulSoup, json_data: dict[str, Any]) -> str | None:
438
+ offers = json_data.get("offers") if isinstance(json_data, dict) else None
439
+ if offers and isinstance(offers, list) and offers:
440
+ first = offers[0]
441
+ cid = first.get("cianId") or first.get("id")
442
+ if cid is not None:
443
+ return f"cian-{cid}"
444
+ url = _extract_url(soup, json_data, None)
445
+ if url:
446
+ match = re.search(r"/sale/flat/(\d+)/", url)
447
+ if match:
448
+ return f"cian-{match.group(1)}"
449
+ return None
450
+
451
+
452
+ def _extract_category(
453
+ json_data: dict[str, Any], offer_data: dict[str, Any]
454
+ ) -> str | None:
455
+ """Return Cian listing category (e.g. flatShare, newBuildingFlatSale)."""
456
+ offer = _offer_of(offer_data)
457
+ if isinstance(offer, dict) and offer.get("category"):
458
+ return _strip(str(offer["category"]))
459
+ offers = json_data.get("offers") if isinstance(json_data, dict) else None
460
+ if offers and isinstance(offers, list) and offers and isinstance(offers[0], dict):
461
+ cat = offers[0].get("category")
462
+ if cat:
463
+ return _strip(str(cat))
464
+ return None
465
+
466
+
467
+ def _extract_url(
468
+ soup: BeautifulSoup, json_data: dict[str, Any], card_id: str | None
469
+ ) -> str | None:
470
+ offers = json_data.get("offers") if isinstance(json_data, dict) else None
471
+ if offers and isinstance(offers, list) and offers:
472
+ url = offers[0].get("url") or offers[0].get("fullUrl")
473
+ if url:
474
+ return url
475
+ canonical = soup.find("link", {"rel": "canonical"})
476
+ if isinstance(canonical, Tag):
477
+ href = canonical.get("href")
478
+ if href:
479
+ return str(href)
480
+ og_url = soup.find("meta", {"property": "og:url"})
481
+ if isinstance(og_url, Tag):
482
+ content = og_url.get("content")
483
+ if content:
484
+ return str(content)
485
+ if card_id:
486
+ cian_id = card_id.replace("cian-", "")
487
+ return f"https://www.cian.ru/sale/flat/{cian_id}/"
488
+ return None
489
+
490
+
491
+ def _extract_title(soup: BeautifulSoup, json_data: dict[str, Any]) -> str | None:
492
+ offers = json_data.get("offers") if isinstance(json_data, dict) else None
493
+ if offers and isinstance(offers, list) and offers:
494
+ title = offers[0].get("title")
495
+ if title:
496
+ return _strip(str(title))
497
+ return _find_text(
498
+ soup,
499
+ [
500
+ "h1[data-name='OfferTitle']",
501
+ "[data-name='OfferTitle']",
502
+ "h1",
503
+ ],
504
+ )
505
+
506
+
507
+ def _extract_description(soup: BeautifulSoup) -> str | None:
508
+ return _find_text(
509
+ soup,
510
+ [
511
+ "[data-name='Description']",
512
+ "[data-name='OfferDescription']",
513
+ "[data-name='DescriptionText']",
514
+ ],
515
+ )
516
+
517
+
518
+ def _offer_of(offer_data: dict[str, Any]) -> dict[str, Any]:
519
+ """Return the inner ``offer`` object from offerData ({} if absent)."""
520
+ offer = offer_data.get("offer") if isinstance(offer_data, dict) else None
521
+ return offer if isinstance(offer, dict) else {}
522
+
523
+
524
+ def _geo_address_part(offer: dict[str, Any], part_type: str) -> str | None:
525
+ """Pick e.g. 'raion'/'street'/'house' from offer.geo.address items."""
526
+ geo = offer.get("geo")
527
+ items = geo.get("address") if isinstance(geo, dict) else None
528
+ if not isinstance(items, list):
529
+ return None
530
+ for item in items:
531
+ if isinstance(item, dict) and item.get("type") == part_type:
532
+ return _strip(str(item.get("name")))
533
+ return None
534
+
535
+
536
+ def _extract_pricing(
537
+ soup: BeautifulSoup, json_data: dict[str, Any], offer_data: dict[str, Any],
538
+ title: str | None = None,
539
+ ) -> Pricing:
540
+ offer = _offer_of(offer_data)
541
+ first = {}
542
+ offers = json_data.get("offers") if isinstance(json_data, dict) else None
543
+ if offers and isinstance(offers, list) and offers and isinstance(offers[0], dict):
544
+ first = offers[0]
545
+
546
+ total: float | None = None
547
+ per_m2: float | None = None
548
+
549
+ # 1) defaultState.offerData.offer.bargainTerms.price (primary, live source)
550
+ bargain = offer.get("bargainTerms")
551
+ if isinstance(bargain, dict):
552
+ price = bargain.get("price")
553
+ if price is not None:
554
+ total = _normalize_price(str(price))
555
+ prices = bargain.get("prices")
556
+ if total is None and isinstance(prices, dict) and prices.get("rur") is not None:
557
+ total = _normalize_price(str(prices.get("rur")))
558
+
559
+ # 2) old _offersData island
560
+ json_price = _get_nested(first, "price", default={})
561
+ if total is None and isinstance(json_price, dict):
562
+ if json_price.get("value") is not None:
563
+ total = _normalize_price(str(json_price.get("value")))
564
+ if json_price.get("pricePerM2") is not None:
565
+ per_m2 = _normalize_number(str(json_price.get("pricePerM2")))
566
+
567
+ # 3) DOM fallback
568
+ if total is None:
569
+ raw_price_str = _find_text(
570
+ soup,
571
+ [
572
+ "[data-name='OfferPrice']",
573
+ "[data-name='Price']",
574
+ "[data-name='NewbuildingPriceInfo']",
575
+ ],
576
+ )
577
+ # Если найден itemprop='price', сначала проверяем атрибут content
578
+ if not raw_price_str:
579
+ itemprop_el = soup.find(attrs={"itemprop": "price"})
580
+ if itemprop_el:
581
+ raw_price_str = itemprop_el.get("content") or itemprop_el.get_text()
582
+
583
+ total = _normalize_price(raw_price_str)
584
+
585
+ # 4) Adaptive fallback (Scrapling pattern)
586
+ if total is None:
587
+ from .adaptive_selectors import extract_price_adaptive
588
+ total = extract_price_adaptive(str(soup))
589
+
590
+ # 5) P3-3 (2026-10-20): title-fallback, затем fail-closed drift-журнал
591
+ if total is None and title:
592
+ from .price_guard import price_from_title
593
+ total = price_from_title(title)
594
+
595
+ # per-m2: from sidebar fact (e.g. '250 100 ₽/м²') or offer price/area
596
+ if per_m2 is None:
597
+ fact = _find_text(
598
+ soup,
599
+ [
600
+ "[data-name='OfferFactItem']",
601
+ "[data-name='OfferPricePerMeter']",
602
+ "[data-name='PricePerMeter']",
603
+ ],
604
+ )
605
+ if fact and "м²" in fact:
606
+ per_m2 = _normalize_number(fact)
607
+ if per_m2 is None and total is not None:
608
+ area_text = offer.get("totalArea")
609
+ area = _normalize_area(str(area_text)) if area_text is not None else None
610
+ if area:
611
+ per_m2 = round(total / area, 2)
612
+
613
+ history: list[PriceHistoryPoint] = []
614
+ # 1) defaultState.offerData.priceChanges (newest first in payload)
615
+ changes = offer_data.get("priceChanges") if isinstance(offer_data, dict) else None
616
+ if isinstance(changes, list):
617
+ for point in changes:
618
+ if not isinstance(point, dict):
619
+ continue
620
+ change_time = point.get("changeTime")
621
+ price_data = point.get("priceData")
622
+ price = price_data.get("price") if isinstance(price_data, dict) else None
623
+ if change_time and price is not None:
624
+ date_match = re.match(r"(\d{4}-\d{2}-\d{2})", str(change_time))
625
+ if date_match:
626
+ history.append(
627
+ PriceHistoryPoint(date=date_match.group(1), price=float(price))
628
+ )
629
+ # 2) old island
630
+ if not history:
631
+ json_history = first.get("priceHistory") if isinstance(first, dict) else None
632
+ if isinstance(json_history, list):
633
+ for point in json_history:
634
+ if isinstance(point, dict):
635
+ date = (
636
+ _strip(str(point.get("date")))
637
+ if point.get("date") is not None
638
+ else None
639
+ )
640
+ price = (
641
+ _normalize_number(str(point.get("price")))
642
+ if point.get("price") is not None
643
+ else None
644
+ )
645
+ if date and price is not None:
646
+ history.append(PriceHistoryPoint(date=date, price=price))
647
+ # 3) DOM
648
+ if not history:
649
+ for item in soup.select("[data-name='PriceHistoryItem']"):
650
+ if not isinstance(item, Tag):
651
+ continue
652
+ date = item.get("data-date") or _strip(item.get_text())
653
+ price_text = _strip(item.get_text())
654
+ price = _normalize_price(price_text)
655
+ if date and price is not None:
656
+ if not re.match(r"\d{4}-\d{2}-\d{2}", str(date)):
657
+ match = re.search(r"(\d{4}-\d{2}-\d{2})", str(date))
658
+ if match:
659
+ date = match.group(1)
660
+ else:
661
+ continue
662
+ history.append(PriceHistoryPoint(date=str(date), price=price))
663
+
664
+ # chronological order (oldest first) + drop consecutive duplicates
665
+ history.sort(key=lambda p: (p.date, p.price))
666
+ deduped: list[PriceHistoryPoint] = []
667
+ for point in history:
668
+ if deduped and deduped[-1].date == point.date and deduped[-1].price == point.price:
669
+ continue
670
+ deduped.append(point)
671
+ history = deduped
672
+
673
+ # v0.3.1 (реалтор-аудит §1): юридические условия сделки
674
+ bt = offer.get("bargainTerms") if isinstance(offer.get("bargainTerms"), dict) else {}
675
+ sale_type: str | None = None
676
+ sale = bt.get("saleType") or offer.get("saleType")
677
+ if sale:
678
+ sale_type = str(sale)
679
+ mortgage: bool | None = None
680
+ if bt.get("mortgageAllowed") is not None:
681
+ mortgage = bool(bt["mortgageAllowed"])
682
+ elif offer.get("mortgageAllowed") is not None:
683
+ mortgage = bool(offer["mortgageAllowed"])
684
+ prices_all = bt.get("prices") if isinstance(bt.get("prices"), dict) else {}
685
+ price_usd = prices_all.get("usd") or offer.get("priceUsd") or bt.get("priceUsd")
686
+ price_eur = prices_all.get("eur") or offer.get("priceEur") or bt.get("priceEur")
687
+
688
+ # v0.3.4 (ТЗ реалтора): торги/аукционы ДГИ/ЦУГИ
689
+ # (в старом формате isAuction лежит в offers[0] = first)
690
+ is_auction = offer.get("isAuction", first.get("isAuction"))
691
+ trading_link = (offer.get("tradingLink") or offer.get("trading_url")
692
+ or first.get("tradingLink") or first.get("trading_url"))
693
+
694
+ return Pricing(
695
+ price_total_rub=total,
696
+ price_per_m2_rub=per_m2,
697
+ price_history=history,
698
+ sale_type=sale_type,
699
+ mortgage_allowed=mortgage,
700
+ price_usd=price_usd,
701
+ price_eur=price_eur,
702
+ is_auction=bool(is_auction) if is_auction is not None else None,
703
+ trading_link=str(trading_link) if trading_link else None,
704
+ )
705
+
706
+
707
+ def _extract_parameters(
708
+ soup: BeautifulSoup, json_data: dict[str, Any], offer_data: dict[str, Any]
709
+ ) -> Parameters:
710
+ offer = _offer_of(offer_data)
711
+ factoids = _factoids_from_container(soup)
712
+ summary = _summary_pairs_from_container(soup)
713
+ # merge: factoids + summary pairs are both {lowercase_title: value}
714
+ merged = {**summary, **factoids}
715
+
716
+ def _pick(*keys: str) -> str | None:
717
+ for key in keys:
718
+ if key in merged:
719
+ return merged[key]
720
+ return None
721
+
722
+ # areas: JSON first, then summary/factoid titles
723
+ area_total = _normalize_area(str(offer.get("totalArea"))) if offer.get("totalArea") is not None else None
724
+ if area_total is None:
725
+ area_total = _normalize_area(_pick("общая площадь", "общая"))
726
+ area_living = _normalize_area(str(offer.get("livingArea"))) if offer.get("livingArea") is not None else None
727
+ if area_living is None:
728
+ area_living = _normalize_area(_pick("жилая площадь", "жилая"))
729
+ area_kitchen = _normalize_area(str(offer.get("kitchenArea"))) if offer.get("kitchenArea") is not None else None
730
+ if area_kitchen is None:
731
+ area_kitchen = _normalize_area(_pick("площадь кухни", "кухня"))
732
+
733
+ floor = offer.get("floorNumber")
734
+ floors_total = None
735
+ building_json = offer.get("building")
736
+ if isinstance(building_json, dict) and building_json.get("floorsCount") is not None:
737
+ floors_total = int(building_json["floorsCount"])
738
+ if floor is None:
739
+ floor, floors_total_json = _parse_floor(_pick("этаж"))
740
+ if floor is not None and floors_total is None:
741
+ floors_total = floors_total_json
742
+ else:
743
+ floor = int(floor)
744
+
745
+ ceiling = None
746
+ if isinstance(building_json, dict) and building_json.get("ceilingHeight") is not None:
747
+ ceiling = _normalize_number(str(building_json["ceilingHeight"]))
748
+ if ceiling is None:
749
+ ceiling = _normalize_number(_pick("высота потолков"))
750
+
751
+ balcony = _pick("балкон", "лоджия")
752
+ bathroom = _parse_bathroom(_pick("санузел"))
753
+ if bathroom is None:
754
+ combined = offer.get("combinedWcsCount")
755
+ separated = offer.get("separatedWcCount")
756
+ parts = []
757
+ if combined:
758
+ parts.append(f"{combined} совмещенный")
759
+ if separated:
760
+ parts.append(f"{separated} раздельный")
761
+ if parts:
762
+ bathroom = ", ".join(parts)
763
+
764
+ decoration = offer.get("decoration")
765
+ renovation = (
766
+ _parse_renovation(str(decoration)) if decoration else _parse_renovation(_pick("ремонт", "отделка"))
767
+ )
768
+
769
+ window_view = None
770
+ wvt = offer.get("windowsViewType")
771
+ if wvt:
772
+ window_view = _parse_window_view(str(wvt))
773
+ else:
774
+ window_view = _parse_window_view(_pick("вид из окон"))
775
+
776
+ rooms_type = _parse_rooms_type(_pick("комнаты"))
777
+
778
+ # v0.3.1 (реалтор-аудит §3): физические параметры раздельно
779
+ loggias_count = offer.get("loggiasCount")
780
+ balconies_count = offer.get("balconiesCount")
781
+ separate_wcs = offer.get("separateWcsCount")
782
+ combined_wcs = offer.get("combinedWcsCount")
783
+ # roomType: JSON (roomType/separate|combined) + DOM-fallback
784
+ rooms_type_json = offer.get("roomType")
785
+ if rooms_type_json:
786
+ rooms_type = str(rooms_type_json)
787
+ # addedTime/lastEditTime живут в offer, а не в offerData верхнем уровне
788
+ added_time = offer.get("addedTime")
789
+ edit_time = offer.get("lastEditTime")
790
+ furniture = offer.get("isSoldFurnished") or offer.get("hasFurniture")
791
+ furniture_str = None
792
+ if offer.get("isSoldFurnished") is True:
793
+ furniture_str = "furnished"
794
+ elif offer.get("hasFurniture") is True:
795
+ furniture_str = "partial"
796
+ elif offer.get("hasFurniture") is False or offer.get("isSoldFurnished") is False:
797
+ furniture_str = "no"
798
+ layouts = []
799
+ cl = offer.get("cianLayouts")
800
+ if isinstance(cl, list):
801
+ layouts = [item.get("fullUrl") or item.get("url") for item in cl
802
+ if isinstance(item, dict) and (item.get("fullUrl") or item.get("url"))]
803
+
804
+ # v0.4.0 (ТЗ реалтора): доля в квартире
805
+ share_amount = _normalize_number(str(offer.get("shareAmount"))) if offer.get("shareAmount") is not None else None
806
+
807
+ title = _extract_title(soup, json_data)
808
+ if not title and offer.get("title"):
809
+ title = _strip(str(offer["title"]))
810
+ rooms = _parse_rooms(title, merged)
811
+ if rooms is None and offer.get("roomsCount") is not None:
812
+ rooms = int(offer["roomsCount"])
813
+
814
+ return Parameters(
815
+ rooms=rooms,
816
+ rooms_type=rooms_type,
817
+ area_total_m2=area_total,
818
+ area_living_m2=area_living,
819
+ area_kitchen_m2=area_kitchen,
820
+ floor=floor,
821
+ floors_total=floors_total,
822
+ ceiling_height_m=ceiling,
823
+ balcony=balcony,
824
+ bathroom=bathroom,
825
+ renovation=renovation,
826
+ window_view=window_view,
827
+ loggias_count=int(loggias_count) if loggias_count is not None else None,
828
+ balconies_count=int(balconies_count) if balconies_count is not None else None,
829
+ separate_wcs_count=int(separate_wcs) if separate_wcs is not None else None,
830
+ combined_wcs_count=int(combined_wcs) if combined_wcs is not None else None,
831
+ furniture=furniture_str,
832
+ layouts=layouts,
833
+ share_amount=share_amount,
834
+ )
835
+
836
+
837
+ def _extract_address_raw(
838
+ soup: BeautifulSoup, json_data: dict[str, Any], offer_data: dict[str, Any] | None = None
839
+ ) -> str | None:
840
+ if offer_data:
841
+ offer = _offer_of(offer_data)
842
+ geo = offer.get("geo")
843
+ items = geo.get("address") if isinstance(geo, dict) else None
844
+ if isinstance(items, list):
845
+ # By-design: includes city (type=location) in address.
846
+ # SPEC examples include the city, e.g. "Москва, ЮАО, р-н Чертаново Северное, ...".
847
+ parts = [
848
+ _strip(str(item.get("name")))
849
+ for item in items
850
+ if isinstance(item, dict) and item.get("name")
851
+ ]
852
+ parts = [p for p in parts if p]
853
+ if parts:
854
+ return ", ".join(parts)
855
+
856
+ offers = json_data.get("offers") if isinstance(json_data, dict) else None
857
+ first = offers[0] if (offers and isinstance(offers, list) and offers) else {}
858
+ if isinstance(first, dict):
859
+ user_input = (first.get("geo") or {}).get("userInput")
860
+ if user_input:
861
+ return _strip(str(user_input))
862
+
863
+ address_items = soup.select("[data-name='GeoAddress'] [data-name='GeoAddressItem']")
864
+ if address_items:
865
+ parts = [_strip(item.get_text()) for item in address_items]
866
+ parts = [p for p in parts if p]
867
+ if parts:
868
+ return ", ".join(parts)
869
+
870
+ return _find_text(
871
+ soup,
872
+ [
873
+ "[data-name='GeoAddress']",
874
+ "[data-name='Address']",
875
+ "address",
876
+ ],
877
+ )
878
+
879
+
880
+ def _extract_district(area_name_text: str | None) -> str | None:
881
+ if not area_name_text:
882
+ return None
883
+ # Moscow district acronyms. Order matters: longer variants first.
884
+ match = re.search(
885
+ r"\b(?:ЮЗАО|ЮВАО|СЗАО|СВАО|ЗелАО|ЦАО|САО|ВАО|ЮАО|ЗАО)\b",
886
+ area_name_text,
887
+ )
888
+ if match:
889
+ return match.group(0)
890
+ return None
891
+
892
+
893
+ def _extract_area_name(address: str | None, json_data: dict[str, Any]) -> str | None:
894
+ offers = json_data.get("offers") if isinstance(json_data, dict) else None
895
+ first = offers[0] if (offers and isinstance(offers, list) and offers) else {}
896
+ if isinstance(first, dict):
897
+ area = _get_nested(first, "geo", "areaName")
898
+ if area:
899
+ return _strip(str(area))
900
+
901
+ if not address:
902
+ return None
903
+ match = re.search(r"р-н\s+([^,]+)", address)
904
+ if match:
905
+ return _strip(match.group(1))
906
+ return None
907
+
908
+
909
+ def _extract_geo(
910
+ soup: BeautifulSoup, json_data: dict[str, Any], offer_data: dict[str, Any] | None = None
911
+ ) -> Geo:
912
+ offers = json_data.get("offers") if isinstance(json_data, dict) else None
913
+ first = offers[0] if (offers and isinstance(offers, list) and offers) else {}
914
+ offer = _offer_of(offer_data) if offer_data else {}
915
+
916
+ lat: float | None = None
917
+ lon: float | None = None
918
+ if offer:
919
+ coords = (offer.get("geo") or {}).get("coordinates")
920
+ if isinstance(coords, dict):
921
+ if coords.get("lat") is not None:
922
+ lat = _normalize_number(str(coords.get("lat")))
923
+ if coords.get("lng") is not None:
924
+ lon = _normalize_number(str(coords.get("lng")))
925
+ if lon is None and coords.get("lon") is not None:
926
+ lon = _normalize_number(str(coords.get("lon")))
927
+ if lat is None and isinstance(first, dict):
928
+ coords = _get_nested(first, "geo", "coordinates", default={})
929
+ if isinstance(coords, dict):
930
+ if coords.get("lat") is not None:
931
+ lat = _normalize_number(str(coords.get("lat")))
932
+ if coords.get("lng") is not None:
933
+ lon = _normalize_number(str(coords.get("lng")))
934
+ if lon is None and coords.get("lon") is not None:
935
+ lon = _normalize_number(str(coords.get("lon")))
936
+
937
+ address = _extract_address_raw(soup, json_data, offer_data)
938
+ area_name = _extract_area_name(address, json_data)
939
+ if area_name is None and offer:
940
+ area_name = _geo_address_part(offer, "raion")
941
+ district = _extract_district(address)
942
+ if district is None and offer:
943
+ district = _extract_district(_geo_address_part(offer, "okrug") or "")
944
+
945
+ claimed: CianClaimedMetro | None = None
946
+ if isinstance(first, dict):
947
+ metro_list = first.get("metro")
948
+ if isinstance(metro_list, list) and metro_list:
949
+ m = metro_list[0]
950
+ if isinstance(m, dict):
951
+ station = (
952
+ _strip(str(m.get("name"))) if m.get("name") is not None else None
953
+ )
954
+ time = (
955
+ _normalize_int(str(m.get("time")))
956
+ if m.get("time") is not None
957
+ else None
958
+ )
959
+ transport = (
960
+ _strip(str(m.get("transportType")))
961
+ if m.get("transportType") is not None
962
+ else None
963
+ )
964
+ if station:
965
+ claimed = CianClaimedMetro(
966
+ station=station,
967
+ claimed_time_minutes=time,
968
+ transport_type=transport,
969
+ )
970
+
971
+ if claimed is None:
972
+ # Underground elements in the main offer block (skip similar-offers
973
+ # carousels by requiring the element NOT inside SimilarOfferItem).
974
+ for underground in soup.select("[data-name='Underground'], [data-name='Metro']"):
975
+ if not isinstance(underground, Tag):
976
+ continue
977
+ if underground.find_parent("[data-name='SimilarOfferItem']"):
978
+ continue
979
+ name_tag = underground.select_one("[data-name='UndergroundName']")
980
+ time_tag = underground.select_one("[data-name='UndergroundTime']")
981
+ station = _strip(name_tag.get_text()) if name_tag else None
982
+ time_text = _strip(time_tag.get_text()) if time_tag else None
983
+ parsed_time: int | None = None
984
+ if not station:
985
+ # fallback: first non-empty text node of the container
986
+ text = _strip(underground.get_text(" "))
987
+ if text:
988
+ station = text
989
+ if station and re.search(r"\s+\d+\s*мин\.?\s*$", station):
990
+ # station text can carry the time: 'Станция 5 мин.'
991
+ st_match = re.search(r"^(.*?)\s+(\d+)\s*мин\.?\s*$", station)
992
+ if st_match:
993
+ station = _strip(st_match.group(1))
994
+ parsed_time = int(st_match.group(2))
995
+ if station:
996
+ time = None
997
+ transport_type = None
998
+ if time_text:
999
+ match = re.search(r"(\d+)\s*мин", time_text)
1000
+ if match:
1001
+ time = int(match.group(1))
1002
+ if "пешком" in time_text.lower():
1003
+ transport_type = "walk"
1004
+ elif "транспорт" in time_text.lower():
1005
+ transport_type = "transport"
1006
+ if time is None and parsed_time is not None:
1007
+ time = parsed_time
1008
+ claimed = CianClaimedMetro(
1009
+ station=station,
1010
+ claimed_time_minutes=time,
1011
+ transport_type=transport_type,
1012
+ )
1013
+ break
1014
+
1015
+ return Geo(
1016
+ lat=lat,
1017
+ lon=lon,
1018
+ district=district,
1019
+ area_name=area_name,
1020
+ cian_claimed_metro=claimed,
1021
+ honest_walk_route=None,
1022
+ )
1023
+
1024
+
1025
+ def _extract_building(
1026
+ soup: BeautifulSoup, json_data: dict[str, Any], offer_data: dict[str, Any] | None = None
1027
+ ) -> Building:
1028
+ house = _house_info_from_container(soup)
1029
+ address = _extract_address_raw(soup, json_data, offer_data)
1030
+ offer = _offer_of(offer_data) if offer_data else {}
1031
+ building_json = offer.get("building") if isinstance(offer, dict) else None
1032
+ building_json = building_json if isinstance(building_json, dict) else {}
1033
+
1034
+ building_type = _parse_building_type(house.get("тип дома"))
1035
+ if building_type is None:
1036
+ material = building_json.get("materialType") or building_json.get("houseMaterialType")
1037
+ if material:
1038
+ building_type = _parse_building_type(str(material))
1039
+
1040
+ year = _parse_year(house.get("год постройки"))
1041
+ if year is None and building_json.get("buildYear") is not None:
1042
+ # v0.3.1 fix (реалтор-аудит): у 100% вторички год лежит в
1043
+ # offer.building.buildYear, а не в deadline.year новостройки
1044
+ year = _parse_year(str(building_json.get("buildYear")))
1045
+ if year is None:
1046
+ # v0.4.0 (ТЗ реалтора): год встречается и в house.yearBuilt
1047
+ house_json = offer.get("house") if isinstance(offer, dict) else None
1048
+ if isinstance(house_json, dict) and house_json.get("yearBuilt") is not None:
1049
+ year = _parse_year(str(house_json.get("yearBuilt")))
1050
+ elif isinstance(offer_data, dict):
1051
+ top_house = offer_data.get("house")
1052
+ if isinstance(top_house, dict) and top_house.get("yearBuilt") is not None:
1053
+ year = _parse_year(str(top_house.get("yearBuilt")))
1054
+ if year is None and building_json.get("deadline", {}).get("isComplete"):
1055
+ year = _parse_year(str(building_json["deadline"].get("year")))
1056
+
1057
+ series = _strip(house.get("серия дома"))
1058
+
1059
+ floor_material = _parse_floor_material(house.get("перекрытия"))
1060
+
1061
+ elevators_passenger, elevators_cargo = _parse_elevators(house.get("лифты"))
1062
+ if elevators_passenger is None and building_json.get("passengerLiftsCount") is not None:
1063
+ elevators_passenger = int(building_json["passengerLiftsCount"])
1064
+ # v0.3.1 (реалтор-аудит §2): грузовые лифты из JSON
1065
+ if elevators_cargo is None and building_json.get("cargoLiftsCount") is not None:
1066
+ elevators_cargo = int(building_json["cargoLiftsCount"])
1067
+
1068
+ gas = _parse_yes_no(house.get("газоснабжение"))
1069
+
1070
+ renovation_program = None
1071
+ if "реновация" in house:
1072
+ renovation_program = _parse_yes_no(house["реновация"])
1073
+
1074
+ # v0.3.1 (реалтор-аудит §2, §4): здание + флаги + парк
1075
+ garbage = building_json.get("hasGarbageChute")
1076
+ parking = building_json.get("parkingType")
1077
+ parking_str = str(parking) if parking else None
1078
+ apartments = offer.get("isApartments") if isinstance(offer, dict) else None
1079
+ illegal = offer.get("isIllegalConstruction") if isinstance(offer, dict) else None
1080
+
1081
+ return Building(
1082
+ address_raw=address,
1083
+ house_series=series,
1084
+ year_built=year,
1085
+ building_type=building_type,
1086
+ floor_material=floor_material,
1087
+ renovation_program=renovation_program,
1088
+ elevators_passenger=elevators_passenger,
1089
+ elevators_cargo=elevators_cargo,
1090
+ gas=gas,
1091
+ has_garbage_chute=bool(garbage) if garbage is not None else None,
1092
+ parking_type=parking_str,
1093
+ is_apartments=bool(apartments) if apartments is not None else None,
1094
+ is_illegal_construction=bool(illegal) if illegal is not None else None,
1095
+ )
1096
+
1097
+
1098
+ def _extract_seller(
1099
+ soup: BeautifulSoup, offer_data: dict[str, Any] | None = None
1100
+ ) -> Seller:
1101
+ name = _strip(
1102
+ _find_text(
1103
+ soup,
1104
+ [
1105
+ "[data-name='OfferCardContactsName']",
1106
+ "[data-name='ContactName']",
1107
+ ],
1108
+ )
1109
+ )
1110
+ phone = _strip(
1111
+ _find_text(
1112
+ soup,
1113
+ [
1114
+ "[data-name='OfferCardContactsPhone']",
1115
+ "[data-name='ContactPhone']",
1116
+ ],
1117
+ )
1118
+ )
1119
+ note = _strip(
1120
+ _find_text(
1121
+ soup,
1122
+ [
1123
+ "[data-name='OfferCardContactsNote']",
1124
+ "[data-name='ContactNote']",
1125
+ ],
1126
+ )
1127
+ )
1128
+
1129
+ seller_type: str | None = None
1130
+ if note:
1131
+ lower = note.lower()
1132
+ if "агентство" in lower or "агент" in lower:
1133
+ seller_type = "realtor"
1134
+ elif "застройщик" in lower:
1135
+ seller_type = "developer"
1136
+ elif "собственник" in lower:
1137
+ seller_type = "owner"
1138
+
1139
+ # 1) defaultState.offerData.agent (accountType: agency/developer/owner etc.)
1140
+ agent = offer_data.get("agent") if isinstance(offer_data, dict) else None
1141
+ if isinstance(agent, dict):
1142
+ account_type = agent.get("accountType")
1143
+ if account_type == "agency" or account_type == "realtor":
1144
+ seller_type = seller_type or "realtor"
1145
+ elif account_type == "developer":
1146
+ seller_type = seller_type or "developer"
1147
+ elif account_type == "owner" or account_type == "private":
1148
+ seller_type = seller_type or "owner"
1149
+ if not name and agent.get("companyName"):
1150
+ name = _strip(str(agent["companyName"]))
1151
+ elif not name and agent.get("name"):
1152
+ name = _strip(str(agent["name"]))
1153
+ # developer name from newObject
1154
+ developer = offer_data.get("newObject", {}).get("developer")
1155
+ if not name and isinstance(developer, dict) and developer.get("name"):
1156
+ name = _strip(str(developer["name"]))
1157
+ seller_type = seller_type or "developer"
1158
+ if not phone:
1159
+ agent_phones = agent.get("phones")
1160
+ if isinstance(agent_phones, list) and agent_phones:
1161
+ first_phone = agent_phones[0]
1162
+ if isinstance(first_phone, dict) and first_phone.get("number"):
1163
+ country = first_phone.get("countryCode") or "+7"
1164
+ phone = _strip(f"{country} {first_phone['number']}")
1165
+
1166
+ # 2) DOM fallback: PhonesContainer text may name the contact type
1167
+ if seller_type is None or not name:
1168
+ phones_text = _strip(
1169
+ _find_text(soup, ["[data-name='PhonesContainer']"])
1170
+ )
1171
+ if phones_text:
1172
+ lower = phones_text.lower()
1173
+ if "застройщик" in lower:
1174
+ seller_type = seller_type or "developer"
1175
+ elif "агент" in lower or "агентство" in lower:
1176
+ seller_type = seller_type or "realtor"
1177
+
1178
+ # v0.3.1 (реалтор-аудит §4, §5): собственник, все телефоны, даты
1179
+ is_by_homeowner = None
1180
+ if isinstance(offer_data, dict):
1181
+ # флаг в самом оффере
1182
+ if offer_data.get("isByHomeowner") is not None:
1183
+ is_by_homeowner = bool(offer_data["isByHomeowner"])
1184
+ elif isinstance(offer_data.get("user"), dict) and offer_data["user"].get("isByHomeowner") is not None:
1185
+ is_by_homeowner = bool(offer_data["user"]["isByHomeowner"])
1186
+ if is_by_homeowner is None and seller_type == "owner":
1187
+ is_by_homeowner = True
1188
+
1189
+ phones_list: list[str] = []
1190
+ if isinstance(agent, dict):
1191
+ agent_phones_all = agent.get("phones")
1192
+ if isinstance(agent_phones_all, list):
1193
+ for ph in agent_phones_all:
1194
+ if isinstance(ph, dict) and ph.get("number"):
1195
+ country = ph.get("countryCode") or "+7"
1196
+ phones_list.append(_strip(f"{country} {ph['number']}"))
1197
+ if not phones_list and isinstance(offer_data, dict):
1198
+ offer_phones = (offer_data.get("offer", {}) or {}).get("phones") or offer_data.get("phones") or []
1199
+ if isinstance(offer_phones, list):
1200
+ for ph in offer_phones:
1201
+ if isinstance(ph, dict) and ph.get("number"):
1202
+ country = ph.get("countryCode") or "+7"
1203
+ phones_list.append(f"{country} {ph['number']}".strip())
1204
+ if not phones_list and phone:
1205
+ phones_list = [phone]
1206
+
1207
+ offer_for_dates = offer_data.get("offer") if isinstance(offer_data, dict) else {}
1208
+ created_at = (offer_data.get("addedTime")
1209
+ or (offer_for_dates.get("addedTime") if isinstance(offer_for_dates, dict) else None))
1210
+ edit_date = (offer_data.get("lastEditTime")
1211
+ or (offer_for_dates.get("lastEditTime") if isinstance(offer_for_dates, dict) else None))
1212
+
1213
+ return Seller(
1214
+ seller_type=seller_type,
1215
+ name=name,
1216
+ agent_phone_masked=phone,
1217
+ is_by_homeowner=is_by_homeowner,
1218
+ phones=phones_list,
1219
+ created_at=str(created_at) if created_at else None,
1220
+ edit_date=str(edit_date) if edit_date else None,
1221
+ )
1222
+
1223
+
1224
+ def _extract_images(
1225
+ soup: BeautifulSoup, offer_data: dict[str, Any] | None = None
1226
+ ) -> list[Image]:
1227
+ images: list[Image] = []
1228
+
1229
+ # 1) defaultState.offerData.offer.photos (full objects with URLs)
1230
+ offer = _offer_of(offer_data) if offer_data else {}
1231
+ photos = offer.get("photos")
1232
+ if isinstance(photos, list):
1233
+ for photo in photos:
1234
+ if not isinstance(photo, dict):
1235
+ continue
1236
+ url = photo.get("fullUrl") or photo.get("thumbnailUrl")
1237
+ if not url:
1238
+ continue
1239
+ images.append(Image(url=str(url), category=None))
1240
+
1241
+ # 2) DOM fallback (and for pages without JSON state)
1242
+ if not images:
1243
+ for img in soup.select("[data-name='OfferGalleryImage']"):
1244
+ if not isinstance(img, Tag):
1245
+ continue
1246
+ src = img.get("src") or img.get("data-src")
1247
+ alt = _strip(img.get("alt"))
1248
+ category: str | None = None
1249
+ if alt:
1250
+ lower = alt.lower()
1251
+ if "план" in lower or "планировк" in lower:
1252
+ category = "plan"
1253
+ elif "интерьер" in lower or "комнат" in lower:
1254
+ category = "interior"
1255
+ elif "экстерьер" in lower or "дом" in lower:
1256
+ category = "exterior"
1257
+ if src:
1258
+ images.append(Image(url=str(src), category=category))
1259
+ if not images:
1260
+ seen: set[str] = set()
1261
+ for img in soup.select("img"):
1262
+ if not isinstance(img, Tag):
1263
+ continue
1264
+ src = img.get("src") or img.get("data-src") or ""
1265
+ if "cdn-cian" not in src or not re.search(r"\.(jpe?g|webp|png)", src):
1266
+ continue
1267
+ if src in seen:
1268
+ continue
1269
+ seen.add(src)
1270
+ images.append(Image(url=src, category=None))
1271
+ return images
1272
+
1273
+
1274
+ # ---------------------------------------------------------------------------
1275
+ # Public API
1276
+ # ---------------------------------------------------------------------------
1277
+
1278
+
1279
+ def _extract_status(
1280
+ soup: BeautifulSoup, offer_data: dict[str, Any]
1281
+ ) -> str:
1282
+ offer = _offer_of(offer_data)
1283
+ raw_status = offer.get("status")
1284
+ if raw_status:
1285
+ mapping = {"published": "active", "removed": "removed", "sold": "sold"}
1286
+ mapped = mapping.get(str(raw_status).lower())
1287
+ if mapped:
1288
+ return mapped
1289
+
1290
+ # Narrow scan: only title/h1/short leaf elements. A broad scan over all
1291
+ # div/span would false-positive on description text like
1292
+ # "в ЖК уже продано 100 квартир" (fail-closed: prefer 'active' on doubt).
1293
+ for tag in soup.find_all(["title", "h1", "p", "span"]):
1294
+ text = (tag.get_text() or "").strip().lower()
1295
+ if not text or len(text) > 120:
1296
+ continue
1297
+ if "объявление удалено" in text or "объявление снято" in text:
1298
+ return "removed"
1299
+ if "продано" in text and len(text) <= 40:
1300
+ return "sold"
1301
+
1302
+ return "active"
1303
+
1304
+
1305
+ def parse_card(html: str) -> ListingCard:
1306
+ """Parse a saved Cian card HTML into a :class:`ListingCard`.
1307
+
1308
+ Raises:
1309
+ ParseError: if the HTML is empty or no card identity can be extracted.
1310
+ """
1311
+ if html is None or not html.strip():
1312
+ raise ParseError("empty_html")
1313
+
1314
+ try:
1315
+ soup = BeautifulSoup(html, "lxml")
1316
+ except Exception as exc:
1317
+ raise ParseError(f"html_parse_error: {exc}") from exc
1318
+
1319
+ json_data = _extract_json_island(soup)
1320
+ offer_data = _extract_default_state(soup)
1321
+
1322
+ card_id = _extract_id(soup, json_data)
1323
+ if not card_id:
1324
+ # try the defaultState offer id
1325
+ offer = _offer_of(offer_data)
1326
+ cid = offer.get("cianId") or offer.get("id")
1327
+ if cid is not None:
1328
+ card_id = f"cian-{cid}"
1329
+ if not card_id:
1330
+ raise ParseError("cannot_extract_id")
1331
+
1332
+ url = _extract_url(soup, json_data, card_id)
1333
+ title = _extract_title(soup, json_data)
1334
+ if not title:
1335
+ offer_title = _offer_of(offer_data).get("title")
1336
+ if offer_title:
1337
+ title = _strip(str(offer_title))
1338
+ description = _extract_description(soup)
1339
+ if not description:
1340
+ description = _strip(str(_offer_of(offer_data).get("description") or ""))
1341
+
1342
+ parameters = _extract_parameters(soup, json_data, offer_data)
1343
+ building = _extract_building(soup, json_data, offer_data)
1344
+
1345
+ # v0.4.0 (ТЗ реалтора): в домах 14+ этажей газа по строительным нормам нет
1346
+ if parameters.floors_total is not None and parameters.floors_total >= 14:
1347
+ building.gas = False
1348
+
1349
+ category = _extract_category(json_data, offer_data)
1350
+
1351
+ pricing = _extract_pricing(soup, json_data, offer_data, title=title)
1352
+ # P3-3: цена пропала после всех фолбэков — не тишина, а карантин-событие
1353
+ if pricing.price_total_rub is None:
1354
+ from .price_guard import record_price_drift
1355
+ record_price_drift(card_id, url, title)
1356
+
1357
+ return ListingCard(
1358
+ id=card_id,
1359
+ source="cian",
1360
+ url=url or f"https://www.cian.ru/sale/flat/{card_id.replace('cian-', '')}/",
1361
+ status=_extract_status(soup, offer_data),
1362
+ title=title,
1363
+ pricing=pricing,
1364
+ parameters=parameters,
1365
+ building=building,
1366
+ geo=_extract_geo(soup, json_data, offer_data),
1367
+ description_text=description,
1368
+ category=category,
1369
+ seller=_extract_seller(soup, offer_data),
1370
+ images=_extract_images(soup, offer_data),
1371
+ )