adyou 0.7.3 → 0.7.5

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.
@@ -1,7 +1,12 @@
1
- import type { GoogleCreds, Placement } from '../core/state.js';
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;
@@ -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
- for (const m of input.markets) {
203
- const geo = GEO[m.country.toUpperCase()];
204
- if (geo)
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)
@@ -307,7 +340,7 @@ export class GoogleConnector {
307
340
  if (!ads.length)
308
341
  return [];
309
342
  const rows = await this.search(`SELECT ad_group_ad.resource_name, ad_group_ad.ad.name, ad_group_ad.policy_summary.approval_status, ad_group_ad.policy_summary.review_status, ad_group_ad.policy_summary.policy_topic_entries FROM ad_group_ad WHERE ad_group_ad.resource_name IN (${ads.map((a) => `'${a.externalId}'`).join(',')})`);
310
- return rows.map((r) => ({ id: r.adGroupAd.resourceName, name: r.adGroupAd.ad.name || '', status: `${r.adGroupAd.policySummary?.approvalStatus || ''} · ${r.adGroupAd.policySummary?.reviewStatus || ''}`, issues: (r.adGroupAd.policySummary?.policyTopicEntries || []).map((p) => `${p.type || ''} ${p.topic || ''}`.trim()) }));
343
+ return rows.map((r) => ({ id: r.adGroupAd.resourceName, name: r.adGroupAd.ad.name || '', status: `${r.adGroupAd.policySummary?.approvalStatus || ''} · ${r.adGroupAd.policySummary?.reviewStatus || ''}`, issues: (r.adGroupAd.policySummary?.policyTopicEntries || []).map((p) => `${p.type || ''} ${p.topic || ''}`.trim()), evidence: [...new Set((r.adGroupAd.policySummary?.policyTopicEntries || []).flatMap((p) => (p.evidences || []).flatMap((e) => [...(e.textList?.texts || []), ...(e.destinationTextList?.destinationTexts || []), ...(e.websiteList?.websites || [])])))].slice(0, 20) }));
311
344
  }
312
345
  async remove(placements) {
313
346
  for (const p of placements.filter((x) => x.medium === 'google' && x.kind === 'campaign'))
@@ -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: { countries }, age_min: 18, targeting_automation: { advantage_audience: 1 } };
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
+ }
@@ -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;
@@ -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)
@@ -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;
@@ -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,17 @@ 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) { return placements.filter((p) => p.medium === this.medium && p.kind === 'ad').map((p) => ({ id: p.externalId, name: p.name, status: 'APPROVED', issues: [] })); }
82
+ async policy(placements) {
83
+ // 시험용 검수 상태 주입 — ADPILOT_MOCK_POLICY='{"google":{"status":"APPROVED_LIMITED · REVIEWED","issues":["FULLY_LIMITED TICKET_RESALE"]}}'
84
+ // skip: 이 글자가 이름에 든 광고는 주입하지 않음(예: "_f" → AI 가 고친 광고는 승인) · evidence: 문제 삼은 문구
85
+ let o;
86
+ try {
87
+ o = JSON.parse(process.env.ADPILOT_MOCK_POLICY || '{}')[this.medium];
88
+ }
89
+ catch {
90
+ o = undefined;
91
+ }
92
+ return placements.filter((p) => p.medium === this.medium && p.kind === 'ad').map((p) => { const hit = o && !(o.skip && `${p.name} ${p.concept || ''}`.includes(o.skip)); return { id: p.externalId, name: p.name, status: hit ? o.status || 'APPROVED' : 'APPROVED', issues: hit ? o.issues || [] : [], ...(hit && o.evidence ? { evidence: o.evidence } : {}) }; });
93
+ }
80
94
  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
95
  }
@@ -66,7 +66,8 @@ export type PolicyRow = {
66
66
  id: string;
67
67
  name: string;
68
68
  status: string;
69
- issues: string[];
69
+ issues: string[]; /** 매체가 문제 삼은 실제 문구(구글 policy evidence) — AI 자동 수정이 정확히 그 표현만 고치게 */
70
+ evidence?: string[];
70
71
  };
71
72
  export type InventoryRow = {
72
73
  level: 'campaign' | 'adset' | 'ad';
@@ -101,6 +102,11 @@ export interface Connector {
101
102
  }): Promise<MetricRow[]>;
102
103
  policy(placements: Placement[]): Promise<PolicyRow[]>;
103
104
  remove?(placements: Placement[]): Promise<void>;
105
+ /** 이미 만든 광고의 지역(나라·동네 반경)·종료일을 바꾼다 — 다시 만들지 않고 캠페인/광고세트 타겟만 교체 */
106
+ updateTargeting?(placements: Placement[], t: {
107
+ markets: Market[];
108
+ endDate?: string | null;
109
+ }): Promise<void>;
104
110
  }
105
111
  export declare class MediumError extends Error {
106
112
  medium: Medium;
@@ -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;
@@ -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 facts = { period: inp.range, conceptNames: Object.fromEntries((inp.project.concepts || []).map((c) => [c.key, `${c.name} — ${c.headlines[0]}`])), company: inp.project.brief?.company, goal: goalWord, totals, byMedium: tables, decisions: inp.decisions.slice(-20), policy: inp.policy, plan: inp.project.plan?.explain, status: inp.project.status };
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명 데려오는 데 든 돈)로 바꾼다. 숫자는 주어진 것만(만들지 않는다). 구성: ① 얼마 써서 무슨 결과 ② 어느 매체·어느 방향(컨셉)이 잘 됐고 무엇을 줄였는지 ③ 자동 조치가 있었으면 한 줄 ④ 검수·정책 문제가 있으면 한 줄 ⑤ 다음 주 계획 한 줄. 지표가 없으면 「아직 노출 전·검수 중」을 있는 그대로.`, user: JSON.stringify(facts) });
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, '&amp;').replace(/</g, '&lt;');
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>`;
@@ -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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adyou",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "ADYou — 대행사 없이, 당신이 직접. 사이트 주소 하나로 광고 소재·매체 등록·자동 운영·보고까지: AI 광고 자율주행 CLI + MCP 서버(Meta·Google · 생성은 항상 PAUSED · 승인 뒤 시작)",
5
5
  "type": "module",
6
6
  "license": "MIT",