ICS → Импорт в Discourse

Я создал небольшую утилиту, которая непрерывно синхронизирует события из источника iCalendar (ICS) в категорию Discourse через REST API.

Это не полноценный плагин для Discourse — он работает параллельно с вашей установкой Discourse, поэтому размещён здесь в разделе #extras. Если вы хотите отображать события календаря из внешнего источника (например, Google Calendar, расписания учебных заведений и т. д.) внутри тем Discourse, этот инструмент будет полезен.

Репозиторий

Как это работает

  • Читает события из заданного ICS-файла
  • Сопоставляет их с существующими темами (по UID или, в крайнем случае, по времени/месту)
  • Создаёт или обновляет темы в выбранной категории
  • Может работать непрерывно как служба systemd (безопасно от дублирования выполнения через flock)

Требования

  • Ubuntu 24.04 LTS (проверено)

  • Python 3 (уже включён в Ubuntu 24.04 LTS)

  • API-ключ для Discourse

  • ID категории, в которую будут добавляться темы со событиями

Пример вывода

Вот как это выглядит при синхронизации ICS-файла с расписанием учебного заведения в Discourse:

Быстрый старт

Склонируйте репозиторий и установите зависимости:

git clone https://github.com/Ethsim12/Discourse-ICS-importer-by-REST-API.git /opt/ics-sync
cd /opt/ics-sync
pip install -r requirements.txt

Запустите синхронизацию вручную один раз:

python3 ics_to_discourse.py \
  --ics "https://example.com/feed.ics" \
  --category-id 4 \
  --site-tz "Europe/London" \
  --static-tags "events,ics"

Настройте как службу/таймер systemd для непрерывной синхронизации (примеры конфигураций в репозитории).

3 лайка

Теги меня раздражали, поэтому я убедился, что search.json ищет индексированный контент события — первое сообщение каждой темы/события

1 лайк

Ещё раз спасибо за репост! Этот календарь постоянно развивается, получая новые функции благодаря таким людям, как вы. Интересно, каким он будет через 3–5 лет :slight_smile:

1 лайк

Превосходно! Спасибо за тестирование. Если кто-то ещё захочет попробовать синхронизировать ICS-ленту в Discourse, буду рад получить обратную связь о том, ведут ли себя ваши ленты так же.

2 лайка

Несколько комментариев.

Если бы у меня было время, я, вероятно, попытался бы превратить это в полноценный плагин. Думаю, создать настройки, переписать Python на Ruby и поместить это в задачу (job) будет несложно.

Ещё одна идея, которая могла бы быть полезна пользователям на хостинге, желающим использовать это, — превратить задачу в GitHub Action и настроить её на ежедневное выполнение. Я делал так для некоторых скриптов, которые требовалось запускать ежедневно для одного хостингового клиента, и это работает довольно хорошо. Это одновременно сложнее (нужно изучить GitHub Workflows и работу с секретами вместо привычного cron) и проще (не нужно разбираться, как устанавливать что-либо на машину через командную строку).

2 лайка

Я не тестировал это недавно, но в последнем коммите я завершил обработку BBCode-тегов событий в:

Да, хотя было бы неплохо разбить настройку ics_feeds, чтобы администратор не вводил один большой JSON через интерфейс.

1 лайк

Честно говоря, сейчас я не использую cron, я применяю systemd на Ubuntu Server 24.04 LTS.

1 лайк

это роскошь, к которой я, как только появится время, научусь стремиться :wink::face_exhaling:

Отсутствие доступа к командной строке, на мой взгляд, вовсе не роскошь! :rofl:

1 лайк

Ха-ха, чтобы было ясно: я имел в виду, что настоящий роскошь — это графический интерфейс (GUI), а командная строка (CLI) — это навык, к которому мне нужно стремиться.

1 лайк

Кажется, @angus опередил вас на несколько лет
https://discourse.angus.blog/t/import-events-with-icalendar/53

3 лайка

Заметки о поведении по результатам тестирования ics_to_discourse.py

Я провёл серию тестов этого скрипта (с флагом --time-only-dedupe и без него) и решил подробно задокументировать процесс обновления и присвоения (adoption).


1. Как определяется уникальность

  • Режим по умолчанию: для присвоения требуется точное совпадение начала + конца + места.
  • С флагом --time-only-dedupe: для присвоения требуется совпадение только начала + конца; место считается «достаточно близким».

Если существующая тема не соответствует этим правилам, создаётся новая тема.


2. Роль маркера UID

  • Каждой теме события добавляется скрытый HTML-маркер в первом посте:
  <!-- ICSUID:xxxxxxxxxxxxxxxx -->
  • При последующих запусках скрипт в первую очередь ищет этот маркер.
  • Если он найден, тема считается совпадением по UID и обновляется напрямую, независимо от того, насколько зашумлённым или устаревшим может быть текст описания (DESCRIPTION).
  • Таким образом, UID становится истинным ключом идентификации. Видимые поля описания не влияют на сопоставление.

