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

Ревью 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:
bot-backend 2026-08-06 03:01:05 +05:00
parent 43aaf91b97
commit ab01f7cc48
6 changed files with 199 additions and 75 deletions

View file

@ -10,7 +10,8 @@
- avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений.
- Строки НЕ удаляются -- история нужна для бэктеста (#667).
- #2674: деактивация в той же транзакции пишет снимок listings_snapshots со статусом
'closed' за текущую дату -- дата снятия объявления перестаёт быть догадкой.
'stale' за текущую дату -- «мы N суток не видели». Жёсткое 'closed' (площадка
ответила 404) пишет только avito_detail_backfill: смешивать факт с догадкой дорого.
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом
через 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"})
# ── Снимок «снято» в дневной истории (#2674) ──────────────────────────────────
# ── Снимок «протухло» в дневной истории (#2674) ───────────────────────────────
# listings_snapshots.status до этого фикса был константой 'active' у всех строк
# (394 299 на момент находки) — оба места вызова upsert_listing_snapshot передавали
# литерал '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 флага: деактивация без снимка (или
# наоборот) невозможна по построению — один statement, data-modifying CTE.
# 1:1 по строкам: `stale` возвращает уникальные listings.id (PK), каждая даёт ровно
# одну затронутую строку listings_snapshots (INSERT либо DO UPDATE — оба считаются
# в rowcount), поэтому rowcount statement'а по-прежнему равен числу деактивированных.
# price_rub берём из listings (NOT NULL в схеме) — это последняя известная цена,
# ровно то, по чему объявление ушло с площадки.
# price_rub берём из listings (NOT NULL в схеме) — это последняя известная цена.
# ON CONFLICT: если снимок за сегодня уже есть (объявление видели активным утром,
# а вечером сработал TTL) — только переводим статус в 'closed', цену не переписываем.
_CLOSED_SNAPSHOT_TAIL = """
# а вечером сработал TTL) — только переводим статус в 'stale', цену не переписываем.
_STALE_SNAPSHOT_TAIL = """
INSERT INTO listings_snapshots
(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
ON CONFLICT (listing_id, snapshot_date) DO UPDATE SET
status = 'closed',
status = 'stale',
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)
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[]))
RETURNING id, price_rub
)
{_CLOSED_SNAPSHOT_TAIL}
{_STALE_SNAPSHOT_TAIL}
"""
)
@ -148,7 +159,7 @@ def deactivate_stale_listings(
свежесть = scraped_at (двигается только реальным скрейпом).
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).
Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками).

View file

@ -104,29 +104,40 @@ _SNAPSHOT_SQL = text(
"""
)
# ── Event diff: все пять типов событий ────────────────────────────────────────
# ── Event diff: три выводимых типа событий из пяти в схеме ────────────────────
# Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с
# самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE).
# today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции).
# p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607).
#
# #2674: схема (079) знает пять типов событий, писатель умел один — price_change,
# 8288 строк; delisted/relisted/edited/first_seen — ноль за всё время. Снимок уже
# содержит ровно те поля, из которых остальные четыре выводятся, так что это не новые
# данные, а недописанные ветки того же сравнения:
# delisted — is_active был true, стал false;
# relisted — обратный переход;
# 8288 строк. Дописаны два:
# edited — payload_hash изменился, а цена нет (изменение цены уже описано
# отдельным событием price_change — дублировать его как «редактирование»
# значило бы считать одно изменение дважды). Прошлый хеш обязан быть
# непустым: md5(NULL) = NULL, и «payload появился впервые» — это не
# правка, а первое наблюдение;
# first_seen — предыдущего снимка нет вовсе (LEFT JOIN LATERAL даёт p.* = NULL).
# ВАЖНО про семантику delisted/relisted: is_active в снимке — derived-признак
# «last_seen_at свежее окна в FRESHNESS_WINDOW_DAYS дней», а НЕ «площадка удалила
# объявление». delisted здесь читается как «источник перестал наблюдаться», и обратный
# переход (источник снова попал в скрейп) поэтому нормален и част. Жёсткий сигнал
# снятия живёт в listings_snapshots.status='closed' (см. deactivate_stale_avito.py).
#
# delisted и relisted НЕ ПИШУТСЯ НАМЕРЕННО — они НЕ ВЫВОДИМЫ из наших данных.
# is_active в снимке — derived-признак «last_seen_at свежее FRESHNESS_WINDOW_DAYS»,
# то есть «мы видели», а не «объявление есть на площадке». При покрытии обхода 10-35%
# такой переход рождается тем, что скрейпер СНОВА ДОШЁЛ до источника, а не тем, что
# объявление вернулось/ушло. Контрольная группа в наших же данных (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'ом, поэтому first_seen был недостижим по построению. План #2607 не меняется —
@ -134,8 +145,8 @@ _SNAPSHOT_SQL = text(
# idx_lss_source_date, просто не отбрасывает строку при отсутствии предыдущей.
#
# Ветки разворачиваются CROSS JOIN LATERAL (VALUES ...) — одна строка сравнения даёт
# до пяти строк-кандидатов, из которых WHERE e.fires оставляет сработавшие. Это
# по-прежнему ОДИН set-based statement (никакого Python-цикла), просто пять предикатов
# до трёх строк-кандидатов, из которых WHERE e.fires оставляет сработавшие. Это
# по-прежнему ОДИН set-based statement (никакого Python-цикла), просто три предиката
# вместо одного.
#
# #2607: раньше `p` был отдельным CTE `DISTINCT ON (listing_source_id) ... FROM
@ -154,8 +165,13 @@ _SNAPSHOT_SQL = text(
#
# Полностью set-based: один INSERT … SELECT по всем источникам, без Python-цикла (LATERAL
# — это внутренний план 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 вычисляются ДО
# фильтра `WHERE e.fires`, поэтому предикат "p.price_rub <> 0" от деления на ноль уже
@ -169,7 +185,7 @@ _SNAPSHOT_SQL = text(
_EVENT_DIFF_SQL = text(
"""
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
WHERE snapshot_date = CURRENT_DATE
),
@ -179,13 +195,13 @@ _EVENT_DIFF_SQL = text(
)
SELECT
t.listing_source_id,
now(),
date_trunc('day', now()),
e.event_type,
t.price_rub,
e.diff_percent
FROM today t
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
WHERE s.listing_source_id = t.listing_source_id
AND s.snapshot_date < CURRENT_DATE
@ -207,16 +223,6 @@ _EVENT_DIFF_SQL = text(
AND p.price_rub <> 0
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',
NULL::numeric,
@ -244,8 +250,9 @@ def snapshot_listing_sources(
Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp).
Два set-based statement'а в одной транзакции:
1. upsert снимка на (listing_source_id, CURRENT_DATE) last-write-wins.
2. diff сегодняшнего снимка против последнего предыдущего события всех пяти
типов, которые знает схема 079 (#2674).
2. diff сегодняшнего снимка против последнего предыдущего три события,
выводимые из наших данных (#2674). delisted/relisted схема разрешает, но
они НЕ выводимы при покрытии обхода 10-35% см. _EVENT_DIFF_SQL.
Params (из default_params jsonb в scrape_schedules, #2607):
budget_sec: float SET LOCAL statement_timeout на транзакцию (default 900,
@ -258,16 +265,14 @@ def snapshot_listing_sources(
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
Returns {"snapshotted": N, "<event_type>_events": M} по счётчику на каждый из
пяти типов событий схемы 079, всегда все пять ключей (тип, который за прогон не
сработал ни разу, честно показывает 0, а не пропадает из counters).
трёх пишущихся типов, всегда все три ключа (тип, который за прогон не сработал
ни разу, честно показывает 0, а не пропадает из counters).
"""
params = params or {}
budget_sec = _clamp_budget_sec(params.get("budget_sec", DEFAULT_BUDGET_SEC))
counters: dict[str, int] = {
"snapshotted": 0,
"price_change_events": 0,
"delisted_events": 0,
"relisted_events": 0,
"edited_events": 0,
"first_seen_events": 0,
}

View file

@ -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;

View file

@ -6,14 +6,18 @@
с «что писатель реально перечисляет», поэтому тесты ниже читают миграции и
сравнивают их с SQL писателя, а не повторяют его же список колонок.
Числа с прода на 2026-08-05 (до фикса):
Числа с прода на 2026-08-05/06 (до фикса):
1. house_suggestions 25 055 строк, image_link/area_m2/rooms/floor/total_floors
заполнены у 0 из них (колонки с миграции 064, ~74 дня).
2. listings_snapshots.status 'active' у всех 394 704 строк при 55 448 реально
неактивных объявлений; 'closed' не писал никто и никогда.
3. listing_source_events 8288 строк, все price_change; delisted/relisted/
edited/first_seen ноль за всё время при 10 501/5 493/414 реальных переходах
в снимках только за последние 12 дней.
неактивных объявлений; ни 'closed', ни 'stale' не писал никто и никогда.
3. listing_source_events 8288 строк, все price_change; edited/first_seen
ноль за всё время.
Отдельный класс тестов гейты на то, что писатель НЕ пишет: delisted/relisted схема
разрешает, но при покрытии обхода 10-35% они неотличимы от «скрейпер снова дошёл»
(контроль домклик со 100% покрытием: 0 возвратов за 5 суток), а TTL-путь не имеет
права называть протухание снятием. Журнал и история из догадок хуже пустых.
БД и сеть замоканы реального 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)
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:
"""Живой capture avito_imv_getdata.json: у подсказок реально есть imageLink."""
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)
# ══ 2. Статус «снято» в дневной истории объявлений ════════════════════════════
# ══ 2. «Снято» и «протухло» в дневной истории объявлений ══════════════════════
_DEACT_SQL_BUILDERS = (deact_mod._build_all_segments_sql, deact_mod._build_segments_sql)
@pytest.mark.parametrize("build", _DEACT_SQL_BUILDERS)
@pytest.mark.parametrize("column", sorted(deact_mod._ALLOWED_STALENESS_COLUMNS))
def test_deactivation_writes_closed_snapshot_in_same_statement(build: Any, column: str) -> None:
"""Деактивация и снимок 'closed' — один statement, значит одна транзакция.
def test_deactivation_writes_stale_snapshot_in_same_statement(build: Any, column: str) -> None:
"""Деактивация и снимок 'stale' — один statement, значит одна транзакция.
До #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 "RETURNING id, price_rub" in sql
assert "INSERT INTO listings_snapshots" in sql
assert "'closed'" in sql
assert "'stale'" in sql
# Снимок пишется по строкам, которые вернул сам UPDATE, — не отдельной выборкой.
assert "FROM stale" in sql
# Идемпотентность: повторный прогон в те же сутки не падает на PK.
@ -192,6 +216,17 @@ def test_deactivation_writes_closed_snapshot_in_same_statement(build: Any, colum
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:
def __init__(self, rowcount: int) -> None:
self.rowcount = rowcount
@ -282,18 +317,43 @@ def _schema_event_types() -> set[str]:
return set(re.findall(r"'([a-z_]+)'", check))
def test_event_writer_covers_every_schema_event_type() -> None:
"""Писатель обязан уметь КАЖДЫЙ тип, который разрешает CHECK схемы.
# Два типа схемы НЕ ВЫВОДИМЫ из наших данных и намеренно не пишутся (#2674).
# 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 без писателя.
"""
declared = _schema_event_types()
assert len(declared) == 5, f"схема 079 изменилась: {sorted(declared)}"
sql = str(snap_mod._EVENT_DIFF_SQL.text)
missing = {t for t in declared if f"'{t}'" not in sql}
assert not missing, f"типы событий без писателя: {sorted(missing)}"
expected = declared - _NOT_DERIVABLE_EVENT_TYPES
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:
@ -305,11 +365,16 @@ def test_first_seen_requires_left_join_and_derivations_use_snapshot_fields() ->
sql = str(snap_mod._EVENT_DIFF_SQL.text)
assert "LEFT JOIN LATERAL" in sql
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 "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:
@ -341,22 +406,26 @@ class _EventFakeDB:
pass
def test_counters_report_all_five_types_including_zeros(monkeypatch: pytest.MonkeyPatch) -> None:
"""Счётчики прогона показывают все пять типов; не сработавший честно равен 0.
def test_counters_report_every_written_type_including_zeros(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Счётчики прогона показывают все пишущиеся типы; не сработавший честно равен 0.
Ровно этого счётчика не хватало, чтобы заметить четыре нуля из пяти за 66 дней.
Счётчиков НЕвыводимых типов быть не должно иначе вечный 0 будет читаться как
«событий не было», а не как «мы это сознательно не пишем».
"""
monkeypatch.setattr(snap_mod.runs_mod, "mark_done", 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]
assert out["delisted_events"] == 10_501
assert out["first_seen_events"] == 600
assert out["relisted_events"] == 0
assert out["edited_events"] == 0
assert out["edited_events"] == 17
assert out["price_change_events"] == 0
# Ключ на каждый тип из схемы — иначе «ноль» неотличим от «типа нет в counters».
for event_type in _schema_event_types():
# Ключ на каждый пишущийся тип — иначе «ноль» неотличим от «типа нет в counters».
for event_type in _schema_event_types() - _NOT_DERIVABLE_EVENT_TYPES:
assert f"{event_type}_events" in out
for event_type in _NOT_DERIVABLE_EVENT_TYPES:
assert f"{event_type}_events" not in out

View file

@ -256,8 +256,6 @@ def test_counter_logic_with_fake_db(monkeypatch: pytest.MonkeyPatch) -> None:
assert out == {
"snapshotted": 18355,
"price_change_events": 42,
"delisted_events": 0,
"relisted_events": 0,
"edited_events": 0,
"first_seen_events": 0,
}

View file

@ -215,10 +215,18 @@ def compute_imv_cache_key(
# Паттерн для заголовка вида "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(
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,
)
# Студия = 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]:
@ -235,7 +243,11 @@ def _parse_title(title: str | None) -> dict[str, int | float | None]:
if not m:
return result
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["floor"] = int(m.group("floor"))
result["total_floors"] = int(m.group("total"))