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/_whoami.py
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""Чтение аккаунта, проверка сессии и профиль возможностей.
|
|
2
|
+
|
|
3
|
+
Три операции об одном: от чьего имени работает клиент и годна ли его сессия.
|
|
4
|
+
|
|
5
|
+
Имя модуля - _whoami, а не _identity: последнее занято сетевой идентичностью,
|
|
6
|
+
то есть парой «исходящий адрес и целевой хост». Разные вещи с похожими именами
|
|
7
|
+
стоят рядом, и путать их дорого: одно про то, кем нас видит площадка, другое про
|
|
8
|
+
то, откуда мы к ней приходим.
|
|
9
|
+
|
|
10
|
+
ЧИТАЕТСЯ СТРАНИЦА С ВИДЖЕТОМ ПЕРЕПИСКИ, и это не безразличная подробность.
|
|
11
|
+
Собственный идентификатор лежит в атрибуте data-user, а атрибут этот есть только
|
|
12
|
+
там, где виджет есть: на списке продаж и на странице баланса его нет вовсе. С
|
|
13
|
+
любой авторизованной страницы прочесть себя нельзя.
|
|
14
|
+
|
|
15
|
+
ИМЯ И ИДЕНТИФИКАТОР ЧИТАЮТСЯ ПО-РАЗНОМУ. Идентификатор - из атрибута, имя - из
|
|
16
|
+
текста в шапке, и узлов имени ДВА: настольное меню и мобильное. Значение в них
|
|
17
|
+
одно и то же, и разбор обязан их сверить: разошлись - разметка изменилась.
|
|
18
|
+
|
|
19
|
+
ПРОФИЛЬ ВОЗМОЖНОСТЕЙ СОБИРАЕТСЯ БЕЗ СЕТИ. Он отвечает на «что этот клиент умеет
|
|
20
|
+
прямо сейчас», а это уже известно из того, что наблюдалось. Неуспешная проба
|
|
21
|
+
даёт unknown, а не unsupported: отсутствие объявляется только по положительному
|
|
22
|
+
свидетельству, и неудача таковым не является.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from datetime import UTC, datetime
|
|
30
|
+
from threading import RLock
|
|
31
|
+
from typing import Final, Literal
|
|
32
|
+
|
|
33
|
+
from selectolax.parser import HTMLParser, Node
|
|
34
|
+
|
|
35
|
+
from ._classify import ResponseClass, Verdict
|
|
36
|
+
from ._observed import Observed
|
|
37
|
+
from ._result import Defect, Severity
|
|
38
|
+
from ._secret import Secret
|
|
39
|
+
from .capabilities import CAPABILITY_INITIAL, Capability, CapabilityState
|
|
40
|
+
from .errors import ProtocolChangedError
|
|
41
|
+
from .extraction import SELECTORS
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"Account",
|
|
45
|
+
"AppData",
|
|
46
|
+
"CapabilityProfile",
|
|
47
|
+
"CapabilityEvaluation",
|
|
48
|
+
"SessionHealth",
|
|
49
|
+
"parse_account",
|
|
50
|
+
"parse_app_data",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
_OWN_ID: Final[str] = SELECTORS["session.identity.own_user_id"]
|
|
54
|
+
_OWN_NAME: Final[str] = SELECTORS["session.identity.own_username"]
|
|
55
|
+
_APP_DATA: Final[str] = SELECTORS["session.identity.app_data"]
|
|
56
|
+
|
|
57
|
+
#: Имя ключа защитного токена внутри объекта настроек страницы.
|
|
58
|
+
_CSRF_KEY: Final[str] = "csrf-token"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class Account:
|
|
63
|
+
"""Аккаунт, от имени которого работает клиент.
|
|
64
|
+
|
|
65
|
+
Балансов здесь нет намеренно: они лежат на другой странице, и читать её ради
|
|
66
|
+
профиля значило бы ходить на площадку дважды за одним ответом. Отсутствие
|
|
67
|
+
поля означает «не наблюдали», а не «нулю равно».
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
user_id (Observed[str]): Собственный идентификатор из атрибута.
|
|
71
|
+
username (Observed[str]): Собственное отображаемое имя.
|
|
72
|
+
locale (Observed[str]): Метка языка страницы.
|
|
73
|
+
observed_at (datetime): Момент наблюдения.
|
|
74
|
+
defects (tuple[Defect, ...]): Замеченные повреждения.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
user_id: Observed[str]
|
|
78
|
+
username: Observed[str]
|
|
79
|
+
locale: Observed[str]
|
|
80
|
+
observed_at: datetime
|
|
81
|
+
defects: tuple[Defect, ...] = ()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class SessionHealth:
|
|
86
|
+
"""Пригодность сессии.
|
|
87
|
+
|
|
88
|
+
Attributes:
|
|
89
|
+
response_class (ResponseClass): Класс ответа площадки.
|
|
90
|
+
is_usable (bool): Годится ли сессия для работы прямо сейчас.
|
|
91
|
+
reason (str): Машиночитаемая причина решения классификатора.
|
|
92
|
+
provisional (bool): Принято ли решение непроверенной сигнатурой.
|
|
93
|
+
checked_at (datetime): Момент проверки.
|
|
94
|
+
from_cache (bool): Отдан ли ответ из кэша, а не получен запросом.
|
|
95
|
+
unsafe_marks (frozenset[str]): Какие защиты вызывающий снял с себя сам.
|
|
96
|
+
|
|
97
|
+
Поле заведено потому, что отметки ставились и не читались. Понижение
|
|
98
|
+
нижнего предела опроса помечалось внутри расписания с самого его
|
|
99
|
+
появления, и не смотрел на эту пометку никто: защита снималась, след
|
|
100
|
+
оставался, и вёл он в никуда.
|
|
101
|
+
|
|
102
|
+
Перечень имён закрыт контрактом. Снять защиту молча нельзя: новое
|
|
103
|
+
послабление обязано сперва получить имя в спецификации.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
response_class: ResponseClass
|
|
107
|
+
is_usable: bool
|
|
108
|
+
reason: str
|
|
109
|
+
provisional: bool
|
|
110
|
+
checked_at: datetime
|
|
111
|
+
from_cache: bool
|
|
112
|
+
unsafe_marks: frozenset[str] = frozenset()
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def of(
|
|
116
|
+
cls,
|
|
117
|
+
verdict: Verdict,
|
|
118
|
+
checked_at: datetime,
|
|
119
|
+
*,
|
|
120
|
+
from_cache: bool,
|
|
121
|
+
unsafe_marks: frozenset[str] = frozenset(),
|
|
122
|
+
) -> SessionHealth:
|
|
123
|
+
"""Собирает ответ из вердикта классификатора.
|
|
124
|
+
|
|
125
|
+
Годной сессия считается РОВНО при классе ok. Прочие классы означают
|
|
126
|
+
разное, но ни один из них не означает «можно работать»: правило «всё,
|
|
127
|
+
кроме явного отказа, годится» однажды приняло бы страницу проверки за
|
|
128
|
+
рабочую.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
verdict (Verdict): Вердикт классификатора.
|
|
132
|
+
checked_at (datetime): Момент проверки.
|
|
133
|
+
from_cache (bool): Отдан ли ответ из кэша.
|
|
134
|
+
unsafe_marks (frozenset[str]): Снятые вызывающим защиты.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
SessionHealth: Пригодность сессии.
|
|
138
|
+
"""
|
|
139
|
+
return cls(
|
|
140
|
+
response_class=verdict.cls,
|
|
141
|
+
is_usable=verdict.cls is ResponseClass.OK,
|
|
142
|
+
reason=verdict.reason,
|
|
143
|
+
provisional=verdict.provisional,
|
|
144
|
+
checked_at=checked_at,
|
|
145
|
+
from_cache=from_cache,
|
|
146
|
+
unsafe_marks=unsafe_marks,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True)
|
|
151
|
+
class CapabilityEvaluation:
|
|
152
|
+
"""Основание состояния одной возможности.
|
|
153
|
+
|
|
154
|
+
evaluated_at - время оценки, которое чтение профиля не обновляет.
|
|
155
|
+
source описывает свидетельство: таблица адаптера, результат операции или
|
|
156
|
+
сброс после отказа. Это не source из реестра способов определения функций.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
state: CapabilityState
|
|
160
|
+
evaluated_at: datetime
|
|
161
|
+
source: Literal["static", "probe", "observed_failure"]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True, slots=True)
|
|
165
|
+
class CapabilityProfile:
|
|
166
|
+
"""Снимок возможностей без сети.
|
|
167
|
+
|
|
168
|
+
observed_at - время сборки снимка. Время и основание каждой оценки
|
|
169
|
+
доступны отдельно через evaluation_of; получение снимка их не освежает.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
observed_at: datetime
|
|
173
|
+
_evaluations: dict[Capability, CapabilityEvaluation] = field(repr=False, default_factory=dict)
|
|
174
|
+
|
|
175
|
+
def states(self) -> dict[Capability, CapabilityState]:
|
|
176
|
+
"""Возвращает независимую копию состояний всех возможностей."""
|
|
177
|
+
return {key: value.state for key, value in self._evaluations.items()}
|
|
178
|
+
|
|
179
|
+
def state_of(self, capability: Capability) -> CapabilityState:
|
|
180
|
+
"""Возвращает состояние; отсутствующий ключ означает дефект профиля."""
|
|
181
|
+
return self._evaluations[capability].state
|
|
182
|
+
|
|
183
|
+
def evaluations(self) -> dict[Capability, CapabilityEvaluation]:
|
|
184
|
+
"""Возвращает копию оценок с их основаниями и временем."""
|
|
185
|
+
return dict(self._evaluations)
|
|
186
|
+
|
|
187
|
+
def evaluation_of(self, capability: Capability) -> CapabilityEvaluation:
|
|
188
|
+
"""Возвращает неизменяемую оценку одной возможности."""
|
|
189
|
+
return self._evaluations[capability]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _CapabilityStates:
|
|
193
|
+
"""Хранит состояние и его основание одной неделимой записью.
|
|
194
|
+
|
|
195
|
+
Обновление из существующих операций остаётся индексным присваиванием.
|
|
196
|
+
Снимок и сброс защищены той же блокировкой, чтобы параллельное чтение
|
|
197
|
+
профиля не соединяло состояние до сброса с его новым основанием.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(self) -> None:
|
|
201
|
+
self._lock = RLock()
|
|
202
|
+
self._evaluations: dict[Capability, CapabilityEvaluation] = {}
|
|
203
|
+
self.reset()
|
|
204
|
+
|
|
205
|
+
def __getitem__(self, capability: Capability) -> CapabilityState:
|
|
206
|
+
with self._lock:
|
|
207
|
+
return self._evaluations[capability].state
|
|
208
|
+
|
|
209
|
+
def __setitem__(self, capability: Capability, state: CapabilityState) -> None:
|
|
210
|
+
with self._lock:
|
|
211
|
+
self._evaluations[capability] = CapabilityEvaluation(state, datetime.now(UTC), "probe")
|
|
212
|
+
|
|
213
|
+
def snapshot(self) -> CapabilityProfile:
|
|
214
|
+
with self._lock:
|
|
215
|
+
return CapabilityProfile(datetime.now(UTC), dict(self._evaluations))
|
|
216
|
+
|
|
217
|
+
def reset(self) -> None:
|
|
218
|
+
with self._lock:
|
|
219
|
+
now = datetime.now(UTC)
|
|
220
|
+
self._evaluations = {
|
|
221
|
+
cap: CapabilityEvaluation(state, now, "static")
|
|
222
|
+
for cap, state in CAPABILITY_INITIAL.items()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
def invalidate(self) -> None:
|
|
226
|
+
with self._lock:
|
|
227
|
+
now = datetime.now(UTC)
|
|
228
|
+
self._evaluations = {
|
|
229
|
+
cap: CapabilityEvaluation(
|
|
230
|
+
# Отказ не снимает запрет или требование согласия из контракта.
|
|
231
|
+
initial if not initial.usable else CapabilityState.UNKNOWN,
|
|
232
|
+
now,
|
|
233
|
+
"observed_failure",
|
|
234
|
+
)
|
|
235
|
+
for cap, initial in CAPABILITY_INITIAL.items()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _text(node: Node | None, name: str) -> Observed[str]:
|
|
240
|
+
"""Извлекает текст узла как наблюдение.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
node (Node | None): Узел либо None.
|
|
244
|
+
name (str): Имя поля для причины отсутствия.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Observed[str]: Наблюдение.
|
|
248
|
+
"""
|
|
249
|
+
if node is None:
|
|
250
|
+
return Observed.missing(f"selector_no_match:{name}")
|
|
251
|
+
value = " ".join((node.text() or "").split())
|
|
252
|
+
return Observed.present(value) if value else Observed.empty("")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _attribute(node: Node | None, name: str, field_name: str) -> Observed[str]:
|
|
256
|
+
"""Читает атрибут, различая три исхода.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
node (Node | None): Узел либо None.
|
|
260
|
+
name (str): Имя атрибута.
|
|
261
|
+
field_name (str): Имя поля для причины отсутствия.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Observed[str]: Наблюдение.
|
|
265
|
+
"""
|
|
266
|
+
if node is None:
|
|
267
|
+
return Observed.missing(f"selector_no_match:{field_name}")
|
|
268
|
+
attributes = node.attributes or {}
|
|
269
|
+
if name not in attributes:
|
|
270
|
+
return Observed.missing(f"attribute_absent:{field_name}")
|
|
271
|
+
value = (attributes.get(name) or "").strip()
|
|
272
|
+
return Observed.present(value) if value else Observed.empty("")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _username(tree: HTMLParser) -> tuple[Observed[str], list[Defect]]:
|
|
276
|
+
"""Читает собственное имя и сверяет два его носителя.
|
|
277
|
+
|
|
278
|
+
Узлов имени два - настольное меню и мобильное, - и значение в них одно и то
|
|
279
|
+
же. Взять первый попавшийся значило бы повторить ошибку, на которой уже
|
|
280
|
+
спотыкались разбор списка продаж и разбор отзывов.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
tree (HTMLParser): Разобранная страница.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
tuple[Observed[str], list[Defect]]: Имя и перечень повреждений.
|
|
287
|
+
"""
|
|
288
|
+
nodes = tree.css(_OWN_NAME)
|
|
289
|
+
if not nodes:
|
|
290
|
+
return Observed.missing(f"selector_no_match:{_OWN_NAME}"), []
|
|
291
|
+
|
|
292
|
+
values = {" ".join((one.text() or "").split()) for one in nodes}
|
|
293
|
+
if len(values) > 1:
|
|
294
|
+
return (
|
|
295
|
+
Observed.missing("username_carriers_disagree"),
|
|
296
|
+
[
|
|
297
|
+
Defect(
|
|
298
|
+
severity=Severity.PAGE,
|
|
299
|
+
code="username_carriers_disagree",
|
|
300
|
+
detail=(
|
|
301
|
+
f"узлы имени разошлись, прочитано {sorted(values)}. "
|
|
302
|
+
"Взять любое значило бы выбрать наугад"
|
|
303
|
+
),
|
|
304
|
+
field_name="username",
|
|
305
|
+
)
|
|
306
|
+
],
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
value = values.pop()
|
|
310
|
+
return (Observed.present(value) if value else Observed.empty("")), []
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def parse_account(html: str, observed_at: datetime) -> Account:
|
|
314
|
+
"""Разбирает страницу и собирает сведения о собственном аккаунте.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
html (str): Тело страницы. Предполагается уже классифицированным как
|
|
318
|
+
пригодное: состояние сессии здесь не проверяется.
|
|
319
|
+
observed_at (datetime): Момент наблюдения. Передаётся снаружи, чтобы
|
|
320
|
+
разбор оставался чистым и повторяемым на сохранённом снимке.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Account: Собственный идентификатор, имя и метка языка.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
ProtocolChangedError: Если на странице нет ни одного признака собственной
|
|
327
|
+
личности. Пустой ответ вернуть нельзя: он неотличим от страницы,
|
|
328
|
+
отданной не нам.
|
|
329
|
+
"""
|
|
330
|
+
tree = HTMLParser(html)
|
|
331
|
+
|
|
332
|
+
carrier = tree.css_first(_OWN_ID)
|
|
333
|
+
if carrier is None and not tree.css(_OWN_NAME):
|
|
334
|
+
raise ProtocolChangedError(
|
|
335
|
+
f"на странице нет ни носителя собственного идентификатора ({_OWN_ID}), "
|
|
336
|
+
f"ни собственного имени ({_OWN_NAME}). Пустой ответ вернуть нельзя: "
|
|
337
|
+
"он неотличим от страницы, отданной не нам"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
username, defects = _username(tree)
|
|
341
|
+
|
|
342
|
+
if carrier is None:
|
|
343
|
+
# Атрибут есть не на всякой странице: там, где виджета переписки нет,
|
|
344
|
+
# нет и его. Это не поломка - это выбор страницы, и он записан
|
|
345
|
+
# повреждением, чтобы вызывающий не принял пробел за отсутствие
|
|
346
|
+
# идентификатора у аккаунта.
|
|
347
|
+
defects.append(
|
|
348
|
+
Defect(
|
|
349
|
+
severity=Severity.PAGE,
|
|
350
|
+
code="own_id_carrier_missing",
|
|
351
|
+
detail=(
|
|
352
|
+
f"на странице нет носителя собственного идентификатора ({_OWN_ID}). "
|
|
353
|
+
"Он есть только там, где есть виджет переписки"
|
|
354
|
+
),
|
|
355
|
+
field_name="user_id",
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
root = tree.css_first("html")
|
|
360
|
+
return Account(
|
|
361
|
+
user_id=_attribute(carrier, "data-user", "user_id"),
|
|
362
|
+
username=username,
|
|
363
|
+
locale=_attribute(root, "lang", "locale"),
|
|
364
|
+
observed_at=observed_at,
|
|
365
|
+
defects=tuple(defects),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass(frozen=True, slots=True)
|
|
370
|
+
class AppData:
|
|
371
|
+
"""Настройки страницы из атрибута data-app-data.
|
|
372
|
+
|
|
373
|
+
ЗДЕСЬ ЛЕЖИТ ЗАЩИТНЫЙ ТОКЕН, без которого не собрать ни одного запроса
|
|
374
|
+
записи. Отдаётся он обёрнутым в Secret: значение видно только по явному
|
|
375
|
+
вызову reveal, и ни в repr, ни в журнал, ни в сообщение об ошибке не
|
|
376
|
+
попадает. Строкой его не отдают нарочно - подстановка в f-строку случайна,
|
|
377
|
+
вызов reveal виден при чтении кода.
|
|
378
|
+
|
|
379
|
+
Идентификатор и метка языка лежат в том же объекте ВТОРЫМИ носителями: их
|
|
380
|
+
же несут атрибут data-user и html[lang]. Сверка носителей - дело
|
|
381
|
+
вызывающего разбора, здесь читается ровно то, что написано.
|
|
382
|
+
|
|
383
|
+
Attributes:
|
|
384
|
+
csrf_token (Secret | None): Защитный токен. None, если не прочитан.
|
|
385
|
+
user_id (Observed[str]): Собственный идентификатор из объекта.
|
|
386
|
+
locale (Observed[str]): Метка языка из объекта.
|
|
387
|
+
upload_size_max (Observed[int]): Наибольший размер выгружаемого файла в
|
|
388
|
+
байтах, КАК ЕГО ОБЪЯВИЛА ПЛОЩАДКА.
|
|
389
|
+
|
|
390
|
+
Читается, а не выдумывается, и в этом весь смысл поля. Свой предел
|
|
391
|
+
отверг бы то, что площадка приняла бы, - и отверг бы молча, на
|
|
392
|
+
стороне вызывающего, которому нечего было бы возразить.
|
|
393
|
+
defects (tuple[Defect, ...]): Замеченные повреждения.
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
csrf_token: Secret | None
|
|
397
|
+
user_id: Observed[str]
|
|
398
|
+
locale: Observed[str]
|
|
399
|
+
upload_size_max: Observed[int] = field(default_factory=lambda: Observed.missing("not_read"))
|
|
400
|
+
defects: tuple[Defect, ...] = ()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _json_field(data: dict[str, object], key: str, field_name: str) -> Observed[str]:
|
|
404
|
+
"""Читает строковое поле объекта настроек как наблюдение.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
data (dict[str, object]): Разобранный объект настроек.
|
|
408
|
+
key (str): Имя ключа в объекте.
|
|
409
|
+
field_name (str): Имя поля для причины отсутствия.
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
Observed[str]: Наблюдение.
|
|
413
|
+
"""
|
|
414
|
+
if key not in data:
|
|
415
|
+
return Observed.missing(f"key_absent:{field_name}")
|
|
416
|
+
value = data[key]
|
|
417
|
+
if not isinstance(value, str):
|
|
418
|
+
# Число здесь читалось бы как строка молча, и разница вышла бы наружу
|
|
419
|
+
# только при сравнении с другим носителем.
|
|
420
|
+
return Observed.missing(f"key_not_a_string:{field_name}")
|
|
421
|
+
value = value.strip()
|
|
422
|
+
return Observed.present(value) if value else Observed.empty("")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _upload_limit(parsed: dict[str, object]) -> Observed[int]:
|
|
426
|
+
"""Читает объявленный площадкой предел размера файла.
|
|
427
|
+
|
|
428
|
+
ПОЛЕ НАШЛОСЬ 31.08.2026, и до того перечень ключей этого объекта числился
|
|
429
|
+
полным из пяти. Ключей шесть, и шестой - именно этот.
|
|
430
|
+
|
|
431
|
+
Аргументы:
|
|
432
|
+
parsed (dict[str, object]): Разобранный объект настроек.
|
|
433
|
+
|
|
434
|
+
Возвращает:
|
|
435
|
+
Observed[int]: Предел в байтах либо причина отсутствия.
|
|
436
|
+
"""
|
|
437
|
+
options = parsed.get("uploadOptions")
|
|
438
|
+
if not isinstance(options, dict):
|
|
439
|
+
return Observed.missing("upload_options_absent")
|
|
440
|
+
|
|
441
|
+
raw = options.get("fileSizeMax")
|
|
442
|
+
# Строкой оно приходит на настоящей странице, числом - могло бы; принимаются
|
|
443
|
+
# оба, а логическое исключается отдельно: истина в Python это единица.
|
|
444
|
+
if isinstance(raw, bool):
|
|
445
|
+
return Observed.missing("upload_size_max_not_a_number")
|
|
446
|
+
if isinstance(raw, int):
|
|
447
|
+
value = raw
|
|
448
|
+
elif isinstance(raw, str) and raw.strip().isdigit():
|
|
449
|
+
value = int(raw.strip())
|
|
450
|
+
else:
|
|
451
|
+
return Observed.missing("upload_size_max_not_a_number")
|
|
452
|
+
|
|
453
|
+
# Ноль и отрицательное отвергаются: предел, запрещающий любой файл, скорее
|
|
454
|
+
# означает, что мы прочитали не то поле, чем что площадка запретила выгрузку.
|
|
455
|
+
if value <= 0:
|
|
456
|
+
return Observed.missing("upload_size_max_not_positive")
|
|
457
|
+
return Observed.present(value)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def parse_app_data(html: str) -> AppData:
|
|
461
|
+
"""Разбирает объект настроек страницы и достаёт защитный токен.
|
|
462
|
+
|
|
463
|
+
НЕ ПАДАЕТ НИ НА ЧЁМ. Атрибута может не быть, он может не разобраться, в нём
|
|
464
|
+
может не быть нужного ключа - и всё это разные обстоятельства с разными
|
|
465
|
+
причинами. Отказ здесь превратил бы «токена нет» в «страница сломана», а это
|
|
466
|
+
решается по-разному: первое ведёт к перезаходу, второе к разбирательству с
|
|
467
|
+
разметкой.
|
|
468
|
+
|
|
469
|
+
Отдельно стоит случай СТАРОГО СНИМКА. До формата скелета v8 атрибут
|
|
470
|
+
маскировался целиком, одной подписью, и разобрать его как JSON нельзя. Это
|
|
471
|
+
не поломка площадки, а возраст снимка, и причина названа отдельно.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
html (str): Тело страницы.
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
AppData: Токен, идентификатор и метка языка из объекта настроек.
|
|
478
|
+
"""
|
|
479
|
+
tree = HTMLParser(html)
|
|
480
|
+
carrier = tree.css_first(_APP_DATA)
|
|
481
|
+
if carrier is None:
|
|
482
|
+
return AppData(
|
|
483
|
+
csrf_token=None,
|
|
484
|
+
user_id=Observed.missing("app_data_carrier_missing"),
|
|
485
|
+
locale=Observed.missing("app_data_carrier_missing"),
|
|
486
|
+
defects=(
|
|
487
|
+
Defect(
|
|
488
|
+
severity=Severity.PAGE,
|
|
489
|
+
code="app_data_carrier_missing",
|
|
490
|
+
detail=f"на странице нет носителя настроек ({_APP_DATA})",
|
|
491
|
+
field_name="csrf_token",
|
|
492
|
+
),
|
|
493
|
+
),
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
raw = (carrier.attributes or {}).get("data-app-data") or ""
|
|
497
|
+
try:
|
|
498
|
+
parsed: object = json.loads(raw)
|
|
499
|
+
except ValueError:
|
|
500
|
+
parsed = None
|
|
501
|
+
|
|
502
|
+
if not isinstance(parsed, dict):
|
|
503
|
+
masked = raw.startswith("T") and ":" in raw
|
|
504
|
+
code = "app_data_masked_by_skeleton" if masked else "app_data_not_json"
|
|
505
|
+
detail = (
|
|
506
|
+
"атрибут настроек замаскирован скелетом целиком: снимок сделан "
|
|
507
|
+
"форматом старше v8, и ключей объекта в нём нет"
|
|
508
|
+
if masked
|
|
509
|
+
else "атрибут настроек не разбирается как объект JSON"
|
|
510
|
+
)
|
|
511
|
+
return AppData(
|
|
512
|
+
csrf_token=None,
|
|
513
|
+
user_id=Observed.missing(code),
|
|
514
|
+
locale=Observed.missing(code),
|
|
515
|
+
defects=(
|
|
516
|
+
Defect(severity=Severity.PAGE, code=code, detail=detail, field_name="csrf_token"),
|
|
517
|
+
),
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
defects: list[Defect] = []
|
|
521
|
+
token: Secret | None = None
|
|
522
|
+
value = parsed.get(_CSRF_KEY)
|
|
523
|
+
if not isinstance(value, str) or not value.strip():
|
|
524
|
+
# Значения токена в сообщении нет и быть не может: оно часть сессии
|
|
525
|
+
# владельца. Названо только то, чего не хватило.
|
|
526
|
+
defects.append(
|
|
527
|
+
Defect(
|
|
528
|
+
severity=Severity.PAGE,
|
|
529
|
+
code="csrf_token_absent",
|
|
530
|
+
detail=(
|
|
531
|
+
f"в объекте настроек нет пригодного ключа {_CSRF_KEY}. "
|
|
532
|
+
f"Прочитанные ключи: {sorted(parsed)}"
|
|
533
|
+
),
|
|
534
|
+
field_name="csrf_token",
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
else:
|
|
538
|
+
token = Secret(value.strip(), label=_CSRF_KEY)
|
|
539
|
+
|
|
540
|
+
return AppData(
|
|
541
|
+
csrf_token=token,
|
|
542
|
+
user_id=_json_field(parsed, "userId", "user_id"),
|
|
543
|
+
locale=_json_field(parsed, "locale", "locale"),
|
|
544
|
+
upload_size_max=_upload_limit(parsed),
|
|
545
|
+
defects=tuple(defects),
|
|
546
|
+
)
|
funora/bot/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Слой бота: наблюдение, реакция и отправка из чужого потока.
|
|
2
|
+
|
|
3
|
+
Подпакет, а не отдельный дистрибутив: он держится на внутренностях ядра, а
|
|
4
|
+
внутренности между дистрибутивами не тянут.
|
|
5
|
+
|
|
6
|
+
ЧТО ЗДЕСЬ ЕСТЬ И ЧЕГО НЕТ. Есть одно: единственный порядок, при котором
|
|
7
|
+
посторонний поток - скажем, обработчик телеграм-бота - может попросить об
|
|
8
|
+
отправке, не портя счёт ограничителя исходящих. Задание кладётся в очередь,
|
|
9
|
+
разбирает её тот же поток, что ведёт наблюдение.
|
|
10
|
+
|
|
11
|
+
АВТОВЫДАЧА ЗДЕСЬ ЕСТЬ, и устроена она вокруг одной черты: выдача идёт сама
|
|
12
|
+
только там, где все условия выполнены ПОЛОЖИТЕЛЬНО. Любое незнание - не повод
|
|
13
|
+
действовать осторожнее, а повод не действовать: вопрос уходит человеку.
|
|
14
|
+
|
|
15
|
+
Идентификатора предложения в заказе нет, и опознаётся лот по описанию - тому
|
|
16
|
+
самому свободному тексту, часть которого дописывает покупатель. Поэтому
|
|
17
|
+
уверенность у опознания всегда выведенная, а двусмысленность - отказ, а не
|
|
18
|
+
выбор наугад.
|
|
19
|
+
|
|
20
|
+
Управления лотами и рассылки нет. Операций записи над лотами никто не
|
|
21
|
+
наблюдал, а пределы отправки объявлены и соблюдаются. Подробности - в
|
|
22
|
+
docs/guide/bot.md.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from .._delivered import Delivery, DeliveryLedger
|
|
28
|
+
from ._delivery import HOLD_FOR_OPERATOR, AutoDelivery, DeliveryDecision, DeliveryPlan
|
|
29
|
+
from ._outbox import MAX_PENDING, Outbox, SendCommand, SendTicket
|
|
30
|
+
from ._runtime import MAX_SENDS_PER_IDLE, Bot
|
|
31
|
+
from ._spool import MAX_SPOOLED, Spool, SpoolEntry, SpoolOutcome
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Bot",
|
|
35
|
+
"Outbox",
|
|
36
|
+
"SendCommand",
|
|
37
|
+
"SendTicket",
|
|
38
|
+
"MAX_PENDING",
|
|
39
|
+
"MAX_SENDS_PER_IDLE",
|
|
40
|
+
# очередь между процессами
|
|
41
|
+
"Spool",
|
|
42
|
+
"SpoolEntry",
|
|
43
|
+
"SpoolOutcome",
|
|
44
|
+
"MAX_SPOOLED",
|
|
45
|
+
# автовыдача
|
|
46
|
+
"AutoDelivery",
|
|
47
|
+
"DeliveryPlan",
|
|
48
|
+
"DeliveryDecision",
|
|
49
|
+
"DeliveryLedger",
|
|
50
|
+
"Delivery",
|
|
51
|
+
"HOLD_FOR_OPERATOR",
|
|
52
|
+
]
|