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.
Files changed (26) hide show
  1. {article_backup-0.3.1 → article_backup-0.3.2}/PKG-INFO +1 -1
  2. {article_backup-0.3.1 → article_backup-0.3.2}/article_backup.egg-info/PKG-INFO +1 -1
  3. {article_backup-0.3.1 → article_backup-0.3.2}/article_backup.egg-info/SOURCES.txt +1 -0
  4. {article_backup-0.3.1 → article_backup-0.3.2}/pyproject.toml +1 -1
  5. {article_backup-0.3.1 → article_backup-0.3.2}/src/boosty.py +78 -18
  6. {article_backup-0.3.1 → article_backup-0.3.2}/src/downloader.py +13 -1
  7. article_backup-0.3.2/src/sponsr.py +381 -0
  8. article_backup-0.3.2/tests/test_boosty_normalize.py +147 -0
  9. article_backup-0.3.2/tests/test_sponsr_normalize.py +172 -0
  10. article_backup-0.3.1/src/sponsr.py +0 -655
  11. article_backup-0.3.1/tests/test_sponsr_normalize.py +0 -359
  12. {article_backup-0.3.1 → article_backup-0.3.2}/LICENSE +0 -0
  13. {article_backup-0.3.1 → article_backup-0.3.2}/README.md +0 -0
  14. {article_backup-0.3.1 → article_backup-0.3.2}/article_backup.egg-info/dependency_links.txt +0 -0
  15. {article_backup-0.3.1 → article_backup-0.3.2}/article_backup.egg-info/entry_points.txt +0 -0
  16. {article_backup-0.3.1 → article_backup-0.3.2}/article_backup.egg-info/requires.txt +0 -0
  17. {article_backup-0.3.1 → article_backup-0.3.2}/article_backup.egg-info/top_level.txt +0 -0
  18. {article_backup-0.3.1 → article_backup-0.3.2}/backup.py +0 -0
  19. {article_backup-0.3.1 → article_backup-0.3.2}/setup.cfg +0 -0
  20. {article_backup-0.3.1 → article_backup-0.3.2}/src/__init__.py +0 -0
  21. {article_backup-0.3.1 → article_backup-0.3.2}/src/config.py +0 -0
  22. {article_backup-0.3.1 → article_backup-0.3.2}/src/database.py +0 -0
  23. {article_backup-0.3.1 → article_backup-0.3.2}/src/utils.py +0 -0
  24. {article_backup-0.3.1 → article_backup-0.3.2}/tests/test_asset_dedup.py +0 -0
  25. {article_backup-0.3.1 → article_backup-0.3.2}/tests/test_incremental_sync.py +0 -0
  26. {article_backup-0.3.1 → article_backup-0.3.2}/tests/test_sponsr_tags.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: article-backup
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией
5
5
  Author-email: Eugene Chaykin <eugene@chayk.in>
6
6
  License: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: article-backup
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией
5
5
  Author-email: Eugene Chaykin <eugene@chayk.in>
6
6
  License: Apache-2.0
@@ -16,6 +16,7 @@ src/downloader.py
16
16
  src/sponsr.py
17
17
  src/utils.py
18
18
  tests/test_asset_dedup.py
19
+ tests/test_boosty_normalize.py
19
20
  tests/test_incremental_sync.py
20
21
  tests/test_sponsr_normalize.py
21
22
  tests/test_sponsr_tags.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "article-backup"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  description = "Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией"
5
5
  readme = "README.md"
6
6
  license = {text = "Apache-2.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
- md = self._block_to_markdown(block, asset_map)
185
- if md:
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
- modificator = block.get("modificator", "")
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
- text = self._apply_styles(text, parsed[2])
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 == 1: # bold
280
- styled = f"**{fragment}**"
281
- elif style_type == 2: # italic
282
- styled = f"*{fragment}*"
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
- posts_index.write_text(f'---\ntitle: "Посты"\n---\n', encoding='utf-8')
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