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.
- {article_backup-0.1.0 → article_backup-0.2.2}/PKG-INFO +58 -18
- {article_backup-0.1.0 → article_backup-0.2.2}/README.md +57 -17
- {article_backup-0.1.0 → article_backup-0.2.2}/article_backup.egg-info/PKG-INFO +58 -18
- {article_backup-0.1.0 → article_backup-0.2.2}/article_backup.egg-info/SOURCES.txt +2 -1
- {article_backup-0.1.0 → article_backup-0.2.2}/backup.py +19 -2
- {article_backup-0.1.0 → article_backup-0.2.2}/pyproject.toml +1 -1
- {article_backup-0.1.0 → article_backup-0.2.2}/src/boosty.py +1 -1
- {article_backup-0.1.0 → article_backup-0.2.2}/src/config.py +10 -1
- {article_backup-0.1.0 → article_backup-0.2.2}/src/downloader.py +30 -13
- {article_backup-0.1.0 → article_backup-0.2.2}/src/sponsr.py +96 -5
- {article_backup-0.1.0 → article_backup-0.2.2}/src/utils.py +58 -17
- article_backup-0.2.2/tests/test_asset_dedup.py +143 -0
- {article_backup-0.1.0 → article_backup-0.2.2}/LICENSE +0 -0
- {article_backup-0.1.0 → article_backup-0.2.2}/article_backup.egg-info/dependency_links.txt +0 -0
- {article_backup-0.1.0 → article_backup-0.2.2}/article_backup.egg-info/entry_points.txt +0 -0
- {article_backup-0.1.0 → article_backup-0.2.2}/article_backup.egg-info/requires.txt +0 -0
- {article_backup-0.1.0 → article_backup-0.2.2}/article_backup.egg-info/top_level.txt +0 -0
- {article_backup-0.1.0 → article_backup-0.2.2}/setup.cfg +0 -0
- {article_backup-0.1.0 → article_backup-0.2.2}/src/__init__.py +0 -0
- {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.
|
|
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
|
+
[](https://pypi.org/project/article-backup/)
|
|
35
|
+
[](https://www.python.org/downloads/)
|
|
36
|
+
[](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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
# Сборка образа
|
|
145
|
-
docker compose build
|
|
172
|
+
Для удобства используйте скрипт `run-docker.sh`, который автоматически подхватывает `output_dir` из вашего `config.yaml` и монтирует правильный volume.
|
|
146
173
|
|
|
147
|
-
|
|
148
|
-
|
|
174
|
+
```bash
|
|
175
|
+
# Синхронизация + сборка сайта (рекомендуемый способ)
|
|
176
|
+
./run-docker.sh
|
|
149
177
|
|
|
150
178
|
# Скачать один пост
|
|
151
|
-
docker
|
|
179
|
+
./run-docker.sh "https://sponsr.ru/author/123/"
|
|
152
180
|
|
|
153
|
-
#
|
|
154
|
-
docker
|
|
181
|
+
# Только пересборка сайта
|
|
182
|
+
./run-docker.sh hugo
|
|
155
183
|
|
|
156
|
-
#
|
|
157
|
-
|
|
184
|
+
# Пересборка контейнеров
|
|
185
|
+
./run-docker.sh build
|
|
186
|
+
```
|
|
158
187
|
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
[](https://pypi.org/project/article-backup/)
|
|
4
|
+
[](https://www.python.org/downloads/)
|
|
5
|
+
[](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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
# Сборка образа
|
|
114
|
-
docker compose build
|
|
141
|
+
Для удобства используйте скрипт `run-docker.sh`, который автоматически подхватывает `output_dir` из вашего `config.yaml` и монтирует правильный volume.
|
|
115
142
|
|
|
116
|
-
|
|
117
|
-
|
|
143
|
+
```bash
|
|
144
|
+
# Синхронизация + сборка сайта (рекомендуемый способ)
|
|
145
|
+
./run-docker.sh
|
|
118
146
|
|
|
119
147
|
# Скачать один пост
|
|
120
|
-
docker
|
|
148
|
+
./run-docker.sh "https://sponsr.ru/author/123/"
|
|
121
149
|
|
|
122
|
-
#
|
|
123
|
-
docker
|
|
150
|
+
# Только пересборка сайта
|
|
151
|
+
./run-docker.sh hugo
|
|
124
152
|
|
|
125
|
-
#
|
|
126
|
-
|
|
153
|
+
# Пересборка контейнеров
|
|
154
|
+
./run-docker.sh build
|
|
155
|
+
```
|
|
127
156
|
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
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
|
+
[](https://pypi.org/project/article-backup/)
|
|
35
|
+
[](https://www.python.org/downloads/)
|
|
36
|
+
[](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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
# Сборка образа
|
|
145
|
-
docker compose build
|
|
172
|
+
Для удобства используйте скрипт `run-docker.sh`, который автоматически подхватывает `output_dir` из вашего `config.yaml` и монтирует правильный volume.
|
|
146
173
|
|
|
147
|
-
|
|
148
|
-
|
|
174
|
+
```bash
|
|
175
|
+
# Синхронизация + сборка сайта (рекомендуемый способ)
|
|
176
|
+
./run-docker.sh
|
|
149
177
|
|
|
150
178
|
# Скачать один пост
|
|
151
|
-
docker
|
|
179
|
+
./run-docker.sh "https://sponsr.ru/author/123/"
|
|
152
180
|
|
|
153
|
-
#
|
|
154
|
-
docker
|
|
181
|
+
# Только пересборка сайта
|
|
182
|
+
./run-docker.sh hugo
|
|
155
183
|
|
|
156
|
-
#
|
|
157
|
-
|
|
184
|
+
# Пересборка контейнеров
|
|
185
|
+
./run-docker.sh build
|
|
186
|
+
```
|
|
158
187
|
|
|
159
|
-
|
|
160
|
-
|
|
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-ленты
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
273
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
18
|
-
|
|
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(
|
|
22
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|