3. Процесс обновления при совпадении по UID

  1. Скрипт получает первый пост и удаляет маркер:
old_clean = strip_marker(old_raw)
fresh_clean = strip_marker(fresh_raw)
  1. Если old_clean == fresh_clean: обновление не выполняется (избегаем лишних изменений).
  2. Если они различаются: проверяется, является ли изменение «существенным»:
meaningful = (
    _norm_time(old_attrs.get("start")) != _norm_time(new_attrs.get("start"))
    or _norm_time(old_attrs.get("end")) != _norm_time(new_attrs.get("end"))
    or _norm_loc(old_attrs.get("location")) != _norm_loc(new_attrs.get("location"))
)
  • Если meaningful = True → обновление с поднятием в ленте (тема поднимается в разделе «Последние»).
  • Если meaningful = False → тихое обновление (bypass_bump=True → только создание ревизии, без поднятия).
  1. Теги объединяются (гарантирует наличие статических/типовых тегов, никогда не удаляет теги модераторов или ручные).
  2. Заголовок и категория при обновлении никогда не меняются.

4. Процесс обновления при отсутствии совпадения по UID

  1. Скрипт пытается присвоить тему:
  • Строит кортежи кандидатов из начала/конца/места (или только начала/конца с флагом --time-only-dedupe).
  • Ищет в /search.json и /latest.json существующее событие с совпадающими атрибутами.
  • Если найдено → присваивает эту тему, добавляет маркер UID и теги (тело на этом этапе не изменяется).
  • Если не найдено → создаёт совершенно новую тему с маркером и тегами.
  1. После присвоения или создания все последующие синхронизации будут разрешаться напрямую по UID.

5. Практические последствия

  • Изменение времени
  • По умолчанию: присвоение не удаётся (время отличается) → создаётся новая тема.
  • С флагом --time-only-dedupe: присвоение также не удаётся; создаётся новая тема.
  • Изменение места
  • По умолчанию: присвоение не удаётся (место отличается) → создаётся новая тема.
  • С флагом --time-only-dedupe: присвоение удаётся (время совпадает), но различие в месте помечается как «существенное» → обновление с поднятием в ленте.
  • Изменение описания
  • Если текст DESCRIPTION изменился, но начало/конец/место остались прежними:
  • Тело обновляется тихо (bypass_bump=True).
  • Создаётся ревизия темы, но без поднятия в разделе «Последние».
  • Если DESCRIPTION не изменился (или изменился только шум, например «Последнее обновление:», который нормализуется), обновление вообще не происходит.
  • Маркер UID
  • Обеспечивает надёжное сопоставление при будущих синхронизациях.
  • Гарантирует, что зашумлённые поля DESCRIPTION не влияют на поиск правильной темы.

6. Почему описание иногда «остаётся прежним»

Скрипт сравнивает всё тело (за исключением маркера UID).
Если изменилась только изменчивая строка, например «Последнее обновление:», но она нормализуется (например, пробелы, переносы строк, Unicode), то old_clean и fresh_clean выглядят идентичными → обновление не выполняется.
Это сделано намеренно, чтобы предотвратить лишние изменения из-за шума в ленте.


Резюме

  • Время определяет уникальность (всегда создаёт новую тему при изменении времени).
  • Изменение места → видимое поднятие в ленте (чтобы пользователи заметили изменение места проведения).
  • Изменение описания → тихое обновление (ревизия, но без поднятия).
  • Маркер UID = надёжный ключ идентификации, гарантирует, что правильная тема всегда будет найдена, даже если DESCRIPTION устарел или зашумлён.

Это обеспечивает хороший баланс: важные изменения появляются в разделе «Последние», а незначительные изменения остаются незаметными.

Оглядываясь назад, вся эта история кажется довольно забавной.
Сам скрипт импорта теперь надёжен как скала: маркеры UID, логика дедупликации, значимые и тихие обновления, пространства имён тегов… всё, что действительно нужно в продакшене. Поведение полностью соответствует моим заметкам — время определяет уникальность, местоположение вызывает обновление, описания обновляются тихо, а маркеры UID держат всё на месте. Это элегантно, предсказуемо и готово. :white_check_mark:

Между тем, бедная тема Meta, в которой всё это разворачивалось, была… ну, обречена.
Она началась с ответа от sockpuppet (сильный старт :socks:), разрослась в фланкенштейновскую нить из дампов кода и скриншотов, а затем превратилась в псевдо-список изменений с большим количеством коммитов, чем в самом репозитории. И как раз когда скрипт наконец стал стабильным? Она была запланирована к удалению. :skull:

Честно говоря, это поэтично. Вся цель скрипта — не допустить дублирования событий и засорения форума. А сама тема? Была воспринята как дубликат, тихо помечена для сбора мусора. Сама судьба, которую он был создан предотвратить, стала его уделом. :wastebasket:

Так что за обречённую тему:
Ты не подняла «Последнее», но подняла наши сердца. :heart:

