Adds site-finder/ subfolder with:
- server.py — FastAPI scoring service v2 (35 endpoints, ~85KB)
- 01_load_sites.py … 12_more_pois.py — data ingest pipeline
- db_init.py — SQLite schema bootstrap
- static/ — Leaflet UI (index.html ~3500 lines + sw.js)
- cache/ — small persistent caches (admin districts, jk polygons,
geocode warm cache, parcel polygons drop-zone with README)
- reports/ — sample generated parcel report (HTML+JSON)
Excluded via .gitignore (regeneratable, too big for git):
- analysis.db (336MB SQLite — rebuild via 01_*..12_*.py)
- cache/objective_raw/ (1.2GB Объектив raw dumps)
- cache/overpass_raw.json, cache/osm_buildings_all.geojson
(regen from Overpass API)
Production deploy: /opt/gendesign/site-finder/ on gendsgn.ru
(container gendesign-site-finder-1, served at /sf/).
148 lines
6.3 KiB
Python
148 lines
6.3 KiB
Python
"""Step 1.1: Pull Objective API and store in local DB.
|
||
|
||
We pull "Сводные/Корпуса" (corp_sum) for Ekb covering last 12 calendar months —
|
||
this is the primary source of:
|
||
- per-corpus monthly: deals (DDU+DKP), volume sold (m²), avg price/m², stock left
|
||
- готовность %, старт продаж, планируемая дата ввода
|
||
- район (Objective's classification)
|
||
|
||
Data lands in two tables:
|
||
objective_raw_reports — JSON payload archive (1 row per fetch)
|
||
objective_corp_month — flattened rows (one per month × project × corpus × room-type)
|
||
"""
|
||
import sqlite3, pathlib, requests, json, time, datetime as dt
|
||
|
||
DB = pathlib.Path(__file__).parent / "analysis.db"
|
||
API = "https://api.objctv.ru"
|
||
KEY = "623f6a57-0179-434b-8202-259525bdc77c"
|
||
|
||
SCHEMA = """
|
||
CREATE TABLE IF NOT EXISTS objective_raw_reports (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||
report_kind TEXT,
|
||
group_name TEXT,
|
||
start_date TEXT,
|
||
end_date TEXT,
|
||
n_rows INTEGER,
|
||
payload TEXT
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS objective_corp_month (
|
||
month TEXT, -- 'YYYY-MM'
|
||
project TEXT,
|
||
developer TEXT,
|
||
district TEXT,
|
||
obj_class TEXT,
|
||
corpus TEXT,
|
||
sales_start TEXT,
|
||
plan_date TEXT,
|
||
fact_date TEXT,
|
||
months_in_sales INTEGER,
|
||
rooms_bucket TEXT, -- '1', '2', '3', '4+', 'студия', etc.
|
||
lots_pd INTEGER,
|
||
area_pd REAL,
|
||
deals_total INTEGER,
|
||
deals_priced INTEGER,
|
||
sold_volume_m2 REAL,
|
||
avg_price_m2 REAL,
|
||
avg_area_m2 REAL,
|
||
stock_lots INTEGER,
|
||
stock_m2 REAL,
|
||
stock_avg_price_m2 REAL,
|
||
PRIMARY KEY (month, project, corpus, rooms_bucket)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS oc_district ON objective_corp_month(district);
|
||
CREATE INDEX IF NOT EXISTS oc_project ON objective_corp_month(project);
|
||
"""
|
||
|
||
def get_token():
|
||
r = requests.get(f"{API}/Users/User/GetToken", params={"apiKey": KEY}, timeout=30)
|
||
r.raise_for_status()
|
||
return r.json()["token"]
|
||
|
||
def fetch_corp_sum(token, group, sd, ed):
|
||
r = requests.get(f"{API}/v2/Report/GetReport",
|
||
params={"Page":"Отчеты","ReportSection":"Объединенные данные",
|
||
"ReportType":"Сводные","ReportName":"Корпуса",
|
||
"GroupName":group,"StartDate":sd,"EndDate":ed,
|
||
"UseDdu":"true","UseDkp":"true"},
|
||
headers={"Authorization":f"Bearer {token}","Accept-Encoding":"br"},
|
||
timeout=120)
|
||
r.raise_for_status()
|
||
return r.json().get("result", [])
|
||
|
||
# Map raw RU column names to our schema
|
||
COL_MAP = {
|
||
"month":"Месяц","project":"Проект","developer":"Девелопер","district":"Район",
|
||
"obj_class":"Класс","corpus":"Корпус","sales_start":"Старт продаж",
|
||
"plan_date":"Планируемая дата ввода","fact_date":"Фактическая дата ввода",
|
||
"months_in_sales":"Месяцев в реализации","rooms_bucket":"Количество комнат (Данные Объектива)",
|
||
"lots_pd":"Лотов по ПД, шт.","area_pd":"Площадь по ПД, м2.",
|
||
"deals_total":"Количество в сделках (всего), шт.",
|
||
"deals_priced":"Количество лотов в сделках (с ценами), шт.",
|
||
"sold_volume_m2":"Объем реализации (всего), м2.",
|
||
"avg_price_m2":"Средняя цена м2 лота в сделках, тыс.Р/м2",
|
||
"avg_area_m2":"Средняя площадь лота в сделках, м2",
|
||
"stock_lots":"Объем предложения, шт.","stock_m2":"Объем предложения, м2.",
|
||
"stock_avg_price_m2":"Средняя цена м2 лота в продаже, тыс.Р/м2",
|
||
}
|
||
|
||
RU_MONTHS = {"январь":1,"февраль":2,"март":3,"апрель":4,"май":5,"июнь":6,
|
||
"июль":7,"август":8,"сентябрь":9,"октябрь":10,"ноябрь":11,"декабрь":12}
|
||
|
||
def normalize_month(s):
|
||
# 'апрель-2026' → '2026-04'
|
||
if not s: return None
|
||
parts = str(s).lower().replace("—","-").split("-")
|
||
if len(parts) != 2: return s
|
||
m = RU_MONTHS.get(parts[0].strip())
|
||
y = parts[1].strip()
|
||
return f"{y}-{m:02d}" if m else s
|
||
|
||
def main():
|
||
conn = sqlite3.connect(DB)
|
||
conn.executescript(SCHEMA)
|
||
|
||
today = dt.date.today()
|
||
sd = (today.replace(day=1) - dt.timedelta(days=365)).strftime("%Y.%m.%d")
|
||
ed = today.strftime("%Y.%m.%d")
|
||
group = "Екатеринбург"
|
||
|
||
print(f"Fetching corp_sum {group} {sd}..{ed}")
|
||
token = get_token()
|
||
rows = fetch_corp_sum(token, group, sd, ed)
|
||
print(f" {len(rows)} rows")
|
||
|
||
conn.execute("INSERT INTO objective_raw_reports(report_kind,group_name,start_date,end_date,n_rows,payload) VALUES (?,?,?,?,?,?)",
|
||
("corp_sum_v2", group, sd, ed, len(rows), json.dumps(rows, ensure_ascii=False)))
|
||
|
||
conn.execute("DELETE FROM objective_corp_month")
|
||
inserted = skipped = 0
|
||
for r in rows:
|
||
try:
|
||
vals = {k: r.get(v) for k, v in COL_MAP.items()}
|
||
vals["month"] = normalize_month(vals["month"])
|
||
cols = list(vals.keys())
|
||
placeholders = ",".join(["?"] * len(cols))
|
||
conn.execute(f"INSERT OR REPLACE INTO objective_corp_month({','.join(cols)}) VALUES ({placeholders})",
|
||
[vals[c] for c in cols])
|
||
inserted += 1
|
||
except Exception as e:
|
||
skipped += 1
|
||
conn.commit()
|
||
print(f" inserted: {inserted}, skipped: {skipped}")
|
||
|
||
# Sanity
|
||
n = conn.execute("SELECT count(*) FROM objective_corp_month").fetchone()[0]
|
||
n_proj = conn.execute("SELECT count(DISTINCT project) FROM objective_corp_month").fetchone()[0]
|
||
n_dist = conn.execute("SELECT count(DISTINCT district) FROM objective_corp_month").fetchone()[0]
|
||
print(f"\nLocal: {n} rows, {n_proj} проекта, {n_dist} районов")
|
||
print("\nDistricts (Objective):")
|
||
for r in conn.execute("""SELECT district, count(*) c, count(DISTINCT project) np
|
||
FROM objective_corp_month GROUP BY 1 ORDER BY 3 DESC""").fetchall():
|
||
print(f" {r[0]:<30} rows={r[1]:>5} projects={r[2]}")
|
||
conn.close()
|
||
|
||
if __name__ == "__main__":
|
||
main()
|