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/_updates.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Разбор ответа канала обновлений.
|
|
2
|
+
|
|
3
|
+
ЧТО ЭТО ЗА КАНАЛ. Площадка держит его для собственной страницы: один POST с
|
|
4
|
+
подпиской, в ответ - изменения только тех объектов, на которые подписан. Ничего
|
|
5
|
+
не изменилось - объекты пусты.
|
|
6
|
+
|
|
7
|
+
ПОЧЕМУ ЭТО ВАЖНЕЕ ВСЕГО ОСТАЛЬНОГО. Наблюдение сегодня читает две полные
|
|
8
|
+
страницы на КАЖДОМ шаге, изменилось что-нибудь или нет. Канал отвечает малым
|
|
9
|
+
телом и молчит, когда молчать нечего.
|
|
10
|
+
|
|
11
|
+
Три факта, наблюдённые 30.08.2026 и записанные в spec/extraction/updates.yaml:
|
|
12
|
+
|
|
13
|
+
Метка - это КВИТАНЦИЯ «я видел до сюда», а не пропуск. Площадка принимает любую,
|
|
14
|
+
в том числе выдуманную, и отвечает всем, что изменилось с той поры. Свою метку
|
|
15
|
+
она возвращает в ответе; тот же опрос с ней даёт пустоту.
|
|
16
|
+
|
|
17
|
+
Подписка длиннее ДЕСЯТИ объектов обрезается МОЛЧА. Послано одиннадцать - пришло
|
|
18
|
+
десять, без единого признака, что один отброшен.
|
|
19
|
+
|
|
20
|
+
Счётчики приходят ЧИСЛАМИ, а не выводятся из разметки.
|
|
21
|
+
|
|
22
|
+
ЧТО ЗДЕСЬ НЕ РАЗБИРАЕТСЯ. Поле html внутри объектов - разметка переписки, и
|
|
23
|
+
разбирать её тем же кодом, что разбирает страницу, никто не проверял. Она
|
|
24
|
+
проходит насквозь как непрозрачная строка: разбор по догадке разошёлся бы с
|
|
25
|
+
площадкой молча.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Any, Final
|
|
32
|
+
|
|
33
|
+
from ._json import load_json
|
|
34
|
+
from .errors import ProtocolChangedError
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"MAX_SUBSCRIPTION",
|
|
38
|
+
"UpdatesAnswer",
|
|
39
|
+
"ChannelObject",
|
|
40
|
+
"build_subscription",
|
|
41
|
+
"parse_updates_answer",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
#: Сколько объектов площадка принимает в одной подписке.
|
|
45
|
+
#:
|
|
46
|
+
#: Наблюдено, а не взято у чужой реализации: послано одиннадцать узлов диалога,
|
|
47
|
+
#: вернулось десять. Чужая константа была поводом проверить, а не свидетельством.
|
|
48
|
+
#:
|
|
49
|
+
#: МОЛЧАЛИВОСТЬ ОБРЕЗКИ - главное в этом числе. Признака, что объект отброшен, в
|
|
50
|
+
#: ответе нет никакого. Подписавшийся на пятнадцать диалогов получит десять и не
|
|
51
|
+
#: узнает об этом ниоткуда: сообщения пяти покупателей не придут, и объяснить это
|
|
52
|
+
#: будет нечем.
|
|
53
|
+
MAX_SUBSCRIPTION: Final[int] = 10
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class ChannelObject:
|
|
58
|
+
"""Один изменившийся объект из ответа канала.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
type (str): Вид объекта, как его назвала площадка.
|
|
62
|
+
id (str): Идентификатор объекта.
|
|
63
|
+
tag (str): Непустая метка для следующего опроса.
|
|
64
|
+
data (dict[str, Any]): Данные объекта как есть. Разметка внутри НЕ
|
|
65
|
+
разбирается: она проходит насквозь непрозрачной строкой.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
type: str
|
|
69
|
+
id: str
|
|
70
|
+
tag: str
|
|
71
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
def number(self, name: str) -> int | None:
|
|
74
|
+
"""Читает целое поле данных.
|
|
75
|
+
|
|
76
|
+
Числа - единственное, что этот разбор берёт из данных объекта, и берёт
|
|
77
|
+
он их без толкования: что означает счётчик, решает вызывающий.
|
|
78
|
+
|
|
79
|
+
Возвращается None, а не ноль, когда поля нет. Ноль означал бы «счётчик
|
|
80
|
+
равен нулю», а это другое утверждение.
|
|
81
|
+
|
|
82
|
+
Аргументы:
|
|
83
|
+
name (str): Имя поля.
|
|
84
|
+
|
|
85
|
+
Возвращает:
|
|
86
|
+
int | None: Значение либо None, если поля нет или оно не целое.
|
|
87
|
+
"""
|
|
88
|
+
value = self.data.get(name)
|
|
89
|
+
# Логическое исключается отдельно: в Python True - это единица, и
|
|
90
|
+
# счётчик, оказавшийся булевым, прочитался бы числом молча.
|
|
91
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
92
|
+
return None
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class UpdatesAnswer:
|
|
98
|
+
"""Ответ канала обновлений.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
objects (tuple[ChannelObject, ...]): Изменившиеся объекты. Пусто -
|
|
102
|
+
штатное состояние, а не признак поломки: канал молчит, когда молчать
|
|
103
|
+
нечего.
|
|
104
|
+
error (object | None): Поле ошибки без преобразования. Только None означает
|
|
105
|
+
отсутствие отказа; форма непустого поля не интерпретируется.
|
|
106
|
+
answered_action (bool): Был ли в запросе действие. Площадка отвечает
|
|
107
|
+
объектом при опросе С ДЕЙСТВИЕМ и логическим - при опросе без него.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
objects: tuple[ChannelObject, ...]
|
|
111
|
+
error: object | None
|
|
112
|
+
answered_action: bool
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def is_quiet(self) -> bool:
|
|
116
|
+
"""Говорит, что изменений не пришло.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
bool: True, если объектов нет.
|
|
120
|
+
"""
|
|
121
|
+
return not self.objects
|
|
122
|
+
|
|
123
|
+
def tags(self, known: dict[tuple[str, str], str] | None = None) -> dict[tuple[str, str], str]:
|
|
124
|
+
"""Собирает метки для следующего опроса, НАКАПЛИВАЯ прежние.
|
|
125
|
+
|
|
126
|
+
Ключ - пара из вида и идентификатора, а не один вид: подписка держит по
|
|
127
|
+
объекту на каждый диалог, и вид у них общий.
|
|
128
|
+
|
|
129
|
+
НАКОПЛЕНИЕ - не удобство, а условие работоспособности. Ответ несёт
|
|
130
|
+
только ИЗМЕНИВШИЕСЯ объекты: у молчавшего диалога метки в ответе нет
|
|
131
|
+
вовсе. Собирая метки лишь из последнего ответа, опрос подставлял бы
|
|
132
|
+
всем молчавшим метку «я ничего не видел» - и площадка отдавала бы их
|
|
133
|
+
целиком заново на каждом шаге.
|
|
134
|
+
|
|
135
|
+
То есть без накопления канал перестаёт быть дешевле страниц ровно в
|
|
136
|
+
тот момент, когда становится нужен: при десяти диалогах и одном
|
|
137
|
+
говорящем девять приезжали бы полностью каждый раз.
|
|
138
|
+
|
|
139
|
+
Аргументы:
|
|
140
|
+
known (dict[tuple[str, str], str] | None): Метки прошлых опросов.
|
|
141
|
+
Пришедшие в этом ответе их вытесняют.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
dict[tuple[str, str], str]: Метки по объектам.
|
|
145
|
+
"""
|
|
146
|
+
collected = dict(known or {})
|
|
147
|
+
collected.update({(one.type, one.id): one.tag for one in self.objects if one.tag})
|
|
148
|
+
return collected
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_subscription(
|
|
152
|
+
wanted: list[tuple[str, str]], tags: dict[tuple[str, str], str]
|
|
153
|
+
) -> list[list[dict[str, Any]]]:
|
|
154
|
+
"""Собирает подписку ПОРЦИЯМИ не длиннее предела.
|
|
155
|
+
|
|
156
|
+
Порции считает вызывающий, а не площадка: она обрезает лишнее молча, и
|
|
157
|
+
заметить обрезку в ответе нечем.
|
|
158
|
+
|
|
159
|
+
Объект без известной метки получает выдуманную. Это не обход защиты, а
|
|
160
|
+
значение «я ничего не видел»: площадка отвечает на него всем, что изменилось,
|
|
161
|
+
- ровно так же, как отвечает странице при первом обращении.
|
|
162
|
+
|
|
163
|
+
Аргументы:
|
|
164
|
+
wanted (list[tuple[str, str]]): На что подписываться: вид и
|
|
165
|
+
идентификатор.
|
|
166
|
+
tags (dict[tuple[str, str], str]): Метки прошлого ответа.
|
|
167
|
+
|
|
168
|
+
Возвращает:
|
|
169
|
+
list[list[dict[str, Any]]]: Подписки порциями, каждая не длиннее
|
|
170
|
+
предела.
|
|
171
|
+
"""
|
|
172
|
+
objects = [
|
|
173
|
+
{
|
|
174
|
+
"type": kind,
|
|
175
|
+
"id": entity,
|
|
176
|
+
"tag": tags.get((kind, entity), UNSEEN_TAG),
|
|
177
|
+
"data": False,
|
|
178
|
+
}
|
|
179
|
+
for kind, entity in wanted
|
|
180
|
+
]
|
|
181
|
+
return [
|
|
182
|
+
objects[at : at + MAX_SUBSCRIPTION] for at in range(0, len(objects), MAX_SUBSCRIPTION)
|
|
183
|
+
] or [[]]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
#: Метка, означающая «я ничего не видел».
|
|
187
|
+
#:
|
|
188
|
+
#: Годится любая несуществующая: площадка отвечает на неё всем, что изменилось.
|
|
189
|
+
#: Наблюдено 30.08.2026 - подписка с этой самой строкой вернула оба объекта
|
|
190
|
+
#: целиком, а повторная с вернувшимися метками вернула пустоту.
|
|
191
|
+
UNSEEN_TAG: Final[str] = "0000000000"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def load_runner_json(body: str) -> object:
|
|
195
|
+
"""Читает JSON канала без потери полей и неконечных чисел.
|
|
196
|
+
|
|
197
|
+
Опрос и отправка делят один декодер. Ошибки ValueError/RecursionError
|
|
198
|
+
вызывающий переводит в свой исход: откат к страницам либо unconfirmed.
|
|
199
|
+
"""
|
|
200
|
+
return load_json(body)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse_updates_answer(body: str) -> UpdatesAnswer:
|
|
204
|
+
"""Разбирает тело ответа канала.
|
|
205
|
+
|
|
206
|
+
Декодер JSON общий с отправкой. Опрос дополнительно проверяет все объекты
|
|
207
|
+
подписки, прежде чем вызывающий сможет подтвердить их метки.
|
|
208
|
+
|
|
209
|
+
Аргументы:
|
|
210
|
+
body (str): Тело ответа.
|
|
211
|
+
|
|
212
|
+
Возвращает:
|
|
213
|
+
UpdatesAnswer: Разобранный ответ.
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
ProtocolChangedError: Если тело не разбирается либо устроено не так,
|
|
217
|
+
как наблюдалось.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
parsed = load_runner_json(body)
|
|
221
|
+
except (ValueError, RecursionError) as exc:
|
|
222
|
+
raise ProtocolChangedError(
|
|
223
|
+
"ответ канала обновлений не разобрался как JSON. Канал отвечал JSON "
|
|
224
|
+
"во всех наблюдениях; разбор чего-то иного означал бы, что мы "
|
|
225
|
+
"приняли за канал не канал"
|
|
226
|
+
) from exc
|
|
227
|
+
|
|
228
|
+
if not isinstance(parsed, dict):
|
|
229
|
+
raise ProtocolChangedError(
|
|
230
|
+
f"ответ канала обновлений - не объект, а {type(parsed).__name__}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
raw = parsed.get("objects")
|
|
234
|
+
if not isinstance(raw, list):
|
|
235
|
+
raise ProtocolChangedError(
|
|
236
|
+
"в ответе канала обновлений нет перечня объектов. Пустой перечень - "
|
|
237
|
+
"штатное состояние, а отсутствие поля означает другой ответ"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
answer = parsed.get("response")
|
|
241
|
+
# Объект при опросе С ДЕЙСТВИЕМ, логическое - без действия. Различие
|
|
242
|
+
# наблюдено, и по нему же читается ошибка: у логического ошибке взяться
|
|
243
|
+
# неоткуда.
|
|
244
|
+
error = None
|
|
245
|
+
if isinstance(answer, dict):
|
|
246
|
+
if "error" not in answer:
|
|
247
|
+
raise ProtocolChangedError("в ответе действия канала нет поля error")
|
|
248
|
+
action = True
|
|
249
|
+
error = answer["error"]
|
|
250
|
+
elif isinstance(answer, bool):
|
|
251
|
+
action = False
|
|
252
|
+
else:
|
|
253
|
+
raise ProtocolChangedError("поле response канала - не логическое и не объект действия")
|
|
254
|
+
|
|
255
|
+
objects: list[ChannelObject] = []
|
|
256
|
+
seen: set[tuple[str, str]] = set()
|
|
257
|
+
for one in raw:
|
|
258
|
+
if not isinstance(one, dict):
|
|
259
|
+
raise ProtocolChangedError("элемент objects канала - не объект")
|
|
260
|
+
kind = one.get("type")
|
|
261
|
+
if not isinstance(kind, str) or not kind:
|
|
262
|
+
raise ProtocolChangedError("у объекта канала нет непустого строкового type")
|
|
263
|
+
entity = one.get("id")
|
|
264
|
+
if not (type(entity) is int or isinstance(entity, str) and entity):
|
|
265
|
+
raise ProtocolChangedError("id объекта канала - не целое число и не непустая строка")
|
|
266
|
+
tag = one.get("tag")
|
|
267
|
+
if not isinstance(tag, str) or not tag:
|
|
268
|
+
raise ProtocolChangedError("у объекта канала нет непустой строковой tag")
|
|
269
|
+
data = one.get("data")
|
|
270
|
+
if not isinstance(data, dict):
|
|
271
|
+
raise ProtocolChangedError("data объекта канала - не объект")
|
|
272
|
+
key = (kind, str(entity))
|
|
273
|
+
try:
|
|
274
|
+
for value in (*key, tag):
|
|
275
|
+
value.encode("utf-8")
|
|
276
|
+
except UnicodeEncodeError:
|
|
277
|
+
raise ProtocolChangedError(
|
|
278
|
+
"ключ или метка канала содержит некорректный Unicode"
|
|
279
|
+
) from None
|
|
280
|
+
if key in seen:
|
|
281
|
+
raise ProtocolChangedError("в ответе канала повторяется объект подписки")
|
|
282
|
+
seen.add(key)
|
|
283
|
+
objects.append(
|
|
284
|
+
ChannelObject(
|
|
285
|
+
type=kind,
|
|
286
|
+
id=key[1],
|
|
287
|
+
tag=tag,
|
|
288
|
+
data=data,
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return UpdatesAnswer(objects=tuple(objects), error=error, answered_action=action)
|
funora/_verdicts.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Превращение вердикта классификатора в ошибку.
|
|
2
|
+
|
|
3
|
+
Отображение живёт в спецификации и порождается, а не пишется: от него зависит,
|
|
4
|
+
повторит клиент запрос или остановится навсегда. Здесь только то, что таблицей
|
|
5
|
+
не выражается, - три уточнения, зависящие от состояния клиента, а не от ответа.
|
|
6
|
+
|
|
7
|
+
Первое. Истёкшая сессия и неверный секрет выглядят одинаково, а лечатся
|
|
8
|
+
по-разному: первую имеет смысл обновить и повторить, второй повторять
|
|
9
|
+
бессмысленно, и попытка обновления только добавит подозрительных запросов к
|
|
10
|
+
аккаунту, который и так под вопросом. Различает их одно: подтверждалась ли
|
|
11
|
+
сессия хоть раз за время жизни клиента.
|
|
12
|
+
|
|
13
|
+
Второе. Вердикт, принятый непроверенной сигнатурой, ошибку поднимает - не
|
|
14
|
+
заметить настоящую страницу проверки опаснее, чем сработать по тексту, - но
|
|
15
|
+
признак непроверенности переносится в экземпляр. По такой ошибке нельзя
|
|
16
|
+
останавливаться навсегда: страницы, ради которой написана сигнатура, никто не
|
|
17
|
+
видел.
|
|
18
|
+
|
|
19
|
+
Третье. Пара, которой нет в таблице, означает, что классификатор вернул вердикт,
|
|
20
|
+
не предусмотренный спецификацией. Это не повод придумать ошибку на месте:
|
|
21
|
+
придуманная ошибка разойдётся между реализациями именно там, где расхождение
|
|
22
|
+
дороже всего.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from ._classify import Verdict
|
|
28
|
+
from .errors import (
|
|
29
|
+
FunoraError,
|
|
30
|
+
InvalidCredentialsError,
|
|
31
|
+
ProtocolChangedError,
|
|
32
|
+
SessionExpiredError,
|
|
33
|
+
)
|
|
34
|
+
from .response_classes import VERDICT_ERRORS
|
|
35
|
+
|
|
36
|
+
__all__ = ["error_for", "PROVISIONAL_ATTR"]
|
|
37
|
+
|
|
38
|
+
#: Имя признака непроверенности на экземпляре ошибки.
|
|
39
|
+
#:
|
|
40
|
+
#: Признак живёт на экземпляре, а не на классе: непроверенность - свойство
|
|
41
|
+
#: конкретного решения, а не вида ошибки. Один и тот же ChallengeRequiredError
|
|
42
|
+
#: может быть поднят и по структурному признаку, и по догадке о тексте.
|
|
43
|
+
PROVISIONAL_ATTR = "provisional"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def error_for(
|
|
47
|
+
verdict: Verdict,
|
|
48
|
+
*,
|
|
49
|
+
session_ever_valid: bool = False,
|
|
50
|
+
) -> FunoraError | None:
|
|
51
|
+
"""Возвращает ошибку, соответствующую вердикту.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
verdict (Verdict): Вердикт классификатора.
|
|
55
|
+
session_ever_valid (bool): Подтверждалась ли сессия хоть раз за время
|
|
56
|
+
жизни клиента. Отличает истёкшую сессию от неверного секрета.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
FunoraError | None: Экземпляр ошибки либо None, если ответ пригоден для
|
|
60
|
+
разбора. У экземпляра выставлен признак provisional, показывающий, было
|
|
61
|
+
ли решение принято непроверенной сигнатурой.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ProtocolChangedError: Если пара «класс, причина» отсутствует в таблице.
|
|
65
|
+
Придумать ошибку на месте нельзя: придуманная разойдётся между
|
|
66
|
+
реализациями.
|
|
67
|
+
"""
|
|
68
|
+
key = (str(verdict.cls), verdict.reason)
|
|
69
|
+
if key not in VERDICT_ERRORS:
|
|
70
|
+
raise ProtocolChangedError(
|
|
71
|
+
f"классификатор вернул вердикт {key}, которого нет в таблице "
|
|
72
|
+
"соответствия. Либо таблица отстала от спецификации, либо появился "
|
|
73
|
+
"новый класс ответа"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
cls = VERDICT_ERRORS[key]
|
|
77
|
+
if cls is None:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
# Уточнение, не выразимое таблицей: истёкшая сессия и неверный секрет
|
|
81
|
+
# выглядят одинаково, но лечатся по-разному.
|
|
82
|
+
if cls is SessionExpiredError and not session_ever_valid:
|
|
83
|
+
cls = InvalidCredentialsError
|
|
84
|
+
|
|
85
|
+
detail = f"{verdict.cls}: {verdict.reason}"
|
|
86
|
+
if verdict.matched:
|
|
87
|
+
detail += f" (сигнатура {verdict.matched})"
|
|
88
|
+
|
|
89
|
+
error = cls(detail)
|
|
90
|
+
setattr(error, PROVISIONAL_ATTR, verdict.provisional)
|
|
91
|
+
return error # type: ignore[return-value]
|
funora/_viewing.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Что покупатель смотрит прямо сейчас.
|
|
2
|
+
|
|
3
|
+
РАСКОЛ НАБЛЮДЕНИЯ, И ОН ЗДЕСЬ ГЛАВНОЕ. Подписка на этот объект наблюдена НАМИ -
|
|
4
|
+
она лежит в семи наших записях канала, и состав её известен: признак,
|
|
5
|
+
идентификатор из восьми цифр, метка из восьми знаков.
|
|
6
|
+
|
|
7
|
+
Ответ на неё мы не видели НИ РАЗУ. Что приходит внутри, известно от независимой
|
|
8
|
+
реализации того же протокола: при пустом признаке покупатель не смотрит ничего,
|
|
9
|
+
иначе внутри лежит разметка со ссылкой на лот.
|
|
10
|
+
|
|
11
|
+
Отсюда устройство записи: разметка сохраняется КАК ЕСТЬ, а ссылка и подпись
|
|
12
|
+
читаются из неё отдельными полями. Не разобралась - поля остаются
|
|
13
|
+
ненаблюдёнными, а разметка при вызывающем: по ней человек увидит то, чего не
|
|
14
|
+
увидел разбор.
|
|
15
|
+
|
|
16
|
+
ИМЯ ОБЪЕКТА ЗАПИСАНО ДОСЛОВНО и странно выглядит. Что оно означает - неизвестно,
|
|
17
|
+
и догадываться незачем: подписка собирается по имени, а не по смыслу.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import Any, Final
|
|
25
|
+
|
|
26
|
+
from selectolax.parser import HTMLParser
|
|
27
|
+
|
|
28
|
+
from ._observed import Observed
|
|
29
|
+
|
|
30
|
+
__all__ = ["BuyerViewing", "parse_buyer_viewing", "VIEWING_OBJECT"]
|
|
31
|
+
|
|
32
|
+
#: Имя объекта подписки. Наблюдено НАМИ в семи записях канала.
|
|
33
|
+
#:
|
|
34
|
+
#: Записано дословно. Как оно добралось до записи, стоит помнить: прежний
|
|
35
|
+
#: образец протокольного знака его не пропускал, и сборщик записал вместо имени
|
|
36
|
+
#: мерку. Образец расширили дефисом ПО ЭТОЙ МЕРКЕ, а не наугад.
|
|
37
|
+
VIEWING_OBJECT: Final[str] = "c-p-u"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class BuyerViewing:
|
|
42
|
+
"""Что покупатель смотрит прямо сейчас.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
buyer_id (str): Идентификатор покупателя, о котором спрашивали.
|
|
46
|
+
viewing (bool): Смотрит ли он что-нибудь. ЛОЖЬ ЗДЕСЬ - НАБЛЮДЕНИЕ:
|
|
47
|
+
площадка прислала пустой признак, то есть сказала «не смотрит».
|
|
48
|
+
Неудачу чтения выражает отказ.
|
|
49
|
+
lot_href (Observed[str]): Ссылка на лот, прочитанная из разметки.
|
|
50
|
+
lot_text (Observed[str]): Подпись лота на локали интерфейса. Не
|
|
51
|
+
разбирается.
|
|
52
|
+
raw_html (Observed[str]): Разметка блока, КАК ЕСТЬ. Сохраняется затем,
|
|
53
|
+
что ответа этой точки мы не наблюдали: не разберись наши поля - у
|
|
54
|
+
вызывающего останется то, из чего он поймёт сам.
|
|
55
|
+
observed_at (datetime): Момент получения ответа.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
buyer_id: str
|
|
59
|
+
viewing: bool
|
|
60
|
+
lot_href: Observed[str]
|
|
61
|
+
lot_text: Observed[str]
|
|
62
|
+
raw_html: Observed[str]
|
|
63
|
+
observed_at: datetime
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _from_markup(markup: str) -> tuple[Observed[str], Observed[str]]:
|
|
67
|
+
"""Читает ссылку и подпись из разметки блока просмотра.
|
|
68
|
+
|
|
69
|
+
РАЗБОР ЗДЕСЬ САМЫЙ ПРОСТОЙ ИЗ ВОЗМОЖНЫХ - первая ссылка, - и это не лень.
|
|
70
|
+
Разметки этой мы не видели; строить по чужому описанию точный селектор
|
|
71
|
+
значило бы выдать чужое описание за наблюдение.
|
|
72
|
+
|
|
73
|
+
Аргументы:
|
|
74
|
+
markup (str): Разметка блока.
|
|
75
|
+
|
|
76
|
+
Возвращает:
|
|
77
|
+
tuple[Observed[str], Observed[str]]: Ссылка и подпись.
|
|
78
|
+
"""
|
|
79
|
+
node = HTMLParser(markup).css_first("a[href]")
|
|
80
|
+
if node is None:
|
|
81
|
+
return (
|
|
82
|
+
Observed.missing("viewing_link_absent"),
|
|
83
|
+
Observed.missing("viewing_text_absent"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
raw_href = (node.attributes or {}).get("href")
|
|
87
|
+
href = (raw_href or "").strip()
|
|
88
|
+
text = (node.text() or "").strip()
|
|
89
|
+
return (
|
|
90
|
+
Observed.present(href) if href else Observed.empty(""),
|
|
91
|
+
Observed.present(text) if text else Observed.empty(""),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_buyer_viewing(obj: Any, *, buyer_id: str, observed_at: datetime) -> BuyerViewing:
|
|
96
|
+
"""Разбирает объект «покупатель смотрит» из ответа канала.
|
|
97
|
+
|
|
98
|
+
ПУСТОЙ ПРИЗНАК - НАБЛЮДЕНИЕ, А НЕ НЕУДАЧА. Площадка сказала «не смотрит», и
|
|
99
|
+
показать это продавцу можно. Неудачу чтения выражает отказ, а не ложь в
|
|
100
|
+
поле.
|
|
101
|
+
|
|
102
|
+
Аргументы:
|
|
103
|
+
obj (Any): Объект из ответа канала.
|
|
104
|
+
buyer_id (str): Идентификатор покупателя, о котором спрашивали.
|
|
105
|
+
observed_at (datetime): Момент получения.
|
|
106
|
+
|
|
107
|
+
Возвращает:
|
|
108
|
+
BuyerViewing: Что смотрит покупатель.
|
|
109
|
+
"""
|
|
110
|
+
empty = BuyerViewing(
|
|
111
|
+
buyer_id=buyer_id,
|
|
112
|
+
viewing=False,
|
|
113
|
+
lot_href=Observed.missing("not_viewing"),
|
|
114
|
+
lot_text=Observed.missing("not_viewing"),
|
|
115
|
+
raw_html=Observed.missing("not_viewing"),
|
|
116
|
+
observed_at=observed_at,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if not isinstance(obj, dict):
|
|
120
|
+
return empty
|
|
121
|
+
|
|
122
|
+
data = obj.get("data")
|
|
123
|
+
# Пустой признак означает «не смотрит ничего». Наблюдено это не нами -
|
|
124
|
+
# известно от сторонней реализации, - и потому обращение осторожное: всё,
|
|
125
|
+
# что не похоже на разметку, читается как «не смотрит», а не как поломка.
|
|
126
|
+
if not isinstance(data, dict):
|
|
127
|
+
return empty
|
|
128
|
+
|
|
129
|
+
raw_html = data.get("html")
|
|
130
|
+
if isinstance(raw_html, dict):
|
|
131
|
+
raw_html = raw_html.get("desktop")
|
|
132
|
+
if not isinstance(raw_html, str) or not raw_html.strip():
|
|
133
|
+
return empty
|
|
134
|
+
|
|
135
|
+
href, text = _from_markup(raw_html)
|
|
136
|
+
return BuyerViewing(
|
|
137
|
+
buyer_id=buyer_id,
|
|
138
|
+
viewing=True,
|
|
139
|
+
lot_href=href,
|
|
140
|
+
lot_text=text,
|
|
141
|
+
raw_html=Observed.present(raw_html),
|
|
142
|
+
observed_at=observed_at,
|
|
143
|
+
)
|