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/_classify.py ADDED
@@ -0,0 +1,466 @@
1
+ """Классификация ответа до разбора страницы.
2
+
3
+ Порядок шагов здесь важнее их содержания. Если поставить проверку отпечатка
4
+ раньше детекторов, страница логина, отданная с кодом 200, уйдёт в парсер как
5
+ валидная и вернёт пустой список - а пустой список неотличим от «данных нет».
6
+ Страница проверки при этом станет неотличима от поломки разметки, и клиент
7
+ продолжит стучаться, подтверждая подозрение.
8
+
9
+ Конвейер:
10
+
11
+ 1. Транспорт и код ответа.
12
+ 2. Проверка личности: конечный URL после переходов принадлежит ожидаемому
13
+ хосту, а в теле есть признак того, что мы вошли под ожидаемым аккаунтом.
14
+ Тот же шаг ловит перепутанные cookie при работе с несколькими аккаунтами.
15
+ 3. Детекторы страниц-перехватчиков: логин, проверка, блокировка, обслуживание.
16
+ 4. Отпечаток страницы.
17
+ 5. Разбор.
18
+
19
+ Шаги 4 и 5 живут в адаптере и в этом модуле не реализованы.
20
+
21
+ Признаки выхода из сессии подтверждены наблюдением 18.08.2026 и помечены
22
+ provisional=False. Признаки проверки, блокировки и технических работ остаются
23
+ умозрительными: таких страниц мы ещё не видели, и придумывать их точный вид
24
+ хуже, чем честно вернуть unknown.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import re
31
+ from dataclasses import dataclass, field
32
+ from enum import StrEnum
33
+ from typing import Final
34
+
35
+ from selectolax.parser import HTMLParser
36
+
37
+ from ._host import host_of, same_host
38
+ from .extraction import SELECTOR_GROUPS
39
+ from .response_classes import STATUS_CLASS
40
+
41
+ __all__ = [
42
+ "ResponseClass",
43
+ "DEFAULT_IDENTITY_CSS",
44
+ "DEFAULT_CONTENT_MARKERS",
45
+ "Verdict",
46
+ "Signature",
47
+ "classify",
48
+ "DEFAULT_SIGNATURES",
49
+ ]
50
+
51
+
52
+ class ResponseClass(StrEnum):
53
+ """Класс ответа площадки.
54
+
55
+ Значения намеренно различают состояния доступа и поломку разметки: это разные
56
+ проблемы с разным лечением, а внешне они выглядят одинаково.
57
+
58
+ Превышение частоты отделено от блокировки по той же причине. Блокировка
59
+ трактуется как отказ с закрытым замком, и попади код 429 в неё, первое же
60
+ попадание в ограничение остановило бы опрос навсегда, а политика повторов
61
+ для этого случая осталась бы недостижимым кодом. «Слишком быстро» и «вам
62
+ сюда нельзя» - разные ответы.
63
+ """
64
+
65
+ OK = "ok"
66
+ LOGIN_REQUIRED = "login_required"
67
+ CHALLENGE = "challenge"
68
+ BLOCKED = "blocked"
69
+ RATE_LIMITED = "rate_limited"
70
+ MAINTENANCE = "maintenance"
71
+ WRONG_IDENTITY = "wrong_identity"
72
+ TRANSPORT_ERROR = "transport_error"
73
+ UNKNOWN = "unknown"
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class Signature:
78
+ """Признак, по которому распознаётся страница-перехватчик.
79
+
80
+ Args:
81
+ name (str): Имя сигнатуры для диагностики.
82
+ verdict (ResponseClass): Класс, который присваивается при срабатывании.
83
+ css (tuple[str, ...]): Селекторы, наличие любого из которых считается
84
+ срабатыванием.
85
+ patterns (tuple[str, ...]): Регулярные выражения по тексту страницы.
86
+ Проверяются только если селекторы не заданы или не сработали.
87
+ provisional (bool): Признак того, что сигнатура составлена умозрительно
88
+ и не подтверждена наблюдением настоящей страницы.
89
+ """
90
+
91
+ name: str
92
+ verdict: ResponseClass
93
+ css: tuple[str, ...] = ()
94
+ patterns: tuple[str, ...] = ()
95
+ provisional: bool = True
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class Verdict:
100
+ """Результат классификации.
101
+
102
+ Args:
103
+ cls (ResponseClass): Класс ответа.
104
+ reason (str): Машиночитаемая причина решения.
105
+ matched (str | None): Имя сработавшей сигнатуры, если решение принято ею.
106
+ provisional (bool): True, если решение принято непроверенной сигнатурой
107
+ и потому требует ручного подтверждения.
108
+ detail (dict[str, str]): Дополнительные сведения для диагностики.
109
+ Содержимого страницы и персональных данных здесь нет.
110
+ """
111
+
112
+ cls: ResponseClass
113
+ reason: str
114
+ matched: str | None = None
115
+ provisional: bool = False
116
+ detail: dict[str, str] = field(default_factory=dict)
117
+
118
+ @property
119
+ def is_ok(self) -> bool:
120
+ """Сообщает, можно ли передавать ответ дальше по конвейеру.
121
+
122
+ Returns:
123
+ bool: True, если ответ пригоден для проверки отпечатка и разбора.
124
+ """
125
+ return self.cls is ResponseClass.OK
126
+
127
+
128
+ #: Селектор, подтверждающий, что страница отдана вошедшему пользователю.
129
+ #:
130
+ #: Наблюдение 18.08.2026 показало пару признаков, различающих состояния: у
131
+ #: вошедшего в шапке стоит navbar-toggle-logged, у гостя - navbar-toggle-guest.
132
+ #: Признак структурный и не зависит от языка интерфейса, что здесь принципиально:
133
+ #: локаль привязана к аккаунту, а не к адресу, и через URL её не переключить.
134
+ DEFAULT_IDENTITY_CSS: Final[str] = SELECTOR_GROUPS["session.markers.logged_in"][0]
135
+
136
+ #: Сигнатуры по умолчанию.
137
+ #:
138
+ #: Признаки выхода из сессии подтверждены наблюдением и помечены provisional=False.
139
+ #: Признаки проверки, блокировки и технических работ остаются умозрительными:
140
+ #: таких страниц мы ещё не видели, и придумывать их точный вид хуже, чем честно
141
+ #: вернуть unknown.
142
+ _log = logging.getLogger("funora.classify")
143
+
144
+ DEFAULT_SIGNATURES: Final[tuple[Signature, ...]] = (
145
+ Signature(
146
+ name="guest_navbar",
147
+ verdict=ResponseClass.LOGIN_REQUIRED,
148
+ # Первые три признака перечня - шапка гостя. Остальные относятся к
149
+ # странице входа и стоят подписью ниже; деление проходит по порядку,
150
+ # объявленному спецификацией.
151
+ css=SELECTOR_GROUPS["session.markers.guest"][:3],
152
+ provisional=False,
153
+ ),
154
+ Signature(
155
+ name="login_page",
156
+ verdict=ResponseClass.LOGIN_REQUIRED,
157
+ css=SELECTOR_GROUPS["session.markers.guest"][3:],
158
+ provisional=False,
159
+ ),
160
+ Signature(
161
+ name="login_form",
162
+ verdict=ResponseClass.LOGIN_REQUIRED,
163
+ css=SELECTOR_GROUPS["session.markers.login_form"],
164
+ ),
165
+ Signature(
166
+ name="challenge_widget",
167
+ verdict=ResponseClass.CHALLENGE,
168
+ css=SELECTOR_GROUPS["session.markers.challenge"],
169
+ ),
170
+ Signature(
171
+ name="challenge_text",
172
+ verdict=ResponseClass.CHALLENGE,
173
+ patterns=(r"\bcaptcha\b", r"подозрительн", r"подтвердите,?\s+что\s+вы"),
174
+ ),
175
+ Signature(
176
+ name="blocked_text",
177
+ verdict=ResponseClass.BLOCKED,
178
+ patterns=(r"\bбан\b", r"заблокирован", r"доступ\s+запрещ", r"access\s+denied"),
179
+ ),
180
+ Signature(
181
+ name="maintenance_text",
182
+ verdict=ResponseClass.MAINTENANCE,
183
+ patterns=(r"технически[ей]\s+работ", r"maintenance", r"временно\s+недоступ"),
184
+ ),
185
+ )
186
+
187
+ #: Коды, при которых тело разбирать бессмысленно.
188
+ #:
189
+ #: Таблица строится из порождённой, а не пишется здесь. Рукописная копия жила
190
+ #: рядом с объявленной в spec/extraction/session.yaml и ничем с ней не
191
+ #: сверялась: правка спецификации перестраивала бы порождённый файл, оставляя
192
+ #: поведение прежним. Расхождение обнаружилось бы в работе - и ровно на той
193
+ #: ошибке, от которой спецификация предостерегает отдельным разделом: код 429,
194
+ #: принятый за блокировку, навсегда останавливает опрос.
195
+ #:
196
+ #: Причина собирается из кода: она уходит в таблицу «вердикт - ошибка», где
197
+ #: ключи имеют вид http_401. Собирать её здесь дешевле, чем объявлять в
198
+ #: спецификации второе имя для того же числа.
199
+ _HARD_STATUS: Final[dict[int, tuple[ResponseClass, str]]] = {
200
+ code: (ResponseClass(name), f"http_{code}") for code, name in STATUS_CLASS.items()
201
+ }
202
+
203
+ #: Максимум текста, по которому идёт поиск текстовых сигнатур. Ограничение
204
+ #: защищает от страницы, раздутой намеренно или по ошибке.
205
+ _TEXT_LIMIT: Final[int] = 200_000
206
+
207
+
208
+ #: Признаки того, что страница отдана приложением, а не перехватчиком.
209
+ #:
210
+ #: Перечень отвечает на вопрос «узнаём ли мы эту страницу», и текстовые сигнатуры
211
+ #: применяются только тогда, когда ответ отрицательный.
212
+ #:
213
+ #: Прежде здесь стоял обратный перечень: где на странице пишут люди. Форма была
214
+ #: неверна. Перечислять пользовательские участки значит перечислять их все, а
215
+ #: любой пропущенный открывает дыру заново - что и случилось: заголовок лота в
216
+ #: боковой панели переписки не попал в перечень, и одного слова в нём хватало,
217
+ #: чтобы весь конвейер признал страницу блокировкой и остановил бота.
218
+ #:
219
+ #: Перечень признаков площадки закрыт по построению: настоящая страница проверки
220
+ #: или блокировки заменяет содержимое целиком, и ни таблицы заказов, ни виджета
221
+ #: переписки на ней нет.
222
+ DEFAULT_CONTENT_MARKERS: Final[tuple[str, ...]] = SELECTOR_GROUPS["session.content_markers"]
223
+
224
+
225
+ def _page_text(html: str) -> str:
226
+ """Извлекает видимый текст страницы в нижнем регистре.
227
+
228
+ Args:
229
+ html (str): Исходный HTML.
230
+
231
+ Returns:
232
+ str: Текст страницы, обрезанный до предела и приведённый к нижнему
233
+ регистру.
234
+ """
235
+ try:
236
+ tree = HTMLParser(html[:_TEXT_LIMIT])
237
+ except Exception:
238
+ return ""
239
+ body = tree.body or tree.root
240
+ if body is None:
241
+ return ""
242
+ return (body.text(separator=" ") or "").lower()
243
+
244
+
245
+ def _matches(tree: HTMLParser, selector: str, where: str) -> bool:
246
+ """Применяет селектор, не давая непригодному уронить классификацию.
247
+
248
+ Непригодный селектор пропускается, а не роняет разбор: классификация идёт по
249
+ живой странице, и уронить её из-за одного выражения значило бы превратить
250
+ опечатку в отказ читать площадку вовсе.
251
+
252
+ Но и молча пропускать нельзя. Молча пропущенный селектор выключает свою
253
+ подпись насовсем: страница входа перестаёт узнаваться, вердикт уходит в
254
+ «разметка изменилась», и виноватой выглядит площадка. Отсюда строка в
255
+ журнале - на каждое применение по разу, зато с именем виноватого.
256
+
257
+ Args:
258
+ tree (HTMLParser): Разобранный документ.
259
+ selector (str): Выражение CSS.
260
+ where (str): Откуда селектор взят - для строки журнала.
261
+
262
+ Returns:
263
+ bool: True, если селектор нашёл узел. False, если не нашёл либо
264
+ оказался непригодным.
265
+ """
266
+ try:
267
+ return tree.css_first(selector) is not None
268
+ except Exception as exc:
269
+ _log.warning(
270
+ "селектор %r из %s не применился (%s); подпись работает без него",
271
+ selector,
272
+ where,
273
+ type(exc).__name__,
274
+ )
275
+ return False
276
+
277
+
278
+ def _is_known_page(tree: HTMLParser | None, markers: tuple[str, ...]) -> bool:
279
+ """Сообщает, узнаётся ли страница как отданная приложением.
280
+
281
+ Args:
282
+ tree (HTMLParser | None): Разобранный документ.
283
+ markers (tuple[str, ...]): Признаки страниц приложения.
284
+
285
+ Returns:
286
+ bool: True, если найден хотя бы один признак.
287
+ """
288
+ if tree is None:
289
+ return False
290
+ return any(_matches(tree, selector, "признаков страниц приложения") for selector in markers)
291
+
292
+
293
+ def classify(
294
+ *,
295
+ status: int,
296
+ final_url: str,
297
+ html: str,
298
+ expected_host: str,
299
+ identity_css: str | None = DEFAULT_IDENTITY_CSS,
300
+ signatures: tuple[Signature, ...] = DEFAULT_SIGNATURES,
301
+ content_markers: tuple[str, ...] = DEFAULT_CONTENT_MARKERS,
302
+ declared_length: int | None = None,
303
+ received_length: int | None = None,
304
+ content_encoding: str = "",
305
+ ) -> Verdict:
306
+ """Классифицирует ответ площадки.
307
+
308
+ Args:
309
+ status (int): Код состояния HTTP.
310
+ final_url (str): URL после всех переходов. Важен именно конечный: переход
311
+ на страницу входа - самый частый способ получить код 200 с чужим
312
+ содержимым.
313
+ html (str): Тело ответа.
314
+ expected_host (str): Хост, которому должен принадлежать конечный URL.
315
+ identity_css (str | None): Селектор элемента, который присутствует только
316
+ на страницах, отданных вошедшему пользователю. По умолчанию берётся
317
+ подтверждённый наблюдением признак. Передайте None, чтобы пропустить
318
+ шаг проверки личности; это отразится в причине.
319
+ signatures (tuple[Signature, ...]): Реестр сигнатур детекторов.
320
+ declared_length (int | None): Длина тела, объявленная заголовком.
321
+ received_length (int | None): Длина полученного тела.
322
+ content_encoding (str): Кодирование тела. Непустое означает, что длины
323
+ несравнимы: объявлена сжатая, получена распакованная.
324
+ content_markers (tuple[str, ...]): Признаки страниц приложения. Если
325
+ найден хотя бы один, текстовые сигнатуры не применяются вовсе:
326
+ страница узнана, и догадываться по тексту не о чем. Иначе
327
+ собеседник останавливает бота одним словом в переписке либо в
328
+ названии своего лота.
329
+
330
+ Returns:
331
+ Verdict: Класс ответа с причиной и признаком того, было ли решение принято
332
+ непроверенной сигнатурой.
333
+ """
334
+ # Шаг 1. Транспорт и код ответа.
335
+ if status in _HARD_STATUS:
336
+ cls, reason = _HARD_STATUS[status]
337
+ return Verdict(cls=cls, reason=reason, detail={"status": str(status)})
338
+ if status >= 500:
339
+ return Verdict(
340
+ cls=ResponseClass.TRANSPORT_ERROR, reason="http_5xx", detail={"status": str(status)}
341
+ )
342
+ if status >= 400:
343
+ return Verdict(
344
+ cls=ResponseClass.TRANSPORT_ERROR, reason="http_4xx", detail={"status": str(status)}
345
+ )
346
+
347
+ # Шаг 2. Целостность тела. ПОСЛЕ кода ответа и до всего остального.
348
+ #
349
+ # После кода намеренно: при 429 и 503 тело разбирать бессмысленно, и его
350
+ # обрыв ничего не добавляет к диагнозу. Прежде порядок был обратным, и ответ
351
+ # 429 с оборванным телом становился сетевым отказом - то есть повторялся
352
+ # коротким отступлением, не урезая ёмкость ведра, ровно тогда, когда
353
+ # площадка сказала «слишком быстро».
354
+ #
355
+ # До всего остального - тоже намеренно: страница, оборванная посреди
356
+ # таблицы, проходит и классификацию как пригодную, и разбор как полный.
357
+ # Вызывающий получает половину заказов и ноль повреждений.
358
+ if declared_length is not None and received_length is not None:
359
+ if content_encoding not in ("", "identity"):
360
+ # Сравнивать нечего: объявленная длина относится к сжатому телу, а
361
+ # полученная - к распакованному. Клиент просит не сжимать; если
362
+ # сервер просьбу не выполнил, честнее сказать об этом, чем сравнить
363
+ # несравнимое и объявить целостность подтверждённой.
364
+ _log.warning(
365
+ "тело пришло сжатым (%s) вопреки просьбе: целостность не "
366
+ "проверена, обрыв на такой странице выглядел бы как изменение "
367
+ "разметки",
368
+ content_encoding,
369
+ )
370
+ elif received_length < declared_length:
371
+ return Verdict(
372
+ cls=ResponseClass.TRANSPORT_ERROR,
373
+ reason="body_truncated",
374
+ detail={"declared": str(declared_length), "received": str(received_length)},
375
+ )
376
+
377
+ # Шаг 3. Проверка личности. Идёт до детекторов: ответ с чужого хоста не
378
+ # заслуживает того, чтобы его разбирали, каким бы он ни выглядел.
379
+ # Правило берётся из _host.py, а не пишется здесь заново. Своя копия тут и
380
+ # была - сравнение хоста с суффиксом, - и она принимала за наш хост адрес
381
+ # вида https://evil.example\.funpay.com/: разборщик Python видит в нём один
382
+ # хост, оканчивающийся на .funpay.com, а браузер идёт на evil.example. Это
383
+ # тот же промах, из-за которого _host.py и появился, и это была четвёртая
384
+ # его копия.
385
+ host = host_of(final_url)
386
+ if not host:
387
+ # Хост нечитаем: это не расхождение, а невозможность судить. Ответу, о
388
+ # происхождении которого сказать нечего, доверять нельзя тем более.
389
+ # Причина названа отдельно, чтобы разбор видел разницу.
390
+ return Verdict(
391
+ cls=ResponseClass.WRONG_IDENTITY,
392
+ reason="host_unreadable",
393
+ detail={"expected": expected_host, "actual": final_url[:64]},
394
+ )
395
+ if not same_host(final_url, expected_host):
396
+ return Verdict(
397
+ cls=ResponseClass.WRONG_IDENTITY,
398
+ reason="host_mismatch",
399
+ detail={"expected": expected_host, "actual": host},
400
+ )
401
+
402
+ if not html.strip():
403
+ return Verdict(cls=ResponseClass.UNKNOWN, reason="empty_body")
404
+
405
+ # Шаг 4. Детекторы страниц-перехватчиков.
406
+ tree: HTMLParser | None
407
+ try:
408
+ tree = HTMLParser(html)
409
+ except Exception as exc:
410
+ # Тело не разобралось вовсе. Прежде здесь ставилось None, и дальше всё
411
+ # шло своим чередом: признаков страницы приложения не нашлось, текст
412
+ # оказался пуст, сигнатуры не сработали, и вердикт выходил
413
+ # identity_marker_absent - то есть «разметка изменилась». Диагноз не тот
414
+ # и лечение не то: разметка могла не меняться вовсе, а ответ прийти не
415
+ # тем, чем должен.
416
+ _log.warning("тело ответа не разобралось (%s)", type(exc).__name__)
417
+ return Verdict(
418
+ cls=ResponseClass.UNKNOWN,
419
+ reason="body_unparsable",
420
+ detail={"error": type(exc).__name__},
421
+ )
422
+
423
+ # Текстовые сигнатуры применяются только к неузнанной странице. Узнанная -
424
+ # это страница приложения, часть текста на которой пишут посторонние:
425
+ # переписка, названия лотов, имена. Догадка по такому тексту даёт
426
+ # контрагенту право остановить чужого бота одним словом.
427
+ known = _is_known_page(tree, content_markers)
428
+
429
+ text: str | None = None
430
+ for sig in signatures:
431
+ if tree is not None:
432
+ for selector in sig.css:
433
+ if _matches(tree, selector, f"подписи {sig.name}"):
434
+ return Verdict(
435
+ cls=sig.verdict,
436
+ reason=f"signature:{sig.name}",
437
+ matched=sig.name,
438
+ provisional=sig.provisional,
439
+ detail={"selector": selector},
440
+ )
441
+ if sig.patterns and not known:
442
+ if text is None:
443
+ text = _page_text(html)
444
+ for pattern in sig.patterns:
445
+ if re.search(pattern, text):
446
+ return Verdict(
447
+ cls=sig.verdict,
448
+ reason=f"signature:{sig.name}",
449
+ matched=sig.name,
450
+ provisional=sig.provisional,
451
+ detail={"pattern": pattern},
452
+ )
453
+
454
+ # Шаг 5. Признак вошедшего пользователя проверяется после
455
+ # детекторов. Его отсутствие само по себе не означает, что мы не вошли -
456
+ # оно может означать, что изменилась разметка, и это разные диагнозы.
457
+ if identity_css:
458
+ if tree is None or tree.css_first(identity_css) is None:
459
+ return Verdict(
460
+ cls=ResponseClass.UNKNOWN,
461
+ reason="identity_marker_absent",
462
+ detail={"selector": identity_css},
463
+ )
464
+ return Verdict(cls=ResponseClass.OK, reason="identity_confirmed")
465
+
466
+ return Verdict(cls=ResponseClass.OK, reason="no_signature_matched_identity_unchecked")