Merge pull request 'Скрапер: newbuilding-провайдеры без config больше не уходят мимо пула прокси, IMV Авито строит сессию общей фабрикой' (#3562) from fix/kit-providers-config into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
This commit is contained in:
commit
10c80e9e1d
9 changed files with 105 additions and 170 deletions
|
|
@ -36,6 +36,7 @@ import pytest
|
||||||
# Mirror tests/test_scraper_kit_pricehistory_session_parity.py.
|
# Mirror tests/test_scraper_kit_pricehistory_session_parity.py.
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from scraper_kit.providers._base import DEFAULT_IMPERSONATE, DOCUMENT_HEADERS
|
||||||
from scraper_kit.providers.avito.imv import (
|
from scraper_kit.providers.avito.imv import (
|
||||||
IMVAddressNotFoundError as KitIMVAddressNotFoundError,
|
IMVAddressNotFoundError as KitIMVAddressNotFoundError,
|
||||||
)
|
)
|
||||||
|
|
@ -102,7 +103,7 @@ def test_kit_evaluate_via_imv_without_config_drops_proxy(monkeypatch: pytest.Mon
|
||||||
captured.update(kwargs)
|
captured.update(kwargs)
|
||||||
return mock_session
|
return mock_session
|
||||||
|
|
||||||
with patch("curl_cffi.requests.AsyncSession", _fake_session):
|
with patch("scraper_kit.providers._base.AsyncSession", _fake_session):
|
||||||
asyncio.run(_call_kit_own_session(config=None))
|
asyncio.run(_call_kit_own_session(config=None))
|
||||||
|
|
||||||
assert captured.get("proxies") is None
|
assert captured.get("proxies") is None
|
||||||
|
|
@ -123,10 +124,15 @@ def test_kit_evaluate_via_imv_with_config_uses_proxy(monkeypatch: pytest.MonkeyP
|
||||||
captured.update(kwargs)
|
captured.update(kwargs)
|
||||||
return mock_session
|
return mock_session
|
||||||
|
|
||||||
with patch("curl_cffi.requests.AsyncSession", _fake_session):
|
with patch("scraper_kit.providers._base.AsyncSession", _fake_session):
|
||||||
asyncio.run(_call_kit_own_session(config=RealScraperConfig()))
|
asyncio.run(_call_kit_own_session(config=RealScraperConfig()))
|
||||||
|
|
||||||
assert captured.get("proxies") == {
|
assert captured.get("proxies") == {
|
||||||
"http": "http://test-proxy.local:8080",
|
"http": "http://test-proxy.local:8080",
|
||||||
"https": "http://test-proxy.local:8080",
|
"https": "http://test-proxy.local:8080",
|
||||||
}
|
}
|
||||||
|
# #2386: сессия строится общей build_document_session — параметры те же, что были у
|
||||||
|
# собственной (impersonate/timeout=25/document-заголовки).
|
||||||
|
assert captured.get("impersonate") == DEFAULT_IMPERSONATE
|
||||||
|
assert captured.get("timeout") == 25
|
||||||
|
assert captured.get("headers") == DOCUMENT_HEADERS
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
# DATABASE_URL required by config before any app import.
|
# DATABASE_URL required by config before any app import.
|
||||||
|
|
@ -28,6 +29,8 @@ from scraper_kit.providers.cian.newbuilding import ( # noqa: E402
|
||||||
resolve_cian_zhk_url_via_search,
|
resolve_cian_zhk_url_via_search,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# config обязателен (#2385); на пути с переданной session он не читается.
|
||||||
|
_CONFIG = SimpleNamespace(cian_proxy_url=None)
|
||||||
_FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "cian_catph_newbuilding_serp.html"
|
_FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "cian_catph_newbuilding_serp.html"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -114,7 +117,7 @@ def test_extract_zhk_url_no_match_returns_none() -> None:
|
||||||
async def test_resolve_via_search_happy_path() -> None:
|
async def test_resolve_via_search_happy_path() -> None:
|
||||||
"""HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed."""
|
"""HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed."""
|
||||||
session = _mock_session(status_code=200, text=_serp_fixture())
|
session = _mock_session(status_code=200, text=_serp_fixture())
|
||||||
url = await resolve_cian_zhk_url_via_search(48853, session=session)
|
url = await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session)
|
||||||
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
|
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
|
||||||
# caller owns a passed-in session — resolver must NOT close it.
|
# caller owns a passed-in session — resolver must NOT close it.
|
||||||
session.close.assert_not_awaited()
|
session.close.assert_not_awaited()
|
||||||
|
|
@ -128,14 +131,14 @@ async def test_resolve_via_search_happy_path() -> None:
|
||||||
async def test_resolve_via_search_no_match_returns_none() -> None:
|
async def test_resolve_via_search_no_match_returns_none() -> None:
|
||||||
"""HTTP 200 but no zhk-slug in the body → None (empty SERP / markup drift)."""
|
"""HTTP 200 but no zhk-slug in the body → None (empty SERP / markup drift)."""
|
||||||
session = _mock_session(status_code=200, text="<html>no results</html>")
|
session = _mock_session(status_code=200, text="<html>no results</html>")
|
||||||
assert await resolve_cian_zhk_url_via_search(999, session=session) is None
|
assert await resolve_cian_zhk_url_via_search(999, config=_CONFIG, session=session) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_via_search_non_200_returns_none() -> None:
|
async def test_resolve_via_search_non_200_returns_none() -> None:
|
||||||
"""A non-200 (block / 404) → None without attempting extraction."""
|
"""A non-200 (block / 404) → None without attempting extraction."""
|
||||||
session = _mock_session(status_code=429, text="too many requests")
|
session = _mock_session(status_code=429, text="too many requests")
|
||||||
assert await resolve_cian_zhk_url_via_search(48853, session=session) is None
|
assert await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -144,4 +147,4 @@ async def test_resolve_via_search_swallows_request_error() -> None:
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
session.get = AsyncMock(side_effect=ConnectionError("proxy refused"))
|
session.get = AsyncMock(side_effect=ConnectionError("proxy refused"))
|
||||||
session.close = AsyncMock()
|
session.close = AsyncMock()
|
||||||
assert await resolve_cian_zhk_url_via_search(48853, session=session) is None
|
assert await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) is None
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ def test_empty_pool_in_production_degrades_without_imv(
|
||||||
# патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта
|
# патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта
|
||||||
# оставило бы тест зелёным против фантома.
|
# оставило бы тест зелёным против фантома.
|
||||||
patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider),
|
patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider),
|
||||||
patch("curl_cffi.requests.AsyncSession", _no_http),
|
patch("scraper_kit.providers._base.AsyncSession", _no_http),
|
||||||
):
|
):
|
||||||
result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4")
|
result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4")
|
||||||
|
|
||||||
|
|
@ -213,7 +213,7 @@ def _run_with_pool(
|
||||||
with (
|
with (
|
||||||
patch.object(estimator, "RealProxyProvider", lambda: provider),
|
patch.object(estimator, "RealProxyProvider", lambda: provider),
|
||||||
patch.object(estimator, "save_imv_evaluation", return_value=1),
|
patch.object(estimator, "save_imv_evaluation", return_value=1),
|
||||||
patch("curl_cffi.requests.AsyncSession", lambda *a, **kw: session),
|
patch("scraper_kit.providers._base.AsyncSession", lambda *a, **kw: session),
|
||||||
# Транспорт нам не интересен — проверяем жизненный цикл lease вокруг него.
|
# Транспорт нам не интересен — проверяем жизненный цикл lease вокруг него.
|
||||||
patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()),
|
patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()),
|
||||||
patch(
|
patch(
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ through `build_curl_cffi_session` — so a missing `config=` does NOT raise like
|
||||||
(non-proxied) connection (`cian_proxy_url` dropped). That is much harder to notice in
|
(non-proxied) connection (`cian_proxy_url` dropped). That is much harder to notice in
|
||||||
prod (no crash, just quietly unproxied traffic that anti-bot can throttle/block), so a
|
prod (no crash, just quietly unproxied traffic that anti-bot can throttle/block), so a
|
||||||
caller that forgets `config=` here gets no signal at all.
|
caller that forgets `config=` here gets no signal at all.
|
||||||
|
|
||||||
|
#2385: `config` стал обязательным во всех четырёх точках входа. Прежние кейсы
|
||||||
|
`*_without_config` (ждали endpoint=None / proxy_url=None) заменены одним тестом
|
||||||
|
«без config — TypeError, фетчер и сессия не строятся».
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -42,6 +46,7 @@ from scraper_kit.providers.yandex.newbuilding import (
|
||||||
|
|
||||||
_TEST_ENDPOINT = "http://test-tradein-browser:9009/fetch"
|
_TEST_ENDPOINT = "http://test-tradein-browser:9009/fetch"
|
||||||
_TEST_PROXY = "http://mobile-proxy.example:8080"
|
_TEST_PROXY = "http://mobile-proxy.example:8080"
|
||||||
|
_CONFIG = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False)
|
||||||
|
|
||||||
|
|
||||||
def _spy_browser_fetcher(html: str) -> MagicMock:
|
def _spy_browser_fetcher(html: str) -> MagicMock:
|
||||||
|
|
@ -77,21 +82,6 @@ async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch):
|
||||||
assert kwargs.get("endpoint") == _TEST_ENDPOINT
|
assert kwargs.get("endpoint") == _TEST_ENDPOINT
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cian_fetch_newbuilding_endpoint_none_without_config(monkeypatch):
|
|
||||||
"""Без config= (backward-compat) endpoint=None передаётся явным kwarg'ом — сигнатура
|
|
||||||
больше не роняет TypeError на отсутствующем обязательном параметре конструктора."""
|
|
||||||
spy = _spy_browser_fetcher("<html></html>")
|
|
||||||
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.BrowserFetcher", spy)
|
|
||||||
|
|
||||||
await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/")
|
|
||||||
|
|
||||||
spy.assert_called_once()
|
|
||||||
_, kwargs = spy.call_args
|
|
||||||
assert kwargs.get("source") == "cian"
|
|
||||||
assert kwargs.get("endpoint") is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── yandex.newbuilding.YandexNewbuildingScraper.fetch_jk ─────────────────────
|
# ── yandex.newbuilding.YandexNewbuildingScraper.fetch_jk ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -115,21 +105,6 @@ async def test_yandex_fetch_jk_passes_endpoint_from_config(monkeypatch):
|
||||||
assert kwargs.get("endpoint") == _TEST_ENDPOINT
|
assert kwargs.get("endpoint") == _TEST_ENDPOINT
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_yandex_fetch_jk_endpoint_none_without_config(monkeypatch):
|
|
||||||
"""Без config= (backward-compat) endpoint=None передаётся явным kwarg'ом."""
|
|
||||||
spy = _spy_browser_fetcher("<html></html>")
|
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
|
||||||
|
|
||||||
scraper = YandexNewbuildingScraper()
|
|
||||||
await scraper.fetch_jk(jk_slug="tatlin", jk_id="1592987")
|
|
||||||
|
|
||||||
spy.assert_called_once()
|
|
||||||
_, kwargs = spy.call_args
|
|
||||||
assert kwargs.get("source") == "yandex"
|
|
||||||
assert kwargs.get("endpoint") is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── yandex.newbuilding.resolve_yandex_jk_slug ────────────────────────────────
|
# ── yandex.newbuilding.resolve_yandex_jk_slug ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -151,20 +126,6 @@ async def test_resolve_yandex_jk_slug_passes_endpoint_from_config(monkeypatch):
|
||||||
assert kwargs.get("endpoint") == _TEST_ENDPOINT
|
assert kwargs.get("endpoint") == _TEST_ENDPOINT
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resolve_yandex_jk_slug_endpoint_none_without_config(monkeypatch):
|
|
||||||
"""Без config= (backward-compat) endpoint=None передаётся явным kwarg'ом."""
|
|
||||||
spy = _spy_browser_fetcher("<html></html>")
|
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
|
||||||
|
|
||||||
await resolve_yandex_jk_slug("1592987")
|
|
||||||
|
|
||||||
spy.assert_called_once()
|
|
||||||
_, kwargs = spy.call_args
|
|
||||||
assert kwargs.get("source") == "yandex"
|
|
||||||
assert kwargs.get("endpoint") is None
|
|
||||||
|
|
||||||
|
|
||||||
# ── #2860: slug ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ────────────────
|
# ── #2860: slug ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ────────────────
|
||||||
#
|
#
|
||||||
# Замер 19.08.2026 через браузерный сайдкар прода: `?siteId=<id>` Яндексом
|
# Замер 19.08.2026 через браузерный сайдкар прода: `?siteId=<id>` Яндексом
|
||||||
|
|
@ -195,9 +156,9 @@ async def test_resolve_yandex_jk_slug_requests_jk_page_not_serp_filter(monkeypat
|
||||||
Это и есть суть правки: адрес запроса, а не разбор ответа.
|
Это и есть суть правки: адрес запроса, а не разбор ответа.
|
||||||
"""
|
"""
|
||||||
spy = _spy_browser_fetcher(_JK_PAGE_HTML)
|
spy = _spy_browser_fetcher(_JK_PAGE_HTML)
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||||||
|
|
||||||
await resolve_yandex_jk_slug("286394")
|
await resolve_yandex_jk_slug("286394", config=_CONFIG)
|
||||||
|
|
||||||
url = spy.return_value.fetch.call_args[0][0]
|
url = spy.return_value.fetch.call_args[0][0]
|
||||||
assert "siteId=" not in url, f"фильтр выдачи больше не работает, а запрос идёт туда: {url}"
|
assert "siteId=" not in url, f"фильтр выдачи больше не работает, а запрос идёт туда: {url}"
|
||||||
|
|
@ -208,9 +169,9 @@ async def test_resolve_yandex_jk_slug_requests_jk_page_not_serp_filter(monkeypat
|
||||||
async def test_resolve_yandex_jk_slug_reads_real_slug_from_page(monkeypatch):
|
async def test_resolve_yandex_jk_slug_reads_real_slug_from_page(monkeypatch):
|
||||||
"""Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL."""
|
"""Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL."""
|
||||||
spy = _spy_browser_fetcher(_JK_PAGE_HTML)
|
spy = _spy_browser_fetcher(_JK_PAGE_HTML)
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||||||
|
|
||||||
assert await resolve_yandex_jk_slug("286394") == "uspenskij"
|
assert await resolve_yandex_jk_slug("286394", config=_CONFIG) == "uspenskij"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -221,9 +182,9 @@ async def test_resolve_yandex_jk_slug_returns_none_on_general_list(monkeypatch):
|
||||||
Без привязки к id функция вернула бы «shishkinn» для чужого дома.
|
Без привязки к id функция вернула бы «shishkinn» для чужого дома.
|
||||||
"""
|
"""
|
||||||
spy = _spy_browser_fetcher(_GENERAL_LIST_HTML)
|
spy = _spy_browser_fetcher(_GENERAL_LIST_HTML)
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||||||
|
|
||||||
assert await resolve_yandex_jk_slug("286394") is None
|
assert await resolve_yandex_jk_slug("286394", config=_CONFIG) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -232,9 +193,9 @@ async def test_resolve_yandex_jk_slug_does_not_return_placeholder(monkeypatch):
|
||||||
spy = _spy_browser_fetcher(
|
spy = _spy_browser_fetcher(
|
||||||
'<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">сама страница</a>'
|
'<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">сама страница</a>'
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||||||
|
|
||||||
assert await resolve_yandex_jk_slug("286394") is None
|
assert await resolve_yandex_jk_slug("286394", config=_CONFIG) is None
|
||||||
|
|
||||||
|
|
||||||
# ── cian.newbuilding.resolve_cian_zhk_url_via_search ─────────────────────────
|
# ── cian.newbuilding.resolve_cian_zhk_url_via_search ─────────────────────────
|
||||||
|
|
@ -276,17 +237,33 @@ async def test_resolve_cian_zhk_url_via_search_passes_proxy_from_config(monkeypa
|
||||||
assert kwargs.get("proxy_url") == _TEST_PROXY
|
assert kwargs.get("proxy_url") == _TEST_PROXY
|
||||||
|
|
||||||
|
|
||||||
|
# ── #2385: config обязателен — без него ни фетчера, ни сессии ────────────────
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_cian_zhk_url_via_search_proxy_none_without_config(monkeypatch):
|
@pytest.mark.parametrize(
|
||||||
"""Без config= (SILENT footgun): вызов НЕ падает (в отличие от BrowserFetcher
|
"call",
|
||||||
assertion выше в этом файле), но proxy_url молча становится None — резолвер уходит
|
[
|
||||||
на прямое (непроксированное) соединение вместо настроенного мобильного прокси."""
|
lambda: fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/"),
|
||||||
spy = _spy_curl_session(_SERP_HTML)
|
lambda: YandexNewbuildingScraper().fetch_jk(jk_slug="tatlin", jk_id="1592987"),
|
||||||
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_curl_cffi_session", spy)
|
lambda: resolve_yandex_jk_slug("1592987"),
|
||||||
|
lambda: resolve_cian_zhk_url_via_search(48853),
|
||||||
|
],
|
||||||
|
ids=["cian_fetch_newbuilding", "yandex_fetch_jk", "resolve_yandex_slug", "resolve_cian_url"],
|
||||||
|
)
|
||||||
|
async def test_newbuilding_entrypoints_refuse_to_run_without_config(monkeypatch, call):
|
||||||
|
"""Вызов без config= падает TypeError ДО сети: браузерный фетчер и curl-сессия не
|
||||||
|
строятся. Раньше тот же вызов молча уходил на env-узел сайдкара мимо пула (#2767,
|
||||||
|
#3197) — отказ по сигнатуре единственное, что ловит забывчивого вызывающего."""
|
||||||
|
browser_spy = _spy_browser_fetcher("<html></html>")
|
||||||
|
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", browser_spy)
|
||||||
|
session_spy = _spy_curl_session(_SERP_HTML)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"scraper_kit.providers.cian.newbuilding.build_curl_cffi_session", session_spy
|
||||||
|
)
|
||||||
|
|
||||||
url = await resolve_cian_zhk_url_via_search(48853)
|
with pytest.raises(TypeError, match="config"):
|
||||||
|
await call()
|
||||||
|
|
||||||
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru" # "works" — silently unproxied
|
assert browser_spy.call_count == 0
|
||||||
spy.assert_called_once()
|
assert session_spy.call_count == 0
|
||||||
_, kwargs = spy.call_args
|
|
||||||
assert kwargs.get("proxy_url") is None
|
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,10 @@ async def test_yandex_valuation_session_no_proxies_when_none():
|
||||||
# После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox),
|
# После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox),
|
||||||
# а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера,
|
# а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера,
|
||||||
# а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся.
|
# а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся.
|
||||||
|
# config обязателен (#2385): фетчер строит фабрика _base.build_browser_fetcher.
|
||||||
|
_NB_CONFIG = SimpleNamespace(
|
||||||
|
browser_http_endpoint="http://tradein-browser:3000", use_proxy_pool_browser=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -218,8 +222,8 @@ async def test_cian_newbuilding_own_session_receives_proxies():
|
||||||
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher)
|
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher)
|
||||||
mock_fetcher.__aexit__ = AsyncMock(return_value=None)
|
mock_fetcher.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher):
|
with patch("scraper_kit.providers._base.BrowserFetcher", lambda *a, **k: mock_fetcher):
|
||||||
await cian_newbuilding.fetch_newbuilding(zhk_url)
|
await cian_newbuilding.fetch_newbuilding(zhk_url, config=_NB_CONFIG)
|
||||||
|
|
||||||
# BrowserFetcher.fetch вызван с ЖК-url
|
# BrowserFetcher.fetch вызван с ЖК-url
|
||||||
assert fetch_calls == [zhk_url]
|
assert fetch_calls == [zhk_url]
|
||||||
|
|
@ -249,8 +253,10 @@ async def test_cian_newbuilding_shared_session_not_recreated():
|
||||||
|
|
||||||
sentinel_session = MagicMock(name="external_session")
|
sentinel_session = MagicMock(name="external_session")
|
||||||
|
|
||||||
with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher):
|
with patch("scraper_kit.providers._base.BrowserFetcher", lambda *a, **k: mock_fetcher):
|
||||||
await cian_newbuilding.fetch_newbuilding(zhk_url, session=sentinel_session)
|
await cian_newbuilding.fetch_newbuilding(
|
||||||
|
zhk_url, config=_NB_CONFIG, session=sentinel_session
|
||||||
|
)
|
||||||
|
|
||||||
# BrowserFetcher.fetch вызван — именно через него тянется страница
|
# BrowserFetcher.fetch вызван — именно через него тянется страница
|
||||||
assert fetch_calls == [zhk_url]
|
assert fetch_calls == [zhk_url]
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ def test_yandex_detail_picks_up_delay():
|
||||||
|
|
||||||
def test_yandex_newbuilding_picks_up_delay():
|
def test_yandex_newbuilding_picks_up_delay():
|
||||||
"""Kit YandexNewbuildingScraper: delay_provider callable вызывается с self.name."""
|
"""Kit YandexNewbuildingScraper: delay_provider callable вызывается с self.name."""
|
||||||
s = YandexNewbuildingScraper(delay_provider=lambda _name: 9.5)
|
s = YandexNewbuildingScraper(config=_KIT_CONFIG, delay_provider=lambda _name: 9.5)
|
||||||
assert s.request_delay_sec == 9.5
|
assert s.request_delay_sec == 9.5
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ from uuid import UUID
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
|
from scraper_kit.providers._base import DOCUMENT_HEADERS, build_document_session
|
||||||
from scraper_kit.providers._proxy import acurl_proxy_url
|
from scraper_kit.providers._proxy import acurl_proxy_url
|
||||||
from scraper_kit.providers.avito.shared import _unix_to_date
|
from scraper_kit.providers.avito.shared import _unix_to_date
|
||||||
|
|
||||||
|
|
@ -54,19 +54,6 @@ IMV_ENDPOINT = "/web/1/realty-imv/get-data"
|
||||||
# Тёплая страница для seed anti-bot cookies (srv_id/_avisc/u/...) перед XHR.
|
# Тёплая страница для seed anti-bot cookies (srv_id/_avisc/u/...) перед XHR.
|
||||||
WARMUP_URL = f"{AVITO_BASE}/evaluation/realty"
|
WARMUP_URL = f"{AVITO_BASE}/evaluation/realty"
|
||||||
|
|
||||||
# Заголовки document-навигации (warm-up GET) — зеркалят production-набор
|
|
||||||
# из avito.py (AvitoScraper.__aenter__). Нужны чтобы пройти TLS+header anti-bot.
|
|
||||||
_DOC_HEADERS = {
|
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
||||||
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
|
||||||
"Cache-Control": "max-age=0",
|
|
||||||
"Sec-Fetch-Dest": "document",
|
|
||||||
"Sec-Fetch-Mode": "navigate",
|
|
||||||
"Sec-Fetch-Site": "none",
|
|
||||||
"Sec-Fetch-User": "?1",
|
|
||||||
"Upgrade-Insecure-Requests": "1",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Заголовки XHR/fetch для API-шагов (coords/position/get-data). Имитируют
|
# Заголовки XHR/fetch для API-шагов (coords/position/get-data). Имитируют
|
||||||
# fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch.
|
# fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch.
|
||||||
_COMMON_HEADERS = {
|
_COMMON_HEADERS = {
|
||||||
|
|
@ -486,8 +473,7 @@ async def evaluate_via_imv(
|
||||||
# камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси +
|
# камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси +
|
||||||
# warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403
|
# warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403
|
||||||
# (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op,
|
# (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op,
|
||||||
# браузер принадлежит caller'у — backfill открывает один на батч). Этот путь
|
# браузер принадлежит caller'у — backfill открывает один на батч).
|
||||||
# НЕ требует curl_cffi, поэтому импорт пакета остаётся только в else-ветке.
|
|
||||||
# AsyncExitStack держит прокси-lease пула (#2163) на всё время own-session: warm-up +
|
# AsyncExitStack держит прокси-lease пула (#2163) на всё время own-session: warm-up +
|
||||||
# geocode + evaluate. Регистрируется ТОЛЬКО когда сами создаём curl_cffi-сессию;
|
# geocode + evaluate. Регистрируется ТОЛЬКО когда сами создаём curl_cffi-сессию;
|
||||||
# для browser_fetcher / переданной cffi_session — no-op. Исключение из блока
|
# для browser_fetcher / переданной cffi_session — no-op. Исключение из блока
|
||||||
|
|
@ -499,53 +485,24 @@ async def evaluate_via_imv(
|
||||||
cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL)
|
cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL)
|
||||||
_own_session = True
|
_own_session = True
|
||||||
else:
|
else:
|
||||||
# Импортируем здесь чтобы избежать циклических зависимостей при тестах без сети
|
|
||||||
try:
|
|
||||||
from curl_cffi.requests import AsyncSession as CffiAsyncSession
|
|
||||||
|
|
||||||
_own_session = False
|
_own_session = False
|
||||||
if cffi_session is None:
|
if cffi_session is None:
|
||||||
# Зеркалим production-набор из AvitoScraper.__aenter__ (avito.py:150-162):
|
# Общая document-сессия из _base (#2386): impersonate=DEFAULT_IMPERSONATE TLS +
|
||||||
# impersonate=DEFAULT_IMPERSONATE TLS + document-заголовки + timeout.
|
# DOCUMENT_HEADERS + timeout. Затем warm-up GET для seed anti-bot cookies —
|
||||||
# Затем warm-up GET для seed anti-bot cookies — bare-session XHR Avito
|
# bare-session XHR Avito банит на server-IP.
|
||||||
# банит на server-IP.
|
|
||||||
# Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
|
# Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
|
||||||
# scraper_proxy_url. proxy=None → прямое подключение (dev).
|
# scraper_proxy_url. proxy=None → прямое подключение (dev).
|
||||||
#
|
# Тесты подменяют конструктор через `scraper_kit.providers._base.AsyncSession`.
|
||||||
# NB (#2361 F4a): НЕ мигрировано на build_document_session() из
|
|
||||||
# scraper_kit.providers._base, хотя headers=_DOC_HEADERS — тот же
|
|
||||||
# дублированный dict, что и там. _base.py делает module-level
|
|
||||||
# `from curl_cffi.requests import AsyncSession` (биндится ОДИН раз
|
|
||||||
# при первом импорте _base) — tests/scrapers/test_avito_imv_kit_parity.py
|
|
||||||
# (#2334) патчит `curl_cffi.requests.AsyncSession` через unittest.mock,
|
|
||||||
# что работает ТОЛЬКО если конструктор вызывается через СВЕЖИЙ lookup
|
|
||||||
# атрибута на живом module-объекте (как этот локальный import ниже),
|
|
||||||
# а не через имя, захваченное в чужом module namespace на момент его
|
|
||||||
# импорта. Мигрировав, ловим 2 real failures
|
|
||||||
# (test_kit_evaluate_via_imv_{with,without}_config_uses_proxy) — НЕ
|
|
||||||
# тестовый шум, тест был зелёным на main. Production-поведение
|
|
||||||
# идентично в обоих вариантах (сеть не отличает откуда взят класс),
|
|
||||||
# но так как таск прямо запрещает "weaken or skip" parity-тесты —
|
|
||||||
# оставляю это единственное место немигрированным и репортю как
|
|
||||||
# finding, а не тихо чиню тест.
|
|
||||||
_env = config.scraper_proxy_url if config is not None else None
|
_env = config.scraper_proxy_url if config is not None else None
|
||||||
# acurl_proxy_url (#3398): операции пула в потоке — синхронный вход
|
# acurl_proxy_url (#3398): операции пула в потоке — синхронный вход
|
||||||
# стоял ДО первого await и держал event loop `/estimate`.
|
# стоял ДО первого await и держал event loop `/estimate`.
|
||||||
_proxy_url = await _proxy_stack.enter_async_context(
|
_proxy_url = await _proxy_stack.enter_async_context(
|
||||||
acurl_proxy_url(config, proxy_provider, "avito", env_fallback_url=_env)
|
acurl_proxy_url(config, proxy_provider, "avito", env_fallback_url=_env)
|
||||||
)
|
)
|
||||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
cffi_session = build_document_session(
|
||||||
cffi_session = CffiAsyncSession(
|
proxy_url=_proxy_url, timeout=_HTTP_TIMEOUT_SEC
|
||||||
impersonate=DEFAULT_IMPERSONATE,
|
|
||||||
timeout=_HTTP_TIMEOUT_SEC,
|
|
||||||
proxies=_proxies,
|
|
||||||
headers=_DOC_HEADERS,
|
|
||||||
)
|
)
|
||||||
_own_session = True
|
_own_session = True
|
||||||
except ImportError as exc:
|
|
||||||
raise RuntimeError(
|
|
||||||
"curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if _own_session:
|
if _own_session:
|
||||||
|
|
@ -579,7 +536,7 @@ async def _warmup(session: Any) -> None:
|
||||||
всё равно попробуют и дадут типизированную ошибку.
|
всё равно попробуют и дадут типизированную ошибку.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
resp = await session.get(WARMUP_URL, headers=_DOC_HEADERS)
|
resp = await session.get(WARMUP_URL, headers=DOCUMENT_HEADERS)
|
||||||
logger.info("IMV warm-up GET /evaluation/realty → HTTP %d", resp.status_code)
|
logger.info("IMV warm-up GET /evaluation/realty → HTTP %d", resp.status_code)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную
|
# warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
|
||||||
from scraper_kit.cian_exceptions import CianBlockedError
|
from scraper_kit.cian_exceptions import CianBlockedError
|
||||||
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_browser_fetcher, build_curl_cffi_session
|
from scraper_kit.providers._base import build_browser_fetcher, build_curl_cffi_session
|
||||||
|
|
@ -214,7 +213,7 @@ class NewbuildingEnrichment:
|
||||||
async def fetch_newbuilding(
|
async def fetch_newbuilding(
|
||||||
zhk_url: str,
|
zhk_url: str,
|
||||||
*,
|
*,
|
||||||
config: ScraperConfig | None = None,
|
config: ScraperConfig,
|
||||||
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,
|
proxy_provider: ProxyProvider | None = None,
|
||||||
) -> NewbuildingEnrichment | None:
|
) -> NewbuildingEnrichment | None:
|
||||||
|
|
@ -225,9 +224,8 @@ async def fetch_newbuilding(
|
||||||
|
|
||||||
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/'
|
||||||
config: ScraperConfig — источник browser_http_endpoint для BrowserFetcher
|
config: ScraperConfig — источник browser_http_endpoint/use_pool для BrowserFetcher.
|
||||||
(#2322 fix: раньше не принимался вообще, BrowserFetcher(source="cian")
|
Обязателен (#2385): без него фетчер строился с endpoint=None мимо пула.
|
||||||
конструировался без endpoint= → TypeError на любом вызове).
|
|
||||||
session: зарезервирован для обратной совместимости с вызывающими;
|
session: зарезервирован для обратной совместимости с вызывающими;
|
||||||
больше не используется для fetch страницы ЖК. Resolve-функции
|
больше не используется для fetch страницы ЖК. Resolve-функции
|
||||||
(resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi.
|
(resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi.
|
||||||
|
|
@ -254,14 +252,10 @@ async def fetch_newbuilding(
|
||||||
# Разметка не менялась: MFE 'newbuilding-card-desktop-frontend'/'initialState'
|
# Разметка не менялась: MFE 'newbuilding-card-desktop-frontend'/'initialState'
|
||||||
# разобрался ТЕКУЩИМ кодом на всех трёх мобильных узлах. Ломался тракт, не разбор.
|
# разобрался ТЕКУЩИМ кодом на всех трёх мобильных узлах. Ломался тракт, не разбор.
|
||||||
#
|
#
|
||||||
# config=None остаётся законным (admin.py debug-роут и
|
# config обязателен (#2385): запасная ветка `config=None` строила
|
||||||
# test_cian_fetch_newbuilding_endpoint_none_without_config зовут без config= и
|
# BrowserFetcher(endpoint=None) мимо фабрики — новый вызывающий без config молча уходил
|
||||||
# ждут graceful endpoint=None, а не AttributeError), поэтому фабрика применяется
|
# на env-узел сайдкара, тот самый путь, что стоил 8 суток выше.
|
||||||
# только когда config есть — она требует ScraperConfig mandatory by design.
|
|
||||||
if config is not None:
|
|
||||||
browser = build_browser_fetcher(config, "cian", proxy_provider=proxy_provider)
|
browser = build_browser_fetcher(config, "cian", proxy_provider=proxy_provider)
|
||||||
else:
|
|
||||||
browser = BrowserFetcher(source="cian", endpoint=None) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
# ponytail: lease берётся на ОДИН дом (фетчер живёт внутри этой функции), то есть
|
# ponytail: lease берётся на ОДИН дом (фетчер живёт внутри этой функции), то есть
|
||||||
# прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый).
|
# прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый).
|
||||||
|
|
@ -883,7 +877,7 @@ async def _fetch_zhk_slug(session: AsyncSession, nb_id: int) -> str | None:
|
||||||
async def resolve_cian_zhk_url_via_search(
|
async def resolve_cian_zhk_url_via_search(
|
||||||
nb_id: int,
|
nb_id: int,
|
||||||
*,
|
*,
|
||||||
config: ScraperConfig | None = None,
|
config: ScraperConfig,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
proxy_provider: ProxyProvider | None = None,
|
proxy_provider: ProxyProvider | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
|
|
@ -928,7 +922,7 @@ async def resolve_cian_zhk_url_via_search(
|
||||||
# Own-session path. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
|
# Own-session path. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
|
||||||
# cian_proxy_url; пусто → прямое подключение (dev/no-op).
|
# cian_proxy_url; пусто → прямое подключение (dev/no-op).
|
||||||
# curl_proxy_url: mark_banned на CianBlockedError + mark_health + release на выходе.
|
# curl_proxy_url: mark_banned на CianBlockedError + mark_health + release на выходе.
|
||||||
_env = config.cian_proxy_url if config is not None else None
|
_env = config.cian_proxy_url
|
||||||
with curl_proxy_url(config, proxy_provider, "cian", env_fallback_url=_env) as _proxy_url:
|
with curl_proxy_url(config, proxy_provider, "cian", env_fallback_url=_env) as _proxy_url:
|
||||||
own_session = build_curl_cffi_session(
|
own_session = build_curl_cffi_session(
|
||||||
proxy_url=_proxy_url,
|
proxy_url=_proxy_url,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ from pydantic import BaseModel, Field
|
||||||
from selectolax.parser import HTMLParser, Node
|
from selectolax.parser import HTMLParser, Node
|
||||||
|
|
||||||
from scraper_kit.base import BaseScraper
|
from scraper_kit.base import BaseScraper
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
|
||||||
from scraper_kit.providers._base import build_browser_fetcher
|
from scraper_kit.providers._base import build_browser_fetcher
|
||||||
from scraper_kit.proxy_errors import caused_by_no_proxy
|
from scraper_kit.proxy_errors import caused_by_no_proxy
|
||||||
from scraper_kit.yandex_helpers import (
|
from scraper_kit.yandex_helpers import (
|
||||||
|
|
@ -158,7 +157,7 @@ class YandexNewbuildingScraper(BaseScraper):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
config: ScraperConfig | None = None,
|
config: ScraperConfig,
|
||||||
delay_provider: Callable[[str], float] | None = None,
|
delay_provider: Callable[[str], float] | None = None,
|
||||||
proxy_provider: ProxyProvider | None = None,
|
proxy_provider: ProxyProvider | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -193,14 +192,10 @@ class YandexNewbuildingScraper(BaseScraper):
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/"
|
url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/"
|
||||||
# #3197: через фабрику — endpoint/use_pool/environment из config mandatory
|
# #3197: через фабрику — endpoint/use_pool/environment из config mandatory
|
||||||
# (образец: yandex/serp.py::__aenter__). config=None (dev/офлайн-тесты) —
|
# (образец: yandex/serp.py::__aenter__). Запасной ветки config=None больше нет (#2385).
|
||||||
# прежний путь без пула, как в cian/newbuilding.py.
|
|
||||||
if self._config is not None:
|
|
||||||
fetcher_cm = build_browser_fetcher(
|
fetcher_cm = build_browser_fetcher(
|
||||||
self._config, "yandex", proxy_provider=self._proxy_provider
|
self._config, "yandex", proxy_provider=self._proxy_provider
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
fetcher_cm = BrowserFetcher(source="yandex", endpoint=None) # type: ignore[arg-type]
|
|
||||||
try:
|
try:
|
||||||
async with fetcher_cm as fetcher:
|
async with fetcher_cm as fetcher:
|
||||||
html = await fetcher.fetch(url)
|
html = await fetcher.fetch(url)
|
||||||
|
|
@ -359,7 +354,7 @@ async def resolve_yandex_jk_slug(
|
||||||
jk_id: str,
|
jk_id: str,
|
||||||
city: str = "ekaterinburg",
|
city: str = "ekaterinburg",
|
||||||
*,
|
*,
|
||||||
config: ScraperConfig | None = None,
|
config: ScraperConfig,
|
||||||
proxy_provider: ProxyProvider | None = None,
|
proxy_provider: ProxyProvider | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Найти Yandex Realty slug для ЖК по его ext_id (jk_id).
|
"""Найти Yandex Realty slug для ЖК по его ext_id (jk_id).
|
||||||
|
|
@ -402,10 +397,7 @@ async def resolve_yandex_jk_slug(
|
||||||
"""
|
"""
|
||||||
page_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/{_JK_SLUG_PLACEHOLDER}-{jk_id}/"
|
page_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/{_JK_SLUG_PLACEHOLDER}-{jk_id}/"
|
||||||
# #3197: см. fetch_jk — та же проводка пула через фабрику.
|
# #3197: см. fetch_jk — та же проводка пула через фабрику.
|
||||||
if config is not None:
|
|
||||||
fetcher_cm = build_browser_fetcher(config, "yandex", proxy_provider=proxy_provider)
|
fetcher_cm = build_browser_fetcher(config, "yandex", proxy_provider=proxy_provider)
|
||||||
else:
|
|
||||||
fetcher_cm = BrowserFetcher(source="yandex", endpoint=None) # type: ignore[arg-type]
|
|
||||||
try:
|
try:
|
||||||
async with fetcher_cm as fetcher:
|
async with fetcher_cm as fetcher:
|
||||||
html = await fetcher.fetch(page_url)
|
html = await fetcher.fetch(page_url)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue