nedviga-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. nedviga/__init__.py +46 -0
  2. nedviga/__main__.py +6 -0
  3. nedviga/_wm.py +93 -0
  4. nedviga/adaptive_selectors.py +100 -0
  5. nedviga/auditlog.py +77 -0
  6. nedviga/auto.py +88 -0
  7. nedviga/bargain.py +183 -0
  8. nedviga/browser_native.py +242 -0
  9. nedviga/callsheet.py +63 -0
  10. nedviga/chains.py +83 -0
  11. nedviga/checklist.py +119 -0
  12. nedviga/cian_card.py +1371 -0
  13. nedviga/cian_live.py +812 -0
  14. nedviga/cli.py +3118 -0
  15. nedviga/compare.py +204 -0
  16. nedviga/data/legal_flags.json +146 -0
  17. nedviga/data/release_pubkey.asc +22 -0
  18. nedviga/deck.py +115 -0
  19. nedviga/dedup.py +242 -0
  20. nedviga/developer.py +169 -0
  21. nedviga/digest.py +205 -0
  22. nedviga/doctor.py +128 -0
  23. nedviga/dossier.py +303 -0
  24. nedviga/eco.py +81 -0
  25. nedviga/errors.py +22 -0
  26. nedviga/export_html.py +280 -0
  27. nedviga/export_pdf.py +96 -0
  28. nedviga/family_card.py +670 -0
  29. nedviga/feedback.py +79 -0
  30. nedviga/geo_routing.py +214 -0
  31. nedviga/graph.py +149 -0
  32. nedviga/legal_triage.py +105 -0
  33. nedviga/litprecheck.py +116 -0
  34. nedviga/market.py +102 -0
  35. nedviga/mcp_server.py +195 -0
  36. nedviga/metro_directory.json +9836 -0
  37. nedviga/metro_vestibules.py +134 -0
  38. nedviga/models.py +164 -0
  39. nedviga/mosdata.py +95 -0
  40. nedviga/net_guard.py +997 -0
  41. nedviga/noise.py +156 -0
  42. nedviga/ops.py +94 -0
  43. nedviga/outcomes.py +35 -0
  44. nedviga/pii.py +85 -0
  45. nedviga/pipeline.py +239 -0
  46. nedviga/poi.py +180 -0
  47. nedviga/price_guard.py +73 -0
  48. nedviga/profiles.py +131 -0
  49. nedviga/projects.py +112 -0
  50. nedviga/purge.py +116 -0
  51. nedviga/py.typed +0 -0
  52. nedviga/risk.py +127 -0
  53. nedviga/roles.py +124 -0
  54. nedviga/routing_cache.py +72 -0
  55. nedviga/rush.py +139 -0
  56. nedviga/scorefit.py +127 -0
  57. nedviga/sources.py +55 -0
  58. nedviga/sources_avito.py +138 -0
  59. nedviga/sources_domclick.py +122 -0
  60. nedviga/stale.py +83 -0
  61. nedviga/textsearch.py +141 -0
  62. nedviga/tour.py +180 -0
  63. nedviga/watch.py +234 -0
  64. nedviga/wizard.py +106 -0
  65. nedviga_cli-0.1.0.dist-info/METADATA +130 -0
  66. nedviga_cli-0.1.0.dist-info/RECORD +71 -0
  67. nedviga_cli-0.1.0.dist-info/WHEEL +5 -0
  68. nedviga_cli-0.1.0.dist-info/entry_points.txt +2 -0
  69. nedviga_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
  70. nedviga_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
  71. nedviga_cli-0.1.0.dist-info/top_level.txt +1 -0
