fix(tradein/cian): обогащение ЖК падало не на разметке, а на сожжённом узле (#2767)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m54s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m54s
Живая проба 2026-08-09 тем же трактом (сайдкар → camoufox → узел пула), одна и та
же страница zhk-pihtovyy-ekb-i.cian.ru:
узел #1 asocks-residential-1 → 374 168 Б, cian_waf_block, initialState нет
узел #9 asocks-mobile-1 → 542 021 Б, initialState ЕСТЬ
узел #10 asocks-mobile-2 → 557 235 Б, initialState ЕСТЬ
узел #11 asocks-mobile-3 → 557 163 Б, initialState ЕСТЬ
Разметка не менялась: MFE 'newbuilding-card-desktop-frontend'/'initialState'
разбирается ТЕКУЩИМ кодом на трёх узлах из четырёх. Страница в 374 КБ — не
карточка и не SPA-оболочка, а страница блокировки Циана: «Обнаружен
подозрительный трафик», код страницы cian_waf_block, собственная аналитика
помечает её pageType:"VPNBlock".
Почему это восемь суток читалось как смена разметки: fetch_newbuilding была
единственным cian-путём, который строил BrowserFetcher вручную, без
proxy_provider (serp.py всегда шёл через build_browser_fetcher). Значит сбор
всегда выходил через ОДИН env-узел сайдкара — SCRAPER_PROXY_URL, он же узел
пула #1, чей exit-IP Циан забанил. Ротация была невозможна, report_ban без
lease — no-op, 25 попыток за ночь уходили в тот же адрес.
Диагностика #2768 при этом отвечала «antibot_markers=none»: список маркеров не
знал подписи cian_waf_block, а страница блока не содержит ни «captcha», ни «вы
не робот». Молчание списка прочиталось как его вердикт «защиты нет» — и увело
разбор в гипотезу про разметку.
Правки:
- fetch_newbuilding принимает proxy_provider и строится через
build_browser_fetcher (пул, как у всех остальных cian-путей);
- страница блока репортится в пул как бан узла ДО выхода из контекста, пока
lease жив, — дальше acquire('cian') этот узел не выдаёт;
- cian_waf_block добавлен в _ANTIBOT_MARKERS; размер сам по себе гипотезы
больше не разделяет (блок весит 374 КБ) — это записано в docstring;
- proxy_provider прокинут во все три вызывающих: обогащение, cian_history,
admin debug-роут;
- из лога убрано «(captcha / parse miss?)» — догадка автора кода, читавшаяся
дальше как факт.
Тест на НАСТОЯЩЕМ сохранённом ответе прода (фикстура cian_waf_block_zhk_page.html,
обрезаны инлайн-стили). На коде до правки: _describe_parse_miss даёт
«antibot_markers=none», _blocked_by нет, аргумента proxy_provider нет.
Refs #2767
This commit is contained in:
parent
ef82172bd1
commit
70f2daf241
9 changed files with 278 additions and 40 deletions
|
|
@ -1953,7 +1953,9 @@ async def scrape_cian_newbuilding(
|
||||||
save_newbuilding_enrichment,
|
save_newbuilding_enrichment,
|
||||||
)
|
)
|
||||||
|
|
||||||
enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig())
|
enrichment = await fetch_newbuilding(
|
||||||
|
zhk_url, config=RealScraperConfig(), proxy_provider=_kit_proxy_provider()
|
||||||
|
)
|
||||||
if enrichment is None:
|
if enrichment is None:
|
||||||
raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}")
|
raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,11 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig
|
from app.services.scraper_adapters import (
|
||||||
|
RealMatcherAdapter,
|
||||||
|
RealProxyProvider,
|
||||||
|
RealScraperConfig,
|
||||||
|
)
|
||||||
from app.services.scraper_settings import get_scraper_delay
|
from app.services.scraper_settings import get_scraper_delay
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -230,7 +234,13 @@ async def backfill_cian_history(
|
||||||
|
|
||||||
enrichment = None
|
enrichment = None
|
||||||
try:
|
try:
|
||||||
enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig())
|
# proxy_provider (#2767): тот же сожжённый env-узел бил и сюда —
|
||||||
|
# это второй вызывающий fetch_newbuilding, чинить надо оба.
|
||||||
|
enrichment = await fetch_newbuilding(
|
||||||
|
zhk_url,
|
||||||
|
config=RealScraperConfig(),
|
||||||
|
proxy_provider=RealProxyProvider(),
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"cian_newbuilding fetch failed for house_id=%s url=%s: %s",
|
"cian_newbuilding fetch failed for house_id=%s url=%s: %s",
|
||||||
|
|
|
||||||
|
|
@ -399,9 +399,15 @@ async def backfill_newbuilding_enrichment(
|
||||||
save_newbuilding_enrichment,
|
save_newbuilding_enrichment,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.services.scraper_adapters import RealScraperConfig
|
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
|
||||||
|
|
||||||
scraper_config = RealScraperConfig()
|
scraper_config = RealScraperConfig()
|
||||||
|
# #2767: обогащение было ЕДИНСТВЕННЫМ cian-путём мимо пула прокси — весь сбор шёл
|
||||||
|
# через env-узел сайдкара, и когда Циан забанил его exit-IP, 8 суток по 25 попыток
|
||||||
|
# уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠
|
||||||
|
# «включить пул»: реально пул задействуется, только если включён
|
||||||
|
# config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding).
|
||||||
|
proxy_provider = RealProxyProvider()
|
||||||
|
|
||||||
result = NewbuildingEnrichBackfillResult()
|
result = NewbuildingEnrichBackfillResult()
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
|
|
@ -525,7 +531,9 @@ async def backfill_newbuilding_enrichment(
|
||||||
# ── Fetch (network; anti-bot surface) ──────────────────────────────
|
# ── Fetch (network; anti-bot surface) ──────────────────────────────
|
||||||
enrichment = None
|
enrichment = None
|
||||||
try:
|
try:
|
||||||
enrichment = await fetch_newbuilding(zhk_url, config=scraper_config)
|
enrichment = await fetch_newbuilding(
|
||||||
|
zhk_url, config=scraper_config, proxy_provider=proxy_provider
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"newbuilding fetch failed house_id=%s url=%s: %s", house_id, zhk_url, exc
|
"newbuilding fetch failed house_id=%s url=%s: %s", house_id, zhk_url, exc
|
||||||
|
|
@ -535,8 +543,12 @@ async def backfill_newbuilding_enrichment(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if enrichment is None:
|
if enrichment is None:
|
||||||
|
# Без «(captcha / parse miss?)» (#2767): догадка автора кода в тексте лога
|
||||||
|
# читается дальше как факт и один раз уже увела диагноз не туда. Причина
|
||||||
|
# печатается строкой ВЫШЕ, в самом месте отказа (html_len + antibot_markers).
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"newbuilding fetch returned None house_id=%s url=%s (captcha / parse miss?)",
|
"newbuilding fetch returned None house_id=%s url=%s — причина в строке "
|
||||||
|
"'initialState extraction failed' выше",
|
||||||
house_id,
|
house_id,
|
||||||
zhk_url,
|
zhk_url,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
1
tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html
vendored
Normal file
1
tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="//cdn.cian.site/frontend/common-css/l/common.min.css"><title>Ошибка - Циан</title><!-- … инлайн-стили, GTM и аналитика вырезаны: 374168 Б → фрагмент … --><body>ISIDPlhwqgqcowpM8uZOWWzawmWX3JbKNOWV5y8jbBk1QTMxXSbNTCYzv2eY56%2BzgvWctsm1nNNBInjiTIE4wTKyIXqNmtYlOzmhaTK60Tn8LgFw465ooSTZRlZbWNAVRqsSkSbwX3cLXr%2BXama%2BvH5PpteyqjVoMEnZZ9fy7U9Mq6lNhNqPwTg6YASdghUaijPGTLJGRj1xv2zyp3nGfDDlmBEufGFsQSmWpaquZAFvWbMSIVagrgmWV%2FpwZY%2B05W7zk%2B48X94xHfjeo42qibiytt3w1NTImtWX6RAUPZOEWRMJX%2FDAhDnp6PpcNPWJMlIzO54LIEgTLTbPMeT8Zcz8bbz8bbz8bbz8bcxqGIlysW6SCSEHnI3OmWtyI1qNpU%2BaVl4fL4yPXGUHMlzpFO8g7x3nL5nBgMCqbNNM489eVZ8CQV07DTtFVFNVZlZbprTPdHkXK8dpDGUHO2YoUriDzZ%2FPcifCVNlgCap0b6RzwJbvsziCR09wO2bk%2B2Fh0BtH1djPV2M9XYz1djPV2MtsmyQM5f7xfaMjZdAbCdhzBtLjB7V9hsvkNjwjKUT8C9Zw3rBwyJ6wsNWNoze1g2nkHtUNoereRt7INYvs9XYwnf%2FAM6pU5R2c0UDE8gAHwP%2FAIr%2FxAA4EQABAwEFBAgFAgcBAAAAAAABAAIRECExQVGRAxIgYQQiUHGBobHRMDKSwdJA8BMjM0JgguHx%2F9oACAEDAQk%2FAPgbQSMJtW01sTgRy7OMNF5z5BWoJxgYK%2FEdmXus1oN5y2e6cx%2F2aXO7MuF6vCvx76f22I%2FK5vjJ7MxCwVrcimhs440F8Hs0dceYqLMEI7OHeEOqELB2g2Ozf%2FV8hN2QlGQUBC3UIPDu7qIgRhwRu4LdjQ13Ykwt0jzrgmhrcAb%2FABujuW7GivoC52QtPeYuHNbuyH1fiulR3NP5KH8gI%2B5Tf4buZ9wKxHNbqvqBGMrdKEOx4jACsYLh9zQ9QrIff4vOuayqOoNZ9qZ0vRlxvKEqE2EIcLjzXzM%2BDnw3L%2BmDfmeAzFnxedc1lQwSChAGFM6P3T3LbDQo2Zoq0H1WC%2BY%2FBz4f9jlyq0gH4%2FOuayV6Mk%2FuPBCwIRLQVnQwBem7ozdf4DEeK6S7ws9Cukv%2Bo%2B66Q8xhvO96Z1dDsDetsNFtm6LbN0Tt5wvMRTPgM7S7uqIi6ULCjIFLyhB4mzPC2RbwDqAWnxQIGHMZoWBXDzRuaAhNqvRkNN2EhCSiGhbb96LbQB97BhmihZQyBI0KEpiYmIQUEIqf5jssOavNBZhKuoIeL7L62u9RwCQaGokGhoJFLWnDgzKNGx3Zmhmgkug2YQZBPIkRQ2UAA5c6PKedU86oyeC%2BLFa5xv8Aag6oVgHBdiOeYpejaQOHaBhzIn1BXSR9I%2FFdJH0j8V0mQCLA0W%2BQptA05kT6grpE8g0ew4L65mtxV62IIzk%2ByY1nMkzpulHecbzn7dws4OXHlUWLrNOcSPRCLOESCpJm0EjyuQ63C2R3hM9EzzCZ5hM8wmeiEFBYpkjvCZ5j3QgCo3gbcL1s9SPdC1XH9yjvDIkSPRMKYVbOATZaeYTIH21oJB7ls%2FT3Qg1E6LZye8e6EH9C4hPOpTzqU86lPOpTzqUZKyo4jxTifHgcQnnWpTynnWjiE4mjiE86ngMJ51P6hknkmGf8M%2F%2FZ" alt="logo"></a></header><article id="article" class="info"><h1 class="title">Обнаружен подозрительный трафик</h1></article><section class="snippet"><p>Просмотрите возможные причины ошибки по ссылке ниже. Если ошибка повторяется, напишите <a href="mailto:support@cian.ru?subject=Нет доступа к сайту и/или приложению&body=%0D%0A%0D%0A%0D%0A%0D%0AIP-адрес: 46.8.110.92%0D%0AID запроса: 9f4b43ee-bd7e-4b91-acde-639dac97a366%0D%0AКод страницы: cian_waf_block">в службу поддержки</a> или на почту <a href="mailto:support@cian.ru">support@cian.ru</a>.</p><p>Укажите в письме эти данные:</p><ul><li>IP-адрес: 46.8.110.92</li><li>ID запроса: 9f4b43ee-bd7e-4b91-acde-639dac97a366</li><li>Код страницы: cian_waf_block</li></ul><script>!function(e,a,t,n){e[n]=e[n]||[],e[n].push({"gtm.start":(new Date).getTime(),event:"gtm.js"});var c=a.getElementsByTagName(t)[0],g=a.createElement(t);g.async=!0,g.src="https://www.googletagmanager.com/gtm.js?id=GTM-KC2KW5",c.parentNode.insertBefore(g,c)}(window,document,"script","dataLayer"),(e=>{"ca"in e||(e.ca=async(...a)=>{e.ca.q.push(a)},e.ca.q=[],e.ca.version="2.0.2-CD-269946.0")})(window),ca("pageview",{category:"page",action:"newpage",label:"open",page:{pageType:"VPNBlock",extra:{antibot:!0}}})</script><noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-KC2KW5" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript><script src="https://static.cdn-cian.ru/frontend/frontend-sdk/analytics/ca.v1.js" data-ab-use-base-url="https://api.cian.ru/ab-use/" data-ebc-analytics-base-url="https://api.cian.ru/ebc-analytics/" data-sopr-base-url="https://sopr-</p></div></body></html>
|
||||||
143
tradein-mvp/backend/tests/test_2767_cian_waf_block.py
Normal file
143
tradein-mvp/backend/tests/test_2767_cian_waf_block.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"""#2767 (вторая итерация) — то, что 8 суток принимали за смену разметки, было блоком.
|
||||||
|
|
||||||
|
Живая проба 2026-08-09 (сайдкар → camoufox → узел пула, тот же тракт, что у задачи)
|
||||||
|
на одной и той же странице `zhk-pihtovyy-ekb-i.cian.ru`:
|
||||||
|
|
||||||
|
узел #1 asocks-residential-1 → 374 168 Б, cian_waf_block, initialState нет
|
||||||
|
узел #9 asocks-mobile-1 → 542 021 Б, initialState ЕСТЬ
|
||||||
|
узел #10 asocks-mobile-2 → 557 235 Б, initialState ЕСТЬ
|
||||||
|
узел #11 asocks-mobile-3 → 557 163 Б, initialState ЕСТЬ
|
||||||
|
|
||||||
|
Разметка не менялась: ТЕКУЩИЙ разбор находит состояние на трёх узлах из четырёх.
|
||||||
|
Ломался тракт: `fetch_newbuilding` была единственным cian-путём без proxy_provider,
|
||||||
|
поэтому всегда выходила через один env-узел сайдкара — как раз тот, чей exit-IP
|
||||||
|
Циан забанил.
|
||||||
|
|
||||||
|
Фикстура `cian_waf_block_zhk_page.html` — НАСТОЯЩИЙ ответ прода (обрезаны инлайн-стили
|
||||||
|
и часть аналитики; текст блока, код страницы и разметка сохранены байт в байт).
|
||||||
|
|
||||||
|
Все три проверки красные на коде до правки:
|
||||||
|
1. страница блока опознаётся (старый список маркеров отвечал на неё `none`);
|
||||||
|
2. блок репортится в пул как бан узла (старый код не мог: lease не брался вовсе);
|
||||||
|
3. proxy_provider доезжает до фабрики фетчера (старый код такого аргумента не знал).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
sys.modules.setdefault("weasyprint", MagicMock())
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
|
||||||
|
_blocked_by,
|
||||||
|
_describe_parse_miss,
|
||||||
|
fetch_newbuilding,
|
||||||
|
)
|
||||||
|
|
||||||
|
_FIXTURE = Path(__file__).parent / "fixtures" / "cian_waf_block_zhk_page.html"
|
||||||
|
|
||||||
|
# Список маркеров ДО правки (#2768). Держим здесь дословно: тест ниже показывает, что
|
||||||
|
# страница блока не содержала ни одного из них — то есть «antibot_markers=none» был не
|
||||||
|
# «защиты нет», а «список не знал этой подписи».
|
||||||
|
_MARKERS_BEFORE_FIX = (
|
||||||
|
"captcha",
|
||||||
|
"qrator",
|
||||||
|
"ddos-guard",
|
||||||
|
"cf-chl",
|
||||||
|
"доступ ограничен",
|
||||||
|
"вы не робот",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def waf_html() -> str:
|
||||||
|
return _FIXTURE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_waf_page_carries_none_of_the_old_markers(waf_html: str) -> None:
|
||||||
|
"""Почему правка вообще нужна: прежний список на этой странице молчал.
|
||||||
|
|
||||||
|
Молчание защиты читалось в журнале как её вердикт «чисто» — и увело разбор
|
||||||
|
в гипотезу «Циан сменил разметку» на восемь суток.
|
||||||
|
"""
|
||||||
|
lowered = waf_html.lower()
|
||||||
|
assert [m for m in _MARKERS_BEFORE_FIX if m in lowered] == []
|
||||||
|
# При этом страница прямо называет себя блоком — и словами, и кодом, и в аналитике.
|
||||||
|
assert "cian_waf_block" in waf_html
|
||||||
|
assert "Обнаружен подозрительный трафик" in waf_html
|
||||||
|
assert 'pageType:"VPNBlock"' in waf_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_parse_miss_names_the_waf_block(waf_html: str) -> None:
|
||||||
|
"""Строка отказа обязана НАЗВАТЬ блок. Красная на старом коде: там `none`."""
|
||||||
|
described = _describe_parse_miss(waf_html)
|
||||||
|
assert "cian_waf_block" in described
|
||||||
|
assert "antibot_markers=none" not in described
|
||||||
|
assert f"html_len={len(waf_html)}" in described
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocked_by_stays_silent_on_a_normal_card() -> None:
|
||||||
|
"""Ложных срабатываний быть не должно: обычная разметка ЖК блоком не считается."""
|
||||||
|
assert _blocked_by("<html><body>ЖК «Пихтовый», сдача 2027</body></html>") is None
|
||||||
|
|
||||||
|
|
||||||
|
def _spy_fetcher(html: str) -> MagicMock:
|
||||||
|
fetcher = MagicMock()
|
||||||
|
fetcher.fetch = AsyncMock(return_value=html)
|
||||||
|
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
|
||||||
|
fetcher.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
return fetcher
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_waf_block_is_reported_as_a_ban_of_the_node(monkeypatch, waf_html, caplog) -> None:
|
||||||
|
"""Блок → `report_ban`, пока lease ещё жив.
|
||||||
|
|
||||||
|
Без этого пул продолжает выдавать сожжённый узел, и следующий дом идёт с того же
|
||||||
|
IP — ровно то, что происходило 200 попыток подряд.
|
||||||
|
"""
|
||||||
|
fetcher = _spy_fetcher(waf_html)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"scraper_kit.providers.cian.newbuilding.build_browser_fetcher",
|
||||||
|
MagicMock(return_value=fetcher),
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"):
|
||||||
|
result = await fetch_newbuilding(
|
||||||
|
"https://zhk-pihtovyy-ekb-i.cian.ru",
|
||||||
|
config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
fetcher.report_ban.assert_called_once()
|
||||||
|
assert "cian_waf_block" in fetcher.report_ban.call_args.args[0]
|
||||||
|
# Бан репортится ДО выхода из контекста — иначе lease уже отпущен и вызов no-op.
|
||||||
|
assert fetcher.__aexit__.await_count == 1
|
||||||
|
assert "cian_waf_block" in "\n".join(r.getMessage() for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_proxy_provider_reaches_the_fetcher_factory(monkeypatch, waf_html) -> None:
|
||||||
|
"""Провод целиком: provider из задачи → build_browser_fetcher → пул.
|
||||||
|
|
||||||
|
Красный на старом коде дважды: аргумента `proxy_provider` там не было, и фабрика
|
||||||
|
для этого провайдера не вызывалась вовсе (BrowserFetcher конструировался вручную,
|
||||||
|
без пула).
|
||||||
|
"""
|
||||||
|
factory = MagicMock(return_value=_spy_fetcher(waf_html))
|
||||||
|
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_browser_fetcher", factory)
|
||||||
|
provider = object()
|
||||||
|
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000")
|
||||||
|
|
||||||
|
await fetch_newbuilding(
|
||||||
|
"https://zhk-pihtovyy-ekb-i.cian.ru", config=config, proxy_provider=provider
|
||||||
|
)
|
||||||
|
|
||||||
|
factory.assert_called_once_with(config, "cian", proxy_provider=provider)
|
||||||
|
|
@ -61,7 +61,7 @@ def test_describe_parse_miss_names_found_markers() -> None:
|
||||||
|
|
||||||
|
|
||||||
def _spy_browser_fetcher(html: str) -> MagicMock:
|
def _spy_browser_fetcher(html: str) -> MagicMock:
|
||||||
"""BrowserFetcher-подмена: async-CM, чей .fetch() отдаёт заданный html."""
|
"""Подмена фабрики фетчера: async-CM, чей .fetch() отдаёт заданный html."""
|
||||||
fetcher = MagicMock()
|
fetcher = MagicMock()
|
||||||
fetcher.fetch = AsyncMock(return_value=html)
|
fetcher.fetch = AsyncMock(return_value=html)
|
||||||
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
|
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
|
||||||
|
|
@ -71,17 +71,24 @@ def _spy_browser_fetcher(html: str) -> MagicMock:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_newbuilding_logs_page_size_on_parse_miss(monkeypatch, caplog) -> None:
|
async def test_fetch_newbuilding_logs_page_size_on_parse_miss(monkeypatch, caplog) -> None:
|
||||||
"""Отказ разбора обязан писать РАЗМЕР полученной страницы — то самое число, которое
|
"""Отказ разбора обязан писать РАЗМЕР полученной страницы.
|
||||||
разделяет «изменилась разметка» и «защитная заглушка» (иначе log бесполезен)."""
|
|
||||||
|
Размер один гипотезы уже НЕ разделяет (страница блока Циана весит 374 КБ — см.
|
||||||
|
test_2767_cian_waf_block.py), но остаётся обязательным фактом в строке отказа.
|
||||||
|
"""
|
||||||
shell = "<html><body>Доступ ограничен</body></html>" # короткая заглушка
|
shell = "<html><body>Доступ ограничен</body></html>" # короткая заглушка
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"scraper_kit.providers.cian.newbuilding.BrowserFetcher", _spy_browser_fetcher(shell)
|
"scraper_kit.providers.cian.newbuilding.build_browser_fetcher",
|
||||||
|
_spy_browser_fetcher(shell),
|
||||||
)
|
)
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"):
|
with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"):
|
||||||
result = await fetch_newbuilding(
|
result = await fetch_newbuilding(
|
||||||
"https://zhk-tihiy-centr-ekb-i.cian.ru",
|
"https://zhk-tihiy-centr-ekb-i.cian.ru",
|
||||||
config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"),
|
config=SimpleNamespace(
|
||||||
|
browser_http_endpoint="http://tradein-browser:3000",
|
||||||
|
use_proxy_pool_browser=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
|
||||||
|
|
@ -236,9 +236,14 @@ async def test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoi
|
||||||
|
|
||||||
captured: dict[str, Any] = {}
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
async def _fake_fetch_newbuilding(zhk_url: str, *, config: Any = None) -> None:
|
async def _fake_fetch_newbuilding(
|
||||||
|
zhk_url: str, *, config: Any = None, proxy_provider: Any = None
|
||||||
|
) -> None:
|
||||||
captured["zhk_url"] = zhk_url
|
captured["zhk_url"] = zhk_url
|
||||||
captured["endpoint"] = config.browser_http_endpoint if config is not None else None
|
captured["endpoint"] = config.browser_http_endpoint if config is not None else None
|
||||||
|
# #2767: этот путь тоже обязан идти через пул прокси — до правки он, как и
|
||||||
|
# обогащение, выходил через единственный env-узел сайдкара.
|
||||||
|
captured["proxy_provider"] = proxy_provider
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
db.execute.return_value.mappings.return_value.all.return_value = [
|
db.execute.return_value.mappings.return_value.all.return_value = [
|
||||||
|
|
@ -258,6 +263,7 @@ async def test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoi
|
||||||
|
|
||||||
assert captured["zhk_url"] == "https://zhk-test-ekb-i.cian.ru/"
|
assert captured["zhk_url"] == "https://zhk-test-ekb-i.cian.ru/"
|
||||||
assert captured["endpoint"] == settings.browser_http_endpoint
|
assert captured["endpoint"] == settings.browser_http_endpoint
|
||||||
|
assert captured["proxy_provider"] is not None
|
||||||
|
|
||||||
|
|
||||||
async def test_cian_history_backfill_valuations_estimate_gets_config() -> None:
|
async def test_cian_history_backfill_valuations_estimate_gets_config() -> None:
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,16 @@ def _spy_browser_fetcher(html: str) -> MagicMock:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch):
|
async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch):
|
||||||
"""config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...)."""
|
"""config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...).
|
||||||
spy = _spy_browser_fetcher("<html></html>")
|
|
||||||
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.BrowserFetcher", spy)
|
|
||||||
|
|
||||||
config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT)
|
С #2767 путь идёт через `build_browser_fetcher` (подключение к пулу прокси), поэтому
|
||||||
|
подменяется BrowserFetcher В ФАБРИКЕ — сама фабрика при этом настоящая, и тест
|
||||||
|
проверяет всю цепочку config → фабрика → фетчер, а не контракт мока.
|
||||||
|
"""
|
||||||
|
spy = _spy_browser_fetcher("<html></html>")
|
||||||
|
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||||||
|
|
||||||
|
config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False)
|
||||||
await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", config=config)
|
await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", config=config)
|
||||||
|
|
||||||
spy.assert_called_once()
|
spy.assert_called_once()
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,12 @@ from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
from scraper_kit.cian_state_parser import extract_all_states, extract_state
|
from scraper_kit.cian_state_parser import extract_all_states, extract_state
|
||||||
from scraper_kit.providers._base import build_curl_cffi_session
|
from scraper_kit.providers._base import build_browser_fetcher, build_curl_cffi_session
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
|
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
|
||||||
|
|
||||||
from scraper_kit.contracts import ScraperConfig
|
from scraper_kit.contracts import ProxyProvider, ScraperConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -72,6 +72,16 @@ _CATPH_NEWBUILDING_SERP = (
|
||||||
|
|
||||||
# Подписи защитных страниц — подстроки, которых на нормальной ЖК-карточке нет (#2767).
|
# Подписи защитных страниц — подстроки, которых на нормальной ЖК-карточке нет (#2767).
|
||||||
# Список НАМЕРЕННО короткий: он не выносит диагноз, а называет найденное.
|
# Список НАМЕРЕННО короткий: он не выносит диагноз, а называет найденное.
|
||||||
|
#
|
||||||
|
# `cian_waf_block` добавлен по живой пробе 2026-08-09 (#2767, вторая итерация). Список
|
||||||
|
# без него ОТВЕЧАЛ «antibot_markers=none» на странице блокировки — восемь суток подряд,
|
||||||
|
# и ровно этот ответ увёл разбор в гипотезу «Циан сменил разметку». Страница блокировки
|
||||||
|
# Циана НЕ содержит ни одного прежнего маркера: заголовок «Ошибка - Циан», текст
|
||||||
|
# «Обнаружен подозрительный трафик», и единственная машинная подпись — код страницы
|
||||||
|
# `cian_waf_block` в mailto-теле для поддержки. Весит она 374 168 байт (инлайн-аналитика),
|
||||||
|
# то есть по размеру НЕОТЛИЧИМА от целой карточки — «~1 МБ значит разметка» не работает,
|
||||||
|
# различает только маркер. Молчание списка читалось как «защита проверена и не нашлась»;
|
||||||
|
# на деле список просто не знал этой подписи (см. capture в #2767).
|
||||||
_ANTIBOT_MARKERS = (
|
_ANTIBOT_MARKERS = (
|
||||||
"captcha", # покрывает и recaptcha, и smartcaptcha
|
"captcha", # покрывает и recaptcha, и smartcaptcha
|
||||||
"qrator",
|
"qrator",
|
||||||
|
|
@ -79,25 +89,38 @@ _ANTIBOT_MARKERS = (
|
||||||
"cf-chl", # Cloudflare challenge
|
"cf-chl", # Cloudflare challenge
|
||||||
"доступ ограничен",
|
"доступ ограничен",
|
||||||
"вы не робот",
|
"вы не робот",
|
||||||
|
"cian_waf_block", # WAF-блок Циана, проверено живьём 2026-08-09
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _describe_parse_miss(html: str) -> str:
|
def _describe_parse_miss(html: str) -> str:
|
||||||
"""Факты о полученной странице для строки отказа разбора: размер + найденные маркеры.
|
"""Факты о полученной странице для строки отказа разбора: размер + найденные маркеры.
|
||||||
|
|
||||||
НЕ диагноз (#2767): перечисляет найденное, причину не называет. Размер разделяет
|
НЕ диагноз (#2767): перечисляет найденное, причину не называет.
|
||||||
две гипотезы одним числом — здоровая ЖК-карточка весит 1.17 МБ и содержит
|
|
||||||
initialState (verified live 2026-06-15), поэтому ~1 МБ без состояния читается как
|
Размер САМ ПО СЕБЕ гипотезы не разделяет — это выяснилось живой пробой 2026-08-09.
|
||||||
«страница пришла целиком, изменилась разметка», а десятки килобайт — как защитная
|
Прежняя трактовка («~1 МБ = страница целиком, значит разметка; десятки килобайт =
|
||||||
заглушка (страница без состояния = подпись антибота, #972). До этой строки в логе
|
заглушка») верна только для капчи: WAF-страница Циана весит 374 КБ и от целой
|
||||||
не было ни байтов, ни признаков защиты: восемь суток по 25 предупреждений за
|
карточки (542-557 КБ) по размеру неотличима. Разделяет ИМЕННО маркер, поэтому
|
||||||
прогон не отвечали на единственный вопрос, ради которого их читают.
|
полнота `_ANTIBOT_MARKERS` здесь несущая, а не украшение.
|
||||||
"""
|
"""
|
||||||
lowered = html.lower()
|
lowered = html.lower()
|
||||||
found = [m for m in _ANTIBOT_MARKERS if m in lowered]
|
found = [m for m in _ANTIBOT_MARKERS if m in lowered]
|
||||||
return f"html_len={len(html)} antibot_markers={','.join(found) if found else 'none'}"
|
return f"html_len={len(html)} antibot_markers={','.join(found) if found else 'none'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked_by(html: str) -> str | None:
|
||||||
|
"""Найденные подписи защиты, если страница — блок; иначе None.
|
||||||
|
|
||||||
|
Отдельно от `_describe_parse_miss` потому, что у результата РАЗНЫЕ потребители:
|
||||||
|
там строка для человека в журнале, здесь — сигнал в пул прокси (`report_ban`),
|
||||||
|
после которого `acquire('cian')` перестаёт выдавать сожжённый узел.
|
||||||
|
"""
|
||||||
|
lowered = html.lower()
|
||||||
|
found = [m for m in _ANTIBOT_MARKERS if m in lowered]
|
||||||
|
return ",".join(found) if found else None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class NewbuildingEnrichment:
|
class NewbuildingEnrichment:
|
||||||
"""Result of fetch_newbuilding() — houses row + sister containers."""
|
"""Result of fetch_newbuilding() — houses row + sister containers."""
|
||||||
|
|
@ -153,12 +176,12 @@ async def fetch_newbuilding(
|
||||||
*,
|
*,
|
||||||
config: ScraperConfig | None = None,
|
config: ScraperConfig | None = None,
|
||||||
session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch
|
session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch
|
||||||
|
proxy_provider: ProxyProvider | None = None,
|
||||||
) -> NewbuildingEnrichment | None:
|
) -> NewbuildingEnrichment | None:
|
||||||
"""Fetch ЖК catalog page и extract все containers.
|
"""Fetch ЖК catalog page и extract все containers.
|
||||||
|
|
||||||
HTML страница ЖК теперь тянется через BrowserFetcher (camoufox), потому что
|
HTML страница ЖК тянется через BrowserFetcher (camoufox), потому что curl_cffi
|
||||||
curl_cffi получает страницу без initialState под анти-ботом Cian (#972).
|
получает страницу без initialState под анти-ботом Cian (#972).
|
||||||
tradein-browser верифицирован живым: 1.17 MB, initialState присутствует.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/'
|
zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/'
|
||||||
|
|
@ -168,24 +191,53 @@ async def fetch_newbuilding(
|
||||||
session: зарезервирован для обратной совместимости с вызывающими;
|
session: зарезервирован для обратной совместимости с вызывающими;
|
||||||
больше не используется для fetch страницы ЖК. Resolve-функции
|
больше не используется для fetch страницы ЖК. Resolve-функции
|
||||||
(resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi.
|
(resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi.
|
||||||
|
proxy_provider: пул браузерных прокси (#2767). None → узел берётся из env
|
||||||
|
сайдкара, как было; см. блок ниже — именно этот пропуск и стоил 8 суток.
|
||||||
|
|
||||||
Returns: NewbuildingEnrichment, or None если fetch / parse failed.
|
Returns: NewbuildingEnrichment, or None если fetch / parse failed.
|
||||||
"""
|
"""
|
||||||
# #2362 (F4b): НЕ переведено на _base.build_browser_fetcher() намеренно.
|
# #2160 P4 wiring gap, закрыт по живой пробе 2026-08-09 (#2767).
|
||||||
# build_browser_fetcher() требует ScraperConfig mandatory (без default) — это
|
#
|
||||||
# весь смысл фабрики (footgun-класс #2322/#2330). Но `config` здесь ЗАКОННО
|
# До этой правки здесь конструировался BrowserFetcher(source="cian", endpoint=...)
|
||||||
# Optional: admin.py debug-роут и test_scraper_kit_newbuilding_endpoint.py
|
# БЕЗ proxy_provider — единственный cian-путь мимо пула (serp.py всегда шёл через
|
||||||
# (test_cian_fetch_newbuilding_endpoint_none_without_config) вызывают
|
# build_browser_fetcher(..., proxy_provider=...)). Следствие: обогащение всегда
|
||||||
# fetch_newbuilding() БЕЗ config= и ожидают graceful endpoint=None (не крash).
|
# выходило через ОДИН env-узел сайдкара (SCRAPER_PROXY_URL = узел пула #1,
|
||||||
# Подмена на build_browser_fetcher(config, ...) уронила бы AttributeError на
|
# asocks-residential-1). Циан забанил его exit-IP, и дальше не помогало ничто:
|
||||||
# config=None вместо текущего degrade-to-None — реальная регрессия поведения,
|
# `report_ban` без lease — no-op, ротация без пула невозможна, 25 попыток за ночь
|
||||||
# а не чистый call-site swap. Оставлено как есть; см. PR #2362 caveat notes.
|
# уходили в один и тот же сожжённый адрес.
|
||||||
endpoint = config.browser_http_endpoint if config is not None else None
|
#
|
||||||
async with BrowserFetcher(source="cian", endpoint=endpoint) as browser:
|
# Замер 2026-08-09, одна и та же страница zhk-pihtovyy-ekb-i.cian.ru, один заход:
|
||||||
|
# узел #1 asocks-residential-1 → 374 168 Б, cian_waf_block, initialState нет
|
||||||
|
# узел #9 asocks-mobile-1 → 542 021 Б, initialState ЕСТЬ
|
||||||
|
# узел #10 asocks-mobile-2 → 557 235 Б, initialState ЕСТЬ
|
||||||
|
# узел #11 asocks-mobile-3 → 557 163 Б, initialState ЕСТЬ
|
||||||
|
# Разметка не менялась: MFE 'newbuilding-card-desktop-frontend'/'initialState'
|
||||||
|
# разобрался ТЕКУЩИМ кодом на всех трёх мобильных узлах. Ломался тракт, не разбор.
|
||||||
|
#
|
||||||
|
# config=None остаётся законным (admin.py debug-роут и
|
||||||
|
# test_cian_fetch_newbuilding_endpoint_none_without_config зовут без config= и
|
||||||
|
# ждут graceful endpoint=None, а не AttributeError), поэтому фабрика применяется
|
||||||
|
# только когда config есть — она требует ScraperConfig mandatory by design.
|
||||||
|
if config is not None:
|
||||||
|
browser = build_browser_fetcher(config, "cian", proxy_provider=proxy_provider)
|
||||||
|
else:
|
||||||
|
browser = BrowserFetcher(source="cian", endpoint=None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# ponytail: lease берётся на ОДИН дом (фетчер живёт внутри этой функции), то есть
|
||||||
|
# прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый).
|
||||||
|
# При паузе 18 с между домами это терпимо и вдобавок само по себе ротация IP.
|
||||||
|
# Понадобится дешевле — поднимать фетчер в вызывающий цикл и передавать сюда.
|
||||||
|
async with browser:
|
||||||
html = await browser.fetch(zhk_url)
|
html = await browser.fetch(zhk_url)
|
||||||
|
# Блок распознаётся ПОКА lease жив: после выхода из `async with` узел уже
|
||||||
|
# отпущен и `report_ban` стал бы no-op. Пул после этого не выдаёт узел под
|
||||||
|
# source='cian' (scrape_proxy_source_bans), и следующий дом идёт с другого IP.
|
||||||
|
blocked = _blocked_by(html)
|
||||||
|
if blocked is not None:
|
||||||
|
browser.report_ban(f"cian newbuilding: {blocked}")
|
||||||
|
|
||||||
# Cian ЖК-карточка: MFE 'newbuilding-card-desktop-frontend', key 'initialState'
|
# Cian ЖК-карточка: MFE 'newbuilding-card-desktop-frontend', key 'initialState'
|
||||||
# (verified live 2026-06-15).
|
# (verified live 2026-06-15; перепроверено живьём 2026-08-09 — разметка та же).
|
||||||
mfe = "newbuilding-card-desktop-frontend"
|
mfe = "newbuilding-card-desktop-frontend"
|
||||||
|
|
||||||
nb_state = extract_state(html, mfe=mfe, key="initialState")
|
nb_state = extract_state(html, mfe=mfe, key="initialState")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue