funora 0.0.1.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- funora/__init__.py +265 -0
- funora/_account.py +661 -0
- funora/_aclient.py +1484 -0
- funora/_budget.py +579 -0
- funora/_calc.py +182 -0
- funora/_canonical.py +235 -0
- funora/_catalog.py +473 -0
- funora/_chat_history.py +367 -0
- funora/_chats.py +392 -0
- funora/_chips.py +403 -0
- funora/_classify.py +466 -0
- funora/_client.py +1490 -0
- funora/_currency_switch.py +130 -0
- funora/_cursor.py +120 -0
- funora/_delivered.py +233 -0
- funora/_diff.py +752 -0
- funora/_engine.py +4795 -0
- funora/_extract.py +124 -0
- funora/_field_schema.py +112 -0
- funora/_fileio.py +58 -0
- funora/_gate.py +69 -0
- funora/_hops.py +101 -0
- funora/_host.py +120 -0
- funora/_identity.py +284 -0
- funora/_json.py +31 -0
- funora/_listen.py +312 -0
- funora/_lot_form.py +331 -0
- funora/_market.py +406 -0
- funora/_matching.py +147 -0
- funora/_money.py +279 -0
- funora/_monitoring.py +396 -0
- funora/_observed.py +237 -0
- funora/_order.py +552 -0
- funora/_order_details.py +281 -0
- funora/_orders.py +807 -0
- funora/_outbound.py +453 -0
- funora/_own_lots.py +301 -0
- funora/_poll.py +410 -0
- funora/_price_audit.py +281 -0
- funora/_proxies.py +232 -0
- funora/_raise.py +150 -0
- funora/_refund.py +102 -0
- funora/_result.py +157 -0
- funora/_retry.py +213 -0
- funora/_review_write.py +138 -0
- funora/_reviews.py +584 -0
- funora/_runner.py +651 -0
- funora/_secret.py +385 -0
- funora/_showcase.py +362 -0
- funora/_signals.py +375 -0
- funora/_skeleton.py +752 -0
- funora/_snapshot.py +276 -0
- funora/_state.py +279 -0
- funora/_stock.py +32 -0
- funora/_thread.py +574 -0
- funora/_transport.py +1023 -0
- funora/_updates.py +292 -0
- funora/_verdicts.py +91 -0
- funora/_viewing.py +143 -0
- funora/_watch.py +825 -0
- funora/_watch_state.py +237 -0
- funora/_whoami.py +546 -0
- funora/bot/__init__.py +52 -0
- funora/bot/_delivery.py +341 -0
- funora/bot/_outbox.py +261 -0
- funora/bot/_runtime.py +447 -0
- funora/bot/_spool.py +534 -0
- funora/budget.py +311 -0
- funora/capabilities.py +297 -0
- funora/conformance.py +758 -0
- funora/contract.py +73 -0
- funora/errors.py +996 -0
- funora/events.py +189 -0
- funora/extraction.py +420 -0
- funora/observe.py +560 -0
- funora/operations.py +671 -0
- funora/py.typed +0 -0
- funora/reconciliation.py +51 -0
- funora/response_classes.py +155 -0
- funora/retry.py +238 -0
- funora/send_outcome.py +78 -0
- funora/skeleton_format.py +77 -0
- funora-0.0.1.dev2.dist-info/METADATA +294 -0
- funora-0.0.1.dev2.dist-info/RECORD +87 -0
- funora-0.0.1.dev2.dist-info/WHEEL +4 -0
- funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
- funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
funora/_transport.py
ADDED
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
"""Транспортный слой наблюдения.
|
|
2
|
+
|
|
3
|
+
Слой намеренно тонкий: задача - не построить клиент, а один раз аккуратно
|
|
4
|
+
сходить на страницу и не оставить следов, которых не должно быть.
|
|
5
|
+
|
|
6
|
+
Что здесь сделано осознанно:
|
|
7
|
+
|
|
8
|
+
* Секрет не хранится в объекте клиента и не кладётся в cookie jar. Он
|
|
9
|
+
разворачивается в момент сборки запроса и живёт ровно до его отправки.
|
|
10
|
+
Cookie jar - это структура, которую легко напечатать целиком при отладке.
|
|
11
|
+
* Клиент не следует за переходами автоматически: конечный URL нужен
|
|
12
|
+
классификатору, а автоматический переход прячет тот факт, что нас увели на
|
|
13
|
+
страницу входа.
|
|
14
|
+
* Переход на чужой хост не выполняется вовсе. Разбор нашёл здесь дыру, стоящую
|
|
15
|
+
аккаунта целиком: одного заголовка Location хватало, чтобы секрет ушёл на
|
|
16
|
+
произвольный адрес. Проверка после отправки бесполезна - секрет уже ушёл бы.
|
|
17
|
+
* Хранилище cookie отключено. С включённым площадка одним Set-Cookie
|
|
18
|
+
подкладывала свой golden_key, и он уходил следующим запросом впереди
|
|
19
|
+
настоящего: сервер читает первое вхождение, и клиент молча читал чужой
|
|
20
|
+
аккаунт как свой.
|
|
21
|
+
* При запуске проверяется уровень журналирования HTTP-стека. httpx на уровне
|
|
22
|
+
DEBUG печатает заголовки, то есть и сессионный ключ. Предупреждение выдаётся
|
|
23
|
+
один раз: молча работать в таком режиме нельзя, а падать - слишком.
|
|
24
|
+
* Числа таймаутов и пределов взяты из spec/runtime/budget.yaml и помечены там
|
|
25
|
+
как провизорные.
|
|
26
|
+
|
|
27
|
+
Транспортов два - синхронный и асинхронный, - но решение о переходе у них одно
|
|
28
|
+
на двоих и живёт в [_hops.py]. Разъехаться правилу безопасности здесь дороже,
|
|
29
|
+
чем любому другому: цена расхождения - чужой доступ к аккаунту.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from typing import Final
|
|
37
|
+
from urllib.parse import urljoin
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
41
|
+
from ._hops import Follow, Reject, next_hop
|
|
42
|
+
from ._host import host_of
|
|
43
|
+
from ._secret import Secret
|
|
44
|
+
from .budget import (
|
|
45
|
+
MAX_CONNECTIONS_PER_HOST,
|
|
46
|
+
MAX_DECOMPRESSED_BYTES,
|
|
47
|
+
MAX_REDIRECTS,
|
|
48
|
+
MAX_RESPONSE_BYTES,
|
|
49
|
+
)
|
|
50
|
+
from .contract import SUPPORTED_LOCALES
|
|
51
|
+
from .errors import ConfigurationError, NetworkError, RemoteServerError, TimeoutError
|
|
52
|
+
|
|
53
|
+
__all__ = ["Observation", "Fetcher", "AsyncFetcher", "TransportSettings"]
|
|
54
|
+
|
|
55
|
+
_log = logging.getLogger("funora.transport")
|
|
56
|
+
|
|
57
|
+
#: Журналы, которые печатают заголовки запроса на уровне DEBUG.
|
|
58
|
+
_RISKY_LOGGERS: Final[tuple[str, ...]] = ("httpx", "httpcore", "urllib3")
|
|
59
|
+
|
|
60
|
+
#: Флаг, чтобы предупреждение о журналировании выдавалось один раз за процесс.
|
|
61
|
+
_warned = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class TransportSettings:
|
|
66
|
+
"""Настройки транспорта.
|
|
67
|
+
|
|
68
|
+
Числа берутся из порождённого модуля budget, а не пишутся здесь. Прежде они
|
|
69
|
+
были литералами «по мотивам» спецификации, и правка спецификации меняла
|
|
70
|
+
порождённый файл, не меняя поведения: предел переходов, размера ответа и
|
|
71
|
+
числа соединений оставался прежним. Молча.
|
|
72
|
+
|
|
73
|
+
В спецификации числа помечены провизорными и будут уточнены по результатам
|
|
74
|
+
наблюдений - тем важнее, чтобы уточнение доходило до транспорта само.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
base_url (str): Базовый адрес площадки.
|
|
78
|
+
connect_timeout_s (float): Предел на установку соединения, секунды.
|
|
79
|
+
read_timeout_s (float): Предел на чтение ответа, секунды.
|
|
80
|
+
max_connections (int): Предел одновременных соединений на хост.
|
|
81
|
+
max_response_bytes (int): Предел размера полученного тела, байты.
|
|
82
|
+
max_decompressed_bytes (int): Предел размера тела после распаковки,
|
|
83
|
+
байты. Отдельный предел, а не тот же самый: сжатый ответ в мегабайт
|
|
84
|
+
разворачивается в сотни, и одна проверка на двоих ловит только тот
|
|
85
|
+
случай, который и так виден.
|
|
86
|
+
max_redirects (int): Предел числа переходов при ручном следовании.
|
|
87
|
+
proxy_url (str | None): Через что ходить. None означает прямое
|
|
88
|
+
соединение. Прокси меняет исходящий адрес, то есть сетевую
|
|
89
|
+
идентичность целиком: у неё свой запас токенов и своё остывание
|
|
90
|
+
после ограничения частоты.
|
|
91
|
+
user_agent (str): Значение заголовка User-Agent. Задаётся спецификацией,
|
|
92
|
+
а не оставляется на усмотрение реализации: одинаковое поведение
|
|
93
|
+
шести SDK начинается с того, как они представляются.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
base_url: str = "https://funpay.com"
|
|
97
|
+
connect_timeout_s: float = 10.0
|
|
98
|
+
read_timeout_s: float = 20.0
|
|
99
|
+
max_connections: int = MAX_CONNECTIONS_PER_HOST
|
|
100
|
+
max_response_bytes: int = MAX_RESPONSE_BYTES
|
|
101
|
+
max_decompressed_bytes: int = MAX_DECOMPRESSED_BYTES
|
|
102
|
+
max_redirects: int = MAX_REDIRECTS
|
|
103
|
+
proxy_url: str | None = None
|
|
104
|
+
user_agent: str = "Funora/0.0.1 (+https://github.com/Funora-Develop)"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class Observation:
|
|
109
|
+
"""Результат одного обращения к странице.
|
|
110
|
+
|
|
111
|
+
Тело сохраняется, потому что дальше из него строится структурный скелет.
|
|
112
|
+
В диагностику и в файлы попадает только скелет, но не это поле.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
status (int): Код состояния HTTP.
|
|
116
|
+
final_url (str): URL после всех переходов.
|
|
117
|
+
html (str): Тело ответа.
|
|
118
|
+
elapsed_ms (int): Длительность запроса, миллисекунды.
|
|
119
|
+
redirects (int): Сколько переходов было выполнено.
|
|
120
|
+
content_length (int): Размер полученного тела в байтах.
|
|
121
|
+
content_encoding (str): Кодирование тела, как его объявил сервер. Пустая
|
|
122
|
+
строка означает, что сервер послушался просьбы не сжимать, и длину
|
|
123
|
+
можно сравнивать с объявленной.
|
|
124
|
+
declared_length (int | None): Длина, объявленная заголовком
|
|
125
|
+
Content-Length, если он был. Нужна для проверки целостности:
|
|
126
|
+
страница, оборванная посреди таблицы, проходит и классификацию, и
|
|
127
|
+
разбор, а вызывающий получает половину заказов с нулём повреждений.
|
|
128
|
+
Это правдоподобный неверный ответ, о неверности которого узнать
|
|
129
|
+
неоткуда, и сверка длин - единственный способ его заметить.
|
|
130
|
+
retry_after_ms (int | None): Значение заголовка Retry-After в
|
|
131
|
+
миллисекундах, если площадка его прислала.
|
|
132
|
+
requests_sent (int): Сколько запросов ушло на самом деле, вместе с
|
|
133
|
+
переходами. Расходуется бюджет именно по этому числу: спецификация
|
|
134
|
+
требует считать отправленные запросы, а не логические операции, и
|
|
135
|
+
переход - тоже запрос.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
status: int
|
|
139
|
+
final_url: str
|
|
140
|
+
html: str
|
|
141
|
+
elapsed_ms: int
|
|
142
|
+
redirects: int
|
|
143
|
+
content_length: int
|
|
144
|
+
content_encoding: str = ""
|
|
145
|
+
declared_length: int | None = None
|
|
146
|
+
retry_after_ms: int | None = None
|
|
147
|
+
requests_sent: int = 1
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _warn_if_headers_logged() -> None:
|
|
151
|
+
"""Предупреждает, если HTTP-стек печатает заголовки в журнал.
|
|
152
|
+
|
|
153
|
+
На уровне DEBUG httpx выводит заголовки запроса, среди которых Cookie с
|
|
154
|
+
сессионным ключом. Граница защиты секрета проходит по краю этого проекта, и
|
|
155
|
+
об этом лучше сказать вслух один раз, чем не сказать вовсе.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
None: Побочный эффект - запись в журнал.
|
|
159
|
+
"""
|
|
160
|
+
global _warned
|
|
161
|
+
if _warned:
|
|
162
|
+
return
|
|
163
|
+
for name in _RISKY_LOGGERS:
|
|
164
|
+
logger = logging.getLogger(name)
|
|
165
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
166
|
+
_log.warning(
|
|
167
|
+
"журнал %s включён на уровне DEBUG: он печатает заголовки запроса, "
|
|
168
|
+
"включая сессионный ключ. Funora не может этому помешать",
|
|
169
|
+
name,
|
|
170
|
+
)
|
|
171
|
+
_warned = True
|
|
172
|
+
return
|
|
173
|
+
_warned = True
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _accept_language() -> str:
|
|
177
|
+
"""Собирает заголовок предпочитаемых языков из перечня спецификации.
|
|
178
|
+
|
|
179
|
+
Первый язык перечня просится без веса, остальные - с убывающим: так принято
|
|
180
|
+
в HTTP, и так площадка поймёт порядок предпочтения.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
str: Значение заголовка Accept-Language.
|
|
184
|
+
"""
|
|
185
|
+
if not SUPPORTED_LOCALES:
|
|
186
|
+
raise ConfigurationError(
|
|
187
|
+
"перечень поддерживаемых локалей пуст: клиент не может назвать язык, "
|
|
188
|
+
"для которого у него есть снимки страниц"
|
|
189
|
+
)
|
|
190
|
+
head, *rest = SUPPORTED_LOCALES
|
|
191
|
+
parts = [head]
|
|
192
|
+
for index, locale in enumerate(rest, start=1):
|
|
193
|
+
parts.append(f"{locale};q={max(0.1, 1.0 - index * 0.2):.1f}")
|
|
194
|
+
return ",".join(parts)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _client_kwargs(settings: TransportSettings) -> dict[str, object]:
|
|
198
|
+
"""Собирает одинаковые для обоих транспортов настройки httpx.
|
|
199
|
+
|
|
200
|
+
Общая функция здесь не ради краткости. Из перечисленных настроек две -
|
|
201
|
+
отключённое хранилище cookie и отключённое следование за переходами -
|
|
202
|
+
держат обе найденные разбором дыры закрытыми. Заданные по отдельности, они
|
|
203
|
+
расходятся при первой же правке, и расхождение это молчаливое.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
settings (TransportSettings): Настройки транспорта.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
dict[str, object]: Аргументы конструктора клиента httpx.
|
|
210
|
+
"""
|
|
211
|
+
return {
|
|
212
|
+
"timeout": httpx.Timeout(
|
|
213
|
+
connect=settings.connect_timeout_s,
|
|
214
|
+
read=settings.read_timeout_s,
|
|
215
|
+
write=settings.read_timeout_s,
|
|
216
|
+
pool=settings.connect_timeout_s,
|
|
217
|
+
),
|
|
218
|
+
"limits": httpx.Limits(
|
|
219
|
+
max_connections=settings.max_connections,
|
|
220
|
+
max_keepalive_connections=settings.max_connections,
|
|
221
|
+
),
|
|
222
|
+
"follow_redirects": False,
|
|
223
|
+
# Хранилище cookie отключено намеренно. С включённым площадка одним
|
|
224
|
+
# заголовком Set-Cookie подкладывала свой golden_key, и он уходил
|
|
225
|
+
# следующим запросом ВПЕРЕДИ настоящего: сервер читает первое вхождение,
|
|
226
|
+
# и клиент молча читал чужой аккаунт как свой. Ни исключения, ни
|
|
227
|
+
# повреждений, ни строки в журнале - правдоподобные данные не того
|
|
228
|
+
# аккаунта. Заголовок Cookie собирается вручную.
|
|
229
|
+
"cookies": None,
|
|
230
|
+
# Прокси передаётся библиотеке как есть. Проверку схемы делает пул: там
|
|
231
|
+
# же, где прокси объявляются, - иначе она обошлась бы передачей готового
|
|
232
|
+
# транспорта.
|
|
233
|
+
"proxy": settings.proxy_url,
|
|
234
|
+
"headers": {
|
|
235
|
+
"User-Agent": settings.user_agent,
|
|
236
|
+
# Перечень берётся из спецификации, а не пишется здесь. Локаль
|
|
237
|
+
# привязана к аккаунту и запросом не переключается, но заголовок
|
|
238
|
+
# обязан называть ровно те языки, для которых у проекта есть
|
|
239
|
+
# снимки: попросив язык без снимков, клиент получил бы страницу,
|
|
240
|
+
# которую не умеет разбирать, и объявил бы это изменением вёрстки.
|
|
241
|
+
"Accept-Language": _accept_language(),
|
|
242
|
+
# Сжатие запрашивается отключённым, и это не про экономию, а про
|
|
243
|
+
# единственную защиту от обрыва тела.
|
|
244
|
+
#
|
|
245
|
+
# Библиотека распаковывает ответ прозрачно, а заголовок
|
|
246
|
+
# Content-Length объявляет длину СЖАТОГО тела. Проверка целостности
|
|
247
|
+
# сравнивала распакованную длину с объявленной сжатой - двести тысяч
|
|
248
|
+
# байт против двухсот пятидесяти, - и проходила всегда, в том числе
|
|
249
|
+
# на оборванном ответе. Проверка была мертва ровно там, где нужна.
|
|
250
|
+
#
|
|
251
|
+
# Распаковка обрыв тоже не ловит: оборванный gzip разворачивается
|
|
252
|
+
# частично и без ошибки. Проверено - половина потока дала 88 тысяч
|
|
253
|
+
# байт правдоподобного текста.
|
|
254
|
+
#
|
|
255
|
+
# Цена - трафик. Запросов от этого больше не становится, а страница,
|
|
256
|
+
# оборванная посреди таблицы, проходит и классификацию как
|
|
257
|
+
# пригодную, и разбор как полный: вызывающий получает половину
|
|
258
|
+
# заказов с нулём повреждений.
|
|
259
|
+
"Accept-Encoding": "identity",
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _translate(exc: httpx.HTTPError, path: str) -> Exception:
|
|
265
|
+
"""Переводит отказ HTTP-стека в иерархию ошибок Funora.
|
|
266
|
+
|
|
267
|
+
Перевод делается здесь, а не у вызывающего. Иначе обработчик, ловящий
|
|
268
|
+
FunoraError, пропускал бы обрыв связи мимо себя, и цикл наблюдения падал бы
|
|
269
|
+
целиком вместо повтора - при том, что политика повторов для сетевых отказов
|
|
270
|
+
написана и покрыта тестами.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
exc (httpx.HTTPError): Исходный отказ.
|
|
274
|
+
path (str): Путь, по которому шло обращение. Нужен для сообщения.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Exception: TimeoutError при истечении предела ожидания, иначе
|
|
278
|
+
NetworkError.
|
|
279
|
+
"""
|
|
280
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
281
|
+
return TimeoutError(f"истёк предел ожидания при обращении к {path}")
|
|
282
|
+
return NetworkError(f"сетевой отказ при обращении к {path}: {type(exc).__name__}")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _observe(
|
|
286
|
+
response: httpx.Response,
|
|
287
|
+
*,
|
|
288
|
+
settings: TransportSettings,
|
|
289
|
+
rejected_url: str | None,
|
|
290
|
+
redirects: int,
|
|
291
|
+
sent: int,
|
|
292
|
+
elapsed: float,
|
|
293
|
+
) -> Observation:
|
|
294
|
+
"""Собирает наблюдение из ответа.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
response (httpx.Response): Последний полученный ответ.
|
|
298
|
+
settings (TransportSettings): Настройки транспорта.
|
|
299
|
+
rejected_url (str | None): Адрес отвергнутого перехода либо None.
|
|
300
|
+
redirects (int): Число выполненных переходов.
|
|
301
|
+
sent (int): Число отправленных запросов.
|
|
302
|
+
elapsed (float): Суммарная длительность запросов, секунды.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Observation: Наблюдение.
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
RemoteServerError: Если ответ превысил предел размера.
|
|
309
|
+
"""
|
|
310
|
+
raw = response.content
|
|
311
|
+
# Два предела, а не один. Полученное тело меряется одним, распакованное -
|
|
312
|
+
# другим: сжатый ответ в мегабайт разворачивается в сотни, и одна проверка
|
|
313
|
+
# на двоих ловит только тот случай, который и так виден.
|
|
314
|
+
encoded = (response.headers.get("content-encoding") or "").strip().lower()
|
|
315
|
+
limit = (
|
|
316
|
+
settings.max_decompressed_bytes
|
|
317
|
+
if encoded not in ("", "identity")
|
|
318
|
+
else settings.max_response_bytes
|
|
319
|
+
)
|
|
320
|
+
if len(raw) > limit:
|
|
321
|
+
raise RemoteServerError(f"ответ превысил предел {limit} байт: получено {len(raw)}")
|
|
322
|
+
|
|
323
|
+
return Observation(
|
|
324
|
+
status=response.status_code,
|
|
325
|
+
final_url=rejected_url or str(response.url),
|
|
326
|
+
html=response.text,
|
|
327
|
+
elapsed_ms=int(elapsed * 1000),
|
|
328
|
+
redirects=redirects,
|
|
329
|
+
requests_sent=sent,
|
|
330
|
+
content_length=len(raw),
|
|
331
|
+
content_encoding=(response.headers.get("content-encoding") or "").strip().lower(),
|
|
332
|
+
declared_length=_header_int(response, "content-length"),
|
|
333
|
+
retry_after_ms=_retry_after_ms(response),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _log_rejected(target: str, expected: str) -> None:
|
|
338
|
+
"""Пишет в журнал об отвергнутом переходе.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
target (str): Адрес, куда нас пытались увести.
|
|
342
|
+
expected (str): Ожидаемый хост площадки.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
None: Побочный эффект - запись в журнал.
|
|
346
|
+
"""
|
|
347
|
+
_log.warning(
|
|
348
|
+
"переход отклонён: %s не принадлежит %s либо понижает схему",
|
|
349
|
+
host_of(target) or "адрес без хоста",
|
|
350
|
+
expected,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class Fetcher:
|
|
355
|
+
"""Выполняет одиночные запросы к площадке.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
secret (Secret | None): Сессионный секрет. Разворачивается только
|
|
359
|
+
при сборке запроса. None создаёт анонимный транспорт.
|
|
360
|
+
cookie_name (str): Имя cookie, в которой передаётся секрет.
|
|
361
|
+
settings (TransportSettings): Настройки транспорта.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
__slots__ = ("_client", "_cookie_name", "_secret", "_settings")
|
|
365
|
+
|
|
366
|
+
def __init__(
|
|
367
|
+
self,
|
|
368
|
+
secret: Secret | None,
|
|
369
|
+
cookie_name: str = "golden_key",
|
|
370
|
+
settings: TransportSettings | None = None,
|
|
371
|
+
) -> None:
|
|
372
|
+
_warn_if_headers_logged()
|
|
373
|
+
self._secret = secret
|
|
374
|
+
self._cookie_name = cookie_name
|
|
375
|
+
self._settings = settings or TransportSettings()
|
|
376
|
+
self._client = httpx.Client(**_client_kwargs(self._settings)) # type: ignore[arg-type]
|
|
377
|
+
|
|
378
|
+
def __enter__(self) -> Fetcher:
|
|
379
|
+
"""Входит в контекстный менеджер.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Fetcher: Сам объект.
|
|
383
|
+
"""
|
|
384
|
+
return self
|
|
385
|
+
|
|
386
|
+
def __exit__(self, *exc: object) -> None:
|
|
387
|
+
"""Закрывает соединения при выходе из контекстного менеджера.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
*exc (object): Сведения об исключении. Не используются.
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
None
|
|
394
|
+
"""
|
|
395
|
+
self.close()
|
|
396
|
+
|
|
397
|
+
def close(self) -> None:
|
|
398
|
+
"""Закрывает пул соединений.
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
None
|
|
402
|
+
"""
|
|
403
|
+
self._client.close()
|
|
404
|
+
|
|
405
|
+
def fetch(self, path: str) -> Observation:
|
|
406
|
+
"""Загружает одну страницу.
|
|
407
|
+
|
|
408
|
+
Переходы выполняются вручную, а не средствами клиента: конечный URL нужен
|
|
409
|
+
классификатору, и автоматический переход скрыл бы тот факт, что нас увели
|
|
410
|
+
на страницу входа. Решение о каждом переходе принимает [_hops.next_hop] -
|
|
411
|
+
то же самое, что и в асинхронном транспорте.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
path (str): Путь или полный адрес страницы.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Observation: Результат обращения. Если переход уводил на чужой хост
|
|
418
|
+
либо понижал схему, возвращается ответ-перенаправление с чужим
|
|
419
|
+
конечным адресом: решение принимает классификатор, а секрет туда не
|
|
420
|
+
уходит вовсе.
|
|
421
|
+
|
|
422
|
+
Raises:
|
|
423
|
+
TimeoutError: Если истёк предел ожидания.
|
|
424
|
+
NetworkError: При любом другом сетевом отказе.
|
|
425
|
+
RemoteServerError: Если ответ превысил предел размера.
|
|
426
|
+
"""
|
|
427
|
+
expected = host_of(self._settings.base_url)
|
|
428
|
+
url = _start_url(self._settings, path)
|
|
429
|
+
rejected_url: str | None = None
|
|
430
|
+
redirects = 0
|
|
431
|
+
sent = 0
|
|
432
|
+
elapsed = 0.0
|
|
433
|
+
|
|
434
|
+
while True:
|
|
435
|
+
sent += 1
|
|
436
|
+
try:
|
|
437
|
+
# Секрет разворачивается здесь и уходит только на проверенный
|
|
438
|
+
# хост. Заголовок собирается вручную: хранилище cookie
|
|
439
|
+
# отключено, чтобы присланное площадкой значение не оседало.
|
|
440
|
+
response = self._client.get(url, headers=self._cookie())
|
|
441
|
+
except httpx.HTTPError as exc:
|
|
442
|
+
raise _translate(exc, path) from exc
|
|
443
|
+
elapsed += response.elapsed.total_seconds()
|
|
444
|
+
|
|
445
|
+
hop = next_hop(
|
|
446
|
+
current=url,
|
|
447
|
+
is_redirect=response.is_redirect,
|
|
448
|
+
location=response.headers.get("location", ""),
|
|
449
|
+
redirects=redirects,
|
|
450
|
+
max_redirects=self._settings.max_redirects,
|
|
451
|
+
expected=expected,
|
|
452
|
+
)
|
|
453
|
+
if isinstance(hop, Follow):
|
|
454
|
+
url = hop.url
|
|
455
|
+
redirects += 1
|
|
456
|
+
continue
|
|
457
|
+
if isinstance(hop, Reject):
|
|
458
|
+
_log_rejected(hop.url, expected)
|
|
459
|
+
rejected_url = hop.url
|
|
460
|
+
redirects += 1
|
|
461
|
+
break
|
|
462
|
+
|
|
463
|
+
return _observe(
|
|
464
|
+
response,
|
|
465
|
+
settings=self._settings,
|
|
466
|
+
rejected_url=rejected_url,
|
|
467
|
+
redirects=redirects,
|
|
468
|
+
sent=sent,
|
|
469
|
+
elapsed=elapsed,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
def submit(self, path: str, fields: dict[str, str], headers: dict[str, str]) -> Observation:
|
|
473
|
+
"""Отправляет форму и возвращает ответ.
|
|
474
|
+
|
|
475
|
+
ПЕРЕХОДЫ НЕ ВЫПОЛНЯЮТСЯ, и это не упрощение. Повторить отправку по
|
|
476
|
+
переходу значило бы отправить второй раз: у сообщения покупателю нет
|
|
477
|
+
отмены, а повтор при неоднозначном исходе - второе сообщение. Переход в
|
|
478
|
+
ответ на запись возвращается как есть, и решение принимает вызывающий,
|
|
479
|
+
видящий, ЧТО именно он отправлял.
|
|
480
|
+
|
|
481
|
+
Секрет уходит только на проверенный хост - тот же порядок, что и у
|
|
482
|
+
чтения. Адрес собирается от базового, а не берётся у вызывающего
|
|
483
|
+
целиком.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
path (str): Путь обращения.
|
|
487
|
+
fields (dict[str, str]): Поля формы.
|
|
488
|
+
headers (dict[str, str]): Заголовки запроса, кроме Cookie.
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
Observation: Результат обращения. Число переходов всегда ноль.
|
|
492
|
+
|
|
493
|
+
Raises:
|
|
494
|
+
TimeoutError: Если истёк предел ожидания.
|
|
495
|
+
NetworkError: При любом другом сетевом отказе.
|
|
496
|
+
RemoteServerError: Если ответ превысил предел размера.
|
|
497
|
+
"""
|
|
498
|
+
url = _start_url(self._settings, path)
|
|
499
|
+
try:
|
|
500
|
+
response = self._client.post(url, data=fields, headers={**headers, **self._cookie()})
|
|
501
|
+
except httpx.HTTPError as exc:
|
|
502
|
+
raise _translate(exc, path) from exc
|
|
503
|
+
|
|
504
|
+
return _observe(
|
|
505
|
+
response,
|
|
506
|
+
settings=self._settings,
|
|
507
|
+
rejected_url=None,
|
|
508
|
+
redirects=0,
|
|
509
|
+
sent=1,
|
|
510
|
+
elapsed=response.elapsed.total_seconds(),
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def ask(self, path: str, headers: dict[str, str]) -> Observation:
|
|
514
|
+
"""Спрашивает методом GET, но получает ОБЪЕКТ, а не разметку страницы.
|
|
515
|
+
|
|
516
|
+
ОТДЕЛЬНЫЙ МЕТОД, А НЕ ПРИЗНАК У fetch. Отличий от чтения страницы два -
|
|
517
|
+
заголовки и то, что ответ разбирается как объект, - и оба можно было бы
|
|
518
|
+
сделать признаками. Признак означал бы, что два разных чтения живут в
|
|
519
|
+
одном месте и различаются условием, а условие однажды упростят.
|
|
520
|
+
|
|
521
|
+
ПЕРЕХОДЫ НЕ ВЫПОЛНЯЮТСЯ. Точка отвечает объектом; переход отсюда означает
|
|
522
|
+
не «страница переехала», а «нас выкинуло на страницу» - и разбирать её
|
|
523
|
+
как объект нельзя. Пусть лучше это станет видно отказом.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
path (str): Путь обращения вместе с параметрами.
|
|
527
|
+
headers (dict[str, str]): Заголовки, кроме Cookie.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
Observation: Результат обращения.
|
|
531
|
+
|
|
532
|
+
Raises:
|
|
533
|
+
TimeoutError: Если истёк предел ожидания.
|
|
534
|
+
NetworkError: При любом другом сетевом отказе.
|
|
535
|
+
RemoteServerError: Если ответ превысил предел размера.
|
|
536
|
+
"""
|
|
537
|
+
url = _start_url(self._settings, path)
|
|
538
|
+
try:
|
|
539
|
+
response = self._client.get(
|
|
540
|
+
url,
|
|
541
|
+
headers={**headers, **self._cookie()},
|
|
542
|
+
)
|
|
543
|
+
except httpx.HTTPError as exc:
|
|
544
|
+
raise _translate(exc, path) from exc
|
|
545
|
+
|
|
546
|
+
return _observe(
|
|
547
|
+
response,
|
|
548
|
+
settings=self._settings,
|
|
549
|
+
rejected_url=None,
|
|
550
|
+
redirects=0,
|
|
551
|
+
sent=1,
|
|
552
|
+
elapsed=response.elapsed.total_seconds(),
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
def query(self, path: str, payload: object, headers: dict[str, str]) -> Observation:
|
|
556
|
+
"""Спрашивает СТРУКТУРНО: тело JSON, ответ JSON.
|
|
557
|
+
|
|
558
|
+
ОТДЕЛЬНЫЙ МЕТОД, А НЕ ПРИЗНАК У submit, и правило у него своё - причём
|
|
559
|
+
обратное. Отправка формы не повторяется по переходу, потому что запись
|
|
560
|
+
повторять нельзя. Здесь ЧТЕНИЕ, выполненное методом POST: повтор
|
|
561
|
+
безвреден, и переходы выполняются как у обычного чтения.
|
|
562
|
+
|
|
563
|
+
Защитного токена здесь нет вовсе - это отличает семейство /api/ от форм.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
path (str): Путь обращения.
|
|
567
|
+
payload (object): Тело запроса. Кодируется в JSON.
|
|
568
|
+
headers (dict[str, str]): Заголовки, кроме Cookie и Content-Type.
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
Observation: Результат обращения.
|
|
572
|
+
|
|
573
|
+
Raises:
|
|
574
|
+
TimeoutError: Если истёк предел ожидания.
|
|
575
|
+
NetworkError: При любом другом сетевом отказе.
|
|
576
|
+
RemoteServerError: Если ответ превысил предел размера.
|
|
577
|
+
"""
|
|
578
|
+
url = _start_url(self._settings, path)
|
|
579
|
+
try:
|
|
580
|
+
response = self._client.post(
|
|
581
|
+
url,
|
|
582
|
+
json=payload,
|
|
583
|
+
headers={**headers, **self._cookie()},
|
|
584
|
+
)
|
|
585
|
+
except httpx.HTTPError as exc:
|
|
586
|
+
raise _translate(exc, path) from exc
|
|
587
|
+
|
|
588
|
+
return _observe(
|
|
589
|
+
response,
|
|
590
|
+
settings=self._settings,
|
|
591
|
+
rejected_url=None,
|
|
592
|
+
redirects=0,
|
|
593
|
+
sent=1,
|
|
594
|
+
elapsed=response.elapsed.total_seconds(),
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
def upload(
|
|
598
|
+
self,
|
|
599
|
+
path: str,
|
|
600
|
+
*,
|
|
601
|
+
field: str,
|
|
602
|
+
filename: str,
|
|
603
|
+
content: bytes,
|
|
604
|
+
content_type: str,
|
|
605
|
+
headers: dict[str, str],
|
|
606
|
+
) -> Observation:
|
|
607
|
+
"""Отправляет ФАЙЛ и возвращает ответ.
|
|
608
|
+
|
|
609
|
+
ОТДЕЛЬНЫЙ МЕТОД, А НЕ ПРИЗНАК У submit, и это не оформление. Тело здесь
|
|
610
|
+
собирается иначе - составное, с границей частей, - и правило у него своё:
|
|
611
|
+
размер тела ограничивает ПЛОЩАДКА, и предел она объявляет на странице.
|
|
612
|
+
Признак у submit означал бы, что оба правила живут в одном месте и
|
|
613
|
+
различаются условием; условие однажды упростят.
|
|
614
|
+
|
|
615
|
+
ПЕРЕХОДЫ НЕ ВЫПОЛНЯЮТСЯ, как и у submit: повторить отправку по переходу
|
|
616
|
+
значило бы отправить файл второй раз.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
path (str): Путь обращения.
|
|
620
|
+
field (str): Имя поля, в котором уходит файл.
|
|
621
|
+
filename (str): Имя файла, как его увидит площадка.
|
|
622
|
+
content (bytes): Содержимое файла.
|
|
623
|
+
content_type (str): Тип содержимого.
|
|
624
|
+
headers (dict[str, str]): Заголовки запроса, кроме Cookie.
|
|
625
|
+
|
|
626
|
+
Returns:
|
|
627
|
+
Observation: Результат обращения. Число переходов всегда ноль.
|
|
628
|
+
|
|
629
|
+
Raises:
|
|
630
|
+
TimeoutError: Если истёк предел ожидания.
|
|
631
|
+
NetworkError: При любом другом сетевом отказе.
|
|
632
|
+
RemoteServerError: Если ответ превысил предел размера.
|
|
633
|
+
"""
|
|
634
|
+
url = _start_url(self._settings, path)
|
|
635
|
+
try:
|
|
636
|
+
response = self._client.post(
|
|
637
|
+
url,
|
|
638
|
+
files={field: (filename, content, content_type)},
|
|
639
|
+
headers={**headers, **self._cookie()},
|
|
640
|
+
)
|
|
641
|
+
except httpx.HTTPError as exc:
|
|
642
|
+
raise _translate(exc, path) from exc
|
|
643
|
+
|
|
644
|
+
return _observe(
|
|
645
|
+
response,
|
|
646
|
+
settings=self._settings,
|
|
647
|
+
rejected_url=None,
|
|
648
|
+
redirects=0,
|
|
649
|
+
sent=1,
|
|
650
|
+
elapsed=response.elapsed.total_seconds(),
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def _cookie(self) -> dict[str, str]:
|
|
654
|
+
"""Собирает заголовок с сессионным секретом.
|
|
655
|
+
|
|
656
|
+
Returns:
|
|
657
|
+
dict[str, str]: Заголовок с секретом либо пустой набор для гостя.
|
|
658
|
+
"""
|
|
659
|
+
if self._secret is None:
|
|
660
|
+
return {}
|
|
661
|
+
return {"Cookie": f"{self._cookie_name}={self._secret.reveal()}"}
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
class AsyncFetcher:
|
|
665
|
+
"""Асинхронный близнец [Fetcher].
|
|
666
|
+
|
|
667
|
+
Отличается ровно тем, чем должен: ожиданием ответа. Решение о переходе,
|
|
668
|
+
настройки клиента, сборка заголовка с секретом, перевод отказов и сборка
|
|
669
|
+
наблюдения - общие с синхронным транспортом и живут в этом же модуле. Дважды
|
|
670
|
+
написанное правило безопасности расходится, и цена расхождения здесь - чужой
|
|
671
|
+
доступ к аккаунту.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
secret (Secret | None): Сессионный секрет. Разворачивается только
|
|
675
|
+
при сборке запроса. None создаёт анонимный транспорт.
|
|
676
|
+
cookie_name (str): Имя cookie, в которой передаётся секрет.
|
|
677
|
+
settings (TransportSettings): Настройки транспорта.
|
|
678
|
+
"""
|
|
679
|
+
|
|
680
|
+
__slots__ = ("_client", "_cookie_name", "_secret", "_settings")
|
|
681
|
+
|
|
682
|
+
def __init__(
|
|
683
|
+
self,
|
|
684
|
+
secret: Secret | None,
|
|
685
|
+
cookie_name: str = "golden_key",
|
|
686
|
+
settings: TransportSettings | None = None,
|
|
687
|
+
) -> None:
|
|
688
|
+
_warn_if_headers_logged()
|
|
689
|
+
self._secret = secret
|
|
690
|
+
self._cookie_name = cookie_name
|
|
691
|
+
self._settings = settings or TransportSettings()
|
|
692
|
+
self._client = httpx.AsyncClient(**_client_kwargs(self._settings)) # type: ignore[arg-type]
|
|
693
|
+
|
|
694
|
+
async def __aenter__(self) -> AsyncFetcher:
|
|
695
|
+
"""Входит в асинхронный контекстный менеджер.
|
|
696
|
+
|
|
697
|
+
Returns:
|
|
698
|
+
AsyncFetcher: Сам объект.
|
|
699
|
+
"""
|
|
700
|
+
return self
|
|
701
|
+
|
|
702
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
703
|
+
"""Закрывает соединения при выходе из контекстного менеджера.
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
*exc (object): Сведения об исключении. Не используются.
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
None
|
|
710
|
+
"""
|
|
711
|
+
await self.close()
|
|
712
|
+
|
|
713
|
+
async def close(self) -> None:
|
|
714
|
+
"""Закрывает пул соединений.
|
|
715
|
+
|
|
716
|
+
Returns:
|
|
717
|
+
None
|
|
718
|
+
"""
|
|
719
|
+
await self._client.aclose()
|
|
720
|
+
|
|
721
|
+
async def fetch(self, path: str) -> Observation:
|
|
722
|
+
"""Загружает одну страницу.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
path (str): Путь или полный адрес страницы.
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
Observation: Результат обращения, устроенный так же, как у
|
|
729
|
+
синхронного транспорта.
|
|
730
|
+
|
|
731
|
+
Raises:
|
|
732
|
+
TimeoutError: Если истёк предел ожидания.
|
|
733
|
+
NetworkError: При любом другом сетевом отказе.
|
|
734
|
+
RemoteServerError: Если ответ превысил предел размера.
|
|
735
|
+
"""
|
|
736
|
+
expected = host_of(self._settings.base_url)
|
|
737
|
+
url = _start_url(self._settings, path)
|
|
738
|
+
rejected_url: str | None = None
|
|
739
|
+
redirects = 0
|
|
740
|
+
sent = 0
|
|
741
|
+
elapsed = 0.0
|
|
742
|
+
|
|
743
|
+
while True:
|
|
744
|
+
sent += 1
|
|
745
|
+
try:
|
|
746
|
+
response = await self._client.get(url, headers=self._cookie())
|
|
747
|
+
except httpx.HTTPError as exc:
|
|
748
|
+
raise _translate(exc, path) from exc
|
|
749
|
+
elapsed += response.elapsed.total_seconds()
|
|
750
|
+
|
|
751
|
+
hop = next_hop(
|
|
752
|
+
current=url,
|
|
753
|
+
is_redirect=response.is_redirect,
|
|
754
|
+
location=response.headers.get("location", ""),
|
|
755
|
+
redirects=redirects,
|
|
756
|
+
max_redirects=self._settings.max_redirects,
|
|
757
|
+
expected=expected,
|
|
758
|
+
)
|
|
759
|
+
if isinstance(hop, Follow):
|
|
760
|
+
url = hop.url
|
|
761
|
+
redirects += 1
|
|
762
|
+
continue
|
|
763
|
+
if isinstance(hop, Reject):
|
|
764
|
+
_log_rejected(hop.url, expected)
|
|
765
|
+
rejected_url = hop.url
|
|
766
|
+
redirects += 1
|
|
767
|
+
break
|
|
768
|
+
|
|
769
|
+
return _observe(
|
|
770
|
+
response,
|
|
771
|
+
settings=self._settings,
|
|
772
|
+
rejected_url=rejected_url,
|
|
773
|
+
redirects=redirects,
|
|
774
|
+
sent=sent,
|
|
775
|
+
elapsed=elapsed,
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
async def submit(
|
|
779
|
+
self, path: str, fields: dict[str, str], headers: dict[str, str]
|
|
780
|
+
) -> Observation:
|
|
781
|
+
"""Отправляет форму и возвращает ответ.
|
|
782
|
+
|
|
783
|
+
ПЕРЕХОДЫ НЕ ВЫПОЛНЯЮТСЯ, и это не упрощение. Повторить отправку по
|
|
784
|
+
переходу значило бы отправить второй раз: у сообщения покупателю нет
|
|
785
|
+
отмены, а повтор при неоднозначном исходе - второе сообщение. Переход в
|
|
786
|
+
ответ на запись возвращается как есть, и решение принимает вызывающий,
|
|
787
|
+
видящий, ЧТО именно он отправлял.
|
|
788
|
+
|
|
789
|
+
Секрет уходит только на проверенный хост - тот же порядок, что и у
|
|
790
|
+
чтения. Адрес собирается от базового, а не берётся у вызывающего
|
|
791
|
+
целиком.
|
|
792
|
+
|
|
793
|
+
Args:
|
|
794
|
+
path (str): Путь обращения.
|
|
795
|
+
fields (dict[str, str]): Поля формы.
|
|
796
|
+
headers (dict[str, str]): Заголовки запроса, кроме Cookie.
|
|
797
|
+
|
|
798
|
+
Returns:
|
|
799
|
+
Observation: Результат обращения. Число переходов всегда ноль.
|
|
800
|
+
|
|
801
|
+
Raises:
|
|
802
|
+
TimeoutError: Если истёк предел ожидания.
|
|
803
|
+
NetworkError: При любом другом сетевом отказе.
|
|
804
|
+
RemoteServerError: Если ответ превысил предел размера.
|
|
805
|
+
"""
|
|
806
|
+
url = _start_url(self._settings, path)
|
|
807
|
+
try:
|
|
808
|
+
response = await self._client.post(
|
|
809
|
+
url, data=fields, headers={**headers, **self._cookie()}
|
|
810
|
+
)
|
|
811
|
+
except httpx.HTTPError as exc:
|
|
812
|
+
raise _translate(exc, path) from exc
|
|
813
|
+
|
|
814
|
+
return _observe(
|
|
815
|
+
response,
|
|
816
|
+
settings=self._settings,
|
|
817
|
+
rejected_url=None,
|
|
818
|
+
redirects=0,
|
|
819
|
+
sent=1,
|
|
820
|
+
elapsed=response.elapsed.total_seconds(),
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
async def ask(self, path: str, headers: dict[str, str]) -> Observation:
|
|
824
|
+
"""Спрашивает методом GET, но получает ОБЪЕКТ, а не разметку страницы.
|
|
825
|
+
|
|
826
|
+
ОТДЕЛЬНЫЙ МЕТОД, А НЕ ПРИЗНАК У fetch. Отличий от чтения страницы два -
|
|
827
|
+
заголовки и то, что ответ разбирается как объект, - и оба можно было бы
|
|
828
|
+
сделать признаками. Признак означал бы, что два разных чтения живут в
|
|
829
|
+
одном месте и различаются условием, а условие однажды упростят.
|
|
830
|
+
|
|
831
|
+
ПЕРЕХОДЫ НЕ ВЫПОЛНЯЮТСЯ. Точка отвечает объектом; переход отсюда означает
|
|
832
|
+
не «страница переехала», а «нас выкинуло на страницу» - и разбирать её
|
|
833
|
+
как объект нельзя. Пусть лучше это станет видно отказом.
|
|
834
|
+
|
|
835
|
+
Args:
|
|
836
|
+
path (str): Путь обращения вместе с параметрами.
|
|
837
|
+
headers (dict[str, str]): Заголовки, кроме Cookie.
|
|
838
|
+
|
|
839
|
+
Returns:
|
|
840
|
+
Observation: Результат обращения.
|
|
841
|
+
|
|
842
|
+
Raises:
|
|
843
|
+
TimeoutError: Если истёк предел ожидания.
|
|
844
|
+
NetworkError: При любом другом сетевом отказе.
|
|
845
|
+
RemoteServerError: Если ответ превысил предел размера.
|
|
846
|
+
"""
|
|
847
|
+
url = _start_url(self._settings, path)
|
|
848
|
+
try:
|
|
849
|
+
response = await self._client.get(
|
|
850
|
+
url,
|
|
851
|
+
headers={**headers, **self._cookie()},
|
|
852
|
+
)
|
|
853
|
+
except httpx.HTTPError as exc:
|
|
854
|
+
raise _translate(exc, path) from exc
|
|
855
|
+
|
|
856
|
+
return _observe(
|
|
857
|
+
response,
|
|
858
|
+
settings=self._settings,
|
|
859
|
+
rejected_url=None,
|
|
860
|
+
redirects=0,
|
|
861
|
+
sent=1,
|
|
862
|
+
elapsed=response.elapsed.total_seconds(),
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
async def query(self, path: str, payload: object, headers: dict[str, str]) -> Observation:
|
|
866
|
+
"""Спрашивает СТРУКТУРНО: тело JSON, ответ JSON.
|
|
867
|
+
|
|
868
|
+
ОТДЕЛЬНЫЙ МЕТОД, А НЕ ПРИЗНАК У submit, и правило у него своё - причём
|
|
869
|
+
обратное. Отправка формы не повторяется по переходу, потому что запись
|
|
870
|
+
повторять нельзя. Здесь ЧТЕНИЕ, выполненное методом POST: повтор
|
|
871
|
+
безвреден, и переходы выполняются как у обычного чтения.
|
|
872
|
+
|
|
873
|
+
Защитного токена здесь нет вовсе - это отличает семейство /api/ от форм.
|
|
874
|
+
|
|
875
|
+
Args:
|
|
876
|
+
path (str): Путь обращения.
|
|
877
|
+
payload (object): Тело запроса. Кодируется в JSON.
|
|
878
|
+
headers (dict[str, str]): Заголовки, кроме Cookie и Content-Type.
|
|
879
|
+
|
|
880
|
+
Returns:
|
|
881
|
+
Observation: Результат обращения.
|
|
882
|
+
|
|
883
|
+
Raises:
|
|
884
|
+
TimeoutError: Если истёк предел ожидания.
|
|
885
|
+
NetworkError: При любом другом сетевом отказе.
|
|
886
|
+
RemoteServerError: Если ответ превысил предел размера.
|
|
887
|
+
"""
|
|
888
|
+
url = _start_url(self._settings, path)
|
|
889
|
+
try:
|
|
890
|
+
response = await self._client.post(
|
|
891
|
+
url,
|
|
892
|
+
json=payload,
|
|
893
|
+
headers={**headers, **self._cookie()},
|
|
894
|
+
)
|
|
895
|
+
except httpx.HTTPError as exc:
|
|
896
|
+
raise _translate(exc, path) from exc
|
|
897
|
+
|
|
898
|
+
return _observe(
|
|
899
|
+
response,
|
|
900
|
+
settings=self._settings,
|
|
901
|
+
rejected_url=None,
|
|
902
|
+
redirects=0,
|
|
903
|
+
sent=1,
|
|
904
|
+
elapsed=response.elapsed.total_seconds(),
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
async def upload(
|
|
908
|
+
self,
|
|
909
|
+
path: str,
|
|
910
|
+
*,
|
|
911
|
+
field: str,
|
|
912
|
+
filename: str,
|
|
913
|
+
content: bytes,
|
|
914
|
+
content_type: str,
|
|
915
|
+
headers: dict[str, str],
|
|
916
|
+
) -> Observation:
|
|
917
|
+
"""Отправляет ФАЙЛ и возвращает ответ.
|
|
918
|
+
|
|
919
|
+
ОТДЕЛЬНЫЙ МЕТОД, А НЕ ПРИЗНАК У submit, и это не оформление. Тело здесь
|
|
920
|
+
собирается иначе - составное, с границей частей, - и правило у него своё:
|
|
921
|
+
размер тела ограничивает ПЛОЩАДКА, и предел она объявляет на странице.
|
|
922
|
+
Признак у submit означал бы, что оба правила живут в одном месте и
|
|
923
|
+
различаются условием; условие однажды упростят.
|
|
924
|
+
|
|
925
|
+
ПЕРЕХОДЫ НЕ ВЫПОЛНЯЮТСЯ, как и у submit: повторить отправку по переходу
|
|
926
|
+
значило бы отправить файл второй раз.
|
|
927
|
+
|
|
928
|
+
Args:
|
|
929
|
+
path (str): Путь обращения.
|
|
930
|
+
field (str): Имя поля, в котором уходит файл.
|
|
931
|
+
filename (str): Имя файла, как его увидит площадка.
|
|
932
|
+
content (bytes): Содержимое файла.
|
|
933
|
+
content_type (str): Тип содержимого.
|
|
934
|
+
headers (dict[str, str]): Заголовки запроса, кроме Cookie.
|
|
935
|
+
|
|
936
|
+
Returns:
|
|
937
|
+
Observation: Результат обращения. Число переходов всегда ноль.
|
|
938
|
+
|
|
939
|
+
Raises:
|
|
940
|
+
TimeoutError: Если истёк предел ожидания.
|
|
941
|
+
NetworkError: При любом другом сетевом отказе.
|
|
942
|
+
RemoteServerError: Если ответ превысил предел размера.
|
|
943
|
+
"""
|
|
944
|
+
url = _start_url(self._settings, path)
|
|
945
|
+
try:
|
|
946
|
+
response = await self._client.post(
|
|
947
|
+
url,
|
|
948
|
+
files={field: (filename, content, content_type)},
|
|
949
|
+
headers={**headers, **self._cookie()},
|
|
950
|
+
)
|
|
951
|
+
except httpx.HTTPError as exc:
|
|
952
|
+
raise _translate(exc, path) from exc
|
|
953
|
+
|
|
954
|
+
return _observe(
|
|
955
|
+
response,
|
|
956
|
+
settings=self._settings,
|
|
957
|
+
rejected_url=None,
|
|
958
|
+
redirects=0,
|
|
959
|
+
sent=1,
|
|
960
|
+
elapsed=response.elapsed.total_seconds(),
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
def _cookie(self) -> dict[str, str]:
|
|
964
|
+
"""Собирает заголовок с сессионным секретом.
|
|
965
|
+
|
|
966
|
+
Returns:
|
|
967
|
+
dict[str, str]: Заголовок с секретом либо пустой набор для гостя.
|
|
968
|
+
"""
|
|
969
|
+
if self._secret is None:
|
|
970
|
+
return {}
|
|
971
|
+
return {"Cookie": f"{self._cookie_name}={self._secret.reveal()}"}
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _start_url(settings: TransportSettings, path: str) -> str:
|
|
975
|
+
"""Приводит путь к полному адресу.
|
|
976
|
+
|
|
977
|
+
Args:
|
|
978
|
+
settings (TransportSettings): Настройки транспорта.
|
|
979
|
+
path (str): Путь либо полный адрес.
|
|
980
|
+
|
|
981
|
+
Returns:
|
|
982
|
+
str: Полный адрес запроса.
|
|
983
|
+
"""
|
|
984
|
+
return urljoin(settings.base_url, path)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _header_int(response: httpx.Response, name: str) -> int | None:
|
|
988
|
+
"""Читает целочисленный заголовок ответа.
|
|
989
|
+
|
|
990
|
+
Args:
|
|
991
|
+
response (httpx.Response): Ответ.
|
|
992
|
+
name (str): Имя заголовка.
|
|
993
|
+
|
|
994
|
+
Returns:
|
|
995
|
+
int | None: Значение либо None, если заголовка нет или он не число.
|
|
996
|
+
"""
|
|
997
|
+
raw = response.headers.get(name)
|
|
998
|
+
if raw is None:
|
|
999
|
+
return None
|
|
1000
|
+
try:
|
|
1001
|
+
return int(raw.strip())
|
|
1002
|
+
except ValueError:
|
|
1003
|
+
return None
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _retry_after_ms(response: httpx.Response) -> int | None:
|
|
1007
|
+
"""Читает заголовок Retry-After в миллисекундах.
|
|
1008
|
+
|
|
1009
|
+
Разбирается только числовая форма, в секундах. Форма с датой не
|
|
1010
|
+
поддерживается намеренно: она требует доверия к часам площадки и к
|
|
1011
|
+
согласованности часовых поясов, а ошибка здесь выражается в неверной паузе -
|
|
1012
|
+
то есть в поведении, которое потом объясняют чем угодно, кроме заголовка.
|
|
1013
|
+
|
|
1014
|
+
Args:
|
|
1015
|
+
response (httpx.Response): Ответ.
|
|
1016
|
+
|
|
1017
|
+
Returns:
|
|
1018
|
+
int | None: Пауза в миллисекундах либо None.
|
|
1019
|
+
"""
|
|
1020
|
+
seconds = _header_int(response, "retry-after")
|
|
1021
|
+
if seconds is None or seconds < 0:
|
|
1022
|
+
return None
|
|
1023
|
+
return seconds * 1000
|