ICS → Discourseインポータ(REST API経由)

iCalendar (ICS) フィードから Discourse カテゴリへ REST API を介してイベントを継続的に同期する小さなユーティリティを構築しました。

これは完全な Discourse プラグインではなく、Discourse のインストールと並行して実行されるため、#extras に配置します。外部ソース (Google カレンダー、大学の時間割フィードなど) からカレンダーイベントを Discourse のトピックに表示したい場合に役立ちます。

リポジトリ

仕組み

  • 指定された ICS フィードからイベントを読み取ります。
  • 既存のトピックと照合します (UID で照合するか、時間/場所でフォールバックします)。
  • 選択したカテゴリにトピックを作成または更新します。
  • systemd サービスとして継続的に実行できます (flock による重複実行に対して安全です)。

要件

  • Ubuntu 24.04 LTS (テスト済み)
  • Python 3 (Ubuntu 24.04 LTS には既に含まれています)
  • Discourse API キー
  • イベントトピックの対象となるカテゴリ 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に変換してジョブに入れるのは、それほど難しくないはずだと思います。

もう一つのアイデアは、ホスティングされているユーザーがこれを使用したい場合に役立つかもしれません。タスクをGitHubアクションに変換し、毎日実行させることです。以前、ホスティングされているクライアントが毎日実行する必要があったスクリプトでこれを行ったことがあり、かなりうまくいっています。GitHubワークフローの学習や、従来のcronジョブではなくシークレットの扱い方を学ぶ必要があるため、より難しく(一方で、コマンドラインインターフェイスを介してマシンに何かをインストールする手間を学ぶ必要がないため、より簡単)なります。

「いいね!」 2

最近テストしていませんが、イベントのbbcode解析を最新のコミットにまとめました

はい、ただし、ics_feeds設定を分解すると、管理者がUIに単一のJSONを入力しなくて済むので、それは良いことです。

「いいね!」 1

正直に言うと、私は今 cron を使用していません。Ubuntu Server 24.04 LTS では systemd を使用しています。

「いいね!」 1

これは、時間があればすぐに習得したい贅沢です :wink::face_exhaling:

コマンドラインにアクセスできないのは、私の意見ではまったく贅沢ではありません! :rofl:

「いいね!」 1

笑、明確にするために、GUIが本当の贅沢であり、CLIは私が習得する必要のあるスキルだと言いたかったのです。

「いいね!」 1

@angus が数年前にあなたより先にそれをやってのけたと思います。

「いいね!」 3

ics_to_discourse.py テストからの動作に関する注記

このスクリプト(--time-only-dedupe あり・なし両方)で一連のテストを実行したので、更新/採用フローを詳細に文書化しておくと便利だと思いました。


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. タイトルとカテゴリは更新時に変更されません。

  1. UID マッチなしによる更新フロー
    1. スクリプトは採用を試みます。
      • 開始/終了/場所(または --time-only-dedupe では開始/終了のみ)の候補トリプルを構築します。
      • 一致する属性を持つ既存のイベントを /search.json および /latest.json で検索します。
      • 見つかった場合 → そのトピックを採用し、UID マーカーとタグを後付けします(この段階では本文は変更されません)。
      • 見つからなかった場合 → マーカーとタグを持つまったく新しいトピックを作成します。
    2. 採用または作成されると、将来のすべての同期は UID によって直接解決されます。

  1. 実用上の結果
    • 時間の変更
    • デフォルト: 採用失敗(時間が異なる)→ 新しいトピックが作成されます。
    --time-only-dedupe を使用した場合: 同様に採用失敗。新しいトピックが作成されます。
    • 場所の変更
    • デフォルト: 採用失敗(場所が異なる)→ 新しいトピックが作成されます。
    --time-only-dedupe を使用した場合: 採用成功(時間が一致する)が、場所の違いは「意味のある」ものとしてフラグ付けされ、バンプ付きで更新されます。
    • 説明の変更
    • DESCRIPTION テキストが変更されたが、開始/終了/場所は変更されなかった場合:
    • 本文は静かに更新されます(bypass_bump=True)。
    • トピックのリビジョンが作成されますが、「最新」でのバンプはありません。
    • DESCRIPTION が変更されていない(または正規化されて削除される Last Updated: のようなノイズのみの場合)、更新はまったく行われません。
    • UID マーカー
    • 将来の同期で確実にマッチングが行われます。
    • DESCRIPTION が古かったりノイズが多くても、正しいトピックが見つかることを保証します。

  1. DESCRIPTION が「同じまま」になることがある理由
    スクリプトは UID マーカーを除いた本文全体を比較します。
    Last Updated: のような揮発性の行のみが異なる場合でも、正規化されて(例: 空白、改行、Unicode)削除されると、old_clean と fresh_clean は同一に見えます → 更新は行われません。
    これは、フィードのノイズによる変更を避けるための意図した動作です。

概要
• 時間が一意性を定義します(時間が変更されると常に新しいトピックが作成されます)。
• 場所の変更 → 目に見えるバンプ(ユーザーが会場の更新に気づくように)。
• 説明の変更 → 静かな更新(リビジョンはあるがバンプはない)。
• UID マーカー = 信頼性の高い識別子キー。DESCRIPTION が古かったりノイズが多くても、常に正しいトピックが見つかることを保証します。

これにより、重要な変更は「最新」に表示され、重要でない変更は表示されないという良いバランスが取れています。

振り返ってみると、この一連の出来事がどのように展開したのか、とても面白いです。

