day-recap 0.2.1__tar.gz

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.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.3
2
+ Name: day-recap
3
+ Version: 0.2.1
4
+ Summary: Саммари проделанной работы за день: задачи и принятые решения из git, транскриптов агентов и чатов Telegram
5
+ Author: Vladimir Voronov
6
+ Author-email: Vladimir Voronov <vvoronov@mwnts.ru>
7
+ Requires-Dist: pydantic>=2.13.5
8
+ Requires-Dist: pydantic-settings>=2.15.0
9
+ Requires-Dist: qrcode>=8.2
10
+ Requires-Dist: telethon>=1.45.0
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+
14
+ # day-recap
15
+
16
+ [Русский](README.ru.md)
17
+
18
+ [![tests](https://github.com/redb0/day-recap/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/redb0/day-recap/actions/workflows/test.yaml)
19
+ [![coverage](https://codecov.io/gh/redb0/day-recap/branch/main/graph/badge.svg)](https://codecov.io/gh/redb0/day-recap)
20
+ ![python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)
21
+
22
+ Collects a day's work from local git repositories, Cursor transcripts, OpenCode
23
+ sessions, and a Telegram allowlist. Only a confirmed block is appended to the
24
+ Obsidian daily note.
25
+
26
+ The raw dump stays outside the vault. An empty day does not change the note.
27
+
28
+ ```markdown
29
+ ## 🤖 Итоги дня
30
+
31
+ ### Сделано
32
+ - **billing** — сузил окно повторной оплаты до одного счёта
33
+
34
+ ### Решения и обоснования
35
+ Оставили повтор по idempotency-key. Повтор всей корзины отбросили: он списывал деньги второй раз.
36
+ ```
37
+
38
+ ## Install and configure
39
+
40
+ From a checkout of this repository:
41
+
42
+ ```sh
43
+ uv tool install --editable .
44
+ mkdir -p ~/.config/day-recap
45
+ cp config.example.toml ~/.config/day-recap/config.toml
46
+ ```
47
+
48
+ Run `day-recap init` in your own terminal, not in the agent chat. Do not
49
+ type `api_id` or `api_hash` into the chat. `init` asks `qr` or `code`. `qr`
50
+ prints a code to scan: Settings → Devices → Link desktop device. `code` waits
51
+ for the login code in the Telegram service chat. `--login qr` or `--login code`
52
+ skips the question. `--relogin` signs in again.
53
+
54
+ `day-recap chats` prints a table and ready `[[telegram.chats]]` blocks.
55
+ Paste the blocks you want into the `[telegram]` section. Only those chats are
56
+ read into the raw dump.
57
+
58
+ | Section | What it sets |
59
+ | --- | --- |
60
+ | `[notes]` | Where to write the daily note. |
61
+ | `[state]` | Where raw dumps are stored. |
62
+ | `[git]` | Which directories to walk and whose commits to take. |
63
+ | `[transcripts]` | Cursor JSONL roots and how messages are trimmed. |
64
+ | `[opencode]` | OpenCode database and the same trim. |
65
+ | `[telegram]` | Whether the source is on, and the allowlist. |
66
+ | `[summary]` | Size threshold. |
67
+
68
+ Keys and defaults are in [`config.example.toml`](config.example.toml).
69
+
70
+ - The size threshold is 100000 bytes.
71
+ - Raw dumps are kept for 14 days.
72
+ - `~/.config/day-recap/.env` is mode 600.
73
+
74
+ ## In the agent
75
+
76
+ Copy [`SKILL.md`](SKILL.md) to `~/.agents/skills/day-recap/SKILL.md`.
77
+
78
+ ```text
79
+ /day-recap
80
+ /day-recap yesterday
81
+ /day-recap 2026-10-05
82
+ ```
83
+
84
+ The date is `today`, `yesterday`, or `YYYY-MM-DD`. With no date, the window
85
+ runs from local midnight to the moment you start.
86
+
87
+ The skill shows a draft and asks what you decided and what you rejected. The
88
+ note changes only after a separate yes. An empty day does not change the note.
89
+ With no Telegram session the other sources are still collected, and the block
90
+ contains the line «Telegram недоступен, чаты не учтены».
91
+
92
+ ## Other commands
93
+
94
+ `collect` stores a raw dump. `show` prints the day status, or one source when
95
+ you name it. The source is optional: `git`, `transcripts`, `opencode`, or
96
+ `telegram`. Both take `--date` (`today`, `yesterday`, or `YYYY-MM-DD`) and
97
+ `--config PATH`.
98
+
99
+ `write` reads the block from stdin. It needs `--date YYYY-MM-DD` and
100
+ `--body -`, and takes `--config PATH`. Without `--apply` the note stays
101
+ unchanged.
102
+
103
+ ```sh
104
+ day-recap collect --date yesterday
105
+ day-recap show git --date 2026-10-05
106
+ day-recap write --date 2026-10-05 --body - --apply
107
+ ```
108
+
109
+ ## Development
110
+
111
+ ```sh
112
+ just lint # ruff
113
+ just fmt # ruff format
114
+ just types # mypy
115
+ just test # pytest
116
+ ```
@@ -0,0 +1,103 @@
1
+ # day-recap
2
+
3
+ [Русский](README.ru.md)
4
+
5
+ [![tests](https://github.com/redb0/day-recap/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/redb0/day-recap/actions/workflows/test.yaml)
6
+ [![coverage](https://codecov.io/gh/redb0/day-recap/branch/main/graph/badge.svg)](https://codecov.io/gh/redb0/day-recap)
7
+ ![python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)
8
+
9
+ Collects a day's work from local git repositories, Cursor transcripts, OpenCode
10
+ sessions, and a Telegram allowlist. Only a confirmed block is appended to the
11
+ Obsidian daily note.
12
+
13
+ The raw dump stays outside the vault. An empty day does not change the note.
14
+
15
+ ```markdown
16
+ ## 🤖 Итоги дня
17
+
18
+ ### Сделано
19
+ - **billing** — сузил окно повторной оплаты до одного счёта
20
+
21
+ ### Решения и обоснования
22
+ Оставили повтор по idempotency-key. Повтор всей корзины отбросили: он списывал деньги второй раз.
23
+ ```
24
+
25
+ ## Install and configure
26
+
27
+ From a checkout of this repository:
28
+
29
+ ```sh
30
+ uv tool install --editable .
31
+ mkdir -p ~/.config/day-recap
32
+ cp config.example.toml ~/.config/day-recap/config.toml
33
+ ```
34
+
35
+ Run `day-recap init` in your own terminal, not in the agent chat. Do not
36
+ type `api_id` or `api_hash` into the chat. `init` asks `qr` or `code`. `qr`
37
+ prints a code to scan: Settings → Devices → Link desktop device. `code` waits
38
+ for the login code in the Telegram service chat. `--login qr` or `--login code`
39
+ skips the question. `--relogin` signs in again.
40
+
41
+ `day-recap chats` prints a table and ready `[[telegram.chats]]` blocks.
42
+ Paste the blocks you want into the `[telegram]` section. Only those chats are
43
+ read into the raw dump.
44
+
45
+ | Section | What it sets |
46
+ | --- | --- |
47
+ | `[notes]` | Where to write the daily note. |
48
+ | `[state]` | Where raw dumps are stored. |
49
+ | `[git]` | Which directories to walk and whose commits to take. |
50
+ | `[transcripts]` | Cursor JSONL roots and how messages are trimmed. |
51
+ | `[opencode]` | OpenCode database and the same trim. |
52
+ | `[telegram]` | Whether the source is on, and the allowlist. |
53
+ | `[summary]` | Size threshold. |
54
+
55
+ Keys and defaults are in [`config.example.toml`](config.example.toml).
56
+
57
+ - The size threshold is 100000 bytes.
58
+ - Raw dumps are kept for 14 days.
59
+ - `~/.config/day-recap/.env` is mode 600.
60
+
61
+ ## In the agent
62
+
63
+ Copy [`SKILL.md`](SKILL.md) to `~/.agents/skills/day-recap/SKILL.md`.
64
+
65
+ ```text
66
+ /day-recap
67
+ /day-recap yesterday
68
+ /day-recap 2026-10-05
69
+ ```
70
+
71
+ The date is `today`, `yesterday`, or `YYYY-MM-DD`. With no date, the window
72
+ runs from local midnight to the moment you start.
73
+
74
+ The skill shows a draft and asks what you decided and what you rejected. The
75
+ note changes only after a separate yes. An empty day does not change the note.
76
+ With no Telegram session the other sources are still collected, and the block
77
+ contains the line «Telegram недоступен, чаты не учтены».
78
+
79
+ ## Other commands
80
+
81
+ `collect` stores a raw dump. `show` prints the day status, or one source when
82
+ you name it. The source is optional: `git`, `transcripts`, `opencode`, or
83
+ `telegram`. Both take `--date` (`today`, `yesterday`, or `YYYY-MM-DD`) and
84
+ `--config PATH`.
85
+
86
+ `write` reads the block from stdin. It needs `--date YYYY-MM-DD` and
87
+ `--body -`, and takes `--config PATH`. Without `--apply` the note stays
88
+ unchanged.
89
+
90
+ ```sh
91
+ day-recap collect --date yesterday
92
+ day-recap show git --date 2026-10-05
93
+ day-recap write --date 2026-10-05 --body - --apply
94
+ ```
95
+
96
+ ## Development
97
+
98
+ ```sh
99
+ just lint # ruff
100
+ just fmt # ruff format
101
+ just types # mypy
102
+ just test # pytest
103
+ ```
File without changes
@@ -0,0 +1,36 @@
1
+ """Замена файла целиком: обрыв не оставляет обрезанный текст."""
2
+
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+
8
+ def replace_text(path: Path, text: str, *, mode: int | None = None) -> None:
9
+ """Записать текст через временный файл и `os.replace`.
10
+
11
+ Args:
12
+ path: Куда положить готовый текст.
13
+ text: Содержимое.
14
+ mode: Права нового файла. `None` — оставить режим существующего
15
+ или `0o644`, если файла ещё нет.
16
+
17
+ Raises:
18
+ OSError: Каталог недоступен или замена не удалась.
19
+ """
20
+ chosen = _mode(path) if mode is None else mode
21
+ fd, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
22
+ tmp = Path(name)
23
+ try:
24
+ os.fchmod(fd, chosen)
25
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
26
+ handle.write(text)
27
+ tmp.replace(path)
28
+ except OSError:
29
+ tmp.unlink(missing_ok=True)
30
+ raise
31
+
32
+
33
+ def _mode(path: Path) -> int:
34
+ if path.is_file():
35
+ return path.stat().st_mode & 0o777
36
+ return 0o644
@@ -0,0 +1,350 @@
1
+ """Точка входа команды `day-recap`."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import getpass
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from datetime import date
9
+ from importlib.metadata import version
10
+ from pathlib import Path
11
+
12
+ from app.collectors.telegram.chats import load_chats
13
+ from app.collectors.telegram.init import run_init
14
+ from app.config import Config, load_config
15
+ from app.errors import ErrorCode, SummaryError
16
+ from app.notes.writer import write_note
17
+ from app.summary.collect import (
18
+ RELATIVE_DAYS,
19
+ SOURCES,
20
+ CollectedDay,
21
+ SourceName,
22
+ collect_and_store,
23
+ load_stored_day,
24
+ local_now,
25
+ resolve_day,
26
+ )
27
+ from app.summary.models import Dump, GitDump, SessionDump, SourceStatus, TelegramDump, Window
28
+ from app.summary.show import render_git, render_sessions, render_telegram, visible_truncation
29
+
30
+ DISTRIBUTION = "day-recap"
31
+ _NOT_KEPT = "дамп не сохранён: дата старше ретенции"
32
+
33
+
34
+ def build_parser() -> argparse.ArgumentParser:
35
+ """Собрать разборщик аргументов.
36
+
37
+ Returns:
38
+ Разборщик верхнего уровня.
39
+ """
40
+ parser = argparse.ArgumentParser(
41
+ prog=DISTRIBUTION,
42
+ description="Саммари проделанной за день работы: задачи и принятые решения.",
43
+ )
44
+ parser.add_argument(
45
+ "--version",
46
+ action="version",
47
+ version=f"%(prog)s {version(DISTRIBUTION)}",
48
+ )
49
+ commands = parser.add_subparsers(dest="command", required=False)
50
+ collect = commands.add_parser("collect", help="Собрать сырой дамп за день.")
51
+ collect.add_argument("source", nargs="?", choices=SOURCES, default=None)
52
+ collect.add_argument("--date", type=_day_token, default=None)
53
+ collect.add_argument("--config", type=Path, default=None)
54
+ show = commands.add_parser("show", help="Показать сводку или тело источника.")
55
+ show.add_argument("source", nargs="?", choices=SOURCES, default=None)
56
+ show.add_argument("--date", type=_day_token, default=None)
57
+ show.add_argument("--config", type=Path, default=None)
58
+ write = commands.add_parser("write", help="Записать блок итогов в ежедневную заметку.")
59
+ write.add_argument("--date", type=_iso_day, required=True)
60
+ write.add_argument("--body", type=_stdin_body, required=True)
61
+ write.add_argument("--apply", action="store_true")
62
+ write.add_argument("--config", type=Path, default=None)
63
+ init = commands.add_parser("init", help="Настроить доступ к Telegram в своём терминале.")
64
+ init.add_argument("--relogin", action="store_true")
65
+ init.add_argument("--login", choices=("qr", "code"), default=None)
66
+ init.add_argument("--config", type=Path, default=None)
67
+ chats = commands.add_parser("chats", help="Показать чаты и фрагмент конфига.")
68
+ chats.add_argument("--config", type=Path, default=None)
69
+ return parser
70
+
71
+
72
+ def main(argv: Sequence[str] | None = None) -> int:
73
+ """Разобрать аргументы и выполнить команду.
74
+
75
+ Args:
76
+ argv: Аргументы без имени программы. `None` — взять из `sys.argv`.
77
+
78
+ Returns:
79
+ Код возврата процесса.
80
+ """
81
+ parser = build_parser()
82
+ args = parser.parse_args(argv)
83
+ if args.command is None:
84
+ parser.print_help()
85
+ return 0
86
+ try:
87
+ return _dispatch(args)
88
+ except SummaryError as error:
89
+ _print_error(error)
90
+ return 1
91
+ except FileNotFoundError as exc:
92
+ sys.stderr.write(f"Файл конфигурации не найден: {exc}\n")
93
+ return 1
94
+
95
+
96
+ def _day_token(value: str) -> str:
97
+ if value in RELATIVE_DAYS or _is_iso_day(value):
98
+ return value
99
+ message = "ожидается YYYY-MM-DD, today или yesterday"
100
+ raise argparse.ArgumentTypeError(message)
101
+
102
+
103
+ def _iso_day(value: str) -> str:
104
+ if _is_iso_day(value):
105
+ return value
106
+ message = "ожидается YYYY-MM-DD"
107
+ raise argparse.ArgumentTypeError(message)
108
+
109
+
110
+ def _is_iso_day(value: str) -> bool:
111
+ try:
112
+ date.fromisoformat(value)
113
+ except ValueError:
114
+ return False
115
+ return True
116
+
117
+
118
+ def _stdin_body(value: str) -> str:
119
+ if value != "-":
120
+ message = "тело читается из stdin: укажите --body -"
121
+ raise argparse.ArgumentTypeError(message) from None
122
+ return value
123
+
124
+
125
+ def _dispatch(args: argparse.Namespace) -> int:
126
+ config = load_config(args.config)
127
+ if args.command == "collect":
128
+ return _run_collect(config, args.date, args.source)
129
+ if args.command == "show":
130
+ return _run_show(config, args.date, args.source)
131
+ if args.command == "write":
132
+ return _run_write(config, args.date, apply=args.apply)
133
+ if args.command == "init":
134
+ return _run_init(config, relogin=args.relogin, login=args.login)
135
+ return _run_chats(config)
136
+
137
+
138
+ def _run_collect(config: Config, token: str | None, source: SourceName | None) -> int:
139
+ day = collect_and_store(config, token, progress=_progress, source=source)
140
+ sys.stdout.write(_report(day, threshold=config.summary.two_stage_threshold_bytes))
141
+ return 0
142
+
143
+
144
+ def _run_show(config: Config, token: str | None, source: SourceName | None) -> int:
145
+ day_date, stored = load_stored_day(config, token)
146
+ if source is None:
147
+ sys.stdout.write(
148
+ _report(
149
+ stored,
150
+ threshold=config.summary.two_stage_threshold_bytes,
151
+ empty_date=day_date,
152
+ ),
153
+ )
154
+ return 0
155
+ sys.stdout.write(_source_text(source, stored))
156
+ return 0
157
+
158
+
159
+ def _source_text(source: SourceName, day: CollectedDay) -> str:
160
+ dump = _dumps(day)[source]
161
+ if dump is None:
162
+ return _source_line(source, None) + "\n"
163
+ return _body(dump)
164
+
165
+
166
+ def _body(dump: Dump) -> str:
167
+ if isinstance(dump, GitDump):
168
+ return render_git(dump)
169
+ if isinstance(dump, TelegramDump):
170
+ return render_telegram(dump)
171
+ return render_sessions(dump)
172
+
173
+
174
+ def _progress(line: str) -> None:
175
+ sys.stderr.write(f"{line}\n")
176
+ sys.stderr.flush()
177
+
178
+
179
+ def _run_write(config: Config, token: str, *, apply: bool) -> int:
180
+ day = resolve_day(token, today=local_now(config.notes.timezone).date())
181
+ result = write_note(config, day, sys.stdin.read(), apply=apply)
182
+ sys.stdout.write(result.diff)
183
+ return 0
184
+
185
+
186
+ def _run_init(config: Config, *, relogin: bool, login: str | None) -> int:
187
+ account = run_init(
188
+ config,
189
+ relogin=relogin,
190
+ login=login,
191
+ ask=_ask_credential,
192
+ choose=_ask_login,
193
+ )
194
+ sys.stdout.write(f"Вошли как {account}\n")
195
+ return 0
196
+
197
+
198
+ def _ask_login() -> str:
199
+ while True:
200
+ answer = input("Вход (qr/code): ").strip().lower()
201
+ if answer in {"qr", "code"}:
202
+ return answer
203
+ sys.stderr.write("Ожидается qr или code.\n")
204
+
205
+
206
+ def _ask_credential(key: str) -> str:
207
+ if key == "TG_API_HASH":
208
+ return getpass.getpass("TG_API_HASH: ")
209
+ if key == "TG_API_ID":
210
+ return input("TG_API_ID (https://my.telegram.org/apps): ")
211
+ return input("TG_PHONE (+7999…): ")
212
+
213
+
214
+ def _run_chats(config: Config) -> int:
215
+ sys.stdout.write(asyncio.run(load_chats(config)))
216
+ return 0
217
+
218
+
219
+ _STATUS = {
220
+ SourceStatus.OK: "ok",
221
+ SourceStatus.EMPTY: "пустой",
222
+ SourceStatus.DISABLED: "выключен",
223
+ SourceStatus.UNAVAILABLE: "недоступен",
224
+ }
225
+
226
+
227
+ def _report(
228
+ day: CollectedDay,
229
+ *,
230
+ threshold: int,
231
+ empty_date: date | None = None,
232
+ ) -> str:
233
+ shown = _day_date(day, empty_date)
234
+ shared = _shared_window(day)
235
+ lines = [
236
+ *_heading(day),
237
+ f"дата: {shown.isoformat()}",
238
+ f"байты: {day.total_bytes}",
239
+ f"порог: {threshold}",
240
+ f"порог превышен: {_exceeded(day.total_bytes, threshold)}",
241
+ ]
242
+ if shared is not None:
243
+ lines.append(_window_text(shared))
244
+ lines.extend(
245
+ _source_line(name, dump) + _window_suffix(dump, shared=shared is not None)
246
+ for name, dump in _dumps(day).items()
247
+ )
248
+ lines.extend(_notes(day))
249
+ return "\n".join(lines) + "\n"
250
+
251
+
252
+ def _dumps(day: CollectedDay) -> dict[SourceName, Dump | None]:
253
+ return {
254
+ "git": day.git,
255
+ "transcripts": day.transcripts,
256
+ "opencode": day.opencode,
257
+ "telegram": day.telegram,
258
+ }
259
+
260
+
261
+ def _shared_window(day: CollectedDay) -> Window | None:
262
+ windows = [dump.window for dump in _dumps(day).values() if dump is not None]
263
+ if not windows or any(item != windows[0] for item in windows):
264
+ return None
265
+ return windows[0]
266
+
267
+
268
+ def _window_text(window: Window) -> str:
269
+ return f"окно: {window.from_.isoformat()}..{window.to.isoformat()}"
270
+
271
+
272
+ def _window_suffix(dump: Dump | None, *, shared: bool) -> str:
273
+ if shared or dump is None:
274
+ return ""
275
+ return ", " + _window_text(dump.window)
276
+
277
+
278
+ def _heading(day: CollectedDay) -> list[str]:
279
+ if day.directory is not None:
280
+ return [str(day.directory)]
281
+ if any(dump is not None for dump in _dumps(day).values()):
282
+ return [_NOT_KEPT]
283
+ return []
284
+
285
+
286
+ def _day_date(day: CollectedDay, fallback: date | None) -> date:
287
+ for dump in _dumps(day).values():
288
+ if dump is not None:
289
+ return dump.date
290
+ if fallback is not None:
291
+ return fallback
292
+ message = "в дне нет ни одного дампа"
293
+ raise RuntimeError(message)
294
+
295
+
296
+ def _exceeded(total: int, threshold: int) -> str:
297
+ if total > threshold:
298
+ return "да"
299
+ return "нет"
300
+
301
+
302
+ def _source_line(name: SourceName, dump: Dump | None) -> str:
303
+ if name == "git":
304
+ return _git_line(dump if isinstance(dump, GitDump) else None)
305
+ if name == "telegram":
306
+ return _telegram_line(dump if isinstance(dump, TelegramDump) else None)
307
+ return _session_line(name, dump if isinstance(dump, SessionDump) else None)
308
+
309
+
310
+ def _login_code(dump: GitDump | SessionDump | TelegramDump) -> str:
311
+ if dump.code in {ErrorCode.NO_SESSION, ErrorCode.NO_CREDENTIALS}:
312
+ return f", code: {dump.code}"
313
+ return ""
314
+
315
+
316
+ def _git_line(dump: GitDump | None) -> str:
317
+ if dump is None:
318
+ return "git: не собран, репозиториев: 0, коммитов: 0"
319
+ commits = sum(len(repo.commits) for repo in dump.repos)
320
+ return (
321
+ f"git: {_STATUS[dump.status]}{_login_code(dump)}, "
322
+ f"репозиториев: {len(dump.repos)}, коммитов: {commits}"
323
+ )
324
+
325
+
326
+ def _session_line(name: str, dump: SessionDump | None) -> str:
327
+ if dump is None:
328
+ return f"{name}: не собран, сессий: 0"
329
+ return f"{name}: {_STATUS[dump.status]}{_login_code(dump)}, сессий: {len(dump.sessions)}"
330
+
331
+
332
+ def _telegram_line(dump: TelegramDump | None) -> str:
333
+ if dump is None:
334
+ return "telegram: не собран, чатов: 0, unlisted: 0"
335
+ return (
336
+ f"telegram: {_STATUS[dump.status]}{_login_code(dump)}, "
337
+ f"чатов: {len(dump.chats)}, unlisted: {dump.unlisted_active}"
338
+ )
339
+
340
+
341
+ def _notes(day: CollectedDay) -> list[str]:
342
+ notes: list[str] = []
343
+ for dump in _dumps(day).values():
344
+ if dump is not None:
345
+ notes.extend(visible_truncation(note) for note in dump.truncations)
346
+ return notes
347
+
348
+
349
+ def _print_error(error: SummaryError) -> None:
350
+ sys.stderr.write(f"code: {error.code}\n{error.message}\n{error.hint}\n")
@@ -0,0 +1 @@
1
+ """Сборщики источников day-recap."""