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.
- package/README.md +64 -0
- package/dist/adapters/google.d.ts +41 -0
- package/dist/adapters/google.js +301 -0
- package/dist/adapters/index.d.ts +6 -0
- package/dist/adapters/index.js +17 -0
- package/dist/adapters/meta.d.ts +33 -0
- package/dist/adapters/meta.js +275 -0
- package/dist/adapters/mock.d.ts +28 -0
- package/dist/adapters/mock.js +67 -0
- package/dist/adapters/types.d.ts +100 -0
- package/dist/adapters/types.js +20 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +315 -0
- package/dist/core/brief.d.ts +12 -0
- package/dist/core/brief.js +71 -0
- package/dist/core/check.d.ts +11 -0
- package/dist/core/check.js +55 -0
- package/dist/core/creatives/copy.d.ts +14 -0
- package/dist/core/creatives/copy.js +52 -0
- package/dist/core/creatives/images.d.ts +9 -0
- package/dist/core/creatives/images.js +89 -0
- package/dist/core/creatives/render.d.ts +25 -0
- package/dist/core/creatives/render.js +157 -0
- package/dist/core/creatives/specs.d.ts +40 -0
- package/dist/core/creatives/specs.js +51 -0
- package/dist/core/llm.d.ts +23 -0
- package/dist/core/llm.js +77 -0
- package/dist/core/money.d.ts +9 -0
- package/dist/core/money.js +34 -0
- package/dist/core/ops.d.ts +121 -0
- package/dist/core/ops.js +407 -0
- package/dist/core/optimize.d.ts +27 -0
- package/dist/core/optimize.js +56 -0
- package/dist/core/plan.d.ts +22 -0
- package/dist/core/plan.js +59 -0
- package/dist/core/report.d.ts +30 -0
- package/dist/core/report.js +34 -0
- package/dist/core/site.d.ts +30 -0
- package/dist/core/site.js +80 -0
- package/dist/core/state.d.ts +162 -0
- package/dist/core/state.js +110 -0
- package/dist/core/tracking.d.ts +19 -0
- package/dist/core/tracking.js +45 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +19 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +130 -0
- package/package.json +63 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Meta(Facebook·Instagram) — Marketing API(Graph v23) 직접 호출. 시스템 사용자 토큰. 오늘(2026-09-14) 실측 함정 반영:
|
|
2
|
+
// 예산=계정 통화 최소 단위 · Advantage+ 오디언스에선 age_max 금지 · DCO 광고세트엔 광고 1개(컨셉=광고세트) · instagram_user_id · WITH_ISSUES 2446984는 조치 불필요
|
|
3
|
+
// 앱 개발 모드면 크리에이티브 생성 100 오류 · 픽셀 트래픽 권한 밖 도메인은 이벤트 조용히 폐기(signals/config blockReason)
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { MediumError, nameTag, utm } from './types.js';
|
|
6
|
+
const G = 'https://graph.facebook.com/v23.0';
|
|
7
|
+
const REQUIRED_SCOPES = ['ads_management', 'ads_read', 'business_management', 'pages_show_list', 'pages_read_engagement', 'pages_manage_ads'];
|
|
8
|
+
const COUNTRY_INTERESTS = {};
|
|
9
|
+
void COUNTRY_INTERESTS;
|
|
10
|
+
export class MetaConnector {
|
|
11
|
+
c;
|
|
12
|
+
medium = 'meta';
|
|
13
|
+
constructor(c) {
|
|
14
|
+
this.c = c;
|
|
15
|
+
}
|
|
16
|
+
async call(method, path, params = {}) {
|
|
17
|
+
const body = new URLSearchParams();
|
|
18
|
+
for (const [k, v] of Object.entries(params))
|
|
19
|
+
if (v !== undefined && v !== null)
|
|
20
|
+
body.set(k, typeof v === 'string' ? v : JSON.stringify(v));
|
|
21
|
+
body.set('access_token', this.c.accessToken);
|
|
22
|
+
const url = method === 'GET' ? `${G}/${path}?${body}` : `${G}/${path}`;
|
|
23
|
+
const res = await fetch(url, { method, body: method === 'GET' ? undefined : body, signal: AbortSignal.timeout(120_000) });
|
|
24
|
+
const data = (await res.json().catch(() => ({})));
|
|
25
|
+
if (!res.ok || data.error)
|
|
26
|
+
throw this.wrap(data.error, path);
|
|
27
|
+
return data;
|
|
28
|
+
}
|
|
29
|
+
wrap(e, path) {
|
|
30
|
+
const msg = e?.error_user_msg || e?.message || '알 수 없는 오류';
|
|
31
|
+
let hint;
|
|
32
|
+
if (e?.code === 190)
|
|
33
|
+
hint = '토큰이 만료·무효예요. Business Suite › 설정 › 사용자 › 시스템 사용자 › 토큰 생성 을 다시 하고 `adpilot connect meta` 로 넣어 주세요.';
|
|
34
|
+
else if (/개발 모드|development mode|public mode/i.test(msg))
|
|
35
|
+
hint = '앱이 개발 모드예요. developers.facebook.com › 앱 › 상단 「앱 모드」를 라이브로 바꿔 주세요(심사 불필요).';
|
|
36
|
+
else if (e?.code === 200 || e?.code === 10 || /permission/i.test(msg))
|
|
37
|
+
hint = '권한이 부족해요. 시스템 사용자에게 광고 계정·페이지·픽셀 자산이 할당됐는지, 토큰 스코프에 ads_management·pages_manage_ads가 있는지 확인해 주세요.';
|
|
38
|
+
else if (/dynamic creative|다이내믹 크리에이티브/i.test(msg))
|
|
39
|
+
hint = 'DCO 광고세트엔 광고 1개만 들어가요. adpilot은 컨셉마다 광고세트를 따로 만드는데, 손으로 추가한 광고가 있는지 확인해 주세요.';
|
|
40
|
+
else if (/age_max|최대 연령/i.test(msg))
|
|
41
|
+
hint = 'Advantage+ 오디언스에선 최대 연령을 강제할 수 없어요 — 타겟에서 age_max를 빼 주세요.';
|
|
42
|
+
else if (/budget|예산/i.test(msg))
|
|
43
|
+
hint = '예산은 계정 통화의 최소 단위(USD는 센트)예요. 광고세트당 하루 최소 약 $1·₩1,500 이상이어야 해요.';
|
|
44
|
+
return new MediumError('meta', `Meta ${path}: ${msg}${e?.code ? ` (code ${e.code}${e.error_subcode ? `/${e.error_subcode}` : ''})` : ''}`, hint, e);
|
|
45
|
+
}
|
|
46
|
+
async account() {
|
|
47
|
+
const a = await this.call('GET', this.c.adAccountId, { fields: 'id,name,currency,account_status,timezone_name,spend_cap,amount_spent,funding_source_details' });
|
|
48
|
+
return { id: a.id, name: a.name, currency: a.currency, timezone: a.timezone_name, status: a.account_status === 1 ? 'ACTIVE' : String(a.account_status), extra: { spend_cap: a.spend_cap, amount_spent: a.amount_spent, funding: a.funding_source_details?.display_string } };
|
|
49
|
+
}
|
|
50
|
+
async check(landingUrl) {
|
|
51
|
+
const items = [];
|
|
52
|
+
// 1) 토큰
|
|
53
|
+
try {
|
|
54
|
+
const d = await this.call('GET', 'debug_token', { input_token: this.c.accessToken });
|
|
55
|
+
const t = d.data;
|
|
56
|
+
const missing = REQUIRED_SCOPES.filter((s) => !(t.scopes || []).includes(s));
|
|
57
|
+
items.push({ key: 'meta.token', ok: !!t.is_valid, title: '토큰 유효', detail: `${t.type || '?'} · 앱 ${t.application || t.app_id || '?'} · ${t.expires_at ? `만료 ${new Date(t.expires_at * 1000).toISOString().slice(0, 10)}` : '만료 없음'}`, fix: t.is_valid ? undefined : '시스템 사용자 토큰을 다시 생성해 주세요.' });
|
|
58
|
+
items.push({ key: 'meta.token.type', ok: t.type === 'SYSTEM_USER', title: '토큰 종류 = 시스템 사용자', detail: t.type || '?', fix: t.type === 'SYSTEM_USER' ? undefined : '개인 사용자 토큰은 60일 만료·권한 흔들림이 있어요. Business Suite › 설정 › 사용자 › 시스템 사용자 에서 만든 토큰을 쓰세요.' });
|
|
59
|
+
items.push({ key: 'meta.scopes', ok: !missing.length, title: '토큰 권한(스코프)', detail: missing.length ? `빠진 권한: ${missing.join(', ')}` : (t.scopes || []).join(', '), fix: missing.length ? '시스템 사용자 › 토큰 생성 에서 빠진 권한을 체크해 새 토큰을 만들어 주세요(read_insights는 UI에 없음 → ads_read로 충분).' : undefined });
|
|
60
|
+
if (t.app_id)
|
|
61
|
+
this.c.appId = this.c.appId || t.app_id;
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
items.push({ key: 'meta.token', ok: false, title: '토큰 유효', detail: e instanceof Error ? e.message : String(e), fix: e.hint });
|
|
65
|
+
}
|
|
66
|
+
// 2) 광고 계정
|
|
67
|
+
try {
|
|
68
|
+
const a = await this.account();
|
|
69
|
+
items.push({ key: 'meta.account', ok: a.status === 'ACTIVE', title: '광고 계정 상태', detail: `${a.name} (${a.id}) · ${a.status}`, fix: a.status === 'ACTIVE' ? undefined : '광고 계정이 비활성·정지 상태예요. 광고 관리자 › 계정 품질 에서 확인해 주세요.' });
|
|
70
|
+
items.push({ key: 'meta.currency', ok: true, title: '계정 통화(예산 최소 단위)', detail: `${a.currency} — 예산은 ${a.currency === 'KRW' ? '원 단위 정수' : `${a.currency} 최소 단위(센트)로 환산`}해서 보냅니다` });
|
|
71
|
+
items.push({ key: 'meta.funding', ok: a.extra?.funding ? true : null, title: '결제 수단', detail: String(a.extra?.funding || '조회 권한 없음 — 광고 관리자 › 결제 설정 에서 확인'), fix: a.extra?.funding ? undefined : '결제 수단이 없으면 광고가 시작되지 않아요. business.facebook.com › 결제 설정 에서 카드를 등록해 주세요.' });
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
items.push({ key: 'meta.account', ok: false, title: '광고 계정 접근', detail: e instanceof Error ? e.message : String(e), fix: e.hint || '시스템 사용자에게 이 광고 계정을 자산으로 할당해 주세요.' });
|
|
75
|
+
}
|
|
76
|
+
// 3) 페이지·인스타
|
|
77
|
+
if (this.c.pageId) {
|
|
78
|
+
try {
|
|
79
|
+
const p = await this.call('GET', this.c.pageId, { fields: 'name,instagram_business_account' });
|
|
80
|
+
items.push({ key: 'meta.page', ok: true, title: '페이지(광고 신원)', detail: `${p.name} (${this.c.pageId})` });
|
|
81
|
+
const ig = this.c.instagramActorId || p.instagram_business_account?.id;
|
|
82
|
+
items.push({ key: 'meta.instagram', ok: ig ? true : null, title: '인스타그램 계정 연결', detail: ig ? `IG ${ig}` : '연결된 인스타그램 비즈니스 계정이 없어요(페이지 이름으로 인스타 노출)', fix: ig ? undefined : 'Business Suite › 설정 › 인스타그램 계정 에서 연결하면 인스타 광고가 브랜드 계정으로 나가요.' });
|
|
83
|
+
if (ig && !this.c.instagramActorId)
|
|
84
|
+
this.c.instagramActorId = ig;
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
items.push({ key: 'meta.page', ok: false, title: '페이지 접근', detail: e instanceof Error ? e.message : String(e), fix: '시스템 사용자에게 페이지 자산을 할당하고 pages_show_list·pages_manage_ads 스코프를 주세요.' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
items.push({ key: 'meta.page', ok: false, title: '페이지 id', detail: '설정되지 않음', fix: '`adpilot connect meta --page <페이지 id>` — 광고는 페이지 이름으로 나가요.' });
|
|
92
|
+
// 4) 픽셀
|
|
93
|
+
if (this.c.pixelId) {
|
|
94
|
+
try {
|
|
95
|
+
const px = await this.call('GET', this.c.pixelId, { fields: 'name,last_fired_time' });
|
|
96
|
+
const recent = px.last_fired_time ? Date.now() - new Date(px.last_fired_time).getTime() < 3 * 86400_000 : false;
|
|
97
|
+
items.push({ key: 'meta.pixel', ok: true, title: '픽셀 존재', detail: `${px.name} (${this.c.pixelId}) · 마지막 수신 ${px.last_fired_time || '없음'}` });
|
|
98
|
+
items.push({ key: 'meta.pixel.recent', ok: recent ? true : null, title: '픽셀 최근 3일 수신', detail: recent ? '수신 중' : '최근 이벤트 없음 — 랜딩에 픽셀이 심어졌는지 `adpilot track verify` 로 확인', fix: recent ? undefined : '`adpilot track install` 로 스니펫을 발급해 사이트 <head> 에 넣어 주세요.' });
|
|
99
|
+
if (landingUrl)
|
|
100
|
+
items.push(await this.pixelTrafficPermission(landingUrl));
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
items.push({ key: 'meta.pixel', ok: false, title: '픽셀 접근', detail: e instanceof Error ? e.message : String(e), fix: '시스템 사용자에게 픽셀(데이터세트) 자산을 할당해 주세요.' });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else
|
|
107
|
+
items.push({ key: 'meta.pixel', ok: null, title: '픽셀', detail: '설정되지 않음 — 트래픽 목표로만 집행되고 전환 최적화는 못 해요', fix: '이벤트 관리자에서 픽셀을 만들고 `adpilot connect meta --pixel <id>`' });
|
|
108
|
+
// 5) 앱 모드 — API로 직접 조회 불가. 크리에이티브 생성 시 오류로 판정됨을 안내.
|
|
109
|
+
items.push({ key: 'meta.appmode', ok: null, title: '앱 라이브 모드', detail: 'API로 조회할 수 없어요. 개발 모드면 소재 생성 때 「개발 모드 앱」 오류로 멈춰요(그때 정확히 안내).', fix: 'developers.facebook.com › 내 앱 › 상단 토글을 「라이브」로. 심사 불필요(자기 계정만 씀).', link: 'https://developers.facebook.com/apps/' });
|
|
110
|
+
return items;
|
|
111
|
+
}
|
|
112
|
+
/** 픽셀 트래픽 권한 — connect.facebook.net/signals/config/<pixel>?domain=<host> 에 blockReason:"traffic_permissions" 가 있으면 이벤트가 조용히 버려진다 */
|
|
113
|
+
async pixelTrafficPermission(landingUrl) {
|
|
114
|
+
const host = new URL(landingUrl).hostname;
|
|
115
|
+
try {
|
|
116
|
+
const txt = await (await fetch(`https://connect.facebook.net/signals/config/${this.c.pixelId}?v=2.9&r=stable&domain=${host}`, { signal: AbortSignal.timeout(15_000) })).text();
|
|
117
|
+
const blocked = /"blockReason"\s*:\s*"traffic_permissions"/.test(txt);
|
|
118
|
+
return { key: 'meta.pixel.traffic', ok: !blocked, title: `픽셀 트래픽 권한(${host})`, detail: blocked ? `허용 목록에 ${host} 가 없어 이벤트가 조용히 폐기돼요` : '허용됨', fix: blocked ? `이벤트 관리자 › 데이터 소스 › 픽셀 › 설정 › 트래픽 권한 › 허용 목록에 ${host} 추가(API 없음 · 사람 몫).` : undefined, link: `https://business.facebook.com/events_manager2/list/pixel/${this.c.pixelId}/settings` };
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
return { key: 'meta.pixel.traffic', ok: null, title: '픽셀 트래픽 권한', detail: `확인 실패: ${e instanceof Error ? e.message : e}` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** 프로젝트 전용 픽셀 — 이름으로 재사용 · 없으면 생성(새 픽셀은 트래픽 권한 제한이 없어 어느 도메인 이벤트든 받는다) */
|
|
125
|
+
async ensurePixel(name) {
|
|
126
|
+
const d = await this.call('GET', `${this.c.adAccountId}/adspixels`, { fields: 'id,name', limit: 200 });
|
|
127
|
+
const found = d.data.find((x) => x.name === name);
|
|
128
|
+
if (found)
|
|
129
|
+
return found.id;
|
|
130
|
+
return (await this.call('POST', `${this.c.adAccountId}/adspixels`, { name })).id;
|
|
131
|
+
}
|
|
132
|
+
async inventory() {
|
|
133
|
+
const rows = [];
|
|
134
|
+
const camps = await this.call('GET', `${this.c.adAccountId}/campaigns`, { fields: 'id,name,status,effective_status,objective,daily_budget,insights.date_preset(last_30d){spend,impressions,clicks}', limit: 200 });
|
|
135
|
+
for (const c of camps.data) {
|
|
136
|
+
const i = c.insights?.data?.[0];
|
|
137
|
+
rows.push({ level: 'campaign', id: c.id, name: c.name, status: c.status, effectiveStatus: c.effective_status, objective: c.objective, dailyBudgetMinor: c.daily_budget ? Number(c.daily_budget) : undefined, spend30d: i ? Number(i.spend) : 0, impressions30d: i ? Number(i.impressions) : 0, clicks30d: i ? Number(i.clicks) : 0 });
|
|
138
|
+
}
|
|
139
|
+
const ads = await this.call('GET', `${this.c.adAccountId}/ads`, { fields: 'id,name,status,effective_status,campaign_id,issues_info', limit: 500 });
|
|
140
|
+
for (const a of ads.data)
|
|
141
|
+
rows.push({ level: 'ad', id: a.id, name: a.name, status: a.status, effectiveStatus: a.effective_status, parent: a.campaign_id, issues: (a.issues_info || []).map((x) => x.error_summary || x.error_message || '').filter(Boolean) });
|
|
142
|
+
return rows;
|
|
143
|
+
}
|
|
144
|
+
async findByName(edge, name, extra = {}) {
|
|
145
|
+
const d = await this.call('GET', `${this.c.adAccountId}/${edge}`, { fields: 'id,name,status', limit: 500, filtering: [{ field: 'name', operator: 'CONTAIN', value: name.slice(0, 60) }], ...extra });
|
|
146
|
+
return d.data.find((x) => x.name === name) || null;
|
|
147
|
+
}
|
|
148
|
+
async uploadImage(file) {
|
|
149
|
+
const d = await this.call('POST', `${this.c.adAccountId}/adimages`, { bytes: fs.readFileSync(file).toString('base64') });
|
|
150
|
+
const first = Object.values(d.images)[0];
|
|
151
|
+
if (!first?.hash)
|
|
152
|
+
throw new MediumError('meta', '이미지 업로드 응답에 hash가 없어요');
|
|
153
|
+
return first.hash;
|
|
154
|
+
}
|
|
155
|
+
async build(input) {
|
|
156
|
+
const log = input.log || (() => { });
|
|
157
|
+
const warnings = [];
|
|
158
|
+
const placements = [...input.existing];
|
|
159
|
+
const has = (kind, concept) => placements.find((p) => p.medium === 'meta' && p.kind === kind && (concept ? p.concept === concept : true));
|
|
160
|
+
const tag = nameTag(input.slug);
|
|
161
|
+
if (!this.c.pageId)
|
|
162
|
+
throw new MediumError('meta', '페이지 id가 없어요', '`adpilot connect meta --page <페이지 id>`');
|
|
163
|
+
const objective = input.goal === 'purchase' ? 'OUTCOME_SALES' : input.goal === 'lead' || input.goal === 'signup' ? (input.pixelId ? 'OUTCOME_SALES' : 'OUTCOME_TRAFFIC') : 'OUTCOME_TRAFFIC';
|
|
164
|
+
const optimization = input.pixelId && input.goal !== 'traffic' ? 'OFFSITE_CONVERSIONS' : input.pixelId ? 'LANDING_PAGE_VIEWS' : 'LINK_CLICKS';
|
|
165
|
+
const eventType = input.goal === 'purchase' ? 'PURCHASE' : input.goal === 'lead' ? 'LEAD' : input.goal === 'signup' ? 'COMPLETE_REGISTRATION' : 'CONTENT_VIEW';
|
|
166
|
+
if (input.validateOnly) {
|
|
167
|
+
log(' meta: 검증 모드 — 실제 생성 없이 구조만 확인');
|
|
168
|
+
return { placements, warnings: ['Meta는 서버측 validate_only가 없어 구조·규격·예산만 로컬 검증했어요'] };
|
|
169
|
+
}
|
|
170
|
+
// 캠페인(CBO)
|
|
171
|
+
let camp = has('campaign');
|
|
172
|
+
if (!camp) {
|
|
173
|
+
const name = `${tag} ${input.brief.company} · ${input.goal}`;
|
|
174
|
+
const found = await this.findByName('campaigns', name);
|
|
175
|
+
const id = found?.id || (await this.call('POST', `${this.c.adAccountId}/campaigns`, { name, objective, status: 'PAUSED', special_ad_categories: [], daily_budget: String(input.dailyBudgetMinor), bid_strategy: 'LOWEST_COST_WITHOUT_CAP', ...(input.endDate ? { stop_time: `${input.endDate}T23:59:00+0900` } : {}) })).id;
|
|
176
|
+
camp = { medium: 'meta', kind: 'campaign', externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
|
|
177
|
+
placements.push(camp);
|
|
178
|
+
log(` meta 캠페인 ${found ? '재사용' : '생성'} ${id}`);
|
|
179
|
+
}
|
|
180
|
+
// 타겟: 시장 국가 · Advantage+ 오디언스(age_max 금지)
|
|
181
|
+
const targeting = { geo_locations: { countries: input.markets.map((m) => m.country.toUpperCase()) }, age_min: 18, targeting_automation: { advantage_audience: 1 }, ...(input.markets.length === 1 && input.markets[0].language ? { locales: undefined } : {}) };
|
|
182
|
+
const domain = new URL(input.landingUrl).hostname;
|
|
183
|
+
for (const c of input.concepts) {
|
|
184
|
+
const imgs = input.assets.filter((a) => a.concept === c.key && (a.medium === 'meta' || a.medium === 'both'));
|
|
185
|
+
if (!imgs.length) {
|
|
186
|
+
warnings.push(`${c.key}: 메타용 소재가 없어 건너뜀`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
let adset = has('adset', c.key);
|
|
190
|
+
if (!adset) {
|
|
191
|
+
const name = `${tag} ${c.key} · ${input.markets.map((m) => m.country).join('+')} · DCO`;
|
|
192
|
+
const found = await this.findByName('adsets', name);
|
|
193
|
+
const id = found?.id || (await this.call('POST', `${this.c.adAccountId}/adsets`, { name, campaign_id: camp.externalId, optimization_goal: optimization, billing_event: 'IMPRESSIONS', targeting, status: 'PAUSED', is_dynamic_creative: true, ...(input.pixelId ? { promoted_object: { pixel_id: input.pixelId, custom_event_type: eventType } } : {}) })).id;
|
|
194
|
+
adset = { medium: 'meta', kind: 'adset', concept: c.key, externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
|
|
195
|
+
placements.push(adset);
|
|
196
|
+
log(` meta 광고세트 ${c.key} ${found ? '재사용' : '생성'} ${id}`);
|
|
197
|
+
}
|
|
198
|
+
let creative = has('creative', c.key);
|
|
199
|
+
if (!creative) {
|
|
200
|
+
const hashes = [];
|
|
201
|
+
for (const a of imgs.filter((x) => [1080].includes(x.w)))
|
|
202
|
+
hashes.push(await this.uploadImage(a.file));
|
|
203
|
+
const name = `${tag} ${c.key} · DCO`;
|
|
204
|
+
const d = await this.call('POST', `${this.c.adAccountId}/adcreatives`, {
|
|
205
|
+
name,
|
|
206
|
+
object_story_spec: { page_id: this.c.pageId, ...(this.c.instagramActorId ? { instagram_user_id: this.c.instagramActorId } : {}) },
|
|
207
|
+
asset_feed_spec: {
|
|
208
|
+
images: hashes.map((h) => ({ hash: h })), bodies: c.bodies.slice(0, 5).map((t) => ({ text: t })), titles: c.headlines.slice(0, 5).map((t) => ({ text: t })), descriptions: c.descriptions.slice(0, 2).map((t) => ({ text: t })),
|
|
209
|
+
link_urls: [{ website_url: utm(input.landingUrl, 'meta', input.utmCampaign, c.key), display_url: domain }], call_to_action_types: [c.cta], ad_formats: ['SINGLE_IMAGE'],
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
creative = { medium: 'meta', kind: 'creative', concept: c.key, externalId: d.id, name, status: 'OK', createdAt: new Date().toISOString() };
|
|
213
|
+
placements.push(creative);
|
|
214
|
+
log(` meta 크리에이티브 ${c.key} 생성 ${d.id} (이미지 ${hashes.length})`);
|
|
215
|
+
}
|
|
216
|
+
if (!has('ad', c.key)) {
|
|
217
|
+
const name = `${tag} ${c.key}`;
|
|
218
|
+
const d = await this.call('POST', `${this.c.adAccountId}/ads`, { name, adset_id: adset.externalId, creative: { creative_id: creative.externalId }, status: 'PAUSED', ...(input.pixelId ? { conversion_domain: domain } : {}) });
|
|
219
|
+
placements.push({ medium: 'meta', kind: 'ad', concept: c.key, externalId: d.id, name, status: 'PAUSED', createdAt: new Date().toISOString() });
|
|
220
|
+
log(` meta 광고 ${c.key} 생성 ${d.id}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { placements, warnings };
|
|
224
|
+
}
|
|
225
|
+
async preview(placements) {
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const ad of placements.filter((p) => p.medium === 'meta' && p.kind === 'ad')) {
|
|
228
|
+
for (const fmt of ['MOBILE_FEED_STANDARD', 'INSTAGRAM_STANDARD']) {
|
|
229
|
+
try {
|
|
230
|
+
const d = await this.call('GET', `${ad.externalId}/previews`, { ad_format: fmt });
|
|
231
|
+
if (d.data[0]?.body)
|
|
232
|
+
out.push({ concept: ad.concept, name: `Meta · ${fmt === 'INSTAGRAM_STANDARD' ? 'Instagram' : 'Facebook 피드'} · ${ad.concept}`, html: d.data[0].body });
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
out.push({ concept: ad.concept, name: `Meta · ${fmt}`, html: `<p>미리보기 실패: ${e instanceof Error ? e.message : e}</p>` });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
async setStatus(placements, status) {
|
|
242
|
+
const order = status === 'ACTIVE' ? ['campaign', 'adset', 'ad'] : ['ad', 'adset', 'campaign'];
|
|
243
|
+
for (const kind of order)
|
|
244
|
+
for (const p of placements.filter((x) => x.medium === 'meta' && x.kind === kind)) {
|
|
245
|
+
await this.call('POST', p.externalId, { status });
|
|
246
|
+
p.status = status;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async updateBudget(placements, dailyBudgetMinor) {
|
|
250
|
+
for (const p of placements.filter((x) => x.medium === 'meta' && x.kind === 'campaign'))
|
|
251
|
+
await this.call('POST', p.externalId, { daily_budget: String(dailyBudgetMinor) });
|
|
252
|
+
}
|
|
253
|
+
async metrics(placements, range) {
|
|
254
|
+
const camp = placements.find((p) => p.medium === 'meta' && p.kind === 'campaign');
|
|
255
|
+
if (!camp)
|
|
256
|
+
return [];
|
|
257
|
+
const acc = await this.account();
|
|
258
|
+
const d = await this.call('GET', `${this.c.adAccountId}/insights`, { level: 'ad', fields: 'ad_id,ad_name,adset_id,spend,impressions,clicks,actions', time_range: { since: range.since, until: range.until }, time_increment: 1, filtering: [{ field: 'campaign.id', operator: 'IN', value: [camp.externalId] }], limit: 1000 });
|
|
259
|
+
const conv = (acts) => (acts || []).filter((a) => ['purchase', 'lead', 'complete_registration', 'offsite_conversion.fb_pixel_purchase', 'offsite_conversion.fb_pixel_lead', 'offsite_conversion.fb_pixel_complete_registration'].includes(a.action_type)).reduce((n, a) => n + Number(a.value), 0);
|
|
260
|
+
return d.data.map((r) => ({ date: r.date_start, level: 'ad', id: r.ad_id, name: r.ad_name, concept: placements.find((p) => p.externalId === r.ad_id)?.concept, spend: Number(r.spend), impressions: Number(r.impressions), clicks: Number(r.clicks), conversions: conv(r.actions), currency: acc.currency }));
|
|
261
|
+
}
|
|
262
|
+
async policy(placements) {
|
|
263
|
+
const out = [];
|
|
264
|
+
for (const ad of placements.filter((p) => p.medium === 'meta' && p.kind === 'ad')) {
|
|
265
|
+
const d = await this.call('GET', ad.externalId, { fields: 'effective_status,issues_info,ad_review_feedback' });
|
|
266
|
+
const issues = (d.issues_info || []).map((i) => i.error_code === 2446984 ? '광고주 본인 확인 알림(조치 불필요 · 광고 관리자 알림에서 확인 1회)' : `${i.error_summary || ''} ${i.error_message || ''}`.trim());
|
|
267
|
+
for (const [k, v] of Object.entries(d.ad_review_feedback?.global || {}))
|
|
268
|
+
issues.push(`정책 거절 ${k}: ${v}`);
|
|
269
|
+
out.push({ id: ad.externalId, name: ad.name, status: d.effective_status, issues });
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
async remove(placements) { for (const p of placements.filter((x) => x.medium === 'meta' && x.kind === 'campaign'))
|
|
274
|
+
await this.call('DELETE', p.externalId); }
|
|
275
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Placement } from '../core/state.js';
|
|
2
|
+
import { type AccountInfo, type BuildInput, type BuildResult, type CheckItem, type Connector, type InventoryRow, type Medium, type MetricRow, type PolicyRow } from './types.js';
|
|
3
|
+
export declare class MockConnector implements Connector {
|
|
4
|
+
medium: Medium;
|
|
5
|
+
private currency;
|
|
6
|
+
private file;
|
|
7
|
+
constructor(medium: Medium, currency?: string);
|
|
8
|
+
private load;
|
|
9
|
+
private save;
|
|
10
|
+
private rec;
|
|
11
|
+
account(): Promise<AccountInfo>;
|
|
12
|
+
check(): Promise<CheckItem[]>;
|
|
13
|
+
inventory(): Promise<InventoryRow[]>;
|
|
14
|
+
build(input: BuildInput): Promise<BuildResult>;
|
|
15
|
+
preview(placements: Placement[]): Promise<{
|
|
16
|
+
concept: string | undefined;
|
|
17
|
+
name: string;
|
|
18
|
+
html: string;
|
|
19
|
+
}[]>;
|
|
20
|
+
setStatus(placements: Placement[], status: 'ACTIVE' | 'PAUSED'): Promise<void>;
|
|
21
|
+
updateBudget(_p: Placement[], b: number): Promise<void>;
|
|
22
|
+
metrics(placements: Placement[], range: {
|
|
23
|
+
since: string;
|
|
24
|
+
until: string;
|
|
25
|
+
}): Promise<MetricRow[]>;
|
|
26
|
+
policy(placements: Placement[]): Promise<PolicyRow[]>;
|
|
27
|
+
remove(placements: Placement[]): Promise<void>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// 모의 어댑터 — ADPILOT_MOCK=1. 실제 매체 호출 없이 전 흐름(e2e)을 돈다. 상태는 ~/.adpilot/mock/<medium>.json.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { HOME } from '../core/state.js';
|
|
5
|
+
import { nameTag } from './types.js';
|
|
6
|
+
export class MockConnector {
|
|
7
|
+
medium;
|
|
8
|
+
currency;
|
|
9
|
+
file;
|
|
10
|
+
constructor(medium, currency = medium === 'meta' ? 'USD' : 'KRW') {
|
|
11
|
+
this.medium = medium;
|
|
12
|
+
this.currency = currency;
|
|
13
|
+
this.file = path.join(HOME, 'mock', `${medium}.json`);
|
|
14
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
load() { try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return { objects: [], calls: [] };
|
|
21
|
+
} }
|
|
22
|
+
save(s) { fs.writeFileSync(this.file, JSON.stringify(s, null, 2)); }
|
|
23
|
+
rec(s) { const st = this.load(); st.calls.push(s); this.save(st); }
|
|
24
|
+
async account() { return { id: `${this.medium}-mock`, name: `${this.medium} 모의 계정`, currency: this.currency, timezone: 'Asia/Seoul', status: 'ACTIVE' }; }
|
|
25
|
+
async check() { return [{ key: `${this.medium}.mock`, ok: true, title: '모의 어댑터', detail: '실제 매체 호출 없음' }]; }
|
|
26
|
+
async inventory() { return this.load().objects.filter((o) => o.kind === 'campaign').map((o) => ({ level: 'campaign', id: o.externalId, name: o.name, status: o.status, spend30d: 0 })); }
|
|
27
|
+
async build(input) {
|
|
28
|
+
const st = this.load();
|
|
29
|
+
const placements = [...input.existing];
|
|
30
|
+
const tag = nameTag(input.slug);
|
|
31
|
+
const mk = (kind, concept) => { const existing = placements.find((p) => p.medium === this.medium && p.kind === kind && p.concept === concept); if (existing)
|
|
32
|
+
return existing; const p = { medium: this.medium, kind, concept, externalId: `${this.medium}_${kind}_${concept || 'root'}_${Math.random().toString(36).slice(2, 8)}`, name: `${tag} ${kind} ${concept || ''}`.trim(), status: 'PAUSED', createdAt: new Date().toISOString() }; placements.push(p); st.objects.push(p); return p; };
|
|
33
|
+
if (input.validateOnly) {
|
|
34
|
+
st.calls.push('validate');
|
|
35
|
+
this.save(st);
|
|
36
|
+
return { placements: input.existing, warnings: ['모의 검증'] };
|
|
37
|
+
}
|
|
38
|
+
mk('campaign');
|
|
39
|
+
if (this.medium === 'google')
|
|
40
|
+
mk('adgroup');
|
|
41
|
+
for (const c of input.concepts) {
|
|
42
|
+
if (this.medium === 'meta') {
|
|
43
|
+
mk('adset', c.key);
|
|
44
|
+
mk('creative', c.key);
|
|
45
|
+
}
|
|
46
|
+
mk('ad', c.key);
|
|
47
|
+
}
|
|
48
|
+
st.calls.push(`build budget=${input.dailyBudgetMinor} ${input.currency}`);
|
|
49
|
+
this.save(st);
|
|
50
|
+
return { placements, warnings: [] };
|
|
51
|
+
}
|
|
52
|
+
async preview(placements) { return placements.filter((p) => p.medium === this.medium && p.kind === 'ad').map((p) => ({ concept: p.concept, name: `${this.medium} · ${p.concept}`, html: `<div class="gpreview" data-concept="${p.concept}">mock</div>` })); }
|
|
53
|
+
async setStatus(placements, status) { for (const p of placements.filter((x) => x.medium === this.medium))
|
|
54
|
+
p.status = status; this.rec(`status ${status}`); }
|
|
55
|
+
async updateBudget(_p, b) { this.rec(`budget ${b}`); }
|
|
56
|
+
async metrics(placements, range) {
|
|
57
|
+
const ads = placements.filter((p) => p.medium === this.medium && p.kind === 'ad');
|
|
58
|
+
const out = [];
|
|
59
|
+
const d0 = new Date(range.since);
|
|
60
|
+
const d1 = new Date(range.until);
|
|
61
|
+
for (let d = new Date(d0); d <= d1; d.setDate(d.getDate() + 1))
|
|
62
|
+
ads.forEach((a, i) => { const imp = 1000 + i * 400; const clicks = Math.round(imp * (0.008 + i * 0.006)); out.push({ date: d.toISOString().slice(0, 10), level: 'ad', id: a.externalId, name: a.name, concept: a.concept, spend: this.currency === 'USD' ? 8 + i : 12000 + i * 1500, impressions: imp, clicks, conversions: i === 1 ? 3 : 1, currency: this.currency }); });
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
async policy(placements) { return placements.filter((p) => p.medium === this.medium && p.kind === 'ad').map((p) => ({ id: p.externalId, name: p.name, status: 'APPROVED', issues: [] })); }
|
|
66
|
+
async remove(placements) { const st = this.load(); const ids = new Set(placements.map((p) => p.externalId)); st.objects = st.objects.filter((o) => !ids.has(o.externalId)); st.calls.push('remove'); this.save(st); }
|
|
67
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Brief, Concept, CreativeAsset, Goal, Market, Placement } from '../core/state.js';
|
|
2
|
+
export type Medium = 'meta' | 'google';
|
|
3
|
+
export type CheckItem = {
|
|
4
|
+
key: string;
|
|
5
|
+
ok: boolean | null;
|
|
6
|
+
title: string;
|
|
7
|
+
detail: string;
|
|
8
|
+
fix?: string;
|
|
9
|
+
link?: string;
|
|
10
|
+
};
|
|
11
|
+
export type AccountInfo = {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
currency: string;
|
|
15
|
+
timezone?: string;
|
|
16
|
+
status?: string;
|
|
17
|
+
extra?: Record<string, unknown>;
|
|
18
|
+
};
|
|
19
|
+
export type BuildInput = {
|
|
20
|
+
slug: string;
|
|
21
|
+
brief: Brief;
|
|
22
|
+
concepts: Concept[];
|
|
23
|
+
assets: CreativeAsset[];
|
|
24
|
+
goal: Goal;
|
|
25
|
+
markets: Market[];
|
|
26
|
+
dailyBudgetMinor: number;
|
|
27
|
+
currency: string;
|
|
28
|
+
endDate?: string;
|
|
29
|
+
landingUrl: string;
|
|
30
|
+
utmCampaign: string;
|
|
31
|
+
pixelId?: string;
|
|
32
|
+
conversionActionRn?: string;
|
|
33
|
+
existing: Placement[];
|
|
34
|
+
validateOnly?: boolean;
|
|
35
|
+
log?: (s: string) => void;
|
|
36
|
+
};
|
|
37
|
+
export type BuildResult = {
|
|
38
|
+
placements: Placement[];
|
|
39
|
+
warnings: string[];
|
|
40
|
+
};
|
|
41
|
+
export type MetricRow = {
|
|
42
|
+
date?: string;
|
|
43
|
+
level: 'campaign' | 'adset' | 'ad';
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
concept?: string;
|
|
47
|
+
spend: number;
|
|
48
|
+
impressions: number;
|
|
49
|
+
clicks: number;
|
|
50
|
+
conversions: number;
|
|
51
|
+
currency: string;
|
|
52
|
+
};
|
|
53
|
+
export type PolicyRow = {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
status: string;
|
|
57
|
+
issues: string[];
|
|
58
|
+
};
|
|
59
|
+
export type InventoryRow = {
|
|
60
|
+
level: 'campaign' | 'adset' | 'ad';
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
status: string;
|
|
64
|
+
effectiveStatus?: string;
|
|
65
|
+
objective?: string;
|
|
66
|
+
dailyBudgetMinor?: number;
|
|
67
|
+
spend30d?: number;
|
|
68
|
+
impressions30d?: number;
|
|
69
|
+
clicks30d?: number;
|
|
70
|
+
parent?: string;
|
|
71
|
+
issues?: string[];
|
|
72
|
+
};
|
|
73
|
+
export interface Connector {
|
|
74
|
+
medium: Medium;
|
|
75
|
+
account(): Promise<AccountInfo>;
|
|
76
|
+
check(landingUrl?: string): Promise<CheckItem[]>;
|
|
77
|
+
inventory(): Promise<InventoryRow[]>;
|
|
78
|
+
build(input: BuildInput): Promise<BuildResult>;
|
|
79
|
+
preview(placements: Placement[]): Promise<{
|
|
80
|
+
concept?: string;
|
|
81
|
+
name: string;
|
|
82
|
+
html: string;
|
|
83
|
+
}[]>;
|
|
84
|
+
setStatus(placements: Placement[], status: 'ACTIVE' | 'PAUSED'): Promise<void>;
|
|
85
|
+
updateBudget(placements: Placement[], dailyBudgetMinor: number): Promise<void>;
|
|
86
|
+
metrics(placements: Placement[], range: {
|
|
87
|
+
since: string;
|
|
88
|
+
until: string;
|
|
89
|
+
}): Promise<MetricRow[]>;
|
|
90
|
+
policy(placements: Placement[]): Promise<PolicyRow[]>;
|
|
91
|
+
remove?(placements: Placement[]): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
export declare class MediumError extends Error {
|
|
94
|
+
medium: Medium;
|
|
95
|
+
hint?: string | undefined;
|
|
96
|
+
raw?: unknown | undefined;
|
|
97
|
+
constructor(medium: Medium, message: string, hint?: string | undefined, raw?: unknown | undefined);
|
|
98
|
+
}
|
|
99
|
+
export declare const utm: (url: string, medium: Medium, campaign: string, content: string) => string;
|
|
100
|
+
export declare const nameTag: (slug: string) => string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class MediumError extends Error {
|
|
2
|
+
medium;
|
|
3
|
+
hint;
|
|
4
|
+
raw;
|
|
5
|
+
constructor(medium, message, hint, raw) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.medium = medium;
|
|
8
|
+
this.hint = hint;
|
|
9
|
+
this.raw = raw;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const utm = (url, medium, campaign, content) => {
|
|
13
|
+
const u = new URL(url);
|
|
14
|
+
u.searchParams.set('utm_source', medium === 'meta' ? 'meta' : 'google');
|
|
15
|
+
u.searchParams.set('utm_medium', medium === 'meta' ? 'paid_social' : 'display');
|
|
16
|
+
u.searchParams.set('utm_campaign', campaign);
|
|
17
|
+
u.searchParams.set('utm_content', content);
|
|
18
|
+
return u.toString();
|
|
19
|
+
};
|
|
20
|
+
export const nameTag = (slug) => `[adp:${slug}]`;
|
package/dist/cli.d.ts
ADDED