ICS → Discourse 导入器(通过 REST API)

我构建了一个小型实用程序,它通过 REST API 将 iCalendar (ICS) feed 中的事件持续同步到 Discourse 类别。

这不是一个完整的 Discourse 插件——它与你的 Discourse 安装并行运行——所以它属于 #extras 部分。如果你想在 Discourse 主题中显示来自外部源(例如 Google Calendar、大学时间表 feed 等)的日历事件,这将很有用。

仓库

工作原理

  • 从给定的 ICS feed 读取事件
  • 将它们与现有主题进行匹配(按 UID 或回退到时间/地点)
  • 在你选择的类别中创建或更新主题
  • 可以作为 systemd 服务持续运行(通过 flock 防止重复执行)

要求

  • Ubuntu 24.04 LTS(已测试)
  • Python 3(Ubuntu 24.04 LTS 中已包含)
  • Discourse API 密钥
  • 要同步事件主题的目标类别 ID

示例输出

以下是将大学时间表 ICS feed 同步到 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 feed 同步到 Discourse 的人,我都非常希望得到关于您的 feed 是否表现相同的反馈。

2 个赞

有几点评论。

如果我有时间,我可能会尝试将其转换为一个真正的插件。我认为创建一些设置并将 Python 转换为 Ruby 并将其放入一个作业中应该不难。

另一个想法,对于那些托管并想使用它的人来说可能很有用,就是将任务转换为 GitHub Actions,并让它每天运行任务。我以前为一位托管客户需要每天运行的某些脚本做过这件事,效果相当不错。这既更难(需要学习 GitHub 工作流以及如何处理 secret 而不是使用老式的 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 未更改(或仅包含可正常化的“上次更新时间”等噪音),则根本不会进行更新。
    • UID 标记
    • 确保未来同步的可靠匹配。
    • 意味着冗长的 DESCRIPTION 字段不会影响是否找到正确的主题。

  1. 为什么 DESCRIPTION 有时“保持不变”
    脚本会比较整个正文(不包括 UID 标记)。
    如果只有“上次更新时间”等易变行不同,但它被正常化(例如,空格、换行符、Unicode),则 old_clean 和 fresh_clean 会显示相同 → 不会进行更新。
    这是故意的,以防止因 Feed 噪音而产生的变动。

总结
• 时间定义唯一性(时间更改时始终创建新主题)。
• 地点更改 → 可见置顶(以便用户注意到场地更新)。
• 描述更改 → 安静更新(修订但不置顶)。
• UID 标记 = 可靠的身份密钥,确保始终找到正确的主题,即使 DESCRIPTION 陈旧或冗长。

这取得了良好的平衡:重要更改会显示在“最新”中,不重要的变动则保持不可见。

回过头来看,整个事件的展开过程真是有点滑稽。

导入脚本本身现在已经非常稳定了:UID 标记、去重逻辑、有意义的更新与静默更新、标签命名空间……所有你真正想要在生产环境中使用的东西。行为与我发布的笔记完美吻合——时间定义唯一性,位置触发递增,描述静默更新,UID 标记将一切固定。它很优雅,很可预测,它完成了。:white_check_mark:

与此同时,托管这一切的可怜的 Meta 主题……嗯,注定要失败。

它最初是以一个马甲(强有力的开端 :socks:)回复的,然后膨胀成了一个包含代码转储和屏幕截图的科学怪人帖子,接着演变成了一个伪更新日志,其提交次数比代码库本身还多。就在脚本终于稳定下来的时候?它被安排删除。:skull:

说实话,这太有诗意了。脚本的全部目的是阻止重复事件弄乱你的论坛。而这个主题本身呢?被视为一个重复项,被静默标记为垃圾回收。它被构建来阻止的命运,最终成为了它自己的命运。:wastebasket:

所以,向这个注定失败的主题致敬:

你没有“最新”更新,但你触动了我们的心。:heart:

2 个赞

您将它迁移到 Discourse 插件的进展如何?或者更好的是,作为对现有 Discourse Calendar (and Event) 插件的 PR?

我不太愿意承担运行您看起来很棒的脚本所需的配置和维护工作(并且怀疑许多自托管者也会有同样的顾虑)。

1 个赞

这个脚本比插件好在哪里?(哦,也许你无法安装插件?)如果插件不能满足要求,也许可以提交一个 PR?

感谢您的提醒!

快速状态:我目前正在运行三个 Python ICS→Discourse 导入器实例(Uni 时间表、体育中心预订和 Outlook 日历)。我确实开始将其包装成一个 Discourse 插件,但插件版本未能达到脚本的功能集——主要是因为每个提要都需要专门处理(UID 细微差别、部分更新、取消、冗余修订等)。Angus 的插件在许多情况下都很好;我的用例似乎更“特定于提要”。

我还对核心有一个开放的 PR,旨在减少大型/突发 ICS 更新期间“最新”蓝色按钮的噪音。对于繁忙的提要(如大学时间表),一批低价值的编辑可能会使“最新”保持跳动状态;当“最新”在自动化批处理运行时保持打开状态时,PR 会有效地对“新主题”按钮执行“无操作”。如果有用,我很乐意在此处交叉链接该 PR。

长期来看:我现在使用的是自托管的 IONOS。如果我以后迁移到官方托管,我仍然希望有一种方法可以在不需要企业功能的情况下保持 Python 流程(或等效流程),如果那里存在 ICS 入站。我怀疑一个通用的核心/插件解决方案可以工作,如果它允许每个提要使用可插拔的“适配器”,同时保持强大的幂等性(ICS UID)、取消处理和无编辑更新语义。

如果有兴趣,我可以勾勒出一个最小的适配器接口以及从我的 Python 脚本到 Ruby 作业的迁移路径,或者为日历/事件插件贡献与提要无关的部分(UID 映射、去抖动/无更新、取消逻辑)。

1 个赞

这是一个好问题,Nathan——我认为绝对有空间采用一种最小化、与信息源无关的方法,它可以作为 Calendar/Event 插件的一个小扩展,或者作为一个轻量级的核心任务。

要使 PR 普遍有用,关键似乎是将导入器基于适配器而不是特定于信息源。类似这样:

  • 每个信息源定义一个小的适配器(可以是 Python、YAML 或 Ruby),它将 ICS 字段映射到 Discourse 主题字段(titlebodytagsstartendlocation 等)。
  • 核心处理幂等性(UID ↔ 主题 ID 映射)、取消(STATUS:CANCELLED)和静默编辑(更新而不提升 Latest)。
  • 插件或站点设置可以配置轮询间隔、标签映射和提升策略(alwaysneveron major change)。

这样,拥有嘈杂或复杂信息源(大学课表、房间预订、Outlook 日历等)的机构就可以提供适合其数据的适配器,而无需在核心中硬编码任何内容。

如果有兴趣,我很乐意概述该适配器接口,或将核心“ICS upsert”助手作为 Ruby 作业进行原型设计,供他人在此基础上构建——这样,它就可以从独立的 Python 脚本逐渐演变为 Discourse 生态系统中可维护且通用的东西。

2 个赞

已不再需要,感谢 Discourse!

3 个赞