fix(scrapers/backend): socks5 health-probe + status Literal + #1820 ext_id=0 dedup #1825
5 changed files with 100 additions and 4 deletions
|
|
@ -1962,7 +1962,9 @@ def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | Non
|
||||||
def list_scrape_runs_unified(
|
def list_scrape_runs_unified(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
source: Annotated[str | None, Query()] = None,
|
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,
|
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||||
offset: Annotated[int, Query(ge=0)] = 0,
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
) -> UnifiedScrapeRunsResponse:
|
) -> UnifiedScrapeRunsResponse:
|
||||||
|
|
|
||||||
|
|
@ -1252,7 +1252,24 @@ def save_house_catalog_enrichment(
|
||||||
Возвращает:
|
Возвращает:
|
||||||
{'house_id': N, 'reviews': N, 'sellers': N, 'listings_linked': N, 'placement_history': N}
|
{'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)
|
house_id = _upsert_house_with_url(db, e.house, e.house_url)
|
||||||
|
|
||||||
# 2. Сохранить отзывы
|
# 2. Сохранить отзывы
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ dependencies = [
|
||||||
"Pillow>=10.3.0", # photo sanitization re-encode (#6 audit fix)
|
"Pillow>=10.3.0", # photo sanitization re-encode (#6 audit fix)
|
||||||
"weasyprint>=62.0",
|
"weasyprint>=62.0",
|
||||||
"jinja2>=3.1.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
|
"tenacity>=9.0.0", # retry с exp backoff
|
||||||
"selectolax>=0.3.0", # быстрый HTML парсинг для scrapers
|
"selectolax>=0.3.0", # быстрый HTML парсинг для scrapers
|
||||||
"segno>=1.6.0", # QR-код для PDF shareable URL
|
"segno>=1.6.0", # QR-код для PDF shareable URL
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
2. Enrichment-поля (rating_score, developer_name) реально записываются в UPDATE.
|
2. Enrichment-поля (rating_score, developer_name) реально записываются в UPDATE.
|
||||||
3. _persist_house не вызывает db.commit().
|
3. _persist_house не вызывает db.commit().
|
||||||
4. upsert_house и _upsert_house_with_url делегируют в _persist_house.
|
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.
|
Использует тот же паттерн mock-DB что в test_matching.py и test_same_building_match.py.
|
||||||
"""
|
"""
|
||||||
|
|
@ -13,14 +14,16 @@
|
||||||
import json
|
import json
|
||||||
import os
|
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 unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from app.services.scrapers.avito_houses import (
|
from app.services.scrapers.avito_houses import (
|
||||||
|
HouseCatalogEnrichment,
|
||||||
HouseInfo,
|
HouseInfo,
|
||||||
_persist_house,
|
_persist_house,
|
||||||
_upsert_house_with_url,
|
_upsert_house_with_url,
|
||||||
|
save_house_catalog_enrichment,
|
||||||
upsert_house,
|
upsert_house,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -269,3 +272,62 @@ def test_persist_house_coalesce_fields_in_bind():
|
||||||
assert bind["lat"] == h.lat
|
assert bind["lat"] == h.lat
|
||||||
assert bind["lon"] == h.lon
|
assert bind["lon"] == h.lon
|
||||||
assert bind["year_built"] == h.year_built
|
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
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,21 @@ def test_unified_runs_negative_offset_422(client: TestClient) -> None:
|
||||||
assert r.status_code == 422
|
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 ───────────────────────────────────────────────
|
# ── API 2: GET /scraper/health ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue