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/_skeleton.py ADDED
@@ -0,0 +1,752 @@
1
+ """Структурный скелет страницы.
2
+
3
+ Фикстуры нужны, чтобы чинить парсер и ловить изменения разметки. Сохранять для
4
+ этого настоящий HTML со страницы под авторизацией нельзя: там имена покупателей,
5
+ тексты переписки, номера заказов. Редактирование такого HTML - подход, при котором
6
+ вопрос «достаточно ли сработала редакция» остаётся открытым навсегда, а git-историю
7
+ из форков и кэшей потом не вычистить.
8
+
9
+ Скелет решает это иначе: он сохраняет всё, что нужно для селекторов, и не может
10
+ содержать персональных данных.
11
+
12
+ Слово «не может» точно для текста и значений атрибутов: там сохраняется подпись,
13
+ а не значение, и обратного преобразования не существует. С путями в ссылках
14
+ правило разное для своего хоста и для чужого.
15
+
16
+ * Структура сохраняется целиком: теги, вложенность, порядок, атрибут class.
17
+ * Каждый текстовый узел заменяется подписью ``T{длина}:{классы символов}``.
18
+ * Значение атрибута заменяется той же подписью с номером: ``T9:d#1``. Подпись
19
+ остаётся на месте - по ней видно длину и состав, - а номер добавляет ровно
20
+ одно сведение: два значения совпадают или нет. Без этого идентификатор
21
+ диалога, лежащий в ``data-id``, схлопывал пятьдесят диалогов в одну подпись.
22
+ * Строка запроса на своём хосте заменяется номером: ``/chat/?{q1}``. У диалога
23
+ путь один на всех, и весь идентификатор сидит там.
24
+ * Путь на своём хосте сохраняет форму, а сегменты с цифрами становятся
25
+ ``{n1}``, ``{n2}`` и так далее: ``/orders/12345`` превращается в
26
+ ``/orders/{n1}``. Видно, что это ссылка на заказ, и видно, тот же это заказ
27
+ или другой, - но не видно какой. Номер выдаётся по первому появлению
28
+ значения в пределах документа и между документами смысла не имеет.
29
+ * Путь на чужом хосте маскируется целиком: ``t.me/ivanpetrov`` превращается в
30
+ ``t.me/{t}``. Ссылки на чужие адреса пишут люди в переписке, и в них живёт
31
+ то, ради чего их и пишут. Прежнее правило маскировало сегмент только при
32
+ наличии цифр либо нелатинских знаков, и такое имя проходило дословно в
33
+ снимок, который лежит в открытом репозитории. Схема и имя хоста остаются:
34
+ они публичны и говорят, куда ведёт ссылка, не говоря, к кому.
35
+ * Содержимое script и style выбрасывается целиком.
36
+ * Результат остаётся разбираемым HTML: пустые элементы записываются парой
37
+ тегов, и только настоящие void-теги - одиночным. Это позволяет применять
38
+ скелет как фикстуру и проверять на нём селекторы тем же парсером, которым
39
+ разбирается настоящая страница.
40
+
41
+ Классы символов в подписи: ``d`` цифры, ``a`` латиница, ``c`` кириллица,
42
+ ``s`` пробельные, ``p`` пунктуация ASCII, ``o`` прочее.
43
+
44
+ Про нумерацию стоит знать три вещи, и все - про то, чего она НЕ делает.
45
+
46
+ Она не касается текстовых узлов. Граница проведена по различию «разметка против
47
+ содержимого»: равенство двух значений атрибута нужно проверкам, равенство двух
48
+ сообщений переписки не нужно никому, а сведением о переписке является.
49
+
50
+ Она не действует на чужих хостах. Там номер сам по себе был бы сведением о
51
+ третьем лице - «эти два сообщения ведут на один аккаунт», - а никакой проверке
52
+ он не нужен: ``t.me/{t}`` остаётся неразличимым всюду.
53
+
54
+ Она не связывает документы. Один и тот же заказ получает разные номера в двух
55
+ снимках, а разные заказы - одинаковые. Сравнивать снимки по номерам нельзя, и
56
+ это ловушка, которую вводит сам формат.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import json
62
+ import re
63
+ import unicodedata
64
+ from typing import Final
65
+ from urllib.parse import urlparse
66
+
67
+ from selectolax.parser import HTMLParser, Node
68
+
69
+ from ._host import same_host
70
+ from .errors import ValidationError
71
+ from .skeleton_format import ACCEPTED_SKELETON_FORMATS
72
+ from .skeleton_format import NUMBERED_SKELETON_FORMATS as GENERATED_NUMBERED_FORMATS
73
+ from .skeleton_format import SKELETON_FORMAT as GENERATED_SKELETON_FORMAT
74
+
75
+ __all__ = [
76
+ "skeletonize",
77
+ "text_signature",
78
+ "mask_path",
79
+ "SkeletonError",
80
+ "SKELETON_FORMAT",
81
+ "SUPPORTED_SKELETON_FORMATS",
82
+ "NUMBERED_SKELETON_FORMATS",
83
+ "DEFAULT_OWN_HOST",
84
+ ]
85
+
86
+ #: Хост площадки. Пути на нём сохраняют форму, на чужих маскируются целиком.
87
+ DEFAULT_OWN_HOST: Final[str] = "funpay.com"
88
+
89
+ #: Имя формата, записываемое в описание происхождения фикстуры.
90
+ #:
91
+ #: Версия 1 записывала пустые элементы как ``<div/>`` и потому не разбиралась
92
+ #: HTML-парсером. Версия 3 маскирует пути на чужих хостах целиком. Версия 4
93
+ #: нумерует подписи идентификаторов пути. Версия 5 распространяет нумерацию на
94
+ #: строку запроса и на значения атрибутов: без них пятьдесят диалогов страницы
95
+ #: оставались неразличимы - идентификатор диалога лежит и там, и там, но не в
96
+ #: пути.
97
+ #: Величина берётся из порождённого файла, а не пишется здесь: формат снимков -
98
+ #: общая проверочная база, и вторая реализация обязана строить такой же.
99
+ SKELETON_FORMAT: Final[str] = GENERATED_SKELETON_FORMAT
100
+
101
+ #: Версии, в которых идентификаторы различимы между собой.
102
+ #:
103
+ #: Нужны проверкам: снимок, снятый до нумерации, различимость восстановить не
104
+ #: может, и требовать её от него нечестно. Перечень объявлен спецификацией, а не
105
+ #: выведен из порядка версий - вывод по порядку сломался бы на первой же версии,
106
+ #: поднятой по другой причине.
107
+ NUMBERED_SKELETON_FORMATS: Final[frozenset[str]] = GENERATED_NUMBERED_FORMATS
108
+
109
+ #: Форматы, которые проект умеет читать.
110
+ #:
111
+ #: Перечень нужен потому, что снимок не преобразуется в v4 автоматически:
112
+ #: нумерация восстанавливает различимость только при захвате, а из уже
113
+ #: замаскированного файла исходные значения не вернуть. Файлы прежних версий
114
+ #: остаются пригодными для всего, что не зависит от различимости.
115
+ SUPPORTED_SKELETON_FORMATS: Final[frozenset[str]] = ACCEPTED_SKELETON_FORMATS
116
+
117
+ #: Атрибуты, значение которых обрабатывается как путь.
118
+ #:
119
+ #: data-href попал сюда не для красоты: ссылка на пользователя в списке заказов
120
+ #: лежит именно в нём, а не в href, и без разбора как пути она превращалась в
121
+ #: безликую подпись. Форма ``/users/{n}/`` говорит, куда ведёт ссылка, и не
122
+ #: говорит, на кого.
123
+ _URL_ATTRS: Final[frozenset[str]] = frozenset(
124
+ {"href", "src", "action", "formaction", "data-url", "data-href"}
125
+ )
126
+
127
+ #: Атрибуты, значение которых сохраняется как есть.
128
+ _VERBATIM_ATTRS: Final[frozenset[str]] = frozenset({"class"})
129
+
130
+ #: Атрибут имени поля формы. Сохраняется дословно, но только по форме.
131
+ #:
132
+ #: ЗАЧЕМ. Имя поля - протокольная константа: по нему собирается запрос, и без
133
+ #: него форма нечитаема как договор. Снимок формы правки лота дал двадцать
134
+ #: полей, и все двадцать имён вышли подписями вида T19:ap - то есть форма
135
+ #: снята, а собрать по ней запрос нельзя.
136
+ #:
137
+ #: Узнать имена иначе можно было только СОХРАНИВ форму и записав запрос, то
138
+ #: есть настоящей записью на площадке. Ради имён полей менять чужой лот - цена,
139
+ #: которой платить не надо.
140
+ #:
141
+ #: ЧЕМ РИСКУЕМ. Имя выбирает площадка, а не человек: это csrf_token, price,
142
+ #: fields[12345]. Чужого текста там не бывает - но проверяется это ФОРМОЙ, а не
143
+ #: доверием: дословно сохраняется только то, что похоже на имя поля.
144
+ #:
145
+ #: Всё, что содержит пробел, кириллицу или иной знак, маскируется по общему
146
+ #: правилу. Значение поля НЕ раскрывается никогда: в нём лежат цена, описание
147
+ #: лота и сообщение покупателю.
148
+ #:
149
+ #: ТИП ПОЛЯ здесь же и по той же причине. Без него флажок неотличим от обычного
150
+ #: поля: снимок формы правки лота дал type подписью T8:a, и разбор считал
151
+ #: флажки текстовыми полями - то есть признак включённости лота, ради которого
152
+ #: форму и снимали, со снимка не читался вовсе.
153
+ #:
154
+ #: Значения типа перечислены самим HTML: checkbox, hidden, text, submit. Чужого
155
+ #: текста там не бывает, и проверяется это той же формой имени.
156
+ _NAME_ATTRS: Final[frozenset[str]] = frozenset({"name", "type"})
157
+
158
+ #: Форма имени поля берётся ГОТОВАЯ - _FIELD_NAME ниже. Второго описания одного
159
+ #: правила заводить нельзя: копия расходится, и в этом же файле такое уже
160
+ #: случалось с именем метода в адресе.
161
+
162
+ #: Атрибуты, несущие метку языка.
163
+ #:
164
+ #: Сохраняются дословно, но только если значение вправду похоже на метку.
165
+ #: Метка языка говорит о странице, а не о человеке: «ru» не сообщает о продавце
166
+ #: ничего, чего не сообщал бы адрес площадки.
167
+ #:
168
+ #: Пока они маскировались, всякий снимок нёс в lang подпись, и разбор объявлял
169
+ #: по нему локаль неподдержанной - собственные фикстуры заставляли реализацию
170
+ #: говорить неправду.
171
+ _LANGUAGE_ATTRS: Final[frozenset[str]] = frozenset({"lang", "hreflang"})
172
+
173
+ #: Как выглядит метка языка по BCP 47.
174
+ _LANGUAGE_TAG: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$")
175
+
176
+ #: Как выглядит ИМЯ ПОЛЯ - ключ объекта, который можно сохранить дословно.
177
+ #:
178
+ #: Ключом бывает и то, что написал человек, - в словаре, собранном из данных, -
179
+ #: и один такой ключ отменяет правило для всего атрибута. Проверка та же, что у
180
+ #: сборщика наблюдений: там на этом уже обожглись, записав вместе с ключами
181
+ #: настоящие суммы операций.
182
+ _FIELD_NAME: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\[\]-]{0,64}$")
183
+
184
+ #: Теги, содержимое которых не сохраняется.
185
+ #:
186
+ #: Выбрасывается только содержимое, атрибуты сохраняются по общим правилам. Путь
187
+ #: в src обезличивается тем же преобразованием, что и остальные ссылки, поэтому
188
+ #: прятать его незачем, а знать, какие скрипты грузит страница, нужно: по их
189
+ #: путям обычно и виден адрес канала обновлений.
190
+ _OPAQUE_TAGS: Final[frozenset[str]] = frozenset({"script", "style", "noscript", "template"})
191
+
192
+ #: Теги, которые в HTML не имеют закрывающей пары.
193
+ #:
194
+ #: Список нужен затем, что запись ``<div/>`` в HTML не означает пустой элемент:
195
+ #: разбор откроет div и вложит в него весь остаток документа. Для script это ещё
196
+ #: хуже - его содержимое считается сырым текстом до закрывающего тега, которого
197
+ #: нет, и в дерево не попадает вообще ничего. Скелет обязан читаться тем же
198
+ #: парсером, что и страница, иначе он не годится ни в фикстуры, ни в проверку.
199
+ _VOID_TAGS: Final[frozenset[str]] = frozenset(
200
+ {
201
+ "area",
202
+ "base",
203
+ "br",
204
+ "col",
205
+ "embed",
206
+ "hr",
207
+ "img",
208
+ "input",
209
+ "link",
210
+ "meta",
211
+ "param",
212
+ "source",
213
+ "track",
214
+ "wbr",
215
+ }
216
+ )
217
+
218
+ #: Строгая форма подписи текстового узла.
219
+ #:
220
+ #: Проверка по этому выражению, а не по «начинается с T и содержит двоеточие»:
221
+ #: строка вида ``Total: 500`` подходит под нестрогое условие и прошла бы
222
+ #: самопроверку как подпись, унеся с собой настоящий текст страницы.
223
+ _SIGNATURE_RE: Final[re.Pattern[str]] = re.compile(r"^T[1-9][0-9]*:[dacspo]+$")
224
+
225
+ #: Подпись сегмента пути, содержащего цифры.
226
+ _SEG_NUM: Final[str] = "{n}"
227
+
228
+ #: Подпись сегмента пути, содержащего символы вне латиницы.
229
+ _SEG_TEXT: Final[str] = "{t}"
230
+
231
+ _RE_LATIN = re.compile(r"[A-Za-z]")
232
+ _RE_DIGIT = re.compile(r"[0-9]")
233
+
234
+ #: Признак того, что сегмент пути - идентификатор, а не слово маршрута.
235
+ #:
236
+ #: Цифра либо ЗАГЛАВНАЯ буква. Второе заведено версией v6: идентификаторы
237
+ #: площадки - восемь заглавных латинских букв без единой цифры, и первый же
238
+ #: снимок страницы отдельного заказа принёс /orders/SBVZKXAF/ дословно.
239
+ #:
240
+ #: Граница проведена по регистру потому, что слова маршрута пишутся строчными по
241
+ #: соглашению об адресах. Ошибаться правило будет в сторону лишней маскировки, и
242
+ #: это верная сторона.
243
+ _RE_OPAQUE = re.compile(r"[0-9A-Z]")
244
+
245
+ #: Имя метода в адресе: начинается со строчной, состоит из одних латинских
246
+ #: букв, заглавные допускаются ВНУТРИ.
247
+ #:
248
+ #: РАСХОЖДЕНИЕ ДВУХ РЕАЛИЗАЦИЙ ОДНОГО ПРАВИЛА, и оно дорого обошлось. Браузерный
249
+ #: сборщик получил это исключение 24.08.2026 - без него адрес загрузки файла
250
+ #: /file/<имя метода> маскировался целиком, и операцию по такой записи собрать
251
+ #: было нельзя. В Python правило не перенесли.
252
+ #:
253
+ #: Цена расхождения: снимок формы правки лота отдал адрес сохранения как
254
+ #: /lots/{n12}. Имя метода - единственное, ради чего форму и снимали, - было
255
+ #: скрыто, и добыть его предлагалось настоящим сохранением лота, то есть
256
+ #: записью на площадке.
257
+ #:
258
+ #: Расширение доказуемо узкое. Сегмент из одних строчных букв правило пропускало
259
+ #: и раньше: /orders/trade писался дословно. Меняется ровно одно - заглавная
260
+ #: ВНУТРИ сегмента, который начинается со строчной и не имеет цифр. Ни один
261
+ #: наблюдённый идентификатор площадки такой формы не имеет: восемь цифр у
262
+ #: человека, девять у диалога, восемь ЗАГЛАВНЫХ у номера заказа - последний
263
+ #: начинается с заглавной и остаётся замаскированным.
264
+ _RE_ROUTE_NAME = re.compile(r"^[a-z][A-Za-z]{0,30}$")
265
+
266
+
267
+ class SkeletonError(ValidationError):
268
+ """Скелет не удалось построить или он не прошёл самопроверку.
269
+
270
+ Наследуется от ValidationError, а не от встроенной ошибки языка. Прежде
271
+ здесь стоял RuntimeError, при том что класс экспортирован публично и
272
+ возникает у всякого, кто зовёт skeletonize: общий перехват FunoraError его
273
+ не ловил, и самопроверка, спасающая от утечки настоящего текста в снимок,
274
+ роняла процесс целиком вместо того, чтобы отдать отказ.
275
+
276
+ ValidationError - потому что произведённое значение непригодно: скелет,
277
+ не прошедший самопроверку, публиковать нельзя.
278
+ """
279
+
280
+
281
+ def _char_class(ch: str) -> str:
282
+ """Определяет класс одного символа.
283
+
284
+ Args:
285
+ ch (str): Символ.
286
+
287
+ Returns:
288
+ str: Односимвольный код класса: d, a, c, s, p или o.
289
+ """
290
+ if ch.isspace():
291
+ return "s"
292
+ if ch.isdigit():
293
+ return "d"
294
+ if "A" <= ch <= "Z" or "a" <= ch <= "z":
295
+ return "a"
296
+ if "Ѐ" <= ch <= "ӿ":
297
+ return "c"
298
+ if ch.isascii() and not ch.isalnum():
299
+ return "p"
300
+ return "o"
301
+
302
+
303
+ def text_signature(text: str) -> str:
304
+ """Строит подпись текстового узла.
305
+
306
+ Подпись сохраняет длину и состав текста, но не сам текст. Этого достаточно,
307
+ чтобы заметить изменение разметки и написать селектор, и недостаточно, чтобы
308
+ восстановить содержимое.
309
+
310
+ Args:
311
+ text (str): Исходный текст узла.
312
+
313
+ Returns:
314
+ str: Подпись вида ``T14:dps`` или пустая строка, если текст состоит
315
+ только из пробельных символов.
316
+ """
317
+ stripped = text.strip()
318
+ if not stripped:
319
+ return ""
320
+ normalized = unicodedata.normalize("NFC", stripped)
321
+ classes = "".join(sorted({_char_class(c) for c in normalized}))
322
+ return f"T{len(normalized)}:{classes}"
323
+
324
+
325
+ def mask_path(
326
+ value: str,
327
+ own_host: str = DEFAULT_OWN_HOST,
328
+ ordinals: dict[str, int] | None = None,
329
+ ) -> str:
330
+ """Обезличивает значение атрибута, содержащего путь.
331
+
332
+ Правило разное для своего хоста и для чужого, и разница не в осторожности.
333
+
334
+ На своём хосте форма пути сохраняется: по ней узнаётся назначение ссылки,
335
+ а сегменты состоят из служебных слов площадки. Идентификатор заменяется
336
+ подписью, и признак его - цифра либо заглавная буква: идентификаторы
337
+ площадки бывают и вовсе без цифр, восемью заглавными латинскими буквами.
338
+
339
+ На чужом хосте маскируется весь путь. Ссылки на чужие адреса пишут люди в
340
+ переписке, и в них живёт то, ради чего их и пишут: ``t.me/ivanpetrov``,
341
+ ``vk.com/durov``. Прежнее правило маскировало сегмент, только если в нём
342
+ были цифры либо нелатинские знаки, - и такое имя проходило дословно в
343
+ снимок, который лежит в открытом репозитории. Имя хоста при этом остаётся:
344
+ оно публично и говорит, куда ведёт ссылка, не говоря, к кому.
345
+
346
+ Подпись идентификатора нумеруется в пределах документа: одинаковые значения
347
+ получают один номер, разные - разные. Без номеров восемь заказов на странице
348
+ неразличимы, и всякая проверка, опирающаяся на различимость, проходит
349
+ впустую, выглядя пройденной. Номер не раскрывает значения - он говорит
350
+ только, совпадают ли два.
351
+
352
+ Нумерация действует внутри одного документа и между документами смысла не
353
+ имеет: ``{n1}`` в двух снимках - разные вещи.
354
+
355
+ Args:
356
+ value (str): Исходное значение атрибута.
357
+ own_host (str): Хост площадки. Пути на нём сохраняют форму.
358
+ ordinals (dict[str, int] | None): Таблица номеров документа, общая для
359
+ всех его путей. None означает разовую маскировку вне документа: там
360
+ нумеровать не с чем и подпись остаётся безномерной.
361
+
362
+ Returns:
363
+ str: Обезличенный путь.
364
+ """
365
+ if not value:
366
+ return value
367
+
368
+ body = value
369
+ raw_query = ""
370
+ if "?" in body:
371
+ body, _, raw_query = body.partition("?")
372
+
373
+ host = urlparse(body).hostname
374
+ foreign = host is not None and not same_host(body, own_host)
375
+
376
+ query = ""
377
+ if raw_query:
378
+ # На чужом хосте строка запроса не разбирается и не нумеруется по той
379
+ # же причине, что и путь: там совпадение само по себе сведение о
380
+ # третьем лице. На своём разбирается по параметрам: имя выбирает
381
+ # площадка, значение - человек.
382
+ query = "?{q}" if foreign else "?" + _mask_query(raw_query, ordinals)
383
+
384
+ parts = body.split("/")
385
+ out: list[str] = []
386
+ for index, part in enumerate(parts):
387
+ if not part:
388
+ out.append(part)
389
+ continue
390
+ # Схема и имя хоста сохраняются всегда: они публичны и говорят, куда
391
+ # ведёт ссылка, не говоря, к кому. Схема нужна отдельно - по ней видно,
392
+ # защищено ли соединение.
393
+ if index == 0 and part.endswith(":"):
394
+ out.append(part)
395
+ continue
396
+ if foreign and host is not None and part.lower().split(":")[0] == host:
397
+ out.append(part)
398
+ continue
399
+ if foreign:
400
+ out.append(_SEG_TEXT)
401
+ continue
402
+ if _RE_ROUTE_NAME.match(part):
403
+ # Имя метода, а не идентификатор. То же исключение, что у браузерного
404
+ # сборщика: два описания одного правила обязаны совпадать, иначе
405
+ # снимок страницы и запись запроса говорят о площадке разное.
406
+ out.append(part)
407
+ elif _RE_OPAQUE.search(part):
408
+ out.append(_numbered(part, ordinals))
409
+ elif not part.isascii():
410
+ out.append(_SEG_TEXT)
411
+ else:
412
+ out.append(part)
413
+ return "/".join(out) + query
414
+
415
+
416
+ def _mask_query(raw: str, ordinals: dict[str, int] | None) -> str:
417
+ """Обезличивает строку запроса, сохраняя ИМЕНА параметров.
418
+
419
+ ИМЯ ВЫБИРАЕТ ПЛОЩАДКА, ЗНАЧЕНИЕ - ЧЕЛОВЕК. Тот же довод, по которому
420
+ дословно сохраняются имена полей формы и имена методов в пути: имя - это
421
+ словарь площадки, а не чьи-то данные.
422
+
423
+ Цена прежнего правила названа в spec/extraction/market.yaml: идентификатор
424
+ ЧУЖОГО предложения лежит только в строке запроса ссылки, и снимок отдавал
425
+ её одной подписью «?{q7}». Из-за этого market.offers - объявленная
426
+ операция - не собиралась вовсе, а причиной называли модель.
427
+
428
+ Собирающий заново описывает то же правило, что браузерный сборщик, у
429
+ которого имена параметров сохранялись с самого начала: там записан
430
+ searchParams.keys(). Два описания одного правила разошлись, и разошлись
431
+ молча - ровно так же, как перед этим разошлось имя метода в пути.
432
+
433
+ ЗНАЧЕНИЕ НЕ РАСКРЫВАЕТСЯ НИКОГДА и по-прежнему нумеруется: одинаковые
434
+ значения получают один номер, и без этого пятьдесят диалогов страницы
435
+ неразличимы.
436
+
437
+ Аргументы:
438
+ raw (str): Строка запроса без ведущего знака вопроса.
439
+ ordinals (dict[str, int] | None): Таблица номеров документа.
440
+
441
+ Возвращает:
442
+ str: Обезличенная строка запроса.
443
+ """
444
+ out: list[str] = []
445
+ for pair in raw.split("&"):
446
+ name, sign, value = pair.partition("=")
447
+ if not _FIELD_NAME.match(name):
448
+ # Имя незнакомой формы маскируется целиком вместе со значением:
449
+ # раз оно не похоже на словарь площадки, оно может быть данными.
450
+ number = _ordinal(pair, "query", ordinals)
451
+ out.append("{q}" if number is None else f"{{q{number}}}")
452
+ continue
453
+ if not sign:
454
+ # Параметр без значения - одно только имя. Раскрывать нечего.
455
+ out.append(name)
456
+ continue
457
+ number = _ordinal(value, "query", ordinals)
458
+ out.append(f"{name}=" + ("{q}" if number is None else f"{{q{number}}}"))
459
+ return "&".join(out)
460
+
461
+
462
+ def _ordinal(value: str, kind: str, ordinals: dict[str, int] | None) -> int | None:
463
+ """Выдаёт номер значения в пределах документа.
464
+
465
+ Номер выдаётся по первому появлению и повторно не выдаётся: два вхождения
466
+ одного значения получают один номер, два разных - разные. Само значение
467
+ никуда не попадает: таблица живёт в памяти и выбрасывается вместе с
468
+ документом.
469
+
470
+ Разряды нумеруются раздельно. Совпадение номера пути с номером атрибута
471
+ ничего не значило бы и только вводило бы в заблуждение: путь и подпись -
472
+ разные пространства значений.
473
+
474
+ Args:
475
+ value (str): Исходное значение.
476
+ kind (str): Разряд нумерации: путь, строка запроса, атрибут.
477
+ ordinals (dict[str, int] | None): Таблица номеров документа либо None,
478
+ если маскируется отдельное значение вне документа.
479
+
480
+ Returns:
481
+ int | None: Номер либо None, если нумеровать не с чем.
482
+ """
483
+ if ordinals is None:
484
+ return None
485
+ key = f"{kind}\x1f{value}"
486
+ if key not in ordinals:
487
+ # Номер отсчитывается внутри разряда, а не сквозным счётчиком: сквозной
488
+ # выдал бы заказу номер 7 только потому, что до него встретилось шесть
489
+ # чужих значений, и перестановка разметки меняла бы все номера сразу.
490
+ ordinals[key] = sum(1 for name in ordinals if name.startswith(f"{kind}\x1f")) + 1
491
+ return ordinals[key]
492
+
493
+
494
+ def _numbered(part: str, ordinals: dict[str, int] | None) -> str:
495
+ """Возвращает подпись сегмента пути с номером в пределах документа.
496
+
497
+ Args:
498
+ part (str): Исходный сегмент пути.
499
+ ordinals (dict[str, int] | None): Таблица номеров документа.
500
+
501
+ Returns:
502
+ str: Подпись вида ``{n3}`` либо безномерная ``{n}``.
503
+ """
504
+ number = _ordinal(part, "path", ordinals)
505
+ return _SEG_NUM if number is None else f"{{n{number}}}"
506
+
507
+
508
+ def _keys_look_like_field_names(value: object) -> bool:
509
+ """Сообщает, состоят ли ключи объекта из одних имён полей.
510
+
511
+ Проверяются ключи ВСЕХ уровней вложенности. Проверка одного уровня
512
+ пропустила бы словарь внутри словаря - а туда и складывают то, что пришло из
513
+ данных.
514
+
515
+ Args:
516
+ value (object): Разобранное значение JSON.
517
+
518
+ Returns:
519
+ bool: True, если ни один ключ на именование поля не притворяется.
520
+ """
521
+ if isinstance(value, dict):
522
+ return all(
523
+ isinstance(key, str)
524
+ and _FIELD_NAME.match(key) is not None
525
+ and _keys_look_like_field_names(item)
526
+ for key, item in value.items()
527
+ )
528
+ if isinstance(value, list):
529
+ return all(_keys_look_like_field_names(one) for one in value)
530
+ return True
531
+
532
+
533
+ def _mask_json(value: object, ordinals: dict[str, int]) -> object:
534
+ """Маскирует значения объекта, сохраняя его ключи.
535
+
536
+ Дословно не сохраняется ни одно значение: правило про ключи, и только про
537
+ них. Читаемым становится ПУТЬ до значения, а не само значение.
538
+
539
+ Args:
540
+ value (object): Разобранное значение JSON.
541
+ ordinals (dict[str, int]): Таблица номеров документа.
542
+
543
+ Returns:
544
+ object: То же по строению, с замаскированными значениями.
545
+ """
546
+ if isinstance(value, dict):
547
+ return {key: _mask_json(item, ordinals) for key, item in value.items()}
548
+ if isinstance(value, list):
549
+ return [_mask_json(one, ordinals) for one in value]
550
+ if isinstance(value, bool) or value is None:
551
+ # Булево и пустота значениями в смысле утечки не являются: они не несут
552
+ # ни имени, ни суммы, ни текста.
553
+ return value
554
+ # Число маскируется подписью СВОЕЙ ЗАПИСИ, а не остаётся числом:
555
+ # восьмизначное число - это идентификатор человека, а не количество.
556
+ return text_signature(str(value))
557
+
558
+
559
+ def _mask_attr(name: str, value: str, ordinals: dict[str, int]) -> str:
560
+ """Обезличивает значение произвольного атрибута.
561
+
562
+ Args:
563
+ name (str): Имя атрибута в нижнем регистре.
564
+ value (str): Исходное значение.
565
+ ordinals (dict[str, int]): Таблица номеров документа.
566
+
567
+ Returns:
568
+ str: Значение, пригодное для хранения в репозитории.
569
+ """
570
+ if name in _VERBATIM_ATTRS:
571
+ return value
572
+ if name in _NAME_ATTRS and _FIELD_NAME.match(value.strip()):
573
+ # Условие про форму существенно ровно так же, как у метки языка:
574
+ # атрибут обычный, и положить в него можно что угодно.
575
+ return value.strip()
576
+ if name in _LANGUAGE_ATTRS and _LANGUAGE_TAG.match(value.strip()):
577
+ # Условие про форму существенно: атрибут обычный, и положить в него
578
+ # можно что угодно. Дословно сохраняется лишь то, что вправду похоже на
579
+ # метку языка, остальное маскируется по общему правилу.
580
+ return value.strip()
581
+ if name in _URL_ATTRS:
582
+ return mask_path(value, DEFAULT_OWN_HOST, ordinals)
583
+
584
+ # Атрибут-объект сохраняет КЛЮЧИ и маскирует значения - правило формата v8.
585
+ #
586
+ # Заведено ради одного места, и место это перекрывает семь операций: защитный
587
+ # токен площадки лежит в объекте JSON атрибута data-app-data. Пока атрибут
588
+ # маскировался целиком, разбор, достающий оттуда токен, проверить было не на
589
+ # чем.
590
+ #
591
+ # Правило узкое по построению: значение обязано быть объектом, и каждый его
592
+ # ключ - иметь вид имени поля. Один ключ, пришедший из данных, отменяет
593
+ # правило для всего атрибута.
594
+ #
595
+ # Проверка на фигурную скобку - про ЦЕНУ, а не про правильность: отсечь
596
+ # лишнее и без неё способна проверка типа разобранного. Она измерена, и
597
+ # потому осталась: на корне площадки одиннадцать с половиной тысяч значений
598
+ # атрибутов, из которых с фигурной скобки начинаются сорок семь. Разбор
599
+ # всех подряд обходится в девятнадцать раз дороже.
600
+ #
601
+ # Отсюда следствие для мутационной проверки: мутация, снимающая эту строку,
602
+ # ВЫЖИВАЕТ, и это верно. Она меняет цену, а не поведение, и ловить её
603
+ # набором проверок было бы ловлей часов - измерение времени в наборе даёт
604
+ # ложные срабатывания чаще, чем находит правду.
605
+ stripped = value.strip()
606
+ if stripped.startswith("{"):
607
+ try:
608
+ parsed = json.loads(stripped)
609
+ except ValueError:
610
+ parsed = None
611
+ if isinstance(parsed, dict) and _keys_look_like_field_names(parsed):
612
+ return json.dumps(_mask_json(parsed, ordinals), ensure_ascii=False, sort_keys=True)
613
+ if not value:
614
+ return value
615
+ sig = text_signature(value)
616
+ if not sig:
617
+ return ""
618
+ # К подписи атрибута добавляется номер. Подпись остаётся на месте: по ней
619
+ # видно длину и состав, и терять их нельзя - именно на длинах строились
620
+ # выводы о позициях сообщений. Номер добавляет ровно одно сведение: два
621
+ # значения совпадают или нет.
622
+ #
623
+ # На текстовые узлы это не распространяется. Атрибут - разметка, текст -
624
+ # содержимое; равенство двух сообщений переписки никакой проверке не нужно,
625
+ # а сведением о переписке является.
626
+ number = _ordinal(value, f"attr:{name}", ordinals)
627
+ return sig if number is None else f"{sig}#{number}"
628
+
629
+
630
+ def _render(node: Node, out: list[str], depth: int, ordinals: dict[str, int]) -> None:
631
+ """Рекурсивно записывает узел в выходной буфер.
632
+
633
+ Args:
634
+ node (Node): Узел документа.
635
+ out (list[str]): Буфер строк результата.
636
+ depth (int): Текущая глубина вложенности, для отступов.
637
+ ordinals (dict[str, int]): Таблица номеров документа. Общая на весь
638
+ обход: номер выдаётся по первому появлению значения, и порядок
639
+ обхода делает нумерацию повторяемой.
640
+
641
+ Returns:
642
+ None: Результат накапливается в out.
643
+ """
644
+ tag = node.tag
645
+ pad = " " * depth
646
+
647
+ if tag == "-text":
648
+ sig = text_signature(node.text_content or "")
649
+ if sig:
650
+ out.append(f"{pad}{sig}")
651
+ return
652
+
653
+ if tag in ("-comment", "_comment"):
654
+ return
655
+
656
+ attrs = node.attributes or {}
657
+ rendered: list[str] = []
658
+ for name in sorted(attrs):
659
+ raw = attrs[name]
660
+ if raw is None:
661
+ rendered.append(name)
662
+ continue
663
+ masked = _mask_attr(name.lower(), raw, ordinals)
664
+ # Кавычка экранируется, иначе значение рвёт собственный атрибут.
665
+ #
666
+ # До формата v8 это не имело значения: маскированное значение кавычек не
667
+ # содержало никогда. Атрибут-объект их содержит, и неэкранированный он
668
+ # сделал бы снимок неразбираемым - то есть бесполезным ровно для того,
669
+ # ради чего заводился.
670
+ rendered.append(f'{name}="{masked.replace(chr(34), "&quot;")}"')
671
+
672
+ head = tag if not rendered else tag + " " + " ".join(rendered)
673
+
674
+ if tag in _OPAQUE_TAGS:
675
+ out.append(f"{pad}<{head}></{tag}>")
676
+ return
677
+
678
+ children = list(node.iter(include_text=True))
679
+ if not children:
680
+ if tag in _VOID_TAGS:
681
+ out.append(f"{pad}<{head}/>")
682
+ else:
683
+ out.append(f"{pad}<{head}></{tag}>")
684
+ return
685
+
686
+ out.append(f"{pad}<{head}>")
687
+ for child in children:
688
+ _render(child, out, depth + 1, ordinals)
689
+ out.append(f"{pad}</{tag}>")
690
+
691
+
692
+ def _self_check(skeleton: str) -> None:
693
+ """Проверяет, что в скелете не осталось человекочитаемого текста.
694
+
695
+ Самопроверка нужна, потому что скелет строится обходом дерева, а дерево
696
+ приходит от стороннего парсера: изменение его поведения не должно тихо
697
+ превратить безопасный формат в небезопасный.
698
+
699
+ Args:
700
+ skeleton (str): Готовый скелет.
701
+
702
+ Raises:
703
+ SkeletonError: Если найдена последовательность, похожая на текст.
704
+ """
705
+ for line in skeleton.splitlines():
706
+ body = line.strip()
707
+ if not body:
708
+ # Пустая строка появляется у сохранённого снимка в конце файла.
709
+ # Без этого условия готовую фикстуру нельзя перепроверить тем же
710
+ # кодом, которым она построена, - а перепроверять её придётся при
711
+ # каждой смене формата.
712
+ continue
713
+ if _SIGNATURE_RE.match(body):
714
+ continue
715
+ if not body.startswith("<"):
716
+ raise SkeletonError(f"строка не является ни подписью, ни тегом: {body[:60]}")
717
+ if any("Ѐ" <= ch <= "ӿ" for ch in body):
718
+ raise SkeletonError(f"кириллица в структурной строке: {body[:60]}")
719
+
720
+
721
+ def skeletonize(html: str) -> str:
722
+ """Строит структурный скелет страницы.
723
+
724
+ Args:
725
+ html (str): Исходный HTML страницы.
726
+
727
+ Returns:
728
+ str: Скелет: дерево тегов с подписями текстовых узлов вместо текста.
729
+
730
+ Raises:
731
+ SkeletonError: Если разбор не удался или самопроверка нашла текст.
732
+ """
733
+ if not html or not html.strip():
734
+ raise SkeletonError("пустой документ")
735
+
736
+ try:
737
+ tree = HTMLParser(html)
738
+ except Exception as exc:
739
+ raise SkeletonError("документ не разбирается как HTML") from exc
740
+
741
+ root = tree.root
742
+ if root is None:
743
+ raise SkeletonError("документ не содержит корневого узла")
744
+
745
+ out: list[str] = []
746
+ # Таблица номеров создаётся на документ и живёт только во время обхода.
747
+ # Значений она не сохраняет никуда: нумерация нужна затем, чтобы по снимку
748
+ # было видно, какие ссылки совпадают, а какие нет.
749
+ _render(root, out, 0, {})
750
+ skeleton = "\n".join(out) + "\n"
751
+ _self_check(skeleton)
752
+ return skeleton