fix(tradein/scrapers): три прод-пути остались на chrome120, пока kit ушёл на chrome146 #3149
8 changed files with 55 additions and 29 deletions
|
|
@ -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 (chrome120), извлекает полный адрес
|
фетчит detail-страницы через curl_cffi (kit-профиль `DEFAULT_IMPERSONATE`),
|
||||||
из <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).
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -132,8 +133,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 impersonate='chrome120' (same as prod scrapers) to avoid
|
Uses curl_cffi with the kit-wide TLS profile (`DEFAULT_IMPERSONATE`, тот же,
|
||||||
TLS-fingerprint bans that httpx would trigger.
|
что у прод-скраперов) to avoid 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)
|
||||||
|
|
@ -161,7 +162,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="chrome120",
|
impersonate=DEFAULT_IMPERSONATE,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
timeout=20.0,
|
timeout=20.0,
|
||||||
proxies=_proxies,
|
proxies=_proxies,
|
||||||
|
|
|
||||||
|
|
@ -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(chrome120), extracts the full address
|
fetches each detail page via curl_cffi (kit-профиль `DEFAULT_IMPERSONATE`),
|
||||||
from the <title> regex, and UPDATE listings.address.
|
extracts the full 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,6 +21,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -150,7 +151,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="chrome120",
|
impersonate=DEFAULT_IMPERSONATE,
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
proxies=_proxies,
|
proxies=_proxies,
|
||||||
headers={
|
headers={
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ 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(chrome120), extracts the full address from the <title>, and
|
detail page via curl_cffi (kit-профиль `DEFAULT_IMPERSONATE`), extracts the full
|
||||||
UPDATE listings.address ONLY when the extracted value differs from the current one.
|
address from the <title>, and UPDATE listings.address ONLY when the extracted
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 (chrome120 + proxy) — mirrors
|
offer detail page via curl_cffi AsyncSession (kit-профиль `DEFAULT_IMPERSONATE` + 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 parse→None results; abort after
|
save_detail_enrichment. Track consecutive parse→None 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 (chrome120 impersonation + mobile proxy) is already proven
|
curl_cffi path (kit-профиль 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,6 +47,7 @@ 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
|
||||||
|
|
@ -95,8 +96,9 @@ 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 chrome120, тот же parse): 6 из 6 — HTTP 200 и parse OK, включая
|
# curl_cffi с kit-профилем (на момент замера — Chrome 120), тот же parse): 6 из 6
|
||||||
# строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php.
|
# — HTTP 200 и parse OK, включая строки, чей сохранённый source_url — рекламный
|
||||||
|
# редирект 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
|
||||||
|
|
@ -154,7 +156,7 @@ async def run_yandex_detail_backfill(
|
||||||
max_consecutive_blocks: int -- consecutive parse→None before abort, default 5.
|
max_consecutive_blocks: int -- consecutive parse→None before abort, default 5.
|
||||||
|
|
||||||
Fetch mechanism:
|
Fetch mechanism:
|
||||||
curl_cffi AsyncSession(impersonate="chrome120") + scraper_proxy_url — mirrors
|
curl_cffi AsyncSession(impersonate=DEFAULT_IMPERSONATE) + 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). parse→None counts as a fail
|
YandexDetailScraper().parse(html, offer_url). parse→None counts as a fail
|
||||||
(possible captcha wall); consecutive None → abort after max_consecutive_blocks.
|
(possible captcha wall); consecutive None → abort after max_consecutive_blocks.
|
||||||
|
|
@ -300,7 +302,7 @@ async def run_yandex_detail_backfill(
|
||||||
scraper = YandexDetailScraper()
|
scraper = YandexDetailScraper()
|
||||||
|
|
||||||
async with AsyncSession(
|
async with AsyncSession(
|
||||||
impersonate="chrome120",
|
impersonate=DEFAULT_IMPERSONATE,
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
proxies=_proxies,
|
proxies=_proxies,
|
||||||
headers={
|
headers={
|
||||||
|
|
|
||||||
|
|
@ -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(chrome120+proxy) + YandexDetailScraper.parse
|
Fetch mechanism changed: curl_cffi AsyncSession(kit-профиль + 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 финальный шаг
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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,
|
||||||
|
|
@ -474,8 +475,13 @@ async def test_verify_session_pool_exhausted_returns_source_unavailable_with_err
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_verify_session_uses_chrome120_impersonate() -> None:
|
async def test_verify_session_uses_kit_impersonate_profile() -> None:
|
||||||
"""curl_cffi AsyncSession must be constructed with impersonate='chrome120'."""
|
"""AsyncSession строится с kit-профилем, а не с литералом.
|
||||||
|
|
||||||
|
Тест раньше прибивал гвоздём `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)
|
||||||
|
|
@ -485,4 +491,4 @@ async def test_verify_session_uses_chrome120_impersonate() -> 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") == "chrome120"
|
assert kwargs.get("impersonate") == DEFAULT_IMPERSONATE
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,20 @@ scraper_kit (`providers/avito/{serp,detail,imv,houses}.py`,
|
||||||
`#`-комментарии — а именно в комментарии литерал незаметнее всего переживёт
|
`#`-комментарии — а именно в комментарии литерал незаметнее всего переживёт
|
||||||
следующий рефактор.
|
следующий рефактор.
|
||||||
|
|
||||||
Область: `packages/scraper-kit/src/scraper_kit/` — пакет, где живёт
|
Область — ДВЕ директории:
|
||||||
`DEFAULT_IMPERSONATE` и все providers. Legacy-дубликаты вне kit (например
|
|
||||||
`app/services/cian_session.py`, ещё не мигрированный на kit, #2361 F4a) сюда
|
* `packages/scraper-kit/src/scraper_kit/` — пакет, где живёт `DEFAULT_IMPERSONATE`
|
||||||
намеренно не входят — отдельный, более крупный периметр вне scope #3034.
|
и все providers;
|
||||||
|
* `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
|
||||||
|
|
@ -28,13 +38,17 @@ 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:
|
||||||
"""Область сканирования жива — иначе сторож зелен вхолостую (путь съехал)."""
|
"""Обе области сканирования живы — иначе сторож зелен вхолостую (путь съехал)."""
|
||||||
assert _KIT_SRC.is_dir(), f"область сканирования съехала: {_KIT_SRC}"
|
for root in _SCAN_ROOTS:
|
||||||
|
assert root.is_dir(), f"область сканирования съехала: {root}"
|
||||||
|
|
||||||
|
|
||||||
def test_detector_actually_detects(tmp_path: Path) -> None:
|
def test_detector_actually_detects(tmp_path: Path) -> None:
|
||||||
|
|
@ -56,8 +70,9 @@ 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(_KIT_SRC))
|
str(path.relative_to(_BACKEND_ROOT.parent))
|
||||||
for path in sorted(_KIT_SRC.rglob("*.py"))
|
for root in _SCAN_ROOTS
|
||||||
|
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 == [], (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue