fix(tradein/cian): обогащение ЖК падало не на разметке, а на сожжённом узле (#2767) #2798

Merged
bot-backend merged 1 commit from fix/2767-cian-newbuilding-markup into main 2026-08-09 17:38:40 +00:00
9 changed files with 278 additions and 40 deletions

View file

@ -1953,7 +1953,9 @@ async def scrape_cian_newbuilding(
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:
raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}")

View file

@ -43,7 +43,11 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
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
logger = logging.getLogger(__name__)
@ -230,7 +234,13 @@ async def backfill_cian_history(
enrichment = None
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:
logger.warning(
"cian_newbuilding fetch failed for house_id=%s url=%s: %s",

View file

@ -399,9 +399,15 @@ async def backfill_newbuilding_enrichment(
save_newbuilding_enrichment,
)
from app.services.scraper_adapters import RealScraperConfig
from app.services.scraper_adapters import RealProxyProvider, 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()
t0 = time.time()
@ -525,7 +531,9 @@ async def backfill_newbuilding_enrichment(
# ── Fetch (network; anti-bot surface) ──────────────────────────────
enrichment = None
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:
logger.warning(
"newbuilding fetch failed house_id=%s url=%s: %s", house_id, zhk_url, exc
@ -535,8 +543,12 @@ async def backfill_newbuilding_enrichment(
continue
if enrichment is None:
# Без «(captcha / parse miss?)» (#2767): догадка автора кода в тексте лога
# читается дальше как факт и один раз уже увела диагноз не туда. Причина
# печатается строкой ВЫШЕ, в самом месте отказа (html_len + antibot_markers).
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,
zhk_url,
)

View 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=Нет доступа к сайту и/или приложению&amp;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">в&nbsp;службу поддержки</a> или на&nbsp;почту <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>

View 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)

View file

@ -61,7 +61,7 @@ def test_describe_parse_miss_names_found_markers() -> None:
def _spy_browser_fetcher(html: str) -> MagicMock:
"""BrowserFetcher-подмена: async-CM, чей .fetch() отдаёт заданный html."""
"""Подмена фабрики фетчера: async-CM, чей .fetch() отдаёт заданный html."""
fetcher = MagicMock()
fetcher.fetch = AsyncMock(return_value=html)
fetcher.__aenter__ = AsyncMock(return_value=fetcher)
@ -71,17 +71,24 @@ def _spy_browser_fetcher(html: str) -> MagicMock:
@pytest.mark.asyncio
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>" # короткая заглушка
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"):
result = await fetch_newbuilding(
"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

View file

@ -236,9 +236,14 @@ async def test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoi
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["endpoint"] = config.browser_http_endpoint if config is not None else None
# #2767: этот путь тоже обязан идти через пул прокси — до правки он, как и
# обогащение, выходил через единственный env-узел сайдкара.
captured["proxy_provider"] = proxy_provider
db = MagicMock()
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["endpoint"] == settings.browser_http_endpoint
assert captured["proxy_provider"] is not None
async def test_cian_history_backfill_valuations_estimate_gets_config() -> None:

View file

@ -59,11 +59,16 @@ def _spy_browser_fetcher(html: str) -> MagicMock:
@pytest.mark.asyncio
async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch):
"""config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...)."""
spy = _spy_browser_fetcher("<html></html>")
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.BrowserFetcher", spy)
"""config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...).
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)
spy.assert_called_once()

View file

@ -24,12 +24,12 @@ from typing import TYPE_CHECKING, Any
from scraper_kit.browser_fetcher import BrowserFetcher
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:
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__)
@ -72,6 +72,16 @@ _CATPH_NEWBUILDING_SERP = (
# Подписи защитных страниц — подстроки, которых на нормальной ЖК-карточке нет (#2767).
# Список НАМЕРЕННО короткий: он не выносит диагноз, а называет найденное.
#
# `cian_waf_block` добавлен по живой пробе 2026-08-09 (#2767, вторая итерация). Список
# без него ОТВЕЧАЛ «antibot_markers=none» на странице блокировки — восемь суток подряд,
# и ровно этот ответ увёл разбор в гипотезу «Циан сменил разметку». Страница блокировки
# Циана НЕ содержит ни одного прежнего маркера: заголовок «Ошибка - Циан», текст
# «Обнаружен подозрительный трафик», и единственная машинная подпись — код страницы
# `cian_waf_block` в mailto-теле для поддержки. Весит она 374 168 байт (инлайн-аналитика),
# то есть по размеру НЕОТЛИЧИМА от целой карточки — «~1 МБ значит разметка» не работает,
# различает только маркер. Молчание списка читалось как «защита проверена и не нашлась»;
# на деле список просто не знал этой подписи (см. capture в #2767).
_ANTIBOT_MARKERS = (
"captcha", # покрывает и recaptcha, и smartcaptcha
"qrator",
@ -79,25 +89,38 @@ _ANTIBOT_MARKERS = (
"cf-chl", # Cloudflare challenge
"доступ ограничен",
"вы не робот",
"cian_waf_block", # WAF-блок Циана, проверено живьём 2026-08-09
)
def _describe_parse_miss(html: str) -> str:
"""Факты о полученной странице для строки отказа разбора: размер + найденные маркеры.
НЕ диагноз (#2767): перечисляет найденное, причину не называет. Размер разделяет
две гипотезы одним числом здоровая ЖК-карточка весит 1.17 МБ и содержит
initialState (verified live 2026-06-15), поэтому ~1 МБ без состояния читается как
«страница пришла целиком, изменилась разметка», а десятки килобайт как защитная
заглушка (страница без состояния = подпись антибота, #972). До этой строки в логе
не было ни байтов, ни признаков защиты: восемь суток по 25 предупреждений за
прогон не отвечали на единственный вопрос, ради которого их читают.
НЕ диагноз (#2767): перечисляет найденное, причину не называет.
Размер САМ ПО СЕБЕ гипотезы не разделяет это выяснилось живой пробой 2026-08-09.
Прежняя трактовка («~1 МБ = страница целиком, значит разметка; десятки килобайт =
заглушка») верна только для капчи: WAF-страница Циана весит 374 КБ и от целой
карточки (542-557 КБ) по размеру неотличима. Разделяет ИМЕННО маркер, поэтому
полнота `_ANTIBOT_MARKERS` здесь несущая, а не украшение.
"""
lowered = html.lower()
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'}"
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
class NewbuildingEnrichment:
"""Result of fetch_newbuilding() — houses row + sister containers."""
@ -153,12 +176,12 @@ async def fetch_newbuilding(
*,
config: ScraperConfig | None = None,
session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch
proxy_provider: ProxyProvider | None = None,
) -> NewbuildingEnrichment | None:
"""Fetch ЖК catalog page и extract все containers.
HTML страница ЖК теперь тянется через BrowserFetcher (camoufox), потому что
curl_cffi получает страницу без initialState под анти-ботом Cian (#972).
tradein-browser верифицирован живым: 1.17 MB, initialState присутствует.
HTML страница ЖК тянется через BrowserFetcher (camoufox), потому что curl_cffi
получает страницу без initialState под анти-ботом Cian (#972).
Args:
zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/'
@ -168,24 +191,53 @@ async def fetch_newbuilding(
session: зарезервирован для обратной совместимости с вызывающими;
больше не используется для fetch страницы ЖК. Resolve-функции
(resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi.
proxy_provider: пул браузерных прокси (#2767). None → узел берётся из env
сайдкара, как было; см. блок ниже именно этот пропуск и стоил 8 суток.
Returns: NewbuildingEnrichment, or None если fetch / parse failed.
"""
# #2362 (F4b): НЕ переведено на _base.build_browser_fetcher() намеренно.
# build_browser_fetcher() требует ScraperConfig mandatory (без default) — это
# весь смысл фабрики (footgun-класс #2322/#2330). Но `config` здесь ЗАКОННО
# Optional: admin.py debug-роут и test_scraper_kit_newbuilding_endpoint.py
# (test_cian_fetch_newbuilding_endpoint_none_without_config) вызывают
# fetch_newbuilding() БЕЗ config= и ожидают graceful endpoint=None (не крash).
# Подмена на build_browser_fetcher(config, ...) уронила бы AttributeError на
# config=None вместо текущего degrade-to-None — реальная регрессия поведения,
# а не чистый 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:
# #2160 P4 wiring gap, закрыт по живой пробе 2026-08-09 (#2767).
#
# До этой правки здесь конструировался BrowserFetcher(source="cian", endpoint=...)
# БЕЗ proxy_provider — единственный cian-путь мимо пула (serp.py всегда шёл через
# build_browser_fetcher(..., proxy_provider=...)). Следствие: обогащение всегда
# выходило через ОДИН env-узел сайдкара (SCRAPER_PROXY_URL = узел пула #1,
# asocks-residential-1). Циан забанил его exit-IP, и дальше не помогало ничто:
# `report_ban` без lease — no-op, ротация без пула невозможна, 25 попыток за ночь
# уходили в один и тот же сожжённый адрес.
#
# Замер 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)
# Блок распознаётся ПОКА 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'
# (verified live 2026-06-15).
# (verified live 2026-06-15; перепроверено живьём 2026-08-09 — разметка та же).
mfe = "newbuilding-card-desktop-frontend"
nb_state = extract_state(html, mfe=mfe, key="initialState")