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/_orders.py
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
"""Разбор списка заказов со страницы /orders/trade.
|
|
2
|
+
|
|
3
|
+
Модуль чистый: он не ходит в сеть, не смотрит на часы и не ведёт состояния.
|
|
4
|
+
Вход - разметка и момент наблюдения, выход - страница записей. Такое разделение
|
|
5
|
+
не эстетическое: разбор нужно уметь повторить на сохранённом снимке спустя
|
|
6
|
+
полгода и получить тот же результат, иначе фикстуры бесполезны.
|
|
7
|
+
|
|
8
|
+
Три решения объясняют почти всё остальное.
|
|
9
|
+
|
|
10
|
+
Отказ одной строки не отменяет страницу. Соседние заказы к сломавшемуся
|
|
11
|
+
отношения не имеют, и терять их из-за него значит терять деньги там, где
|
|
12
|
+
достаточно было пометить одну запись негодной.
|
|
13
|
+
|
|
14
|
+
Ноль строк даёт неизвестную полноту, а не пустой успех. Снимка страницы без
|
|
15
|
+
заказов у проекта нет, поэтому отличить продавца без продаж от переименованного
|
|
16
|
+
класса строки нечем. Это сознательно неудобно и станет полнотой COMPLETE в тот
|
|
17
|
+
день, когда снимок появится, - расширением наблюдений, а не догадкой.
|
|
18
|
+
|
|
19
|
+
Присутствие контрагента читается по словарю классов, а не по отрицанию.
|
|
20
|
+
Площадка помечает оба состояния явно - online и offline, - и снимок, сделанный
|
|
21
|
+
в момент, когда трое покупателей были в сети, дал оба класса разом. Правило
|
|
22
|
+
«нет offline, значит online» осталось запрещённым: оно выглядело бы работающим
|
|
23
|
+
ровно до переименования класса, а неузнанный класс честнее объявить
|
|
24
|
+
ненаблюдённым.
|
|
25
|
+
|
|
26
|
+
Состояние заказа читается по двум структурным носителям, но роли у них разные.
|
|
27
|
+
Класс ячейки положителен в обе стороны и служит источником; модификатор строки
|
|
28
|
+
положителен только для оплаченного и служит проверкой в одну сторону. Закрытый
|
|
29
|
+
заказ узнаётся по отсутствию модификатора, а отсутствие само по себе не
|
|
30
|
+
свидетельство: под ним с равным успехом лежит переименование класса.
|
|
31
|
+
|
|
32
|
+
Расхождение носителей громкое. Переименуй площадка ту половину разметки, что
|
|
33
|
+
несёт положительный признак, - и второй носитель не согласится, а разбор скажет
|
|
34
|
+
об этом вслух вместо того, чтобы молча поменять ответ. Ответ здесь - «оплачен ли
|
|
35
|
+
заказ», то есть решение бота выдавать товар.
|
|
36
|
+
|
|
37
|
+
Опознаются классы пересечением с наблюдённым перечнем, а не сравнением строки
|
|
38
|
+
классов целиком. Разница не косметическая: при точном сравнении добавленный
|
|
39
|
+
площадкой hidden-xs делал бы состояние нечитаемым на всех строках сразу, при
|
|
40
|
+
полноте complete и нуле повреждений.
|
|
41
|
+
|
|
42
|
+
Наблюдались два состояния из скольких-то. Носитель, которого нет в таблице,
|
|
43
|
+
даёт ненаблюдённое значение, а не unknown: второе означало бы, что состояние
|
|
44
|
+
прочитано и не опознано, тогда как оно не прочитано вовсе.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import re
|
|
50
|
+
from dataclasses import dataclass, field
|
|
51
|
+
from datetime import datetime
|
|
52
|
+
from typing import Final
|
|
53
|
+
|
|
54
|
+
from selectolax.parser import HTMLParser, Node
|
|
55
|
+
|
|
56
|
+
from ._extract import attribute
|
|
57
|
+
from ._money import Money
|
|
58
|
+
from ._observed import Confidence, Observed
|
|
59
|
+
from ._result import Completeness, Defect, Severity, collect_rows
|
|
60
|
+
from .errors import IncompleteResultError, ProtocolChangedError, ValidationError
|
|
61
|
+
from .extraction import (
|
|
62
|
+
AMBIGUOUS_CURRENCY_SYMBOLS,
|
|
63
|
+
CURRENCY_BY_SYMBOL,
|
|
64
|
+
PRESENCE_BY_CLASS,
|
|
65
|
+
ROW_MARKER_BY_STATUS,
|
|
66
|
+
SELECTOR_GROUPS,
|
|
67
|
+
SELECTORS,
|
|
68
|
+
STATUS_BY_CELL_CLASS,
|
|
69
|
+
OrderStatus,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"Severity",
|
|
74
|
+
"Completeness",
|
|
75
|
+
"Defect",
|
|
76
|
+
"OrderListEntry",
|
|
77
|
+
"OrdersPage",
|
|
78
|
+
"parse_orders_page",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def normalize_order_filters(
|
|
83
|
+
*,
|
|
84
|
+
order_id: str | None = None,
|
|
85
|
+
buyer: str | None = None,
|
|
86
|
+
status: OrderStatus | str | None = None,
|
|
87
|
+
game_id: str | None = None,
|
|
88
|
+
section: str | None = None,
|
|
89
|
+
) -> dict[str, str]:
|
|
90
|
+
"""Проверяет фильтры до сети и переводит имена SDK в поля формы FunPay."""
|
|
91
|
+
fields: dict[str, str] = {}
|
|
92
|
+
for name, value in (
|
|
93
|
+
("id", order_id),
|
|
94
|
+
("buyer", buyer),
|
|
95
|
+
("state", status),
|
|
96
|
+
("game", game_id),
|
|
97
|
+
("section", section),
|
|
98
|
+
):
|
|
99
|
+
if value is None:
|
|
100
|
+
continue
|
|
101
|
+
if not isinstance(value, str) or not value.strip():
|
|
102
|
+
raise ValidationError(f"фильтр {name} должен быть непустой строкой")
|
|
103
|
+
fields[name] = value.strip()
|
|
104
|
+
try:
|
|
105
|
+
fields[name].encode("utf-8")
|
|
106
|
+
except UnicodeEncodeError:
|
|
107
|
+
raise ValidationError(f"фильтр {name} содержит некорректный Unicode") from None
|
|
108
|
+
if "state" in fields and fields["state"] not in {one.value for one in OrderStatus}:
|
|
109
|
+
raise ValidationError("статус должен быть paid, closed или refunded")
|
|
110
|
+
if "id" in fields and not (fields["id"].isascii() and fields["id"].isalnum()):
|
|
111
|
+
raise ValidationError("номер заказа должен содержать только латинские буквы и цифры")
|
|
112
|
+
if "game" in fields and not (fields["game"].isascii() and fields["game"].isdecimal()):
|
|
113
|
+
raise ValidationError("номер игры должен содержать только цифры ASCII")
|
|
114
|
+
if "section" in fields and "game" not in fields:
|
|
115
|
+
raise ValidationError("категория section требует game_id")
|
|
116
|
+
return fields
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
#: Контейнер таблицы заказов.
|
|
120
|
+
_TABLE: Final[str] = SELECTORS["orders.container"]
|
|
121
|
+
|
|
122
|
+
#: Заголовок таблицы. Несёт те же классы ячеек, что и строки.
|
|
123
|
+
_HEADER: Final[str] = SELECTORS["orders.container.header"]
|
|
124
|
+
|
|
125
|
+
#: Контейнер строк. Второй, независимый от класса строки признак.
|
|
126
|
+
_ROWS_CONTAINER: Final[str] = SELECTORS["orders.rows_container"]
|
|
127
|
+
|
|
128
|
+
#: Сообщение, которое площадка ставит вместо таблицы, когда показывать нечего.
|
|
129
|
+
#:
|
|
130
|
+
#: Наблюдено 24.08.2026 контрольной парой: та же страница с фильтром, дающим
|
|
131
|
+
#: пустоту, и без него. Признак сработал ровно в одну сторону - на пустой есть,
|
|
132
|
+
#: на полной нет.
|
|
133
|
+
_EMPTY_MARKER: Final[str] = SELECTORS["orders.empty_list.marker"]
|
|
134
|
+
|
|
135
|
+
#: Форма фильтра. Остаётся и на пустой странице: второй признак того, что
|
|
136
|
+
#: страница отрисовалась целиком, просто показывать нечего.
|
|
137
|
+
_FILTER: Final[str] = SELECTORS["orders.empty_list.filter_stays"]
|
|
138
|
+
|
|
139
|
+
#: Селектор строки заказа.
|
|
140
|
+
_ROW: Final[str] = SELECTORS["orders.row"]
|
|
141
|
+
|
|
142
|
+
#: Идентификатор заказа в адресе строки.
|
|
143
|
+
_ID_IN_HREF: Final[re.Pattern[str]] = re.compile(r"/orders/([^/?#]+)")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class OrderListEntry:
|
|
148
|
+
"""Сокращённая запись заказа, прочитанная из списка.
|
|
149
|
+
|
|
150
|
+
Не Order и намеренно отличается от него типом. Полную запись из списка
|
|
151
|
+
собрать нельзя: страница не даёт ни кода валюты, ни машиночитаемого
|
|
152
|
+
времени, ни ролей сторон. Символ валюты есть, но символ и код не в
|
|
153
|
+
однозначном соответствии, а тип money требует именно кода. Поля, которых
|
|
154
|
+
там нет структурно, в этом типе отсутствуют, а не лежат в нём как None -
|
|
155
|
+
прочитать их нельзя даже по ошибке.
|
|
156
|
+
|
|
157
|
+
Attributes:
|
|
158
|
+
order_id (str): Идентификатор заказа из адреса строки.
|
|
159
|
+
href (str): Адрес страницы заказа.
|
|
160
|
+
row_index (int): Порядковый номер строки на странице, с нуля.
|
|
161
|
+
status (Observed[OrderStatus]): Состояние заказа. Читается по двум
|
|
162
|
+
независимым структурным носителям сразу; расходятся - значение
|
|
163
|
+
ненаблюдённое и повреждение уровня поля. Наблюдались два состояния
|
|
164
|
+
из скольких-то, и носитель вне таблицы тоже даёт ненаблюдённое
|
|
165
|
+
значение, а не unknown.
|
|
166
|
+
status_carrier (Observed[str]): Цветовой класс ячейки статуса как он
|
|
167
|
+
есть. Читается класс, а не текст: текст локализован. Поле остаётся
|
|
168
|
+
и после того, как соответствие установлено, - по нему узнают, как
|
|
169
|
+
выглядит состояние, которого в таблице ещё нет.
|
|
170
|
+
order_number_text (Observed[str]): Видимый номер заказа, текст.
|
|
171
|
+
description_text (Observed[str]): Название лота, текст. Только первый
|
|
172
|
+
узел описания: текст контейнера целиком склеивал бы название с
|
|
173
|
+
разделом, и граница между ними ничем не помечена.
|
|
174
|
+
category_text (Observed[str]): Раздел лота, текст.
|
|
175
|
+
counterparty_name (Observed[str]): Имя контрагента, текст.
|
|
176
|
+
counterparty_href (Observed[str]): Адрес профиля контрагента.
|
|
177
|
+
counterparty_online (Observed[bool]): Признак присутствия контрагента.
|
|
178
|
+
price (Observed[Money]): Сумма заказа с кодом валюты. Собирается из
|
|
179
|
+
собственного текста ячейки и знака из соседнего узла по таблице,
|
|
180
|
+
наблюдённой переключателем площадки. Знак вне таблицы даёт
|
|
181
|
+
ненаблюдённое значение и повреждение уровня поля, а не угаданный код.
|
|
182
|
+
amount_text (Observed[str]): Сумма и знак, текст ячейки целиком. Поле
|
|
183
|
+
остаётся рядом с price: по нему видно, как выглядит цена, которую
|
|
184
|
+
разобрать не удалось.
|
|
185
|
+
currency_symbol_text (Observed[str]): Символ валюты, текст. Лежит в
|
|
186
|
+
отдельном узле ячейки цены.
|
|
187
|
+
time_text (Observed[str]): Время заказа, текст. Наблюдались
|
|
188
|
+
относительные метки вида «сегодня, 13:56»: точного момента страница
|
|
189
|
+
не даёт вовсе.
|
|
190
|
+
time_ago_text (Observed[str]): Давность заказа, текст. Именно давность,
|
|
191
|
+
а не остаток срока, хотя имя класса подсказывает второе.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
order_id: str
|
|
195
|
+
href: str
|
|
196
|
+
row_index: int
|
|
197
|
+
status: Observed[OrderStatus]
|
|
198
|
+
status_carrier: Observed[str]
|
|
199
|
+
order_number_text: Observed[str]
|
|
200
|
+
description_text: Observed[str]
|
|
201
|
+
category_text: Observed[str]
|
|
202
|
+
counterparty_name: Observed[str]
|
|
203
|
+
counterparty_href: Observed[str]
|
|
204
|
+
counterparty_online: Observed[bool]
|
|
205
|
+
price: Observed[Money]
|
|
206
|
+
amount_text: Observed[str]
|
|
207
|
+
currency_symbol_text: Observed[str]
|
|
208
|
+
time_text: Observed[str]
|
|
209
|
+
time_ago_text: Observed[str]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
#: Форма сужения списка. Показывается площадкой НЕ ВСЕГДА: в снимке
|
|
213
|
+
#: 19.08.2026 её нет вовсе, в снимке 28.08.2026 она есть.
|
|
214
|
+
_FILTER_FORM: Final[str] = SELECTORS["orders.filters.form"]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True, slots=True)
|
|
218
|
+
class OrdersPage:
|
|
219
|
+
"""Результат чтения страницы списка заказов.
|
|
220
|
+
|
|
221
|
+
Записи получают через :meth:`rows`, а не напрямую. Открытый список сделал бы
|
|
222
|
+
неполноту незаметной: обойти его в цикле проще, чем спросить о полноте, и
|
|
223
|
+
именно так теряются заказы.
|
|
224
|
+
|
|
225
|
+
Attributes:
|
|
226
|
+
completeness (Completeness): Полнота прочитанного.
|
|
227
|
+
reason (str): Машиночитаемая причина, по которой полнота такова.
|
|
228
|
+
observed_at (datetime): Момент наблюдения. Единственное время в записи,
|
|
229
|
+
известное точно.
|
|
230
|
+
rows_total (int): Сколько кандидатов в строки нашлось.
|
|
231
|
+
rows_accepted (int): Сколько записей собрано.
|
|
232
|
+
rows_rejected (int): Сколько строк отброшено.
|
|
233
|
+
filters_available (bool): Есть ли на странице форма сужения списка.
|
|
234
|
+
|
|
235
|
+
ЧИТАЕТСЯ, А НЕ ПРЕДПОЛАГАЕТСЯ. В снимке 19.08.2026 формы нет вовсе,
|
|
236
|
+
в снимке 28.08.2026 она есть - то есть площадка показывает её не
|
|
237
|
+
всегда, и отправить фильтр туда, где формы нет, значило бы получить
|
|
238
|
+
несуженный список молча.
|
|
239
|
+
defects (tuple[Defect, ...]): Обнаруженные повреждения.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
completeness: Completeness
|
|
243
|
+
reason: str
|
|
244
|
+
observed_at: datetime
|
|
245
|
+
rows_total: int
|
|
246
|
+
rows_accepted: int
|
|
247
|
+
rows_rejected: int
|
|
248
|
+
filters_available: bool
|
|
249
|
+
defects: tuple[Defect, ...]
|
|
250
|
+
_entries: tuple[OrderListEntry, ...] = field(repr=False, default=())
|
|
251
|
+
|
|
252
|
+
def rows(self, *, accept_incomplete: bool = False) -> tuple[OrderListEntry, ...]:
|
|
253
|
+
"""Возвращает собранные записи.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
accept_incomplete (bool): Признание того, что результат может быть
|
|
257
|
+
неполным. Без него неполный результат не выдаётся.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
tuple[OrderListEntry, ...]: Записи в порядке появления на странице.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
IncompleteResultError: Если полнота отлична от COMPLETE, а неполнота
|
|
264
|
+
не признана. Молча отданный неполный список неотличим от
|
|
265
|
+
полного, и обработчик примет решение по данным, которых нет.
|
|
266
|
+
"""
|
|
267
|
+
if self.completeness is not Completeness.COMPLETE and not accept_incomplete:
|
|
268
|
+
raise IncompleteResultError(
|
|
269
|
+
f"результат неполон ({self.completeness}, причина: {self.reason}), "
|
|
270
|
+
f"собрано {self.rows_accepted} из {self.rows_total}, "
|
|
271
|
+
f"повреждений {len(self.defects)}. Передайте accept_incomplete=True, "
|
|
272
|
+
"если готовы работать с неполными данными"
|
|
273
|
+
)
|
|
274
|
+
return self._entries
|
|
275
|
+
|
|
276
|
+
def __len__(self) -> int:
|
|
277
|
+
"""Возвращает число собранных записей.
|
|
278
|
+
|
|
279
|
+
Длина доступна без признания неполноты намеренно: узнать, сколько
|
|
280
|
+
записей собрано, нужно как раз для того, чтобы решить, признавать ли её.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
int: Число собранных записей.
|
|
284
|
+
"""
|
|
285
|
+
return len(self._entries)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _text(node: Node | None, name: str) -> Observed[str]:
|
|
289
|
+
"""Извлекает текст узла как наблюдаемое значение.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
node (Node | None): Узел или None, если селектор не нашёл ничего.
|
|
293
|
+
name (str): Имя поля для причины отсутствия.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Observed[str]: Наблюдение. Отсутствие узла и пустой текст различаются.
|
|
297
|
+
"""
|
|
298
|
+
if node is None:
|
|
299
|
+
return Observed.missing(f"selector_no_match:{name}")
|
|
300
|
+
value = " ".join((node.text() or "").split())
|
|
301
|
+
return Observed.present(value) if value else Observed.empty("")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
#: Как выглядит сумма, годная к разбору.
|
|
305
|
+
#:
|
|
306
|
+
#: Только цифры и одна точка. Запятая НЕ принимается: она бывает и десятичным
|
|
307
|
+
#: разделителем, и разделителем разрядов, и какой из двух перед нами - из строки
|
|
308
|
+
#: не видно. Принять её значило бы однажды прочитать «1,5» как полторы тысячи.
|
|
309
|
+
_AMOUNT = re.compile(r"^-?\d+(?:\.(\d{1,4}))?$")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _money(cell: Node | None, row: Node) -> tuple[Observed[Money], str | None]:
|
|
313
|
+
"""Собирает сумму заказа с кодом валюты.
|
|
314
|
+
|
|
315
|
+
Знак валюты лежит в ОТДЕЛЬНОМ узле ячейки цены, а не в тексте рядом с
|
|
316
|
+
суммой, - это наблюдено, и потому разбирать «1500 знак» из строки не
|
|
317
|
+
приходится. Сумма берётся собственным текстом ячейки, без вложенных узлов:
|
|
318
|
+
текст целиком склеил бы её со знаком.
|
|
319
|
+
|
|
320
|
+
Код валюты берётся по таблице, наблюдённой переключателем площадки. Знак
|
|
321
|
+
вне таблицы кодом НЕ становится: значение объявляется ненаблюдённым, и это
|
|
322
|
+
повреждение уровня поля. Придуманное соответствие приписало бы чужую валюту
|
|
323
|
+
чужому заказу молча, и заметил бы это не разработчик, а продавец.
|
|
324
|
+
|
|
325
|
+
Масштаб берётся из самой строки, а не из валюты: он хранится в записи money
|
|
326
|
+
именно затем, чтобы не выводиться. «7.86» даёт scale 2, «100» - scale 0.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
cell (Node | None): Узел ячейки цены либо None.
|
|
330
|
+
row (Node): Строка заказа - в ней ищется узел знака.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
tuple[Observed[Money], str | None]: Сумма и код повреждения, если знак
|
|
334
|
+
найден и кодом не стал. Отсутствие узла повреждением не является:
|
|
335
|
+
селектор мог не найти ничего на странице иного вида.
|
|
336
|
+
"""
|
|
337
|
+
if cell is None:
|
|
338
|
+
return Observed.missing("selector_no_match:price"), None
|
|
339
|
+
|
|
340
|
+
# Собственный текст узла, без вложенных: знак валюты лежит в дочернем span,
|
|
341
|
+
# и text() целиком вернул бы «4682.01 знак».
|
|
342
|
+
raw = "".join((cell.text(deep=False) or "").split())
|
|
343
|
+
symbol_node = row.css_first(SELECTORS["orders.fields.currency_symbol_text"])
|
|
344
|
+
symbol = ((symbol_node.text() if symbol_node is not None else "") or "").strip()
|
|
345
|
+
|
|
346
|
+
if not symbol:
|
|
347
|
+
return Observed.missing("selector_no_match:currency_symbol"), None
|
|
348
|
+
|
|
349
|
+
if symbol in AMBIGUOUS_CURRENCY_SYMBOLS:
|
|
350
|
+
# Знак известен и не решает. Это не то же, что «знака нет в таблице»:
|
|
351
|
+
# там ждут наблюдения, здесь наблюдение уже есть и говорит «неясно».
|
|
352
|
+
return Observed.missing("currency_symbol_ambiguous"), "currency_symbol_ambiguous"
|
|
353
|
+
|
|
354
|
+
code = CURRENCY_BY_SYMBOL.get(symbol)
|
|
355
|
+
if code is None:
|
|
356
|
+
# Знака нет в таблице - НЕ повреждение, и это то же решение, что принято
|
|
357
|
+
# для носителя статуса: так выглядит валюта, которой в таблице ещё нет,
|
|
358
|
+
# а не поломка разбора. Заодно это единственное, что даёт разбирать
|
|
359
|
+
# скелеты: там знак заменён подписью, и объяви мы это повреждением -
|
|
360
|
+
# каждая фикстура стала бы неполной страницей.
|
|
361
|
+
return Observed.missing("currency_symbol_not_mapped"), None
|
|
362
|
+
|
|
363
|
+
match = _AMOUNT.match(raw)
|
|
364
|
+
if match is None:
|
|
365
|
+
return Observed.missing("amount_not_numeric"), "amount_not_numeric"
|
|
366
|
+
|
|
367
|
+
scale = len(match.group(1) or "")
|
|
368
|
+
return Observed.present(Money(int(raw.replace(".", "")), code, scale)), None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _classes(node: Node | None, *, without: str) -> frozenset[str]:
|
|
372
|
+
"""Возвращает классы узла без общего, по которому он найден.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
node (Node | None): Узел либо None.
|
|
376
|
+
without (str): Класс, который нужно отбросить: он одинаков у всех и
|
|
377
|
+
ничего не различает.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
frozenset[str]: Оставшиеся классы. Пустое множество, если узла нет.
|
|
381
|
+
"""
|
|
382
|
+
if node is None:
|
|
383
|
+
return frozenset()
|
|
384
|
+
raw = (node.attributes or {}).get("class") or ""
|
|
385
|
+
return frozenset(raw.split()) - {without}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _carrier(node: Node | None) -> Observed[str]:
|
|
389
|
+
"""Извлекает класс-носитель статуса.
|
|
390
|
+
|
|
391
|
+
Читается именно класс, а не текст ячейки. Текст локализован, и составить по
|
|
392
|
+
нему соответствие статусам нельзя: сменив язык аккаунта, площадка вернула бы
|
|
393
|
+
другие значения для тех же состояний. Класс от языка не зависит - именно
|
|
394
|
+
поэтому спецификация и называет носителем его.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
node (Node | None): Узел ячейки статуса.
|
|
398
|
+
|
|
399
|
+
Поле остаётся и после того, как соответствие носителей состояниям
|
|
400
|
+
установлено. Наблюдались два состояния из скольких-то, и когда встретится
|
|
401
|
+
третье, узнать, как оно выглядит, можно будет только отсюда: само поле
|
|
402
|
+
статуса на нём окажется ненаблюдённым.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
node (Node | None): Узел ячейки статуса.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Observed[str]: Цветовой класс ячейки без общего класса tc-status.
|
|
409
|
+
"""
|
|
410
|
+
if node is None:
|
|
411
|
+
return Observed.missing("selector_no_match:status_carrier")
|
|
412
|
+
classes = sorted(_classes(node, without="tc-status"))
|
|
413
|
+
return Observed.present(" ".join(classes)) if classes else Observed.empty("")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _presence(media: Node | None) -> Observed[bool]:
|
|
417
|
+
"""Читает признак присутствия контрагента.
|
|
418
|
+
|
|
419
|
+
Словарь классов закрыт по наблюдению, но не по умолчанию. Площадка помечает
|
|
420
|
+
оба состояния явно: ``online`` и ``offline``, - и снимок, сделанный в момент,
|
|
421
|
+
когда трое покупателей были в сети, дал оба класса разом.
|
|
422
|
+
|
|
423
|
+
Вывод по отрицанию при этом остаётся запрещённым. Правило «нет offline,
|
|
424
|
+
значит online» выглядело бы работающим ровно до переименования класса:
|
|
425
|
+
стань он ``is-offline``, и каждый контрагент молча оказался бы
|
|
426
|
+
присутствующим - без повреждения, без исключения, без строки в журнале.
|
|
427
|
+
Поэтому неузнанный класс даёт ненаблюдённое значение, а не догадку.
|
|
428
|
+
|
|
429
|
+
Два маркера сразу - тоже неузнанное состояние: разметка, объявляющая
|
|
430
|
+
пользователя одновременно в сети и не в сети, не даёт ответа, и выбирать
|
|
431
|
+
один из двух было бы выдумкой.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
media (Node | None): Узел карточки пользователя внутри ячейки
|
|
435
|
+
контрагента либо None, если селектор не нашёл её.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
Observed[bool]: True либо False по наблюдённому классу; ненаблюдённое
|
|
439
|
+
значение, если класс не узнан либо узнаны сразу два.
|
|
440
|
+
"""
|
|
441
|
+
if media is None:
|
|
442
|
+
return Observed.missing("selector_no_match:counterparty_online")
|
|
443
|
+
hits = {name for name in _classes(media, without="media-user") if name in PRESENCE_BY_CLASS}
|
|
444
|
+
if len(hits) != 1:
|
|
445
|
+
return Observed.missing("presence_marker_not_recognised")
|
|
446
|
+
return Observed.present(PRESENCE_BY_CLASS[hits.pop()], Confidence.OBSERVED)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _status(row: Node) -> tuple[Observed[OrderStatus], str | None]:
|
|
450
|
+
"""Читает состояние заказа по двум независимым носителям.
|
|
451
|
+
|
|
452
|
+
Носителя два, и оба структурные: цветовой класс ячейки статуса и модификатор
|
|
453
|
+
самой строки. Читаются оба. В наблюдении они совпали во всех восьми строках,
|
|
454
|
+
и это свойство используется как проверка, а не выбрасывается: переименуй
|
|
455
|
+
площадка любой из них - и второй не согласится.
|
|
456
|
+
|
|
457
|
+
Читать один носитель было бы дешевле и хуже. Смена вёрстки поменяла бы
|
|
458
|
+
ответ молча, а ответ здесь - это «оплачен ли заказ», то есть решение бота
|
|
459
|
+
выдавать товар.
|
|
460
|
+
|
|
461
|
+
Роли у носителей разные, и разница существенна.
|
|
462
|
+
|
|
463
|
+
Класс ячейки положителен в обе стороны: и оплаченный, и закрытый заказ несут
|
|
464
|
+
свой класс. Это основной носитель, и состояние берётся из него. Опознаётся
|
|
465
|
+
он пересечением классов узла с наблюдённым перечнем, а не сравнением строки
|
|
466
|
+
классов целиком: ячейка вправе нести и косметические классы, и требование
|
|
467
|
+
точного совпадения делало бы состояние нечитаемым от добавления
|
|
468
|
+
``hidden-xs`` - на всех строках сразу и без единого повреждения.
|
|
469
|
+
|
|
470
|
+
Модификатор строки положителен только для оплаченного. Закрытый узнаётся по
|
|
471
|
+
отсутствию ``info``, а отсутствие само по себе не свидетельство: под ним с
|
|
472
|
+
равным успехом лежит переименование класса. Поэтому модификатор служит
|
|
473
|
+
проверкой в одну сторону - ``info`` обязан стоять там и только там, где
|
|
474
|
+
ячейка говорит ``paid``.
|
|
475
|
+
|
|
476
|
+
Отсюда и разница между двумя видами отказа. Ячейка не опознана, но ``info``
|
|
477
|
+
стоит - разметка изменилась, и об этом надо сказать вслух. Ячейка не
|
|
478
|
+
опознана и ``info`` не стоит - так выглядит состояние, которого в таблице
|
|
479
|
+
ещё нет, и повреждением это не является.
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
row (Node): Узел строки заказа.
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
tuple[Observed[OrderStatus], str | None]: Состояние и причина
|
|
486
|
+
повреждения, если носители разошлись между собой.
|
|
487
|
+
"""
|
|
488
|
+
cell = row.css_first(SELECTOR_GROUPS["orders.fields.status.carriers"][0])
|
|
489
|
+
if cell is None:
|
|
490
|
+
return Observed.missing("selector_no_match:status"), None
|
|
491
|
+
|
|
492
|
+
marks = _classes(cell, without="tc-status") & set(STATUS_BY_CELL_CLASS)
|
|
493
|
+
marked_paid = ROW_MARKER_BY_STATUS[OrderStatus.PAID] in _classes(row, without="tc-item")
|
|
494
|
+
|
|
495
|
+
if len(marks) != 1:
|
|
496
|
+
# Ноль - состояние, которого в таблице нет; больше одного - разметка
|
|
497
|
+
# объявляет два состояния сразу, и выбрать одно было бы выдумкой.
|
|
498
|
+
if marked_paid:
|
|
499
|
+
return (
|
|
500
|
+
Observed.missing("status_carriers_disagree"),
|
|
501
|
+
"status_carriers_disagree",
|
|
502
|
+
)
|
|
503
|
+
return Observed.missing("status_carrier_not_mapped"), None
|
|
504
|
+
|
|
505
|
+
by_cell = STATUS_BY_CELL_CLASS[next(iter(marks))]
|
|
506
|
+
if marked_paid is not (by_cell is OrderStatus.PAID):
|
|
507
|
+
return Observed.missing("status_carriers_disagree"), "status_carriers_disagree"
|
|
508
|
+
|
|
509
|
+
return Observed.present(by_cell, Confidence.OBSERVED), None
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _parse_row(row: Node, index: int) -> tuple[OrderListEntry | None, list[Defect]]:
|
|
513
|
+
"""Разбирает одну строку заказа.
|
|
514
|
+
|
|
515
|
+
Все селекторы применяются внутри строки, а не по документу: заголовок
|
|
516
|
+
таблицы несёт те же классы ячеек, и .tc-price по документу находит четыре
|
|
517
|
+
элемента при трёх заказах.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
row (Node): Узел строки.
|
|
521
|
+
index (int): Порядковый номер строки на странице.
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
tuple[OrderListEntry | None, list[Defect]]: Запись либо None, если
|
|
525
|
+
строка непригодна, и перечень обнаруженных повреждений.
|
|
526
|
+
"""
|
|
527
|
+
defects: list[Defect] = []
|
|
528
|
+
|
|
529
|
+
href = (row.attributes.get("href") or "").strip()
|
|
530
|
+
match = _ID_IN_HREF.search(href)
|
|
531
|
+
if not href or match is None:
|
|
532
|
+
defects.append(
|
|
533
|
+
Defect(
|
|
534
|
+
severity=Severity.ROW,
|
|
535
|
+
code="order_id_not_extractable",
|
|
536
|
+
detail="в адресе строки нет идентификатора заказа",
|
|
537
|
+
row_index=index,
|
|
538
|
+
)
|
|
539
|
+
)
|
|
540
|
+
return None, defects
|
|
541
|
+
|
|
542
|
+
# Селектор ссылки на профиль ограничен ячейкой контрагента. Раньше брался
|
|
543
|
+
# первый [data-href] строки; в снимке их два, и оба ведут на одного человека,
|
|
544
|
+
# поэтому ошибки не было видно. Появись data-href в описании лота - и первым
|
|
545
|
+
# оказался бы он, а контрагентом молча стал бы адрес товара.
|
|
546
|
+
user_link = row.css_first(SELECTORS["orders.fields.counterparty_link"])
|
|
547
|
+
media = row.css_first(SELECTORS["orders.fields.counterparty_online"])
|
|
548
|
+
online = _presence(media)
|
|
549
|
+
price, money_defect = _money(row.css_first(SELECTORS["orders.fields.amount_text"]), row)
|
|
550
|
+
if money_defect is not None:
|
|
551
|
+
defects.append(
|
|
552
|
+
Defect(
|
|
553
|
+
severity=Severity.FIELD,
|
|
554
|
+
code=money_defect,
|
|
555
|
+
detail="знак валюты найден, а кодом не стал - сумма не собрана",
|
|
556
|
+
row_index=index,
|
|
557
|
+
field_name="price",
|
|
558
|
+
)
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
status, disagreement = _status(row)
|
|
562
|
+
if disagreement is not None:
|
|
563
|
+
# Расхождение носителей - не мелочь и не косметика. Оно означает, что
|
|
564
|
+
# разметка изменилась, и любой из двух ответов может быть неверным.
|
|
565
|
+
# Выбрать один значило бы угадывать там, где угадывать нельзя: ответ
|
|
566
|
+
# здесь - это «оплачен ли заказ».
|
|
567
|
+
defects.append(
|
|
568
|
+
Defect(
|
|
569
|
+
severity=Severity.FIELD,
|
|
570
|
+
code=disagreement,
|
|
571
|
+
detail="цветовой класс ячейки и модификатор строки говорят о разном",
|
|
572
|
+
row_index=index,
|
|
573
|
+
field_name="status",
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
entry = OrderListEntry(
|
|
578
|
+
order_id=match.group(1),
|
|
579
|
+
href=href,
|
|
580
|
+
row_index=index,
|
|
581
|
+
status=status,
|
|
582
|
+
status_carrier=_carrier(row.css_first(SELECTOR_GROUPS["orders.fields.status.carriers"][0])),
|
|
583
|
+
order_number_text=_text(
|
|
584
|
+
row.css_first(SELECTORS["orders.fields.order_number_text"]), "order_number_text"
|
|
585
|
+
),
|
|
586
|
+
description_text=_text(
|
|
587
|
+
row.css_first(SELECTORS["orders.fields.description"]), "description_text"
|
|
588
|
+
),
|
|
589
|
+
category_text=_text(row.css_first(SELECTORS["orders.fields.category"]), "category_text"),
|
|
590
|
+
counterparty_name=_text(
|
|
591
|
+
row.css_first(SELECTORS["orders.fields.counterparty_name"]), "counterparty_name"
|
|
592
|
+
),
|
|
593
|
+
counterparty_href=attribute(user_link, "data-href", "counterparty_href"),
|
|
594
|
+
counterparty_online=online,
|
|
595
|
+
price=price,
|
|
596
|
+
amount_text=_text(row.css_first(SELECTORS["orders.fields.amount_text"]), "amount_text"),
|
|
597
|
+
currency_symbol_text=_text(
|
|
598
|
+
row.css_first(SELECTORS["orders.fields.currency_symbol_text"]), "currency_symbol_text"
|
|
599
|
+
),
|
|
600
|
+
time_text=_text(row.css_first(SELECTORS["orders.fields.time_text"]), "time_text"),
|
|
601
|
+
time_ago_text=_text(
|
|
602
|
+
row.css_first(SELECTORS["orders.fields.time_ago_text"]), "time_ago_text"
|
|
603
|
+
),
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
for name in (
|
|
607
|
+
"status_carrier",
|
|
608
|
+
"order_number_text",
|
|
609
|
+
"description_text",
|
|
610
|
+
"category_text",
|
|
611
|
+
"currency_symbol_text",
|
|
612
|
+
"time_ago_text",
|
|
613
|
+
"counterparty_name",
|
|
614
|
+
"counterparty_href",
|
|
615
|
+
# counterparty_online сюда не входит по той же причине, что и статус:
|
|
616
|
+
# его ненаблюдённость - решение о границе наблюдений, а не поломка
|
|
617
|
+
# разметки. Считать её повреждением значило бы отмечать повреждением
|
|
618
|
+
# каждого присутствующего контрагента.
|
|
619
|
+
"amount_text",
|
|
620
|
+
"time_text",
|
|
621
|
+
):
|
|
622
|
+
if not getattr(entry, name).is_observed:
|
|
623
|
+
defects.append(
|
|
624
|
+
Defect(
|
|
625
|
+
severity=Severity.FIELD,
|
|
626
|
+
code="field_not_observed",
|
|
627
|
+
detail="селектор поля не нашёл узла там, где он ожидался",
|
|
628
|
+
row_index=index,
|
|
629
|
+
field_name=name,
|
|
630
|
+
)
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
return entry, defects
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
#: Поля, отсутствие которых во всех строках означает поломку страницы, а не
|
|
637
|
+
#: сумму поломок полей. Статус сюда не входит: он ненаблюдаем по решению
|
|
638
|
+
#: спецификации, а не из-за разметки.
|
|
639
|
+
_PAGE_LEVEL_FIELDS: Final[tuple[str, ...]] = (
|
|
640
|
+
"status_carrier",
|
|
641
|
+
"order_number_text",
|
|
642
|
+
"counterparty_name",
|
|
643
|
+
"amount_text",
|
|
644
|
+
"time_text",
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def parse_orders_page(html: str, *, observed_at: datetime) -> OrdersPage:
|
|
649
|
+
"""Разбирает страницу списка заказов.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
html (str): Тело страницы. Предполагается уже классифицированным как
|
|
653
|
+
пригодное: состояние сессии здесь не проверяется.
|
|
654
|
+
observed_at (datetime): Момент наблюдения. Передаётся снаружи, чтобы
|
|
655
|
+
разбор оставался чистым и повторяемым на сохранённом снимке.
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
OrdersPage: Записи вместе с полнотой и перечнем повреждений.
|
|
659
|
+
|
|
660
|
+
Raises:
|
|
661
|
+
ProtocolChangedError: Если разметка изменилась настолько, что читать
|
|
662
|
+
нечего: нет таблицы, нет контейнера строк, либо кандидаты в строки
|
|
663
|
+
были, а собрать не удалось ни одной.
|
|
664
|
+
"""
|
|
665
|
+
tree = HTMLParser(html)
|
|
666
|
+
defects: list[Defect] = []
|
|
667
|
+
|
|
668
|
+
# Пустой список площадка рисует БЕЗ таблицы вовсе - наблюдено 24.08.2026
|
|
669
|
+
# контрольной парой. Сторожа ниже поэтому обязаны знать о нём: прежде они
|
|
670
|
+
# объявляли такую страницу сменой разметки, и сообщение «пустой список
|
|
671
|
+
# вернуть нельзя: он неотличим от отсутствия заказов» говорило ровно то, что
|
|
672
|
+
# было правдой ДО наблюдения.
|
|
673
|
+
#
|
|
674
|
+
# Признак позитивный и двойной: сообщение вместо таблицы есть И форма
|
|
675
|
+
# фильтра на месте. Второе означает, что страница отрисовалась целиком.
|
|
676
|
+
# Одного первого мало: сообщение в отдельном абзаце может появиться и на
|
|
677
|
+
# сломанной странице.
|
|
678
|
+
empty_page = tree.css_first(_EMPTY_MARKER) is not None and tree.css_first(_FILTER) is not None
|
|
679
|
+
|
|
680
|
+
if tree.css_first(_TABLE) is None and not empty_page:
|
|
681
|
+
raise ProtocolChangedError(
|
|
682
|
+
f"на странице нет контейнера таблицы заказов ({_TABLE}) и нет "
|
|
683
|
+
f"признака пустого списка ({_EMPTY_MARKER}). Пустой список вернуть "
|
|
684
|
+
"нельзя: без признака он неотличим от смены разметки"
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
if tree.css_first(_ROWS_CONTAINER) is None and not empty_page:
|
|
688
|
+
raise ProtocolChangedError(
|
|
689
|
+
f"на странице нет контейнера строк ({_ROWS_CONTAINER}). "
|
|
690
|
+
"Без него нельзя проверить, что строки найдены все"
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
if tree.css_first(_HEADER) is None and not empty_page:
|
|
694
|
+
defects.append(
|
|
695
|
+
Defect(
|
|
696
|
+
severity=Severity.PAGE,
|
|
697
|
+
code="header_missing",
|
|
698
|
+
detail="заголовок таблицы отсутствует, разметка изменилась",
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
found = collect_rows(tree, _ROWS_CONTAINER, _ROW)
|
|
703
|
+
defects.extend(found.defects)
|
|
704
|
+
|
|
705
|
+
entries: list[OrderListEntry] = []
|
|
706
|
+
for index, row in enumerate(found.rows):
|
|
707
|
+
entry, row_defects = _parse_row(row, index)
|
|
708
|
+
defects.extend(row_defects)
|
|
709
|
+
if entry is not None:
|
|
710
|
+
entries.append(entry)
|
|
711
|
+
|
|
712
|
+
rows_total = max(len(found.rows), found.children, len(tree.css(_ROW)))
|
|
713
|
+
rows_accepted = len(entries)
|
|
714
|
+
rows_rejected = len(found.rows) - rows_accepted
|
|
715
|
+
|
|
716
|
+
if rows_total and not rows_accepted:
|
|
717
|
+
raise ProtocolChangedError(
|
|
718
|
+
f"кандидатов в строки {rows_total}, собрать не удалось ни одной. "
|
|
719
|
+
"Это изменение разметки, а не пустой список"
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# Дубли идентификаторов - повреждение уровня страницы. Курсор наблюдения
|
|
723
|
+
# хранит их множеством либо словарём, и два одинаковых схлопываются в один:
|
|
724
|
+
# число записей молча расходится с числом строк, а вызывающий получает
|
|
725
|
+
# список, в котором позиций меньше, чем было на странице.
|
|
726
|
+
#
|
|
727
|
+
# Проверка стала возможна только после того, как формат снимков научился
|
|
728
|
+
# различать идентификаторы: прежде маска схлопывала их сама, и проверка
|
|
729
|
+
# срабатывала бы на каждой фикстуре.
|
|
730
|
+
ids = [entry.order_id for entry in entries]
|
|
731
|
+
if len(set(ids)) != len(ids):
|
|
732
|
+
defects.append(
|
|
733
|
+
Defect(
|
|
734
|
+
severity=Severity.PAGE,
|
|
735
|
+
code="duplicate_identifiers",
|
|
736
|
+
detail=(
|
|
737
|
+
f"строк {len(ids)}, различимых идентификаторов {len(set(ids))}: "
|
|
738
|
+
"часть записей схлопнется в курсоре"
|
|
739
|
+
),
|
|
740
|
+
field_name="order_id",
|
|
741
|
+
)
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
for name in _PAGE_LEVEL_FIELDS:
|
|
745
|
+
if entries and all(not getattr(entry, name).is_observed for entry in entries):
|
|
746
|
+
defects.append(
|
|
747
|
+
Defect(
|
|
748
|
+
severity=Severity.PAGE,
|
|
749
|
+
code="field_missing_in_all_rows",
|
|
750
|
+
detail=f"поле {name} отсутствует во всех собранных строках",
|
|
751
|
+
field_name=name,
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
# Состояние сюда не входит: один заказ в состоянии, которого нет в таблице,
|
|
756
|
+
# - обычное дело и повреждением не является. А вот ни одного прочитанного
|
|
757
|
+
# состояния на непустой странице - другое дело. Все восемь заказов разом в
|
|
758
|
+
# новом состоянии неправдоподобны, и такое чтение означает, что разметка
|
|
759
|
+
# изменилась. Это последний рубеж: переименование класса закрытого заказа
|
|
760
|
+
# иначе не ловится ничем - у закрытого нет положительного модификатора
|
|
761
|
+
# строки, и отличить переименование от нового состояния по одной строке
|
|
762
|
+
# нельзя даже в принципе.
|
|
763
|
+
if entries and all(not entry.status.is_observed for entry in entries):
|
|
764
|
+
defects.append(
|
|
765
|
+
Defect(
|
|
766
|
+
severity=Severity.PAGE,
|
|
767
|
+
code="status_unreadable_on_every_row",
|
|
768
|
+
detail="ни в одной строке состояние не опознано по наблюдённым носителям",
|
|
769
|
+
field_name="status",
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
# Пустой список объявляется ПОЛНЫМ чтением только по позитивному
|
|
774
|
+
# свидетельству: таблицы нет И сообщение вместо неё есть.
|
|
775
|
+
#
|
|
776
|
+
# Прежде всякая пустота шла в unknown, потому что страницы без заказов никто
|
|
777
|
+
# не видел. Цена того незнания была не в полноте: курсор снимается только с
|
|
778
|
+
# полного чтения, значит на пустом списке цикл наблюдения не заводился - и
|
|
779
|
+
# заводился на СЛЕДУЮЩЕМ чтении, том, где первая продажа уже была. Событие о
|
|
780
|
+
# ней поглощалось заведением курсора и не приходило вовсе.
|
|
781
|
+
#
|
|
782
|
+
# Одного отсутствия таблицы мало. Переименуй площадка класс контейнера - и
|
|
783
|
+
# всякое чтение стало бы «пустым списком», а бот решил бы, что продаж не
|
|
784
|
+
# осталось. Это то же правило, по которому возможность объявляется
|
|
785
|
+
# отсутствующей: только по положительному свидетельству, никогда по неудаче.
|
|
786
|
+
if not rows_total and empty_page:
|
|
787
|
+
completeness, reason = Completeness.COMPLETE, "empty_list"
|
|
788
|
+
elif not rows_total:
|
|
789
|
+
completeness, reason = Completeness.UNKNOWN, "empty_list_not_observed"
|
|
790
|
+
elif not defects:
|
|
791
|
+
completeness, reason = Completeness.COMPLETE, "all_rows_parsed"
|
|
792
|
+
elif any(d.severity is Severity.PAGE for d in defects):
|
|
793
|
+
completeness, reason = Completeness.PARTIAL, "page_defects"
|
|
794
|
+
else:
|
|
795
|
+
completeness, reason = Completeness.PARTIAL, "row_defects"
|
|
796
|
+
|
|
797
|
+
return OrdersPage(
|
|
798
|
+
completeness=completeness,
|
|
799
|
+
reason=reason,
|
|
800
|
+
observed_at=observed_at,
|
|
801
|
+
rows_total=rows_total,
|
|
802
|
+
rows_accepted=rows_accepted,
|
|
803
|
+
rows_rejected=rows_rejected,
|
|
804
|
+
filters_available=tree.css_first(_FILTER_FORM) is not None,
|
|
805
|
+
defects=tuple(defects),
|
|
806
|
+
_entries=tuple(entries),
|
|
807
|
+
)
|