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/_thread.py
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""Разбор переписки со страницы отдельного диалога.
|
|
2
|
+
|
|
3
|
+
Это самый опасный модуль пакета, и опасность в нём одна. Если бот принимает
|
|
4
|
+
решение выдать товар по сообщению в переписке, покупателю достаточно написать
|
|
5
|
+
текст уведомления об оплате. Площадка знает об этой схеме и предупреждает о ней
|
|
6
|
+
сама, первым сообщением в каждом диалоге: не доверяйте сообщениям в чате, перед
|
|
7
|
+
выполнением заказа проверяйте наличие оплаты в разделе продаж.
|
|
8
|
+
|
|
9
|
+
Отсюда два правила, и второе важнее первого.
|
|
10
|
+
|
|
11
|
+
Первое: происхождение сообщения определяется разметкой, а не текстом. Признаков
|
|
12
|
+
два, и требуются оба сразу - обёртка предупреждения в теле и отсутствие ссылки на
|
|
13
|
+
автора. Ссылка надёжнее: у сообщения пользователя автор всегда ссылка на профиль,
|
|
14
|
+
и убрать её отправитель не может, что бы он ни написал. При расхождении признаков
|
|
15
|
+
происхождение объявляется неизвестным, а не системным: иначе достаточно площадке
|
|
16
|
+
перестать оборачивать уведомления, и любое сообщение станет системным.
|
|
17
|
+
|
|
18
|
+
Второе: даже верно опознанное системное сообщение не является подтверждением
|
|
19
|
+
оплаты. Оно могло относиться к другому заказу, устареть, прийти по отменённому
|
|
20
|
+
платежу. Модуль намеренно не предоставляет ничего, что выглядело бы как ответ на
|
|
21
|
+
вопрос «оплачено ли»: такой метод появился бы в чужом коде в тот же день, когда
|
|
22
|
+
появился бы здесь.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from enum import StrEnum
|
|
30
|
+
from typing import Final
|
|
31
|
+
|
|
32
|
+
from selectolax.parser import HTMLParser, Node
|
|
33
|
+
|
|
34
|
+
from ._extract import attribute
|
|
35
|
+
from ._host import host_of, same_host
|
|
36
|
+
from ._observed import Observed
|
|
37
|
+
from ._result import Completeness, Defect, Severity, collect_rows
|
|
38
|
+
from .errors import IncompleteResultError, ProtocolChangedError
|
|
39
|
+
from .extraction import SELECTOR_GROUPS, SELECTORS
|
|
40
|
+
|
|
41
|
+
__all__ = ["Origin", "Message", "Thread", "parse_thread"]
|
|
42
|
+
|
|
43
|
+
#: Контейнер сообщений.
|
|
44
|
+
_LIST: Final[str] = SELECTORS["chats.message.container"]
|
|
45
|
+
|
|
46
|
+
#: Селектор сообщения.
|
|
47
|
+
_MESSAGE: Final[str] = SELECTORS["chats.message.item"]
|
|
48
|
+
|
|
49
|
+
#: Тело сообщения.
|
|
50
|
+
_BODY: Final[str] = SELECTORS["chats.message.fields.body"]
|
|
51
|
+
|
|
52
|
+
#: Обёртка предупреждения внутри тела. Селектор обязан включать тело: класс
|
|
53
|
+
#: alert встречается на страницах и вне переписки, где к происхождению сообщения
|
|
54
|
+
#: отношения не имеет.
|
|
55
|
+
_ALERT: Final[str] = SELECTOR_GROUPS["chats.system_message.markers"][1]
|
|
56
|
+
|
|
57
|
+
#: Ссылка на профиль автора.
|
|
58
|
+
_AUTHOR_LINK: Final[str] = SELECTORS["chats.message.fields.author_link"]
|
|
59
|
+
|
|
60
|
+
#: Текст сообщения.
|
|
61
|
+
_TEXT: Final[str] = SELECTORS["chats.message.fields.text"]
|
|
62
|
+
|
|
63
|
+
#: Дата сообщения.
|
|
64
|
+
_DATE: Final[str] = SELECTORS["chats.message.fields.time_text"]
|
|
65
|
+
|
|
66
|
+
#: Имя автора.
|
|
67
|
+
#:
|
|
68
|
+
#: Берётся из ссылки на профиль, а не из содержащего её узла. Узел содержит ещё
|
|
69
|
+
#: ярлык роли и дату, и текст его целиком склеивал всё вместе: значение выходило
|
|
70
|
+
#: вида «имя, ярлык, время» - причём на неизменённом снимке, безо всякой порчи
|
|
71
|
+
#: разметки.
|
|
72
|
+
#:
|
|
73
|
+
#: У системного сообщения ссылки нет, и имя честно оказывается ненаблюдённым.
|
|
74
|
+
#: Подставлять туда ярлык роли было бы удобно и неверно: ярлык говорит, кем
|
|
75
|
+
#: сообщение отправлено, а не кем подписано, и спецификация прямо называет автора
|
|
76
|
+
#: отсутствующим у сообщений площадки.
|
|
77
|
+
_AUTHOR_NAME: Final[str] = _AUTHOR_LINK
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Origin(StrEnum):
|
|
81
|
+
"""Происхождение сообщения.
|
|
82
|
+
|
|
83
|
+
Значений три, а не два, и третье здесь не для симметрии. Разметка может
|
|
84
|
+
измениться так, что признаки перестанут согласовываться, и тогда честный
|
|
85
|
+
ответ - «не знаю». Ответ «системное» в этом случае открыл бы ровно ту дыру,
|
|
86
|
+
ради закрытия которой признак и заведён.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
#: Сообщение площадки: есть обёртка предупреждения, нет ссылки на автора.
|
|
90
|
+
SYSTEM = "system"
|
|
91
|
+
|
|
92
|
+
#: Сообщение человека: есть ссылка на автора, нет обёртки.
|
|
93
|
+
HUMAN = "human"
|
|
94
|
+
|
|
95
|
+
#: Признаки разошлись. Доверять сообщению нельзя.
|
|
96
|
+
UNKNOWN = "unknown"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class Message:
|
|
101
|
+
"""Одно сообщение переписки.
|
|
102
|
+
|
|
103
|
+
Attributes:
|
|
104
|
+
message_id (Observed[str]): Идентификатор сообщения из разметки.
|
|
105
|
+
row_index (int): Порядковый номер в переписке, с нуля.
|
|
106
|
+
origin (Origin): Происхождение, определённое структурно.
|
|
107
|
+
author_name (Observed[str]): Имя автора, текст.
|
|
108
|
+
author_href (Observed[str]): Ссылка на профиль автора. У сообщений
|
|
109
|
+
площадки не наблюдается по определению.
|
|
110
|
+
text (Observed[str]): Текст сообщения. Чужой ввод: ни разбирать его для
|
|
111
|
+
принятия решений, ни ходить по ссылкам из него нельзя.
|
|
112
|
+
time_text (Observed[str]): Время, краткая форма.
|
|
113
|
+
time_full_text (Observed[str]): Время, полная форма из подсказки. Тоже
|
|
114
|
+
локализованный текст, разбирать его нельзя.
|
|
115
|
+
external_links (Observed[tuple[str, ...]]): Ссылки из текста, ведущие
|
|
116
|
+
за пределы площадки. Собраны для того, чтобы вызывающий видел их, а
|
|
117
|
+
не для того, чтобы по ним ходить. Поле наблюдаемое: пустая
|
|
118
|
+
последовательность означает «ссылок не было», ненаблюдённое - «тела
|
|
119
|
+
сообщения мы не нашли», и разница между этими случаями решает,
|
|
120
|
+
доверять ли выводу.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
message_id: Observed[str]
|
|
124
|
+
row_index: int
|
|
125
|
+
origin: Origin
|
|
126
|
+
author_name: Observed[str]
|
|
127
|
+
author_href: Observed[str]
|
|
128
|
+
text: Observed[str]
|
|
129
|
+
time_text: Observed[str]
|
|
130
|
+
time_full_text: Observed[str]
|
|
131
|
+
external_links: Observed[tuple[str, ...]]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True)
|
|
135
|
+
class Thread:
|
|
136
|
+
"""Результат чтения переписки.
|
|
137
|
+
|
|
138
|
+
Attributes:
|
|
139
|
+
completeness (Completeness): Полнота прочитанного.
|
|
140
|
+
reason (str): Машиночитаемая причина, по которой полнота такова.
|
|
141
|
+
observed_at (datetime): Момент наблюдения.
|
|
142
|
+
rows_total (int): Сколько кандидатов в сообщения нашлось.
|
|
143
|
+
rows_accepted (int): Сколько сообщений собрано.
|
|
144
|
+
rows_rejected (int): Сколько отброшено.
|
|
145
|
+
defects (tuple[Defect, ...]): Обнаруженные повреждения.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
completeness: Completeness
|
|
149
|
+
reason: str
|
|
150
|
+
observed_at: datetime
|
|
151
|
+
rows_total: int
|
|
152
|
+
rows_accepted: int
|
|
153
|
+
rows_rejected: int
|
|
154
|
+
defects: tuple[Defect, ...]
|
|
155
|
+
_messages: tuple[Message, ...] = field(repr=False, default=())
|
|
156
|
+
|
|
157
|
+
def messages(self, *, accept_incomplete: bool = False) -> tuple[Message, ...]:
|
|
158
|
+
"""Возвращает собранные сообщения.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
accept_incomplete (bool): Признание готовности работать с неполным
|
|
162
|
+
результатом.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
tuple[Message, ...]: Сообщения в порядке появления.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
IncompleteResultError: Если полнота отлична от COMPLETE, а неполнота
|
|
169
|
+
не признана. В переписке это опаснее, чем в списке: пропущенное
|
|
170
|
+
сообщение выглядит как ненаписанное.
|
|
171
|
+
"""
|
|
172
|
+
if self.completeness is not Completeness.COMPLETE and not accept_incomplete:
|
|
173
|
+
raise IncompleteResultError(
|
|
174
|
+
f"переписка прочитана не полностью ({self.completeness}, причина: "
|
|
175
|
+
f"{self.reason}), собрано {self.rows_accepted} из {self.rows_total}. "
|
|
176
|
+
"Передайте accept_incomplete=True, если готовы работать с неполными "
|
|
177
|
+
"данными"
|
|
178
|
+
)
|
|
179
|
+
return self._messages
|
|
180
|
+
|
|
181
|
+
def __len__(self) -> int:
|
|
182
|
+
"""Возвращает число собранных сообщений.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
int: Число собранных сообщений.
|
|
186
|
+
"""
|
|
187
|
+
return len(self._messages)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _text(node: Node | None, name: str) -> Observed[str]:
|
|
191
|
+
"""Извлекает текст узла как наблюдаемое значение.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
node (Node | None): Узел или None.
|
|
195
|
+
name (str): Имя поля для причины отсутствия.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Observed[str]: Наблюдение.
|
|
199
|
+
"""
|
|
200
|
+
if node is None:
|
|
201
|
+
return Observed.missing(f"selector_no_match:{name}")
|
|
202
|
+
value = " ".join((node.text() or "").split())
|
|
203
|
+
return Observed.present(value) if value else Observed.empty("")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _origin(message: Node) -> Origin:
|
|
207
|
+
"""Определяет происхождение сообщения по разметке.
|
|
208
|
+
|
|
209
|
+
Требуются оба признака сразу, и это правило с закрытым отказом. Достаточно
|
|
210
|
+
было бы одного, если бы разметка не менялась; она меняется.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
message (Node): Узел сообщения.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Origin: Происхождение. UNKNOWN, если признаки разошлись.
|
|
217
|
+
"""
|
|
218
|
+
has_alert = message.css_first(_ALERT) is not None
|
|
219
|
+
has_author_link = message.css_first(_AUTHOR_LINK) is not None
|
|
220
|
+
|
|
221
|
+
if has_alert and not has_author_link:
|
|
222
|
+
return Origin.SYSTEM
|
|
223
|
+
if has_author_link and not has_alert:
|
|
224
|
+
return Origin.HUMAN
|
|
225
|
+
return Origin.UNKNOWN
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _external_links(message: Node, host: str) -> Observed[tuple[str, ...]]:
|
|
229
|
+
"""Собирает ссылки из текста, ведущие за пределы площадки.
|
|
230
|
+
|
|
231
|
+
Ссылки собираются, чтобы вызывающий их видел. Ходить по ним нельзя: их пишет
|
|
232
|
+
собеседник, и переход означал бы, что содержимое переписки управляет
|
|
233
|
+
поведением клиента.
|
|
234
|
+
|
|
235
|
+
Поле наблюдаемое, а не голой последовательностью. Разница здесь та же, что и
|
|
236
|
+
у остальных полей: пустая последовательность означает «ссылок не было», а
|
|
237
|
+
ненаблюдённое - «тела сообщения мы не нашли». Прежде эти два случая
|
|
238
|
+
совпадали, и переименование класса тела давало ноль ссылок при полноте
|
|
239
|
+
complete и нуле повреждений - то есть неотличимо от сообщения без ссылок.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
message (Node): Узел сообщения.
|
|
243
|
+
host (str): Хост площадки.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Observed[tuple[str, ...]]: Адреса, ведущие на другие хосты, либо
|
|
247
|
+
ненаблюдённое значение, если тело сообщения не найдено.
|
|
248
|
+
"""
|
|
249
|
+
body = message.css_first(_TEXT)
|
|
250
|
+
if body is None:
|
|
251
|
+
return Observed.missing("selector_no_match:external_links")
|
|
252
|
+
|
|
253
|
+
found: list[str] = []
|
|
254
|
+
for link in body.css("a[href]"):
|
|
255
|
+
href = ((link.attributes or {}).get("href") or "").strip()
|
|
256
|
+
if not href:
|
|
257
|
+
continue
|
|
258
|
+
# Пустой хост - это не чужой хост. Относительная ссылка, якорь, mailto и
|
|
259
|
+
# javascript хоста не имеют вовсе, и объявлять их внешними значило бы
|
|
260
|
+
# выдавать за адрес другой площадки то, что адресом другой площадки не
|
|
261
|
+
# является. Условие взято из _skeleton.mask_path, где оно с самого начала
|
|
262
|
+
# написано верно: разошедшиеся копии одного правила - ровно то, из-за
|
|
263
|
+
# чего заводился _host.py.
|
|
264
|
+
if not host_of(href):
|
|
265
|
+
continue
|
|
266
|
+
# Сравнение подстрокой здесь стояло раньше и выглядело работающим.
|
|
267
|
+
# Адрес funpay.com.evil.example содержит имя площадки и проходил такую
|
|
268
|
+
# проверку - то есть ссылка на подставной сайт числилась своей и в
|
|
269
|
+
# перечень внешних не попадала.
|
|
270
|
+
if not same_host(href, host):
|
|
271
|
+
found.append(href)
|
|
272
|
+
return Observed.present(tuple(found)) if found else Observed.empty(())
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _parse_message(message: Node, index: int, host: str) -> tuple[Message, list[Defect]]:
|
|
276
|
+
"""Разбирает одно сообщение.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
message (Node): Узел сообщения.
|
|
280
|
+
index (int): Порядковый номер в переписке.
|
|
281
|
+
host (str): Хост площадки, для отделения внешних ссылок.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
tuple[Message, list[Defect]]: Сообщение и перечень повреждений.
|
|
285
|
+
"""
|
|
286
|
+
defects: list[Defect] = []
|
|
287
|
+
origin = _origin(message)
|
|
288
|
+
|
|
289
|
+
if origin is Origin.UNKNOWN:
|
|
290
|
+
defects.append(
|
|
291
|
+
Defect(
|
|
292
|
+
severity=Severity.ROW,
|
|
293
|
+
code="origin_indeterminate",
|
|
294
|
+
detail=(
|
|
295
|
+
"признаки происхождения разошлись: обёртка предупреждения и "
|
|
296
|
+
"ссылка на автора присутствуют либо отсутствуют одновременно"
|
|
297
|
+
),
|
|
298
|
+
row_index=index,
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
raw_id = ((message.attributes or {}).get("id") or "").strip()
|
|
303
|
+
author_link = message.css_first(_AUTHOR_LINK)
|
|
304
|
+
|
|
305
|
+
entry = Message(
|
|
306
|
+
message_id=(
|
|
307
|
+
Observed.present(raw_id) if raw_id else Observed.missing("attribute_absent:id")
|
|
308
|
+
),
|
|
309
|
+
row_index=index,
|
|
310
|
+
origin=origin,
|
|
311
|
+
author_name=_text(message.css_first(_AUTHOR_NAME), "author_name"),
|
|
312
|
+
author_href=attribute(author_link, "href", "author_href"),
|
|
313
|
+
text=_text(message.css_first(_TEXT), "text"),
|
|
314
|
+
time_text=_text(message.css_first(_DATE), "time_text"),
|
|
315
|
+
time_full_text=_title(message.css_first(_DATE)),
|
|
316
|
+
external_links=_external_links(message, host),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
for name in _CHECKED_FIELDS:
|
|
320
|
+
if not getattr(entry, name).is_observed:
|
|
321
|
+
defects.append(
|
|
322
|
+
Defect(
|
|
323
|
+
severity=Severity.FIELD,
|
|
324
|
+
code="field_not_observed",
|
|
325
|
+
detail="поле сообщения не найдено там, где ожидалось",
|
|
326
|
+
row_index=index,
|
|
327
|
+
field_name=name,
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return entry, defects
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
#: Поля, отсутствие которых у сообщения - повреждение уровня поля.
|
|
335
|
+
#:
|
|
336
|
+
#: Перечень узкий намеренно. Имя и время автора помечены в схеме как возможно
|
|
337
|
+
#: ненаблюдаемые - у системного сообщения автора нет вовсе, и в снимке шесть
|
|
338
|
+
#: таких из одиннадцати. Повреждение на каждое из них было бы ложной тревогой,
|
|
339
|
+
#: то есть шумом, за которым перестают следить.
|
|
340
|
+
_CHECKED_FIELDS: Final[tuple[str, ...]] = ("message_id", "text")
|
|
341
|
+
|
|
342
|
+
#: Поля, отсутствие которых во ВСЕХ сообщениях означает поломку разметки.
|
|
343
|
+
#:
|
|
344
|
+
#: Здесь перечень шире. Одно сообщение без автора - обычное дело, все сообщения
|
|
345
|
+
#: без автора - изменившаяся вёрстка, и разница между этими случаями видна
|
|
346
|
+
#: только по странице целиком. Прежде не было ни того перечня, ни этого:
|
|
347
|
+
#: переименование класса имени, даты или тела давало complete и ноль
|
|
348
|
+
#: повреждений, тогда как у соседних разборов та же порча даёт partial.
|
|
349
|
+
_PAGE_LEVEL_FIELDS: Final[tuple[str, ...]] = (
|
|
350
|
+
"message_id",
|
|
351
|
+
"text",
|
|
352
|
+
"author_name",
|
|
353
|
+
"time_text",
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _title(node: Node | None) -> Observed[str]:
|
|
358
|
+
"""Извлекает подсказку с полной формой времени.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
node (Node | None): Узел даты или None.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Observed[str]: Значение подсказки. На странице заказов подсказки нет
|
|
365
|
+
вовсе, поэтому её отсутствие здесь - наблюдение, а не поломка.
|
|
366
|
+
"""
|
|
367
|
+
if node is None:
|
|
368
|
+
return Observed.missing("selector_no_match:time_full_text")
|
|
369
|
+
raw = ((node.attributes or {}).get("title") or "").strip()
|
|
370
|
+
return Observed.present(raw) if raw else Observed.missing("attribute_absent:title")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _shape_defects(tree: HTMLParser) -> list[Defect]:
|
|
374
|
+
"""Проверяет, что дерево сообщений имеет наблюдённую форму.
|
|
375
|
+
|
|
376
|
+
Проверка нужна против одной угрозы: текст сообщения, попавший в разметку как
|
|
377
|
+
разметка. Закрой отправитель ровно столько элементов, сколько нужно, и
|
|
378
|
+
следом открой поддельное сообщение с обёрткой предупреждения - разбор
|
|
379
|
+
прочтёт его как сообщение площадки, а бот выдачи товара примет за
|
|
380
|
+
уведомление об оплате.
|
|
381
|
+
|
|
382
|
+
Что проверка закрывает и чего не закрывает, надо назвать точно, иначе она
|
|
383
|
+
даёт ложное спокойствие.
|
|
384
|
+
|
|
385
|
+
Закрывает всякую несбалансированную попытку. Закрыто меньше, чем нужно, -
|
|
386
|
+
поддельное сообщение оказывается внутри настоящего. Больше - оказывается вне
|
|
387
|
+
контейнера. Оба случая здесь и ловятся.
|
|
388
|
+
|
|
389
|
+
НЕ закрывает попытку с точным числом. Закрыв ровно столько элементов,
|
|
390
|
+
сколько лежит между текстом и контейнером, отправитель получает поддельное
|
|
391
|
+
сообщение, которое является законным прямым потомком контейнера и от
|
|
392
|
+
настоящего структурно неотличимо. Отличить его нечем в принципе: следа
|
|
393
|
+
вставки в разобранном дереве не остаётся.
|
|
394
|
+
|
|
395
|
+
Отсюда две вещи. Первая: проверка поднимает цену - отправителю нужно знать
|
|
396
|
+
точную глубину вёрстки, а она меняется при любой правке шаблона. Вторая, и
|
|
397
|
+
она важнее: последним рубежом остаётся правило, записанное в docstring
|
|
398
|
+
модуля, - **происхождение system не является подтверждением оплаты**. Ни
|
|
399
|
+
одна проверка формы этого правила не заменяет.
|
|
400
|
+
|
|
401
|
+
Наблюдалось при этом, что площадка текст сообщения экранирует: по скелету
|
|
402
|
+
это непроверяемо (класс p покрывает и угловые скобки), но косвенно на это
|
|
403
|
+
указывает разметка ссылок в сообщениях - она построена площадкой из простого
|
|
404
|
+
текста, а не сохранена как есть.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
tree (HTMLParser): Разобранный документ.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
list[Defect]: Повреждения уровня страницы. Пустой перечень, если форма
|
|
411
|
+
совпадает с наблюдённой.
|
|
412
|
+
"""
|
|
413
|
+
defects: list[Defect] = []
|
|
414
|
+
items = tree.css(_MESSAGE)
|
|
415
|
+
|
|
416
|
+
# Считается число совпадений внутри узла, а не сравнение узлов между собой:
|
|
417
|
+
# selectolax отдаёт новую обёртку на каждое обращение, и проверка по
|
|
418
|
+
# тождеству срабатывала бы на неизменённой разметке. Совпадение ровно одно -
|
|
419
|
+
# сам узел; больше одного означает вложенность.
|
|
420
|
+
inside_message = sum(1 for node in items if len(node.css(_MESSAGE)) > 1)
|
|
421
|
+
if inside_message:
|
|
422
|
+
defects.append(
|
|
423
|
+
Defect(
|
|
424
|
+
severity=Severity.PAGE,
|
|
425
|
+
code="message_nested_in_message",
|
|
426
|
+
detail=(
|
|
427
|
+
f"сообщений, вложенных в другое сообщение: {inside_message}. "
|
|
428
|
+
"В наблюдённой разметке такого не бывает, и вложенность "
|
|
429
|
+
"означает либо смену вёрстки, либо разметку внутри текста"
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
inside_text = sum(1 for node in tree.css(_TEXT) if node.css_first(_MESSAGE) is not None)
|
|
435
|
+
if inside_text:
|
|
436
|
+
defects.append(
|
|
437
|
+
Defect(
|
|
438
|
+
severity=Severity.PAGE,
|
|
439
|
+
code="message_inside_message_text",
|
|
440
|
+
detail=(
|
|
441
|
+
f"сообщений внутри текста другого сообщения: {inside_text}. "
|
|
442
|
+
"Текст пишет собеседник, и разметка в нём означает, что он "
|
|
443
|
+
"управляет разбором"
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
stray = sum(
|
|
449
|
+
1
|
|
450
|
+
for node in items
|
|
451
|
+
if node.parent is None
|
|
452
|
+
or "chat-message-list" not in ((node.parent.attributes or {}).get("class") or "")
|
|
453
|
+
)
|
|
454
|
+
if stray:
|
|
455
|
+
defects.append(
|
|
456
|
+
Defect(
|
|
457
|
+
severity=Severity.PAGE,
|
|
458
|
+
code="message_outside_the_list",
|
|
459
|
+
detail=(
|
|
460
|
+
f"сообщений не на своём месте в дереве: {stray}. В наблюдённой "
|
|
461
|
+
"разметке каждое сообщение - прямой потомок контейнера"
|
|
462
|
+
),
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
return defects
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def parse_thread(html: str, *, observed_at: datetime, host: str = "funpay.com") -> Thread:
|
|
470
|
+
"""Разбирает переписку.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
html (str): Тело страницы, уже признанное пригодным.
|
|
474
|
+
observed_at (datetime): Момент наблюдения.
|
|
475
|
+
host (str): Хост площадки, для отделения внешних ссылок в тексте.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
Thread: Сообщения вместе с полнотой и перечнем повреждений.
|
|
479
|
+
|
|
480
|
+
Raises:
|
|
481
|
+
ProtocolChangedError: Если контейнера сообщений нет либо кандидаты были,
|
|
482
|
+
а собрать не удалось ни одного.
|
|
483
|
+
"""
|
|
484
|
+
tree = HTMLParser(html)
|
|
485
|
+
defects: list[Defect] = []
|
|
486
|
+
|
|
487
|
+
if tree.css_first(_LIST) is None:
|
|
488
|
+
raise ProtocolChangedError(
|
|
489
|
+
f"на странице нет контейнера сообщений ({_LIST}). Пустую переписку "
|
|
490
|
+
"вернуть нельзя: она неотличима от несуществующей"
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
found = collect_rows(tree, _LIST, _MESSAGE)
|
|
494
|
+
defects.extend(found.defects)
|
|
495
|
+
|
|
496
|
+
entries: list[Message] = []
|
|
497
|
+
for index, node in enumerate(found.rows):
|
|
498
|
+
entry, message_defects = _parse_message(node, index, host)
|
|
499
|
+
defects.extend(message_defects)
|
|
500
|
+
entries.append(entry)
|
|
501
|
+
|
|
502
|
+
rows_total = max(len(found.rows), found.children, len(tree.css(_MESSAGE)))
|
|
503
|
+
rows_accepted = len(entries)
|
|
504
|
+
|
|
505
|
+
if rows_total and not rows_accepted:
|
|
506
|
+
raise ProtocolChangedError(
|
|
507
|
+
f"кандидатов в сообщения {rows_total}, собрать не удалось ни одного. "
|
|
508
|
+
"Это изменение разметки, а не пустая переписка"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
defects.extend(_shape_defects(tree))
|
|
512
|
+
|
|
513
|
+
# Идентификаторы сообщений хранятся множеством: курсор переписки - это набор
|
|
514
|
+
# уже виденных. Два одинаковых схлопываются в один, и второе сообщение
|
|
515
|
+
# объявляется виденным, не будучи доставленным ни разу.
|
|
516
|
+
#
|
|
517
|
+
# Для списков заказов и диалогов такая проверка уже стоит, а здесь её не
|
|
518
|
+
# было, и цена ошибки тут выше: непрочитанное сообщение покупателя.
|
|
519
|
+
seen = [entry.message_id.value for entry in entries if entry.message_id.is_observed]
|
|
520
|
+
if len(set(seen)) != len(seen):
|
|
521
|
+
defects.append(
|
|
522
|
+
Defect(
|
|
523
|
+
severity=Severity.PAGE,
|
|
524
|
+
code="duplicate_identifiers",
|
|
525
|
+
detail=(
|
|
526
|
+
f"сообщений с прочитанным идентификатором {len(seen)}, "
|
|
527
|
+
f"различимых {len(set(seen))}: часть событий не породится никогда"
|
|
528
|
+
),
|
|
529
|
+
field_name="message_id",
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
for name in _PAGE_LEVEL_FIELDS:
|
|
534
|
+
if entries and all(not getattr(entry, name).is_observed for entry in entries):
|
|
535
|
+
defects.append(
|
|
536
|
+
Defect(
|
|
537
|
+
severity=Severity.PAGE,
|
|
538
|
+
code="field_missing_in_all_rows",
|
|
539
|
+
detail=f"поле {name} отсутствует у всех собранных сообщений",
|
|
540
|
+
field_name=name,
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
if entries and all(m.origin is Origin.UNKNOWN for m in entries):
|
|
545
|
+
defects.append(
|
|
546
|
+
Defect(
|
|
547
|
+
severity=Severity.PAGE,
|
|
548
|
+
code="origin_indeterminate_in_all_messages",
|
|
549
|
+
detail=(
|
|
550
|
+
"происхождение не определяется ни у одного сообщения: "
|
|
551
|
+
"разметка изменилась, и различать площадку и собеседника нечем"
|
|
552
|
+
),
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
if not rows_total:
|
|
557
|
+
completeness, reason = Completeness.UNKNOWN, "empty_thread_not_observed"
|
|
558
|
+
elif not defects:
|
|
559
|
+
completeness, reason = Completeness.COMPLETE, "all_messages_parsed"
|
|
560
|
+
elif any(d.severity is Severity.PAGE for d in defects):
|
|
561
|
+
completeness, reason = Completeness.PARTIAL, "page_defects"
|
|
562
|
+
else:
|
|
563
|
+
completeness, reason = Completeness.PARTIAL, "row_defects"
|
|
564
|
+
|
|
565
|
+
return Thread(
|
|
566
|
+
completeness=completeness,
|
|
567
|
+
reason=reason,
|
|
568
|
+
observed_at=observed_at,
|
|
569
|
+
rows_total=rows_total,
|
|
570
|
+
rows_accepted=rows_accepted,
|
|
571
|
+
rows_rejected=rows_total - rows_accepted,
|
|
572
|
+
defects=tuple(defects),
|
|
573
|
+
_messages=tuple(entries),
|
|
574
|
+
)
|