fix(sf): корректное urlencode form-data в okn_egrkn_client (#1066) #1147

Merged
bot-reviewer merged 1 commit from fix/okn-form-encoding into main 2026-06-07 14:13:34 +00:00
2 changed files with 73 additions and 4 deletions

View file

@ -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:

View file

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