article-backup 0.1.0__tar.gz → 0.2.2__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.
Files changed (20) hide show
  1. {article_backup-0.1.0 → article_backup-0.2.2}/PKG-INFO +58 -18
  2. {article_backup-0.1.0 → article_backup-0.2.2}/README.md +57 -17
  3. {article_backup-0.1.0 → article_backup-0.2.2}/article_backup.egg-info/PKG-INFO +58 -18
  4. {article_backup-0.1.0 → article_backup-0.2.2}/article_backup.egg-info/SOURCES.txt +2 -1
  5. {article_backup-0.1.0 → article_backup-0.2.2}/backup.py +19 -2
  6. {article_backup-0.1.0 → article_backup-0.2.2}/pyproject.toml +1 -1
  7. {article_backup-0.1.0 → article_backup-0.2.2}/src/boosty.py +1 -1
  8. {article_backup-0.1.0 → article_backup-0.2.2}/src/config.py +10 -1
  9. {article_backup-0.1.0 → article_backup-0.2.2}/src/downloader.py +30 -13
  10. {article_backup-0.1.0 → article_backup-0.2.2}/src/sponsr.py +96 -5
  11. {article_backup-0.1.0 → article_backup-0.2.2}/src/utils.py +58 -17
  12. article_backup-0.2.2/tests/test_asset_dedup.py +143 -0
  13. {article_backup-0.1.0 → article_backup-0.2.2}/LICENSE +0 -0
  14. {article_backup-0.1.0 → article_backup-0.2.2}/article_backup.egg-info/dependency_links.txt +0 -0
  15. {article_backup-0.1.0 → article_backup-0.2.2}/article_backup.egg-info/entry_points.txt +0 -0
  16. {article_backup-0.1.0 → article_backup-0.2.2}/article_backup.egg-info/requires.txt +0 -0
  17. {article_backup-0.1.0 → article_backup-0.2.2}/article_backup.egg-info/top_level.txt +0 -0
  18. {article_backup-0.1.0 → article_backup-0.2.2}/setup.cfg +0 -0
  19. {article_backup-0.1.0 → article_backup-0.2.2}/src/__init__.py +0 -0
  20. {article_backup-0.1.0 → article_backup-0.2.2}/src/database.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: article-backup
3
- Version: 0.1.0
3
+ Version: 0.2.2
4
4
  Summary: Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией
5
5
  Author-email: Eugene Chaykin <eugene@chayk.in>
6
6
  License: Apache-2.0
@@ -31,6 +31,10 @@ Dynamic: license-file
31
31
 
32
32
  # Article Backup
33
33
 
