From 28ddf6ef17509755ee0948816ba9c8ad03cf80dc Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 23 May 2026 14:29:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20SQL=20migrations=20009-018=20?= =?UTF-8?q?=E2=80=94=20houses=20+=20listings=20extension=20+=20history=20+?= =?UTF-8?q?=20IMV=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Stage 1 of AvitoScraper_v2 plan. - 009_houses.sql: CREATE TABLE houses (multi-source: avito/cian, geom trigger, 24 cols) - 010_houses_alter.sql: + 15 cols (material_walls, parking_type, rating_distribution, raw_characteristics, ...) - 011_listings_alter.sql: + 24 cols (house linking, kitchen_area_m2, owners_count, owners_at_least, metro_stations, detail_enriched_at, domoteka/Rosreestr badges, 8 partial indexes) - 012_sellers.sql: CREATE TABLE sellers (Avito seller cache, no logos) - 013_listings_alter_seller.sql: listings.seller_id_fk FK - 014_house_reviews.sql: CREATE TABLE house_reviews (textSections main/pros/cons, no images) - 015_scrape_runs.sql: CREATE TABLE scrape_runs (status CHECK enum, heartbeat, zombie partial idx) - 016_listings_snapshots.sql: CREATE TABLE listings_snapshots (append-only, PK listing_id+date) - 017_house_placement_history.sql: CREATE TABLE house_placement_history (removed_date from IMV only) - 018_avito_imv_evaluations.sql: CREATE TABLE + cache_key col + imv_cache_key_idx (TTL 24h lookup) All migrations: BEGIN/COMMIT, IF NOT EXISTS idempotent, PostGIS geom triggers reuse listings_set_geom(). Smoke tested locally: 7 new tables, 34 indexes, 14 listings columns verified. Refs: decisions/Schema_Avito_Houses_Listings_History.md (sec 4.2, 4.3, 4.4, 6, 7, 13, 25, 29) Refs: decisions/AvitoScraper_v2_Implementation_Plan.md Stage 1 --- tradein-mvp/backend/data/sql/009_houses.sql | 72 ++++++++++++++ .../backend/data/sql/010_houses_alter.sql | 58 +++++++++++ .../backend/data/sql/011_listings_alter.sql | 96 +++++++++++++++++++ tradein-mvp/backend/data/sql/012_sellers.sql | 41 ++++++++ .../data/sql/013_listings_alter_seller.sql | 26 +++++ .../backend/data/sql/014_house_reviews.sql | 57 +++++++++++ .../backend/data/sql/015_scrape_runs.sql | 65 +++++++++++++ .../data/sql/016_listings_snapshots.sql | 51 ++++++++++ .../data/sql/017_house_placement_history.sql | 70 ++++++++++++++ .../data/sql/018_avito_imv_evaluations.sql | 79 +++++++++++++++ 10 files changed, 615 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/009_houses.sql create mode 100644 tradein-mvp/backend/data/sql/010_houses_alter.sql create mode 100644 tradein-mvp/backend/data/sql/011_listings_alter.sql create mode 100644 tradein-mvp/backend/data/sql/012_sellers.sql create mode 100644 tradein-mvp/backend/data/sql/013_listings_alter_seller.sql create mode 100644 tradein-mvp/backend/data/sql/014_house_reviews.sql create mode 100644 tradein-mvp/backend/data/sql/015_scrape_runs.sql create mode 100644 tradein-mvp/backend/data/sql/016_listings_snapshots.sql create mode 100644 tradein-mvp/backend/data/sql/017_house_placement_history.sql create mode 100644 tradein-mvp/backend/data/sql/018_avito_imv_evaluations.sql diff --git a/tradein-mvp/backend/data/sql/009_houses.sql b/tradein-mvp/backend/data/sql/009_houses.sql new file mode 100644 index 00000000..282769fc --- /dev/null +++ b/tradein-mvp/backend/data/sql/009_houses.sql @@ -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 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; diff --git a/tradein-mvp/backend/data/sql/010_houses_alter.sql b/tradein-mvp/backend/data/sql/010_houses_alter.sql new file mode 100644 index 00000000..fb6c5f99 --- /dev/null +++ b/tradein-mvp/backend/data/sql/010_houses_alter.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/011_listings_alter.sql b/tradein-mvp/backend/data/sql/011_listings_alter.sql new file mode 100644 index 00000000..b93c47f9 --- /dev/null +++ b/tradein-mvp/backend/data/sql/011_listings_alter.sql @@ -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// + 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//. 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; diff --git a/tradein-mvp/backend/data/sql/012_sellers.sql b/tradein-mvp/backend/data/sql/012_sellers.sql new file mode 100644 index 00000000..ef1ee455 --- /dev/null +++ b/tradein-mvp/backend/data/sql/012_sellers.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/013_listings_alter_seller.sql b/tradein-mvp/backend/data/sql/013_listings_alter_seller.sql new file mode 100644 index 00000000..1446a214 --- /dev/null +++ b/tradein-mvp/backend/data/sql/013_listings_alter_seller.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/014_house_reviews.sql b/tradein-mvp/backend/data/sql/014_house_reviews.sql new file mode 100644 index 00000000..5bd0dcc9 --- /dev/null +++ b/tradein-mvp/backend/data/sql/014_house_reviews.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/015_scrape_runs.sql b/tradein-mvp/backend/data/sql/015_scrape_runs.sql new file mode 100644 index 00000000..fd0f274a --- /dev/null +++ b/tradein-mvp/backend/data/sql/015_scrape_runs.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/016_listings_snapshots.sql b/tradein-mvp/backend/data/sql/016_listings_snapshots.sql new file mode 100644 index 00000000..f12aeb8b --- /dev/null +++ b/tradein-mvp/backend/data/sql/016_listings_snapshots.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/017_house_placement_history.sql b/tradein-mvp/backend/data/sql/017_house_placement_history.sql new file mode 100644 index 00000000..9d2481fb --- /dev/null +++ b/tradein-mvp/backend/data/sql/017_house_placement_history.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/018_avito_imv_evaluations.sql b/tradein-mvp/backend/data/sql/018_avito_imv_evaluations.sql new file mode 100644 index 00000000..b102b8f0 --- /dev/null +++ b/tradein-mvp/backend/data/sql/018_avito_imv_evaluations.sql @@ -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;