fix(tradein): убрать невыводимые события, развести «снято» и «протухло» (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m1s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m1s
Ревью PR #2682 нашло контрольную группу в наших же данных. Перепроверено собственными запросами к проду — сходится, местами хуже заявленного. 1. delisted/relisted УБРАНЫ из писателя событий. Покрытие обхода за 14-18.07: domklik 99.9-100%, yandex 34-43%, cian 21-27%, avito 1.6-3.4%. Переходы за те же дни: domklik — снятий 1/2/0/2/4 в сутки и возвратов РОВНО 0 все пять суток; yandex — снятий 343-433 в сутки. Тот же обход, тот же день, разница только в покрытии: событие рождается тем, что скрейпер снова дошёл, а не тем, что объявление вернулось. Подтверждения: avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против контрольной ставки 1-4 (точность ≈4%); 4705 возвратов из 5493 за 12 дней (85.7%) — это 2-3.08, два дня после возобновления обхода. Сужение окна свежести сделало бы хуже (больше флапаний). Журнал из догадок хуже пустого журнала — не пишем. is_active убран из запроса целиком. Гейт-тест ослаблен до трёх типов + новый гейт «невыводимые НЕ пишутся». 2. TTL-путь пишет 'stale', а не 'closed'. Прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая «дата продажи» одной датой. 'closed' остаётся только за 404: там ответила площадка. CHECK на колонке нет, миграция 212 обновляет только COMMENT. 3. change_time усечён до суток (date_trunc). С now() UNIQUE(source, change_time, type) работал только внутри прогона: второй прогон в те же сутки (2 августа их было два) давал дубли. Теперь заявленная идемпотентность действительно работает. 4. Комнатность в разборе заголовка стала необязательной: 1991 заголовок из 25 055 (7.9%) — «Квартира-студия, 34,2 м², 9/10 эт.», обязательная группа роняла match и обнуляла все четыре поля. Чинит обоих писателей сразу (house_suggestions + house_placement_history, там 8.8% без площади). Студия → rooms=0 по конвенции kit'а, а не None. Фальсификация: вернуть delisted — 1 красный; 'closed' на TTL-пути — 6; обязательная комнатность — 2; now() вместо date_trunc — 1.
This commit is contained in:
parent
43aaf91b97
commit
ab01f7cc48
6 changed files with 199 additions and 75 deletions
|
|
@ -10,7 +10,8 @@
|
||||||
- avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений.
|
- avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений.
|
||||||
- Строки НЕ удаляются -- история нужна для бэктеста (#667).
|
- Строки НЕ удаляются -- история нужна для бэктеста (#667).
|
||||||
- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом
|
- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом
|
||||||
'closed' за текущую дату -- дата снятия объявления перестаёт быть догадкой.
|
'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка
|
||||||
|
ответила 404) пишет только avito_detail_backfill: смешивать факт с догадкой дорого.
|
||||||
|
|
||||||
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом
|
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом
|
||||||
через product_handlers._job_deactivate_stale (wildcard-handler deactivate_stale_*),
|
через product_handlers._job_deactivate_stale (wildcard-handler deactivate_stale_*),
|
||||||
|
|
@ -43,7 +44,7 @@ logger = logging.getLogger(__name__)
|
||||||
# только реальный скрейп).
|
# только реальный скрейп).
|
||||||
_ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"})
|
_ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"})
|
||||||
|
|
||||||
# ── Снимок «снято» в дневной истории (#2674) ──────────────────────────────────
|
# ── Снимок «протухло» в дневной истории (#2674) ───────────────────────────────
|
||||||
# listings_snapshots.status до этого фикса был константой 'active' у всех строк
|
# listings_snapshots.status до этого фикса был константой 'active' у всех строк
|
||||||
# (394 299 на момент находки) — оба места вызова upsert_listing_snapshot передавали
|
# (394 299 на момент находки) — оба места вызова upsert_listing_snapshot передавали
|
||||||
# литерал 'active', и это честно: там объявление ДЕЙСТВИТЕЛЬНО видели. А деактивация
|
# литерал 'active', и это честно: там объявление ДЕЙСТВИТЕЛЬНО видели. А деактивация
|
||||||
|
|
@ -51,22 +52,32 @@ _ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"})
|
||||||
# объявления (лучший доступный сигнал «скорее всего продано») не запрашивалась из
|
# объявления (лучший доступный сигнал «скорее всего продано») не запрашивалась из
|
||||||
# истории, а восстанавливалась на глаз: последний показ + предполагаемый срок жизни.
|
# истории, а восстанавливалась на глаз: последний показ + предполагаемый срок жизни.
|
||||||
#
|
#
|
||||||
|
# ПОЧЕМУ 'stale', А НЕ 'closed'. Эта задача НЕ знает, что объявление снято, — она
|
||||||
|
# знает только, что МЫ его N суток не видели, а это разные факты, когда TTL короче
|
||||||
|
# простоя обхода. Замер: прогон по домклику 02.08 снял 6131 объявление за раз (TTL
|
||||||
|
# 14 суток против 12 суток простоя обхода) — с общим статусом это были бы 6131
|
||||||
|
# фальшивая «дата продажи» одной датой. Продукт про цены, смешивать факт с догадкой
|
||||||
|
# дорого. Поэтому:
|
||||||
|
# 'closed' — только путь 404: площадка ответила «нет» (avito_detail_backfill);
|
||||||
|
# 'stale' — этот путь: «мы N суток не смотрели».
|
||||||
|
# Дата всё равно фиксируется, но читатель отличает одно от другого. Ограничения
|
||||||
|
# CHECK на колонке нет (проверено на проде), миграция не нужна — только COMMENT.
|
||||||
|
#
|
||||||
# Пишем снимок в ТОЙ ЖЕ транзакции, что и UPDATE флага: деактивация без снимка (или
|
# Пишем снимок в ТОЙ ЖЕ транзакции, что и UPDATE флага: деактивация без снимка (или
|
||||||
# наоборот) невозможна по построению — один statement, data-modifying CTE.
|
# наоборот) невозможна по построению — один statement, data-modifying CTE.
|
||||||
# 1:1 по строкам: `stale` возвращает уникальные listings.id (PK), каждая даёт ровно
|
# 1:1 по строкам: `stale` возвращает уникальные listings.id (PK), каждая даёт ровно
|
||||||
# одну затронутую строку listings_snapshots (INSERT либо DO UPDATE — оба считаются
|
# одну затронутую строку listings_snapshots (INSERT либо DO UPDATE — оба считаются
|
||||||
# в rowcount), поэтому rowcount statement'а по-прежнему равен числу деактивированных.
|
# в rowcount), поэтому rowcount statement'а по-прежнему равен числу деактивированных.
|
||||||
# price_rub берём из listings (NOT NULL в схеме) — это последняя известная цена,
|
# price_rub берём из listings (NOT NULL в схеме) — это последняя известная цена.
|
||||||
# ровно то, по чему объявление ушло с площадки.
|
|
||||||
# ON CONFLICT: если снимок за сегодня уже есть (объявление видели активным утром,
|
# ON CONFLICT: если снимок за сегодня уже есть (объявление видели активным утром,
|
||||||
# а вечером сработал TTL) — только переводим статус в 'closed', цену не переписываем.
|
# а вечером сработал TTL) — только переводим статус в 'stale', цену не переписываем.
|
||||||
_CLOSED_SNAPSHOT_TAIL = """
|
_STALE_SNAPSHOT_TAIL = """
|
||||||
INSERT INTO listings_snapshots
|
INSERT INTO listings_snapshots
|
||||||
(listing_id, snapshot_date, run_id, price_rub, status, observed_at)
|
(listing_id, snapshot_date, run_id, price_rub, status, observed_at)
|
||||||
SELECT id, CURRENT_DATE, CAST(:run_id AS bigint), price_rub, 'closed', NOW()
|
SELECT id, CURRENT_DATE, CAST(:run_id AS bigint), price_rub, 'stale', NOW()
|
||||||
FROM stale
|
FROM stale
|
||||||
ON CONFLICT (listing_id, snapshot_date) DO UPDATE SET
|
ON CONFLICT (listing_id, snapshot_date) DO UPDATE SET
|
||||||
status = 'closed',
|
status = 'stale',
|
||||||
observed_at = EXCLUDED.observed_at
|
observed_at = EXCLUDED.observed_at
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -88,7 +99,7 @@ def _build_all_segments_sql(staleness_column: str) -> Any:
|
||||||
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval)
|
||||||
RETURNING id, price_rub
|
RETURNING id, price_rub
|
||||||
)
|
)
|
||||||
{_CLOSED_SNAPSHOT_TAIL}
|
{_STALE_SNAPSHOT_TAIL}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -110,7 +121,7 @@ def _build_segments_sql(staleness_column: str) -> Any:
|
||||||
AND listing_segment = ANY(CAST(:segments AS text[]))
|
AND listing_segment = ANY(CAST(:segments AS text[]))
|
||||||
RETURNING id, price_rub
|
RETURNING id, price_rub
|
||||||
)
|
)
|
||||||
{_CLOSED_SNAPSHOT_TAIL}
|
{_STALE_SNAPSHOT_TAIL}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -148,7 +159,7 @@ def deactivate_stale_listings(
|
||||||
свежесть = scraped_at (двигается только реальным скрейпом).
|
свежесть = scraped_at (двигается только реальным скрейпом).
|
||||||
|
|
||||||
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
|
||||||
Один statement в транзакции: UPDATE флага + снимок 'closed' в listings_snapshots
|
Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots
|
||||||
(data-modifying CTE, #2674). Финализирует scrape_runs (mark_done / mark_failed).
|
(data-modifying CTE, #2674). Финализирует scrape_runs (mark_done / mark_failed).
|
||||||
|
|
||||||
Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками).
|
Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками).
|
||||||
|
|
|
||||||
|
|
@ -104,29 +104,40 @@ _SNAPSHOT_SQL = text(
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Event diff: все пять типов событий ────────────────────────────────────────
|
# ── Event diff: три выводимых типа событий из пяти в схеме ────────────────────
|
||||||
# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с
|
# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с
|
||||||
# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE).
|
# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE).
|
||||||
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
|
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
|
||||||
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
|
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
|
||||||
#
|
#
|
||||||
# #2674: схема (079) знает пять типов событий, писатель умел один — price_change,
|
# #2674: схема (079) знает пять типов событий, писатель умел один — price_change,
|
||||||
# 8288 строк; delisted/relisted/edited/first_seen — ноль за всё время. Снимок уже
|
# 8288 строк. Дописаны два:
|
||||||
# содержит ровно те поля, из которых остальные четыре выводятся, так что это не новые
|
|
||||||
# данные, а недописанные ветки того же сравнения:
|
|
||||||
# delisted — is_active был true, стал false;
|
|
||||||
# relisted — обратный переход;
|
|
||||||
# edited — payload_hash изменился, а цена нет (изменение цены уже описано
|
# edited — payload_hash изменился, а цена нет (изменение цены уже описано
|
||||||
# отдельным событием price_change — дублировать его как «редактирование»
|
# отдельным событием price_change — дублировать его как «редактирование»
|
||||||
# значило бы считать одно изменение дважды). Прошлый хеш обязан быть
|
# значило бы считать одно изменение дважды). Прошлый хеш обязан быть
|
||||||
# непустым: md5(NULL) = NULL, и «payload появился впервые» — это не
|
# непустым: md5(NULL) = NULL, и «payload появился впервые» — это не
|
||||||
# правка, а первое наблюдение;
|
# правка, а первое наблюдение;
|
||||||
# first_seen — предыдущего снимка нет вовсе (LEFT JOIN LATERAL даёт p.* = NULL).
|
# first_seen — предыдущего снимка нет вовсе (LEFT JOIN LATERAL даёт p.* = NULL).
|
||||||
# ВАЖНО про семантику delisted/relisted: is_active в снимке — derived-признак
|
#
|
||||||
# «last_seen_at свежее окна в FRESHNESS_WINDOW_DAYS дней», а НЕ «площадка удалила
|
# delisted и relisted НЕ ПИШУТСЯ НАМЕРЕННО — они НЕ ВЫВОДИМЫ из наших данных.
|
||||||
# объявление». delisted здесь читается как «источник перестал наблюдаться», и обратный
|
# is_active в снимке — derived-признак «last_seen_at свежее FRESHNESS_WINDOW_DAYS»,
|
||||||
# переход (источник снова попал в скрейп) поэтому нормален и част. Жёсткий сигнал
|
# то есть «мы видели», а не «объявление есть на площадке». При покрытии обхода 10-35%
|
||||||
# снятия живёт в listings_snapshots.status='closed' (см. deactivate_stale_avito.py).
|
# такой переход рождается тем, что скрейпер СНОВА ДОШЁЛ до источника, а не тем, что
|
||||||
|
# объявление вернулось/ушло. Контрольная группа в наших же данных (14-18.07):
|
||||||
|
# domklik, покрытие 99.9-100%: снятий 1/2/0/2/4 в сутки, возвратов — РОВНО 0 все дни;
|
||||||
|
# yandex, покрытие 34-43%: снятий 343-433 в сутки, возвратов до 155.
|
||||||
|
# Тот же обход, тот же день — разница только в покрытии. Отсюда же всплески:
|
||||||
|
# avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против контрольной
|
||||||
|
# ставки 1-4, точность события ≈4%; 4705 «возвратов» из 5493 за 12 дней (86%) — это
|
||||||
|
# два дня после возобновления обхода 2-3.08.
|
||||||
|
# Сузить окно свежести НЕ поможет — станет хуже (больше флапаний); окно шире
|
||||||
|
# максимального интервала повторного визита обессмысливает само событие.
|
||||||
|
# Честный ответ схеме — не писать эти два типа, а не наполнять журнал догадками.
|
||||||
|
# Единственный жёсткий сигнал снятия — 404 при поштучном обходе, он пишется в
|
||||||
|
# listings_snapshots.status='closed' (avito_detail_backfill).
|
||||||
|
#
|
||||||
|
# Оставшиеся три события утверждают факты о НАШИХ СОБСТВЕННЫХ строках («появился новый
|
||||||
|
# источник», «хеш изменился при той же цене», «цена другая»), а не о поведении площадки.
|
||||||
#
|
#
|
||||||
# JOIN → LEFT JOIN LATERAL: без LEFT источники без предыдущего снимка отбрасывались
|
# JOIN → LEFT JOIN LATERAL: без LEFT источники без предыдущего снимка отбрасывались
|
||||||
# join'ом, поэтому first_seen был недостижим по построению. План #2607 не меняется —
|
# join'ом, поэтому first_seen был недостижим по построению. План #2607 не меняется —
|
||||||
|
|
@ -134,8 +145,8 @@ _SNAPSHOT_SQL = text(
|
||||||
# idx_lss_source_date, просто не отбрасывает строку при отсутствии предыдущей.
|
# idx_lss_source_date, просто не отбрасывает строку при отсутствии предыдущей.
|
||||||
#
|
#
|
||||||
# Ветки разворачиваются CROSS JOIN LATERAL (VALUES ...) — одна строка сравнения даёт
|
# Ветки разворачиваются CROSS JOIN LATERAL (VALUES ...) — одна строка сравнения даёт
|
||||||
# до пяти строк-кандидатов, из которых WHERE e.fires оставляет сработавшие. Это
|
# до трёх строк-кандидатов, из которых WHERE e.fires оставляет сработавшие. Это
|
||||||
# по-прежнему ОДИН set-based statement (никакого Python-цикла), просто пять предикатов
|
# по-прежнему ОДИН set-based statement (никакого Python-цикла), просто три предиката
|
||||||
# вместо одного.
|
# вместо одного.
|
||||||
#
|
#
|
||||||
# #2607: раньше `p` был отдельным CTE `DISTINCT ON (listing_source_id) ... FROM
|
# #2607: раньше `p` был отдельным CTE `DISTINCT ON (listing_source_id) ... FROM
|
||||||
|
|
@ -154,8 +165,13 @@ _SNAPSHOT_SQL = text(
|
||||||
#
|
#
|
||||||
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла (LATERAL
|
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла (LATERAL
|
||||||
# — это внутренний план Postgres, не Python-итерация).
|
# — это внутренний план Postgres, не Python-итерация).
|
||||||
# change_time = now() детерминирует UNIQUE(listing_source_id, change_time, event_type)
|
#
|
||||||
# в пределах прогона → ON CONFLICT DO NOTHING делает писатель идемпотентным.
|
# change_time = date_trunc('day', now()), а НЕ now() (#2674): с now() уникальность
|
||||||
|
# UNIQUE(listing_source_id, change_time, event_type) работала только ВНУТРИ прогона —
|
||||||
|
# второй прогон в те же сутки перезаписывал сегодняшний снимок, предикаты срабатывали
|
||||||
|
# заново с другим временем и давали дубли (2 августа таких прогонов было два).
|
||||||
|
# Суточная гранулярность честнее для суточного же сравнения и включает заявленную
|
||||||
|
# идемпотентность: ON CONFLICT DO NOTHING теперь действительно гасит повтор за день.
|
||||||
#
|
#
|
||||||
# NULLIF(p.price_rub, 0) в diff_percent обязателен: выражения VALUES вычисляются ДО
|
# NULLIF(p.price_rub, 0) в diff_percent обязателен: выражения VALUES вычисляются ДО
|
||||||
# фильтра `WHERE e.fires`, поэтому предикат "p.price_rub <> 0" от деления на ноль уже
|
# фильтра `WHERE e.fires`, поэтому предикат "p.price_rub <> 0" от деления на ноль уже
|
||||||
|
|
@ -169,7 +185,7 @@ _SNAPSHOT_SQL = text(
|
||||||
_EVENT_DIFF_SQL = text(
|
_EVENT_DIFF_SQL = text(
|
||||||
"""
|
"""
|
||||||
WITH today AS (
|
WITH today AS (
|
||||||
SELECT listing_source_id, price_rub, is_active, payload_hash
|
SELECT listing_source_id, price_rub, payload_hash
|
||||||
FROM listing_source_snapshots
|
FROM listing_source_snapshots
|
||||||
WHERE snapshot_date = CURRENT_DATE
|
WHERE snapshot_date = CURRENT_DATE
|
||||||
),
|
),
|
||||||
|
|
@ -179,13 +195,13 @@ _EVENT_DIFF_SQL = text(
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
t.listing_source_id,
|
t.listing_source_id,
|
||||||
now(),
|
date_trunc('day', now()),
|
||||||
e.event_type,
|
e.event_type,
|
||||||
t.price_rub,
|
t.price_rub,
|
||||||
e.diff_percent
|
e.diff_percent
|
||||||
FROM today t
|
FROM today t
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT s.snapshot_date, s.price_rub, s.is_active, s.payload_hash
|
SELECT s.snapshot_date, s.price_rub, s.payload_hash
|
||||||
FROM listing_source_snapshots s
|
FROM listing_source_snapshots s
|
||||||
WHERE s.listing_source_id = t.listing_source_id
|
WHERE s.listing_source_id = t.listing_source_id
|
||||||
AND s.snapshot_date < CURRENT_DATE
|
AND s.snapshot_date < CURRENT_DATE
|
||||||
|
|
@ -207,16 +223,6 @@ _EVENT_DIFF_SQL = text(
|
||||||
AND p.price_rub <> 0
|
AND p.price_rub <> 0
|
||||||
AND t.price_rub <> p.price_rub
|
AND t.price_rub <> p.price_rub
|
||||||
),
|
),
|
||||||
(
|
|
||||||
'delisted',
|
|
||||||
NULL::numeric,
|
|
||||||
p.is_active AND NOT t.is_active
|
|
||||||
),
|
|
||||||
(
|
|
||||||
'relisted',
|
|
||||||
NULL::numeric,
|
|
||||||
NOT p.is_active AND t.is_active
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
'edited',
|
'edited',
|
||||||
NULL::numeric,
|
NULL::numeric,
|
||||||
|
|
@ -244,8 +250,9 @@ def snapshot_listing_sources(
|
||||||
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
|
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
|
||||||
Два set-based statement'а в одной транзакции:
|
Два set-based statement'а в одной транзакции:
|
||||||
1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins.
|
1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins.
|
||||||
2. diff сегодняшнего снимка против последнего предыдущего → события всех пяти
|
2. diff сегодняшнего снимка против последнего предыдущего → три события,
|
||||||
типов, которые знает схема 079 (#2674).
|
выводимые из наших данных (#2674). delisted/relisted схема разрешает, но
|
||||||
|
они НЕ выводимы при покрытии обхода 10-35% — см. _EVENT_DIFF_SQL.
|
||||||
|
|
||||||
Params (из default_params jsonb в scrape_schedules, #2607):
|
Params (из default_params jsonb в scrape_schedules, #2607):
|
||||||
budget_sec: float — SET LOCAL statement_timeout на транзакцию (default 900,
|
budget_sec: float — SET LOCAL statement_timeout на транзакцию (default 900,
|
||||||
|
|
@ -258,16 +265,14 @@ def snapshot_listing_sources(
|
||||||
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
|
||||||
|
|
||||||
Returns {"snapshotted": N, "<event_type>_events": M} — по счётчику на каждый из
|
Returns {"snapshotted": N, "<event_type>_events": M} — по счётчику на каждый из
|
||||||
пяти типов событий схемы 079, всегда все пять ключей (тип, который за прогон не
|
трёх пишущихся типов, всегда все три ключа (тип, который за прогон не сработал
|
||||||
сработал ни разу, честно показывает 0, а не пропадает из counters).
|
ни разу, честно показывает 0, а не пропадает из counters).
|
||||||
"""
|
"""
|
||||||
params = params or {}
|
params = params or {}
|
||||||
budget_sec = _clamp_budget_sec(params.get("budget_sec", DEFAULT_BUDGET_SEC))
|
budget_sec = _clamp_budget_sec(params.get("budget_sec", DEFAULT_BUDGET_SEC))
|
||||||
counters: dict[str, int] = {
|
counters: dict[str, int] = {
|
||||||
"snapshotted": 0,
|
"snapshotted": 0,
|
||||||
"price_change_events": 0,
|
"price_change_events": 0,
|
||||||
"delisted_events": 0,
|
|
||||||
"relisted_events": 0,
|
|
||||||
"edited_events": 0,
|
"edited_events": 0,
|
||||||
"first_seen_events": 0,
|
"first_seen_events": 0,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- 212_listings_snapshots_status_vocab.sql
|
||||||
|
-- #2674 — словарь listings_snapshots.status стал трёхзначным: 'stale' ≠ 'closed'.
|
||||||
|
--
|
||||||
|
-- ПРОБЛЕМА: комментарий колонки (016) обещал два значения — 'active' / 'closed' —
|
||||||
|
-- и при этом 'closed' не писал никто и никогда: status был константой 'active' у
|
||||||
|
-- всех 394 704 строк при 55 448 реально неактивных объявлениях. Писатель «снято»
|
||||||
|
-- появился в #2674, но одним значением обойтись нельзя:
|
||||||
|
-- - путь 404 (avito_detail_backfill) ЗНАЕТ, что объявления нет: площадка ответила;
|
||||||
|
-- - путь TTL (deactivate_stale_listings) знает только, что МЫ N суток не смотрели.
|
||||||
|
-- Замер: прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток
|
||||||
|
-- против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая «дата
|
||||||
|
-- продажи» одной датой. Продукт про цены: смешивать факт с догадкой дорого.
|
||||||
|
--
|
||||||
|
-- ДЕЛАЕТ: только обновляет COMMENT — сама колонка `text` без CHECK, DDL не нужен.
|
||||||
|
-- CHECK намеренно НЕ добавляем: 394 704 существующие строки валидны, а жёсткий
|
||||||
|
-- словарь на историческую таблицу — деструктивный риск ради нулевой выгоды.
|
||||||
|
--
|
||||||
|
-- Idempotent: COMMENT ON COLUMN — безусловная перезапись, безопасно повторно.
|
||||||
|
-- Apply after: 211_sales_vs_listings_segment_guard.sql
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN listings_snapshots.status IS
|
||||||
|
'''active'' = объявление видели в прогоне. '
|
||||||
|
'''closed'' = площадка ответила 404 на поштучном обходе (жёсткий факт снятия). '
|
||||||
|
'''stale'' = TTL-деактивация: мы N суток не смотрели (догадка, НЕ дата продажи). '
|
||||||
|
'NULL = неизвестно. Словарь расширен в #2674 — до него писалось только ''active''.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -6,14 +6,18 @@
|
||||||
с «что писатель реально перечисляет», поэтому тесты ниже читают миграции и
|
с «что писатель реально перечисляет», поэтому тесты ниже читают миграции и
|
||||||
сравнивают их с SQL писателя, а не повторяют его же список колонок.
|
сравнивают их с SQL писателя, а не повторяют его же список колонок.
|
||||||
|
|
||||||
Числа с прода на 2026-08-05 (до фикса):
|
Числа с прода на 2026-08-05/06 (до фикса):
|
||||||
1. house_suggestions — 25 055 строк, image_link/area_m2/rooms/floor/total_floors
|
1. house_suggestions — 25 055 строк, image_link/area_m2/rooms/floor/total_floors
|
||||||
заполнены у 0 из них (колонки с миграции 064, ~74 дня).
|
заполнены у 0 из них (колонки с миграции 064, ~74 дня).
|
||||||
2. listings_snapshots.status — 'active' у всех 394 704 строк при 55 448 реально
|
2. listings_snapshots.status — 'active' у всех 394 704 строк при 55 448 реально
|
||||||
неактивных объявлений; 'closed' не писал никто и никогда.
|
неактивных объявлений; ни 'closed', ни 'stale' не писал никто и никогда.
|
||||||
3. listing_source_events — 8288 строк, все price_change; delisted/relisted/
|
3. listing_source_events — 8288 строк, все price_change; edited/first_seen —
|
||||||
edited/first_seen — ноль за всё время при 10 501/5 493/414 реальных переходах
|
ноль за всё время.
|
||||||
в снимках только за последние 12 дней.
|
|
||||||
|
Отдельный класс тестов — гейты на то, что писатель НЕ пишет: delisted/relisted схема
|
||||||
|
разрешает, но при покрытии обхода 10-35% они неотличимы от «скрейпер снова дошёл»
|
||||||
|
(контроль — домклик со 100% покрытием: 0 возвратов за 5 суток), а TTL-путь не имеет
|
||||||
|
права называть протухание снятием. Журнал и история из догадок хуже пустых.
|
||||||
|
|
||||||
БД и сеть замоканы — реального Postgres не нужно.
|
БД и сеть замоканы — реального Postgres не нужно.
|
||||||
"""
|
"""
|
||||||
|
|
@ -96,6 +100,26 @@ def test_suggestion_parser_survives_unparsable_title() -> None:
|
||||||
assert (sugg.rooms, sugg.area_m2, sugg.floor, sugg.total_floors) == (None, None, None, None)
|
assert (sugg.rooms, sugg.area_m2, sugg.floor, sugg.total_floors) == (None, None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_studio_title_keeps_area_and_floors() -> None:
|
||||||
|
"""Студия не роняет разбор целиком: 1991 заголовок из 25 055 (7.9%) — без комнатности.
|
||||||
|
|
||||||
|
Обязательная группа комнатности обнуляла ВСЕ ЧЕТЫРЕ поля, хотя площадь и этажность
|
||||||
|
в заголовке есть. rooms=0 — конвенция kit'а («0 = студия»), а не «неизвестно».
|
||||||
|
"""
|
||||||
|
sugg = _parse_suggestion(
|
||||||
|
{"id": 3, "title": "Квартира-студия, 34,2 м², 9/10 эт.", "price": 3_500_000}
|
||||||
|
)
|
||||||
|
assert (sugg.rooms, sugg.area_m2, sugg.floor, sugg.total_floors) == (0, 34.2, 9, 10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_placement_history_gets_same_title_fix() -> None:
|
||||||
|
"""Тот же регексп чинит второго писателя — house_placement_history (8.8% без площади)."""
|
||||||
|
from scraper_kit.providers.avito.imv import _parse_placement_item
|
||||||
|
|
||||||
|
item = _parse_placement_item({"id": 9, "title": "Квартира-студия, 28 м², 2/17 эт."})
|
||||||
|
assert (item.rooms, item.area_m2, item.floor, item.total_floors) == (0, 28.0, 2, 17)
|
||||||
|
|
||||||
|
|
||||||
def test_live_fixture_suggestions_carry_image_link() -> None:
|
def test_live_fixture_suggestions_carry_image_link() -> None:
|
||||||
"""Живой capture avito_imv_getdata.json: у подсказок реально есть imageLink."""
|
"""Живой capture avito_imv_getdata.json: у подсказок реально есть imageLink."""
|
||||||
data = json.loads((_FIXTURES / "avito_imv_getdata.json").read_text("utf-8"))
|
data = json.loads((_FIXTURES / "avito_imv_getdata.json").read_text("utf-8"))
|
||||||
|
|
@ -165,15 +189,15 @@ def test_save_imv_result_binds_image_link_and_metrics() -> None:
|
||||||
assert (bound["rooms"], bound["area"], bound["floor"], bound["total_floors"]) == (2, 42.0, 4, 5)
|
assert (bound["rooms"], bound["area"], bound["floor"], bound["total_floors"]) == (2, 42.0, 4, 5)
|
||||||
|
|
||||||
|
|
||||||
# ══ 2. Статус «снято» в дневной истории объявлений ════════════════════════════
|
# ══ 2. «Снято» и «протухло» в дневной истории объявлений ══════════════════════
|
||||||
|
|
||||||
_DEACT_SQL_BUILDERS = (deact_mod._build_all_segments_sql, deact_mod._build_segments_sql)
|
_DEACT_SQL_BUILDERS = (deact_mod._build_all_segments_sql, deact_mod._build_segments_sql)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("build", _DEACT_SQL_BUILDERS)
|
@pytest.mark.parametrize("build", _DEACT_SQL_BUILDERS)
|
||||||
@pytest.mark.parametrize("column", sorted(deact_mod._ALLOWED_STALENESS_COLUMNS))
|
@pytest.mark.parametrize("column", sorted(deact_mod._ALLOWED_STALENESS_COLUMNS))
|
||||||
def test_deactivation_writes_closed_snapshot_in_same_statement(build: Any, column: str) -> None:
|
def test_deactivation_writes_stale_snapshot_in_same_statement(build: Any, column: str) -> None:
|
||||||
"""Деактивация и снимок 'closed' — один statement, значит одна транзакция.
|
"""Деактивация и снимок 'stale' — один statement, значит одна транзакция.
|
||||||
|
|
||||||
До #2674 задача только двигала флаг: в listings_snapshots не появлялось ничего,
|
До #2674 задача только двигала флаг: в listings_snapshots не появлялось ничего,
|
||||||
и дата снятия объявления (лучший сигнал «скорее всего продано») восстанавливалась
|
и дата снятия объявления (лучший сигнал «скорее всего продано») восстанавливалась
|
||||||
|
|
@ -183,7 +207,7 @@ def test_deactivation_writes_closed_snapshot_in_same_statement(build: Any, colum
|
||||||
assert "SET is_active = false" in sql
|
assert "SET is_active = false" in sql
|
||||||
assert "RETURNING id, price_rub" in sql
|
assert "RETURNING id, price_rub" in sql
|
||||||
assert "INSERT INTO listings_snapshots" in sql
|
assert "INSERT INTO listings_snapshots" in sql
|
||||||
assert "'closed'" in sql
|
assert "'stale'" in sql
|
||||||
# Снимок пишется по строкам, которые вернул сам UPDATE, — не отдельной выборкой.
|
# Снимок пишется по строкам, которые вернул сам UPDATE, — не отдельной выборкой.
|
||||||
assert "FROM stale" in sql
|
assert "FROM stale" in sql
|
||||||
# Идемпотентность: повторный прогон в те же сутки не падает на PK.
|
# Идемпотентность: повторный прогон в те же сутки не падает на PK.
|
||||||
|
|
@ -192,6 +216,17 @@ def test_deactivation_writes_closed_snapshot_in_same_statement(build: Any, colum
|
||||||
assert not re.search(r":\w+::", sql)
|
assert not re.search(r":\w+::", sql)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("build", _DEACT_SQL_BUILDERS)
|
||||||
|
def test_ttl_path_never_claims_closed(build: Any) -> None:
|
||||||
|
"""TTL-путь НЕ имеет права писать 'closed' — он не знает, что объявление снято.
|
||||||
|
|
||||||
|
Замер: прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток
|
||||||
|
против 12 суток простоя обхода). Под статусом 'closed' это 6131 фальшивая «дата
|
||||||
|
продажи» одной датой. 'closed' остаётся только за 404 — там ответила площадка.
|
||||||
|
"""
|
||||||
|
assert "'closed'" not in str(build("last_seen_at").text)
|
||||||
|
|
||||||
|
|
||||||
class _FakeResult:
|
class _FakeResult:
|
||||||
def __init__(self, rowcount: int) -> None:
|
def __init__(self, rowcount: int) -> None:
|
||||||
self.rowcount = rowcount
|
self.rowcount = rowcount
|
||||||
|
|
@ -282,18 +317,43 @@ def _schema_event_types() -> set[str]:
|
||||||
return set(re.findall(r"'([a-z_]+)'", check))
|
return set(re.findall(r"'([a-z_]+)'", check))
|
||||||
|
|
||||||
|
|
||||||
def test_event_writer_covers_every_schema_event_type() -> None:
|
# Два типа схемы НЕ ВЫВОДИМЫ из наших данных и намеренно не пишутся (#2674).
|
||||||
"""Писатель обязан уметь КАЖДЫЙ тип, который разрешает CHECK схемы.
|
# is_active в снимке значит «мы видели», а не «есть на площадке», поэтому переход
|
||||||
|
# рождается тем, что скрейпер снова дошёл до источника. Контрольная группа за 14-18.07:
|
||||||
|
# domklik при покрытии 99.9-100% дал возвратов РОВНО 0 и снятий 1-4 в сутки, yandex при
|
||||||
|
# 34-43% — снятий 343-433 в сутки. Тот же обход, тот же день, разница только в покрытии.
|
||||||
|
# Отсюда: avito 13.07 (остановка обхода) 3023 «снятия» за сутки против контрольных 1-4
|
||||||
|
# (точность ≈4%), и 4705 «возвратов» из 5493 за 12 дней — два дня после возобновления.
|
||||||
|
_NOT_DERIVABLE_EVENT_TYPES = {"delisted", "relisted"}
|
||||||
|
|
||||||
До #2674 из пяти типов писался один (price_change, 8288 строк), остальные
|
|
||||||
четыре — ноль за всё время. Тест сверяет со схемой, а не с копией списка,
|
def test_event_writer_covers_every_derivable_schema_event_type() -> None:
|
||||||
|
"""Писатель обязан уметь каждый ВЫВОДИМЫЙ тип из CHECK схемы.
|
||||||
|
|
||||||
|
До #2674 из пяти типов писался один (price_change, 8288 строк). Дописаны два
|
||||||
|
выводимых; два оставшихся — сознательное решение, а не забытая ветка (см.
|
||||||
|
_NOT_DERIVABLE_EVENT_TYPES). Тест сверяет со схемой, а не с копией списка,
|
||||||
поэтому покраснеет и на шестом типе, добавленном в CHECK без писателя.
|
поэтому покраснеет и на шестом типе, добавленном в CHECK без писателя.
|
||||||
"""
|
"""
|
||||||
declared = _schema_event_types()
|
declared = _schema_event_types()
|
||||||
assert len(declared) == 5, f"схема 079 изменилась: {sorted(declared)}"
|
assert len(declared) == 5, f"схема 079 изменилась: {sorted(declared)}"
|
||||||
sql = str(snap_mod._EVENT_DIFF_SQL.text)
|
sql = str(snap_mod._EVENT_DIFF_SQL.text)
|
||||||
missing = {t for t in declared if f"'{t}'" not in sql}
|
expected = declared - _NOT_DERIVABLE_EVENT_TYPES
|
||||||
assert not missing, f"типы событий без писателя: {sorted(missing)}"
|
missing = {t for t in expected if f"'{t}'" not in sql}
|
||||||
|
assert not missing, f"выводимые типы без писателя: {sorted(missing)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_derivable_events_are_never_written() -> None:
|
||||||
|
"""delisted/relisted не пишутся: при покрытии обхода 10-35% они неотличимы от
|
||||||
|
«скрейпер снова дошёл». Контроль — домклик со 100% покрытием: 0 возвратов за 5 суток.
|
||||||
|
|
||||||
|
Гейт против «дописать для полноты»: журнал из догадок хуже пустого журнала.
|
||||||
|
"""
|
||||||
|
sql = str(snap_mod._EVENT_DIFF_SQL.text)
|
||||||
|
written = {t for t in _NOT_DERIVABLE_EVENT_TYPES if f"'{t}'" in sql}
|
||||||
|
assert not written, f"невыводимые типы попали в писатель: {sorted(written)}"
|
||||||
|
# is_active больше не читается вовсе — иначе ветка вернётся незаметно.
|
||||||
|
assert "is_active" not in sql
|
||||||
|
|
||||||
|
|
||||||
def test_first_seen_requires_left_join_and_derivations_use_snapshot_fields() -> None:
|
def test_first_seen_requires_left_join_and_derivations_use_snapshot_fields() -> None:
|
||||||
|
|
@ -305,11 +365,16 @@ def test_first_seen_requires_left_join_and_derivations_use_snapshot_fields() ->
|
||||||
sql = str(snap_mod._EVENT_DIFF_SQL.text)
|
sql = str(snap_mod._EVENT_DIFF_SQL.text)
|
||||||
assert "LEFT JOIN LATERAL" in sql
|
assert "LEFT JOIN LATERAL" in sql
|
||||||
assert "p.snapshot_date IS NULL" in sql # first_seen
|
assert "p.snapshot_date IS NULL" in sql # first_seen
|
||||||
assert "p.is_active AND NOT t.is_active" in sql # delisted
|
|
||||||
assert "NOT p.is_active AND t.is_active" in sql # relisted
|
|
||||||
assert "t.payload_hash IS DISTINCT FROM p.payload_hash" in sql # edited
|
assert "t.payload_hash IS DISTINCT FROM p.payload_hash" in sql # edited
|
||||||
# Снимок за сегодня обязан отдавать поля, из которых выводятся ветки.
|
# Снимок за сегодня обязан отдавать поля, из которых выводятся ветки.
|
||||||
assert "SELECT listing_source_id, price_rub, is_active, payload_hash" in sql
|
assert "SELECT listing_source_id, price_rub, payload_hash" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_dedup_works_across_runs_not_only_within_one() -> None:
|
||||||
|
"""change_time усечён до суток: UNIQUE(source, change_time, type) должен гасить
|
||||||
|
повторный прогон в те же сутки (2 августа их было два), а не только строки одного."""
|
||||||
|
sql = str(snap_mod._EVENT_DIFF_SQL.text)
|
||||||
|
assert "date_trunc('day', now())" in sql
|
||||||
|
|
||||||
|
|
||||||
def test_price_change_division_guarded_by_nullif() -> None:
|
def test_price_change_division_guarded_by_nullif() -> None:
|
||||||
|
|
@ -341,22 +406,26 @@ class _EventFakeDB:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def test_counters_report_all_five_types_including_zeros(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_counters_report_every_written_type_including_zeros(
|
||||||
"""Счётчики прогона показывают все пять типов; не сработавший честно равен 0.
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Счётчики прогона показывают все пишущиеся типы; не сработавший честно равен 0.
|
||||||
|
|
||||||
Ровно этого счётчика не хватало, чтобы заметить четыре нуля из пяти за 66 дней.
|
Ровно этого счётчика не хватало, чтобы заметить четыре нуля из пяти за 66 дней.
|
||||||
|
Счётчиков НЕвыводимых типов быть не должно — иначе вечный 0 будет читаться как
|
||||||
|
«событий не было», а не как «мы это сознательно не пишем».
|
||||||
"""
|
"""
|
||||||
monkeypatch.setattr(snap_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
monkeypatch.setattr(snap_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||||||
monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None)
|
monkeypatch.setattr(snap_mod.runs_mod, "mark_failed", lambda *a, **k: None)
|
||||||
|
|
||||||
db = _EventFakeDB([("delisted_events", 10_501), ("first_seen_events", 600)])
|
db = _EventFakeDB([("first_seen_events", 600), ("edited_events", 17)])
|
||||||
out = snap_mod.snapshot_listing_sources(db, run_id=1) # type: ignore[arg-type]
|
out = snap_mod.snapshot_listing_sources(db, run_id=1) # type: ignore[arg-type]
|
||||||
|
|
||||||
assert out["delisted_events"] == 10_501
|
|
||||||
assert out["first_seen_events"] == 600
|
assert out["first_seen_events"] == 600
|
||||||
assert out["relisted_events"] == 0
|
assert out["edited_events"] == 17
|
||||||
assert out["edited_events"] == 0
|
|
||||||
assert out["price_change_events"] == 0
|
assert out["price_change_events"] == 0
|
||||||
# Ключ на каждый тип из схемы — иначе «ноль» неотличим от «типа нет в counters».
|
# Ключ на каждый пишущийся тип — иначе «ноль» неотличим от «типа нет в counters».
|
||||||
for event_type in _schema_event_types():
|
for event_type in _schema_event_types() - _NOT_DERIVABLE_EVENT_TYPES:
|
||||||
assert f"{event_type}_events" in out
|
assert f"{event_type}_events" in out
|
||||||
|
for event_type in _NOT_DERIVABLE_EVENT_TYPES:
|
||||||
|
assert f"{event_type}_events" not in out
|
||||||
|
|
|
||||||
|
|
@ -256,8 +256,6 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
assert out == {
|
assert out == {
|
||||||
"snapshotted": 18355,
|
"snapshotted": 18355,
|
||||||
"price_change_events": 42,
|
"price_change_events": 42,
|
||||||
"delisted_events": 0,
|
|
||||||
"relisted_events": 0,
|
|
||||||
"edited_events": 0,
|
"edited_events": 0,
|
||||||
"first_seen_events": 0,
|
"first_seen_events": 0,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -215,10 +215,18 @@ def compute_imv_cache_key(
|
||||||
|
|
||||||
|
|
||||||
# Паттерн для заголовка вида "2-к. квартира, 42 м², 4/5 эт."
|
# Паттерн для заголовка вида "2-к. квартира, 42 м², 4/5 эт."
|
||||||
|
# Комнатность НЕОБЯЗАТЕЛЬНА (#2674): 1991 заголовок из 25 055 (7.9%) — «Квартира-студия,
|
||||||
|
# 34,2 м², 9/10 эт.». Площадь и этажность там есть, но обязательная группа комнатности
|
||||||
|
# роняла match целиком и обнуляла ВСЕ ЧЕТЫРЕ поля. Тот же потолок был виден на соседней
|
||||||
|
# таблице (8.8% строк house_placement_history без площади) — обоих писателей чинит один
|
||||||
|
# регексп. Опциональная группа жадная, поэтому «3-к. квартира…» по-прежнему даёт rooms=3.
|
||||||
_TITLE_RE = re.compile(
|
_TITLE_RE = re.compile(
|
||||||
r"^(?P<rooms>\d+)-к[.\s].*?(?P<area>[\d,]+)\s*м².*?(?P<floor>\d+)/(?P<total>\d+)\s*эт",
|
r"^(?:(?P<rooms>\d+)-к[.\s])?.*?(?P<area>[\d,]+)\s*м².*?(?P<floor>\d+)/(?P<total>\d+)\s*эт",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
# Студия = 0 комнат — конвенция kit'а (scraper_kit.base.RawLot.rooms «0 = студия»,
|
||||||
|
# providers/yandex/detail.py). Отличаем её от «комнатность неизвестна» (None).
|
||||||
|
_TITLE_STUDIO_RE = re.compile(r"студи[яюей]", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _parse_title(title: str | None) -> dict[str, int | float | None]:
|
def _parse_title(title: str | None) -> dict[str, int | float | None]:
|
||||||
|
|
@ -235,7 +243,11 @@ def _parse_title(title: str | None) -> dict[str, int | float | None]:
|
||||||
if not m:
|
if not m:
|
||||||
return result
|
return result
|
||||||
try:
|
try:
|
||||||
result["rooms"] = int(m.group("rooms"))
|
rooms = m.group("rooms")
|
||||||
|
if rooms:
|
||||||
|
result["rooms"] = int(rooms)
|
||||||
|
elif _TITLE_STUDIO_RE.search(title):
|
||||||
|
result["rooms"] = 0 # студия
|
||||||
result["area_m2"] = float(m.group("area").replace(",", "."))
|
result["area_m2"] = float(m.group("area").replace(",", "."))
|
||||||
result["floor"] = int(m.group("floor"))
|
result["floor"] = int(m.group("floor"))
|
||||||
result["total_floors"] = int(m.group("total"))
|
result["total_floors"] = int(m.group("total"))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue