add data
This commit is contained in:
parent
a2dcd9844a
commit
c13cbaef2e
18 changed files with 2409 additions and 0 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -21,3 +21,9 @@ node_modules/
|
||||||
|
|
||||||
# Claude Code local state
|
# Claude Code local state
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Local data (raw CSVs from Rosreestr/etc — too large for git)
|
||||||
|
data/raw/
|
||||||
|
*.csv
|
||||||
|
*.csv.zip
|
||||||
|
!backend/db/init/*.sql
|
||||||
|
|
|
||||||
92
data/sql/01_schema_rosreestr_deals.sql
Normal file
92
data/sql/01_schema_rosreestr_deals.sql
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
-- Rosreestr open dataset: dataset_СДЕЛКИ (registered real estate deals).
|
||||||
|
-- Source: https://rosreestr.gov.ru/data-sets/{Q}%20квартал%20{YYYY}г./dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{Q}.csv.zip
|
||||||
|
-- Encoding: UTF-8. Separator: '~' (since 2024 Q4) or ';' (2024 Q3 only).
|
||||||
|
-- Granularity: aggregated rows — same (region+district+city+cad_quarter+street+type+material+year+floor+purpose+area+doc_type) deals
|
||||||
|
-- collapsed into a single row with `deal_count` (was `number`) and AVERAGE price.
|
||||||
|
-- Time granularity: quarter (period_start_date = first day of quarter).
|
||||||
|
-- Coverage: ALL registered transactions (DKP=secondary+land+commercial, DDU=primary in МКД).
|
||||||
|
-- Excluded: gratuitous deals (gift/inheritance), buyer/seller PII, exact deal date, apartment number, room count, mortgage flag.
|
||||||
|
-- Data quality notes (observed in 7 loaded quarters):
|
||||||
|
-- * deal_price может быть мусорным с десятками знаков — фильтруем при INSERT
|
||||||
|
-- * year_build бывает с датой типа '09.07.1988' — оставляем NULL если не 4-цифровой год
|
||||||
|
-- * wall_material_code/realestate_type_code иногда '"code1;code2"' — поэтому TEXT вместо VARCHAR(15)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rosreestr_deals (
|
||||||
|
id BIGSERIAL,
|
||||||
|
-- Origin
|
||||||
|
source_quarter VARCHAR(8) NOT NULL, -- '2024Q3' for traceability
|
||||||
|
period_start_date DATE NOT NULL, -- first day of the quarter (partition key)
|
||||||
|
-- Geo
|
||||||
|
region_code SMALLINT NOT NULL, -- 66 = Свердловская обл, 77 = Москва, ...
|
||||||
|
okato TEXT, -- ОКАТО (often '-' or 11 digits)
|
||||||
|
district TEXT, -- муниципальный район/округ
|
||||||
|
city TEXT, -- населённый пункт (часто пустой)
|
||||||
|
quarter_cad_number VARCHAR(30) NOT NULL, -- '66:41:0506001' — гео-ключ для JOIN с ПКК / NSPD
|
||||||
|
street TEXT,
|
||||||
|
-- Object
|
||||||
|
realestate_type_code TEXT, -- 002001001000=ЗУ | 002001002000=Здание | 002001003000=Помещение | 002001009000=Машиноместо
|
||||||
|
wall_material_code TEXT, -- справочник 126_УНСИ (может быть multi-value)
|
||||||
|
purpose_code TEXT, -- 66/67/148_УНСИ
|
||||||
|
year_build SMALLINT,
|
||||||
|
floor TEXT,
|
||||||
|
area NUMERIC, -- м²
|
||||||
|
-- Deal
|
||||||
|
doc_type VARCHAR(8) NOT NULL, -- 'ДКП' | 'ДДУ'
|
||||||
|
deal_price NUMERIC, -- средняя если deal_count > 1
|
||||||
|
currency VARCHAR(20) DEFAULT 'рубль',
|
||||||
|
deal_count INT NOT NULL DEFAULT 1, -- было `number`
|
||||||
|
-- Computed
|
||||||
|
price_per_sqm NUMERIC GENERATED ALWAYS AS (
|
||||||
|
CASE WHEN area > 0 AND deal_price > 0 THEN deal_price / area ELSE NULL END
|
||||||
|
) STORED,
|
||||||
|
-- Audit
|
||||||
|
loaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (id, period_start_date)
|
||||||
|
) PARTITION BY RANGE (period_start_date);
|
||||||
|
|
||||||
|
-- Quarterly partitions for available data (2024 Q3 — 2026 Q1)
|
||||||
|
CREATE TABLE IF NOT EXISTS rosreestr_deals_2024q3 PARTITION OF rosreestr_deals
|
||||||
|
FOR VALUES FROM ('2024-07-01') TO ('2024-10-01');
|
||||||
|
CREATE TABLE IF NOT EXISTS rosreestr_deals_2024q4 PARTITION OF rosreestr_deals
|
||||||
|
FOR VALUES FROM ('2024-10-01') TO ('2025-01-01');
|
||||||
|
CREATE TABLE IF NOT EXISTS rosreestr_deals_2025q1 PARTITION OF rosreestr_deals
|
||||||
|
FOR VALUES FROM ('2025-01-01') TO ('2025-04-01');
|
||||||
|
CREATE TABLE IF NOT EXISTS rosreestr_deals_2025q2 PARTITION OF rosreestr_deals
|
||||||
|
FOR VALUES FROM ('2025-04-01') TO ('2025-07-01');
|
||||||
|
CREATE TABLE IF NOT EXISTS rosreestr_deals_2025q3 PARTITION OF rosreestr_deals
|
||||||
|
FOR VALUES FROM ('2025-07-01') TO ('2025-10-01');
|
||||||
|
CREATE TABLE IF NOT EXISTS rosreestr_deals_2025q4 PARTITION OF rosreestr_deals
|
||||||
|
FOR VALUES FROM ('2025-10-01') TO ('2026-01-01');
|
||||||
|
CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q1 PARTITION OF rosreestr_deals
|
||||||
|
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS rosreestr_deals_region_cq_idx
|
||||||
|
ON rosreestr_deals (region_code, quarter_cad_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS rosreestr_deals_period_brin
|
||||||
|
ON rosreestr_deals USING BRIN (period_start_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS rosreestr_deals_dealtype_idx
|
||||||
|
ON rosreestr_deals (doc_type) WHERE doc_type IN ('ДКП', 'ДДУ');
|
||||||
|
CREATE INDEX IF NOT EXISTS rosreestr_deals_realtype_idx
|
||||||
|
ON rosreestr_deals (realestate_type_code);
|
||||||
|
|
||||||
|
-- Staging: один общий unlogged-буфер. Перед каждым кварталом TRUNCATE.
|
||||||
|
CREATE UNLOGGED TABLE IF NOT EXISTS rosreestr_deals_staging (
|
||||||
|
deal_count INT,
|
||||||
|
okato TEXT,
|
||||||
|
region_code SMALLINT,
|
||||||
|
district TEXT,
|
||||||
|
city TEXT,
|
||||||
|
quarter_cad_number TEXT,
|
||||||
|
street TEXT,
|
||||||
|
realestate_type_code TEXT,
|
||||||
|
wall_material_code TEXT,
|
||||||
|
year_build TEXT,
|
||||||
|
floor TEXT,
|
||||||
|
purpose_code TEXT,
|
||||||
|
area NUMERIC,
|
||||||
|
period_start_date DATE,
|
||||||
|
deal_price NUMERIC,
|
||||||
|
currency VARCHAR(20),
|
||||||
|
doc_type VARCHAR(8)
|
||||||
|
);
|
||||||
96
data/sql/02_load_all_quarters.sh
Normal file
96
data/sql/02_load_all_quarters.sh
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Loads all 7 quarters of dataset_СДЕЛКИ into rosreestr_deals via staging.
|
||||||
|
# Q3 2024 uses ';' separator, all later quarters use '~'.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash 02_load_all_quarters.sh # local test container (gd_test_pg)
|
||||||
|
# PG_HOST=localhost PG_PORT=15432 PGPASSWORD=... bash 02_load_all_quarters.sh # remote (via SSH tunnel)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -n "${PG_HOST:-}" ]]; then
|
||||||
|
# Remote / TCP mode (e.g. prod via SSH tunnel)
|
||||||
|
PG_PORT="${PG_PORT:-5432}"
|
||||||
|
PG_USER="${PG_USER:-gendesign}"
|
||||||
|
PG_DB="${PG_DB:-gendesign}"
|
||||||
|
PSQL="docker run --rm -i -e PGPASSWORD postgres:16-alpine psql -h host.docker.internal -p $PG_PORT -U $PG_USER -d $PG_DB -v ON_ERROR_STOP=1"
|
||||||
|
PSQL_PIPE="docker run --rm -i -e PGPASSWORD postgres:16-alpine psql -h host.docker.internal -p $PG_PORT -U $PG_USER -d $PG_DB -v ON_ERROR_STOP=1"
|
||||||
|
else
|
||||||
|
# Local docker exec into the test container
|
||||||
|
PG_CONTAINER="${PG_CONTAINER:-gd_test_pg}"
|
||||||
|
PSQL="docker exec -i $PG_CONTAINER psql -U gendesign -d gendesign -v ON_ERROR_STOP=1"
|
||||||
|
PSQL_PIPE="$PSQL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../raw"
|
||||||
|
|
||||||
|
declare -a JOBS=(
|
||||||
|
"2024Q3:2024-07-01:dataset_СДЕЛКИ_r_all_q_3.csv:;"
|
||||||
|
"2024Q4:2024-10-01:dataset_СДЕЛКИ_r-r_01-92_y_2024_q_4.csv:~"
|
||||||
|
"2025Q1:2025-01-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_1.csv:~"
|
||||||
|
"2025Q2:2025-04-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_2.csv:~"
|
||||||
|
"2025Q3:2025-07-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_3.csv:~"
|
||||||
|
"2025Q4:2025-10-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_4.csv:~"
|
||||||
|
"2026Q1:2026-01-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_1.csv:~"
|
||||||
|
)
|
||||||
|
|
||||||
|
for job in "${JOBS[@]}"; do
|
||||||
|
IFS=':' read -r SRC_Q PERIOD FILE SEP <<< "$job"
|
||||||
|
echo "=== $SRC_Q ($FILE, sep='$SEP') ==="
|
||||||
|
$PSQL -c "TRUNCATE rosreestr_deals_staging;"
|
||||||
|
start=$(date +%s)
|
||||||
|
cat "$FILE" | $PSQL -c "\\copy rosreestr_deals_staging FROM stdin (FORMAT csv, HEADER true, DELIMITER '$SEP', QUOTE '\"', NULL '')"
|
||||||
|
$PSQL <<SQL
|
||||||
|
INSERT INTO rosreestr_deals (
|
||||||
|
source_quarter, period_start_date,
|
||||||
|
region_code, okato, district, city, quarter_cad_number, street,
|
||||||
|
realestate_type_code, wall_material_code, purpose_code,
|
||||||
|
year_build, floor, area,
|
||||||
|
doc_type, deal_price, currency, deal_count
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'$SRC_Q', period_start_date,
|
||||||
|
region_code, NULLIF(okato, '-'), NULLIF(district, ''), NULLIF(city, ''),
|
||||||
|
quarter_cad_number, NULLIF(street, ''),
|
||||||
|
NULLIF(realestate_type_code, ''),
|
||||||
|
NULLIF(wall_material_code, ''),
|
||||||
|
NULLIF(purpose_code, ''),
|
||||||
|
-- year_build can contain garbage like '09.07.1988' — keep only plausible 4-digit years
|
||||||
|
CASE WHEN year_build ~ '^[12][0-9]{3}$' THEN year_build::INT ELSE NULL END,
|
||||||
|
NULLIF(floor, ''),
|
||||||
|
-- discard absurd area/price (data-quality outliers from source)
|
||||||
|
CASE WHEN area > 0 AND area < 1e8 THEN area ELSE NULL END,
|
||||||
|
NULLIF(doc_type, ''),
|
||||||
|
CASE WHEN deal_price > 0 AND deal_price < 1e15 THEN deal_price ELSE NULL END,
|
||||||
|
COALESCE(NULLIF(currency, ''), 'рубль'),
|
||||||
|
COALESCE(deal_count, 1)
|
||||||
|
FROM rosreestr_deals_staging
|
||||||
|
WHERE region_code IS NOT NULL
|
||||||
|
AND quarter_cad_number IS NOT NULL
|
||||||
|
AND quarter_cad_number <> ''
|
||||||
|
AND doc_type IS NOT NULL
|
||||||
|
AND doc_type <> ''
|
||||||
|
AND period_start_date IS NOT NULL;
|
||||||
|
SQL
|
||||||
|
end=$(date +%s)
|
||||||
|
echo " loaded in $((end-start))s"
|
||||||
|
done
|
||||||
|
|
||||||
|
$PSQL <<'SQL'
|
||||||
|
SELECT
|
||||||
|
source_quarter,
|
||||||
|
COUNT(*) AS rows_loaded,
|
||||||
|
SUM(deal_count) AS deals,
|
||||||
|
SUM(deal_count) FILTER (WHERE region_code = 66) AS sverdl_deals,
|
||||||
|
pg_size_pretty(pg_relation_size('rosreestr_deals_'||lower(source_quarter))) AS partition_size
|
||||||
|
FROM rosreestr_deals
|
||||||
|
GROUP BY source_quarter
|
||||||
|
ORDER BY source_quarter;
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'TOTAL' AS quarter,
|
||||||
|
COUNT(*) AS rows,
|
||||||
|
SUM(deal_count) AS deals,
|
||||||
|
SUM(deal_count) FILTER (WHERE region_code = 66) AS sverdl_deals,
|
||||||
|
pg_size_pretty(pg_total_relation_size('rosreestr_deals')) AS total_size
|
||||||
|
FROM rosreestr_deals;
|
||||||
|
SQL
|
||||||
98
data/sql/03_materialized_views.sql
Normal file
98
data/sql/03_materialized_views.sql
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
-- Materialized aggregates for fast UI queries.
|
||||||
|
-- Refresh strategy: REFRESH MATERIALIZED VIEW CONCURRENTLY after each new quarter load.
|
||||||
|
|
||||||
|
-- 1. Per cadastral quarter × period × doc_type × area_bucket
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS rr_agg_cad_quarter CASCADE;
|
||||||
|
CREATE MATERIALIZED VIEW rr_agg_cad_quarter AS
|
||||||
|
SELECT
|
||||||
|
region_code,
|
||||||
|
quarter_cad_number,
|
||||||
|
period_start_date,
|
||||||
|
doc_type,
|
||||||
|
-- area bucket inspired by NSPD MarketAnalytics filter
|
||||||
|
CASE
|
||||||
|
WHEN area < 25 THEN 'lt25'
|
||||||
|
WHEN area < 40 THEN '25-40'
|
||||||
|
WHEN area < 60 THEN '40-60'
|
||||||
|
WHEN area < 90 THEN '60-90'
|
||||||
|
WHEN area < 150 THEN '90-150'
|
||||||
|
ELSE 'gt150'
|
||||||
|
END AS area_bucket,
|
||||||
|
SUM(deal_count) AS deals_count,
|
||||||
|
COUNT(*) AS rows_count,
|
||||||
|
SUM(deal_count * area) AS total_area_sqm,
|
||||||
|
SUM(deal_count * deal_price) AS total_value_rub,
|
||||||
|
percentile_cont(0.10) WITHIN GROUP (ORDER BY price_per_sqm) AS p10_price_sqm,
|
||||||
|
percentile_cont(0.50) WITHIN GROUP (ORDER BY price_per_sqm) AS median_price_sqm,
|
||||||
|
percentile_cont(0.90) WITHIN GROUP (ORDER BY price_per_sqm) AS p90_price_sqm,
|
||||||
|
AVG(price_per_sqm) AS mean_price_sqm,
|
||||||
|
MIN(price_per_sqm) AS min_price_sqm,
|
||||||
|
MAX(price_per_sqm) AS max_price_sqm
|
||||||
|
FROM rosreestr_deals
|
||||||
|
WHERE area BETWEEN 5 AND 1000
|
||||||
|
AND deal_price BETWEEN 100000 AND 1e10 -- discard outliers (typos, junk in source)
|
||||||
|
AND realestate_type_code IN ('002001003000') -- помещения (квартиры). Здания/ЗУ — отдельный rollup
|
||||||
|
GROUP BY region_code, quarter_cad_number, period_start_date, doc_type,
|
||||||
|
CASE WHEN area<25 THEN 'lt25' WHEN area<40 THEN '25-40' WHEN area<60 THEN '40-60'
|
||||||
|
WHEN area<90 THEN '60-90' WHEN area<150 THEN '90-150' ELSE 'gt150' END
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX rr_agg_cq_pk ON rr_agg_cad_quarter
|
||||||
|
(region_code, quarter_cad_number, period_start_date, doc_type, area_bucket);
|
||||||
|
CREATE INDEX rr_agg_cq_period_idx ON rr_agg_cad_quarter (period_start_date);
|
||||||
|
CREATE INDEX rr_agg_cq_region_period_idx ON rr_agg_cad_quarter (region_code, period_start_date);
|
||||||
|
|
||||||
|
-- 2. Per region × period × doc_type — для регионального обзора (Урал dashboard)
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS rr_agg_region CASCADE;
|
||||||
|
CREATE MATERIALIZED VIEW rr_agg_region AS
|
||||||
|
SELECT
|
||||||
|
region_code,
|
||||||
|
period_start_date,
|
||||||
|
doc_type,
|
||||||
|
realestate_type_code,
|
||||||
|
SUM(deal_count) AS deals_count,
|
||||||
|
COUNT(*) AS rows_count,
|
||||||
|
SUM(deal_count * area) AS total_area_sqm,
|
||||||
|
SUM(deal_count * deal_price) AS total_value_rub,
|
||||||
|
percentile_cont(0.10) WITHIN GROUP (ORDER BY price_per_sqm) AS p10_price_sqm,
|
||||||
|
percentile_cont(0.50) WITHIN GROUP (ORDER BY price_per_sqm) AS median_price_sqm,
|
||||||
|
percentile_cont(0.90) WITHIN GROUP (ORDER BY price_per_sqm) AS p90_price_sqm
|
||||||
|
FROM rosreestr_deals
|
||||||
|
WHERE area BETWEEN 5 AND 1000
|
||||||
|
AND deal_price BETWEEN 100000 AND 1e10
|
||||||
|
GROUP BY region_code, period_start_date, doc_type, realestate_type_code
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX rr_agg_region_pk ON rr_agg_region
|
||||||
|
(region_code, period_start_date, doc_type, realestate_type_code);
|
||||||
|
|
||||||
|
-- 3. Per city/settlement (для дашборда Екатеринбург)
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS rr_agg_settlement CASCADE;
|
||||||
|
CREATE MATERIALIZED VIEW rr_agg_settlement AS
|
||||||
|
SELECT
|
||||||
|
region_code,
|
||||||
|
COALESCE(city, district) AS settlement_name,
|
||||||
|
period_start_date,
|
||||||
|
doc_type,
|
||||||
|
realestate_type_code,
|
||||||
|
SUM(deal_count) AS deals_count,
|
||||||
|
SUM(deal_count * area) AS total_area_sqm,
|
||||||
|
percentile_cont(0.50) WITHIN GROUP (ORDER BY price_per_sqm) AS median_price_sqm,
|
||||||
|
percentile_cont(0.10) WITHIN GROUP (ORDER BY price_per_sqm) AS p10_price_sqm,
|
||||||
|
percentile_cont(0.90) WITHIN GROUP (ORDER BY price_per_sqm) AS p90_price_sqm,
|
||||||
|
COUNT(DISTINCT quarter_cad_number) AS distinct_cad_quarters
|
||||||
|
FROM rosreestr_deals
|
||||||
|
WHERE area BETWEEN 5 AND 1000
|
||||||
|
AND deal_price BETWEEN 100000 AND 1e10
|
||||||
|
AND COALESCE(city, district) IS NOT NULL
|
||||||
|
GROUP BY region_code, COALESCE(city, district), period_start_date, doc_type, realestate_type_code
|
||||||
|
WITH NO DATA;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX rr_agg_settl_pk ON rr_agg_settlement
|
||||||
|
(region_code, settlement_name, period_start_date, doc_type, realestate_type_code);
|
||||||
|
CREATE INDEX rr_agg_settl_search ON rr_agg_settlement (region_code, settlement_name);
|
||||||
|
|
||||||
|
-- Initial fill
|
||||||
|
REFRESH MATERIALIZED VIEW rr_agg_cad_quarter;
|
||||||
|
REFRESH MATERIALIZED VIEW rr_agg_region;
|
||||||
|
REFRESH MATERIALIZED VIEW rr_agg_settlement;
|
||||||
67
data/sql/10_schema_domrf.sql
Normal file
67
data/sql/10_schema_domrf.sql
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
-- DOM.RF (наш.дом.рф) portal-analytics dashboard data.
|
||||||
|
-- Source: https://xn--80az8a.xn--d1aqf.xn--p1ai/portal-analytics/api/*
|
||||||
|
-- Coverage: aggregate of construction-stage flats in МКД (multi-apartment buildings).
|
||||||
|
-- ⚠ NOT historical: this is a SNAPSHOT of "currently being built" state. Updated continuously.
|
||||||
|
-- We store snapshot_date so we can rebuild trend over time by re-pulling.
|
||||||
|
|
||||||
|
-- 1. Federal districts + regions dictionary (from /dictionaries/regions)
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_regions (
|
||||||
|
region_id INT PRIMARY KEY,
|
||||||
|
region_name TEXT NOT NULL,
|
||||||
|
federal_district TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Developers dictionary (from /dictionaries/developers, 2734 entries as of 2026-04-26)
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_developers (
|
||||||
|
developer_id TEXT PRIMARY KEY, -- '6208_0' (PRINZIP), '5655_0' (ПИК), '0_14583' etc.
|
||||||
|
developer_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Snapshot metadata
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_snapshots (
|
||||||
|
snapshot_date DATE PRIMARY KEY,
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. By-region aggregates with breakdown by room count
|
||||||
|
-- One row per (region, room_count_type, snapshot)
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_region_aggregates (
|
||||||
|
region_id INT NOT NULL,
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
room_count_type TEXT NOT NULL, -- 'ONE'|'TWO'|'THREE'|'FOUR' or 'TOTAL' for region totals
|
||||||
|
flat_count INT NOT NULL,
|
||||||
|
area_sqm NUMERIC(14, 2) NOT NULL,
|
||||||
|
percent SMALLINT, -- % within region for room_count breakdown
|
||||||
|
PRIMARY KEY (region_id, snapshot_date, room_count_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. By-flat-area distribution (RF-wide, не работает фильтр по региону)
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_flat_area_distribution (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
region_id INT NOT NULL DEFAULT 0, -- 0 = вся РФ (api ignores ?regionId on this page)
|
||||||
|
area_bucket TEXT NOT NULL, -- 'FROM_0_TO_25', 'FROM_25_TO_35', ..., 'FROM_100'
|
||||||
|
room_count_type TEXT NOT NULL, -- 'ONE'|'TWO'|'THREE'|'FOUR' or 'TOTAL'
|
||||||
|
flat_count INT NOT NULL,
|
||||||
|
area_sqm NUMERIC(14, 2) NOT NULL,
|
||||||
|
percent SMALLINT,
|
||||||
|
PRIMARY KEY (snapshot_date, region_id, area_bucket, room_count_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. By-developer aggregates with breakdown by room count
|
||||||
|
-- Topology: developer × snapshot × room_count_type
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_developer_aggregates (
|
||||||
|
developer_id TEXT NOT NULL,
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
region_id INT NOT NULL DEFAULT 0, -- 0 = РФ агрегат (api ignores ?regionId on this page)
|
||||||
|
room_count_type TEXT NOT NULL,
|
||||||
|
flat_count INT NOT NULL,
|
||||||
|
area_sqm NUMERIC(14, 2) NOT NULL,
|
||||||
|
percent SMALLINT,
|
||||||
|
PRIMARY KEY (developer_id, snapshot_date, region_id, room_count_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domrf_region_aggregates_room ON domrf_region_aggregates (room_count_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domrf_developer_aggregates_room ON domrf_developer_aggregates (room_count_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domrf_developer_aggregates_dev ON domrf_developer_aggregates (developer_id);
|
||||||
183
data/sql/11_load_domrf.py
Normal file
183
data/sql/11_load_domrf.py
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
"""Loads DOM.RF analytics JSON files from data/raw/domrf/ into Postgres.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
PG_HOST=localhost PG_PORT=15432 PGPASSWORD=... python data/sql/11_load_domrf.py
|
||||||
|
# or for local test:
|
||||||
|
PGPASSWORD=test PG_PORT=6543 python data/sql/11_load_domrf.py
|
||||||
|
|
||||||
|
Files expected (snapshot from https://наш.дом.рф/portal-analytics/api/):
|
||||||
|
data/raw/domrf/regions.json — list of FO with regions inside
|
||||||
|
data/raw/domrf/developers.json — flat list of {id, name}
|
||||||
|
data/raw/domrf/by-region.json — per-region aggregates with room-count details
|
||||||
|
data/raw/domrf/by-flat-area-RF.json — RF area distribution with room-count details
|
||||||
|
data/raw/domrf/by-developer-RF.json — RF top developers with room-count details
|
||||||
|
data/raw/domrf/by-room-count-RF.json — RF totals (sanity check, not stored)
|
||||||
|
"""
|
||||||
|
import json, os, sys, datetime, subprocess
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
RAW = os.path.join(HERE, '..', 'raw', 'domrf')
|
||||||
|
|
||||||
|
PG_HOST = os.environ.get('PG_HOST', '127.0.0.1')
|
||||||
|
PG_PORT = os.environ.get('PG_PORT', '5432')
|
||||||
|
PG_USER = os.environ.get('PG_USER', 'gendesign')
|
||||||
|
PG_DB = os.environ.get('PG_DB', 'gendesign')
|
||||||
|
PG_PASS = os.environ.get('PGPASSWORD', '')
|
||||||
|
|
||||||
|
SNAPSHOT_DATE = os.environ.get('SNAPSHOT_DATE', datetime.date.today().isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
def psql(sql_or_stdin, stdin_text=None):
|
||||||
|
"""Run psql via docker. Pipe SQL via stdin."""
|
||||||
|
cmd = [
|
||||||
|
'docker', 'run', '--rm', '-i',
|
||||||
|
'-e', f'PGPASSWORD={PG_PASS}',
|
||||||
|
'postgres:16-alpine',
|
||||||
|
'psql', '-h', 'host.docker.internal', '-p', PG_PORT, '-U', PG_USER, '-d', PG_DB,
|
||||||
|
'-v', 'ON_ERROR_STOP=1', '--quiet',
|
||||||
|
]
|
||||||
|
if stdin_text is None:
|
||||||
|
stdin_text = sql_or_stdin
|
||||||
|
res = subprocess.run(cmd, input=stdin_text, capture_output=True, text=True, encoding='utf-8')
|
||||||
|
if res.returncode != 0:
|
||||||
|
print('psql FAILED:', res.stderr, file=sys.stderr)
|
||||||
|
raise SystemExit(res.returncode)
|
||||||
|
return res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def sql_escape(s):
|
||||||
|
return s.replace("'", "''")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
raw_files = {
|
||||||
|
'regions': os.path.join(RAW, 'regions.json'),
|
||||||
|
'developers': os.path.join(RAW, 'developers.json'),
|
||||||
|
'by-region': os.path.join(RAW, 'by-region.json'),
|
||||||
|
'by-flat-area-RF': os.path.join(RAW, 'by-flat-area-RF.json'),
|
||||||
|
'by-developer-RF': os.path.join(RAW, 'by-developer-RF.json'),
|
||||||
|
}
|
||||||
|
for k, p in raw_files.items():
|
||||||
|
if not os.path.exists(p):
|
||||||
|
print(f'MISSING: {p}', file=sys.stderr); raise SystemExit(2)
|
||||||
|
|
||||||
|
# 0. Apply schema (idempotent CREATE TABLE IF NOT EXISTS)
|
||||||
|
schema = open(os.path.join(HERE, '10_schema_domrf.sql'), encoding='utf-8').read()
|
||||||
|
psql(schema)
|
||||||
|
print('schema applied')
|
||||||
|
|
||||||
|
# 1. Snapshot row
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_snapshots (snapshot_date, notes)
|
||||||
|
VALUES ('{SNAPSHOT_DATE}', 'naash.dom.rf portal-analytics, captured via chrome-devtools MCP')
|
||||||
|
ON CONFLICT (snapshot_date) DO UPDATE SET fetched_at = NOW();
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 2. Regions dictionary
|
||||||
|
regions_data = json.load(open(raw_files['regions'], encoding='utf-8'))
|
||||||
|
parts = []
|
||||||
|
for fo in regions_data:
|
||||||
|
for r in fo['regions']:
|
||||||
|
parts.append(f"({r['id']}, '{sql_escape(r['name'])}', '{sql_escape(fo['name'])}')")
|
||||||
|
if parts:
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_regions (region_id, region_name, federal_district)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (region_id) DO UPDATE SET
|
||||||
|
region_name = EXCLUDED.region_name,
|
||||||
|
federal_district = EXCLUDED.federal_district;
|
||||||
|
""")
|
||||||
|
print(f'regions: {sum(len(fo["regions"]) for fo in regions_data)}')
|
||||||
|
|
||||||
|
# 3. Developers dictionary
|
||||||
|
devs = json.load(open(raw_files['developers'], encoding='utf-8'))
|
||||||
|
# batch insert in chunks of 500 to keep statement size sane
|
||||||
|
BATCH = 500
|
||||||
|
for i in range(0, len(devs), BATCH):
|
||||||
|
chunk = devs[i:i+BATCH]
|
||||||
|
parts = [f"('{sql_escape(d['id'])}', '{sql_escape(d['name'])}')" for d in chunk]
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_developers (developer_id, developer_name)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (developer_id) DO UPDATE SET developer_name = EXCLUDED.developer_name;
|
||||||
|
""")
|
||||||
|
print(f'developers: {len(devs)}')
|
||||||
|
|
||||||
|
# 4. By-region aggregates: insert TOTAL row + per-room rows for each region
|
||||||
|
byregion = json.load(open(raw_files['by-region'], encoding='utf-8'))
|
||||||
|
rows = []
|
||||||
|
for r in byregion:
|
||||||
|
rid = r['id']
|
||||||
|
# TOTAL row
|
||||||
|
rows.append((rid, SNAPSHOT_DATE, 'TOTAL', r['flatCount'], r['area'], None))
|
||||||
|
for d in r.get('details', []):
|
||||||
|
rows.append((rid, SNAPSHOT_DATE, d['roomCountType'], d['flatCount'], d['area'], d.get('percent')))
|
||||||
|
parts = [f"({rid}, '{sd}', '{rt}', {fc}, {a}, {'NULL' if p is None else p})"
|
||||||
|
for (rid, sd, rt, fc, a, p) in rows]
|
||||||
|
for i in range(0, len(parts), BATCH):
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_region_aggregates (region_id, snapshot_date, room_count_type, flat_count, area_sqm, percent)
|
||||||
|
VALUES {','.join(parts[i:i+BATCH])}
|
||||||
|
ON CONFLICT (region_id, snapshot_date, room_count_type) DO UPDATE SET
|
||||||
|
flat_count = EXCLUDED.flat_count,
|
||||||
|
area_sqm = EXCLUDED.area_sqm,
|
||||||
|
percent = EXCLUDED.percent;
|
||||||
|
""")
|
||||||
|
print(f'region_aggregates rows: {len(rows)}')
|
||||||
|
|
||||||
|
# 5. Flat-area distribution (RF-wide)
|
||||||
|
byarea = json.load(open(raw_files['by-flat-area-RF'], encoding='utf-8'))
|
||||||
|
rows = []
|
||||||
|
for bucket in byarea:
|
||||||
|
bid = bucket['id']
|
||||||
|
rows.append((bid, 'TOTAL', bucket['flatCount'], bucket['area'], bucket.get('percent')))
|
||||||
|
for d in bucket.get('details', []):
|
||||||
|
rows.append((bid, d['roomCountType'], d['flatCount'], d['area'], d.get('percent')))
|
||||||
|
parts = [f"('{SNAPSHOT_DATE}', 0, '{sql_escape(bid)}', '{rt}', {fc}, {a}, {'NULL' if p is None else p})"
|
||||||
|
for (bid, rt, fc, a, p) in rows]
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_flat_area_distribution (snapshot_date, region_id, area_bucket, room_count_type, flat_count, area_sqm, percent)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (snapshot_date, region_id, area_bucket, room_count_type) DO UPDATE SET
|
||||||
|
flat_count = EXCLUDED.flat_count,
|
||||||
|
area_sqm = EXCLUDED.area_sqm,
|
||||||
|
percent = EXCLUDED.percent;
|
||||||
|
""")
|
||||||
|
print(f'flat_area_distribution rows: {len(rows)}')
|
||||||
|
|
||||||
|
# 6. By-developer aggregates (RF-wide because ?regionId is ignored)
|
||||||
|
bydev = json.load(open(raw_files['by-developer-RF'], encoding='utf-8'))
|
||||||
|
rows = []
|
||||||
|
for dev in bydev:
|
||||||
|
did = dev['id']
|
||||||
|
rows.append((did, 'TOTAL', dev['flatCount'], dev['area'], dev.get('percent')))
|
||||||
|
for d in dev.get('details', []):
|
||||||
|
rows.append((did, d['roomCountType'], d['flatCount'], d['area'], d.get('percent')))
|
||||||
|
parts = [f"('{sql_escape(did)}', '{SNAPSHOT_DATE}', 0, '{rt}', {fc}, {a}, {'NULL' if p is None else p})"
|
||||||
|
for (did, rt, fc, a, p) in rows]
|
||||||
|
for i in range(0, len(parts), BATCH):
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_developer_aggregates (developer_id, snapshot_date, region_id, room_count_type, flat_count, area_sqm, percent)
|
||||||
|
VALUES {','.join(parts[i:i+BATCH])}
|
||||||
|
ON CONFLICT (developer_id, snapshot_date, region_id, room_count_type) DO UPDATE SET
|
||||||
|
flat_count = EXCLUDED.flat_count,
|
||||||
|
area_sqm = EXCLUDED.area_sqm,
|
||||||
|
percent = EXCLUDED.percent;
|
||||||
|
""")
|
||||||
|
print(f'developer_aggregates rows: {len(rows)}')
|
||||||
|
|
||||||
|
# Final summary
|
||||||
|
out = psql("""
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM domrf_regions) AS regions,
|
||||||
|
(SELECT COUNT(*) FROM domrf_developers) AS developers,
|
||||||
|
(SELECT COUNT(*) FROM domrf_region_aggregates) AS region_agg,
|
||||||
|
(SELECT COUNT(*) FROM domrf_flat_area_distribution) AS area_dist,
|
||||||
|
(SELECT COUNT(*) FROM domrf_developer_aggregates) AS dev_agg;
|
||||||
|
""")
|
||||||
|
print('--- summary ---')
|
||||||
|
print(out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
136
data/sql/20_schema_domrf_extras.sql
Normal file
136
data/sql/20_schema_domrf_extras.sql
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
-- DOM.RF additional snapshots (downloaded as XLSX/PDF from наш.дом.рф via "Скачать" buttons).
|
||||||
|
-- Snapshot date stamped on the file: 26.04.2026 (mortgage_rates is 19.04.2026).
|
||||||
|
-- These extend the JSON dashboard data with: full developer registry, escrow banks,
|
||||||
|
-- 214-FZ guaranty per region, sold-out (Tile3 velocity), housing info by mechanism, mortgage rates.
|
||||||
|
|
||||||
|
-- 1. Полный реестр застройщиков с метриками (НЕ группа компаний — а юрлица из проектных деклараций)
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_developers_full (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
developer_name TEXT NOT NULL,
|
||||||
|
area_thousand_sqm NUMERIC,
|
||||||
|
permits_count INT,
|
||||||
|
houses_count INT,
|
||||||
|
flats_count INT,
|
||||||
|
market_share_pct NUMERIC,
|
||||||
|
PRIMARY KEY (snapshot_date, developer_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Банки эскроу с метриками
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_escrow_banks (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
bank_name TEXT NOT NULL,
|
||||||
|
houses_count INT,
|
||||||
|
permits_count INT,
|
||||||
|
area_thousand_sqm NUMERIC,
|
||||||
|
developers_count INT,
|
||||||
|
regions_count INT,
|
||||||
|
PRIMARY KEY (snapshot_date, bank_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Долевое стр-во по 214-ФЗ — по регионам с разбивкой эскроу/ПП480
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_guaranty_regions (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
territory_name TEXT NOT NULL, -- 'Российская Федерация' | 'Центральный ФО' | 'Свердловская область' ...
|
||||||
|
territory_level TEXT NOT NULL, -- 'rf' | 'fo' | 'region'
|
||||||
|
-- Всего строится
|
||||||
|
total_permits INT,
|
||||||
|
total_area_th_sqm NUMERIC,
|
||||||
|
total_developers INT,
|
||||||
|
-- Имеют право привлекать ДУ по 214-ФЗ (всего)
|
||||||
|
fz214_permits INT,
|
||||||
|
fz214_area_th_sqm NUMERIC,
|
||||||
|
fz214_developers INT,
|
||||||
|
fz214_pct_of_total NUMERIC,
|
||||||
|
-- В т.ч. с эскроу
|
||||||
|
escrow_permits INT,
|
||||||
|
escrow_area_th_sqm NUMERIC,
|
||||||
|
escrow_developers INT,
|
||||||
|
-- В т.ч. ПП №480 (без эскроу)
|
||||||
|
pp480_permits INT,
|
||||||
|
pp480_area_th_sqm NUMERIC,
|
||||||
|
pp480_developers INT,
|
||||||
|
PRIMARY KEY (snapshot_date, territory_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Сводные показатели по механизмам привлечения (housing_info)
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_housing_summary (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
territory_name TEXT NOT NULL, -- 'Российская Федерация' | город | регион
|
||||||
|
territory_level TEXT NOT NULL, -- 'rf' | 'region' | 'city'
|
||||||
|
mechanism TEXT NOT NULL, -- 'all' | 'escrow' | 'fund' | 'no_attraction'
|
||||||
|
developers_count INT,
|
||||||
|
permits_count INT,
|
||||||
|
declarations_count INT,
|
||||||
|
houses_count INT,
|
||||||
|
area_thousand_sqm NUMERIC,
|
||||||
|
flats_thousand NUMERIC,
|
||||||
|
PRIMARY KEY (snapshot_date, territory_name, mechanism)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. Плановые сроки ввода (housing_info, нижняя таблица)
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_planned_commissioning (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
plan_year INT NOT NULL,
|
||||||
|
escrow_th_sqm NUMERIC,
|
||||||
|
fund_th_sqm NUMERIC,
|
||||||
|
no_attraction_th_sqm NUMERIC,
|
||||||
|
total_th_sqm NUMERIC,
|
||||||
|
PRIMARY KEY (snapshot_date, plan_year)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. Реализация (sold_out_info) — РФ итог + регионы
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_sold_out (
|
||||||
|
snapshot_date DATE NOT NULL, -- '2026-04-11' (отчёт за март 2026)
|
||||||
|
territory_name TEXT NOT NULL,
|
||||||
|
-- Площади
|
||||||
|
total_area_th_sqm NUMERIC,
|
||||||
|
sold_area_th_sqm NUMERIC,
|
||||||
|
unsold_area_th_sqm NUMERIC,
|
||||||
|
not_open_area_th_sqm NUMERIC,
|
||||||
|
-- Проценты
|
||||||
|
sold_pct NUMERIC,
|
||||||
|
unsold_pct NUMERIC,
|
||||||
|
not_open_pct NUMERIC,
|
||||||
|
-- Дополнительно (только для РФ)
|
||||||
|
avg_price_per_sqm NUMERIC, -- ₽/м²
|
||||||
|
attracted_funds_mln_rub NUMERIC, -- млн ₽
|
||||||
|
PRIMARY KEY (snapshot_date, territory_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 7. Реализация по году ввода
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_sold_out_by_year (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
plan_year TEXT NOT NULL, -- '2026' .. '2031+'
|
||||||
|
total_th_sqm NUMERIC,
|
||||||
|
pct_of_total NUMERIC,
|
||||||
|
sold_pct NUMERIC,
|
||||||
|
unsold_pct NUMERIC,
|
||||||
|
not_open_pct NUMERIC,
|
||||||
|
PRIMARY KEY (snapshot_date, plan_year)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 8. Распределение по % реализации в доме
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_sold_out_by_progress (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
progress_bucket TEXT NOT NULL, -- 'not_open' | '0-20' | '21-40' | '41-60' | '61-80' | '80+'
|
||||||
|
total_th_sqm NUMERIC,
|
||||||
|
pct_of_total NUMERIC,
|
||||||
|
sold_pct_within NUMERIC,
|
||||||
|
unsold_pct_within NUMERIC,
|
||||||
|
PRIMARY KEY (snapshot_date, progress_bucket)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 9. Ставки ипотеки топ-20 банков (mortgage_rates)
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_mortgage_rates (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
bank_name TEXT NOT NULL,
|
||||||
|
primary_rate NUMERIC, -- %, NULL если 'не предоставляет'
|
||||||
|
secondary_rate NUMERIC,
|
||||||
|
refinance_rate NUMERIC,
|
||||||
|
note TEXT, -- 'Максимальный срок – 19 лет', 'ПВ от 50%' etc.
|
||||||
|
PRIMARY KEY (snapshot_date, bank_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domrf_developers_full_share ON domrf_developers_full (market_share_pct DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domrf_guaranty_level ON domrf_guaranty_regions (territory_level);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domrf_housing_summary_terr ON domrf_housing_summary (territory_name, mechanism);
|
||||||
405
data/sql/21_load_domrf_extras.py
Normal file
405
data/sql/21_load_domrf_extras.py
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
"""Loads DOM.RF extra snapshots (XLSX downloads + PDF-extracted data) into Postgres.
|
||||||
|
|
||||||
|
Inputs in C:\\Users\\user\\Downloads\\:
|
||||||
|
- developer_rf_26-04-2026.xlsx → domrf_developers_full
|
||||||
|
- escrow_rf_26-04-2026.xlsx → domrf_escrow_banks
|
||||||
|
- guaranty_26-04-2026.xlsx → domrf_guaranty_regions
|
||||||
|
- housing_info_26-04-2026.pdf → domrf_housing_summary, domrf_planned_commissioning (hardcoded extract)
|
||||||
|
- sold_out_info_26-04-2026.pdf → domrf_sold_out, ..._by_year, ..._by_progress (hardcoded extract)
|
||||||
|
- mortgage_rates_19-04-2026.pdf → domrf_mortgage_rates (hardcoded extract)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
PG_HOST=localhost PG_PORT=15432 PGPASSWORD=... python data/sql/21_load_domrf_extras.py
|
||||||
|
"""
|
||||||
|
import os, sys, subprocess
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
DOWNLOADS = r'C:\Users\user\Downloads'
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
PG_PORT = os.environ.get('PG_PORT', '5432')
|
||||||
|
PG_USER = os.environ.get('PG_USER', 'gendesign')
|
||||||
|
PG_DB = os.environ.get('PG_DB', 'gendesign')
|
||||||
|
PG_PASS = os.environ.get('PGPASSWORD', '')
|
||||||
|
|
||||||
|
|
||||||
|
def psql(sql):
|
||||||
|
cmd = ['docker', 'run', '--rm', '-i', '-e', f'PGPASSWORD={PG_PASS}',
|
||||||
|
'postgres:16-alpine', 'psql', '-h', 'host.docker.internal', '-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[-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 or v == '-' or v == '': return 'NULL'
|
||||||
|
try: return str(int(float(v)))
|
||||||
|
except (ValueError, TypeError): return 'NULL'
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 0. Apply schema
|
||||||
|
schema = open(os.path.join(HERE, '20_schema_domrf_extras.sql'), encoding='utf-8').read()
|
||||||
|
psql(schema)
|
||||||
|
print('schema applied')
|
||||||
|
|
||||||
|
SNAP_APR26 = '2026-04-26'
|
||||||
|
SNAP_MORTGAGE = '2026-04-19'
|
||||||
|
SNAP_SOLD = '2026-04-11' # отчётная дата на самом PDF
|
||||||
|
|
||||||
|
# ─────────── 1. developer_rf.xlsx → domrf_developers_full ───────────
|
||||||
|
wb = openpyxl.load_workbook(os.path.join(DOWNLOADS, 'developer_rf_26-04-2026.xlsx'),
|
||||||
|
read_only=True, data_only=True)
|
||||||
|
rows = list(wb.active.iter_rows(values_only=True))
|
||||||
|
wb.close()
|
||||||
|
# rows[3] = header; rows[4] = numeric column index; rows[5] = 'Итого'; rows[6+] = data
|
||||||
|
seen = {}
|
||||||
|
for r in rows[6:]:
|
||||||
|
name = r[0]
|
||||||
|
if not name or not isinstance(name, str) or name.strip() == '': continue
|
||||||
|
# Deduplicate: keep entry with highest area (xlsx может содержать дубли названий)
|
||||||
|
key = name.strip()
|
||||||
|
prev = seen.get(key)
|
||||||
|
cur_area = r[1] if r[1] is not None else 0
|
||||||
|
if prev is None or (prev[1] or 0) < cur_area:
|
||||||
|
seen[key] = r
|
||||||
|
parts = [f"('{SNAP_APR26}', {esc(k)}, {num(r[1])}, {integer(r[2])}, "
|
||||||
|
f"{integer(r[3])}, {integer(r[4])}, {num(r[5])})"
|
||||||
|
for k, r in seen.items()]
|
||||||
|
BATCH = 500
|
||||||
|
for i in range(0, len(parts), BATCH):
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_developers_full (snapshot_date, developer_name, area_thousand_sqm,
|
||||||
|
permits_count, houses_count, flats_count, market_share_pct)
|
||||||
|
VALUES {','.join(parts[i:i+BATCH])}
|
||||||
|
ON CONFLICT (snapshot_date, developer_name) DO UPDATE SET
|
||||||
|
area_thousand_sqm = EXCLUDED.area_thousand_sqm,
|
||||||
|
permits_count = EXCLUDED.permits_count,
|
||||||
|
houses_count = EXCLUDED.houses_count,
|
||||||
|
flats_count = EXCLUDED.flats_count,
|
||||||
|
market_share_pct = EXCLUDED.market_share_pct;
|
||||||
|
""")
|
||||||
|
print(f'developers_full: {len(parts)} (deduped from {len(rows)-6})')
|
||||||
|
|
||||||
|
# ─────────── 2. escrow_rf.xlsx → domrf_escrow_banks ───────────
|
||||||
|
wb = openpyxl.load_workbook(os.path.join(DOWNLOADS, 'escrow_rf_26-04-2026.xlsx'),
|
||||||
|
read_only=True, data_only=True)
|
||||||
|
rows = list(wb.active.iter_rows(values_only=True))
|
||||||
|
wb.close()
|
||||||
|
parts = []
|
||||||
|
for r in rows[6:]:
|
||||||
|
name = r[0]
|
||||||
|
if not name or not isinstance(name, str) or name.strip() == '' or name == 'Итого': continue
|
||||||
|
parts.append(f"('{SNAP_APR26}', {esc(name.strip())}, {integer(r[1])}, {integer(r[2])}, "
|
||||||
|
f"{num(r[3])}, {integer(r[4])}, {integer(r[5])})")
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_escrow_banks (snapshot_date, bank_name, houses_count, permits_count,
|
||||||
|
area_thousand_sqm, developers_count, regions_count)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (snapshot_date, bank_name) DO UPDATE SET
|
||||||
|
houses_count = EXCLUDED.houses_count,
|
||||||
|
permits_count = EXCLUDED.permits_count,
|
||||||
|
area_thousand_sqm = EXCLUDED.area_thousand_sqm,
|
||||||
|
developers_count = EXCLUDED.developers_count,
|
||||||
|
regions_count = EXCLUDED.regions_count;
|
||||||
|
""")
|
||||||
|
print(f'escrow_banks: {len(parts)}')
|
||||||
|
|
||||||
|
# ─────────── 3. guaranty.xlsx → domrf_guaranty_regions ───────────
|
||||||
|
wb = openpyxl.load_workbook(os.path.join(DOWNLOADS, 'guaranty_26-04-2026.xlsx'),
|
||||||
|
read_only=True, data_only=True)
|
||||||
|
rows = list(wb.active.iter_rows(values_only=True))
|
||||||
|
wb.close()
|
||||||
|
# rows[6] = РФ; rows[7..] = ФО + регионы
|
||||||
|
FO_NAMES = {'Центральный ФО', 'Северо-Западный ФО', 'Южный ФО', 'Северо-Кавказский ФО',
|
||||||
|
'Приволжский ФО', 'Уральский ФО', 'Сибирский ФО', 'Дальневосточный ФО',
|
||||||
|
'Новые субъекты Российской Федерации'}
|
||||||
|
parts = []
|
||||||
|
for r in rows[6:]:
|
||||||
|
name = r[0]
|
||||||
|
if not name or not isinstance(name, str) or name.strip() == '': continue
|
||||||
|
name = name.strip()
|
||||||
|
if name == 'Российская Федерация': level = 'rf'
|
||||||
|
elif name in FO_NAMES: level = 'fo'
|
||||||
|
else: level = 'region'
|
||||||
|
parts.append(f"('{SNAP_APR26}', {esc(name)}, '{level}', "
|
||||||
|
f"{integer(r[1])}, {num(r[2])}, {integer(r[3])}, "
|
||||||
|
f"{integer(r[4])}, {num(r[5])}, {integer(r[6])}, {num(r[7])}, "
|
||||||
|
f"{integer(r[8])}, {num(r[9])}, {integer(r[10])}, "
|
||||||
|
f"{integer(r[11])}, {num(r[12])}, {integer(r[13])})")
|
||||||
|
BATCH = 100
|
||||||
|
for i in range(0, len(parts), BATCH):
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_guaranty_regions (snapshot_date, territory_name, territory_level,
|
||||||
|
total_permits, total_area_th_sqm, total_developers,
|
||||||
|
fz214_permits, fz214_area_th_sqm, fz214_developers, fz214_pct_of_total,
|
||||||
|
escrow_permits, escrow_area_th_sqm, escrow_developers,
|
||||||
|
pp480_permits, pp480_area_th_sqm, pp480_developers)
|
||||||
|
VALUES {','.join(parts[i:i+BATCH])}
|
||||||
|
ON CONFLICT (snapshot_date, territory_name) DO UPDATE SET
|
||||||
|
total_permits = EXCLUDED.total_permits,
|
||||||
|
total_area_th_sqm = EXCLUDED.total_area_th_sqm,
|
||||||
|
total_developers = EXCLUDED.total_developers,
|
||||||
|
fz214_permits = EXCLUDED.fz214_permits,
|
||||||
|
fz214_area_th_sqm = EXCLUDED.fz214_area_th_sqm,
|
||||||
|
fz214_developers = EXCLUDED.fz214_developers,
|
||||||
|
fz214_pct_of_total = EXCLUDED.fz214_pct_of_total,
|
||||||
|
escrow_permits = EXCLUDED.escrow_permits,
|
||||||
|
escrow_area_th_sqm = EXCLUDED.escrow_area_th_sqm,
|
||||||
|
escrow_developers = EXCLUDED.escrow_developers,
|
||||||
|
pp480_permits = EXCLUDED.pp480_permits,
|
||||||
|
pp480_area_th_sqm = EXCLUDED.pp480_area_th_sqm,
|
||||||
|
pp480_developers = EXCLUDED.pp480_developers;
|
||||||
|
""")
|
||||||
|
print(f'guaranty_regions: {len(parts)}')
|
||||||
|
|
||||||
|
# ─────────── 4. housing_info.pdf → domrf_housing_summary + planned_commissioning ───────────
|
||||||
|
# extracted manually from PDF
|
||||||
|
housing_summary = [
|
||||||
|
# (territory, level, mechanism, devs, permits, decls, houses, area, flats_th)
|
||||||
|
('Российская Федерация', 'rf', 'all', 4336, 7257, 7447, 11510, 119234, 2424),
|
||||||
|
('Российская Федерация', 'rf', 'escrow', 4285, 7170, 7359, 11385, 117670, 2394),
|
||||||
|
('Российская Федерация', 'rf', 'fund', 42, 61, 62, 85, 1104, 22),
|
||||||
|
('Российская Федерация', 'rf', 'no_attraction', 15, 28, 28, 40, 460, 8),
|
||||||
|
]
|
||||||
|
# top-10 regions (escrow / fund / no_attraction / total) area_th_sqm
|
||||||
|
region_breakdown = [
|
||||||
|
# (region, escrow, fund, no_attr, total)
|
||||||
|
('Город Москва', 15670, 364, 286, 16320),
|
||||||
|
('Краснодарский край', 8179, 24, 4, 8206),
|
||||||
|
('Московская область', 7905, 81, None, 7986),
|
||||||
|
('Свердловская область', 5637, 17, None, 5654),
|
||||||
|
('Город Санкт-Петербург', 5258, None, None, 5258),
|
||||||
|
('Ростовская область', 4926, None, None, 4926),
|
||||||
|
('Ленинградская область', 4640, 60, None, 4700),
|
||||||
|
('Новосибирская область', 3580, 107, None, 3687),
|
||||||
|
('Республика Башкортостан',3583, None, None, 3583),
|
||||||
|
('Тюменская область', 3513, None, 11, 3524),
|
||||||
|
]
|
||||||
|
city_breakdown = [
|
||||||
|
# (city, escrow, fund, no_attr, total)
|
||||||
|
('Москва', 15670, 364, 286, 16320),
|
||||||
|
('Краснодар', 5791, None, None, 5791),
|
||||||
|
('Екатеринбург', 5294, 17, None, 5310),
|
||||||
|
('Санкт-Петербург', 5258, None, None, 5258),
|
||||||
|
('Ростов-на-Дону', 3605, None, None, 3605),
|
||||||
|
('Тюмень', 3204, None, 11, 3215),
|
||||||
|
('Новосибирск', 2755, 104, None, 2859),
|
||||||
|
('Уфа', 2786, None, None, 2786),
|
||||||
|
('Казань', 2205, None, None, 2205),
|
||||||
|
('Владивосток', 2134, None, None, 2134),
|
||||||
|
]
|
||||||
|
parts = []
|
||||||
|
for (terr, level, mech, devs, permits, decls, houses, area, flats) in housing_summary:
|
||||||
|
parts.append(f"('{SNAP_APR26}', {esc(terr)}, '{level}', '{mech}', {integer(devs)}, "
|
||||||
|
f"{integer(permits)}, {integer(decls)}, {integer(houses)}, {num(area)}, {num(flats)})")
|
||||||
|
for breakdown, level in [(region_breakdown, 'region'), (city_breakdown, 'city')]:
|
||||||
|
for (terr, escrow, fund, no_attr, total) in breakdown:
|
||||||
|
for mech, area in [('escrow', escrow), ('fund', fund),
|
||||||
|
('no_attraction', no_attr), ('all', total)]:
|
||||||
|
if area is None: continue
|
||||||
|
parts.append(f"('{SNAP_APR26}', {esc(terr)}, '{level}', '{mech}', NULL, "
|
||||||
|
f"NULL, NULL, NULL, {num(area)}, NULL)")
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_housing_summary (snapshot_date, territory_name, territory_level,
|
||||||
|
mechanism, developers_count, permits_count, declarations_count, houses_count,
|
||||||
|
area_thousand_sqm, flats_thousand)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (snapshot_date, territory_name, mechanism) DO UPDATE SET
|
||||||
|
developers_count = EXCLUDED.developers_count,
|
||||||
|
permits_count = EXCLUDED.permits_count,
|
||||||
|
declarations_count = EXCLUDED.declarations_count,
|
||||||
|
houses_count = EXCLUDED.houses_count,
|
||||||
|
area_thousand_sqm = EXCLUDED.area_thousand_sqm,
|
||||||
|
flats_thousand = EXCLUDED.flats_thousand;
|
||||||
|
""")
|
||||||
|
print(f'housing_summary: {len(parts)}')
|
||||||
|
|
||||||
|
# planned_commissioning (year, escrow, fund, no_attr, total)
|
||||||
|
pc = [
|
||||||
|
(2026, 37084, 685, 154, 37923),
|
||||||
|
(2027, 34896, 246, 221, 35363),
|
||||||
|
(2028, 26360, 113, 30, 26503),
|
||||||
|
(2029, 10924, 19, 24, 10967),
|
||||||
|
(2030, 4245, 13, 32, 4290),
|
||||||
|
(2031, 1797, 28, None, 1825),
|
||||||
|
(2032, 1323, None, None, 1323),
|
||||||
|
(2033, 351, None, None, 351),
|
||||||
|
(2034, 234, None, None, 234),
|
||||||
|
(2035, 67, None, None, 67),
|
||||||
|
]
|
||||||
|
parts = [f"('{SNAP_APR26}', {y}, {num(e)}, {num(f)}, {num(n)}, {num(t)})"
|
||||||
|
for (y, e, f, n, t) in pc]
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_planned_commissioning (snapshot_date, plan_year, escrow_th_sqm,
|
||||||
|
fund_th_sqm, no_attraction_th_sqm, total_th_sqm)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (snapshot_date, plan_year) DO UPDATE SET
|
||||||
|
escrow_th_sqm = EXCLUDED.escrow_th_sqm,
|
||||||
|
fund_th_sqm = EXCLUDED.fund_th_sqm,
|
||||||
|
no_attraction_th_sqm = EXCLUDED.no_attraction_th_sqm,
|
||||||
|
total_th_sqm = EXCLUDED.total_th_sqm;
|
||||||
|
""")
|
||||||
|
print(f'planned_commissioning: {len(parts)}')
|
||||||
|
|
||||||
|
# ─────────── 5. sold_out_info.pdf → 3 tables ───────────
|
||||||
|
# RF total
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_sold_out (snapshot_date, territory_name, total_area_th_sqm,
|
||||||
|
sold_area_th_sqm, unsold_area_th_sqm, not_open_area_th_sqm,
|
||||||
|
sold_pct, unsold_pct, not_open_pct, avg_price_per_sqm, attracted_funds_mln_rub)
|
||||||
|
VALUES ('{SNAP_SOLD}', 'Российская Федерация',
|
||||||
|
118876, 36749, 53044, 29084, 31, 45, 24, 216316, 7949316)
|
||||||
|
ON CONFLICT (snapshot_date, territory_name) DO UPDATE SET
|
||||||
|
total_area_th_sqm = EXCLUDED.total_area_th_sqm,
|
||||||
|
sold_area_th_sqm = EXCLUDED.sold_area_th_sqm,
|
||||||
|
unsold_area_th_sqm = EXCLUDED.unsold_area_th_sqm,
|
||||||
|
not_open_area_th_sqm = EXCLUDED.not_open_area_th_sqm,
|
||||||
|
sold_pct = EXCLUDED.sold_pct,
|
||||||
|
unsold_pct = EXCLUDED.unsold_pct,
|
||||||
|
not_open_pct = EXCLUDED.not_open_pct,
|
||||||
|
avg_price_per_sqm = EXCLUDED.avg_price_per_sqm,
|
||||||
|
attracted_funds_mln_rub = EXCLUDED.attracted_funds_mln_rub;
|
||||||
|
""")
|
||||||
|
# Top-8 regions: (region, total, sold%, unsold%, not_open%)
|
||||||
|
so_regions = [
|
||||||
|
('Город Москва', 16405, 47, 44, None),
|
||||||
|
('Краснодарский край', 8286, 20, 39, 41),
|
||||||
|
('Московская область', 7942, 37, 43, None),
|
||||||
|
('Свердловская область', 5686, 29, 54, None),
|
||||||
|
('Город Санкт-Петербург', 5336, 40, 44, None),
|
||||||
|
('Ростовская область', 4990, None, 48, None),
|
||||||
|
('Ленинградская область', 4705, None, 44, None),
|
||||||
|
('Тюменская область', 3573, None, 50, None),
|
||||||
|
]
|
||||||
|
parts = []
|
||||||
|
for (r, total, sp, up, nop) in so_regions:
|
||||||
|
parts.append(f"('{SNAP_SOLD}', {esc(r)}, {num(total)}, NULL, NULL, NULL, "
|
||||||
|
f"{num(sp)}, {num(up)}, {num(nop)}, NULL, NULL)")
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_sold_out (snapshot_date, territory_name, total_area_th_sqm,
|
||||||
|
sold_area_th_sqm, unsold_area_th_sqm, not_open_area_th_sqm,
|
||||||
|
sold_pct, unsold_pct, not_open_pct, avg_price_per_sqm, attracted_funds_mln_rub)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (snapshot_date, territory_name) DO UPDATE SET
|
||||||
|
total_area_th_sqm = EXCLUDED.total_area_th_sqm,
|
||||||
|
sold_pct = EXCLUDED.sold_pct,
|
||||||
|
unsold_pct = EXCLUDED.unsold_pct,
|
||||||
|
not_open_pct = EXCLUDED.not_open_pct;
|
||||||
|
""")
|
||||||
|
print(f'sold_out: 1 RF + {len(parts)} regions')
|
||||||
|
|
||||||
|
# by_year — (year, total_th_sqm, pct, sold%, unsold%, not_open%)
|
||||||
|
so_year = [
|
||||||
|
('2026', 40201, 34, 52, 38, 10),
|
||||||
|
('2027', 34880, 29, 30, 53, 17),
|
||||||
|
('2028', 25363, 21, 17, 52, 31),
|
||||||
|
('2029', 10359, 9, None, 37, 54),
|
||||||
|
('2030', 4127, 3, None, None, 71),
|
||||||
|
('2031+', 3946, 3, None, None, 65),
|
||||||
|
]
|
||||||
|
parts = [f"('{SNAP_SOLD}', '{y}', {num(t)}, {num(p)}, {num(s)}, {num(u)}, {num(n)})"
|
||||||
|
for (y, t, p, s, u, n) in so_year]
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_sold_out_by_year (snapshot_date, plan_year, total_th_sqm,
|
||||||
|
pct_of_total, sold_pct, unsold_pct, not_open_pct)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (snapshot_date, plan_year) DO UPDATE SET
|
||||||
|
total_th_sqm = EXCLUDED.total_th_sqm,
|
||||||
|
pct_of_total = EXCLUDED.pct_of_total,
|
||||||
|
sold_pct = EXCLUDED.sold_pct,
|
||||||
|
unsold_pct = EXCLUDED.unsold_pct,
|
||||||
|
not_open_pct = EXCLUDED.not_open_pct;
|
||||||
|
""")
|
||||||
|
print(f'sold_out_by_year: {len(parts)}')
|
||||||
|
|
||||||
|
# by_progress
|
||||||
|
so_prog = [
|
||||||
|
('not_open', 29084, 24, None, 100),
|
||||||
|
('0-20', 26170, 22, 9, 91),
|
||||||
|
('21-40', 21790, 18, 30, 70),
|
||||||
|
('41-60', 17409, 15, 50, 50),
|
||||||
|
('61-80', 13402, 11, 69, 31),
|
||||||
|
('80+', 11021, 9, 90, 9),
|
||||||
|
]
|
||||||
|
parts = [f"('{SNAP_SOLD}', '{b}', {num(t)}, {num(p)}, {num(s)}, {num(u)})"
|
||||||
|
for (b, t, p, s, u) in so_prog]
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_sold_out_by_progress (snapshot_date, progress_bucket, total_th_sqm,
|
||||||
|
pct_of_total, sold_pct_within, unsold_pct_within)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (snapshot_date, progress_bucket) DO UPDATE SET
|
||||||
|
total_th_sqm = EXCLUDED.total_th_sqm,
|
||||||
|
pct_of_total = EXCLUDED.pct_of_total,
|
||||||
|
sold_pct_within = EXCLUDED.sold_pct_within,
|
||||||
|
unsold_pct_within = EXCLUDED.unsold_pct_within;
|
||||||
|
""")
|
||||||
|
print(f'sold_out_by_progress: {len(parts)}')
|
||||||
|
|
||||||
|
# ─────────── 6. mortgage_rates.pdf → domrf_mortgage_rates ───────────
|
||||||
|
rates = [
|
||||||
|
# (bank, primary, secondary, refi, note)
|
||||||
|
('Сбербанк', 20.2, 19.7, 21.5, None),
|
||||||
|
('ВТБ', 19.9, 19.9, 19.6, None),
|
||||||
|
('Банк ДОМ.РФ', 18.2, 18.2, 22.1, None),
|
||||||
|
('Альфа-банк', 19.49, 19.49, 18.99, None),
|
||||||
|
('Совкомбанк', 19.99, 20.49, 19.99, None),
|
||||||
|
('Т-Банк', 16.9, 16.9, 16.9, None),
|
||||||
|
('Банк Санкт-Петербург', 18.49, 18.49, 18.49, None),
|
||||||
|
('Промсвязьбанк', 19.49, 18.79, 18.99, None),
|
||||||
|
('Московский Кредитный Банк', 18.6, 18.6, 18.6, None),
|
||||||
|
('Уралсиб', 17.89, 17.89, 18.99, None),
|
||||||
|
('Абсолют банк', 19.35, 19.35, 19.35, None),
|
||||||
|
('Металлинвестбанк', 18.1, 18.1, 18.5, 'к пред.неделе -0.7 пп'),
|
||||||
|
('Газпромбанк', None, None, None, 'Максимальный срок – 19 лет, рефинанс не предоставляет'),
|
||||||
|
('Азиатско-Тихоокеанский Банк', 18.9, 18.9, 18.9, None),
|
||||||
|
('Россельхозбанк', None, None, None, 'не предоставляет'),
|
||||||
|
('Банк Кубань Кредит', 16.4, 16.4, 15.9, None),
|
||||||
|
('Транскапиталбанк', 19.6, 19.6, 19.6, None),
|
||||||
|
('УБРиР', None, None, None, 'не предоставляет'),
|
||||||
|
('Ак Барс Банк', None, None, None, 'ПВ от 50%, рефинанс не предоставляет'),
|
||||||
|
('Средневзвешенная', 19.90, 19.59, 18.81, 'aggregate top-20'),
|
||||||
|
]
|
||||||
|
parts = [f"('{SNAP_MORTGAGE}', {esc(b)}, {num(p)}, {num(s)}, {num(r)}, {esc(n)})"
|
||||||
|
for (b, p, s, r, n) in rates]
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_mortgage_rates (snapshot_date, bank_name, primary_rate, secondary_rate, refinance_rate, note)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (snapshot_date, bank_name) DO UPDATE SET
|
||||||
|
primary_rate = EXCLUDED.primary_rate,
|
||||||
|
secondary_rate = EXCLUDED.secondary_rate,
|
||||||
|
refinance_rate = EXCLUDED.refinance_rate,
|
||||||
|
note = EXCLUDED.note;
|
||||||
|
""")
|
||||||
|
print(f'mortgage_rates: {len(parts)}')
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print('\n--- summary ---')
|
||||||
|
out = psql("""
|
||||||
|
SELECT 'developers_full' tab, COUNT(*) FROM domrf_developers_full
|
||||||
|
UNION ALL SELECT 'escrow_banks', COUNT(*) FROM domrf_escrow_banks
|
||||||
|
UNION ALL SELECT 'guaranty_regions', COUNT(*) FROM domrf_guaranty_regions
|
||||||
|
UNION ALL SELECT 'housing_summary', COUNT(*) FROM domrf_housing_summary
|
||||||
|
UNION ALL SELECT 'planned_commissioning', COUNT(*) FROM domrf_planned_commissioning
|
||||||
|
UNION ALL SELECT 'sold_out', COUNT(*) FROM domrf_sold_out
|
||||||
|
UNION ALL SELECT 'sold_out_by_year', COUNT(*) FROM domrf_sold_out_by_year
|
||||||
|
UNION ALL SELECT 'sold_out_by_progress', COUNT(*) FROM domrf_sold_out_by_progress
|
||||||
|
UNION ALL SELECT 'mortgage_rates', COUNT(*) FROM domrf_mortgage_rates
|
||||||
|
ORDER BY 1;
|
||||||
|
""")
|
||||||
|
print(out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
181
data/sql/30_scrape_domrf.py
Normal file
181
data/sql/30_scrape_domrf.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
"""DOM.RF analytics scraper using Playwright (headless Chromium).
|
||||||
|
|
||||||
|
Bypasses:
|
||||||
|
- ServicePipe WAF (Chromium executes JS challenge automatically)
|
||||||
|
- Lazy-loading (wait for `networkidle` + page-specific triggers)
|
||||||
|
- Cookie session (Chromium maintains it across navigations)
|
||||||
|
|
||||||
|
Captures: every JSON response from /api/* endpoints on the same domain.
|
||||||
|
Saves: data/raw/domrf_full/<section>/<endpoint>.json (one JSON per endpoint).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
PYTHONIOENCODING=utf-8 python data/sql/30_scrape_domrf.py [--headed] [--page <name>]
|
||||||
|
|
||||||
|
Run --headed first to verify WAF passes; switch to default headless after.
|
||||||
|
"""
|
||||||
|
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_full')
|
||||||
|
BASE = 'https://xn--80az8a.xn--d1aqf.xn--p1ai'
|
||||||
|
|
||||||
|
# 12 analytics pages — name (used as folder) → URL path
|
||||||
|
PAGES = [
|
||||||
|
('housing', '/аналитика/показатели_жилищного_строительства'),
|
||||||
|
('launch', '/аналитика/запуски-и-вводы?repYear=2026&calculationType=SQUARE&period=YEAR'),
|
||||||
|
('share_construction','/аналитика/долевое_строительство'),
|
||||||
|
('housing_dev', '/аналитика/жилищное_строительство'),
|
||||||
|
('escrow', '/аналитика/эскроу'),
|
||||||
|
('realization', '/аналитика/реализация_строящихся_квартир'),
|
||||||
|
('sold_ready', '/аналитика/распроданность-стройготовность?repYear=2026&repMonth=3'),
|
||||||
|
('quartirografia', '/аналитика/квартирография'),
|
||||||
|
('commissioning', '/аналитика/ввод_жилья'),
|
||||||
|
('mortgage_rates', '/аналитика/ставки_предложений_по_ипотеке'),
|
||||||
|
('mortgage_stats', '/аналитика/ипотечное_кредитование'),
|
||||||
|
('stat_series', '/аналитика/статистические_ряды'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename(url: str) -> str:
|
||||||
|
"""Turn API URL into safe filename: path + query → snake_case."""
|
||||||
|
parts = urlsplit(url)
|
||||||
|
base = parts.path.split('/api/')[-1].strip('/')
|
||||||
|
base = re.sub(r'[^A-Za-z0-9_\-]', '_', base)[:100]
|
||||||
|
if parts.query:
|
||||||
|
q = re.sub(r'[^A-Za-z0-9_\-]', '_', parts.query)[:80]
|
||||||
|
base = f'{base}__{q}'
|
||||||
|
return base + '.json'
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_size(url: str) -> str:
|
||||||
|
"""If URL has size=15 (or any small value), upgrade to size=1000 to get full data."""
|
||||||
|
parts = urlsplit(url)
|
||||||
|
if not parts.query:
|
||||||
|
return url
|
||||||
|
qs = parse_qs(parts.query, keep_blank_values=True)
|
||||||
|
if 'size' in qs:
|
||||||
|
qs['size'] = ['1000']
|
||||||
|
new_q = urlencode({k: v[0] for k, v in qs.items()})
|
||||||
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_q, parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape_page(browser, name, path):
|
||||||
|
out_dir = os.path.join(ROOT, name)
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
print(f'\n=== {name} ({path}) ===')
|
||||||
|
context = 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 context.new_page()
|
||||||
|
|
||||||
|
seen_urls = set()
|
||||||
|
saved = []
|
||||||
|
|
||||||
|
async def on_response(resp):
|
||||||
|
url = resp.url
|
||||||
|
# Match same-origin /api/ endpoints, skip yandex/sendsay trackers
|
||||||
|
if 'xn--80az8a.xn--d1aqf.xn--p1ai' not in url or '/api/' not in url:
|
||||||
|
return
|
||||||
|
ct = (resp.headers.get('content-type') or '').lower()
|
||||||
|
if 'json' not in ct:
|
||||||
|
return
|
||||||
|
if url in seen_urls:
|
||||||
|
return
|
||||||
|
seen_urls.add(url)
|
||||||
|
try:
|
||||||
|
text = await resp.text()
|
||||||
|
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' saved {fname} ({len(text)}b)')
|
||||||
|
except Exception as e:
|
||||||
|
print(f' ERR {url}: {e}')
|
||||||
|
|
||||||
|
page.on('response', lambda r: asyncio.create_task(on_response(r)))
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Re-fetch with size=1000 for endpoints that have small page-size
|
||||||
|
for url in list(seen_urls):
|
||||||
|
up = upgrade_size(url)
|
||||||
|
if up != url and up not in seen_urls:
|
||||||
|
try:
|
||||||
|
resp = await page.evaluate(
|
||||||
|
'''(u) => fetch(u, {credentials:'include'})
|
||||||
|
.then(r => r.text()).then(t => ({status: 200, text: t}))''',
|
||||||
|
up,
|
||||||
|
)
|
||||||
|
if resp and resp.get('text'):
|
||||||
|
seen_urls.add(up)
|
||||||
|
fname = safe_filename(up)
|
||||||
|
with open(os.path.join(out_dir, fname), 'w', encoding='utf-8') as f:
|
||||||
|
f.write(resp['text'])
|
||||||
|
saved.append((fname, len(resp['text']), up))
|
||||||
|
print(f' upgraded {fname} ({len(resp["text"])}b)')
|
||||||
|
except Exception as e:
|
||||||
|
print(f' upgrade err {up}: {e}')
|
||||||
|
finally:
|
||||||
|
await context.close()
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
headed = '--headed' in sys.argv
|
||||||
|
target = None
|
||||||
|
if '--page' in sys.argv:
|
||||||
|
target = sys.argv[sys.argv.index('--page') + 1]
|
||||||
|
|
||||||
|
pages = [(n, p) for n, p in PAGES if target is None or n == target]
|
||||||
|
print(f'Scraping {len(pages)} pages, headed={headed}')
|
||||||
|
os.makedirs(ROOT, exist_ok=True)
|
||||||
|
|
||||||
|
async with async_playwright() as pw:
|
||||||
|
browser = await pw.chromium.launch(headless=not headed)
|
||||||
|
all_saved = {}
|
||||||
|
for name, path in pages:
|
||||||
|
try:
|
||||||
|
all_saved[name] = await scrape_page(browser, name, path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'PAGE {name} FAILED: {e}')
|
||||||
|
all_saved[name] = []
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
print('\n=== SUMMARY ===')
|
||||||
|
for name, saved in all_saved.items():
|
||||||
|
total = sum(s[1] for s in saved)
|
||||||
|
print(f' {name:22s} files={len(saved):3d} total={total:>10}b')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
asyncio.run(main())
|
||||||
166
data/sql/31_schema_domrf_full.sql
Normal file
166
data/sql/31_schema_domrf_full.sql
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
-- DOM.RF analytics, normalized schema for scraper output (data/raw/domrf_full/*).
|
||||||
|
-- Snapshot date stamped per scrape; idempotent UPSERT loaders.
|
||||||
|
|
||||||
|
-- ── 1. Launch (запуски + ввод в эксплуатацию) ────────────────────────────────
|
||||||
|
-- Topology: page returns top-N entities (developers/regions/areas/fo) with launch (app)
|
||||||
|
-- and commissioning (rnv) values. Single table covers all 4 dimensions via dim_type.
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_launch_top (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
rep_year INT NOT NULL,
|
||||||
|
calc_type TEXT NOT NULL, -- 'SQUARE' (м²) | 'HOUSES' (шт) | 'FLATS' (шт)
|
||||||
|
dim_type TEXT NOT NULL, -- 'developer' | 'region' | 'area' | 'fo'
|
||||||
|
metric_type TEXT NOT NULL, -- 'app' (запуски) | 'rnv' (ввод)
|
||||||
|
entity_id TEXT NOT NULL, -- '6072_0' (developer) | '50' (region) | 'Москва' (area)
|
||||||
|
entity_name TEXT NOT NULL,
|
||||||
|
value NUMERIC NOT NULL,
|
||||||
|
PRIMARY KEY (snapshot_date, rep_year, calc_type, dim_type, metric_type, entity_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_launch_top_dim ON domrf_launch_top (dim_type, metric_type, value DESC);
|
||||||
|
|
||||||
|
-- Объёмы по классам жилья
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_launch_obj_class (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
rep_year INT NOT NULL,
|
||||||
|
calc_type TEXT NOT NULL,
|
||||||
|
obj_class_cd INT NOT NULL, -- 1=Типовой, 2=Комфорт, 3=Бизнес, 4=Элитный
|
||||||
|
obj_class_desc TEXT NOT NULL,
|
||||||
|
app_value NUMERIC,
|
||||||
|
rnv_value NUMERIC,
|
||||||
|
PRIMARY KEY (snapshot_date, rep_year, calc_type, obj_class_cd)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Time series: год × месяц → запуски/вводы
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_launch_monthly (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
calc_type TEXT NOT NULL,
|
||||||
|
rep_year INT NOT NULL,
|
||||||
|
rep_month INT NOT NULL, -- 1..12
|
||||||
|
app_value NUMERIC,
|
||||||
|
rnv_value NUMERIC,
|
||||||
|
PRIMARY KEY (snapshot_date, calc_type, rep_year, rep_month)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── 2. Реализация / распроданность-стройготовность (sold_ready) ──────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_sold_ready_index (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
rep_year INT NOT NULL,
|
||||||
|
rep_month INT NOT NULL,
|
||||||
|
square_sum NUMERIC, -- всего стр. площади РФ, м²
|
||||||
|
sold_perc NUMERIC, -- % проданного
|
||||||
|
sold_sum NUMERIC, -- проданная площадь, м²
|
||||||
|
sold_ready_perc NUMERIC, -- "распроданность ÷ готовность"
|
||||||
|
ready_perc NUMERIC, -- % готовности
|
||||||
|
PRIMARY KEY (snapshot_date, rep_year, rep_month)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reality breakdowns: foChart / regionChart / cityChart / devChart …
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_sold_ready_breakdown (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
rep_year INT NOT NULL,
|
||||||
|
rep_month INT NOT NULL,
|
||||||
|
chart_type TEXT NOT NULL, -- 'foChart' | 'regionChart' | 'cityChart' | 'devChart' | ...
|
||||||
|
entity_key TEXT NOT NULL, -- название/код
|
||||||
|
square_sum NUMERIC,
|
||||||
|
sold_perc NUMERIC,
|
||||||
|
ready_perc NUMERIC,
|
||||||
|
sold_ready_perc NUMERIC,
|
||||||
|
fo_cd INT,
|
||||||
|
extra JSONB, -- backup для прочих полей
|
||||||
|
PRIMARY KEY (snapshot_date, rep_year, rep_month, chart_type, entity_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Динамика: chartType × год × месяц → значение
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_sold_ready_dynamics (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
dynamic_chart_type TEXT NOT NULL, -- 'squareSumChart' | 'soldPercChart' | 'readyPercChart' | 'soldReadyPercChart'
|
||||||
|
rep_year INT NOT NULL,
|
||||||
|
rep_month INT NOT NULL,
|
||||||
|
value NUMERIC,
|
||||||
|
PRIMARY KEY (snapshot_date, dynamic_chart_type, rep_year, rep_month)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── 3. Share construction (долевое стр-во / project finance) ────────────────
|
||||||
|
-- /аналитика/api/project/finance/dashboard returns rows per region/FO/RF.
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_project_finance (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
report_date DATE,
|
||||||
|
subject_type TEXT, -- 'rf' | 'fo' | 'region'
|
||||||
|
subject_name TEXT,
|
||||||
|
region_cd INT,
|
||||||
|
fo_cd INT,
|
||||||
|
fo_desc TEXT,
|
||||||
|
rns_cnt INT, -- разрешения на стр-во
|
||||||
|
dev_cnt INT, -- застройщики
|
||||||
|
liv_sq_amt NUMERIC, -- жилая площадь м²
|
||||||
|
guaranty_escrow_rns_cnt INT, -- эскроу разрешения
|
||||||
|
guaranty_escrow_dev_cnt INT,
|
||||||
|
guaranty_escrow_liv_sq_amt NUMERIC,
|
||||||
|
guaranty_zosg_rns_cnt INT,
|
||||||
|
guaranty_zosg_dev_cnt INT,
|
||||||
|
guaranty_zosg_liv_sq_amt NUMERIC,
|
||||||
|
guaranty_rns_cnt INT, -- всего по 214-ФЗ
|
||||||
|
guaranty_dev_cnt INT,
|
||||||
|
guaranty_liv_sq_amt NUMERIC,
|
||||||
|
nonguaranty_rns_cnt INT, -- ПП №480
|
||||||
|
nonguaranty_dev_cnt INT,
|
||||||
|
nonguaranty_liv_sq_amt NUMERIC,
|
||||||
|
extra JSONB,
|
||||||
|
id BIGSERIAL PRIMARY KEY
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_project_finance_snap
|
||||||
|
ON domrf_project_finance (snapshot_date, COALESCE(subject_type, ''), COALESCE(subject_name, ''));
|
||||||
|
|
||||||
|
-- ── 4. Ввод жилья (commissioning building_summary) ──────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_commissioning (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
region_id INT NOT NULL,
|
||||||
|
region_name TEXT NOT NULL,
|
||||||
|
region_type TEXT, -- 'rf' | 'fo' | 'region'
|
||||||
|
reporting_period DATE NOT NULL,
|
||||||
|
rep_year INT,
|
||||||
|
accumulated_fact_area NUMERIC,
|
||||||
|
accumulated_fact_area_multifamily NUMERIC,
|
||||||
|
accumulated_fact_area_private NUMERIC,
|
||||||
|
accumulated_fact_area_change NUMERIC,
|
||||||
|
accumulated_fact_area_change_share NUMERIC,
|
||||||
|
extra JSONB,
|
||||||
|
PRIMARY KEY (snapshot_date, region_id, reporting_period)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── 5. Mortgage stats ───────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_mortgage_dashboard (
|
||||||
|
snapshot_date DATE NOT NULL PRIMARY KEY,
|
||||||
|
total_credit_count INT,
|
||||||
|
total_credit_count_delta_pct NUMERIC,
|
||||||
|
primary_credit_count INT,
|
||||||
|
primary_credit_count_delta_pct NUMERIC,
|
||||||
|
secondary_credit_count INT,
|
||||||
|
secondary_credit_count_delta_pct NUMERIC,
|
||||||
|
total_credit_amount NUMERIC,
|
||||||
|
total_credit_amount_delta_pct NUMERIC,
|
||||||
|
primary_credit_amount NUMERIC,
|
||||||
|
primary_credit_amount_delta_pct NUMERIC,
|
||||||
|
secondary_credit_amount NUMERIC,
|
||||||
|
secondary_credit_amount_delta_pct NUMERIC,
|
||||||
|
total_credit_avg_rate NUMERIC,
|
||||||
|
total_credit_avg_rate_delta NUMERIC,
|
||||||
|
primary_credit_avg_rate NUMERIC,
|
||||||
|
primary_credit_avg_rate_delta NUMERIC,
|
||||||
|
secondary_credit_avg_rate NUMERIC,
|
||||||
|
secondary_credit_avg_rate_delta NUMERIC,
|
||||||
|
extra JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_mortgage_details (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
credit_amount_avg NUMERIC,
|
||||||
|
credit_amount_avg_delta_pct NUMERIC,
|
||||||
|
credit_avg_period NUMERIC,
|
||||||
|
credit_avg_period_delta_pct NUMERIC,
|
||||||
|
credit_debts_amount NUMERIC,
|
||||||
|
credit_debts_amount_delta_pct NUMERIC,
|
||||||
|
credit_debts_overdue_percent NUMERIC,
|
||||||
|
credit_debts_overdue_percent_delta NUMERIC,
|
||||||
|
PRIMARY KEY (snapshot_date, currency)
|
||||||
|
);
|
||||||
18
data/sql/31_schema_domrf_raw.sql
Normal file
18
data/sql/31_schema_domrf_raw.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- DOM.RF analytics raw API endpoint snapshots.
|
||||||
|
-- Universal JSONB store: one row per (snapshot_date, section, endpoint).
|
||||||
|
-- Section = page name on наш.дом.рф (launch / share_construction / escrow / ...).
|
||||||
|
-- Endpoint = sanitized path+query of /api/* call.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domrf_raw_endpoints (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
section TEXT NOT NULL, -- 'launch' | 'sold_ready' | 'quartirografia' | ...
|
||||||
|
endpoint TEXT NOT NULL, -- 'launch_developers_app__calculationType_SQUARE_repYear_2026_size_1000'
|
||||||
|
source_url TEXT, -- inferred original URL (best effort)
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
payload_size INT, -- bytes (raw text length)
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (snapshot_date, section, endpoint)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domrf_raw_section ON domrf_raw_endpoints (section);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domrf_raw_payload_gin ON domrf_raw_endpoints USING GIN (payload);
|
||||||
378
data/sql/32_load_domrf_full.py
Normal file
378
data/sql/32_load_domrf_full.py
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
"""Loads scraped DOM.RF JSONs into normalized PG tables.
|
||||||
|
|
||||||
|
Reads: data/raw/domrf_full/<section>/*.json
|
||||||
|
Writes: domrf_launch_top, ..._obj_class, ..._monthly,
|
||||||
|
domrf_sold_ready_index, ..._breakdown, ..._dynamics,
|
||||||
|
domrf_project_finance, domrf_commissioning,
|
||||||
|
domrf_mortgage_dashboard, domrf_mortgage_details.
|
||||||
|
|
||||||
|
Existing tables (domrf_developers, domrf_regions, domrf_developer_aggregates,
|
||||||
|
domrf_region_aggregates, domrf_developers_full, domrf_escrow_banks,
|
||||||
|
domrf_guaranty_regions, domrf_housing_summary, domrf_sold_out,
|
||||||
|
domrf_mortgage_rates, etc.) are NOT touched.
|
||||||
|
"""
|
||||||
|
import os, json, sys, subprocess, datetime
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ROOT = os.path.join(HERE, '..', 'raw', 'domrf_full')
|
||||||
|
PG_PORT = os.environ.get('PG_PORT', '5432')
|
||||||
|
PG_USER = os.environ.get('PG_USER', 'gendesign')
|
||||||
|
PG_DB = os.environ.get('PG_DB', 'gendesign')
|
||||||
|
PG_PASS = os.environ.get('PGPASSWORD', '')
|
||||||
|
SNAP = os.environ.get('SNAPSHOT_DATE', datetime.date.today().isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
def psql(sql):
|
||||||
|
cmd = ['docker', 'run', '--rm', '-i', '-e', f'PGPASSWORD={PG_PASS}',
|
||||||
|
'postgres:16-alpine', 'psql', '-h', 'host.docker.internal', '-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[-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 load_json(section, fname):
|
||||||
|
p = os.path.join(ROOT, section, fname + '.json')
|
||||||
|
if not os.path.exists(p): return None
|
||||||
|
return json.load(open(p, encoding='utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
def find_files(section, prefix):
|
||||||
|
"""Find files in section/ starting with prefix (e.g. 'launch_developers_app__')."""
|
||||||
|
sec_dir = os.path.join(ROOT, section)
|
||||||
|
if not os.path.isdir(sec_dir): return []
|
||||||
|
return [f for f in os.listdir(sec_dir) if f.startswith(prefix) and f.endswith('.json')]
|
||||||
|
|
||||||
|
|
||||||
|
def best_size_file(section, prefix):
|
||||||
|
"""Pick the file with largest size= parameter (or only one)."""
|
||||||
|
files = find_files(section, prefix)
|
||||||
|
if not files: return None
|
||||||
|
# Prefer size=1000 over size=15
|
||||||
|
files.sort(key=lambda f: 0 if 'size_1000' in f else 1)
|
||||||
|
return files[0][:-5] # strip .json
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
schema = open(os.path.join(HERE, '31_schema_domrf_full.sql'), encoding='utf-8').read()
|
||||||
|
psql(schema)
|
||||||
|
print('schema applied')
|
||||||
|
|
||||||
|
# ── 1. LAUNCH ────────────────────────────────────────────────────────────
|
||||||
|
rep_year = 2026
|
||||||
|
parts = []
|
||||||
|
for dim, prefix_app, prefix_rnv in [
|
||||||
|
('developer', 'launch_developers_app__', 'launch_developers_rnv__'),
|
||||||
|
('region', 'launch_regions_app__', 'launch_regions_rnv__'),
|
||||||
|
('area', 'launch_areas_app__', 'launch_areas_rnv__'),
|
||||||
|
('fo', 'launch_federal-districts_app__', 'launch_federal-districts_rnv__'),
|
||||||
|
]:
|
||||||
|
for metric, prefix in [('app', prefix_app), ('rnv', prefix_rnv)]:
|
||||||
|
fname = best_size_file('launch', prefix)
|
||||||
|
if not fname: continue
|
||||||
|
d = load_json('launch', fname)
|
||||||
|
calc = d.get('calculationType', 'SQUARE')
|
||||||
|
for v in d.get('values', []):
|
||||||
|
eid = str(v.get('id', v.get('name', '')))
|
||||||
|
ename = v.get('name', eid)
|
||||||
|
value = v.get('value')
|
||||||
|
if value is None: continue
|
||||||
|
parts.append(f"('{SNAP}', {rep_year}, {esc(calc)}, '{dim}', '{metric}', "
|
||||||
|
f"{esc(eid)}, {esc(ename)}, {num(value)})")
|
||||||
|
if parts:
|
||||||
|
for i in range(0, len(parts), 500):
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_launch_top (snapshot_date, rep_year, calc_type, dim_type, metric_type, entity_id, entity_name, value)
|
||||||
|
VALUES {','.join(parts[i:i+500])}
|
||||||
|
ON CONFLICT (snapshot_date, rep_year, calc_type, dim_type, metric_type, entity_id) DO UPDATE SET
|
||||||
|
entity_name = EXCLUDED.entity_name, value = EXCLUDED.value;
|
||||||
|
""")
|
||||||
|
print(f'launch_top: {len(parts)}')
|
||||||
|
|
||||||
|
# obj_class
|
||||||
|
d = load_json('launch', 'launch_obj-class_table__calculationType_SQUARE_repYear_2026') \
|
||||||
|
or load_json('launch', 'launch_obj-class_table')
|
||||||
|
if d:
|
||||||
|
calc = d.get('calculationType', 'SQUARE')
|
||||||
|
parts = [f"('{SNAP}', {rep_year}, {esc(calc)}, {integer(o['objClassCd'])}, "
|
||||||
|
f"{esc(o['objClassDesc'])}, {num(o.get('appValue'))}, {num(o.get('rnvValue'))})"
|
||||||
|
for o in d.get('objClassTypes', [])]
|
||||||
|
if parts:
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_launch_obj_class (snapshot_date, rep_year, calc_type, obj_class_cd, obj_class_desc, app_value, rnv_value)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (snapshot_date, rep_year, calc_type, obj_class_cd) DO UPDATE SET
|
||||||
|
obj_class_desc = EXCLUDED.obj_class_desc,
|
||||||
|
app_value = EXCLUDED.app_value, rnv_value = EXCLUDED.rnv_value;
|
||||||
|
""")
|
||||||
|
print(f'launch_obj_class: {len(parts)}')
|
||||||
|
|
||||||
|
# monthly time series
|
||||||
|
d = load_json('launch', 'charts')
|
||||||
|
if d:
|
||||||
|
calc = d.get('calculationType', 'SQUARE')
|
||||||
|
parts = []
|
||||||
|
for y in d.get('repYears', []):
|
||||||
|
yr = y['repYear']
|
||||||
|
for m in y.get('repMonth', []):
|
||||||
|
parts.append(f"('{SNAP}', {esc(calc)}, {yr}, {integer(m['repMonth'])}, "
|
||||||
|
f"{num(m.get('appValue'))}, {num(m.get('rnvValue'))})")
|
||||||
|
if parts:
|
||||||
|
for i in range(0, len(parts), 500):
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_launch_monthly (snapshot_date, calc_type, rep_year, rep_month, app_value, rnv_value)
|
||||||
|
VALUES {','.join(parts[i:i+500])}
|
||||||
|
ON CONFLICT (snapshot_date, calc_type, rep_year, rep_month) DO UPDATE SET
|
||||||
|
app_value = EXCLUDED.app_value, rnv_value = EXCLUDED.rnv_value;
|
||||||
|
""")
|
||||||
|
print(f'launch_monthly: {len(parts)}')
|
||||||
|
|
||||||
|
# ── 2. SOLD READY ────────────────────────────────────────────────────────
|
||||||
|
rep_year, rep_month = 2026, 3
|
||||||
|
d = load_json('sold_ready', 'ready-construction_index__repMonth_3_repYear_2026')
|
||||||
|
if d:
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_sold_ready_index (snapshot_date, rep_year, rep_month, square_sum, sold_perc, sold_sum, sold_ready_perc, ready_perc)
|
||||||
|
VALUES ('{SNAP}', {rep_year}, {rep_month}, {num(d.get('squareSumIndex'))},
|
||||||
|
{num(d.get('soldPercIndex'))}, {num(d.get('soldSumIndex'))},
|
||||||
|
{num(d.get('soldReadyPercIndex'))}, {num(d.get('readyPercIndex'))})
|
||||||
|
ON CONFLICT (snapshot_date, rep_year, rep_month) DO UPDATE SET
|
||||||
|
square_sum = EXCLUDED.square_sum, sold_perc = EXCLUDED.sold_perc,
|
||||||
|
sold_sum = EXCLUDED.sold_sum, sold_ready_perc = EXCLUDED.sold_ready_perc,
|
||||||
|
ready_perc = EXCLUDED.ready_perc;
|
||||||
|
""")
|
||||||
|
print('sold_ready_index: 1')
|
||||||
|
|
||||||
|
d = load_json('sold_ready', 'ready-construction_charts__repMonth_3_repYear_2026')
|
||||||
|
if d:
|
||||||
|
parts = []
|
||||||
|
seen = set()
|
||||||
|
for chart in d.get('charts', []):
|
||||||
|
ct = chart.get('chartType')
|
||||||
|
for row in chart.get('data', []):
|
||||||
|
key = row.get('key', '')
|
||||||
|
if not key: continue
|
||||||
|
pk = (ct, key)
|
||||||
|
if pk in seen: continue
|
||||||
|
seen.add(pk)
|
||||||
|
extra = {k: v for k, v in row.items() if k not in
|
||||||
|
{'key', 'squareSum', 'soldPerc', 'readyPerc', 'soldReadyPerc', 'foCd'}}
|
||||||
|
extra_json = json.dumps(extra, ensure_ascii=False) if extra else None
|
||||||
|
parts.append(f"('{SNAP}', {rep_year}, {rep_month}, {esc(ct)}, {esc(key)}, "
|
||||||
|
f"{num(row.get('squareSum'))}, {num(row.get('soldPerc'))}, "
|
||||||
|
f"{num(row.get('readyPerc'))}, {num(row.get('soldReadyPerc'))}, "
|
||||||
|
f"{integer(row.get('foCd'))}, "
|
||||||
|
f"{('NULL' if extra_json is None else esc(extra_json) + '::jsonb')})")
|
||||||
|
if parts:
|
||||||
|
for i in range(0, len(parts), 500):
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO 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, extra)
|
||||||
|
VALUES {','.join(parts[i:i+500])}
|
||||||
|
ON CONFLICT (snapshot_date, rep_year, rep_month, chart_type, entity_key) DO UPDATE SET
|
||||||
|
square_sum = EXCLUDED.square_sum, sold_perc = EXCLUDED.sold_perc,
|
||||||
|
ready_perc = EXCLUDED.ready_perc, sold_ready_perc = EXCLUDED.sold_ready_perc,
|
||||||
|
fo_cd = EXCLUDED.fo_cd, extra = EXCLUDED.extra;
|
||||||
|
""")
|
||||||
|
print(f'sold_ready_breakdown: {len(parts)}')
|
||||||
|
|
||||||
|
d = load_json('sold_ready', 'ready-construction_dynamics__dynamicChartType_readyPercChart_2CsoldPercChart_2CsoldReadyPercChart_2CsquareSum')
|
||||||
|
if d:
|
||||||
|
parts = []
|
||||||
|
for chart in d.get('dynamicCharts', []):
|
||||||
|
ct = chart.get('dynamicChartType')
|
||||||
|
for y in chart.get('repYears', []):
|
||||||
|
yr = y.get('repYear')
|
||||||
|
for m in y.get('repMonths', []):
|
||||||
|
parts.append(f"('{SNAP}', {esc(ct)}, {integer(yr)}, {integer(m.get('repMonth'))}, {num(m.get('value'))})")
|
||||||
|
if parts:
|
||||||
|
for i in range(0, len(parts), 500):
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_sold_ready_dynamics (snapshot_date, dynamic_chart_type, rep_year, rep_month, value)
|
||||||
|
VALUES {','.join(parts[i:i+500])}
|
||||||
|
ON CONFLICT (snapshot_date, dynamic_chart_type, rep_year, rep_month) DO UPDATE SET
|
||||||
|
value = EXCLUDED.value;
|
||||||
|
""")
|
||||||
|
print(f'sold_ready_dynamics: {len(parts)}')
|
||||||
|
|
||||||
|
# ── 3. PROJECT FINANCE (share_construction) ─────────────────────────────
|
||||||
|
d = load_json('share_construction', 'dashboard') or load_json('share_construction', 'project_finance_dashboard__dateDay_26-04-2026')
|
||||||
|
if d:
|
||||||
|
rows = d.get('data', {}).get('data', []) if isinstance(d.get('data'), dict) else d.get('data', [])
|
||||||
|
parts = []
|
||||||
|
for row in rows:
|
||||||
|
extra = {k: v for k, v in row.items() if k not in {
|
||||||
|
'id','subjectType','subject','regionCd','foCd','foDesc',
|
||||||
|
'rnsCnt','devCnt','livSqAmt',
|
||||||
|
'guarantyEscrowRnsCnt','guarantyEscrowDevCnt','guarantyEscrowLivSqAmt',
|
||||||
|
'guarantyZosgRnsCnt','guarantyZosgDevCnt','guarantyZosgLivSqAmt',
|
||||||
|
'guarantyRnsCnt','guarantyDevCnt','guarantyLivSqAmt',
|
||||||
|
'nonguarantyRnsCnt','nonguarantyDevCnt','nonguarantyLivSqAmt'}}
|
||||||
|
parts.append((
|
||||||
|
row.get('subjectType'), row.get('subject'),
|
||||||
|
row.get('regionCd'), row.get('foCd'), row.get('foDesc'),
|
||||||
|
row.get('rnsCnt'), row.get('devCnt'), row.get('livSqAmt'),
|
||||||
|
row.get('guarantyEscrowRnsCnt'), row.get('guarantyEscrowDevCnt'), row.get('guarantyEscrowLivSqAmt'),
|
||||||
|
row.get('guarantyZosgRnsCnt'), row.get('guarantyZosgDevCnt'), row.get('guarantyZosgLivSqAmt'),
|
||||||
|
row.get('guarantyRnsCnt'), row.get('guarantyDevCnt'), row.get('guarantyLivSqAmt'),
|
||||||
|
row.get('nonguarantyRnsCnt'), row.get('nonguarantyDevCnt'), row.get('nonguarantyLivSqAmt'),
|
||||||
|
json.dumps(extra, ensure_ascii=False) if extra else None,
|
||||||
|
))
|
||||||
|
if parts:
|
||||||
|
# Clear today's data first (since PK is by subject_type+name, may have duplicates by region within fo)
|
||||||
|
psql(f"DELETE FROM domrf_project_finance WHERE snapshot_date = '{SNAP}';")
|
||||||
|
sql_parts = []
|
||||||
|
for p in parts:
|
||||||
|
(st, sn, rc, fc, fd, rn, dn, ls, gers, gerd, gerls,
|
||||||
|
gzrs, gzrd, gzrls, grns, grdv, grls, nrns, nrdv, nrls, ej) = p
|
||||||
|
sql_parts.append(
|
||||||
|
f"('{SNAP}', NULL, {esc(st)}, {esc(sn)}, {integer(rc)}, {integer(fc)}, {esc(fd)}, "
|
||||||
|
f"{integer(rn)}, {integer(dn)}, {num(ls)}, "
|
||||||
|
f"{integer(gers)}, {integer(gerd)}, {num(gerls)}, "
|
||||||
|
f"{integer(gzrs)}, {integer(gzrd)}, {num(gzrls)}, "
|
||||||
|
f"{integer(grns)}, {integer(grdv)}, {num(grls)}, "
|
||||||
|
f"{integer(nrns)}, {integer(nrdv)}, {num(nrls)}, "
|
||||||
|
f"{('NULL' if ej is None else esc(ej) + '::jsonb')})"
|
||||||
|
)
|
||||||
|
for i in range(0, len(sql_parts), 200):
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_project_finance (snapshot_date, report_date, subject_type, subject_name,
|
||||||
|
region_cd, fo_cd, fo_desc, rns_cnt, dev_cnt, liv_sq_amt,
|
||||||
|
guaranty_escrow_rns_cnt, guaranty_escrow_dev_cnt, guaranty_escrow_liv_sq_amt,
|
||||||
|
guaranty_zosg_rns_cnt, guaranty_zosg_dev_cnt, guaranty_zosg_liv_sq_amt,
|
||||||
|
guaranty_rns_cnt, guaranty_dev_cnt, guaranty_liv_sq_amt,
|
||||||
|
nonguaranty_rns_cnt, nonguaranty_dev_cnt, nonguaranty_liv_sq_amt, extra)
|
||||||
|
VALUES {','.join(sql_parts[i:i+200])}
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
""")
|
||||||
|
print(f'project_finance: {len(parts)}')
|
||||||
|
|
||||||
|
# ── 4. COMMISSIONING ───────────────────────────────────────────────────
|
||||||
|
d = load_json('commissioning', 'building_summary_data')
|
||||||
|
if d:
|
||||||
|
rows = d.get('data', [])
|
||||||
|
parts = []
|
||||||
|
seen = set()
|
||||||
|
for r in rows:
|
||||||
|
pk = (r.get('regionId'), r.get('reportingPeriod'))
|
||||||
|
if pk in seen: continue
|
||||||
|
seen.add(pk)
|
||||||
|
extra = {k: v for k, v in r.items() if k not in {
|
||||||
|
'regionId','regionName','regionType','reportingPeriod','year',
|
||||||
|
'accumulatedFactArea','accumulatedFactAreaMultifamily','accumulatedFactAreaPrivate',
|
||||||
|
'accumulatedFactAreaChange','accumulatedFactAreaChangeShare'}}
|
||||||
|
parts.append(f"('{SNAP}', {integer(r.get('regionId'))}, {esc(r.get('regionName'))}, "
|
||||||
|
f"{esc(r.get('regionType'))}, {esc(r.get('reportingPeriod'))}::date, "
|
||||||
|
f"{integer(r.get('year'))}, "
|
||||||
|
f"{num(r.get('accumulatedFactArea'))}, "
|
||||||
|
f"{num(r.get('accumulatedFactAreaMultifamily'))}, "
|
||||||
|
f"{num(r.get('accumulatedFactAreaPrivate'))}, "
|
||||||
|
f"{num(r.get('accumulatedFactAreaChange'))}, "
|
||||||
|
f"{num(r.get('accumulatedFactAreaChangeShare'))}, "
|
||||||
|
f"{esc(json.dumps(extra, ensure_ascii=False)) + '::jsonb' if extra else 'NULL'})")
|
||||||
|
if parts:
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_commissioning (snapshot_date, region_id, region_name, region_type,
|
||||||
|
reporting_period, rep_year, accumulated_fact_area, accumulated_fact_area_multifamily,
|
||||||
|
accumulated_fact_area_private, accumulated_fact_area_change, accumulated_fact_area_change_share, extra)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (snapshot_date, region_id, reporting_period) DO UPDATE SET
|
||||||
|
region_name = EXCLUDED.region_name,
|
||||||
|
accumulated_fact_area = EXCLUDED.accumulated_fact_area,
|
||||||
|
accumulated_fact_area_multifamily = EXCLUDED.accumulated_fact_area_multifamily,
|
||||||
|
accumulated_fact_area_private = EXCLUDED.accumulated_fact_area_private,
|
||||||
|
accumulated_fact_area_change = EXCLUDED.accumulated_fact_area_change,
|
||||||
|
accumulated_fact_area_change_share = EXCLUDED.accumulated_fact_area_change_share;
|
||||||
|
""")
|
||||||
|
print(f'commissioning: {len(parts)}')
|
||||||
|
|
||||||
|
# ── 5. MORTGAGE STATS ──────────────────────────────────────────────────
|
||||||
|
d = load_json('mortgage_stats', 'mortgage_dashboard_general')
|
||||||
|
if d and d.get('data'):
|
||||||
|
m = d['data']
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_mortgage_dashboard (snapshot_date,
|
||||||
|
total_credit_count, total_credit_count_delta_pct,
|
||||||
|
primary_credit_count, primary_credit_count_delta_pct,
|
||||||
|
secondary_credit_count, secondary_credit_count_delta_pct,
|
||||||
|
total_credit_amount, total_credit_amount_delta_pct,
|
||||||
|
primary_credit_amount, primary_credit_amount_delta_pct,
|
||||||
|
secondary_credit_amount, secondary_credit_amount_delta_pct,
|
||||||
|
total_credit_avg_rate, total_credit_avg_rate_delta,
|
||||||
|
primary_credit_avg_rate, primary_credit_avg_rate_delta,
|
||||||
|
secondary_credit_avg_rate, secondary_credit_avg_rate_delta)
|
||||||
|
VALUES ('{SNAP}',
|
||||||
|
{integer(m.get('totalCreditCount'))}, {num(m.get('totalCreditCountDeltaPct'))},
|
||||||
|
{integer(m.get('primaryCreditCount'))}, {num(m.get('primaryCreditCountDeltaPct'))},
|
||||||
|
{integer(m.get('secondaryCreditCount'))}, {num(m.get('secondaryCreditCountDeltaPct'))},
|
||||||
|
{num(m.get('totalCreditAmount'))}, {num(m.get('totalCreditAmountDeltaPct'))},
|
||||||
|
{num(m.get('primaryCreditAmount'))}, {num(m.get('primaryCreditAmountDeltaPct'))},
|
||||||
|
{num(m.get('secondaryCreditAmount'))}, {num(m.get('secondaryCreditAmountDeltaPct'))},
|
||||||
|
{num(m.get('totalCreditAvgRate'))}, {num(m.get('totalCreditAvgRateDelta'))},
|
||||||
|
{num(m.get('primaryCreditAvgRate'))}, {num(m.get('primaryCreditAvgRateDelta'))},
|
||||||
|
{num(m.get('secondaryCreditAvgRate'))}, {num(m.get('secondaryCreditAvgRateDelta'))})
|
||||||
|
ON CONFLICT (snapshot_date) DO UPDATE SET
|
||||||
|
total_credit_count = EXCLUDED.total_credit_count,
|
||||||
|
total_credit_amount = EXCLUDED.total_credit_amount,
|
||||||
|
total_credit_avg_rate = EXCLUDED.total_credit_avg_rate;
|
||||||
|
""")
|
||||||
|
print('mortgage_dashboard: 1')
|
||||||
|
|
||||||
|
d = load_json('mortgage_stats', 'mortgage_dashboard_details')
|
||||||
|
if d and d.get('data'):
|
||||||
|
rows = d['data']
|
||||||
|
parts = [f"('{SNAP}', {esc(r.get('currency'))}, {num(r.get('creditAmountAvg'))}, "
|
||||||
|
f"{num(r.get('creditAmountAvgDeltaPct'))}, {num(r.get('creditAvgPeriod'))}, "
|
||||||
|
f"{num(r.get('creditAvgPeriodDeltaPct'))}, {num(r.get('creditDebtsAmount'))}, "
|
||||||
|
f"{num(r.get('creditDebtsAmountDeltaPct'))}, {num(r.get('creditDebtsOverduePercent'))}, "
|
||||||
|
f"{num(r.get('creditDebtsOverduePercentDelta'))})"
|
||||||
|
for r in rows]
|
||||||
|
if parts:
|
||||||
|
psql(f"""
|
||||||
|
INSERT INTO domrf_mortgage_details (snapshot_date, currency, credit_amount_avg,
|
||||||
|
credit_amount_avg_delta_pct, credit_avg_period, credit_avg_period_delta_pct,
|
||||||
|
credit_debts_amount, credit_debts_amount_delta_pct,
|
||||||
|
credit_debts_overdue_percent, credit_debts_overdue_percent_delta)
|
||||||
|
VALUES {','.join(parts)}
|
||||||
|
ON CONFLICT (snapshot_date, currency) DO UPDATE SET
|
||||||
|
credit_amount_avg = EXCLUDED.credit_amount_avg,
|
||||||
|
credit_avg_period = EXCLUDED.credit_avg_period,
|
||||||
|
credit_debts_amount = EXCLUDED.credit_debts_amount;
|
||||||
|
""")
|
||||||
|
print(f'mortgage_details: {len(parts)}')
|
||||||
|
|
||||||
|
print('\n--- summary ---')
|
||||||
|
out = psql(f"""
|
||||||
|
SELECT 'launch_top' tab, COUNT(*) FROM domrf_launch_top WHERE snapshot_date = '{SNAP}'
|
||||||
|
UNION ALL SELECT 'launch_obj_class', COUNT(*) FROM domrf_launch_obj_class WHERE snapshot_date = '{SNAP}'
|
||||||
|
UNION ALL SELECT 'launch_monthly', COUNT(*) FROM domrf_launch_monthly WHERE snapshot_date = '{SNAP}'
|
||||||
|
UNION ALL SELECT 'sold_ready_index', COUNT(*) FROM domrf_sold_ready_index WHERE snapshot_date = '{SNAP}'
|
||||||
|
UNION ALL SELECT 'sold_ready_breakdown', COUNT(*) FROM domrf_sold_ready_breakdown WHERE snapshot_date = '{SNAP}'
|
||||||
|
UNION ALL SELECT 'sold_ready_dynamics', COUNT(*) FROM domrf_sold_ready_dynamics WHERE snapshot_date = '{SNAP}'
|
||||||
|
UNION ALL SELECT 'project_finance', COUNT(*) FROM domrf_project_finance WHERE snapshot_date = '{SNAP}'
|
||||||
|
UNION ALL SELECT 'commissioning', COUNT(*) FROM domrf_commissioning WHERE snapshot_date = '{SNAP}'
|
||||||
|
UNION ALL SELECT 'mortgage_dashboard', COUNT(*) FROM domrf_mortgage_dashboard WHERE snapshot_date = '{SNAP}'
|
||||||
|
UNION ALL SELECT 'mortgage_details', COUNT(*) FROM domrf_mortgage_details WHERE snapshot_date = '{SNAP}'
|
||||||
|
ORDER BY 1;
|
||||||
|
""")
|
||||||
|
print(out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
99
data/sql/32_load_domrf_raw.py
Normal file
99
data/sql/32_load_domrf_raw.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""Universal loader: dumps every scraped JSON from data/raw/domrf_full/ into Postgres
|
||||||
|
as JSONB rows in domrf_raw_endpoints. Idempotent (UPSERT by snapshot+section+endpoint).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
PG_HOST=localhost PG_PORT=15432 PGPASSWORD=... python data/sql/32_load_domrf_raw.py
|
||||||
|
"""
|
||||||
|
import os, json, subprocess, sys, datetime
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ROOT = os.path.join(HERE, '..', 'raw', 'domrf_full')
|
||||||
|
PG_PORT = os.environ.get('PG_PORT', '5432')
|
||||||
|
PG_USER = os.environ.get('PG_USER', 'gendesign')
|
||||||
|
PG_DB = os.environ.get('PG_DB', 'gendesign')
|
||||||
|
PG_PASS = os.environ.get('PGPASSWORD', '')
|
||||||
|
SNAP = os.environ.get('SNAPSHOT_DATE', datetime.date.today().isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
def psql(sql):
|
||||||
|
cmd = ['docker', 'run', '--rm', '-i', '-e', f'PGPASSWORD={PG_PASS}',
|
||||||
|
'postgres:16-alpine', 'psql', '-h', 'host.docker.internal', '-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[-1500:], file=sys.stderr)
|
||||||
|
raise SystemExit(res.returncode)
|
||||||
|
return res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def psql_stdin(sql, stdin_data):
|
||||||
|
"""Use psql with COPY via stdin to load big JSONs (avoids cmdline length limits)."""
|
||||||
|
cmd = ['docker', 'run', '--rm', '-i', '-e', f'PGPASSWORD={PG_PASS}',
|
||||||
|
'postgres:16-alpine', 'psql', '-h', 'host.docker.internal', '-p', PG_PORT,
|
||||||
|
'-U', PG_USER, '-d', PG_DB, '-v', 'ON_ERROR_STOP=1', '--quiet']
|
||||||
|
res = subprocess.run(cmd, input=stdin_data, capture_output=True, text=True, encoding='utf-8')
|
||||||
|
if res.returncode != 0:
|
||||||
|
print('psql FAILED:', res.stderr[-1500:], file=sys.stderr)
|
||||||
|
raise SystemExit(res.returncode)
|
||||||
|
return res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
schema = open(os.path.join(HERE, '31_schema_domrf_raw.sql'), encoding='utf-8').read()
|
||||||
|
psql(schema)
|
||||||
|
print('schema applied')
|
||||||
|
|
||||||
|
loaded = 0
|
||||||
|
skipped = 0
|
||||||
|
by_section = {}
|
||||||
|
|
||||||
|
for section in sorted(os.listdir(ROOT)):
|
||||||
|
sec_dir = os.path.join(ROOT, section)
|
||||||
|
if not os.path.isdir(sec_dir):
|
||||||
|
continue
|
||||||
|
files = [f for f in os.listdir(sec_dir) if f.endswith('.json')]
|
||||||
|
for fname in sorted(files):
|
||||||
|
fpath = os.path.join(sec_dir, fname)
|
||||||
|
try:
|
||||||
|
text = open(fpath, encoding='utf-8').read()
|
||||||
|
json.loads(text) # validate
|
||||||
|
except Exception as e:
|
||||||
|
print(f' SKIP invalid JSON: {section}/{fname}: {e}')
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
endpoint = fname[:-5] # strip .json
|
||||||
|
# Use COPY ... FROM STDIN for safe binary insertion
|
||||||
|
# Use psycopg-style escape: \\ → \\\\, replace tabs/newlines in JSON keys
|
||||||
|
# Easier: pass JSON via stdin to a Python heredoc-style INSERT
|
||||||
|
sql = f"""
|
||||||
|
INSERT INTO domrf_raw_endpoints (snapshot_date, section, endpoint, source_url, payload, payload_size, fetched_at)
|
||||||
|
VALUES ('{SNAP}', $sec${section}$sec$, $end${endpoint}$end$, NULL, $j${text}$j$::jsonb, {len(text)}, NOW())
|
||||||
|
ON CONFLICT (snapshot_date, section, endpoint) DO UPDATE SET
|
||||||
|
payload = EXCLUDED.payload,
|
||||||
|
payload_size = EXCLUDED.payload_size,
|
||||||
|
fetched_at = NOW();
|
||||||
|
"""
|
||||||
|
psql_stdin('', sql)
|
||||||
|
loaded += 1
|
||||||
|
by_section.setdefault(section, 0)
|
||||||
|
by_section[section] += 1
|
||||||
|
|
||||||
|
print(f'\nLOADED {loaded} files, SKIPPED {skipped}')
|
||||||
|
print('--- by section ---')
|
||||||
|
for s, n in sorted(by_section.items()):
|
||||||
|
print(f' {s:22s} {n}')
|
||||||
|
|
||||||
|
# Final summary from DB
|
||||||
|
print('\n--- DB summary ---')
|
||||||
|
out = psql(f"""
|
||||||
|
SELECT section, COUNT(*) AS endpoints, pg_size_pretty(SUM(payload_size)::bigint) AS total
|
||||||
|
FROM domrf_raw_endpoints
|
||||||
|
WHERE snapshot_date = '{SNAP}'
|
||||||
|
GROUP BY section
|
||||||
|
ORDER BY section;
|
||||||
|
""")
|
||||||
|
print(out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
287
data/sql/40_relations.sql
Normal file
287
data/sql/40_relations.sql
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
-- Foreign-key constraints + helper VIEWs across DOM.RF + Rosreestr tables.
|
||||||
|
-- Idempotent: bootstraps missing dimension rows first, then ALTER TABLE ... ADD CONSTRAINT IF NOT EXISTS.
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 1. BOOTSTRAP DIMENSION TABLES
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Add row 0 = РФ to domrf_regions (used as "all-Russia" placeholder by some tables)
|
||||||
|
INSERT INTO domrf_regions (region_id, region_name, federal_district)
|
||||||
|
VALUES (0, 'Российская Федерация', '_RF')
|
||||||
|
ON CONFLICT (region_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Backfill domrf_snapshots from every snapshot_date currently used in children
|
||||||
|
INSERT INTO domrf_snapshots (snapshot_date, notes)
|
||||||
|
SELECT DISTINCT s, 'auto-backfilled by 40_relations.sql' FROM (
|
||||||
|
SELECT snapshot_date AS s FROM domrf_developers_full
|
||||||
|
UNION SELECT snapshot_date FROM domrf_escrow_banks
|
||||||
|
UNION SELECT snapshot_date FROM domrf_guaranty_regions
|
||||||
|
UNION SELECT snapshot_date FROM domrf_housing_summary
|
||||||
|
UNION SELECT snapshot_date FROM domrf_planned_commissioning
|
||||||
|
UNION SELECT snapshot_date FROM domrf_sold_out
|
||||||
|
UNION SELECT snapshot_date FROM domrf_sold_out_by_year
|
||||||
|
UNION SELECT snapshot_date FROM domrf_sold_out_by_progress
|
||||||
|
UNION SELECT snapshot_date FROM domrf_mortgage_rates
|
||||||
|
UNION SELECT snapshot_date FROM domrf_region_aggregates
|
||||||
|
UNION SELECT snapshot_date FROM domrf_flat_area_distribution
|
||||||
|
UNION SELECT snapshot_date FROM domrf_developer_aggregates
|
||||||
|
UNION SELECT snapshot_date FROM domrf_launch_top
|
||||||
|
UNION SELECT snapshot_date FROM domrf_launch_obj_class
|
||||||
|
UNION SELECT snapshot_date FROM domrf_launch_monthly
|
||||||
|
UNION SELECT snapshot_date FROM domrf_sold_ready_index
|
||||||
|
UNION SELECT snapshot_date FROM domrf_sold_ready_breakdown
|
||||||
|
UNION SELECT snapshot_date FROM domrf_sold_ready_dynamics
|
||||||
|
UNION SELECT snapshot_date FROM domrf_project_finance
|
||||||
|
UNION SELECT snapshot_date FROM domrf_commissioning
|
||||||
|
UNION SELECT snapshot_date FROM domrf_mortgage_dashboard
|
||||||
|
UNION SELECT snapshot_date FROM domrf_mortgage_details
|
||||||
|
) AS dates
|
||||||
|
ON CONFLICT (snapshot_date) DO NOTHING;
|
||||||
|
|
||||||
|
-- Helper to add a constraint only if it does not already exist
|
||||||
|
-- (PostgreSQL ≥9.6: do via DO block; idempotent)
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
rec RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────
|
||||||
|
-- 2. SNAPSHOT FKs (every domrf_* with snapshot_date column)
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────
|
||||||
|
FOR rec IN SELECT * FROM (VALUES
|
||||||
|
('domrf_developers_full', 'fk_snap_developers_full'),
|
||||||
|
('domrf_escrow_banks', 'fk_snap_escrow_banks'),
|
||||||
|
('domrf_guaranty_regions', 'fk_snap_guaranty_regions'),
|
||||||
|
('domrf_housing_summary', 'fk_snap_housing_summary'),
|
||||||
|
('domrf_planned_commissioning', 'fk_snap_planned_commissioning'),
|
||||||
|
('domrf_sold_out', 'fk_snap_sold_out'),
|
||||||
|
('domrf_sold_out_by_year', 'fk_snap_sold_out_by_year'),
|
||||||
|
('domrf_sold_out_by_progress', 'fk_snap_sold_out_by_progress'),
|
||||||
|
('domrf_mortgage_rates', 'fk_snap_mortgage_rates'),
|
||||||
|
('domrf_region_aggregates', 'fk_snap_region_aggregates'),
|
||||||
|
('domrf_flat_area_distribution', 'fk_snap_flat_area_distribution'),
|
||||||
|
('domrf_developer_aggregates', 'fk_snap_developer_aggregates'),
|
||||||
|
('domrf_launch_top', 'fk_snap_launch_top'),
|
||||||
|
('domrf_launch_obj_class', 'fk_snap_launch_obj_class'),
|
||||||
|
('domrf_launch_monthly', 'fk_snap_launch_monthly'),
|
||||||
|
('domrf_sold_ready_index', 'fk_snap_sold_ready_index'),
|
||||||
|
('domrf_sold_ready_breakdown', 'fk_snap_sold_ready_breakdown'),
|
||||||
|
('domrf_sold_ready_dynamics', 'fk_snap_sold_ready_dynamics'),
|
||||||
|
('domrf_project_finance', 'fk_snap_project_finance'),
|
||||||
|
('domrf_commissioning', 'fk_snap_commissioning'),
|
||||||
|
('domrf_mortgage_dashboard', 'fk_snap_mortgage_dashboard'),
|
||||||
|
('domrf_mortgage_details', 'fk_snap_mortgage_details'),
|
||||||
|
('domrf_raw_endpoints', 'fk_snap_raw_endpoints')
|
||||||
|
) AS t(tbl, cn)
|
||||||
|
LOOP
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = rec.cn
|
||||||
|
) THEN
|
||||||
|
EXECUTE format(
|
||||||
|
'ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (snapshot_date) REFERENCES domrf_snapshots(snapshot_date) ON UPDATE CASCADE',
|
||||||
|
rec.tbl, rec.cn
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────
|
||||||
|
-- 3. REGION FKs (domrf_region_aggregates / flat_area_distribution / developer_aggregates → domrf_regions)
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_region_aggregates_region') THEN
|
||||||
|
ALTER TABLE domrf_region_aggregates
|
||||||
|
ADD CONSTRAINT fk_region_aggregates_region
|
||||||
|
FOREIGN KEY (region_id) REFERENCES domrf_regions(region_id) ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_flat_area_region') THEN
|
||||||
|
ALTER TABLE domrf_flat_area_distribution
|
||||||
|
ADD CONSTRAINT fk_flat_area_region
|
||||||
|
FOREIGN KEY (region_id) REFERENCES domrf_regions(region_id) ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_developer_agg_region') THEN
|
||||||
|
ALTER TABLE domrf_developer_aggregates
|
||||||
|
ADD CONSTRAINT fk_developer_agg_region
|
||||||
|
FOREIGN KEY (region_id) REFERENCES domrf_regions(region_id) ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────
|
||||||
|
-- 4. DEVELOPER FK (domrf_developer_aggregates → domrf_developers)
|
||||||
|
-- ──────────────────────────────────────────────────────────────────────
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_developer_agg_developer') THEN
|
||||||
|
ALTER TABLE domrf_developer_aggregates
|
||||||
|
ADD CONSTRAINT fk_developer_agg_developer
|
||||||
|
FOREIGN KEY (developer_id) REFERENCES domrf_developers(developer_id) ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 5. HELPER VIEWS
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- v_region_master: одна строка на регион — все ключевые метрики на последний снапшот
|
||||||
|
DROP VIEW IF EXISTS v_region_master CASCADE;
|
||||||
|
CREATE VIEW v_region_master AS
|
||||||
|
WITH latest AS (SELECT MAX(snapshot_date) AS d FROM domrf_snapshots)
|
||||||
|
SELECT
|
||||||
|
r.region_id,
|
||||||
|
r.region_name,
|
||||||
|
r.federal_district,
|
||||||
|
-- Quartirografia totals (TOTAL row)
|
||||||
|
rta.flat_count AS total_flats,
|
||||||
|
rta.area_sqm AS total_area_sqm,
|
||||||
|
-- Sold-out (DOM.RF PDF data)
|
||||||
|
so.total_area_th_sqm AS sold_out_total_th_sqm,
|
||||||
|
so.sold_pct AS sold_out_sold_pct,
|
||||||
|
so.unsold_pct AS sold_out_unsold_pct,
|
||||||
|
-- Sold-ready breakdown (Playwright API data)
|
||||||
|
srb.square_sum AS sold_ready_square,
|
||||||
|
srb.sold_perc AS sold_ready_sold_pct,
|
||||||
|
srb.ready_perc AS sold_ready_ready_pct,
|
||||||
|
srb.sold_ready_perc AS sold_ready_balance_pct,
|
||||||
|
-- Project finance
|
||||||
|
pf.rns_cnt AS project_finance_permits,
|
||||||
|
pf.dev_cnt AS project_finance_developers,
|
||||||
|
pf.guaranty_escrow_dev_cnt AS project_finance_escrow_devs,
|
||||||
|
-- Guaranty (XLSX)
|
||||||
|
g.fz214_developers AS guaranty_fz214_devs,
|
||||||
|
g.escrow_developers AS guaranty_escrow_devs,
|
||||||
|
-- Launch (запуски новых)
|
||||||
|
(SELECT value FROM domrf_launch_top
|
||||||
|
WHERE dim_type = 'region' AND metric_type = 'app'
|
||||||
|
AND entity_id = r.region_id::text AND snapshot_date = (SELECT d FROM latest)
|
||||||
|
LIMIT 1) AS launch_app_2026_sqm,
|
||||||
|
(SELECT value FROM domrf_launch_top
|
||||||
|
WHERE dim_type = 'region' AND metric_type = 'rnv'
|
||||||
|
AND entity_id = r.region_id::text AND snapshot_date = (SELECT d FROM latest)
|
||||||
|
LIMIT 1) AS launch_rnv_2026_sqm
|
||||||
|
FROM domrf_regions r
|
||||||
|
LEFT JOIN domrf_region_aggregates rta
|
||||||
|
ON rta.region_id = r.region_id
|
||||||
|
AND rta.room_count_type = 'TOTAL'
|
||||||
|
AND rta.snapshot_date = (SELECT d FROM latest)
|
||||||
|
LEFT JOIN domrf_sold_out so
|
||||||
|
ON so.territory_name = r.region_name
|
||||||
|
AND so.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_sold_out WHERE territory_name = r.region_name)
|
||||||
|
LEFT JOIN domrf_sold_ready_breakdown srb
|
||||||
|
ON srb.entity_key = r.region_name
|
||||||
|
AND srb.chart_type = 'regionChart'
|
||||||
|
AND srb.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_sold_ready_breakdown WHERE chart_type='regionChart')
|
||||||
|
LEFT JOIN domrf_project_finance pf
|
||||||
|
ON pf.subject_name = r.region_name
|
||||||
|
AND pf.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_project_finance)
|
||||||
|
LEFT JOIN domrf_guaranty_regions g
|
||||||
|
ON g.territory_name = r.region_name
|
||||||
|
AND g.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_guaranty_regions)
|
||||||
|
WHERE r.region_id > 0
|
||||||
|
ORDER BY total_area_sqm DESC NULLS LAST;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_region_master IS
|
||||||
|
'Один срез по региону: квартирография + распроданность (DOM.РФ + sold_out PDF) + проектное финансирование + запуски/ввод 2026';
|
||||||
|
|
||||||
|
-- v_developer_master: профиль застройщика (latest snapshot)
|
||||||
|
DROP VIEW IF EXISTS v_developer_master CASCADE;
|
||||||
|
CREATE VIEW v_developer_master AS
|
||||||
|
WITH latest AS (SELECT MAX(snapshot_date) AS d FROM domrf_snapshots)
|
||||||
|
SELECT
|
||||||
|
d.developer_id,
|
||||||
|
d.developer_name,
|
||||||
|
-- Group-of-companies aggregates (from JSON dashboard)
|
||||||
|
dat.flat_count AS group_flat_count,
|
||||||
|
dat.area_sqm AS group_area_sqm,
|
||||||
|
-- Per-юрлицо реестр (XLSX)
|
||||||
|
df.area_thousand_sqm AS legal_area_th_sqm,
|
||||||
|
df.permits_count AS legal_permits,
|
||||||
|
df.houses_count AS legal_houses,
|
||||||
|
df.flats_count AS legal_flats,
|
||||||
|
df.market_share_pct AS market_share_pct,
|
||||||
|
-- Запуски / ввод 2026 (из launch_top, dim=developer)
|
||||||
|
(SELECT value FROM domrf_launch_top
|
||||||
|
WHERE dim_type='developer' AND metric_type='app'
|
||||||
|
AND entity_id = d.developer_id AND snapshot_date = (SELECT d FROM latest)
|
||||||
|
LIMIT 1) AS launch_app_2026_sqm,
|
||||||
|
(SELECT value FROM domrf_launch_top
|
||||||
|
WHERE dim_type='developer' AND metric_type='rnv'
|
||||||
|
AND entity_id = d.developer_id AND snapshot_date = (SELECT d FROM latest)
|
||||||
|
LIMIT 1) AS launch_rnv_2026_sqm,
|
||||||
|
-- Распроданность (devChart)
|
||||||
|
(SELECT square_sum FROM domrf_sold_ready_breakdown
|
||||||
|
WHERE chart_type='devChart' AND entity_key = d.developer_name
|
||||||
|
AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_sold_ready_breakdown WHERE chart_type='devChart')
|
||||||
|
LIMIT 1) AS sold_ready_square,
|
||||||
|
(SELECT sold_perc FROM domrf_sold_ready_breakdown
|
||||||
|
WHERE chart_type='devChart' AND entity_key = d.developer_name
|
||||||
|
AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_sold_ready_breakdown WHERE chart_type='devChart')
|
||||||
|
LIMIT 1) AS sold_ready_sold_pct
|
||||||
|
FROM domrf_developers d
|
||||||
|
LEFT JOIN domrf_developer_aggregates dat
|
||||||
|
ON dat.developer_id = d.developer_id
|
||||||
|
AND dat.room_count_type = 'TOTAL'
|
||||||
|
AND dat.snapshot_date = (SELECT d FROM latest)
|
||||||
|
LEFT JOIN domrf_developers_full df
|
||||||
|
ON df.developer_name = d.developer_name
|
||||||
|
AND df.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_developers_full WHERE developer_name = d.developer_name);
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_developer_master IS
|
||||||
|
'Профиль застройщика: реестр + квартирография по комнатностям + запуски/ввод + распроданность';
|
||||||
|
|
||||||
|
-- v_rosreestr_market_by_region — срез цен сделок и застройщиков по региону
|
||||||
|
DROP VIEW IF EXISTS v_rosreestr_market_by_region CASCADE;
|
||||||
|
CREATE VIEW v_rosreestr_market_by_region AS
|
||||||
|
SELECT
|
||||||
|
r.region_id,
|
||||||
|
r.region_name,
|
||||||
|
r.federal_district,
|
||||||
|
rar.period_start_date,
|
||||||
|
rar.doc_type,
|
||||||
|
rar.deals_count,
|
||||||
|
rar.median_price_sqm AS median_rub_sqm,
|
||||||
|
rar.p10_price_sqm AS p10_rub_sqm,
|
||||||
|
rar.p90_price_sqm AS p90_rub_sqm
|
||||||
|
FROM domrf_regions r
|
||||||
|
JOIN rr_agg_region rar
|
||||||
|
ON rar.region_code = r.region_id
|
||||||
|
WHERE rar.realestate_type_code = '002001003000' -- Помещения (квартиры)
|
||||||
|
ORDER BY r.region_id, rar.period_start_date, rar.doc_type;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_rosreestr_market_by_region IS
|
||||||
|
'Цены сделок Росреестра по регионам с DOM.РФ-именами регионов (квартиры)';
|
||||||
|
|
||||||
|
-- v_market_pulse_rf — РФ-уровень summary с разных источников за last snapshot
|
||||||
|
DROP VIEW IF EXISTS v_market_pulse_rf CASCADE;
|
||||||
|
CREATE VIEW v_market_pulse_rf AS
|
||||||
|
SELECT
|
||||||
|
-- Quartirografia
|
||||||
|
(SELECT SUM(area_sqm)/1e6 FROM domrf_region_aggregates
|
||||||
|
WHERE room_count_type='TOTAL'
|
||||||
|
AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_region_aggregates)) AS total_construction_mln_sqm,
|
||||||
|
-- Sold-ready
|
||||||
|
(SELECT square_sum/1e6 FROM domrf_sold_ready_index
|
||||||
|
ORDER BY snapshot_date DESC LIMIT 1) AS sold_ready_total_mln_sqm,
|
||||||
|
(SELECT sold_perc FROM domrf_sold_ready_index
|
||||||
|
ORDER BY snapshot_date DESC LIMIT 1) AS sold_pct,
|
||||||
|
(SELECT ready_perc FROM domrf_sold_ready_index
|
||||||
|
ORDER BY snapshot_date DESC LIMIT 1) AS ready_pct,
|
||||||
|
-- Прайс
|
||||||
|
(SELECT avg_price_per_sqm FROM domrf_sold_out
|
||||||
|
WHERE territory_name = 'Российская Федерация'
|
||||||
|
ORDER BY snapshot_date DESC LIMIT 1) AS rf_avg_price_per_sqm,
|
||||||
|
-- Ставки
|
||||||
|
(SELECT primary_rate FROM domrf_mortgage_rates
|
||||||
|
WHERE bank_name = 'Средневзвешенная'
|
||||||
|
ORDER BY snapshot_date DESC LIMIT 1) AS market_primary_rate,
|
||||||
|
(SELECT secondary_rate FROM domrf_mortgage_rates
|
||||||
|
WHERE bank_name = 'Средневзвешенная'
|
||||||
|
ORDER BY snapshot_date DESC LIMIT 1) AS market_secondary_rate,
|
||||||
|
-- Реальные ставки (по выданным кредитам)
|
||||||
|
(SELECT primary_credit_avg_rate FROM domrf_mortgage_dashboard
|
||||||
|
ORDER BY snapshot_date DESC LIMIT 1) AS actual_primary_rate,
|
||||||
|
(SELECT secondary_credit_avg_rate FROM domrf_mortgage_dashboard
|
||||||
|
ORDER BY snapshot_date DESC LIMIT 1) AS actual_secondary_rate,
|
||||||
|
-- Объём кредитования
|
||||||
|
(SELECT total_credit_count FROM domrf_mortgage_dashboard
|
||||||
|
ORDER BY snapshot_date DESC LIMIT 1) AS total_credit_count,
|
||||||
|
(SELECT total_credit_amount/1e9 FROM domrf_mortgage_dashboard
|
||||||
|
ORDER BY snapshot_date DESC LIMIT 1) AS total_credit_amount_bln_rub;
|
||||||
|
|
||||||
|
COMMENT ON VIEW v_market_pulse_rf IS
|
||||||
|
'РФ pulse: объём строительства, распроданность, средняя цена, ставки (рыночные vs реальные), объём ипотеки';
|
||||||
11
data/sql/decode_to_file.py
Normal file
11
data/sql/decode_to_file.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""Reads JSON-encoded string from stdin (the kind MCP returns when wrapping a string),
|
||||||
|
and writes the decoded value as UTF-8 to the given file."""
|
||||||
|
import json, sys, os
|
||||||
|
out_path = sys.argv[1]
|
||||||
|
raw = sys.stdin.read()
|
||||||
|
# raw is a JSON string like "[{\"name\":\"...\"}]" — already JSON-quoted
|
||||||
|
decoded = json.loads(raw)
|
||||||
|
os.makedirs(os.path.dirname(out_path) or '.', exist_ok=True)
|
||||||
|
with open(out_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(decoded)
|
||||||
|
print(f'wrote {out_path} ({len(decoded)} chars)')
|
||||||
57
data/sql/upload_server.py
Normal file
57
data/sql/upload_server.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Tiny HTTP receiver: POST <body> to /<path>, file gets written to data/raw/domrf/<path>.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python data/sql/upload_server.py # listens on 127.0.0.1:8765
|
||||||
|
|
||||||
|
In browser DevTools:
|
||||||
|
fetch('http://127.0.0.1:8765/regions.json', { method: 'POST', body: JSON.stringify(window.__domrf['regions']) });
|
||||||
|
"""
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.join(os.path.dirname(__file__), '..', 'raw', 'domrf')
|
||||||
|
os.makedirs(ROOT, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def _cors(self):
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
|
||||||
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.send_response(204)
|
||||||
|
self._cors()
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get('Content-Length', 0))
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
name = self.path.lstrip('/').replace('..', '_').replace('\\', '_')
|
||||||
|
if not name:
|
||||||
|
name = 'upload.json'
|
||||||
|
path = os.path.join(ROOT, name)
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
f.write(body)
|
||||||
|
print(f'WROTE {path} ({len(body)} bytes)', flush=True)
|
||||||
|
self.send_response(200)
|
||||||
|
self._cors()
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"ok":true}')
|
||||||
|
except Exception as e:
|
||||||
|
self.send_response(500)
|
||||||
|
self._cors()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(str(e).encode())
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
addr = ('127.0.0.1', 8765)
|
||||||
|
print(f'listening on http://{addr[0]}:{addr[1]}, output dir: {os.path.abspath(ROOT)}', flush=True)
|
||||||
|
HTTPServer(addr, H).serve_forever()
|
||||||
File diff suppressed because one or more lines are too long
24
sshkey.txt
24
sshkey.txt
|
|
@ -56,3 +56,27 @@ Port: 15432
|
||||||
Database: gendesign
|
Database: gendesign
|
||||||
Username: gendesign
|
Username: gendesign
|
||||||
Password: 2J2SBPMKuS998fiwhtQqDhMI
|
Password: 2J2SBPMKuS998fiwhtQqDhMI
|
||||||
|
|
||||||
|
Локально установить pre-commit один раз:
|
||||||
|
make pre-commit-install
|
||||||
|
# или:
|
||||||
|
pip install pre-commit
|
||||||
|
pre-commit install
|
||||||
|
|
||||||
|
python -m pre_commit install
|
||||||
|
|
||||||
|
python -m pre_commit run --all-files
|
||||||
|
# или, если PATH добавлен:
|
||||||
|
pre-commit run --all-files
|
||||||
|
|
||||||
|
Только текущая сессия:
|
||||||
|
|
||||||
|
$env:PATH += ";$env:LOCALAPPDATA\Python\pythoncore-3.14-64\Scripts"
|
||||||
|
pre-commit install
|
||||||
|
Постоянно (нужно ОДИН раз):
|
||||||
|
|
||||||
|
[Environment]::SetEnvironmentVariable(
|
||||||
|
"PATH",
|
||||||
|
$env:PATH + ";$env:LOCALAPPDATA\Python\pythoncore-3.14-64\Scripts",
|
||||||
|
"User"
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue