| name | scc-web-articles-publish |
| description | Notion에 작성한 콘텐츠를 web.staircrusher.club/articles 정적 페이지로 발행해 검색엔진(SEO) + AI답변엔진(AEO/GEO)에 노출시킨다. "노션 글 발행해줘", "아티클 올려줘", "articles 갱신", "노션 콘텐츠 검색에 걸리게", "article list DB 렌더링" 같은 요청 시 사용. 사람은 Notion에 제목+본문만 쓰고, 이 스킬이 메타데이터(slug/summary/ogImage/tags/faq)를 LLM으로 생성해 DB에 라이트백한 뒤, 결정론적 노드 스크립트로 본문을 HTML로 변환한다. last_edited_time 기반 incremental — 신규/변경/삭제 문서만 처리해 토큰을 아낀다. |
SCC Web Articles Publish — Notion → web.staircrusher.club/articles
목적
팀이 Notion으로 만든 콘텐츠는 Notion publish로는 검색에 안 걸려 신규 유입이 0이다. 이 스킬은 그 콘텐츠를 우리 도메인(web.staircrusher.club/articles)의 완전 정적 HTML로 발행해 SEO + AEO/GEO 유입을 만든다.
- iframe 아님(
X-Frame-Options로 막히고 SEO 크레딧이 notion으로 샘). 블록→HTML 1회 변환 후 정적 서빙.
- 사람은 제목+본문만 작성. 나머지 메타는 스킬이 생성·DB 라이트백.
- 본문 변환은 결정론적(노드 스크립트, LLM 토큰 0). 메타 생성만 LLM, 그것도 신규/변경분만.
구성 요소 (이미 레포에 있음)
| 파일 | 역할 |
|---|
scripts/build-articles.js | 결정론적 생성기: DB 쿼리 → incremental diff → 블록 fetch → 시맨틱 HTML + 이미지 다운로드 + manifest + sitemap/robots/llms |
scripts/article-template.js | 자체 반응형 셸 + SEO 메타 + JSON-LD(Article/FAQPage). 480px SPA 프레임 안 탐 |
web-articles/ (git-tracked) | manifest.json(=발행됨의 근거) + {slug}/index.html + {slug}/assets/* 커밋본 |
scc-server/.../lambda/seo-handler.js | /articles*를 UA 무관 항상 index.html로 리라이트(STATIC_PATTERNS) |
전제조건 (최초 1회)
- Notion integration: https://www.notion.so/my-integrations 에서 internal integration 생성 → secret 발급.
- DB 공유: article-list DB(+ 하위 페이지)를 그 integration에 share.
- 토큰:
export NOTION_TOKEN=... (커밋 금지). DB id는 DB URL의 32자 hex.
- 토큰 위치(이 환경):
~/.claude.json의 notion MCP 서버 env에 NOTION_TOKEN(ntn_…)로 저장돼 있음. 빌드 시 로그 노출 없이 주입:
NOTION_TOKEN=$(python3 -c "import json,re;print(re.search(r'\"NOTION_TOKEN\"\s*:\s*\"(ntn_[A-Za-z0-9]+)\"',json.dumps(json.load(open(__import__('os').path.expanduser('~/.claude.json'))))).group(1))") \
node scripts/build-articles.js --db <database_id>
- 발행 대상 article-list DB:
383c9499b06080639b1be2bcdc48981c (Notion "web.staircrusher.club 아티클").
- 의존성:
yarn add @notionhq/client (scc-app).
- Lambda 배포(1회):
seo-handler.js의 /articles 패턴을 반영하려면 Lambda@Edge 재배포 필요.
/scc-infra-ops 절차로 staircrusher-club-web 모듈 aws-vault exec swann-scc -- terraform apply. (사용자 명시 요청 시에만)
Notion DB 스키마 (최소화 — 사람은 글만 쓴다)
- 사람이 작성: 페이지 제목(= h1/
<title>) + 본문. 그게 전부.
- 스킬이 생성해 DB에 라이트백(머신 관리, 사람은 손 안 댐):
slug (rich_text) — 제목+내용 기반 URL id
summary (rich_text) — 검색 최적 한줄 요약 (meta description/리드 겸용)
ogImage (url, 선택) — 대표 이미지. 없으면 본문 첫 이미지 자동 사용
tags (multi_select, 선택)
faq (rich_text, 선택) — [{"q":"...","a":"..."}] JSON 문자열 → FAQPage 스키마
published 프로퍼티 없음 — 발행 여부 = web-articles/manifest.json에 존재하는지로 판단.
콘텐츠 투입: mention row (다른 DB 글을 "옮기기")
이미 다른 Notion DB/페이지에 쓴 글을 발행하려면, article-list DB에 제목이 그 페이지 mention인 row를 만든다(원본은 그대로 둠). 빌드가 mention을 따라가 본문을 가져오고, 메타는 이 row 프로퍼티에서 읽는다.
"properties": {"Name": {"title": [
{"type":"mention","mention":{"type":"page","page":{"id":"<원본 page id>"}}},
{"type":"text","text":{"content":" "}}
]}}
- 소스 컬렉션 DB의 글 목록은
API-query-data-source(child DB의 data_source id는 API-retrieve-a-database로 얻음)로 뽑는다.
- 대량이면 병렬 subagent에 (rowId 생성 + STEP 2 메타 라이트백)을 위임하되 slug는 오케스트레이터가 미리 배정(agent 간 충돌 방지).
절차
STEP 1 — diff (무변경 문서는 건드리지 않는다)
NOTION_TOKEN=... node scripts/build-articles.js --db <database_id> --dry
- 출력의 "신규/변경 N · 삭제 K · 메타미비 M"을 확인. 변경 문서 목록을 STEP 2 대상으로 잡는다.
- "메타미비"(slug/summary 없음)로 잡힌 문서가 STEP 2에서 메타를 채워야 하는 신규 글이다.
STEP 2 — 메타 생성 + DB 라이트백 (신규/변경분만, LLM)
변경된 각 문서에 대해:
- 본문을 읽는다 — MCP
notion-fetch(enhanced markdown)로 내용 파악.
- 검색에 최대한 잘 걸리도록 생성:
slug: 영문 kebab-case, 핵심 키워드 포함, 적절한 길이(과도하게 길지 않게).
summary: 1~2문장, 핵심 답변을 앞에. 검색 의도 키워드 자연 포함.
ogImage: 본문 내 대표 이미지 1개(없으면 비워둠 → 스크립트가 첫 이미지 사용).
tags: 2~5개.
faq: 본문에 Q&A 성격이 있으면 [{"q","a"}]로(AEO 핵심). 없으면 생략.
- MCP
notion-update-page로 해당 프로퍼티를 DB에 라이트백(캐싱 + 사람이 검토·수정 가능).
★ faq 라이트백 함정 (MCP): API-patch-page는 rich_text content가 그 자체로 유효한 JSON(맨 앞 [/{)이면 자동 파싱 후 "should be a string"으로 거부한다. 그래서 faq JSON 배열은 그대로 못 쓴다. 해결: content 앞에 zero-width space()를 붙여 문자열로 저장한다(예: "[{\"q\":...}]"). 빌드가 build-articles.js에서 앞쪽 공백/zero-width를 strip 후 JSON.parse하므로 정상 복원된다. (병렬 작성 시 인코딩 통일 필수 — prose로 쓰면 FAQPage 스키마 누락.)
★ write-back 시계 함정: 라이트백은 last_edited_time을 올린다. 그래서 STEP 2(라이트백) → STEP 3(빌드, DB 재쿼리) 순서를 지키면, 빌드가 라이트백 이후의 시각을 manifest에 저장한다. 다음 실행 땐 사람이 본문을 또 고치지 않는 한 시각이 같아 스킵된다. 순서를 어기면 매번 재처리되니 주의.
STEP 3 — 결정론적 빌드 (본문→HTML, 무LLM)
NOTION_TOKEN=... node scripts/build-articles.js --db <database_id>
- 변경분만 블록 fetch + 이미지 다운로드(presigned 만료 대응 — 로컬 에셋으로 커밋) + HTML 생성.
web-articles/{slug}/(커밋본)과 web-dist/articles/(배포용) + 목록/sitemap/robots/llms 동시 갱신.
STEP 4 — 시각 검증 (E2E)
npx serve web-dist -s -l 5050
- Playwright/브라우저로
/articles, 변경된 /articles/{slug} 접속 → callout/toggle/이미지/표가 정상인지 확인.
- HTML 소스에 title/description/canonical/OG/JSON-LD 존재 확인. [Google Rich Results Test]로 Article/FAQPage 검증.
STEP 5 — 커밋
web-articles/(manifest + 생성 HTML + 에셋)를 커밋&푸시. (web-dist/는 gitignore라 커밋 안 됨)
STEP 6 — 배포 (/scc-app-release 절차, 사용자 명시 요청 시에만)
순서 필수 — web-deploy.sh는 --delete sync라 web-dist에 SPA+bbucle+articles가 모두 있어야 기존 사이트가 안 지워진다:
yarn web:build
NOTION_TOKEN=... node scripts/build-articles.js --db <id>
npx serve web-dist -s
aws-vault exec swann-scc -- ./web-deploy.sh
블록 렌더링 & 디자인 충실도 (Notion ↔ article 1:1)
빌드 STEP 3 후 모든 article을 Notion 원본과 시각 대조한다(STEP 4). 새 글은 기존 글이 안 쓰던 블록을 쓸 수 있고, renderBlock의 default는 본문을 버린다(⚠️ 미지원 블록(스킵) 로그 필수 확인). 지금까지 처리한 것:
child_database (인라인 DB) — row 본문 유무로 3분기 (renderChildDatabase). 핵심 콘텐츠가 프로퍼티에 있는지/row 하위 페이지 본문에 있는지 반드시 확인(get-block-children으로 row 본문 샘플). 안 하면 통째로 유실된다:
- 본문 없음(프로퍼티형): 흑백/BTS/전국-표 등 → 가로 스크롤 표(select/multi_select는 Notion 색 pill).
- 본문=사진만(image-only): goyang/kspo/nationwide → 표 + row별 사진 썸네일 컬럼 인라인(얇은 상세 페이지 안 만듦).
- 본문=리치(heading/callout/텍스트): diaspora/콜택시-지역별 → row별 독립 상세 페이지 발행(
/articles/<parent>/<rowSlug>/) + 부모는 링크 카드/링크 표(Notion "카드 클릭→상세" 모방). 상세 slug/summary는 web-articles/subpages.json(rowId→메타)에 LLM으로 생성해 커밋(STEP 2b). sitemap 포함, 목록엔 미노출.
- 컬럼 순서: 공식 API가 뷰 순서를 안 줌 →
COLUMN_ORDER 맵(블록 id→컬럼 배열), 새 DB는 갱신. 뷰 순서는 DB public 페이지 .notion-table-view-header-cell로 확인.
- DB 제목은 표 위 — 인라인 DB 제목은
<figcaption>(표 하단)이 아니라 표 위 <p class="db-title">로. (Notion 인라인 DB 디자인)
- 표 셀은 wrap —
.db-wrap table에 white-space:nowrap 금지(모든 셀 1줄 강제 → 무한 가로 스크롤). white-space:normal;word-break:keep-all;overflow-wrap:anywhere로 한글 단어 유지하며 컨테이너 폭에 맞춰 줄바꿈. 표 셀 shift+enter는 아래 \n→<br> 규칙으로 해결됨.
- 하위 블록 재귀 필수 —
paragraph·to_do도 has_children면 하위를 렌더해야 한다. 안 하면 문단 하위 섹션·중첩 체크리스트가 통째로 유실(nationwide '추가 정보' 섹션, 전동휠체어 준비물 6항목 실제 사고).
- 컬러 callout/블록 — callout
color=배경(default_background→Notion 기본 회색 #f1f1ef), paragraph/heading 블록 color도 반영(colorStyle). 인라인 span 색/밑줄은 renderRich.
- callout 아이콘은 Notion 원본대로(
renderCalloutIcon) — emoji는 그대로; 빌트인(type:"icon", 예 cursor-click)은 https://www.notion.so/icons/{name}_{color}.svg 다운로드; external/file/custom_emoji는 그 URL 다운로드; icon:null이면 아이콘 없음(💡 강제 금지). 💡 폴백으로 뭉개면 커스텀 디자인이 다 죽는다(실제 지적).
- 줄바꿈(shift+enter) 보존 — Notion rich_text
plain_text의 \n은 HTML에서 공백으로 붕괴 → renderRich에서 \n→<br>. 표 셀(renderPropValue→renderRich)에도 적용됨.
- 빈 줄(empty line) 보존 — 내용·하위블록 없는 빈
paragraph도 <p class="empty">(높이 1em)로 유지. 버리면 의도된 줄간격이 뭉개져 다닥다닥 붙는다.
- 이미지 표시 폭/정렬은 비공식 v3 API로 반영(
fetchImageLayout) — 공식 API 이미지 블록은 caption/file만 주고 표시 폭/정렬을 안 준다. Notion 비공식 POST https://www.notion.so/api/v3/loadPageChunk(공개 페이지는 무인증 200, Oopy가 쓰는 그 데이터)의 recordMap.block[id].value.value.format에서 block_width(px)·block_alignment·block_full_width를 페이지 단위로 수집(ctx.imgLayout, blockId=하이픈 UUID로 매칭). image 렌더에서 full이면 100%, 아니면 max-width:{block_width}px + align(center=margin auto/right). 페이지가 비공개면 무데이터 → 자연 크기 폴백. (공식 API "불가능"으로 착각 금지 — 실제 지적받음.)
- heading 토글 —
is_toggleable면 <details>(하위 블록 유실 방지). 모든 heading에 id(=블록id no-hyphen) 부여.
table_of_contents + 앵커 — heading id 기반 목차 nav 렌더. 인페이지 #블록id 링크는 fixHref가 #no-hyphen으로 remap.
- 내부 링크 remap(
fixHref) — Notion 페이지-id 경로(/d490…)·노션 도메인은 죽은 링크 → LINK_MAP(발행된 글/상세 URL) 있으면 그리로, 없으면 /articles. ("뒤로가기" 등 전 글의 dead link 제거.)
- 이미지 가드 — 트래킹 픽셀(seeyoufarm)·비-http(
file:) 스킵.
- fetch 타임아웃/재시도(
fetchWithTimeout) — 무타임아웃 fetch는 stalled 연결(만료 presigned 등)에서 빌드 무한 hang. 25~30s 타임아웃 + 재시도 + 429 백오프 필수.
- 불가피: Notion API가
type:"unsupported"로 주는 블록(button 등)은 콘텐츠가 없어 렌더 불가 — 기록만.
AEO/GEO 체크리스트 (생성기/메타에 반영됨)
- ✅ 정적 본문이 초기 HTML에 텍스트로 존재(JS 의존 0) — LLM 크롤러가 읽음
- ✅ 리드 요약(직접답변) + FAQ →
FAQPage JSON-LD
- ✅
Article JSON-LD(author/publisher=계단뿌셔클럽, datePublished)
- ✅ canonical=자기 자신, OG/Twitter 카드
- ✅
robots.txt에 GPTBot/ClaudeBot/PerplexityBot/Google-Extended 허용, /llms.txt 글 인덱스
- ✅ 엔티티 일관성(계단뿌셔클럽 브랜드/용어), sitemap 등록
범위 밖 (다음 페이즈)
- 저장 → 로그인 유도: 백엔드(scc-api/server) 저장 API + 웹 로그인 플로우 필요. 크로스레포라
/scc-feature로 별도 진행. (템플릿에 <!-- TODO --> 자리만 둠)