インポータースクリプト自体は、今では非常に堅牢です。UIDマーカー、重複排除ロジック、意味のある更新とサイレント更新、タグの名前空間…これらはすべて、本番環境で実際に必要とされるものです。動作は、私が投稿したメモと完全に一致しています。時刻がユニークさを定義し、場所が更新をトリガーし、説明がサイレントに更新され、UIDマーカーがすべてを固定します。エレガントで、予測可能で、完了しました。:white_check_mark:

一方、それをすべてホストしていたかわいそうなMetaトピックは…まあ、運命づけられていました。

それは、ソックパペットとして返信する(力強いスタート :socks:)ことから始まり、コードのダンプとスクリーンショットのフランケンシュタインのスレッドに膨れ上がり、その後、リポジトリ自体よりも多くのコミットを持つ疑似チェンジログに進化したのです。そして、スクリプトがようやく安定したところで?削除が予定されていました。:skull:

正直、詩的です。スクリプトの唯一の目的は、重複したイベントがフォーラムを散らかすのを防ぐことです。トピック自体は?重複と見なされ、サイレントにガベージコレクションのためにマークされました。それが防ぐために構築されたまさにその運命が、その運命となったのです。:wastebasket:

だから、運命づけられたトピックに乾杯です。

「最新」を更新しませんでしたが、私たちの心を更新しました。:heart:

「いいね!」 2

それをDiscourseプラグインに移行するのはどうでしたか?それよりも、既存のhttps://meta.discourse.org/t/discourse-calendar-and-event/97376プラグインにPRとして追加する方が良いでしょうか?

私は、あなたが作成した素晴らしいスクリプトをそのまま実行するために必要な設定やメンテナンスに飛び込むことをためらっています(そして、多くのセルフホスティングユーザーも同じような状況にあると推測します)。

「いいね!」 1

このスクリプトはプラグインよりも優れている点は何ですか?(ああ、プラグインはインストールできないのですか?)プラグインが要求どおりに機能しない場合は、PRを提出しますか?

お声がけありがとうございます!

現在の状況を簡単にご報告します。現在、PythonでICS→Discourseインポーターを3つ(大学の時間割、スポーツセンターの予約、Outlookカレンダー)実行しています。Discourseプラグインとしてラップし始めたのですが、プラグイン版ではスクリプトの機能セットに及びませんでした。これは主に、各フィードが個別の処理(UIDの癖、部分的な更新、キャンセル、ノイズの多いリビジョンなど)を必要とするためです。Angusのプラグインは多くのケースで優れていますが、私のユースケースはより「フィード固有」のようです。

また、大規模/バースト的なICS更新中の「最新」の青いボタンのノイズを削減することを目的とした、コアへのPR(プルリクエスト)もオープンしています。大学の時間割のような忙しいフィードでは、一連の価値の低い編集が「最新」をバウンドさせ続ける可能性があります。このPRは、自動化されたバッチが実行されている間に「最新」が開いたままになっている場合に、「新規トピック」ボタンを効果的にノーオペレーションにします。必要であれば、そのPRをここにクロスリンクできます。

長期的には、現在セルフホストのIONOSを使用しています。後で公式ホスティングに移行する場合でも、Enterprise機能なしでPythonフロー(または同等のもの)を維持する方法があれば嬉しいです。ICSインバウンドがそこで利用可能であれば、の話ですが。汎用的なコア/プラグインソリューションは、強力な冪等性(ICS UID)、キャンセル処理、および編集時のバンプなしセマンティクスを維持しながら、フィードごとのプラグ可能な「アダプター」を許可すれば機能するのではないかと推測しています。

関心があれば、最小限のアダプターインターフェースと、PythonスクリプトからRubyジョブへの移行パスをスケッチするか、カレンダー/イベントプラグインにフィードに依存しない部分(UIDマッピング、デバウンス/バンプなし更新、キャンセルロジック)を提供できます。

「いいね!」 1

それは良い質問ですね、ネイサン。カレンダー/イベントプラグインへの小さな拡張機能として、あるいは軽量なコアジョブとして存在する、フィードに依存しないミニマルなアプローチの余地は間違いなくあると思います。

PRが一般的に役立つためには、インポーターをフィード固有のものではなく、アダプターベースにすることが鍵のようです。例えば:

  • 各フィードは、ICSフィールド → Discourseトピックフィールド(titlebodytagsstartendlocationなど)をマッピングする小さなアダプター(Python、YAML、またはRuby)を定義します。
  • コアは、冪等性(UID ↔ トピックIDマッピング)、キャンセル(STATUS:CANCELLED)、およびサイレント編集(更新時に「最新」を更新しない)を処理します。
  • プラグインまたはサイト設定で、ポーリング間隔、タグマッピング、および更新ポリシー(alwaysneveron major change)を設定できます。

これにより、ノイズが多い、または複雑なフィード(大学の時間割、部屋の予約、Outlookカレンダーなど)を持つ機関は、コアに何もハードコーディングすることなく、データに適したアダプターを提供できます。

関心があれば、そのアダプターインターフェイスの概要を説明したり、他の人が構築できるRubyジョブとしてコアの「ICS upsert」ヘルパーをプロトタイプしたりできます。これにより、スタンドアロンのPythonスクリプトから、Discourseのエコシステム内で保守可能で汎用的なものへと徐々に進化させることができます。

「いいね!」 2

このコミットにより、それは不要になりました。Discourse様、ありがとうございます!

「いいね!」 3