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 +5 -0
- package/README.md +24 -0
- package/index.html +16 -3
- package/package.json +1 -1
- package/scripts/chatgpt-image-gen.js +65 -18
- package/server.mjs +4 -1
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
|
-
|
|
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
|
@@ -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
|
|
184
|
-
if (!
|
|
185
|
-
|
|
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
|
|
196
|
-
console.warn(` (재시도 ${attempt}/2) 첨부가 ${
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|