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 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
- $('outPath').textContent = `${slides.length}장 · ${dir}/ 슬라이드를 누르면 내려받습니다`
748
- + (canRegen ? ' · 오타가 있으면 “다시 만들기”' : '');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sns-auto-builder",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "인스타 카드뉴스 자동 제작 빌더. Claude Code + ChatGPT 웹을 그대로 써서 API 키 없이 돌아갑니다.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- await page.type('#prompt-textarea', text.replace(/\s*\n\s*/g, ' '));
241
- await page.keyboard.press('Enter');
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
- async function waitForNewImage(page, before, timeoutMs) {
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 now = (await fileIds(page)).filter((id) => !before.includes(id));
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
- else if (fresh.length && Date.now() - lastChange >= SETTLE_MS) {
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
- console.log(`\n출력: ${outDir}`);
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
- send({ result: { dir, slides, caption, canRegen: !!meta.slides } });
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);