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
@@ -0,0 +1,407 @@
1
+ // 작업층 — CLI와 MCP가 똑같이 부르는 함수들. 화면 출력은 log 콜백으로만.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { connectedMedia, connector } from '../adapters/index.js';
5
+ import { buildBrief, explainBrief } from './brief.js';
6
+ import { generateConcepts, validateConcepts } from './creatives/copy.js';
7
+ import { generateBackgrounds, userBackgrounds } from './creatives/images.js';
8
+ import { renderConcept, renderLogo } from './creatives/render.js';
9
+ import { SIZES } from './creatives/specs.js';
10
+ import { aiAvailable, completeJson } from './llm.js';
11
+ import { fmtKrw } from './money.js';
12
+ import { proposeFor, withinCap } from './optimize.js';
13
+ import { makePlan } from './plan.js';
14
+ import { makeReport } from './report.js';
15
+ import { creativesDir, logDecision, newProject, projectDir, readDecisions, saveProject } from './state.js';
16
+ import { idsOf, snippet } from './tracking.js';
17
+ import { z } from 'zod';
18
+ const noop = () => { };
19
+ export function parseMarkets(s, siteLang = 'ko') {
20
+ if (!s)
21
+ return [{ country: 'KR', language: siteLang }];
22
+ const langOf = { KR: 'ko', US: 'en', GB: 'en', AU: 'en', CA: 'en', SG: 'en', JP: 'ja', TW: 'zh-TW', DE: 'de', FR: 'fr', ES: 'es', VN: 'vi', TH: 'th', ID: 'id', IN: 'en', HK: 'zh-TW', MY: 'en', PH: 'en', BR: 'pt', MX: 'es', IT: 'it', NL: 'nl' };
23
+ return s.split(',').map((x) => x.trim()).filter(Boolean).map((x) => { const [c, l] = x.split(':'); const cc = c.toUpperCase(); return { country: cc, language: l || langOf[cc] || 'en' }; });
24
+ }
25
+ export async function opBrief(url, o) {
26
+ const log = o.log || noop;
27
+ if (!Number.isFinite(o.budget) || o.budget < 100_000)
28
+ throw new Error('월 예산은 10만원 이상이어야 매체 최소 예산을 맞출 수 있어요.');
29
+ if (!/^https?:\/\//.test(url))
30
+ url = `https://${url}`;
31
+ log(`사이트 읽는 중… ${url}`);
32
+ const tmpMarkets = parseMarkets(o.markets);
33
+ const { brief, site } = await buildBrief(url, { goal: o.goal, markets: tmpMarkets, hints: o.hints });
34
+ const markets = o.markets ? tmpMarkets : [{ country: 'KR', language: site.lang || 'ko' }];
35
+ if (!o.markets && site.lang && site.lang !== 'ko')
36
+ markets[0] = { country: site.lang === 'en' ? 'US' : site.lang === 'ja' ? 'JP' : 'KR', language: site.lang };
37
+ if (site.lang !== brief.language && o.markets)
38
+ brief.language = markets[0].language;
39
+ const p = newProject(url, { monthlyKrw: o.budget, goal: o.goal, markets, endDate: o.endDate });
40
+ p.brief = brief;
41
+ p.status = 'briefed';
42
+ p.tracking = { ...(p.tracking || {}), pixelId: p.tracking?.pixelId || site.pixelIds[0], gtagId: p.tracking?.gtagId || site.gtagIds[0], ga4Id: p.tracking?.ga4Id || site.ga4Ids[0] };
43
+ saveProject(p);
44
+ logDecision(p.slug, { kind: 'brief', action: 'created', by: 'user', data: { budget: o.budget, goal: o.goal, markets } });
45
+ return { project: p, explain: explainBrief(brief) };
46
+ }
47
+ export async function opCreatives(p, o) {
48
+ const log = o.log || noop;
49
+ if (!p.brief)
50
+ throw new Error('브리프가 먼저 필요해요.');
51
+ if (p.brief.specialCategory)
52
+ throw new Error(`「${p.brief.specialCategory}」 특별 광고 카테고리는 접수할 수 없어요.`);
53
+ const count = o.count || 3;
54
+ const dir = creativesDir(p.slug);
55
+ if (o.force || o.feedback || !p.concepts?.length) {
56
+ log(`컨셉 ${count}개·카피 만드는 중…${o.feedback ? ` (수정 요청: ${o.feedback})` : ''}`);
57
+ p.concepts = await generateConcepts(p.brief, { goal: p.budget.goal, count, feedback: o.feedback, previous: p.concepts });
58
+ if (o.feedback || o.force)
59
+ for (const f of fs.readdirSync(dir))
60
+ if (/^(?!bg_).*\.png$/.test(f))
61
+ fs.rmSync(path.join(dir, f));
62
+ if (o.feedback)
63
+ for (const f of fs.readdirSync(dir))
64
+ if (/^bg_/.test(f))
65
+ fs.rmSync(path.join(dir, f));
66
+ }
67
+ const v = validateConcepts(p.concepts);
68
+ if (!v.ok)
69
+ log(`규격 검사 경고:\n ${v.report.join('\n ')}`);
70
+ const assets = [];
71
+ // 로고 자산(구글 RDA 용 1:1 · 4:1)
72
+ assets.push(...(await renderLogo(dir, p.brief)));
73
+ for (const c of p.concepts) {
74
+ let bgs;
75
+ if (o.images)
76
+ bgs = userBackgrounds(o.images, c.key);
77
+ else if (o.noImages)
78
+ bgs = {};
79
+ else {
80
+ log(`배경 이미지 생성 ${c.key}…`);
81
+ bgs = await generateBackgrounds(dir, c.key, c.imagePrompt, undefined, log);
82
+ }
83
+ log(`합성 ${c.key} × ${SIZES.length}규격…`);
84
+ assets.push(...(await renderConcept(dir, p.brief, c, bgs, { force: o.force || !!o.feedback, log })));
85
+ }
86
+ p.assets = assets;
87
+ p.status = 'creatives';
88
+ fs.writeFileSync(path.join(dir, 'copy.json'), JSON.stringify(p.concepts, null, 2));
89
+ fs.writeFileSync(path.join(dir, 'index.html'), galleryHtml(p));
90
+ saveProject(p);
91
+ logDecision(p.slug, { kind: 'creatives', action: o.feedback ? 'regenerated' : 'generated', by: 'user', reason: o.feedback, data: { concepts: p.concepts.map((c) => c.key), assets: assets.length } });
92
+ return { project: p, report: v.report };
93
+ }
94
+ export function galleryHtml(p) {
95
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
96
+ const groups = (p.concepts || []).map((c) => { const files = (p.assets || []).filter((a) => a.concept === c.key); return `<section><h2>${esc(c.name)} <small>${esc(c.key)} · ${esc(c.angle)}</small></h2><p><b>${esc(c.headlines[0] || '')}</b><br>${esc(c.bodies[0] || '')}</p><details><summary>카피 전부(제목 ${c.headlines.length} · 본문 ${c.bodies.length})</summary><ul>${[...c.headlines, ...c.bodies, ...c.descriptions].map((t) => `<li>${esc(t)}</li>`).join('')}</ul></details><div class="grid">${files.map((a) => `<figure><img src="${path.basename(a.file)}" style="width:${Math.min(360, a.w)}px"><figcaption>${a.w}×${a.h} · ${a.medium}</figcaption></figure>`).join('')}</div></section>`; }).join('');
97
+ return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>${esc(p.brief?.company || p.slug)} 광고 소재</title><style>body{font-family:Pretendard,'Apple SD Gothic Neo',sans-serif;margin:0;padding:32px;color:#0b1526;background:#fff}h1{font-size:22px}h2{font-size:18px;margin:36px 0 6px}h2 small{color:#6b7a90;font-weight:500;font-size:13px}.grid{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start}figure{margin:0;background:#f4f7fb;border-radius:10px;padding:8px}img{display:block;border-radius:6px;box-shadow:0 6px 24px -10px rgba(0,0,0,.3)}figcaption{font-size:11px;color:#6b7a90;margin-top:6px}p{max-width:720px}</style></head><body><h1>${esc(p.brief?.company || p.slug)} 광고 소재 <small style="color:#6b7a90;font-size:13px">${new Date().toISOString().slice(0, 10)} · ${(p.assets || []).filter((a) => a.concept !== '_logo').length}장</small></h1>${groups}</body></html>`;
98
+ }
99
+ export async function opPlan(p, o = {}) {
100
+ p.plan = await makePlan(p, { media: o.media });
101
+ p.status = 'planned';
102
+ saveProject(p);
103
+ logDecision(p.slug, { kind: 'plan', action: 'created', by: 'user', data: { daily: p.plan.dailyKrw, media: p.plan.media.map((m) => `${m.medium}:${m.dailyBudgetKrw}`) } });
104
+ return p;
105
+ }
106
+ export async function opBuild(p, o = {}) {
107
+ const log = o.log || noop;
108
+ if (!p.brief || !p.concepts?.length || !p.assets?.length)
109
+ throw new Error('소재가 먼저 필요해요 (`adpilot creatives`).');
110
+ if (!p.plan)
111
+ await opPlan(p, { media: o.media });
112
+ const plan = p.plan;
113
+ const media = (o.media && o.media.length ? o.media : plan.media.map((m) => m.medium));
114
+ const warnings = [];
115
+ const errors = [];
116
+ const concepts = p.concepts.filter((c) => plan.concepts.includes(c.key));
117
+ for (const m of media) {
118
+ const c = connector(m);
119
+ const pm = plan.media.find((x) => x.medium === m);
120
+ if (!c || !pm) {
121
+ errors.push(`${m}: 연결·계획이 없어요`);
122
+ continue;
123
+ }
124
+ log(`${m}: ${o.validate ? '검증' : '생성'} 시작 (하루 ${fmtKrw(pm.dailyBudgetKrw)})`);
125
+ try {
126
+ let conversionActionRn;
127
+ if (m === 'google' && !o.validate && 'ensureConversionAction' in c) {
128
+ const ca = await c.ensureConversionAction(p.slug, p.budget.goal);
129
+ conversionActionRn = ca.resourceName;
130
+ p.tracking = { ...(p.tracking || {}), gtagId: ca.gtagId || p.tracking?.gtagId, gtagLabel: ca.label || p.tracking?.gtagLabel, conversionActionRn: ca.resourceName };
131
+ saveProject(p);
132
+ log(` google 전환 액션 ${ca.gtagId}/${ca.label}`);
133
+ }
134
+ const r = await c.build({ slug: p.slug, brief: p.brief, concepts, assets: p.assets, goal: p.budget.goal, markets: plan.markets, dailyBudgetMinor: pm.dailyBudgetMinor, currency: pm.currency, endDate: plan.endDate, landingUrl: p.url, utmCampaign: `adpilot_${p.slug}`, pixelId: p.tracking?.pixelId, conversionActionRn, existing: p.placements, validateOnly: o.validate, log });
135
+ warnings.push(...r.warnings);
136
+ if (!o.validate) {
137
+ p.placements = mergePlacements(p.placements, r.placements);
138
+ saveProject(p);
139
+ logDecision(p.slug, { kind: 'build', medium: m, action: 'created_paused', by: 'user', data: r.placements.filter((x) => x.medium === m).map((x) => `${x.kind}:${x.externalId}`) });
140
+ }
141
+ }
142
+ catch (e) {
143
+ const msg = e instanceof Error ? e.message : String(e);
144
+ const hint = e.hint;
145
+ errors.push(`${m}: ${msg}${hint ? `\n → ${hint}` : ''}`);
146
+ log(` ❌ ${m}: ${msg}`);
147
+ saveProject(p); // 부분 성공분 보존
148
+ }
149
+ }
150
+ if (!o.validate && p.placements.some((x) => x.kind === 'ad')) {
151
+ p.status = errors.length ? p.status : 'built';
152
+ saveProject(p);
153
+ }
154
+ return { project: p, warnings, errors };
155
+ }
156
+ function mergePlacements(a, b) { const m = new Map(a.map((x) => [`${x.medium}/${x.kind}/${x.concept || ''}`, x])); for (const x of b)
157
+ m.set(`${x.medium}/${x.kind}/${x.concept || ''}`, x); return [...m.values()]; }
158
+ export async function opPreview(p, o = {}) {
159
+ const items = [];
160
+ for (const m of [...new Set(p.placements.map((x) => x.medium))]) {
161
+ const c = connector(m);
162
+ if (c)
163
+ try {
164
+ items.push(...(await c.preview(p.placements)));
165
+ }
166
+ catch (e) {
167
+ items.push({ name: `${m} 미리보기 실패`, html: `<p>${e instanceof Error ? e.message : e}</p>` });
168
+ }
169
+ }
170
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
171
+ const local = (concept) => { const c = p.concepts?.find((x) => x.key === concept); const img = p.assets?.find((a) => a.concept === concept && a.w === 1200 && a.h === 628); return c ? `<div class="rda"><img src="../creatives/${img ? path.basename(img.file) : ''}"><div class="t"><b>${esc(c.headlines[0])}</b><span>${esc(c.descriptions[0] || '')}</span><em>${esc(p.brief?.company || '')}</em></div></div>` : ''; };
172
+ const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>${esc(p.brief?.company || p.slug)} 광고 미리보기</title><style>body{font-family:Pretendard,'Apple SD Gothic Neo',sans-serif;margin:0;padding:28px;background:#f2f4f8;color:#0b1526}h1{font-size:20px}.row{display:flex;flex-wrap:wrap;gap:20px}.card{background:#fff;border-radius:12px;padding:14px;box-shadow:0 8px 30px -18px rgba(0,0,0,.4)}.card h3{margin:0 0 10px;font-size:14px;color:#41506a}.rda{width:300px;border:1px solid #e3e8ef;border-radius:8px;overflow:hidden;font-size:13px}.rda img{width:100%;display:block}.rda .t{padding:10px;display:flex;flex-direction:column;gap:4px}.rda em{color:#6b7a90;font-style:normal;font-size:11px}iframe{border:0}.note{background:#fff;border-radius:12px;padding:14px 18px;margin-bottom:20px;font-size:14px;line-height:1.6}</style></head><body><h1>${esc(p.brief?.company || p.slug)} 광고 미리보기 <small style="color:#6b7a90;font-size:13px">${new Date().toISOString().slice(0, 16).replace('T', ' ')} · 전부 대기 상태(노출 안 됨)</small></h1><div class="note">${esc(p.plan?.explain || '').replace(/\n/g, '<br>')}</div><div class="row">${items.map((i) => `<div class="card"><h3>${esc(i.name)}</h3>${/gpreview/.test(i.html) ? local(i.concept) : i.html}</div>`).join('')}</div></body></html>`;
173
+ const dir = path.join(projectDir(p.slug), 'preview');
174
+ fs.mkdirSync(dir, { recursive: true });
175
+ const file = path.join(dir, 'index.html');
176
+ fs.writeFileSync(file, html);
177
+ if (p.status === 'built') {
178
+ p.status = 'built';
179
+ }
180
+ return { file, count: items.length };
181
+ }
182
+ export async function opLaunch(p, o = {}) {
183
+ const log = o.log || noop;
184
+ if (!p.plan)
185
+ throw new Error('계획이 없어요.');
186
+ if (!p.placements.some((x) => x.kind === 'ad'))
187
+ throw new Error('만들어진 광고가 없어요 (`adpilot build`).');
188
+ // 예산 상한 재확인
189
+ const daily = p.plan.media.reduce((n, m) => n + m.dailyBudgetKrw, 0);
190
+ if (daily > p.plan.dailyKrw * 1.02 + 2000)
191
+ throw new Error(`일 예산 합(${fmtKrw(daily)})이 계획 상한(${fmtKrw(p.plan.dailyKrw)})을 넘어 광고를 시작할 수 없어요.`);
192
+ const media = (o.media && o.media.length ? o.media : [...new Set(p.placements.map((x) => x.medium))]);
193
+ const launched = [];
194
+ const errors = [];
195
+ for (const m of media) {
196
+ const c = connector(m);
197
+ if (!c) {
198
+ errors.push(`${m}: 연결 없음`);
199
+ continue;
200
+ }
201
+ try {
202
+ await c.setStatus(p.placements, 'ACTIVE');
203
+ launched.push(m);
204
+ log(`${m}: 켰어요(매체 검수 뒤 노출 시작)`);
205
+ logDecision(p.slug, { kind: 'launch', medium: m, action: 'ACTIVE', by: 'user' });
206
+ }
207
+ catch (e) {
208
+ errors.push(`${m}: ${e instanceof Error ? e.message : e}`);
209
+ }
210
+ }
211
+ if (launched.length) {
212
+ p.status = 'live';
213
+ p.approvedAt = p.approvedAt || new Date().toISOString();
214
+ p.launchedAt = p.launchedAt || new Date().toISOString();
215
+ }
216
+ saveProject(p);
217
+ return { project: p, launched, errors };
218
+ }
219
+ export async function opPause(p, o = {}) {
220
+ const media = (o.media && o.media.length ? o.media : [...new Set(p.placements.map((x) => x.medium))]);
221
+ const paused = [];
222
+ const errors = [];
223
+ for (const m of media) {
224
+ const c = connector(m);
225
+ if (!c)
226
+ continue;
227
+ try {
228
+ await c.setStatus(p.placements, 'PAUSED');
229
+ paused.push(m);
230
+ logDecision(p.slug, { kind: 'pause', medium: m, action: 'PAUSED', by: 'user' });
231
+ }
232
+ catch (e) {
233
+ errors.push(`${m}: ${e instanceof Error ? e.message : e}`);
234
+ }
235
+ }
236
+ if (paused.length) {
237
+ p.status = 'paused';
238
+ saveProject(p);
239
+ }
240
+ return { paused, errors };
241
+ }
242
+ export function dateRange(days) { const until = new Date(); const since = new Date(Date.now() - (days - 1) * 86400_000); return { since: since.toISOString().slice(0, 10), until: until.toISOString().slice(0, 10) }; }
243
+ export async function opStatus(p, o = {}) {
244
+ const range = dateRange(o.days || 7);
245
+ const metrics = {};
246
+ const policy = {};
247
+ for (const m of [...new Set(p.placements.map((x) => x.medium))]) {
248
+ const c = connector(m);
249
+ if (!c)
250
+ continue;
251
+ metrics[m] = await c.metrics(p.placements, range).catch(() => []);
252
+ policy[m] = await c.policy(p.placements).catch(() => []);
253
+ }
254
+ const mdir = path.join(projectDir(p.slug), 'metrics');
255
+ fs.mkdirSync(mdir, { recursive: true });
256
+ fs.writeFileSync(path.join(mdir, `${range.until}.json`), JSON.stringify({ range, metrics, policy }, null, 2));
257
+ return { metrics, policy, range };
258
+ }
259
+ export async function opOptimize(p, o = {}) {
260
+ const log = o.log || noop;
261
+ if (!p.plan)
262
+ throw new Error('계획이 없어요.');
263
+ const { metrics } = await opStatus(p, { days: 7 });
264
+ const proposals = [];
265
+ for (const m of Object.keys(metrics))
266
+ proposals.push(...proposeFor(m, metrics[m], p.plan, p.launchedAt));
267
+ const applied = [];
268
+ if (o.apply) {
269
+ const newDaily = {};
270
+ for (const pr of proposals)
271
+ if (pr.newDailyMinor) {
272
+ const pm = p.plan.media.find((x) => x.medium === pr.medium);
273
+ newDaily[pr.medium] = Math.round(pm.dailyBudgetKrw * (pr.newDailyMinor / pm.dailyBudgetMinor));
274
+ }
275
+ const capOk = withinCap(p.plan, newDaily);
276
+ for (const pr of proposals) {
277
+ const c = connector(pr.medium);
278
+ if (!c)
279
+ continue;
280
+ try {
281
+ if (pr.kind === 'pause_ad' && pr.target) {
282
+ const ads = p.placements.filter((x) => x.medium === pr.medium && x.kind === 'ad');
283
+ const active = ads.filter((x) => x.status !== 'PAUSED');
284
+ if (active.length <= 1) {
285
+ log(` ${pr.medium}: 마지막 광고는 끄지 않아요`);
286
+ continue;
287
+ }
288
+ const t = p.placements.find((x) => x.externalId === pr.target);
289
+ if (!t)
290
+ continue;
291
+ await c.setStatus([t], 'PAUSED');
292
+ applied.push(`${pr.medium} ${pr.concept} 광고 끔`);
293
+ logDecision(p.slug, { kind: 'optimize', medium: pr.medium, target: pr.target, action: 'pause_ad', reason: pr.reason, by: 'rule' });
294
+ }
295
+ else if ((pr.kind === 'budget_up' || pr.kind === 'budget_down') && pr.newDailyMinor) {
296
+ if (pr.kind === 'budget_up' && !capOk) {
297
+ log(` ${pr.medium}: 월 상한 때문에 예산을 올리지 않아요`);
298
+ continue;
299
+ }
300
+ const pm = p.plan.media.find((x) => x.medium === pr.medium);
301
+ await c.updateBudget(p.placements, pr.newDailyMinor);
302
+ pm.dailyBudgetKrw = newDaily[pr.medium];
303
+ pm.dailyBudgetMinor = pr.newDailyMinor;
304
+ saveProject(p);
305
+ applied.push(`${pr.medium} 일 예산 → ${fmtKrw(pm.dailyBudgetKrw)}`);
306
+ logDecision(p.slug, { kind: 'optimize', medium: pr.medium, action: pr.kind, reason: pr.reason, by: 'rule', data: { newDailyMinor: pr.newDailyMinor } });
307
+ }
308
+ }
309
+ catch (e) {
310
+ log(` ❌ ${pr.medium}: ${e instanceof Error ? e.message : e}`);
311
+ }
312
+ }
313
+ }
314
+ return { proposals, applied };
315
+ }
316
+ export async function opReport(p, o = {}) {
317
+ const st = await opStatus(p, { days: o.days || 7 });
318
+ const r = await makeReport({ project: p, range: st.range, rows: st.metrics, decisions: readDecisions(p.slug), policy: st.policy });
319
+ const dir = path.join(projectDir(p.slug), 'reports');
320
+ fs.mkdirSync(dir, { recursive: true });
321
+ const file = path.join(dir, `${st.range.until}.html`);
322
+ fs.writeFileSync(file, r.html);
323
+ fs.writeFileSync(file.replace(/\.html$/, '.md'), r.markdown);
324
+ return { ...r, file };
325
+ }
326
+ export async function opAudit(o = {}) {
327
+ const media = (o.media && o.media.length ? o.media : connectedMedia());
328
+ const out = {};
329
+ for (const m of media) {
330
+ const c = connector(m);
331
+ if (!c)
332
+ continue;
333
+ const rows = await c.inventory();
334
+ const findings = [];
335
+ const camps = rows.filter((r) => r.level === 'campaign');
336
+ for (const r of camps) {
337
+ if (/ACTIVE|ENABLED/.test(r.status) && (r.impressions30d || 0) === 0)
338
+ findings.push(`「${r.name}」 노출 중인데 30일 노출 0 — 검수 거절·예산 0·타겟 문제`);
339
+ if (/ACTIVE|ENABLED/.test(r.status) && r.spend30d && r.clicks30d === 0)
340
+ findings.push(`「${r.name}」 ${r.spend30d} 썼는데 클릭 0`);
341
+ }
342
+ const active = camps.filter((r) => /ACTIVE|ENABLED/.test(r.status));
343
+ const names = active.map((r) => `${r.name.replace(/\[adp:[^\]]+\]\s*/, '').split('·')[0].trim()}|${r.objective || ''}`);
344
+ const dup = names.filter((n, i) => names.indexOf(n) !== i);
345
+ if (dup.length)
346
+ findings.push(`같은 이름·같은 유형의 노출 중인 캠페인 중복: ${[...new Set(dup.map((d) => d.split('|')[0]))].join(', ')} — 서로 경매 경쟁`);
347
+ for (const r of rows.filter((r) => r.level === 'ad' && r.issues?.length))
348
+ findings.push(`광고 「${r.name}」: ${r.issues.join('; ')}`);
349
+ out[m] = { rows, findings };
350
+ }
351
+ return out;
352
+ }
353
+ export function opTrackSnippet(p, o = {}) { return snippet(idsOf(p), { goal: p.budget.goal, spa: o.spa }); }
354
+ /** 자연어 수정 — 「예산 200만원으로」「미국 빼」「크레딧 컨셉 빼고 더 고급스럽게」 → 구조화된 변경 → 규칙 안에서 적용 */
355
+ const ChangeSchema = z.object({ budgetKrw: z.number().nullable().default(null), goal: z.enum(['traffic', 'lead', 'signup', 'purchase']).nullable().default(null), markets: z.string().nullable().default(null), dropConcepts: z.array(z.string()).default([]), creativeFeedback: z.string().nullable().default(null), pause: z.boolean().default(false), resume: z.boolean().default(false), summary: z.string() });
356
+ export async function opChange(p, request, o = {}) {
357
+ const log = o.log || noop;
358
+ if (!aiAvailable())
359
+ throw new Error('자연어 수정은 LLM 키가 필요해요. 대신 `adpilot budget <원>` · `adpilot creatives --feedback "…"` 를 써 주세요.');
360
+ const ch = await completeJson({ schema: ChangeSchema, maxTokens: 800, system: `광고주의 자연어 요청을 구조화한다. 현재 프로젝트 상태를 참고해 바꿀 값만 채우고 나머지는 null/빈 배열. markets는 "US:en,KR:ko" 형식. dropConcepts는 현재 컨셉 key 중 빼라는 것. creativeFeedback은 소재·카피 방향 수정 요청(있으면 소재를 다시 만든다). summary는 무엇을 바꾸는지 한 문장(존댓말).`, user: JSON.stringify({ request, current: { budgetKrw: p.budget.monthlyKrw, goal: p.budget.goal, markets: p.budget.markets, concepts: p.concepts?.map((c) => ({ key: c.key, name: c.name })), status: p.status } }) });
361
+ const steps = [];
362
+ if (ch.budgetKrw && ch.budgetKrw !== p.budget.monthlyKrw) {
363
+ p.budget.monthlyKrw = ch.budgetKrw;
364
+ steps.push(`월 예산 → ${fmtKrw(ch.budgetKrw)}`);
365
+ }
366
+ if (ch.goal && ch.goal !== p.budget.goal) {
367
+ p.budget.goal = ch.goal;
368
+ steps.push(`목표 → ${ch.goal}`);
369
+ }
370
+ if (ch.markets) {
371
+ p.budget.markets = parseMarkets(ch.markets);
372
+ steps.push(`시장 → ${ch.markets}`);
373
+ }
374
+ if (ch.dropConcepts.length && p.concepts) {
375
+ p.concepts = p.concepts.filter((c) => !ch.dropConcepts.includes(c.key));
376
+ p.assets = p.assets?.filter((a) => !ch.dropConcepts.includes(a.concept));
377
+ steps.push(`컨셉 제외 → ${ch.dropConcepts.join(', ')}`);
378
+ }
379
+ saveProject(p);
380
+ if (ch.creativeFeedback) {
381
+ steps.push(`소재 다시 만들기: ${ch.creativeFeedback}`);
382
+ await opCreatives(p, { feedback: ch.creativeFeedback, count: p.concepts?.length || 3, log });
383
+ }
384
+ if (p.plan && (ch.budgetKrw || ch.markets || ch.dropConcepts.length)) {
385
+ await opPlan(p, { log });
386
+ steps.push('계획 다시 계산');
387
+ if (p.placements.length) {
388
+ for (const pm of p.plan.media) {
389
+ const c = connector(pm.medium);
390
+ if (c)
391
+ await c.updateBudget(p.placements, pm.dailyBudgetMinor).catch(() => { });
392
+ }
393
+ steps.push('매체 일 예산 반영');
394
+ }
395
+ }
396
+ if (ch.pause) {
397
+ await opPause(p, { log });
398
+ steps.push('전부 끔');
399
+ }
400
+ if (ch.resume && p.placements.length) {
401
+ await opLaunch(p, { log });
402
+ steps.push('다시 시작');
403
+ }
404
+ logDecision(p.slug, { kind: 'change', action: 'nl_change', by: 'llm', reason: request, data: ch });
405
+ return { summary: ch.summary, steps };
406
+ }
407
+ export async function opCheckSummary(items) { return { pass: items.filter((i) => i.ok === true).length, fail: items.filter((i) => i.ok === false).length, unknown: items.filter((i) => i.ok === null).length }; }
@@ -0,0 +1,27 @@
1
+ import type { MetricRow } from '../adapters/types.js';
2
+ import type { Plan } from './state.js';
3
+ export type Proposal = {
4
+ medium: 'meta' | 'google';
5
+ kind: 'pause_ad' | 'budget_up' | 'budget_down' | 'note';
6
+ target?: string;
7
+ concept?: string;
8
+ reason: string;
9
+ newDailyMinor?: number;
10
+ };
11
+ export declare function summarize(rows: MetricRow[]): {
12
+ days: number;
13
+ ctr: number;
14
+ cpc: number;
15
+ cpa: number;
16
+ id: string;
17
+ name: string;
18
+ concept?: string;
19
+ spend: number;
20
+ impressions: number;
21
+ clicks: number;
22
+ conversions: number;
23
+ currency: string;
24
+ }[];
25
+ export declare function proposeFor(medium: 'meta' | 'google', rows: MetricRow[], plan: Plan, launchedAt?: string): Proposal[];
26
+ /** 예산 재배분이 월 상한을 넘지 않는지 — 매체별 새 일 예산(원 환산) 합 ≤ 계획 일 예산 */
27
+ export declare function withinCap(plan: Plan, newDailyKrwByMedium: Record<string, number>): boolean;
@@ -0,0 +1,56 @@
1
+ export function summarize(rows) {
2
+ const by = new Map();
3
+ for (const r of rows) {
4
+ const k = r.id;
5
+ const s = by.get(k) || { id: r.id, name: r.name, concept: r.concept, spend: 0, impressions: 0, clicks: 0, conversions: 0, currency: r.currency, days: new Set() };
6
+ s.spend += r.spend;
7
+ s.impressions += r.impressions;
8
+ s.clicks += r.clicks;
9
+ s.conversions += r.conversions;
10
+ if (r.date)
11
+ s.days.add(r.date);
12
+ by.set(k, s);
13
+ }
14
+ return [...by.values()].map((s) => ({ ...s, days: s.days.size, ctr: s.impressions ? s.clicks / s.impressions : 0, cpc: s.clicks ? s.spend / s.clicks : 0, cpa: s.conversions ? s.spend / s.conversions : 0 }));
15
+ }
16
+ export function proposeFor(medium, rows, plan, launchedAt) {
17
+ const ads = summarize(rows);
18
+ const out = [];
19
+ const hours = launchedAt ? (Date.now() - new Date(launchedAt).getTime()) / 3600_000 : 0;
20
+ const pm = plan.media.find((m) => m.medium === medium);
21
+ if (!ads.length)
22
+ return [{ medium, kind: 'note', reason: '아직 지표가 없어요(검수 중이거나 노출 전).' }];
23
+ if (hours < 48)
24
+ return [{ medium, kind: 'note', reason: `광고 시작 ${Math.floor(hours)}시간 뒤 — 학습 단계(48시간)라 소재를 건드리지 않아요.` }];
25
+ const eligible = ads.filter((a) => a.impressions >= 2000);
26
+ if (eligible.length < 2)
27
+ return [{ medium, kind: 'note', reason: '노출 2,000 이상인 소재가 2개 미만 — 비교하기엔 일러요.' }];
28
+ const useCpa = eligible.filter((a) => a.conversions > 0).length >= 2 && eligible.reduce((n, a) => n + a.conversions, 0) >= 20;
29
+ const score = (a) => (useCpa ? (a.conversions ? -a.cpa : -Infinity) : a.ctr);
30
+ const sorted = [...eligible].sort((a, b) => score(b) - score(a));
31
+ const best = sorted[0];
32
+ const worst = sorted[sorted.length - 1];
33
+ const ratio = useCpa ? (best.cpa && worst.cpa ? worst.cpa / best.cpa : 1) : best.ctr && worst.ctr ? best.ctr / worst.ctr : 1;
34
+ if (sorted.length >= 2 && ratio >= 1.8)
35
+ out.push({ medium, kind: 'pause_ad', target: worst.id, concept: worst.concept, reason: useCpa ? `「${worst.concept || worst.name}」 전환 단가가 1등의 ${ratio.toFixed(1)}배 — 끄고 예산을 잘 되는 쪽에 몰아요` : `「${worst.concept || worst.name}」 클릭률 ${(worst.ctr * 100).toFixed(2)}%가 1등(${(best.ctr * 100).toFixed(2)}%)의 1/${ratio.toFixed(1)} — 끄고 예산을 잘 되는 쪽에 몰아요` });
36
+ // 예산: 전체 평균 CTR이 1% 이상이고 예산 소진율이 높으면 +20% (상한 안에서), 소진율 낮으면 유지
37
+ if (pm) {
38
+ const days = Math.max(1, ...ads.map((a) => a.days));
39
+ const spendPerDay = ads.reduce((n, a) => n + a.spend, 0) / days;
40
+ const planMajor = pm.dailyBudgetMinor / (pm.currency === 'KRW' || pm.currency === 'JPY' ? 1 : 100);
41
+ const pacing = planMajor ? spendPerDay / planMajor : 0;
42
+ const avgCtr = eligible.reduce((n, a) => n + a.clicks, 0) / Math.max(1, eligible.reduce((n, a) => n + a.impressions, 0));
43
+ if (pacing > 0.9 && avgCtr >= 0.01)
44
+ out.push({ medium, kind: 'budget_up', reason: `예산을 ${Math.round(pacing * 100)}% 소진하고 클릭률 ${(avgCtr * 100).toFixed(2)}% — 이 매체에 +20% 제안(월 상한 안에서 다른 매체에서 옮김)`, newDailyMinor: Math.floor(pm.dailyBudgetMinor * 1.2) });
45
+ else if (pacing < 0.4)
46
+ out.push({ medium, kind: 'budget_down', reason: `예산의 ${Math.round(pacing * 100)}%만 쓰고 있어요 — 노출 경쟁이 약하거나 타겟이 좁아요. -20% 후 다른 매체로`, newDailyMinor: Math.floor(pm.dailyBudgetMinor * 0.8) });
47
+ }
48
+ if (!out.length)
49
+ out.push({ medium, kind: 'note', reason: `소재 간 차이가 크지 않아 그대로 둡니다 (1등 「${best.concept || best.name}」).` });
50
+ return out;
51
+ }
52
+ /** 예산 재배분이 월 상한을 넘지 않는지 — 매체별 새 일 예산(원 환산) 합 ≤ 계획 일 예산 */
53
+ export function withinCap(plan, newDailyKrwByMedium) {
54
+ const total = plan.media.reduce((n, m) => n + (newDailyKrwByMedium[m.medium] ?? m.dailyBudgetKrw), 0);
55
+ return total <= plan.dailyKrw * 1.02 + 2000;
56
+ }
@@ -0,0 +1,22 @@
1
+ import type { Medium } from '../adapters/types.js';
2
+ import type { Goal, Market, Plan, Project } from './state.js';
3
+ export type PlanInput = {
4
+ monthlyKrw: number;
5
+ goal: Goal;
6
+ markets: Market[];
7
+ conceptKeys: string[];
8
+ briefLanguage: string;
9
+ endDate?: string;
10
+ hasTracking: boolean;
11
+ media: {
12
+ medium: Medium;
13
+ currency: string;
14
+ }[];
15
+ shares?: Partial<Record<Medium, number>>;
16
+ };
17
+ /** 순수 계산 — 계정 통화만 있으면 어디서든(CLI·서버) 같은 계획을 만든다 */
18
+ export declare function computePlan(i: PlanInput): Plan;
19
+ export declare function makePlan(p: Project, opts?: {
20
+ media?: Medium[];
21
+ shares?: Partial<Record<Medium, number>>;
22
+ }): Promise<Plan>;
@@ -0,0 +1,59 @@
1
+ // 계획 — 월 예산을 매체·시장·컨셉으로 나누고(작은 예산은 분할 수를 줄임) 계정 통화 최소 단위로 환산한다. 사람 말 설명(explain) 포함. 예산 상한은 여기서만 계산된다.
2
+ import { connectedMedia, connector } from '../adapters/index.js';
3
+ import { fmtKrw, fmtMinor, krwToMinor, minDailyMinor, minorToKrw } from './money.js';
4
+ const MEDIUM_LABEL = { meta: 'Meta(페이스북·인스타그램)', google: 'Google(디스플레이)' };
5
+ /** 순수 계산 — 계정 통화만 있으면 어디서든(CLI·서버) 같은 계획을 만든다 */
6
+ export function computePlan(i) {
7
+ if (!i.media.length)
8
+ throw new Error('연결된 매체가 없어요.');
9
+ const monthly = i.monthlyKrw;
10
+ if (!Number.isFinite(monthly) || monthly < 100_000)
11
+ throw new Error('월 예산은 10만원 이상이어야 매체 최소 예산을 맞출 수 있어요.');
12
+ const daily = Math.floor(monthly / 30.4);
13
+ const perMediumDaily = daily / i.media.length;
14
+ const maxConcepts = Math.max(1, Math.min(i.conceptKeys.length, Math.floor(perMediumDaily / 5000)));
15
+ const concepts = i.conceptKeys.slice(0, maxConcepts);
16
+ const base = { meta: 0.55, google: 0.45 };
17
+ let shares = i.media.map((m) => i.shares?.[m.medium] ?? base[m.medium]);
18
+ const sum = shares.reduce((a, b) => a + b, 0);
19
+ shares = shares.map((s) => s / sum);
20
+ const out = [];
21
+ i.media.forEach((m, idx) => {
22
+ const dailyKrw = Math.floor(daily * shares[idx]);
23
+ let minor = krwToMinor(dailyKrw, m.currency);
24
+ const min = minDailyMinor(m.medium, m.currency) * Math.max(1, m.medium === 'meta' ? concepts.length : 1);
25
+ let note = '';
26
+ if (minor < min) {
27
+ minor = min;
28
+ note = `매체 최소 예산(${fmtMinor(min, m.currency)})에 맞춰 올렸어요`;
29
+ }
30
+ out.push({ medium: m.medium, share: Math.round(shares[idx] * 100) / 100, dailyBudgetKrw: minorToKrw(minor, m.currency), currency: m.currency, dailyBudgetMinor: minor, note });
31
+ });
32
+ const total = out.reduce((n, m) => n + m.dailyBudgetKrw, 0);
33
+ if (total > daily * 1.02 + 2000)
34
+ throw new Error(`매체 최소 예산 합(${fmtKrw(total)}/일)이 월 예산(${fmtKrw(monthly)} → 하루 ${fmtKrw(daily)})을 넘어요. 예산을 올리거나 매체를 하나로 줄여 주세요.`);
35
+ const goalWord = { traffic: '사이트 방문', lead: '문의', signup: '가입', purchase: '구매' }[i.goal];
36
+ const explain = [
37
+ `한 달 ${fmtKrw(monthly)} 을 하루 약 ${fmtKrw(daily)} 으로 나눠 씁니다.`,
38
+ ...out.map((m) => `· ${MEDIUM_LABEL[m.medium]} 하루 ${fmtKrw(m.dailyBudgetKrw)}${m.currency !== 'KRW' ? ` (계정 통화 ${m.currency} → ${fmtMinor(m.dailyBudgetMinor, m.currency)})` : ''}${m.note ? ` — ${m.note}` : ''}`),
39
+ `보여 줄 사람: ${i.markets.map((m) => `${m.country}(${m.language})`).join(' · ')} · 18세 이상 · 매체가 관심사를 자동으로 찾아요(Advantage+ / 자동 타겟).`,
40
+ `광고 소재는 ${concepts.length}가지 방향(${concepts.join(', ')})으로 나눠서 어느 쪽이 잘 되는지 비교합니다${i.conceptKeys.length > concepts.length ? ` — 예산이 작아 ${i.conceptKeys.length - concepts.length}개는 뒤로 미뤘어요` : ''}.`,
41
+ `목표: ${goalWord}${i.hasTracking ? ' — 전환이 50건쯤 쌓이면 전환 최적화로 자동 전환' : ' — 추적 태그가 없어 방문 기준으로만 최적화돼요(추적 코드를 사이트에 넣어 주세요)'}.`,
42
+ ...(i.markets.some((m) => m.language.split('-')[0] !== (i.briefLanguage || 'ko').split('-')[0]) ? [`⚠ 카피 언어가 ${i.briefLanguage} 인데 시장에 다른 언어(${[...new Set(i.markets.map((m) => m.language))].join(', ')})가 있어요 — 언어별 소재를 따로 만들어 주세요.`] : []),
43
+ `만들어지는 광고는 전부 「대기 상태(PAUSED · 노출 안 됨)」이고, 광고 시작 전엔 1원도 나가지 않아요.${i.endDate ? ` 종료일 ${i.endDate}.` : ''}`,
44
+ ].join('\n');
45
+ return { monthlyKrw: monthly, dailyKrw: daily, media: out, concepts, markets: i.markets, goal: i.goal, endDate: i.endDate, explain, createdAt: new Date().toISOString() };
46
+ }
47
+ export async function makePlan(p, opts = {}) {
48
+ if (!p.brief)
49
+ throw new Error('브리프가 먼저 필요해요 (`adpilot brief <url>`).');
50
+ const media = (opts.media && opts.media.length ? opts.media : connectedMedia());
51
+ if (!media.length)
52
+ throw new Error('연결된 매체가 없어요. `adpilot connect meta …` 또는 `adpilot connect google …` 먼저.');
53
+ const accounts = [];
54
+ for (const m of media) {
55
+ const acc = await connector(m).account();
56
+ accounts.push({ medium: m, currency: acc.currency });
57
+ }
58
+ return computePlan({ monthlyKrw: p.budget.monthlyKrw, goal: p.budget.goal, markets: p.budget.markets, conceptKeys: (p.concepts || []).map((c) => c.key), briefLanguage: p.brief.language, endDate: p.budget.endDate, hasTracking: !!(p.tracking?.pixelId || p.tracking?.gtagId), media: accounts, shares: opts.shares });
59
+ }
@@ -0,0 +1,30 @@
1
+ import type { MetricRow } from '../adapters/types.js';
2
+ import type { Project, Decision } from './state.js';
3
+ export type ReportInput = {
4
+ project: Project;
5
+ range: {
6
+ since: string;
7
+ until: string;
8
+ };
9
+ rows: Record<string, MetricRow[]>;
10
+ decisions: Decision[];
11
+ policy: Record<string, {
12
+ name: string;
13
+ status: string;
14
+ issues: string[];
15
+ }[]>;
16
+ };
17
+ export declare function tableOf(rows: MetricRow[]): {
18
+ concept: string;
19
+ spendKrw: number;
20
+ impressions: number;
21
+ clicks: number;
22
+ ctr: number;
23
+ conversions: number;
24
+ cpaKrw: number | null;
25
+ }[];
26
+ export declare function makeReport(inp: ReportInput): Promise<{
27
+ text: string;
28
+ markdown: string;
29
+ html: string;
30
+ }>;
@@ -0,0 +1,34 @@
1
+ // 보고 — 지표를 사람 말로. LLM이 있으면 「이번 주 ○○만원 써서 방문 ○명…」 요약, 없으면 표 기반 문장.
2
+ import { aiAvailable, complete } from './llm.js';
3
+ import { fmtKrw, minorToKrw, minorUnitsPerMajor } from './money.js';
4
+ import { summarize } from './optimize.js';
5
+ export function tableOf(rows) {
6
+ const s = summarize(rows);
7
+ const krw = (v, cur) => minorToKrw(Math.round(v * minorUnitsPerMajor(cur)), cur);
8
+ return s.map((a) => ({ concept: a.concept || a.name, spendKrw: krw(a.spend, a.currency), impressions: a.impressions, clicks: a.clicks, ctr: a.ctr, conversions: a.conversions, cpaKrw: a.conversions ? krw(a.cpa, a.currency) : null }));
9
+ }
10
+ export async function makeReport(inp) {
11
+ const media = Object.keys(inp.rows);
12
+ const tables = Object.fromEntries(media.map((m) => [m, tableOf(inp.rows[m])]));
13
+ const totals = { spend: 0, imp: 0, clicks: 0, conv: 0 };
14
+ for (const t of Object.values(tables))
15
+ for (const r of t) {
16
+ totals.spend += r.spendKrw;
17
+ totals.imp += r.impressions;
18
+ totals.clicks += r.clicks;
19
+ totals.conv += r.conversions;
20
+ }
21
+ const goalWord = { traffic: '방문', lead: '문의', signup: '가입', purchase: '구매' }[inp.project.budget.goal];
22
+ const facts = { period: inp.range, conceptNames: Object.fromEntries((inp.project.concepts || []).map((c) => [c.key, `${c.name} — ${c.headlines[0]}`])), company: inp.project.brief?.company, goal: goalWord, totals, byMedium: tables, decisions: inp.decisions.slice(-20), policy: inp.policy, plan: inp.project.plan?.explain, status: inp.project.status };
23
+ let text;
24
+ if (aiAvailable()) {
25
+ text = await complete({ maxTokens: 1500, system: `너는 광고 운영 담당자다. 광고를 모르는 사장님께 이번 기간 결과를 한국어 존댓말(해요체)로 6~10문장 보고한다. 광고 용어(CTR·CPA·노출·DCO)는 쓰지 말고 사람 말(본 사람·클릭한 사람·1명 데려오는 데 든 돈)로 바꾼다. 숫자는 주어진 것만(만들지 않는다). 구성: ① 얼마 써서 무슨 결과 ② 어느 매체·어느 방향(컨셉)이 잘 됐고 무엇을 줄였는지 ③ 자동 조치가 있었으면 한 줄 ④ 검수·정책 문제가 있으면 한 줄 ⑤ 다음 주 계획 한 줄. 지표가 없으면 「아직 노출 전·검수 중」을 있는 그대로.`, user: JSON.stringify(facts) });
26
+ }
27
+ else {
28
+ text = [`${inp.range.since}~${inp.range.until} 동안 ${fmtKrw(totals.spend)} 을 써서 ${totals.imp.toLocaleString()}명에게 보였고 ${totals.clicks.toLocaleString()}명이 클릭했어요${totals.conv ? `. ${goalWord} ${totals.conv}건 (1건당 ${fmtKrw(totals.spend / totals.conv)})` : ''}.`, ...media.map((m) => { const best = [...tables[m]].sort((a, b) => b.ctr - a.ctr)[0]; return best ? `${m === 'meta' ? '메타' : '구글'}에선 「${best.concept}」 방향이 가장 반응이 좋았어요(클릭률 ${(best.ctr * 100).toFixed(2)}%).` : `${m}: 아직 지표가 없어요.`; })].join(' ');
29
+ }
30
+ const md = [`# ${inp.project.brief?.company || inp.project.slug} 광고 보고 (${inp.range.since} ~ ${inp.range.until})`, '', text, '', ...media.flatMap((m) => [`## ${m === 'meta' ? 'Meta' : 'Google'}`, '| 방향 | 쓴 돈 | 본 사람 | 클릭 | 클릭률 | ' + goalWord + ' | 1건당 |', '|---|---|---|---|---|---|---|', ...tables[m].map((r) => `| ${r.concept} | ${fmtKrw(r.spendKrw)} | ${r.impressions.toLocaleString()} | ${r.clicks.toLocaleString()} | ${(r.ctr * 100).toFixed(2)}% | ${r.conversions} | ${r.cpaKrw ? fmtKrw(r.cpaKrw) : '-'} |`), ''])].join('\n');
31
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;');
32
+ const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>${esc(inp.project.brief?.company || inp.project.slug)} 광고 보고</title><style>body{font-family:Pretendard,'Apple SD Gothic Neo',sans-serif;max-width:820px;margin:40px auto;padding:0 20px;color:#0b1526;line-height:1.6}h1{font-size:24px}table{border-collapse:collapse;width:100%;font-size:14px}td,th{border-bottom:1px solid #e3e8ef;padding:8px 10px;text-align:right}th:first-child,td:first-child{text-align:left}.lead{font-size:17px;background:#f4f7fb;border-radius:12px;padding:18px 20px}</style></head><body><h1>${esc(inp.project.brief?.company || inp.project.slug)} 광고 보고 <small style="color:#6b7a90;font-size:14px">${inp.range.since} ~ ${inp.range.until}</small></h1><div class="lead">${esc(text).replace(/\n/g, '<br>')}</div>${media.map((m) => `<h2>${m === 'meta' ? 'Meta' : 'Google'}</h2><table><tr><th>방향</th><th>쓴 돈</th><th>본 사람</th><th>클릭</th><th>클릭률</th><th>${goalWord}</th><th>1건당</th></tr>${tables[m].map((r) => `<tr><td>${esc(String(r.concept))}</td><td>${fmtKrw(r.spendKrw)}</td><td>${r.impressions.toLocaleString()}</td><td>${r.clicks.toLocaleString()}</td><td>${(r.ctr * 100).toFixed(2)}%</td><td>${r.conversions}</td><td>${r.cpaKrw ? fmtKrw(r.cpaKrw) : '-'}</td></tr>`).join('')}</table>`).join('')}</body></html>`;
33
+ return { text, markdown: md, html };
34
+ }