Скрипт для локального бэкапа статей с платформ Sponsr.ru и Boosty.to. Конвертирует в Markdown, скачивает медиа, поддерживает инкрементальную синхронизацию.
- Диалог с пользователем ведётся на русском языке.
- Сообщения git-коммитов, аннотации git-тегов, заголовки GitHub Release и release notes пишутся на русском языке.
- В сообщениях git-коммитов, аннотациях git-тегов, заголовках GitHub Release и release notes не использовать emoji.
- Перед созданием git-коммита сначала показать пользователю предлагаемое сообщение и дождаться подтверждения.
- Не коммитить generated artifacts:
dist/,*.egg-info/,site/public/, quickstart-архивыarticle-backup-v*-quickstart.tar.gz. - Для release-коммитов использовать явный
git addнужных файлов вместоgit add .. - Не трогать пользовательские и локальные untracked-файлы без явной просьбы.
- После release-сборки локальные generated artifacts можно удалить, если они больше не нужны для публикации.
- Перед заявлением о завершении задачи указывать фактически выполненные проверки.
backup.py → CLI точка входа, парсинг аргументов
src/
├── config.py → загрузка YAML-конфига, dataclasses Config/Source/Auth/HugoConfig
├── database.py → SQLite индекс, CRUD для PostRecord
├── downloader.py → BaseDownloader (абстрактный), общая логика сохранения
├── sponsr.py → SponsorDownloader, API sponsr.ru
├── boosty.py → BoostyDownloader, API boosty.to
└── utils.py → транслитерация, фильтрация assets, парсинг URL
-
BaseDownloader— абстрактный базовый классsync()— синхронизация автора (полная или инкрементальная)fetch_posts_list()— получение списка постов (с поддержкой инкрементального режима)download_single()— один пост по ID_save_post()— сохранение на диск + запись в БД_download_assets()— параллельное скачивание медиа (ThreadPoolExecutor) с retry_deduplicate_filename()— генерация уникальных имён файлов при коллизии
-
Post— dataclass с полями: post_id, title, content_html, post_date, source_url, tags, assets -
Database— SQLite wrapper с connection pooling- Использует один connection на сессию с timeout=30 и WAL mode
- Поддерживает context manager (
with Database(...) as db:) - Таблица
sync_stateдля отслеживания статуса инкрементальной синхронизации
-
retry_request()— функция для retry с exponential backoff (3 попытки, задержка 1-30 сек). Применяется для API-запросов и скачивания файлов.
Sponsr:
- Список постов:
GET /project/{project_id}/more-posts/?offset={n} - Один пост: парсинг
__NEXT_DATA__со страницы/{author}/{post_id}/ - project_id из
__NEXT_DATA__на странице проекта - Авторизация: Cookie header
Boosty:
- Список постов:
GET /v1/blog/{author}/post/?limit=20&offset={token} - Один пост:
GET /v1/blog/{author}/post/{uuid} - Авторизация: Cookie + Authorization: Bearer
- URL картинок Boosty не содержат расширения — определяем по Content-Type
- Заголовки могут содержать кавычки — экранируем для YAML
- Внутренние ссылки между статьями — фиксим после скачивания всех постов
- frontmatter не должен модифицироваться при фиксе ссылок
- Сетевые запросы используют retry с exponential backoff (кроме 4xx ошибок)
- При коллизии имён файлов добавляется хеш URL
- Встроенные видео (iframe/embed) в Sponsr и ok_video в Boosty конвертируются в markdown-ссылки вида
[📹 Видео](embed_url). Hugo render hook (site/layouts/_default/_markup/render-link.html) распознаёт embed URL известных видеохостингов (Rutube, YouTube, Vimeo, OK.ru, VK) и рендерит<iframe>с адаптивным контейнером.video-container. В Markdown файлах остаётся чистая ссылка без HTML-вставок. Boostyok_videoблоки содержатplayerUrls— подписанные временные URL прямых видеопотоков; скрипт выбирает лучшее качество (full_hd > ... > lowest, fallback наhls/dash) и скачивает видео как asset. Превью-картинка (preview/previewUrl) скачивается всегда (сforce=True, обходя фильтрasset_types). Если превью скачано — в Markdown вставляется кликабельная картинка[](video_link). Целевая ссылка: локальный видеофайл (если скачан) >ok.ru/video/{vid}>ok.ru/videoembed/{id}. Если превью недоступно — обычная текстовая ссылка[📹 Видео](video_link). - Hugo
relativeURLs = true+relURLдаёт пути вида../../../path/— не работает для субдоменов, используемpath.Baseв list.html - Sponsr: HTML предобрабатывается перед html2text — слияние вложенных тегов (
<em><em>→<em>), слияние соседних<em>/<i>тегов в одном родителе. Важно: для сохранения пробелов вокруг inline-тегов (<b>,<em>) используется вставка специального маркера@@@SP@@@, который заменяется на пробел после конвертации (так какhtml2textсклонен "съедать" пробелы на границах тегов). - SQLite использует timeout=30 сек и WAL mode для избежания "database is locked" при множественных источниках
site/hugo.tomlперезаписывается при каждом запуске backup.py — ручные изменения не сохраняются- Дедупликация имён скачиваемых assets должна происходить до записи файла и быть потокобезопасной, иначе Markdown может ссылаться на несуществующий файл
- Фикс внутренних ссылок должен быть ограничен одной платформой и одним автором (не трогать ссылки на другие платформы/авторов)
- Для субдоменной раздачи автора ссылки в
list.htmlдолжны использоватьpath.Base .RelPermalink(иначе возможны 404 из-за../../../путей) - Теги в API Sponsr могут приходить как массив строк (старый формат) или как массив объектов с вложенной структурой
{tag: {tag_name: "..."}}— извлекаем толькоtag_name - Инкрементальная синхронизация: после первой полной загрузки (
is_full_sync=True) скрипт загружает чанками по 20 постов и останавливается, когда встречает N чанков подряд, состоящих только из уже загруженных постов (по умолчанию N=1, т.е. 1 защитный чанк). Порядок постов в API: от новых к старым (offset=0 = самые свежие). - Заголовок статьи выводится Hugo из frontmatter (
title), поэтому конвертеры платформ не добавляют# Заголовокв body Markdown (иначе получается дублирование). - Boosty: при применении стилей (bold/italic) пробелы на границах фрагмента выносятся наружу маркеров (
*текст *→*текст*), иначе Markdown-разметка невалидна. Фрагменты из одних пробелов не оборачиваются. - Boosty: API отдаёт контент как массив блоков.
BLOCK_END— разделитель параграфов, все inline-блоки (text, link) между двумяBLOCK_ENDконкатенируются в один параграф. Позиции стилей в блоках — глобальные (относительно начала параграфа), при применении нормализуются вычитанием offset-а предыдущих блоков. - Sponsr: для консистентности разметки в
sync()новые посты догружаются полным запросом поpost_id(как вdownload_single()), так как HTML из/more-postsможет отличаться от HTML страницы поста.
Dockerfile → Python 3.12-slim, копирует backup.py и src/
docker-compose.yml → готовый образ из GHCR (для пользователей)
docker-compose-dev.yml → сборка из исходников (build: .) для разработки
run-docker.sh → скрипт-обертка для корректного запуска с учетом config.yaml
.dockerignore → исключает __pycache__, .git, backup/, site/public/
Запуск: ./run-docker.sh (рекомендуется). Скрипт использует docker-compose.yml (готовый образ).
Разработка:
docker compose -f docker-compose-dev.yml build
docker compose -f docker-compose-dev.yml run --rm backupСервис hugo после сборки автоматически копирует CSS в папки авторов для поддержки субдоменов.
Публикация образов:
Настроен GitHub Actions workflow (.github/workflows/docker-publish.yml). При создании тега v* (релиз):
- Собирается мультиплатформенный образ (amd64/arm64).
- Публикуется в GHCR:
ghcr.io/strannick-ru/article-backup:latestи:vX.Y.Z.
site/
├── hugo.toml → конфиг Hugo (генерируется из config.yaml)
├── build.sh → сборка + копирование CSS в папки авторов
├── static/css/ → стили (reader.css)
├── layouts/_default/ → шаблоны (baseof.html, single.html, list.html, _markup/render-link.html)
└── public/ → сгенерированный сайт
backup.pyавтоматически создаёт симлинкsite/content → output_dir- Hugo (проверено на v0.166.0) не заходит в
content, если это симлинк за пределы корня проекта: сборка завершается успешно, но статей вpublicнет. Поэтомуrun-docker.shудаляет симлинк и готовитsite/contentкак обычный каталог — точку монтирования бэкапа. Для локальной сборкиbackup.pyпишет вhugo.tomlсекцию[[module.mounts]]с относительным путём доoutput_dir; в Docker (заданBACKUP_OUTPUT_DIR) mount не генерируется. Симлинкsite/contentостаётся радиsite/build.sh, который передаёт путьcontentв дедупликацию; Hugo его игнорирует, дублирования страниц нет. - Compose-команды Hugo не должны завершаться ненулевым кодом при отсутствии каталога одной из платформ, иначе
set -eвrun-docker.shобрывает запуск до дедупликации assets backup.pyгенерируетsite/hugo.tomlиз секцииhugo:в config.yaml (base_url, title, language_code)- Перед полной синхронизацией
backup.pyвыполняет preflight-проверку авторизации по всем источникам. Секцияsync.on_errorуправляет поведением при ошибках:stop(по умолчанию) останавливает запуск до скачивания,continueпропускает проблемные источники и позволяет Docker-запуску собрать Hugo-сайт из доступных данных. build.sh— собирает Hugo и копирует CSS в каждую папку автора для автономной раздачи через субдомены- После успешной Hugo-сборки
site/deduplicate-assets.shзаменяет идентичные public-копии assets hardlinks на исходные файлы бэкапа. Перед каждой сборкойsite/publicполностью очищается, чтобы Hugo не мог записать поверх hardlink. В Docker дедупликация выполняется на хосте изrun-docker.sh; при разных файловых системах остаются обычные копии. - Относительные URL включены — сайт работает из любой директории
- RSS генерируется для автора и секции постов:
/{platform}/{author}/index.xml/{platform}/{author}/posts/index.xml— title берётся изauthor, description — изdisplay_name(записывается вposts/_index.md)
- Slug папки:
{YYYY-MM-DD}-{transliterated-title}-{short-hash}(для обратной совместимости старые посты остаются без хеша) - Assets в подпапке
assets/рядом сindex.md - Белый список расширений: jpg, png, gif, webp, svg, mp4, webm, mov, mkv, avi, mp3, wav, flac, ogg, pdf
Добавить новую платформу:
- Создать
src/newplatform.pyс классомNewPlatformDownloader(BaseDownloader) - Реализовать:
_setup_session(),fetch_posts_list(),fetch_post(),_parse_post(),_to_markdown() - Добавить в
backup.pyвget_downloader() - Добавить auth-поля в
config.py
Изменить формат frontmatter:
→ BaseDownloader._make_frontmatter()
Изменить фильтрацию assets:
→ utils.py: ASSET_TYPES, should_download_asset()
→ config.py: Source.asset_types (image, video, audio, document)
Настроить retry параметры:
→ downloader.py: retry_request() — параметры max_retries, base_delay, max_delay, backoff_factor
Настроить инкрементальную синхронизацию:
→ downloader.py: sync() — параметр safety_chunks в вызове fetch_posts_list() (по умолчанию 1)
→ sponsr.py/boosty.py: fetch_posts_list() — логика остановки при достижении чистых чанков
→ database.py: sync_state — таблица с полями platform, author, is_full_sync, last_sync_at
Изменить шаблоны Hugo:
→ site/layouts/_default/ — single.html (статья), list.html (списки), baseof.html (базовый)
→ CSS использует переменные для тем (Light, Dark, Sepia, Gruvbox, Everforest)
→ CSS и внешние ресурсы используют relURL для относительных путей
→ Ссылки на посты в list.html используют path.Base .RelPermalink для прямых путей (совместимость с субдоменами)
Добавить CSS для автора:
→ Создать backup/{platform}/{author}/css/author.css с кастомными переменными
Изменить Docker-конфигурацию:
→ Dockerfile — базовый образ, зависимости, точка входа
→ docker-compose.yml — volumes, сервисы backup и hugo
→ Пересборка: docker compose build
Изменить настройки Hugo:
→ config.yaml секция hugo: — base_url, title, language_code, default_theme
→ backup.py: generate_hugo_config() — шаблон генерации hugo.toml
→ src/config.py: HugoConfig — dataclass с параметрами и значениями по умолчанию
Структура пакета для PyPI:
backup.py— основной модуль в корне (py-modules)src/— пакет с модулями (packages.find)pyproject.toml— метаданные, зависимости, entry pointarticle-backup- Entry point:
article-backup→backup:main
Процесс релиза:
-
Обновить версию:
pyproject.toml:version = "X.Y.Z"CHANGELOG.md: переместить [Unreleased] → [X.Y.Z] с датой
-
Обновить документацию:
README.md: проверить актуальность инструкций- Бейджи PyPI, Python, License в начале README
-
Собрать пакет:
rm -rf dist article_backup.egg-info python -m build python -m twine check dist/* -
Опубликовать на PyPI: Если
TWINE_PASSWORDзадан в окружении, использовать non-interactive upload:TWINE_USERNAME="${TWINE_USERNAME:-__token__}" python -m twine upload dist/*
Если токен не задан, остановиться и попросить пользователя выполнить команду локально:
TWINE_USERNAME=__token__ TWINE_PASSWORD='pypi-...' python -m twine upload dist/*
-
Git релиз:
git add pyproject.toml CHANGELOG.md README.md git commit -m "Релиз vX.Y.Z: краткое описание" git tag -a vX.Y.Z -m "Релиз vX.Y.Z" git push origin main git push origin vX.Y.Z
-
GitHub Release:
- Создать через веб-интерфейс или
gh release create. - Заголовок и описание брать из CHANGELOG, на русском языке и без emoji.
- Приложить quickstart архив:
tar -czf article-backup-vX.Y.Z-quickstart.tar.gz \ README.md LICENSE config.yaml.example \ docker-compose.yml docker-compose-dev.yml \ Dockerfile .dockerignore run-docker.sh \ requirements.txt pyproject.toml \ --transform 's,^,article-backup/,' gh release upload vX.Y.Z article-backup-vX.Y.Z-quickstart.tar.gz
- Создать через веб-интерфейс или
После релиза:
- Проверить https://pypi.org/project/article-backup/
- Протестировать установку:
pip install article-backup==X.Y.Z - Проверить GitHub Release с assets
- Удалить локальные generated artifacts, если они больше не нужны:
dist/,*.egg-info/,article-backup-vX.Y.Z-quickstart.tar.gz