article-backup 0.3.11__tar.gz → 0.3.12__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 (30) hide show
  1. {article_backup-0.3.11 → article_backup-0.3.12}/PKG-INFO +8 -1
  2. {article_backup-0.3.11 → article_backup-0.3.12}/README.md +7 -0
  3. {article_backup-0.3.11 → article_backup-0.3.12}/article_backup.egg-info/PKG-INFO +8 -1
  4. {article_backup-0.3.11 → article_backup-0.3.12}/article_backup.egg-info/SOURCES.txt +1 -0
  5. {article_backup-0.3.11 → article_backup-0.3.12}/backup.py +33 -5
  6. {article_backup-0.3.11 → article_backup-0.3.12}/pyproject.toml +1 -1
  7. {article_backup-0.3.11 → article_backup-0.3.12}/src/boosty.py +11 -0
  8. {article_backup-0.3.11 → article_backup-0.3.12}/src/config.py +18 -1
  9. {article_backup-0.3.11 → article_backup-0.3.12}/src/downloader.py +4 -0
  10. {article_backup-0.3.11 → article_backup-0.3.12}/src/sponsr.py +14 -0
  11. {article_backup-0.3.11 → article_backup-0.3.12}/tests/test_config_hardening.py +20 -0
  12. {article_backup-0.3.11 → article_backup-0.3.12}/tests/test_sponsr_normalize.py +1 -0
  13. article_backup-0.3.12/tests/test_sync_policy.py +129 -0
  14. {article_backup-0.3.11 → article_backup-0.3.12}/LICENSE +0 -0
  15. {article_backup-0.3.11 → article_backup-0.3.12}/article_backup.egg-info/dependency_links.txt +0 -0
  16. {article_backup-0.3.11 → article_backup-0.3.12}/article_backup.egg-info/entry_points.txt +0 -0
  17. {article_backup-0.3.11 → article_backup-0.3.12}/article_backup.egg-info/requires.txt +0 -0
  18. {article_backup-0.3.11 → article_backup-0.3.12}/article_backup.egg-info/top_level.txt +0 -0
  19. {article_backup-0.3.11 → article_backup-0.3.12}/setup.cfg +0 -0
  20. {article_backup-0.3.11 → article_backup-0.3.12}/src/__init__.py +0 -0
  21. {article_backup-0.3.11 → article_backup-0.3.12}/src/database.py +0 -0
  22. {article_backup-0.3.11 → article_backup-0.3.12}/src/utils.py +0 -0
  23. {article_backup-0.3.11 → article_backup-0.3.12}/tests/test_asset_dedup.py +0 -0
  24. {article_backup-0.3.11 → article_backup-0.3.12}/tests/test_boosty_empty_link.py +0 -0
  25. {article_backup-0.3.11 → article_backup-0.3.12}/tests/test_boosty_normalize.py +0 -0
  26. {article_backup-0.3.11 → article_backup-0.3.12}/tests/test_incremental_sync.py +0 -0
  27. {article_backup-0.3.11 → article_backup-0.3.12}/tests/test_slug_safety.py +0 -0
  28. {article_backup-0.3.11 → article_backup-0.3.12}/tests/test_sponsr_formatting_fix.py +0 -0
  29. {article_backup-0.3.11 → article_backup-0.3.12}/tests/test_sponsr_tags.py +0 -0
  30. {article_backup-0.3.11 → article_backup-0.3.12}/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.11
3
+ Version: 0.3.12
4
4
  Summary: Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией
5
5
  Author-email: Eugene Chaykin <eugene@chayk.in>
6
6
  License: Apache-2.0
@@ -91,6 +91,11 @@ hugo:
91
91
  title: "Бэкап статей"
92
92
  language_code: "ru"
93
93
 
94
+ sync:
95
+ # stop: остановиться, если авторизация любого источника не прошла
96
+ # continue: пропустить проблемные источники и собрать сайт из доступных данных
97
+ on_error: stop
98
+
94
99
  auth:
95
100
  sponsr_cookie_file: ./sponsr_cookie.txt
96
101
  boosty_cookie_file: ./boosty_cookie.txt
@@ -142,6 +147,8 @@ article-backup
142
147
  python backup.py
143
148
  ```
144
149
 
150
+ Перед скачиванием выполняется проверка авторизации для всех источников. По умолчанию `sync.on_error: stop`: если один токен протух, скачивание не начинается и команда завершается с ошибкой. Если указать `sync.on_error: continue`, источники с ошибками авторизации будут пропущены, остальные источники синхронизируются, а Docker-запуск продолжит сборку Hugo-сайта.
151
+
145
152
  ### Скачать один пост по URL
146
153
 
147
154
  ```bash
