Я веду базу знаний в Obsidian, а этот сайт работает на Hugo. Копировать заметки вручную, перекладывать картинки и править ссылки - это путь к тому, чтобы забросить блог через неделю. В этой статье разберу работу Python-скрипта, который работает в Docker-контейнере на моем Worker-узле и делает всю нудную работу по публикации заметок за меня. Вчера доработал скрипт поддержкой внутренних ссылок и корректной обработкой вложений, считаю его законченным, решил описать принципы работы.

Задача

Obsidian и Hugo по-разному работают с файлами:

  1. Картинки: В Obsidian они могут лежать где угодно (у меня это папка 3 Бэкенд и подпапки). Hugo требует, чтобы картинки лежали строго в /static/images.
  2. Ссылки: Obsidian использует [Wiki Links](/blog/blogwiki-links/). Hugo понимает только стандартный Markdown [Link](/blog/url/).
  3. Отбор: Не все заметки из базы должны попадать на сайт.

Архитектура решения

Скрипт (script.py) упакован в Docker-контейнер obsidian-publisher. Он мониторит папку с заметками, которая синхронизируется на мой сервер через Syncthing.

Логика работы (цикл каждые 15 минут):

  1. Pull: Забирает свежие изменения из Git-репозитория сайта.
  2. Индексация: Сканирует папку ассетов и строит карту путей.
  3. Процессинг: Ищет файлы с тегом публикация, обрабатывает их и копирует в папку контента Hugo.
  4. Push: Если есть изменения, коммитит их и пушит в GitLab. Дальше в дело вступает CI/CD пайплайн.

Реализация

1. Глобальный поиск ассетов

Самая большая проблема - найти картинку. В заметке написано ![[screen.png]], но физически файл может лежать в 3 Бэкенд/Проекты/Скриншоты/screen.png.

Чтобы не хардкодить пути, скрипт сначала строит индекс:

ASSET_MAP = {}
 
def build_asset_map():
    # Рекурсивно проходим по всем папкам источника
    for root, dirs, files in os.walk(SOURCE_DIR):
        if ".git" in root: continue
        for file in files:
            # Сохраняем абсолютный путь к каждому файлу
            ASSET_MAP[file] = os.path.join(root, file)

Теперь, встретив ссылку на файл, мы мгновенно находим его полный путь в словаре ASSET_MAP.

2. Регулярные выражения

Скрипт преобразует синтаксис Obsidian в синтаксис Hugo.

Картинки: Превращает ![[image.png]] или ![image](image.png) в ![image](/assets/blog/images/image.png), попутно копируя файл в нужную папку сайта.

Внутренние ссылки: Превращает Заметка (здесь должны быть двойные квадратные скобки, но скрипт их обработает) в [Заметка](/blog/blogзаметка/).

3. Проблема “процентов” и кириллицы

Obsidian при вставке ссылок кодирует пробелы как %20. Если просто перевести это в slug, получится тест%20ссылки, который дополнительно придется обрабатывать уже в Hugo. Для генерации красивых URL используется такая функция:

def slugify(text):
    # 1. Сначала декодируем URL (убираем %20)
    text = urllib.parse.unquote(text)
    # 2. Приводим к нижнему регистру
    text = text.lower().strip()
    # 3. Заменяем пробелы на дефисы
    text = re.sub(r'[\s_]+', '-', text)
    # 4. Оставляем только буквы (включая русские), цифры и дефис
    text = re.sub(r'[^\w\-]', '', text)
    return text

Теперь ссылка [Тест интеграции](/blog/test-integratsii/) превращается в чистый URL /blog/test-integratsii/.

4. Управление датой и сортировкой

Чтобы статьи на сайте шли в правильном хронологическом порядке, скрипт использует приоритеты:

  1. Если в заметке есть YAML-поле date - берется оно.
  2. Если нет - берется время изменения файла (mtime).

Время форматируется в ISO 8601 (YYYY-MM-DDTHH:MM:SS), чтобы Hugo мог точно отсортировать записи.

Итог

Теперь процесс публикации выглядит так:

  1. Пишу заметку в Obsidian.
  2. Ставлю тег публикация.
  3. …Всё.

Через 15 минут (или быстрее, если триггернуть вручную) скрипт обработает файл, запушит в Git, GitLab CI соберет сайт, и статья появится на сайте. Ссылки работают, картинки на месте, файлы прикреплены. Абсолютно также работает и редактирование: стоит мне внести любые изменения (как здесь, это я пишу уже через два дня после публикации) - изменения появятся на сайте. Возможно, стоит добавить в начале статьи поля “дата публикации” и “дата изменения”, чтобы Hugo сортировал только по дате публикации, но над этим я подумаю позже.

Возможная проблема - если вы используете прекоммит в Git, нужно добавить этот скрипт в исключения, потому что в нем используются двойные пробелы, которые критичны для синтаксиса Hugo, а прекоммит их убирает.

Самостоятельное развертывание

Если вдруг вы набрели на этот сайт и хотите развернуть данный скрипт у себя - исходники во вложении или в моем GitHub. В начале скрипта - переменные, которые можете настроить самостоятельно под ваши данные.

Docker Compose (используются переменные Jinja2, можете захардкодить свои):

obsidian-publisher:
  build: {{ root_dir }}/obsidian_publisher
  container_name: obsidian-publisher
  restart: unless-stopped
  user: "{{ puid }}:{{ pgid }}" # или "1000:1000" по умолчанию
  environment:
    - TZ={{ timezone }}
    - SITE_REPO_URL=ssh://git@gitlab.example:2222/user/site.git # Ваша ссылка на Git
    - GIT_SSH_COMMAND=ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i /ssh_keys/id_ed25519 # Git SSH Command
  volumes:
    - {{ data_drive_path }}/obsidian:/data/obsidian:ro # Исходники заметок
    - site_repo_data:/app/site-repo # Репозиторий сайта
    - /home/{{ sys_user }}/.ssh/id_ed25519:/ssh_keys/id_ed25519:ro # SSH ключ

Сам архив со скриптом и Dockerfile: 📎 obsidian_publisher