ICS → Discourse 가져오기 도구

iCalendar(ICS) 피드에서 이벤트를 Discourse 카테고리로 지속적으로 동기화하는 작은 유틸리티를 만들었습니다.

이것은 완전한 Discourse 플러그인이 아니라 Discourse 설치와 함께 실행되므로, Customization > 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로 변환해서 작업(job)으로 넣는 건 그렇게 어렵지 않을 것 같아요.

또 다른 아이디어로, 호스팅 서비스를 사용하면서 이걸 활용하고 싶은 사람들에게 유용할 수 있는 방법은 이 작업을 GitHub Action으로 변환해서 매일 실행되도록 하는 것입니다. 얼마 전 호스팅 클라이언트가 매일 실행해야 하는 몇 가지 스크립트에 대해 이 방법을 적용했는데 꽤 잘 작동하고 있습니다. 이 방식은 더 어렵기도(구식 cron 작업 대신 GitHub 워크플로우를 배우고 시크릿(secrets)을 다루는 방법을 알아야 하므로)하고 더 쉽기도(명령줄 인터페이스를 통해 머신에 프로그램을 설치하는 복잡한 과정을 배울 필요가 없으므로)합니다.

2개의 좋아요

최근에는 테스트해 보지 않았지만, 이벤트 bbcode 파싱을 최신 커밋에 반영하여

네, 그렇습니다. 다만 ics_feeds 설정이 분리되어 관리자가 UI에 단일 JSON을 입력하지 않아도 되는 것이 좋겠습니다.

1개의 좋아요

솔직히 저는 이제 cron을 사용하지 않고, Ubuntu Server 24.04 LTS에서 systemd를 사용하고 있습니다.

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. 고유성(Uniqueness) 결정 방식

  • 기본 모드: 채택(Adoption)을 위해서는 시작 시간 + 종료 시간 + 위치이 정확히 일치해야 합니다.
  • --time-only-dedupe 사용 시: 채택을 위해서는 시작 시간 + 종료 시간만 일치하면 됩니다. 위치는 "충분히 가까움"으로 처리됩니다.

기존 토픽이 이러한 규칙과 일치하지 않으면 새 토픽이 생성됩니다.


2. UID 마커의 역할

  • 모든 이벤트 토픽에는 첫 번째 게시물에 숨겨진 HTML 마커가 삽입됩니다:
  <!-- ICSUID:xxxxxxxxxxxxxxxx -->
  • 이후 실행 시 스크립트는 먼저 해당 마커를 검색합니다.
  • 마커가 발견되면 해당 토픽은 UID 일치로 간주되며, DESCRIPTION 텍스트가 얼마나 노이즈가 많거나 구식이든 관계없이 직접 업데이트됩니다.
  • 이는 UID가 **진정한 식별 키(identity key)**임을 의미합니다. 가시적인 설명 필드는 매칭에 영향을 주지 않습니다.

3. UID 일치 시 업데이트 흐름

  1. 스크립트가 첫 번째 게시물을 가져오고 마커를 제거합니다:
old_clean = strip_marker(old_raw)
fresh_clean = strip_marker(fresh_raw)
  1. old_clean == fresh_clean인 경우: 업데이트하지 않음 (불필요한 변경 방지).
  2. 서로 다른 경우: 변경 사항이 "의미 있는(meaningful)"지 여부를 확인합니다:
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인 경우 → bump와 함께 업데이트 (토픽이 ‘최근’ 목록에서 위로 올라감).

  • meaningful = False인 경우 → 조용히 업데이트 (bypass_bump=True → 리비전만 생성, bump 없음).

    1. 태그가 병합됩니다 (정적/기본 태그가 존재하도록 보장하며, 관리자나 수동으로 추가된 태그는 절대 제거하지 않음).
    2. 업데이트 시 제목과 카테고리는 절대 변경되지 않습니다.

  1. UID 일치 없이 업데이트 흐름
    1. 스크립트가 채택을 시도합니다:
      • 시작/종료/위치(또는 --time-only-dedupe 사용 시 시작/종료만) 후보 3원조를 구성합니다.
      /search.json/latest.json에서 속성이 일치하는 기존 이벤트를 검색합니다.
      • 발견되면 → 해당 토픽을 채택하고 UID 마커 + 태그를 후속 적용합니다 (이 단계에서는 본문은 변경되지 않음).
      • 발견되지 않으면 → 마커와 태그가 포함된 완전히 새로운 토픽을 생성합니다.
    2. 채택 또는 생성이 완료되면, 모든 향후 동기화는 UID를 통해 직접 해결됩니다.

  1. 실질적 결과
    • 시간 변경
    • 기본: 채택 실패 (시간이 다름) → 새 토픽 생성.
    --time-only-dedupe 사용 시: 채택이 동일한 방식으로 실패함. 새 토픽 생성.
    • 위치 변경
    • 기본: 채택 실패 (위치가 다름) → 새 토픽 생성.
    --time-only-dedupe 사용 시: 채택 성공 (시간 일치), 하지만 위치 차이는 “의미 있는” 것으로 플래그가 지정되어 → bump와 함께 업데이트.
    • 설명(Description) 변경
    • DESCRIPTION 텍스트가 변경되었지만 시작/종료/위치는 변경되지 않은 경우:
    • 본문을 조용히 업데이트 (bypass_bump=True).
    • 토픽 리비전이 생성되지만 ‘최근’ 목록에서 bump는 발생하지 않음.
    • DESCRIPTION이 변경되지 않았거나 (또는 Last Updated:와 같이 정규화되어 사라지는 노이즈만 있는 경우) 업데이트가 전혀 발생하지 않음.
    • UID 마커
    • 향후 동기화 시 신뢰할 수 있는 매칭을 보장합니다.
    • 노이즈가 많은 DESCRIPTION 필드가 올바른 토픽이 발견되는지에 영향을 주지 않음을 의미합니다.

  1. DESCRIPTION이 때로는 “변경되지 않은 것처럼” 보이는 이유

