article-backup 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of article-backup might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: article-backup
3
- Version: 0.1.0
3
+ Version: 0.2.0
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://badge.fury.io/py/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,24 @@ 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)
45
50
  - Исправление внутренних ссылок между статьями
46
- - Интеграция с Hugo для просмотра в браузере
51
+ - Интеграция с Hugo для просмотра в браузере (поддержка тем, улучшенная типографика)
47
52
  - SQLite-индекс для быстрого поиска
48
53
 
49
54
  ## Установка
50
55
 
51
56
  Требуется **Python 3.10+**
52
57
 
58
+ ### Вариант 1: Через pip (рекомендуется)
59
+
60
+ ```bash
61
+ pip install article-backup
62
+ ```
63
+
64
+ ### Вариант 2: Из исходников
65
+
53
66
  ```bash
54
67
  git clone https://github.com/strannick-ru/article-backup.git
55
68
  cd article-backup
@@ -86,7 +99,8 @@ sources:
86
99
  - platform: sponsr
87
100
  author: pushkin
88
101
  display_name: "Пушкин. Проза"
89
-
102
+ asset_types: ["image", "document"] # Скачивать только картинки и документы
103
+
90
104
  - platform: boosty
91
105
  author: lermontov
92
106
  display_name: "Лермонтов. Стихи"
@@ -120,44 +134,56 @@ console.log("Cookie:\n" + cookie + "\n\nAuthorization:\nBearer " + auth.accessTo
120
134
  ### Синхронизация всех авторов
121
135
 
122
136
  ```bash
137
+ # Если установлено через pip
138
+ article-backup
139
+
140
+ # Или из исходников
123
141
  python backup.py
124
142
  ```
125
143
 
126
144
  ### Скачать один пост по URL
127
145
 
128
146
  ```bash
129
- python backup.py "https://sponsr.ru/author/12345/post-title/"
130
- python backup.py "https://boosty.to/author/posts/uuid"
147
+ article-backup "https://sponsr.ru/author/12345/post-title/"
148
+ article-backup "https://boosty.to/author/posts/uuid"
131
149
  ```
132
150
 
133
151
  ### Указать другой конфиг
134
152
 
135
153
  ```bash
136
- python backup.py -c /path/to/config.yaml
154
+ article-backup -c /path/to/config.yaml
137
155
  ```
138
156
 
139
157
  ## Docker
140
158
 
141
159
  Для серверов с устаревшим Python можно использовать Docker.
142
160
 
143
- ```bash
144
- # Сборка образа
145
- docker compose build
161
+ Для удобства используйте скрипт `run-docker.sh`, который автоматически подхватывает `output_dir` из вашего `config.yaml` и монтирует правильный volume.
146
162
 
147
- # Синхронизация всех авторов
148
- docker compose run --rm backup
163
+ ```bash
164
+ # Синхронизация + сборка сайта (рекомендуемый способ)
165
+ ./run-docker.sh
149
166
 
150
167
  # Скачать один пост
151
- docker compose run --rm backup "https://sponsr.ru/author/123/"
168
+ ./run-docker.sh "https://sponsr.ru/author/123/"
152
169
 
153
- # Сборка Hugo-сайта
154
- docker compose run --rm hugo
170
+ # Только пересборка сайта
171
+ ./run-docker.sh hugo
155
172
 
156
- # Полная синхронизация (backup + hugo)
157
- docker compose run --rm backup && docker compose run --rm hugo
173
+ # Пересборка контейнеров
174
+ ./run-docker.sh build
175
+ ```
176
+
177
+ ### Ручной запуск (Advanced)
178
+
179
+ Если вы не хотите использовать скрипт, можно запускать через `docker compose`, но нужно вручную указывать путь к бэкапам, если он отличается от `./backup`.
158
180
 
159
- # Пересборка после изменений кода
160
- docker compose build --no-cache
181
+ ```bash
182
+ # Если output_dir в конфиге = ./backup
183
+ docker compose run --rm backup
184
+
185
+ # Если output_dir другой
186
+ HOST_BACKUP_DIR=/path/to/data docker compose run --rm backup
161
187
  ```
162
188
 
163
189
  ### Cron
@@ -211,8 +237,11 @@ hugo:
211
237
  base_url: "https://example.com/" # URL сайта для production
212
238
  title: "Мой архив статей" # Заголовок сайта
213
239
  language_code: "ru" # Язык контента
240
+ default_theme: "sepia" # Тема по умолчанию: light, dark, sepia, gruvbox, everforest
214
241
  ```
215
242
 
243
+ Сайт поддерживает переключение тем "на лету" (кнопки в углу экрана). Выбор пользователя сохраняется в браузере.
244
+
216
245
  Если секция `hugo:` не указана, используются значения по умолчанию (`http://localhost:1313/`).
