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.
Files changed (87) hide show
  1. funora/__init__.py +265 -0
  2. funora/_account.py +661 -0
  3. funora/_aclient.py +1484 -0
  4. funora/_budget.py +579 -0
  5. funora/_calc.py +182 -0
  6. funora/_canonical.py +235 -0
  7. funora/_catalog.py +473 -0
  8. funora/_chat_history.py +367 -0
  9. funora/_chats.py +392 -0
  10. funora/_chips.py +403 -0
  11. funora/_classify.py +466 -0
  12. funora/_client.py +1490 -0
  13. funora/_currency_switch.py +130 -0
  14. funora/_cursor.py +120 -0
  15. funora/_delivered.py +233 -0
  16. funora/_diff.py +752 -0
  17. funora/_engine.py +4795 -0
  18. funora/_extract.py +124 -0
  19. funora/_field_schema.py +112 -0
  20. funora/_fileio.py +58 -0
  21. funora/_gate.py +69 -0
  22. funora/_hops.py +101 -0
  23. funora/_host.py +120 -0
  24. funora/_identity.py +284 -0
  25. funora/_json.py +31 -0
  26. funora/_listen.py +312 -0
  27. funora/_lot_form.py +331 -0
  28. funora/_market.py +406 -0
  29. funora/_matching.py +147 -0
  30. funora/_money.py +279 -0
  31. funora/_monitoring.py +396 -0
  32. funora/_observed.py +237 -0
  33. funora/_order.py +552 -0
  34. funora/_order_details.py +281 -0
  35. funora/_orders.py +807 -0
  36. funora/_outbound.py +453 -0
  37. funora/_own_lots.py +301 -0
  38. funora/_poll.py +410 -0
  39. funora/_price_audit.py +281 -0
  40. funora/_proxies.py +232 -0
  41. funora/_raise.py +150 -0
  42. funora/_refund.py +102 -0
  43. funora/_result.py +157 -0
  44. funora/_retry.py +213 -0
  45. funora/_review_write.py +138 -0
  46. funora/_reviews.py +584 -0
  47. funora/_runner.py +651 -0
  48. funora/_secret.py +385 -0
  49. funora/_showcase.py +362 -0
  50. funora/_signals.py +375 -0
  51. funora/_skeleton.py +752 -0
  52. funora/_snapshot.py +276 -0
  53. funora/_state.py +279 -0
  54. funora/_stock.py +32 -0
  55. funora/_thread.py +574 -0
  56. funora/_transport.py +1023 -0
  57. funora/_updates.py +292 -0
  58. funora/_verdicts.py +91 -0
  59. funora/_viewing.py +143 -0
  60. funora/_watch.py +825 -0
  61. funora/_watch_state.py +237 -0
  62. funora/_whoami.py +546 -0
  63. funora/bot/__init__.py +52 -0
  64. funora/bot/_delivery.py +341 -0
  65. funora/bot/_outbox.py +261 -0
  66. funora/bot/_runtime.py +447 -0
  67. funora/bot/_spool.py +534 -0
  68. funora/budget.py +311 -0
  69. funora/capabilities.py +297 -0
  70. funora/conformance.py +758 -0
  71. funora/contract.py +73 -0
  72. funora/errors.py +996 -0
  73. funora/events.py +189 -0
  74. funora/extraction.py +420 -0
  75. funora/observe.py +560 -0
  76. funora/operations.py +671 -0
  77. funora/py.typed +0 -0
  78. funora/reconciliation.py +51 -0
  79. funora/response_classes.py +155 -0
  80. funora/retry.py +238 -0
  81. funora/send_outcome.py +78 -0
  82. funora/skeleton_format.py +77 -0
  83. funora-0.0.1.dev2.dist-info/METADATA +294 -0
  84. funora-0.0.1.dev2.dist-info/RECORD +87 -0
  85. funora-0.0.1.dev2.dist-info/WHEEL +4 -0
  86. funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
  87. funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
