diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index d4c8499c..a90df7cc 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -411,8 +411,6 @@ class Settings(BaseSettings): "cian.ru", "ekb.cian.ru", "realty.yandex.ru", - "ekaterinburg.n1.ru", - "n1.ru", "domclick.ru", "www.domclick.ru", } diff --git a/tradein-mvp/backend/app/schemas/search.py b/tradein-mvp/backend/app/schemas/search.py index b1ecad9a..62d9e5f2 100644 --- a/tradein-mvp/backend/app/schemas/search.py +++ b/tradein-mvp/backend/app/schemas/search.py @@ -41,7 +41,7 @@ class SearchParams(BaseModel): # --- Quality / cross-source --- has_kadastr: bool = False - sources: list[Literal["avito", "cian", "yandex_realty", "n1"]] | None = None + sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None multi_source_only: bool = False require_avito: bool = False require_cian: bool = False diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 86237492..7e97a5f6 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -506,7 +506,7 @@ class SalesListingPair(BaseModel): deal_address: str | None = None listing_id: int | None = None - listing_source: str | None = None # 'avito' / 'cian' / 'yandex' / 'n1' / 'domklik' + listing_source: str | None = None # 'avito' / 'cian' / 'yandex' / 'domklik' (+ inactive 'n1') listing_source_url: str | None = None listing_date: date | None = None listing_price_rub: int | None = None diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 2b8f6c30..a5580bbc 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -59,8 +59,6 @@ _ALLOWED_URL_DOMAINS: frozenset[str] = frozenset( "www.cian.ru", "cian.ru", "realty.yandex.ru", - "n1.ru", - "ekaterinburg.n1.ru", } ) @@ -70,7 +68,6 @@ _ALLOWED_PHOTO_CDN: frozenset[str] = frozenset( "images.avito.st", "cdn-p.cian.site", "avatars.mds.yandex.net", - "n1ru.cdn-cw.com", } ) @@ -176,12 +173,14 @@ def _make_safe_url_fetcher(): # type: ignore[no-untyped-def] # ── Source pseudo-logos (текстовые pill-badges как у Брусники с логотипами) ── +# Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится +# через безопасный fallback в _source_pill: серый фон + source.title() — код на +# исторических строках НЕ падает. _SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = { "avito": ("#00aaff", "#fff"), # Avito brand blue (упрощённо) "cian": ("#0468ff", "#fff"), # Циан фирменный синий "domklik": ("#1ab248", "#fff"), # ДомКлик зелёный Сбер "yandex": ("#fc3f1d", "#fff"), # Я.Недвижимость красный - "n1": ("#ff6b00", "#fff"), # N1 оранжевый "rosreestr": ("#003d82", "#fff"), # Росреестр тёмно-синий "etazhi": ("#e30613", "#fff"), # Этажи красный } @@ -191,7 +190,6 @@ _SOURCE_DISPLAY_NAMES: dict[str, str] = { "cian": "Циан", "domklik": "Домклик · Сбер", "yandex": "Я.Недвижимость", - "n1": "N1", "rosreestr": "Росреестр", "etazhi": "Этажи", } diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py index 3fdc40af..6d8baf1b 100644 --- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py +++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py @@ -100,7 +100,7 @@ def deactivate_stale_listings( Параметры: listing_source: значение поля source в таблице listings ('avito', 'yandex', - 'cian', 'domklik', 'n1'). + 'cian', 'domklik'). ttl_days: количество дней TTL; объявления старше этого порога деактивируются. segments: если задан -- деактивировать только объявления с указанными listing_segment значениями. None -> все сегменты (поведение avito по умолчанию). diff --git a/tradein-mvp/backend/data/sql/165_remove_n1_source.sql b/tradein-mvp/backend/data/sql/165_remove_n1_source.sql new file mode 100644 index 00000000..23a1fc74 --- /dev/null +++ b/tradein-mvp/backend/data/sql/165_remove_n1_source.sql @@ -0,0 +1,60 @@ +-- 165_remove_n1_source.sql +-- +-- ПРОДУКТОВОЕ РЕШЕНИЕ (user, 2026-07-03, #2204): +-- Источник n1 ВЫКЛЮЧАЕМ И ВЫЧИЩАЕМ из всех продуктовых поверхностей trade-in +-- («удалить отовсюду, чтобы не появлялся»). Кода скрейпера n1 давно нет +-- (см. 114 — портал переехал в cian ещё 2026-06); 381 активная строка +-- listings source='n1' деактивируется TTL-джобой deactivate_stale_n1 +-- (заведена 160) сегодня в 07:00 UTC. Эта миграция закрепляет выключение +-- декларативно и убирает n1 из scheduler/settings, чтобы источник не мог +-- ожить и не считался в active-пуле. +-- +-- ЧТО ДЕЛАЕТ: +-- 1. UPDATE listings — страховка: если TTL-джоба ещё не отработала на момент +-- деплоя, добиваем оставшиеся is_active строки source='n1'. Строки НЕ +-- удаляем — остаются в БД с is_active=false как история (аналитика/аудит). +-- 2. DELETE из scrape_schedules — убираем обе n1-строки расписаний: +-- - 'n1_city_sweep' (сеяна 084, выключена 114) — активный свип; +-- - 'deactivate_stale_n1'(сеяна 160) — TTL-деактивация: после того как +-- инвентарь деактивирован, ежедневный прогон больше не нужен. +-- Row'ы n1 нужны только историческим run'ам scrape_runs (audit сохранён +-- там); сами scrape_schedules — конфиг, не история, чистить безопасно. +-- 3. DELETE из scraper_settings — убираем per-source knob 'n1' (сеян 054): +-- anti-ban delay мёртвого источника не нужен. +-- +-- ИДЕМПОТЕНТНОСТЬ: +-- - UPDATE ограничен `AND is_active` -> повторный прогон трогает 0 строк. +-- - DELETE ... WHERE source IN (...) -> повторный прогон удаляет 0 строк. +-- Безопасно применять многократно. +-- +-- НЕДЕСТРУКТИВНОСТЬ К ДАННЫМ: +-- Никакого `DELETE FROM listings` — исторические объявления source='n1' +-- сохраняются (только is_active=false). Удаляются лишь конфиг-строки +-- расписаний/настроек скрейпера. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql, 053/054 scraper_settings, 160 (seed). + +BEGIN; + +-- (1) Страховка-деактивация оставшихся активных n1-объявлений (обычно no-op: +-- TTL-джоба deactivate_stale_n1 отрабатывает раньше). Строки НЕ удаляем. +UPDATE listings + SET is_active = false + WHERE source = 'n1' + AND is_active; + +-- (2) Убрать n1-расписания из scheduler-конфига (свип + TTL-деактивация). +DELETE FROM scrape_schedules + WHERE source IN ('n1_city_sweep', 'deactivate_stale_n1'); + +-- (3) Убрать per-source anti-ban knob мёртвого источника. +DELETE FROM scraper_settings + WHERE source = 'n1'; + +-- Освежить справочный комментарий таблицы (n1 больше не источник свипа). +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config: avito/yandex/cian city sweep, rosreestr import, ' + 'listing_source_snapshot, deactivate_stale_* family. Источник n1 удалён ' + '(#2204, 2026-07-03 — портал мёртв, переехал в cian).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 4e9db911..3f5f10f2 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -169,3 +169,4 @@ 162_seed_deals_freshness_monitor.sql 163_disable_deactivate_stale_domklik.sql 164_yandex_url_canonicalize_active_dups.sql +165_remove_n1_source.sql diff --git a/tradein-mvp/backend/scripts/backfill_listing_sources.py b/tradein-mvp/backend/scripts/backfill_listing_sources.py index 7f2477ff..ad0b4283 100644 --- a/tradein-mvp/backend/scripts/backfill_listing_sources.py +++ b/tradein-mvp/backend/scripts/backfill_listing_sources.py @@ -450,8 +450,8 @@ def run_backfill( and gives a checkpoint every commit. limit: stop after processing this many total rows (--limit). None = run to exhaustion. - source: filter to one ext_source ('avito'/'cian'/'yandex'/'n1'). - None = all sources. + source: filter to one ext_source ('avito'/'cian'/'yandex'). + None = all sources (historical 'n1' rows still processed in a full run). dry_run: skip all writes, just count. """ stats = Stats() @@ -569,7 +569,7 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: ) p.add_argument( "--source", - choices=("avito", "cian", "yandex", "n1"), + choices=("avito", "cian", "yandex"), default=None, help=( "Restrict to one ext_source for staged rollout. Default: all sources mixed in id order." diff --git a/tradein-mvp/backend/tests/test_admin_ssrf.py b/tradein-mvp/backend/tests/test_admin_ssrf.py index 4de4089c..d40a6b0e 100644 --- a/tradein-mvp/backend/tests/test_admin_ssrf.py +++ b/tradein-mvp/backend/tests/test_admin_ssrf.py @@ -66,8 +66,11 @@ def test_absolute_yandex_realty_allowed() -> None: _assert_allowed_url("https://realty.yandex.ru/ekaterinburg/kupit/kvartira/123/") -def test_absolute_n1_allowed() -> None: - _assert_allowed_url("https://ekaterinburg.n1.ru/kupit/kvartiry/vtorichka/") +def test_absolute_n1_blocked() -> None: + """n1 удалён из allowlist (#2204, источник мёртв) → абсолютный n1-URL блокируется.""" + with pytest.raises(HTTPException) as exc_info: + _assert_allowed_url("https://ekaterinburg.n1.ru/kupit/kvartiry/vtorichka/") + assert exc_info.value.status_code == 400 # --------------------------------------------------------------------------- @@ -100,7 +103,13 @@ def test_allowlist_contains_required_scraper_hosts() -> None: "ekb.cian.ru", "www.cian.ru", "realty.yandex.ru", - "ekaterinburg.n1.ru", } missing = required - settings.scrape_allowed_hosts assert not missing, f"Missing hosts in scrape_allowed_hosts: {missing}" + + +def test_allowlist_excludes_removed_n1_hosts() -> None: + """n1 полностью удалён из allowlist (#2204) — ни один n1-хост не должен остаться.""" + n1_hosts = {"n1.ru", "ekaterinburg.n1.ru"} + leftover = n1_hosts & settings.scrape_allowed_hosts + assert not leftover, f"n1 hosts still in scrape_allowed_hosts: {leftover}" diff --git a/tradein-mvp/backend/tests/test_cian_city_sweep.py b/tradein-mvp/backend/tests/test_cian_city_sweep.py index a82dcacd..a2e542e2 100644 --- a/tradein-mvp/backend/tests/test_cian_city_sweep.py +++ b/tradein-mvp/backend/tests/test_cian_city_sweep.py @@ -1,6 +1,6 @@ """Offline unit tests for run_cian_city_sweep (#860). -Pure & fast: NO live network, NO DB. Mirrors test_n1_city_sweep / test_city_sweep. +Pure & fast: NO live network, NO DB. Mirrors test_city_sweep. Asserts orchestration semantics only. Coverage: diff --git a/tradein-mvp/backend/tests/test_migration_165_remove_n1_source.py b/tradein-mvp/backend/tests/test_migration_165_remove_n1_source.py new file mode 100644 index 00000000..e2101408 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_165_remove_n1_source.py @@ -0,0 +1,95 @@ +"""Static guards for migration 165 (issue #2204 — полное выключение источника n1). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон +требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ +идемпотентность и НЕдеструктивность к самим listings-строкам по построению: + + 1. Транзакционность (BEGIN/COMMIT) — атомарный апдейт. + 2. НЕдеструктивность к данным: строки listings source='n1' НЕ удаляются, только + деактивируются (is_active=false) — исторические объявления остаются в БД. + Никакого `DELETE FROM listings` в коде. + 3. Идемпотентность по построению: + - страховочный UPDATE ограничен `AND is_active` -> повторный прогон no-op; + - `DELETE ... WHERE source IN (...)` / `WHERE source = 'n1'` -> повторный + прогон удаляет 0 строк. + 4. Скоуп чистки конфига: обе n1-строки расписаний (n1_city_sweep + + deactivate_stale_n1) и per-source knob scraper_settings 'n1'. + +Комментарий-заголовок в прозе объясняет намерение (в т.ч. фразу «Никакого +DELETE FROM listings»), поэтому destructive-проверки смотрят на ИСПОЛНЯЕМЫЙ SQL +(`--`-комменты вырезаны), а не на прозу. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_165 = _SQL_DIR / "165_remove_n1_source.sql" + + +def _sql() -> str: + return _MIGRATION_165.read_text(encoding="utf-8") + + +def _executable_sql() -> str: + """SQL без комментариев — только исполняемые строки (`--`-комменты вырезаны). + + В этой миграции `--` не встречается внутри строковых литералов, поэтому + split по первому `--` безопасно отделяет код от построчного комментария. + """ + lines = [] + for raw in _sql().splitlines(): + code = raw.split("--", 1)[0] + if code.strip(): + lines.append(code) + return "\n".join(lines) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +def test_migration_165_exists() -> None: + assert _MIGRATION_165.exists(), f"missing migration: {_MIGRATION_165}" + + +def test_migration_165_is_transactional() -> None: + sql = _sql() + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_165_does_not_delete_listings() -> None: + """Строки listings source='n1' НЕ удаляются — только деактивируются.""" + flat = _flat(_executable_sql()) + assert "delete from listings" not in flat + # Деактивация вместо удаления. + assert "update listings" in flat + assert "set is_active = false" in flat + + +def test_migration_165_listings_update_idempotent_and_scoped() -> None: + """UPDATE ограничен source='n1' AND is_active -> повторный прогон no-op.""" + flat = _flat(_executable_sql()) + assert "where source = 'n1'" in flat + assert "and is_active" in flat + + +def test_migration_165_deletes_both_n1_schedules() -> None: + flat = _flat(_executable_sql()) + assert "delete from scrape_schedules" in flat + assert "'n1_city_sweep'" in flat + assert "'deactivate_stale_n1'" in flat + + +def test_migration_165_deletes_n1_scraper_settings() -> None: + flat = _flat(_executable_sql()) + assert "delete from scraper_settings" in flat + assert "where source = 'n1'" in flat + + +def test_migration_165_no_psycopg_trap() -> None: + """Никаких :param::type — psycopg v3 требует CAST(... AS type).""" + assert not re.search(r":\w+::", _sql()) diff --git a/tradein-mvp/deploy/cron-scrape.sh b/tradein-mvp/deploy/cron-scrape.sh index daf6d524..9ba7c79d 100755 --- a/tradein-mvp/deploy/cron-scrape.sh +++ b/tradein-mvp/deploy/cron-scrape.sh @@ -4,7 +4,7 @@ # Запрос к backend идёт через `docker exec tradein-backend curl localhost:8000` # — не зависит от Caddy и не выставляет admin/scrape наружу. # -# Usage: ./cron-scrape.sh [all|avito|cian|yandex|n1] [deep|fast] +# Usage: ./cron-scrape.sh [all|avito|cian|yandex] [deep|fast] # # Crontab пример: # 0 */3 * * * /opt/gendesign/tradein-mvp/deploy/cron-scrape.sh all deep @@ -48,13 +48,12 @@ build_payload() { yandex) sources_json='["yandex"]' ;; avito) sources_json='["avito"]' ;; cian) sources_json='["cian"]' ;; - n1) sources_json='["n1"]' ;; - all|*) sources_json='["avito","cian","yandex","n1"]' ;; + all|*) sources_json='["avito","cian","yandex"]' ;; esac if [[ "$mode" == "deep" ]]; then - echo "{\"lat\":$lat,\"lon\":$lon,\"radius_m\":1500,\"sources\":$sources_json,\"multi_room_yandex\":true,\"deep_yandex\":true,\"multi_room_cian\":true,\"multi_room_n1\":true}" + echo "{\"lat\":$lat,\"lon\":$lon,\"radius_m\":1500,\"sources\":$sources_json,\"multi_room_yandex\":true,\"deep_yandex\":true,\"multi_room_cian\":true}" else - echo "{\"lat\":$lat,\"lon\":$lon,\"radius_m\":1500,\"sources\":$sources_json,\"multi_room_yandex\":true,\"multi_room_cian\":true,\"multi_room_n1\":true}" + echo "{\"lat\":$lat,\"lon\":$lon,\"radius_m\":1500,\"sources\":$sources_json,\"multi_room_yandex\":true,\"multi_room_cian\":true}" fi } @@ -74,7 +73,7 @@ for anchor in "${ANCHORS[@]}"; do sleep 5 done -# Геокодинг свежих лотов без координат (Cian/N1 отдают только адрес). +# Геокодинг свежих лотов без координат (Cian отдаёт только адрес). # Чанками по 100 (~200с/чанк, под таймаутом), пока remaining > 0. # Макс 15 чанков = 1500 адресов. geocode_tried_at не даёт зациклиться. log "→ geocode-missing listings (чанками)" diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index db49b22d..125466c3 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -39,7 +39,7 @@ import { useBrand, useActiveBrandSlug } from "@/lib/useBrand"; import { LIVE_LISTING_SOURCES } from "@/lib/source-registry"; // #2211 находка 7: список источников объявлений выводится из source-registry -// (n1 деактивирован #2204 → его тут больше нет), а не хардкодится «4 источника (…N1)». +// (источник n1 полностью выключен #2204 → его тут больше нет), а не хардкодится числом. const LISTING_SOURCE_NAMES = LIVE_LISTING_SOURCES.map((s) => s.label).join(", "); function useEstimateId() { @@ -348,7 +348,7 @@ export default function TradeInPage() {