From 4d0795ae7a6ae9330a00d6bbb3833bd99da98848 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 03:34:18 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/admin):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BC=D0=BE=D0=B3=D1=83=D1=82=20=D0=B1=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BD=D0=B5=D0=BD=D1=83=D0=BB=D0=B5=D0=B2=D1=8B?= =?UTF-8?q?=D0=BC=D0=B8,=20=D0=B8=20=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B8=D0=B7=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20(#2674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Четыре находки одного класса: админка показывает числа, которые никогда не бывают ненулевыми, и подаёт это как результат. Ноль читается оператором как «всё чисто», а не как «мы это не считаем» — такой показатель хуже отсутствующего. 1. «Помечено выбросов» (v_data_quality.outliers_flagged) — УБРАН вместе с колонкой listings.is_outlier. Механизм не «не доделан»: «выброс» у эстиматора вычисляется Tukey-фильтром по КОНКРЕТНОЙ подборке аналогов и живёт один запрос — один и тот же лот выброс для одной оценки и нормальный аналог для соседней. Persist-флаг на объявлении такое отношение выразить не может, реализовать пометку нечем. 2. http_requests / http_errors / returning_count / disappeared_count — УБРАНЫ. HTTP-запросы не считает ни один фетчер (заполнить нечем без сквозной инструментации). Ошибки и «пропало/вернулось» уже считает тот, кто их знает, и кладёт в counters jsonb: errors_count у pipeline, deactivated/revived у deactivate_stale_*. Отдельные колонки были бы вторым определением того же. 3. run_type — УБРАН из API, из таблицы админки и из схемы. Ни одно место кода его не задавало; DEFAULT из 051 подписывал 'city_sweep' даже proxy_healthcheck. Колонка «Тип» в UI заменена на «Источник» — там осмысленное значение. 4. Фильтр источников — теперь из данных (GET /scrape/runs/sources, SELECT DISTINCT source). Захардкоженная тройка не просто была неполной: сравнение точное, а строк с source='avito'/'cian'/'yandex' в таблице нет вообще, то есть каждый пункт фильтра давал пустую выдачу, и пустой выбор («Все») тоже — он молча подставлял source вкладки. Новый источник появляется в списке сам. Числа с прода (tradein-postgres, 2026-08-06): is_outlier=true у 0 из 93 408 listings (NULL у 0 — только DEFAULT); четыре счётчика = 0 во всех 3244 прогонах с миграции 015; run_type — одно значение на 3244 строки; 53 реальных источника, 2466 прогонов (76%) вне трёх площадок, включая весь Домклик. Миграция 214 идемпотентна; v_data_quality пересоздан тем же DDL минус outliers_flagged (порядок DROP VIEW → DROP COLUMN → CREATE как в 095). --- tradein-mvp/backend/app/api/v1/admin.py | 32 ++- .../backend/app/services/scrape_runs.py | 25 +- .../data/sql/214_drop_dead_run_metrics.sql | 109 +++++++++ .../tests/test_2674_dead_admin_metrics.py | 221 ++++++++++++++++++ .../backend/tests/test_scraper_admin_apis.py | 9 +- .../src/components/scrapers/RunsTable.tsx | 84 ++++--- .../src/scraper_kit/orchestration/runs.py | 6 +- 7 files changed, 440 insertions(+), 46 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql create mode 100644 tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index f536f684..288e71b8 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -2189,11 +2189,17 @@ async def scrape_house_imv_backfill( class UnifiedScrapeRunRow(BaseModel): - """Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче).""" + """Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче). + + #2674: поля run_type больше нет. Вид прогона в БД всегда был дефолтом + 'city_sweep' (3244 из 3244 строк, ни одно место кода его не задавало), и + таблица подписывала им прогоны, которые никаким sweep не были — + proxy_healthcheck, deactivate_stale_*, sber_index_pull. Что именно бежало, + называет `source`. + """ run_id: int source: str - run_type: str | None = None status: str params: dict | None = None counters: dict | None = None @@ -2210,6 +2216,12 @@ class UnifiedScrapeRunsResponse(BaseModel): rows: list[UnifiedScrapeRunRow] +class ScrapeRunSourcesResponse(BaseModel): + """Список source'ов для фильтра истории прогонов — из данных, не из литерала.""" + + sources: list[str] + + class BrowserHealth(BaseModel): reachable: bool browsers: dict[str, bool] = Field(default_factory=dict) @@ -2294,7 +2306,6 @@ def list_scrape_runs_unified( UnifiedScrapeRunRow( run_id=r["run_id"], source=r["source"], - run_type=r.get("run_type"), status=r["status"], params=r.get("params"), counters=r.get("counters"), @@ -2310,6 +2321,21 @@ def list_scrape_runs_unified( ) +@router.get("/scrape/runs/sources", response_model=ScrapeRunSourcesResponse) +def list_scrape_run_sources( + db: Annotated[Session, Depends(get_db)], +) -> ScrapeRunSourcesResponse: + """Источники для фильтра истории прогонов — ровно те, что есть в scrape_runs. + + #2674: фильтр в UI был захардкожен тремя значениями (avito/cian/yandex), а в + таблице 53 разных source и НИ ОДНОЙ строки с таким точным значением — каждый + пункт фильтра давал пустую выдачу, и 76% прогонов (вся площадка Домклик в том + числе) были недоступны для вопроса «что там происходит». Список берётся из + данных: новый source появляется в фильтре сам, без правки кода. + """ + return ScrapeRunSourcesResponse(sources=runs_mod.distinct_sources(db)) + + async def _probe_browser_health() -> BrowserHealth: """GET tradein-browser /health (timeout 5с). reachable=False при ошибке.""" url = f"{settings.browser_http_endpoint.rstrip('/')}/health" diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index 39c91d5a..9090601d 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -201,7 +201,9 @@ def _alert_on_run_id( def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int: """INSERT scrape_runs(source, status='running', params, started_at=NOW()). - run_type DEFAULT 'city_sweep' (из 051 миграции). + Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / …); + отдельной колонки run_type больше нет — она 3244 прогона подряд молчала + дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674). Returns run_id (bigint). """ row = db.execute( @@ -445,7 +447,7 @@ def list_all( db.execute( text( f""" - SELECT id AS run_id, source, run_type, status, params, counters, + SELECT id AS run_id, source, status, params, counters, total_seen, new_count, started_at, finished_at, heartbeat_at, error AS error_text FROM scrape_runs @@ -460,3 +462,22 @@ def list_all( .all() ) return total, [dict(r) for r in rows] + + +def distinct_sources(db: Session) -> list[str]: + """Все значения source, которые РЕАЛЬНО есть в scrape_runs (по алфавиту). + + #2674: фильтр источников в админке был захардкожен тремя площадками + (avito/cian/yandex), а в таблице 53 разных source и ни одной строки с таким + точным значением — все три пункта фильтра давали пустую выдачу, а 76% + прогонов (включая всю площадку Домклик) отфильтровать было нечем. + Список обязан приходить из данных: новый source появляется в фильтре сам, + без правки кода. + + Игнорирует фильтры /scrape/runs — иначе выбор источника вырезал бы из + выпадающего списка все остальные. + """ + rows = db.execute( + text("SELECT DISTINCT source FROM scrape_runs WHERE source IS NOT NULL ORDER BY source") + ).fetchall() + return [str(r.source) for r in rows] diff --git a/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql b/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql new file mode 100644 index 00000000..bfb456f1 --- /dev/null +++ b/tradein-mvp/backend/data/sql/214_drop_dead_run_metrics.sql @@ -0,0 +1,109 @@ +-- 214_drop_dead_run_metrics.sql +-- Purpose (#2674): удалить показатели, которые не могут быть ненулевыми. +-- +-- Один класс находок: колонка есть, админка её показывает, писателя нет ни одного — +-- и ноль читается как «всё чисто», а не как «мы это не считаем». Такой показатель +-- хуже отсутствующего: он создаёт ложную уверенность. Числа с прода 2026-08-06. +-- +-- 1. listings.is_outlier + v_data_quality.outliers_flagged +-- 93 408 строк listings, is_outlier = false у ВСЕХ, NULL у нуля — то есть +-- только DEFAULT из 002, ни одного UPDATE за всю историю. Писателя нет и не +-- задумывалось: «выброс» у эстиматора вычисляется Tukey-фильтром по КОНКРЕТНОЙ +-- подборке аналогов (estimator._filter_outliers) и живёт ровно один запрос — +-- один и тот же лот выброс для одной оценки и нормальный аналог для соседней. +-- Persist-флаг на объявлении такое отношение выразить не может в принципе, +-- поэтому реализовать «пометку» нечем: это остаток отменённой затеи +-- («помечено модели после aggregation», 002_core_tables.sql), а не недоделка. +-- +-- 2. scrape_runs.run_type +-- 3244 прогона, ровно одно значение 'city_sweep' — DEFAULT из 051; ни одно +-- место кода run_type не задаёт. Колонка подписывала «city_sweep» прогоны, +-- которые никаким sweep не были: proxy_healthcheck (1630 шт.), +-- deactivate_stale_*, sber_index_pull, rosreestr_dkp_import. Что именно бежало, +-- честно называет source — второй, вырожденной оси не нужно. +-- +-- 3. scrape_runs.http_requests / http_errors / returning_count / disappeared_count +-- Ноль во всех 3244 прогонах с миграции 015, писателя нет вообще. +-- HTTP-запросы не считает никто ни в одном фетчере — заполнить нечем без +-- сквозной инструментации всех путей. Ошибки и «пропало/вернулось» УЖЕ +-- считает тот, кто их знает, и кладёт в counters jsonb: errors_count у +-- pipeline, deactivated/revived у deactivate_stale_*. Отдельные колонки были +-- бы вторым, иначе определённым счётчиком того же слова. +-- +-- ⚠️ View-зависимость: v_data_quality (последний DDL — 095_dead_schema.sql) читает +-- listings.is_outlier и содержит CTE `SELECT * FROM listings`, что фиксирует +-- column-level зависимость на все колонки. Порядок тот же, что в 095: +-- DROP VIEW → DROP COLUMN → CREATE VIEW (уже без outliers_flagged). +-- +-- Dependencies: 002_core_tables.sql, 015_scrape_runs.sql, 051_scrape_runs_extend.sql, +-- 095_dead_schema.sql (последний DDL v_data_quality). +-- Идемпотентно: DROP VIEW IF EXISTS / DROP COLUMN IF EXISTS / CREATE OR REPLACE VIEW. + +BEGIN; + +-- ── 1. is_outlier: снять зависимый view, дропнуть колонку, пересоздать view ─── + +DROP VIEW IF EXISTS v_data_quality; + +ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS is_outlier; + +-- DDL идентичен 095, минус строка outliers_flagged (см. п.1 шапки). +CREATE OR REPLACE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT * FROM listings WHERE is_active = true +) +SELECT + (SELECT count(*) FROM houses) AS houses_total, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian, + (SELECT count(*) FROM houses h + WHERE EXISTS (SELECT 1 FROM house_sources hs + WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2 + ) sub) AS houses_2plus_sources, + (SELECT count(*) FROM ( + SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3 + ) sub) AS houses_3plus_sources, + (SELECT count(*) FROM active_listings) AS listings_active, + (SELECT count(*) FROM ( + SELECT listing_id FROM listing_sources + WHERE listing_id IN (SELECT id FROM active_listings) + GROUP BY listing_id HAVING count(*) >= 2 + ) sub) AS listings_dedup_2sources, + (SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded, + (SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr, + (SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description, + (SELECT count(*) FROM active_listings l + JOIN houses h ON h.id = l.house_id_fk + WHERE h.year_built IS NOT NULL) * 100.0 + / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago, + NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago, + (SELECT count(*) FROM v_price_divergence) AS price_disagreements_count; + +COMMENT ON VIEW v_data_quality IS + 'KPI snapshot. Refreshed on-demand by /api/v1/admin/data-quality endpoint (Master Plan sec 8.1). ' + '#2674: outliers_flagged убран — is_outlier не писал никто, «выброс» определён только ' + 'внутри одной подборки аналогов (estimator._filter_outliers), не на объявлении.'; + +-- ── 2-3. scrape_runs: вырожденный run_type + четыре счётчика без писателя ───── + +ALTER TABLE IF EXISTS scrape_runs + DROP COLUMN IF EXISTS run_type, + DROP COLUMN IF EXISTS http_requests, + DROP COLUMN IF EXISTS http_errors, + DROP COLUMN IF EXISTS returning_count, + DROP COLUMN IF EXISTS disappeared_count; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py new file mode 100644 index 00000000..72178b9e --- /dev/null +++ b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py @@ -0,0 +1,221 @@ +"""#2674 — показатели админки, которые не могут быть ненулевыми. + +Один класс: колонка есть, поверхность её показывает, писателя нет ни одного — и +ноль читается оператором как «всё чисто», а не как «мы это не считаем». Такой +показатель хуже отсутствующего, поэтому «честно убрать» здесь — такой же +правильный исход, как «реализовать». + +Числа с прода (tradein-postgres, 2026-08-06, до фикса): + 1. listings.is_outlier — 93 408 строк, true у 0, NULL у 0: только DEFAULT из + миграции 002, ни одного UPDATE. v_data_quality.outliers_flagged рапортовал + этот ноль как «выбросов не помечено». + 2. scrape_runs.http_requests / http_errors / returning_count / + disappeared_count — ноль во всех 3244 прогонах с миграции 015 (74 дня у + самого старого), писателя нет вообще. + 3. scrape_runs.run_type — 3244 строки, ровно одно значение 'city_sweep' + (DEFAULT из 051); таблица админки подписывала им proxy_healthcheck (1630 + прогонов), deactivate_stale_*, sber_index_pull. + 4. Фильтр источников знал три значения ('avito'/'cian'/'yandex'), а + `source = :source` — точное сравнение: строк с ТАКИМ source в таблице 0. + То есть каждый пункт фильтра давал пустую выдачу, а пустой выбор («Все») + молча подставлял source вкладки — и тоже ноль. 53 реальных источника, + 2466 прогонов (76%) вне трёх площадок, включая весь Домклик. + +БД и сеть замоканы — реального Postgres не нужно. +""" + +from __future__ import annotations + +import inspect +import os +import re +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.orchestration import runs as kit_runs + +from app.services import scrape_runs as runs_mod + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +_SQL_DIR = _BACKEND_ROOT / "data" / "sql" +_RUNS_TABLE_TSX = ( + _BACKEND_ROOT.parent / "frontend" / "src" / "components" / "scrapers" / "RunsTable.tsx" +) + +# Колонки scrape_runs без единого писателя (см. шапку). Плюс listings.is_outlier. +_DEAD_RUN_COLUMNS = ( + "run_type", + "http_requests", + "http_errors", + "returning_count", + "disappeared_count", +) + + +# ── Фейковая БД: отдаёт ровно те source'ы, что ей задали ────────────────────── + + +class _FakeRow: + def __init__(self, source: str) -> None: + self.source = source + + +class _FakeResult: + def __init__(self, rows: list[_FakeRow]) -> None: + self._rows = rows + + def fetchall(self) -> list[_FakeRow]: + return self._rows + + +class _FakeDB: + """Минимальный Session-стаб: любой execute отдаёт заданный список source'ов.""" + + def __init__(self, sources: list[str]) -> None: + self.sources = sources + self.statements: list[str] = [] + + def execute(self, stmt: Any, params: Any = None) -> _FakeResult: + self.statements.append(str(stmt)) + return _FakeResult([_FakeRow(s) for s in self.sources]) + + +@pytest.fixture +def client_factory(): + from app.api.v1 import admin as admin_module + from app.core.db import get_db + + def _make(db: Any) -> TestClient: + app = FastAPI() + app.include_router(admin_module.router, prefix="/api/v1/admin") + app.dependency_overrides[get_db] = lambda: db + return TestClient(app) + + return _make + + +# ══ 4. Фильтр источников берётся из данных ════════════════════════════════════ + + +def test_sources_endpoint_returns_whatever_the_table_holds(client_factory) -> None: + """Новый источник появляется в фильтре БЕЗ правки кода. + + 'domclick_city_sweep' — площадка, которой не было в захардкоженной тройке + (46 прогонов на проде нельзя было отфильтровать). 'newmarket_2031_sweep' — + заведомо несуществующий источник: если он доезжает до ответа, список + действительно приходит из данных, а не из литерала в коде. + """ + db = _FakeDB(["avito_city_sweep", "domclick_city_sweep", "newmarket_2031_sweep"]) + r = client_factory(db).get("/api/v1/admin/scrape/runs/sources") + + assert r.status_code == 200 + assert r.json()["sources"] == [ + "avito_city_sweep", + "domclick_city_sweep", + "newmarket_2031_sweep", + ] + + +def test_sources_query_ignores_run_filters() -> None: + """SELECT DISTINCT без WHERE по source/status — иначе выбор одного источника + вырезал бы из выпадающего списка все остальные.""" + sql = inspect.getsource(runs_mod.distinct_sources) + assert "DISTINCT source" in sql + assert "WHERE source = " not in sql + assert ":status" not in sql + + +def test_frontend_source_filter_has_no_hardcoded_list() -> None: + """RunsTable.tsx не содержит литерального списка площадок. + + Red на origin/main: там был `["", "avito", "cian", "yandex"] as const` — + три значения, которых в колонке source нет ни в одной из 3244 строк. + """ + tsx = _RUNS_TABLE_TSX.read_text(encoding="utf-8") + hardcoded = re.search(r'"avito"\s*,\s*"cian"\s*,\s*"yandex"', tsx) + assert hardcoded is None, ( + "список источников снова захардкожен в RunsTable.tsx — он обязан приходить " + "из /api/v1/admin/scrape/runs/sources, иначе следующая площадка опять " + "останется невидимой для оператора" + ) + assert "/scrape/runs/sources" in tsx + + +# ══ 3. run_type: вырожденная колонка ушла из API и из выдачи ══════════════════ + + +def test_unified_run_row_has_no_run_type(client_factory) -> None: + """Ответ /scrape/runs не несёт run_type — подписывать 'city_sweep' прогон + proxy_healthcheck честнее вообще нечем, кроме source.""" + from unittest.mock import patch + + row = { + "run_id": 5, + "source": "proxy_healthcheck", + "status": "done", + "params": None, + "counters": {"ok": 3}, + "total_seen": None, + "new_count": None, + "started_at": None, + "finished_at": None, + "heartbeat_at": None, + "error_text": None, + } + with patch("app.services.scrape_runs.list_all", return_value=(1, [row])): + r = client_factory(MagicMock()).get("/api/v1/admin/scrape/runs") + + assert r.status_code == 200 + body_row = r.json()["rows"][0] + assert "run_type" not in body_row + assert body_row["source"] == "proxy_healthcheck" + + +@pytest.mark.parametrize("list_all", [runs_mod.list_all, kit_runs.list_all]) +def test_list_all_selects_no_dropped_columns(list_all) -> None: + """Обе копии list_all (app.services и scraper_kit) перестали читать колонки, + которых после миграции 214 в таблице нет — иначе админка отдаёт 500.""" + sql = inspect.getsource(list_all) + still_there = [c for c in _DEAD_RUN_COLUMNS if re.search(rf"\b{c}\b", sql)] + assert not still_there, f"list_all всё ещё выбирает дропнутые колонки: {still_there}" + + +# ══ 1-3. Схема: колонок больше нет, и v_data_quality не рапортует выбросы ═════ + + +def _migration_214() -> str: + matches = sorted(_SQL_DIR.glob("214_*.sql")) + assert matches, "не найдена миграция 214_* (удаление мёртвых показателей #2674)" + return matches[0].read_text(encoding="utf-8") + + +def test_migration_drops_every_dead_column() -> None: + """Все пять колонок scrape_runs + listings.is_outlier дропаются идемпотентно.""" + sql = _migration_214() + missing = [c for c in _DEAD_RUN_COLUMNS if f"DROP COLUMN IF EXISTS {c}" not in sql] + assert not missing, f"миграция не дропает: {missing}" + assert "DROP COLUMN IF EXISTS is_outlier" in sql + + +def test_latest_v_data_quality_no_longer_reports_outliers() -> None: + """Действующее определение v_data_quality (последняя миграция, которая его + создаёт) не упоминает is_outlier. + + Red на origin/main: там последним был 095_dead_schema.sql со строкой + `(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged` + — показатель, который не мог быть ненулевым, потому что колонку не писал никто. + """ + marker = "CREATE OR REPLACE VIEW v_data_quality" + creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker in p.read_text(encoding="utf-8")) + assert creators, "не найдено ни одной миграции, создающей v_data_quality" + latest = creators[-1].read_text(encoding="utf-8") + body = latest.split("CREATE OR REPLACE VIEW v_data_quality")[-1].split(";")[0] + assert "outliers_flagged" not in body + assert "is_outlier" not in body diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index ab6d29f7..1a2db7e6 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -42,12 +42,15 @@ def client() -> TestClient: def test_unified_runs_returns_rows_and_total(client: TestClient) -> None: - """list_all → total + rows; ISO-форматирование дат + error_text/run_type.""" + """list_all → total + rows; ISO-форматирование дат + error_text. + + run_type из выдачи убран (#2674): вырожденная колонка (одно значение во всех + 3244 прогонах), вид прогона называет source. + """ fake_rows = [ { "run_id": 5, "source": "avito_city_sweep", - "run_type": "city_sweep", "status": "done", "params": {"pages_per_anchor": 3}, "counters": {"lots_fetched": 120}, @@ -68,7 +71,7 @@ def test_unified_runs_returns_rows_and_total(client: TestClient) -> None: row = body["rows"][0] assert row["run_id"] == 5 assert row["source"] == "avito_city_sweep" - assert row["run_type"] == "city_sweep" + assert "run_type" not in row assert row["total_seen"] == 120 assert row["new_count"] == 30 assert row["error_text"] is None diff --git a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx index 3544eb47..a94ef81e 100644 --- a/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx +++ b/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx @@ -15,7 +15,6 @@ import { export interface ScrapeRunFull { run_id: number; source: string; - run_type: string | null; status: string; params: Record | null; counters: Record | null; @@ -32,6 +31,10 @@ interface RunsListResp { rows: ScrapeRunFull[]; } +interface RunSourcesResp { + sources: string[]; +} + // ── Hook ─────────────────────────────────────────────────────────────────── // "skipped" (#2658) — пропущенное расписание (нет кук / уже бежит / нет handler'а); @@ -48,35 +51,32 @@ const RUN_STATUS_ALL = [ ] as const; type RunStatusFilter = (typeof RUN_STATUS_ALL)[number]; -// "" means "all sources"; otherwise a specific source prefix (avito / cian / yandex) -const RUN_SOURCE_FILTERS = ["", "avito", "cian", "yandex"] as const; -type RunSourceFilter = (typeof RUN_SOURCE_FILTERS)[number]; +// #2674: список источников приходит из данных, а не из литерала. Раньше здесь +// стояли три площадки (avito/cian/yandex), а в scrape_runs 53 разных source и НИ +// ОДНОЙ строки с таким точным значением — каждый пункт фильтра давал пустую +// выдачу, а 76% прогонов (вся площадка Домклик в том числе) отфильтровать было +// нечем. Новый источник теперь появляется в списке сам. +function useScrapeRunSources() { + return useQuery({ + queryKey: ["scrape-run-sources"], + // Отдельный ключ (не зависит от фильтров) — иначе список схлопывался бы при + // каждой смене фильтра, пока летит запрос. + queryFn: () => + apiFetch("/api/v1/admin/scrape/runs/sources"), + staleTime: 60_000, + retry: 1, + }); +} -const SOURCE_FILTER_LABELS: Record = { - "": "Все", - avito: "Avito", - cian: "Cian", - yandex: "Yandex", -}; - -function useScraperRuns( - source: ScraperSource, - status: RunStatusFilter, - sourceFilter: RunSourceFilter, - limit = 20, -) { +function useScraperRuns(status: RunStatusFilter, sourceFilter: string, limit = 20) { return useQuery({ - queryKey: ["scrape-runs", source, status, sourceFilter, limit], + queryKey: ["scrape-runs", status, sourceFilter, limit], queryFn: () => { const qs = new URLSearchParams({ limit: String(limit) }); - // When a specific sourceFilter is chosen, ignore the tab-level source - // and pass it verbatim as the ?source= param - if (sourceFilter) { - qs.set("source", sourceFilter); - } else { - // fallback: filter by the current tab provider - qs.set("source", source); - } + // Пусто = «Все», и это буквально все источники. Раньше пустой выбор молча + // подставлял source вкладки ("avito"), которого в данных не существует, — + // подпись «Все» показывала ноль прогонов (#2674). + if (sourceFilter) qs.set("source", sourceFilter); if (status) qs.set("status", status); return apiFetch( `/api/v1/admin/scrape/runs?${qs.toString()}`, @@ -186,16 +186,19 @@ interface RunsTableProps { export function RunsTable({ source }: RunsTableProps) { const [statusFilter, setStatusFilter] = useState(""); - const [sourceFilter, setSourceFilter] = useState(""); + const [sourceFilter, setSourceFilter] = useState(""); const qc = useQueryClient(); - const runsQ = useScraperRuns(source, statusFilter, sourceFilter); + const runsQ = useScraperRuns(statusFilter, sourceFilter); + const sourcesQ = useScrapeRunSources(); + // mark_cancelled(run_id) source-агностичен — отменяется тот прогон, что выбран + // в таблице, независимо от вкладки, с которой нажали. const cancelMut = useCancelCitySweep(source); function handleCancel(runId: number) { cancelMut.mutate(runId, { onSuccess: () => { void qc.invalidateQueries({ - queryKey: ["scrape-runs", source, statusFilter, sourceFilter], + queryKey: ["scrape-runs", statusFilter, sourceFilter], }); }, }); @@ -205,7 +208,7 @@ export function RunsTable({ source }: RunsTableProps) {

История прогонов

- Последние 20 прогонов. Автообновление каждые 8 сек. + Последние 20 прогонов по всем источникам. Автообновление каждые 8 сек.

{/* Source filter */} @@ -219,7 +222,7 @@ export function RunsTable({ source }: RunsTableProps) { + {sourcesQ.isError && ( + + список источников не загрузился — фильтр показывает только «Все» + + )} {/* Status filter */} @@ -301,7 +310,10 @@ export function RunsTable({ source }: RunsTableProps) { # - Тип + {/* #2674: было «Тип» = run_type, а он у всех 3244 прогонов один + ('city_sweep' по DEFAULT) и подписывал так даже + proxy_healthcheck. Что бежало — говорит source. */} + Источник Статус Старт Финиш @@ -323,7 +335,7 @@ export function RunsTable({ source }: RunsTableProps) { color: "var(--fg-secondary, #5b6066)", }} > - {r.run_type ?? "sweep"} + {r.source} diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index 233bfc46..fd5fa55f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -216,7 +216,9 @@ def _alert_on_run_id( def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int: """INSERT scrape_runs(source, status='running', params, started_at=NOW()). - run_type DEFAULT 'city_sweep' (из 051 миграции). + Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / …); + отдельной колонки run_type больше нет — она 3244 прогона подряд молчала + дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674). Returns run_id (bigint). """ row = db.execute( @@ -549,7 +551,7 @@ def list_all( db.execute( text( f""" - SELECT id AS run_id, source, run_type, status, params, counters, + SELECT id AS run_id, source, status, params, counters, total_seen, new_count, started_at, finished_at, heartbeat_at, error AS error_text FROM scrape_runs