Compare commits

..

No commits in common. "37cb61da85c7f0a3f998e1a3897216557e7a8b06" and "3297c1b683aa45230f98330bb1d986ea4a3cfff5" have entirely different histories.

8 changed files with 29 additions and 55 deletions

View file

@ -2197,9 +2197,9 @@ async def scrape_yandex_address_backfill(
"""T10: Backfill listings.address для Yandex-листингов без номера дома. """T10: Backfill listings.address для Yandex-листингов без номера дома.
Yandex SERP-карточки содержат только улицу («улица Горького»). Этот endpoint Yandex SERP-карточки содержат только улицу («улица Горького»). Этот endpoint
фетчит detail-страницы через curl_cffi (kit-профиль `DEFAULT_IMPERSONATE`), фетчит detail-страницы через curl_cffi (chrome120), извлекает полный адрес
извлекает полный адрес из <title> («Екатеринбург, улица Горького, 36 id »), из <title> («Екатеринбург, улица Горького, 36 id »), обновляет
обновляет listings.address и сбрасывает geocode_tried_at для перегеокодирования. listings.address и сбрасывает geocode_tried_at для перегеокодирования.
limit: сколько листингов обработать за запуск (default 200). limit: сколько листингов обработать за запуск (default 200).
request_delay_sec: пауза между запросами (default 3.0s). request_delay_sec: пауза между запросами (default 3.0s).

View file

@ -17,7 +17,6 @@ from curl_cffi.requests import AsyncSession
# cian_state_parser module, deleted #2397 финальный шаг E — golden-parity против # cian_state_parser module, deleted #2397 финальный шаг E — golden-parity против
# него уже недоступна, kit — единственный живой путь). # него уже недоступна, kit — единственный живой путь).
from scraper_kit.cian_state_parser import extract_state from scraper_kit.cian_state_parser import extract_state
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -133,8 +132,8 @@ def _classify_verify_response(
async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None: async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
"""Hit Cian Valuation Calculator with cookies — return parsed state if authenticated. """Hit Cian Valuation Calculator with cookies — return parsed state if authenticated.
Uses curl_cffi with the kit-wide TLS profile (`DEFAULT_IMPERSONATE`, тот же, Uses curl_cffi with impersonate='chrome120' (same as prod scrapers) to avoid
что у прод-скраперов) to avoid TLS-fingerprint bans that httpx would trigger. TLS-fingerprint bans that httpx would trigger.
Returns (проверяй через `is`, НЕ `==` это sentinel-объекты): Returns (проверяй через `is`, НЕ `==` это sentinel-объекты):
state dict authenticated (user.isAuthenticated + userId) state dict authenticated (user.isAuthenticated + userId)
@ -162,7 +161,7 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
_proxy_url = resolve_proxy_url_sync("cian") _proxy_url = resolve_proxy_url_sync("cian")
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
async with AsyncSession( async with AsyncSession(
impersonate=DEFAULT_IMPERSONATE, impersonate="chrome120",
cookies=cookies, cookies=cookies,
timeout=20.0, timeout=20.0,
proxies=_proxies, proxies=_proxies,

View file

@ -5,8 +5,8 @@ The offer detail page <title> has the full address including house number:
«Екатеринбург, улица Горького, 36 id 12345». «Екатеринбург, улица Горького, 36 id 12345».
This service finds active yandex listings without a house number in `address`, This service finds active yandex listings without a house number in `address`,
fetches each detail page via curl_cffi (kit-профиль `DEFAULT_IMPERSONATE`), fetches each detail page via curl_cffi(chrome120), extracts the full address
extracts the full address from the <title> regex, and UPDATE listings.address. from the <title> regex, and UPDATE listings.address.
Deliberately NOT wired into scheduler manual trigger only. Deliberately NOT wired into scheduler manual trigger only.
Triggered via POST /api/v1/admin/scrape/yandex-address-backfill. Triggered via POST /api/v1/admin/scrape/yandex-address-backfill.
@ -21,7 +21,6 @@ import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from curl_cffi.requests import AsyncSession from curl_cffi.requests import AsyncSession
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -151,7 +150,7 @@ async def backfill_yandex_addresses(
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
async with AsyncSession( async with AsyncSession(
impersonate=DEFAULT_IMPERSONATE, impersonate="chrome120",
timeout=30.0, timeout=30.0,
proxies=_proxies, proxies=_proxies,
headers={ headers={

View file

@ -7,9 +7,8 @@ page <title> contains the full address including house number:
This task selects active listings that match the predicate (source='yandex', region_code=66, This task selects active listings that match the predicate (source='yandex', region_code=66,
address IS NOT NULL, address has no house number, source_url IS NOT NULL), fetches each address IS NOT NULL, address has no house number, source_url IS NOT NULL), fetches each
detail page via curl_cffi (kit-профиль `DEFAULT_IMPERSONATE`), extracts the full detail page via curl_cffi(chrome120), extracts the full address from the <title>, and
address from the <title>, and UPDATE listings.address ONLY when the extracted UPDATE listings.address ONLY when the extracted value differs from the current one.
value differs from the current one.
Wired into the in-app scheduler as source='yandex_address_backfill'. Triggered nightly via Wired into the in-app scheduler as source='yandex_address_backfill'. Triggered nightly via
the scrape_schedules seed (migration 091). Manual trigger also available via POST the scrape_schedules seed (migration 091). Manual trigger also available via POST

View file

@ -8,7 +8,7 @@ Yandex city sweep does not call YandexDetailScraper — SERP data only.
area_m2 coverage 23%, living/kitchen 0%, repair_state 1% on prod. area_m2 coverage 23%, living/kitchen 0%, repair_state 1% on prod.
Solution: single snapshot SELECT at start (guarantees termination), fetch each Solution: single snapshot SELECT at start (guarantees termination), fetch each
offer detail page via curl_cffi AsyncSession (kit-профиль `DEFAULT_IMPERSONATE` + proxy) mirrors offer detail page via curl_cffi AsyncSession (chrome120 + proxy) mirrors
yandex_address_backfill.py which already gets full HTML from Yandex on prod. yandex_address_backfill.py which already gets full HTML from Yandex on prod.
Parse HTML via YandexDetailScraper.parse (pure, no network). Persist via Parse HTML via YandexDetailScraper.parse (pure, no network). Persist via
save_detail_enrichment. Track consecutive parseNone results; abort after save_detail_enrichment. Track consecutive parseNone results; abort after
@ -35,7 +35,7 @@ Why curl_cffi and not YandexDetailScraper.fetch_detail:
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
fingerprinting). On datacenter IPs Yandex returns captcha / shell-HTML fingerprinting). On datacenter IPs Yandex returns captcha / shell-HTML
parse always returns None backfill would be 0% effective. The parse always returns None backfill would be 0% effective. The
curl_cffi path (kit-профиль impersonation + mobile proxy) is already proven curl_cffi path (chrome120 impersonation + mobile proxy) is already proven
by yandex_address_backfill, which fetches identical offer detail pages. by yandex_address_backfill, which fetches identical offer detail pages.
""" """
@ -47,7 +47,6 @@ import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from curl_cffi.requests import AsyncSession from curl_cffi.requests import AsyncSession
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail_enrichment from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail_enrichment
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -96,9 +95,8 @@ OFFER_URL_PATTERN = "/offer/[0-9]+"
# него собирается — это инвариант #2235 (`_canonical_source_url` в # него собирается — это инвариант #2235 (`_canonical_source_url` в
# scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164 # scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164
# чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси, # чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси,
# curl_cffi с kit-профилем (на момент замера — Chrome 120), тот же parse): 6 из 6 # curl_cffi chrome120, тот же parse): 6 из 6 — HTTP 200 и parse OK, включая
# — HTTP 200 и parse OK, включая строки, чей сохранённый source_url — рекламный # строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php.
# редирект na100.pro/go.php.
# #
# Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни # Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни
# в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235 # в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235
@ -156,7 +154,7 @@ async def run_yandex_detail_backfill(
max_consecutive_blocks: int -- consecutive parseNone before abort, default 5. max_consecutive_blocks: int -- consecutive parseNone before abort, default 5.
Fetch mechanism: Fetch mechanism:
curl_cffi AsyncSession(impersonate=DEFAULT_IMPERSONATE) + scraper_proxy_url mirrors curl_cffi AsyncSession(impersonate="chrome120") + scraper_proxy_url mirrors
yandex_address_backfill. On HTTP 200: pass resp.text to yandex_address_backfill. On HTTP 200: pass resp.text to
YandexDetailScraper().parse(html, offer_url). parseNone counts as a fail YandexDetailScraper().parse(html, offer_url). parseNone counts as a fail
(possible captcha wall); consecutive None abort after max_consecutive_blocks. (possible captcha wall); consecutive None abort after max_consecutive_blocks.
@ -302,7 +300,7 @@ async def run_yandex_detail_backfill(
scraper = YandexDetailScraper() scraper = YandexDetailScraper()
async with AsyncSession( async with AsyncSession(
impersonate=DEFAULT_IMPERSONATE, impersonate="chrome120",
timeout=30.0, timeout=30.0,
proxies=_proxies, proxies=_proxies,
headers={ headers={

View file

@ -1,7 +1,7 @@
"""Tests for app.tasks.yandex_detail_backfill (the KIT task — already imports """Tests for app.tasks.yandex_detail_backfill (the KIT task — already imports
`scraper_kit.providers.yandex.detail.{YandexDetailScraper,save_detail_enrichment}`). `scraper_kit.providers.yandex.detail.{YandexDetailScraper,save_detail_enrichment}`).
Fetch mechanism changed: curl_cffi AsyncSession(kit-профиль + proxy) + YandexDetailScraper.parse Fetch mechanism changed: curl_cffi AsyncSession(chrome120+proxy) + YandexDetailScraper.parse
(instead of old fetch_detail path). Mocks target session.get and scraper.parse. (instead of old fetch_detail path). Mocks target session.get and scraper.parse.
Легаси `app.services.scrapers.yandex_detail` удалён (#2277 финальный шаг Легаси `app.services.scrapers.yandex_detail` удалён (#2277 финальный шаг

View file

@ -6,7 +6,6 @@ import json
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
from app.services.cian_session import ( from app.services.cian_session import (
CIAN_REQUIRED_COOKIES, CIAN_REQUIRED_COOKIES,
@ -475,13 +474,8 @@ async def test_verify_session_pool_exhausted_returns_source_unavailable_with_err
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_verify_session_uses_kit_impersonate_profile() -> None: async def test_verify_session_uses_chrome120_impersonate() -> None:
"""AsyncSession строится с kit-профилем, а не с литералом. """curl_cffi AsyncSession must be constructed with impersonate='chrome120'."""
Тест раньше прибивал гвоздём `chrome120` то есть закреплял ровно тот дефект,
ради которого #3034 заводился: обновление TLS-профиля в kit ломало бы этот тест,
и «починка» состояла бы в возврате к устаревшему профилю.
"""
mock_session = AsyncMock() mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None) mock_session.__aexit__ = AsyncMock(return_value=None)
@ -491,4 +485,4 @@ async def test_verify_session_uses_kit_impersonate_profile() -> None:
await verify_session({"DMIR_AUTH": "x"}) await verify_session({"DMIR_AUTH": "x"})
_, kwargs = mock_cls.call_args _, kwargs = mock_cls.call_args
assert kwargs.get("impersonate") == DEFAULT_IMPERSONATE assert kwargs.get("impersonate") == "chrome120"

View file

@ -16,20 +16,10 @@ scraper_kit (`providers/avito/{serp,detail,imv,houses}.py`,
`#`-комментарии — а именно в комментарии литерал незаметнее всего переживёт `#`-комментарии — а именно в комментарии литерал незаметнее всего переживёт
следующий рефактор. следующий рефактор.
Область ДВЕ директории: Область: `packages/scraper-kit/src/scraper_kit/` пакет, где живёт
`DEFAULT_IMPERSONATE` и все providers. Legacy-дубликаты вне kit (например
* `packages/scraper-kit/src/scraper_kit/` пакет, где живёт `DEFAULT_IMPERSONATE` `app/services/cian_session.py`, ещё не мигрированный на kit, #2361 F4a) сюда
и все providers; намеренно не входят отдельный, более крупный периметр вне scope #3034.
* `backend/app/` прод-код бэкенда. Добавлен после того, как выяснилось, что
первая волна #3034 вычистила kit, а три живых прод-пути остались на
`chrome120`: верификация куки Циана (`services/cian_session.py`) и два
яндексовых бэкфилла. Оговорка «legacy-периметр вне scope» разошлась с фактом:
периметр не спит, он ходит в сеть каждый день, и та же ссылка (#2361 F4a)
к тому моменту была закрыта, то есть отсылать было уже некуда.
НЕ входят: `tests/fixtures/**` (там номер профиля часть записи о том, чем
снят фикстур-HTML, историю переписывать нельзя) и `scripts/**` (разовые
инструменты, в прод-путях не участвуют).
""" """
from __future__ import annotations from __future__ import annotations
@ -38,17 +28,13 @@ from pathlib import Path
_BACKEND_ROOT = Path(__file__).resolve().parents[1] _BACKEND_ROOT = Path(__file__).resolve().parents[1]
_KIT_SRC = _BACKEND_ROOT.parent / "packages" / "scraper-kit" / "src" / "scraper_kit" _KIT_SRC = _BACKEND_ROOT.parent / "packages" / "scraper-kit" / "src" / "scraper_kit"
_APP_SRC = _BACKEND_ROOT / "app"
_SCAN_ROOTS = (_KIT_SRC, _APP_SRC)
_BANNED_LITERAL = "chrome120" _BANNED_LITERAL = "chrome120"
def test_scan_area_exists() -> None: def test_scan_area_exists() -> None:
"""Обе области сканирования живы — иначе сторож зелен вхолостую (путь съехал).""" """Область сканирования жива — иначе сторож зелен вхолостую (путь съехал)."""
for root in _SCAN_ROOTS: assert _KIT_SRC.is_dir(), f"область сканирования съехала: {_KIT_SRC}"
assert root.is_dir(), f"область сканирования съехала: {root}"
def test_detector_actually_detects(tmp_path: Path) -> None: def test_detector_actually_detects(tmp_path: Path) -> None:
@ -70,9 +56,8 @@ def test_default_impersonate_is_the_only_chrome_profile_literal() -> None:
профиля значение `DEFAULT_IMPERSONATE` в `providers/_base.py`. профиля значение `DEFAULT_IMPERSONATE` в `providers/_base.py`.
""" """
offenders = [ offenders = [
str(path.relative_to(_BACKEND_ROOT.parent)) str(path.relative_to(_KIT_SRC))
for root in _SCAN_ROOTS for path in sorted(_KIT_SRC.rglob("*.py"))
for path in sorted(root.rglob("*.py"))
if _BANNED_LITERAL in path.read_text(encoding="utf-8") if _BANNED_LITERAL in path.read_text(encoding="utf-8")
] ]
assert offenders == [], ( assert offenders == [], (