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/_signals.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Сравнение изменяемых значений страницы между двумя чтениями.
|
|
2
|
+
|
|
3
|
+
Зачем понадобился отдельный модуль. Структурный скелет намеренно прячет значения
|
|
4
|
+
атрибутов: это то, что делает его безопасным. Но ровно из-за этого по скелетам
|
|
5
|
+
нельзя ответить на главный вопрос спецификации - отдаёт ли площадка устойчивую
|
|
6
|
+
позицию, по которой можно переиграть пропущенные события. Монотонный счётчик и
|
|
7
|
+
хеш состояния в скелете выглядят одинаково, потому что различает их не форма, а
|
|
8
|
+
поведение во времени.
|
|
9
|
+
|
|
10
|
+
Как это решено. Значения извлекаются, сравниваются в памяти и уничтожаются
|
|
11
|
+
вместе с процессом. На диск не попадает ничего. Наружу выходит только характер
|
|
12
|
+
изменения - выросло, уменьшилось, изменилось, не изменилось - и величина шага
|
|
13
|
+
для числовых значений. Гарантия скелета при этом не ослабляется, потому что сам
|
|
14
|
+
формат скелета не меняется.
|
|
15
|
+
|
|
16
|
+
Белый список атрибутов задан явно. Смысл списка не в защите - защита в том, что
|
|
17
|
+
значения не сохраняются, - а в том, чтобы сравнение не превращалось в обход всей
|
|
18
|
+
страницы: сравнивать нужно кандидатов в позицию канала обновлений, а не разметку.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import StrEnum
|
|
25
|
+
from hashlib import blake2s
|
|
26
|
+
from typing import Final
|
|
27
|
+
|
|
28
|
+
from selectolax.parser import HTMLParser, Node
|
|
29
|
+
|
|
30
|
+
from .extraction import ATTRIBUTES, SELECTORS
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"BLANK",
|
|
34
|
+
"ATTR_ALLOWLIST",
|
|
35
|
+
"Change",
|
|
36
|
+
"ChangeKind",
|
|
37
|
+
"collect",
|
|
38
|
+
"compare",
|
|
39
|
+
"format_report",
|
|
40
|
+
"Relations",
|
|
41
|
+
"relations",
|
|
42
|
+
"format_relations",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
#: Атрибуты, значения которых сравниваются между чтениями.
|
|
46
|
+
#:
|
|
47
|
+
#: Список выведен из снимков от 18.08.2026 и обоснован в docs/observations.md.
|
|
48
|
+
#: Это кандидаты в позицию канала обновлений и идентификаторы, по которым
|
|
49
|
+
#: элементы опознаются между чтениями.
|
|
50
|
+
ATTR_ALLOWLIST: Final[frozenset[str]] = frozenset(
|
|
51
|
+
{
|
|
52
|
+
"data-app-data",
|
|
53
|
+
"data-chat",
|
|
54
|
+
"data-orders",
|
|
55
|
+
"data-bookmarks-tag",
|
|
56
|
+
"data-user",
|
|
57
|
+
}
|
|
58
|
+
# Имена атрибутов списка диалогов берутся из объявления, а не повторяются
|
|
59
|
+
# здесь: повторённое имя расходится с объявленным молча, и признак
|
|
60
|
+
# непрочитанного перестал бы выводиться на первой же смене разметки.
|
|
61
|
+
| {
|
|
62
|
+
ATTRIBUTES["chats.contact_list.attributes.node_id"],
|
|
63
|
+
ATTRIBUTES["chats.contact_list.attributes.last_message_position"],
|
|
64
|
+
ATTRIBUTES["chats.contact_list.attributes.own_position"],
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
#: Длина короткого отпечатка, которым элемент опознаётся в отчёте.
|
|
69
|
+
_FINGERPRINT_LEN: Final[int] = 6
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ChangeKind(StrEnum):
|
|
73
|
+
"""Характер изменения значения между двумя чтениями."""
|
|
74
|
+
|
|
75
|
+
UNCHANGED = "не изменилось"
|
|
76
|
+
GREW = "выросло"
|
|
77
|
+
SHRANK = "уменьшилось"
|
|
78
|
+
CHANGED = "изменилось"
|
|
79
|
+
APPEARED = "появилось"
|
|
80
|
+
DISAPPEARED = "исчезло"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class Change:
|
|
85
|
+
"""Изменение одного значения.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
key (str): Обезличенный ключ элемента: тег, класс и отпечаток.
|
|
89
|
+
attr (str): Имя атрибута.
|
|
90
|
+
kind (ChangeKind): Характер изменения.
|
|
91
|
+
delta (int | None): Величина шага для числовых значений. Для нечисловых
|
|
92
|
+
и для отсутствующих значений равна None.
|
|
93
|
+
numeric (bool): Было ли значение полностью числовым.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
key: str
|
|
97
|
+
attr: str
|
|
98
|
+
kind: ChangeKind
|
|
99
|
+
delta: int | None = None
|
|
100
|
+
numeric: bool = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _fingerprint(value: str) -> str:
|
|
104
|
+
"""Строит короткий необратимый отпечаток значения.
|
|
105
|
+
|
|
106
|
+
Отпечаток нужен, чтобы опознать один и тот же элемент в двух чтениях после
|
|
107
|
+
того, как список переупорядочился. Позиция в списке для этого не годится:
|
|
108
|
+
пришедшее сообщение поднимает диалог наверх, и сравнение по индексу
|
|
109
|
+
сопоставило бы разные диалоги.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
value (str): Исходное значение.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
str: Шестнадцатеричный отпечаток фиксированной длины.
|
|
116
|
+
"""
|
|
117
|
+
return blake2s(value.encode("utf-8"), digest_size=8).hexdigest()[:_FINGERPRINT_LEN]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _element_key(node: Node, index: int) -> str:
|
|
121
|
+
"""Строит обезличенный ключ элемента.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
node (Node): Узел документа.
|
|
125
|
+
index (int): Порядковый номер элемента среди однотипных. Используется
|
|
126
|
+
только если у элемента нет собственного идентификатора.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
str: Ключ вида ``a.contact-item#1f4c9e``, пригодный для отчёта.
|
|
130
|
+
"""
|
|
131
|
+
attrs = node.attributes or {}
|
|
132
|
+
tag = node.tag or "?"
|
|
133
|
+
classes = (attrs.get("class") or "").split()
|
|
134
|
+
label = tag + ("." + classes[0] if classes else "")
|
|
135
|
+
own = attrs.get(ATTRIBUTES["chats.contact_list.attributes.node_id"]) or attrs.get("id")
|
|
136
|
+
mark = _fingerprint(own) if own else "i" + str(index)
|
|
137
|
+
return label + "#" + mark
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
#: Чем обозначается атрибут без значения.
|
|
141
|
+
#:
|
|
142
|
+
#: Разборщик отдаёт None и для `data-chat`, и для `data-chat=""`. Прежде такая
|
|
143
|
+
#: пара молча выбрасывалась, и сравнение о ней не говорило ничего: атрибут,
|
|
144
|
+
#: стоящий без значения в обоих чтениях, не попадал в отчёт вовсе, а появление у
|
|
145
|
+
#: него значения выглядело как появление самого атрибута.
|
|
146
|
+
#:
|
|
147
|
+
#: Знак выбран невозможный внутри значения: разборщики HTML заменяют нулевой
|
|
148
|
+
#: байт на символ замены, так что совпасть с настоящим значением он не может.
|
|
149
|
+
BLANK: Final[str] = "\x00"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def collect(html: str, attrs: frozenset[str] = ATTR_ALLOWLIST) -> dict[tuple[str, str], str]:
|
|
153
|
+
"""Извлекает значения атрибутов из белого списка.
|
|
154
|
+
|
|
155
|
+
Результат существует только в памяти вызывающего кода. Записывать его на
|
|
156
|
+
диск нельзя: в нём настоящие значения со страницы под авторизацией.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
html (str): Тело ответа.
|
|
160
|
+
attrs (frozenset[str]): Имена атрибутов, значения которых собираются.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
dict[tuple[str, str], str]: Отображение пары «ключ элемента, атрибут» в
|
|
164
|
+
значение.
|
|
165
|
+
"""
|
|
166
|
+
tree = HTMLParser(html)
|
|
167
|
+
found: dict[tuple[str, str], str] = {}
|
|
168
|
+
counters: dict[str, int] = {}
|
|
169
|
+
|
|
170
|
+
for node in tree.css("*"):
|
|
171
|
+
node_attrs = node.attributes or {}
|
|
172
|
+
present = [name for name in node_attrs if name in attrs]
|
|
173
|
+
if not present:
|
|
174
|
+
continue
|
|
175
|
+
tag = node.tag or "?"
|
|
176
|
+
counters[tag] = counters.get(tag, 0) + 1
|
|
177
|
+
key = _element_key(node, counters[tag])
|
|
178
|
+
for name in present:
|
|
179
|
+
value = node_attrs[name]
|
|
180
|
+
# Атрибут без значения обозначается явно, а не выбрасывается:
|
|
181
|
+
# выброшенный он неотличим от отсутствующего, и сравнение назвало бы
|
|
182
|
+
# появление значения появлением атрибута.
|
|
183
|
+
found[(key, name)] = BLANK if value is None else value
|
|
184
|
+
return found
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def compare(
|
|
188
|
+
before: dict[tuple[str, str], str],
|
|
189
|
+
after: dict[tuple[str, str], str],
|
|
190
|
+
) -> list[Change]:
|
|
191
|
+
"""Сравнивает два набора значений.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
before (dict[tuple[str, str], str]): Значения первого чтения.
|
|
195
|
+
after (dict[tuple[str, str], str]): Значения второго чтения.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
list[Change]: Изменения, упорядоченные по ключу и имени атрибута.
|
|
199
|
+
"""
|
|
200
|
+
changes: list[Change] = []
|
|
201
|
+
for slot in sorted(set(before) | set(after)):
|
|
202
|
+
key, attr = slot
|
|
203
|
+
old = before.get(slot)
|
|
204
|
+
new = after.get(slot)
|
|
205
|
+
|
|
206
|
+
if old is None:
|
|
207
|
+
changes.append(Change(key=key, attr=attr, kind=ChangeKind.APPEARED))
|
|
208
|
+
continue
|
|
209
|
+
if new is None:
|
|
210
|
+
changes.append(Change(key=key, attr=attr, kind=ChangeKind.DISAPPEARED))
|
|
211
|
+
continue
|
|
212
|
+
if old == new:
|
|
213
|
+
changes.append(Change(key=key, attr=attr, kind=ChangeKind.UNCHANGED))
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
if old.isdigit() and new.isdigit():
|
|
217
|
+
delta = int(new) - int(old)
|
|
218
|
+
kind = ChangeKind.GREW if delta > 0 else ChangeKind.SHRANK
|
|
219
|
+
changes.append(Change(key=key, attr=attr, kind=kind, delta=abs(delta), numeric=True))
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
changes.append(Change(key=key, attr=attr, kind=ChangeKind.CHANGED))
|
|
223
|
+
return changes
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def format_report(changes: list[Change], *, only_changed: bool = True) -> str:
|
|
227
|
+
"""Готовит отчёт о сравнении.
|
|
228
|
+
|
|
229
|
+
В отчёте нет значений: только характер изменения и величина шага для
|
|
230
|
+
числовых. Отчёт можно показывать и прикладывать к обсуждению.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
changes (list[Change]): Изменения.
|
|
234
|
+
only_changed (bool): Показывать только изменившееся. Неизменившееся
|
|
235
|
+
сводится в итоговую строку.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
str: Готовый текст отчёта.
|
|
239
|
+
"""
|
|
240
|
+
shown = [c for c in changes if not only_changed or c.kind is not ChangeKind.UNCHANGED]
|
|
241
|
+
stable = sum(1 for c in changes if c.kind is ChangeKind.UNCHANGED)
|
|
242
|
+
|
|
243
|
+
lines: list[str] = []
|
|
244
|
+
for change in sorted(shown, key=lambda x: (x.attr, x.key)):
|
|
245
|
+
tail = "" if change.delta is None else " на " + str(change.delta)
|
|
246
|
+
lines.append(" " + change.attr.ljust(20) + change.key.ljust(26) + str(change.kind) + tail)
|
|
247
|
+
|
|
248
|
+
if not lines:
|
|
249
|
+
lines.append(" изменений нет")
|
|
250
|
+
lines.append("")
|
|
251
|
+
lines.append(" всего значений: " + str(len(changes)) + ", без изменений: " + str(stable))
|
|
252
|
+
return "\n".join(lines)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True, slots=True)
|
|
256
|
+
class Relations:
|
|
257
|
+
"""Соотношения между значениями внутри одного снимка.
|
|
258
|
+
|
|
259
|
+
Наружу выходят только количества. Ни одно значение атрибута в результат не
|
|
260
|
+
попадает, поэтому режим не ослабляет защиту, ради которой скелет прячет
|
|
261
|
+
значения.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
contacts (int): Сколько элементов диалога найдено.
|
|
265
|
+
equal (int): У скольких data-node-msg совпадает с data-user-msg.
|
|
266
|
+
differing (int): У скольких значения различаются.
|
|
267
|
+
incomplete (int): У скольких одного из атрибутов нет.
|
|
268
|
+
unread_badge (str): Состояние счётчика непрочитанного в шапке:
|
|
269
|
+
``"скрыт"``, ``"виден"`` либо ``"не найден"``.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
contacts: int
|
|
273
|
+
equal: int
|
|
274
|
+
differing: int
|
|
275
|
+
incomplete: int
|
|
276
|
+
unread_badge: str
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def relations(html: str) -> Relations:
|
|
280
|
+
"""Считает соотношения пары позиций по всем диалогам снимка.
|
|
281
|
+
|
|
282
|
+
Режим отвечает на вопрос, который сравнением двух чтений не решается.
|
|
283
|
+
data-user-msg может означать «последнее прочитанное этим аккаунтом» либо
|
|
284
|
+
«последнее написанное этим аккаунтом», и своя отправка двигает поле при
|
|
285
|
+
обеих трактовках. Но по списку целиком версии расходятся: если счётчик
|
|
286
|
+
непрочитанного пуст, а значения расходятся у многих диалогов, то поле не
|
|
287
|
+
может быть отметкой прочтения.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
html (str): Тело страницы списка диалогов.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Relations: Количества и состояние счётчика непрочитанного.
|
|
294
|
+
"""
|
|
295
|
+
tree = HTMLParser(html)
|
|
296
|
+
|
|
297
|
+
equal = differing = incomplete = 0
|
|
298
|
+
contacts = tree.css(SELECTORS["chats.contact_list.item"])
|
|
299
|
+
for node in contacts:
|
|
300
|
+
attrs = node.attributes or {}
|
|
301
|
+
node_msg = attrs.get(ATTRIBUTES["chats.contact_list.attributes.last_message_position"])
|
|
302
|
+
user_msg = attrs.get(ATTRIBUTES["chats.contact_list.attributes.own_position"])
|
|
303
|
+
if not node_msg or not user_msg:
|
|
304
|
+
incomplete += 1
|
|
305
|
+
elif node_msg == user_msg:
|
|
306
|
+
equal += 1
|
|
307
|
+
else:
|
|
308
|
+
differing += 1
|
|
309
|
+
|
|
310
|
+
badge = tree.css_first(SELECTORS["chats.unread_badge"])
|
|
311
|
+
if badge is None:
|
|
312
|
+
state = "не найден"
|
|
313
|
+
else:
|
|
314
|
+
state = "скрыт" if "hidden" in (badge.attributes.get("class") or "") else "виден"
|
|
315
|
+
|
|
316
|
+
return Relations(
|
|
317
|
+
contacts=len(contacts),
|
|
318
|
+
equal=equal,
|
|
319
|
+
differing=differing,
|
|
320
|
+
incomplete=incomplete,
|
|
321
|
+
unread_badge=state,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def format_relations(rel: Relations) -> str:
|
|
326
|
+
"""Составляет отчёт о соотношениях и о том, что из них следует.
|
|
327
|
+
|
|
328
|
+
Толкование печатается рядом с числами намеренно. Без него отчёт выглядит
|
|
329
|
+
набором цифр, а решение по нему всё равно придётся принимать, и принято оно
|
|
330
|
+
будет по памяти.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
rel (Relations): Посчитанные соотношения.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
str: Готовый к печати отчёт.
|
|
337
|
+
"""
|
|
338
|
+
lines = [
|
|
339
|
+
f" диалогов: {rel.contacts}",
|
|
340
|
+
f" позиции совпадают: {rel.equal}",
|
|
341
|
+
f" позиции различаются: {rel.differing}",
|
|
342
|
+
f" атрибут отсутствует: {rel.incomplete}",
|
|
343
|
+
f" счётчик непрочитанного: {rel.unread_badge}",
|
|
344
|
+
"",
|
|
345
|
+
]
|
|
346
|
+
|
|
347
|
+
if rel.contacts == 0:
|
|
348
|
+
lines.append(" диалогов не найдено, толковать нечего")
|
|
349
|
+
return "\n".join(lines)
|
|
350
|
+
|
|
351
|
+
if rel.unread_badge == "скрыт" and rel.differing > 0:
|
|
352
|
+
lines += [
|
|
353
|
+
f" Непрочитанного нет, а позиции расходятся у {rel.differing} диалогов.",
|
|
354
|
+
" Значит data-user-msg не отметка прочтения: будь она отметкой,",
|
|
355
|
+
" при пустом счётчике расхождений не было бы ни одного.",
|
|
356
|
+
" Остаётся трактовка «последнее написанное этим аккаунтом», и",
|
|
357
|
+
" признак непрочитанного придётся искать иначе.",
|
|
358
|
+
]
|
|
359
|
+
elif rel.unread_badge == "скрыт" and rel.differing == 0:
|
|
360
|
+
lines += [
|
|
361
|
+
" Непрочитанного нет, и позиции совпадают у всех диалогов.",
|
|
362
|
+
" Это отвечает трактовке «последнее прочитанное». Обратная",
|
|
363
|
+
" трактовка потребовала бы, чтобы последнее сообщение во всех",
|
|
364
|
+
f" {rel.contacts} диалогах было написано вами, что неправдоподобно.",
|
|
365
|
+
]
|
|
366
|
+
elif rel.unread_badge == "виден":
|
|
367
|
+
lines += [
|
|
368
|
+
" Непрочитанное есть. Число расхождений имеет смысл сравнить с",
|
|
369
|
+
" числом непрочитанных диалогов в интерфейсе: совпало - отметка",
|
|
370
|
+
" прочтения, расхождений заметно больше - последнее написанное.",
|
|
371
|
+
]
|
|
372
|
+
else:
|
|
373
|
+
lines.append(" счётчик непрочитанного не найден, вывод сделать нельзя")
|
|
374
|
+
|
|
375
|
+
return "\n".join(lines)
|