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_live.py ADDED
@@ -0,0 +1,812 @@
1
+ """Live Cian.ru fetcher using the native embedded browser.
2
+
3
+ Provides :func:`get_card` and :func:`search`. Both are fail-closed: any
4
+ WAF/captcha/rate-limit page is reported as :class:`ChallengeError` (CLI exit
5
+ code 2); any other fetch or parse problem is reported as :class:`FetchError`
6
+ (exit code 3).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from .net_guard import BreakerOpenError, EgressBlockedError
13
+ import time
14
+ import urllib.parse
15
+ from typing import Any
16
+
17
+ from .browser_native import get_last_http_status, native_fetch
18
+ from .cian_card import ParseError, parse_card
19
+ from .errors import ChallengeError, FetchError
20
+ from .models import ListingCard
21
+
22
+
23
+ # Substrings / selectors that reliably identify a challenge page on Cian.
24
+ _CHALLENGE_MARKERS = (
25
+ "smartcaptcha",
26
+ "captcha_challenge",
27
+ "show-captcha",
28
+ "check-captcha",
29
+ "cloudflare",
30
+ "cf-turnstile",
31
+ "challenge-platform",
32
+ "доступ ограничен",
33
+ "доступ временно ограничен",
34
+ "access denied",
35
+ "access temporarily restricted",
36
+ "проверка безопасности",
37
+ "проверяем, что вы не робот",
38
+ "вы робот",
39
+ "ддос",
40
+ "ddos",
41
+ )
42
+
43
+ # v0.4.0 (ТЗ реалтора): административные и ближайшие НП за МКАД.
44
+ _STRICT_MKAD_TOKENS = (
45
+ "нао",
46
+ "тао",
47
+ "зелао",
48
+ "новомосковский",
49
+ "троицкий",
50
+ "зеленоград",
51
+ "московская область",
52
+ "коммунарка",
53
+ "рогозинино",
54
+ "ватутинки",
55
+ "первомайское",
56
+ "сосенское",
57
+ "красногорск",
58
+ "мытищи",
59
+ "люберцы",
60
+ "балашиха",
61
+ "одинцово",
62
+ "химки",
63
+ "видное",
64
+ "реутов",
65
+ "котельники",
66
+ )
67
+
68
+ # v0.4.0 (ТЗ реалтора): юридические стоп-слова.
69
+ _LEGAL_RISK_PHRASES = (
70
+ "отказник от приватизации",
71
+ "пожизненное проживание",
72
+ "бессрочное пользование",
73
+ "без просмотра",
74
+ "третье лицо с правом",
75
+ )
76
+
77
+
78
+ def _is_strict_mkad(card: ListingCard) -> bool:
79
+ """Return True when the listing address/geo is outside the strict MKAD set."""
80
+ text = " ".join(
81
+ [
82
+ card.building.address_raw or "",
83
+ card.geo.area_name or "",
84
+ card.geo.district or "",
85
+ ]
86
+ ).lower()
87
+ return any(token in text for token in _STRICT_MKAD_TOKENS)
88
+
89
+
90
+ def _is_apartment(card: ListingCard) -> bool:
91
+ """Return True when the listing is an apartment (апартаменты)."""
92
+ if card.building.is_apartments is True:
93
+ return True
94
+ text = f"{card.title or ''} {card.description_text or ''}".lower()
95
+ return "апартамент" in text
96
+
97
+
98
+ def _is_share(card: ListingCard) -> bool:
99
+ """Return True when the listing is a flat share (доля в квартире)."""
100
+ if card.category == "flatShare":
101
+ return True
102
+ if card.parameters.share_amount is not None:
103
+ return True
104
+ text = f"{card.title or ''} {card.description_text or ''}".lower()
105
+ return "доля в квартире" in text
106
+
107
+
108
+ def _has_legal_risk(card: ListingCard) -> bool:
109
+ """Return True when the description contains a legal-risk stop phrase."""
110
+ text = (card.description_text or "").lower()
111
+ return any(phrase in text for phrase in _LEGAL_RISK_PHRASES)
112
+
113
+
114
+ def _normalize_url(url: str) -> str:
115
+ url = url.strip()
116
+ if not url.startswith("http"):
117
+ url = "https://" + url
118
+ return url
119
+
120
+
121
+ def _target_to_url(target: str) -> str:
122
+ """Normalize a get-card target into a fetchable Cian URL.
123
+
124
+ Accepts a bare numeric id, the tool's own ``cian-<digits>`` id format,
125
+ or a URL. Normalization must happen before netguard sees the target.
126
+ """
127
+ target = target.strip()
128
+ if target.isdigit():
129
+ return f"https://www.cian.ru/sale/flat/{target}/"
130
+ match = re.fullmatch(r"(?i:cian-)?(\d+)", target)
131
+ if match:
132
+ return f"https://www.cian.ru/sale/flat/{match.group(1)}/"
133
+ return _normalize_url(target)
134
+
135
+
136
+ def _cian_id_from_url(url: str) -> str | None:
137
+ match = re.search(r"/sale/flat/(\d+)/?", url)
138
+ if match:
139
+ return match.group(1)
140
+ return None
141
+
142
+
143
+ def detect_challenge(html: str, http_hint: str | None = None) -> tuple[bool, str | None]:
144
+ """Return (is_challenge, reason) for a Cian HTML response."""
145
+ if not html or not html.strip():
146
+ return False, None
147
+
148
+ lowered = html.lower()
149
+
150
+ # Explicit HTTP status markers help even when the body is tiny.
151
+ if http_hint in ("403", "429"):
152
+ return True, f"http_{http_hint}"
153
+
154
+ for marker in _CHALLENGE_MARKERS:
155
+ if marker in lowered:
156
+ return True, f"challenge_marker:{marker}"
157
+
158
+ # Cian's normal pages contain offer data or a search app root. A page that
159
+ # is neither a card nor a search result and contains challenge-looking text
160
+ # has already been caught by the markers above.
161
+ return False, None
162
+
163
+
164
+ def _requests_get_card(url: str, *, timeout: float) -> str | None:
165
+ """v0.3.4: requests-first транспорт для карточки (параллельный hydrate).
166
+
167
+ Возвращает HTML если обычный HTTP отдал полноценную карточку без капчи,
168
+ иначе None -> fallback на Camoufox.
169
+ """
170
+ try:
171
+ from .net_guard import guarded_get
172
+
173
+ resp = guarded_get(
174
+ url,
175
+ timeout=timeout,
176
+ headers={
177
+ "User-Agent": (
178
+ "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) "
179
+ "Gecko/20100101 Firefox/130.0"
180
+ ),
181
+ "Accept": "text/html,application/xhtml+xml",
182
+ "Accept-Language": "ru-RU,ru;q=0.9",
183
+ },
184
+ )
185
+ if resp.status_code == 200 and len(resp.text) > 100_000:
186
+ http_hint = f"HTTP {resp.status_code}"
187
+ is_challenge, _ = detect_challenge(resp.text, http_hint=http_hint)
188
+ if not is_challenge:
189
+ return resp.text
190
+ except Exception as _exc:
191
+ # v0.4.5 (панель R2): DNS-pin и breaker-вердикты не глотаются. Матчинг по
192
+ # ИМЕНИ класса, а не только isinstance: reload(net_guard) в тестах/реальности
193
+ # плодит свежие класс-объекты, и isinstance теряет вердикты (ловлено панелью-за-панелью).
194
+ if isinstance(_exc, (EgressBlockedError, BreakerOpenError)) or type(_exc).__name__ in ("EgressBlockedError", "BreakerOpenError"):
195
+ raise
196
+ except Exception:
197
+ pass
198
+ return None
199
+
200
+
201
+ def get_card(target: str, *, timeout: float = 60.0, settle_ms: int = 1500) -> ListingCard:
202
+ """Fetch and parse a single Cian listing card.
203
+
204
+ ``target`` may be a full URL, a numeric Cian ID, or a ``cian-<digits>``
205
+ ID as emitted by the tool itself.
206
+ """
207
+ url = _target_to_url(target)
208
+
209
+ # v0.3.5: netguard — не идём в браузер, если хост в cooldown (защита подсети)
210
+ from .net_guard import BreakerOpenError, check_before_fetch
211
+
212
+ try:
213
+ check_before_fetch(url)
214
+ except BreakerOpenError as exc:
215
+ raise ChallengeError(
216
+ f"Cian is in cooldown for {url} ({exc}); retry later."
217
+ ) from exc
218
+
219
+ # v0.3.4: requests-first (0.4с вместо ~10с браузера); браузер — fallback
220
+ html_req = _requests_get_card(url, timeout=timeout)
221
+ if html_req is not None:
222
+ try:
223
+ return parse_card(html_req)
224
+ except Exception:
225
+ pass # парсинг не удался — попробуем браузерную версию страницы
226
+
227
+ try:
228
+ html = native_fetch(url, timeout=timeout, settle_ms=settle_ms)
229
+ except FetchError:
230
+ raise
231
+ except Exception as exc:
232
+ raise FetchError(f"failed to fetch {url}: {exc}") from exc
233
+
234
+ http_hint = get_last_http_status()
235
+ is_challenge, reason = detect_challenge(html, http_hint=http_hint)
236
+ if is_challenge:
237
+ raise ChallengeError(
238
+ f"Cian returned a challenge page for {url} (reason={reason}). "
239
+ "Try again later or change network/proxy."
240
+ )
241
+
242
+ try:
243
+ return parse_card(html)
244
+ except ParseError as exc:
245
+ # If the parser cannot find a card, the DOM may have changed or the
246
+ # listing was removed. Surface this as a fetch-time parse error.
247
+ raise FetchError(f"failed to parse card at {url}: {exc}") from exc
248
+
249
+
250
+ # Коды округов Москвы в cat.php (подтверждено реалтором, v0.3.3)
251
+ DISTRICT_CODES: dict[str, int] = {
252
+ "ЦАО": 4, "САО": 5, "СВАО": 6, "ВАО": 7, "ЮВАО": 8,
253
+ "ЮАО": 9, "ЮЗАО": 10, "ЗАО": 11, "СЗАО": 12,
254
+ }
255
+
256
+
257
+ def _district_code(raw: str) -> int | None:
258
+ """'ЮАО' -> 9, '9' -> 9, неизвестное -> None."""
259
+ name = raw.strip().upper()
260
+ if name in DISTRICT_CODES:
261
+ return DISTRICT_CODES[name]
262
+ if raw.strip().isdigit():
263
+ return int(raw.strip())
264
+ return None
265
+
266
+
267
+ def _requests_get_search(url: str, *, timeout: float) -> str | None:
268
+ """v0.3.3 (фидбэк реалтора): requests-first транспорт для cat.php.
269
+
270
+ Возвращает HTML если обычный HTTP отдал полноценную выдачу без капчи,
271
+ иначе None -> fallback на Camoufox.
272
+ """
273
+ try:
274
+ from .net_guard import guarded_get
275
+
276
+ resp = guarded_get(
277
+ url,
278
+ timeout=timeout,
279
+ headers={
280
+ "User-Agent": (
281
+ "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) "
282
+ "Gecko/20100101 Firefox/130.0"
283
+ ),
284
+ "Accept": "text/html,application/xhtml+xml",
285
+ "Accept-Language": "ru-RU,ru;q=0.9",
286
+ },
287
+ )
288
+ if resp.status_code == 200 and len(resp.text) > 50_000:
289
+ http_hint = f"HTTP {resp.status_code}"
290
+ is_challenge, reason = detect_challenge(resp.text, http_hint=http_hint)
291
+ if not is_challenge:
292
+ return resp.text
293
+ except Exception as _exc:
294
+ # v0.4.5 (панель R2): DNS-pin и breaker-вердикты не глотаются. Матчинг по
295
+ # ИМЕНИ класса, а не только isinstance: reload(net_guard) в тестах/реальности
296
+ # плодит свежие класс-объекты, и isinstance теряет вердикты (ловлено панелью-за-панелью).
297
+ if isinstance(_exc, (EgressBlockedError, BreakerOpenError)) or type(_exc).__name__ in ("EgressBlockedError", "BreakerOpenError"):
298
+ raise
299
+ except Exception:
300
+ pass
301
+ return None
302
+
303
+
304
+ def _build_search_url(
305
+ *,
306
+ deal_type: str | None = "sale",
307
+ rooms: int | str | None = None,
308
+ price_min: float | None = None,
309
+ price_max: float | None = None,
310
+ kitchen_min: float | None = None,
311
+ floor_not_first: bool = False,
312
+ min_house_floors: int | None = None,
313
+ geo_districts: str | None = None,
314
+ limit: int | None = None,
315
+ page: int = 1,
316
+ metro_walk_max: int | None = None,
317
+ ) -> str:
318
+ """Build a Cian search URL from CLI-style filters.
319
+
320
+ This is intentionally simple: the native browser fetches the URL and the
321
+ result page is parsed for listing links. The URL is kept compatible with
322
+ Cian's ``cat.php`` endpoint.
323
+ """
324
+ params: dict[str, Any] = {
325
+ "deal_type": deal_type or "sale",
326
+ "engine_version": 2,
327
+ "offer_type": "flat",
328
+ "region": 1, # Moscow
329
+ "p": max(1, int(page)),
330
+ }
331
+
332
+ if rooms is not None:
333
+ if isinstance(rooms, str) and rooms.lower() in ("studio", "студия"):
334
+ params["room0"] = 1
335
+ elif rooms == 1:
336
+ params["room1"] = 1
337
+ elif rooms == 2:
338
+ params["room2"] = 1
339
+ elif rooms == 3:
340
+ params["room3"] = 1
341
+ elif rooms >= 4:
342
+ params["room9"] = 1 # 4+
343
+
344
+ if price_min is not None:
345
+ params["minprice"] = int(price_min)
346
+ if price_max is not None:
347
+ params["maxprice"] = int(price_max)
348
+
349
+ if kitchen_min is not None:
350
+ params["kitchen[0]"] = kitchen_min
351
+
352
+ # v0.3.2 (ТЗ реалтора, блок 1b): server-side фильтры Циан
353
+ if floor_not_first:
354
+ params["minfloor"] = 2 # не первый этаж
355
+ if min_house_floors is not None:
356
+ # Этажность ДОМА; minfloors Циан игнорирует
357
+ params["minhousefloors"] = int(min_house_floors)
358
+ if geo_districts:
359
+ # 'ЮАО,ЮЗАО' или '9,10' -> district[0]=9&district[1]=10 (v0.3.3: имена тоже)
360
+ codes = [d.strip() for d in str(geo_districts).split(",") if d.strip()]
361
+ idx = 0
362
+ for code in codes:
363
+ num = _district_code(code)
364
+ if num is None:
365
+ raise ValueError(
366
+ f"unknown district '{code}'; use name (ЦАО..СЗАО) or code "
367
+ f"({min(DISTRICT_CODES.values())}..{max(DISTRICT_CODES.values())})"
368
+ )
369
+ params[f"district[{idx}]"] = num
370
+ idx += 1
371
+ if metro_walk_max is not None:
372
+ params["in_metro_time"] = int(metro_walk_max)
373
+ params["metro_transport_type"] = "walk"
374
+
375
+ # limit: client-side slice in search().
376
+
377
+ query = urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
378
+ return f"https://www.cian.ru/cat.php?{query}"
379
+
380
+
381
+ def _container_segments(link) -> list[str] | None:
382
+ """v0.4.3: подняться от <a> к карточке-контейнеру выдачи (data-testid=offer-card)."""
383
+ node = link
384
+ for _ in range(10):
385
+ node = getattr(node, "parent", None)
386
+ if node is None:
387
+ return None
388
+ try:
389
+ if node.attrs.get("data-testid") == "offer-card":
390
+ return [s.strip() for s in node.stripped_strings if s.strip()]
391
+ except AttributeError:
392
+ return None
393
+ return None
394
+
395
+
396
+ _PRICE_RE = __import__("re").compile(r"^([\d][\d\s\u00a0\u202f]{4,})\s*₽$")
397
+ _PRICE_M2_RE = __import__("re").compile(r"^([\d][\d\s\u00a0\u202f]{2,})\s*₽/м")
398
+ _FLOOR_RE = __import__("re").compile(r"(\d+)\s*/\s*(\d+)\s*эт")
399
+ _ROOMS_RE = __import__("re").compile(r"^(\d+)[- ]комн")
400
+ _AREA_RE = __import__("re").compile(r"([\d]+(?:[.,]\d+)?)\s*м²")
401
+ _KITCHEN_RE = __import__("re").compile(r"кухня[^\d]{0,4}([\d]+(?:[.,]\d+)?)\s*м", __import__("re").IGNORECASE)
402
+ _COMPLEX_RE = __import__("re").compile(r"ЖК\s*[«\"]([^»\"]{2,40})[»\"]")
403
+ _METRO_TIME_RE = __import__("re").compile(r"^(\d+)\s*(?:мин\w*|м)\b.*?(пешком|транспортом|на метро)")
404
+
405
+
406
+ def _digits_to_amount(group: str, cap: float) -> float | None:
407
+ """v0.4.4 (независимый аудит sa-2, F-01): цифра с верхней границей.
408
+
409
+ Склеенные в один сегмент два ценника ('16 900 000 18 000 000 ₽') давали
410
+ 1.69e15 — несуществующую сумму, которая всё равно уходила в выдачу.
411
+ Sanity-капер: значение выше границы = None (fail-closed), мусор не продаём.
412
+ """
413
+ d = __import__("re").sub(r"\D", "", group)
414
+ if not d or len(d) > 11:
415
+ return None
416
+ v = float(int(d))
417
+ return v if v <= cap else None
418
+
419
+
420
+ def _snippet_fields(segs: list[str]) -> dict:
421
+ """v0.4.3: базовые поля из текста сниппета. Непрочитанное = None, догадок нет."""
422
+ out: dict = {}
423
+ price_i = None
424
+ for i, s in enumerate(segs):
425
+ if _PRICE_RE.match(s):
426
+ price_i = i
427
+ break
428
+ scope = segs[: price_i + 2] if price_i is not None else segs[:40]
429
+
430
+ if price_i is not None:
431
+ m = _PRICE_RE.match(segs[price_i])
432
+ if m:
433
+ sane = _digits_to_amount(m.group(1), 10**11)
434
+ if sane is not None:
435
+ out["price_total_rub"] = sane
436
+ for s in scope:
437
+ if price_i is not None and s is segs[price_i]:
438
+ continue
439
+ m = _PRICE_M2_RE.match(s)
440
+ if m:
441
+ sane = _digits_to_amount(m.group(1), 10**8)
442
+ if sane is not None:
443
+ out["price_per_m2_rub"] = sane
444
+ break
445
+
446
+ short = [s for s in scope if len(s) < 120]
447
+ for s in short:
448
+ m = _FLOOR_RE.search(s)
449
+ if m:
450
+ out["floor"] = int(m.group(1))
451
+ out["floors_total"] = int(m.group(2))
452
+ break
453
+ for s in short:
454
+ m = _ROOMS_RE.match(s)
455
+ if m:
456
+ out["rooms"] = int(m.group(1))
457
+ a = _AREA_RE.search(s)
458
+ if a:
459
+ out["area_total_m2"] = float(a.group(1).replace(",", "."))
460
+ if "студия" in s.lower():
461
+ out["rooms_type"] = "студия"
462
+ break
463
+ for s in short:
464
+ m = _KITCHEN_RE.search(s)
465
+ if m:
466
+ out["area_kitchen_m2"] = float(m.group(1).replace(",", "."))
467
+ break
468
+ joined = " | ".join(short)
469
+ m = _COMPLEX_RE.search(joined)
470
+ if m:
471
+ out["complex"] = m.group(1).strip()
472
+
473
+ city_i = next((i for i, s in enumerate(scope) if s == "Москва" or s.startswith("Москва,")), None)
474
+ if city_i is not None:
475
+ parts: list[str] = []
476
+ for s in scope[city_i:city_i + 14]:
477
+ if _PRICE_RE.match(s) or _PRICE_M2_RE.match(s):
478
+ break
479
+ if s == ",":
480
+ parts.append(",")
481
+ else:
482
+ parts.append(s)
483
+ addr = " ".join(parts).replace(" ,", ",").strip(" ,")
484
+ if addr:
485
+ out["address_raw"] = addr
486
+
487
+ if price_i is not None:
488
+ for i in range(max(0, price_i - 24), price_i):
489
+ m = _METRO_TIME_RE.match(segs[i])
490
+ if m and i > 0 and 2 <= len(segs[i - 1]) <= 24 and segs[i - 1].isalpha():
491
+ tt = "пешком" if "пешком" in m.group(2) else "метро/транспорт"
492
+ out["metro"] = {"station": segs[i - 1], "claimed_time_minutes": int(m.group(1)),
493
+ "transport_type": tt}
494
+ break
495
+ return out
496
+
497
+
498
+ def _apply_snippet(card, fields: dict) -> None:
499
+ if not fields:
500
+ return
501
+ p = card.parameters
502
+ if "rooms" in fields:
503
+ p.rooms = fields["rooms"]
504
+ if "rooms_type" in fields:
505
+ p.rooms_type = fields["rooms_type"]
506
+ if "area_total_m2" in fields:
507
+ p.area_total_m2 = fields["area_total_m2"]
508
+ if "area_kitchen_m2" in fields:
509
+ p.area_kitchen_m2 = fields["area_kitchen_m2"]
510
+ if "floor" in fields:
511
+ p.floor = fields["floor"]
512
+ if "floors_total" in fields:
513
+ p.floors_total = fields["floors_total"]
514
+ if "address_raw" in fields:
515
+ card.building.address_raw = fields["address_raw"]
516
+ if "complex" in fields:
517
+ card.building.complex = fields["complex"]
518
+ if "price_total_rub" in fields:
519
+ card.pricing.price_total_rub = fields["price_total_rub"]
520
+ if "price_per_m2_rub" in fields:
521
+ card.pricing.price_per_m2_rub = fields["price_per_m2_rub"]
522
+ if "metro" in fields:
523
+ from .models import CianClaimedMetro
524
+ card.geo.cian_claimed_metro = CianClaimedMetro(**fields["metro"])
525
+
526
+
527
+ def _merge_snippet_into_hydrated(skel, card) -> None:
528
+ """v0.4.3 инвариант: hydrated — источник истины; сниппет дополняет только пустое."""
529
+ sp, cp = skel.parameters, card.parameters
530
+ for attr in ("rooms", "rooms_type", "area_total_m2", "area_kitchen_m2", "floor", "floors_total"):
531
+ if getattr(cp, attr) is None and getattr(sp, attr) is not None:
532
+ setattr(cp, attr, getattr(sp, attr))
533
+ for attr in ("address_raw", "complex"):
534
+ if getattr(card.building, attr) is None and getattr(skel.building, attr) is not None:
535
+ setattr(card.building, attr, getattr(skel.building, attr))
536
+ for attr in ("price_total_rub", "price_per_m2_rub"):
537
+ if getattr(card.pricing, attr) is None and getattr(skel.pricing, attr) is not None:
538
+ setattr(card.pricing, attr, getattr(skel.pricing, attr))
539
+ if card.geo.cian_claimed_metro is None and skel.geo.cian_claimed_metro is not None:
540
+ card.geo.cian_claimed_metro = skel.geo.cian_claimed_metro
541
+
542
+
543
+ def _parse_search_result_links(html: str, limit: int | None = None) -> list[ListingCard]:
544
+ """Extract listing cards from a Cian search result page.
545
+
546
+ v0.4.3 (ТЗ the task brief): из контейнера сниппета (data-testid=offer-card)
547
+ одним прогоном читаются базовые поля (цена/площадь/комнатность/адрес/
548
+ ЖК/этаж-этажность + best-effort метро). Непрочитанное остаётся None.
549
+ Карточка по-прежнему может быть обогащена через get_card (hydrate),
550
+ hydrated-значения имеют приоритет (_merge_snippet_into_hydrated).
551
+ """
552
+ from bs4 import BeautifulSoup
553
+
554
+ soup = BeautifulSoup(html, "lxml")
555
+ by_id: dict[str, ListingCard] = {}
556
+ order: list[str] = []
557
+
558
+ # Cian search results link to /sale/flat/<id>/
559
+ for link in soup.find_all("a", href=True):
560
+ href = str(link["href"])
561
+ cid = _cian_id_from_url(href)
562
+ if not cid:
563
+ continue
564
+ if cid not in by_id:
565
+ card = ListingCard(
566
+ id=f"cian-{cid}",
567
+ source="cian",
568
+ url=f"https://www.cian.ru/sale/flat/{cid}/",
569
+ status="active",
570
+ )
571
+ by_id[cid] = card
572
+ order.append(cid)
573
+ else:
574
+ card = by_id[cid]
575
+ if card.parameters.floor is None: # ещё не обогатали
576
+ segs = _container_segments(link)
577
+ if segs:
578
+ _apply_snippet(card, _snippet_fields(segs))
579
+ if limit is not None and len(order) >= limit:
580
+ break
581
+
582
+ return [by_id[c] for c in order[: limit or len(order)]]
583
+
584
+
585
+ def _post_filter_cards(
586
+ cards: list[ListingCard],
587
+ *,
588
+ floor_not_first: bool = False,
589
+ min_house_floors: int | None = None,
590
+ geo_districts: str | None = None,
591
+ limit: int | None = None,
592
+ ) -> list[ListingCard]:
593
+ """Apply client-side filters that lack reliable Cian URL parameters.
594
+
595
+ ``floor_not_first`` and ``min_house_floors`` require per-card
596
+ ``parameters`` data (floor / floors_total). ``geo_districts`` requires
597
+ ``geo.district``. These fields are absent on lightweight search-result
598
+ cards and only effective on fully hydrated cards.
599
+
600
+ ``limit`` always applies (slice).
601
+ """
602
+ if floor_not_first:
603
+ cards = [c for c in cards if c.parameters.floor != 1]
604
+ if min_house_floors is not None:
605
+ cards = [
606
+ c
607
+ for c in cards
608
+ if c.parameters.floors_total is None
609
+ or c.parameters.floors_total >= min_house_floors
610
+ ]
611
+ if geo_districts is not None:
612
+ districts = {
613
+ d.strip().lower() for d in geo_districts.split(",") if d.strip()
614
+ }
615
+ if districts:
616
+ cards = [
617
+ c
618
+ for c in cards
619
+ if c.geo.district is None
620
+ or c.geo.district.lower() in districts
621
+ ]
622
+ if limit is not None:
623
+ cards = cards[:limit]
624
+ return cards
625
+
626
+
627
+ def search(
628
+ *,
629
+ deal_type: str | None = "sale",
630
+ rooms: int | str | None = None,
631
+ price_min: float | None = None,
632
+ price_max: float | None = None,
633
+ kitchen_min: float | None = None,
634
+ floor_not_first: bool = False,
635
+ min_house_floors: int | None = None,
636
+ geo_districts: str | None = None,
637
+ limit: int | None = None,
638
+ timeout: float = 60.0,
639
+ settle_ms: int = 2500,
640
+ page: int = 1,
641
+ pages: int | None = None,
642
+ metro_walk_max: int | None = None,
643
+ hydrate: bool = False,
644
+ no_auction: bool = False,
645
+ only_auction: bool = False,
646
+ workers: int = 4,
647
+ # v0.4.0 (ТЗ реалтора): боевые фильтры риелтора
648
+ strict_mkad: bool = False,
649
+ only_flat: bool = False,
650
+ no_shares: bool = False,
651
+ exclude_legal_risks: bool = False,
652
+ ) -> list[ListingCard]:
653
+ """Fetch Cian search result pages and return a list of listing cards.
654
+
655
+ v0.3.2 (ТЗ реалтора):
656
+ - ``page``/``pages``: пагинация (pages=3 соберёт страницы 1..3);
657
+ - ``metro_walk_max``: server-side пешая доступность (in_metro_time+walk);
658
+ - ``hydrate``: прогнать полученные URL через get_card для полных карточек.
659
+ """
660
+
661
+ def _fetch_page(page_no: int) -> list[ListingCard]:
662
+ url = _build_search_url(
663
+ deal_type=deal_type,
664
+ rooms=rooms,
665
+ price_min=price_min,
666
+ price_max=price_max,
667
+ kitchen_min=kitchen_min,
668
+ floor_not_first=floor_not_first,
669
+ min_house_floors=min_house_floors,
670
+ geo_districts=geo_districts,
671
+ limit=limit,
672
+ page=page_no,
673
+ metro_walk_max=metro_walk_max,
674
+ )
675
+ html: str | None = _requests_get_search(url, timeout=timeout)
676
+ if html is None:
677
+ # v0.3.5: netguard — не идём в браузер, если хост в cooldown.
678
+ # Проверяем ТОЛЬКО breaker (без DNS-резолва: egress уже проверен
679
+ # в _requests_get_search, а резолв здесь ломал офлайн-работу).
680
+ from .net_guard import breaker_remaining
681
+
682
+ remaining = breaker_remaining(url)
683
+ if remaining > 0:
684
+ raise ChallengeError(
685
+ f"Cian is in cooldown for search ({remaining:.0f}s left); retry later."
686
+ )
687
+ try:
688
+ html = native_fetch(url, timeout=timeout, settle_ms=settle_ms)
689
+ except FetchError:
690
+ raise
691
+ except Exception as exc:
692
+ raise FetchError(f"failed to fetch search {url}: {exc}") from exc
693
+ http_hint = get_last_http_status()
694
+ is_challenge, reason = detect_challenge(html, http_hint=http_hint)
695
+ if is_challenge:
696
+ raise ChallengeError(
697
+ f"Cian returned a challenge page for search (reason={reason}). "
698
+ "Try again later or change network/proxy."
699
+ )
700
+
701
+ cards = _parse_search_result_links(html)
702
+ if not cards:
703
+ return []
704
+ return _post_filter_cards(
705
+ cards,
706
+ floor_not_first=floor_not_first,
707
+ min_house_floors=min_house_floors,
708
+ geo_districts=geo_districts,
709
+ limit=limit,
710
+ )
711
+
712
+ page_list = [page] if not pages or pages < 2 else list(range(1, int(pages) + 1))
713
+ all_cards: list[ListingCard] = []
714
+ seen_urls: set[str] = set()
715
+ for no in page_list:
716
+ for card in _fetch_page(no):
717
+ if card.url in seen_urls:
718
+ continue
719
+ seen_urls.add(card.url)
720
+ all_cards.append(card)
721
+ if no != page_list[-1] and settle_ms:
722
+ time.sleep(settle_ms / 1000.0)
723
+
724
+ # v0.3.4 (ТЗ реалтора): фильтр торгов/аукционов (требует hydrate-полей
725
+ # is_auction; на скелетах поле None — пропускаем без решения, фильтруем
726
+ # только когда значение известно)
727
+ def _auction_known(c: ListingCard) -> bool:
728
+ return c.pricing.is_auction is not None
729
+
730
+ def _auction_val(c: ListingCard) -> bool:
731
+ return bool(c.pricing.is_auction)
732
+
733
+ if no_auction or only_auction:
734
+ unknown = [c for c in all_cards if not _auction_known(c)]
735
+ known = [c for c in all_cards if _auction_known(c)]
736
+ if only_auction:
737
+ known = [c for c in known if _auction_val(c)]
738
+ if no_auction:
739
+ known = [c for c in known if not _auction_val(c)]
740
+ # скелеты с неизвестным статусом сохраняем только если оба флага согласованы
741
+ all_cards = known + unknown if not (no_auction and only_auction) else known
742
+
743
+ if limit is not None:
744
+ all_cards = all_cards[: int(limit)]
745
+
746
+ if hydrate:
747
+ from concurrent.futures import ThreadPoolExecutor, as_completed
748
+
749
+ hydrated: list[ListingCard | None] = [None] * len(all_cards)
750
+
751
+ def _hydrate_one(idx_url: tuple[int, str]) -> tuple[int, ListingCard]:
752
+ idx, url = idx_url
753
+ try:
754
+ card = get_card(url, timeout=timeout, settle_ms=settle_ms)
755
+ # v0.4.3: сниппет-поля дополняют пустое в hydrated (никогда не перекрывают)
756
+ _merge_snippet_into_hydrated(all_cards[idx], card)
757
+ return idx, card
758
+ except (FetchError, ChallengeError):
759
+ return idx, all_cards[idx] # fail-open: скелет
760
+
761
+ max_workers = max(1, min(int(workers), len(all_cards) or 1))
762
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
763
+ futures = [pool.submit(_hydrate_one, (i, c.url)) for i, c in enumerate(all_cards)]
764
+ for fut in as_completed(futures):
765
+ idx, card = fut.result()
766
+ hydrated[idx] = card
767
+ all_cards = [c for c in hydrated if c is not None]
768
+
769
+ # v0.4.3 (ТЗ the task brief §2): напольные фильтры повторяются ПОСЛЕ гидрации —
770
+ # карточки, у которых в сниппете этаж был None и не отсеялись заранее,
771
+ # проверяются по полным данным (correctness > экономии), None проходит дальше.
772
+ if floor_not_first:
773
+ all_cards = [c for c in all_cards if c.parameters.floor != 1]
774
+ if min_house_floors is not None:
775
+ all_cards = [
776
+ c for c in all_cards
777
+ if c.parameters.floors_total is None
778
+ or c.parameters.floors_total >= int(min_house_floors)
779
+ ]
780
+ # v0.4.4 (независимый аудит sa-2, F-02): аукционы/гео применялись только к
781
+ # скелетам ДО гидрации (поля None => no-op) и не повторялись после — в бою
782
+ # не фильтровали. Повтор post-hydrate: известное значение — решающее,
783
+ # None после гидрации (не удалось прочитать) остаётся честным no-op.
784
+ if no_auction or only_auction:
785
+ known = [c for c in all_cards if c.pricing.is_auction is not None]
786
+ unknown = [c for c in all_cards if c.pricing.is_auction is None]
787
+ if only_auction:
788
+ known = [c for c in known if bool(c.pricing.is_auction)]
789
+ if no_auction:
790
+ known = [c for c in known if not c.pricing.is_auction]
791
+ all_cards = known + unknown
792
+ if geo_districts:
793
+ districts = {d.strip().lower() for d in geo_districts.split(",") if d.strip()}
794
+ if districts:
795
+ all_cards = [
796
+ c for c in all_cards
797
+ if c.geo.district is None or c.geo.district.lower() in districts
798
+ ]
799
+
800
+ # v0.4.0 (ТЗ реалтора): фильтры требуют полных карточек (hydrate=True)
801
+ if strict_mkad:
802
+ all_cards = [c for c in all_cards if not _is_strict_mkad(c)]
803
+ if only_flat:
804
+ all_cards = [c for c in all_cards if not _is_apartment(c)]
805
+ if no_shares:
806
+ all_cards = [c for c in all_cards if not _is_share(c)]
807
+ if exclude_legal_risks:
808
+ all_cards = [c for c in all_cards if not _has_legal_risk(c)]
809
+ if limit is not None:
810
+ all_cards = all_cards[: int(limit)]
811
+
812
+ return all_cards