article-backup 0.3.3__tar.gz → 0.3.4__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.3 → article_backup-0.3.4}/PKG-INFO +1 -1
  2. {article_backup-0.3.3 → article_backup-0.3.4}/article_backup.egg-info/PKG-INFO +1 -1
  3. {article_backup-0.3.3 → article_backup-0.3.4}/article_backup.egg-info/SOURCES.txt +3 -1
  4. {article_backup-0.3.3 → article_backup-0.3.4}/pyproject.toml +1 -1
  5. {article_backup-0.3.3 → article_backup-0.3.4}/src/boosty.py +3 -3
  6. {article_backup-0.3.3 → article_backup-0.3.4}/src/sponsr.py +151 -25
  7. article_backup-0.3.4/tests/test_boosty_empty_link.py +86 -0
  8. {article_backup-0.3.3 → article_backup-0.3.4}/tests/test_sponsr_normalize.py +115 -7
  9. article_backup-0.3.4/tests/test_video_embed.py +184 -0
  10. {article_backup-0.3.3 → article_backup-0.3.4}/LICENSE +0 -0
  11. {article_backup-0.3.3 → article_backup-0.3.4}/README.md +0 -0
  12. {article_backup-0.3.3 → article_backup-0.3.4}/article_backup.egg-info/dependency_links.txt +0 -0
  13. {article_backup-0.3.3 → article_backup-0.3.4}/article_backup.egg-info/entry_points.txt +0 -0
  14. {article_backup-0.3.3 → article_backup-0.3.4}/article_backup.egg-info/requires.txt +0 -0
  15. {article_backup-0.3.3 → article_backup-0.3.4}/article_backup.egg-info/top_level.txt +0 -0
  16. {article_backup-0.3.3 → article_backup-0.3.4}/backup.py +0 -0
  17. {article_backup-0.3.3 → article_backup-0.3.4}/setup.cfg +0 -0
  18. {article_backup-0.3.3 → article_backup-0.3.4}/src/__init__.py +0 -0
  19. {article_backup-0.3.3 → article_backup-0.3.4}/src/config.py +0 -0
  20. {article_backup-0.3.3 → article_backup-0.3.4}/src/database.py +0 -0
  21. {article_backup-0.3.3 → article_backup-0.3.4}/src/downloader.py +0 -0
  22. {article_backup-0.3.3 → article_backup-0.3.4}/src/utils.py +0 -0
  23. {article_backup-0.3.3 → article_backup-0.3.4}/tests/test_asset_dedup.py +0 -0
  24. {article_backup-0.3.3 → article_backup-0.3.4}/tests/test_boosty_normalize.py +0 -0
  25. {article_backup-0.3.3 → article_backup-0.3.4}/tests/test_incremental_sync.py +0 -0
  26. {article_backup-0.3.3 → article_backup-0.3.4}/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.3
3
+ Version: 0.3.4
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.3
3
+ Version: 0.3.4
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,7 +16,9 @@ src/downloader.py
16
16
  src/sponsr.py
17
17
  src/utils.py
18
18
  tests/test_asset_dedup.py
19
+ tests/test_boosty_empty_link.py
19
20
  tests/test_boosty_normalize.py
20
21
  tests/test_incremental_sync.py
21
22
  tests/test_sponsr_normalize.py
22
- tests/test_sponsr_tags.py
23
+ tests/test_sponsr_tags.py
24
+ tests/test_video_embed.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "article-backup"
3
- version = "0.3.3"
3
+ version = "0.3.4"
4
4
  description = "Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией"
5
5
  readme = "README.md"
6
6
  license = {text = "Apache-2.0"}
@@ -240,8 +240,8 @@ class BoostyDownloader(BaseDownloader):
240
240
  text = self._parse_text_block(block, paragraph_offset)
241
241
  if text and url:
242
242
  return f"[{text}]({url})"
243
- elif url:
244
- return f"<{url}>"
243
+ # Пустые ссылки (без текста) пропускаем — это часто артефакты редактора
244
+ # Было: elif url: return f"<{url}>"
245
245
 
246
246
  elif block_type == "audio_file":
247
247
  url = block.get("url", "")
@@ -254,7 +254,7 @@ class BoostyDownloader(BaseDownloader):
254
254
 
255
255
  elif block_type == "ok_video":
256
256
  video_id = block.get("id", "")
257
- return f"\n📹 Видео: https://ok.ru/video/{video_id}\n"
257
+ return f"\n[\U0001f4f9 Видео](https://ok.ru/videoembed/{video_id})\n"
258
258
 
259
259
  return ""
260
260
 
@@ -14,14 +14,14 @@ from .config import Config, Source, load_cookie
14
14
  from .database import Database
15
15
  from .downloader import BaseDownloader, Post
16
16
 
17
- # Паттерны для преобразования embed URL в watch URL
17
+ # Паттерны для распознавания embed URL видеохостингов (whitelist).
18
+ # Если iframe src матчит один из паттернов — это встроенное видео.
18
19
  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)}'),
20
+ r'rutube\.ru/play/embed/',
21
+ r'youtube\.com/embed/',
22
+ r'player\.vimeo\.com/video/',
23
+ r'ok\.ru/videoembed/',
24
+ r'vk\.com/video_ext\.php',
25
25
  ]
26
26
 
27
27
 
@@ -253,28 +253,41 @@ class SponsorDownloader(BaseDownloader):
253
253
 
254
254
  return assets
255
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
256
+ def _is_video_embed(self, src: str) -> bool:
257
+ """Проверяет, является ли URL embed-ссылкой на известный видеохостинг."""
258
+ for pattern in VIDEO_EMBED_PATTERNS:
259
+ if re.search(pattern, src):
260
+ return True
261
+ return False
266
262
 
267
263
  def _replace_video_embeds(self, html_content: str) -> str:
268
- """Заменяет iframe/embed видео на markdown-ссылки."""
264
+ """Заменяет iframe/embed видео на HTML-ссылки.
265
+
266
+ Распознанные видеохостинги → <a href="embed_url">📹 Видео</a>
267
+ (html2text превратит в markdown-ссылку, Hugo render hook — в iframe).
268
+ Нераспознанные → текстовая ссылка как fallback.
269
+ """
269
270
  soup = BeautifulSoup(html_content, 'lxml')
270
271
 
271
272
  for iframe in soup.find_all(['iframe', 'embed']):
272
273
  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)
274
+ if not src:
275
+ continue
276
+
277
+ if self._is_video_embed(src):
278
+ # Распознанный видеохостинг → ссылка с embed URL
279
+ link = soup.new_tag('a', href=src)
280
+ link.string = '\U0001f4f9 Видео'
281
+ wrapper = soup.new_tag('p')
282
+ wrapper.append(link)
283
+ iframe.replace_with(wrapper)
284
+ elif 'video' in src or 'embed' in src:
285
+ # Нераспознанный, но похож на видео → текстовая ссылка
286
+ link = soup.new_tag('a', href=src)
287
+ link.string = '\U0001f4f9 Видео'
288
+ wrapper = soup.new_tag('p')
289
+ wrapper.append(link)
290
+ iframe.replace_with(wrapper)
278
291
 
279
292
  return str(soup)
280
293
 
@@ -299,7 +312,15 @@ class SponsorDownloader(BaseDownloader):
299
312
  # Разворачиваем внутренний тег, оставляя внешний
300
313
  child.unwrap()
301
314
 
302
- # 2. Удаляем пустые теги форматирования и выносим пробелы наружу
315
+ # 2. Слияние соседних <em>/<i> тегов внутри одного родителя.
316
+ # <em>вы</em> <b><em>обязаны</em></b> <em>это</em>
317
+ # → <em>вы <b>обязаны</b> это</em>
318
+ # Это предотвращает фрагментированный курсив после html2text.
319
+ em_tags = {'em', 'i'}
320
+ bold_tags = {'b', 'strong'}
321
+ self._merge_adjacent_em(soup, em_tags, bold_tags)
322
+
323
+ # 3. Удаляем пустые теги форматирования и выносим пробелы наружу
303
324
  for tag in list(soup.find_all(['b', 'strong', 'em', 'i'])):
304
325
  if tag.parent is None:
305
326
  continue
