sns-auto-builder 1.0.0 → 1.0.1

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.
package/index.html CHANGED
@@ -194,6 +194,9 @@ button.go:focus-visible, button.quiet:focus-visible, .copy:focus-visible { outli
194
194
  border:1px solid var(--rule); border-radius:7px; background:transparent; color:var(--ink-2);
195
195
  }
196
196
  .out-row .acts a:hover, .out-row .acts button:hover { color:var(--accent); border-color:var(--accent); }
197
+ .refnow { font-size:13.5px; color:var(--ink-2); margin:-4px 0 12px; }
198
+ .linkbtn { font:inherit; font-size:13.5px; background:none; border:0; padding:0; cursor:pointer;
199
+ color:var(--accent); text-decoration:underline; }
197
200
  .outs .empty { color:var(--ink-2); font-size:14.5px; padding:8px 2px; }
198
201
 
199
202
  /* ── 참고자료 템플릿 ───────────────────────── */
@@ -350,6 +353,8 @@ button.go:focus-visible, button.quiet:focus-visible, .copy:focus-visible { outli
350
353
  </div>
351
354
  <div class="bar">
352
355
  <button class="quiet" id="brandOpen">⚙ 브랜드 컨셉 설정</button>
356
+ <a class="quiet dl" id="logDl" href="/logs" download
357
+ title="문제가 생기면 이 파일을 담당자에게 보내세요">🛟 문제 신고용 로그</a>
353
358
  <span class="fine" id="brandNow"></span>
354
359
  </div>
355
360
  </section>
@@ -447,6 +452,9 @@ button.go:focus-visible, button.quiet:focus-visible, .copy:focus-visible { outli
447
452
  </div>
448
453
 
449
454
  <label for="pick-file" id="refLabel">참고 자료 <span class="fine">선택 · 최대 6장</span></label>
455
+ <!-- 템플릿 선택은 화면을 옮겨도 유지된다. 안 보이면 "레퍼런스를 지웠다"고 착각하고
456
+ 이전 참고자료가 그대로 들어간 결과물을 받게 된다. 여기서 항상 보이게 한다. -->
457
+ <p class="refnow" id="refNow" hidden></p>
450
458
  <div class="drop" id="drop" tabindex="0" role="button"
451
459
  aria-describedby="refHint" aria-label="참고 이미지 추가. 끌어다 놓거나 붙여넣거나 눌러서 고르세요">
452
460
  <p class="fine" id="refHint">끌어다 놓거나, <kbd>⌘/Ctrl + V</kbd>로 붙여넣거나, 눌러서 고르세요</p>
@@ -470,7 +478,13 @@ button.go:focus-visible, button.quiet:focus-visible, .copy:focus-visible { outli
470
478
  </div>
471
479
  <ol class="steps" id="steps" hidden aria-label="진행 단계"></ol>
472
480
  <div class="live" id="live" aria-live="polite" aria-busy="true"></div>
473
- <div class="bar"><button class="quiet" id="stop">중단</button></div>
481
+ <div class="bar">
482
+ <button class="quiet" id="stop">중단</button>
483
+ <span id="runFail" hidden>
484
+ <button class="go" id="retry">다시 시도</button>
485
+ <button class="quiet" id="backToPlan">시나리오로</button>
486
+ </span>
487
+ </div>
474
488
  </section>
475
489
 
476
490
  <!-- 3.5 시나리오 확인 — 이미지에 시간·쿼터를 쓰기 전에 사람이 승인한다 -->
@@ -570,7 +584,7 @@ document.querySelectorAll('[data-engine]').forEach((b) => b.onclick = () => {
570
584
  engine = b.dataset.engine;
571
585
  toForm();
572
586
  });
573
- $('engineBack').onclick = () => show('pick');
587
+ $('engineBack').onclick = () => { refs = []; refSet = null; renderThumbs(); show('pick'); };
574
588
 
575
589
  function renderSteps(active, status) {
576
590
  const host = $('steps');
@@ -790,6 +804,30 @@ async function addFiles(list) {
790
804
  renderThumbs();
791
805
  }
792
806
 
807
+ // 지금 무엇이 참고자료로 들어가는지 한 줄로 못박아 보여준다.
808
+ function renderRefNow(sets) {
809
+ const box = $('refNow');
810
+ box.textContent = '';
811
+ if (refSet) {
812
+ const s = (sets || []).find((x) => x.id === refSet);
813
+ const n = s ? s.files.length : '';
814
+ box.append(document.createTextNode(`저장된 템플릿 ${n}장이 이번 작업에 들어갑니다 · `));
815
+ const off = document.createElement('button');
816
+ off.type = 'button';
817
+ off.className = 'linkbtn';
818
+ off.textContent = '쓰지 않기';
819
+ off.onclick = () => { refSet = null; loadTemplates(); };
820
+ box.append(off);
821
+ box.hidden = false;
822
+ } else if (refs.length) {
823
+ box.textContent = `올린 이미지 ${refs.length}장이 이번 작업에 들어갑니다`;
824
+ box.hidden = false;
825
+ } else {
826
+ box.textContent = '참고자료 없이 만듭니다';
827
+ box.hidden = false;
828
+ }
829
+ }
830
+
793
831
  function renderThumbs() {
794
832
  const host = $('thumbs');
795
833
  loadTemplates(); // 업로드/삭제로 refSet 이 풀렸을 수 있어 템플릿 강조를 다시 맞춘다
@@ -946,6 +984,7 @@ async function build() {
946
984
  if (!plan?.dir) return;
947
985
  raw = '';
948
986
  result = null;
987
+ $('runFail').hidden = true;
949
988
  $('live').textContent = '';
950
989
  $('steps').textContent = '';
951
990
  renderSteps();
@@ -968,9 +1007,12 @@ async function build() {
968
1007
  runReview(plan.dir); // 결과를 먼저 띄우고 채점은 뒤에서 — 기다리게 하지 않는다
969
1008
  } catch (e) {
970
1009
  if (e.name === 'AbortError') { renderPlan(plan); return; }
971
- $('err').textContent = e.message;
972
- $('err').hidden = false;
973
- show('form');
1010
+ // 실패했다고 주제 입력 화면으로 되돌리면 안 된다. 사용자 눈에는
1011
+ // "처음으로 돌아가서 새로 시작됐다" 로 보이고, 원인이 적힌 로그도 같이 사라진다.
1012
+ // 실행 화면에 남겨 로그를 보여주고, 시나리오로 돌아가 재시도할 수 있게 한다.
1013
+ $('runNote').textContent = `실패: ${e.message}`;
1014
+ $('runFail').hidden = false;
1015
+ show('run');
974
1016
  } finally {
975
1017
  clearInterval(tick);
976
1018
  ctrl = null;
@@ -1028,6 +1070,7 @@ async function loadTemplates() {
1028
1070
  }
1029
1071
 
1030
1072
  function renderTemplates(sets) {
1073
+ renderRefNow(sets);
1031
1074
  const host = $('tpls');
1032
1075
  host.textContent = '';
1033
1076
  $('tplBox').hidden = !sets.length;
@@ -1336,12 +1379,14 @@ loadBrand();
1336
1379
 
1337
1380
  $('go').onclick = generate;
1338
1381
  $('approve').onclick = build;
1382
+ $('retry').onclick = build;
1383
+ $('backToPlan').onclick = () => { $('runFail').hidden = true; renderPlan(plan); };
1339
1384
  $('redo').onclick = () => show('form'); // 장수를 바꿔 리서치부터 다시
1340
1385
  $('planCancel').onclick = () => show('pick');
1341
1386
  $('back').onclick = () => show('pick');
1342
1387
  $('again').onclick = () => show('form');
1343
1388
  $('back').onclick = () => show(mode === 'cardnews' ? 'engine' : 'pick');
1344
- $('reset').onclick = () => show('pick');
1389
+ $('reset').onclick = () => { refs = []; refSet = null; renderThumbs(); show('pick'); };
1345
1390
  $('stop').onclick = () => ctrl?.abort();
1346
1391
  $('copyAll').onclick = (e) => copy(clean, e.currentTarget);
1347
1392
  $('topic').onkeydown = (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) generate(); };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sns-auto-builder",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "인스타 카드뉴스 자동 제작 빌더. Claude Code + ChatGPT 웹을 그대로 써서 API 키 없이 돌아갑니다.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -125,14 +125,40 @@ async function waitForComposer(page) {
125
125
  // 로그인이 풀렸으면 여기서 프롬프트 창이 안 뜬다 — 위 waitForSelector 가 던진다.
126
126
  }
127
127
 
128
+ // 레퍼런스 첨부. **실패하면 던진다.**
129
+ //
130
+ // 예전에는 썸네일 확인이 실패해도 경고만 찍고 그냥 보냈다. 그러면 레퍼런스 없이 만들어진
131
+ // 결과물이 정상인 것처럼 나와서, 사용자는 "왜 레퍼런스가 반영이 안 되지" 로만 알게 된다.
132
+ // 조용히 틀리느니 시끄럽게 멈추는 게 낫다.
128
133
  async function attachRef(page, refPaths) {
129
- const input = await page.waitForSelector('input[type="file"]', { timeout: 30_000 });
130
- await input.uploadFile(...refPaths); // 여러 장을 번에 올린다
131
- // 업로드가 끝나기 전에 Enter 치면 첨부 없이 전송된다. 썸네일이 붙을 때까지 기다린다.
132
- await page.waitForFunction(
133
- () => !!document.querySelector('form img, form [data-testid*="attachment"]'),
134
- { timeout: 120_000 },
135
- ).catch(() => console.warn(' (경고) 첨부 썸네일을 확인하지 못했습니다. 그대로 진행합니다.'));
134
+ const want = refPaths.length;
135
+ // 파일 입력이 여러 개다. 컴포저(form) 안에 있는 것이 첨부용이다.
136
+ const pickInput = async () => {
137
+ const handles = await page.$$('input[type="file"]');
138
+ for (const h of handles) {
139
+ if (await h.evaluate((el) => !!el.closest('form'))) return h;
140
+ }
141
+ return handles[0] || null;
142
+ };
143
+
144
+ for (let attempt = 1; attempt <= 2; attempt++) {
145
+ const input = await pickInput();
146
+ if (!input) throw new Error('첨부용 파일 입력을 찾지 못했습니다 (ChatGPT 화면 구조가 바뀌었을 수 있음)');
147
+ await input.uploadFile(...refPaths);
148
+
149
+ // 한 장만 붙어도 통과시키면 안 된다. **올린 개수만큼** 붙을 때까지 기다린다.
150
+ const ok = await page
151
+ .waitForFunction((n) => document.querySelectorAll('form img').length >= n, { timeout: 60_000 }, want)
152
+ .then(() => true).catch(() => false);
153
+ if (ok) {
154
+ console.log(` 레퍼런스 ${want}장 첨부 확인`);
155
+ return;
156
+ }
157
+ const got = await page.evaluate(() => document.querySelectorAll('form img').length);
158
+ console.warn(` (재시도 ${attempt}/2) 첨부가 ${got}/${want}장만 확인됨`);
159
+ await new Promise((r) => setTimeout(r, 3000));
160
+ }
161
+ throw new Error(`레퍼런스 ${want}장을 첨부하지 못했습니다. 레퍼런스 없이 만들면 디자인이 달라지므로 중단합니다.`);
136
162
  }
137
163
 
138
164
  async function sendPrompt(page, text) {
@@ -150,11 +176,37 @@ async function sendPrompt(page, text) {
150
176
  // 여러 장이 나왔으면 경고를 남긴다 — 프롬프트가 다장 생성을 유도했다는 신호다.
151
177
  const SETTLE_MS = 12_000; // 개수가 이만큼 안 늘면 그 턴은 끝난 것으로 본다
152
178
 
179
+ // ChatGPT 가 생성에 실패하면 본문에 영어 안내가 뜨고 이미지는 안 나온다.
180
+ // 그걸 못 읽으면 우리는 타임아웃까지 5분을 그냥 기다린 뒤 "안 나옴" 이라고만 말하게 된다.
181
+ // 실제 문구를 그대로 사용자에게 전달해야 원인 파악이 된다.
182
+ const CHATGPT_ERRORS = [
183
+ 'Something went wrong while generating',
184
+ 'I was unable to generate',
185
+ "I can't generate",
186
+ 'content policy',
187
+ 'rate limit',
188
+ 'try again later',
189
+ ];
190
+ const readPageError = (page) => page.evaluate((needles) => {
191
+ const main = document.querySelector('main');
192
+ const t = main ? main.innerText : '';
193
+ const tail = t.slice(-1500);
194
+ const hit = needles.find((n) => tail.toLowerCase().includes(n.toLowerCase()));
195
+ if (!hit) return null;
196
+ const line = tail.split('\n').reverse().find((l) => l.toLowerCase().includes(hit.toLowerCase()));
197
+ return (line || hit).trim().slice(0, 200);
198
+ }, CHATGPT_ERRORS);
199
+
153
200
  async function waitForNewImage(page, before, timeoutMs) {
154
201
  const deadline = Date.now() + timeoutMs;
155
202
  let fresh = [], lastChange = 0;
156
203
  while (Date.now() < deadline) {
157
204
  const now = (await fileIds(page)).filter((id) => !before.includes(id));
205
+ if (!now.length) {
206
+ // 이미지가 아직 없을 때만 본다. 이미 나왔으면 옛 대화의 문구를 오탐할 수 있다.
207
+ const err = await readPageError(page);
208
+ if (err) throw new Error(`ChatGPT 가 생성을 거부했습니다: ${err}`);
209
+ }
158
210
  if (now.length !== fresh.length) { fresh = now; lastChange = Date.now(); }
159
211
  else if (fresh.length && Date.now() - lastChange >= SETTLE_MS) {
160
212
  if (fresh.length > 1) {
package/server.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // 파이프라인 로직을 여기 복제하지 말 것 — CLAUDE.md 가 단일 출처다.
5
5
  import { createServer } from 'node:http';
6
6
  import { spawn } from 'node:child_process';
7
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
7
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { relative, join, extname, sep } from 'node:path';
10
10
 
@@ -35,6 +35,28 @@ export function buildPrompt(mode, topic, refPaths = []) {
35
35
  ].join('\n\n---\n\n');
36
36
  }
37
37
 
38
+ // ── 로그 ────────────────────────────────────────────────────────
39
+ // 다른 PC 에서 난 문제를 여기서 볼 방법이 없으면 재현에만 매달리게 된다.
40
+ // 실행 기록과 오류를 파일로 남겨, 사용자가 그 파일만 보내주면 분석할 수 있게 한다.
41
+ // 개인정보가 섞이지 않도록 주제·경로 같은 작업 정보만 남기고 토큰·쿠키는 절대 안 쓴다.
42
+ const LOG_DIR = new URL('./logs/', import.meta.url);
43
+ const LOG_FILE = new URL('./logs/app.log', import.meta.url);
44
+ const MAX_LOG = 2 * 1024 * 1024; // 2MB 넘으면 한 번 갈아끼운다 (무한정 커지면 보내기도 어렵다)
45
+
46
+ export function log(...parts) {
47
+ const line = `[${new Date().toISOString()}] ${parts.map((p) =>
48
+ typeof p === 'string' ? p : JSON.stringify(p)).join(' ')}\n`;
49
+ try {
50
+ mkdirSync(LOG_DIR, { recursive: true });
51
+ try {
52
+ if (statSync(LOG_FILE).size > MAX_LOG) writeFileSync(new URL('./logs/app.prev.log', import.meta.url), readFileSync(LOG_FILE));
53
+ // 위에서 갈아끼웠으면 새로 시작
54
+ if (statSync(LOG_FILE).size > MAX_LOG) writeFileSync(LOG_FILE, '');
55
+ } catch { /* 파일이 아직 없으면 그냥 새로 만든다 */ }
56
+ appendFileSync(LOG_FILE, line);
57
+ } catch { /* 로그 실패가 기능을 막으면 안 된다 */ }
58
+ }
59
+
38
60
  // ── 브랜드 설정 ─────────────────────────────────────────────────
39
61
  // 화면과 brand-facts.md 가 같은 스키마를 본다. 항목을 늘리려면 brand.schema.json 만 고치면 된다.
40
62
  // 값은 brand.json 에 저장하고, brand-facts.md 는 거기서 **생성**한다.
@@ -578,7 +600,7 @@ function run(prompt, tools, res, onLine, timeoutMs, onFinish) {
578
600
 
579
601
  p.stdout.setEncoding('utf8');
580
602
  p.stderr.setEncoding('utf8');
581
- p.stderr.on('data', (d) => (err += d));
603
+ p.stderr.on('data', (d) => { err += d; log('stderr', String(d).slice(0, 500)); });
582
604
  p.stdout.on('data', (chunk) => {
583
605
  buf += chunk;
584
606
  const lines = buf.split('\n');
@@ -600,7 +622,8 @@ function run(prompt, tools, res, onLine, timeoutMs, onFinish) {
600
622
  p.on('error', (e) => (stop(), send({ error: e.message }), res.end()));
601
623
  p.on('close', (code) => {
602
624
  stop();
603
- if (!got) send({ error: err.trim() || `claude 종료 코드 ${code}` });
625
+ if (!got) { log('pipeline 실패', { code, err: err.trim().slice(0, 800) }); send({ error: err.trim() || `claude 종료 코드 ${code}` }); }
626
+ else log('pipeline 종료', { code });
604
627
  onFinish?.(send); // 결과는 반드시 done/res.end() **전에** 흘린다
605
628
  send({ done: true });
606
629
  res.end();
@@ -622,12 +645,35 @@ const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp',
622
645
  const server = createServer(async (req, res) => {
623
646
  const json = (code, obj) => res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }).end(JSON.stringify(obj));
624
647
  try {
648
+ // 문제 분석용 로그 내려받기. 사용자가 이 파일만 보내주면 원인을 볼 수 있다.
649
+ if (req.method === 'GET' && req.url.split('?')[0] === '/logs') {
650
+ const parts = [`# sns-auto-builder 로그`, `생성: ${new Date().toISOString()}`, ''];
651
+ try {
652
+ const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
653
+ parts.push(`버전: ${pkg.version}`);
654
+ } catch { parts.push('버전: (알 수 없음)'); }
655
+ parts.push(`Node: ${process.version} / ${process.platform}`, `작업폴더: ${ROOT}`, '');
656
+ // 브랜드 값은 회사 정보라 넣지 않는다. 채워졌는지 여부만.
657
+ const b = readBrand();
658
+ parts.push(`브랜드 설정: ${Object.values(b).filter((v) => String(v).trim()).length}개 항목 채움`,
659
+ `로고: ${hasLogo() ? '있음' : '없음'}`, '', '─'.repeat(50), '');
660
+ for (const f of ['./logs/app.prev.log', './logs/app.log']) {
661
+ try { parts.push(readFileSync(new URL(f, import.meta.url), 'utf8')); } catch { /* 없을 수 있다 */ }
662
+ }
663
+ const body = Buffer.from(parts.join('\n'), 'utf8');
664
+ return res.writeHead(200, {
665
+ 'content-type': 'text/plain; charset=utf-8',
666
+ 'content-disposition': `attachment; filename="sns-auto-builder-log.txt"`,
667
+ }).end(body);
668
+ }
669
+
625
670
  if (req.method === 'POST' && req.url === '/gen') {
626
671
  const { mode, engine, topic, refs, today, count, refSet } = JSON.parse(await body(req));
627
672
  // 저장된 템플릿을 고르면 다시 업로드하지 않고 그 경로를 그대로 쓴다.
628
673
  const picked = refSet ? listRefSets().find((s) => s.id === safeRefId(refSet)) : null;
629
674
  if (refSet && !picked) throw new Error('고른 참고자료 템플릿을 찾을 수 없습니다');
630
675
  const paths = picked ? picked.files : saveRefs(refs, String(Date.now()));
676
+ log('gen 시작', { mode, engine, count, refs: paths.length, refSet: refSet || null, topic: String(topic||'').slice(0,60) });
631
677
 
632
678
  res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-cache' });
633
679
 
@@ -664,7 +710,7 @@ const server = createServer(async (req, res) => {
664
710
  const { dir } = JSON.parse(await body(req));
665
711
  const abs = join(ROOT, String(dir || ''));
666
712
  if (!abs.startsWith(join(ROOT, 'output')) || !existsSync(join(abs, 'gen.json'))) {
667
- return json(400, { error: '이 폴더의 실행 정보를 찾을 수 없습니다' });
713
+ { log('거절', 400, req.url?.split('?')[0], '이 폴더의 실행 정보를 찾을 수 없습니다'); return json(400, { error: '이 폴더의 실행 정보를 찾을 수 없습니다' }); }
668
714
  }
669
715
  const meta = JSON.parse(readFileSync(join(abs, 'gen.json'), 'utf8'));
670
716
  const { engine, topic } = meta;
@@ -672,6 +718,7 @@ const server = createServer(async (req, res) => {
672
718
  const slideCount = clampCount(meta.count);
673
719
 
674
720
  res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-cache' });
721
+ log('build 시작', { dir, engine, count: slideCount, refs: paths.length });
675
722
  const prompt = buildPipelinePrompt(engine, topic, dir, paths, slideCount);
676
723
 
677
724
  let tail = '', stopWatch = null;
@@ -700,7 +747,7 @@ const server = createServer(async (req, res) => {
700
747
 
701
748
  // 로고 미리보기용. HTML 슬라이드도 이 경로로 로고를 불러 쓴다.
702
749
  if (req.method === 'GET' && req.url.split('?')[0] === '/logo.svg') {
703
- if (!hasLogo()) return json(404, { error: 'no logo' });
750
+ if (!hasLogo()) { log('거절', 404, req.url?.split('?')[0], 'no logo'); return json(404, { error: 'no logo' }); }
704
751
  return res.writeHead(200, { 'content-type': 'image/svg+xml; charset=utf-8', 'cache-control': 'no-cache' })
705
752
  .end(readFileSync(LOGO_SVG));
706
753
  }
@@ -722,14 +769,14 @@ const server = createServer(async (req, res) => {
722
769
  return json(200, { ok: true, logo: false });
723
770
  }
724
771
  const m = /^data:image\/svg\+xml(?:;charset=[^;,]+)?;base64,([A-Za-z0-9+/=]+)$/.exec(String(dataUrl ?? ''));
725
- if (!m) return json(400, { error: 'SVG 파일만 올릴 수 있습니다' });
772
+ if (!m) { log('거절', 400, req.url?.split('?')[0], 'SVG 파일만 올릴 수 있습니다'); return json(400, { error: 'SVG 파일만 올릴 수 있습니다' }); }
726
773
  const svg = Buffer.from(m[1], 'base64');
727
- if (svg.length > 2 * 1024 * 1024) return json(400, { error: 'SVG 가 2MB를 넘습니다' });
774
+ if (svg.length > 2 * 1024 * 1024) { log('거절', 400, req.url?.split('?')[0], 'SVG 가 2MB를 넘습니다'); return json(400, { error: 'SVG 가 2MB를 넘습니다' }); }
728
775
  const text = svg.toString('utf8');
729
- if (!/<svg[\s>]/i.test(text)) return json(400, { error: 'SVG 내용이 아닙니다' });
776
+ if (!/<svg[\s>]/i.test(text)) { log('거절', 400, req.url?.split('?')[0], 'SVG 내용이 아닙니다'); return json(400, { error: 'SVG 내용이 아닙니다' }); }
730
777
  // SVG 안의 스크립트는 제거한다. 슬라이드 HTML 을 puppeteer 가 그대로 여는 구조라
731
778
  // 스크립트가 살아 있으면 그 안에서 실행된다.
732
- if (/<script[\s>]|javascript:/i.test(text)) return json(400, { error: '스크립트가 든 SVG 는 쓸 수 없습니다' });
779
+ if (/<script[\s>]|javascript:/i.test(text)) { log('거절', 400, req.url?.split('?')[0], '스크립트가 든 SVG 는 쓸 수 없습니다'); return json(400, { error: '스크립트가 든 SVG 는 쓸 수 없습니다' }); }
733
780
  writeFileSync(LOGO_SVG, svg);
734
781
  return json(200, { ok: true, logo: true });
735
782
  }
@@ -743,9 +790,9 @@ const server = createServer(async (req, res) => {
743
790
  if (req.method === 'POST' && req.url === '/refs/delete') {
744
791
  const { id } = JSON.parse(await body(req));
745
792
  const safe = safeRefId(id);
746
- if (!safe) return json(400, { error: '잘못된 id 입니다' });
793
+ if (!safe) { log('거절', 400, req.url?.split('?')[0], '잘못된 id 입니다'); return json(400, { error: '잘못된 id 입니다' }); }
747
794
  const dir = join(REFS_ROOT(), safe);
748
- if (!dir.startsWith(REFS_ROOT()) || !existsSync(dir)) return json(404, { error: '없는 템플릿입니다' });
795
+ if (!dir.startsWith(REFS_ROOT()) || !existsSync(dir)) { log('거절', 404, req.url?.split('?')[0], '없는 템플릿입니다'); return json(404, { error: '없는 템플릿입니다' }); }
749
796
  rmSync(dir, { recursive: true, force: true });
750
797
  return json(200, { ok: true, sets: listRefSets() });
751
798
  }
@@ -755,7 +802,7 @@ const server = createServer(async (req, res) => {
755
802
  const { dir } = JSON.parse(await body(req));
756
803
  const abs = join(ROOT, String(dir || ''));
757
804
  if (!abs.startsWith(join(ROOT, 'output')) || !existsSync(join(abs, 'gen.json'))) {
758
- return json(400, { error: '이 폴더의 실행 정보를 찾을 수 없습니다' });
805
+ { log('거절', 400, req.url?.split('?')[0], '이 폴더의 실행 정보를 찾을 수 없습니다'); return json(400, { error: '이 폴더의 실행 정보를 찾을 수 없습니다' }); }
759
806
  }
760
807
  const meta = JSON.parse(readFileSync(join(abs, 'gen.json'), 'utf8'));
761
808
  res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-cache' });
@@ -770,12 +817,12 @@ const server = createServer(async (req, res) => {
770
817
  if (req.method === 'POST' && req.url === '/regen') {
771
818
  const { dir, n } = JSON.parse(await body(req));
772
819
  const num = Number(n);
773
- if (!Number.isInteger(num) || num < 1 || num > 99) return json(400, { error: '슬라이드 번호가 잘못됐습니다' });
820
+ if (!Number.isInteger(num) || num < 1 || num > 99) { log('거절', 400, req.url?.split('?')[0], '슬라이드 번호가 잘못됐습니다'); return json(400, { error: '슬라이드 번호가 잘못됐습니다' }); }
774
821
 
775
822
  // dir 은 클라이언트 값이다. output/ 밖으로 못 나가게 실제 경로로 검사한다.
776
823
  const abs = join(ROOT, String(dir || ''));
777
824
  if (!abs.startsWith(join(ROOT, 'output')) || !existsSync(join(abs, 'gen.json'))) {
778
- return json(400, { error: '재생성 정보를 찾을 수 없습니다' });
825
+ { log('거절', 400, req.url?.split('?')[0], '재생성 정보를 찾을 수 없습니다'); return json(400, { error: '재생성 정보를 찾을 수 없습니다' }); }
779
826
  }
780
827
  const meta = JSON.parse(readFileSync(join(abs, 'gen.json'), 'utf8'));
781
828
  if (!meta.slides || !existsSync(join(ROOT, meta.slides))) {
@@ -817,9 +864,9 @@ const server = createServer(async (req, res) => {
817
864
  const abs = join(ROOT, String(dir || ''));
818
865
  const base = join(ROOT, 'output');
819
866
  // output 자체나 _refs 같은 내부 폴더는 못 지운다
820
- if (!abs.startsWith(base + sep) || abs === base) return json(400, { error: '지울 수 없는 경로입니다' });
821
- if (/[\\/]_/.test(abs.slice(base.length))) return json(400, { error: '내부 폴더는 지울 수 없습니다' });
822
- if (!existsSync(abs)) return json(404, { error: '없는 결과물입니다' });
867
+ if (!abs.startsWith(base + sep) || abs === base) { log('거절', 400, req.url?.split('?')[0], '지울 수 없는 경로입니다'); return json(400, { error: '지울 수 없는 경로입니다' }); }
868
+ if (/[\\/]_/.test(abs.slice(base.length))) { log('거절', 400, req.url?.split('?')[0], '내부 폴더는 지울 수 없습니다'); return json(400, { error: '내부 폴더는 지울 수 없습니다' }); }
869
+ if (!existsSync(abs)) { log('거절', 404, req.url?.split('?')[0], '없는 결과물입니다'); return json(404, { error: '없는 결과물입니다' }); }
823
870
  rmSync(abs, { recursive: true, force: true });
824
871
  return json(200, { ok: true, items: listOutputs() });
825
872
  }
@@ -828,17 +875,18 @@ const server = createServer(async (req, res) => {
828
875
  if (req.method === 'GET' && req.url.startsWith('/zip/')) {
829
876
  const rel = decodeURIComponent(req.url.slice(5).split('?')[0]);
830
877
  const abs = join(ROOT, 'output', rel);
831
- if (!abs.startsWith(join(ROOT, 'output')) || !existsSync(abs)) return json(404, { error: 'not found' });
878
+ if (!abs.startsWith(join(ROOT, 'output')) || !existsSync(abs)) { log('거절', 404, req.url?.split('?')[0], 'not found'); return json(404, { error: 'not found' }); }
832
879
 
833
880
  const entries = readdirSync(abs)
834
881
  .filter((f) => /^slide-\d{2}\.png$/.test(f))
835
882
  .sort()
836
883
  .map((f) => ({ name: f, data: readFileSync(join(abs, f)) }));
837
- if (!entries.length) return json(404, { error: '슬라이드가 없습니다' });
884
+ if (!entries.length) { log('거절', 404, req.url?.split('?')[0], '슬라이드가 없습니다'); return json(404, { error: '슬라이드가 없습니다' }); }
838
885
  for (const extra of ['caption.md', 'review.md']) {
839
886
  if (existsSync(join(abs, extra))) entries.push({ name: extra, data: readFileSync(join(abs, extra)) });
840
887
  }
841
888
 
889
+ log('zip 다운로드', { dir: rel, files: entries.length });
842
890
  const zip = makeZip(entries);
843
891
  // 폴더명이 한글이라 filename* (RFC 5987) 로 줘야 파일명이 안 깨진다.
844
892
  const base = rel.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'carousel';
@@ -853,7 +901,7 @@ const server = createServer(async (req, res) => {
853
901
  if (req.method === 'GET' && req.url.startsWith('/out/')) {
854
902
  const rel = decodeURIComponent(req.url.slice(5).split('?')[0]);
855
903
  const abs = join(ROOT, 'output', rel);
856
- if (!abs.startsWith(join(ROOT, 'output')) || !existsSync(abs)) return json(404, { error: 'not found' });
904
+ if (!abs.startsWith(join(ROOT, 'output')) || !existsSync(abs)) { log('거절', 404, req.url?.split('?')[0], 'not found'); return json(404, { error: 'not found' }); }
857
905
  return res.writeHead(200, { 'content-type': MIME[extname(abs).toLowerCase()] || 'application/octet-stream' })
858
906
  .end(readFileSync(abs));
859
907
  }
@@ -864,6 +912,8 @@ const server = createServer(async (req, res) => {
864
912
  }
865
913
  json(404, { error: 'not found' });
866
914
  } catch (e) {
915
+ // 오류를 남기는 게 로그의 존재 이유다. 스택까지 넣어야 원인을 짚을 수 있다.
916
+ log('오류', req.method, req.url?.split('?')[0], e.message, (e.stack || '').split('\n')[1]?.trim() || '');
867
917
  if (!res.headersSent) json(400, { error: e.message });
868
918
  else res.end(JSON.stringify({ error: e.message }) + '\n');
869
919
  }
@@ -873,5 +923,5 @@ const server = createServer(async (req, res) => {
873
923
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
874
924
  server.on('error', (e) => console.error(
875
925
  e.code === 'EADDRINUSE' ? `포트 ${PORT} 사용 중. PORT=8788 node server.mjs 로 바꿔 실행하세요.` : e));
876
- server.listen(PORT, '127.0.0.1', () => console.log(`http://127.0.0.1:${PORT}`));
926
+ server.listen(PORT, '127.0.0.1', () => { log('서버 시작', { port: PORT, node: process.version, platform: process.platform }); console.log(`http://127.0.0.1:${PORT}`); });
877
927
  }