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_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
|
+
)
|