스크립트는 UID 마커를 제외한 전체 본문을 비교합니다.
Last Updated:와 같이 변동성이 큰 줄만 다르고, 이것이 정규화되어 사라지는 경우(예: 공백, 줄바꿈, 유니코드) old_cleanfresh_clean이 동일하게 보임 → 업데이트가 수행되지 않음.
이는 피드 노이즈로 인한 불필요한 변경을 방지하기 위한 의도적인 설계입니다.


요약

  • 시간은 고유성을 정의합니다 (시간이 변경되면 항상 새 토픽이 생성됨).
  • 위치 변경 → 가시적인 bump 발생 (사용자가 장소 업데이트를 인지할 수 있도록).
  • 설명 변경 → 조용한 업데이트 (리비전은 생성되지만 bump 없음).
  • UID 마커 = 신뢰할 수 있는 식별 키, DESCRIPTION이 구식이거나 노이즈가 많아도 항상 올바른 토픽이 발견되도록 보장.

이는 좋은 균형을 맞춥니다: 중요한 변경 사항은 ‘최근’ 목록에 노출되고, 중요하지 않은 불필요한 변경은 보이지 않게 유지됩니다.

돌이켜 보면, 이 일련의 사건이 어떻게 흘러갔는지가 꽤나 웃기기도 합니다.
임포트 스크립트 자체는 이제 탄탄합니다: UID 마커, 중복 제거 로직, 의미 있는 업데이트와 조용한 업데이트, 태그 네임스페이스… 프로덕션 환경에서 실제로 원할 법한 모든 기능이 갖춰졌죠. 제가 올린 노트와 동작 방식이 완벽하게 일치합니다 — 시간이 고유성을 정의하고, 위치가 변경 시 업데이트를 트리거하며, 설명은 조용히 갱신되고, UID 마커가 모든 것을 고정해 줍니다. 우아하고, 예측 가능하며, 완성되었습니다. :white_check_mark:

그동안 이 모든 것을 담았던 불쌍한 메타 토픽은… 글쎄요, 운명이 정해져 있었습니다.
처음에는 소켓퍼펫(대체 계정)으로 답글을 올리며 시작했고(훌륭한 출발 :socks:), 코드 덤프와 스크린샷으로 가득 찬 프랑켄슈타인 같은 스레드로 불어났으며, 결국 리포지토리 자체보다 커밋이 더 많은 유사 변경 이력(pseudo-changelog)으로 진화했습니다. 그리고 마침내 스크립트가 안정적으로 변했을 때? 삭제 예정으로 잡혀 있었습니다. :skull:

솔직히 시적(詩的)입니다. 스크립트의 궁극적인 목적은 중복 이벤트가 포럼을 어지럽히는 것을 막는 것이었죠. 그런데 토픽 자체는? 중복으로 간주되어 조용히 가비지 컬렉션 대상이 되었습니다. 스크립트가 방지하려던 그 운명이, 바로 그 토픽의 운명이 된 것입니다. :wastebasket:

그래서 운명된 토픽에게 건배를 올립니다:
당신은 최신 게시글(Latest)을 올리지 못했지만, 우리의 마음을 울렸습니다. :heart:

2개의 좋아요

기존의 Discourse Events 플러그인에 PR을 올리는 것이 더 나을 수도 있지만, Discourse 플러그인으로 전환하는 작업은 어떻게 진행되고 있나요?

멋진 스크립트를 그대로 실행하기 위해 필요한 설정 및 유지보수에 바로 뛰어들기에는 주저됩니다. (많은 셀프호스터들도 같은 상황에 있을 것으로 추정됩니다.)

1개의 좋아요

이 스크립트가 플러그인보다 나은 점은 무엇인가요? (아, 플러그인을 설치할 수 없나요?) 플러그인이 필요한 기능을 수행하지 못한다면, PR을 제출해 보는 건 어떨까요?

알려주셔서 감사합니다!

간단한 현황 보고드립니다. 현재 Python 기반 ICS→Discourse 가져오기 도구를 세 가지 인스턴스로 운영 중입니다(대학 시간표, 스포츠 센터 예약, Outlook 캘린더). Discourse 플러그인으로 래핑하는 작업도 시작했지만, 플러그인 버전은 스크립트의 기능 범위에 미치지 못했습니다. 주로 각 피드가 개별적인 처리(UID 특수성, 부분 업데이트, 취소 처리, 노이즈가 많은 수정 사항 등)를 필요로 하기 때문입니다. Angus님의 플러그인은 많은 경우에 훌륭하지만, 제 사용 사례는 좀 더 "피드 특정"에 가깝습니다.

