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/_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
|
+
)
|