@@ -60,6 +60,11 @@ hugo:
60
60
  title: "Бэкап статей"
61
61
  language_code: "ru"
62
62
 
63
+ sync:
64
+ # stop: остановиться, если авторизация любого источника не прошла
65
+ # continue: пропустить проблемные источники и собрать сайт из доступных данных
66
+ on_error: stop
67
+
63
68
  auth:
64
69
  sponsr_cookie_file: ./sponsr_cookie.txt
65
70
  boosty_cookie_file: ./boosty_cookie.txt
@@ -111,6 +116,8 @@ article-backup
111
116
  python backup.py
112
117
  ```
113
118
 
119
+ Перед скачиванием выполняется проверка авторизации для всех источников. По умолчанию `sync.on_error: stop`: если один токен протух, скачивание не начинается и команда завершается с ошибкой. Если указать `sync.on_error: continue`, источники с ошибками авторизации будут пропущены, остальные источники синхронизируются, а Docker-запуск продолжит сборку Hugo-сайта.
120
+
114
121
  ### Скачать один пост по URL
115
122
 
116
123
  ```bash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: article-backup
3
- Version: 0.3.11
3
+ Version: 0.3.12
4
4
  Summary: Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией
5
5
  Author-email: Eugene Chaykin <eugene@chayk.in>
6
6
  License: Apache-2.0
@@ -91,6 +91,11 @@ hugo:
91
91
  title: "Бэкап статей"
92
92
  language_code: "ru"
93
93
 
94
+ sync:
95
+ # stop: остановиться, если авторизация любого источника не прошла
96
+ # continue: пропустить проблемные источники и собрать сайт из доступных данных
97
+ on_error: stop
98
+
94
99
  auth:
95
100
  sponsr_cookie_file: ./sponsr_cookie.txt
96
101
  boosty_cookie_file: ./boosty_cookie.txt
@@ -142,6 +147,8 @@ article-backup
142
147
  python backup.py
143
148
  ```
144
149
 
150
+ Перед скачиванием выполняется проверка авторизации для всех источников. По умолчанию `sync.on_error: stop`: если один токен протух, скачивание не начинается и команда завершается с ошибкой. Если указать `sync.on_error: continue`, источники с ошибками авторизации будут пропущены, остальные источники синхронизируются, а Docker-запуск продолжит сборку Hugo-сайта.
151
+
145
152
  ### Скачать один пост по URL
146
153
 
147
154
  ```bash
@@ -24,4 +24,5 @@ tests/test_slug_safety.py
24
24
  tests/test_sponsr_formatting_fix.py
25
25
  tests/test_sponsr_normalize.py
26
26
  tests/test_sponsr_tags.py
27
+ tests/test_sync_policy.py
27
28
  tests/test_video_embed.py
@@ -26,7 +26,7 @@ def generate_hugo_config(config: Config):
26
26
  return json.dumps(value, ensure_ascii=False)
27
27
 