@@ -323,7 +344,7 @@ class SponsorDownloader(BaseDownloader):
323
344
  last_text.replace_with(last_text.rstrip())
324
345
  tag.insert_after(NavigableString(trailing))
325
346
 
326
- # 3. Вынос trailing/leading пробелов из <a> тегов наружу
347
+ # 4. Вынос trailing/leading пробелов из <a> тегов наружу
327
348
  # После выноса пробелов из formatting тегов, пробел может остаться
328
349
  # внутри <a> (но вне <em>/<b>), что даёт [текст ](url) в markdown
329
350
  for tag in list(soup.find_all('a')):
@@ -340,6 +361,111 @@ class SponsorDownloader(BaseDownloader):
340
361
 
341
362
  return str(soup)
342
363
 
364
+ @staticmethod
365
+ def _merge_adjacent_em(soup, em_tags: set, bold_tags: set):
366
+ """Объединяет соседние <em>/<i> теги внутри одного родителя.
367
+
368
+ Обрабатывает случаи вида:
369
+ <em>вы</em> <b><em>обязаны</em></b> <em>это</em>
370
+ → <em>вы <b>обязаны</b> это</em>
371
+
372
+ Между <em> могут быть:
373
+ - whitespace (NavigableString из пробелов)
374
+ - <b>/<strong>, целиком обёрнутые в <em> (<b><em>текст</em></b>)
375
+ """
376
+ from bs4 import NavigableString, Tag
377
+
378
+ def is_em(node):
379
+ """Проверяет, является ли узел тегом em/i."""
380
+ return isinstance(node, Tag) and node.name in em_tags
381
+
382
+ def is_bold_wrapped_em(node):
383
+ """Проверяет, является ли узел <b><em>текст</em></b>."""
384
+ if not isinstance(node, Tag) or node.name not in bold_tags:
385
+ return False
386
+ children = list(node.children)
387
+ return len(children) == 1 and is_em(children[0])
388
+
389
+ def is_whitespace(node):
390
+ """Проверяет, является ли узел пробельным текстом."""
391
+ return isinstance(node, NavigableString) and node.strip() == ''
392
+
393
+ # Обходим все элементы, которые могут содержать em-последовательности
394
+ # Нельзя итерировать напрямую, т.к. дерево мутирует — собираем список родителей
395
+ parents = set()
396
+ for em in soup.find_all(list(em_tags)):
397
+ if em.parent is not None:
398
+ parents.add(id(em.parent))
399
+
400
+ # Для каждого родителя проверяем его children
401
+ for parent in list(soup.descendants):
402
+ if not isinstance(parent, Tag) or id(parent) not in parents:
403
+ continue
404
+
405
+ # Собираем runs — последовательности соседних em-элементов
406
+ children = list(parent.children)
407
+ i = 0
408
+ while i < len(children):
409
+ # Ищем начало run: первый <em>
410
+ if not is_em(children[i]):
411
+ i += 1
412
+ continue
413
+
414
+ # Собираем run: <em>, whitespace, <b><em>...</em></b>, <em>, ...
415
+ run_start = i
416
+ run_nodes = [children[i]]
417
+ j = i + 1
418
+ while j < len(children):
419
+ node = children[j]
420
+ if is_em(node) or is_bold_wrapped_em(node):
421
+ run_nodes.append(node)
422
+ j += 1
423
+ elif is_whitespace(node):
424
+ # Пробел между em-элементами — добавляем в run
425
+ # но только если за ним следует ещё em/bold-em
426
+ if j + 1 < len(children) and (is_em(children[j + 1]) or is_bold_wrapped_em(children[j + 1])):
427
+ run_nodes.append(node)
428
+ j += 1
429
+ else:
430
+ break
431
+ else:
432
+ break
433
+
434
+ # Нужно минимум 2 em-элемента (не считая whitespace) для слияния
435
+ em_count = sum(1 for n in run_nodes if is_em(n) or is_bold_wrapped_em(n))
436
+ if em_count < 2:
437
+ i = j
438
+ continue
439
+
440
+ # Объединяем run в один <em>
441
+ # Берём первый <em> как базу, переносим в него содержимое остальных
442
+ first_em = run_nodes[0]
443
+
444
+ for node in run_nodes[1:]:
445
+ if is_whitespace(node):
446
+ # Пробел → переносим внутрь first_em
447
+ ws = NavigableString(str(node))
448
+ node.extract()
449
+ first_em.append(ws)
450
+ elif is_em(node):
451
+ # <em>текст</em> → переносим содержимое в first_em
452
+ for child in list(node.children):
453
+ child.extract()
454
+ first_em.append(child)
455
+ node.extract()
456
+ elif is_bold_wrapped_em(node):
457
+ # <b><em>текст</em></b> → <b>текст</b>, переносим в first_em
458
+ inner_em = list(node.children)[0]
459
+ inner_em.unwrap() # убираем <em>, оставляя содержимое в <b>
460
+ node.extract()
461
+ first_em.append(node)
462
+
463
+ # Пересобираем children, т.к. дерево изменилось
464
+ children = list(parent.children)
465
+ # Не инкрементируем i — начинаем с того же места
466
+ # (first_em остался, но children пересобрались)
467
+ i = children.index(first_em) + 1 if first_em in children else j
468
+
343
469
  @staticmethod
344
470
  def _first_navigable_string(tag):
345
471
  """Находит первый текстовый узел (NavigableString) внутри тега."""
@@ -0,0 +1,86 @@
1
+ import json
2
+ import unittest
3
+ from unittest.mock import MagicMock, patch
4
+ from pathlib import Path
5
+
6
+ from src.config import Config, Source, Auth
7
+ from src.database import Database
8
+ from src.boosty import BoostyDownloader
9
+ from src.downloader import Post
10
+
11
+
12
+ class BoostyEmptyLinkTests(unittest.TestCase):
13
+ """Тесты обработки пустых ссылок в Boosty."""
14
+
15
+ def setUp(self):
16
+ self.config = Config(output_dir=Path('/tmp/test'), auth=Auth())
17
+ self.source = Source(platform='boosty', author='test_author')
18
+ self.db = MagicMock(spec=Database)
19
+
20
+ with patch('src.boosty.load_cookie', return_value='fake_cookie'), \
21
+ patch('src.boosty.load_auth_header', return_value='Bearer fake_token'):
22
+ self.downloader = BoostyDownloader(self.config, self.source, self.db)
23
+
24
+ def test_empty_link_is_ignored(self):
25
+ """Ссылка с пустым текстом игнорируется (не превращается в <url>)."""
26
+ blocks = [
27
+ # Пустая ссылка (артефакт)
28
+ {
29
+ "type": "link",
30
+ "url": "https://boosty.to/post/1",
31
+ "content": json.dumps(["", "unstyled", []])
32
+ },
33
+ # Нормальная ссылка
34
+ {
35
+ "type": "link",
36
+ "url": "https://boosty.to/post/2",
37
+ "content": json.dumps(["Вторая часть", "unstyled", []])
38
+ },
39
+ # Конец блока (параграфа)
40
+ {"type": "text", "modificator": "BLOCK_END", "content": ""}
41
+ ]
42
+
43
+ post = Post(
44
+ post_id='1', title='Test',
45
+ content_html=json.dumps(blocks),
46
+ post_date='2025-01-01', source_url='https://test.com',
47
+ tags=[], assets=[]
48
+ )
49
+
50
+ result = self.downloader._to_markdown(post, {})
51
+
52
+ # Должна быть только текстовая ссылка
53
+ self.assertIn('[Вторая часть](https://boosty.to/post/2)', result)
54
+
55
+ # Не должно быть артефакта <url>
56
+ self.assertNotIn('<https://boosty.to/post/1>', result)
57
+ # Не должно быть пустых скобок
58
+ self.assertNotIn('[]', result)
59
+
60
+ def test_empty_link_does_not_break_paragraph(self):
61
+ """Пустая ссылка не должна создавать лишние переводы строк."""
62
+ blocks = [
63
+ {"type": "text", "content": json.dumps(["Текст до."])},
64
+ {
65
+ "type": "link",
66
+ "url": "https://boosty.to/post/empty",
67
+ "content": json.dumps(["", "unstyled", []])
68
+ },
69
+ {"type": "text", "content": json.dumps(["Текст после."])},
70
+ {"type": "text", "modificator": "BLOCK_END", "content": ""}
71
+ ]
72
+
73
+ post = Post(
74
+ post_id='1', title='Test',
75
+ content_html=json.dumps(blocks),
76
+ post_date='2025-01-01', source_url='https://test.com',
77
+ tags=[], assets=[]
78
+ )
79
+
80
+ result = self.downloader._to_markdown(post, {})
81
+
82
+ # Текст должен быть слитным (без разрывов)
83
+ self.assertEqual(result.strip(), "Текст до.Текст после.")
84
+
85
+ if __name__ == '__main__':
86
+ unittest.main()
@@ -225,7 +225,8 @@ class SponsorNormalizeTests(unittest.TestCase):
225
225
  """Проблема 1: italic + bold-italic внутри ссылки с trailing пробелами.
226
226
 
227
227
  HTML: «<em>39 лет ... пишу: </em><a href="..."><em>вы </em><b><em>обязаны</em></b><em> это посмотреть</em></a>»
228
- Плохо: «_39 лет ... пишу: _[ _вы ****обязаны****это посмотреть_](...)»
228
+ Плохо: «_39 лет ... пишу: _[ _вы_ ***обязаны*** _это посмотреть_](...)»
229
+ Хорошо: «_39 лет ... пишу:_ [_вы **обязаны** это посмотреть_](...)»
229
230
  """
230
231
  post = Post(
231
232
  post_id='1',
@@ -243,12 +244,13 @@ class SponsorNormalizeTests(unittest.TestCase):
243
244
  self.assertNotIn('****', result)
244
245
  # Закрывающий _ не должен иметь пробел перед ним
245
246
  self.assertNotIn('пишу: _', result)
246
- # Внутри ссылки italic/bold-italic должны быть валидны
247
- # Пробел между "вы" и "обязаны" не должен теряться
248
- self.assertIn('обязаны', result)
249
- self.assertIn('это посмотреть', result)
250
- # Не должно быть _[ _вы
251
- self.assertNotIn('_[ _', result)
247
+ # Соседние <em> внутри ссылки объединены в один курсив
248
+ # Ожидаем: [_вы **обязаны** это посмотреть_](url)
249
+ self.assertIn('[_вы **обязаны** это посмотреть_]', result)
250
+ # Не должно быть фрагментированного курсива
251
+ self.assertNotIn('_вы_', result)
252
+ self.assertNotIn('***обязаны***', result)
253
+ self.assertNotIn('_это посмотреть_]', result)
252
254
 
253
255
  def test_nested_identical_tags_merged(self):
254
256
  """Тест слияния вложенных одинаковых тегов: <em><em>text</em></em> → <em>text</em>."""
@@ -305,5 +307,111 @@ class SponsorNormalizeTests(unittest.TestCase):
305
307
  self.assertIn('слово', result)
306
308
 
307
309
 
310
+ def test_adjacent_em_merged_in_link(self):
311
+ """Соседние <em> внутри <a> объединяются в один.
312
+
313
+ HTML: <a><em>раз</em> <em>два</em> <em>три</em></a>
314
+ Хорошо: [_раз два три_](url)
315
+ Плохо: [_раз_ _два_ _три_](url)
316
+ """
317
+ post = Post(
318
+ post_id='1',
319
+ title='Test',
320
+ content_html='<p><a href="https://example.com"><em>раз</em> <em>два</em> <em>три</em></a></p>',
321
+ post_date='2025-01-01',
322
+ source_url='https://test.com',
323
+ tags=[],
324
+ assets=[]
325
+ )
326
+
327
+ result = self.downloader._to_markdown(post, {})
328
+
329
+ self.assertIn('[_раз два три_](https://example.com)', result)
330
+ self.assertNotIn('_раз_', result)
331
+ self.assertNotIn('_два_', result)
332
+
333
+ def test_adjacent_em_merged_in_paragraph(self):
334
+ """Соседние <em> внутри <p> объединяются в один.
335
+
336
+ HTML: <p>перед <em>курсив1</em> <em>курсив2</em> после</p>
337
+ Хорошо: перед _курсив1 курсив2_ после
338
+ Плохо: перед _курсив1_ _курсив2_ после
339
+ """
340
+ post = Post(
341
+ post_id='1',
342
+ title='Test',
343
+ content_html='<p>перед <em>курсив1</em> <em>курсив2</em> после</p>',
344
+ post_date='2025-01-01',
345
+ source_url='https://test.com',
346
+ tags=[],
347
+ assets=[]
348
+ )
349
+
350
+ result = self.downloader._to_markdown(post, {})
351
+
352
+ self.assertIn('_курсив1 курсив2_', result)
353
+ self.assertNotIn('_курсив1_', result)
354
+
355
+ def test_adjacent_em_with_bold_merged(self):
356
+ """Соседние <em> с <b><em> между ними объединяются.
357
+
358
+ HTML: <em>раз</em> <b><em>два</em></b> <em>три</em>
359
+ Хорошо: _раз **два** три_
360
+ Плохо: _раз_ ***два*** _три_
361
+ """
362
+ post = Post(
363
+ post_id='1',
364
+ title='Test',
365
+ content_html='<p><em>раз</em> <b><em>два</em></b> <em>три</em></p>',
366
+ post_date='2025-01-01',
367
+ source_url='https://test.com',
368
+ tags=[],
369
+ assets=[]
370
+ )
371
+
372
+ result = self.downloader._to_markdown(post, {})
373
+
374
+ self.assertIn('_раз **два** три_', result)
375
+ self.assertNotIn('***два***', result)
376
+
377
+ def test_single_em_not_affected(self):
378
+ """Одиночный <em> не затрагивается слиянием."""
379
+ post = Post(
380
+ post_id='1',
381
+ title='Test',
382
+ content_html='<p>текст <em>курсив</em> обычный</p>',
383
+ post_date='2025-01-01',
384
+ source_url='https://test.com',
385
+ tags=[],
386
+ assets=[]
387
+ )
388
+
389
+ result = self.downloader._to_markdown(post, {})
390
+
391
+ self.assertIn('_курсив_', result)
392
+
393
+ def test_non_adjacent_em_not_merged(self):
394
+ """<em> теги, разделённые обычным текстом, не объединяются.
395
+
396
+ HTML: <em>курсив1</em> обычный <em>курсив2</em>
397
+ Должно остаться: _курсив1_ обычный _курсив2_
398
+ """
399
+ post = Post(
400
+ post_id='1',
401
+ title='Test',
402
+ content_html='<p><em>курсив1</em> обычный <em>курсив2</em></p>',
403
+ post_date='2025-01-01',
404
+ source_url='https://test.com',
405
+ tags=[],
406
+ assets=[]
407
+ )
408
+
409
+ result = self.downloader._to_markdown(post, {})
410
+
411
+ self.assertIn('_курсив1_', result)
412
+ self.assertIn('_курсив2_', result)
413
+ self.assertIn('обычный', result)
414
+
415
+
308
416
  if __name__ == '__main__':
309
417
  unittest.main()
@@ -0,0 +1,184 @@
1
+ import json
2
+ import unittest
3
+ from unittest.mock import MagicMock, patch
4
+ from pathlib import Path
5
+
6
+ from src.config import Config, Source, Auth
7
+ from src.database import Database
8
+ from src.sponsr import SponsorDownloader
9
+ from src.boosty import BoostyDownloader
10
+ from src.downloader import Post
11
+
12
+
13
+ class SponsorVideoEmbedTests(unittest.TestCase):
14
+ """Тесты встраивания видео для Sponsr."""
15
+
16
+ def setUp(self):
17
+ self.config = Config(output_dir=Path('/tmp/test'), auth=Auth())
18
+ self.source = Source(platform='sponsr', author='test_author')
19
+ self.db = MagicMock(spec=Database)
20
+
21
+ with patch('src.sponsr.load_cookie', return_value='fake_cookie'):
22
+ self.downloader = SponsorDownloader(self.config, self.source, self.db)
23
+
24
+ def _make_post(self, html: str) -> Post:
25
+ return Post(
26
+ post_id='1', title='Test', content_html=html,
27
+ post_date='2025-01-01', source_url='https://test.com',
28
+ tags=[], assets=[]
29
+ )
30
+
31
+ def test_rutube_iframe_becomes_markdown_link(self):
32
+ """Rutube iframe → markdown-ссылка с embed URL."""
33
+ html = '<p>Текст</p><iframe src="https://rutube.ru/play/embed/a1b2c3d4e5f6"></iframe><p>Ещё текст</p>'
34
+ result = self.downloader._to_markdown(self._make_post(html), {})
35
+
36
+ self.assertIn('[📹 Видео](https://rutube.ru/play/embed/a1b2c3d4e5f6)', result)
37
+ self.assertNotIn('<iframe', result)
38
+ self.assertNotIn('📹 Видео:', result) # не текстовый формат
39
+
40
+ def test_youtube_iframe_becomes_markdown_link(self):
41
+ """YouTube iframe → markdown-ссылка с embed URL."""
42
+ html = '<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe>'
43
+ result = self.downloader._to_markdown(self._make_post(html), {})
44
+
45
+ self.assertIn('[📹 Видео](https://www.youtube.com/embed/dQw4w9WgXcQ)', result)
46
+
47
+ def test_vimeo_iframe_becomes_markdown_link(self):
48
+ """Vimeo iframe → markdown-ссылка с embed URL."""
49
+ html = '<iframe src="https://player.vimeo.com/video/123456789"></iframe>'
50
+ result = self.downloader._to_markdown(self._make_post(html), {})
51
+
52
+ self.assertIn('[📹 Видео](https://player.vimeo.com/video/123456789)', result)
53
+
54
+ def test_ok_ru_iframe_becomes_markdown_link(self):
55
+ """OK.ru iframe → markdown-ссылка с embed URL."""
56
+ html = '<iframe src="https://ok.ru/videoembed/987654321"></iframe>'
57
+ result = self.downloader._to_markdown(self._make_post(html), {})
58
+
59
+ self.assertIn('[📹 Видео](https://ok.ru/videoembed/987654321)', result)
60
+
61
+ def test_vk_iframe_becomes_markdown_link(self):
62
+ """VK Video iframe → markdown-ссылка с embed URL."""
63
+ html = '<iframe src="https://vk.com/video_ext.php?oid=-12345&id=67890&hd=2"></iframe>'
64
+ result = self.downloader._to_markdown(self._make_post(html), {})
65
+
66
+ self.assertIn('[📹 Видео](https://vk.com/video_ext.php?oid=-12345&id=67890&hd=2)', result)
67
+
68
+ def test_unknown_video_embed_fallback(self):
69
+ """Нераспознанный iframe с video/embed в src → markdown-ссылка (fallback)."""
70
+ html = '<iframe src="https://unknown-host.com/embed/video123"></iframe>'
71
+ result = self.downloader._to_markdown(self._make_post(html), {})
72
+
73
+ # Должна быть markdown-ссылка, а не сырой iframe
74
+ self.assertIn('[📹 Видео](https://unknown-host.com/embed/video123)', result)
75
+ self.assertNotIn('<iframe', result)
76
+
77
+ def test_non_video_iframe_ignored(self):
78
+ """iframe без video/embed в src — игнорируется (не заменяется)."""
79
+ html = '<p>Текст</p><iframe src="https://example.com/widget/form"></iframe><p>Ещё</p>'
80
+ result = self.downloader._to_markdown(self._make_post(html), {})
81
+
82
+ # Не должно быть видео-ссылки
83
+ self.assertNotIn('📹', result)
84
+
85
+ def test_embed_tag_also_converted(self):
86
+ """Тег <embed> тоже обрабатывается."""
87
+ html = '<embed src="https://rutube.ru/play/embed/a1b2c3d4e5f6">'
88
+ result = self.downloader._to_markdown(self._make_post(html), {})
89
+
90
+ self.assertIn('[📹 Видео](https://rutube.ru/play/embed/a1b2c3d4e5f6)', result)
91
+
92
+ def test_video_link_surrounded_by_text(self):
93
+ """Видео-ссылка корректно окружена текстом."""
94
+ html = '<p>Вот видео:</p><iframe src="https://rutube.ru/play/embed/abc123"></iframe><p>А вот продолжение.</p>'
95
+ result = self.downloader._to_markdown(self._make_post(html), {})
96
+
97
+ self.assertIn('Вот видео:', result)
98
+ self.assertIn('[📹 Видео](https://rutube.ru/play/embed/abc123)', result)
99
+ self.assertIn('А вот продолжение.', result)
100
+
101
+ def test_is_video_embed_recognizes_all_hosts(self):
102
+ """_is_video_embed распознаёт все хостинги из whitelist."""
103
+ urls = [
104
+ 'https://rutube.ru/play/embed/abc123',
105
+ 'https://www.youtube.com/embed/xyz789',
106
+ 'https://player.vimeo.com/video/111222',
107
+ 'https://ok.ru/videoembed/333444',
108
+ 'https://vk.com/video_ext.php?oid=-1&id=2',
109
+ ]
110
+ for url in urls:
111
+ self.assertTrue(
112
+ self.downloader._is_video_embed(url),
113
+ f"Должен распознать: {url}"
114
+ )
115
+
116
+ def test_is_video_embed_rejects_non_video(self):
117
+ """_is_video_embed отклоняет обычные URL."""
118
+ urls = [
119
+ 'https://example.com/page',
120
+ 'https://rutube.ru/video/abc123/', # watch URL, не embed
121
+ 'https://google.com',
122
+ ]
123
+ for url in urls:
124
+ self.assertFalse(
125
+ self.downloader._is_video_embed(url),
126
+ f"Не должен распознать: {url}"
127
+ )
128
+
129
+
130
+ class BoostyVideoEmbedTests(unittest.TestCase):
131
+ """Тесты встраивания видео для Boosty."""
132
+
133
+ def setUp(self):
134
+ self.config = Config(output_dir=Path('/tmp/test'), auth=Auth())
135
+ self.source = Source(platform='boosty', author='test_author')
136
+ self.db = MagicMock(spec=Database)
137
+
138
+ with patch('src.boosty.load_cookie', return_value='fake_cookie'), \
139
+ patch('src.boosty.load_auth_header', return_value='Bearer fake_token'):
140
+ self.downloader = BoostyDownloader(self.config, self.source, self.db)
141
+
142
+ def test_ok_video_becomes_markdown_link(self):
143
+ """ok_video блок → markdown-ссылка с embed URL."""
144
+ blocks = [
145
+ {"type": "ok_video", "id": "123456789"},
146
+ ]
147
+ post = Post(
148
+ post_id='1', title='Test',
149
+ content_html=json.dumps(blocks),
150
+ post_date='2025-01-01', source_url='https://test.com',
151
+ tags=[], assets=[]
152
+ )
153
+
154
+ result = self.downloader._to_markdown(post, {})
155
+
156
+ self.assertIn('[📹 Видео](https://ok.ru/videoembed/123456789)', result)
157
+ # Не должно быть старого формата
158
+ self.assertNotIn('📹 Видео:', result)
159
+
160
+ def test_ok_video_with_surrounding_text(self):
161
+ """ok_video между текстовыми блоками."""
162
+ blocks = [
163
+ {"type": "text", "content": json.dumps(["Посмотрите видео:"])},
164
+ {"type": "text", "modificator": "BLOCK_END"},
165
+ {"type": "ok_video", "id": "999888777"},
166
+ {"type": "text", "content": json.dumps(["Вот такие дела."])},
167
+ {"type": "text", "modificator": "BLOCK_END"},
168
+ ]
169
+ post = Post(
170
+ post_id='1', title='Test',
171
+ content_html=json.dumps(blocks),
172
+ post_date='2025-01-01', source_url='https://test.com',
173
+ tags=[], assets=[]
174
+ )
175
+
176
+ result = self.downloader._to_markdown(post, {})
177
+
178
+ self.assertIn('Посмотрите видео:', result)
179
+ self.assertIn('[📹 Видео](https://ok.ru/videoembed/999888777)', result)
180
+ self.assertIn('Вот такие дела.', result)
181
+
182
+
183
+ if __name__ == '__main__':
184
+ unittest.main()
File without changes
File without changes
File without changes
File without changes