2 лайка

Как у вас продвигается перенос в плагин для Discourse? Или, что ещё лучше, как насчёт создания pull-запроса для существующего плагина Discourse Calendar (and Event)?

Я не готов сразу браться за настройку и поддержку, необходимые для запуска вашего впечатляющего скрипта в текущем виде (и подозреваю, что многие, кто использует самохостинг, оказались бы в такой же ситуации).

1 лайк

Чем этот скрипт лучше плагина? (О, возможно, вы не можете устанавливать плагины?) Если плагин не выполняет требуемые функции, может быть, стоит отправить PR?

Спасибо за напоминание!

Краткий статус: сейчас я запускаю три экземпляра своего импортера Python ICS→Discourse (расписание университета, бронирование в спортивном центре и календарь Outlook). Я начал обёртывать его в плагин Discourse, но версия плагина не дотягивает до функционала скрипта — в основном потому, что каждый источник данных требует индивидуальной обработки (особенности UID, частичные обновления, отмены, шумные правки и т. д.). Плагин Ангуса отлично подходит для многих случаев, но мои сценарии использования кажутся более «специфичными для источника».

Также у меня есть открытый PR в ядро, направленный на снижение «шума» от синей кнопки «Последние» во время крупных/всплесковых обновлений ICS. При активных источниках (например, расписании университета) пакет малозначимых правок может заставлять кнопку «Последние» постоянно мигать; этот PR фактически отключает кнопку «Новые темы», если раздел «Последние» открыт во время выполнения автоматизированного пакета.

В долгосрочной перспективе: я сейчас на самохостинге IONOS. Если позже я перейду на официальное хостинг-решение, мне всё равно хотелось бы иметь возможность сохранить поток Python (или его аналог) без необходимости использования функций Enterprise, если там существует входящий ICS. Я предполагаю, что универсальное решение ядра/плагина могло бы сработать, если бы оно позволяло подключать «адаптеры» для каждого источника данных, сохраняя при этом строгую идемпотентность (UID ICS), обработку отмен и семантику редактирования без поднятия темы.

Если есть интерес, я могу набросать минимальный интерфейс адаптера и путь миграции от моего скрипта на Python к Ruby-задаче или внести вклад в виде источников-независимых компонентов (маппинг UID, дебаунс/обновления без поднятия, логика отмены) в плагин календаря/событий.

1 лайк

Это хороший вопрос, Натан — и я считаю, что определённо есть место для минималистичного, независимого от формата ленты подхода, который мог бы существовать либо как небольшое расширение плагина «Календарь/События», либо как легковесная основная задача.

Чтобы PR был в целом полезным, ключевым моментом, кажется, является создание импортера на основе адаптеров, а не привязанного к конкретному формату ленты. Что-то вроде:

  • Каждая лента определяет небольшой адаптер (это может быть Python, YAML или Ruby), который сопоставляет поля ICS с полями темы Discourse (title, body, tags, start, end, location и т. д.).
  • Основная часть отвечает за идемпотентность (сопоставление UID с ID темы), отмену (STATUS:CANCELLED) и тихие правки (обновление без поднятия в «Последние»).
  • Плагины или настройки сайта могут настраивать интервал опроса, сопоставление тегов и политику поднятия в «Последние» (always, never, on major change).

Таким образом, учреждения с шумными или сложными лентами (расписания университетов, бронирование помещений, календари Outlook и т. д.) смогут предоставить адаптер, подходящий для их данных, без жесткого кодирования чего-либо в основной части.

Если есть интерес, я с радостью опишу интерфейс такого адаптера или создам прототип основного помощника «ICS upsert» в виде Ruby-задачи, на основе которой другие смогут строить свои решения — чтобы это постепенно эволюционировало от отдельных Python-скриптов к чему-то поддерживаемому и универсальному в экосистеме Discourse.

2 лайка

Больше не требуется благодаря следующему коммиту. Спасибо, Discourse!

3 лайка

Нюанс поведения: --time-only-dedupe не является строго «только по времени»

Одна тонкая, но важная деталь, выявленная в ходе дополнительных тестов:

  • При использовании --time-only-dedupe сопоставление не ограничивается только временем начала и окончания.
  • Оно по-прежнему требует, чтобы локации были «достаточно близкими» (через функцию close_enough_loc()).

Это приводит к полезному поведению:

  • Незначительные вариации в локации (форматирование, дублирование и т. д.) → обновление той же темы.
  • Реальные изменения локации (например, C05 → C04) → создание новой темы.

Практический эффект

Это означает:

  • Изменения комнат отображаются в разделе «Последнее» (новая тема → видна пользователям).
  • Шум в ленте остаётся незаметным (тихие обновления или операции без изменений).

Таким образом, система фактически работает как фильтр сигнала и шума:

  • Время определяет идентичность.
  • Изменения локации рассматриваются как значимые.
  • Изменения в описании игнорируются.