adyou 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/dist/adapters/google.d.ts +41 -0
- package/dist/adapters/google.js +301 -0
- package/dist/adapters/index.d.ts +6 -0
- package/dist/adapters/index.js +17 -0
- package/dist/adapters/meta.d.ts +33 -0
- package/dist/adapters/meta.js +275 -0
- package/dist/adapters/mock.d.ts +28 -0
- package/dist/adapters/mock.js +67 -0
- package/dist/adapters/types.d.ts +100 -0
- package/dist/adapters/types.js +20 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +315 -0
- package/dist/core/brief.d.ts +12 -0
- package/dist/core/brief.js +71 -0
- package/dist/core/check.d.ts +11 -0
- package/dist/core/check.js +55 -0
- package/dist/core/creatives/copy.d.ts +14 -0
- package/dist/core/creatives/copy.js +52 -0
- package/dist/core/creatives/images.d.ts +9 -0
- package/dist/core/creatives/images.js +89 -0
- package/dist/core/creatives/render.d.ts +25 -0
- package/dist/core/creatives/render.js +157 -0
- package/dist/core/creatives/specs.d.ts +40 -0
- package/dist/core/creatives/specs.js +51 -0
- package/dist/core/llm.d.ts +23 -0
- package/dist/core/llm.js +77 -0
- package/dist/core/money.d.ts +9 -0
- package/dist/core/money.js +34 -0
- package/dist/core/ops.d.ts +121 -0
- package/dist/core/ops.js +407 -0
- package/dist/core/optimize.d.ts +27 -0
- package/dist/core/optimize.js +56 -0
- package/dist/core/plan.d.ts +22 -0
- package/dist/core/plan.js +59 -0
- package/dist/core/report.d.ts +30 -0
- package/dist/core/report.js +34 -0
- package/dist/core/site.d.ts +30 -0
- package/dist/core/site.js +80 -0
- package/dist/core/state.d.ts +162 -0
- package/dist/core/state.js +110 -0
- package/dist/core/tracking.d.ts +19 -0
- package/dist/core/tracking.js +45 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +19 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +130 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# adyou — ADYou 코어 · CLI · MCP
|
|
2
|
+
|
|
3
|
+
**ADYou** — 대행사 없이, 당신이 직접. 광고를 24시간 집행하고 통제하세요.
|
|
4
|
+
https://adyou.ai
|
|
5
|
+
|
|
6
|
+
`adyou`는 ADYou 웹(https://adyou.ai)이 쓰는 **코어 엔진**이면서, 개발자·마케터가 터미널이나 Claude Code·Cursor 같은 MCP 클라이언트에서 같은 일을 직접 할 수 있는 **CLI + MCP 서버**입니다.
|
|
7
|
+
|
|
8
|
+
사이트 주소와 한 달 예산만 주면 — 사이트를 읽고 브리프를 만들고 · 소재(이미지 12규격 × 3방향 + 문구)를 만들고 · Meta(페이스북·인스타그램)·Google에 광고를 **전부 대기 상태(PAUSED · 노출 안 됨)** 로 등록하고 · 미리보기를 보여 주고 · 당신이 승인하면 시작하고 · 한 시간마다 숫자를 보고 정해진 규칙대로 조정하고 · 매주 쉬운 말로 보고합니다.
|
|
9
|
+
|
|
10
|
+
- 광고 계정은 **당신 계정**(직광고). 매체비는 어디도 거치지 않고 대행 수수료가 없습니다.
|
|
11
|
+
- 광고 시작 전엔 1원도 나가지 않습니다. 예산은 계정 통화 최소 단위로 환산하고 월 상한을 코드로 재확인합니다.
|
|
12
|
+
- 자동 운영은 정확히 다섯 가지만: 시작 뒤 48시간 무변경 · 노출 2,000 이상 광고끼리만 비교 · 꼴등이 1등보다 1.8배 나쁘면 끔(매체마다 1개 남김) · 매체 하루 예산 ±20%(월 상한 안) · 잔액 이틀치 미만이면 멈춤. 모든 조치는 이유와 함께 기록됩니다.
|
|
13
|
+
|
|
14
|
+
## 설치
|
|
15
|
+
```bash
|
|
16
|
+
npm i -g adyou
|
|
17
|
+
adyou init # 매체 연결 가이드 + 검사기
|
|
18
|
+
```
|
|
19
|
+
Node 20 이상. 브라우저·파이썬 불필요(이미지 합성은 satori + resvg). `adpilot` 명령도 같은 바이너리를 가리킵니다(옛 이름 호환).
|
|
20
|
+
|
|
21
|
+
## 5분 흐름
|
|
22
|
+
```bash
|
|
23
|
+
adyou connect meta --token <시스템 사용자 토큰> --account act_… --page <페이지 id> [--pixel <픽셀 id>]
|
|
24
|
+
adyou connect google --sa <서비스계정.json> --customer <10자리 고객 ID>
|
|
25
|
+
adyou check # 토큰 스코프·계정 통화·픽셀 트래픽 권한·구글 액세스 수준·랜딩 태그·UTM 보존 실측
|
|
26
|
+
adyou brief https://example.com --budget 300만 --goal signup [--markets KR,US]
|
|
27
|
+
adyou creatives # 방향 3 × 이미지 12규격 + 문구 + 규격 검사 → 갤러리 HTML
|
|
28
|
+
adyou plan # 예산 분할을 쉬운 말로
|
|
29
|
+
adyou build [--validate] # 전부 PAUSED 생성 · 멱등 · 부분 실패 재실행 안전
|
|
30
|
+
adyou preview --open # 메타 실제 미리보기 · 구글 근사
|
|
31
|
+
adyou launch # 승인 → ACTIVE(광고 시작)
|
|
32
|
+
adyou status | optimize [--apply] | report --open | change "예산 200만원으로, 미국 빼"
|
|
33
|
+
adyou audit # 기존 캠페인 전수 조회·진단(인수)
|
|
34
|
+
adyou track install | verify # 픽셀·gtag·GA4 스니펫 발급 · 랜딩 검증
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## MCP
|
|
38
|
+
```json
|
|
39
|
+
{ "mcpServers": { "adyou": { "command": "npx", "args": ["-y", "adyou", "mcp"] } } }
|
|
40
|
+
```
|
|
41
|
+
Claude Code·Cursor·bizcoder에서 「우리 사이트 광고 미국에 월 200만원으로 시작해」 한 문장이 곧 브리프 → 소재 → 계획 → 생성(PAUSED) → 미리보기. 시작(`adyou_launch`)은 사용자가 명시적으로 승인했을 때만 호출됩니다. 툴 이름은 `adyou_*`.
|
|
42
|
+
|
|
43
|
+
## 웹 SaaS — https://adyou.ai
|
|
44
|
+
가입해 사이트 주소·예산만 넣으면 같은 엔진이 웹에서 돌아갑니다(광고 계정 없이 ADYou 계정으로 집행 · 잔액 충전 · 수수료 매체비의 10%, 또는 내 계정 연결 · 수수료 0). 웹은 이 패키지를 npm으로만 소비합니다. 체험 계정으로 미리 볼 수 있습니다: https://adyou.ai/demo · 도움말 https://adyou.ai/help
|
|
45
|
+
|
|
46
|
+
## 매체 연결(사람이 해야 하는 것)
|
|
47
|
+
- **Meta**: 비즈니스 포트폴리오 · 광고 계정 · 결제수단 · 페이지 · 앱(Marketing API · 라이브 모드) · 시스템 사용자(관리자) 토큰(ads_management · ads_read · business_management · pages_show_list · pages_read_engagement · pages_manage_ads) · 픽셀 트래픽 권한 도메인.
|
|
48
|
+
- **Google**: 광고 계정(통화·결제수단) · GCP 프로젝트에 Google Ads API 사용 설정 · 서비스 계정 JSON · 광고 계정에 서비스 계정 초대(표준) · Google Ads API 액세스 수준 Explorer. 개발자 토큰은 더 이상 필요 없습니다.
|
|
49
|
+
`adyou check`가 전부 실측해서 어느 화면에서 무엇을 누를지 알려 줍니다.
|
|
50
|
+
|
|
51
|
+
## 안전장치
|
|
52
|
+
생성은 항상 PAUSED · ACTIVE는 `launch` 뒤에만 · 돈 환산은 `core/money.ts` 한 곳 · 월 상한 `withinCap` · 모든 mutate는 `decisions.jsonl`(웹은 `decisions` 테이블)에 남습니다. 매체 함정(RDA 한글 2자 · DCO 광고 1개 · Advantage+ age_max 불가 · end_date_time 23:59:59 · validate_only 본문 · 픽셀 트래픽 권한 · 앱 라이브 모드)은 어댑터 주석에 박혀 있습니다.
|
|
53
|
+
|
|
54
|
+
## 로컬 상태
|
|
55
|
+
`~/.adpilot/`(자격 0600 · 프로젝트별 `project.json` · `decisions.jsonl`). 폴더 이름은 호환을 위해 그대로입니다.
|
|
56
|
+
|
|
57
|
+
## 개발
|
|
58
|
+
```bash
|
|
59
|
+
npm i && npm run build && npm test # unit + mock e2e
|
|
60
|
+
npm run test:live # 읽기 전용 라이브 검사
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 라이선스 · 문의
|
|
64
|
+
AI3 Inc. · support@adyou.ai
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { GoogleCreds, Placement } from '../core/state.js';
|
|
2
|
+
import { type AccountInfo, type BuildInput, type BuildResult, type CheckItem, type Connector, type InventoryRow, type MetricRow, type PolicyRow } from './types.js';
|
|
3
|
+
export declare const GEO: Record<string, number>;
|
|
4
|
+
export declare const LANG: Record<string, number>;
|
|
5
|
+
export declare class GoogleConnector implements Connector {
|
|
6
|
+
private c;
|
|
7
|
+
medium: "google";
|
|
8
|
+
private token;
|
|
9
|
+
constructor(c: GoogleCreds);
|
|
10
|
+
private sa;
|
|
11
|
+
private accessToken;
|
|
12
|
+
private call;
|
|
13
|
+
private wrap;
|
|
14
|
+
private search;
|
|
15
|
+
listAccessible(): Promise<string[]>;
|
|
16
|
+
account(): Promise<AccountInfo>;
|
|
17
|
+
check(): Promise<CheckItem[]>;
|
|
18
|
+
inventory(): Promise<InventoryRow[]>;
|
|
19
|
+
private uploadImage;
|
|
20
|
+
private esc;
|
|
21
|
+
/** 전환 액션 — 목표별 이름으로 재사용 · gtag 스니펫(AW-id · 라벨) 반환 */
|
|
22
|
+
ensureConversionAction(slug: string, goal: string, defaultValue?: number): Promise<{
|
|
23
|
+
resourceName: string;
|
|
24
|
+
gtagId: string;
|
|
25
|
+
label: string;
|
|
26
|
+
}>;
|
|
27
|
+
build(input: BuildInput): Promise<BuildResult>;
|
|
28
|
+
preview(placements: Placement[]): Promise<{
|
|
29
|
+
concept: string | undefined;
|
|
30
|
+
name: string;
|
|
31
|
+
html: string;
|
|
32
|
+
}[]>;
|
|
33
|
+
setStatus(placements: Placement[], status: 'ACTIVE' | 'PAUSED'): Promise<void>;
|
|
34
|
+
updateBudget(placements: Placement[], dailyBudgetMinor: number): Promise<void>;
|
|
35
|
+
metrics(placements: Placement[], range: {
|
|
36
|
+
since: string;
|
|
37
|
+
until: string;
|
|
38
|
+
}): Promise<MetricRow[]>;
|
|
39
|
+
policy(placements: Placement[]): Promise<PolicyRow[]>;
|
|
40
|
+
remove(placements: Placement[]): Promise<void>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Google Ads — REST(v25) 직접 호출 · 서비스 계정 JWT(개발자 토큰 폐지 이후 규격 · Cloud 프로젝트 액세스 수준이 한도). 오늘 실측 함정 반영:
|
|
2
|
+
// validate_only는 요청 본문 · end_date_time은 23:59:59 · RDA 글자 수는 한글 2자 · CTA는 영문 고정 문구 · 클릭 최대화=target_spend · 로고 1:1/4:1 · 이름 기준 멱등 재실행
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { fit, GOOGLE_CTA, GOOGLE_LIMITS, wlen } from '../core/creatives/specs.js';
|
|
6
|
+
import { MediumError, nameTag, utm } from './types.js';
|
|
7
|
+
const API = 'https://googleads.googleapis.com/v25';
|
|
8
|
+
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
|
+
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 };
|
|
10
|
+
export class GoogleConnector {
|
|
11
|
+
c;
|
|
12
|
+
medium = 'google';
|
|
13
|
+
token = null;
|
|
14
|
+
constructor(c) {
|
|
15
|
+
this.c = c;
|
|
16
|
+
}
|
|
17
|
+
sa() { const p = this.c.serviceAccountJson; const raw = p.trim().startsWith('{') ? p : fs.readFileSync(p, 'utf8'); return JSON.parse(raw); }
|
|
18
|
+
async accessToken() {
|
|
19
|
+
if (this.token && this.token.exp > Date.now() + 60_000)
|
|
20
|
+
return this.token.value;
|
|
21
|
+
const sa = this.sa();
|
|
22
|
+
const now = Math.floor(Date.now() / 1000);
|
|
23
|
+
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
24
|
+
const unsigned = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64({ iss: sa.client_email, scope: 'https://www.googleapis.com/auth/adwords', aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600 })}`;
|
|
25
|
+
const sig = crypto.sign('RSA-SHA256', Buffer.from(unsigned), sa.private_key).toString('base64url');
|
|
26
|
+
const res = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: `${unsigned}.${sig}` }), signal: AbortSignal.timeout(20_000) });
|
|
27
|
+
const d = (await res.json());
|
|
28
|
+
if (!d.access_token)
|
|
29
|
+
throw new MediumError('google', `서비스 계정 토큰 발급 실패: ${d.error_description || d.error || res.status}`, '서비스 계정 JSON 키가 유효한지, GCP 프로젝트에 Google Ads API가 켜져 있는지 확인해 주세요.');
|
|
30
|
+
this.token = { value: d.access_token, exp: Date.now() + (d.expires_in || 3600) * 1000 };
|
|
31
|
+
return d.access_token;
|
|
32
|
+
}
|
|
33
|
+
async call(path, body, cid = this.c.customerId) {
|
|
34
|
+
const headers = { authorization: `Bearer ${await this.accessToken()}`, 'content-type': 'application/json' };
|
|
35
|
+
if (this.c.loginCustomerId)
|
|
36
|
+
headers['login-customer-id'] = this.c.loginCustomerId;
|
|
37
|
+
const res = await fetch(`${API}/customers/${cid}/${path}`, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000) });
|
|
38
|
+
const data = (await res.json().catch(() => ({})));
|
|
39
|
+
if (!res.ok || data.error)
|
|
40
|
+
throw this.wrap(data.error, path);
|
|
41
|
+
return data;
|
|
42
|
+
}
|
|
43
|
+
wrap(e, path) {
|
|
44
|
+
const errs = (e?.details || []).flatMap((d) => d.errors || []);
|
|
45
|
+
const lines = errs.map((x) => `${Object.values(x.errorCode || {})[0] || ''} ${x.message || ''}${x.location?.fieldPathElements ? ` @${x.location.fieldPathElements.map((f) => f.fieldName).join('.')}` : ''}`.trim());
|
|
46
|
+
const msg = lines.join(' | ') || e?.message || '알 수 없는 오류';
|
|
47
|
+
let hint;
|
|
48
|
+
if (/test accounts|NOT_APPROVED|DEVELOPER_TOKEN/i.test(msg))
|
|
49
|
+
hint = 'Cloud 프로젝트가 테스트 계정 전용(Test) 수준이에요. Cloud Console › Google Ads API 개요 › 액세스 수준 → Explorer 신청(자동 승인 · 프로젝트 소유 계정으로 로그인).';
|
|
50
|
+
else if (/USER_PERMISSION_DENIED|CUSTOMER_NOT_FOUND|not associated/i.test(msg))
|
|
51
|
+
hint = '서비스 계정이 이 광고 계정에 초대되지 않았어요. ads.google.com › 관리자 › 액세스 및 보안 › 사용자 추가 → 서비스 계정 이메일(표준 이상).';
|
|
52
|
+
else if (/PERMISSION_DENIED|403/.test(String(e?.status || e?.code)))
|
|
53
|
+
hint = 'GCP 프로젝트에 Google Ads API가 켜져 있는지, 서비스 계정 키가 맞는지 확인해 주세요.';
|
|
54
|
+
else if (/TOO_LONG|LINE_TOO_LONG|LENGTH/i.test(msg))
|
|
55
|
+
hint = '구글은 한글·전각 1자를 2자로 세요(헤드라인 30·설명 90). adpilot 검사기가 놓친 문장이 있으면 알려 주세요.';
|
|
56
|
+
else if (/UNAUTHENTICATED|401/.test(String(e?.status || e?.code)))
|
|
57
|
+
hint = '서비스 계정 토큰이 거부됐어요. 키 파일과 프로젝트를 확인해 주세요.';
|
|
58
|
+
return new MediumError('google', `Google ${path}: ${msg}`, hint, e);
|
|
59
|
+
}
|
|
60
|
+
async search(query, cid = this.c.customerId) {
|
|
61
|
+
const out = [];
|
|
62
|
+
let pageToken;
|
|
63
|
+
do {
|
|
64
|
+
const d = await this.call('googleAds:search', { query, pageToken }, cid);
|
|
65
|
+
out.push(...(d.results || []));
|
|
66
|
+
pageToken = d.nextPageToken;
|
|
67
|
+
} while (pageToken);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
async listAccessible() {
|
|
71
|
+
const headers = { authorization: `Bearer ${await this.accessToken()}` };
|
|
72
|
+
const res = await fetch(`${API}/customers:listAccessibleCustomers`, { headers, signal: AbortSignal.timeout(20_000) });
|
|
73
|
+
const d = (await res.json());
|
|
74
|
+
if (d.error)
|
|
75
|
+
throw this.wrap(d.error, 'listAccessibleCustomers');
|
|
76
|
+
return (d.resourceNames || []).map((r) => r.split('/')[1]);
|
|
77
|
+
}
|
|
78
|
+
async account() {
|
|
79
|
+
const rows = await this.search('SELECT customer.id, customer.descriptive_name, customer.currency_code, customer.time_zone, customer.status, customer.manager FROM customer');
|
|
80
|
+
const c = rows[0]?.customer;
|
|
81
|
+
if (!c)
|
|
82
|
+
throw new MediumError('google', '고객 정보를 읽지 못했어요');
|
|
83
|
+
return { id: c.id, name: c.descriptiveName || c.id, currency: c.currencyCode, timezone: c.timeZone, status: c.status, extra: { manager: c.manager } };
|
|
84
|
+
}
|
|
85
|
+
async check() {
|
|
86
|
+
const items = [];
|
|
87
|
+
let accessible = [];
|
|
88
|
+
try {
|
|
89
|
+
accessible = await this.listAccessible();
|
|
90
|
+
items.push({ key: 'google.sa', ok: accessible.length > 0, title: '서비스 계정 인증·초대', detail: accessible.length ? `접근 가능 고객 ID: ${accessible.join(', ')}` : '인증은 통과했지만 초대된 광고 계정이 없어요', fix: accessible.length ? undefined : `ads.google.com › 관리자 › 액세스 및 보안 › 사용자 + → ${this.sa().client_email} (표준 이상)` });
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
items.push({ key: 'google.sa', ok: false, title: '서비스 계정 인증', detail: e instanceof Error ? e.message : String(e), fix: e.hint });
|
|
94
|
+
return items;
|
|
95
|
+
}
|
|
96
|
+
items.push({ key: 'google.customer', ok: accessible.includes(this.c.customerId) || !!this.c.loginCustomerId, title: `고객 ID ${this.c.customerId} 초대됨`, detail: accessible.includes(this.c.customerId) ? '초대 확인' : this.c.loginCustomerId ? `MCC ${this.c.loginCustomerId} 경유` : '이 고객 ID는 접근 목록에 없어요', fix: accessible.includes(this.c.customerId) || this.c.loginCustomerId ? undefined : '고객 ID를 다시 확인하거나 서비스 계정을 이 계정에 초대해 주세요.' });
|
|
97
|
+
try {
|
|
98
|
+
const a = await this.account();
|
|
99
|
+
items.push({ key: 'google.access', ok: true, title: 'Cloud 프로젝트 액세스 수준(프로덕션 호출 가능)', detail: `${a.name} · ${a.currency} · ${a.timezone}${a.extra?.manager ? ' · 관리자 계정(MCC)' : ''}` });
|
|
100
|
+
items.push({ key: 'google.currency', ok: true, title: '계정 통화(예산 단위)', detail: `${a.currency} — 예산은 micros(×1,000,000)로 보냅니다` });
|
|
101
|
+
if (a.extra?.manager)
|
|
102
|
+
items.push({ key: 'google.manager', ok: false, title: '관리자 계정에는 캠페인을 만들 수 없어요', detail: '하위 광고 계정 ID를 customerId로, 이 MCC를 loginCustomerId로 지정', fix: '`adpilot connect google --customer <하위 계정> --login-customer <MCC>`' });
|
|
103
|
+
const conv = await this.search("SELECT conversion_action.id, conversion_action.name, conversion_action.status, conversion_action.type FROM conversion_action WHERE conversion_action.status = 'ENABLED' AND conversion_action.type = 'WEBPAGE'");
|
|
104
|
+
items.push({ key: 'google.conversion', ok: conv.length ? true : null, title: '웹 전환 액션', detail: conv.length ? conv.map((r) => r.conversionAction.name).slice(0, 5).join(', ') : '없음 — build 때 목표에 맞는 전환 액션을 만들어요' });
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
items.push({ key: 'google.access', ok: false, title: 'Cloud 프로젝트 액세스 수준', detail: e instanceof Error ? e.message : String(e), fix: e.hint, link: 'https://console.cloud.google.com/apis/api/googleads.googleapis.com/overview' });
|
|
108
|
+
}
|
|
109
|
+
return items;
|
|
110
|
+
}
|
|
111
|
+
async inventory() {
|
|
112
|
+
const rows = [];
|
|
113
|
+
const camps = await this.search("SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type, campaign_budget.amount_micros, metrics.cost_micros, metrics.impressions, metrics.clicks FROM campaign WHERE campaign.status != 'REMOVED' AND segments.date DURING LAST_30_DAYS");
|
|
114
|
+
const agg = new Map();
|
|
115
|
+
for (const r of camps) {
|
|
116
|
+
const c = r.campaign;
|
|
117
|
+
const row = agg.get(c.id) || { level: 'campaign', id: c.id, name: c.name, status: c.status, objective: c.advertisingChannelType, dailyBudgetMinor: r.campaignBudget?.amountMicros ? Number(r.campaignBudget.amountMicros) / 1e6 : undefined, spend30d: 0, impressions30d: 0, clicks30d: 0 };
|
|
118
|
+
row.spend30d += Number(r.metrics?.costMicros || 0) / 1e6;
|
|
119
|
+
row.impressions30d += Number(r.metrics?.impressions || 0);
|
|
120
|
+
row.clicks30d += Number(r.metrics?.clicks || 0);
|
|
121
|
+
agg.set(c.id, row);
|
|
122
|
+
}
|
|
123
|
+
rows.push(...agg.values());
|
|
124
|
+
const ads = await this.search("SELECT campaign.id, ad_group_ad.resource_name, ad_group_ad.status, ad_group_ad.ad.id, 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.status != 'REMOVED'");
|
|
125
|
+
for (const r of ads)
|
|
126
|
+
rows.push({ level: 'ad', id: r.adGroupAd.ad.id, name: r.adGroupAd.ad.name || r.adGroupAd.ad.id, status: r.adGroupAd.status, effectiveStatus: `${r.adGroupAd.policySummary?.approvalStatus || ''}/${r.adGroupAd.policySummary?.reviewStatus || ''}`, parent: r.campaign.id, issues: (r.adGroupAd.policySummary?.policyTopicEntries || []).map((p) => `${p.type || ''} ${p.topic || ''}`.trim()) });
|
|
127
|
+
return rows;
|
|
128
|
+
}
|
|
129
|
+
async uploadImage(file, name) {
|
|
130
|
+
const found = await this.search(`SELECT asset.resource_name, asset.name FROM asset WHERE asset.type = 'IMAGE' AND asset.name = '${name.replace(/'/g, "\\'")}'`);
|
|
131
|
+
if (found[0])
|
|
132
|
+
return found[0].asset.resourceName;
|
|
133
|
+
const d = await this.call('assets:mutate', { operations: [{ create: { name, type: 'IMAGE', imageAsset: { data: fs.readFileSync(file).toString('base64') } } }] });
|
|
134
|
+
return d.results[0].resourceName;
|
|
135
|
+
}
|
|
136
|
+
esc(s) { return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
|
|
137
|
+
/** 전환 액션 — 목표별 이름으로 재사용 · gtag 스니펫(AW-id · 라벨) 반환 */
|
|
138
|
+
async ensureConversionAction(slug, goal, defaultValue = 0) {
|
|
139
|
+
const name = `${nameTag(slug)} ${goal === 'purchase' ? '구매 완료' : goal === 'lead' ? '문의 완료' : goal === 'signup' ? '가입 완료' : '핵심 페이지 조회'}`;
|
|
140
|
+
let rn = (await this.search(`SELECT conversion_action.resource_name FROM conversion_action WHERE conversion_action.name = '${this.esc(name)}' AND conversion_action.status != 'REMOVED'`))[0]?.conversionAction.resourceName;
|
|
141
|
+
if (!rn) {
|
|
142
|
+
const category = goal === 'purchase' ? 'PURCHASE' : goal === 'lead' ? 'SUBMIT_LEAD_FORM' : goal === 'signup' ? 'SIGNUP' : 'PAGE_VIEW';
|
|
143
|
+
const d = await this.call('conversionActions:mutate', { operations: [{ create: { name, type: 'WEBPAGE', category, status: 'ENABLED', countingType: goal === 'purchase' ? 'MANY_PER_CLICK' : 'ONE_PER_CLICK', valueSettings: { defaultValue, alwaysUseDefaultValue: false }, clickThroughLookbackWindowDays: 30 } }] });
|
|
144
|
+
rn = d.results[0].resourceName;
|
|
145
|
+
}
|
|
146
|
+
const rows = await this.search(`SELECT conversion_action.tag_snippets FROM conversion_action WHERE conversion_action.resource_name = '${rn}'`);
|
|
147
|
+
const snip = (rows[0]?.conversionAction.tagSnippets || []).find((s) => s.type === 'WEBPAGE' && s.pageFormat === 'HTML');
|
|
148
|
+
const gtagId = snip?.globalSiteTag?.match(/AW-\d+/)?.[0] || '';
|
|
149
|
+
const label = snip?.eventSnippet?.match(/AW-\d+\/([A-Za-z0-9_-]+)/)?.[1] || '';
|
|
150
|
+
return { resourceName: rn, gtagId, label };
|
|
151
|
+
}
|
|
152
|
+
async build(input) {
|
|
153
|
+
const log = input.log || (() => { });
|
|
154
|
+
const warnings = [];
|
|
155
|
+
const placements = [...input.existing];
|
|
156
|
+
const has = (kind, concept) => placements.find((p) => p.medium === 'google' && p.kind === kind && (concept ? p.concept === concept : true));
|
|
157
|
+
const tag = nameTag(input.slug);
|
|
158
|
+
const V = !!input.validateOnly;
|
|
159
|
+
const acc = await this.account();
|
|
160
|
+
// 1) 예산(공유 안 함) — micros
|
|
161
|
+
const budgetName = `${tag} 일 예산`;
|
|
162
|
+
const amountMicros = String(Math.round(input.dailyBudgetMinor * (acc.currency === 'KRW' || acc.currency === 'JPY' ? 1 : 0.01) * 1_000_000));
|
|
163
|
+
const campName = `${tag} ${input.brief.company} · 디스플레이 · ${input.goal}`;
|
|
164
|
+
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` } : {}) });
|
|
165
|
+
if (V) {
|
|
166
|
+
// 🔴 validate_only는 요청 본문 · 예산 없이 캠페인만 검증하면 REQUIRED 오류 → 임시 id(-1)로 예산+캠페인을 한 번에 검증
|
|
167
|
+
const tmp = `customers/${this.c.customerId}/campaignBudgets/-1`;
|
|
168
|
+
await this.call('googleAds:mutate', { mutateOperations: [{ campaignBudgetOperation: { create: { resourceName: tmp, name: budgetName, amountMicros, deliveryMethod: 'STANDARD', explicitlyShared: false } } }, { campaignOperation: { create: campaignCreate(tmp) } }], validateOnly: true });
|
|
169
|
+
log(` google 예산 ${amountMicros} micros + 캠페인 구조 서버 검증 통과`);
|
|
170
|
+
return { placements, warnings: [...warnings, '구글 예산·캠페인 구조 서버 검증 통과(생성 없음)'] };
|
|
171
|
+
}
|
|
172
|
+
let budgetRn = (await this.search(`SELECT campaign_budget.resource_name FROM campaign_budget WHERE campaign_budget.name = '${this.esc(budgetName)}' AND campaign_budget.status = 'ENABLED'`))[0]?.campaignBudget.resourceName;
|
|
173
|
+
if (!budgetRn) {
|
|
174
|
+
const d = await this.call('campaignBudgets:mutate', { operations: [{ create: { name: budgetName, amountMicros, deliveryMethod: 'STANDARD', explicitlyShared: false } }] });
|
|
175
|
+
budgetRn = d.results[0].resourceName;
|
|
176
|
+
log(` google 예산 생성 ${amountMicros} micros`);
|
|
177
|
+
}
|
|
178
|
+
// 2) 캠페인 — 디스플레이 · 클릭 최대화(target_spend) · 전환 쌓이면 tCPA로
|
|
179
|
+
let camp = has('campaign');
|
|
180
|
+
if (!camp) {
|
|
181
|
+
const found = (await this.search(`SELECT campaign.resource_name, campaign.id FROM campaign WHERE campaign.name = '${this.esc(campName)}' AND campaign.status != 'REMOVED'`))[0]?.campaign;
|
|
182
|
+
let rn = found?.resourceName;
|
|
183
|
+
if (!rn) {
|
|
184
|
+
const d = await this.call('campaigns:mutate', { operations: [{ create: campaignCreate(budgetRn) }] });
|
|
185
|
+
rn = d.results[0].resourceName;
|
|
186
|
+
const ops = [];
|
|
187
|
+
for (const m of input.markets) {
|
|
188
|
+
const geo = GEO[m.country.toUpperCase()];
|
|
189
|
+
if (geo)
|
|
190
|
+
ops.push({ create: { campaign: rn, location: { geoTargetConstant: `geoTargetConstants/${geo}` } } });
|
|
191
|
+
else
|
|
192
|
+
warnings.push(`구글 지역 상수 미등록: ${m.country}`);
|
|
193
|
+
}
|
|
194
|
+
const langs = [...new Set(input.markets.map((m) => LANG[m.language] || LANG[m.language.split('-')[0]]).filter(Boolean))];
|
|
195
|
+
for (const l of langs)
|
|
196
|
+
ops.push({ create: { campaign: rn, language: { languageConstant: `languageConstants/${l}` } } });
|
|
197
|
+
if (ops.length)
|
|
198
|
+
await this.call('campaignCriteria:mutate', { operations: ops });
|
|
199
|
+
}
|
|
200
|
+
camp = { medium: 'google', kind: 'campaign', externalId: rn, name: campName, status: 'PAUSED', createdAt: new Date().toISOString() };
|
|
201
|
+
placements.push(camp);
|
|
202
|
+
log(` google 캠페인 ${found ? '재사용' : '생성'} ${rn}`);
|
|
203
|
+
}
|
|
204
|
+
// 3) 광고그룹
|
|
205
|
+
let ag = has('adgroup');
|
|
206
|
+
if (!ag) {
|
|
207
|
+
const agName = `${tag} ${input.markets.map((m) => m.country).join('+')} · 관심사 자동`;
|
|
208
|
+
let rn = (await this.search(`SELECT ad_group.resource_name FROM ad_group WHERE ad_group.name = '${this.esc(agName)}' AND ad_group.campaign = '${camp.externalId}' AND ad_group.status != 'REMOVED'`))[0]?.adGroup.resourceName;
|
|
209
|
+
if (!rn)
|
|
210
|
+
rn = (await this.call('adGroups:mutate', { operations: [{ create: { name: agName, campaign: camp.externalId, status: 'ENABLED', type: 'DISPLAY_STANDARD' } }] })).results[0].resourceName;
|
|
211
|
+
ag = { medium: 'google', kind: 'adgroup', externalId: rn, name: agName, status: 'ENABLED', createdAt: new Date().toISOString() };
|
|
212
|
+
placements.push(ag);
|
|
213
|
+
log(` google 광고그룹 ${rn}`);
|
|
214
|
+
}
|
|
215
|
+
// 4) 로고 자산(1:1 · 4:1 — 렌더러가 만든 파일) · 컨셉별 RDA
|
|
216
|
+
const logoSq = input.assets.find((a) => a.concept === '_logo' && a.w === a.h);
|
|
217
|
+
const logoWide = input.assets.find((a) => a.concept === '_logo' && a.w === a.h * 4);
|
|
218
|
+
const logoSqRn = logoSq ? await this.uploadImage(logoSq.file, `${tag} 로고 1:1`) : null;
|
|
219
|
+
const logoWideRn = logoWide ? await this.uploadImage(logoWide.file, `${tag} 로고 4:1`) : null;
|
|
220
|
+
const existingAds = new Map((await this.search(`SELECT ad_group_ad.resource_name, ad_group_ad.ad.name FROM ad_group_ad WHERE ad_group_ad.ad_group = '${ag.externalId}' AND ad_group_ad.status != 'REMOVED'`)).map((r) => [r.adGroupAd.ad.name || '', r.adGroupAd.resourceName]));
|
|
221
|
+
const business = fit(input.brief.company, GOOGLE_LIMITS.businessName);
|
|
222
|
+
for (const c of input.concepts) {
|
|
223
|
+
if (has('ad', c.key))
|
|
224
|
+
continue;
|
|
225
|
+
const adName = `${tag} ${c.key}`;
|
|
226
|
+
if (existingAds.has(adName)) {
|
|
227
|
+
placements.push({ medium: 'google', kind: 'ad', concept: c.key, externalId: existingAds.get(adName), name: adName, status: 'PAUSED', createdAt: new Date().toISOString() });
|
|
228
|
+
log(` google 광고 ${c.key} 재사용`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const land = input.assets.find((a) => a.concept === c.key && a.w === 1200 && a.h === 628);
|
|
232
|
+
const sq = input.assets.find((a) => a.concept === c.key && a.w === 1200 && a.h === 1200);
|
|
233
|
+
if (!land || !sq) {
|
|
234
|
+
warnings.push(`${c.key}: 구글용 1200x628·1200x1200 소재가 없어 건너뜀`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const landRn = await this.uploadImage(land.file, `${tag} ${c.key} 1.91:1`);
|
|
238
|
+
const sqRn = await this.uploadImage(sq.file, `${tag} ${c.key} 1:1`);
|
|
239
|
+
const headlines = c.headlines.map((h) => fit(h, GOOGLE_LIMITS.headline)).filter((h) => wlen(h) > 0).slice(0, 5);
|
|
240
|
+
const descriptions = c.descriptions.map((d) => fit(d, GOOGLE_LIMITS.description)).filter(Boolean).slice(0, 5);
|
|
241
|
+
const longHeadline = fit(c.bodies[0] || c.headlines[0], GOOGLE_LIMITS.longHeadline);
|
|
242
|
+
const ctaText = GOOGLE_CTA.includes(c.cta) ? c.cta : { LEARN_MORE: 'Learn More', SIGN_UP: 'Sign Up', SHOP_NOW: 'Shop Now', CONTACT_US: 'Contact Us', DOWNLOAD: 'Download', GET_OFFER: 'See More', GET_QUOTE: 'Get Quote', SUBSCRIBE: 'Subscribe', APPLY_NOW: 'Apply Now', ORDER_NOW: 'Shop Now', BUY_NOW: 'Shop Now', BOOK_TRAVEL: 'Book Now' }[c.cta] || 'Learn More';
|
|
243
|
+
const rda = { headlines: headlines.map((t) => ({ text: t })), longHeadline: { text: longHeadline }, descriptions: descriptions.map((t) => ({ text: t })), businessName: business, marketingImages: [{ asset: landRn }], squareMarketingImages: [{ asset: sqRn }], callToActionText: ctaText, mainColor: input.brief.palette.primary, accentColor: input.brief.palette.dark, allowFlexibleColor: false, formatSetting: 'ALL_FORMATS' };
|
|
244
|
+
if (logoSqRn)
|
|
245
|
+
rda.squareLogoImages = [{ asset: logoSqRn }];
|
|
246
|
+
if (logoWideRn)
|
|
247
|
+
rda.logoImages = [{ asset: logoWideRn }];
|
|
248
|
+
const d = await this.call('adGroupAds:mutate', { operations: [{ create: { adGroup: ag.externalId, status: 'PAUSED', ad: { name: adName, finalUrls: [utm(input.landingUrl, 'google', input.utmCampaign, c.key)], responsiveDisplayAd: rda } } }] });
|
|
249
|
+
placements.push({ medium: 'google', kind: 'ad', concept: c.key, externalId: d.results[0].resourceName, name: adName, status: 'PAUSED', createdAt: new Date().toISOString() });
|
|
250
|
+
log(` google RDA ${c.key} 생성`);
|
|
251
|
+
}
|
|
252
|
+
return { placements, warnings };
|
|
253
|
+
}
|
|
254
|
+
async preview(placements) {
|
|
255
|
+
// 구글은 미리보기 API가 없다 → 우리가 만든 소재와 카피로 RDA 모양을 근사해 보여 준다(호출자가 로컬 자산으로 HTML 구성)
|
|
256
|
+
return placements.filter((p) => p.medium === 'google' && p.kind === 'ad').map((p) => ({ concept: p.concept, name: `Google 디스플레이 · ${p.concept}`, html: `<div class="gpreview" data-concept="${p.concept}"></div>` }));
|
|
257
|
+
}
|
|
258
|
+
async setStatus(placements, status) {
|
|
259
|
+
const s = status === 'ACTIVE' ? 'ENABLED' : 'PAUSED';
|
|
260
|
+
for (const p of placements.filter((x) => x.medium === 'google' && x.kind === 'campaign')) {
|
|
261
|
+
await this.call('campaigns:mutate', { operations: [{ update: { resourceName: p.externalId, status: s }, updateMask: 'status' }] });
|
|
262
|
+
p.status = s;
|
|
263
|
+
}
|
|
264
|
+
const ads = placements.filter((x) => x.medium === 'google' && x.kind === 'ad');
|
|
265
|
+
if (ads.length) {
|
|
266
|
+
await this.call('adGroupAds:mutate', { operations: ads.map((p) => ({ update: { resourceName: p.externalId, status: s }, updateMask: 'status' })) });
|
|
267
|
+
for (const p of ads)
|
|
268
|
+
p.status = s;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async updateBudget(placements, dailyBudgetMinor) {
|
|
272
|
+
const camp = placements.find((x) => x.medium === 'google' && x.kind === 'campaign');
|
|
273
|
+
if (!camp)
|
|
274
|
+
return;
|
|
275
|
+
const acc = await this.account();
|
|
276
|
+
const rows = await this.search(`SELECT campaign.campaign_budget FROM campaign WHERE campaign.resource_name = '${camp.externalId}'`);
|
|
277
|
+
const rn = rows[0]?.campaign.campaignBudget;
|
|
278
|
+
if (!rn)
|
|
279
|
+
return;
|
|
280
|
+
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' }] });
|
|
281
|
+
}
|
|
282
|
+
async metrics(placements, range) {
|
|
283
|
+
const camp = placements.find((p) => p.medium === 'google' && p.kind === 'campaign');
|
|
284
|
+
if (!camp)
|
|
285
|
+
return [];
|
|
286
|
+
const acc = await this.account();
|
|
287
|
+
const rows = await this.search(`SELECT segments.date, ad_group_ad.resource_name, ad_group_ad.ad.id, ad_group_ad.ad.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM ad_group_ad WHERE campaign.resource_name = '${camp.externalId}' AND segments.date BETWEEN '${range.since}' AND '${range.until}'`);
|
|
288
|
+
return rows.map((r) => ({ date: r.segments.date, level: 'ad', id: r.adGroupAd.resourceName, name: r.adGroupAd.ad.name || r.adGroupAd.ad.id, concept: placements.find((p) => p.externalId === r.adGroupAd.resourceName)?.concept, spend: Number(r.metrics.costMicros || 0) / 1e6, impressions: Number(r.metrics.impressions || 0), clicks: Number(r.metrics.clicks || 0), conversions: Number(r.metrics.conversions || 0), currency: acc.currency }));
|
|
289
|
+
}
|
|
290
|
+
async policy(placements) {
|
|
291
|
+
const ads = placements.filter((p) => p.medium === 'google' && p.kind === 'ad');
|
|
292
|
+
if (!ads.length)
|
|
293
|
+
return [];
|
|
294
|
+
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(',')})`);
|
|
295
|
+
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()) }));
|
|
296
|
+
}
|
|
297
|
+
async remove(placements) {
|
|
298
|
+
for (const p of placements.filter((x) => x.medium === 'google' && x.kind === 'campaign'))
|
|
299
|
+
await this.call('campaigns:mutate', { operations: [{ remove: p.externalId }] });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Connector, Medium } from './types.js';
|
|
2
|
+
export declare function isMock(): boolean;
|
|
3
|
+
/** 자격이 있는 매체만 커넥터를 돌려준다. 없으면 null(호출자가 안내). */
|
|
4
|
+
export declare function connector(medium: Medium): Connector | null;
|
|
5
|
+
export declare function connectedMedia(): Medium[];
|
|
6
|
+
export type { Connector, Medium } from './types.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { googleCreds, metaCreds } from '../core/state.js';
|
|
2
|
+
import { GoogleConnector } from './google.js';
|
|
3
|
+
import { MetaConnector } from './meta.js';
|
|
4
|
+
import { MockConnector } from './mock.js';
|
|
5
|
+
export function isMock() { return process.env.ADPILOT_MOCK === '1'; }
|
|
6
|
+
/** 자격이 있는 매체만 커넥터를 돌려준다. 없으면 null(호출자가 안내). */
|
|
7
|
+
export function connector(medium) {
|
|
8
|
+
if (isMock())
|
|
9
|
+
return new MockConnector(medium);
|
|
10
|
+
if (medium === 'meta') {
|
|
11
|
+
const c = metaCreds();
|
|
12
|
+
return c ? new MetaConnector(c) : null;
|
|
13
|
+
}
|
|
14
|
+
const g = googleCreds();
|
|
15
|
+
return g ? new GoogleConnector(g) : null;
|
|
16
|
+
}
|
|
17
|
+
export function connectedMedia() { return ['meta', 'google'].filter((m) => connector(m) !== null); }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { MetaCreds } from '../core/state.js';
|
|
2
|
+
import type { Placement } from '../core/state.js';
|
|
3
|
+
import { type AccountInfo, type BuildInput, type BuildResult, type CheckItem, type Connector, type InventoryRow, type MetricRow, type PolicyRow } from './types.js';
|
|
4
|
+
export declare class MetaConnector implements Connector {
|
|
5
|
+
private c;
|
|
6
|
+
medium: "meta";
|
|
7
|
+
constructor(c: MetaCreds);
|
|
8
|
+
private call;
|
|
9
|
+
private wrap;
|
|
10
|
+
account(): Promise<AccountInfo>;
|
|
11
|
+
check(landingUrl?: string): Promise<CheckItem[]>;
|
|
12
|
+
/** 픽셀 트래픽 권한 — connect.facebook.net/signals/config/<pixel>?domain=<host> 에 blockReason:"traffic_permissions" 가 있으면 이벤트가 조용히 버려진다 */
|
|
13
|
+
pixelTrafficPermission(landingUrl: string): Promise<CheckItem>;
|
|
14
|
+
/** 프로젝트 전용 픽셀 — 이름으로 재사용 · 없으면 생성(새 픽셀은 트래픽 권한 제한이 없어 어느 도메인 이벤트든 받는다) */
|
|
15
|
+
ensurePixel(name: string): Promise<string>;
|
|
16
|
+
inventory(): Promise<InventoryRow[]>;
|
|
17
|
+
private findByName;
|
|
18
|
+
private uploadImage;
|
|
19
|
+
build(input: BuildInput): Promise<BuildResult>;
|
|
20
|
+
preview(placements: Placement[]): Promise<{
|
|
21
|
+
concept?: string;
|
|
22
|
+
name: string;
|
|
23
|
+
html: string;
|
|
24
|
+
}[]>;
|
|
25
|
+
setStatus(placements: Placement[], status: 'ACTIVE' | 'PAUSED'): Promise<void>;
|
|
26
|
+
updateBudget(placements: Placement[], dailyBudgetMinor: number): Promise<void>;
|
|
27
|
+
metrics(placements: Placement[], range: {
|
|
28
|
+
since: string;
|
|
29
|
+
until: string;
|
|
30
|
+
}): Promise<MetricRow[]>;
|
|
31
|
+
policy(placements: Placement[]): Promise<PolicyRow[]>;
|
|
32
|
+
remove(placements: Placement[]): Promise<void>;
|
|
33
|
+
}
|