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 +51 -6
- package/package.json +1 -1
- package/scripts/chatgpt-image-gen.js +59 -7
- package/server.mjs +71 -21
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"
|
|
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
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
@@ -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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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) =>
|
|
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
|
}
|