Merge pull request 'chore(tradein): полное выключение источника n1 — миграция 165 + вычистка backend/ops/frontend (#2204)' (#2276) from chore/tradein-remove-n1-source into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Successful in 37s
Deploy Trade-In / test (push) Successful in 1m49s
Deploy Trade-In / build-frontend (push) Successful in 2m56s
Deploy Trade-In / build-backend (push) Successful in 1m13s
Deploy Trade-In / deploy (push) Successful in 2m53s

This commit is contained in:
lekss361 2026-07-03 07:33:28 +00:00
commit fc5c4b1e40
22 changed files with 219 additions and 62 deletions

View file

@ -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",
}

View file

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

View file

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

View file

@ -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": "Этажи",
}

View file

@ -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 по умолчанию).

View file

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

View file

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

View file

@ -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."

View file

@ -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}"

View file

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

View file

@ -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())

View file

@ -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 (чанками)"

View file

@ -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() {
<footer className="page-foot">
<div>
Мера · MVP ·{" "}
<span className="mono">data: Avito + Cian + Yandex + N1 + Росреестр</span>
<span className="mono">data: Avito + Cian + Yandex + Росреестр</span>
</div>
<div style={{ display: "flex", gap: 18 }}>
<a href="#">Документация</a>

View file

@ -3,8 +3,8 @@
/**
* SourcesProgress карточка «Шаг B · Агрегация».
*
* Ростер источников берётся из source-registry (живые листинговые + Росреестр),
* а НЕ из хардкода #2211. n1 деактивирован (#2204) в ростере его нет.
* Ростер источников берётся из source-registry (листинговые + Росреестр),
* а НЕ из хардкода #2211. Источник n1 полностью выключен (#2204) в реестре его нет.
* Статусы выводятся из реального `estimate.sources_used` / `countBySource` /
* `actual_deals`; источник без вклада честно показывает «нет данных».
*/
@ -39,7 +39,7 @@ export function SourcesProgress({ estimate, isPending }: Props) {
}
}
// Живые листинговые источники — из реестра (n1 исключён как деактивированный).
// Листинговые источники — из реестра.
const listingRows: SourceRow[] = LIVE_LISTING_SOURCES.map((s) => ({
key: s.id,
label: s.label,

View file

@ -2,7 +2,7 @@
/**
* StreetDealsCard «По вашей улице» ДКП-сделки Росреестра по улице target адреса
* с привязкой к историческим listings (Avito / Cian / Yandex / N1).
* с привязкой к историческим listings (Avito / Cian / Yandex / ДомКлик).
*
* Питается от GET /api/v1/trade-in/sales-vs-listings (PR L / #564 Phase 2).
* Endpoint = superset предыдущего /street-deals: возвращает все pairs (LEFT JOIN

View file

@ -53,13 +53,14 @@ export function houseTypeLabel(t: string | null | undefined): string {
return HOUSE_TYPE_LABELS[t] ?? t;
}
// Источник n1 полностью выключен (#2204) → в справочнике его нет. Исторические
// строки из БД с source='n1' безопасно проходят через fallback `?? s` (сырой id).
const SOURCE_LABELS: Record<string, string> = {
cian: "Циан",
avito: "Avito",
yandex: "Я.Недв",
domklik: "ДомКлик",
rosreestr: "Росреестр",
n1: "N1.ru",
};
export function sourceLabel(s: string | null | undefined): string {

View file

@ -1115,7 +1115,6 @@
.source-chip .src-dot.rosreestr { background: var(--fg); }
.source-chip .src-dot.etagi { background: var(--viz-2); }
.source-chip .src-dot.restate { background: var(--viz-2); }
.source-chip .src-dot.n1 { background: var(--muted-2); }
.source-chip .src-dot.domrf { background: var(--accent-2); }
/* ============================================================
@ -2037,7 +2036,7 @@
.listing-link:hover:not(.is-static) .listing-price { color: var(--accent); }
.listing-link.is-static { cursor: default; }
/* Source badge (AVITO / CIAN / YANDEX / N1 / DOMKLIK) — per ui-tokens accent-soft + accent text uppercase 11px */
/* Source badge (AVITO / CIAN / YANDEX / DOMKLIK) — per ui-tokens accent-soft + accent text uppercase 11px */
.listing-source-badge {
display: inline-block;
padding: 1px 6px;

View file

@ -147,7 +147,6 @@ export const sources: SourceCard[] = [
{ name: "АВИТО ОЦЕНКА", count: "—", label: "нет данных", active: false },
{ name: "ДОМКЛИК ПРАЙС", count: "—", label: "нет данных", active: false },
{ name: "RESTATE", count: "—", label: "нет данных", active: false },
{ name: "N1.RU", count: "—", label: "нет данных", active: false },
];
// ---- 03 SUMMARY -----------------------------------------------------------

View file

@ -481,8 +481,9 @@ const SOURCE_SLOTS: ReadonlyArray<{ name: string; keys: string[] }> = [
{ name: "ДОМКЛИК", keys: ["domklik", "domclick"] },
];
// #2081 round2: N1.RU + RESTATE dropped — мёртвые (всегда «нет данных», не
// учитываем); Домклик оставлен (живой источник, реально даёт лоты). TOTAL_SOURCES
// = единый счётчик «N / M», чтобы плитки, счётчик и текст не расходились.
// учитываем). N1 с #2204 выключен полностью (backend + расписания). Домклик оставлен
// (живой источник, реально даёт лоты). TOTAL_SOURCES = единый счётчик «N / M»,
// чтобы плитки, счётчик и текст не расходились.
const TOTAL_SOURCES = SOURCE_SLOTS.length;
function buildSources(e: AggregatedEstimate): SourceCard[] {
@ -1116,8 +1117,7 @@ const SOURCE_LABEL: Record<string, string> = {
domklik: "Домклик",
domclick: "Домклик",
restate: "Restate",
n1: "N1.RU",
n1ru: "N1.RU",
// n1 полностью выключен (#2204) → истор. строки source='n1' идут через fallback `?? s`.
};
function sourceLabel(s: string | null | undefined): string {

View file

@ -6,8 +6,8 @@
* участвовали в оценке». Любой ростер/чип/лейбл/знаменатель в UI выводится ЛИБО
* из фактического `estimate.sources_used` (что реально дало данные), ЛИБО из
* `SOURCES` ниже (справочник известных источников для лейблов/цветов).
* 2. Знаменатели вида «N/7», «N/5» НЕ хардкодятся числом. Если нужен знаменатель
* «живых» источников берётся из `LIVE_SOURCE_COUNT` (derived), а не из литерала.
* 2. Знаменатели вида «N/6», «N/5» НЕ хардкодятся числом. Если нужен знаменатель
* источников берётся из `LIVE_SOURCE_COUNT` (derived), а не из литерала.
* 3. Пустой `sources_used` честный текст «источники не зафиксированы», НЕ подстановка
* фейковых чипов avito/cian/yandex/rosreestr.
*
@ -15,8 +15,10 @@
* sources_used = {листинговые источники, реально давшие аналоги}
* {'rosreestr' сделки ДКП}
* {оценочные: 'avito_imv' | 'yandex_valuation' | 'cian_valuation'}.
* n1 деактивирован (#2204) присутствует в справочнике для лейблинга исторических
* данных, но помечен live:false и исключён из «живого» ростера.
* n1 полностью выключен (#2204): удалён из справочника. Исторические строки из БД с
* `source='n1'` могут прийти в placement history они безопасно проходят через
* fallback `sourceLabel`/`sourceDot` (лейбл = сырой id, точка = нейтральный `dom`),
* код не падает на неизвестном источнике.
*/
export type SourceKind = "listing" | "deals" | "valuation";
@ -30,42 +32,39 @@ export interface SourceMeta {
readonly dot: string;
/** Тип вклада: объявления / сделки / внешняя оценка. */
readonly kind: SourceKind;
/** true — источник участвует в текущем сборе; false — деактивирован (n1). */
readonly live: boolean;
}
/**
* Справочник известных источников. `as const satisfies` фиксирует форму на уровне
* типов: массив readonly, добавить «фейковый» источник или сломать поле не даст TS.
* Справочник известных (активных) источников. `as const satisfies` фиксирует форму на
* уровне типов: массив readonly, добавить «фейковый» источник или сломать поле не даст TS.
* Cоответствие CSS-классам `.src-dot.*` (trade-in.css) проверено вручную для
* rosreestr валиден класс `rosreestr` (не мифический `ros`, у которого нет стиля).
*/
export const SOURCES = [
{ id: "avito", label: "Avito", dot: "avito", kind: "listing", live: true },
{ id: "cian", label: "Циан", dot: "cian", kind: "listing", live: true },
{ id: "yandex", label: "Я.Недвижимость", dot: "yandex", kind: "listing", live: true },
{ id: "domklik", label: "ДомКлик", dot: "dom", kind: "listing", live: true },
{ id: "n1", label: "N1.ru", dot: "n1", kind: "listing", live: false },
{ id: "rosreestr", label: "Росреестр", dot: "rosreestr", kind: "deals", live: true },
{ id: "etagi", label: "Этажи", dot: "etagi", kind: "deals", live: true },
{ id: "avito_imv", label: "Avito Оценка", dot: "avito", kind: "valuation", live: true },
{ id: "yandex_valuation", label: "Я.Оценка", dot: "yandex", kind: "valuation", live: true },
{ id: "cian_valuation", label: "Циан Оценка", dot: "cian", kind: "valuation", live: true },
{ id: "avito", label: "Avito", dot: "avito", kind: "listing" },
{ id: "cian", label: "Циан", dot: "cian", kind: "listing" },
{ id: "yandex", label: "Я.Недвижимость", dot: "yandex", kind: "listing" },
{ id: "domklik", label: "ДомКлик", dot: "dom", kind: "listing" },
{ id: "rosreestr", label: "Росреестр", dot: "rosreestr", kind: "deals" },
{ id: "etagi", label: "Этажи", dot: "etagi", kind: "deals" },
{ id: "avito_imv", label: "Avito Оценка", dot: "avito", kind: "valuation" },
{ id: "yandex_valuation", label: "Я.Оценка", dot: "yandex", kind: "valuation" },
{ id: "cian_valuation", label: "Циан Оценка", dot: "cian", kind: "valuation" },
] as const satisfies readonly SourceMeta[];
const BY_ID: ReadonlyMap<string, SourceMeta> = new Map(SOURCES.map((s) => [s.id, s]));
/** Живые листинговые источники (объявления) — для ростера прогресса сбора. */
/** Листинговые источники (объявления) — для ростера прогресса сбора. */
export const LIVE_LISTING_SOURCES: readonly SourceMeta[] = SOURCES.filter(
(s) => s.kind === "listing" && s.live,
(s) => s.kind === "listing",
);
/**
* Знаменатель «живых» источников: живые листинговые + сделки (rosreestr).
* Derived из реестра заменяет хардкод «/7» / «/5» в компонентах.
* Знаменатель источников: листинговые + сделки (rosreestr / etagi).
* Derived из реестра заменяет хардкод «/6» / «/5» в компонентах.
*/
export const LIVE_SOURCE_COUNT: number = SOURCES.filter(
(s) => s.live && (s.kind === "listing" || s.kind === "deals"),
(s) => s.kind === "listing" || s.kind === "deals",
).length;
/** Известен ли источник справочнику. */

View file

@ -362,7 +362,7 @@ export interface SalesListingPair {
deal_address: string;
listing_id: number | null;
listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'n1' / 'domklik'
listing_source: string | null; // 'avito' / 'cian' / 'yandex' / 'domklik' (истор. могут встречаться выключенные источники)
listing_source_url: string | null;
listing_date: string | null; // ISO date
listing_price_rub: number | null;

View file

@ -29,7 +29,6 @@ log = logging.getLogger(__name__)
_SOURCE_TO_PATH: dict[str, str] = {
"avito_city_sweep": "/api/v1/admin/scrape/avito-city-sweep",
"yandex_city_sweep": "/api/v1/admin/scrape/yandex-city-sweep",
"n1_city_sweep": "/api/v1/admin/scrape/n1",
"cian_history_backfill": "/api/v1/admin/scrape/cian-backfill-history",
"yandex_address_backfill": "/api/v1/admin/scrape/yandex-address-backfill",
}