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.
Files changed (28) hide show
  1. {article_backup-0.3.5 → article_backup-0.3.6}/PKG-INFO +1 -1
  2. {article_backup-0.3.5 → article_backup-0.3.6}/article_backup.egg-info/PKG-INFO +1 -1
  3. {article_backup-0.3.5 → article_backup-0.3.6}/pyproject.toml +1 -1
  4. {article_backup-0.3.5 → article_backup-0.3.6}/src/sponsr.py +51 -35
  5. {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_sponsr_normalize.py +94 -0
  6. {article_backup-0.3.5 → article_backup-0.3.6}/LICENSE +0 -0
  7. {article_backup-0.3.5 → article_backup-0.3.6}/README.md +0 -0
  8. {article_backup-0.3.5 → article_backup-0.3.6}/article_backup.egg-info/SOURCES.txt +0 -0
  9. {article_backup-0.3.5 → article_backup-0.3.6}/article_backup.egg-info/dependency_links.txt +0 -0
  10. {article_backup-0.3.5 → article_backup-0.3.6}/article_backup.egg-info/entry_points.txt +0 -0
  11. {article_backup-0.3.5 → article_backup-0.3.6}/article_backup.egg-info/requires.txt +0 -0
  12. {article_backup-0.3.5 → article_backup-0.3.6}/article_backup.egg-info/top_level.txt +0 -0
  13. {article_backup-0.3.5 → article_backup-0.3.6}/backup.py +0 -0
  14. {article_backup-0.3.5 → article_backup-0.3.6}/setup.cfg +0 -0
  15. {article_backup-0.3.5 → article_backup-0.3.6}/src/__init__.py +0 -0
  16. {article_backup-0.3.5 → article_backup-0.3.6}/src/boosty.py +0 -0
  17. {article_backup-0.3.5 → article_backup-0.3.6}/src/config.py +0 -0
  18. {article_backup-0.3.5 → article_backup-0.3.6}/src/database.py +0 -0
  19. {article_backup-0.3.5 → article_backup-0.3.6}/src/downloader.py +0 -0
  20. {article_backup-0.3.5 → article_backup-0.3.6}/src/utils.py +0 -0
  21. {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_asset_dedup.py +0 -0
  22. {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_boosty_empty_link.py +0 -0
  23. {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_boosty_normalize.py +0 -0
  24. {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_config_hardening.py +0 -0
  25. {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_incremental_sync.py +0 -0
  26. {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_slug_safety.py +0 -0
  27. {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_sponsr_tags.py +0 -0
  28. {article_backup-0.3.5 → article_backup-0.3.6}/tests/test_video_embed.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: article-backup
3
- Version: 0.3.5
3
+ Version: 0.3.6
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.5
3
+ Version: 0.3.6
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
  [project]
2
2
  name = "article-backup"
3
- version = "0.3.5"
3
+ version = "0.3.6"
4
4
  description = "Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией"
5
5
  readme = "README.md"
6
6
  license = {text = "Apache-2.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
- def _fix_spacing(text: str, pattern: re.Pattern) -> str:
571
- """Добавляет пробелы вокруг элементов, если их нет."""
572
- parts = []
573
- last = 0
574
- for match in pattern.finditer(text):
575
- start, end = match.span()
576
- before = text[last:start]
577
-
578
- # Добавляем пробел слева, если нужно
579
- if start > 0 and before and before[-1].isalnum():
580
- before = before + ' '
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