sns-auto-builder 1.0.8 → 1.1.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.
- package/index.html +16 -3
- package/package.json +1 -1
- package/scripts/chatgpt-image-gen.js +93 -9
- package/server.mjs +4 -1
package/index.html
CHANGED
|
@@ -722,7 +722,7 @@ function renderIg({ dir, slides }) {
|
|
|
722
722
|
sync();
|
|
723
723
|
}
|
|
724
724
|
|
|
725
|
-
function renderSlides({ dir, slides, canRegen }) {
|
|
725
|
+
function renderSlides({ dir, slides, canRegen, expected }) {
|
|
726
726
|
const host = $('slides');
|
|
727
727
|
renderIg({ dir, slides });
|
|
728
728
|
host.textContent = '';
|
|
@@ -744,8 +744,21 @@ function renderSlides({ dir, slides, canRegen }) {
|
|
|
744
744
|
if (canRegen) cell.append(regenButton({ dir, f, img, cell }));
|
|
745
745
|
host.append(cell);
|
|
746
746
|
});
|
|
747
|
-
|
|
748
|
-
|
|
747
|
+
// 장수가 모자란 채로 "완료"만 뜨면, 몇 번이 빠졌는지 모르고 그대로 쓰게 된다.
|
|
748
|
+
// 실제로 ChatGPT 가 3장만 만들었는데 4장인 것처럼 끝난 적이 있다.
|
|
749
|
+
const want = Number(expected) || 0;
|
|
750
|
+
const short = want && slides.length < want;
|
|
751
|
+
if (short) {
|
|
752
|
+
const got = new Set(slides.map((f) => Number(f.match(/\d+/)[0])));
|
|
753
|
+
const miss = [];
|
|
754
|
+
for (let i = 1; i <= want; i++) if (!got.has(i)) miss.push(i);
|
|
755
|
+
$('outPath').innerHTML = `<b style="color:#e0603a">${want}장 중 ${slides.length}장만 나왔습니다`
|
|
756
|
+
+ ` — 빠진 장: ${miss.join(', ')}번</b><br>ChatGPT 가 그 장의 이미지를 안 준 것입니다.`
|
|
757
|
+
+ ` 아래에서 다시 만들거나, 같은 주제로 한 번 더 실행하세요.`;
|
|
758
|
+
} else {
|
|
759
|
+
$('outPath').textContent = `${slides.length}장 · ${dir}/ — 슬라이드를 누르면 내려받습니다`
|
|
760
|
+
+ (canRegen ? ' · 오타가 있으면 “다시 만들기”' : '');
|
|
761
|
+
}
|
|
749
762
|
$('outPath').hidden = false;
|
|
750
763
|
// 한 장씩 누르면 브라우저가 연속 다운로드를 막는다. zip 하나로 묶어 받는다.
|
|
751
764
|
const zip = $('zipAll');
|
package/package.json
CHANGED
|
@@ -234,11 +234,48 @@ async function attachRef(page, refPaths) {
|
|
|
234
234
|
+ ` 그때 화면에 있던 file input 목록이 남습니다 — 빌더 첫 화면의 로그 버튼으로 받으세요.`);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// 보내기 전에 앞 턴이 끝났는지 보고, 보낸 뒤에 정말 나갔는지 확인한다.
|
|
238
|
+
// 예전에는 클릭·타이핑·Enter 만 하고 끝이라, Enter 가 씹혀도 그대로 다음 단계로 갔다.
|
|
239
|
+
// 앞 턴이 "응답 중" 으로 굳어 있으면 Enter 가 아예 안 먹는다(실측). "답변 중지" 를
|
|
240
|
+
// 누르면 풀리고, 그때 입력칸이 다시 그려지므로 셀렉터를 다시 기다려야 한다.
|
|
241
|
+
async function clearBusy(page) {
|
|
242
|
+
console.warn(' 앞 턴이 응답 중 상태로 굳어 있습니다 — "답변 중지" 로 정리합니다.');
|
|
243
|
+
await page.click(STOP_BTN).catch(() => { /* 그새 사라졌으면 그만 */ });
|
|
244
|
+
await waitForIdle(page, 15_000);
|
|
245
|
+
await page.waitForSelector('#prompt-textarea', { timeout: 30_000 }).catch(() => {});
|
|
246
|
+
}
|
|
247
|
+
|
|
237
248
|
async function sendPrompt(page, text) {
|
|
238
|
-
await page.click('#prompt-textarea');
|
|
239
249
|
// 프롬프트는 항상 한 줄로 만든다 — 개행이 들어가면 Enter 가 도중에 전송해버린다.
|
|
240
|
-
|
|
241
|
-
await page
|
|
250
|
+
const one = text.replace(/\s*\n\s*/g, ' ');
|
|
251
|
+
if (!(await waitForIdle(page, IDLE_WAIT_MS))) await clearBusy(page);
|
|
252
|
+
|
|
253
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
254
|
+
await page.waitForSelector('#prompt-textarea', { timeout: 30_000 });
|
|
255
|
+
// 중지 뒤에는 앞서 친 글자가 남아 있을 수도, 비어 있을 수도 있다. 다르면 지우고 다시 친다.
|
|
256
|
+
const cur = await page.evaluate(() => document.getElementById('prompt-textarea').innerText.trim());
|
|
257
|
+
if (cur !== one) {
|
|
258
|
+
await page.click('#prompt-textarea');
|
|
259
|
+
if (cur) {
|
|
260
|
+
await page.keyboard.down('Control'); await page.keyboard.press('KeyA'); await page.keyboard.up('Control');
|
|
261
|
+
await page.keyboard.press('Backspace');
|
|
262
|
+
}
|
|
263
|
+
await page.type('#prompt-textarea', one);
|
|
264
|
+
}
|
|
265
|
+
await page.keyboard.press('Enter');
|
|
266
|
+
|
|
267
|
+
// **입력칸이 비는 것만 본다.** 우리가 넣은 글자가 실제로 나갔다는 뜻이라 확실하다.
|
|
268
|
+
// stop 버튼은 앞 턴 것이 남아 있을 수 있어 전송 확인에 쓰면 안 된다.
|
|
269
|
+
const sent = await page.waitForFunction(() => {
|
|
270
|
+
const c = document.getElementById('prompt-textarea');
|
|
271
|
+
return !!c && !c.innerText.trim();
|
|
272
|
+
}, { timeout: 15_000, polling: 500 }).then(() => true).catch(() => false);
|
|
273
|
+
if (sent) return;
|
|
274
|
+
|
|
275
|
+
console.warn(` (재시도 ${attempt}/2) Enter 가 먹지 않았습니다 — 입력칸에 글자가 그대로입니다`);
|
|
276
|
+
await clearBusy(page);
|
|
277
|
+
}
|
|
278
|
+
throw new Error('프롬프트를 전송하지 못했습니다 — ChatGPT 가 계속 응답 중 상태입니다');
|
|
242
279
|
}
|
|
243
280
|
|
|
244
281
|
// 새 이미지를 기다렸다가 **이 프롬프트에 해당하는 1장**을 골라 돌려준다.
|
|
@@ -249,6 +286,22 @@ async function sendPrompt(page, text) {
|
|
|
249
286
|
// 여러 장이 나왔으면 경고를 남긴다 — 프롬프트가 다장 생성을 유도했다는 신호다.
|
|
250
287
|
const SETTLE_MS = 12_000; // 개수가 이만큼 안 늘면 그 턴은 끝난 것으로 본다
|
|
251
288
|
|
|
289
|
+
// 응답하는 동안 뜨는 "답변 중지" 버튼. 생성 중이라는 뜻이지만 **끝났다는 뜻으로는 못 쓴다** —
|
|
290
|
+
// 답변이 다 나온 뒤에도 이 버튼이 5분 넘게 남아 있는 걸 실측했다. 그래서 이 신호로는
|
|
291
|
+
// 절대 막지 않는다(막으면 멀쩡한 실행이 영원히 안 끝난다). 빨리 실패할 때만 참고한다.
|
|
292
|
+
const STOP_BTN = '[data-testid="stop-button"]';
|
|
293
|
+
const IDLE_GRACE_MS = 20_000; // 버튼이 실제로 사라진 뒤 이만큼 더 봤는데도 이미지가 없으면 실패
|
|
294
|
+
const IDLE_WAIT_MS = 90_000; // 앞 턴을 기다려보는 상한. 넘으면 경고만 하고 그냥 보낸다
|
|
295
|
+
|
|
296
|
+
const isStreaming = (page) => page.$(STOP_BTN).then((h) => !!h).catch(() => false);
|
|
297
|
+
|
|
298
|
+
// 앞 턴이 끝나길 기다린다. **던지지 않는다** — 위 이유로 이 신호는 조언일 뿐이다.
|
|
299
|
+
async function waitForIdle(page, timeoutMs) {
|
|
300
|
+
return page
|
|
301
|
+
.waitForFunction((sel) => !document.querySelector(sel), { timeout: timeoutMs, polling: 1000 }, STOP_BTN)
|
|
302
|
+
.then(() => true).catch(() => false);
|
|
303
|
+
}
|
|
304
|
+
|
|
252
305
|
// ChatGPT 가 생성에 실패하면 본문에 영어 안내가 뜨고 이미지는 안 나온다.
|
|
253
306
|
// 그걸 못 읽으면 우리는 타임아웃까지 5분을 그냥 기다린 뒤 "안 나옴" 이라고만 말하게 된다.
|
|
254
307
|
// 실제 문구를 그대로 사용자에게 전달해야 원인 파악이 된다.
|
|
@@ -270,26 +323,49 @@ const readPageError = (page) => page.evaluate((needles) => {
|
|
|
270
323
|
return (line || hit).trim().slice(0, 200);
|
|
271
324
|
}, CHATGPT_ERRORS);
|
|
272
325
|
|
|
273
|
-
|
|
326
|
+
// used = 이 실행에서 **이미 저장에 쓴** 이미지들. before(이번 턴 직전 스냅샷)만으로는
|
|
327
|
+
// 부족하다: ChatGPT 가 위쪽 메시지를 DOM 에서 걷어냈다가 다시 붙이면 그 이미지가
|
|
328
|
+
// before 에 없어서 "새 것" 으로 잡힌다. 실제로 그렇게 2번 이미지가 3번 자리에도
|
|
329
|
+
// 저장돼, 3장만 만들어졌는데 4장이 나온 것처럼 끝난 적이 있다.
|
|
330
|
+
async function waitForNewImage(page, before, timeoutMs, used = new Set()) {
|
|
274
331
|
const deadline = Date.now() + timeoutMs;
|
|
275
|
-
let fresh = [], lastChange = 0;
|
|
332
|
+
let fresh = [], lastChange = 0, sawOnlyUsed = false, idleSince = 0;
|
|
276
333
|
while (Date.now() < deadline) {
|
|
277
|
-
const
|
|
334
|
+
const all = (await fileIds(page)).filter((id) => !before.includes(id));
|
|
335
|
+
const now = all.filter((id) => !used.has(id));
|
|
336
|
+
if (all.length && !now.length) sawOnlyUsed = true;
|
|
278
337
|
if (!now.length) {
|
|
279
338
|
// 이미지가 아직 없을 때만 본다. 이미 나왔으면 옛 대화의 문구를 오탐할 수 있다.
|
|
280
339
|
const err = await readPageError(page);
|
|
281
340
|
if (err) throw new Error(`ChatGPT 가 생성을 거부했습니다: ${err}`);
|
|
282
341
|
}
|
|
283
342
|
if (now.length !== fresh.length) { fresh = now; lastChange = Date.now(); }
|
|
284
|
-
|
|
343
|
+
|
|
344
|
+
// 턴이 끝났는지를 **먼저** 본다. 12초 settle 은 stop 버튼을 못 읽을 때의 보조 장치다.
|
|
345
|
+
const streaming = await isStreaming(page);
|
|
346
|
+
if (streaming) idleSince = 0;
|
|
347
|
+
else if (!idleSince) idleSince = Date.now();
|
|
348
|
+
|
|
349
|
+
if (fresh.length && (idleSince || Date.now() - lastChange >= SETTLE_MS)) {
|
|
285
350
|
if (fresh.length > 1) {
|
|
286
351
|
console.warn(` (경고) 한 프롬프트에 ${fresh.length}장이 생성됨 — 첫 장만 사용합니다.`);
|
|
287
352
|
}
|
|
288
353
|
return fresh[0];
|
|
289
354
|
}
|
|
355
|
+
// 응답은 끝났는데 새 이미지가 없다 = 이 프롬프트는 이미지를 못 받았다.
|
|
356
|
+
// 예전에는 여기서 5분 타임아웃까지 기다렸다가, 남아 있던 앞 장 이미지를 집어갔다.
|
|
357
|
+
if (!fresh.length && idleSince && Date.now() - idleSince >= IDLE_GRACE_MS) {
|
|
358
|
+
throw new Error(sawOnlyUsed
|
|
359
|
+
? '새 이미지가 안 나왔습니다 — 화면에 있는 건 앞 장에 이미 쓴 것입니다. 앞 장을 복사해 넣지 않고 이 장은 실패로 둡니다'
|
|
360
|
+
: 'ChatGPT 가 응답을 끝냈는데 이미지가 없습니다 (글로만 답했을 수 있습니다)');
|
|
361
|
+
}
|
|
290
362
|
await new Promise((r) => setTimeout(r, 3000));
|
|
291
363
|
}
|
|
292
364
|
if (fresh.length) return fresh[0]; // 타임아웃이어도 받은 게 있으면 쓴다
|
|
365
|
+
if (sawOnlyUsed) {
|
|
366
|
+
throw new Error('이 프롬프트로는 새 이미지가 안 나왔습니다 (화면에 있는 건 앞 장에 이미 쓴 것). '
|
|
367
|
+
+ '앞 장을 복사해 넣으면 안 되므로 이 장은 실패로 둡니다');
|
|
368
|
+
}
|
|
293
369
|
throw new Error(`이미지가 ${Math.round(timeoutMs / 1000)}초 안에 안 나옴`);
|
|
294
370
|
}
|
|
295
371
|
|
|
@@ -444,6 +520,7 @@ async function main() {
|
|
|
444
520
|
|
|
445
521
|
const summary = [];
|
|
446
522
|
let primed = false; // 이 실행에서 스타일/레퍼런스를 실은 첫 장을 이미 보냈는가
|
|
523
|
+
const usedIds = new Set(); // 이미 저장에 쓴 이미지 — 같은 것을 두 장에 넣지 않기 위해
|
|
447
524
|
const page = await browser.newPage();
|
|
448
525
|
try {
|
|
449
526
|
await page.goto(CHATGPT, { waitUntil: 'domcontentloaded', timeout: 120_000 });
|
|
@@ -479,7 +556,8 @@ async function main() {
|
|
|
479
556
|
).catch(() => console.warn('\n (경고) 레퍼런스 썸네일을 확인하지 못했습니다.'));
|
|
480
557
|
before = await fileIds(page);
|
|
481
558
|
}
|
|
482
|
-
const fileId = await waitForNewImage(page, before, timeoutMs);
|
|
559
|
+
const fileId = await waitForNewImage(page, before, timeoutMs, usedIds);
|
|
560
|
+
usedIds.add(fileId);
|
|
483
561
|
let buf = await downloadImage(page, fileId);
|
|
484
562
|
const src = checkRatio(buf);
|
|
485
563
|
if (mode === 'slide') buf = await cropTo45(buf, cropPage, logo);
|
|
@@ -507,7 +585,13 @@ async function main() {
|
|
|
507
585
|
console.log('\n=== SUMMARY ===');
|
|
508
586
|
summary.forEach(([f, st, dt, info]) => console.log(` ${f.padEnd(12)} ${st.padEnd(5)} ${dt.padStart(7)} ${info}`));
|
|
509
587
|
const missing = summary.filter(([, st]) => st === 'ERR').length;
|
|
510
|
-
|
|
588
|
+
// ChatGPT 가 실제로 만든 **서로 다른** 이미지 수를 장수와 나란히 찍는다.
|
|
589
|
+
// 같은 이미지를 두 장에 넣으면 파일 수는 맞아도 이 줄에서 어긋난다.
|
|
590
|
+
const ok = summary.filter(([, st]) => st === 'OK').length;
|
|
591
|
+
console.log(`\n생성된 이미지 ${usedIds.size}장 / 저장 ${ok}장 / 요청 ${slides.length}장`);
|
|
592
|
+
if (usedIds.size < ok) console.warn(' (경고) 같은 이미지가 두 장에 들어갔을 수 있습니다.');
|
|
593
|
+
logDom('생성 결과', { made: usedIds.size, saved: ok, want: slides.length, err: missing });
|
|
594
|
+
console.log(`출력: ${outDir}`);
|
|
511
595
|
console.log(missing
|
|
512
596
|
? `Chrome 창은 열어둡니다 (${missing}장 남음). 같은 명령을 다시 부르면 그 창에 붙어 이어서 뽑습니다.`
|
|
513
597
|
: 'Chrome 창은 열어둡니다. 다음 실행이 같은 창에 붙습니다.');
|
package/server.mjs
CHANGED
|
@@ -887,7 +887,10 @@ ${context || '(없음)'}
|
|
|
887
887
|
// 이미지 트랙만 장 단위 재생성이 된다. HTML 트랙은 글자를 HTML 이 그리므로 오타가 안 난다.
|
|
888
888
|
const meta = { engine, refs: paths, slides: engine === 'image' ? newestPromptFile() : null, count: slideCount, topic };
|
|
889
889
|
writeGenMeta(abs, meta);
|
|
890
|
-
|
|
890
|
+
// expected 를 같이 보낸다. 장수가 모자라도 화면은 "완료"만 띄웠고,
|
|
891
|
+
// 몇 번이 빠졌는지는 아무 데도 안 나왔다.
|
|
892
|
+
if (slides.length < slideCount) log('장수 부족', { dir, got: slides.length, want: slideCount });
|
|
893
|
+
send({ result: { dir, slides, caption, canRegen: !!meta.slides, expected: slideCount } });
|
|
891
894
|
});
|
|
892
895
|
// 모델 출력을 기다리지 않고 바로 감시를 켠다. 완성된 슬라이드가 생기는 즉시 화면에 뜬다.
|
|
893
896
|
stopWatch = watchProgress(dir, engine, sendOut, slideCount);
|