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.
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![](assets/{local})\n"
172
+ elif url:
173
+ return f"\n![]({url})\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()