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.
- nedviga/__init__.py +46 -0
- nedviga/__main__.py +6 -0
- nedviga/_wm.py +93 -0
- nedviga/adaptive_selectors.py +100 -0
- nedviga/auditlog.py +77 -0
- nedviga/auto.py +88 -0
- nedviga/bargain.py +183 -0
- nedviga/browser_native.py +242 -0
- nedviga/callsheet.py +63 -0
- nedviga/chains.py +83 -0
- nedviga/checklist.py +119 -0
- nedviga/cian_card.py +1371 -0
- nedviga/cian_live.py +812 -0
- nedviga/cli.py +3118 -0
- nedviga/compare.py +204 -0
- nedviga/data/legal_flags.json +146 -0
- nedviga/data/release_pubkey.asc +22 -0
- nedviga/deck.py +115 -0
- nedviga/dedup.py +242 -0
- nedviga/developer.py +169 -0
- nedviga/digest.py +205 -0
- nedviga/doctor.py +128 -0
- nedviga/dossier.py +303 -0
- nedviga/eco.py +81 -0
- nedviga/errors.py +22 -0
- nedviga/export_html.py +280 -0
- nedviga/export_pdf.py +96 -0
- nedviga/family_card.py +670 -0
- nedviga/feedback.py +79 -0
- nedviga/geo_routing.py +214 -0
- nedviga/graph.py +149 -0
- nedviga/legal_triage.py +105 -0
- nedviga/litprecheck.py +116 -0
- nedviga/market.py +102 -0
- nedviga/mcp_server.py +195 -0
- nedviga/metro_directory.json +9836 -0
- nedviga/metro_vestibules.py +134 -0
- nedviga/models.py +164 -0
- nedviga/mosdata.py +95 -0
- nedviga/net_guard.py +997 -0
- nedviga/noise.py +156 -0
- nedviga/ops.py +94 -0
- nedviga/outcomes.py +35 -0
- nedviga/pii.py +85 -0
- nedviga/pipeline.py +239 -0
- nedviga/poi.py +180 -0
- nedviga/price_guard.py +73 -0
- nedviga/profiles.py +131 -0
- nedviga/projects.py +112 -0
- nedviga/purge.py +116 -0
- nedviga/py.typed +0 -0
- nedviga/risk.py +127 -0
- nedviga/roles.py +124 -0
- nedviga/routing_cache.py +72 -0
- nedviga/rush.py +139 -0
- nedviga/scorefit.py +127 -0
- nedviga/sources.py +55 -0
- nedviga/sources_avito.py +138 -0
- nedviga/sources_domclick.py +122 -0
- nedviga/stale.py +83 -0
- nedviga/textsearch.py +141 -0
- nedviga/tour.py +180 -0
- nedviga/watch.py +234 -0
- nedviga/wizard.py +106 -0
- nedviga_cli-0.1.0.dist-info/METADATA +130 -0
- nedviga_cli-0.1.0.dist-info/RECORD +71 -0
- nedviga_cli-0.1.0.dist-info/WHEEL +5 -0
- nedviga_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nedviga_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
- nedviga_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
- 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
|