sns-auto-builder 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ """
2
+ insta-carousel-builder — 나노바나나 Pro(Gemini 3.0 Pro Image) 인스타 캐러셀 생성기
3
+
4
+ 사용법:
5
+ # 1. .env 에 GEMINI_API_KEY=... 추가 (.env.example 참고)
6
+ # 2. slides.json 에 9장 프롬프트 수정 (또는 기본 예제 그대로 사용)
7
+ # 3. 실행:
8
+ python scripts/nanobanana-gen.py --topic my-topic
9
+
10
+ 결과:
11
+ output/{topic}/slide-01.png ~ slide-09.png
12
+
13
+ 참고:
14
+ - 모델: gemini-3-pro-image-preview (별칭 nano-banana-pro-preview)
15
+ - 한글 정확도 실측: 97.8% (에이나우 2026-04-15 9장 테스트 기준)
16
+ - 1장당 약 25초 / 9장 약 4분 / 비용 약 500~1000원
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import base64
22
+ import json
23
+ import os
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+
28
+ try:
29
+ from dotenv import load_dotenv
30
+ from google import genai
31
+ from google.genai import types
32
+ except ImportError as e:
33
+ print(f"[ERROR] 의존성 누락: {e}")
34
+ print(" pip install python-dotenv google-genai")
35
+ sys.exit(1)
36
+
37
+
38
+ REPO_ROOT = Path(__file__).resolve().parent.parent
39
+ DEFAULT_SLIDES_JSON = REPO_ROOT / "templates" / "slides.example.json"
40
+
41
+
42
+ def load_slides(slides_path: Path) -> list[dict]:
43
+ if not slides_path.exists():
44
+ print(f"[ERROR] slides 파일 없음: {slides_path}")
45
+ print(f" templates/slides.example.json 을 복사해서 수정하세요.")
46
+ sys.exit(1)
47
+ with slides_path.open("r", encoding="utf-8") as f:
48
+ data = json.load(f)
49
+ slides = data.get("slides", [])
50
+ if len(slides) != 9:
51
+ print(f"[WARN] 슬라이드 개수 {len(slides)} (권장: 9장 — 인스타 캐러셀 최대)")
52
+ return slides
53
+
54
+
55
+ def render_prompt(slide: dict, common_style: str) -> str:
56
+ return f"{common_style}\n\n{slide['prompt']}"
57
+
58
+
59
+ def main():
60
+ ap = argparse.ArgumentParser(description="나노바나나 Pro 인스타 캐러셀 생성기")
61
+ ap.add_argument("--topic", default="default", help="출력 폴더명. output/{topic}/ 에 저장")
62
+ ap.add_argument("--slides", default=str(DEFAULT_SLIDES_JSON), help="slides.json 경로")
63
+ ap.add_argument("--model", default="gemini-3-pro-image-preview", help="Gemini 이미지 모델 ID")
64
+ ap.add_argument("--dry-run", action="store_true", help="API 호출 없이 프롬프트만 출력")
65
+ args = ap.parse_args()
66
+
67
+ try:
68
+ sys.stdout.reconfigure(encoding="utf-8")
69
+ except Exception:
70
+ pass
71
+
72
+ load_dotenv(REPO_ROOT / ".env")
73
+ api_key = os.getenv("GEMINI_API_KEY")
74
+ if not api_key and not args.dry_run:
75
+ print("[ERROR] GEMINI_API_KEY not found in .env or environment.")
76
+ print(" .env.example 을 복사해서 .env 를 만들고 키를 추가하세요.")
77
+ print(" 키 발급: https://aistudio.google.com/apikey")
78
+ sys.exit(1)
79
+
80
+ slides_path = Path(args.slides)
81
+ data = json.loads(slides_path.read_text(encoding="utf-8"))
82
+ common_style = data.get("common_style", "")
83
+ slides = data.get("slides", [])
84
+
85
+ out_dir = REPO_ROOT / "output" / args.topic
86
+ out_dir.mkdir(parents=True, exist_ok=True)
87
+
88
+ if args.dry_run:
89
+ print(f"[DRY RUN] {len(slides)} slides, output: {out_dir}")
90
+ for slide in slides:
91
+ print(f"\n--- slide-{slide['n']:02d} ({slide.get('role','?')}) ---")
92
+ print(render_prompt(slide, common_style)[:500])
93
+ return
94
+
95
+ client = genai.Client(api_key=api_key)
96
+ summary = []
97
+
98
+ for slide in slides:
99
+ n = slide["n"]
100
+ out_path = out_dir / f"slide-{n:02d}.png"
101
+ prompt = render_prompt(slide, common_style)
102
+
103
+ print(f"[{n}/{len(slides)}] generating {slide.get('role','?')}...", end=" ", flush=True)
104
+ t0 = time.time()
105
+ try:
106
+ resp = client.models.generate_content(model=args.model, contents=[prompt])
107
+ image_saved = False
108
+ for part in resp.candidates[0].content.parts:
109
+ if getattr(part, "inline_data", None) and part.inline_data.data:
110
+ blob = part.inline_data.data
111
+ if isinstance(blob, str):
112
+ blob = base64.b64decode(blob)
113
+ with open(out_path, "wb") as f:
114
+ f.write(blob)
115
+ image_saved = True
116
+ break
117
+ dt = time.time() - t0
118
+ if image_saved:
119
+ kb = out_path.stat().st_size / 1024
120
+ print(f"OK ({dt:.1f}s, {kb:.0f}KB) -> {out_path.name}")
121
+ summary.append((n, "OK", f"{dt:.1f}s", f"{kb:.0f}KB"))
122
+ else:
123
+ print(f"FAIL ({dt:.1f}s, no image in response)")
124
+ summary.append((n, "FAIL_NO_IMG", f"{dt:.1f}s", "-"))
125
+ except Exception as e:
126
+ dt = time.time() - t0
127
+ print(f"ERR ({dt:.1f}s): {e}")
128
+ summary.append((n, "ERR", f"{dt:.1f}s", str(e)[:60]))
129
+
130
+ print("\n=== SUMMARY ===")
131
+ for n, status, dt, info in summary:
132
+ print(f" slide-{n:02d} {status:12} {dt:>7} {info}")
133
+ print(f"\nOutput: {out_dir}")
134
+
135
+
136
+ if __name__ == "__main__":
137
+ main()
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sns-auto-builder — OpenAI 이미지 생성기 (배경 / 슬라이드 전체)
4
+ *
5
+ * 원본 레포에 없는 추가분입니다. `scripts/nanobanana-gen.py` 는 손대지 않았습니다.
6
+ * 그 스크립트의 CLI/JSON 스키마를 미러링한 형제입니다.
7
+ * (Python 이 이 환경에 없어 Node 로 작성. 의존성은 캡처용 puppeteer 하나뿐 — 이미 설치돼 있음.)
8
+ *
9
+ * ── 두 가지 모드 ──────────────────────────────────────
10
+ * --mode bg (기본) 글자 **없는 배경**만. 한글은 HTML 이 위에 얹는다 → 한글 100% 정확
11
+ * 출력: output/{topic}/bg/bg-NN.png (원본 비율 그대로, HTML 이 cover 로 채움)
12
+ *
13
+ * --mode slide 슬라이드 **완성본**. 한글이 이미지 안에 들어간다 → 비결정적, 육안 검수 필수
14
+ * 출력: output/{topic}/slide-NN.png (1080×1350 의 2배 = 2160×2700 로 크롭)
15
+ *
16
+ * ⚠️ slide 모드 경고
17
+ * AI 이미지 모델의 한글은 매번 달라진다. 원본 레포가 Gemini 로 실측한 값이 97.8%
18
+ * (9장당 1~2글자 오타)이고, OpenAI 의 한글 정확도는 그보다 낮을 수 있다.
19
+ * **생성 후 9장 전부 눈으로 확인해라.** 글자가 중요한 콘텐츠면 bg 모드 + HTML 을 써라.
20
+ *
21
+ * 해상도 처리
22
+ * OpenAI 는 4:5(1080×1350)를 직접 지원하지 않는다. 세로는 1024×1536(2:3)이 가장 가깝다.
23
+ * slide 모드는 받은 이미지를 puppeteer 로 1080×1350 캔버스에 cover 로 깔아 2x 캡처한다.
24
+ * → quality-check.js --dir 의 "1080×1350 정수배" 검사를 통과하고 HTML 트랙과 배율이 같아진다.
25
+ * 위아래가 잘리므로 프롬프트에서 중요한 요소를 가장자리로 몰지 마라.
26
+ *
27
+ * 사용법:
28
+ * node scripts/openai-image-gen.js --topic my-topic --slides templates/backgrounds.my-topic.json
29
+ * node scripts/openai-image-gen.js --topic my-topic --slides templates/slides.my-topic.json --mode slide
30
+ * node scripts/openai-image-gen.js --topic my-topic --dry-run
31
+ * node scripts/openai-image-gen.js --topic my-topic --only 3
32
+ */
33
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
34
+ import { join, resolve, dirname } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+
37
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
38
+ const API = 'https://api.openai.com/v1/images/generations';
39
+ const BASE_W = 1080, BASE_H = 1350, SCALE = 2; // html-carousel-gen.js 와 동일
40
+
41
+ // 배경에 글자가 들어가면 한글이 깨진다. 그게 bg 트랙을 쓰는 이유 자체를 무너뜨린다.
42
+ const NO_TEXT = 'Absolutely no text, no letters, no words, no numbers, no logos, no watermarks, '
43
+ + 'no UI elements anywhere in the image. Pure imagery only. '
44
+ + 'Leave the composition calm and uncluttered so headline text can be overlaid on top later.';
45
+
46
+ // slide 모드에서는 한글을 이미지가 직접 그린다. 정확도를 최대한 끌어올린다.
47
+ const KOREAN = 'Render every Korean character exactly as written, with correct jamo composition. '
48
+ + 'Do not invent, translate, substitute, or stylize any character. '
49
+ + 'Do not add any text that is not explicitly specified. '
50
+ + 'Vertical 4:5 composition; keep all text well inside the frame with generous margins, '
51
+ + 'because the top and bottom edges will be cropped.';
52
+
53
+ function parseArgs(argv) {
54
+ const args = {};
55
+ for (let i = 2; i < argv.length; i++) {
56
+ if (!argv[i].startsWith('--')) continue;
57
+ const key = argv[i].slice(2);
58
+ args[key] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
59
+ }
60
+ return args;
61
+ }
62
+
63
+ // dotenv 없이 .env 를 읽는다. 이미 설정된 환경변수는 덮어쓰지 않는다.
64
+ function loadEnv(file) {
65
+ if (!existsSync(file)) return;
66
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
67
+ const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
68
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
69
+ }
70
+ }
71
+
72
+ async function generateOne({ prompt, model, size, quality, apiKey }) {
73
+ const res = await fetch(API, {
74
+ method: 'POST',
75
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
76
+ body: JSON.stringify({ model, prompt, size, quality, n: 1 }),
77
+ });
78
+ const text = await res.text();
79
+ let body;
80
+ try { body = JSON.parse(text); } catch { throw new Error(`응답이 JSON 이 아님 (HTTP ${res.status}): ${text.slice(0, 200)}`); }
81
+ if (!res.ok) throw new Error(body?.error?.message || `HTTP ${res.status}`);
82
+ const item = body?.data?.[0];
83
+ if (item?.b64_json) return Buffer.from(item.b64_json, 'base64');
84
+ if (item?.url) return Buffer.from(await (await fetch(item.url)).arrayBuffer()); // url 로 오는 모델 대비
85
+ throw new Error('응답에 이미지가 없음');
86
+ }
87
+
88
+ // 워드마크 위치 → CSS. 브랜드 설정의 선택지와 문자열이 같아야 한다.
89
+ const LOGO_POS = {
90
+ '하단 중앙': 'bottom:4.5%;left:50%;transform:translateX(-50%)',
91
+ '하단 좌측': 'bottom:4.5%;left:8%',
92
+ '하단 우측': 'bottom:4.5%;right:8%',
93
+ '상단 좌측': 'top:5%;left:8%',
94
+ };
95
+
96
+ // OpenAI 비율(2:3)을 인스타 4:5 로 맞춘다. 새 의존성 없이 이미 있는 puppeteer 로 처리.
97
+ //
98
+ // logo 를 주면 크롭하면서 **진짜 로고를 얹는다.** 이미지 모델에게 로고를 그리게 하면
99
+ // 매번 뭉개지므로, 모델에겐 자리만 비우게 하고 원본 SVG 를 여기서 합성한다.
100
+ export async function cropTo45(buf, page, logo = null) {
101
+ const dataUrl = `data:image/png;base64,${buf.toString('base64')}`;
102
+ const pos = logo && LOGO_POS[logo.pos];
103
+ const mark = pos
104
+ ? `<img src="data:image/svg+xml;base64,${logo.svg.toString('base64')}"
105
+ style="position:absolute;${pos};height:3.4%;max-width:34%;object-fit:contain">`
106
+ : '';
107
+ await page.setContent(
108
+ `<body style="margin:0;width:${BASE_W}px;height:${BASE_H}px;overflow:hidden;background:#000;position:relative">
109
+ <img src="${dataUrl}" style="width:100%;height:100%;object-fit:cover;display:block">
110
+ ${mark}
111
+ </body>`,
112
+ { waitUntil: 'load' });
113
+ await page.evaluate(() => Promise.all(Array.from(document.images, (i) => i.decode().catch(() => {}))));
114
+ // 최신 puppeteer 는 Uint8Array 를 준다. 이후 처리를 위해 Buffer 로 맞춘다.
115
+ return Buffer.from(await page.screenshot({ type: "png", clip: { x: 0, y: 0, width: BASE_W, height: BASE_H } }));
116
+ }
117
+
118
+ async function main() {
119
+ const args = parseArgs(process.argv);
120
+ const topic = args.topic || 'default';
121
+ const mode = args.mode === 'slide' ? 'slide' : 'bg';
122
+ const model = args.model || 'gpt-image-2'; // --model 로 교체 가능 (gpt-image-1, -1-mini, -1.5 등)
123
+ const size = args.size || '1024x1536';
124
+ const quality = args.quality || 'high';
125
+ const dryRun = !!args['dry-run'];
126
+
127
+ const slidesPath = args.slides
128
+ ? resolve(args.slides)
129
+ : join(REPO_ROOT, 'templates', mode === 'slide' ? 'slides.example.json' : 'backgrounds.example.json');
130
+
131
+ if (!existsSync(slidesPath)) {
132
+ console.error(`[ERROR] 프롬프트 파일 없음: ${slidesPath}`);
133
+ console.error(' templates/ 의 예제를 복사해서 수정하세요.');
134
+ process.exit(1);
135
+ }
136
+
137
+ loadEnv(join(REPO_ROOT, '.env'));
138
+ const apiKey = process.env.OPENAI_API_KEY;
139
+ if (!apiKey && !dryRun) {
140
+ console.error('[ERROR] OPENAI_API_KEY 가 .env 또는 환경변수에 없습니다.');
141
+ console.error(' .env.example 을 복사해 .env 를 만들고 키를 넣으세요.');
142
+ console.error(' 키 발급: https://platform.openai.com/api-keys');
143
+ process.exit(1);
144
+ }
145
+
146
+ const data = JSON.parse(readFileSync(slidesPath, 'utf8'));
147
+ const commonStyle = data.common_style || '';
148
+ let slides = data.slides || [];
149
+ if (!slides.length) { console.error('[ERROR] slides 배열이 비어 있습니다.'); process.exit(1); }
150
+
151
+ if (args.only) {
152
+ const n = Number(args.only);
153
+ slides = slides.filter((s) => s.n === n);
154
+ if (!slides.length) { console.error(`[ERROR] n=${n} 슬라이드 없음`); process.exit(1); }
155
+ }
156
+
157
+ const outDir = mode === 'slide'
158
+ ? join(REPO_ROOT, 'output', topic)
159
+ : join(REPO_ROOT, 'output', topic, 'bg');
160
+ mkdirSync(outDir, { recursive: true });
161
+
162
+ const build = (s) => [commonStyle, s.prompt, mode === 'slide' ? KOREAN : NO_TEXT].filter(Boolean).join('\n\n');
163
+ const name = (n) => `${mode === 'slide' ? 'slide' : 'bg'}-${String(n).padStart(2, '0')}.png`;
164
+
165
+ if (dryRun) {
166
+ console.log(`[DRY RUN] mode=${mode} · ${slides.length}장 · 출력: ${outDir}`);
167
+ console.log(` model=${model} size=${size} quality=${quality}`);
168
+ if (mode === 'slide') console.log(` → 생성 후 ${BASE_W}×${BASE_H} 의 ${SCALE}배로 크롭\n`);
169
+ else console.log('');
170
+ for (const s of slides) {
171
+ console.log(`--- ${name(s.n)} (${s.role || '?'}) ---`);
172
+ console.log(build(s) + '\n'); // 자르지 않는다 — dry-run 은 가드까지 눈으로 확인하는 자리다
173
+ }
174
+ return;
175
+ }
176
+
177
+ console.log(`\nmode=${mode} · ${slides.length}장 생성 (${model} / ${size} / ${quality})`);
178
+ if (mode === 'slide') {
179
+ console.log('⚠️ 한글이 이미지 안에 들어갑니다. 매번 달라지므로 생성 후 9장 전부 눈으로 확인하세요.');
180
+ }
181
+ console.log('');
182
+
183
+ // slide 모드에서만 크롭용 브라우저를 띄운다.
184
+ let browser = null, page = null;
185
+ if (mode === 'slide') {
186
+ const { default: puppeteer } = await import('puppeteer');
187
+ browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] });
188
+ page = await browser.newPage();
189
+ await page.setViewport({ width: BASE_W, height: BASE_H, deviceScaleFactor: SCALE });
190
+ }
191
+
192
+ const summary = [];
193
+ try {
194
+ for (const s of slides) {
195
+ const outPath = join(outDir, name(s.n));
196
+ process.stdout.write(`[${s.n}/${slides.length}] ${s.role || '?'} ... `);
197
+ const t0 = Date.now();
198
+ try {
199
+ let buf = await generateOne({ prompt: build(s), model, size, quality, apiKey });
200
+ if (mode === 'slide') buf = await cropTo45(buf, page);
201
+ writeFileSync(outPath, buf);
202
+ const kb = Math.round(statSync(outPath).size / 1024);
203
+ const dt = ((Date.now() - t0) / 1000).toFixed(1);
204
+ console.log(`OK (${dt}s, ${kb}KB) -> ${name(s.n)}`);
205
+ summary.push([name(s.n), 'OK', `${dt}s`, `${kb}KB`]);
206
+ } catch (e) {
207
+ const dt = ((Date.now() - t0) / 1000).toFixed(1);
208
+ console.log(`ERR (${dt}s): ${e.message}`);
209
+ summary.push([name(s.n), 'ERR', `${dt}s`, e.message.slice(0, 60)]);
210
+ }
211
+ }
212
+ } finally {
213
+ await browser?.close();
214
+ }
215
+
216
+ console.log('\n=== SUMMARY ===');
217
+ summary.forEach(([f, st, dt, info]) => console.log(` ${f.padEnd(12)} ${st.padEnd(5)} ${dt.padStart(7)} ${info}`));
218
+ console.log(`\n출력: ${outDir}`);
219
+ if (mode === 'slide') {
220
+ console.log(`검사: node scripts/quality-check.js --dir output/${topic}`);
221
+ console.log('※ 한글 오타는 스크립트가 못 잡습니다. 반드시 눈으로 확인하세요.');
222
+ } else {
223
+ console.log('다음: carousel-html-writer 가 이 배경을 참조해 슬라이드 HTML 을 작성 →');
224
+ console.log(` node scripts/html-carousel-gen.js --topic ${topic}`);
225
+ }
226
+
227
+ if (summary.some(([, st]) => st === 'ERR')) process.exit(1);
228
+ }
229
+
230
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main().catch((e) => { console.error('ERROR:', e); process.exit(1); });
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 카피(글) 기계 검사 — 금칙어 / 글자 수 / 구조.
4
+ *
5
+ * 원본 레포에 없는 추가분입니다. 원본 `quality-check.js` 는 손대지 않았습니다.
6
+ * 원본은 이미지 파이프라인용(프롬프트 JSON 스키마 + PNG 해상도)이라
7
+ * 텍스트 카피에는 적용되지 않습니다. 이 파일이 그 자리를 텍스트용으로 채웁니다.
8
+ *
9
+ * 사용법:
10
+ * node scripts/quality-check-text.js --file output/<topic>/copy.md
11
+ */
12
+ import { readFileSync } from 'node:fs';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const BANNED = JSON.parse(readFileSync(new URL('../knowledge/banned-words.json', import.meta.url), 'utf8'));
16
+ const HEADLINE_MAX = 20; // carousel-structure.md: 헤드라인 한 줄
17
+ const THREAD_MAX = 500; // thread-structure.md: 글 하나
18
+
19
+ const words = () => Object.entries(BANNED)
20
+ .filter(([k]) => !k.startsWith('_'))
21
+ .flatMap(([group, list]) => list.map((w) => ({ group, w })));
22
+
23
+ // `##` 로 시작하는 줄이 조각 구분자 (knowledge/patterns/*.md 의 출력 형식).
24
+ export function parseBlocks(text) {
25
+ const out = [];
26
+ let cur = null;
27
+ text.split(/\r?\n/).forEach((line, i) => {
28
+ const m = /^##\s+(.*)$/.exec(line);
29
+ if (m) { cur = { title: m[1].trim(), line: i + 1, body: [] }; out.push(cur); }
30
+ else if (cur) cur.body.push(line);
31
+ });
32
+ return out.map((b) => ({ ...b, text: b.body.join('\n').trim() }));
33
+ }
34
+
35
+ export function detectMode(blocks) {
36
+ if (blocks.some((b) => /Cover/i.test(b.title))) return 'cardnews';
37
+ if (blocks.some((b) => /^\d+\s*\(훅\)/.test(b.title))) return 'thread';
38
+ return 'unknown';
39
+ }
40
+
41
+ export function check(text) {
42
+ const issues = [];
43
+ const add = (level, msg, line) => issues.push({ level, msg, line });
44
+ const lines = text.split(/\r?\n/);
45
+
46
+ for (const { group, w } of words()) {
47
+ lines.forEach((l, i) => { if (l.includes(w)) add('error', `금칙어(${group}): "${w}"`, i + 1); });
48
+ }
49
+
50
+ const blocks = parseBlocks(text);
51
+ const mode = detectMode(blocks);
52
+ if (!blocks.length) {
53
+ add('error', '## 로 시작하는 조각이 없습니다 — 출력 형식이 깨졌습니다', 1);
54
+ return { mode, blocks, issues };
55
+ }
56
+
57
+ if (mode === 'cardnews') {
58
+ const slides = blocks.filter((b) => /^\d{2}\s*·/.test(b.title));
59
+ if (slides.length !== 9) add('error', `슬라이드가 ${slides.length}장입니다 (9장이어야 함)`, 1);
60
+ if (!blocks.some((b) => b.title.includes('캡션'))) add('warn', '인스타 캡션 조각이 없습니다', 1);
61
+ for (const b of blocks) {
62
+ const idx = b.body.findIndex((l) => l.startsWith('헤드라인:'));
63
+ if (idx < 0) continue;
64
+ // `헤드라인:` 줄 + 이어지는 들여쓴 줄이 한 헤드라인
65
+ for (let i = idx; i < b.body.length; i++) {
66
+ if (i > idx && !/^\s{2,}\S/.test(b.body[i])) break;
67
+ const t = (i === idx ? b.body[i].replace(/^헤드라인:/, '') : b.body[i]).replace(/\*\*/g, '').trim();
68
+ if (t.length > HEADLINE_MAX) add('error', `헤드라인 ${t.length}자 (${HEADLINE_MAX}자 이내): "${t}"`, b.line + i + 1);
69
+ }
70
+ }
71
+ } else if (mode === 'thread') {
72
+ const posts = blocks.filter((b) => !/글자\s*수/.test(b.title));
73
+ if (posts.length < 3 || posts.length > 5) add('error', `글이 ${posts.length}개입니다 (3~5개여야 함)`, 1);
74
+ for (const b of posts) {
75
+ if (b.text.length > THREAD_MAX) add('error', `${b.title}: ${b.text.length}자 (${THREAD_MAX}자 이내)`, b.line);
76
+ }
77
+ } else {
78
+ add('warn', '모드를 판별하지 못했습니다 — 제목 형식을 확인하세요', 1);
79
+ }
80
+
81
+ return { mode, blocks, issues };
82
+ }
83
+
84
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
85
+ const i = process.argv.indexOf('--file');
86
+ if (i < 0 || !process.argv[i + 1]) {
87
+ console.error('사용: node scripts/quality-check-text.js --file <copy.md>');
88
+ process.exit(2);
89
+ }
90
+ const { mode, blocks, issues } = check(readFileSync(process.argv[i + 1], 'utf8'));
91
+ console.log(`모드: ${mode} · 조각 ${blocks.length}개`);
92
+ if (!issues.length) console.log('PASS — 위반 0건');
93
+ else issues.forEach((x) => console.log(`${x.level === 'error' ? 'FAIL' : 'WARN'} L${x.line} ${x.msg}`));
94
+ process.exit(issues.some((x) => x.level === 'error') ? 1 : 0);
95
+ }
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 인스타 캐러셀 품질 자동 검사.
4
+ *
5
+ * 2 모드:
6
+ * 1. JSON 검증: --prompt templates/slides.<topic>.json
7
+ * - 9장 구조, common_style 존재, n 1~9 순차, role/prompt 필수
8
+ * 2. PNG 검증: --dir output/<topic>
9
+ * - 9장 PNG 존재, 해상도 1080×1350 의 정수배(레티나 허용), 배율 통일, 파일 크기 분포
10
+ * (인스타는 업로드 시 가로 1080 으로 줄이므로 2x=2160×2700 도 정상)
11
+ *
12
+ * 사용법:
13
+ * node scripts/quality-check.js --prompt templates/slides.claude-code.json
14
+ * node scripts/quality-check.js --dir output/claude-code-tips
15
+ */
16
+ import { readFileSync, existsSync, readdirSync, statSync, writeFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ function parseArgs(argv) {
20
+ const args = {};
21
+ for (let i = 2; i < argv.length; i++) {
22
+ if (argv[i].startsWith('--') && argv[i + 1]) {
23
+ args[argv[i].slice(2)] = argv[++i];
24
+ }
25
+ }
26
+ return args;
27
+ }
28
+
29
+ function checkPromptJson(path) {
30
+ if (!existsSync(path)) {
31
+ return { status: 'FAIL', errors: [`파일 없음: ${path}`] };
32
+ }
33
+ let data;
34
+ try {
35
+ data = JSON.parse(readFileSync(path, 'utf-8'));
36
+ } catch (e) {
37
+ return { status: 'FAIL', errors: [`JSON 파싱 실패: ${e.message}`] };
38
+ }
39
+
40
+ const errors = [];
41
+ const warnings = [];
42
+
43
+ if (!data.common_style || data.common_style.length < 50) {
44
+ errors.push('common_style 누락 또는 너무 짧음');
45
+ }
46
+ if (!Array.isArray(data.slides)) {
47
+ errors.push('slides 배열 누락');
48
+ return { status: 'FAIL', errors };
49
+ }
50
+ if (data.slides.length !== EXPECT) {
51
+ warnings.push(`슬라이드 ${data.slides.length}장 (권장: ${EXPECT}장)`);
52
+ }
53
+
54
+ for (let i = 0; i < data.slides.length; i++) {
55
+ const s = data.slides[i];
56
+ const expectedN = i + 1;
57
+ if (s.n !== expectedN) errors.push(`slides[${i}].n 이 ${s.n} (기대: ${expectedN})`);
58
+ if (!s.role) errors.push(`slides[${i}].role 누락`);
59
+ if (!s.prompt || s.prompt.length < 50) errors.push(`slides[${i}].prompt 누락 또는 너무 짧음`);
60
+ }
61
+
62
+ const coverExists = data.slides.some((s) => /cover/i.test(s.role));
63
+ const outroExists = data.slides.some((s) => /outro|closing/i.test(s.role));
64
+ if (!coverExists) warnings.push('Cover 슬라이드 없음 (권장)');
65
+ if (!outroExists) warnings.push('Outro 슬라이드 없음 (권장)');
66
+
67
+ return {
68
+ status: errors.length > 0 ? 'FAIL' : warnings.length > 0 ? 'WARN' : 'PASS',
69
+ errors,
70
+ warnings,
71
+ slide_count: data.slides.length,
72
+ };
73
+ }
74
+
75
+ // 기대 장수. main() 에서 --count 또는 gen.json 으로 덮어쓴다.
76
+ let EXPECT = 9;
77
+
78
+ const BASE_W = 1080;
79
+ const BASE_H = 1350;
80
+
81
+ // PNG 헤더(IHDR)에서 폭·높이를 직접 읽는다. 의존성 없이 8~24바이트만 보면 된다.
82
+ function pngSize(fp) {
83
+ const b = readFileSync(fp);
84
+ const MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
85
+ if (b.length < 24 || !b.subarray(0, 8).equals(MAGIC)) return null; // PNG 아님/깨짐
86
+ return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
87
+ }
88
+
89
+ function checkPngDir(dir) {
90
+ if (!existsSync(dir)) {
91
+ return { status: 'FAIL', errors: [`폴더 없음: ${dir}`] };
92
+ }
93
+
94
+ const files = readdirSync(dir)
95
+ .filter((f) => /^slide-\d{2}\.png$/.test(f))
96
+ .sort();
97
+
98
+ const errors = [];
99
+ const warnings = [];
100
+ const sizes = [];
101
+
102
+ if (files.length !== EXPECT) {
103
+ errors.push(`slide-*.png ${files.length}장 (기대: ${EXPECT}장)`);
104
+ }
105
+
106
+ const expected = Array.from({ length: EXPECT }, (_, i) => `slide-${String(i + 1).padStart(2, '0')}.png`);
107
+ for (const name of expected) {
108
+ if (!files.includes(name)) errors.push(`${name} 누락`);
109
+ }
110
+
111
+ const scales = new Set();
112
+
113
+ for (const f of files) {
114
+ const fp = join(dir, f);
115
+ const st = statSync(fp);
116
+ const dim = pngSize(fp);
117
+
118
+ sizes.push({ file: f, kb: Math.round(st.size / 1024), w: dim?.w ?? null, h: dim?.h ?? null });
119
+
120
+ if (!dim) {
121
+ errors.push(`${f} PNG 헤더를 읽을 수 없음 (깨진 파일)`);
122
+ } else {
123
+ // 인스타는 업로드 시 가로 1080 으로 줄인다. 1080×1350 또는 그 정수배(레티나)만 허용.
124
+ const scale = dim.w / BASE_W;
125
+ if (!Number.isInteger(scale) || scale < 1 || dim.h !== BASE_H * scale) {
126
+ errors.push(`${f} 해상도 ${dim.w}×${dim.h} — ${BASE_W}×${BASE_H} 의 정수배여야 함`);
127
+ } else {
128
+ scales.add(scale);
129
+ }
130
+ }
131
+
132
+ if (st.size < 20 * 1024) warnings.push(`${f} 너무 작음 (${Math.round(st.size / 1024)}KB, 깨진 이미지 가능)`);
133
+ if (st.size > 3 * 1024 * 1024) warnings.push(`${f} 너무 큼 (${Math.round(st.size / 1024)}KB)`);
134
+ }
135
+
136
+ // 9장이 서로 다른 배율이면 캐러셀에서 크기가 튄다.
137
+ if (scales.size > 1) errors.push(`배율이 섞여 있음: ${[...scales].sort().map((s) => `${s}x`).join(', ')}`);
138
+
139
+ return {
140
+ status: errors.length > 0 ? 'FAIL' : warnings.length > 0 ? 'WARN' : 'PASS',
141
+ errors,
142
+ warnings,
143
+ slide_count: files.length,
144
+ scale: scales.size === 1 ? [...scales][0] : null,
145
+ sizes,
146
+ };
147
+ }
148
+
149
+ function icon(s) {
150
+ return s === 'PASS' ? '✅' : s === 'WARN' ? '⚠️ ' : '❌';
151
+ }
152
+
153
+ function main() {
154
+ const args = parseArgs(process.argv);
155
+ // 빌더에서 장수를 고를 수 있게 되면서 9장 고정이 깨졌다.
156
+ // --count 로 주거나, 없으면 결과 폴더의 gen.json 에 기록된 값을 쓴다.
157
+ if (args.count) EXPECT = Number(args.count);
158
+ else if (args.dir) {
159
+ try { EXPECT = JSON.parse(readFileSync(join(args.dir, 'gen.json'), 'utf8')).count || EXPECT; } catch { /* 없으면 기본값 */ }
160
+ }
161
+ console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
162
+ console.log('📊 캐러셀 품질 검사');
163
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
164
+
165
+ let result;
166
+ if (args.prompt) {
167
+ console.log(`[모드] 프롬프트 JSON 검증: ${args.prompt}`);
168
+ result = checkPromptJson(args.prompt);
169
+ } else if (args.dir) {
170
+ console.log(`[모드] PNG 폴더 검증: ${args.dir}`);
171
+ result = checkPngDir(args.dir);
172
+ } else {
173
+ console.error('❌ --prompt <json> 또는 --dir <folder> 중 하나 필수');
174
+ process.exit(1);
175
+ }
176
+
177
+ console.log(`\n${icon(result.status)} 상태: ${result.status}`);
178
+ console.log(` 슬라이드 수: ${result.slide_count ?? '?'} / ${EXPECT}`);
179
+
180
+ if (result.errors && result.errors.length > 0) {
181
+ console.log('\n❌ Errors:');
182
+ result.errors.forEach((e) => console.log(` - ${e}`));
183
+ }
184
+ if (result.warnings && result.warnings.length > 0) {
185
+ console.log('\n⚠️ Warnings:');
186
+ result.warnings.forEach((w) => console.log(` - ${w}`));
187
+ }
188
+ if (result.scale) console.log(` 배율: ${result.scale}x (${1080 * result.scale}×${1350 * result.scale})`);
189
+
190
+ if (result.sizes) {
191
+ console.log('\n📦 파일:');
192
+ result.sizes.forEach((s) => console.log(` ${s.file}: ${s.w ?? '?'}×${s.h ?? '?'}, ${s.kb} KB`));
193
+ }
194
+
195
+ const reportDir = args.dir || (args.prompt ? args.prompt.replace(/\.json$/, '') : '.');
196
+ const reportPath = args.dir ? join(args.dir, 'quality-report.json') : null;
197
+ if (reportPath) {
198
+ writeFileSync(reportPath, JSON.stringify({ ...result, checked_at: new Date().toISOString() }, null, 2));
199
+ console.log(`\n📄 리포트: ${reportPath}`);
200
+ }
201
+
202
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
203
+ process.exit(result.status === 'FAIL' ? 1 : 0);
204
+ }
205
+
206
+ main();