fix(scrapers/backend): socks5 health-probe (httpx[socks]) + status Literal whitelist + #1820 ext_id=0 dedup-гард
All checks were successful
CI / changes (pull_request) Successful in 7s
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

- pyproject.toml: httpx[socks]>=0.27.0 — тянет socksio, socks5:// прокси в _probe_current_ip работает прозрачно
- admin.py list_scrape_runs_unified: status Literal["done","running","banned","zombie","failed","cancelled"] вместо str|None — невалидные значения → 422
- avito_houses.py save_house_catalog_enrichment: гард ext_id=0 + нет адреса → skip without DB touch; два id-less novostroyka URL не схлопываются в bogus avito:0 в house_sources
- тесты: test_unified_runs_invalid_status_422 + test_unified_runs_valid_statuses_200; test_save_house_catalog_enrichment_skips_zero_ext_id_no_address + *_with_address_persists
This commit is contained in:
bot-backend 2026-06-20 10:17:37 +03:00
parent 7639dfe5fd
commit 8361961a44
5 changed files with 100 additions and 4 deletions

View file

@ -1962,7 +1962,9 @@ def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | Non
def list_scrape_runs_unified(
db: Annotated[Session, Depends(get_db)],
source: Annotated[str | None, Query()] = None,
status: Annotated[str | None, Query()] = None,
status: Annotated[
Literal["done", "running", "banned", "zombie", "failed", "cancelled"] | None, Query()
] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> UnifiedScrapeRunsResponse:

View file

@ -1252,7 +1252,24 @@ def save_house_catalog_enrichment(
Возвращает:
{'house_id': N, 'reviews': N, 'sellers': N, 'listings_linked': N, 'placement_history': N}
"""
# 1. Upsert дом с правильным URL из enrichment
# 1. Upsert дом с правильным URL из enrichment.
# Гард #1820: id-less graceful-skip новостройки (ext_id=0, нет адреса/координат) —
# НЕ персистим canonical house, иначе все безымянные URL склеиваются в один bogus
# avito:0 через source_exact-tier и плодят мусорную house_sources("avito","0").
if not e.house.ext_id and not (e.house.short_address or e.house.full_address):
logger.warning(
"save_house_catalog_enrichment: пропуск id-less новостройки без адреса"
" (ext_id=%s url=%s) — не создаём bogus avito:0 в house_sources",
e.house.ext_id,
e.house_url,
)
return {
"house_id": 0,
"reviews": 0,
"sellers": 0,
"listings_linked": 0,
"placement_history": 0,
}
house_id = _upsert_house_with_url(db, e.house, e.house_url)
# 2. Сохранить отзывы

View file

@ -14,7 +14,7 @@ dependencies = [
"Pillow>=10.3.0", # photo sanitization re-encode (#6 audit fix)
"weasyprint>=62.0",
"jinja2>=3.1.0",
"httpx>=0.27.0", # для geocoder + scrapers
"httpx[socks]>=0.27.0", # для geocoder + scrapers; [socks] тянет socksio для socks5://
"tenacity>=9.0.0", # retry с exp backoff
"selectolax>=0.3.0", # быстрый HTML парсинг для scrapers
"segno>=1.6.0", # QR-код для PDF shareable URL

View file

@ -6,6 +6,7 @@
2. Enrichment-поля (rating_score, developer_name) реально записываются в UPDATE.
3. _persist_house не вызывает db.commit().
4. upsert_house и _upsert_house_with_url делегируют в _persist_house.
5. #1820: два id-less URL (ext_id=0, без адреса) НЕ создают house_sources(avito,"0").
Использует тот же паттерн mock-DB что в test_matching.py и test_same_building_match.py.
"""
@ -13,14 +14,16 @@
import json
import os
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from unittest.mock import MagicMock, patch
from app.services.scrapers.avito_houses import (
HouseCatalogEnrichment,
HouseInfo,
_persist_house,
_upsert_house_with_url,
save_house_catalog_enrichment,
upsert_house,
)
@ -269,3 +272,62 @@ def test_persist_house_coalesce_fields_in_bind():
assert bind["lat"] == h.lat
assert bind["lon"] == h.lon
assert bind["year_built"] == h.year_built
# ---------------------------------------------------------------------------
# Test 8: #1820 — ext_id=0 graceful-skip гард
# Два id-less URL (ext_id=0, без адреса) → match_or_create_house НЕ вызывается,
# house_sources("avito","0") НЕ создаётся, возвращается sentinel dict с house_id=0.
# ---------------------------------------------------------------------------
def test_save_house_catalog_enrichment_skips_zero_ext_id_no_address():
"""#1820: ext_id=0 + нет адреса → гард пропускает персистирование canonical house."""
db = MagicMock()
# Создаём два id-less graceful-skip enrichment (как _parse_development_or_skip возвращает)
enrichment_a = HouseCatalogEnrichment(
house_url="https://www.avito.ru/catalog/novostroyki/dom-A/",
house=HouseInfo(ext_id=0),
)
enrichment_b = HouseCatalogEnrichment(
house_url="https://www.avito.ru/catalog/novostroyki/dom-B/",
house=HouseInfo(ext_id=0),
)
with patch(
"app.services.scrapers.avito_houses.match_or_create_house",
) as mock_match:
result_a = save_house_catalog_enrichment(db, enrichment_a)
result_b = save_house_catalog_enrichment(db, enrichment_b)
# match_or_create_house НЕ должен вызываться — гард сработал до него
mock_match.assert_not_called()
# Возвращается sentinel dict с house_id=0, не плодятся дубли
assert result_a["house_id"] == 0, f"ожидали house_id=0, получили {result_a['house_id']}"
assert result_b["house_id"] == 0, f"ожидали house_id=0, получили {result_b['house_id']}"
# db.commit() НЕ вызывается (нет реальной транзакции)
db.commit.assert_not_called()
def test_save_house_catalog_enrichment_with_address_persists_despite_zero_ext_id():
"""#1820: ext_id=0 НО есть адрес → НЕ пропускаем (developmentPage с адресом, без avitoId)."""
db = MagicMock()
db.execute.return_value = MagicMock()
enrichment = HouseCatalogEnrichment(
house_url="https://www.avito.ru/catalog/novostroyki/dom-C/",
house=HouseInfo(ext_id=0, short_address="ул. Ленина, 1"),
)
with patch(
"app.services.scrapers.avito_houses.match_or_create_house",
return_value=(77, 0.9, "fingerprint"),
) as mock_match:
result = save_house_catalog_enrichment(db, enrichment)
# Адрес есть → персистим через fingerprint tier
mock_match.assert_called_once()
assert result["house_id"] == 77

View file

@ -111,6 +111,21 @@ def test_unified_runs_negative_offset_422(client: TestClient) -> None:
assert r.status_code == 422
def test_unified_runs_invalid_status_422(client: TestClient) -> None:
"""status с непредусмотренным значением → 422 (Literal-валидация)."""
r = client.get("/api/v1/admin/scrape/runs", params={"status": "unknown_status"})
assert r.status_code == 422
def test_unified_runs_valid_statuses_200(client: TestClient) -> None:
"""Все допустимые значения status проходят валидацию (ожидаем 200, не 422)."""
valid_statuses = ["done", "running", "banned", "zombie", "failed", "cancelled"]
for s in valid_statuses:
with patch("app.services.scrape_runs.list_all", return_value=(0, [])):
r = client.get("/api/v1/admin/scrape/runs", params={"status": s})
assert r.status_code == 200, f"status={s!r} должен возвращать 200, получили {r.status_code}"
# ── API 2: GET /scraper/health ───────────────────────────────────────────────