adyou 0.4.0 → 0.5.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/dist/adapters/meta.js +93 -82
- package/dist/adapters/mock.js +23 -19
- package/dist/adapters/types.d.ts +28 -1
- package/dist/adapters/types.js +15 -1
- package/dist/core/brief.js +12 -3
- package/dist/core/creatives/factory.d.ts +13 -6
- package/dist/core/creatives/factory.js +151 -80
- package/dist/core/creatives/media.d.ts +12 -2
- package/dist/core/creatives/media.js +16 -7
- package/dist/core/creatives/playbook.d.ts +40 -0
- package/dist/core/creatives/playbook.js +61 -0
- package/dist/core/creatives/render.d.ts +15 -2
- package/dist/core/creatives/render.js +61 -6
- package/dist/core/creatives/siteshots.d.ts +9 -0
- package/dist/core/creatives/siteshots.js +53 -0
- package/dist/core/creatives/storyboard.d.ts +70 -0
- package/dist/core/creatives/storyboard.js +149 -0
- package/dist/core/creatives/videofx.d.ts +31 -0
- package/dist/core/creatives/videofx.js +39 -0
- package/dist/core/ops.js +13 -3
- package/dist/core/site.d.ts +2 -0
- package/dist/core/site.js +5 -1
- package/dist/core/state.d.ts +22 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +1 -1
package/dist/adapters/meta.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 예산=계정 통화 최소 단위 · Advantage+ 오디언스에선 age_max 금지 · DCO 광고세트엔 광고 1개(컨셉=광고세트) · instagram_user_id · WITH_ISSUES 2446984는 조치 불필요
|
|
3
3
|
// 앱 개발 모드면 크리에이티브 생성 100 오류 · 픽셀 트래픽 권한 밖 도메인은 이벤트 조용히 폐기(signals/config blockReason)
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
-
import { abKey, MediumError, nameTag, utm } from './types.js';
|
|
5
|
+
import { abKey, langGroups, localizedCopy, MediumError, nameTag, utm } from './types.js';
|
|
6
6
|
const G = 'https://graph.facebook.com/v23.0';
|
|
7
7
|
const REQUIRED_SCOPES = ['ads_management', 'ads_read', 'business_management', 'pages_show_list', 'pages_read_engagement', 'pages_manage_ads'];
|
|
8
8
|
const COUNTRY_INTERESTS = {};
|
|
@@ -179,7 +179,7 @@ export class MetaConnector {
|
|
|
179
179
|
const log = input.log || (() => { });
|
|
180
180
|
const warnings = [];
|
|
181
181
|
const placements = [...input.existing];
|
|
182
|
-
const has = (kind, concept, format = 'image') => placements.find((p) => p.medium === 'meta' && p.kind === kind && (concept ? p.concept === concept && (p.format || 'image') === format : true));
|
|
182
|
+
const has = (kind, concept, format = 'image', lang) => placements.find((p) => p.medium === 'meta' && p.kind === kind && (concept ? p.concept === concept && (p.format || 'image') === format && (!lang || (p.lang || '') === (lang || '')) : true));
|
|
183
183
|
const tag = nameTag(input.slug);
|
|
184
184
|
if (!this.c.pageId)
|
|
185
185
|
throw new MediumError('meta', '페이지 id가 없어요', '`adpilot connect meta --page <페이지 id>`');
|
|
@@ -203,89 +203,100 @@ export class MetaConnector {
|
|
|
203
203
|
// 타겟: 시장 국가 · Advantage+ 오디언스(age_max 금지)
|
|
204
204
|
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 } : {}) };
|
|
205
205
|
const domain = new URL(input.landingUrl).hostname;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
adset = { medium: 'meta', kind: 'adset', concept: c.key, externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
|
|
220
|
-
placements.push(adset);
|
|
221
|
-
log(` meta 광고세트 ${c.key} ${found ? '재사용' : '생성'} ${id}`);
|
|
222
|
-
}
|
|
223
|
-
let creative = has('creative', c.key);
|
|
224
|
-
if (!creative) {
|
|
225
|
-
const hashes = [];
|
|
226
|
-
for (const a of imgs.filter((x) => [1080].includes(x.w)))
|
|
227
|
-
hashes.push(await this.uploadImage(a.file));
|
|
228
|
-
const name = `${tag} ${c.key} · DCO`;
|
|
229
|
-
const d = await this.call('POST', `${this.c.adAccountId}/adcreatives`, {
|
|
230
|
-
name,
|
|
231
|
-
object_story_spec: { page_id: this.c.pageId, ...(this.c.instagramActorId ? { instagram_user_id: this.c.instagramActorId } : {}) },
|
|
232
|
-
asset_feed_spec: {
|
|
233
|
-
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 })),
|
|
234
|
-
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'],
|
|
235
|
-
},
|
|
236
|
-
});
|
|
237
|
-
creative = { medium: 'meta', kind: 'creative', concept: c.key, externalId: d.id, name, status: 'OK', createdAt: new Date().toISOString() };
|
|
238
|
-
placements.push(creative);
|
|
239
|
-
log(` meta 크리에이티브 ${c.key} 생성 ${d.id} (이미지 ${hashes.length})`);
|
|
240
|
-
}
|
|
241
|
-
if (!has('ad', c.key)) {
|
|
242
|
-
const name = `${tag} ${c.key}`;
|
|
243
|
-
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 } : {}) });
|
|
244
|
-
placements.push({ medium: 'meta', kind: 'ad', concept: c.key, externalId: d.id, name, status: 'PAUSED', createdAt: new Date().toISOString() });
|
|
245
|
-
log(` meta 광고 ${c.key} 생성 ${d.id}`);
|
|
206
|
+
// 언어 그룹 — 같은 언어를 쓰는 나라끼리 광고세트를 나눠 그 언어 문구·자막 소재만 넣는다(언어 섞임 방지 · 시장별 성과 비교)
|
|
207
|
+
const groups = langGroups(input.markets, input.brief.language);
|
|
208
|
+
for (const c of input.concepts)
|
|
209
|
+
for (const g of groups) {
|
|
210
|
+
const L = g.lang;
|
|
211
|
+
const lt = groups.length > 1 ? ` · ${L}` : '';
|
|
212
|
+
const cc = localizedCopy(c, L);
|
|
213
|
+
const forLang = (a) => (a.lang || input.brief.language) === L || (!a.lang && L === input.brief.language);
|
|
214
|
+
const imgs = input.assets.filter((a) => a.concept === c.key && (a.medium === 'meta' || a.medium === 'both') && (a.type || 'image') === 'image' && forLang(a));
|
|
215
|
+
const vids = input.assets.filter((a) => a.concept === c.key && (a.medium === 'meta' || a.medium === 'both') && a.type === 'video' && forLang(a));
|
|
216
|
+
if (!imgs.length && !vids.length) {
|
|
217
|
+
warnings.push(`${c.key}${lt}: 메타용 소재가 없어 건너뜀`);
|
|
218
|
+
continue;
|
|
246
219
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
220
|
+
const gTargeting = { ...targeting, geo_locations: { countries: g.countries } };
|
|
221
|
+
const gl = groups.length > 1 ? L : undefined;
|
|
222
|
+
if (imgs.length) {
|
|
223
|
+
let adset = has('adset', c.key, 'image', gl);
|
|
224
|
+
if (!adset) {
|
|
225
|
+
const name = `${tag} ${c.key} · ${g.countries.join('+')} · DCO`;
|
|
226
|
+
const found = await this.findByName('adsets', name);
|
|
227
|
+
const id = found?.id || (await this.call('POST', `${this.c.adAccountId}/adsets`, { name, campaign_id: camp.externalId, optimization_goal: optimization, billing_event: 'IMPRESSIONS', targeting: gTargeting, status: 'PAUSED', is_dynamic_creative: true, ...(input.pixelId ? { promoted_object: { pixel_id: input.pixelId, custom_event_type: eventType } } : {}) })).id;
|
|
228
|
+
adset = { medium: 'meta', kind: 'adset', concept: c.key, ...(gl ? { lang: gl } : {}), externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
|
|
229
|
+
placements.push(adset);
|
|
230
|
+
log(` meta 광고세트 ${c.key}${lt} ${found ? '재사용' : '생성'} ${id}`);
|
|
231
|
+
}
|
|
232
|
+
let creative = has('creative', c.key, 'image', gl);
|
|
233
|
+
if (!creative) {
|
|
234
|
+
const hashes = [];
|
|
235
|
+
for (const a of imgs.filter((x) => [1080].includes(x.w)))
|
|
236
|
+
hashes.push(await this.uploadImage(a.file));
|
|
237
|
+
const name = `${tag} ${c.key}${lt} · DCO`;
|
|
238
|
+
const d = await this.call('POST', `${this.c.adAccountId}/adcreatives`, {
|
|
239
|
+
name,
|
|
240
|
+
object_story_spec: { page_id: this.c.pageId, ...(this.c.instagramActorId ? { instagram_user_id: this.c.instagramActorId } : {}) },
|
|
241
|
+
asset_feed_spec: {
|
|
242
|
+
images: hashes.map((h) => ({ hash: h })), bodies: cc.bodies.slice(0, 5).map((t) => ({ text: t })), titles: cc.headlines.slice(0, 5).map((t) => ({ text: t })), descriptions: cc.descriptions.slice(0, 2).map((t) => ({ text: t })),
|
|
243
|
+
link_urls: [{ website_url: utm(input.landingUrl, 'meta', input.utmCampaign, abKey(c.key, 'image', gl)), display_url: domain }], call_to_action_types: [c.cta], ad_formats: ['SINGLE_IMAGE'],
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
creative = { medium: 'meta', kind: 'creative', concept: c.key, ...(gl ? { lang: gl } : {}), externalId: d.id, name, status: 'OK', createdAt: new Date().toISOString() };
|
|
247
|
+
placements.push(creative);
|
|
248
|
+
log(` meta 크리에이티브 ${c.key}${lt} 생성 ${d.id} (이미지 ${hashes.length})`);
|
|
249
|
+
}
|
|
250
|
+
if (!has('ad', c.key, 'image', gl)) {
|
|
251
|
+
const name = `${tag} ${c.key}${lt}`;
|
|
252
|
+
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 } : {}) });
|
|
253
|
+
placements.push({ medium: 'meta', kind: 'ad', concept: c.key, ...(gl ? { lang: gl } : {}), externalId: d.id, name, status: 'PAUSED', createdAt: new Date().toISOString() });
|
|
254
|
+
log(` meta 광고 ${c.key}${lt} 생성 ${d.id}`);
|
|
267
255
|
}
|
|
268
|
-
const name = `${tag} ${c.key} · VIDEO · DCO`;
|
|
269
|
-
const d = await this.call('POST', `${this.c.adAccountId}/adcreatives`, {
|
|
270
|
-
name,
|
|
271
|
-
object_story_spec: { page_id: this.c.pageId, ...(this.c.instagramActorId ? { instagram_user_id: this.c.instagramActorId } : {}) },
|
|
272
|
-
asset_feed_spec: {
|
|
273
|
-
videos, 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 })),
|
|
274
|
-
link_urls: [{ website_url: utm(input.landingUrl, 'meta', input.utmCampaign, abKey(c.key, 'video')), display_url: domain }], call_to_action_types: [c.cta], ad_formats: ['SINGLE_VIDEO'],
|
|
275
|
-
},
|
|
276
|
-
});
|
|
277
|
-
creative = { medium: 'meta', kind: 'creative', concept: c.key, format: 'video', externalId: d.id, name, status: 'OK', createdAt: new Date().toISOString() };
|
|
278
|
-
placements.push(creative);
|
|
279
|
-
log(` meta 영상 크리에이티브 ${c.key} 생성 ${d.id} (영상 ${videos.length})`);
|
|
280
256
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
257
|
+
// 영상 광고 — 컨셉마다 별도 DCO 광고세트(형태 A/B: 이미지 vs 영상) · 9:16 원본 + 파생 비율을 한 크리에이티브의 변형으로
|
|
258
|
+
if (vids.length) {
|
|
259
|
+
let adset = has('adset', c.key, 'video', gl);
|
|
260
|
+
if (!adset) {
|
|
261
|
+
const name = `${tag} ${c.key} · ${g.countries.join('+')} · VIDEO`;
|
|
262
|
+
const found = await this.findByName('adsets', name);
|
|
263
|
+
const id = found?.id || (await this.call('POST', `${this.c.adAccountId}/adsets`, { name, campaign_id: camp.externalId, optimization_goal: optimization, billing_event: 'IMPRESSIONS', targeting: gTargeting, status: 'PAUSED', is_dynamic_creative: true, ...(input.pixelId ? { promoted_object: { pixel_id: input.pixelId, custom_event_type: eventType } } : {}) })).id;
|
|
264
|
+
adset = { medium: 'meta', kind: 'adset', concept: c.key, format: 'video', ...(gl ? { lang: gl } : {}), externalId: id, name, status: 'PAUSED', createdAt: new Date().toISOString() };
|
|
265
|
+
placements.push(adset);
|
|
266
|
+
log(` meta 영상 광고세트 ${c.key}${lt} ${found ? '재사용' : '생성'} ${id}`);
|
|
267
|
+
}
|
|
268
|
+
let creative = has('creative', c.key, 'video', gl);
|
|
269
|
+
if (!creative) {
|
|
270
|
+
// 포맷(셀카 후기·화면 데모·시네마틱…)별 9:16 원본 + 파생 비율을 한 DCO 크리에이티브의 변형으로(최대 10) — 매체가 포맷·비율을 고른다
|
|
271
|
+
const videos = [];
|
|
272
|
+
const ordered = [...vids].sort((a, b) => (a.ratio === '9x16' ? -1 : 1) - (b.ratio === '9x16' ? -1 : 1));
|
|
273
|
+
for (const v of ordered.slice(0, 10)) {
|
|
274
|
+
const vid = await this.uploadVideo(v.file, `${tag} ${c.key} ${v.format || ''} ${v.ratio}${lt}`, log);
|
|
275
|
+
const thumb = imgs.find((a) => a.ratio === v.ratio && a.w === 1080) || imgs.find((a) => a.w === 1080);
|
|
276
|
+
videos.push({ video_id: vid, ...(thumb ? { thumbnail_hash: await this.uploadImage(thumb.file) } : {}) });
|
|
277
|
+
log(` meta 영상 업로드 ${c.key} ${v.format || ''} ${v.ratio}${lt} → ${vid}`);
|
|
278
|
+
}
|
|
279
|
+
const name = `${tag} ${c.key}${lt} · VIDEO · DCO`;
|
|
280
|
+
const d = await this.call('POST', `${this.c.adAccountId}/adcreatives`, {
|
|
281
|
+
name,
|
|
282
|
+
object_story_spec: { page_id: this.c.pageId, ...(this.c.instagramActorId ? { instagram_user_id: this.c.instagramActorId } : {}) },
|
|
283
|
+
asset_feed_spec: {
|
|
284
|
+
videos, bodies: cc.bodies.slice(0, 5).map((t) => ({ text: t })), titles: cc.headlines.slice(0, 5).map((t) => ({ text: t })), descriptions: cc.descriptions.slice(0, 2).map((t) => ({ text: t })),
|
|
285
|
+
link_urls: [{ website_url: utm(input.landingUrl, 'meta', input.utmCampaign, abKey(c.key, 'video', gl)), display_url: domain }], call_to_action_types: [c.cta], ad_formats: ['SINGLE_VIDEO'],
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
creative = { medium: 'meta', kind: 'creative', concept: c.key, format: 'video', ...(gl ? { lang: gl } : {}), externalId: d.id, name, status: 'OK', createdAt: new Date().toISOString() };
|
|
289
|
+
placements.push(creative);
|
|
290
|
+
log(` meta 영상 크리에이티브 ${c.key}${lt} 생성 ${d.id} (영상 ${videos.length})`);
|
|
291
|
+
}
|
|
292
|
+
if (!has('ad', c.key, 'video', gl)) {
|
|
293
|
+
const name = `${tag} ${c.key}${lt} · video`;
|
|
294
|
+
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 } : {}) });
|
|
295
|
+
placements.push({ medium: 'meta', kind: 'ad', concept: c.key, format: 'video', ...(gl ? { lang: gl } : {}), externalId: d.id, name, status: 'PAUSED', createdAt: new Date().toISOString() });
|
|
296
|
+
log(` meta 영상 광고 ${c.key}${lt} 생성 ${d.id}`);
|
|
297
|
+
}
|
|
286
298
|
}
|
|
287
299
|
}
|
|
288
|
-
}
|
|
289
300
|
return { placements, warnings };
|
|
290
301
|
}
|
|
291
302
|
async preview(placements) {
|
|
@@ -321,9 +332,9 @@ export class MetaConnector {
|
|
|
321
332
|
if (!camp)
|
|
322
333
|
return [];
|
|
323
334
|
const acc = await this.account();
|
|
324
|
-
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 });
|
|
335
|
+
const d = await this.call('GET', `${this.c.adAccountId}/insights`, { level: 'ad', fields: 'ad_id,ad_name,adset_id,spend,impressions,clicks,actions,video_play_actions,video_thruplay_watched_actions,video_p50_watched_actions', time_range: { since: range.since, until: range.until }, time_increment: 1, filtering: [{ field: 'campaign.id', operator: 'IN', value: [camp.externalId] }], limit: 1000 });
|
|
325
336
|
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);
|
|
326
|
-
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, format: placements.find((p) => p.externalId === r.ad_id)?.format || 'image', spend: Number(r.spend), impressions: Number(r.impressions), clicks: Number(r.clicks), conversions: conv(r.actions), currency: acc.currency }));
|
|
337
|
+
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, format: placements.find((p) => p.externalId === r.ad_id)?.format || 'image', lang: placements.find((p) => p.externalId === r.ad_id)?.lang, spend: Number(r.spend), impressions: Number(r.impressions), clicks: Number(r.clicks), conversions: conv(r.actions), videoPlays: Number(r.video_play_actions?.[0]?.value || 0) || undefined, thruplays: Number(r.video_thruplay_watched_actions?.[0]?.value || 0) || undefined, videoP50: Number(r.video_p50_watched_actions?.[0]?.value || 0) || undefined, currency: acc.currency }));
|
|
327
338
|
}
|
|
328
339
|
async policy(placements) {
|
|
329
340
|
const out = [];
|
package/dist/adapters/mock.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { HOME } from '../core/state.js';
|
|
5
|
-
import { nameTag } from './types.js';
|
|
5
|
+
import { langGroups, nameTag } from './types.js';
|
|
6
6
|
export class MockConnector {
|
|
7
7
|
medium;
|
|
8
8
|
currency;
|
|
@@ -28,8 +28,8 @@ export class MockConnector {
|
|
|
28
28
|
const st = this.load();
|
|
29
29
|
const placements = [...input.existing];
|
|
30
30
|
const tag = nameTag(input.slug);
|
|
31
|
-
const mk = (kind, concept, format) => { const existing = placements.find((p) => p.medium === this.medium && p.kind === kind && p.concept === concept && (p.format || 'image') === (format || 'image')); if (existing)
|
|
32
|
-
return existing; const p = { medium: this.medium, kind, concept, ...(format ? { format } : {}), externalId: `${this.medium}_${kind}_${concept || 'root'}${format === 'video' ? '_video' : ''}_${Math.random().toString(36).slice(2, 8)}`, name: `${tag} ${kind} ${concept || ''}${format === 'video' ? ' · video' : ''}`.trim(), status: 'PAUSED', createdAt: new Date().toISOString() }; placements.push(p); st.objects.push(p); return p; };
|
|
31
|
+
const mk = (kind, concept, format, lang) => { const existing = placements.find((p) => p.medium === this.medium && p.kind === kind && p.concept === concept && (p.format || 'image') === (format || 'image') && (p.lang || '') === (lang || '')); if (existing)
|
|
32
|
+
return existing; const p = { medium: this.medium, kind, concept, ...(format ? { format } : {}), ...(lang ? { lang } : {}), externalId: `${this.medium}_${kind}_${concept || 'root'}${format === 'video' ? '_video' : ''}${lang ? `_${lang}` : ''}_${Math.random().toString(36).slice(2, 8)}`, name: `${tag} ${kind} ${concept || ''}${format === 'video' ? ' · video' : ''}${lang ? ` · ${lang}` : ''}`.trim(), status: 'PAUSED', createdAt: new Date().toISOString() }; placements.push(p); st.objects.push(p); return p; };
|
|
33
33
|
if (input.validateOnly) {
|
|
34
34
|
st.calls.push('validate');
|
|
35
35
|
this.save(st);
|
|
@@ -38,23 +38,27 @@ export class MockConnector {
|
|
|
38
38
|
mk('campaign');
|
|
39
39
|
if (this.medium === 'google')
|
|
40
40
|
mk('adgroup');
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
const groups = this.medium === 'meta' ? langGroups(input.markets, input.brief.language) : [{ lang: input.brief.language, countries: input.markets.map((m) => m.country) }];
|
|
42
|
+
for (const c of input.concepts)
|
|
43
|
+
for (const g of groups) {
|
|
44
|
+
const gl = groups.length > 1 ? g.lang : undefined;
|
|
45
|
+
const forLang = (a) => (a.lang || input.brief.language) === g.lang;
|
|
46
|
+
const imgs = input.assets.filter((a) => a.concept === c.key && (a.type || 'image') === 'image' && forLang(a));
|
|
47
|
+
const vids = input.assets.filter((a) => a.concept === c.key && a.type === 'video' && forLang(a));
|
|
48
|
+
if (imgs.length || !vids.length) {
|
|
49
|
+
if (this.medium === 'meta') {
|
|
50
|
+
mk('adset', c.key, 'image', gl);
|
|
51
|
+
mk('creative', c.key, 'image', gl);
|
|
52
|
+
}
|
|
53
|
+
mk('ad', c.key, undefined, gl);
|
|
54
|
+
}
|
|
55
|
+
// 영상은 메타만(구글 디스플레이는 유튜브 영상만 받는다) · 컨셉별 형태 A/B · 언어별
|
|
56
|
+
if (vids.length && this.medium === 'meta') {
|
|
57
|
+
mk('adset', c.key, 'video', gl);
|
|
58
|
+
mk('creative', c.key, 'video', gl);
|
|
59
|
+
mk('ad', c.key, 'video', gl);
|
|
48
60
|
}
|
|
49
|
-
mk('ad', c.key);
|
|
50
|
-
}
|
|
51
|
-
// 영상은 메타만(구글 디스플레이는 유튜브 영상만 받는다) · 컨셉별 형태 A/B
|
|
52
|
-
if (vids.length && this.medium === 'meta') {
|
|
53
|
-
mk('adset', c.key, 'video');
|
|
54
|
-
mk('creative', c.key, 'video');
|
|
55
|
-
mk('ad', c.key, 'video');
|
|
56
61
|
}
|
|
57
|
-
}
|
|
58
62
|
st.calls.push(`build budget=${input.dailyBudgetMinor} ${input.currency}`);
|
|
59
63
|
this.save(st);
|
|
60
64
|
return { placements, warnings: [] };
|
|
@@ -69,7 +73,7 @@ export class MockConnector {
|
|
|
69
73
|
const d0 = new Date(range.since);
|
|
70
74
|
const d1 = new Date(range.until);
|
|
71
75
|
for (let d = new Date(d0); d <= d1; d.setDate(d.getDate() + 1))
|
|
72
|
-
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, format: a.format || 'image', spend: this.currency === 'USD' ? 8 + i : 12000 + i * 1500, impressions: imp, clicks, conversions: i === 1 ? 3 : 1, currency: this.currency }); });
|
|
76
|
+
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, format: a.format || 'image', lang: a.lang, ...(a.format === 'video' ? { videoPlays: Math.round(imp * 0.6), thruplays: Math.round(imp * (0.12 + i * 0.03)), videoP50: Math.round(imp * 0.2) } : {}), spend: this.currency === 'USD' ? 8 + i : 12000 + i * 1500, impressions: imp, clicks, conversions: i === 1 ? 3 : 1, currency: this.currency }); });
|
|
73
77
|
return out;
|
|
74
78
|
}
|
|
75
79
|
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: [] })); }
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -45,6 +45,10 @@ export type MetricRow = {
|
|
|
45
45
|
name: string;
|
|
46
46
|
concept?: string;
|
|
47
47
|
format?: 'image' | 'video';
|
|
48
|
+
lang?: string; /** 영상: 재생 수 · 완주(ThruPlay · 15초 또는 끝까지) · 50% 시청 — 훅률·완주율 계산 */
|
|
49
|
+
videoPlays?: number;
|
|
50
|
+
thruplays?: number;
|
|
51
|
+
videoP50?: number;
|
|
48
52
|
spend: number;
|
|
49
53
|
impressions: number;
|
|
50
54
|
clicks: number;
|
|
@@ -100,4 +104,27 @@ export declare class MediumError extends Error {
|
|
|
100
104
|
export declare const utm: (url: string, medium: Medium, campaign: string, content: string) => string;
|
|
101
105
|
export declare const nameTag: (slug: string) => string;
|
|
102
106
|
/** A/B 키 — 컨셉(+영상이면 _video) · utm_content 와 광고 이름에 같이 쓴다 */
|
|
103
|
-
export declare const abKey: (concept: string, format?: "image" | "video") => string;
|
|
107
|
+
export declare const abKey: (concept: string, format?: "image" | "video", lang?: string) => string;
|
|
108
|
+
/** 시장을 언어별로 묶는다(같은 언어 나라 = 한 광고세트) · 기본 언어 그룹이 먼저 */
|
|
109
|
+
export declare function langGroups(markets: {
|
|
110
|
+
country: string;
|
|
111
|
+
language: string;
|
|
112
|
+
}[], baseLang: string): {
|
|
113
|
+
lang: string;
|
|
114
|
+
countries: string[];
|
|
115
|
+
}[];
|
|
116
|
+
/** 언어별 문구(컨셉 i18n 이 있으면 치환) */
|
|
117
|
+
export declare function localizedCopy(c: {
|
|
118
|
+
headlines: string[];
|
|
119
|
+
bodies: string[];
|
|
120
|
+
descriptions: string[];
|
|
121
|
+
i18n?: Record<string, {
|
|
122
|
+
headlines: string[];
|
|
123
|
+
bodies: string[];
|
|
124
|
+
descriptions: string[];
|
|
125
|
+
}>;
|
|
126
|
+
}, lang: string): {
|
|
127
|
+
headlines: string[];
|
|
128
|
+
bodies: string[];
|
|
129
|
+
descriptions: string[];
|
|
130
|
+
};
|
package/dist/adapters/types.js
CHANGED
|
@@ -19,4 +19,18 @@ export const utm = (url, medium, campaign, content) => {
|
|
|
19
19
|
};
|
|
20
20
|
export const nameTag = (slug) => `[adp:${slug}]`;
|
|
21
21
|
/** A/B 키 — 컨셉(+영상이면 _video) · utm_content 와 광고 이름에 같이 쓴다 */
|
|
22
|
-
export const abKey = (concept, format) =>
|
|
22
|
+
export const abKey = (concept, format, lang) => `${format === 'video' ? `${concept}_video` : concept}${lang ? `_${lang}` : ''}`;
|
|
23
|
+
/** 시장을 언어별로 묶는다(같은 언어 나라 = 한 광고세트) · 기본 언어 그룹이 먼저 */
|
|
24
|
+
export function langGroups(markets, baseLang) {
|
|
25
|
+
const m = new Map();
|
|
26
|
+
for (const x of markets) {
|
|
27
|
+
const l = x.language || baseLang;
|
|
28
|
+
m.set(l, [...(m.get(l) || []), x.country.toUpperCase()]);
|
|
29
|
+
}
|
|
30
|
+
return [...m.entries()].map(([lang, countries]) => ({ lang, countries })).sort((a, b) => (a.lang === baseLang ? -1 : 0) - (b.lang === baseLang ? -1 : 0));
|
|
31
|
+
}
|
|
32
|
+
/** 언어별 문구(컨셉 i18n 이 있으면 치환) */
|
|
33
|
+
export function localizedCopy(c, lang) {
|
|
34
|
+
const t = c.i18n?.[lang];
|
|
35
|
+
return t && t.headlines.length ? { headlines: t.headlines, bodies: t.bodies.length ? t.bodies : c.bodies, descriptions: t.descriptions.length ? t.descriptions : c.descriptions } : { headlines: c.headlines, bodies: c.bodies, descriptions: c.descriptions };
|
|
36
|
+
}
|
package/dist/core/brief.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// 브리프 — 사이트 스냅샷 → 「이 회사는 ○○를 팔고 고객은 ○○로 보여요」. LLM이 있으면 LLM, 없으면 규칙으로 최소 브리프.
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { aiAvailable, completeJson } from './llm.js';
|
|
4
|
+
import { INDUSTRY_KEYS } from './creatives/playbook.js';
|
|
4
5
|
import { readSite } from './site.js';
|
|
5
6
|
/** 특별 광고 카테고리(정치·금융·주택·고용·의료·도박 등)는 자동 심사 통과가 어려워 접수 거절 */
|
|
6
7
|
const SPECIAL = [
|
|
@@ -21,6 +22,10 @@ const BriefSchema = z.object({
|
|
|
21
22
|
palette: z.object({ primary: z.string(), dark: z.string(), light: z.string() }),
|
|
22
23
|
specialCategory: z.string().nullable().default(null),
|
|
23
24
|
notes: z.string().default(''),
|
|
25
|
+
industry: z.string().default('other'),
|
|
26
|
+
visualGenres: z.array(z.string()).min(1).max(3).default(['photoreal']),
|
|
27
|
+
audienceProfile: z.object({ ageRange: z.string().optional(), gender: z.string().optional(), persona: z.string().optional(), interests: z.array(z.string()).optional(), painPoint: z.string().optional() }).default({}),
|
|
28
|
+
proofPoints: z.array(z.string()).max(6).default([]),
|
|
24
29
|
});
|
|
25
30
|
function pickPalette(s) {
|
|
26
31
|
const primary = (s.themeColor && /^#[0-9a-f]{6}$/i.test(s.themeColor) ? s.themeColor : s.colors.find((c) => !/^#([0-9a-f])\1{5}$/i.test(c) && !/^#(f[0-9a-f]f[0-9a-f]f[0-9a-f]|[0-2][0-9a-f]{5})$/i.test(c))) || '#3182f6';
|
|
@@ -46,13 +51,17 @@ export async function buildBrief(url, opts) {
|
|
|
46
51
|
- language: 광고 카피 기본 언어 코드(사이트 언어 · 시장이 해외면 그 언어)
|
|
47
52
|
- palette: 사이트에서 읽힌 대표색 hex(primary) · 어두운 배경색(dark) · 밝은 배경색(light). 못 읽으면 후보를 그대로.
|
|
48
53
|
- specialCategory: 금융·의료·정치·도박·담배·성인 등 특별 광고 카테고리에 해당하면 그 이름, 아니면 null
|
|
49
|
-
- notes: 광고 만들 때 주의할 점 한두 문장(가격 표기·금지어 등)
|
|
54
|
+
- notes: 광고 만들 때 주의할 점 한두 문장(가격 표기·금지어 등)
|
|
55
|
+
- industry: 업종 키 하나 — ${INDUSTRY_KEYS.join('|')} 중에서(없으면 other)
|
|
56
|
+
- visualGenres: 소재 비주얼 장르 1~3개(우선순위) — photoreal(실사) · ugc(셀카·후기 실사) · anime(애니) · 3d(3D 렌더) · illustration(일러스트) · motion_graphics(모션그래픽) · product_shot(제품 스튜디오). 브랜드가 애니·게임·웹툰이면 anime 를 앞에, 일반 상품·서비스는 photoreal/ugc 를 앞에. 애니 브랜드가 아닌데 anime 를 쓰면 이상하니 넣지 않는다.
|
|
57
|
+
- audienceProfile: ageRange(예 "25-34") · gender(all|female|male) · persona(UGC 배우로 쓸 한 줄 인물 묘사 · 영어) · interests 2~4 · painPoint(한 문장)
|
|
58
|
+
- proofPoints: 숫자·수상·후기·보증 등 사이트에 실제로 있는 증거 문구 0~5(없으면 빈 배열 · 만들지 않는다)`,
|
|
50
59
|
user: JSON.stringify({ url: site.finalUrl, title: site.title, description: site.description, lang: site.lang, headings: site.headings, prices: site.prices, colorsFound: site.colors.slice(0, 8), themeColor: site.themeColor, textExcerpt: site.text.slice(0, 3500), goal: opts.goal, markets: opts.markets, hints: opts.hints || '' }),
|
|
51
60
|
});
|
|
52
|
-
brief = { url: site.finalUrl, ...out, palette: { primary: /^#[0-9a-f]{6}$/i.test(out.palette.primary) ? out.palette.primary.toLowerCase() : fallbackPalette.primary, dark: /^#[0-9a-f]{6}$/i.test(out.palette.dark) ? out.palette.dark : fallbackPalette.dark, light: /^#[0-9a-f]{6}$/i.test(out.palette.light) ? out.palette.light : fallbackPalette.light }, logoUrl: site.logo, ogImage: site.ogImage, specialCategory: out.specialCategory || special };
|
|
61
|
+
brief = { url: site.finalUrl, ...out, palette: { primary: /^#[0-9a-f]{6}$/i.test(out.palette.primary) ? out.palette.primary.toLowerCase() : fallbackPalette.primary, dark: /^#[0-9a-f]{6}$/i.test(out.palette.dark) ? out.palette.dark : fallbackPalette.dark, light: /^#[0-9a-f]{6}$/i.test(out.palette.light) ? out.palette.light : fallbackPalette.light }, logoUrl: site.logo, ogImage: site.ogImage, specialCategory: out.specialCategory || special, siteImages: site.images };
|
|
53
62
|
}
|
|
54
63
|
else {
|
|
55
|
-
brief = { url: site.finalUrl, company: site.title.split(/[|·—\-–:]/)[0].trim() || new URL(site.finalUrl).hostname, offer: site.description || site.headings[0] || site.title, category: '', audience: ['사이트 방문자'], usp: site.headings.slice(0, 3), tone: '단정하고 명확', language: site.lang || 'ko', palette: fallbackPalette, logoUrl: site.logo, ogImage: site.ogImage, specialCategory: special, notes: 'LLM 키가 없어 규칙으로 만든 최소 브리프예요. `adpilot brief --edit` 로 다듬어 주세요.' };
|
|
64
|
+
brief = { url: site.finalUrl, company: site.title.split(/[|·—\-–:]/)[0].trim() || new URL(site.finalUrl).hostname, offer: site.description || site.headings[0] || site.title, category: '', audience: ['사이트 방문자'], usp: site.headings.slice(0, 3), tone: '단정하고 명확', language: site.lang || 'ko', palette: fallbackPalette, logoUrl: site.logo, ogImage: site.ogImage, specialCategory: special, industry: 'other', visualGenres: ['photoreal'], siteImages: site.images, notes: 'LLM 키가 없어 규칙으로 만든 최소 브리프예요. `adpilot brief --edit` 로 다듬어 주세요.' };
|
|
56
65
|
}
|
|
57
66
|
return { brief, site };
|
|
58
67
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { Brief, Concept, CreativeAsset } from '../state.js';
|
|
1
|
+
import type { Brief, Concept, CreativeAsset, Goal } from '../state.js';
|
|
2
2
|
import { type VideoJob, type VideoRatio } from './media.js';
|
|
3
|
+
import { type Tone } from './playbook.js';
|
|
3
4
|
export type VideoAsset = CreativeAsset & {
|
|
4
5
|
type: 'video';
|
|
5
6
|
costKrw: number;
|
|
@@ -7,16 +8,22 @@ export type VideoAsset = CreativeAsset & {
|
|
|
7
8
|
mime: 'video/mp4';
|
|
8
9
|
};
|
|
9
10
|
export type FactoryLog = (s: string) => void;
|
|
10
|
-
|
|
11
|
-
export declare function renderEndCard(dir: string, brief: Brief, concept: Concept, ratio: VideoRatio): Promise<string | null>;
|
|
12
|
-
/** 컨셉의 영상 소재 전부(계획된 비율·길이) — 실패한 건은 건너뛰고 로그 · 성공한 건마다 onAsset */
|
|
13
|
-
export declare function produceConceptVideos(o: {
|
|
11
|
+
export type FactoryInput = {
|
|
14
12
|
dir: string;
|
|
15
13
|
brief: Brief;
|
|
16
14
|
concept: Concept;
|
|
17
15
|
jobs: VideoJob[];
|
|
18
16
|
backgrounds: Record<string, string | null>;
|
|
19
17
|
refs?: string[];
|
|
18
|
+
siteShot?: string | null;
|
|
19
|
+
siteImages?: string[];
|
|
20
|
+
langs?: string[];
|
|
21
|
+
tone?: Tone;
|
|
22
|
+
goal?: Goal;
|
|
20
23
|
log?: FactoryLog;
|
|
21
24
|
onAsset?: (a: VideoAsset) => void | Promise<void>;
|
|
22
|
-
}
|
|
25
|
+
};
|
|
26
|
+
/** 영상용 엔드카드 PNG — 해당 비율의 포스터를 영상 크기로 렌더(언어별 문구) */
|
|
27
|
+
export declare function renderEndCard(dir: string, brief: Brief, concept: Concept, ratio: VideoRatio, lang: string, backgrounds?: Record<string, string | null>): Promise<string | null>;
|
|
28
|
+
/** 컨셉의 영상 소재 전부(포맷 × 언어 × 비율) — 실패한 포맷은 건너뛰고 로그 · 성공한 건마다 onAsset */
|
|
29
|
+
export declare function produceConceptVideos(o: FactoryInput): Promise<VideoAsset[]>;
|