adyou 0.3.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.
Files changed (48) hide show
  1. package/README.md +64 -0
  2. package/dist/adapters/google.d.ts +41 -0
  3. package/dist/adapters/google.js +301 -0
  4. package/dist/adapters/index.d.ts +6 -0
  5. package/dist/adapters/index.js +17 -0
  6. package/dist/adapters/meta.d.ts +33 -0
  7. package/dist/adapters/meta.js +275 -0
  8. package/dist/adapters/mock.d.ts +28 -0
  9. package/dist/adapters/mock.js +67 -0
  10. package/dist/adapters/types.d.ts +100 -0
  11. package/dist/adapters/types.js +20 -0
  12. package/dist/cli.d.ts +2 -0
  13. package/dist/cli.js +315 -0
  14. package/dist/core/brief.d.ts +12 -0
  15. package/dist/core/brief.js +71 -0
  16. package/dist/core/check.d.ts +11 -0
  17. package/dist/core/check.js +55 -0
  18. package/dist/core/creatives/copy.d.ts +14 -0
  19. package/dist/core/creatives/copy.js +52 -0
  20. package/dist/core/creatives/images.d.ts +9 -0
  21. package/dist/core/creatives/images.js +89 -0
  22. package/dist/core/creatives/render.d.ts +25 -0
  23. package/dist/core/creatives/render.js +157 -0
  24. package/dist/core/creatives/specs.d.ts +40 -0
  25. package/dist/core/creatives/specs.js +51 -0
  26. package/dist/core/llm.d.ts +23 -0
  27. package/dist/core/llm.js +77 -0
  28. package/dist/core/money.d.ts +9 -0
  29. package/dist/core/money.js +34 -0
  30. package/dist/core/ops.d.ts +121 -0
  31. package/dist/core/ops.js +407 -0
  32. package/dist/core/optimize.d.ts +27 -0
  33. package/dist/core/optimize.js +56 -0
  34. package/dist/core/plan.d.ts +22 -0
  35. package/dist/core/plan.js +59 -0
  36. package/dist/core/report.d.ts +30 -0
  37. package/dist/core/report.js +34 -0
  38. package/dist/core/site.d.ts +30 -0
  39. package/dist/core/site.js +80 -0
  40. package/dist/core/state.d.ts +162 -0
  41. package/dist/core/state.js +110 -0
  42. package/dist/core/tracking.d.ts +19 -0
  43. package/dist/core/tracking.js +45 -0
  44. package/dist/index.d.ts +18 -0
  45. package/dist/index.js +19 -0
  46. package/dist/mcp.d.ts +1 -0
  47. package/dist/mcp.js +130 -0
  48. package/package.json +63 -0
