From cf3d4850e2679f691ff82f4fb2d8f7578994dae7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 18:18:05 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein/scrapers):=20=D1=82=D1=80=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B4-=D0=BF=D1=83=D1=82=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BB=D0=B8=D1=81=D1=8C=20=D0=BD=D0=B0=20chr?= =?UTF-8?q?ome120,=20=D0=BF=D0=BE=D0=BA=D0=B0=20kit=20=D1=83=D1=88=D1=91?= =?UTF-8?q?=D0=BB=20=D0=BD=D0=B0=20chrome146?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3034 свёл impersonate к единственной константе внутри scraper_kit и поднял профиль до chrome146. Сторож литерала сканирует только пакет, а его докстрока объявила остальное «отдельным периметром вне scope», сославшись на #2361 F4a. Периметр не спящий — он ходит в сеть каждый день, а #2361 к тому моменту был закрыт, то есть отсылка вела в никуда. На chrome120 оставались: app/services/cian_session.py:164 верификация куки Циана app/services/yandex_address_backfill.py:153 бэкфилл адресов app/tasks/yandex_detail_backfill.py:303 detail-бэкфилл Разрыв в 31 мажорную версию живёт в TLS-отпечатке (JA3/JA4), а не в строке User-Agent, поэтому сменой прокси он не лечится. ПРО ЦИАН ОТДЕЛЬНО. По #2673 оценка Циана мертва с 29 июня — «куки протухли, ни одной новой строки 37 дней». Путь, которым проверяется живость этих куки, всё это время представлялся площадке браузером двухлетней давности. Причину этим не объявляю: утверждаю, что при таком отпечатке отличить «куки протухли» от «нас узнали по рукопожатию» нечем. ТЕСТ ЗАКРЕПЛЯЛ ДЕФЕКТ. test_cian_session прибивал chrome120 гвоздём: подъём профиля в kit ронял бы этот тест, а «починкой» выглядел бы возврат к устаревшему профилю. Теперь тест сверяется с DEFAULT_IMPERSONATE. Сторож литерала расширен на backend/app — без этого периметр возвращается молча, что уже один раз и произошло. Намеренно НЕ входят tests/fixtures/** (номер профиля там — часть записи о том, чем снят фикстур-HTML) и scripts/** (разовые инструменты, в прод-путях не участвуют). Исторические замеры в комментариях сохранены как замеры: «curl_cffi с kit-профилем (на момент замера — Chrome 120)» вместо переписывания истории. Проверено: сканер сторожа на дереве даёт ноль нарушителей, на подсаженном литерале краснеет; все изменённые модули компилируются. Refs #3148 --- tradein-mvp/backend/app/api/v1/admin.py | 2 +- .../backend/app/services/cian_session.py | 7 +++-- .../app/services/yandex_address_backfill.py | 5 +-- .../app/tasks/yandex_address_backfill.py | 3 +- .../app/tasks/yandex_detail_backfill.py | 11 ++++--- .../tasks/test_yandex_detail_backfill.py | 2 +- .../backend/tests/test_cian_session.py | 12 +++++-- .../tests/test_impersonate_single_source.py | 31 ++++++++++++++----- 8 files changed, 49 insertions(+), 24 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index cfb63fd3..6b8d251a 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -2197,7 +2197,7 @@ async def scrape_yandex_address_backfill( """T10: Backfill listings.address для Yandex-листингов без номера дома. Yandex SERP-карточки содержат только улицу («улица Горького»). Этот endpoint - фетчит detail-страницы через curl_cffi (chrome120), извлекает полный адрес + фетчит detail-страницы через curl_cffi (kit-профиль `DEFAULT_IMPERSONATE`), извлекает полный адрес из («Екатеринбург, улица Горького, 36 — id …»), обновляет listings.address и сбрасывает geocode_tried_at для перегеокодирования. diff --git a/tradein-mvp/backend/app/services/cian_session.py b/tradein-mvp/backend/app/services/cian_session.py index f40378e2..388d94fb 100644 --- a/tradein-mvp/backend/app/services/cian_session.py +++ b/tradein-mvp/backend/app/services/cian_session.py @@ -17,6 +17,7 @@ from curl_cffi.requests import AsyncSession # cian_state_parser module, deleted #2397 финальный шаг E — golden-parity против # него уже недоступна, kit — единственный живой путь). from scraper_kit.cian_state_parser import extract_state +from scraper_kit.providers._base import DEFAULT_IMPERSONATE from sqlalchemy import text 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: """Hit Cian Valuation Calculator with cookies — return parsed state if authenticated. - Uses curl_cffi with impersonate='chrome120' (same as prod scrapers) to avoid - TLS-fingerprint bans that httpx would trigger. + Uses curl_cffi with the kit-wide TLS profile (`DEFAULT_IMPERSONATE`, тот же, + что у прод-скраперов) to avoid TLS-fingerprint bans that httpx would trigger. Returns (проверяй через `is`, НЕ `==` — это sentinel-объекты): 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") _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None async with AsyncSession( - impersonate="chrome120", + impersonate=DEFAULT_IMPERSONATE, cookies=cookies, timeout=20.0, proxies=_proxies, diff --git a/tradein-mvp/backend/app/services/yandex_address_backfill.py b/tradein-mvp/backend/app/services/yandex_address_backfill.py index b4a660c5..ac8bbf01 100644 --- a/tradein-mvp/backend/app/services/yandex_address_backfill.py +++ b/tradein-mvp/backend/app/services/yandex_address_backfill.py @@ -5,7 +5,7 @@ The offer detail page <title> has the full address including house number: «Екатеринбург, улица Горького, 36 — id 12345». 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`), extracts the full address from the <title> regex, and UPDATE listings.address. Deliberately NOT wired into scheduler — manual trigger only. @@ -21,6 +21,7 @@ import time from dataclasses import dataclass, field from curl_cffi.requests import AsyncSession +from scraper_kit.providers._base import DEFAULT_IMPERSONATE from sqlalchemy import text 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 async with AsyncSession( - impersonate="chrome120", + impersonate=DEFAULT_IMPERSONATE, timeout=30.0, proxies=_proxies, headers={ diff --git a/tradein-mvp/backend/app/tasks/yandex_address_backfill.py b/tradein-mvp/backend/app/tasks/yandex_address_backfill.py index 66e37f6d..b9d267fb 100644 --- a/tradein-mvp/backend/app/tasks/yandex_address_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_address_backfill.py @@ -7,7 +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, 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 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 diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index 48771f0b..4ed4e0df 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -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. 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. Parse HTML via YandexDetailScraper.parse (pure, no network). Persist via 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 fingerprinting). On datacenter IPs Yandex returns captcha / shell-HTML → 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. """ @@ -47,6 +47,7 @@ import time from dataclasses import dataclass, field 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 sqlalchemy import text from sqlalchemy.orm import Session @@ -95,7 +96,7 @@ OFFER_URL_PATTERN = "/offer/[0-9]+" # него собирается — это инвариант #2235 (`_canonical_source_url` в # scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164 # чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси, -# curl_cffi chrome120, тот же parse): 6 из 6 — HTTP 200 и parse OK, включая +# curl_cffi с kit-профилем (на момент замера — Chrome 120), тот же parse): 6 из 6 — HTTP 200 и parse OK, включая # строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php. # # Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни @@ -154,7 +155,7 @@ async def run_yandex_detail_backfill( max_consecutive_blocks: int -- consecutive parse→None before abort, default 5. 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 YandexDetailScraper().parse(html, offer_url). parse→None counts as a fail (possible captcha wall); consecutive None → abort after max_consecutive_blocks. @@ -300,7 +301,7 @@ async def run_yandex_detail_backfill( scraper = YandexDetailScraper() async with AsyncSession( - impersonate="chrome120", + impersonate=DEFAULT_IMPERSONATE, timeout=30.0, proxies=_proxies, headers={ diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py index d4d34cae..c9ef5c3a 100644 --- a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py @@ -1,7 +1,7 @@ """Tests for app.tasks.yandex_detail_backfill (the KIT task — already imports `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. Легаси `app.services.scrapers.yandex_detail` удалён (#2277 финальный шаг diff --git a/tradein-mvp/backend/tests/test_cian_session.py b/tradein-mvp/backend/tests/test_cian_session.py index 4468e8ed..9f293e17 100644 --- a/tradein-mvp/backend/tests/test_cian_session.py +++ b/tradein-mvp/backend/tests/test_cian_session.py @@ -6,6 +6,7 @@ import json from unittest.mock import AsyncMock, MagicMock, patch import pytest +from scraper_kit.providers._base import DEFAULT_IMPERSONATE from app.services.cian_session import ( CIAN_REQUIRED_COOKIES, @@ -474,8 +475,13 @@ async def test_verify_session_pool_exhausted_returns_source_unavailable_with_err @pytest.mark.asyncio -async def test_verify_session_uses_chrome120_impersonate() -> None: - """curl_cffi AsyncSession must be constructed with impersonate='chrome120'.""" +async def test_verify_session_uses_kit_impersonate_profile() -> None: + """AsyncSession строится с kit-профилем, а не с литералом. + + Тест раньше прибивал гвоздём `chrome120` — то есть закреплял ровно тот дефект, + ради которого #3034 заводился: обновление TLS-профиля в kit ломало бы этот тест, + и «починка» состояла бы в возврате к устаревшему профилю. + """ mock_session = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) 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"}) _, kwargs = mock_cls.call_args - assert kwargs.get("impersonate") == "chrome120" + assert kwargs.get("impersonate") == DEFAULT_IMPERSONATE diff --git a/tradein-mvp/backend/tests/test_impersonate_single_source.py b/tradein-mvp/backend/tests/test_impersonate_single_source.py index 4388b25e..0ea51e95 100644 --- a/tradein-mvp/backend/tests/test_impersonate_single_source.py +++ b/tradein-mvp/backend/tests/test_impersonate_single_source.py @@ -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) сюда -намеренно не входят — отдельный, более крупный периметр вне scope #3034. +Область — ДВЕ директории: + + * `packages/scraper-kit/src/scraper_kit/` — пакет, где живёт `DEFAULT_IMPERSONATE` + и все providers; + * `backend/app/` — прод-код бэкенда. Добавлен после того, как выяснилось, что + первая волна #3034 вычистила kit, а три живых прод-пути остались на + `chrome120`: верификация куки Циана (`services/cian_session.py`) и два + яндексовых бэкфилла. Оговорка «legacy-периметр вне scope» разошлась с фактом: + периметр не спит, он ходит в сеть каждый день, и та же ссылка (#2361 F4a) + к тому моменту была закрыта, то есть отсылать было уже некуда. + +НЕ входят: `tests/fixtures/**` (там номер профиля — часть записи о том, чем +снят фикстур-HTML, историю переписывать нельзя) и `scripts/**` (разовые +инструменты, в прод-путях не участвуют). """ from __future__ import annotations @@ -28,13 +38,17 @@ from pathlib import Path _BACKEND_ROOT = Path(__file__).resolve().parents[1] _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" 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: @@ -56,8 +70,9 @@ def test_default_impersonate_is_the_only_chrome_profile_literal() -> None: профиля — значение `DEFAULT_IMPERSONATE` в `providers/_base.py`. """ offenders = [ - str(path.relative_to(_KIT_SRC)) - for path in sorted(_KIT_SRC.rglob("*.py")) + str(path.relative_to(_BACKEND_ROOT.parent)) + for root in _SCAN_ROOTS + for path in sorted(root.rglob("*.py")) if _BANNED_LITERAL in path.read_text(encoding="utf-8") ] assert offenders == [], ( -- 2.45.3 From dd3a79b47543603126b114ed7d20c15b2ce95bc2 Mon Sep 17 00:00:00 2001 From: bot-backend <alexandro361@gmail.com> Date: Thu, 27 Aug 2026 18:24:09 +0300 Subject: [PATCH 2/2] =?UTF-8?q?style(tradein):=20=D1=83=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=20100=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BC=D0=B2=D0=BE=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruff E501 на трёх строках, которые удлинились от замены литерала на `DEFAULT_IMPERSONATE` в докстроках. Абзацы перевёрстаны целиком, а не разорваны по месту переполнения — рваный перенос читался бы как опечатка. Прогон: `ruff check app tests` — All checks passed. Refs #3148 --- tradein-mvp/backend/app/api/v1/admin.py | 6 +++--- tradein-mvp/backend/app/services/yandex_address_backfill.py | 4 ++-- tradein-mvp/backend/app/tasks/yandex_address_backfill.py | 6 +++--- tradein-mvp/backend/app/tasks/yandex_detail_backfill.py | 5 +++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 6b8d251a..d4cb58cc 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -2197,9 +2197,9 @@ async def scrape_yandex_address_backfill( """T10: Backfill listings.address для Yandex-листингов без номера дома. Yandex SERP-карточки содержат только улицу («улица Горького»). Этот endpoint - фетчит detail-страницы через curl_cffi (kit-профиль `DEFAULT_IMPERSONATE`), извлекает полный адрес - из <title> («Екатеринбург, улица Горького, 36 — id …»), обновляет - listings.address и сбрасывает geocode_tried_at для перегеокодирования. + фетчит detail-страницы через curl_cffi (kit-профиль `DEFAULT_IMPERSONATE`), + извлекает полный адрес из <title> («Екатеринбург, улица Горького, 36 — id …»), + обновляет listings.address и сбрасывает geocode_tried_at для перегеокодирования. limit: сколько листингов обработать за запуск (default 200). request_delay_sec: пауза между запросами (default 3.0s). diff --git a/tradein-mvp/backend/app/services/yandex_address_backfill.py b/tradein-mvp/backend/app/services/yandex_address_backfill.py index ac8bbf01..059d3dc2 100644 --- a/tradein-mvp/backend/app/services/yandex_address_backfill.py +++ b/tradein-mvp/backend/app/services/yandex_address_backfill.py @@ -5,8 +5,8 @@ The offer detail page <title> has the full address including house number: «Екатеринбург, улица Горького, 36 — id 12345». This service finds active yandex listings without a house number in `address`, -fetches each detail page via curl_cffi (kit-профиль `DEFAULT_IMPERSONATE`), extracts the full address -from the <title> regex, and UPDATE listings.address. +fetches each detail page via curl_cffi (kit-профиль `DEFAULT_IMPERSONATE`), +extracts the full address from the <title> regex, and UPDATE listings.address. Deliberately NOT wired into scheduler — manual trigger only. Triggered via POST /api/v1/admin/scrape/yandex-address-backfill. diff --git a/tradein-mvp/backend/app/tasks/yandex_address_backfill.py b/tradein-mvp/backend/app/tasks/yandex_address_backfill.py index b9d267fb..b4170792 100644 --- a/tradein-mvp/backend/app/tasks/yandex_address_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_address_backfill.py @@ -7,9 +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, 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 address -from the <title>, and -UPDATE listings.address ONLY when the extracted value differs from the current one. +detail page via curl_cffi (kit-профиль `DEFAULT_IMPERSONATE`), extracts the full +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 the scrape_schedules seed (migration 091). Manual trigger also available via POST diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index 4ed4e0df..4ea7305e 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -96,8 +96,9 @@ OFFER_URL_PATTERN = "/offer/[0-9]+" # него собирается — это инвариант #2235 (`_canonical_source_url` в # scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164 # чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси, -# curl_cffi с kit-профилем (на момент замера — Chrome 120), тот же parse): 6 из 6 — HTTP 200 и parse OK, включая -# строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php. +# curl_cffi с kit-профилем (на момент замера — Chrome 120), тот же parse): 6 из 6 +# — HTTP 200 и parse OK, включая строки, чей сохранённый source_url — рекламный +# редирект na100.pro/go.php. # # Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни # в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235 -- 2.45.3