nedviga/risk.py ADDED
@@ -0,0 +1,127 @@
1
+ """v0.4.9 (the task brief) F-07b/F-19: risk scoring."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from .bargain import _load_cards, _ppm2, _percentile
10
+ from .models import ListingCard
11
+
12
+
13
+ URGENT_WORDS = ["срочно", "торопимся", "переоценка", "обмен", "срочная"]
14
+ DISCOUNT_WORDS = ["без торга", "реально всё", "реально все", "без посредников"]
15
+
16
+
17
+ def _listing(card: Any) -> ListingCard:
18
+ if isinstance(card, ListingCard):
19
+ return card
20
+ if isinstance(card, dict):
21
+ return ListingCard(**card)
22
+ if isinstance(card, str):
23
+ return _load_cards(card)[0]
24
+ raise TypeError("unsupported card")
25
+
26
+
27
+ def _title_caps_ratio(title: str) -> float:
28
+ letters = [ch for ch in title if ch.isalpha()]
29
+ if not letters:
30
+ return 0.0
31
+ caps = [ch for ch in letters if ch.isupper()]
32
+ return len(caps) / len(letters)
33
+
34
+
35
+ def _exclamations(title: str) -> int:
36
+ return title.count("!")
37
+
38
+
39
+ def score_card(card: Any, corpus: Optional[Any] = None, district: Optional[str] = None,
40
+ rooms: Optional[int] = None) -> dict[str, Any]:
41
+ c = _listing(card)
42
+ title = c.title or ""
43
+ low = title.lower()
44
+ reasons: list[str] = []
45
+ score = 0.0
46
+
47
+ for w in URGENT_WORDS:
48
+ if w in low:
49
+ reasons.append(f"urgent:{w}")
50
+ score += 0.15
51
+
52
+ if _title_caps_ratio(title) > 0.6:
53
+ reasons.append("caps_ratio>0.6")
54
+ score += 0.2
55
+
56
+ if _exclamations(title) >= 3:
57
+ reasons.append("exclamations>=3")
58
+ score += 0.15
59
+
60
+ # minus signal: discount phrases
61
+ for w in DISCOUNT_WORDS:
62
+ if w in low:
63
+ reasons.append(f"discount_phrase:{w}")
64
+ score -= 0.1
65
+
66
+ # price vs group
67
+ bait_reasons: list[str] = []
68
+ bait_suspect = False
69
+ if corpus is not None:
70
+ try:
71
+ corpus_cards = corpus if isinstance(corpus, list) else _load_cards(corpus) if isinstance(corpus, str) else corpus
72
+ except Exception:
73
+ corpus_cards = []
74
+ # normalize
75
+ if not isinstance(corpus_cards, list):
76
+ corpus_cards = []
77
+ # filter district/rooms
78
+ t_district = district or (c.geo.district if c.geo else None)
79
+ t_rooms = rooms if rooms is not None else c.parameters.rooms
80
+ group_prices = []
81
+ for cc in corpus_cards:
82
+ try:
83
+ lc = cc if isinstance(cc, ListingCard) else ListingCard(**cc) if isinstance(cc, dict) else None
84
+ except Exception:
85
+ lc = None
86
+ if lc is None:
87
+ continue
88
+ cd = lc.geo.district if lc.geo else None
89
+ cr = lc.parameters.rooms
90
+ if t_district is not None and cd != t_district:
91
+ continue
92
+ if t_rooms is not None and cr is not None and abs(cr - t_rooms) > 1:
93
+ continue
94
+ p = _ppm2(lc)
95
+ if p is not None:
96
+ group_prices.append(p)
97
+ if len(group_prices) >= 20:
98
+ s = sorted(group_prices)
99
+ p10 = _percentile(s, 0.10)
100
+ median = _percentile(s, 0.5)
101
+ target_price = _ppm2(c)
102
+ if target_price is not None and target_price < p10:
103
+ reasons.append("price_below_p10")
104
+ score += 0.4
105
+ if target_price is not None and median and target_price < 0.6 * median:
106
+ bait_suspect = True
107
+ bait_reasons.append("price_below_0.6_median")
108
+ else:
109
+ # not enough sample, no strong signal
110
+ pass
111
+
112
+ score = max(0.0, min(1.0, score))
113
+ if bait_suspect:
114
+ bait_reasons.append("похоже на недостоверное объявление — проверьте звонком")
115
+
116
+ return {
117
+ "risk_score": round(score, 3),
118
+ "reasons": reasons,
119
+ "bait_suspect": bait_suspect,
120
+ "bait_reasons": bait_reasons,
121
+ }
122
+
123
+
124
+ def bait_text(result: dict[str, Any]) -> str:
125
+ if not result.get("bait_suspect"):
126
+ return ""
127
+ return "похоже на недостоверное объявление — проверьте звонком: " + "; ".join(result.get("bait_reasons", []))
nedviga/roles.py ADDED
@@ -0,0 +1,124 @@
1
+ """v0.5.5 (the task brief) roles."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ WRITE_COMMANDS = {
10
+ "pipeline": {"set"},
11
+ "watch": {"add", "rm"},
12
+ "feedback": {"add", "apply"},
13
+ "project": {"new", "add", "refresh"},
14
+ "chain": {"new", "link"},
15
+ "checklist": {"handoff"},
16
+ "purge": {"card", "profile"},
17
+ }
18
+
19
+ READ_COMMANDS = {
20
+ "search", "get-card", "export", "import", "walk-distance", "doctor",
21
+ "pipeline", "watch", "digest", "compare", "bargain", "risk", "triage",
22
+ "checklist", "callsheet", "project", "poi", "tour", "market", "stale",
23
+ "catalog-pdf", "sources", "dedup", "scorefit", "chain", "text", "audit",
24
+ "pii-log", "feedback",
25
+ }
26
+
27
+
28
+ def roles_file() -> Path:
29
+ p = os.environ.get("NEDVIGA_ROLES_FILE")
30
+ if p:
31
+ return Path(p)
32
+ base = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
33
+ return base / "roles.json"
34
+
35
+
36
+ def load_roles() -> list[dict]:
37
+ p = roles_file()
38
+ if not p.exists():
39
+ return []
40
+ try:
41
+ data = json.loads(p.read_text(encoding="utf-8"))
42
+ except Exception:
43
+ return []
44
+ if isinstance(data, list):
45
+ return data
46
+ return []
47
+
48
+
49
+ def current_role() -> str:
50
+ return os.environ.get("NEDVIGA_ROLE", "agent")
51
+
52
+
53
+ def validate_role() -> tuple[bool, Optional[str]]:
54
+ role = current_role()
55
+ roles = load_roles()
56
+ # default agent allowed even without manifest? Task: family/auditor must be declared explicitly.
57
+ if role == "agent":
58
+ return True, None
59
+ for r in roles:
60
+ if r.get("name") == role or r.get("role") == role:
61
+ # manifest contains role
62
+ if r.get("role") == role:
63
+ return True, None
64
+ # also allow if manifest lists role as a role value
65
+ for r in roles:
66
+ if r.get("role") == role:
67
+ return True, None
68
+ return False, "unknown_role"
69
+
70
+
71
+ def _subcommand(args) -> Optional[str]:
72
+ # determine subcommand for grouped commands
73
+ for attr in ("pipeline_action", "watch_action", "feedback_action", "project_action",
74
+ "chain_action", "checklist_action", "text_action", "sources_action"):
75
+ v = getattr(args, attr, None)
76
+ if v:
77
+ return v
78
+ return None
79
+
80
+
81
+ def is_write(args) -> bool:
82
+ cmd = getattr(args, "command", None)
83
+ sub = _subcommand(args)
84
+ if cmd in ("purge",):
85
+ return True
86
+ if cmd in WRITE_COMMANDS:
87
+ if sub is None:
88
+ return True
89
+ return sub in WRITE_COMMANDS[cmd]
90
+ return False
91
+
92
+
93
+ def check_permission(args) -> tuple[bool, Optional[str]]:
94
+ role = current_role()
95
+ ok, reason = validate_role()
96
+ if not ok:
97
+ return False, reason
98
+ cmd = getattr(args, "command", None)
99
+ sub = _subcommand(args)
100
+ if role == "agent":
101
+ return True, None
102
+ if role == "auditor":
103
+ # read-only: no writes, no contacts
104
+ if is_write(args):
105
+ return False, "role_forbidden"
106
+ return True, None
107
+ if role == "family":
108
+ if is_write(args):
109
+ # allow feedback add
110
+ if cmd == "feedback" and sub == "add":
111
+ return True, None
112
+ # pipeline set only show->feedback: allow if status == feedback
113
+ if cmd == "pipeline" and sub == "set":
114
+ if getattr(args, "status", None) == "feedback":
115
+ return True, None
116
+ return False, "role_forbidden"
117
+ return False, "role_forbidden"
118
+ return True, None
119
+ return False, "unknown_role"
120
+
121
+
122
+ def role_allows_contacts(role: Optional[str] = None) -> bool:
123
+ r = role or current_role()
124
+ return r == "agent"
@@ -0,0 +1,72 @@
1
+ """SQLite-кеш маршрутов (routing_cache.sqlite)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import sqlite3
7
+ import threading
8
+ from pathlib import Path
9
+
10
+ # v0.4.5 (панель): кеш внутри пакета ломал read-only установки и параллельные запуски.
11
+ _USER_DIR = Path(os.environ.get("NEDVIGA_CACHE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
12
+ _USER_DIR.mkdir(parents=True, exist_ok=True)
13
+ _DEFAULT = _USER_DIR / "routing_cache.sqlite"
14
+ _lock = threading.Lock()
15
+
16
+
17
+ def _connect(db_path: Path | None = None) -> sqlite3.Connection:
18
+ p = db_path or _DEFAULT
19
+ p.parent.mkdir(parents=True, exist_ok=True)
20
+ conn = sqlite3.connect(p, timeout=30.0)
21
+ conn.execute("PRAGMA journal_mode=WAL")
22
+ conn.execute("PRAGMA busy_timeout=5000")
23
+ conn.execute(
24
+ "CREATE TABLE IF NOT EXISTS routes ("
25
+ " key TEXT PRIMARY KEY,"
26
+ " meters REAL NOT NULL,"
27
+ " provider TEXT NOT NULL,"
28
+ " created_at TEXT NOT NULL DEFAULT (datetime('now')))"
29
+ )
30
+ return conn
31
+
32
+
33
+ def cache_key(lat: float, lon: float, station: str, grid_m: float = 50.0) -> str:
34
+ """Ключ = округлённые координаты (грид ~50м) + станция."""
35
+ lat_r = round(lat / (grid_m / 111_000.0))
36
+ lon_r = round(lon / (grid_m / 76_000.0))
37
+ return f"{lat_r}:{lon_r}:{station.casefold()}"
38
+
39
+
40
+ def get(lat: float, lon: float, station: str, db_path: Path | None = None) -> float | None:
41
+ k = cache_key(lat, lon, station)
42
+ with _lock:
43
+ conn = _connect(db_path)
44
+ try:
45
+ row = conn.execute("SELECT meters FROM routes WHERE key=?", (k,)).fetchone()
46
+ return row[0] if row else None
47
+ finally:
48
+ conn.close()
49
+
50
+
51
+ def put(lat: float, lon: float, station: str, meters: float,
52
+ provider: str = "unknown", db_path: Path | None = None) -> None:
53
+ k = cache_key(lat, lon, station)
54
+ with _lock:
55
+ conn = _connect(db_path)
56
+ try:
57
+ conn.execute(
58
+ "INSERT OR REPLACE INTO routes (key, meters, provider) VALUES (?, ?, ?)",
59
+ (k, meters, provider),
60
+ )
61
+ conn.commit()
62
+ finally:
63
+ conn.close()
64
+
65
+
66
+ def stats(db_path: Path | None = None) -> dict:
67
+ conn = _connect(db_path)
68
+ try:
69
+ n = conn.execute("SELECT COUNT(*) FROM routes").fetchone()[0]
70
+ return {"entries": n, "path": str(db_path or _DEFAULT)}
71
+ finally:
72
+ conn.close()
nedviga/rush.py ADDED
@@ -0,0 +1,139 @@
1
+ """v0.5.13 F-15t: транспортная доступность — пешая до метро + ТЕКУЩИЕ пробки.
2
+
3
+ Честная рамка: прогноза «час-пик» нет — Яндекс отдаёт только
4
+ текущий балл, прогноз требует платного API-ключа. Балл замеряется ТОЛЬКО
5
+ через RU-egress браузерным каналом; без egress — честный unavailable
6
+ (не 0, не среднее, не выдумка).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+
17
+ def _egress() -> str | None:
18
+ v = (os.environ.get("NEDVIGA_EGRESS_PROXY") or "").strip()
19
+ return v or None
20
+
21
+
22
+ def _fetch_traffic() -> str:
23
+ """Текущий балл пробок Москвы. Только RU-egress; без — RuntimeError.
24
+ (тесты мокают эту функцию — сеть в тестах запрещена)"""
25
+ proxy = _egress()
26
+ if not proxy:
27
+ raise RuntimeError("egress_required")
28
+ from camoufox.sync_api import Camoufox
29
+ with Camoufox(headless=True, humanize=True, geoip=False,
30
+ proxy={"server": proxy}) as browser:
31
+ pg = browser.new_page()
32
+ pg.goto("https://yandex.ru/maps/213/moscow/probki/",
33
+ wait_until="domcontentloaded", timeout=45000)
34
+ pg.wait_for_timeout(9000)
35
+ return (pg.evaluate("document.body.innerText") or "")
36
+
37
+
38
+ def parse_balls(text: str) -> int | None:
39
+ m = re.search(r"(\d+)\s*балл", text)
40
+ return int(m.group(1)) if m else None
41
+
42
+
43
+ def _pairs_from_run(nums: list[int]) -> list[dict] | None:
44
+ """Пары «балл@час» из ряда чисел; чередование сломалось -> None."""
45
+ pairs: list[dict] = []
46
+ for i in range(0, len(nums) - 1, 2):
47
+ val, hour = nums[i], nums[i + 1]
48
+ if not (0 <= val <= 10 and 0 <= hour <= 23):
49
+ return None
50
+ if pairs and hour <= pairs[-1]["hour"]:
51
+ break # прогноз остатка дня: часы идут по возрастанию
52
+ pairs.append({"hour": hour, "balls": val})
53
+ return pairs if len(pairs) >= 2 else None
54
+
55
+
56
+ def parse_forecast_hours(text: str) -> list[dict]:
57
+ """v0.5.14 (the task brief §2): почасовой web-прогноз загруженности со
58
+ страницы Яндекс Карт (innerText), НЕ платный API.
59
+
60
+ Живая форма: «Дорожная ситуация сейчас Пробки N баллов» + пары
61
+ «балл@час», идущие подряд: «6 18 5 19 4 20 2 21 1 22 1 23»
62
+ (остаток дня; балл, затем час).
63
+
64
+ Честная рамка: только целые баллы 0-10 и целые часы 0-23,
65
+ часы строго по возрастанию, минимум 2 полные пары. Структура не
66
+ распарсилась -> пустой список. Никогда не угадываем.
67
+ """
68
+ if not text:
69
+ return []
70
+ m = re.search(r"\b(\d{1,2})\s*балл", text)
71
+ if not m:
72
+ return []
73
+ runs: list[list[int]] = []
74
+ cur: list[int] = []
75
+ for tok in re.split(r"[\s\u00a0]+", text[m.end():]):
76
+ if re.fullmatch(r"\d{1,2}", tok):
77
+ cur.append(int(tok))
78
+ elif cur:
79
+ runs.append(cur)
80
+ cur = []
81
+ if cur:
82
+ runs.append(cur)
83
+ for run in runs:
84
+ if len(run) < 4:
85
+ continue
86
+ # смещение 1 — если первым в ряд попал мусор-число (напр. «41 км/ч»)
87
+ for off in (0, 1):
88
+ pairs = _pairs_from_run(run[off:])
89
+ if pairs:
90
+ return pairs
91
+ return []
92
+
93
+
94
+ def walk_to_metro(lat: float, lon: float) -> dict:
95
+ """Ближайшее метро по внутреннему графу (офлайн, пешая дистанция).
96
+ Без графа — честная unavailable."""
97
+ try:
98
+ from .graph import load_graph, _haversine
99
+ g = load_graph()
100
+ node = g.nearest_node(lat, lon, max_m=4000.0)
101
+ if node is None:
102
+ return {"status": "no_metro_in_4km", "walk_minutes": None}
103
+ nla, nlo = g.nodes[node]
104
+ dist = _haversine(lat, lon, nla, nlo)
105
+ minutes = round(dist / 80.0) # 80 м/мин пешком
106
+ return {"status": "ok", "metro_node": node,
107
+ "distance_m": round(dist), "walk_minutes": minutes}
108
+ except Exception as exc:
109
+ return {"status": "graph_unavailable", "reason": type(exc).__name__,
110
+ "walk_minutes": None}
111
+
112
+
113
+ def rush_report(card_data: dict) -> dict:
114
+ geo = (card_data or {}).get("geo") or {}
115
+ lat, lon = geo.get("lat"), geo.get("lon")
116
+ out: dict = {"walk": None, "traffic": {}, "honest_note":
117
+ "загруженность текущего момента; прогноз час-пика недоступен без платного API-ключа"}
118
+ if lat is not None and lon is not None:
119
+ out["walk"] = walk_to_metro(lat, lon)
120
+ else:
121
+ out["walk"] = {"status": "no_coordinates", "walk_minutes": None}
122
+ if _egress():
123
+ try:
124
+ text = _fetch_traffic()
125
+ balls = parse_balls(text)
126
+ out["traffic"] = {"score": balls,
127
+ "measured_at_utc": datetime.now(timezone.utc).isoformat(timespec="seconds")}
128
+ if balls is None:
129
+ out["traffic"]["status"] = "unavailable"
130
+ # v0.5.14: почасовой web-прогноз с той же страницы (офлайн-разбор,
131
+ # без угадывания: пустой список, если структура не распарсилась)
132
+ out["traffic"]["forecast_hours"] = parse_forecast_hours(text)
133
+ except Exception:
134
+ out["traffic"] = {"status": "unavailable", "score": None,
135
+ "forecast_hours": []}
136
+ else:
137
+ out["traffic"] = {"status": "unavailable (нет RU-egress: не замерено)",
138
+ "score": None, "forecast_hours": []}
139
+ return out
nedviga/scorefit.py ADDED
@@ -0,0 +1,127 @@
1
+ """v0.5.4 (the task brief) scorefit."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from .models import ListingCard
9
+
10
+
11
+ def _listing(card: Any) -> ListingCard:
12
+ if isinstance(card, ListingCard):
13
+ return card
14
+ if isinstance(card, dict):
15
+ return ListingCard(**card)
16
+ raise TypeError("unsupported card")
17
+
18
+
19
+ def _load_profile(profile) -> dict:
20
+ if isinstance(profile, dict):
21
+ return profile
22
+ if isinstance(profile, str):
23
+ # could be path or profile name
24
+ p = Path(profile)
25
+ if p.exists():
26
+ return json.loads(p.read_text(encoding="utf-8"))
27
+ # builtin profiles
28
+ from .profiles import get_profile
29
+ try:
30
+ return get_profile(profile)
31
+ except Exception:
32
+ return {}
33
+ return {}
34
+
35
+
36
+ def score_card(card: Any, profile: Any = None) -> dict[str, Any]:
37
+ c = _listing(card)
38
+ prof = _load_profile(profile)
39
+ breakdown = []
40
+ total_weight = 0.0
41
+ total_score = 0.0
42
+
43
+ # district
44
+ w = 20.0
45
+ total_weight += w
46
+ expected = prof.get("districts") or prof.get("geo_districts")
47
+ actual = c.geo.district if c.geo else None
48
+ ok = (expected is None) or (actual in (expected if isinstance(expected, list) else [expected]))
49
+ contrib = w if ok else 0.0
50
+ total_score += contrib
51
+ breakdown.append({"criterion": "district", "value": actual, "expected": expected, "weight": w, "contribution": contrib})
52
+
53
+ # budget (price <= price_max)
54
+ w = 20.0
55
+ total_weight += w
56
+ price_max = prof.get("price_max")
57
+ price = c.pricing.price_total_rub
58
+ if price_max is None or price is None or price <= price_max:
59
+ ok = True
60
+ else:
61
+ ok = False
62
+ contrib = w if ok else 0.0
63
+ total_score += contrib
64
+ breakdown.append({"criterion": "budget", "value": price, "expected": f"<= {price_max}", "weight": w, "contribution": contrib})
65
+
66
+ # rooms
67
+ w = 15.0
68
+ total_weight += w
69
+ exp_rooms = prof.get("rooms")
70
+ actual_rooms = c.parameters.rooms
71
+ ok = (exp_rooms is None) or (actual_rooms == exp_rooms)
72
+ contrib = w if ok else 0.0
73
+ total_score += contrib
74
+ breakdown.append({"criterion": "rooms", "value": actual_rooms, "expected": exp_rooms, "weight": w, "contribution": contrib})
75
+
76
+ # metro walk
77
+ w = 15.0
78
+ total_weight += w
79
+ max_walk = prof.get("metro_walk_max")
80
+ walk = None
81
+ if c.geo and c.geo.honest_walk_route:
82
+ walk = c.geo.honest_walk_route.walk_duration_minutes
83
+ elif c.geo and c.geo.cian_claimed_metro:
84
+ walk = c.geo.cian_claimed_metro.claimed_time_minutes
85
+ ok = (max_walk is None) or (walk is None) or (walk <= max_walk)
86
+ contrib = w if ok else 0.0
87
+ total_score += contrib
88
+ breakdown.append({"criterion": "metro_walk", "value": walk, "expected": f"<= {max_walk}", "weight": w, "contribution": contrib})
89
+
90
+ # kitchen
91
+ w = 10.0
92
+ total_weight += w
93
+ kitchen_min = prof.get("kitchen_min")
94
+ kitchen = c.parameters.area_kitchen_m2
95
+ ok = (kitchen_min is None) or (kitchen is None) or (kitchen >= kitchen_min)
96
+ contrib = w if ok else 0.0
97
+ total_score += contrib
98
+ breakdown.append({"criterion": "kitchen", "value": kitchen, "expected": f">= {kitchen_min}", "weight": w, "contribution": contrib})
99
+
100
+ # floor not first
101
+ w = 10.0
102
+ total_weight += w
103
+ floor = c.parameters.floor
104
+ ok = (floor is None) or (floor != 1)
105
+ contrib = w if ok else 0.0
106
+ total_score += contrib
107
+ breakdown.append({"criterion": "floor_not_first", "value": floor, "expected": "!=1", "weight": w, "contribution": contrib})
108
+
109
+ # area total
110
+ w = 10.0
111
+ total_weight += w
112
+ # no explicit expected; give full if present
113
+ ok = c.parameters.area_total_m2 is not None
114
+ contrib = w if ok else 0.0
115
+ total_score += contrib
116
+ breakdown.append({"criterion": "area_total", "value": c.parameters.area_total_m2, "expected": "present", "weight": w, "contribution": contrib})
117
+
118
+ score = (total_score / total_weight * 100.0) if total_weight else 0.0
119
+ return {"score": round(score, 2), "breakdown": breakdown}
120
+
121
+
122
+ def score_cards(cards, profile=None) -> list[dict]:
123
+ out = []
124
+ for c in cards:
125
+ r = score_card(c, profile=profile)
126
+ out.append({"id": getattr(c, "id", None), "score": r["score"], "breakdown": r["breakdown"]})
127
+ return out
nedviga/sources.py ADDED
@@ -0,0 +1,55 @@
1
+ """v0.5.2 (the task brief) pluggable source adapters."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Callable, Optional, Protocol, runtime_checkable
5
+
6
+ _REGISTRY: list[Any] = []
7
+
8
+
9
+ @runtime_checkable
10
+ class SourceAdapter(Protocol):
11
+ id: str
12
+ site_patterns: list[str]
13
+ status: str
14
+
15
+ def parse_listing(self, payload: Any) -> Any: ...
16
+ def search_url(self, params: dict) -> str: ...
17
+ def fetch(self, params: dict) -> list: ...
18
+
19
+
20
+ def register_source(adapter: Any) -> None:
21
+ _REGISTRY.append(adapter)
22
+
23
+
24
+ def list_sources() -> list[dict[str, Any]]:
25
+ out = []
26
+ for a in _REGISTRY:
27
+ out.append({
28
+ "id": getattr(a, "id", None),
29
+ "site_patterns": getattr(a, "site_patterns", []),
30
+ "status": getattr(a, "status", "experimental"),
31
+ })
32
+ return out
33
+
34
+
35
+ class CianAdapter:
36
+ id = "cian"
37
+ site_patterns = ["cian.ru"]
38
+ status = "ready"
39
+
40
+ def parse_listing(self, payload):
41
+ from .models import ListingCard
42
+ if isinstance(payload, dict):
43
+ return ListingCard(**payload)
44
+ return payload
45
+
46
+ def search_url(self, params):
47
+ return "https://www.cian.ru/cat.php"
48
+
49
+ def fetch(self, params):
50
+ from .cian_live import search
51
+ return search(**params) if params else []
52
+
53
+
54
+ # register default
55
+ register_source(CianAdapter())