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
src/sponsr.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# src/sponsr.py
|
|
2
|
+
"""Загрузчик для Sponsr.ru"""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from bs4 import BeautifulSoup
|
|
11
|
+
import html2text
|
|
12
|
+
|
|
13
|
+
from .config import Config, Source, load_cookie
|
|
14
|
+
from .database import Database
|
|
15
|
+
from .downloader import BaseDownloader, Post
|
|
16
|
+
|
|
17
|
+
# Паттерны для преобразования embed URL в watch URL
|
|
18
|
+
VIDEO_EMBED_PATTERNS = [
|
|
19
|
+
(r'rutube\.ru/play/embed/([a-f0-9]+)', lambda m: f'https://rutube.ru/video/{m.group(1)}/'),
|
|
20
|
+
(r'youtube\.com/embed/([^/?]+)', lambda m: f'https://youtube.com/watch?v={m.group(1)}'),
|
|
21
|
+
(r'youtu\.be/([^/?]+)', lambda m: f'https://youtube.com/watch?v={m.group(1)}'),
|
|
22
|
+
(r'player\.vimeo\.com/video/(\d+)', lambda m: f'https://vimeo.com/{m.group(1)}'),
|
|
23
|
+
(r'ok\.ru/videoembed/(\d+)', lambda m: f'https://ok.ru/video/{m.group(1)}'),
|
|
24
|
+
(r'vk\.com/video_ext\.php\?.*?oid=(-?\d+).*?id=(\d+)', lambda m: f'https://vk.com/video{m.group(1)}_{m.group(2)}'),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SponsorDownloader(BaseDownloader):
|
|
29
|
+
"""Загрузчик статей с Sponsr.ru"""
|
|
30
|
+
|
|
31
|
+
PLATFORM = "sponsr"
|
|
32
|
+
|
|
33
|
+
def __init__(self, config: Config, source: Source, db: Database):
|
|
34
|
+
self._project_id: str | None = None
|
|
35
|
+
super().__init__(config, source, db)
|
|
36
|
+
|
|
37
|
+
def _setup_session(self):
|
|
38
|
+
"""Настройка сессии с cookies."""
|
|
39
|
+
cookie = load_cookie(self.config.auth.sponsr_cookie_file)
|
|
40
|
+
self.session.headers.update({
|
|
41
|
+
'Cookie': cookie,
|
|
42
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
43
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
def _get_project_id(self) -> str:
|
|
47
|
+
"""Получает project_id со страницы проекта."""
|
|
48
|
+
if self._project_id:
|
|
49
|
+
return self._project_id
|
|
50
|
+
|
|
51
|
+
url = f"https://sponsr.ru/{self.source.author}/"
|
|
52
|
+
response = self.session.get(url, timeout=self.TIMEOUT)
|
|
53
|
+
response.raise_for_status()
|
|
54
|
+
|
|
55
|
+
soup = BeautifulSoup(response.text, 'lxml')
|
|
56
|
+
data_tag = soup.find('script', id='__NEXT_DATA__')
|
|
57
|
+
if not data_tag:
|
|
58
|
+
raise ValueError(f"Не найден __NEXT_DATA__ на странице {url}")
|
|
59
|
+
|
|
60
|
+
data = json.loads(data_tag.string)
|
|
61
|
+
project_id = data.get('props', {}).get('pageProps', {}).get('project', {}).get('id')
|
|
62
|
+
if not project_id:
|
|
63
|
+
raise ValueError(f"Не найден project.id в __NEXT_DATA__")
|
|
64
|
+
|
|
65
|
+
self._project_id = str(project_id)
|
|
66
|
+
return self._project_id
|
|
67
|
+
|
|
68
|
+
def fetch_posts_list(self) -> list[dict]:
|
|
69
|
+
"""Получает список всех постов через API."""
|
|
70
|
+
project_id = self._get_project_id()
|
|
71
|
+
all_posts = []
|
|
72
|
+
offset = 0
|
|
73
|
+
|
|
74
|
+
while True:
|
|
75
|
+
api_url = f"https://sponsr.ru/project/{project_id}/more-posts/?offset={offset}"
|
|
76
|
+
response = self.session.get(api_url, timeout=self.TIMEOUT)
|
|
77
|
+
response.raise_for_status()
|
|
78
|
+
|
|
79
|
+
data = response.json().get("response", {})
|
|
80
|
+
posts_chunk = data.get("rows", [])
|
|
81
|
+
|
|
82
|
+
if not posts_chunk:
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
all_posts.extend(posts_chunk)
|
|
86
|
+
offset = len(all_posts)
|
|
87
|
+
|
|
88
|
+
total = data.get("rows_count", 0)
|
|
89
|
+
print(f" Получено {offset}/{total} постов...")
|
|
90
|
+
|
|
91
|
+
return all_posts
|
|
92
|
+
|
|
93
|
+
def fetch_post(self, post_id: str) -> Post | None:
|
|
94
|
+
"""Получает один пост по ID."""
|
|
95
|
+
# Сначала пробуем получить напрямую со страницы поста
|
|
96
|
+
post = self._fetch_post_from_page(post_id)
|
|
97
|
+
if post:
|
|
98
|
+
return post
|
|
99
|
+
|
|
100
|
+
# Fallback: ищем в API постранично (без загрузки всего списка)
|
|
101
|
+
return self._find_post_in_api(post_id)
|
|
102
|
+
|
|
103
|
+
def _fetch_post_from_page(self, post_id: str) -> Post | None:
|
|
104
|
+
"""Получает пост напрямую со страницы."""
|
|
105
|
+
# URL формат: https://sponsr.ru/{author}/{post_id}/...
|
|
106
|
+
url = f"https://sponsr.ru/{self.source.author}/{post_id}/"
|
|
107
|
+
try:
|
|
108
|
+
response = self.session.get(url, timeout=self.TIMEOUT)
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
|
|
111
|
+
soup = BeautifulSoup(response.text, 'lxml')
|
|
112
|
+
data_tag = soup.find('script', id='__NEXT_DATA__')
|
|
113
|
+
if not data_tag:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
data = json.loads(data_tag.string)
|
|
117
|
+
post_data = data.get('props', {}).get('pageProps', {}).get('post')
|
|
118
|
+
if not post_data:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
return self._parse_post(post_data)
|
|
122
|
+
except requests.RequestException:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def _find_post_in_api(self, post_id: str) -> Post | None:
|
|
126
|
+
"""Ищет пост в API постранично (останавливается при нахождении)."""
|
|
127
|
+
project_id = self._get_project_id()
|
|
128
|
+
offset = 0
|
|
129
|
+
|
|
130
|
+
while True:
|
|
131
|
+
api_url = f"https://sponsr.ru/project/{project_id}/more-posts/?offset={offset}"
|
|
132
|
+
try:
|
|
133
|
+
response = self.session.get(api_url, timeout=self.TIMEOUT)
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
|
|
136
|
+
data = response.json().get("response", {})
|
|
137
|
+
posts_chunk = data.get("rows", [])
|
|
138
|
+
|
|
139
|
+
if not posts_chunk:
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
for raw_post in posts_chunk:
|
|
143
|
+
if str(raw_post.get('post_id')) == post_id:
|
|
144
|
+
return self._parse_post(raw_post)
|
|
145
|
+
|
|
146
|
+
offset += len(posts_chunk)
|
|
147
|
+
except requests.RequestException:
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def _parse_post(self, raw_data: dict) -> Post:
|
|
153
|
+
"""Парсит сырые данные API в Post."""
|
|
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 ''
|
|
157
|
+
|
|
158
|
+
# URL поста
|
|
159
|
+
post_url = raw_data.get('post_url') or f"/{self.source.author}/{post_id}/"
|
|
160
|
+
if post_url and not post_url.startswith('http'):
|
|
161
|
+
post_url = f"https://sponsr.ru{post_url}"
|
|
162
|
+
|
|
163
|
+
# HTML контент
|
|
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 = ''
|
|
171
|
+
|
|
172
|
+
# Теги - извлекаем только имена из объектов
|
|
173
|
+
tags_raw = raw_data.get('tags', [])
|
|
174
|
+
tags = []
|
|
175
|
+
if isinstance(tags_raw, list):
|
|
176
|
+
for tag in tags_raw:
|
|
177
|
+
if isinstance(tag, dict):
|
|
178
|
+
# API может вернуть объект с полем tag_name или tag.tag_name
|
|
179
|
+
tag_name = tag.get('tag_name') or tag.get('tag', {}).get('tag_name')
|
|
180
|
+
if tag_name:
|
|
181
|
+
tags.append(tag_name)
|
|
182
|
+
elif isinstance(tag, str):
|
|
183
|
+
tags.append(tag)
|
|
184
|
+
|
|
185
|
+
# Извлекаем assets из HTML
|
|
186
|
+
assets = self._extract_assets(content_html)
|
|
187
|
+
|
|
188
|
+
return Post(
|
|
189
|
+
post_id=post_id,
|
|
190
|
+
title=title,
|
|
191
|
+
content_html=content_html,
|
|
192
|
+
post_date=post_date,
|
|
193
|
+
source_url=post_url,
|
|
194
|
+
tags=tags,
|
|
195
|
+
assets=assets,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def _extract_assets(self, html_content: str) -> list[dict]:
|
|
199
|
+
"""Извлекает URL изображений из HTML."""
|
|
200
|
+
if not html_content:
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
assets = []
|
|
204
|
+
soup = BeautifulSoup(html_content, 'lxml')
|
|
205
|
+
|
|
206
|
+
for img in soup.find_all('img'):
|
|
207
|
+
src = img.get('src') or img.get('data-src')
|
|
208
|
+
if not src:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
# Абсолютный URL
|
|
212
|
+
if not src.startswith('http'):
|
|
213
|
+
src = urljoin('https://sponsr.ru', src)
|
|
214
|
+
|
|
215
|
+
# Alt текст
|
|
216
|
+
alt = img.get('alt', '')
|
|
217
|
+
if not alt:
|
|
218
|
+
parent = img.find_parent('div', class_='post-image')
|
|
219
|
+
if parent and parent.get('data-alt'):
|
|
220
|
+
alt = parent.get('data-alt')
|
|
221
|
+
|
|
222
|
+
assets.append({'url': src, 'alt': alt})
|
|
223
|
+
|
|
224
|
+
return assets
|
|
225
|
+
|
|
226
|
+
def _parse_video_url(self, embed_src: str) -> str | None:
|
|
227
|
+
"""Преобразует embed URL в watch URL."""
|
|
228
|
+
for pattern, converter in VIDEO_EMBED_PATTERNS:
|
|
229
|
+
match = re.search(pattern, embed_src)
|
|
230
|
+
if match:
|
|
231
|
+
return converter(match)
|
|
232
|
+
# Fallback: вернуть оригинальный URL если не распознан
|
|
233
|
+
if embed_src and ('video' in embed_src or 'embed' in embed_src):
|
|
234
|
+
return embed_src
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
def _replace_video_embeds(self, html_content: str) -> str:
|
|
238
|
+
"""Заменяет iframe/embed видео на markdown-ссылки."""
|
|
239
|
+
soup = BeautifulSoup(html_content, 'lxml')
|
|
240
|
+
|
|
241
|
+
for iframe in soup.find_all(['iframe', 'embed']):
|
|
242
|
+
src = iframe.get('src', '')
|
|
243
|
+
video_url = self._parse_video_url(src)
|
|
244
|
+
if video_url:
|
|
245
|
+
placeholder = soup.new_tag('p')
|
|
246
|
+
placeholder.string = f'📹 Видео: {video_url}'
|
|
247
|
+
iframe.replace_with(placeholder)
|
|
248
|
+
|
|
249
|
+
return str(soup)
|
|
250
|
+
|
|
251
|
+
def _cleanup_html(self, html: str) -> str:
|
|
252
|
+
"""Предобработка HTML перед конвертацией в Markdown."""
|
|
253
|
+
from bs4 import BeautifulSoup
|
|
254
|
+
|
|
255
|
+
soup = BeautifulSoup(html, 'lxml')
|
|
256
|
+
|
|
257
|
+
# Удаляем пустые теги форматирования (содержат только пробелы/пустые)
|
|
258
|
+
for tag in soup.find_all(['b', 'strong', 'em', 'i']):
|
|
259
|
+
text = tag.get_text()
|
|
260
|
+
if not text or text.isspace():
|
|
261
|
+
tag.decompose()
|
|
262
|
+
|
|
263
|
+
return str(soup)
|
|
264
|
+
|
|
265
|
+
def _to_markdown(self, post: Post, asset_map: dict[str, str]) -> str:
|
|
266
|
+
"""Конвертирует HTML в Markdown."""
|
|
267
|
+
if not post.content_html:
|
|
268
|
+
return f"# {post.title}\n\n"
|
|
269
|
+
|
|
270
|
+
# Заменяем URL изображений на локальные
|
|
271
|
+
html = post.content_html
|
|
272
|
+
for original_url, local_filename in asset_map.items():
|
|
273
|
+
html = html.replace(original_url, f"assets/{local_filename}")
|
|
274
|
+
|
|
275
|
+
# Заменяем iframe/embed видео на markdown-ссылки
|
|
276
|
+
html = self._replace_video_embeds(html)
|
|
277
|
+
|
|
278
|
+
# Предобработка HTML
|
|
279
|
+
html = self._cleanup_html(html)
|
|
280
|
+
|
|
281
|
+
# Конвертируем HTML в Markdown
|
|
282
|
+
h2t = html2text.HTML2Text()
|
|
283
|
+
h2t.ignore_links = False
|
|
284
|
+
h2t.ignore_images = False
|
|
285
|
+
h2t.body_width = 0 # Без переноса строк
|
|
286
|
+
h2t.unicode_snob = True
|
|
287
|
+
|
|
288
|
+
markdown = h2t.handle(html)
|
|
289
|
+
|
|
290
|
+
# Удаляем bidi-маркеры, которые ломают пробелы рядом с текстом
|
|
291
|
+
markdown = re.sub(r'[\u200e\u200f\u202a-\u202e\u2066-\u2069]', '', markdown)
|
|
292
|
+
|
|
293
|
+
# Нормализуем неразрывные пробелы
|
|
294
|
+
markdown = re.sub(r'[\u00a0\u202f]', ' ', markdown)
|
|
295
|
+
|
|
296
|
+
# Склеиваем вложенные em/strong в жирный курсив
|
|
297
|
+
# html2text создаёт ** _текст_** или _**текст**_ для <b><em> (с пробелами)
|
|
298
|
+
markdown = re.sub(r'\*\*\s*_(.+?)_\s*\*\*', r'***\1***', markdown)
|
|
299
|
+
markdown = re.sub(r'_\s*\*\*(.+?)\*\*\s*_', r'***\1***', markdown)
|
|
300
|
+
|
|
301
|
+
# Перемещаем форматирование внутрь ссылок
|
|
302
|
+
# [** _текст_**](url) → [***текст***](url)
|
|
303
|
+
markdown = re.sub(r'\[(\*{2,3})\s*(.+?)\s*(\*{2,3})\]\((.+?)\)', r'[\1\2\3](\4)', markdown)
|
|
304
|
+
# ***[текст](url)*** → [***текст***](url)
|
|
305
|
+
markdown = re.sub(r'(\*{2,3})\[(.+?)\]\((.+?)\)\1', r'[\1\2\1](\3)', markdown)
|
|
306
|
+
# _[текст](url)_ → [_текст_](url)
|
|
307
|
+
markdown = re.sub(r'_\[(.+?)\]\((.+?)\)_', r'[_\1_](\2)', markdown)
|
|
308
|
+
|
|
309
|
+
# Убираем лишние пробелы, добавленные html2text рядом с Unicode-кавычками
|
|
310
|
+
# Открывающие: « „ " '
|
|
311
|
+
markdown = re.sub(r'([\u00ab\u201e\u201c\u2018])\s+', r'\1', markdown)
|
|
312
|
+
# Закрывающие: » " '
|
|
313
|
+
markdown = re.sub(r'\s+([\u00bb\u201d\u2019])', r'\1', markdown)
|
|
314
|
+
|
|
315
|
+
# Восстанавливаем пробелы вокруг форматирования и ссылок
|
|
316
|
+
def _fix_spacing(text: str, pattern: re.Pattern) -> str:
|
|
317
|
+
"""Добавляет пробелы вокруг элементов, если их нет."""
|
|
318
|
+
parts = []
|
|
319
|
+
last = 0
|
|
320
|
+
for match in pattern.finditer(text):
|
|
321
|
+
start, end = match.span()
|
|
322
|
+
before = text[last:start]
|
|
323
|
+
|
|
324
|
+
# Добавляем пробел слева, если нужно
|
|
325
|
+
if start > 0 and before and before[-1].isalnum():
|
|
326
|
+
before = before + ' '
|
|
327
|
+
|
|
328
|
+
parts.append(before)
|
|
329
|
+
|
|
330
|
+
# Добавляем сам матч
|
|
331
|
+
matched_text = text[start:end]
|
|
332
|
+
|
|
333
|
+
# Добавляем пробел справа, если нужно
|
|
334
|
+
if end < len(text) and text[end].isalnum():
|
|
335
|
+
matched_text = matched_text + ' '
|
|
336
|
+
|
|
337
|
+
parts.append(matched_text)
|
|
338
|
+
last = end
|
|
339
|
+
|
|
340
|
+
parts.append(text[last:])
|
|
341
|
+
return ''.join(parts)
|
|
342
|
+
|
|
343
|
+
# Восстанавливаем пробелы вокруг bold-italic, bold, ссылок
|
|
344
|
+
markdown = _fix_spacing(markdown, re.compile(r'\*\*\*.+?\*\*\*'))
|
|
345
|
+
markdown = _fix_spacing(markdown, re.compile(r'(?<!\*)\*\*(?!\*).+?(?<!\*)\*\*(?!\*)'))
|
|
346
|
+
markdown = _fix_spacing(markdown, re.compile(r'\[[^\]]+\]\([^)]+\)'))
|
|
347
|
+
|
|
348
|
+
# Добавляем заголовок
|
|
349
|
+
return f"# {post.title}\n\n{markdown}"
|
src/utils.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# src/utils.py
|
|
2
|
+
"""Вспомогательные функции для бэкапа статей."""
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
from slugify import slugify
|
|
8
|
+
|
|
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
|
+
}
|
|
16
|
+
|
|
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
|
+
}
|
|
27
|
+
|
|
28
|
+
# Паттерны для внутренних ссылок
|
|
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
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def transliterate(text: str) -> str:
|
|
38
|
+
"""Транслитерация текста в slug."""
|
|
39
|
+
return slugify(text, lowercase=True, max_length=80)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_post_url(url: str) -> tuple[str, str, str]:
|
|
43
|
+
"""
|
|
44
|
+
Парсит URL поста, возвращает (platform, author, post_id).
|
|
45
|
+
|
|
46
|
+
Примеры:
|
|
47
|
+
https://sponsr.ru/pushkin/134833/... → ('sponsr', 'pushkin', '134833')
|
|
48
|
+
https://boosty.to/lermontov/posts/uuid → ('boosty', 'lermontov', 'uuid')
|
|
49
|
+
"""
|
|
50
|
+
parsed = urlparse(url)
|
|
51
|
+
parts = [p for p in parsed.path.strip('/').split('/') if p]
|
|
52
|
+
|
|
53
|
+
if 'sponsr.ru' in parsed.netloc:
|
|
54
|
+
if len(parts) < 2:
|
|
55
|
+
raise ValueError(f"Неверный формат URL Sponsr: {url}")
|
|
56
|
+
return ('sponsr', parts[0], parts[1])
|
|
57
|
+
|
|
58
|
+
elif 'boosty.to' in parsed.netloc:
|
|
59
|
+
if len(parts) < 3 or parts[1] != 'posts':
|
|
60
|
+
raise ValueError(f"Неверный формат URL Boosty: {url}")
|
|
61
|
+
return ('boosty', parts[0], parts[2])
|
|
62
|
+
|
|
63
|
+
raise ValueError(f"Неизвестная платформа: {url}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_post_url(text: str) -> bool:
|
|
67
|
+
"""Проверяет, является ли строка URL поста."""
|
|
68
|
+
try:
|
|
69
|
+
parse_post_url(text)
|
|
70
|
+
return True
|
|
71
|
+
except ValueError:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def should_download_asset(
|
|
76
|
+
url: str,
|
|
77
|
+
content_type: str | None = None,
|
|
78
|
+
allowed_types: list[str] | None = None
|
|
79
|
+
) -> bool:
|
|
80
|
+
"""
|
|
81
|
+
Проверяет, нужно ли скачивать файл.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
url: URL файла
|
|
85
|
+
content_type: Content-Type из заголовков ответа (опционально)
|
|
86
|
+
allowed_types: Список разрешенных типов (image, video, audio, document).
|
|
87
|
+
Если None или пустой — разрешено всё из ALLOWED_EXTENSIONS.
|
|
88
|
+
"""
|
|
89
|
+
ext = Path(urlparse(url).path).suffix.lower()
|
|
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. Проверка по расширению
|
|
106
|
+
if ext:
|
|
107
|
+
for type_name in allowed_types:
|
|
108
|
+
if ext in ASSET_TYPES.get(type_name, set()):
|
|
109
|
+
return True
|
|
110
|
+
# Если расширение есть, но не совпало ни с одним разрешенным типом — запрещаем
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
# 2. Проверка по Content-Type (если нет расширения)
|
|
114
|
+
if content_type:
|
|
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
|
|
119
|
+
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_extension_from_content_type(content_type: str) -> str:
|
|
124
|
+
"""Определяет расширение файла по Content-Type."""
|
|
125
|
+
mapping = {
|
|
126
|
+
'image/jpeg': '.jpg',
|
|
127
|
+
'image/png': '.png',
|
|
128
|
+
'image/gif': '.gif',
|
|
129
|
+
'image/webp': '.webp',
|
|
130
|
+
'image/svg+xml': '.svg',
|
|
131
|
+
'video/mp4': '.mp4',
|
|
132
|
+
'video/webm': '.webm',
|
|
133
|
+
'audio/mpeg': '.mp3',
|
|
134
|
+
'audio/wav': '.wav',
|
|
135
|
+
'audio/flac': '.flac',
|
|
136
|
+
'audio/ogg': '.ogg',
|
|
137
|
+
'application/pdf': '.pdf',
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
ct = content_type.split(';')[0].strip().lower()
|
|
141
|
+
return mapping.get(ct, '')
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def sanitize_filename(name: str) -> str:
|
|
145
|
+
"""Очищает имя файла от недопустимых символов."""
|
|
146
|
+
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '', name)
|
|
147
|
+
name = re.sub(r'\s+', ' ', name).strip()
|
|
148
|
+
return name or 'unnamed'
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def extract_internal_links(content: str) -> list[tuple[str, str, str, str]]:
|
|
152
|
+
"""
|
|
153
|
+
Извлекает внутренние ссылки из контента.
|
|
154
|
+
Возвращает [(full_url, platform, author, post_id), ...]
|
|
155
|
+
"""
|
|
156
|
+
links = []
|
|
157
|
+
|
|
158
|
+
for match in SPONSR_LINK_PATTERN.finditer(content):
|
|
159
|
+
links.append((match.group(0), 'sponsr', match.group('author'), match.group('post_id')))
|
|
160
|
+
|
|
161
|
+
for match in BOOSTY_LINK_PATTERN.finditer(content):
|
|
162
|
+
links.append((match.group(0), 'boosty', match.group('author'), match.group('post_id')))
|
|
163
|
+
|
|
164
|
+
return links
|