fix(tradein/cian): обогащение ЖК падало не на разметке, а на сожжённом узле (#2767) (#2798)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 1m33s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 1m33s
This commit is contained in:
parent
f1f2bca2e9
commit
f3bcb1a25f
9 changed files with 278 additions and 40 deletions
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
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:
|
||||
"""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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue