article-backup 0.3.5__tar.gz → 0.3.6__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.5 → article_backup-0.3.6}/PKG-INFO +1 -1
- {article_backup-0.3.5 → article_backup-0.3.6}/article_backup.egg-info/PKG-INFO +1 -1
- {article_backup-0.3.5 → article_backup-0.3.6}/pyproject.toml +1 -1
- {article_backup-0.3.5 → article_backup-0.3.6}/src/sponsr.py +51 -35
- {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_sponsr_normalize.py +94 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/LICENSE +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/README.md +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/article_backup.egg-info/SOURCES.txt +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/article_backup.egg-info/dependency_links.txt +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/article_backup.egg-info/entry_points.txt +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/article_backup.egg-info/requires.txt +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/article_backup.egg-info/top_level.txt +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/backup.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/setup.cfg +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/src/__init__.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/src/boosty.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/src/config.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/src/database.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/src/downloader.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/src/utils.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_asset_dedup.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_boosty_empty_link.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_boosty_normalize.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_config_hardening.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_incremental_sync.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_slug_safety.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_sponsr_tags.py +0 -0
- {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_video_embed.py +0 -0
|
@@ -361,12 +361,9 @@ class SponsorDownloader(BaseDownloader):
|
|
|
361
361
|
tag.insert_after(NavigableString(trailing))
|
|
362
362
|
|
|
363
363
|
# 4. Вынос trailing/leading пробелов из <a> тегов наружу
|
|
364
|
-
# После выноса пробелов из formatting тегов, пробел может остаться
|
|
365
|
-
# внутри <a> (но вне <em>/<b>), что даёт [текст ](url) в markdown
|
|
366
364
|
for tag in list(soup.find_all('a')):
|
|
367
365
|
if tag.parent is None:
|
|
368
366
|
continue
|
|
369
|
-
# Trailing: проверяем последний дочерний узел (может быть голый пробел)
|
|
370
367
|
children = list(tag.children)
|
|
371
368
|
if children:
|
|
372
369
|
last_child = children[-1]
|
|
@@ -375,8 +372,40 @@ class SponsorDownloader(BaseDownloader):
|
|
|
375
372
|
last_child.replace_with(NavigableString(str(last_child).rstrip()))
|
|
376
373
|
tag.insert_after(NavigableString(trailing))
|
|
377
374
|
|
|
375
|
+
# 5. Экранирование markdown-символов в текстовых узлах
|
|
376
|
+
# Чтобы "сырые" _, *, [ ] в тексте не превращались в разметку
|
|
377
|
+
self._escape_text_nodes(soup)
|
|
378
|
+
|
|
378
379
|
return str(soup)
|
|
379
380
|
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _escape_text_nodes(soup):
|
|
383
|
+
"""Экранирует спецсимволы Markdown в текстовых узлах."""
|
|
384
|
+
from bs4 import NavigableString
|
|
385
|
+
|
|
386
|
+
replacements = {
|
|
387
|
+
'_': '@@@US@@@',
|
|
388
|
+
'*': '@@@AST@@@',
|
|
389
|
+
'[': '@@@LBR@@@',
|
|
390
|
+
']': '@@@RBR@@@',
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for text_node in soup.find_all(string=True):
|
|
394
|
+
if text_node.parent and text_node.parent.name in ['script', 'style', 'title']:
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
text = str(text_node)
|
|
398
|
+
if not text:
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
new_text = text
|
|
402
|
+
for char, placeholder in replacements.items():
|
|
403
|
+
if char in new_text:
|
|
404
|
+
new_text = new_text.replace(char, placeholder)
|
|
405
|
+
|
|
406
|
+
if new_text != text:
|
|
407
|
+
text_node.replace_with(NavigableString(new_text))
|
|
408
|
+
|
|
380
409
|
@staticmethod
|
|
381
410
|
def _merge_adjacent_em(soup, em_tags: set, bold_tags: set):
|
|
382
411
|
"""Объединяет соседние <em>/<i> теги внутри одного родителя.
|
|
@@ -535,6 +564,12 @@ class SponsorDownloader(BaseDownloader):
|
|
|
535
564
|
|
|
536
565
|
markdown = h2t.handle(html)
|
|
537
566
|
|
|
567
|
+
# Восстанавливаем экранированные символы (из плейсхолдеров DOM)
|
|
568
|
+
markdown = markdown.replace('@@@US@@@', r'\_')
|
|
569
|
+
markdown = markdown.replace('@@@AST@@@', r'\*')
|
|
570
|
+
markdown = markdown.replace('@@@LBR@@@', r'\[')
|
|
571
|
+
markdown = markdown.replace('@@@RBR@@@', r'\]')
|
|
572
|
+
|
|
538
573
|
# Удаляем bidi-маркеры, которые ломают пробелы рядом с текстом
|
|
539
574
|
markdown = re.sub(r'[\u200e\u200f\u202a-\u202e\u2066-\u2069]', '', markdown)
|
|
540
575
|
|
|
@@ -566,38 +601,19 @@ class SponsorDownloader(BaseDownloader):
|
|
|
566
601
|
# Закрывающие: » " '
|
|
567
602
|
markdown = re.sub(r'\s+([\u00bb\u201d\u2019])', r'\1', markdown)
|
|
568
603
|
|
|
569
|
-
# Восстанавливаем пробелы вокруг
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
parts.append(before)
|
|
583
|
-
|
|
584
|
-
# Добавляем сам матч
|
|
585
|
-
matched_text = text[start:end]
|
|
586
|
-
|
|
587
|
-
# Добавляем пробел справа, если нужно
|
|
588
|
-
if end < len(text) and text[end].isalnum():
|
|
589
|
-
matched_text = matched_text + ' '
|
|
590
|
-
|
|
591
|
-
parts.append(matched_text)
|
|
592
|
-
last = end
|
|
593
|
-
|
|
594
|
-
parts.append(text[last:])
|
|
595
|
-
return ''.join(parts)
|
|
596
|
-
|
|
597
|
-
# Восстанавливаем пробелы вокруг bold-italic, bold, ссылок
|
|
598
|
-
markdown = _fix_spacing(markdown, re.compile(r'\*\*\*.+?\*\*\*'))
|
|
599
|
-
markdown = _fix_spacing(markdown, re.compile(r'(?<!\*)\*\*(?!\*).+?(?<!\*)\*\*(?!\*)'))
|
|
600
|
-
markdown = _fix_spacing(markdown, re.compile(r'\[[^\]]+\]\([^)]+\)'))
|
|
604
|
+
# Восстанавливаем пробелы вокруг **bold**
|
|
605
|
+
# html2text часто склеивает: слово**bold** -> слово **bold**
|
|
606
|
+
# Используем поиск пар **, чтобы не сломать closing tag (bold**word -> bold **word - WRONG)
|
|
607
|
+
# 1. Left side: word**bold** -> word **bold**
|
|
608
|
+
markdown = re.sub(r'(\w)\*\*(.+?)\*\*', r'\1 **\2**', markdown)
|
|
609
|
+
# 2. Right side: **bold**word -> **bold** word
|
|
610
|
+
markdown = re.sub(r'\*\*(.+?)\*\*(\w)', r'**\1** \2', markdown)
|
|
611
|
+
|
|
612
|
+
# Убираем пробел между ссылкой и знаками препинания (даже если они курсивные)
|
|
613
|
+
# [link](url) . -> [link](url).
|
|
614
|
+
# [link](url) _._ -> [link](url)_._
|
|
615
|
+
markdown = re.sub(r'(\)\s+)([.,:;!?])', r')\2', markdown)
|
|
616
|
+
markdown = re.sub(r'(\)\s+)(_[.,:;!?]_)', r')\2', markdown)
|
|
601
617
|
|
|
602
618
|
# Исправляем артефакты html2text внутри ссылок: [ _текст_ ] -> [_текст_]
|
|
603
619
|
markdown = re.sub(r'\[\s+_', r'[_', markdown)
|
|
@@ -412,6 +412,100 @@ class SponsorNormalizeTests(unittest.TestCase):
|
|
|
412
412
|
self.assertIn('_курсив2_', result)
|
|
413
413
|
self.assertIn('обычный', result)
|
|
414
414
|
|
|
415
|
+
def _convert_full(self, html):
|
|
416
|
+
"""Helper to convert HTML to Markdown (full text)."""
|
|
417
|
+
post = Post(
|
|
418
|
+
post_id='1',
|
|
419
|
+
title='Test',
|
|
420
|
+
content_html=html,
|
|
421
|
+
post_date='2025-01-01',
|
|
422
|
+
source_url='https://test.com',
|
|
423
|
+
tags=[],
|
|
424
|
+
assets=[]
|
|
425
|
+
)
|
|
426
|
+
return self.downloader._to_markdown(post, {})
|
|
427
|
+
|
|
428
|
+
def test_case_1_spacing_cleanup(self):
|
|
429
|
+
"""1. Пробелы внутри курсива (_ текст _) и вокруг."""
|
|
430
|
+
html = (
|
|
431
|
+
'<p>фильме.</em></p><p><em>Например, Гор предсказал, что к 2016 году на Килиманджаро не останется снега. '
|
|
432
|
+
'В 2020 году газета The Times сообщила, что снег на горе высотой 19 000 футов (около 5800 метров) остался, '
|
|
433
|
+
'несмотря на предсказания Гора. </em></p><p><em>Гор'
|
|
434
|
+
)
|
|
435
|
+
md = self._convert_full(html)
|
|
436
|
+
|
|
437
|
+
# Expectation: no spaces inside markers, clean paragraphs
|
|
438
|
+
self.assertIn('фильме.', md)
|
|
439
|
+
self.assertIn('_Например, Гор', md)
|
|
440
|
+
self.assertIn('предсказания Гора._', md)
|
|
441
|
+
self.assertIn('_Гор', md)
|
|
442
|
+
|
|
443
|
+
self.assertNotIn('_ Например', md)
|
|
444
|
+
self.assertNotIn('Гора. _', md)
|
|
445
|
+
self.assertNotIn(' _Гор', md)
|
|
446
|
+
|
|
447
|
+
def test_case_2_multiline_italic(self):
|
|
448
|
+
"""2. Курсив через границы абзацев."""
|
|
449
|
+
html = (
|
|
450
|
+
'<p>В.М.).</em></p><p><em>Метеоролог Крис Марц сказал, что климатология полна неопределенности и нюансов, '
|
|
451
|
+
'которые «Неудобная правда» полностью отвергает. </em></p><p><em>Однако'
|
|
452
|
+
)
|
|
453
|
+
md = self._convert_full(html)
|
|
454
|
+
|
|
455
|
+
self.assertIn('В.М.).', md)
|
|
456
|
+
self.assertIn('_Метеоролог Крис', md)
|
|
457
|
+
self.assertIn('отвергает._', md)
|
|
458
|
+
self.assertIn('_Однако', md)
|
|
459
|
+
|
|
460
|
+
self.assertNotIn('_ Метеоролог', md)
|
|
461
|
+
self.assertNotIn('отвергает. _', md)
|
|
462
|
+
|
|
463
|
+
def test_case_3_literal_underscore_in_text(self):
|
|
464
|
+
"""3. Символы _ в обычном тексте не должны становиться разметкой."""
|
|
465
|
+
html = (
|
|
466
|
+
'<p>сформулировал: «_39 лет я никогда не писал этих слов в отзыве на кино, а сейчас пишу: _'
|
|
467
|
+
'<a href="http://example.com" target="_blank"><em>вы <strong>обязаны</strong> это посмотреть</em></a>».</p><p>К тому же'
|
|
468
|
+
)
|
|
469
|
+
md = self._convert_full(html)
|
|
470
|
+
|
|
471
|
+
# Literal underscores should be escaped
|
|
472
|
+
self.assertIn(r'\_39 лет', md)
|
|
473
|
+
self.assertIn(r'пишу: \_', md)
|
|
474
|
+
|
|
475
|
+
# Link formatting should be clean
|
|
476
|
+
self.assertIn('[_вы **обязаны** это посмотреть_](http://example.com)', md)
|
|
477
|
+
|
|
478
|
+
# No extra spaces
|
|
479
|
+
self.assertNotIn('[ _вы', md)
|
|
480
|
+
|
|
481
|
+
def test_case_4_underscore_suffix(self):
|
|
482
|
+
"""4. Пробел перед закрывающим _."""
|
|
483
|
+
html = '<p>читатель данного проекта ощутил себя _не таким как все _(которого не проведёшь)?</p>'
|
|
484
|
+
md = self._convert_full(html)
|
|
485
|
+
|
|
486
|
+
# Literal underscores should be escaped
|
|
487
|
+
self.assertIn(r'\_не таким как все \_', md)
|
|
488
|
+
|
|
489
|
+
# Verify no unescaped underscores (except inside words if any, but here they are spaced)
|
|
490
|
+
# Using regex to ensure underscores are preceded by backslash
|
|
491
|
+
import re
|
|
492
|
+
self.assertFalse(re.search(r'(?<!\\)_', md), "Found unescaped underscore")
|
|
493
|
+
|
|
494
|
+
def test_case_5_link_italic_punctuation(self):
|
|
495
|
+
"""5. Курсив вокруг ссылки и точки."""
|
|
496
|
+
html = (
|
|
497
|
+
'<p>бежать.</em></p><p><em>Из нескольких разговоров ... из </em>'
|
|
498
|
+
'<a href="https://example.com" target="_blank"><em>свежего текста</em></a><em>.</em></p><p><em>Поэтому'
|
|
499
|
+
)
|
|
500
|
+
md = self._convert_full(html)
|
|
501
|
+
|
|
502
|
+
self.assertIn('бежать.', md)
|
|
503
|
+
self.assertIn('_Из нескольких', md)
|
|
504
|
+
# Link inside italic context
|
|
505
|
+
self.assertIn('](https://example.com)', md)
|
|
506
|
+
self.assertNotIn(' _.', md)
|
|
507
|
+
self.assertNotIn('_. _', md)
|
|
508
|
+
|
|
415
509
|
|
|
416
510
|
if __name__ == '__main__':
|
|
417
511
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|