diff --git a/backend/app/services/scrapers/okn_egrkn_client.py b/backend/app/services/scrapers/okn_egrkn_client.py index f785d1b5..e2d2a0ea 100644 --- a/backend/app/services/scrapers/okn_egrkn_client.py +++ b/backend/app/services/scrapers/okn_egrkn_client.py @@ -17,6 +17,7 @@ TLS: mkrf.ru использует сертификат, верификация from __future__ import annotations import logging +import urllib.parse from typing import Any import httpx @@ -29,8 +30,8 @@ _SEARCHMAP_URL = "https://okn-mk.mkrf.ru/Maps/searchMap" _REQUEST_TIMEOUT = 30.0 # Browser headers -- required: without User-Agent the server returns 403. -# Content-Type intentionally omitted: httpx sets it automatically with data= -# (application/x-www-form-urlencoded); duplicating it would conflict. +# Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его +# автоматически — нужно прописать вручную. Конфликта с data= нет, т.к. data= не используется. _HEADERS: dict[str, str] = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " @@ -39,6 +40,7 @@ _HEADERS: dict[str, str] = { "X-Requested-With": "XMLHttpRequest", "Referer": "https://okn-mk.mkrf.ru/Maps", "Origin": "https://okn-mk.mkrf.ru", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", } @@ -100,11 +102,16 @@ def _do_request( verify: bool, ) -> list[dict[str, Any]]: """Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False.""" + # urllib.parse.urlencode гарантированно кодирует список кортежей с повторяющимися ключами + # (data[0][name]=...&data[0][value]=...&data[1][name]=...). Использование data= с list[tuple] + # конфликтует с ручным Content-Type → httpx не urlencode-ит, бросает + # «expected bytes, tuple found». content= передаёт тело как raw str. + body = urllib.parse.urlencode(form_data) try: with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify, headers=_HEADERS) as client: resp = client.post( _SEARCHMAP_URL, - data=form_data, + content=body, ) resp.raise_for_status() except httpx.HTTPStatusError as exc: diff --git a/backend/tests/services/scrapers/test_okn_egrkn_client.py b/backend/tests/services/scrapers/test_okn_egrkn_client.py index 43f5aa1b..89f16e8f 100644 --- a/backend/tests/services/scrapers/test_okn_egrkn_client.py +++ b/backend/tests/services/scrapers/test_okn_egrkn_client.py @@ -5,6 +5,7 @@ - TLS-fallback: SSLError с verify=True → retry с verify=False. - Некорректный JSON / HTTP-ошибка → пустой список, не raise. - _build_form_data: формат jQuery serializeArray. +- form-encoding: content= содержит urlencoded-строку с повторяющимися ключами (не data=). """ from __future__ import annotations @@ -239,7 +240,7 @@ def test_request_sends_browser_user_agent() -> None: Without UA, okn-mk.mkrf.ru returns 403. With Chrome/125 UA: 200 response, cob_address=Ekaterinburg = 272 features. Checks: - 1. _HEADERS constant contains User-Agent with Mozilla string. + 1. _HEADERS constant contains User-Agent with Mozilla string and Content-Type. 2. httpx.Client is called with headers=_HEADERS when fetch_okn_points runs. """ assert "User-Agent" in _HEADERS @@ -247,6 +248,8 @@ def test_request_sends_browser_user_agent() -> None: assert "X-Requested-With" in _HEADERS assert "Referer" in _HEADERS assert "Origin" in _HEADERS + assert "Content-Type" in _HEADERS + assert "application/x-www-form-urlencoded" in _HEADERS["Content-Type"] features = [_make_feature("ua-001", 56.83, 60.61)] payload = _make_geojson_response(features) @@ -269,6 +272,65 @@ def test_request_sends_browser_user_agent() -> None: assert "Mozilla" in sent_headers["User-Agent"] +def test_post_uses_content_urlencode_not_data() -> None: + """client.post вызывается с content=urlencoded-строкой (не data=list[tuple]). + + Баг #1066: data=list[tuple] + ручной Content-Type → httpx бросает + «expected bytes, tuple found». Фикс: urllib.parse.urlencode(form_data) → content=. + Проверяем: + - post вызван с параметром content=, а НЕ data= + - content — строка с правильными urlencoded-ключами (повторяющиеся data[i][name]) + - UA-заголовок присутствует в headers переданных в Client + """ + import urllib.parse + + features = [_make_feature("enc-001", 56.83, 60.61)] + payload = _make_geojson_response(features) + captured_post_kwargs: list[dict] = [] + captured_client_kwargs: list[dict] = [] + + def _make_client(**kwargs: Any) -> MagicMock: + captured_client_kwargs.append(dict(kwargs)) + mc = MagicMock() + mc.__enter__ = MagicMock(return_value=mc) + mc.__exit__ = MagicMock(return_value=False) + + def _post(url: str, **post_kwargs: Any) -> MagicMock: + captured_post_kwargs.append(post_kwargs) + return _mock_response(payload) + + mc.post.side_effect = _post + return mc + + with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client): + result = fetch_okn_points("Екатеринбург", 1) + + assert len(result) == 1 + + assert len(captured_post_kwargs) == 1 + post_kw = captured_post_kwargs[0] + + # content= должен быть, data= — нет + assert "content" in post_kw, "post() должен использовать content=, а не data=" + assert "data" not in post_kw, "post() не должен передавать data= (баг с tuple-encoding)" + + # content — корректная urlencoded-строка + body = post_kw["content"] + assert isinstance(body, str), f"content должен быть str, получили {type(body)}" + parsed = urllib.parse.parse_qs(body, keep_blank_values=True) + assert "data[0][name]" in parsed + assert "data[0][value]" in parsed + assert "data[1][name]" in parsed + assert "data[1][value]" in parsed + assert parsed["data[0][value]"] == ["Екатеринбург"] + assert parsed["data[1][value]"] == ["1"] + + # UA в заголовках клиента + client_headers = captured_client_kwargs[0].get("headers", {}) + assert "User-Agent" in client_headers + assert "Mozilla" in client_headers["User-Agent"] + + def test_build_form_data_category_type_as_string() -> None: """category_type конвертируется в строку (httpx data= ожидает str).""" result = _build_form_data("Свердловская", 4)