sns-auto-builder 1.0.3 → 1.0.5
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 +32 -29
- package/README.md +191 -92
- package/bin/sns-auto-builder.mjs +45 -0
- package/index.html +67 -7
- package/package.json +1 -1
- package/scripts/build-dist-zip.mjs +90 -0
- package/scripts/chatgpt-image-gen.js +25 -1
- package/server.mjs +39 -9
package/CLAUDE.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# insta-carousel-builder — Claude Code 지시서
|
|
2
2
|
|
|
3
3
|
> 이 프로젝트는 Claude Code에서 직접 실행하는 인스타 캐러셀 자동 제작 도구입니다.
|
|
4
|
-
> 팀원이 "이 주제로 캐러셀 만들어줘"라고 요청하면 **리서치 →
|
|
4
|
+
> 팀원이 "이 주제로 캐러셀 만들어줘"라고 요청하면 **리서치 → 시나리오 설계 → 사람 승인 → 생성(HTML 또는 ChatGPT 웹) → 품질 검증**까지 수행합니다.
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
```
|
|
39
39
|
Phase 3a (HTML — 기본):
|
|
40
|
-
# 1. carousel-html-writer 가 output/<topic>/slides/slide-01~
|
|
40
|
+
# 1. carousel-html-writer 가 output/<topic>/slides/slide-01~NN.html 작성
|
|
41
41
|
# 2. node scripts/html-carousel-gen.js --topic <keyword>
|
|
42
42
|
|
|
43
43
|
Phase 3b (ChatGPT 웹 — 슬라이드 전체를 이미지로):
|
|
@@ -56,30 +56,30 @@ Phase 3c (ChatGPT 웹 — 배경만 뽑고 한글은 HTML 로 얹기):
|
|
|
56
56
|
|
|
57
57
|
---
|
|
58
58
|
|
|
59
|
-
## 기본 산출물 —
|
|
59
|
+
## 기본 산출물 — 장수는 요청에서 온다
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
- 각 슬라이드는 `templates/slides.*.json` 의 프롬프트로 정의
|
|
61
|
+
**빌더가 "장수: N장" 을 명시하면 그 수를 정확히 따른다.** 그게 최우선이다.
|
|
62
|
+
명시가 없을 때만 9장을 기본으로 한다. (빌더에서 3~12장 중 고를 수 있다.)
|
|
64
63
|
|
|
65
|
-
|
|
64
|
+
- 구성: Cover 1장 + 본문 (N-2)장 + Outro 1장
|
|
65
|
+
- 각 슬라이드는 `templates/slides.*.json` 의 프롬프트로 정의
|
|
66
66
|
|
|
67
67
|
---
|
|
68
68
|
|
|
69
|
-
## 품질 킬라인 (
|
|
69
|
+
## 품질 킬라인 (모든 장에 적용)
|
|
70
70
|
|
|
71
71
|
| # | 기준 | 허용 범위 |
|
|
72
72
|
|:---:|:---|:---|
|
|
73
|
-
| 1 | **해상도** | 1080×1350 의 **정수배** (4:5 인스타 권장).
|
|
74
|
-
| 2 | **한글 렌더링** | 오타 없어야
|
|
75
|
-
| 3 | **디자인 DNA 일관성** |
|
|
73
|
+
| 1 | **해상도** | 1080×1350 의 **정수배** (4:5 인스타 권장). 기본은 2x = 2160×2700. 전 장 배율 통일 필수 |
|
|
74
|
+
| 2 | **한글 렌더링** | 오타 없어야 함. **자동 검출 수단이 없다 — 반드시 육안 확인** |
|
|
75
|
+
| 3 | **디자인 DNA 일관성** | 전 장 배경색/폰트/액센트 컬러 통일 |
|
|
76
76
|
| 4 | **Cover 후킹** | 3초 내 스크롤 멈춤 가능한 헤드라인 |
|
|
77
77
|
| 5 | **1장당 정보 밀도** | 한 슬라이드 = 한 포인트 (여러 개 = 혼란) |
|
|
78
78
|
| 6 | **CTA 슬라이드** | Outro 에 저장/공유/팔로우 중 **1개만** |
|
|
79
79
|
| 7 | **금칙어** | `knowledge/banned-words.json` 참조 |
|
|
80
80
|
| 8 | **톤** | 친근한 전문가. "안녕하세요 오늘은" 같은 filler 금지 |
|
|
81
81
|
|
|
82
|
-
→ `scripts/quality-check.js` 가
|
|
82
|
+
→ `scripts/quality-check.js` 가 해상도·배율·장수를 검사한다. **한글 오타는 못 잡는다** (픽셀 안을 못 본다).
|
|
83
83
|
|
|
84
84
|
---
|
|
85
85
|
|
|
@@ -87,9 +87,9 @@ Phase 3c (ChatGPT 웹 — 배경만 뽑고 한글은 HTML 로 얹기):
|
|
|
87
87
|
|
|
88
88
|
**매 작업마다**:
|
|
89
89
|
1. `knowledge/brand-facts.md` — 수치/브랜드 SSOT (이 파일 외 숫자 금지)
|
|
90
|
-
2. `knowledge/patterns/carousel-structure.md` — 9장
|
|
90
|
+
2. `knowledge/patterns/carousel-structure.md` — 구조 공식 (9장 기준. 장수가 다르면 비례 조정)
|
|
91
91
|
3. `templates/slides.example.json` — 프롬프트 템플릿
|
|
92
|
-
4. `output
|
|
92
|
+
4. `output/` 의 최근 결과 폴더 — 직전 캐러셀과 **의도적으로 다른 구조**를 고른다 (없으면 넘어간다)
|
|
93
93
|
|
|
94
94
|
**상황별 추가 Read**:
|
|
95
95
|
- 주제 리서치: `knowledge/reference/` (있다면)
|
|
@@ -97,18 +97,19 @@ Phase 3c (ChatGPT 웹 — 배경만 뽑고 한글은 HTML 로 얹기):
|
|
|
97
97
|
|
|
98
98
|
---
|
|
99
99
|
|
|
100
|
-
##
|
|
100
|
+
## 파이프라인 (carousel-new 풀 파이프라인)
|
|
101
101
|
|
|
102
102
|
```
|
|
103
103
|
Phase 1 [리서치/기획] → carousel-researcher 서브에이전트 dispatch
|
|
104
|
-
주제 요약,
|
|
104
|
+
주제 요약, N장 구조 초안, hook 후보 생성
|
|
105
105
|
Phase 2 [프롬프트 설계] → carousel-prompt-writer 서브에이전트 dispatch
|
|
106
|
-
|
|
106
|
+
N장 프롬프트 JSON 작성 (templates/slides.{topic}.json)
|
|
107
107
|
Phase 3 [이미지 생성] → node scripts/chatgpt-image-gen.js 실행
|
|
108
|
-
로그인된 Chrome 으로 ChatGPT 를 몰아
|
|
109
|
-
Phase 4 [품질 검증] → scripts/quality-check.js
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
로그인된 Chrome 으로 ChatGPT 를 몰아 N장 PNG 생성 (API 키 불필요)
|
|
109
|
+
Phase 4 [품질 검증] → scripts/quality-check.js 실행 (해상도·배율·장수)
|
|
110
|
+
Phase 5 [정성 리뷰] → carousel-reviewer 가 review.md 작성
|
|
111
|
+
**결과를 사람에게 보여준 뒤 따로 돈다.** 빌더가 별도로 부르므로
|
|
112
|
+
생성 단계에서 미리 호출하지 마라. 리뷰어는 지적만 하고 고치지 않는다
|
|
112
113
|
```
|
|
113
114
|
|
|
114
115
|
**서브에이전트 원칙**:
|
|
@@ -122,10 +123,12 @@ Phase 4 [품질 검증] → scripts/quality-check.js 자동 실행
|
|
|
122
123
|
|
|
123
124
|
```
|
|
124
125
|
output/{topic}/
|
|
125
|
-
├── slide-01.png ~ slide-
|
|
126
|
-
├──
|
|
127
|
-
├──
|
|
128
|
-
|
|
126
|
+
├── slide-01.png ~ slide-NN.png # 2160×2700 PNG (1080×1350 의 2배)
|
|
127
|
+
├── brief.json # 리서치 결과 = 사람이 승인한 시나리오
|
|
128
|
+
├── gen.json # 실행 조건 (엔진·장수·레퍼런스) — 재생성에 쓰인다
|
|
129
|
+
├── caption.md # 인스타 본문 캡션
|
|
130
|
+
├── quality-report.json # 기계 검사 결과
|
|
131
|
+
└── review.md # 정성 채점 (결과를 보여준 뒤 따로 생성)
|
|
129
132
|
```
|
|
130
133
|
|
|
131
134
|
**명명 규칙**: `{YYYY-MM-DD}_{주제압축}` (공백 제거, 특수문자 금지)
|
|
@@ -135,8 +138,8 @@ output/{topic}/
|
|
|
135
138
|
## Zero-Inference 원칙
|
|
136
139
|
|
|
137
140
|
- 수치는 반드시 `brand-facts.md` 에서만 인용
|
|
138
|
-
-
|
|
139
|
-
-
|
|
141
|
+
- 한글 오타는 **자동 판정 수단이 없다.** 사람이 눈으로 본다
|
|
142
|
+
- `quality-check.js` 가 FAIL 이면 원인을 고치고 다시 만든다 (해상도·장수는 기계가 잡는다)
|
|
140
143
|
|
|
141
144
|
---
|
|
142
145
|
|
|
@@ -147,7 +150,7 @@ ChatGPT 구독 계정을 쓴다. `.env` 에 키가 있든 없든 이 트랙과
|
|
|
147
150
|
|
|
148
151
|
`.env` 파일 (git 추적 금지):
|
|
149
152
|
```
|
|
150
|
-
|
|
153
|
+
# 글·이미지 생성 모두 키가 필요 없다. 이 파일은 비어 있어도 된다.
|
|
151
154
|
```
|
|
152
155
|
|
|
153
156
|
### 의존성
|
|
@@ -167,7 +170,7 @@ npm install # puppeteer 자동 설치 — 이것만 하면 된다
|
|
|
167
170
|
- 생성된 이미지는 **반드시 육안 검토 후 업로드** — AI 이미지 특유의 미세 왜곡 가능
|
|
168
171
|
- 자동 업로드 없음 (의도적 배제 — 인스타 알고리즘 패턴 탐지 회피)
|
|
169
172
|
- 하루 1~2 캐러셀 권장 (과도한 업로드 역효과)
|
|
170
|
-
- 이미지 트랙은
|
|
173
|
+
- 이미지 트랙은 오타가 나올 수 있음. **에이전트가 임의로 다시 뽑지 마라** — 몇 번 장이 왜 이상한지 보고만 하고, 다시 뽑을 장은 사람이 결과 화면 버튼으로 고른다
|
|
171
174
|
- 한 장이 실패(응답 없음)해도 스크립트는 `ERR` 로 찍고 다음 장으로 넘어간다 → 나중에 `--only N`
|
|
172
175
|
|
|
173
176
|
---
|
package/README.md
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
1
|
# sns-auto-builder
|
|
2
2
|
|
|
3
|
-
카드뉴스(인스타 캐러셀
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
카드뉴스(인스타 캐러셀) / 쓰레드(Threads) 생성 도구.
|
|
4
|
+
|
|
5
|
+
**API 키가 필요 없습니다.** 글은 Claude Code CLI(`claude -p`), 이미지는 로그인된 Chrome 으로
|
|
6
|
+
ChatGPT 웹을 몰아 만듭니다 — 둘 다 **본인 구독 계정**으로 돕니다.
|
|
6
7
|
|
|
7
8
|
구조는 [insta-carousel-builder](https://github.com/shdsjh123-cpu/insta-carousel-builder) 하네스를
|
|
8
|
-
|
|
9
|
+
가져왔습니다. 원본 파일은 최대한 두고 부족한 부분을 별도 파일로 추가했습니다.
|
|
9
10
|
|
|
10
11
|
---
|
|
11
12
|
|
|
12
|
-
##
|
|
13
|
+
## 쓰는 법
|
|
14
|
+
|
|
15
|
+
### 배포판 (팀원용)
|
|
16
|
+
|
|
17
|
+
`dist/` 의 파일 **4개를 같은 폴더에** 두고 씁니다:
|
|
18
|
+
|
|
19
|
+
1. **설치.bat** — 처음 한 번. 저장 폴더 선택 → Node · Claude Code 설치 → 로그인 2번(Claude, ChatGPT)
|
|
20
|
+
2. **실행.bat** — 매번. 더블클릭하면 브라우저가 열립니다
|
|
21
|
+
3. **config.bat** — 설정. **두 bat 이 이것을 읽습니다**
|
|
22
|
+
4. **사용법.txt** — 안내
|
|
13
23
|
|
|
14
|
-
|
|
24
|
+
`실행.bat` 은 `npx sns-auto-builder@latest` 를 부릅니다. **켤 때마다 최신 버전을 받으므로
|
|
25
|
+
업데이트는 자동**입니다 (`npm publish` 하면 팀원은 다음 실행부터 새 버전).
|
|
26
|
+
|
|
27
|
+
단 `.bat` 자체는 패키지에 없어서 자동 갱신이 안 됩니다. 고치면 다시 보내야 합니다.
|
|
28
|
+
|
|
29
|
+
### 개발 (이 레포에서)
|
|
15
30
|
|
|
16
31
|
```bash
|
|
17
32
|
node server.mjs # → http://127.0.0.1:8787
|
|
18
33
|
```
|
|
19
34
|
|
|
20
|
-
|
|
35
|
+
Claude Code 안에서는 슬래시 커맨드도 됩니다:
|
|
21
36
|
|
|
22
37
|
```
|
|
23
38
|
/carousel-new 클로드 코드 생산성 꿀팁
|
|
@@ -25,47 +40,138 @@ node server.mjs # → http://127.0.0.1:8787
|
|
|
25
40
|
```
|
|
26
41
|
|
|
27
42
|
웹 서버는 파이프라인을 복제하지 않고 `claude -p` 로 **같은 서브에이전트를 호출**합니다.
|
|
28
|
-
`CLAUDE.md` 가 단일 출처입니다.
|
|
43
|
+
`CLAUDE.md` 가 단일 출처입니다. 포트 충돌 시 `PORT=8788 node server.mjs`.
|
|
44
|
+
|
|
45
|
+
### 데이터가 쌓이는 곳
|
|
46
|
+
|
|
47
|
+
배포판은 패키지 밖 **워크스페이스**에 사용자 데이터를 둡니다. 그래서 업데이트해도
|
|
48
|
+
브랜드 설정 · ChatGPT 로그인 · 결과물이 그대로 남습니다.
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
<설치.bat 에서 고른 폴더>\ ← 기본값은 %LOCALAPPDATA%\sns-auto-builder
|
|
52
|
+
├── workspace 파일들 (output, knowledge, .chrome-profile, logs …)
|
|
53
|
+
├── browser\ puppeteer 크로미움 (약 2.6GB)
|
|
54
|
+
└── npm-cache\ npx 캐시 (수 GB)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`설치.bat` 1단계가 폴더 선택 창을 띄우고, 고른 경로를 `config.bat` 의
|
|
58
|
+
`set "DATA_PATH="` 줄에 써넣습니다. 바꾸려면 `설치.bat` 을 다시 실행하면 됩니다.
|
|
59
|
+
기존 워크스페이스는 최초 1회 자동으로 옮겨집니다.
|
|
60
|
+
|
|
61
|
+
`config.bat` 을 손으로 고치지 마세요. 이 파일은 **UTF-8(BOM 없음) + CRLF** 여야 하고,
|
|
62
|
+
둘 중 하나만 어긋나면 cmd 가 한글 섞인 줄을 잘못 읽어 **설정이 조용히 무시**됩니다
|
|
63
|
+
(LF 로 저장된 적이 있었고, 그때 한글 경로가 통째로 날아갔습니다).
|
|
64
|
+
`설치.bat` 은 PowerShell `WriteAllLines` 로 써서 이 조합을 보장합니다.
|
|
65
|
+
잘못돼도 빌더 첫 화면에 **실제 사용 중인 폴더가 항상 표시**되므로 눈에 띕니다.
|
|
66
|
+
|
|
67
|
+
**USB·외장 드라이브, 다운로드 폴더, OneDrive 동기화 폴더는 피하세요** —
|
|
68
|
+
각각 분리 시 로그인 소실, 정리 중 삭제, 2.6GB 크로미움 무한 동기화 문제가 있습니다.
|
|
29
69
|
|
|
30
|
-
|
|
70
|
+
> **경로는 한 곳에서만 계산합니다.** 예전에는 `실행.bat` · `설치.bat` · 스크립트가
|
|
71
|
+
> 각자 경로를 계산했고, 한쪽만 바뀌자 **ChatGPT 로그인이 빌더가 안 보는 폴더에 저장**됐습니다.
|
|
72
|
+
> 지금은 두 bat 이 `config.bat` 을 읽고, 크롬 창은 빌더 자신이 엽니다.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npx sns-auto-builder --where # 실제 워크스페이스·프로필·크롬 경로 출력
|
|
76
|
+
npx sns-auto-builder --login # 빌더가 읽는 그 프로필로 ChatGPT 로그인 창 열기
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`--login` 은 **디버깅 포트를 붙여** 크롬을 엽니다. 포트 없이 뜬 창이 그 프로필을 잡고 있으면
|
|
80
|
+
Chrome 은 인스턴스를 하나만 허용하므로 빌더가 영영 포트를 못 엽니다.
|
|
31
81
|
|
|
32
82
|
---
|
|
33
83
|
|
|
34
84
|
## 카드뉴스 엔진 3개
|
|
35
85
|
|
|
36
|
-
|
|
86
|
+
카드뉴스를 고르면 엔진을 묻습니다. 한글 정확도가 여기서 갈립니다. **셋 다 키는 불필요**합니다.
|
|
37
87
|
|
|
38
|
-
| 엔진 | 한글 |
|
|
39
|
-
|
|
40
|
-
| **HTML만** | **100% 결정적** |
|
|
41
|
-
| **이미지 + HTML** | **100% 결정적** |
|
|
42
|
-
| **이미지만** | 비결정적 ⚠️ |
|
|
88
|
+
| 엔진 | 한글 | 어울리는 곳 |
|
|
89
|
+
|:--|:--|:--|
|
|
90
|
+
| **HTML만** | **100% 결정적** | 세법·법무·정보성. 한 글자만 고쳐 재캡처 가능 |
|
|
91
|
+
| **이미지 + HTML** | **100% 결정적** | 배경만 이미지로 뽑고 글자는 HTML. 분위기가 필요한 주제 |
|
|
92
|
+
| **이미지만** | 비결정적 ⚠️ | 글자가 적은 비주얼 위주 |
|
|
43
93
|
|
|
44
94
|
**이미지만 모드는 한글이 이미지 안에 들어가 매번 달라집니다.** 스크립트가 오타를 못 잡으니
|
|
45
|
-
|
|
95
|
+
눈으로 확인하고, 깨진 장은 결과 화면의 **다시 만들기** 버튼으로 그 장만 다시 뽑으세요.
|
|
46
96
|
|
|
47
|
-
|
|
97
|
+
장수는 **3~12장** 중 고를 수 있고, 고르지 않으면 9장입니다.
|
|
98
|
+
쓰레드는 글만 뽑으므로 엔진·장수 선택이 없습니다.
|
|
48
99
|
|
|
49
100
|
---
|
|
50
101
|
|
|
51
102
|
## 파이프라인
|
|
52
103
|
|
|
53
104
|
```
|
|
54
|
-
리서치
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
105
|
+
1. 리서치 carousel-researcher → brief.json (검증 사실 / 불확실 분리)
|
|
106
|
+
↓
|
|
107
|
+
2. 사람이 승인 "이렇게 만들까요?" 문구를 직접 고치거나 AI 추천을 받아 교체
|
|
108
|
+
↓
|
|
109
|
+
3. 생성 carousel-html-writer → slides/slide-NN.html → html-carousel-gen.js
|
|
110
|
+
또는 carousel-prompt-writer → templates/slides.<topic>.json → chatgpt-image-gen.js
|
|
111
|
+
(배경만 뽑을 땐 chatgpt-image-gen.js --mode bg)
|
|
112
|
+
↓
|
|
113
|
+
4. 검사 quality-check.js → 해상도·배율·장수 (기계, 즉시)
|
|
114
|
+
↓
|
|
115
|
+
결과 화면을 먼저 보여준 뒤
|
|
116
|
+
5. 리뷰 carousel-reviewer → review.md (10항목 채점, 백그라운드)
|
|
66
117
|
```
|
|
67
118
|
|
|
68
|
-
|
|
119
|
+
**2번에서 한 번 멈춥니다.** 이미지에 시간·쿼터를 쓰기 전에 시나리오를 확인·수정하는 자리입니다.
|
|
120
|
+
|
|
121
|
+
**5번은 결과를 보여준 뒤 뒤에서 돕니다.** 이미지를 눈으로 훑는 작업이라 10분 넘게 걸리는데,
|
|
122
|
+
그걸 기다리느라 결과 화면이 늦어지면 안 됩니다. 끝나면 결과 화면에 점수와 함께 접혀서 붙습니다.
|
|
123
|
+
|
|
124
|
+
리뷰어는 **지적만** 합니다. 어느 장을 다시 뽑을지는 사람이 버튼으로 정합니다.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 웹 UI
|
|
129
|
+
|
|
130
|
+
1. **⚙ 브랜드 컨셉 설정** (처음 한 번) — 브랜드명만 필수, 나머지는 비워도 됩니다
|
|
131
|
+
2. **카드뉴스** 또는 **쓰레드**
|
|
132
|
+
3. (카드뉴스) 엔진 선택 — 여기서 **📁 결과물 보기** 로 지난 작업을 열람·삭제
|
|
133
|
+
4. 장수 선택 · 참고 자료 · 로고 · 주제 입력 (`⌘/Ctrl + Enter` 로도 시작)
|
|
134
|
+
5. **"이렇게 만들까요?"** — 문구 수정 / ✨ AI 추천 / 장수 바꿔 다시
|
|
135
|
+
6. 생성 — 완성되는 대로 **한 장씩 실시간**으로 뜹니다
|
|
136
|
+
7. 결과 — 인스타 목업 스와이프 미리보기 · 다시 만들기 · **한번에 받기**(zip)
|
|
137
|
+
|
|
138
|
+
진행 표시는 **모델 보고가 아니라 파일이 생겼는지로 판정**합니다. 모델은 실제 작업 전에
|
|
139
|
+
"done" 을 찍은 적이 있어서 믿지 않습니다.
|
|
140
|
+
|
|
141
|
+
카드뉴스는 리서치 5~8분 + 이미지 장당 25~40초. 쓰레드는 1~2분.
|
|
142
|
+
|
|
143
|
+
### 브랜드 설정
|
|
144
|
+
|
|
145
|
+
값은 `knowledge/brand.json` 에 저장되고 `brand-facts.md` 가 **자동 생성**됩니다.
|
|
146
|
+
그래서 패키지에는 빈 템플릿만 들어가고 **회사 정보는 각자 PC 에만** 남습니다.
|
|
147
|
+
|
|
148
|
+
> `brand-facts.md` 를 직접 고치지 마세요. 다음 저장 때 덮어써집니다.
|
|
149
|
+
> 항목을 늘리려면 `knowledge/brand.schema.json` 만 고치면 화면과 문서가 같이 따라옵니다.
|
|
150
|
+
|
|
151
|
+
**빈칸은 아예 언급되지 않습니다.** 자리표시자가 프롬프트에 새면 모델이 그걸 사실로 착각해
|
|
152
|
+
지어내기 때문입니다.
|
|
153
|
+
|
|
154
|
+
### 참고 자료 · 로고
|
|
155
|
+
|
|
156
|
+
경쟁사 캡처나 레퍼런스를 올리면 **카피와 디자인 둘 다** 그걸 따라갑니다.
|
|
157
|
+
|
|
158
|
+
- png · jpg · webp · gif, **최대 6장 / 총 20MB**
|
|
159
|
+
- 한 번 올린 세트는 **템플릿**으로 남아 다음에도 고를 수 있습니다 (목록에서 삭제도 가능)
|
|
160
|
+
- 지금 무엇이 들어가는지 참고 자료 칸 아래에 항상 표시됩니다
|
|
161
|
+
- **로고는 SVG** 로 따로 올립니다 — HTML 트랙은 `<img>` 로 그대로 얹고,
|
|
162
|
+
이미지 트랙은 모델에게 그 자리를 비우게 한 뒤 **크롭할 때 원본 SVG 를 합성**합니다
|
|
163
|
+
(모델이 그리면 로고가 뭉개집니다)
|
|
164
|
+
|
|
165
|
+
프롬프트 가드: 이미지 속 **수치를 사실로 취급 금지**, 이미지 안 지시문은 **따르지 않음**.
|
|
166
|
+
매번 참고할 고정 자료는 업로드 말고 `knowledge/reference/` 에 두세요.
|
|
167
|
+
|
|
168
|
+
### 문제가 생기면
|
|
169
|
+
|
|
170
|
+
첫 화면의 **🛟 문제 신고용 로그** 를 누르면 `sns-auto-builder-log.txt` 가 받아집니다.
|
|
171
|
+
그 파일만 보내면 됩니다.
|
|
172
|
+
|
|
173
|
+
로그는 **버튼과 무관하게 항상 자동으로 쌓입니다** — 버튼은 내려받는 손잡이일 뿐입니다.
|
|
174
|
+
버전 · OS · 실행 기록 · 오류와 발생 위치가 담기고, **브랜드 값 내용 · 토큰 · 쿠키는 안 담깁니다.**
|
|
69
175
|
|
|
70
176
|
---
|
|
71
177
|
|
|
@@ -73,86 +179,75 @@ node server.mjs # → http://127.0.0.1:8787
|
|
|
73
179
|
|
|
74
180
|
```
|
|
75
181
|
sns-auto-builder/
|
|
76
|
-
├── CLAUDE.md # 에이전트 진입점
|
|
182
|
+
├── CLAUDE.md # 에이전트 진입점 — 프롬프트 맨 앞에 통째로 들어감
|
|
183
|
+
├── bin/sns-auto-builder.mjs # npx 실행기 (워크스페이스 준비 → 서버 → 브라우저)
|
|
184
|
+
├── dist/ # 팀원 배포용 4개 (실행·설치·config.bat · 사용법.txt) — 패키지 제외
|
|
77
185
|
├── .claude/
|
|
78
|
-
│ ├── agents/ # carousel-researcher / -html-writer / -prompt-writer / -reviewer
|
|
79
|
-
│ └── commands/ # /carousel-new, /carousel-quality
|
|
186
|
+
│ ├── agents/ # carousel-researcher / -html-writer / -prompt-writer / -reviewer
|
|
187
|
+
│ └── commands/ # /carousel-new, /carousel-quality
|
|
80
188
|
├── knowledge/ # ★ 프롬프트 단일 출처
|
|
81
|
-
│ ├── brand
|
|
82
|
-
│ ├──
|
|
83
|
-
│
|
|
84
|
-
│
|
|
85
|
-
│
|
|
86
|
-
│
|
|
189
|
+
│ ├── brand.schema.json # 브랜드 설정 폼 정의 (화면 + 문서가 같이 참조)
|
|
190
|
+
│ ├── brand.json # 입력된 값 (gitignored·패키지 제외)
|
|
191
|
+
│ ├── brand-facts.md # 위 값에서 자동 생성 (gitignored·패키지 제외)
|
|
192
|
+
│ ├── logo.svg # 올린 로고 (gitignored·패키지 제외)
|
|
193
|
+
│ ├── banned-words.json # 금칙어
|
|
194
|
+
│ └── patterns/ # 구조 공식 · 출력 형식 · 스레드 공식
|
|
87
195
|
├── scripts/
|
|
88
|
-
│ ├── quality-check.js #
|
|
89
|
-
│ ├── quality-check-text.js # 카피 금칙어·글자수·구조
|
|
90
|
-
│ ├── html-carousel-gen.js # HTML → PNG 캡처
|
|
91
|
-
│ ├── chatgpt-image-gen.js # ChatGPT
|
|
92
|
-
│ ├── openai-image-gen.js # OpenAI API
|
|
93
|
-
│ └── nanobanana-gen.py #
|
|
94
|
-
├── templates/
|
|
95
|
-
├── output/ #
|
|
96
|
-
├──
|
|
196
|
+
│ ├── quality-check.js # PNG 해상도/배율/장수 + 프롬프트 JSON 스키마
|
|
197
|
+
│ ├── quality-check-text.js # 카피 금칙어·글자수·구조
|
|
198
|
+
│ ├── html-carousel-gen.js # HTML → PNG 캡처
|
|
199
|
+
│ ├── chatgpt-image-gen.js # ChatGPT 웹으로 이미지 생성 (키 불필요)
|
|
200
|
+
│ ├── openai-image-gen.js # OpenAI API 트랙. 빌더는 안 쓰고 cropTo45 만 재사용
|
|
201
|
+
│ └── nanobanana-gen.py # 업스트림 잔재. 이 환경에서는 쓰지 않음
|
|
202
|
+
├── templates/ # *.example.json 만 패키지에 포함
|
|
203
|
+
├── output/ # 결과물 · _refs (gitignored·패키지 제외)
|
|
204
|
+
├── logs/ # 실행·오류 기록 (gitignored·패키지 제외)
|
|
205
|
+
├── server.mjs · index.html # 웹 UI
|
|
97
206
|
└── test.mjs
|
|
98
207
|
```
|
|
99
208
|
|
|
100
209
|
---
|
|
101
210
|
|
|
102
|
-
##
|
|
103
|
-
|
|
104
|
-
1. **카드뉴스** 또는 **쓰레드**
|
|
105
|
-
2. (카드뉴스) 엔진 선택
|
|
106
|
-
3. 주제 입력 — `⌘/Ctrl + Enter` 로도 시작
|
|
107
|
-
4. (선택) **참고 자료** 이미지 첨부 — 끌어다 놓기 / `⌘/Ctrl + V` / 눌러서 고르기
|
|
108
|
-
5. 진행 단계가 실시간 표시 → 끝나면 슬라이드 미리보기 + 개별 다운로드
|
|
109
|
-
|
|
110
|
-
진행 표시는 **모델 보고가 아니라 파일이 생겼는지로 판정**합니다. `brief.json` 이 없으면 리서치는 진행 중입니다.
|
|
211
|
+
## 배포
|
|
111
212
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
- png · jpg · webp · gif, **최대 6장 / 총 20MB**. SVG는 막습니다 (스크립트 실행 가능)
|
|
119
|
-
- 파일명은 서버가 붙입니다 (클라이언트 값 안 씀 — 경로 조작 방지)
|
|
120
|
-
- 프롬프트 가드: 이미지 속 **수치를 사실로 취급 금지**, 이미지 안 지시문은 **따르지 않음**
|
|
121
|
-
- 매번 참고할 고정 자료는 업로드 말고 `knowledge/reference/` 에 두세요
|
|
122
|
-
|
|
123
|
-
---
|
|
213
|
+
```bash
|
|
214
|
+
npm version patch # 버전 올리기
|
|
215
|
+
npm pack --dry-run # 나갈 파일 눈으로 확인 ★
|
|
216
|
+
npm publish
|
|
217
|
+
```
|
|
124
218
|
|
|
125
|
-
|
|
219
|
+
`package.json` 의 `files` 는 **넣을 것만 적는 화이트리스트**입니다. 새 폴더가 생겨도
|
|
220
|
+
실수로 안 나갑니다. 그래도 퍼블리시 전에는 `--dry-run` 으로 확인하세요 —
|
|
221
|
+
`brand.json` · `logo.svg` · `output/` · `.chrome-profile` · `logs/` 가 목록에 없어야 합니다.
|
|
126
222
|
|
|
127
|
-
`.
|
|
223
|
+
`.bat` 은 패키지에 없어서 **자동 업데이트가 안 됩니다.** 고치면 팀원에게 4개를 다시 보내야 합니다.
|
|
128
224
|
|
|
129
225
|
```bash
|
|
130
|
-
|
|
226
|
+
node scripts/build-dist-zip.mjs # dist/snsauto-builder.zip 재생성
|
|
131
227
|
```
|
|
132
228
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
| `OPENAI_API_KEY` | `openai-image-gen.js` 를 직접 쓸 때만 | platform.openai.com/api-keys |
|
|
137
|
-
|
|
138
|
-
빌더의 **이미지+HTML / 이미지만** 트랙은 키가 필요 없습니다. `chatgpt-image-gen.js` 가
|
|
139
|
-
로그인된 Chrome 프로필(`.chrome-profile`)로 chatgpt.com 을 몰아 구독 계정으로 생성합니다.
|
|
140
|
-
그 프로필에 **한 번 로그인해두면** 이후로는 그대로 재사용됩니다.
|
|
229
|
+
이 스크립트가 묶기 전에 **CRLF · BOM 없음 · 명령부 ASCII** 를 검사하고, 어긋나면
|
|
230
|
+
멈춥니다. zip 은 한글 파일명에 UTF-8 플래그(0x800)를 직접 세웁니다
|
|
231
|
+
(`Compress-Archive` 는 이걸 안 세워서 푸는 쪽에 따라 이름이 깨집니다).
|
|
141
232
|
|
|
142
|
-
`.
|
|
233
|
+
> `.bat` 의 **명령과 주석은 ASCII 로만** 쓰세요. cmd 는 콘솔 코드페이지로 파일을 읽는데
|
|
234
|
+
> 그 값이 PC 마다 달라서, 한글 바이트가 명령어를 갉아먹습니다
|
|
235
|
+
> (`echo.` 가 `ech` + `o.` 로 쪼개진 적 있음). 한글 안내는 `사용법.txt` 와 Node 출력이 담당합니다.
|
|
236
|
+
> 예외는 `config.bat` 의 `set "DATA_PATH=..."` 값 한 줄뿐이고,
|
|
237
|
+
> 그건 파일이 UTF-8 + CRLF 일 때만 안전합니다.
|
|
143
238
|
|
|
144
239
|
---
|
|
145
240
|
|
|
146
|
-
##
|
|
241
|
+
## 키 설정 — 대부분 필요 없습니다
|
|
147
242
|
|
|
148
|
-
|
|
243
|
+
빌더의 세 엔진 모두 키 없이 돕니다. `.env` 는 아래 경우에만 씁니다.
|
|
244
|
+
|
|
245
|
+
| 키 | 언제 |
|
|
149
246
|
|:--|:--|
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
152
|
-
| `knowledge/patterns/*.md` | 구조 공식, 글자수, 출력 형식 |
|
|
153
|
-
| `scripts/html-carousel-gen.js` | `CONFIG.deviceScaleFactor` — 배율 (기본 2x) |
|
|
247
|
+
| `OPENAI_API_KEY` | `openai-image-gen.js` 를 직접 호출할 때만 |
|
|
248
|
+
| `GEMINI_API_KEY` | `nanobanana-gen.py` (이 환경에서는 안 씀) |
|
|
154
249
|
|
|
155
|
-
`
|
|
250
|
+
`.env` 는 gitignored 입니다. **키를 `.env.example` 에 넣지 마세요.**
|
|
156
251
|
|
|
157
252
|
---
|
|
158
253
|
|
|
@@ -162,17 +257,21 @@ cp .env.example .env
|
|
|
162
257
|
node test.mjs # 조립·검증 로직
|
|
163
258
|
node scripts/quality-check.js --dir output/<topic> # PNG 해상도·배율·장수
|
|
164
259
|
node scripts/quality-check.js --prompt templates/slides.<topic>.json
|
|
165
|
-
node scripts/quality-check-text.js --file output/<topic>/copy.md
|
|
166
260
|
node scripts/chatgpt-image-gen.js --topic t --slides templates/slides.example.json --dry-run
|
|
167
261
|
```
|
|
168
262
|
|
|
169
|
-
**해상도**: `1080×1350` 의 정수배만 통과합니다.
|
|
263
|
+
**해상도**: `1080×1350` 의 정수배만 통과합니다. 기본이 2x(`2160×2700`)이고,
|
|
170
264
|
인스타는 업로드 시 가로 1080으로 줄이므로 2x가 손해가 아닙니다.
|
|
171
265
|
|
|
266
|
+
`quality-check.js` 의 기대 장수는 `--count` 또는 결과 폴더의 `gen.json` 에서 읽습니다.
|
|
267
|
+
|
|
172
268
|
---
|
|
173
269
|
|
|
174
270
|
## 안 하는 것
|
|
175
271
|
|
|
176
272
|
- **자동 업로드** — 의도적 배제 (알고리즘 패턴 탐지 회피)
|
|
177
|
-
- **한글 오타 자동 검출** —
|
|
178
|
-
-
|
|
273
|
+
- **한글 오타 자동 검출** — 이미지 트랙은 육안 검수가 필수입니다
|
|
274
|
+
- **에이전트가 알아서 재생성** — 확인 못 한 상태로 덮어쓰면 멀쩡한 장을 잃습니다.
|
|
275
|
+
다시 뽑을 장은 사람이 버튼으로 고릅니다
|
|
276
|
+
- 툴 UI의 크림/Jua 배색은 **툴 자체 컨셉**입니다. 카드뉴스 디자인은
|
|
277
|
+
레퍼런스 > 브랜드 설정 > 기본값 순으로 정해집니다
|
package/bin/sns-auto-builder.mjs
CHANGED
|
@@ -94,7 +94,52 @@ const open = (url) => {
|
|
|
94
94
|
spawn(cmd[0], cmd[1], { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
+
// 설치된 Chrome. puppeteer 번들(구버전)은 프로필 버전이 안 맞아 바로 죽는다.
|
|
98
|
+
function findChrome() {
|
|
99
|
+
const c = [
|
|
100
|
+
process.env.PROGRAMFILES && join(process.env.PROGRAMFILES, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
101
|
+
process.env['PROGRAMFILES(X86)'] && join(process.env['PROGRAMFILES(X86)'], 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
102
|
+
process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
103
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
104
|
+
'/usr/bin/google-chrome',
|
|
105
|
+
].filter(Boolean);
|
|
106
|
+
return c.find((p) => existsSync(p)) || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
97
109
|
const ws = workspaceDir();
|
|
110
|
+
|
|
111
|
+
// --where / --login 은 **경로 계산을 여기 한 곳으로 모으기 위한** 진입점이다.
|
|
112
|
+
// .bat 이 경로를 따로 계산하면 한쪽만 바뀌었을 때 로그인이 빌더가 안 보는 폴더에 저장된다
|
|
113
|
+
// (실제로 그 사고가 났다). .bat 은 이제 이 명령에 물어본다.
|
|
114
|
+
const argv = process.argv.slice(2);
|
|
115
|
+
if (argv.includes('--where')) {
|
|
116
|
+
console.log(`workspace : ${ws}`);
|
|
117
|
+
console.log(`profile : ${join(ws, '.chrome-profile')}`);
|
|
118
|
+
console.log(`chrome : ${findChrome() || '(찾지 못함)'}`);
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
if (argv.includes('--login')) {
|
|
122
|
+
const profile = join(ws, '.chrome-profile');
|
|
123
|
+
mkdirSync(profile, { recursive: true });
|
|
124
|
+
const exe = findChrome();
|
|
125
|
+
if (!exe) {
|
|
126
|
+
console.error('Chrome 을 찾지 못했습니다. Chrome 을 설치한 뒤 다시 실행하세요.');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
console.log(`\n ChatGPT 로그인 창을 엽니다.`);
|
|
130
|
+
console.log(` 프로필: ${profile}`);
|
|
131
|
+
console.log(` 로그인만 하고 창은 그대로 두거나 닫으면 됩니다.\n`);
|
|
132
|
+
// 포트를 반드시 붙인다. Chrome 은 user-data-dir 당 인스턴스 하나만 띄우므로,
|
|
133
|
+
// 포트 없이 뜬 창이 남으면 빌더가 그 프로필로 포트를 못 연다.
|
|
134
|
+
spawn(exe, [
|
|
135
|
+
'--remote-debugging-port=9222',
|
|
136
|
+
`--user-data-dir=${profile}`,
|
|
137
|
+
'--no-first-run', '--no-default-browser-check',
|
|
138
|
+
'https://chatgpt.com',
|
|
139
|
+
], { detached: true, stdio: 'ignore' }).unref();
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
|
|
98
143
|
mkdirSync(ws, { recursive: true });
|
|
99
144
|
syncTools(ws);
|
|
100
145
|
linkModules(ws);
|
package/index.html
CHANGED
|
@@ -245,6 +245,8 @@ button.go:focus-visible, button.quiet:focus-visible, .copy:focus-visible { outli
|
|
|
245
245
|
}
|
|
246
246
|
.sug-opt:hover { border-color:var(--accent); color:var(--accent); }
|
|
247
247
|
.sug-opt:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
|
248
|
+
.scenario .ed-role { font-size:11.5px; font-weight:700; color:var(--accent); letter-spacing:.03em; margin:2px 0 8px 8px; }
|
|
249
|
+
.scenario .ed-cap { font-size:11.5px; font-weight:600; color:var(--ink-2); margin:8px 0 2px 8px; }
|
|
248
250
|
.scenario .ed-sub { font-size:13.5px; color:var(--ink-2); line-height:1.5; margin-top:2px; }
|
|
249
251
|
.scenario textarea::placeholder { color:#9a8b7a; }
|
|
250
252
|
|
|
@@ -380,10 +382,13 @@ button.go:focus-visible, button.quiet:focus-visible, .copy:focus-visible { outli
|
|
|
380
382
|
</div>
|
|
381
383
|
<div class="bar">
|
|
382
384
|
<button class="quiet" id="brandOpen">⚙ 브랜드 컨셉 설정</button>
|
|
385
|
+
<button class="quiet" id="openHome" title="결과물이 저장되는 폴더를 엽니다">📂 작업 폴더 열기</button>
|
|
383
386
|
<a class="quiet dl" id="logDl" href="/logs" download
|
|
384
387
|
title="문제가 생기면 이 파일을 담당자에게 보내세요">🛟 문제 신고용 로그</a>
|
|
385
388
|
<span class="fine" id="brandNow"></span>
|
|
386
389
|
</div>
|
|
390
|
+
<!-- 설정이 조용히 무시돼도 여기서 바로 드러나게 실제 경로를 보여준다 -->
|
|
391
|
+
<p class="fine" id="homePath"></p>
|
|
387
392
|
</section>
|
|
388
393
|
|
|
389
394
|
<!-- 1.2 브랜드 설정 — 여기 값이 모든 카피·디자인의 기준이 된다 -->
|
|
@@ -1080,13 +1085,50 @@ function renderPlan(p) {
|
|
|
1080
1085
|
return row;
|
|
1081
1086
|
};
|
|
1082
1087
|
|
|
1088
|
+
// 리서처가 쓰는 키 이름이 브리프마다 다르다 (headline/sub, subcopy, core_message,
|
|
1089
|
+
// card_items …). 키를 고정해 찾으면 실제 카피가 화면에 안 뜨고 수정도 못 한다.
|
|
1090
|
+
// 그래서 **그 슬라이드가 실제로 가진 글자 필드**를 그대로 그린다.
|
|
1091
|
+
const SKIP = new Set(['n', 'role', 'source', 'notes', 'type']);
|
|
1092
|
+
const LABEL = {
|
|
1093
|
+
headline: '헤드라인', core_message: '핵심 문구', title: '제목', hook: '훅',
|
|
1094
|
+
sub: '설명', subcopy: '설명', label: '라벨',
|
|
1095
|
+
body_points: '본문 포인트 (한 줄에 하나)', card_items: '카드 항목 (한 줄에 하나)',
|
|
1096
|
+
cta: 'CTA', verse_text: '인용문', verse_ref: '출처',
|
|
1097
|
+
};
|
|
1098
|
+
const isBig = (k) => /headline|core_message|title|hook/i.test(k);
|
|
1099
|
+
|
|
1083
1100
|
p.slides.forEach((s) => {
|
|
1084
1101
|
const li = document.createElement('li');
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1102
|
+
|
|
1103
|
+
const keys = Object.keys(s).filter((k) => !SKIP.has(k)
|
|
1104
|
+
&& (typeof s[k] === 'string' || (Array.isArray(s[k]) && s[k].every((v) => typeof v === 'string'))));
|
|
1105
|
+
|
|
1106
|
+
// 글자 필드가 하나도 없으면 최소한 역할이라도 보여준다 (빈 카드 방지)
|
|
1107
|
+
if (!keys.length) {
|
|
1108
|
+
const only = document.createElement('div');
|
|
1109
|
+
only.className = 'fine';
|
|
1110
|
+
only.textContent = `${s.role || ''} — 수정할 문구가 없습니다`;
|
|
1111
|
+
li.append(only);
|
|
1112
|
+
host.append(li);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (s.role) {
|
|
1117
|
+
const role = document.createElement('div');
|
|
1118
|
+
role.className = 'ed-role';
|
|
1119
|
+
role.textContent = s.role;
|
|
1120
|
+
li.append(role);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
for (const k of keys) {
|
|
1124
|
+
const cap = document.createElement('label');
|
|
1125
|
+
cap.className = 'ed-cap';
|
|
1126
|
+
cap.textContent = LABEL[k] || k;
|
|
1127
|
+
li.append(cap);
|
|
1128
|
+
const val = Array.isArray(s[k]) ? s[k].join('\n') : s[k];
|
|
1129
|
+
const box = field(val, isBig(k) ? 'ed-head' : 'ed-sub', '', k, s.n);
|
|
1130
|
+
li.append(box, suggestRow(box, s.n, k));
|
|
1131
|
+
}
|
|
1090
1132
|
host.append(li);
|
|
1091
1133
|
});
|
|
1092
1134
|
|
|
@@ -1101,8 +1143,9 @@ async function savePlanEdits() {
|
|
|
1101
1143
|
const byN = new Map();
|
|
1102
1144
|
$('scenario').querySelectorAll('textarea').forEach((t) => {
|
|
1103
1145
|
const n = Number(t.dataset.n);
|
|
1104
|
-
if (!byN.has(n)) byN.set(n, { n });
|
|
1105
|
-
|
|
1146
|
+
if (!byN.has(n)) byN.set(n, { n, fields: {} });
|
|
1147
|
+
// 키 이름은 브리프에서 온 그대로 되돌려준다. 서버가 원래 있던 키에만 되쓴다.
|
|
1148
|
+
byN.get(n).fields[t.dataset.key] = t.value.trim();
|
|
1106
1149
|
});
|
|
1107
1150
|
const res = await fetch('/plan', {
|
|
1108
1151
|
method: 'POST',
|
|
@@ -1514,6 +1557,23 @@ async function openOutput(it) {
|
|
|
1514
1557
|
$('outsOpen').onclick = () => { loadOutputs(); show('outs'); };
|
|
1515
1558
|
$('outsBack').onclick = () => show('engine');
|
|
1516
1559
|
|
|
1560
|
+
// 데이터는 bat 옆이 아니라 안정된 위치에 쌓인다 (새 zip 을 다른 곳에 풀어도 살아남게).
|
|
1561
|
+
// 대신 어디인지 보이고, 눌러서 열 수 있어야 한다.
|
|
1562
|
+
$('openHome').onclick = async () => {
|
|
1563
|
+
try {
|
|
1564
|
+
const r = await fetch('/home/open', { method: 'POST' });
|
|
1565
|
+
if (!r.ok) throw new Error('열지 못했습니다');
|
|
1566
|
+
} catch (e) { alert(e.message); }
|
|
1567
|
+
};
|
|
1568
|
+
|
|
1569
|
+
(async () => {
|
|
1570
|
+
try {
|
|
1571
|
+
const { output } = await (await fetch('/home')).json();
|
|
1572
|
+
$('openHome').title = `결과물 폴더: ${output}`;
|
|
1573
|
+
$('homePath').textContent = `저장 위치: ${output}`;
|
|
1574
|
+
} catch { /* 경로 표시는 없어도 동작에 지장 없다 */ }
|
|
1575
|
+
})();
|
|
1576
|
+
|
|
1517
1577
|
loadBrand();
|
|
1518
1578
|
|
|
1519
1579
|
$('go').onclick = generate;
|
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// 팀원에게 보내는 4파일 묶음을 만든다.
|
|
2
|
+
// node scripts/build-dist-zip.mjs
|
|
3
|
+
//
|
|
4
|
+
// store 모드(무압축) zip 을 직접 쓴다. 의존성을 늘리지 않으려는 것도 있지만,
|
|
5
|
+
// Compress-Archive 가 한글 파일명에 UTF-8 플래그를 안 세워서 압축을 푸는
|
|
6
|
+
// 쪽에 따라 이름이 깨지기 때문이다. 여기서는 0x800 을 직접 세운다.
|
|
7
|
+
//
|
|
8
|
+
// bat 은 UTF-8(BOM 없음) + CRLF 여야 한다. 둘 중 하나만 어긋나도 cmd 가
|
|
9
|
+
// 한글이 섞인 줄을 조용히 잘못 읽어서 저장 폴더 설정이 무시된다.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist');
|
|
16
|
+
const OUT = 'snsauto-builder.zip';
|
|
17
|
+
|
|
18
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
19
|
+
let c = n;
|
|
20
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
21
|
+
return c >>> 0;
|
|
22
|
+
});
|
|
23
|
+
const crc32 = (buf) => {
|
|
24
|
+
let c = 0xffffffff;
|
|
25
|
+
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
|
26
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function checkBat(name, buf) {
|
|
30
|
+
if (buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) throw new Error(`${name}: BOM 이 있다`);
|
|
31
|
+
const text = buf.toString('utf8');
|
|
32
|
+
if (/(?<!\r)\n/.test(text)) throw new Error(`${name}: LF 줄바꿈이 남아 있다 (CRLF 여야 함)`);
|
|
33
|
+
for (const [i, line] of text.split('\r\n').entries()) {
|
|
34
|
+
// 경로 값 줄만 한글 허용. 명령·주석은 ASCII 여야 콘솔 코드페이지와 무관하게 읽힌다.
|
|
35
|
+
if (/[^\x00-\x7F]/.test(line) && !/^set "DATA_PATH=/.test(line)) {
|
|
36
|
+
throw new Error(`${name}:${i + 1}: bat 명령부에 비ASCII 문자 — ${line.trim()}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const files = readdirSync(DIST).filter((f) => f !== OUT).sort();
|
|
42
|
+
const locals = [];
|
|
43
|
+
const central = [];
|
|
44
|
+
let offset = 0;
|
|
45
|
+
|
|
46
|
+
for (const name of files) {
|
|
47
|
+
const body = readFileSync(join(DIST, name));
|
|
48
|
+
if (name.endsWith('.bat')) checkBat(name, body);
|
|
49
|
+
|
|
50
|
+
const nameBuf = Buffer.from(name, 'utf8');
|
|
51
|
+
const crc = crc32(body);
|
|
52
|
+
const flags = 0x0800; // 파일명이 UTF-8 임을 표시
|
|
53
|
+
|
|
54
|
+
const local = Buffer.alloc(30);
|
|
55
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
56
|
+
local.writeUInt16LE(20, 4);
|
|
57
|
+
local.writeUInt16LE(flags, 6);
|
|
58
|
+
local.writeUInt32LE(crc, 14);
|
|
59
|
+
local.writeUInt32LE(body.length, 18);
|
|
60
|
+
local.writeUInt32LE(body.length, 22);
|
|
61
|
+
local.writeUInt16LE(nameBuf.length, 26);
|
|
62
|
+
locals.push(local, nameBuf, body);
|
|
63
|
+
|
|
64
|
+
const dir = Buffer.alloc(46);
|
|
65
|
+
dir.writeUInt32LE(0x02014b50, 0);
|
|
66
|
+
dir.writeUInt16LE(20, 4);
|
|
67
|
+
dir.writeUInt16LE(20, 6);
|
|
68
|
+
dir.writeUInt16LE(flags, 8);
|
|
69
|
+
dir.writeUInt32LE(crc, 16);
|
|
70
|
+
dir.writeUInt32LE(body.length, 20);
|
|
71
|
+
dir.writeUInt32LE(body.length, 24);
|
|
72
|
+
dir.writeUInt16LE(nameBuf.length, 28);
|
|
73
|
+
dir.writeUInt32LE(offset, 42);
|
|
74
|
+
central.push(dir, nameBuf);
|
|
75
|
+
|
|
76
|
+
offset += 30 + nameBuf.length + body.length;
|
|
77
|
+
console.log(` ${name.padEnd(14)} ${String(body.length).padStart(6)} bytes`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const centralBuf = Buffer.concat(central);
|
|
81
|
+
const end = Buffer.alloc(22);
|
|
82
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
83
|
+
end.writeUInt16LE(files.length, 8);
|
|
84
|
+
end.writeUInt16LE(files.length, 10);
|
|
85
|
+
end.writeUInt32LE(centralBuf.length, 12);
|
|
86
|
+
end.writeUInt32LE(offset, 16);
|
|
87
|
+
|
|
88
|
+
const zip = Buffer.concat([...locals, centralBuf, end]);
|
|
89
|
+
writeFileSync(join(DIST, OUT), zip);
|
|
90
|
+
console.log(`\n${OUT}: ${files.length}개 파일, ${zip.length} bytes`);
|
|
@@ -117,7 +117,31 @@ async function spawnDetachedChrome(exe) {
|
|
|
117
117
|
} catch { /* 아직 안 떴다 */ }
|
|
118
118
|
await new Promise((r) => setTimeout(r, 500));
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
|
|
121
|
+
// Chrome 은 user-data-dir 하나당 인스턴스 하나만 띄운다. 이미 그 프로필로 떠 있는 창이
|
|
122
|
+
// 있으면 우리가 포트를 붙여 실행해도 **기존 창에 넘기고 종료**한다 → 포트가 안 열린다.
|
|
123
|
+
// 이게 압도적으로 흔한 원인이라, 추측이 아니라 실제로 확인해서 알려준다.
|
|
124
|
+
const blocker = await profileHolders();
|
|
125
|
+
if (blocker.length) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`이 프로필로 Chrome 이 이미 떠 있어서 디버깅 포트를 열 수 없습니다 (pid ${blocker.join(', ')}).\n`
|
|
128
|
+
+ ` 그 Chrome 창을 모두 닫고 다시 실행하세요.\n`
|
|
129
|
+
+ ` 프로필: ${PROFILE}`);
|
|
130
|
+
}
|
|
131
|
+
throw new Error(`Chrome 이 30초 안에 ${DEBUG_URL} 를 열지 못했습니다 (프로필: ${PROFILE})`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 이 프로필을 붙잡고 있는 chrome.exe 를 찾는다. 원인을 눈으로 확인시켜 주기 위한 것.
|
|
135
|
+
async function profileHolders() {
|
|
136
|
+
if (process.platform !== 'win32') return [];
|
|
137
|
+
try {
|
|
138
|
+
const { execFileSync } = await import('node:child_process');
|
|
139
|
+
const out = execFileSync('powershell', ['-NoProfile', '-Command',
|
|
140
|
+
`Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | `
|
|
141
|
+
+ `Where-Object { $_.CommandLine -like '*${PROFILE.replace(/\\/g, '\\\\')}*' } | `
|
|
142
|
+
+ `Select-Object -ExpandProperty ProcessId`], { encoding: 'utf8', timeout: 15_000 });
|
|
143
|
+
return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean).slice(0, 8);
|
|
144
|
+
} catch { return []; }
|
|
121
145
|
}
|
|
122
146
|
|
|
123
147
|
async function waitForComposer(page) {
|
package/server.mjs
CHANGED
|
@@ -774,13 +774,20 @@ const server = createServer(async (req, res) => {
|
|
|
774
774
|
let changed = 0;
|
|
775
775
|
for (const slide of brief.slides || []) {
|
|
776
776
|
const edit = byN.get(Number(slide.n));
|
|
777
|
-
if (!edit) continue;
|
|
778
|
-
//
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
if (
|
|
783
|
-
slide[key]
|
|
777
|
+
if (!edit?.fields) continue;
|
|
778
|
+
// 키 이름을 고정하지 않는다. 리서처가 브리프마다 다르게 쓴다
|
|
779
|
+
// (headline/sub, subcopy, core_message, card_items …).
|
|
780
|
+
// **원래 있던 키에만** 되쓴다 — 없는 키를 새로 만들면 스키마가 오염된다.
|
|
781
|
+
for (const [key, val] of Object.entries(edit.fields)) {
|
|
782
|
+
if (!(key in slide) || val == null) continue;
|
|
783
|
+
if (Array.isArray(slide[key])) {
|
|
784
|
+
const arr = String(val).split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
|
|
785
|
+
if (JSON.stringify(arr) === JSON.stringify(slide[key])) continue;
|
|
786
|
+
slide[key] = arr;
|
|
787
|
+
} else {
|
|
788
|
+
if (typeof slide[key] !== 'string' || slide[key] === String(val)) continue;
|
|
789
|
+
slide[key] = String(val).slice(0, 1000);
|
|
790
|
+
}
|
|
784
791
|
changed++;
|
|
785
792
|
}
|
|
786
793
|
}
|
|
@@ -798,7 +805,13 @@ const server = createServer(async (req, res) => {
|
|
|
798
805
|
}
|
|
799
806
|
const brief = JSON.parse(readFileSync(join(abs, 'brief.json'), 'utf8'));
|
|
800
807
|
const slide = (brief.slides || []).find((s) => Number(s.n) === Number(n)) || {};
|
|
801
|
-
|
|
808
|
+
// 어느 칸을 고치는지는 키 이름으로 판단한다 (headline/core_message = 큰 문구).
|
|
809
|
+
const isHead = /headline|core_message|title|hook/i.test(String(key || ''));
|
|
810
|
+
// 같은 장의 나머지 문구를 맥락으로 준다 — 키 이름이 뭐든.
|
|
811
|
+
const context = Object.entries(slide)
|
|
812
|
+
.filter(([k, v]) => k !== key && k !== 'n' && (typeof v === 'string' || Array.isArray(v)))
|
|
813
|
+
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(' / ') : v}`)
|
|
814
|
+
.join('\n');
|
|
802
815
|
ensureBrandFacts();
|
|
803
816
|
const banned = JSON.parse(read('banned-words.json'));
|
|
804
817
|
const bannedList = Object.entries(banned).filter(([k]) => !k.startsWith('_'))
|
|
@@ -814,7 +827,8 @@ ${bannedList}
|
|
|
814
827
|
인스타 카드뉴스 **${n}번째 장**(${slide.role || '본문'})의 ${isHead ? '헤드라인' : '설명 문구'}을 다시 써라.
|
|
815
828
|
|
|
816
829
|
전체 주제: ${brief.topic || ''}
|
|
817
|
-
이 장의
|
|
830
|
+
이 장의 다른 문구:
|
|
831
|
+
${context || '(없음)'}
|
|
818
832
|
현재 문구: ${String(text || '(비어 있음)')}
|
|
819
833
|
|
|
820
834
|
**대안 3개**를 제안해라. 뜻은 유지하되 표현을 바꾼다.
|
|
@@ -888,6 +902,22 @@ ${bannedList}
|
|
|
888
902
|
.end(readFileSync(LOGO_SVG));
|
|
889
903
|
}
|
|
890
904
|
|
|
905
|
+
// 작업 폴더 위치를 알려주고, 탐색기로 열어준다.
|
|
906
|
+
// 데이터를 bat 옆이 아니라 안정된 위치에 두는 대신, 찾기는 쉬워야 한다.
|
|
907
|
+
if (req.method === 'GET' && req.url === '/home') {
|
|
908
|
+
return json(200, { root: ROOT, output: join(ROOT, 'output') });
|
|
909
|
+
}
|
|
910
|
+
if (req.method === 'POST' && req.url === '/home/open') {
|
|
911
|
+
const target = join(ROOT, 'output');
|
|
912
|
+
try { mkdirSync(target, { recursive: true }); } catch { /* 이미 있으면 그만 */ }
|
|
913
|
+
const cmd = process.platform === 'win32' ? ['explorer', [target]]
|
|
914
|
+
: process.platform === 'darwin' ? ['open', [target]] : ['xdg-open', [target]];
|
|
915
|
+
// explorer 는 성공해도 0 이 아닌 코드를 내는 일이 있어 종료코드를 보지 않는다.
|
|
916
|
+
spawn(cmd[0], cmd[1], { detached: true, stdio: 'ignore' }).on('error', () => {}).unref();
|
|
917
|
+
log('작업 폴더 열기', target);
|
|
918
|
+
return json(200, { ok: true, path: target });
|
|
919
|
+
}
|
|
920
|
+
|
|
891
921
|
// 브랜드 설정 — 스키마와 현재 값을 같이 준다 (화면이 스키마로 폼을 그린다)
|
|
892
922
|
if (req.method === 'GET' && req.url === '/brand') {
|
|
893
923
|
return json(200, { schema: JSON.parse(read('brand.schema.json')), values: readBrand(), logo: hasLogo() });
|