article-backup 0.2.3__tar.gz → 0.3.0__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.2.3 → article_backup-0.3.0}/PKG-INFO +37 -11
- {article_backup-0.2.3 → article_backup-0.3.0}/README.md +36 -10
- {article_backup-0.2.3 → article_backup-0.3.0}/article_backup.egg-info/PKG-INFO +37 -11
- {article_backup-0.2.3 → article_backup-0.3.0}/article_backup.egg-info/SOURCES.txt +1 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/pyproject.toml +1 -1
- {article_backup-0.2.3 → article_backup-0.3.0}/src/boosty.py +33 -3
- {article_backup-0.2.3 → article_backup-0.3.0}/src/database.py +41 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/src/downloader.py +31 -3
- {article_backup-0.2.3 → article_backup-0.3.0}/src/sponsr.py +33 -3
- {article_backup-0.2.3 → article_backup-0.3.0}/tests/test_asset_dedup.py +6 -1
- article_backup-0.3.0/tests/test_incremental_sync.py +216 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/LICENSE +0 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/article_backup.egg-info/dependency_links.txt +0 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/article_backup.egg-info/entry_points.txt +0 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/article_backup.egg-info/requires.txt +0 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/article_backup.egg-info/top_level.txt +0 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/backup.py +0 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/setup.cfg +0 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/src/__init__.py +0 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/src/config.py +0 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/src/utils.py +0 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/tests/test_sponsr_normalize.py +0 -0
- {article_backup-0.2.3 → article_backup-0.3.0}/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
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией
|
|
5
5
|
Author-email: Eugene Chaykin <eugene@chayk.in>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -41,16 +41,16 @@ Dynamic: license-file
|
|
|
41
41
|
|
|
42
42
|
## Возможности
|
|
43
43
|
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
-
- SQLite
|
|
44
|
+
- **Полный архив статей** одного или нескольких авторов
|
|
45
|
+
- **Инкрементальная синхронизация** — после первой загрузки проверяет только новые посты (⚡ до 98% быстрее повторных запусков)
|
|
46
|
+
- **Конвертация в Markdown** с frontmatter (title, date, tags, source)
|
|
47
|
+
- **Локальное сохранение** изображений, видео, аудио, PDF
|
|
48
|
+
- **Гибкая фильтрация** типов скачиваемых файлов (image, video, audio, document)
|
|
49
|
+
- **Сохранение ссылок** на встроенные видео (Rutube, YouTube, Vimeo, VK, OK.ru)
|
|
50
|
+
- **Нормализация разметки** Sponsr (вложенный em/strong, кавычки, bidi-маркеры)
|
|
51
|
+
- **Исправление внутренних ссылок** между статьями
|
|
52
|
+
- **Интеграция с Hugo** для просмотра в браузере (поддержка тем, улучшенная типографика)
|
|
53
|
+
- **SQLite-индекс** для быстрого поиска
|
|
54
54
|
|
|
55
55
|
## Установка
|
|
56
56
|
|
|
@@ -155,6 +155,32 @@ article-backup "https://boosty.to/author/posts/uuid"
|
|
|
155
155
|
article-backup -c /path/to/config.yaml
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
+
### Инкрементальная синхронизация
|
|
159
|
+
|
|
160
|
+
После первого полного запуска скрипт автоматически переключается в инкрементальный режим:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
Первый запуск (полная загрузка):
|
|
164
|
+
[sponsr] Синхронизация author...
|
|
165
|
+
Полная загрузка индекса...
|
|
166
|
+
Получено 2156/2156 постов...
|
|
167
|
+
✓ Архив полностью синхронизирован
|
|
168
|
+
|
|
169
|
+
Повторные запуски (только новые посты):
|
|
170
|
+
[sponsr] Синхронизация author...
|
|
171
|
+
Инкрементальный режим...
|
|
172
|
+
Получено 20/2156 постов... (чанк уже скачан)
|
|
173
|
+
Получено 40/2156 постов... (чанк уже скачан)
|
|
174
|
+
⚡ Остановлено на 40 постах (все новые загружены)
|
|
175
|
+
Найдено постов: 40, новых: 0
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Производительность:**
|
|
179
|
+
- Автор с 2000+ постами: первый запуск ~30 мин, повторные ~30 сек
|
|
180
|
+
- Проверка обновлений для 10 авторов: ~5 мин вместо часов
|
|
181
|
+
|
|
182
|
+
Статус синхронизации хранится в `backup/index.db` (таблица `sync_state`). Старые базы данных автоматически обновляются при первом запуске новой версии.
|
|
183
|
+
|
|
158
184
|
## Разработка
|
|
159
185
|
|
|
160
186
|
### Тесты
|
|
@@ -10,16 +10,16 @@
|
|
|
10
10
|
|
|
11
11
|
## Возможности
|
|
12
12
|
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
- SQLite
|
|
13
|
+
- **Полный архив статей** одного или нескольких авторов
|
|
14
|
+
- **Инкрементальная синхронизация** — после первой загрузки проверяет только новые посты (⚡ до 98% быстрее повторных запусков)
|
|
15
|
+
- **Конвертация в Markdown** с frontmatter (title, date, tags, source)
|
|
16
|
+
- **Локальное сохранение** изображений, видео, аудио, PDF
|
|
17
|
+
- **Гибкая фильтрация** типов скачиваемых файлов (image, video, audio, document)
|
|
18
|
+
- **Сохранение ссылок** на встроенные видео (Rutube, YouTube, Vimeo, VK, OK.ru)
|
|
19
|
+
- **Нормализация разметки** Sponsr (вложенный em/strong, кавычки, bidi-маркеры)
|
|
20
|
+
- **Исправление внутренних ссылок** между статьями
|
|
21
|
+
- **Интеграция с Hugo** для просмотра в браузере (поддержка тем, улучшенная типографика)
|
|
22
|
+
- **SQLite-индекс** для быстрого поиска
|
|
23
23
|
|
|
24
24
|
## Установка
|
|
25
25
|
|
|
@@ -124,6 +124,32 @@ article-backup "https://boosty.to/author/posts/uuid"
|
|
|
124
124
|
article-backup -c /path/to/config.yaml
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
+
### Инкрементальная синхронизация
|
|
128
|
+
|
|
129
|
+
После первого полного запуска скрипт автоматически переключается в инкрементальный режим:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
Первый запуск (полная загрузка):
|
|
133
|
+
[sponsr] Синхронизация author...
|
|
134
|
+
Полная загрузка индекса...
|
|
135
|
+
Получено 2156/2156 постов...
|
|
136
|
+
✓ Архив полностью синхронизирован
|
|
137
|
+
|
|
138
|
+
Повторные запуски (только новые посты):
|
|
139
|
+
[sponsr] Синхронизация author...
|
|
140
|
+
Инкрементальный режим...
|
|
141
|
+
Получено 20/2156 постов... (чанк уже скачан)
|
|
142
|
+
Получено 40/2156 постов... (чанк уже скачан)
|
|
143
|
+
⚡ Остановлено на 40 постах (все новые загружены)
|
|
144
|
+
Найдено постов: 40, новых: 0
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Производительность:**
|
|
148
|
+
- Автор с 2000+ постами: первый запуск ~30 мин, повторные ~30 сек
|
|
149
|
+
- Проверка обновлений для 10 авторов: ~5 мин вместо часов
|
|
150
|
+
|
|
151
|
+
Статус синхронизации хранится в `backup/index.db` (таблица `sync_state`). Старые базы данных автоматически обновляются при первом запуске новой версии.
|
|
152
|
+
|
|
127
153
|
## Разработка
|
|
128
154
|
|
|
129
155
|
### Тесты
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: article-backup
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Локальный бэкап статей с Sponsr.ru и Boosty.to в Markdown с Hugo-интеграцией
|
|
5
5
|
Author-email: Eugene Chaykin <eugene@chayk.in>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -41,16 +41,16 @@ Dynamic: license-file
|
|
|
41
41
|
|
|
42
42
|
## Возможности
|
|
43
43
|
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
-
- SQLite
|
|
44
|
+
- **Полный архив статей** одного или нескольких авторов
|
|
45
|
+
- **Инкрементальная синхронизация** — после первой загрузки проверяет только новые посты (⚡ до 98% быстрее повторных запусков)
|
|
46
|
+
- **Конвертация в Markdown** с frontmatter (title, date, tags, source)
|
|
47
|
+
- **Локальное сохранение** изображений, видео, аудио, PDF
|
|
48
|
+
- **Гибкая фильтрация** типов скачиваемых файлов (image, video, audio, document)
|
|
49
|
+
- **Сохранение ссылок** на встроенные видео (Rutube, YouTube, Vimeo, VK, OK.ru)
|
|
50
|
+
- **Нормализация разметки** Sponsr (вложенный em/strong, кавычки, bidi-маркеры)
|
|
51
|
+
- **Исправление внутренних ссылок** между статьями
|
|
52
|
+
- **Интеграция с Hugo** для просмотра в браузере (поддержка тем, улучшенная типографика)
|
|
53
|
+
- **SQLite-индекс** для быстрого поиска
|
|
54
54
|
|
|
55
55
|
## Установка
|
|
56
56
|
|
|
@@ -155,6 +155,32 @@ article-backup "https://boosty.to/author/posts/uuid"
|
|
|
155
155
|
article-backup -c /path/to/config.yaml
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
+
### Инкрементальная синхронизация
|
|
159
|
+
|
|
160
|
+
После первого полного запуска скрипт автоматически переключается в инкрементальный режим:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
Первый запуск (полная загрузка):
|
|
164
|
+
[sponsr] Синхронизация author...
|
|
165
|
+
Полная загрузка индекса...
|
|
166
|
+
Получено 2156/2156 постов...
|
|
167
|
+
✓ Архив полностью синхронизирован
|
|
168
|
+
|
|
169
|
+
Повторные запуски (только новые посты):
|
|
170
|
+
[sponsr] Синхронизация author...
|
|
171
|
+
Инкрементальный режим...
|
|
172
|
+
Получено 20/2156 постов... (чанк уже скачан)
|
|
173
|
+
Получено 40/2156 постов... (чанк уже скачан)
|
|
174
|
+
⚡ Остановлено на 40 постах (все новые загружены)
|
|
175
|
+
Найдено постов: 40, новых: 0
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Производительность:**
|
|
179
|
+
- Автор с 2000+ постами: первый запуск ~30 мин, повторные ~30 сек
|
|
180
|
+
- Проверка обновлений для 10 авторов: ~5 мин вместо часов
|
|
181
|
+
|
|
182
|
+
Статус синхронизации хранится в `backup/index.db` (таблица `sync_state`). Старые базы данных автоматически обновляются при первом запуске новой версии.
|
|
183
|
+
|
|
158
184
|
## Разработка
|
|
159
185
|
|
|
160
186
|
### Тесты
|
|
@@ -28,10 +28,23 @@ class BoostyDownloader(BaseDownloader):
|
|
|
28
28
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
def fetch_posts_list(
|
|
32
|
-
|
|
31
|
+
def fetch_posts_list(
|
|
32
|
+
self,
|
|
33
|
+
existing_ids: set[str] | None = None,
|
|
34
|
+
incremental: bool = False,
|
|
35
|
+
safety_chunks: int = 1
|
|
36
|
+
) -> list[dict]:
|
|
37
|
+
"""
|
|
38
|
+
Получает список постов через API.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
existing_ids: Множество уже загруженных post_id (для инкрементального режима)
|
|
42
|
+
incremental: Включить инкрементальный режим
|
|
43
|
+
safety_chunks: Количество "защитных" чанков перед остановкой
|
|
44
|
+
"""
|
|
33
45
|
all_posts = []
|
|
34
46
|
offset = None
|
|
47
|
+
clean_chunks_count = 0 # Счётчик "чистых" чанков
|
|
35
48
|
|
|
36
49
|
while True:
|
|
37
50
|
url = f"{self.API_BASE}/blog/{self.source.author}/post/?limit=20"
|
|
@@ -48,7 +61,24 @@ class BoostyDownloader(BaseDownloader):
|
|
|
48
61
|
break
|
|
49
62
|
|
|
50
63
|
all_posts.extend(posts_chunk)
|
|
51
|
-
|
|
64
|
+
|
|
65
|
+
# Инкрементальный режим: проверяем, все ли посты уже существуют
|
|
66
|
+
if incremental and existing_ids is not None:
|
|
67
|
+
chunk_ids = {p.get("id") for p in posts_chunk}
|
|
68
|
+
all_existing = chunk_ids.issubset(existing_ids)
|
|
69
|
+
|
|
70
|
+
if all_existing:
|
|
71
|
+
clean_chunks_count += 1
|
|
72
|
+
print(f" Получено {len(all_posts)} постов... (чанк уже скачан)")
|
|
73
|
+
# Останавливаемся после safety_chunks + 1 (первый чистый + N защитных)
|
|
74
|
+
if clean_chunks_count > safety_chunks:
|
|
75
|
+
print(f" ⚡ Остановлено на {len(all_posts)} постах (все новые загружены)")
|
|
76
|
+
break
|
|
77
|
+
else:
|
|
78
|
+
clean_chunks_count = 0
|
|
79
|
+
print(f" Получено {len(all_posts)} постов...")
|
|
80
|
+
else:
|
|
81
|
+
print(f" Получено {len(all_posts)} постов...")
|
|
52
82
|
|
|
53
83
|
# Проверяем, есть ли ещё страницы
|
|
54
84
|
extra = data.get("extra", {})
|
|
@@ -58,6 +58,15 @@ class Database:
|
|
|
58
58
|
CREATE INDEX IF NOT EXISTS idx_platform_author
|
|
59
59
|
ON posts(platform, author)
|
|
60
60
|
''')
|
|
61
|
+
conn.execute('''
|
|
62
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
63
|
+
platform TEXT NOT NULL,
|
|
64
|
+
author TEXT NOT NULL,
|
|
65
|
+
is_full_sync INTEGER DEFAULT 0,
|
|
66
|
+
last_sync_at TEXT,
|
|
67
|
+
UNIQUE(platform, author)
|
|
68
|
+
)
|
|
69
|
+
''')
|
|
61
70
|
conn.commit()
|
|
62
71
|
|
|
63
72
|
def close(self):
|
|
@@ -167,3 +176,35 @@ class Database:
|
|
|
167
176
|
tags=row['tags'],
|
|
168
177
|
synced_at=row['synced_at'],
|
|
169
178
|
)
|
|
179
|
+
|
|
180
|
+
def mark_full_sync(self, platform: str, author: str):
|
|
181
|
+
"""Помечает архив как полностью синхронизированный."""
|
|
182
|
+
from datetime import datetime, timezone
|
|
183
|
+
conn = self._get_conn()
|
|
184
|
+
conn.execute('''
|
|
185
|
+
INSERT OR REPLACE INTO sync_state (platform, author, is_full_sync, last_sync_at)
|
|
186
|
+
VALUES (?, ?, 1, ?)
|
|
187
|
+
''', (platform, author, datetime.now(timezone.utc).isoformat()))
|
|
188
|
+
conn.commit()
|
|
189
|
+
|
|
190
|
+
def is_full_sync(self, platform: str, author: str) -> bool:
|
|
191
|
+
"""Проверяет, полностью ли синхронизирован архив."""
|
|
192
|
+
conn = self._get_conn()
|
|
193
|
+
cursor = conn.execute('''
|
|
194
|
+
SELECT is_full_sync FROM sync_state
|
|
195
|
+
WHERE platform = ? AND author = ?
|
|
196
|
+
''', (platform, author))
|
|
197
|
+
row = cursor.fetchone()
|
|
198
|
+
return bool(row and row[0])
|
|
199
|
+
|
|
200
|
+
def update_last_sync(self, platform: str, author: str):
|
|
201
|
+
"""Обновляет время последней синхронизации."""
|
|
202
|
+
from datetime import datetime, timezone
|
|
203
|
+
conn = self._get_conn()
|
|
204
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
205
|
+
conn.execute('''
|
|
206
|
+
INSERT INTO sync_state (platform, author, is_full_sync, last_sync_at)
|
|
207
|
+
VALUES (?, ?, 0, ?)
|
|
208
|
+
ON CONFLICT(platform, author) DO UPDATE SET last_sync_at = ?
|
|
209
|
+
''', (platform, author, now, now))
|
|
210
|
+
conn.commit()
|
|
@@ -98,8 +98,20 @@ class BaseDownloader(ABC):
|
|
|
98
98
|
pass
|
|
99
99
|
|
|
100
100
|
@abstractmethod
|
|
101
|
-
def fetch_posts_list(
|
|
102
|
-
|
|
101
|
+
def fetch_posts_list(
|
|
102
|
+
self,
|
|
103
|
+
existing_ids: set[str] | None = None,
|
|
104
|
+
incremental: bool = False,
|
|
105
|
+
safety_chunks: int = 1
|
|
106
|
+
) -> list[dict]:
|
|
107
|
+
"""
|
|
108
|
+
Получает список постов с API.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
existing_ids: Множество уже загруженных post_id (для инкрементального режима)
|
|
112
|
+
incremental: Включить инкрементальный режим
|
|
113
|
+
safety_chunks: Количество "защитных" чанков перед остановкой
|
|
114
|
+
"""
|
|
103
115
|
pass
|
|
104
116
|
|
|
105
117
|
@abstractmethod
|
|
@@ -119,7 +131,16 @@ class BaseDownloader(ABC):
|
|
|
119
131
|
self._create_index_files()
|
|
120
132
|
|
|
121
133
|
existing_ids = self.db.get_all_post_ids(self.PLATFORM, self.source.author)
|
|
122
|
-
|
|
134
|
+
is_full = self.db.is_full_sync(self.PLATFORM, self.source.author)
|
|
135
|
+
|
|
136
|
+
if is_full:
|
|
137
|
+
# Инкрементальная синхронизация
|
|
138
|
+
print(" Инкрементальный режим...")
|
|
139
|
+
posts = self.fetch_posts_list(existing_ids, incremental=True, safety_chunks=1)
|
|
140
|
+
else:
|
|
141
|
+
# Первый запуск или неполный архив
|
|
142
|
+
print(" Полная загрузка индекса...")
|
|
143
|
+
posts = self.fetch_posts_list()
|
|
123
144
|
|
|
124
145
|
new_posts = [p for p in posts if str(p.get('id', p.get('post_id'))) not in existing_ids]
|
|
125
146
|
print(f" Найдено постов: {len(posts)}, новых: {len(new_posts)}")
|
|
@@ -134,6 +155,13 @@ class BaseDownloader(ABC):
|
|
|
134
155
|
print(f" Фиксим внутренние ссылки...")
|
|
135
156
|
self.fix_internal_links()
|
|
136
157
|
|
|
158
|
+
# Помечаем архив как полный после успешной полной загрузки
|
|
159
|
+
if not is_full:
|
|
160
|
+
print(" ✓ Архив полностью синхронизирован")
|
|
161
|
+
self.db.mark_full_sync(self.PLATFORM, self.source.author)
|
|
162
|
+
|
|
163
|
+
self.db.update_last_sync(self.PLATFORM, self.source.author)
|
|
164
|
+
|
|
137
165
|
def download_single(self, post_id: str):
|
|
138
166
|
"""Скачивает один пост по ID."""
|
|
139
167
|
print(f"[{self.PLATFORM}] Скачивание поста {post_id}...")
|
|
@@ -65,11 +65,24 @@ class SponsorDownloader(BaseDownloader):
|
|
|
65
65
|
self._project_id = str(project_id)
|
|
66
66
|
return self._project_id
|
|
67
67
|
|
|
68
|
-
def fetch_posts_list(
|
|
69
|
-
|
|
68
|
+
def fetch_posts_list(
|
|
69
|
+
self,
|
|
70
|
+
existing_ids: set[str] | None = None,
|
|
71
|
+
incremental: bool = False,
|
|
72
|
+
safety_chunks: int = 1
|
|
73
|
+
) -> list[dict]:
|
|
74
|
+
"""
|
|
75
|
+
Получает список постов через API.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
existing_ids: Множество уже загруженных post_id (для инкрементального режима)
|
|
79
|
+
incremental: Включить инкрементальный режим
|
|
80
|
+
safety_chunks: Количество "защитных" чанков перед остановкой
|
|
81
|
+
"""
|
|
70
82
|
project_id = self._get_project_id()
|
|
71
83
|
all_posts = []
|
|
72
84
|
offset = 0
|
|
85
|
+
clean_chunks_count = 0 # Счётчик "чистых" чанков
|
|
73
86
|
|
|
74
87
|
while True:
|
|
75
88
|
api_url = f"https://sponsr.ru/project/{project_id}/more-posts/?offset={offset}"
|
|
@@ -86,7 +99,24 @@ class SponsorDownloader(BaseDownloader):
|
|
|
86
99
|
offset = len(all_posts)
|
|
87
100
|
|
|
88
101
|
total = data.get("rows_count", 0)
|
|
89
|
-
|
|
102
|
+
|
|
103
|
+
# Инкрементальный режим: проверяем, все ли посты уже существуют
|
|
104
|
+
if incremental and existing_ids is not None:
|
|
105
|
+
chunk_ids = {str(p.get('post_id')) for p in posts_chunk}
|
|
106
|
+
all_existing = chunk_ids.issubset(existing_ids)
|
|
107
|
+
|
|
108
|
+
if all_existing:
|
|
109
|
+
clean_chunks_count += 1
|
|
110
|
+
print(f" Получено {offset}/{total} постов... (чанк уже скачан)")
|
|
111
|
+
# Останавливаемся после safety_chunks + 1 (первый чистый + N защитных)
|
|
112
|
+
if clean_chunks_count > safety_chunks:
|
|
113
|
+
print(f" ⚡ Остановлено на {offset} постах (все новые загружены)")
|
|
114
|
+
break
|
|
115
|
+
else:
|
|
116
|
+
clean_chunks_count = 0
|
|
117
|
+
print(f" Получено {offset}/{total} постов...")
|
|
118
|
+
else:
|
|
119
|
+
print(f" Получено {offset}/{total} постов...")
|
|
90
120
|
|
|
91
121
|
return all_posts
|
|
92
122
|
|
|
@@ -33,7 +33,12 @@ class _DummyDownloader(BaseDownloader):
|
|
|
33
33
|
# Tests patch session.get directly.
|
|
34
34
|
return None
|
|
35
35
|
|
|
36
|
-
def fetch_posts_list(
|
|
36
|
+
def fetch_posts_list(
|
|
37
|
+
self,
|
|
38
|
+
existing_ids: set[str] | None = None,
|
|
39
|
+
incremental: bool = False,
|
|
40
|
+
safety_chunks: int = 1
|
|
41
|
+
):
|
|
37
42
|
raise NotImplementedError
|
|
38
43
|
|
|
39
44
|
def fetch_post(self, post_id: str):
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from src.config import Auth, Config, Source
|
|
7
|
+
from src.database import Database
|
|
8
|
+
from src.boosty import BoostyDownloader
|
|
9
|
+
from src.sponsr import SponsorDownloader
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IncrementalSyncTests(unittest.TestCase):
|
|
13
|
+
def test_sponsr_incremental_stops_on_clean_chunk(self):
|
|
14
|
+
"""Sponsr: останавливается на чистом чанке."""
|
|
15
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
16
|
+
tmp_path = Path(tmp)
|
|
17
|
+
config = Config(output_dir=tmp_path, auth=Auth())
|
|
18
|
+
source = Source(platform='sponsr', author='test_author')
|
|
19
|
+
|
|
20
|
+
with Database(tmp_path / 'test.db') as db:
|
|
21
|
+
with patch('src.sponsr.load_cookie', return_value='fake_cookie'):
|
|
22
|
+
dl = SponsorDownloader(config, source, db)
|
|
23
|
+
|
|
24
|
+
# Мокаем _get_project_id
|
|
25
|
+
dl._project_id = '123'
|
|
26
|
+
|
|
27
|
+
# Мокаем API: возвращает 2 чанка по 20 постов
|
|
28
|
+
responses = [
|
|
29
|
+
{
|
|
30
|
+
"response": {
|
|
31
|
+
"rows": [{"post_id": f"{i}"} for i in range(1, 21)],
|
|
32
|
+
"rows_count": 40
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"response": {
|
|
37
|
+
"rows": [{"post_id": f"{i}"} for i in range(21, 41)],
|
|
38
|
+
"rows_count": 40
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
call_count = 0
|
|
44
|
+
def mock_get(url, timeout=None):
|
|
45
|
+
nonlocal call_count
|
|
46
|
+
resp = MagicMock()
|
|
47
|
+
resp.json.return_value = responses[call_count]
|
|
48
|
+
call_count += 1
|
|
49
|
+
return resp
|
|
50
|
+
|
|
51
|
+
dl.session.get = mock_get
|
|
52
|
+
|
|
53
|
+
# Все посты уже существуют
|
|
54
|
+
existing_ids = {str(i) for i in range(1, 41)}
|
|
55
|
+
|
|
56
|
+
posts = dl.fetch_posts_list(existing_ids, incremental=True, safety_chunks=1)
|
|
57
|
+
|
|
58
|
+
# Должен остановиться после первого чанка + 1 защитный
|
|
59
|
+
self.assertEqual(call_count, 2)
|
|
60
|
+
self.assertEqual(len(posts), 40)
|
|
61
|
+
|
|
62
|
+
def test_sponsr_incremental_finds_new_posts(self):
|
|
63
|
+
"""Sponsr: находит новые посты."""
|
|
64
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
65
|
+
tmp_path = Path(tmp)
|
|
66
|
+
config = Config(output_dir=tmp_path, auth=Auth())
|
|
67
|
+
source = Source(platform='sponsr', author='test_author')
|
|
68
|
+
|
|
69
|
+
with Database(tmp_path / 'test.db') as db:
|
|
70
|
+
with patch('src.sponsr.load_cookie', return_value='fake_cookie'):
|
|
71
|
+
dl = SponsorDownloader(config, source, db)
|
|
72
|
+
|
|
73
|
+
dl._project_id = '123'
|
|
74
|
+
|
|
75
|
+
responses = [
|
|
76
|
+
{
|
|
77
|
+
"response": {
|
|
78
|
+
"rows": [{"post_id": f"{i}"} for i in range(1, 21)],
|
|
79
|
+
"rows_count": 40
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"response": {
|
|
84
|
+
"rows": [{"post_id": f"{i}"} for i in range(21, 41)],
|
|
85
|
+
"rows_count": 40
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"response": {
|
|
90
|
+
"rows": [],
|
|
91
|
+
"rows_count": 40
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
call_count = 0
|
|
97
|
+
def mock_get(url, timeout=None):
|
|
98
|
+
nonlocal call_count
|
|
99
|
+
idx = min(call_count, len(responses) - 1)
|
|
100
|
+
resp = MagicMock()
|
|
101
|
+
resp.json.return_value = responses[idx]
|
|
102
|
+
call_count += 1
|
|
103
|
+
return resp
|
|
104
|
+
|
|
105
|
+
dl.session.get = mock_get
|
|
106
|
+
|
|
107
|
+
# Первые 5 постов новые, остальные существуют
|
|
108
|
+
existing_ids = {str(i) for i in range(6, 41)}
|
|
109
|
+
|
|
110
|
+
posts = dl.fetch_posts_list(existing_ids, incremental=True, safety_chunks=1)
|
|
111
|
+
|
|
112
|
+
# Должен загрузить: первый (с новыми) + второй (чистый) + остановиться
|
|
113
|
+
# Всего 2 чанка с данными (call_count может быть 2 или 3 в зависимости от реализации)
|
|
114
|
+
# Важнее что все посты загружены
|
|
115
|
+
self.assertEqual(len(posts), 40)
|
|
116
|
+
self.assertGreaterEqual(call_count, 2)
|
|
117
|
+
|
|
118
|
+
def test_boosty_incremental_stops_on_clean_chunk(self):
|
|
119
|
+
"""Boosty: останавливается на чистом чанке."""
|
|
120
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
121
|
+
tmp_path = Path(tmp)
|
|
122
|
+
config = Config(output_dir=tmp_path, auth=Auth())
|
|
123
|
+
source = Source(platform='boosty', author='test_author')
|
|
124
|
+
|
|
125
|
+
with Database(tmp_path / 'test.db') as db:
|
|
126
|
+
with patch('src.boosty.load_cookie', return_value='fake_cookie'):
|
|
127
|
+
with patch('src.boosty.load_auth_header', return_value='Bearer token'):
|
|
128
|
+
dl = BoostyDownloader(config, source, db)
|
|
129
|
+
|
|
130
|
+
responses = [
|
|
131
|
+
{
|
|
132
|
+
"data": [{"id": f"uuid-{i}"} for i in range(1, 21)],
|
|
133
|
+
"extra": {"isLast": False, "offset": "token1"}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"data": [{"id": f"uuid-{i}"} for i in range(21, 41)],
|
|
137
|
+
"extra": {"isLast": True}
|
|
138
|
+
}
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
call_count = 0
|
|
142
|
+
def mock_get(url, timeout=None):
|
|
143
|
+
nonlocal call_count
|
|
144
|
+
resp = MagicMock()
|
|
145
|
+
resp.json.return_value = responses[call_count]
|
|
146
|
+
call_count += 1
|
|
147
|
+
return resp
|
|
148
|
+
|
|
149
|
+
dl.session.get = mock_get
|
|
150
|
+
|
|
151
|
+
existing_ids = {f"uuid-{i}" for i in range(1, 41)}
|
|
152
|
+
|
|
153
|
+
posts = dl.fetch_posts_list(existing_ids, incremental=True, safety_chunks=1)
|
|
154
|
+
|
|
155
|
+
self.assertEqual(call_count, 2)
|
|
156
|
+
self.assertEqual(len(posts), 40)
|
|
157
|
+
|
|
158
|
+
def test_database_sync_state_methods(self):
|
|
159
|
+
"""Проверяет методы sync_state в Database."""
|
|
160
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
161
|
+
tmp_path = Path(tmp)
|
|
162
|
+
|
|
163
|
+
with Database(tmp_path / 'test.db') as db:
|
|
164
|
+
# Изначально не синхронизирован
|
|
165
|
+
self.assertFalse(db.is_full_sync('sponsr', 'author1'))
|
|
166
|
+
|
|
167
|
+
# Помечаем как синхронизированный
|
|
168
|
+
db.mark_full_sync('sponsr', 'author1')
|
|
169
|
+
self.assertTrue(db.is_full_sync('sponsr', 'author1'))
|
|
170
|
+
|
|
171
|
+
# update_last_sync не должен сбрасывать is_full_sync
|
|
172
|
+
db.update_last_sync('sponsr', 'author1')
|
|
173
|
+
self.assertTrue(db.is_full_sync('sponsr', 'author1'))
|
|
174
|
+
|
|
175
|
+
# Другой автор не синхронизирован
|
|
176
|
+
self.assertFalse(db.is_full_sync('sponsr', 'author2'))
|
|
177
|
+
|
|
178
|
+
def test_backward_compatibility(self):
|
|
179
|
+
"""Старая БД без sync_state работает корректно."""
|
|
180
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
181
|
+
tmp_path = Path(tmp)
|
|
182
|
+
|
|
183
|
+
# Создаём старую БД без sync_state
|
|
184
|
+
import sqlite3
|
|
185
|
+
conn = sqlite3.connect(tmp_path / 'old.db')
|
|
186
|
+
conn.execute('''
|
|
187
|
+
CREATE TABLE posts (
|
|
188
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
189
|
+
platform TEXT NOT NULL,
|
|
190
|
+
author TEXT NOT NULL,
|
|
191
|
+
post_id TEXT NOT NULL,
|
|
192
|
+
title TEXT,
|
|
193
|
+
slug TEXT,
|
|
194
|
+
post_date TEXT,
|
|
195
|
+
source_url TEXT,
|
|
196
|
+
local_path TEXT,
|
|
197
|
+
tags TEXT,
|
|
198
|
+
synced_at TEXT,
|
|
199
|
+
UNIQUE(platform, author, post_id)
|
|
200
|
+
)
|
|
201
|
+
''')
|
|
202
|
+
conn.commit()
|
|
203
|
+
conn.close()
|
|
204
|
+
|
|
205
|
+
# Открываем через Database — должна создаться таблица sync_state
|
|
206
|
+
with Database(tmp_path / 'old.db') as db:
|
|
207
|
+
# is_full_sync должен вернуть False для несуществующих записей
|
|
208
|
+
self.assertFalse(db.is_full_sync('sponsr', 'author1'))
|
|
209
|
+
|
|
210
|
+
# Можем пометить как синхронизированный
|
|
211
|
+
db.mark_full_sync('sponsr', 'author1')
|
|
212
|
+
self.assertTrue(db.is_full_sync('sponsr', 'author1'))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == '__main__':
|
|
216
|
+
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
|