ENFRKOantony langlois
작업

Vocab Catcher

방문자동화PythonTelegramGeminiObsidian

방금 만난 단어를 Obsidian 플래시카드로 바꿔주는 Telegram 봇 · Gemini JSON 모드 보강 · 텍스트 메시지 하나가 몇 초 만에 간격 반복 카드가 됩니다

해결할 문제

저 같은 언어 학습자들은 새로운 단어를 배우고 기억하기 위해 흔히 플래시카드를 사용합니다. 플래시카드는 한쪽 면에 영어(또는 어떤 언어든) 뜻이, 다른 쪽 면에 학습 중인 언어가 적혀 있습니다. 제 경우에는 한국어입니다.

하지만 이 과정은 무척 번거롭고 시간이 오래 걸립니다. 그리고 한국어를 배우면서 깨달은 것은, 일상 속에서 친구가 새 단어를 알려주거나 길에서 새 단어를 마주쳤을 때 그 자리에서 단어마다 플래시카드를 만들거나 자세한 정의를 적을 시간이 없다는 점이었습니다. 그래서 5분 뒤에는 결국 잊어버리게 되고, 이는 쌓이고 쌓여 막대한 지식의 낭비가 됩니다.

어떻게 해결했는가

저는 새 단어를 빠르게 던져 넣을 수 있는 방법을 만들어 이 문제를 해결했습니다. 그러면 시스템이 알아서 Obsidian에 플래시카드를 자동으로 만들어 줍니다. 덕분에 하루 동안 매끄럽게 새 단어를 배우고, 집에 돌아와 복습하며 장기적으로 기억할 수 있습니다.

Telegram 봇에 단어를, 선택적으로 그 단어의 짧은 뜻과 함께 보내면, VPS에 설치된 Python 스크립트가 Gemini를 이용해 더 자세하고 정확한 뜻을 얻어 그 단어를 플래시카드로 만들어 줍니다.

플래시카드는 2장이 만들어집니다. 하나는 단어의 정의만 담은 것이고, 다른 하나는 빈칸이 들어간 문장으로, 실제 문장에서 그 단어를 쓰는 연습을 하도록 해줍니다. Obsidian 플래시카드는 그저 markdown 파일입니다.

플래시카드가 만들어지면 Telegram 봇이 확인 메시지와 함께 그 단어의 뜻을 다시 보내주어, 우리가 올바른 뜻을 파악했는지 확인할 수 있게 합니다.

그다음 플래시카드는 GitHub 저장소로 푸시되고, Obsidian Git 플러그인을 통해 Obsidian 데스크톱과 모바일이 그 저장소에서 카드를 가져와 실시간으로 여러분의 플래시카드에 추가됩니다.

아키텍처

phone
클라이언트 · 포착
Telegram 앱
any device
사용자가 단어를 만나는 순간 보냅니다: 한국어 단어, 로마자 표기, 또는 평범한 영어 설명
SaaS
외부 · 메시지 브로커
Telegram Bot API
api.telegram.org
봇이 가져갈 때까지 업데이트를 대기시킴 · 인바운드 포트 불필요
desktop · mobile
클라이언트 · 복습
Obsidian
Spaced Repetition plugin
각 노트를 두 개의 플래시카드로 렌더링: word::meaning과 빈칸 문장
Python · asyncio
코어 · 폴링 루프
vocab-catcher 봇
bot.py · single file
Telegram을 롱 폴링하고, 각 단어를 Gemini로 보강하고, 노트와 빈칸을 만들어 보관함에 기록합니다
long-poll getUpdatesJSON 모드 보강빈칸 빌더자기 종료 워치독git 동기화 (VPS 브랜치)
gemini-2.5-flash
외부 · LLM
Google Gemini
google-genai SDK
JSON 호출 한 번으로 철자, 의미, 예문, 주제, 표면형을 반환합니다
Markdown files
저장소 · 노트
Obsidian 보관함
OBSIDIAN_VAULT_PATH
단어당 노트 하나, #flashcards/<lang>/<theme> 태그 부착
private repo
외부 · 동기화
보관함 git 원격
SSH deploy key
VPS가 각 노트를 푸시 · 기기들은 Obsidian Git 플러그인으로 풀

사용자가 Telegram 앱에서 봇에게 단어를 보내면, Telegram Bot APIgetUpdates 롱 폴링으로 가져갈 때까지 그것을 보관합니다. 핸들러는 Gemini에 JSON 모드 호출을 한 번 하고, Gemini는 철자, 의미, 예문, 주제, 그리고 그 문장 안에서의 단어의 정확한 표면형을 반환합니다. 이로부터 봇은 word::meaning 카드와 빈칸(cloze) 카드가 담긴 Markdown 노트를 만들어 Obsidian 보관함에 기록하고, (VPS 브랜치에서는) 백그라운드로 보관함 git 원격에 커밋하고 푸시합니다. 봇은 Saved: ...라고 답하며, 사용자의 기기들이 그 노트를 받아 Obsidian에서 복습합니다.

배포: 봇 · 노트북 → macOS launchd 사용자 에이전트 (macos-background-service 브랜치) · 봇 · 상시 가동 → VPS · Restart=always 설정의 systemd (vps-deployment 브랜치)

성과

  • ~120줄 · 핵심 전체가 한 파일에 · bot.py
  • 열린 포트 0개 · 롱 폴링만 사용 · 웹훅, 도메인, TLS 불필요
  • 단어당 카드 2개 · 정의 + 빈칸, 자동 생성
  • 자가 복구 · 워치독이 멈추면 종료 · 슈퍼바이저가 재시작