package/dist/cli.js ADDED
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+ // adyou — ADYou(대행사 없이, 당신이 직접) CLI. 광고주가 AI와 자연어로 광고 전 과정을 직접 한다. 모든 명령은 core/ops를 부른다(MCP도 같은 함수).
3
+ import { Command } from 'commander';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { connectedMedia, connector } from './adapters/index.js';
7
+ import { formatCheck, GOOGLE_GUIDE, META_GUIDE, runCheck } from './core/check.js';
8
+ import { explainBrief } from './core/brief.js';
9
+ import { fmtKrw } from './core/money.js';
10
+ import { dateRange, opAudit, opBuild, opBrief, opChange, opCreatives, opLaunch, opOptimize, opPause, opPlan, opPreview, opReport, opStatus, opTrackSnippet } from './core/ops.js';
11
+ import { tableOf } from './core/report.js';
12
+ import { HOME, loadConfig, loadCredentials, resolveProject, saveConfig, saveCredentials, saveProject, listProjects } from './core/state.js';
13
+ import { verifyLanding, idsOf } from './core/tracking.js';
14
+ const log = (s) => console.log(s);
15
+ const die = (e) => { const m = e instanceof Error ? e.message : String(e); const hint = e?.hint; console.error(`❌ ${m}${hint ? `\n → ${hint}` : ''}`); process.exit(1); };
16
+ const mediaOpt = (s) => s ? s.split(',').map((x) => x.trim()) : undefined;
17
+ const goalOf = (s) => { const g = { traffic: 'traffic', visit: 'traffic', 방문: 'traffic', lead: 'lead', 문의: 'lead', signup: 'signup', 가입: 'signup', purchase: 'purchase', 구매: 'purchase', sales: 'purchase' }[s.toLowerCase()]; if (!g)
18
+ throw new Error('목표는 traffic(방문)·lead(문의)·signup(가입)·purchase(구매) 중 하나'); return g; };
19
+ const budgetOf = (s) => { const n = Number(String(s).replace(/[^\d.]/g, '')) * (/만/.test(s) ? 10_000 : 1); if (!n)
20
+ throw new Error('예산은 숫자(원)로'); return Math.round(n); };
21
+ const program = new Command().name('adyou').description('ADYou — 대행사 없이, 당신이 직접. 광고 대행사가 하던 전 과정을 AI와 직접 — 브리프 · 소재 · Meta/Google 집행(항상 PAUSED 생성) · 추적 · 최적화 · 보고').version('0.1.0').option('-p, --project <slug>', '프로젝트(기본: 마지막 사용)');
22
+ program.command('init').description('처음 시작 — 자격 온보딩 가이드 + 검사기').action(async () => {
23
+ const media = connectedMedia();
24
+ log(`adyou 홈: ${HOME}\n연결된 매체: ${media.length ? media.join(', ') : '없음'}\n`);
25
+ if (!media.includes('meta'))
26
+ log(`■ Meta(페이스북·인스타그램) 연결 — 약 10분\n ${META_GUIDE.join('\n ')}\n`);
27
+ if (!media.includes('google'))
28
+ log(`■ Google Ads 연결 — 약 10분 + Explorer 승인\n ${GOOGLE_GUIDE.join('\n ')}\n`);
29
+ log('■ AI 키(선택 · 없으면 규칙 기반)\n adyou config set llm.apiKey <Anthropic 또는 bizrouter 키>\n adyou config set image.apiKey <OpenAI 키> # 배경 이미지 생성\n');
30
+ log('그 다음: adyou check → adyou brief <사이트 주소> --budget 3000000 --goal signup → adyou creatives → adyou plan → adyou build → adyou preview → adyou launch');
31
+ if (media.length) {
32
+ const { items } = await runCheck(null);
33
+ log('\n현재 검사 결과\n' + formatCheck(items));
34
+ }
35
+ });
36
+ const connect = program.command('connect').description('매체 자격 저장(~/.adpilot/credentials.json · 0600) + 즉시 검사');
37
+ connect.command('meta').option('--token <t>', '시스템 사용자 토큰').option('--account <act_id>', '광고 계정 id').option('--page <id>', '페이지 id').option('--instagram <id>', '인스타 비즈니스 계정 id').option('--pixel <id>', '픽셀 id').option('--from-env <file>', '오늘 키트의 meta.env를 그대로 읽기').action(async (o) => {
38
+ const c = loadCredentials();
39
+ const cur = c.meta || { accessToken: '', adAccountId: '' };
40
+ if (o.fromEnv) {
41
+ const kv = Object.fromEntries(fs.readFileSync(o.fromEnv, 'utf8').split('\n').filter((l) => /^[A-Z_]+=/.test(l)).map((l) => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1).trim()]; }));
42
+ cur.accessToken = kv.ACCESS_TOKEN || cur.accessToken;
43
+ cur.adAccountId = kv.AD_ACCOUNT_ID || cur.adAccountId;
44
+ cur.pageId = kv.PAGE_ID || cur.pageId;
45
+ cur.instagramActorId = kv.INSTAGRAM_ACTOR_ID || cur.instagramActorId;
46
+ cur.pixelId = kv.PIXEL_ID || cur.pixelId;
47
+ }
48
+ if (o.token)
49
+ cur.accessToken = o.token;
50
+ if (o.account)
51
+ cur.adAccountId = o.account.startsWith('act_') ? o.account : `act_${o.account}`;
52
+ if (o.page)
53
+ cur.pageId = o.page;
54
+ if (o.instagram)
55
+ cur.instagramActorId = o.instagram;
56
+ if (o.pixel)
57
+ cur.pixelId = o.pixel;
58
+ if (!cur.accessToken || !cur.adAccountId)
59
+ die(new Error('--token과 --account는 필수예요'));
60
+ c.meta = cur;
61
+ saveCredentials(c);
62
+ log('저장했어요. 검사 중…');
63
+ const conn = connector('meta');
64
+ if (conn)
65
+ log(formatCheck(await conn.check()));
66
+ });
67
+ connect.command('google').option('--sa <file>', '서비스 계정 JSON 키 파일').option('--customer <id>', '10자리 고객 ID').option('--login-customer <id>', 'MCC 경유 시').action(async (o) => {
68
+ const c = loadCredentials();
69
+ const cur = c.google || { serviceAccountJson: '', customerId: '' };
70
+ if (o.sa)
71
+ cur.serviceAccountJson = path.resolve(o.sa);
72
+ if (o.customer)
73
+ cur.customerId = o.customer.replace(/-/g, '');
74
+ if (o.loginCustomer)
75
+ cur.loginCustomerId = o.loginCustomer.replace(/-/g, '');
76
+ if (!cur.serviceAccountJson || !cur.customerId)
77
+ die(new Error('--sa와 --customer는 필수예요'));
78
+ c.google = cur;
79
+ saveCredentials(c);
80
+ log('저장했어요. 검사 중…');
81
+ const conn = connector('google');
82
+ if (conn)
83
+ log(formatCheck(await conn.check()));
84
+ });
85
+ program.command('check').description('자격·계정·픽셀·랜딩 실측 검사 — 실패 항목마다 어느 화면에서 무엇을 누를지').option('--media <list>', 'meta,google').action(async (o) => {
86
+ let p = null;
87
+ try {
88
+ p = resolveProject(program.opts().project);
89
+ }
90
+ catch { /* 프로젝트 없이도 */ }
91
+ const { items } = await runCheck(p, { media: mediaOpt(o.media) });
92
+ log(formatCheck(items));
93
+ const fail = items.filter((i) => i.ok === false).length;
94
+ log(`\n${items.filter((i) => i.ok === true).length} 통과 · ${fail} 실패 · ${items.filter((i) => i.ok === null).length} 확인 불가`);
95
+ if (fail)
96
+ process.exit(2);
97
+ });
98
+ program.command('brief <url>').description('사이트 읽고 브리프 만들기 — 「이 회사는 ○○, 고객은 ○○」').requiredOption('--budget <krw>', '월 예산(원) 예 3000000 · 300만').option('--goal <g>', 'traffic|lead|signup|purchase', 'traffic').option('--markets <list>', '예 KR,US 또는 US:en,JP:ja (기본 사이트 언어로 추정)').option('--hints <text>', '브리프에 반영할 힌트(강조점·금지어)').option('--end <YYYY-MM-DD>', '종료일').action(async (url, o) => {
99
+ try {
100
+ const { project, explain } = await opBrief(url, { budget: budgetOf(o.budget), goal: goalOf(o.goal), markets: o.markets, hints: o.hints, endDate: o.end, log });
101
+ log(`\n${explain}\n\n프로젝트 「${project.slug}」 저장 · 월 ${fmtKrw(project.budget.monthlyKrw)} · 목표 ${project.budget.goal} · 시장 ${project.budget.markets.map((m) => m.country).join(',')}\n맞으면 다음: adyou creatives (틀리면: adyou brief ${url} --hints "…" 로 다시)`);
102
+ }
103
+ catch (e) {
104
+ die(e);
105
+ }
106
+ });
107
+ program.command('creatives').description('컨셉·카피 + 이미지 생성 + 12규격 합성 + 규격 검사').option('--concepts <n>', '컨셉 수', '3').option('--feedback <text>', '자연어 수정(「더 고급스럽게」) — 전부 다시 만든다').option('--images <folder>', '직접 준비한 배경 이미지 폴더').option('--no-images', '이미지 생성 없이 브랜드 그래디언트').option('--force', '다시 만들기').action(async (o) => {
108
+ try {
109
+ const p = resolveProject(program.opts().project);
110
+ const { project, report } = await opCreatives(p, { count: Number(o.concepts), feedback: o.feedback, images: o.images, noImages: o.images === false, force: o.force, log });
111
+ log(`\n컨셉 ${project.concepts.length}개 · 소재 ${project.assets.filter((a) => a.concept !== '_logo').length}장`);
112
+ for (const c of project.concepts)
113
+ log(` · ${c.name}(${c.key}) — ${c.headlines[0]}`);
114
+ if (report.length)
115
+ log(`규격 경고 ${report.length}건(빌드 때 자동 맞춤):\n ${report.join('\n ')}`);
116
+ log(`갤러리: ${path.join(HOME, 'projects', project.slug, 'creatives', 'index.html')}\n다음: adyou plan`);
117
+ }
118
+ catch (e) {
119
+ die(e);
120
+ }
121
+ });
122
+ program.command('plan').description('예산 분할·시장·컨셉 수·매체 구조를 사람 말로').option('--media <list>', 'meta,google').action(async (o) => {
123
+ try {
124
+ const p = await opPlan(resolveProject(program.opts().project), { media: mediaOpt(o.media), log });
125
+ log(p.plan.explain + '\n\n다음: adyou build (검증만: adyou build --validate)');
126
+ }
127
+ catch (e) {
128
+ die(e);
129
+ }
130
+ });
131
+ program.command('build').description('매체에 전부 PAUSED로 생성(멱등 · 부분 실패 재실행 안전)').option('--media <list>', 'meta,google').option('--validate', '서버 검증만').action(async (o) => {
132
+ try {
133
+ const { project, warnings, errors } = await opBuild(resolveProject(program.opts().project), { media: mediaOpt(o.media), validate: o.validate, log });
134
+ for (const w of warnings)
135
+ log(`⚠ ${w}`);
136
+ for (const e of errors)
137
+ log(`❌ ${e}`);
138
+ log(`\n생성물 ${project.placements.length}개 (전부 대기 상태 · 노출 안 됨)`);
139
+ for (const x of project.placements)
140
+ log(` ${x.medium} ${x.kind}${x.concept ? ` ${x.concept}` : ''} ${x.externalId}`);
141
+ log(errors.length ? '\n일부 실패 — 원인을 고친 뒤 같은 명령을 다시 실행하면 이어서 만들어요.' : '\n다음: adyou preview → adyou launch');
142
+ if (errors.length)
143
+ process.exit(2);
144
+ }
145
+ catch (e) {
146
+ die(e);
147
+ }
148
+ });
149
+ program.command('preview').description('매체 미리보기 HTML(메타 실제 렌더 · 구글 근사)').option('--open', '브라우저로 열기').action(async (o) => {
150
+ try {
151
+ const { file, count } = await opPreview(resolveProject(program.opts().project), { log });
152
+ log(`미리보기 ${count}개 → ${file}`);
153
+ if (o.open) {
154
+ const { exec } = await import('node:child_process');
155
+ exec(`${process.platform === 'darwin' ? 'open' : 'xdg-open'} "${file}"`);
156
+ }
157
+ }
158
+ catch (e) {
159
+ die(e);
160
+ }
161
+ });
162
+ program.command('launch').description('승인 → ACTIVE (예산 상한 재확인 · 매체 검수 뒤 노출)').option('--media <list>').option('-y, --yes', '확인 없이').action(async (o) => {
163
+ try {
164
+ const p = resolveProject(program.opts().project);
165
+ log(p.plan?.explain || '');
166
+ if (!o.yes) {
167
+ const rl = await import('node:readline/promises');
168
+ const r = rl.createInterface({ input: process.stdin, output: process.stdout });
169
+ const a = await r.question('\n이 조건으로 광고를 시작할까요? 시작하는 순간부터 하루 예산이 나가요. (yes/no) ');
170
+ r.close();
171
+ if (!/^y(es)?$/i.test(a.trim())) {
172
+ log('취소했어요.');
173
+ return;
174
+ }
175
+ }
176
+ const { launched, errors } = await opLaunch(p, { media: mediaOpt(o.media), log, yes: true });
177
+ for (const e of errors)
178
+ log(`❌ ${e}`);
179
+ log(launched.length ? `\n✅ ${launched.join(', ')} 켰어요. 메타 검수 24시간 이내 · 구글 1영업일. \`adyou status\` 로 확인.` : '켜진 매체가 없어요.');
180
+ }
181
+ catch (e) {
182
+ die(e);
183
+ }
184
+ });
185
+ program.command('pause').description('전부 끄기(즉시)').option('--media <list>').action(async (o) => { try {
186
+ const { paused, errors } = await opPause(resolveProject(program.opts().project), { media: mediaOpt(o.media), log });
187
+ for (const e of errors)
188
+ log(`❌ ${e}`);
189
+ log(paused.length ? `⏸ ${paused.join(', ')} 껐어요.` : '끈 매체가 없어요.');
190
+ }
191
+ catch (e) {
192
+ die(e);
193
+ } });
194
+ program.command('budget <krw>').description('월 예산 바꾸기(계획·매체 일 예산 즉시 반영)').action(async (krw) => { try {
195
+ const p = resolveProject(program.opts().project);
196
+ p.budget.monthlyKrw = budgetOf(krw);
197
+ saveProject(p);
198
+ if (p.plan) {
199
+ await opPlan(p, { log });
200
+ for (const pm of p.plan.media) {
201
+ const c = connector(pm.medium);
202
+ if (c && p.placements.length)
203
+ await c.updateBudget(p.placements, pm.dailyBudgetMinor);
204
+ }
205
+ log(p.plan.explain);
206
+ }
207
+ else
208
+ log(`월 예산 ${fmtKrw(p.budget.monthlyKrw)} 저장`);
209
+ }
210
+ catch (e) {
211
+ die(e);
212
+ } });
213
+ program.command('status').description('지표·검수 상태(최근 7일)').option('--days <n>', '기간', '7').action(async (o) => {
214
+ try {
215
+ const p = resolveProject(program.opts().project);
216
+ log(`프로젝트 ${p.slug} · 상태 ${p.status} · 월 ${fmtKrw(p.budget.monthlyKrw)} · 생성물 ${p.placements.length}`);
217
+ const st = await opStatus(p, { days: Number(o.days) });
218
+ for (const m of Object.keys(st.metrics)) {
219
+ log(`\n[${m}] ${st.range.since}~${st.range.until}`);
220
+ const t = tableOf(st.metrics[m]);
221
+ if (!t.length)
222
+ log(' 지표 없음(검수 중·노출 전)');
223
+ for (const r of t)
224
+ log(` ${String(r.concept).padEnd(12)} 쓴 돈 ${fmtKrw(r.spendKrw).padStart(10)} · 노출 ${String(r.impressions).padStart(7)} · 클릭 ${String(r.clicks).padStart(5)} · CTR ${(r.ctr * 100).toFixed(2)}% · 전환 ${r.conversions}`);
225
+ for (const pr of st.policy[m] || [])
226
+ log(` 검수 ${pr.name}: ${pr.status}${pr.issues.length ? ` — ${pr.issues.join('; ')}` : ''}`);
227
+ }
228
+ }
229
+ catch (e) {
230
+ die(e);
231
+ }
232
+ });
233
+ program.command('optimize').description('규칙 기반 제안(소재 끄기·예산 ±20%) · --apply로 실행').option('--apply').action(async (o) => { try {
234
+ const { proposals, applied } = await opOptimize(resolveProject(program.opts().project), { apply: o.apply, log });
235
+ for (const pr of proposals)
236
+ log(`${pr.kind === 'note' ? '·' : '→'} [${pr.medium}] ${pr.reason}`);
237
+ if (o.apply)
238
+ log(applied.length ? `\n적용: ${applied.join(' · ')}` : '\n적용할 것이 없었어요.');
239
+ else if (proposals.some((x) => x.kind !== 'note'))
240
+ log('\n실행하려면: adyou optimize --apply');
241
+ }
242
+ catch (e) {
243
+ die(e);
244
+ } });
245
+ program.command('report').description('사람 말 보고서(HTML·MD)').option('--days <n>', '7').option('--open').action(async (o) => { try {
246
+ const r = await opReport(resolveProject(program.opts().project), { days: Number(o.days || 7) });
247
+ log(r.text + `\n\n→ ${r.file}`);
248
+ if (o.open) {
249
+ const { exec } = await import('node:child_process');
250
+ exec(`open "${r.file}"`);
251
+ }
252
+ }
253
+ catch (e) {
254
+ die(e);
255
+ } });
256
+ program.command('audit').description('현재 계정의 모든 캠페인·광고 전수 조회 + 진단(BYO 인수)').option('--media <list>').action(async (o) => { try {
257
+ const r = await opAudit({ media: mediaOpt(o.media) });
258
+ for (const [m, v] of Object.entries(r)) {
259
+ log(`\n[${m}] 캠페인 ${v.rows.filter((x) => x.level === 'campaign').length} · 광고 ${v.rows.filter((x) => x.level === 'ad').length}`);
260
+ for (const c of v.rows.filter((x) => x.level === 'campaign'))
261
+ log(` ${c.status.padEnd(8)} ${c.name.slice(0, 50).padEnd(50)} 30일 ${c.spend30d ?? 0} · 노출 ${c.impressions30d ?? 0} · 클릭 ${c.clicks30d ?? 0}`);
262
+ log(v.findings.length ? ` 진단:\n - ${v.findings.join('\n - ')}` : ' 진단: 특이사항 없음');
263
+ }
264
+ }
265
+ catch (e) {
266
+ die(e);
267
+ } });
268
+ program.command('change <request...>').description('자연어로 바꾸기 — "예산 200만원으로" "미국 빼" "더 고급스럽게"').action(async (words) => { try {
269
+ const { summary, steps } = await opChange(resolveProject(program.opts().project), words.join(' '), { log });
270
+ log(`${summary}\n${steps.map((s) => ` · ${s}`).join('\n')}`);
271
+ }
272
+ catch (e) {
273
+ die(e);
274
+ } });
275
+ const track = program.command('track').description('추적 스니펫 발급·랜딩 검증');
276
+ track.command('install').option('--spa', 'SPA PageView 수동 전송 포함').option('--pixel <id>').option('--gtag <AW-id>').option('--label <l>').option('--ga4 <G-id>').action(async (o) => { try {
277
+ const p = resolveProject(program.opts().project);
278
+ p.tracking = { ...(p.tracking || {}), ...(o.pixel ? { pixelId: o.pixel } : {}), ...(o.gtag ? { gtagId: o.gtag } : {}), ...(o.label ? { gtagLabel: o.label } : {}), ...(o.ga4 ? { ga4Id: o.ga4 } : {}) };
279
+ saveProject(p);
280
+ const s = opTrackSnippet(p, { spa: o.spa });
281
+ log(`■ 모든 페이지 <head>\n${s.head}\n\n■ 목표 달성 화면\n${s.conversion}\n\n■ CSP를 쓰면 script-src·connect-src·img-src 에: ${s.csp.join(' ')}\n\n붙인 뒤: adyou track verify`);
282
+ }
283
+ catch (e) {
284
+ die(e);
285
+ } });
286
+ track.command('verify').action(async () => { try {
287
+ const p = resolveProject(program.opts().project);
288
+ const items = await verifyLanding(p.url, idsOf(p));
289
+ const meta = connector('meta');
290
+ if (meta && p.tracking?.pixelId && 'pixelTrafficPermission' in meta)
291
+ items.push(await meta.pixelTrafficPermission(p.url));
292
+ log(formatCheck(items));
293
+ }
294
+ catch (e) {
295
+ die(e);
296
+ } });
297
+ const config = program.command('config').description('설정 보기/바꾸기 (llm.apiKey · llm.baseUrl · llm.model · image.apiKey · fx.USD …)');
298
+ config.command('set <key> <value>').action((k, v) => { const c = loadConfig(); const [a, b] = k.split('.'); if (!b)
299
+ die(new Error('키는 llm.apiKey처럼 두 단계')); const sec = (c[a] || {}); sec[b] = a === 'fx' ? Number(v) : v; c[a] = sec; saveConfig(c); log(`${k} 저장`); });
300
+ config.command('get').action(() => { const c = loadConfig(); log(JSON.stringify({ ...c, llm: c.llm ? { ...c.llm, apiKey: c.llm.apiKey ? '••••' : undefined } : undefined, image: c.image ? { ...c.image, apiKey: c.image.apiKey ? '••••' : undefined } : undefined }, null, 2)); });
301
+ program.command('projects').description('프로젝트 목록').action(() => { for (const s of listProjects())
302
+ log(` ${s}${loadConfig().lastProject === s ? ' (현재)' : ''}`); });
303
+ program.command('mcp').description('MCP 서버(stdio) — Claude Code·Cursor·bizcoder에서 같은 툴을 자연어로').action(async () => { const { startMcp } = await import('./mcp.js'); await startMcp(); });
304
+ program.command('range').description('(내부) 최근 N일 범위').option('--days <n>', '7').action((o) => log(JSON.stringify(dateRange(Number(o.days)))));
305
+ program.command('explain').description('현재 브리프·계획 다시 보기').action(() => { try {
306
+ const p = resolveProject(program.opts().project);
307
+ if (p.brief)
308
+ log(explainBrief(p.brief));
309
+ if (p.plan)
310
+ log('\n' + p.plan.explain);
311
+ }
312
+ catch (e) {
313
+ die(e);
314
+ } });
315
+ program.parseAsync(process.argv).catch(die);
@@ -0,0 +1,12 @@
1
+ import type { Brief, Goal, Market } from './state.js';
2
+ import { type SiteSnapshot } from './site.js';
3
+ export declare function detectSpecialCategory(text: string): string | null;
4
+ export declare function buildBrief(url: string, opts: {
5
+ goal: Goal;
6
+ markets: Market[];
7
+ hints?: string;
8
+ }): Promise<{
9
+ brief: Brief;
10
+ site: SiteSnapshot;
11
+ }>;
12
+ export declare function explainBrief(b: Brief): string;
@@ -0,0 +1,71 @@
1
+ // 브리프 — 사이트 스냅샷 → 「이 회사는 ○○를 팔고 고객은 ○○로 보여요」. LLM이 있으면 LLM, 없으면 규칙으로 최소 브리프.
2
+ import { z } from 'zod';
3
+ import { aiAvailable, completeJson } from './llm.js';
4
+ import { readSite } from './site.js';
5
+ /** 특별 광고 카테고리(정치·금융·주택·고용·의료·도박 등)는 자동 심사 통과가 어려워 접수 거절 */
6
+ const SPECIAL = [
7
+ [/대출|카드론|신용|투자|주식|코인|암호화폐|보험|loan|crypto|forex|insurance|invest/i, '금융(대출·투자·보험·암호화폐)'],
8
+ [/카지노|베팅|도박|casino|betting|gambl/i, '도박'],
9
+ [/정당|선거|후보|political|election/i, '정치·선거'],
10
+ [/처방|치료제|병원|의약|clinic|pharma|treatment/i, '의료·의약'],
11
+ [/담배|전자담배|vape|tobacco/i, '담배'],
12
+ [/성인|adult\s*content|19금/i, '성인'],
13
+ ];
14
+ export function detectSpecialCategory(text) { for (const [r, n] of SPECIAL)
15
+ if (r.test(text))
16
+ return n; return null; }
17
+ const BriefSchema = z.object({
18
+ company: z.string(), offer: z.string(), category: z.string(),
19
+ audience: z.array(z.string()).min(1).max(5), usp: z.array(z.string()).min(1).max(6), tone: z.string(),
20
+ language: z.string().default('ko'),
21
+ palette: z.object({ primary: z.string(), dark: z.string(), light: z.string() }),
22
+ specialCategory: z.string().nullable().default(null),
23
+ notes: z.string().default(''),
24
+ });
25
+ function pickPalette(s) {
26
+ const primary = (s.themeColor && /^#[0-9a-f]{6}$/i.test(s.themeColor) ? s.themeColor : s.colors.find((c) => !/^#([0-9a-f])\1{5}$/i.test(c) && !/^#(f[0-9a-f]f[0-9a-f]f[0-9a-f]|[0-2][0-9a-f]{5})$/i.test(c))) || '#3182f6';
27
+ return { primary: primary.toLowerCase(), dark: '#0b1526', light: '#f5f8fc' };
28
+ }
29
+ export async function buildBrief(url, opts) {
30
+ const site = await readSite(url);
31
+ if (site.status >= 400)
32
+ throw new Error(`사이트를 읽지 못했어요 (HTTP ${site.status}). 주소를 확인해 주세요.`);
33
+ const fallbackPalette = pickPalette(site);
34
+ const special = detectSpecialCategory(`${site.title} ${site.description} ${site.headings.join(' ')}`);
35
+ let brief;
36
+ if (aiAvailable()) {
37
+ const out = await completeJson({
38
+ schema: BriefSchema, maxTokens: 2000,
39
+ system: `너는 광고 기획자다. 사이트 내용을 읽고 광고 브리프를 만든다. 광고 용어 없이 사장님이 읽어도 바로 이해할 사람 말로 쓴다. 한국어 존댓말이 아닌 명사형·짧은 문장. 과장·추정 금지: 사이트에 없는 사실을 만들지 않는다.
40
+ - company: 회사/브랜드 이름(사이트 표기 그대로)
41
+ - offer: 무엇을 파는지 한 문장(누구에게 어떤 가치를)
42
+ - category: 업종 한 단어~두 단어
43
+ - audience: 핵심 고객 유형 2~4개(예 「마케터 없는 중소기업 대표」)
44
+ - usp: 광고에 쓸 강점·증거 3~5개(사이트에 근거가 있는 것만)
45
+ - tone: 브랜드 말투(예 「단정하고 실무적」)
46
+ - language: 광고 카피 기본 언어 코드(사이트 언어 · 시장이 해외면 그 언어)
47
+ - palette: 사이트에서 읽힌 대표색 hex(primary) · 어두운 배경색(dark) · 밝은 배경색(light). 못 읽으면 후보를 그대로.
48
+ - specialCategory: 금융·의료·정치·도박·담배·성인 등 특별 광고 카테고리에 해당하면 그 이름, 아니면 null
49
+ - notes: 광고 만들 때 주의할 점 한두 문장(가격 표기·금지어 등)`,
50
+ user: JSON.stringify({ url: site.finalUrl, title: site.title, description: site.description, lang: site.lang, headings: site.headings, prices: site.prices, colorsFound: site.colors.slice(0, 8), themeColor: site.themeColor, textExcerpt: site.text.slice(0, 3500), goal: opts.goal, markets: opts.markets, hints: opts.hints || '' }),
51
+ });
52
+ brief = { url: site.finalUrl, ...out, palette: { primary: /^#[0-9a-f]{6}$/i.test(out.palette.primary) ? out.palette.primary.toLowerCase() : fallbackPalette.primary, dark: /^#[0-9a-f]{6}$/i.test(out.palette.dark) ? out.palette.dark : fallbackPalette.dark, light: /^#[0-9a-f]{6}$/i.test(out.palette.light) ? out.palette.light : fallbackPalette.light }, logoUrl: site.logo, ogImage: site.ogImage, specialCategory: out.specialCategory || special };
53
+ }
54
+ else {
55
+ brief = { url: site.finalUrl, company: site.title.split(/[|·—\-–:]/)[0].trim() || new URL(site.finalUrl).hostname, offer: site.description || site.headings[0] || site.title, category: '', audience: ['사이트 방문자'], usp: site.headings.slice(0, 3), tone: '단정하고 명확', language: site.lang || 'ko', palette: fallbackPalette, logoUrl: site.logo, ogImage: site.ogImage, specialCategory: special, notes: 'LLM 키가 없어 규칙으로 만든 최소 브리프예요. `adpilot brief --edit` 로 다듬어 주세요.' };
56
+ }
57
+ return { brief, site };
58
+ }
59
+ export function explainBrief(b) {
60
+ const lines = [
61
+ `이 회사는 「${b.company}」 — ${b.offer}`,
62
+ `고객은 ${b.audience.join(' · ')} 로 보여요.`,
63
+ `광고에서 내세울 강점: ${b.usp.map((u) => `「${u}」`).join(' ')}`,
64
+ `말투는 ${b.tone} · 카피 언어 ${b.language} · 대표색 ${b.palette.primary}`,
65
+ ];
66
+ if (b.specialCategory)
67
+ lines.push(`⚠ 「${b.specialCategory}」 특별 광고 카테고리로 보여요 — 매체 정책상 자동 집행 대상이 아니어서 접수할 수 없어요.`);
68
+ if (b.notes)
69
+ lines.push(`메모: ${b.notes}`);
70
+ return lines.join('\n');
71
+ }
@@ -0,0 +1,11 @@
1
+ import type { CheckItem, Medium } from '../adapters/types.js';
2
+ import type { Project } from './state.js';
3
+ export declare const META_GUIDE: string[];
4
+ export declare const GOOGLE_GUIDE: string[];
5
+ export declare function runCheck(p: Project | null, opts?: {
6
+ media?: Medium[];
7
+ }): Promise<{
8
+ items: CheckItem[];
9
+ media: Medium[];
10
+ }>;
11
+ export declare function formatCheck(items: CheckItem[]): string;
@@ -0,0 +1,55 @@
1
+ // 온보딩 검사기 — 매체 자격·계정·픽셀·랜딩을 실측 판정하고, 실패 항목마다 「어느 화면에서 무엇을 누르라」를 낸다.
2
+ import { connectedMedia, connector } from '../adapters/index.js';
3
+ import { aiAvailable } from './llm.js';
4
+ import { imagesAvailable } from './creatives/images.js';
5
+ import { idsOf, verifyLanding } from './tracking.js';
6
+ export const META_GUIDE = [
7
+ '1. business.facebook.com에 비즈니스 포트폴리오·광고 계정·결제수단·페이지가 있는지 확인(없으면 만들기).',
8
+ '2. developers.facebook.com/apps › 앱 만들기(비즈니스) › 사용 사례 「Marketing API」 추가 › 상단 앱 모드를 **라이브**로(심사 불필요).',
9
+ '3. Business Suite › 설정 › 사용자 › 시스템 사용자 › 추가(관리자) › 자산 할당: 광고 계정·페이지·픽셀.',
10
+ '4. 앱 › 앱 역할 › 시스템 사용자를 관리자로 추가.',
11
+ '5. 시스템 사용자 › 토큰 생성 › 앱 선택 › 권한 ads_management · ads_read · business_management · pages_show_list · pages_read_engagement · pages_manage_ads › 토큰 복사.',
12
+ '6. `adpilot connect meta --token <토큰> --account act_… --page <페이지 id> [--pixel <픽셀 id>]`',
13
+ '7. 픽셀이 있으면 이벤트 관리자 › 픽셀 › 설정 › 트래픽 권한 허용 목록에 랜딩 도메인 추가.',
14
+ ];
15
+ export const GOOGLE_GUIDE = [
16
+ '1. ads.google.com에 광고 계정(통화·결제수단)이 있는지 확인. 첫 캠페인 만들기 화면은 「전문가 모드」로 건너뛰기.',
17
+ '2. console.cloud.google.com › 새 프로젝트 › API 라이브러리에서 Google Ads API 사용 설정.',
18
+ '3. IAM › 서비스 계정 만들기 › 키(JSON) 내려받기.',
19
+ '4. 광고 계정 › 관리자 › 액세스 및 보안 › 사용자 + › 서비스 계정 이메일 · 권한 표준(이상) › 추가.',
20
+ '5. Cloud Console › Google Ads API 개요 › 액세스 수준 → **Explorer 신청**(자동 승인 · 프로젝트 소유 계정으로 로그인 · 조직 계정은 재인증 챌린지가 뜰 수 있음).',
21
+ '6. `adpilot connect google --sa <키.json> --customer <10자리 고객 ID> [--login-customer <MCC>]`',
22
+ ];
23
+ export async function runCheck(p, opts = {}) {
24
+ const media = opts.media && opts.media.length ? opts.media : connectedMedia();
25
+ const items = [];
26
+ items.push({ key: 'ai.llm', ok: aiAvailable() ? true : null, title: 'AI(LLM) 키', detail: aiAvailable() ? '설정됨' : '없음 — 브리프·카피·보고서는 규칙 기반 최소 버전으로 만들어져요', fix: aiAvailable() ? undefined : '`adpilot config set llm.apiKey <키>` (Anthropic 또는 bizrouter 키)' });
27
+ items.push({ key: 'ai.image', ok: imagesAvailable() ? true : null, title: '이미지 생성 키', detail: imagesAvailable() ? '설정됨' : '없음 — 배경은 브랜드색 그래디언트로 만들어요', fix: imagesAvailable() ? undefined : '`adpilot config set image.apiKey <OpenAI 키>` 또는 `--images <폴더>` 로 직접 이미지 제공' });
28
+ for (const m of ['meta', 'google']) {
29
+ const c = connector(m);
30
+ if (!c) {
31
+ items.push({ key: `${m}.connected`, ok: false, title: `${m === 'meta' ? 'Meta' : 'Google'} 연결`, detail: '자격이 없어요', fix: (m === 'meta' ? META_GUIDE : GOOGLE_GUIDE).join('\n ') });
32
+ continue;
33
+ }
34
+ if (!media.includes(m))
35
+ continue;
36
+ try {
37
+ items.push(...(await c.check(p?.url)));
38
+ }
39
+ catch (e) {
40
+ items.push({ key: `${m}.check`, ok: false, title: `${m} 검사 실패`, detail: e instanceof Error ? e.message : String(e) });
41
+ }
42
+ }
43
+ if (p) {
44
+ try {
45
+ items.push(...(await verifyLanding(p.url, idsOf(p))));
46
+ }
47
+ catch (e) {
48
+ items.push({ key: 'landing', ok: false, title: '랜딩 검사', detail: e instanceof Error ? e.message : String(e) });
49
+ }
50
+ }
51
+ return { items, media };
52
+ }
53
+ export function formatCheck(items) {
54
+ return items.map((i) => `${i.ok === true ? '✅' : i.ok === false ? '❌' : '➖'} ${i.title} — ${i.detail}${i.fix ? `\n → ${i.fix}` : ''}${i.link ? `\n ${i.link}` : ''}`).join('\n');
55
+ }
@@ -0,0 +1,14 @@
1
+ import type { Brief, Concept, Goal } from '../state.js';
2
+ import { wlen } from './specs.js';
3
+ export declare function generateConcepts(brief: Brief, opts: {
4
+ goal: Goal;
5
+ count: number;
6
+ feedback?: string;
7
+ previous?: Concept[];
8
+ }): Promise<Concept[]>;
9
+ /** 매체별 카피 규격 검사 요약 */
10
+ export declare function validateConcepts(concepts: Concept[]): {
11
+ ok: boolean;
12
+ report: string[];
13
+ };
14
+ export { wlen };
@@ -0,0 +1,52 @@
1
+ // 컨셉·카피 — 브리프 → 컨셉 3~5(혜택·증거·긴급·비교·감성) × 제목 5·본문 5·설명 2·CTA. 규격 검사(specs.checkCopy)를 통과할 때까지 다듬는다.
2
+ import { z } from 'zod';
3
+ import { aiAvailable, completeJson } from '../llm.js';
4
+ import { checkCopy, ctaFor, fit, GOOGLE_LIMITS, wlen } from './specs.js';
5
+ const ConceptSchema = z.object({
6
+ key: z.string().regex(/^[a-z][a-z0-9_]{1,19}$/), name: z.string(), angle: z.string(),
7
+ headlines: z.array(z.string()).min(3).max(6), bodies: z.array(z.string()).min(2).max(6), descriptions: z.array(z.string()).min(1).max(3),
8
+ imagePrompt: z.string(), theme: z.enum(['dark', 'light']).default('dark'),
9
+ });
10
+ export async function generateConcepts(brief, opts) {
11
+ const cta = ctaFor(opts.goal);
12
+ if (!aiAvailable())
13
+ return fallbackConcepts(brief, opts);
14
+ const out = await completeJson({
15
+ schema: z.object({ concepts: z.array(ConceptSchema).min(1) }), maxTokens: 6000, timeoutMs: 120_000,
16
+ system: `너는 퍼포먼스 광고 카피라이터이자 아트디렉터다. 브리프로 서로 다른 각도의 광고 컨셉 ${opts.count}개를 만든다(혜택·증거/사회적 증명·긴급/희소·비교/대안·감성 중에서 고르되 겹치지 않게).
17
+ 규칙:
18
+ - 언어는 brief.language. 사실은 브리프(usp·offer)에 있는 것만. 과장·최상급(최고·1위·100%·보장·무조건)·클릭 유도 금지.
19
+ - headlines: 짧은 제목 5개 — 각각 ${GOOGLE_LIMITS.headline}자(한글·전각은 2자로 셈 → 한글 15자) 이내. 첫 번째는 그 컨셉의 대표 문장.
20
+ - bodies: 본문 5개 — 60~120자. 구체적 혜택·근거·누구를 위한 것인지. 존댓말(해요체).
21
+ - descriptions: 설명 2개 — 각각 ${GOOGLE_LIMITS.description}자(한글 45자) 이내.
22
+ - imagePrompt: 배경 비주얼 생성 프롬프트(영어). 글자·로고·UI 절대 없음. 헤드라인을 올릴 넉넉한 여백. 브랜드색 ${brief.palette.primary}. 실존 인물·타사 로고 금지.
23
+ - theme: 어두운 배경(dark)이 어울리면 dark, 밝고 가벼우면 light.
24
+ - key: 영문 소문자 짧은 식별자(예 benefit·proof·urgency).${opts.feedback ? `\n사용자 수정 요청: 「${opts.feedback}」 — 이 요청을 반영해 전부 다시 만든다.` : ''}`,
25
+ user: JSON.stringify({ brief, goal: opts.goal, cta, previous: opts.previous?.map((c) => ({ key: c.key, name: c.name, angle: c.angle, headline: c.headlines[0] })) }),
26
+ });
27
+ const concepts = out.concepts.slice(0, opts.count).map((c) => ({ ...c, cta: cta.meta, headlines: c.headlines.map((h) => fit(h.trim(), GOOGLE_LIMITS.headline)).filter(Boolean), descriptions: c.descriptions.map((d) => fit(d.trim(), GOOGLE_LIMITS.description)).filter(Boolean), bodies: c.bodies.map((b) => b.trim()).filter(Boolean) }));
28
+ return concepts;
29
+ }
30
+ function fallbackConcepts(brief, opts) {
31
+ const cta = ctaFor(opts.goal);
32
+ const base = brief.usp.length ? brief.usp : [brief.offer];
33
+ return base.slice(0, opts.count).map((u, i) => ({
34
+ key: ['benefit', 'proof', 'urgency', 'compare', 'emotion'][i] || `c${i}`, name: `컨셉 ${i + 1}`, angle: u, cta: cta.meta, theme: 'dark',
35
+ headlines: [fit(u, GOOGLE_LIMITS.headline), fit(brief.company, GOOGLE_LIMITS.headline), fit(brief.offer, GOOGLE_LIMITS.headline)].filter(Boolean),
36
+ bodies: [`${brief.offer} ${brief.company}에서 확인해 보세요.`, `${u}. ${brief.company}.`],
37
+ descriptions: [fit(brief.offer, GOOGLE_LIMITS.description)],
38
+ imagePrompt: `Abstract premium background in ${brief.palette.primary} and deep navy, soft gradients, no text, no logos, generous empty space`,
39
+ }));
40
+ }
41
+ /** 매체별 카피 규격 검사 요약 */
42
+ export function validateConcepts(concepts) {
43
+ const report = [];
44
+ for (const c of concepts)
45
+ for (const m of ['meta', 'google']) {
46
+ const r = checkCopy(c, m);
47
+ for (const p of r.problems)
48
+ report.push(`[${c.key}/${m}] ${p}`);
49
+ }
50
+ return { ok: !report.length, report };
51
+ }
52
+ export { wlen };
@@ -0,0 +1,9 @@
1
+ export declare function imageConfig(): {
2
+ key: string;
3
+ model: string;
4
+ } | null;
5
+ export declare function imagesAvailable(): boolean;
6
+ /** 컨셉 × 비율 배경 생성 → 파일 경로. 이미 있으면 건너뜀(재실행 안전). */
7
+ export declare function generateBackgrounds(dir: string, conceptKey: string, prompt: string, ratios?: string[], log?: (s: string) => void): Promise<Record<string, string | null>>;
8
+ /** 사용자 이미지 폴더 → 비율별 배경 매핑(가장 가까운 비율) */
9
+ export declare function userBackgrounds(folder: string, conceptKey: string): Record<string, string | null>;
@@ -0,0 +1,89 @@
1
+ // 배경 이미지 — OpenAI Images(gpt-image)로 비율 4종 생성. 키가 없으면 null → 렌더러가 브랜드 그래디언트로 대신한다. 사용자가 준 이미지(--images 폴더)도 그대로 배경으로 쓴다.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { loadConfig } from '../state.js';
5
+ import { BG_RATIOS } from './specs.js';
6
+ export function imageConfig() {
7
+ const c = loadConfig().image || {};
8
+ const key = process.env.ADPILOT_IMAGE_KEY || process.env.OPENAI_API_KEY || c.apiKey;
9
+ if (!key)
10
+ return null;
11
+ return { key, model: process.env.ADPILOT_IMAGE_MODEL || c.model || 'gpt-image-2.5-flare' };
12
+ }
13
+ export function imagesAvailable() { return imageConfig() !== null; }
14
+ const STYLE = 'Premium advertising key visual, cinematic, editorial quality, photoreal render or refined abstract. ABSOLUTELY NO text, letters, numbers, logos, watermarks, UI, or real people\'s faces. Composition leaves large calm negative space for a headline overlay.';
15
+ /** 컨셉 × 비율 배경 생성 → 파일 경로. 이미 있으면 건너뜀(재실행 안전). */
16
+ export async function generateBackgrounds(dir, conceptKey, prompt, ratios = Object.keys(BG_RATIOS), log = () => { }) {
17
+ const cfg = imageConfig();
18
+ const out = {};
19
+ const one = async (r) => {
20
+ const file = path.join(dir, `bg_${conceptKey}_${r}.png`);
21
+ if (fs.existsSync(file)) {
22
+ out[r] = file;
23
+ return;
24
+ }
25
+ if (!cfg) {
26
+ out[r] = null;
27
+ return;
28
+ }
29
+ let size = BG_RATIOS[r].openai;
30
+ let model = cfg.model;
31
+ const FALLBACK = ['gpt-image-2.5-flare', 'gpt-image-2', 'gpt-image-1.5', 'gpt-image-1'];
32
+ for (let attempt = 0; attempt < 5; attempt++) {
33
+ try {
34
+ const res = await fetch('https://api.openai.com/v1/images/generations', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.key}` }, body: JSON.stringify({ model, prompt: `${prompt} ${STYLE}`, size, quality: 'high', n: 1 }), signal: AbortSignal.timeout(300_000) });
35
+ if (!res.ok) {
36
+ const t = await res.text();
37
+ if (res.status === 400 && /does not exist|model/i.test(t) && /model/i.test(t) && !/size/i.test(t)) {
38
+ const next = FALLBACK.find((m) => m !== model && FALLBACK.indexOf(m) > FALLBACK.indexOf(model)) || FALLBACK.find((m) => m !== model);
39
+ if (next) {
40
+ log(` ${conceptKey} ${r}: 모델 ${model} 없음 → ${next}`);
41
+ model = next;
42
+ continue;
43
+ }
44
+ }
45
+ if (res.status === 400 && /size/i.test(t) && size !== '1024x1024') {
46
+ log(` ${conceptKey} ${r}: 이 모델은 ${size} 를 지원하지 않아요 → 1024x1024`);
47
+ size = '1024x1024';
48
+ continue;
49
+ }
50
+ if (res.status === 400 || res.status === 401 || res.status === 403)
51
+ throw Object.assign(new Error(`${res.status} ${t.slice(0, 200)}`), { fatal: true });
52
+ throw new Error(`${res.status} ${t.slice(0, 200)}`);
53
+ }
54
+ const d = (await res.json());
55
+ const b64 = d.data[0]?.b64_json;
56
+ if (b64)
57
+ fs.writeFileSync(file, Buffer.from(b64, 'base64'));
58
+ else if (d.data[0]?.url)
59
+ fs.writeFileSync(file, Buffer.from(await (await fetch(d.data[0].url)).arrayBuffer()));
60
+ else
61
+ throw new Error('이미지 응답이 비었어요');
62
+ out[r] = file;
63
+ log(` 배경 ${conceptKey} ${r} ✓`);
64
+ return;
65
+ }
66
+ catch (e) {
67
+ if (e.fatal) {
68
+ log(` ${conceptKey} ${r}: 이미지 생성 실패 — ${e.message.slice(0, 160)} (브랜드 그래디언트로 대체)`);
69
+ out[r] = null;
70
+ return;
71
+ }
72
+ log(` ${conceptKey} ${r}: 이미지 생성 재시도 (${e instanceof Error ? e.message.slice(0, 120) : e})`);
73
+ await new Promise((r2) => setTimeout(r2, 3000 * (attempt + 1)));
74
+ }
75
+ }
76
+ out[r] = null;
77
+ };
78
+ await Promise.all(ratios.map(one));
79
+ return out;
80
+ }
81
+ /** 사용자 이미지 폴더 → 비율별 배경 매핑(가장 가까운 비율) */
82
+ export function userBackgrounds(folder, conceptKey) {
83
+ const files = fs.readdirSync(folder).filter((f) => /\.(png|jpe?g|webp)$/i.test(f)).map((f) => path.join(folder, f));
84
+ const out = {};
85
+ const pick = files.find((f) => path.basename(f).toLowerCase().includes(conceptKey)) || files[0] || null;
86
+ for (const r of Object.keys(BG_RATIOS))
87
+ out[r] = files.find((f) => path.basename(f).includes(r)) || pick;
88
+ return out;
89
+ }