원문 수집 → 한글 콘텐츠 기획(JSON) → Notion 페이지 생성 → AI 이미지 생성·삽입 → Slack 완료 보고까지, 사람의 개입 없이 도는 에이전트 기반 콘텐츠 생산 파이프라인의 구조와 설계 원칙을 뜯어봅니다.
무엇을 푸는가
쓸만한 아티클·영상을 발견하는 것은 사람이 잘하는 일(큐레이션)이고, 그것을 일정한 포맷의 콘텐츠로 정리하는 것은 반복되는 공정입니다. 이 시스템은 후자를 자동화해서, 사람이 큐레이션에만 집중할 수 있게 만듭니다.
@봇 <URL> 한 줄이면 끝. 나머지는 파이프라인이 처리.이 사례의 핵심 교훈
모델에 프롬프트를 던져 자유롭게 답하게 하면, 매번 형식과 품질이 달라져 시스템으로 신뢰할 수 없습니다. 비결은 두 가지입니다 — LLM의 출력마저 JSON으로 구조화하고, 나머지는 전부 코드와 CLI로 감싸는 것.
매 실행마다 제목 형식·요약 길이·항목 개수가 제각각. 저자를 요청자로 착각하거나 형식이 깨져도 그대로 저장됨. 실패를 사후에 잡아낼 방법이 없음.
plan.json의 칸만 채운다출력이 고정 스키마라 기계가 검증 가능. TL;DR 5개·저자 규칙·한글 포함을 코드가 강제하고, 어기면 VALIDATION_FAILED로 중단. 저장·이미지·보고는 전부 결정론적.
구성 부품
본격적인 흐름에 들어가기 전에, 이 시스템이 어떤 부품으로 구성되는지 지도부터 봅니다. 아래는 이 사례가 실제로 쓴 도구일 뿐 정답이 아닙니다. 중요한 건 각 레이어의 역할이고, 도구는 팀 환경·비용·이미 쓰는 인프라에 맞춰 얼마든지 바꿔 끼울 수 있습니다.
| 레이어 | 이 사례에서 쓴 것 | 대체 가능한 방식 |
|---|---|---|
| 트리거 / 보고 | Slack (봇 멘션 + 스레드 댓글) | Discord · Telegram · 이메일 · 웹훅 · CLI 직접 실행 · GitHub Issue 코멘트 |
| 런타임 | Node.js (의존성 없는 순수 .mjs) |
Python · Deno · Bash 스크립트 · 서버리스 함수(Lambda/Cloud Functions) |
| 원문 수집 | web_fetch · Firecrawl |
Playwright/Puppeteer · Jina Reader · Readability · curl + 파서 · yt-dlp(영상 자막) |
| 콘텐츠 기획 | LLM (코딩 에이전트) | Claude · GPT · Gemini · 로컬 모델(Ollama) — 단, 출력은 구조화(JSON)로 고정 |
| 저장소 | Notion API (DB 페이지 + 블록) | Google Docs/Sheets · Obsidian · 마크다운 파일 + Git · Airtable · WordPress |
| 이미지 생성 | Gemini 이미지 모델 → GitHub raw URL | DALL·E · Stable Diffusion · 이미지 없음(텍스트만) · S3/Cloudinary 호스팅 |
진입점
Slack 채널에서 봇을 멘션하고 URL을 붙이면 실행됩니다. 조건이 안 맞으면 조용히 무시(NO_REPLY)합니다.
# Slack 메시지 <@YOUR_BOT_ID> https://example.com/article # 실행 조건 (둘 다 만족해야 함) 1. 봇 멘션(<@YOUR_BOT_ID>)이 포함 2. http(s):// URL이 최소 1개 포함
동작 흐름
LLM 판단은 딱 한 곳(3단계, 콘텐츠 기획)에만 있고, 나머지는 전부 결정론적 스크립트입니다.
메시지 wrapper에 없는 채널 ID·스레드 ts를 로컬 세션 상태 파일에서 읽어 보완합니다.
핵심 코드 · read_last_slack_ctx.mjs// Slack 이벤트 메타에는 채널ID·ts가 없을 때가 있다 → 세션 상태 파일에서 직접 읽는다 const p = path.join(os.homedir(), '.openclaw', 'agents', 'notion-codex-writer', 'sessions', 'sessions.json'); const entry = JSON.parse(fs.readFileSync(p, 'utf8'))[sessionKey]; // "channel:C012..." 형태에서 채널 ID만 정규식으로 추출 const slackChannelId = parseChannelId(entry.lastTo); const slackThreadTs = String(entry.lastThreadId).trim();
일반 URL은 web_fetch, 본문 추출이 불안정한 도메인은 Firecrawl로 폴백. 짧거나 nav/footer만 오면 실패로 간주하고 재시도합니다.
핵심 코드 · firecrawl_scrape_markdown.mjs// Firecrawl(MCP)을 CLI로 호출. 결과 JSON을 모델에 통째로 주지 않는다 const res = spawnSync('mcporter', ['call', 'firecrawl.firecrawl_scrape', '--args', argsJson, '--output', 'json']); // jq는 응답 속 제어문자에서 깨진다 → 직접 제거 후 파싱하는 게 안전 const clean = String(res.stdout).replace(/[\u0000-\u001F]/g, ''); const md = JSON.parse(clean).markdown ?? ''; // markdown 본문만 뽑음
원문을 읽고 한글 제목·요약·TL;DR 5개·섹션별 본문·이미지 아이디어를 구조화된 JSON으로 만듭니다.
산출물 · tmp_plan.json (LLM이 이 계약을 채운다)// 스크립트에 넘길 유일한 "데이터 계약". Slack에 JSON 전문을 붙이지 않는다 { "title_ko": "'읽지 않은 코드도 배포한다'는 선언 …", "author": "요즘IT · 트파원", // 원문 저자 (요청자 아님) "tldr_bullets": [ /* 정확히 5개 */ ], "sections": [{ "h1": …, "toggle_title": …, "image_placeholders": [/* 페이지 전체 3~5개 */], "bullets": [ … ] }] }
제어문자를 제거하고 파싱 가능 여부를 확인합니다. jq가 제어문자에 깨지는 문제를 회피하는 전용 도구.
핵심 코드 · json_minify_sanitize.mjs// 제어문자를 지운 뒤 파싱 → 성공해야 다음 단계로 넘어간다 const clean = String(raw).replace(/[\u0000-\u001F]/g, ''); try { var obj = JSON.parse(clean); } catch (e) { process.exit(1); } // 파싱 실패 = 검증 실패 process.stdout.write(JSON.stringify(obj)); // 한 줄(minify)로 출력
Slack ctx 로드 + --planFile 전달 + 실패 시 재시도 + "최종 실패 댓글 1개만" 정책을 담당합니다.
핵심 코드 · run_notion_apply_from_files.mjslet r1 = runApply({ planFilePath: planFile }); if (r1.status === 0) process.exit(0); // 성공하면 그대로 종료 // 'Invalid plan JSON'일 때만 sanitize 후 1회 재시도 (그 외엔 재시도 안 함) if (shouldRetryPlanParse) r2 = runApply({ planFilePath: planMinPath }); // 끝내 실패하면 스레드에 실패 댓글을 "1개만" 남긴다 (중간 실패는 숨김) await slackPostMessage({ token, channelId, threadTs, text });
URL 중복 체크 → 페이지 생성 → 속성/본문 블록 작성 → 소요시간 기록 → Slack 완료 보고. LLM 호출 없음.
핵심 코드 · notion_apply.mjsconst existing = await queryByUrl(notionKey, dbid, url, props.URL); if (existing) { // 같은 URL이면 스킵 (업데이트도 금지) return { action: 'skipped', reason: 'URL_EXISTS' }; } // 가드레일: 저자가 요청자와 같으면 버리고, 없으면 '알수없음' if (authorNorm === requestedByNorm) author = ''; if (!author) author = '알수없음'; if (tldr.length !== 5) die('TL;DR must have exactly 5 bullets');
플레이스홀더 문단을 찾아 이미지를 생성(Gemini) → GitHub 레포에 커밋·푸시 → raw URL 200 검증 → Notion 이미지 블록으로 교체.
핵심 코드 · notion_images_replace.mjs// 본문에서 "[이미지 플레이스홀더]"로 시작하는 문단만 골라낸다 const targets = children.filter(b => b.type === 'paragraph' && isImagePlaceholderText(b.text)); // 생성 → GitHub 커밋/푸시 후, raw URL이 200일 때만 Notion에 삽입 if (await verifyHttp200WithRetry(s.rawUrl) !== 200) die('raw url not 200'); await insertAfterInPage(notionKey, pageId, s.placeholderId, imgBlock); await notionRequest(notionKey, 'DELETE', `/blocks/${s.placeholderId}`); // 자리표시자 제거
구성 요소
각 스크립트는 한 가지 일만 합니다. 거대한 단일 프로그램 대신, 셸에서 조합 가능한 작은 도구들로 나눴습니다.
| 스크립트 | 역할 |
|---|---|
read_last_slack_ctx.mjs | 세션 상태에서 Slack 채널 ID·스레드 ts를 읽어옴 |
firecrawl_scrape_markdown.mjs | Firecrawl 결과에서 markdown 본문만 안전하게 추출(긴 JSON을 모델에 직접 주지 않는 wrapper) |
json_minify_sanitize.mjs | plan JSON의 파싱 가능성 검증 + minify/sanitize |
run_notion_apply_from_files.mjs | Slack ctx 로드·plan 전달·재시도·실패 보고를 묶은 오케스트레이터 |
notion_apply.mjs | Notion 페이지 생성/스킵/본문 작성/Slack 보고의 중심 스크립트 |
notion_images_replace.mjs | 이미지 플레이스홀더를 실제 생성 이미지로 교체·삽입 |
데이터 계약
LLM과 스크립트 사이의 인터페이스입니다. 이 "계약"이 고정돼 있기 때문에, 생성 단계(LLM)와 반영 단계(결정론적 코드)를 완전히 분리할 수 있습니다.
{
"title_ko": string, // 한글 제목 (원문 제목 그대로 복사 금지)
"author": string, // 원문 저자. 불명 시 "알수없음"
"category": string,
"keywords": string[],
"summary_ko": string, // 한글 3~5문장
"tldr_bullets": string[5], // 정확히 5개
"sections": [{ // 최소 2개
"h1": string,
"image_placeholders": string[], // 페이지 전체 3~5개
"toggle_title": string,
"bullets": string[]
}]
}
VALIDATION_FAILED로 중단합니다.
여기서 배울 것
이 사례가 교육적으로 가치 있는 이유 — 다른 자동화에도 그대로 적용되는 패턴들입니다.
판단이 필요한 큐레이션은 사람에게, 반복되는 공정은 에이전트에게. 자동화의 범위를 이렇게 그으면 신뢰와 속도를 동시에 얻습니다.
7단계 중 LLM 판단은 단 한 곳. 나머지는 결정론적 스크립트라 재현 가능하고 디버깅이 쉽습니다. "가능한 한 코드로, 꼭 필요할 때만 LLM."
LLM과 코드 사이를 고정된 JSON 스키마로 연결. 자연어를 흘려보내는 대신 계약을 두면, 양쪽을 독립적으로 발전시킬 수 있습니다.
"저자는 요청자가 아니다", "TL;DR은 5개" 같은 규칙을 문서 권고가 아니라 실행 코드로 검증. 어기면 실패시킵니다.
같은 URL은 URL_EXISTS로 스킵하고 업데이트도 막습니다. 재실행해도 본문이 중복 append 되지 않습니다.
zsh 따옴표 깨짐, jq 제어문자 크래시 같은 실패를 우회하고 끝내지 않고, 근본 원인과 대응을 코드·문서에 박아뒀습니다.
실제 산출물
examples/plans/에 실제로 생성된 기획 JSON 8건이 들어 있습니다. 단순 요약이 아니라
제목·TL;DR·섹션·이미지 프롬프트까지 갖춘 Notion용 콘텐츠로 재구성된 형태를 확인할 수 있습니다.
| 제목 | 카테고리 | 섹션 | 이미지 |
|---|---|---|---|
| AI는 일을 줄이지 않는다: 오히려 일을 '진하게' 만든다 | AI/업무혁신 | 4 | 4 |
| Anthropic은 왜 '바이브'로 굴러가는데도 강해 보일까 | AI/조직문화 | 6 | 5 |
| 컴파운드 엔지니어링: 오늘 한 작업이 내일을 더 쉽게 만들게 하는 법 | 개발/프로덕트 | 5 | 4 |
| 회사 AI 도입이 늘 '체감'이 없는 이유 | AI/전략 | 5 | 5 |
| 검색 대신 '리서치'가 필요한 시대: 단계별 AI 도구 7가지 | 업무/리서치 | 5 | 5 |
| LLM이 코드를 잘 짜도 개발자를 대체 못 하는 이유 | AI/개발인사이트 | 4 | 4 |
| '읽지 않은 코드도 배포한다'는 선언: AI 네이티브 개발의 요점 | AI/개발문화 | 5 | 4 |
| 클로드 코드, '잘 쓰는 사람들'은 무엇을 다르게 하나 | AI/업무도구 | 4 | 4 |