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/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"
|
nedviga/routing_cache.py
ADDED
|
@@ -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())
|