28
28
  content = f'''baseURL = {toml_str(config.hugo.base_url)}
29
- languageCode = {toml_str(config.hugo.language_code)}
29
+ locale = {toml_str(config.hugo.language_code)}
30
30
  title = {toml_str(config.hugo.title)}
31
31
  relativeURLs = true
32
32
 
@@ -91,16 +91,35 @@ def get_downloader(platform: str, config: Config, source: Source, db: Database):
91
91
  raise ValueError(f"Неизвестная платформа: {platform}")
92
92
 
93
93
 
94
- def sync_all(config: Config, db: Database):
95
- """Синхронизирует всех авторов из конфига."""
94
+ def preflight_sources(config: Config, db: Database):
95
+ """Проверяет доступность источников до начала синхронизации."""
96
+ ready_sources: list[Source] = []
96
97
  errors: list[tuple[Source, Exception]] = []
98
+
97
99
  for source in config.sources:
100
+ try:
101
+ downloader = get_downloader(source.platform, config, source, db)
102
+ downloader.check_auth()
103
+ ready_sources.append(source)
104
+ except Exception as e:
105
+ print(f"[{source.platform}] Ошибка проверки авторизации {source.author}: {e}")
106
+ errors.append((source, e))
107
+
108
+ return ready_sources, errors
109
+
110
+
111
+ def sync_all(config: Config, db: Database, sources: list[Source] | None = None):
112
+ """Синхронизирует всех авторов из конфига."""
113
+ errors: list[tuple[Source, Exception]] = []
114
+ for source in sources if sources is not None else config.sources:
98
115
  try:
99
116
  downloader = get_downloader(source.platform, config, source, db)
100
117
  downloader.sync()
101
118
  except Exception as e:
102
119
  print(f"[{source.platform}] Ошибка при синхронизации {source.author}: {e}")
103
120
  errors.append((source, e))
121
+ if config.sync.on_error == 'stop':
122
+ break
104
123
  return errors
105
124
 
106
125
 
@@ -181,7 +200,15 @@ def main():
181
200
  if not config.sources:
182
201
  print("Нет источников в конфиге. Добавьте секцию 'sources'.")
183
202
  sys.exit(1)
184
- sync_errors = sync_all(config, db)
203
+ ready_sources, preflight_errors = preflight_sources(config, db)
204
+ if preflight_errors:
205
+ sync_errors.extend(preflight_errors)
206
+ if config.sync.on_error == 'stop':
207
+ print("\nОстановлено из-за ошибок проверки авторизации.")
208
+ else:
209
+ print("\nИсточники с ошибками проверки авторизации будут пропущены.")
210
+ if not preflight_errors or config.sync.on_error == 'continue':
211
+ sync_errors.extend(sync_all(config, db, ready_sources))
185
212
 
186
213
  ensure_site_content_link(config)
187
214
  generate_hugo_config(config)
@@ -190,7 +217,8 @@ def main():
190
217
  print(f"\nЗавершено с ошибками: {len(sync_errors)}")
191
218
  for source, error in sync_errors:
192
219
  print(f" - [{source.platform}] {source.author}: {error}")
193
- sys.exit(1)
220
+ if config.sync.on_error == 'stop':
221
+ sys.exit(1)
194
222
 
195
223
  print("\nГотово!")
196
224
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "article-backup"
3
- version = "0.3.11"
3
+ version = "0.3.12"
4
4
  description = "Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией"
5
5
  readme = "README.md"
6
6
  license = {text = "Apache-2.0"}
@@ -43,6 +43,17 @@ class BoostyDownloader(BaseDownloader):
43
43
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
44
44
  })
45
45
 
46
+ def check_auth(self):
47
+ """Проверяет доступ к ленте автора минимальным API-запросом."""
48
+ url = f"{self.API_BASE}/blog/{self.source.author}/post/?limit=1"
49
+
50
+ def do_request():
51
+ resp = self.session.get(url, timeout=self.TIMEOUT)
52
+ resp.raise_for_status()
53
+ return resp
54
+
55
+ retry_request(do_request, max_retries=3)
56
+
46
57
  def fetch_posts_list(
47
58
  self,
48
59
  existing_ids: set[str] | None = None,
@@ -34,12 +34,18 @@ class HugoConfig:
34
34
  default_theme: str = "light"
35
35
 
36
36
 
37
+ @dataclass
38
+ class SyncConfig:
39
+ on_error: Literal['stop', 'continue'] = "stop"
40
+
41
+
37
42
  @dataclass
38
43
  class Config:
39
44
  output_dir: Path
40
45
  auth: Auth
41
46
  sources: list[Source] = field(default_factory=list)
42
47
  hugo: HugoConfig = field(default_factory=HugoConfig)
48
+ sync: SyncConfig = field(default_factory=SyncConfig)
43
49
 
44
50
 
45
51
  def load_config(config_path: Path) -> Config:
@@ -105,7 +111,18 @@ def load_config(config_path: Path) -> Config:
105
111
  default_theme=hugo_data.get('default_theme', HugoConfig.default_theme),
106
112
  )
107
113
 
108
- return Config(output_dir=output_dir, auth=auth, sources=sources, hugo=hugo)
114
+ # sync
115
+ sync_data = data.get('sync', {})
116
+ if sync_data is None:
117
+ sync_data = {}
118
+ if not isinstance(sync_data, dict):
119
+ raise ValueError("Секция 'sync' должна быть объектом")
120
+ sync_on_error = sync_data.get('on_error', SyncConfig.on_error)
121
+ if sync_on_error not in ('stop', 'continue'):
122
+ raise ValueError("sync.on_error должен быть 'stop' или 'continue'")
123
+ sync = SyncConfig(on_error=sync_on_error)
124
+
125
+ return Config(output_dir=output_dir, auth=auth, sources=sources, hugo=hugo, sync=sync)
109
126
 
110
127
 
111
128
  def _to_path(value: str | None) -> Path | None:
@@ -98,6 +98,10 @@ class BaseDownloader(ABC):
98
98
  """Настройка сессии (cookies, headers)."""
99
99
  pass
100
100
 
101
+ def check_auth(self):
102
+ """Проверяет, что авторизация позволяет читать источник."""
103
+ raise NotImplementedError(f"{self.PLATFORM} не реализует проверку авторизации")
104
+
101
105
  @abstractmethod
102
106
  def fetch_posts_list(
103
107
  self,
@@ -44,6 +44,18 @@ class SponsorDownloader(BaseDownloader):
44
44
  'X-Requested-With': 'XMLHttpRequest',
45
45
  })
46
46
 
47
+ def check_auth(self):
48
+ """Проверяет доступ к проекту минимальным API-запросом."""
49
+ project_id = self._get_project_id()
50
+ api_url = f"https://sponsr.ru/project/{project_id}/more-posts/?offset=0"
51
+
52
+ def do_request():
53
+ resp = self.session.get(api_url, timeout=self.TIMEOUT)
54
+ resp.raise_for_status()
55
+ return resp
56
+
57
+ retry_request(do_request, max_retries=3)
58
+
47
59
  def _get_project_id(self) -> str:
48
60
  """Получает project_id со страницы проекта."""
49
61
  if self._project_id:
@@ -640,6 +652,8 @@ class SponsorDownloader(BaseDownloader):
640
652
  markdown = markdown.replace('@@@LBR@@@', r'\[')
641
653
  markdown = markdown.replace('@@@RBR@@@', r'\]')
642
654
  # Заменяем маркеры пробелов, вставленные в DOM
655
+ markdown = re.sub(r'[ \t]*@@@SP@@@[ \t]*', '@@@SP@@@', markdown)
656
+ markdown = re.sub(r'(?:@@@SP@@@)+', '@@@SP@@@', markdown)
643
657
  markdown = markdown.replace('@@@SP@@@', ' ')
644
658
 
645
659
  # Удаляем bidi-маркеры, которые ломают пробелы рядом с текстом
@@ -17,6 +17,24 @@ class ConfigHardeningTests(unittest.TestCase):
17
17
 
18
18
  self.assertEqual(cfg.output_dir, Path("./backup"))
19
19
  self.assertEqual(cfg.sources, [])
20
+ self.assertEqual(cfg.sync.on_error, "stop")
21
+
22
+ def test_load_config_accepts_sync_continue_policy(self):
23
+ with tempfile.TemporaryDirectory() as tmp:
24
+ cfg_path = Path(tmp) / "config.yaml"
25
+ cfg_path.write_text("sync:\n on_error: continue\n", encoding="utf-8")
26
+
27
+ cfg = load_config(cfg_path)
28
+
29
+ self.assertEqual(cfg.sync.on_error, "continue")
30
+
31
+ def test_load_config_rejects_unknown_sync_policy(self):
32
+ with tempfile.TemporaryDirectory() as tmp:
33
+ cfg_path = Path(tmp) / "config.yaml"
34
+ cfg_path.write_text("sync:\n on_error: ignore\n", encoding="utf-8")
35
+
36
+ with self.assertRaisesRegex(ValueError, "sync.on_error"):
37
+ load_config(cfg_path)
20
38
 
21
39
  def test_generate_hugo_config_escapes_quotes(self):
22
40
  with tempfile.TemporaryDirectory() as tmp:
@@ -42,6 +60,8 @@ class ConfigHardeningTests(unittest.TestCase):
42
60
 
43
61
  self.assertIn('title = "Bob\'s \\"backup\\""', toml)
44
62
  self.assertIn('baseURL = "https://example.com/a\\"b"', toml)
63
+ self.assertIn('locale = "ru"', toml)
64
+ self.assertNotIn('languageCode', toml)
45
65
  self.assertIn('default_theme = "light\\"mode"', toml)
46
66
  finally:
47
67
  os.chdir(old_cwd)
@@ -119,6 +119,7 @@ class SponsorNormalizeTests(unittest.TestCase):
119
119
  # Ожидаем пробелы вокруг **жирное**
120
120
  self.assertIn('слово **жирное** слово', result)
121
121
  self.assertNotIn('слово**жирное**слово', result)
122
+ self.assertNotIn('**жирное** слово', result)
122
123
 
123
124
  def test_real_world_case_from_issue(self):
124
125
  """Тест реального случая из issue."""
@@ -0,0 +1,129 @@
1
+ import unittest
2
+ import sys
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ import backup
7
+ from src.config import Auth, Config, Source, SyncConfig
8
+
9
+
10
+ class DummyDownloader:
11
+ checks: list[str] = []
12
+ synced: list[str] = []
13
+ check_failures: dict[str, Exception] = {}
14
+ sync_failures: dict[str, Exception] = {}
15
+
16
+ def __init__(self, config, source, db):
17
+ self.source = source
18
+
19
+ def check_auth(self):
20
+ DummyDownloader.checks.append(self.source.author)
21
+ error = DummyDownloader.check_failures.get(self.source.author)
22
+ if error:
23
+ raise error
24
+
25
+ def sync(self):
26
+ DummyDownloader.synced.append(self.source.author)
27
+ error = DummyDownloader.sync_failures.get(self.source.author)
28
+ if error:
29
+ raise error
30
+
31
+
32
+ class SyncPolicyTests(unittest.TestCase):
33
+ def setUp(self):
34
+ self.old_get_downloader = backup.get_downloader
35
+ backup.get_downloader = lambda platform, config, source, db: DummyDownloader(config, source, db)
36
+ DummyDownloader.checks = []
37
+ DummyDownloader.synced = []
38
+ DummyDownloader.check_failures = {}
39
+ DummyDownloader.sync_failures = {}
40
+
41
+ def tearDown(self):
42
+ backup.get_downloader = self.old_get_downloader
43
+
44
+ def make_config(self, on_error):
45
+ return Config(
46
+ output_dir=Path("/tmp/test"),
47
+ auth=Auth(),
48
+ sources=[
49
+ Source(platform="sponsr", author="good"),
50
+ Source(platform="boosty", author="bad"),
51
+ ],
52
+ sync=SyncConfig(on_error=on_error),
53
+ )
54
+
55
+ def test_preflight_continue_filters_failed_sources(self):
56
+ config = self.make_config("continue")
57
+ DummyDownloader.check_failures = {"bad": RuntimeError("401 Unauthorized")}
58
+
59
+ ready_sources, errors = backup.preflight_sources(config, object())
60
+
61
+ self.assertEqual([source.author for source in ready_sources], ["good"])
62
+ self.assertEqual([source.author for source, _ in errors], ["bad"])
63
+ self.assertEqual(DummyDownloader.checks, ["good", "bad"])
64
+
65
+ def test_sync_all_continue_keeps_syncing_after_source_error(self):
66
+ config = self.make_config("continue")
67
+ DummyDownloader.sync_failures = {"good": RuntimeError("boom")}
68
+
69
+ errors = backup.sync_all(config, object())
70
+
71
+ self.assertEqual([source.author for source, _ in errors], ["good"])
72
+ self.assertEqual(DummyDownloader.synced, ["good", "bad"])
73
+
74
+ def test_sync_all_stop_stops_after_first_source_error(self):
75
+ config = self.make_config("stop")
76
+ DummyDownloader.sync_failures = {"good": RuntimeError("boom")}
77
+
78
+ errors = backup.sync_all(config, object())
79
+
80
+ self.assertEqual([source.author for source, _ in errors], ["good"])
81
+ self.assertEqual(DummyDownloader.synced, ["good"])
82
+
83
+ def test_main_continue_preflight_errors_do_not_exit_with_failure(self):
84
+ config = self.make_config("continue")
85
+ DummyDownloader.check_failures = {"bad": RuntimeError("401 Unauthorized")}
86
+
87
+ class DummyDatabase:
88
+ def __init__(self, path):
89
+ self.path = path
90
+
91
+ def __enter__(self):
92
+ return self
93
+
94
+ def __exit__(self, exc_type, exc, tb):
95
+ return False
96
+
97
+ old_argv = sys.argv
98
+ old_load_config = backup.load_config
99
+ old_database = backup.Database
100
+ old_ensure_link = backup.ensure_site_content_link
101
+ old_generate_hugo_config = backup.generate_hugo_config
102
+
103
+ with tempfile.TemporaryDirectory() as tmp:
104
+ cfg_path = Path(tmp) / "config.yaml"
105
+ cfg_path.write_text("", encoding="utf-8")
106
+ config.output_dir = Path(tmp) / "backup"
107
+
108
+ try:
109
+ sys.argv = ["backup.py", "--config", str(cfg_path)]
110
+ backup.load_config = lambda path: config
111
+ backup.Database = DummyDatabase
112
+ backup.ensure_site_content_link = lambda cfg: None
113
+ backup.generate_hugo_config = lambda cfg: None
114
+
115
+ backup.main()
116
+ except SystemExit as e:
117
+ self.fail(f"main() exited with {e.code} for continue policy")
118
+ finally:
119
+ sys.argv = old_argv
120
+ backup.load_config = old_load_config
121
+ backup.Database = old_database
122
+ backup.ensure_site_content_link = old_ensure_link
123
+ backup.generate_hugo_config = old_generate_hugo_config
124
+
125
+ self.assertEqual(DummyDownloader.synced, ["good"])
126
+
127
+
128
+ if __name__ == "__main__":
129
+ unittest.main()
File without changes