Open-Source Cold Email & Warmup Platform
Warmbly 是开源的冷邮件平台:温暖您的邮箱、发送活动、并对每条回复进行分流。可在我们的云端运行,也可自行托管整套系统。
AI 翻译
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
키워드나 기사 URL을 넣으면 인스타그램 카드뉴스(1080×1350, 4:5) 4~5장을 만들고 PNG로 내려받는 웹앱입니다. 생성 결과는 브라우저의 localStorage에 최대 20건까지 보관합니다 (src/lib/cardNewsStore.ts). 결과마다 id가 있어 /preview?id=... 주소로 다시 열 수 있고, 새로고침하거나 브라우저를 껐다 켜도 남습니다. 서버에 저장하지는 않습니다. 그래서 다른 기기나 다른 사람과는 공유되지 않습니다. 링크 공유가 필요해지면 데이터베이스가 필요합니다. 규칙의 원본은 .claude/skills/mice-cardnews/SKILL.md입니다. 코드를 고칠 때는 그 문서를 먼저 읽으세요. 아래는 각 규칙이 실제로 어디에 구현돼 있는지입니다. SKILL.md는 태그로 줄을 끊으라고 하지만, 여기서는 줄 배열을 씁니다. 효과는 같으면서 HTML을 문자열로 주입하지 않아도 되기 때문입니다.查看完整 README ↗
키워드나 기사 URL을 넣으면 인스타그램 카드뉴스(1080×1350, 4:5) 5~7장을 만들고 PNG로 내려받는 웹앱입니다.
홈(/) → 로딩(/loading) → 미리보기(/preview)
↓
① POST /api/research 기사 본문 읽기 (못 읽거나 키워드뿐이면 Gemini 검색으로 자료 수집)
↓ 자료(subject·material·evidence)를 화면이 받아서 그대로 넘김
② POST /api/generate 자료로 카드뉴스 생성 (검색 없음) → 카드 데이터
요청을 둘로 나눈 것은 시간 제한 때문입니다. 한 요청에서 검색과 생성을 다 하면 합이 서버 예산(55초)을 넘었습니다. 요청마다 예산이 따로라, 나누면 단계마다 온전히 씁니다.
생성 결과는 브라우저의 localStorage에 최대 20건까지 보관합니다
(src/lib/cardNewsStore.ts). 결과마다 id가 있어 /preview?id=... 주소로
다시 열 수 있고, 새로고침하거나 브라우저를 껐다 켜도 남습니다.
서버에 저장하지는 않습니다. 그래서 다른 기기나 다른 사람과는 공유되지 않습니다. 링크 공유가 필요해지면 데이터베이스가 필요합니다.
규칙의 원본은 .claude/skills/mice-cardnews/SKILL.md입니다. 코드를 고칠 때는
그 문서를 먼저 읽으세요. 아래는 각 규칙이 실제로 어디에 구현돼 있는지입니다.
| 규칙 (SKILL.md) | 구현 위치 |
|---|---|
| 원문 베끼지 않고 패러프레이즈 | api/generate/route.ts 프롬프트 [문장 규칙] |
| 직접인용 15단어 미만, 세트당 1회 | 〃 |
| 관계자 코멘트는 요약체로 | 〃 |
| 카드 5~7장 (표지 + 상세 + 마무리) | 프롬프트 [구성], cardnews.ts의 MIN_SLIDES/GENERATED_MAX_SLIDES, 벗어나면 generate/route.ts가 재시도 |
| 보도용·유료 스톡 이미지 사용 금지 | 사진을 아예 쓰지 않음. CardNewsCard.tsx가 그라데이션으로 대체 |
| 캔버스 1080 × 1350 (4:5) | cardnews.ts의 CARD_WIDTH/CARD_HEIGHT |
| 원티드산스 | public/fonts/, globals.css의 @font-face. 명조 템플릿은 마루 부리 (일부러 다르게: SKILL.md 는 원티드산스만 말한다) |
| 좌상단 카테고리 배지 | 각 템플릿 (src/components/templates/). DarkNeon은 알약 대신 네온색 글자 |
| 중앙 반투명 다크 카드 위 흰 텍스트 | GradientCenter.tsx (rgba(10,14,20,0.58)) |
| 세트 전체를 포인트 컬러 하나로 통일 | lib/templates/color.ts — 색상은 고정, 명도만 장별로 변화 |
| 표지는 작은 소제목 → 큰 헤드라인 | 각 템플릿의 isCover 분기 |
| 한글 줄바꿈을 직접 끊기 | 헤드라인·본문을 줄 배열로 받음 (CardSlide.heading, .body) |
| (규칙 밖) 낱말 강조 | *색만*과 ==형광펜== 두 가지. parseSpans가 조각으로 쪼갠다 |
| 캡션 3~5줄 + 출처 표기 | 프롬프트 JSON 구조의 caption, source |
| (규칙 밖) 상세 장 결론 상자 | CardSlide.note — 데이터와 결론을 눈으로 분리 |
저장 위치 YYYY-MM-DD-제목 | preview/page.tsx의 baseName (PNG/ZIP 파일명) |
SKILL.md는 <br> 태그로 줄을 끊으라고 하지만, 여기서는 줄 배열을 씁니다.
효과는 같으면서 HTML을 문자열로 주입하지 않아도 되기 때문입니다.
한 줄 안에서 일부만 다르게 칠할 수 있습니다. 세기가 다른 두 가지입니다.
| 표시 | 효과 | 얼마나 |
|---|---|---|
*이렇게* | 글자 색만 바뀝니다 | 슬라이드마다 1~2번 |
==이렇게== | 형광펜처럼 뒤에 색이 깔립니다 | 세트 전체에서 2~3번 |
cardnews.ts의 parseSpans가 조각(TextSpan)으로 쪼개고, 템플릿이
style에 따라 다르게 그립니다.
형광펜 위에 올릴 글자색은 readableOn이 바탕 밝기를 보고 고릅니다. 색을
열어 둔 이상 이것도 계산해야 합니다 — 노란 형광펜에는 검은 글자가, 분홍
형광펜에는 흰 글자가 읽힙니다.
모델에게 중첩 객체 배열을 받는 대신 문자열 안에 표시를 넣게 했습니다. 응답 형식이 문자열 배열 그대로라 이미 저장해 둔 결과도 그대로 읽힙니다. 모델 입장에서도 배열을 겹쳐 만드는 것보다 쉽습니다.
아직 구현하지 않은 규칙:
/my)만든 결과는 이 브라우저(localStorage)에만 최근 20건 저장됩니다(cardNewsStore.ts의 MAX_ENTRIES).
/my 에서 표지 썸네일로 모아 보고, 누르면 미리보기로 다시 열립니다. 입구는 메뉴(☰·상단), 홈 입력칸 아래
(결과가 있을 때만), 미리보기 "최근 생성"의 "전체 보기"입니다. 서버에 저장하지 않으므로 다른 기기에서는 안 보입니다.
미리보기에서 내용 고치기를 누르면 지금 보고 있는 장을 손볼 수 있습니다
(src/components/SlideEditor.tsx). 고치는 즉시 왼쪽 카드에 반영되고
브라우저에 저장됩니다.
| 할 수 있는 것 | 방법 |
|---|---|
| 문장 수정 | 칸에 그냥 고쳐 씁니다 |
| 줄바꿈 | 줄바꿈 하나가 카드의 한 줄입니다 |
| 강조 | 글자를 고르고 색 / 형광펜 버튼. 직접 *...*, ==...== 를 쳐도 됩니다 |
| 장 추가·삭제 | 칸 아래 버튼 |
사람과 모델이 같은 표기(*...*, ==...==)를 씁니다. 그래서 편집기가
따로 배울 것을 만들지 않습니다.
줄 수 상한을 넘기면 입력을 받지 않고 이유를 알려 줍니다. 상한은
normalizeCardNews 가 자르는 값과 같습니다.
생성은 5~7장이지만 사람이 최대 8장까지 늘릴 수 있습니다(MAX_SLIDES).
src/lib/templates/에 있습니다. 두 축으로 나눕니다.
color(단색·그라데이션)와 photo(사진 위 글씨)sans(원티드산스), serif(마루 부리), hand(개구 손글씨). 사진 · 자막은 장마다
도현·검은고딕도 씁니다(OFL).사진형은 글자에 테두리나 그늘을 넣는데, 그건 사진이 복잡해서 생긴 장치입니다. 같은 처리를 단색 배경에 쓰면 근거 없이 요란해지므로 섞지 않습니다.
템플릿은 10종입니다: 그라데이션 · 고딕, 사진 · 반투명 카드, 크림 바탕 · 굵은 고딕, 흰 바탕 · 사진 상자, 사진 · 자막, 검정 · 번호 정리, 손글씨 메모, 문서 페이지, 어두운 바탕 · 네온, 흰 바탕 · 강조색(이 둘은 서체 고딕·명조를 고름). 사진 템플릿도 늘 고를 수 있습니다(사진을 못 찾으면 그라데이션으로 그립니다).
어두운 바탕 · 네온은 헤드라인과 본문 크기를 숫자로 박지 않고
fitFontSize(src/lib/templates/fit.ts)로 계산합니다.
고정값으로는 성립하지 않기 때문입니다. 한 줄이 세 글자인 장과 열 글자인 장에 같은 크기를 주면 짧은 쪽은 혼자 작게 남고 긴 쪽은 넘칩니다. 가로로는 가장 긴 줄이, 세로로는 전체 줄이 들어가야 하므로 둘 중 빡빡한 쪽을 따릅니다.
그래서 프롬프트가 줄을 짧게 끊게 합니다(표지 한 줄 36자, 45줄).
줄이 짧아야 글자가 커지고 화면이 찹니다. 참고 자료를 재 보니 "텅 비어
보인다"의 원인은 글자 크기가 아니라 줄 수였습니다 — 자세한 것은
DEVLOG 2026-10-05 항목에 있습니다.
새 디자인을 넣으려면 src/components/templates/에 컴포넌트를 만들고
src/lib/templates/index.ts의 TEMPLATES 배열에 한 줄 추가하면 됩니다.
홈 화면의 디자인 선택기(src/components/TemplatePicker.tsx)는 이 배열을 읽어
실제 템플릿을 견본 표지로 작게 그립니다. 미리보기 그림 파일을 따로 만들
필요가 없고, 추가한 템플릿은 선택기에도 저절로 나옵니다. 사진 템플릿의
견본에는 public/sample-photo.svg 를 깝니다.
사용자가 보낸 참고 게시물(서울 신상 미술관, 서울썸머비치)을 옮긴 레이아웃입니다
(PhotoStory.tsx). 장마다 다른 사진을 꽉 채워 깔고,
강조색은 사람이 고릅니다(PHOTO_VARIANTS + 직접 고르기).
사진은 생성할 때 서버가 자동으로 찾습니다(src/lib/photo.ts). 모델이 장마다
다른 장면의 검색어(slides[].imageQuery)를 내고, findSlidePhotos가 이를
한꺼번에 찾아 장마다 겹치지 않게 나눠 줍니다. 장 검색어로 못 찾은 장은 세트
검색어의 후보에서 아직 안 쓴 사진을 받습니다. 그릴 때는 이 장에 올린 사진 →
이 장 사진 → 세트 사진(옛 결과) 순으로 씁니다(slidePhoto).
미리보기의 다른 사진 / 내 사진 올리기 / 올린 사진 지우기는 지금 보는 장에만 적용됩니다. 캡션 끝 사진 표기는 쓴 사진을 모두 적되, 픽사베이·언스플래쉬는 곳마다 작가를 한 줄로 묶고 위키미디어는 사진마다 원본 주소까지 한 줄입니다.
docs/template-references.md 의 "H. 추가 묶음" 1~4번에서 하나씩 만들었습니다.
| 템플릿 | 묶음 | 컴포넌트 | 핵심 |
|---|---|---|---|
| 흰 바탕 · 사진 상자 | 1번 제작 가이드 | PhotoFrame | 흰 바탕에 둥근 사진을 얹는다. 본문 장은 글보다 사진이 크다 |
| 사진 · 자막 | 3번 사진 자막 | PhotoCaption | 상자 없이 자막처럼. 장마다 글씨 모양 7가지 중 사진 밝기에 맞는 것을 자동으로(아래) |
| 손글씨 메모 | 2번 손글씨·필기 | HandNote | 종이색 바탕, 개구 손글씨, 형광펜·빨간 펜·손으로 그은 밑줄 |
| 문서 페이지 | 4번 노션 스타일 | DocPage | 흰 문서 한 쪽. 굵은 제목, 구분선, 체크한 주제 |
| 검정 · 번호 정리 | 2026-10-30 "8가지 프롬프트" 게시물 | DarkNumbered | 표지는 사진 아래 큰 제목. 상세는 큰 번호 "01." + 색 점, 제목·부제, 둥근 글 상자(형광펜은 회색 칩). 사진은 표지에만(coverPhotoOnly) |
background: "photo" 라 장마다 다른 사진과 사진 패널(다른 사진·올리기)이 그대로 붙습니다.Hand Fallback 이 채웁니다.
나눔은 OFL 예약 이름이 있어 떼어 낸 판의 이름을 바꿨습니다.src/lib/templates/captionLooks.ts): 굵은 고딕·기울임, 좁고 높은 고딕(도현),
외곽선 + 흰 띠, 명조 색, 개큰글씨 스티커(검은고딕), 색 고딕, 검정 띠 자막.src/lib/photoTone.ts) 그 자리에서 읽히는 모양만 고르고, 세트 전체에서
이웃한 장이 겹치지 않게 정합니다(assignLooks, 미리보기에서). 미리보기에서 장마다 바꾸거나
모든 장을 한 모양으로 맞출 수 있습니다.copy: "caption").홈에서 뉴스형·정보형·홍보형·감성형(반말 구어체)을 고릅니다(src/lib/tone.ts). 생성 프롬프트의 문장 규칙과
마무리 장 규칙만 바뀌고, 사실 규칙(자료에 없는 사실·과장 금지)은 같습니다.
사용자가 보낸 참고 게시물("인스타 카드뉴스 성공하는 방법 vs 실패하는 방법")에서
가져왔습니다(CreamBold.tsx). 크림 바탕에 아주 굵은 검정 제목, 핵심어만
강조색(기본 빨강, CREAM_VARIANTS + 직접 고르기). 장의 주제는 가는 가로줄 위에
얹힌 회색 알약, 표지 아래에는 "옆으로 넘겨보세요 →". 참고 게시물의 그림(휴대폰
화면, ✅❌ 이모지)은 그 작가가 그린 것이라 옮기지 않았습니다.
찾는 곳과 순서: 픽사베이 → 언스플래쉬 → 위키미디어 공용. 앞의 두 곳은 API 키가 있을 때만 씁니다(아래). 키가 없으면 위키미디어만 씁니다.
| 곳 | 라이선스 | 카드·캡션 표기 |
|---|---|---|
| 픽사베이 | Pixabay Content License (상업적 사용 가능) | 사진 · 작가 · Pixabay |
| 언스플래쉬 | Unsplash License (상업적 사용 가능) | 사진 · 작가 · Unsplash (API 규정상 의무) |
| 위키미디어 | CC0·PD·CC BY·CC BY-SA 만 | 사진 · 작가 · 라이선스 · Wikimedia Commons |
| 직접 올린 사진 | 올린 사람이 확인 | 붙이지 않음 |
imageQuery). 행사장 그 자체가 아니어도 되고
장면("conference audience")·소재("semiconductor wafer")도 됩니다. 실존 인물·
브랜드 이름은 금지./api/photo 를 거쳐 받습니다(PNG 로 구울 때 같은 출처여야 함).
픽사베이는 사진 주소가 한동안만 살아 있어 사진 번호만 저장하고 그릴 때마다
새 주소를 받습니다(/api/photo?pixabay=번호).COMMONS_API_URL, PIXABAY_API_URL, UNSPLASH_API_URL, PHOTO_HOSTS
로 가짜 서버를 붙일 수 있습니다.| 변수 이름 | 받는 곳 |
|---|---|
PIXABAY_API_KEY | pixabay.com 가입 → https://pixabay.com/api/docs/ 에 로그인한 채로 들어가면 "key" 에 바로 나옴 |
UNSPLASH_ACCESS_KEY | https://unsplash.com/developers → Your apps → New Application → 약관 동의 → 앱 이름 입력 → 만들어진 앱의 "Access Key" (Secret Key 아님) |
Vercel: 프로젝트 → Settings → Environment Variables 에 위 이름으로 넣고(Production),
Deployments 에서 Redeploy 해야 반영됩니다. 들어갔는지는 /api/photo-check 로 확인합니다
(키 값은 보여 주지 않고, 있는지·시험 검색 상태만 알려 줍니다). 언스플래쉬는 처음에 시간당 50회
제한(Demo)이고, 앱 페이지에서 Production 신청을 하면 늘어납니다.
홈에서 고르거나 미리보기에서 바꾼 디자인(템플릿·색 조합·정렬)은
localStorage의 cardNewsDesign에 남고, 다음 생성의 기본값이 됩니다.
결과마다 붙는 선택(CardNewsEntry.templateId 등)과는 따로입니다. 결과의
선택은 그 결과를 다시 열 때 쓰고, 이것은 새로 만들 때 씁니다.
다른 곳은 건드릴 필요가 없습니다. 배열의 첫 항목이 기본값입니다.
고른 템플릿은 결과와 함께 보관되므로(CardNewsEntry.templateId), 다른 결과를
보다가 돌아와도 유지됩니다.
레이아웃은 같고 색만 다른 것은 새 템플릿이 아니라 색 조합입니다. 그렇게 나누지 않으면 템플릿 목록이 금세 색 견본으로 뒤덮입니다.
어두운 바탕 · 네온 템플릿이 이 구조를 씁니다. 거의 검정인 바탕 위에 형광
한 색만 올리는 디자인이라, 색 한 쌍을 바꾸면 인상이 완전히 달라집니다.
새 조합을 넣는 일은 src/lib/templates/variants.ts에 한 줄 더하는 것이
전부입니다.
{ id: "black-lime", name: "검정 · 형광 그린", base: "#0A0C0A", neon: "#B8FF2E" },
컴포넌트는 base와 neon 두 값만 보고 나머지를 계산합니다. 글자색은
inkFor(base)가 바탕 밝기를 보고 뒤집으므로, 밝은 바탕도 그대로 됩니다.
그래서 흰 바탕 · 강조색은 같은 컴포넌트(DarkNeon)에 흰 바탕 색 조합
(LIGHT_VARIANTS)만 붙인 템플릿입니다.
두 템플릿 모두 색 조합 견본 외에 강조색을 직접 고를 수 있습니다(홈과
미리보기의 AccentPicker). 바탕은 고른 견본의 바탕 그대로이고 강조색만
바뀝니다. 직접 고른 색은 템플릿별로 따로 기억합니다(customAccents).
검정 위에서 고른 형광 연두가 흰 바탕으로 넘어오면 안 읽히기 때문입니다.
사용자가 바탕에서 안 읽히는 색(흰 바탕에 노랑 등)을 고르면, 글자로 쓰는
강조색만 legibleOn이 대비 3:1이 될 때까지 바탕 반대쪽으로 옮깁니다.
형광펜 바탕·바·네모는 고른 색 그대로 둡니다. 형광펜 위 글자색은
readableOn이 고릅니다.
네온·흰 바탕 계열은 정렬과 서체도 고를 수 있습니다(Template.alignable).
홈의 디자인 선택기와 미리보기 양쪽에서 고릅니다. 기본은
좌측이고, 짧고 단정적인 세트에서 중앙으로 바꿀 수 있습니다.
정렬은 장식이 아니라 기능입니다. 중앙 정렬은 줄마다 왼쪽 끝이 움직여서 서너 줄만 넘어가도 눈이 돌아올 지점을 잃습니다. 그래서 본문이 길면 좌측입니다.
우측 정렬은 넣지 않았습니다. 한글에서 우측 정렬은 너무 센 제스처라 본문에 쓰면 읽기가 확 나빠집니다. 필요해지면 한 줄짜리 전용으로 열면 됩니다.
모든 템플릿에 열지 않았습니다. GradientCenter는 중앙이 그 디자인의 정체성이라
바꾸면 다른 템플릿이 되어 버립니다.
네온과 흰 바탕 · 강조색은 서체를 고를 수 있습니다(Template.serifable). 정렬처럼 홈과 미리보기에서
고르고, 결과마다 저장합니다(CardNewsEntry.font). 같은 컴포넌트(DarkNeon)에 font 만 넘깁니다.
예전에는 명조가 별도 템플릿(… · 명조)이었습니다(2026-10-30 옵션으로 바꿈). 그 id 와 단색 · 명조
(SolidSerif, 2026-10-15 뺌)로 저장된 결과는 같은 배치 + 명조로 열립니다(templates/index.ts 의 RETIRED, fontFor).
이 템플릿만 기사에서 뽑은 accent를 쓰지 않습니다. 바탕이 고정이고 네온 한
색이 혼자 튀는 것이 전부라, 기사마다 색이 달라지면 인상이 무너지기 때문입니다.
색은 사람이 고르고, 고른 값은 결과에 함께 보관됩니다(CardNewsEntry.variantId).
앞으로 만들 디자인의 참고 자료와, 그것을 넣기 전에 풀어야 할 구조적 문제는
docs/template-references.md에 정리해 두었습니다.
카드가 아니라 사이트 화면(홈·미리보기·안내 페이지)의 규칙입니다(2026-10-16 사용자 결정).
tailwind.config.js 의 primary(#5b9bff)와 globals.css 의
.cta-gradient(#3b82f6 → #1d4ed8). 청록·인스타그램식 주황분홍은 쓰지 않습니다.
빨강(오류)만 예외입니다.components/SampleCards.tsx).app/opengraph-image.png(1200×630 고정 파일)입니다./about, /guide, /privacy, /terms (src/components/InfoPage.tsx 틀).
개인정보처리방침은 코드가 실제로 다루는 정보만 적었다. 다루는 정보가 바뀌면 함께 고친다.| 환경변수 | 내용 | 없으면 |
|---|---|---|
NEXT_PUBLIC_ADSENSE_CLIENT | 애드센스 게시자 번호 ca-pub-… | 광고 스크립트·광고 칸·/ads.txt·소유 확인 태그·방침의 광고 항목 모두 없음 |
NEXT_PUBLIC_ADSENSE_SLOT_LOADING | 생성 대기 화면 광고 단위 번호 | 그 칸 없음 |
NEXT_PUBLIC_ADSENSE_SLOT_RESULT | 결과 화면 맨 아래 광고 단위 번호 | 그 칸 없음 |
NEXT_PUBLIC_CONTACT_EMAIL | 문의 이메일 | "문의처 준비 중" |
/feedback)익명 공개 게시판. 글은 이 저장소의 GitHub 이슈(feedback 표시)로 저장됩니다
(src/lib/feedback.ts, src/app/api/feedback/route.ts).
FEEDBACK_GITHUB_TOKEN: 이 저장소만 Issues 읽기·쓰기 권한이 있는 fine-grained 토큰.
없으면 게시판은 "준비 중". FEEDBACK_REPO(기본 choiyh040-bit/newsletter)로 다른 저장소를 쓸 수 있습니다.npm install
cp .env.example .env.local # GEMINI_API_KEY 채우기
npm run dev # http://localhost:3000
GEMINI_API_KEY는 Google AI Studio에서
발급받습니다. 없으면 화면은 뜨지만 생성 요청이 500으로 실패합니다.
기사 URL을 줘서 본문을 읽어오는 데 성공하면 검색 도구를 붙이지 않습니다. 근거가 이미 손에 있고, 검색 도구는 일반 생성과 할당량이 따로라 붙이는 것만으로 429가 날 수 있기 때문입니다.
키워드만 준 경우에는 검색을 씁니다. 근거가 없는데 검색까지 빼면 모델이
없는 숫자를 지어냅니다(실제로 확인했습니다). 다만 검색은 /api/research
에서만 합니다. 사실만 평문으로 모으고, 그 자료를 기사 본문처럼 넘겨
/api/generate가 검색 없이 카드뉴스를 짓습니다.
검색 결과에 검색 근거 표시(groundingMetadata)가 없어도 버리지 않습니다.
lite 모델은 검색을 하고도 표시를 비워 보낼 때가 많습니다. 서버 로그에
검색 근거 표시 없음으로만 남깁니다.
즉 기사 본문을 읽어오는 성공률이 곧 기능의 가용성입니다. 그래서
readArticle은 평범한 브라우저 User-Agent로 요청합니다. 언론사 상당수가
봇처럼 보이는 요청을 막기 때문입니다.
출처 주소는 모델이 적은 것을 쓰지 않고 서버가 아는 주소를 붙입니다
(generate/route.ts의 withSources). 모델은 주소를 지어냅니다.
groundingChunks)의 주소. 구글이 주는 것은
vertexaisearch.cloud.google.com 경유 주소라 한동안만 살아 있으므로, 서버가
따라가서 실제 기사 주소로 바꿉니다(research/route.ts의 resolveLinks).
못 바꾸면 뺍니다.미리보기의 출처 칸에서 출처: 매체명 주소 꼴로 복사합니다.
src/lib/gemini.ts가 실행할 때 models.list()로 목록을 받아
쓸 만한 것을 골라 차례로 시도합니다. 이름을 박아 두면 낡습니다 — 실제로
단종(404)과 용량(503)으로 두 번 낡았습니다.
고르는 규칙:
generateContent를 지원한다고 밝힌 것만image·tts·transcribe·audio·lyria 같은 게 들어가면 제외
(목록에는 음악·이미지·음성 모델이 섞여 있습니다)gemini.ts의 Tier)flash 를 빼고 lite + LOW 로 고정한 것은 시간 때문입니다. flash(3.8)는 생성 한 번에 24~30초였고 대부분이 생각하는 시간이었습니다. lite + LOW 는 검색 2초, 생성 3초 안팎입니다. lite 가 전부 막히는 일이 로그에 실제로 보이면 그때 flash 를 맨 뒤에 붙입니다.
넘어가는 실패: 503·429·404, 시도 상한(32초)에 걸린 무응답, 생각 수준 설정을 거부한 400, 빈 검색 결과.
어느 모델이 응답했는지는 서버 로그([gemini] 검색|생성 성공 · 모델명 (걸린 시간))에
남습니다.
실패 화면이 원인을 세 갈래로 나눠 알려줍니다.
| 화면 문구 | 원인 | 할 일 |
|---|---|---|
| AI 모델 설정을 고쳐야 합니다 | 모델 단종(NOT_FOUND) | MODEL 값을 바꿉니다. 다시 시도해도 소용없습니다 |
| 사용량 한도 안내 | 쿼터 초과 | 할당량을 확인합니다 |
| 서버 혼잡 안내 | 일시적 UNAVAILABLE | 잠시 후 다시 시도합니다 |
주의할 점 두 가지입니다.
ListModels에는 보이는데
generateContent는 404를 주는 경우가 있습니다. 호출해 봐야 압니다.googleSearch)는 쿼터가 따로입니다. 맨 요청은 되는데 카드뉴스
생성만 429가 날 수 있습니다.GEMINI_API_KEY를 추가합니다.
Production / Preview / Development 세 곳 모두 체크하세요.환경변수를 나중에 추가했다면 재배포해야 반영됩니다. 기존 배포에는 적용되지 않습니다.
저장소의 vercel.json이 "framework": "nextjs"를 지정합니다. 이 값은 Vercel
대시보드의 Framework Preset 설정보다 우선합니다.
이 파일이 없고 대시보드 설정이 Other이면, Vercel은 next build를 돌려
빌드는 성공시키지만 그 결과물 대신 public/ 폴더를 정적 사이트로 서빙합니다.
public/에는 index.html이 없으므로 모든 경로가 NOT_FOUND로 404가 됩니다.
빌드가 성공했는데 사이트가 404인 증상이면 이 설정부터 확인하세요.
/api/research와 /api/generate는 각각 maxDuration을 60초로 잡아두었고,
그 안에서 55초 예산으로 먼저 끝냅니다(src/lib/gemini.ts의 BUDGET_MS).
Vercel 요금제별 함수 실행 시간 상한을 넘으면 이 값과 무관하게 잘리므로,
타임아웃이 나면 요금제 상한부터 확인하세요.
카드에 쓰는 원티드산스(고딕)와
마루 부리(명조, 네이버)를
public/fonts/에 함께 넣어 두었습니다. 둘 다 OFL-1.1이고 라이선스 원문은
public/fonts/OFL.txt(원티드산스), public/fonts/OFL-MaruBuri.txt에 있습니다.
마루 부리는 원본 TTF를 글자를 빼지 않고 WOFF2로만 바꿨고, 굵기 셋
(400·600·700, 각 약 440KB)만 넣었습니다.
서체를 바꿀 때는 라이선스부터 봅니다. 웹사이트에 올리는 것은 서체 파일을 남에게 내려보내는 일(재배포·임베딩)이고, WOFF2로 바꾸는 것은 형식 변환입니다. 둘 다 막아 둔 서체가 많습니다. 열린명조가 그래서 후보에서 빠졌습니다.
한글 한 자의 폭이 서체마다 다릅니다(원티드산스 0.86em, 마루 부리 0.97em).
글자 크기 계산(fitFontSize)은 이 폭으로 가장 긴 줄을 어림하므로, 서체를
바꾸면 charWidth도 맞춰야 합니다. 안 맞추면 긴 줄이 칸을 넘어 쪼개집니다.
CDN에서 불러오지 않고 저장소에 두는 이유는 PNG로 내보낼 때입니다. 카드를 이미지로 구우려면 폰트를 파일 안에 심어야 하는데, 다른 출처에서 받은 폰트는 CORS 때문에 글자가 깨질 수 있습니다.
| 경로 | 하는 일 |
|---|---|
src/app/api/research/route.ts | 기사 읽기, 못 읽으면 검색으로 자료 수집 |
src/app/api/generate/route.ts | 자료로 카드뉴스 생성 + 형식 검증/재시도 |
src/lib/gemini.ts | 모델 고르기(lite·LOW), 폴백, 시간 예산 |
src/lib/cardnews.ts | 카드 규격, 데이터 타입, 모델 응답 정리 |
src/components/templates/ | 카드 템플릿별 렌더링 (1080×1350) |
src/lib/templates/ | 템플릿 목록과 배경·글꼴 축 정의 |
src/lib/templates/variants.ts | 색 조합 목록. 새 색은 여기 한 줄 |
docs/template-references.md | 디자인 참고 자료와 미해결 과제 |
src/lib/exportCards.ts | 카드 DOM을 PNG/ZIP으로 내보내기 |
src/app/preview/page.tsx | 미리보기 화면과 내보내기 버튼 |