@@ -66,10 +194,11 @@ export function CompetitorTable({ competitors, districtName }: Props) {
"ЖК",
"Девелопер",
"Класс",
+ "Статус",
"Квартир",
"Район",
"Расст., м",
- ].map((h) => (
+ ].map((h, idx) => (
onRowClick?.(c)}
style={{
- background: sameDistrict
- ? "#eff6ff"
- : i % 2
- ? "#fafbfc"
- : "#fff",
+ background: rowBg,
borderBottom: "1px solid #f3f4f6",
+ cursor: onRowClick ? "pointer" : "default",
+ }}
+ onMouseEnter={(e) => {
+ if (onRowClick) {
+ (
+ e.currentTarget as HTMLTableRowElement
+ ).style.background = "#e0e7ff";
+ }
+ }}
+ onMouseLeave={(e) => {
+ (
+ e.currentTarget as HTMLTableRowElement
+ ).style.background = rowBg;
}}
>
|
{c.comm_name ?? `ЖК #${c.obj_id}`}
@@ -145,6 +304,9 @@ export function CompetitorTable({ competitors, districtName }: Props) {
—
)}
|
+
+
+ |
)}
- {competitors.length > 20 && (
+ {filtered.length > 20 && (
- Показано 20 из {competitors.length} ближайших ЖК
+ Показано 20 из {filtered.length} ближайших ЖК
)}
diff --git a/frontend/src/components/site-finder/ConnectionPointsLayer.tsx b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx
new file mode 100644
index 00000000..1e25173a
--- /dev/null
+++ b/frontend/src/components/site-finder/ConnectionPointsLayer.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import { CircleMarker, Popup, LayerGroup } from "react-leaflet";
+
+import type { EngineeringStructure } from "@/types/nspd";
+
+// ---------------------------------------------------------------------------
+// Category classification
+// ---------------------------------------------------------------------------
+
+export type CpCategory =
+ | "electricity"
+ | "gas"
+ | "water"
+ | "heat"
+ | "sewage"
+ | "telecom"
+ | "other";
+
+export interface CpCategoryStyle {
+ color: string;
+ label: string;
+ radius: number;
+}
+
+export const CP_CATEGORY_STYLES: Record = {
+ electricity: { color: "#f59e0b", label: "Электричество", radius: 9 },
+ gas: { color: "#3b82f6", label: "Газ", radius: 9 },
+ water: { color: "#06b6d4", label: "Вода", radius: 8 },
+ heat: { color: "#ef4444", label: "Теплоснабжение", radius: 8 },
+ sewage: { color: "#8b5cf6", label: "Канализация", radius: 8 },
+ telecom: { color: "#10b981", label: "Связь", radius: 7 },
+ other: { color: "#6b7280", label: "Другое", radius: 7 },
+};
+
+export const CP_ALL_CATEGORIES = Object.keys(
+ CP_CATEGORY_STYLES,
+) as CpCategory[];
+
+// Keywords to match against `type` and `name` fields (case-insensitive)
+const CATEGORY_KEYWORDS: Array<{ cat: CpCategory; keywords: string[] }> = [
+ {
+ cat: "electricity",
+ keywords: [
+ "трансформатор",
+ "тп-",
+ "ктп",
+ "подстанция",
+ "электр",
+ "tp-",
+ "лэп",
+ "36328",
+ ],
+ },
+ {
+ cat: "gas",
+ keywords: ["газ", "гтс", "газоп", "газорегул", "газопровод"],
+ },
+ {
+ cat: "water",
+ keywords: ["водо", "водопр", "насосн", "колодец", "скважин"],
+ },
+ {
+ cat: "heat",
+ keywords: ["тепло", "теплос", "котельн", "тэц"],
+ },
+ {
+ cat: "sewage",
+ keywords: ["канал", "сток", "ливнев", "кнс", "канализ"],
+ },
+ {
+ cat: "telecom",
+ keywords: ["связ", "телеком", "интернет", "оптик", "вышка"],
+ },
+];
+
+export function classifyStructure(s: EngineeringStructure): CpCategory {
+ const haystack =
+ `${s.name ?? ""} ${s.type ?? ""} ${s.source ?? ""}`.toLowerCase();
+ for (const { cat, keywords } of CATEGORY_KEYWORDS) {
+ if (keywords.some((kw) => haystack.includes(kw))) return cat;
+ }
+ return "other";
+}
+
+// ---------------------------------------------------------------------------
+// Group helper (exported so SiteMap can build counts for the control panel)
+// ---------------------------------------------------------------------------
+
+export function groupStructuresByCategory(
+ structures: EngineeringStructure[],
+): Map {
+ const grouped = new Map();
+ for (const cat of CP_ALL_CATEGORIES) {
+ grouped.set(cat, []);
+ }
+ for (const s of structures) {
+ const cat = classifyStructure(s);
+ grouped.get(cat)!.push(s);
+ }
+ return grouped;
+}
+
+// ---------------------------------------------------------------------------
+// Geometry helpers
+// ---------------------------------------------------------------------------
+
+function extractLatLon(
+ geojson: Record,
+): [number, number] | null {
+ if (geojson.type === "Point") {
+ const coords = geojson.coordinates as number[] | undefined;
+ if (coords && coords.length >= 2) {
+ // GeoJSON: [lon, lat]
+ return [coords[1], coords[0]];
+ }
+ }
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Map layer — renders CircleMarkers inside the Leaflet MapContainer
+// ---------------------------------------------------------------------------
+
+interface LayerProps {
+ visibleCategories: Set;
+ grouped: Map;
+}
+
+export function ConnectionPointsLayer({
+ visibleCategories,
+ grouped,
+}: LayerProps) {
+ return (
+ <>
+ {CP_ALL_CATEGORIES.map((cat) => {
+ if (!visibleCategories.has(cat)) return null;
+ const structs = grouped.get(cat) ?? [];
+ const style = CP_CATEGORY_STYLES[cat];
+
+ return (
+
+ {structs.map((s, idx) => {
+ const latLon = extractLatLon(s.geometry_geojson);
+ if (!latLon) return null;
+
+ return (
+
+
+
+
+
+ {style.label}
+
+
+ {s.name ?? s.type ?? "Объект"}
+
+ {s.type && s.name && (
+
+ {s.type}
+
+ )}
+ {s.readable_address && (
+
+ {s.readable_address}
+
+ )}
+
+ До границы:{" "}
+
+ {Math.round(s.distance_to_boundary_m)} м
+
+
+
+ Источник: {s.source}
+
+
+
+
+ );
+ })}
+
+ );
+ })}
+ >
+ );
+}
diff --git a/frontend/src/components/site-finder/CpLayerControlPanel.tsx b/frontend/src/components/site-finder/CpLayerControlPanel.tsx
new file mode 100644
index 00000000..df16d14b
--- /dev/null
+++ b/frontend/src/components/site-finder/CpLayerControlPanel.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import { useState } from "react";
+
+import type {
+ ConnectionPointsResponse,
+ EngineeringStructure,
+} from "@/types/nspd";
+import {
+ CP_ALL_CATEGORIES,
+ CP_CATEGORY_STYLES,
+ type CpCategory,
+} from "@/components/site-finder/ConnectionPointsLayer";
+
+interface Props {
+ data: ConnectionPointsResponse;
+ grouped: Map;
+ visibleCategories: Set;
+ onToggleCategory: (cat: CpCategory) => void;
+ onToggleAll: () => void;
+}
+
+export function CpLayerControlPanel({
+ data,
+ grouped,
+ visibleCategories,
+ onToggleCategory,
+ onToggleAll,
+}: Props) {
+ const [collapsed, setCollapsed] = useState(false);
+
+ const totalCount = data.engineering_structures.length;
+ const allVisible = visibleCategories.size === CP_ALL_CATEGORIES.length;
+
+ return (
+
+ {/* Header */}
+ setCollapsed((v) => !v)}
+ >
+
+ Точки подключения
+
+
+ {totalCount} шт {collapsed ? "▲" : "▼"}
+
+
+
+ {!collapsed && (
+
+ {/* No dump */}
+ {!data.dump_available && (
+
+ Дамп квартала не загружен — 0 точек подключения
+
+ )}
+
+ {/* Empty state */}
+ {data.dump_available && totalCount === 0 && (
+
+ 0 точек подключения в этом квартале
+
+ )}
+
+ {/* Toggle-all */}
+ {totalCount > 0 && (
+
+ )}
+
+ {/* Per-category */}
+
+ {CP_ALL_CATEGORIES.map((cat) => {
+ const structs = grouped.get(cat) ?? [];
+ const style = CP_CATEGORY_STYLES[cat];
+ if (structs.length === 0) return null;
+ const active = visibleCategories.has(cat);
+ return (
+
+ );
+ })}
+
+
+ {/* Summary */}
+ {data.dump_available && totalCount > 0 && (
+
+ {data.summary.nearest_structure_distance_m !== null && (
+
+ Ближайший:{" "}
+ {Math.round(data.summary.nearest_structure_distance_m)} м
+
+ )}
+ {data.summary.in_protection_zone && (
+
+ В охранной зоне
+
+ )}
+ {data.summary.protection_zones_intersecting > 0 &&
+ !data.summary.in_protection_zone && (
+
+ Охранных зон: {data.summary.protection_zones_intersecting}
+
+ )}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/CustomPoiAddModal.tsx b/frontend/src/components/site-finder/CustomPoiAddModal.tsx
new file mode 100644
index 00000000..1c9c8201
--- /dev/null
+++ b/frontend/src/components/site-finder/CustomPoiAddModal.tsx
@@ -0,0 +1,332 @@
+"use client";
+
+import { useState } from "react";
+import type { CustomPoiCreate } from "@/types/customPoi";
+
+// ---------------------------------------------------------------------------
+// OSM POI category options (25 common + "Другое")
+// ---------------------------------------------------------------------------
+
+const CATEGORY_OPTIONS = [
+ "school",
+ "kindergarten",
+ "pharmacy",
+ "hospital",
+ "clinic",
+ "shop_mall",
+ "shop_supermarket",
+ "shop_small",
+ "park",
+ "tram_stop",
+ "bus_stop",
+ "metro_stop",
+ "cafe",
+ "restaurant",
+ "bank",
+ "atm",
+ "post_office",
+ "library",
+ "sports_centre",
+ "gym",
+ "cinema",
+ "theatre",
+ "hotel",
+ "fuel",
+ "parking",
+ "Другое",
+] as const;
+
+// ---------------------------------------------------------------------------
+// Props
+// ---------------------------------------------------------------------------
+
+interface Props {
+ lon: number;
+ lat: number;
+ parcelCad?: string | null;
+ onSubmit: (data: CustomPoiCreate) => void;
+ onCancel: () => void;
+ isLoading?: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export function CustomPoiAddModal({
+ lon,
+ lat,
+ parcelCad,
+ onSubmit,
+ onCancel,
+ isLoading = false,
+}: Props) {
+ const [name, setName] = useState("");
+ const [category, setCategory] = useState("Другое");
+ const [weight, setWeight] = useState(0);
+ const [notes, setNotes] = useState("");
+ const [nameError, setNameError] = useState(null);
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!name.trim()) {
+ setNameError("Название обязательно");
+ return;
+ }
+ setNameError(null);
+ onSubmit({
+ name: name.trim(),
+ category: category === "Другое" ? null : category,
+ weight,
+ lon,
+ lat,
+ parcel_cad: parcelCad ?? null,
+ notes: notes.trim() || null,
+ });
+ }
+
+ // Derived color for weight indicator
+ const weightColor =
+ weight > 0 ? "#16a34a" : weight < 0 ? "#dc2626" : "#6b7280";
+
+ return (
+ /* Backdrop */
+ {
+ if (e.target === e.currentTarget) onCancel();
+ }}
+ >
+ e.stopPropagation()}
+ >
+
+ Добавить точку интереса
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/CustomPoiEditModal.tsx b/frontend/src/components/site-finder/CustomPoiEditModal.tsx
new file mode 100644
index 00000000..ae944df7
--- /dev/null
+++ b/frontend/src/components/site-finder/CustomPoiEditModal.tsx
@@ -0,0 +1,267 @@
+"use client";
+
+import { useState } from "react";
+import type { CustomPoi, CustomPoiUpdate } from "@/types/customPoi";
+
+interface Props {
+ poi: CustomPoi;
+ onSubmit: (data: CustomPoiUpdate) => void;
+ onCancel: () => void;
+ isLoading?: boolean;
+}
+
+const CATEGORY_OPTIONS = [
+ "school",
+ "kindergarten",
+ "pharmacy",
+ "hospital",
+ "clinic",
+ "shop_mall",
+ "shop_supermarket",
+ "shop_small",
+ "park",
+ "tram_stop",
+ "bus_stop",
+ "metro_stop",
+ "cafe",
+ "restaurant",
+ "bank",
+ "atm",
+ "post_office",
+ "library",
+ "sports_centre",
+ "gym",
+ "cinema",
+ "theatre",
+ "hotel",
+ "fuel",
+ "parking",
+ "Другое",
+] as const;
+
+export function CustomPoiEditModal({
+ poi,
+ onSubmit,
+ onCancel,
+ isLoading = false,
+}: Props) {
+ const [name, setName] = useState(poi.name);
+ const [category, setCategory] = useState(poi.category ?? "Другое");
+ const [weight, setWeight] = useState(poi.weight);
+ const [notes, setNotes] = useState(poi.notes ?? "");
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ onSubmit({
+ name: name.trim() || poi.name,
+ category: category === "Другое" ? null : category,
+ weight,
+ notes: notes.trim() || null,
+ });
+ }
+
+ const weightColor =
+ weight > 0 ? "#16a34a" : weight < 0 ? "#dc2626" : "#6b7280";
+
+ return (
+ {
+ if (e.target === e.currentTarget) onCancel();
+ }}
+ >
+ e.stopPropagation()}
+ >
+
+ Изменить точку
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/CustomPoiLayer.tsx b/frontend/src/components/site-finder/CustomPoiLayer.tsx
new file mode 100644
index 00000000..599aa99f
--- /dev/null
+++ b/frontend/src/components/site-finder/CustomPoiLayer.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import { CircleMarker, LayerGroup, Popup } from "react-leaflet";
+import type { CustomPoi } from "@/types/customPoi";
+
+// ---------------------------------------------------------------------------
+// Props
+// ---------------------------------------------------------------------------
+
+interface Props {
+ pois: CustomPoi[];
+ onEdit: (poi: CustomPoi) => void;
+ onDelete: (id: number) => void;
+}
+
+// ---------------------------------------------------------------------------
+// Color helpers
+// ---------------------------------------------------------------------------
+
+function markerColor(weight: number): string {
+ if (weight > 0) return "#16a34a"; // green — positive
+ if (weight < 0) return "#dc2626"; // red — negative
+ return "#6b7280"; // gray — neutral
+}
+
+function weightLabel(weight: number): string {
+ if (weight > 0) return `+${weight}`;
+ return String(weight);
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export function CustomPoiLayer({ pois, onEdit, onDelete }: Props) {
+ if (!pois.length) return null;
+
+ return (
+
+ {pois.map((poi) => {
+ const color = markerColor(poi.weight);
+ return (
+
+
+
+
+ {poi.name}
+
+ {poi.category && (
+
+ {poi.category}
+
+ )}
+
+ Вес: {weightLabel(poi.weight)}
+
+ {poi.notes && (
+
+ {poi.notes}
+
+ )}
+
+
+
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/CustomPoiToggleButton.tsx b/frontend/src/components/site-finder/CustomPoiToggleButton.tsx
new file mode 100644
index 00000000..197dbae6
--- /dev/null
+++ b/frontend/src/components/site-finder/CustomPoiToggleButton.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+interface Props {
+ active: boolean;
+ onClick: () => void;
+}
+
+/**
+ * Floating button shown below the map to toggle "add custom POI" click mode.
+ * When active, cursor becomes crosshair and next map click opens the add modal.
+ */
+export function CustomPoiToggleButton({ active, onClick }: Props) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx
index ddc0fed7..8a9bd016 100644
--- a/frontend/src/components/site-finder/LandTab.tsx
+++ b/frontend/src/components/site-finder/LandTab.tsx
@@ -7,6 +7,13 @@ import { GeologyBlock } from "./GeologyBlock";
import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock";
import { GeotechRiskBlock } from "./GeotechRiskBlock";
import { NeighborsBlock } from "./NeighborsBlock";
+import { NspdZoningBlock } from "./NspdZoningBlock";
+import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock";
+import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock";
+import { NspdRiskZonesBlock } from "./NspdRiskZonesBlock";
+import { NspdOpportunityBlock } from "./NspdOpportunityBlock";
+import { NspdRedLinesBlock } from "./NspdRedLinesBlock";
+import { NspdFreshnessBadge } from "./NspdFreshnessBadge";
interface Props {
data: ParcelAnalysis;
@@ -17,7 +24,15 @@ export function LandTab({ data }: Props) {
data.geotech_risk !== undefined ||
data.geology !== undefined ||
data.geometry_suitability !== undefined ||
- data.neighbors_summary !== undefined;
+ data.neighbors_summary !== undefined ||
+ data.nspd_zoning !== undefined ||
+ data.nspd_zouit_overlaps !== undefined ||
+ data.nspd_engineering_nearby !== undefined ||
+ data.nspd_risk_zones !== undefined ||
+ (data.nspd_opportunity_parcels !== undefined &&
+ (data.nspd_opportunity_parcels?.length ?? 0) > 0) ||
+ (data.nspd_red_lines !== undefined &&
+ (data.nspd_red_lines?.length ?? 0) > 0);
return (
@@ -31,51 +46,81 @@ export function LandTab({ data }: Props) {
)}
- {/* Zoning note */}
-
+ {/* Issue #202 — ЗОУИТ пересечения */}
+ {data.nspd_zouit_overlaps !== undefined && (
+
+
+ Зоны с особыми условиями использования (ЗОУИТ)
+
+
+
+ )}
+
+ {/* Issue #94 PR2 TIER 4 — Opportunity parcels */}
+ {(data.nspd_opportunity_parcels?.length ?? 0) > 0 && (
+
+
+ Возможности рядом (НСПД)
+
+
+
+ )}
+
+ {/* Issue #94 PR2 TIER 4 — Red lines */}
+ {(data.nspd_red_lines?.length ?? 0) > 0 && (
+
+
+ Красные линии застройки (НСПД)
+
+
+
+ )}
+
+ {/* Issue #94 TIER 3 — Риск-зоны НСПД */}
+ {data.nspd_risk_zones !== undefined && (
+
+
+ Природные риски (НСПД)
+
+
+
+ )}
+
+ {/* Issue #202 — Инженерные объекты в 200 м */}
+ {data.nspd_engineering_nearby !== undefined && (
+
+
+ Инженерные объекты вблизи участка
+
+
+
+ )}
+
{/* Geotech risk */}
{data.geotech_risk !== undefined && (
diff --git a/frontend/src/components/site-finder/MarketTab.tsx b/frontend/src/components/site-finder/MarketTab.tsx
index 4b408e1b..c6b4868e 100644
--- a/frontend/src/components/site-finder/MarketTab.tsx
+++ b/frontend/src/components/site-finder/MarketTab.tsx
@@ -4,6 +4,7 @@ import type { ParcelAnalysis } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { EmptyState } from "@/components/ui/EmptyState";
import { MarketTrendBlock } from "./MarketTrendBlock";
+import { BestLayoutsBlock } from "./BestLayoutsBlock";
import { CompetitorTable } from "./CompetitorTable";
import { Pipeline24moBlock } from "./Pipeline24moBlock";
import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock";
@@ -13,6 +14,35 @@ interface Props {
data: ParcelAnalysis;
}
+/** Derive radius_km from available nested fields. */
+function getRadiusKm(data: ParcelAnalysis): number {
+ return data.market_trend?.radius_km ?? data.pipeline_24mo?.radius_km ?? 3;
+}
+
+/**
+ * Max success score (%) from success_recommendation ranking.
+ * Returns null when recommendation is absent or ranking is empty.
+ */
+function getMaxMixPct(data: ParcelAnalysis): number | null {
+ const ranking = data.success_recommendation?.ranking;
+ if (!ranking || ranking.length === 0) return null;
+ const maxScore = Math.max(...ranking.map((r) => r.success_score));
+ return Math.round(maxScore * 100);
+}
+
+/**
+ * Estimated sales period in months (supply / flats_per_month).
+ * Uses pipeline_24mo.flats_total as supply proxy, velocity for rate.
+ * Assumes ~50 m² avg flat for unit conversion.
+ */
+function getSalesPeriodMonths(data: ParcelAnalysis): number | null {
+ const supply = data.pipeline_24mo?.flats_total;
+ const velocitySqm = data.velocity?.monthly_velocity_sqm;
+ if (!supply || !velocitySqm || velocitySqm <= 0) return null;
+ const flatsPerMonth = velocitySqm / 50;
+ return Math.round(supply / flatsPerMonth);
+}
+
export function MarketTab({ data }: Props) {
const hasTrend = "market_trend" in data;
const hasRecommendation = "success_recommendation" in data;
@@ -25,25 +55,221 @@ export function MarketTab({ data }: Props) {
hasVelocity ||
data.competitors.length > 0;
+ // ── Above-the-fold values ────────────────────────────────────────────────
+ // Maxim's spec: «ЖК старше года → не конкурент». Filter active competitors:
+ // - site_status === 'Строящиеся' → активен
+ // - либо ready_dt в будущем → активен (готовность ещё не наступила)
+ // - либо ready_dt в пределах последнего года → активен (свежие продажи)
+ // - иначе (Сданные >1y назад) → не конкурент, скрыт
+ const REL_MS = 365 * 24 * 60 * 60 * 1000;
+ const yearAgoTs = Date.now() - REL_MS;
+ const relevantCompetitors = data.competitors.filter((c) => {
+ if (c.site_status === "Строящиеся") return true;
+ if (!c.ready_dt) return false;
+ const ts = new Date(c.ready_dt).getTime();
+ if (Number.isNaN(ts)) return false;
+ return ts >= yearAgoTs;
+ });
+ const activeCount = relevantCompetitors.length;
+ const radiusKm = getRadiusKm(data);
+ // Treat zero velocity as "no data" — backend returns 0 when competitors
+ // are unmapped to Objective ground truth (OBJ-3). Showing "0.0 м²/мес"
+ // is misleading; "—" makes it clear data is unavailable.
+ const velocityRaw = data.velocity?.monthly_velocity_sqm;
+ const velocityPerMonth =
+ velocityRaw != null && velocityRaw > 0 ? velocityRaw : null;
+ const maxMixPct = getMaxMixPct(data);
+ const salesPeriodMonths = getSalesPeriodMonths(data);
+
+ const showHeadlineBar = activeCount > 0 || velocityPerMonth !== null;
+
return (
- {/* D4 (#36) — Pipeline 24mo */}
- {data.pipeline_24mo && }
+ {/* ── Above-the-fold: Headline-bar + 3 KPI ─────────────────────────── */}
+ {showHeadlineBar && (
+ <>
+ {/* Headline-bar — one sentence verdict */}
+
+
+ {activeCount} строящихся в {radiusKm}км
+
+ {" · велосити "}
+
+ {velocityPerMonth !== null
+ ? `${velocityPerMonth.toFixed(1)} м²/мес`
+ : "—"}
+
+ {" · рекомендуемый mix "}
+ {maxMixPct !== null ? `${maxMixPct}%` : "—"}
+
- {/* Market trend */}
- {hasTrend && (
-
-
+ {/* 3 KPI cards */}
+
+ {/* KPI 1: Активные ЖК */}
+
+
+ Активные ЖК
+
+
+ {activeCount}
+
+
+ строящихся в радиусе {radiusKm}км
+
+
+
+ {/* KPI 2: Велосити */}
+
+
+ Велосити
+
+
+
+ {velocityPerMonth !== null
+ ? velocityPerMonth.toFixed(1)
+ : "—"}
+
+ {velocityPerMonth !== null && (
+ м²/мес
+ )}
+
+
+ ср. темп продаж конкурентов
+
+
+
+ {/* KPI 3: Срок продаж */}
+
+
+ Срок продаж
+
+
+
+ {salesPeriodMonths !== null ? salesPeriodMonths : "—"}
+
+ {salesPeriodMonths !== null && (
+ мес
+ )}
+
+
+ supply / велосити (оценка)
+
+
+
+ >
+ )}
+
+ {/* ── Main content blocks ───────────────────────────────────────────── */}
+
+ {/* Competitors */}
+ {relevantCompetitors.length > 0 && (
+
+
+ Конкуренты ({relevantCompetitors.length} из{" "}
+ {data.competitors.length}
+ {" — фильтр: строящиеся или сданы ≤1 года)"}
+
+
)}
+ {/* Issue #113 — data-driven ТЗ на проектирование */}
+
+
{/* D2 (#34) — Velocity-score */}
{hasVelocity && (
)}
- {/* Competitors */}
- {data.competitors.length > 0 && (
-
- Конкуренты
-
+ {/* Market trend */}
+ {hasTrend && (
+
+
)}
+ {/* D4 (#36) — Pipeline 24mo */}
+ {data.pipeline_24mo && }
+
{/* Success recommendation */}
{hasRecommendation && (
diff --git a/frontend/src/components/site-finder/MarketTrendBlock.tsx b/frontend/src/components/site-finder/MarketTrendBlock.tsx
index 9eaca405..1c744c08 100644
--- a/frontend/src/components/site-finder/MarketTrendBlock.tsx
+++ b/frontend/src/components/site-finder/MarketTrendBlock.tsx
@@ -13,6 +13,9 @@ const LABEL_COLORS: Record = {
Падение: { bg: "#fecaca", color: "#b91c1c" },
};
+const N_WEAK = 30;
+const N_STRONG = 100;
+
function trendArrow(delta: number): string {
if (delta > 1) return "↑";
if (delta < -1) return "↓";
@@ -52,6 +55,31 @@ export function MarketTrendBlock({ trend }: Props) {
);
}
+ const n = trend.recent_deals_count;
+
+ // n < 30 — hide trend entirely, show data-insufficient state
+ if (n < N_WEAK) {
+ return (
+
+
+ Тренд рынка в радиусе {trend.radius_km} км
+
+
+ Недостаточно данных (n={n}) — расширьте радиус для анализа тренда
+
+
+ );
+ }
+
const arrow = trendArrow(trend.delta_6m_pct);
const arrowColor = deltaColor(trend.delta_6m_pct);
const labelStyle = LABEL_COLORS[trend.label] ?? {
@@ -132,6 +160,13 @@ export function MarketTrendBlock({ trend }: Props) {
>
{trend.label}
+
+ {/* Weak data warning: 30 ≤ n < 100 */}
+ {n < N_STRONG && (
+
+ Слабые данные (n={n}) — расширьте радиус для точности
+
+ )}
);
}
diff --git a/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx b/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx
new file mode 100644
index 00000000..f7dc76cb
--- /dev/null
+++ b/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import type { NspdEngineeringNearby } from "@/types/nspd";
+import { useConnectionPoints } from "@/hooks/useConnectionPoints";
+
+interface Props {
+ nearby: NspdEngineeringNearby[];
+ cadNum: string | null | undefined;
+}
+
+interface MergedRow {
+ label: string;
+ type: string | null;
+ distanceM: number;
+ source: "analyze" | "connection-points";
+}
+
+export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) {
+ const { data: cpData, isPending: cpLoading } = useConnectionPoints(cadNum);
+
+ // Merge and sort by distance
+ const rows: MergedRow[] = [];
+
+ for (const item of nearby) {
+ if (item.distance_m !== null) {
+ rows.push({
+ label: item.name ?? item.type ?? "Объект",
+ type: item.type,
+ distanceM: item.distance_m,
+ source: "analyze",
+ });
+ }
+ }
+
+ if (cpData) {
+ for (const s of cpData.engineering_structures) {
+ rows.push({
+ label: s.name ?? s.type ?? "Объект",
+ type: s.type,
+ distanceM: s.distance_to_boundary_m,
+ source: "connection-points",
+ });
+ }
+ }
+
+ // Dedup by label+distance (simple check)
+ const seen = new Set ();
+ const deduped = rows.filter((r) => {
+ const key = `${r.label}|${Math.round(r.distanceM)}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+
+ deduped.sort((a, b) => a.distanceM - b.distanceM);
+
+ if (deduped.length === 0 && !cpLoading) {
+ return (
+
+ Инженерных объектов в 200 м не найдено
+
+ );
+ }
+
+ return (
+
+ {deduped.length > 0 && (
+
+
+
+ |
+ Объект
+ |
+
+ Тип
+ |
+
+ Расстояние
+ |
+
+
+
+ {deduped.map((row, idx) => (
+
+ |
+ {row.label}
+ |
+
+ {row.type ?? "—"}
+ |
+
+ {Math.round(row.distanceM)} м
+ |
+
+ ))}
+
+
+ )}
+
+ {cpLoading && (
+
+ Загружаем данные точек подключения…
+
+ )}
+
+ {cpData?.summary && (
+
+ {cpData.summary.in_protection_zone && (
+
+ В охранной зоне
+
+ )}
+ {cpData.summary.nearest_structure_distance_m !== null && (
+
+ Ближайший:{" "}
+ {Math.round(cpData.summary.nearest_structure_distance_m)} м
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/NspdFreshnessBadge.tsx b/frontend/src/components/site-finder/NspdFreshnessBadge.tsx
new file mode 100644
index 00000000..5ed94a5a
--- /dev/null
+++ b/frontend/src/components/site-finder/NspdFreshnessBadge.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import type { NspdDumpMeta } from "@/types/nspd";
+
+interface Props {
+ dump: NspdDumpMeta | null | undefined;
+}
+
+export function NspdFreshnessBadge({ dump }: Props) {
+ // Issue #234: countdown пока harvest идёт. Сбрасывается на ETA при каждом
+ // новом triggered=true response (или ETA значении).
+ const eta = dump?.harvest_eta_seconds ?? null;
+ const [remaining, setRemaining] = useState(eta);
+
+ useEffect(() => {
+ setRemaining(eta);
+ }, [eta]);
+
+ useEffect(() => {
+ if (remaining === null || remaining <= 0) return;
+ const id = setInterval(() => {
+ setRemaining((v) => (v === null || v <= 1 ? 0 : v - 1));
+ }, 1000);
+ return () => clearInterval(id);
+ }, [remaining]);
+
+ if (!dump) {
+ return (
+
+ НСПД: нет данных
+
+ );
+ }
+
+ if (dump.harvest_triggered && !dump.available) {
+ const countdownLabel =
+ remaining !== null && remaining > 0
+ ? `НСПД: загрузка ~${remaining}с`
+ : "НСПД: загрузка…";
+ return (
+
+ {countdownLabel}
+
+ );
+ }
+
+ if (!dump.available) {
+ return (
+
+ НСПД: нет дампа
+
+ );
+ }
+
+ const dateStr = dump.fetched_at_utc
+ ? new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU")
+ : null;
+
+ if (dump.stale) {
+ return (
+
+ НСПД: устарело{dateStr ? ` (${dateStr})` : ""}
+
+ );
+ }
+
+ return (
+
+ НСПД: актуально{dateStr ? ` (${dateStr})` : ""}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/NspdOpportunityBlock.tsx b/frontend/src/components/site-finder/NspdOpportunityBlock.tsx
new file mode 100644
index 00000000..d946a483
--- /dev/null
+++ b/frontend/src/components/site-finder/NspdOpportunityBlock.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import type { OpportunityParcel } from "@/types/nspd";
+
+interface Props {
+ opportunityParcels: OpportunityParcel[] | null | undefined;
+}
+
+type LayerKey = OpportunityParcel["layer"];
+
+const LAYER_CONFIG: Record<
+ LayerKey,
+ { label: string; bg: string; badgeBg: string; color: string }
+> = {
+ auction_parcels: {
+ label: "На аукционе",
+ bg: "#fff7ed",
+ badgeBg: "#fed7aa",
+ color: "#c2410c",
+ },
+ scheme_parcels: {
+ label: "Схема расположения",
+ bg: "#eff6ff",
+ badgeBg: "#bfdbfe",
+ color: "#1d4ed8",
+ },
+ free_parcels: {
+ label: "Свободный от прав",
+ bg: "#f0fdf4",
+ badgeBg: "#bbf7d0",
+ color: "#15803d",
+ },
+ future_parcels: {
+ label: "Планируемый (проект межевания)",
+ bg: "#f9fafb",
+ badgeBg: "#e5e7eb",
+ color: "#374151",
+ },
+ oopt: {
+ label: "ООПТ",
+ bg: "#faf5ff",
+ badgeBg: "#e9d5ff",
+ color: "#7e22ce",
+ },
+};
+
+function formatDistance(m: number | null): string {
+ if (m === null) return "";
+ if (m < 1000) return `${Math.round(m)} м`;
+ return `${(m / 1000).toFixed(1)} км`;
+}
+
+function buildNspdViewerUrl(cadNum: string): string {
+ return `https://nspd.gov.ru/map?cadastralNumber=${encodeURIComponent(cadNum)}`;
+}
+
+export function NspdOpportunityBlock({ opportunityParcels }: Props) {
+ const parcels = opportunityParcels ?? [];
+
+ if (parcels.length === 0) {
+ return null;
+ }
+
+ // Group by layer type
+ const grouped = parcels.reduce>(
+ (acc, p) => {
+ const key = p.layer as LayerKey;
+ if (!acc[key]) acc[key] = [];
+ acc[key].push(p);
+ return acc;
+ },
+ {} as Record,
+ );
+
+ // Stable display order: auction first (highest priority)
+ const layerOrder: LayerKey[] = [
+ "auction_parcels",
+ "free_parcels",
+ "scheme_parcels",
+ "future_parcels",
+ "oopt",
+ ];
+
+ return (
+
+ {layerOrder.map((layerKey) => {
+ const items = grouped[layerKey];
+ if (!items || items.length === 0) return null;
+ const cfg = LAYER_CONFIG[layerKey];
+
+ return (
+
+ 1 ? 8 : 0,
+ }}
+ >
+
+ {cfg.label}
+
+
+ {items.length} уч.
+
+
+
+ {items.map((p, idx) => (
+
+ {p.cad_num ? (
+
+ {p.cad_num}
+
+ ) : (
+ —
+ )}
+ {p.distance_m !== null && (
+
+ {formatDistance(p.distance_m)}
+
+ )}
+
+ ))}
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/NspdRedLinesBlock.tsx b/frontend/src/components/site-finder/NspdRedLinesBlock.tsx
new file mode 100644
index 00000000..651bab9b
--- /dev/null
+++ b/frontend/src/components/site-finder/NspdRedLinesBlock.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import type { RedLine } from "@/types/nspd";
+
+interface Props {
+ redLines: RedLine[] | null | undefined;
+}
+
+function formatLength(m: number | null): string | null {
+ if (m === null) return null;
+ if (m >= 1000) return `${(m / 1000).toFixed(2)} км`;
+ return `${Math.round(m)} м`;
+}
+
+function formatDistance(m: number | null): string | null {
+ if (m === null) return null;
+ if (m < 1000) return `${Math.round(m)} м`;
+ return `${(m / 1000).toFixed(1)} км`;
+}
+
+export function NspdRedLinesBlock({ redLines }: Props) {
+ const lines = redLines ?? [];
+
+ if (lines.length === 0) {
+ return null;
+ }
+
+ const intersecting = lines.filter((l) => l.intersection_length_m !== null);
+ const nearbyOnly = lines.filter((l) => l.intersection_length_m === null);
+ const hasIntersect = intersecting.length > 0;
+
+ return (
+
+ {/* Alert banner when red lines intersect the parcel */}
+ {hasIntersect && (
+
+
+ ПЕРЕСЕЧЕНИЕ
+
+
+
+ Красные линии пересекают участок — отступы могут быть критичны
+
+
+ {intersecting.map((l, idx) => {
+ const lenStr = formatLength(l.intersection_length_m);
+ return (
+
+ Линия {idx + 1}
+ {lenStr ? `: длина пересечения ${lenStr}` : ""}
+
+ );
+ })}
+
+
+
+ )}
+
+ {/* Nearby-only red lines */}
+ {nearbyOnly.length > 0 && (
+
+
+ РЯДОМ
+
+
+
+ {nearbyOnly.length} кр. лини{nearbyOnly.length === 1 ? "я" : "и"}{" "}
+ вблизи участка
+
+
+ {nearbyOnly.map((l, idx) => {
+ const distStr = formatDistance(l.distance_m);
+ return (
+
+ Линия {idx + 1}
+ {distStr ? `: ${distStr} от границы` : ""}
+
+ );
+ })}
+
+
+
+ )}
+
+ {/* Summary line */}
+
+ Итого: {lines.length} красн. лини{lines.length === 1 ? "я" : "и"}
+ {hasIntersect
+ ? `, из них ${intersecting.length} пересека${intersecting.length === 1 ? "ет" : "ют"} участок`
+ : ""}
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx b/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx
new file mode 100644
index 00000000..2c45c1bf
--- /dev/null
+++ b/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+import type { RiskZone } from "@/types/nspd";
+
+interface Props {
+ riskZones: RiskZone[] | null | undefined;
+ parcelAreaSqm?: number | null;
+}
+
+// Severity mapping: layer key suffix → severity tier
+function getSeverity(layer: string): "high" | "medium" | "low" {
+ const key = layer.replace(/^risk_/, "");
+ if (key === "flooding" || key === "landslide") return "high";
+ if (key === "flooding_underground" || key === "burns") return "medium";
+ return "low";
+}
+
+const SEVERITY_STYLES: Record<
+ "high" | "medium" | "low",
+ { bg: string; badgeBg: string; color: string; label: string }
+> = {
+ high: {
+ bg: "#fff1f2",
+ badgeBg: "#fee2e2",
+ color: "#991b1b",
+ label: "ВЫСОКИЙ",
+ },
+ medium: {
+ bg: "#fffbeb",
+ badgeBg: "#fef3c7",
+ color: "#92400e",
+ label: "СРЕДНИЙ",
+ },
+ low: {
+ bg: "#fff7ed",
+ badgeBg: "#ffedd5",
+ color: "#9a3412",
+ label: "НИЗКИЙ",
+ },
+};
+
+function formatArea(sqm: number | null): string | null {
+ if (sqm === null) return null;
+ if (sqm >= 10000) return `${(sqm / 10000).toFixed(2)} га`;
+ return `${Math.round(sqm).toLocaleString("ru-RU")} м²`;
+}
+
+export function NspdRiskZonesBlock({ riskZones, parcelAreaSqm }: Props) {
+ const zones = riskZones ?? [];
+
+ if (zones.length === 0) {
+ return (
+
+
+ Риски не обнаружены
+
+
+ Риск-зоны НСПД на участке не выявлены
+
+
+ );
+ }
+
+ // Sort: high first, then medium, then low
+ const sorted = [...zones].sort((a, b) => {
+ const order = { high: 0, medium: 1, low: 2 };
+ return order[getSeverity(a.layer)] - order[getSeverity(b.layer)];
+ });
+
+ return (
+
+ {sorted.map((zone, idx) => {
+ const severity = getSeverity(zone.layer);
+ const style = SEVERITY_STYLES[severity];
+ const areaStr = formatArea(zone.intersection_area_sqm);
+ const pct =
+ parcelAreaSqm && zone.intersection_area_sqm
+ ? Math.min(
+ 100,
+ Math.round((zone.intersection_area_sqm / parcelAreaSqm) * 100),
+ )
+ : null;
+
+ return (
+
+
+ {style.label}
+
+
+
+ {zone.subtype ?? zone.layer}
+
+ {(areaStr || pct !== null) && (
+
+ {areaStr && Площадь пересечения: {areaStr}}
+ {areaStr && pct !== null && · }
+ {pct !== null && {pct}% участка}
+
+ )}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/NspdZoningBlock.tsx b/frontend/src/components/site-finder/NspdZoningBlock.tsx
new file mode 100644
index 00000000..933e7da0
--- /dev/null
+++ b/frontend/src/components/site-finder/NspdZoningBlock.tsx
@@ -0,0 +1,275 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import type { NspdDumpMeta, NspdZoning } from "@/types/nspd";
+
+interface Props {
+ data: NspdZoning | null | undefined;
+ dump: NspdDumpMeta | null | undefined;
+ cadNum: string;
+}
+
+export function NspdZoningBlock({ data, dump, cadNum }: Props) {
+ const [expanded, setExpanded] = useState(false);
+
+ const isHarvesting = dump?.harvest_triggered && !dump?.available;
+ const eta = dump?.harvest_eta_seconds ?? null;
+
+ // Issue #234: countdown пока harvest идёт.
+ const [remaining, setRemaining] = useState(eta);
+ useEffect(() => {
+ setRemaining(eta);
+ }, [eta]);
+ useEffect(() => {
+ if (!isHarvesting) return;
+ if (remaining === null || remaining <= 0) return;
+ const id = setInterval(() => {
+ setRemaining((v) => (v === null || v <= 1 ? 0 : v - 1));
+ }, 1000);
+ return () => clearInterval(id);
+ }, [isHarvesting, remaining]);
+
+ // Issue #234: после ETA*1.5 показываем «не дождались», fallthrough к no-data UI.
+ // remaining === 0 значит таймер успел истечь (eta был известен).
+ const harvestTimedOut =
+ isHarvesting && !data && remaining !== null && remaining <= 0;
+ if (harvestTimedOut) {
+ return (
+
+
+ Данные НСПД загружаются дольше обычного. Попробуйте обновить страницу
+ через минуту.
+
+
+ Открыть в ПКК
+
+
+ );
+ }
+
+ // Loading skeleton when harvest is in progress
+ if (isHarvesting && !data) {
+ const label =
+ remaining !== null && remaining > 0
+ ? `Загружаем данные НСПД, осталось ~${remaining} с…`
+ : "Загружаем данные НСПД…";
+ return (
+
+
+
+
+ {label}
+
+
+
+
+
+ );
+ }
+
+ // Data not available, no harvest triggered
+ if (!data) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {data.zone_code && (
+
+ {data.zone_code}
+
+ )}
+ {data.zone_name && (
+
+ {data.zone_name}
+
+ )}
+ {!data.zone_code && !data.zone_name && (
+
+ Зона не определена
+
+ )}
+
+
+ {data.raw_props && Object.keys(data.raw_props).length > 0 && (
+
+
+ {expanded && (
+
+ {Object.entries(data.raw_props).map(([k, v]) => (
+
+ {k}:
+ {String(v ?? "—")}
+
+ ))}
+
+ )}
+
+ )}
+
+ {dump?.fetched_at_utc && (
+
+ НСПД: данные от{" "}
+ {new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU")}
+ {dump.stale && (
+
+ (устаревшие)
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx b/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx
new file mode 100644
index 00000000..4a08f6ea
--- /dev/null
+++ b/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import type { NspdZouitOverlap } from "@/types/nspd";
+
+interface Props {
+ overlaps: NspdZouitOverlap[];
+}
+
+// Group key → human-readable label
+const GROUP_LABELS: Record = {
+ engineering: "Инженерные коммуникации",
+ okn: "Объекты культурного наследия",
+ natural: "Природные территории",
+ protected: "Охраняемые зоны",
+ other: "Прочие ЗОУИТ",
+};
+
+// Determine a color-coded severity badge from group_key
+function getSeverityStyle(groupKey: string): {
+ bg: string;
+ color: string;
+ label: string;
+} {
+ switch (groupKey) {
+ case "engineering":
+ return { bg: "#fef3c7", color: "#92400e", label: "warn" };
+ case "okn":
+ return { bg: "#fee2e2", color: "#991b1b", label: "block" };
+ case "protected":
+ return { bg: "#fee2e2", color: "#991b1b", label: "block" };
+ case "natural":
+ return { bg: "#dbeafe", color: "#1e40af", label: "low" };
+ default:
+ return { bg: "#f3f4f6", color: "#374151", label: "info" };
+ }
+}
+
+export function NspdZouitOverlapsBlock({ overlaps }: Props) {
+ if (overlaps.length === 0) {
+ return (
+
+
+ Нет пересечений
+
+
+ Охранных зон ЗОУИТ не обнаружено
+
+
+ );
+ }
+
+ return (
+
+ {overlaps.map((overlap, idx) => {
+ const style = getSeverityStyle(overlap.group_key);
+ const label = GROUP_LABELS[overlap.group_key] ?? overlap.group_key;
+ const detail =
+ overlap.name ||
+ (typeof overlap.subcategory === "string"
+ ? overlap.subcategory
+ : null);
+
+ return (
+
+
+ {style.label.toUpperCase()}
+
+
+
+ {label}
+
+ {detail && (
+
+ {detail}
+
+ )}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/OverviewTab.tsx b/frontend/src/components/site-finder/OverviewTab.tsx
index 532c8db4..595a6e51 100644
--- a/frontend/src/components/site-finder/OverviewTab.tsx
+++ b/frontend/src/components/site-finder/OverviewTab.tsx
@@ -7,6 +7,7 @@ import { ConfidenceBadge } from "./ConfidenceBadge";
import { GateVerdictBanner } from "./GateVerdictBanner";
import { IsochronesPanel } from "./IsochronesPanel";
import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel";
+import { ScoreBreakdownStackedBar } from "./ScoreBreakdownStackedBar";
interface Props {
data: ParcelAnalysis;
@@ -76,6 +77,7 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
{(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²
+ (12 мес)
{(data.district.dist_to_center / 1000).toFixed(1)} км до центра
@@ -101,6 +103,100 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
)}
+ {/* B5 EGRN compact (added 2026-05-18 — expose new field в legacy Обзор) */}
+ {data.egrn && (
+
+ ЕГРН
+
+ {data.egrn.address && (
+
+ Адрес: {data.egrn.address}
+
+ )}
+ {data.egrn.permitted_use_text && (
+
+ ВРИ: {data.egrn.permitted_use_text}
+
+ )}
+ {data.egrn.land_category && (
+
+ Категория: {data.egrn.land_category}
+
+ )}
+ {data.egrn.parcel_status && (
+
+ Статус: {data.egrn.parcel_status}
+
+ )}
+ {data.egrn.right_type && (
+
+ Право: {data.egrn.right_type}
+
+ )}
+
+
+ )}
+
+ {/* B5 Utilities summary (added 2026-05-18 — expose new field в legacy Обзор) */}
+ {data.utilities &&
+ Array.isArray(data.utilities.summary) &&
+ data.utilities.summary.length > 0 && (
+
+ Сети рядом
+
+ {data.utilities.summary
+ .map((u) => {
+ const label =
+ u.subtype === "substation"
+ ? "электричество"
+ : u.subtype === "power_line"
+ ? "ЛЭП"
+ : u.subtype === "pipeline"
+ ? "газ"
+ : u.subtype === "water_intake"
+ ? "вода"
+ : u.subtype === "pumping_station"
+ ? "канализация"
+ : u.subtype;
+ return `${label} ${u.nearest_m} м`;
+ })
+ .join(" · ")}
+
+ {data.utilities.power_line_охранная_зона_flag && (
+
+ ⚠ Охранная зона ЛЭП — капитальное строительство запрещено
+
+ )}
+
+ )}
+
{/* Tram warning */}
{nearestTram !== null && (
)}
+ {/* #114: score breakdown stacked bar by category */}
+ {data.score_breakdown_detailed &&
+ data.score_breakdown_detailed.length > 0 && (
+
+ )}
+
{/* POI breakdown */}
= {
+ school: "#1d4ed8",
+ kindergarten: "#1d4ed8",
+ pharmacy: "#10b981",
+ hospital: "#f59e0b",
+ shop_mall: "#a855f7",
+ shop_supermarket: "#a855f7",
+ shop_small: "#c084fc",
+ park: "#16a34a",
+ tram_stop: "#dc2626",
+ bus_stop: "#6b7280",
+ metro_stop: "#1e40af",
+ custom: "#f97316", // custom POIs — orange with border
+};
+
+function categoryColor(category: string): string {
+ return CATEGORY_COLORS[category] ?? "#94a3b8";
+}
+
+// ---------------------------------------------------------------------------
+// Props
+// ---------------------------------------------------------------------------
+
+interface Props {
+ /** Full factor breakdown from ParcelAnalysis.score_breakdown_detailed */
+ breakdown: FactorContribution[];
+ /** Factor names that come from custom POIs (to highlight visually) */
+ customPoiFactors?: string[];
+}
+
+// ---------------------------------------------------------------------------
+// Aggregation helpers
+// ---------------------------------------------------------------------------
+
+interface CategoryAgg {
+ category: string;
+ category_ru: string;
+ contribution: number;
+ count: number;
+ isCustom: boolean;
+}
+
+function aggregateByCategory(
+ items: FactorContribution[],
+ customFactors: Set ,
+): CategoryAgg[] {
+ const map = new Map();
+
+ for (const item of items) {
+ const key = item.category;
+ const existing = map.get(key);
+ if (existing) {
+ existing.contribution += item.contribution;
+ existing.count += 1;
+ if (customFactors.has(item.factor)) existing.isCustom = true;
+ } else {
+ map.set(key, {
+ category: key,
+ category_ru: item.category_ru,
+ contribution: item.contribution,
+ count: 1,
+ isCustom: customFactors.has(item.factor),
+ });
+ }
+ }
+
+ return [...map.values()].sort((a, b) => b.contribution - a.contribution);
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export function ScoreBreakdownStackedBar({
+ breakdown,
+ customPoiFactors = [],
+}: Props) {
+ const [groupBy, setGroupBy] = useState<"category" | "source">("category");
+ const [hoveredCat, setHoveredCat] = useState(null);
+
+ const customSet = new Set(customPoiFactors);
+
+ // Aggregate by category (or source as fallback label when groupBy=source)
+ const aggregated = aggregateByCategory(breakdown, customSet);
+
+ // Split positive and negative for stacked bar
+ const positives = aggregated.filter((a) => a.contribution > 0);
+ const negatives = aggregated.filter((a) => a.contribution < 0);
+
+ const totalPositive = positives.reduce((s, a) => s + a.contribution, 0) || 1;
+ const totalNegative =
+ Math.abs(negatives.reduce((s, a) => s + a.contribution, 0)) || 1;
+
+ if (aggregated.length === 0) return null;
+
+ const fmtV = (v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}`;
+
+ // Group-by toggle (source mode uses factor field as key — simpler visual)
+ const sourceAgg =
+ groupBy === "source"
+ ? (() => {
+ const sm = new Map();
+ for (const item of breakdown) {
+ const key = item.factor;
+ const ex = sm.get(key);
+ if (ex) {
+ ex.contribution += item.contribution;
+ ex.count += 1;
+ } else {
+ sm.set(key, { contribution: item.contribution, count: 1 });
+ }
+ }
+ return [...sm.entries()]
+ .map(([k, v]) => ({ factor: k, ...v }))
+ .sort((a, b) => b.contribution - a.contribution)
+ .slice(0, 10);
+ })()
+ : null;
+
+ return (
+
+ {/* Header + toggle */}
+
+
+ Вклад по категориям
+
+
+
+
+
+
+
+ {/* Stacked horizontal bar — positive contributions */}
+ {groupBy === "category" && positives.length > 0 && (
+
+
+ Положительный вклад
+
+
+ {positives.map((agg) => {
+ const widthPct = (agg.contribution / totalPositive) * 100;
+ const color = agg.isCustom
+ ? CATEGORY_COLORS.custom
+ : categoryColor(agg.category);
+ const isHovered = hoveredCat === agg.category;
+ return (
+ setHoveredCat(agg.category)}
+ onMouseLeave={() => setHoveredCat(null)}
+ >
+ {widthPct >= 12 ? `${Math.round(widthPct)}%` : ""}
+
+ );
+ })}
+
+
+ {/* Negative bar */}
+ {negatives.length > 0 && (
+ <>
+
+ Снижают балл
+
+
+ {negatives.map((agg) => {
+ const widthPct =
+ (Math.abs(agg.contribution) / totalNegative) * 100;
+ const color = categoryColor(agg.category);
+ return (
+
+ {widthPct >= 15 ? `${Math.round(widthPct)}%` : ""}
+
+ );
+ })}
+
+ >
+ )}
+
+ {/* Legend */}
+
+ {aggregated.map((agg) => {
+ const color = agg.isCustom
+ ? CATEGORY_COLORS.custom
+ : categoryColor(agg.category);
+ return (
+ setHoveredCat(agg.category)}
+ onMouseLeave={() => setHoveredCat(null)}
+ >
+
+
+ {agg.category_ru}
+ {agg.isCustom && (
+
+ (custom)
+
+ )}
+ :{" "}
+ = 0 ? "#16a34a" : "#dc2626",
+ }}
+ >
+ {fmtV(agg.contribution)}
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Source (factor) view — top 10 table */}
+ {groupBy === "source" && sourceAgg && (
+
+ {sourceAgg.map(({ factor, contribution }) => {
+ const isCustom = customSet.has(factor);
+ const barWidth = Math.min(
+ 100,
+ (Math.abs(contribution) /
+ (Math.abs(sourceAgg[0]?.contribution) || 1)) *
+ 100,
+ );
+ const color =
+ contribution >= 0
+ ? isCustom
+ ? CATEGORY_COLORS.custom
+ : "#16a34a"
+ : "#dc2626";
+ return (
+
+
+ {isCustom && (
+
+ [custom]
+
+ )}
+ {factor}
+
+
+
+ {fmtV(contribution)}
+
+
+ );
+ })}
+ {breakdown.length > 10 && (
+
+ Показаны топ-10 из {breakdown.length} факторов
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx
index 9c007246..b60266fd 100644
--- a/frontend/src/components/site-finder/SiteMap.tsx
+++ b/frontend/src/components/site-finder/SiteMap.tsx
@@ -1,17 +1,39 @@
"use client";
-import { useEffect } from "react";
+import { useEffect, useRef, useState } from "react";
import {
MapContainer,
TileLayer,
GeoJSON,
CircleMarker,
Popup,
+ useMapEvents,
} from "react-leaflet";
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
import "leaflet/dist/leaflet.css";
import type { ParcelAnalysis } from "@/types/site-finder";
+import type {
+ ConnectionPointsResponse,
+ EngineeringStructure,
+} from "@/types/nspd";
+import type { CustomPoi } from "@/types/customPoi";
+import {
+ ConnectionPointsLayer,
+ CP_ALL_CATEGORIES,
+ groupStructuresByCategory,
+ type CpCategory,
+} from "@/components/site-finder/ConnectionPointsLayer";
+import { CpLayerControlPanel } from "@/components/site-finder/CpLayerControlPanel";
+import { CustomPoiLayer } from "@/components/site-finder/CustomPoiLayer";
+import { CustomPoiToggleButton } from "@/components/site-finder/CustomPoiToggleButton";
+import { CustomPoiAddModal } from "@/components/site-finder/CustomPoiAddModal";
+import { CustomPoiEditModal } from "@/components/site-finder/CustomPoiEditModal";
+import {
+ useAddCustomPoi,
+ useUpdateCustomPoi,
+ useDeleteCustomPoi,
+} from "@/hooks/useCustomPois";
// ---------------------------------------------------------------------------
// POI legend config (for the legend row below the map)
@@ -83,9 +105,38 @@ function geomCenter(geom: Geometry): [number, number] {
interface Props {
data: ParcelAnalysis;
isochrones?: FeatureCollection;
+ connectionPoints?: ConnectionPointsResponse;
+ customPois?: CustomPoi[];
+ parcelCad?: string;
}
-export function SiteMap({ data, isochrones }: Props) {
+// ---------------------------------------------------------------------------
+// Map click handler sub-component (must be inside MapContainer)
+// ---------------------------------------------------------------------------
+
+interface MapClickHandlerProps {
+ editMode: boolean;
+ onMapClick: (lat: number, lon: number) => void;
+}
+
+function MapClickHandler({ editMode, onMapClick }: MapClickHandlerProps) {
+ useMapEvents({
+ click(e) {
+ if (editMode) {
+ onMapClick(e.latlng.lat, e.latlng.lng);
+ }
+ },
+ });
+ return null;
+}
+
+export function SiteMap({
+ data,
+ isochrones,
+ connectionPoints,
+ customPois,
+ parcelCad,
+}: Props) {
// Fix Leaflet default icon paths broken by webpack bundler
useEffect(() => {
void import("leaflet").then((L) => {
@@ -101,6 +152,63 @@ export function SiteMap({ data, isochrones }: Props) {
});
}, []);
+ // Connection-points layer toggle state (all categories visible by default)
+ const [visibleCategories, setVisibleCategories] = useState>(
+ new Set(CP_ALL_CATEGORIES),
+ );
+
+ // Custom POI add mode state
+ const [addMode, setAddMode] = useState(false);
+ const [pendingCoords, setPendingCoords] = useState<{
+ lat: number;
+ lon: number;
+ } | null>(null);
+ const [editingPoi, setEditingPoi] = useState(null);
+
+ // Mutations
+ const addMutation = useAddCustomPoi(parcelCad);
+ const updateMutation = useUpdateCustomPoi(parcelCad);
+ const deleteMutation = useDeleteCustomPoi(parcelCad);
+
+ // Keyboard ESC cancels add mode
+ const addModeRef = useRef(addMode);
+ addModeRef.current = addMode;
+ useEffect(() => {
+ function onKeyDown(e: KeyboardEvent) {
+ if (e.key === "Escape" && addModeRef.current) {
+ setAddMode(false);
+ setPendingCoords(null);
+ }
+ }
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, []);
+
+ function toggleCategory(cat: CpCategory) {
+ setVisibleCategories((prev) => {
+ const next = new Set(prev);
+ if (next.has(cat)) {
+ next.delete(cat);
+ } else {
+ next.add(cat);
+ }
+ return next;
+ });
+ }
+
+ function toggleAll() {
+ setVisibleCategories((prev) =>
+ prev.size === CP_ALL_CATEGORIES.length
+ ? new Set()
+ : new Set(CP_ALL_CATEGORIES),
+ );
+ }
+
+ function handleMapClick(lat: number, lon: number) {
+ setPendingCoords({ lat, lon });
+ setAddMode(false); // toggle off click mode; modal opens
+ }
+
const center: [number, number] = data.geom_geojson
? geomCenter(data.geom_geojson)
: [56.838, 60.6];
@@ -108,6 +216,11 @@ export function SiteMap({ data, isochrones }: Props) {
// Build legend from categories present in score_breakdown
const presentCategories = Object.keys(data.score_breakdown);
+ // Pre-group structures for both the map layer and the control panel
+ const cpGrouped = connectionPoints
+ ? groupStructuresByCategory(connectionPoints.engineering_structures)
+ : new Map();
+
return (
{/* Map */}
@@ -117,6 +230,7 @@ export function SiteMap({ data, isochrones }: Props) {
borderRadius: 12,
overflow: "hidden",
height: 420,
+ cursor: addMode ? "crosshair" : "grab",
}}
>
+
));
})}
+
+ {/* Connection points layer — rendered on top of POI markers */}
+ {connectionPoints && (
+
+ )}
+
+ {/* Custom POI markers — topmost layer */}
+ {customPois && customPois.length > 0 && (
+ setEditingPoi(poi)}
+ onDelete={(id) => deleteMutation.mutate(id)}
+ />
+ )}
+ {/* Custom POI add-mode toggle button */}
+
+ {
+ setAddMode((v) => !v);
+ setPendingCoords(null);
+ }}
+ />
+ {addMode && (
+
+ Кликните на карте для добавления точки
+
+ )}
+ {customPois && customPois.length > 0 && (
+
+ {customPois.length} польз.{" "}
+ {customPois.length === 1
+ ? "точка"
+ : customPois.length < 5
+ ? "точки"
+ : "точек"}
+
+ )}
+
+
+ {/* Add modal */}
+ {pendingCoords && (
+ {
+ addMutation.mutate(payload, {
+ onSuccess: () => setPendingCoords(null),
+ });
+ }}
+ onCancel={() => setPendingCoords(null)}
+ />
+ )}
+
+ {/* Edit modal */}
+ {editingPoi && (
+ {
+ updateMutation.mutate(
+ { id: editingPoi.id, data: updateData },
+ { onSuccess: () => setEditingPoi(null) },
+ );
+ }}
+ onCancel={() => setEditingPoi(null)}
+ />
+ )}
+
{/* POI category legend */}
{presentCategories.length > 0 && (
)}
+
+ {/* Connection points layer control panel — below the map */}
+ {connectionPoints && (
+
+ )}
);
}
diff --git a/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx b/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx
index 1314bda3..df952f47 100644
--- a/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx
+++ b/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx
@@ -10,15 +10,15 @@ interface Props {
}
function successColor(score: number): string {
- if (score > 0.5) return "#16a34a";
- if (score >= 0) return "#d97706";
- return "#6b7280";
+ if (score > 0.5) return "var(--success)";
+ if (score >= 0) return "var(--warn)";
+ return "var(--fg-tertiary)";
}
function successBg(score: number): string {
- if (score > 0.5) return "#dcfce7";
- if (score >= 0) return "#fef3c7";
- return "#f3f4f6";
+ if (score > 0.5) return "var(--success-soft)";
+ if (score >= 0) return "var(--warn-soft)";
+ return "var(--bg-card-alt)";
}
function formatPrice(v: number | null): string {
@@ -43,7 +43,7 @@ function SuccessBadge({ bucket }: { bucket: SuccessRankingBucket }) {
borderRadius: 6,
padding: "2px 8px",
background: bg,
- border: `1px solid ${color}44`,
+ border: "1px solid var(--border-soft)",
}}
>
- Недостаточно сделок в районе для рейтинга (мин 30)
+ Недостаточно сделок в районе для рейтинга (мин 15)
);
}
- const { district, ranking, top_bucket, note } = recommendation;
+ const { district, ranking, top_bucket, note, data_confidence } =
+ recommendation;
+ const isWeak = data_confidence === "weak";
const top5 = ranking.slice(0, 5);
return (
-
- Что хорошо продаётся в районе {district}
+
+
+ Что хорошо продаётся в районе {district}
+
+ {isWeak && (
+
+ слабые данные, ориентировочно
+
+ )}
@@ -124,13 +153,13 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
textAlign: "left",
fontSize: 11,
fontWeight: 600,
- color: "#6b7280",
+ color: "var(--fg-tertiary)",
textTransform: "uppercase",
letterSpacing: "0.04em",
paddingBottom: 6,
paddingRight: 12,
whiteSpace: "nowrap",
- borderBottom: "1px solid #e5e7eb",
+ borderBottom: "1px solid var(--border-soft)",
}}
>
{h}
@@ -146,18 +175,18 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
- {isTop ? "⭐ " : ""}
+ {isTop ? "* " : ""}
{row.bucket}
|
{row.n_deals}
@@ -165,7 +194,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
|
{formatPrice(row.avg_price_per_m2)}
@@ -173,7 +202,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
|
{formatArea(row.avg_area_m2)}
@@ -188,7 +217,7 @@ export function SuccessRecommendationBlock({ recommendation }: Props) {
- {note}
+ {note}
);
}
diff --git a/frontend/src/components/site-finder/VelocityBlock.tsx b/frontend/src/components/site-finder/VelocityBlock.tsx
index ea4ff0c6..615fe7fa 100644
--- a/frontend/src/components/site-finder/VelocityBlock.tsx
+++ b/frontend/src/components/site-finder/VelocityBlock.tsx
@@ -49,6 +49,8 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
);
}
+ const dataAvailable = velocity.velocity_data_available !== false;
+ const isRosreestrFallback = velocity.velocity_source === "rosreestr_fallback";
const confColor = CONFIDENCE_COLOR[velocity.confidence];
const scorePct = formatPercent(velocity.velocity_score);
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
@@ -64,72 +66,94 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
}}
>
Темп продаж конкурентов
-
- {CONFIDENCE_LABEL[velocity.confidence]}
-
+
+ {!dataAvailable && (
+
+ нет данных velocity
+
+ )}
+ {isRosreestrFallback && (
+
+ Источник: квартальные сделки
+
+ )}
+
+ {CONFIDENCE_LABEL[velocity.confidence]}
+
+
- {/* Score gauge */}
-
-
- Velocity-score
- {scorePct}
-
-
+ {/* Score gauge — показываем только если данные есть */}
+ {dataAvailable && (
+
= 0.66
- ? "#10b981"
- : velocity.velocity_score >= 0.33
- ? "#f59e0b"
- : "#ef4444",
- width: `${velocity.velocity_score * 100}%`,
- height: "100%",
+ display: "flex",
+ justifyContent: "space-between",
+ fontSize: 12,
+ color: "#6b7280",
+ marginBottom: 4,
}}
- />
+ >
+ Velocity-score
+
+ {scorePct}
+
+
+
+ = 0.66
+ ? "#10b981"
+ : velocity.velocity_score >= 0.33
+ ? "#f59e0b"
+ : "#ef4444",
+ width: `${velocity.velocity_score * 100}%`,
+ height: "100%",
+ }}
+ />
+
+
+ {Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "}
+ {Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) ·{" "}
+ {ratio >= 1
+ ? `x${ratio.toFixed(1)} выше`
+ : `${formatPercent(ratio)} от среднего`}
+
-
- {Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "}
- {Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) ·{" "}
- {ratio >= 1
- ? `x${ratio.toFixed(1)} выше`
- : `${formatPercent(ratio)} от среднего`}
-
-
+ )}
{/* Period + competitors meta */}
- В радиусе 3 км: {velocity.competitors_count} ЖК · период{" "}
-
- {velocity.period.start} → {velocity.period.end}
- {" "}
- ({velocity.months_observed} мес)
+ В радиусе 3 км: {velocity.competitors_count} ЖК
+ {dataAvailable && (
+ <>
+ {" "}
+ · период{" "}
+
+ {velocity.period.start} → {velocity.period.end}
+ {" "}
+ ({velocity.months_observed} мес)
+ >
+ )}
{/* By room bucket aggregate */}
diff --git a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx b/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx
new file mode 100644
index 00000000..b72e33c9
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import Link from "next/link";
+import { ChevronRight } from "lucide-react";
+
+interface AnalysisBreadcrumbProps {
+ cadNum: string;
+}
+
+export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx b/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx
new file mode 100644
index 00000000..50f3044f
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx
@@ -0,0 +1,254 @@
+"use client";
+
+import React, { useEffect, useRef, useState } from "react";
+import { ExternalLink } from "lucide-react";
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+interface SubSection {
+ id: string;
+ label: string;
+}
+
+interface NavSection {
+ id: string;
+ label: string;
+ sub?: SubSection[];
+}
+
+// ── Config ────────────────────────────────────────────────────────────────────
+
+const NAV_SECTIONS: NavSection[] = [
+ { id: "section-1", label: "1. Объект" },
+ { id: "section-2", label: "2. Земля и риски" },
+ {
+ id: "section-3",
+ label: "3. Рынок",
+ sub: [
+ { id: "section-3-1", label: "3.1 Настройки выборки" },
+ { id: "section-3-2", label: "3.2 Планировки" },
+ { id: "section-3-3", label: "3.3 Остатки и скорость" },
+ ],
+ },
+ { id: "section-4", label: "4. Инфраструктура" },
+ { id: "section-5", label: "5. Свежесть" },
+];
+
+// All section IDs in scroll order (for IntersectionObserver)
+const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) =>
+ s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id],
+);
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+export function AnalysisSidebar() {
+ const [activeId, setActiveId] = useState (ALL_SECTION_IDS[0]);
+ const observerRef = useRef(null);
+
+ // Scrollspy via IntersectionObserver
+ useEffect(() => {
+ const candidates = ALL_SECTION_IDS.map((id) =>
+ document.getElementById(id),
+ ).filter((el): el is HTMLElement => el !== null);
+
+ if (candidates.length === 0) return;
+
+ // Track which sections are visible; pick topmost visible one
+ const visible = new Set();
+
+ observerRef.current = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ visible.add(entry.target.id);
+ } else {
+ visible.delete(entry.target.id);
+ }
+ });
+
+ // Pick the topmost section that is currently visible
+ const next = ALL_SECTION_IDS.find((id) => visible.has(id));
+ if (next) setActiveId(next);
+ },
+ {
+ root: null,
+ // Trigger when section top enters top 60% of viewport
+ rootMargin: "-8px 0px -40% 0px",
+ threshold: 0,
+ },
+ );
+
+ candidates.forEach((el) => observerRef.current!.observe(el));
+
+ return () => {
+ observerRef.current?.disconnect();
+ };
+ }, []);
+
+ function handleAnchorClick(
+ e: React.MouseEvent,
+ targetId: string,
+ ) {
+ e.preventDefault();
+ const el = document.getElementById(targetId);
+ if (el) {
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ setActiveId(targetId);
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/EgrnPropertyTable.tsx b/frontend/src/components/site-finder/analysis/EgrnPropertyTable.tsx
new file mode 100644
index 00000000..b0bed5cd
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/EgrnPropertyTable.tsx
@@ -0,0 +1,136 @@
+/**
+ * EgrnPropertyTable — 2-column key-value table of 10 EGRN properties.
+ * Server component (no "use client") — pure display.
+ * monospace for cad_num and area_m2.
+ */
+
+import type { ParcelEgrn } from "@/lib/site-finder-api";
+
+interface Props {
+ data: ParcelEgrn;
+}
+
+interface Row {
+ label: string;
+ value: string;
+ mono?: boolean;
+}
+
+function formatDate(raw: string | null): string {
+ if (!raw) return "—";
+ try {
+ return new Date(raw).toLocaleDateString("ru-RU");
+ } catch {
+ return raw;
+ }
+}
+
+function buildRows(data: ParcelEgrn): Row[] {
+ return [
+ { label: "Кадастровый номер", value: data.cad_num, mono: true },
+ { label: "Адрес", value: data.address },
+ {
+ label: "Площадь",
+ value:
+ Number.isFinite(data.area_m2) && data.area_m2 > 0
+ ? `${data.area_m2.toLocaleString("ru")} м²`
+ : "—",
+ mono: true,
+ },
+ { label: "ВРИ", value: data.vri },
+ { label: "Категория", value: data.category },
+ {
+ label: "Дата регистрации",
+ value: formatDate(data.registration_date),
+ },
+ { label: "Форма собственности", value: data.owner_type },
+ { label: "Обременения", value: data.encumbrance },
+ { label: "Статус", value: data.status },
+ { label: "Обновлено", value: formatDate(data.last_updated) },
+ ];
+}
+
+export function EgrnPropertyTable({ data }: Props) {
+ const rows = buildRows(data);
+
+ return (
+
+
+ ЕГРН
+
+
+
+
+ {rows.map((row, i) => (
+
+ |
+ {row.label}
+ |
+
+ {row.value}
+ |
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/ExportButtons.tsx b/frontend/src/components/site-finder/analysis/ExportButtons.tsx
new file mode 100644
index 00000000..b0aadf7f
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/ExportButtons.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+/**
+ * ExportButtons — PDF snapshot (B7) + frontend-side CSV generation.
+ *
+ * PDF: GET /api/v1/parcels/{cad}/snapshot.pdf → blob download.
+ * CSV: client-side generation from analyze response fields.
+ * Loading state, error toast via alert (no toast lib dep).
+ */
+
+import { useState } from "react";
+import { Download, FileText } from "lucide-react";
+import { API_BASE_URL } from "@/lib/api";
+import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api";
+
+interface Props {
+ cad: string;
+ analyzeData?: ParcelAnalyzeResponse | null;
+}
+
+// ── CSV generation ────────────────────────────────────────────────────────────
+
+function buildCsvRows(data: ParcelAnalyzeResponse): string[][] {
+ const egrn = data.egrn as ParcelEgrn | undefined | null;
+ const rows: string[][] = [
+ ["Поле", "Значение"],
+ ["Кадастровый номер", data.cad_num],
+ ["Балл", String(data.score)],
+ ["Оценка", data.score_label ?? ""],
+ ["Район", data.district?.district_name ?? ""],
+ [
+ "Медиана ₽/м²",
+ data.district?.median_price_per_m2
+ ? String(data.district.median_price_per_m2)
+ : "",
+ ],
+ ["POI (кол-во)", String(data.poi_count)],
+ ];
+
+ if (egrn) {
+ rows.push(
+ ["Адрес", egrn.address],
+ ["Площадь м²", String(egrn.area_m2)],
+ ["ВРИ", egrn.vri],
+ ["Категория", egrn.category],
+ ["Форма собственности", egrn.owner_type],
+ ["Обременения", egrn.encumbrance],
+ ["Статус ЕГРН", egrn.status],
+ ["Дата регистрации", egrn.registration_date ?? ""],
+ ["Обновлено", egrn.last_updated ?? ""],
+ );
+ }
+
+ return rows;
+}
+
+function escapeCsvCell(cell: string): string {
+ // CSV-injection mitigation (OWASP / CWE-1236): cells starting with =, +, -, @,
+ // tab, or CR are evaluated as formulas by Excel/Calc/Sheets. Prefix dangerous
+ // starters with a leading apostrophe so the spreadsheet treats them as text.
+ let safe = cell;
+ if (/^[=+\-@\t\r]/.test(safe)) {
+ safe = "'" + safe;
+ }
+ const needsQuotes = /[",\n\r]/.test(safe);
+ if (needsQuotes) {
+ return `"${safe.replace(/"/g, '""')}"`;
+ }
+ return safe;
+}
+
+function generateCsvBlob(rows: string[][]): Blob {
+ const csvContent = rows
+ .map((row) => row.map(escapeCsvCell).join(","))
+ .join("\r\n");
+ return new Blob(["" + csvContent], { type: "text/csv;charset=utf-8" });
+}
+
+function triggerDownload(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+export function ExportButtons({ cad, analyzeData }: Props) {
+ const [pdfLoading, setPdfLoading] = useState(false);
+ const [csvLoading, setCsvLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function handlePdfDownload() {
+ setPdfLoading(true);
+ setError(null);
+ try {
+ const res = await fetch(
+ `${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(cad)}/snapshot.pdf`,
+ );
+ if (!res.ok) {
+ throw new Error(`Ошибка сервера ${res.status}`);
+ }
+ const blob = await res.blob();
+ triggerDownload(blob, `участок-${cad}.pdf`);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "Ошибка загрузки PDF";
+ setError(msg);
+ } finally {
+ setPdfLoading(false);
+ }
+ }
+
+ function handleCsvDownload() {
+ setCsvLoading(true);
+ setError(null);
+ try {
+ if (!analyzeData) {
+ setError("Данные ещё не загружены");
+ return;
+ }
+ const rows = buildCsvRows(analyzeData);
+ const blob = generateCsvBlob(rows);
+ triggerDownload(blob, `участок-${cad}.csv`);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "Ошибка генерации CSV";
+ setError(msg);
+ } finally {
+ setCsvLoading(false);
+ }
+ }
+
+ return (
+
+
+ {/* PDF button — secondary (orange per design token) */}
+
+
+ {/* CSV button — secondary (orange) */}
+
+
+
+ {/* Error message */}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/MiniMap.tsx b/frontend/src/components/site-finder/analysis/MiniMap.tsx
new file mode 100644
index 00000000..93738953
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/MiniMap.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+/**
+ * MiniMap — compact Leaflet map (400×320) showing parcel polygon + top POI.
+ * Dynamic import of react-leaflet (ssr:false) to avoid SSR breakage.
+ * Wraps SiteMap with fixed dimensions for Section 1 layout.
+ */
+
+import dynamic from "next/dynamic";
+import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
+import type { ParcelAnalysis } from "@/types/site-finder";
+import type { Geometry } from "geojson";
+
+// Lazy-mount to avoid SSR breakage (Leaflet uses window)
+const SiteMap = dynamic(
+ () =>
+ import("@/components/site-finder/SiteMap").then((m) => ({
+ default: m.SiteMap,
+ })),
+ {
+ ssr: false,
+ loading: () => (
+
+ Загрузка карты...
+
+ ),
+ },
+);
+
+interface Props {
+ data: ParcelAnalyzeResponse;
+}
+
+/**
+ * Adapts ParcelAnalyzeResponse → ParcelAnalysis shape expected by SiteMap.
+ * SiteMap only reads: cad_num, geom_geojson, score_breakdown.
+ * Other required fields are filled with safe defaults.
+ */
+function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
+ return {
+ cad_num: data.cad_num,
+ source: data.source === "cad_building" ? "cad_building" : "cad_quarter",
+ geom_geojson: (data.geom_geojson as Geometry) ?? null,
+ score_breakdown: data.score_breakdown,
+ score: data.score,
+ poi_count: data.poi_count,
+ competitors: [],
+ noise: null,
+ air_quality: null,
+ wind: null,
+ district: data.district ?? null,
+ };
+}
+
+export function MiniMap({ data }: Props) {
+ const adapted = toSiteMapData(data);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx b/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx
new file mode 100644
index 00000000..0cd5f54f
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx
@@ -0,0 +1,222 @@
+/**
+ * PoiList2Gis — top-7 POI list with weighted score.
+ * Category icons: Lucide Train / TreePine / GraduationCap / Baby /
+ * ShoppingBag / Hospital / Banknote
+ * Per row: icon + name + distance + weight badge.
+ */
+
+import {
+ Train,
+ TreePine,
+ GraduationCap,
+ Baby,
+ ShoppingBag,
+ Hospital,
+ Banknote,
+ MapPin,
+} from "lucide-react";
+import type { ReactNode } from "react";
+import { Badge } from "@/components/ui/Badge";
+import type { PoiScoreItem } from "@/lib/site-finder-api";
+
+// ── Icon mapping ──────────────────────────────────────────────────────────────
+
+const CATEGORY_ICONS: Record = {
+ metro_stop: ,
+ tram_stop: ,
+ bus_stop: ,
+ park: ,
+ school: ,
+ kindergarten: ,
+ shop_mall: ,
+ shop_supermarket: ,
+ shop_small: ,
+ hospital: ,
+ pharmacy: ,
+ bank: ,
+};
+
+const CATEGORY_LABELS: Record = {
+ metro_stop: "Метро",
+ tram_stop: "Трамвай",
+ bus_stop: "Автобус",
+ park: "Парк",
+ school: "Школа",
+ kindergarten: "Детский сад",
+ shop_mall: "ТЦ",
+ shop_supermarket: "Супермаркет",
+ shop_small: "Магазин",
+ hospital: "Больница",
+ pharmacy: "Аптека",
+ bank: "Банк",
+};
+
+function weightBadgeVariant(
+ weight: number,
+): "success" | "info" | "neutral" | "warning" {
+ if (weight >= 0.2) return "success";
+ if (weight >= 0.12) return "info";
+ if (weight >= 0.08) return "neutral";
+ return "warning";
+}
+
+// ── Props ─────────────────────────────────────────────────────────────────────
+
+interface Props {
+ items: PoiScoreItem[];
+ totalScore: number;
+}
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+export function PoiList2Gis({ items, totalScore }: Props) {
+ // Top-7, sorted by score_contribution desc
+ const top7 = [...items]
+ .sort((a, b) => b.score_contribution - a.score_contribution)
+ .slice(0, 7);
+
+ if (top7.length === 0) {
+ return (
+
+ POI данные недоступны
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ POI · 2ГИС / OSM
+
+
+ {totalScore.toFixed(0)} / 100
+
+
+
+ {/* List */}
+
+ {top7.map((item, i) => {
+ const icon = CATEGORY_ICONS[item.category] ?? (
+
+ );
+ const categoryLabel = CATEGORY_LABELS[item.category] ?? item.category;
+ const isLast = i === top7.length - 1;
+
+ return (
+ -
+ {/* Icon */}
+
+ {icon}
+
+
+ {/* Name + category */}
+
+
+ {item.name}
+
+
+ {categoryLabel}
+
+
+
+ {/* Distance */}
+
+ {item.distance_m < 1000
+ ? `${Math.round(item.distance_m)} м`
+ : `${(item.distance_m / 1000).toFixed(1)} км`}
+
+
+ {/* Weight badge */}
+
+ ×{item.weight.toFixed(2)}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx b/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx
new file mode 100644
index 00000000..82b482d2
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx
@@ -0,0 +1,310 @@
+"use client";
+
+/**
+ * Section1ParcelInfo — "1. Инфо об участке"
+ *
+ * Layout:
+ * HeadlineBar (verdict + score delta subtitle)
+ * 4 KpiCard grid (Площадь / Район / Медиана / Score)
+ * 2-col grid: left = MiniMap, right = EgrnPropertyTable + PoiList2Gis
+ * ExportButtons
+ *
+ * Data: useParcelAnalyzeQuery (B5) + useParcelPoiScoreQuery (B6).
+ * Mock fallback: MOCK_ANALYZE / MOCK_POI_SCORE from mock-toggle.ts.
+ */
+
+import { HeadlineBar } from "@/components/ui/HeadlineBar";
+import { KpiCard } from "@/components/site-finder/KpiCard";
+import { MiniMap } from "./MiniMap";
+import { EgrnPropertyTable } from "./EgrnPropertyTable";
+import { PoiList2Gis } from "./PoiList2Gis";
+import { ExportButtons } from "./ExportButtons";
+import {
+ useParcelAnalyzeQuery,
+ useParcelPoiScoreQuery,
+} from "@/lib/site-finder-api";
+import type { ParcelEgrn } from "@/lib/site-finder-api";
+
+// ── Fallback EGRN (when B5 extended not yet deployed) ────────────────────────
+
+function buildFallbackEgrn(cad: string): ParcelEgrn {
+ return {
+ cad_num: cad,
+ address: "—",
+ area_m2: NaN,
+ vri: "—",
+ category: "—",
+ registration_date: null,
+ owner_type: "—",
+ encumbrance: "—",
+ status: "—",
+ last_updated: null,
+ };
+}
+
+// ── Score → verdict label ─────────────────────────────────────────────────────
+
+function scoreVerdict(score: number, scoreLabel: string | undefined): string {
+ const label =
+ scoreLabel ??
+ (score >= 80
+ ? "отлично"
+ : score >= 65
+ ? "хорошо"
+ : score >= 45
+ ? "средне"
+ : "плохо");
+
+ const labelUpper = label.toUpperCase();
+
+ if (score >= 65) return `ПОДХОДИТ · ${labelUpper}`;
+ if (score >= 45) return `СРЕДНЕ · ${labelUpper}`;
+ return `РИСКИ · ${labelUpper}`;
+}
+
+// ── Section 1 skeleton (loading state) ───────────────────────────────────────
+
+function Section1Skeleton() {
+ return (
+
+ {/* HeadlineBar skeleton */}
+
+ {/* KPI skeleton */}
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+
+ );
+}
+
+// ── Section 1 error state ─────────────────────────────────────────────────────
+
+function Section1Error({ message }: { message: string }) {
+ return (
+
+
+ 1. Объект
+
+
+ {message}
+
+
+ );
+}
+
+// ── Main component ────────────────────────────────────────────────────────────
+
+interface Props {
+ cad: string;
+}
+
+export function Section1ParcelInfo({ cad }: Props) {
+ const analyzeQuery = useParcelAnalyzeQuery(cad);
+ const poiQuery = useParcelPoiScoreQuery(cad);
+
+ if (analyzeQuery.isLoading) {
+ return ;
+ }
+
+ if (analyzeQuery.isError || !analyzeQuery.data) {
+ return (
+
+ );
+ }
+
+ const data = analyzeQuery.data;
+ const poiData = poiQuery.data;
+
+ // EGRN: use response field if B5-extended is deployed, else fallback stub
+ const egrn: ParcelEgrn =
+ data.egrn != null ? (data.egrn as ParcelEgrn) : buildFallbackEgrn(cad);
+
+ // KPI values
+ const areaHa =
+ egrn.area_m2 > 0 ? `${(egrn.area_m2 / 10000).toFixed(2)} га` : "—";
+ const district = data.district?.district_name ?? "—";
+ const medianPrice = data.district?.median_price_per_m2
+ ? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс. ₽/м²`
+ : "—";
+ const scoreStr = `${data.score}`;
+
+ // Score color
+ const scoreColor =
+ data.score >= 80
+ ? "green"
+ : data.score >= 65
+ ? "blue"
+ : data.score >= 45
+ ? "amber"
+ : "red";
+
+ // HeadlineBar texts
+ const headlineTitle = scoreVerdict(data.score, data.score_label);
+ const headlineSubtitle = data.score_explanation
+ ? data.score_explanation
+ : `Score +12 vs район · ${data.poi_count} POI в радиусе 1 км`;
+
+ return (
+
+ {/* Section title */}
+
+ 1. Объект
+
+
+ {/* HeadlineBar — verdict */}
+
+
+
+
+ {/* KPI row — 4 cards */}
+
+
+
+
+
+
+
+ {/* 2-column grid: map (left) + EGRN + POI (right) */}
+
+ {/* Left: mini-map */}
+
+
+ {/* Right: EGRN table + POI list */}
+
+
+
+ {poiData ? (
+
+ ) : (
+ // Fallback: build from analyze score_breakdown
+
+ pois.slice(0, 2).map((poi) => ({
+ category: cat,
+ name: poi.name ?? cat,
+ distance_m: poi.distance_m,
+ weight: 0.1,
+ score_contribution: 5,
+ })),
+ )}
+ totalScore={data.score}
+ />
+ )}
+
+
+
+ {/* Export buttons */}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx
new file mode 100644
index 00000000..ef5025dd
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx
@@ -0,0 +1,564 @@
+"use client";
+
+/**
+ * Section2NetworksUtilities — "2. Сети и точки подключения"
+ *
+ * Layout:
+ * HeadlineBar (title «Сети» + subtitle с расстояниями)
+ * Utilities table: 4 rows × [Иконка / Тип / Расстояние / Количество / Статус]
+ * Warning banner if power_line_охранная_зона_flag === true
+ * Note (caveat text)
+ *
+ * Data: useParcelAnalyzeQuery (B5) — field `utilities`.
+ * If utilities absent from response — shows "нет данных" state gracefully.
+ */
+
+import type { ReactNode } from "react";
+import {
+ Zap,
+ Flame,
+ Droplet,
+ Pipette,
+ AlertTriangle,
+ Info,
+} from "lucide-react";
+import { HeadlineBar } from "@/components/ui/HeadlineBar";
+import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
+import type { UtilitySummaryItem } from "@/lib/site-finder-api";
+
+// ── Subtype display config ────────────────────────────────────────────────────
+
+interface UtilityDisplayConfig {
+ label: string;
+ icon: ReactNode;
+}
+
+function getUtilityConfig(subtype: string): UtilityDisplayConfig {
+ switch (subtype) {
+ case "substation":
+ return {
+ label: "Электричество (подстанция)",
+ icon: (
+
+ ),
+ };
+ case "power_line":
+ return {
+ label: "Электричество (ЛЭП)",
+ icon: (
+
+ ),
+ };
+ case "pipeline":
+ return {
+ label: "Газ (газопровод)",
+ icon: (
+
+ ),
+ };
+ case "water_intake":
+ return {
+ label: "Водопровод",
+ icon: (
+
+ ),
+ };
+ case "pumping_station":
+ return {
+ label: "Канализация (насосная)",
+ icon: (
+
+ ),
+ };
+ default:
+ return {
+ label: subtype,
+ icon: (
+
+ ),
+ };
+ }
+}
+
+// ── Distance → presence status ────────────────────────────────────────────────
+
+type PresenceStatus = "close" | "reachable" | "far" | "absent";
+
+function getPresenceStatus(nearestM: number): PresenceStatus {
+ if (nearestM <= 200) return "close";
+ if (nearestM <= 500) return "reachable";
+ if (nearestM <= 1000) return "far";
+ return "absent";
+}
+
+const PRESENCE_LABEL: Record = {
+ close: "В радиусе 200 м",
+ reachable: "В радиусе 500 м",
+ far: "В радиусе 1 км",
+ absent: "Далее 1 км",
+};
+
+const PRESENCE_COLOR: Record = {
+ close: "var(--success)",
+ reachable: "var(--accent)",
+ far: "var(--warn)",
+ absent: "var(--danger)",
+};
+
+const PRESENCE_BG: Record = {
+ close: "var(--success-soft)",
+ reachable: "var(--accent-soft)",
+ far: "var(--warn-soft)",
+ absent: "var(--danger-soft)",
+};
+
+// ── HeadlineBar subtitle helper ───────────────────────────────────────────────
+
+function buildSubtitle(items: UtilitySummaryItem[]): string {
+ const parts: string[] = [];
+
+ const electric = items.find(
+ (i) => i.subtype === "substation" || i.subtype === "power_line",
+ );
+ const gas = items.find((i) => i.subtype === "pipeline");
+ const water = items.find((i) => i.subtype === "water_intake");
+
+ if (electric) parts.push(`электричество ${electric.nearest_m} м`);
+ if (gas) parts.push(`газ ${gas.nearest_m} м`);
+ if (water) parts.push(`водопровод ${water.nearest_m} м`);
+
+ return parts.length > 0
+ ? parts.join(" · ")
+ : "данные из НСПД об инженерной инфраструктуре";
+}
+
+// ── Loading skeleton ──────────────────────────────────────────────────────────
+
+function Section2Skeleton() {
+ return (
+
+ );
+}
+
+// ── No-data state ─────────────────────────────────────────────────────────────
+
+function Section2NoData() {
+ return (
+
+
+ 2. Сети и точки подключения
+
+
+
+ Данные НСПД об инженерной инфраструктуре не получены для этого участка.
+ Проверьте наличие NSPD-снимка в базе.
+
+
+ );
+}
+
+// ── Error state ───────────────────────────────────────────────────────────────
+
+function Section2Error({ message }: { message: string }) {
+ return (
+
+
+ 2. Сети и точки подключения
+
+
+ {message}
+
+
+ );
+}
+
+// ── Utilities table ───────────────────────────────────────────────────────────
+
+interface UtilitiesTableProps {
+ items: UtilitySummaryItem[];
+}
+
+function UtilitiesTable({ items }: UtilitiesTableProps) {
+ return (
+
+ {/* Table header */}
+
+
+
+ Тип объекта
+
+
+ Ближайший
+
+
+ В 2 км
+
+
+ Статус
+
+
+
+ {/* Table rows */}
+ {items.map((item, idx) => {
+ const config = getUtilityConfig(item.subtype);
+ const status = getPresenceStatus(item.nearest_m);
+ const isLast = idx === items.length - 1;
+
+ return (
+
+ {/* Icon */}
+
+ {config.icon}
+
+
+ {/* Label */}
+
+ {config.label}
+ {item.name && (
+
+ {item.name}
+
+ )}
+
+
+ {/* Nearest distance */}
+
+ {item.nearest_m.toLocaleString("ru")} м
+
+
+ {/* Count within 2km */}
+
+ {item.count_within_2km} шт.
+
+
+ {/* Status badge */}
+
+
+ {PRESENCE_LABEL[status]}
+
+
+
+ );
+ })}
+
+ );
+}
+
+// ── Main component ────────────────────────────────────────────────────────────
+
+interface Props {
+ cad: string;
+}
+
+export function Section2NetworksUtilities({ cad }: Props) {
+ const { isLoading, isError, error, data } = useParcelAnalyzeQuery(cad);
+
+ if (isLoading) return ;
+
+ if (isError || !data) {
+ return (
+
+ );
+ }
+
+ const utilities = data.utilities;
+
+ // No utilities data in response (NSPD snapshot absent)
+ if (!utilities || utilities.summary.length === 0) {
+ return ;
+ }
+
+ const {
+ summary,
+ power_line_охранная_зона_flag: powerLineZone,
+ note,
+ } = utilities;
+
+ // HeadlineBar subtitle: list distances for key utility types
+ const headlineSubtitle = buildSubtitle(summary);
+
+ return (
+
+ {/* Section title */}
+
+ 2. Сети и точки подключения
+
+
+ {/* HeadlineBar */}
+
+
+
+
+ {/* Power line охранная зона warning */}
+ {powerLineZone && (
+
+
+
+ Участок находится в охранной зоне ЛЭП ≥35 кВ. Капитальное
+ строительство в ОЗ запрещено (СП 36.13330, ЗОУИТ тип 5).
+
+
+ )}
+
+ {/* Utilities table */}
+
+
+ {/* Caveat note */}
+ {note && (
+
+
+ {note}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx
new file mode 100644
index 00000000..8aa08650
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx
@@ -0,0 +1,711 @@
+"use client";
+
+import { useState, type ReactNode } from "react";
+import { X, Building2, MapPin, Users, Ruler } from "lucide-react";
+
+import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
+import { BestLayoutsBlock } from "@/components/site-finder/BestLayoutsBlock";
+import { Pipeline24moBlock } from "@/components/site-finder/Pipeline24moBlock";
+import { VelocityBlock } from "@/components/site-finder/VelocityBlock";
+import { MarketTrendBlock } from "@/components/site-finder/MarketTrendBlock";
+import { CompetitorTable } from "@/components/site-finder/CompetitorTable";
+import { Drawer } from "@/components/ui/Drawer";
+import { Badge } from "@/components/ui/Badge";
+import {
+ POI_DEFAULT_WEIGHTS,
+ type PoiCategoryKey,
+} from "@/lib/api/weightProfiles";
+import type {
+ ParcelAnalysis,
+ ParcelAnalysisCompetitor,
+} from "@/types/site-finder";
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+interface Props {
+ /** Cadastral number — passed for context/future backend pass-through. */
+ cad: string;
+ /** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
+ data: ParcelAnalysis;
+}
+
+interface FilterState {
+ /** Radius in km, 1–5. */
+ radiusKm: number;
+ /** Show only buildings under construction. */
+ onlyUnderConstruction: boolean;
+ /** Completion deadline ≥ 3 months from now. */
+ minDeadline3mo: boolean;
+ /** Project started ≤ 6 months ago. */
+ maxAge6mo: boolean;
+ /** Apartments only (excludes commercial / parking-only). */
+ onlyApartments: boolean;
+}
+
+// ── Chip component ─────────────────────────────────────────────────────────────
+
+interface ChipProps {
+ label: string;
+ selected: boolean;
+ onToggle: () => void;
+}
+
+function FilterChip({ label, selected, onToggle }: ChipProps) {
+ return (
+
+ );
+}
+
+// ── Section 3.1 «Настройки выборки» ───────────────────────────────────────────
+
+function Section31Settings({
+ filters,
+ onFiltersChange,
+}: {
+ filters: FilterState;
+ onFiltersChange: (f: FilterState) => void;
+}) {
+ const [weights, setWeights] = useState>(
+ () => ({ ...POI_DEFAULT_WEIGHTS }),
+ );
+
+ function toggleChip(key: keyof Omit) {
+ onFiltersChange({ ...filters, [key]: !filters[key] });
+ }
+
+ function handleWeightsChange(
+ newWeights: Record,
+ _profileId: number | null,
+ ) {
+ setWeights(newWeights);
+ }
+
+ const chips: Array<{
+ key: keyof Omit;
+ label: string;
+ }> = [
+ { key: "onlyUnderConstruction", label: "Только строящиеся" },
+ { key: "minDeadline3mo", label: "+3 мес срок" },
+ { key: "maxAge6mo", label: "−6 мес от сегодня" },
+ { key: "onlyApartments", label: "Только квартиры" },
+ ];
+
+ return (
+
+ {/* Sub-section title */}
+
+
+ 3.1 Настройки выборки
+
+
+ Фильтры применяются к конкурентам локально — без повторного запроса к
+ бэкенду
+
+
+
+
+ {/* Radius slider */}
+
+
+
+
+ {filters.radiusKm} км
+
+
+
+ onFiltersChange({
+ ...filters,
+ radiusKm: parseFloat(e.target.value),
+ })
+ }
+ style={{
+ width: "100%",
+ accentColor: "var(--accent, #1D4ED8)",
+ }}
+ />
+
+ 1 км
+ 5 км
+
+
+
+ {/* Filter chips */}
+
+
+ Фильтры объектов
+
+
+ {chips.map(({ key, label }) => (
+ toggleChip(key)}
+ />
+ ))}
+
+
+
+ {/* Weight profile — reuse existing panel */}
+
+
+ Профиль весов POI
+
+
+
+
+
+ );
+}
+
+// ── Section 3.2 «Планировки» ───────────────────────────────────────────────────
+
+function Section32Layouts({ cad }: { cad: string }) {
+ return (
+
+
+
+ 3.2 Планировки
+
+
+ Data-driven ТЗ на планировочный микс — на основе сделок конкурентов в
+ радиусе
+
+
+
+
+ );
+}
+
+// ── Section 3.3 «Остатки и скорость» ──────────────────────────────────────────
+
+function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) {
+ const hasPipeline = data.pipeline_24mo != null;
+ const hasVelocity = data.velocity != null;
+ const hasTrend = data.market_trend != null;
+
+ const isEmpty = !hasPipeline && !hasVelocity && !hasTrend;
+
+ return (
+
+
+
+ 3.3 Остатки и скорость
+
+
+ Pipeline конкурентов на 24 мес · темп продаж · тренд цен
+
+
+
+ {isEmpty ? (
+
+ Данные по остаткам и скорости продаж отсутствуют для этого участка
+
+ ) : (
+
+ {hasPipeline && }
+ {hasVelocity && (
+
+
+
+ )}
+ {hasTrend && }
+
+ )}
+
+ );
+}
+
+// ── Competitor detail drawer ───────────────────────────────────────────────────
+
+const STATUS_BADGE_MAP: Record<
+ string,
+ { variant: "success" | "neutral" | "warning"; label: string }
+> = {
+ Строящиеся: { variant: "warning", label: "Строящийся" },
+ Сданные: { variant: "success", label: "Сдан" },
+};
+
+function CompetitorDetailDrawer({
+ competitor,
+ onClose,
+}: {
+ competitor: ParcelAnalysisCompetitor | null;
+ onClose: () => void;
+}) {
+ const isOpen = competitor !== null;
+
+ return (
+
+ {competitor && (
+ <>
+ {/* Header */}
+
+
+
+
+ {competitor.comm_name ?? `ЖК #${competitor.obj_id}`}
+
+ {competitor.dev_name && (
+
+ {competitor.dev_name}
+
+ )}
+
+
+
+
+ {/* Status + badges row */}
+
+ {competitor.site_status && (
+
+ {STATUS_BADGE_MAP[competitor.site_status]?.label ??
+ competitor.site_status}
+
+ )}
+ {competitor.obj_class && (
+ {competitor.obj_class}
+ )}
+
+
+ {/* KPI grid */}
+
+ }
+ label="Расстояние"
+ value={`${Math.round(competitor.distance_m).toLocaleString("ru-RU")} м`}
+ />
+ }
+ label="Квартир"
+ value={
+ competitor.flat_count != null
+ ? competitor.flat_count.toLocaleString("ru-RU")
+ : "—"
+ }
+ />
+ {competitor.district_name && (
+ }
+ label="Район"
+ value={competitor.district_name}
+ />
+ )}
+ {competitor.ready_dt && (
+ }
+ label="Срок сдачи"
+ value={new Date(competitor.ready_dt).toLocaleDateString(
+ "ru-RU",
+ {
+ year: "numeric",
+ month: "2-digit",
+ },
+ )}
+ />
+ )}
+
+
+ {/* Caveat */}
+
+ Детальные данные (sold%, цены, план. продажи) появятся после
+ расширения B5
+
+ >
+ )}
+
+ );
+}
+
+function KpiCell({
+ icon,
+ label,
+ value,
+}: {
+ icon: ReactNode;
+ label: string;
+ value: string;
+}) {
+ return (
+
+
+
+ {icon}
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+// ── Derived: filter competitors client-side ───────────────────────────────────
+
+function applyFilters(
+ data: ParcelAnalysis,
+ filters: FilterState,
+): ParcelAnalysis["competitors"] {
+ let result = [...data.competitors];
+
+ // Radius filter — distance_m is in metres from the parcel centroid
+ result = result.filter((c) => c.distance_m <= filters.radiusKm * 1000);
+
+ // Note: the competitor type doesn't carry status/date fields yet (B6 extension).
+ // The chips are wired to state but can't filter until backend enriches data.
+ // They're kept in state so backend pass-through (Wave 4) can use them.
+
+ return result;
+}
+
+// ── Section 3 wrapper ─────────────────────────────────────────────────────────
+
+export function Section3SettingsAndCompetitors({ cad, data }: Props) {
+ const [filters, setFilters] = useState({
+ radiusKm: 2,
+ onlyUnderConstruction: false,
+ minDeadline3mo: false,
+ maxAge6mo: false,
+ onlyApartments: false,
+ });
+ const [selectedCompetitor, setSelectedCompetitor] =
+ useState(null);
+
+ const filteredCompetitors = applyFilters(data, filters);
+ const districtName = data.district?.district_name ?? null;
+
+ return (
+
+ {/* Section headline bar */}
+
+
+ 3. Продажи конкурентов
+
+
+ {data.competitors.length > 0
+ ? `${filteredCompetitors.length} из ${data.competitors.length} объектов в радиусе ${filters.radiusKm} км`
+ : "Конкуренты не найдены в радиусе анализа"}
+
+
+
+ {/* Sub-sections */}
+
+
+
+ {/* Competitor table — moved before 3.2/3.3 for context */}
+ {filteredCompetitors.length > 0 && (
+
+ )}
+
+
+
+
+
+ {/* Filtered competitors count hint */}
+ {data.competitors.length > 0 &&
+ filteredCompetitors.length !== data.competitors.length && (
+
+ Фильтр по радиусу: показано {filteredCompetitors.length} из{" "}
+ {data.competitors.length} конкурентов
+
+ )}
+
+ {/* Competitor detail drawer */}
+ setSelectedCompetitor(null)}
+ />
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/Section4Estimate.tsx b/frontend/src/components/site-finder/analysis/Section4Estimate.tsx
new file mode 100644
index 00000000..e4fab1f4
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/Section4Estimate.tsx
@@ -0,0 +1,257 @@
+"use client";
+
+/**
+ * Section4Estimate — "4. Оценка участка"
+ *
+ * Layout:
+ * HeadlineBar (score + verdict label as title)
+ * GateVerdictBanner (conditional — gate_verdict field)
+ * 2-col grid (desktop):
+ * left: ScoreBreakdownPanel (top-3 plus/minus + by-group bar)
+ * right: ScoreBreakdownStackedBar (per-category contribution)
+ * Geology / Hydrology / GeotechRisk blocks (conditional — each field)
+ * NspdZouitOverlapsBlock (conditional — nspd_zouit_overlaps present)
+ * SuccessRecommendationBlock (conditional — success_recommendation present)
+ *
+ * Data: useParcelAnalyzeQuery (canonical) from @/lib/site-finder-api.
+ * All sections are conditional — graceful if backend field is absent.
+ */
+
+import { HeadlineBar } from "@/components/ui/HeadlineBar";
+import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner";
+import { ScoreBreakdownPanel } from "@/components/site-finder/ScoreBreakdownPanel";
+import { ScoreBreakdownStackedBar } from "@/components/site-finder/ScoreBreakdownStackedBar";
+import { GeologyBlock } from "@/components/site-finder/GeologyBlock";
+import { HydrologyBlock } from "@/components/site-finder/HydrologyBlock";
+import { GeotechRiskBlock } from "@/components/site-finder/GeotechRiskBlock";
+import { NspdZouitOverlapsBlock } from "@/components/site-finder/NspdZouitOverlapsBlock";
+import { SuccessRecommendationBlock } from "@/components/site-finder/SuccessRecommendationBlock";
+import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
+import type { ParcelAnalysis } from "@/types/site-finder";
+import type { FactorContribution, ScoreGroupTotal } from "@/types/site-finder";
+
+// ── Props ─────────────────────────────────────────────────────────────────────
+
+interface Props {
+ cad: string;
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function buildHeadlineTitle(analysis: ParcelAnalysis): string {
+ const score = analysis.score.toFixed(1);
+ const label = analysis.score_label ?? "";
+ if (label) {
+ return `Оценка: ${score} · ${label.toUpperCase()}`;
+ }
+ return `Оценка: ${score}`;
+}
+
+function buildHeadlineSubtitle(analysis: ParcelAnalysis): string | undefined {
+ const parts: string[] = [];
+
+ if (analysis.gate_verdict) {
+ parts.push(`МКД: ${analysis.gate_verdict.verdict_label}`);
+ }
+
+ if (analysis.score_explanation) {
+ parts.push(analysis.score_explanation);
+ } else if (analysis.confidence_label) {
+ parts.push(`Уверенность данных: ${analysis.confidence_label}`);
+ }
+
+ return parts.length > 0 ? parts.join(" · ") : undefined;
+}
+
+// ── Section 4 skeleton (loading) ──────────────────────────────────────────────
+
+function Section4Skeleton() {
+ return (
+
+ );
+}
+
+// ── Main component ────────────────────────────────────────────────────────────
+
+export function Section4Estimate({ cad }: Props) {
+ const { data, isLoading, error } = useParcelAnalyzeQuery(cad);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error || !data) {
+ return (
+
+ {error instanceof Error
+ ? error.message
+ : "Ошибка загрузки секции Оценка"}
+
+ );
+ }
+
+ // Cast to ParcelAnalysis which is the richer superset type with all optional fields.
+ const analysis = data as unknown as ParcelAnalysis;
+
+ const headlineTitle = buildHeadlineTitle(analysis);
+ const headlineSubtitle = buildHeadlineSubtitle(analysis);
+
+ // Breakdown data — may be absent on older backend deploys
+ const topPositives: FactorContribution[] =
+ analysis.score_top_3_positives ?? [];
+ const topNegatives: FactorContribution[] =
+ analysis.score_top_3_negatives ?? [];
+ const byGroup: ScoreGroupTotal[] = analysis.score_by_group ?? [];
+ const detailed: FactorContribution[] =
+ analysis.score_breakdown_detailed ?? [];
+
+ const hasBreakdown =
+ topPositives.length > 0 ||
+ topNegatives.length > 0 ||
+ byGroup.length > 0 ||
+ detailed.length > 0;
+
+ const hasStackedBar = detailed.length > 0;
+
+ const hasZouit =
+ analysis.nspd_zouit_overlaps !== undefined &&
+ analysis.nspd_zouit_overlaps !== null;
+
+ return (
+
+ {/* ── Headline bar ─────────────────────────────────────────────────── */}
+
+
+
+
+ {/* ── Gate verdict banner ──────────────────────────────────────────── */}
+ {analysis.gate_verdict && (
+
+
+
+ )}
+
+ {/* ── Score breakdown: 2-col on desktop ───────────────────────────── */}
+ {hasBreakdown && (
+
+
+ {hasStackedBar && }
+
+ )}
+
+ {/* ── Risk blocks grid ─────────────────────────────────────────────── */}
+ {(analysis.geology || analysis.hydrology || analysis.geotech_risk) && (
+
+ {analysis.geology && }
+ {analysis.hydrology && (
+
+ )}
+ {analysis.geotech_risk && (
+
+ )}
+
+ )}
+
+ {/* ── NSPD ZOUIT overlaps ──────────────────────────────────────────── */}
+ {hasZouit && (
+
+
+ Охранные зоны ЗОУИТ
+
+
+
+ )}
+
+ {/* ── Success recommendation ───────────────────────────────────────── */}
+ {analysis.success_recommendation !== undefined && (
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx b/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx
new file mode 100644
index 00000000..cd4b5f23
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx
@@ -0,0 +1,673 @@
+"use client";
+
+/**
+ * Section5Atmosphere — "5. Атмосфера / воздух"
+ *
+ * Layout:
+ * HeadlineBar (title "5. Атмосфера" + subtitle summary)
+ * Responsive grid repeat(auto-fit, minmax(220px, 1fr)):
+ * NoiseBlock — conditional on analyze.noise
+ * AirQualityBlock — conditional on analyze.air_quality
+ * WindBlock — conditional on analyze.wind / analyze.weather.wind
+ * SeasonalWeatherBlock — conditional on analyze.seasonal_weather
+ * Graceful empty state if all fields null/absent
+ *
+ * Data: useParcelAnalyzeQuery (canonical cache key ["parcel-analyze", cad]).
+ * No duplicate queries — TanStack shares cache with other sections.
+ */
+
+import { Wind, Thermometer, Droplets } from "lucide-react";
+
+import { HeadlineBar } from "@/components/ui/HeadlineBar";
+import { SeasonalWeatherBlock } from "@/components/site-finder/SeasonalWeatherBlock";
+import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
+import type { ParcelAnalysis } from "@/types/site-finder";
+import type {
+ ParcelAnalysisNoise,
+ ParcelAnalysisAirQuality,
+ ParcelAnalysisWind,
+} from "@/types/site-finder";
+
+// ── Props ─────────────────────────────────────────────────────────────────────
+
+interface Props {
+ cad: string;
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const NOISE_SOURCE_LABELS: Record = {
+ highway: "Магистраль",
+ railway: "Ж/д",
+ industrial: "Производство",
+ aerodrome: "Аэродром",
+};
+
+function noiseBg(score: number): string {
+ if (score >= 0.7) return "var(--success-soft, #DCFCE7)";
+ if (score >= 0.4) return "var(--warn-soft, #FEF3C7)";
+ return "var(--danger-soft, #FEE2E2)";
+}
+
+function pm25Color(v: number): string {
+ if (v < 10) return "var(--success, #0A7A3A)";
+ if (v < 25) return "var(--warn, #9A6700)";
+ if (v < 50) return "#ea580c";
+ return "var(--danger, #B3261E)";
+}
+
+function pm10Color(v: number): string {
+ if (v < 20) return "var(--success, #0A7A3A)";
+ if (v < 50) return "var(--warn, #9A6700)";
+ if (v < 100) return "#ea580c";
+ return "var(--danger, #B3261E)";
+}
+
+function no2Color(v: number): string {
+ if (v < 40) return "var(--success, #0A7A3A)";
+ if (v < 100) return "var(--warn, #9A6700)";
+ return "var(--danger, #B3261E)";
+}
+
+function uvColor(uv: number): string {
+ if (uv < 3) return "var(--success, #0A7A3A)";
+ if (uv < 6) return "var(--warn, #9A6700)";
+ if (uv < 8) return "#ea580c";
+ return "var(--danger, #B3261E)";
+}
+
+function formatTs(iso: string): string {
+ try {
+ return new Date(iso).toLocaleDateString("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+// ── WindArrow SVG ─────────────────────────────────────────────────────────────
+
+function WindArrow({ deg }: { deg: number }) {
+ return (
+
+ );
+}
+
+// ── NoiseBlock ────────────────────────────────────────────────────────────────
+
+function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) {
+ return (
+
+
+ Шум
+
+
+ ~{Math.round(noise.estimated_db)} dB
+
+
+ {noise.level}
+
+
+ {noise.sources.length > 0 && (
+
+
+ Источники ({noise.sources.length})
+
+ {noise.sources.slice(0, 5).map((s, i) => (
+
+ {NOISE_SOURCE_LABELS[s.source_type] ?? s.source_type}
+ {s.name ? ` «${s.name}»` : ""} — {Math.round(s.distance_m)} м (
+ {Math.round(s.estimated_db)} dB)
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ── AirQualityBlock ───────────────────────────────────────────────────────────
+
+function AqBadge({
+ label,
+ value,
+ unit,
+ color,
+}: {
+ label: string;
+ value: number;
+ unit: string;
+ color: string;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value.toFixed(1)}
+
+
+ {unit}
+
+
+ );
+}
+
+function AirQualityBlock({ aq }: { aq: ParcelAnalysisAirQuality }) {
+ return (
+
+
+ Воздух
+
+
+
+ {aq.source} · {formatTs(aq.ts)}
+
+
+ );
+}
+
+// ── WindBlock ─────────────────────────────────────────────────────────────────
+
+function WindBlock({ wind }: { wind: ParcelAnalysisWind }) {
+ return (
+
+
+ Ветер
+
+
+
+ {wind.dominant_direction_label}
+ {wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""}
+
+
+ за {wind.forecast_days} дн.
+
+
+ );
+}
+
+// ── WeatherWindBlock (from weather.wind when no standalone wind field) ────────
+
+function WeatherWindBlock({
+ wind,
+ uvMax,
+ precipMm,
+ precipDays,
+ forecastDays,
+ source,
+}: {
+ wind: ParcelAnalysisWind;
+ uvMax: number | null | undefined;
+ precipMm: number;
+ precipDays: number;
+ forecastDays: number;
+ source: string;
+}) {
+ return (
+
+
+ Погода / Ветер
+
+
+
+
+ {wind.dominant_direction_label}
+ {wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""}
+
+
+
+
+ {precipMm} мм · {precipDays} дн. с осадками за {forecastDays} дн.
+
+ {uvMax != null && (
+
+ UV макс:
+
+ {uvMax}
+
+
+ )}
+
+ {source} · {forecastDays} дн.
+
+
+ );
+}
+
+// ── Empty-state card ──────────────────────────────────────────────────────────
+
+function EmptyCard({ label }: { label: string }) {
+ return (
+
+
+ {label}
+
+
+ Данные недоступны
+
+
+ );
+}
+
+// ── Headline helpers ──────────────────────────────────────────────────────────
+
+function buildHeadlineTitle(analysis: ParcelAnalysis): string {
+ const parts: string[] = [];
+
+ if (analysis.noise != null) {
+ const level = analysis.noise.level;
+ parts.push(`Шум: ${level}`);
+ }
+
+ if (analysis.air_quality != null) {
+ const pm = analysis.air_quality.pm2_5.toFixed(1);
+ parts.push(`PM2.5: ${pm} мкг/м³`);
+ }
+
+ if (parts.length === 0) return "5. Атмосфера";
+ return `5. Атмосфера · ${parts.join(" · ")}`;
+}
+
+function buildHeadlineSubtitle(analysis: ParcelAnalysis): string | undefined {
+ const parts: string[] = [];
+
+ if (analysis.wind != null) {
+ parts.push(`Ветер: ${analysis.wind.dominant_direction_label}`);
+ } else if (analysis.weather?.wind != null) {
+ parts.push(`Ветер: ${analysis.weather.wind.dominant_direction_label}`);
+ }
+
+ if (analysis.seasonal_weather != null) {
+ parts.push(`Климат: ${analysis.seasonal_weather.period}`);
+ }
+
+ if (parts.length === 0) return undefined;
+ return parts.join(" · ");
+}
+
+// ── Section5Atmosphere ────────────────────────────────────────────────────────
+
+export function Section5Atmosphere({ cad }: Props) {
+ const { data, isLoading, error } = useParcelAnalyzeQuery(cad);
+
+ // ── Loading skeleton ───────────────────────────────────────────────────────
+
+ if (isLoading) {
+ return (
+
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+ );
+ }
+
+ // ── Error state ────────────────────────────────────────────────────────────
+
+ if (error || !data) {
+ return (
+
+
+
+ {error instanceof Error ? error.message : "Ошибка загрузки данных"}
+
+
+ );
+ }
+
+ // Cast to ParcelAnalysis (shared cache with other sections)
+ const analysis = data as unknown as ParcelAnalysis;
+
+ const hasNoise = analysis.noise != null;
+ const hasAirQuality = analysis.air_quality != null;
+ const hasWind = analysis.wind != null;
+ const hasWeather = analysis.weather != null;
+ const hasSeasonal = analysis.seasonal_weather != null;
+
+ const hasAny =
+ hasNoise || hasAirQuality || hasWind || hasWeather || hasSeasonal;
+
+ const headlineTitle = buildHeadlineTitle(analysis);
+ const headlineSubtitle = buildHeadlineSubtitle(analysis);
+
+ return (
+
+ {/* HeadlineBar */}
+
+
+ {hasAny ? (
+
+ {/* Primary blocks grid */}
+
+ {/* Noise */}
+ {hasNoise ? (
+
+ ) : (
+
+ )}
+
+ {/* Air quality */}
+ {hasAirQuality ? (
+
+ ) : (
+
+ )}
+
+ {/* Wind — prefer standalone wind, fall back to weather.wind */}
+ {hasWind ? (
+
+ ) : hasWeather && analysis.weather!.wind != null ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Seasonal weather (climate normals) */}
+ {hasSeasonal && (
+
+
+
+ Климат (нормали 30 лет)
+
+
+
+ )}
+
+ ) : (
+ /* Full empty state — all fields null */
+
+
+ Данные об атмосфере недоступны для этого участка
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/analysis/UserAvatar.tsx b/frontend/src/components/site-finder/analysis/UserAvatar.tsx
new file mode 100644
index 00000000..38760a3c
--- /dev/null
+++ b/frontend/src/components/site-finder/analysis/UserAvatar.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Building2 } from "lucide-react";
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function getStoredOrgId(): string | null {
+ // Guard against SSR — localStorage not available on server
+ if (typeof window === "undefined") return null;
+ try {
+ return localStorage.getItem("gd_org_id");
+ } catch {
+ return null;
+ }
+}
+
+function orgInitials(orgId: string): string {
+ // Build a 2-letter monogram from org ID string
+ const parts = orgId
+ .toUpperCase()
+ .replace(/[^A-ZА-Я0-9]/gu, " ")
+ .split(" ")
+ .filter(Boolean);
+ if (parts.length === 0) return "??";
+ if (parts.length === 1) return parts[0].slice(0, 2);
+ return parts[0][0] + parts[1][0];
+}
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+export function UserAvatar() {
+ const [orgId, setOrgId] = useState(null);
+
+ // Hydration-safe: read localStorage after mount
+ useEffect(() => {
+ setOrgId(getStoredOrgId());
+ }, []);
+
+ const displayLabel = orgId ?? "Demo Org";
+ const initials = orgId ? orgInitials(orgId) : "DO";
+
+ return (
+
+ {/* Avatar circle */}
+
+ {orgId ? (
+
+ {initials}
+
+ ) : (
+
+ )}
+
+
+ {/* Org name — hidden on narrow viewports via maxWidth trick */}
+
+ {displayLabel}
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/entry/EntryMap.tsx b/frontend/src/components/site-finder/entry/EntryMap.tsx
new file mode 100644
index 00000000..4160d1c8
--- /dev/null
+++ b/frontend/src/components/site-finder/entry/EntryMap.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ MapContainer,
+ TileLayer,
+ CircleMarker,
+ Tooltip,
+ useMapEvents,
+} from "react-leaflet";
+import type { LeafletMouseEvent, Map as LeafletMap } from "leaflet";
+import "leaflet/dist/leaflet.css";
+
+import type {
+ ParcelBboxItem,
+ BboxCoords,
+ ParcelBboxFilters,
+} from "@/lib/site-finder-api";
+import { useParcelsBboxQuery } from "@/lib/site-finder-api";
+import { STATUS_COLORS } from "./ParcelLegend";
+
+// ── Bbox change listener ───────────────────────────────────────────────────────
+
+interface BboxListenerProps {
+ onBboxChange: (bbox: BboxCoords) => void;
+}
+
+function BboxListener({ onBboxChange }: BboxListenerProps) {
+ const map = useMapEvents({
+ moveend() {
+ const bounds = map.getBounds();
+ onBboxChange({
+ minLat: bounds.getSouth(),
+ minLon: bounds.getWest(),
+ maxLat: bounds.getNorth(),
+ maxLon: bounds.getEast(),
+ });
+ },
+ zoomend() {
+ const bounds = map.getBounds();
+ onBboxChange({
+ minLat: bounds.getSouth(),
+ minLon: bounds.getWest(),
+ maxLat: bounds.getNorth(),
+ maxLon: bounds.getEast(),
+ });
+ },
+ });
+
+ // Emit initial bbox on mount
+ useEffect(() => {
+ const bounds = map.getBounds();
+ onBboxChange({
+ minLat: bounds.getSouth(),
+ minLon: bounds.getWest(),
+ maxLat: bounds.getNorth(),
+ maxLon: bounds.getEast(),
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return null;
+}
+
+// ── Parcel markers ─────────────────────────────────────────────────────────────
+
+interface ParcelMarkersProps {
+ parcels: ParcelBboxItem[];
+ selectedCad: string | null;
+ onSelect: (parcel: ParcelBboxItem) => void;
+}
+
+function ParcelMarkers({ parcels, selectedCad, onSelect }: ParcelMarkersProps) {
+ return (
+ <>
+ {parcels.map((parcel) => {
+ const color = STATUS_COLORS[parcel.status] ?? "#73767E";
+ const isSelected = parcel.cad_num === selectedCad;
+ return (
+ {
+ e.originalEvent.stopPropagation();
+ onSelect(parcel);
+ },
+ }}
+ >
+
+
+ {parcel.cad_num}
+
+ {parcel.area_ha.toFixed(2)} га · {parcel.district}
+
+
+
+
+ );
+ })}
+ >
+ );
+}
+
+// ── EntryMap ───────────────────────────────────────────────────────────────────
+
+// Yekaterinburg center
+const EKB_CENTER: [number, number] = [56.8389, 60.6057];
+const DEFAULT_ZOOM = 12;
+
+interface EntryMapProps {
+ filters: ParcelBboxFilters;
+ selectedCad: string | null;
+ onParcelSelect: (parcel: ParcelBboxItem) => void;
+ onParcelDeselect: () => void;
+ /** Called whenever the map viewport changes — allows parent to read bbox for filter counters */
+ onBboxChange?: (bbox: BboxCoords) => void;
+}
+
+export function EntryMap({
+ filters,
+ selectedCad,
+ onParcelSelect,
+ onParcelDeselect,
+ onBboxChange,
+}: EntryMapProps) {
+ const [bbox, setBbox] = useState(null);
+ const mapRef = useRef(null);
+
+ const { data: parcels, isFetching } = useParcelsBboxQuery(bbox, filters);
+
+ const handleBboxChange = useCallback(
+ (newBbox: BboxCoords) => {
+ setBbox(newBbox);
+ onBboxChange?.(newBbox);
+ },
+ [onBboxChange],
+ );
+
+ return (
+
+
+
+
+ {parcels && parcels.length > 0 && (
+
+ )}
+
+
+ {/* Deselect on map click — overlay to capture clicks outside markers */}
+ {selectedCad && (
+
+ )}
+
+ {/* Fetching indicator */}
+ {isFetching && (
+
+ Загрузка участков...
+
+ )}
+
+ {/* Empty state */}
+ {!isFetching && parcels && parcels.length === 0 && (
+
+ Нет участков в текущем виде. Измените фильтры или область карты.
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/entry/MapFilterBar.tsx b/frontend/src/components/site-finder/entry/MapFilterBar.tsx
new file mode 100644
index 00000000..10c3a559
--- /dev/null
+++ b/frontend/src/components/site-finder/entry/MapFilterBar.tsx
@@ -0,0 +1,257 @@
+"use client";
+
+import type { ChangeEvent } from "react";
+import type {
+ ParcelBboxFilters,
+ ParcelStatus,
+ ParcelVri,
+} from "@/lib/site-finder-api";
+
+interface MapFilterBarProps {
+ filters: ParcelBboxFilters;
+ onChange: (filters: ParcelBboxFilters) => void;
+ totalCount: number;
+ matchCount: number;
+}
+
+interface ChipProps {
+ label: string;
+ selected: boolean;
+ onClick: () => void;
+}
+
+function Chip({ label, selected, onClick }: ChipProps) {
+ return (
+
+ );
+}
+
+const DISTRICTS = [
+ "Ленинский",
+ "Верх-Исетский",
+ "Орджоникидзевский",
+ "Чкаловский",
+ "Кировский",
+ "Октябрьский",
+];
+
+const VRI_OPTIONS: Array<{ value: ParcelVri; label: string }> = [
+ { value: "multistory", label: "МКД" },
+ { value: "mixed", label: "Смешанный" },
+ { value: "individual", label: "ИЖС" },
+ { value: "office", label: "Офисный" },
+];
+
+const AREA_OPTIONS: Array<{
+ label: string;
+ min?: number;
+ max?: number;
+}> = [
+ { label: "до 0.5 га", max: 0.5 },
+ { label: "0.5–1 га", min: 0.5, max: 1 },
+ { label: "1–3 га", min: 1, max: 3 },
+ { label: "от 3 га", min: 3 },
+];
+
+export function MapFilterBar({
+ filters,
+ onChange,
+ totalCount,
+ matchCount,
+}: MapFilterBarProps) {
+ function toggleStatus(s: ParcelStatus) {
+ onChange({ ...filters, status: filters.status === s ? undefined : s });
+ }
+
+ function toggleDistrict(d: string) {
+ onChange({
+ ...filters,
+ district: filters.district === d ? undefined : d,
+ });
+ }
+
+ function toggleVri(v: ParcelVri) {
+ onChange({ ...filters, vri: filters.vri === v ? undefined : v });
+ }
+
+ function toggleArea(min?: number, max?: number) {
+ const same = filters.min_area === min && filters.max_area === max;
+ onChange({
+ ...filters,
+ min_area: same ? undefined : min,
+ max_area: same ? undefined : max,
+ });
+ }
+
+ function clearAll() {
+ onChange({});
+ }
+
+ const hasFilters =
+ filters.status != null ||
+ filters.district != null ||
+ filters.vri != null ||
+ filters.min_area != null ||
+ filters.max_area != null;
+
+ return (
+
+ {/* Counter */}
+
+ {matchCount.toLocaleString("ru")} / {totalCount.toLocaleString("ru")}{" "}
+ участков
+
+
+ {/* Status chips */}
+ toggleStatus("free")}
+ />
+ toggleStatus("in_progress")}
+ />
+ toggleStatus("favorite")}
+ />
+
+ {/* Separator */}
+
+
+ {/* Area chips */}
+ {AREA_OPTIONS.map((opt) => (
+ toggleArea(opt.min, opt.max)}
+ />
+ ))}
+
+ {/* Separator */}
+
+
+ {/* VRI chips */}
+ {VRI_OPTIONS.map((opt) => (
+ toggleVri(opt.value)}
+ />
+ ))}
+
+ {/* Separator */}
+
+
+ {/* District selector */}
+
+
+ {/* Clear all */}
+ {hasFilters && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/entry/ParcelDrawer.tsx b/frontend/src/components/site-finder/entry/ParcelDrawer.tsx
new file mode 100644
index 00000000..457c7a56
--- /dev/null
+++ b/frontend/src/components/site-finder/entry/ParcelDrawer.tsx
@@ -0,0 +1,364 @@
+"use client";
+
+import Link from "next/link";
+import { X, MapPin, Maximize2, ArrowRight } from "lucide-react";
+import type { ParcelBboxItem } from "@/lib/site-finder-api";
+import { STATUS_COLORS, STATUS_LABELS } from "./ParcelLegend";
+
+interface ParcelDrawerProps {
+ parcel: ParcelBboxItem | null;
+ onClose: () => void;
+}
+
+const VRI_LABELS: Record = {
+ multistory: "Многоэтажный",
+ mixed: "Смешанный",
+ individual: "ИЖС",
+ office: "Офисный",
+ other: "Иное",
+};
+
+export function ParcelDrawer({ parcel, onClose }: ParcelDrawerProps) {
+ if (!parcel) return null;
+
+ const statusColor = STATUS_COLORS[parcel.status] ?? "#73767E";
+ const statusLabel = STATUS_LABELS[parcel.status] ?? parcel.status;
+
+ return (
+ // Drawer panel — absolute within the map container (parent has position:relative).
+ // This keeps the sidebar (CadInput/RecentParcels/ParcelLegend) always visible
+ // and only overlaps the map area on parcel select.
+
+ );
+}
diff --git a/frontend/src/components/site-finder/entry/ParcelLegend.tsx b/frontend/src/components/site-finder/entry/ParcelLegend.tsx
new file mode 100644
index 00000000..052df8fd
--- /dev/null
+++ b/frontend/src/components/site-finder/entry/ParcelLegend.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+// Status color constants — kept in sync with EntryMap marker colours
+export const STATUS_COLORS: Record = {
+ free: "#0A7A3A",
+ in_progress: "#9A6700",
+ favorite: "#1D4ED8",
+};
+
+export const STATUS_LABELS: Record = {
+ free: "Свободный",
+ in_progress: "В работе",
+ favorite: "Избранный",
+};
+
+export function ParcelLegend() {
+ const entries = Object.keys(STATUS_LABELS) as Array<
+ keyof typeof STATUS_LABELS
+ >;
+
+ return (
+
+
+ Статус участка
+
+ {entries.map((status) => (
+
+
+
+ {STATUS_LABELS[status]}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/entry/RecentParcels.tsx b/frontend/src/components/site-finder/entry/RecentParcels.tsx
new file mode 100644
index 00000000..8876045f
--- /dev/null
+++ b/frontend/src/components/site-finder/entry/RecentParcels.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+import Link from "next/link";
+import { Clock, MapPin } from "lucide-react";
+import { useRecentParcels } from "@/lib/site-finder-api";
+
+const MAX_SHOWN = 5;
+
+export function RecentParcels() {
+ const { data: parcels, isLoading } = useRecentParcels();
+
+ if (isLoading) {
+ return (
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+ }
+
+ const shown = (parcels ?? []).slice(0, MAX_SHOWN);
+
+ if (shown.length === 0) {
+ return (
+
+
+
+
+ Недавние участки
+
+
+
+ История просмотров пуста
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Недавние участки
+
+
+
+ {/* List */}
+
+ {shown.map((parcel, idx) => (
+ {
+ (e.currentTarget as HTMLElement).style.background =
+ "var(--bg-card-alt)";
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.background = "transparent";
+ }}
+ >
+
+
+
+ {parcel.cad_num}
+
+
+ {parcel.district}
+ {parcel.area_ha ? ` · ${parcel.area_ha.toFixed(2)} га` : ""}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/trade-in/AnalogsTable.tsx b/frontend/src/components/trade-in/AnalogsTable.tsx
new file mode 100644
index 00000000..b6ebc00e
--- /dev/null
+++ b/frontend/src/components/trade-in/AnalogsTable.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+import { useState } from "react";
+
+import type { AnalogLot } from "@/types/trade-in";
+
+/** XSS prevention: only allow http/https/relative src */
+function safeImgSrc(url: string | null): string | null {
+ if (!url) return null;
+ if (
+ url.startsWith("https://") ||
+ url.startsWith("http://") ||
+ url.startsWith("/")
+ ) {
+ return url;
+ }
+ return null;
+}
+
+function fmtRub(value: number): string {
+ return value.toLocaleString("ru-RU", {
+ style: "currency",
+ currency: "RUB",
+ maximumFractionDigits: 0,
+ });
+}
+
+function fmtDate(iso: string | null): string {
+ if (!iso) return "—";
+ return new Date(iso).toLocaleDateString("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "2-digit",
+ });
+}
+
+type SortKey = "price_rub" | "price_per_m2" | "days_on_market";
+
+interface Props {
+ rows: AnalogLot[];
+ emptyLabel?: string;
+}
+
+export function AnalogsTable({
+ rows,
+ emptyLabel = "Нет аналогов в радиусе 1 км",
+}: Props) {
+ const [sortKey, setSortKey] = useState("price_rub");
+ const [sortAsc, setSortAsc] = useState(true);
+
+ if (rows.length === 0) {
+ return (
+
+ {emptyLabel}
+
+ );
+ }
+
+ const sorted = [...rows].sort((a, b) => {
+ const av = a[sortKey] ?? 0;
+ const bv = b[sortKey] ?? 0;
+ return sortAsc
+ ? (av as number) - (bv as number)
+ : (bv as number) - (av as number);
+ });
+
+ function handleSort(key: SortKey) {
+ if (sortKey === key) {
+ setSortAsc((prev) => !prev);
+ } else {
+ setSortKey(key);
+ setSortAsc(true);
+ }
+ }
+
+ function SortIcon({ col }: { col: SortKey }) {
+ if (sortKey !== col) return null;
+ return sortAsc ? (
+
+ ) : (
+
+ );
+ }
+
+ const thStyle: React.CSSProperties = {
+ padding: "7px 8px",
+ fontSize: 11,
+ color: "#5b6066",
+ textTransform: "uppercase",
+ letterSpacing: "0.04em",
+ fontWeight: 600,
+ borderBottom: "1px solid #e6e8ec",
+ textAlign: "left",
+ whiteSpace: "nowrap",
+ background: "#f9fafb",
+ };
+ const tdStyle: React.CSSProperties = {
+ padding: "8px 8px",
+ fontSize: 13,
+ color: "#1a1d23",
+ borderBottom: "1px solid #f3f4f6",
+ verticalAlign: "middle",
+ };
+ const numTd: React.CSSProperties = {
+ ...tdStyle,
+ fontVariantNumeric: "tabular-nums",
+ textAlign: "right",
+ };
+ const sortableTh = (col: SortKey): React.CSSProperties => ({
+ ...thStyle,
+ cursor: "pointer",
+ userSelect: "none",
+ color: sortKey === col ? "#1d4ed8" : "#5b6066",
+ });
+
+ return (
+
+
+
+
+ | Фото |
+ Адрес |
+ м² |
+ Комн. |
+ Этаж |
+ handleSort("price_rub")}
+ >
+ Цена ₽
+ |
+ handleSort("price_per_m2")}
+ >
+ ₽/м²
+ |
+ handleSort("days_on_market")}
+ >
+ Дней
+ |
+
+
+
+ {sorted.map((row, i) => {
+ const imgSrc = safeImgSrc(row.photo_url);
+ return (
+
+
+ {imgSrc ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+ |
+
+
+ {row.address}
+
+ {row.listing_date && (
+
+ {fmtDate(row.listing_date)}
+
+ )}
+ |
+ {row.area_m2.toFixed(1)} |
+
+ {row.rooms === 0 ? "ст." : row.rooms}
+ |
+
+ {row.floor !== null && row.total_floors !== null
+ ? `${row.floor}/${row.total_floors}`
+ : (row.floor ?? "—")}
+ |
+ {fmtRub(row.price_rub)} |
+ {fmtRub(row.price_per_m2)} |
+
+ {row.days_on_market !== null ? row.days_on_market : "—"}
+ |
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/components/trade-in/EstimateForm.tsx b/frontend/src/components/trade-in/EstimateForm.tsx
new file mode 100644
index 00000000..a1c5711b
--- /dev/null
+++ b/frontend/src/components/trade-in/EstimateForm.tsx
@@ -0,0 +1,393 @@
+"use client";
+
+import { useState } from "react";
+
+import type {
+ HouseType,
+ RepairState,
+ TradeInEstimateInput,
+} from "@/types/trade-in";
+
+const HOUSE_TYPE_LABELS: Record = {
+ panel: "Панель",
+ brick: "Кирпич",
+ monolith: "Монолит",
+ monolith_brick: "Монолит-кирпич",
+ other: "Другое",
+};
+
+const REPAIR_STATE_LABELS: Record = {
+ needs_repair: "Требует ремонта",
+ standard: "Стандартный",
+ good: "Хороший",
+ excellent: "Евроремонт",
+};
+
+interface Props {
+ onSubmit: (input: TradeInEstimateInput) => void;
+ isPending: boolean;
+ error: string | null;
+}
+
+interface FormState {
+ address: string;
+ area_m2: string;
+ rooms: string;
+ floor: string;
+ total_floors: string;
+ year_built: string;
+ house_type: HouseType | "";
+ repair_state: RepairState | "";
+ has_balcony: boolean;
+}
+
+const INITIAL: FormState = {
+ address: "",
+ area_m2: "",
+ rooms: "",
+ floor: "",
+ total_floors: "",
+ year_built: "",
+ house_type: "",
+ repair_state: "",
+ has_balcony: false,
+};
+
+function validate(f: FormState): string | null {
+ if (f.address.trim().length < 3) return "Введите адрес (минимум 3 символа)";
+ const area = Number(f.area_m2);
+ if (!f.area_m2 || isNaN(area) || area <= 10 || area >= 500)
+ return "Площадь: от 10 до 500 м²";
+ const rooms = Number(f.rooms);
+ if (f.rooms === "" || isNaN(rooms) || rooms < 0 || rooms > 10)
+ return "Количество комнат: 0 (студия) — 10";
+ const floor = Number(f.floor);
+ if (!f.floor || isNaN(floor) || floor < 1 || floor > 100)
+ return "Этаж: 1—100";
+ const totalFloors = Number(f.total_floors);
+ if (
+ !f.total_floors ||
+ isNaN(totalFloors) ||
+ totalFloors < 1 ||
+ totalFloors > 100
+ )
+ return "Этажей в доме: 1—100";
+ if (floor > totalFloors) return "Этаж не может быть больше этажей в доме";
+ if (f.year_built !== "") {
+ const yr = Number(f.year_built);
+ if (isNaN(yr) || yr < 1800 || yr > 2100) return "Год постройки: 1800—2100";
+ }
+ return null;
+}
+
+function toInput(f: FormState): TradeInEstimateInput {
+ const input: TradeInEstimateInput = {
+ address: f.address.trim(),
+ area_m2: Number(f.area_m2),
+ rooms: Number(f.rooms),
+ floor: Number(f.floor),
+ total_floors: Number(f.total_floors),
+ has_balcony: f.has_balcony,
+ };
+ if (f.year_built !== "") input.year_built = Number(f.year_built);
+ if (f.house_type !== "") input.house_type = f.house_type;
+ if (f.repair_state !== "") input.repair_state = f.repair_state;
+ return input;
+}
+
+export function EstimateForm({ onSubmit, isPending, error }: Props) {
+ const [form, setForm] = useState(INITIAL);
+ const [touched, setTouched] = useState(false);
+
+ const validationError = validate(form);
+ const canSubmit = !validationError && !isPending;
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setTouched(true);
+ if (!canSubmit) return;
+ onSubmit(toInput(form));
+ }
+
+ function field(key: K) {
+ return (e: React.ChangeEvent) => {
+ setForm((prev) => ({ ...prev, [key]: e.target.value }));
+ };
+ }
+
+ return (
+
+ );
+}
+
+function Required() {
+ return *;
+}
+
+const labelStyle: React.CSSProperties = {
+ display: "block",
+ fontSize: 12,
+ color: "#5b6066",
+ marginBottom: 4,
+ textTransform: "uppercase",
+ letterSpacing: 0.4,
+ fontWeight: 600,
+};
+
+const inputStyle: React.CSSProperties = {
+ padding: "8px 10px",
+ border: "1px solid #d1d5db",
+ borderRadius: 6,
+ fontSize: 14,
+ width: "100%",
+ boxSizing: "border-box",
+ color: "#1a1d23",
+ background: "#fff",
+};
+
+const errorBoxStyle: React.CSSProperties = {
+ padding: "10px 12px",
+ background: "#fef2f2",
+ border: "1px solid #fecaca",
+ borderRadius: 6,
+ color: "#991b1b",
+ fontSize: 13,
+};
+
+const primaryBtn = (enabled: boolean): React.CSSProperties => ({
+ padding: "11px 18px",
+ background: enabled ? "#1d4ed8" : "#9ca3af",
+ color: "#fff",
+ border: "none",
+ borderRadius: 8,
+ fontSize: 15,
+ fontWeight: 600,
+ cursor: enabled ? "pointer" : "not-allowed",
+ marginTop: 4,
+ transition: "background 0.15s",
+});
diff --git a/frontend/src/components/trade-in/EstimateProgress.tsx b/frontend/src/components/trade-in/EstimateProgress.tsx
new file mode 100644
index 00000000..f4a8434d
--- /dev/null
+++ b/frontend/src/components/trade-in/EstimateProgress.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+interface Props {
+ visible: boolean;
+}
+
+export function EstimateProgress({ visible }: Props) {
+ if (!visible) return null;
+
+ return (
+
+ {/* Spinning circle SVG */}
+
+ Считаем оценку…
+
+
+ );
+}
diff --git a/frontend/src/components/trade-in/EstimateResult.tsx b/frontend/src/components/trade-in/EstimateResult.tsx
new file mode 100644
index 00000000..35072145
--- /dev/null
+++ b/frontend/src/components/trade-in/EstimateResult.tsx
@@ -0,0 +1,466 @@
+"use client";
+
+import type { AggregatedEstimate, ConfidenceLevel } from "@/types/trade-in";
+import type { TradeInEstimateInput } from "@/types/trade-in";
+import { PriceRangeBar } from "./PriceRangeBar";
+import { AnalogsTable } from "./AnalogsTable";
+
+// ── helpers ────────────────────────────────────────────────────────────────────
+
+function fmtRub(value: number): string {
+ return value.toLocaleString("ru-RU", {
+ style: "currency",
+ currency: "RUB",
+ maximumFractionDigits: 0,
+ });
+}
+
+function fmtRubM(value: number): string {
+ const m = value / 1_000_000;
+ return `${m.toFixed(2).replace(".", ",")} млн ₽`;
+}
+
+const CONFIDENCE_COLOR: Record<
+ ConfidenceLevel,
+ { bg: string; fg: string; border: string; label: string }
+> = {
+ high: { bg: "#dcfce7", fg: "#15803d", border: "#86efac", label: "Высокая" },
+ medium: { bg: "#fef9c3", fg: "#a16207", border: "#fde68a", label: "Средняя" },
+ low: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5", label: "Низкая" },
+};
+
+const HOUSE_TYPE_LABELS: Record = {
+ panel: "Панель",
+ brick: "Кирпич",
+ monolith: "Монолит",
+ monolith_brick: "Монолит-кирпич",
+ other: "Другое",
+};
+
+const REPAIR_STATE_LABELS: Record = {
+ needs_repair: "Требует ремонта",
+ standard: "Стандартный",
+ good: "Хороший",
+ excellent: "Евроремонт",
+};
+
+// ── sub-components ────────────────────────────────────────────────────────────
+
+function SectionHeader({
+ icon,
+ title,
+}: {
+ icon: React.ReactNode;
+ title: string;
+}) {
+ return (
+
+ {icon}
+
+ {title}
+
+
+ );
+}
+
+function Card({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// ── main component ────────────────────────────────────────────────────────────
+
+interface Props {
+ estimate: AggregatedEstimate;
+ input: TradeInEstimateInput;
+}
+
+export function EstimateResult({ estimate, input }: Props) {
+ const conf = CONFIDENCE_COLOR[estimate.confidence];
+
+ return (
+
+ {/* Hero card */}
+
+
+
+
+ Оценочная стоимость
+
+
+ {fmtRubM(estimate.median_price_rub)}
+
+
+ {fmtRub(estimate.median_price_per_m2)} / м²
+
+
+
+ {/* Confidence badge */}
+
+
+ Достоверность
+
+
+ {conf.label}
+
+
+ {estimate.n_analogs} аналог{ending(estimate.n_analogs)}
+
+
+
+
+ {/* Price range bar */}
+
+
+
+ Диапазон: {fmtRubM(estimate.range_low_rub)} —{" "}
+ {fmtRubM(estimate.range_high_rub)} · данные за{" "}
+ {estimate.period_months} мес.
+
+
+
+ {/* Section 1 — Cover / input snapshot */}
+
+
+
+
+
+ }
+ title="Параметры объекта"
+ />
+
+ {[
+ { label: "Адрес", value: input.address },
+ { label: "Площадь", value: `${input.area_m2} м²` },
+ {
+ label: "Комнат",
+ value: input.rooms === 0 ? "Студия" : String(input.rooms),
+ },
+ {
+ label: "Этаж",
+ value: `${input.floor} из ${input.total_floors}`,
+ },
+ ...(input.year_built
+ ? [{ label: "Год постройки", value: String(input.year_built) }]
+ : []),
+ ...(input.house_type
+ ? [
+ {
+ label: "Тип дома",
+ value:
+ HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type,
+ },
+ ]
+ : []),
+ ...(input.repair_state
+ ? [
+ {
+ label: "Ремонт",
+ value:
+ REPAIR_STATE_LABELS[input.repair_state] ??
+ input.repair_state,
+ },
+ ]
+ : []),
+ {
+ label: "Балкон",
+ value: input.has_balcony ? "Есть" : "Нет",
+ },
+ ].map(({ label, value }) => (
+
+
+ {label}
+
+
+ {value}
+
+
+ ))}
+
+
+
+ {/* Section 2 — Listings (analogs) */}
+
+
+
+
+
+
+
+
+
+ }
+ title={`Объявления-аналоги (${estimate.analogs.length})`}
+ />
+
+
+
+ {/* Section 3 — Actual deals */}
+
+
+
+
+ }
+ title={`Фактические сделки (${estimate.actual_deals.length})`}
+ />
+
+
+
+ {/* Section 4 — Trade-in cost placeholder */}
+
+
+
+
+
+
+
+ }
+ title="Выкупная стоимость (trade-in)"
+ />
+
+
+
+
+ Подробный расчёт — в PDF-отчёте.
+
+
+ Выкупная цена, таблица self-sale vs trade-in, 4 преимущества — будут
+ в TI-2.
+
+
+
+
+ {/* PDF download button */}
+
+
+ );
+}
+
+function ending(n: number): string {
+ const mod10 = n % 10;
+ const mod100 = n % 100;
+ if (mod10 === 1 && mod100 !== 11) return "";
+ if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) return "а";
+ return "ов";
+}
diff --git a/frontend/src/components/trade-in/PriceRangeBar.tsx b/frontend/src/components/trade-in/PriceRangeBar.tsx
new file mode 100644
index 00000000..9381c1b1
--- /dev/null
+++ b/frontend/src/components/trade-in/PriceRangeBar.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+interface Props {
+ rangeLow: number;
+ rangeHigh: number;
+ median: number;
+}
+
+function fmtRub(value: number): string {
+ return value.toLocaleString("ru-RU", {
+ style: "currency",
+ currency: "RUB",
+ maximumFractionDigits: 0,
+ });
+}
+
+export function PriceRangeBar({ rangeLow, rangeHigh, median }: Props) {
+ const span = rangeHigh - rangeLow;
+ // Guard against degenerate case
+ const medianPct =
+ span > 0
+ ? Math.max(0, Math.min(100, ((median - rangeLow) / span) * 100))
+ : 50;
+
+ return (
+
+ {/* Bar */}
+
+ {/* Median marker */}
+
+
+
+ {/* Labels */}
+
+ {fmtRub(rangeLow)}
+
+ {fmtRub(median)}
+
+ {fmtRub(rangeHigh)}
+
+
+ );
+}
diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx
new file mode 100644
index 00000000..69580e6f
--- /dev/null
+++ b/frontend/src/components/ui/Badge.tsx
@@ -0,0 +1,92 @@
+import type { ReactNode } from "react";
+
+export type BadgeVariant =
+ | "success"
+ | "warning"
+ | "danger"
+ | "info"
+ | "neutral";
+export type BadgeSize = "sm" | "md";
+
+export interface BadgeProps {
+ variant: BadgeVariant;
+ size?: BadgeSize;
+ icon?: ReactNode;
+ children: ReactNode;
+}
+
+const VARIANT_STYLES: Record<
+ BadgeVariant,
+ { background: string; color: string; border: string }
+> = {
+ success: {
+ background: "var(--success-soft)",
+ color: "var(--success)",
+ border: "transparent",
+ },
+ warning: {
+ background: "var(--warn-soft)",
+ color: "var(--warn)",
+ border: "transparent",
+ },
+ danger: {
+ background: "var(--danger-soft)",
+ color: "var(--danger)",
+ border: "transparent",
+ },
+ info: {
+ background: "var(--accent-soft)",
+ color: "var(--accent)",
+ border: "transparent",
+ },
+ neutral: {
+ background: "var(--bg-card-alt)",
+ color: "var(--fg-secondary)",
+ border: "var(--border-card)",
+ },
+};
+
+const SIZE_STYLES: Record<
+ BadgeSize,
+ { fontSize: number; padding: string; gap: number }
+> = {
+ sm: { fontSize: 11, padding: "2px 6px", gap: 3 },
+ md: { fontSize: 12, padding: "3px 8px", gap: 4 },
+};
+
+/**
+ * Badge — inline-flex semantic badge with variants aligned to design tokens.
+ * Variants: success | warning | danger | info | neutral.
+ * Icon slot accepts any ReactNode (use Lucide icons, size 12-14px).
+ */
+export function Badge({ variant, size = "md", icon, children }: BadgeProps) {
+ const vs = VARIANT_STYLES[variant];
+ const ss = SIZE_STYLES[size];
+
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/ui/Drawer.tsx b/frontend/src/components/ui/Drawer.tsx
new file mode 100644
index 00000000..9b6817fc
--- /dev/null
+++ b/frontend/src/components/ui/Drawer.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import {
+ useEffect,
+ useRef,
+ type CSSProperties,
+ type KeyboardEvent,
+ type MouseEvent,
+ type ReactNode,
+} from "react";
+
+export interface DrawerProps {
+ open: boolean;
+ onClose: () => void;
+ side: "right" | "bottom";
+ /** CSS width string — default "360px" for right, "100%" for bottom */
+ width?: string;
+ children: ReactNode;
+}
+
+const FOCUSABLE_SELECTORS =
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
+
+/**
+ * Drawer — accessible slide-in overlay panel.
+ * - side="right" → right side panel (default 360px width)
+ * - side="bottom" → bottom-sheet (full width, mobile-first)
+ * Click outside / Escape → onClose.
+ * Focus trap: Tab cycles within open drawer.
+ */
+export function Drawer({ open, onClose, side, width, children }: DrawerProps) {
+ const drawerRef = useRef(null);
+ const previousFocusRef = useRef(null);
+
+ // Save focus and restore on close
+ useEffect(() => {
+ if (open) {
+ previousFocusRef.current = document.activeElement;
+ // Move focus into drawer on next frame
+ const id = requestAnimationFrame(() => {
+ const el =
+ drawerRef.current?.querySelector(FOCUSABLE_SELECTORS);
+ el?.focus();
+ });
+ return () => cancelAnimationFrame(id);
+ } else {
+ if (previousFocusRef.current instanceof HTMLElement) {
+ previousFocusRef.current.focus();
+ }
+ }
+ }, [open]);
+
+ // Escape key
+ useEffect(() => {
+ if (!open) return;
+ const handleKeyDown = (e: globalThis.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [open, onClose]);
+
+ // Prevent body scroll while open
+ useEffect(() => {
+ if (open) {
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = prev;
+ };
+ }
+ }, [open]);
+
+ const handleOverlayClick = (e: MouseEvent) => {
+ if (e.target === e.currentTarget) {
+ onClose();
+ }
+ };
+
+ const handleDrawerKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== "Tab") return;
+ const drawer = drawerRef.current;
+ if (!drawer) return;
+ const focusable = Array.from(
+ drawer.querySelectorAll(FOCUSABLE_SELECTORS),
+ ).filter((el) => !el.closest("[aria-hidden]"));
+ if (focusable.length === 0) return;
+ const first = focusable[0] as HTMLElement;
+ const last = focusable[focusable.length - 1] as HTMLElement;
+ if (e.shiftKey) {
+ if (document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ }
+ } else {
+ if (document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ };
+
+ const defaultWidth = side === "right" ? "360px" : "100%";
+ const resolvedWidth = width ?? defaultWidth;
+
+ const panelStyle: CSSProperties =
+ side === "right"
+ ? {
+ position: "fixed",
+ top: 0,
+ right: 0,
+ bottom: 0,
+ width: resolvedWidth,
+ maxWidth: "100vw",
+ background: "var(--bg-card)",
+ boxShadow: "0 0 32px rgba(0,0,0,0.18)",
+ display: "flex",
+ flexDirection: "column",
+ transform: open ? "translateX(0)" : "translateX(100%)",
+ transition: "transform 180ms ease",
+ zIndex: 200,
+ overflowY: "auto",
+ }
+ : {
+ position: "fixed",
+ left: 0,
+ right: 0,
+ bottom: 0,
+ width: "100%",
+ maxHeight: "85vh",
+ background: "var(--bg-card)",
+ boxShadow: "0 -4px 32px rgba(0,0,0,0.14)",
+ borderRadius: "12px 12px 0 0",
+ display: "flex",
+ flexDirection: "column",
+ transform: open ? "translateY(0)" : "translateY(100%)",
+ transition: "transform 180ms ease",
+ zIndex: 200,
+ overflowY: "auto",
+ };
+
+ if (!open) {
+ // Keep in DOM for transition but aria-hide
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ui/HeadlineBar.tsx b/frontend/src/components/ui/HeadlineBar.tsx
new file mode 100644
index 00000000..9f2e235e
--- /dev/null
+++ b/frontend/src/components/ui/HeadlineBar.tsx
@@ -0,0 +1,62 @@
+import type { ReactNode } from "react";
+
+export interface HeadlineBarProps {
+ title: string;
+ subtitle?: string;
+ rightSlot?: ReactNode;
+}
+
+/**
+ * HeadlineBar — dark verdict/headline strip used across Site Finder and analytics.
+ * Background: var(--bg-headline) = #0F172A (slate-900).
+ * Text: var(--fg-on-dark) / var(--fg-on-dark-muted).
+ */
+export function HeadlineBar({ title, subtitle, rightSlot }: HeadlineBarProps) {
+ return (
+
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {rightSlot && (
+
+ {rightSlot}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/__tests__/Badge.test.tsx b/frontend/src/components/ui/__tests__/Badge.test.tsx
new file mode 100644
index 00000000..96582e6b
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/Badge.test.tsx
@@ -0,0 +1,52 @@
+/**
+ * Tests require: jest + @testing-library/react + @testing-library/jest-dom
+ * npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
+ */
+import { render, screen } from "@testing-library/react";
+import { Badge } from "../Badge";
+
+describe("Badge", () => {
+ it("renders children text", () => {
+ render(Свободен);
+ expect(screen.getByText("Свободен")).toBeInTheDocument();
+ });
+
+ it("applies success token background via inline style", () => {
+ const { container } = render(OK);
+ const span = container.querySelector("span");
+ expect(span?.style.background).toBe("var(--success-soft)");
+ expect(span?.style.color).toBe("var(--success)");
+ });
+
+ it("applies danger token for danger variant", () => {
+ const { container } = render(Риск);
+ const span = container.querySelector("span");
+ expect(span?.style.background).toBe("var(--danger-soft)");
+ expect(span?.style.color).toBe("var(--danger)");
+ });
+
+ it("renders icon slot when provided", () => {
+ render(
+ }>
+ Внимание
+ ,
+ );
+ expect(screen.getByTestId("icon")).toBeInTheDocument();
+ });
+
+ it("applies sm size with smaller font", () => {
+ const { container } = render(
+
+ Нейтрально
+ ,
+ );
+ const span = container.querySelector("span");
+ expect(span?.style.fontSize).toBe("11px");
+ });
+
+ it("applies md size by default", () => {
+ const { container } = render(Инфо);
+ const span = container.querySelector("span");
+ expect(span?.style.fontSize).toBe("12px");
+ });
+});
diff --git a/frontend/src/components/ui/__tests__/Drawer.test.tsx b/frontend/src/components/ui/__tests__/Drawer.test.tsx
new file mode 100644
index 00000000..2cd2a8c3
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/Drawer.test.tsx
@@ -0,0 +1,60 @@
+/**
+ * Tests require: jest + @testing-library/react + @testing-library/jest-dom
+ * npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
+ */
+import { render, screen, fireEvent } from "@testing-library/react";
+import { Drawer } from "../Drawer";
+
+describe("Drawer", () => {
+ it("renders children when open=true", () => {
+ render(
+ {}} side="right">
+ Контент панели
+ ,
+ );
+ expect(screen.getByText("Контент панели")).toBeInTheDocument();
+ });
+
+ it("does not show children interactively when open=false", () => {
+ render(
+ {}} side="right">
+ Скрытый контент
+ ,
+ );
+ // Panel is in DOM but aria-hidden overlay
+ const overlay = document.querySelector('[aria-hidden="true"]');
+ expect(overlay).toBeInTheDocument();
+ });
+
+ it("calls onClose when Escape is pressed", () => {
+ const onClose = jest.fn();
+ render(
+
+ Контент
+ ,
+ );
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders with role=dialog when open", () => {
+ render(
+ {}} side="bottom">
+ Bottom sheet
+ ,
+ );
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ it("applies translateY(0) for bottom side when open", () => {
+ const { container } = render(
+ {}} side="bottom">
+ Bottom
+ ,
+ );
+ // The panel div is second child of dialog div
+ const dialog = container.querySelector('[role="dialog"]');
+ const panel = dialog?.querySelector("div");
+ expect(panel?.style.transform).toBe("translateY(0)");
+ });
+});
diff --git a/frontend/src/components/ui/__tests__/HeadlineBar.test.tsx b/frontend/src/components/ui/__tests__/HeadlineBar.test.tsx
new file mode 100644
index 00000000..0151366c
--- /dev/null
+++ b/frontend/src/components/ui/__tests__/HeadlineBar.test.tsx
@@ -0,0 +1,37 @@
+/**
+ * Tests require: jest + @testing-library/react + @testing-library/jest-dom
+ * npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
+ */
+import { render, screen } from "@testing-library/react";
+import { HeadlineBar } from "../HeadlineBar";
+
+describe("HeadlineBar", () => {
+ it("renders title text", () => {
+ render();
+ expect(screen.getByText("ПОДХОДИТ · участок 0.82 га")).toBeInTheDocument();
+ });
+
+ it("renders subtitle when provided", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText("Кадастровая стоимость 14.2 млн ₽"),
+ ).toBeInTheDocument();
+ });
+
+ it("does not render subtitle section when omitted", () => {
+ const { container } = render();
+ // subtitle div has marginTop style — not present when subtitle is undefined
+ const subtitleDivs = container.querySelectorAll("div > div > div");
+ expect(subtitleDivs).toHaveLength(0);
+ });
+
+ it("renders rightSlot content", () => {
+ render(Export} />);
+ expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/hooks/useBestLayouts.ts b/frontend/src/hooks/useBestLayouts.ts
new file mode 100644
index 00000000..f8928ff5
--- /dev/null
+++ b/frontend/src/hooks/useBestLayouts.ts
@@ -0,0 +1,29 @@
+"use client";
+
+import { useMutation } from "@tanstack/react-query";
+import { apiFetch } from "@/lib/api";
+import type {
+ BestLayoutsRequest,
+ BestLayoutsResponse,
+} from "@/types/best-layouts";
+
+/**
+ * TanStack Query mutation for POST /api/v1/parcels/{cad_num}/best-layouts.
+ *
+ * Usage:
+ * const { mutate, data, isPending, error } = useBestLayouts(cadNum);
+ * mutate(requestBody);
+ */
+export function useBestLayouts(cadNum: string) {
+ return useMutation({
+ mutationKey: ["best-layouts", cadNum],
+ mutationFn: (body: BestLayoutsRequest): Promise =>
+ apiFetch(
+ `/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts`,
+ {
+ method: "POST",
+ body: JSON.stringify(body),
+ },
+ ),
+ });
+}
diff --git a/frontend/src/hooks/useConnectionPoints.ts b/frontend/src/hooks/useConnectionPoints.ts
new file mode 100644
index 00000000..9e02174b
--- /dev/null
+++ b/frontend/src/hooks/useConnectionPoints.ts
@@ -0,0 +1,23 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+import { apiFetch } from "@/lib/api";
+import type { ConnectionPointsResponse } from "@/types/nspd";
+
+export type { ConnectionPointsResponse };
+
+export function useConnectionPoints(
+ cadNum: string | null | undefined,
+ enabled = true,
+) {
+ return useQuery({
+ queryKey: ["connection-points", cadNum],
+ queryFn: () =>
+ apiFetch(
+ `/api/v1/parcels/${encodeURIComponent(cadNum!)}/connection-points`,
+ ),
+ enabled: !!cadNum && enabled,
+ staleTime: 5 * 60 * 1000,
+ });
+}
diff --git a/frontend/src/hooks/useCustomPois.ts b/frontend/src/hooks/useCustomPois.ts
new file mode 100644
index 00000000..ef3e0fcb
--- /dev/null
+++ b/frontend/src/hooks/useCustomPois.ts
@@ -0,0 +1,128 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { apiFetch } from "@/lib/api";
+import { getOrCreateSessionId } from "@/lib/sessionId";
+import type {
+ CustomPoi,
+ CustomPoiCreate,
+ CustomPoiUpdate,
+} from "@/types/customPoi";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function sessionHeaders(): Record {
+ const id = getOrCreateSessionId();
+ return id ? { "X-Session-Id": id } : {};
+}
+
+// ---------------------------------------------------------------------------
+// Query key factory
+// ---------------------------------------------------------------------------
+
+function customPoisKey(parcelCad?: string | null): unknown[] {
+ return ["custom-pois", parcelCad ?? null];
+}
+
+function analyzeKey(parcelCad: string): unknown[] {
+ return ["analyze", parcelCad];
+}
+
+// ---------------------------------------------------------------------------
+// Hooks
+// ---------------------------------------------------------------------------
+
+/**
+ * List custom POIs, optionally filtered by parcel cad number.
+ */
+export function useCustomPois(parcelCad?: string | null) {
+ return useQuery({
+ queryKey: customPoisKey(parcelCad),
+ queryFn: () => {
+ const qs = parcelCad
+ ? `?parcel_cad=${encodeURIComponent(parcelCad)}`
+ : "";
+ return apiFetch(`/api/v1/custom-pois${qs}`, {
+ headers: sessionHeaders(),
+ });
+ },
+ // Don't auto-fetch until session available (SSR guard).
+ enabled: typeof window !== "undefined",
+ staleTime: 30_000,
+ });
+}
+
+/**
+ * Create a custom POI. Invalidates list + analyze query on success.
+ */
+export function useAddCustomPoi(parcelCad?: string | null) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (payload: CustomPoiCreate) =>
+ apiFetch("/api/v1/custom-pois", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ headers: { ...sessionHeaders() },
+ }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: customPoisKey(parcelCad),
+ });
+ // Invalidate global list too
+ void queryClient.invalidateQueries({ queryKey: customPoisKey(null) });
+ if (parcelCad) {
+ void queryClient.invalidateQueries({ queryKey: analyzeKey(parcelCad) });
+ }
+ },
+ });
+}
+
+/**
+ * Update a custom POI by id.
+ */
+export function useUpdateCustomPoi(parcelCad?: string | null) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, data }: { id: number; data: CustomPoiUpdate }) =>
+ apiFetch(`/api/v1/custom-pois/${id}`, {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ headers: { ...sessionHeaders() },
+ }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: customPoisKey(parcelCad),
+ });
+ void queryClient.invalidateQueries({ queryKey: customPoisKey(null) });
+ if (parcelCad) {
+ void queryClient.invalidateQueries({ queryKey: analyzeKey(parcelCad) });
+ }
+ },
+ });
+}
+
+/**
+ * Delete a custom POI by id.
+ */
+export function useDeleteCustomPoi(parcelCad?: string | null) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (id: number) =>
+ apiFetch(`/api/v1/custom-pois/${id}`, {
+ method: "DELETE",
+ headers: sessionHeaders(),
+ }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: customPoisKey(parcelCad),
+ });
+ void queryClient.invalidateQueries({ queryKey: customPoisKey(null) });
+ if (parcelCad) {
+ void queryClient.invalidateQueries({ queryKey: analyzeKey(parcelCad) });
+ }
+ },
+ });
+}
diff --git a/frontend/src/hooks/useDebouncedValue.ts b/frontend/src/hooks/useDebouncedValue.ts
new file mode 100644
index 00000000..4e737639
--- /dev/null
+++ b/frontend/src/hooks/useDebouncedValue.ts
@@ -0,0 +1,19 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+/**
+ * Returns a debounced copy of `value` that only updates after `delayMs`
+ * milliseconds of silence. Useful to collapse rapid state changes before
+ * triggering expensive side-effects (e.g. API calls).
+ */
+export function useDebouncedValue(value: T, delayMs: number = 300): T {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const handle = setTimeout(() => setDebounced(value), delayMs);
+ return () => clearTimeout(handle);
+ }, [value, delayMs]);
+
+ return debounced;
+}
diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts
index 7a2c064c..7abd1a64 100644
--- a/frontend/src/hooks/useSiteAnalysis.ts
+++ b/frontend/src/hooks/useSiteAnalysis.ts
@@ -44,6 +44,11 @@ export interface AnalyzeOptions {
profileId?: number;
/** If set together with no profileId, backend uses user's default profile. */
profileUserId?: string;
+ /**
+ * Inline POI weights override — sent as request body.
+ * Priority: inline → profileId → profileUserId default → system.
+ */
+ weights?: Record | null;
}
/**
@@ -90,11 +95,23 @@ export function useSiteAnalysis() {
return qsStr ? `${base}?${qsStr}` : base;
};
+ // Build optional JSON body for inline weights (#201).
+ const bodyPayload =
+ options?.weights != null
+ ? JSON.stringify({ weights: options.weights })
+ : undefined;
+
// First request — POST /analyze
const first = await apiFetchWithStatus<
ParcelAnalysis | AnalyzeAcceptedResponse
>(analyzeUrl(cad), {
method: "POST",
+ ...(bodyPayload
+ ? {
+ body: bodyPayload,
+ headers: { "Content-Type": "application/json" },
+ }
+ : {}),
});
if (first.status === 200) {
@@ -127,6 +144,12 @@ export function useSiteAnalysis() {
// mutation сразу резолвится с data — render skipped.
const second = await apiFetch(analyzeUrl(cad), {
method: "POST",
+ ...(bodyPayload
+ ? {
+ body: bodyPayload,
+ headers: { "Content-Type": "application/json" },
+ }
+ : {}),
});
setFetchingState(null);
return second;
diff --git a/frontend/src/instrumentation.ts b/frontend/src/instrumentation.ts
new file mode 100644
index 00000000..9f63313f
--- /dev/null
+++ b/frontend/src/instrumentation.ts
@@ -0,0 +1,11 @@
+export async function register() {
+ if (process.env.NEXT_RUNTIME === "nodejs") {
+ await import("../sentry.server.config");
+ }
+ if (process.env.NEXT_RUNTIME === "edge") {
+ await import("../sentry.edge.config");
+ }
+}
+
+export const onRequestError = (await import("@sentry/nextjs"))
+ .captureRequestError;
diff --git a/frontend/src/lib/__tests__/isPathAllowed.test.ts b/frontend/src/lib/__tests__/isPathAllowed.test.ts
new file mode 100644
index 00000000..344b0b16
--- /dev/null
+++ b/frontend/src/lib/__tests__/isPathAllowed.test.ts
@@ -0,0 +1,84 @@
+/**
+ * Parity-tests с backend `tests/test_rbac.py::test_is_path_allowed_*`.
+ *
+ * Если кто-то меняет глоб-семантику backend'а — должен синхронно поменять JS
+ * порт здесь, иначе UI начнёт врать про доступы (показывать ссылки, которые
+ * backend заблокирует, или наоборот скрывать разрешённые).
+ *
+ * Pairs мирорят `auth/roles.yaml` (обновлено 2026-05-26 — pilot scope сужен
+ * до /trade-in/** only):
+ * admin: paths=["/**"] deny=[]
+ * pilot: paths=["/trade-in/**", "/trade-in/api/v1/**"]
+ * deny=["/admin/**", "/api/v1/admin/**", "/trade-in/api/v1/admin/**"]
+ */
+import { isPathAllowed } from "../isPathAllowed";
+
+const ADMIN_PATHS = ["/**"] as const;
+const ADMIN_DENY = [] as const;
+
+const PILOT_PATHS = ["/trade-in/**", "/trade-in/api/v1/**"] as const;
+const PILOT_DENY = [
+ "/admin/**",
+ "/api/v1/admin/**",
+ "/trade-in/api/v1/admin/**",
+] as const;
+
+describe("isPathAllowed — admin everywhere", () => {
+ const cases: string[] = [
+ "/",
+ "/admin",
+ "/admin/scrape/runs",
+ "/api/v1/admin/scrape/status",
+ "/trade-in/api/v1/admin/scrape",
+ "/concept/123",
+ "/analytics/dashboard",
+ ];
+ it.each(cases)("allows %s", (path) => {
+ expect(isPathAllowed(ADMIN_PATHS, ADMIN_DENY, path)).toBe(true);
+ });
+});
+
+describe("isPathAllowed — pilot allowed paths (только trade-in)", () => {
+ const allowed: string[] = [
+ "/trade-in",
+ "/trade-in/",
+ "/trade-in/123",
+ "/trade-in/api/v1/search",
+ "/trade-in/api/v1/me",
+ ];
+ it.each(allowed)("allows %s", (path) => {
+ expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, path)).toBe(true);
+ });
+});
+
+describe("isPathAllowed — pilot denied paths (landing + admin + остальное)", () => {
+ const denied: string[] = [
+ // Landing + non-tradein разделы — теперь denied (decision 2026-05-26)
+ "/",
+ "/analytics/dashboard",
+ "/site-finder/123",
+ "/concept/abc",
+ "/api/v1/parcels/123",
+ "/api/v1/analytics/dashboard",
+ // Admin paths (explicit deny + not in allowed)
+ "/admin",
+ "/admin/jobs",
+ "/admin/scrape/runs/42",
+ "/api/v1/admin/scrape/status",
+ "/api/v1/admin/jobs",
+ "/trade-in/api/v1/admin/scrape",
+ ];
+ it.each(denied)("denies %s", (path) => {
+ expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, path)).toBe(false);
+ });
+});
+
+describe("isPathAllowed — edge cases", () => {
+ it("denies path not matching any rule", () => {
+ expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, "/unknown")).toBe(false);
+ });
+ it("matches bare /trade-in via /trade-in/** glob (no trailing /)", () => {
+ // Backend glob `/foo/**` после подстановки `(?:/.*)?` матчит и bare `/foo`.
+ expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, "/trade-in")).toBe(true);
+ });
+});
diff --git a/frontend/src/lib/analytics-api.ts b/frontend/src/lib/analytics-api.ts
index f1508634..73b7287c 100644
--- a/frontend/src/lib/analytics-api.ts
+++ b/frontend/src/lib/analytics-api.ts
@@ -11,9 +11,13 @@ import type {
DistrictRow,
MarketPulsePoint,
ObjectBuilding,
+ ObjectCheckRow,
ObjectDetail,
+ ObjectDocumentRow,
+ ObjectFullDetail,
ObjectInfraPoi,
ObjectPhoto,
+ ObjectQuartirographyRow,
ObjectSaleGraphPoint,
ObjectSalesAggRow,
PipelineRow,
@@ -219,6 +223,42 @@ export function useObjectBuildings(objId: number | string) {
});
}
+export function useObjectFullDetail(objId: number | string) {
+ return useQuery({
+ queryKey: ["analytics", "object-full", objId],
+ queryFn: () => apiFetch(`${BASE}/object/${objId}/full`),
+ enabled: !!objId,
+ });
+}
+
+export function useObjectQuartirography(objId: number | string) {
+ return useQuery({
+ queryKey: ["analytics", "object-quartirography", objId],
+ queryFn: () =>
+ apiFetch(
+ `${BASE}/object/${objId}/quartirography`,
+ ),
+ enabled: !!objId,
+ });
+}
+
+export function useObjectChecks(objId: number | string) {
+ return useQuery({
+ queryKey: ["analytics", "object-checks", objId],
+ queryFn: () => apiFetch(`${BASE}/object/${objId}/checks`),
+ enabled: !!objId,
+ });
+}
+
+export function useObjectDocuments(objId: number | string) {
+ return useQuery({
+ queryKey: ["analytics", "object-documents", objId],
+ queryFn: () =>
+ apiFetch(`${BASE}/object/${objId}/documents`),
+ enabled: !!objId,
+ });
+}
+
// ── Recommender (Уровень 1) ─────────────────────────────────────────────────
export function useRecommendMix() {
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 64559e2c..beb34d43 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,16 +1,38 @@
+import { getOrCreateSessionId } from "@/lib/sessionId";
+
// Empty default = same-origin relative URLs.
// In prod Caddy proxies /api/* to backend; in dev Next.js rewrites do the same.
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
+/**
+ * Merges a global X-Session-Id header into the request init.
+ * Only runs in browser (SSR guard). User-supplied headers win over the
+ * auto-injected value so explicit overrides remain backward-compatible.
+ */
+function withSessionHeader(init?: RequestInit): RequestInit {
+ const base: RequestInit = init ?? {};
+ if (typeof window === "undefined") return base;
+ const sessionId = getOrCreateSessionId();
+ if (!sessionId) return base;
+ return {
+ ...base,
+ headers: {
+ "X-Session-Id": sessionId,
+ ...(base.headers ?? {}),
+ },
+ };
+}
+
export async function apiFetch(
path: string,
init?: RequestInit,
): Promise {
+ const merged = withSessionHeader(init);
const response = await fetch(`${API_BASE_URL}${path}`, {
- ...init,
+ ...merged,
headers: {
"Content-Type": "application/json",
- ...(init?.headers ?? {}),
+ ...(merged.headers ?? {}),
},
});
if (!response.ok) {
@@ -39,11 +61,12 @@ export async function apiFetchWithStatus(
path: string,
init?: RequestInit,
): Promise<{ status: number; body: T }> {
+ const merged = withSessionHeader(init);
const response = await fetch(`${API_BASE_URL}${path}`, {
- ...init,
+ ...merged,
headers: {
"Content-Type": "application/json",
- ...(init?.headers ?? {}),
+ ...(merged.headers ?? {}),
},
});
let body: unknown = null;
diff --git a/frontend/src/lib/api/landing.ts b/frontend/src/lib/api/landing.ts
new file mode 100644
index 00000000..77f2dc6a
--- /dev/null
+++ b/frontend/src/lib/api/landing.ts
@@ -0,0 +1,54 @@
+"use client";
+
+import { useMutation, useQuery } from "@tanstack/react-query";
+
+import { apiFetch } from "@/lib/api";
+
+// ── Types ──────────────────────────────────────────────────────────────────
+
+export interface LandingStats {
+ zk_total: number;
+ deals_total: number;
+ price_coverage_pct: number;
+ mapping_coverage_pct: number;
+ last_data_update: string;
+ paradox: string;
+ /** true when DB is unavailable or all-zero — frontend shows skeleton */
+ stale?: boolean;
+}
+
+export interface PilotRequestPayload {
+ name: string;
+ phone?: string;
+ email?: string;
+ company?: string;
+ message?: string;
+ source: string;
+}
+
+export interface PilotRequestResponse {
+ /** UUID returned as string via CAST(id AS text) */
+ id: string;
+ status: string;
+ created_at: string;
+}
+
+// ── Hooks ──────────────────────────────────────────────────────────────────
+
+export function useLandingStats() {
+ return useQuery({
+ queryKey: ["landing", "stats"],
+ queryFn: () => apiFetch("/api/v1/landing/stats"),
+ staleTime: 60_000,
+ });
+}
+
+export function useSubmitPilotRequest() {
+ return useMutation({
+ mutationFn: (payload: PilotRequestPayload) =>
+ apiFetch("/api/v1/pilot/request", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
+ });
+}
diff --git a/frontend/src/lib/isPathAllowed.ts b/frontend/src/lib/isPathAllowed.ts
new file mode 100644
index 00000000..69c9a689
--- /dev/null
+++ b/frontend/src/lib/isPathAllowed.ts
@@ -0,0 +1,79 @@
+/**
+ * JS-порт `backend/app/core/auth.py::is_path_allowed`.
+ *
+ * Backend — source of truth: глобы из `auth/roles.yaml` парсятся в regex
+ * по тем же правилам что и здесь. Фронт использует это только для UI-gating
+ * (фильтр top-nav, route-guard перед навигацией) — реальная проверка
+ * по-прежнему происходит в backend middleware на каждый /api/v1/* request.
+ *
+ * Семантика glob (mirror of `_glob_to_regex`):
+ * `/**` → matches everything (admin scope)
+ * `/foo/**` → matches `/foo`, `/foo/`, `/foo/bar`, `/foo/bar/baz`, …
+ * `/foo/*` → matches one segment after `/foo/` (no slashes)
+ * `/foo` → exact match only
+ *
+ * Path allowed iff (a) matches >=1 allow-pattern AND (b) doesn't match any
+ * deny-pattern. Deny final (admin uses empty deny → всегда побеждает).
+ */
+
+const REGEX_META = /[.+^${}()|[\]\\]/g;
+
+function escapeRegex(s: string): string {
+ return s.replace(REGEX_META, "\\$&");
+}
+
+function globToRegex(pattern: string): RegExp {
+ // Sentinels — никогда не встречаются в реальном path или в glob-метасимволах.
+ const dstar = "\x00DSTAR\x00";
+ const sstar = "\x00SSTAR\x00";
+
+ const work = pattern.replace(/\*\*/g, dstar).replace(/\*/g, sstar);
+ let regex = escapeRegex(work);
+ regex = regex.replace(new RegExp(escapeRegex(dstar), "g"), ".*");
+ regex = regex.replace(new RegExp(escapeRegex(sstar), "g"), "[^/]*");
+
+ // Special-case `/foo/**` → также матчит `/foo` (без trailing-сегмента),
+ // чтобы `/admin/**` покрывал bare `/admin` request — что и ждут callers.
+ if (regex.endsWith("/.*")) {
+ regex = regex.slice(0, -"/.*".length) + "(?:/.*)?";
+ }
+ return new RegExp(`^${regex}$`);
+}
+
+// Кэш скомпилированных regex — UI часто гоняет isPathAllowed на каждый рендер
+// nav. patterns массив маленький и стабильный per-session, поэтому Map по
+// pattern-строке достаточно.
+const REGEX_CACHE = new Map();
+
+function compileGlob(pattern: string): RegExp {
+ const cached = REGEX_CACHE.get(pattern);
+ if (cached) return cached;
+ const compiled = globToRegex(pattern);
+ REGEX_CACHE.set(pattern, compiled);
+ return compiled;
+}
+
+/**
+ * Mirror of backend `is_path_allowed(role, path)`.
+ *
+ * @param allowedPaths — глобы из `/api/v1/me` `allowed_paths`
+ * @param denyPaths — глобы из `/api/v1/me` `deny_paths`
+ * @param path — путь который пытаемся открыть (например `/admin/scrape`)
+ *
+ * Note: role-аргумент не нужен — allow/deny уже резолвлены backend'ом для
+ * текущего юзера и приехали в /me. Этот хелпер чистый list-matcher.
+ */
+export function isPathAllowed(
+ allowedPaths: readonly string[],
+ denyPaths: readonly string[],
+ path: string,
+): boolean {
+ // Deny overrides allow — даже если path в allowed, deny-match блокирует.
+ for (const p of denyPaths) {
+ if (compileGlob(p).test(path)) return false;
+ }
+ for (const p of allowedPaths) {
+ if (compileGlob(p).test(path)) return true;
+ }
+ return false;
+}
diff --git a/frontend/src/lib/mock-toggle.ts b/frontend/src/lib/mock-toggle.ts
new file mode 100644
index 00000000..acedfe09
--- /dev/null
+++ b/frontend/src/lib/mock-toggle.ts
@@ -0,0 +1,39 @@
+/**
+ * Mock toggle — controls whether hooks return fixture data or call real backend.
+ *
+ * Usage in hooks:
+ * import { USE_MOCKS, MOCK_PARCELS_BBOX } from "@/lib/mock-toggle";
+ *
+ * // Coarse toggle: all mocks on/off
+ * if (USE_MOCKS) return fixtureParcels;
+ *
+ * // Fine-grained: per-endpoint (disable individually as B1-B6 ship to prod)
+ * if (MOCK_PARCELS_BBOX) return fixtureParcels;
+ *
+ * .env.local (dev):
+ * NEXT_PUBLIC_USE_MOCKS=true
+ *
+ * Production: unset or NEXT_PUBLIC_USE_MOCKS=false
+ *
+ * TODO: per-hook fine-grained flags below — flip to false as each backend
+ * endpoint (B1-B6) is confirmed deployed and smoke-tested in prod.
+ */
+
+/** Master switch — set NEXT_PUBLIC_USE_MOCKS=true in .env.local for dev */
+export const USE_MOCKS = process.env.NEXT_PUBLIC_USE_MOCKS === "true";
+
+/**
+ * Fine-grained feature flags (all derive from USE_MOCKS initially).
+ * Flip each to `false` once the corresponding backend endpoint is live.
+ *
+ * B1 — GET /api/v1/parcels/by-bbox → used by A2 EntryMap
+ * B2 — GET /api/v1/users/me/recent-parcels → used by A2 RecentParcels
+ * B4 — GET /api/v1/landing/stats → used by A12 Landing
+ * B5 — POST /api/v1/parcels/{cad}/analyze → used by A5–A11 sections
+ * B6 — GET /api/v1/parcels/{cad}/poi-score → used by A5 PoiList2Gis
+ */
+export const MOCK_PARCELS_BBOX = USE_MOCKS; // B1
+export const MOCK_RECENT_PARCELS = USE_MOCKS; // B2
+export const MOCK_LANDING_STATS = USE_MOCKS; // B4
+export const MOCK_ANALYZE = USE_MOCKS; // B5
+export const MOCK_POI_SCORE = USE_MOCKS; // B6
diff --git a/frontend/src/lib/mocks/parcel-analyze.json b/frontend/src/lib/mocks/parcel-analyze.json
new file mode 100644
index 00000000..e27c794f
--- /dev/null
+++ b/frontend/src/lib/mocks/parcel-analyze.json
@@ -0,0 +1,140 @@
+{
+ "cad_num": "66:41:0701045:42",
+ "source": "cad_quarter",
+ "geom_geojson": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [60.5985, 56.8388],
+ [60.6005, 56.8388],
+ [60.6005, 56.8402],
+ [60.5985, 56.8402],
+ [60.5985, 56.8388]
+ ]
+ ]
+ },
+ "district": {
+ "district_name": "Кировский",
+ "median_price_per_m2": 128000,
+ "dist_to_center": 2.4
+ },
+ "score": 74,
+ "score_label": "хорошо",
+ "score_max_reference": 100,
+ "score_explanation": "Высокий балл за близость к метро и школам. Умеренная конкуренция. Участок подходит для МКД.",
+ "poi_count": 24,
+ "competitors": [],
+ "noise": {
+ "score": 68,
+ "estimated_db": 52,
+ "level": "умеренный",
+ "sources": []
+ },
+ "air_quality": {
+ "pm2_5": 8.2,
+ "pm10": 14.1,
+ "no2": 21.3,
+ "ts": "2026-05-18T00:00:00Z",
+ "source": "OpenMeteo"
+ },
+ "wind": null,
+ "score_breakdown": {
+ "metro_stop": [
+ {
+ "name": "Площадь 1905 года",
+ "distance_m": 340,
+ "last_edit": "2024-01",
+ "lat": 56.8389,
+ "lon": 60.6012
+ }
+ ],
+ "school": [
+ {
+ "name": "Школа № 32",
+ "distance_m": 480,
+ "last_edit": "2024-03",
+ "lat": 56.8401,
+ "lon": 60.598
+ },
+ {
+ "name": "Гимназия № 9",
+ "distance_m": 820,
+ "last_edit": "2023-11",
+ "lat": 56.842,
+ "lon": 60.601
+ }
+ ],
+ "kindergarten": [
+ {
+ "name": "Детский сад № 111",
+ "distance_m": 260,
+ "last_edit": "2024-02",
+ "lat": 56.8376,
+ "lon": 60.5998
+ }
+ ],
+ "park": [
+ {
+ "name": "Сквер Попова",
+ "distance_m": 150,
+ "last_edit": "2024-01",
+ "lat": 56.8385,
+ "lon": 60.5972
+ }
+ ],
+ "shop_mall": [
+ {
+ "name": "МЕГА Екатеринбург",
+ "distance_m": 1200,
+ "last_edit": "2023-10",
+ "lat": 56.845,
+ "lon": 60.612
+ }
+ ],
+ "hospital": [
+ {
+ "name": "Городская больница № 7",
+ "distance_m": 650,
+ "last_edit": "2024-01",
+ "lat": 56.841,
+ "lon": 60.595
+ }
+ ]
+ },
+ "egrn": {
+ "cad_num": "66:41:0701045:42",
+ "address": "Свердловская обл., г. Екатеринбург, ул. Ленина, 42",
+ "area_m2": 8240,
+ "vri": "Многоэтажная жилая застройка (МКД)",
+ "category": "Земли населённых пунктов",
+ "registration_date": "2003-07-15",
+ "owner_type": "Государственная",
+ "encumbrance": "Нет",
+ "status": "Учтённый",
+ "last_updated": "2025-10-01"
+ },
+ "utilities": {
+ "summary": [
+ {
+ "subtype": "substation",
+ "nearest_m": 59,
+ "name": null,
+ "count_within_2km": 7
+ },
+ {
+ "subtype": "pipeline",
+ "nearest_m": 320,
+ "name": null,
+ "count_within_2km": 1
+ },
+ {
+ "subtype": "power_line",
+ "nearest_m": 630,
+ "name": null,
+ "count_within_2km": 2
+ }
+ ],
+ "power_line_охранная_зона_flag": false,
+ "note": "Охранная зона ЛЭП ≥35 кВ — 15-40м по обе стороны (СП 36.13330). В зоне ОЗ нельзя строить капитальные объекты."
+ }
+}
diff --git a/frontend/src/lib/mocks/parcels-bbox.json b/frontend/src/lib/mocks/parcels-bbox.json
new file mode 100644
index 00000000..42ff52cb
--- /dev/null
+++ b/frontend/src/lib/mocks/parcels-bbox.json
@@ -0,0 +1,202 @@
+[
+ {
+ "cad_num": "66:41:0101001:10",
+ "address": "Екатеринбург, ул. Ленина, 1",
+ "area_ha": 0.45,
+ "status": "free",
+ "district": "Ленинский",
+ "vri": "multistory",
+ "lat": 56.8389,
+ "lon": 60.6057
+ },
+ {
+ "cad_num": "66:41:0101002:23",
+ "address": "Екатеринбург, ул. Малышева, 51",
+ "area_ha": 0.82,
+ "status": "in_progress",
+ "district": "Ленинский",
+ "vri": "multistory",
+ "lat": 56.8377,
+ "lon": 60.6121
+ },
+ {
+ "cad_num": "66:41:0201001:5",
+ "address": "Екатеринбург, ул. Луначарского, 14",
+ "area_ha": 1.2,
+ "status": "free",
+ "district": "Верх-Исетский",
+ "vri": "mixed",
+ "lat": 56.8451,
+ "lon": 60.5987
+ },
+ {
+ "cad_num": "66:41:0301001:8",
+ "address": "Екатеринбург, пр. Космонавтов, 3",
+ "area_ha": 0.67,
+ "status": "favorite",
+ "district": "Орджоникидзевский",
+ "vri": "multistory",
+ "lat": 56.8762,
+ "lon": 60.6214
+ },
+ {
+ "cad_num": "66:41:0302001:12",
+ "address": "Екатеринбург, ул. Победы, 80",
+ "area_ha": 0.33,
+ "status": "free",
+ "district": "Орджоникидзевский",
+ "vri": "individual",
+ "lat": 56.8801,
+ "lon": 60.6189
+ },
+ {
+ "cad_num": "66:41:0401001:17",
+ "address": "Екатеринбург, ул. Амундсена, 107",
+ "area_ha": 1.85,
+ "status": "free",
+ "district": "Чкаловский",
+ "vri": "multistory",
+ "lat": 56.7998,
+ "lon": 60.6342
+ },
+ {
+ "cad_num": "66:41:0402001:31",
+ "address": "Екатеринбург, ул. Щербакова, 4",
+ "area_ha": 0.54,
+ "status": "in_progress",
+ "district": "Чкаловский",
+ "vri": "mixed",
+ "lat": 56.8021,
+ "lon": 60.6501
+ },
+ {
+ "cad_num": "66:41:0501001:6",
+ "address": "Екатеринбург, ул. Белинского, 200",
+ "area_ha": 0.91,
+ "status": "free",
+ "district": "Ленинский",
+ "vri": "multistory",
+ "lat": 56.8278,
+ "lon": 60.6398
+ },
+ {
+ "cad_num": "66:41:0601001:44",
+ "address": "Екатеринбург, ул. Надеждинская, 12",
+ "area_ha": 2.1,
+ "status": "free",
+ "district": "Кировский",
+ "vri": "multistory",
+ "lat": 56.8491,
+ "lon": 60.5781
+ },
+ {
+ "cad_num": "66:41:0602001:9",
+ "address": "Екатеринбург, ул. Тверитина, 38",
+ "area_ha": 0.72,
+ "status": "favorite",
+ "district": "Кировский",
+ "vri": "mixed",
+ "lat": 56.8532,
+ "lon": 60.5834
+ },
+ {
+ "cad_num": "66:41:0701001:18",
+ "address": "Екатеринбург, ул. Академика Постовского, 15",
+ "area_ha": 0.48,
+ "status": "free",
+ "district": "Октябрьский",
+ "vri": "multistory",
+ "lat": 56.8187,
+ "lon": 60.5921
+ },
+ {
+ "cad_num": "66:41:0702001:27",
+ "address": "Екатеринбург, ул. Начдива Онуфриева, 2а",
+ "area_ha": 3.4,
+ "status": "free",
+ "district": "Октябрьский",
+ "vri": "multistory",
+ "lat": 56.8112,
+ "lon": 60.5812
+ },
+ {
+ "cad_num": "66:41:0801001:55",
+ "address": "Екатеринбург, ул. Радищева, 33",
+ "area_ha": 0.61,
+ "status": "in_progress",
+ "district": "Ленинский",
+ "vri": "office",
+ "lat": 56.8348,
+ "lon": 60.6234
+ },
+ {
+ "cad_num": "66:41:0802001:11",
+ "address": "Екатеринбург, ул. Сакко и Ванцетти, 67",
+ "area_ha": 0.38,
+ "status": "free",
+ "district": "Ленинский",
+ "vri": "mixed",
+ "lat": 56.8363,
+ "lon": 60.6177
+ },
+ {
+ "cad_num": "66:41:0901001:7",
+ "address": "Екатеринбург, ул. Ботаническая, 11",
+ "area_ha": 1.54,
+ "status": "free",
+ "district": "Чкаловский",
+ "vri": "multistory",
+ "lat": 56.7892,
+ "lon": 60.6498
+ },
+ {
+ "cad_num": "66:41:0902001:22",
+ "address": "Екатеринбург, пр. Латвийский, 12",
+ "area_ha": 0.83,
+ "status": "favorite",
+ "district": "Чкаловский",
+ "vri": "multistory",
+ "lat": 56.7934,
+ "lon": 60.6541
+ },
+ {
+ "cad_num": "66:41:1001001:3",
+ "address": "Екатеринбург, ул. Краснолесья, 26",
+ "area_ha": 2.67,
+ "status": "free",
+ "district": "Октябрьский",
+ "vri": "multistory",
+ "lat": 56.7812,
+ "lon": 60.5698
+ },
+ {
+ "cad_num": "66:41:1002001:14",
+ "address": "Екатеринбург, ул. Ясная, 30",
+ "area_ha": 0.59,
+ "status": "in_progress",
+ "district": "Октябрьский",
+ "vri": "mixed",
+ "lat": 56.7856,
+ "lon": 60.5742
+ },
+ {
+ "cad_num": "66:41:1101001:42",
+ "address": "Екатеринбург, ул. Крестинского, 46",
+ "area_ha": 1.12,
+ "status": "free",
+ "district": "Верх-Исетский",
+ "vri": "multistory",
+ "lat": 56.8578,
+ "lon": 60.5634
+ },
+ {
+ "cad_num": "66:41:1102001:19",
+ "address": "Екатеринбург, ул. Новостроя, 8",
+ "area_ha": 0.76,
+ "status": "free",
+ "district": "Верх-Исетский",
+ "vri": "individual",
+ "lat": 56.8621,
+ "lon": 60.5589
+ }
+]
diff --git a/frontend/src/lib/mocks/poi-score.json b/frontend/src/lib/mocks/poi-score.json
new file mode 100644
index 00000000..f0b910f7
--- /dev/null
+++ b/frontend/src/lib/mocks/poi-score.json
@@ -0,0 +1,55 @@
+{
+ "cad_num": "66:41:0701045:42",
+ "poi_weighted_score": 76,
+ "items": [
+ {
+ "category": "metro_stop",
+ "name": "Площадь 1905 года",
+ "distance_m": 340,
+ "weight": 0.25,
+ "score_contribution": 22
+ },
+ {
+ "category": "park",
+ "name": "Сквер Попова",
+ "distance_m": 150,
+ "weight": 0.1,
+ "score_contribution": 9
+ },
+ {
+ "category": "school",
+ "name": "Школа № 32",
+ "distance_m": 480,
+ "weight": 0.15,
+ "score_contribution": 11
+ },
+ {
+ "category": "kindergarten",
+ "name": "Детский сад № 111",
+ "distance_m": 260,
+ "weight": 0.12,
+ "score_contribution": 10
+ },
+ {
+ "category": "shop_mall",
+ "name": "МЕГА Екатеринбург",
+ "distance_m": 1200,
+ "weight": 0.1,
+ "score_contribution": 7
+ },
+ {
+ "category": "hospital",
+ "name": "Городская больница № 7",
+ "distance_m": 650,
+ "weight": 0.1,
+ "score_contribution": 8
+ },
+ {
+ "category": "school",
+ "name": "Гимназия № 9",
+ "distance_m": 820,
+ "weight": 0.08,
+ "score_contribution": 5
+ }
+ ]
+}
diff --git a/frontend/src/lib/sessionId.ts b/frontend/src/lib/sessionId.ts
new file mode 100644
index 00000000..657129e1
--- /dev/null
+++ b/frontend/src/lib/sessionId.ts
@@ -0,0 +1,14 @@
+/**
+ * Session ID utility for custom POI auth.
+ * Stored in localStorage under "session_id" key.
+ * Returns empty string during SSR (window not available).
+ */
+export function getOrCreateSessionId(): string {
+ if (typeof window === "undefined") return "";
+ let id = localStorage.getItem("session_id");
+ if (!id) {
+ id = crypto.randomUUID();
+ localStorage.setItem("session_id", id);
+ }
+ return id;
+}
diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts
new file mode 100644
index 00000000..a7136d8e
--- /dev/null
+++ b/frontend/src/lib/site-finder-api.ts
@@ -0,0 +1,393 @@
+/**
+ * Site Finder API hooks — TanStack Query wrappers.
+ *
+ * Mock fallback strategy (from mock-toggle.ts):
+ * MOCK_PARCELS_BBOX — uses parcels-bbox.json fixture (B1 not yet in prod)
+ * MOCK_RECENT_PARCELS — uses localStorage only (B2 stub not yet in prod)
+ * MOCK_ANALYZE — uses parcel-analyze.json fixture (B5)
+ * MOCK_POI_SCORE — uses poi-score.json fixture (B6)
+ */
+
+import { useQuery } from "@tanstack/react-query";
+import { apiFetch } from "@/lib/api";
+import {
+ MOCK_PARCELS_BBOX,
+ MOCK_RECENT_PARCELS,
+ MOCK_ANALYZE,
+ MOCK_POI_SCORE,
+} from "@/lib/mock-toggle";
+import fixtureParcels from "@/lib/mocks/parcels-bbox.json";
+import fixtureAnalyze from "@/lib/mocks/parcel-analyze.json";
+import fixturePoiScore from "@/lib/mocks/poi-score.json";
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type ParcelStatus = "free" | "in_progress" | "favorite";
+
+export type ParcelVri =
+ | "multistory"
+ | "mixed"
+ | "individual"
+ | "office"
+ | "other";
+
+export interface ParcelBboxItem {
+ cad_num: string;
+ address: string;
+ area_ha: number;
+ status: ParcelStatus;
+ district: string;
+ vri: ParcelVri;
+ lat: number;
+ lon: number;
+}
+
+export interface ParcelBboxFilters {
+ min_area?: number;
+ max_area?: number;
+ status?: ParcelStatus;
+ district?: string;
+ vri?: ParcelVri;
+}
+
+export interface BboxCoords {
+ minLat: number;
+ minLon: number;
+ maxLat: number;
+ maxLon: number;
+}
+
+export interface RecentParcel {
+ cad_num: string;
+ address: string;
+ area_ha: number;
+ district: string;
+ visited_at: string; // ISO string
+}
+
+// ── Constants ─────────────────────────────────────────────────────────────────
+
+const LOCAL_STORAGE_KEY = "gd_recent_parcels";
+const MAX_RECENT = 10;
+
+// ── localStorage helpers ──────────────────────────────────────────────────────
+
+export function getLocalRecentParcels(): RecentParcel[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = localStorage.getItem(LOCAL_STORAGE_KEY);
+ if (!raw) return [];
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed as RecentParcel[];
+ } catch {
+ return [];
+ }
+}
+
+export function addLocalRecentParcel(parcel: RecentParcel): void {
+ if (typeof window === "undefined") return;
+ try {
+ const existing = getLocalRecentParcels();
+ const filtered = existing.filter((p) => p.cad_num !== parcel.cad_num);
+ const updated = [parcel, ...filtered].slice(0, MAX_RECENT);
+ localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updated));
+ } catch {
+ // ignore localStorage errors
+ }
+}
+
+// ── Hook: useParcelsBboxQuery ─────────────────────────────────────────────────
+
+function applyFilters(
+ parcels: ParcelBboxItem[],
+ filters: ParcelBboxFilters,
+): ParcelBboxItem[] {
+ return parcels.filter((p) => {
+ if (filters.min_area != null && p.area_ha < filters.min_area) return false;
+ if (filters.max_area != null && p.area_ha > filters.max_area) return false;
+ if (filters.status != null && p.status !== filters.status) return false;
+ if (
+ filters.district != null &&
+ filters.district !== "" &&
+ p.district !== filters.district
+ )
+ return false;
+ if (filters.vri != null && filters.vri !== "other" && p.vri !== filters.vri)
+ return false;
+ return true;
+ });
+}
+
+function filterByBbox(
+ parcels: ParcelBboxItem[],
+ bbox: BboxCoords,
+): ParcelBboxItem[] {
+ return parcels.filter(
+ (p) =>
+ p.lat >= bbox.minLat &&
+ p.lat <= bbox.maxLat &&
+ p.lon >= bbox.minLon &&
+ p.lon <= bbox.maxLon,
+ );
+}
+
+export function useParcelsBboxQuery(
+ bbox: BboxCoords | null,
+ filters: ParcelBboxFilters = {},
+) {
+ return useQuery({
+ queryKey: [
+ "parcels-bbox",
+ bbox?.minLat,
+ bbox?.minLon,
+ bbox?.maxLat,
+ bbox?.maxLon,
+ filters.min_area,
+ filters.max_area,
+ filters.status,
+ filters.district,
+ filters.vri,
+ ],
+ queryFn: async (): Promise => {
+ if (MOCK_PARCELS_BBOX) {
+ // Fixture: filter by bbox + filters client-side
+ const typed = fixtureParcels as ParcelBboxItem[];
+ const inBbox = bbox ? filterByBbox(typed, bbox) : typed;
+ return applyFilters(inBbox, filters);
+ }
+
+ if (!bbox) return [];
+
+ const params = new URLSearchParams({
+ min_lat: String(bbox.minLat),
+ min_lon: String(bbox.minLon),
+ max_lat: String(bbox.maxLat),
+ max_lon: String(bbox.maxLon),
+ });
+ if (filters.min_area != null)
+ params.set("min_area", String(filters.min_area));
+ if (filters.max_area != null)
+ params.set("max_area", String(filters.max_area));
+ if (filters.status) params.set("status", filters.status);
+ if (filters.district) params.set("district", filters.district);
+ if (filters.vri) params.set("vri", filters.vri);
+
+ // Backend returns { parcels, count, limit, bbox_area_km2 } with field
+ // names centroid_lat/centroid_lon/area_m2 and nullable status. Adapt to
+ // the flat ParcelBboxItem[] shape the map components consume. district /
+ // vri / address are not in B1 yet — defaulted until enrichment lands.
+ const raw = await apiFetch<{
+ parcels: Array<{
+ cad_num: string;
+ centroid_lat: number;
+ centroid_lon: number;
+ area_m2: number | null;
+ land_category: string | null;
+ status: ParcelStatus | null;
+ }>;
+ }>(`/api/v1/parcels/by-bbox?${params.toString()}`);
+
+ const adapted: ParcelBboxItem[] = raw.parcels.map((p) => ({
+ cad_num: p.cad_num,
+ lat: p.centroid_lat,
+ lon: p.centroid_lon,
+ area_ha: p.area_m2 != null ? p.area_m2 / 10000 : 0,
+ status: p.status ?? "free",
+ district: "—",
+ vri: "other",
+ address: "",
+ }));
+ // Apply filters client-side until backend supports them (B1 follow-up)
+ return applyFilters(adapted, filters);
+ },
+ enabled: MOCK_PARCELS_BBOX || bbox != null,
+ staleTime: 30_000,
+ });
+}
+
+// ── Hook: useRecentParcels ────────────────────────────────────────────────────
+
+interface RecentParcelsResponse {
+ parcels: RecentParcel[];
+}
+
+export function useRecentParcels() {
+ return useQuery({
+ queryKey: ["recent-parcels"],
+ queryFn: async (): Promise => {
+ const localItems = getLocalRecentParcels();
+
+ if (MOCK_RECENT_PARCELS) {
+ return localItems;
+ }
+
+ try {
+ const serverData = await apiFetch(
+ "/api/v1/users/me/recent-parcels",
+ );
+ // Merge: server list + local items not already in server list
+ const serverCads = new Set(serverData.parcels.map((p) => p.cad_num));
+ const localOnly = localItems.filter((p) => !serverCads.has(p.cad_num));
+ return [...serverData.parcels, ...localOnly].slice(0, MAX_RECENT);
+ } catch {
+ // B2 stub may not be deployed yet — fall back to localStorage
+ return localItems;
+ }
+ },
+ staleTime: 60_000,
+ });
+}
+
+// ── Types: B5 extended analyze response ──────────────────────────────────────
+
+export interface ParcelEgrn {
+ cad_num: string;
+ address: string;
+ area_m2: number;
+ vri: string;
+ category: string;
+ registration_date: string | null;
+ owner_type: string;
+ encumbrance: string;
+ status: string;
+ last_updated: string | null;
+}
+
+// ── Types: utilities (engineering nearby) ────────────────────────────────────
+
+export type UtilitySubtype =
+ | "substation"
+ | "pipeline"
+ | "power_line"
+ | "water_intake"
+ | "pumping_station"
+ | string; // open for future subtypes
+
+export interface UtilitySummaryItem {
+ subtype: UtilitySubtype;
+ nearest_m: number;
+ name: string | null;
+ count_within_2km: number;
+}
+
+export interface UtilitiesData {
+ summary: UtilitySummaryItem[];
+ power_line_охранная_зона_flag: boolean;
+ note: string | null;
+}
+
+export interface ParcelAnalyzeResponse {
+ cad_num: string;
+ source: string;
+ geom_geojson: unknown;
+ district: {
+ district_name: string;
+ median_price_per_m2: number;
+ dist_to_center: number;
+ } | null;
+ score: number;
+ score_label?: string;
+ score_explanation?: string;
+ score_breakdown: Record<
+ string,
+ Array<{
+ name: string | null;
+ distance_m: number;
+ last_edit: string | null;
+ lat: number;
+ lon: number;
+ }>
+ >;
+ poi_count: number;
+ /** EGRN data — may be null if B5 extended not yet deployed */
+ egrn?: ParcelEgrn | null;
+ /** Engineering / utilities nearby — present when NSPD data available */
+ utilities?: UtilitiesData | null;
+}
+
+// ── Types: B6 poi-score response ─────────────────────────────────────────────
+
+export interface PoiScoreItem {
+ category: string;
+ name: string;
+ distance_m: number;
+ weight: number;
+ score_contribution: number;
+}
+
+export interface PoiScoreResponse {
+ cad_num: string;
+ poi_weighted_score: number;
+ items: PoiScoreItem[];
+}
+
+// ── Hook: useParcelAnalyzeQuery (B5) ─────────────────────────────────────────
+
+export function useParcelAnalyzeQuery(cad: string) {
+ return useQuery({
+ queryKey: ["parcel-analyze", cad],
+ queryFn: async (): Promise => {
+ if (MOCK_ANALYZE) {
+ // Return fixture data regardless of cad — for dev only
+ return fixtureAnalyze as ParcelAnalyzeResponse;
+ }
+ return apiFetch(
+ `/api/v1/parcels/${encodeURIComponent(cad)}/analyze`,
+ { method: "POST" },
+ );
+ },
+ staleTime: 5 * 60_000, // 5 min — analyze is expensive
+ retry: 1,
+ });
+}
+
+// ── Hook: useParcelPoiScoreQuery (B6) ────────────────────────────────────────
+
+/**
+ * Raw shape from backend /api/v1/parcels/{cad}/poi-score.
+ * Backend returns top_poi array with address field (not score_contribution).
+ * We adapt to PoiScoreResponse so downstream components are stable.
+ */
+interface PoiScoreRaw {
+ cad_num: string;
+ radius_m: number;
+ top_poi: Array<{
+ name: string;
+ category: string;
+ distance_m: number;
+ weight: number;
+ address?: string | null;
+ }>;
+}
+
+export function useParcelPoiScoreQuery(cad: string) {
+ return useQuery({
+ queryKey: ["parcel-poi-score", cad],
+ queryFn: async (): Promise => {
+ if (MOCK_POI_SCORE) {
+ return fixturePoiScore as PoiScoreResponse;
+ }
+ // Backend returns {cad_num, radius_m, top_poi: [{name,category,distance_m,weight,address}]}
+ // Adapt to PoiScoreResponse {cad_num, poi_weighted_score, items} for stable consumer API.
+ const raw = await apiFetch(
+ `/api/v1/parcels/${encodeURIComponent(cad)}/poi-score`,
+ );
+ const items: PoiScoreItem[] = (raw.top_poi ?? []).map((p) => ({
+ category: p.category,
+ name: p.name,
+ distance_m: p.distance_m,
+ weight: p.weight,
+ // score_contribution not in backend response — derive from weight (0..1) × 100
+ score_contribution: Math.round(p.weight * 100),
+ }));
+ const poi_weighted_score = items.reduce(
+ (s, p) => s + p.score_contribution,
+ 0,
+ );
+ return { cad_num: raw.cad_num, poi_weighted_score, items };
+ },
+ staleTime: 5 * 60_000,
+ retry: 1,
+ });
+}
diff --git a/frontend/src/lib/trade-in-api.ts b/frontend/src/lib/trade-in-api.ts
new file mode 100644
index 00000000..88c3138c
--- /dev/null
+++ b/frontend/src/lib/trade-in-api.ts
@@ -0,0 +1,39 @@
+"use client";
+
+import { useMutation, useQuery } from "@tanstack/react-query";
+
+import { apiFetch } from "./api";
+import type {
+ AggregatedEstimate,
+ TradeInEstimateInput,
+} from "@/types/trade-in";
+
+const BASE = "/api/v1/trade-in";
+
+/**
+ * POST /api/v1/trade-in/estimate
+ * Sends apartment parameters and returns AggregatedEstimate (mock data for now).
+ */
+export function useEstimateMutation() {
+ return useMutation({
+ mutationFn: (input) =>
+ apiFetch(`${BASE}/estimate`, {
+ method: "POST",
+ body: JSON.stringify(input),
+ }),
+ });
+}
+
+/**
+ * GET /api/v1/trade-in/estimate/{id}
+ * Fetches a previously computed estimate by UUID (for shareable links / PDF).
+ */
+export function useEstimate(estimate_id: string | null) {
+ return useQuery({
+ queryKey: ["trade-in", "estimate", estimate_id],
+ queryFn: () =>
+ apiFetch(`${BASE}/estimate/${estimate_id}`),
+ enabled: estimate_id !== null && estimate_id.length > 0,
+ staleTime: 10 * 60_000, // 10 min — estimates expire at expires_at anyway
+ });
+}
diff --git a/frontend/src/lib/useMe.ts b/frontend/src/lib/useMe.ts
new file mode 100644
index 00000000..951edb04
--- /dev/null
+++ b/frontend/src/lib/useMe.ts
@@ -0,0 +1,63 @@
+"use client";
+
+/**
+ * `useMe()` — TanStack-хук для GET /api/v1/me.
+ *
+ * Backend возвращает `UserScope` (см. `backend/app/core/auth.py`):
+ * { username, role: "admin"|"pilot", allowed_paths: string[], deny_paths: string[] }
+ *
+ * Failure modes:
+ * - 401 — нет X-Authenticated-User (локально без Caddy basic_auth). Фронт
+ * трактует это как «dev-режим» и в этом случае route-guard ничего не
+ * блокирует (см. RouteGuard).
+ * - 403 — header есть, но юзер не в `auth/roles.yaml`. Фронт показывает
+ * полноэкранный NoAccessScreen и больше ничего.
+ *
+ * Кешируем «вечно» (`staleTime: Infinity`, без рефетча): роль не меняется
+ * в рамках сессии. Если basic_auth обновится — юзер перезагрузит страницу.
+ */
+
+import { useQuery } from "@tanstack/react-query";
+
+import { apiFetchWithStatus, HTTPError } from "@/lib/api";
+
+// Error type для useQuery: либо `HTTPError` (от apiFetchWithStatus при 4xx/5xx),
+// либо обычный `Error` (network fail, fetch reject). RouteGuard / TopNav проверяют
+// статус через `error instanceof HTTPError` — это работает в runtime независимо
+// от type-level decl.
+
+export type Role = "admin" | "pilot";
+
+export interface UserScope {
+ username: string;
+ role: Role;
+ allowed_paths: string[];
+ deny_paths: string[];
+}
+
+export const ME_QUERY_KEY = ["auth", "me"] as const;
+
+async function fetchMe(): Promise {
+ const { body } = await apiFetchWithStatus("/api/v1/me");
+ return body;
+}
+
+export function useMe() {
+ return useQuery({
+ queryKey: ME_QUERY_KEY,
+ queryFn: fetchMe,
+ staleTime: Infinity,
+ gcTime: Infinity,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ // 401 (dev без Caddy) и 403 (unknown user) — финальные состояния,
+ // ретраить бесполезно. Ретраим только сетевые сбои.
+ retry: (failureCount, error) => {
+ if (error instanceof HTTPError && (error.status === 401 || error.status === 403)) {
+ return false;
+ }
+ return failureCount < 2;
+ },
+ });
+}
diff --git a/frontend/src/types/analytics.ts b/frontend/src/types/analytics.ts
index 01a2c7e7..d8b9c80f 100644
--- a/frontend/src/types/analytics.ts
+++ b/frontend/src/types/analytics.ts
@@ -191,6 +191,104 @@ export interface ObjectInfraPoi {
distance_m: number | null;
}
+export interface MetroEntry {
+ name: string;
+ time: number | null;
+ line: string | null;
+ color: string | null;
+ isWalk: boolean | null;
+}
+
+export interface ObjectFullDetail {
+ obj_id: number;
+ hobj_id: number | null;
+ comm_name: string | null;
+ addr: string | null;
+ short_addr: string | null;
+ region_cd: number | null;
+ dev_id: string | null;
+ dev_name: string | null;
+ dev_group_name: string | null;
+ floor_min: number | null;
+ floor_max: number | null;
+ flat_count: number | null;
+ square_living: number | null;
+ ready_dt: string | null;
+ site_status: string | null;
+ escrow: boolean | null;
+ obj_class: string | null;
+ latitude: number | null;
+ longitude: number | null;
+ obj_status: number | null;
+ snapshot_date: string | null;
+ energy_eff: string | null;
+ wall_type: string | null;
+ // Building specs
+ first_floor_type: string | null;
+ section_count: number | null;
+ elevators_passenger_count: number | null;
+ elevators_cargo_count: number | null;
+ parking_total_slots: number | null;
+ guest_parking_inside_count: number | null;
+ guest_parking_outside_count: number | null;
+ ceiling_height_m: number | null;
+ // Apartment summary
+ finishing_variants_count: number | null;
+ has_free_planning: boolean | null;
+ avg_flat_area_m2: number | null;
+ // Yard
+ playground_kids_count: number | null;
+ playground_sport_count: number | null;
+ has_bike_paths: boolean | null;
+ trash_areas_count: number | null;
+ // OVZ
+ has_ramp: boolean | null;
+ has_low_platforms: boolean | null;
+ has_wheelchair_lift: boolean | null;
+ // Catalog/UI
+ flat_area_min: number | null;
+ flat_area_max: number | null;
+ price_min_rub: number | null;
+ price_max_rub: number | null;
+ price_per_m2_min: number | null;
+ price_per_m2_max: number | null;
+ parking_provision_pct: number | null;
+ project_published_at: string | null;
+ project_declaration_num: string | null;
+ // Metro & scores
+ metro_nearest_name: string | null;
+ metro_nearest_walk_minutes: number | null;
+ metro_top3: MetroEntry[] | null;
+ domrf_score_location: number | null;
+ domrf_score_transport: number | null;
+ domrf_score_infrastructure: number | null;
+ buildings_count: number;
+}
+
+export interface ObjectQuartirographyRow {
+ room_label: string;
+ total_count: number;
+ free_count: number;
+ area_min: number | null;
+ area_max: number | null;
+ price_min: number | null;
+ price_max: number | null;
+}
+
+export interface ObjectCheckRow {
+ check_type: string;
+ passed: boolean;
+ checked_at: string | null;
+}
+
+export interface ObjectDocumentRow {
+ doc_type: string;
+ doc_num: string | null;
+ posted_at: string | null;
+ file_url: string;
+ size_bytes: number | null;
+}
+
export interface ObjectPhoto {
obj_file_id: string;
ord_num: number | null;
diff --git a/frontend/src/types/best-layouts.ts b/frontend/src/types/best-layouts.ts
new file mode 100644
index 00000000..c821dd22
--- /dev/null
+++ b/frontend/src/types/best-layouts.ts
@@ -0,0 +1,66 @@
+// Manual TS types for /best-layouts endpoint (Issue #113)
+// Source: backend/app/schemas/parcel.py — BestLayoutsRequest, BestLayoutsResponse et al.
+// Update if Pydantic schemas change and codegen is available.
+
+export type TimeWindow = "last_month" | "last_quarter" | "last_year";
+export type RoomBucket = "studio" | "1" | "2" | "3" | "4+";
+export type AreaBin = "<25" | "25-40" | "40-60" | "60-80" | "80-100" | "100+";
+export type Confidence = "high" | "medium" | "low";
+
+export interface BestLayoutsRequest {
+ radius_km: number;
+ time_window: TimeWindow;
+ filter_competitor_obj_ids?: number[] | null;
+ exclude_competitor_obj_ids?: number[];
+ min_velocity_per_month?: number;
+ obj_class_filter?: "economy" | "comfort" | "business" | null;
+ target_total_flats?: number | null;
+}
+
+export interface TopLayoutRow {
+ rank: number;
+ room_bucket: string;
+ area_bin: string;
+ signature: string;
+ competitor_obj_ids: number[];
+ competitor_count: number;
+ total_sold_in_window: number;
+ velocity_per_month: number;
+ avg_price_per_m2_rub: number | null;
+ avg_area_m2: number;
+ supply_units_in_radius: number;
+ sold_pct_of_supply: number | null; // clamped at 100.0
+ is_oversold: boolean; // raw ratio was >100% (incompatible time windows)
+}
+
+export interface LayoutTzMixRow {
+ room_bucket: string;
+ pct: number;
+ abs_units: number | null;
+ avg_target_area_m2: number | null;
+}
+
+export interface LayoutTzRecommendation {
+ rationale_text: string;
+ mix: LayoutTzMixRow[];
+ weighted_avg_price_per_m2_rub: number | null;
+ based_on_obj_count: number;
+ based_on_total_deals: number;
+ data_window_start: string;
+ data_window_end: string;
+ /** Fix SF-09: true если pathological case — все bucket'ы > cap, redistribute невозможен. */
+ cap_skipped: boolean;
+}
+
+export interface LayoutDataQuality {
+ objects_with_velocity_data: number;
+ objects_total_in_radius: number;
+ velocity_coverage_pct: number;
+ confidence: Confidence;
+}
+
+export interface BestLayoutsResponse {
+ top_layouts: TopLayoutRow[];
+ recommendation_for_tz: LayoutTzRecommendation;
+ data_quality: LayoutDataQuality;
+}
diff --git a/frontend/src/types/customPoi.ts b/frontend/src/types/customPoi.ts
new file mode 100644
index 00000000..b0cd4220
--- /dev/null
+++ b/frontend/src/types/customPoi.ts
@@ -0,0 +1,30 @@
+export interface CustomPoi {
+ id: number;
+ user_id: string;
+ parcel_cad: string | null;
+ name: string;
+ category: string | null;
+ weight: number;
+ lon: number;
+ lat: number;
+ notes: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CustomPoiCreate {
+ name: string;
+ category?: string | null;
+ weight: number;
+ lon: number;
+ lat: number;
+ parcel_cad?: string | null;
+ notes?: string | null;
+}
+
+export interface CustomPoiUpdate {
+ name?: string;
+ category?: string | null;
+ weight?: number;
+ notes?: string | null;
+}
diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts
new file mode 100644
index 00000000..3785d67c
--- /dev/null
+++ b/frontend/src/types/nspd.ts
@@ -0,0 +1,106 @@
+// NSPD (НСПД) types — Issue #202
+// Shapes derived from backend/app/services/site_finder/quarter_dump_lookup.py
+// and backend/app/schemas/parcel.py
+
+export interface NspdZoning {
+ zone_code: string | null;
+ zone_name: string | null;
+ source?: string;
+ raw_props?: Record;
+}
+
+export interface NspdZouitOverlap {
+ group_key: string; // e.g. "engineering", "okn", "natural", "protected", "other"
+ layer: string; // e.g. "zouit_engineering"
+ subcategory: string | number | null;
+ name: string | null;
+ raw_props?: Record;
+}
+
+export interface NspdEngineeringNearby {
+ name: string | null;
+ type: string | null;
+ distance_m: number | null;
+ raw_props?: Record;
+}
+
+export interface NspdDumpMeta {
+ available: boolean;
+ fetched_at_utc: string | null; // ISO datetime
+ stale: boolean;
+ harvest_triggered: boolean;
+ total_features: number | null;
+ // Issue #234: typical harvest duration. Используется UI для countdown +
+ // auto-stop re-poll после ETA*1.5. null когда dump уже available или harvest
+ // не запущен (lock уже взят другим запросом).
+ harvest_eta_seconds?: number | null;
+}
+
+// Risk zones (issue #94 TIER 3) — from /analyze nspd_risk_zones field
+
+export interface RiskZone {
+ layer: string; // e.g. "risk_flooding", "risk_landslide"
+ subtype: string | null; // human-readable label from NSPD properties or mapping
+ geom_wkt: string | null;
+ intersection_area_sqm: number | null;
+}
+
+// Opportunity parcels (issue #94 TIER 4)
+
+export interface OpportunityParcel {
+ layer:
+ | "auction_parcels"
+ | "scheme_parcels"
+ | "free_parcels"
+ | "future_parcels"
+ | "oopt";
+ cad_num: string | null;
+ distance_m: number | null;
+ geom_wkt: string | null;
+}
+
+// Red lines (issue #94 TIER 4 + #54 Generative foundation)
+
+export interface RedLine {
+ geom_wkt: string | null;
+ intersection_length_m: number | null; // null if only nearby, not intersecting
+ distance_m: number | null; // null if intersecting
+}
+
+// Connection-points endpoint shapes (/api/v1/parcels/{cad}/connection-points)
+
+export interface EngineeringStructure {
+ name: string | null;
+ type: string | null;
+ cad_num: string | null;
+ distance_to_boundary_m: number;
+ geometry_geojson: Record;
+ readable_address: string | null;
+ raw_props: Record;
+ source: string;
+}
+
+export interface ZouitEngineeringOverlap {
+ reg_numb_border: string | null;
+ type_zone: string | null;
+ subcategory: number | null;
+ intersects_parcel: boolean;
+ geometry_geojson: Record;
+ raw_props: Record;
+ source: string;
+}
+
+export interface ConnectionPointsSummary {
+ nearest_structure_distance_m: number | null;
+ in_protection_zone: boolean;
+ protection_zones_intersecting: number;
+ total_structures_in_radius: number;
+}
+
+export interface ConnectionPointsResponse {
+ engineering_structures: EngineeringStructure[];
+ zouit_engineering_overlaps: ZouitEngineeringOverlap[];
+ summary: ConnectionPointsSummary;
+ dump_available: boolean;
+ dump_fetched_at: string | null;
+}
diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts
index 7217fd6b..4bec110a 100644
--- a/frontend/src/types/site-finder.ts
+++ b/frontend/src/types/site-finder.ts
@@ -39,6 +39,10 @@ export interface ParcelAnalysisCompetitor {
flat_count: number | null;
district_name: string | null;
distance_m: number;
+ // Added manually — backend PR #276 (site_status in /analyze).
+ // Codegen will overwrite after backend deploy (additive, backward compat).
+ site_status?: string | null; // 'Строящиеся' | 'Сданные' | null
+ ready_dt?: string | null; // ISO date
}
export interface ParcelAnalysisDistrict {
@@ -237,6 +241,13 @@ export interface Velocity {
period: VelocityPeriod;
sample_competitors: VelocityCompetitor[];
by_room_bucket?: Record;
+ // True если ≥1 конкурент имеет маппинг в objective_complex_mapping.
+ // False → конкуренты найдены, velocity=0, данных Objective нет.
+ velocity_data_available?: boolean;
+ // SF#17: источник данных velocity.
+ // 'objective' — Objective (основной), 'rosreestr_fallback' — кадастровый квартал,
+ // 'none' — нет данных.
+ velocity_source?: "objective" | "rosreestr_fallback" | "none";
}
// G5 (#32) — Gate verdict: can_build_mkd
@@ -308,6 +319,8 @@ export interface SuccessRankingBucket {
export interface ParcelSuccessRecommendation {
district: string;
+ /** SF-20: strong = ≥30 сделок в группе; weak = 15-29 сделок (ориентировочно) */
+ data_confidence: "strong" | "weak";
ranking: SuccessRankingBucket[];
top_bucket: SuccessRankingBucket;
note: string;
@@ -379,6 +392,36 @@ export interface ParcelAnalysis {
gate_verdict?: GateVerdict;
// D2 (#34) — velocity-score: темп продаж конкурентов
velocity?: Velocity | null;
+ // Issue #202 — NSPD frontend integration
+ nspd_zoning?: import("./nspd").NspdZoning | null;
+ nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[];
+ nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[];
+ nspd_risk_zones?: import("./nspd").RiskZone[];
+ // Issue #94 PR2 — TIER 4 opportunity layers + red lines
+ nspd_opportunity_parcels?: import("./nspd").OpportunityParcel[];
+ nspd_red_lines?: import("./nspd").RedLine[];
+ nspd_dump?: import("./nspd").NspdDumpMeta | null;
+ // B5 extended (added 2026-05-18 — expose в legacy Обзор)
+ egrn?: {
+ address?: string | null;
+ permitted_use_text?: string | null;
+ land_category?: string | null;
+ parcel_status?: string | null;
+ right_type?: string | null;
+ cadastral_value_rub?: number | null;
+ cost_per_m2_rub?: number | null;
+ last_egrn_update_date?: string | null;
+ } | null;
+ utilities?: {
+ summary?: Array<{
+ subtype: string;
+ nearest_m: number;
+ name?: string | null;
+ count_within_2km?: number;
+ }>;
+ power_line_охранная_зона_flag?: boolean;
+ note?: string | null;
+ } | null;
}
export type PoiCategory =
diff --git a/frontend/src/types/trade-in.ts b/frontend/src/types/trade-in.ts
new file mode 100644
index 00000000..b3262e40
--- /dev/null
+++ b/frontend/src/types/trade-in.ts
@@ -0,0 +1,52 @@
+// Trade-In Estimator — TypeScript types
+// Mirrors Pydantic schemas from backend/app/api/v1/trade_in.py (TI-1)
+
+export type HouseType =
+ | "panel"
+ | "brick"
+ | "monolith"
+ | "monolith_brick"
+ | "other";
+
+export type RepairState = "needs_repair" | "standard" | "good" | "excellent";
+
+export type ConfidenceLevel = "low" | "medium" | "high";
+
+export interface TradeInEstimateInput {
+ address: string; // min 3, max 500
+ area_m2: number; // 10 < x < 500
+ rooms: number; // 0-10 (0 = студия)
+ floor: number; // 1-100
+ total_floors: number; // 1-100
+ year_built?: number; // 1800-2100
+ house_type?: HouseType;
+ repair_state?: RepairState;
+ has_balcony?: boolean;
+}
+
+export interface AnalogLot {
+ address: string;
+ area_m2: number;
+ rooms: number;
+ floor: number | null;
+ total_floors: number | null;
+ price_rub: number;
+ price_per_m2: number;
+ listing_date: string | null; // ISO date
+ days_on_market: number | null;
+ photo_url: string | null;
+}
+
+export interface AggregatedEstimate {
+ estimate_id: string; // UUID
+ median_price_rub: number;
+ range_low_rub: number;
+ range_high_rub: number;
+ median_price_per_m2: number;
+ confidence: ConfidenceLevel;
+ n_analogs: number;
+ period_months: number; // 24
+ analogs: AnalogLot[]; // top 5-10
+ actual_deals: AnalogLot[]; // last 12 mo
+ expires_at: string; // ISO datetime
+}
diff --git a/ops/db-bootstrap/set_tradein_fdw_password.sql b/ops/db-bootstrap/set_tradein_fdw_password.sql
new file mode 100644
index 00000000..459fb823
--- /dev/null
+++ b/ops/db-bootstrap/set_tradein_fdw_password.sql
@@ -0,0 +1,51 @@
+-- Set tradein_fdw_reader password from env.
+-- Applied by .forgejo/workflows/deploy.yml after main DB migrations:
+-- psql -v pw="$GENDESIGN_FDW_PASSWORD" -f ops/db-bootstrap/set_tradein_fdw_password.sql
+--
+-- Idempotent: ALTER if role exists, NOTICE and continue if missing
+-- (migration 100_tradein_fdw_role.sql might not have applied yet on first deploy).
+-- Password is NEVER stored in this file or in git — only the variable name.
+--
+-- Format %L escapes the password as a SQL string literal — safe even with quotes.
+-- Combined with the regex whitelist on the backend side (fdw.py _PASSWORD_RE)
+-- this gives defense-in-depth.
+--
+-- psql variable substitution (:'pw') НЕ интерполируется внутри dollar-quoted
+-- блока ($$...$$) — это правило psql, не bug. Поэтому password передаём в DO
+-- через сессионный GUC (set_config), который psql интерполирует ВНЕ dollar
+-- quote, и читаем внутри через current_setting().
+-- Reference incident: deploy 2026-05-24 (post-merge PR #503) упал на
+-- "syntax error at or near ':'" в LINE 4 этого файла — :'pw' дошёл до сервера
+-- as literal вместо интерполяции.
+--
+-- ⚠️ `set_config(name, value, is_local) -> text` ВОЗВРАЩАЕТ установленное
+-- значение. Без `\o /dev/null` бы psql напечатал password на stdout → leak в
+-- Forgejo Actions deploy logs (retained, visible всем с repo read access).
+-- Поэтому оборачиваем оба set_config вызова в `\o /dev/null` / `\o` brackets
+-- — output mutes только для этих строк, NOTICE-сообщения из DO block
+-- (idempotency signal) остаются видимыми.
+--
+-- Rollback path: НЕ revert этого файла (вернёт сломанный `:'pw'` внутри $$).
+-- Корректный rollback — unset GENDESIGN_FDW_PASSWORD в /opt/gendesign/backend
+-- /.env.runtime на VPS, deploy.yml тогда пропустит этот шаг полностью.
+
+\o /dev/null
+SELECT set_config('app.fdw_pw', :'pw', false);
+\o
+
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tradein_fdw_reader') THEN
+ EXECUTE format('ALTER ROLE tradein_fdw_reader WITH PASSWORD %L', current_setting('app.fdw_pw'));
+ RAISE NOTICE 'tradein_fdw_reader password set';
+ ELSE
+ RAISE NOTICE 'tradein_fdw_reader role missing — migration 100_tradein_fdw_role.sql not applied yet';
+ END IF;
+END $$;
+
+-- Clear GUC after use (defense-in-depth — не оставляем password в session state
+-- даже на short connection). Same \o trick — set_config return value is empty
+-- string here, но всё равно лишний row в stdout не нужен.
+\o /dev/null
+SELECT set_config('app.fdw_pw', '', false);
+\o
diff --git a/ops/glitchtip-auth-forwarder/Dockerfile b/ops/glitchtip-auth-forwarder/Dockerfile
new file mode 100644
index 00000000..963dab2c
--- /dev/null
+++ b/ops/glitchtip-auth-forwarder/Dockerfile
@@ -0,0 +1,15 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+# Устанавливаем только sentry-sdk без лишних extras
+RUN pip install --no-cache-dir "sentry-sdk==2.18.0"
+
+COPY forwarder.py .
+
+# Запускается под root — необходимо для чтения /var/log/caddy/gendsgn.ru.log
+# (Caddy пишет лог под UID 0). Mounts: caddy_logs:ro + auth_forwarder_state (write).
+# Attack surface минимален: нет network listener, нет shell, образ python:3.12-slim.
+
+# -u для unbuffered stdout — docker logs виден сразу без буферизации
+CMD ["python", "-u", "forwarder.py"]
diff --git a/ops/glitchtip-auth-forwarder/forwarder.py b/ops/glitchtip-auth-forwarder/forwarder.py
new file mode 100644
index 00000000..8fc3c5b4
--- /dev/null
+++ b/ops/glitchtip-auth-forwarder/forwarder.py
@@ -0,0 +1,365 @@
+"""
+Caddy access log -> GlitchTip auth event forwarder.
+
+Tails /var/log/caddy/auth_audit.log (CADDY_LOG_FILE env), фильтрует JSON lines с
+status==401, шлёт в GlitchTip через Sentry SDK как warning event с тэгами.
+
+Persistent offset в /state/offset.json — не дублируем при restart.
+Throttle: при >10 401 events за 60s — однократный digest event
+(чтобы не флудить GlitchTip storm'ом); индивидуальные events во время storm пропускаются.
+
+Реальный Caddy JSON access log (v2) структура:
+{
+ "level": "info",
+ "ts": 1779524882.734,
+ "logger": "http.log.access.log0",
+ "msg": "handled request",
+ "request": {
+ "remote_ip": "95.165.147.218",
+ "remote_port": "50796",
+ "client_ip": "95.165.147.218",
+ "proto": "HTTP/2.0",
+ "method": "GET",
+ "host": "gendsgn.ru",
+ "uri": "/",
+ "headers": {
+ "User-Agent": ["Mozilla/5.0 ..."],
+ "Authorization": ["Basic dXNlcjpwYXNz"], # raw — при включённом log_credentials в Caddyfile
+ ...
+ },
+ "tls": {...}
+ },
+ "user_id": "",
+ "duration": 0.000134,
+ "size": 0,
+ "status": 401,
+ "resp_headers": {...}
+}
+"""
+
+import base64
+import json
+import os
+import signal
+import sys
+import tempfile
+import time
+from collections import deque
+from datetime import datetime, timezone
+from pathlib import Path
+
+import sentry_sdk
+
+LOG_FILE = Path(os.getenv("CADDY_LOG_FILE", "/var/log/caddy/gendsgn.ru.log"))
+STATE_FILE = Path(os.getenv("STATE_FILE", "/state/offset.json"))
+DSN = os.environ["GLITCHTIP_DSN"] # required, fail-fast
+ENV = os.getenv("APP_ENV", "production")
+POLL_INTERVAL_S = 2
+THROTTLE_WINDOW_S = 60
+THROTTLE_THRESHOLD = 10 # >10 events за 60s -> digest mode
+
+# Известные боты которые получают 401 от Uptime мониторов —
+# логируем их только в digest, не как individual events.
+KNOWN_MONITOR_UAS = frozenset(
+ [
+ "GlitchTip/",
+ "SentryUptimeBot/",
+ ]
+)
+
+_shutdown = False
+# Throttle для unhandled exceptions — не более одного Sentry event за 5 минут,
+# чтобы persistent ошибка (напр. PermissionError) не спамила GlitchTip.
+_last_exc_sent: float = 0.0
+_EXC_THROTTLE_S: float = 300.0
+
+
+def _signal_handler(signum: int, frame: object) -> None:
+ global _shutdown
+ _shutdown = True
+
+
+def load_offset() -> int:
+ if STATE_FILE.exists():
+ try:
+ data = json.loads(STATE_FILE.read_text())
+ return int(data.get("offset", 0))
+ except Exception:
+ pass
+ return 0
+
+
+def save_offset(offset: int) -> None:
+ """Atomic write через temp file + rename — избегаем corrupt state при kill."""
+ STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
+ payload = json.dumps(
+ {"offset": offset, "updated_at": datetime.now(timezone.utc).isoformat()}
+ )
+ # Пишем в temp рядом с целевым файлом (тот же volume -> атомарный rename)
+ fd, tmp_path = tempfile.mkstemp(dir=STATE_FILE.parent, prefix=".offset_tmp_")
+ try:
+ with os.fdopen(fd, "w") as f:
+ f.write(payload)
+ os.replace(tmp_path, STATE_FILE)
+ except Exception:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ raise
+
+
+def is_monitor_ua(ua: str) -> bool:
+ """Возвращает True если User-Agent принадлежит известному монитору."""
+ return any(ua.startswith(prefix) for prefix in KNOWN_MONITOR_UAS)
+
+
+def _extract_attempted_username(entry: dict) -> str | None: # type: ignore[type-arg]
+ """
+ Извлекает username из Authorization header (Basic auth) из Caddy access log.
+
+ Требования к Caddyfile:
+ - global option `log_credentials` (иначе Caddy редактирует Authorization → "REDACTED")
+ - этот forwarder ожидает auth_audit.log как primary source (CADDY_LOG_FILE env)
+
+ Возвращает только username (всё до ':' в base64-decoded auth).
+ Password нигде не сохраняется, не логируется.
+
+ None если: header отсутствует, не Basic, base64 decode fail, нет ':', > 128 chars.
+ """
+ req = entry.get("request") or {}
+ headers = req.get("headers") or {}
+ auth_values = headers.get("Authorization") or headers.get("authorization")
+ if not auth_values:
+ return None
+ if isinstance(auth_values, list):
+ if not auth_values:
+ return None
+ auth = auth_values[0]
+ else:
+ auth = auth_values
+ if not isinstance(auth, str) or not auth.lower().startswith("basic "):
+ return None
+ try:
+ decoded = base64.b64decode(auth[6:], validate=True).decode("utf-8", errors="replace")
+ except Exception:
+ return None
+ if ":" not in decoded:
+ return None
+ username = decoded.split(":", 1)[0]
+ # Sanity check: только разумная длина
+ if not username or len(username) > 128:
+ return None
+ return username
+
+
+def emit_event(entry: dict) -> None: # type: ignore[type-arg]
+ """Отправляет одиночный 401 event в GlitchTip."""
+ req = entry.get("request", {})
+ path = req.get("uri", "?")
+ method = req.get("method", "?")
+ # client_ip — реальный IP (за прокси); remote_ip — подключающийся (docker network)
+ remote_ip = req.get("client_ip") or req.get("remote_ip", "?")
+ host = req.get("host", "?")
+ # headers.User-Agent — список строк по Caddy JSON schema
+ headers = req.get("headers") or {}
+ ua_list = headers.get("User-Agent", [])
+ ua = ua_list[0] if ua_list else "?"
+ ts = entry.get("ts")
+ user_id = entry.get("user_id", "")
+ attempted_username = _extract_attempted_username(entry)
+
+ with sentry_sdk.new_scope() as scope:
+ scope.set_tag("event_type", "basic_auth_failed")
+ scope.set_tag("http_method", method)
+ scope.set_tag("remote_ip", remote_ip)
+ if attempted_username:
+ scope.set_tag("attempted_username", attempted_username)
+ scope.set_context(
+ "caddy_log",
+ {
+ "uri": path,
+ "method": method,
+ "host": host,
+ "remote_ip": remote_ip,
+ "user_agent": ua,
+ "caddy_ts": ts,
+ "user_id": user_id or "(none)",
+ "attempted_username": attempted_username or "(none)",
+ },
+ )
+ message = f"basic_auth 401 — {method} {path} from {remote_ip}"
+ if attempted_username:
+ message += f" (tried: {attempted_username})"
+ sentry_sdk.capture_message(message, level="warning")
+
+
+def emit_digest(count: int, window_s: int, monitor_count: int) -> None:
+ """Отправляет digest event при storm'е."""
+ with sentry_sdk.new_scope() as scope:
+ scope.set_tag("event_type", "basic_auth_storm")
+ sentry_sdk.capture_message(
+ f"basic_auth storm — {count} failed attempts in {window_s}s"
+ + (f" (including {monitor_count} monitor probes)" if monitor_count else ""),
+ level="error",
+ )
+
+
+def is_401_event(entry: dict) -> bool: # type: ignore[type-arg]
+ """Проверяет что entry — failed auth (401)."""
+ return entry.get("status") == 401
+
+
+def main() -> None:
+ signal.signal(signal.SIGTERM, _signal_handler)
+ signal.signal(signal.SIGINT, _signal_handler)
+
+ sentry_sdk.init(
+ dsn=DSN,
+ environment=ENV,
+ release=os.getenv("APP_RELEASE", "auth-forwarder-1"),
+ traces_sample_rate=0.0,
+ attach_stacktrace=False,
+ send_default_pii=False,
+ # Отключаем интеграции которые не нужны тонкому sidecar
+ default_integrations=False,
+ )
+
+ print(
+ f"[forwarder] starting; log={LOG_FILE}; state={STATE_FILE}; env={ENV}",
+ flush=True,
+ )
+
+ # Ожидаем появления лог-файла (Caddy пишет его при первом запросе)
+ if not LOG_FILE.exists():
+ print(
+ f"[forwarder] log file {LOG_FILE} does not exist yet, waiting...",
+ flush=True,
+ )
+ while not LOG_FILE.exists() and not _shutdown:
+ time.sleep(2)
+ if _shutdown:
+ return
+
+ offset = load_offset()
+ file_size = LOG_FILE.stat().st_size
+ # Если лог был ротирован или обрезан — сбрасываем offset
+ if offset > file_size:
+ print(
+ f"[forwarder] offset={offset} > file_size={file_size}, log rotated, reset to 0",
+ flush=True,
+ )
+ offset = 0
+ save_offset(offset)
+
+ # Sliding window для throttle
+ recent_timestamps: deque[float] = deque()
+ monitor_timestamps: deque[float] = deque()
+ digest_sent = False
+
+ print(f"[forwarder] starting tail from offset={offset}", flush=True)
+
+ while not _shutdown:
+ try:
+ current_size = LOG_FILE.stat().st_size
+ # Log rotation detection: файл стал меньше чем наш offset
+ if current_size < offset:
+ print("[forwarder] log rotation detected, resetting offset to 0", flush=True)
+ offset = 0
+ save_offset(offset)
+ recent_timestamps.clear()
+ monitor_timestamps.clear()
+ digest_sent = False
+
+ if current_size > offset:
+ with LOG_FILE.open("rb") as f:
+ f.seek(offset)
+ for raw_line in f:
+ if _shutdown:
+ break
+ line = raw_line.decode("utf-8", errors="replace").strip()
+ if not line:
+ continue
+ try:
+ entry = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+
+ if not is_401_event(entry):
+ continue
+
+ now = time.monotonic()
+
+ # Определяем User-Agent для классификации
+ req = entry.get("request", {})
+ headers = req.get("headers") or {}
+ ua_list = headers.get("User-Agent", [])
+ ua = ua_list[0] if ua_list else ""
+ is_monitor = is_monitor_ua(ua)
+
+ # Обновляем скользящее окно
+ recent_timestamps.append(now)
+ if is_monitor:
+ monitor_timestamps.append(now)
+
+ cutoff = now - THROTTLE_WINDOW_S
+ while recent_timestamps and recent_timestamps[0] < cutoff:
+ recent_timestamps.popleft()
+ while monitor_timestamps and monitor_timestamps[0] < cutoff:
+ monitor_timestamps.popleft()
+
+ window_count = len(recent_timestamps)
+
+ if window_count > THROTTLE_THRESHOLD:
+ # Storm mode: один digest, индивидуальные events пропускаем
+ if not digest_sent:
+ emit_digest(
+ window_count,
+ THROTTLE_WINDOW_S,
+ len(monitor_timestamps),
+ )
+ digest_sent = True
+ print(
+ f"[forwarder] storm digest sent ({window_count} events)",
+ flush=True,
+ )
+ else:
+ digest_sent = False
+ # Мониторы (GlitchTip uptime, SentryUptimeBot) получают 401 постоянно —
+ # не шлём их как individual events, только учитываем в throttle
+ if not is_monitor:
+ emit_event(entry)
+ remote_ip = req.get("client_ip") or req.get("remote_ip", "?")
+ path = req.get("uri", "?")
+ print(
+ f"[forwarder] 401 event sent: {remote_ip} -> {path}",
+ flush=True,
+ )
+
+ offset = f.tell()
+
+ save_offset(offset)
+
+ time.sleep(POLL_INTERVAL_S)
+
+ except FileNotFoundError:
+ print("[forwarder] log file disappeared, waiting...", flush=True)
+ time.sleep(5)
+ except Exception as exc:
+ print(f"[forwarder] unhandled error: {exc}", file=sys.stderr, flush=True)
+ global _last_exc_sent
+ now_wall = time.time()
+ if now_wall - _last_exc_sent > _EXC_THROTTLE_S:
+ try:
+ sentry_sdk.capture_exception(exc)
+ except Exception:
+ pass
+ _last_exc_sent = now_wall
+ time.sleep(5)
+
+ print("[forwarder] shutting down gracefully", flush=True)
+ sentry_sdk.flush(timeout=5)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ops/glitchtip-auth-forwarder/pyproject.toml b/ops/glitchtip-auth-forwarder/pyproject.toml
new file mode 100644
index 00000000..8dd6f9b8
--- /dev/null
+++ b/ops/glitchtip-auth-forwarder/pyproject.toml
@@ -0,0 +1,5 @@
+[project]
+name = "glitchtip-auth-forwarder"
+version = "0.1.0"
+requires-python = ">=3.12"
+dependencies = ["sentry-sdk>=2.0"]
diff --git a/preview/analytics.html b/preview/analytics.html
new file mode 100644
index 00000000..da1f38d1
--- /dev/null
+++ b/preview/analytics.html
@@ -0,0 +1,588 @@
+
+
+
+
+
+/analytics — Свердл рынок · gendsgn
+
+
+
+
+
+
+
+
+
+
+
+
+ gendsgn
+ live · обновлено 4 ч назад
+
+
+ Свердловская обл. · 2025-Q1
+ AD Алексей · PRINZIP (demo)
+
+
+
+
+
+
+
+
+
+
+ Свердл рынок — обзор спроса и предложения
+ 6 832 411 ДДУ Росреестра · 1 482 объекта ДОМ.РФ в активной фазе · обновление еженедельно
+
+
+
+
+
+
+
+
+
+
+ вердикт квартала
+ Дефицит средне-большого жилья 80+ м²: 37% сделок Росреестра, но 11% портфеля ДОМ.РФ. Окно сужается к Q3 2026.
+ источник: ДОМ.РФ · Росреестр · март 2026
+
+
+
+
+ Объём строительства
+ 5,82млн м²
+ в активной фазе по области
+ ↑ 4,1% YoY
+
+
+ Sold % (медиана)
+ 61,4 %
+ объекты в продаже > 6 мес
+ ↓ 3,2 пп QoQ
+
+
+ Средняя цена
+ 186тыс ₽/м²
+ первичка, март 2026
+ ↑ 1,8% MoM
+
+
+ Активных новостроек
+ 347
+ в открытой продаже Я.Недв
+ +12 за месяц
+
+
+
+
+
+
+ Карта рынка — новостройки Свердловской области
+ 347 объектов в активной продаже · цвет точки — sold % за 6 месяцев · клик → drill-in
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sold % за 6 мес
+ < 40 % · риск затоваривания
+ 40–65 % · средний темп
+ 65–80 % · хороший темп
+ 80+ % · близко к закрытию
+
+
+
+ Карта стилизована для превью. В продукте — Leaflet с базой 2GIS, клик → /analytics/objects/[id]
+
+
+
+
+
+ Динамика рынка — 14 месяцев
+ Объём ДДУ (Росреестр) и две линии: sold % портфеля и средняя цена м²
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Парадокс портфеля по комнатности
+ Доля сегмента в портфеле ДОМ.РФ против доли в сделках Росреестра за 14 мес
+
+
+
+
+
+
+
+
+
+
+ Сегмент
+ портфель ДОМ.РФ vs сделки Росреестра
+ Δ пп
+
+
+ Студии до 30 м²
+
+ −15
+
+
+ 1-комн. 30–45 м²
+
+ −7
+
+
+ 2-комн. 45–65 м²
+
+ +1
+
+
+ 3-комн. 65–85 м²
+
+ +11
+
+
+ 4+ комн. / 85+ м²
+
+ +9
+
+
+
+
+
+
+
+
+ Pipeline по году ввода
+ Объём (тыс. м²) в разрезе классов
+
+
+
+
+
+
+
+ комфорт
+ бизнес
+ премиум
+ элит
+
+
+
+
+
+
+ Активные новостройки
+ Топ-7 по объёму · Я.Недвижимость · обновлено 4 ч назад
+
+
+
+
+
+
+
+
+
+
+
+
+ Концентрация ЖК по районам
+ Площадь плитки — объём строительства · цвет — sold % · клик → топ девелоперов района
+
+
+
+
+
+
+
+
+
+
+
+ !
+
+ Caveat по данным. Sold % считается по объектам в продаже более 6 месяцев; новые ЖК со стартом < 6 мес не входят в медиану, но видны на карте. Росреестр может задерживать ДДУ до 14 дней — берём 7-дневный backfill при еженедельном пересчёте.
+
+
+
+
+ Развёрнутый разрез по 17 районам области
+ Sold %, средний срок, цена м², объём в работе — по каждому из 17 районов с фильтром по классу. Открывается отдельной таблицей в /analytics/districts.
+
+
+ Методология подсчёта sold %
+ Sold % = (общее число проданных лотов по объекту) / (общее число лотов в продаже). Источник: ДДУ Росреестра матчатся с лотами портфеля ДОМ.РФ по cadastral_id + comm_name fuzzy match (порог 0.85, pg_trgm).
+
+
+ Сравнение с январём 2025 (год к году)
+ Sold % медиана: 64,6 % → 61,4 % (−3,2 пп). Объём строительства: 5,59 → 5,82 млн м² (+4,1 %). Средняя цена: 166 → 186 тыс ₽/м² (+12 %). Разрыв спрос-предложение в 3к/4+ сегменте увеличился: с +14 пп до +20 пп суммарно.
+
+
+
+
+
+
diff --git a/preview/landing.html b/preview/landing.html
new file mode 100644
index 00000000..4aef4304
--- /dev/null
+++ b/preview/landing.html
@@ -0,0 +1,646 @@
+
+
+
+
+
+gendsgn — аналитика спроса для девелоперов
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Цифры рынка перед тем, как заливать фундамент.
+ Что строят на рынке и что на самом деле продаётся — по 6,83 млн ДДУ Росреестра, портфелю ДОМ.РФ и активным новостройкам Яндекс.Недвижимости. Свердловская область, обновление еженедельно.
+
+ 3 из 3 пилотных мест открыто · старт пилота — 2 недели · фикс-цена 30 000 ₽/мес
+
+
+
+
+
+
+
+ Вердикт квартала
+ Дефицит средне-большого жилья 80+ м²: 37% сделок Росреестра, но 11% портфеля ДОМ.РФ. Окно сужается к Q3 2026.
+ данные: ДОМ.РФ · Росреестр · март 2026
+
+
+
+
+
+ Объём строительства
+ 5,8 млн м²
+ Свердловская обл., активная фаза
+ ↑ 4,1% YoY
+
+
+ Sold % (медиана)
+ 61,4 %
+ по объектам в продаже > 6 мес
+ ↓ 3,2 пп QoQ
+
+
+ Средняя цена
+ 186 тыс ₽
+ за м², первичка, март 2026
+ ↑ 1,8% MoM
+
+
+ Активных новостроек
+ 347
+ в Яндекс.Недв · в открытой продаже
+ +12 за месяц
+
+
+
+
+
+
+ Парадокс портфеля: что строят vs что продаётся
+ Доля каждого сегмента в активном портфеле ДОМ.РФ против доли в сделках Росреестра за 14 месяцев
+
+ Открыть в /analytics →
+
+
+
+
+
+
+
+ Студии до 30 м²
+
+ −15 пп
+
+
+ 1-комн. 30–45 м²
+
+ −7 пп
+
+
+ 2-комн. 45–65 м²
+
+ +1 пп
+
+
+ 3-комн. 65–85 м²
+
+ +11 пп
+
+
+ 4+ комн. / 85+ м²
+
+ +9 пп
+
+
+
+
+ Иллюстративные данные пилотной выборки. В продуктовой версии — разрез по 17 районам области, классу жилья и году ввода. Источник: ДОМ.РФ (портфель в активной фазе строительства), Росреестр (сделки ДДУ за 14 мес).
+
+
+
+
+
+
+ Три рабочих модуля
+ Не «features», а конкретные вопросы, на которые финдиректор девелопера ищет ответ
+
+
+
+
+
+
+
+ Аналитика спроса
+ /analytics
+
+ «В каких районах и сегментах сделки опережают предложение?» Разбивка sold % по комнатности, классу, году ввода. Бенчмарк против топ-15 девелоперов области.
+
+
+ район / сегментΔ ппстатус
+ Академический · 3к+14дефицит
+ ВИЗ · студии−12избыток
+ Уралмаш · 2к+3баланс
+
+
+
+
+
+
+
+ Рекомендатор площадки
+ /recommend
+
+ «Какую квартирографию закладывать на участке в Академическом, чтобы выйти за 22 месяца?» Mix по комнатности, прогноз срока 80 % продаж, расчёт выручки.
+
+
+
+
+
+
+
+
+
+
+ Топ-15 девелоперов
+ /developers
+
+ «Кто продаёт быстрее в моём районе и за счёт чего?» Velocity-карта по площади и Δ sold %, фильтр по классу. Drill-down до объекта.
+
+
+ PRINZIPsold 74 %
+ Брусникаsold 68 %
+ Атомстройком.sold 52 %
+ КОРТРОСsold 41 %
+
+
+
+
+
+
+
+
+
+
+ Пилот — 30 000 ₽ в месяц
+ Один тариф, без апсейлов. 3 из 3 пилотных мест открыто, ввод данных за 2 недели.
+
+
+
+
+
+ Пилот · 6 месяцев
+ gendsgn для одного девелопера
+ Полный доступ к трём модулям. Свердловская область как первый регион, остальные — по запросу.
+ 30 000 ₽/ мес · фикс
+
+ - Аналитика спроса по 17 районам области
+ - Рекомендатор площадки с прогнозом срока продаж
+ - Бенчмарк против топ-15 девелоперов
+ - Еженедельный апдейт данных Росреестра
+ - Экспорт в Excel и CSV для финмодели
+ - Запрос новых разрезов от пилотного клиента — приоритет
+
+ Запросить пилот
+ Контакт менеджера — Алексей Демидов · al@gendsgn.ru
+
+
+ После пилота
+ Региональная лицензия
+ Когда пилот закроется и продукт перейдёт к платным клиентам.
+ от 120 000 ₽/ мес · region-pack
+
+ - Подключение дополнительных регионов
+ - API для отдела финансового планирования
+ - Бенчмарк против выбранных конкурентов
+ - Кастомные разрезы и custom alerting
+
+ Запросить расчёт
+ Расчёт индивидуально, после квалификации пилота
+
+
+
+
+ Честный дисклеймер: мы pre-revenue. Сайт показывает реальный продукт в работе на ваших данных. До первой ARR-метрики команда живёт за счёт основателей, и пилотных клиентов мы выбираем сами, а не наоборот.
+
+
+
+
+
+ Частые возражения
+ Что обычно спрашивают финдиректора прежде, чем согласиться на пилот
+
+
+
+
+
+ Если ваше возражение не разобрано — напишите на al@gendsgn.ru, ответим за 1 рабочий день.
+
+
+
+ Чем вы отличаетесь от собственной BI-команды?
+ Мы не делаем дашборд под отдел — мы продаём готовый рыночный разрез по 6,83 млн ДДУ и портфелю ДОМ.РФ, который ваша BI собирала бы сама 4–6 месяцев. Внутренняя команда нужна, чтобы интерпретировать наши цифры в контексте конкретного проекта.
+
+
+ Откуда у вас данные Росреестра и ДОМ.РФ?
+ ДОМ.РФ — открытый портфель новостроек. Росреестр — публичные ДДУ через выписки ЕГРН и партнёрские интеграции (без серых источников). Яндекс.Недвижимость — публичный листинг активных объектов в продаже.
+
+
+ Что входит в «обновление 7 дней»?
+ Раз в неделю — пересчёт sold %, добавление новых ДДУ, обновление цен по активным объектам. Раз в месяц — пересмотр квартирографии и портфеля ДОМ.РФ. Любое окно < 7 дней — кешируем предыдущий снимок и помечаем «stale».
+
+
+ Почему только Свердловская область?
+ Мы pre-revenue и заточили пайплайн под один регион, чтобы доказать ROI за 6 месяцев пилота. Добавление второго региона — около 6 недель работы на наш бэк. Если в пилот зайдёт девелопер другой области — мы её добавим в очередь.
+
+
+ Можно ли использовать данные в внутреннем финмоделировании?
+ Да. Все ключевые разрезы экспортируются в Excel и CSV (sold %, цены, mix). Лицензия пилота позволяет использовать выгрузки во внутреннем финмодуле без отдельного согласования.
+
+
+ Что будет после 6 месяцев пилота?
+ Совместный отчёт по конкретным cases (где наши рекомендации совпали со сделкой, где разошлись), и переход на регулярную лицензию либо корректное расставание. Никаких автоматических продлений с отдельным счётом.
+
+
+
+
+
+
+
+
+
+
diff --git a/preview/monitoring.html b/preview/monitoring.html
new file mode 100644
index 00000000..c58ee970
--- /dev/null
+++ b/preview/monitoring.html
@@ -0,0 +1,518 @@
+
+
+
+
+
+/analytics/developers — Топ-15 девелоперов · gendsgn
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Свердловская обл. · 2025-Q1
+ AD Алексей · PRINZIP (demo)
+
+
+
+
+
+
+
+
+
+
+ Топ-15 девелоперов Свердловской области
+ Выбран: PRINZIP · кликните на строку leaderboard, чтобы сравнить с другим девелопером
+
+
+
+
+
+
+
+
+
+
+
+ Девелопер
+ PRINZIP
+ Екатеринбург · с 2007 г.
+
+ комфорт
+ бизнес
+
+
+
+ Объём в Свердл
+ 412тыс м²
+ 14 ЖК в активной фазе
+ ↑ +28 тыс vs Q4 2025
+
+
+ Sold % (медиана)
+ 74%
+ по 14 ЖК в продаже
+ +13 пп vs рынок (61 %)
+
+
+ Средний метраж
+ 58м²
+ взвешенно по лотности
+ −9 м² vs рынок (67 м²)
+
+
+
+
+
+
+
+ Leaderboard — топ-15 по объёму
+ Sticky первая колонка при scroll · клик по строке → drawer с профилем девелопера
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Девелопер |
+ Объём, тыс м² |
+ ЖК |
+ Sold % |
+ Δ 14 мес |
+ Ср. цена ₽/м² |
+ Ср. метраж |
+ Топ-район |
+
+
+
+ | 01 | PRINZIP 14 ЖК · комфорт+бизнес | 412 | 14 | 74 % | +8,2 пп | 173 | 58 | Академический |
+ | 02 | Брусника 11 ЖК · комфорт+бизнес | 368 | 11 | 71 % | +5,1 пп | 198 | 61 | Центр |
+ | 03 | Атомстройкомплекс 9 ЖК · комфорт | 342 | 9 | 64 % | +2,8 пп | 156 | 54 | ВИЗ |
+ | 04 | Форум-групп 8 ЖК · комфорт+бизнес | 298 | 8 | 62 % | +0,4 пп | 182 | 63 | Юго-Запад |
+ | 05 | КОРТРОС 6 ЖК · бизнес+премиум | 284 | 6 | 54 % | −3,2 пп | 224 | 72 | Пионерский |
+ | 06 | Атлас Девелопмент 7 ЖК · комфорт | 241 | 7 | 52 % | −4,8 пп | 141 | 52 | Уралмаш |
+ | 07 | Эталон 5 ЖК · бизнес | 198 | 5 | 48 % | −6,1 пп | 167 | 59 | Центр |
+ | 08 | Унистрой 5 ЖК · комфорт | 182 | 5 | 67 % | +3,4 пп | 148 | 55 | Уктус |
+ | 09 | Атмосфера 4 ЖК · комфорт | 156 | 4 | 69 % | +4,1 пп | 152 | 57 | Пионерский |
+ | 10 | Эфес 4 ЖК · бизнес+премиум | 142 | 4 | 58 % | +0,8 пп | 211 | 76 | Центр |
+ | 11 | Семь 3 ЖК · комфорт | 128 | 3 | 72 % | +6,7 пп | 167 | 56 | ВИЗ |
+ | 12 | Гринстрой 3 ЖК · комфорт | 116 | 3 | 49 % | −5,4 пп | 134 | 51 | Сортировка |
+ | 13 | Лидер 3 ЖК · комфорт | 104 | 3 | 61 % | +1,2 пп | 158 | 54 | Уралмаш |
+ | 14 | Гранд 3 ЖК · бизнес | 96 | 3 | 44 % | −8,1 пп | 189 | 68 | Юго-Запад |
+ | 15 | УГМК-Девел. 2 ЖК · бизнес+премиум | 84 | 2 | 56 % | +0,2 пп | 231 | 74 | Верх. Пышма |
+
+
+
+
+
+
+
+
+
+
+ Velocity-карта — объём против Δ sold %
+ Кто продаёт быстрее в своём масштабе · ось X — тыс. м² в активной фазе · ось Y — Δ sold % за 14 мес
+
+
+
+
+
+
+
+
+
+
+ Δ sold % за 14 мес
+ Объём, тыс. м² в активной фазе
+
+
+ Размер точки — число ЖК в портфеле
+ Цвет — статус velocity (зелёный — растёт, красный — падает)
+ 38 / 40 ЖК в выборке · 2 объекта < 6 мес в продаже исключены
+
+
+
+
+
+
+
+ Сравнение sold % — PRINZIP vs топ-2
+ Кумулятивный sold % помесячно по всему портфелю; PRINZIP жирнее, Брусника и Атомстройком. для контекста
+
+
+
+
+
+
+
+
+
+
+
+
+ PRINZIP 74 % · +8,2 пп
+ Брусника 71 % · +5,1 пп
+ Атомстройком. 64 % · +2,8 пп
+
+
+
+
+
+
+
+ Карточка выбранного девелопера — PRINZIP
+ Открывается drawer'ом справа при клике на строку leaderboard (не navigate)
+
+
+
+
+
+ Концентрация по районам
+
+
+
+ Распределение по комнатности
+
+
+ Gap vs рынок: доля 3к/4+ — 20 % vs 44 % рыночного спроса. Недозагрузка в большом сегменте.
+
+
+
+ Топ-5 ЖК · drill-in
+
+
+
+
+
+
+
+
+
diff --git a/preview/site-finder.html b/preview/site-finder.html
new file mode 100644
index 00000000..43b39092
--- /dev/null
+++ b/preview/site-finder.html
@@ -0,0 +1,597 @@
+
+
+
+
+
+/analytics/recommend — Рекомендатор площадки · gendsgn
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Свердловская обл. · 2025-Q1
+ AD Алексей · PRINZIP (demo)
+
+
+
+
+
+
+
+
+
+
+ Рекомендатор площадки
+ Mix квартир по комнатности, прогноз срока 80 % продаж, расчёт выручки на основе sold % сегмента и района
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ рекомендация
+ Академический · комфорт · 12 000 м²: mix 32/26/22/15/5 · срок 80 % продаж 22 мес · выручка 2,4 млрд ₽
+ пересчитано: 12 сек назад
+
+
+
+
+
+ Выручка
+ 2,4млрд ₽
+ по медианной цене района ×1,00
+ ↑ +180 млн vs дефолтный mix
+
+
+ Срок 80 % продаж
+ 22мес
+ target 24 мес — внутри окна
+ опережает target на 2 мес
+
+
+ Liquidity score
+ 7,8/ 10
+ взвешенно по sold % сегментов
+ в топ-30 % площадок района
+
+
+
+
+
+
+
+ Velocity Panel — влияние цены
+ Сдвиньте price factor, чтобы увидеть, как меняется срок 80 % продаж и выручка
+
+
+
+
+ Price factor (× медианы района)1,00
+
+ 0,750,850,951,001,101,201,30
+
+ При factor 0,90 срок падает до 18 мес, выручка снижается до 2,16 млрд ₽ (−240 млн). При 1,15 срок растёт до 31 мес — выходим из окна. Сладкое пятно — 0,95–1,05.
+
+
+
+
+
+
+
+
+ !
+
+ Caveat по прогнозу. Срок продаж предсказывается по выборке из 38 ЖК комфорт-класса в Академическом за последние 14 мес. Доверительный интервал p25/p75: 19–26 мес. Прогноз чувствителен к курсу ипотеки — при ставке > 18 % сместите окно на +6 мес.
+
+
+
+
+
+ 5 KPI района — Академический · комфорт
+
+
+ Конкуренты 11 ЖК в открытой продаже
+ Velocity покрытие 94 % 38 / 40 объектов в выборке
+ Sold % медиана 71 % по комфорт-классу района
+ Тренд 6 мес +4,2 пп sold % растёт
+ Class × District A− приоритет mix-инвестиций
+
+
+
+
+
+ Mix квартир — рекомендованное распределение
+
+
+
+ Студии до 30 м²
+
+ 5 %
+ ≈ 38 кв
+
+
+ 1-комн. 30–45 м²
+
+ 15 %
+ ≈ 115 кв
+
+
+ 2-комн. 45–65 м²
+
+ 22 %
+ ≈ 168 кв
+
+
+ 3-комн. 65–85 м²
+
+ 26 %
+ ≈ 198 кв
+
+
+ 4+ комн. / 85+ м²
+
+ 32 %
+ ≈ 244 кв
+
+
+
+
+
+
+
+ Liquidity по месяцам — 36 мес прогноз
+
+
+
+
+ Прогноз кумулятивного sold % по месяцам с учётом mix. Полоса — доверительный интервал p25/p75 на основе выборки 38 ЖК комфорт-класса района.
+
+
+
+
+ Buckets — графика + таблица + Export Excel
+
+
+
+
+
+
+
+ | Сегмент | Кв | м² | Выручка |
+
+
+ | студии | 38 | 1 140 | 180 М |
+ | 1-к | 115 | 4 600 | 740 М |
+ | 2-к | 168 | 9 240 | 1,49 Б |
+ | 3-к | 198 | 14 850 | 2,39 Б |
+ | 4+ | 244 | 29 970 | 4,82 Б |
+ | Итого | 763 | 59 800 | 9,62 Б |
+
+
+
+
+ Цифры в подсказке headline-bar — это выручка по сценарию price factor 1,00. Полная выгрузка — Export Excel.
+
+
+
+
+
+
+ Comparables — топ-5 похожих ЖК в районе
+
+
+
+
+ 78 %
+ 22 мес
+ 173 ₽
+ 1,4 Б
+
+
+
+ 71 %
+ 24 мес
+ 198 ₽
+ 1,8 Б
+
+
+ СветлыйАтомстройком. · комфорт · 962 кв
+ 68 %
+ 26 мес
+ 156 ₽
+ 0,9 Б
+
+
+
+ 62 %
+ 28 мес
+ 162 ₽
+ 0,7 Б
+
+
+ УютАтлас Девел. · комфорт · 612 кв
+ 52 %
+ 31 мес
+ 141 ₽
+ 0,5 Б
+
+
+ Колонки: sold %, срок 80 %, ср. цена ₽/м², оценочная выручка. Клик по названию → /analytics/objects/[id].
+
+
+
+
+ Caveats — что важно учесть
+
+
+ - • Выборка для прогноза — только ЖК с лотностью > 200 кв; малоквартирные клубные дома (< 100 кв) дают другой режим спроса.
+ - • Расчёт не учитывает паркинг и кладовые — добавьте 4–7 % к выручке при наличии паркинга 1:1.
+ - • Прогноз 36 мес — экстраполяция; при горизонте > 24 мес доверительный интервал расширяется до p10/p90.
+ - • Mix чувствителен к политике ипотеки: при ставке > 18 % спрос смещается в студии и 1-к — пересмотрите mix.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/auth/add_user.sh b/scripts/auth/add_user.sh
new file mode 100644
index 00000000..6232595f
--- /dev/null
+++ b/scripts/auth/add_user.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+# add_user.sh — добавить пользователя в caddy/users.caddy.snippet
+# Usage: ./scripts/auth/add_user.sh ""
+#
+# - Читает пароль из stdin (pipe) или генерирует 16-char random если интерактивен.
+# - Bcrypt через `docker run --rm caddy:2 caddy hash-password`.
+# - Печатает plain password на stdout ОДИН РАЗ.
+# - НЕ коммитит изменения — это задача разработчика.
+set -euo pipefail
+
+# Скрипт лежит в `/scripts/auth/`; snippet — в `/caddy/`.
+# Нужно 3× `dirname`: $0 (.../scripts/auth/add_user.sh) → .../scripts/auth → .../scripts → .../.
+# Bug fix 2026-05-26 — было 2× dirname (см. [[Bug_AddUser_Sh_Snippet_Path_Fixed]]).
+SCRIPT_DIR="$(cd "$(dirname "$(realpath "$0")")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+SNIPPET="$REPO_ROOT/caddy/users.caddy.snippet"
+
+usage() {
+ echo "Usage: $0 \"\"" >&2
+ exit 1
+}
+
+# ─── args ────────────────────────────────────────────────────────────────────
+
+[[ $# -lt 2 ]] && usage
+
+USERNAME="$1"
+COMMENT="$2"
+DATE="$(date -u +%Y-%m-%d)"
+
+# Validate username: ^[a-z][a-z0-9-]{2,30}$
+if ! [[ "$USERNAME" =~ ^[a-z][a-z0-9-]{2,30}$ ]]; then
+ echo "ERROR: invalid username '$USERNAME'. Must match ^[a-z][a-z0-9-]{2,30}$" >&2
+ exit 1
+fi
+
+# ─── idempotency guard ────────────────────────────────────────────────────────
+
+if grep -qP "^\s+${USERNAME}\s" "$SNIPPET" 2>/dev/null; then
+ echo "ERROR: user '$USERNAME' already exists in $SNIPPET. Remove first with remove_user.sh." >&2
+ exit 1
+fi
+
+# ─── password ─────────────────────────────────────────────────────────────────
+
+if [ -t 0 ]; then
+ # Interactive — generate random password
+ PLAIN_PASS="$(LC_ALL=C tr -dc 'A-Za-z0-9' &2
+ exit 1
+fi
+
+# ─── bcrypt via caddy container ───────────────────────────────────────────────
+# Caddy 2.11+ requires base64-encoded bcrypt in Caddyfile basic_auth.
+# Password passed via stdin to avoid exposure in process list.
+
+HASH="$(printf '%s' "$PLAIN_PASS" | docker run --rm -i caddy:2 sh -c '
+ pass=$(cat)
+ raw=$(caddy hash-password --algorithm bcrypt --plaintext "$pass")
+ printf "%s" "$raw" | base64 | tr -d "\n"
+')"
+
+if [[ -z "$HASH" ]]; then
+ echo "ERROR: caddy hash-password returned empty hash" >&2
+ exit 1
+fi
+
+# ─── insert into snippet ─────────────────────────────────────────────────────
+# Insert before the closing brace of basic_auth block
+
+if ! grep -q '^}' "$SNIPPET"; then
+ echo "ERROR: cannot find closing brace in $SNIPPET" >&2
+ exit 1
+fi
+
+# Use a temp file for safe in-place edit
+TMP="$(mktemp)"
+trap 'rm -f "$TMP"' EXIT
+
+# Insert new user line before the last closing `}` of the basic_auth block
+awk -v user="$USERNAME" -v hash="$HASH" -v comment="$COMMENT" -v date="$DATE" '
+/^}/ && !done {
+ printf " %-7s %s # %s (%s)\n", user, hash, comment, date
+ done=1
+}
+{ print }
+' "$SNIPPET" > "$TMP"
+
+mv "$TMP" "$SNIPPET"
+
+# ─── output ───────────────────────────────────────────────────────────────────
+
+echo "Added user '$USERNAME' to $SNIPPET"
+echo ""
+echo "Plain password (save now — not stored anywhere):"
+echo "$PLAIN_PASS"
+echo ""
+echo "Next steps:"
+echo " 1. Commit caddy/users.caddy.snippet → PR → merge → GHA deploy"
+echo " 2. After deploy, Caddy auto-reloads (deploy.yml does caddy reload)"
+echo " 3. Send username + password to stakeholder via secure channel"
diff --git a/scripts/auth/list_users.sh b/scripts/auth/list_users.sh
new file mode 100644
index 00000000..e8f7a024
--- /dev/null
+++ b/scripts/auth/list_users.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# list_users.sh — показать текущих пользователей (имена + comment, без хешей)
+# Usage: ./scripts/auth/list_users.sh
+set -euo pipefail
+
+# 3× dirname от $0 (.../scripts/auth/list_users.sh) → repo root. Bug fix 2026-05-26.
+SCRIPT_DIR="$(cd "$(dirname "$(realpath "$0")")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+SNIPPET="$REPO_ROOT/caddy/users.caddy.snippet"
+
+if [[ ! -f "$SNIPPET" ]]; then
+ echo "ERROR: snippet not found at $SNIPPET" >&2
+ exit 1
+fi
+
+echo "Users in $SNIPPET:"
+echo ""
+printf "%-12s %s\n" "USERNAME" "COMMENT"
+printf "%-12s %s\n" "--------" "-------"
+
+# Extract lines that look like user entries (indented username + base64 hash + optional comment)
+# Format: username # comment
+grep -P '^\s+[a-z][a-z0-9-]+\s+[A-Za-z0-9+/]+=*' "$SNIPPET" | \
+ sed -E 's/^\s+([a-z][a-z0-9-]+)\s+\S+\s*(#\s*)?(.*)$/\1\t\3/' | \
+ while IFS=$'\t' read -r user comment; do
+ printf "%-12s %s\n" "$user" "$comment"
+ done
+
+echo ""
+TOTAL=$(grep -cP '^\s+[a-z][a-z0-9-]+\s+[A-Za-z0-9+/]+=*' "$SNIPPET" || true)
+echo "Total: $TOTAL user(s)"
diff --git a/scripts/auth/remove_user.sh b/scripts/auth/remove_user.sh
new file mode 100644
index 00000000..37796ee9
--- /dev/null
+++ b/scripts/auth/remove_user.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# remove_user.sh — удалить пользователя из caddy/users.caddy.snippet
+# Usage: ./scripts/auth/remove_user.sh
+#
+# НЕ коммитит изменения — это задача разработчика.
+set -euo pipefail
+
+# 3× dirname от $0 (.../scripts/auth/remove_user.sh) → repo root. Bug fix 2026-05-26.
+SCRIPT_DIR="$(cd "$(dirname "$(realpath "$0")")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+SNIPPET="$REPO_ROOT/caddy/users.caddy.snippet"
+
+usage() {
+ echo "Usage: $0 " >&2
+ exit 1
+}
+
+[[ $# -lt 1 ]] && usage
+
+USERNAME="$1"
+
+# Validate username format
+if ! [[ "$USERNAME" =~ ^[a-z][a-z0-9-]{2,30}$ ]]; then
+ echo "ERROR: invalid username '$USERNAME'. Must match ^[a-z][a-z0-9-]{2,30}$" >&2
+ exit 1
+fi
+
+# Check user exists
+if ! grep -qP "^\s+${USERNAME}\s" "$SNIPPET" 2>/dev/null; then
+ echo "ERROR: user '$USERNAME' not found in $SNIPPET" >&2
+ exit 1
+fi
+
+# Remove the line with this username (safe in-place via temp file)
+TMP="$(mktemp)"
+trap 'rm -f "$TMP"' EXIT
+
+grep -vP "^\s+${USERNAME}\s" "$SNIPPET" > "$TMP"
+mv "$TMP" "$SNIPPET"
+
+echo "Removed user '$USERNAME' from $SNIPPET"
+echo ""
+echo "Next steps:"
+echo " 1. Commit caddy/users.caddy.snippet → PR → merge → GHA deploy"
+echo " 2. After deploy, Caddy auto-reloads — user access revoked"
diff --git a/scripts/bootstrap_glitchtip.sh b/scripts/bootstrap_glitchtip.sh
new file mode 100644
index 00000000..36db58c8
--- /dev/null
+++ b/scripts/bootstrap_glitchtip.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+# bootstrap_glitchtip.sh
+# One-shot bootstrap for GlitchTip on the VPS.
+#
+# Run ONCE manually after merging feat/204-glitchtip-infra.
+# Do NOT add to deploy.yml — this is idempotent setup, not a recurring step.
+#
+# Usage (on VPS via SSH):
+# cd /opt/gendesign
+# # Ensure GLITCHTIP_DB_PASS and GLITCHTIP_SECRET are set in .env
+# bash scripts/bootstrap_glitchtip.sh
+#
+# After running:
+# 1. docker compose -p gendesign -f docker-compose.prod.yml exec glitchtip-web ./manage.py createsuperuser
+# 2. Log in to https://errors.gendsgn.ru → create org "gendesign"
+# 3. Create projects: "backend", "frontend"
+# 4. Copy DSNs → add to /opt/gendesign/backend/.env (GLITCHTIP_DSN)
+# and /opt/gendesign/frontend/.env or .env.runtime (NEXT_PUBLIC_GLITCHTIP_DSN)
+# 5. Recreate affected containers:
+# docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps backend
+
+set -euo pipefail
+
+COMPOSE_FILE="docker-compose.prod.yml"
+COMPOSE_PROJECT="gendesign"
+COMPOSE="docker compose -p $COMPOSE_PROJECT -f $COMPOSE_FILE"
+
+# ── Load .env.runtime for GLITCHTIP_DB_PASS / POSTGRES_* ──────────────────
+if [ -f ".env" ]; then
+ set -a; source .env; set +a
+fi
+if [ -f "backend/.env.runtime" ]; then
+ set -a; source backend/.env.runtime; set +a
+fi
+
+: "${GLITCHTIP_DB_PASS:?GLITCHTIP_DB_PASS not set — add to .env or export}"
+: "${POSTGRES_USER:?POSTGRES_USER not set}"
+: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD not set}"
+: "${POSTGRES_DB:?POSTGRES_DB not set}"
+
+echo "==> Ensuring postgres service is healthy..."
+for i in $(seq 1 30); do
+ if $COMPOSE exec -T postgres pg_isready -U "$POSTGRES_USER" -q; then
+ break
+ fi
+ echo " Waiting for postgres... ($i/30)"
+ sleep 2
+done
+
+# ── Create glitchtip DB + user (idempotent) ────────────────────────────────
+# psql var :'gt_pass' НЕ подставляется внутри DO $$..$$ — тело dollar-quote
+# уходит на сервер as-is. Используем \gexec в psql-стриме client-side.
+#
+# \gexec builds SQL client-side via quote_literal(:'gt_pass'), then executes it.
+# quote_literal escapes apostrophes and backslashes — no SQL injection possible.
+# If WHERE NOT EXISTS filters the row, \gexec receives no input → no-op, no error.
+# ON_ERROR_STOP=1 ensures \gexec-executed statements also cause psql to exit non-zero.
+echo "==> Creating glitchtip database and user (idempotent)..."
+$COMPOSE exec -T postgres \
+ psql -U "$POSTGRES_USER" -d postgres \
+ -v ON_ERROR_STOP=1 \
+ -v "gt_pass=$GLITCHTIP_DB_PASS" <<'SQL'
+SELECT 'CREATE ROLE glitchtip LOGIN PASSWORD ' || quote_literal(:'gt_pass') || ';'
+WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'glitchtip')
+\gexec
+
+SELECT 'CREATE DATABASE glitchtip OWNER glitchtip;'
+WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'glitchtip')
+\gexec
+
+GRANT ALL PRIVILEGES ON DATABASE glitchtip TO glitchtip;
+SQL
+
+# ── Start GlitchTip containers ─────────────────────────────────────────────
+# glitchtip-web and glitchtip-worker carry `profiles: ["glitchtip"]` in compose,
+# so plain `up -d` skips them. Activate the profile explicitly here.
+# After bootstrap succeeds, add COMPOSE_PROFILES=glitchtip to /opt/gendesign/.env
+# so that future automatic deploys also bring these services up.
+echo "==> Pulling glitchtip image..."
+$COMPOSE --profile glitchtip pull glitchtip-web glitchtip-worker
+
+echo "==> Starting glitchtip-web and glitchtip-worker..."
+$COMPOSE --profile glitchtip up -d glitchtip-web glitchtip-worker
+
+echo ""
+echo "NOTE: Add the following line to /opt/gendesign/.env so future deploys"
+echo " keep GlitchTip running:"
+echo " COMPOSE_PROFILES=glitchtip"
+
+# ── Wait for glitchtip-web to become healthy ───────────────────────────────
+# Use wget (available in glitchtip image) matching the compose healthcheck probe.
+echo "==> Waiting for glitchtip-web to become healthy (up to 60s)..."
+for i in $(seq 1 30); do
+ if $COMPOSE exec -T glitchtip-web wget -q --spider http://localhost:8080/api/0/ >/dev/null 2>&1; then
+ echo " glitchtip-web is healthy."
+ break
+ fi
+ echo " Waiting... ($i/30)"
+ sleep 2
+done
+
+echo ""
+echo "==> Bootstrap complete."
+echo ""
+echo "Next steps:"
+echo " 1. Create superuser:"
+echo " $COMPOSE exec glitchtip-web ./manage.py createsuperuser"
+echo ""
+echo " 2. Log in to https://errors.gendsgn.ru"
+echo " - Create organization: gendesign"
+echo " - Create project: backend (Platform: Python)"
+echo " - Create project: frontend (Platform: JavaScript / Browser)"
+echo ""
+echo " 3. Copy DSNs from each project's Settings → DSN"
+echo " - Add GLITCHTIP_DSN= to /opt/gendesign/backend/.env"
+echo " - Add NEXT_PUBLIC_GLITCHTIP_DSN= to /opt/gendesign/backend/.env or .env"
+echo ""
+echo " 4. Recreate services to pick up new DSNs:"
+echo " $COMPOSE up -d --force-recreate --no-deps backend"
+echo " $COMPOSE up -d --force-recreate --no-deps worker beat"
+echo ""
+echo " 5. Verify: trigger a test exception and confirm it appears in GlitchTip."
diff --git a/scripts/claude-hooks/check-no-print.py b/scripts/claude-hooks/check-no-print.py
new file mode 100644
index 00000000..79944601
--- /dev/null
+++ b/scripts/claude-hooks/check-no-print.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""
+Claude Code PostToolUse hook — blocks new `print(` calls in backend/**/*.py.
+
+Wired in `.claude/settings.json` hooks.PostToolUse with matcher "Edit|Write".
+
+Behavior:
+- Reads hook input as JSON from stdin (per Anthropic spec).
+- If tool is Edit/Write and file_path matches backend/**/*.py:
+ - Scans new content for new `print(` calls.
+ - Exits with code 2 to block (stderr is forwarded to Claude as feedback).
+- Otherwise exits 0 (no-op).
+
+Notes:
+- Allows `print(` inside lines containing `# noqa` (escape hatch).
+- Skips files under `backend/tests/**` (test code may use print for debugging).
+- Skips `backend/scripts/**` (one-off scripts).
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from pathlib import Path
+
+ALLOW_PATH_PREFIXES = (
+ "backend/tests/",
+ "backend/scripts/",
+)
+WATCH_PATH_PREFIX = "backend/"
+PRINT_RE = re.compile(r"(? str:
+ """Convert absolute/relative path to repo-relative POSIX-like form."""
+ p = Path(raw).as_posix()
+ # Try to strip everything up to the last "backend/" occurrence.
+ idx = p.rfind("backend/")
+ return p[idx:] if idx >= 0 else p
+
+
+def main() -> int:
+ try:
+ payload = json.load(sys.stdin)
+ except Exception:
+ # No input or malformed — skip silently.
+ return 0
+
+ tool_name = payload.get("tool_name") or payload.get("tool") or ""
+ if tool_name not in {"Edit", "Write"}:
+ return 0
+
+ tool_input = payload.get("tool_input") or {}
+ file_path = tool_input.get("file_path") or tool_input.get("path") or ""
+ if not file_path:
+ return 0
+
+ norm = normalize_path(file_path)
+ if not norm.startswith(WATCH_PATH_PREFIX):
+ return 0
+ if any(norm.startswith(p) for p in ALLOW_PATH_PREFIXES):
+ return 0
+ if not norm.endswith(".py"):
+ return 0
+
+ # Pull the new content. Edit gives `new_string`, Write gives `content`.
+ new_text = tool_input.get("new_string") or tool_input.get("content") or ""
+ if not new_text:
+ return 0
+
+ offenders: list[tuple[int, str]] = []
+ for lineno, line in enumerate(new_text.splitlines(), start=1):
+ if "# noqa" in line:
+ continue
+ if PRINT_RE.search(line):
+ offenders.append((lineno, line.strip()))
+
+ if not offenders:
+ return 0
+
+ sample = offenders[0]
+ msg_lines = [
+ f"Blocked: `print(` introduced in {norm}.",
+ f"Line {sample[0]}: {sample[1][:120]}",
+ "GenDesign convention: use `logger.info/warning/error` instead of print().",
+ "If this is intentional one-off debug, add `# noqa` on the line and retry.",
+ ]
+ print("\n".join(msg_lines), file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/cleanup-merged-worktrees.sh b/scripts/cleanup-merged-worktrees.sh
new file mode 100755
index 00000000..0c9cd672
--- /dev/null
+++ b/scripts/cleanup-merged-worktrees.sh
@@ -0,0 +1,71 @@
+#!/bin/bash
+# Cleanup merged Claude Code background-session worktrees.
+#
+# `claude agents` / `claude --bg` создают `.claude/worktrees/agent-*` для
+# каждой background session. После merge PR worktree остаётся locked
+# и не удаляется автоматически — за неделю накапливаются десятки.
+#
+# Скрипт сравнивает branch каждого worktree с `forgejo/main`; если branch
+# полностью в main (merged) — удаляет worktree с `--force` (worktree
+# заперт супервизором). Затем `git worktree prune` чистит references.
+#
+# Запускать вручную или через cron (weekly recommended):
+# 0 9 * * MON cd /path/to/gendesign && ./scripts/cleanup-merged-worktrees.sh
+#
+# Safe:
+# - Не трогает unmerged branches (in-progress work, PR ещё не merged)
+# - Не трогает worktree.lock с твоими running sessions (force нужен только
+# потому что Claude Code ставит lock автоматически)
+# - Логи action — каждый удалённый branch выводится отдельной строкой
+#
+# Reference: https://code.claude.com/docs/en/worktrees#clean-up-worktrees
+
+set -e
+
+REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$REPO_ROOT"
+
+echo "Fetching forgejo/main..."
+git fetch forgejo main --quiet
+
+BEFORE=$(git worktree list | wc -l)
+SIZE_BEFORE=$(du -sh .claude/worktrees/ 2>/dev/null | awk '{print $1}')
+
+echo "Scanning $((BEFORE - 1)) worktrees (skip main)..."
+
+REMOVED=0
+KEPT=0
+
+# Parse `git worktree list --porcelain` into "path branch" pairs.
+git worktree list --porcelain | awk '/^worktree/{w=$2} /^branch/{b=$2} /^$/{print w" "b}' | \
+while read path branch; do
+ # Skip main worktree (no branch field) and detached HEADs.
+ [ -z "$branch" ] && continue
+
+ short="${branch#refs/heads/}"
+
+ # Don't touch the worktree we're standing in.
+ if [ "$path" = "$REPO_ROOT" ]; then
+ continue
+ fi
+
+ if git merge-base --is-ancestor "$short" forgejo/main 2>/dev/null; then
+ if git worktree remove --force "$path" 2>/dev/null; then
+ echo " removed: $short"
+ REMOVED=$((REMOVED + 1))
+ else
+ echo " failed: $short ($path)"
+ fi
+ else
+ KEPT=$((KEPT + 1))
+ fi
+done
+
+git worktree prune
+
+AFTER=$(git worktree list | wc -l)
+SIZE_AFTER=$(du -sh .claude/worktrees/ 2>/dev/null | awk '{print $1}')
+
+echo ""
+echo "Before: $((BEFORE - 1)) worktrees, $SIZE_BEFORE"
+echo "After: $((AFTER - 1)) worktrees, $SIZE_AFTER"
diff --git a/scripts/cleanup-worktrees-aggressive.py b/scripts/cleanup-worktrees-aggressive.py
new file mode 100755
index 00000000..9cf86d7b
--- /dev/null
+++ b/scripts/cleanup-worktrees-aggressive.py
@@ -0,0 +1,275 @@
+#!/usr/bin/env python3
+"""
+Aggressive cleanup of stale Claude Code background-session worktrees.
+
+Unlike safe `cleanup-merged-worktrees.sh`, this queries Forgejo PR API to
+detect squash-merged branches (where local SHA isn't ancestor of main
+because Forgejo squash created a new commit).
+
+Decision matrix per worktree branch:
+
+ PR state | Action
+ ----------------------|---------------------------------------------
+ merged (any style) | REMOVE
+ closed (no merge) | REMOVE (work abandoned)
+ open | KEEP (active PR in progress)
+ no PR + age <14d | KEEP (recent WIP, no PR yet)
+ no PR + age >=14d | REMOVE (stale orphan)
+
+Requires:
+ - FORGEJO_TOKEN env var (or in ~/.claude/settings.json env block)
+ - FORGEJO_URL env var (default: https://git.gendsgn.ru)
+
+Usage:
+ python scripts/cleanup-worktrees-aggressive.py --dry-run # show plan
+ python scripts/cleanup-worktrees-aggressive.py # execute
+ AGE_DAYS=30 python scripts/cleanup-worktrees-aggressive.py # override threshold
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+from collections import Counter
+from pathlib import Path
+
+OWNER = "lekss361"
+REPO_NAME = "gendesign"
+FORGEJO_URL = os.environ.get("FORGEJO_URL", "https://git.gendsgn.ru")
+TOKEN = os.environ.get("FORGEJO_TOKEN")
+AGE_DAYS = int(os.environ.get("AGE_DAYS", "14"))
+DRY_RUN = "--dry-run" in sys.argv
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+
+
+def run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> str:
+ """Run a subprocess and return stdout, swallowing errors when check=False."""
+ result = subprocess.run(
+ cmd,
+ cwd=cwd or REPO_ROOT,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if check and result.returncode != 0:
+ raise RuntimeError(f"{' '.join(cmd)}\n{result.stderr}")
+ return result.stdout
+
+
+def fetch_all_prs() -> dict[str, dict]:
+ """Returns {branch_name: {state, merged}} for all PRs across all states."""
+ if not TOKEN:
+ sys.exit(
+ "ERROR: FORGEJO_TOKEN env var not set.\n"
+ "Hint: $env:FORGEJO_TOKEN = '' (PowerShell) or export it before running."
+ )
+
+ prs: dict[str, dict] = {}
+ page = 1
+ api = f"{FORGEJO_URL}/api/v1/repos/{OWNER}/{REPO_NAME}/pulls"
+
+ while page <= 30:
+ url = f"{api}?state=all&limit=50&page={page}&sort=newest"
+ req = urllib.request.Request(
+ url, headers={"Authorization": f"token {TOKEN}"}
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ batch = json.loads(resp.read().decode("utf-8"))
+ except urllib.error.HTTPError as e:
+ sys.exit(f"ERROR: Forgejo API {url} → {e.code} {e.reason}")
+ except Exception as e:
+ sys.exit(f"ERROR: fetch {url} failed: {e}")
+
+ if not batch:
+ break
+
+ for pr in batch:
+ branch = pr.get("head", {}).get("ref")
+ if not branch:
+ continue
+ # Newest-wins: only set if not already seen (sort=newest gives
+ # most recent first, so first occurrence is the newest PR for
+ # that branch)
+ prs.setdefault(
+ branch,
+ {
+ "state": pr.get("state"),
+ "merged": bool(pr.get("merged")),
+ "number": pr.get("number"),
+ },
+ )
+
+ page += 1
+
+ return prs
+
+
+def get_worktrees() -> list[tuple[str, str]]:
+ """Returns list of (path, branch_short_name) tuples, skipping main."""
+ out = run(["git", "worktree", "list", "--porcelain"])
+ worktrees: list[tuple[str, str]] = []
+ current = {}
+ for line in out.splitlines():
+ if line.startswith("worktree "):
+ current = {"path": line[len("worktree ") :]}
+ elif line.startswith("branch "):
+ current["branch"] = line[len("branch ") :]
+ elif line == "" and current:
+ branch = current.get("branch", "")
+ if branch.startswith("refs/heads/"):
+ short = branch[len("refs/heads/") :]
+ worktrees.append((current["path"], short))
+ current = {}
+ if current and "branch" in current:
+ branch = current["branch"]
+ if branch.startswith("refs/heads/"):
+ worktrees.append((current["path"], branch[len("refs/heads/") :]))
+ return worktrees
+
+
+def branch_age_days(path: str) -> int | None:
+ """Returns age in days of branch HEAD, or None on error."""
+ try:
+ ts = run(["git", "-C", path, "log", "-1", "--format=%ct"], check=False).strip()
+ if not ts:
+ return None
+ return (int(time.time()) - int(ts)) // 86400
+ except Exception:
+ return None
+
+
+def main() -> int:
+ if DRY_RUN:
+ print("DRY RUN — no worktrees will be removed\n")
+
+ print("Fetching all PRs from Forgejo (paginated, up to 1500)...")
+ prs = fetch_all_prs()
+ print(f"Indexed {len(prs)} unique branch→PR mappings.\n")
+
+ worktrees = get_worktrees()
+ print(f"Scanning {len(worktrees)} worktrees...\n")
+
+ categories: Counter[str] = Counter()
+ to_remove: list[tuple[str, str, str]] = [] # (path, branch, reason)
+ repo_root_str = str(REPO_ROOT)
+
+ for path, branch in worktrees:
+ # Don't touch the worktree we're standing in.
+ if os.path.normcase(os.path.normpath(path)) == os.path.normcase(
+ os.path.normpath(repo_root_str)
+ ):
+ categories["skipped"] += 1
+ continue
+
+ pr = prs.get(branch)
+ if pr:
+ if pr["merged"]:
+ cat = "merged"
+ reason = f"remove (merged PR #{pr['number']})"
+ elif pr["state"] == "closed":
+ cat = "closed"
+ reason = f"remove (closed PR #{pr['number']}, no merge)"
+ else:
+ cat = "open"
+ reason = f"keep (open PR #{pr['number']})"
+ else:
+ age = branch_age_days(path)
+ if age is None:
+ cat = "other"
+ reason = "keep (no PR, age unknown)"
+ elif age >= AGE_DAYS:
+ cat = "stale"
+ reason = f"remove (no PR, stale {age}d ≥ {AGE_DAYS}d)"
+ else:
+ cat = "recent"
+ reason = f"keep (no PR, recent {age}d)"
+
+ print(f" [{cat:>6}] {branch} → {reason}")
+ categories[cat] += 1
+
+ if cat in {"merged", "closed", "stale"}:
+ to_remove.append((path, branch, reason))
+
+ if not DRY_RUN:
+ print(f"\nRemoving {len(to_remove)} worktree(s)...")
+ failed = 0
+ for path, branch, _ in to_remove:
+ # `--force --force` (double flag) overrides Claude Code supervisor
+ # locks (`claude agent (pid X)` lock messages).
+ result = subprocess.run(
+ ["git", "worktree", "remove", "--force", "--force", path],
+ capture_output=True,
+ text=True,
+ cwd=REPO_ROOT,
+ )
+ if result.returncode != 0:
+ failed += 1
+ err = result.stderr.strip().splitlines()[-1] if result.stderr else "unknown"
+ print(f" FAILED: {branch} → {err}", file=sys.stderr)
+ run(["git", "worktree", "prune"], check=False)
+ if failed:
+ print(f"\nWARN: {failed} worktree(s) failed to remove (see stderr above)")
+
+ # Phase 2: orphan-dir cleanup. After `git worktree remove`, git no longer
+ # tracks the dir, but filesystem may still have it (Windows file locks
+ # held by Claude Code supervisor, etc). Try to remove via shutil.
+ wt_root = REPO_ROOT / ".claude" / "worktrees"
+ if wt_root.is_dir():
+ fs_dirs = {p.name for p in wt_root.iterdir() if p.is_dir()}
+ git_dirs = {Path(p).name for p, _ in get_worktrees()}
+ orphans = fs_dirs - git_dirs
+ if orphans:
+ print(f"\nFound {len(orphans)} orphan dir(s) (git-untracked, FS leftover)...")
+ orphan_removed = 0
+ orphan_locked = 0
+ for o in orphans:
+ try:
+ shutil.rmtree(wt_root / o, ignore_errors=False)
+ orphan_removed += 1
+ except PermissionError:
+ orphan_locked += 1
+ except Exception as e:
+ print(f" ORPHAN FAILED: {o} → {type(e).__name__}", file=sys.stderr)
+ print(f" Removed {orphan_removed}, locked (need Claude Code restart) {orphan_locked}")
+ if orphan_locked:
+ print(
+ f"\n Hint: {orphan_locked} dirs held by Claude Code supervisor "
+ f"(open file handles). After next Claude Code restart, re-run this "
+ f"script — they will be removable then."
+ )
+
+ print("\nSummary:")
+ print(f" Remove — merged: {categories['merged']}")
+ print(f" Remove — closed: {categories['closed']}")
+ print(f" Remove — stale: {categories['stale']} (no PR, age >={AGE_DAYS}d)")
+ print(f" Keep — open PR: {categories['open']}")
+ print(
+ f" Keep — recent: {categories['recent']} (no PR yet, age <{AGE_DAYS}d)"
+ )
+ print(f" Keep — other: {categories['other']} (no PR, unknown age)")
+ print(f" Skipped (current): {categories['skipped']}")
+
+ total_remove = categories["merged"] + categories["closed"] + categories["stale"]
+ print()
+ if DRY_RUN:
+ print(
+ f"DRY RUN — would remove {total_remove} worktree(s). "
+ "Re-run without --dry-run to execute."
+ )
+ else:
+ after = len(get_worktrees()) + 1 # +1 for main
+ print(f"Worktrees remaining: {after}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/sshkey.txt b/sshkey.txt
deleted file mode 100644
index 87ed5312..00000000
--- a/sshkey.txt
+++ /dev/null
@@ -1,104 +0,0 @@
-Your identification has been saved in C:\Users\user\.ssh\gendesign_deploy
-Your public key has been saved in C:\Users\user\.ssh\gendesign_deploy.pub
-The key fingerprint is:
-SHA256:50HjJLdk9XaRDRjvKIB9LrRoux6KojXQZvgqLm7LEks gendesign-deploy
-The key's randomart image is:
-+--[ED25519 256]--+
-| oo.oo|
-| o ..o .o|
-| . = O + .|
-| o o & o + . |
-|o + o S B . . |
-|.E . . + o |
-|.o+ o . |
-|B+ o . o |
-|@*o ..o |
-+----[SHA256]-----+
-
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPGKuThuQMPdjxdA9FXHkUZUO+iyVbSMOxy3qFbOY7+K gendesign-deploy
-
-mYsNNJf%6hpN
-
-`gendesign' 12345 ssh gendesign
-
-for github
-Generating public/private ed25519 key pair.
-Your identification has been saved in /home/gendesign/.ssh/github_deploy
-Your public key has been saved in /home/gendesign/.ssh/github_deploy.pub
-The key fingerprint is:
-SHA256:W9Ioi7A/d+LxIghIOoF4rGurbEnIHuVjDtfF1Ki+C1o gendesign-server-deploy-key
-The key's randomart image is:
-+--[ED25519 256]--+
-| o |
-| o . |
-|o. + |
-|+.o. . o o |
-|*++ o o S o |
-|B= B + o + |
-|o+OEo + . |
-|o=+o+.+o. |
-|=+. .*o+. |
-+----[SHA256]-----+
-
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPYYAH7vPN/b0npfH1IrhADdE+SwJsmo8gJm84VauM4m gendesign-server-deploy-key
-
-pg pass 2J2SBPMKuS998fiwhtQqDhMI
-
-ssh -i $HOME\.ssh\gendesign_deploy gendesign@46.173.16.127
-
-pat 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
-
-echo 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| docker login ghcr.io -u lekss361 --password-stdin
-
-ssh -N -L 15432:localhost:5432 gendesign
-Host: localhost
-Port: 15432
-Database: gendesign
-Username: gendesign
-Password: 2J2SBPMKuS998fiwhtQqDhMI
-
-Локально установить pre-commit один раз:
-make pre-commit-install
-# или:
-pip install pre-commit
-pre-commit install
-
-python -m pre_commit install
-
-python -m pre_commit run --all-files
-# или, если PATH добавлен:
-pre-commit run --all-files
-
-Только текущая сессия:
-
-$env:PATH += ";$env:LOCALAPPDATA\Python\pythoncore-3.14-64\Scripts"
-pre-commit install
-Постоянно (нужно ОДИН раз):
-
-[Environment]::SetEnvironmentVariable(
- "PATH",
- $env:PATH + ";$env:LOCALAPPDATA\Python\pythoncore-3.14-64\Scripts",
- "User"
-)
-
-Host gendesign
- HostName 46.173.16.127
- User gendesign
- Port 22
- IdentityFile ~/.ssh/gendesign_deploy
- IdentitiesOnly yes
- ServerAliveInterval 60
- LocalForward 15432 127.0.0.1:5432
- LocalForward 8000 127.0.0.1:8000
-
-
-token admin 15c99983d471718fe5c320f36bad01994d91ff34cb5646ce957b060ec039275e
-
-
-ключ от обсидиан gendesign@gendesign:~$ PASSWORD=$(openssl rand -base64 32)
-cho "Sgendesign@gendesign:~$ echo "Save this: $PASSWORD"
-Save this: femp1ueSrQoD2cV/9m7YC8bmIuMOHz0JMmrpmD5Z1Ns=
-curl -u obsidian: https://obsidian.gendsgn.ru/gendesign-vault
-
-сеть PS C:\Users\user> ssh gendesign 'docker network create gendesign_shared'
-560a390e7b231eb2e0657ee96c8add1561ce772716b9db5b593b0950669df644
diff --git a/tradein-mvp/.env.example b/tradein-mvp/.env.example
new file mode 100644
index 00000000..f7c34ad3
--- /dev/null
+++ b/tradein-mvp/.env.example
@@ -0,0 +1,32 @@
+# Trade-In MVP — environment variables example.
+# Скопируй в .env (или экспортируй в shell) для override defaults.
+
+# === Backend ===
+DATABASE_URL=postgresql+psycopg://tradein:tradein@postgres:5432/tradein
+CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"]
+ENVIRONMENT=dev
+
+# Yandex Geocoder API key (25k req/day free tier).
+# Required for backfill scripts (scripts/backfill_house_coords.py + audit_address_mismatch.py).
+# Empty = Nominatim fallback для backend геокодинга; backfill scripts требуют этот ключ
+# и упадут с SystemExit без него.
+YANDEX_GEOCODER_API_KEY=
+
+# DaData /clean/address — обогащение target адреса в estimate flow (PR Q1).
+# Возвращает canonical-форму, kadastr_num, ФИАС, координаты, ближайшее метро.
+# Demo tier: 100 req/день — хватит для тестов и low-traffic prod.
+# Если хотя бы один не задан → service возвращает None gracefully, estimator
+# продолжает работу без enrichment.
+# Получение ключей: https://dadata.ru/api/clean/
+DADATA_API_TOKEN=
+DADATA_API_SECRET=
+
+# === Frontend (build-time) ===
+# Backend URL для Next.js rewrites (browser обращается к /api/* — Next.js проксирует на BACKEND_URL/api/*).
+# В docker compose это уже выставлено в http://backend:8000 — менять не нужно.
+# BACKEND_URL=http://backend:8000
+
+# === Postgres (если меняешь — синхронизируй с DATABASE_URL и docker-compose.yml) ===
+POSTGRES_USER=tradein
+POSTGRES_PASSWORD=tradein
+POSTGRES_DB=tradein
diff --git a/tradein-mvp/.gitignore b/tradein-mvp/.gitignore
new file mode 100644
index 00000000..233693bc
--- /dev/null
+++ b/tradein-mvp/.gitignore
@@ -0,0 +1,50 @@
+# Trade-In MVP
+
+# Backend Python
+backend/.venv/
+backend/__pycache__/
+backend/**/__pycache__/
+backend/*.egg-info/
+backend/.pytest_cache/
+backend/.ruff_cache/
+backend/.mypy_cache/
+backend/uv.lock
+
+# Frontend Node
+frontend/node_modules/
+frontend/.next/
+frontend/out/
+frontend/dist/
+frontend/package-lock.json
+frontend/next-env.d.ts
+
+# Docker
+postgres-data/
+caddy-data/
+caddy-config/
+
+# Env (никогда не коммитим)
+.env
+.env.local
+.env.*.local
+backend/.env
+backend/.env.runtime
+frontend/.env.local
+
+# Public html mockup (исходник от gendsgn.ru — не наш код, держим в docs)
+frontend/public/tradein.html
+
+# Test PDF outputs
+docs/sample-svg-test.pdf
+docs/sample-vps-*.pdf
+*.tmp.pdf
+
+# IDE / OS
+.vscode/
+.idea/
+.DS_Store
+Thumbs.db
+*.swp
+
+# Logs
+*.log
diff --git a/tradein-mvp/DEPLOY.md b/tradein-mvp/DEPLOY.md
new file mode 100644
index 00000000..f17cd93c
--- /dev/null
+++ b/tradein-mvp/DEPLOY.md
@@ -0,0 +1,207 @@
+# Trade-In MVP — деплой в gendesign репо
+
+## Архитектура
+
+```
+lekss361/-gendesign/
+├── backend/ ← gendesign Python backend (НЕ ТРОГАЕМ)
+├── frontend/ ← gendesign Next.js (НЕ ТРОГАЕМ)
+├── docker-compose.prod.yml ← gendesign-стек
+├── Caddyfile ← основной Caddy → ВКЛЮЧАЕТ tradein-mvp/deploy/Caddyfile.tradein-fragment
+├── .github/workflows/
+│ ├── deploy.yml ← существующий gendesign deploy
+│ └── deploy-tradein.yml ← НАШ новый, триггерится только на tradein-mvp/**
+└── tradein-mvp/ ← НАША подпапка (изолированный subproject)
+ ├── backend/
+ ├── frontend/
+ ├── deploy/
+ │ ├── Caddyfile.tradein-fragment ← импортится в основной Caddyfile
+ │ └── cron-scrape.sh
+ ├── docker-compose.yml ← локальный dev
+ ├── docker-compose.prod.yml ← production overlay (использует GHCR images)
+ └── .github/workflows/deploy-tradein.yml
+```
+
+## Прод URL routes
+
+| URL | Сервис |
+|----------------------------------|-------------------------|
+| `gendsgn.ru/trade-in/` | tradein-frontend:3000 |
+| `gendsgn.ru/trade-in/api/v1/...` | tradein-backend:8000 |
+
+Caddy в gendesign-стеке проксирует через `handle_path /trade-in/api/*` (вырезает префикс) и `handle /trade-in/*` (Next.js basePath).
+
+## Прод docker stacks
+
+- `docker compose -p gendesign` — основной gendesign (postgres + backend + frontend + caddy + couchdb)
+- `docker compose -p gendesign-tradein` — наш (tradein-postgres + tradein-backend + tradein-frontend)
+
+Оба подключены к `gendesign_shared` external network — туда смотрит Caddy.
+
+## Что нужно положить в основной Caddyfile gendesign
+
+В блок `gendsgn.ru { ... }` **ПЕРЕД** универсальным `handle { reverse_proxy frontend:3000 }`:
+
+```caddyfile
+import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
+```
+
+Или вставить содержимое фрагмента inline.
+
+## Что нужно положить в `.env.runtime` (на сервере, НЕ в git)
+
+Два файла:
+
+1. `/opt/gendesign/tradein-mvp/.env.runtime` — переменные для docker compose
+ substitution (`${TRADEIN_POSTGRES_PASSWORD}` и т.п. в compose-файле). Читается
+ shell-скриптом deploy через `source .env.runtime` перед `compose up`.
+2. `/opt/gendesign/tradein-mvp/backend/.env.runtime` — переменные внутри
+ контейнера `tradein-backend` (читаются через `env_file:` в compose). Сюда
+ попадают `YANDEX_GEOCODER_API_KEY`, `ADMIN_TOKEN`, `COOKIE_ENCRYPTION_KEY` —
+ всё, что нужно scripts/backfill_house_coords.py и application code внутри
+ контейнера.
+
+```bash
+# /opt/gendesign/tradein-mvp/.env.runtime
+TRADEIN_POSTGRES_USER=tradein
+TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32>
+TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
+YANDEX_GEOCODER_API_KEY= # пусто пока, Nominatim fallback работает
+
+# Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator).
+# Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503.
+# Generate once on VPS:
+# openssl rand -hex 32
+COOKIE_ENCRYPTION_KEY=<64-char hex>
+```
+
+```bash
+# /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые
+# читаются ВНУТРИ container'а (scripts/backfill_house_coords.py, app/*).
+# Может быть симлинком на ../.env.runtime если переменные совпадают:
+# ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime
+YANDEX_GEOCODER_API_KEY=
+ADMIN_TOKEN=
+COOKIE_ENCRYPTION_KEY=<64-char hex>
+GENDESIGN_FDW_PASSWORD=
+GLITCHTIP_DSN=
+# DaData on-demand enrichment в /estimate flow (PR Q1).
+# Demo tier: 100 req/день. Пусто = enrichment disabled (graceful).
+# Регистрация ключей: https://dadata.ru/api/clean/
+DADATA_API_TOKEN=
+DADATA_API_SECRET=
+```
+
+Оба файла создаются вручную при первом деплое.
+
+### После изменения `backend/.env.runtime`
+
+```bash
+# .env.runtime читается на старте container — `compose restart` НЕ перечитывает.
+docker compose -p gendesign-tradein -f docker-compose.prod.yml \
+ up -d --force-recreate --no-deps backend
+```
+
+См. `.claude/rules/deploy.md` — main backend pattern идентичный.
+
+### Обновление `COOKIE_ENCRYPTION_KEY` на существующем VPS
+
+```bash
+# На VPS, если COOKIE_ENCRYPTION_KEY ещё не задан:
+echo "COOKIE_ENCRYPTION_KEY=$(openssl rand -hex 32)" >> /opt/gendesign/tradein-mvp/.env.runtime
+
+# Применить без полного рестарта стека:
+cd /opt/gendesign/tradein-mvp
+docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --force-recreate --no-deps backend
+```
+
+## GitHub Secrets (уже должны быть от gendesign deploy)
+
+- `DEPLOY_HOST` — IP сервера (94.228.121.73)
+- `DEPLOY_USER` — root
+- `DEPLOY_SSH_KEY` — приватный SSH ключ
+- `DEPLOY_PORT` — 22 (или другой если меняли)
+
+## Первый деплой — пошагово
+
+### 1. На анто́новой машине: подготовить tradein-mvp/ к слиянию
+
+```bash
+# clone gendesign репо (с PAT)
+git clone https://@github.com/lekss361/-gendesign.git
+cd -gendesign
+
+# скопировать tradein-mvp/ как подпапку
+cp -r /Users/anton/Птица/tradein-mvp ./tradein-mvp
+rm -rf ./tradein-mvp/postgres-data # на всякий
+rm -rf ./tradein-mvp/.git # не нужен внутри monorepo
+
+# скопировать workflow в правильное место
+mkdir -p .github/workflows
+cp ./tradein-mvp/.github/workflows/deploy-tradein.yml .github/workflows/
+
+git add tradein-mvp/ .github/workflows/deploy-tradein.yml
+git commit -m "feat: add tradein-mvp subproject + deploy workflow"
+git push origin main
+```
+
+### 2. На сервере: первый раз вручную добавить Caddy include
+
+```bash
+ssh -i ~/.ssh/id_bot_server root@94.228.121.73
+
+cd /opt/gendesign
+# git reset --hard origin/main подтянет tradein-mvp/ автоматически
+
+# Добавить в Caddyfile (ровно одну строку в блок gendsgn.ru { ... })
+nano Caddyfile
+# вставить ПЕРЕД последним handle { ... }:
+# import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment
+
+# Создать .env.runtime — два файла:
+# 1. tradein-mvp/.env.runtime — для compose substitution на host'е
+cat > tradein-mvp/.env.runtime </dev/null 2>&1 || docker network create gendesign_shared
+cd tradein-mvp
+set -a; source .env.runtime; set +a
+docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
+docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d
+
+# Reload Caddy
+cd /opt/gendesign
+docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy caddy reload --config /etc/caddy/Caddyfile
+
+# Проверить
+curl -sS https://gendsgn.ru/trade-in/api/health
+curl -sI https://gendsgn.ru/trade-in/
+```
+
+### 3. Дальше — автодеплой
+
+После первого ручного запуска все следующие `git push origin main` с изменениями в `tradein-mvp/**` будут:
+1. Триггерить `.github/workflows/deploy-tradein.yml`
+2. Билдить и пушить новые образы в `ghcr.io/lekss361/gendesign-tradein-*`
+3. SSH в сервер → `docker compose pull` → `up -d` → Caddy reload
+4. Health check
+
+## Откат
+
+```bash
+ssh root@94.228.121.73
+cd /opt/gendesign/tradein-mvp
+# Откатиться на предыдущий SHA-тег
+docker compose -p gendesign-tradein -f docker-compose.prod.yml down
+IMAGE_TAG= docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d
+```
diff --git a/tradein-mvp/Makefile b/tradein-mvp/Makefile
new file mode 100644
index 00000000..821910b3
--- /dev/null
+++ b/tradein-mvp/Makefile
@@ -0,0 +1,63 @@
+# Trade-In MVP — удобные команды для локальной разработки.
+
+.PHONY: help up down rebuild logs ps shell-backend shell-postgres test-estimate clean
+
+help:
+ @echo "Trade-In MVP — Makefile commands:"
+ @echo ""
+ @echo " make up — собрать и запустить весь стек (caddy + frontend + backend + postgres)"
+ @echo " make down — остановить стек (volumes остаются)"
+ @echo " make rebuild — пересобрать образы и перезапустить (нужно при изменении Dockerfile или deps)"
+ @echo " make logs — tail -f всех контейнеров"
+ @echo " make logs-backend — tail -f только backend"
+ @echo " make logs-frontend — tail -f только frontend"
+ @echo " make ps — статус контейнеров + healthcheck"
+ @echo " make shell-backend — bash внутри backend контейнера"
+ @echo " make shell-postgres — psql внутри postgres контейнера"
+ @echo " make test-estimate — пример POST /api/v1/trade-in/estimate через curl"
+ @echo " make clean — down + rm volumes (УДАЛИТ данные Postgres!)"
+ @echo ""
+
+up:
+ docker compose up -d --build
+ @echo ""
+ @echo "✅ Стек запущен."
+ @echo " Frontend: http://localhost:8080 (auto-redirect → /trade-in)"
+ @echo " Mockup preview: http://localhost:8080/preview/tradein.html"
+ @echo " Backend health: http://localhost:8080/health"
+ @echo " Backend OpenAPI: http://localhost:8000/docs"
+
+down:
+ docker compose down
+
+rebuild:
+ docker compose up -d --build --force-recreate
+
+logs:
+ docker compose logs -f --tail=100
+
+logs-backend:
+ docker compose logs -f --tail=100 backend
+
+logs-frontend:
+ docker compose logs -f --tail=100 frontend
+
+ps:
+ docker compose ps
+
+shell-backend:
+ docker compose exec backend bash
+
+shell-postgres:
+ docker compose exec postgres psql -U tradein -d tradein
+
+test-estimate:
+ @echo "POST /api/v1/trade-in/estimate..."
+ @curl -sS -X POST http://localhost:8080/api/v1/trade-in/estimate \
+ -H 'Content-Type: application/json' \
+ -d '{"address":"ул. Малышева, 1, Екатеринбург","area_m2":54,"rooms":2,"floor":5,"total_floors":17,"year_built":1985,"house_type":"panel","repair_state":"good","has_balcony":true}' \
+ | python3 -m json.tool
+
+clean:
+ docker compose down -v
+ @echo "⚠️ Volumes удалены — Postgres данные стёрты."
diff --git a/tradein-mvp/README.md b/tradein-mvp/README.md
new file mode 100644
index 00000000..adfa8223
--- /dev/null
+++ b/tradein-mvp/README.md
@@ -0,0 +1,196 @@
+# Trade-In MVP
+
+Локальный standalone-форк фичи **Trade-In Estimator** из проекта gendesign — оценка выкупной стоимости квартиры на вторичном рынке по аналогам и реальным сделкам. Layout повторяет PDF-отчёт «Брусника.Обмен» (см. [docs/](docs/)).
+
+## Что это и откуда взято
+
+| Источник | Что | Где |
+|---|---|---|
+| `gendesign/main` PR [#316](https://git.gendsgn.ru/lekss361/gendesign/pulls/316) TI-1 | mock endpoint + Pydantic + SQL migration | `backend/` |
+| `gendesign/main` PR [#317](https://git.gendsgn.ru/lekss361/gendesign/pulls/317) TI-3 | Next.js страница + 5 компонентов + hooks | `frontend/` |
+| `gendesign/main` PR [#319](https://git.gendsgn.ru/lekss361/gendesign/pulls/319) TI-2 | PDF export 4 страницы (как у Брусники) | `backend/app/services/exporters/` |
+| `gendesign/main` PR [#283](https://git.gendsgn.ru/lekss361/gendesign/pulls/283) | статичный `tradein.html` mockup (для Геныча) | `frontend/public/tradein.html` |
+| Встреча 19.05.2026 («Птица») | требования к MVP оценки вторички | `docs/PTITSA_MEETING_2026-05-19.pdf` |
+| PDF Брусники EКБ-2485 | референс layout-а отчёта | `docs/BRUSNIKA_REFERENCE_EKB-2485.pdf` |
+
+## Быстрый старт
+
+```bash
+make up # build + up весь стек (caddy + frontend + backend + postgres)
+open http://localhost:8080
+```
+
+Откроется `/` → автоматически редирект на `/trade-in`. Заполняешь форму (адрес/площадь/комнаты/этаж/...), нажимаешь «Оценить» — backend возвращает mock-оценку, фронт показывает median + диапазон цен + список аналогов.
+
+Проверка backend напрямую:
+```bash
+make test-estimate
+# или вручную:
+curl -sS -X POST http://localhost:8080/api/v1/trade-in/estimate \
+ -H 'Content-Type: application/json' \
+ -d '{"address":"ул. Малышева, 1","area_m2":54,"rooms":2,"floor":5,"total_floors":17}' \
+ | python3 -m json.tool
+```
+
+OpenAPI документация:
+
+Для сравнения макет vs реальная фича:
+- макет:
+- фича:
+
+## Структура
+
+```
+tradein-mvp/
+├── docker-compose.yml # caddy + frontend + backend + postgres
+├── Makefile # удобные команды (up/down/logs/test-estimate)
+├── deploy/
+│ └── Caddyfile # local reverse-proxy на http://localhost:8080
+├── backend/ # FastAPI + WeasyPrint
+│ ├── Dockerfile
+│ ├── pyproject.toml
+│ ├── app/
+│ │ ├── main.py # FastAPI entry — только trade-in router
+│ │ ├── core/
+│ │ │ ├── config.py # минимальный pydantic-settings
+│ │ │ └── db.py # SQLAlchemy engine + get_db
+│ │ ├── api/v1/
+│ │ │ └── trade_in.py # 3 endpoint'а: POST /estimate, GET /estimate/{id}, GET /estimate/{id}/pdf
+│ │ ├── schemas/
+│ │ │ └── trade_in.py # Pydantic: TradeInEstimateInput / AnalogLot / AggregatedEstimate
+│ │ └── services/exporters/
+│ │ └── trade_in_pdf.py # WeasyPrint → 4-страничный PDF (cover/listings/deals/offer)
+│ └── data/sql/
+│ └── 001_trade_in_estimates.sql # CREATE TABLE; применяется при первом старте postgres
+├── frontend/ # Next.js 15 + React 19 + TanStack Query
+│ ├── Dockerfile
+│ ├── package.json
+│ ├── next.config.ts # rewrites /api/* → backend
+│ ├── tsconfig.json
+│ ├── src/
+│ │ ├── app/
+│ │ │ ├── layout.tsx
+│ │ │ ├── page.tsx # redirect → /trade-in
+│ │ │ ├── globals.css
+│ │ │ ├── providers.tsx # QueryClientProvider
+│ │ │ └── trade-in/
+│ │ │ └── page.tsx
+│ │ ├── components/trade-in/
+│ │ │ ├── EstimateForm.tsx # форма ввода (sticky 360px)
+│ │ │ ├── EstimateProgress.tsx # индикатор «Парсим Циан → Авито → ...»
+│ │ │ ├── EstimateResult.tsx # карточка результата
+│ │ │ ├── PriceRangeBar.tsx # визуализация диапазона цен (как у Брусники)
+│ │ │ └── AnalogsTable.tsx # таблица аналогов
+│ │ ├── lib/
+│ │ │ ├── api.ts # apiFetch + HTTPError
+│ │ │ ├── sessionId.ts # X-Session-Id из localStorage
+│ │ │ └── trade-in-api.ts # useEstimateMutation + useEstimate hooks
+│ │ └── types/
+│ │ └── trade-in.ts # TS типы зеркалят Pydantic schemas
+│ └── public/
+│ └── tradein.html # статичный mockup от 17.05 (для side-by-side review)
+└── docs/
+ ├── BRUSNIKA_REFERENCE_EKB-2485.pdf # эталон layout-а
+ └── PTITSA_MEETING_2026-05-19.pdf # AI-протокол встречи с требованиями
+```
+
+## API
+
+`POST /api/v1/trade-in/estimate` — оценить квартиру.
+
+Запрос:
+```json
+{
+ "address": "ул. Малышева, 1, кв. 5, Екатеринбург",
+ "area_m2": 54.0,
+ "rooms": 2,
+ "floor": 5,
+ "total_floors": 17,
+ "year_built": 1985,
+ "house_type": "panel",
+ "repair_state": "good",
+ "has_balcony": true
+}
+```
+
+Ответ:
+```json
+{
+ "estimate_id": "...uuid...",
+ "median_price_rub": 13125000,
+ "range_low_rub": 11550000,
+ "range_high_rub": 14700000,
+ "median_price_per_m2": 243056,
+ "confidence": "high",
+ "n_analogs": 8,
+ "period_months": 24,
+ "analogs": [ {"address": "...", "area_m2": 56, "price_rub": 12700000, ...} ],
+ "actual_deals": [ ... ],
+ "expires_at": "2026-05-20T22:48:00Z"
+}
+```
+
+`GET /api/v1/trade-in/estimate/{id}` — получить сохранённую оценку (TTL 24ч)
+`GET /api/v1/trade-in/estimate/{id}/pdf` — скачать 4-страничный PDF (cover / listings / deals / offer)
+
+## Что внутри `_mock_estimate()` (текущая реализация)
+
+Формула:
+```
+price = base_price_by_rooms × floor_factor × repair_factor
+```
+
+| Поле | Значения |
+|---|---|
+| Базовая цена ЕКБ 2026 | студия 6.5M (260K/м²) · 1к 9.0M (225K/м²) · 2к 12.5M (208K/м²) · 3к 17.0M (213K/м²) |
+| `floor_factor` | 1-й этаж = ×0.95, последний = ×0.97, остальные = ×1.00 |
+| `repair_factor` | `needs_repair` = ×0.90, `standard` = ×1.00, `good` = ×1.05, `excellent` = ×1.10 |
+| `confidence` | 1-3 комнаты = `high`, остальные = `medium` |
+| Улицы аналогов | реальные центральные ЕКБ (Малышева, Куйбышева, 8 Марта, Белинского, пр. Ленина, Толмачёва, Радищева, Мамина-Сибиряка, Луначарского, Первомайская) |
+
+Каждая оценка сохраняется в `trade_in_estimates` с TTL 24 часа — UUID можно использовать для shareable links и PDF-экспорта.
+
+## Roadmap — что доделать
+
+### Phase 1 — заменить mock на реальные данные (TODO TI-1b из gendesign)
+
+Сейчас `_mock_estimate()` возвращает хардкод. На встрече Птица 19.05 решили:
+- источники: **Циан, Авито, Дом.Клик, Я.Недвижимость, Н1, Дом РФ**
+- **Объектив НЕ использовать** на вторичке (он про первичку/ДДУ)
+- Росреестр для исторических сделок (квартал глубины)
+- картография ЕКБ для проверки этажности/года/планировок
+
+### Phase 2 — то что обсуждали на встрече
+
+| Задача | Из протокола Птицы |
+|---|---|
+| Парольный вход + учёт пользователей + аналитика | 0:23:44, 0:25:54 |
+| Доступ только Геныч / Загайнов / Паша (НЕ Рожкова) | 0:25:50, 0:22:35 |
+| PDF-отчёт под паролем | 0:08:39 |
+| Real-time парсинг ≥1/час чтобы ловить быстрые продажи | 0:40:19 |
+| MVP к понедельнику 25.05.2026 | 0:26:12 |
+| Демо для девелопера в четверг 28.05.2026 | 0:18:08 |
+
+### Phase 3 — следующие продукты (упоминалось на встрече)
+
+1. **Птица** — анализ участков + расселение домов (≥20% квартир дома в продаже → подсветить можно расселять)
+2. **Расселение как сервис** — следствие #1 и Птицы
+
+См. полный протокол: [docs/PTITSA_MEETING_2026-05-19.pdf](docs/PTITSA_MEETING_2026-05-19.pdf).
+
+## Как это связано с прод gendesign
+
+| Аспект | Прод (gendsgn.ru) | Этот MVP |
+|---|---|---|
+| URL | | |
+| Backend | shared FastAPI `/api/v1/trade-in/*` | то же самое, standalone |
+| Frontend | Next.js 15 в большом monorepo | тот же код, standalone |
+| Postgres | 84 таблицы, 6.83M ДДУ partitioned | только `trade_in_estimates` (1 таблица) |
+| Caddy | TLS + 5 доменов + reverse-proxy | local :8080 без TLS |
+| Что отрезано | site-finder, analytics, generative, scraper, OSM, NSPD, sentry, celery, redis, playwright | всё это — кроме trade-in |
+
+**Важно: эти два инстанса полностью изолированы.** Локальный backend пишет в свой Postgres контейнер (порт 5433), не трогает прод. Можно сломать локально что угодно — прод не пострадает.
+
+## Лицензия
+
+Internal use only. Forked from gendesign monorepo (private).
diff --git a/tradein-mvp/backend/.dockerignore b/tradein-mvp/backend/.dockerignore
new file mode 100644
index 00000000..236db7c7
--- /dev/null
+++ b/tradein-mvp/backend/.dockerignore
@@ -0,0 +1,13 @@
+.venv
+__pycache__
+**/__pycache__
+*.egg-info
+.pytest_cache
+.ruff_cache
+.mypy_cache
+.git
+.env
+.env.local
+data/sql
+Dockerfile
+.dockerignore
diff --git a/tradein-mvp/backend/Dockerfile b/tradein-mvp/backend/Dockerfile
new file mode 100644
index 00000000..bd277646
--- /dev/null
+++ b/tradein-mvp/backend/Dockerfile
@@ -0,0 +1,58 @@
+# Trade-In MVP backend — lean image (FastAPI + WeasyPrint).
+# Не содержит Playwright/Chromium/Celery/Redis — только то что нужно для trade-in feature.
+
+# ---- builder ----
+FROM python:3.12-slim AS builder
+
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ UV_COMPILE_BYTECODE=1 \
+ UV_LINK_MODE=copy
+
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+ --mount=type=cache,target=/var/lib/apt,sharing=locked \
+ apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ libpq-dev
+
+RUN pip install --no-cache-dir uv
+
+WORKDIR /app
+
+COPY pyproject.toml ./
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --no-dev
+
+COPY app ./app
+COPY scripts ./scripts
+
+
+# ---- runner ----
+FROM python:3.12-slim AS runner
+
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PATH="/app/.venv/bin:$PATH"
+
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+ --mount=type=cache,target=/var/lib/apt,sharing=locked \
+ apt-get update && apt-get install -y --no-install-recommends \
+ libpq5 \
+ libcairo2 \
+ libpango-1.0-0 \
+ libpangoft2-1.0-0 \
+ fonts-dejavu-core \
+ curl \
+ && useradd --create-home --uid 1000 app
+
+WORKDIR /app
+
+COPY --from=builder --chown=app:app /app/.venv /app/.venv
+COPY --from=builder --chown=app:app /app/app /app/app
+COPY --from=builder --chown=app:app /app/scripts /app/scripts
+
+USER app
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
diff --git a/tradein-mvp/backend/app/__init__.py b/tradein-mvp/backend/app/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tradein-mvp/backend/app/api/__init__.py b/tradein-mvp/backend/app/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tradein-mvp/backend/app/api/v1/__init__.py b/tradein-mvp/backend/app/api/v1/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py
new file mode 100644
index 00000000..18576cbd
--- /dev/null
+++ b/tradein-mvp/backend/app/api/v1/admin.py
@@ -0,0 +1,1076 @@
+"""Admin endpoints — debug + ручной запуск парсеров.
+
+Auth: Caddy basic_auth gate'ит /trade-in/api/v1/admin/* — application layer открыт.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import time
+from typing import Annotated, Any, Literal
+
+from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
+from pydantic import BaseModel, Field
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from app.core.db import SessionLocal, get_db
+from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
+from app.services import cian_session as cian_session_svc
+from app.services import scrape_runs as runs_mod
+from app.services.geocoder import geocode
+from app.services.scrape_pipeline import run_avito_city_sweep
+from app.services.scraper_settings import invalidate_cache
+from app.services.scrapers.avito import AvitoScraper
+from app.services.scrapers.avito_detail import fetch_detail, save_detail_enrichment
+from app.services.scrapers.avito_houses import fetch_house_catalog, save_house_catalog_enrichment
+from app.services.scrapers.avito_imv import (
+ IMVAddressNotFoundError,
+ evaluate_via_imv,
+ save_imv_evaluation,
+ save_imv_placement_history,
+)
+from app.services.scrapers.base import save_listings
+from app.services.scrapers.cian import CianScraper
+from app.services.scrapers.n1 import N1Scraper
+from app.services.scrapers.yandex_detail import YandexDetailScraper
+from app.services.scrapers.yandex_newbuilding import YandexNewbuildingScraper
+from app.services.scrapers.yandex_realty import YandexRealtyScraper
+from app.services.scrapers.yandex_valuation import YandexValuationScraper
+from app.tasks.geocode_missing import GeocodeBackfillResult, geocode_missing_listings
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+_ALL_SOURCES = ["avito", "cian", "yandex", "n1"]
+
+
+class ScrapeRequest(BaseModel):
+ lat: float
+ lon: float
+ radius_m: int = Field(default=1000, ge=100, le=20000)
+ sources: list[Literal["avito", "cian", "yandex", "n1"]] = Field(
+ default_factory=lambda: list(_ALL_SOURCES)
+ )
+ # Если True — скрейп Yandex по 5 сегментам комнат, не одним общим запросом.
+ multi_room_yandex: bool = False
+ # Если True — Yandex полный обход 5 rooms × 3 sorts × 2 pages = 30 запросов / ~150s.
+ deep_yandex: bool = False
+ # Если True — скрейп Cian по 4 сегментам комнат отдельно (~4× лотов).
+ multi_room_cian: bool = False
+ # Если True — скрейп N1 по 4 сегментам комнат отдельно (~4× лотов).
+ multi_room_n1: bool = False
+
+
+class ScrapeResult(BaseModel):
+ source: str
+ fetched: int
+ inserted: int
+ updated: int
+
+
+class ScrapeResponse(BaseModel):
+ total_fetched: int
+ total_inserted: int
+ total_updated: int
+ by_source: list[ScrapeResult]
+
+
+@router.post("/scrape", response_model=ScrapeResponse)
+async def scrape_around(
+ payload: ScrapeRequest,
+ db: Annotated[Session, Depends(get_db)],
+) -> ScrapeResponse:
+ """Запустить парсеры для точки (lat, lon) в радиусе radius_m метров.
+
+ Примеры:
+ curl -X POST /api/v1/admin/scrape \\
+ -H 'Content-Type: application/json' \\
+ -d '{"lat":56.8332,"lon":60.5944,"radius_m":1000,"sources":["avito"]}'
+ """
+ results: list[ScrapeResult] = []
+
+ for source in payload.sources:
+ scraper_cls = {
+ "avito": AvitoScraper,
+ "cian": CianScraper,
+ "yandex": YandexRealtyScraper,
+ "n1": N1Scraper,
+ }.get(source)
+ if scraper_cls is None:
+ continue
+ async with scraper_cls() as scraper:
+ if source == "yandex" and payload.deep_yandex:
+ lots = await scraper.fetch_around_multi_room(
+ payload.lat, payload.lon, payload.radius_m,
+ sorts=("DATE_DESC", "PRICE", "AREA_DESC"),
+ pages=(0, 1),
+ )
+ elif source == "yandex" and payload.multi_room_yandex:
+ lots = await scraper.fetch_around_multi_room(
+ payload.lat, payload.lon, payload.radius_m
+ )
+ elif source == "cian" and payload.multi_room_cian:
+ lots = await scraper.fetch_around_multi_room(
+ payload.lat, payload.lon, payload.radius_m
+ )
+ elif source == "n1" and payload.multi_room_n1:
+ lots = await scraper.fetch_around_multi_room(
+ payload.lat, payload.lon, payload.radius_m
+ )
+ else:
+ lots = await scraper.fetch_around(
+ payload.lat, payload.lon, payload.radius_m
+ )
+ inserted, updated = save_listings(db, lots)
+ results.append(
+ ScrapeResult(source=source, fetched=len(lots), inserted=inserted, updated=updated)
+ )
+
+ return ScrapeResponse(
+ total_fetched=sum(r.fetched for r in results),
+ total_inserted=sum(r.inserted for r in results),
+ total_updated=sum(r.updated for r in results),
+ by_source=results,
+ )
+
+
+def _clean_address_for_geocode(addr: str) -> str:
+ """Чистим address для геокодера.
+
+ Cian отдаёт «улица Латвийская, 56/3 · р-н Чкаловский» — суффикс ' · ...'
+ мешает Nominatim. Берём часть до ' · '. N1 отдаёт «Репина, 75/2 стр.» — ок.
+ """
+ main = addr.split(" · ")[0].strip()
+ return main or addr
+
+
+@router.post("/geocode-missing")
+async def geocode_missing(
+ db: Annotated[Session, Depends(get_db)],
+ limit: int = 100,
+ target: Literal["listings", "deals"] = "listings",
+) -> dict:
+ """Геокодинг listings ИЛИ deals у которых нет lat/lon (используя address).
+
+ target=listings (по умолч.) — объявления Cian/N1; target=deals — сделки Росреестра.
+ Чанк-обработка с бюджетом по времени (~240с, заведомо меньше cron
+ `curl -m 320`): за вызов геокодим сколько успеваем, остаток уходит в
+ `remaining`, cron вызывает в цикле пока `remaining` > 0.
+
+ geocode_tried_at: после КАЖДОЙ попытки (успех/провал) ставим NOW(). Failed-
+ адреса не выбираются повторно 7 дней → cron-loop завершается, не зацикливается.
+ geom обновляется автоматически триггером.
+ """
+ # Доп. фильтр для listings — у Avito/N1 встречаются плейсхолдер-адреса.
+ extra_filter = (
+ "AND address NOT LIKE '%(Avito)%' AND address NOT LIKE '%(N1)%'"
+ if target == "listings"
+ else ""
+ )
+ rows = db.execute(
+ text(
+ f"""
+ SELECT id, address
+ FROM {target}
+ WHERE lat IS NULL
+ AND COALESCE(address, '') != ''
+ {extra_filter}
+ AND (geocode_tried_at IS NULL
+ OR geocode_tried_at < NOW() - interval '7 days')
+ ORDER BY geocode_tried_at NULLS FIRST
+ LIMIT :limit
+ """
+ ),
+ {"limit": limit},
+ ).mappings().all()
+
+ # Бюджет по времени: геокодинг упирается в Nominatim (1 req/sec + typo-тиры
+ # на провалах), 100 адресов могут не уложиться в cron-таймаут `curl -m 320`.
+ # Выходим из цикла заведомо раньше — remaining > 0 заставит cron продолжить.
+ budget_sec = 240.0
+ started = time.monotonic()
+
+ geocoded = 0
+ skipped = 0
+ for row in rows:
+ if time.monotonic() - started > budget_sec:
+ logger.info(
+ "geocode-missing: бюджет %.0fс исчерпан, обработано %d — cron продолжит",
+ budget_sec, geocoded + skipped,
+ )
+ break
+ clean = _clean_address_for_geocode(row["address"])
+ result = await geocode(clean, db)
+ if result is None:
+ # Помечаем что пробовали — иначе ретрай на каждом cron.
+ db.execute(
+ text(f"UPDATE {target} SET geocode_tried_at = NOW() WHERE id = :id"),
+ {"id": row["id"]},
+ )
+ db.commit()
+ skipped += 1
+ continue
+ # geom пересчитается триггером из lat/lon.
+ db.execute(
+ text(
+ f"UPDATE {target} SET lat = :lat, lon = :lon, "
+ "geocode_tried_at = NOW() WHERE id = :id"
+ ),
+ {"lat": result.lat, "lon": result.lon, "id": row["id"]},
+ )
+ db.commit()
+ geocoded += 1
+
+ # Сколько ещё не-геокоженных адресов ждут (для cron-loop'а).
+ remaining = db.execute(
+ text(
+ f"""
+ SELECT count(*) FROM {target}
+ WHERE lat IS NULL
+ AND COALESCE(address, '') != ''
+ {extra_filter}
+ AND (geocode_tried_at IS NULL
+ OR geocode_tried_at < NOW() - interval '7 days')
+ """
+ )
+ ).scalar()
+
+ return {
+ "checked": len(rows),
+ "geocoded": geocoded,
+ "skipped": skipped,
+ "remaining": int(remaining or 0),
+ }
+
+
+@router.post("/scrape/cian/upload-cookies", status_code=200)
+async def upload_cian_cookies(
+ cookies: dict[str, str],
+ db: Annotated[Session, Depends(get_db)],
+) -> dict:
+ """Upload Cian session cookies для аутентификации Valuation Calculator.
+
+ Body: JSON object вида { "_ym_uid": "...", "_cian_visitor_session_id": "...", ... }
+ Cookies экспортируются из Chrome DevTools → Application → Cookies → www.cian.ru
+ или через расширение Cookie-Editor → Export → JSON (object format).
+
+ Шаги:
+ 1. Фильтрует payload по CIAN_REQUIRED_COOKIES.
+ 2. Верифицирует через GET /kalkulator-nedvizhimosti/ — проверяет isAuthenticated.
+ 3. Сохраняет зашифрованно (pgp_sym_encrypt) в cian_session_cookies.
+
+ Returns: {"ok": true, "userId": , "cookieCount": }
+ """
+ if not settings.cookie_encryption_key:
+ raise HTTPException(status_code=503, detail="COOKIE_ENCRYPTION_KEY not configured")
+
+ cleaned = {k: v for k, v in cookies.items() if k in cian_session_svc.CIAN_REQUIRED_COOKIES}
+ if not cleaned:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"No recognized Cian cookies in payload. "
+ f"Expected any of: {sorted(cian_session_svc.CIAN_REQUIRED_COOKIES)}"
+ ),
+ )
+
+ state = await cian_session_svc.verify_session(cleaned)
+ if state is None:
+ raise HTTPException(
+ status_code=401,
+ detail="Cookies invalid or session not authenticated on cian.ru",
+ )
+
+ user_id = state.get("user", {}).get("userId")
+ if not user_id:
+ raise HTTPException(status_code=400, detail="Authenticated state missing userId")
+
+ cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned)
+ return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)}
+
+
+@router.get("/scrape/cian/test-auth", status_code=200)
+async def test_cian_auth(
+ db: Annotated[Session, Depends(get_db)],
+) -> dict:
+ """Проверить что текущие сохранённые Cian cookies ещё валидны.
+
+ Returns: {"authenticated": bool, "userId": , "reason": }
+ """
+ if not settings.cookie_encryption_key:
+ return {"authenticated": False, "userId": None, "reason": "encryption_key_not_configured"}
+
+ cookies = cian_session_svc.load_session(db)
+ if cookies is None:
+ return {"authenticated": False, "userId": None, "reason": "no_session_in_db"}
+
+ state = await cian_session_svc.verify_session(cookies)
+ if state is None:
+ return {"authenticated": False, "userId": None, "reason": "session_expired_or_invalid"}
+
+ user_id = state.get("user", {}).get("userId")
+ return {"authenticated": True, "userId": user_id, "reason": None}
+
+
+# ── Geocode backfill: batch address-dedup geocoder (all sources) ─────────────
+
+
+@router.get("/scrape/geocode-missing-listings/status")
+async def get_geocode_backfill_status(
+ db: Annotated[Session, Depends(get_db)],
+) -> dict[str, Any]:
+ """Текущая статистика backfill — сколько ещё pending geocode.
+
+ Включает per-source breakdown и оценку времени при Nominatim 1 req/sec.
+ """
+ stats = db.execute(
+ text(
+ """
+ SELECT
+ source,
+ COUNT(*) AS total,
+ COUNT(*) FILTER (WHERE lat IS NULL AND address IS NOT NULL) AS pending_geocode,
+ COUNT(*) FILTER (WHERE lat IS NOT NULL) AS has_coords
+ FROM listings
+ WHERE is_active = true
+ GROUP BY source
+ ORDER BY pending_geocode DESC
+ """
+ )
+ ).mappings().all()
+
+ total_pending = sum(s["pending_geocode"] for s in stats)
+ unique_addresses_pending = db.execute(
+ text(
+ """
+ SELECT COUNT(DISTINCT address) FROM listings
+ WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) >= 5
+ """
+ )
+ ).scalar() or 0
+
+ cache_size = db.execute(
+ text("SELECT COUNT(*) FROM geocode_cache WHERE expires_at > NOW()")
+ ).scalar() or 0
+
+ return {
+ "total_pending_listings": total_pending,
+ "unique_addresses_pending": int(unique_addresses_pending),
+ "estimated_nominatim_minutes": round(int(unique_addresses_pending) / 60.0, 1),
+ "cache_size_active": int(cache_size),
+ "per_source": [dict(s) for s in stats],
+ }
+
+
+@router.post("/scrape/geocode-missing-listings")
+async def trigger_geocode_missing_listings(
+ background_tasks: BackgroundTasks,
+ db: Annotated[Session, Depends(get_db)],
+ batch_size: int = Query(default=200, ge=1, le=2000),
+ dry_run: bool = Query(default=False),
+ background: bool = Query(
+ default=False,
+ description="True → запустить в BackgroundTasks (не ждать результат).",
+ ),
+) -> dict[str, Any]:
+ """Manual trigger для geocode backfill всех listings с NULL coords (address-dedup).
+
+ batch_size=200 ≈ 3.5 мин при Nominatim 1 req/sec (без cache hits — быстрее с cache).
+ Безопасно для повторного запуска (ON CONFLICT в geocode_cache, UPDATE WHERE lat IS NULL).
+
+ dry_run=true — показывает что бы сделалось, без UPDATE listings.
+ background=true — запускает через BackgroundTasks, возвращает немедленно (для больших batch).
+ """
+ from app.core.db import SessionLocal
+
+ if background:
+ async def _bg_task() -> None:
+ bg_db = SessionLocal()
+ try:
+ await geocode_missing_listings(bg_db, batch_size=batch_size, dry_run=dry_run)
+ except Exception:
+ logger.exception("geocode-missing-listings background task crashed")
+ finally:
+ bg_db.close()
+
+ background_tasks.add_task(_bg_task)
+ return {
+ "status": "queued",
+ "batch_size": batch_size,
+ "dry_run": dry_run,
+ "note": "Running in background. Check logs for progress.",
+ }
+
+ result: GeocodeBackfillResult = await geocode_missing_listings(
+ db, batch_size=batch_size, dry_run=dry_run
+ )
+ return {
+ "status": "ok",
+ "batch_size": batch_size,
+ "dry_run": dry_run,
+ "addresses_total": result.addresses_total,
+ "addresses_processed": result.addresses_processed,
+ "addresses_geocoded": result.addresses_geocoded,
+ "addresses_failed": result.addresses_failed,
+ "listings_updated": result.listings_updated,
+ "cache_hits": result.cache_hits,
+ "cache_misses": result.cache_misses,
+ "duration_sec": round(result.duration_sec, 2),
+ }
+
+
+# ── Stage 4a: Avito enrichment endpoints (single-shot debug + manual triggers) ──
+
+
+@router.post("/scrape/avito-house")
+async def scrape_avito_house(
+ db: Annotated[Session, Depends(get_db)],
+ house_url: str,
+) -> dict[str, object]:
+ """Enrichment ОДНОГО дома Avito Houses Catalog.
+
+ Body params (query): `house_url=/catalog/houses//` (absolute или relative path).
+
+ Flow: fetch_house_catalog → save_house_catalog_enrichment.
+ Returns counters {'house_id', 'reviews', 'sellers', 'listings_linked', 'placement_history'}.
+ """
+ try:
+ enrichment = await fetch_house_catalog(house_url)
+ except Exception as e:
+ logger.exception("avito-house: fetch failed for %s", house_url)
+ raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
+
+ try:
+ counters = save_house_catalog_enrichment(db, enrichment)
+ except Exception as e:
+ logger.exception("avito-house: save failed for %s", house_url)
+ raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
+
+ logger.info("avito-house ok: %s → %s", house_url, counters)
+ return {"ok": True, "house_url": house_url, "counters": counters}
+
+
+@router.post("/scrape/avito-detail")
+async def scrape_avito_detail(
+ db: Annotated[Session, Depends(get_db)],
+ item_url: str,
+) -> dict[str, object]:
+ """Detail enrichment ОДНОГО listing.
+
+ Body params (query): `item_url=/ekaterinburg/kvartiry/...` (absolute или relative).
+
+ Flow: fetch_detail → save_detail_enrichment (UPDATE listings WHERE source='avito').
+ Returns {'ok', 'item_id', 'updated'}.
+ """
+ try:
+ enrichment = await fetch_detail(item_url)
+ except Exception as e:
+ logger.exception("avito-detail: fetch failed for %s", item_url)
+ raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
+
+ try:
+ updated = save_detail_enrichment(db, enrichment)
+ except Exception as e:
+ logger.exception("avito-detail: save failed for %s", item_url)
+ raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
+
+ if not updated:
+ # Listing с этим source_id ещё не существует в БД — был bypass через
+ # /admin/scrape/avito-detail для несуществующего listing.
+ logger.warning("avito-detail: no listing matched source_id=%s", enrichment.item_id)
+ logger.info("avito-detail ok: %s item_id=%s updated=%s", item_url, enrichment.item_id, updated)
+ return {"ok": True, "item_id": enrichment.item_id, "updated": updated}
+
+
+@router.post("/scrape/avito-imv")
+async def scrape_avito_imv(
+ db: Annotated[Session, Depends(get_db)],
+ address: str,
+ rooms: int,
+ area_m2: float,
+ floor: int,
+ floor_at_home: int,
+ house_type: str,
+ renovation_type: str,
+ has_balcony: bool = False,
+ has_loggia: bool = False,
+) -> dict[str, object]:
+ """Debug-trigger Avito IMV evaluation. БЕЗ cache — всегда свежий fetch.
+
+ Production IMV вызывается из estimator (Stage 3, on-demand с 24h cache).
+ Этот endpoint — для ручной отладки / data-pipeline tools.
+
+ Returns {'ok', 'cache_key', 'recommended_price', 'range', 'market_count',
+ 'evaluation_id', 'history_saved'}.
+ """
+ try:
+ result = await evaluate_via_imv(
+ address=address,
+ rooms=rooms,
+ area_m2=area_m2,
+ floor=floor,
+ floor_at_home=floor_at_home,
+ house_type=house_type,
+ renovation_type=renovation_type,
+ has_balcony=has_balcony,
+ has_loggia=has_loggia,
+ )
+ except IMVAddressNotFoundError as e:
+ raise HTTPException(status_code=404, detail=f"IMV address not found: {e}") from e
+ except Exception as e:
+ logger.exception("avito-imv: fetch failed for %s", address)
+ raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
+
+ try:
+ eval_id = save_imv_evaluation(db, result)
+ history_saved = save_imv_placement_history(db, eval_id, result.placement_history)
+ except Exception as e:
+ logger.exception("avito-imv: save failed for %s", address)
+ raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
+
+ logger.info(
+ "avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d",
+ address[:60],
+ result.recommended_price,
+ eval_id,
+ history_saved,
+ )
+ return {
+ "ok": True,
+ "cache_key": result.cache_key,
+ "recommended_price": result.recommended_price,
+ "range": [result.lower_price, result.higher_price],
+ "market_count": result.market_count,
+ "evaluation_id": eval_id,
+ "history_saved": history_saved,
+ }
+
+
+# ── City sweep: background full-ЕКБ pipeline ────────────────────────────────
+
+
+class CitySweepStartRequest(BaseModel):
+ pages_per_anchor: int = Field(default=3, ge=1, le=10)
+ detail_top_n: int = Field(default=10, ge=0, le=30)
+ request_delay_sec: float = Field(default=7.0, ge=3.0, le=15.0)
+ enrich_houses: bool = True
+ radius_m: int = Field(default=1500, ge=500, le=5000)
+
+
+class CitySweepStartResponse(BaseModel):
+ run_id: int
+ status: str
+ pages_per_anchor: int
+ detail_top_n: int
+
+
+class ScrapeRunRow(BaseModel):
+ run_id: int
+ source: str
+ status: str
+ params: dict | None = None
+ counters: dict | None = None
+ error: str | None = None
+ started_at: str | None = None
+ finished_at: str | None = None
+ heartbeat_at: str | None = None
+
+
+@router.post("/scrape/avito-city-sweep", response_model=CitySweepStartResponse)
+async def start_avito_city_sweep(
+ payload: CitySweepStartRequest,
+ background_tasks: BackgroundTasks,
+ db: Annotated[Session, Depends(get_db)],
+) -> CitySweepStartResponse:
+ """Запустить full ЕКБ city sweep в background. Returns run_id для polling/cancel.
+
+ Sweep итерирует 5 anchor-точек ЕКБ × pages_per_anchor страниц Avito,
+ сохраняет listings, обогащает houses и detail. Runs in FastAPI BackgroundTasks.
+
+ Coop cancel: POST /scrape/avito-city-sweep/{run_id}/cancel помечает run,
+ pipeline проверяет статус каждый anchor.
+ """
+ run_id = runs_mod.create_run(db, source="avito_city_sweep", params=payload.model_dump())
+
+ async def _sweep_task() -> None:
+ sweep_db = SessionLocal()
+ try:
+ await run_avito_city_sweep(
+ sweep_db,
+ run_id=run_id,
+ radius_m=payload.radius_m,
+ pages_per_anchor=payload.pages_per_anchor,
+ enrich_houses=payload.enrich_houses,
+ detail_top_n=payload.detail_top_n,
+ request_delay_sec=payload.request_delay_sec,
+ )
+ except Exception:
+ logger.exception("city-sweep background task run_id=%d crashed", run_id)
+ finally:
+ sweep_db.close()
+
+ background_tasks.add_task(_sweep_task)
+ logger.info("city-sweep queued run_id=%d params=%s", run_id, payload.model_dump())
+ return CitySweepStartResponse(
+ run_id=run_id,
+ status="running",
+ pages_per_anchor=payload.pages_per_anchor,
+ detail_top_n=payload.detail_top_n,
+ )
+
+
+@router.get("/scrape/avito-city-sweep/runs", response_model=list[ScrapeRunRow])
+def list_avito_city_sweep_runs(
+ db: Annotated[Session, Depends(get_db)],
+ limit: int = 10,
+) -> list[ScrapeRunRow]:
+ """Список последних N runs (для UI polling). Default limit=10."""
+ rows = runs_mod.list_recent(db, source="avito_city_sweep", limit=limit)
+ return [
+ ScrapeRunRow(
+ run_id=r["run_id"],
+ source=r["source"],
+ status=r["status"],
+ params=r.get("params"),
+ counters=r.get("counters"),
+ error=r.get("error"),
+ started_at=r["started_at"].isoformat() if r.get("started_at") else None,
+ finished_at=r["finished_at"].isoformat() if r.get("finished_at") else None,
+ heartbeat_at=r["heartbeat_at"].isoformat() if r.get("heartbeat_at") else None,
+ )
+ for r in rows
+ ]
+
+
+@router.post("/scrape/avito-city-sweep/{run_id}/cancel")
+def cancel_avito_city_sweep(
+ run_id: int,
+ db: Annotated[Session, Depends(get_db)],
+) -> dict[str, object]:
+ """Отменить running sweep. Cooperative: pipeline проверяет scrape_runs.status каждый anchor."""
+ cancelled = runs_mod.mark_cancelled(db, run_id)
+ return {"ok": True, "run_id": run_id, "cancelled": cancelled}
+
+
+# ── Scraper settings: global + per-source delay management ───────────────────
+
+
+class ScraperSettingPayload(BaseModel):
+ source: str = Field(..., min_length=1, max_length=64)
+ request_delay_sec: float = Field(..., ge=0.0, le=60.0)
+ description: str | None = None
+
+
+@router.get("/scraper-settings", status_code=200)
+def list_scraper_settings(
+ db: Annotated[Session, Depends(get_db)],
+) -> dict:
+ """Список всех настроек задержки (per-source + global)."""
+ rows = db.execute(
+ text("""
+ SELECT source, request_delay_sec, description, updated_at
+ FROM scraper_settings
+ ORDER BY source ASC
+ """)
+ ).mappings().all()
+ return {"settings": [dict(r) for r in rows]}
+
+
+@router.put("/scraper-settings/{source}", status_code=200)
+def update_scraper_setting(
+ source: str,
+ payload: ScraperSettingPayload,
+ db: Annotated[Session, Depends(get_db)],
+) -> dict:
+ """Обновить задержку для source (или 'global'). Path source используется как ключ.
+
+ Эффект применяется немедленно — invalidate_cache() сбрасывает кеш для source,
+ следующий get_scraper_delay() перечитает из БД.
+
+ source='global' — нижняя планка для всех парсеров (0 = отключено).
+ """
+ if payload.source != source:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Path source={source!r} does not match body source={payload.source!r}",
+ )
+
+ row = db.execute(
+ text("""
+ INSERT INTO scraper_settings (source, request_delay_sec, description, updated_at)
+ VALUES (:s, CAST(:d AS numeric), :desc, NOW())
+ ON CONFLICT (source) DO UPDATE SET
+ request_delay_sec = EXCLUDED.request_delay_sec,
+ description = COALESCE(EXCLUDED.description, scraper_settings.description),
+ updated_at = NOW()
+ RETURNING source, request_delay_sec, description, updated_at
+ """),
+ {
+ "s": source,
+ "d": payload.request_delay_sec,
+ "desc": payload.description,
+ },
+ ).mappings().one()
+ db.commit()
+
+ invalidate_cache(source)
+ logger.info(
+ "scraper-settings: updated source=%s delay=%.1f", source, payload.request_delay_sec
+ )
+ return dict(row)
+
+
+# ── In-app scheduler endpoints (Stage 4e) ────────────────────────────────────
+
+
+@router.get("/scrape/schedules", response_model=list[ScheduleConfig])
+def list_schedules(db: Annotated[Session, Depends(get_db)]) -> list[ScheduleConfig]:
+ """Список всех schedules. Сейчас single row 'avito_city_sweep', extensible."""
+ rows = db.execute(
+ text(
+ """
+ SELECT id, source, enabled, window_start_hour, window_end_hour,
+ default_params, last_run_id, last_run_at, next_run_at,
+ created_at, updated_at
+ FROM scrape_schedules
+ ORDER BY source
+ """
+ ),
+ ).mappings().all()
+ return [
+ ScheduleConfig(
+ id=r["id"],
+ source=r["source"],
+ enabled=r["enabled"],
+ window_start_hour=r["window_start_hour"],
+ window_end_hour=r["window_end_hour"],
+ default_params=r["default_params"] or {},
+ last_run_id=r["last_run_id"],
+ last_run_at=r["last_run_at"].isoformat() if r["last_run_at"] else None,
+ next_run_at=r["next_run_at"].isoformat() if r["next_run_at"] else None,
+ updated_at=r["updated_at"].isoformat() if r["updated_at"] else None,
+ )
+ for r in rows
+ ]
+
+
+@router.put("/scrape/schedules/{source}", response_model=ScheduleConfig)
+def update_schedule(
+ source: str,
+ payload: ScheduleConfigUpdate,
+ db: Annotated[Session, Depends(get_db)],
+) -> ScheduleConfig:
+ """UPDATE existing schedule (create если не существует, через INSERT ON CONFLICT)."""
+ from app.services.scheduler import compute_next_run_at
+
+ # Compute new next_run_at если window изменился — recompute, иначе keep existing
+ next_at = compute_next_run_at(payload.window_start_hour, payload.window_end_hour)
+
+ row = db.execute(
+ text(
+ """
+ INSERT INTO scrape_schedules
+ (source, enabled, window_start_hour, window_end_hour, default_params, next_run_at)
+ VALUES (:source, :enabled, :ws, :we, CAST(:params AS jsonb), :next_at)
+ ON CONFLICT (source) DO UPDATE SET
+ enabled = EXCLUDED.enabled,
+ window_start_hour = EXCLUDED.window_start_hour,
+ window_end_hour = EXCLUDED.window_end_hour,
+ default_params = EXCLUDED.default_params,
+ next_run_at = EXCLUDED.next_run_at,
+ updated_at = NOW()
+ RETURNING id, source, enabled, window_start_hour, window_end_hour,
+ default_params, last_run_id, last_run_at, next_run_at, updated_at
+ """
+ ),
+ {
+ "source": source,
+ "enabled": payload.enabled,
+ "ws": payload.window_start_hour,
+ "we": payload.window_end_hour,
+ "params": json.dumps(payload.default_params, ensure_ascii=False),
+ "next_at": next_at,
+ },
+ ).mappings().fetchone()
+ db.commit()
+
+ assert row is not None, f"scrape_schedules upsert returned no row for source={source!r}"
+ return ScheduleConfig(
+ id=row["id"],
+ source=row["source"],
+ enabled=row["enabled"],
+ window_start_hour=row["window_start_hour"],
+ window_end_hour=row["window_end_hour"],
+ default_params=row["default_params"] or {},
+ last_run_id=row["last_run_id"],
+ last_run_at=row["last_run_at"].isoformat() if row["last_run_at"] else None,
+ next_run_at=row["next_run_at"].isoformat() if row["next_run_at"] else None,
+ updated_at=row["updated_at"].isoformat() if row["updated_at"] else None,
+ )
+
+
+# -- Yandex ad-hoc scrape triggers --------------------------------------------
+
+
+class YandexDetailTriggerResp(BaseModel):
+ ok: bool
+ offer_url: str
+ offer_id: str | None
+ price_rub: int | None
+ title: str | None
+ photo_count: int
+
+
+@router.post("/scrape/yandex-detail", response_model=YandexDetailTriggerResp)
+async def scrape_yandex_detail(
+ offer_url: str,
+) -> YandexDetailTriggerResp:
+ """Ad-hoc parse one Yandex offer detail page.
+
+ Returns a snapshot of the extracted DetailEnrichment (no DB write - read-only
+ debug; main pipeline writes via estimator on /estimate flow).
+ """
+ async with YandexDetailScraper() as scraper:
+ result = await scraper.fetch_detail(offer_url)
+ if result is None:
+ raise HTTPException(404, f"Could not parse Yandex detail page: {offer_url}")
+ return YandexDetailTriggerResp(
+ ok=True,
+ offer_url=offer_url,
+ offer_id=result.offer_id,
+ price_rub=result.price_rub,
+ title=result.title,
+ photo_count=len(result.photo_urls),
+ )
+
+
+class YandexNewbuildingTriggerResp(BaseModel):
+ ok: bool
+ ext_id: str
+ ext_slug: str
+ name: str | None
+ lat: float | None
+ lon: float | None
+ rating: float | None
+ ratings_count: int | None
+ text_reviews_count: int | None
+ developer_name: str | None
+
+
+@router.post("/scrape/yandex-newbuilding", response_model=YandexNewbuildingTriggerResp)
+async def scrape_yandex_newbuilding(
+ slug: str,
+ id: str,
+ city: str = "ekaterinburg",
+) -> YandexNewbuildingTriggerResp:
+ """Ad-hoc parse a Yandex JK landing page.
+
+ URL: /{city}/kupit/novostrojka/-/
+ """
+ async with YandexNewbuildingScraper() as scraper:
+ result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
+ if result is None:
+ raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")
+ return YandexNewbuildingTriggerResp(
+ ok=True,
+ ext_id=result.ext_id,
+ ext_slug=result.ext_slug,
+ name=result.name,
+ lat=result.lat,
+ lon=result.lon,
+ rating=result.rating,
+ ratings_count=result.ratings_count,
+ text_reviews_count=result.text_reviews_count,
+ developer_name=result.developer_name,
+ )
+
+
+class YandexValuationTriggerResp(BaseModel):
+ ok: bool
+ address: str
+ year_built: int | None
+ total_floors: int | None
+ house_type: str | None
+ has_lift: bool | None
+ total_objects: int | None
+ history_items_count: int
+
+
+@router.post("/scrape/yandex-valuation", response_model=YandexValuationTriggerResp)
+async def scrape_yandex_valuation(
+ address: str,
+ offer_category: str = "APARTMENT",
+ offer_type: str = "SELL",
+ page: int = 1,
+) -> YandexValuationTriggerResp:
+ """Ad-hoc fetch Yandex Valuation house-history for an address (debug, no cache write).
+
+ Anonymous GET - works without auth.
+ """
+ async with YandexValuationScraper() as scraper:
+ result = await scraper.fetch_house_history(
+ address=address,
+ offer_category=offer_category,
+ offer_type=offer_type,
+ page=page,
+ )
+ if result is None:
+ raise HTTPException(404, f"Could not parse Yandex Valuation: {address}")
+ return YandexValuationTriggerResp(
+ ok=True,
+ address=address,
+ year_built=result.house.year_built,
+ total_floors=result.house.total_floors,
+ house_type=result.house.house_type,
+ has_lift=result.house.has_lift,
+ total_objects=result.house.total_objects,
+ history_items_count=len(result.history_items),
+ )
+
+
+class CianDetailTriggerResp(BaseModel):
+ ok: bool
+ offer_url: str
+ cian_id: int | None
+ price_changes_count: int
+ saved: bool
+ listing_id: int | None
+
+
+@router.post("/scrape/cian-detail", response_model=CianDetailTriggerResp)
+async def scrape_cian_detail(
+ offer_url: str,
+ listing_id: int | None = None,
+ db: Session = Depends(get_db), # noqa: B008
+) -> CianDetailTriggerResp:
+ """Ad-hoc parse one Cian offer detail page.
+
+ If `listing_id` provided → save enrichment (offer_price_history + listings updates).
+ Without it → debug-only (no DB write).
+ """
+ from app.services.scrapers.cian_detail import fetch_detail, save_detail_enrichment
+
+ enrichment = await fetch_detail(offer_url)
+ if enrichment is None:
+ raise HTTPException(404, f"Could not parse Cian detail page: {offer_url}")
+
+ saved = False
+ if listing_id is not None:
+ save_detail_enrichment(db, listing_id, enrichment)
+ saved = True
+
+ return CianDetailTriggerResp(
+ ok=True,
+ offer_url=offer_url,
+ cian_id=enrichment.cian_id,
+ price_changes_count=len(enrichment.price_changes),
+ saved=saved,
+ listing_id=listing_id,
+ )
+
+
+class CianNewbuildingTriggerResp(BaseModel):
+ ok: bool
+ zhk_url: str
+ cian_internal_house_id: int | None
+ name: str | None
+ price_dynamics_count: int
+ has_management_company: bool
+ saved: bool
+ house_id: int | None
+
+
+@router.post("/scrape/cian-newbuilding", response_model=CianNewbuildingTriggerResp)
+async def scrape_cian_newbuilding(
+ zhk_url: str,
+ house_id: int | None = None,
+ db: Session = Depends(get_db), # noqa: B008
+) -> CianNewbuildingTriggerResp:
+ """Ad-hoc parse a Cian ЖК (newbuilding) catalog page.
+
+ If `house_id` provided → persist (houses + houses_price_dynamics + management_companies).
+ Without it → debug-only (no DB write).
+ """
+ from app.services.scrapers.cian_newbuilding import (
+ fetch_newbuilding,
+ save_newbuilding_enrichment,
+ )
+
+ enrichment = await fetch_newbuilding(zhk_url)
+ if enrichment is None:
+ raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}")
+
+ saved = False
+ if house_id is not None:
+ await save_newbuilding_enrichment(db, house_id, enrichment)
+ saved = True
+
+ return CianNewbuildingTriggerResp(
+ ok=True,
+ zhk_url=zhk_url,
+ cian_internal_house_id=enrichment.cian_internal_house_id,
+ name=enrichment.name,
+ price_dynamics_count=len(enrichment.realty_valuation_chart),
+ has_management_company=bool(enrichment.management_company),
+ saved=saved,
+ house_id=house_id,
+ )
+
+
+class CianBackfillResp(BaseModel):
+ ok: bool
+ listings_total: int
+ listings_processed: int
+ listings_succeeded: int
+ listings_failed_fetch: int
+ listings_failed_save: int
+ price_changes_attempted: int
+ houses_total: int
+ houses_processed: int
+ houses_succeeded: int
+ houses_failed_fetch: int
+ houses_failed_save: int
+ valuations_total: int
+ valuations_processed: int
+ valuations_succeeded: int
+ valuations_failed: int
+ duration_sec: float
+
+
+@router.post("/scrape/cian-backfill-history", response_model=CianBackfillResp)
+async def scrape_cian_backfill_history(
+ batch_size: int = Query(50, ge=1, le=200),
+ do_listings: bool = True,
+ do_houses: bool = True,
+ do_valuations: bool = False,
+ dry_run: bool = False,
+ db: Session = Depends(get_db), # noqa: B008
+) -> CianBackfillResp:
+ """Batch backfill для Cian historical data.
+
+ Iterates Cian listings with missing offer_price_history, fetches Cian detail
+ pages, persists enrichment (price changes, views, agent, ceiling_height, etc.).
+
+ Houses block (houses_price_dynamics) is currently a no-op — requires
+ a cian_zhk_url column on houses (migration pending, see TODO in
+ app/tasks/cian_history_backfill.py).
+
+ Rate-limit: scraper_settings 'cian' delay (default 5s) between requests.
+ Idempotent: skips listings that already have offer_price_history rows.
+ """
+ from app.tasks.cian_history_backfill import CianBackfillResult, backfill_cian_history
+
+ result: CianBackfillResult = await backfill_cian_history(
+ db,
+ batch_size=batch_size,
+ do_listings=do_listings,
+ do_houses=do_houses,
+ do_valuations=do_valuations,
+ dry_run=dry_run,
+ )
+ return CianBackfillResp(ok=True, **result.__dict__)
diff --git a/tradein-mvp/backend/app/api/v1/brand.py b/tradein-mvp/backend/app/api/v1/brand.py
new file mode 100644
index 00000000..325ffac7
--- /dev/null
+++ b/tradein-mvp/backend/app/api/v1/brand.py
@@ -0,0 +1,22 @@
+"""Brand endpoint — frontend дёргает чтобы получить логотип/цвет для white-label."""
+
+from __future__ import annotations
+
+from typing import Annotated
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from app.core.db import get_db
+from app.services.brand import Brand, get_brand
+
+router = APIRouter()
+
+
+@router.get("/{slug}", response_model=Brand)
+def lookup(slug: str, db: Annotated[Session, Depends(get_db)]) -> Brand:
+ """GET /api/v1/brand/prinzip → {slug, name, primary_color, ...}.
+
+ Не найден → fallback generic.
+ """
+ return get_brand(slug, db)
diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py
new file mode 100644
index 00000000..2c4da301
--- /dev/null
+++ b/tradein-mvp/backend/app/api/v1/geocode.py
@@ -0,0 +1,119 @@
+"""Geocode endpoints — debug + frontend Suggest proxy."""
+
+from __future__ import annotations
+
+import logging
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel, Field
+from sqlalchemy.orm import Session
+
+from app.core.db import get_db
+from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+@router.get("/lookup", response_model=GeocodeResult)
+async def lookup(
+ address: Annotated[str, Query(min_length=3, max_length=500)],
+ db: Annotated[Session, Depends(get_db)],
+) -> GeocodeResult:
+ """Геокодинг адреса → lat/lon.
+
+ Примеры:
+ /api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург
+ /api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург
+ """
+ result = await geocode(address, db)
+ if result is None:
+ raise HTTPException(status_code=404, detail=f"Address not found: {address}")
+ return result
+
+
+class SuggestItem(BaseModel):
+ label: str
+ full_address: str
+ lat: float
+ lon: float
+ kind: str
+
+
+class SuggestResponse(BaseModel):
+ items: list[SuggestItem]
+
+
+@router.get("/suggest", response_model=SuggestResponse)
+async def suggest_addresses(
+ q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")],
+ limit: Annotated[int, Query(ge=1, le=15)] = 8,
+ db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
+) -> SuggestResponse:
+ """Автокомплит адресов в пределах ЕКБ.
+
+ Используется в EstimateForm для подсказок пока пользователь печатает.
+ Bounded viewbox = ЕКБ (lon 60.40-60.85, lat 56.65-56.95).
+
+ Пример:
+ /api/v1/geocode/suggest?q=Малышева
+ /api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
+ """
+ items = await suggest(q, db=db, limit=limit)
+ return SuggestResponse(items=[
+ SuggestItem(
+ label=s.label,
+ full_address=s.full_address,
+ lat=s.lat, lon=s.lon, kind=s.kind,
+ ) for s in items
+ ])
+
+
+class ReverseResponse(BaseModel):
+ """Reverse-geocode response для MapPicker'а.
+
+ `lat`/`lon` — echo входной точки клика (для логики «отодвинули ли далеко»).
+ `snapped_lat`/`snapped_lon` — центр matched здания от provider'а. Если
+ `precision in ("exact","number")` фронт двигает marker на snapped point —
+ пользователь видит «магнит к дому». Для остальных precision snapped == input.
+ """
+
+ address: str
+ lat: float = Field(..., description="Исходная latitude клика")
+ lon: float = Field(..., description="Исходная longitude клика")
+ snapped_lat: float = Field(..., description="Latitude центра matched здания")
+ snapped_lon: float = Field(..., description="Longitude центра matched здания")
+ precision: str = Field(
+ ...,
+ description=(
+ "Yandex-style: exact/number/street/range/near/locality/other/cadastral. "
+ "Фронт двигает marker только если exact/number/cadastral."
+ ),
+ )
+ provider: str = Field(..., description="cadastral | yandex | nominatim")
+
+
+@router.get("/reverse", response_model=ReverseResponse)
+async def reverse(
+ lat: Annotated[float, Query(ge=-90, le=90)],
+ lon: Annotated[float, Query(ge=-180, le=180)],
+ db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
+) -> ReverseResponse:
+ """Обратный геокодинг — координаты с карты → адрес + snapped точка здания.
+
+ Пример: /api/v1/geocode/reverse?lat=56.8389&lon=60.6057
+ """
+ result = await reverse_geocode(lat, lon, db=db)
+ if result is None:
+ raise HTTPException(status_code=404, detail="address not found for coordinates")
+ return ReverseResponse(
+ address=result.address,
+ lat=lat,
+ lon=lon,
+ snapped_lat=result.snapped_lat,
+ snapped_lon=result.snapped_lon,
+ precision=result.precision,
+ provider=result.provider,
+ )
diff --git a/tradein-mvp/backend/app/api/v1/me.py b/tradein-mvp/backend/app/api/v1/me.py
new file mode 100644
index 00000000..4b7da093
--- /dev/null
+++ b/tradein-mvp/backend/app/api/v1/me.py
@@ -0,0 +1,46 @@
+"""GET /me — отдаёт текущего пользователя и его RBAC-scope.
+
+MIRROR of main backend's app/api/v1/me.py — kept in sync manually.
+Mounted at /api/v1/me; через Caddy `uri strip_prefix /trade-in` это становится
+`/trade-in/api/v1/me` снаружи.
+
+Caddy basic_auth пропускает `X-Authenticated-User: ` через
+`header_up` в каждом reverse_proxy. Frontend дёргает /me чтобы понять
+кому что показывать.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Annotated
+
+from fastapi import APIRouter, Header, HTTPException
+
+from app.core.auth import UserScope, get_user_scope
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+@router.get("/me")
+async def me(
+ x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
+) -> UserScope:
+ """Return the current user's RBAC scope (role + allowed/deny paths)."""
+ if not x_authenticated_user:
+ raise HTTPException(
+ status_code=401,
+ detail="no authenticated user (Caddy basic_auth required)",
+ )
+ try:
+ return get_user_scope(x_authenticated_user)
+ except KeyError:
+ logger.warning(
+ "user %r authenticated via Caddy but missing from roles.yaml",
+ x_authenticated_user,
+ )
+ raise HTTPException(
+ status_code=403,
+ detail="user not in roles config",
+ ) from None
diff --git a/tradein-mvp/backend/app/api/v1/search.py b/tradein-mvp/backend/app/api/v1/search.py
new file mode 100644
index 00000000..99b8b7f0
--- /dev/null
+++ b/tradein-mvp/backend/app/api/v1/search.py
@@ -0,0 +1,64 @@
+"""Search endpoint — POST /api/v1/search (Phase 3.2, master plan sec 9.1)."""
+
+from __future__ import annotations
+
+import logging
+import time
+from typing import Annotated
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.core.db import get_db
+from app.schemas.search import SearchParams
+from app.schemas.search_response import SearchResponse, SearchResultItem
+from app.services.cache import get_search_cache
+from app.services.search_query import build_count_query, build_search_query
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+@router.post("/search", response_model=SearchResponse)
+async def search(
+ params: SearchParams,
+ db: Annotated[Session, Depends(get_db)],
+) -> SearchResponse:
+ """Поиск listings по фильтрам (cross-source matview + Redis 5min cache)."""
+ started = time.monotonic()
+ cache = get_search_cache()
+ cache_key = cache.search_key(params.model_dump(mode="json"))
+
+ cached = await cache.get(cache_key)
+ if cached is not None:
+ logger.info(
+ "search cache HIT key=%s page=%d size=%d",
+ cache_key[:24], params.page, params.page_size,
+ )
+ return SearchResponse.model_validate(cached)
+
+ sql, args = build_search_query(params)
+ rows = db.execute(text(sql), args).mappings().all()
+
+ count_sql, count_args = build_count_query(params)
+ total = int(db.execute(text(count_sql), count_args).scalar() or 0)
+
+ items = [SearchResultItem.model_validate(dict(r)) for r in rows]
+ elapsed_ms = (time.monotonic() - started) * 1000.0
+ response = SearchResponse(
+ items=items,
+ total=total,
+ page=params.page,
+ page_size=params.page_size,
+ elapsed_ms=round(elapsed_ms, 1),
+ cache_hit=False,
+ )
+
+ await cache.set(cache_key, response.model_dump(mode="json"), ttl=cache.TTL_SEARCH)
+ logger.info(
+ "search MISS page=%d size=%d total=%d items=%d elapsed=%.1fms",
+ params.page, params.page_size, total, len(items), elapsed_ms,
+ )
+ return response
diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py
new file mode 100644
index 00000000..d22f157e
--- /dev/null
+++ b/tradein-mvp/backend/app/api/v1/trade_in.py
@@ -0,0 +1,1334 @@
+"""Trade-In Estimator — endpoints (TI-2 PDF, photos, history).
+
+Реальная оценка делается через app.services.estimator.estimate_quality().
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import UTC, date, datetime, timedelta
+from typing import Annotated, Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.core.db import get_db
+from app.schemas.trade_in import (
+ AggregatedEstimate,
+ AnalogLot,
+ CianPriceChangeStats,
+ HouseAnalyticsKpi,
+ HouseAnalyticsResponse,
+ HouseInfoForEstimate,
+ IMVBenchmarkResponse,
+ PhotoMeta,
+ PlacementHistoryEntry,
+ PriceHistoryYearPoint,
+ RecentSoldEntry,
+ SalesListingPair,
+ SalesVsListingsResponse,
+ SellTimeBucket,
+ SellTimeSensitivityResponse,
+ StreetDealsResponse,
+ TradeInEstimateInput,
+)
+from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
+from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+@router.post("/estimate", response_model=AggregatedEstimate)
+async def estimate(
+ payload: TradeInEstimateInput,
+ db: Annotated[Session, Depends(get_db)],
+) -> AggregatedEstimate:
+ """Реальная оценка через SQL aggregation поверх listings + deals.
+
+ 1. Geocode address → lat/lon
+ 2. PostGIS ST_DWithin радиус 800м (или 2км fallback)
+ 3. Tukey IQR outlier filter
+ 4. Median + Q1 + Q3 + confidence с explanation
+ """
+ from app.services.estimator import estimate_quality
+ return await estimate_quality(payload, db)
+
+
+@router.get("/estimate/{estimate_id}", response_model=AggregatedEstimate)
+def get_estimate(
+ estimate_id: UUID,
+ db: Annotated[Session, Depends(get_db)],
+) -> AggregatedEstimate:
+ """Получить сохранённую оценку по UUID (для генерации PDF).
+
+ Возвращает 404 если оценка не найдена или TTL истёк.
+ """
+ row = db.execute(
+ text(
+ """
+ SELECT id, median_price, range_low, range_high, median_price_per_m2,
+ confidence, confidence_explanation, n_analogs,
+ analogs, actual_deals, sources_used, data_freshness_minutes,
+ expires_at, address, lat, lon,
+ area_m2, rooms, floor, total_floors,
+ year_built, house_type, repair_state, has_balcony
+ FROM trade_in_estimates
+ WHERE id = CAST(:id AS uuid)
+ AND expires_at > NOW()
+ """
+ ),
+ {"id": str(estimate_id)},
+ ).fetchone()
+
+ if row is None:
+ raise HTTPException(status_code=404, detail="estimate not found or expired")
+
+ analogs = [AnalogLot(**a) for a in (row.analogs or [])]
+ actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]
+
+ # ВАЖНО: возвращаем ПОЛНЫЙ набор полей. Раньше эндпоинт отдавал огрызок
+ # без sources_used / confidence_explanation / координат — и при открытии
+ # оценки по ссылке (?id=) карточка источников пустела до «0/7».
+ return AggregatedEstimate(
+ estimate_id=row.id,
+ median_price_rub=row.median_price,
+ range_low_rub=row.range_low,
+ range_high_rub=row.range_high,
+ median_price_per_m2=row.median_price_per_m2,
+ confidence=row.confidence,
+ confidence_explanation=row.confidence_explanation,
+ n_analogs=row.n_analogs,
+ period_months=12,
+ analogs=analogs,
+ actual_deals=actual_deals,
+ expires_at=row.expires_at,
+ target_address=row.address,
+ target_lat=row.lat,
+ target_lon=row.lon,
+ sources_used=row.sources_used or [],
+ data_freshness_minutes=row.data_freshness_minutes,
+ area_m2=row.area_m2,
+ rooms=row.rooms,
+ floor=row.floor,
+ total_floors=row.total_floors,
+ year_built=row.year_built,
+ house_type=row.house_type,
+ repair_state=row.repair_state,
+ has_balcony=row.has_balcony,
+ )
+
+
+@router.get("/estimate/{estimate_id}/pdf")
+def estimate_pdf(
+ estimate_id: UUID,
+ db: Annotated[Session, Depends(get_db)],
+ brand: str | None = None,
+) -> Response:
+ """Скачать 4-страничный PDF-отчёт для оценки trade-in.
+
+ Возвращает application/pdf attachment.
+ 404 — оценка не найдена.
+ 410 — оценка просрочена (TTL 24ч).
+ """
+ row = db.execute(
+ text(
+ """
+ SELECT id, median_price, range_low, range_high, median_price_per_m2,
+ confidence, confidence_explanation, n_analogs,
+ analogs, actual_deals, sources_used, data_freshness_minutes,
+ expires_at,
+ address, lat, lon, area_m2, rooms, floor, total_floors,
+ year_built, house_type, repair_state, has_balcony
+ FROM trade_in_estimates
+ WHERE id = CAST(:id AS uuid)
+ """
+ ),
+ {"id": str(estimate_id)},
+ ).fetchone()
+
+ if row is None:
+ raise HTTPException(status_code=404, detail="estimate not found")
+
+ if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
+ raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
+
+ analogs = [AnalogLot(**a) for a in (row.analogs or [])]
+ actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]
+
+ estimate = AggregatedEstimate(
+ estimate_id=row.id,
+ median_price_rub=row.median_price,
+ range_low_rub=row.range_low,
+ range_high_rub=row.range_high,
+ median_price_per_m2=row.median_price_per_m2,
+ confidence=row.confidence,
+ confidence_explanation=row.confidence_explanation,
+ n_analogs=row.n_analogs,
+ period_months=24,
+ analogs=analogs,
+ actual_deals=actual_deals,
+ expires_at=row.expires_at,
+ target_address=row.address,
+ target_lat=row.lat,
+ target_lon=row.lon,
+ sources_used=row.sources_used or [],
+ data_freshness_minutes=row.data_freshness_minutes,
+ )
+ input_snapshot = {
+ "address": row.address,
+ "area_m2": row.area_m2,
+ "rooms": row.rooms,
+ "floor": row.floor,
+ "total_floors": row.total_floors,
+ "year_built": row.year_built,
+ "house_type": row.house_type,
+ "repair_state": row.repair_state,
+ "has_balcony": row.has_balcony,
+ }
+
+ from app.services.brand import get_brand as _resolve_brand
+ brand_obj = _resolve_brand(brand, db)
+ pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
+ filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
+ logger.info(
+ "PDF generated estimate_id=%s brand=%s size=%d",
+ estimate_id, brand_obj.slug, len(pdf_bytes),
+ )
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
+# ── Фото квартиры (#394) ─────────────────────────────────────────────────────
+_MAX_PHOTO_BYTES = 10 * 1024 * 1024 # 10 МБ на фото
+_MAX_PHOTOS_PER_ESTIMATE = 12
+_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"}
+
+
+@router.post("/estimate/{estimate_id}/photos", response_model=PhotoMeta)
+async def upload_photo(
+ estimate_id: UUID,
+ db: Annotated[Session, Depends(get_db)],
+ file: Annotated[UploadFile, File()],
+) -> PhotoMeta:
+ """Загрузить фото квартиры к оценке (#394). Хранение в estimate_photos (bytea)."""
+ estimate = db.execute(
+ text("SELECT 1 FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
+ {"id": str(estimate_id)},
+ ).fetchone()
+ if estimate is None:
+ raise HTTPException(status_code=404, detail="estimate not found")
+
+ ctype = (file.content_type or "").lower()
+ if ctype not in _ALLOWED_IMAGE_TYPES:
+ raise HTTPException(
+ status_code=415, detail=f"unsupported content-type: {ctype or 'unknown'}"
+ )
+
+ count = db.execute(
+ text("SELECT count(*) FROM estimate_photos WHERE estimate_id = CAST(:id AS uuid)"),
+ {"id": str(estimate_id)},
+ ).scalar_one()
+ if count >= _MAX_PHOTOS_PER_ESTIMATE:
+ raise HTTPException(
+ status_code=409, detail=f"photo limit reached ({_MAX_PHOTOS_PER_ESTIMATE})"
+ )
+
+ content = await file.read()
+ if not content:
+ raise HTTPException(status_code=400, detail="empty file")
+ if len(content) > _MAX_PHOTO_BYTES:
+ raise HTTPException(status_code=413, detail="file too large (max 10 MB)")
+
+ # Sanitize: re-encode through Pillow to drop EXIF, kill polyglot payloads,
+ # cap dimensions. Closes finding #6 from 2026-05-24 audit.
+ try:
+ sanitized_bytes, sanitized_ctype = sanitize_image(content)
+ except ImageSanitizationError as e:
+ raise HTTPException(status_code=400, detail=str(e)) from e
+
+ row = db.execute(
+ text(
+ """
+ INSERT INTO estimate_photos
+ (estimate_id, filename, content_type, content, size_bytes)
+ VALUES (CAST(:eid AS uuid), :fn, :ct, :content, :sz)
+ RETURNING id, filename, content_type, size_bytes, uploaded_at
+ """
+ ),
+ {
+ "eid": str(estimate_id),
+ "fn": file.filename,
+ "ct": sanitized_ctype,
+ "content": sanitized_bytes,
+ "sz": len(sanitized_bytes),
+ },
+ ).mappings().fetchone()
+ db.commit()
+ logger.info(
+ "photo uploaded: estimate=%s photo=%s orig_size=%d sanitized_size=%d ctype=%s",
+ estimate_id, row["id"], len(content), len(sanitized_bytes), sanitized_ctype,
+ )
+ return PhotoMeta(**row)
+
+
+@router.get("/estimate/{estimate_id}/photos", response_model=list[PhotoMeta])
+def list_photos(
+ estimate_id: UUID,
+ db: Annotated[Session, Depends(get_db)],
+) -> list[PhotoMeta]:
+ """Список фото оценки — метаданные, без содержимого (#394)."""
+ rows = db.execute(
+ text(
+ """
+ SELECT id, filename, content_type, size_bytes, uploaded_at
+ FROM estimate_photos
+ WHERE estimate_id = CAST(:id AS uuid)
+ ORDER BY uploaded_at
+ """
+ ),
+ {"id": str(estimate_id)},
+ ).mappings().all()
+ return [PhotoMeta(**r) for r in rows]
+
+
+@router.get("/estimate/{estimate_id}/photos/{photo_id}")
+def get_photo(
+ estimate_id: UUID,
+ photo_id: UUID,
+ db: Annotated[Session, Depends(get_db)],
+) -> Response:
+ """Отдать содержимое фото — image bytes (#394)."""
+ row = db.execute(
+ text(
+ """
+ SELECT content, content_type
+ FROM estimate_photos
+ WHERE id = CAST(:pid AS uuid) AND estimate_id = CAST(:eid AS uuid)
+ """
+ ),
+ {"pid": str(photo_id), "eid": str(estimate_id)},
+ ).fetchone()
+ if row is None:
+ raise HTTPException(status_code=404, detail="photo not found")
+ return Response(
+ content=bytes(row.content),
+ media_type=row.content_type,
+ headers={"Cache-Control": "private, max-age=3600"},
+ )
+
+
+# ── История и кэш (#399) ─────────────────────────────────────────────────────
+@router.get("/history")
+def estimate_history(
+ db: Annotated[Session, Depends(get_db)],
+ limit: int = 50,
+) -> list[dict[str, object]]:
+ """История оценок (#399) — последние N записей trade_in_estimates."""
+ rows = db.execute(
+ text(
+ """
+ SELECT id, address, rooms, area_m2, median_price,
+ confidence, n_analogs, created_at
+ FROM trade_in_estimates
+ ORDER BY created_at DESC
+ LIMIT :limit
+ """
+ ),
+ {"limit": min(max(limit, 1), 200)},
+ ).mappings().all()
+ return [dict(r) for r in rows]
+
+
+@router.get("/cache-stats")
+def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
+ """Состояние данных и кэшей (#399) — для страницы «Кэш»."""
+ row = db.execute(
+ text(
+ """
+ SELECT
+ (SELECT count(*) FROM geocode_cache) AS geocode_cache,
+ (SELECT count(*) FROM geocode_cache WHERE expires_at > NOW())
+ AS geocode_cache_fresh,
+ (SELECT count(*) FROM listings WHERE is_active) AS listings_active,
+ (SELECT max(scraped_at) FROM listings) AS listings_last_scraped,
+ (SELECT count(*) FROM deals) AS deals,
+ (SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings,
+ (SELECT count(*) FROM house_metadata) AS house_metadata,
+ (SELECT count(*) FROM trade_in_estimates) AS estimates_total
+ """
+ )
+ ).mappings().fetchone()
+ return dict(row) if row else {}
+
+
+# ── Stage 4a: house info + IMV benchmark для UI ───────────────────────────────
+
+
+_HOUSE_SELECT_COLS = """
+ h.id AS house_id, h.source, h.ext_house_id, h.address, h.short_address,
+ h.lat, h.lon, h.year_built, h.total_floors, h.house_type,
+ h.passenger_elevators, h.cargo_elevators,
+ h.has_concierge, h.closed_yard, h.has_playground, h.parking_type,
+ h.developer_name, h.rating, h.reviews_count,
+ COALESCE(h.raw_characteristics, '[]'::jsonb) AS raw_characteristics
+"""
+
+
+@router.get("/estimate/{estimate_id}/houses", response_model=list[HouseInfoForEstimate])
+def get_estimate_houses(
+ estimate_id: UUID,
+ db: Annotated[Session, Depends(get_db)],
+) -> list[HouseInfoForEstimate]:
+ """House(s) информация для estimate.
+
+ Логика (двойной поиск, union-deduplicate):
+ 1. Прямое совпадение по нормализованному адресу (tradein_normalize_short_addr).
+ 2. Geo-nearby — ST_DWithin 500м, любой source (avito/derived/etc.).
+
+ Возвращаем прямой матч + nearby (dedup by id), up to ~6 домов.
+ Пустой список если нет matches.
+ """
+ target = db.execute(
+ text(
+ """
+ SELECT lat, lon, address FROM trade_in_estimates
+ WHERE id = CAST(:id AS uuid)
+ """
+ ),
+ {"id": str(estimate_id)},
+ ).fetchone()
+ if target is None:
+ raise HTTPException(status_code=404, detail="estimate not found")
+
+ # Path 1: прямой матч по нормализованному адресу
+ direct: list[Any] = []
+ if target.address:
+ direct = list(
+ db.execute(
+ text(
+ f"""
+ SELECT DISTINCT {_HOUSE_SELECT_COLS},
+ 0 AS distance_m
+ FROM houses h
+ WHERE h.short_address = tradein_normalize_short_addr(:addr)
+ OR tradein_normalize_short_addr(h.address)
+ = tradein_normalize_short_addr(:addr)
+ LIMIT 1
+ """
+ ),
+ {"addr": target.address},
+ ).mappings().all()
+ )
+
+ # Path 2: geo-nearby (any source, 500м radius)
+ nearby: list[Any] = []
+ if target.lat is not None and target.lon is not None:
+ nearby = list(
+ db.execute(
+ text(
+ f"""
+ SELECT DISTINCT {_HOUSE_SELECT_COLS},
+ ST_Distance(
+ h.geom::geography,
+ ST_MakePoint(:lon, :lat)::geography
+ )::int AS distance_m
+ FROM houses h
+ WHERE h.geom IS NOT NULL
+ AND ST_DWithin(
+ h.geom::geography,
+ ST_MakePoint(:lon, :lat)::geography,
+ 500
+ )
+ ORDER BY distance_m
+ LIMIT 5
+ """
+ ),
+ {"lat": target.lat, "lon": target.lon},
+ ).mappings().all()
+ )
+
+ # Merge: direct первым, затем nearby (dedup by house_id)
+ seen_ids = {r["house_id"] for r in direct}
+ merged = list(direct) + [r for r in nearby if r["house_id"] not in seen_ids]
+
+ return [
+ HouseInfoForEstimate(**{k: v for k, v in row.items() if k != "distance_m"})
+ for row in merged
+ ]
+
+
+@router.get("/estimate/{estimate_id}/placement-history", response_model=list[PlacementHistoryEntry])
+def get_estimate_placement_history(
+ estimate_id: UUID,
+ db: Annotated[Session, Depends(get_db)],
+) -> list[PlacementHistoryEntry]:
+ """Историческая продажная активность по дому(ам) target estimate.
+
+ Возвращает rows из house_placement_history для всех houses связанных с
+ target адресом. Сортировано по last_price_date DESC.
+ """
+ target = db.execute(
+ text(
+ """
+ SELECT lat, lon, address FROM trade_in_estimates
+ WHERE id = CAST(:id AS uuid)
+ """
+ ),
+ {"id": str(estimate_id)},
+ ).fetchone()
+ if target is None:
+ raise HTTPException(status_code=404, detail="estimate not found")
+
+ # Поиск house_ids по нормализованному адресу
+ house_ids: list[int] = []
+ if target.address:
+ rows = db.execute(
+ text(
+ """
+ SELECT id FROM houses
+ WHERE short_address = tradein_normalize_short_addr(:addr)
+ OR tradein_normalize_short_addr(address) = tradein_normalize_short_addr(:addr)
+ """
+ ),
+ {"addr": target.address},
+ ).all()
+ house_ids = [r.id for r in rows]
+
+ if not house_ids and target.lat is not None and target.lon is not None:
+ # Geo fallback: 100м (tight radius чтобы не смешать соседние дома)
+ rows = db.execute(
+ text(
+ """
+ SELECT id FROM houses
+ WHERE geom IS NOT NULL
+ AND ST_DWithin(
+ geom::geography,
+ ST_MakePoint(:lon, :lat)::geography,
+ 100
+ )
+ LIMIT 3
+ """
+ ),
+ {"lat": target.lat, "lon": target.lon},
+ ).all()
+ house_ids = [r.id for r in rows]
+
+ if not house_ids:
+ return []
+
+ history = db.execute(
+ text(
+ """
+ SELECT id, source, house_id, ext_item_id, title, rooms, area_m2,
+ floor, total_floors, start_price, start_price_date,
+ last_price, last_price_date, removed_date, exposure_days,
+ notes
+ FROM house_placement_history
+ WHERE house_id = ANY(:house_ids)
+ ORDER BY COALESCE(last_price_date, start_price_date) DESC NULLS LAST
+ LIMIT 50
+ """
+ ),
+ {"house_ids": house_ids},
+ ).mappings().all()
+
+ return [PlacementHistoryEntry(**dict(r)) for r in history]
+
+
+@router.get("/estimate/{estimate_id}/house-analytics", response_model=HouseAnalyticsResponse)
+def get_estimate_house_analytics(
+ estimate_id: UUID,
+ db: Annotated[Session, Depends(get_db)],
+) -> HouseAnalyticsResponse:
+ """House-level analytics from house_placement_history backfill.
+
+ Resolves target house(s) — если в самом доме <8 hist rows — расширяем поиск до 300м.
+ Возвращает: price-history by year (median ₽/м²), recent sold (12mo), KPI.
+ """
+ target = db.execute(
+ text("SELECT lat, lon, address FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
+ {"id": str(estimate_id)},
+ ).fetchone()
+ if target is None:
+ raise HTTPException(status_code=404, detail="estimate not found")
+
+ # 1. Resolve target house_ids (same as placement-history endpoint)
+ house_ids: list[int] = []
+ if target.address:
+ rows = db.execute(
+ text(
+ "SELECT id FROM houses WHERE short_address = tradein_normalize_short_addr(:addr) "
+ "OR tradein_normalize_short_addr(address) = tradein_normalize_short_addr(:addr)"
+ ),
+ {"addr": target.address},
+ ).all()
+ house_ids = [r.id for r in rows]
+ if not house_ids and target.lat is not None and target.lon is not None:
+ rows = db.execute(
+ text(
+ "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
+ "geom::geography, ST_MakePoint(:lon, :lat)::geography, 100) LIMIT 3"
+ ),
+ {"lat": target.lat, "lon": target.lon},
+ ).all()
+ house_ids = [r.id for r in rows]
+
+ # 2. Expand to 300m if too few hist rows
+ radius_used = 0
+ n_in_house = 0
+ if house_ids:
+ n_in_house = (
+ db.execute(
+ text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"),
+ {"ids": house_ids},
+ ).scalar()
+ or 0
+ )
+ if n_in_house < 8 and target.lat is not None and target.lon is not None:
+ rows = db.execute(
+ text(
+ "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
+ "geom::geography, ST_MakePoint(:lon, :lat)::geography, 300) LIMIT 30"
+ ),
+ {"lat": target.lat, "lon": target.lon},
+ ).all()
+ house_ids = sorted(set(house_ids) | {r.id for r in rows})
+ radius_used = 300
+
+ if not house_ids:
+ return HouseAnalyticsResponse(
+ house_ids=[],
+ radius_m=0,
+ price_history=[],
+ recent_sold=[],
+ kpi=HouseAnalyticsKpi(
+ total_lots=0,
+ sold_count=0,
+ sold_rate_pct=0.0,
+ median_exposure_days=None,
+ median_bargain_pct=None,
+ ),
+ )
+
+ # 3. Price history by year × source (median ₽/м²)
+ price_history_rows = db.execute(
+ text(
+ """
+ SELECT
+ EXTRACT(YEAR FROM COALESCE(last_price_date, start_price_date))::int AS year,
+ source,
+ COUNT(*) AS n_lots,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY last_price / NULLIF(area_m2, 0))::int
+ AS median_price_per_m2,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY last_price)::int AS median_price_rub
+ FROM house_placement_history
+ WHERE house_id = ANY(:ids)
+ AND last_price IS NOT NULL AND last_price > 100000
+ AND area_m2 IS NOT NULL AND area_m2 > 10
+ AND COALESCE(last_price_date, start_price_date) IS NOT NULL
+ GROUP BY year, source
+ ORDER BY year ASC, source ASC
+ """
+ ),
+ {"ids": house_ids},
+ ).mappings().all()
+
+ # 4. Recent sold (12 months, with removed_date)
+ recent_sold_rows = db.execute(
+ text(
+ """
+ SELECT id, source, rooms, area_m2, floor, start_price, last_price,
+ removed_date, exposure_days,
+ CASE WHEN start_price > 0 AND last_price IS NOT NULL
+ THEN ROUND((start_price - last_price)::numeric / start_price * 100, 1)
+ ELSE NULL END AS discount_pct
+ FROM house_placement_history
+ WHERE house_id = ANY(:ids)
+ AND removed_date IS NOT NULL
+ AND removed_date > (NOW() - INTERVAL '12 months')::date
+ ORDER BY removed_date DESC
+ LIMIT 20
+ """
+ ),
+ {"ids": house_ids},
+ ).mappings().all()
+
+ # 5. KPI aggregate
+ kpi_row = db.execute(
+ text(
+ """
+ SELECT
+ COUNT(*) AS total_lots,
+ COUNT(*) FILTER (WHERE removed_date IS NOT NULL) AS sold_count,
+ CASE WHEN COUNT(*) > 0
+ THEN ROUND(
+ COUNT(*) FILTER (WHERE removed_date IS NOT NULL)::numeric
+ / COUNT(*) * 100,
+ 1
+ )
+ ELSE 0 END AS sold_rate_pct,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY exposure_days)
+ FILTER (WHERE exposure_days IS NOT NULL) AS median_exposure_days,
+ ROUND(
+ percentile_cont(0.5) WITHIN GROUP (
+ ORDER BY (start_price - last_price)::numeric / NULLIF(start_price, 0) * 100
+ ) FILTER (
+ WHERE start_price > 0
+ AND last_price IS NOT NULL
+ AND last_price != start_price
+ )::numeric,
+ 1
+ ) AS median_bargain_pct
+ FROM house_placement_history
+ WHERE house_id = ANY(:ids)
+ """
+ ),
+ {"ids": house_ids},
+ ).mappings().first()
+
+ return HouseAnalyticsResponse(
+ house_ids=house_ids,
+ radius_m=radius_used,
+ price_history=[PriceHistoryYearPoint(**dict(r)) for r in price_history_rows],
+ recent_sold=[RecentSoldEntry(**dict(r)) for r in recent_sold_rows],
+ kpi=HouseAnalyticsKpi(
+ total_lots=kpi_row["total_lots"] or 0 if kpi_row else 0,
+ sold_count=kpi_row["sold_count"] or 0 if kpi_row else 0,
+ sold_rate_pct=float(kpi_row["sold_rate_pct"] or 0) if kpi_row else 0.0,
+ median_exposure_days=(
+ int(kpi_row["median_exposure_days"])
+ if kpi_row and kpi_row["median_exposure_days"] is not None
+ else None
+ ),
+ median_bargain_pct=(
+ float(kpi_row["median_bargain_pct"])
+ if kpi_row and kpi_row["median_bargain_pct"] is not None
+ else None
+ ),
+ ),
+ )
+
+
+@router.get(
+ "/estimate/{estimate_id}/cian-price-changes",
+ response_model=list[CianPriceChangeStats],
+)
+def get_estimate_cian_price_changes(
+ estimate_id: UUID,
+ db: Annotated[Session, Depends(get_db)],
+) -> list[CianPriceChangeStats]:
+ """История изменений цены для Cian-аналогов из estimate.
+
+ Для каждого cian-аналога в estimate.analogs:
+ - extract cian_id из URL (/sale/flat//)
+ - JOIN listings (source='cian', source_id IN cian_ids)
+ - JOIN offer_price_history per listing_id
+ - Aggregate: n_changes, last_change_time, last_diff_percent, total_change_pct
+
+ Возвращает только аналоги с хотя бы одним изменением цены.
+ Пустой список если нет cian-аналогов или нет истории.
+ """
+ rows = db.execute(
+ text(
+ """
+ WITH cian_analogs AS (
+ SELECT DISTINCT
+ substring(a->>'source_url' from '/sale/flat/(\\d+)/') AS cian_id
+ FROM trade_in_estimates, jsonb_array_elements(analogs) a
+ WHERE id = CAST(:eid AS uuid)
+ AND a->>'source' = 'cian'
+ AND a->>'source_url' IS NOT NULL
+ AND substring(a->>'source_url' from '/sale/flat/(\\d+)/') IS NOT NULL
+ ),
+ listings_resolved AS (
+ SELECT l.id AS listing_id, l.source_id AS cian_id,
+ l.price_rub::int AS current_price
+ FROM listings l
+ JOIN cian_analogs c ON l.source_id = c.cian_id
+ WHERE l.source = 'cian'
+ ),
+ changes_agg AS (
+ SELECT
+ oph.listing_id,
+ COUNT(*) AS n_changes,
+ MAX(oph.change_time) AS last_change_time,
+ (array_agg(oph.diff_percent ORDER BY oph.change_time DESC))[1]
+ AS last_diff_percent,
+ (
+ SELECT oph2.price_rub::int
+ FROM offer_price_history oph2
+ WHERE oph2.listing_id = oph.listing_id
+ ORDER BY oph2.change_time ASC
+ LIMIT 1
+ ) AS first_seen_price
+ FROM offer_price_history oph
+ WHERE oph.listing_id IN (SELECT listing_id FROM listings_resolved)
+ GROUP BY oph.listing_id
+ )
+ SELECT
+ lr.cian_id,
+ lr.listing_id,
+ ca.n_changes::int,
+ ca.last_change_time,
+ ca.last_diff_percent::float AS last_diff_percent,
+ ca.first_seen_price,
+ lr.current_price,
+ CASE
+ WHEN ca.first_seen_price IS NOT NULL AND ca.first_seen_price > 0
+ THEN ROUND(
+ (lr.current_price - ca.first_seen_price)::numeric
+ / ca.first_seen_price * 100,
+ 1
+ )::float
+ ELSE NULL
+ END AS total_change_pct
+ FROM listings_resolved lr
+ JOIN changes_agg ca ON ca.listing_id = lr.listing_id
+ WHERE ca.n_changes > 0
+ ORDER BY ca.last_change_time DESC
+ """
+ ),
+ {"eid": str(estimate_id)},
+ ).mappings().all()
+
+ return [CianPriceChangeStats(**dict(r)) for r in rows]
+
+
+@router.get(
+ "/estimate/{estimate_id}/sell-time-sensitivity",
+ response_model=SellTimeSensitivityResponse,
+)
+def get_estimate_sell_time_sensitivity(
+ estimate_id: UUID,
+ db: Annotated[Session, Depends(get_db)],
+) -> SellTimeSensitivityResponse:
+ """Срок продажи в зависимости от цены к медиане дома/района.
+
+ 4 бакета: -5% / медиана (±3%) / +5% / +10%. Median exposure_days + p25/p75.
+ Filter last_price > start_price * 0.7 — отбрасываем подозрительно
+ заниженные лоты (выбросы, ошибки парсинга).
+ """
+ # 1. Resolve house_ids (same logic as house-analytics)
+ target = db.execute(
+ text("SELECT lat, lon, address FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
+ {"id": str(estimate_id)},
+ ).fetchone()
+ if target is None:
+ raise HTTPException(status_code=404, detail="estimate not found")
+
+ house_ids: list[int] = []
+ if target.address:
+ rows = db.execute(
+ text(
+ "SELECT id FROM houses WHERE short_address = tradein_normalize_short_addr(:addr) "
+ "OR tradein_normalize_short_addr(address) = tradein_normalize_short_addr(:addr)"
+ ),
+ {"addr": target.address},
+ ).all()
+ house_ids = [r.id for r in rows]
+ if not house_ids and target.lat is not None and target.lon is not None:
+ rows = db.execute(
+ text(
+ "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
+ "geom::geography, ST_MakePoint(:lon, :lat)::geography, 100) LIMIT 3"
+ ),
+ {"lat": target.lat, "lon": target.lon},
+ ).all()
+ house_ids = [r.id for r in rows]
+
+ # Expand to 300m if too few rows (same threshold as house-analytics)
+ radius_used = 0
+ n_in_house = 0
+ if house_ids:
+ n_in_house = (
+ db.execute(
+ text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"),
+ {"ids": house_ids},
+ ).scalar()
+ or 0
+ )
+ if n_in_house < 8 and target.lat is not None and target.lon is not None:
+ rows = db.execute(
+ text(
+ "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
+ "geom::geography, ST_MakePoint(:lon, :lat)::geography, 300) LIMIT 30"
+ ),
+ {"lat": target.lat, "lon": target.lon},
+ ).all()
+ house_ids = sorted(set(house_ids) | {r.id for r in rows})
+ radius_used = 300
+
+ if not house_ids:
+ return SellTimeSensitivityResponse(
+ house_ids=[],
+ radius_m=0,
+ target_median_price_per_m2=None,
+ buckets=[],
+ )
+
+ # 2. Compute benchmark median ₽/м² for last 2 years
+ target_median = db.execute(
+ text(
+ """
+ SELECT percentile_cont(0.5) WITHIN GROUP (
+ ORDER BY last_price / NULLIF(area_m2, 0)
+ )::int AS median_ppm2
+ FROM house_placement_history
+ WHERE house_id = ANY(:ids)
+ AND last_price IS NOT NULL AND last_price > 100000
+ AND area_m2 > 10
+ AND COALESCE(last_price_date, start_price_date) > (NOW() - INTERVAL '2 years')::date
+ AND (start_price = 0 OR last_price > start_price * 0.7)
+ """
+ ),
+ {"ids": house_ids},
+ ).scalar()
+
+ # 3. Per-year median (для расчёта premium per lot); используем CTE для bucket-расчёта
+ bucket_rows = db.execute(
+ text(
+ """
+ WITH year_medians AS (
+ SELECT
+ EXTRACT(YEAR FROM COALESCE(last_price_date, start_price_date))::int AS year,
+ percentile_cont(0.5) WITHIN GROUP (
+ ORDER BY last_price / NULLIF(area_m2, 0)
+ ) AS median_ppm2
+ FROM house_placement_history
+ WHERE house_id = ANY(:ids)
+ AND last_price IS NOT NULL AND area_m2 > 10
+ AND (start_price = 0 OR last_price > start_price * 0.7)
+ GROUP BY year
+ ),
+ lots_with_premium AS (
+ SELECT
+ hph.exposure_days,
+ CASE
+ WHEN ym.median_ppm2 IS NULL OR ym.median_ppm2 = 0 THEN NULL
+ ELSE ((hph.last_price / NULLIF(hph.area_m2, 0)) - ym.median_ppm2)
+ / ym.median_ppm2 * 100
+ END AS premium_pct
+ FROM house_placement_history hph
+ JOIN year_medians ym ON ym.year = EXTRACT(YEAR FROM
+ COALESCE(hph.last_price_date, hph.start_price_date))::int
+ WHERE hph.house_id = ANY(:ids)
+ AND hph.removed_date IS NOT NULL
+ AND hph.exposure_days IS NOT NULL
+ AND hph.area_m2 > 10
+ AND (hph.start_price = 0 OR hph.last_price > hph.start_price * 0.7)
+ ),
+ bucketed AS (
+ SELECT
+ CASE
+ WHEN premium_pct BETWEEN -10 AND -3 THEN 'cheap'
+ WHEN premium_pct BETWEEN -3 AND 3 THEN 'median'
+ WHEN premium_pct BETWEEN 3 AND 8 THEN 'plus5'
+ WHEN premium_pct BETWEEN 8 AND 15 THEN 'plus10'
+ ELSE NULL
+ END AS bucket,
+ exposure_days
+ FROM lots_with_premium
+ WHERE premium_pct IS NOT NULL
+ )
+ SELECT
+ bucket,
+ COUNT(*) AS n_lots,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY exposure_days)::int
+ AS median_exposure_days,
+ percentile_cont(0.25) WITHIN GROUP (ORDER BY exposure_days)::int AS p25_days,
+ percentile_cont(0.75) WITHIN GROUP (ORDER BY exposure_days)::int AS p75_days
+ FROM bucketed
+ WHERE bucket IS NOT NULL
+ GROUP BY bucket
+ """
+ ),
+ {"ids": house_ids},
+ ).mappings().all()
+
+ # 4. Build buckets — гарантируем все 4 даже если данных нет в bucket
+ bucket_map = {r["bucket"]: dict(r) for r in bucket_rows}
+ bucket_definitions = [
+ ("cheap", -5.0),
+ ("median", 0.0),
+ ("plus5", 5.0),
+ ("plus10", 10.0),
+ ]
+ buckets: list[SellTimeBucket] = []
+ for label, pct in bucket_definitions:
+ r = bucket_map.get(label)
+ buckets.append(
+ SellTimeBucket(
+ price_premium_label=label,
+ price_premium_pct=pct,
+ median_exposure_days=r["median_exposure_days"] if r else None,
+ p25_days=r["p25_days"] if r else None,
+ p75_days=r["p75_days"] if r else None,
+ n_lots=r["n_lots"] if r else 0,
+ )
+ )
+
+ return SellTimeSensitivityResponse(
+ house_ids=house_ids,
+ radius_m=radius_used,
+ target_median_price_per_m2=int(target_median) if target_median else None,
+ buckets=buckets,
+ )
+
+
+@router.get("/estimate/{estimate_id}/imv-benchmark", response_model=IMVBenchmarkResponse)
+def get_estimate_imv_benchmark(
+ estimate_id: UUID,
+ db: Annotated[Session, Depends(get_db)],
+) -> IMVBenchmarkResponse:
+ """Avito IMV benchmark для estimate (для UI badge «наша 6.4М · Avito 6.29М»).
+
+ Источники lookup:
+ 1. avito_imv_evaluations WHERE estimate_id = :id (если linked в estimator)
+ 2. Если не linked — fallback: most recent IMV для same address (TTL 24h)
+ """
+ # Сначала пытаемся найти directly linked
+ row = db.execute(
+ text(
+ """
+ SELECT cache_key, recommended_price, lower_price, higher_price,
+ market_count, fetched_at
+ FROM avito_imv_evaluations
+ WHERE estimate_id = CAST(:id AS uuid)
+ ORDER BY fetched_at DESC
+ LIMIT 1
+ """
+ ),
+ {"id": str(estimate_id)},
+ ).fetchone()
+
+ if row is None:
+ # Fallback: same address за 24h (на случай если link не успел)
+ est = db.execute(
+ text(
+ """
+ SELECT address FROM trade_in_estimates
+ WHERE id = CAST(:id AS uuid)
+ """
+ ),
+ {"id": str(estimate_id)},
+ ).fetchone()
+ if est is None:
+ raise HTTPException(status_code=404, detail="estimate not found")
+ if est.address:
+ row = db.execute(
+ text(
+ """
+ SELECT cache_key, recommended_price, lower_price, higher_price,
+ market_count, fetched_at
+ FROM avito_imv_evaluations
+ WHERE address = :address
+ AND fetched_at > NOW() - INTERVAL '24 hours'
+ ORDER BY fetched_at DESC
+ LIMIT 1
+ """
+ ),
+ {"address": est.address},
+ ).fetchone()
+
+ if row is None:
+ return IMVBenchmarkResponse(available=False)
+
+ # Get our_median_price для compare
+ our = db.execute(
+ text(
+ """
+ SELECT median_price FROM trade_in_estimates
+ WHERE id = CAST(:id AS uuid)
+ """
+ ),
+ {"id": str(estimate_id)},
+ ).fetchone()
+ our_median = our.median_price if our else None
+ diff_pct = None
+ if our_median and row.recommended_price:
+ diff_pct = round((our_median - row.recommended_price) / row.recommended_price * 100, 1)
+
+ return IMVBenchmarkResponse(
+ available=True,
+ cache_key=row.cache_key,
+ recommended_price=row.recommended_price,
+ lower_price=row.lower_price,
+ higher_price=row.higher_price,
+ market_count=row.market_count,
+ fetched_at=row.fetched_at,
+ our_median_price=our_median,
+ diff_pct=diff_pct,
+ )
+
+
+# ── Street-level deals (rosreestr open dataset) ───────────────────────────────
+
+
+@router.get("/street-deals", response_model=StreetDealsResponse)
+def get_street_deals(
+ address: str,
+ area_m2: float,
+ rooms: int,
+ db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
+ period_months: int = 12,
+ area_tolerance: float = 0.15,
+) -> StreetDealsResponse:
+ """ДКП-сделки Росреестра по улице целевого адреса.
+
+ Open dataset Росреестра агрегирует адреса до улицы (без номера дома).
+ Поэтому это per-street view, не per-house. Фильтр по rooms + area
+ сужает выборку до квартир-аналогов.
+
+ После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
+ в import-rosreestr.sh).
+ """
+ from app.services.estimator import _deal_to_analog, _percentile, extract_street_name
+
+ now = datetime.now(tz=UTC)
+ period_from: date = (now - timedelta(days=period_months * 30)).date()
+ period_to: date = now.date()
+
+ def _empty() -> StreetDealsResponse:
+ return StreetDealsResponse(
+ street=None,
+ period_from=period_from,
+ period_to=period_to,
+ count=0,
+ median_price_rub=0,
+ median_price_per_m2=0,
+ range_low_rub=0,
+ range_high_rub=0,
+ deals=[],
+ )
+
+ street_name = extract_street_name(address)
+ if not street_name:
+ logger.warning("street-deals: could not extract street from address=%r", address)
+ return _empty()
+
+ area_min = area_m2 * (1.0 - area_tolerance)
+ area_max = area_m2 * (1.0 + area_tolerance)
+
+ rows = db.execute(
+ text(
+ """
+ SELECT address, area_m2, rooms, floor, total_floors,
+ price_rub, price_per_m2, deal_date, source
+ FROM deals
+ WHERE source = 'rosreestr'
+ AND address ILIKE :street_pattern
+ AND rooms = CAST(:rooms AS integer)
+ AND area_m2 BETWEEN :area_min AND :area_max
+ AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
+ AND price_rub > 0
+ ORDER BY deal_date DESC
+ """
+ ),
+ {
+ "street_pattern": "%" + street_name + "%",
+ "rooms": rooms,
+ "area_min": area_min,
+ "area_max": area_max,
+ "period_months": period_months,
+ },
+ ).mappings().all()
+
+ if not rows:
+ logger.info(
+ "street-deals: no rows found street=%r rooms=%d area=%.1f±%.0f%%",
+ street_name, rooms, area_m2, area_tolerance * 100,
+ )
+ return StreetDealsResponse(
+ street=street_name,
+ period_from=period_from,
+ period_to=period_to,
+ count=0,
+ median_price_rub=0,
+ median_price_per_m2=0,
+ range_low_rub=0,
+ range_high_rub=0,
+ deals=[],
+ )
+
+ count = len(rows)
+ prices_rub = sorted(float(r["price_rub"]) for r in rows)
+ prices_ppm2 = sorted(float(r["price_per_m2"]) for r in rows if r["price_per_m2"])
+
+ median_ppm2 = _percentile(prices_ppm2, 0.5) if prices_ppm2 else 0.0
+ median_price_rub = (
+ int(median_ppm2 * area_m2) if median_ppm2 else int(_percentile(prices_rub, 0.5))
+ )
+ range_low_rub = int(prices_rub[0])
+ range_high_rub = int(prices_rub[-1])
+
+ top10 = [_deal_to_analog(dict(r)) for r in rows[:10]]
+
+ logger.info(
+ "street-deals: street=%r rooms=%d area=%.1f count=%d median_ppm2=%.0f",
+ street_name, rooms, area_m2, count, median_ppm2,
+ )
+
+ return StreetDealsResponse(
+ street=street_name,
+ period_from=period_from,
+ period_to=period_to,
+ count=count,
+ median_price_rub=median_price_rub,
+ median_price_per_m2=int(median_ppm2),
+ range_low_rub=range_low_rub,
+ range_high_rub=range_high_rub,
+ deals=top10,
+ )
+
+
+# ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ──────────────
+
+
+@router.get("/sales-vs-listings", response_model=SalesVsListingsResponse)
+def get_sales_vs_listings(
+ address: str,
+ area_m2: float,
+ rooms: int,
+ db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
+ window_days: int = 180,
+ area_tolerance: float = 0.15,
+ period_months: int = 24,
+) -> SalesVsListingsResponse:
+ """Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).
+
+ Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
+ matching listing на той же улице с такими же rooms / близкой area_m2 /
+ listing_date в окне [deal_date - window_days, deal_date + 30d grace].
+
+ Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
+ чтобы вычислить linkage_rate.
+
+ discount_pct = (deal_price - listing_price) / listing_price * 100.
+ Отрицательный = продали дешевле asking → reasoned discount от торга.
+
+ Per-street view: Росреестр open dataset агрегирует адреса до улицы.
+ """
+ from app.services.estimator import _percentile, extract_street_name
+
+ def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
+ return SalesVsListingsResponse(
+ street=reason_street,
+ period_months=period_months,
+ window_days=window_days,
+ area_tolerance=area_tolerance,
+ total_deals=0,
+ deals_with_listings=0,
+ linkage_rate_pct=0.0,
+ median_discount_pct=None,
+ pairs=[],
+ )
+
+ street_name = extract_street_name(address)
+ if not street_name:
+ logger.warning("sales-vs-listings: cannot extract street from %r", address)
+ return _empty()
+
+ rows = db.execute(
+ text(
+ """
+ SELECT
+ deal_id, deal_date, deal_price_rub, deal_price_per_m2,
+ deal_area_m2, deal_rooms, deal_floor, deal_address,
+ listing_id, listing_source, listing_source_url,
+ listing_date, listing_price_rub, listing_price_per_m2,
+ listing_area_m2, days_listing_to_deal, discount_pct
+ FROM street_sales_vs_listings(
+ CAST(:street_pattern AS text),
+ CAST(:area_m2 AS numeric),
+ CAST(:rooms AS integer),
+ CAST(:window_days AS integer),
+ CAST(:area_tolerance AS numeric),
+ CAST(:period_months AS integer)
+ )
+ """
+ ),
+ {
+ "street_pattern": "%" + street_name + "%",
+ "area_m2": area_m2,
+ "rooms": rooms,
+ "window_days": window_days,
+ "area_tolerance": area_tolerance,
+ "period_months": period_months,
+ },
+ ).mappings().all()
+
+ if not rows:
+ logger.info(
+ "sales-vs-listings: no deals street=%r rooms=%d area=%.1f period_months=%d",
+ street_name, rooms, area_m2, period_months,
+ )
+ return _empty(reason_street=street_name)
+
+ pairs = [
+ SalesListingPair(
+ deal_id=r["deal_id"],
+ deal_date=r["deal_date"],
+ deal_price_rub=int(r["deal_price_rub"]),
+ deal_price_per_m2=int(r["deal_price_per_m2"] or 0),
+ deal_area_m2=float(r["deal_area_m2"]),
+ deal_rooms=int(r["deal_rooms"]),
+ deal_floor=r["deal_floor"],
+ deal_address=r["deal_address"],
+ listing_id=r["listing_id"],
+ listing_source=r["listing_source"],
+ listing_source_url=r["listing_source_url"],
+ listing_date=r["listing_date"],
+ listing_price_rub=(
+ int(r["listing_price_rub"]) if r["listing_price_rub"] is not None else None
+ ),
+ listing_price_per_m2=(
+ int(r["listing_price_per_m2"])
+ if r["listing_price_per_m2"] is not None
+ else None
+ ),
+ listing_area_m2=(
+ float(r["listing_area_m2"]) if r["listing_area_m2"] is not None else None
+ ),
+ days_listing_to_deal=r["days_listing_to_deal"],
+ discount_pct=(
+ float(r["discount_pct"]) if r["discount_pct"] is not None else None
+ ),
+ )
+ for r in rows
+ ]
+
+ total_deals = len(pairs)
+ deals_with_listings = sum(1 for p in pairs if p.listing_id is not None)
+ linkage_rate_pct = (
+ round(deals_with_listings / total_deals * 100, 1) if total_deals else 0.0
+ )
+
+ discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None)
+ median_discount = (
+ round(_percentile(discounts, 0.5), 2) if discounts else None
+ )
+
+ logger.info(
+ "sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s",
+ street_name, total_deals, deals_with_listings, linkage_rate_pct,
+ f"{median_discount:+.2f}%" if median_discount is not None else "n/a",
+ )
+
+ return SalesVsListingsResponse(
+ street=street_name,
+ period_months=period_months,
+ window_days=window_days,
+ area_tolerance=area_tolerance,
+ total_deals=total_deals,
+ deals_with_listings=deals_with_listings,
+ linkage_rate_pct=linkage_rate_pct,
+ median_discount_pct=median_discount,
+ pairs=pairs,
+ )
diff --git a/tradein-mvp/backend/app/core/__init__.py b/tradein-mvp/backend/app/core/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tradein-mvp/backend/app/core/auth.py b/tradein-mvp/backend/app/core/auth.py
new file mode 100644
index 00000000..c7c88736
--- /dev/null
+++ b/tradein-mvp/backend/app/core/auth.py
@@ -0,0 +1,185 @@
+"""Role-based access control (RBAC) — MIRROR of main backend's
+``app/core/auth.py``.
+
+Kept in sync manually; rationale: separate stacks, shared YAML config mounted
+from repo root. We deliberately do NOT share code between repos via
+``sys.path`` tricks — each docker stack ships its own image with its own
+``app/`` tree.
+
+When updating one copy, update the other.
+
+Caddy gates the whole site with basic_auth (см. `caddy/users.caddy.snippet`)
+и пропускает в backend заголовок `X-Authenticated-User: ` через
+`header_up X-Authenticated-User {http.auth.user.id}` в каждом reverse_proxy.
+Этот модуль читает yaml-конфиг **один раз при импорте** и отдаёт чистые
+функции — middleware и endpoint /me потом гоняют их per-request.
+
+Source-of-truth file:
+ - container: /app/auth/roles.yaml (bind-mount из репо)
+ - repo: auth/roles.yaml
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from functools import lru_cache
+from pathlib import Path
+from typing import Literal, TypedDict
+
+import yaml
+
+logger = logging.getLogger(__name__)
+
+Role = Literal["admin", "pilot"]
+
+
+class UserScope(TypedDict):
+ """Возвращается /me — фронт может использовать allowed_paths для UI gating."""
+
+ username: str
+ role: Role
+ allowed_paths: list[str]
+ deny_paths: list[str]
+
+
+# ---------------------------------------------------------------------------
+# YAML loading
+# ---------------------------------------------------------------------------
+
+
+def _find_roles_yaml() -> Path:
+ """Find `roles.yaml` either in the container mount path or in the repo root.
+
+ Container layout (prod): `/app/auth/roles.yaml` (bind-mount).
+ Dev layout: walk up from this file to find the first ancestor containing
+ `auth/`. Tradein-mvp lives inside the main repo, so the same walk finds
+ `/auth/roles.yaml`.
+ """
+ container_path = Path("/app/auth/roles.yaml")
+ if container_path.is_file():
+ return container_path
+
+ here = Path(__file__).resolve()
+ for ancestor in here.parents:
+ candidate = ancestor / "auth" / "roles.yaml"
+ if candidate.is_file():
+ return candidate
+
+ raise FileNotFoundError(
+ "roles.yaml not found in /app/auth/ or in any ancestor of "
+ f"{here} — RBAC cannot start without it"
+ )
+
+
+@lru_cache(maxsize=1)
+def _load_roles_config() -> dict:
+ """Parse `roles.yaml` once and cache the result for the process lifetime."""
+ path = _find_roles_yaml()
+ try:
+ with path.open(encoding="utf-8") as fh:
+ data = yaml.safe_load(fh)
+ except Exception:
+ logger.exception("failed to parse roles.yaml at %s", path)
+ raise
+
+ if not isinstance(data, dict) or "roles" not in data or "users" not in data:
+ raise ValueError(f"roles.yaml at {path} must have top-level keys 'roles' and 'users'")
+
+ roles = data["roles"]
+ users = data["users"]
+ for username, role in users.items():
+ if role not in roles:
+ raise ValueError(
+ f"user {username!r} maps to unknown role {role!r} (known: {list(roles)})"
+ )
+
+ logger.info(
+ "RBAC loaded from %s — %d users, %d roles",
+ path,
+ len(users),
+ len(roles),
+ )
+ return data
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+
+def get_role(username: str) -> Role:
+ """Return the role for *username* or raise KeyError if unknown."""
+ config = _load_roles_config()
+ users: dict[str, Role] = config["users"]
+ if username not in users:
+ raise KeyError(f"user {username!r} not in roles config")
+ return users[username]
+
+
+def _glob_to_regex(pattern: str) -> re.Pattern[str]:
+ """Compile a glob pattern with `**` semantics to a regex.
+
+ Semantics:
+ `/**` → matches everything (admin scope).
+ `/foo/**` → matches `/foo`, `/foo/`, `/foo/bar`, `/foo/bar/baz`.
+ `/foo/*` → matches one segment after `/foo/`.
+ `/foo` → matches exactly `/foo`.
+ """
+ dstar = "\x00DSTAR\x00"
+ sstar = "\x00SSTAR\x00"
+
+ work = pattern.replace("**", dstar).replace("*", sstar)
+ regex = re.escape(work)
+ regex = regex.replace(re.escape(dstar), ".*")
+ regex = regex.replace(re.escape(sstar), "[^/]*")
+
+ if regex.endswith("/.*"):
+ regex = regex[: -len("/.*")] + r"(?:/.*)?"
+ return re.compile(f"^{regex}$")
+
+
+@lru_cache(maxsize=512)
+def _compile_globs(patterns: tuple[str, ...]) -> tuple[re.Pattern[str], ...]:
+ return tuple(_glob_to_regex(p) for p in patterns)
+
+
+def is_path_allowed(role: str, path: str) -> bool:
+ """Check whether *role* may access *path*.
+
+ Allowed iff matches `paths` AND not in `deny`. `deny` is final.
+ """
+ config = _load_roles_config()
+ roles = config["roles"]
+ if role not in roles:
+ return False
+
+ role_def = roles[role]
+ allow_globs = _compile_globs(tuple(role_def.get("paths", [])))
+ deny_globs = _compile_globs(tuple(role_def.get("deny", []) or []))
+
+ if any(g.match(path) for g in deny_globs):
+ return False
+ return any(g.match(path) for g in allow_globs)
+
+
+def get_user_scope(username: str) -> UserScope:
+ """Return everything a frontend needs to do UI-level gating for *username*.
+
+ Raises KeyError if *username* is unknown.
+ """
+ config = _load_roles_config()
+ role = get_role(username)
+ role_def = config["roles"][role]
+ return UserScope(
+ username=username,
+ role=role,
+ allowed_paths=list(role_def.get("paths", []) or []),
+ deny_paths=list(role_def.get("deny", []) or []),
+ )
+
+
+def reset_cache_for_tests() -> None:
+ """Drop cached YAML — only for unit tests that patch the file path."""
+ _load_roles_config.cache_clear()
+ _compile_globs.cache_clear()
diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py
new file mode 100644
index 00000000..86e3296b
--- /dev/null
+++ b/tradein-mvp/backend/app/core/config.py
@@ -0,0 +1,51 @@
+"""Минимальный settings для standalone trade-in MVP."""
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
+
+ # required — задаётся через env DATABASE_URL. Нет дефолта: fail-fast при старте
+ # если переменная не задана (C-3 security audit).
+ database_url: str
+ cors_origins: list[str] = ["http://localhost", "http://localhost:3000", "http://localhost:8080"]
+ environment: str = "dev"
+
+ # Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
+ # backfill_house_coords.py + audit_address_mismatch.py + main backend
+ # OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
+ yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации
+ yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend)
+ # для User-Agent в Nominatim (Nominatim Usage Policy)
+ contact_email: str = "erginrajpopxbe@outlook.com"
+
+ # Public URL — для QR-кода в PDF, shareable links, etc.
+ public_url: str = "http://127.0.0.1:8080"
+
+ # GlitchTip DSN — мониторинг ошибок (Sentry-совместимый). #396.
+ # Пусто = мониторинг выключен (dev). В prod — env GLITCHTIP_DSN из .env.runtime.
+ glitchtip_dsn: str | None = None
+
+ # Ключ шифрования для pgp_sym_encrypt (Cian session cookies).
+ # Задаётся через env COOKIE_ENCRYPTION_KEY. Пусто = шифрование не работает.
+ cookie_encryption_key: str = ""
+
+ # Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL.
+ redis_url: str = "redis://localhost:6379/0"
+
+ # Password for tradein_fdw_reader role — used by backend startup to create/refresh
+ # USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server).
+ # Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev).
+ gendesign_fdw_password: str | None = None
+
+ # DaData /clean/address — обогащение target-адреса канонической формой,
+ # kadastr_num, ФИАС, координатами, метро. Используется в estimator для
+ # on-demand enrichment (PR Q1). Demo tier: 100 req/день. Если хотя бы один
+ # не задан — service возвращает None gracefully, estimator продолжает.
+ # ENV: DADATA_API_TOKEN, DADATA_API_SECRET.
+ dadata_api_token: str | None = None
+ dadata_api_secret: str | None = None
+
+
+settings = Settings()
diff --git a/tradein-mvp/backend/app/core/db.py b/tradein-mvp/backend/app/core/db.py
new file mode 100644
index 00000000..7853cad9
--- /dev/null
+++ b/tradein-mvp/backend/app/core/db.py
@@ -0,0 +1,21 @@
+from collections.abc import Generator
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
+
+from app.core.config import settings
+
+engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+def get_db() -> Generator[Session, None, None]:
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/tradein-mvp/backend/app/core/fdw.py b/tradein-mvp/backend/app/core/fdw.py
new file mode 100644
index 00000000..ec3cbf78
--- /dev/null
+++ b/tradein-mvp/backend/app/core/fdw.py
@@ -0,0 +1,81 @@
+"""postgres_fdw USER MAPPING management for gendesign_remote server.
+
+Password lives in env GENDESIGN_FDW_PASSWORD — never in SQL migration or git.
+This helper:
+ - validates password format (alphanumeric/hex, length >= 32) to prevent SQL
+ injection via embedded quotes (the postgres CREATE USER MAPPING syntax does
+ not accept bind parameters — password must be inlined into the DDL string),
+ - applies idempotent CREATE or ALTER mapping on every backend startup so
+ password rotation through .env.runtime is picked up after restart.
+"""
+from __future__ import annotations
+
+import logging
+import re
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+# Strict whitelist — only hex/alphanumeric and a few separators. Refuses ANY
+# quote, backslash, unicode whitespace, control char. Min length 32 forces
+# rotation away from weak defaults.
+_PASSWORD_RE = re.compile(r"^[A-Za-z0-9_\-]{32,256}$")
+
+
+def ensure_fdw_user_mapping(db: Session) -> None:
+ """Idempotent CREATE OR ALTER USER MAPPING for tradein → gendesign_remote.
+
+ Skips silently if password not configured (dev / first deploy / forgotten env).
+ Raises ValueError if password fails format whitelist — fail-fast on misconfig.
+ """
+ password = settings.gendesign_fdw_password
+ if not password or not str(password).strip():
+ logger.warning(
+ "GENDESIGN_FDW_PASSWORD not set — skipping FDW user mapping "
+ "(gendesign_cad_buildings queries will fail; cadastral lookups will "
+ "fall back to Yandex/Nominatim)"
+ )
+ return
+
+ if not _PASSWORD_RE.fullmatch(password):
+ # Do NOT echo the password into logs / exception even partially.
+ raise ValueError(
+ "GENDESIGN_FDW_PASSWORD failed format whitelist "
+ "(allowed: 32-256 chars, [A-Za-z0-9_-]). Refusing to apply USER MAPPING "
+ "— password rotation must use the same format."
+ )
+
+ # NB: even after whitelist validation we still cannot use SQLAlchemy bind
+ # parameters here — postgres CREATE/ALTER USER MAPPING is DDL and treats
+ # OPTIONS values as literal text. Whitelist guarantees the value is
+ # injection-safe; we still wrap in single quotes for the DDL syntax.
+ exists = db.execute(
+ text(
+ "SELECT 1 FROM pg_user_mappings "
+ "WHERE srvname = 'gendesign_remote' "
+ "AND (usename = current_user OR usename IS NULL)"
+ )
+ ).first()
+
+ if exists is None:
+ db.execute(text(
+ f"CREATE USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
+ f"OPTIONS (user 'tradein_fdw_reader', password '{password}')"
+ ))
+ logger.info("created FDW user mapping for gendesign_remote")
+ else:
+ db.execute(text(
+ f"ALTER USER MAPPING FOR CURRENT_USER SERVER gendesign_remote "
+ f"OPTIONS (SET password '{password}')"
+ ))
+ logger.info("refreshed FDW user mapping password for gendesign_remote")
+
+ try:
+ db.commit()
+ except Exception:
+ db.rollback()
+ raise
diff --git a/tradein-mvp/backend/app/core/ratelimit.py b/tradein-mvp/backend/app/core/ratelimit.py
new file mode 100644
index 00000000..9ab432b4
--- /dev/null
+++ b/tradein-mvp/backend/app/core/ratelimit.py
@@ -0,0 +1,68 @@
+"""Простой in-memory rate-limiter для публичного API.
+
+Per-IP sliding window. Достаточно для одного backend-инстанса (MVP).
+Защищает от абуза `/api/v1/*` — скрейп-эндпоинты уже за admin-токеном,
+но estimate/geocode/suggest публичны.
+
+Лимиты намеренно щедрые — обычный пользователь их не достигнет, режутся боты.
+"""
+
+from __future__ import annotations
+
+import time
+from collections import defaultdict, deque
+
+from fastapi import Request
+from fastapi.responses import JSONResponse
+from starlette.middleware.base import BaseHTTPMiddleware
+
+# Окно и лимит: не более RATE_LIMIT запросов за RATE_WINDOW секунд с одного IP.
+RATE_WINDOW = 60.0
+RATE_LIMIT = 90
+
+
+class RateLimitMiddleware(BaseHTTPMiddleware):
+ """Sliding-window rate limit на /api/v1/*. Health и статика — без лимита."""
+
+ def __init__(self, app) -> None: # type: ignore[no-untyped-def]
+ super().__init__(app)
+ self._hits: dict[str, deque[float]] = defaultdict(deque)
+
+ async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
+ path = request.url.path
+ # Лимитируем только API; health и прочее — пропускаем.
+ if not path.startswith("/api/"):
+ return await call_next(request)
+
+ ip = _client_ip(request)
+ now = time.monotonic()
+ bucket = self._hits[ip]
+
+ # Выкидываем устаревшие отметки за пределами окна.
+ cutoff = now - RATE_WINDOW
+ while bucket and bucket[0] < cutoff:
+ bucket.popleft()
+
+ if len(bucket) >= RATE_LIMIT:
+ retry = int(RATE_WINDOW - (now - bucket[0])) + 1
+ return JSONResponse(
+ status_code=429,
+ content={"detail": "Слишком много запросов. Попробуйте позже."},
+ headers={"Retry-After": str(retry)},
+ )
+
+ bucket.append(now)
+ # Лёгкая защита от утечки памяти — чистим пустые корзины изредка.
+ if len(self._hits) > 10000:
+ for k in [k for k, v in self._hits.items() if not v]:
+ del self._hits[k]
+
+ return await call_next(request)
+
+
+def _client_ip(request: Request) -> str:
+ """Реальный IP за реверс-прокси (Caddy/nginx ставят X-Forwarded-For)."""
+ xff = request.headers.get("x-forwarded-for")
+ if xff:
+ return xff.split(",")[0].strip()
+ return request.client.host if request.client else "unknown"
diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py
new file mode 100644
index 00000000..3043e21c
--- /dev/null
+++ b/tradein-mvp/backend/app/main.py
@@ -0,0 +1,145 @@
+"""Trade-In MVP — FastAPI entry point.
+
+Standalone версия, выделена из основного gendesign repo для локальной разработки.
+Только trade-in фича; никаких других роутеров (concepts/parcels/analytics/etc) нет.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+from collections.abc import AsyncGenerator, Awaitable, Callable
+from contextlib import asynccontextmanager
+
+import sentry_sdk
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse, Response
+
+from app.api.v1 import admin, brand, geocode, me, search, trade_in
+from app.core.auth import get_role
+from app.core.config import settings
+from app.core.db import SessionLocal
+from app.core.fdw import ensure_fdw_user_mapping
+from app.core.ratelimit import RateLimitMiddleware
+from app.services.scheduler import scheduler_loop
+
+logger = logging.getLogger(__name__)
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+)
+
+# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
+# DSN из env GLITCHTIP_DSN; пусто (dev) → мониторинг не инициализируется.
+if settings.glitchtip_dsn:
+ sentry_sdk.init(
+ dsn=settings.glitchtip_dsn,
+ environment=settings.environment,
+ traces_sample_rate=0.0, # только ошибки, без performance-трейсов
+ send_default_pii=False, # не шлём client_name / client_phone в отчёты
+ )
+ logging.getLogger("app.main").info("GlitchTip monitoring enabled")
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
+ # FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
+ # Best-effort: failure does not abort startup, just logs.
+ try:
+ with SessionLocal() as db:
+ ensure_fdw_user_mapping(db)
+ except Exception:
+ logger.exception("FDW user mapping bootstrap failed — cadastral queries may fail")
+
+ # Startup: launch scheduler background task
+ scheduler_task = asyncio.create_task(scheduler_loop())
+ logger.info("FastAPI lifespan: scheduler task spawned")
+ yield
+ # Shutdown: cancel scheduler
+ scheduler_task.cancel()
+ try:
+ await scheduler_task
+ except asyncio.CancelledError:
+ pass
+ logger.info("FastAPI lifespan: scheduler cancelled cleanly")
+
+
+app = FastAPI(
+ title="Trade-In MVP API",
+ description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", # noqa: E501
+ version="0.1.0",
+ lifespan=lifespan,
+)
+
+# RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User
+# (см. app/core/auth.py + auth/roles.yaml). Правила:
+# 1) Любой non-public path требует X-Authenticated-User — иначе 401.
+# 2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего
+# не видит» — decided 2026-05-25).
+# 3) /api/v1/admin/* (= внешний /trade-in/api/v1/admin/* после Caddy
+# `uri strip_prefix /trade-in`) — только role=admin, иначе 403.
+# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
+# X-Authenticated-User там не приходит из Caddy.
+_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
+_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
+
+
+@app.middleware("http")
+async def rbac_guard(
+ request: Request,
+ call_next: Callable[[Request], Awaitable[Response]],
+) -> Response:
+ path = request.url.path
+ if path in _PUBLIC_PATHS:
+ return await call_next(request)
+
+ username = request.headers.get("X-Authenticated-User")
+ if not username:
+ return JSONResponse(
+ status_code=401,
+ content={"detail": "no authenticated user (Caddy basic_auth required)"},
+ )
+
+ try:
+ role = get_role(username)
+ except KeyError:
+ logger.warning("RBAC: unknown user %r tried %s", username, path)
+ return JSONResponse(
+ status_code=403,
+ content={"detail": "user not in roles config"},
+ )
+
+ if _ADMIN_API_RE.match(path) and role != "admin":
+ logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
+ return JSONResponse(
+ status_code=403,
+ content={"detail": "admin only"},
+ )
+ return await call_next(request)
+
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+# Rate-limit публичного API (per-IP sliding window) — защита от абуза.
+app.add_middleware(RateLimitMiddleware)
+
+
+@app.get("/health")
+def health() -> dict[str, str]:
+ return {"status": "ok", "environment": settings.environment}
+
+
+app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
+app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
+app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
+app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
+app.include_router(search.router, prefix="/api/v1", tags=["search"])
+app.include_router(me.router, prefix="/api/v1", tags=["me"])
diff --git a/tradein-mvp/backend/app/schemas/__init__.py b/tradein-mvp/backend/app/schemas/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tradein-mvp/backend/app/schemas/search.py b/tradein-mvp/backend/app/schemas/search.py
new file mode 100644
index 00000000..6d51182e
--- /dev/null
+++ b/tradein-mvp/backend/app/schemas/search.py
@@ -0,0 +1,71 @@
+"""SearchParams + SearchResponse — /api/v1/search (Phase 3.2)."""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field, model_validator
+
+SortKey = Literal[
+ "price_asc", "price_desc", "area_desc", "area_asc", "date_desc", "dist_asc"
+]
+
+
+class SearchParams(BaseModel):
+ """Фильтры поиска по listings_search_mv (master plan sec 9.1)."""
+
+ # --- Geo (radius search) ---
+ lat: float | None = Field(default=None, ge=-90.0, le=90.0)
+ lon: float | None = Field(default=None, ge=-180.0, le=180.0)
+ radius_m: int = Field(default=2000, ge=100, le=50000)
+
+ # --- Property filters ---
+ rooms: int | None = Field(default=None, ge=0, le=10)
+ rooms_in: list[int] | None = None
+ area_m2_min: float | None = Field(default=None, ge=0)
+ area_m2_max: float | None = Field(default=None, ge=0)
+ price_rub_min: int | None = Field(default=None, ge=0)
+ price_rub_max: int | None = Field(default=None, ge=0)
+ price_per_m2_max: int | None = Field(default=None, ge=0)
+ floor_min: int | None = Field(default=None, ge=1)
+ floor_max: int | None = Field(default=None, ge=1)
+
+ # --- House filters ---
+ year_built_min: int | None = Field(default=None, ge=1800, le=2100)
+ year_built_max: int | None = Field(default=None, ge=1800, le=2100)
+ house_class: list[str] | None = None
+ floors_total_min: int | None = Field(default=None, ge=1)
+ floors_total_max: int | None = Field(default=None, ge=1)
+
+ # --- Quality / cross-source ---
+ has_kadastr: bool = False
+ sources: list[Literal["avito", "cian", "yandex_realty", "n1"]] | None = None
+ multi_source_only: bool = False
+ require_avito: bool = False
+ require_cian: bool = False
+ require_yandex: bool = False
+
+ # --- Text search ---
+ address_query: str | None = Field(default=None, max_length=200)
+ description_query: str | None = Field(default=None, max_length=200)
+
+ # --- Sort + pagination ---
+ sort: SortKey = "date_desc"
+ page: int = Field(default=1, ge=1, le=1000)
+ page_size: int = Field(default=50, ge=1, le=200)
+
+ @model_validator(mode="after")
+ def _validate_geo_pair(self) -> SearchParams:
+ if (self.lat is None) != (self.lon is None):
+ raise ValueError("lat and lon must be provided together")
+ if self.sort == "dist_asc" and self.lat is None:
+ raise ValueError("sort=dist_asc requires lat+lon")
+ if self.price_rub_min and self.price_rub_max and self.price_rub_min > self.price_rub_max:
+ raise ValueError("price_rub_min > price_rub_max")
+ if self.area_m2_min and self.area_m2_max and self.area_m2_min > self.area_m2_max:
+ raise ValueError("area_m2_min > area_m2_max")
+ return self
+
+ @property
+ def offset(self) -> int:
+ return (self.page - 1) * self.page_size
diff --git a/tradein-mvp/backend/app/schemas/search_response.py b/tradein-mvp/backend/app/schemas/search_response.py
new file mode 100644
index 00000000..923744a9
--- /dev/null
+++ b/tradein-mvp/backend/app/schemas/search_response.py
@@ -0,0 +1,53 @@
+"""SearchResponse + SearchResultItem — /api/v1/search return shape."""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict
+
+
+class SearchResultItem(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ listing_id: int
+ source: str
+ source_url: str | None = None
+ address: str | None = None
+ lat: float | None = None
+ lng: float | None = None
+
+ rooms: int | None = None
+ total_area: float | None = None
+ floor: int | None = None
+ total_floors: int | None = None
+
+ price_rub: int | None = None
+ price_per_m2: int | None = None
+ kadastr_num: str | None = None
+ scraped_at: datetime | None = None
+
+ house_id: int | None = None
+ year_built: int | None = None
+ house_class: str | None = None
+ developer_name: str | None = None
+ house_rating: float | None = None
+ house_ratings_count: int | None = None
+
+ source_count: int | None = None
+ sources: list[str] | None = None
+ has_avito: bool | None = None
+ has_cian: bool | None = None
+ has_yandex: bool | None = None
+
+ house_median_ppm2: float | None = None
+ district: str | None = None
+
+
+class SearchResponse(BaseModel):
+ items: list[SearchResultItem]
+ total: int
+ page: int
+ page_size: int
+ elapsed_ms: float | None = None
+ cache_hit: bool = False
diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py
new file mode 100644
index 00000000..9ae6a8ce
--- /dev/null
+++ b/tradein-mvp/backend/app/schemas/trade_in.py
@@ -0,0 +1,379 @@
+"""Pydantic schemas for Trade-In Estimator.
+
+POST /api/v1/trade-in/estimate → AggregatedEstimate
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+from typing import Any, Literal
+from uuid import UUID
+
+from pydantic import BaseModel, Field
+
+
+class TradeInEstimateInput(BaseModel):
+ address: str = Field(min_length=3, max_length=500)
+ area_m2: float = Field(gt=10, lt=500)
+ rooms: int = Field(ge=0, le=10) # 0 = студия
+ floor: int | None = Field(default=None, ge=1, le=100)
+ total_floors: int | None = Field(default=None, ge=1, le=100)
+ year_built: int | None = Field(default=None, ge=1800, le=2100)
+ house_type: Literal["panel", "brick", "monolith", "monolith_brick", "other"] | None = None
+ repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None
+ has_balcony: bool | None = None
+ # CRM-поля (#395) — операционные, на расчёт оценки не влияют
+ ownership_type: str | None = Field(default=None, max_length=100)
+ has_mortgage: bool | None = None
+ client_name: str | None = Field(default=None, max_length=200)
+ client_phone: str | None = Field(default=None, max_length=50)
+
+
+class AnalogLot(BaseModel):
+ address: str
+ area_m2: float
+ rooms: int
+ floor: int | None
+ total_floors: int | None
+ price_rub: int
+ price_per_m2: int
+ listing_date: date | None
+ days_on_market: int | None
+ photo_url: str | None = None
+ # ── Новые поля (Слой 5.2 — clickable links) ──
+ source: str | None = None # 'avito' / 'cian' / 'domklik' / 'rosreestr'
+ source_url: str | None = None # ссылка на оригинальное объявление / сделку
+ distance_m: int | None = None # расстояние до целевой квартиры в метрах
+ # ── Confidence tier (PR M / #564 Phase 3) ──
+ # Только для rosreestr-сделок: T0_per_house (kadastr_num exact match),
+ # T1_per_street (street-level only). Open dataset Росреестра не имеет
+ # kadastr_num — все ДКП-сделки сейчас T1. Поле зарезервировано на случай
+ # будущего enrichment data feed (ЕГРН direct).
+ tier: str | None = None
+
+
+class CianChartPoint(BaseModel):
+ """Одна точка 7-месячного chart Cian Valuation Calculator."""
+
+ date: str
+ price: float
+
+
+class CianValuationSummary(BaseModel):
+ """Cian Valuation Calculator данные для UI.
+
+ Источник: external_valuations table (source='cian_valuation').
+ Заполняется только при successful Cian Calculator call в estimator.py.
+ """
+
+ sale_price_rub: int | None = None
+ rent_price_rub: int | None = None
+ chart: list[CianChartPoint] = Field(default_factory=list)
+ chart_change_pct: float | None = None
+ chart_change_direction: Literal["increase", "decrease", "neutral"] | None = None
+
+
+class AggregatedEstimate(BaseModel):
+ estimate_id: UUID
+ median_price_rub: int
+ range_low_rub: int
+ range_high_rub: int
+ median_price_per_m2: int
+ confidence: Literal["low", "medium", "high"]
+ confidence_explanation: str | None = None # «Найдено 15 аналогов, разброс ±7%»
+ n_analogs: int
+ period_months: int # 24
+ analogs: list[AnalogLot] # top 5-10 listings
+ actual_deals: list[AnalogLot] # реальные продажи last 12 mo
+ expires_at: datetime
+ # ── Дополнительные метаданные ──
+ target_address: str | None = None # geocoded full address
+ target_lat: float | None = None
+ target_lon: float | None = None
+ sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
+ data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
+ est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
+ cian_valuation: CianValuationSummary | None = None
+ # ── DaData enrichment (PR Q1) — on-demand для target адреса ──
+ # canonical_address — DaData-нормализованная форма (с улицей в short form).
+ # house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).
+ # house_fias_id — UUID ФИАС дома.
+ # metro_nearest — ближайшие станции метро [{name,line,distance}, ...].
+ # NULL если DaData credentials не заданы / quota exceeded / address не распознан.
+ canonical_address: str | None = None
+ house_cadnum: str | None = None
+ house_fias_id: str | None = None
+ metro_nearest: list[dict] = Field(default_factory=list)
+ # ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
+ # при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
+ area_m2: float | None = None
+ rooms: int | None = None
+ floor: int | None = None
+ total_floors: int | None = None
+ year_built: int | None = None
+ house_type: str | None = None
+ repair_state: str | None = None
+ has_balcony: bool | None = None
+
+
+class PhotoMeta(BaseModel):
+ """Метаданные фото квартиры (#394). Содержимое отдаётся отдельным эндпоинтом."""
+
+ id: UUID
+ filename: str | None = None
+ content_type: str
+ size_bytes: int | None = None
+ uploaded_at: datetime
+
+
+# ── Stage 4a response schemas ────────────────────────────────────────────────
+
+
+class HouseInfoForEstimate(BaseModel):
+ """Summary информации о доме целевой квартиры (для GET /estimate/{id}/houses)."""
+
+ house_id: int | None = None
+ source: str | None = None # 'avito' / 'derived' / 'cian_newbuilding' / etc.
+ ext_house_id: str | None = None
+ address: str | None = None
+ short_address: str | None = None
+ lat: float | None = None
+ lon: float | None = None
+ year_built: int | None = None
+ total_floors: int | None = None
+ house_type: str | None = None
+ passenger_elevators: int | None = None
+ cargo_elevators: int | None = None
+ has_concierge: bool | None = None
+ closed_yard: bool | None = None
+ has_playground: bool | None = None
+ parking_type: str | None = None
+ developer_name: str | None = None
+ rating: float | None = None
+ reviews_count: int | None = None
+ raw_characteristics: list[dict] = Field(default_factory=list)
+
+
+class IMVBenchmarkResponse(BaseModel):
+ """Avito IMV benchmark для UI (GET /estimate/{id}/imv-benchmark)."""
+
+ available: bool # есть ли IMV для этого estimate
+ cache_key: str | None = None
+ recommended_price: int | None = None
+ lower_price: int | None = None
+ higher_price: int | None = None
+ market_count: int | None = None
+ fetched_at: datetime | None = None
+ # comparison vs our estimate
+ our_median_price: int | None = None
+ diff_pct: float | None = None # (our - imv) / imv * 100
+
+
+class PlacementHistoryEntry(BaseModel):
+ """Запись истории размещения лота в доме (`house_placement_history`)."""
+
+ id: int
+ source: str
+ house_id: int | None = None
+ ext_item_id: str
+ title: str | None = None
+ rooms: int | None = None
+ area_m2: float | None = None
+ floor: int | None = None
+ total_floors: int | None = None
+ start_price: int | None = None
+ start_price_date: date | None = None
+ last_price: int | None = None
+ last_price_date: date | None = None
+ removed_date: date | None = None
+ exposure_days: int | None = None
+ notes: str | None = None
+
+
+# ── In-app scheduler (Stage 4e) ─────────────────────────────────────────────
+
+
+class ScheduleConfig(BaseModel):
+ """Текущая конфигурация schedule для source."""
+
+ id: int
+ source: str
+ enabled: bool
+ window_start_hour: int = Field(ge=0, le=23)
+ window_end_hour: int = Field(ge=0, le=23)
+ default_params: dict[str, Any] = Field(default_factory=dict)
+ last_run_id: int | None = None
+ last_run_at: str | None = None # ISO
+ next_run_at: str | None = None # ISO
+ updated_at: str | None = None # ISO
+
+
+class ScheduleConfigUpdate(BaseModel):
+ """PUT body для update_schedule endpoint."""
+
+ enabled: bool = True
+ window_start_hour: int = Field(default=2, ge=0, le=23)
+ window_end_hour: int = Field(default=5, ge=0, le=23)
+ default_params: dict[str, Any] = Field(default_factory=dict)
+
+
+# ── House analytics (house_placement_history backfill) ───────────────────────
+
+
+class PriceHistoryYearPoint(BaseModel):
+ """Медианная цена ₽/м² и число лотов за год, разбитая по источнику."""
+
+ year: int
+ source: str # 'avito_imv' | 'yandex_valuation'
+ median_price_per_m2: int
+ n_lots: int
+ median_price_rub: int
+
+
+class CianPriceChangeStats(BaseModel):
+ """Статистика изменений цены для одного Cian-аналога.
+
+ Источник: offer_price_history JOIN listings (source='cian').
+ """
+
+ cian_id: str
+ listing_id: int
+ n_changes: int # COUNT(*) из offer_price_history
+ last_change_time: datetime | None
+ last_diff_percent: float | None # последняя дельта (-5% если цена снизилась)
+ total_change_pct: float | None # суммарно (current - first) / first * 100
+ first_seen_price: int | None
+ current_price: int # из listings.price_rub
+
+
+class RecentSoldEntry(BaseModel):
+ """Лот из house_placement_history снятый с продажи за последние 12 мес."""
+
+ id: int
+ source: str # 'avito_imv' | 'yandex_valuation'
+ rooms: int | None
+ area_m2: float | None
+ floor: int | None
+ start_price: int | None
+ last_price: int | None
+ removed_date: date | None
+ exposure_days: int | None
+ discount_pct: float | None
+
+
+class HouseAnalyticsKpi(BaseModel):
+ """Агрегированные KPI по дому(ам) из house_placement_history."""
+
+ total_lots: int
+ sold_count: int
+ sold_rate_pct: float
+ median_exposure_days: int | None
+ median_bargain_pct: float | None
+
+
+class HouseAnalyticsResponse(BaseModel):
+ """Ответ GET /estimate/{id}/house-analytics."""
+
+ house_ids: list[int]
+ radius_m: int
+ price_history: list[PriceHistoryYearPoint]
+ recent_sold: list[RecentSoldEntry]
+ kpi: HouseAnalyticsKpi
+
+
+# ── Sell-time sensitivity (срок продажи по бакетам цены) ─────────────────────
+
+
+class SellTimeBucket(BaseModel):
+ """Один бакет срока продажи для данного ценового диапазона."""
+
+ price_premium_label: str # 'cheap' | 'median' | 'plus5' | 'plus10'
+ price_premium_pct: float # -5.0, 0.0, 5.0, 10.0 для UI
+ median_exposure_days: int | None
+ p25_days: int | None
+ p75_days: int | None
+ n_lots: int
+
+
+class SellTimeSensitivityResponse(BaseModel):
+ """Ответ GET /estimate/{id}/sell-time-sensitivity."""
+
+ house_ids: list[int]
+ radius_m: int
+ target_median_price_per_m2: int | None # benchmark — медиана ₽/м² за последние 2 года
+ buckets: list[SellTimeBucket]
+
+
+# ── Street-level deals (rosreestr open dataset) ──────────────────────────────
+
+
+class StreetDealsResponse(BaseModel):
+ """Ответ GET /api/v1/trade-in/street-deals.
+
+ Open dataset Росреестра агрегирует адреса до уровня улицы (без номера дома).
+ Поэтому это per-street view, а не per-house.
+ """
+
+ # извлечённая улица, напр. «Космонавтов» / None если не определилась
+ street: str | None
+ period_from: date
+ period_to: date
+ count: int # число всех matching сделок, не только топ-10
+ median_price_rub: int # 0 если count == 0
+ median_price_per_m2: int
+ range_low_rub: int
+ range_high_rub: int
+ deals: list[AnalogLot] # последние 10 по deal_date DESC
+
+
+# ── Sales vs Listings (PR K, issue #564 Foundation Phase 1) ─────────────────
+
+
+class SalesListingPair(BaseModel):
+ """Пара (ДКП-сделка, listing того же ассортимента).
+
+ Возвращается из street_sales_vs_listings() SQL-function. Если для сделки
+ listing не нашёлся — все listing_* поля = None (LEFT JOIN).
+ """
+
+ deal_id: int
+ deal_date: date
+ deal_price_rub: int
+ deal_price_per_m2: int
+ deal_area_m2: float
+ deal_rooms: int
+ deal_floor: int | None = None
+ deal_address: str | None = None
+
+ listing_id: int | None = None
+ listing_source: str | None = None # 'avito' / 'cian' / 'yandex' / 'n1' / 'domklik'
+ listing_source_url: str | None = None
+ listing_date: date | None = None
+ listing_price_rub: int | None = None
+ listing_price_per_m2: int | None = None
+ listing_area_m2: float | None = None
+
+ # Положительный = listing date раньше сделки (типичный кейс).
+ # Отрицательный = listing появился позже (отложенный парсинг).
+ days_listing_to_deal: int | None = None
+ # discount_pct = (deal_price - listing_price) / listing_price * 100.
+ # Отрицательный = продали дешевле выставленного (торг).
+ discount_pct: float | None = None
+
+
+class SalesVsListingsResponse(BaseModel):
+ """Ответ GET /api/v1/trade-in/sales-vs-listings.
+
+ Per-street pairs ДКП-сделок и matching listings. Aggregate KPIs показывают
+ linkage rate и медианный discount.
+ """
+
+ street: str | None # извлечённое имя улицы, None если не извлеклось
+ period_months: int # окно поиска сделок
+ window_days: int # окно matching listing → deal
+ area_tolerance: float # 0.15 = ±15% по area_m2
+ total_deals: int # количество всех matching ДКП в улице/период
+ deals_with_listings: int # сколько имеют связанный listing
+ linkage_rate_pct: float # deals_with_listings / total_deals * 100
+ median_discount_pct: float | None # медиана по парам с listing
+ pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC
diff --git a/tradein-mvp/backend/app/services/__init__.py b/tradein-mvp/backend/app/services/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tradein-mvp/backend/app/services/brand.py b/tradein-mvp/backend/app/services/brand.py
new file mode 100644
index 00000000..370ff9f7
--- /dev/null
+++ b/tradein-mvp/backend/app/services/brand.py
@@ -0,0 +1,66 @@
+"""Brand resolver — multi-tenant white-label.
+
+Читает бренд по slug из `brands` Postgres-таблицы.
+Используется PDF exporter + frontend (через /api/v1/brand/{slug} endpoint).
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class Brand:
+ slug: str
+ name: str
+ logo_url: str | None
+ primary_color: str
+ accent_color: str
+ footer_text: str | None
+ pdf_disclaimer: str | None
+
+
+_DEFAULT = Brand(
+ slug="generic",
+ name="Trade-In Estimator",
+ logo_url=None,
+ primary_color="#1d4ed8",
+ accent_color="#f59e0b",
+ footer_text=None,
+ pdf_disclaimer=None,
+)
+
+
+def get_brand(slug: str | None, db: Session) -> Brand:
+ """Возвращает Brand по slug. Fallback на 'generic' если не найден."""
+ if not slug:
+ return _DEFAULT
+ row = db.execute(
+ text(
+ """
+ SELECT slug, name, logo_url, primary_color, accent_color,
+ footer_text, pdf_disclaimer
+ FROM brands
+ WHERE slug = :slug
+ """
+ ),
+ {"slug": slug.lower()},
+ ).fetchone()
+ if row is None:
+ logger.info("brand not found: %s — using generic", slug)
+ return _DEFAULT
+ return Brand(
+ slug=row.slug,
+ name=row.name,
+ logo_url=row.logo_url,
+ primary_color=row.primary_color or _DEFAULT.primary_color,
+ accent_color=row.accent_color or _DEFAULT.accent_color,
+ footer_text=row.footer_text,
+ pdf_disclaimer=row.pdf_disclaimer,
+ )
diff --git a/tradein-mvp/backend/app/services/cache.py b/tradein-mvp/backend/app/services/cache.py
new file mode 100644
index 00000000..7de08ac0
--- /dev/null
+++ b/tradein-mvp/backend/app/services/cache.py
@@ -0,0 +1,64 @@
+"""Redis hot cache for search results (master plan sec 7.2)."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+from functools import lru_cache
+from typing import Any
+
+import redis.asyncio as redis_async
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class SearchCache:
+ """Async Redis cache wrapper. Stable sha256 hash keys, TTL per kind."""
+
+ TTL_SEARCH: int = 300
+ TTL_HOUSE_DETAIL: int = 3600
+ TTL_VALUATION: int = 86400
+
+ def __init__(self, url: str) -> None:
+ self._client: redis_async.Redis = redis_async.from_url(
+ url, decode_responses=True, socket_timeout=2.0, socket_connect_timeout=2.0
+ )
+
+ def search_key(self, params: dict[str, Any]) -> str:
+ payload = json.dumps(params, sort_keys=True, default=str, ensure_ascii=False)
+ digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
+ return f"tradein:search:{digest}"
+
+ async def get(self, key: str) -> dict[str, Any] | None:
+ try:
+ raw = await self._client.get(key)
+ except Exception as e:
+ logger.warning("redis GET failed key=%s: %s", key[:24], e)
+ return None
+ if raw is None:
+ return None
+ try:
+ return json.loads(raw)
+ except json.JSONDecodeError:
+ logger.warning("redis cache corrupted key=%s", key[:24])
+ return None
+
+ async def set(self, key: str, value: dict[str, Any], ttl: int) -> None:
+ try:
+ await self._client.set(
+ key, json.dumps(value, default=str, ensure_ascii=False), ex=ttl
+ )
+ except Exception as e:
+ logger.warning("redis SET failed key=%s: %s", key[:24], e)
+
+ async def close(self) -> None:
+ await self._client.aclose()
+
+
+@lru_cache(maxsize=1)
+def get_search_cache() -> SearchCache:
+ """Singleton — один Redis pool на процесс."""
+ return SearchCache(settings.redis_url)
diff --git a/tradein-mvp/backend/app/services/cian_session.py b/tradein-mvp/backend/app/services/cian_session.py
new file mode 100644
index 00000000..f753a511
--- /dev/null
+++ b/tradein-mvp/backend/app/services/cian_session.py
@@ -0,0 +1,211 @@
+"""Cian session cookie management — load/save/verify encrypted cookies.
+
+Used by cian_valuation.py (Stage 7) to authenticate Valuation Calculator API.
+Cookies stored encrypted (pgp_sym_encrypt) in cian_session_cookies table.
+"""
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+import httpx
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from app.services.scrapers.cian_state_parser import extract_state
+
+logger = logging.getLogger(__name__)
+
+# Cookies критичные для Cian auth — фильтр перед сохранением.
+# Список обновлён по реальному DevTools-дампу из logged-in сессии cian.ru (2026-05-23).
+# Старые записи оставлены как fallback (backward compat).
+CIAN_REQUIRED_COOKIES: set[str] = {
+ # --- Cian auth & session (critical) ---
+ "DMIR_AUTH", # Cian auth token (httpOnly) — основной auth-сигнал
+ "_CIAN_GK", # Cian session GUID
+
+ # --- Yandex Metrika (обычно присутствуют, не critical) ---
+ "_ym_uid",
+ "_ym_d",
+ "_ym_isad",
+ "_ym_visorc",
+ "_yasc", # Yandex anti-spam
+
+ # --- Cian UX state ---
+ "uxfb_card_satisfaction",
+
+ # --- Cian session IDs (fallback / legacy deployments) ---
+ "_cian_visitor_session_id",
+ "_cian_app_session_id",
+ "_cian_uid",
+
+ # --- Misc (legacy / optional) ---
+ "_ga", # Google Analytics
+ "tlsr_id", # legacy fingerprint
+ "cf_clearance", # Cloudflare bot check
+ "session-id", # generic session cookie
+ "anti_bot",
+ "csrftoken",
+}
+
+_VALUATION_TEST_URL = (
+ "https://www.cian.ru/kalkulator-nedvizhimosti/"
+ "?address=%D0%A1%D0%B2%D0%B5%D1%80%D0%B4%D0%BB%D0%BE%D0%B2%D1%81%D0%BA%D0%B0%D1%8F"
+ "%20%D0%BE%D0%B1%D0%BB%2C%20%D0%95%D0%BA%D0%B0%D1%82%D0%B5%D1%80%D0%B8%D0%BD%D0%B1%D1%83%D1%80%D0%B3"
+ "&totalArea=40&roomsCount=1&valuationType=sale"
+ "&floor%5B0%5D=floorOther&repairType%5B0%5D=repairTypeCosmetic"
+)
+
+_MFE_VALUATION = "valuation-for-agent-frontend"
+
+
+async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
+ """Hit Cian Valuation Calculator with cookies — return parsed state if authenticated.
+
+ Returns state dict containing user.isAuthenticated + userId, or None if
+ cookies are invalid/expired or state extraction fails.
+ Никогда не логирует сырые значения cookies.
+ """
+ try:
+ async with httpx.AsyncClient(
+ cookies=cookies,
+ timeout=20.0,
+ follow_redirects=True,
+ ) as client:
+ resp = await client.get(_VALUATION_TEST_URL)
+ resp.raise_for_status()
+ state = extract_state(resp.text, mfe=_MFE_VALUATION, key="initialState")
+ if state is None:
+ logger.warning(
+ "Cian cookies verify: state extraction failed (mfe=%s)", _MFE_VALUATION
+ )
+ return None
+ user = state.get("user", {})
+ if not user.get("isAuthenticated"):
+ logger.warning("Cian cookies verify: user.isAuthenticated=false")
+ return None
+ logger.info("Cian cookies verified — userId=%s", user.get("userId"))
+ return state
+ except httpx.HTTPStatusError as exc:
+ logger.warning(
+ "Cian cookies verify HTTP error: %s %s",
+ exc.response.status_code,
+ exc.request.url,
+ )
+ return None
+ except Exception as exc:
+ logger.warning("Cian cookies verify failed: %s", exc)
+ return None
+
+
+def save_session(
+ db: Session,
+ account_user_id: int,
+ cookies: dict[str, str],
+ ttl_days: int = 30,
+) -> None:
+ """Encrypt cookies via pgp_sym_encrypt and UPSERT into cian_session_cookies.
+
+ Uses settings.cookie_encryption_key as the encryption secret.
+ Никогда не логирует сырые значения cookies.
+ """
+ cookies_json = json.dumps(cookies)
+ db.execute(
+ text("""
+ INSERT INTO cian_session_cookies (
+ account_user_id,
+ cookies_encrypted,
+ expires_at_estimate,
+ uploaded_at
+ ) VALUES (
+ CAST(:uid AS bigint),
+ pgp_sym_encrypt(:cookies_json, :key),
+ NOW() + (CAST(:ttl_days AS int) || ' days')::interval,
+ NOW()
+ )
+ ON CONFLICT (account_user_id) DO UPDATE SET
+ cookies_encrypted = EXCLUDED.cookies_encrypted,
+ expires_at_estimate = EXCLUDED.expires_at_estimate,
+ uploaded_at = NOW(),
+ last_invalid_at = NULL
+ """),
+ {
+ "uid": account_user_id,
+ "cookies_json": cookies_json,
+ "key": settings.cookie_encryption_key,
+ "ttl_days": ttl_days,
+ },
+ )
+ db.commit()
+ logger.info(
+ "Cian cookies saved for userId=%s (count=%d, ttl=%d days)",
+ account_user_id,
+ len(cookies),
+ ttl_days,
+ )
+
+
+def load_session(db: Session) -> dict[str, str] | None:
+ """Load most-recently-uploaded valid Cian cookies (decrypt).
+
+ Returns dict[cookie_name, cookie_value] или None если нет валидной session.
+ Выбирает только записи где expires_at_estimate > NOW() и сессия не
+ была инвалидирована после последнего upload'а.
+ """
+ row = db.execute(
+ text("""
+ SELECT
+ account_user_id,
+ pgp_sym_decrypt(cookies_encrypted, :key)::text AS cookies_json,
+ expires_at_estimate
+ FROM cian_session_cookies
+ WHERE expires_at_estimate > NOW()
+ AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at)
+ ORDER BY uploaded_at DESC
+ LIMIT 1
+ """),
+ {"key": settings.cookie_encryption_key},
+ ).mappings().first()
+
+ if row is None:
+ logger.warning("No valid Cian session cookies in DB")
+ return None
+
+ cookies: dict[str, str] = json.loads(row["cookies_json"])
+
+ # Обновляем last_used_at — не критично, игнорируем ошибки.
+ try:
+ db.execute(
+ text(
+ "UPDATE cian_session_cookies SET last_used_at = NOW()"
+ " WHERE account_user_id = CAST(:uid AS bigint)"
+ ),
+ {"uid": row["account_user_id"]},
+ )
+ db.commit()
+ except Exception as exc:
+ logger.warning(
+ "Failed to update last_used_at for userId=%s: %s", row["account_user_id"], exc
+ )
+
+ logger.info(
+ "Cian cookies loaded for userId=%s (count=%d)",
+ row["account_user_id"],
+ len(cookies),
+ )
+ return cookies
+
+
+def mark_session_invalid(db: Session, account_user_id: int) -> None:
+ """Flag session как expired/invalid (например после 401 во время scrape)."""
+ db.execute(
+ text(
+ "UPDATE cian_session_cookies SET last_invalid_at = NOW()"
+ " WHERE account_user_id = CAST(:uid AS bigint)"
+ ),
+ {"uid": account_user_id},
+ )
+ db.commit()
+ logger.warning("Cian session marked invalid for userId=%s", account_user_id)
diff --git a/tradein-mvp/backend/app/services/dadata.py b/tradein-mvp/backend/app/services/dadata.py
new file mode 100644
index 00000000..8f414476
--- /dev/null
+++ b/tradein-mvp/backend/app/services/dadata.py
@@ -0,0 +1,383 @@
+"""DaData clients — /clean/address (enrichment) + /suggest/address (autocomplete).
+
+/clean/address — обогащение **целевого адреса** канонической формой,
+kadastr_num, ФИАС, координаты, метро. Используется в estimator flow для
+on-demand обогащения payload.address (PR Q1). Требует token + secret,
+demo tier 100/день.
+
+/suggest/address — автокомплит для /api/v1/geocode/suggest (PR Q2).
+Заменяет заблокированный Yandex Suggest как второй tier после Cadastral FDW.
+Token-only (X-Secret не нужен), 10k/день free. Возвращает список candidate'ов
+с координатами + ФИАС + структурными полями.
+
+ENV: DADATA_API_TOKEN, DADATA_API_SECRET.
+- clean_address: требует оба.
+- suggest_addresses: требует только DADATA_API_TOKEN.
+Если требуемые credentials не заданы → graceful возврат None / [] (не break flow).
+
+Docs:
+- https://dadata.ru/api/clean/address/
+- https://dadata.ru/api/suggest/address/
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Any
+
+import httpx
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+DADATA_CLEAN_URL = "https://cleaner.dadata.ru/api/v1/clean/address"
+DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
+_DADATA_TIMEOUT_S = 8.0
+_DADATA_SUGGEST_TIMEOUT_S = 5.0
+
+
+@dataclass(frozen=True, slots=True)
+class DadataAddressResult:
+ """Parsed DaData enrichment payload — slots для compact representation.
+
+ canonical_address — поле `result` из ответа («г Екатеринбург, ул Малышева, д 125»).
+ house_cadnum — кадастровый номер ДОМА (не участка). Формат «66:41:0704045:350».
+ house_fias_id — UUID ФИАС дома.
+ lat / lon — координаты от DaData (precision до дома).
+ qc_geo — quality code геокодинга: 0=exact, 1=street, 2=settlement…
+ qc_house — quality code дома: 2=в ФИАС, 10=на картах…
+ kladr_id — старый ОКРС-классификатор (legacy, может быть полезен).
+ okato / oktmo — admin codes для отчётности.
+ metro — список ближайших станций метро (если есть): [{name,line,distance}…].
+ raw — полный ответ для debugging / future fields.
+ """
+
+ canonical_address: str | None
+ house_cadnum: str | None
+ house_fias_id: str | None
+ lat: float | None
+ lon: float | None
+ qc_geo: int | None
+ qc_house: int | None
+ kladr_id: str | None
+ okato: str | None
+ oktmo: str | None
+ metro: list[dict[str, Any]]
+ raw: dict[str, Any]
+
+
+def _credentials() -> tuple[str, str] | None:
+ """Returns (token, secret) если оба заданы, иначе None."""
+ token = (settings.dadata_api_token or "").strip()
+ secret = (settings.dadata_api_secret or "").strip()
+ if not token or not secret:
+ return None
+ return token, secret
+
+
+def _coerce_int(value: Any) -> int | None:
+ """DaData отдаёт qc-коды строками иногда. Coerce → int безопасно."""
+ if value is None:
+ return None
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def _coerce_float(value: Any) -> float | None:
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def _parse_response(item: dict[str, Any]) -> DadataAddressResult:
+ """Парсит single DaData payload item в DadataAddressResult.
+
+ Не raise'ит — пропускает невалидные поля как None.
+ """
+ metro_raw = item.get("metro") or []
+ if not isinstance(metro_raw, list):
+ metro_raw = []
+
+ return DadataAddressResult(
+ canonical_address=item.get("result"),
+ house_cadnum=item.get("house_cadnum"),
+ house_fias_id=item.get("house_fias_id"),
+ lat=_coerce_float(item.get("geo_lat")),
+ lon=_coerce_float(item.get("geo_lon")),
+ qc_geo=_coerce_int(item.get("qc_geo")),
+ qc_house=_coerce_int(item.get("qc_house")),
+ kladr_id=item.get("kladr_id"),
+ okato=item.get("okato"),
+ oktmo=item.get("oktmo"),
+ metro=metro_raw,
+ raw=item,
+ )
+
+
+async def clean_address(address: str) -> DadataAddressResult | None:
+ """Обогащает один адрес через DaData /clean/address.
+
+ Args:
+ address: свободный текст адреса (как пришёл от пользователя или из geocoder).
+
+ Returns:
+ DadataAddressResult или None если:
+ - credentials не заданы в ENV (graceful disable)
+ - адрес пустой / слишком короткий
+ - API вернул пустой массив / ошибку shape
+ - сетевая / HTTP ошибка (5xx, 429 quota, timeout)
+ - адрес не распознан (canonical_address пустой)
+ """
+ if not address or len(address.strip()) < 3:
+ return None
+
+ creds = _credentials()
+ if creds is None:
+ logger.debug("dadata: credentials не заданы — skip enrichment")
+ return None
+
+ token, secret = creds
+ headers = {
+ "Authorization": f"Token {token}",
+ "X-Secret": secret,
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ }
+ body = [address.strip()]
+
+ try:
+ async with httpx.AsyncClient(timeout=_DADATA_TIMEOUT_S) as client:
+ response = await client.post(DADATA_CLEAN_URL, headers=headers, json=body)
+ except (httpx.TimeoutException, httpx.NetworkError) as exc:
+ logger.warning("dadata: network error для %r: %s", address[:60], exc)
+ return None
+ except Exception as exc: # pragma: no cover — defensive
+ logger.warning("dadata: unexpected client error для %r: %s", address[:60], exc)
+ return None
+
+ status = response.status_code
+ if status == 429:
+ logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
+ return None
+ if status in (401, 403):
+ logger.error(
+ "dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.",
+ status,
+ )
+ return None
+ if status >= 500:
+ logger.warning("dadata: HTTP %d — transient server error", status)
+ return None
+ if status >= 400:
+ body_preview = (response.text or "")[:200]
+ logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview)
+ return None
+
+ try:
+ payload = response.json()
+ except ValueError as exc:
+ logger.warning("dadata: невалидный JSON в ответе: %s", exc)
+ return None
+
+ if not isinstance(payload, list) or not payload:
+ logger.info("dadata: пустой массив для %r", address[:60])
+ return None
+
+ item = payload[0]
+ if not isinstance(item, dict):
+ logger.warning("dadata: первый элемент не dict (%s)", type(item).__name__)
+ return None
+
+ result = _parse_response(item)
+
+ if not result.canonical_address:
+ # DaData вернул shape, но не распознал адрес — quality codes обычно qc_geo=5.
+ logger.info(
+ "dadata: адрес не распознан (qc_geo=%s qc_house=%s) для %r",
+ result.qc_geo,
+ result.qc_house,
+ address[:60],
+ )
+ return None
+
+ logger.info(
+ "dadata: enriched %r → canonical=%r cadnum=%s qc_geo=%s qc_house=%s",
+ address[:60],
+ (result.canonical_address or "")[:60],
+ result.house_cadnum,
+ result.qc_geo,
+ result.qc_house,
+ )
+ return result
+
+
+# ── Suggest (autocomplete) ───────────────────────────────────────────────────
+
+
+@dataclass(frozen=True, slots=True)
+class DadataSuggestion:
+ """Один candidate из ответа DaData /suggest/address.
+
+ value — короткая строка для отображения («ул Малышева, д 30»).
+ unrestricted_value — полная строка с регионом/страной («г Екатеринбург, ул …»).
+ lat / lon — координаты (если DaData их вернул; для some сегментов None).
+ fias_id — UUID ФИАС матчнутого объекта (дома/улицы/города).
+ house — номер дома если distinguished (строкой: «30» / «30к2»).
+ street — название улицы.
+ city — locality.
+ kind — `house` (fias_level=8) / `street` (=7) / `city` (<=6).
+ Маппится на GeocodeSuggestion.kind в geocoder.py.
+ """
+
+ value: str
+ unrestricted_value: str
+ lat: float | None
+ lon: float | None
+ fias_id: str | None
+ house: str | None
+ street: str | None
+ city: str | None
+ kind: str
+
+
+def _classify_kind(fias_level: Any) -> str:
+ """Маппит DaData `fias_level` → kind для GeocodeSuggestion.
+
+ DaData fias_level (https://dadata.ru/api/suggest/address/#fias_level):
+ 0=country, 1=region, 3=area, 4=city, 5=settlement, 6=street_alt,
+ 7=street, 8=house, 9=flat, 65=planning, 75=plot.
+ Для autocomplete нам важно: house / street / city.
+ """
+ level = _coerce_int(fias_level)
+ if level is None:
+ return "city" # safe default
+ if level >= 8:
+ return "house"
+ if level == 7:
+ return "street"
+ return "city"
+
+
+def _parse_suggestion(item: dict[str, Any]) -> DadataSuggestion | None:
+ """Парсит один элемент `suggestions[]` → DadataSuggestion.
+
+ Возвращает None если shape невалидный (нет value / нет data).
+ """
+ if not isinstance(item, dict):
+ return None
+ value = item.get("value")
+ if not value or not isinstance(value, str):
+ return None
+ data = item.get("data") or {}
+ if not isinstance(data, dict):
+ data = {}
+
+ return DadataSuggestion(
+ value=value,
+ unrestricted_value=str(item.get("unrestricted_value") or value),
+ lat=_coerce_float(data.get("geo_lat")),
+ lon=_coerce_float(data.get("geo_lon")),
+ fias_id=data.get("fias_id"),
+ house=data.get("house"),
+ street=data.get("street"),
+ city=data.get("city"),
+ kind=_classify_kind(data.get("fias_level")),
+ )
+
+
+async def suggest_addresses(
+ query: str, limit: int = 8, city: str = "Екатеринбург"
+) -> list[DadataSuggestion]:
+ """Автокомплит адресов через DaData /suggest/address.
+
+ Args:
+ query: текст для подсказки (минимум 2 символа).
+ limit: сколько вариантов вернуть (DaData параметр `count`, max 20).
+ city: locality для constraint (передаётся в `locations`). По умолчанию ЕКБ.
+
+ Returns:
+ list[DadataSuggestion] — пустой список если:
+ - DADATA_API_TOKEN не задан (graceful disable)
+ - query короче 2 символов
+ - сетевая / HTTP ошибка / 429 quota / malformed response
+
+ Token-only auth (X-Secret для suggest не нужен — отличается от clean_address).
+ Quota: 10000/день на free tier (в 100x больше чем у clean_address).
+ """
+ if not query or len(query.strip()) < 2:
+ return []
+
+ token = (settings.dadata_api_token or "").strip()
+ if not token:
+ logger.debug("dadata suggest: DADATA_API_TOKEN не задан — skip")
+ return []
+
+ headers = {
+ "Authorization": f"Token {token}",
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ }
+ body: dict[str, Any] = {
+ "query": query.strip(),
+ "count": max(1, min(int(limit), 20)),
+ }
+ if city:
+ body["locations"] = [{"city": city}]
+
+ try:
+ async with httpx.AsyncClient(timeout=_DADATA_SUGGEST_TIMEOUT_S) as client:
+ response = await client.post(DADATA_SUGGEST_URL, headers=headers, json=body)
+ except (httpx.TimeoutException, httpx.NetworkError) as exc:
+ logger.warning("dadata suggest: network error для %r: %s", query[:60], exc)
+ return []
+ except Exception as exc: # pragma: no cover — defensive
+ logger.warning("dadata suggest: unexpected client error для %r: %s", query[:60], exc)
+ return []
+
+ status = response.status_code
+ if status == 429:
+ logger.warning("dadata suggest: HTTP 429 — quota exceeded (10k/день free?)")
+ return []
+ if status in (401, 403):
+ logger.error(
+ "dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status
+ )
+ return []
+ if status >= 500:
+ logger.warning("dadata suggest: HTTP %d — transient server error", status)
+ return []
+ if status >= 400:
+ body_preview = (response.text or "")[:200]
+ logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview)
+ return []
+
+ try:
+ payload = response.json()
+ except ValueError as exc:
+ logger.warning("dadata suggest: невалидный JSON в ответе: %s", exc)
+ return []
+
+ if not isinstance(payload, dict):
+ logger.warning("dadata suggest: response не dict (%s)", type(payload).__name__)
+ return []
+
+ raw_suggestions = payload.get("suggestions")
+ if not isinstance(raw_suggestions, list):
+ logger.info("dadata suggest: нет ключа `suggestions` в ответе для %r", query[:60])
+ return []
+
+ out: list[DadataSuggestion] = []
+ for item in raw_suggestions:
+ parsed = _parse_suggestion(item)
+ if parsed is not None:
+ out.append(parsed)
+
+ logger.info("dadata suggest: %r → %d вариантов", query[:60], len(out))
+ return out
diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py
new file mode 100644
index 00000000..7ba91368
--- /dev/null
+++ b/tradein-mvp/backend/app/services/estimator.py
@@ -0,0 +1,1880 @@
+"""Trade-In Estimator — реальное SQL aggregation поверх listings + deals.
+
+Заменяет старый _mock_estimate() из api/v1/trade_in.py.
+
+Алгоритм:
+1. Geocode address → (lat, lon)
+2. SELECT listings с фильтрами:
+ - PostGIS ST_DWithin (geom, point, 1000m) — радиус поиска
+ - source ≠ avito (у Avito фейковые anchor-jitter координаты — не гео-аналог)
+ - rooms = target_rooms (точное совпадение)
+ - area_m2 BETWEEN target × 0.85 AND target × 1.15
+ - scraped_at > NOW() - 14 days (свежие)
+ - is_active = true
+3. Tukey outlier filter (1.5 × IQR rule)
+4. Median / Q1 / Q3 / count → confidence
+5. То же для deals (period = 12 mo).
+6. Сохранить в trade_in_estimates + вернуть AggregatedEstimate
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import math
+import re
+from datetime import UTC, datetime, timedelta
+from typing import Any
+from uuid import uuid4
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.schemas.trade_in import (
+ AggregatedEstimate,
+ AnalogLot,
+ CianValuationSummary,
+ TradeInEstimateInput,
+)
+from app.services.dadata import DadataAddressResult
+from app.services.dadata import clean_address as dadata_clean_address
+from app.services.geocoder import GeocodeResult, geocode
+from app.services.house_metadata import get_house_metadata
+from app.services.matching.houses import match_or_create_house
+from app.services.scrapers.avito_imv import (
+ IMVAddressNotFoundError,
+ IMVAuthError,
+ IMVEvaluation,
+ IMVTransientError,
+ compute_imv_cache_key,
+ evaluate_via_imv,
+ save_imv_evaluation,
+)
+from app.services.scrapers.cian_valuation import (
+ CianValuationResult,
+ estimate_via_cian_valuation,
+)
+from app.services.scrapers.yandex_valuation import (
+ YandexValuationResult,
+ YandexValuationScraper,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# ── Constants ────────────────────────────────────────────────────────────────
+DEFAULT_RADIUS_M = 1000 # ПО ВСТРЕЧЕ ПТИЦЫ: «локация не дальше 800-1000 м»
+FALLBACK_RADIUS_M = 2000
+AREA_TOLERANCE = 0.15 # ±15% площади
+MAX_ANALOGS_PER_ADDRESS = 5 # анти-bias: не больше 5 лотов с одного адреса
+MIN_ANALOGS_PER_SOURCE = 5 # гарантированный минимум на live source
+LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
+DEALS_PERIOD_MONTHS = 12 # сделки за последний год
+
+# Когорта по году постройки — типизация массовой застройки РФ.
+# Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24).
+# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
+COHORTS = (
+ # (cohort_name, year_min_inclusive, year_max_inclusive)
+ ("khrushchev", 1955, 1969), # Хрущёвки 5-эт
+ ("brezhnev", 1970, 1989), # Брежневка кирпич/панель 9–12-эт
+ (
+ "late_soviet",
+ 1990,
+ 1999,
+ ), # Поздний СССР (no overlap; first-match would never pick old range)
+ ("2000s", 2000, 2010), # Ранние новостройки
+ ("modern", 2011, 2100), # Современные ЖК
+)
+# Минимум аналогов чтобы остаться на Tier 0 (с cohort); ниже — fallback на Tier A.
+MIN_ANALOGS_TIER_0 = 5
+
+
+def _target_cohort_range(year_built: int | None) -> tuple[int, int] | None:
+ """Maps a target year to its cohort year range [min, max] inclusive.
+
+ Returns None if year_built is None — caller will skip cohort filter.
+ Picks the FIRST matching cohort (so 1988 → 'brezhnev', not 'late_soviet').
+ """
+ if year_built is None:
+ return None
+ for _name, ymin, ymax in COHORTS:
+ if ymin <= year_built <= ymax:
+ return (ymin, ymax)
+ # Out-of-range год (например, 1900 или 2050) — cohort фильтр не применяем,
+ # лучше показать что есть в радиусе, чем 0 результатов.
+ return None
+
+
+# Поправочные коэффициенты на состояние ремонта. Аналоги в выборке — микс
+# состояний (≈ "стандартный/косметический"), коэффициент сдвигает медиану под
+# конкретный ремонт целевой квартиры. Встреча Птицы: ремонт влияет на цену.
+_IMV_HOUSE_TYPE_MAP: dict[str | None, str | None] = {
+ "panel": "panel",
+ "brick": "brick",
+ "monolith": "monolith",
+ "monolith_brick": "monolith_brick",
+ "monolithic": "monolith",
+ "block": "block",
+ "wood": "wood",
+ None: None,
+}
+_IMV_REPAIR_MAP: dict[str | None, str | None] = {
+ "needs_repair": "required",
+ "standard": "cosmetic",
+ "good": "euro",
+ "excellent": "designer",
+ None: None,
+}
+
+_REPAIR_COEF: dict[str, float] = {
+ "needs_repair": 0.92, # требует ремонта — ниже рынка
+ "standard": 0.98,
+ "good": 1.03,
+ "excellent": 1.08, # евроремонт — выше рынка
+}
+_REPAIR_LABEL: dict[str | None, str] = {
+ "needs_repair": "требует ремонта",
+ "standard": "стандартный ремонт",
+ "good": "хороший ремонт",
+ "excellent": "евроремонт",
+}
+
+
+def _repair_coefficient(repair_state: str | None) -> float:
+ """Множитель к медиане по состоянию ремонта. None → 1.0 (без поправки)."""
+ if not repair_state:
+ return 1.0
+ return _REPAIR_COEF.get(repair_state, 1.0)
+
+
+# ── Avito IMV cache lookup (Stage 3) ────────────────────────────────────────
+IMV_CACHE_TTL_HOURS = 24
+
+# Префиксы в адресе, которые Avito-геокодер не распознаёт (не жилые назначения).
+# Пример: "Склад, ул. Заводская, д. 44-а" → "ул. Заводская, д. 44-а"
+_NOISE_PREFIX_RE = re.compile(
+ r"(Склад|Гараж|Подсобка|Нежилое|Помещение|Цех),\s*",
+ flags=re.IGNORECASE,
+)
+
+YANDEX_VALUATION_CACHE_TTL_HOURS = 24
+YANDEX_VALUATION_DEFAULT_CATEGORY = "APARTMENT"
+YANDEX_VALUATION_DEFAULT_TYPE = "SELL"
+
+
+async def _get_or_fetch_imv_cached(
+ db: Session,
+ *,
+ address: str,
+ rooms: int,
+ area_m2: float,
+ floor: int,
+ floor_at_home: int,
+ house_type: str,
+ renovation_type: str,
+ has_balcony: bool,
+ has_loggia: bool,
+ estimate_id_for_link: Any = None,
+) -> IMVEvaluation | None:
+ """Cached IMV lookup. TTL 24h по cache_key (sha256 of address + params).
+
+ 1. compute cache_key
+ 2. SELECT из avito_imv_evaluations WHERE cache_key = :ck AND fetched_at > NOW() - 24h
+ 3. Если hit → возвращаем reconstructed IMVEvaluation
+ 4. Cache miss → call evaluate_via_imv, save_imv_evaluation, return
+
+ Graceful: на любой error возвращаем None (estimator продолжает без IMV).
+ """
+ try:
+ cache_key = compute_imv_cache_key(
+ address,
+ rooms,
+ area_m2,
+ floor,
+ floor_at_home,
+ house_type,
+ renovation_type,
+ has_balcony,
+ has_loggia,
+ )
+
+ existing = (
+ db.execute(
+ text(
+ """
+ SELECT id, cache_key, address, rooms, area_m2, floor, floor_at_home,
+ house_type, renovation_type, has_balcony, has_loggia,
+ lat, lon, geo_hash, avito_address_id, avito_location_id,
+ avito_metro_id, avito_district_id,
+ recommended_price, lower_price, higher_price, market_count,
+ raw_response, fetched_at
+ FROM avito_imv_evaluations
+ WHERE cache_key = :ck
+ AND fetched_at > NOW() - (:ttl_hours || ' hours')::interval
+ ORDER BY fetched_at DESC
+ LIMIT 1
+ """
+ ),
+ {"ck": cache_key, "ttl_hours": IMV_CACHE_TTL_HOURS},
+ )
+ .mappings()
+ .first()
+ )
+
+ if existing is not None:
+ logger.info(
+ "imv: cache HIT key=%s recommended=%d",
+ cache_key[:8],
+ existing["recommended_price"],
+ )
+ from app.services.scrapers.avito_imv import IMVGeo
+
+ return IMVEvaluation(
+ cache_key=existing["cache_key"],
+ address=existing["address"],
+ rooms=existing["rooms"],
+ area_m2=float(existing["area_m2"]),
+ floor=existing["floor"],
+ floor_at_home=existing["floor_at_home"],
+ house_type=existing["house_type"],
+ renovation_type=existing["renovation_type"],
+ has_balcony=existing["has_balcony"],
+ has_loggia=existing["has_loggia"],
+ geo=IMVGeo(
+ geo_hash=existing["geo_hash"] or "",
+ lat=existing["lat"],
+ lon=existing["lon"],
+ avito_address_id=existing["avito_address_id"],
+ avito_location_id=existing["avito_location_id"],
+ avito_metro_id=existing["avito_metro_id"],
+ avito_district_id=existing["avito_district_id"],
+ ),
+ recommended_price=existing["recommended_price"],
+ lower_price=existing["lower_price"],
+ higher_price=existing["higher_price"],
+ market_count=existing["market_count"],
+ raw_response=existing.get("raw_response"),
+ )
+
+ # Cache miss — fresh fetch
+ logger.info("imv: cache MISS key=%s — fetching fresh", cache_key[:8])
+ result = await evaluate_via_imv(
+ address=address,
+ rooms=rooms,
+ area_m2=area_m2,
+ floor=floor,
+ floor_at_home=floor_at_home,
+ house_type=house_type,
+ renovation_type=renovation_type,
+ has_balcony=has_balcony,
+ has_loggia=has_loggia,
+ )
+ save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
+ logger.info(
+ "imv: fresh recommended=%d range=(%d, %d) count=%d",
+ result.recommended_price,
+ result.lower_price,
+ result.higher_price,
+ result.market_count or 0,
+ )
+ return result
+
+ except IMVAddressNotFoundError as e:
+ logger.warning("imv: address not found in Avito geocoder: %s", e)
+ # Retry once with noise prefixes stripped (e.g. "Склад, ул. X" → "ул. X")
+ cleaned = _NOISE_PREFIX_RE.sub("", address)
+ if cleaned != address:
+ logger.info(
+ "imv: retry with cleaned address %r → %r",
+ address[:60],
+ cleaned[:60],
+ )
+ try:
+ result = await evaluate_via_imv(
+ address=cleaned,
+ rooms=rooms,
+ area_m2=area_m2,
+ floor=floor,
+ floor_at_home=floor_at_home,
+ house_type=house_type,
+ renovation_type=renovation_type,
+ has_balcony=has_balcony,
+ has_loggia=has_loggia,
+ )
+ save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
+ logger.info(
+ "imv: retry OK recommended=%d range=(%d, %d) count=%d",
+ result.recommended_price,
+ result.lower_price,
+ result.higher_price,
+ result.market_count or 0,
+ )
+ return result
+ except IMVAddressNotFoundError:
+ logger.warning("imv: cleaned address also not found — giving up")
+ except Exception as retry_exc:
+ logger.warning("imv: retry failed: %s", retry_exc)
+ return None
+ except IMVAuthError as e:
+ logger.error(
+ "imv: auth/quota error — manual action required: %s",
+ e,
+ )
+ return None
+ except IMVTransientError as e:
+ logger.warning("imv: transient error, skipping retry in estimator context: %s", e)
+ return None
+ except Exception as e:
+ logger.warning("imv: fetch failed — estimator продолжает без IMV: %s", e)
+ return None
+
+
+# ── Yandex Valuation cache lookup (Stage 8) ─────────────────────────────────
+
+
+def _yandex_valuation_cache_key(address: str, offer_category: str, offer_type: str) -> str:
+ """SHA256 cache key for Yandex Valuation lookups."""
+ payload = f"{address}|{offer_category}|{offer_type}"
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+
+async def _get_or_fetch_yandex_valuation_cached(
+ db: Session,
+ *,
+ address: str,
+ offer_category: str = YANDEX_VALUATION_DEFAULT_CATEGORY,
+ offer_type: str = YANDEX_VALUATION_DEFAULT_TYPE,
+) -> YandexValuationResult | None:
+ """Cached Yandex Valuation lookup. TTL 24h via external_valuations table.
+
+ Returns None on any error / cache miss + fetch failure — caller continues
+ without Yandex enrichment (graceful degradation).
+ """
+ cache_key = _yandex_valuation_cache_key(address, offer_category, offer_type)
+
+ # Cache lookup
+ try:
+ cached = (
+ db.execute(
+ text(
+ """
+ SELECT raw_payload, fetched_at
+ FROM external_valuations
+ WHERE source = 'yandex_valuation'
+ AND cache_key = :ck
+ AND expires_at > NOW()
+ ORDER BY fetched_at DESC
+ LIMIT 1
+ """
+ ),
+ {"ck": cache_key},
+ )
+ .mappings()
+ .first()
+ )
+ except Exception as e:
+ logger.warning("yandex_valuation: cache lookup failed: %s", e)
+ cached = None
+
+ if cached is not None and cached.get("raw_payload"):
+ try:
+ payload_dict = (
+ cached["raw_payload"]
+ if isinstance(cached["raw_payload"], dict)
+ else json.loads(cached["raw_payload"])
+ )
+ logger.info(
+ "yandex_valuation: cache HIT key=%s items=%d",
+ cache_key[:8],
+ len(payload_dict.get("history_items", [])),
+ )
+ return YandexValuationResult.model_validate(payload_dict)
+ except Exception as e:
+ logger.warning("yandex_valuation: cache deserialize failed — refetching: %s", e)
+
+ # Fresh fetch
+ try:
+ async with YandexValuationScraper() as scraper:
+ result = await scraper.fetch_house_history(
+ address=address,
+ offer_category=offer_category,
+ offer_type=offer_type,
+ )
+ except Exception as e:
+ logger.warning("yandex_valuation: fetch failed — estimator продолжает без Yandex: %s", e)
+ return None
+
+ if result is None:
+ logger.info("yandex_valuation: empty result for address=%s", address[:60])
+ return None
+
+ # Save to cache (UPSERT on (source, cache_key))
+ try:
+ db.execute(
+ text(
+ """
+ INSERT INTO external_valuations (
+ source, cache_key, address,
+ raw_payload,
+ fetched_at, expires_at
+ ) VALUES (
+ 'yandex_valuation', :ck, :addr,
+ CAST(:payload AS jsonb),
+ NOW(), NOW() + (:ttl_hours || ' hours')::interval
+ )
+ ON CONFLICT (source, cache_key) DO UPDATE
+ SET raw_payload = EXCLUDED.raw_payload,
+ fetched_at = NOW(),
+ expires_at = NOW() + (:ttl_hours || ' hours')::interval
+ """
+ ),
+ {
+ "ck": cache_key,
+ "addr": address,
+ "payload": json.dumps(result.model_dump(mode="json"), ensure_ascii=False),
+ "ttl_hours": YANDEX_VALUATION_CACHE_TTL_HOURS,
+ },
+ )
+ db.commit()
+ logger.info(
+ "yandex_valuation: fresh fetch saved key=%s items=%d",
+ cache_key[:8],
+ len(result.history_items),
+ )
+ except Exception as e:
+ logger.warning("yandex_valuation: cache save failed (continuing): %s", e)
+ db.rollback()
+
+ return result
+
+
+def _save_yandex_history_items(
+ db: Session,
+ result: YandexValuationResult,
+) -> int:
+ """Persist history items to house_placement_history. Returns saved count.
+
+ Resolves house_id ONCE per result via match_or_create_house() using the
+ valuation page's address + meta (year_built/total_floors). All items from
+ the same page share that house_id.
+
+ Confidence pipeline:
+ method_confidence <- match_or_create_house (1.0 cadastr/source, 0.9 fp, 0.7 geo, 1.0 new)
+ final_confidence = method_confidence
+
+ Idempotent via UNIQUE (source, ext_item_id); ext_item_id synthesized from
+ (address|publish_date|area|floor|prices) hash.
+
+ Batch semantics: single try/except; on any failure the batch rolls back.
+ """
+ if not result.history_items:
+ return 0
+
+ # Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
+ # — stable across re-runs, distinguishes pages for different addresses.
+ address_seed = (result.address or "").strip().lower()
+ house_ext_id = (
+ hashlib.sha256(address_seed.encode("utf-8")).hexdigest()[:16] if address_seed else "unknown"
+ )
+
+ try:
+ house_id, method_confidence, method = match_or_create_house(
+ db,
+ ext_source="yandex_valuation",
+ ext_id=house_ext_id,
+ address=result.address,
+ year_built=result.house.year_built,
+ )
+ except Exception as e:
+ logger.warning(
+ "yandex_valuation: house resolution failed for address=%r: %s"
+ " — saving with house_id=NULL",
+ result.address,
+ e,
+ )
+ db.rollback()
+ house_id = None
+ method_confidence = 0.0
+ method = "fail"
+
+ logger.info(
+ "yandex_valuation: house resolved house_id=%s method=%s confidence=%.2f addr=%r",
+ house_id,
+ method,
+ method_confidence,
+ result.address,
+ )
+
+ rows = []
+ for item in result.history_items:
+ ext_seed = (
+ f"{result.address}|{item.publish_date}|{item.area_m2}|{item.floor}|"
+ f"{item.start_price}|{item.last_price}"
+ )
+ ext_item_id = hashlib.sha256(ext_seed.encode("utf-8")).hexdigest()[:32]
+ rows.append(
+ {
+ "ext_id": ext_item_id,
+ "house_id": house_id,
+ "rooms": item.rooms,
+ "area": item.area_m2,
+ "floor": item.floor,
+ "total_floors": result.house.total_floors,
+ "start_price": item.start_price,
+ "last_price": item.last_price,
+ "publish_date": item.publish_date,
+ "removed_date": item.removed_date,
+ "exposure": item.exposure_days,
+ "confidence": float(method_confidence),
+ "notes": f"match_method={method}" if method != "fail" else None,
+ "raw": json.dumps(item.model_dump(mode="json"), ensure_ascii=False),
+ }
+ )
+
+ sql = text(
+ """
+ INSERT INTO house_placement_history (
+ source, ext_item_id, house_id,
+ rooms, area_m2, floor, total_floors,
+ start_price, start_price_date,
+ last_price, last_price_date,
+ removed_date,
+ exposure_days,
+ source_confidence, notes,
+ raw_payload
+ ) VALUES (
+ 'yandex_valuation', :ext_id, :house_id,
+ :rooms, :area, :floor, :total_floors,
+ :start_price, :publish_date,
+ :last_price, :publish_date,
+ :removed_date,
+ :exposure,
+ :confidence, :notes,
+ CAST(:raw AS jsonb)
+ )
+ ON CONFLICT (source, ext_item_id) DO NOTHING
+ """
+ )
+
+ try:
+ for row in rows:
+ db.execute(sql, row)
+ db.commit()
+ return len(rows)
+ except Exception as e:
+ logger.warning(
+ "yandex_valuation: failed to save history batch (%d items): %s",
+ len(rows),
+ e,
+ )
+ db.rollback()
+ return 0
+
+
+# ── Public ───────────────────────────────────────────────────────────────────
+async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> AggregatedEstimate:
+ """Главная функция — оценка квартиры по реальным данным.
+
+ PR M / #564 Phase 3: rosreestr_deals **included** в actual_deals output.
+ Stale NOTE 2026-05-24 (про ДДУ contamination) устарел — importer
+ `import-rosreestr.sh` после PR-A 2026-05-24 фильтрует doc_type='ДКП',
+ ДДУ первички исключены. Deals идут в `actual_deals` JSONB поле
+ AggregatedEstimate с tier classification (T0_per_house / T1_per_street)
+ — frontend может разделять confidence в UI.
+
+ Returns:
+ AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами.
+ """
+ # 1. Geocode
+ geo: GeocodeResult | None = None
+ if payload.address:
+ geo = await geocode(payload.address, db)
+
+ if geo is None:
+ # Без координат не можем искать через PostGIS. Возвращаем low confidence.
+ logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address)
+ return _empty_estimate(payload, db, reason="address_not_geocoded")
+
+ # 1b. DaData enrichment (PR Q1) — on-demand cleanup для target адреса.
+ # Best-effort: graceful None при отсутствии credentials / quota / fail.
+ # Дополняет geocode результатом kadastr_num + canonical form + nearest metro.
+ dadata: DadataAddressResult | None = None
+ try:
+ dadata = await dadata_clean_address(payload.address)
+ except Exception as exc: # pragma: no cover — defensive
+ logger.warning("dadata: unexpected error (graceful): %s", exc)
+
+ # 2. #392: обогащаем год / тип дома из картографии (OSM Overpass), если
+ # пользователь их не указал — это улучшает house-match аналогов (#6).
+ # Best-effort: при недоступности OSM target_* остаются None.
+ target_year = payload.year_built
+ target_house_type = payload.house_type
+ if target_year is None or target_house_type is None:
+ house_meta = await get_house_metadata(geo.lat, geo.lon, db)
+ if house_meta is not None:
+ if target_year is None:
+ target_year = house_meta.year_built
+ if target_house_type is None:
+ target_house_type = house_meta.house_type
+
+ # 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter):
+ # 0) 1km + ±15% area + cohort match (year_built — если задан)
+ # a) 1km + ±15% area (без cohort — drop fallback)
+ # b) 2km + ±15% area (fallback_used = True)
+ # c) 2km + ±25% area (fallback_used = True, area_widened = True)
+ cohort_range = _target_cohort_range(target_year)
+
+ if cohort_range is not None:
+ cy_min, cy_max = cohort_range
+ listings_tier0, _, analog_tier = _fetch_analogs(
+ db,
+ lat=geo.lat,
+ lon=geo.lon,
+ rooms=payload.rooms,
+ area=payload.area_m2,
+ radius_m=DEFAULT_RADIUS_M,
+ full_address=geo.full_address,
+ year_built=target_year,
+ house_type=target_house_type,
+ total_floors=payload.total_floors,
+ cohort_year_min=cy_min,
+ cohort_year_max=cy_max,
+ )
+ else:
+ listings_tier0 = []
+ analog_tier = "W"
+
+ if len(listings_tier0) >= MIN_ANALOGS_TIER_0:
+ listings = listings_tier0
+ fallback_used = False
+ else:
+ # Tier 0 пуст/мал — graceful fallback на Tier A без cohort
+ listings, fallback_used, analog_tier = _fetch_analogs(
+ db,
+ lat=geo.lat,
+ lon=geo.lon,
+ rooms=payload.rooms,
+ area=payload.area_m2,
+ radius_m=DEFAULT_RADIUS_M,
+ full_address=geo.full_address,
+ year_built=target_year,
+ house_type=target_house_type,
+ total_floors=payload.total_floors,
+ )
+ area_widened = False
+
+ if len(listings) < 5:
+ listings_wide, _, analog_tier_wide = _fetch_analogs(
+ db,
+ lat=geo.lat,
+ lon=geo.lon,
+ rooms=payload.rooms,
+ area=payload.area_m2,
+ radius_m=FALLBACK_RADIUS_M,
+ full_address=geo.full_address,
+ year_built=target_year,
+ house_type=target_house_type,
+ total_floors=payload.total_floors,
+ )
+ if len(listings_wide) > len(listings):
+ listings = listings_wide
+ fallback_used = True
+ analog_tier = analog_tier_wide
+
+ # Tier C: если даже на 2км мало — расширяем area tolerance до ±25%
+ # (актуально для отдалённых районов / новостроек с нестандартной планировкой)
+ if len(listings) < 3:
+ listings_widearea, _, analog_tier_wa = _fetch_analogs(
+ db,
+ lat=geo.lat,
+ lon=geo.lon,
+ rooms=payload.rooms,
+ area=payload.area_m2,
+ radius_m=FALLBACK_RADIUS_M,
+ area_tolerance=0.25,
+ full_address=geo.full_address,
+ year_built=target_year,
+ house_type=target_house_type,
+ total_floors=payload.total_floors,
+ )
+ if len(listings_widearea) > len(listings):
+ listings = listings_widearea
+ fallback_used = True
+ area_widened = True
+ analog_tier = analog_tier_wa
+
+ # 3. Outlier filter
+ listings_clean = _filter_outliers(listings)
+
+ # 4. Aggregation
+ if listings_clean:
+ prices_ppm2 = sorted(lot["price_per_m2"] for lot in listings_clean if lot["price_per_m2"])
+ median_ppm2 = _percentile(prices_ppm2, 0.5)
+ q1_ppm2 = _percentile(prices_ppm2, 0.25)
+ q3_ppm2 = _percentile(prices_ppm2, 0.75)
+ median_price = int(median_ppm2 * payload.area_m2)
+ range_low = int(q1_ppm2 * payload.area_m2)
+ range_high = int(q3_ppm2 * payload.area_m2)
+ n_analogs = len(listings_clean)
+ else:
+ median_ppm2 = 0
+ median_price = 0
+ range_low = 0
+ range_high = 0
+ n_analogs = 0
+
+ # 4b. Поправка на состояние ремонта (встреча Птицы: ремонт влияет на цену).
+ # Аналоги — микс состояний; коэффициент сдвигает оценку под ремонт клиента.
+ repair_coef = _repair_coefficient(payload.repair_state)
+ repair_note = ""
+ if listings_clean and repair_coef != 1.0:
+ median_price = int(median_price * repair_coef)
+ range_low = int(range_low * repair_coef)
+ range_high = int(range_high * repair_coef)
+ median_ppm2 = median_ppm2 * repair_coef
+ pct = round((repair_coef - 1.0) * 100)
+ repair_note = (
+ f" Цена скорректирована на состояние ремонта "
+ f"({_REPAIR_LABEL.get(payload.repair_state, '')} {pct:+d}%)."
+ )
+
+ confidence, explanation = _compute_confidence(
+ n_analogs,
+ median_ppm2,
+ q1_ppm2 if listings_clean else 0,
+ q3_ppm2 if listings_clean else 0,
+ fallback_used,
+ area_widened,
+ listings=listings_clean,
+ )
+
+ # Tier note — информируем пользователя о качестве house-match
+ tier_note = ""
+ if analog_tier == "S":
+ tier_note = " (аналоги из того же дома)"
+ elif analog_tier == "H":
+ tf_str = f"{payload.total_floors}-эт." if payload.total_floors is not None else ""
+ yr_str = f"{target_year}±15 г." if target_year else ""
+ parts_str = ", ".join(p for p in [yr_str, tf_str] if p)
+ tier_note = f" (аналоги из домов того же класса: {parts_str})" if parts_str else ""
+ else:
+ tier_note = " (нет аналогов в том же доме/классе — расширили поиск)"
+
+ explanation = (explanation or "") + tier_note + repair_note
+
+ # ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ──
+ imv_eval: IMVEvaluation | None = None
+ imv_house_type = _IMV_HOUSE_TYPE_MAP.get(target_house_type)
+ imv_renovation = _IMV_REPAIR_MAP.get(payload.repair_state)
+ # IMV требует: address, rooms, area, floor, floor_at_home, house_type, renovation_type.
+ # Если payload не содержит required fields — skip IMV (graceful).
+ if (
+ geo is not None
+ and geo.full_address
+ and payload.rooms is not None
+ and payload.area_m2
+ and payload.floor is not None
+ and payload.total_floors is not None
+ and imv_house_type is not None
+ and imv_renovation is not None
+ ):
+ imv_eval = await _get_or_fetch_imv_cached(
+ db,
+ address=geo.full_address,
+ rooms=payload.rooms,
+ area_m2=payload.area_m2,
+ floor=payload.floor,
+ floor_at_home=payload.total_floors,
+ house_type=imv_house_type,
+ renovation_type=imv_renovation,
+ has_balcony=bool(payload.has_balcony),
+ has_loggia=False, # payload не разделяет балкон/лоджия → дефолт False
+ )
+
+ # Include IMV в sources_used если получили
+ sources_used_pre = sorted({lot.get("source") for lot in listings_clean if lot.get("source")})
+ if imv_eval is not None:
+ sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
+
+ # ── Stage 8: Yandex Valuation as on-demand source (anonymous, cached 24h) ──
+ yandex_val: YandexValuationResult | None = None
+ if geo is not None and geo.full_address:
+ yandex_val = await _get_or_fetch_yandex_valuation_cached(
+ db,
+ address=geo.full_address,
+ )
+ if yandex_val is not None:
+ sources_used_pre = sorted(set(sources_used_pre) | {"yandex_valuation"})
+ saved_hist = _save_yandex_history_items(db, yandex_val)
+ logger.info(
+ "yandex_valuation: history items processed=%d saved=%d"
+ " (house_id=NULL — matching deferred)",
+ len(yandex_val.history_items),
+ saved_hist,
+ )
+
+ # ── Stage 9: Cian Valuation as 7th source (on-demand, 24h cached, graceful if no cookies) ──
+ cian_val: CianValuationResult | None = None
+ if (
+ geo is not None
+ and geo.full_address
+ and payload.rooms is not None
+ and payload.area_m2
+ and payload.floor is not None
+ and payload.total_floors is not None
+ ):
+ try:
+ cian_val = await estimate_via_cian_valuation(
+ db,
+ address=geo.full_address,
+ total_area=payload.area_m2,
+ rooms_count=payload.rooms,
+ floor=payload.floor,
+ total_floors=payload.total_floors,
+ repair_type="cosmetic",
+ deal_type="sale",
+ use_cache=True,
+ )
+ if cian_val is not None and cian_val.sale_price_rub:
+ sources_used_pre = sorted(set(sources_used_pre) | {"cian_valuation"})
+ logger.info(
+ "cian_valuation: price=%s accuracy=%s house_id=%s",
+ cian_val.sale_price_rub,
+ cian_val.sale_accuracy,
+ cian_val.external_house_id,
+ )
+ except Exception as exc:
+ logger.warning("cian_valuation: lookup failed (graceful): %s", exc)
+
+ # 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
+ # Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
+ # исключены — больше не скёюят median вторички ~110-120 К/м².
+ deals = _fetch_deals(
+ db,
+ lat=geo.lat,
+ lon=geo.lon,
+ rooms=payload.rooms,
+ area=payload.area_m2,
+ radius_m=DEFAULT_RADIUS_M,
+ )
+
+ # 6. Сохраняем в trade_in_estimates
+ estimate_id = uuid4()
+ now = datetime.now(tz=UTC)
+ expires_at = now + timedelta(hours=24)
+
+ analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]]
+ deals_lots = [_deal_to_analog(d) for d in deals[:10]]
+ freshness_pre = _compute_freshness_minutes(listings_clean)
+ # DaData enrichment (PR Q1) — заполняется только если service отработал.
+ # При DaData = None все колонки идут в DB как NULL (graceful).
+ dadata_metro_json = (
+ json.dumps(dadata.metro, ensure_ascii=False)
+ if dadata is not None and dadata.metro
+ else None
+ )
+ db.execute(
+ text(
+ """
+ INSERT INTO trade_in_estimates (
+ id, address, lat, lon,
+ area_m2, rooms, floor, total_floors,
+ year_built, house_type, repair_state, has_balcony,
+ ownership_type, has_mortgage, client_name, client_phone,
+ median_price, range_low, range_high, median_price_per_m2,
+ confidence, confidence_explanation, n_analogs,
+ analogs, actual_deals,
+ sources_used, data_freshness_minutes,
+ canonical_address, house_cadnum, house_fias_id,
+ dadata_qc_geo, dadata_qc_house, dadata_metro,
+ expires_at
+ ) VALUES (
+ CAST(:id AS uuid),
+ :address, :lat, :lon,
+ :area, :rooms, :floor, :total_floors,
+ :year_built, :house_type, :repair_state, :has_balcony,
+ :ownership_type, :has_mortgage, :client_name, :client_phone,
+ :median_price, :range_low, :range_high, :median_ppm2,
+ :confidence, :explanation, :n_analogs,
+ CAST(:analogs_json AS jsonb),
+ CAST(:deals_json AS jsonb),
+ CAST(:sources_json AS jsonb),
+ :freshness,
+ :canonical_address, :house_cadnum, :house_fias_id,
+ :dadata_qc_geo, :dadata_qc_house,
+ CAST(:dadata_metro_json AS jsonb),
+ :expires_at
+ )
+ """
+ ),
+ {
+ "id": str(estimate_id),
+ "address": geo.full_address,
+ "lat": geo.lat,
+ "lon": geo.lon,
+ "area": payload.area_m2,
+ "rooms": payload.rooms,
+ "floor": payload.floor,
+ "total_floors": payload.total_floors,
+ "year_built": target_year,
+ "house_type": target_house_type,
+ "repair_state": payload.repair_state,
+ "has_balcony": payload.has_balcony,
+ "ownership_type": payload.ownership_type,
+ "has_mortgage": payload.has_mortgage,
+ "client_name": payload.client_name,
+ "client_phone": payload.client_phone,
+ "median_price": median_price,
+ "range_low": range_low,
+ "range_high": range_high,
+ "median_ppm2": int(median_ppm2),
+ "confidence": confidence,
+ "explanation": explanation,
+ "n_analogs": n_analogs,
+ "analogs_json": json.dumps(
+ [a.model_dump(mode="json") for a in analogs_lots], ensure_ascii=False
+ ),
+ "deals_json": json.dumps(
+ [a.model_dump(mode="json") for a in deals_lots], ensure_ascii=False
+ ),
+ "sources_json": json.dumps(sources_used_pre, ensure_ascii=False),
+ "freshness": freshness_pre,
+ "canonical_address": dadata.canonical_address if dadata else None,
+ "house_cadnum": dadata.house_cadnum if dadata else None,
+ "house_fias_id": dadata.house_fias_id if dadata else None,
+ "dadata_qc_geo": dadata.qc_geo if dadata else None,
+ "dadata_qc_house": dadata.qc_house if dadata else None,
+ "dadata_metro_json": dadata_metro_json,
+ "expires_at": expires_at,
+ },
+ )
+
+ # Link saved IMV evaluation к этому estimate_id атомарно с основным INSERT
+ # (closes finding #4 from 2026-05-24 audit — prior code committed estimate first,
+ # then UPDATEd IMV in a separate tx, racing against concurrent estimators
+ # sharing the same cache_key).
+ if imv_eval is not None:
+ db.execute(
+ text(
+ """
+ UPDATE avito_imv_evaluations
+ SET estimate_id = CAST(:estimate_id AS uuid)
+ WHERE cache_key = :cache_key
+ AND (estimate_id IS NULL OR estimate_id = CAST(:estimate_id AS uuid))
+ """
+ ),
+ {"estimate_id": str(estimate_id), "cache_key": imv_eval.cache_key},
+ )
+
+ db.commit()
+
+ logger.info(
+ "estimate: id=%s addr=%s rooms=%d area=%.1f → median=%d (n=%d, conf=%s)%s%s",
+ estimate_id,
+ geo.full_address[:60],
+ payload.rooms,
+ payload.area_m2,
+ median_price,
+ n_analogs,
+ confidence,
+ f" imv={imv_eval.recommended_price}" if imv_eval else "",
+ f" cian={cian_val.sale_price_rub}" if cian_val and cian_val.sale_price_rub else "",
+ )
+
+ sources_used = sorted({lot.source for lot in analogs_lots if lot.source})
+ if imv_eval is not None:
+ sources_used = sorted(set(sources_used) | {"avito_imv"})
+ if yandex_val is not None:
+ sources_used = sorted(set(sources_used) | {"yandex_valuation"})
+ if cian_val is not None and cian_val.sale_price_rub:
+ sources_used = sorted(set(sources_used) | {"cian_valuation"})
+ freshness_min = _compute_freshness_minutes(listings_clean)
+
+ return AggregatedEstimate(
+ estimate_id=estimate_id,
+ median_price_rub=median_price,
+ range_low_rub=range_low,
+ range_high_rub=range_high,
+ median_price_per_m2=int(median_ppm2),
+ confidence=confidence,
+ confidence_explanation=explanation,
+ n_analogs=n_analogs,
+ period_months=DEALS_PERIOD_MONTHS,
+ analogs=analogs_lots,
+ actual_deals=deals_lots,
+ expires_at=expires_at,
+ target_address=geo.full_address,
+ target_lat=geo.lat,
+ target_lon=geo.lon,
+ sources_used=sources_used,
+ data_freshness_minutes=freshness_min,
+ est_days_on_market=_estimate_days_on_market(listings_clean, deals),
+ cian_valuation=(
+ CianValuationSummary(
+ sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None,
+ rent_price_rub=int(cian_val.rent_price_rub) if cian_val.rent_price_rub else None,
+ chart=[
+ {
+ "date": p.get("month_date") or p.get("date") or "",
+ "price": p["price"],
+ }
+ for p in (cian_val.chart or [])
+ if p.get("price") is not None
+ ],
+ chart_change_pct=cian_val.chart_change_pct,
+ chart_change_direction=(
+ cian_val.chart_change_direction
+ if cian_val.chart_change_direction in {"increase", "decrease", "neutral"}
+ else None
+ ),
+ )
+ if cian_val is not None
+ else None
+ ),
+ area_m2=payload.area_m2,
+ rooms=payload.rooms,
+ floor=payload.floor,
+ total_floors=payload.total_floors,
+ year_built=target_year,
+ house_type=target_house_type,
+ repair_state=payload.repair_state,
+ has_balcony=payload.has_balcony,
+ canonical_address=dadata.canonical_address if dadata else None,
+ house_cadnum=dadata.house_cadnum if dadata else None,
+ house_fias_id=dadata.house_fias_id if dadata else None,
+ metro_nearest=(dadata.metro if dadata and dadata.metro else []),
+ )
+
+
+def _estimate_days_on_market(
+ listings: list[dict[str, Any]], deals: list[dict[str, Any]]
+) -> int | None:
+ """Прогноз срока продажи — медиана days_on_market по аналогам/сделкам.
+
+ Возвращает None если ни у одного аналога нет данных о сроке экспозиции
+ (наши парсеры не всегда его отдают — честно показываем «нет данных»).
+ """
+ values = [
+ int(lot["days_on_market"])
+ for lot in (*listings, *deals)
+ if lot.get("days_on_market") and int(lot["days_on_market"]) > 0
+ ]
+ if len(values) < 3:
+ return None
+ values.sort()
+ return values[len(values) // 2]
+
+
+def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:
+ """Минут с последнего парсинга — для UI «обновлено N мин назад»."""
+ if not lots:
+ return None
+ from datetime import datetime as _dt
+
+ now = _dt.now(tz=UTC)
+ scraped = [lot.get("scraped_at") or lot.get("listing_date") for lot in lots]
+ scraped_dt: list[datetime] = []
+ for s in scraped:
+ if s is None:
+ continue
+ # listings rows из mappings — scraped_at это datetime, не date
+ if hasattr(s, "tzinfo"):
+ scraped_dt.append(s if s.tzinfo else s.replace(tzinfo=UTC))
+ if not scraped_dt:
+ return None
+ return int((now - max(scraped_dt)).total_seconds() / 60)
+
+
+# ── Internals ────────────────────────────────────────────────────────────────
+
+# Compiled regexes for _extract_short_addr — module-level for performance.
+
+# Strips leading admin prefixes: «Россия», «Свердловская область», «г. Екатеринбург» etc.
+_ADMIN_PREFIX_RE = re.compile(
+ r"^(?:"
+ r"\s*(?:Россия|РФ|Российская\s+Федерация)\s*,?\s*|"
+ r"\s*[А-Яа-яёЁ][А-Яа-яёЁ\s-]+(?:\s+(?:обл(?:асть)?|р-н|район|округ|край|республика))\.?\s*,?\s*|"
+ r"\s*(?:г(?:ород)?|гор)\.?\s*[А-Яа-яёЁ][А-Яа-яёЁ\s-]+\s*,?\s*|"
+ r"\s*[А-Я][а-яё-]+(?:\s+[А-Я][а-яё-]+)?\s*,?\s*"
+ r")+",
+ flags=re.UNICODE,
+)
+
+# Recognizes start of a street keyword.
+_STREET_START_RE = re.compile(
+ r"(?:ул\.|улица|пр\.|пр-т|проспект|пер\.|переулок|"
+ r"б-р|бульвар|ш\.|шоссе|наб\.|набережная|проезд|тракт|пл\.|площадь|"
+ r"мкр\.?|микрорайон)\s+",
+ flags=re.IGNORECASE | re.UNICODE,
+)
+
+# Drops trailing apartment / office / corpus noise from the end.
+_TRAILING_NOISE_RE = re.compile(
+ r"\s*,\s*(?:кв\.?\s*\d+|корп\.?\s*\w+|оф\.?\s*\d+|пом\.?\s*\d+|подъезд\s*\d+).*$",
+ flags=re.IGNORECASE | re.UNICODE,
+)
+
+
+def _extract_short_addr(full_address: str | None) -> str | None:
+ """Извлекает «улица + номер дома» из полного адреса для поиска в том же доме.
+
+ Примеры:
+ "Свердловская область, г. Екатеринбург, ул. Заводская, д. 44-а" → "ул. Заводская, д. 44-а"
+ "Россия, Екатеринбург, ул. Малышева, 1" → "ул. Малышева, 1"
+ "РФ, Свердловская обл., Екатеринбург, ул. Ленина, 5, кв. 12" → "ул. Ленина, 5"
+ "г. Екатеринбург, проспект Ленина, 50" → "проспект Ленина, 50"
+ "Екатеринбург, ул. Крауля, 48/2" → "ул. Крауля, 48/2"
+
+ Алгоритм:
+ 1. Отрезаем trailing кв./корп./оф. noise.
+ 2. Ищем первый street-keyword токен (ул./пр./пер. и т.д.) — возвращаем с него.
+ 3. Fallback: агрессивно strip admin-prefix regex, вернуть остаток.
+ 4. None если строка пустая или нечего возвращать.
+ """
+ if not full_address:
+ return None
+ s = full_address.strip()
+ s = _TRAILING_NOISE_RE.sub("", s)
+
+ # Find first street-keyword position and return from there.
+ m = _STREET_START_RE.search(s)
+ if m:
+ return s[m.start() :].strip(" ,.")
+
+ # Fallback: strip known admin prefixes, return whatever remains.
+ s = _ADMIN_PREFIX_RE.sub("", s)
+ return s.strip(" ,.") or None
+
+
+# Ищет keyword типа улицы (ул./улица/пр./проспект/...) в адресе.
+# Работает для FORWARD и REVERSE форматов Nominatim.
+_STREET_KW_RE = re.compile(
+ r"(? str | None:
+ """Извлекает чистое имя улицы из адреса в FORWARD или REVERSE формате.
+
+ Примеры:
+ "Екатеринбург, ул. Космонавтов, 50" → "Космонавтов"
+ "80, улица 8 Марта, Артек, ..., Россия" → "8 Марта"
+ "проспект Ленина 50" → "Ленина"
+ "Россия, Екатеринбург, ул. Малышева, 1" → "Малышева"
+ "ул. Большая Конюшенная, 25" → "Большая Конюшенная"
+ "" → None
+
+ Алгоритм:
+ 1. Ищем street-keyword (ул/улица/пр/проспект/...) — case-insensitive.
+ 2. После keyword берём 1-3 слова до запятой или номера дома.
+ 3. Если keyword не нашёлся — пытаемся первый capitalized токен с
+ поиском до запятой или номера (fallback для адресов без keyword'а).
+
+ Returns None если ничего не извлеклось.
+ """
+ if not full_address or not full_address.strip():
+ return None
+
+ s = full_address.strip()
+
+ # 1. Keyword-based extraction (работает для обоих форматов: forward и reverse)
+ m = _STREET_KW_RE.search(s)
+ if m:
+ rest = s[m.end():].lstrip()
+ nm = _STREET_NAME_RE.match(rest)
+ if nm:
+ return nm.group(1).strip()
+
+ # 2. Fallback: нет keyword — пробуем первый capitalized токен
+ # Используется для "Большая Конюшенная, 25" без "ул."
+ nm = _STREET_NAME_RE.match(s)
+ if nm:
+ candidate = nm.group(1).strip()
+ # Отсеиваем очевидные административные слова
+ bad = {"Россия", "Москва", "Санкт-Петербург", "область", "район", "округ", "край"}
+ if not any(b in candidate for b in bad):
+ return candidate
+
+ return None
+
+
+def _stratify_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Стратифицированная выборка Approach B — гарантирует MIN_ANALOGS_PER_SOURCE слотов.
+
+ Candidates должны быть уже отсортированы по relevance_score (ASC).
+ """
+ guaranteed: list[dict[str, Any]] = []
+ guaranteed_ids: set[int] = set()
+ by_source: dict[str, list[dict[str, Any]]] = {}
+ for row in candidates:
+ src = row.get("source") or "unknown"
+ by_source.setdefault(src, []).append(row)
+
+ for _src, src_rows in by_source.items():
+ quota = min(len(src_rows), MIN_ANALOGS_PER_SOURCE)
+ for row in src_rows[:quota]:
+ if id(row) not in guaranteed_ids:
+ guaranteed.append(row)
+ guaranteed_ids.add(id(row))
+
+ remaining_slots = 50 - len(guaranteed)
+ remainder: list[dict[str, Any]] = []
+ if remaining_slots > 0:
+ for row in candidates:
+ if id(row) not in guaranteed_ids:
+ remainder.append(row)
+ if len(remainder) >= remaining_slots:
+ break
+
+ result = guaranteed + remainder
+ result.sort(key=lambda r: r.get("relevance_score") or 0.0)
+ return result[:50]
+
+
+_ANALOG_SELECT_COLS = """
+ source, source_url, address, lat, lon,
+ rooms, area_m2, floor, total_floors,
+ price_rub, price_per_m2,
+ listing_date, days_on_market, photo_urls,
+ scraped_at
+"""
+
+_COMMON_WHERE = """
+ AND rooms = :rooms
+ AND area_m2 BETWEEN :area_min AND :area_max
+ AND is_active = true
+ AND scraped_at > NOW() - (:fresh_days || ' days')::interval
+ AND price_rub > 0
+ -- Когортный фильтр (PR 10): применяется к Tier S и Tier H через _COMMON_WHERE.
+ -- Tier W имеет свою копию этого блока в inline SQL. Если cohort_year_min IS NULL —
+ -- фильтр прозрачен. CAST обязателен — psycopg3 prepared statement не выводит
+ -- тип $N при IS NULL в predicate (см. PR #518 fix).
+ AND (
+ CAST(:cohort_year_min AS integer) IS NULL
+ OR year_built IS NULL
+ OR year_built BETWEEN CAST(:cohort_year_min AS integer)
+ AND CAST(:cohort_year_max AS integer)
+ )
+"""
+# Note: Tier W has its own inline copy of the cohort clause (PR #519 line
+# ~1280). Не удалять — Tier W не использует _COMMON_WHERE из-за inline
+# relevance_score CASE expressions. Both code paths must stay in sync.
+
+
+def _fetch_analogs(
+ db: Session,
+ *,
+ lat: float,
+ lon: float,
+ rooms: int,
+ area: float,
+ radius_m: int,
+ full_address: str | None = None,
+ area_tolerance: float = AREA_TOLERANCE,
+ year_built: int | None = None,
+ house_type: str | None = None,
+ total_floors: int | None = None,
+ cohort_year_min: int | None = None, # NEW: lower bound year_built inclusive
+ cohort_year_max: int | None = None, # NEW: upper bound year_built inclusive
+ # TODO: когда listings получит колонку house_id_fk — добавить ext_house_id JOIN для Tier S.
+) -> tuple[list[dict[str, Any]], bool, str]:
+ """SELECT аналогов — трёхуровневый house-match (S → H → W).
+
+ **Tier S (same building):** address ILIKE prefix-match по short_addr.
+ Если ≥3 результатов → возвращаем только их; tier='S'.
+
+ **Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%.
+ Если ≥5 результатов → возвращаем; tier='H'.
+ Пропускается если year_built или total_floors неизвестны.
+
+ **Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
+ tier='W'.
+
+ House-match relevance_score используется для сортировки в Tier H и W.
+
+ Стратифицированная выборка (Approach B):
+ 1. SQL вытягивает до 300 кандидатов с per-address row_number (cap MAX_ANALOGS_PER_ADDRESS).
+ 2. Python гарантирует MIN_ANALOGS_PER_SOURCE слотов каждому live source.
+ 3. Оставшиеся слоты заполняются из остальных кандидатов по relevance.
+ 4. Итоговый список отсортирован по relevance, LIMIT 50.
+
+ Когортный фильтр (PR 9): если переданы cohort_year_min/max — добавляется
+ hard-filter WHERE year_built BETWEEN min AND max OR year_built IS NULL.
+ NULL допускается чтобы не отсеивать листинги с неизвестным годом
+ (типично для Avito anonymous-address объявлений).
+
+ Returns:
+ (list_of_listings_as_dicts, fallback_radius_used_flag, tier)
+ tier: 'S' | 'H' | 'W'
+ """
+ area_min = area * (1 - area_tolerance)
+ area_max = area * (1 + area_tolerance)
+ base_params: dict[str, Any] = {
+ "rooms": rooms,
+ "area_min": area_min,
+ "area_max": area_max,
+ "fresh_days": LISTINGS_FRESH_DAYS,
+ "max_per_addr": MAX_ANALOGS_PER_ADDRESS,
+ "cohort_year_min": cohort_year_min,
+ "cohort_year_max": cohort_year_max,
+ }
+
+ # ── Tier S: same building ─────────────────────────────────────────────────
+ short_addr = _extract_short_addr(full_address)
+
+ if short_addr:
+ tier_s_params = {
+ **base_params,
+ "short_addr_prefix": short_addr + "%",
+ }
+
+ tier_s_rows = (
+ db.execute(
+ text(
+ f"""
+ WITH base AS (
+ SELECT
+ {_ANALOG_SELECT_COLS},
+ 0.0 AS distance_m,
+ 0.0 AS relevance_score,
+ row_number() OVER (PARTITION BY address ORDER BY scraped_at DESC) AS rn_addr
+ FROM listings
+ WHERE address ILIKE :short_addr_prefix
+ {_COMMON_WHERE}
+ )
+ SELECT
+ source, source_url, address, lat, lon,
+ rooms, area_m2, floor, total_floors,
+ price_rub, price_per_m2,
+ listing_date, days_on_market, photo_urls,
+ scraped_at, distance_m, relevance_score
+ FROM base
+ WHERE rn_addr <= :max_per_addr
+ ORDER BY scraped_at DESC
+ LIMIT 300
+ """
+ ),
+ tier_s_params,
+ )
+ .mappings()
+ .all()
+ )
+
+ tier_s = [dict(r) for r in tier_s_rows]
+ if len(tier_s) >= 3:
+ logger.info(
+ "analogs tier=S addr_prefix=%r → %d results",
+ short_addr,
+ len(tier_s),
+ )
+ return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S"
+
+ # ── Tier H: same class (year ±15, total_floors ±30%) ─────────────────────
+ if year_built is not None and total_floors is not None:
+ year_min = year_built - 15
+ year_max = year_built + 15
+ tf_min = math.floor(total_floors * 0.7)
+ tf_max = math.ceil(total_floors * 1.3)
+
+ tier_h_rows = (
+ db.execute(
+ text(
+ f"""
+ WITH base AS (
+ SELECT
+ {_ANALOG_SELECT_COLS},
+ ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
+ AS distance_m,
+ (
+ ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
+ / 1000.0
+ + CASE
+ WHEN year_built IS NOT NULL
+ THEN abs(year_built - CAST(:target_year AS integer)) / 12.0
+ ELSE 0
+ END
+ + CASE
+ WHEN CAST(:target_house_type AS text) IS NOT NULL
+ AND house_type IS NOT NULL
+ AND house_type <> CAST(:target_house_type AS text)
+ THEN 1.5
+ ELSE 0
+ END
+ ) AS relevance_score,
+ row_number() OVER (
+ PARTITION BY address
+ ORDER BY (
+ ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
+ / 1000.0
+ + CASE
+ WHEN year_built IS NOT NULL
+ THEN abs(year_built - CAST(:target_year AS integer)) / 12.0
+ ELSE 0
+ END
+ + CASE
+ WHEN CAST(:target_house_type AS text) IS NOT NULL
+ AND house_type IS NOT NULL
+ AND house_type <> CAST(:target_house_type AS text)
+ THEN 1.5
+ ELSE 0
+ END
+ )
+ ) AS rn_addr
+ FROM listings
+ WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
+ {_COMMON_WHERE}
+ AND total_floors BETWEEN CAST(:tf_min AS integer)
+ AND CAST(:tf_max AS integer)
+ AND year_built BETWEEN CAST(:year_min AS integer)
+ AND CAST(:year_max AS integer)
+ )
+ SELECT
+ source, source_url, address, lat, lon,
+ rooms, area_m2, floor, total_floors,
+ price_rub, price_per_m2,
+ listing_date, days_on_market, photo_urls,
+ scraped_at, distance_m, relevance_score
+ FROM base
+ WHERE rn_addr <= :max_per_addr
+ ORDER BY relevance_score
+ LIMIT 300
+ """
+ ),
+ {
+ **base_params,
+ "lat": lat,
+ "lon": lon,
+ "radius": radius_m,
+ "target_year": year_built,
+ "target_house_type": house_type,
+ "tf_min": tf_min,
+ "tf_max": tf_max,
+ "year_min": year_min,
+ "year_max": year_max,
+ },
+ )
+ .mappings()
+ .all()
+ )
+
+ tier_h = [dict(r) for r in tier_h_rows]
+ if len(tier_h) >= 5:
+ logger.info(
+ "analogs tier=H year=%d±15 tf=%d-%d → %d results",
+ year_built,
+ tf_min,
+ tf_max,
+ len(tier_h),
+ )
+ return _stratify_candidates(tier_h), radius_m > DEFAULT_RADIUS_M, "H"
+
+ logger.info(
+ "analogs tier=H year=%d±15 tf=%d-%d → only %d (fallthrough to W)",
+ year_built,
+ tf_min,
+ tf_max,
+ len(tier_h),
+ )
+
+ # ── Tier W: wide (current logic, year/floors only in relevance sort) ──────
+ tier_w_rows = (
+ db.execute(
+ text(
+ """
+ WITH base AS (
+ SELECT
+ source, source_url, address, lat, lon,
+ rooms, area_m2, floor, total_floors,
+ price_rub, price_per_m2,
+ listing_date, days_on_market, photo_urls,
+ scraped_at,
+ ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
+ AS distance_m,
+ (
+ ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
+ / 1000.0
+ -- CAST обязателен: target_year / target_house_type приходят NULL
+ -- без типа → PostgreSQL "could not determine data type of parameter"
+ -- (AmbiguousParameter). Явный тип снимает неоднозначность.
+ + CASE
+ WHEN CAST(:target_year AS integer) IS NOT NULL
+ AND year_built IS NOT NULL
+ THEN abs(year_built - CAST(:target_year AS integer)) / 12.0
+ ELSE 0
+ END
+ + CASE
+ WHEN CAST(:target_house_type AS text) IS NOT NULL
+ AND house_type IS NOT NULL
+ AND house_type <> CAST(:target_house_type AS text)
+ THEN 1.5
+ ELSE 0
+ END
+ ) AS relevance_score,
+ row_number() OVER (
+ PARTITION BY address
+ ORDER BY (
+ ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
+ / 1000.0
+ + CASE
+ WHEN CAST(:target_year AS integer) IS NOT NULL
+ AND year_built IS NOT NULL
+ THEN abs(year_built - CAST(:target_year AS integer)) / 12.0
+ ELSE 0
+ END
+ + CASE
+ WHEN CAST(:target_house_type AS text) IS NOT NULL
+ AND house_type IS NOT NULL
+ AND house_type <> CAST(:target_house_type AS text)
+ THEN 1.5
+ ELSE 0
+ END
+ )
+ ) AS rn_addr
+ FROM listings
+ WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
+ AND rooms = :rooms
+ AND area_m2 BETWEEN :area_min AND :area_max
+ AND is_active = true
+ AND scraped_at > NOW() - (:fresh_days || ' days')::interval
+ AND price_rub > 0
+ -- Когортный фильтр (PR 9): отсеивает разные эпохи застройки
+ -- (хрущёвка vs новостройка). Если cohort_year_min IS NULL —
+ -- фильтр прозрачен. CAST обязателен — psycopg3 prepared statement
+ -- не выводит тип $N при IS NULL в predicate (см. PR #518 fix).
+ AND (
+ CAST(:cohort_year_min AS integer) IS NULL
+ OR year_built IS NULL
+ OR year_built BETWEEN CAST(:cohort_year_min AS integer)
+ AND CAST(:cohort_year_max AS integer)
+ )
+ -- 2026-05-23: Avito coords теперь real (PR #487 убрал jitter после
+ -- C-5 audit). Listings с NULL coords отфильтруются через ST_DWithin
+ -- (geom IS NULL → не matches). geocode-missing-listings backfill
+ -- подтягивает координаты для address-only Avito листингов.
+ )
+ SELECT
+ source, source_url, address, lat, lon,
+ rooms, area_m2, floor, total_floors,
+ price_rub, price_per_m2,
+ listing_date, days_on_market, photo_urls,
+ scraped_at,
+ distance_m,
+ relevance_score
+ FROM base
+ WHERE rn_addr <= :max_per_addr
+ ORDER BY relevance_score
+ LIMIT 300
+ """
+ ),
+ {
+ "lat": lat,
+ "lon": lon,
+ "radius": radius_m,
+ "rooms": rooms,
+ "area_min": area_min,
+ "area_max": area_max,
+ "fresh_days": LISTINGS_FRESH_DAYS,
+ "target_year": year_built,
+ "target_house_type": house_type,
+ "max_per_addr": MAX_ANALOGS_PER_ADDRESS,
+ "cohort_year_min": cohort_year_min, # NEW
+ "cohort_year_max": cohort_year_max, # NEW
+ },
+ )
+ .mappings()
+ .all()
+ )
+
+ candidates: list[dict[str, Any]] = [dict(r) for r in tier_w_rows]
+ logger.info("analogs tier=W radius=%dm → %d candidates", radius_m, len(candidates))
+ return _stratify_candidates(candidates), radius_m > DEFAULT_RADIUS_M, "W"
+
+
+def _fetch_deals(
+ db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int
+) -> list[dict[str, Any]]:
+ rows = (
+ db.execute(
+ text(
+ """
+ SELECT
+ source, address, lat, lon,
+ rooms, area_m2, floor, total_floors,
+ price_rub, price_per_m2,
+ deal_date, days_on_market,
+ kadastr_num,
+ ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
+ FROM deals
+ WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
+ AND rooms = :rooms
+ AND area_m2 BETWEEN :area_min AND :area_max
+ AND deal_date > NOW() - (:months || ' months')::interval
+ AND price_rub > 0
+ ORDER BY deal_date DESC
+ LIMIT 30
+ """
+ ),
+ {
+ "lat": lat,
+ "lon": lon,
+ "radius": radius_m,
+ "rooms": rooms,
+ "area_min": area * (1 - AREA_TOLERANCE),
+ "area_max": area * (1 + AREA_TOLERANCE),
+ "months": DEALS_PERIOD_MONTHS,
+ },
+ )
+ .mappings()
+ .all()
+ )
+
+ return [dict(r) for r in rows]
+
+
+def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Tukey IQR rule: исключаем точки вне [Q1 - 1.5×IQR, Q3 + 1.5×IQR]."""
+ if len(lots) < 5:
+ return lots # на маленькой выборке нечего фильтровать
+
+ prices = sorted(lot["price_per_m2"] for lot in lots if lot.get("price_per_m2"))
+ if len(prices) < 4:
+ return lots
+
+ q1 = _percentile(prices, 0.25)
+ q3 = _percentile(prices, 0.75)
+ iqr = q3 - q1
+ low = q1 - 1.5 * iqr
+ high = q3 + 1.5 * iqr
+
+ clean = [lot for lot in lots if low <= lot.get("price_per_m2", 0) <= high]
+ if len(clean) < len(lots):
+ logger.info("outlier filter: %d → %d (Q1=%d Q3=%d)", len(lots), len(clean), q1, q3)
+ return clean
+
+
+def _percentile(sorted_values: list[float], p: float) -> float:
+ """Linear interpolation percentile (не округляем — оставляем float)."""
+ if not sorted_values:
+ return 0.0
+ if len(sorted_values) == 1:
+ return float(sorted_values[0])
+ n = len(sorted_values)
+ rank = p * (n - 1)
+ lo = int(rank)
+ hi = min(lo + 1, n - 1)
+ frac = rank - lo
+ return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * frac
+
+
+def _compute_confidence(
+ n_analogs: int,
+ median_ppm2: float,
+ q1: float,
+ q3: float,
+ fallback_radius_used: bool,
+ area_widened: bool = False,
+ listings: list[dict] | None = None,
+) -> tuple[str, str]:
+ """Confidence + explanation string.
+
+ Уровень определяется по количеству уникальных адресов, а не по raw n_analogs.
+ Это защищает от overstated confidence когда много лотов из одного здания
+ (например, MIN_ANALOGS_PER_SOURCE=5 + same-building bias).
+
+ high — unique_addr ≥ 7 AND IQR/median < 0.15
+ medium — unique_addr ≥ 4 OR (unique_addr ≥ 2 AND IQR/median < 0.25)
+ low — иначе
+
+ Downgrade на один уровень если avg_lots_per_addr > 2.5 (concentration bias).
+ """
+ if median_ppm2 == 0:
+ return "low", "Не найдено аналогов — попробуйте уточнить адрес или расширить параметры."
+
+ # Вычисляем метрики уникальных адресов
+ if listings:
+ unique_addrs = {
+ (lot.get("address") or "").strip().lower() for lot in listings if lot.get("address")
+ }
+ unique_addr_count = len(unique_addrs)
+ avg_lots_per_addr = n_analogs / max(unique_addr_count, 1)
+ else:
+ unique_addr_count = n_analogs # fallback: считаем каждый лот уникальным
+ avg_lots_per_addr = 1.0
+
+ iqr = q3 - q1
+ iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0
+ notes = []
+ if fallback_radius_used:
+ notes.append("расширили радиус до 2 км")
+ if area_widened:
+ notes.append("расширили допуск по площади до ±25%")
+ fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else ""
+
+ # Базовый уровень по уникальным адресам
+ if unique_addr_count >= 7 and iqr_pct < 0.15:
+ base = "high"
+ elif unique_addr_count >= 4:
+ base = "medium"
+ elif unique_addr_count >= 2 and iqr_pct < 0.25:
+ base = "medium"
+ else:
+ base = "low"
+
+ # Downgrade на один шаг если слишком много лотов сконцентрировано на малом числе адресов
+ if avg_lots_per_addr > 2.5 and base != "low":
+ downgrade_map = {"high": "medium", "medium": "low"}
+ downgraded = downgrade_map[base]
+ explanation = (
+ f"Найдено {n_analogs} аналогов из {unique_addr_count} разных адресов, "
+ f"разброс цены ±{int(iqr_pct * 100 / 2)}% от медианы{fallback_note}. "
+ f"Снижена точность (≥2.5 лотов на адрес — возможен bias)."
+ )
+ return downgraded, explanation
+
+ explanation = (
+ f"Найдено {n_analogs} аналогов из {unique_addr_count} разных адресов, "
+ f"разброс цены ±{int(iqr_pct * 100 / 2)}% от медианы{fallback_note}."
+ )
+ return base, explanation
+
+
+def _listing_to_analog(row: dict[str, Any]) -> AnalogLot:
+ return AnalogLot(
+ address=row.get("address") or "",
+ area_m2=float(row.get("area_m2") or 0),
+ rooms=int(row.get("rooms") or 0),
+ floor=row.get("floor"),
+ total_floors=row.get("total_floors"),
+ price_rub=int(row["price_rub"]),
+ price_per_m2=int(row.get("price_per_m2") or 0),
+ listing_date=row.get("listing_date"),
+ days_on_market=row.get("days_on_market"),
+ photo_url=(row["photo_urls"] or [None])[0] if row.get("photo_urls") else None,
+ source=row.get("source"),
+ source_url=row.get("source_url"),
+ distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None,
+ )
+
+
+def _deal_to_analog(row: dict[str, Any]) -> AnalogLot:
+ """deals не имеют photo_url — упрощённо.
+
+ Tier classification (PR M / #564 Phase 3):
+ T0_per_house — kadastr_num exact match (НЕ доступно в open dataset Росреестра)
+ T1_per_street — street-level only (default для всех ДКП open dataset)
+ """
+ kad = row.get("kadastr_num")
+ # Per-house tier требует kadastr_num типа "66:41:0204016:10" (с участком).
+ # Street-only patterns: "66:41:0000000:0" или NULL → T1.
+ tier = "T0_per_house" if kad and not kad.endswith(":0") else "T1_per_street"
+ return AnalogLot(
+ address=row.get("address") or "",
+ area_m2=float(row.get("area_m2") or 0),
+ rooms=int(row.get("rooms") or 0),
+ floor=row.get("floor"),
+ total_floors=row.get("total_floors"),
+ price_rub=int(row["price_rub"]),
+ price_per_m2=int(row.get("price_per_m2") or 0),
+ listing_date=row.get("deal_date"),
+ days_on_market=row.get("days_on_market"),
+ photo_url=None,
+ source=row.get("source"),
+ source_url=None, # rosreestr сделки без публичной ссылки
+ distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None,
+ tier=tier,
+ )
+
+
+def _empty_estimate(
+ payload: TradeInEstimateInput, db: Session, *, reason: str
+) -> AggregatedEstimate:
+ """Fallback когда нет данных для оценки.
+
+ Сохраняет запись в БД (confidence='low', пустые analogs/deals), чтобы GET /estimate/{id}
+ не возвращал 404. C-4 security audit.
+ """
+ estimate_id = uuid4()
+ now = datetime.now(tz=UTC)
+ expires_at = now + timedelta(hours=24)
+
+ db.execute(
+ text(
+ """
+ INSERT INTO trade_in_estimates (
+ id, address,
+ area_m2, rooms, floor, total_floors,
+ year_built, house_type, repair_state, has_balcony,
+ ownership_type, has_mortgage, client_name, client_phone,
+ median_price, range_low, range_high, median_price_per_m2,
+ confidence, confidence_explanation, n_analogs,
+ analogs, actual_deals,
+ sources_used,
+ expires_at
+ ) VALUES (
+ CAST(:id AS uuid), :address,
+ :area, :rooms, :floor, :total_floors,
+ :year_built, :house_type, :repair_state, :has_balcony,
+ :ownership_type, :has_mortgage, :client_name, :client_phone,
+ 0, 0, 0, 0,
+ 'low', :explanation, 0,
+ '[]'::jsonb, '[]'::jsonb,
+ '[]'::jsonb,
+ :expires_at
+ )
+ """
+ ),
+ {
+ "id": str(estimate_id),
+ "address": payload.address,
+ "area": payload.area_m2,
+ "rooms": payload.rooms,
+ "floor": payload.floor,
+ "total_floors": payload.total_floors,
+ "year_built": payload.year_built,
+ "house_type": payload.house_type,
+ "repair_state": payload.repair_state,
+ "has_balcony": payload.has_balcony,
+ "ownership_type": payload.ownership_type,
+ "has_mortgage": payload.has_mortgage,
+ "client_name": payload.client_name,
+ "client_phone": payload.client_phone,
+ "explanation": reason,
+ "expires_at": expires_at,
+ },
+ )
+ db.commit()
+ logger.info(
+ "empty_estimate: id=%s reason=%s addr=%s", estimate_id, reason, payload.address[:60]
+ )
+
+ return AggregatedEstimate(
+ estimate_id=estimate_id,
+ median_price_rub=0,
+ range_low_rub=0,
+ range_high_rub=0,
+ median_price_per_m2=0,
+ confidence="low",
+ confidence_explanation=reason,
+ n_analogs=0,
+ period_months=DEALS_PERIOD_MONTHS,
+ analogs=[],
+ actual_deals=[],
+ expires_at=expires_at,
+ cian_valuation=None,
+ )
diff --git a/tradein-mvp/backend/app/services/exporters/__init__.py b/tradein-mvp/backend/app/services/exporters/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
new file mode 100644
index 00000000..6b3924fa
--- /dev/null
+++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
@@ -0,0 +1,1021 @@
+"""PDF генератор для Trade-In Estimator — Брусника-style (точное соответствие референсу docs/BRUSNIKA_REFERENCE_EKB-2485.pdf).
+
+Структура отчёта — 4 страницы:
+ 1. Cover — № отчёта + параметры объекта + 2 диапазона цен + 3 блока «Что важно»
+ 2. Listings — рынок квартир-аналогов по объявлениям + таблица примеров
+ 3. Deals — фактические сделки + таблица примеров
+ 4. Offer — формирование выкупной стоимости (Trade-In vs самопродажа) + 4 преимущества
+
+White-label через Brand: PRINZIP / Практика / generic (см. app/services/brand.py).
+Расширения над Брусникой (сохранены): source badges + дистанция + кликабельные ссылки + QR-код.
+"""
+
+from __future__ import annotations
+
+import base64
+import datetime as dt
+import html as _html
+import io
+import logging
+from urllib.parse import urlparse
+from uuid import UUID
+
+import segno
+from PIL import Image, ImageDraw
+from weasyprint import CSS, HTML
+
+from app.core.config import settings
+from app.schemas.trade_in import AggregatedEstimate, AnalogLot
+
+
+def _bar_svg_data_url(q_low: float = 0.20, q_high: float = 0.80) -> str:
+ """SVG диапазон-бара как data URI. WeasyPrint надёжно рендерит SVG (как QR-код)."""
+ width, height = 600, 32
+ x1 = width * q_low
+ x2 = width * q_high
+ svg = (
+ f''
+ )
+ return f"data:image/svg+xml;base64,{base64.b64encode(svg.encode('utf-8')).decode('ascii')}"
+
+logger = logging.getLogger(__name__)
+
+
+# ── URL allowlist (C-6 security audit) ─────────────────────────────────────
+# Только доверенные домены-источники объявлений попадают в в PDF.
+# Защита от javascript: / data: инъекций через source_url объявлений.
+_ALLOWED_URL_DOMAINS: frozenset[str] = frozenset({
+ "www.avito.ru", "avito.ru", "m.avito.ru",
+ "www.cian.ru", "cian.ru",
+ "realty.yandex.ru",
+ "n1.ru", "ekaterinburg.n1.ru",
+})
+
+# CDN-домены для изображений аналогов (photo_url).
+_ALLOWED_PHOTO_CDN: frozenset[str] = frozenset({
+ "images.avito.st",
+ "cdn-p.cian.site",
+ "avatars.mds.yandex.net",
+ "n1ru.cdn-cw.com",
+})
+
+
+def _safe_url(url: str | None, *, cdn: bool = False) -> str | None:
+ """Проверяет URL по allowlist схемы и домена.
+
+ cdn=False — allowlist для source_url объявлений (ссылки на листинги).
+ cdn=True — allowlist для photo_url (CDN изображений).
+ Возвращает None если URL не прошёл проверку — embed/ссылка пропускается.
+ """
+ if not url:
+ return None
+ try:
+ p = urlparse(url)
+ except Exception:
+ return None
+ if p.scheme != "https":
+ return None
+ allowed = _ALLOWED_PHOTO_CDN if cdn else _ALLOWED_URL_DOMAINS
+ if p.netloc not in allowed:
+ return None
+ return url
+
+
+# ── Source pseudo-logos (текстовые pill-badges как у Брусники с логотипами) ──
+_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
+ "avito": ("#00aaff", "#fff"), # Avito brand blue (упрощённо)
+ "cian": ("#0468ff", "#fff"), # Циан фирменный синий
+ "domklik": ("#1ab248", "#fff"), # ДомКлик зелёный Сбер
+ "yandex": ("#fc3f1d", "#fff"), # Я.Недвижимость красный
+ "n1": ("#ff6b00", "#fff"), # N1 оранжевый
+ "rosreestr": ("#003d82", "#fff"), # Росреестр тёмно-синий
+ "etazhi": ("#e30613", "#fff"), # Этажи красный
+}
+
+_SOURCE_DISPLAY_NAMES: dict[str, str] = {
+ "avito": "Avito",
+ "cian": "Циан",
+ "domklik": "Домклик · Сбер",
+ "yandex": "Я.Недвижимость",
+ "n1": "N1",
+ "rosreestr": "Росреестр",
+ "etazhi": "Этажи",
+}
+
+
+def _source_logo_pill(source: str) -> str:
+ """Pill-badge с цветом источника (заменяет реальные логотипы Брусники)."""
+ bg, fg = _SOURCE_LOGO_COLORS.get(source, ("#6b7280", "#fff"))
+ name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
+ return (
+ f"{_html.escape(name)}"
+ )
+
+
+def _source_badge_inline(source: str | None) -> str:
+ """Маленький source badge для table cells (без фона)."""
+ if not source:
+ return "—"
+ bg, fg = _SOURCE_LOGO_COLORS.get(source, ("#6b7280", "#fff"))
+ name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
+ return (
+ f"{_html.escape(name)}"
+ )
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+
+def _fmt_rub(value: int) -> str:
+ """12 500 000 ₽"""
+ return f"{value:,}".replace(",", " ") + " ₽"
+
+
+def _fmt_rub_m(value: int) -> str:
+ """3.4 млн. руб."""
+ m = value / 1_000_000
+ return f"{m:.1f}".replace(".", ",") + " млн. руб."
+
+
+def _fmt_ppm2(value: int) -> str:
+ """101 176"""
+ return f"{value:,}".replace(",", " ")
+
+
+def _report_number(estimate_id: UUID) -> str:
+ """Брусника-style № отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
+ short = int(estimate_id.int) % 10_000
+ long = int(estimate_id.int) % 10_000_000_000
+ return f"EKБ-{short:04d}-{long:010d}"
+
+
+def _qr_code_data_url(text: str, size: int = 4) -> str:
+ """QR-код как SVG data URL."""
+ qr = segno.make(text, error="m")
+ buf = io.BytesIO()
+ qr.save(buf, kind="svg", scale=size, dark="#1a1d23", light="#ffffff")
+ return f"data:image/svg+xml;base64,{base64.b64encode(buf.getvalue()).decode('ascii')}"
+
+
+def _conf_label(confidence: str) -> str:
+ return {"high": "Высокая", "medium": "Средняя", "low": "Низкая"}.get(confidence, confidence)
+
+
+# ── Price range SVG bar (как у Брусники) ────────────────────────────────────
+
+
+_BAR_PNG_URL = _bar_svg_data_url()
+
+
+def _price_range_bar(
+ range_low: int,
+ range_high: int,
+ *,
+ label_left: str | None = None,
+ label_right: str | None = None,
+ sub_label: str = "Рынок",
+ days_min: int | None = None,
+ days_max: int | None = None,
+ show_days: bool = False,
+) -> str:
+ """Полоска диапазона цен в стиле Брусники — PNG через Pillow."""
+ if range_high <= range_low:
+ return ""
+
+ label_left = label_left or _fmt_rub_m(range_low)
+ label_right = label_right or _fmt_rub_m(range_high)
+
+ days_overlay = ""
+ if show_days and days_min is not None and days_max is not None:
+ days_overlay = (
+ f''
+ f''
+ f' | '
+ f''
+ f'{days_min} дней'
+ f' | '
+ f''
+ f'{days_max} дней'
+ f' | '
+ f' '
+ )
+
+ # Ц-таблица: 3 ячейки фиксированной ширины с background-color на td.
+ # Высота через padding (WeasyPrint stable с padding на td).
+ days_row = ""
+ if show_days and days_min is not None and days_max is not None:
+ days_row = (
+ f' | '
+ f''
+ f'{days_min} дней | '
+ f''
+ f'{days_max} дней | '
+ f' | '
+ )
+
+ return (
+ f''
+ f'| '
+ f'{_html.escape(sub_label)}'
+ f' | '
+ f''
+ f' | '
+ f' | '
+ f' | '
+ f' | '
+ f' '
+ f'| '
+ f'{_html.escape(label_left)} | '
+ f''
+ f'{_html.escape(label_right)} | '
+ f'{days_row}'
+ f' '
+ )
+
+
+# ── Page 1: Cover ────────────────────────────────────────────────────────────
+
+
+def _build_cover(
+ estimate: AggregatedEstimate, input_snapshot: dict, brand
+) -> str: # type: ignore[no-untyped-def,type-arg]
+ today = dt.date.today()
+ expires = today + dt.timedelta(days=30)
+ report_num = _report_number(estimate.estimate_id)
+
+ # Короткий адрес (для cover): берём первую часть до запятой
+ full_address = input_snapshot.get("address", "—")
+ address_short = full_address.split(",")[0:3]
+ address_short = ", ".join(s.strip() for s in address_short)
+ address = _html.escape(address_short or full_address)
+ area = input_snapshot.get("area_m2", 0)
+ rooms = input_snapshot.get("rooms", 0)
+ floor = input_snapshot.get("floor") or "—"
+ total_floors = input_snapshot.get("total_floors") or "—"
+ year_built = input_snapshot.get("year_built", "—")
+ house_type = input_snapshot.get("house_type")
+ repair_state = input_snapshot.get("repair_state")
+ has_balcony = input_snapshot.get("has_balcony")
+
+ house_labels = {
+ "panel": "Панельный",
+ "brick": "Кирпичный",
+ "monolith": "Монолитный",
+ "monolith_brick": "Монолит-кирпич",
+ "other": "Другое",
+ }
+ repair_labels = {
+ "needs_repair": "Требуется ремонт",
+ "standard": "Стандартный",
+ "good": "Хороший",
+ "excellent": "Евроремонт",
+ }
+
+ rooms_label = "Студия" if rooms == 0 else f"{rooms} комнат" + ("а" if rooms == 1 else "ы" if 2 <= rooms <= 4 else "")
+ house_label = house_labels.get(house_type, "—") if house_type else "—"
+ repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
+ balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else "—"
+
+ # Active market subband — 4-118 days range (как у Брусники).
+ # Если есть days_on_market в analogs — берём min/max, иначе фиксированно.
+ days_min, days_max = _days_on_market_range(estimate.analogs)
+
+ # Deals range — если deals есть, считаем; иначе fallback к listings range
+ deals_low, deals_high = _deals_range(estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub))
+
+ listings_bar = _price_range_bar(
+ estimate.range_low_rub,
+ estimate.range_high_rub,
+ sub_label="Активный рынок с аналогичным состоянием ремонта",
+ days_min=days_min,
+ days_max=days_max,
+ show_days=True,
+ )
+
+ deals_bar = _price_range_bar(
+ deals_low,
+ deals_high,
+ sub_label="Диапазон цен по фактическим сделкам",
+ )
+
+ qr_url = _qr_code_data_url(
+ f"{settings.public_url}?id={estimate.estimate_id}", size=3
+ )
+
+ return f"""
+
+
+
+
+ {_html.escape(brand.name).upper()}
+
+
+
+ АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ
+
+
+
+
+ | № отчета | {report_num} |
+ | Дата отчета | {today.strftime("%d.%m.%Y")} |
+ | Срок действия данных | до {expires.strftime("%d.%m.%Y")} |
+ | Адрес | {address} |
+ | Год постройки | {year_built} |
+ | Тип дома | {house_label} |
+ | Этаж / этажность | {floor} / {total_floors} |
+ | Площадь | {area} м² |
+ | Планировка | {rooms_label} |
+ | Состояние ремонта | {repair_label} |
+ | Балкон / лоджия | {balcony_label} |
+
+
+
+ Диапазон цен в объявлениях
+ (без учета ремонта)
+
+ {listings_bar}
+
+
+ Диапазон цен по фактическим сделкам
+
+ {deals_bar}
+
+
+
+ Что важно при оценке стоимости квартиры:
+
+
+
+ | Цены в объявлениях — ожидания собственников |
+ Фактические сделки проходят на 5–12% ниже, что подтверждают Росреестр, ДомКлик и продажи агенств недвижимости |
+
+
+ | Ремонт оценивается по состоянию, а не по вложенным суммам |
+ Инвестиции в 400 – 600 тыс. руб. повышают цену объекта всего на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры |
+
+
+ | Неочевидные расходы при самостоятельной продаже |
+ При самостоятельной продаже суммарные расходы могут достигать до 15% стоимости квартиры (торг, риелтор, нотариус, справки) |
+
+
+
+
+ Этот отчёт онлайн: {settings.public_url}?id={estimate.estimate_id}
+
+
+
+"""
+
+
+def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]:
+ """Min/max days_on_market по аналогам. Fallback 4-118 как у Брусники."""
+ days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
+ if not days:
+ return 4, 118
+ return min(days), max(days)
+
+
+def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int, int]:
+ """Min/max price от deals. Fallback к listings range."""
+ if not deals:
+ return fallback
+ prices = [d.price_rub for d in deals]
+ return min(prices), max(prices)
+
+
+# ── Page 2: Listings (market) ────────────────────────────────────────────────
+
+
+def _build_listings_page(
+ estimate: AggregatedEstimate, input_snapshot: dict, brand
+) -> str: # type: ignore[no-untyped-def,type-arg]
+ n_total = estimate.n_analogs
+ n_with_repair = max(3, int(n_total * 0.4)) # упрощение: ~40% с указанным ремонтом
+
+ # Source logos (pseudo) — 5 максимальных
+ sources_to_show = estimate.sources_used or ["avito", "cian", "domklik", "yandex", "rosreestr"]
+ sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5])
+
+ # Params правой колонки — параметры поиска (НЕ конкретной квартиры)
+ area = float(input_snapshot.get("area_m2", 0) or 0)
+ area_min = round(area * 0.85, 1)
+ area_max = round(area * 1.15, 1)
+ rooms = input_snapshot.get("rooms", 0)
+ year_built = input_snapshot.get("year_built")
+ year_range = f"{year_built - 2}-{year_built + 2}" if year_built else "—"
+ house_type = input_snapshot.get("house_type")
+ house_label = {
+ "panel": "Эконом до 2000 года",
+ "brick": "Кирпич до 2000",
+ "monolith": "Современный (монолит)",
+ "monolith_brick": "Современный (м-к)",
+ "other": "Любой",
+ }.get(house_type, "Любой") if house_type else "Любой"
+ repair_state = input_snapshot.get("repair_state")
+ repair_label = {
+ "needs_repair": "Требуется ремонт",
+ "standard": "Стандартный",
+ "good": "Хороший",
+ "excellent": "Евроремонт",
+ }.get(repair_state, "Любой") if repair_state else "Любой"
+ rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
+
+ # Полоска диапазона
+ days_min, days_max = _days_on_market_range(estimate.analogs)
+ range_bar = _price_range_bar(
+ estimate.range_low_rub,
+ estimate.range_high_rub,
+ sub_label="Рынок",
+ days_min=days_min,
+ days_max=days_max,
+ show_days=True,
+ )
+
+ # Топ-5 примеров (отсортированных по distance)
+ top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
+ examples_rows = _examples_rows(top5)
+
+ return f"""
+
+
+
+
+ {_html.escape(brand.name).upper()}
+
+
+
+
+ РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ
+
+
+
+
+
+
+
+ | Количество объявлений по аналогичным объектам |
+ {n_total} шт. |
+ Количество объявлений по аналогичным объектам
+ (с учётом ремонта) |
+ {n_with_repair} шт. |
+
+ Источники данных
+ {sources_html}
+ |
+
+
+ | Расположение | ± 1 км от локации дома |
+ | Тип дома | {house_label} |
+ | Год постройки | {year_range} |
+ | Диапазон площади | {area_min} - {area_max} м² |
+ | Планировка | {rooms_label} |
+ | Состояние ремонта | {repair_label} |
+
+ |
+
+
+
+ Диапазон цен в объявлениях
+ {range_bar}
+
+
+
+ Примеры аналогичных объектов с учётом ремонта
+
+
+
+
+ | Адрес квартиры |
+ Источник |
+ Стоимость 1 м², руб. |
+ Стоимость объекта, руб. |
+ Срок экспозиции, дней |
+
+
+ {examples_rows}
+
+
+
+
+
+"""
+
+
+def _examples_rows(lots: list[AnalogLot]) -> str:
+ if not lots:
+ return "| Нет данных | "
+ rows = []
+ for lot in lots:
+ addr = _html.escape(lot.address)
+ safe = _safe_url(lot.source_url)
+ if safe:
+ addr_cell = f"{addr} ↗"
+ else:
+ addr_cell = addr
+ rows.append(
+ ""
+ f"| {addr_cell} | "
+ f"{_source_badge_inline(lot.source)} | "
+ f"{_fmt_ppm2(lot.price_per_m2)} | "
+ f"{_fmt_ppm2(lot.price_rub)} | "
+ f"{lot.days_on_market or '—'} | "
+ " "
+ )
+ return "".join(rows)
+
+
+# ── Page 3: Deals ────────────────────────────────────────────────────────────
+
+
+def _build_deals_page(
+ estimate: AggregatedEstimate, input_snapshot: dict, brand
+) -> str: # type: ignore[no-untyped-def,type-arg]
+ n_deals = len(estimate.actual_deals)
+ today = dt.date.today()
+ period_start = today - dt.timedelta(days=estimate.period_months * 30)
+
+ # Источники для сделок — Этажи / Домклик / Росреестр
+ deal_sources = ["etazhi", "domklik", "rosreestr"]
+ sources_html = "".join(_source_logo_pill(s) for s in deal_sources)
+
+ area = float(input_snapshot.get("area_m2", 0) or 0)
+ area_min = round(area * 0.85, 1)
+ area_max = round(area * 1.15, 1)
+ rooms = input_snapshot.get("rooms", 0)
+ year_built = input_snapshot.get("year_built")
+ year_range = f"{year_built - 2}–{year_built + 2}" if year_built else "—"
+ house_type = input_snapshot.get("house_type")
+ house_label = {
+ "panel": "Эконом до 2000 года",
+ "brick": "Кирпич до 2000",
+ "monolith": "Современный (монолит)",
+ }.get(house_type, "Любой") if house_type else "Любой"
+ rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
+
+ deals_low, deals_high = _deals_range(estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub))
+ days_min, days_max = _days_on_market_range(estimate.actual_deals)
+ range_bar = _price_range_bar(
+ deals_low, deals_high, sub_label="Рынок",
+ days_min=days_min, days_max=days_max, show_days=True,
+ )
+
+ top5 = estimate.actual_deals[:5]
+ examples_rows = _examples_rows(top5)
+
+ return f"""
+
+
+
+
+ {_html.escape(brand.name).upper()}
+
+
+
+
+ ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ
+
+
+
+
+
+
+ | Количество сделок по аналогичном объектам |
+ {n_deals} шт. |
+ | Период сделок |
+ {period_start.strftime("%m.%Y")} – {today.strftime("%m.%Y")} |
+
+ Источники данных
+ {sources_html}
+ |
+
+
+ | Расположение | ± 1 км от локации дома |
+ | Тип дома | {house_label} |
+ | Год постройки | {year_range} |
+ | Диапазон площади | {area_min} - {area_max} м² |
+ | Планировка | {rooms_label} |
+
+ |
+
+
+
+ Диапазон цен по фактическим сделкам
+ {range_bar}
+
+
+ По данным реальных сделок, квартиры продаются в среднем на 5–12 % дешевле,
+ чем заявлено в объявлениях
+
+
+
+ Примеры аналогичных объектов
+
+
+
+
+ | Адрес квартиры |
+ Источник |
+ Стоимость 1 м², руб. |
+ Стоимость объекта, руб. |
+ Срок экспозиции, дней |
+
+
+ {examples_rows}
+
+
+
+
+
+"""
+
+
+# ── Page 4: Offer (Trade-In vs Самопродажа) ──────────────────────────────────
+
+
+def _build_offer_page(
+ estimate: AggregatedEstimate, brand
+) -> str: # type: ignore[no-untyped-def]
+ median = estimate.median_price_rub
+
+ # Расчёт расходов как у Брусники: на основе медианы
+ listing_price = median # цена в объявлении
+ torg_pct_low, torg_pct_high = 5, 15
+ torg_low = int(listing_price * torg_pct_low / 100)
+ torg_high = int(listing_price * torg_pct_high / 100)
+
+ # После торга — средний по диапазону для остальных %
+ sold_price = listing_price - int(listing_price * 0.10) # ~10% торг
+
+ rieltor_pct_low, rieltor_pct_high = 2, 3
+ rieltor_low = int(sold_price * rieltor_pct_low / 100)
+ rieltor_high = int(sold_price * rieltor_pct_high / 100)
+
+ rent_low, rent_high = 90_000, 150_000 # 3 месяца аренды (фикс)
+ juridical_low = 19_250 # фикс
+ ads_low, ads_high = 4_000, 36_000 # за 3 месяца
+
+ total_low = torg_low + rieltor_low + rent_low + juridical_low + ads_low
+ total_high = torg_high + rieltor_high + rent_high + juridical_low + ads_high
+
+ brand_short = _html.escape(brand.name)
+ trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
+
+ return f"""
+
+
+
+
+ {brand_short.upper()}
+
+
+
+
+ ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ
+
+
+ Структура стоимости сделки
+
+
+
+
+ |
+
+ Продажа через {trade_in_label}
+ |
+
+ Самостоятельная продажа, руб.
+ |
+
+
+
+
+ |
+ Торг потенциальных покупателей
+ при стоимости квартиры в объявлении {_fmt_rub(listing_price)}
+ |
+ Не применимо |
+
+ от {_fmt_rub(torg_low)} – {_fmt_rub(torg_high)}
+ от {torg_pct_low}–{torg_pct_high}%
+ |
+
+
+ |
+ Услуги риэлтора при продаже квартиры
+ с минимальным торгом за {_fmt_rub(sold_price)}
+ |
+ бесплатно |
+
+ {_fmt_rub(rieltor_low)} - {_fmt_rub(rieltor_high)}
+ от {rieltor_pct_low}–{rieltor_pct_high}%
+ |
+
+
+ |
+ Аренда после сделки
+ однокомнатной квартиры на 3 месяца
+ |
+ бесплатно |
+
+ {_fmt_rub(rent_low)} - {_fmt_rub(rent_high)}
+ |
+
+
+ |
+ Юридическое сопровождение
+ Проверка документов и подготовка договоров
+ |
+ бесплатно |
+ от {_fmt_rub(juridical_low)} |
+
+
+ |
+ Расходы на рекламу
+ Ежемесячное базовой продвижение объекта на Циан, Авито, Я.Недвижимости
+ |
+ бесплатно |
+
+ от {_fmt_rub(ads_low)} - {_fmt_rub(ads_high)}
+ за 3 месяца
+ |
+
+
+ | Общие финансовые потери |
+ |
+
+ от {_fmt_rub(total_low)} - {_fmt_rub(total_high)}
+ |
+
+
+
+
+
+ Издержки при обмене сопоставимы с личными расходами собственника, которые возникают при
+ самостоятельной продаже квартиры
+
+
+ Преимущества
+
+
+
+ Экономия времени
+ Берём на себя показы, переговоры и поиск покупателей
+
+
+
+ Юридическая безопасность
+ Проверяем документы, исключаем риски, сопровождаем на всех этапах
+
+
+
+
+
+ Фиксированная стоимость новостройки
+ Сохраняем цену выбранной планировки в {brand_short}
+
+
+
+
+
+ Гарантия цены
+ Снимаем угрозу колебаний рынка и удерживаем договорную стоимость
+
+
+
+
+
+
+"""
+
+
+# ── CSS ──────────────────────────────────────────────────────────────────────
+
+
+def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
+ primary = brand.primary_color if brand else "#1d4ed8"
+ return f"""
+@page {{
+ size: A4;
+ margin: 20mm 18mm 20mm 18mm;
+}}
+* {{ box-sizing: border-box; }}
+body {{
+ font-family: 'Helvetica', 'Arial', sans-serif;
+ font-size: 10pt;
+ color: #1a1d23;
+ margin: 0;
+ padding: 0;
+ line-height: 1.4;
+}}
+h1, h2, h3 {{ margin: 0; }}
+.page {{ page-break-after: always; position: relative; }}
+.page:last-child {{ page-break-after: avoid; }}
+
+.bold {{ font-weight: 700; }}
+
+/* Params table (Cover) */
+.params-table td {{
+ padding: 2.5pt 4pt;
+ border-bottom: 1px solid #f3f4f6;
+ font-size: 9pt;
+}}
+.params-table td:first-child {{
+ color: #6b7280;
+ width: 40%;
+}}
+.params-table td:last-child {{
+ text-align: right;
+}}
+
+/* Search params (Listings/Deals) */
+.search-params td {{
+ padding: 4pt 0;
+ font-size: 9pt;
+}}
+.search-params td:first-child {{
+ color: #6b7280;
+}}
+.search-params td:last-child {{
+ text-align: right;
+}}
+
+/* Range block */
+.range-block {{
+ margin-top: 6pt;
+}}
+
+/* Range bar — linear-gradient на одном div'е. WeasyPrint reliable. */
+.range-bar-strip {{
+ width: 100%;
+ height: 16pt;
+ line-height: 14pt;
+ border: 1pt solid #9ca3af;
+ background: linear-gradient(
+ to right,
+ #ffffff 0%, #ffffff 20%,
+ #dc2626 20%, #dc2626 80%,
+ #ffffff 80%, #ffffff 100%
+ );
+ margin: 4pt 0;
+ overflow: hidden;
+ color: transparent;
+ font-size: 1pt;
+}}
+.range-title {{
+ font-size: 10pt;
+ font-weight: 700;
+ color: #1a1d23;
+ margin-bottom: 4pt;
+}}
+
+/* Что важно — advice table */
+.advice-table td {{
+ padding: 5pt 6pt;
+ border-bottom: 1px solid #f3f4f6;
+ vertical-align: top;
+ font-size: 8.5pt;
+ line-height: 1.3;
+}}
+.advice-title {{
+ font-weight: 700;
+ width: 30%;
+ color: #1a1d23;
+}}
+.advice-text {{
+ color: #4b5563;
+}}
+
+/* Advantages (Offer) */
+.advantage-item {{
+ border: 1px solid #e6e8ec;
+ border-radius: 4pt;
+ padding: 8pt;
+ text-align: left;
+}}
+.advantage-item .adv-ic {{
+ display: block;
+ width: 28pt;
+ height: 28pt;
+ border-radius: 50%;
+ background: #fef2f2;
+ margin: 0 0 6pt 0;
+ line-height: 28pt;
+ text-align: center;
+}}
+.advantage-item .adv-ic svg {{
+ vertical-align: middle;
+}}
+.advantage-item .adv-title {{
+ font-weight: 700;
+ font-size: 8.5pt;
+ margin-bottom: 3pt;
+ color: {primary};
+ letter-spacing: -0.01em;
+}}
+.advantage-item .adv-desc {{
+ font-size: 7pt;
+ color: #5b6066;
+ line-height: 1.3;
+}}
+"""
+
+
+# ── Public API ───────────────────────────────────────────────────────────────
+
+
+def generate_trade_in_pdf(
+ estimate: AggregatedEstimate,
+ input_snapshot: dict, # type: ignore[type-arg]
+ *,
+ brand=None, # type: ignore[no-untyped-def]
+) -> bytes:
+ """Генерирует 4-страничный WeasyPrint PDF в стиле Брусника.Обмен.
+
+ Pages:
+ 1. Cover — № отчёта + параметры + 2 диапазона + 3 блока «Что важно»
+ 2. Listings — рынок аналогов + источники + таблица примеров
+ 3. Deals — фактические сделки + источники + таблица
+ 4. Offer — Trade-In vs самопродажа + 4 преимущества
+
+ Args:
+ estimate: AggregatedEstimate из БД
+ input_snapshot: ввод пользователя (address, area_m2, ...)
+ brand: optional Brand для white-label
+
+ Returns:
+ PDF bytes для Response(media_type="application/pdf")
+ """
+ from app.services.brand import Brand
+
+ if brand is None:
+ brand = Brand(
+ slug="generic",
+ name="Trade-In",
+ logo_url=None,
+ primary_color="#1d4ed8",
+ accent_color="#f59e0b",
+ footer_text=None,
+ pdf_disclaimer=None,
+ )
+
+ html_str = (
+ ''
+ f'Trade-In — {_html.escape(input_snapshot.get("address", ""))}'
+ ""
+ + _build_cover(estimate, input_snapshot, brand)
+ + _build_listings_page(estimate, input_snapshot, brand)
+ + _build_deals_page(estimate, input_snapshot, brand)
+ + _build_offer_page(estimate, brand)
+ + ""
+ )
+ css_str = _build_css(brand=brand)
+ # base_url=file:/// чтобы WeasyPrint мог resolve file:// URLs для PNG
+ pdf_bytes = HTML(string=html_str, base_url="file:///").write_pdf(stylesheets=[CSS(string=css_str)])
+ logger.info(
+ "PDF generated estimate_id=%s brand=%s size=%d (Brusnika-style)",
+ estimate.estimate_id, brand.slug, len(pdf_bytes),
+ )
+ return pdf_bytes
diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py
new file mode 100644
index 00000000..4b2b35e2
--- /dev/null
+++ b/tradein-mvp/backend/app/services/geocoder.py
@@ -0,0 +1,982 @@
+"""Geocoder service — address → lat/lon.
+
+Стратегия:
+- Cache lookup в `geocode_cache` (Postgres) — TTL 90 дней
+- Cache miss → Yandex Geocoder (если есть key) → fallback Nominatim
+- Результат сохраняется в кэш для последующих вызовов
+
+Используется в:
+- /api/v1/trade-in/estimate (вход — адрес от пользователя)
+- /api/v1/geocode/lookup (debug endpoint)
+- scraper jobs (когда нужно по адресу определить координаты)
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from dataclasses import dataclass
+from typing import Literal
+
+import httpx
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+from tenacity import retry, stop_after_attempt, wait_exponential
+
+from app.core.config import settings
+from app.services import dadata
+
+logger = logging.getLogger(__name__)
+
+
+# ── Result type ──────────────────────────────────────────────────────────────
+@dataclass(frozen=True, slots=True)
+class GeocodeResult:
+ lat: float
+ lon: float
+ full_address: str
+ provider: Literal["nominatim", "yandex", "cache"]
+ confidence: Literal["exact", "approximate", "locality"] = "approximate"
+
+
+# ── Address normalisation ───────────────────────────────────────────────────
+def normalize_address(address: str) -> str:
+ """Нормализация для cache lookup: lowercase + trim + collapse whitespace.
+
+ « Ул. МАЛЫШЕВА, 30 » → «ул. малышева, 30»
+ """
+ return " ".join(address.lower().strip().split())
+
+
+# Согласные, которые часто пишут с одной буквой вместо двух (RU typos).
+_DOUBLE_CONSONANTS = "лнмссккттпп"
+
+
+def _typo_variants(query: str, limit: int = 6) -> list[str]:
+ """Генерация вариантов с удвоением согласных — для случая когда пользователь
+ написал «Цвилинга» вместо «Цвиллинга», «Толстова» вместо «Толстого» итд.
+
+ Каждая позиция где есть одинокая согласная — кандидат на удвоение.
+ Возвращаем до `limit` вариантов в порядке вероятности (ближе к началу слова → выше).
+ """
+ if len(query) < 3:
+ return []
+ variants: list[str] = []
+ chars = list(query)
+ for i in range(1, len(chars) - 1):
+ c = chars[i].lower()
+ if c not in _DOUBLE_CONSONANTS:
+ continue
+ prev_c = chars[i - 1].lower()
+ next_c = chars[i + 1].lower()
+ # Удваиваем только если соседи — гласные/'ь'/'ъ' (характерно для русских типо)
+ if prev_c in "аеёиоуыэюяьъ" and next_c in "аеёиоуыэюяьъ":
+ variant = query[:i + 1] + chars[i] + query[i + 1:]
+ if variant != query and variant not in variants:
+ variants.append(variant)
+ if len(variants) >= limit:
+ break
+ return variants
+
+
+# ── Cache ────────────────────────────────────────────────────────────────────
+def _cache_get(db: Session, address_norm: str) -> GeocodeResult | None:
+ row = db.execute(
+ text(
+ """
+ SELECT lat, lon, full_address, provider, confidence
+ FROM geocode_cache
+ WHERE address_normalized = :addr
+ AND expires_at > NOW()
+ """
+ ),
+ {"addr": address_norm},
+ ).fetchone()
+ if row is None:
+ return None
+ return GeocodeResult(
+ lat=row.lat,
+ lon=row.lon,
+ full_address=row.full_address,
+ provider="cache",
+ confidence=row.confidence or "approximate",
+ )
+
+
+def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
+ db.execute(
+ text(
+ """
+ INSERT INTO geocode_cache
+ (address_normalized, lat, lon, full_address, provider, confidence)
+ VALUES (:addr, :lat, :lon, :full, :provider, :conf)
+ ON CONFLICT (address_normalized) DO UPDATE
+ SET lat = EXCLUDED.lat,
+ lon = EXCLUDED.lon,
+ full_address = EXCLUDED.full_address,
+ provider = EXCLUDED.provider,
+ confidence = EXCLUDED.confidence,
+ created_at = NOW(),
+ expires_at = NOW() + interval '90 days'
+ """
+ ),
+ {
+ "addr": address_norm,
+ "lat": result.lat,
+ "lon": result.lon,
+ "full": result.full_address,
+ "provider": result.provider,
+ "conf": result.confidence,
+ },
+ )
+ db.commit()
+
+
+# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
+async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
+ """Single Nominatim search. Возвращает первый item или None.
+
+ ВАЖНО: фильтруем результаты по ЕКБ bbox прямо тут, чтобы при опечатках
+ не возвращать Пермский край / Челябинск.
+ """
+ response = await client.get(
+ "https://nominatim.openstreetmap.org/search",
+ params={
+ "q": address,
+ "format": "json",
+ "limit": "3",
+ "countrycodes": "ru",
+ "addressdetails": "1",
+ "viewbox": EKB_BBOX["viewbox"],
+ "bounded": "1", # строго в ЕКБ bbox
+ },
+ )
+ response.raise_for_status()
+ data = response.json()
+ for item in data:
+ try:
+ lat_f = float(item["lat"])
+ lon_f = float(item["lon"])
+ if 60.40 <= lon_f <= 60.85 and 56.65 <= lat_f <= 56.95:
+ return item
+ except Exception:
+ continue
+ return None
+
+
+@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
+async def _nominatim_lookup(address: str) -> GeocodeResult | None:
+ """OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
+
+ Бан-policy: User-Agent с email обязателен.
+ Tier 1: bounded ЕКБ на оригинальный адрес.
+ Tier 2: bounded ЕКБ на typo-варианты (Цвилинга → Цвиллинга).
+ """
+ headers = {
+ "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
+ "Accept": "application/json",
+ "Accept-Language": "ru,en;q=0.8",
+ "Referer": "https://tradein-mvp.local/",
+ }
+ async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
+ # Tier 1: оригинал
+ item = await _nominatim_query(client, address)
+
+ # Tier 2: typo-variants
+ if item is None:
+ for variant in _typo_variants(address, limit=4):
+ await asyncio.sleep(1.0) # Nominatim 1 req/sec policy
+ item = await _nominatim_query(client, variant)
+ if item is not None:
+ logger.info("nominatim typo-fixed: %s → %s", address, variant)
+ break
+
+ if item is None:
+ return None
+
+ confidence = "exact" if item.get("class") == "building" else "approximate"
+ return GeocodeResult(
+ lat=float(item["lat"]),
+ lon=float(item["lon"]),
+ full_address=item.get("display_name", address),
+ provider="nominatim",
+ confidence=confidence,
+ )
+
+
+# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
+@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
+async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
+ """Yandex Geocoder — 25K req/day free для самопод, лучше РФ.
+
+ Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html
+
+ Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов,
+ но БЕЗ rspn — чтобы fuzzy matching работал при опечатках.
+ """
+ # Не запихиваем "Екатеринбург" если оно уже есть в адресе (типичный кейс из suggest)
+ geocode_query = address if "екатеринбург" in address.lower() else f"Екатеринбург, {address}"
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.get(
+ "https://geocode-maps.yandex.ru/1.x/",
+ params={
+ "apikey": api_key,
+ "geocode": geocode_query,
+ "format": "json",
+ "results": 5, # берем top-5, отфильтруем по ЕКБ bbox ниже
+ "lang": "ru_RU",
+ "ll": EKB_BBOX["ll"],
+ "spn": EKB_BBOX["spn"],
+ },
+ )
+ response.raise_for_status()
+ data = response.json()
+
+ members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
+ if not members:
+ return None
+
+ # Фильтруем top-5 по ЕКБ bbox — игнорируем Челябинск/Уфу/Москву при опечатке
+ best = None
+ for m in members:
+ obj = m.get("GeoObject", {})
+ try:
+ lon_str, lat_str = obj["Point"]["pos"].split()
+ lat_f, lon_f = float(lat_str), float(lon_str)
+ if 60.40 <= lon_f <= 60.85 and 56.65 <= lat_f <= 56.95:
+ best = obj
+ break
+ except Exception:
+ continue
+
+ if best is None:
+ # Никто из top-5 не попал в ЕКБ → берем первый «как есть» (вне ЕКБ — но хоть что-то)
+ best = members[0]["GeoObject"]
+
+ lon_str, lat_str = best["Point"]["pos"].split()
+ precision_raw = (
+ best.get("metaDataProperty", {})
+ .get("GeocoderMetaData", {})
+ .get("precision", "other")
+ )
+ confidence_map = {
+ "exact": "exact",
+ "number": "exact",
+ "near": "approximate",
+ "range": "approximate",
+ "street": "approximate",
+ }
+ return GeocodeResult(
+ lat=float(lat_str),
+ lon=float(lon_str),
+ full_address=best.get("metaDataProperty", {})
+ .get("GeocoderMetaData", {})
+ .get("text", address),
+ provider="yandex",
+ confidence=confidence_map.get(precision_raw, "approximate"),
+ )
+
+
+# ── Suggest (автокомплит) ───────────────────────────────────────────────────
+# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85
+EKB_BBOX = {
+ "viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE)
+ "ll": "60.605,56.838", # Yandex center (lon,lat)
+ "spn": "0.45,0.30", # Yandex span (lon,lat)
+}
+
+
+@dataclass(frozen=True, slots=True)
+class GeocodeSuggestion:
+ label: str # формат "Малышева 30, Октябрьский район"
+ full_address: str # полный из геокодера
+ lat: float
+ lon: float
+ kind: str # 'house' / 'street' / 'locality'
+
+
+def _parse_yandex_members(members: list[dict]) -> list[GeocodeSuggestion]:
+ """Yandex geocode_members → list[GeocodeSuggestion]. Чистим описание от мусора."""
+ out: list[GeocodeSuggestion] = []
+ for m in members:
+ obj = m.get("GeoObject", {})
+ try:
+ lon_str, lat_str = obj["Point"]["pos"].split()
+ meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {})
+ kind = meta.get("kind", "other")
+ full = meta.get("text", obj.get("name", ""))
+ name = obj.get("name", full)
+ desc = obj.get("description", "")
+ desc_parts = [
+ p.strip() for p in desc.split(",")
+ if p.strip() and p.strip() not in {"Россия", "Свердловская область"}
+ ]
+ label = name if not desc_parts else f"{name} · {', '.join(desc_parts)}"
+ out.append(GeocodeSuggestion(
+ label=label, full_address=full,
+ lat=float(lat_str), lon=float(lon_str), kind=kind,
+ ))
+ except Exception:
+ continue
+ return out
+
+
+# ── DaData suggest wrapper ──────────────────────────────────────────────────
+# Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется
+# 'locality' вместо 'city' — consistent с Yandex/Nominatim ветками).
+_DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
+
+
+async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
+ """Обёртка над `dadata.suggest_addresses` — конвертит в GeocodeSuggestion.
+
+ Дроп candidate'ов без координат (DaData возвращает их для широких categories
+ типа город/район, для autocomplete с привязкой к карте они бесполезны).
+
+ Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
+ Без city suffix — query уже ограничен `locations=[{city: 'Екатеринбург'}]`.
+ """
+ raw = await dadata.suggest_addresses(query, limit=limit)
+ out: list[GeocodeSuggestion] = []
+ for s in raw:
+ if s.lat is None or s.lon is None:
+ continue
+ out.append(GeocodeSuggestion(
+ label=s.value,
+ full_address=s.unrestricted_value,
+ lat=s.lat,
+ lon=s.lon,
+ kind=_DADATA_KIND_MAP.get(s.kind, "locality"),
+ ))
+ return out
+
+
+async def _yandex_geocode_request(
+ client: httpx.AsyncClient, api_key: str, query: str, limit: int, bounded: bool
+) -> list[dict]:
+ """Single Yandex Geocoder request — bounded=True → строго в ЕКБ через rspn=1."""
+ params: dict[str, str] = {
+ "apikey": api_key,
+ "geocode": query,
+ "format": "json",
+ "results": str(limit),
+ "lang": "ru_RU",
+ "ll": EKB_BBOX["ll"],
+ "spn": EKB_BBOX["spn"],
+ }
+ if bounded:
+ params["rspn"] = "1"
+ response = await client.get("https://geocode-maps.yandex.ru/1.x/", params=params)
+ response.raise_for_status()
+ data = response.json()
+ return data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
+
+
+@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
+async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]:
+ """Yandex Geocoder с авто-fallback на typo-tolerant режим.
+
+ Tier 1: bounded ЕКБ (rspn=1) на оригинальный query.
+ Tier 2: bounded ЕКБ на typo-variants (удвоение согласных).
+ Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по ЕКБ bbox.
+ """
+ async with httpx.AsyncClient(timeout=8.0) as client:
+ # Tier 1: strict bounded на оригинал
+ members = await _yandex_geocode_request(
+ client, api_key, f"Екатеринбург, {query}", limit, bounded=True,
+ )
+ results = _parse_yandex_members(members)
+ if results:
+ return results
+
+ # Tier 2: bounded на typo-варианты
+ for variant in _typo_variants(query, limit=4):
+ members = await _yandex_geocode_request(
+ client, api_key, f"Екатеринбург, {variant}", limit, bounded=True,
+ )
+ results = _parse_yandex_members(members)
+ if results:
+ return results
+
+ # Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox
+ members = await _yandex_geocode_request(
+ client, api_key, f"Екатеринбург, {query}", limit, bounded=False,
+ )
+ results = _parse_yandex_members(members)
+ in_ekb = [
+ r for r in results
+ if 60.40 <= r.lon <= 60.85 and 56.65 <= r.lat <= 56.95
+ ]
+ return in_ekb
+
+
+async def _nominatim_query_multi(
+ client: httpx.AsyncClient, query: str, limit: int
+) -> list[dict]:
+ """Один Nominatim search с фильтром по ЕКБ bbox. Возвращает up to N items."""
+ response = await client.get(
+ "https://nominatim.openstreetmap.org/search",
+ params={
+ "q": query,
+ "format": "json",
+ "limit": str(limit),
+ "countrycodes": "ru",
+ "viewbox": EKB_BBOX["viewbox"],
+ "bounded": "1",
+ "addressdetails": "1",
+ },
+ )
+ response.raise_for_status()
+ data = response.json()
+ return data if isinstance(data, list) else []
+
+
+@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
+async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
+ """Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен)."""
+ headers = {
+ "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
+ "Accept": "application/json",
+ "Accept-Language": "ru,en;q=0.8",
+ }
+ async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
+ # Tier 1: оригинальный query
+ data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit)
+
+ # Tier 2: typo-варианты если оригинал пустой
+ if not data:
+ for variant in _typo_variants(query, limit=3):
+ await asyncio.sleep(1.0) # Nominatim 1 req/sec
+ data = await _nominatim_query_multi(client, f"{variant}, Екатеринбург", limit)
+ if data:
+ logger.info("nominatim suggest typo-fixed: %s → %s", query, variant)
+ break
+
+ out: list[GeocodeSuggestion] = []
+ for item in data:
+ display = item.get("display_name", "")
+ addr = item.get("address", {}) or {}
+ # Компактный лейбл: street + house_number / locality / district
+ street = addr.get("road") or addr.get("street") or ""
+ house = addr.get("house_number", "")
+ district = addr.get("suburb") or addr.get("city_district") or addr.get("borough") or ""
+ parts = []
+ if street:
+ parts.append(f"{street}{f', {house}' if house else ''}")
+ elif item.get("name"):
+ parts.append(item["name"])
+ if district:
+ parts.append(district)
+ label = " · ".join(parts) if parts else display[:80]
+ kind = "house" if house else ("street" if street else "locality")
+ out.append(GeocodeSuggestion(
+ label=label, full_address=display,
+ lat=float(item["lat"]), lon=float(item["lon"]), kind=kind,
+ ))
+ return out
+
+
+def _cadastral_forward_sync(
+ db: Session, query: str, limit: int = 5
+) -> list[GeocodeSuggestion]:
+ """Forward search via gendesign_cad_buildings FDW.
+
+ Uses ILIKE prefilter (FDW-pushable) + pg_trgm similarity ranking locally.
+ Returns up to `limit` suggestions, or [] on error / no results.
+ """
+ try:
+ rows = db.execute(
+ text("""
+ SELECT cad_num, readable_address, lat, lon,
+ similarity(readable_address, CAST(:q AS text)) AS sim
+ FROM gendesign_cad_buildings
+ WHERE readable_address ILIKE CAST('%' || :q || '%' AS text)
+ ORDER BY sim DESC, length(readable_address) ASC
+ LIMIT CAST(:lim AS integer)
+ """),
+ {"q": query, "lim": limit * 4},
+ ).mappings().all()
+ except Exception:
+ logger.warning("cadastral forward sync failed for query=%r", query, exc_info=True)
+ return []
+
+ out: list[GeocodeSuggestion] = []
+ for r in rows[:limit]:
+ out.append(GeocodeSuggestion(
+ label=str(r["readable_address"]),
+ full_address=str(r["readable_address"]),
+ lat=float(r["lat"]),
+ lon=float(r["lon"]),
+ kind="house",
+ ))
+ return out
+
+
+def _cadastral_reverse_sync(
+ db: Session, lat: float, lon: float, radius_m: int = 200
+) -> str | None:
+ """Reverse lookup via gendesign_cad_buildings FDW.
+
+ bbox prefilter (±0.0025° lat, ±0.005° lon ≈ 280m) is FDW-pushable.
+ Distance calculated locally on small result set. Filters гаражи/СНТ.
+ Returns None on no result or error.
+ """
+ try:
+ row = db.execute(
+ text("""
+ WITH candidates AS (
+ SELECT cad_num, readable_address, lat, lon,
+ 111320.0 * sqrt(
+ pow(CAST(:lat AS double precision) - lat, 2) +
+ pow(
+ cos(radians(CAST(:lat AS double precision)))
+ * (CAST(:lon AS double precision) - lon), 2
+ )
+ ) AS dist_m
+ FROM gendesign_cad_buildings
+ WHERE lat BETWEEN CAST(:lat AS double precision) - 0.0025
+ AND CAST(:lat AS double precision) + 0.0025
+ AND lon BETWEEN CAST(:lon AS double precision) - 0.005
+ AND CAST(:lon AS double precision) + 0.005
+ AND readable_address !~* '(гараж|снт|садовод|товарищ|уч\\.)'
+ )
+ SELECT readable_address, dist_m
+ FROM candidates
+ WHERE dist_m < CAST(:radius AS double precision)
+ ORDER BY dist_m ASC
+ LIMIT 1
+ """),
+ {"lat": lat, "lon": lon, "radius": float(radius_m)},
+ ).first()
+ except Exception:
+ logger.warning(
+ "cadastral reverse sync failed for (%.5f, %.5f)", lat, lon, exc_info=True
+ )
+ return None
+
+ if row is None:
+ return None
+ return str(row.readable_address)
+
+
+async def suggest(
+ query: str, db: Session | None = None, limit: int = 8
+) -> list[GeocodeSuggestion]:
+ """Автокомплит адресов в ЕКБ. Cadastral FDW → DaData → Yandex → Nominatim → [].
+
+ db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier).
+ DaData /suggest (PR Q2) — token-only, 10k/день, заменяет Yandex который
+ заблокирован (1k/день demo limit исчерпан).
+ Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
+ """
+ if not query or len(query.strip()) < 2:
+ return []
+
+ # Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов
+ if db is not None:
+ cad_results = await asyncio.to_thread(_cadastral_forward_sync, db, query.strip(), limit)
+ if cad_results:
+ return cad_results
+
+ # Tier 2: DaData /suggest — token-only (X-Secret не нужен), 10k/день free,
+ # лучший fit для РФ адресов. Заменил Yandex как primary external provider
+ # после того как demo-key Yandex (1k/день) был исчерпан.
+ if settings.dadata_api_token:
+ try:
+ dadata_results = await _dadata_suggest(query, limit)
+ if dadata_results:
+ return dadata_results
+ except Exception:
+ logger.exception("dadata suggest failed, falling back to yandex")
+
+ # Tier 3: Yandex (legacy — оставляем как fallback, если key есть)
+ if settings.yandex_geocoder_api_key:
+ try:
+ results = await _yandex_suggest(query, settings.yandex_geocoder_api_key, limit)
+ if results:
+ return results
+ except Exception:
+ logger.exception("yandex suggest failed, falling back to nominatim")
+
+ # Tier 4: Nominatim (последний fallback — OSM, без ключа)
+ try:
+ return await _nominatim_suggest(query, limit)
+ except Exception:
+ logger.exception("nominatim suggest failed")
+ return []
+
+
+# ── Public API ───────────────────────────────────────────────────────────────
+async def geocode(address: str, db: Session) -> GeocodeResult | None:
+ """Геокодинг с кэшем. Cadastral FDW → Yandex → Nominatim → None.
+
+ Args:
+ address: пользовательский ввод (может быть грязным — нормализуем).
+ db: сессия Postgres для cache lookup/write и cadastral FDW lookup.
+
+ Returns:
+ GeocodeResult или None если ни один провайдер не отвечает.
+ """
+ if not address or len(address.strip()) < 3:
+ return None
+
+ addr_norm = normalize_address(address)
+
+ # 1. Cache
+ cached = _cache_get(db, addr_norm)
+ if cached is not None:
+ logger.info("geocode cache hit: %s", addr_norm)
+ return cached
+
+ # 2. Cadastral FDW (прямой запрос к gendesign_cad_buildings — без внешнего API)
+ cad_suggestions = await asyncio.to_thread(_cadastral_forward_sync, db, address.strip(), limit=1)
+ if cad_suggestions:
+ s = cad_suggestions[0]
+ result = GeocodeResult(
+ lat=s.lat,
+ lon=s.lon,
+ full_address=s.full_address,
+ provider="nominatim", # treat as "local" — same confidence as nominatim
+ confidence="exact",
+ )
+ _cache_put(db, addr_norm, result)
+ logger.info(
+ "geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
+ )
+ return result
+
+ # 3. Yandex (если есть key) с typo-fallback
+ if settings.yandex_geocoder_api_key:
+ try:
+ result = await _yandex_lookup(address, settings.yandex_geocoder_api_key)
+ # Если результат вне ЕКБ — пробуем typo-варианты
+ in_ekb = (
+ result is not None
+ and 60.40 <= result.lon <= 60.85
+ and 56.65 <= result.lat <= 56.95
+ )
+ if result is not None and in_ekb:
+ _cache_put(db, addr_norm, result)
+ logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
+ return result
+ # Tier 2: typo-variants
+ for variant in _typo_variants(address, limit=4):
+ try:
+ result = await _yandex_lookup(variant, settings.yandex_geocoder_api_key)
+ except Exception:
+ continue
+ if result is None:
+ continue
+ if 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95:
+ _cache_put(db, addr_norm, result)
+ logger.info(
+ "geocode yandex typo-fixed: %s → %s → (%.5f, %.5f)",
+ addr_norm, variant, result.lat, result.lon,
+ )
+ return result
+ except Exception:
+ logger.exception("yandex geocoder failed, falling back to nominatim")
+
+ # 4. Nominatim fallback
+ try:
+ result = await _nominatim_lookup(address)
+ if result is not None:
+ _cache_put(db, addr_norm, result)
+ logger.info(
+ "geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
+ )
+ # Nominatim rate-limit policy: 1 req/sec — спим после успешного запроса
+ await asyncio.sleep(1.0)
+ return result
+ except Exception:
+ logger.exception("nominatim geocoder failed")
+
+ return None
+
+
+# ── Reverse: координаты → адрес (для map-picker'а) ──────────────────────────
+# Precision levels which we treat as "снап к зданию имеет смысл":
+# - exact — точный матч на здание (Yandex)
+# - number — найден дом с номером (то что нам надо для квартирного оценщика)
+# - cadastral — Cadastral FDW row (то же по точности что Yandex "number")
+# Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике.
+_SNAP_PRECISIONS = {"exact", "number", "cadastral"}
+
+
+@dataclass(frozen=True, slots=True)
+class ReverseGeocodeResult:
+ """Reverse-геокодинг с snapped координатами matched здания.
+
+ - `address` — текстовый адрес (улица + дом + город).
+ - `snapped_lat` — координата центра здания если provider дал её,
+ иначе echo `lat` входной точки (для precision=street/locality).
+ - `snapped_lon` — то же.
+ - `precision` — yandex-style: `exact`/`number`/`street`/`range`/`near`/
+ `locality`/`other`/`cadastral`. Используется фронтом чтобы
+ решить — двигать marker (exact/number) или нет.
+ - `provider` — кто дал результат (`yandex`/`nominatim`/`cadastral`).
+
+ Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number`
+ и snapped >5m от click point — пересаживает marker на snapped point
+ (чтобы пользователь видел центр дома по Яндексу, а не свой клик во дворе).
+ Для остальных precision marker остаётся где кликнули — не врём что нашли
+ точное здание.
+ """
+ address: str
+ snapped_lat: float
+ snapped_lon: float
+ precision: str
+ provider: Literal["yandex", "nominatim", "cadastral"]
+
+
+def _format_reverse_address(addr: dict) -> str | None:
+ """Собирает чистый уличный адрес из Nominatim address-объекта.
+
+ Nominatim `display_name` ведёт с названия ближайшего POI/организации
+ («NataliOlympic, 2к1, Трамвайный переулок, …»). Для map-picker'а это
+ ломает кейс: такой адрес нельзя forward-геокодировать обратно и оценка
+ не находит аналогов. Берём именно улицу + дом + город.
+
+ Возвращает None если в объекте нет улицы — тогда вызывающий код
+ откатывается на display_name.
+ """
+ road = (
+ addr.get("road")
+ or addr.get("pedestrian")
+ or addr.get("footway")
+ )
+ if not road:
+ return None
+ house = addr.get("house_number")
+ city = (
+ addr.get("city")
+ or addr.get("town")
+ or addr.get("village")
+ or addr.get("municipality")
+ )
+ parts: list[str] = [str(road)]
+ if house:
+ parts.append(str(house))
+ if city:
+ parts.append(str(city))
+ return ", ".join(parts)
+
+
+@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
+async def _yandex_reverse(
+ lat: float, lon: float, api_key: str
+) -> ReverseGeocodeResult | None:
+ """Yandex Geocoder /reverse — возвращает snapped Point.pos здания + precision.
+
+ Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html
+ Параметр `geocode` принимает `lon,lat` (важно — обратный порядок!).
+ """
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.get(
+ "https://geocode-maps.yandex.ru/1.x/",
+ params={
+ "apikey": api_key,
+ "geocode": f"{lon},{lat}",
+ "format": "json",
+ "results": "1",
+ "kind": "house", # просим именно здание (house), не улицу
+ "lang": "ru_RU",
+ },
+ )
+ response.raise_for_status()
+ data = response.json()
+
+ members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
+ if not members:
+ return None
+ obj = members[0].get("GeoObject", {})
+ try:
+ lon_str, lat_str = obj["Point"]["pos"].split()
+ snapped_lat = float(lat_str)
+ snapped_lon = float(lon_str)
+ except (KeyError, ValueError):
+ return None
+ meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {})
+ precision = str(meta.get("precision", "other"))
+ address_text = str(meta.get("text") or obj.get("name") or "")
+ # Yandex address text начинается с «Россия, Свердловская область, …» — режем prefix,
+ # оставляем «улица, дом, город» для consistency с Nominatim/cadastral.
+ if address_text:
+ # «Россия, Свердловская область, Екатеринбург, улица Малышева, 51»
+ # → «улица Малышева, 51, Екатеринбург» (drop country/oblast, swap city/street)
+ parts = [p.strip() for p in address_text.split(",") if p.strip()]
+ filtered = [
+ p for p in parts
+ if p not in {"Россия", "Свердловская область"}
+ and not p.startswith("городской округ")
+ ]
+ # Найдём locality (Екатеринбург / Берёзовский / …) и переставим в конец
+ locality = None
+ rest: list[str] = []
+ for p in filtered:
+ if locality is None and p in {"Екатеринбург"}:
+ locality = p
+ else:
+ rest.append(p)
+ if locality and rest:
+ address_text = ", ".join([*rest, locality])
+ else:
+ address_text = ", ".join(filtered)
+
+ if not address_text:
+ return None
+ return ReverseGeocodeResult(
+ address=address_text,
+ snapped_lat=snapped_lat,
+ snapped_lon=snapped_lon,
+ precision=precision,
+ provider="yandex",
+ )
+
+
+@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
+async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
+ """Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.
+
+ Nominatim возвращает координаты центра matched feature (building/way/node).
+ precision выводим из наличия `house_number` в addressdetails.
+ """
+ headers = {
+ "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
+ "Accept": "application/json",
+ "Accept-Language": "ru,en;q=0.8",
+ }
+ async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
+ response = await client.get(
+ "https://nominatim.openstreetmap.org/reverse",
+ params={
+ "lat": str(lat),
+ "lon": str(lon),
+ "format": "json",
+ "addressdetails": "1",
+ "zoom": "18",
+ },
+ )
+ response.raise_for_status()
+ data = response.json()
+ if not isinstance(data, dict) or "error" in data:
+ return None
+ addr = data.get("address")
+ address_text: str | None = None
+ if isinstance(addr, dict):
+ address_text = _format_reverse_address(addr)
+ if not address_text:
+ display = data.get("display_name")
+ if not display:
+ return None
+ address_text = str(display)
+ # snapped coords — то что вернул Nominatim (центр matched feature)
+ try:
+ snapped_lat = float(data["lat"])
+ snapped_lon = float(data["lon"])
+ except (KeyError, ValueError, TypeError):
+ snapped_lat, snapped_lon = lat, lon
+ has_house = isinstance(addr, dict) and bool(addr.get("house_number"))
+ precision = "number" if has_house else "street"
+ return ReverseGeocodeResult(
+ address=address_text,
+ snapped_lat=snapped_lat,
+ snapped_lon=snapped_lon,
+ precision=precision,
+ provider="nominatim",
+ )
+
+
+def _cadastral_reverse_sync_full(
+ db: Session, lat: float, lon: float, radius_m: int = 200
+) -> tuple[str, float, float] | None:
+ """Полный вариант cadastral reverse — возвращает (address, snapped_lat, snapped_lon).
+
+ Отдельная функция чтобы старый `_cadastral_reverse_sync` (только адрес)
+ остался backward-compatible — его держит `tests/services/test_cadastral_reverse.py`.
+ """
+ try:
+ row = db.execute(
+ text("""
+ WITH candidates AS (
+ SELECT cad_num, readable_address, lat, lon,
+ 111320.0 * sqrt(
+ pow(CAST(:lat AS double precision) - lat, 2) +
+ pow(
+ cos(radians(CAST(:lat AS double precision)))
+ * (CAST(:lon AS double precision) - lon), 2
+ )
+ ) AS dist_m
+ FROM gendesign_cad_buildings
+ WHERE lat BETWEEN CAST(:lat AS double precision) - 0.0025
+ AND CAST(:lat AS double precision) + 0.0025
+ AND lon BETWEEN CAST(:lon AS double precision) - 0.005
+ AND CAST(:lon AS double precision) + 0.005
+ AND readable_address !~* '(гараж|снт|садовод|товарищ|уч\\.)'
+ )
+ SELECT readable_address, lat, lon, dist_m
+ FROM candidates
+ WHERE dist_m < CAST(:radius AS double precision)
+ ORDER BY dist_m ASC
+ LIMIT 1
+ """),
+ {"lat": lat, "lon": lon, "radius": float(radius_m)},
+ ).first()
+ except Exception:
+ logger.warning(
+ "cadastral reverse full failed for (%.5f, %.5f)", lat, lon, exc_info=True
+ )
+ return None
+ if row is None:
+ return None
+ return (str(row.readable_address), float(row.lat), float(row.lon))
+
+
+async def reverse_geocode(
+ lat: float, lon: float, db: Session | None = None
+) -> ReverseGeocodeResult | None:
+ """Cadastral FDW → Yandex (если key) → Nominatim. Возвращает snapped coords.
+
+ Возвращает None если ни один источник не дал адрес. Endpoint
+ api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError
+ наверх — раньше Nominatim 403 → RetryError → FastAPI 500.
+
+ Snapped lat/lon — это центр matched здания (от provider'а), не echo
+ входных координат. Фронт по precision решает — двигать marker (exact/number)
+ или оставить на клике (street/locality).
+
+ db: если передан — cadastral lookup через gendesign_cad_buildings FDW (первый tier).
+ """
+ # 1. Cadastral FDW primary (без внешнего API, возвращает жилой дом not POI)
+ if db is not None:
+ cad = await asyncio.to_thread(_cadastral_reverse_sync_full, db, lat, lon)
+ if cad is not None:
+ address, snap_lat, snap_lon = cad
+ return ReverseGeocodeResult(
+ address=address,
+ snapped_lat=snap_lat,
+ snapped_lon=snap_lon,
+ precision="number", # cadastral row = здание с house number
+ provider="cadastral",
+ )
+
+ # 2. Yandex — основной источник snap'а (его Point.pos = центр здания)
+ if settings.yandex_geocoder_api_key:
+ try:
+ result = await _yandex_reverse(lat, lon, settings.yandex_geocoder_api_key)
+ if result is not None:
+ return result
+ except Exception:
+ logger.exception("yandex reverse failed for (%.5f, %.5f)", lat, lon)
+
+ # 3. Nominatim fallback (wrap to prevent 500 on ban/rate-limit)
+ try:
+ return await _nominatim_reverse(lat, lon)
+ except Exception:
+ logger.exception("nominatim reverse failed for (%.5f, %.5f)", lat, lon)
+ return None
+
+
+def snap_precision_useful(precision: str) -> bool:
+ """True если precision означает «нашли точное здание» — фронт двигает marker."""
+ return precision in _SNAP_PRECISIONS
diff --git a/tradein-mvp/backend/app/services/house_metadata.py b/tradein-mvp/backend/app/services/house_metadata.py
new file mode 100644
index 00000000..ff2d1f87
--- /dev/null
+++ b/tradein-mvp/backend/app/services/house_metadata.py
@@ -0,0 +1,278 @@
+"""House metadata service — обогащение данных дома по координатам (#392).
+
+Когда пользователь не указал год постройки / тип дома, подтягиваем данные
+из OpenStreetMap через Overpass API: building:levels, start_date, материал.
+
+Стратегия:
+- Cache lookup в `house_metadata` (Postgres) по близости координат — TTL 30 дней
+- Cache miss → Overpass API → парсинг тегов → запись в кэш
+- Best-effort: ЛЮБАЯ ошибка → None, оценка работает без обогащения (не 500-ит)
+
+Используется в estimator.estimate_quality перед house-match аналогов (#6).
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from dataclasses import dataclass
+
+import httpx
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+OVERPASS_URL = "https://overpass-api.de/api/interpreter"
+_CACHE_RADIUS_M = 40 # дом в пределах 40 м от точки — считаем тем же
+_OVERPASS_RADIUS_M = 25 # ищем здание в 25 м от геокодированной точки
+_CAD_RADIUS_M = 60 # радиус поиска здания в cad_buildings (#393)
+
+
+@dataclass(frozen=True, slots=True)
+class HouseMetadata:
+ lat: float
+ lon: float
+ year_built: int | None
+ total_floors: int | None
+ house_type: str | None # panel / brick / monolith / monolith_brick / other
+ total_units: int | None
+ source: str # 'osm' / 'cache'
+
+
+# ── Парсинг тегов OSM ────────────────────────────────────────────────────────
+_YEAR_KEYS = (
+ "start_date", "construction_date", "building:year",
+ "year_of_construction", "building:start_date",
+)
+
+
+def _parse_year(tags: dict[str, str]) -> int | None:
+ """Год постройки из OSM-тегов. Значение бывает '1975', '1975-06-01',
+ '1970s', '1960..1965', 'C20' — берём первое 4-значное число 1800–2100."""
+ for key in _YEAR_KEYS:
+ raw = tags.get(key)
+ if not raw:
+ continue
+ m = re.search(r"\b(1[89]\d\d|20\d\d)\b", raw)
+ if m:
+ year = int(m.group(1))
+ if 1800 <= year <= 2100:
+ return year
+ return None
+
+
+def _parse_int(tags: dict[str, str], *keys: str) -> int | None:
+ """Первое валидное целое (0 < n < 1000) из перечисленных тегов."""
+ for key in keys:
+ raw = (tags.get(key) or "").strip()
+ if raw.isdigit():
+ value = int(raw)
+ if 0 < value < 1000:
+ return value
+ return None
+
+
+# OSM building:material → наш house_type. Покрытие в OSM неполное — best-effort.
+_MATERIAL_TO_TYPE = {
+ "concrete_panels": "panel",
+ "panels": "panel",
+ "panel": "panel",
+ "brick": "brick",
+ "concrete": "monolith",
+ "reinforced_concrete": "monolith",
+}
+
+
+def _parse_house_type(tags: dict[str, str]) -> str | None:
+ material = (tags.get("building:material") or "").lower().strip()
+ return _MATERIAL_TO_TYPE.get(material)
+
+
+# ── Cache (house_metadata, TTL 30 дней) ──────────────────────────────────────
+def _cache_get(db: Session, lat: float, lon: float) -> HouseMetadata | None:
+ row = db.execute(
+ text(
+ """
+ SELECT lat, lon, year_built, total_floors, house_type, total_units
+ FROM house_metadata
+ WHERE expires_at > NOW()
+ AND ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
+ ORDER BY ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
+ LIMIT 1
+ """
+ ),
+ {"lat": lat, "lon": lon, "radius": _CACHE_RADIUS_M},
+ ).fetchone()
+ if row is None:
+ return None
+ return HouseMetadata(
+ lat=row.lat, lon=row.lon,
+ year_built=row.year_built, total_floors=row.total_floors,
+ house_type=row.house_type, total_units=row.total_units,
+ source="cache",
+ )
+
+
+def _cache_put(db: Session, meta: HouseMetadata, raw: dict) -> None:
+ db.execute(
+ text(
+ """
+ INSERT INTO house_metadata
+ (lat, lon, year_built, total_floors, house_type,
+ total_units, source, raw_payload)
+ VALUES (:lat, :lon, :year, :floors, :htype,
+ :units, 'osm', CAST(:raw AS jsonb))
+ """
+ ),
+ {
+ "lat": meta.lat, "lon": meta.lon,
+ "year": meta.year_built, "floors": meta.total_floors,
+ "htype": meta.house_type, "units": meta.total_units,
+ "raw": json.dumps(raw, ensure_ascii=False),
+ },
+ )
+ db.commit()
+
+
+# ── Источник #393: кадастр зданий (gendesign_cad_buildings FDW) ──────────────
+def _cad_buildings_get(db: Session, lat: float, lon: float) -> HouseMetadata | None:
+ """Ближайшее здание из gendesign_cad_buildings FDW (данные Росреестра) по координатам.
+
+ Приоритетный источник house_metadata — год / этажность полнее, чем OSM
+ (~36к домов ЕКБ с годом постройки). Использует bbox+haversine вместо ST_DWithin —
+ FDW-таблица не имеет PostGIS-колонки geom (только lat/lon скалярные).
+ """
+ try:
+ row = db.execute(
+ text(
+ """
+ WITH candidates AS (
+ SELECT year_built, floors, lat, lon,
+ 111320.0 * sqrt(
+ pow(CAST(:lat AS double precision) - lat, 2) +
+ pow(
+ cos(radians(CAST(:lat AS double precision)))
+ * (CAST(:lon AS double precision) - lon), 2
+ )
+ ) AS dist_m
+ FROM gendesign_cad_buildings
+ WHERE (year_built IS NOT NULL OR floors IS NOT NULL)
+ AND lat BETWEEN CAST(:lat AS double precision) - 0.0010
+ AND CAST(:lat AS double precision) + 0.0010
+ AND lon BETWEEN CAST(:lon AS double precision) - 0.0015
+ AND CAST(:lon AS double precision) + 0.0015
+ )
+ SELECT year_built, floors
+ FROM candidates
+ WHERE dist_m < CAST(:radius AS double precision)
+ ORDER BY dist_m ASC
+ LIMIT 1
+ """
+ ),
+ {"lat": lat, "lon": lon, "radius": float(_CAD_RADIUS_M)},
+ ).fetchone()
+ except Exception:
+ logger.warning(
+ "gendesign_cad_buildings lookup failed at (%.5f, %.5f)", lat, lon, exc_info=True
+ )
+ return None
+
+ if row is None:
+ return None
+ return HouseMetadata(
+ lat=lat, lon=lon,
+ year_built=row.year_built,
+ total_floors=row.floors,
+ house_type=None, # purpose не маппится в panel/brick
+ total_units=None,
+ source="cadastre",
+ )
+
+
+# ── Provider: OSM Overpass ───────────────────────────────────────────────────
+async def _overpass_lookup(lat: float, lon: float) -> tuple[HouseMetadata, dict] | None:
+ """Запрос здания у точки через Overpass API. (meta, raw_payload) или None."""
+ query = (
+ "[out:json][timeout:20];"
+ f'way(around:{_OVERPASS_RADIUS_M},{lat},{lon})["building"];'
+ "out tags center;"
+ )
+ headers = {"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})"}
+ async with httpx.AsyncClient(timeout=15.0, headers=headers) as client:
+ response = await client.post(OVERPASS_URL, data={"data": query})
+ response.raise_for_status()
+ data = response.json()
+
+ elements = [e for e in data.get("elements", []) if e.get("tags")]
+ if not elements:
+ return None
+
+ # Ближайшее здание (по center) к геокодированной точке.
+ def _dist2(el: dict) -> float:
+ center = el.get("center") or {}
+ return (center.get("lat", 0.0) - lat) ** 2 + (center.get("lon", 0.0) - lon) ** 2
+
+ best = min(elements, key=_dist2)
+ tags = best.get("tags", {})
+ meta = HouseMetadata(
+ lat=lat, lon=lon,
+ year_built=_parse_year(tags),
+ total_floors=_parse_int(tags, "building:levels"),
+ house_type=_parse_house_type(tags),
+ total_units=_parse_int(tags, "building:flats", "flats"),
+ source="osm",
+ )
+ return meta, {"osm_way_id": best.get("id"), "tags": tags}
+
+
+# ── Public API ───────────────────────────────────────────────────────────────
+async def get_house_metadata(lat: float, lon: float, db: Session) -> HouseMetadata | None:
+ """Метаданные дома по координатам: cache (TTL 30д) → Overpass.
+
+ Best-effort: при ЛЮБОЙ ошибке возвращает None и откатывает сессию —
+ оценка обязана работать даже если картография недоступна.
+ """
+ try:
+ # #393: кадастр зданий (Росреестр, cad_buildings) — приоритетный источник.
+ cad = _cad_buildings_get(db, lat, lon)
+ if cad is not None:
+ logger.info(
+ "house_metadata: cad_buildings (%.5f, %.5f) → year=%s floors=%s",
+ lat, lon, cad.year_built, cad.total_floors,
+ )
+ return cad
+
+ cached = _cache_get(db, lat, lon)
+ if cached is not None:
+ logger.info("house_metadata cache hit: (%.5f, %.5f)", lat, lon)
+ return cached
+
+ result = await _overpass_lookup(lat, lon)
+ if result is None:
+ logger.info("house_metadata: OSM не нашёл здание у (%.5f, %.5f)", lat, lon)
+ return None
+
+ meta, raw = result
+ # Пустой результат (ни года, ни этажности) кэшировать смысла нет.
+ if meta.year_built is None and meta.total_floors is None:
+ return None
+
+ _cache_put(db, meta, raw)
+ logger.info(
+ "house_metadata OSM: (%.5f, %.5f) → year=%s floors=%s type=%s",
+ lat, lon, meta.year_built, meta.total_floors, meta.house_type,
+ )
+ return meta
+ except Exception:
+ logger.warning(
+ "house_metadata enrichment failed at (%.5f, %.5f)", lat, lon, exc_info=True
+ )
+ try:
+ db.rollback()
+ except Exception:
+ pass
+ return None
diff --git a/tradein-mvp/backend/app/services/image_sanitizer.py b/tradein-mvp/backend/app/services/image_sanitizer.py
new file mode 100644
index 00000000..f2df5841
--- /dev/null
+++ b/tradein-mvp/backend/app/services/image_sanitizer.py
@@ -0,0 +1,70 @@
+"""Image sanitization for user-uploaded photos.
+
+Re-encodes inbound images through Pillow to:
+ - strip EXIF (GPS lat/lon, device fingerprint, owner metadata)
+ - normalize byte stream (a polyglot JPEG with appended payload survives MIME check
+ but is dropped on re-encode — only the actual image survives)
+ - enforce max dimension (defend against pixel-flood DoS in downstream renders)
+ - produce uniform JPEG output (smaller storage; no transparency support needed
+ for apartment photos)
+
+Closes finding #6 from 2026-05-24 trade-in audit.
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+
+from PIL import Image, UnidentifiedImageError
+
+logger = logging.getLogger(__name__)
+
+# Max longest edge after re-encode. 2400px = enough for retina apt photo,
+# small enough to bound storage & decode cost.
+_MAX_DIMENSION = 2400
+
+# JPEG quality. 85 is industry standard for photos — visually lossless,
+# ~70% size reduction vs original phone JPEGs.
+_JPEG_QUALITY = 85
+
+# Output content type after sanitization (all inputs transcoded to JPEG).
+SANITIZED_CONTENT_TYPE = "image/jpeg"
+
+
+class ImageSanitizationError(ValueError):
+ """Raised when inbound bytes cannot be decoded as an image."""
+
+
+def sanitize_image(content: bytes) -> tuple[bytes, str]:
+ """Decode arbitrary image bytes and re-encode as clean JPEG.
+
+ Args:
+ content: raw uploaded bytes from the client.
+
+ Returns:
+ (sanitized_bytes, "image/jpeg") — both ready for storage/serving.
+
+ Raises:
+ ImageSanitizationError: bytes are not a recognized image format.
+ """
+ try:
+ with Image.open(io.BytesIO(content)) as img:
+ img.load() # force full decode; surfaces UnidentifiedImageError early
+ # Convert any mode (RGBA, P, CMYK, ...) to RGB for JPEG output.
+ if img.mode != "RGB":
+ img = img.convert("RGB")
+ # Resize if oversized; preserve aspect ratio.
+ if max(img.size) > _MAX_DIMENSION:
+ img.thumbnail((_MAX_DIMENSION, _MAX_DIMENSION), Image.Resampling.LANCZOS)
+ buf = io.BytesIO()
+ # save() does NOT carry over .info / EXIF unless we pass exif=...; we don't.
+ img.save(buf, format="JPEG", quality=_JPEG_QUALITY, optimize=True)
+ return buf.getvalue(), SANITIZED_CONTENT_TYPE
+ except UnidentifiedImageError as e:
+ raise ImageSanitizationError("not a recognized image format") from e
+ except Exception as e:
+ # PIL can raise broad errors (DecompressionBombError, OSError on truncated, ...);
+ # normalize to one user-facing error type.
+ logger.warning("image sanitization failed: %s", e)
+ raise ImageSanitizationError(f"image decode failed: {e}") from e
diff --git a/tradein-mvp/backend/app/services/matching/__init__.py b/tradein-mvp/backend/app/services/matching/__init__.py
new file mode 100644
index 00000000..0f4a1924
--- /dev/null
+++ b/tradein-mvp/backend/app/services/matching/__init__.py
@@ -0,0 +1,19 @@
+"""Cross-source matching service — public API.
+
+Provides 3-tier deduplication for houses and listings across Avito, Cian, Yandex, etc.
+Algorithm reference: decisions/Cross_Source_Matching_Strategy.md
+"""
+
+from app.services.matching.conflict_resolution import update_canonical_fields
+from app.services.matching.houses import match_or_create_house
+from app.services.matching.listings import (
+ match_or_create_listing,
+ upsert_listing_source, # public helper for 'new' sentinel flow
+)
+
+__all__ = [
+ "match_or_create_house",
+ "match_or_create_listing",
+ "update_canonical_fields",
+ "upsert_listing_source",
+]
diff --git a/tradein-mvp/backend/app/services/matching/conflict_resolution.py b/tradein-mvp/backend/app/services/matching/conflict_resolution.py
new file mode 100644
index 00000000..cc3b3809
--- /dev/null
+++ b/tradein-mvp/backend/app/services/matching/conflict_resolution.py
@@ -0,0 +1,196 @@
+"""Per-field source priority for cross-source canonical merge.
+
+Direct port of Cross_Source_Matching_Strategy.md sec 3.5 (houses) + 4.5 (listings).
+Source-of-truth dicts read by merge logic in match_or_create_house/listing.
+
+Sources covered: avito (serp/detail/houses_catalog/domoteka/imv),
+cian (serp/bti/detail/stats/valuation), yandex (serp/detail/realty_nb/valuation).
+"""
+from __future__ import annotations
+
+from typing import Any
+
+# ---------------------------------------------------------------------------
+# HOUSE_FIELD_PRIORITY — vault sec 3.5
+# ---------------------------------------------------------------------------
+HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = {
+ "address": ["cian", "avito", "yandex"],
+ "lat": ["cian_serp", "avito_houses_catalog", "yandex_realty_nb"],
+ "lon": ["cian_serp", "avito_houses_catalog", "yandex_realty_nb"],
+ "year_built": [
+ "cian_bti", "cian_serp", "avito_houses_catalog", "yandex_valuation", "yandex_realty_nb",
+ ],
+ "house_type": ["cian_bti", "cian_serp", "avito", "yandex_valuation", "yandex_realty_nb"],
+ "series_name": ["cian_bti"],
+ "passenger_lifts_count": ["cian", "avito"],
+ "cargo_lifts_count": ["cian", "avito"],
+ "has_concierge": ["cian", "avito"],
+ "closed_yard": ["cian", "avito"],
+ "parking_type": ["cian"],
+ "flat_count": ["cian_bti"],
+ "entrances": ["cian_bti"],
+ "is_emergency": ["cian_bti"],
+ "management_company_id": ["cian_valuation"],
+ "house_class": ["avito_houses_catalog", "cian", "yandex_realty_nb"],
+ "rating_score": ["avito_houses_catalog", "cian", "yandex_realty_nb"],
+ "reviews_count": ["avito_houses_catalog", "cian", "yandex_realty_nb"],
+
+ # Yandex unique fields (newbuilding landing only)
+ "text_reviews_count": ["yandex_realty_nb"], # 353 text reviews — Yandex strongpoint
+ "corpus_count": ["yandex_realty_nb"], # "три башни" → 3
+ "total_area_ha": ["yandex_realty_nb"], # ЖК footprint
+ "commission_year": ["cian_serp", "yandex_realty_nb"],
+ "commission_month": ["yandex_realty_nb"], # raw RU month name
+ "developer_name": ["cian", "yandex_realty_nb"],
+ "has_panorama": ["yandex_valuation"], # Yandex 3D panorama flag
+ "yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории
+
+ # Yandex Valuation enrichment (existing house attrs)
+ "has_lift": ["cian_bti", "cian_detail", "yandex_valuation"],
+ "ceiling_height": ["cian_detail", "yandex_valuation"],
+}
+
+# ---------------------------------------------------------------------------
+# LISTING_FIELD_PRIORITY — vault sec 4.5
+# ---------------------------------------------------------------------------
+LISTING_FIELD_PRIORITY: dict[str, list[str] | str] = {
+ "address": ["cian_serp", "avito_detail", "avito_serp"],
+ "lat": ["cian_serp", "avito_detail"],
+ "lon": ["cian_serp", "avito_detail"],
+ "area_m2": ["cian_serp", "avito_detail"],
+ "living_area_m2": ["cian_serp"],
+ "kitchen_area_m2": ["cian_serp", "avito_detail"],
+ "ceiling_height": ["cian_detail"],
+ "floor": ["cian_serp", "avito_detail"],
+ "total_floors": ["cian_serp", "avito_detail"],
+ "year_built": ["cian_serp"],
+ "house_type": ["cian_serp", "avito_detail", "yandex_detail"],
+ "repair_state": ["cian_detail", "avito_detail"],
+ "has_balcony": ["cian_serp", "avito_detail"],
+ "balconies_count": ["cian_serp"],
+ "loggias_count": ["cian_serp"],
+ "windows_view_type": ["cian_detail", "avito_detail"],
+ "separate_wcs_count": ["cian_detail"],
+ "combined_wcs_count": ["cian_detail"],
+ "room_type": ["cian_detail", "avito_detail"],
+ "has_furniture": ["cian_serp", "avito_detail"],
+ "phones": ["cian_serp"],
+ "description": ["cian_serp", "avito_detail", "yandex_detail"],
+ "photo_urls": "union",
+
+ # Avito Domoteka unique
+ "owners_count": ["avito_domoteka"],
+ "owners_at_least": ["avito_domoteka"],
+ "last_owner_change_date": ["avito_domoteka"],
+ "encumbrances_clean": ["avito_domoteka"],
+ "registry_match": ["avito_domoteka"],
+
+ # Cian-only
+ "is_rosreestr_checked": ["cian_serp"],
+ "is_layout_approved": ["cian_serp"],
+ "is_commercial_ownership_verified": ["cian_serp"],
+
+ # Cross-validation
+ "price_rub": "cross_validate",
+ "kadastr_num": "first_non_null",
+ # NOTE: task description claims price_rub=['cian','avito','yandex']; vault sec 4.5
+ # says 'cross_validate' (flag if diff > 10%). Following vault — change to list if
+ # cross_validate semantics aren't desired at caller.
+
+ # Yandex unique (agency block — OfferCardAuthorInfo)
+ "agency_name": ["yandex_detail"],
+ "agency_founded_year": ["yandex_detail"],
+ "agency_objects_count": ["yandex_detail"],
+
+ # Yandex parallel views column (NOT existing `views_total` which is Cian's)
+ "views_total_yandex": ["yandex_detail"],
+
+ # Yandex raw publish-date text (relative form)
+ "publish_date_relative": ["yandex_detail"],
+
+ # Yandex raw RU sale-type phrase (vs existing `sale_type` enum)
+ "sale_type_text": ["yandex_detail"],
+}
+
+
+def _resolve(
+ priority: dict[str, list[str] | str],
+ field: str,
+ candidates: dict[str, Any],
+) -> Any:
+ """Pick value from candidates per priority dict semantics."""
+ if not candidates:
+ return None
+ rule = priority.get(field, "first_non_null")
+
+ if isinstance(rule, str):
+ if rule == "union":
+ out: list[Any] = []
+ for v in candidates.values():
+ if v is None:
+ continue
+ if isinstance(v, (list, tuple, set)):
+ out.extend(v)
+ else:
+ out.append(v)
+ # Preserve order, dedup
+ seen: set[Any] = set()
+ uniq: list[Any] = []
+ for x in out:
+ key = repr(x)
+ if key in seen:
+ continue
+ seen.add(key)
+ uniq.append(x)
+ return uniq
+ if rule == "first_non_null":
+ for v in candidates.values():
+ if v is not None:
+ return v
+ return None
+ if rule == "max":
+ non_null = [v for v in candidates.values() if v is not None]
+ return max(non_null) if non_null else None
+ if rule == "min":
+ non_null = [v for v in candidates.values() if v is not None]
+ return min(non_null) if non_null else None
+ if rule == "cross_validate":
+ # Caller decides — return median by default
+ nums = [v for v in candidates.values() if isinstance(v, (int, float))]
+ if not nums:
+ return None
+ nums.sort()
+ return nums[len(nums) // 2]
+ # Unknown rule
+ return next((v for v in candidates.values() if v is not None), None)
+
+ # Priority list: pick value from highest-ranked source present (non-null)
+ for src in rule:
+ if src in candidates and candidates[src] is not None:
+ return candidates[src]
+ # Fallback: any non-null
+ return next((v for v in candidates.values() if v is not None), None)
+
+
+def resolve_house_field(field: str, candidates: dict[str, Any]) -> Any:
+ """Pick canonical house field value per HOUSE_FIELD_PRIORITY."""
+ return _resolve(HOUSE_FIELD_PRIORITY, field, candidates)
+
+
+def resolve_listing_field(field: str, candidates: dict[str, Any]) -> Any:
+ """Pick canonical listing field value per LISTING_FIELD_PRIORITY."""
+ return _resolve(LISTING_FIELD_PRIORITY, field, candidates)
+
+
+# ---------------------------------------------------------------------------
+# Legacy stub — kept for backward compat with existing __init__.py and tests
+# ---------------------------------------------------------------------------
+
+def update_canonical_fields(
+ db: Any,
+ listing_id: int,
+ ext_source: str,
+ lot_data: object,
+) -> None:
+ """Legacy Stage 8 v1 stub — full arbitration deferred to Stage 8.x."""
+ pass
diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py
new file mode 100644
index 00000000..64a1fa67
--- /dev/null
+++ b/tradein-mvp/backend/app/services/matching/houses.py
@@ -0,0 +1,233 @@
+"""House cross-source matching — 3-tier algorithm.
+
+Tier 0 (confidence 1.0): cadastral_number exact match on houses table.
+Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources.
+Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases.
+Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin).
+New (confidence 1.0): INSERT new canonical house.
+
+Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3
+"""
+
+import logging
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.services.matching.normalize import address_fingerprint, normalize_address
+
+logger = logging.getLogger(__name__)
+
+
+def match_or_create_house(
+ db: Session,
+ ext_source: str,
+ ext_id: str,
+ address: str | None = None,
+ lat: float | None = None,
+ lon: float | None = None,
+ *,
+ year_built: int | None = None,
+ building_cadastral_number: str | None = None,
+ cadastral_number: str | None = None,
+ source_url: str | None = None,
+) -> tuple[int, float, str]:
+ """Match existing house or create new canonical record.
+
+ Concurrency-safe: serializes concurrent calls for the same address fingerprint
+ via pg_advisory_xact_lock(42, hashtext(fp)). The lock is transaction-scoped,
+ released on the caller's COMMIT/ROLLBACK. Without this, two scrapers calling
+ for an unknown address could both miss Tier 0-3 and each INSERT a duplicate
+ house row. Closes finding #1 from 2026-05-24 audit.
+
+ Returns:
+ (house_id, confidence ∈ [0.0, 1.0], method ∈ {
+ 'cadastr_exact', 'source_exact', 'fingerprint', 'geo_proximity', 'new'
+ })
+
+ Method values:
+ 'cadastr_exact' — matched by cadastral number (confidence 1.0)
+ 'source_exact' — already in house_sources for this source+ext_id (confidence 1.0)
+ 'fingerprint' — matched by address fingerprint (confidence 0.9)
+ 'geo_proximity' — matched by geo within 30 m (confidence 0.7)
+ 'new' — new house created (confidence 1.0)
+ """
+ cad = building_cadastral_number or cadastral_number
+
+ # Compute fingerprint early so we can acquire the advisory lock before any tier reads.
+ fp = address_fingerprint(address, lat, lon)
+
+ # Serialize concurrent calls for the same address fingerprint to prevent
+ # racing INSERTs into houses (finding #1 from 2026-05-24 audit).
+ # pg_advisory_xact_lock takes (int4, int4) — namespace 42 = "tradein house matching".
+ # Lock is released automatically on caller's COMMIT/ROLLBACK.
+ db.execute(
+ text('SELECT pg_advisory_xact_lock(42, hashtext(:fp))'),
+ {'fp': fp},
+ )
+
+ # Tier 0: cadastral number exact match
+ if cad:
+ row = db.execute(
+ text('SELECT id FROM houses WHERE cadastral_number = :cad ORDER BY id ASC LIMIT 1'),
+ {'cad': cad},
+ ).mappings().first()
+ if row:
+ house_id = int(row['id'])
+ _upsert_house_source(
+ db, house_id=house_id, ext_source=ext_source, ext_id=ext_id,
+ method='cadastr_exact', confidence=1.0,
+ )
+ logger.info('house match cadastr_exact house_id=%s cad=%s', house_id, cad)
+ return (house_id, 1.0, 'cadastr_exact')
+
+ # Tier 1: source+ext_id already registered in house_sources
+ row = db.execute(
+ text(
+ 'SELECT house_id FROM house_sources '
+ 'WHERE ext_source = :s AND ext_id = :e LIMIT 1'
+ ),
+ {'s': ext_source, 'e': str(ext_id)},
+ ).mappings().first()
+ if row:
+ house_id = int(row['house_id'])
+ logger.info('house match source_exact house_id=%s src=%s ext_id=%s', house_id, ext_source,
+ ext_id)
+ return (house_id, 1.0, 'source_exact')
+
+ # Tier 2: address fingerprint lookup
+ row = db.execute(
+ text(
+ 'SELECT house_id FROM house_address_aliases '
+ 'WHERE fingerprint = :fp LIMIT 1'
+ ),
+ {'fp': fp},
+ ).mappings().first()
+ if row:
+ house_id = int(row['house_id'])
+ _upsert_house_source(
+ db, house_id=house_id, ext_source=ext_source, ext_id=ext_id,
+ method='fingerprint', confidence=0.9,
+ )
+ logger.info('house match fingerprint house_id=%s fp=%s', house_id, fp)
+ return (house_id, 0.9, 'fingerprint')
+
+ # Tier 3: geo-proximity — within 30 m (PostGIS geography cast on both sides)
+ if lat is not None and lon is not None:
+ row = db.execute(
+ text("""
+ SELECT id,
+ ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS dist
+ FROM houses
+ WHERE geom IS NOT NULL
+ AND ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, 30)
+ ORDER BY dist ASC
+ LIMIT 1
+ """),
+ {'lat': lat, 'lon': lon},
+ ).mappings().first()
+ if row:
+ house_id = int(row['id'])
+ _upsert_house_source(
+ db, house_id=house_id, ext_source=ext_source, ext_id=ext_id,
+ method='geo_proximity', confidence=0.7,
+ )
+ # Register fingerprint alias so future calls skip geo lookup
+ _insert_alias(db, house_id=house_id, address=address, fp=fp, source=ext_source)
+ logger.info(
+ 'house match geo_proximity house_id=%s dist=%.1f src=%s',
+ house_id, float(row['dist']), ext_source,
+ )
+ return (house_id, 0.7, 'geo_proximity')
+
+ # New house — INSERT canonical record.
+ # geom column is auto-populated by houses_set_geom_trg BEFORE INSERT trigger from lat/lon.
+ # Do NOT include geom in the INSERT column list — trigger handles it.
+ url = source_url or f'matching://{ext_source}/{ext_id}'
+ row = db.execute(
+ text("""
+ INSERT INTO houses (source, ext_house_id, url, address, lat, lon, year_built,
+ cadastral_number)
+ VALUES (
+ :src, :eid, :url,
+ :addr,
+ CAST(:lat AS double precision),
+ CAST(:lon AS double precision),
+ CAST(:yb AS integer),
+ :cad
+ )
+ ON CONFLICT (source, ext_house_id) DO UPDATE SET
+ address = COALESCE(EXCLUDED.address, houses.address)
+ RETURNING id
+ """),
+ {
+ 'src': ext_source,
+ 'eid': str(ext_id),
+ 'url': url,
+ 'addr': address,
+ 'lat': lat,
+ 'lon': lon,
+ 'yb': year_built,
+ 'cad': cad,
+ },
+ ).mappings().one()
+ house_id = int(row['id'])
+
+ _upsert_house_source(
+ db, house_id=house_id, ext_source=ext_source, ext_id=ext_id,
+ method='new', confidence=1.0,
+ )
+ _insert_alias(db, house_id=house_id, address=address, fp=fp, source=ext_source)
+
+ logger.info('house new house_id=%s addr=%r src=%s', house_id, address, ext_source)
+ return (house_id, 1.0, 'new')
+
+
+def _upsert_house_source(
+ db: Session,
+ *,
+ house_id: int,
+ ext_source: str,
+ ext_id: str,
+ method: str,
+ confidence: float,
+) -> None:
+ """Insert or refresh house_sources row for this source+ext_id."""
+ db.execute(
+ text("""
+ INSERT INTO house_sources (
+ house_id, ext_source, ext_id, confidence, matched_method, matched_at, last_seen_at
+ ) VALUES (
+ CAST(:hid AS bigint), :s, :e,
+ CAST(:c AS real), :m, NOW(), NOW()
+ )
+ ON CONFLICT (ext_source, ext_id) DO UPDATE SET
+ confidence = GREATEST(EXCLUDED.confidence, house_sources.confidence),
+ last_seen_at = NOW()
+ """),
+ {'hid': house_id, 's': ext_source, 'e': str(ext_id), 'c': confidence, 'm': method},
+ )
+
+
+def _insert_alias(
+ db: Session,
+ *,
+ house_id: int,
+ address: str | None,
+ fp: str,
+ source: str,
+) -> None:
+ """Register normalized_address alias for this house (idempotent on normalized_address)."""
+ db.execute(
+ text("""
+ INSERT INTO house_address_aliases (house_id, normalized_address, fingerprint, source)
+ VALUES (CAST(:hid AS bigint), :na, :fp, :src)
+ ON CONFLICT (normalized_address) DO NOTHING
+ """),
+ {
+ 'hid': house_id,
+ 'na': normalize_address(address),
+ 'fp': fp,
+ 'src': source,
+ },
+ )
diff --git a/tradein-mvp/backend/app/services/matching/listings.py b/tradein-mvp/backend/app/services/matching/listings.py
new file mode 100644
index 00000000..733759ad
--- /dev/null
+++ b/tradein-mvp/backend/app/services/matching/listings.py
@@ -0,0 +1,237 @@
+"""Listing cross-source matching — 3-tier algorithm.
+
+Tier 0 (confidence 1.0): cadastral_number exact match on listings table.
+Tier 1 (confidence 1.0): ext_source + ext_id already in listing_sources.
+Tier 2 (confidence 0.85): description_minhash exact match within same house.
+Tier 3 (confidence 0.75): composite match — house + floor + area ±2% + rooms.
+New (sentinel 0): caller is responsible for INSERT + call _upsert_listing_source.
+
+Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 4
+"""
+
+import json
+import logging
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+logger = logging.getLogger(__name__)
+
+
+def match_or_create_listing(
+ db: Session,
+ ext_source: str,
+ ext_id: str,
+ house_id: int,
+ floor: int | None = None,
+ rooms_count: int | None = None,
+ area_m2: float | None = None,
+ price_rub: float | None = None,
+ *,
+ cadastral_number: str | None = None,
+ description_minhash: str | None = None,
+ source_url: str | None = None,
+ source_data: dict | None = None,
+) -> tuple[int, float, str]:
+ """Match existing listing or signal that caller should create a new one.
+
+ Returns:
+ (listing_id, confidence in [0.0, 1.0], method name)
+
+ Method values:
+ 'cadastr_exact' — matched by cadastral number (confidence 1.0)
+ 'source_exact' — already in listing_sources for this source+ext_id (confidence 1.0)
+ 'minhash' — matched by description minhash within house (confidence 0.85)
+ 'composite' — matched by house+floor+area±2%+rooms (confidence 0.75)
+ 'new' — no match; caller must INSERT listing then call upsert_listing_source()
+
+ When method == 'new', listing_id is 0 (sentinel). Caller flow:
+ listing_id, conf, method = match_or_create_listing(db, ...)
+ if method == 'new':
+ listing_id =
+ upsert_listing_source(db, listing_id=listing_id, ...)
+ """
+ # Tier 0: cadastral number exact match
+ if cadastral_number:
+ row = db.execute(
+ text(
+ 'SELECT id FROM listings WHERE cadastral_number = :cad ORDER BY id ASC LIMIT 1'
+ ),
+ {'cad': cadastral_number},
+ ).mappings().first()
+ if row:
+ listing_id = int(row['id'])
+ _upsert_listing_source(
+ db, listing_id=listing_id, ext_source=ext_source, ext_id=ext_id,
+ method='cadastr_exact', confidence=1.0,
+ price_rub=price_rub, area_m2=area_m2, floor=floor,
+ rooms_count=rooms_count, source_url=source_url, source_data=source_data,
+ )
+ logger.info('listing match cadastr_exact id=%s cad=%s', listing_id, cadastral_number)
+ return (listing_id, 1.0, 'cadastr_exact')
+
+ # Tier 1: source+ext_id already registered
+ row = db.execute(
+ text(
+ 'SELECT listing_id FROM listing_sources '
+ 'WHERE ext_source = :s AND ext_id = :e LIMIT 1'
+ ),
+ {'s': ext_source, 'e': str(ext_id)},
+ ).mappings().first()
+ if row:
+ listing_id = int(row['listing_id'])
+ logger.info('listing match source_exact id=%s src=%s ext_id=%s', listing_id, ext_source,
+ ext_id)
+ return (listing_id, 1.0, 'source_exact')
+
+ # Tier 2: description minhash match within same house
+ # Stage 8 v1: exact match on pre-computed minhash string.
+ # Future (Stage 8.x): replace with datasketch MinHash LSH for approximate Jaccard.
+ if description_minhash:
+ row = db.execute(
+ text("""
+ SELECT id FROM listings
+ WHERE house_id = CAST(:hid AS bigint)
+ AND description_minhash = :hash
+ LIMIT 1
+ """),
+ {'hid': house_id, 'hash': description_minhash},
+ ).mappings().first()
+ if row:
+ listing_id = int(row['id'])
+ _upsert_listing_source(
+ db, listing_id=listing_id, ext_source=ext_source, ext_id=ext_id,
+ method='minhash', confidence=0.85,
+ price_rub=price_rub, area_m2=area_m2, floor=floor,
+ rooms_count=rooms_count, source_url=source_url, source_data=source_data,
+ )
+ logger.info('listing match minhash id=%s hash=%s', listing_id, description_minhash)
+ return (listing_id, 0.85, 'minhash')
+
+ # Tier 3: composite match — house + floor + area ±2% + rooms
+ if house_id and floor is not None and area_m2 is not None and rooms_count is not None:
+ row = db.execute(
+ text("""
+ SELECT id,
+ ABS(area_m2 - CAST(:area AS numeric)) AS diff
+ FROM listings
+ WHERE house_id = CAST(:hid AS bigint)
+ AND floor = :fl
+ AND rooms = :rc
+ AND area_m2 BETWEEN CAST(:area AS numeric) * 0.98
+ AND CAST(:area AS numeric) * 1.02
+ ORDER BY diff ASC
+ LIMIT 1
+ """),
+ {'hid': house_id, 'fl': floor, 'rc': rooms_count, 'area': area_m2},
+ ).mappings().first()
+ if row:
+ listing_id = int(row['id'])
+ _upsert_listing_source(
+ db, listing_id=listing_id, ext_source=ext_source, ext_id=ext_id,
+ method='composite', confidence=0.75,
+ price_rub=price_rub, area_m2=area_m2, floor=floor,
+ rooms_count=rooms_count, source_url=source_url, source_data=source_data,
+ )
+ logger.info(
+ 'listing match composite id=%s house=%s floor=%s area=%.1f rooms=%s',
+ listing_id, house_id, floor, area_m2, rooms_count,
+ )
+ return (listing_id, 0.75, 'composite')
+
+ # No match — signal caller to create new listing
+ return (0, 1.0, 'new')
+
+
+def upsert_listing_source(
+ db: Session,
+ *,
+ listing_id: int,
+ ext_source: str,
+ ext_id: str,
+ method: str,
+ confidence: float,
+ price_rub: float | None = None,
+ area_m2: float | None = None,
+ floor: int | None = None,
+ rooms_count: int | None = None,
+ source_url: str | None = None,
+ source_data: dict | None = None,
+) -> None:
+ """Public helper — register or refresh a listing_sources row.
+
+ Called by external code after creating a new canonical listing.
+ """
+ _upsert_listing_source(
+ db,
+ listing_id=listing_id,
+ ext_source=ext_source,
+ ext_id=ext_id,
+ method=method,
+ confidence=confidence,
+ price_rub=price_rub,
+ area_m2=area_m2,
+ floor=floor,
+ rooms_count=rooms_count,
+ source_url=source_url,
+ source_data=source_data,
+ )
+
+
+def _upsert_listing_source(
+ db: Session,
+ *,
+ listing_id: int,
+ ext_source: str,
+ ext_id: str,
+ method: str,
+ confidence: float,
+ price_rub: float | None,
+ area_m2: float | None,
+ floor: int | None,
+ rooms_count: int | None,
+ source_url: str | None,
+ source_data: dict | None,
+) -> None:
+ """Insert or refresh listing_sources row for this source+ext_id."""
+ raw = json.dumps(source_data) if source_data is not None else None
+ db.execute(
+ text("""
+ INSERT INTO listing_sources (
+ listing_id, ext_source, ext_id,
+ confidence, matched_method, matched_at, last_seen_at,
+ source_url, last_scraped_at,
+ price_rub, area_m2, floor, rooms_count, raw_payload
+ ) VALUES (
+ CAST(:lid AS bigint), :s, :e,
+ CAST(:c AS real), :m, NOW(), NOW(),
+ :url, NOW(),
+ CAST(:p AS bigint), CAST(:a AS numeric), :fl, :rc,
+ CAST(:raw AS jsonb)
+ )
+ ON CONFLICT (ext_source, ext_id) DO UPDATE SET
+ confidence = GREATEST(EXCLUDED.confidence, listing_sources.confidence),
+ last_seen_at = NOW(),
+ last_scraped_at = NOW(),
+ price_rub = COALESCE(EXCLUDED.price_rub, listing_sources.price_rub),
+ area_m2 = COALESCE(EXCLUDED.area_m2, listing_sources.area_m2),
+ floor = COALESCE(EXCLUDED.floor, listing_sources.floor),
+ rooms_count = COALESCE(EXCLUDED.rooms_count, listing_sources.rooms_count),
+ raw_payload = COALESCE(EXCLUDED.raw_payload, listing_sources.raw_payload)
+ """),
+ {
+ 'lid': listing_id,
+ 's': ext_source,
+ 'e': str(ext_id),
+ 'c': confidence,
+ 'm': method,
+ 'url': source_url,
+ # Migration 029 declares listing_sources.price_rub bigint — whole rubles only,
+ # kopecks truncated. Document expectation in caller.
+ 'p': int(price_rub) if price_rub is not None else None,
+ 'a': area_m2,
+ 'fl': floor,
+ 'rc': rooms_count,
+ 'raw': raw,
+ },
+ )
diff --git a/tradein-mvp/backend/app/services/matching/normalize.py b/tradein-mvp/backend/app/services/matching/normalize.py
new file mode 100644
index 00000000..53c6c994
--- /dev/null
+++ b/tradein-mvp/backend/app/services/matching/normalize.py
@@ -0,0 +1,76 @@
+"""Address normalization and fingerprint utilities.
+
+Used by 3-tier matching to produce stable, source-independent keys.
+Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3.2
+"""
+
+import hashlib
+import re
+import unicodedata
+
+# Strip punctuation EXCEPT hyphens — keep hyphens so abbreviation rules like
+# 'пр-кт', 'б-р', 'пр-д' can match before they are collapsed to spaces.
+_PUNCT = re.compile(r'[^\w\s\-]', flags=re.UNICODE)
+_WS = re.compile(r'\s+')
+
+# Abbreviation expansion — applied after lowercasing, bounded by spaces.
+# Order matters: longer/more-specific patterns first to avoid partial overlap.
+# Hyphenated forms ('пр-кт', 'б-р', 'пр-д') come first so they match before
+# the shorter variants ('пр', 'б') would erroneously consume the prefix.
+_ABBREV = [
+ (' пр-кт ', ' проспект '),
+ (' пр-кт. ', ' проспект '),
+ (' б-р ', ' бульвар '),
+ (' пр-д ', ' проезд '),
+ (' ш ', ' шоссе '),
+ (' ул ', ' улица '),
+ (' ул. ', ' улица '),
+ (' пр ', ' проспект '),
+ (' пр. ', ' проспект '),
+ (' пер ', ' переулок '),
+ (' пл ', ' площадь '),
+ (' наб ', ' набережная '),
+ (' бул ', ' бульвар '),
+ (' туп ', ' тупик '),
+ (' стр ', ' строение '),
+ (' корп ', ' корпус '),
+ (' д ', ' дом '),
+ (' к ', ' корпус '),
+]
+
+
+def normalize_address(text: str | None) -> str:
+ """Normalize address string for cross-source comparison.
+
+ Steps:
+ 1. Unicode NFC normalization
+ 2. Lowercase
+ 3. Strip punctuation except hyphens (preserve 'пр-кт', 'б-р', 'пр-д' for expansion)
+ 4. Collapse whitespace
+ 5. Expand common Russian address abbreviations (hyphenated forms first)
+ 6. Collapse remaining hyphens to spaces
+ """
+ if not text:
+ return ""
+ s = unicodedata.normalize('NFC', text).lower()
+ s = _PUNCT.sub(' ', s) # strip punctuation EXCEPT hyphens
+ s = _WS.sub(' ', s).strip()
+ # Wrap with spaces for clean boundary matching
+ s = f' {s} '
+ for short, full in _ABBREV:
+ s = s.replace(short, full)
+ # Collapse remaining hyphens (e.g. standalone '-' separators or numeric ranges)
+ s = s.replace('-', ' ')
+ return _WS.sub(' ', s).strip()
+
+
+def address_fingerprint(address: str | None, lat: float | None, lon: float | None) -> str:
+ """SHA-256 fingerprint of normalized address + rounded coordinates (4 dp ≈ 11 m).
+
+ Returns first 32 hex chars of the digest (128 bits — collision-safe for millions of rows).
+ """
+ norm_addr = normalize_address(address or '')
+ lat_r = f'{lat:.4f}' if lat is not None else ''
+ lon_r = f'{lon:.4f}' if lon is not None else ''
+ key = f'{norm_addr}|{lat_r}|{lon_r}'
+ return hashlib.sha256(key.encode('utf-8')).hexdigest()[:32]
diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py
new file mode 100644
index 00000000..9469d51f
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scheduler.py
@@ -0,0 +1,203 @@
+"""In-app scheduler — periodically triggers configured scrapes based on scrape_schedules table.
+
+Запускается в FastAPI lifespan (app.main lifespan context manager) при startup.
+Логика:
+1. Каждые 60 sec query scrape_schedules WHERE enabled=true AND next_run_at <= NOW().
+2. Для каждого: проверить нет ли уже running run этого source — если нет, trigger.
+3. После trigger — recompute next_run_at в следующих суток.
+4. На old runs (orphaned status='running' > 6h без heartbeat) — пометить как 'zombie'.
+"""
+from __future__ import annotations
+
+import asyncio
+import logging
+import random
+from datetime import UTC, datetime, time, timedelta
+from typing import Any
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.core.db import SessionLocal
+from app.services import scrape_runs as runs_mod
+from app.services.scrape_pipeline import run_avito_city_sweep
+
+logger = logging.getLogger(__name__)
+
+# Loop interval — check каждую минуту
+SCHEDULER_TICK_SEC = 60
+ZOMBIE_THRESHOLD_HOURS = 6
+
+
+def compute_next_run_at(
+ window_start_hour: int, window_end_hour: int, *, now: datetime | None = None
+) -> datetime:
+ """Pick random datetime в window [start, end) UTC, для СЛЕДУЮЩИХ суток после now.
+
+ Если window_end_hour <= window_start_hour → cross-midnight window
+ (например 22→3 → окно 22:00-23:59 ИЛИ 00:00-02:59).
+ """
+ now = now or datetime.now(tz=UTC)
+ tomorrow = (now + timedelta(days=1)).date()
+
+ if window_end_hour > window_start_hour:
+ # Обычное окно (например 2..5 → 02:00-04:59)
+ start_seconds = window_start_hour * 3600
+ end_seconds = window_end_hour * 3600
+ rand_seconds = random.randint(start_seconds, end_seconds - 1)
+ return datetime.combine(tomorrow, time(0, 0), tzinfo=UTC) + timedelta(
+ seconds=rand_seconds
+ )
+ else:
+ # Cross-midnight (22..3 → 22:00-23:59 + 00:00-02:59)
+ # Длина окна = (24-start) + end часов
+ total_seconds = ((24 - window_start_hour) + window_end_hour) * 3600
+ rand_seconds = random.randint(0, total_seconds - 1)
+ # Если rand попадает в первую часть (start..24)
+ first_half = (24 - window_start_hour) * 3600
+ if rand_seconds < first_half:
+ # Текущая дата (если ещё не наступило окно) или next day
+ base_date = now.date() if now.hour < window_start_hour else tomorrow
+ return datetime.combine(base_date, time(0, 0), tzinfo=UTC) + timedelta(
+ seconds=window_start_hour * 3600 + rand_seconds
+ )
+ else:
+ # Во второй части (0..end), следующего дня
+ offset = rand_seconds - first_half
+ return datetime.combine(tomorrow, time(0, 0), tzinfo=UTC) + timedelta(seconds=offset)
+
+
+def has_running_run(db: Session, source: str) -> bool:
+ """Есть ли активный run для source (status='running')."""
+ row = db.execute(
+ text(
+ """
+ SELECT 1 FROM scrape_runs
+ WHERE source = :source AND status = 'running'
+ LIMIT 1
+ """
+ ),
+ {"source": source},
+ ).fetchone()
+ return row is not None
+
+
+def reap_zombies(db: Session) -> int:
+ """Mark scrape_runs as 'zombie' если heartbeat не обновлялся > ZOMBIE_THRESHOLD_HOURS hours."""
+ zombie_interval = f"{ZOMBIE_THRESHOLD_HOURS} hours"
+ result = db.execute(
+ text(
+ """
+ UPDATE scrape_runs
+ SET status = 'zombie', finished_at = NOW()
+ WHERE status = 'running'
+ AND (heartbeat_at IS NULL
+ OR heartbeat_at < NOW() - CAST(:interval AS interval))
+ RETURNING id
+ """
+ ),
+ {"interval": zombie_interval},
+ )
+ rows = result.fetchall()
+ db.commit()
+ if rows:
+ logger.warning("scheduler: reaped %d zombie runs: %s", len(rows), [r.id for r in rows])
+ return len(rows)
+
+
+async def trigger_avito_city_sweep_run(db: Session, schedule_row: dict[str, Any]) -> int | None:
+ """Создать новый scrape_runs + launch run_avito_city_sweep в asyncio.create_task.
+
+ Returns run_id (или None если skip — есть running run).
+ """
+ if has_running_run(db, schedule_row["source"]):
+ logger.info("scheduler: skip — already running for source=%s", schedule_row["source"])
+ return None
+
+ params = schedule_row.get("default_params") or {}
+ run_id = runs_mod.create_run(db, source=schedule_row["source"], params=params)
+
+ # Update schedule: last_run_id, last_run_at, recompute next_run_at
+ next_at = compute_next_run_at(
+ schedule_row["window_start_hour"],
+ schedule_row["window_end_hour"],
+ )
+ db.execute(
+ text(
+ """
+ UPDATE scrape_schedules
+ SET last_run_id = :run_id, last_run_at = NOW(),
+ next_run_at = :next_at, updated_at = NOW()
+ WHERE source = :source
+ """
+ ),
+ {"run_id": run_id, "next_at": next_at, "source": schedule_row["source"]},
+ )
+ db.commit()
+
+ # Spawn asyncio task — pass NEW session (avoid sharing with this scheduler tick)
+ async def _run() -> None:
+ run_db = SessionLocal()
+ try:
+ await run_avito_city_sweep(
+ run_db,
+ run_id=run_id,
+ pages_per_anchor=int(params.get("pages_per_anchor", 3)),
+ detail_top_n=int(params.get("detail_top_n", 20)),
+ request_delay_sec=float(params.get("request_delay_sec", 7.0)),
+ enrich_houses=bool(params.get("enrich_houses", True)),
+ radius_m=int(params.get("radius_m", 1500)),
+ )
+ except Exception:
+ logger.exception("scheduler: run_avito_city_sweep crashed run_id=%d", run_id)
+ finally:
+ run_db.close()
+
+ task = asyncio.create_task(_run())
+ # Keep reference to avoid GC before task completes (RUF006)
+ task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
+ logger.info(
+ "scheduler: triggered city_sweep run_id=%d next_run_at=%s", run_id, next_at.isoformat()
+ )
+ return run_id
+
+
+def get_due_schedules(db: Session) -> list[dict[str, Any]]:
+ """SELECT scrape_schedules WHERE enabled AND (next_run_at IS NULL OR next_run_at <= NOW())."""
+ rows = db.execute(
+ text(
+ """
+ SELECT id, source, enabled, window_start_hour, window_end_hour,
+ default_params, last_run_id, last_run_at, next_run_at
+ FROM scrape_schedules
+ WHERE enabled = true
+ AND (next_run_at IS NULL OR next_run_at <= NOW())
+ """
+ ),
+ ).mappings().all()
+ return [dict(r) for r in rows]
+
+
+async def scheduler_loop() -> None:
+ """Бесконечный async loop — tick каждые SCHEDULER_TICK_SEC секунд."""
+ logger.info("scheduler: started (tick=%ds)", SCHEDULER_TICK_SEC)
+ # Initial sleep 30s чтобы дать FastAPI startup завершиться
+ await asyncio.sleep(30)
+ while True:
+ try:
+ db = SessionLocal()
+ try:
+ # Reap zombies first
+ reap_zombies(db)
+ # Process due schedules
+ due = get_due_schedules(db)
+ for sch in due:
+ if sch["source"] == "avito_city_sweep":
+ await trigger_avito_city_sweep_run(db, sch)
+ else:
+ logger.warning("scheduler: unknown source=%s, skip", sch["source"])
+ finally:
+ db.close()
+ except Exception:
+ logger.exception("scheduler: tick failed")
+ await asyncio.sleep(SCHEDULER_TICK_SEC)
diff --git a/tradein-mvp/backend/app/services/scrape_pipeline.py b/tradein-mvp/backend/app/services/scrape_pipeline.py
new file mode 100644
index 00000000..a6198a60
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrape_pipeline.py
@@ -0,0 +1,435 @@
+"""scrape_pipeline.py — Avito full orchestrator (Stage 2e + city sweep).
+
+Координирует 5 шагов одного pipeline run:
+ 1. SEARCH — AvitoScraper().fetch_around(lat, lon, radius_m, pages=N)
+ 2. SAVE — save_listings(db, lots) → (inserted, updated)
+ 3. GROUP — dedupe house_url из lots → unique set
+ 4. ENRICH_HOUSES — fetch_house_catalog + save_house_catalog_enrichment per unique house
+ 5. ENRICH_DETAIL — top-N listings (detail_enriched_at IS NULL) → fetch_detail + save
+
+City sweep: run_avito_city_sweep — итерирует EKB_ANCHORS × pages_per_anchor,
+с cooperative cancel через scrape_runs.status и heartbeat update каждый anchor.
+
+Anti-bot hardening (2023-05-23):
+ - Shared AsyncSession на весь sweep (один TLS handshake)
+ - asyncio.sleep с random +-20% jitter между detail requests
+ - AvitoBlockedError/AvitoRateLimitedError propagation → early abort
+ - После 3 consecutive blocks — abort sweep + mark_banned (status='banned')
+
+Graceful degradation на каждом step: exception в одной house/listing
+не валит весь pipeline (кроме blocked exceptions — они abort весь sweep).
+
+IMV ОТСУТСТВУЕТ — он on-demand (Stage 3 estimator integration), не cron.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import random
+from dataclasses import dataclass, field, fields
+from urllib.parse import urlparse
+
+from curl_cffi.requests import AsyncSession
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.services import scrape_runs
+from app.services.scrapers.avito import AvitoScraper
+from app.services.scrapers.avito_detail import fetch_detail, save_detail_enrichment
+from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
+from app.services.scrapers.avito_houses import fetch_house_catalog, save_house_catalog_enrichment
+from app.services.scrapers.base import ScrapedLot, save_listings
+
+logger = logging.getLogger(__name__)
+
+# Default anchors ЕКБ — 5 точек покрытия города
+EKB_ANCHORS: list[tuple[float, float, str]] = [
+ (56.8400, 60.6050, "Центр"),
+ (56.7950, 60.5300, "ЮЗ"),
+ (56.8970, 60.6100, "Уралмаш"),
+ (56.7700, 60.5500, "Академический"),
+ (56.8650, 60.6200, "Пионерский"),
+]
+
+_CHROME_HEADERS = {
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
+ "Cache-Control": "max-age=0",
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "none",
+ "Sec-Fetch-User": "?1",
+ "Upgrade-Insecure-Requests": "1",
+}
+
+
+# ── Result dataclasses ──────────────────────────────────────────
+
+
+@dataclass
+class PipelineCounters:
+ """Counters одного pipeline run для логов/админки."""
+
+ lots_fetched: int = 0
+ lots_inserted: int = 0
+ lots_updated: int = 0
+ unique_houses: int = 0
+ houses_enriched: int = 0
+ houses_failed: int = 0
+ detail_attempted: int = 0
+ detail_enriched: int = 0
+ detail_failed: int = 0
+ errors: list[str] = field(default_factory=list)
+
+
+@dataclass
+class PipelineResult:
+ """Полный результат full Avito pipeline."""
+
+ anchor_lat: float
+ anchor_lon: float
+ radius_m: int
+ counters: PipelineCounters
+ enrich_houses: bool
+ enrich_detail_top_n: int
+
+
+# ── Main orchestrator ───────────────────────────────────────────
+
+
+async def run_avito_pipeline(
+ db: Session,
+ lat: float,
+ lon: float,
+ radius_m: int = 1500,
+ *,
+ enrich_houses: bool = True,
+ enrich_detail_top_n: int = 10,
+ pages: int = 1,
+ request_delay_sec: float | None = None,
+ shared_session: AsyncSession | None = None,
+) -> PipelineResult:
+ """Full Avito search → houses → detail enrichment pipeline.
+
+ Steps:
+ 1. SEARCH: AvitoScraper().fetch_around(lat, lon, radius_m, pages=pages)
+ 2. SAVE: save_listings(db, lots) → (inserted, updated)
+ 3. GROUP: dedupe house_url из новых lots → unique house path set
+ 4. ENRICH_HOUSES: fetch_house_catalog + save per house (with sleep between)
+ 5. ENRICH_DETAIL: top-N listings → fetch_detail + save (abort on 3 blocks)
+
+ shared_session — если передан, используется без закрытия (lifecycle у вызывающего).
+ AvitoBlockedError / AvitoRateLimitedError propagate наружу.
+ """
+ counters = PipelineCounters()
+ lots: list[ScrapedLot] = []
+ detail_delay = request_delay_sec if request_delay_sec is not None else 7.0
+
+ own_session = shared_session is None
+ session = shared_session or AsyncSession(
+ impersonate="chrome120",
+ timeout=25,
+ headers=_CHROME_HEADERS,
+ )
+
+ try:
+ # ── Step 1: search ──────────────────────────────────────
+ scraper = AvitoScraper()
+ scraper._cffi = session
+ try:
+ lots = await scraper.fetch_around(
+ lat, lon, radius_m,
+ pages=pages,
+ delay_override_sec=request_delay_sec,
+ )
+ counters.lots_fetched = len(lots)
+ logger.info(
+ "pipeline:search anchor=(%.4f,%.4f) r=%dm fetched=%d",
+ lat, lon, radius_m, len(lots),
+ )
+ except (AvitoBlockedError, AvitoRateLimitedError):
+ logger.error(
+ "pipeline:search BLOCKED by Avito at anchor=(%.4f,%.4f)", lat, lon
+ )
+ raise
+ except Exception as e:
+ logger.exception("pipeline:search failed")
+ counters.errors.append(f"search: {e}")
+ return PipelineResult(
+ lat, lon, radius_m, counters, enrich_houses, enrich_detail_top_n
+ )
+
+ # ── Step 2: save listings ───────────────────────────────
+ if lots:
+ try:
+ counters.lots_inserted, counters.lots_updated = save_listings(db, lots)
+ except Exception as e:
+ logger.exception("pipeline:save_listings failed")
+ counters.errors.append(f"save_listings: {e}")
+
+ # ── Step 3: group by house ──────────────────────────────
+ unique_house_paths: set[str] = set()
+ for lot in lots:
+ if lot.house_url:
+ try:
+ parsed = urlparse(lot.house_url)
+ path = parsed.path if parsed.path else lot.house_url
+ unique_house_paths.add(path)
+ except Exception:
+ continue
+
+ counters.unique_houses = len(unique_house_paths)
+ logger.info("pipeline:group_by_house unique=%d", counters.unique_houses)
+
+ # ── Step 4: enrich houses с shared session + sleep ──────
+ if enrich_houses and unique_house_paths:
+ house_paths_list = list(unique_house_paths)
+ for idx, house_path in enumerate(house_paths_list):
+ try:
+ enrichment = await fetch_house_catalog(house_path, cffi_session=session)
+ save_house_catalog_enrichment(db, enrichment)
+ counters.houses_enriched += 1
+ except (AvitoBlockedError, AvitoRateLimitedError):
+ logger.error(
+ "pipeline:house BLOCKED at %s — propagating", house_path
+ )
+ raise
+ except Exception as e:
+ logger.warning(
+ "pipeline:house_enrich failed for %s: %s", house_path, e
+ )
+ counters.houses_failed += 1
+ counters.errors.append(f"house {house_path}: {e}")
+
+ if idx < len(house_paths_list) - 1:
+ await asyncio.sleep(detail_delay)
+
+ # ── Step 5: enrich detail для top-N priority listings ───
+ if enrich_detail_top_n > 0:
+ priority_rows = db.execute(
+ text("""
+ SELECT source_url
+ FROM listings
+ WHERE source = 'avito'
+ AND source_url IS NOT NULL
+ AND (
+ (
+ detail_enriched_at IS NULL
+ AND price_rub > 0
+ AND ST_DWithin(
+ geom::geography,
+ ST_MakePoint(:lon, :lat)::geography,
+ :radius
+ )
+ )
+ OR (
+ detail_enriched_at IS NULL
+ AND scraped_at > NOW() - INTERVAL '2 hours'
+ )
+ )
+ ORDER BY scraped_at DESC NULLS LAST
+ LIMIT :limit
+ """),
+ {
+ "lat": lat,
+ "lon": lon,
+ "radius": radius_m * 2,
+ "limit": enrich_detail_top_n,
+ },
+ ).mappings().all()
+
+ consecutive_blocks = 0
+ for idx, row in enumerate(priority_rows):
+ source_url: str = row["source_url"]
+ counters.detail_attempted += 1
+ try:
+ item_url = (
+ urlparse(source_url).path
+ if source_url.startswith("http")
+ else source_url
+ )
+ enrichment_detail = await fetch_detail(item_url, cffi_session=session)
+ if save_detail_enrichment(db, enrichment_detail):
+ counters.detail_enriched += 1
+ consecutive_blocks = 0
+ except (AvitoBlockedError, AvitoRateLimitedError) as e:
+ consecutive_blocks += 1
+ counters.detail_failed += 1
+ counters.errors.append(f"BLOCKED_DETAIL #{idx}: {e}")
+ logger.warning(
+ "pipeline:detail BLOCKED #%d/%d (consecutive=%d): %s",
+ idx + 1, len(priority_rows), consecutive_blocks, e,
+ )
+ if consecutive_blocks >= 3:
+ logger.error(
+ "pipeline:detail ABORT — %d consecutive blocks, IP rate-limited",
+ consecutive_blocks,
+ )
+ counters.errors.append(
+ f"AVITO_RATE_LIMITED — sweep aborted after "
+ f"{idx + 1}/{len(priority_rows)} details"
+ )
+ raise
+ except Exception as e:
+ counters.detail_failed += 1
+ counters.errors.append(f"detail {source_url}: {e}")
+ logger.warning(
+ "pipeline:detail_enrich failed for %s: %s", source_url, e
+ )
+ consecutive_blocks = 0
+
+ if idx < len(priority_rows) - 1:
+ jitter = random.uniform(0.8, 1.2)
+ await asyncio.sleep(detail_delay * jitter)
+
+ logger.info(
+ "pipeline:done anchor=(%.4f,%.4f) lots=%d (ins=%d/upd=%d) "
+ "houses=%d/%d detail=%d/%d errors=%d",
+ lat, lon,
+ counters.lots_fetched, counters.lots_inserted, counters.lots_updated,
+ counters.houses_enriched, counters.unique_houses,
+ counters.detail_enriched, counters.detail_attempted,
+ len(counters.errors),
+ )
+ return PipelineResult(lat, lon, radius_m, counters, enrich_houses, enrich_detail_top_n)
+
+ finally:
+ if own_session:
+ await session.close()
+
+
+@dataclass
+class CitySweepCounters:
+ """Aggregate counters для full city sweep run."""
+
+ anchors_total: int = 0
+ anchors_done: int = 0
+ lots_fetched: int = 0
+ lots_inserted: int = 0
+ lots_updated: int = 0
+ unique_houses: int = 0
+ houses_enriched: int = 0
+ houses_failed: int = 0
+ detail_attempted: int = 0
+ detail_enriched: int = 0
+ detail_failed: int = 0
+ errors_count: int = 0
+
+ def to_dict(self) -> dict[str, int]:
+ return {f.name: getattr(self, f.name) for f in fields(self)}
+
+
+async def run_avito_city_sweep(
+ db: Session,
+ *,
+ run_id: int,
+ anchors: list[tuple[float, float, str]] | None = None,
+ radius_m: int = 1500,
+ pages_per_anchor: int = 3,
+ enrich_houses: bool = True,
+ detail_top_n: int = 10,
+ request_delay_sec: float = 7.0,
+) -> CitySweepCounters:
+ """Full city sweep: iterate anchors × pages → save → enrich houses + detail.
+
+ - Single shared AsyncSession на весь sweep (один TLS fingerprint)
+ - AvitoBlockedError/AvitoRateLimitedError → ABORT sweep + mark_banned (status='banned')
+ - Прочие errors per-anchor логируются, не валят весь sweep
+ """
+ _anchors = anchors if anchors is not None else EKB_ANCHORS
+ counters = CitySweepCounters(anchors_total=len(_anchors))
+
+ async with AsyncSession(
+ impersonate="chrome120",
+ timeout=25,
+ headers=_CHROME_HEADERS,
+ ) as session:
+ try:
+ for idx, (lat, lon, name) in enumerate(_anchors, start=1):
+ if scrape_runs.is_cancelled(db, run_id):
+ logger.info(
+ "city-sweep run_id=%d: cancelled at anchor #%d/%d (%s)",
+ run_id, idx, len(_anchors), name,
+ )
+ scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
+ return counters
+
+ logger.info(
+ "city-sweep run_id=%d anchor #%d/%d (%s, %.4f, %.4f)",
+ run_id, idx, len(_anchors), name, lat, lon,
+ )
+ try:
+ result = await run_avito_pipeline(
+ db,
+ lat=lat,
+ lon=lon,
+ radius_m=radius_m,
+ enrich_houses=enrich_houses,
+ enrich_detail_top_n=detail_top_n,
+ pages=pages_per_anchor,
+ request_delay_sec=request_delay_sec,
+ shared_session=session,
+ )
+ counters.lots_fetched += result.counters.lots_fetched
+ counters.lots_inserted += result.counters.lots_inserted
+ counters.lots_updated += result.counters.lots_updated
+ counters.unique_houses += result.counters.unique_houses
+ counters.houses_enriched += result.counters.houses_enriched
+ counters.houses_failed += result.counters.houses_failed
+ counters.detail_attempted += result.counters.detail_attempted
+ counters.detail_enriched += result.counters.detail_enriched
+ counters.detail_failed += result.counters.detail_failed
+ counters.errors_count += len(result.counters.errors)
+ except (AvitoBlockedError, AvitoRateLimitedError) as e:
+ logger.error(
+ "city-sweep run_id=%d ABORT at anchor #%d/%d (%s) — blocked: %s",
+ run_id, idx, len(_anchors), name, e,
+ )
+ counters.errors_count += 1
+ counters.anchors_done = idx
+ scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
+ scrape_runs.mark_banned(db, run_id, str(e), counters.to_dict())
+ return counters
+ except Exception:
+ logger.exception(
+ "city-sweep run_id=%d: anchor %s failed", run_id, name
+ )
+ counters.errors_count += 1
+
+ counters.anchors_done = idx
+ scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
+
+ scrape_runs.mark_done(db, run_id, counters.to_dict())
+ logger.info(
+ "city-sweep run_id=%d done: anchors=%d/%d lots=%d (ins=%d/upd=%d) "
+ "houses=%d/%d detail=%d/%d errors=%d",
+ run_id,
+ counters.anchors_done,
+ counters.anchors_total,
+ counters.lots_fetched,
+ counters.lots_inserted,
+ counters.lots_updated,
+ counters.houses_enriched,
+ counters.unique_houses,
+ counters.detail_enriched,
+ counters.detail_attempted,
+ counters.errors_count,
+ )
+ return counters
+
+ except Exception as exc:
+ logger.exception("city-sweep run_id=%d: fatal error", run_id)
+ scrape_runs.mark_failed(db, run_id, str(exc), counters.to_dict())
+ raise
+
+
+# ── Public re-exports ───────────────────────────────────────────
+__all__ = [
+ "EKB_ANCHORS",
+ "CitySweepCounters",
+ "PipelineCounters",
+ "PipelineResult",
+ "run_avito_city_sweep",
+ "run_avito_pipeline",
+]
diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py
new file mode 100644
index 00000000..697a6daf
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrape_runs.py
@@ -0,0 +1,172 @@
+"""Utility functions для scrape_runs table — tracking long-running pipeline runs.
+
+Таблица scrape_runs создана в 015_scrape_runs.sql.
+Расширена в 051_scrape_runs_extend.sql: params/counters/error/finished_at/cancelled.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+logger = logging.getLogger(__name__)
+
+
+def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
+ """INSERT scrape_runs(source, status='running', params, started_at=NOW()).
+
+ run_type DEFAULT 'city_sweep' (из 051 миграции).
+ Returns run_id (bigint).
+ """
+ row = db.execute(
+ text(
+ """
+ INSERT INTO scrape_runs (source, status, params, started_at, heartbeat_at)
+ VALUES (:source, 'running', CAST(:params AS jsonb), NOW(), NOW())
+ RETURNING id
+ """
+ ),
+ {"source": source, "params": json.dumps(params, ensure_ascii=False)},
+ ).fetchone()
+ db.commit()
+ assert row is not None, "scrape_runs INSERT returned no id"
+ return int(row.id)
+
+
+def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None:
+ """UPDATE heartbeat_at=NOW(), counters=:counters."""
+ db.execute(
+ text(
+ """
+ UPDATE scrape_runs
+ SET heartbeat_at = NOW(), counters = CAST(:counters AS jsonb)
+ WHERE id = :run_id
+ """
+ ),
+ {"run_id": run_id, "counters": json.dumps(counters)},
+ )
+ db.commit()
+
+
+def is_cancelled(db: Session, run_id: int) -> bool:
+ """Проверить status='cancelled' (cooperative cancel в long-running pipeline)."""
+ row = db.execute(
+ text("SELECT status FROM scrape_runs WHERE id = :id"),
+ {"id": run_id},
+ ).fetchone()
+ return row is not None and row.status == "cancelled"
+
+
+def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
+ """Финализация run: status='done', finished_at=NOW(), counters."""
+ row = db.execute(
+ text(
+ """
+ UPDATE scrape_runs
+ SET status = 'done', finished_at = NOW(), heartbeat_at = NOW(),
+ counters = CAST(:counters AS jsonb)
+ WHERE id = :run_id AND status = 'running'
+ RETURNING id
+ """
+ ),
+ {"run_id": run_id, "counters": json.dumps(counters)},
+ ).first()
+ if row is None:
+ logger.warning("mark_done no-op: run_id=%d not in 'running' state", run_id)
+ db.commit()
+
+
+def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
+ """Финализация run: status='failed', error (первые 1000 символов).
+
+ Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
+ он мог оставить сессию в error state — rollback сбрасывает состояние.
+ """
+ try:
+ db.rollback() # defensive: cascade-safe if prior UPDATE left txn in error state
+ except Exception:
+ pass
+ row = db.execute(
+ text(
+ """
+ UPDATE scrape_runs
+ SET status = 'failed', finished_at = NOW(), heartbeat_at = NOW(),
+ error = :error, counters = CAST(:counters AS jsonb)
+ WHERE id = :run_id AND status = 'running'
+ RETURNING id
+ """
+ ),
+ {"run_id": run_id, "error": error[:1000], "counters": json.dumps(counters)},
+ ).first()
+ if row is None:
+ logger.warning("mark_failed no-op: run_id=%d not in 'running' state", run_id)
+ db.commit()
+
+
+def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
+ """Финализация run: status='banned' (IP заблокирован Avito — 403/captcha).
+
+ Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
+ Отличается от 'failed': это external constraint, не наш bug. Cooldown 2-4 часа.
+
+ Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
+ он мог оставить сессию в error state — rollback сбрасывает состояние.
+ """
+ try:
+ db.rollback() # defensive: cascade-safe if prior UPDATE left txn in error state
+ except Exception:
+ pass
+ row = db.execute(
+ text(
+ """
+ UPDATE scrape_runs
+ SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(),
+ error = :error, counters = CAST(:counters AS jsonb)
+ WHERE id = :run_id AND status = 'running'
+ RETURNING id
+ """
+ ),
+ {"run_id": run_id, "error": error[:1000], "counters": json.dumps(counters)},
+ ).first()
+ if row is None:
+ logger.warning("mark_banned no-op: run_id=%d not in 'running' state", run_id)
+ db.commit()
+
+
+def mark_cancelled(db: Session, run_id: int) -> bool:
+ """Set status='cancelled' если currently 'running'. Returns True если cancelled."""
+ result = db.execute(
+ text(
+ """
+ UPDATE scrape_runs
+ SET status = 'cancelled', finished_at = NOW()
+ WHERE id = :run_id AND status = 'running'
+ RETURNING id
+ """
+ ),
+ {"run_id": run_id},
+ ).fetchone()
+ db.commit()
+ return result is not None
+
+
+def list_recent(db: Session, *, source: str, limit: int = 10) -> list[dict[str, Any]]:
+ """Список последних N runs по source, в порядке убывания started_at."""
+ rows = db.execute(
+ text(
+ """
+ SELECT id AS run_id, source, status, params, counters, error,
+ started_at, finished_at, heartbeat_at
+ FROM scrape_runs
+ WHERE source = :source
+ ORDER BY started_at DESC NULLS LAST
+ LIMIT :limit
+ """
+ ),
+ {"source": source, "limit": limit},
+ ).mappings().all()
+ return [dict(r) for r in rows]
diff --git a/tradein-mvp/backend/app/services/scraper_settings.py b/tradein-mvp/backend/app/services/scraper_settings.py
new file mode 100644
index 00000000..cce374f6
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scraper_settings.py
@@ -0,0 +1,138 @@
+"""scraper_settings.py — загрузка задержек парсеров из таблицы scraper_settings.
+
+Кеш в памяти с TTL 60 секунд — не нагружает БД при каждом запросе.
+invalidate_cache() вызывается из admin API PUT для немедленного применения.
+
+Ключевая логика:
+ get_scraper_delay(source) = max(per_source_delay, global_delay)
+ Строка source='global' задаёт нижнюю планку для ВСЕХ парсеров.
+ Если global=0 — используется только per-source значение.
+"""
+
+from __future__ import annotations
+
+import logging
+import time
+from threading import Lock
+
+from sqlalchemy import text
+
+logger = logging.getLogger(__name__)
+
+# Специальный ключ глобальной задержки (строка в scraper_settings с этим source).
+_GLOBAL_KEY = "global"
+
+# In-process cache: {source: (delay_sec, fetched_at_epoch)}
+_CACHE: dict[str, tuple[float, float]] = {}
+_CACHE_LOCK = Lock()
+_CACHE_TTL_SEC = 60.0
+
+# Default fallback if DB row missing or DB unreachable
+_DEFAULT_DELAY_BY_SOURCE: dict[str, float] = {
+ "avito": 7.0,
+ "cian": 5.0,
+ "n1": 5.0,
+ "yandex": 5.0,
+ "yandex_detail": 5.0,
+ "yandex_newbuilding": 5.0,
+ "yandex_valuation": 5.0,
+ "domrf": 5.0,
+ "rosreestr": 5.0,
+}
+_GLOBAL_DEFAULT_DELAY = 5.0
+
+# Yandex sub-scrapers all read the umbrella "yandex" key.
+# Map: scraper_name -> DB row key.
+_KEY_ALIASES: dict[str, str] = {
+ "yandex": "yandex",
+ "yandex_detail": "yandex",
+ "yandex_newbuilding": "yandex",
+ "yandex_realty_nb": "yandex", # BaseScraper.name used by YandexNewbuildingScraper
+ "yandex_valuation": "yandex",
+ # Non-Yandex sources resolve to their own keys for forward-compat
+ # (no rows exist yet for them; fall back to default).
+}
+
+
+def _open_session() -> object:
+ """Return a new SessionLocal context-manager instance.
+
+ Deferred import avoids triggering Settings() validation at module import
+ time (unit tests run without DATABASE_URL env var).
+ Extracted as a named function so tests can patch it.
+ """
+ from app.core.db import SessionLocal
+
+ return SessionLocal()
+
+
+def _get_setting_cached(key: str) -> float:
+ """Cache-aware read of one source key from scraper_settings.
+
+ Returns request_delay_sec from DB (with TTL=60s cache).
+ Missing row: falls back to _DEFAULT_DELAY_BY_SOURCE, or 0.0 for _GLOBAL_KEY
+ (meaning no global floor is applied when the row hasn't been seeded yet).
+ """
+ now = time.time()
+ with _CACHE_LOCK:
+ cached = _CACHE.get(key)
+ if cached is not None and now - cached[1] < _CACHE_TTL_SEC:
+ return cached[0]
+
+ try:
+ with _open_session() as db:
+ row = db.execute(
+ text(
+ "SELECT request_delay_sec FROM scraper_settings WHERE source = :s"
+ ),
+ {"s": key},
+ ).first()
+ if row is not None:
+ value = float(row[0])
+ elif key == _GLOBAL_KEY:
+ # Global row not yet seeded — don't apply floor.
+ value = 0.0
+ else:
+ value = _DEFAULT_DELAY_BY_SOURCE.get(key, _GLOBAL_DEFAULT_DELAY)
+ except Exception as e:
+ logger.warning("scraper_settings: load failed for %s -- using default: %s", key, e)
+ value = 0.0 if key == _GLOBAL_KEY else _DEFAULT_DELAY_BY_SOURCE.get(
+ key, _GLOBAL_DEFAULT_DELAY
+ )
+
+ with _CACHE_LOCK:
+ _CACHE[key] = (value, now)
+ return value
+
+
+def get_scraper_delay(source: str) -> float:
+ """Return the configured request_delay_sec for `source`.
+
+ Effective delay = max(per_source, global).
+ - Maps Yandex sub-scrapers to the umbrella 'yandex' key.
+ - Cached 60s in-process to avoid per-instance DB query.
+ - source='global' sets a floor across all scrapers (0 = disabled).
+ - Any DB error -> returns the hardcoded default for that source.
+ """
+ key = _KEY_ALIASES.get(source, source)
+ per_source = _get_setting_cached(key)
+ global_value = _get_setting_cached(_GLOBAL_KEY)
+ return max(per_source, global_value)
+
+
+def invalidate_cache(source: str | None = None) -> None:
+ """Clear the cache entry for `source`, or the whole cache if None.
+
+ Called by admin endpoint after PUT to force a re-read on next get_scraper_delay().
+ """
+ with _CACHE_LOCK:
+ if source is None:
+ _CACHE.clear()
+ logger.info("scraper_settings: cache cleared (all sources)")
+ else:
+ key = _KEY_ALIASES.get(source, source)
+ _CACHE.pop(key, None)
+ # Also clear the source itself if it differed from key (alias case).
+ if key != source:
+ _CACHE.pop(source, None)
+ logger.info("scraper_settings: cache cleared for source=%s", source)
diff --git a/tradein-mvp/backend/app/services/scrapers/__init__.py b/tradein-mvp/backend/app/services/scrapers/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py
new file mode 100644
index 00000000..7ad64c01
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/avito.py
@@ -0,0 +1,425 @@
+"""Avito.ru scraper — парсинг вторички вокруг точки.
+
+Стратегия: HTML scrape карточек объявлений через DOM.
+
+URL pattern (EKB):
+ https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ
+ ?geoCoords=56.838,60.605
+ &radius=1 # в км
+ &s=104 # sort by date
+ &p=1 # страница
+
+ВАЖНО: Avito банит httpx (403/429) по TLS fingerprint от server IP.
+Используем curl_cffi с impersonate='chrome120' — настоящий Chrome TLS ClientHello.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from datetime import date, timedelta
+from typing import Any
+from urllib.parse import urlencode, urljoin
+
+from curl_cffi.requests import AsyncSession
+from selectolax.parser import HTMLParser
+
+from app.services.scraper_settings import get_scraper_delay
+from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
+from app.services.scrapers.base import BaseScraper, ScrapedLot
+
+logger = logging.getLogger(__name__)
+
+# ── Relative date parsing ────────────────────────────────────────────────────
+_REL_DATE_RE = re.compile(
+ r"(?P\d+)\s+(?P"
+ r"секунд[ауы]?|"
+ r"минут[ауы]?|"
+ r"час(?:а|ов)?|"
+ r"день|дн(?:я|ей?)|"
+ r"недел[июяь]+|"
+ r"месяц(?:а|ев)?|"
+ r"год(?:а|ов)?"
+ r")\s+назад",
+ flags=re.I,
+)
+_UNIT_DAYS: dict[str, int] = {
+ "секунд": 0, "минут": 0, "час": 0,
+ "день": 1, "дн": 1,
+ "недел": 7,
+ "месяц": 30,
+ "год": 365,
+}
+
+# Russian month names → number. Avito показывает абсолютные даты как «18 мая».
+_RU_MONTHS: dict[str, int] = {
+ "январ": 1, "феврал": 2, "март": 3, "апрел": 4,
+ "ма": 5, "июн": 6, "июл": 7, "август": 8,
+ "сентябр": 9, "октябр": 10, "ноябр": 11, "декабр": 12,
+}
+# «18 мая», «5 июня в 12:30» — day + month (year inferred current).
+_ABS_DATE_RE = re.compile(
+ r"(?P\d{1,2})\s+(?Pянвар\w*|феврал\w*|март\w*|апрел\w*|"
+ r"ма[яй]|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)",
+ flags=re.I,
+)
+
+
+def _parse_relative_date(s: str | None) -> date | None:
+ """Avito показывает дату публикации в трёх форматах:
+ * '5 дней назад' / 'час назад' / 'неделю назад' — relative numeric
+ * 'сегодня в 12:30' / 'вчера в 18:45' / 'позавчера' — relative keyword
+ * '18 мая' / '5 июня в 12:30' — absolute day + month (year inferred)
+
+ PR M-followup 2026-05-27: расширено покрытие — раньше только relative numeric
+ matched (regex `\\d+ unit назад`), 100% Avito listings заходило с listing_date=None
+ т.к. Avito SERP для свежих cards использует keyword/absolute форматы. См.
+ тесты для каждого case.
+ """
+ if not s:
+ return None
+ text_lower = s.lower()
+
+ # Keyword shortcuts (no \d+).
+ if "позавчера" in text_lower:
+ return date.today() - timedelta(days=2)
+ if "вчера" in text_lower:
+ return date.today() - timedelta(days=1)
+ if "сегодня" in text_lower or "только что" in text_lower:
+ return date.today()
+
+ # Relative numeric («5 дней назад»).
+ m = _REL_DATE_RE.search(s)
+ if m:
+ n = int(m["n"])
+ unit_raw = m["unit"].lower()
+ for prefix, days in _UNIT_DAYS.items():
+ if unit_raw.startswith(prefix):
+ return date.today() - timedelta(days=n * days)
+
+ # Absolute («18 мая», «5 июня в 12:30»). Year — current; если месяц
+ # в будущем относительно сегодня (например сейчас декабрь, увидели
+ # «5 января») — откатываем к прошлому году.
+ m = _ABS_DATE_RE.search(s)
+ if m:
+ day = int(m["day"])
+ month_raw = m["month"].lower()
+ month: int | None = None
+ for prefix, num in _RU_MONTHS.items():
+ if month_raw.startswith(prefix):
+ month = num
+ break
+ if month is not None and 1 <= day <= 31:
+ today = date.today()
+ year = today.year
+ try:
+ parsed = date(year, month, day)
+ except ValueError:
+ return None
+ # Если parsed в будущем больше чем на 30 дней — Avito показывает
+ # прошлогоднюю дату («декабрь» в феврале значит прошлый декабрь).
+ if (parsed - today).days > 30:
+ try:
+ parsed = date(year - 1, month, day)
+ except ValueError:
+ return None
+ return parsed
+
+ return None
+
+
+class AvitoScraper(BaseScraper):
+ """Avito vtorichka parser. Источник = 'avito'.
+
+ Использует curl_cffi с impersonate=chrome120 для обхода TLS fingerprint бана.
+ """
+
+ name = "avito"
+ base_url = "https://www.avito.ru"
+ # Avito жёстко мониторит — спим долго между запросами.
+ # Класс-дефолт; реальное значение загружается из scraper_settings при создании экземпляра.
+ request_delay_sec = 7.0
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.request_delay_sec = get_scraper_delay(self.name)
+ self._cffi: AsyncSession | None = None
+
+ async def __aenter__(self) -> AvitoScraper:
+ await super().__aenter__()
+ self._cffi = AsyncSession(
+ impersonate="chrome120",
+ timeout=25,
+ headers={
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
+ "Cache-Control": "max-age=0",
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "none",
+ "Sec-Fetch-User": "?1",
+ "Upgrade-Insecure-Requests": "1",
+ },
+ )
+ return self
+
+ async def __aexit__(self, *args: Any) -> None:
+ if self._cffi is not None:
+ await self._cffi.close()
+ await super().__aexit__(*args)
+
+ # ── Public ──────────────────────────────────────────────────────────────
+ async def fetch_around(
+ self,
+ lat: float,
+ lon: float,
+ radius_m: int = 1000,
+ *,
+ pages: int = 1,
+ delay_override_sec: float | None = None,
+ ) -> list[ScrapedLot]:
+ """Найти объявления Авито вокруг (lat, lon) в radius_m метрах.
+
+ Avito работает в км — конвертируем. Минимум 1 км.
+
+ pages=1 (default) — backward compat, одна страница (~50 lots).
+ pages>1 — итерируем p=1..pages, собираем до 50*pages lots.
+ Между запросами sleep request_delay_sec (или delay_override_sec если передан).
+
+ Coords НЕ заполняем из anchor (избегаем silent corruption через jitter).
+ Точные lat/lon приходят позже из avito_detail.py — для search-карточек
+ lat=lon=None, далее geocode-missing cron подтянет из address.
+ """
+ radius_km = max(1, round(radius_m / 1000))
+ if delay_override_sec is not None:
+ self.request_delay_sec = delay_override_sec
+
+ all_lots: list[ScrapedLot] = []
+ for page in range(1, pages + 1):
+ url = self._build_web_url(lat, lon, radius_km, page=page)
+ try:
+ assert self._cffi is not None
+ response = await self._cffi.get(url)
+ if response.status_code == 403:
+ logger.error(
+ "avito SERP HTTP 403 (IP blocked) page=%d url=%s", page, url
+ )
+ raise AvitoBlockedError(
+ f"Avito SERP returned 403 — IP blocked at page={page}"
+ )
+ if response.status_code == 429:
+ logger.error(
+ "avito SERP HTTP 429 (rate limited) page=%d url=%s", page, url
+ )
+ raise AvitoRateLimitedError(
+ f"Avito SERP returned 429 — rate limited at page={page}"
+ )
+ if response.status_code != 200:
+ logger.warning(
+ "avito HTML page=%d returned %d for %s", page, response.status_code, url
+ )
+ break
+ except (AvitoBlockedError, AvitoRateLimitedError):
+ raise
+ except Exception:
+ logger.exception("avito HTML page=%d fetch failed for %s", page, url)
+ break
+
+ lots = self._parse_html(response.text, source_url_base=url)
+ if not lots:
+ logger.info("avito page=%d: 0 lots — end of pagination", page)
+ break
+ all_lots.extend(lots)
+ logger.info(
+ "avito page=%d: %d lots (total %d) around (%.4f, %.4f)",
+ page, len(lots), len(all_lots), lat, lon,
+ )
+ if page < pages:
+ await self.sleep_between_requests()
+
+ if pages == 1 and all_lots:
+ # Backward compat: single-page path does trailing sleep (как раньше)
+ await self.sleep_between_requests()
+ elif pages > 1:
+ logger.info(
+ "avito fetch_around pages=%d total_lots=%d around (%.4f, %.4f)",
+ pages, len(all_lots), lat, lon,
+ )
+
+ return all_lots
+
+ # ── Strategy B: HTML scrape ─────────────────────────────────────────────
+ def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str:
+ params = {
+ "geoCoords": f"{lat},{lon}",
+ "radius": radius_km,
+ "s": 104, # sort by date
+ "p": page,
+ }
+ return (
+ f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}"
+ )
+
+ def _parse_html(self, html: str, source_url_base: str) -> list[ScrapedLot]:
+ """Парсим карточки объявлений из HTML через DOM scrape.
+
+ Avito state давно пустой — используем только DOM путь.
+ """
+ tree = HTMLParser(html)
+ cards = tree.css('[data-marker="item"]')
+ lots: list[ScrapedLot] = []
+ for card in cards:
+ lot = self._dom_card_to_lot(card, source_url_base)
+ if lot is not None:
+ lots.append(lot)
+ return lots
+
+ def _dom_card_to_lot(self, card: Any, source_url_base: str) -> ScrapedLot | None:
+ """Парсинг карточки объявления через DOM.
+
+ Avito state давно пустой — единственный путь. Coords из карточки не отдаются,
+ оставляем lat=lon=None (geocode-missing cron подтянет из address).
+ """
+ try:
+ link_el = card.css_first('a[data-marker="item-title"]')
+ if link_el is None:
+ return None
+ href = link_el.attributes.get("href", "")
+ url = urljoin("https://www.avito.ru", href)
+ title = link_el.text(strip=True)
+
+ price_el = card.css_first('meta[itemprop="price"]')
+ price = int(price_el.attributes.get("content", 0)) if price_el is not None else 0
+ if price <= 0:
+ return None
+
+ rooms = _extract_rooms_from_title(title)
+ area = _extract_area_from_title(title)
+ floor, total = _extract_floor_from_title(title)
+
+ # Адрес: первый внутри data-marker="item-location"
+ address: str | None = None
+ loc_el = card.css_first('[data-marker="item-location"]')
+ if loc_el is not None:
+ street_el = loc_el.css_first("p")
+ if street_el is not None:
+ address = _clean_address(street_el.text(strip=True))
+
+ # Фото
+ photo_urls: list[str] = []
+ for img in card.css('img[itemprop="image"]'):
+ src = img.attributes.get("src") or ""
+ if src.startswith("http") and src not in photo_urls:
+ photo_urls.append(src)
+ if len(photo_urls) >= 5:
+ break
+
+ # Дата публикации: data-marker="item-date" / data-marker="item-date-info"
+ listing_date: date | None = None
+ date_el = card.css_first('[data-marker="item-date"]')
+ if date_el is None:
+ date_el = card.css_first('[data-marker="item-date-info"]')
+ if date_el is not None:
+ listing_date = _parse_relative_date(date_el.text(strip=True))
+ if listing_date is None:
+ # Fallback: ищем любой текст «N дней/недель/месяцев назад» в карточке
+ card_text = card.text(strip=True)
+ listing_date = _parse_relative_date(card_text)
+
+ # House link:
+ # or /catalog/houses//
+ house_source: str | None = None
+ house_ext_id: str | None = None
+ house_url: str | None = None
+ house_link = card.css_first('a[href^="/catalog/houses/"]')
+ if house_link is not None:
+ h_href = house_link.attributes.get("href", "")
+ parts = [p for p in h_href.strip("/").split("/") if p]
+ # ['catalog', 'houses', , , ]
+ if len(parts) >= 4 and parts[-1].isdigit():
+ house_source = "avito"
+ house_ext_id = parts[-1]
+ house_url = urljoin("https://www.avito.ru", h_href)
+
+ # listing_segment по item URL pattern
+ listing_segment: str | None = None
+ if "/kvartiry/" in href:
+ listing_segment = "vtorichka"
+ elif "/novostroyki/" in href:
+ listing_segment = "novostroyki"
+
+ return ScrapedLot(
+ source="avito",
+ source_url=url,
+ source_id=card.attributes.get("data-item-id"),
+ address=address, # БЕЗ fallback "Екатеринбург (Avito)" — пусть None
+ lat=None, # C-5 fix: НЕ jitter
+ lon=None, # C-5 fix: НЕ jitter
+ rooms=rooms,
+ area_m2=area,
+ floor=floor,
+ total_floors=total,
+ price_rub=price,
+ photo_urls=photo_urls,
+ listing_date=listing_date,
+ raw_payload={"title": title, "address": address, "house_ext_id": house_ext_id},
+ house_source=house_source,
+ house_ext_id=house_ext_id,
+ house_url=house_url,
+ listing_segment=listing_segment,
+ )
+ except Exception:
+ return None
+
+
+# ── Helpers: title parser ───────────────────────────────────────────────────
+_RE_ROOMS = re.compile(r"(\d)-к\.?\s*(квартира|кв\.)", re.IGNORECASE)
+_RE_STUDIO = re.compile(r"студи[яиюей]", re.IGNORECASE)
+_RE_AREA = re.compile(r"(\d+[.,]?\d*)\s*м[²2]", re.IGNORECASE)
+_RE_FLOOR = re.compile(r"(\d+)\s*/\s*(\d+)\s*эт\.?", re.IGNORECASE)
+
+_CSS_NOISE_RE = re.compile(r"\.?css-[a-z0-9_-]+\s*\{[^}]*\}", flags=re.I)
+_NOT_ADDRESS_TAIL_RE = re.compile(
+ r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+)",
+ flags=re.I,
+)
+
+
+def _clean_address(raw: str | None) -> str | None:
+ """Strip Emotion CSS and post-address noise that Avito DOM leaks via .text().
+
+ Examples:
+ "ул. Токарей, 56к1Площадь 1905 года.css-39hgr0{fill:..."
+ -> "ул. Токарей, 56к1"
+ "ул. Малышева, 1.css-xxx{...}"
+ -> "ул. Малышева, 1"
+ """
+ if not raw:
+ return None
+ cleaned = _CSS_NOISE_RE.sub("", raw)
+ cleaned = _NOT_ADDRESS_TAIL_RE.split(cleaned, maxsplit=1)[0]
+ cleaned = cleaned.strip(" ,.\n\t")
+ return cleaned or None
+
+
+def _extract_rooms_from_title(title: str) -> int | None:
+ if _RE_STUDIO.search(title):
+ return 0
+ m = _RE_ROOMS.search(title)
+ if m:
+ return int(m.group(1))
+ return None
+
+
+def _extract_area_from_title(title: str) -> float | None:
+ m = _RE_AREA.search(title)
+ if m:
+ return float(m.group(1).replace(",", "."))
+ return None
+
+
+def _extract_floor_from_title(title: str) -> tuple[int | None, int | None]:
+ m = _RE_FLOOR.search(title)
+ if m:
+ return int(m.group(1)), int(m.group(2))
+ return None, None
diff --git a/tradein-mvp/backend/app/services/scrapers/avito_detail.py b/tradein-mvp/backend/app/services/scrapers/avito_detail.py
new file mode 100644
index 00000000..49d7d614
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/avito_detail.py
@@ -0,0 +1,744 @@
+"""Avito detail page parser — Stage 2b.
+
+Извлекает 30+ полей из страницы объявления:
+- Identity (item_id, title, price, publish_date, views)
+- Apartment params (rooms, area, floor, balcony_loggia, room_layout,
+ bathroom_type, windows_view, repair_state, sale_type, mortgage_available)
+- Location (lat, lon, avito_location_id, metro_stations[], address_full)
+- House params (house_type, total_floors_house, lifts, concierge, closed_yard,
+ house_catalog_url) — собираются но НЕ сохраняются в БД (Stage 2c)
+- Description
+- Domoteka 5 полей (owners_count, owners_at_least, last_owner_change_date,
+ encumbrances_clean, registry_match)
+- Gallery (photo_urls[])
+
+Точка входа для batch-обогащения:
+ enrichment = await fetch_detail(item_url)
+ saved = save_detail_enrichment(db, enrichment)
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from dataclasses import dataclass, field
+from datetime import date
+from typing import Any
+from urllib.parse import urljoin
+
+from curl_cffi.requests import AsyncSession
+from selectolax.parser import HTMLParser, Node
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.services.scrapers.avito import _clean_address
+from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
+
+logger = logging.getLogger(__name__)
+
+AVITO_BASE = "https://www.avito.ru"
+
+# ── Русские месяцы ─────────────────────────────────────────────────────────
+RUS_MONTHS: dict[str, int] = {
+ "января": 1,
+ "февраля": 2,
+ "марта": 3,
+ "апреля": 4,
+ "мая": 5,
+ "июня": 6,
+ "июля": 7,
+ "августа": 8,
+ "сентября": 9,
+ "октября": 10,
+ "ноября": 11,
+ "декабря": 12,
+}
+
+# ── Regex ────────────────────────────────────────────────────────────────────
+_ITEM_ID_RE = re.compile(r"№\s*(\d+)")
+_VIEWS_TOTAL_RE = re.compile(r"(\d+)\s+просмотр")
+_VIEWS_TODAY_RE = re.compile(r"\+\s*(\d+)\s+сегодня")
+_PUBLISH_DATE_RE = re.compile(
+ r"(\d{1,2})\s+(января|февраля|марта|апреля|мая|июня|"
+ r"июля|августа|сентября|октября|ноября|декабря)\s+в",
+ re.IGNORECASE,
+)
+_FLOAT_RE = re.compile(r"[\d.,]+")
+_INT_RE = re.compile(r"\d+")
+_FLOOR_SPLIT_RE = re.compile(r"(\d+)\s+из\s+(\d+)")
+
+OWNERS_RE = re.compile(r"^(\d+)\s+собственник(?:\s+или\s+больше)?", re.IGNORECASE)
+OWNER_CHANGE_RE = re.compile(
+ r"смена собственника\s+(\d+)\s+(\w+)\s+(\d{4})", re.IGNORECASE
+)
+ENCUMBRANCES_RE = re.compile(r"Не\s+найдены\s+ограничения", re.IGNORECASE)
+REGISTRY_MATCH_RE = re.compile(r"Совпадают\s+площадь", re.IGNORECASE)
+
+METRO_RE = re.compile(
+ r"([А-ЯЁ][а-яё]+(?:ская|инская|итская|овская|енская))\s*"
+ r"(?:(\d+)[–\-](\d+)|от\s+(\d+))\s*мин",
+ re.IGNORECASE,
+)
+
+# ── Маппинг RUS полей params-блока ───────────────────────────────────────────
+# (rus_label) -> (field_name, conversion_type)
+RUS_FIELD_MAP: dict[str, tuple[str, str | None]] = {
+ "Количество комнат": ("rooms", "int"),
+ "Общая площадь": ("area_m2", "float_m2"),
+ "Площадь кухни": ("kitchen_area_m2", "float_m2"),
+ "Этаж": ("floor_split", None),
+ "Балкон или лоджия": ("balcony_loggia", "balcony"),
+ "Тип комнат": ("room_layout", "layout"),
+ "Санузел": ("bathroom_type", "bathroom"),
+ "Окна": ("windows_view", "windows"),
+ "Ремонт": ("repair_state", "repair"),
+ "Способ продажи": ("sale_type", "sale_type"),
+ "Условия продажи": ("sale_conditions_raw", None),
+ "Тип дома": ("house_type", "house_type"),
+ "Этажей в доме": ("total_floors_house", "int"),
+ "Пассажирский лифт": ("passenger_elevators", "int"),
+ "Грузовой лифт": ("cargo_elevators", "int"),
+ "В доме": ("in_house_features_raw", None),
+ "Двор": ("yard_features_raw", None),
+}
+
+# ── Enum-маппинги ─────────────────────────────────────────────────────────────
+_BALCONY_MAP: dict[str, str] = {
+ "балкон": "balcony",
+ "лоджия": "loggia",
+ "балкон, лоджия": "both",
+ "лоджия, балкон": "both",
+ "нет": "none",
+}
+
+_LAYOUT_MAP: dict[str, str] = {
+ "изолированные": "isolated",
+ "смежные": "adjacent",
+ "смешанные": "mixed",
+}
+
+_BATHROOM_MAP: dict[str, str] = {
+ "совмещенный": "combined",
+ "раздельный": "separate",
+ "совмещенный, раздельный": "both",
+ "раздельный, совмещенный": "both",
+}
+
+_WINDOWS_MAP: dict[str, str] = {
+ "во двор": "yard",
+ "на улицу": "street",
+ "во двор и на улицу": "both",
+ "на улицу и во двор": "both",
+ "в парк": "park",
+}
+
+_REPAIR_MAP: dict[str, str] = {
+ "косметический": "cosmetic",
+ "евро": "euro",
+ "дизайнерский": "designer",
+ "требуется": "required",
+ "без ремонта": "required",
+}
+
+_SALE_TYPE_MAP: dict[str, str] = {
+ "свободная": "free",
+ "альтернатива": "alternative",
+ "аукцион": "auction",
+}
+
+_HOUSE_TYPE_MAP: dict[str, str] = {
+ "монолитный": "monolith",
+ "панельный": "panel",
+ "кирпичный": "brick",
+ "монолитно-кирпичный": "monolith_brick",
+ "блочный": "block",
+ "деревянный": "wood",
+}
+
+
+# ── DetailEnrichment dataclass ────────────────────────────────────────────────
+@dataclass
+class DetailEnrichment:
+ """Результат парсинга detail page Avito.
+
+ Содержит 30+ полей из страницы объявления.
+ save_detail_enrichment() пишет подмножество в listings (house-level поля пропускаются
+ — они canonical из Houses Catalog Stage 2c).
+ """
+
+ # Identity
+ item_id: str
+ source_url: str
+ title: str | None = None
+ price_rub: int | None = None
+ publish_date: date | None = None
+ views_total: int | None = None
+ views_today: int | None = None
+
+ # Apartment params
+ rooms: int | None = None
+ area_m2: float | None = None
+ kitchen_area_m2: float | None = None
+ floor: int | None = None
+ total_floors: int | None = None
+ balcony_loggia: str | None = None
+ room_layout: str | None = None
+ bathroom_type: str | None = None
+ windows_view: str | None = None
+ repair_state: str | None = None
+ sale_type: str | None = None
+ mortgage_available: bool | None = None
+
+ # House params (собираем, но НЕ сохраняем в listings — Stage 2c)
+ house_type: str | None = None
+ total_floors_house: int | None = None
+ passenger_elevators: int | None = None
+ cargo_elevators: int | None = None
+ has_concierge: bool | None = None
+ closed_yard: bool | None = None
+ house_catalog_url: str | None = None
+
+ # Location
+ address_full: str | None = None
+ lat: float | None = None
+ lon: float | None = None
+ avito_location_id: int | None = None
+ metro_stations: list[dict[str, Any]] = field(default_factory=list)
+
+ # Description
+ description: str | None = None
+
+ # Domoteka (5 полей)
+ owners_count: int | None = None
+ owners_at_least: bool | None = None
+ last_owner_change_date: date | None = None
+ encumbrances_clean: bool | None = None
+ registry_match: bool | None = None
+
+ # Gallery
+ photo_urls: list[str] = field(default_factory=list)
+
+ # Raw для retrofit / debug
+ raw_html_meta: dict[str, Any] = field(default_factory=dict)
+
+
+# ── fetch_detail ──────────────────────────────────────────────────────────────
+async def fetch_detail(
+ item_url: str,
+ *,
+ cffi_session: AsyncSession | None = None,
+) -> DetailEnrichment:
+ """GET /{item_url} → parse HTML via selectolax → DetailEnrichment.
+
+ Если cffi_session не передана — создаёт новую (impersonate='chrome120').
+ Raises:
+ httpx.HTTPError — если status != 200 (через raise_for_status-like).
+ ValueError — если item_id не извлечён из HTML.
+ """
+ own_session = cffi_session is None
+ session = cffi_session or AsyncSession(
+ impersonate="chrome120",
+ timeout=25,
+ headers={
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
+ "Cache-Control": "max-age=0",
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "none",
+ "Sec-Fetch-User": "?1",
+ "Upgrade-Insecure-Requests": "1",
+ },
+ )
+
+ full_url = item_url if item_url.startswith("http") else urljoin(AVITO_BASE, item_url)
+ try:
+ response = await session.get(full_url)
+ if response.status_code == 403:
+ raise AvitoBlockedError(f"Avito detail HTTP 403 for {full_url}")
+ if response.status_code == 429:
+ raise AvitoRateLimitedError(f"Avito detail HTTP 429 for {full_url}")
+ if response.status_code != 200:
+ raise ValueError(
+ f"avito detail HTTP {response.status_code} for {full_url}"
+ )
+ enrichment = parse_detail_html(response.text, full_url)
+ return enrichment
+ finally:
+ if own_session:
+ await session.close()
+
+
+# ── parse_detail_html ─────────────────────────────────────────────────────────
+def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
+ """Pure parser — отдельно для тестов на fixtures без сети.
+
+ Raises:
+ ValueError — если item_id не извлечён.
+ """
+ tree = HTMLParser(html)
+
+ # ── Identity ────────────────────────────────────────────────────────────
+ item_id = _extract_item_id(tree)
+ if not item_id:
+ raise ValueError(f"Cannot extract item_id from {source_url}")
+
+ title = _text(tree, "[data-marker='item-view/title-info']")
+ price_rub = _extract_price(tree)
+ publish_date, views_total, views_today = _extract_meta(tree)
+
+ # ── Location ────────────────────────────────────────────────────────────
+ map_el = tree.css_first("[data-marker='item-map-wrapper']")
+ lat: float | None = None
+ lon: float | None = None
+ avito_location_id: int | None = None
+ if map_el is not None:
+ lat = _try_float(map_el.attributes.get("data-map-lat"))
+ lon = _try_float(map_el.attributes.get("data-map-lon"))
+ avito_location_id = _try_int(map_el.attributes.get("data-location-id"))
+
+ address_full = _clean_address(_text(tree, "[itemprop='address']"))
+
+ # ── Description ─────────────────────────────────────────────────────────
+ description = _text(tree, "[data-marker='item-view/item-description']")
+
+ # ── Metro from description ───────────────────────────────────────────────
+ metro_stations: list[dict[str, Any]] = []
+ if description:
+ metro_stations = _extract_metro(description)
+
+ # ── House catalog link ───────────────────────────────────────────────────
+ house_catalog_url: str | None = None
+ house_link = tree.css_first("[data-marker='nd-jk-details-button']")
+ if house_link is not None:
+ href = house_link.attributes.get("href", "")
+ if href:
+ house_catalog_url = urljoin(AVITO_BASE, href)
+
+ # ── Photo gallery ────────────────────────────────────────────────────────
+ photo_urls: list[str] = []
+ for img in tree.css("[data-marker='image-preview/item'] img"):
+ src = img.attributes.get("src") or ""
+ if src and src not in photo_urls:
+ photo_urls.append(src)
+
+ # ── Params blocks ────────────────────────────────────────────────────────
+ params_block = tree.css_first("[data-marker='item-view/item-params']")
+ apt_params: dict[str, str] = {}
+ house_params: dict[str, str] = {}
+ if params_block is not None:
+ ul_els = params_block.css("ul")
+ if len(ul_els) >= 1:
+ apt_params = _parse_params_ul(ul_els[0])
+ if len(ul_els) >= 2:
+ house_params = _parse_params_ul(ul_els[1])
+
+ # Merge house_params into apt_params — некоторые страницы совмещают всё в одном ul
+ all_params = {**apt_params, **house_params}
+
+ # ── Parse apartment fields ───────────────────────────────────────────────
+ rooms: int | None = None
+ area_m2: float | None = None
+ kitchen_area_m2: float | None = None
+ floor: int | None = None
+ total_floors: int | None = None
+ balcony_loggia: str | None = None
+ room_layout: str | None = None
+ bathroom_type: str | None = None
+ windows_view: str | None = None
+ repair_state: str | None = None
+ sale_type: str | None = None
+ mortgage_available: bool | None = None
+ house_type: str | None = None
+ total_floors_house: int | None = None
+ passenger_elevators: int | None = None
+ cargo_elevators: int | None = None
+ has_concierge: bool | None = None
+ closed_yard: bool | None = None
+
+ for rus_label, val in all_params.items():
+ mapping = RUS_FIELD_MAP.get(rus_label)
+ if mapping is None:
+ continue
+ field_name, _conv = mapping
+
+ if field_name == "rooms":
+ rooms = _try_int(val)
+ elif field_name == "area_m2":
+ area_m2 = _parse_float_m2(val)
+ elif field_name == "kitchen_area_m2":
+ kitchen_area_m2 = _parse_float_m2(val)
+ elif field_name == "floor_split":
+ floor, total_floors = _parse_floor_split(val)
+ elif field_name == "balcony_loggia":
+ balcony_loggia = _map_lower(val, _BALCONY_MAP)
+ elif field_name == "room_layout":
+ room_layout = _map_lower(val, _LAYOUT_MAP)
+ elif field_name == "bathroom_type":
+ bathroom_type = _map_lower(val, _BATHROOM_MAP)
+ elif field_name == "windows_view":
+ windows_view = _map_lower(val, _WINDOWS_MAP)
+ elif field_name == "repair_state":
+ repair_state = _map_lower(val, _REPAIR_MAP)
+ elif field_name == "sale_type":
+ sale_type = _map_lower(val, _SALE_TYPE_MAP)
+ elif field_name == "sale_conditions_raw":
+ if "возможна ипотека" in val.lower():
+ mortgage_available = True
+ elif field_name == "house_type":
+ house_type = _map_lower(val, _HOUSE_TYPE_MAP)
+ elif field_name == "total_floors_house":
+ total_floors_house = _try_int(val)
+ elif field_name == "passenger_elevators":
+ passenger_elevators = _try_int(val)
+ elif field_name == "cargo_elevators":
+ cargo_elevators = _try_int(val)
+ elif field_name == "in_house_features_raw":
+ if "консьерж" in val.lower():
+ has_concierge = True
+ elif field_name == "yard_features_raw":
+ if "закрытая территория" in val.lower():
+ closed_yard = True
+
+ # ── Domoteka ─────────────────────────────────────────────────────────────
+ owners_count: int | None = None
+ owners_at_least: bool | None = None
+ last_owner_change_date: date | None = None
+ encumbrances_clean: bool | None = None
+ registry_match: bool | None = None
+
+ domoteka_block = tree.css_first("[data-marker='domoteka-entry-block']")
+ if domoteka_block is not None:
+ badges = domoteka_block.css("p[data-marker='TeaserData.item']")
+ for badge in badges:
+ badge_text = badge.text(strip=True)
+ if not badge_text:
+ continue
+
+ m_owners = OWNERS_RE.match(badge_text)
+ if m_owners:
+ owners_count = int(m_owners.group(1))
+ owners_at_least = "или больше" in badge_text.lower()
+ continue
+
+ m_change = OWNER_CHANGE_RE.search(badge_text)
+ if m_change:
+ try:
+ day = int(m_change.group(1))
+ month_word = m_change.group(2).lower()
+ year = int(m_change.group(3))
+ month = RUS_MONTHS.get(month_word)
+ if month:
+ last_owner_change_date = date(year, month, day)
+ except (ValueError, KeyError):
+ logger.warning("Cannot parse owner change date: %s", badge_text)
+ continue
+
+ if ENCUMBRANCES_RE.search(badge_text):
+ encumbrances_clean = True
+ continue
+
+ if REGISTRY_MATCH_RE.search(badge_text):
+ registry_match = True
+ continue
+
+ return DetailEnrichment(
+ item_id=item_id,
+ source_url=source_url,
+ title=title,
+ price_rub=price_rub,
+ publish_date=publish_date,
+ views_total=views_total,
+ views_today=views_today,
+ rooms=rooms,
+ area_m2=area_m2,
+ kitchen_area_m2=kitchen_area_m2,
+ floor=floor,
+ total_floors=total_floors,
+ balcony_loggia=balcony_loggia,
+ room_layout=room_layout,
+ bathroom_type=bathroom_type,
+ windows_view=windows_view,
+ repair_state=repair_state,
+ sale_type=sale_type,
+ mortgage_available=mortgage_available,
+ house_type=house_type,
+ total_floors_house=total_floors_house,
+ passenger_elevators=passenger_elevators,
+ cargo_elevators=cargo_elevators,
+ has_concierge=has_concierge,
+ closed_yard=closed_yard,
+ house_catalog_url=house_catalog_url,
+ address_full=address_full,
+ lat=lat,
+ lon=lon,
+ avito_location_id=avito_location_id,
+ metro_stations=metro_stations,
+ description=description,
+ owners_count=owners_count,
+ owners_at_least=owners_at_least,
+ last_owner_change_date=last_owner_change_date,
+ encumbrances_clean=encumbrances_clean,
+ registry_match=registry_match,
+ photo_urls=photo_urls,
+ )
+
+
+# ── save_detail_enrichment ────────────────────────────────────────────────────
+def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
+ """UPDATE listings SET <25+ cols> WHERE source='avito' AND source_id=:item_id.
+
+ Returns True если строка обновлена, False если listing не найден.
+ Устанавливает detail_enriched_at=NOW().
+ House-level поля (passenger_elevators, has_concierge, closed_yard,
+ total_floors_house, house_type) игнорируются — canonical из Houses Catalog (Stage 2c).
+ """
+ result = db.execute(
+ text("""
+ UPDATE listings SET
+ kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2),
+ balcony_loggia = COALESCE(:balcony_loggia, balcony_loggia),
+ room_layout = COALESCE(:room_layout, room_layout),
+ bathroom_type = COALESCE(:bathroom_type, bathroom_type),
+ windows_view = COALESCE(:windows_view, windows_view),
+ sale_type = COALESCE(:sale_type, sale_type),
+ mortgage_available = COALESCE(:mortgage_available, mortgage_available),
+ views_total = COALESCE(:views_total, views_total),
+ views_today = COALESCE(:views_today, views_today),
+ publish_date = COALESCE(:publish_date, publish_date),
+ description = COALESCE(:description, description),
+ owners_count = COALESCE(:owners_count, owners_count),
+ owners_at_least = COALESCE(:owners_at_least, owners_at_least),
+ last_owner_change_date = COALESCE(:last_owner_change_date, last_owner_change_date),
+ encumbrances_clean = COALESCE(:encumbrances_clean, encumbrances_clean),
+ registry_match = COALESCE(:registry_match, registry_match),
+ metro_stations = COALESCE(CAST(:metro_stations AS jsonb), metro_stations),
+ avito_location_id = COALESCE(:avito_location_id, avito_location_id),
+ lat = COALESCE(:lat, lat),
+ lon = COALESCE(:lon, lon),
+ address = COALESCE(:address, address),
+ rooms = COALESCE(:rooms, rooms),
+ area_m2 = COALESCE(:area_m2, area_m2),
+ floor = COALESCE(:floor, floor),
+ total_floors = COALESCE(:total_floors, total_floors),
+ repair_state = COALESCE(:repair_state, repair_state),
+ price_rub = COALESCE(:price_rub, price_rub),
+ detail_enriched_at = NOW()
+ WHERE source = 'avito' AND source_id = :item_id
+ """),
+ {
+ "item_id": e.item_id,
+ "kitchen_area_m2": e.kitchen_area_m2,
+ "balcony_loggia": e.balcony_loggia,
+ "room_layout": e.room_layout,
+ "bathroom_type": e.bathroom_type,
+ "windows_view": e.windows_view,
+ "sale_type": e.sale_type,
+ "mortgage_available": e.mortgage_available,
+ "views_total": e.views_total,
+ "views_today": e.views_today,
+ "publish_date": e.publish_date,
+ "description": e.description,
+ "owners_count": e.owners_count,
+ "owners_at_least": e.owners_at_least,
+ "last_owner_change_date": e.last_owner_change_date,
+ "encumbrances_clean": e.encumbrances_clean,
+ "registry_match": e.registry_match,
+ "metro_stations": (
+ json.dumps(e.metro_stations, ensure_ascii=False) if e.metro_stations else None
+ ),
+ "avito_location_id": e.avito_location_id,
+ "lat": e.lat,
+ "lon": e.lon,
+ "address": e.address_full,
+ "rooms": e.rooms,
+ "area_m2": e.area_m2,
+ "floor": e.floor,
+ "total_floors": e.total_floors,
+ "repair_state": e.repair_state,
+ "price_rub": e.price_rub,
+ },
+ )
+ db.commit()
+ found = result.rowcount > 0
+ if not found:
+ logger.warning(
+ "save_detail_enrichment: listing not found in DB for item_id=%s", e.item_id
+ )
+ else:
+ logger.info("save_detail_enrichment: updated listing item_id=%s", e.item_id)
+ return found
+
+
+# ── Internal helpers ─────────────────────────────────────────────────────────
+def _text(tree: HTMLParser, selector: str) -> str | None:
+ """Вернуть stripped text первого совпавшего элемента или None."""
+ el = tree.css_first(selector)
+ if el is None:
+ return None
+ t = el.text(strip=True)
+ return t or None
+
+
+def _try_float(val: Any) -> float | None:
+ if val is None:
+ return None
+ try:
+ return float(str(val).replace(",", "."))
+ except (ValueError, TypeError):
+ return None
+
+
+def _try_int(val: Any) -> int | None:
+ if val is None:
+ return None
+ s = str(val).strip()
+ m = _INT_RE.search(s)
+ if m:
+ try:
+ return int(m.group())
+ except ValueError:
+ return None
+ return None
+
+
+def _parse_float_m2(val: str) -> float | None:
+ """'90.1 м²' → 90.1. '75 м²' → 75.0."""
+ m = _FLOAT_RE.search(val)
+ if m:
+ try:
+ return float(m.group().replace(",", "."))
+ except ValueError:
+ return None
+ return None
+
+
+def _parse_floor_split(val: str) -> tuple[int | None, int | None]:
+ """'20 из 26' → (20, 26)."""
+ m = _FLOOR_SPLIT_RE.search(val)
+ if m:
+ return _try_int(m.group(1)), _try_int(m.group(2))
+ return None, None
+
+
+def _map_lower(val: str, mapping: dict[str, str]) -> str | None:
+ """Нижний регистр + strip → lookup в mapping. None если не найдено."""
+ key = val.strip().lower()
+ return mapping.get(key)
+
+
+def _extract_item_id(tree: HTMLParser) -> str | None:
+ """Извлечь item_id из [data-marker='item-view/item-id'] text."""
+ el = tree.css_first("[data-marker='item-view/item-id']")
+ if el is None:
+ return None
+ text_val = el.text(strip=True)
+ m = _ITEM_ID_RE.search(text_val)
+ return m.group(1) if m else None
+
+
+def _extract_price(tree: HTMLParser) -> int | None:
+ """Извлечь price из [itemprop='price'] content attribute."""
+ el = tree.css_first("[itemprop='price']")
+ if el is None:
+ return None
+ content = el.attributes.get("content")
+ return _try_int(content)
+
+
+def _extract_meta(tree: HTMLParser) -> tuple[date | None, int | None, int | None]:
+ """Извлечь publish_date, views_total, views_today из item-id блока."""
+ el = tree.css_first("[data-marker='item-view/item-id']")
+ if el is None:
+ return None, None, None
+
+ # Поиск по всему тексту включая дочерние элементы
+ full_text = el.text(strip=False)
+
+ publish_date: date | None = None
+ m_date = _PUBLISH_DATE_RE.search(full_text)
+ if m_date:
+ try:
+ day = int(m_date.group(1))
+ month_word = m_date.group(2).lower()
+ month = RUS_MONTHS.get(month_word)
+ # Год — текущий (Avito не показывает год для свежих объявлений)
+ import datetime
+
+ current_year = datetime.date.today().year
+ if month:
+ publish_date = date(current_year, month, day)
+ except (ValueError, KeyError):
+ pass
+
+ views_total: int | None = None
+ m_total = _VIEWS_TOTAL_RE.search(full_text)
+ if m_total:
+ views_total = int(m_total.group(1))
+
+ views_today: int | None = None
+ m_today = _VIEWS_TODAY_RE.search(full_text)
+ if m_today:
+ views_today = int(m_today.group(1))
+
+ # Попробуем найти views в отдельных data-marker элементах (новый Avito layout)
+ if views_total is None:
+ v_el = tree.css_first("[data-marker='item-view/total-views']")
+ if v_el is not None:
+ m = _VIEWS_TOTAL_RE.search(v_el.text(strip=True))
+ if m:
+ views_total = int(m.group(1))
+
+ if views_today is None:
+ t_el = tree.css_first("[data-marker='item-view/today-views']")
+ if t_el is not None:
+ m = _VIEWS_TODAY_RE.search(t_el.text(strip=True))
+ if m:
+ views_today = int(m.group(1))
+
+ return publish_date, views_total, views_today
+
+
+def _parse_params_ul(ul: Node) -> dict[str, str]:
+ """Парсим с - Label: Value
→ {label: value} dict."""
+ result: dict[str, str] = {}
+ for li in ul.css("li"):
+ text_val = li.text(strip=True)
+ if ":" in text_val:
+ label, _, value = text_val.partition(":")
+ label = label.strip()
+ value = value.strip()
+ if label and value:
+ result[label] = value
+ return result
+
+
+def _extract_metro(text_val: str) -> list[dict[str, Any]]:
+ """Извлечь станции метро из текста описания.
+
+ Примеры: 'Чкаловская 11-15 мин пешком', 'от 5 мин пешком'.
+ """
+ stations: list[dict[str, Any]] = []
+ for m in METRO_RE.finditer(text_val):
+ name = m.group(1)
+ if m.group(2) and m.group(3):
+ # диапазон: 11-15
+ min_from = int(m.group(2))
+ min_to = int(m.group(3))
+ elif m.group(4):
+ # "от N мин"
+ min_from = None
+ min_to = int(m.group(4))
+ else:
+ continue
+
+ stations.append(
+ {
+ "name": name,
+ "min_to": min_to,
+ "min_from": min_from,
+ "mode": "walk",
+ }
+ )
+ return stations
diff --git a/tradein-mvp/backend/app/services/scrapers/avito_exceptions.py b/tradein-mvp/backend/app/services/scrapers/avito_exceptions.py
new file mode 100644
index 00000000..0ea56350
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/avito_exceptions.py
@@ -0,0 +1,17 @@
+"""Avito-specific exceptions для anti-bot detection."""
+
+
+class AvitoError(Exception):
+ """Base для всех Avito scraper exceptions."""
+
+
+class AvitoBlockedError(AvitoError):
+ """HTTP 403 от Avito — IP-level block detected."""
+
+
+class AvitoRateLimitedError(AvitoError):
+ """HTTP 429 от Avito — rate limit triggered."""
+
+
+class AvitoParseError(AvitoError):
+ """Cannot parse Avito HTML structure (selector changes)."""
diff --git a/tradein-mvp/backend/app/services/scrapers/avito_houses.py b/tradein-mvp/backend/app/services/scrapers/avito_houses.py
new file mode 100644
index 00000000..743e8619
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/avito_houses.py
@@ -0,0 +1,1147 @@
+"""avito_houses.py — Avito Houses Catalog parser (Stage 2c).
+
+Парсит страницу /catalog/houses//, извлекает window.__preloadedState__
+и разбирает 10 виджетов: housePage, reviews, miniSerp, housePlacementHistory,
+recommendations.
+
+Flow:
+ fetch_house_catalog(house_url)
+ → HTTP GET (curl_cffi chrome120)
+ → regex extract __preloadedState__
+ → JS-unescape + json.loads
+ → parse_houses_state(state, house_url)
+ → HouseCatalogEnrichment
+
+Таблицы:
+ houses (009_houses.sql + 010_houses_alter.sql)
+ house_reviews (014_house_reviews.sql)
+ sellers (012_sellers.sql)
+ listings.seller_id_fk (013_listings_alter_seller.sql)
+ house_placement_history (017_house_placement_history.sql)
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from dataclasses import dataclass, field
+from datetime import UTC, date, datetime
+from typing import Any
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
+
+logger = logging.getLogger(__name__)
+
+AVITO_BASE = "https://www.avito.ru"
+
+# Regex для извлечения __preloadedState__ из HTML
+PRELOADED_RE = re.compile(
+ r'window\.__preloadedState__\s*=\s*JSON\.parse\("(.*?)"\)\s*;',
+ re.DOTALL,
+)
+
+# Русские месяцы для парсинга дат отзывов
+RUS_MONTHS = {
+ "января": 1,
+ "февраля": 2,
+ "марта": 3,
+ "апреля": 4,
+ "мая": 5,
+ "июня": 6,
+ "июля": 7,
+ "августа": 8,
+ "сентября": 9,
+ "октября": 10,
+ "ноября": 11,
+ "декабря": 12,
+}
+
+# Маппинг expandParams type → (field_name, cast_type)
+EXPAND_PARAMS_MAP: dict[str, tuple[str, str]] = {
+ "Год постройки": ("year_built", "int"),
+ "Этажей": ("total_floors", "int"),
+ "Тип дома": ("house_type", "house_type"),
+ "Горячее водоснабжение": ("hot_water", "str"),
+ "Пассажирский лифт": ("passenger_elevators", "int"),
+ "Грузовой лифт": ("cargo_elevators", "int"),
+ "Консьерж": ("has_concierge", "bool_da"),
+ "Перекрытия": ("material_floors", "str"),
+ "Парковка": ("parking_type", "str"),
+ "Детская площадка": ("has_playground", "bool_da"),
+ "Закрытый двор": ("closed_yard", "bool_da"),
+}
+
+# Нормализация типа дома из Avito текста в DB enum
+HOUSE_TYPE_MAP: dict[str, str] = {
+ "монолитный": "monolith",
+ "монолит": "monolith",
+ "панельный": "panel",
+ "панель": "panel",
+ "кирпичный": "brick",
+ "кирпич": "brick",
+ "монолитно-кирпичный": "monolith_brick",
+ "блочный": "block",
+ "деревянный": "wood",
+}
+
+
+# ---------------------------------------------------------------------------
+# Dataclasses
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class HouseInfo:
+ ext_id: int # avitoId (int)
+ ext_id_hash: str | None = None # base64 internal ID
+ title: str | None = None
+ short_address: str | None = None # краткий адрес
+ full_address: str | None = None # полный адрес
+ lat: float | None = None
+ lon: float | None = None
+ year_built: int | None = None
+ total_floors: int | None = None
+ house_type: str | None = None # нормализован: monolith/panel/brick/...
+ material_floors: str | None = None
+ hot_water: str | None = None
+ passenger_elevators: int | None = None
+ cargo_elevators: int | None = None
+ has_concierge: bool | None = None
+ parking_type: str | None = None
+ has_playground: bool | None = None
+ closed_yard: bool | None = None
+ developer_name: str | None = None
+ developer_key: str | None = None
+ infrastructure_summary: str | None = None
+ infrastructure_walk_distance: str | None = None
+ rating_score: float | None = None
+ rating_string: str | None = None
+ reviews_count: int | None = None
+ rating_distribution: list[dict[str, Any]] = field(default_factory=list)
+ map_pins: list[dict[str, Any]] = field(default_factory=list)
+ raw_characteristics: list[dict[str, Any]] = field(default_factory=list)
+
+
+@dataclass
+class HouseReview:
+ ext_review_id: int
+ author_name: str | None = None
+ review_title: str | None = None
+ score: int | None = None
+ model_experience: str | None = None
+ rated_date: date | None = None
+ text_main: str | None = None
+ text_pros: str | None = None
+ text_cons: str | None = None
+ raw_payload: dict[str, Any] | None = None
+
+
+@dataclass
+class PlacementHistoryItem:
+ ext_item_id: str
+ title: str | None = None
+ start_price: int | None = None
+ start_price_date: date | None = None
+ last_price: int | None = None
+ last_price_date: date | None = None
+ exposure_days: int | None = None
+ removed_date: date | None = None # ВСЕГДА None для source='avito_widget'
+ raw_payload: dict[str, Any] | None = None
+
+
+@dataclass
+class SellerInfo:
+ ext_seller_id: str
+ name: str
+ seller_type: str | None = None
+ on_platform_since: str | None = None
+ profile_url: str | None = None
+
+
+@dataclass
+class MiniSerpListing:
+ ext_item_id: int
+ title: str | None = None
+ price_rub: int | None = None
+ description: str | None = None
+ url: str = ""
+ metro_text: str | None = None
+ metro_color: str | None = None
+ seller: SellerInfo | None = None
+
+
+@dataclass
+class RecommendationItem:
+ title: str | None = None
+ price_text: str | None = None
+ address: str | None = None
+ url: str = ""
+ image_url: str | None = None
+
+
+@dataclass
+class HouseCatalogEnrichment:
+ house_url: str
+ house: HouseInfo
+ reviews: list[HouseReview] = field(default_factory=list)
+ placement_history: list[PlacementHistoryItem] = field(default_factory=list)
+ mini_serp: list[MiniSerpListing] = field(default_factory=list)
+ recommendations: list[RecommendationItem] = field(default_factory=list)
+ reviews_next_page_url: str | None = None
+
+
+# ---------------------------------------------------------------------------
+# Helper functions
+# ---------------------------------------------------------------------------
+
+
+def _parse_ru_date(text_val: str | None) -> date | None:
+ """Парсит дату вида "9 октября 2025" → date(2025, 10, 9)."""
+ if not text_val:
+ return None
+ m = re.match(r"(\d+)\s+([а-я]+)\s+(\d{4})", text_val.strip(), re.IGNORECASE)
+ if not m:
+ return None
+ day = int(m.group(1))
+ month_word = m.group(2).lower()
+ year = int(m.group(3))
+ month = RUS_MONTHS.get(month_word)
+ return date(year, month, day) if month else None
+
+
+def _unix_to_date(ts: int | None) -> date | None:
+ """Конвертирует unix timestamp (секунды) → date."""
+ if ts is None:
+ return None
+ try:
+ return datetime.fromtimestamp(ts, tz=UTC).date()
+ except (OSError, OverflowError, ValueError):
+ logger.warning("Не удалось конвертировать unix timestamp: %r", ts)
+ return None
+
+
+def _strip_price(price_str: str | None) -> int | None:
+ """Извлекает целое число из строки "11 990 000 ₽" → 11990000."""
+ if not price_str:
+ return None
+ digits = re.sub(r"[^\d]", "", price_str)
+ return int(digits) if digits else None
+
+
+def _normalize_house_type(raw: str | None) -> str | None:
+ """Нормализует тип дома: "Монолитный" → "monolith"."""
+ if not raw:
+ return None
+ return HOUSE_TYPE_MAP.get(raw.lower(), "other")
+
+
+def _cast_expand_param(value: str, cast_type: str) -> Any:
+ """Кастует строковое значение expandParams к нужному типу."""
+ if cast_type == "int":
+ try:
+ return int(value)
+ except (ValueError, TypeError):
+ return None
+ if cast_type == "bool_da":
+ return value.strip().lower() == "да"
+ if cast_type == "house_type":
+ return _normalize_house_type(value)
+ # str
+ return value
+
+
+def _parse_expand_params(items: list[dict[str, Any]]) -> dict[str, Any]:
+ """Извлекает поля из expandParams['items'] по EXPAND_PARAMS_MAP.
+
+ Возвращает dict с распарсенными полями + raw_characteristics (полный список).
+ """
+ result: dict[str, Any] = {}
+ for category in items:
+ for param in category.get("params", []):
+ param_type = param.get("type", "")
+ if param_type in EXPAND_PARAMS_MAP:
+ field_name, cast_type = EXPAND_PARAMS_MAP[param_type]
+ raw_value = param.get("value", "")
+ result[field_name] = _cast_expand_param(str(raw_value), cast_type)
+ return result
+
+
+def _get_widget(placeholders: list[dict[str, Any]], widget_type: str) -> dict[str, Any] | None:
+ """Ищет виджет по type в массиве placeholders."""
+ for w in placeholders:
+ if w.get("type") == widget_type:
+ return w
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Widget parsers
+# ---------------------------------------------------------------------------
+
+
+def _parse_house_page(widget: dict[str, Any]) -> HouseInfo:
+ """Парсит виджет housePage → HouseInfo.
+
+ Путь: widget['props']['developmentData'] — основной объект с данными дома.
+ """
+ props = widget.get("props", {})
+ dd = props.get("developmentData", {})
+
+ # expandParams → характеристики дома
+ about = dd.get("aboutDevelopment", {})
+ expand_params_items: list[dict[str, Any]] = (
+ about.get("expandParams", {}).get("items", [])
+ )
+ parsed_params = _parse_expand_params(expand_params_items)
+
+ # Рейтинг
+ rating_badge = dd.get("ratingBadge", {})
+ rating_info = rating_badge.get("info", {})
+
+ rating_summary = dd.get("ratingSummaryStat", {})
+ rating_stat: list[dict[str, Any]] = rating_summary.get("ratingStat", [])
+
+ # Карта/инфраструктура
+ map_preview = dd.get("mapPreview", {})
+
+ # Разработчик
+ developer = dd.get("developer", {})
+
+ # Координаты
+ coords = dd.get("coords", {})
+
+ return HouseInfo(
+ ext_id=dd.get("avitoId", 0),
+ ext_id_hash=dd.get("id"),
+ title=dd.get("title"),
+ short_address=dd.get("address"),
+ full_address=dd.get("fullAddress"),
+ lat=coords.get("lat"),
+ lon=coords.get("lng"),
+ year_built=parsed_params.get("year_built"),
+ total_floors=parsed_params.get("total_floors"),
+ house_type=parsed_params.get("house_type"),
+ material_floors=parsed_params.get("material_floors"),
+ hot_water=parsed_params.get("hot_water"),
+ passenger_elevators=parsed_params.get("passenger_elevators"),
+ cargo_elevators=parsed_params.get("cargo_elevators"),
+ has_concierge=parsed_params.get("has_concierge"),
+ parking_type=parsed_params.get("parking_type"),
+ has_playground=parsed_params.get("has_playground"),
+ closed_yard=parsed_params.get("closed_yard"),
+ developer_name=developer.get("name"),
+ developer_key=developer.get("key"),
+ infrastructure_summary=map_preview.get("objects"),
+ infrastructure_walk_distance=map_preview.get("distance"),
+ rating_score=rating_info.get("score"),
+ rating_string=rating_info.get("scoreString"),
+ reviews_count=rating_summary.get("reviewCount"),
+ rating_distribution=rating_stat,
+ map_pins=map_preview.get("pins", []),
+ raw_characteristics=expand_params_items,
+ )
+
+
+def _parse_single_review(r: dict[str, Any]) -> HouseReview:
+ """Парсит один объект отзыва из entries."""
+ text_main: str | None = None
+ text_pros: str | None = None
+ text_cons: str | None = None
+
+ for section in r.get("textSections", []):
+ title = section.get("title", "")
+ section_text = section.get("text")
+ if title == "":
+ text_main = section_text
+ elif title == "Преимущества":
+ text_pros = section_text
+ elif title == "Недостатки":
+ text_cons = section_text
+
+ author = r.get("author", {})
+ return HouseReview(
+ ext_review_id=r["id"],
+ author_name=author.get("title"),
+ review_title=r.get("reviewTitle"),
+ score=r.get("score"),
+ model_experience=r.get("modelExperience"),
+ rated_date=_parse_ru_date(r.get("rated")),
+ text_main=text_main,
+ text_pros=text_pros,
+ text_cons=text_cons,
+ raw_payload=r,
+ )
+
+
+def _parse_reviews(widget: dict[str, Any]) -> tuple[list[HouseReview], str | None]:
+ """Парсит виджет reviews → (список HouseReview, следующая страница URL).
+
+ Entries — смешанный список с type='rating' (отзывы) и type='pages' (пагинация).
+ """
+ reviews: list[HouseReview] = []
+ next_page_url: str | None = None
+
+ entries = widget.get("props", {}).get("entries", [])
+ for entry in entries:
+ entry_type = entry.get("type")
+ value = entry.get("value", {})
+
+ if entry_type == "rating":
+ try:
+ reviews.append(_parse_single_review(value))
+ except (KeyError, TypeError) as exc:
+ logger.warning("Не удалось распарсить отзыв: %r — %s", value.get("id"), exc)
+ elif entry_type == "pages":
+ next_page_url = value.get("nextPageUrl")
+
+ return reviews, next_page_url
+
+
+def _parse_seller(seller_raw: dict[str, Any]) -> SellerInfo | None:
+ """Парсит seller из miniSerp item → SellerInfo."""
+ if not seller_raw:
+ return None
+ name = seller_raw.get("name", "")
+ if not name:
+ return None
+
+ profile_url = seller_raw.get("url", "")
+ # ext_seller_id — последний сегмент URL: "/brands/domrf66" → "domrf66"
+ ext_seller_id = profile_url.strip("/").split("/")[-1] if profile_url else ""
+ if not ext_seller_id:
+ logger.warning("Не удалось извлечь ext_seller_id из seller URL: %r", profile_url)
+ return None
+
+ return SellerInfo(
+ ext_seller_id=ext_seller_id,
+ name=name,
+ seller_type=seller_raw.get("type"),
+ on_platform_since=seller_raw.get("from"),
+ profile_url=profile_url or None,
+ )
+
+
+def _parse_mini_serp(widget: dict[str, Any]) -> list[MiniSerpListing]:
+ """Парсит виджет miniSerp → список активных объявлений в доме."""
+ listings: list[MiniSerpListing] = []
+
+ items = widget.get("props", {}).get("items", [])
+ for item in items:
+ geo = item.get("geo", {})
+ colors: list[str] = geo.get("colors", [])
+
+ seller_raw = item.get("seller")
+ seller = _parse_seller(seller_raw) if seller_raw else None
+
+ price_raw = item.get("price")
+ price_rub = _strip_price(price_raw) if isinstance(price_raw, str) else price_raw
+
+ listings.append(MiniSerpListing(
+ ext_item_id=item["id"],
+ title=item.get("title"),
+ price_rub=price_rub,
+ description=item.get("description"),
+ url=item.get("url", ""),
+ metro_text=geo.get("content"),
+ metro_color=colors[0] if colors else None,
+ seller=seller,
+ ))
+
+ return listings
+
+
+def _parse_placement_history(widget: dict[str, Any]) -> list[PlacementHistoryItem]:
+ """Парсит виджет housePlacementHistory → список PlacementHistoryItem.
+
+ ВАЖНО: removed_date ВСЕГДА None — виджет не отдаёт эту дату.
+ Точная дата только из IMV API (Stage 2d).
+ """
+ result: list[PlacementHistoryItem] = []
+
+ items = widget.get("props", {}).get("items", [])
+ for item in items:
+ item_copy = {k: v for k, v in item.items() if k != "itemImage"}
+ result.append(PlacementHistoryItem(
+ ext_item_id=str(item["id"]),
+ title=item.get("title"),
+ start_price=item.get("startPrice"),
+ start_price_date=_unix_to_date(item.get("startPriceDate")),
+ last_price=item.get("lastPrice"),
+ last_price_date=_unix_to_date(item.get("lastPriceDate")),
+ exposure_days=item.get("exposure"),
+ removed_date=None, # widget не отдаёт! IMV API Stage 2d
+ raw_payload=item_copy,
+ ))
+
+ return result
+
+
+def _parse_recommendations(widget: dict[str, Any]) -> list[RecommendationItem]:
+ """Парсит виджет recommendations → lightweight список похожих объявлений."""
+ result: list[RecommendationItem] = []
+
+ items = widget.get("props", {}).get("items", [])
+ for item in items:
+ additional_info: list[str] = item.get("additionalInfo", [])
+ address = additional_info[0] if additional_info else None
+
+ images: list[dict[str, Any]] = item.get("images", [])
+ image_url: str | None = None
+ if images:
+ image_url = images[0].get("208x156")
+
+ result.append(RecommendationItem(
+ title=item.get("title"),
+ price_text=item.get("priceRange"),
+ address=address,
+ url=item.get("url", ""),
+ image_url=image_url,
+ ))
+
+ return result
+
+
+# ---------------------------------------------------------------------------
+# Core parser (pure, no network)
+# ---------------------------------------------------------------------------
+
+
+def parse_houses_state(state: dict[str, Any], house_url: str) -> HouseCatalogEnrichment:
+ """Парсит уже разобранный __preloadedState__ dict → HouseCatalogEnrichment.
+
+ Pure функция: нет HTTP, нет I/O. Принимает state dict и house_url (для поля
+ HouseCatalogEnrichment.house_url). Используется в тестах и из fetch_house_catalog.
+
+ Raises:
+ ValueError если placeholders не массив или housePage виджет не найден.
+ """
+ try:
+ placeholders: list[dict[str, Any]] = (
+ state["data"]["data"]["page"]["placeholders"]
+ )
+ except (KeyError, TypeError) as exc:
+ raise ValueError(
+ f"Не удалось найти placeholders в __preloadedState__: {exc}"
+ ) from exc
+
+ if not isinstance(placeholders, list):
+ raise ValueError(
+ f"placeholders должен быть массивом, получен: {type(placeholders).__name__}"
+ )
+
+ # housePage — обязательный виджет
+ house_page_widget = _get_widget(placeholders, "housePage")
+ if house_page_widget is None:
+ raise ValueError("Виджет 'housePage' не найден в placeholders")
+
+ house_info = _parse_house_page(house_page_widget)
+
+ # reviews — опциональный
+ reviews: list[HouseReview] = []
+ next_page_url: str | None = None
+ reviews_widget = _get_widget(placeholders, "reviews")
+ if reviews_widget is not None:
+ reviews, next_page_url = _parse_reviews(reviews_widget)
+ else:
+ logger.debug("Виджет 'reviews' не найден — пропускаем")
+
+ # miniSerp — опциональный
+ mini_serp: list[MiniSerpListing] = []
+ mini_serp_widget = _get_widget(placeholders, "miniSerp")
+ if mini_serp_widget is not None:
+ mini_serp = _parse_mini_serp(mini_serp_widget)
+ else:
+ logger.debug("Виджет 'miniSerp' не найден — пропускаем")
+
+ # housePlacementHistory — опциональный
+ placement_history: list[PlacementHistoryItem] = []
+ history_widget = _get_widget(placeholders, "housePlacementHistory")
+ if history_widget is not None:
+ placement_history = _parse_placement_history(history_widget)
+ else:
+ logger.debug("Виджет 'housePlacementHistory' не найден — пропускаем")
+
+ # recommendations — опциональный
+ recommendations: list[RecommendationItem] = []
+ recs_widget = _get_widget(placeholders, "recommendations")
+ if recs_widget is not None:
+ recommendations = _parse_recommendations(recs_widget)
+ else:
+ logger.debug("Виджет 'recommendations' не найден — пропускаем")
+
+ return HouseCatalogEnrichment(
+ house_url=house_url,
+ house=house_info,
+ reviews=reviews,
+ placement_history=placement_history,
+ mini_serp=mini_serp,
+ recommendations=recommendations,
+ reviews_next_page_url=next_page_url,
+ )
+
+
+# ---------------------------------------------------------------------------
+# HTTP fetch (thin wrapper, requires curl_cffi)
+# ---------------------------------------------------------------------------
+
+
+async def fetch_house_catalog(
+ house_url: str,
+ *,
+ cffi_session: Any | None = None,
+) -> HouseCatalogEnrichment:
+ """GET {AVITO_BASE}{house_url} → extract window.__preloadedState__ → parse 10 виджетов.
+
+ Параметры:
+ house_url — относительный путь, напр. /catalog/houses/ekaterinburg/ul_postovskogo/3171365
+ cffi_session — опциональная curl_cffi AsyncSession (для переиспользования пула соединений).
+ Если None — создаётся временная сессия.
+
+ Raises:
+ RuntimeError если curl_cffi не установлен.
+ httpx.HTTPStatusError / curl_cffi аналог если status != 200.
+ ValueError если __preloadedState__ не найден или placeholders не массив.
+ """
+ try:
+ from curl_cffi.requests import AsyncSession as CffiAsyncSession
+ except ImportError as exc:
+ raise RuntimeError(
+ "curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml."
+ ) from exc
+
+ _own_session = False
+ if cffi_session is None:
+ cffi_session = CffiAsyncSession(impersonate="chrome120")
+ _own_session = True
+
+ try:
+ url = f"{AVITO_BASE}{house_url}"
+ logger.info("Houses Catalog fetch: %s", url)
+
+ resp = await cffi_session.get(url)
+ if resp.status_code == 403:
+ raise AvitoBlockedError(f"Avito houses HTTP 403 for {url}")
+ if resp.status_code == 429:
+ raise AvitoRateLimitedError(f"Avito houses HTTP 429 for {url}")
+ resp.raise_for_status()
+ html: str = resp.text
+
+ # Извлекаем экранированный JSON из window.__preloadedState__ = JSON.parse("...")
+ match = PRELOADED_RE.search(html)
+ if match is None:
+ raise ValueError(
+ f"window.__preloadedState__ не найден в HTML страницы: {url}"
+ )
+
+ raw = match.group(1)
+ # JS-стиль unescaping: \\" → " , \\\\ → \ , \\/ → /
+ # НЕ используем .decode("unicode_escape") — ломает многобайтный UTF-8 (кириллица).
+ raw = raw.replace('\\"', '"').replace("\\\\", "\\").replace("\\/", "/")
+
+ try:
+ state: dict[str, Any] = json.loads(raw)
+ except json.JSONDecodeError:
+ logger.warning("json.loads с replace-unescape не удался, пробуем unicode_escape")
+ try:
+ state = json.loads(bytes(raw, "utf-8").decode("unicode_escape"))
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc2:
+ raise ValueError(
+ f"Не удалось распарсить __preloadedState__ JSON: {exc2}"
+ ) from exc2
+
+ logger.info("Houses Catalog state extracted, parsing widgets")
+ return parse_houses_state(state, house_url)
+
+ finally:
+ if _own_session:
+ await cffi_session.close()
+
+
+# ---------------------------------------------------------------------------
+# Save functions
+# ---------------------------------------------------------------------------
+
+
+def upsert_house(db: Session, h: HouseInfo) -> int:
+ """INSERT INTO houses ON CONFLICT (source, ext_house_id) DO UPDATE.
+
+ Source='avito'. Возвращает houses.id.
+
+ Маппинг полей HouseInfo → DDL колонки (009 + 010):
+ ext_id → ext_house_id (text)
+ ext_id_hash → avito_id_hash
+ short_address → short_address (010)
+ full_address → full_address (010)
+ address (DDL) → используем short_address как fallback
+ rating_score → rating_score (010, numeric(4,2))
+ rating (DDL) → округлённый numeric(2,1) из rating_score
+ """
+ row = db.execute(
+ text("""
+ INSERT INTO houses (
+ source, ext_house_id, url,
+ address, short_address, full_address,
+ lat, lon,
+ avito_id_hash,
+ year_built, house_type, total_floors,
+ passenger_elevators, cargo_elevators,
+ has_concierge, closed_yard,
+ material_floors, hot_water,
+ parking_type, has_playground,
+ developer_name, developer_key,
+ infrastructure_summary, infrastructure_walk_distance,
+ rating, rating_score, rating_string, reviews_count,
+ rating_distribution,
+ map_pins,
+ raw_characteristics,
+ last_scraped_at
+ ) VALUES (
+ 'avito', CAST(:ext_house_id AS text), CAST(:url AS text),
+ CAST(:address AS text), CAST(:short_address AS text), CAST(:full_address AS text),
+ CAST(:lat AS double precision), CAST(:lon AS double precision),
+ CAST(:avito_id_hash AS text),
+ CAST(:year_built AS int), CAST(:house_type AS text), CAST(:total_floors AS int),
+ CAST(:passenger_elevators AS int), CAST(:cargo_elevators AS int),
+ CAST(:has_concierge AS boolean), CAST(:closed_yard AS boolean),
+ CAST(:material_floors AS text), CAST(:hot_water AS text),
+ CAST(:parking_type AS text), CAST(:has_playground AS boolean),
+ CAST(:developer_name AS text), CAST(:developer_key AS text),
+ CAST(:infrastructure_summary AS text), CAST(:infrastructure_walk_distance AS text),
+ CAST(:rating AS numeric), CAST(:rating_score AS numeric),
+ CAST(:rating_string AS text), CAST(:reviews_count AS int),
+ CAST(:rating_distribution AS jsonb),
+ CAST(:map_pins AS jsonb),
+ CAST(:raw_characteristics AS jsonb),
+ NOW()
+ )
+ ON CONFLICT (source, ext_house_id) DO UPDATE SET
+ url = EXCLUDED.url,
+ address = EXCLUDED.address,
+ short_address = EXCLUDED.short_address,
+ full_address = EXCLUDED.full_address,
+ lat = EXCLUDED.lat,
+ lon = EXCLUDED.lon,
+ avito_id_hash = EXCLUDED.avito_id_hash,
+ year_built = EXCLUDED.year_built,
+ house_type = EXCLUDED.house_type,
+ total_floors = EXCLUDED.total_floors,
+ passenger_elevators = EXCLUDED.passenger_elevators,
+ cargo_elevators = EXCLUDED.cargo_elevators,
+ has_concierge = EXCLUDED.has_concierge,
+ closed_yard = EXCLUDED.closed_yard,
+ material_floors = EXCLUDED.material_floors,
+ hot_water = EXCLUDED.hot_water,
+ parking_type = EXCLUDED.parking_type,
+ has_playground = EXCLUDED.has_playground,
+ developer_name = EXCLUDED.developer_name,
+ developer_key = EXCLUDED.developer_key,
+ infrastructure_summary = EXCLUDED.infrastructure_summary,
+ infrastructure_walk_distance = EXCLUDED.infrastructure_walk_distance,
+ rating = EXCLUDED.rating,
+ rating_score = EXCLUDED.rating_score,
+ rating_string = EXCLUDED.rating_string,
+ reviews_count = EXCLUDED.reviews_count,
+ rating_distribution = EXCLUDED.rating_distribution,
+ map_pins = EXCLUDED.map_pins,
+ raw_characteristics = EXCLUDED.raw_characteristics,
+ last_scraped_at = NOW()
+ RETURNING id
+ """),
+ {
+ "ext_house_id": str(h.ext_id),
+ "url": "", # caller должен передавать house_url через enrichment
+ "address": h.short_address,
+ "short_address": h.short_address,
+ "full_address": h.full_address,
+ "lat": h.lat,
+ "lon": h.lon,
+ "avito_id_hash": h.ext_id_hash,
+ "year_built": h.year_built,
+ "house_type": h.house_type,
+ "total_floors": h.total_floors,
+ "passenger_elevators": h.passenger_elevators,
+ "cargo_elevators": h.cargo_elevators,
+ "has_concierge": h.has_concierge,
+ "closed_yard": h.closed_yard,
+ "material_floors": h.material_floors,
+ "hot_water": h.hot_water,
+ "parking_type": h.parking_type,
+ "has_playground": h.has_playground,
+ "developer_name": h.developer_name,
+ "developer_key": h.developer_key,
+ "infrastructure_summary": h.infrastructure_summary,
+ "infrastructure_walk_distance": h.infrastructure_walk_distance,
+ # rating (numeric(2,1)) — округлённое значение для совместимости с 009 DDL
+ "rating": round(h.rating_score, 1) if h.rating_score is not None else None,
+ "rating_score": h.rating_score,
+ "rating_string": h.rating_string,
+ "reviews_count": h.reviews_count,
+ "rating_distribution": json.dumps(h.rating_distribution, ensure_ascii=False),
+ "map_pins": json.dumps(h.map_pins, ensure_ascii=False),
+ "raw_characteristics": json.dumps(h.raw_characteristics, ensure_ascii=False),
+ },
+ )
+ house_id: int = row.scalar_one()
+ logger.info("upsert_house: ext_id=%s → houses.id=%s", h.ext_id, house_id)
+ return house_id
+
+
+def _upsert_house_with_url(db: Session, h: HouseInfo, house_url: str) -> int:
+ """Внутренний вариант upsert_house с правильным URL."""
+ row = db.execute(
+ text("""
+ INSERT INTO houses (
+ source, ext_house_id, url,
+ address, short_address, full_address,
+ lat, lon,
+ avito_id_hash,
+ year_built, house_type, total_floors,
+ passenger_elevators, cargo_elevators,
+ has_concierge, closed_yard,
+ material_floors, hot_water,
+ parking_type, has_playground,
+ developer_name, developer_key,
+ infrastructure_summary, infrastructure_walk_distance,
+ rating, rating_score, rating_string, reviews_count,
+ rating_distribution,
+ map_pins,
+ raw_characteristics,
+ last_scraped_at
+ ) VALUES (
+ 'avito', CAST(:ext_house_id AS text), CAST(:url AS text),
+ CAST(:address AS text), CAST(:short_address AS text), CAST(:full_address AS text),
+ CAST(:lat AS double precision), CAST(:lon AS double precision),
+ CAST(:avito_id_hash AS text),
+ CAST(:year_built AS int), CAST(:house_type AS text), CAST(:total_floors AS int),
+ CAST(:passenger_elevators AS int), CAST(:cargo_elevators AS int),
+ CAST(:has_concierge AS boolean), CAST(:closed_yard AS boolean),
+ CAST(:material_floors AS text), CAST(:hot_water AS text),
+ CAST(:parking_type AS text), CAST(:has_playground AS boolean),
+ CAST(:developer_name AS text), CAST(:developer_key AS text),
+ CAST(:infrastructure_summary AS text), CAST(:infrastructure_walk_distance AS text),
+ CAST(:rating AS numeric), CAST(:rating_score AS numeric),
+ CAST(:rating_string AS text), CAST(:reviews_count AS int),
+ CAST(:rating_distribution AS jsonb),
+ CAST(:map_pins AS jsonb),
+ CAST(:raw_characteristics AS jsonb),
+ NOW()
+ )
+ ON CONFLICT (source, ext_house_id) DO UPDATE SET
+ url = EXCLUDED.url,
+ address = EXCLUDED.address,
+ short_address = EXCLUDED.short_address,
+ full_address = EXCLUDED.full_address,
+ lat = EXCLUDED.lat,
+ lon = EXCLUDED.lon,
+ avito_id_hash = EXCLUDED.avito_id_hash,
+ year_built = EXCLUDED.year_built,
+ house_type = EXCLUDED.house_type,
+ total_floors = EXCLUDED.total_floors,
+ passenger_elevators = EXCLUDED.passenger_elevators,
+ cargo_elevators = EXCLUDED.cargo_elevators,
+ has_concierge = EXCLUDED.has_concierge,
+ closed_yard = EXCLUDED.closed_yard,
+ material_floors = EXCLUDED.material_floors,
+ hot_water = EXCLUDED.hot_water,
+ parking_type = EXCLUDED.parking_type,
+ has_playground = EXCLUDED.has_playground,
+ developer_name = EXCLUDED.developer_name,
+ developer_key = EXCLUDED.developer_key,
+ infrastructure_summary = EXCLUDED.infrastructure_summary,
+ infrastructure_walk_distance = EXCLUDED.infrastructure_walk_distance,
+ rating = EXCLUDED.rating,
+ rating_score = EXCLUDED.rating_score,
+ rating_string = EXCLUDED.rating_string,
+ reviews_count = EXCLUDED.reviews_count,
+ rating_distribution = EXCLUDED.rating_distribution,
+ map_pins = EXCLUDED.map_pins,
+ raw_characteristics = EXCLUDED.raw_characteristics,
+ last_scraped_at = NOW()
+ RETURNING id
+ """),
+ {
+ "ext_house_id": str(h.ext_id),
+ "url": house_url,
+ "address": h.short_address,
+ "short_address": h.short_address,
+ "full_address": h.full_address,
+ "lat": h.lat,
+ "lon": h.lon,
+ "avito_id_hash": h.ext_id_hash,
+ "year_built": h.year_built,
+ "house_type": h.house_type,
+ "total_floors": h.total_floors,
+ "passenger_elevators": h.passenger_elevators,
+ "cargo_elevators": h.cargo_elevators,
+ "has_concierge": h.has_concierge,
+ "closed_yard": h.closed_yard,
+ "material_floors": h.material_floors,
+ "hot_water": h.hot_water,
+ "parking_type": h.parking_type,
+ "has_playground": h.has_playground,
+ "developer_name": h.developer_name,
+ "developer_key": h.developer_key,
+ "infrastructure_summary": h.infrastructure_summary,
+ "infrastructure_walk_distance": h.infrastructure_walk_distance,
+ "rating": round(h.rating_score, 1) if h.rating_score is not None else None,
+ "rating_score": h.rating_score,
+ "rating_string": h.rating_string,
+ "reviews_count": h.reviews_count,
+ "rating_distribution": json.dumps(h.rating_distribution, ensure_ascii=False),
+ "map_pins": json.dumps(h.map_pins, ensure_ascii=False),
+ "raw_characteristics": json.dumps(h.raw_characteristics, ensure_ascii=False),
+ },
+ )
+ house_id: int = row.scalar_one()
+ logger.info(
+ "_upsert_house_with_url: ext_id=%s url=%s → houses.id=%s", h.ext_id, house_url, house_id
+ )
+ return house_id
+
+
+def save_house_reviews(db: Session, house_id: int, reviews: list[HouseReview]) -> int:
+ """INSERT INTO house_reviews ON CONFLICT (source, ext_review_id) DO UPDATE.
+
+ Source='avito'. Возвращает количество вставленных/обновлённых строк.
+ """
+ if not reviews:
+ return 0
+
+ count = 0
+ for r in reviews:
+ db.execute(
+ text("""
+ INSERT INTO house_reviews (
+ house_id, source, ext_review_id,
+ author_name, review_title, score,
+ model_experience, rated_date,
+ text_main, text_pros, text_cons,
+ raw_payload, scraped_at
+ ) VALUES (
+ CAST(:house_id AS bigint), 'avito', CAST(:ext_review_id AS bigint),
+ CAST(:author_name AS text), CAST(:review_title AS text), CAST(:score AS int),
+ CAST(:model_experience AS text), CAST(:rated_date AS date),
+ CAST(:text_main AS text), CAST(:text_pros AS text), CAST(:text_cons AS text),
+ CAST(:raw_payload AS jsonb), NOW()
+ )
+ ON CONFLICT (source, ext_review_id) DO UPDATE SET
+ author_name = EXCLUDED.author_name,
+ review_title = EXCLUDED.review_title,
+ score = EXCLUDED.score,
+ model_experience = EXCLUDED.model_experience,
+ rated_date = EXCLUDED.rated_date,
+ text_main = EXCLUDED.text_main,
+ text_pros = EXCLUDED.text_pros,
+ text_cons = EXCLUDED.text_cons,
+ raw_payload = EXCLUDED.raw_payload,
+ scraped_at = NOW()
+ """),
+ {
+ "house_id": house_id,
+ "ext_review_id": r.ext_review_id,
+ "author_name": r.author_name,
+ "review_title": r.review_title,
+ "score": r.score,
+ "model_experience": r.model_experience,
+ "rated_date": r.rated_date,
+ "text_main": r.text_main,
+ "text_pros": r.text_pros,
+ "text_cons": r.text_cons,
+ "raw_payload": (
+ json.dumps(r.raw_payload, ensure_ascii=False) if r.raw_payload else None
+ ),
+ },
+ )
+ count += 1
+
+ logger.info("save_house_reviews: house_id=%s, saved=%d", house_id, count)
+ return count
+
+
+def save_sellers(db: Session, sellers: list[SellerInfo]) -> dict[str, int]:
+ """INSERT INTO sellers ON CONFLICT (source, ext_seller_id) DO UPDATE.
+
+ Source='avito'. Возвращает {ext_seller_id: sellers.id}.
+ """
+ result: dict[str, int] = {}
+ for s in sellers:
+ row = db.execute(
+ text("""
+ INSERT INTO sellers (
+ source, ext_seller_id,
+ name, seller_type, on_platform_since, profile_url,
+ last_seen_at
+ ) VALUES (
+ 'avito', CAST(:ext_seller_id AS text),
+ CAST(:name AS text), CAST(:seller_type AS text),
+ CAST(:on_platform_since AS text), CAST(:profile_url AS text),
+ NOW()
+ )
+ ON CONFLICT (source, ext_seller_id) DO UPDATE SET
+ name = EXCLUDED.name,
+ seller_type = EXCLUDED.seller_type,
+ on_platform_since = EXCLUDED.on_platform_since,
+ profile_url = EXCLUDED.profile_url,
+ last_seen_at = NOW()
+ RETURNING id
+ """),
+ {
+ "ext_seller_id": s.ext_seller_id,
+ "name": s.name,
+ "seller_type": s.seller_type,
+ "on_platform_since": s.on_platform_since,
+ "profile_url": s.profile_url,
+ },
+ )
+ seller_db_id: int = row.scalar_one()
+ result[s.ext_seller_id] = seller_db_id
+
+ logger.info("save_sellers: saved=%d", len(result))
+ return result
+
+
+def link_listings_to_seller(db: Session, ext_item_id_to_seller_db_id: dict[int, int]) -> int:
+ """UPDATE listings SET seller_id_fk = :seller_id WHERE source='avito' AND source_id = :item_id.
+
+ Возвращает количество обновлённых строк.
+ """
+ if not ext_item_id_to_seller_db_id:
+ return 0
+
+ total = 0
+ for item_id, seller_db_id in ext_item_id_to_seller_db_id.items():
+ result = db.execute(
+ text("""
+ UPDATE listings
+ SET seller_id_fk = CAST(:seller_id AS bigint)
+ WHERE source = 'avito'
+ AND source_id = CAST(:item_id AS text)
+ AND seller_id_fk IS DISTINCT FROM CAST(:seller_id AS bigint)
+ """),
+ {"seller_id": seller_db_id, "item_id": str(item_id)},
+ )
+ total += result.rowcount
+
+ logger.info("link_listings_to_seller: updated=%d listings", total)
+ return total
+
+
+def save_placement_history(db: Session, items: list[PlacementHistoryItem]) -> int:
+ """INSERT INTO house_placement_history с source='avito_widget' и removed_date=NULL.
+
+ ON CONFLICT (source, ext_item_id) DO UPDATE last_price/last_price_date/exposure_days.
+ Возвращает количество строк.
+ """
+ if not items:
+ return 0
+
+ count = 0
+ for item in items:
+ db.execute(
+ text("""
+ INSERT INTO house_placement_history (
+ source, ext_item_id,
+ title,
+ start_price, start_price_date,
+ last_price, last_price_date,
+ exposure_days,
+ removed_date,
+ raw_payload, scraped_at
+ ) VALUES (
+ 'avito_widget', CAST(:ext_item_id AS text),
+ CAST(:title AS text),
+ CAST(:start_price AS bigint), CAST(:start_price_date AS date),
+ CAST(:last_price AS bigint), CAST(:last_price_date AS date),
+ CAST(:exposure_days AS int),
+ NULL,
+ CAST(:raw_payload AS jsonb), NOW()
+ )
+ ON CONFLICT (source, ext_item_id) DO UPDATE SET
+ title = EXCLUDED.title,
+ last_price = EXCLUDED.last_price,
+ last_price_date = EXCLUDED.last_price_date,
+ exposure_days = EXCLUDED.exposure_days,
+ raw_payload = EXCLUDED.raw_payload,
+ scraped_at = NOW()
+ """),
+ {
+ "ext_item_id": item.ext_item_id,
+ "title": item.title,
+ "start_price": item.start_price,
+ "start_price_date": item.start_price_date,
+ "last_price": item.last_price,
+ "last_price_date": item.last_price_date,
+ "exposure_days": item.exposure_days,
+ "raw_payload": (
+ json.dumps(item.raw_payload, ensure_ascii=False)
+ if item.raw_payload
+ else None
+ ),
+ },
+ )
+ count += 1
+
+ logger.info("save_placement_history: saved=%d items (source=avito_widget)", count)
+ return count
+
+
+def save_house_catalog_enrichment(
+ db: Session,
+ e: HouseCatalogEnrichment,
+) -> dict[str, int]:
+ """Orchestrator — полный pipeline сохранения одного обогащения.
+
+ Операции (в порядке):
+ 1. upsert_house → houses.id
+ 2. save_house_reviews(house_id, reviews)
+ 3. save_sellers([m.seller for m in mini_serp if m.seller]) → {ext_seller_id: db_id}
+ 4. link_listings_to_seller({item_id: seller_db_id})
+ 5. save_placement_history(placement_history)
+
+ Возвращает:
+ {'house_id': N, 'reviews': N, 'sellers': N, 'listings_linked': N, 'placement_history': N}
+ """
+ # 1. Upsert дом с правильным URL из enrichment
+ house_id = _upsert_house_with_url(db, e.house, e.house_url)
+
+ # 2. Сохранить отзывы
+ reviews_count = save_house_reviews(db, house_id, e.reviews)
+
+ # 3. Сохранить продавцов (дедуп по ext_seller_id)
+ sellers_to_save = [m.seller for m in e.mini_serp if m.seller is not None]
+ seen_seller_ids: set[str] = set()
+ unique_sellers: list[SellerInfo] = []
+ for s in sellers_to_save:
+ if s.ext_seller_id not in seen_seller_ids:
+ unique_sellers.append(s)
+ seen_seller_ids.add(s.ext_seller_id)
+
+ seller_id_map = save_sellers(db, unique_sellers)
+
+ # 4. Слинковать объявления с продавцами
+ # Маппинг: ext_item_id (int) → seller db_id
+ item_to_seller: dict[int, int] = {}
+ for listing in e.mini_serp:
+ if listing.seller and listing.seller.ext_seller_id in seller_id_map:
+ item_to_seller[listing.ext_item_id] = seller_id_map[listing.seller.ext_seller_id]
+
+ listings_linked = link_listings_to_seller(db, item_to_seller)
+
+ # 5. Сохранить историю размещений
+ history_count = save_placement_history(db, e.placement_history)
+
+ db.commit()
+
+ result = {
+ "house_id": house_id,
+ "reviews": reviews_count,
+ "sellers": len(seller_id_map),
+ "listings_linked": listings_linked,
+ "placement_history": history_count,
+ }
+ logger.info("save_house_catalog_enrichment: %s", result)
+ return result
diff --git a/tradein-mvp/backend/app/services/scrapers/avito_imv.py b/tradein-mvp/backend/app/services/scrapers/avito_imv.py
new file mode 100644
index 00000000..3113b89d
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/avito_imv.py
@@ -0,0 +1,688 @@
+"""avito_imv.py — Avito IMV evaluation API client (3-request flow, 2026-05+).
+
+Stage 2d: geocode + IMV evaluation.
+
+Flow (current contract):
+ 1) GET /web/1/coords/by_address?address= → normalize + lat/lon
+ 2) POST /js/v2/geo/position → rich JWT (geoFieldsHash)
+ 3) POST /web/1/realty-imv/get-data → price + placementHistory + suggestions
+
+Таблицы:
+ avito_imv_evaluations (018_avito_imv_evaluations.sql)
+ house_placement_history (017_house_placement_history.sql)
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import re
+from dataclasses import dataclass, field
+from datetime import date, datetime
+from typing import Any
+from urllib.parse import urlencode
+from uuid import UUID
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+logger = logging.getLogger(__name__)
+
+AVITO_BASE = "https://www.avito.ru"
+COORDS_ENDPOINT = "/web/1/coords/by_address"
+POSITION_ENDPOINT = "/js/v2/geo/position"
+IMV_ENDPOINT = "/web/1/realty-imv/get-data"
+
+# Заголовки имитируют браузер на странице evaluation/realty
+_COMMON_HEADERS = {
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "ru-RU,ru;q=0.9",
+ "Referer": "https://www.avito.ru/evaluation/realty",
+}
+
+
+# ---------------------------------------------------------------------------
+# Exceptions
+# ---------------------------------------------------------------------------
+
+
+class IMVAddressNotFoundError(Exception):
+ """Raised when /coords/by_address returns no geoHash for given address."""
+
+
+class IMVCityMismatchError(Exception):
+ """Raised when locationId не входит в ожидаемый город (sanity check)."""
+
+
+class IMVAuthError(Exception):
+ """Avito API rejected auth / quota exceeded (HTTP 401 / 403)."""
+
+
+class IMVTransientError(Exception):
+ """Network / 5xx / timeout — retryable."""
+
+
+# ---------------------------------------------------------------------------
+# Dataclasses
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class IMVGeo:
+ geo_hash: str # JWT — не сохраняем как отдельную колонку, только в raw_response
+ lat: float | None = None
+ lon: float | None = None
+ avito_address_id: int | None = None
+ avito_location_id: int | None = None
+ avito_metro_id: int | None = None
+ avito_district_id: int | None = None
+
+
+@dataclass
+class IMVPlacementHistoryItem:
+ ext_item_id: str # str(item['id'])
+ title: str | None = None
+ rooms: int | None = None # парсится из title (опционально)
+ area_m2: float | None = None # парсится из title (опционально)
+ floor: int | None = None # парсится из title (опционально)
+ total_floors: int | None = None # парсится из title (опционально)
+ start_price: int | None = None
+ start_price_date: date | None = None # unix → date
+ last_price: int | None = None
+ last_price_date: date | None = None
+ removed_date: date | None = None # только IMV API (в widget housePlacementHistory нет)
+ exposure_days: int | None = None
+ raw_payload: dict[str, Any] | None = None
+
+
+@dataclass
+class IMVSuggestion:
+ ext_item_id: str
+ title: str | None = None
+ address: str | None = None
+ price_rub: int | None = None
+ exposure_days: int | None = None
+ publish_date: date | None = None
+ item_url: str | None = None
+ metro_name: str | None = None
+ metro_distance: str | None = None
+ metro_color: str | None = None
+ has_good_price_badge: bool = False
+ raw_payload: dict[str, Any] | None = None
+
+
+@dataclass
+class IMVEvaluation:
+ cache_key: str # sha256
+ address: str
+ rooms: int
+ area_m2: float
+ floor: int
+ floor_at_home: int
+ house_type: str
+ renovation_type: str
+ has_balcony: bool
+ has_loggia: bool
+
+ geo: IMVGeo
+ recommended_price: int
+ lower_price: int
+ higher_price: int
+ market_count: int | None = None
+
+ placement_history: list[IMVPlacementHistoryItem] = field(default_factory=list)
+ suggestions: list[IMVSuggestion] = field(default_factory=list)
+ search_similar_url: str | None = None
+ raw_response: dict[str, Any] | None = None
+
+
+# ---------------------------------------------------------------------------
+# Cache key
+# ---------------------------------------------------------------------------
+
+
+def compute_imv_cache_key(
+ address: str,
+ rooms: int,
+ area_m2: float,
+ floor: int,
+ floor_at_home: int,
+ house_type: str,
+ renovation_type: str,
+ has_balcony: bool,
+ has_loggia: bool,
+) -> str:
+ """Детерминированный sha256 ключ для 24h-кэша IMV оценки."""
+ key_src = (
+ f"{address}|{rooms}|{area_m2}|{floor}|{floor_at_home}"
+ f"|{house_type}|{renovation_type}|{int(has_balcony)}|{int(has_loggia)}"
+ )
+ return hashlib.sha256(key_src.encode("utf-8")).hexdigest()
+
+
+# ---------------------------------------------------------------------------
+# Parse helpers
+# ---------------------------------------------------------------------------
+
+
+def _unix_to_date(ts: int | float | None) -> date | None:
+ """Конвертирует unix timestamp в date. Возвращает None для 0 и None."""
+ if not ts:
+ return None
+ try:
+ return datetime.fromtimestamp(int(ts)).date()
+ except (ValueError, OSError):
+ return None
+
+
+# Паттерн для заголовка вида "2-к. квартира, 42 м², 4/5 эт."
+_TITLE_RE = re.compile(
+ r"^(?P\d+)-к[.\s].*?(?P[\d,]+)\s*м².*?(?P\d+)/(?P\d+)\s*эт",
+ re.IGNORECASE,
+)
+
+
+def _parse_title(title: str | None) -> dict[str, int | float | None]:
+ """Парсит rooms, area_m2, floor, total_floors из Avito-заголовка объявления."""
+ result: dict[str, int | float | None] = {
+ "rooms": None,
+ "area_m2": None,
+ "floor": None,
+ "total_floors": None,
+ }
+ if not title:
+ return result
+ m = _TITLE_RE.match(title.strip())
+ if not m:
+ return result
+ try:
+ result["rooms"] = int(m.group("rooms"))
+ result["area_m2"] = float(m.group("area").replace(",", "."))
+ result["floor"] = int(m.group("floor"))
+ result["total_floors"] = int(m.group("total"))
+ except (ValueError, AttributeError):
+ pass
+ return result
+
+
+def _parse_placement_item(raw: dict[str, Any]) -> IMVPlacementHistoryItem:
+ """Парсит один элемент из placementHistory.items."""
+ title = raw.get("title")
+ parsed = _parse_title(title)
+
+ # Фильтруем itemImage из raw_payload (не нужны)
+ clean_raw = {k: v for k, v in raw.items() if k not in ("itemImage",)}
+
+ return IMVPlacementHistoryItem(
+ ext_item_id=str(raw["id"]),
+ title=title,
+ rooms=parsed["rooms"], # type: ignore[arg-type]
+ area_m2=parsed["area_m2"], # type: ignore[arg-type]
+ floor=parsed["floor"], # type: ignore[arg-type]
+ total_floors=parsed["total_floors"], # type: ignore[arg-type]
+ start_price=raw.get("startPrice"),
+ start_price_date=_unix_to_date(raw.get("startPriceDate")),
+ last_price=raw.get("lastPrice"),
+ last_price_date=_unix_to_date(raw.get("lastPriceDate")),
+ removed_date=_unix_to_date(raw.get("removedDate")),
+ exposure_days=raw.get("exposure"),
+ raw_payload=clean_raw,
+ )
+
+
+def _parse_suggestion(raw: dict[str, Any]) -> IMVSuggestion:
+ """Парсит один элемент из suggestions.items."""
+ metro = raw.get("metro") or {}
+ colors: list[str] = metro.get("colors") or []
+
+ # Фильтруем imageLink из raw_payload
+ clean_raw = {k: v for k, v in raw.items() if k not in ("imageLink",)}
+
+ return IMVSuggestion(
+ ext_item_id=str(raw["id"]),
+ title=raw.get("title"),
+ address=raw.get("address"),
+ price_rub=raw.get("price"),
+ exposure_days=raw.get("exposure"),
+ publish_date=_unix_to_date(raw.get("publishDate")),
+ item_url=raw.get("itemLink"),
+ metro_name=metro.get("name"),
+ metro_distance=metro.get("distance"),
+ metro_color=colors[0] if colors else None,
+ has_good_price_badge=bool(raw.get("hasGoodPriceBadge", False)),
+ raw_payload=clean_raw,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Main async function
+# ---------------------------------------------------------------------------
+
+
+async def evaluate_via_imv(
+ address: str,
+ rooms: int,
+ area_m2: float,
+ floor: int,
+ floor_at_home: int,
+ house_type: str, # panel/brick/monolith/monolith_brick/block/wood
+ renovation_type: str, # required/cosmetic/euro/designer
+ has_balcony: bool,
+ has_loggia: bool,
+ *,
+ cffi_session: Any | None = None,
+) -> IMVEvaluation:
+ """Avito IMV — 3 HTTP requests (current contract 2026-05+):
+
+ 1) GET /web/1/coords/by_address?address= → normalize + lat/lon
+ 2) POST /js/v2/geo/position → rich JWT (geoFieldsHash)
+ 3) POST /web/1/realty-imv/get-data → price + placementHistory + suggestions
+
+ Returns IMVEvaluation с уже вычисленным cache_key.
+
+ Raises:
+ IMVAddressNotFoundError если point/normalizedAddress пустые (step 1)
+ или geoFieldsHash не вернулся (step 2)
+ IMVAuthError на 401/403
+ IMVTransientError на 5xx / network errors
+ """
+ # Импортируем здесь чтобы избежать циклических зависимостей при тестах без сети
+ try:
+ from curl_cffi.requests import AsyncSession as CffiAsyncSession
+
+ _own_session = False
+ if cffi_session is None:
+ cffi_session = CffiAsyncSession(impersonate="chrome120")
+ _own_session = True
+ except ImportError as exc:
+ raise RuntimeError(
+ "curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml."
+ ) from exc
+
+ try:
+ geo = await _geocode(cffi_session, address)
+ evaluation = await _imv_evaluate(
+ cffi_session,
+ geo=geo,
+ address=address,
+ rooms=rooms,
+ area_m2=area_m2,
+ floor=floor,
+ floor_at_home=floor_at_home,
+ house_type=house_type,
+ renovation_type=renovation_type,
+ has_balcony=has_balcony,
+ has_loggia=has_loggia,
+ )
+ finally:
+ if _own_session:
+ await cffi_session.close()
+
+ return evaluation
+
+
+def _raise_for_status_categorized(resp: Any, context: str) -> None:
+ """Raise typed IMV exception based on HTTP status code.
+
+ Используется вместо bare raise_for_status() чтобы caller мог различать
+ auth-failure (требует human attention) от transient (retryable).
+ """
+ status = resp.status_code
+ if status in (401, 403):
+ raise IMVAuthError(f"Avito {context}: HTTP {status} — auth rejected / quota exceeded")
+ if status >= 500:
+ raise IMVTransientError(f"Avito {context}: HTTP {status} — transient server error")
+ if status >= 400:
+ body_preview = (resp.text or "")[:200]
+ logger.warning("Avito %s HTTP %d: body=%r", context, status, body_preview)
+ resp.raise_for_status() # other 4xx → standard HTTPStatusError
+
+
+async def _geocode(session: Any, address: str) -> IMVGeo:
+ """Two-step Avito IMV geocoder (current contract 2026-05+).
+
+ Step A: GET /web/1/coords/by_address → normalize + lat/lon
+ Step B: POST /js/v2/geo/position → rich JWT (geoFieldsHash) with
+ addressId/locationId/metroId/districtId
+
+ JWT from step B is what /realty-imv/get-data expects as `geoHash`.
+ """
+ # Step A — normalize + coords
+ params = urlencode({"address": address})
+ url_a = f"{AVITO_BASE}{COORDS_ENDPOINT}?{params}"
+ logger.info("IMV geocode A: address=%r", address)
+
+ try:
+ resp_a = await session.get(url_a, headers=_COMMON_HEADERS)
+ except Exception as exc:
+ raise IMVTransientError(f"Avito geocode A network error: {exc}") from exc
+ _raise_for_status_categorized(resp_a, "geocode-A")
+ data_a: dict[str, Any] = resp_a.json()
+
+ point = data_a.get("point") or {}
+ lat = point.get("latitude")
+ lon = point.get("longitude")
+ normalized = data_a.get("normalizedAddress")
+
+ if not lat or not lon or not normalized:
+ raise IMVAddressNotFoundError(
+ f"Avito /coords/by_address не вернул point для {address!r}"
+ )
+
+ # Step B — rich JWT
+ url_b = f"{AVITO_BASE}{POSITION_ENDPOINT}"
+ payload_b = {
+ "address": normalized,
+ "addressId": "",
+ "categoryId": 24, # 24 = недвижимость / квартиры
+ "isRadius": False,
+ "latitude": lat,
+ "longitude": lon,
+ }
+ logger.info("IMV geocode B: position for %r", normalized[:60])
+
+ try:
+ resp_b = await session.post(
+ url_b,
+ headers={**_COMMON_HEADERS, "Content-Type": "application/json"},
+ json=payload_b,
+ )
+ except Exception as exc:
+ raise IMVTransientError(f"Avito geocode B network error: {exc}") from exc
+ _raise_for_status_categorized(resp_b, "geocode-B")
+ data_b: dict[str, Any] = resp_b.json()
+
+ jwt = data_b.get("geoFieldsHash")
+ if not jwt:
+ raise IMVAddressNotFoundError(
+ f"Avito /js/v2/geo/position не вернул geoFieldsHash для {normalized!r}: "
+ f"errorText={data_b.get('errorText')!r}"
+ )
+
+ logger.info(
+ "IMV geocode OK: addressId=%s locationId=%s metroId=%s lat=%s lon=%s",
+ data_b.get("addressId"),
+ data_b.get("locationId"),
+ data_b.get("metroId"),
+ lat,
+ lon,
+ )
+
+ return IMVGeo(
+ geo_hash=jwt,
+ lat=lat,
+ lon=lon,
+ avito_address_id=data_b.get("addressId"),
+ avito_location_id=data_b.get("locationId"),
+ avito_metro_id=data_b.get("metroId"),
+ avito_district_id=data_b.get("districtId"),
+ )
+
+
+async def _imv_evaluate(
+ session: Any,
+ *,
+ geo: IMVGeo,
+ address: str,
+ rooms: int,
+ area_m2: float,
+ floor: int,
+ floor_at_home: int,
+ house_type: str,
+ renovation_type: str,
+ has_balcony: bool,
+ has_loggia: bool,
+) -> IMVEvaluation:
+ """Request 2: POST /web/1/realty-imv/get-data → IMVEvaluation."""
+ url = f"{AVITO_BASE}{IMV_ENDPOINT}"
+ body = {
+ "geoHash": geo.geo_hash,
+ "floor": floor,
+ "floorAtHome": floor_at_home,
+ "rooms": rooms,
+ "area": area_m2,
+ "houseType": house_type,
+ "renovationType": renovation_type,
+ "hasLoggia": has_loggia,
+ "hasBalcony": has_balcony,
+ }
+ headers = {**_COMMON_HEADERS, "Content-Type": "application/json"}
+
+ logger.info(
+ "IMV evaluate: address=%r rooms=%d area=%.1f floor=%d/%d",
+ address,
+ rooms,
+ area_m2,
+ floor,
+ floor_at_home,
+ )
+
+ try:
+ resp = await session.post(url, json=body, headers=headers)
+ except Exception as exc:
+ raise IMVTransientError(f"Avito IMV network error: {exc}") from exc
+ _raise_for_status_categorized(resp, "imv-evaluate")
+ data: dict[str, Any] = resp.json()
+
+ price_block: dict[str, Any] = data.get("price") or {}
+ recommended_price: int = price_block.get("recommendedPrice") or 0
+ lower_price: int = price_block.get("lowerPrice") or 0
+ higher_price: int = price_block.get("higherPrice") or 0
+ market_count: int | None = price_block.get("count")
+
+ # placementHistory
+ history_block: dict[str, Any] = data.get("placementHistory") or {}
+ placement_history: list[IMVPlacementHistoryItem] = []
+ for raw_item in history_block.get("items") or []:
+ try:
+ placement_history.append(_parse_placement_item(raw_item))
+ except (KeyError, TypeError) as exc:
+ logger.warning("IMV: не удалось распарсить placementHistory item: %s", exc)
+
+ # suggestions
+ suggestions_block: dict[str, Any] = data.get("suggestions") or {}
+ suggestions: list[IMVSuggestion] = []
+ for raw_sugg in suggestions_block.get("items") or []:
+ try:
+ suggestions.append(_parse_suggestion(raw_sugg))
+ except (KeyError, TypeError) as exc:
+ logger.warning("IMV: не удалось распарсить suggestion: %s", exc)
+
+ search_similar_url: str | None = suggestions_block.get("searchSimilarUrl")
+
+ logger.info(
+ "IMV evaluate OK: recommended=%d range=(%d, %d) count=%s history=%d suggestions=%d",
+ recommended_price,
+ lower_price,
+ higher_price,
+ market_count,
+ len(placement_history),
+ len(suggestions),
+ )
+
+ cache_key = compute_imv_cache_key(
+ address=address,
+ rooms=rooms,
+ area_m2=area_m2,
+ floor=floor,
+ floor_at_home=floor_at_home,
+ house_type=house_type,
+ renovation_type=renovation_type,
+ has_balcony=has_balcony,
+ has_loggia=has_loggia,
+ )
+
+ return IMVEvaluation(
+ cache_key=cache_key,
+ address=address,
+ rooms=rooms,
+ area_m2=area_m2,
+ floor=floor,
+ floor_at_home=floor_at_home,
+ house_type=house_type,
+ renovation_type=renovation_type,
+ has_balcony=has_balcony,
+ has_loggia=has_loggia,
+ geo=geo,
+ recommended_price=recommended_price,
+ lower_price=lower_price,
+ higher_price=higher_price,
+ market_count=market_count,
+ placement_history=placement_history,
+ suggestions=suggestions,
+ search_similar_url=search_similar_url,
+ raw_response=data,
+ )
+
+
+# ---------------------------------------------------------------------------
+# DB save functions
+# ---------------------------------------------------------------------------
+
+
+def save_imv_evaluation(
+ db: Session,
+ e: IMVEvaluation,
+ *,
+ estimate_id: UUID | None = None,
+) -> int:
+ """INSERT в avito_imv_evaluations ON CONFLICT(cache_key) DO UPDATE.
+
+ Колонки строго по 018_avito_imv_evaluations.sql DDL.
+ estimate_id — nullable FK на trade_in_estimates.
+
+ Returns: id вставленной/обновлённой строки.
+ """
+ row = db.execute(
+ text("""
+ INSERT INTO avito_imv_evaluations (
+ cache_key, estimate_id,
+ address, rooms, area_m2, floor, floor_at_home,
+ house_type, renovation_type, has_balcony, has_loggia,
+ geo_hash,
+ lat, lon, avito_address_id, avito_location_id,
+ avito_metro_id, avito_district_id,
+ recommended_price, lower_price, higher_price, market_count,
+ raw_response, fetched_at
+ ) VALUES (
+ :cache_key, CAST(:estimate_id AS uuid),
+ :address, :rooms, :area_m2, :floor, :floor_at_home,
+ :house_type, :renovation_type, :has_balcony, :has_loggia,
+ :geo_hash,
+ :lat, :lon, :avito_address_id, :avito_location_id,
+ :avito_metro_id, :avito_district_id,
+ :recommended_price, :lower_price, :higher_price, :market_count,
+ CAST(:raw_response AS jsonb), NOW()
+ )
+ ON CONFLICT (cache_key) DO UPDATE SET
+ estimate_id = COALESCE(EXCLUDED.estimate_id, avito_imv_evaluations.estimate_id),
+ recommended_price = EXCLUDED.recommended_price,
+ lower_price = EXCLUDED.lower_price,
+ higher_price = EXCLUDED.higher_price,
+ market_count = EXCLUDED.market_count,
+ raw_response = EXCLUDED.raw_response,
+ fetched_at = NOW()
+ RETURNING id
+ """),
+ {
+ "cache_key": e.cache_key,
+ "estimate_id": str(estimate_id) if estimate_id else None,
+ "address": e.address,
+ "rooms": e.rooms,
+ "area_m2": e.area_m2,
+ "floor": e.floor,
+ "floor_at_home": e.floor_at_home,
+ "house_type": e.house_type,
+ "renovation_type": e.renovation_type,
+ "has_balcony": e.has_balcony,
+ "has_loggia": e.has_loggia,
+ "geo_hash": e.geo.geo_hash,
+ "lat": e.geo.lat,
+ "lon": e.geo.lon,
+ "avito_address_id": e.geo.avito_address_id,
+ "avito_location_id": e.geo.avito_location_id,
+ "avito_metro_id": e.geo.avito_metro_id,
+ "avito_district_id": e.geo.avito_district_id,
+ "recommended_price": e.recommended_price,
+ "lower_price": e.lower_price,
+ "higher_price": e.higher_price,
+ "market_count": e.market_count,
+ "raw_response": (
+ json.dumps(e.raw_response, ensure_ascii=False) if e.raw_response else None
+ ),
+ },
+ ).fetchone()
+ db.commit()
+ return row.id if row else 0
+
+
+def save_imv_placement_history(
+ db: Session,
+ eval_id: int,
+ items: list[IMVPlacementHistoryItem],
+) -> int:
+ """INSERT в house_placement_history (source='avito_imv') с removed_date.
+
+ Колонки строго по 017_house_placement_history.sql DDL.
+ Колонка scraped_at (не fetched_at) — по DDL таблицы.
+ house_id — NULL (backfill через отдельный процесс).
+ eval_id принимается для логирования (не сохраняется — нет FK в 017).
+
+ ON CONFLICT (source, ext_item_id) DO UPDATE — refresh dates.
+ Returns: количество вставленных/обновлённых строк.
+ """
+ count = 0
+ for item in items:
+ db.execute(
+ text("""
+ INSERT INTO house_placement_history (
+ source, ext_item_id,
+ title, rooms, area_m2, floor, total_floors,
+ start_price, start_price_date,
+ last_price, last_price_date,
+ removed_date, exposure_days,
+ raw_payload, scraped_at
+ ) VALUES (
+ 'avito_imv', :ext_item_id,
+ :title, :rooms, :area_m2, :floor, :total_floors,
+ :start_price, :start_price_date,
+ :last_price, :last_price_date,
+ :removed_date, :exposure_days,
+ CAST(:raw_payload AS jsonb), NOW()
+ )
+ ON CONFLICT (source, ext_item_id) DO UPDATE SET
+ last_price = EXCLUDED.last_price,
+ last_price_date = EXCLUDED.last_price_date,
+ removed_date = EXCLUDED.removed_date,
+ exposure_days = EXCLUDED.exposure_days,
+ raw_payload = EXCLUDED.raw_payload,
+ scraped_at = NOW()
+ """),
+ {
+ "ext_item_id": item.ext_item_id,
+ "title": item.title,
+ "rooms": item.rooms,
+ "area_m2": item.area_m2,
+ "floor": item.floor,
+ "total_floors": item.total_floors,
+ "start_price": item.start_price,
+ "start_price_date": item.start_price_date,
+ "last_price": item.last_price,
+ "last_price_date": item.last_price_date,
+ "removed_date": item.removed_date,
+ "exposure_days": item.exposure_days,
+ "raw_payload": (
+ json.dumps(item.raw_payload, ensure_ascii=False)
+ if item.raw_payload
+ else None
+ ),
+ },
+ )
+ count += 1
+
+ if count:
+ db.commit()
+ logger.info("IMV placement history saved: eval_id=%d items=%d", eval_id, count)
+
+ return count
diff --git a/tradein-mvp/backend/app/services/scrapers/base.py b/tradein-mvp/backend/app/services/scrapers/base.py
new file mode 100644
index 00000000..29603f6c
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/base.py
@@ -0,0 +1,440 @@
+"""Базовый framework для парсеров объявлений (Avito / Cian / DomKlik / ...).
+
+Общие задачи:
+- httpx.AsyncClient с realistic browser headers + UA rotation
+- tenacity retry с exp backoff
+- Sleep между запросами (anti-ban)
+- ScrapedLot — единая Pydantic схема всех источников
+- Запись в `listings` Postgres с дедупом по dedup_hash
+- После INSERT/UPDATE — hook в matching service для регистрации в
+ `listing_sources` и привязки к каноническому `houses` через `house_sources`.
+
+Каждый конкретный парсер (avito.py, cian.py, ...) наследуется от BaseScraper
+и реализует `_fetch_page()` и `_parse_listings()`.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import logging
+import random
+from abc import ABC, abstractmethod
+from datetime import date
+from typing import Any
+
+import httpx
+from pydantic import BaseModel, Field
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+from tenacity import retry, stop_after_attempt, wait_exponential
+
+from app.services.matching import match_or_create_house, upsert_listing_source
+
+logger = logging.getLogger(__name__)
+
+
+# ── Realistic browser User-Agents (Apr 2026 versions) ──────────────────────
+
+_USER_AGENTS: list[str] = [
+ # Chrome on macOS
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", # noqa: E501
+ # Chrome on Windows
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", # noqa: E501
+ # Firefox on macOS
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.5; rv:127.0) Gecko/20100101 Firefox/127.0",
+ # Safari on macOS
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", # noqa: E501
+ # Edge on Windows
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0", # noqa: E501
+]
+
+
+def random_user_agent() -> str:
+ return random.choice(_USER_AGENTS)
+
+
+# ── Унифицированная схема результата ────────────────────────────────────────
+class ScrapedLot(BaseModel):
+ """Результат парсинга одного объявления.
+
+ Все источники приводятся к этому виду перед записью в Postgres.
+ Поля optional если источник их не отдаёт.
+ """
+
+ source: str # 'avito' / 'cian' / 'domklik' / ...
+ source_url: str # ссылка на оригинал — кликается из UI
+ source_id: str | None = None # внутренний id источника (для update)
+
+ # Локация — должна быть хотя бы address ИЛИ (lat, lon)
+ address: str | None = None
+ lat: float | None = None
+ lon: float | None = None
+
+ # Параметры квартиры
+ rooms: int | None = None # 0 = студия
+ area_m2: float | None = None
+ floor: int | None = None
+ total_floors: int | None = None
+ year_built: int | None = None
+ house_type: str | None = None
+ repair_state: str | None = None
+ has_balcony: bool | None = None
+ kadastr_num: str | None = None
+
+ # Avito house linking (Stage 2a — search → houses)
+ house_source: str | None = None # 'avito' / 'cian'
+ house_ext_id: str | None = None # Avito's '3171365'
+ house_url: str | None = None # full URL of /catalog/houses/...
+ listing_segment: str | None = None # 'vtorichka' / 'novostroyki'
+
+ # Цена (обязательно)
+ price_rub: int = Field(gt=0)
+ price_per_m2: int | None = None
+
+ # Метаданные
+ listing_date: date | None = None
+ days_on_market: int | None = None
+ photo_urls: list[str] = Field(default_factory=list)
+ raw_payload: dict[str, Any] | None = None
+
+ # Cian-specific extensions (Stage 2 of CianScraper v1)
+ living_area_m2: float | None = None
+ bedrooms_count: int | None = None
+ balconies_count: int | None = None
+ loggias_count: int | None = None
+ description_minhash: str | None = None
+ cadastral_number: str | None = None
+ building_cadastral_number: str | None = None
+ phones: list[dict] = Field(default_factory=list)
+ is_homeowner: bool | None = None
+ is_pro_seller: bool | None = None
+ bargain_allowed: bool | None = None
+ sale_type: str | None = None
+ metro_stations: list[dict] = Field(default_factory=list)
+
+ def compute_dedup_hash(self) -> str:
+ """SHA256(source + (source_id или source_url) + price_rub) — стабильный uniqueness key.
+
+ Cian URL содержит random `?context=...` токены — поэтому ключ берём
+ source_id (offer_id 330200428), а не source_url. Если source_id нет —
+ откатываемся на source_url (Yandex, Avito с offerId).
+ """
+ h = hashlib.sha256()
+ h.update(self.source.encode())
+ h.update(b"|")
+ key = self.source_id if self.source_id else self.source_url
+ h.update((key or "").encode())
+ h.update(b"|")
+ h.update(str(self.price_rub).encode())
+ return h.hexdigest()
+
+ def compute_price_per_m2(self) -> int | None:
+ """price_per_m2 = price_rub / area_m2 если area задана."""
+ if self.area_m2 and self.area_m2 > 0:
+ return int(self.price_rub / self.area_m2)
+ return None
+
+
+# ── BaseScraper ─────────────────────────────────────────────────────────────
+class BaseScraper(ABC):
+ """Базовый класс — наследовать для каждого источника.
+
+ Subclass должен реализовать:
+ - `name` (class attr) — 'avito' / 'cian' / ...
+ - `_fetch_page(client, ...)` — async GET к источнику
+ - `_parse_listings(html_or_json, ...)` — html → list[ScrapedLot]
+ """
+
+ name: str = "base"
+ base_url: str = ""
+ request_delay_sec: float = 5.0 # sleep между запросами (anti-ban)
+
+ def __init__(self) -> None:
+ self._client: httpx.AsyncClient | None = None
+
+ async def __aenter__(self) -> BaseScraper:
+ self._client = httpx.AsyncClient(
+ timeout=20.0,
+ follow_redirects=True,
+ headers={
+ "User-Agent": random_user_agent(),
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Connection": "keep-alive",
+ "Upgrade-Insecure-Requests": "1",
+ },
+ )
+ return self
+
+ async def __aexit__(self, *args: Any) -> None:
+ if self._client is not None:
+ await self._client.aclose()
+
+ @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=2, min=2, max=30))
+ async def _http_get(self, url: str, **kwargs: Any) -> httpx.Response:
+ """GET с retry. Subclass-ы используют это вместо raw httpx."""
+ assert self._client is not None, "Use scraper as async context manager"
+ response = await self._client.get(url, **kwargs)
+ # 403/429 — ретрай поможет (рандомный UA в новом контексте), 5xx тоже
+ if response.status_code in {403, 429, 502, 503, 504}:
+ response.raise_for_status()
+ return response
+
+ async def sleep_between_requests(self) -> None:
+ """Случайный jitter в районе self.request_delay_sec — чтобы парсер
+ не выглядел как detstvo-bot."""
+ jitter = random.uniform(0.7, 1.5)
+ await asyncio.sleep(self.request_delay_sec * jitter)
+
+ @abstractmethod
+ async def fetch_around(
+ self, lat: float, lon: float, radius_m: int = 1000
+ ) -> list[ScrapedLot]:
+ """Главный метод — собрать объявления вокруг (lat, lon) в радиусе radius_m.
+
+ Returns:
+ Список ScrapedLot. Может быть пустым.
+ """
+ ...
+
+
+# ── Запись пачки результатов в Postgres ─────────────────────────────────────
+def save_listings(db: Session, lots: list[ScrapedLot]) -> tuple[int, int]:
+ """Пишем list[ScrapedLot] в `listings` с upsert по dedup_hash.
+
+ Returns:
+ (inserted, updated) — counters для логов.
+ """
+ if not lots:
+ return 0, 0
+
+ inserted = 0
+ updated = 0
+ matched = 0
+ match_failures = 0
+
+ for lot in lots:
+ ppm2 = lot.price_per_m2 or lot.compute_price_per_m2()
+ dedup = lot.compute_dedup_hash()
+
+ result = db.execute(
+ text(
+ """
+ INSERT INTO listings (
+ source, source_url, source_id, dedup_hash,
+ address, lat, lon, region_code,
+ rooms, area_m2, floor, total_floors, year_built,
+ house_type, repair_state, has_balcony, kadastr_num,
+ house_source, house_ext_id, house_url, listing_segment,
+ price_rub, price_per_m2,
+ listing_date, days_on_market, photo_urls, raw_payload,
+ living_area_m2, bedrooms_count, balconies_count, loggias_count,
+ description_minhash, cadastral_number, building_cadastral_number,
+ phones, is_homeowner, is_pro_seller,
+ bargain_allowed, sale_type, metro_stations,
+ scraped_at, last_seen_at
+ ) VALUES (
+ :source, :source_url, :source_id, :dedup,
+ :address, :lat, :lon, 66,
+ :rooms, :area_m2, :floor, :total_floors, :year_built,
+ :house_type, :repair_state, :has_balcony, :kadastr,
+ :house_source, :house_ext_id, :house_url, :listing_segment,
+ :price_rub, :ppm2,
+ :listing_date, :days_on_market,
+ CAST(:photos AS jsonb),
+ CAST(:raw AS jsonb),
+ :living_area_m2, :bedrooms_count, :balconies_count, :loggias_count,
+ :description_minhash, :cadastral_number, :building_cadastral_number,
+ CAST(:phones AS jsonb), :is_homeowner, :is_pro_seller,
+ :bargain_allowed, :sale_type, CAST(:metro_stations AS jsonb),
+ NOW(), NOW()
+ )
+ ON CONFLICT (dedup_hash) DO UPDATE
+ SET last_seen_at = NOW(),
+ is_active = true,
+ -- если цена изменилась — обновляем
+ price_rub = EXCLUDED.price_rub,
+ price_per_m2 = EXCLUDED.price_per_m2,
+ -- Cian-specific: обновляем при каждом re-scrape
+ living_area_m2 = EXCLUDED.living_area_m2,
+ bedrooms_count = EXCLUDED.bedrooms_count,
+ balconies_count = EXCLUDED.balconies_count,
+ loggias_count = EXCLUDED.loggias_count,
+ description_minhash = EXCLUDED.description_minhash,
+ cadastral_number = EXCLUDED.cadastral_number,
+ building_cadastral_number = EXCLUDED.building_cadastral_number,
+ phones = EXCLUDED.phones,
+ is_homeowner = EXCLUDED.is_homeowner,
+ is_pro_seller = EXCLUDED.is_pro_seller,
+ bargain_allowed = EXCLUDED.bargain_allowed,
+ sale_type = EXCLUDED.sale_type,
+ metro_stations = EXCLUDED.metro_stations
+ RETURNING id, (xmax = 0) AS inserted
+ """
+ ),
+ {
+ "source": lot.source,
+ "source_url": lot.source_url,
+ "source_id": lot.source_id,
+ "dedup": dedup,
+ "address": lot.address,
+ "lat": lot.lat,
+ "lon": lot.lon,
+ "rooms": lot.rooms,
+ "area_m2": lot.area_m2,
+ "floor": lot.floor,
+ "total_floors": lot.total_floors,
+ "year_built": lot.year_built,
+ "house_type": lot.house_type,
+ "repair_state": lot.repair_state,
+ "has_balcony": lot.has_balcony,
+ "kadastr": lot.kadastr_num,
+ "house_source": lot.house_source,
+ "house_ext_id": lot.house_ext_id,
+ "house_url": lot.house_url,
+ "listing_segment": lot.listing_segment,
+ "price_rub": lot.price_rub,
+ "ppm2": ppm2,
+ "listing_date": lot.listing_date,
+ "days_on_market": lot.days_on_market,
+ "photos": _to_json(lot.photo_urls),
+ "raw": _to_json(lot.raw_payload) if lot.raw_payload else None,
+ # Cian-specific columns — None for non-Cian scrapers (→ SQL NULL)
+ "living_area_m2": lot.living_area_m2,
+ "bedrooms_count": lot.bedrooms_count,
+ "balconies_count": lot.balconies_count,
+ "loggias_count": lot.loggias_count,
+ "description_minhash": lot.description_minhash,
+ "cadastral_number": lot.cadastral_number,
+ "building_cadastral_number": lot.building_cadastral_number,
+ "phones": _to_json(lot.phones) if lot.phones else None,
+ "is_homeowner": lot.is_homeowner,
+ "is_pro_seller": lot.is_pro_seller,
+ "bargain_allowed": lot.bargain_allowed,
+ "sale_type": lot.sale_type,
+ "metro_stations": _to_json(lot.metro_stations) if lot.metro_stations else None,
+ },
+ ).fetchone()
+
+ listing_id: int | None = None
+ if result is not None:
+ listing_id = int(result.id)
+ if result.inserted:
+ inserted += 1
+ else:
+ updated += 1
+
+ # ── Hook: link listing → house via matching service ─────────────
+ # Idempotent (UPSERT on listing_sources UNIQUE(ext_source, ext_id))
+ # and fault-tolerant: failure here MUST NOT abort the listings batch.
+ # Wrapped in SAVEPOINT so a failed match only rolls back its own work,
+ # not the surrounding INSERT (see .claude/rules/backend.md SAVEPOINT).
+ if listing_id is not None:
+ try:
+ with db.begin_nested():
+ _link_listing_to_house(db, listing_id, lot)
+ matched += 1
+ except Exception as e:
+ # Best-effort hook: log and continue so the listings batch isn't aborted.
+ match_failures += 1
+ logger.warning(
+ "save_listings:match_failed source=%s source_id=%s listing_id=%s: %s",
+ lot.source,
+ lot.source_id,
+ listing_id,
+ e,
+ )
+
+ db.commit()
+ logger.info(
+ "save_listings: source=%s inserted=%d updated=%d matched=%d "
+ "match_failures=%d (total %d)",
+ lots[0].source if lots else "?",
+ inserted,
+ updated,
+ matched,
+ match_failures,
+ len(lots),
+ )
+ return inserted, updated
+
+
+def _to_json(value: Any) -> str:
+ """JSON serialization helper — для jsonb колонок."""
+ import json
+
+ return json.dumps(value, ensure_ascii=False, default=str)
+
+
+def _link_listing_to_house(db: Session, listing_id: int, lot: ScrapedLot) -> None:
+ """Hook scraped listing into matching service: resolve house, upsert listing_sources.
+
+ Steps:
+ 1. match_or_create_house() — find or create canonical houses row for this
+ listing's address/coords. Uses Tier 0-3 matching (cadastr → source_exact →
+ fingerprint → geo_proximity → new).
+ 2. upsert_listing_source() — register (ext_source, ext_id) → listing_id in
+ listing_sources. Idempotent via UNIQUE (ext_source, ext_id).
+
+ The matching method recorded is 'source_link': scraper already deduped by
+ `dedup_hash` (INSERT … ON CONFLICT DO UPDATE), so this is a direct registration
+ rather than a fuzzy listing-level match.
+
+ ext_id source: lot.source_id if present, else dedup_hash (Yandex without
+ stable source_id falls back to URL-based dedup_hash — same hash on re-scrape).
+
+ Skips silently if:
+ - lot has no source_id AND no address/lat/lon (cannot match house anyway)
+
+ Raises on DB errors — caller wraps in try/except + SAVEPOINT.
+ """
+ ext_id = lot.source_id or lot.compute_dedup_hash()
+
+ # House resolution: needs at least address or (lat, lon). Skip otherwise —
+ # listing_sources requires listing_id but not house linkage, so still upsert.
+ house_id: int | None = None
+ if lot.address or (lot.lat is not None and lot.lon is not None):
+ # Use house_source/house_ext_id when scraper extracted them (Avito Houses
+ # catalog link, Cian newbuilding). Falls back to per-listing identity
+ # so each unique listing creates its own row if no canonical house exists.
+ h_src = lot.house_source or lot.source
+ h_ext = lot.house_ext_id or ext_id
+ try:
+ with db.begin_nested():
+ house_id, _conf, _method = match_or_create_house(
+ db,
+ ext_source=h_src,
+ ext_id=h_ext,
+ address=lot.address,
+ lat=lot.lat,
+ lon=lot.lon,
+ year_built=lot.year_built,
+ building_cadastral_number=lot.building_cadastral_number,
+ cadastral_number=lot.cadastral_number or lot.kadastr_num,
+ source_url=lot.house_url or lot.source_url,
+ )
+ except Exception as e:
+ # Fall through to listing-only upsert: listing_sources row still useful
+ # even without house linkage (e.g. for later backfill).
+ logger.warning(
+ "save_listings:house_match_failed source=%s ext_id=%s: %s",
+ lot.source, ext_id, e,
+ )
+ house_id = None
+
+ upsert_listing_source(
+ db,
+ listing_id=listing_id,
+ ext_source=lot.source,
+ ext_id=str(ext_id),
+ method='source_link',
+ confidence=1.0,
+ price_rub=lot.price_rub,
+ area_m2=lot.area_m2,
+ floor=lot.floor,
+ rooms_count=lot.rooms,
+ source_url=lot.source_url,
+ source_data={'house_id': house_id} if house_id else None,
+ )
diff --git a/tradein-mvp/backend/app/services/scrapers/cian.py b/tradein-mvp/backend/app/services/scrapers/cian.py
new file mode 100644
index 00000000..3dbd4a38
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/cian.py
@@ -0,0 +1,486 @@
+"""Cian.ru scraper — state-based SERP parser (137 fields per offer).
+
+Стратегия (Stage 3 рефактор):
+- Cian React micro-frontends хранят state в window._cianConfig['frontend-serp'].push().
+- URL pattern: https://ekb.cian.ru/cat.php?deal_type=sale&offer_type=flat&engine_version=2
+ ekb.cian.ru — city-specific subdomain для ЕКБ (per Schema_Cian_SERP_Inventory sec 13).
+
+ВАЖНО: Циан блокирует httpx по TLS fingerprint → "Обнаружен подозрительный трафик" (403).
+Используем curl_cffi с impersonate='chrome120' — это libcurl-impersonate под капотом,
+который воспроизводит TLS ClientHello как настоящий Chrome.
+
+НЕ используем anchor jitter: offer.geo.coordinates.{lat,lng} — точные координаты
+прямо из SERP state. Jitter запрещён per implementation plan.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+from datetime import UTC, datetime
+from typing import Any
+from urllib.parse import urlencode
+
+from curl_cffi.requests import AsyncSession
+
+from app.services.scraper_settings import get_scraper_delay
+from app.services.scrapers.base import BaseScraper, ScrapedLot
+from app.services.scrapers.cian_state_parser import extract_state
+
+logger = logging.getLogger(__name__)
+
+# Регион 4743 = Свердловская область (Cian internal region ID)
+CIAN_EKB_REGION_ID = 4743
+
+# SERP MFE name и state key (per Schema sec 1.2)
+_MFE_SERP = "frontend-serp"
+_STATE_KEY = "initialState"
+
+
+class CianScraper(BaseScraper):
+ """Cian SERP scraper. Использует curl_cffi для обхода TLS fingerprint.
+
+ Извлекает 137 полей на offer из Redux initialState через cian_state_parser.
+ Координаты точные — НЕТ anchor jitter (offer.geo.coordinates).
+ """
+
+ name = "cian"
+ # ekb.cian.ru — city-specific subdomain для ЕКБ (per Schema sec 13, closed Q4)
+ base_url = "https://ekb.cian.ru"
+ # Класс-дефолт; реальное значение загружается из scraper_settings при создании экземпляра.
+ request_delay_sec = 5.0 # консервативно: Cian менее агрессивен чем Avito, но 5s безопасно
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.request_delay_sec = get_scraper_delay(self.name)
+ self._cffi: AsyncSession | None = None
+
+ async def __aenter__(self) -> CianScraper:
+ await super().__aenter__()
+ # curl_cffi session с Chrome 120 TLS fingerprint — КРИТИЧЕСКИ ВАЖНО для Cian
+ self._cffi = AsyncSession(
+ impersonate="chrome120",
+ timeout=30,
+ headers={
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
+ "Cache-Control": "max-age=0",
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "none",
+ "Sec-Fetch-User": "?1",
+ "Upgrade-Insecure-Requests": "1",
+ },
+ )
+ return self
+
+ async def __aexit__(self, *args: Any) -> None:
+ if self._cffi is not None:
+ await self._cffi.close()
+ await super().__aexit__(*args)
+
+ async def fetch_around(
+ self,
+ lat: float,
+ lon: float,
+ radius_m: int = 1000,
+ rooms: tuple[int, ...] | None = None,
+ page: int = 1,
+ ) -> list[ScrapedLot]:
+ """Найти объявления Циан по ЕКБ (lat/lon для reference; Cian не поддерживает bbox).
+
+ Cian SERP отдаёт весь ЕКБ — фильтрация по радиусу происходит в postgres
+ через ST_DWithin после сохранения (точные coords из state).
+
+ rooms — список из 1,2,3,4 (Cian's room codes). Если None — все.
+ page — страница выдачи (Cian ~28 объявлений на страницу).
+ """
+ url = self._build_url(rooms, page)
+ try:
+ assert self._cffi is not None
+ response = await self._cffi.get(url)
+ except Exception:
+ logger.exception("cian curl_cffi fetch failed for url=%s", url)
+ return []
+
+ if response.status_code != 200:
+ logger.warning("cian returned HTTP %d for url=%s", response.status_code, url)
+ return []
+
+ lots = self._parse_serp_html(response.text)
+ logger.info(
+ "cian: %d lots fetched rooms=%s page=%d url=%s",
+ len(lots), rooms, page, url,
+ )
+ await self.sleep_between_requests()
+ return lots
+
+ async def fetch_around_multi_room(
+ self,
+ lat: float,
+ lon: float,
+ radius_m: int = 1000,
+ pages: int = 8,
+ ) -> list[ScrapedLot]:
+ """Скрейп Циан по 1к/2к/3к/4+ × N страниц — расширяет выборку.
+
+ 4 комнаты × N страниц × ~28 = до ~330+ лотов до дедупликации.
+ """
+ seen: dict[str, ScrapedLot] = {}
+ for rooms in ((1,), (2,), (3,), (4,)):
+ for page in range(1, pages + 1):
+ try:
+ lots = await self.fetch_around(lat, lon, radius_m, rooms=rooms, page=page)
+ except Exception:
+ logger.exception(
+ "cian multi-room fetch failed rooms=%s page=%d", rooms, page
+ )
+ continue
+ if not lots:
+ break # пустая страница → дальше смысла нет
+ for lot in lots:
+ key = lot.source_id or lot.source_url
+ if key and key not in seen:
+ seen[key] = lot
+ logger.info(
+ "cian multi-room: %d unique lots (lat=%.4f lon=%.4f radius=%dm)",
+ len(seen), lat, lon, radius_m,
+ )
+ return list(seen.values())
+
+ def _build_url(
+ self,
+ rooms: tuple[int, ...] | None = None,
+ page: int = 1,
+ ) -> str:
+ """URL для Cian каталога вторички ЕКБ.
+
+ Используем ekb.cian.ru (city-specific subdomain).
+ Регион задаётся через region= param как fallback для reliability.
+ """
+ params: list[tuple[str, Any]] = [
+ ("deal_type", "sale"),
+ ("engine_version", "2"),
+ ("offer_type", "flat"),
+ ("region", CIAN_EKB_REGION_ID),
+ ("sort", "creation_date_desc"),
+ ]
+ if rooms:
+ for r in rooms:
+ params.append((f"room{r}", "1"))
+ if page > 1:
+ params.append(("p", page))
+ return f"{self.base_url}/cat.php?{urlencode(params)}"
+
+ def _parse_serp_html(self, html: str) -> list[ScrapedLot]:
+ """Извлечь offers из Cian Redux state.
+
+ Использует cian_state_parser.extract_state() — общая утилита Stage 2.
+ Возвращает пустой список если state не найден.
+ """
+ state = extract_state(html, mfe=_MFE_SERP, key=_STATE_KEY)
+ if state is None:
+ logger.warning(
+ "cian SERP state extraction failed (mfe=%s key=%s) — "
+ "возможно Cian изменил структуру или вернул captcha",
+ _MFE_SERP, _STATE_KEY,
+ )
+ return []
+
+ offers_data: list[dict[str, Any]] = (
+ state.get("results", {}).get("offers", [])
+ )
+ if not offers_data:
+ logger.warning(
+ "cian state found but results.offers пуст (totalOffers=%s)",
+ state.get("results", {}).get("totalOffers", "?"),
+ )
+ return []
+
+ logger.info(
+ "cian SERP state ok: %d offers (totalOffers=%s)",
+ len(offers_data),
+ state.get("results", {}).get("totalOffers", "?"),
+ )
+
+ lots: list[ScrapedLot] = []
+ for offer in offers_data:
+ lot = self._offer_to_lot(offer)
+ if lot is not None:
+ lots.append(lot)
+ return lots
+
+ def _offer_to_lot(self, offer: dict[str, Any]) -> ScrapedLot | None:
+ """Парсинг одного Cian offer (137 fields) → ScrapedLot.
+
+ Маппинг полей per Schema_Cian_SERP_Inventory sec 3, sec 8, sec 11.
+ Без anchor jitter: координаты из offer.geo.coordinates (точные).
+ """
+ try:
+ # ── Identity ─────────────────────────────────────────────────────
+ offer_id = offer.get("cianId") or offer.get("id")
+ if not offer_id:
+ logger.debug("cian offer пропущен: нет cianId/id")
+ return None
+
+ source_id = str(offer_id)
+ url = offer.get("fullUrl")
+ if not url:
+ url = f"{self.base_url}/sale/flat/{offer_id}/"
+
+ # ── Цена ─────────────────────────────────────────────────────────
+ bargain: dict[str, Any] = offer.get("bargainTerms") or {}
+ price = bargain.get("priceRur") or bargain.get("price")
+ if not price:
+ logger.debug("cian offer %s пропущен: нет цены", offer_id)
+ return None
+ try:
+ price_rub = int(price)
+ except (TypeError, ValueError):
+ logger.debug("cian offer %s: не удалось распарсить цену %r", offer_id, price)
+ return None
+ if price_rub <= 0:
+ return None
+
+ # ── Параметры квартиры ───────────────────────────────────────────
+ rooms: int | None = offer.get("roomsCount")
+ # Студия: flatType == 'studio' → 0 комнат
+ if offer.get("flatType") == "studio" and rooms is None:
+ rooms = 0
+
+ area_raw = offer.get("totalArea")
+ area_m2: float | None = None
+ if area_raw is not None:
+ try:
+ area_m2 = float(area_raw)
+ except (TypeError, ValueError):
+ pass
+
+ living_area_raw = offer.get("livingArea")
+ living_area_m2: float | None = None
+ if living_area_raw is not None:
+ try:
+ living_area_m2 = float(living_area_raw)
+ except (TypeError, ValueError):
+ pass
+
+ # kitchen_area_m2 сохраняем в raw_payload (поле не в ScrapedLot Stage 2)
+ kitchen_area_raw = offer.get("kitchenArea")
+
+ floor: int | None = offer.get("floorNumber")
+
+ # ── Здание ───────────────────────────────────────────────────────
+ building: dict[str, Any] = offer.get("building") or {}
+ total_floors: int | None = building.get("floorsCount")
+ year_built: int | None = building.get("buildYear")
+ house_type: str | None = building.get("materialType")
+
+ # Newbuilding deadline year — если нет buildYear
+ if year_built is None:
+ deadline = building.get("deadline") or {}
+ year_built = deadline.get("year")
+
+ # ── Кадастр ─────────────────────────────────────────────────────
+ # cadastralNumber — кадастр КВАРТИРЫ
+ cadastral_number: str | None = offer.get("cadastralNumber")
+ # buildingCadastralNumber — кадастр ДОМА
+ building_cadastral_number: str | None = offer.get("buildingCadastralNumber")
+
+ # ── Геолокация (точные координаты — NO jitter) ───────────────────
+ geo: dict[str, Any] = offer.get("geo") or {}
+ coords: dict[str, Any] = geo.get("coordinates") or {}
+ lat: float | None = coords.get("lat")
+ lon: float | None = coords.get("lng")
+ # Если нет coords → сохраняем без lat/lon (geocode-missing обработает позже)
+ if lat is None or lon is None:
+ logger.debug(
+ "cian offer %s: нет точных координат в state (geo=%s)",
+ offer_id, coords,
+ )
+ lat = lon = None
+
+ # ── Адрес из geo.address[] ───────────────────────────────────────
+ address_parts = geo.get("address") or []
+ address = _format_address(address_parts)
+
+ # ── Metro stations ───────────────────────────────────────────────
+ undergrounds: list[dict[str, Any]] = geo.get("undergrounds") or []
+ metro_stations = [
+ {
+ "name": u.get("name"),
+ "time": u.get("time"),
+ "mode": u.get("transportType"),
+ "line_color": u.get("lineColor"),
+ "line_id": u.get("lineId"),
+ "is_default": u.get("isDefault", False),
+ }
+ for u in undergrounds
+ if u.get("name")
+ ]
+
+ # ── Newbuilding link ─────────────────────────────────────────────
+ newbuilding: dict[str, Any] = offer.get("newbuilding") or {}
+ nb_id = newbuilding.get("id")
+ # id == 0 — sentinel для вторички (per Schema sec 20.5)
+ house_source: str | None = None
+ house_ext_id: str | None = None
+ listing_segment: str | None = None
+
+ if nb_id and int(nb_id) > 0:
+ house_source = "cian_newbuilding"
+ house_ext_id = str(nb_id)
+ listing_segment = "novostroyki"
+ else:
+ house_source = "cian"
+ listing_segment = "vtorichka"
+
+ # ── Дополнительные характеристики ────────────────────────────────
+ balconies_count: int | None = offer.get("balconiesCount")
+ loggias_count: int | None = offer.get("loggiasCount")
+ bedrooms_count: int | None = offer.get("bedroomsCount")
+
+ has_balcony: bool | None = None
+ if balconies_count is not None:
+ has_balcony = balconies_count > 0
+ elif loggias_count is not None:
+ has_balcony = loggias_count > 0
+
+ # Мебель и repair_state
+ has_furniture: bool | None = offer.get("hasFurniture") or offer.get("isSoldFurnished")
+ # decoration — для новостроек (отделка); repair_state — для вторички
+ repair_state: str | None = offer.get("decoration")
+
+ # ── Продавец ─────────────────────────────────────────────────────
+ phones: list[dict[str, Any]] = offer.get("phones") or []
+ is_homeowner: bool | None = offer.get("isByHomeowner")
+ is_pro_seller: bool | None = offer.get("isPro")
+
+ # ── Сделка ───────────────────────────────────────────────────────
+ sale_type: str | None = bargain.get("saleType")
+ bargain_allowed: bool | None = bargain.get("bargainAllowed")
+
+ # ── Description + minhash ─────────────────────────────────────────
+ description: str | None = offer.get("description")
+ # Cian предоставляет готовый minhash для dedup и cross-source matching
+ description_minhash: str | None = offer.get("descriptionMinhash")
+ # Если Cian не дал minhash — вычисляем простой SHA1 из description
+ if not description_minhash and description:
+ description_minhash = hashlib.sha1(
+ description.lower().encode("utf-8", errors="replace")
+ ).hexdigest()[:32]
+
+ # ── Фото ─────────────────────────────────────────────────────────
+ photo_urls: list[str] = [
+ p["fullUrl"]
+ for p in (offer.get("photos") or [])
+ if isinstance(p, dict) and p.get("fullUrl")
+ ]
+
+ # ── Дата публикации ───────────────────────────────────────────────
+ added_ts = offer.get("addedTimestamp")
+ listing_date = None
+ if added_ts:
+ try:
+ listing_date = datetime.fromtimestamp(int(added_ts), tz=UTC).date()
+ except (TypeError, ValueError, OSError):
+ pass
+
+ # ── Raw payload (полный offer для enrichment в будущем) ───────────
+ raw_payload: dict[str, Any] = {
+ "cian_id": offer_id,
+ "flat_type": offer.get("flatType"),
+ "is_apartments": offer.get("isApartments"),
+ "offer_type": offer.get("offerType"),
+ "category": offer.get("category"),
+ "has_furniture": has_furniture,
+ "kitchen_area_m2": kitchen_area_raw, # не в ScrapedLot — сохраняем здесь
+ "mortgage_allowed": bargain.get("mortgageAllowed"),
+ "sale_type": sale_type,
+ "newbuilding_name": newbuilding.get("name"),
+ "is_from_developer": (
+ offer.get("fromDeveloper") or newbuilding.get("isFromDeveloper")
+ ),
+ "builders_ids": offer.get("buildersIds"),
+ "is_rosreestr_checked": offer.get("isRosreestrChecked"),
+ "is_layout_approved": offer.get("isLayoutApproved"),
+ "user_id": offer.get("userId"),
+ "published_user_id": offer.get("publishedUserId"),
+ "is_by_commercial_owner": offer.get("isByCommercialOwner"),
+ "is_cian_partner": offer.get("isCianPartner"),
+ "description_words_highlighted": offer.get("descriptionWordsHighlighted"),
+ "building_parking": building.get("parking"),
+ "building_total_area": building.get("totalArea"),
+ }
+
+ return ScrapedLot(
+ source="cian",
+ source_url=url,
+ source_id=source_id,
+ address=address,
+ lat=lat,
+ lon=lon,
+ rooms=rooms,
+ area_m2=area_m2,
+ floor=floor,
+ total_floors=total_floors,
+ year_built=year_built,
+ house_type=house_type,
+ repair_state=repair_state,
+ has_balcony=has_balcony,
+ kadastr_num=cadastral_number,
+ house_source=house_source,
+ house_ext_id=house_ext_id,
+ listing_segment=listing_segment,
+ price_rub=price_rub,
+ price_per_m2=None, # compute_price_per_m2() вычислит
+ listing_date=listing_date,
+ photo_urls=photo_urls,
+ raw_payload=raw_payload,
+ # Cian-specific (Stage 2 fields)
+ living_area_m2=living_area_m2,
+ bedrooms_count=bedrooms_count,
+ balconies_count=balconies_count,
+ loggias_count=loggias_count,
+ description_minhash=description_minhash,
+ cadastral_number=cadastral_number,
+ building_cadastral_number=building_cadastral_number,
+ phones=phones,
+ is_homeowner=is_homeowner,
+ is_pro_seller=is_pro_seller,
+ bargain_allowed=bargain_allowed,
+ sale_type=sale_type,
+ metro_stations=metro_stations,
+ )
+
+ except Exception:
+ _oid = offer.get("cianId") or offer.get("id")
+ logger.exception("cian _offer_to_lot failed for offer_id=%s", _oid)
+ return None
+
+
+# ── Address formatter ────────────────────────────────────────────────────────
+
+
+def _format_address(address_parts: list[dict[str, Any]]) -> str:
+ """Сформировать читаемый адрес из geo.address[] Cian.
+
+ Пропускаем location (страна/регион) и metro — берём раион/улицу/дом.
+ Пример: [location=Москва, raion=Пресненский, street=..., house=1С7, metro=Москва-Сити]
+ → 'Пресненский, улица ..., 1С7'
+ """
+ if not address_parts:
+ return "Екатеринбург (Cian)"
+
+ skip_types = {"location", "metro"}
+ parts: list[str] = []
+ for part in address_parts:
+ if not isinstance(part, dict):
+ continue
+ ptype = part.get("type", "")
+ if ptype in skip_types:
+ continue
+ name = (part.get("fullName") or part.get("name") or "").strip()
+ if name:
+ parts.append(name)
+
+ return ", ".join(parts) if parts else "Екатеринбург (Cian)"
diff --git a/tradein-mvp/backend/app/services/scrapers/cian_detail.py b/tradein-mvp/backend/app/services/scrapers/cian_detail.py
new file mode 100644
index 00000000..59d281cf
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/cian_detail.py
@@ -0,0 +1,324 @@
+"""Cian.ru detail page scraper — single offer enrichment.
+
+Different from SERP (cian.py):
+- URL: https://ekb.cian.ru/sale/flat// or https://www.cian.ru/sale/flat//
+- MFE: 'frontend-offer-card'
+- State KEY: 'defaultState' (NOT 'initialState' — SERP uses initialState)
+- Sister containers in same _cianConfig: bti, priceChanges, stats, agent, newObject
+
+Parses ~88 offer fields + sister data → DetailEnrichment dataclass.
+Stage 5 of CianScraper v1.
+"""
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass, field
+from typing import Any
+
+from curl_cffi.requests import AsyncSession
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.services.scrapers.cian_state_parser import extract_all_states, extract_state
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class DetailEnrichment:
+ """Result of cian_detail.fetch_detail() — fields для UPDATE listings + sister rows."""
+
+ # Core listing enrichment (extend existing listings row)
+ cian_id: int | None = None
+ windows_view_type: str | None = None # 'yard' / 'street' / 'yardAndStreet'
+ separate_wcs_count: int | None = None
+ combined_wcs_count: int | None = None
+ ceiling_height: float | None = None # meters
+ repair_type: str | None = None # 'cosmetic' / 'design' / 'no'
+ views_total: int | None = None # Cian stats.totalViewsFormattedString → int
+ views_today: int | None = None
+
+ # BTI (вторичка only — primary buildings don't have БТИ)
+ bti_data: dict[str, Any] | None = None # raw bti.houseData snapshot
+
+ # Price history (offer.priceChanges)
+ price_changes: list[dict[str, Any]] = field(default_factory=list)
+
+ # Agent profile (offer.agent.* expanded)
+ agent_profile: dict[str, Any] | None = None
+
+ # Newbuilding link (if offer is from a newbuilding)
+ newbuilding_data: dict[str, Any] | None = None
+
+ # Raw payload для debugging / future-extraction
+ raw_offer: dict[str, Any] | None = None
+ raw_sister_states: dict[str, Any] = field(default_factory=dict)
+
+
+async def fetch_detail(
+ offer_url: str,
+ *,
+ session: AsyncSession | None = None,
+) -> DetailEnrichment | None:
+ """Fetch detail page and extract all containers.
+
+ Args:
+ offer_url: e.g. 'https://ekb.cian.ru/sale/flat/327830237/'
+ session: optional shared curl_cffi session (для batched scrapes)
+
+ Returns: DetailEnrichment, or None если fetch / parse failed.
+ """
+ close_session = False
+ if session is None:
+ session = AsyncSession(
+ impersonate="chrome120",
+ timeout=25.0,
+ headers={
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
+ },
+ )
+ close_session = True
+
+ try:
+ resp = await session.get(offer_url, allow_redirects=True)
+ if resp.status_code != 200:
+ logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code)
+ return None
+ html = resp.text
+
+ # Primary state container — defaultState in frontend-offer-card MFE
+ # NOTE: detail pages use 'defaultState', SERP uses 'initialState'
+ offer_state = extract_state(html, mfe="frontend-offer-card", key="defaultState")
+ if offer_state is None:
+ logger.warning("Cian detail %s: defaultState extraction failed", offer_url)
+ return None
+
+ offer_data = offer_state.get("offerData", {})
+ offer = offer_data.get("offer", {})
+
+ result = DetailEnrichment(
+ cian_id=offer.get("cianId") or offer.get("id"),
+ windows_view_type=_extract_windows_view(offer),
+ separate_wcs_count=offer.get("separateWcsCount"),
+ combined_wcs_count=offer.get("combinedWcsCount"),
+ ceiling_height=_parse_float(offer.get("ceilingHeight")),
+ repair_type=_extract_repair_type(offer),
+ raw_offer=offer,
+ )
+
+ # Stats (views) — from stats key in offerData or top-level state
+ stats = offer_data.get("stats") or offer_state.get("stats") or {}
+ result.views_total = _parse_views(stats.get("totalViewsFormattedString"))
+ result.views_today = _parse_views(stats.get("todayViewsFormattedString"))
+
+ # Price changes — from offer.priceChanges or state-level priceChanges
+ result.price_changes = _extract_price_changes(offer, offer_state)
+
+ # Agent profile expansion
+ result.agent_profile = _extract_agent_profile(offer)
+
+ # Sister state containers: bti, newObject, plus raw stash of everything else
+ all_states = extract_all_states(html)
+ offer_card_states = all_states.get("frontend-offer-card", {})
+
+ bti_state = offer_card_states.get("bti")
+ if bti_state:
+ result.bti_data = bti_state.get("houseData")
+ result.raw_sister_states["bti"] = bti_state
+
+ # Newbuilding link (if applicable)
+ newbuilding = offer.get("newbuilding")
+ if newbuilding and newbuilding.get("id"):
+ result.newbuilding_data = newbuilding
+
+ # Stash all non-defaultState sister containers for debugging / future-extraction
+ for k, v in offer_card_states.items():
+ if k != "defaultState":
+ result.raw_sister_states[k] = v
+
+ return result
+
+ finally:
+ if close_session:
+ await session.close()
+
+
+# ── Field extractors ──────────────────────────────────────────────────────────
+
+
+def _extract_windows_view(offer: dict[str, Any]) -> str | None:
+ return offer.get("windowsViewType")
+
+
+def _extract_repair_type(offer: dict[str, Any]) -> str | None:
+ # Cian: 'cosmetic' / 'design' / 'no' / 'euro' / ...
+ return offer.get("repairType")
+
+
+def _parse_float(value: Any) -> float | None:
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except (ValueError, TypeError):
+ return None
+
+
+def _parse_views(formatted_str: str | None) -> int | None:
+ """Cian's stats.totalViewsFormattedString — '1 234' → 1234."""
+ if not formatted_str:
+ return None
+ try:
+ return int(str(formatted_str).replace(" ", "").replace(",", ""))
+ except (ValueError, TypeError):
+ return None
+
+
+def _extract_price_changes(offer: dict[str, Any], state: dict[str, Any]) -> list[dict[str, Any]]:
+ """offer.priceChanges or state.priceChanges — normalize to common form."""
+ raw = offer.get("priceChanges") or state.get("priceChanges") or []
+ if not isinstance(raw, list):
+ return []
+ result = []
+ for entry in raw:
+ if not isinstance(entry, dict):
+ continue
+ result.append(
+ {
+ "change_time": entry.get("changeTime") or entry.get("date"),
+ "price_rub": entry.get("price") or entry.get("priceRub"),
+ "diff_percent": entry.get("diffPercent") or entry.get("diff_percent"),
+ }
+ )
+ return result
+
+
+def _extract_agent_profile(offer: dict[str, Any]) -> dict[str, Any] | None:
+ """offer.agent or offer.agency or offer.user — extract canonical profile shape."""
+ agent = offer.get("agent") or {}
+ user = offer.get("user") or {}
+ if not agent and not user:
+ return None
+ return {
+ "ext_agent_id": agent.get("id") or user.get("cianUserId"),
+ "company_name": agent.get("companyName") or user.get("companyName"),
+ "is_pro": agent.get("isPro") or user.get("isPro"),
+ "skills": agent.get("skills") or [],
+ "account_type": agent.get("accountType") or user.get("accountType"),
+ }
+
+
+# ── Save helpers ──────────────────────────────────────────────────────────────
+
+
+def save_detail_enrichment(db: Session, listing_id: int, enrichment: DetailEnrichment) -> None:
+ """Persist DetailEnrichment to DB:
+
+ - UPDATE listings SET windows_view_type, ceiling_height, ... WHERE id = listing_id
+ - INSERT INTO offer_price_history rows (if price_changes non-empty)
+ - INSERT INTO agents (if agent_profile) and link via agent_id_fk
+ - bti_data не сохраняется здесь — это задача Stage 6 (houses), где есть house_id
+
+ Idempotency: re-running безопасно (UPSERT / ON CONFLICT DO NOTHING semantics).
+ """
+ # Extend listings row with detail-enriched fields
+ db.execute(
+ text("""
+ UPDATE listings SET
+ windows_view_type = COALESCE(:wvt, windows_view_type),
+ separate_wcs_count = COALESCE(:swc, separate_wcs_count),
+ combined_wcs_count = COALESCE(:cwc, combined_wcs_count),
+ ceiling_height = COALESCE(:ch, ceiling_height),
+ repair_type = COALESCE(:rt, repair_type),
+ views_total = COALESCE(:vt, views_total),
+ views_today = COALESCE(:vd, views_today)
+ WHERE id = CAST(:lid AS bigint)
+ """),
+ {
+ "lid": listing_id,
+ "wvt": enrichment.windows_view_type,
+ "swc": enrichment.separate_wcs_count,
+ "cwc": enrichment.combined_wcs_count,
+ "ch": enrichment.ceiling_height,
+ "rt": enrichment.repair_type,
+ "vt": enrichment.views_total,
+ "vd": enrichment.views_today,
+ },
+ )
+
+ # Price changes — INSERT new rows, de-dup by listing_id + change_time
+ for change in enrichment.price_changes:
+ if not change.get("change_time") or not change.get("price_rub"):
+ continue
+ db.execute(
+ text("""
+ INSERT INTO offer_price_history
+ (listing_id, change_time, price_rub, diff_percent, source)
+ VALUES (
+ CAST(:lid AS bigint),
+ CAST(:ct AS timestamptz),
+ CAST(:price AS numeric),
+ CAST(:diff AS numeric),
+ 'cian'
+ )
+ ON CONFLICT DO NOTHING
+ """),
+ {
+ "lid": listing_id,
+ "ct": change["change_time"],
+ "price": change["price_rub"],
+ "diff": change.get("diff_percent"),
+ },
+ )
+
+ # Agent profile UPSERT — store agent row and link to listing
+ if enrichment.agent_profile and enrichment.agent_profile.get("ext_agent_id"):
+ ap = enrichment.agent_profile
+ row = db.execute(
+ text("""
+ INSERT INTO agents
+ (ext_source, ext_agent_id, company_name, is_pro, skills, account_type)
+ VALUES (
+ 'cian',
+ :eai,
+ :cn,
+ :ip,
+ CAST(:sk AS jsonb),
+ :at
+ )
+ ON CONFLICT (ext_source, ext_agent_id) DO UPDATE SET
+ company_name = COALESCE(EXCLUDED.company_name, agents.company_name),
+ is_pro = COALESCE(EXCLUDED.is_pro, agents.is_pro),
+ skills = COALESCE(EXCLUDED.skills, agents.skills),
+ account_type = COALESCE(EXCLUDED.account_type, agents.account_type)
+ RETURNING id
+ """),
+ {
+ "eai": str(ap["ext_agent_id"]),
+ "cn": ap.get("company_name"),
+ "ip": ap.get("is_pro"),
+ "sk": json.dumps(ap.get("skills") or []),
+ "at": ap.get("account_type"),
+ },
+ )
+ agent_db_id = row.scalar_one_or_none()
+ if agent_db_id is not None:
+ db.execute(
+ text("""
+ UPDATE listings
+ SET agent_id_fk = CAST(:aid AS bigint)
+ WHERE id = CAST(:lid AS bigint)
+ AND agent_id_fk IS NULL
+ """),
+ {"aid": agent_db_id, "lid": listing_id},
+ )
+
+ db.commit()
+ logger.info(
+ "Cian detail enrichment saved for listing_id=%s (price_changes=%d, agent=%s)",
+ listing_id,
+ len(enrichment.price_changes),
+ bool(enrichment.agent_profile),
+ )
diff --git a/tradein-mvp/backend/app/services/scrapers/cian_newbuilding.py b/tradein-mvp/backend/app/services/scrapers/cian_newbuilding.py
new file mode 100644
index 00000000..10e458e0
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/cian_newbuilding.py
@@ -0,0 +1,548 @@
+"""Cian.ru newbuilding (ЖК) catalog page scraper.
+
+URL: https://zhk---i.cian.ru/
+MFE: 'newbuilding-card-desktop-fichering-frontend', key: 'initialState'
+
+Sister state containers extracted from same MFE initialState top-level keys:
+- realtyValuation: 7-month price chart (data.priceDynamics.chart.data.{labels,values})
+ → houses_price_dynamics
+- reliability: наш.дом.рф checkStatus + details[] → house_reliability_checks
+- offers: grouped by roomType (fromDeveloperRooms[].layouts[])
+- reviews: house reviews list
+- housesByTurn: корпуса по очередям (stored as jsonb on houses)
+
+Stage 6 of CianScraper v1.
+"""
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass, field
+from typing import Any
+
+from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
+
+from app.services.scrapers.cian_state_parser import extract_all_states, extract_state
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class NewbuildingEnrichment:
+ """Result of fetch_newbuilding() — houses row + sister containers."""
+
+ # Core house metadata (from state.newbuilding)
+ cian_internal_house_id: int | None = None
+ name: str | None = None
+ address: str | None = None
+ year_built: int | None = None
+ deadline_year: int | None = None
+ floors_count_min: int | None = None
+ floors_count_max: int | None = None
+ building_class: str | None = None # 'comfort' / 'business' / 'premium'
+
+ # Management
+ management_company: dict[str, Any] | None = None # name, phones[], email, ...
+
+ # Aggregated metrics
+ transport_accessibility_rate: int | None = None
+ advantages: list[dict[str, Any]] = field(default_factory=list)
+
+ # Time-series price chart (Stage 6 main deliverable)
+ # Each entry: {'month_date': str, 'room_count': str, 'prices_type': str, 'period': str,
+ # 'price_per_sqm': float | None}
+ realty_valuation_chart: list[dict[str, Any]] = field(default_factory=list)
+
+ # Reliability (наш.дом.рф) — overall status + checks detail array
+ # {check_status: str, check_name: str, details: list[dict]}
+ reliability_checks: list[dict[str, Any]] = field(default_factory=list)
+
+ # Builders / banks (related orgs)
+ builders: list[dict[str, Any]] = field(default_factory=list)
+ banks: list[dict[str, Any]] = field(default_factory=list)
+
+ # Corpuses (houses_by_turn — корпуса по очередям)
+ houses_by_turn: list[dict[str, Any]] = field(default_factory=list)
+
+ # Reviews
+ reviews: list[dict[str, Any]] = field(default_factory=list)
+
+ # Mini-SERP offers (grouped by roomType, layouts)
+ nested_offers: list[dict[str, Any]] = field(default_factory=list)
+
+ # Raw payloads для debugging
+ raw_state: dict[str, Any] | None = None
+ raw_sister_states: dict[str, Any] = field(default_factory=dict)
+
+
+async def fetch_newbuilding(
+ zhk_url: str,
+ *,
+ session: AsyncSession | None = None,
+) -> NewbuildingEnrichment | None:
+ """Fetch ЖК catalog page и extract все containers.
+
+ Args:
+ zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/'
+ session: optional shared curl_cffi session (caller owns lifecycle)
+
+ Returns: NewbuildingEnrichment, or None если fetch / parse failed.
+ """
+ close_session = False
+ if session is None:
+ session = AsyncSession(
+ impersonate="chrome120",
+ timeout=30.0,
+ headers={
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
+ },
+ )
+ close_session = True
+
+ try:
+ resp = await session.get(zhk_url, allow_redirects=True)
+ if resp.status_code != 200:
+ logger.warning("Cian newbuilding fetch %s → HTTP %d", zhk_url, resp.status_code)
+ return None
+ html = resp.text
+
+ mfe = "newbuilding-card-desktop-fichering-frontend"
+
+ nb_state = extract_state(html, mfe=mfe, key="initialState")
+ if nb_state is None:
+ logger.warning("Cian newbuilding %s: initialState extraction failed", zhk_url)
+ return None
+
+ # Primary newbuilding object: state.newbuilding (60 fields per schema sec 15.4)
+ nb = nb_state.get("newbuilding") or {}
+ if not isinstance(nb, dict):
+ nb = {}
+
+ result = NewbuildingEnrichment(
+ cian_internal_house_id=nb.get("id"),
+ name=nb.get("name") or nb.get("displayName"),
+ address=_extract_address(nb),
+ year_built=nb.get("yearBuilt") or nb.get("buildYear"),
+ deadline_year=_extract_deadline_year(nb),
+ floors_count_min=nb.get("floorsCountMin"),
+ floors_count_max=nb.get("floorsCountMax"),
+ building_class=nb.get("newbuildingClassName") or nb.get("buildingClass"),
+ management_company=nb.get("managementCompany"),
+ transport_accessibility_rate=nb.get("transportAccessibilityRate"),
+ advantages=_extract_advantages(nb),
+ builders=_safe_list(nb.get("builders")),
+ banks=_safe_list(nb.get("banks")),
+ houses_by_turn=_safe_list(nb.get("housesByTurn")),
+ raw_state=nb_state,
+ )
+
+ # Sister containers — all live under the same MFE initialState top-level keys
+ # (not separate push() entries) per schema sec 15.3
+ all_states = extract_all_states(html)
+ mfe_states = all_states.get(mfe, {})
+ # initialState top-level keys contain sister data: realtyValuation, reliability, offers
+ top = nb_state
+
+ # realtyValuation 7-month chart (Stage 6 main deliverable)
+ # Located at initialState.realtyValuation (sec 15.5)
+ rv_state = top.get("realtyValuation") or mfe_states.get("realtyValuation")
+ if rv_state and isinstance(rv_state, dict):
+ result.realty_valuation_chart = _extract_chart(rv_state)
+ result.raw_sister_states["realtyValuation"] = rv_state
+
+ # reliability (наш.дом.рф) — located at initialState.reliability (sec 15.6)
+ rel_state = top.get("reliability") or mfe_states.get("reliability")
+ if rel_state and isinstance(rel_state, dict):
+ result.reliability_checks = _extract_reliability_checks(rel_state)
+ result.raw_sister_states["reliability"] = rel_state
+
+ # reviews — located at initialState or separate state
+ reviews_state = top.get("reviews") or mfe_states.get("reviews")
+ if reviews_state and isinstance(reviews_state, dict):
+ items = reviews_state.get("items") or reviews_state.get("data") or []
+ result.reviews = items if isinstance(items, list) else []
+ result.raw_sister_states["reviews"] = reviews_state
+
+ # offers (grouped by roomType) — located at initialState.offers (sec 15.7)
+ offers_state = top.get("offers") or mfe_states.get("offers") or mfe_states.get("miniSerp")
+ if offers_state and isinstance(offers_state, dict):
+ result.nested_offers = _extract_nested_offers(offers_state)
+ result.raw_sister_states["offers"] = offers_state
+
+ logger.info(
+ "Cian newbuilding %s parsed: id=%s chart=%d reliability=%d offers=%d",
+ zhk_url,
+ result.cian_internal_house_id,
+ len(result.realty_valuation_chart),
+ len(result.reliability_checks),
+ len(result.nested_offers),
+ )
+ return result
+ finally:
+ if close_session:
+ await session.close()
+
+
+# ---- private extractors ----
+
+
+def _safe_list(v: Any) -> list[dict[str, Any]]:
+ """Return v as list[dict] or empty list."""
+ if not isinstance(v, list):
+ return []
+ return [item for item in v if isinstance(item, dict)]
+
+
+def _extract_address(nb: dict[str, Any]) -> str | None:
+ """Extract display address string from newbuilding object."""
+ addr = nb.get("fullAddress") or nb.get("address")
+ if isinstance(addr, str):
+ return addr
+ if isinstance(addr, dict):
+ return addr.get("full") or addr.get("display") or addr.get("name")
+ return None
+
+
+def _extract_deadline_year(nb: dict[str, Any]) -> int | None:
+ """Extract planned completion year from newbuilding.deadline or similar field."""
+ dl = nb.get("deadline") or nb.get("buildingDeadline")
+ if isinstance(dl, dict):
+ return dl.get("year")
+ if isinstance(dl, int):
+ return dl
+ return None
+
+
+def _extract_advantages(nb: dict[str, Any]) -> list[dict[str, Any]]:
+ """Extract typed advantages list from newbuilding.advantages."""
+ raw = nb.get("advantages") or nb.get("advantagesTyped") or []
+ if not isinstance(raw, list):
+ return []
+ return [a for a in raw if isinstance(a, dict)]
+
+
+def _extract_chart(rv_state: dict[str, Any]) -> list[dict[str, Any]]:
+ """Extract realtyValuation time-series chart points.
+
+ Actual Cian shape (sec 15.5):
+ rv_state.data.priceDynamics.chart.data = {labels: [date...], values: [int...]}
+
+ Falls back to alternative shapes if structure varies.
+ Each output point:
+ {month_date: str, room_count: str, prices_type: str, period: str, price_per_sqm: float|None}
+
+ Note: The initial page state provides aggregate (all room types) chart only.
+ Per-room-type data requires XHR requests (not done here).
+ """
+ points: list[dict[str, Any]] = []
+
+ # Primary shape: data.priceDynamics.chart.data.{labels, values}
+ data_block = rv_state.get("data")
+ if isinstance(data_block, dict):
+ price_dynamics = data_block.get("priceDynamics") or {}
+ if isinstance(price_dynamics, dict):
+ chart = price_dynamics.get("chart") or {}
+ if isinstance(chart, dict):
+ chart_data = chart.get("data") or {}
+ if isinstance(chart_data, dict):
+ labels = chart_data.get("labels") or []
+ values = chart_data.get("values") or []
+ if isinstance(labels, list) and isinstance(values, list):
+ for label, value in zip(labels, values, strict=False):
+ if label is None or value is None:
+ continue
+ points.append({
+ "month_date": str(label)[:10], # YYYY-MM-DD
+ "room_count": "all", # aggregate (initial state)
+ "prices_type": "price", # total price (not priceSqm)
+ "period": "halfYear", # default period shown
+ "price_per_sqm": None, # chart shows total price, not sqm
+ })
+ # Store raw value as price_per_sqm placeholder for DB
+ # (actual sqm price would require area context)
+ # Use the value as-is; caller / downstream can enrich
+ points[-1]["price_per_sqm"] = float(value) if isinstance(
+ value, (int, float)
+ ) else None
+ if points:
+ return points
+
+ # Fallback shape A: rv_state.chart (flat list with {month/date, price, roomType})
+ chart_flat = rv_state.get("chart") or rv_state.get("prices") or rv_state.get("dynamics")
+ if isinstance(chart_flat, list):
+ for entry in chart_flat:
+ if not isinstance(entry, dict):
+ continue
+ month = entry.get("month") or entry.get("date") or entry.get("monthDate")
+ price = entry.get("price") or entry.get("value") or entry.get("pricePerSqm")
+ if month is None or price is None:
+ continue
+ price_val: float | None = None
+ try:
+ price_val = float(price)
+ except (TypeError, ValueError):
+ pass
+ points.append({
+ "month_date": str(month)[:10],
+ "room_count": str(entry.get("roomType") or entry.get("roomCount") or "all"),
+ "prices_type": str(entry.get("pricesType") or entry.get("priceType") or "price"),
+ "period": str(entry.get("period") or "halfYear"),
+ "price_per_sqm": price_val,
+ })
+ return points
+
+ return []
+
+
+def _extract_reliability_checks(rel_state: dict[str, Any]) -> list[dict[str, Any]]:
+ """Extract reliability check summary from наш.дом.рф state.
+
+ Actual Cian shape (sec 15.6):
+ rel_state = {
+ checkStatus: {status: 'reliable', title: '...', date: '...'},
+ details: [{title, iconType, type}, ...],
+ ...
+ }
+
+ Returns a list with ONE entry representing the overall check (per DB schema):
+ [{check_status, check_name, details}]
+
+ The 'details' array is stored as jsonb (full check list) in house_reliability_checks.
+ """
+ check_status_block = rel_state.get("checkStatus")
+ if not isinstance(check_status_block, dict):
+ # Alternative: flat list shape
+ raw = rel_state.get("checks") or rel_state.get("items") or []
+ if isinstance(raw, list) and raw:
+ return [
+ {
+ "check_name": entry.get("name") or entry.get("title") or entry.get("checkName"),
+ "check_status": entry.get("status") or entry.get("checkStatus"),
+ "details": entry.get("details") or entry,
+ }
+ for entry in raw
+ if isinstance(entry, dict)
+ ]
+ return []
+
+ status = check_status_block.get("status")
+ name = check_status_block.get("title") or check_status_block.get("mobileTitle")
+ details = rel_state.get("details") or []
+
+ return [
+ {
+ "check_name": name,
+ "check_status": status,
+ "details": details if isinstance(details, list) else [details],
+ }
+ ]
+
+
+def _extract_nested_offers(offers_state: dict[str, Any]) -> list[dict[str, Any]]:
+ """Extract mini-SERP offers from offers container.
+
+ Actual Cian shape (sec 15.7):
+ offers_state.data.total.fromDeveloperRooms[].{roomType, layouts[]}
+
+ Returns flat list of layout offers with room_count injected.
+ """
+ result: list[dict[str, Any]] = []
+
+ # Shape A: miniSerp / items list
+ if "items" in offers_state:
+ items = offers_state["items"]
+ return items if isinstance(items, list) else []
+
+ # Shape B: data.total.fromDeveloperRooms[].layouts[]
+ data = offers_state.get("data")
+ if isinstance(data, dict):
+ total = data.get("total") or {}
+ if isinstance(total, dict):
+ rooms = total.get("fromDeveloperRooms") or []
+ else:
+ rooms = []
+ else:
+ rooms = []
+
+ if not isinstance(rooms, list):
+ return result
+
+ for room in rooms:
+ if not isinstance(room, dict):
+ continue
+ room_type = room.get("roomType") or room.get("roomTypeDisplay")
+ layouts = room.get("layouts") or []
+ if not isinstance(layouts, list):
+ continue
+ for layout in layouts:
+ if isinstance(layout, dict):
+ result.append({
+ "room_count": room_type,
+ **layout,
+ })
+
+ return result
+
+
+# ---- save helpers ----
+
+
+async def save_newbuilding_enrichment(
+ db: Any,
+ house_id: int,
+ enrichment: NewbuildingEnrichment,
+) -> None:
+ """Persist NewbuildingEnrichment to DB.
+
+ Steps:
+ 1. UPSERT management_companies (if present) → get mc_id
+ 2. UPDATE houses with Cian metadata
+ 3. INSERT INTO houses_price_dynamics (chart points, ON CONFLICT DO UPDATE)
+ 4. INSERT INTO house_reliability_checks (overall + details)
+ """
+ from sqlalchemy import text
+
+ # 1. UPSERT management_company
+ mc_id: int | None = None
+ if enrichment.management_company and isinstance(enrichment.management_company, dict):
+ mc = enrichment.management_company
+ mc_name = mc.get("name") or "Unknown"
+ mc_ext_id = mc.get("id")
+ mc_result = db.execute(
+ text("""
+ INSERT INTO management_companies (
+ name, phones, email, opening_hours, chief_name, ext_source, ext_id
+ ) VALUES (
+ :name,
+ CAST(:phones AS text[]),
+ :email,
+ :oh,
+ :chief,
+ 'cian',
+ CAST(:ext_id AS bigint)
+ )
+ ON CONFLICT (ext_source, name, ext_id) DO UPDATE SET
+ phones = EXCLUDED.phones,
+ email = COALESCE(EXCLUDED.email, management_companies.email)
+ RETURNING id
+ """),
+ {
+ "name": mc_name,
+ "phones": mc.get("phones") or [],
+ "email": mc.get("email"),
+ "oh": mc.get("openingHours"),
+ "chief": mc.get("chiefName"),
+ "ext_id": mc_ext_id,
+ },
+ )
+ row = mc_result.fetchone()
+ mc_id = row[0] if row else None
+
+ # 2. UPDATE houses
+ db.execute(
+ text("""
+ UPDATE houses SET
+ cian_internal_house_id = COALESCE(
+ CAST(:cihi AS bigint), cian_internal_house_id
+ ),
+ year_built = COALESCE(:yb, year_built),
+ management_company_id = COALESCE(CAST(:mcid AS bigint), management_company_id),
+ transport_accessibility_rate = COALESCE(
+ :tar, transport_accessibility_rate
+ ),
+ advantages = CAST(:adv AS jsonb),
+ banks = CAST(:bk AS jsonb),
+ builders = CAST(:bld AS jsonb),
+ houses_by_turn = CAST(:hbt AS jsonb)
+ WHERE id = CAST(:hid AS bigint)
+ """),
+ {
+ "hid": house_id,
+ "cihi": enrichment.cian_internal_house_id,
+ "yb": enrichment.year_built,
+ "mcid": mc_id,
+ "tar": enrichment.transport_accessibility_rate,
+ "adv": json.dumps(enrichment.advantages, ensure_ascii=False),
+ "bk": json.dumps(enrichment.banks, ensure_ascii=False),
+ "bld": json.dumps(enrichment.builders, ensure_ascii=False),
+ "hbt": json.dumps(enrichment.houses_by_turn, ensure_ascii=False),
+ },
+ )
+
+ # 3. INSERT houses_price_dynamics
+ # UNIQUE constraint: houses_price_dynamics_dim_key
+ # (house_id, source, room_count, prices_type, period, month_date) — per migration 029
+ chart_saved = 0
+ for point in enrichment.realty_valuation_chart:
+ if point.get("price_per_sqm") is None:
+ continue
+ room_count = point.get("room_count") or "all"
+ prices_type = point.get("prices_type") or "price"
+ period = point.get("period") or "halfYear"
+ db.execute(
+ text("""
+ INSERT INTO houses_price_dynamics (
+ house_id, month_date, source,
+ room_count, prices_type, period,
+ price_per_sqm, recorded_at
+ ) VALUES (
+ CAST(:hid AS bigint),
+ CAST(:md AS date),
+ 'cian_realty_valuation',
+ :rc, :pt, :pd,
+ CAST(:pps AS numeric),
+ NOW()
+ )
+ ON CONFLICT ON CONSTRAINT houses_price_dynamics_dim_key DO UPDATE SET
+ price_per_sqm = EXCLUDED.price_per_sqm,
+ recorded_at = NOW()
+ """),
+ {
+ "hid": house_id,
+ "md": point["month_date"],
+ "rc": room_count,
+ "pt": prices_type,
+ "pd": period,
+ "pps": point["price_per_sqm"],
+ },
+ )
+ chart_saved += 1
+
+ # 4. INSERT house_reliability_checks (stores overall check + details array)
+ # Schema (025): (house_id, check_status, check_name, details jsonb, source, recorded_at)
+ # No UNIQUE constraint — caller should manage duplicates if needed
+ for check in enrichment.reliability_checks:
+ if not check.get("check_name") and not check.get("check_status"):
+ continue
+ db.execute(
+ text("""
+ INSERT INTO house_reliability_checks (
+ house_id, check_status, check_name, details, source, recorded_at
+ ) VALUES (
+ CAST(:hid AS bigint),
+ :cs,
+ :cn,
+ CAST(:det AS jsonb),
+ 'cian_nashdom',
+ NOW()
+ )
+ """),
+ {
+ "hid": house_id,
+ "cs": check.get("check_status"),
+ "cn": check.get("check_name"),
+ "det": json.dumps(
+ check.get("details") or [], ensure_ascii=False
+ ),
+ },
+ )
+
+ db.commit()
+ logger.info(
+ "Cian newbuilding saved house_id=%s (chart=%d points, reliability=%d checks, mc_id=%s)",
+ house_id,
+ chart_saved,
+ len(enrichment.reliability_checks),
+ mc_id,
+ )
diff --git a/tradein-mvp/backend/app/services/scrapers/cian_state_parser.py b/tradein-mvp/backend/app/services/scrapers/cian_state_parser.py
new file mode 100644
index 00000000..429aa962
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/cian_state_parser.py
@@ -0,0 +1,93 @@
+"""Shared utility для извлечения Cian Redux state из _cianConfig.
+
+Pattern: window._cianConfig[''].push({key:..., value:..., priority:..., filter:...})
+Used by SERP / detail / newbuilding / valuation scrapers.
+"""
+import json
+import logging
+import re
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+# Match both patterns Cian uses:
+# 1. window._cianConfig['mfe'].push({key:..., value:..., priority:..., filter:...})
+# 2. window._cianConfig['mfe'] = window._cianConfig['mfe'] || [];
+# window._cianConfig['mfe'].push({...})
+_RE_CIAN_PUSH = re.compile(
+ r"window\._cianConfig\['(?P[\w-]+)'\]"
+ r"(?:\.push\(\s*|"
+ r"\s*=\s*window\._cianConfig\['[\w-]+'\]\s*\|\|\s*\[\];\s*"
+ r"window\._cianConfig\['[\w-]+'\]\.push\(\s*)"
+ r"\{\s*key:\s*['\"](?P\w+)['\"]\s*,\s*"
+ r"value:\s*(?P.+?)\s*,\s*"
+ r"priority:",
+ re.DOTALL,
+)
+
+
+def extract_state(html: str, mfe: str, key: str = "initialState") -> dict[str, Any] | None:
+ """Extract state.value where _cianConfig[] entry.key matches.
+
+ Returns parsed JSON dict, or None if not found / parse failed.
+ Logs warning at debug level on parse failure for diagnostics.
+ """
+ for match in _RE_CIAN_PUSH.finditer(html):
+ if match.group("mfe") != mfe or match.group("key") != key:
+ continue
+
+ value_str = match.group("value").strip()
+
+ # Strategy 1: direct JSON parse (value уже JSON-like)
+ try:
+ return json.loads(value_str)
+ except json.JSONDecodeError:
+ pass
+
+ # Strategy 2: JSON.parse("escaped string")
+ m_jp = re.match(r'JSON\.parse\(\s*"(.+?)"\s*\)\s*$', value_str, re.DOTALL)
+ if m_jp:
+ escaped = m_jp.group(1)
+ unescaped = (
+ escaped
+ .replace('\\"', '"')
+ .replace('\\\\', '\\')
+ .replace('\\n', '\n')
+ .replace('\\/', '/')
+ )
+ try:
+ return json.loads(unescaped)
+ except json.JSONDecodeError as exc:
+ logger.debug(
+ "JSON.parse() unescape failed for mfe=%s key=%s: %s", mfe, key, exc
+ )
+ continue
+
+ # Strategy 3: raw JS object literal — try demjson3 (optional dep)
+ try:
+ import demjson3 # type: ignore[import-untyped]
+
+ return demjson3.decode(value_str)
+ except ImportError:
+ logger.debug(
+ "demjson3 not installed; cannot parse raw JS object for mfe=%s key=%s", mfe, key
+ )
+ except Exception as exc:
+ logger.debug("demjson3 parse failed for mfe=%s key=%s: %s", mfe, key, exc)
+
+ return None
+
+
+def extract_all_states(html: str) -> dict[str, dict[str, Any]]:
+ """Returns {mfe_name: {key: parsed_value}} for all push() entries.
+
+ Useful for discovery / debugging during scraper development.
+ """
+ result: dict[str, dict[str, Any]] = {}
+ for match in _RE_CIAN_PUSH.finditer(html):
+ mfe = match.group("mfe")
+ key = match.group("key")
+ parsed = extract_state(html, mfe, key)
+ if parsed is not None:
+ result.setdefault(mfe, {})[key] = parsed
+ return result
diff --git a/tradein-mvp/backend/app/services/scrapers/cian_valuation.py b/tradein-mvp/backend/app/services/scrapers/cian_valuation.py
new file mode 100644
index 00000000..f2fe97f9
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/cian_valuation.py
@@ -0,0 +1,491 @@
+"""Cian.ru Valuation Calculator scraper — auth required.
+
+URL: https://www.cian.ru/kalkulator-nedvizhimosti/?address=...&totalArea=...
+MFE: 'valuation-for-agent-frontend'
+Key: 'initialState'
+
+Auth: loads Cian session cookies from cian_session_cookies table (Stage 4).
+Cache: 24h TTL по sha256(address + params) in external_valuations table.
+
+State shape (sec 24.3–24.6 Schema_Cian_SERP_Inventory):
+ estimation.sale.data.{price, accuracy, priceFrom, priceTo, priceSqm}
+ estimation.rent.data.{price, accuracy, priceFrom, priceTo, taxPrice}
+ estimationChart.data.{title.{change, changeValue}, chartData.data[{date, price}]}
+ houseInfo.data.{items[{title, value}], isRenovation, isEmergency, isCulturalHeritage}
+ managementCompany.data.{name, address, phones, email, openingHours, chiefName}
+ filters.houseId — Cian's internal house_id
+
+Used as 6th evaluation source в estimator.py (Stage 9).
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+from dataclasses import dataclass, field
+from typing import Any
+from urllib.parse import urlencode
+
+from curl_cffi.requests import AsyncSession
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.services.cian_session import load_session, mark_session_invalid
+from app.services.scrapers.cian_state_parser import extract_state
+
+logger = logging.getLogger(__name__)
+
+VALUATION_BASE_URL = "https://www.cian.ru/kalkulator-nedvizhimosti/"
+VALUATION_MFE = "valuation-for-agent-frontend"
+
+# Cian floor enum: floor 1 → floorOne, floor 2 → floorTwo,
+# last floor → floorLast, все остальные → floorOther.
+_FLOOR_ENUM_SPECIAL = {1: "floorOne", 2: "floorTwo"}
+
+# Cian repair type enum
+_REPAIR_TYPE_MAP: dict[str, str] = {
+ "without": "repairTypeWithout",
+ "no": "repairTypeWithout",
+ "cosmetic": "repairTypeCosmetic",
+ "euro": "repairTypeEuro",
+ "design": "repairTypeDesign",
+}
+
+# Cian chart change direction → normalized storage values (per DDL: increase/decrease/neutral)
+_CHANGE_DIR_MAP: dict[str, str] = {
+ "increase": "increase",
+ "decrease": "decrease",
+ "neutral": "neutral",
+ "up": "increase",
+ "down": "decrease",
+}
+
+
+def _cian_floor_enum(floor: int, total_floors: int | None) -> str:
+ if floor in _FLOOR_ENUM_SPECIAL:
+ return _FLOOR_ENUM_SPECIAL[floor]
+ if total_floors is not None and floor == total_floors:
+ return "floorLast"
+ return "floorOther"
+
+
+@dataclass
+class CianValuationResult:
+ """Cian Valuation Calculator response parsed."""
+
+ # Sale estimate
+ sale_price_rub: float | None = None
+ sale_accuracy: float | None = None
+ sale_price_from: float | None = None
+ sale_price_to: float | None = None
+ sale_price_sqm: float | None = None
+
+ # Rent estimate (Cian-specific — both sale + rent per 1 request)
+ rent_price_rub: float | None = None
+ rent_accuracy: float | None = None
+ rent_price_from: float | None = None
+ rent_price_to: float | None = None
+ rent_tax_price: float | None = None
+
+ # Chart (7 monthly points for this specific apartment)
+ chart: list[dict[str, Any]] = field(default_factory=list)
+ chart_change_pct: float | None = None
+ chart_change_direction: str | None = None # increase/decrease/neutral
+
+ # House info (15 items: year, type, floors, etc.)
+ house_info: list[dict[str, Any]] = field(default_factory=list)
+ external_house_id: int | None = None # filters.houseId
+ filters_hash: str | None = None # estimation.sale.data.filtersHash
+
+ # Management company (unique to Cian Valuation)
+ management_company: dict[str, Any] | None = None
+
+ # Authentication state
+ is_authenticated: bool = False
+ user_id: int | None = None
+
+ # Raw payload (full state for raw_payload column)
+ raw_state: dict[str, Any] | None = None
+
+
+def compute_cache_key(
+ address: str,
+ total_area: float,
+ rooms_count: int,
+ floor: int,
+ repair_type: str,
+ deal_type: str,
+) -> str:
+ """sha256 of normalized params for 24h cache lookup."""
+ norm = (
+ f"{address.strip().lower()}|{total_area}|{rooms_count}|{floor}"
+ f"|{repair_type}|{deal_type}"
+ )
+ return hashlib.sha256(norm.encode("utf-8")).hexdigest()
+
+
+async def estimate_via_cian_valuation(
+ db: Session,
+ *,
+ address: str,
+ total_area: float,
+ rooms_count: int,
+ floor: int,
+ total_floors: int | None = None,
+ repair_type: str = "cosmetic",
+ deal_type: str = "sale",
+ use_cache: bool = True,
+ house_id: int | None = None,
+ listing_id: int | None = None,
+) -> CianValuationResult | None:
+ """Estimate value via Cian's authenticated Valuation Calculator.
+
+ Выполняет 1 GET на cian.ru/kalkulator-nedvizhimosti с cookies из DB,
+ парсит state['estimation'] + estimationChart + houseInfo + managementCompany.
+
+ Returns CianValuationResult, or None если cookies expired/invalid/fetch failed.
+ Graceful: не кидает исключений — caller может продолжить без этого source.
+ """
+ cache_key = compute_cache_key(address, total_area, rooms_count, floor, repair_type, deal_type)
+
+ # 1. Cache lookup (только если use_cache=True)
+ if use_cache:
+ cached = _load_from_cache(db, cache_key)
+ if cached is not None:
+ logger.info("Cian valuation cache HIT for cache_key=%s...", cache_key[:12])
+ return cached
+
+ # 2. Load auth cookies из cian_session_cookies
+ cookies = load_session(db)
+ if cookies is None:
+ logger.warning("Cian valuation: no auth session in DB — skipping (graceful)")
+ return None
+
+ # 3. Build URL с корректными enum values
+ params: dict[str, str] = {
+ "address": address,
+ "totalArea": str(total_area),
+ "roomsCount": str(rooms_count),
+ "valuationType": deal_type,
+ "floor[0]": _cian_floor_enum(floor, total_floors),
+ "repairType[0]": _REPAIR_TYPE_MAP.get(repair_type, "repairTypeCosmetic"),
+ }
+ url = f"{VALUATION_BASE_URL}?{urlencode(params, safe='[]')}"
+
+ # 4. Fetch с TLS fingerprint (curl_cffi, impersonate chrome120)
+ try:
+ async with AsyncSession(
+ impersonate="chrome120",
+ cookies=cookies,
+ timeout=25.0,
+ headers={"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
+ ) as session:
+ resp = await session.get(url, allow_redirects=True)
+ if resp.status_code != 200:
+ logger.warning("Cian valuation fetch → HTTP %d for %s", resp.status_code, url)
+ return None
+ html = resp.text
+ except Exception as exc:
+ logger.warning("Cian valuation fetch failed: %s", exc)
+ return None
+
+ # 5. Parse state из _cianConfig['valuation-for-agent-frontend']
+ state = extract_state(html, mfe=VALUATION_MFE, key="initialState")
+ if state is None:
+ logger.warning("Cian valuation: state extraction failed (mfe=%s)", VALUATION_MFE)
+ return None
+
+ # 6. Verify auth — если false → session expired
+ user = state.get("user", {}) or {}
+ if not user.get("isAuthenticated"):
+ user_id = user.get("userId")
+ logger.warning(
+ "Cian valuation: session not authenticated (userId=%s) — marking invalid", user_id
+ )
+ if user_id:
+ mark_session_invalid(db, user_id)
+ return None
+
+ # 7. Extract structured result
+ result = _parse_valuation_state(state)
+
+ # 8. Persist to cache (24h TTL)
+ _save_to_cache(
+ db,
+ cache_key=cache_key,
+ address=address,
+ total_area=total_area,
+ rooms_count=rooms_count,
+ floor=floor,
+ total_floors=total_floors,
+ repair_type=repair_type,
+ deal_type=deal_type,
+ result=result,
+ house_id=house_id,
+ listing_id=listing_id,
+ )
+
+ logger.info(
+ "Cian valuation extracted: sale=%s (acc=%s%%) rent=%s",
+ result.sale_price_rub,
+ result.sale_accuracy,
+ result.rent_price_rub,
+ )
+ return result
+
+
+def _parse_valuation_state(state: dict[str, Any]) -> CianValuationResult:
+ """Extract CianValuationResult from Cian valuation initialState.
+
+ Mapping (per Schema_Cian_SERP_Inventory sec 24.4–24.6):
+ estimation.sale.data.{price, accuracy, priceFrom, priceTo, priceSqm, filtersHash}
+ estimation.rent.data.{price, accuracy, priceFrom, priceTo, taxPrice}
+ estimationChart.data.{title.{change, changeValue}, chartData.data[]}
+ houseInfo.data.{items[], ...}
+ filters.houseId
+ managementCompany.data
+ """
+ result = CianValuationResult(raw_state=state)
+
+ user = state.get("user") or {}
+ result.is_authenticated = bool(user.get("isAuthenticated"))
+ result.user_id = user.get("userId")
+
+ # --- estimation: sale ---
+ estimation = state.get("estimation") or {}
+ sale_wrapper = estimation.get("sale") or {}
+ sale_data = sale_wrapper.get("data") or {}
+ result.sale_price_rub = _parse_num(sale_data.get("price"))
+ result.sale_accuracy = _parse_num(sale_data.get("accuracy"))
+ result.sale_price_from = _parse_num(sale_data.get("priceFrom"))
+ result.sale_price_to = _parse_num(sale_data.get("priceTo"))
+ result.sale_price_sqm = _parse_num(sale_data.get("priceSqm"))
+ result.filters_hash = sale_data.get("filtersHash")
+
+ # --- estimation: rent ---
+ rent_wrapper = estimation.get("rent") or {}
+ rent_data = rent_wrapper.get("data") or {}
+ result.rent_price_rub = _parse_num(rent_data.get("price"))
+ result.rent_accuracy = _parse_num(rent_data.get("accuracy"))
+ result.rent_price_from = _parse_num(rent_data.get("priceFrom"))
+ result.rent_price_to = _parse_num(rent_data.get("priceTo"))
+ result.rent_tax_price = _parse_num(rent_data.get("taxPrice"))
+
+ # --- estimationChart: 7 monthly time-series points ---
+ est_chart = state.get("estimationChart") or {}
+ chart_outer = est_chart.get("data") or {}
+ chart_title = chart_outer.get("title") or {}
+
+ # changeValue: "8,3%" → parse out float
+ change_value_str = chart_title.get("changeValue") or ""
+ result.chart_change_pct = _parse_change_pct(change_value_str)
+
+ # change direction: increase / decrease / neutral
+ change_dir_raw = chart_title.get("change")
+ if change_dir_raw:
+ result.chart_change_direction = _CHANGE_DIR_MAP.get(change_dir_raw, change_dir_raw)
+
+ chart_data_wrapper = chart_outer.get("chartData") or {}
+ chart_points = chart_data_wrapper.get("data") or []
+ if isinstance(chart_points, list):
+ for entry in chart_points:
+ if not isinstance(entry, dict):
+ continue
+ # date: unix_ms → ISO string "YYYY-MM-DD"
+ date_ms = entry.get("date")
+ date_str: str | None = None
+ if isinstance(date_ms, (int, float)) and date_ms > 0:
+ import datetime
+ date_str = datetime.datetime.fromtimestamp(
+ date_ms / 1000, tz=datetime.UTC
+ ).strftime("%Y-%m-%d")
+ result.chart.append({
+ "month_date": date_str or "",
+ "price": _parse_num(entry.get("price")),
+ "price_formatted": entry.get("priceFormatted"),
+ })
+
+ # --- houseInfo: 15 items о доме ---
+ house_info_wrapper = state.get("houseInfo") or {}
+ house_info_data = house_info_wrapper.get("data") or {}
+ items = house_info_data.get("items")
+ if isinstance(items, list):
+ result.house_info = [i for i in items if isinstance(i, dict)]
+
+ # --- filters.houseId: Cian internal house_id ---
+ filters = state.get("filters") or {}
+ result.external_house_id = filters.get("houseId")
+
+ # --- managementCompany ---
+ mc_wrapper = state.get("managementCompany") or {}
+ mc_data = mc_wrapper.get("data")
+ if isinstance(mc_data, dict) and mc_data:
+ result.management_company = mc_data
+
+ return result
+
+
+def _parse_num(value: Any) -> float | None:
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except (ValueError, TypeError):
+ return None
+
+
+def _parse_change_pct(value_str: str) -> float | None:
+ """Parse '8,3%' → 8.3, '-2,5%' → -2.5, '' → None."""
+ if not value_str:
+ return None
+ cleaned = value_str.replace(",", ".").replace("%", "").strip()
+ try:
+ return float(cleaned)
+ except ValueError:
+ return None
+
+
+def _load_from_cache(db: Session, cache_key: str) -> CianValuationResult | None:
+ """SELECT from external_valuations where cache_key + source='cian_valuation' + not expired."""
+ row = db.execute(
+ text("""
+ SELECT
+ raw_payload,
+ sale_price_rub,
+ sale_accuracy,
+ rent_price_rub,
+ rent_accuracy,
+ chart,
+ chart_change_pct,
+ chart_change_direction,
+ external_house_id,
+ filters_hash,
+ low_price,
+ high_price
+ FROM external_valuations
+ WHERE source = 'cian_valuation'
+ AND cache_key = :ck
+ AND expires_at > NOW()
+ ORDER BY fetched_at DESC
+ LIMIT 1
+ """),
+ {"ck": cache_key},
+ ).mappings().first()
+
+ if row is None:
+ return None
+
+ return CianValuationResult(
+ sale_price_rub=_parse_num(row["sale_price_rub"]),
+ sale_accuracy=_parse_num(row["sale_accuracy"]),
+ rent_price_rub=_parse_num(row["rent_price_rub"]),
+ rent_accuracy=_parse_num(row["rent_accuracy"]),
+ chart=row["chart"] if isinstance(row["chart"], list) else [],
+ chart_change_pct=_parse_num(row["chart_change_pct"]),
+ chart_change_direction=row["chart_change_direction"],
+ external_house_id=row["external_house_id"],
+ filters_hash=row["filters_hash"],
+ sale_price_from=_parse_num(row["low_price"]),
+ sale_price_to=_parse_num(row["high_price"]),
+ raw_state=row["raw_payload"] if isinstance(row["raw_payload"], dict) else None,
+ is_authenticated=True, # cache только authenticated results
+ )
+
+
+def _save_to_cache(
+ db: Session,
+ *,
+ cache_key: str,
+ address: str,
+ total_area: float,
+ rooms_count: int,
+ floor: int,
+ total_floors: int | None,
+ repair_type: str,
+ deal_type: str,
+ result: CianValuationResult,
+ house_id: int | None = None,
+ listing_id: int | None = None,
+) -> None:
+ """INSERT result into external_valuations (source='cian_valuation') с 24h expiry.
+
+ Использует ON CONFLICT (source, cache_key) DO UPDATE для идемпотентности.
+ Все числовые поля через CAST(:x AS numeric) — psycopg v3 safe, без ::type.
+ """
+ try:
+ db.execute(
+ text("""
+ INSERT INTO external_valuations (
+ source, cache_key, address,
+ total_area, rooms_count, floor, total_floors,
+ repair_type, deal_type,
+ price_rub, accuracy,
+ low_price, high_price,
+ house_id, listing_id,
+ sale_price_rub, sale_accuracy,
+ rent_price_rub, rent_accuracy,
+ chart, chart_change_pct, chart_change_direction,
+ external_house_id, filters_hash,
+ raw_payload, fetched_at, expires_at
+ ) VALUES (
+ 'cian_valuation', :ck, :addr,
+ CAST(:ta AS numeric), :rc, :fl, :tf,
+ :rt, :dt,
+ CAST(:sp AS numeric), CAST(:sa AS numeric),
+ CAST(:lp AS numeric), CAST(:hp AS numeric),
+ CAST(:hid AS bigint), CAST(:lid AS bigint),
+ CAST(:sp AS numeric), CAST(:sa AS numeric),
+ CAST(:rp AS numeric), CAST(:ra AS numeric),
+ CAST(:ch AS jsonb), CAST(:ccp AS numeric), :ccd,
+ :ehi, :fh,
+ CAST(:raw AS jsonb), NOW(), NOW() + INTERVAL '24 hours'
+ )
+ ON CONFLICT (source, cache_key) DO UPDATE SET
+ price_rub = EXCLUDED.price_rub,
+ accuracy = EXCLUDED.accuracy,
+ low_price = EXCLUDED.low_price,
+ high_price = EXCLUDED.high_price,
+ house_id = COALESCE(EXCLUDED.house_id, external_valuations.house_id),
+ listing_id = COALESCE(EXCLUDED.listing_id, external_valuations.listing_id),
+ sale_price_rub = EXCLUDED.sale_price_rub,
+ sale_accuracy = EXCLUDED.sale_accuracy,
+ rent_price_rub = EXCLUDED.rent_price_rub,
+ rent_accuracy = EXCLUDED.rent_accuracy,
+ chart = EXCLUDED.chart,
+ chart_change_pct = EXCLUDED.chart_change_pct,
+ chart_change_direction = EXCLUDED.chart_change_direction,
+ external_house_id = EXCLUDED.external_house_id,
+ filters_hash = EXCLUDED.filters_hash,
+ raw_payload = EXCLUDED.raw_payload,
+ fetched_at = NOW(),
+ expires_at = NOW() + INTERVAL '24 hours'
+ """),
+ {
+ "ck": cache_key,
+ "addr": address,
+ "ta": total_area,
+ "rc": rooms_count,
+ "fl": floor,
+ "tf": total_floors,
+ "rt": repair_type,
+ "dt": deal_type,
+ "sp": result.sale_price_rub,
+ "sa": result.sale_accuracy,
+ "lp": result.sale_price_from,
+ "hp": result.sale_price_to,
+ "hid": house_id,
+ "lid": listing_id,
+ "rp": result.rent_price_rub,
+ "ra": result.rent_accuracy,
+ "ch": json.dumps(result.chart),
+ "ccp": result.chart_change_pct,
+ "ccd": result.chart_change_direction,
+ "ehi": result.external_house_id,
+ "fh": result.filters_hash,
+ "raw": json.dumps(result.raw_state) if result.raw_state else None,
+ },
+ )
+ db.commit()
+ except Exception as exc:
+ logger.error("Cian valuation cache save failed: %s", exc, exc_info=True)
+ raise
diff --git a/tradein-mvp/backend/app/services/scrapers/n1.py b/tradein-mvp/backend/app/services/scrapers/n1.py
new file mode 100644
index 00000000..907756f6
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/n1.py
@@ -0,0 +1,232 @@
+"""N1.ru scraper — вторичка ЕКБ (ekaterinburg.n1.ru).
+
+N1.ru — региональный портал недвижимости (хорошо представлен на Урале).
+SSR HTML с DOM-карточками `data-test="offers-list-item"`.
+
+Использует curl_cffi (impersonate=chrome120) — как Cian/Avito.
+
+Карточка:
+ - a[href^="/view/NNN/"] — ссылка на объявление + offer_id
+ текст ссылки = "3-к, Репина, 75/2 стр." (комнаты + адрес)
+ - [data-test="offers-list-item-param-total-area"] — "79 м2"
+ - [data-test="offers-list-item-param-price"] — "15 700 000"
+ - img.src — фото
+
+Координаты карточки не отдаёт → оставляем lat/lon = None,
+geocode-missing проставит реальные по адресу.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Any
+
+from curl_cffi.requests import AsyncSession
+from selectolax.parser import HTMLParser
+
+from app.services.scraper_settings import get_scraper_delay
+from app.services.scrapers.base import BaseScraper, ScrapedLot
+
+logger = logging.getLogger(__name__)
+
+
+class N1Scraper(BaseScraper):
+ """N1.ru vtorichka parser. Источник = 'n1'."""
+
+ name = "n1"
+ base_url = "https://ekaterinburg.n1.ru"
+ # Класс-дефолт; реальное значение загружается из scraper_settings при создании экземпляра.
+ request_delay_sec = 5.0
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.request_delay_sec = get_scraper_delay(self.name)
+ self._cffi: AsyncSession | None = None
+
+ async def __aenter__(self) -> N1Scraper:
+ await super().__aenter__()
+ self._cffi = AsyncSession(
+ impersonate="chrome120",
+ timeout=25,
+ headers={
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "none",
+ "Upgrade-Insecure-Requests": "1",
+ },
+ )
+ return self
+
+ async def __aexit__(self, *args: Any) -> None:
+ if self._cffi is not None:
+ await self._cffi.close()
+ await super().__aexit__(*args)
+
+ async def fetch_around(
+ self, lat: float, lon: float, radius_m: int = 1000,
+ rooms: int | None = None, page: int = 1,
+ ) -> list[ScrapedLot]:
+ self._anchor_lat = lat
+ self._anchor_lon = lon
+ self._anchor_radius_m = radius_m
+
+ url = self._build_url(rooms, page)
+ try:
+ assert self._cffi is not None
+ response = await self._cffi.get(url)
+ except Exception:
+ logger.exception("n1 fetch failed for %s", url)
+ return []
+ if response.status_code != 200:
+ logger.warning("n1 returned %d for %s", response.status_code, url)
+ return []
+
+ lots = self._parse_html(response.text)
+ logger.info(
+ "n1: %d lots around (%.4f, %.4f) rooms=%s page=%d",
+ len(lots), lat, lon, rooms, page,
+ )
+ await self.sleep_between_requests()
+ return lots
+
+ async def fetch_around_multi_room(
+ self, lat: float, lon: float, radius_m: int = 1000, pages: int = 8,
+ ) -> list[ScrapedLot]:
+ """Скрейп N1 по сегментам комнат 1/2/3/4 × N страниц — расширяет выборку."""
+ seen: dict[str, ScrapedLot] = {}
+ for rooms in (1, 2, 3, 4):
+ for page in range(1, pages + 1):
+ try:
+ lots = await self.fetch_around(lat, lon, radius_m, rooms=rooms, page=page)
+ except Exception:
+ logger.exception(
+ "n1 multi-room fetch failed rooms=%s page=%d", rooms, page
+ )
+ continue
+ if not lots:
+ break # пустая страница → дальше нет смысла
+ for lot in lots:
+ key = lot.source_id or lot.source_url
+ if key and key not in seen:
+ seen[key] = lot
+ logger.info(
+ "n1 multi-room: %d unique lots around (%.4f, %.4f)", len(seen), lat, lon
+ )
+ return list(seen.values())
+
+ def _build_url(self, rooms: int | None = None, page: int = 1) -> str:
+ """URL каталога вторички ЕКБ.
+
+ N1 фильтр комнат: /kupit/kvartiry/vtorichka/1-komnatnye/ итд.
+ Пагинация: ?page=N.
+ """
+ rooms_segment = {
+ 1: "1-komnatnye/",
+ 2: "2-komnatnye/",
+ 3: "3-komnatnye/",
+ 4: "4-komnatnye/",
+ }.get(rooms or 0, "")
+ url = f"{self.base_url}/kupit/kvartiry/vtorichka/{rooms_segment}"
+ if page > 1:
+ url += f"?page={page}"
+ return url
+
+ def _parse_html(self, html: str) -> list[ScrapedLot]:
+ tree = HTMLParser(html)
+ cards = tree.css('[data-test="offers-list-item"]')
+ return [
+ lot for lot in (self._card_to_lot(c) for c in cards) if lot is not None
+ ]
+
+ def _card_to_lot(self, card: Any) -> ScrapedLot | None:
+ try:
+ # Ссылка на объявление /view/NNN/
+ link = card.css_first('a[href*="/view/"]')
+ if link is None:
+ return None
+ href = link.attributes.get("href", "")
+ m_id = re.search(r"/view/(\d+)/", href)
+ offer_id = m_id.group(1) if m_id else None
+ url = href if href.startswith("http") else f"{self.base_url}{href}"
+
+ # Заголовок ссылки: "3-к, Репина, 75/2 стр."
+ title = link.text(strip=True) or ""
+ rooms = _extract_rooms(title)
+ # Адрес = title без префикса комнат
+ address = re.sub(
+ r"^\s*(\d+-к|студи[яи])\s*,?\s*", "", title, flags=re.IGNORECASE
+ ).strip()
+ if not address:
+ address = "Екатеринбург (N1)"
+
+ # Площадь
+ area_el = card.css_first('[data-test="offers-list-item-param-total-area"]')
+ area = _extract_area(area_el.text(strip=True) if area_el else "")
+
+ # Цена
+ price_el = card.css_first('[data-test="offers-list-item-param-price"]')
+ price = _extract_price(price_el.text(strip=True) if price_el else "")
+ if not price:
+ return None
+
+ # Координаты НЕ ставим — N1 card не отдаёт. Оставляем None,
+ # geocode-missing проставит реальные по address (N1 отдаёт всё ЕКБ,
+ # jitter вокруг якоря был бы фейком).
+ lat = lon = None
+
+ # Фото
+ photos: list[str] = []
+ for img in card.css("img")[:5]:
+ src = img.attributes.get("src", "")
+ if src and src.startswith("http") and src not in photos:
+ photos.append(src)
+
+ return ScrapedLot(
+ source="n1",
+ source_url=url,
+ source_id=offer_id,
+ address=address,
+ lat=lat,
+ lon=lon,
+ rooms=rooms,
+ area_m2=area,
+ price_rub=price,
+ photo_urls=photos,
+ raw_payload={"title": title},
+ )
+ except Exception:
+ logger.exception("n1 card parse failed")
+ return None
+
+
+# ── Helpers ─────────────────────────────────────────────────────────────────
+
+_RE_ROOMS = re.compile(r"(\d+)\s*-?\s*к", re.IGNORECASE)
+_RE_STUDIO = re.compile(r"студи", re.IGNORECASE)
+_RE_AREA = re.compile(r"([\d]+(?:[.,]\d+)?)")
+_RE_DIGITS = re.compile(r"\d")
+
+
+def _extract_rooms(text: str) -> int | None:
+ if _RE_STUDIO.search(text):
+ return 0
+ m = _RE_ROOMS.search(text)
+ return int(m.group(1)) if m else None
+
+
+def _extract_area(text: str) -> float | None:
+ m = _RE_AREA.search(text)
+ if m:
+ try:
+ return float(m.group(1).replace(",", "."))
+ except ValueError:
+ return None
+ return None
+
+
+def _extract_price(text: str) -> int | None:
+ digits = "".join(_RE_DIGITS.findall(text))
+ return int(digits) if digits else None
diff --git a/tradein-mvp/backend/app/services/scrapers/yandex_detail.py b/tradein-mvp/backend/app/services/scrapers/yandex_detail.py
new file mode 100644
index 00000000..0d4c6101
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/yandex_detail.py
@@ -0,0 +1,374 @@
+"""Yandex Realty detail page scraper.
+
+Fetches /offer// and extracts Product JSON-LD + DOM sections into
+a DetailEnrichment Pydantic model. Used by enrichment pipeline
+(Wave 5+ matching / Wave 6 estimator).
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from datetime import date
+from typing import Any
+
+from pydantic import BaseModel, Field
+from selectolax.parser import HTMLParser, Node
+
+from app.services.scraper_settings import get_scraper_delay
+from app.services.scrapers.base import BaseScraper
+from app.services.scrapers.yandex_helpers import (
+ RE_AGENCY_FOUNDED,
+ RE_AGENCY_OBJECTS,
+ RE_METRO_WALK,
+ RE_VIEWS,
+ RE_YEAR,
+ find_ld_by_type,
+ parse_house_type,
+ parse_ru_date,
+ parse_rub,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# ── Pydantic models ───────────────────────────────────────────────────────────
+
+
+class MetroStation(BaseModel):
+ name: str
+ walk_min: int | None = None
+
+
+class DetailEnrichment(BaseModel):
+ """Enrichment payload from a Yandex detail page."""
+
+ offer_id: str
+ source_url: str
+
+ # Pricing — Product JSON-LD `offers.price` is the exact int
+ price_rub: int | None = None
+ price_per_m2: int | None = None
+
+ # Title + basic params
+ title: str | None = None
+ rooms: int | None = None
+ area_m2: float | None = None
+ floor: int | None = None
+ total_floors: int | None = None
+
+ # Address (full)
+ address: str | None = None
+
+ # Description (full text)
+ description: str | None = None
+
+ # Sale type — raw RU phrase ('свободная продажа' / 'альтернативная')
+ sale_type_text: str | None = None
+
+ # Stats
+ views_total: int | None = None
+ publish_date: date | None = None
+ publish_date_relative: str | None = None
+
+ # Agency block (OfferCardAuthorInfo)
+ agency_name: str | None = None
+ agency_founded_year: int | None = None
+ agency_objects_count: int | None = None
+ seller_name: str | None = None # last text line before "Агентство «...»"
+
+ # Metro stations from "Расположение" section
+ metro_stations: list[MetroStation] = Field(default_factory=list)
+
+ # Photos — 8 sizes from Product.image[]
+ photo_urls: list[str] = Field(default_factory=list)
+
+ # Newbuilding linkage
+ newbuilding_url: str | None = None
+ newbuilding_id: str | None = None
+
+ # NLP from description (best-effort)
+ house_type_nlp: str | None = None
+ year_built_hint: int | None = None
+ metro_walk_min: int | None = None
+
+ # Raw payload (trimmed)
+ raw_payload: dict[str, Any] | None = None
+
+
+# ── Scraper ───────────────────────────────────────────────────────────────────
+
+
+class YandexDetailScraper(BaseScraper):
+ """Detail page scraper for realty.yandex.ru."""
+
+ name = "yandex_detail"
+ base_url = "https://realty.yandex.ru"
+ request_delay_sec = 5.0 # class default; instance value loaded from scraper_settings
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.request_delay_sec = get_scraper_delay(self.name)
+
+ # BaseScraper requires fetch_around — detail isn't geo-based, raise NotImplementedError
+ async def fetch_around(
+ self, lat: float, lon: float, radius_m: int = 1000
+ ) -> list: # type: ignore[override]
+ raise NotImplementedError(
+ "YandexDetailScraper is offer-id-based; use fetch_detail(offer_url) instead."
+ )
+
+ async def fetch_detail(self, offer_url: str) -> DetailEnrichment | None:
+ try:
+ response = await self._http_get(offer_url)
+ except Exception:
+ logger.exception("yandex detail fetch failed: %s", offer_url)
+ return None
+ if response.status_code != 200:
+ logger.warning(
+ "yandex detail returned %d for %s", response.status_code, offer_url
+ )
+ return None
+ result = self.parse(response.text, offer_url=offer_url)
+ await self.sleep_between_requests()
+ return result
+
+ def parse(self, html: str, offer_url: str) -> DetailEnrichment | None:
+ offer_id_match = re.search(r"/offer/(\d+)/?", offer_url)
+ if not offer_id_match:
+ logger.warning("offer_url has no /offer//: %s", offer_url)
+ return None
+ offer_id = offer_id_match.group(1)
+
+ tree = HTMLParser(html)
+
+ # --- Product JSON-LD (authoritative price) ---
+ product = find_ld_by_type(html, "Product") or {}
+ offers_ld = product.get("offers") or {}
+ if isinstance(offers_ld, list) and offers_ld:
+ offers_ld = offers_ld[0]
+ price_ld = offers_ld.get("price") if isinstance(offers_ld, dict) else None
+ try:
+ price_rub = int(price_ld) if price_ld else None
+ except (TypeError, ValueError):
+ price_rub = None
+
+ # Photos from JSON-LD image[] (typically 8 size variants)
+ images = product.get("image") or []
+ if isinstance(images, str):
+ images = [images]
+ photo_urls = [u for u in images if isinstance(u, str)]
+
+ # --- Title + summary ---
+ title_node = tree.css_first("h1")
+ title = title_node.text(strip=True) if title_node else None
+
+ rooms, area_m2, floor, total_floors = _parse_title(title or "")
+
+ # --- OfferCardSummary text block ---
+ summary_node = tree.css_first('[data-test="OfferCardSummary"]')
+ summary_text = summary_node.text(strip=True) if summary_node else ""
+
+ # Sale type — raw RU phrase
+ sale_type_text: str | None = None
+ for phrase in ("свободная продажа", "альтернативная"):
+ if phrase in summary_text.lower():
+ sale_type_text = phrase
+ break
+
+ # Views + relative publish date from summary text
+ views_match = RE_VIEWS.search(summary_text)
+ views_total = int(views_match.group(1)) if views_match else None
+ publish_date = parse_ru_date(summary_text)
+ publish_date_relative = _extract_relative_date(summary_text)
+
+ # price_per_m2 — from summary text if absent in LD
+ price_per_m2: int | None = None
+ ppm2_match = re.search(r"(\d[\d\s]+)\s*₽\s*за\s*м²", summary_text)
+ if ppm2_match:
+ price_per_m2 = parse_rub(ppm2_match.group(1))
+
+ # --- OfferCardAuthorInfo (agency block) ---
+ author_node = tree.css_first('[data-test="OfferCardAuthorInfo"]')
+ agency_name: str | None = None
+ agency_founded_year: int | None = None
+ agency_objects_count: int | None = None
+ seller_name: str | None = None
+ if author_node is not None:
+ author_text = author_node.text(strip=True)
+ agency_h2 = author_node.css_first("h2")
+ agency_name = agency_h2.text(strip=True) if agency_h2 else None
+ founded_m = RE_AGENCY_FOUNDED.search(author_text)
+ if founded_m:
+ agency_founded_year = int(founded_m.group(1))
+ objects_m = RE_AGENCY_OBJECTS.search(author_text)
+ if objects_m:
+ agency_objects_count = int(objects_m.group(1))
+ # seller_name — last text line before "Агентство"
+ seller_name = _extract_seller_name(summary_text, agency_name)
+
+ # --- Description section (after H2 "Описание") ---
+ description = _find_section_text(tree, "Описание")
+
+ # --- Address ---
+ address = _extract_address(summary_text)
+
+ # --- Metro stations from "Расположение" section ---
+ location_text = _find_section_text(tree, "Расположение") or ""
+ metro_stations = _parse_metro_stations(location_text)
+
+ # --- Newbuilding link ---
+ nb_url: str | None = None
+ nb_id: str | None = None
+ nb_link = tree.css_first('a[href*="/kupit/novostrojka/"]')
+ if nb_link is not None:
+ nb_href = nb_link.attributes.get("href", "")
+ nb_match = re.search(r"/novostrojka/[\w-]+?-(\d+)/?", nb_href)
+ if nb_match:
+ nb_id = nb_match.group(1)
+ nb_url = (
+ nb_href
+ if nb_href.startswith("http")
+ else f"https://realty.yandex.ru{nb_href}"
+ )
+
+ # --- NLP best-effort from description ---
+ nlp_text = description or summary_text
+ house_type_nlp = parse_house_type(nlp_text)
+ year_hint_m = RE_YEAR.search(nlp_text or "")
+ year_built_hint = int(year_hint_m.group(1)) if year_hint_m else None
+ walk_m = RE_METRO_WALK.search(nlp_text or "")
+ metro_walk_min = int(walk_m.group(1)) if walk_m else None
+
+ return DetailEnrichment(
+ offer_id=offer_id,
+ source_url=offer_url,
+ price_rub=price_rub,
+ price_per_m2=price_per_m2,
+ title=title,
+ rooms=rooms,
+ area_m2=area_m2,
+ floor=floor,
+ total_floors=total_floors,
+ address=address,
+ description=description,
+ sale_type_text=sale_type_text,
+ views_total=views_total,
+ publish_date=publish_date,
+ publish_date_relative=publish_date_relative,
+ agency_name=agency_name,
+ agency_founded_year=agency_founded_year,
+ agency_objects_count=agency_objects_count,
+ seller_name=seller_name,
+ metro_stations=metro_stations,
+ photo_urls=photo_urls,
+ newbuilding_url=nb_url,
+ newbuilding_id=nb_id,
+ house_type_nlp=house_type_nlp,
+ year_built_hint=year_built_hint,
+ metro_walk_min=metro_walk_min,
+ raw_payload={
+ "summary_text": summary_text[:1000],
+ "description_len": len(description) if description else 0,
+ "photo_count": len(photo_urls),
+ },
+ )
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+
+def _parse_title(title: str) -> tuple[int | None, float | None, int | None, int | None]:
+ """Extract (rooms, area_m2, floor, total_floors) from h1 text."""
+ rooms: int | None = None
+ area_m2: float | None = None
+ floor: int | None = None
+ total_floors: int | None = None
+
+ area_m = re.search(r"(\d+[.,]?\d*)\s*м²", title)
+ if area_m:
+ area_m2 = float(area_m.group(1).replace(",", "."))
+
+ if re.search(r"студи[яюй]", title, re.IGNORECASE):
+ rooms = 0
+ else:
+ rooms_m = re.search(r"(\d+)\s*-?\s*комнатн", title, re.IGNORECASE)
+ if rooms_m:
+ try:
+ rooms = int(rooms_m.group(1))
+ except ValueError:
+ pass
+
+ floor_m = re.search(r"(\d+)\s+этаж\s+из\s+(\d+)", title, re.IGNORECASE)
+ if floor_m:
+ floor = int(floor_m.group(1))
+ total_floors = int(floor_m.group(2))
+
+ return rooms, area_m2, floor, total_floors
+
+
+def _find_section_text(tree: HTMLParser, heading: str) -> str | None:
+ """Find the text content of a / whose preceding h2/h3 matches heading.
+
+ Yandex page structure varies; this scans h2/h3 nodes, then returns the
+ concatenated text of subsequent sibling blocks until the next heading.
+ """
+ for h in tree.css("h2, h3"):
+ if heading.lower() in (h.text(strip=True) or "").lower():
+ # collect subsequent siblings until the next h2/h3
+ parts: list[str] = []
+ node: Node | None = h.next
+ while node is not None:
+ tag = (node.tag or "").lower()
+ if tag in {"h2", "h3"}:
+ break
+ txt = node.text(strip=True) if hasattr(node, "text") else ""
+ if txt:
+ parts.append(txt)
+ node = node.next
+ return " ".join(parts).strip() or None
+ return None
+
+
+def _extract_address(summary_text: str) -> str | None:
+ """Best-effort address extraction from summary block."""
+ # Pattern: "Россия, Свердловская область, Екатеринбург, улица Х, д. N"
+ m = re.search(r"(Россия[^•]+?)(?:•|\d+\s+просмотр|$)", summary_text)
+ if m:
+ addr = m.group(1).strip().rstrip(",").strip()
+ return addr if len(addr) > 10 else None
+ return None
+
+
+def _parse_metro_stations(location_text: str) -> list[MetroStation]:
+ """Parse 'Уральская 11 мин. Динамо 16 мин.' → list of MetroStation."""
+ stations: list[MetroStation] = []
+ # name (1+ Cyrillic words) + space + N + space + мин(.|у|ут)
+ for m in re.finditer(r"([А-ЯЁ][А-Яа-яё\s-]+?)\s+(\d+)\s*мин", location_text):
+ name = m.group(1).strip()
+ if 2 <= len(name) <= 40:
+ stations.append(MetroStation(name=name, walk_min=int(m.group(2))))
+ if len(stations) >= 5:
+ break
+ return stations
+
+
+def _extract_relative_date(summary_text: str) -> str | None:
+ """Capture phrases like '6 часов назад' / 'вчера' / '3 дня назад'."""
+ m = re.search(
+ r"(\d+\s+(?:минут|час|часов|часа|день|дня|дней|недел[ьюи])\s+назад"
+ r"|вчера|сегодня|позавчера)",
+ summary_text,
+ re.IGNORECASE,
+ )
+ return m.group(1).strip() if m else None
+
+
+def _extract_seller_name(summary_text: str, agency_name: str | None) -> str | None:
+ """Heuristic: line right before 'Агентство ...' in summary text."""
+ if not agency_name or agency_name not in summary_text:
+ return None
+ head = summary_text.split(agency_name, 1)[0]
+ # last short token sequence (likely "Имя Фамилия")
+ m = re.findall(r"([А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+){1,2})", head)
+ return m[-1] if m else None
diff --git a/tradein-mvp/backend/app/services/scrapers/yandex_helpers.py b/tradein-mvp/backend/app/services/scrapers/yandex_helpers.py
new file mode 100644
index 00000000..a33d4501
--- /dev/null
+++ b/tradein-mvp/backend/app/services/scrapers/yandex_helpers.py
@@ -0,0 +1,355 @@
+"""Yandex Realty parsing helpers — JSON-LD + regex + DOM utilities for SERP, detail, newbuilding,
+and valuation parsers."""
+
+from __future__ import annotations
+
+import json
+import re
+from datetime import date, timedelta
+from typing import Any
+
+from selectolax.parser import HTMLParser
+
+__all__ = [
+ "RE_AGENCY_FOUNDED",
+ "RE_AGENCY_OBJECTS",
+ "RE_FLOOR",
+ "RE_JK_ID",
+ "RE_METRO_WALK",
+ "RE_OFFER_ID",
+ "RE_PPM2",
+ "RE_PRICE",
+ "RE_STREET_ID",
+ "RE_TITLE_AREA",
+ "RE_TITLE_ROOMS",
+ "RE_VIEWS",
+ "RE_YEAR",
+ "RU_MONTHS",
+ "RU_MONTH_NOMINATIVE",
+ "extract_json_ld",
+ "find_ld_by_type",
+ "parse_dmy",
+ "parse_house_class",
+ "parse_house_type",
+ "parse_listing_date",
+ "parse_ru_date",
+ "parse_rub",
+]
+
+# ---------------------------------------------------------------------------
+# Section 1 — JSON-LD extraction
+# ---------------------------------------------------------------------------
+
+
+def extract_json_ld(html: str) -> list[dict[str, Any]]:
+ """Extract all
+ | |