217
246
 
218
247
  ### RSS-ленты
@@ -0,0 +1,14 @@
1
+ backup.py,sha256=6I5hGCY7UFMcH_GC437rcf3Pf5HI38rJdb2V0TKwcT8,6203
2
+ article_backup-0.2.0.dist-info/licenses/LICENSE,sha256=H7HKbB-BFnuJAfBBasE5FntYOdqzSGckwx6wxMlnfSA,10173
3
+ src/__init__.py,sha256=uV3ILKFkjpxUZ3w5HS9i5dxnL-gGIMFBzT96lSOETXM,14
4
+ src/boosty.py,sha256=oozGgcCqCsLup3LNWylb89M9AETudDHEQhExu2JjsDw,9007
5
+ src/config.py,sha256=xmAFMHv1ya2lnyFPZe9hDnRfI5MooIl2aBr2BKAcpGo,3373
6
+ src/database.py,sha256=TSbdI_8OgVtUNT_K5Wxlq0wLq3uti07qKsSuRqY0aoU,5763
7
+ src/downloader.py,sha256=ahmNSPDt5wav-tHlyEK7wmizgnB-kQNsH3sZ3vGF6X0,13493
8
+ src/sponsr.py,sha256=3770Zy_L3CmaJRdyy-rNgQPhPuZpiry9VoCVV_zprts,9896
9
+ src/utils.py,sha256=q_LZ_a-UStZCNBde027FOjDpTCpCtPii54ou48IWeJY,5594
10
+ article_backup-0.2.0.dist-info/METADATA,sha256=MMn8cycysh-0vpJZfAbvlBwEvpCGi4tksGQmOsGv53M,10208
11
+ article_backup-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
+ article_backup-0.2.0.dist-info/entry_points.txt,sha256=Nz5rb3V5-Vkm0gJ18e1eOb6fY6f1GeoHiAGGQHQgHrQ,47
13
+ article_backup-0.2.0.dist-info/top_level.txt,sha256=9Iu_UMhupxvQh2cehV6bzaqPeTDrDCakTE82bJG5WxI,11
14
+ article_backup-0.2.0.dist-info/RECORD,,
backup.py CHANGED
@@ -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
 
src/boosty.py CHANGED
@@ -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,
src/config.py CHANGED
@@ -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)
src/downloader.py CHANGED
@@ -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)
@@ -251,9 +254,9 @@ class BaseDownloader(ABC):
251
254
  def download_one(asset: dict) -> tuple[str, str | None]:
252
255
  url = asset["url"]
253
256
  try:
254
- # Предварительная проверка только по расширению (если есть)
257
+ # Предварительная проверка (если расширение есть)
255
258
  ext = Path(urlparse(url).path).suffix.lower()
256
- if ext and ext not in ALLOWED_EXTENSIONS:
259
+ if ext and not should_download_asset(url, None, self.source.asset_types):
257
260
  return url, None
258
261
 
259
262
  def do_request():
@@ -266,7 +269,7 @@ class BaseDownloader(ABC):
266
269
  content_type = response.headers.get('Content-Type', '')
267
270
 
268
271
  # Полная проверка после получения Content-Type
269
- if not should_download_asset(url, content_type):
272
+ if not should_download_asset(url, content_type, self.source.asset_types):
270
273
  return url, None
271
274
 
272
275
  filename = self._make_asset_filename(url, content_type, asset.get('alt'))
src/sponsr.py CHANGED
@@ -150,17 +150,23 @@ class SponsorDownloader(BaseDownloader):
150
150
 
151
151
  def _parse_post(self, raw_data: dict) -> Post:
152
152
  """Парсит сырые данные 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', '')
153
+ post_id = str(raw_data.get('post_id') or raw_data.get('id'))
154
+ title = raw_data.get('post_title') or raw_data.get('title') or 'Без названия'
155
+ post_date = raw_data.get('post_date') or raw_data.get('date') or ''
156
156
 
157
157
  # URL поста
158
- post_url = raw_data.get('post_url', '')
158
+ post_url = raw_data.get('post_url') or f"/{self.source.author}/{post_id}/"
159
159
  if post_url and not post_url.startswith('http'):
160
160
  post_url = f"https://sponsr.ru{post_url}"
161
161
 
162
162
  # HTML контент
163
- content_html = raw_data.get('post_text', '')
163
+ content_obj = raw_data.get('post_text') or raw_data.get('text')
164
+ if isinstance(content_obj, dict):
165
+ content_html = content_obj.get('text', '')
166
+ elif isinstance(content_obj, str):
167
+ content_html = content_obj
168
+ else:
169
+ content_html = ''
164
170
 
165
171
  # Теги
166
172
  tags = raw_data.get('tags', [])
src/utils.py CHANGED
@@ -6,16 +6,24 @@ 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
29
  SPONSR_LINK_PATTERN = re.compile(r'https?://sponsr\.ru/([^/]+)/(\d+)(?:/[^\s\)\]"\'<>]*)?')
@@ -60,21 +68,50 @@ def is_post_url(text: str) -> bool:
60
68
  return False
61
69
 
62
70
 
63
- def should_download_asset(url: str, content_type: str | None = None) -> bool:
71
+ def should_download_asset(
72
+ url: str,
73
+ content_type: str | None = None,
74
+ allowed_types: list[str] | None = None
75
+ ) -> bool:
64
76
  """
