article-backup 0.3.1__tar.gz → 0.3.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.3.1 → article_backup-0.3.2}/PKG-INFO +1 -1
- {article_backup-0.3.1 → article_backup-0.3.2}/article_backup.egg-info/PKG-INFO +1 -1
- {article_backup-0.3.1 → article_backup-0.3.2}/article_backup.egg-info/SOURCES.txt +1 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/pyproject.toml +1 -1
- {article_backup-0.3.1 → article_backup-0.3.2}/src/boosty.py +78 -18
- {article_backup-0.3.1 → article_backup-0.3.2}/src/downloader.py +13 -1
- article_backup-0.3.2/src/sponsr.py +381 -0
- article_backup-0.3.2/tests/test_boosty_normalize.py +147 -0
- article_backup-0.3.2/tests/test_sponsr_normalize.py +172 -0
- article_backup-0.3.1/src/sponsr.py +0 -655
- article_backup-0.3.1/tests/test_sponsr_normalize.py +0 -359
- {article_backup-0.3.1 → article_backup-0.3.2}/LICENSE +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/README.md +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/article_backup.egg-info/dependency_links.txt +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/article_backup.egg-info/entry_points.txt +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/article_backup.egg-info/requires.txt +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/article_backup.egg-info/top_level.txt +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/backup.py +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/setup.cfg +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/src/__init__.py +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/src/config.py +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/src/database.py +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/src/utils.py +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/tests/test_asset_dedup.py +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/tests/test_incremental_sync.py +0 -0
- {article_backup-0.3.1 → article_backup-0.3.2}/tests/test_sponsr_tags.py +0 -0
|
@@ -178,21 +178,54 @@ class BoostyDownloader(BaseDownloader):
|
|
|
178
178
|
except json.JSONDecodeError:
|
|
179
179
|
return ""
|
|
180
180
|
|
|
181
|
+
# Заголовок берётся из frontmatter (Hugo), не дублируем его в body.
|
|
182
|
+
# Inline-блоки (text, link) между BLOCK_END конкатенируются в один параграф.
|
|
183
|
+
# BLOCK_END завершает параграф. Block-level элементы разрывают параграф.
|
|
181
184
|
lines: list[str] = []
|
|
185
|
+
current_paragraph: list[str] = []
|
|
186
|
+
paragraph_offset: int = 0
|
|
182
187
|
|
|
183
188
|
for block in blocks:
|
|
184
|
-
|
|
185
|
-
|
|
189
|
+
block_type = block.get("type", "")
|
|
190
|
+
modificator = block.get("modificator", "")
|
|
191
|
+
|
|
192
|
+
# BLOCK_END завершает параграф
|
|
193
|
+
if modificator == "BLOCK_END":
|
|
194
|
+
if current_paragraph:
|
|
195
|
+
lines.append("".join(current_paragraph))
|
|
196
|
+
current_paragraph = []
|
|
197
|
+
lines.append("")
|
|
198
|
+
paragraph_offset = 0
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
md = self._block_to_markdown(block, asset_map, paragraph_offset)
|
|
202
|
+
if not md:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
# Block-level элементы разрывают параграф
|
|
206
|
+
if block_type in ("image", "audio_file", "ok_video"):
|
|
207
|
+
if current_paragraph:
|
|
208
|
+
lines.append("".join(current_paragraph))
|
|
209
|
+
current_paragraph = []
|
|
186
210
|
lines.append(md)
|
|
211
|
+
paragraph_offset = 0
|
|
212
|
+
else:
|
|
213
|
+
# Inline-элементы (text, link) — в текущий параграф
|
|
214
|
+
current_paragraph.append(md)
|
|
215
|
+
paragraph_offset += self._block_text_length(block)
|
|
216
|
+
|
|
217
|
+
# Не забыть незавершённый параграф
|
|
218
|
+
if current_paragraph:
|
|
219
|
+
lines.append("".join(current_paragraph))
|
|
187
220
|
|
|
188
|
-
return "\n".join(lines)
|
|
221
|
+
return "\n".join(lines).strip() + "\n" if lines else ""
|
|
189
222
|
|
|
190
|
-
def _block_to_markdown(self, block: dict, asset_map: dict[str, str]) -> str:
|
|
223
|
+
def _block_to_markdown(self, block: dict, asset_map: dict[str, str], paragraph_offset: int = 0) -> str:
|
|
191
224
|
"""Конвертирует один блок в Markdown."""
|
|
192
225
|
block_type = block.get("type", "")
|
|
193
226
|
|
|
194
227
|
if block_type == "text":
|
|
195
|
-
return self._parse_text_block(block)
|
|
228
|
+
return self._parse_text_block(block, paragraph_offset)
|
|
196
229
|
|
|
197
230
|
elif block_type == "image":
|
|
198
231
|
url = block.get("url", "")
|
|
@@ -204,7 +237,7 @@ class BoostyDownloader(BaseDownloader):
|
|
|
204
237
|
|
|
205
238
|
elif block_type == "link":
|
|
206
239
|
url = block.get("url", "")
|
|
207
|
-
text = self._parse_text_block(block)
|
|
240
|
+
text = self._parse_text_block(block, paragraph_offset)
|
|
208
241
|
if text and url:
|
|
209
242
|
return f"[{text}]({url})"
|
|
210
243
|
elif url:
|
|
@@ -225,14 +258,15 @@ class BoostyDownloader(BaseDownloader):
|
|
|
225
258
|
|
|
226
259
|
return ""
|
|
227
260
|
|
|
228
|
-
def _parse_text_block(self, block: dict) -> str:
|
|
229
|
-
"""Парсит текстовый блок Boosty.
|
|
261
|
+
def _parse_text_block(self, block: dict, paragraph_offset: int = 0) -> str:
|
|
262
|
+
"""Парсит текстовый блок Boosty.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
block: Блок контента
|
|
266
|
+
paragraph_offset: Смещение начала блока в текущем параграфе (для коррекции стилей)
|
|
267
|
+
"""
|
|
230
268
|
content = block.get("content", "")
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
# BLOCK_END — разделитель параграфов
|
|
234
|
-
if modificator == "BLOCK_END":
|
|
235
|
-
return "\n"
|
|
269
|
+
# BLOCK_END обрабатывается в _to_markdown, здесь он не нужен
|
|
236
270
|
|
|
237
271
|
if not content:
|
|
238
272
|
return ""
|
|
@@ -245,7 +279,14 @@ class BoostyDownloader(BaseDownloader):
|
|
|
245
279
|
|
|
246
280
|
# Применяем стили, если есть
|
|
247
281
|
if len(parsed) >= 3 and parsed[2]:
|
|
248
|
-
|
|
282
|
+
styles = parsed[2]
|
|
283
|
+
# Корректируем глобальные позиции стилей в локальные
|
|
284
|
+
if paragraph_offset > 0:
|
|
285
|
+
styles = [
|
|
286
|
+
[s[0], s[1] - paragraph_offset, s[2]]
|
|
287
|
+
for s in styles if len(s) >= 3
|
|
288
|
+
]
|
|
289
|
+
text = self._apply_styles(text, styles)
|
|
249
290
|
|
|
250
291
|
return text
|
|
251
292
|
except (json.JSONDecodeError, IndexError, TypeError):
|
|
@@ -253,6 +294,19 @@ class BoostyDownloader(BaseDownloader):
|
|
|
253
294
|
|
|
254
295
|
return ""
|
|
255
296
|
|
|
297
|
+
def _block_text_length(self, block: dict) -> int:
|
|
298
|
+
"""Возвращает длину сырого текста блока (до стилизации)."""
|
|
299
|
+
content = block.get("content", "")
|
|
300
|
+
if not content:
|
|
301
|
+
return 0
|
|
302
|
+
try:
|
|
303
|
+
parsed = json.loads(content)
|
|
304
|
+
if isinstance(parsed, list) and len(parsed) >= 1:
|
|
305
|
+
return len(str(parsed[0]))
|
|
306
|
+
except (json.JSONDecodeError, IndexError, TypeError):
|
|
307
|
+
pass
|
|
308
|
+
return 0
|
|
309
|
+
|
|
256
310
|
def _apply_styles(self, text: str, styles: list) -> str:
|
|
257
311
|
"""Применяет стили к тексту (bold, italic)."""
|
|
258
312
|
if not styles or not text:
|
|
@@ -276,10 +330,16 @@ class BoostyDownloader(BaseDownloader):
|
|
|
276
330
|
fragment = result[start:end]
|
|
277
331
|
|
|
278
332
|
# Типы стилей (примерные, на основе анализа)
|
|
279
|
-
if style_type
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
333
|
+
if style_type in (1, 2):
|
|
334
|
+
# Выносим пробелы наружу маркеров: "*текст *" → "*текст* "
|
|
335
|
+
stripped = fragment.strip()
|
|
336
|
+
if not stripped:
|
|
337
|
+
styled = fragment # только пробелы — не оборачиваем
|
|
338
|
+
else:
|
|
339
|
+
leading = fragment[:len(fragment) - len(fragment.lstrip())]
|
|
340
|
+
trailing = fragment[len(fragment.rstrip()):]
|
|
341
|
+
marker = "**" if style_type == 1 else "*"
|
|
342
|
+
styled = f"{leading}{marker}{stripped}{marker}{trailing}"
|
|
283
343
|
elif style_type == 4: # ссылка (обрабатывается в link блоках)
|
|
284
344
|
styled = fragment
|
|
285
345
|
else:
|
|
@@ -193,7 +193,19 @@ class BaseDownloader(ABC):
|
|
|
193
193
|
posts_dir = author_dir / "posts"
|
|
194
194
|
posts_dir.mkdir(parents=True, exist_ok=True)
|
|
195
195
|
posts_index = posts_dir / "_index.md"
|
|
196
|
-
|
|
196
|
+
# RSS на уровне /{platform}/{author}/posts/ должен быть "про автора",
|
|
197
|
+
# а не про абстрактную секцию "Посты".
|
|
198
|
+
# title: author из config.yaml, description: display_name (если есть).
|
|
199
|
+
posts_title = self.source.author.replace('"', '\\"')
|
|
200
|
+
posts_desc = self.source.display_name or self.source.author
|
|
201
|
+
safe_posts_desc = posts_desc.replace('"', '\\"')
|
|
202
|
+
posts_index.write_text(
|
|
203
|
+
f'---\n'
|
|
204
|
+
f'title: "{posts_title}"\n'
|
|
205
|
+
f'description: "{safe_posts_desc}"\n'
|
|
206
|
+
f'---\n',
|
|
207
|
+
encoding='utf-8'
|
|
208
|
+
)
|
|
197
209
|
|
|
198
210
|
def _save_post(self, post: Post):
|
|
199
211
|
"""Сохраняет пост на диск."""
|
|
@@ -0,0 +1,381 @@
|
|
|
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(
|
|
69
|
+
self,
|
|
70
|
+
existing_ids: set[str] | None = None,
|
|
71
|
+
incremental: bool = False,
|
|
72
|
+
safety_chunks: int = 1
|
|
73
|
+
) -> list[dict]:
|
|
74
|
+
"""
|
|
75
|
+
Получает список постов через API.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
existing_ids: Множество уже загруженных post_id (для инкрементального режима)
|
|
79
|
+
incremental: Включить инкрементальный режим
|
|
80
|
+
safety_chunks: Количество "защитных" чанков перед остановкой
|
|
81
|
+
"""
|
|
82
|
+
project_id = self._get_project_id()
|
|
83
|
+
all_posts = []
|
|
84
|
+
offset = 0
|
|
85
|
+
clean_chunks_count = 0 # Счётчик "чистых" чанков
|
|
86
|
+
|
|
87
|
+
while True:
|
|
88
|
+
api_url = f"https://sponsr.ru/project/{project_id}/more-posts/?offset={offset}"
|
|
89
|
+
response = self.session.get(api_url, timeout=self.TIMEOUT)
|
|
90
|
+
response.raise_for_status()
|
|
91
|
+
|
|
92
|
+
data = response.json().get("response", {})
|
|
93
|
+
posts_chunk = data.get("rows", [])
|
|
94
|
+
|
|
95
|
+
if not posts_chunk:
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
all_posts.extend(posts_chunk)
|
|
99
|
+
offset = len(all_posts)
|
|
100
|
+
|
|
101
|
+
total = data.get("rows_count", 0)
|
|
102
|
+
|
|
103
|
+
# Инкрементальный режим: проверяем, все ли посты уже существуют
|
|
104
|
+
if incremental and existing_ids is not None:
|
|
105
|
+
chunk_ids = {str(p.get('post_id')) for p in posts_chunk}
|
|
106
|
+
all_existing = chunk_ids.issubset(existing_ids)
|
|
107
|
+
|
|
108
|
+
if all_existing:
|
|
109
|
+
clean_chunks_count += 1
|
|
110
|
+
print(f" Получено {offset}/{total} постов... (чанк уже скачан)")
|
|
111
|
+
# Останавливаемся после safety_chunks + 1 (первый чистый + N защитных)
|
|
112
|
+
if clean_chunks_count > safety_chunks:
|
|
113
|
+
print(f" ⚡ Остановлено на {offset} постах (все новые загружены)")
|
|
114
|
+
break
|
|
115
|
+
else:
|
|
116
|
+
clean_chunks_count = 0
|
|
117
|
+
print(f" Получено {offset}/{total} постов...")
|
|
118
|
+
else:
|
|
119
|
+
print(f" Получено {offset}/{total} постов...")
|
|
120
|
+
|
|
121
|
+
return all_posts
|
|
122
|
+
|
|
123
|
+
def fetch_post(self, post_id: str) -> Post | None:
|
|
124
|
+
"""Получает один пост по ID."""
|
|
125
|
+
# Сначала пробуем получить напрямую со страницы поста
|
|
126
|
+
post = self._fetch_post_from_page(post_id)
|
|
127
|
+
if post:
|
|
128
|
+
return post
|
|
129
|
+
|
|
130
|
+
# Fallback: ищем в API постранично (без загрузки всего списка)
|
|
131
|
+
return self._find_post_in_api(post_id)
|
|
132
|
+
|
|
133
|
+
def _fetch_post_from_page(self, post_id: str) -> Post | None:
|
|
134
|
+
"""Получает пост напрямую со страницы."""
|
|
135
|
+
# URL формат: https://sponsr.ru/{author}/{post_id}/...
|
|
136
|
+
url = f"https://sponsr.ru/{self.source.author}/{post_id}/"
|
|
137
|
+
try:
|
|
138
|
+
response = self.session.get(url, timeout=self.TIMEOUT)
|
|
139
|
+
response.raise_for_status()
|
|
140
|
+
|
|
141
|
+
soup = BeautifulSoup(response.text, 'lxml')
|
|
142
|
+
data_tag = soup.find('script', id='__NEXT_DATA__')
|
|
143
|
+
if not data_tag:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
data = json.loads(data_tag.string)
|
|
147
|
+
post_data = data.get('props', {}).get('pageProps', {}).get('post')
|
|
148
|
+
if not post_data:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
return self._parse_post(post_data)
|
|
152
|
+
except requests.RequestException:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def _find_post_in_api(self, post_id: str) -> Post | None:
|
|
156
|
+
"""Ищет пост в API постранично (останавливается при нахождении)."""
|
|
157
|
+
project_id = self._get_project_id()
|
|
158
|
+
offset = 0
|
|
159
|
+
|
|
160
|
+
while True:
|
|
161
|
+
api_url = f"https://sponsr.ru/project/{project_id}/more-posts/?offset={offset}"
|
|
162
|
+
try:
|
|
163
|
+
response = self.session.get(api_url, timeout=self.TIMEOUT)
|
|
164
|
+
response.raise_for_status()
|
|
165
|
+
|
|
166
|
+
data = response.json().get("response", {})
|
|
167
|
+
posts_chunk = data.get("rows", [])
|
|
168
|
+
|
|
169
|
+
if not posts_chunk:
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
for raw_post in posts_chunk:
|
|
173
|
+
if str(raw_post.get('post_id')) == post_id:
|
|
174
|
+
return self._parse_post(raw_post)
|
|
175
|
+
|
|
176
|
+
offset += len(posts_chunk)
|
|
177
|
+
except requests.RequestException:
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
def _parse_post(self, raw_data: dict) -> Post:
|
|
183
|
+
"""Парсит сырые данные API в Post."""
|
|
184
|
+
post_id = str(raw_data.get('post_id') or raw_data.get('id'))
|
|
185
|
+
title = raw_data.get('post_title') or raw_data.get('title') or 'Без названия'
|
|
186
|
+
post_date = raw_data.get('post_date') or raw_data.get('date') or ''
|
|
187
|
+
|
|
188
|
+
# URL поста
|
|
189
|
+
post_url = raw_data.get('post_url') or f"/{self.source.author}/{post_id}/"
|
|
190
|
+
if post_url and not post_url.startswith('http'):
|
|
191
|
+
post_url = f"https://sponsr.ru{post_url}"
|
|
192
|
+
|
|
193
|
+
# HTML контент
|
|
194
|
+
content_obj = raw_data.get('post_text') or raw_data.get('text')
|
|
195
|
+
if isinstance(content_obj, dict):
|
|
196
|
+
content_html = content_obj.get('text', '')
|
|
197
|
+
elif isinstance(content_obj, str):
|
|
198
|
+
content_html = content_obj
|
|
199
|
+
else:
|
|
200
|
+
content_html = ''
|
|
201
|
+
|
|
202
|
+
# Теги - извлекаем только имена из объектов
|
|
203
|
+
tags_raw = raw_data.get('tags', [])
|
|
204
|
+
tags = []
|
|
205
|
+
if isinstance(tags_raw, list):
|
|
206
|
+
for tag in tags_raw:
|
|
207
|
+
if isinstance(tag, dict):
|
|
208
|
+
# API может вернуть объект с полем tag_name или tag.tag_name
|
|
209
|
+
tag_name = tag.get('tag_name') or tag.get('tag', {}).get('tag_name')
|
|
210
|
+
if tag_name:
|
|
211
|
+
tags.append(tag_name)
|
|
212
|
+
elif isinstance(tag, str):
|
|
213
|
+
tags.append(tag)
|
|
214
|
+
|
|
215
|
+
# Извлекаем assets из HTML
|
|
216
|
+
assets = self._extract_assets(content_html)
|
|
217
|
+
|
|
218
|
+
return Post(
|
|
219
|
+
post_id=post_id,
|
|
220
|
+
title=title,
|
|
221
|
+
content_html=content_html,
|
|
222
|
+
post_date=post_date,
|
|
223
|
+
source_url=post_url,
|
|
224
|
+
tags=tags,
|
|
225
|
+
assets=assets,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def _extract_assets(self, html_content: str) -> list[dict]:
|
|
229
|
+
"""Извлекает URL изображений из HTML."""
|
|
230
|
+
if not html_content:
|
|
231
|
+
return []
|
|
232
|
+
|
|
233
|
+
assets = []
|
|
234
|
+
soup = BeautifulSoup(html_content, 'lxml')
|
|
235
|
+
|
|
236
|
+
for img in soup.find_all('img'):
|
|
237
|
+
src = img.get('src') or img.get('data-src')
|
|
238
|
+
if not src:
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
# Абсолютный URL
|
|
242
|
+
if not src.startswith('http'):
|
|
243
|
+
src = urljoin('https://sponsr.ru', src)
|
|
244
|
+
|
|
245
|
+
# Alt текст
|
|
246
|
+
alt = img.get('alt', '')
|
|
247
|
+
if not alt:
|
|
248
|
+
parent = img.find_parent('div', class_='post-image')
|
|
249
|
+
if parent and parent.get('data-alt'):
|
|
250
|
+
alt = parent.get('data-alt')
|
|
251
|
+
|
|
252
|
+
assets.append({'url': src, 'alt': alt})
|
|
253
|
+
|
|
254
|
+
return assets
|
|
255
|
+
|
|
256
|
+
def _parse_video_url(self, embed_src: str) -> str | None:
|
|
257
|
+
"""Преобразует embed URL в watch URL."""
|
|
258
|
+
for pattern, converter in VIDEO_EMBED_PATTERNS:
|
|
259
|
+
match = re.search(pattern, embed_src)
|
|
260
|
+
if match:
|
|
261
|
+
return converter(match)
|
|
262
|
+
# Fallback: вернуть оригинальный URL если не распознан
|
|
263
|
+
if embed_src and ('video' in embed_src or 'embed' in embed_src):
|
|
264
|
+
return embed_src
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
def _replace_video_embeds(self, html_content: str) -> str:
|
|
268
|
+
"""Заменяет iframe/embed видео на markdown-ссылки."""
|
|
269
|
+
soup = BeautifulSoup(html_content, 'lxml')
|
|
270
|
+
|
|
271
|
+
for iframe in soup.find_all(['iframe', 'embed']):
|
|
272
|
+
src = iframe.get('src', '')
|
|
273
|
+
video_url = self._parse_video_url(src)
|
|
274
|
+
if video_url:
|
|
275
|
+
placeholder = soup.new_tag('p')
|
|
276
|
+
placeholder.string = f'📹 Видео: {video_url}'
|
|
277
|
+
iframe.replace_with(placeholder)
|
|
278
|
+
|
|
279
|
+
return str(soup)
|
|
280
|
+
|
|
281
|
+
def _cleanup_html(self, html: str) -> str:
|
|
282
|
+
"""Предобработка HTML перед конвертацией в Markdown."""
|
|
283
|
+
from bs4 import BeautifulSoup
|
|
284
|
+
|
|
285
|
+
soup = BeautifulSoup(html, 'lxml')
|
|
286
|
+
|
|
287
|
+
# Удаляем пустые теги форматирования (содержат только пробелы/пустые)
|
|
288
|
+
for tag in soup.find_all(['b', 'strong', 'em', 'i']):
|
|
289
|
+
text = tag.get_text()
|
|
290
|
+
if not text:
|
|
291
|
+
tag.decompose()
|
|
292
|
+
elif text.isspace():
|
|
293
|
+
tag.replace_with(text)
|
|
294
|
+
|
|
295
|
+
return str(soup)
|
|
296
|
+
|
|
297
|
+
def _to_markdown(self, post: Post, asset_map: dict[str, str]) -> str:
|
|
298
|
+
"""Конвертирует HTML в Markdown."""
|
|
299
|
+
if not post.content_html:
|
|
300
|
+
return ""
|
|
301
|
+
|
|
302
|
+
# Заменяем URL изображений на локальные
|
|
303
|
+
html = post.content_html
|
|
304
|
+
for original_url, local_filename in asset_map.items():
|
|
305
|
+
html = html.replace(original_url, f"assets/{local_filename}")
|
|
306
|
+
|
|
307
|
+
# Заменяем iframe/embed видео на markdown-ссылки
|
|
308
|
+
html = self._replace_video_embeds(html)
|
|
309
|
+
|
|
310
|
+
# Предобработка HTML
|
|
311
|
+
html = self._cleanup_html(html)
|
|
312
|
+
|
|
313
|
+
# Конвертируем HTML в Markdown
|
|
314
|
+
h2t = html2text.HTML2Text()
|
|
315
|
+
h2t.ignore_links = False
|
|
316
|
+
h2t.ignore_images = False
|
|
317
|
+
h2t.body_width = 0 # Без переноса строк
|
|
318
|
+
h2t.unicode_snob = True
|
|
319
|
+
|
|
320
|
+
markdown = h2t.handle(html)
|
|
321
|
+
|
|
322
|
+
# Удаляем bidi-маркеры, которые ломают пробелы рядом с текстом
|
|
323
|
+
markdown = re.sub(r'[\u200e\u200f\u202a-\u202e\u2066-\u2069]', '', markdown)
|
|
324
|
+
|
|
325
|
+
# Нормализуем неразрывные пробелы
|
|
326
|
+
markdown = re.sub(r'[\u00a0\u202f]', ' ', markdown)
|
|
327
|
+
|
|
328
|
+
# Склеиваем вложенные em/strong в жирный курсив
|
|
329
|
+
# html2text создаёт ** _текст_** или _**текст**_ для <b><em> (с пробелами)
|
|
330
|
+
markdown = re.sub(r'\*\*\s*_(.+?)_\s*\*\*', r'***\1***', markdown)
|
|
331
|
+
markdown = re.sub(r'_\s*\*\*(.+?)\*\*\s*_', r'***\1***', markdown)
|
|
332
|
+
|
|
333
|
+
# Перемещаем форматирование внутрь ссылок
|
|
334
|
+
# [** _текст_**](url) → [***текст***](url)
|
|
335
|
+
markdown = re.sub(r'\[(\*{2,3})\s*(.+?)\s*(\*{2,3})\]\((.+?)\)', r'[\1\2\3](\4)', markdown)
|
|
336
|
+
# ***[текст](url)*** → [***текст***](url)
|
|
337
|
+
markdown = re.sub(r'(\*{2,3})\[(.+?)\]\((.+?)\)\1', r'[\1\2\1](\3)', markdown)
|
|
338
|
+
# _[текст](url)_ → [_текст_](url)
|
|
339
|
+
markdown = re.sub(r'_\[(.+?)\]\((.+?)\)_', r'[_\1_](\2)', markdown)
|
|
340
|
+
|
|
341
|
+
# Убираем лишние пробелы, добавленные html2text рядом с Unicode-кавычками
|
|
342
|
+
# Открывающие: « „ " '
|
|
343
|
+
markdown = re.sub(r'([\u00ab\u201e\u201c\u2018])\s+', r'\1', markdown)
|
|
344
|
+
# Закрывающие: » " '
|
|
345
|
+
markdown = re.sub(r'\s+([\u00bb\u201d\u2019])', r'\1', markdown)
|
|
346
|
+
|
|
347
|
+
# Восстанавливаем пробелы вокруг форматирования и ссылок
|
|
348
|
+
def _fix_spacing(text: str, pattern: re.Pattern) -> str:
|
|
349
|
+
"""Добавляет пробелы вокруг элементов, если их нет."""
|
|
350
|
+
parts = []
|
|
351
|
+
last = 0
|
|
352
|
+
for match in pattern.finditer(text):
|
|
353
|
+
start, end = match.span()
|
|
354
|
+
before = text[last:start]
|
|
355
|
+
|
|
356
|
+
# Добавляем пробел слева, если нужно
|
|
357
|
+
if start > 0 and before and before[-1].isalnum():
|
|
358
|
+
before = before + ' '
|
|
359
|
+
|
|
360
|
+
parts.append(before)
|
|
361
|
+
|
|
362
|
+
# Добавляем сам матч
|
|
363
|
+
matched_text = text[start:end]
|
|
364
|
+
|
|
365
|
+
# Добавляем пробел справа, если нужно
|
|
366
|
+
if end < len(text) and text[end].isalnum():
|
|
367
|
+
matched_text = matched_text + ' '
|
|
368
|
+
|
|
369
|
+
parts.append(matched_text)
|
|
370
|
+
last = end
|
|
371
|
+
|
|
372
|
+
parts.append(text[last:])
|
|
373
|
+
return ''.join(parts)
|
|
374
|
+
|
|
375
|
+
# Восстанавливаем пробелы вокруг bold-italic, bold, ссылок
|
|
376
|
+
markdown = _fix_spacing(markdown, re.compile(r'\*\*\*.+?\*\*\*'))
|
|
377
|
+
markdown = _fix_spacing(markdown, re.compile(r'(?<!\*)\*\*(?!\*).+?(?<!\*)\*\*(?!\*)'))
|
|
378
|
+
markdown = _fix_spacing(markdown, re.compile(r'\[[^\]]+\]\([^)]+\)'))
|
|
379
|
+
|
|
380
|
+
# Заголовок берётся из frontmatter (Hugo), не дублируем его в body.
|
|
381
|
+
return markdown
|