보여준 역량: LLM 구조화 출력 (JSON 모드) · 슈퍼바이저 재시작을 갖춘 crash-only 설계 · 비동기 잠금 뒤의 best-effort git 동기화 · 부가적 git 브랜치로 구현한 배포 변형

문제와 해결책

하나의 제약이 모든 것을 이끕니다: 단어 포착은 몇 초 안에 끝나야 하고, 파이프라인은 결코 조용히 죽어서는 안 됩니다.

▒ 산문 파싱 대신 구조화된 출력

문제: 자유 텍스트로 된 Gemini 응답은 취약한 문자열 파싱이 필요하고, 파싱이 잘못되면 사용자가 몇 주 뒤 복습 도중에야 발견하는 손상된 플래시카드가 생깁니다.

JSON 모드를 강제하고(response_mime_type: application/json) 정확한 키를 고정하는 시스템 지시를 더했습니다. 핸들러는 평범한 json.loads로 응답을 소비합니다.

  • 정규식으로 산문 답변을 파싱하는 방식은 거부: 조용한 손상은 눈에 보이는 실패보다 나쁩니다
  • 잘못된 형식의 응답은 요란하게 실패하므로('Failed to process'), 사용자는 단어를 다시 보내기만 하면 됩니다
  • 모든 하위 단계가 사소한 딕셔너리 접근이 되어, 방어적 파싱 코드가 사라집니다

▩ 활용되는 언어를 위한 빈칸 카드

문제: 한국어는 굴절합니다: 동사의 사전형은 실제 문장에 그대로 나타나는 경우가 드물기 때문에, 예문 안에서 사전형을 빈칸으로 만드는 것은 실패합니다.

프롬프트는 Gemini에게 그 자신의 예문 안에 나타나는 정확한 표면형도 요청합니다. 그러면 빈칸 생성은 단일 리터럴 문자열 치환이 됩니다.

  • Python으로 형태론 규칙을 작성하는 방식은 거부: 임의의 LANGUAGE 설정에는 가망이 없습니다
  • 표면형이 일치하지 않으면, 빈칸 카드는 잘못 저장하기보다 건너뜁니다
  • 언어학을 모델에 떠넘기면 코드가 언어에 무관해집니다: 환경 변수 하나로 한국어를 일본어나 스페인어로 전환합니다

░ 웹훅 대신 롱 폴링

문제: 봇은 노트북이나 저렴한 VPS에서 돌아갑니다; 웹훅은 단일 사용자 도구를 위해 공개 HTTPS 엔드포인트, 도메인, 인증서 관리를 요구합니다.

무한 부트스트랩 재시도가 있는 run_polling을 통한 롱 폴링. Telegram은 봇이 오프라인인 동안 메시지를 서버 측에 대기시켜 두었다가 재연결 시 전달합니다.

  • 웹훅 인프라는 거부: 사용자 한 명의 메시지 양에서는 이점이 전혀 없습니다
  • 토큰당 폴러 하나 제한을 수용(Telegram은 409 Conflict를 반환)하고, 런북에 문서화했습니다
  • 인터넷에 노출되는 것이 없으므로, 유지할 공격 표면이 없습니다

▥ 자기 프로세스를 죽이는 워치독

문제: 폴러는 노트북 절전/재개나 네트워크 변경 후에 조용히 멈출 수 있습니다: 프로세스는 아무것도 받지 않으면서도 launchd에게는 살아 있는 것처럼 보입니다.

asyncio 태스크가 30초마다 updater.running을 확인하고 멈추면 **os._exit(1)**을 호출합니다; launchd 또는 systemd가 새 상태로 프로세스를 재시작합니다.

  • 프로세스 내부 재연결 로직은 거부: 라이브러리 내부와 씨름하는 것은 취약합니다
  • Crash-only 설계: 재시작이 유일한 복구 경로이므로 항상 잘 테스트됩니다
  • 같은 방식이 launchd와 systemd 양쪽에서 그대로 동작합니다

▨ 플래그가 아닌 브랜치로 구현한 배포 변형

문제: 세 가지 실행 방식(포그라운드 개발, macOS 백그라운드 에이전트, git 동기화가 있는 VPS)은 단일 파일을 설정 플래그와 죽은 코드 경로로 채울 것입니다.

main은 최소한으로 유지됩니다; launchd 설치 프로그램은 한 브랜치에 있고, VPS 브랜치는 vault_sync.py와 bot.py의 세 줄을 더합니다.

  • 환경 변수 기능 플래그는 거부: 모든 배포가 다른 배포의 죽은 코드를 지니게 됩니다
  • 브랜치를 main과 동기화 상태로 유지하는 것을 수용하고, 엄격히 부가적인 diff(66줄)로 비용을 낮게 유지했습니다
  • 각 체크아웃은 정확히 그것이 실행하는 코드로 읽히며, 가정적인 것은 없습니다

▣ 저장을 결코 막지 않는 git 동기화

문제: VPS에서 보관함은 사용자의 기기들에 도달해야 하지만, 실패한 푸시가 노트를 잃거나 메시지 핸들러를 죽여서는 안 됩니다.

Best-effort 동기화: 노트가 먼저 디스크에 닿고, 그다음 커밋 + 푸시가 asyncio 잠금 뒤의 스레드에서 실행됩니다; 실패는 로깅되며 결코 던져지지 않습니다.

  • Obsidian Sync와 Syncthing은 거부: git은 비공개 저장소에서 이력과 배포 키 인증을 공짜로 줍니다
  • 잠금이 동시 메시지를 직렬화하여 git 인덱스가 결코 경쟁하지 않게 합니다
  • 실패한 푸시는 자가 복구됩니다: 대기 중인 커밋이 다음 단어의 푸시에 함께 실려 갑니다