feat(tradein): SQL migrations 009-018 — houses + listings extension + IMV cache (#440)
This commit is contained in:
parent
43a8ec426e
commit
a2147872ed
10 changed files with 615 additions and 0 deletions
72
tradein-mvp/backend/data/sql/009_houses.sql
Normal file
72
tradein-mvp/backend/data/sql/009_houses.sql
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
-- 009_houses.sql
|
||||||
|
-- CREATE TABLE houses — multi-source house cache (Avito / CIAN / etc.)
|
||||||
|
--
|
||||||
|
-- Dependencies: 002_core_tables.sql (listings_set_geom function must exist)
|
||||||
|
-- Apply after: 008_crm_fields.sql
|
||||||
|
-- Apply before: 010_houses_alter.sql, 011_listings_alter.sql
|
||||||
|
--
|
||||||
|
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 4.2
|
||||||
|
--
|
||||||
|
-- Идемпотентно: безопасно запускать повторно.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS houses (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
source text NOT NULL, -- 'avito' / 'cian' / ...
|
||||||
|
ext_house_id text NOT NULL, -- Avito's house ID e.g. '3171365'
|
||||||
|
|
||||||
|
-- Identity
|
||||||
|
url text NOT NULL, -- /catalog/houses/<slug>/<id>
|
||||||
|
slug text, -- ul_akademika_postovskogo_17a
|
||||||
|
|
||||||
|
-- Geo (точные coords из developmentData.coords)
|
||||||
|
address text,
|
||||||
|
lat double precision,
|
||||||
|
lon double precision,
|
||||||
|
geom geometry(Point, 4326), -- PostGIS для ST_DWithin
|
||||||
|
|
||||||
|
-- Метаданные дома
|
||||||
|
year_built int,
|
||||||
|
house_type text, -- panel/brick/monolith/monolith_brick/other
|
||||||
|
house_class text, -- comfort/business/premium/economy
|
||||||
|
total_floors int,
|
||||||
|
total_units int, -- кол-во квартир/апартаментов
|
||||||
|
|
||||||
|
-- Лифты + удобства (из detail page / houses catalog developmentData)
|
||||||
|
passenger_elevators int,
|
||||||
|
cargo_elevators int,
|
||||||
|
has_concierge boolean,
|
||||||
|
closed_yard boolean,
|
||||||
|
|
||||||
|
-- Рейтинг
|
||||||
|
rating numeric(2,1), -- 4.8 (1 decimal)
|
||||||
|
reviews_count int, -- кол-во отзывов
|
||||||
|
|
||||||
|
-- Pin'ы соседних домов (для map preview) — JSONB
|
||||||
|
map_pins jsonb DEFAULT '[]'::jsonb, -- [{lat, lng, type}, ...]
|
||||||
|
|
||||||
|
-- Lifecycle
|
||||||
|
raw_payload jsonb,
|
||||||
|
first_seen_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
last_scraped_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE (source, ext_house_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Spatial index (обязателен для ST_Intersects/ST_DWithin)
|
||||||
|
CREATE INDEX IF NOT EXISTS houses_geom_idx ON houses USING GIST (geom);
|
||||||
|
CREATE INDEX IF NOT EXISTS houses_source_ext_idx ON houses (source, ext_house_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS houses_last_scrape_idx ON houses (last_scraped_at DESC);
|
||||||
|
|
||||||
|
-- Trigger: auto-set geom из lat/lon (reuse listings_set_geom из 002_core_tables.sql)
|
||||||
|
DROP TRIGGER IF EXISTS houses_set_geom_trg ON houses;
|
||||||
|
CREATE TRIGGER houses_set_geom_trg
|
||||||
|
BEFORE INSERT OR UPDATE OF lat, lon ON houses
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION listings_set_geom();
|
||||||
|
|
||||||
|
COMMENT ON TABLE houses IS
|
||||||
|
'Кэш домов из Houses Catalog endpoint. 1 запись на (source, ext_house_id). '
|
||||||
|
'Re-scrape если last_scraped_at > 30 дней. Geo через listings_set_geom trigger.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
58
tradein-mvp/backend/data/sql/010_houses_alter.sql
Normal file
58
tradein-mvp/backend/data/sql/010_houses_alter.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
-- 010_houses_alter.sql
|
||||||
|
-- ALTER TABLE houses — добавить 15+ полей из Houses Catalog endpoint
|
||||||
|
-- (material_walls, parking_type, rating_distribution, raw_characteristics, etc.)
|
||||||
|
--
|
||||||
|
-- Dependencies: 009_houses.sql (houses table must exist)
|
||||||
|
-- Apply after: 009_houses.sql
|
||||||
|
-- Apply before: 011_listings_alter.sql
|
||||||
|
--
|
||||||
|
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 25
|
||||||
|
--
|
||||||
|
-- Идемпотентно: ADD COLUMN IF NOT EXISTS безопасен при повторном запуске.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE houses
|
||||||
|
-- Идентификатор хеша (из Avito internal base64-encoded ID)
|
||||||
|
ADD COLUMN IF NOT EXISTS avito_id_hash text, -- base64 "MTE3LjM3MDg5MA"
|
||||||
|
|
||||||
|
-- Адрес (два формата)
|
||||||
|
ADD COLUMN IF NOT EXISTS short_address text, -- "Свердловская область, Екатеринбург"
|
||||||
|
ADD COLUMN IF NOT EXISTS full_address text, -- полный с улицей
|
||||||
|
|
||||||
|
-- Материалы и конструкция
|
||||||
|
ADD COLUMN IF NOT EXISTS material_walls text, -- "Монолитный" / "Железобетонные"
|
||||||
|
ADD COLUMN IF NOT EXISTS material_floors text, -- "Железобетонные перекрытия"
|
||||||
|
|
||||||
|
-- Инженерные системы
|
||||||
|
ADD COLUMN IF NOT EXISTS hot_water text, -- "Центральное" / "Автономное"
|
||||||
|
|
||||||
|
-- Парковка и придомовая территория
|
||||||
|
ADD COLUMN IF NOT EXISTS parking_type text, -- "Подземная / За шлагбаумом во дворе"
|
||||||
|
ADD COLUMN IF NOT EXISTS has_playground boolean, -- "Детская площадка"
|
||||||
|
|
||||||
|
-- Застройщик
|
||||||
|
ADD COLUMN IF NOT EXISTS developer_name text, -- "ЛСР Недвижимость" (может быть NULL)
|
||||||
|
ADD COLUMN IF NOT EXISTS developer_key text, -- Avito internal key застройщика
|
||||||
|
|
||||||
|
-- Инфраструктура
|
||||||
|
ADD COLUMN IF NOT EXISTS infrastructure_summary text, -- "Больница, детский сад, магазин, школа"
|
||||||
|
ADD COLUMN IF NOT EXISTS infrastructure_walk_distance text, -- "в 5 минутах ходьбы"
|
||||||
|
|
||||||
|
-- Рейтинг (детальный) — дополняет houses.rating (numeric(2,1))
|
||||||
|
ADD COLUMN IF NOT EXISTS rating_score numeric(4,2), -- 4.666 (точное значение до сотых)
|
||||||
|
ADD COLUMN IF NOT EXISTS rating_string text, -- "4,7" (как отображает Avito)
|
||||||
|
ADD COLUMN IF NOT EXISTS rating_distribution jsonb, -- [{score:5,count:4},{score:4,count:1},...]
|
||||||
|
|
||||||
|
-- Карта и дополнительные данные
|
||||||
|
ADD COLUMN IF NOT EXISTS map_pins jsonb, -- перезапись поля из 009 (соседние дома + POI)
|
||||||
|
ADD COLUMN IF NOT EXISTS raw_characteristics jsonb; -- весь expandParams — для retrofit новых полей
|
||||||
|
|
||||||
|
COMMENT ON COLUMN houses.rating_score IS
|
||||||
|
'Точный рейтинг до сотых (напр. 4.666). Avito округляет до 4.7 для отображения (rating_string).';
|
||||||
|
COMMENT ON COLUMN houses.rating_distribution IS
|
||||||
|
'Распределение оценок: [{score: 5, count: 4}, {score: 4, count: 1}, ...] из Houses Catalog.';
|
||||||
|
COMMENT ON COLUMN houses.raw_characteristics IS
|
||||||
|
'Сырые expandParams из Houses Catalog для retrofit новых полей без re-scrape.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
96
tradein-mvp/backend/data/sql/011_listings_alter.sql
Normal file
96
tradein-mvp/backend/data/sql/011_listings_alter.sql
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
-- 011_listings_alter.sql
|
||||||
|
-- ALTER TABLE listings — добавить ~30 полей для Avito detail page + domoteka (Росреестр)
|
||||||
|
--
|
||||||
|
-- Dependencies:
|
||||||
|
-- 002_core_tables.sql (listings table)
|
||||||
|
-- 009_houses.sql (houses table — FK house_id_fk)
|
||||||
|
--
|
||||||
|
-- Apply after: 009_houses.sql
|
||||||
|
-- Apply before: 012_sellers.sql, 013_listings_alter_seller.sql
|
||||||
|
--
|
||||||
|
-- Sources:
|
||||||
|
-- decisions/Schema_Avito_Houses_Listings_History.md sec 13 (listings ALTER)
|
||||||
|
-- decisions/Schema_Avito_Houses_Listings_History.md sec 29 (owners_count/owners_at_least)
|
||||||
|
--
|
||||||
|
-- Note: owners_2plus (boolean) из sec 13 заменён на owners_count + owners_at_least (sec 29).
|
||||||
|
-- Идемпотентно: ADD COLUMN IF NOT EXISTS безопасен при повторном запуске.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE listings
|
||||||
|
-- ── House linking ──────────────────────────────────────────────────────
|
||||||
|
ADD COLUMN IF NOT EXISTS house_source text, -- 'avito' / 'cian'
|
||||||
|
ADD COLUMN IF NOT EXISTS house_ext_id text, -- Avito's '3171365'
|
||||||
|
ADD COLUMN IF NOT EXISTS house_url text, -- /catalog/houses/<slug>/<id>
|
||||||
|
ADD COLUMN IF NOT EXISTS house_id_fk bigint REFERENCES houses(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS listing_segment text, -- 'vtorichka' / 'novostroyki'
|
||||||
|
|
||||||
|
-- ── Характеристики квартиры (из detail page "О квартире") ─────────────
|
||||||
|
ADD COLUMN IF NOT EXISTS kitchen_area_m2 numeric(6,2),
|
||||||
|
ADD COLUMN IF NOT EXISTS balcony_loggia text, -- balcony/loggia/both/none
|
||||||
|
-- (заменяет has_balcony boolean для detail)
|
||||||
|
ADD COLUMN IF NOT EXISTS room_layout text, -- isolated/adjacent/mixed
|
||||||
|
ADD COLUMN IF NOT EXISTS bathroom_type text, -- combined/separate/both/multi
|
||||||
|
ADD COLUMN IF NOT EXISTS windows_view text, -- yard/street/both/park
|
||||||
|
|
||||||
|
-- ── Условия продажи ────────────────────────────────────────────────────
|
||||||
|
ADD COLUMN IF NOT EXISTS sale_type text, -- free/alternative/auction
|
||||||
|
ADD COLUMN IF NOT EXISTS mortgage_available boolean,
|
||||||
|
|
||||||
|
-- ── Метрики просмотров (Avito tracks viewership) ──────────────────────
|
||||||
|
ADD COLUMN IF NOT EXISTS views_total int,
|
||||||
|
ADD COLUMN IF NOT EXISTS views_today int,
|
||||||
|
ADD COLUMN IF NOT EXISTS publish_date date, -- "18 мая в 13:40" → 2025-05-18
|
||||||
|
|
||||||
|
-- ── Текстовое описание ─────────────────────────────────────────────────
|
||||||
|
ADD COLUMN IF NOT EXISTS description text, -- 1000+ chars обычно
|
||||||
|
|
||||||
|
-- ── Domoteka / Росреестр verification badges ──────────────────────────
|
||||||
|
-- Note: owners_2plus (boolean из sec 13) заменён на owners_count + owners_at_least (sec 29)
|
||||||
|
ADD COLUMN IF NOT EXISTS owners_count int, -- 1 = точно 1; 2 = '2 или больше'
|
||||||
|
ADD COLUMN IF NOT EXISTS owners_at_least boolean, -- true если Avito выдал "X или больше"
|
||||||
|
ADD COLUMN IF NOT EXISTS last_owner_change_date date, -- "Последняя смена собственника YYYY-MM-DD"
|
||||||
|
ADD COLUMN IF NOT EXISTS encumbrances_clean boolean, -- "Не найдены ограничения и обременения"
|
||||||
|
ADD COLUMN IF NOT EXISTS registry_match boolean, -- "Совпадают площадь, адрес и этаж"
|
||||||
|
|
||||||
|
-- ── Metro + location internal IDs ─────────────────────────────────────
|
||||||
|
ADD COLUMN IF NOT EXISTS metro_stations jsonb DEFAULT '[]'::jsonb,
|
||||||
|
-- [{"name":"Чкаловская","minutes":"11-15","mode":"walk"},...]
|
||||||
|
ADD COLUMN IF NOT EXISTS avito_location_id int, -- 654070 (ЕКБ internal ID)
|
||||||
|
|
||||||
|
-- ── Detail-page enrichment tracking ───────────────────────────────────
|
||||||
|
ADD COLUMN IF NOT EXISTS detail_enriched_at timestamptz; -- когда обогатили с detail page
|
||||||
|
|
||||||
|
-- Индексы для аналитических запросов
|
||||||
|
CREATE INDEX IF NOT EXISTS listings_house_ext_id_idx ON listings (house_source, house_ext_id)
|
||||||
|
WHERE house_ext_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS listings_house_id_fk_idx ON listings (house_id_fk)
|
||||||
|
WHERE house_id_fk IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS listings_kitchen_idx ON listings (kitchen_area_m2)
|
||||||
|
WHERE kitchen_area_m2 IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS listings_views_idx ON listings (views_total DESC)
|
||||||
|
WHERE views_total IS NOT NULL;
|
||||||
|
-- Partial index для фильтра "без обременений" — используется estimator
|
||||||
|
CREATE INDEX IF NOT EXISTS listings_encumbrances_idx ON listings (encumbrances_clean)
|
||||||
|
WHERE encumbrances_clean = true;
|
||||||
|
-- Partial index для очереди detail-enrichment (пустой = нужно обогатить)
|
||||||
|
CREATE INDEX IF NOT EXISTS listings_detail_enrich_idx ON listings (detail_enriched_at)
|
||||||
|
WHERE detail_enriched_at IS NULL;
|
||||||
|
-- Partial index для "только с одним собственником" (clean title filter)
|
||||||
|
CREATE INDEX IF NOT EXISTS listings_owners_idx ON listings (owners_count)
|
||||||
|
WHERE owners_count IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN listings.house_ext_id IS
|
||||||
|
'Avito house ID из /catalog/houses/<slug>/<id>. NULL для ~18% карточек без ссылки на дом.';
|
||||||
|
COMMENT ON COLUMN listings.balcony_loggia IS
|
||||||
|
'Тип балкона: balcony/loggia/both/none. Детальнее чем has_balcony (boolean). Из detail page.';
|
||||||
|
COMMENT ON COLUMN listings.owners_count IS
|
||||||
|
'Кол-во собственников из domoteka badge. 1=точно один; 2=''2 или больше'' (Avito скрывает точное для privacy).';
|
||||||
|
COMMENT ON COLUMN listings.owners_at_least IS
|
||||||
|
'true если badge содержит "X или больше" — значит owners_count является нижней границей.';
|
||||||
|
COMMENT ON COLUMN listings.metro_stations IS
|
||||||
|
'Список станций метро: [{"name":"Чкаловская","minutes":"11-15","mode":"walk"},...]. Из detail page.';
|
||||||
|
COMMENT ON COLUMN listings.detail_enriched_at IS
|
||||||
|
'Timestamp обогащения с detail page. NULL = ещё не обогащено (используется как queue flag).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
41
tradein-mvp/backend/data/sql/012_sellers.sql
Normal file
41
tradein-mvp/backend/data/sql/012_sellers.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
-- 012_sellers.sql
|
||||||
|
-- CREATE TABLE sellers — кэш продавцов с Avito (компании и частные лица)
|
||||||
|
--
|
||||||
|
-- Dependencies: 002_core_tables.sql (listings table exists — FK будет в 013)
|
||||||
|
-- Apply after: 011_listings_alter.sql
|
||||||
|
-- Apply before: 013_listings_alter_seller.sql
|
||||||
|
--
|
||||||
|
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 25 (NEW sellers table)
|
||||||
|
-- Note: logos НЕ сохраняем (требование 2026-05-23 — без картинок).
|
||||||
|
--
|
||||||
|
-- Идемпотентно: безопасно запускать повторно.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sellers (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
source text NOT NULL, -- 'avito' / 'cian'
|
||||||
|
ext_seller_id text, -- из seller.url ("/brands/domrf66")
|
||||||
|
|
||||||
|
name text NOT NULL, -- "Самолёт Плюс Екатеринбург"
|
||||||
|
seller_type text, -- "Компания" / "Частное лицо"
|
||||||
|
on_platform_since text, -- "На Авито с июля 2012" (raw text, не дата)
|
||||||
|
profile_url text, -- "/brands/domrf66"
|
||||||
|
-- logos НЕ сохраняем (user req: без картинок)
|
||||||
|
|
||||||
|
raw_payload jsonb,
|
||||||
|
first_seen_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
last_seen_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE (source, ext_seller_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sellers_source_idx ON sellers (source, ext_seller_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE sellers IS
|
||||||
|
'Кэш продавцов Avito (компании + частные лица). 1 запись на (source, ext_seller_id). '
|
||||||
|
'Logos не сохраняются. Связь с listings через sellers.id (FK в 013_listings_alter_seller.sql).';
|
||||||
|
COMMENT ON COLUMN sellers.on_platform_since IS
|
||||||
|
'Raw текст из Avito (напр. "На Авито с июля 2012"). Не парсим в дату — слишком вариативно.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
26
tradein-mvp/backend/data/sql/013_listings_alter_seller.sql
Normal file
26
tradein-mvp/backend/data/sql/013_listings_alter_seller.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- 013_listings_alter_seller.sql
|
||||||
|
-- ALTER TABLE listings — добавить FK на sellers
|
||||||
|
--
|
||||||
|
-- Dependencies:
|
||||||
|
-- 002_core_tables.sql (listings table)
|
||||||
|
-- 012_sellers.sql (sellers table must exist для FK)
|
||||||
|
--
|
||||||
|
-- Apply after: 012_sellers.sql
|
||||||
|
-- Apply before: 014_house_reviews.sql
|
||||||
|
--
|
||||||
|
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 25 (ALTER listings — seller link)
|
||||||
|
--
|
||||||
|
-- Идемпотентно: ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE listings
|
||||||
|
ADD COLUMN IF NOT EXISTS seller_id_fk bigint REFERENCES sellers(id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS listings_seller_idx ON listings (seller_id_fk)
|
||||||
|
WHERE seller_id_fk IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN listings.seller_id_fk IS
|
||||||
|
'FK на sellers.id. NULL если продавец не спарсен. Для анализа активности агентств.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
57
tradein-mvp/backend/data/sql/014_house_reviews.sql
Normal file
57
tradein-mvp/backend/data/sql/014_house_reviews.sql
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
-- 014_house_reviews.sql
|
||||||
|
-- CREATE TABLE house_reviews — отзывы жильцов на дома (из Houses Catalog)
|
||||||
|
--
|
||||||
|
-- Dependencies: 009_houses.sql (houses table — FK house_id)
|
||||||
|
-- Apply after: 013_listings_alter_seller.sql
|
||||||
|
-- Apply before: 015_scrape_runs.sql
|
||||||
|
--
|
||||||
|
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 25 (NEW house_reviews table)
|
||||||
|
-- Note: author_avatar и images НЕ сохраняем (требование 2026-05-23 — без картинок).
|
||||||
|
--
|
||||||
|
-- Идемпотентно: безопасно запускать повторно.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS house_reviews (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
house_id bigint NOT NULL REFERENCES houses(id) ON DELETE CASCADE,
|
||||||
|
source text NOT NULL DEFAULT 'avito',
|
||||||
|
ext_review_id bigint NOT NULL, -- value.id из Avito API
|
||||||
|
|
||||||
|
-- Автор (без аватара)
|
||||||
|
author_name text, -- "Мария"
|
||||||
|
-- author_avatar НЕ сохраняем (user req: без картинок)
|
||||||
|
|
||||||
|
-- Мета-данные отзыва
|
||||||
|
review_title text, -- "Новый, уютный, теплый..."
|
||||||
|
score int CHECK (score BETWEEN 1 AND 5),
|
||||||
|
model_experience text, -- "Живу в своей квартире" / "Часто там бываю"
|
||||||
|
rated_date date, -- parsed из "9 октября 2025"
|
||||||
|
|
||||||
|
-- Текст отзыва (три секции из textSections[])
|
||||||
|
text_main text, -- textSections[0].text если title=""
|
||||||
|
text_pros text, -- textSections где title="Преимущества"
|
||||||
|
text_cons text, -- textSections где title="Недостатки"
|
||||||
|
|
||||||
|
-- images НЕ сохраняем (user req: без картинок — raw_payload содержит URLs для retrofit)
|
||||||
|
|
||||||
|
raw_payload jsonb, -- полный объект из API для retrofit
|
||||||
|
scraped_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE (source, ext_review_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS hr_house_idx ON house_reviews (house_id, rated_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS hr_source_ext_idx ON house_reviews (source, ext_review_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE house_reviews IS
|
||||||
|
'Отзывы жильцов на дома из Avito Houses Catalog. 1 запись на (source, ext_review_id). '
|
||||||
|
'Аватары и фото не сохраняются — используйте raw_payload для retrofit.';
|
||||||
|
COMMENT ON COLUMN house_reviews.model_experience IS
|
||||||
|
'Опыт автора: "Живу в своей квартире" / "Часто там бываю" / etc. (raw текст из Avito).';
|
||||||
|
COMMENT ON COLUMN house_reviews.text_pros IS
|
||||||
|
'Секция "Преимущества" из textSections. NULL если автор не заполнил.';
|
||||||
|
COMMENT ON COLUMN house_reviews.text_cons IS
|
||||||
|
'Секция "Недостатки" из textSections. NULL если автор не заполнил.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
65
tradein-mvp/backend/data/sql/015_scrape_runs.sql
Normal file
65
tradein-mvp/backend/data/sql/015_scrape_runs.sql
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
-- 015_scrape_runs.sql
|
||||||
|
-- CREATE TABLE scrape_runs — логирование scrape-сессий с heartbeat + lifecycle
|
||||||
|
--
|
||||||
|
-- Dependencies: нет (независимая таблица)
|
||||||
|
-- Apply after: 014_house_reviews.sql
|
||||||
|
-- Apply before: 016_listings_snapshots.sql
|
||||||
|
--
|
||||||
|
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 4.3
|
||||||
|
-- Status enum: running / done / failed / zombie / skipped / banned
|
||||||
|
--
|
||||||
|
-- Паттерн совпадает с objective_scrape_runs из основной GenDesign БД.
|
||||||
|
-- Zombie detection: cron каждые 5 мин помечает runs где heartbeat_at < NOW() - 30 min.
|
||||||
|
--
|
||||||
|
-- Идемпотентно: безопасно запускать повторно.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scrape_runs (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
source text NOT NULL, -- 'avito' / 'cian' / ...
|
||||||
|
run_type text NOT NULL, -- 'search' / 'house_catalog' / 'detail' / 'imv'
|
||||||
|
|
||||||
|
-- Scope (для search runs — геометрия поиска)
|
||||||
|
anchor_lat double precision,
|
||||||
|
anchor_lon double precision,
|
||||||
|
radius_m int,
|
||||||
|
segment text, -- 'vtorichka' / 'novostroyki'
|
||||||
|
|
||||||
|
-- Scope (для house_catalog/detail runs)
|
||||||
|
target_ext_id text, -- house_id или item_id
|
||||||
|
|
||||||
|
-- Lifecycle
|
||||||
|
started_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
heartbeat_at timestamptz NOT NULL DEFAULT NOW(), -- обновляется каждые N сек в process loop
|
||||||
|
finished_at timestamptz,
|
||||||
|
status text NOT NULL DEFAULT 'running'
|
||||||
|
CHECK (status IN ('running','done','failed','zombie','skipped','banned')),
|
||||||
|
error_text text,
|
||||||
|
|
||||||
|
-- Counters (для аналитики и debugging)
|
||||||
|
total_seen int DEFAULT 0, -- кол-во объявлений в выдаче
|
||||||
|
new_count int DEFAULT 0, -- новых (не было в БД)
|
||||||
|
returning_count int DEFAULT 0, -- видели → пропали → снова видим
|
||||||
|
disappeared_count int DEFAULT 0, -- пропали (last_seen > cutoff после run)
|
||||||
|
|
||||||
|
-- HTTP stats (для мониторинга rate limits)
|
||||||
|
http_requests int DEFAULT 0,
|
||||||
|
http_errors int DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS scrape_runs_source_started_idx ON scrape_runs (source, started_at DESC);
|
||||||
|
-- Partial index для zombie detection (только 'running' статус)
|
||||||
|
CREATE INDEX IF NOT EXISTS scrape_runs_zombies_idx ON scrape_runs (status, heartbeat_at)
|
||||||
|
WHERE status = 'running';
|
||||||
|
|
||||||
|
COMMENT ON TABLE scrape_runs IS
|
||||||
|
'Лог scrape-сессий. Heartbeat обновляется в process loop. '
|
||||||
|
'Zombie: status=running AND heartbeat_at < NOW() - 30 min — помечается cron.';
|
||||||
|
COMMENT ON COLUMN scrape_runs.status IS
|
||||||
|
'Enum: running=активен, done=успех, failed=ошибка, zombie=heartbeat устарел, '
|
||||||
|
'skipped=нет новых данных, banned=Avito вернул 403/captcha.';
|
||||||
|
COMMENT ON COLUMN scrape_runs.returning_count IS
|
||||||
|
'Объявления которые пропадали (is_active=false) и снова появились в выдаче.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
51
tradein-mvp/backend/data/sql/016_listings_snapshots.sql
Normal file
51
tradein-mvp/backend/data/sql/016_listings_snapshots.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
-- 016_listings_snapshots.sql
|
||||||
|
-- CREATE TABLE listings_snapshots — append-only daily price/status history
|
||||||
|
--
|
||||||
|
-- Dependencies:
|
||||||
|
-- 002_core_tables.sql (listings table — FK listing_id)
|
||||||
|
-- 015_scrape_runs.sql (scrape_runs table — FK run_id)
|
||||||
|
--
|
||||||
|
-- Apply after: 015_scrape_runs.sql
|
||||||
|
-- Apply before: 017_house_placement_history.sql
|
||||||
|
--
|
||||||
|
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 4.4
|
||||||
|
-- Pattern: аналог objective_lots_history из основной GenDesign БД.
|
||||||
|
--
|
||||||
|
-- ON CONFLICT DO UPDATE: берём последний snapshot за день (если за сутки несколько run'ов).
|
||||||
|
-- Cleanup policy: хранить вечно (data moat). Partitioning отложить на 1-2 года.
|
||||||
|
--
|
||||||
|
-- Идемпотентно: безопасно запускать повторно.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS listings_snapshots (
|
||||||
|
listing_id bigint NOT NULL REFERENCES listings(id) ON DELETE CASCADE,
|
||||||
|
snapshot_date date NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
run_id bigint REFERENCES scrape_runs(id),
|
||||||
|
|
||||||
|
-- Snapshot изменяющихся полей
|
||||||
|
price_rub bigint NOT NULL,
|
||||||
|
price_per_m2 int,
|
||||||
|
position_in_serp int, -- 1..60 — позиция в поисковой выдаче
|
||||||
|
status text, -- 'active' / 'closed' / null (unknown)
|
||||||
|
|
||||||
|
observed_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- 1 snapshot в день максимум (ON CONFLICT DO UPDATE в scrapers)
|
||||||
|
PRIMARY KEY (listing_id, snapshot_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS listings_snapshots_run_idx ON listings_snapshots (run_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS listings_snapshots_listing_date_idx ON listings_snapshots (listing_id, snapshot_date DESC);
|
||||||
|
-- Для "что нового/изменилось сегодня" запросов
|
||||||
|
CREATE INDEX IF NOT EXISTS listings_snapshots_date_idx ON listings_snapshots (snapshot_date DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE listings_snapshots IS
|
||||||
|
'Append-only ежедневные снэпшоты listings. PRIMARY KEY (listing_id, snapshot_date) = max 1/день. '
|
||||||
|
'Pattern: objective_lots_history. ON CONFLICT DO UPDATE — берём последний за день.';
|
||||||
|
COMMENT ON COLUMN listings_snapshots.position_in_serp IS
|
||||||
|
'Позиция в поисковой выдаче (1-60). NULL для snapshot''ов из non-search sources.';
|
||||||
|
COMMENT ON COLUMN listings_snapshots.status IS
|
||||||
|
'''active'' = видели в run, ''closed'' = не нашли (пропало). NULL = неизвестно.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
70
tradein-mvp/backend/data/sql/017_house_placement_history.sql
Normal file
70
tradein-mvp/backend/data/sql/017_house_placement_history.sql
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
-- 017_house_placement_history.sql
|
||||||
|
-- CREATE TABLE house_placement_history — архив размещений из IMV API
|
||||||
|
-- (исторические объявления в конкретном доме, уже снятые с продажи)
|
||||||
|
--
|
||||||
|
-- Dependencies: 009_houses.sql (houses table — FK house_id, nullable)
|
||||||
|
-- Apply after: 016_listings_snapshots.sql
|
||||||
|
-- Apply before: 018_avito_imv_evaluations.sql
|
||||||
|
--
|
||||||
|
-- Source: decisions/Schema_Avito_Houses_Listings_History.md sec 6 (UPDATE 2026-05-23)
|
||||||
|
-- Key notes:
|
||||||
|
-- - photo_url НЕ сохраняем (требование 2026-05-23 "без картинок")
|
||||||
|
-- - removed_date заполняется ТОЛЬКО из IMV API (в widget housePlacementHistory его НЕТ)
|
||||||
|
-- - source = 'avito_imv' для данных из IMV API, 'avito' для данных из Houses Catalog widget
|
||||||
|
--
|
||||||
|
-- Идемпотентно: безопасно запускать повторно.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS house_placement_history (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
source text NOT NULL, -- 'avito_imv' / 'avito'
|
||||||
|
house_id bigint REFERENCES houses(id), -- NULL если дом ещё не в houses table
|
||||||
|
|
||||||
|
-- Старый item ID (объявление уже снято → 404 на Avito)
|
||||||
|
ext_item_id text NOT NULL, -- e.g. '7489769108'
|
||||||
|
|
||||||
|
-- Описание (raw text + parsed fields)
|
||||||
|
title text, -- "1-к. квартира, 27,1 м², 2/5 эт."
|
||||||
|
rooms int,
|
||||||
|
area_m2 numeric(8,2),
|
||||||
|
floor int,
|
||||||
|
total_floors int,
|
||||||
|
|
||||||
|
-- Эволюция цены
|
||||||
|
start_price bigint, -- начальная ask price
|
||||||
|
start_price_date date, -- из unix timestamp startPriceDate
|
||||||
|
last_price bigint, -- финальная ask price (при снятии)
|
||||||
|
last_price_date date, -- из unix timestamp lastPriceDate
|
||||||
|
|
||||||
|
-- Дата снятия (ТОЛЬКО из IMV API — в widget housePlacementHistory отсутствует)
|
||||||
|
removed_date date, -- лучший proxy для deal_date
|
||||||
|
|
||||||
|
-- Точный days_on_market (от Avito, точнее нашего вычисления)
|
||||||
|
exposure_days int,
|
||||||
|
|
||||||
|
-- photo_url НЕ сохраняем (raw_payload содержит itemImage URL для retrofit)
|
||||||
|
raw_payload jsonb, -- полный объект из API
|
||||||
|
scraped_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE (source, ext_item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS hph_house_idx ON house_placement_history (house_id, last_price_date DESC)
|
||||||
|
WHERE house_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS hph_source_item_idx ON house_placement_history (source, ext_item_id);
|
||||||
|
-- Для "comparable sales в заданный период"
|
||||||
|
CREATE INDEX IF NOT EXISTS hph_removed_idx ON house_placement_history (removed_date DESC)
|
||||||
|
WHERE removed_date IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE house_placement_history IS
|
||||||
|
'Архив снятых объявлений (proxy для сделок) из IMV API + Houses Catalog. '
|
||||||
|
'removed_date заполняется только из IMV API. photo_url не сохраняется.';
|
||||||
|
COMMENT ON COLUMN house_placement_history.removed_date IS
|
||||||
|
'Дата снятия объявления с продажи. Лучший proxy для deal_date. '
|
||||||
|
'ТОЛЬКО из IMV API (из widget housePlacementHistory это поле отсутствует).';
|
||||||
|
COMMENT ON COLUMN house_placement_history.house_id IS
|
||||||
|
'FK на houses.id. NULL если при scrape дом ещё не был в houses table. '
|
||||||
|
'Backfill через UPDATE ... WHERE ext_item_id IN (...) при первом scrape дома.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
79
tradein-mvp/backend/data/sql/018_avito_imv_evaluations.sql
Normal file
79
tradein-mvp/backend/data/sql/018_avito_imv_evaluations.sql
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
-- 018_avito_imv_evaluations.sql
|
||||||
|
-- CREATE TABLE avito_imv_evaluations — кэш оценок Avito IMV API
|
||||||
|
--
|
||||||
|
-- Dependencies:
|
||||||
|
-- 001_trade_in_estimates.sql (trade_in_estimates table — FK estimate_id, nullable ON DELETE CASCADE)
|
||||||
|
--
|
||||||
|
-- Apply after: 017_house_placement_history.sql
|
||||||
|
-- Apply before: ничего (последняя миграция Stage 1)
|
||||||
|
--
|
||||||
|
-- Sources:
|
||||||
|
-- decisions/Schema_Avito_Houses_Listings_History.md sec 7 (avito_imv_evaluations DDL)
|
||||||
|
-- decisions/AvitoScraper_v2_Implementation_Plan.md (UPDATE: cache_key обязателен для Stage 3)
|
||||||
|
--
|
||||||
|
-- cache_key: sha256(address + rooms + area + floor + house_type + renovation + balcony + loggia)
|
||||||
|
-- TTL: 24 часа по cache_key (lookup в estimator перед вызовом IMV API)
|
||||||
|
-- IMV endpoint: POST /web/1/realty-imv/get-data (2 HTTP requests: coords/by_address → imv)
|
||||||
|
--
|
||||||
|
-- Идемпотентно: безопасно запускать повторно.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS avito_imv_evaluations (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
estimate_id uuid REFERENCES trade_in_estimates(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Input snapshot (для cache lookup + аудита)
|
||||||
|
address text NOT NULL,
|
||||||
|
rooms int,
|
||||||
|
area_m2 numeric(8,2),
|
||||||
|
floor int,
|
||||||
|
floor_at_home int,
|
||||||
|
house_type text, -- panel/brick/monolith/monolith_brick/block/wood
|
||||||
|
renovation_type text, -- required/cosmetic/euro/designer
|
||||||
|
has_balcony boolean,
|
||||||
|
has_loggia boolean,
|
||||||
|
|
||||||
|
-- Geo (из /coords/by_address response)
|
||||||
|
geo_hash text, -- JWT token (для передачи в /realty-imv)
|
||||||
|
avito_address_id bigint,
|
||||||
|
avito_location_id bigint,
|
||||||
|
avito_metro_id int,
|
||||||
|
avito_district_id int,
|
||||||
|
lat double precision,
|
||||||
|
lon double precision,
|
||||||
|
|
||||||
|
-- Output (из /realty-imv/get-data → price block)
|
||||||
|
recommended_price bigint NOT NULL, -- price.recommendedPrice (центральная оценка)
|
||||||
|
lower_price bigint NOT NULL, -- price.lowerPrice (нижняя граница ~-3%)
|
||||||
|
higher_price bigint NOT NULL, -- price.higherPrice (верхняя граница ~+5%)
|
||||||
|
market_count int, -- price.count (кол-во активных в рынке)
|
||||||
|
|
||||||
|
-- Cache key (критично для on-demand cache — Stage 3)
|
||||||
|
-- sha256(address + rooms + area + floor + house_type + renovation_type + has_balcony + has_loggia)
|
||||||
|
cache_key text NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
raw_response jsonb, -- полный ответ /realty-imv/get-data для retrofit
|
||||||
|
fetched_at timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Основной index для on-demand cache lookup (Stage 3: get_or_fetch_imv)
|
||||||
|
CREATE INDEX IF NOT EXISTS imv_cache_key_idx ON avito_imv_evaluations (cache_key, fetched_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS imv_estimate_idx ON avito_imv_evaluations (estimate_id)
|
||||||
|
WHERE estimate_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS imv_address_idx ON avito_imv_evaluations (address);
|
||||||
|
|
||||||
|
COMMENT ON TABLE avito_imv_evaluations IS
|
||||||
|
'Кэш оценок Avito IMV API. TTL 24h по cache_key. '
|
||||||
|
'IMV = POST /web/1/realty-imv/get-data. Вызывается только из estimator (НЕ из cron-scrape).';
|
||||||
|
COMMENT ON COLUMN avito_imv_evaluations.cache_key IS
|
||||||
|
'sha256(address + rooms + area + floor + house_type + renovation_type + has_balcony + has_loggia). '
|
||||||
|
'Используется для lookup с TTL 24h перед вызовом Avito API.';
|
||||||
|
COMMENT ON COLUMN avito_imv_evaluations.recommended_price IS
|
||||||
|
'Центральная оценка Avito IMV. Используется как external benchmark для валидации нашей median оценки.';
|
||||||
|
COMMENT ON COLUMN avito_imv_evaluations.market_count IS
|
||||||
|
'Кол-во активных объявлений в рынке по параметрам запроса (confidence indicator). 800+ = высокий.';
|
||||||
|
COMMENT ON COLUMN avito_imv_evaluations.geo_hash IS
|
||||||
|
'JWT token из /coords/by_address. Передаётся в /realty-imv/get-data как geoHash. TTL неизвестен.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Loading…
Add table
Reference in a new issue