funora/_account.py ADDED
@@ -0,0 +1,661 @@
1
+ """Разбор страницы баланса аккаунта.
2
+
3
+ Страница отдаёт две вещи, и вторую не искали вовсе.
4
+
5
+ БАЛАНСЫ - за ними и шли. Схема с 0.8.0 объявляла перечень, а не одно значение,
6
+ по переписи знаков валют; разметка это подтвердила - три узла значения, ровно
7
+ три.
8
+
9
+ ТАБЛИЦА ОПЕРАЦИЙ - двадцать пять строк на той же машинерии dyn-table, что список
10
+ продаж и отзывы. Идентификатор в атрибуте, состояние в имени класса строки,
11
+ сумма отделена от знака валюты. Операции для неё в контракте не было объявлено
12
+ вовсе.
13
+
14
+ Три решения объясняют остальное.
15
+
16
+ Знак валюты в балансе не объявляется знаком валюты. Узлы значения и разделителя
17
+ чередуются, начиная с разделителя: три разделителя на три значения, а
18
+ разделяющий знак дал бы два. Строение исключает прочтение «разделитель между
19
+ значениями» и не доказывает, что там именно валюта, - сам знак замаскирован.
20
+ Поэтому поле называется тем, что о нём известно: узел перед значением.
21
+
22
+ Поля строки ищутся ВНУТРИ строки, а не по документу. Заголовок таблицы несёт
23
+ ячейку цены с тем же классом, что и строки, и поиск по документу дал бы
24
+ двадцать шесть ячеек на двадцать пять строк.
25
+
26
+ Полнота решается кнопкой догрузки, и её класс наблюдён с обеих сторон: здесь
27
+ кнопка не спрятана при двадцати пяти строках, на профиле отзывов - спрятана при
28
+ шести из шести.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ from dataclasses import dataclass, field
35
+ from datetime import datetime
36
+ from typing import Any, Final
37
+
38
+ from selectolax.parser import HTMLParser, Node
39
+
40
+ from ._observed import Observed
41
+ from ._result import Completeness, Defect, Severity, collect_rows
42
+ from .errors import IncompleteResultError, ProtocolChangedError
43
+ from .extraction import SELECTORS
44
+
45
+ __all__ = ["BalancePage", "Transaction", "parse_balance_page"]
46
+
47
+ _BALANCES_LIST: Final[str] = SELECTORS["account.balances.list"]
48
+ _BALANCE_VALUE: Final[str] = SELECTORS["account.balances.value"]
49
+ _BALANCE_DELIMITER: Final[str] = SELECTORS["account.balances.delimiter"]
50
+
51
+ _TABLE: Final[str] = SELECTORS["account.transactions.table"]
52
+ _ROWS_CONTAINER: Final[str] = SELECTORS["account.transactions.rows_container"]
53
+ _ROW: Final[str] = SELECTORS["account.transactions.row"]
54
+ _HEADER: Final[str] = SELECTORS["account.transactions.header_is_not_a_row"]
55
+
56
+ #: Носитель идентификатора и состояния - сама строка. Оба объявлены
57
+ #: отдельными правилами, и берутся они отсюда, а не из имени строки в коде:
58
+ #: разойдись спека с кодом - разойдётся молча.
59
+ _ID_CARRIER: Final[str] = SELECTORS["account.transactions.fields.transaction_id"]
60
+ _STATUS_CARRIER: Final[str] = SELECTORS["account.transactions.fields.status"]
61
+
62
+ _STATUS_TEXT: Final[str] = SELECTORS["account.transactions.fields.status_text"]
63
+ _TITLE: Final[str] = SELECTORS["account.transactions.fields.title_text"]
64
+ _DATE: Final[str] = SELECTORS["account.transactions.fields.date_text"]
65
+ _DATE_LEFT: Final[str] = SELECTORS["account.transactions.fields.date_left_text"]
66
+ _AMOUNT: Final[str] = SELECTORS["account.transactions.fields.amount_text"]
67
+ _SYMBOL: Final[str] = SELECTORS["account.transactions.fields.currency_symbol_text"]
68
+
69
+ _CONTINUE: Final[str] = SELECTORS["account.pagination.continue_button"]
70
+ _PAGE_FORM: Final[str] = SELECTORS["account.pagination.form"]
71
+
72
+ #: Приставка класса состояния операции.
73
+ #:
74
+ #: Наблюдено одно значение на двадцати пяти строках. Фильтр над таблицей
75
+ #: перечисляет четыре, но их имена - локализованный текст, и связать текст с
76
+ #: классом нечем.
77
+ _STATUS_PREFIX: Final[str] = "transaction-status-"
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class Balance:
82
+ """Один баланс аккаунта.
83
+
84
+ Attributes:
85
+ value_text (Observed[str]): Сумма, как показана.
86
+ marker_text (Observed[str]): Узел перед суммой - вероятнее всего знак
87
+ валюты, но утверждать это по снимку нельзя.
88
+ position (int): Место в перечне, считая с нуля.
89
+ """
90
+
91
+ value_text: Observed[str]
92
+ marker_text: Observed[str]
93
+ position: int
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class Transaction:
98
+ """Одна операция по счёту.
99
+
100
+ Attributes:
101
+ transaction_id (Observed[str]): Идентификатор операции.
102
+ status_class (Observed[str]): Состояние из имени класса строки.
103
+ status_text (Observed[str]): Состояние словами, локализовано.
104
+ title_text (Observed[str]): Описание операции, локализовано.
105
+ date_text (Observed[str]): Дата, как показана.
106
+ date_left_text (Observed[str]): Вторая метка времени рядом с первой.
107
+ amount_text (Observed[str]): Сумма без знака валюты.
108
+ currency_symbol_text (Observed[str]): Знак валюты.
109
+ row_index (int): Место строки на странице, считая с нуля.
110
+ """
111
+
112
+ transaction_id: Observed[str]
113
+ status_class: Observed[str]
114
+ status_text: Observed[str]
115
+ title_text: Observed[str]
116
+ date_text: Observed[str]
117
+ date_left_text: Observed[str]
118
+ amount_text: Observed[str]
119
+ currency_symbol_text: Observed[str]
120
+ row_index: int
121
+
122
+
123
+ #: Обёртка вокруг формы вывода, несущая настройки объектом.
124
+ _WITHDRAW_BOX: Final[str] = SELECTORS["account.withdrawal_options.carrier"]
125
+
126
+ #: Имя атрибута с настройками. Стоит рядом с селектором нарочно.
127
+ _WITHDRAW_DATA: Final[str] = "data-data"
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class WithdrawalOption:
132
+ """Один способ вывода средств, как его объявила площадка.
133
+
134
+ ПЕРЕЧЕНЬ ЧИТАЕТСЯ, А НЕ ЗАШИВАЕТСЯ, и это главное свойство этой записи.
135
+
136
+ Независимая реализация того же протокола держит перечень способов рукописным
137
+ списком из восьми. У площадки их ТРИНАДЦАТЬ, и два из восьми неверны:
138
+ значения binance в перечне площадки нет вовсе (там binance_usdc и
139
+ binance_usdt), а fps и yandex - разные способы, слитые у неё в один.
140
+
141
+ Рукописный перечень чужой стороны устарел молча. Наш устарел бы так же, и
142
+ потому его тут нет: ключи читаются из того, что прислала площадка.
143
+
144
+ Attributes:
145
+ key (str): Машинное имя способа: card_rub, fps, usdt_trc и прочие.
146
+ Именно оно уходит в запрос вывода.
147
+ name (Observed[str]): Название на локали интерфейса. Не разбирается.
148
+ unit (Observed[str]): Единица, в которой способ выдаёт средства.
149
+ wallet_label (Observed[str]): Подпись поля адреса кошелька - то, что
150
+ площадка просит ввести.
151
+ saved_wallets (tuple[str, ...]): Сохранённые адреса этого способа.
152
+
153
+ У наблюдённого аккаунта пусты все тринадцать. Строение наблюдено,
154
+ наполнение - нет. Пустой список здесь положительный признак:
155
+ площадка сказала «сохранённых нет», а не промолчала.
156
+ """
157
+
158
+ key: str
159
+ name: Observed[str]
160
+ unit: Observed[str]
161
+ wallet_label: Observed[str]
162
+ saved_wallets: tuple[str, ...]
163
+
164
+
165
+ @dataclass(frozen=True, slots=True)
166
+ class WithdrawalChannel:
167
+ """Канал вывода: пара «валюта счёта - способ» и сведения о комиссии.
168
+
169
+ СЛОВА «КОМИССИЯ» В ПРОЕКТЕ ДО 31.08.2026 НЕ БЫЛО НИГДЕ. Это первое место,
170
+ где она названа, - и названа она текстом, потому что иначе нельзя.
171
+
172
+ Attributes:
173
+ currency (str): Валюта счёта, с которой выводят. Код ISO 4217.
174
+ option_key (str): Машинное имя способа, к которому ведёт канал.
175
+ name (Observed[str]): Название канала на локали. Не разбирается.
176
+ fee_text (Observed[str]): Сведения о комиссии, КАК ЕСТЬ. Разбирать их мы
177
+ не станем: это текст на локали интерфейса, а локаль привязана к
178
+ аккаунту. Строить расчёт ДЕНЕГ на переводе нельзя.
179
+ """
180
+
181
+ currency: str
182
+ option_key: str
183
+ name: Observed[str]
184
+ fee_text: Observed[str]
185
+
186
+
187
+ @dataclass(frozen=True, slots=True)
188
+ class BalancePage:
189
+ """Результат чтения страницы баланса.
190
+
191
+ Балансы отдаются полем, а операции - методом. Разница не в прихоти: перечень
192
+ балансов страница показывает целиком, а операции догружаются, и открытый
193
+ список делал бы неполноту незаметной.
194
+
195
+ Attributes:
196
+ balances (tuple[Balance, ...]): Балансы по валютам.
197
+ completeness (Completeness): Полнота чтения ОПЕРАЦИЙ.
198
+ reason (str): Машиночитаемая причина, по которой полнота такова.
199
+ observed_at (datetime): Момент наблюдения.
200
+ rows_total (int): Сколько кандидатов в операции нашлось, штук.
201
+ rows_accepted (int): Сколько операций собрано, штук.
202
+ rows_rejected (int): Сколько кандидатов отброшено, штук.
203
+ withdrawal_options (tuple[WithdrawalOption, ...]): Способы вывода,
204
+ объявленные площадкой на этой же странице. Отдельного запроса за
205
+ ними нет - тот же довод, по которому здесь же лежат операции.
206
+ withdrawal_channels (tuple[WithdrawalChannel, ...]): Каналы вывода:
207
+ какой валютой каким способом и с какой комиссией.
208
+ defects (tuple[Defect, ...]): Замеченные повреждения.
209
+ """
210
+
211
+ balances: tuple[Balance, ...]
212
+ completeness: Completeness
213
+ reason: str
214
+ observed_at: datetime
215
+ rows_total: int
216
+ rows_accepted: int
217
+ rows_rejected: int
218
+ withdrawal_options: tuple[WithdrawalOption, ...] = ()
219
+ withdrawal_channels: tuple[WithdrawalChannel, ...] = ()
220
+ defects: tuple[Defect, ...] = ()
221
+ _entries: tuple[Transaction, ...] = field(repr=False, default=())
222
+
223
+ def transactions(self, *, accept_incomplete: bool = False) -> tuple[Transaction, ...]:
224
+ """Возвращает собранные операции.
225
+
226
+ Args:
227
+ accept_incomplete (bool): Признание того, что результат может быть
228
+ неполным. Без него неполный результат не выдаётся.
229
+
230
+ Returns:
231
+ tuple[Transaction, ...]: Операции в порядке появления на странице.
232
+
233
+ Raises:
234
+ IncompleteResultError: Если полнота отлична от COMPLETE, а неполнота
235
+ не признана.
236
+ """
237
+ if self.completeness is not Completeness.COMPLETE and not accept_incomplete:
238
+ raise IncompleteResultError(
239
+ f"результат неполон ({self.completeness}, причина: {self.reason}), "
240
+ f"собрано {self.rows_accepted} из {self.rows_total}, "
241
+ f"повреждений {len(self.defects)}. Передайте accept_incomplete=True, "
242
+ "если готовы работать с неполными данными"
243
+ )
244
+ return self._entries
245
+
246
+
247
+ def _text(node: Node | None, name: str) -> Observed[str]:
248
+ """Извлекает текст узла как наблюдение.
249
+
250
+ Args:
251
+ node (Node | None): Узел либо None.
252
+ name (str): Имя поля для причины отсутствия.
253
+
254
+ Returns:
255
+ Observed[str]: Наблюдение.
256
+ """
257
+ if node is None:
258
+ return Observed.missing(f"selector_no_match:{name}")
259
+ value = " ".join((node.text() or "").split())
260
+ return Observed.present(value) if value else Observed.empty("")
261
+
262
+
263
+ def _own_text(node: Node | None, name: str) -> Observed[str]:
264
+ """Извлекает СОБСТВЕННЫЙ текст узла, без вложенных.
265
+
266
+ Знак валюты лежит отдельным узлом внутри ячейки суммы, и текст целиком
267
+ склеил бы сумму со знаком - то же устройство, что у цены в списке продаж.
268
+
269
+ Args:
270
+ node (Node | None): Узел либо None.
271
+ name (str): Имя поля для причины отсутствия.
272
+
273
+ Returns:
274
+ Observed[str]: Наблюдение.
275
+ """
276
+ if node is None:
277
+ return Observed.missing(f"selector_no_match:{name}")
278
+ value = " ".join((node.text(deep=False) or "").split())
279
+ return Observed.present(value) if value else Observed.empty("")
280
+
281
+
282
+ def _balances(tree: HTMLParser) -> tuple[tuple[Balance, ...], list[Defect]]:
283
+ """Собирает балансы, сохраняя связь значения с идущим перед ним узлом.
284
+
285
+ Узлы чередуются, начиная с разделителя: три разделителя на три значения.
286
+ Разделяющий знак дал бы два на три, и строение исключает прочтение
287
+ «разделитель между значениями» - он часть своего значения.
288
+
289
+ Args:
290
+ tree (HTMLParser): Разобранная страница.
291
+
292
+ Returns:
293
+ tuple[tuple[Balance, ...], list[Defect]]: Балансы и повреждения.
294
+ """
295
+ values = tree.css(_BALANCE_VALUE)
296
+ markers = tree.css(_BALANCE_DELIMITER)
297
+
298
+ if not values:
299
+ return (), [
300
+ Defect(
301
+ severity=Severity.PAGE,
302
+ code="balances_missing",
303
+ detail=f"узлов баланса ({_BALANCE_VALUE}) на странице нет ни одного",
304
+ )
305
+ ]
306
+
307
+ defects: list[Defect] = []
308
+ if len(markers) != len(values):
309
+ defects.append(
310
+ Defect(
311
+ severity=Severity.PAGE,
312
+ code="balance_markers_mismatch",
313
+ detail=(
314
+ f"значений баланса {len(values)}, узлов перед ними {len(markers)}. "
315
+ "Связать значение с его знаком валюты нельзя: строение изменилось"
316
+ ),
317
+ )
318
+ )
319
+
320
+ out: list[Balance] = []
321
+ for index, value in enumerate(values):
322
+ marker = markers[index] if index < len(markers) else None
323
+ out.append(
324
+ Balance(
325
+ value_text=_text(value, "balance_value"),
326
+ marker_text=_text(marker, "balance_marker"),
327
+ position=index,
328
+ )
329
+ )
330
+ return tuple(out), defects
331
+
332
+
333
+ def _carrier(row: Node, selector: str) -> Node | None:
334
+ """Возвращает узел-носитель поля внутри строки.
335
+
336
+ Носителем идентификатора и состояния объявлена сама строка. Проверка
337
+ делается явной, а не подразумевается: разойдись объявление с разбором -
338
+ разойдётся молча.
339
+
340
+ Args:
341
+ row (Node): Узел строки.
342
+ selector (str): Объявленный селектор носителя.
343
+
344
+ Returns:
345
+ Node | None: Сама строка, если селектор указывает на неё; иначе
346
+ вложенный узел либо None.
347
+ """
348
+ if selector == _ROW:
349
+ return row
350
+ return row.css_first(selector)
351
+
352
+
353
+ def _row_of(node: Node, index: int) -> Transaction:
354
+ """Собирает одну операцию.
355
+
356
+ Поля ищутся ВНУТРИ строки, а не по документу: заголовок таблицы несёт ячейку
357
+ цены с тем же классом, и поиск по документу дал бы на одну ячейку больше,
358
+ чем строк.
359
+
360
+ Args:
361
+ node (Node): Узел строки.
362
+ index (int): Место строки на странице, считая с нуля.
363
+
364
+ Returns:
365
+ Transaction: Операция.
366
+ """
367
+ carrier = _carrier(node, _STATUS_CARRIER)
368
+ attributes = (carrier.attributes if carrier is not None else None) or {}
369
+ names = set((attributes.get("class") or "").split())
370
+ status = sorted(one for one in names if one.startswith(_STATUS_PREFIX))
371
+
372
+ if len(status) == 1:
373
+ status_class: Observed[str] = Observed.present(status[0])
374
+ elif not status:
375
+ status_class = Observed.missing("status_class_absent")
376
+ else:
377
+ status_class = Observed.missing("status_classes_disagree")
378
+
379
+ price = node.css_first(_AMOUNT)
380
+ return Transaction(
381
+ transaction_id=_attribute(
382
+ _carrier(node, _ID_CARRIER), "data-transaction", "transaction_id"
383
+ ),
384
+ status_class=status_class,
385
+ status_text=_text(node.css_first(_STATUS_TEXT), "status_text"),
386
+ title_text=_text(node.css_first(_TITLE), "title_text"),
387
+ date_text=_text(node.css_first(_DATE), "date_text"),
388
+ date_left_text=_text(node.css_first(_DATE_LEFT), "date_left_text"),
389
+ amount_text=_own_text(price, "amount_text"),
390
+ currency_symbol_text=_text(node.css_first(_SYMBOL), "currency_symbol_text"),
391
+ row_index=index,
392
+ )
393
+
394
+
395
+ def _attribute(node: Node | None, name: str, field_name: str) -> Observed[str]:
396
+ """Читает атрибут, различая три исхода.
397
+
398
+ Args:
399
+ node (Node | None): Узел либо None.
400
+ name (str): Имя атрибута.
401
+ field_name (str): Имя поля для причины отсутствия.
402
+
403
+ Returns:
404
+ Observed[str]: Наблюдение.
405
+ """
406
+ if node is None:
407
+ return Observed.missing(f"selector_no_match:{field_name}")
408
+ attributes = node.attributes or {}
409
+ if name not in attributes:
410
+ return Observed.missing(f"attribute_absent:{field_name}")
411
+ value = (attributes.get(name) or "").strip()
412
+ return Observed.present(value) if value else Observed.empty("")
413
+
414
+
415
+ def _totality(tree: HTMLParser) -> tuple[Completeness, str]:
416
+ """Решает, все ли операции показаны, по кнопке догрузки.
417
+
418
+ Класс hidden у этой кнопки наблюдён с ОБЕИХ сторон, и вторая сторона - эта
419
+ самая страница: здесь кнопка не спрятана при двадцати пяти строках и
420
+ продолжении, а на профиле отзывов спрятана при шести из шести.
421
+
422
+ Args:
423
+ tree (HTMLParser): Разобранная страница.
424
+
425
+ Returns:
426
+ tuple[Completeness, str]: Полнота и машиночитаемая причина.
427
+ """
428
+ button = tree.css_first(_CONTINUE)
429
+ if button is None:
430
+ # Кнопки нет вовсе. Объявлять по её отсутствию полноту значило бы
431
+ # вывести знание из ненаходки.
432
+ return Completeness.PARTIAL, "pagination_control_missing"
433
+
434
+ if "hidden" not in ((button.attributes or {}).get("class") or "").split():
435
+ return Completeness.PARTIAL, "more_rows_available"
436
+
437
+ return Completeness.COMPLETE, "all_rows_parsed"
438
+
439
+
440
+ def _text_field(source: dict[str, Any], key: str) -> Observed[str]:
441
+ """Читает строковое поле объекта настроек вывода.
442
+
443
+ Аргументы:
444
+ source (dict[str, Any]): Объект.
445
+ key (str): Имя ключа.
446
+
447
+ Возвращает:
448
+ Observed[str]: Наблюдение.
449
+ """
450
+ raw = source.get(key)
451
+ if not isinstance(raw, str):
452
+ return Observed.missing(f"{key}_not_a_string")
453
+ value = raw.strip()
454
+ return Observed.present(value) if value else Observed.empty("")
455
+
456
+
457
+ def parse_withdrawal_box(
458
+ html_source: str,
459
+ ) -> tuple[tuple[WithdrawalOption, ...], tuple[WithdrawalChannel, ...], tuple[Defect, ...]]:
460
+ """Читает способы и каналы вывода из настроек области вывода.
461
+
462
+ ПЕРЕЧЕНЬ СПОСОБОВ БЕРЁТСЯ ИЗ КЛЮЧЕЙ ОБЪЕКТА, а не из нашего списка. Список,
463
+ записанный здесь, устарел бы молча - ровно так устарел рукописный перечень
464
+ сторонней реализации, где восемь способов вместо тринадцати и два неверных.
465
+
466
+ ОТСУТСТВИЕ ОБЛАСТИ - НЕ ПОВРЕЖДЕНИЕ. Гостю она не показывается вовсе, и
467
+ объявлять это поломкой значило бы ломать чтение страницы под гостем.
468
+
469
+ Аргументы:
470
+ html_source (str): Разметка страницы баланса.
471
+
472
+ Возвращает:
473
+ tuple: Способы, каналы и перечень повреждений.
474
+ """
475
+ tree = HTMLParser(html_source)
476
+ node = tree.css_first(_WITHDRAW_BOX)
477
+ if node is None:
478
+ return (), (), ()
479
+
480
+ raw = (node.attributes or {}).get(_WITHDRAW_DATA)
481
+ if not raw:
482
+ return (), (), ()
483
+
484
+ try:
485
+ settings: Any = json.loads(raw)
486
+ except ValueError:
487
+ return (
488
+ (),
489
+ (),
490
+ (
491
+ Defect(
492
+ severity=Severity.PAGE,
493
+ code="withdraw_box_not_json",
494
+ detail="настройки области вывода не разбираются как объект JSON",
495
+ field_name="withdrawal_options",
496
+ ),
497
+ ),
498
+ )
499
+
500
+ if not isinstance(settings, dict):
501
+ return (), (), ()
502
+
503
+ options: list[WithdrawalOption] = []
504
+ raw_options = settings.get("extCurrencies")
505
+ if isinstance(raw_options, dict):
506
+ for key in sorted(raw_options):
507
+ body = raw_options[key]
508
+ if not isinstance(body, dict):
509
+ continue
510
+ raw_wallets = body.get("wallets")
511
+ options.append(
512
+ WithdrawalOption(
513
+ key=key,
514
+ name=_text_field(body, "name"),
515
+ unit=_text_field(body, "unit"),
516
+ wallet_label=_text_field(body, "walletName"),
517
+ saved_wallets=tuple(one for one in raw_wallets if isinstance(one, str))
518
+ if isinstance(raw_wallets, list)
519
+ else (),
520
+ )
521
+ )
522
+
523
+ channels: list[WithdrawalChannel] = []
524
+ raw_currencies = settings.get("currencies")
525
+ if isinstance(raw_currencies, dict):
526
+ for code in sorted(raw_currencies):
527
+ body = raw_currencies[code]
528
+ if not isinstance(body, dict):
529
+ continue
530
+ for one in body.get("channels") or []:
531
+ if not isinstance(one, dict):
532
+ continue
533
+ raw_key = one.get("extCurrency")
534
+ channels.append(
535
+ WithdrawalChannel(
536
+ # Код приводится к прописным: площадка объявляет валюты
537
+ # строчными ключами, а переключатель в шапке - прописными.
538
+ # Два вида одного кода разошлись бы при сверке молча.
539
+ currency=code.upper(),
540
+ option_key=raw_key if isinstance(raw_key, str) else "",
541
+ name=_text_field(one, "name"),
542
+ fee_text=_text_field(one, "feeInfo"),
543
+ )
544
+ )
545
+
546
+ return tuple(options), tuple(channels), ()
547
+
548
+
549
+ def parse_balance_page(html: str, observed_at: datetime) -> BalancePage:
550
+ """Разбирает страницу баланса: балансы и операции по счёту.
551
+
552
+ Args:
553
+ html (str): Тело страницы. Предполагается уже классифицированным как
554
+ пригодное: состояние сессии здесь не проверяется.
555
+ observed_at (datetime): Момент наблюдения. Передаётся снаружи, чтобы
556
+ разбор оставался чистым и повторяемым на сохранённом снимке.
557
+
558
+ Returns:
559
+ BalancePage: Балансы и операции вместе с полнотой и повреждениями.
560
+
561
+ Raises:
562
+ ProtocolChangedError: Если нет перечня балансов либо контейнера строк:
563
+ без них читать нечего, а пустой ответ неотличим от пустого счёта.
564
+ """
565
+ tree = HTMLParser(html)
566
+
567
+ if tree.css_first(_BALANCES_LIST) is None:
568
+ raise ProtocolChangedError(
569
+ f"на странице баланса нет перечня балансов ({_BALANCES_LIST}). "
570
+ "Пустой ответ вернуть нельзя: он неотличим от счёта без валют"
571
+ )
572
+
573
+ if tree.css_first(_TABLE) is None:
574
+ raise ProtocolChangedError(
575
+ f"на странице баланса нет таблицы операций ({_TABLE}). "
576
+ "Без неё нельзя отличить счёт без операций от смены разметки"
577
+ )
578
+
579
+ if tree.css_first(_ROWS_CONTAINER) is None:
580
+ raise ProtocolChangedError(
581
+ f"на странице баланса нет контейнера строк ({_ROWS_CONTAINER}). "
582
+ "Без него нельзя проверить, что строки найдены все"
583
+ )
584
+
585
+ balances, defects = _balances(tree)
586
+
587
+ if tree.css_first(_HEADER) is None:
588
+ defects.append(
589
+ Defect(
590
+ severity=Severity.PAGE,
591
+ code="header_missing",
592
+ detail="заголовок таблицы операций отсутствует, разметка изменилась",
593
+ )
594
+ )
595
+
596
+ if tree.css_first(_PAGE_FORM) is None:
597
+ defects.append(
598
+ Defect(
599
+ severity=Severity.PAGE,
600
+ code="pagination_form_missing",
601
+ detail=(
602
+ f"формы догрузки ({_PAGE_FORM}) на странице нет. Она стоит за "
603
+ "таблицей на всех наблюдённых снимках этого виджета"
604
+ ),
605
+ )
606
+ )
607
+
608
+ found = collect_rows(tree, _ROWS_CONTAINER, _ROW)
609
+ defects.extend(found.defects)
610
+
611
+ entries = tuple(_row_of(node, index) for index, node in enumerate(found.rows))
612
+ rows_total = max(len(found.rows), found.children, len(tree.css(_ROW)))
613
+
614
+ if rows_total and not entries:
615
+ raise ProtocolChangedError(
616
+ f"кандидатов в операции {rows_total}, собрать не удалось ни одной. "
617
+ "Это изменение разметки, а не пустой счёт"
618
+ )
619
+
620
+ ids = [one.transaction_id.or_none() for one in entries if one.transaction_id.is_observed]
621
+ if len(set(ids)) != len(ids):
622
+ defects.append(
623
+ Defect(
624
+ severity=Severity.PAGE,
625
+ code="duplicate_identifiers",
626
+ detail=(
627
+ f"операций {len(ids)}, различимых идентификаторов {len(set(ids))}: "
628
+ "часть записей схлопнется у всякого, кто сложит их в словарь"
629
+ ),
630
+ field_name="transaction_id",
631
+ )
632
+ )
633
+
634
+ if not rows_total:
635
+ # Счёта без единой операции никто не видел, и отличить его от
636
+ # переименованного класса строки нечем.
637
+ completeness, reason = Completeness.UNKNOWN, "empty_list_not_observed"
638
+ elif any(one.severity is Severity.PAGE for one in defects):
639
+ completeness, reason = Completeness.PARTIAL, "page_defects"
640
+ else:
641
+ completeness, reason = _totality(tree)
642
+
643
+ # Способы вывода читаются С ТОЙ ЖЕ СТРАНИЦЫ. Отдельного запроса за ними нет,
644
+ # и делать его значило бы ходить на площадку дважды за одним ответом - тот же
645
+ # довод, по которому здесь же лежат операции по счёту.
646
+ options, channels, box_defects = parse_withdrawal_box(html)
647
+ defects.extend(box_defects)
648
+
649
+ return BalancePage(
650
+ balances=balances,
651
+ completeness=completeness,
652
+ reason=reason,
653
+ observed_at=observed_at,
654
+ rows_total=rows_total,
655
+ rows_accepted=len(entries),
656
+ rows_rejected=len(found.rows) - len(entries),
657
+ withdrawal_options=options,
658
+ withdrawal_channels=channels,
659
+ defects=tuple(defects),
660
+ _entries=entries,
661
+ )