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/_own_lots.py ADDED
@@ -0,0 +1,301 @@
1
+ """Чтение собственных лотов продавца со страницы раздела.
2
+
3
+ Идентификатор предложения берётся из data-offer, видимость - из класса warning.
4
+ Количество показывается не во всех разделах. Пустая ячейка и отсутствие колонки
5
+ не подтверждают ноль; неизвестный формат сохраняется текстом и причиной.
6
+ Поля читаются внутри строки: заголовки таблицы используют те же классы ячеек.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import Final
14
+
15
+ from selectolax.parser import HTMLParser, Node
16
+
17
+ from ._observed import Observed
18
+ from ._result import Completeness, Defect, Severity
19
+ from ._stock import parse_stock_column
20
+ from .errors import IncompleteResultError, ProtocolChangedError
21
+ from .extraction import ATTRIBUTES, SELECTORS
22
+
23
+ __all__ = ["OwnLot", "OwnLotsPage", "parse_own_lots"]
24
+
25
+ _ROW: Final[str] = SELECTORS["lots.rows"]
26
+
27
+ #: Класс, которым помечена ВЫКЛЮЧЕННАЯ строка.
28
+ #:
29
+ #: Литералом, а не из порождённой таблицы, и это не небрежность: генератор
30
+ #: классы-признаки не собирает вовсе - ровно так же литералом стоит offer-promo
31
+ #: в разборе рынка. Объявление живёт в spec/extraction/lots.yaml, раздел
32
+ #: visibility.
33
+ #:
34
+ #: Собрать их в таблицу стоит, и это отдельная работа: признаков таких в
35
+ #: проекте уже три, и все три - литералы.
36
+ _OFF_CLASS: Final[str] = "warning"
37
+ _SERVER: Final[str] = SELECTORS["lots.fields.server_text"]
38
+ _DESCRIPTION: Final[str] = SELECTORS["lots.fields.description_text"]
39
+ _PRICE_CELL: Final[str] = SELECTORS["lots.fields.price_cell"]
40
+ _PRICE_TEXT: Final[str] = SELECTORS["lots.fields.price_text"]
41
+ _CURRENCY: Final[str] = SELECTORS["lots.fields.currency_symbol_text"]
42
+ _AMOUNT: Final[str] = SELECTORS["lots.fields.amount_text"]
43
+ _RAISE: Final[str] = SELECTORS["lots.controls.raise_button"]
44
+
45
+ _OFFER_ID: Final[str] = ATTRIBUTES["lots.rows.attributes.offer_id"]
46
+ _OFFER_HREF: Final[str] = ATTRIBUTES["lots.rows.attributes.offer_href"]
47
+ _SORT_VALUE: Final[str] = ATTRIBUTES["lots.fields.price_cell.attributes.sort_value"]
48
+ _GAME_ID: Final[str] = ATTRIBUTES["lots.controls.raise_button.attributes.game_id"]
49
+ _NODE_ID: Final[str] = ATTRIBUTES["lots.controls.raise_button.attributes.node_id"]
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class OwnLot:
54
+ """Одно собственное предложение продавца.
55
+
56
+ Идентичность берётся из data-offer, видимость - из класса warning.
57
+ Количество показывается только в части разделов и не выводится из
58
+ видимости: выключенный лот может иметь ненулевой остаток.
59
+
60
+ Attributes:
61
+ offer_id (Observed[str]): Идентификатор предложения. Ради него страница
62
+ и читается: витрина его не даёт.
63
+ offer_href (Observed[str]): Ссылка на правку предложения.
64
+ server_text (Observed[str]): Название сервера, как показано.
65
+ description_text (Observed[str]): Описание, как показано.
66
+ price_text (Observed[str]): Цена без знака валюты, как показана.
67
+ currency_symbol_text (Observed[str]): Знак валюты.
68
+ sort_value (Observed[str]): Значение сортировки из атрибута ячейки цены.
69
+ is_active (bool): Показывается ли лот в выдаче.
70
+
71
+ ЧИТАЕТСЯ НАЛИЧИЕМ КЛАССА warning у строки, и появилось это поле
72
+ 31.08.2026 - исправлением нашей же ошибки. Три недели модель
73
+ утверждала, что признака на странице нет вовсе; строки одинаковы,
74
+ пока все лоты включены, а у владельца все и были включены.
75
+ row_index (int): Место строки на странице, считая с нуля.
76
+ amount_text (Observed[str]): Показанное наличие исходным текстом.
77
+ stock (Observed[int]): Целое наличие либо неизвестное значение с причиной.
78
+ """
79
+
80
+ offer_id: Observed[str]
81
+ offer_href: Observed[str]
82
+ server_text: Observed[str]
83
+ description_text: Observed[str]
84
+ price_text: Observed[str]
85
+ currency_symbol_text: Observed[str]
86
+ sort_value: Observed[str]
87
+ is_active: bool
88
+ row_index: int
89
+ amount_text: Observed[str] = field(
90
+ default_factory=lambda: Observed.missing("stock_not_normalized")
91
+ )
92
+ stock: Observed[int] = field(default_factory=lambda: Observed.missing("stock_not_normalized"))
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class OwnLotsPage:
97
+ """Собственные лоты продавца в одном разделе.
98
+
99
+ Attributes:
100
+ completeness (Completeness): Полнота чтения.
101
+ reason (str | None): Причина неполноты.
102
+ observed_at (datetime): Момент наблюдения.
103
+ rows_total (int): Сколько строк нашлось.
104
+ rows_accepted (int): Сколько собрано.
105
+ raise_game_id (Observed[str]): Игра из кнопки поднятия.
106
+ raise_node_id (Observed[str]): Раздел из кнопки поднятия.
107
+ defects (tuple[Defect, ...]): Замеченные повреждения.
108
+ """
109
+
110
+ completeness: Completeness
111
+ reason: str | None
112
+ observed_at: datetime
113
+ rows_total: int
114
+ rows_accepted: int
115
+ raise_game_id: Observed[str]
116
+ raise_node_id: Observed[str]
117
+ defects: tuple[Defect, ...] = ()
118
+ _lots: tuple[OwnLot, ...] = field(default=(), repr=False)
119
+
120
+ def lots(self, *, accept_incomplete: bool = False) -> tuple[OwnLot, ...]:
121
+ """Возвращает собранные лоты.
122
+
123
+ Args:
124
+ accept_incomplete (bool): Признание готовности работать с неполным
125
+ результатом.
126
+
127
+ Returns:
128
+ tuple[OwnLot, ...]: Лоты в порядке появления.
129
+
130
+ Raises:
131
+ IncompleteResultError: Если полнота отлична от COMPLETE, а неполнота
132
+ не признана. Пропущенный лот здесь опаснее обычного: продавец
133
+ решит, что предложения нет, и заведёт второе.
134
+ """
135
+ if self.completeness is not Completeness.COMPLETE and not accept_incomplete:
136
+ raise IncompleteResultError(
137
+ f"лоты прочитаны не полностью ({self.completeness}, причина: {self.reason}), "
138
+ f"собрано {self.rows_accepted} из {self.rows_total}. Передайте "
139
+ "accept_incomplete=True, если готовы работать с неполными данными"
140
+ )
141
+ return self._lots
142
+
143
+
144
+ def _text(node: Node | None, name: str) -> Observed[str]:
145
+ """Читает текст узла как наблюдение.
146
+
147
+ Args:
148
+ node (Node | None): Узел либо None.
149
+ name (str): Имя поля для причины отсутствия.
150
+
151
+ Returns:
152
+ Observed[str]: Наблюдение.
153
+ """
154
+ if node is None:
155
+ return Observed.missing(f"selector_no_match:{name}")
156
+ value = " ".join((node.text() or "").split())
157
+ return Observed.present(value) if value else Observed.empty("")
158
+
159
+
160
+ def _own_text(node: Node | None, name: str) -> Observed[str]:
161
+ """Читает СОБСТВЕННЫЙ текст узла, без текста вложенных.
162
+
163
+ Цена лежит в узле вместе со знаком валюты, и общий текст склеил бы их в одно
164
+ значение. Разделить их потом нечем: знак валюты у разных валют разный длины.
165
+
166
+ Args:
167
+ node (Node | None): Узел либо None.
168
+ name (str): Имя поля для причины отсутствия.
169
+
170
+ Returns:
171
+ Observed[str]: Наблюдение.
172
+ """
173
+ if node is None:
174
+ return Observed.missing(f"selector_no_match:{name}")
175
+ own = " ".join(
176
+ part.text(deep=False).strip() for part in [node] if part.text(deep=False) is not None
177
+ ).strip()
178
+ own = " ".join(own.split())
179
+ return Observed.present(own) if own else Observed.empty("")
180
+
181
+
182
+ def _attribute(node: Node | None, name: str, field_name: str) -> Observed[str]:
183
+ """Читает атрибут узла как наблюдение.
184
+
185
+ Args:
186
+ node (Node | None): Узел либо None.
187
+ name (str): Имя атрибута.
188
+ field_name (str): Имя поля для причины отсутствия.
189
+
190
+ Returns:
191
+ Observed[str]: Наблюдение.
192
+ """
193
+ if node is None:
194
+ return Observed.missing(f"carrier_missing:{field_name}")
195
+ attributes = node.attributes or {}
196
+ if name not in attributes:
197
+ return Observed.missing(f"attribute_absent:{field_name}")
198
+ value = (attributes.get(name) or "").strip()
199
+ return Observed.present(value) if value else Observed.empty("")
200
+
201
+
202
+ def _row(node: Node, index: int) -> tuple[OwnLot, list[Defect]]:
203
+ """Собирает одно предложение из строки таблицы.
204
+
205
+ Все ячейки берутся ВНУТРИ строки: те же классы есть у шапки таблицы, и поиск
206
+ по документу дал бы на одну ячейку больше, чем строк.
207
+
208
+ Args:
209
+ node (Node): Узел строки.
210
+ index (int): Место строки.
211
+
212
+ Returns:
213
+ tuple[OwnLot, list[Defect]]: Предложение и перечень повреждений.
214
+ """
215
+ defects: list[Defect] = []
216
+ offer_id = _attribute(node, _OFFER_ID, "offer_id")
217
+ if not offer_id.is_observed:
218
+ # Идентификатор - то единственное, ради чего страница и читается.
219
+ # Строка без него бесполезна для операций записи, и молчать об этом
220
+ # нельзя: вызывающий принял бы пробел за лот без идентификатора.
221
+ defects.append(
222
+ Defect(
223
+ severity=Severity.ROW,
224
+ code="offer_id_missing",
225
+ detail=(
226
+ f"у строки нет атрибута {_OFFER_ID}. Именно ради него страница и "
227
+ "читается: витрина идентификатора не даёт"
228
+ ),
229
+ row_index=index,
230
+ field_name="offer_id",
231
+ )
232
+ )
233
+
234
+ price_cell = node.css_first(_PRICE_CELL)
235
+ return (
236
+ OwnLot(
237
+ offer_id=offer_id,
238
+ offer_href=_attribute(node, _OFFER_HREF, "offer_href"),
239
+ server_text=_text(node.css_first(_SERVER), "server_text"),
240
+ description_text=_text(node.css_first(_DESCRIPTION), "description_text"),
241
+ price_text=_own_text(node.css_first(_PRICE_TEXT), "price_text"),
242
+ currency_symbol_text=_text(node.css_first(_CURRENCY), "currency_symbol_text"),
243
+ sort_value=_attribute(price_cell, _SORT_VALUE, "sort_value"),
244
+ # Читается НАЛИЧИЕМ класса: включённая строка его не несёт вовсе.
245
+ # Тот же идиом, что у флажка в форме правки, и по той же причине -
246
+ # отсутствие здесь значимо.
247
+ is_active=_OFF_CLASS not in ((node.attributes or {}).get("class") or "").split(),
248
+ row_index=index,
249
+ amount_text=_text(node.css_first(_AMOUNT), "amount_text"),
250
+ stock=parse_stock_column(node, _AMOUNT),
251
+ ),
252
+ defects,
253
+ )
254
+
255
+
256
+ def parse_own_lots(html: str, *, observed_at: datetime) -> OwnLotsPage:
257
+ """Разбирает страницу собственных лотов раздела.
258
+
259
+ Args:
260
+ html (str): Тело страницы.
261
+ observed_at (datetime): Момент наблюдения. Передаётся снаружи, чтобы
262
+ разбор оставался чистым и повторяемым на сохранённом снимке.
263
+
264
+ Returns:
265
+ OwnLotsPage: Лоты раздела и доводы кнопки поднятия.
266
+
267
+ Raises:
268
+ ProtocolChangedError: Если на странице нет ни одной строки И нет кнопки
269
+ поднятия. Пустой список вернуть нельзя: он неотличим от смены
270
+ разметки, а разница решает, заводить ли лот заново.
271
+ """
272
+ tree = HTMLParser(html)
273
+ rows = tree.css(_ROW)
274
+ raise_button = tree.css_first(_RAISE)
275
+
276
+ if not rows and raise_button is None:
277
+ raise ProtocolChangedError(
278
+ f"на странице нет ни строк лотов ({_ROW}), ни кнопки поднятия ({_RAISE}). "
279
+ "Пустой список вернуть нельзя: он неотличим от смены разметки, а разница "
280
+ "решает, заводить ли лот заново"
281
+ )
282
+
283
+ lots: list[OwnLot] = []
284
+ defects: list[Defect] = []
285
+ for index, node in enumerate(rows):
286
+ lot, row_defects = _row(node, index)
287
+ lots.append(lot)
288
+ defects += row_defects
289
+
290
+ damaged = any(one.severity is Severity.ROW for one in defects)
291
+ return OwnLotsPage(
292
+ completeness=Completeness.PARTIAL if damaged else Completeness.COMPLETE,
293
+ reason="rows_damaged" if damaged else None,
294
+ observed_at=observed_at,
295
+ rows_total=len(rows),
296
+ rows_accepted=len(lots),
297
+ raise_game_id=_attribute(raise_button, _GAME_ID, "raise_game_id"),
298
+ raise_node_id=_attribute(raise_button, _NODE_ID, "raise_node_id"),
299
+ defects=tuple(defects),
300
+ _lots=tuple(lots),
301
+ )