adyou 0.7.2 → 0.7.4
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/google.d.ts +11 -1
- package/dist/adapters/google.js +41 -8
- package/dist/adapters/meta-targeting.d.ts +24 -3
- package/dist/adapters/meta-targeting.js +50 -1
- package/dist/adapters/meta.d.ts +6 -1
- package/dist/adapters/meta.js +15 -2
- package/dist/adapters/mock.d.ts +9 -0
- package/dist/adapters/mock.js +14 -1
- package/dist/adapters/types.d.ts +5 -0
- package/dist/core/creatives/linebreak.js +5 -6
- package/dist/core/creatives/render.js +32 -6
- package/dist/core/report.d.ts +4 -0
- package/dist/core/report.js +8 -2
- package/dist/core/state.d.ts +11 -0
- package/dist/core/state.js +2 -0
- package/package.json +1 -1
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type GoogleCreds, type Market, type Placement } from '../core/state.js';
|
|
2
2
|
import { type AccountInfo, type BuildInput, type BuildResult, type CheckItem, type Connector, type InventoryRow, type MetricRow, type PolicyRow } from './types.js';
|
|
3
3
|
export declare const GEO: Record<string, number>;
|
|
4
4
|
export declare const LANG: Record<string, number>;
|
|
5
|
+
/** 나라 전체(geoTargetConstant) 또는 동네 반경(proximity) — 지역을 고른 나라는 나라 전체를 걸지 않는다(겹치면 나라 전체로 퍼짐) */
|
|
6
|
+
export declare function locationOps(campaign: string, markets: Market[]): {
|
|
7
|
+
ops: unknown[];
|
|
8
|
+
warnings: string[];
|
|
9
|
+
};
|
|
5
10
|
export declare class GoogleConnector implements Connector {
|
|
6
11
|
private c;
|
|
7
12
|
medium: "google";
|
|
@@ -37,6 +42,11 @@ export declare class GoogleConnector implements Connector {
|
|
|
37
42
|
}[]>;
|
|
38
43
|
setStatus(placements: Placement[], status: 'ACTIVE' | 'PAUSED'): Promise<void>;
|
|
39
44
|
updateBudget(placements: Placement[], dailyBudgetMinor: number): Promise<void>;
|
|
45
|
+
/** 지역·종료일 교체 — 캠페인의 위치 조건(LOCATION·PROXIMITY)을 지우고 새로 · 동네 반경이면 「그곳에 있는 사람」만(PRESENCE) */
|
|
46
|
+
updateTargeting(placements: Placement[], t: {
|
|
47
|
+
markets: Market[];
|
|
48
|
+
endDate?: string | null;
|
|
49
|
+
}): Promise<void>;
|
|
40
50
|
metrics(placements: Placement[], range: {
|
|
41
51
|
since: string;
|
|
42
52
|
until: string;
|
package/dist/adapters/google.js
CHANGED
|
@@ -2,11 +2,31 @@
|
|
|
2
2
|
// validate_only는 요청 본문 · end_date_time은 23:59:59 · RDA 글자 수는 한글 2자 · CTA는 영문 고정 문구 · 클릭 최대화=target_spend · 로고 1:1/4:1 · 이름 기준 멱등 재실행
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import { clampRadiusKm } from '../core/state.js';
|
|
5
6
|
import { fit, GOOGLE_CTA, GOOGLE_LIMITS, wlen } from '../core/creatives/specs.js';
|
|
6
7
|
import { MediumError, nameTag, utm } from './types.js';
|
|
7
8
|
const API = 'https://googleads.googleapis.com/v25';
|
|
8
9
|
export const GEO = { KR: 2410, US: 2840, JP: 2392, GB: 2826, DE: 2276, FR: 2250, SG: 2702, TW: 2158, VN: 2704, AU: 2036, CA: 2124, ID: 2360, TH: 2764, IN: 2356, HK: 2344, MY: 2458, PH: 2608, MX: 2484, BR: 2076, ES: 2724, IT: 2380, NL: 2528, AE: 2784, SA: 2682, TR: 2792, PL: 2616, SE: 2752, CH: 2756, NZ: 2554 };
|
|
9
10
|
export const LANG = { ko: 1012, en: 1000, ja: 1005, zh: 1017, 'zh-TW': 1018, de: 1001, fr: 1002, es: 1003, vi: 1040, th: 1044, id: 1025, pt: 1014, it: 1004, nl: 1010, ms: 1102, hi: 1023, ar: 1019, ru: 1031, tr: 1037, pl: 1030, sv: 1015 };
|
|
11
|
+
const hasAreas = (markets) => markets.some((m) => m.areas?.length);
|
|
12
|
+
/** 나라 전체(geoTargetConstant) 또는 동네 반경(proximity) — 지역을 고른 나라는 나라 전체를 걸지 않는다(겹치면 나라 전체로 퍼짐) */
|
|
13
|
+
export function locationOps(campaign, markets) {
|
|
14
|
+
const ops = [];
|
|
15
|
+
const warnings = [];
|
|
16
|
+
for (const m of markets) {
|
|
17
|
+
if (m.areas?.length) {
|
|
18
|
+
for (const a of m.areas)
|
|
19
|
+
ops.push({ create: { campaign, proximity: { geoPoint: { latitudeInMicroDegrees: Math.round(a.lat * 1e6), longitudeInMicroDegrees: Math.round(a.lng * 1e6) }, radius: clampRadiusKm(a.radiusKm), radiusUnits: 'KILOMETERS' } } });
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const geo = GEO[m.country.toUpperCase()];
|
|
23
|
+
if (geo)
|
|
24
|
+
ops.push({ create: { campaign, location: { geoTargetConstant: `geoTargetConstants/${geo}` } } });
|
|
25
|
+
else
|
|
26
|
+
warnings.push(`구글 지역 상수 미등록: ${m.country}`);
|
|
27
|
+
}
|
|
28
|
+
return { ops, warnings };
|
|
29
|
+
}
|
|
10
30
|
export class GoogleConnector {
|
|
11
31
|
c;
|
|
12
32
|
medium = 'google';
|
|
@@ -176,7 +196,7 @@ export class GoogleConnector {
|
|
|
176
196
|
const budgetName = `${tag} 일 예산`;
|
|
177
197
|
const amountMicros = String(Math.round(input.dailyBudgetMinor * (acc.currency === 'KRW' || acc.currency === 'JPY' ? 1 : 0.01) * 1_000_000));
|
|
178
198
|
const campName = `${tag} ${input.brief.company} · 디스플레이 · ${input.goal}`;
|
|
179
|
-
const campaignCreate = (budgetRn) => ({ name: campName, status: 'PAUSED', advertisingChannelType: 'DISPLAY', campaignBudget: budgetRn, targetSpend: {}, networkSettings: { targetContentNetwork: true, targetGoogleSearch: false, targetSearchNetwork: false }, containsEuPoliticalAdvertising: 'DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING', ...(input.endDate ? { endDateTime: `${input.endDate} 23:59:59` } : {}) });
|
|
199
|
+
const campaignCreate = (budgetRn) => ({ name: campName, status: 'PAUSED', advertisingChannelType: 'DISPLAY', campaignBudget: budgetRn, targetSpend: {}, networkSettings: { targetContentNetwork: true, targetGoogleSearch: false, targetSearchNetwork: false }, containsEuPoliticalAdvertising: 'DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING', ...(input.endDate ? { endDateTime: `${input.endDate} 23:59:59` } : {}), ...(hasAreas(input.markets) ? { geoTargetTypeSetting: { positiveGeoTargetType: 'PRESENCE' } } : {}) });
|
|
180
200
|
if (V) {
|
|
181
201
|
// 🔴 validate_only는 요청 본문 · 예산 없이 캠페인만 검증하면 REQUIRED 오류 → 임시 id(-1)로 예산+캠페인을 한 번에 검증
|
|
182
202
|
const tmp = `customers/${this.c.customerId}/campaignBudgets/-1`;
|
|
@@ -199,13 +219,9 @@ export class GoogleConnector {
|
|
|
199
219
|
const d = await this.call('campaigns:mutate', { operations: [{ create: campaignCreate(budgetRn) }] });
|
|
200
220
|
rn = d.results[0].resourceName;
|
|
201
221
|
const ops = [];
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
ops.push({ create: { campaign: rn, location: { geoTargetConstant: `geoTargetConstants/${geo}` } } });
|
|
206
|
-
else
|
|
207
|
-
warnings.push(`구글 지역 상수 미등록: ${m.country}`);
|
|
208
|
-
}
|
|
222
|
+
const geo = locationOps(rn, input.markets);
|
|
223
|
+
ops.push(...geo.ops);
|
|
224
|
+
warnings.push(...geo.warnings);
|
|
209
225
|
const langs = [...new Set(input.markets.map((m) => LANG[m.language] || LANG[m.language.split('-')[0]]).filter(Boolean))];
|
|
210
226
|
for (const l of langs)
|
|
211
227
|
ops.push({ create: { campaign: rn, language: { languageConstant: `languageConstants/${l}` } } });
|
|
@@ -294,6 +310,23 @@ export class GoogleConnector {
|
|
|
294
310
|
return;
|
|
295
311
|
await this.call('campaignBudgets:mutate', { operations: [{ update: { resourceName: rn, amountMicros: String(Math.round(dailyBudgetMinor * (acc.currency === 'KRW' || acc.currency === 'JPY' ? 1 : 0.01) * 1_000_000)) }, updateMask: 'amount_micros' }] });
|
|
296
312
|
}
|
|
313
|
+
/** 지역·종료일 교체 — 캠페인의 위치 조건(LOCATION·PROXIMITY)을 지우고 새로 · 동네 반경이면 「그곳에 있는 사람」만(PRESENCE) */
|
|
314
|
+
async updateTargeting(placements, t) {
|
|
315
|
+
for (const camp of placements.filter((x) => x.medium === 'google' && x.kind === 'campaign')) {
|
|
316
|
+
const cur = await this.search(`SELECT campaign_criterion.resource_name FROM campaign_criterion WHERE campaign.resource_name = '${camp.externalId}' AND campaign_criterion.type IN ('LOCATION', 'PROXIMITY') AND campaign_criterion.negative = FALSE`);
|
|
317
|
+
const geo = locationOps(camp.externalId, t.markets);
|
|
318
|
+
if (!geo.ops.length)
|
|
319
|
+
throw new MediumError('google', `구글 지역 조건을 만들 수 없어요: ${geo.warnings.join(' · ')}`);
|
|
320
|
+
await this.call('campaignCriteria:mutate', { operations: [...cur.map((r) => ({ remove: r.campaignCriterion.resourceName })), ...geo.ops] });
|
|
321
|
+
const update = { resourceName: camp.externalId, geoTargetTypeSetting: { positiveGeoTargetType: hasAreas(t.markets) ? 'PRESENCE' : 'PRESENCE_OR_INTEREST' } };
|
|
322
|
+
const mask = ['geo_target_type_setting.positive_geo_target_type'];
|
|
323
|
+
if (t.endDate !== undefined) {
|
|
324
|
+
update.endDateTime = t.endDate ? `${t.endDate} 23:59:59` : '2037-12-30 23:59:59';
|
|
325
|
+
mask.push('end_date_time');
|
|
326
|
+
}
|
|
327
|
+
await this.call('campaigns:mutate', { operations: [{ update, updateMask: mask.join(',') }] });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
297
330
|
async metrics(placements, range) {
|
|
298
331
|
const camp = placements.find((p) => p.medium === 'google' && p.kind === 'campaign');
|
|
299
332
|
if (!camp)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Area } from '../core/state.js';
|
|
1
2
|
export type AudienceHint = {
|
|
2
3
|
ageRange?: string;
|
|
3
4
|
gender?: string;
|
|
@@ -7,11 +8,21 @@ export type TargetingInput = {
|
|
|
7
8
|
lang?: string;
|
|
8
9
|
audience?: AudienceHint;
|
|
9
10
|
strict?: boolean;
|
|
11
|
+
areas?: Record<string, Area[]>;
|
|
12
|
+
};
|
|
13
|
+
export type MetaGeo = {
|
|
14
|
+
countries?: string[];
|
|
15
|
+
custom_locations?: {
|
|
16
|
+
latitude: number;
|
|
17
|
+
longitude: number;
|
|
18
|
+
radius: number;
|
|
19
|
+
distance_unit: 'kilometer';
|
|
20
|
+
name?: string;
|
|
21
|
+
}[];
|
|
22
|
+
location_types?: ('home' | 'recent')[];
|
|
10
23
|
};
|
|
11
24
|
export type MetaTargeting = {
|
|
12
|
-
geo_locations:
|
|
13
|
-
countries: string[];
|
|
14
|
-
};
|
|
25
|
+
geo_locations: MetaGeo;
|
|
15
26
|
age_min: number;
|
|
16
27
|
age_max?: number;
|
|
17
28
|
genders?: number[];
|
|
@@ -35,3 +46,13 @@ export declare function parseAgeRange(s?: string): {
|
|
|
35
46
|
max?: number;
|
|
36
47
|
} | null;
|
|
37
48
|
export declare function metaTargeting(i: TargetingInput): TargetingResult;
|
|
49
|
+
/** 지역 — 동네 반경(custom_locations)을 고른 나라는 나라 전체를 걸지 않는다(메타는 겹치는 위치를 거절 · 나라를 두면 나라 전체로 퍼짐).
|
|
50
|
+
* 지역을 고르지 않은 나라는 나라 전체 그대로. areas = 나라 → 그 나라의 동네 반경 */
|
|
51
|
+
export declare function metaGeo(countries: string[], areas?: Record<string, Area[]>): MetaGeo;
|
|
52
|
+
/** 시장 목록에서 나라별 동네 반경만 뽑는다(이 광고세트의 나라만) */
|
|
53
|
+
export declare function areasFor(markets: {
|
|
54
|
+
country: string;
|
|
55
|
+
areas?: Area[];
|
|
56
|
+
}[], countries: string[]): Record<string, Area[]>;
|
|
57
|
+
/** 겹치는 원을 하나로 — 이웃한 동네 여러 곳(천천동·율전동…)은 반경이 겹치는데, 메타는 겹치는 위치를 거절할 수 있다. 겹치는 무리는 무리 전체를 덮는 원 하나로(구글은 겹쳐도 돼서 원래대로). */
|
|
58
|
+
export declare function mergeOverlaps(areas: Area[]): Area[];
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// 메타 광고세트 타겟 빌더(순수 함수 · 단위 테스트) — 밈튜브 광고 하네스(2026-09-22)에서 배운 것을 제품에 옮김.
|
|
2
|
+
// ① 디아스포라(나라 기본 언어 ≠ 시장 언어 · 예 US:ko)는 `locales` 로 그 언어 사용자만 · ② 대만·싱가포르 포함이면 `regional_regulated_categories` 필수(100/3858498·3858550)
|
|
3
|
+
// ③ 브리프 audienceProfile(연령대·성별)을 타겟에 연결 — 기본(auto)은 Advantage+ 오디언스 안에서 허용되는 최소 연령만(age_max 금지) ·
|
|
4
|
+
// strict 는 Advantage+ 를 끄고 연령 상한·성별을 강제(밈튜브가 쓴 방식 · 「밈 만들어 공유할 18~34」처럼 타겟이 좁고 분명할 때).
|
|
5
|
+
import { clampRadiusKm } from '../core/state.js';
|
|
1
6
|
/** 나라 기본 언어 — 시장 언어가 이것과 다르면 「그 나라에 사는 다른 언어 사용자」(디아스포라) 로 보고 locales 를 건다 */
|
|
2
7
|
export const COUNTRY_LANG = {
|
|
3
8
|
KR: 'ko', JP: 'ja', CN: 'zh', TW: 'zh', HK: 'zh', MO: 'zh', SG: 'en', MY: 'ms', US: 'en', GB: 'en', CA: 'en', AU: 'en', NZ: 'en', IE: 'en',
|
|
@@ -36,7 +41,10 @@ const clamp = (n) => Math.max(18, Math.min(65, n));
|
|
|
36
41
|
export function metaTargeting(i) {
|
|
37
42
|
const countries = [...new Set(i.countries.map((c) => c.toUpperCase()))];
|
|
38
43
|
const notes = [];
|
|
39
|
-
const t = { geo_locations:
|
|
44
|
+
const t = { geo_locations: metaGeo(countries, i.areas), age_min: 18, targeting_automation: { advantage_audience: 1 } };
|
|
45
|
+
const flat = Object.values(i.areas || {}).flat();
|
|
46
|
+
if (flat.length)
|
|
47
|
+
notes.push(`지역: ${flat.map((a) => `${a.label} 반경 ${clampRadiusKm(a.radiusKm)}km`).join(' · ')}(사는 사람·최근 있었던 사람)`);
|
|
40
48
|
const lang = (i.lang || '').split('-')[0];
|
|
41
49
|
// ① 디아스포라 → locales(그 언어를 쓰는 사용자만). 나라 전부가 그 언어를 기본으로 쓰면 걸지 않는다(도달 손실만 있음).
|
|
42
50
|
if (lang && countries.some((c) => (COUNTRY_LANG[c] || '') !== lang) && META_LOCALE[lang]) {
|
|
@@ -64,3 +72,44 @@ export function metaTargeting(i) {
|
|
|
64
72
|
notes.push(`최소 연령 ${age.min}세(Advantage+ 오디언스 · 상한은 매체가 넓힘${age.max ? ` · 브리프 상한 ${age.max}세는 strict 에서만 강제` : ''})`);
|
|
65
73
|
return { targeting: t, regionalCategories, notes };
|
|
66
74
|
}
|
|
75
|
+
/** 지역 — 동네 반경(custom_locations)을 고른 나라는 나라 전체를 걸지 않는다(메타는 겹치는 위치를 거절 · 나라를 두면 나라 전체로 퍼짐).
|
|
76
|
+
* 지역을 고르지 않은 나라는 나라 전체 그대로. areas = 나라 → 그 나라의 동네 반경 */
|
|
77
|
+
export function metaGeo(countries, areas) {
|
|
78
|
+
const cs = countries.map((c) => c.toUpperCase());
|
|
79
|
+
const local = cs.flatMap((c) => areas?.[c] || []);
|
|
80
|
+
if (!local.length)
|
|
81
|
+
return { countries: cs };
|
|
82
|
+
const whole = cs.filter((c) => !areas?.[c]?.length);
|
|
83
|
+
return { ...(whole.length ? { countries: whole } : {}), custom_locations: mergeOverlaps(local).map((a) => ({ latitude: Math.round(a.lat * 1e6) / 1e6, longitude: Math.round(a.lng * 1e6) / 1e6, radius: clampRadiusKm(a.radiusKm), distance_unit: 'kilometer' })), location_types: ['home', 'recent'] };
|
|
84
|
+
}
|
|
85
|
+
/** 시장 목록에서 나라별 동네 반경만 뽑는다(이 광고세트의 나라만) */
|
|
86
|
+
export function areasFor(markets, countries) {
|
|
87
|
+
const set = new Set(countries.map((c) => c.toUpperCase()));
|
|
88
|
+
const out = {};
|
|
89
|
+
for (const m of markets)
|
|
90
|
+
if (m.areas?.length && set.has(m.country.toUpperCase()))
|
|
91
|
+
out[m.country.toUpperCase()] = m.areas;
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/** 겹치는 원을 하나로 — 이웃한 동네 여러 곳(천천동·율전동…)은 반경이 겹치는데, 메타는 겹치는 위치를 거절할 수 있다. 겹치는 무리는 무리 전체를 덮는 원 하나로(구글은 겹쳐도 돼서 원래대로). */
|
|
95
|
+
export function mergeOverlaps(areas) {
|
|
96
|
+
const km = (a, b) => { const R = 6371, dLat = (b.lat - a.lat) * Math.PI / 180, dLng = (b.lng - a.lng) * Math.PI / 180; const h = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * Math.PI / 180) * Math.cos(b.lat * Math.PI / 180) * Math.sin(dLng / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(h)); };
|
|
97
|
+
let groups = areas.map((a) => [a]);
|
|
98
|
+
for (let changed = true; changed;) {
|
|
99
|
+
changed = false;
|
|
100
|
+
outer: for (let i = 0; i < groups.length; i++)
|
|
101
|
+
for (let j = i + 1; j < groups.length; j++)
|
|
102
|
+
if (groups[i].some((a) => groups[j].some((b) => km(a, b) < clampRadiusKm(a.radiusKm) + clampRadiusKm(b.radiusKm)))) {
|
|
103
|
+
groups[i] = [...groups[i], ...groups[j]];
|
|
104
|
+
groups.splice(j, 1);
|
|
105
|
+
changed = true;
|
|
106
|
+
break outer;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return groups.map((g) => {
|
|
110
|
+
if (g.length === 1)
|
|
111
|
+
return g[0];
|
|
112
|
+
const c = { lat: g.reduce((n, a) => n + a.lat, 0) / g.length, lng: g.reduce((n, a) => n + a.lng, 0) / g.length };
|
|
113
|
+
return { label: g.map((a) => a.label).join('+'), ...c, radiusKm: clampRadiusKm(Math.ceil(Math.max(...g.map((a) => km(a, c) + clampRadiusKm(a.radiusKm))))) };
|
|
114
|
+
});
|
|
115
|
+
}
|
package/dist/adapters/meta.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MetaCreds } from '../core/state.js';
|
|
2
|
-
import type { Placement } from '../core/state.js';
|
|
2
|
+
import type { Market, Placement } from '../core/state.js';
|
|
3
3
|
import { type AccountInfo, type BuildInput, type BuildResult, type CheckItem, type Connector, type InventoryRow, type MetricRow, type PolicyRow } from './types.js';
|
|
4
4
|
export declare class MetaConnector implements Connector {
|
|
5
5
|
private c;
|
|
@@ -26,6 +26,11 @@ export declare class MetaConnector implements Connector {
|
|
|
26
26
|
}[]>;
|
|
27
27
|
setStatus(placements: Placement[], status: 'ACTIVE' | 'PAUSED'): Promise<void>;
|
|
28
28
|
updateBudget(placements: Placement[], dailyBudgetMinor: number): Promise<void>;
|
|
29
|
+
/** 지역·종료일 교체 — 광고세트마다 지금 타겟을 읽어 위치(geo_locations)만 바꿔 다시 쓴다(연령·언어·Advantage+ 는 그대로) */
|
|
30
|
+
updateTargeting(placements: Placement[], t: {
|
|
31
|
+
markets: Market[];
|
|
32
|
+
endDate?: string | null;
|
|
33
|
+
}): Promise<void>;
|
|
29
34
|
metrics(placements: Placement[], range: {
|
|
30
35
|
since: string;
|
|
31
36
|
until: string;
|
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 { metaTargeting } from './meta-targeting.js';
|
|
5
|
+
import { areasFor, metaGeo, metaTargeting } from './meta-targeting.js';
|
|
6
6
|
import { abKey, langGroups, localizedCopy, MediumError, nameTag, utm } from './types.js';
|
|
7
7
|
const G = 'https://graph.facebook.com/v23.0';
|
|
8
8
|
const REQUIRED_SCOPES = ['ads_management', 'ads_read', 'business_management', 'pages_show_list', 'pages_read_engagement', 'pages_manage_ads'];
|
|
@@ -202,7 +202,7 @@ export class MetaConnector {
|
|
|
202
202
|
log(` meta 캠페인 ${found ? '재사용' : '생성'} ${id}`);
|
|
203
203
|
}
|
|
204
204
|
// 타겟: 언어 그룹별 — 디아스포라 locales · 대만/싱가포르 규제 카테고리 · 브리프 오디언스(meta-targeting.ts · 밈튜브 하네스 학습 2026-09-23)
|
|
205
|
-
const targetFor = (g) => metaTargeting({ countries: g.countries, lang: g.lang, audience: input.audience, strict: input.strictAudience });
|
|
205
|
+
const targetFor = (g) => metaTargeting({ countries: g.countries, lang: g.lang, audience: input.audience, strict: input.strictAudience, areas: areasFor(input.markets, g.countries) });
|
|
206
206
|
/** 광고세트 생성 — locales id 가 거절되면 locales 없이 1회 재시도(도달만 넓어짐 · 생성은 성공) */
|
|
207
207
|
const createAdset = async (body, tr) => {
|
|
208
208
|
const base = { ...body, targeting: tr.targeting, ...(tr.regionalCategories.length ? { regional_regulated_categories: tr.regionalCategories } : {}) };
|
|
@@ -346,6 +346,19 @@ export class MetaConnector {
|
|
|
346
346
|
for (const p of placements.filter((x) => x.medium === 'meta' && x.kind === 'campaign'))
|
|
347
347
|
await this.call('POST', p.externalId, { daily_budget: String(dailyBudgetMinor) });
|
|
348
348
|
}
|
|
349
|
+
/** 지역·종료일 교체 — 광고세트마다 지금 타겟을 읽어 위치(geo_locations)만 바꿔 다시 쓴다(연령·언어·Advantage+ 는 그대로) */
|
|
350
|
+
async updateTargeting(placements, t) {
|
|
351
|
+
for (const s of placements.filter((x) => x.medium === 'meta' && x.kind === 'adset')) {
|
|
352
|
+
const cur = await this.call('GET', s.externalId, { fields: 'targeting' });
|
|
353
|
+
// 이 광고세트가 맡은 나라 = 광고세트 이름의 「· KR+US ·」(build 가 붙임) · 못 읽으면 시장 전체
|
|
354
|
+
const named = (s.name.match(/ · ([A-Z]{2}(?:\+[A-Z]{2})*) · /)?.[1] || '').split('+').filter((c) => t.markets.some((m) => m.country.toUpperCase() === c));
|
|
355
|
+
const countries = named.length ? named : t.markets.map((m) => m.country.toUpperCase());
|
|
356
|
+
await this.call('POST', s.externalId, { targeting: { ...cur.targeting, geo_locations: metaGeo(countries, areasFor(t.markets, countries)) } });
|
|
357
|
+
}
|
|
358
|
+
if (t.endDate !== undefined)
|
|
359
|
+
for (const c of placements.filter((x) => x.medium === 'meta' && x.kind === 'campaign'))
|
|
360
|
+
await this.call('POST', c.externalId, { stop_time: t.endDate ? `${t.endDate}T23:59:00+0900` : 0 });
|
|
361
|
+
}
|
|
349
362
|
async metrics(placements, range) {
|
|
350
363
|
const camp = placements.find((p) => p.medium === 'meta' && p.kind === 'campaign');
|
|
351
364
|
if (!camp)
|
package/dist/adapters/mock.d.ts
CHANGED
|
@@ -19,6 +19,15 @@ export declare class MockConnector implements Connector {
|
|
|
19
19
|
}[]>;
|
|
20
20
|
setStatus(placements: Placement[], status: 'ACTIVE' | 'PAUSED'): Promise<void>;
|
|
21
21
|
updateBudget(_p: Placement[], b: number): Promise<void>;
|
|
22
|
+
updateTargeting(_p: Placement[], t: {
|
|
23
|
+
markets: {
|
|
24
|
+
country: string;
|
|
25
|
+
areas?: {
|
|
26
|
+
label: string;
|
|
27
|
+
}[];
|
|
28
|
+
}[];
|
|
29
|
+
endDate?: string | null;
|
|
30
|
+
}): Promise<void>;
|
|
22
31
|
metrics(placements: Placement[], range: {
|
|
23
32
|
since: string;
|
|
24
33
|
until: string;
|
package/dist/adapters/mock.js
CHANGED
|
@@ -67,7 +67,10 @@ export class MockConnector {
|
|
|
67
67
|
async setStatus(placements, status) { for (const p of placements.filter((x) => x.medium === this.medium))
|
|
68
68
|
p.status = status; this.rec(`status ${status}`); }
|
|
69
69
|
async updateBudget(_p, b) { this.rec(`budget ${b}`); }
|
|
70
|
+
async updateTargeting(_p, t) { this.rec(`targeting ${t.markets.map((m) => m.areas?.length ? m.areas.map((a) => a.label).join('+') : m.country).join(',')}${t.endDate !== undefined ? ` end=${t.endDate || 'none'}` : ''}`); }
|
|
70
71
|
async metrics(placements, range) {
|
|
72
|
+
if (process.env.ADPILOT_MOCK_NO_METRICS === '1')
|
|
73
|
+
return []; // 시험: 노출이 멈춘 광고 재현
|
|
71
74
|
const ads = placements.filter((p) => p.medium === this.medium && p.kind === 'ad');
|
|
72
75
|
const out = [];
|
|
73
76
|
const d0 = new Date(range.since);
|
|
@@ -76,6 +79,16 @@ export class MockConnector {
|
|
|
76
79
|
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 }); });
|
|
77
80
|
return out;
|
|
78
81
|
}
|
|
79
|
-
async policy(placements) {
|
|
82
|
+
async policy(placements) {
|
|
83
|
+
// 시험용 검수 상태 주입 — ADPILOT_MOCK_POLICY='{"google":{"status":"APPROVED_LIMITED · REVIEWED","issues":["FULLY_LIMITED TICKET_RESALE"]}}'
|
|
84
|
+
let o;
|
|
85
|
+
try {
|
|
86
|
+
o = JSON.parse(process.env.ADPILOT_MOCK_POLICY || '{}')[this.medium];
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
o = undefined;
|
|
90
|
+
}
|
|
91
|
+
return placements.filter((p) => p.medium === this.medium && p.kind === 'ad').map((p) => ({ id: p.externalId, name: p.name, status: o?.status || 'APPROVED', issues: o?.issues || [] }));
|
|
92
|
+
}
|
|
80
93
|
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); }
|
|
81
94
|
}
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -101,6 +101,11 @@ export interface Connector {
|
|
|
101
101
|
}): Promise<MetricRow[]>;
|
|
102
102
|
policy(placements: Placement[]): Promise<PolicyRow[]>;
|
|
103
103
|
remove?(placements: Placement[]): Promise<void>;
|
|
104
|
+
/** 이미 만든 광고의 지역(나라·동네 반경)·종료일을 바꾼다 — 다시 만들지 않고 캠페인/광고세트 타겟만 교체 */
|
|
105
|
+
updateTargeting?(placements: Placement[], t: {
|
|
106
|
+
markets: Market[];
|
|
107
|
+
endDate?: string | null;
|
|
108
|
+
}): Promise<void>;
|
|
104
109
|
}
|
|
105
110
|
export declare class MediumError extends Error {
|
|
106
111
|
medium: Medium;
|
|
@@ -76,17 +76,16 @@ const isHangul = (s) => /[가-힣]/.test(s);
|
|
|
76
76
|
const KO_ADVERB_FOR_MODIFIER = /[가-힣](에|에서|로|으로|부터|께|에게|한테|와|과|서|처럼|같이|보다)$/;
|
|
77
77
|
/** 자주 쓰는 2음절 관형형 용언(광고 문구) — 명사와 겹치지 않는 것만 */
|
|
78
78
|
const KO_MODIFIER_2 = new Set(['구운', '만든', '고른', '담은', '키운', '빚은', '캐낸', '뽑은', '볶은', '우린', '지은', '튀긴', '삶은', '말린', '재운', '절인', '쪄낸', '끓인', '내린', '갈아낸', '짜낸', '따낸', '건진', '골라낸', '추린', '모은', '익힌', '갓난', '길러낸', '빻은', '깎은', '썰은', '다진', '무친', '데친', '졸인', '지진', '부친', '섞은', '띄운', '숙성한', '엄선한', '수확한', '재배한', '직접', '갓']);
|
|
79
|
+
/** 1음절 관형형 용언(광고 문구에서 부사어 뒤에 오는 것) — 명사와 겹치는 글자(문·손·길·말·물·눈)는 넣지 않는다 */
|
|
80
|
+
const KO_MODIFIER_1 = new Set(['딴', '산', '쓴', '든', '본', '준', '큰', '낼', '쓸']);
|
|
79
81
|
/** 짧은 관형형 용언인가 — 1음절은 받침 ㄴ·ㄹ(딴·산·쓴·든·본·준·큰·낼·쓸 …)이고 관형사·수사·부사 결속 표에 없는 것 · 2음절은 표에 있는 것 */
|
|
80
82
|
function isShortModifier(w) {
|
|
81
83
|
const chars = [...w.replace(/[^가-힣]/g, '')];
|
|
82
84
|
if (!chars.length || chars.length !== [...w].length)
|
|
83
85
|
return false;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const fin = (w.codePointAt(0) - 0xac00) % 28;
|
|
88
|
-
return fin === 4 || fin === 8;
|
|
89
|
-
}
|
|
86
|
+
// 🔴 받침 ㄴ·ㄹ 이면 다 관형형이라고 보면 「문 앞까지」의 「문」·「손」·「길」 같은 명사가 앞 어절에 붙는다(2026-10-03 「하우스에서 문 / 앞까지 하루」) → 실제로 쓰는 1음절 관형형만 표로
|
|
87
|
+
if (chars.length === 1)
|
|
88
|
+
return KO_MODIFIER_1.has(w);
|
|
90
89
|
if (chars.length === 2)
|
|
91
90
|
return KO_MODIFIER_2.has(w);
|
|
92
91
|
return false;
|
|
@@ -226,7 +226,18 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
226
226
|
// 🔴 글 위주(배경 이미지 없음) 소재가 허전하다는 사장님 지적(2026-09-30 「이렇게 허전하면 텍스트 광고를 누가 하겠니」) → 글 위주 포스터는 장식 도형·혜택 뱃지·증거 칩으로 채운다
|
|
227
227
|
const rich = !bg && !banner && !short && !narrow && !googleLight && Math.min(w, hh) >= 600;
|
|
228
228
|
const richProofN = rich && !widePoster ? (hh / w > 1.5 ? 3 : 2) : 3;
|
|
229
|
-
|
|
229
|
+
// 🔴 같은 사실을 뱃지·알약·본문에 되풀이하지 않는다(2026-10-03 「무료배송」이 뱃지·알약·본문에 세 번) — 헤드라인 > 뱃지 > 알약 > 본문 순으로 한 번만 보여 준다
|
|
230
|
+
const flat = (t) => String(t || '').replace(/[\s·,.]/g, '').toLowerCase();
|
|
231
|
+
const headFlat = flat(head), bodyFlat = flat(body);
|
|
232
|
+
const badgePool = [brief.offer, ...(brief.usp || []), ...(brief.proofPoints || [])].map((x) => String(x || '').replace(/\s+/g, ' ').trim()).filter((x) => x && wlen(x) <= 18 && !policyIssues(x).length);
|
|
233
|
+
const badge = rich && !widePoster ? (badgePool.find((x) => /%|할인|무료|혜택|증정|off|free/i.test(x) && !headFlat.includes(flat(x))) || '') : '';
|
|
234
|
+
const badgeFlat = flat(badge);
|
|
235
|
+
const grams = (t) => { const a = []; for (let i = 0; i + 1 < t.length; i++)
|
|
236
|
+
a.push(t.slice(i, i + 2)); return a; };
|
|
237
|
+
const seenText = headFlat + '|' + bodyFlat + '|' + badgeFlat;
|
|
238
|
+
const novel = (x) => { const f = flat(x); if (!f || headFlat.includes(f) || bodyFlat.includes(f) || (badgeFlat && (badgeFlat.includes(f) || f.includes(badgeFlat))))
|
|
239
|
+
return false; const g = grams(f).filter((q) => !/\d/.test(q)); return !(g.length >= 4 && g.filter((q) => seenText.includes(q)).length / g.length >= 0.5); };
|
|
240
|
+
let proofs = widePoster || rich ? [...(brief.proofPoints || []), ...(brief.usp || [])].map((x) => String(x || '').replace(/\s+/g, ' ').trim()).filter((x) => x && wlen(x) <= 36 && !policyIssues(x).length).filter((x, i, a) => a.indexOf(x) === i).filter(novel).slice(0, richProofN) : [];
|
|
230
241
|
const cjk = /[\u3000-\u9fff\uac00-\ud7af]/.test(body);
|
|
231
242
|
let bodyText = banner ? cutWords(body, 70) : widePoster ? cutWords(body, cjk ? 60 : 110) : googleLight ? cutWords(body, 48) : body;
|
|
232
243
|
if (widePoster) {
|
|
@@ -257,8 +268,23 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
257
268
|
if (total() > avail)
|
|
258
269
|
proofs = [];
|
|
259
270
|
}
|
|
260
|
-
|
|
261
|
-
|
|
271
|
+
if (rich && !widePoster && proofs.length) {
|
|
272
|
+
const colW = w - pad * 2;
|
|
273
|
+
const lns = (t, fs, cap = 9) => Math.min(cap, Math.max(1, breakLines(t, fs, colW).length));
|
|
274
|
+
const chipRows = () => { let rows = 0, x = 0; for (const t of proofs) {
|
|
275
|
+
const cw = textW(t, chipFs) + chipFs * 2.4;
|
|
276
|
+
if (!rows || x + cw > colW) {
|
|
277
|
+
rows++;
|
|
278
|
+
x = cw;
|
|
279
|
+
}
|
|
280
|
+
else
|
|
281
|
+
x += cw + chipFs * 0.45;
|
|
282
|
+
} return rows; };
|
|
283
|
+
const total = () => headFs * 0.9 + (badge ? bodyFs * 1.7 : 0) + lns(head, headFs) * headFs * 1.12 + lns(bodyText, bodyFs, 4) * bodyFs * 1.45 + chipRows() * chipFs * 1.9 + ctaFs * 2.2 + headFs * 0.3 + pad * 0.5 * 4;
|
|
284
|
+
const avail = hh - pad * 2;
|
|
285
|
+
while (proofs.length && total() > avail)
|
|
286
|
+
proofs = proofs.slice(0, -1);
|
|
287
|
+
}
|
|
262
288
|
const chips = proofs.length ? h('div', { display: 'flex', flexWrap: 'wrap', gap: Math.round(chipFs * 0.45) }, proofs.map((t) => h('div', { display: 'flex', alignItems: 'center', gap: Math.round(chipFs * 0.4), fontSize: chipFs, fontWeight: 700, color: fg, backgroundColor: dark ? 'rgba(255,255,255,.16)' : 'rgba(255,255,255,.78)', borderRadius: 999, padding: `${Math.round(chipFs * 0.28)}px ${Math.round(chipFs * 0.7)}px`, whiteSpace: 'nowrap' }, [h('div', { width: Math.round(chipFs * 0.42), height: Math.round(chipFs * 0.42), borderRadius: 999, backgroundColor: primary }), h('div', { whiteSpace: 'nowrap' }, t)]))) : null;
|
|
263
289
|
const children = [];
|
|
264
290
|
if (bg)
|
|
@@ -270,8 +296,8 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
270
296
|
children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, backgroundImage: `radial-gradient(circle at 82% 16%, ${tint(0.34)} 0%, ${tint(0)} 52%)` }, ''));
|
|
271
297
|
children.push(h('div', { position: 'absolute', right: -Math.round(base * 0.2), top: -Math.round(base * 0.16), width: Math.round(base * 0.85), height: Math.round(base * 0.85), borderRadius: 9999, backgroundColor: tint(0.16) }, ''));
|
|
272
298
|
children.push(h('div', { position: 'absolute', left: -Math.round(base * 0.28), bottom: -Math.round(base * 0.3), width: Math.round(base * 0.8), height: Math.round(base * 0.8), borderRadius: 9999, backgroundColor: tint(0.12) }, ''));
|
|
273
|
-
children.push(h('div', { position: 'absolute', right: Math.round(base * 0.
|
|
274
|
-
children.push(h('div', { position: 'absolute', right: Math.round(base * 0.
|
|
299
|
+
children.push(h('div', { position: 'absolute', right: Math.round(base * 0.07), top: Math.round(base * 0.2), width: Math.round(base * 0.15), height: Math.round(base * 0.15), borderRadius: 9999, border: `${Math.max(2, Math.round(base * 0.004))}px solid ${tint(0.4)}` }, ''));
|
|
300
|
+
children.push(h('div', { position: 'absolute', right: Math.round(base * 0.095), top: Math.round(base * 0.225), width: Math.round(base * 0.1), height: Math.round(base * 0.1), borderRadius: 9999, backgroundColor: tint(0.22) }, ''));
|
|
275
301
|
}
|
|
276
302
|
const brandRow = h('div', { display: 'flex', alignItems: 'center', gap: Math.round(pad * 0.3) }, [
|
|
277
303
|
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),
|
|
@@ -289,7 +315,7 @@ export function tree(size, concept, brief, bg, logo, ctaText) {
|
|
|
289
315
|
children.push(h('div', { position: 'absolute', top: 0, left: 0, width: w, height: hh, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: pad }, [
|
|
290
316
|
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', {}, '')]),
|
|
291
317
|
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 }, [
|
|
292
|
-
...(rich && !isWide ? [h('div', { display: 'flex', alignItems: 'center', gap: Math.round(pad * 0.3) }, [h('div', { width: Math.round(base * 0.075), height: Math.max(6, Math.round(base * 0.008)), borderRadius: 9, backgroundColor: primary }, ''), ...(badge ? [h('div', { display: 'flex', fontSize: Math.round(bodyFs * 0.82), fontWeight: 700, color: onColor(primary), backgroundColor: primary, borderRadius: 999, padding: `${Math.round(bodyFs * 0.25)}px ${Math.round(bodyFs * 0.7)}px` }, badge)] : [])])] : []),
|
|
318
|
+
...(rich && !isWide ? [h('div', { display: 'flex', alignItems: 'center', gap: Math.round(pad * 0.3) }, [...(badge ? [] : [h('div', { width: Math.round(base * 0.075), height: Math.max(6, Math.round(base * 0.008)), borderRadius: 9, backgroundColor: primary }, '')]), ...(badge ? [h('div', { display: 'flex', fontSize: Math.round(bodyFs * 0.82), fontWeight: 700, color: onColor(primary), backgroundColor: primary, borderRadius: 999, padding: `${Math.round(bodyFs * 0.25)}px ${Math.round(bodyFs * 0.7)}px` }, badge)] : [])])] : []),
|
|
293
319
|
linesEl(head, { fontSize: headFs, fontWeight: 800, color: fg, lineHeight: 1.12, letterSpacing: -1.5 }, isWide ? Math.round(w * 0.72) : w - pad * 2),
|
|
294
320
|
// 본문도 같은 규칙으로 균형 있게(「… 분들께 / 추천해요.」 한 어절 고아 방지 · 최대 4줄 · 배너는 폭이 좁아 렌더러 줄바꿈)
|
|
295
321
|
(banner && bodyFs < 14) || bodyFs === 0 ? h('div', {}, '') : banner ? wordsEl(bodyText, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }) : linesEl(bodyText, { fontSize: bodyFs, fontWeight: 500, color: sub, lineHeight: 1.45 }, isWide ? Math.round(w * 0.72) : w - pad * 2, 'left', 4),
|
package/dist/core/report.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export type ReportInput = {
|
|
|
13
13
|
status: string;
|
|
14
14
|
issues: string[];
|
|
15
15
|
}[]>;
|
|
16
|
+
/** 사장님이 하셔야 할 일(호출자가 매체 상태로 정해 넘김) — 보고서 끝에 그대로 붙는다. LLM 이 지어내지 않게 */
|
|
17
|
+
todo?: string[];
|
|
18
|
+
/** 매체 검수 상태를 사람 말로(호출자가 판정) — LLM 은 이 문장만 옮긴다(「노출이 덜 퍼졌을 수도」 같은 추측 금지) */
|
|
19
|
+
reviewNotes?: string[];
|
|
16
20
|
};
|
|
17
21
|
export declare function tableOf(rows: MetricRow[]): {
|
|
18
22
|
concept: string;
|
package/dist/core/report.js
CHANGED
|
@@ -19,14 +19,20 @@ export async function makeReport(inp) {
|
|
|
19
19
|
totals.conv += r.conversions;
|
|
20
20
|
}
|
|
21
21
|
const goalWord = { traffic: '방문', lead: '문의', signup: '가입', purchase: '구매' }[inp.project.budget.goal];
|
|
22
|
-
const
|
|
22
|
+
const todo = inp.todo?.filter(Boolean) || [];
|
|
23
|
+
const facts = { period: inp.range, conceptNames: Object.fromEntries((inp.project.concepts || []).map((c) => [c.key, `${c.name} — ${c.headlines[0]}`])), company: inp.project.brief?.company, goal: goalWord, totals, byMedium: tables, decisions: inp.decisions.slice(-20).map((d) => ({ ...d, who: { user: '사장님이 직접', rule: '자동 규칙', llm: 'AI(사장님 지시)', system: '시스템' }[d.by] || d.by })), review: inp.reviewNotes?.length ? inp.reviewNotes : inp.policy, plan: inp.project.plan?.explain, status: inp.project.status, hasTodo: todo.length > 0, autoRules: '자동으로 하는 일은 이것뿐: 한 시간마다 숫자 확인 · 시작 48시간 뒤부터 노출 2,000 이상 광고끼리 비교해 1.8배 나쁜 광고 끄기 · 하루 예산 ±20% · 잔액 이틀치 미만이면 멈춤. 문구 수정·재검수 신청·매체 인증 신청은 자동으로 하지 않는다.' };
|
|
23
24
|
let text;
|
|
24
25
|
if (aiAvailable()) {
|
|
25
|
-
text = await complete({ maxTokens: 1500, system: `너는 광고 운영 담당자다. 광고를 모르는 사장님께 이번 기간 결과를 한국어 존댓말(해요체)로 6~10문장 보고한다. 광고 용어(CTR·CPA·노출·DCO)는 쓰지 말고 사람 말(본 사람·클릭한 사람·1명 데려오는 데 든 돈)로 바꾼다. 숫자는 주어진 것만(만들지 않는다). 구성: ① 얼마 써서 무슨 결과 ② 어느 매체·어느 방향(컨셉)이 잘 됐고 무엇을 줄였는지 ③ 자동 조치가 있었으면 한 줄
|
|
26
|
+
text = await complete({ maxTokens: 1500, system: `너는 ADYou(광고 대행 서비스)의 광고 운영 담당자다. 「저희」는 ADYou 이고 광고주는 「사장님」이다 — 광고주 회사를 「저희」라고 부르지 않는다. 광고를 모르는 사장님께 이번 기간 결과를 한국어 존댓말(해요체)로 6~10문장 보고한다. 광고 용어(CTR·CPA·노출·DCO)는 쓰지 말고 사람 말(본 사람·클릭한 사람·1명 데려오는 데 든 돈)로 바꾼다. 숫자는 주어진 것만(만들지 않는다). 구성: ① 얼마 써서 무슨 결과 ② 어느 매체·어느 방향(컨셉)이 잘 됐고 무엇을 줄였는지 ③ 자동 조치가 있었으면 한 줄(decisions 의 who 를 그대로 — 사장님이 직접 한 일을 「담당자」라고 하지 않는다) ④ 검수 문제는 review 문장을 그대로 옮긴다(추측·완곡 금지 · 광고가 막혔으면 「막혀서 보이지 않는다」고 분명히) ⑤ 다음 주 한 줄 — 자동으로 하는 일은 autoRules 를 나열하지 말고 짧게(예: 「성과는 계속 한 시간마다 확인해요」), 그 밖의 일(문구 수정·재검수·인증)을 우리가 하겠다고 약속하지 않는다. hasTodo 이면 「사장님이 하실 일은 아래에 정리했어요」로 끝내고 할 일 내용·제목·구분선(---)은 쓰지 않는다(코드가 붙인다). 지표가 없으면 「아직 노출 전·검수 중」을 있는 그대로.`, user: JSON.stringify(facts) });
|
|
26
27
|
}
|
|
27
28
|
else {
|
|
28
29
|
text = [`${inp.range.since}~${inp.range.until} 동안 ${fmtKrw(totals.spend)} 을 써서 ${totals.imp.toLocaleString()}명에게 보였고 ${totals.clicks.toLocaleString()}명이 클릭했어요${totals.conv ? `. ${goalWord} ${totals.conv}건 (1건당 ${fmtKrw(totals.spend / totals.conv)})` : ''}.`, ...media.map((m) => { const best = [...tables[m]].sort((a, b) => b.ctr - a.ctr)[0]; return best ? `${m === 'meta' ? '메타' : '구글'}에선 「${best.concept}」 방향이 가장 반응이 좋았어요(클릭률 ${(best.ctr * 100).toFixed(2)}%).` : `${m}: 아직 지표가 없어요.`; })].join(' ');
|
|
29
30
|
}
|
|
31
|
+
// LLM 이 할 일을 따로 또 쓰면(구분선·「하실 일」 제목) 잘라 낸다 — 할 일은 코드가 정한 목록 하나만
|
|
32
|
+
if (todo.length)
|
|
33
|
+
text = text.split(/\n\s*-{3,}\s*\n|\n\s*\**\s*(?:\[?지금 )?(?:사장님이 )?하실 일\s*\]?\**\s*[::]/)[0];
|
|
34
|
+
if (todo.length)
|
|
35
|
+
text = `${text.trim()}\n\n[지금 하실 일]\n${todo.map((t, i) => `${i + 1}. ${t}`).join('\n')}`;
|
|
30
36
|
const md = [`# ${inp.project.brief?.company || inp.project.slug} 광고 보고 (${inp.range.since} ~ ${inp.range.until})`, '', text, '', ...media.flatMap((m) => [`## ${m === 'meta' ? 'Meta' : 'Google'}`, '| 방향 | 쓴 돈 | 본 사람 | 클릭 | 클릭률 | ' + goalWord + ' | 1건당 |', '|---|---|---|---|---|---|---|', ...tables[m].map((r) => `| ${r.concept} | ${fmtKrw(r.spendKrw)} | ${r.impressions.toLocaleString()} | ${r.clicks.toLocaleString()} | ${(r.ctr * 100).toFixed(2)}% | ${r.conversions} | ${r.cpaKrw ? fmtKrw(r.cpaKrw) : '-'} |`), ''])].join('\n');
|
|
31
37
|
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<');
|
|
32
38
|
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>${esc(inp.project.brief?.company || inp.project.slug)} 광고 보고</title><style>body{font-family:Pretendard,'Apple SD Gothic Neo',sans-serif;max-width:820px;margin:40px auto;padding:0 20px;color:#0b1526;line-height:1.6}h1{font-size:24px}table{border-collapse:collapse;width:100%;font-size:14px}td,th{border-bottom:1px solid #e3e8ef;padding:8px 10px;text-align:right}th:first-child,td:first-child{text-align:left}.lead{font-size:17px;background:#f4f7fb;border-radius:12px;padding:18px 20px}</style></head><body><h1>${esc(inp.project.brief?.company || inp.project.slug)} 광고 보고 <small style="color:#6b7a90;font-size:14px">${inp.range.since} ~ ${inp.range.until}</small></h1><div class="lead">${esc(text).replace(/\n/g, '<br>')}</div>${media.map((m) => `<h2>${m === 'meta' ? 'Meta' : 'Google'}</h2><table><tr><th>방향</th><th>쓴 돈</th><th>본 사람</th><th>클릭</th><th>클릭률</th><th>${goalWord}</th><th>1건당</th></tr>${tables[m].map((r) => `<tr><td>${esc(String(r.concept))}</td><td>${fmtKrw(r.spendKrw)}</td><td>${r.impressions.toLocaleString()}</td><td>${r.clicks.toLocaleString()}</td><td>${(r.ctr * 100).toFixed(2)}%</td><td>${r.conversions}</td><td>${r.cpaKrw ? fmtKrw(r.cpaKrw) : '-'}</td></tr>`).join('')}</table>`).join('')}</body></html>`;
|
package/dist/core/state.d.ts
CHANGED
|
@@ -39,10 +39,21 @@ export type Config = {
|
|
|
39
39
|
lastProject?: string;
|
|
40
40
|
};
|
|
41
41
|
export type Goal = 'traffic' | 'lead' | 'signup' | 'purchase';
|
|
42
|
+
/** 지역 좁히기 — 나라 안의 동네·구·시를 중심점 + 반경(km)으로. 구글 proximity · 메타 custom_locations 에 같은 값이 그대로 간다(매체별 지역 사전 대신 좌표 한 표현) */
|
|
43
|
+
export type Area = {
|
|
44
|
+
label: string;
|
|
45
|
+
lat: number;
|
|
46
|
+
lng: number;
|
|
47
|
+
radiusKm: number;
|
|
48
|
+
};
|
|
49
|
+
/** areas 가 있으면 그 나라는 나라 전체가 아니라 그 지역(들)에만 — 「사는 사람(최근 있었던 사람)」 기준 */
|
|
42
50
|
export type Market = {
|
|
43
51
|
country: string;
|
|
44
52
|
language: string;
|
|
53
|
+
areas?: Area[];
|
|
45
54
|
};
|
|
55
|
+
/** 매체 반경 허용 범위(구글 proximity · 메타 custom_locations 둘 다 1~80km 안) */
|
|
56
|
+
export declare const clampRadiusKm: (km: number) => number;
|
|
46
57
|
export type Brief = {
|
|
47
58
|
url: string;
|
|
48
59
|
company: string;
|
package/dist/core/state.js
CHANGED
|
@@ -9,6 +9,8 @@ import os from 'node:os';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
export const HOME = process.env.ADPILOT_HOME || path.join(os.homedir(), '.adpilot');
|
|
11
11
|
export const PROJECTS = path.join(HOME, 'projects');
|
|
12
|
+
/** 매체 반경 허용 범위(구글 proximity · 메타 custom_locations 둘 다 1~80km 안) */
|
|
13
|
+
export const clampRadiusKm = (km) => Math.max(1, Math.min(80, Math.round((Number(km) || 3) * 10) / 10));
|
|
12
14
|
function ensureDir(p) { fs.mkdirSync(p, { recursive: true, mode: 0o700 }); }
|
|
13
15
|
function readJson(file, fallback) {
|
|
14
16
|
try {
|
package/package.json
CHANGED