65
77
  Проверяет, нужно ли скачивать файл.
66
78
 
67
79
  Args:
68
80
  url: URL файла
69
81
  content_type: Content-Type из заголовков ответа (опционально)
82
+ allowed_types: Список разрешенных типов (image, video, audio, document).
83
+ Если None или пустой — разрешено всё из ALLOWED_EXTENSIONS.
70
84
  """
71
85
  ext = Path(urlparse(url).path).suffix.lower()
72
86
 
87
+ # Если типы не указаны, используем глобальный фильтр
88
+ if not allowed_types:
89
+ if ext:
90
+ return ext in ALLOWED_EXTENSIONS
91
+
92
+ # Fallback для content-type (старое поведение)
93
+ if content_type:
94
+ basic_types = ['image/', 'video/', 'audio/', 'application/pdf']
95
+ return any(ct in content_type for ct in basic_types)
96
+
97
+ return False
98
+
99
+ # Если типы указаны, проверяем строго по ним
100
+
101
+ # 1. Проверка по расширению
73
102
  if ext:
74
- return ext in ALLOWED_EXTENSIONS
103
+ for type_name in allowed_types:
104
+ if ext in ASSET_TYPES.get(type_name, set()):
105
+ return True
106
+ # Если расширение есть, но не совпало ни с одним разрешенным типом — запрещаем
107
+ return False
75
108
 
109
+ # 2. Проверка по Content-Type (если нет расширения)
76
110
  if content_type:
77
- return any(ct in content_type for ct in ALLOWED_CONTENT_TYPES)
111
+ for type_name in allowed_types:
112
+ prefixes = CONTENT_TYPE_MAP.get(type_name, [])
113
+ if any(prefix in content_type for prefix in prefixes):
114
+ return True
78
115
 
79
116
  return False
80
117
 
@@ -1,14 +0,0 @@
1
- backup.py,sha256=StDTlMZmcUYoPCz6c9ez_NYPtO_LgiIYIVJ5HPWqrgw,5420
2
- article_backup-0.1.0.dist-info/licenses/LICENSE,sha256=H7HKbB-BFnuJAfBBasE5FntYOdqzSGckwx6wxMlnfSA,10173
3
- src/__init__.py,sha256=uV3ILKFkjpxUZ3w5HS9i5dxnL-gGIMFBzT96lSOETXM,14
4
- src/boosty.py,sha256=98UuDfoCIXdG6gdrifNlL1hF0iqlknlgceNZ3JxzSSI,8981
5
- src/config.py,sha256=XQQM4XudXPxQwd8rMMQA3t25PcgYAHjYF3pIF-GDIs0,3025
6
- src/database.py,sha256=TSbdI_8OgVtUNT_K5Wxlq0wLq3uti07qKsSuRqY0aoU,5763
7
- src/downloader.py,sha256=hPwdV71__orw60tvjxcPrfvC2OKQFVyYOyV2ttnDXcw,13278
8
- src/sponsr.py,sha256=awfliPtZ3er7381ZgOCtH3YexlBsFSCbSBN3cLHr4bw,9540
9
- src/utils.py,sha256=YQBoOghLqxWQSNpMaW5hycDs77ck_KuA3Qd4upRwIJQ,3988
10
- article_backup-0.1.0.dist-info/METADATA,sha256=wNVdh6jUftUZ-V99hdXzlDPT4dIiptTBAX51pGGABSs,8450
11
- article_backup-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
- article_backup-0.1.0.dist-info/entry_points.txt,sha256=Nz5rb3V5-Vkm0gJ18e1eOb6fY6f1GeoHiAGGQHQgHrQ,47
13
- article_backup-0.1.0.dist-info/top_level.txt,sha256=9Iu_UMhupxvQh2cehV6bzaqPeTDrDCakTE82bJG5WxI,11
14
- article_backup-0.1.0.dist-info/RECORD,,