또한, 대량/단발성 ICS 업데이트 시 “최신(Latest)” 탭의 파란 버튼 노이즈를 줄이기 위해 코어에 대한 공개된 PR도 있습니다. 바쁜 피드(예: 대학 시간표)의 경우, 가치가 낮은 편집 일괄 처리가 “최신” 탭을 계속 흔들리게 만들 수 있습니다. 이 PR은 자동화된 배치 실행 중에 “최신” 탭이 열려 있는 상태에서 “새 주제” 버튼을 사실상 무효화(no-op)하는 효과를 냅니다. 유용하다면 해당 PR에 대한 크로스 링크를 여기에 달아도 좋습니다.

장기적으로는 현재 IONOS의 셀프호스팅을 사용 중입니다. 나중에 공식 호스팅으로 이전하더라도, ICS 수신 기능이 존재한다면 엔터프라이즈 기능이 아닌 Python 플로우(또는 동등한 방식)를 유지할 수 있는 방법을 원합니다. 피드별로 플러그인 가능한 "어댑터"를 허용하면서 강력한 멱등성(ICS UID), 취소 처리, 업데이트 시 알림(bump) 없이 편집하는 의미를 유지할 수 있다면, 일반적인 코어/플러그인 솔루션이 작동할 수 있을 것 같습니다.

관심이 있으시다면, Python 스크립트에서 Ruby 작업으로의 마이그레이션 경로와 최소한의 어댑터 인터페이스를 스케치하거나, 캘린더/이벤트 플러그인에 피드 비종속적 요소(UID 매핑, 디바운스/노-번 업데이트, 취소 로직)를 기여할 수 있습니다.

1개의 좋아요

좋은 질문입니다, Nathan — 캘린더/이벤트 플러그인의 작은 확장으로, 또는 경량화된 코어 잡으로 구현할 수 있는 최소한의 피드 비종속적 접근 방식이 충분히 가능하다고 생각합니다.

PR이 일반적으로 유용하려면, 핵심은 가져오기(importer)를 피드 전용이 아닌 어댑터 기반으로 만드는 것입니다. 예를 들어 다음과 같은 방식이 가능합니다:

  • 각 피드는 ICS 필드를 Discourse 토픽 필드(title, body, tags, start, end, location 등)로 매핑하는 작은 어댑터(Python, YAML, Ruby 등)를 정의합니다.
  • 코어는 멱등성(UID ↔ 토픽 ID 매핑), 취소(STATUS:CANCELLED), 조용한 편집(Latest를 올리지 않고 업데이트)을 처리합니다.
  • 플러그인 또는 사이트 설정을 통해 폴링 간격, 태그 매핑, 번(bump) 정책(always, never, on major change)을 구성할 수 있습니다.

이렇게 하면 소음이 많거나 복잡한 피드(대학 시간표, 강의실 예약, Outlook 캘린더 등)를 가진 기관이 코어에 아무것도 하드코딩하지 않고 자신의 데이터에 맞는 어댑터를 제공할 수 있습니다.

관심이 있으시다면, 그 어댑터 인터페이스를 개요로 정리하거나, 다른 사람들이 기반으로 구축할 수 있는 Ruby 잡 형태의 코어 “ICS 업서트(upsert)” 헬퍼를 프로토타입으로 만들어 볼 수 있습니다. 이를 통해 독립적인 Python 스크립트에서 Discourse 생태계 내에서 유지보수 가능하고 범용적인 것으로 점진적으로 발전시킬 수 있을 것입니다.

2개의 좋아요

다음 커밋으로 더 이상 그렇지 않습니다. Discourse에 감사드립니다!

3개의 좋아요

동작의 미묘한 차이: --time-only-dedupe는 진정한 의미의 “시간 전용”이 아님

추가 테스트를 통해 확인된 미묘하지만 중요한 세부 사항:

  • --time-only-dedupe를 사용할 경우 매칭이 단순히 시작/종료 시간에만 기반하지 않음
  • 여전히 위치가 “충분히 가깝게”(close_enough_loc()을 통해) 일치해야 함

이로 인해 유용한 동작이 나타남:

  • 위치의 사소한 노이즈(형식, 중복 등) → 동일한 토픽이 업데이트됨
  • 실제 위치 변경(예: C05 → C04) → 새로운 토픽이 생성됨

실질적 효과

즉, 다음과 같은 결과를 낳음:

  • 방 변경은 Latest에 표시됨(새 토픽 생성 → 사용자에게 노출)
  • 피드 노이즈는 보이지 않음(조용한 업데이트 또는 무의미한 작업)

따라서 이 시스템은 신호 대 노이즈 필터 역할을 하게 됨:

  • 시간은 정체성을 정의
  • 위치 변경은 의미 있는 것으로 처리
  • 설명의 빈번한 변경은 무시