add anal 2
This commit is contained in:
parent
c13cbaef2e
commit
16481868a6
18 changed files with 2493 additions and 23 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -13,6 +13,7 @@ node_modules/
|
|||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.mcp.json
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
|
|
|||
258
README.md
258
README.md
|
|
@ -10,7 +10,7 @@ Acceptance criteria разбиты на 3 блока (функционально
|
|||
|
||||
---
|
||||
|
||||
## Текущий статус (26 апреля 2026)
|
||||
## Текущий статус (27 апреля 2026)
|
||||
|
||||
**Инфраструктура: live на HTTPS.** Прод доступен по [https://gendsgn.ru/](https://gendsgn.ru/), Let's Encrypt cert получен. Полный цикл автодеплоя из GitHub Actions работает. 26.04 прод полностью пересоздан (`down -v` + redeploy) — БД свежая, backend в multi-stage образе под non-root пользователем, healthcheck-и работают, ловит ошибки в Sentry.
|
||||
|
||||
|
|
@ -46,6 +46,251 @@ Acceptance criteria разбиты на 3 блока (функционально
|
|||
2. **ДОМ.РФ фронт-API** (`DataSources_Analysis_Apr26` + 12 вкладок) — открытые JSON без auth (либо `Basic MTpqd2U=` хардкоден), per-flat прайс-лист, реализация/готовность как velocity.
|
||||
3. **НСПД MarketAnalytics** (`NSPD_MarketAnalytics_FullArchitecture_Apr26`) — индикаторы цен по кадастровым кварталам, 8 лет longitudinal, GeoJSON-полигоны для heatmap. Архитектура полностью раскрыта в debug-браузере (saveUserFilters → graphEgrn*/tableEgrn/geomDataForHeatMap, серверная сессия фильтров через userFiltersId). ⚠ `graphEgrnCandleMedian`/`LineMedian` (абсолютные ₽/м²) дают 403 для physic-аккаунта — но эти данные мы уже получаем из CSV-датасета Росреестра.
|
||||
|
||||
**27.04 — analytical layer полностью построен.** В прод-БД 43 `domrf_*/cbr_*/yandex_*/ekb_*/sv_*/rosreestr_*` таблицы (см. блок «Аналитический пласт данных» ниже). За день:
|
||||
- Загружено 5 lazy-load страниц ДОМ.РФ (`DomRF_Lazy_Loaded_Apr27_2026`) — `domrf_realization` 9.7K + stat_series XLSX 809K observations + 333 raw JSONB endpoints.
|
||||
- Догружена история 14 месяцев (Янв 2025 - Февр 2026) для realization + sold_ready через `repYear/repMonth` параметры (`DomRF_History_Loaded_Apr27_2026`) — `domrf_realization` итого **143 963** рядов, sold_ready_breakdown расширен до 7 chart-types с типизированными колонками.
|
||||
- Импортирован SQLite Антона (23 МБ) — 13 новых таблиц: `domrf_kn_flats` (27 563 поквартирно), `domrf_kn_objects` (1 516 ЖК), `cbr_mortgage_series` (3 308 ЦБ), `sber_ddf_metrics`, `yandex_realty_zk`, `ekb_districts`, `rosreestr_rent_deals`. Архив девелоперов смерджен (2 734 → 8 629). См. `Anton_Imported_Apr27_2026`.
|
||||
- Подключён MCP `crystaldba/postgres-mcp` (`.mcp.json`) — все DDL/SELECT теперь через `mcp__postgres__execute_sql` без `docker run psql` обёртки.
|
||||
- FK-аудит и cleanup: добавлены 4 missing FK, дропнуты 2 дублирующих индекса и 2 устаревшие таблицы (`sold_out_by_year`, `sold_out_by_progress` ⊂ `domrf_realization`), 8 `COMMENT ON TABLE` для документации источников.
|
||||
- Territory aliases для stat_series ↔ regions name mapping (`v_stat_series_resolved` view): 100% покрытие 809K observations через 101 alias.
|
||||
- Готовы 2 стратегических отчёта: `Sverdl_Quartirography_Report_Apr27` (общий по Свердл) + `PRINZIP_Strategy_Apr27` (что строить и куда смотреть).
|
||||
|
||||
---
|
||||
|
||||
## Аналитический пласт данных (data warehouse) — 27.04.2026
|
||||
|
||||
В прод-БД `gendesign` живёт **43 предметных таблицы** + 5 helper views + Rosreestr partitions. Размер БД ~2.1 ГБ.
|
||||
|
||||
### 🔌 Подключение
|
||||
|
||||
| Метод | Команда |
|
||||
|---|---|
|
||||
| **MCP** (рекомендуется) | конфиг в `.mcp.json` (project root) — `mcp__postgres__execute_sql`, `analyze_db_health`, `list_objects`, `explain_query` и т.д. |
|
||||
| **psql via SSH-туннель** | `ssh -fN -L 15432:127.0.0.1:5432 gendesign` затем `PGPASSWORD=… psql -h 127.0.0.1 -p 15432 -U gendesign gendesign` |
|
||||
| **DBeaver / TablePlus** | localhost:15432 после открытия SSH-туннеля |
|
||||
|
||||
⚠️ Туннель уже открыт на машинах команды (gendesign@46.173.16.127). `.mcp.json` в `.gitignore` (содержит пароль).
|
||||
|
||||
### 📊 Карта таблиц (43 предметных)
|
||||
|
||||
**ROSREESTR per-deal (1.1 ГБ)**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `rosreestr_deals` (+ 7 партиций по кварталам 2024Q3-2026Q1) | 4 975 398 | Сырые сделки ДКП+ДДУ из ФГИС ЕГРН, 89 регионов |
|
||||
| `rosreestr_deals_staging` | UNLOGGED | буфер для `\copy` из CSV |
|
||||
| `rosreestr_rent_deals` | 1 071 | Договоры аренды Свердл Q1 2026 (Антон) |
|
||||
|
||||
**ROSREESTR materialized views (305 МБ)**
|
||||
| MV | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `rr_agg_cad_quarter` | 952 799 | (region, cad_quarter, period, doc_type, area_bucket) → p10/median/p90 ₽/м² |
|
||||
| `rr_agg_settlement` | 422 852 | (region, settlement, period, doc_type, realestate_type) |
|
||||
| `rr_agg_region` | 3 549 | percentile-агрегаты по регионам |
|
||||
|
||||
**DOM.РФ справочники / dimensions**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `domrf_regions` | 90 | PK region_id (0=РФ + 89 субъектов). FK target для 4 таблиц |
|
||||
| `domrf_developers` | **8 629** | реестр + архив (после merge с Антоном). PK developer_id ('6208_0'=PRINZIP) |
|
||||
| `domrf_developers_full` | 2 563 | XLSX-выгрузка с метриками area/permits/flats/share |
|
||||
| `domrf_snapshots` | 18 | snapshot_date dimension (4 базовых + 14 history Янв 2025-Февр 2026) |
|
||||
| `domrf_federal_districts` | 8 | 8 ФО как first-class lookup |
|
||||
| `domrf_territory_aliases` | 101 | text→(level, region_id\|fo_id) для нормализации имён |
|
||||
| `domrf_stat_series_indicators` | 123 | XLSX sheet codes → indicator_name |
|
||||
|
||||
**DOM.РФ realization & sold_ready (history)**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `domrf_realization` | **143 963** | 4 endpoint_types (total/housing/ready_year/developer) × 89 регионов × 14 monthly snapshots |
|
||||
| `domrf_sold_ready_index` | 15 | РФ-итог helio метрики per snapshot |
|
||||
| `domrf_sold_ready_breakdown` | 40 044 | 7 chart_types × dim_value × 14 snapshots (regionChart/devChart/foChart/cityPopulationChart/classChart/devValueChart/readyYearChart) с типизированными колонками |
|
||||
| `domrf_sold_ready_dynamics` | 3 450 | timeseries 2020-period × 4 chart_types |
|
||||
|
||||
**DOM.РФ JSON dashboard**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `domrf_region_aggregates` | 439 | (region, room_count_type) → flat_count/area/percent |
|
||||
| `domrf_developer_aggregates` | 11 063 | (developer × region × room_count) |
|
||||
| `domrf_flat_area_distribution` | 40 | РФ-уровень бакеты площади |
|
||||
|
||||
**DOM.РФ XLSX/PDF снапшоты**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `domrf_developers_full` | 2 563 | реестр девелоперов из XLSX |
|
||||
| `domrf_escrow_banks` | 28 | банки эскроу (Сбер 61%) |
|
||||
| `domrf_guaranty_regions` | 99 | РФ + 8 ФО + 89 регионов разбивка |
|
||||
| `domrf_housing_summary` | 58 | разбивка по механизмам |
|
||||
| `domrf_planned_commissioning` | 10 | план ввода 2026-2035 |
|
||||
| `domrf_sold_out` | 9 | РФ + 8 топ-регионов (PDF, ради unique `attracted_funds_mln_rub`) |
|
||||
| `domrf_mortgage_rates` | 20 | ставки топ-20 банков (PDF) |
|
||||
|
||||
**DOM.РФ launch / commissioning**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `domrf_launch_top` | 1 410 | топ-N developer/region/area/fo по запускам/вводу |
|
||||
| `domrf_launch_obj_class` | 4 | Типовой/Комфорт/Бизнес/Элитный |
|
||||
| `domrf_launch_monthly` | 75 | timeseries 2020-2026 |
|
||||
| `domrf_commissioning` | 1 | факт ввод (multifamily/private) |
|
||||
| `domrf_project_finance` | 99 | ПР финансирование РФ/ФО/регионы |
|
||||
|
||||
**DOM.РФ stat_series (parsed XLSX 254 МБ)**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `domrf_stat_series_observations` | **809 236** | long-form panel: 99 территорий × 123 индикатора × ~70 месяцев (2020-2026) |
|
||||
| `domrf_xlsx_files` | 9 | BYTEA-архив исходных XLSX (8 МБ) |
|
||||
|
||||
**DOM.РФ ipoteka / mortgage**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `domrf_mortgage_dashboard` | 1 | агрегат рынка ипотеки РФ |
|
||||
| `domrf_mortgage_details` | 2 | RUB/OTH валюты |
|
||||
|
||||
**DOM.РФ /сервисы/api/kn/* (от Антона)**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `domrf_kn_flats` | **27 563** | поквартирный прайс РФ free/booked/sold с ценами |
|
||||
| `domrf_kn_objects` | 1 516 | реестр ЖК Свердл (1285 ЕКБ + 28 PRINZIP) с GPS, классом, эскроу |
|
||||
| `domrf_dev_growth` | 139 | динамика 2024→2025 девелоперов |
|
||||
|
||||
**ЦБ РФ ипотека (от Антона)**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `cbr_mortgage_series` | 3 308 | 47 серий × {rf,sverdl} × Jan 2019 - Feb 2026 |
|
||||
| `cbr_mortgage_files` | 19 | XLSX-источники |
|
||||
|
||||
**Внешние источники**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `sber_ddf_metrics` | 20 | Сбер ДомКлик DDF nov2025: affordability, demand index, миграция, повторные ипотечники, price gap |
|
||||
| `yandex_realty_zk` | 12 | Yandex Realty ЖК ЕКБ snapshot |
|
||||
| `yandex_realty_class_prices` | 3 | разбивка по классам econom/comfort/elite |
|
||||
|
||||
**Geo Свердл/ЕКБ**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `ekb_districts` | 9 | 9 районов ЕКБ с zk_count/flat_count/area |
|
||||
| `ekb_metro` | 10 | станции метро ЕКБ |
|
||||
| `sv_cities` | 24 | не-ЕКБ города Свердл (Н.Тагил, Первоуральск, Арамиль) |
|
||||
| `sv_dev_sales` | 50 | ТОП-50 Свердл девелоперы Март 2026 |
|
||||
|
||||
**Universal raw**
|
||||
| Таблица | Рядов | Описание |
|
||||
|---|---:|---|
|
||||
| `domrf_raw_endpoints` | 349 | universal JSONB store: каждый /api/* response с GIN-index на payload |
|
||||
|
||||
### 🔍 Helper views (9 шт)
|
||||
|
||||
**Базовые (legacy):**
|
||||
| View | Описание |
|
||||
|---|---|
|
||||
| `v_market_pulse_rf` | one-row live-метрика РФ: 118.7M м² стройка / sold 30.91% / Цена 216 316 ₽/м² / ставка 19.9% vs 6.83% |
|
||||
| `v_developer_master` | полный профиль девелопера (legacy). PRINZIP sold% 48.4% — ЛИДЕР Свердл |
|
||||
| `v_region_master` | one-row-per-region: квартирография + sold_out + sold_ready + project_finance + guaranty + launch |
|
||||
| `v_rosreestr_market_by_region` | Rosreestr p10/median/p90 + DOM.РФ имена |
|
||||
| `v_stat_series_resolved` | stat_series_observations + alias + indicator_name (`WHERE region_id=66` → Свердл) |
|
||||
|
||||
**Связи квартиры↔ЖК↔застройщики (NEW 27.04 evening):**
|
||||
| View | Описание |
|
||||
|---|---|
|
||||
| `v_jk_with_developer` | ЖК Свердл + canonical dev_name + region_name (через FK `fk_kn_obj_developer/region`). 1 516 строк. |
|
||||
| `v_jk_sales_summary` | flats GROUP BY obj_name: total/sold/booked/free + sold_pct + avg_ppm + avg_area + total_value. Standalone-вид поквартирных снапшотов из `domrf_kn_flats`. |
|
||||
| `v_developer_portfolio` | Девелопер + все его корпуса/ЖК Свердл: count, flats_total, sqm + фильтры ekb/problem/escrow/completed/in_progress + earliest/latest ready_dt. |
|
||||
| `v_developer_full_metrics` | **⭐ ULTIMATE QUERY**: portfolio (ЖК) + квартирография (1к/2к/3к/4+) с процентами + realization Свердл sold%/цена. Лучший view для PRINZIP/Брусника/конкурентов сравнения. |
|
||||
|
||||
### 🔗 FK relationships
|
||||
|
||||
**35 FK constraints** (после relations v2):
|
||||
- `domrf_snapshots`: 24 child FKs (все таблицы со snapshot_date)
|
||||
- `domrf_regions`: 5 (developer_aggregates, flat_area_distribution, region_aggregates, realization, kn_objects⭐, rent_deals⭐, territory_aliases)
|
||||
- `domrf_developers`: 3 (developer_aggregates, **kn_objects** ⭐ NEW, **sv_dev_sales** ⭐ NEW)
|
||||
- `domrf_federal_districts`: 1 (territory_aliases)
|
||||
|
||||
**Цепочка анализа продаж:**
|
||||
```
|
||||
domrf_kn_flats (поквартирно) ─── obj_name ──→ domrf_kn_objects (ЖК)
|
||||
│ dev_id (FK)
|
||||
▼
|
||||
domrf_developers (справочник)
|
||||
│ developer_id (FK)
|
||||
▼
|
||||
domrf_realization (sold%/price 14 мес)
|
||||
│
|
||||
│ region_code=66 ─→ domrf_regions
|
||||
```
|
||||
|
||||
⚠ **Известное ограничение**: `domrf_kn_flats.obj_id` НЕ имеет FK на `domrf_kn_objects.obj_id` — два API endpoint используют разные ID-системы (27 563/27 563 orphans, текстовый match по `obj_name`/`comm_name` тоже даёт только 2 совпадения). Поквартирные снапшоты обрабатываются standalone через `v_jk_sales_summary`.
|
||||
|
||||
### 📜 ETL artefacts (data/sql/)
|
||||
|
||||
```
|
||||
01_schema_rosreestr_deals.sql partitioned table + staging
|
||||
02_load_all_quarters.sh \copy CSV (sep ~ и ;)
|
||||
03_materialized_views.sql 3 MV для аналитики
|
||||
|
||||
10_schema_domrf.sql domrf_regions/developers/snapshots/aggregates
|
||||
11_load_domrf.py JSON dashboard loader
|
||||
20_schema_domrf_extras.sql XLSX/PDF schemas
|
||||
21_load_domrf_extras.py XLSX/PDF loader
|
||||
|
||||
30_scrape_domrf.py Playwright headless Chromium с per-page direct API triggers
|
||||
31_schema_domrf_full.sql launch/sold_ready/etc normalized
|
||||
31_schema_domrf_raw.sql universal JSONB store
|
||||
32_load_domrf_full.py normalize scraper output
|
||||
32_load_domrf_raw.py JSONB raw store loader
|
||||
|
||||
33_schema_domrf_lazy.sql domrf_realization + xlsx_files (5 lazy pages)
|
||||
34_load_domrf_lazy.py realization/xlsx/raw loader
|
||||
35_schema_domrf_stats.sql stat_series_indicators + observations
|
||||
36_parse_xlsx_stats.py parse 7 XLSX → 809K observations
|
||||
37_cleanup_redundant.sql DROP sold_out_by_year/progress
|
||||
|
||||
38_relations_fix.sql +4 FK, миграция -1→0 RF, drop dup indexes, COMMENT'ы
|
||||
39_territory_aliases.sql federal_districts + territory_aliases + v_stat_series_resolved
|
||||
40_relations.sql оригинальные FK + 4 helper views (legacy)
|
||||
40_scrape_history.py 14-month history scraper для realization+sold_ready
|
||||
41_load_history.py loader с SNAPSHOT_DATE = end of period
|
||||
42_schema_breakdown_widen.sql drop JSONB extra → 6 typed dimension columns
|
||||
43_anton_import.sql 13 new tables for Anton's SQLite
|
||||
44_import_anton_db.py sqlite3 → postgres migration
|
||||
45_relations_v2.sql +4 FK (kn_obj→regions/developers, sv_dev_sales→developers, rent_deals→regions) + 4 analytical views (jk_with_developer, jk_sales_summary, developer_portfolio, developer_full_metrics)
|
||||
```
|
||||
|
||||
### 🎯 Аналитика — что делать с этими данными
|
||||
|
||||
**Готовые отчёты в графе памяти:**
|
||||
- `Sverdl_Quartirography_Report_Apr27` — полный отчёт по квартирографии Свердл (что строится, что продаётся, тренды 14 мес, топ-15 девелоперов)
|
||||
- `PRINZIP_Strategy_Apr27` — стратегические рекомендации для PRINZIP (что строить, куда смотреть, BENCHMARK с Брусникой/Форум-групп)
|
||||
|
||||
**Использование (паттерны):**
|
||||
```sql
|
||||
-- Свердл realization timeseries за год
|
||||
SELECT TO_CHAR(snapshot_date,'YYYY-MM') AS p, total_square, sold_perc, price_avg
|
||||
FROM domrf_realization
|
||||
WHERE region_code=66 AND endpoint_type='total' AND type_square='total'
|
||||
ORDER BY snapshot_date;
|
||||
|
||||
-- ЕКБ ДДУ-сделки по площадным сегментам
|
||||
SELECT
|
||||
CASE WHEN area<30 THEN 'studio' WHEN area<45 THEN '1k' ... END,
|
||||
COUNT(*), AVG(price_per_sqm)
|
||||
FROM rosreestr_deals
|
||||
WHERE region_code=66 AND city ILIKE '%катеринбург%'
|
||||
AND period_start_date >= '2025-04-01' AND doc_type='ДДУ';
|
||||
|
||||
-- Свердл stat_series (МКД 214-ФЗ, временной ряд) через resolved view
|
||||
SELECT obs_date, value
|
||||
FROM v_stat_series_resolved
|
||||
WHERE region_id=66 AND sheet_code='01_01_01';
|
||||
|
||||
-- Поквартирный прайс ЕКБ (Антон)
|
||||
SELECT obj_name, rooms, total_area, price_per_m2, status
|
||||
FROM domrf_kn_flats WHERE city='Город Екатеринбург' AND status='free';
|
||||
|
||||
-- Топ ЖК ЕКБ (Антон)
|
||||
SELECT comm_name, dev_name, flat_count, square_living, ready_dt
|
||||
FROM domrf_kn_objects WHERE is_ekb=true ORDER BY flat_count DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Структура репозитория
|
||||
|
|
@ -325,7 +570,16 @@ git push
|
|||
|
||||
⚠️ Эти документы — **снимок состояния на момент написания**. Свежее состояние стратегии — в графе памяти. При расхождении доверяем графу.
|
||||
|
||||
Релевантные сущности графа на 26.04.2026:
|
||||
Релевантные сущности графа на 27.04.2026:
|
||||
|
||||
**Аналитика и отчёты (27.04):**
|
||||
- `DomRF_Lazy_Loaded_Apr27_2026` ⭐ — итог дня: 5 lazy-load страниц + stat_series + cleanup + territory aliases + MCP
|
||||
- `DomRF_History_Loaded_Apr27_2026` ⭐ — 14 месяцев истории realization+sold_ready (143K рядов realization)
|
||||
- `Anton_Imported_Apr27_2026` ⭐ — 13 таблиц от Антона + merge developers (8 629 итого)
|
||||
- `Sverdl_Quartirography_Report_Apr27` 📊 — полный отчёт по Свердл квартирографии
|
||||
- `PRINZIP_Strategy_Apr27` 📊 — стратегия PRINZIP: что строить и куда смотреть
|
||||
|
||||
Сущности 26.04.2026:
|
||||
|
||||
**Стратегия / план:**
|
||||
- `Project_GenDesign` — общая шапка
|
||||
|
|
|
|||
|
|
@ -60,6 +60,330 @@ def upgrade_size(url: str) -> str:
|
|||
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_q, parts.fragment))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page-specific lazy-load triggers
|
||||
# ---------------------------------------------------------------------------
|
||||
# Most DOM.RF analytics pages render Material-UI tabs/selects; data fetches
|
||||
# fire on tab/option switch. The "kitchen sink" trigger walks every tab and
|
||||
# every Select option, with networkidle waits in between. Pages with custom
|
||||
# UI (tree pickers, multi-step filters) get dedicated triggers below.
|
||||
|
||||
async def _wait_quiet(page, ms=2500):
|
||||
try:
|
||||
await page.wait_for_load_state('networkidle', timeout=8000)
|
||||
except Exception:
|
||||
pass
|
||||
await page.wait_for_timeout(ms)
|
||||
|
||||
|
||||
async def _click_all_tabs(page):
|
||||
"""Click every Material-UI tab one-by-one, waiting between to capture XHR."""
|
||||
tab_count = await page.evaluate('''() => document.querySelectorAll(
|
||||
'[role="tab"], .MuiTab-root, button[data-tab]'
|
||||
).length''')
|
||||
print(f' tabs found: {tab_count}')
|
||||
for i in range(tab_count):
|
||||
try:
|
||||
await page.evaluate(
|
||||
'''(i) => {
|
||||
const tabs = document.querySelectorAll(
|
||||
'[role="tab"], .MuiTab-root, button[data-tab]'
|
||||
);
|
||||
if (tabs[i]) tabs[i].click();
|
||||
}''',
|
||||
i,
|
||||
)
|
||||
await _wait_quiet(page, 2000)
|
||||
except Exception as e:
|
||||
print(f' tab[{i}] err: {e}')
|
||||
|
||||
|
||||
async def _iterate_selects(page, max_options=20):
|
||||
"""Open every MUI Select / native <select>, pick each option, wait."""
|
||||
select_count = await page.evaluate('''() => document.querySelectorAll(
|
||||
'[role="combobox"], [role="button"][aria-haspopup="listbox"], select'
|
||||
).length''')
|
||||
print(f' selects found: {select_count}')
|
||||
for i in range(select_count):
|
||||
try:
|
||||
opt_count = await page.evaluate(
|
||||
'''async (i) => {
|
||||
const sels = document.querySelectorAll(
|
||||
'[role="combobox"], [role="button"][aria-haspopup="listbox"], select'
|
||||
);
|
||||
const sel = sels[i];
|
||||
if (!sel) return 0;
|
||||
if (sel.tagName === 'SELECT') return sel.options.length;
|
||||
sel.click();
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
return document.querySelectorAll('[role="option"], li[data-value]').length;
|
||||
}''',
|
||||
i,
|
||||
)
|
||||
opt_count = min(opt_count, max_options)
|
||||
for j in range(opt_count):
|
||||
try:
|
||||
await page.evaluate(
|
||||
'''async ([i, j]) => {
|
||||
const sels = document.querySelectorAll(
|
||||
'[role="combobox"], [role="button"][aria-haspopup="listbox"], select'
|
||||
);
|
||||
const sel = sels[i];
|
||||
if (!sel) return;
|
||||
if (sel.tagName === 'SELECT') {
|
||||
sel.selectedIndex = j;
|
||||
sel.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
} else {
|
||||
sel.click();
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
const opts = document.querySelectorAll('[role="option"], li[data-value]');
|
||||
if (opts[j]) opts[j].click();
|
||||
}
|
||||
}''',
|
||||
[i, j],
|
||||
)
|
||||
await _wait_quiet(page, 1500)
|
||||
except Exception as e:
|
||||
print(f' select[{i}].opt[{j}] err: {e}')
|
||||
break
|
||||
except Exception as e:
|
||||
print(f' select[{i}] err: {e}')
|
||||
|
||||
|
||||
async def _click_all_safe_buttons(page, deny=('Войти', 'Регистр', 'Выйти', 'Меню')):
|
||||
"""Click every non-disabled button whose text doesn't match deny list."""
|
||||
deny_js = json.dumps(list(deny))
|
||||
btn_count = await page.evaluate(
|
||||
'''(deny) => Array.from(document.querySelectorAll('button:not([disabled])'))
|
||||
.filter(b => !deny.some(d => (b.textContent || '').includes(d))).length''',
|
||||
deny,
|
||||
)
|
||||
print(f' safe buttons: {btn_count}')
|
||||
for i in range(min(btn_count, 30)):
|
||||
try:
|
||||
await page.evaluate(
|
||||
'''([i, deny]) => {
|
||||
const bs = Array.from(document.querySelectorAll('button:not([disabled])'))
|
||||
.filter(b => !deny.some(d => (b.textContent || '').includes(d)));
|
||||
if (bs[i]) bs[i].click();
|
||||
}''',
|
||||
[i, list(deny)],
|
||||
)
|
||||
await _wait_quiet(page, 1500)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def trigger_kitchen_sink(page):
|
||||
"""Generic: scroll, click all tabs, iterate all selects."""
|
||||
for _ in range(2):
|
||||
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
|
||||
await page.wait_for_timeout(1200)
|
||||
await page.evaluate('window.scrollTo(0, 0)')
|
||||
await page.wait_for_timeout(800)
|
||||
await _click_all_tabs(page)
|
||||
await _iterate_selects(page, max_options=10)
|
||||
await _click_all_safe_buttons(page)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direct-API helpers — fetch from inside browser ctx so cookies/WAF survive
|
||||
# ---------------------------------------------------------------------------
|
||||
# These five pages don't fire XHR on user interaction — they're SSR-baked or
|
||||
# expose a documented REST API. Instead of clicking we just call the APIs
|
||||
# directly via page.evaluate(fetch()) and dump the JSON via the response hook.
|
||||
# Page spec source: knowledge graph entities DomRF_Tab*_*_Apr26.
|
||||
|
||||
REGIONS_FULL_RU = list(range(1, 100)) # try region codes 1..99 (Rosstat-style)
|
||||
|
||||
|
||||
async def _fetch_and_save(page, url, out_dir, seen_urls, saved):
|
||||
"""Fetch URL inside browser context (so cookies/WAF stay valid), save body."""
|
||||
if url in seen_urls:
|
||||
return
|
||||
try:
|
||||
resp = await page.evaluate(
|
||||
'''async (u) => {
|
||||
const r = await fetch(u, {credentials: 'include'});
|
||||
const t = await r.text();
|
||||
return {status: r.status, ct: r.headers.get('content-type') || '', text: t};
|
||||
}''',
|
||||
url,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f' direct err {url}: {e}')
|
||||
return
|
||||
if not resp or resp.get('status', 0) >= 400:
|
||||
return
|
||||
text = resp.get('text') or ''
|
||||
if not text or len(text) < 20:
|
||||
return
|
||||
seen_urls.add(url)
|
||||
fname = safe_filename(url)
|
||||
with open(os.path.join(out_dir, fname), 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
saved.append((fname, len(text), url))
|
||||
print(f' direct {fname} ({len(text)}b)')
|
||||
|
||||
|
||||
async def _dump_next_data(page, out_dir, saved):
|
||||
"""Pull __NEXT_DATA__ JSON blob from the rendered HTML and save it."""
|
||||
blob = await page.evaluate('''() => {
|
||||
const el = document.getElementById('__NEXT_DATA__');
|
||||
return el ? el.textContent : null;
|
||||
}''')
|
||||
if not blob:
|
||||
return
|
||||
fname = '__NEXT_DATA__.json'
|
||||
with open(os.path.join(out_dir, fname), 'w', encoding='utf-8') as f:
|
||||
f.write(blob)
|
||||
saved.append((fname, len(blob), 'inline:__NEXT_DATA__'))
|
||||
print(f' ssr {fname} ({len(blob)}b)')
|
||||
|
||||
|
||||
async def trigger_housing(page, out_dir, seen_urls, saved):
|
||||
"""Tab 1: показатели жилищного строительства — SSR + portal-analytics REST."""
|
||||
await _dump_next_data(page, out_dir, saved)
|
||||
base = '/portal-analytics/api/dashboard'
|
||||
# Aggregate (no filter) variants first
|
||||
for ep in ('by-region', 'by-developer', 'by-room-count', 'by-flat-area'):
|
||||
await _fetch_and_save(page, f'{base}/{ep}', out_dir, seen_urls, saved)
|
||||
# Per-region drilldown
|
||||
for rid in REGIONS_FULL_RU:
|
||||
await _fetch_and_save(page, f'{base}/by-region?regionId={rid}', out_dir, seen_urls, saved)
|
||||
await _fetch_and_save(page, f'{base}/by-room-count?regionId={rid}', out_dir, seen_urls, saved)
|
||||
await _fetch_and_save(page, f'{base}/by-flat-area?regionId={rid}', out_dir, seen_urls, saved)
|
||||
|
||||
|
||||
async def trigger_housing_dev(page, out_dir, seen_urls, saved):
|
||||
"""Tab 2: жилищное строительство — different Redux slice, same portal-analytics base."""
|
||||
await _dump_next_data(page, out_dir, saved)
|
||||
# Probe common analytics endpoints
|
||||
bases = [
|
||||
'/portal-analytics/api/housing',
|
||||
'/portal-analytics/api/dashboard',
|
||||
'/аналитика/api/housing',
|
||||
]
|
||||
for b in bases:
|
||||
for ep in ('summary', 'by-region', 'by-developer', 'by-period', 'overview', 'main'):
|
||||
await _fetch_and_save(page, f'{b}/{ep}', out_dir, seen_urls, saved)
|
||||
await trigger_kitchen_sink(page)
|
||||
|
||||
|
||||
async def trigger_realization(page, out_dir, seen_urls, saved):
|
||||
"""Tab 6: реализация — /аналитика/api/rpp/* documented in graph."""
|
||||
await _dump_next_data(page, out_dir, saved)
|
||||
base = '/аналитика/api/rpp'
|
||||
rep_year, rep_month = 2026, 3
|
||||
# RF-level (no regionCode)
|
||||
for ep in ('total', 'housing', 'readyYear'):
|
||||
await _fetch_and_save(
|
||||
page, f'{base}/{ep}?repMonth={rep_month}&repYear={rep_year}',
|
||||
out_dir, seen_urls, saved,
|
||||
)
|
||||
await _fetch_and_save(
|
||||
page,
|
||||
f'{base}/developer?typeSquare=total&repMonth={rep_month}&repYear={rep_year}'
|
||||
f'&developerOrder=totalSquare:desc',
|
||||
out_dir, seen_urls, saved,
|
||||
)
|
||||
# Per-region (89 регионов РФ — codes mostly in 1..99)
|
||||
for rcode in REGIONS_FULL_RU:
|
||||
for ep in ('total', 'housing', 'readyYear'):
|
||||
await _fetch_and_save(
|
||||
page,
|
||||
f'{base}/{ep}?regionCode={rcode}&repMonth={rep_month}&repYear={rep_year}',
|
||||
out_dir, seen_urls, saved,
|
||||
)
|
||||
for ts in ('total', 'living'):
|
||||
await _fetch_and_save(
|
||||
page,
|
||||
f'{base}/developer?typeSquare={ts}®ionCode={rcode}'
|
||||
f'&repMonth={rep_month}&repYear={rep_year}'
|
||||
f'&developerOrder=totalSquare:desc',
|
||||
out_dir, seen_urls, saved,
|
||||
)
|
||||
|
||||
|
||||
async def trigger_mortgage_rates(page, out_dir, seen_urls, saved):
|
||||
"""Tab 10: ставки предложений по ипотеке — likely SSR; probe known APIs."""
|
||||
await _dump_next_data(page, out_dir, saved)
|
||||
bases = [
|
||||
'/portal-analytics/api/mortgage-rates',
|
||||
'/portal-analytics/api/mortgage',
|
||||
'/аналитика/api/mortgage-rates',
|
||||
'/аналитика/api/mortgage',
|
||||
]
|
||||
for b in bases:
|
||||
for ep in ('', '/list', '/banks', '/offers', '/dashboard', '/summary'):
|
||||
await _fetch_and_save(page, f'{b}{ep}', out_dir, seen_urls, saved)
|
||||
await trigger_kitchen_sink(page)
|
||||
|
||||
|
||||
async def trigger_stat_series(page, out_dir, seen_urls, saved):
|
||||
"""Tab 12: статистические ряды — index of XLSX files baked into __NEXT_DATA__.
|
||||
|
||||
Per graph: data lives in props.initialState.housingData.list (CMS hierarchy).
|
||||
XLSX URLs: /site/binaries/content/assets/domrf/xlsdashboard/{filename}.xlsx
|
||||
Strategy: dump __NEXT_DATA__ + extract every .xlsx href and download.
|
||||
"""
|
||||
await _dump_next_data(page, out_dir, saved)
|
||||
# Extract all .xlsx links from the rendered DOM and the __NEXT_DATA__ blob
|
||||
links = await page.evaluate('''() => {
|
||||
const set = new Set();
|
||||
document.querySelectorAll('a[href]').forEach(a => {
|
||||
const h = a.getAttribute('href');
|
||||
if (h && h.toLowerCase().includes('.xlsx')) set.add(h);
|
||||
});
|
||||
const blob = document.getElementById('__NEXT_DATA__');
|
||||
if (blob) {
|
||||
const re = /["'](\\/site\\/binaries[^"']+\\.xlsx)["']/g;
|
||||
let m;
|
||||
while ((m = re.exec(blob.textContent)) !== null) set.add(m[1]);
|
||||
}
|
||||
return Array.from(set);
|
||||
}''')
|
||||
print(f' xlsx links found: {len(links)}')
|
||||
xlsx_dir = os.path.join(out_dir, 'xlsx')
|
||||
os.makedirs(xlsx_dir, exist_ok=True)
|
||||
for href in links:
|
||||
url = href if href.startswith('http') else BASE + href
|
||||
# Download via fetch in browser context, then write bytes locally
|
||||
try:
|
||||
b64 = await page.evaluate(
|
||||
'''async (u) => {
|
||||
const r = await fetch(u, {credentials: 'include'});
|
||||
if (!r.ok) return null;
|
||||
const buf = await r.arrayBuffer();
|
||||
let s = '';
|
||||
const arr = new Uint8Array(buf);
|
||||
for (let i = 0; i < arr.length; i++) s += String.fromCharCode(arr[i]);
|
||||
return btoa(s);
|
||||
}''',
|
||||
url,
|
||||
)
|
||||
if not b64:
|
||||
continue
|
||||
import base64
|
||||
data = base64.b64decode(b64)
|
||||
fname = re.sub(r'[^A-Za-z0-9_.\-]', '_', os.path.basename(href.split('?')[0]))
|
||||
with open(os.path.join(xlsx_dir, fname), 'wb') as f:
|
||||
f.write(data)
|
||||
saved.append((f'xlsx/{fname}', len(data), url))
|
||||
print(f' xlsx {fname} ({len(data)}b)')
|
||||
except Exception as e:
|
||||
print(f' xlsx err {href}: {e}')
|
||||
|
||||
|
||||
PAGE_TRIGGERS = {
|
||||
'housing': trigger_housing,
|
||||
'housing_dev': trigger_housing_dev,
|
||||
'realization': trigger_realization,
|
||||
'mortgage_rates': trigger_mortgage_rates,
|
||||
'stat_series': trigger_stat_series,
|
||||
}
|
||||
|
||||
|
||||
async def scrape_page(browser, name, path):
|
||||
out_dir = os.path.join(ROOT, name)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
|
@ -101,30 +425,26 @@ async def scrape_page(browser, name, path):
|
|||
full_url = BASE + path
|
||||
try:
|
||||
await page.goto(full_url, wait_until='domcontentloaded', timeout=60_000)
|
||||
# WAF challenge usually completes within 1-3 seconds
|
||||
try:
|
||||
await page.wait_for_load_state('networkidle', timeout=30_000)
|
||||
except:
|
||||
pass
|
||||
# Extra wait for lazy-loaded API calls
|
||||
await page.wait_for_timeout(8000)
|
||||
# Scroll multiple times to trigger any IntersectionObserver-bound fetches
|
||||
for _ in range(3):
|
||||
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
|
||||
await page.wait_for_timeout(1500)
|
||||
await page.evaluate('window.scrollTo(0, document.body.scrollHeight / 2)')
|
||||
await page.wait_for_timeout(1500)
|
||||
await page.evaluate('window.scrollTo(0, 0)')
|
||||
await page.wait_for_timeout(1500)
|
||||
# Click first interactive button (triggers React state → API)
|
||||
try:
|
||||
await page.evaluate('''() => {
|
||||
const btn = document.querySelector('button:not([disabled])');
|
||||
if (btn) btn.click();
|
||||
}''')
|
||||
await page.wait_for_timeout(3000)
|
||||
except Exception:
|
||||
pass
|
||||
await page.wait_for_timeout(5000)
|
||||
|
||||
# Per-page triggers (falls back to kitchen sink for everything new)
|
||||
trigger = PAGE_TRIGGERS.get(name)
|
||||
try:
|
||||
if trigger is None:
|
||||
await trigger_kitchen_sink(page)
|
||||
else:
|
||||
# New triggers take (page, out_dir, seen_urls, saved) so they
|
||||
# can perform direct API fetches and dump SSR blobs themselves.
|
||||
await trigger(page, out_dir, seen_urls, saved)
|
||||
except Exception as e:
|
||||
print(f' trigger err: {e}')
|
||||
|
||||
# Final settle
|
||||
await _wait_quiet(page, 3000)
|
||||
|
||||
# Re-fetch with size=1000 for endpoints that have small page-size
|
||||
for url in list(seen_urls):
|
||||
|
|
|
|||
60
data/sql/33_schema_domrf_lazy.sql
Normal file
60
data/sql/33_schema_domrf_lazy.sql
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
-- DOM.RF lazy-load pages (housing, housing_dev, realization, mortgage_rates,
|
||||
-- stat_series) — schema for the 5 pages that didn't capture on the first
|
||||
-- scrape pass. Realization = 4 endpoints (rpp/total, /housing, /readyYear,
|
||||
-- /developer) all share the same row shape, so a single wide table works.
|
||||
|
||||
-- ── REALIZATION ────────────────────────────────────────────────────────────
|
||||
-- Per-region (and RF-level when region_code IS NULL) realization data from
|
||||
-- /аналитика/api/rpp/{total,housing,readyYear,developer}. The "subject"
|
||||
-- field is overloaded:
|
||||
-- - total : RF code (always "100")
|
||||
-- - housing : sold-progress bucket id ("0".."6")
|
||||
-- - ready_year: year of planned readiness ("2026"..)
|
||||
-- - developer: developer_id (e.g. "6208_0" = PRINZIP)
|
||||
-- subject_desc carries the human-readable label.
|
||||
CREATE TABLE IF NOT EXISTS domrf_realization (
|
||||
snapshot_date DATE NOT NULL,
|
||||
endpoint_type TEXT NOT NULL, -- 'total' | 'housing' | 'ready_year' | 'developer'
|
||||
region_code INT NOT NULL DEFAULT -1, -- Rosstat code; -1 sentinel for RF-only call
|
||||
rep_year INT NOT NULL,
|
||||
rep_month INT NOT NULL,
|
||||
type_square TEXT NOT NULL, -- 'total' | 'living'
|
||||
subject TEXT NOT NULL, -- bucket id / year / developer_id
|
||||
subject_desc TEXT,
|
||||
share_total NUMERIC, -- доля от итогового объёма (для housing/ready_year)
|
||||
total_square NUMERIC, -- м² × тыс
|
||||
open_square NUMERIC,
|
||||
sold_square NUMERIC,
|
||||
unsold_square NUMERIC,
|
||||
unopened_square NUMERIC,
|
||||
sold_perc NUMERIC,
|
||||
unsold_perc NUMERIC,
|
||||
unopened_perc NUMERIC,
|
||||
sold_amount NUMERIC, -- # квартир
|
||||
price_avg NUMERIC, -- ₽/м² средний
|
||||
PRIMARY KEY (snapshot_date, endpoint_type, region_code, type_square, subject)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_realization_region ON domrf_realization (region_code, endpoint_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_realization_dev ON domrf_realization (subject) WHERE endpoint_type='developer';
|
||||
|
||||
-- ── STAT_SERIES XLSX FILE INDEX ────────────────────────────────────────────
|
||||
-- Catalogue of the public XLSX statistics files served at
|
||||
-- /site/binaries/content/assets/domrf/xlsdashboard/*. Stores download metadata
|
||||
-- and a copy of the bytes (for offline reload). XLSX bodies live in BYTEA so
|
||||
-- the prod DB is the single source of truth — no filesystem dependency.
|
||||
CREATE TABLE IF NOT EXISTS domrf_xlsx_files (
|
||||
snapshot_date DATE NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
bytes INT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
content BYTEA NOT NULL,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (snapshot_date, filename)
|
||||
);
|
||||
|
||||
-- ── SSR BLOBS for housing / housing_dev / mortgage_rates ────────────────────
|
||||
-- These pages bake their data into Next.js __NEXT_DATA__ instead of XHR.
|
||||
-- We dump the whole blob into the existing universal store so future
|
||||
-- normalization can mine it without re-scraping.
|
||||
-- (no new table — uses existing domrf_raw_endpoints from 31_schema_domrf_raw.sql)
|
||||
222
data/sql/34_load_domrf_lazy.py
Normal file
222
data/sql/34_load_domrf_lazy.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"""Load lazy-load DOM.RF pages (housing, housing_dev, realization,
|
||||
mortgage_rates, stat_series) into prod PG.
|
||||
|
||||
Reads: data/raw/domrf_full/{realization,housing,housing_dev,mortgage_rates,stat_series}/*
|
||||
Writes: domrf_realization (new),
|
||||
domrf_xlsx_files (new),
|
||||
domrf_raw_endpoints (existing universal store, append-only).
|
||||
|
||||
Connect via SSH tunnel: PG_HOST=host.docker.internal PG_PORT=15432 (default).
|
||||
"""
|
||||
import os, json, re, sys, hashlib, base64, subprocess, datetime
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.join(HERE, '..', 'raw', 'domrf_full')
|
||||
PG_HOST = os.environ.get('PG_HOST', 'host.docker.internal')
|
||||
PG_PORT = os.environ.get('PG_PORT', '15432')
|
||||
PG_USER = os.environ.get('PG_USER', 'gendesign')
|
||||
PG_DB = os.environ.get('PG_DB', 'gendesign')
|
||||
PG_PASS = os.environ.get('PGPASSWORD', '2J2SBPMKuS998fiwhtQqDhMI')
|
||||
SNAP = os.environ.get('SNAPSHOT_DATE', datetime.date.today().isoformat())
|
||||
|
||||
|
||||
def psql(sql, capture=True):
|
||||
cmd = ['docker', 'run', '--rm', '-i', '-e', f'PGPASSWORD={PG_PASS}',
|
||||
'postgres:16-alpine', 'psql',
|
||||
'-h', PG_HOST, '-p', PG_PORT, '-U', PG_USER, '-d', PG_DB,
|
||||
'-v', 'ON_ERROR_STOP=1', '--quiet']
|
||||
res = subprocess.run(cmd, input=sql, capture_output=capture, text=True, encoding='utf-8')
|
||||
if res.returncode != 0:
|
||||
print('psql FAILED:', (res.stderr or '')[-2000:], file=sys.stderr)
|
||||
raise SystemExit(res.returncode)
|
||||
return res.stdout
|
||||
|
||||
|
||||
def esc(s):
|
||||
if s is None:
|
||||
return 'NULL'
|
||||
return "'" + str(s).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def num(v):
|
||||
if v is None or v == '-' or v == '':
|
||||
return 'NULL'
|
||||
try:
|
||||
return str(float(v))
|
||||
except (ValueError, TypeError):
|
||||
return 'NULL'
|
||||
|
||||
|
||||
def integer(v):
|
||||
if v is None:
|
||||
return 'NULL'
|
||||
try:
|
||||
return str(int(float(v)))
|
||||
except (ValueError, TypeError):
|
||||
return 'NULL'
|
||||
|
||||
|
||||
def upsert_chunked(rows, table, cols, conflict_cols, batch=500):
|
||||
"""Insert rows in batches with ON CONFLICT DO UPDATE."""
|
||||
if not rows:
|
||||
return 0
|
||||
update = ', '.join(f'{c}=EXCLUDED.{c}' for c in cols if c not in conflict_cols)
|
||||
total = 0
|
||||
for i in range(0, len(rows), batch):
|
||||
chunk = rows[i:i + batch]
|
||||
sql = (f"INSERT INTO {table} ({','.join(cols)}) VALUES "
|
||||
+ ',\n'.join(chunk)
|
||||
+ f"\nON CONFLICT ({','.join(conflict_cols)}) DO UPDATE SET {update};")
|
||||
psql(sql)
|
||||
total += len(chunk)
|
||||
return total
|
||||
|
||||
|
||||
# ── 1. REALIZATION (rpp/{total,housing,readyYear,developer}) ─────────────────
|
||||
RPP_FNAME_RE = re.compile(
|
||||
r'rpp_(total|housing|readyYear|developer)__'
|
||||
r'(?:typeSquare_(total|living)_)?'
|
||||
r'(?:regionCode_(\d+)_)?'
|
||||
r'repMonth_(\d+)_repYear_(\d+)'
|
||||
)
|
||||
|
||||
|
||||
def load_realization():
|
||||
sec_dir = os.path.join(ROOT, 'realization')
|
||||
if not os.path.isdir(sec_dir):
|
||||
print(' realization dir missing')
|
||||
return 0
|
||||
cols = ['snapshot_date', 'endpoint_type', 'region_code', 'rep_year', 'rep_month',
|
||||
'type_square', 'subject', 'subject_desc', 'share_total',
|
||||
'total_square', 'open_square', 'sold_square', 'unsold_square', 'unopened_square',
|
||||
'sold_perc', 'unsold_perc', 'unopened_perc', 'sold_amount', 'price_avg']
|
||||
rows = []
|
||||
seen = set()
|
||||
for fname in sorted(os.listdir(sec_dir)):
|
||||
if not fname.endswith('.json') or not fname.startswith('rpp_'):
|
||||
continue
|
||||
m = RPP_FNAME_RE.match(fname)
|
||||
if not m:
|
||||
continue
|
||||
ep_raw, ts_filename, rcode_filename, rmonth, ryear = m.groups()
|
||||
endpoint_type = {'total': 'total', 'housing': 'housing',
|
||||
'readyYear': 'ready_year', 'developer': 'developer'}[ep_raw]
|
||||
region_code = int(rcode_filename) if rcode_filename else -1
|
||||
try:
|
||||
doc = json.load(open(os.path.join(sec_dir, fname), encoding='utf-8'))
|
||||
except Exception as e:
|
||||
print(f' parse err {fname}: {e}')
|
||||
continue
|
||||
for it in doc.get('data') or []:
|
||||
ts = ts_filename or it.get('typeSquare') or 'total'
|
||||
subject = str(it.get('subject', ''))
|
||||
if not subject:
|
||||
continue
|
||||
key = (endpoint_type, region_code, ts, subject)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
rows.append(
|
||||
f"({esc(SNAP)},{esc(endpoint_type)},{region_code},"
|
||||
f"{int(ryear)},{int(rmonth)},{esc(ts)},{esc(subject)},"
|
||||
f"{esc(it.get('subjectDesc'))},{num(it.get('shareTotal'))},"
|
||||
f"{num(it.get('totalSquare'))},{num(it.get('openSquare'))},"
|
||||
f"{num(it.get('soldSquare'))},{num(it.get('unsoldSquare'))},"
|
||||
f"{num(it.get('unopenedSquare'))},{num(it.get('soldPerc'))},"
|
||||
f"{num(it.get('unsoldPerc'))},{num(it.get('unopenedPerc'))},"
|
||||
f"{num(it.get('soldAmount'))},{num(it.get('priceAvg'))})"
|
||||
)
|
||||
return upsert_chunked(
|
||||
rows, 'domrf_realization', cols,
|
||||
conflict_cols=['snapshot_date', 'endpoint_type', 'region_code', 'type_square', 'subject'],
|
||||
)
|
||||
|
||||
|
||||
# ── 2. STAT_SERIES XLSX FILES ────────────────────────────────────────────────
|
||||
def load_xlsx_files():
|
||||
xlsx_dir = os.path.join(ROOT, 'stat_series', 'xlsx')
|
||||
if not os.path.isdir(xlsx_dir):
|
||||
print(' xlsx dir missing')
|
||||
return 0
|
||||
base_url = 'https://xn--80az8a.xn--d1aqf.xn--p1ai/site/binaries/content/assets/domrf/xlsdashboard/'
|
||||
rows = []
|
||||
cols = ['snapshot_date', 'filename', 'source_url', 'bytes', 'sha256', 'content']
|
||||
for fname in sorted(os.listdir(xlsx_dir)):
|
||||
if not fname.endswith('.xlsx'):
|
||||
continue
|
||||
path = os.path.join(xlsx_dir, fname)
|
||||
with open(path, 'rb') as f:
|
||||
blob = f.read()
|
||||
sha = hashlib.sha256(blob).hexdigest()
|
||||
# PG bytea hex format: \xDEADBEEF
|
||||
hex_blob = '\\\\x' + blob.hex()
|
||||
rows.append(
|
||||
f"({esc(SNAP)},{esc(fname)},{esc(base_url + fname)},"
|
||||
f"{len(blob)},{esc(sha)},E{esc(hex_blob)})"
|
||||
)
|
||||
return upsert_chunked(
|
||||
rows, 'domrf_xlsx_files', cols,
|
||||
conflict_cols=['snapshot_date', 'filename'],
|
||||
batch=5, # XLSX bytes are big — keep batches tiny to avoid huge SQL strings
|
||||
)
|
||||
|
||||
|
||||
# ── 3. RAW JSONB store for SSR + housing per-region ──────────────────────────
|
||||
def load_raw_endpoints():
|
||||
"""Dump every captured JSON file from the 5 lazy pages into
|
||||
domrf_raw_endpoints (existing universal store). Keeps all data accessible
|
||||
for future normalization without re-scraping."""
|
||||
cols = ['snapshot_date', 'section', 'endpoint', 'source_url', 'payload',
|
||||
'payload_size']
|
||||
rows = []
|
||||
for section in ('housing', 'housing_dev', 'mortgage_rates', 'stat_series'):
|
||||
sec_dir = os.path.join(ROOT, section)
|
||||
if not os.path.isdir(sec_dir):
|
||||
continue
|
||||
for fname in sorted(os.listdir(sec_dir)):
|
||||
path = os.path.join(sec_dir, fname)
|
||||
if not os.path.isfile(path) or not fname.endswith('.json'):
|
||||
continue
|
||||
with open(path, encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
# Validate JSON; skip if parse fails (rare).
|
||||
try:
|
||||
json.loads(text)
|
||||
except Exception:
|
||||
continue
|
||||
endpoint = fname[:-5] # drop .json
|
||||
rows.append(
|
||||
f"({esc(SNAP)},{esc(section)},{esc(endpoint)},NULL,"
|
||||
f"{esc(text)}::jsonb,{len(text)})"
|
||||
)
|
||||
return upsert_chunked(
|
||||
rows, 'domrf_raw_endpoints', cols,
|
||||
conflict_cols=['snapshot_date', 'section', 'endpoint'],
|
||||
batch=50, # payloads can be 100KB+
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
print(f'Snapshot date: {SNAP}')
|
||||
print(f'PG: {PG_USER}@{PG_HOST}:{PG_PORT}/{PG_DB}')
|
||||
|
||||
schema = open(os.path.join(HERE, '33_schema_domrf_lazy.sql'), encoding='utf-8').read()
|
||||
psql(schema)
|
||||
print('schema applied')
|
||||
|
||||
# Stamp snapshot in domrf_snapshots (FK target)
|
||||
psql(f"INSERT INTO domrf_snapshots(snapshot_date) VALUES ({esc(SNAP)}) "
|
||||
f"ON CONFLICT DO NOTHING;")
|
||||
|
||||
n_real = load_realization()
|
||||
print(f' realization: {n_real} rows')
|
||||
|
||||
n_xlsx = load_xlsx_files()
|
||||
print(f' xlsx_files: {n_xlsx} rows')
|
||||
|
||||
n_raw = load_raw_endpoints()
|
||||
print(f' raw_endpoints: {n_raw} rows')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
29
data/sql/35_schema_domrf_stats.sql
Normal file
29
data/sql/35_schema_domrf_stats.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
-- Normalized stat_series observations parsed from DOM.RF XLSX files.
|
||||
-- Source: domrf_xlsx_files (BYTEA archive) → parsed via 36_parse_xlsx_stats.py.
|
||||
-- Long-form panel: (territory × indicator × month) → numeric value.
|
||||
|
||||
-- sheet_code alone isn't unique — '01_01_01' appears in both
|
||||
-- stockvariablesexsales (МКД 214-ФЗ без эскроу) and stockvariableskrt (МКД КРТ).
|
||||
-- Composite PK on (file_name, sheet_code) keeps them separate.
|
||||
CREATE TABLE IF NOT EXISTS domrf_stat_series_indicators (
|
||||
file_name TEXT NOT NULL, -- source xlsx
|
||||
sheet_code TEXT NOT NULL, -- '01_01_01' .. '01_04_NN'
|
||||
indicator_name TEXT NOT NULL, -- human label from row 2
|
||||
section_title TEXT, -- top-of-file context
|
||||
n_periods INT, -- # of monthly columns
|
||||
n_territories INT,
|
||||
PRIMARY KEY (file_name, sheet_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domrf_stat_series_observations (
|
||||
snapshot_date DATE NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
sheet_code TEXT NOT NULL,
|
||||
territory TEXT NOT NULL, -- 'Российская Федерация' | ФО | регион
|
||||
obs_date DATE NOT NULL, -- first day of month
|
||||
value NUMERIC,
|
||||
PRIMARY KEY (snapshot_date, file_name, sheet_code, territory, obs_date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stat_obs_sheet ON domrf_stat_series_observations (file_name, sheet_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_stat_obs_territory ON domrf_stat_series_observations (territory, obs_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_stat_obs_date ON domrf_stat_series_observations (obs_date);
|
||||
218
data/sql/36_parse_xlsx_stats.py
Normal file
218
data/sql/36_parse_xlsx_stats.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""Parse DOM.RF stat_series XLSX files into long-form observations.
|
||||
|
||||
Reads: data/raw/domrf_full/stat_series/xlsx/*.xlsx (4 standard-format files)
|
||||
Writes: domrf_stat_series_indicators, domrf_stat_series_observations
|
||||
(created by 35_schema_domrf_stats.sql).
|
||||
|
||||
Standard format (files 01_01..01_04):
|
||||
Sheet '*_00' = index (skipped — derive from individual sheets)
|
||||
Sheets '*_NN' = one indicator × all territories × all months
|
||||
row 1: section title (long sentence)
|
||||
row 2: indicator name ("Количество МКД...")
|
||||
row 4: header — col 0 = "Регион", col 1..N = month dates
|
||||
row 5+: territory + numeric values
|
||||
|
||||
Files with bespoke format (01_05_*, 01_06_*) are skipped — left in
|
||||
domrf_xlsx_files BYTEA archive for ad-hoc analysis.
|
||||
"""
|
||||
import os, re, sys, subprocess, datetime
|
||||
import openpyxl
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.join(HERE, '..', 'raw', 'domrf_full', 'stat_series', 'xlsx')
|
||||
PG_HOST = os.environ.get('PG_HOST', 'host.docker.internal')
|
||||
PG_PORT = os.environ.get('PG_PORT', '15432')
|
||||
PG_USER = os.environ.get('PG_USER', 'gendesign')
|
||||
PG_DB = os.environ.get('PG_DB', 'gendesign')
|
||||
PG_PASS = os.environ.get('PGPASSWORD', '2J2SBPMKuS998fiwhtQqDhMI')
|
||||
SNAP = os.environ.get('SNAPSHOT_DATE', datetime.date.today().isoformat())
|
||||
|
||||
# Sheet name pattern: NN_NN_NN (e.g. '01_01_01', '01_02_05'). The '*_00' index
|
||||
# is skipped (last group != '00').
|
||||
SHEET_RE = re.compile(r'^(\d\d)_(\d\d)_(\d\d)$')
|
||||
|
||||
# Standard-format files only. Others have bespoke layouts.
|
||||
STANDARD_FILES = {
|
||||
'01_01_stockvariablesexsales.xlsx',
|
||||
'01_01_stockvariableskrt.xlsx',
|
||||
'01_02_flowvariables.xlsx',
|
||||
'01_03_sales.xlsx',
|
||||
'01_04_flowvariables.xlsx',
|
||||
'01_04_stockvariables.xlsx',
|
||||
'01_06_stockvariables_izs.xlsx',
|
||||
}
|
||||
|
||||
|
||||
def psql(sql, capture=True):
|
||||
cmd = ['docker', 'run', '--rm', '-i', '-e', f'PGPASSWORD={PG_PASS}',
|
||||
'postgres:16-alpine', 'psql',
|
||||
'-h', PG_HOST, '-p', PG_PORT, '-U', PG_USER, '-d', PG_DB,
|
||||
'-v', 'ON_ERROR_STOP=1', '--quiet']
|
||||
res = subprocess.run(cmd, input=sql, capture_output=capture, text=True, encoding='utf-8')
|
||||
if res.returncode != 0:
|
||||
print('psql FAILED:', (res.stderr or '')[-2000:], file=sys.stderr)
|
||||
raise SystemExit(res.returncode)
|
||||
return res.stdout
|
||||
|
||||
|
||||
def esc(s):
|
||||
if s is None:
|
||||
return 'NULL'
|
||||
return "'" + str(s).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def num(v):
|
||||
if v is None or v == '':
|
||||
return 'NULL'
|
||||
try:
|
||||
f = float(v)
|
||||
# Strip trailing .0 noise but keep precision
|
||||
if f != f: # NaN
|
||||
return 'NULL'
|
||||
return repr(f)
|
||||
except (ValueError, TypeError):
|
||||
return 'NULL'
|
||||
|
||||
|
||||
def to_date(v):
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, datetime.datetime):
|
||||
return v.date().isoformat()
|
||||
if isinstance(v, datetime.date):
|
||||
return v.isoformat()
|
||||
s = str(v)
|
||||
# Sometimes the cell is a string like '2024-03-01'
|
||||
m = re.match(r'(\d{4})-(\d{2})-(\d{2})', s)
|
||||
if m:
|
||||
return f'{m.group(1)}-{m.group(2)}-{m.group(3)}'
|
||||
return None
|
||||
|
||||
|
||||
def parse_sheet(ws, file_name, sheet_code):
|
||||
"""Return (indicator_name, [(territory, obs_date, value)...]). Stream rows."""
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
if len(rows) < 5:
|
||||
return None, []
|
||||
# Row 1 (index 0) — section title; row 2 — indicator; rows 4..end — data
|
||||
indicator_name = (rows[1][0] or '').strip() if rows[1] and rows[1][0] else ''
|
||||
header = rows[3]
|
||||
if not header or len(header) < 2:
|
||||
return indicator_name, []
|
||||
# Skip first 'Регион' column; remaining are month dates
|
||||
date_cols = []
|
||||
for col_idx, cell in enumerate(header[1:], start=1):
|
||||
d = to_date(cell)
|
||||
if d:
|
||||
date_cols.append((col_idx, d))
|
||||
obs = []
|
||||
for r in rows[4:]:
|
||||
if not r or r[0] is None:
|
||||
continue
|
||||
territory = str(r[0]).strip()
|
||||
if not territory or territory.lower() == 'перейти в начало':
|
||||
continue
|
||||
for col_idx, obs_date in date_cols:
|
||||
if col_idx >= len(r):
|
||||
continue
|
||||
val = r[col_idx]
|
||||
if val is None or val == '':
|
||||
continue
|
||||
obs.append((territory, obs_date, val))
|
||||
return indicator_name, obs
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isdir(ROOT):
|
||||
print('xlsx dir missing:', ROOT)
|
||||
return
|
||||
schema = open(os.path.join(HERE, '35_schema_domrf_stats.sql'),
|
||||
encoding='utf-8').read()
|
||||
psql(schema)
|
||||
print('schema applied')
|
||||
|
||||
indicator_rows = [] # (file_name, sheet_code, indicator_name, n_periods, n_territories)
|
||||
obs_rows = [] # (file_name, sheet_code, territory, obs_date, value)
|
||||
parsed_sheets = 0
|
||||
skipped_files = []
|
||||
|
||||
for fname in sorted(os.listdir(ROOT)):
|
||||
if not fname.endswith('.xlsx'):
|
||||
continue
|
||||
if fname not in STANDARD_FILES:
|
||||
skipped_files.append(fname)
|
||||
continue
|
||||
path = os.path.join(ROOT, fname)
|
||||
print(f' parsing {fname}')
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
for sn in wb.sheetnames:
|
||||
m = SHEET_RE.match(sn)
|
||||
if not m or m.group(3) == '00':
|
||||
continue
|
||||
ws = wb[sn]
|
||||
indicator_name, obs = parse_sheet(ws, fname, sn)
|
||||
if not obs:
|
||||
continue
|
||||
territories = {t for t, _, _ in obs}
|
||||
periods = {d for _, d, _ in obs}
|
||||
indicator_rows.append((fname, sn, indicator_name, len(periods), len(territories)))
|
||||
for t, d, v in obs:
|
||||
obs_rows.append((fname, sn, t, d, v))
|
||||
parsed_sheets += 1
|
||||
wb.close()
|
||||
|
||||
print(f'parsed {parsed_sheets} sheets; skipped (bespoke format): {skipped_files}')
|
||||
print(f' indicators: {len(indicator_rows)}')
|
||||
print(f' observations: {len(obs_rows)}')
|
||||
|
||||
# ── INDICATORS upsert ──
|
||||
if indicator_rows:
|
||||
sql = ("INSERT INTO domrf_stat_series_indicators "
|
||||
"(file_name, sheet_code, indicator_name, n_periods, n_territories) VALUES "
|
||||
+ ','.join(
|
||||
f"({esc(f)},{esc(c)},{esc(n)},{p},{t})"
|
||||
for f, c, n, p, t in indicator_rows)
|
||||
+ " ON CONFLICT (file_name, sheet_code) DO UPDATE SET "
|
||||
"indicator_name=EXCLUDED.indicator_name, "
|
||||
"n_periods=EXCLUDED.n_periods, "
|
||||
"n_territories=EXCLUDED.n_territories;")
|
||||
psql(sql)
|
||||
print(f' indicators upserted: {len(indicator_rows)}')
|
||||
|
||||
# ── OBSERVATIONS upsert (chunked — ~810K rows). Dedupe within batch
|
||||
# because the same (file, sheet, territory, obs_date) can appear twice
|
||||
# in the source XLSX (rarely — duplicate header dates) and ON CONFLICT
|
||||
# rejects in-batch duplicates.
|
||||
seen = set()
|
||||
deduped = []
|
||||
for f, c, t, d, v in obs_rows:
|
||||
key = (f, c, t, d)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append((f, c, t, d, v))
|
||||
if len(deduped) != len(obs_rows):
|
||||
print(f' deduped {len(obs_rows) - len(deduped)} duplicate observations')
|
||||
obs_rows = deduped
|
||||
|
||||
BATCH = 2000
|
||||
total = 0
|
||||
for i in range(0, len(obs_rows), BATCH):
|
||||
chunk = obs_rows[i:i + BATCH]
|
||||
values = ','.join(
|
||||
f"({esc(SNAP)},{esc(f)},{esc(c)},{esc(t)},{esc(d)},{num(v)})"
|
||||
for f, c, t, d, v in chunk
|
||||
)
|
||||
sql = ("INSERT INTO domrf_stat_series_observations "
|
||||
"(snapshot_date, file_name, sheet_code, territory, obs_date, value) VALUES "
|
||||
+ values
|
||||
+ " ON CONFLICT (snapshot_date, file_name, sheet_code, territory, obs_date) "
|
||||
"DO UPDATE SET value=EXCLUDED.value;")
|
||||
psql(sql)
|
||||
total += len(chunk)
|
||||
if total % 20000 == 0 or total == len(obs_rows):
|
||||
print(f' obs upserted: {total}/{len(obs_rows)}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
19
data/sql/37_cleanup_redundant.sql
Normal file
19
data/sql/37_cleanup_redundant.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- Drop tables fully superseded by domrf_realization (loaded 27.04.2026).
|
||||
-- Coverage check confirmed:
|
||||
-- • domrf_sold_out_by_year (6 rows, RF-only) ⊂
|
||||
-- domrf_realization WHERE endpoint_type='ready_year' AND region_code=-1
|
||||
-- (+ × 89 regions in same source). Values match exactly: e.g. 2027 sold 30%,
|
||||
-- 2028 sold 17%, 2029 sold 9%. API data is more complete (PDF had nulls).
|
||||
-- • domrf_sold_out_by_progress (6 rows, RF-only) ⊂
|
||||
-- domrf_realization WHERE endpoint_type='housing' AND region_code=-1
|
||||
-- (+ × 89 regions). Same buckets: not_open / до 20% / ... / 80+.
|
||||
--
|
||||
-- domrf_sold_out (9 rows) is KEPT — has unique attracted_funds_mln_rub column
|
||||
-- not present in the API response.
|
||||
--
|
||||
-- Run manually:
|
||||
-- docker run --rm -e PGPASSWORD=... postgres:16-alpine psql \
|
||||
-- -h host.docker.internal -p 15432 -U gendesign -d gendesign \
|
||||
-- -f data/sql/37_cleanup_redundant.sql
|
||||
DROP TABLE IF EXISTS domrf_sold_out_by_year;
|
||||
DROP TABLE IF EXISTS domrf_sold_out_by_progress;
|
||||
63
data/sql/38_relations_fix.sql
Normal file
63
data/sql/38_relations_fix.sql
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
-- Fix missing relations + housekeeping after 27.04.2026 loads.
|
||||
-- Audit findings:
|
||||
-- 1. domrf_realization, domrf_xlsx_files, domrf_stat_series_observations
|
||||
-- have snapshot_date but NO FK to domrf_snapshots.
|
||||
-- 2. domrf_realization.region_code uses sentinel -1 for RF-level rows; the
|
||||
-- RF placeholder already exists in domrf_regions as region_id=0. Migrate
|
||||
-- sentinel and add FK to keep data consistent.
|
||||
-- 3. Two duplicate indexes (covered by PK):
|
||||
-- - idx_domrf_developer_aggregates_dev
|
||||
-- - rr_agg_settl_search (on rr_agg_settlement)
|
||||
-- 4. domrf_stat_series_observations.territory is TEXT and uses naming that
|
||||
-- does NOT line up with domrf_regions.region_name (e.g. 'г. Москва' vs
|
||||
-- 'Город Москва', 'Республика Адыгея (Адыгея)' vs 'Республика Адыгея',
|
||||
-- plus 8 federal districts and a few synthetic rows). FK is not added;
|
||||
-- lookup-by-alias work tracked as a separate TODO.
|
||||
-- 5. ~8 700 stat_series rows have territory='-' or 'г. Москва1' (parser
|
||||
-- noise). NOT deleted here — keep raw for future cleaning pass.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Migrate RF sentinel -1 → 0 (matches existing RF placeholder in regions)
|
||||
UPDATE domrf_realization SET region_code = 0 WHERE region_code = -1;
|
||||
ALTER TABLE domrf_realization ALTER COLUMN region_code SET DEFAULT 0;
|
||||
|
||||
-- 2. Add missing FKs
|
||||
ALTER TABLE domrf_realization
|
||||
ADD CONSTRAINT fk_snap_realization
|
||||
FOREIGN KEY (snapshot_date) REFERENCES domrf_snapshots(snapshot_date) ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT fk_realization_region
|
||||
FOREIGN KEY (region_code) REFERENCES domrf_regions(region_id) ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE domrf_xlsx_files
|
||||
ADD CONSTRAINT fk_snap_xlsx_files
|
||||
FOREIGN KEY (snapshot_date) REFERENCES domrf_snapshots(snapshot_date) ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE domrf_stat_series_observations
|
||||
ADD CONSTRAINT fk_snap_stat_series_observations
|
||||
FOREIGN KEY (snapshot_date) REFERENCES domrf_snapshots(snapshot_date) ON UPDATE CASCADE;
|
||||
|
||||
-- 3. Drop duplicate indexes (functionally covered by PK)
|
||||
DROP INDEX IF EXISTS idx_domrf_developer_aggregates_dev;
|
||||
DROP INDEX IF EXISTS rr_agg_settl_search;
|
||||
|
||||
-- 4. Document tables with COMMENT — helps future maintainers (and AI agents)
|
||||
-- distinguish overlapping data sources (PDF snapshot vs API daily etc).
|
||||
COMMENT ON TABLE domrf_realization IS
|
||||
'RPP API per-region realization (sold/unsold/price). 4 endpoint_types: total, housing, ready_year, developer. region_code=0 = RF total. SUPERSET of the dropped sold_out_by_year / sold_out_by_progress (kept domrf_sold_out for unique attracted_funds_mln_rub).';
|
||||
COMMENT ON TABLE domrf_sold_out IS
|
||||
'PDF-snapshot of RF + 8 top regions (manual export 26.04). Kept ONLY for unique attracted_funds_mln_rub column. For full 89-region coverage use domrf_realization endpoint_type=total.';
|
||||
COMMENT ON TABLE domrf_stat_series_observations IS
|
||||
'Long-form panel of monthly Rosstat-form stats from 7 XLSX files (parsed 27.04). Indicators: см. domrf_stat_series_indicators. Coverage: 2020-2026, ~70 territories incl. 8 federal districts. NB: territory naming differs from domrf_regions.region_name (FK not enforced).';
|
||||
COMMENT ON TABLE domrf_stat_series_indicators IS
|
||||
'Lookup of XLSX sheet codes to human indicator names. PK (file_name, sheet_code) — codes like 01_01_01 repeat across files with different semantics.';
|
||||
COMMENT ON TABLE domrf_xlsx_files IS
|
||||
'BYTEA archive of raw stat_series XLSX. Acts as backup for re-parsing (domrf_stat_series_observations). 2 of 9 files (01_05_*) bespoke format — not yet normalized.';
|
||||
COMMENT ON TABLE domrf_raw_endpoints IS
|
||||
'Universal JSONB store: every captured /api/* response from DOM.RF analytics scrape. Source-of-truth backup for future normalization.';
|
||||
COMMENT ON TABLE domrf_developers IS
|
||||
'Developer dictionary from JSON dashboard (with developer_id). FK target for domrf_developer_aggregates and domrf_realization (when endpoint_type=developer; not enforced).';
|
||||
COMMENT ON TABLE domrf_developers_full IS
|
||||
'XLSX export of full developer registry (manual 26.04). Has metrics (area, permits, flats, market_share) but NO developer_id — name-only join to domrf_developers possible.';
|
||||
|
||||
COMMIT;
|
||||
120
data/sql/39_territory_aliases.sql
Normal file
120
data/sql/39_territory_aliases.sql
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
-- Resolve stat_series.territory ↔ domrf_regions.region_name name mismatches.
|
||||
-- Stat_series uses Rosstat-style names that differ in 18 of 99 territories:
|
||||
-- • 9 regions: "г. Москва" vs "Город Москва", "Республика Адыгея (Адыгея)"
|
||||
-- vs "Республика Адыгея", "- Алания"/"(Татарстан)"/"- Чувашия" suffixes;
|
||||
-- • 8 federal districts ("Центральный ФО") — not in domrf_regions at all;
|
||||
-- • 1 synthetic row "Российская Федерация (без г. Москва)";
|
||||
-- • 2 noise rows '-' and 'г. Москва1' (XLSX parser artefacts).
|
||||
--
|
||||
-- Solution: an alias lookup that maps any incoming text to a structured target
|
||||
-- (level + region_id|fo_id). View v_stat_series_resolved joins observations
|
||||
-- through it for clean territory resolution without rewriting 809K rows.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Federal districts as a first-class table ─────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS domrf_federal_districts (
|
||||
fo_id INT PRIMARY KEY,
|
||||
fo_name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
INSERT INTO domrf_federal_districts (fo_id, fo_name) VALUES
|
||||
(1, 'Центральный ФО'),
|
||||
(2, 'Северо-Западный ФО'),
|
||||
(3, 'Южный ФО'),
|
||||
(4, 'Северо-Кавказский ФО'),
|
||||
(5, 'Приволжский ФО'),
|
||||
(6, 'Уральский ФО'),
|
||||
(7, 'Сибирский ФО'),
|
||||
(8, 'Дальневосточный ФО')
|
||||
ON CONFLICT (fo_id) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE domrf_federal_districts IS
|
||||
'8 федеральных округов РФ. Связывается с domrf_regions.federal_district по имени, и используется как target для domrf_territory_aliases (level=fo).';
|
||||
|
||||
-- ── 2. Generic territory alias table ────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS domrf_territory_aliases (
|
||||
territory_text TEXT PRIMARY KEY,
|
||||
level TEXT NOT NULL CHECK (level IN ('rf','fo','region','synthetic','noise')),
|
||||
region_id INT REFERENCES domrf_regions(region_id),
|
||||
fo_id INT REFERENCES domrf_federal_districts(fo_id),
|
||||
canonical_name TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
CONSTRAINT alias_target_chk CHECK (
|
||||
(level='rf' AND region_id=0 AND fo_id IS NULL) OR
|
||||
(level='region' AND region_id IS NOT NULL AND fo_id IS NULL) OR
|
||||
(level='fo' AND region_id IS NULL AND fo_id IS NOT NULL) OR
|
||||
(level='synthetic' AND region_id IS NULL AND fo_id IS NULL) OR
|
||||
(level='noise' AND region_id IS NULL AND fo_id IS NULL)
|
||||
)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_territory_aliases_region ON domrf_territory_aliases (region_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_territory_aliases_fo ON domrf_territory_aliases (fo_id);
|
||||
|
||||
COMMENT ON TABLE domrf_territory_aliases IS
|
||||
'Text→(level, region_id|fo_id) lookup. Resolves stat_series territory naming variants to canonical regions/FO. Use v_stat_series_resolved for joined views.';
|
||||
|
||||
-- ── 3a. Auto-populate identity aliases for the 81 regions whose names match ─
|
||||
-- (and the RF row at region_id=0)
|
||||
INSERT INTO domrf_territory_aliases (territory_text, level, region_id, canonical_name, notes)
|
||||
SELECT
|
||||
r.region_name,
|
||||
CASE WHEN r.region_id = 0 THEN 'rf' ELSE 'region' END,
|
||||
r.region_id,
|
||||
r.region_name,
|
||||
'auto: identity alias from domrf_regions.region_name'
|
||||
FROM domrf_regions r
|
||||
WHERE r.region_name IN (SELECT DISTINCT territory FROM domrf_stat_series_observations)
|
||||
ON CONFLICT (territory_text) DO NOTHING;
|
||||
|
||||
-- ── 3b. Manual aliases for 9 regions with format differences ────────────────
|
||||
INSERT INTO domrf_territory_aliases (territory_text, level, region_id, canonical_name, notes) VALUES
|
||||
('г. Москва', 'region', 77, 'Город Москва', 'г./Город prefix mismatch'),
|
||||
('г. Санкт-Петербург', 'region', 78, 'Город Санкт-Петербург', 'г./Город prefix mismatch'),
|
||||
('г. Севастополь', 'region', 92, 'Город Севастополь', 'г./Город prefix mismatch'),
|
||||
('Республика Адыгея (Адыгея)', 'region', 1, 'Республика Адыгея', 'parenthetical suffix in Rosstat naming'),
|
||||
('Республика Татарстан (Татарстан)', 'region', 16, 'Республика Татарстан', 'parenthetical suffix in Rosstat naming'),
|
||||
('Республика Северная Осетия - Алания', 'region', 15, 'Республика Северная Осетия', '- Алания suffix in Rosstat naming'),
|
||||
('Чувашская Республика - Чувашия', 'region', 21, 'Чувашская Республика', '- Чувашия suffix in Rosstat naming')
|
||||
ON CONFLICT (territory_text) DO NOTHING;
|
||||
-- Note: "Республика Саха (Якутия)" and "Кемеровская область - Кузбасс" use the
|
||||
-- same parenthetical convention BUT match exactly between sources, so identity
|
||||
-- aliases above already cover them.
|
||||
|
||||
-- ── 3c. Federal districts — 8 aliases pointing at the new FO table ──────────
|
||||
INSERT INTO domrf_territory_aliases (territory_text, level, fo_id, canonical_name, notes)
|
||||
SELECT fo_name, 'fo', fo_id, fo_name, 'auto: identity alias from domrf_federal_districts'
|
||||
FROM domrf_federal_districts
|
||||
ON CONFLICT (territory_text) DO NOTHING;
|
||||
|
||||
-- ── 3d. Synthetic + noise rows ──────────────────────────────────────────────
|
||||
INSERT INTO domrf_territory_aliases (territory_text, level, canonical_name, notes) VALUES
|
||||
('Российская Федерация (без г. Москва)', 'synthetic', 'РФ без Москвы',
|
||||
'Synthetic row in some XLSX summaries — RF totals excluding Moscow.'),
|
||||
('-', 'noise', '-', 'XLSX parser artefact: empty separator row swallowed by parser.'),
|
||||
('г. Москва1', 'noise', 'г. Москва1','XLSX source typo (extra "1" suffix). Real "г. Москва" present separately.')
|
||||
ON CONFLICT (territory_text) DO NOTHING;
|
||||
|
||||
-- ── 4. Resolved view: stat_series_observations + alias target ───────────────
|
||||
CREATE OR REPLACE VIEW v_stat_series_resolved AS
|
||||
SELECT
|
||||
o.snapshot_date,
|
||||
o.file_name,
|
||||
o.sheet_code,
|
||||
i.indicator_name,
|
||||
o.territory,
|
||||
a.level AS territory_level,
|
||||
a.region_id,
|
||||
a.fo_id,
|
||||
a.canonical_name,
|
||||
o.obs_date,
|
||||
o.value
|
||||
FROM domrf_stat_series_observations o
|
||||
LEFT JOIN domrf_territory_aliases a
|
||||
ON a.territory_text = o.territory
|
||||
LEFT JOIN domrf_stat_series_indicators i
|
||||
ON i.file_name = o.file_name AND i.sheet_code = o.sheet_code;
|
||||
|
||||
COMMENT ON VIEW v_stat_series_resolved IS
|
||||
'stat_series_observations + indicator name + resolved territory (level/region_id/fo_id). Filter WHERE territory_level=''region'' for clean region-level analysis.';
|
||||
|
||||
COMMIT;
|
||||
151
data/sql/40_scrape_history.py
Normal file
151
data/sql/40_scrape_history.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Historical scrape: 12 months of 2025 + Jan/Feb 2026 for realization +
|
||||
sold_ready. The DOM.RF API accepts repYear/repMonth params and returns
|
||||
historical state — already verified by probe.
|
||||
|
||||
Output: data/raw/domrf_history/<section>/<YYYY-MM>/<endpoint>.json
|
||||
|
||||
Usage:
|
||||
PYTHONIOENCODING=utf-8 python data/sql/40_scrape_history.py
|
||||
"""
|
||||
import asyncio, json, os, re, sys
|
||||
from urllib.parse import urlsplit, parse_qs, urlencode, urlunsplit
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), '..', 'raw', 'domrf_history')
|
||||
BASE = 'https://xn--80az8a.xn--d1aqf.xn--p1ai'
|
||||
|
||||
# (year, month) pairs to backfill. 2025 full year + 2026 Jan/Feb.
|
||||
# 2026-03 already has snapshot_date=2026-04-27 in domrf_realization.
|
||||
PERIODS = [(2025, m) for m in range(1, 13)] + [(2026, 1), (2026, 2)]
|
||||
|
||||
REGIONS = list(range(1, 100)) # Rosstat-style 1..99
|
||||
|
||||
|
||||
def safe_filename(url):
|
||||
parts = urlsplit(url)
|
||||
base = parts.path.split('/api/')[-1].strip('/')
|
||||
base = re.sub(r'[^A-Za-z0-9_\-]', '_', base)[:120]
|
||||
if parts.query:
|
||||
q = re.sub(r'[^A-Za-z0-9_\-]', '_', parts.query)[:80]
|
||||
base = f'{base}__{q}'
|
||||
return base + '.json'
|
||||
|
||||
|
||||
async def warm_up(page, url):
|
||||
"""Goto a section page so Chromium passes WAF for that subdomain segment."""
|
||||
await page.goto(BASE + url, wait_until='domcontentloaded', timeout=60_000)
|
||||
try:
|
||||
await page.wait_for_load_state('networkidle', timeout=30_000)
|
||||
except Exception:
|
||||
pass
|
||||
await page.wait_for_timeout(5000)
|
||||
|
||||
|
||||
async def fetch_save(page, url, out_dir, saved):
|
||||
full = url if url.startswith('http') else BASE + url
|
||||
try:
|
||||
resp = await page.evaluate(
|
||||
'''async (u) => {
|
||||
const r = await fetch(u, {credentials: 'include'});
|
||||
const t = await r.text();
|
||||
return {s: r.status, ct: r.headers.get('content-type')||'', t};
|
||||
}''',
|
||||
full,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f' ERR {full}: {e}')
|
||||
return False
|
||||
if not resp or resp.get('s', 0) != 200:
|
||||
return False
|
||||
text = resp.get('t') or ''
|
||||
if len(text) < 20:
|
||||
return False
|
||||
fname = safe_filename(full)
|
||||
with open(os.path.join(out_dir, fname), 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
saved.append((fname, len(text)))
|
||||
return True
|
||||
|
||||
|
||||
async def scrape_realization_period(page, year, month):
|
||||
out_dir = os.path.join(ROOT, 'realization', f'{year}-{month:02d}')
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
saved = []
|
||||
base = '/аналитика/api/rpp'
|
||||
# RF-level total (no regionCode)
|
||||
for ep in ('total', 'housing', 'readyYear'):
|
||||
await fetch_save(page, f'{base}/{ep}?repMonth={month}&repYear={year}', out_dir, saved)
|
||||
await fetch_save(
|
||||
page,
|
||||
f'{base}/developer?typeSquare=total&repMonth={month}&repYear={year}'
|
||||
f'&developerOrder=totalSquare:desc',
|
||||
out_dir, saved,
|
||||
)
|
||||
# Per-region
|
||||
for rcode in REGIONS:
|
||||
for ep in ('total', 'housing', 'readyYear'):
|
||||
await fetch_save(
|
||||
page,
|
||||
f'{base}/{ep}?regionCode={rcode}&repMonth={month}&repYear={year}',
|
||||
out_dir, saved,
|
||||
)
|
||||
for ts in ('total', 'living'):
|
||||
await fetch_save(
|
||||
page,
|
||||
f'{base}/developer?typeSquare={ts}®ionCode={rcode}'
|
||||
f'&repMonth={month}&repYear={year}&developerOrder=totalSquare:desc',
|
||||
out_dir, saved,
|
||||
)
|
||||
print(f' realization {year}-{month:02d}: {len(saved)} files')
|
||||
return len(saved)
|
||||
|
||||
|
||||
async def scrape_sold_ready_period(page, year, month):
|
||||
out_dir = os.path.join(ROOT, 'sold_ready', f'{year}-{month:02d}')
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
saved = []
|
||||
base = '/portal-analytics/api/ready-construction'
|
||||
for ep_path in [
|
||||
f'/index?repYear={year}&repMonth={month}',
|
||||
f'/charts?repYear={year}&repMonth={month}',
|
||||
f'/filters?repYear={year}&repMonth={month}',
|
||||
f'/ready-year-charts?repYear={year}&repMonth={month}',
|
||||
'/dynamics?dynamicChartType=readyPercChart,soldPercChart,soldReadyPercChart,squareSum',
|
||||
]:
|
||||
await fetch_save(page, base + ep_path, out_dir, saved)
|
||||
print(f' sold_ready {year}-{month:02d}: {len(saved)} files')
|
||||
return len(saved)
|
||||
|
||||
|
||||
async def main():
|
||||
os.makedirs(ROOT, exist_ok=True)
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(headless=True)
|
||||
ctx = await browser.new_context(
|
||||
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/147.0.0.0 Safari/537.36',
|
||||
locale='ru-RU',
|
||||
viewport={'width': 1920, 'height': 1080},
|
||||
)
|
||||
page = await ctx.new_page()
|
||||
|
||||
# Warm up realization page first
|
||||
print('Warming up: realization page')
|
||||
await warm_up(page, '/аналитика/реализация_строящихся_квартир')
|
||||
|
||||
# Realization for all periods (uses /аналитика/api/rpp/*)
|
||||
for year, month in PERIODS:
|
||||
await scrape_realization_period(page, year, month)
|
||||
|
||||
# Switch to sold_ready warm-up
|
||||
print('Warming up: sold_ready page')
|
||||
await warm_up(page, '/аналитика/распроданность-стройготовность?repYear=2026&repMonth=3')
|
||||
for year, month in PERIODS:
|
||||
await scrape_sold_ready_period(page, year, month)
|
||||
|
||||
await browser.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
283
data/sql/41_load_history.py
Normal file
283
data/sql/41_load_history.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"""Load historical scrape into prod (realization + sold_ready).
|
||||
|
||||
Reads: data/raw/domrf_history/<section>/<YYYY-MM>/*.json
|
||||
Writes: domrf_realization, domrf_sold_ready_index/breakdown/dynamics
|
||||
+ a fresh row in domrf_snapshots per period.
|
||||
|
||||
SNAPSHOT_DATE for each period = last day of repMonth (so PK collisions are
|
||||
avoided across multiple historical pulls done on the same calendar day).
|
||||
"""
|
||||
import os, json, re, sys, subprocess, calendar, datetime
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.join(HERE, '..', 'raw', 'domrf_history')
|
||||
PG_HOST = os.environ.get('PG_HOST', 'host.docker.internal')
|
||||
PG_PORT = os.environ.get('PG_PORT', '15432')
|
||||
PG_USER = os.environ.get('PG_USER', 'gendesign')
|
||||
PG_DB = os.environ.get('PG_DB', 'gendesign')
|
||||
PG_PASS = os.environ.get('PGPASSWORD', '2J2SBPMKuS998fiwhtQqDhMI')
|
||||
|
||||
|
||||
def psql(sql):
|
||||
cmd = ['docker', 'run', '--rm', '-i', '-e', f'PGPASSWORD={PG_PASS}',
|
||||
'postgres:16-alpine', 'psql',
|
||||
'-h', PG_HOST, '-p', PG_PORT, '-U', PG_USER, '-d', PG_DB,
|
||||
'-v', 'ON_ERROR_STOP=1', '--quiet']
|
||||
res = subprocess.run(cmd, input=sql, capture_output=True, text=True, encoding='utf-8')
|
||||
if res.returncode != 0:
|
||||
print('psql FAILED:', (res.stderr or '')[-2000:], file=sys.stderr)
|
||||
raise SystemExit(res.returncode)
|
||||
return res.stdout
|
||||
|
||||
|
||||
def esc(s):
|
||||
if s is None:
|
||||
return 'NULL'
|
||||
return "'" + str(s).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def num(v):
|
||||
if v is None or v == '' or v == '-':
|
||||
return 'NULL'
|
||||
try:
|
||||
f = float(v)
|
||||
if f != f:
|
||||
return 'NULL'
|
||||
return repr(f)
|
||||
except (ValueError, TypeError):
|
||||
return 'NULL'
|
||||
|
||||
|
||||
def upsert_chunked(rows, table, cols, conflict_cols, batch=500):
|
||||
if not rows:
|
||||
return 0
|
||||
update = ', '.join(f'{c}=EXCLUDED.{c}' for c in cols if c not in conflict_cols)
|
||||
total = 0
|
||||
for i in range(0, len(rows), batch):
|
||||
chunk = rows[i:i + batch]
|
||||
sql = (f"INSERT INTO {table} ({','.join(cols)}) VALUES "
|
||||
+ ','.join(chunk)
|
||||
+ (f" ON CONFLICT ({','.join(conflict_cols)}) DO UPDATE SET {update};"
|
||||
if update else f" ON CONFLICT ({','.join(conflict_cols)}) DO NOTHING;"))
|
||||
psql(sql)
|
||||
total += len(chunk)
|
||||
return total
|
||||
|
||||
|
||||
# ── REALIZATION ─────────────────────────────────────────────────────────────
|
||||
RPP_FNAME_RE = re.compile(
|
||||
r'rpp_(total|housing|readyYear|developer)__'
|
||||
r'(?:typeSquare_(total|living)_)?'
|
||||
r'(?:regionCode_(\d+)_)?'
|
||||
r'repMonth_(\d+)_repYear_(\d+)'
|
||||
)
|
||||
|
||||
|
||||
def load_realization(period_dir, snap):
|
||||
cols = ['snapshot_date', 'endpoint_type', 'region_code', 'rep_year', 'rep_month',
|
||||
'type_square', 'subject', 'subject_desc', 'share_total',
|
||||
'total_square', 'open_square', 'sold_square', 'unsold_square', 'unopened_square',
|
||||
'sold_perc', 'unsold_perc', 'unopened_perc', 'sold_amount', 'price_avg']
|
||||
rows, seen = [], set()
|
||||
for fname in sorted(os.listdir(period_dir)):
|
||||
if not fname.endswith('.json') or not fname.startswith('rpp_'):
|
||||
continue
|
||||
m = RPP_FNAME_RE.match(fname)
|
||||
if not m:
|
||||
continue
|
||||
ep_raw, ts_filename, rcode_filename, rmonth, ryear = m.groups()
|
||||
endpoint_type = {'total': 'total', 'housing': 'housing',
|
||||
'readyYear': 'ready_year', 'developer': 'developer'}[ep_raw]
|
||||
region_code = int(rcode_filename) if rcode_filename else 0
|
||||
try:
|
||||
doc = json.load(open(os.path.join(period_dir, fname), encoding='utf-8'))
|
||||
except Exception:
|
||||
continue
|
||||
for it in doc.get('data') or []:
|
||||
ts = ts_filename or it.get('typeSquare') or 'total'
|
||||
subject = str(it.get('subject', ''))
|
||||
if not subject:
|
||||
continue
|
||||
key = (endpoint_type, region_code, ts, subject)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
rows.append(
|
||||
f"({esc(snap)},{esc(endpoint_type)},{region_code},"
|
||||
f"{int(ryear)},{int(rmonth)},{esc(ts)},{esc(subject)},"
|
||||
f"{esc(it.get('subjectDesc'))},{num(it.get('shareTotal'))},"
|
||||
f"{num(it.get('totalSquare'))},{num(it.get('openSquare'))},"
|
||||
f"{num(it.get('soldSquare'))},{num(it.get('unsoldSquare'))},"
|
||||
f"{num(it.get('unopenedSquare'))},{num(it.get('soldPerc'))},"
|
||||
f"{num(it.get('unsoldPerc'))},{num(it.get('unopenedPerc'))},"
|
||||
f"{num(it.get('soldAmount'))},{num(it.get('priceAvg'))})"
|
||||
)
|
||||
return upsert_chunked(
|
||||
rows, 'domrf_realization', cols,
|
||||
conflict_cols=['snapshot_date', 'endpoint_type', 'region_code', 'type_square', 'subject'],
|
||||
)
|
||||
|
||||
|
||||
# ── SOLD_READY ──────────────────────────────────────────────────────────────
|
||||
def load_sold_ready(period_dir, snap, year, month):
|
||||
n_idx = n_brk = n_dyn = 0
|
||||
# index file
|
||||
for fname in os.listdir(period_dir):
|
||||
path = os.path.join(period_dir, fname)
|
||||
if not fname.startswith('ready-construction_index') or not fname.endswith('.json'):
|
||||
continue
|
||||
try:
|
||||
d = json.load(open(path, encoding='utf-8'))
|
||||
except Exception:
|
||||
continue
|
||||
row = (
|
||||
f"({esc(snap)},{int(year)},{int(month)},"
|
||||
f"{num(d.get('squareSumIndex'))},{num(d.get('soldPercIndex'))},"
|
||||
f"{num(d.get('soldSumIndex'))},{num(d.get('soldReadyPercIndex'))},"
|
||||
f"{num(d.get('readyPercIndex'))})"
|
||||
)
|
||||
n_idx = upsert_chunked(
|
||||
[row], 'domrf_sold_ready_index',
|
||||
['snapshot_date', 'rep_year', 'rep_month',
|
||||
'square_sum', 'sold_perc', 'sold_sum', 'sold_ready_perc', 'ready_perc'],
|
||||
conflict_cols=['snapshot_date', 'rep_year', 'rep_month'],
|
||||
)
|
||||
# charts → breakdown (shape: {"charts":[{"chartType":"...","data":[...]}]} )
|
||||
# Each chart_type has exactly one discriminator field — extracted to its
|
||||
# own typed column instead of dumped into JSONB.
|
||||
chart_rows = []
|
||||
seen_keys = set()
|
||||
for fname in os.listdir(period_dir):
|
||||
if not (fname.startswith('ready-construction_charts')
|
||||
or fname.startswith('ready-construction_ready-year-charts')) or not fname.endswith('.json'):
|
||||
continue
|
||||
try:
|
||||
d = json.load(open(os.path.join(period_dir, fname), encoding='utf-8'))
|
||||
except Exception:
|
||||
continue
|
||||
# Format A: {"charts":[{"chartType":"foChart","data":[...]}, ...]}
|
||||
for chart_obj in (d.get('charts') or []):
|
||||
chart_type = chart_obj.get('chartType')
|
||||
if not chart_type:
|
||||
continue
|
||||
for it in chart_obj.get('data') or []:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
key = it.get('key') or it.get('id') or it.get('subject') or it.get('name') or ''
|
||||
if not key:
|
||||
continue
|
||||
pk = (chart_type, str(key))
|
||||
if pk in seen_keys:
|
||||
continue
|
||||
seen_keys.add(pk)
|
||||
chart_rows.append(
|
||||
f"({esc(snap)},{int(year)},{int(month)},{esc(chart_type)},{esc(str(key))},"
|
||||
f"{num(it.get('squareSum'))},{num(it.get('soldPerc'))},"
|
||||
f"{num(it.get('readyPerc'))},{num(it.get('soldReadyPerc'))},"
|
||||
f"{esc(it.get('foCd'))},"
|
||||
f"{esc(it.get('regionCd'))},"
|
||||
f"{esc(it.get('devCalcGroupId'))},"
|
||||
f"{esc(it.get('devGroupValue'))},"
|
||||
f"{esc(it.get('cityPopulation'))},"
|
||||
f"{esc(it.get('objClass'))},"
|
||||
f"NULL)" # ready_year — only for readyYearChart format below
|
||||
)
|
||||
# Format B: {"readyYears":[{"readyYear":2030,...}]} → readyYearChart
|
||||
for it in (d.get('readyYears') or []):
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
ry = it.get('readyYear')
|
||||
if ry is None:
|
||||
continue
|
||||
key = str(ry)
|
||||
pk = ('readyYearChart', key)
|
||||
if pk in seen_keys:
|
||||
continue
|
||||
seen_keys.add(pk)
|
||||
chart_rows.append(
|
||||
f"({esc(snap)},{int(year)},{int(month)},{esc('readyYearChart')},{esc(key)},"
|
||||
f"{num(it.get('squareSum'))},{num(it.get('soldPerc'))},"
|
||||
f"{num(it.get('readyPerc'))},{num(it.get('soldReadyPerc'))},"
|
||||
f"NULL,NULL,NULL,NULL,NULL,NULL,{int(ry)})"
|
||||
)
|
||||
n_brk = upsert_chunked(
|
||||
chart_rows, 'domrf_sold_ready_breakdown',
|
||||
['snapshot_date', 'rep_year', 'rep_month', 'chart_type', 'entity_key',
|
||||
'square_sum', 'sold_perc', 'ready_perc', 'sold_ready_perc',
|
||||
'fo_cd', 'region_cd', 'dev_calc_group_id', 'dev_group_value',
|
||||
'city_population', 'obj_class', 'ready_year'],
|
||||
conflict_cols=['snapshot_date', 'rep_year', 'rep_month', 'chart_type', 'entity_key'],
|
||||
)
|
||||
# dynamics: {"dynamicCharts":[{"dynamicChartType":"soldPercChart",
|
||||
# "repYears":[{"repYear":2020,"repMonths":[{"repMonth":1,"value":41.54}]}]}]}
|
||||
dyn_rows = []
|
||||
dyn_seen = set()
|
||||
for fname in os.listdir(period_dir):
|
||||
if not fname.startswith('ready-construction_dynamics') or not fname.endswith('.json'):
|
||||
continue
|
||||
try:
|
||||
d = json.load(open(os.path.join(period_dir, fname), encoding='utf-8'))
|
||||
except Exception:
|
||||
continue
|
||||
for chart_obj in (d.get('dynamicCharts') or []):
|
||||
ch_type = chart_obj.get('dynamicChartType')
|
||||
if not ch_type:
|
||||
continue
|
||||
for ry_obj in chart_obj.get('repYears') or []:
|
||||
ry = ry_obj.get('repYear')
|
||||
if ry is None:
|
||||
continue
|
||||
for pt in ry_obj.get('repMonths') or []:
|
||||
rm = pt.get('repMonth')
|
||||
if rm is None:
|
||||
continue
|
||||
pk = (ch_type, ry, rm)
|
||||
if pk in dyn_seen:
|
||||
continue
|
||||
dyn_seen.add(pk)
|
||||
dyn_rows.append(
|
||||
f"({esc(snap)},{esc(ch_type)},{int(ry)},{int(rm)},{num(pt.get('value'))})"
|
||||
)
|
||||
n_dyn = upsert_chunked(
|
||||
dyn_rows, 'domrf_sold_ready_dynamics',
|
||||
['snapshot_date', 'dynamic_chart_type', 'rep_year', 'rep_month', 'value'],
|
||||
conflict_cols=['snapshot_date', 'dynamic_chart_type', 'rep_year', 'rep_month'],
|
||||
)
|
||||
return n_idx, n_brk, n_dyn
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isdir(ROOT):
|
||||
print(f'{ROOT} not found, run 40_scrape_history.py first')
|
||||
return
|
||||
|
||||
# Discover periods from realization subdirs
|
||||
realization_root = os.path.join(ROOT, 'realization')
|
||||
periods = sorted(
|
||||
d for d in os.listdir(realization_root)
|
||||
if re.match(r'^\d{4}-\d{2}$', d) and os.path.isdir(os.path.join(realization_root, d))
|
||||
)
|
||||
print(f'Periods to load: {periods}')
|
||||
|
||||
for period in periods:
|
||||
year, month = map(int, period.split('-'))
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
snap = f'{year:04d}-{month:02d}-{last_day:02d}'
|
||||
|
||||
# Ensure parent snapshot row
|
||||
psql(f"INSERT INTO domrf_snapshots(snapshot_date) VALUES ({esc(snap)}) ON CONFLICT DO NOTHING;")
|
||||
|
||||
period_dir = os.path.join(realization_root, period)
|
||||
n_real = load_realization(period_dir, snap)
|
||||
|
||||
sr_dir = os.path.join(ROOT, 'sold_ready', period)
|
||||
n_idx = n_brk = n_dyn = 0
|
||||
if os.path.isdir(sr_dir):
|
||||
n_idx, n_brk, n_dyn = load_sold_ready(sr_dir, snap, year, month)
|
||||
|
||||
print(f'{period} (snap={snap}): realization={n_real} '
|
||||
f'sr_index={n_idx} sr_breakdown={n_brk} sr_dynamics={n_dyn}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
37
data/sql/42_schema_breakdown_widen.sql
Normal file
37
data/sql/42_schema_breakdown_widen.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
-- Replace JSONB `extra` column in domrf_sold_ready_breakdown with explicit
|
||||
-- typed columns. Each chart_type has exactly one discriminator field:
|
||||
-- • foChart → fo_cd (already existed)
|
||||
-- • regionChart → region_cd
|
||||
-- • devChart → dev_calc_group_id (e.g. '6072_0' = Самолет)
|
||||
-- • devValueChart → dev_group_value ('do10','do25','do50',...)
|
||||
-- • cityPopulationChart→ city_population ('do50','do100','do250',...)
|
||||
-- • classChart → obj_class ('typical','comfort','business','elite')
|
||||
-- • readyYearChart → ready_year (INT, plan year)
|
||||
-- The extra JSONB was a dumping ground for these — split them out.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE domrf_sold_ready_breakdown
|
||||
ADD COLUMN IF NOT EXISTS region_cd INT,
|
||||
ADD COLUMN IF NOT EXISTS dev_calc_group_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS dev_group_value TEXT,
|
||||
ADD COLUMN IF NOT EXISTS city_population TEXT,
|
||||
ADD COLUMN IF NOT EXISTS obj_class TEXT,
|
||||
ADD COLUMN IF NOT EXISTS ready_year INT;
|
||||
|
||||
ALTER TABLE domrf_sold_ready_breakdown DROP COLUMN IF EXISTS extra;
|
||||
|
||||
COMMENT ON COLUMN domrf_sold_ready_breakdown.region_cd IS
|
||||
'Rosstat region code, populated for chart_type=regionChart';
|
||||
COMMENT ON COLUMN domrf_sold_ready_breakdown.dev_calc_group_id IS
|
||||
'Developer ID (matches domrf_developers.developer_id), for chart_type=devChart';
|
||||
COMMENT ON COLUMN domrf_sold_ready_breakdown.dev_group_value IS
|
||||
'Size bucket key for chart_type=devValueChart (do10/do25/do50/...)';
|
||||
COMMENT ON COLUMN domrf_sold_ready_breakdown.city_population IS
|
||||
'Population bucket key for chart_type=cityPopulationChart (do50/do100/...)';
|
||||
COMMENT ON COLUMN domrf_sold_ready_breakdown.obj_class IS
|
||||
'Housing class for chart_type=classChart (typical/comfort/business/elite)';
|
||||
COMMENT ON COLUMN domrf_sold_ready_breakdown.ready_year IS
|
||||
'Plan-completion year for chart_type=readyYearChart';
|
||||
|
||||
COMMIT;
|
||||
193
data/sql/43_anton_import.sql
Normal file
193
data/sql/43_anton_import.sql
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
-- Import unique tables from Anton's domrf.db (SQLite, 23.5MB).
|
||||
-- Skipped duplicates: regions, federal_districts, mortgage_rates (empty),
|
||||
-- escrow_banks, rc_classes/dev_size/city_pop, rc_dynamics_rf, egrn_deals
|
||||
-- (we have raw rosreestr_deals — 4.97M rows, 32× larger).
|
||||
-- developers (8 618) handled separately via merge into domrf_developers.
|
||||
|
||||
-- ── DOM.РФ /сервисы/api/kn/* — поквартирный прайс + ЖК ─────────────────────
|
||||
CREATE TABLE IF NOT EXISTS domrf_kn_flats (
|
||||
id BIGINT PRIMARY KEY,
|
||||
ods_id TEXT,
|
||||
flat_type TEXT,
|
||||
flat_number TEXT,
|
||||
is_studio BOOLEAN,
|
||||
total_area NUMERIC,
|
||||
living_area NUMERIC,
|
||||
rooms INT,
|
||||
status TEXT, -- free | booked | sold
|
||||
price_rub BIGINT,
|
||||
price_per_m2 NUMERIC,
|
||||
floor INT,
|
||||
num_floors INT,
|
||||
obj_id BIGINT, -- FK target → domrf_kn_objects.obj_id
|
||||
city TEXT,
|
||||
region_cd INT,
|
||||
obj_name TEXT,
|
||||
snapshot_date DATE NOT NULL DEFAULT CURRENT_DATE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_kn_flats_obj ON domrf_kn_flats (obj_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kn_flats_status ON domrf_kn_flats (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_kn_flats_region ON domrf_kn_flats (region_cd);
|
||||
CREATE INDEX IF NOT EXISTS idx_kn_flats_rooms ON domrf_kn_flats (rooms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domrf_kn_objects (
|
||||
obj_id BIGINT PRIMARY KEY,
|
||||
hobj_id BIGINT,
|
||||
comm_name TEXT,
|
||||
addr TEXT,
|
||||
short_addr TEXT,
|
||||
region_cd INT,
|
||||
dev_id TEXT,
|
||||
dev_name TEXT,
|
||||
dev_inn TEXT,
|
||||
floor_min INT,
|
||||
floor_max INT,
|
||||
flat_count INT,
|
||||
square_living NUMERIC,
|
||||
ready_dt DATE,
|
||||
problem_flag TEXT,
|
||||
site_status TEXT,
|
||||
green_house BOOLEAN,
|
||||
escrow BOOLEAN,
|
||||
obj_class TEXT,
|
||||
wall_type TEXT,
|
||||
energy_eff TEXT,
|
||||
latitude NUMERIC,
|
||||
longitude NUMERIC,
|
||||
obj_status INT,
|
||||
is_prinzip BOOLEAN DEFAULT FALSE,
|
||||
is_ekb BOOLEAN DEFAULT FALSE,
|
||||
is_problem BOOLEAN DEFAULT FALSE,
|
||||
snapshot_date DATE NOT NULL DEFAULT CURRENT_DATE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_kn_obj_dev ON domrf_kn_objects (dev_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kn_obj_region ON domrf_kn_objects (region_cd);
|
||||
CREATE INDEX IF NOT EXISTS idx_kn_obj_prinzip ON domrf_kn_objects (is_prinzip);
|
||||
CREATE INDEX IF NOT EXISTS idx_kn_obj_ekb ON domrf_kn_objects (is_ekb);
|
||||
|
||||
-- ── ЦБ РФ ипотечная статистика (cbr.ru/statistics/bank_sector/mortgage/) ──
|
||||
CREATE TABLE IF NOT EXISTS cbr_mortgage_files (
|
||||
filename TEXT PRIMARY KEY,
|
||||
size_kb INT,
|
||||
description TEXT,
|
||||
url TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cbr_mortgage_series (
|
||||
series_id TEXT NOT NULL, -- e.g. '02_05_Debt_ind_в_рублях'
|
||||
region TEXT NOT NULL, -- 'rf' | 'sverdl'
|
||||
period TEXT NOT NULL, -- 'Feb 2019', 'Mar 2026', ...
|
||||
value NUMERIC,
|
||||
title TEXT,
|
||||
PRIMARY KEY (series_id, region, period)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cbr_period ON cbr_mortgage_series (period);
|
||||
CREATE INDEX IF NOT EXISTS idx_cbr_region ON cbr_mortgage_series (region);
|
||||
|
||||
-- ── Сбер ДомКлик DDF (PDF nov2025) — ключевые метрики ────────────────────
|
||||
CREATE TABLE IF NOT EXISTS sber_ddf_metrics (
|
||||
metric_key TEXT PRIMARY KEY,
|
||||
region TEXT,
|
||||
value_2019 NUMERIC,
|
||||
value_2025 NUMERIC,
|
||||
unit TEXT,
|
||||
source TEXT
|
||||
);
|
||||
|
||||
-- ── Yandex Realty ЕКБ ─────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS yandex_realty_zk (
|
||||
yid BIGINT PRIMARY KEY,
|
||||
name TEXT,
|
||||
developer TEXT,
|
||||
developer_id BIGINT,
|
||||
finished_obj INT,
|
||||
unfinished_obj INT,
|
||||
price_from NUMERIC,
|
||||
price_to NUMERIC,
|
||||
address TEXT,
|
||||
obj_class TEXT,
|
||||
latitude NUMERIC,
|
||||
longitude NUMERIC,
|
||||
snapshot_date DATE NOT NULL DEFAULT CURRENT_DATE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yandex_realty_class_prices (
|
||||
obj_class TEXT PRIMARY KEY,
|
||||
total_sites INT,
|
||||
price_per_m2_min NUMERIC,
|
||||
price_per_m2_max NUMERIC,
|
||||
snapshot_date DATE NOT NULL DEFAULT CURRENT_DATE
|
||||
);
|
||||
|
||||
-- ── Geo Свердл/ЕКБ ─────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS ekb_districts (
|
||||
district_name TEXT PRIMARY KEY,
|
||||
population INT,
|
||||
zk_count INT,
|
||||
flat_count INT,
|
||||
area_m2 NUMERIC,
|
||||
median_price_per_m2 NUMERIC,
|
||||
mean_price_per_m2 NUMERIC
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ekb_metro (
|
||||
station_name TEXT PRIMARY KEY,
|
||||
zk_count INT,
|
||||
median_price_per_m2 NUMERIC
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sv_cities (
|
||||
city_name TEXT PRIMARY KEY,
|
||||
zk_count INT,
|
||||
flat_count INT,
|
||||
area_m2 NUMERIC,
|
||||
median_price_per_m2 NUMERIC
|
||||
);
|
||||
|
||||
-- ── Pre-aggregated Свердл developer rankings (топ-50, март 2026) ──────────
|
||||
CREATE TABLE IF NOT EXISTS sv_dev_sales (
|
||||
rank INT,
|
||||
dev_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
total_square INT,
|
||||
sold_perc NUMERIC,
|
||||
unsold_perc NUMERIC,
|
||||
unopened_perc NUMERIC,
|
||||
rep_period TEXT NOT NULL, -- 'YYYY-MM' (e.g. '2026-03')
|
||||
PRIMARY KEY (dev_id, rep_period)
|
||||
);
|
||||
|
||||
-- ── Developer growth 2024→2025 (РФ + Свердл) ───────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS domrf_dev_growth (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dev_id TEXT,
|
||||
dev_name TEXT NOT NULL,
|
||||
area_2024 NUMERIC,
|
||||
area_2025 NUMERIC,
|
||||
delta_pct NUMERIC,
|
||||
region TEXT, -- 'rf' | 'sverdl'
|
||||
UNIQUE (dev_name, region)
|
||||
);
|
||||
|
||||
-- ── Росреестр rent deals Свердл (1071 договоров аренды) ──────────────────
|
||||
CREATE TABLE IF NOT EXISTS rosreestr_rent_deals (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
quarter TEXT NOT NULL, -- 'Q1 2026'
|
||||
obj_type TEXT, -- '002001001000' и т.п.
|
||||
cad_quarter TEXT, -- '66:02:1401003'
|
||||
area NUMERIC,
|
||||
rent_rub NUMERIC,
|
||||
rent_per_m2 NUMERIC,
|
||||
duration_months INT,
|
||||
region_cd INT DEFAULT 66
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rent_quarter ON rosreestr_rent_deals (quarter);
|
||||
CREATE INDEX IF NOT EXISTS idx_rent_cadq ON rosreestr_rent_deals (cad_quarter);
|
||||
CREATE INDEX IF NOT EXISTS idx_rent_region ON rosreestr_rent_deals (region_cd);
|
||||
|
||||
COMMENT ON TABLE domrf_kn_flats IS 'DOM.РФ /сервисы/api/kn/flats per-flat catalog. 27 563 квартир РФ status=free/booked/sold с ценами. Источник: Anton domrf.db imported 27.04.2026.';
|
||||
COMMENT ON TABLE domrf_kn_objects IS 'DOM.РФ /сервисы/api/kn/object — реестр ЖК Свердл. 1 516 объектов с GPS, классом, эскроу-флагом. is_prinzip flag для 28 PRINZIP ЖК. Источник: Anton.';
|
||||
COMMENT ON TABLE cbr_mortgage_series IS 'ЦБ РФ статистика по ипотеке (XLSX cbr.ru/statistics/bank_sector/mortgage/). 47 серий × {rf|sverdl} × месяцы 2019-2026. Свердл Фев-2026: ставка 11%, +318 б.п. за месяц.';
|
||||
COMMENT ON TABLE sber_ddf_metrics IS 'Сбер ДомКлик DDF nov2025 PDF: affordability, demand index, миграция, повторные ипотечники, price gap 79/21%, quality shift.';
|
||||
COMMENT ON TABLE yandex_realty_zk IS 'Yandex Realty snapshot — 12 ЖК ЕКБ из INITIAL_STATE с классами/ценами. Полный snapshot пользовательских listings.';
|
||||
COMMENT ON TABLE rosreestr_rent_deals IS 'Договоры аренды Свердл Q1 2026 (1071) из Росреестра data-sets.';
|
||||
298
data/sql/44_import_anton_db.py
Normal file
298
data/sql/44_import_anton_db.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"""Migrate Anton's domrf.db (SQLite, 23.5 MB) into prod Postgres.
|
||||
|
||||
Reads: C:\\Users\\user\\Downloads\\Telegram Desktop\\db\\db\\domrf.db
|
||||
Writes: 13 new tables created by 43_anton_import.sql + merges developers.
|
||||
|
||||
Skipped tables (duplicates of our existing data):
|
||||
• regions, federal_districts (we have domrf_regions/_federal_districts)
|
||||
• mortgage_rates (empty in Anton's DB, we have domrf_mortgage_rates)
|
||||
• escrow_banks (we have domrf_escrow_banks, 28 vs 27)
|
||||
• rc_classes/dev_size/city_pop (in our domrf_sold_ready_breakdown)
|
||||
• rc_dynamics_rf (in our domrf_sold_ready_dynamics)
|
||||
• egrn_deals (we have raw rosreestr_deals — 4.97M rows, far richer)
|
||||
• _meta, _sources, sqlite_sequence, launch_5y(empty)
|
||||
|
||||
Special handling:
|
||||
• developers (8 618) — merge missing rows into domrf_developers (we have 2 734)
|
||||
"""
|
||||
import os, sys, sqlite3, subprocess, datetime
|
||||
|
||||
SQLITE_PATH = r"C:\Users\user\Downloads\Telegram Desktop\db\db\domrf.db"
|
||||
PG_HOST = os.environ.get('PG_HOST', 'host.docker.internal')
|
||||
PG_PORT = os.environ.get('PG_PORT', '15432')
|
||||
PG_USER = os.environ.get('PG_USER', 'gendesign')
|
||||
PG_DB = os.environ.get('PG_DB', 'gendesign')
|
||||
PG_PASS = os.environ.get('PGPASSWORD', '2J2SBPMKuS998fiwhtQqDhMI')
|
||||
|
||||
|
||||
def psql(sql):
|
||||
cmd = ['docker', 'run', '--rm', '-i', '-e', f'PGPASSWORD={PG_PASS}',
|
||||
'postgres:16-alpine', 'psql',
|
||||
'-h', PG_HOST, '-p', PG_PORT, '-U', PG_USER, '-d', PG_DB,
|
||||
'-v', 'ON_ERROR_STOP=1', '--quiet']
|
||||
res = subprocess.run(cmd, input=sql, capture_output=True, text=True, encoding='utf-8')
|
||||
if res.returncode != 0:
|
||||
print('psql FAILED:', (res.stderr or '')[-2000:], file=sys.stderr)
|
||||
raise SystemExit(res.returncode)
|
||||
return res.stdout
|
||||
|
||||
|
||||
def esc_val(v):
|
||||
if v is None:
|
||||
return 'NULL'
|
||||
if isinstance(v, bool):
|
||||
return 'TRUE' if v else 'FALSE'
|
||||
if isinstance(v, (int, float)):
|
||||
if isinstance(v, float) and v != v: # NaN
|
||||
return 'NULL'
|
||||
return str(v)
|
||||
return "'" + str(v).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def to_pg_bool(v):
|
||||
if v is None:
|
||||
return 'NULL'
|
||||
return 'TRUE' if v else 'FALSE'
|
||||
|
||||
|
||||
def to_pg_date(v):
|
||||
"""SQLite stores dates as TEXT. Try parse YYYY-MM-DD; else NULL."""
|
||||
if v is None or v == '':
|
||||
return 'NULL'
|
||||
s = str(v)[:10]
|
||||
try:
|
||||
datetime.date.fromisoformat(s)
|
||||
return f"'{s}'"
|
||||
except Exception:
|
||||
return 'NULL'
|
||||
|
||||
|
||||
def chunked_insert(rows, table, cols, conflict_cols=None, batch=500, do_update=False):
|
||||
if not rows:
|
||||
return 0
|
||||
total = 0
|
||||
on_conflict = ''
|
||||
if conflict_cols:
|
||||
if do_update:
|
||||
update = ', '.join(f'{c}=EXCLUDED.{c}' for c in cols if c not in conflict_cols)
|
||||
on_conflict = f' ON CONFLICT ({",".join(conflict_cols)}) DO UPDATE SET {update}'
|
||||
else:
|
||||
on_conflict = f' ON CONFLICT ({",".join(conflict_cols)}) DO NOTHING'
|
||||
for i in range(0, len(rows), batch):
|
||||
chunk = rows[i:i + batch]
|
||||
sql = (f"INSERT INTO {table} ({','.join(cols)}) VALUES "
|
||||
+ ','.join(chunk) + on_conflict + ';')
|
||||
psql(sql)
|
||||
total += len(chunk)
|
||||
return total
|
||||
|
||||
|
||||
def migrate_kn_flats(con):
|
||||
rows = []
|
||||
for r in con.execute('SELECT id, ods_id, type, number, is_studio, total_area, '
|
||||
'living_area, rooms, status, price_rub, price_per_m2, '
|
||||
'floor, num_floors, obj_id, city, region_cd, obj_name '
|
||||
'FROM flats'):
|
||||
(fid, ods, ftype, fnum, studio, ta, la, rooms, status, pr, ppm,
|
||||
fl, nfl, obj_id, city, region, obj_name) = r
|
||||
rows.append(
|
||||
f"({fid},{esc_val(ods)},{esc_val(ftype)},{esc_val(fnum)},"
|
||||
f"{to_pg_bool(studio)},{esc_val(ta)},{esc_val(la)},{esc_val(rooms)},"
|
||||
f"{esc_val(status)},{esc_val(pr)},{esc_val(ppm)},{esc_val(fl)},"
|
||||
f"{esc_val(nfl)},{esc_val(obj_id)},{esc_val(city)},{esc_val(region)},"
|
||||
f"{esc_val(obj_name)},DEFAULT)"
|
||||
)
|
||||
return chunked_insert(
|
||||
rows, 'domrf_kn_flats',
|
||||
['id','ods_id','flat_type','flat_number','is_studio','total_area','living_area',
|
||||
'rooms','status','price_rub','price_per_m2','floor','num_floors','obj_id',
|
||||
'city','region_cd','obj_name','snapshot_date'],
|
||||
conflict_cols=['id'], do_update=True,
|
||||
)
|
||||
|
||||
|
||||
def migrate_kn_objects(con):
|
||||
rows = []
|
||||
for r in con.execute('SELECT obj_id, hobj_id, comm_name, addr, short_addr, region_cd, '
|
||||
'dev_id, dev_name, dev_inn, floor_min, floor_max, flat_count, '
|
||||
'square_living, ready_dt, problem_flag, site_status, green_house, '
|
||||
'escrow, obj_class, wall_type, energy_eff, latitude, longitude, '
|
||||
'obj_status, is_prinzip, is_ekb, is_problem FROM objects'):
|
||||
(oid, hid, cn, addr, sa, rc, did, dn, dinn, fmn, fmx, fc, sl, rdt,
|
||||
pf, ss, gh, esc, oc, wt, ee, lat, lon, ostat, ipr, iekb, iprob) = r
|
||||
rows.append(
|
||||
f"({oid},{esc_val(hid)},{esc_val(cn)},{esc_val(addr)},{esc_val(sa)},"
|
||||
f"{esc_val(rc)},{esc_val(did)},{esc_val(dn)},{esc_val(dinn)},"
|
||||
f"{esc_val(fmn)},{esc_val(fmx)},{esc_val(fc)},{esc_val(sl)},"
|
||||
f"{to_pg_date(rdt)},{esc_val(pf)},{esc_val(ss)},"
|
||||
f"{to_pg_bool(gh)},{to_pg_bool(esc)},{esc_val(oc)},{esc_val(wt)},"
|
||||
f"{esc_val(ee)},{esc_val(lat)},{esc_val(lon)},{esc_val(ostat)},"
|
||||
f"{to_pg_bool(ipr)},{to_pg_bool(iekb)},{to_pg_bool(iprob)},DEFAULT)"
|
||||
)
|
||||
return chunked_insert(
|
||||
rows, 'domrf_kn_objects',
|
||||
['obj_id','hobj_id','comm_name','addr','short_addr','region_cd','dev_id','dev_name',
|
||||
'dev_inn','floor_min','floor_max','flat_count','square_living','ready_dt',
|
||||
'problem_flag','site_status','green_house','escrow','obj_class','wall_type',
|
||||
'energy_eff','latitude','longitude','obj_status','is_prinzip','is_ekb','is_problem',
|
||||
'snapshot_date'],
|
||||
conflict_cols=['obj_id'], do_update=True,
|
||||
)
|
||||
|
||||
|
||||
def migrate_simple(con, src, dst, cols_src, cols_dst, conflict_cols, do_update=True,
|
||||
transforms=None):
|
||||
"""Generic migration: SELECT cols from src → INSERT into dst."""
|
||||
transforms = transforms or {}
|
||||
rows = []
|
||||
for r in con.execute(f'SELECT {",".join(cols_src)} FROM "{src}"'):
|
||||
vals = []
|
||||
for col_name, raw in zip(cols_src, r):
|
||||
if col_name in transforms:
|
||||
vals.append(transforms[col_name](raw))
|
||||
else:
|
||||
vals.append(esc_val(raw))
|
||||
rows.append('(' + ','.join(vals) + ')')
|
||||
return chunked_insert(rows, dst, cols_dst, conflict_cols=conflict_cols, do_update=do_update)
|
||||
|
||||
|
||||
def merge_developers(con):
|
||||
"""Anton has 8 618 developers (incl. archived); we have 2 734.
|
||||
INSERT only IDs that don't exist in our table — no update on existing."""
|
||||
# Pull existing developer_ids from prod via psql
|
||||
existing_ids = set()
|
||||
res = psql('SELECT developer_id FROM domrf_developers;')
|
||||
for line in res.splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith('---') and 'developer_id' not in line and 'rows' not in line and '(' not in line:
|
||||
existing_ids.add(line)
|
||||
print(f' existing developers in prod: {len(existing_ids)}')
|
||||
|
||||
new_rows = []
|
||||
skipped = 0
|
||||
cur = con.execute('SELECT id, name FROM developers WHERE id IS NOT NULL')
|
||||
for did, name in cur:
|
||||
if not did or not name:
|
||||
continue
|
||||
if str(did) in existing_ids:
|
||||
skipped += 1
|
||||
continue
|
||||
new_rows.append(f"({esc_val(did)},{esc_val(name)})")
|
||||
print(f' skipped (already in prod): {skipped}')
|
||||
print(f' to INSERT (new from Anton): {len(new_rows)}')
|
||||
|
||||
return chunked_insert(
|
||||
new_rows, 'domrf_developers', ['developer_id', 'developer_name'],
|
||||
conflict_cols=['developer_id'], do_update=False,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
con = sqlite3.connect(SQLITE_PATH)
|
||||
|
||||
print('--- domrf_kn_flats ---')
|
||||
n = migrate_kn_flats(con)
|
||||
print(f' loaded {n} flats')
|
||||
|
||||
print('--- domrf_kn_objects ---')
|
||||
n = migrate_kn_objects(con)
|
||||
print(f' loaded {n} objects')
|
||||
|
||||
print('--- cbr_mortgage_files ---')
|
||||
n = migrate_simple(con, 'cbr_files', 'cbr_mortgage_files',
|
||||
['filename', 'size_kb', 'description', 'url'],
|
||||
['filename', 'size_kb', 'description', 'url'],
|
||||
conflict_cols=['filename'])
|
||||
print(f' loaded {n}')
|
||||
|
||||
print('--- cbr_mortgage_series ---')
|
||||
n = migrate_simple(con, 'cbr_series', 'cbr_mortgage_series',
|
||||
['series_id', 'region', 'period', 'value', 'title'],
|
||||
['series_id', 'region', 'period', 'value', 'title'],
|
||||
conflict_cols=['series_id', 'region', 'period'])
|
||||
print(f' loaded {n}')
|
||||
|
||||
print('--- sber_ddf_metrics ---')
|
||||
n = migrate_simple(con, 'ddf_metrics', 'sber_ddf_metrics',
|
||||
['metric_key', 'region', 'value_2019', 'value_2025', 'unit', 'source'],
|
||||
['metric_key', 'region', 'value_2019', 'value_2025', 'unit', 'source'],
|
||||
conflict_cols=['metric_key'])
|
||||
print(f' loaded {n}')
|
||||
|
||||
print('--- yandex_realty_zk ---')
|
||||
n = migrate_simple(con, 'yandex_zk', 'yandex_realty_zk',
|
||||
['yid', 'name', 'developer', 'developer_id', 'finished_obj', 'unfinished_obj',
|
||||
'price_from', 'price_to', 'address', 'obj_class', 'latitude', 'longitude'],
|
||||
['yid', 'name', 'developer', 'developer_id', 'finished_obj', 'unfinished_obj',
|
||||
'price_from', 'price_to', 'address', 'obj_class', 'latitude', 'longitude'],
|
||||
conflict_cols=['yid'])
|
||||
print(f' loaded {n}')
|
||||
|
||||
print('--- yandex_realty_class_prices ---')
|
||||
n = migrate_simple(con, 'yandex_class_prices', 'yandex_realty_class_prices',
|
||||
['obj_class', 'total_sites', 'price_per_m2_min', 'price_per_m2_max'],
|
||||
['obj_class', 'total_sites', 'price_per_m2_min', 'price_per_m2_max'],
|
||||
conflict_cols=['obj_class'])
|
||||
print(f' loaded {n}')
|
||||
|
||||
print('--- ekb_districts ---')
|
||||
n = migrate_simple(con, 'ekb_districts', 'ekb_districts',
|
||||
['name', 'population', 'zk_count', 'flat_count', 'area_m2',
|
||||
'median_price_per_m2', 'mean_price_per_m2'],
|
||||
['district_name', 'population', 'zk_count', 'flat_count', 'area_m2',
|
||||
'median_price_per_m2', 'mean_price_per_m2'],
|
||||
conflict_cols=['district_name'])
|
||||
print(f' loaded {n}')
|
||||
|
||||
print('--- ekb_metro ---')
|
||||
n = migrate_simple(con, 'ekb_metro', 'ekb_metro',
|
||||
['station_name', 'zk_count', 'median_price_per_m2'],
|
||||
['station_name', 'zk_count', 'median_price_per_m2'],
|
||||
conflict_cols=['station_name'])
|
||||
print(f' loaded {n}')
|
||||
|
||||
print('--- sv_cities ---')
|
||||
n = migrate_simple(con, 'sv_cities', 'sv_cities',
|
||||
['city_name', 'zk_count', 'flat_count', 'area_m2', 'median_price_per_m2'],
|
||||
['city_name', 'zk_count', 'flat_count', 'area_m2', 'median_price_per_m2'],
|
||||
conflict_cols=['city_name'])
|
||||
print(f' loaded {n}')
|
||||
|
||||
print('--- sv_dev_sales ---')
|
||||
n = migrate_simple(con, 'sv_dev_sales', 'sv_dev_sales',
|
||||
['rank', 'dev_id', 'name', 'total_square', 'sold_perc', 'unsold_perc',
|
||||
'unopened_perc', 'rep_period'],
|
||||
['rank', 'dev_id', 'name', 'total_square', 'sold_perc', 'unsold_perc',
|
||||
'unopened_perc', 'rep_period'],
|
||||
conflict_cols=['dev_id', 'rep_period'])
|
||||
print(f' loaded {n}')
|
||||
|
||||
print('--- domrf_dev_growth ---')
|
||||
n = migrate_simple(con, 'dev_growth', 'domrf_dev_growth',
|
||||
['dev_id', 'dev_name', 'area_2024', 'area_2025', 'delta_pct', 'region'],
|
||||
['dev_id', 'dev_name', 'area_2024', 'area_2025', 'delta_pct', 'region'],
|
||||
conflict_cols=['dev_name', 'region'])
|
||||
print(f' loaded {n}')
|
||||
|
||||
print('--- rosreestr_rent_deals ---')
|
||||
rows = []
|
||||
for r in con.execute('SELECT quarter, obj_type, cad_quarter, area, rent_rub, '
|
||||
'rent_per_m2, duration_months, region_cd FROM rent_deals'):
|
||||
rows.append('(' + ','.join(esc_val(v) for v in r) + ')')
|
||||
n = chunked_insert(
|
||||
rows, 'rosreestr_rent_deals',
|
||||
['quarter','obj_type','cad_quarter','area','rent_rub','rent_per_m2',
|
||||
'duration_months','region_cd'],
|
||||
conflict_cols=None, # no PK conflict — id is BIGSERIAL
|
||||
)
|
||||
print(f' loaded {n}')
|
||||
|
||||
print('--- merge developers ---')
|
||||
n = merge_developers(con)
|
||||
print(f' inserted {n} new developers')
|
||||
|
||||
con.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
130
data/sql/45_relations_v2.sql
Normal file
130
data/sql/45_relations_v2.sql
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
-- Дополнительные связи между таблицами (27.04.2026 evening) + analytical views
|
||||
-- для сквозного анализа: квартиры ↔ ЖК ↔ застройщики ↔ регионы.
|
||||
--
|
||||
-- Pre-checked orphans:
|
||||
-- • domrf_kn_objects.region_cd → regions: 1516/1516 valid ✓
|
||||
-- • domrf_kn_objects.dev_id → developers: 34 orphans (NULLed before FK)
|
||||
-- • sv_dev_sales.dev_id → developers: 50/50 valid ✓
|
||||
-- • rosreestr_rent_deals.region_cd → regions: 1071/1071 valid ✓
|
||||
-- • dev_growth.dev_id → developers: ALL NULL — no FK possible
|
||||
-- • domrf_kn_flats.obj_id → kn_objects.obj_id: ALL 27563 orphans
|
||||
-- (different ID systems: /api/kn/flats uses hobj_id-style codes, only
|
||||
-- 2 unique obj_id from flats match objects.hobj_id). NO FK; join only
|
||||
-- via obj_name text match (best-effort) or aggregate by obj_name.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. NULL out 34 orphan dev_id values in kn_objects before adding FK
|
||||
UPDATE domrf_kn_objects SET dev_id=NULL
|
||||
WHERE dev_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM domrf_developers d WHERE d.developer_id=domrf_kn_objects.dev_id);
|
||||
|
||||
-- 2. Add 4 clean FKs (31 → 35 total)
|
||||
ALTER TABLE domrf_kn_objects
|
||||
ADD CONSTRAINT fk_kn_obj_region
|
||||
FOREIGN KEY (region_cd) REFERENCES domrf_regions(region_id) ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT fk_kn_obj_developer
|
||||
FOREIGN KEY (dev_id) REFERENCES domrf_developers(developer_id) ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE sv_dev_sales
|
||||
ADD CONSTRAINT fk_sv_dev_sales_developer
|
||||
FOREIGN KEY (dev_id) REFERENCES domrf_developers(developer_id) ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE rosreestr_rent_deals
|
||||
ADD CONSTRAINT fk_rent_deals_region
|
||||
FOREIGN KEY (region_cd) REFERENCES domrf_regions(region_id) ON UPDATE CASCADE;
|
||||
|
||||
-- 3. Analytical view: ЖК + dev + регион (use FK joins)
|
||||
CREATE OR REPLACE VIEW v_jk_with_developer AS
|
||||
SELECT o.obj_id, o.hobj_id, o.comm_name AS jk_name,
|
||||
o.addr, o.short_addr, o.region_cd, r.region_name,
|
||||
o.dev_id, COALESCE(d.developer_name, o.dev_name) AS dev_name,
|
||||
o.flat_count, o.square_living,
|
||||
o.obj_class, o.wall_type, o.energy_eff, o.escrow, o.green_house,
|
||||
o.ready_dt, o.site_status,
|
||||
o.is_prinzip, o.is_ekb, o.is_problem,
|
||||
o.latitude, o.longitude
|
||||
FROM domrf_kn_objects o
|
||||
LEFT JOIN domrf_developers d ON o.dev_id = d.developer_id
|
||||
LEFT JOIN domrf_regions r ON o.region_cd = r.region_id;
|
||||
|
||||
COMMENT ON VIEW v_jk_with_developer IS
|
||||
'ЖК Свердл с обогащением dev_name из канонического справочника + region_name. Использует FK fk_kn_obj_developer/region.';
|
||||
|
||||
-- 4. ЖК + поквартирные продажи (text match obj_name → only 2 ЖК matchятся,
|
||||
-- но view удобен для standalone-анализа kn_flats по ЖК).
|
||||
CREATE OR REPLACE VIEW v_jk_sales_summary AS
|
||||
SELECT obj_name AS jk_name,
|
||||
MIN(city) AS city,
|
||||
COUNT(*) AS flats_total,
|
||||
COUNT(*) FILTER (WHERE status='sold') AS flats_sold,
|
||||
COUNT(*) FILTER (WHERE status='booked') AS flats_booked,
|
||||
COUNT(*) FILTER (WHERE status='free') AS flats_free,
|
||||
ROUND((COUNT(*) FILTER (WHERE status='sold') * 100.0 / NULLIF(COUNT(*),0))::numeric, 1) AS sold_pct,
|
||||
ROUND(AVG(price_per_m2)::numeric, 0) AS avg_ppm,
|
||||
ROUND(AVG(total_area)::numeric, 1) AS avg_area_sqm,
|
||||
SUM(price_rub) AS total_value_rub
|
||||
FROM domrf_kn_flats
|
||||
WHERE obj_name IS NOT NULL
|
||||
GROUP BY obj_name;
|
||||
|
||||
COMMENT ON VIEW v_jk_sales_summary IS
|
||||
'Поквартирная агрегация по ЖК (имя): кол-во × free/booked/sold + avg цена/площадь. Источник: domrf_kn_flats (РФ-snapshot).';
|
||||
|
||||
-- 5. Портфель ЖК у каждого девелопера (через FK)
|
||||
CREATE OR REPLACE VIEW v_developer_portfolio AS
|
||||
SELECT d.developer_id, d.developer_name,
|
||||
COUNT(DISTINCT o.obj_id) AS jk_count,
|
||||
SUM(o.flat_count) AS jk_flats_total,
|
||||
ROUND(SUM(o.square_living)::numeric, 0) AS jk_sqm_total,
|
||||
COUNT(DISTINCT o.obj_id) FILTER (WHERE o.is_ekb=true) AS jk_ekb,
|
||||
COUNT(DISTINCT o.obj_id) FILTER (WHERE o.is_problem=true) AS jk_problem,
|
||||
COUNT(DISTINCT o.obj_id) FILTER (WHERE o.escrow=true) AS jk_escrow,
|
||||
COUNT(DISTINCT o.obj_id) FILTER (WHERE o.site_status='Сданные') AS jk_completed,
|
||||
COUNT(DISTINCT o.obj_id) FILTER (WHERE o.site_status='Строящиеся') AS jk_in_progress,
|
||||
MIN(o.ready_dt) AS earliest_ready, MAX(o.ready_dt) AS latest_ready
|
||||
FROM domrf_developers d
|
||||
LEFT JOIN domrf_kn_objects o ON o.dev_id = d.developer_id
|
||||
GROUP BY d.developer_id, d.developer_name;
|
||||
|
||||
COMMENT ON VIEW v_developer_portfolio IS
|
||||
'Портфель ЖК у каждого девелопера: total ЖК / квартир / м² + разбивка ЕКБ / problem / эскроу / Сданы/Строятся. Только Свердл (что в kn_objects).';
|
||||
|
||||
-- 6. Полный профиль девелопера = portfolio + квартирография + realization Свердл
|
||||
CREATE OR REPLACE VIEW v_developer_full_metrics AS
|
||||
SELECT
|
||||
p.developer_id, p.developer_name,
|
||||
p.jk_count, p.jk_flats_total, p.jk_sqm_total, p.jk_ekb,
|
||||
p.jk_completed, p.jk_in_progress, p.jk_escrow,
|
||||
da_total.flat_count AS agg_flats_total,
|
||||
da_one.flat_count AS agg_one_room,
|
||||
da_two.flat_count AS agg_two_room,
|
||||
da_three.flat_count AS agg_three_room,
|
||||
da_four.flat_count AS agg_four_plus,
|
||||
ROUND((da_one.flat_count * 100.0 / NULLIF(da_total.flat_count,0))::numeric, 1) AS pct_one,
|
||||
ROUND(((COALESCE(da_three.flat_count,0) + COALESCE(da_four.flat_count,0)) * 100.0
|
||||
/ NULLIF(da_total.flat_count,0))::numeric, 1) AS pct_three_plus,
|
||||
ROUND((da_total.area_sqm / NULLIF(da_total.flat_count,0))::numeric, 1) AS avg_area_sqm,
|
||||
rl.total_square AS sverdl_sqm,
|
||||
rl.sold_perc AS sverdl_sold_pct,
|
||||
rl.unsold_perc AS sverdl_unsold_pct,
|
||||
rl.price_avg AS sverdl_price_avg
|
||||
FROM v_developer_portfolio p
|
||||
LEFT JOIN domrf_developer_aggregates da_total
|
||||
ON da_total.developer_id=p.developer_id AND da_total.room_count_type='TOTAL' AND da_total.region_id=0
|
||||
LEFT JOIN domrf_developer_aggregates da_one
|
||||
ON da_one.developer_id=p.developer_id AND da_one.room_count_type='ONE' AND da_one.region_id=0
|
||||
LEFT JOIN domrf_developer_aggregates da_two
|
||||
ON da_two.developer_id=p.developer_id AND da_two.room_count_type='TWO' AND da_two.region_id=0
|
||||
LEFT JOIN domrf_developer_aggregates da_three
|
||||
ON da_three.developer_id=p.developer_id AND da_three.room_count_type='THREE'AND da_three.region_id=0
|
||||
LEFT JOIN domrf_developer_aggregates da_four
|
||||
ON da_four.developer_id=p.developer_id AND da_four.room_count_type='FOUR' AND da_four.region_id=0
|
||||
LEFT JOIN domrf_realization rl
|
||||
ON rl.subject=p.developer_id AND rl.endpoint_type='developer' AND rl.region_code=66
|
||||
AND rl.type_square='total' AND rl.snapshot_date='2026-04-27';
|
||||
|
||||
COMMENT ON VIEW v_developer_full_metrics IS
|
||||
'Единый профиль девелопера: portfolio (ЖК) + квартирография (1-к/2-к/3-к доли) + realization Свердл. Best query для PRINZIP/Брусника/конкурентов сравнения.';
|
||||
|
||||
COMMIT;
|
||||
File diff suppressed because one or more lines are too long
10
sshkey.txt
10
sshkey.txt
|
|
@ -80,3 +80,13 @@ pre-commit install
|
|||
$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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue