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.
- {article_backup-0.3.3 → article_backup-0.3.4}/PKG-INFO +1 -1
- {article_backup-0.3.3 → article_backup-0.3.4}/article_backup.egg-info/PKG-INFO +1 -1
- {article_backup-0.3.3 → article_backup-0.3.4}/article_backup.egg-info/SOURCES.txt +3 -1
- {article_backup-0.3.3 → article_backup-0.3.4}/pyproject.toml +1 -1
- {article_backup-0.3.3 → article_backup-0.3.4}/src/boosty.py +3 -3
- {article_backup-0.3.3 → article_backup-0.3.4}/src/sponsr.py +151 -25
- article_backup-0.3.4/tests/test_boosty_empty_link.py +86 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/tests/test_sponsr_normalize.py +115 -7
- article_backup-0.3.4/tests/test_video_embed.py +184 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/LICENSE +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/README.md +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/article_backup.egg-info/dependency_links.txt +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/article_backup.egg-info/entry_points.txt +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/article_backup.egg-info/requires.txt +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/article_backup.egg-info/top_level.txt +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/backup.py +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/setup.cfg +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/src/__init__.py +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/src/config.py +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/src/database.py +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/src/downloader.py +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/src/utils.py +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/tests/test_asset_dedup.py +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/tests/test_boosty_normalize.py +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/tests/test_incremental_sync.py +0 -0
- {article_backup-0.3.3 → article_backup-0.3.4}/tests/test_sponsr_tags.py +0 -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
|
|
@@ -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
|
-
|
|
244
|
-
|
|
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
|
|
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
|
-
# Паттерны для
|
|
17
|
+
# Паттерны для распознавания embed URL видеохостингов (whitelist).
|
|
18
|
+
# Если iframe src матчит один из паттернов — это встроенное видео.
|
|
18
19
|
VIDEO_EMBED_PATTERNS = [
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
257
|
-
"""
|
|
258
|
-
for pattern
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 видео на
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
247
|
-
#
|
|
248
|
-
self.assertIn('
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
self.assertNotIn('
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|