34
+ [![PyPI version](https://img.shields.io/pypi/v/article-backup.svg)](https://pypi.org/project/article-backup/)
35
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
36
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
37
+
34
38
  Скрипт для локального бэкапа статей с платформ **Sponsr.ru** и **Boosty.to**.
35
39
 
36
40
  Конвертирует статьи в Markdown с YAML-метаданными, скачивает изображения и другие медиафайлы, поддерживает инкрементальную синхронизацию.
@@ -41,15 +45,25 @@ Dynamic: license-file
41
45
  - Инкрементальные обновления — скачивает только новые статьи
42
46
  - Конвертация в Markdown с frontmatter (title, date, tags, source)
43
47
  - Локальное сохранение изображений, видео, аудио, PDF
48
+ - Гибкая фильтрация типов скачиваемых файлов (image, video, audio, document)
44
49
  - Сохранение ссылок на встроенные видео (Rutube, YouTube, Vimeo, VK, OK.ru)
50
+ - Нормализация сложной разметки Sponsr (вложенный em/strong, кавычки, bidi-маркеры)
45
51
  - Исправление внутренних ссылок между статьями
46
- - Интеграция с Hugo для просмотра в браузере
52
+ - Интеграция с Hugo для просмотра в браузере (поддержка тем, улучшенная типографика)
47
53
  - SQLite-индекс для быстрого поиска
48
54
 
49
55
  ## Установка
50
56
 
51
57
  Требуется **Python 3.10+**
52
58
 
59
+ ### Вариант 1: Через pip (рекомендуется)
60
+
61
+ ```bash
62
+ pip install article-backup
63
+ ```
64
+
65
+ ### Вариант 2: Из исходников
66
+
53
67
  ```bash
54
68
  git clone https://github.com/strannick-ru/article-backup.git
55
69
  cd article-backup
@@ -86,7 +100,8 @@ sources:
86
100
  - platform: sponsr
87
101
  author: pushkin
88
102
  display_name: "Пушкин. Проза"
89
-
103
+ asset_types: ["image", "document"] # Скачивать только картинки и документы
104
+
90
105
  - platform: boosty
91
106
  author: lermontov
92
107
  display_name: "Лермонтов. Стихи"
@@ -120,44 +135,66 @@ console.log("Cookie:\n" + cookie + "\n\nAuthorization:\nBearer " + auth.accessTo
120
135
  ### Синхронизация всех авторов
121
136
 
122
137
  ```bash
138
+ # Если установлено через pip
139
+ article-backup
140
+
141
+ # Или из исходников
123
142
  python backup.py
124
143
  ```
125
144
 
126
145
  ### Скачать один пост по URL
127
146
 
128
147
  ```bash
129
- python backup.py "https://sponsr.ru/author/12345/post-title/"
130
- python backup.py "https://boosty.to/author/posts/uuid"
148
+ article-backup "https://sponsr.ru/author/12345/post-title/"
149
+ article-backup "https://boosty.to/author/posts/uuid"
131
150
  ```
132
151
 
133
152
  ### Указать другой конфиг
134
153
 
135
154
  ```bash
136
- python backup.py -c /path/to/config.yaml
155
+ article-backup -c /path/to/config.yaml
156
+ ```
157
+
158
+ ## Разработка
159
+
160
+ ### Тесты
161
+
162
+ Проект использует встроенный `unittest`.
163
+
164
+ ```bash
165
+ python -m unittest -q
137
166
  ```
138
167
 
139
168
  ## Docker
140
169
 
141
170
  Для серверов с устаревшим Python можно использовать Docker.
142
171
 
143
- ```bash
144
- # Сборка образа
145
- docker compose build
172
+ Для удобства используйте скрипт `run-docker.sh`, который автоматически подхватывает `output_dir` из вашего `config.yaml` и монтирует правильный volume.
146
173
 
147
- # Синхронизация всех авторов
148
- docker compose run --rm backup
174
+ ```bash
175
+ # Синхронизация + сборка сайта (рекомендуемый способ)
176
+ ./run-docker.sh
149
177
 
150
178
  # Скачать один пост
151
- docker compose run --rm backup "https://sponsr.ru/author/123/"
179
+ ./run-docker.sh "https://sponsr.ru/author/123/"
152
180
 
153
- # Сборка Hugo-сайта
154
- docker compose run --rm hugo
181
+ # Только пересборка сайта
182
+ ./run-docker.sh hugo
155
183
 
156
- # Полная синхронизация (backup + hugo)
157
- docker compose run --rm backup && docker compose run --rm hugo
184
+ # Пересборка контейнеров
185
+ ./run-docker.sh build
186
+ ```
158
187
 
159
- # Пересборка после изменений кода
160
- docker compose build --no-cache
188
+ ### Ручной запуск (Advanced)
189
+
190
+ Если вы не хотите использовать скрипт, можно запускать через `docker compose`, но нужно вручную указывать путь к бэкапам, если он отличается от `./backup`.
191
+
192
+ ```bash
193
+ # Если output_dir в конфиге = ./backup
194
+ docker compose run --rm backup
195
+
196
+ # Если output_dir другой
197
+ HOST_BACKUP_DIR=/path/to/data docker compose run --rm backup
161
198
  ```
162
199
 
163
200
  ### Cron
@@ -211,8 +248,11 @@ hugo:
211
248
  base_url: "https://example.com/" # URL сайта для production
212
249
  title: "Мой архив статей" # Заголовок сайта
213
250
  language_code: "ru" # Язык контента
251
+ default_theme: "sepia" # Тема по умолчанию: light, dark, sepia, gruvbox, everforest
214
252
  ```
215
253
 
254
+ Сайт поддерживает переключение тем "на лету" (кнопки в углу экрана). Выбор пользователя сохраняется в браузере.
255
+
216
256
  Если секция `hugo:` не указана, используются значения по умолчанию (`http://localhost:1313/`).
217
257
 
218
258
  ### RSS-ленты
@@ -1,5 +1,9 @@
1
1
  # Article Backup
2
2
 
3
+ [![PyPI version](https://img.shields.io/pypi/v/article-backup.svg)](https://pypi.org/project/article-backup/)
4
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
5
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
6
+
3
7
  Скрипт для локального бэкапа статей с платформ **Sponsr.ru** и **Boosty.to**.
4
8
 
5
9
  Конвертирует статьи в Markdown с YAML-метаданными, скачивает изображения и другие медиафайлы, поддерживает инкрементальную синхронизацию.
@@ -10,15 +14,25 @@
10
14
  - Инкрементальные обновления — скачивает только новые статьи
11
15
  - Конвертация в Markdown с frontmatter (title, date, tags, source)
12
16
  - Локальное сохранение изображений, видео, аудио, PDF
17
+ - Гибкая фильтрация типов скачиваемых файлов (image, video, audio, document)
13
18
  - Сохранение ссылок на встроенные видео (Rutube, YouTube, Vimeo, VK, OK.ru)
19
+ - Нормализация сложной разметки Sponsr (вложенный em/strong, кавычки, bidi-маркеры)
14
20
  - Исправление внутренних ссылок между статьями
15
- - Интеграция с Hugo для просмотра в браузере
21
+ - Интеграция с Hugo для просмотра в браузере (поддержка тем, улучшенная типографика)
16
22
  - SQLite-индекс для быстрого поиска
17
23
 
18
24
  ## Установка
19
25
 
20
26
  Требуется **Python 3.10+**
21
27
 
28
+ ### Вариант 1: Через pip (рекомендуется)
29
+
30
+ ```bash
31
+ pip install article-backup
32
+ ```
33
+
34
+ ### Вариант 2: Из исходников
35
+
22
36
  ```bash
23
37
  git clone https://github.com/strannick-ru/article-backup.git
24
38
  cd article-backup
@@ -55,7 +69,8 @@ sources:
55
69
  - platform: sponsr
56
70
  author: pushkin
57
71
  display_name: "Пушкин. Проза"
58
-
72
+ asset_types: ["image", "document"] # Скачивать только картинки и документы
73
+
59
74
  - platform: boosty
60
75
  author: lermontov
61
76
  display_name: "Лермонтов. Стихи"
@@ -89,44 +104,66 @@ console.log("Cookie:\n" + cookie + "\n\nAuthorization:\nBearer " + auth.accessTo
89
104
  ### Синхронизация всех авторов
90
105
 
91
106
  ```bash
107
+ # Если установлено через pip
108
+ article-backup
109
+
110
+ # Или из исходников
92
111
  python backup.py
93
112
  ```
94
113
 
95
114
  ### Скачать один пост по URL
96
115
 
97
116
  ```bash
98
- python backup.py "https://sponsr.ru/author/12345/post-title/"
99
- python backup.py "https://boosty.to/author/posts/uuid"
117
+ article-backup "https://sponsr.ru/author/12345/post-title/"
118
+ article-backup "https://boosty.to/author/posts/uuid"
100
119
  ```
101
120
 
102
121
  ### Указать другой конфиг
103
122
 
104
123
  ```bash
105
- python backup.py -c /path/to/config.yaml
124
+ article-backup -c /path/to/config.yaml
125
+ ```
126
+
127
+ ## Разработка
128
+
129
+ ### Тесты
130
+
131
+ Проект использует встроенный `unittest`.
132
+
133
+ ```bash
134
+ python -m unittest -q
106
135
  ```
107
136
 
108
137
  ## Docker
109
138
 
110
139
  Для серверов с устаревшим Python можно использовать Docker.
111
140
 
112
- ```bash
113
- # Сборка образа
114
- docker compose build
141
+ Для удобства используйте скрипт `run-docker.sh`, который автоматически подхватывает `output_dir` из вашего `config.yaml` и монтирует правильный volume.
115
142
 
116
- # Синхронизация всех авторов
117
- docker compose run --rm backup
143
+ ```bash
144
+ # Синхронизация + сборка сайта (рекомендуемый способ)
145
+ ./run-docker.sh
118
146
 
119
147
  # Скачать один пост
120
- docker compose run --rm backup "https://sponsr.ru/author/123/"
148
+ ./run-docker.sh "https://sponsr.ru/author/123/"
121
149
 
122
- # Сборка Hugo-сайта
123
- docker compose run --rm hugo
150
+ # Только пересборка сайта
151
+ ./run-docker.sh hugo
124
152
 
125
- # Полная синхронизация (backup + hugo)
126
- docker compose run --rm backup && docker compose run --rm hugo
153
+ # Пересборка контейнеров
154
+ ./run-docker.sh build
155
+ ```
127
156
 
128
- # Пересборка после изменений кода
129
- docker compose build --no-cache
157
+ ### Ручной запуск (Advanced)
158
+
159
+ Если вы не хотите использовать скрипт, можно запускать через `docker compose`, но нужно вручную указывать путь к бэкапам, если он отличается от `./backup`.
160
+
161
+ ```bash
162
+ # Если output_dir в конфиге = ./backup
163
+ docker compose run --rm backup
164
+
165
+ # Если output_dir другой
166
+ HOST_BACKUP_DIR=/path/to/data docker compose run --rm backup
130
167
  ```
131
168
 
132
169
  ### Cron
@@ -180,8 +217,11 @@ hugo:
180
217
  base_url: "https://example.com/" # URL сайта для production
181
218
  title: "Мой архив статей" # Заголовок сайта
182
219
  language_code: "ru" # Язык контента
220
+ default_theme: "sepia" # Тема по умолчанию: light, dark, sepia, gruvbox, everforest
183
221
  ```
184
222
 
223
+ Сайт поддерживает переключение тем "на лету" (кнопки в углу экрана). Выбор пользователя сохраняется в браузере.
224
+
185
225
  Если секция `hugo:` не указана, используются значения по умолчанию (`http://localhost:1313/`).
186
226
 
187
227
  ### RSS-ленты
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: article-backup
3
- Version: 0.1.0
3
+ Version: 0.2.2
4
4
  Summary: Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией
5
5
  Author-email: Eugene Chaykin <eugene@chayk.in>
6
6
  License: Apache-2.0
@@ -31,6 +31,10 @@ Dynamic: license-file
31
31
 
32
32
  # Article Backup
33
33
 
34
+ [![PyPI version](https://img.shields.io/pypi/v/article-backup.svg)](https://pypi.org/project/article-backup/)
35
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
36
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
37
+
34
38
  Скрипт для локального бэкапа статей с платформ **Sponsr.ru** и **Boosty.to**.
35
39
 
36
40
  Конвертирует статьи в Markdown с YAML-метаданными, скачивает изображения и другие медиафайлы, поддерживает инкрементальную синхронизацию.
@@ -41,15 +45,25 @@ Dynamic: license-file
41
45
  - Инкрементальные обновления — скачивает только новые статьи
42
46
  - Конвертация в Markdown с frontmatter (title, date, tags, source)
43
47
  - Локальное сохранение изображений, видео, аудио, PDF
48
+ - Гибкая фильтрация типов скачиваемых файлов (image, video, audio, document)
44
49
  - Сохранение ссылок на встроенные видео (Rutube, YouTube, Vimeo, VK, OK.ru)
50
+ - Нормализация сложной разметки Sponsr (вложенный em/strong, кавычки, bidi-маркеры)
45
51
  - Исправление внутренних ссылок между статьями
46
- - Интеграция с Hugo для просмотра в браузере
52
+ - Интеграция с Hugo для просмотра в браузере (поддержка тем, улучшенная типографика)
47
53
  - SQLite-индекс для быстрого поиска
48
54
 
49
55
  ## Установка
50
56
 
51
57
  Требуется **Python 3.10+**
52
58
 
59
+ ### Вариант 1: Через pip (рекомендуется)
60
+
61
+ ```bash
62
+ pip install article-backup
63
+ ```
64
+
65
+ ### Вариант 2: Из исходников
66
+
53
67
  ```bash
54
68
  git clone https://github.com/strannick-ru/article-backup.git
55
69
  cd article-backup
@@ -86,7 +100,8 @@ sources:
86
100
  - platform: sponsr
87
101
  author: pushkin
88
102
  display_name: "Пушкин. Проза"
89
-
103
+ asset_types: ["image", "document"] # Скачивать только картинки и документы
104
+
90
105
  - platform: boosty
91
106
  author: lermontov
92
107
  display_name: "Лермонтов. Стихи"
@@ -120,44 +135,66 @@ console.log("Cookie:\n" + cookie + "\n\nAuthorization:\nBearer " + auth.accessTo
120
135
  ### Синхронизация всех авторов
121
136
 
122
137
  ```bash
138
+ # Если установлено через pip
139
+ article-backup
140
+
141
+ # Или из исходников
123
142
  python backup.py
124
143
  ```
125
144
 
126
145
  ### Скачать один пост по URL
127
146
 
128
147
  ```bash
129
- python backup.py "https://sponsr.ru/author/12345/post-title/"
130
- python backup.py "https://boosty.to/author/posts/uuid"
148
+ article-backup "https://sponsr.ru/author/12345/post-title/"
149
+ article-backup "https://boosty.to/author/posts/uuid"
131
150
  ```
132
151
 
133
152
  ### Указать другой конфиг
134
153
 
135
154
  ```bash
136
- python backup.py -c /path/to/config.yaml
155
+ article-backup -c /path/to/config.yaml
156
+ ```
157
+
158
+ ## Разработка
159
+
160
+ ### Тесты
161
+
162
+ Проект использует встроенный `unittest`.
163
+
164
+ ```bash
165
+ python -m unittest -q
137
166
  ```
138
167
 
139
168
  ## Docker
140
169
 
141
170
  Для серверов с устаревшим Python можно использовать Docker.
142
171
 
143
- ```bash
144
- # Сборка образа
145
- docker compose build
172
+ Для удобства используйте скрипт `run-docker.sh`, который автоматически подхватывает `output_dir` из вашего `config.yaml` и монтирует правильный volume.
146
173
 
147
- # Синхронизация всех авторов
148
- docker compose run --rm backup
174
+ ```bash
175
+ # Синхронизация + сборка сайта (рекомендуемый способ)
176
+ ./run-docker.sh
149
177
 
150
178
  # Скачать один пост
151
- docker compose run --rm backup "https://sponsr.ru/author/123/"
179
+ ./run-docker.sh "https://sponsr.ru/author/123/"
152
180
 
153
- # Сборка Hugo-сайта
154
- docker compose run --rm hugo
181
+ # Только пересборка сайта
182
+ ./run-docker.sh hugo
155
183
 
156
- # Полная синхронизация (backup + hugo)
157
- docker compose run --rm backup && docker compose run --rm hugo
184
+ # Пересборка контейнеров
185
+ ./run-docker.sh build
186
+ ```
158
187
 
159
- # Пересборка после изменений кода
160
- docker compose build --no-cache
188
+ ### Ручной запуск (Advanced)
189
+
190
+ Если вы не хотите использовать скрипт, можно запускать через `docker compose`, но нужно вручную указывать путь к бэкапам, если он отличается от `./backup`.
191
+
192
+ ```bash
193
+ # Если output_dir в конфиге = ./backup
194
+ docker compose run --rm backup
195
+
196
+ # Если output_dir другой
197
+ HOST_BACKUP_DIR=/path/to/data docker compose run --rm backup
161
198
  ```
162
199
 
163
200
  ### Cron
@@ -211,8 +248,11 @@ hugo:
211
248
  base_url: "https://example.com/" # URL сайта для production
212
249
  title: "Мой архив статей" # Заголовок сайта
213
250
  language_code: "ru" # Язык контента
251
+ default_theme: "sepia" # Тема по умолчанию: light, dark, sepia, gruvbox, everforest
214
252
  ```
215
253
 
254
+ Сайт поддерживает переключение тем "на лету" (кнопки в углу экрана). Выбор пользователя сохраняется в браузере.
255
+
216
256
  Если секция `hugo:` не указана, используются значения по умолчанию (`http://localhost:1313/`).
217
257
 
218
258
  ### RSS-ленты
@@ -14,4 +14,5 @@ src/config.py
14
14
  src/database.py
15
15
  src/downloader.py
16
16
  src/sponsr.py
17
- src/utils.py
17
+ src/utils.py
18
+ tests/test_asset_dedup.py
@@ -6,8 +6,9 @@ import argparse
6
6
  import os
7
7
  import sys
8
8
  from pathlib import Path
9
+ from typing import cast
9
10
 
10
- from src.config import Config, load_config, Source
11
+ from src.config import Config, load_config, Source, Platform
11
12
  from src.database import Database
12
13
  from src.utils import is_post_url, parse_post_url
13
14
  from src.sponsr import SponsorDownloader
@@ -25,6 +26,9 @@ languageCode = '{config.hugo.language_code}'
25
26
  title = '{config.hugo.title}'
26
27
  relativeURLs = true
27
28
 
29
+ [params]
30
+ default_theme = '{config.hugo.default_theme}'
31
+
28
32
  [markup.goldmark.renderer]
29
33
  unsafe = true
30
34
 
@@ -43,6 +47,12 @@ relativeURLs = true
43
47
 
44
48
  def ensure_site_content_link(config: Config):
45
49
  """Создаёт симлинк site/content → output_dir."""
50
+ # В Docker-среде (когда задан BACKUP_OUTPUT_DIR) мы не создаем симлинк,
51
+ # так как пути внутри контейнера (/app/backup) не совпадают с хостовыми.
52
+ # Симлинк должен создаваться скриптом запуска (run-docker.sh) на хосте.
53
+ if os.environ.get('BACKUP_OUTPUT_DIR'):
54
+ return
55
+
46
56
  site_content = Path('site/content')
47
57
 
48
58
  # Если уже правильный симлинк — ничего не делаем
@@ -89,11 +99,18 @@ def sync_all(config: Config, db: Database):
89
99
 
90
100
  def download_single_post(url: str, config: Config, db: Database):
91
101
  """Скачивает один пост по URL."""
92
- platform, author, post_id = parse_post_url(url)
102
+ platform_str, author, post_id = parse_post_url(url)
103
+ platform = cast(Platform, platform_str)
93
104
 
94
105
  # Создаём Source для этого автора
95
106
  source = Source(platform=platform, author=author, download_assets=True)
96
107
 
108
+ # Пытаемся найти настройки источника в конфиге
109
+ for src in config.sources:
110
+ if src.platform == platform and src.author == author:
111
+ source = src
112
+ break
113
+
97
114
  downloader = get_downloader(platform, config, source, db)
98
115
  downloader.download_single(post_id)
99
116
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "article-backup"
3
- version = "0.1.0"
3
+ version = "0.2.2"
4
4
  description = "Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией"
5
5
  readme = "README.md"
6
6
  license = {text = "Apache-2.0"}
@@ -132,7 +132,7 @@ class BoostyDownloader(BaseDownloader):
132
132
  elif block_type == "ok_video":
133
133
  # ok.ru видео требует отдельной обработки
134
134
  # Пока сохраняем только превью, если есть
135
- preview = block.get("previewUrl", "")
135
+ preview = block.get("previewUrl") or block.get("preview") or ""
136
136
  if preview:
137
137
  assets.append({
138
138
  "url": preview,
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  from typing import Literal
7
7
 
8
8
  import yaml
9
+ import os
9
10
 
10
11
  Platform = Literal['sponsr', 'boosty']
11
12
 
@@ -16,6 +17,7 @@ class Source:
16
17
  author: str
17
18
  download_assets: bool = True
18
19
  display_name: str | None = None
20
+ asset_types: list[str] | None = None
19
21
 
20
22
  @dataclass
21
23
  class Auth:
@@ -29,6 +31,7 @@ class HugoConfig:
29
31
  base_url: str = "http://localhost:1313/"
30
32
  title: str = "Бэкап статей"
31
33
  language_code: str = "ru"
34
+ default_theme: str = "light"
32
35
 
33
36
 
34
37
  @dataclass
@@ -45,7 +48,11 @@ def load_config(config_path: Path) -> Config:
45
48
  data = yaml.safe_load(f)
46
49
 
47
50
  # output_dir
48
- output_dir = Path(data.get('output_dir', './backup'))
51
+ env_output_dir = os.environ.get('BACKUP_OUTPUT_DIR')
52
+ if env_output_dir:
53
+ output_dir = Path(env_output_dir)
54
+ else:
55
+ output_dir = Path(data.get('output_dir', './backup'))
49
56
 
50
57
  # auth
51
58
  auth_data = data.get('auth', {})
@@ -63,6 +70,7 @@ def load_config(config_path: Path) -> Config:
63
70
  author=src['author'],
64
71
  download_assets=src.get('download_assets', True),
65
72
  display_name=src.get('display_name'),
73
+ asset_types=src.get('asset_types'),
66
74
  ))
67
75
 
68
76
  # hugo
@@ -71,6 +79,7 @@ def load_config(config_path: Path) -> Config:
71
79
  base_url=hugo_data.get('base_url', HugoConfig.base_url),
72
80
  title=hugo_data.get('title', HugoConfig.title),
73
81
  language_code=hugo_data.get('language_code', HugoConfig.language_code),
82
+ default_theme=hugo_data.get('default_theme', HugoConfig.default_theme),
74
83
  )
75
84
 
76
85
  return Config(output_dir=output_dir, auth=auth, sources=sources, hugo=hugo)
@@ -4,6 +4,7 @@
4
4
  import hashlib
5
5
  import json
6
6
  import re
7
+ import threading
7
8
  import time
8
9
  from abc import ABC, abstractmethod
9
10
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -22,7 +23,6 @@ from .utils import (
22
23
  should_download_asset,
23
24
  get_extension_from_content_type,
24
25
  transliterate,
25
- sanitize_filename,
26
26
  extract_internal_links,
27
27
  )
28
28
 
@@ -61,7 +61,9 @@ def retry_request(
61
61
  time.sleep(delay)
62
62
  delay = min(delay * backoff_factor, max_delay)
63
63
 
64
- raise last_exception
64
+ if last_exception:
65
+ raise last_exception
66
+ raise Exception("Max retries exceeded")
65
67
 
66
68
 
67
69
  @dataclass
@@ -135,6 +137,7 @@ class BaseDownloader(ABC):
135
137
  def download_single(self, post_id: str):
136
138
  """Скачивает один пост по ID."""
137
139
  print(f"[{self.PLATFORM}] Скачивание поста {post_id}...")
140
+ self._create_index_files() # Создаем индексы, чтобы не было "Boosties"
138
141
  post = self.fetch_post(post_id)
139
142
  if post:
140
143
  self._save_post(post)
@@ -245,15 +248,16 @@ class BaseDownloader(ABC):
245
248
  Скачивает assets параллельно.
246
249
  Возвращает маппинг {original_url: local_filename}.
247
250
  """
248
- asset_map = {}
251
+ asset_map: dict[str, str] = {}
249
252
  used_filenames: set[str] = set()
253
+ used_lock = threading.Lock()
250
254
 
251
255
  def download_one(asset: dict) -> tuple[str, str | None]:
252
256
  url = asset["url"]
253
257
  try:
254
- # Предварительная проверка только по расширению (если есть)
258
+ # Предварительная проверка (если расширение есть)
255
259
  ext = Path(urlparse(url).path).suffix.lower()
256
- if ext and ext not in ALLOWED_EXTENSIONS:
260
+ if ext and not should_download_asset(url, None, self.source.asset_types):
257
261
  return url, None
258
262
 
259
263
  def do_request():
@@ -266,11 +270,24 @@ class BaseDownloader(ABC):
266
270
  content_type = response.headers.get('Content-Type', '')
267
271
 
268
272
  # Полная проверка после получения Content-Type
269
- if not should_download_asset(url, content_type):
273
+ if not should_download_asset(url, content_type, self.source.asset_types):
270
274
  return url, None
271
275
 
272
- filename = self._make_asset_filename(url, content_type, asset.get('alt'))
273
- filepath = assets_dir / filename
276
+ filename_base = self._make_asset_filename(url, content_type, asset.get('alt'))
277
+
278
+ with used_lock:
279
+ filename = filename_base
280
+ filepath = assets_dir / filename
281
+ if filename in used_filenames or filepath.exists():
282
+ filename = self._deduplicate_filename(filename, url)
283
+ filepath = assets_dir / filename
284
+
285
+ # На всякий случай добиваемся уникальности в рамках сессии
286
+ while filename in used_filenames or filepath.exists():
287
+ filename = self._deduplicate_filename(filename, url + filename)
288
+ filepath = assets_dir / filename
289
+
290
+ used_filenames.add(filename)
274
291
 
275
292
  if not filepath.exists():
276
293
  with open(filepath, 'wb') as f:
@@ -287,10 +304,6 @@ class BaseDownloader(ABC):
287
304
  for future in as_completed(futures):
288
305
  url, filename = future.result()
289
306
  if filename:
290
- # Дедупликация имён файлов
291
- if filename in used_filenames:
292
- filename = self._deduplicate_filename(filename, url)
293
- used_filenames.add(filename)
294
307
  asset_map[url] = filename
295
308
 
296
309
  return asset_map
@@ -348,7 +361,11 @@ class BaseDownloader(ABC):
348
361
 
349
362
  original_body = body
350
363
 
351
- for full_url, platform, post_id in extract_internal_links(body):
364
+ for full_url, platform, author, post_id in extract_internal_links(body):
365
+ if platform != self.PLATFORM:
366
+ continue
367
+ if author != self.source.author:
368
+ continue
352
369
  if post_id in id_to_slug:
353
370
  body = body.replace(full_url, f"../{id_to_slug[post_id]}/")
354
371
 
@@ -3,6 +3,7 @@
3
3
 
4
4
  import json
5
5
  import re
6
+
6
7
  from urllib.parse import urljoin
7
8
 
8
9
  import requests
@@ -150,17 +151,23 @@ class SponsorDownloader(BaseDownloader):
150
151
 
151
152
  def _parse_post(self, raw_data: dict) -> Post:
152
153
  """Парсит сырые данные API в Post."""
153
- post_id = str(raw_data['post_id'])
154
- title = raw_data.get('post_title', 'Без названия')
155
- post_date = raw_data.get('post_date', '')
154
+ post_id = str(raw_data.get('post_id') or raw_data.get('id'))
155
+ title = raw_data.get('post_title') or raw_data.get('title') or 'Без названия'
156
+ post_date = raw_data.get('post_date') or raw_data.get('date') or ''
156
157
 
157
158
  # URL поста
158
- post_url = raw_data.get('post_url', '')
159
+ post_url = raw_data.get('post_url') or f"/{self.source.author}/{post_id}/"
159
160
  if post_url and not post_url.startswith('http'):
160
161
  post_url = f"https://sponsr.ru{post_url}"
161
162
 
162
163
  # HTML контент
163
- content_html = raw_data.get('post_text', '')
164
+ content_obj = raw_data.get('post_text') or raw_data.get('text')
165
+ if isinstance(content_obj, dict):
166
+ content_html = content_obj.get('text', '')
167
+ elif isinstance(content_obj, str):
168
+ content_html = content_obj
169
+ else:
170
+ content_html = ''
164
171
 
165
172
  # Теги
166
173
  tags = raw_data.get('tags', [])
@@ -253,5 +260,89 @@ class SponsorDownloader(BaseDownloader):
253
260
 
254
261
  markdown = h2t.handle(html)
255
262
 
263
+ # Удаляем bidi-маркеры, которые ломают пробелы рядом с текстом
264
+ markdown = re.sub(r'[\u200e\u200f\u202a-\u202e\u2066-\u2069]', '', markdown)
265
+
266
+ # Нормализуем неразрывные пробелы
267
+ markdown = re.sub(r'[\u00a0\u202f]', ' ', markdown)
268
+
269
+ # Склеиваем разорванный курсив вокруг bold-italic (вложенные em/strong)
270
+ prev = None
271
+ pattern = re.compile(
272
+ r'_(?P<left>[^_\n]+?)_(?P<sep1>[ \t]*)\*\*(?P<space1>[ \t]*)_(?P<bold>[^_\n]+?)_'
273
+ r'(?P<space2>[ \t]*)\*\*(?P<sep2>[ \t]*)_(?P<right>[^_\n]+?)_'
274
+ )
275
+ while prev != markdown:
276
+ prev = markdown
277
+ markdown = pattern.sub(
278
+ r'_\g<left>\g<sep1>\g<space1>**\g<bold>**\g<space2>\g<sep2>\g<right>_',
279
+ markdown,
280
+ )
281
+
282
+ prev = None
283
+ pattern = re.compile(
284
+ r'\*(?P<left>[^*\n]+?)\*(?P<sep1>[ \t]*)\*\*(?P<space1>[ \t]*)\*(?P<bold>[^*\n]+?)\*'
285
+ r'(?P<space2>[ \t]*)\*\*(?P<sep2>[ \t]*)\*(?P<right>[^*\n]+?)\*'
286
+ )
287
+ while prev != markdown:
288
+ prev = markdown
289
+ markdown = pattern.sub(
290
+ r'*\g<left>\g<sep1>\g<space1>**\g<bold>**\g<space2>\g<sep2>\g<right>*',
291
+ markdown,
292
+ )
293
+
294
+ # Склеиваем вложенные em/strong в жирный курсив
295
+ markdown = re.sub(r'\*\*\s*_(.+?)_\s*\*\*', r'***\1***', markdown)
296
+ markdown = re.sub(r'_\s*\*\*(.+?)\*\*\s*_', r'***\1***', markdown)
297
+ markdown = re.sub(r'\*\*\s*\*(.+?)\*\s*\*\*', r'***\1***', markdown)
298
+ markdown = re.sub(r'\*\s*\*\*(.+?)\*\*\s*\*', r'***\1***', markdown)
299
+
300
+ # Убираем лишние пробелы, добавленные html2text рядом с Unicode-кавычками
301
+ # Открывающие: « „ “ ‘
302
+ markdown = re.sub(r'([\u00ab\u201e\u201c\u2018])\s+', r'\1', markdown)
303
+ # Закрывающие: » ” ’
304
+ markdown = re.sub(r'\s+([\u00bb\u201d\u2019])', r'\1', markdown)
305
+
306
+ # Убираем пробелы внутри **bold** (особенно при вложенных em/strong)
307
+ markdown = re.sub(r'\*\*[ \t]+([^*\n]+?)[ \t]*\*\*', r'**\1**', markdown)
308
+ markdown = re.sub(r'\*\*[ \t]*([^*\n]+?)[ \t]+\*\*', r'**\1**', markdown)
309
+
310
+ # Убираем пробелы внутри ***bold-italic***
311
+ markdown = re.sub(r'\*\*\*[ \t]+([^*\n]+?)[ \t]*\*\*\*', r'***\1***', markdown)
312
+ markdown = re.sub(r'\*\*\*[ \t]*([^*\n]+?)[ \t]+\*\*\*', r'***\1***', markdown)
313
+
314
+ # Восстанавливаем пробелы вокруг **...** и ***...***, если они потерялись
315
+ def _fix_emphasis_spacing(text: str, pattern: re.Pattern) -> str:
316
+ parts = []
317
+ last = 0
318
+ for match in pattern.finditer(text):
319
+ start, end = match.span()
320
+ parts.append(text[last:start])
321
+
322
+ if start > 0:
323
+ prev = text[start - 1]
324
+ if prev.isalnum() and not prev.isspace():
325
+ if not (parts and parts[-1].endswith(' ')):
326
+ parts.append(' ')
327
+
328
+ parts.append(text[start:end])
329
+
330
+ if end < len(text):
331
+ next_char = text[end]
332
+ if next_char.isalnum() and not next_char.isspace():
333
+ parts.append(' ')
334
+
335
+ last = end
336
+
337
+ parts.append(text[last:])
338
+ return ''.join(parts)
339
+
340
+ markdown = _fix_emphasis_spacing(markdown, re.compile(r'\*\*\*.+?\*\*\*'))
341
+ markdown = _fix_emphasis_spacing(
342
+ markdown,
343
+ re.compile(r'(?<!\*)\*\*(?!\*).+?(?<!\*)\*\*(?!\*)'),
344
+ )
345
+
346
+
256
347
  # Добавляем заголовок
257
348
  return f"# {post.title}\n\n{markdown}"
@@ -6,20 +6,32 @@ from pathlib import Path
6
6
  from urllib.parse import urlparse
7
7
  from slugify import slugify
8
8
 
9
- # Белый список расширений
10
- ALLOWED_EXTENSIONS = {
11
- '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg',
12
- '.mp4', '.webm', '.mov', '.mkv', '.avi',
13
- '.mp3', '.wav', '.flac', '.ogg',
14
- '.pdf',
9
+ # Типы ассетов и их расширения
10
+ ASSET_TYPES = {
11
+ 'image': {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'},
12
+ 'video': {'.mp4', '.webm', '.mov', '.mkv', '.avi'},
13
+ 'audio': {'.mp3', '.wav', '.flac', '.ogg'},
14
+ 'document': {'.pdf'},
15
15
  }
16
16
 
17
- # Допустимые Content-Type
18
- ALLOWED_CONTENT_TYPES = {'image/', 'video/', 'audio/', 'application/pdf'}
17
+ # Глобальный список разрешенных расширений
18
+ ALLOWED_EXTENSIONS = set().union(*ASSET_TYPES.values())
19
+
20
+ # Префиксы Content-Type для категорий
21
+ CONTENT_TYPE_MAP = {
22
+ 'image': ['image/'],
23
+ 'video': ['video/'],
24
+ 'audio': ['audio/'],
25
+ 'document': ['application/pdf'],
26
+ }
19
27
 
20
28
  # Паттерны для внутренних ссылок
21
- SPONSR_LINK_PATTERN = re.compile(r'https?://sponsr\.ru/([^/]+)/(\d+)(?:/[^\s\)\]"\'<>]*)?')
22
- BOOSTY_LINK_PATTERN = re.compile(r'https?://boosty\.to/([^/]+)/posts/([a-f0-9-]+)(?:[^\s\)\]"\'<>]*)?')
29
+ SPONSR_LINK_PATTERN = re.compile(
30
+ r'https?://sponsr\.ru/(?P<author>[^/]+)/(?P<post_id>\d+)(?:/[^\s\)\]"\'<>]*)?'
31
+ )
32
+ BOOSTY_LINK_PATTERN = re.compile(
33
+ r'https?://boosty\.to/(?P<author>[^/]+)/posts/(?P<post_id>[a-f0-9-]+)(?:[^\s\)\]"\'<>]*)?'
34
+ )
23
35
 
24
36
 
25
37
  def transliterate(text: str) -> str:
@@ -60,21 +72,50 @@ def is_post_url(text: str) -> bool:
60
72
  return False
61
73
 
62
74
 
63
- def should_download_asset(url: str, content_type: str | None = None) -> bool:
75
+ def should_download_asset(
76
+ url: str,
77
+ content_type: str | None = None,
78
+ allowed_types: list[str] | None = None
79
+ ) -> bool:
64
80
  """
65
81
  Проверяет, нужно ли скачивать файл.
66
82
 
67
83
  Args:
68
84
  url: URL файла
69
85
  content_type: Content-Type из заголовков ответа (опционально)
86
+ allowed_types: Список разрешенных типов (image, video, audio, document).
87
+ Если None или пустой — разрешено всё из ALLOWED_EXTENSIONS.
70
88
  """
71
89
  ext = Path(urlparse(url).path).suffix.lower()
72
90
 
91
+ # Если типы не указаны, используем глобальный фильтр
92
+ if not allowed_types:
93
+ if ext:
94
+ return ext in ALLOWED_EXTENSIONS
95
+
96
+ # Fallback для content-type (старое поведение)
97
+ if content_type:
98
+ basic_types = ['image/', 'video/', 'audio/', 'application/pdf']
99
+ return any(ct in content_type for ct in basic_types)
100
+
101
+ return False
102
+
103
+ # Если типы указаны, проверяем строго по ним
104
+
105
+ # 1. Проверка по расширению
73
106
  if ext:
74
- return ext in ALLOWED_EXTENSIONS
107
+ for type_name in allowed_types:
108
+ if ext in ASSET_TYPES.get(type_name, set()):
109
+ return True
110
+ # Если расширение есть, но не совпало ни с одним разрешенным типом — запрещаем
111
+ return False
75
112
 
113
+ # 2. Проверка по Content-Type (если нет расширения)
76
114
  if content_type:
77
- return any(ct in content_type for ct in ALLOWED_CONTENT_TYPES)
115
+ for type_name in allowed_types:
116
+ prefixes = CONTENT_TYPE_MAP.get(type_name, [])
117
+ if any(prefix in content_type for prefix in prefixes):
118
+ return True
78
119
 
79
120
  return False
80
121
 
@@ -107,17 +148,17 @@ def sanitize_filename(name: str) -> str:
107
148
  return name or 'unnamed'
108
149
 
109
150
 
110
- def extract_internal_links(content: str) -> list[tuple[str, str, str]]:
151
+ def extract_internal_links(content: str) -> list[tuple[str, str, str, str]]:
111
152
  """
112
153
  Извлекает внутренние ссылки из контента.
113
- Возвращает [(full_url, platform, post_id), ...]
154
+ Возвращает [(full_url, platform, author, post_id), ...]
114
155
  """
115
156
  links = []
116
157
 
117
158
  for match in SPONSR_LINK_PATTERN.finditer(content):
118
- links.append((match.group(0), 'sponsr', match.group(2)))
159
+ links.append((match.group(0), 'sponsr', match.group('author'), match.group('post_id')))
119
160
 
120
161
  for match in BOOSTY_LINK_PATTERN.finditer(content):
121
- links.append((match.group(0), 'boosty', match.group(2)))
162
+ links.append((match.group(0), 'boosty', match.group('author'), match.group('post_id')))
122
163
 
123
164
  return links
@@ -0,0 +1,143 @@
1
+ import tempfile
2
+ import unittest
3
+ from pathlib import Path
4
+ from typing import cast
5
+
6
+ from src.config import Auth, Config, Source
7
+ from src.database import Database
8
+ from src.downloader import BaseDownloader
9
+
10
+
11
+ class _FakeResponse:
12
+ def __init__(self, content_type: str, body: bytes):
13
+ self.headers = {"Content-Type": content_type}
14
+ self._body = body
15
+
16
+ def raise_for_status(self):
17
+ return None
18
+
19
+ def iter_content(self, chunk_size: int = 8192):
20
+ # Yield at least one chunk to trigger file write.
21
+ yield self._body
22
+
23
+
24
+ class _DummyDB:
25
+ pass
26
+
27
+
28
+ class _DummyDownloader(BaseDownloader):
29
+ PLATFORM = "dummy"
30
+ MAX_WORKERS = 2
31
+
32
+ def _setup_session(self):
33
+ # Tests patch session.get directly.
34
+ return None
35
+
36
+ def fetch_posts_list(self):
37
+ raise NotImplementedError
38
+
39
+ def fetch_post(self, post_id: str):
40
+ raise NotImplementedError
41
+
42
+ def _parse_post(self, raw_data: dict):
43
+ raise NotImplementedError
44
+
45
+ def _to_markdown(self, post, asset_map):
46
+ raise NotImplementedError
47
+
48
+
49
+ class AssetDedupTests(unittest.TestCase):
50
+ def test_download_assets_deduplicates_colliding_names(self):
51
+ with tempfile.TemporaryDirectory() as tmp:
52
+ tmp_path = Path(tmp)
53
+ assets_dir = tmp_path / "assets"
54
+ assets_dir.mkdir(parents=True, exist_ok=True)
55
+
56
+ config = Config(output_dir=tmp_path, auth=Auth())
57
+ source = Source(platform="sponsr", author="author", download_assets=True)
58
+ dl = _DummyDownloader(config, source, cast(Database, _DummyDB()))
59
+
60
+ def fake_get(url: str, stream: bool = True, timeout=None):
61
+ # URLs intentionally do not contain extensions.
62
+ return _FakeResponse("image/jpeg", body=(url + "\n").encode("ascii"))
63
+
64
+ dl.session.get = fake_get # type: ignore[method-assign]
65
+
66
+ assets = [
67
+ {"url": "https://example.test/media/1", "alt": "same name"},
68
+ {"url": "https://example.test/media/2", "alt": "same name"},
69
+ ]
70
+
71
+ asset_map = dl._download_assets(assets, assets_dir)
72
+
73
+ self.assertEqual(set(asset_map.keys()), {a["url"] for a in assets})
74
+
75
+ filenames = list(asset_map.values())
76
+ self.assertEqual(len(filenames), 2)
77
+ self.assertNotEqual(filenames[0], filenames[1])
78
+
79
+ for fn in filenames:
80
+ self.assertTrue((assets_dir / fn).exists(), msg=f"missing file: {fn}")
81
+
82
+ def test_download_assets_deduplicates_when_file_exists(self):
83
+ with tempfile.TemporaryDirectory() as tmp:
84
+ tmp_path = Path(tmp)
85
+ assets_dir = tmp_path / "assets"
86
+ assets_dir.mkdir(parents=True, exist_ok=True)
87
+
88
+ config = Config(output_dir=tmp_path, auth=Auth())
89
+ source = Source(platform="sponsr", author="author", download_assets=True)
90
+ dl = _DummyDownloader(config, source, cast(Database, _DummyDB()))
91
+
92
+ # Pre-create a file with the expected base name.
93
+ base = dl._make_asset_filename(
94
+ "https://example.test/media/1",
95
+ "image/jpeg",
96
+ "same name",
97
+ )
98
+ (assets_dir / base).write_bytes(b"existing")
99
+
100
+ def fake_get(url: str, stream: bool = True, timeout=None):
101
+ return _FakeResponse("image/jpeg", body=b"downloaded")
102
+
103
+ dl.session.get = fake_get # type: ignore[method-assign]
104
+
105
+ assets = [{"url": "https://example.test/media/1", "alt": "same name"}]
106
+ asset_map = dl._download_assets(assets, assets_dir)
107
+
108
+ self.assertIn("https://example.test/media/1", asset_map)
109
+ self.assertNotEqual(asset_map["https://example.test/media/1"], base)
110
+ self.assertTrue((assets_dir / asset_map["https://example.test/media/1"]).exists())
111
+
112
+ def test_download_assets_keeps_unique_names_under_parallelism(self):
113
+ with tempfile.TemporaryDirectory() as tmp:
114
+ tmp_path = Path(tmp)
115
+ assets_dir = tmp_path / "assets"
116
+ assets_dir.mkdir(parents=True, exist_ok=True)
117
+
118
+ config = Config(output_dir=tmp_path, auth=Auth())
119
+ source = Source(platform="sponsr", author="author", download_assets=True)
120
+ dl = _DummyDownloader(config, source, cast(Database, _DummyDB()))
121
+ dl.MAX_WORKERS = 5
122
+
123
+ def fake_get(url: str, stream: bool = True, timeout=None):
124
+ return _FakeResponse("image/jpeg", body=(url + "\n").encode("ascii"))
125
+
126
+ dl.session.get = fake_get # type: ignore[method-assign]
127
+
128
+ assets = [
129
+ {"url": f"https://example.test/media/{i}", "alt": "same name"}
130
+ for i in range(20)
131
+ ]
132
+
133
+ asset_map = dl._download_assets(assets, assets_dir)
134
+
135
+ self.assertEqual(len(asset_map), 20)
136
+ filenames = list(asset_map.values())
137
+ self.assertEqual(len(set(filenames)), 20)
138
+ for fn in filenames:
139
+ self.assertTrue((assets_dir / fn).exists(), msg=f"missing file: {fn}")
140
+
141
+
142
+ if __name__ == "__main__":
143
+ unittest.main()
File without changes
File without changes