sns-auto-builder 1.0.7 → 1.0.9

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/CLAUDE.md CHANGED
@@ -48,6 +48,11 @@ Phase 3c (ChatGPT 웹 — 배경만 뽑고 한글은 HTML 로 얹기):
48
48
  # 그다음 3a 의 HTML 단계를 이어서 수행
49
49
  ```
50
50
 
51
+ **크롬이 떴는데 프롬프트를 입력하지 않으면** 레퍼런스 첨부에서 막힌 것이다. ChatGPT 가
52
+ DOM 을 바꾸면 생긴다. `logs/app.log` 의 `첨부 실패` 줄에 그때 화면의 file input 목록이
53
+ 남으니 그걸 보고 `SELECTORS` 를 고쳐라. **임의로 레퍼런스를 빼고 다시 돌리지 마라** —
54
+ 레퍼런스 없이 만들면 디자인이 달라진다.
55
+
51
56
  `chatgpt-image-gen.js` 는 로그인된 Chrome 프로필(`.chrome-profile`)로 chatgpt.com 을 몰아
52
57
  프롬프트를 한 장씩 넣고 이미지를 받아 1080×1350 의 2배로 크롭해 저장한다.
53
58
  참고 이미지가 있으면 `--ref <경로>` 로 첫 장에 첨부해 그 디자인을 따라가게 한다.
package/README.md CHANGED
@@ -173,6 +173,30 @@ Chrome 은 인스턴스를 하나만 허용하므로 빌더가 영영 포트를
173
173
  로그는 **버튼과 무관하게 항상 자동으로 쌓입니다** — 버튼은 내려받는 손잡이일 뿐입니다.
174
174
  버전 · OS · 실행 기록 · 오류와 발생 위치가 담기고, **브랜드 값 내용 · 토큰 · 쿠키는 안 담깁니다.**
175
175
 
176
+ #### 크롬 자동화가 멈출 때
177
+
178
+ ChatGPT 는 화면 구조를 예고 없이 바꿉니다. 그러면 **크롬은 뜨는데 프롬프트를 입력하지 않는**
179
+ 모습이 됩니다 (첨부 단계에서 썸네일을 2분 기다리다 실패). 이때 로그에 이런 줄이 남습니다:
180
+
181
+ ```
182
+ […] 첨부 실패 1/2 {"used":"input[type=\"file\"][accept*=\"image\"]","formImgs":0,"forms":1,
183
+ "composer":true,"fileInputs":[{"testid":null,"accept":null,"inForm":true},
184
+ {"testid":"upload-photos-input","accept":"image/*","inForm":false}, …]}
185
+ ```
186
+
187
+ `fileInputs` 가 그 순간 화면에 있던 파일 입력의 목록입니다. 어느 것을 썼는지(`used`)와
188
+ 대조하면 셀렉터를 어떻게 고쳐야 하는지 바로 나옵니다. 실제로 이 목록이 있었다면
189
+ `accept` 없는 input 을 고르고 있다는 걸 즉시 알 수 있었습니다.
190
+ → [scripts/chatgpt-image-gen.js](scripts/chatgpt-image-gen.js) 의 `SELECTORS`
191
+
192
+ #### 크롬 포트
193
+
194
+ 빌더는 CDP 포트 **19222** 를 씁니다 (`SNS_BUILDER_CDP_PORT` 로 변경 가능).
195
+ 관례 포트인 9222 를 쓰면 사용자 본인의 크롬이나 chrome-devtools MCP 와 겹치는데,
196
+ **겹쳐도 에러가 안 납니다** — 한쪽이 `::1:9222`, 다른 쪽이 `127.0.0.1:9222` 를 잡고
197
+ 둘 다 정상으로 보입니다. 그 상태에서 빌더는 조용히 **남의 브라우저를 조종합니다.**
198
+ `bin/sns-auto-builder.mjs` 와 `scripts/chatgpt-image-gen.js` 의 `CDP_PORT` 는 항상 같아야 합니다.
199
+
176
200
  ---
177
201
 
178
202
  ## 구조
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.7",
3
+ "version": "1.0.9",
4
4
  "description": "인스타 카드뉴스 자동 제작 빌더. Claude Code + ChatGPT 웹을 그대로 써서 API 키 없이 돌아갑니다.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  * 떠 있으면(예: chrome-devtools MCP) 프로필이 잠겨 실패한다. 그때는
27
27
  * --browser-url http://127.0.0.1:19222 로 떠 있는 Chrome 에 붙어라.
28
28
  */
29
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
29
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, appendFileSync } from 'node:fs';
30
30
  import { join, resolve, dirname } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { cropTo45 } from './openai-image-gen.js';
@@ -154,6 +154,34 @@ async function waitForComposer(page) {
154
154
  // 로그인이 풀렸으면 여기서 프롬프트 창이 안 뜬다 — 위 waitForSelector 가 던진다.
155
155
  }
156
156
 
157
+ // 첨부가 실패했을 때 "그때 화면에 뭐가 있었는지" 를 남긴다.
158
+ //
159
+ // ChatGPT 는 DOM 을 예고 없이 바꾼다. 실제로 업로드 input 이 <form> 밖으로 옮겨진
160
+ // 적이 있고, 그때 증상은 "크롬은 떴는데 프롬프트를 입력하지 않는다" 뿐이라 원인을
161
+ // 찾는 데 오래 걸렸다. 다음에 또 바뀌면 이 스냅샷만 보면 된다.
162
+ async function domSnapshot(page) {
163
+ return page.evaluate(() => ({
164
+ formImgs: document.querySelectorAll('form img').length,
165
+ forms: document.querySelectorAll('form').length,
166
+ composer: !!document.getElementById('prompt-textarea'),
167
+ // 값이 아니라 구조만 남긴다 — 로그에 개인 정보가 들어가면 안 된다.
168
+ fileInputs: [...document.querySelectorAll('input[type="file"]')].map((el) => ({
169
+ testid: el.dataset.testid || null,
170
+ accept: el.accept || null,
171
+ inForm: !!el.closest('form'),
172
+ })),
173
+ })).catch((e) => ({ error: e.message }));
174
+ }
175
+
176
+ // 워크스페이스의 logs/app.log 에 붙인다 — server.mjs 와 같은 파일, 같은 형식이라
177
+ // 빌더 첫 화면의 로그 버튼으로 그대로 받아진다.
178
+ function logDom(msg, data) {
179
+ try {
180
+ mkdirSync('logs', { recursive: true });
181
+ appendFileSync('logs/app.log', `[${new Date().toISOString()}] ${msg} ${JSON.stringify(data)}\n`);
182
+ } catch { /* 로그 실패가 생성을 막을 이유는 없다 */ }
183
+ }
184
+
157
185
  // 레퍼런스 첨부. **실패하면 던진다.**
158
186
  //
159
187
  // 예전에는 썸네일 확인이 실패해도 경고만 찍고 그냥 보냈다. 그러면 레퍼런스 없이 만들어진
@@ -167,36 +195,43 @@ async function attachRef(page, refPaths) {
167
195
  // form 밖으로 옮기면서 form 안에는 accept 없는 다른 input 만 남았다. 거기에
168
196
  // 파일을 넣으면 아무 일도 안 일어나고, 썸네일을 2분 기다리다 실패한다
169
197
  // (증상: "크롬은 떴는데 프롬프트를 입력하지 않는다").
198
+ const SELECTORS = [
199
+ 'input[type="file"][data-testid="upload-photos-input"]', // 현재 ChatGPT
200
+ 'input[type="file"][accept*="image"]', // testid 가 바뀌어도 살아남는 조건
201
+ 'input[type="file"]',
202
+ ];
170
203
  const pickInput = async () => {
171
- for (const sel of [
172
- 'input[type="file"][data-testid="upload-photos-input"]', // 현재 ChatGPT
173
- 'input[type="file"][accept*="image"]', // testid 가 바뀌어도 살아남는 조건
174
- 'input[type="file"]',
175
- ]) {
204
+ for (const sel of SELECTORS) {
176
205
  const h = await page.$(sel);
177
- if (h) return h;
206
+ if (h) return { sel, handle: h };
178
207
  }
179
208
  return null;
180
209
  };
181
210
 
182
211
  for (let attempt = 1; attempt <= 2; attempt++) {
183
- const input = await pickInput();
184
- if (!input) throw new Error('첨부용 파일 입력을 찾지 못했습니다 (ChatGPT 화면 구조가 바뀌었을 수 있음)');
185
- await input.uploadFile(...refPaths);
212
+ const picked = await pickInput();
213
+ if (!picked) {
214
+ logDom('첨부 input 없음', await domSnapshot(page));
215
+ throw new Error('첨부용 파일 입력을 찾지 못했습니다 (ChatGPT 화면 구조가 바뀌었을 수 있음)');
216
+ }
217
+ await picked.handle.uploadFile(...refPaths);
186
218
 
187
219
  // 한 장만 붙어도 통과시키면 안 된다. **올린 개수만큼** 붙을 때까지 기다린다.
188
220
  const ok = await page
189
221
  .waitForFunction((n) => document.querySelectorAll('form img').length >= n, { timeout: 60_000 }, want)
190
222
  .then(() => true).catch(() => false);
191
223
  if (ok) {
192
- console.log(` 레퍼런스 ${want}장 첨부 확인`);
224
+ console.log(` 레퍼런스 ${want}장 첨부 확인 (${picked.sel})`);
193
225
  return;
194
226
  }
195
- const got = await page.evaluate(() => document.querySelectorAll('form img').length);
196
- console.warn(` (재시도 ${attempt}/2) 첨부가 ${got}/${want}장만 확인됨`);
227
+ const snap = await domSnapshot(page);
228
+ console.warn(` (재시도 ${attempt}/2) 첨부가 ${snap.formImgs}/${want}장만 확인됨 — 쓴 input: ${picked.sel}`);
229
+ logDom(`첨부 실패 ${attempt}/2`, { used: picked.sel, ...snap });
197
230
  await new Promise((r) => setTimeout(r, 3000));
198
231
  }
199
- throw new Error(`레퍼런스 ${want}장을 첨부하지 못했습니다. 레퍼런스 없이 만들면 디자인이 달라지므로 중단합니다.`);
232
+ throw new Error(`레퍼런스 ${want}장을 첨부하지 못했습니다. 레퍼런스 없이 만들면 디자인이 달라지므로 중단합니다.\n`
233
+ + ` ChatGPT 화면 구조가 바뀌었을 수 있습니다. logs/app.log 의 "첨부 실패" 줄에\n`
234
+ + ` 그때 화면에 있던 file input 목록이 남습니다 — 빌더 첫 화면의 로그 버튼으로 받으세요.`);
200
235
  }
201
236
 
202
237
  async function sendPrompt(page, text) {
@@ -235,11 +270,17 @@ const readPageError = (page) => page.evaluate((needles) => {
235
270
  return (line || hit).trim().slice(0, 200);
236
271
  }, CHATGPT_ERRORS);
237
272
 
238
- async function waitForNewImage(page, before, timeoutMs) {
273
+ // used = 이 실행에서 **이미 저장에 쓴** 이미지들. before(이번 턴 직전 스냅샷)만으로는
274
+ // 부족하다: ChatGPT 가 위쪽 메시지를 DOM 에서 걷어냈다가 다시 붙이면 그 이미지가
275
+ // before 에 없어서 "새 것" 으로 잡힌다. 실제로 그렇게 2번 이미지가 3번 자리에도
276
+ // 저장돼, 3장만 만들어졌는데 4장이 나온 것처럼 끝난 적이 있다.
277
+ async function waitForNewImage(page, before, timeoutMs, used = new Set()) {
239
278
  const deadline = Date.now() + timeoutMs;
240
- let fresh = [], lastChange = 0;
279
+ let fresh = [], lastChange = 0, sawOnlyUsed = false;
241
280
  while (Date.now() < deadline) {
242
- const now = (await fileIds(page)).filter((id) => !before.includes(id));
281
+ const all = (await fileIds(page)).filter((id) => !before.includes(id));
282
+ const now = all.filter((id) => !used.has(id));
283
+ if (all.length && !now.length) sawOnlyUsed = true;
243
284
  if (!now.length) {
244
285
  // 이미지가 아직 없을 때만 본다. 이미 나왔으면 옛 대화의 문구를 오탐할 수 있다.
245
286
  const err = await readPageError(page);
@@ -255,6 +296,10 @@ async function waitForNewImage(page, before, timeoutMs) {
255
296
  await new Promise((r) => setTimeout(r, 3000));
256
297
  }
257
298
  if (fresh.length) return fresh[0]; // 타임아웃이어도 받은 게 있으면 쓴다
299
+ if (sawOnlyUsed) {
300
+ throw new Error('이 프롬프트로는 새 이미지가 안 나왔습니다 (화면에 있는 건 앞 장에 이미 쓴 것). '
301
+ + '앞 장을 복사해 넣으면 안 되므로 이 장은 실패로 둡니다');
302
+ }
258
303
  throw new Error(`이미지가 ${Math.round(timeoutMs / 1000)}초 안에 안 나옴`);
259
304
  }
260
305
 
@@ -409,6 +454,7 @@ async function main() {
409
454
 
410
455
  const summary = [];
411
456
  let primed = false; // 이 실행에서 스타일/레퍼런스를 실은 첫 장을 이미 보냈는가
457
+ const usedIds = new Set(); // 이미 저장에 쓴 이미지 — 같은 것을 두 장에 넣지 않기 위해
412
458
  const page = await browser.newPage();
413
459
  try {
414
460
  await page.goto(CHATGPT, { waitUntil: 'domcontentloaded', timeout: 120_000 });
@@ -444,7 +490,8 @@ async function main() {
444
490
  ).catch(() => console.warn('\n (경고) 레퍼런스 썸네일을 확인하지 못했습니다.'));
445
491
  before = await fileIds(page);
446
492
  }
447
- const fileId = await waitForNewImage(page, before, timeoutMs);
493
+ const fileId = await waitForNewImage(page, before, timeoutMs, usedIds);
494
+ usedIds.add(fileId);
448
495
  let buf = await downloadImage(page, fileId);
449
496
  const src = checkRatio(buf);
450
497
  if (mode === 'slide') buf = await cropTo45(buf, cropPage, logo);
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);