article-backup 0.2.3__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.
- article_backup-0.2.3.dist-info/METADATA +315 -0
- article_backup-0.2.3.dist-info/RECORD +14 -0
- article_backup-0.2.3.dist-info/WHEEL +5 -0
- article_backup-0.2.3.dist-info/entry_points.txt +2 -0
- article_backup-0.2.3.dist-info/licenses/LICENSE +177 -0
- article_backup-0.2.3.dist-info/top_level.txt +2 -0
- backup.py +179 -0
- src/__init__.py +1 -0
- src/boosty.py +260 -0
- src/config.py +108 -0
- src/database.py +169 -0
- src/downloader.py +383 -0
- src/sponsr.py +349 -0
- src/utils.py +164 -0
backup.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# backup.py
|
|
3
|
+
"""CLI точка входа для бэкапа статей."""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from src.config import Config, load_config, Source, Platform
|
|
12
|
+
from src.database import Database
|
|
13
|
+
from src.utils import is_post_url, parse_post_url
|
|
14
|
+
from src.sponsr import SponsorDownloader
|
|
15
|
+
from src.boosty import BoostyDownloader
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def generate_hugo_config(config: Config):
|
|
19
|
+
"""Генерирует site/hugo.toml из конфига."""
|
|
20
|
+
hugo_toml = Path('site/hugo.toml')
|
|
21
|
+
if not hugo_toml.parent.exists():
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
content = f'''baseURL = '{config.hugo.base_url}'
|
|
25
|
+
languageCode = '{config.hugo.language_code}'
|
|
26
|
+
title = '{config.hugo.title}'
|
|
27
|
+
relativeURLs = true
|
|
28
|
+
|
|
29
|
+
[params]
|
|
30
|
+
default_theme = '{config.hugo.default_theme}'
|
|
31
|
+
|
|
32
|
+
[markup.goldmark.renderer]
|
|
33
|
+
unsafe = true
|
|
34
|
+
|
|
35
|
+
[taxonomies]
|
|
36
|
+
tag = 'tags'
|
|
37
|
+
|
|
38
|
+
[outputs]
|
|
39
|
+
home = ["HTML"]
|
|
40
|
+
section = ["HTML", "RSS"]
|
|
41
|
+
|
|
42
|
+
[services.rss]
|
|
43
|
+
limit = 50
|
|
44
|
+
'''
|
|
45
|
+
hugo_toml.write_text(content, encoding='utf-8')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ensure_site_content_link(config: Config):
|
|
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
|
+
|
|
56
|
+
site_content = Path('site/content')
|
|
57
|
+
|
|
58
|
+
# Если уже правильный симлинк — ничего не делаем
|
|
59
|
+
if site_content.is_symlink():
|
|
60
|
+
current_target = site_content.resolve()
|
|
61
|
+
expected_target = config.output_dir.resolve()
|
|
62
|
+
if current_target == expected_target:
|
|
63
|
+
return
|
|
64
|
+
# Симлинк на другую директорию — удаляем
|
|
65
|
+
site_content.unlink()
|
|
66
|
+
elif site_content.exists():
|
|
67
|
+
# Это реальная директория — не трогаем
|
|
68
|
+
print(f"Предупреждение: site/content существует и не является симлинком")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# Создаём симлинк
|
|
72
|
+
site_dir = Path('site')
|
|
73
|
+
if site_dir.exists():
|
|
74
|
+
# Относительный путь от site/ к output_dir
|
|
75
|
+
rel_path = os.path.relpath(config.output_dir.resolve(), site_dir.resolve())
|
|
76
|
+
site_content.symlink_to(rel_path)
|
|
77
|
+
print(f"Симлинк: site/content → {rel_path}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_downloader(platform: str, config: Config, source: Source, db: Database):
|
|
81
|
+
"""Возвращает загрузчик для платформы."""
|
|
82
|
+
if platform == 'sponsr':
|
|
83
|
+
return SponsorDownloader(config, source, db)
|
|
84
|
+
elif platform == 'boosty':
|
|
85
|
+
return BoostyDownloader(config, source, db)
|
|
86
|
+
else:
|
|
87
|
+
raise ValueError(f"Неизвестная платформа: {platform}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def sync_all(config: Config, db: Database):
|
|
91
|
+
"""Синхронизирует всех авторов из конфига."""
|
|
92
|
+
for source in config.sources:
|
|
93
|
+
try:
|
|
94
|
+
downloader = get_downloader(source.platform, config, source, db)
|
|
95
|
+
downloader.sync()
|
|
96
|
+
except Exception as e:
|
|
97
|
+
print(f"[{source.platform}] Ошибка при синхронизации {source.author}: {e}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def download_single_post(url: str, config: Config, db: Database):
|
|
101
|
+
"""Скачивает один пост по URL."""
|
|
102
|
+
platform_str, author, post_id = parse_post_url(url)
|
|
103
|
+
platform = cast(Platform, platform_str)
|
|
104
|
+
|
|
105
|
+
# Создаём Source для этого автора
|
|
106
|
+
source = Source(platform=platform, author=author, download_assets=True)
|
|
107
|
+
|
|
108
|
+
# Пытаемся найти настройки источника в конфиге
|
|
109
|
+
for src in config.sources:
|
|
110
|
+
if src.platform == platform and src.author == author:
|
|
111
|
+
source = src
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
downloader = get_downloader(platform, config, source, db)
|
|
115
|
+
downloader.download_single(post_id)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main():
|
|
119
|
+
parser = argparse.ArgumentParser(
|
|
120
|
+
description='Бэкап статей с Sponsr и Boosty',
|
|
121
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
122
|
+
epilog='''
|
|
123
|
+
Примеры:
|
|
124
|
+
%(prog)s # синхронизация по конфигу
|
|
125
|
+
%(prog)s "https://sponsr.ru/author/123/..." # скачать один пост
|
|
126
|
+
%(prog)s "https://boosty.to/author/posts/uuid"
|
|
127
|
+
'''
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
'url',
|
|
132
|
+
nargs='?',
|
|
133
|
+
help='URL поста для скачивания (опционально)'
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
'-c', '--config',
|
|
138
|
+
type=Path,
|
|
139
|
+
default=Path('config.yaml'),
|
|
140
|
+
help='Путь к конфигу (по умолчанию: config.yaml)'
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
args = parser.parse_args()
|
|
144
|
+
|
|
145
|
+
# Загружаем конфиг
|
|
146
|
+
if not args.config.exists():
|
|
147
|
+
print(f"Ошибка: конфиг не найден: {args.config}")
|
|
148
|
+
print("Создайте config.yaml по образцу.")
|
|
149
|
+
sys.exit(1)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
config = load_config(args.config)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
print(f"Ошибка загрузки конфига: {e}")
|
|
155
|
+
sys.exit(1)
|
|
156
|
+
|
|
157
|
+
# Создаём директорию и базу
|
|
158
|
+
config.output_dir.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
|
|
160
|
+
with Database(config.output_dir / 'index.db') as db:
|
|
161
|
+
# Выполняем команду
|
|
162
|
+
if args.url:
|
|
163
|
+
if not is_post_url(args.url):
|
|
164
|
+
print(f"Ошибка: неверный URL поста: {args.url}")
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
download_single_post(args.url, config, db)
|
|
167
|
+
else:
|
|
168
|
+
if not config.sources:
|
|
169
|
+
print("Нет источников в конфиге. Добавьте секцию 'sources'.")
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
sync_all(config, db)
|
|
172
|
+
|
|
173
|
+
ensure_site_content_link(config)
|
|
174
|
+
generate_hugo_config(config)
|
|
175
|
+
print("\nГотово!")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == '__main__':
|
|
179
|
+
main()
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# src package
|
src/boosty.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# src/boosty.py
|
|
2
|
+
"""Загрузчик для Boosty.to"""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .config import Config, Source, load_cookie, load_auth_header
|
|
10
|
+
from .database import Database
|
|
11
|
+
from .downloader import BaseDownloader, Post
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BoostyDownloader(BaseDownloader):
|
|
15
|
+
"""Загрузчик статей с Boosty.to"""
|
|
16
|
+
|
|
17
|
+
PLATFORM = "boosty"
|
|
18
|
+
API_BASE = "https://api.boosty.to/v1"
|
|
19
|
+
|
|
20
|
+
def _setup_session(self):
|
|
21
|
+
"""Настройка сессии с cookies и authorization."""
|
|
22
|
+
cookie = load_cookie(self.config.auth.boosty_cookie_file)
|
|
23
|
+
auth = load_auth_header(self.config.auth.boosty_auth_file)
|
|
24
|
+
|
|
25
|
+
self.session.headers.update({
|
|
26
|
+
'Cookie': cookie,
|
|
27
|
+
'Authorization': auth,
|
|
28
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
def fetch_posts_list(self) -> list[dict]:
|
|
32
|
+
"""Получает список всех постов через API."""
|
|
33
|
+
all_posts = []
|
|
34
|
+
offset = None
|
|
35
|
+
|
|
36
|
+
while True:
|
|
37
|
+
url = f"{self.API_BASE}/blog/{self.source.author}/post/?limit=20"
|
|
38
|
+
if offset:
|
|
39
|
+
url += f"&offset={offset}"
|
|
40
|
+
|
|
41
|
+
response = self.session.get(url, timeout=self.TIMEOUT)
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
|
|
44
|
+
data = response.json()
|
|
45
|
+
posts_chunk = data.get("data", [])
|
|
46
|
+
|
|
47
|
+
if not posts_chunk:
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
all_posts.extend(posts_chunk)
|
|
51
|
+
print(f" Получено {len(all_posts)} постов...")
|
|
52
|
+
|
|
53
|
+
# Проверяем, есть ли ещё страницы
|
|
54
|
+
extra = data.get("extra", {})
|
|
55
|
+
if extra.get("isLast", True):
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
offset = extra.get("offset")
|
|
59
|
+
if not offset:
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
return all_posts
|
|
63
|
+
|
|
64
|
+
def fetch_post(self, post_id: str) -> Post | None:
|
|
65
|
+
"""Получает один пост по ID."""
|
|
66
|
+
url = f"{self.API_BASE}/blog/{self.source.author}/post/{post_id}"
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
response = self.session.get(url, timeout=self.TIMEOUT)
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
data = response.json()
|
|
72
|
+
return self._parse_post(data)
|
|
73
|
+
except requests.RequestException as e:
|
|
74
|
+
print(f" Ошибка получения поста {post_id}: {e}")
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def _parse_post(self, raw_data: dict) -> Post:
|
|
78
|
+
"""Парсит сырые данные API в Post."""
|
|
79
|
+
post_id = raw_data.get("id", "")
|
|
80
|
+
title = raw_data.get("title", "Без названия")
|
|
81
|
+
|
|
82
|
+
# Дата — timestamp в секундах
|
|
83
|
+
created_at = raw_data.get("createdAt", 0)
|
|
84
|
+
post_date = datetime.fromtimestamp(created_at, tz=timezone.utc).isoformat()
|
|
85
|
+
|
|
86
|
+
# URL поста
|
|
87
|
+
author = raw_data.get("user", {}).get("blogUrl", self.source.author)
|
|
88
|
+
source_url = f"https://boosty.to/{author}/posts/{post_id}"
|
|
89
|
+
|
|
90
|
+
# Теги
|
|
91
|
+
tags = [t.get("title", "") for t in raw_data.get("tags", []) if t.get("title")]
|
|
92
|
+
|
|
93
|
+
# Контент — массив блоков
|
|
94
|
+
content_blocks = raw_data.get("data", [])
|
|
95
|
+
|
|
96
|
+
# Извлекаем assets
|
|
97
|
+
assets = self._extract_assets(content_blocks)
|
|
98
|
+
|
|
99
|
+
return Post(
|
|
100
|
+
post_id=post_id,
|
|
101
|
+
title=title,
|
|
102
|
+
content_html=json.dumps(content_blocks, ensure_ascii=False),
|
|
103
|
+
post_date=post_date,
|
|
104
|
+
source_url=source_url,
|
|
105
|
+
tags=tags,
|
|
106
|
+
assets=assets,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _extract_assets(self, blocks: list[dict]) -> list[dict]:
|
|
110
|
+
"""Извлекает URL медиафайлов из блоков контента."""
|
|
111
|
+
assets = []
|
|
112
|
+
|
|
113
|
+
for block in blocks:
|
|
114
|
+
block_type = block.get("type", "")
|
|
115
|
+
|
|
116
|
+
if block_type == "image":
|
|
117
|
+
url = block.get("url", "")
|
|
118
|
+
if url:
|
|
119
|
+
assets.append({
|
|
120
|
+
"url": url,
|
|
121
|
+
"alt": block.get("id", ""),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
elif block_type == "audio_file":
|
|
125
|
+
url = block.get("url", "")
|
|
126
|
+
if url:
|
|
127
|
+
assets.append({
|
|
128
|
+
"url": url,
|
|
129
|
+
"alt": block.get("title", block.get("id", "")),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
elif block_type == "ok_video":
|
|
133
|
+
# ok.ru видео требует отдельной обработки
|
|
134
|
+
# Пока сохраняем только превью, если есть
|
|
135
|
+
preview = block.get("previewUrl") or block.get("preview") or ""
|
|
136
|
+
if preview:
|
|
137
|
+
assets.append({
|
|
138
|
+
"url": preview,
|
|
139
|
+
"alt": f"video-preview-{block.get('id', '')}",
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return assets
|
|
143
|
+
|
|
144
|
+
def _to_markdown(self, post: Post, asset_map: dict[str, str]) -> str:
|
|
145
|
+
"""Конвертирует блоки контента в Markdown."""
|
|
146
|
+
try:
|
|
147
|
+
blocks = json.loads(post.content_html)
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
return f"# {post.title}\n\n"
|
|
150
|
+
|
|
151
|
+
lines = [f"# {post.title}\n"]
|
|
152
|
+
|
|
153
|
+
for block in blocks:
|
|
154
|
+
md = self._block_to_markdown(block, asset_map)
|
|
155
|
+
if md:
|
|
156
|
+
lines.append(md)
|
|
157
|
+
|
|
158
|
+
return "\n".join(lines)
|
|
159
|
+
|
|
160
|
+
def _block_to_markdown(self, block: dict, asset_map: dict[str, str]) -> str:
|
|
161
|
+
"""Конвертирует один блок в Markdown."""
|
|
162
|
+
block_type = block.get("type", "")
|
|
163
|
+
|
|
164
|
+
if block_type == "text":
|
|
165
|
+
return self._parse_text_block(block)
|
|
166
|
+
|
|
167
|
+
elif block_type == "image":
|
|
168
|
+
url = block.get("url", "")
|
|
169
|
+
local = asset_map.get(url)
|
|
170
|
+
if local:
|
|
171
|
+
return f"\n\n"
|
|
172
|
+
elif url:
|
|
173
|
+
return f"\n\n"
|
|
174
|
+
|
|
175
|
+
elif block_type == "link":
|
|
176
|
+
url = block.get("url", "")
|
|
177
|
+
text = self._parse_text_block(block)
|
|
178
|
+
if text and url:
|
|
179
|
+
return f"[{text}]({url})"
|
|
180
|
+
elif url:
|
|
181
|
+
return f"<{url}>"
|
|
182
|
+
|
|
183
|
+
elif block_type == "audio_file":
|
|
184
|
+
url = block.get("url", "")
|
|
185
|
+
title = block.get("title", "audio")
|
|
186
|
+
local = asset_map.get(url)
|
|
187
|
+
if local:
|
|
188
|
+
return f"\n🎵 **{title}**: [скачать](assets/{local})\n"
|
|
189
|
+
elif url:
|
|
190
|
+
return f"\n🎵 **{title}**: [слушать]({url})\n"
|
|
191
|
+
|
|
192
|
+
elif block_type == "ok_video":
|
|
193
|
+
video_id = block.get("id", "")
|
|
194
|
+
return f"\n📹 Видео: https://ok.ru/video/{video_id}\n"
|
|
195
|
+
|
|
196
|
+
return ""
|
|
197
|
+
|
|
198
|
+
def _parse_text_block(self, block: dict) -> str:
|
|
199
|
+
"""Парсит текстовый блок Boosty."""
|
|
200
|
+
content = block.get("content", "")
|
|
201
|
+
modificator = block.get("modificator", "")
|
|
202
|
+
|
|
203
|
+
# BLOCK_END — разделитель параграфов
|
|
204
|
+
if modificator == "BLOCK_END":
|
|
205
|
+
return "\n"
|
|
206
|
+
|
|
207
|
+
if not content:
|
|
208
|
+
return ""
|
|
209
|
+
|
|
210
|
+
# Формат: ["текст", "стиль", [[тип, начало, длина], ...]]
|
|
211
|
+
try:
|
|
212
|
+
parsed = json.loads(content)
|
|
213
|
+
if isinstance(parsed, list) and len(parsed) >= 1:
|
|
214
|
+
text = str(parsed[0])
|
|
215
|
+
|
|
216
|
+
# Применяем стили, если есть
|
|
217
|
+
if len(parsed) >= 3 and parsed[2]:
|
|
218
|
+
text = self._apply_styles(text, parsed[2])
|
|
219
|
+
|
|
220
|
+
return text
|
|
221
|
+
except (json.JSONDecodeError, IndexError, TypeError):
|
|
222
|
+
return content
|
|
223
|
+
|
|
224
|
+
return ""
|
|
225
|
+
|
|
226
|
+
def _apply_styles(self, text: str, styles: list) -> str:
|
|
227
|
+
"""Применяет стили к тексту (bold, italic)."""
|
|
228
|
+
if not styles or not text:
|
|
229
|
+
return text
|
|
230
|
+
|
|
231
|
+
# Сортируем стили по позиции в обратном порядке
|
|
232
|
+
# чтобы вставка не сбивала индексы
|
|
233
|
+
sorted_styles = sorted(styles, key=lambda s: s[1] if len(s) > 1 else 0, reverse=True)
|
|
234
|
+
|
|
235
|
+
result = text
|
|
236
|
+
for style in sorted_styles:
|
|
237
|
+
if len(style) < 3:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
style_type, start, length = style[0], style[1], style[2]
|
|
241
|
+
end = start + length
|
|
242
|
+
|
|
243
|
+
if start < 0 or end > len(result):
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
fragment = result[start:end]
|
|
247
|
+
|
|
248
|
+
# Типы стилей (примерные, на основе анализа)
|
|
249
|
+
if style_type == 1: # bold
|
|
250
|
+
styled = f"**{fragment}**"
|
|
251
|
+
elif style_type == 2: # italic
|
|
252
|
+
styled = f"*{fragment}*"
|
|
253
|
+
elif style_type == 4: # ссылка (обрабатывается в link блоках)
|
|
254
|
+
styled = fragment
|
|
255
|
+
else:
|
|
256
|
+
styled = fragment
|
|
257
|
+
|
|
258
|
+
result = result[:start] + styled + result[end:]
|
|
259
|
+
|
|
260
|
+
return result
|
src/config.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# src/config.py
|
|
2
|
+
"""Загрузка и валидация конфигурации."""
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
Platform = Literal['sponsr', 'boosty']
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Source:
|
|
16
|
+
platform: Platform
|
|
17
|
+
author: str
|
|
18
|
+
download_assets: bool = True
|
|
19
|
+
display_name: str | None = None
|
|
20
|
+
asset_types: list[str] | None = None
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Auth:
|
|
24
|
+
sponsr_cookie_file: Path | None = None
|
|
25
|
+
boosty_cookie_file: Path | None = None
|
|
26
|
+
boosty_auth_file: Path | None = None # Authorization: Bearer ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class HugoConfig:
|
|
31
|
+
base_url: str = "http://localhost:1313/"
|
|
32
|
+
title: str = "Бэкап статей"
|
|
33
|
+
language_code: str = "ru"
|
|
34
|
+
default_theme: str = "light"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Config:
|
|
39
|
+
output_dir: Path
|
|
40
|
+
auth: Auth
|
|
41
|
+
sources: list[Source] = field(default_factory=list)
|
|
42
|
+
hugo: HugoConfig = field(default_factory=HugoConfig)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_config(config_path: Path) -> Config:
|
|
46
|
+
"""Загружает конфигурацию из YAML-файла."""
|
|
47
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
48
|
+
data = yaml.safe_load(f)
|
|
49
|
+
|
|
50
|
+
# output_dir
|
|
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'))
|
|
56
|
+
|
|
57
|
+
# auth
|
|
58
|
+
auth_data = data.get('auth', {})
|
|
59
|
+
auth = Auth(
|
|
60
|
+
sponsr_cookie_file=_to_path(auth_data.get('sponsr_cookie_file')),
|
|
61
|
+
boosty_cookie_file=_to_path(auth_data.get('boosty_cookie_file')),
|
|
62
|
+
boosty_auth_file=_to_path(auth_data.get('boosty_auth_file')),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# sources
|
|
66
|
+
sources = []
|
|
67
|
+
for src in data.get('sources', []):
|
|
68
|
+
sources.append(Source(
|
|
69
|
+
platform=src['platform'],
|
|
70
|
+
author=src['author'],
|
|
71
|
+
download_assets=src.get('download_assets', True),
|
|
72
|
+
display_name=src.get('display_name'),
|
|
73
|
+
asset_types=src.get('asset_types'),
|
|
74
|
+
))
|
|
75
|
+
|
|
76
|
+
# hugo
|
|
77
|
+
hugo_data = data.get('hugo', {})
|
|
78
|
+
hugo = HugoConfig(
|
|
79
|
+
base_url=hugo_data.get('base_url', HugoConfig.base_url),
|
|
80
|
+
title=hugo_data.get('title', HugoConfig.title),
|
|
81
|
+
language_code=hugo_data.get('language_code', HugoConfig.language_code),
|
|
82
|
+
default_theme=hugo_data.get('default_theme', HugoConfig.default_theme),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return Config(output_dir=output_dir, auth=auth, sources=sources, hugo=hugo)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _to_path(value: str | None) -> Path | None:
|
|
89
|
+
"""Конвертирует строку в Path или возвращает None."""
|
|
90
|
+
return Path(value) if value else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_cookie(cookie_file: Path | None) -> str:
|
|
94
|
+
"""Загружает cookie из файла."""
|
|
95
|
+
if cookie_file is None:
|
|
96
|
+
raise FileNotFoundError("Cookie file path not specified")
|
|
97
|
+
if not cookie_file.exists():
|
|
98
|
+
raise FileNotFoundError(f"Cookie file not found: {cookie_file}")
|
|
99
|
+
return cookie_file.read_text(encoding='utf-8').strip()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_auth_header(auth_file: Path | None) -> str:
|
|
103
|
+
"""Загружает Authorization header из файла."""
|
|
104
|
+
if auth_file is None:
|
|
105
|
+
raise FileNotFoundError("Auth file path not specified")
|
|
106
|
+
if not auth_file.exists():
|
|
107
|
+
raise FileNotFoundError(f"Auth file not found: {auth_file}")
|
|
108
|
+
return auth_file.read_text(encoding='utf-8').strip()
|