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,25 @@
1
+ import type { Brief, Concept, CreativeAsset } from '../state.js';
2
+ import { type Size } from './specs.js';
3
+ export declare function loadFonts(): Promise<{
4
+ name: string;
5
+ data: ArrayBuffer;
6
+ weight: 400 | 500 | 700 | 800;
7
+ style: 'normal';
8
+ }[]>;
9
+ type El = {
10
+ type: string;
11
+ props: Record<string, unknown>;
12
+ };
13
+ /** 한 규격의 트리 */
14
+ export declare function tree(size: Size, concept: Concept, brief: Brief, bg: string | null, logo: string | null, ctaText: string): El;
15
+ export declare const CTA_LABEL: Record<string, Record<string, string>>;
16
+ export declare function ctaLabel(cta: string, lang: string): string;
17
+ /** 컨셉 하나를 12규격으로 렌더 → 파일 목록. 이미 있는 파일은 건너뜀(--force로 다시). */
18
+ export declare function renderConcept(dir: string, brief: Brief, concept: Concept, backgrounds: Record<string, string | null>, opts?: {
19
+ force?: boolean;
20
+ only?: Size[];
21
+ log?: (s: string) => void;
22
+ }): Promise<CreativeAsset[]>;
23
+ /** 구글 RDA 로고 자산 — 사이트 로고가 있으면 그것을, 없으면 회사명 텍스트 로고를 1:1(512)·4:1(1200×300)로 만든다 */
24
+ export declare function renderLogo(dir: string, brief: Brief): Promise<CreativeAsset[]>;
25
+ export {};
@@ -0,0 +1,157 @@
1
+ // 소재 합성 — 배경(생성 이미지 또는 브랜드 그래디언트) 위에 로고·헤드라인·본문·CTA를 satori(HTML/CSS→SVG)+resvg(SVG→PNG)로 얹는다. 브라우저 불필요.
2
+ // 글꼴은 Pretendard(OFL)를 첫 실행 때 ~/.adpilot/fonts로 내려받는다(시스템에 있으면 그것을 씀).
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import satori from 'satori';
7
+ import { Resvg } from '@resvg/resvg-js';
8
+ import { HOME } from '../state.js';
9
+ import { SIZES } from './specs.js';
10
+ const FONT_CDN = 'https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/packages/pretendard/dist/public/static';
11
+ const WEIGHTS = [['Pretendard-ExtraBold', 800], ['Pretendard-Bold', 700], ['Pretendard-Medium', 500]];
12
+ export async function loadFonts() {
13
+ const dir = path.join(HOME, 'fonts');
14
+ fs.mkdirSync(dir, { recursive: true });
15
+ const out = [];
16
+ for (const [file, weight] of WEIGHTS) {
17
+ const candidates = [path.join(dir, `${file}.otf`), path.join(os.homedir(), 'Library/Fonts', `${file}.otf`), `/usr/share/fonts/${file}.otf`];
18
+ let p = candidates.find((c) => fs.existsSync(c));
19
+ if (!p) {
20
+ const res = await fetch(`${FONT_CDN}/${file}.otf`, { signal: AbortSignal.timeout(60_000) });
21
+ if (!res.ok)
22
+ throw new Error(`글꼴 내려받기 실패 ${file}`);
23
+ p = candidates[0];
24
+ fs.writeFileSync(p, Buffer.from(await res.arrayBuffer()));
25
+ }
26
+ const buf = fs.readFileSync(p);
27
+ out.push({ name: 'Pretendard', data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), weight: weight, style: 'normal' });
28
+ }
29
+ return out;
30
+ }
31
+ const dataUri = (file) => { const ext = path.extname(file).slice(1).toLowerCase(); return `data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${fs.readFileSync(file).toString('base64')}`; };
32
+ async function fetchLogo(url, dir) {
33
+ if (!url)
34
+ return null;
35
+ const cache = path.join(dir, 'logo.bin');
36
+ const meta = path.join(dir, 'logo.type');
37
+ try {
38
+ if (!fs.existsSync(cache)) {
39
+ const r = await fetch(url, { signal: AbortSignal.timeout(15_000) });
40
+ if (!r.ok)
41
+ return null;
42
+ const ct = r.headers.get('content-type') || '';
43
+ if (!/png|jpe?g|svg/.test(ct))
44
+ return null;
45
+ fs.writeFileSync(cache, Buffer.from(await r.arrayBuffer()));
46
+ fs.writeFileSync(meta, ct);
47
+ }
48
+ const ct = fs.readFileSync(meta, 'utf8');
49
+ const mime = /svg/.test(ct) ? 'image/svg+xml' : /png/.test(ct) ? 'image/png' : 'image/jpeg';
50
+ return `data:${mime};base64,${fs.readFileSync(cache).toString('base64')}`;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ const h = (type, style, children, extra = {}) => ({ type, props: { style, ...extra, ...(children === undefined ? {} : { children }) } });
57
+ function hexA(hex, a) { const m = hex.replace('#', ''); const n = parseInt(m.length === 3 ? m.split('').map((c) => c + c).join('') : m, 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; }
58
+ /** 한 규격의 트리 */
59
+ export function tree(size, concept, brief, bg, logo, ctaText) {
60
+ const { w, h: hh } = size;
61
+ const dark = concept.theme === 'dark';
62
+ const primary = brief.palette.primary;
63
+ const base = Math.min(w, hh);
64
+ const banner = size.kind === 'banner';
65
+ const short = hh <= 120; // 728x90 · 320x50
66
+ const narrow = w <= 200; // 160x600
67
+ const pad = banner ? Math.round(base * (short ? 0.12 : 0.08)) : Math.round(base * 0.075);
68
+ const head = concept.headlines[0] || brief.company;
69
+ const body = concept.bodies[0] || brief.offer;
70
+ const domain = (() => { try {
71
+ return new URL(brief.url).hostname.replace(/^www\./, '');
72
+ }
73
+ catch {
74
+ return '';
75
+ } })();
76
+ const fg = dark ? '#ffffff' : '#0b1526';
77
+ const sub = dark ? 'rgba(255,255,255,.82)' : 'rgba(11,21,38,.72)';
78
+ const bgStyle = bg ? {} : { backgroundImage: dark ? `linear-gradient(135deg, ${brief.palette.dark} 0%, ${hexA(primary, 0.55)} 100%)` : `linear-gradient(135deg, ${brief.palette.light} 0%, ${hexA(primary, 0.18)} 100%)` };
79
+ // 크기별 글자 크기
80
+ const headFs = short ? Math.round(hh * 0.34) : narrow ? Math.round(w * 0.14) : banner ? Math.round(base * 0.12) : Math.round(base * (hh > w ? 0.085 : 0.078));
81
+ const bodyFs = short ? 0 : narrow ? Math.round(w * 0.085) : banner ? Math.round(base * 0.062) : Math.round(base * 0.034);
82
+ const ctaFs = short ? Math.round(hh * 0.28) : narrow ? Math.round(w * 0.09) : banner ? Math.round(base * 0.06) : Math.round(base * 0.028);
83
+ const children = [];
84
+ if (bg)
85
+ children.push(h('img', { position: 'absolute', top: 0, left: 0, width: w, height: hh, objectFit: 'cover', filter: dark ? 'brightness(.66)' : 'brightness(1.02)' }, undefined, { src: bg, width: w, height: hh }));
86
+ children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, backgroundImage: dark ? `linear-gradient(180deg, ${hexA('#0b1526', 0.15)} 0%, ${hexA('#0b1526', 0.75)} 100%)` : `linear-gradient(180deg, rgba(255,255,255,.10) 0%, rgba(255,255,255,.55) 100%)` }));
87
+ const brandRow = h('div', { display: 'flex', alignItems: 'center', gap: Math.round(pad * 0.3) }, [
88
+ logo ? h('img', { height: short ? Math.round(hh * 0.5) : Math.round(headFs * 0.9), borderRadius: 6 }, undefined, { src: logo }) : h('div', { fontSize: Math.round(headFs * 0.42), fontWeight: 800, color: fg, letterSpacing: -0.5 }, brief.company),
89
+ ]);
90
+ const ctaPill = h('div', { display: 'flex', alignItems: 'center', fontSize: ctaFs, fontWeight: 700, color: '#fff', backgroundColor: primary, borderRadius: 999, padding: `${Math.round(ctaFs * 0.5)}px ${Math.round(ctaFs * 1.1)}px`, ...(banner ? {} : { boxShadow: `0 10px 30px ${hexA(primary, 0.45)}` }) }, ctaText);
91
+ if (short) {
92
+ // 가로 띠: 로고 | 헤드라인 | CTA
93
+ children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: `0 ${pad}px`, gap: pad }, [brandRow, h('div', { flex: 1, fontSize: headFs, fontWeight: 800, color: fg, letterSpacing: -1, lineHeight: 1.1, whiteSpace: 'nowrap', overflow: 'hidden' }, head), ctaPill]));
94
+ }
95
+ else if (narrow) {
96
+ children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [brandRow, h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * 0.6) }, [h('div', { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.15, letterSpacing: -0.8, wordBreak: 'keep-all' }, head), h('div', { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.4, wordBreak: 'keep-all' }, body.slice(0, 60))]), ctaPill]));
97
+ }
98
+ else {
99
+ const isWide = w / hh > 1.6;
100
+ children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [
101
+ h('div', { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, [brandRow, domain && !banner ? h('div', { fontSize: Math.round(headFs * 0.3), fontWeight: 500, color: sub }, domain) : h('div', {}, '')]),
102
+ h('div', { display: 'flex', flexDirection: 'column', gap: Math.round(pad * (banner ? 0.35 : 0.5)), maxWidth: isWide ? Math.round(w * 0.72) : w - pad * 2 }, [
103
+ h('div', { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5, wordBreak: 'keep-all' }, head),
104
+ banner && bodyFs < 14 ? h('div', {}, '') : h('div', { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45, wordBreak: 'keep-all' }, banner ? body.slice(0, 70) : body),
105
+ h('div', { display: 'flex', marginTop: Math.round(pad * 0.3) }, [ctaPill]),
106
+ ]),
107
+ banner ? h('div', {}, '') : h('div', { display: 'flex', justifyContent: 'space-between', fontSize: Math.round(headFs * 0.28), fontWeight: 500, color: sub }, [h('div', {}, brief.company), h('div', {}, domain)]),
108
+ ]));
109
+ }
110
+ return h('div', { width: w, height: hh, display: 'flex', position: 'relative', overflow: 'hidden', fontFamily: 'Pretendard', backgroundColor: dark ? brief.palette.dark : brief.palette.light, ...bgStyle }, children);
111
+ }
112
+ export const CTA_LABEL = {
113
+ ko: { LEARN_MORE: '자세히 보기', SIGN_UP: '지금 시작하기', SHOP_NOW: '지금 구매', CONTACT_US: '문의하기', DOWNLOAD: '내려받기', GET_OFFER: '혜택 받기', GET_QUOTE: '견적 받기', SUBSCRIBE: '구독하기', APPLY_NOW: '지금 신청', ORDER_NOW: '지금 주문', BUY_NOW: '지금 구매', BOOK_TRAVEL: '예약하기' },
114
+ en: { LEARN_MORE: 'Learn more', SIGN_UP: 'Get started', SHOP_NOW: 'Shop now', CONTACT_US: 'Contact us', DOWNLOAD: 'Download', GET_OFFER: 'Get offer', GET_QUOTE: 'Get a quote', SUBSCRIBE: 'Subscribe', APPLY_NOW: 'Apply now', ORDER_NOW: 'Order now', BUY_NOW: 'Buy now', BOOK_TRAVEL: 'Book now' },
115
+ ja: { LEARN_MORE: '詳しく見る', SIGN_UP: '今すぐ始める', SHOP_NOW: '今すぐ購入', CONTACT_US: 'お問い合わせ' },
116
+ };
117
+ export function ctaLabel(cta, lang) { return CTA_LABEL[lang]?.[cta] || CTA_LABEL.en[cta] || CTA_LABEL.en.LEARN_MORE; }
118
+ /** 컨셉 하나를 12규격으로 렌더 → 파일 목록. 이미 있는 파일은 건너뜀(--force로 다시). */
119
+ export async function renderConcept(dir, brief, concept, backgrounds, opts = {}) {
120
+ const fonts = await loadFonts();
121
+ const logo = await fetchLogo(brief.logoUrl, dir);
122
+ const out = [];
123
+ const ctaText = ctaLabel(concept.cta, brief.language);
124
+ for (const size of opts.only || SIZES) {
125
+ const file = path.join(dir, `${concept.key}_${size.w}x${size.h}.png`);
126
+ if (!opts.force && fs.existsSync(file)) {
127
+ out.push({ concept: concept.key, w: size.w, h: size.h, ratio: size.ratio, file, medium: size.medium });
128
+ continue;
129
+ }
130
+ const bgFile = backgrounds[size.ratio] || backgrounds['1x1'] || Object.values(backgrounds).find(Boolean) || null;
131
+ const bg = bgFile ? dataUri(bgFile) : null;
132
+ const svg = await satori(tree(size, concept, brief, bg, logo, ctaText), { width: size.w, height: size.h, fonts });
133
+ const png = new Resvg(svg, { fitTo: { mode: 'width', value: size.w } }).render().asPng();
134
+ fs.writeFileSync(file, png);
135
+ out.push({ concept: concept.key, w: size.w, h: size.h, ratio: size.ratio, file, medium: size.medium });
136
+ opts.log?.(` ${concept.key} ${size.w}x${size.h} ✓`);
137
+ }
138
+ return out;
139
+ }
140
+ /** 구글 RDA 로고 자산 — 사이트 로고가 있으면 그것을, 없으면 회사명 텍스트 로고를 1:1(512)·4:1(1200×300)로 만든다 */
141
+ export async function renderLogo(dir, brief) {
142
+ const fonts = await loadFonts();
143
+ const logo = await fetchLogo(brief.logoUrl, dir);
144
+ const out = [];
145
+ for (const [w, hh] of [[512, 512], [1200, 300]]) {
146
+ const file = path.join(dir, `_logo_${w}x${hh}.png`);
147
+ if (!fs.existsSync(file)) {
148
+ const el = h('div', { width: w, height: hh, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: brief.palette.primary, fontFamily: 'Pretendard' }, [
149
+ logo ? h('img', { height: Math.round(hh * 0.5) }, undefined, { src: logo }) : h('div', { fontSize: Math.round(Math.min(w, hh) * (w === hh ? 0.16 : 0.42)), fontWeight: 800, color: '#fff', letterSpacing: -1, whiteSpace: 'nowrap' }, brief.company.slice(0, 16)),
150
+ ]);
151
+ const svg = await satori(el, { width: w, height: hh, fonts });
152
+ fs.writeFileSync(file, new Resvg(svg, { fitTo: { mode: 'width', value: w } }).render().asPng());
153
+ }
154
+ out.push({ concept: '_logo', w, h: hh, ratio: w === hh ? '1x1' : '4x1', file, medium: 'google' });
155
+ }
156
+ return out;
157
+ }
@@ -0,0 +1,40 @@
1
+ export type Size = {
2
+ w: number;
3
+ h: number;
4
+ ratio: string;
5
+ medium: 'meta' | 'google' | 'both';
6
+ kind: 'poster' | 'banner';
7
+ };
8
+ export declare const SIZES: Size[];
9
+ /** 배경 이미지를 생성할 비율(4종) → 나머지 규격은 object-fit cover로 재사용 */
10
+ export declare const BG_RATIOS: Record<string, {
11
+ openai: string;
12
+ aspect: string;
13
+ }>;
14
+ /** 🔴 구글은 한글·전각을 2자로 센다 */
15
+ export declare function wlen(t: string): number;
16
+ export declare function fit(t: string, limit: number): string;
17
+ export declare const GOOGLE_LIMITS: {
18
+ headline: number;
19
+ longHeadline: number;
20
+ description: number;
21
+ businessName: number;
22
+ };
23
+ /** RDA call_to_action_text는 구글이 정한 영문 문구만 */
24
+ export declare const GOOGLE_CTA: readonly ["Apply Now", "Book Now", "Contact Us", "Download", "Learn More", "Install", "Visit Site", "Shop Now", "Sign Up", "Get Quote", "Subscribe", "See More"];
25
+ export declare const META_CTA: readonly ["LEARN_MORE", "SIGN_UP", "SHOP_NOW", "BOOK_TRAVEL", "CONTACT_US", "DOWNLOAD", "GET_OFFER", "GET_QUOTE", "SUBSCRIBE", "APPLY_NOW", "ORDER_NOW", "BUY_NOW"];
26
+ export type Goal = 'traffic' | 'lead' | 'signup' | 'purchase';
27
+ export declare function ctaFor(goal: Goal): {
28
+ meta: (typeof META_CTA)[number];
29
+ google: (typeof GOOGLE_CTA)[number];
30
+ };
31
+ export declare function policyIssues(text: string): string[];
32
+ export type CopyCheck = {
33
+ ok: boolean;
34
+ problems: string[];
35
+ };
36
+ export declare function checkCopy(c: {
37
+ headlines: string[];
38
+ bodies: string[];
39
+ descriptions: string[];
40
+ }, medium: 'meta' | 'google'): CopyCheck;
@@ -0,0 +1,51 @@
1
+ export const SIZES = [
2
+ { w: 1080, h: 1080, ratio: '1x1', medium: 'meta', kind: 'poster' },
3
+ { w: 1080, h: 1350, ratio: '4x5', medium: 'meta', kind: 'poster' },
4
+ { w: 1080, h: 1920, ratio: '9x16', medium: 'meta', kind: 'poster' },
5
+ { w: 1200, h: 628, ratio: '16x9', medium: 'google', kind: 'poster' },
6
+ { w: 1200, h: 1200, ratio: '1x1', medium: 'google', kind: 'poster' },
7
+ { w: 300, h: 250, ratio: '1x1', medium: 'google', kind: 'banner' },
8
+ { w: 336, h: 280, ratio: '1x1', medium: 'google', kind: 'banner' },
9
+ { w: 300, h: 600, ratio: '9x16', medium: 'google', kind: 'banner' },
10
+ { w: 160, h: 600, ratio: '9x16', medium: 'google', kind: 'banner' },
11
+ { w: 970, h: 250, ratio: '16x9', medium: 'google', kind: 'banner' },
12
+ { w: 728, h: 90, ratio: '16x9', medium: 'google', kind: 'banner' },
13
+ { w: 320, h: 50, ratio: '1x1', medium: 'google', kind: 'banner' },
14
+ ];
15
+ /** 배경 이미지를 생성할 비율(4종) → 나머지 규격은 object-fit cover로 재사용 */
16
+ export const BG_RATIOS = { '1x1': { openai: '1024x1024', aspect: '1:1' }, '4x5': { openai: '1024x1280', aspect: '4:5' }, '9x16': { openai: '1088x1920', aspect: '9:16' }, '16x9': { openai: '1536x864', aspect: '16:9' } };
17
+ /** 🔴 구글은 한글·전각을 2자로 센다 */
18
+ export function wlen(t) { return [...t].reduce((n, ch) => n + ((ch.codePointAt(0) || 0) > 0x2e7f ? 2 : 1), 0); }
19
+ export function fit(t, limit) { let s = t; while (wlen(s) > limit && s.length)
20
+ s = s.slice(0, -1); return s.replace(/[\s·,\-—:;]+$/, ''); }
21
+ export const GOOGLE_LIMITS = { headline: 30, longHeadline: 90, description: 90, businessName: 25 };
22
+ /** RDA call_to_action_text는 구글이 정한 영문 문구만 */
23
+ export const GOOGLE_CTA = ['Apply Now', 'Book Now', 'Contact Us', 'Download', 'Learn More', 'Install', 'Visit Site', 'Shop Now', 'Sign Up', 'Get Quote', 'Subscribe', 'See More'];
24
+ export const META_CTA = ['LEARN_MORE', 'SIGN_UP', 'SHOP_NOW', 'BOOK_TRAVEL', 'CONTACT_US', 'DOWNLOAD', 'GET_OFFER', 'GET_QUOTE', 'SUBSCRIBE', 'APPLY_NOW', 'ORDER_NOW', 'BUY_NOW'];
25
+ export function ctaFor(goal) {
26
+ return { traffic: { meta: 'LEARN_MORE', google: 'Learn More' }, lead: { meta: 'CONTACT_US', google: 'Contact Us' }, signup: { meta: 'SIGN_UP', google: 'Sign Up' }, purchase: { meta: 'SHOP_NOW', google: 'Shop Now' } }[goal];
27
+ }
28
+ /** 금지·과장 표현(매체 정책 자주 걸리는 것) — 소재 카피에서 걸러낸다 */
29
+ const BANNED = [/100\s*%/, /무조건/, /완치/, /최고의(?!\s*(경험|하루))/, /1위(?!\s*(기록|수상))/, /보장(?!\s*(기간|제도|서비스))/, /확실히\s*(살|빠|번)/, /부작용\s*없/, /단\s*하루/, /클릭\s*(하세요|해주세요)/i, /free\s+money/i, /guaranteed/i, /#1(?!\d)/, /cure/i, /100%/];
30
+ export function policyIssues(text) { return BANNED.filter((r) => r.test(text)).map((r) => `표현 「${(text.match(r) || [''])[0]}」 은(는) 매체 정책에 걸리기 쉬워요`); }
31
+ export function checkCopy(c, medium) {
32
+ const problems = [];
33
+ for (const t of [...c.headlines, ...c.bodies, ...c.descriptions])
34
+ for (const p of policyIssues(t))
35
+ problems.push(p);
36
+ if (medium === 'google') {
37
+ c.headlines.forEach((h, i) => { if (wlen(h) > GOOGLE_LIMITS.headline)
38
+ problems.push(`구글 헤드라인 ${i + 1} 이 ${GOOGLE_LIMITS.headline}자(한글 2자)를 넘어요: 「${h}」`); });
39
+ c.descriptions.forEach((d, i) => { if (wlen(d) > GOOGLE_LIMITS.description)
40
+ problems.push(`구글 설명 ${i + 1} 이 ${GOOGLE_LIMITS.description}자를 넘어요`); });
41
+ if (c.headlines.length < 1)
42
+ problems.push('구글 헤드라인이 1개 이상 필요해요');
43
+ }
44
+ else {
45
+ c.headlines.forEach((h, i) => { if ([...h].length > 255)
46
+ problems.push(`메타 제목 ${i + 1} 이 255자를 넘어요`); });
47
+ c.bodies.forEach((b, i) => { if ([...b].length > 2000)
48
+ problems.push(`메타 본문 ${i + 1} 이 너무 길어요`); });
49
+ }
50
+ return { ok: !problems.length, problems };
51
+ }
@@ -0,0 +1,23 @@
1
+ import type { z } from 'zod';
2
+ type Cfg = {
3
+ base: string;
4
+ key: string;
5
+ model: string;
6
+ };
7
+ export declare function llmConfig(): Cfg | null;
8
+ export declare function aiAvailable(): boolean;
9
+ export declare function complete(opts: {
10
+ system: string;
11
+ user: string;
12
+ maxTokens?: number;
13
+ timeoutMs?: number;
14
+ }): Promise<string>;
15
+ export declare function extractJson(text: string): string;
16
+ export declare function completeJson<T>(opts: {
17
+ system: string;
18
+ user: string;
19
+ schema: z.ZodType<T>;
20
+ maxTokens?: number;
21
+ timeoutMs?: number;
22
+ }): Promise<T>;
23
+ export {};
@@ -0,0 +1,77 @@
1
+ import { loadConfig } from './state.js';
2
+ export function llmConfig() {
3
+ const c = loadConfig().llm || {};
4
+ const key = process.env.ADPILOT_LLM_KEY || process.env.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_API_KEY || c.apiKey;
5
+ if (!key)
6
+ return null;
7
+ const base = (process.env.ADPILOT_LLM_BASE_URL || process.env.ANTHROPIC_BASE_URL || c.baseUrl || (key.startsWith('sk-ant-') ? 'https://api.anthropic.com' : 'https://api.bizrouter.ai')).replace(/\/+$/, '');
8
+ const model = process.env.ADPILOT_LLM_MODEL || c.model || 'claude-sonnet-5';
9
+ return { base, key, model };
10
+ }
11
+ export function aiAvailable() { return llmConfig() !== null; }
12
+ export async function complete(opts) {
13
+ const cfg = llmConfig();
14
+ if (!cfg)
15
+ throw new Error('LLM 키가 없어요. `adpilot config set llm.apiKey <키>` 또는 ANTHROPIC_API_KEY를 설정해 주세요.');
16
+ let lastErr;
17
+ for (let attempt = 0; attempt < 2; attempt++) {
18
+ const ctrl = new AbortController();
19
+ const t = setTimeout(() => ctrl.abort(), opts.timeoutMs || 90_000);
20
+ try {
21
+ const res = await fetch(`${cfg.base}/v1/messages`, {
22
+ method: 'POST', signal: ctrl.signal,
23
+ headers: { 'content-type': 'application/json', 'anthropic-version': '2023-06-01', 'x-api-key': cfg.key, authorization: `Bearer ${cfg.key}` },
24
+ body: JSON.stringify({ model: cfg.model, max_tokens: opts.maxTokens ?? 4000, thinking: { type: 'disabled' }, system: opts.system, messages: [{ role: 'user', content: opts.user }] }),
25
+ });
26
+ if (!res.ok) {
27
+ const body = await res.text().catch(() => '');
28
+ lastErr = new Error(`LLM ${res.status} ${body.slice(0, 200)}`);
29
+ if (res.status < 500 && res.status !== 429)
30
+ break;
31
+ continue;
32
+ }
33
+ const data = (await res.json());
34
+ const text = (data.content || []).filter((c) => c.type === 'text').map((c) => c.text || '').join('\n').trim();
35
+ if (text)
36
+ return text;
37
+ lastErr = new Error('LLM 빈 응답');
38
+ }
39
+ catch (e) {
40
+ lastErr = e;
41
+ }
42
+ finally {
43
+ clearTimeout(t);
44
+ }
45
+ }
46
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
47
+ }
48
+ export function extractJson(text) {
49
+ let t = text.trim();
50
+ const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i);
51
+ if (fence)
52
+ t = fence[1].trim();
53
+ const s = [t.indexOf('{'), t.indexOf('[')].filter((i) => i >= 0);
54
+ if (!s.length)
55
+ return t;
56
+ const start = Math.min(...s);
57
+ const endCh = t[start] === '{' ? '}' : ']';
58
+ const end = t.lastIndexOf(endCh);
59
+ return end > start ? t.slice(start, end + 1) : t.slice(start);
60
+ }
61
+ export async function completeJson(opts) {
62
+ const system = `${opts.system}\n\n출력 규칙: 설명 없이 JSON 하나만 출력한다. 코드펜스를 쓰지 않는다. 문자열 안 줄바꿈은 \\n으로 이스케이프한다.`;
63
+ let user = opts.user;
64
+ for (let i = 0; i < 2; i++) {
65
+ const raw = await complete({ ...opts, system, user });
66
+ try {
67
+ const r = opts.schema.safeParse(JSON.parse(extractJson(raw)));
68
+ if (r.success)
69
+ return r.data;
70
+ user = `${opts.user}\n\n(직전 답이 형식과 달랐어요: ${r.error.issues.slice(0, 3).map((x) => `${x.path.join('.')}: ${x.message}`).join(' | ')}. 요구한 키와 타입을 정확히 지켜 JSON만 다시 출력해 주세요.)`;
71
+ }
72
+ catch (e) {
73
+ user = `${opts.user}\n\n(직전 답이 JSON으로 파싱되지 않았어요. JSON만 다시 출력해 주세요.)`;
74
+ }
75
+ }
76
+ throw new Error('LLM이 요구한 JSON 형식을 두 번 모두 지키지 못했어요.');
77
+ }
@@ -0,0 +1,9 @@
1
+ export declare function fxKrwPer(currency: string): number;
2
+ export declare function minorUnitsPerMajor(currency: string): number;
3
+ /** 원 → 매체 계정 통화의 최소 단위 정수(내림 — 상한을 절대 넘지 않도록) */
4
+ export declare function krwToMinor(krw: number, currency: string): number;
5
+ export declare function minorToKrw(minor: number, currency: string): number;
6
+ export declare function fmtKrw(n: number): string;
7
+ export declare function fmtMinor(minor: number, currency: string): string;
8
+ /** 매체 최소 일 예산(대략 · 최소 단위) — 메타 광고세트 하루 약 $1·₩1,500 · 구글 제한 없음(₩1,000 권장) */
9
+ export declare function minDailyMinor(medium: 'meta' | 'google', currency: string): number;
@@ -0,0 +1,34 @@
1
+ // 돈 — 광고주 예산은 원(KRW) 정수. 매체 계정 통화의 최소 단위(USD=센트·KRW=원·JPY=엔)로 환산하는 검증 계층. 이 파일 밖에서 예산 숫자를 만들지 않는다.
2
+ import { loadConfig } from './state.js';
3
+ /** 소수 자리 없는 통화(최소 단위 = 1) */
4
+ const ZERO_DECIMAL = new Set(['KRW', 'JPY', 'VND', 'CLP', 'ISK', 'HUF', 'TWD']);
5
+ const DEFAULT_FX = { KRW: 1, USD: 1380, EUR: 1500, JPY: 9.2, GBP: 1750, SGD: 1030, TWD: 43, AUD: 900, CAD: 1000, HKD: 177, VND: 0.054, INR: 16 };
6
+ export function fxKrwPer(currency) {
7
+ const c = currency.toUpperCase();
8
+ const rate = (loadConfig().fx || {})[c] ?? DEFAULT_FX[c];
9
+ if (!rate)
10
+ throw new Error(`통화 ${c} 의 환율을 모릅니다. \`adpilot config set fx.${c} <원>\` 로 1 ${c} 당 원화를 지정해 주세요.`);
11
+ return rate;
12
+ }
13
+ export function minorUnitsPerMajor(currency) { return ZERO_DECIMAL.has(currency.toUpperCase()) ? 1 : 100; }
14
+ /** 원 → 매체 계정 통화의 최소 단위 정수(내림 — 상한을 절대 넘지 않도록) */
15
+ export function krwToMinor(krw, currency) {
16
+ if (!Number.isFinite(krw) || krw < 0)
17
+ throw new Error('예산은 0 이상의 숫자여야 해요.');
18
+ const major = krw / fxKrwPer(currency);
19
+ return Math.floor(major * minorUnitsPerMajor(currency));
20
+ }
21
+ export function minorToKrw(minor, currency) { return Math.round((minor / minorUnitsPerMajor(currency)) * fxKrwPer(currency)); }
22
+ export function fmtKrw(n) { return `₩${Math.round(n).toLocaleString('ko-KR')}`; }
23
+ export function fmtMinor(minor, currency) {
24
+ const c = currency.toUpperCase();
25
+ const per = minorUnitsPerMajor(c);
26
+ return per === 1 ? `${minor.toLocaleString()} ${c}` : `${(minor / per).toFixed(2)} ${c}`;
27
+ }
28
+ /** 매체 최소 일 예산(대략 · 최소 단위) — 메타 광고세트 하루 약 $1·₩1,500 · 구글 제한 없음(₩1,000 권장) */
29
+ export function minDailyMinor(medium, currency) {
30
+ const c = currency.toUpperCase();
31
+ if (medium === 'meta')
32
+ return c === 'KRW' ? 1500 : krwToMinor(1500, c);
33
+ return c === 'KRW' ? 1000 : krwToMinor(1000, c);
34
+ }
@@ -0,0 +1,121 @@
1
+ import { connector } from '../adapters/index.js';
2
+ import type { CheckItem, Medium, MetricRow } from '../adapters/types.js';
3
+ import { type Proposal } from './optimize.js';
4
+ import { type Goal, type Market, type Project } from './state.js';
5
+ export type Log = (s: string) => void;
6
+ export declare function parseMarkets(s?: string, siteLang?: string): Market[];
7
+ export declare function opBrief(url: string, o: {
8
+ budget: number;
9
+ goal: Goal;
10
+ markets?: string;
11
+ hints?: string;
12
+ endDate?: string;
13
+ log?: Log;
14
+ }): Promise<{
15
+ project: Project;
16
+ explain: string;
17
+ }>;
18
+ export declare function opCreatives(p: Project, o: {
19
+ count?: number;
20
+ feedback?: string;
21
+ images?: string;
22
+ noImages?: boolean;
23
+ force?: boolean;
24
+ log?: Log;
25
+ }): Promise<{
26
+ project: Project;
27
+ report: string[];
28
+ }>;
29
+ export declare function galleryHtml(p: Project): string;
30
+ export declare function opPlan(p: Project, o?: {
31
+ media?: Medium[];
32
+ log?: Log;
33
+ }): Promise<Project>;
34
+ export declare function opBuild(p: Project, o?: {
35
+ media?: Medium[];
36
+ validate?: boolean;
37
+ log?: Log;
38
+ }): Promise<{
39
+ project: Project;
40
+ warnings: string[];
41
+ errors: string[];
42
+ }>;
43
+ export declare function opPreview(p: Project, o?: {
44
+ log?: Log;
45
+ }): Promise<{
46
+ file: string;
47
+ count: number;
48
+ }>;
49
+ export declare function opLaunch(p: Project, o?: {
50
+ media?: Medium[];
51
+ log?: Log;
52
+ yes?: boolean;
53
+ }): Promise<{
54
+ project: Project;
55
+ launched: Medium[];
56
+ errors: string[];
57
+ }>;
58
+ export declare function opPause(p: Project, o?: {
59
+ media?: Medium[];
60
+ log?: Log;
61
+ }): Promise<{
62
+ paused: Medium[];
63
+ errors: string[];
64
+ }>;
65
+ export declare function dateRange(days: number): {
66
+ since: string;
67
+ until: string;
68
+ };
69
+ export declare function opStatus(p: Project, o?: {
70
+ days?: number;
71
+ }): Promise<{
72
+ metrics: Record<string, MetricRow[]>;
73
+ policy: Record<string, {
74
+ name: string;
75
+ status: string;
76
+ issues: string[];
77
+ }[]>;
78
+ range: {
79
+ since: string;
80
+ until: string;
81
+ };
82
+ }>;
83
+ export declare function opOptimize(p: Project, o?: {
84
+ apply?: boolean;
85
+ log?: Log;
86
+ }): Promise<{
87
+ proposals: Proposal[];
88
+ applied: string[];
89
+ }>;
90
+ export declare function opReport(p: Project, o?: {
91
+ days?: number;
92
+ }): Promise<{
93
+ text: string;
94
+ markdown: string;
95
+ html: string;
96
+ file: string;
97
+ }>;
98
+ export declare function opAudit(o?: {
99
+ media?: Medium[];
100
+ }): Promise<Record<string, {
101
+ rows: Awaited<ReturnType<NonNullable<ReturnType<typeof connector>>["inventory"]>>;
102
+ findings: string[];
103
+ }>>;
104
+ export declare function opTrackSnippet(p: Project, o?: {
105
+ spa?: boolean;
106
+ }): {
107
+ head: string;
108
+ conversion: string;
109
+ csp: string[];
110
+ };
111
+ export declare function opChange(p: Project, request: string, o?: {
112
+ log?: Log;
113
+ }): Promise<{
114
+ summary: string;
115
+ steps: string[];
116
+ }>;
117
+ export declare function opCheckSummary(items: CheckItem[]): Promise<{
118
+ pass: number;
119
+ fail: number;
120
+ unknown: number;
121
+ }>;