All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m2s
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
app.services.scrapers.{cian,cian_detail,yandex_realty,domclick,domclick_exceptions,
avito_houses} имели 0 runtime-импортёров после E1 (scrape_pipeline.py удалён,
#2405) — admin.py/cian_history_backfill.py уже на scraper_kit.providers.*.
Удаление:
- 6 модулей целиком (avito_houses.py, cian.py, cian_detail.py, domclick.py,
domclick_exceptions.py, yandex_realty.py)
- 3 осиротевших dev-sweep скрипта (local-sweep-ekb{,-cian,-yandex}.py) —
эксклюзивно драйвили удалённые легаси-скраперы, kit-путь их заменяет
Тесты (18 файлов DELETE — эксклюзивно тестировали удалённые модули или были
чистым legacy-vs-kit parity, чей легаси-эталон исчез):
test_cian_detail.py, test_cian_serp_scraper.py, tests/scrapers/test_cian_exhaustive.py,
test_yandex_realty_serp.py, tests/scrapers/test_domclick.py,
test_avito_houses_dedup.py, test_avito_houses_parse.py,
test_houses_rating_filter_entries_1789.py, test_scraper_kit_domclick_golden_parity.py,
tests/scrapers/test_admin_{avito,cian,yandex}_kit_parity.py.
CONVERT (файл покрывает СМЕШАННЫЙ live+legacy набор — вырезаны только
секции удалённых модулей, live-покрытие сохранено):
- test_scraper_kit_cian_golden_parity.py — SERP/`_format_address`/detail
(cian.py/cian_detail.py) убраны; extract_state (cian_state_parser, live)/
valuation (cian_valuation, live)/newbuilding (cian_newbuilding, live)/
no-app-imports guard — остались
- test_scraper_kit_yandex_golden_parity.py — SERP `_entity_to_lot`/
`_parse_gate_json`/`_build_url` (yandex_realty) убраны; normalize_house_type
(live)/newbuilding parse (yandex_newbuilding, live)/no-app-imports guard —
остались
- test_scraper_kit_bisection_parity.py — cian/domclick/yandex-ветки убраны;
avito-ветка (live, единственная оставшаяся легаси-бисекция) — осталась
полностью, включая full-seed-grid
- test_scraper_kit_group_c_backfill_kit_parity.py — cian fetch_detail
browser_fetcher parity-тест убран; avito/house_imv_backfill/
cian_history_backfill/ekb_geoportal тесты — остались
- test_scraper_kit_pricehistory_session_parity.py — fetch_detail
config-injection/proxy-wiring тесты (cian_detail) убраны; extract_state
(cian_state_parser, live)/ScrapedLot (base, live) parity — остались
- test_yandex_scrapers_delay_wiring.py — yandex_realty delay-тест убран
(kit-эквивалент уже покрыт test_kit_serp_proxy_pool.py); yandex_newbuilding
(live)/yandex_detail/yandex_valuation (kit) тесты — остались
- test_scraper_proxy.py — yandex_realty BrowserFetcher-тест убран (redundant
с test_kit_serp_proxy_pool.py::test_yandex_serp_no_pool_by_default)
RETARGET на kit (символ живёт, только источник поменялся):
- test_domclick_sweep.py — `_map_item` → scraper_kit.providers.domclick.serp
- test_yandex_city_sweep.py — DEFAULT_PRICE_RANGES/ROOM_PATH →
scraper_kit.providers.yandex.serp
- test_pipeline_browser_routing.py — fetch_house_catalog →
scraper_kit.providers.avito.houses (+ KitAvitoBlockedError для firewall-теста)
- test_snapshot_writer.py — DetailEnrichment/save_detail_enrichment →
scraper_kit.providers.cian.detail (byte-identical strangler-копия)
- test_backfill_wave2.py — DetailEnrichment → scraper_kit.providers.cian.detail
- tests/scrapers/test_avito_unix_date_tz_consistency.py — avito_houses →
scraper_kit.providers.avito.houses (houses-ветка _unix_to_date/RUS_MONTHS
consolidation-проверки)
Stale-комментарии поправлены в cian_price_history.py, tests/scrapers/
test_domclick_detail.py (описывали ещё-живой domclick.py/domclick_exceptions.py).
grep -rn scrapers\.\(cian\|cian_detail\|yandex_realty\|domclick\|domclick_exceptions\|
avito_houses\) app/ scripts/ packages/ — только historical footnotes
("strangler-копия app.services.scrapers.X", past-tense "удалён/deleted").
Backend suite: 2811 passed, 6 skipped, 1 failed (known flake test_search_cache_hit
#2208, не регрессия). ruff check чист на всех изменённых файлах.
180 lines
6.3 KiB
Python
180 lines
6.3 KiB
Python
"""T8a: Cian offer price-history backfill service.
|
||
|
||
Fetches Cian detail pages (curl_cffi, без Playwright) for listings that have
|
||
no rows in offer_price_history, extracts priceChanges from
|
||
_cianConfig['frontend-offer-card'] defaultState, writes to offer_price_history.
|
||
|
||
Deliberately NOT wired into scheduler — rate-limit risk with datacenter IPs.
|
||
Triggered manually via POST /api/v1/admin/scrape/cian-price-history.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
|
||
# #2306: fetch_detail/save_detail_enrichment migrated to scraper_kit (byte-identical
|
||
# golden-parity была доказана против legacy app.services.scrapers.cian_detail до его
|
||
# удаления, #2397 Part E2 — см. tests/test_scraper_kit_pricehistory_session_parity.py
|
||
# для оставшейся extract_state/ScrapedLot parity). RealScraperConfig — тот же
|
||
# read-only адаптер над settings, что и остальные kit-инжекции (#2131) — сохраняет
|
||
# proxy-поведение (config.cian_proxy_url) идентичным прежнему прямому импорту settings.
|
||
from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.services.scraper_adapters import RealScraperConfig
|
||
from app.services.scraper_settings import get_scraper_delay
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class CianPriceHistoryResult:
|
||
checked: int = 0
|
||
saved: int = 0
|
||
skipped: int = 0
|
||
errors: int = 0
|
||
duration_sec: float = field(default=0.0)
|
||
|
||
|
||
async def backfill_cian_price_history(
|
||
db: Session,
|
||
*,
|
||
batch_size: int = 50,
|
||
listing_id: int | None = None,
|
||
) -> CianPriceHistoryResult:
|
||
"""Fetch Cian detail pages and write missing price-history rows.
|
||
|
||
Args:
|
||
db: SQLAlchemy session (caller-owned; commits internally per listing).
|
||
batch_size: max listings to process when listing_id is None.
|
||
listing_id: process a single specific listing (ignores batch_size).
|
||
|
||
Selection query picks cian listings with no existing offer_price_history rows.
|
||
Idempotent: re-run safe via ON CONFLICT DO NOTHING in save_detail_enrichment.
|
||
"""
|
||
result = CianPriceHistoryResult()
|
||
t0 = time.time()
|
||
delay = get_scraper_delay("cian") # default 5.0s
|
||
|
||
if listing_id is not None:
|
||
rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT l.id, l.source_url
|
||
FROM listings l
|
||
WHERE l.id = CAST(:lid AS bigint)
|
||
AND l.source = 'cian'
|
||
AND l.source_url IS NOT NULL
|
||
"""),
|
||
{"lid": listing_id},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
else:
|
||
rows = (
|
||
db.execute(
|
||
text("""
|
||
SELECT l.id, l.source_url
|
||
FROM listings l
|
||
LEFT JOIN offer_price_history oph ON oph.listing_id = l.id
|
||
WHERE l.source = 'cian'
|
||
AND l.source_url IS NOT NULL
|
||
AND oph.listing_id IS NULL
|
||
ORDER BY l.id
|
||
LIMIT :lim
|
||
"""),
|
||
{"lim": batch_size},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
result.checked = len(rows)
|
||
logger.info(
|
||
"cian_price_history backfill: checked=%d delay=%.1fs",
|
||
result.checked,
|
||
delay,
|
||
)
|
||
|
||
for i, row in enumerate(rows):
|
||
lid: int = row["id"]
|
||
url: str = row["source_url"]
|
||
|
||
try:
|
||
# config= обязателен — kit fetch_detail без него не читает cian_proxy_url
|
||
# (direct connection), а без прокси datacenter-IP блокируется Cian (#806).
|
||
enrichment = await fetch_detail(url, config=RealScraperConfig())
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"cian_price_history: fetch failed listing_id=%s url=%s: %s",
|
||
lid,
|
||
url,
|
||
exc,
|
||
)
|
||
result.errors += 1
|
||
await asyncio.sleep(delay)
|
||
continue
|
||
|
||
if enrichment is None:
|
||
logger.warning(
|
||
"cian_price_history: fetch returned None listing_id=%s url=%s",
|
||
lid,
|
||
url,
|
||
)
|
||
result.errors += 1
|
||
await asyncio.sleep(delay)
|
||
continue
|
||
|
||
if not enrichment.price_changes:
|
||
logger.debug("cian_price_history: no price_changes listing_id=%s", lid)
|
||
result.skipped += 1
|
||
else:
|
||
try:
|
||
# Count rows actually inserted: save_detail_enrichment skips
|
||
# changes without change_time/price_rub and uses ON CONFLICT
|
||
# DO NOTHING, so len(price_changes) overcounts on invalid
|
||
# elements or idempotent re-runs. Diff the row count instead.
|
||
before = db.execute(
|
||
text(
|
||
"SELECT COUNT(*) FROM offer_price_history "
|
||
"WHERE listing_id = CAST(:lid AS bigint)"
|
||
),
|
||
{"lid": lid},
|
||
).scalar_one()
|
||
save_detail_enrichment(db, lid, enrichment)
|
||
after = db.execute(
|
||
text(
|
||
"SELECT COUNT(*) FROM offer_price_history "
|
||
"WHERE listing_id = CAST(:lid AS bigint)"
|
||
),
|
||
{"lid": lid},
|
||
).scalar_one()
|
||
result.saved += max(0, int(after) - int(before))
|
||
except Exception as exc:
|
||
logger.warning("cian_price_history: save failed listing_id=%s: %s", lid, exc)
|
||
result.errors += 1
|
||
try:
|
||
db.rollback()
|
||
except Exception:
|
||
pass
|
||
await asyncio.sleep(delay)
|
||
continue
|
||
|
||
if i < len(rows) - 1:
|
||
await asyncio.sleep(delay)
|
||
|
||
result.duration_sec = time.time() - t0
|
||
logger.info(
|
||
"cian_price_history done: checked=%d saved=%d skipped=%d errors=%d %.1fs",
|
||
result.checked,
|
||
result.saved,
|
||
result.skipped,
|
||
result.errors,
|
||
result.duration_sec,
|
||
)
|
||
return result
|