fix(sf): корректное urlencode form-data в okn_egrkn_client (#1147)
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
parent
bcb1285341
commit
a6e100b52a
2 changed files with 73 additions and 4 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue