@yoonion/mimi-seed-mcp 0.19.13 → 0.19.17
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 +2 -1
- package/assets/agent-guide.md +44 -7
- package/dist/appstore/tools.js +83 -29
- package/dist/auth/browser.d.ts +2 -0
- package/dist/auth/browser.js +4 -0
- package/dist/auth/cli.js +33 -8
- package/dist/auth/google-auth.d.ts +26 -5
- package/dist/auth/google-auth.js +133 -47
- package/dist/auth/scopes.d.ts +5 -0
- package/dist/auth/scopes.js +8 -0
- package/dist/auth/youtube-channel.d.ts +8 -0
- package/dist/auth/youtube-channel.js +26 -0
- package/dist/googleads/errors.d.ts +2 -0
- package/dist/googleads/errors.js +31 -0
- package/dist/googleads/tools.d.ts +7 -0
- package/dist/googleads/tools.js +89 -46
- package/dist/helpers.d.ts +1 -1
- package/dist/helpers.js +10 -10
- package/dist/instagram/browser-login.d.ts +13 -0
- package/dist/instagram/browser-login.js +125 -0
- package/dist/instagram/setup.d.ts +1 -1
- package/dist/instagram/setup.js +5 -2
- package/dist/lib/googleapis-lite.d.ts +2 -0
- package/dist/lib/googleapis-lite.js +3 -0
- package/dist/lib/store-create-result.d.ts +221 -0
- package/dist/lib/store-create-result.js +38 -0
- package/dist/playstore/tools.d.ts +2 -0
- package/dist/playstore/tools.js +23 -8
- package/dist/registers/appstore.js +3 -47
- package/dist/registers/auth.js +30 -6
- package/dist/registers/googleads.js +5 -2
- package/dist/registers/playstore.js +3 -16
- package/dist/registers/video.js +11 -5
- package/dist/registers/youtube.d.ts +2 -0
- package/dist/registers/youtube.js +89 -0
- package/dist/server.js +2 -0
- package/dist/social/setup-cli.js +66 -19
- package/dist/threads/browser-login.d.ts +13 -0
- package/dist/threads/browser-login.js +125 -0
- package/dist/threads/config.js +2 -4
- package/dist/threads/setup.d.ts +1 -1
- package/dist/threads/setup.js +7 -4
- package/dist/video/schemas.d.ts +12 -12
- package/dist/video/youtube-publish.d.ts +14 -0
- package/dist/video/youtube-publish.js +8 -0
- package/dist/youtube/comments.d.ts +156 -0
- package/dist/youtube/comments.js +158 -0
- package/dist/youtube/insights.d.ts +82 -0
- package/dist/youtube/insights.js +155 -0
- package/dist/youtube/tools.d.ts +174 -0
- package/dist/youtube/tools.js +292 -0
- package/package.json +5 -3
- package/scripts/googleads-report.mjs +32 -0
- package/tool-manifest.json +18 -1
package/dist/googleads/tools.js
CHANGED
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
import { fetchWithTimeout } from '../lib/http.js';
|
|
2
|
+
import { googleAdsError } from './errors.js';
|
|
2
3
|
// Google Ads API는 ~13개월 주기로 sunset (항상 최신 3개 major만 유지).
|
|
3
4
|
// v24 = 2026-06 기준 현행 major. 새 major 출시 시 갱신 필요.
|
|
4
5
|
// https://developers.google.com/google-ads/api/docs/sunset-dates
|
|
5
6
|
const API_VERSION = 'v24';
|
|
6
7
|
const BASE = `https://googleads.googleapis.com/${API_VERSION}`;
|
|
8
|
+
export const REPORT_METRIC_NOTE = 'cost is in account currency, not micros. conversions are Google Ads conversions, not necessarily installs. Legacy installs/cpi aliases do not establish install CPI or cohort D7 ROAS.';
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function metricNumber(value) {
|
|
13
|
+
// Protobuf JSON can omit zero-valued scalars, but explicit malformed values are not zero.
|
|
14
|
+
if (value === undefined)
|
|
15
|
+
return 0;
|
|
16
|
+
if ((typeof value !== 'number' && typeof value !== 'string') ||
|
|
17
|
+
(typeof value === 'string' && !/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value)) ||
|
|
18
|
+
!Number.isFinite(Number(value)))
|
|
19
|
+
throw new Error('Google Ads returned an invalid numeric metric.');
|
|
20
|
+
return Number(value);
|
|
21
|
+
}
|
|
22
|
+
function validateDateRange(range) {
|
|
23
|
+
for (const value of [range.startDate, range.endDate]) {
|
|
24
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
|
|
25
|
+
!Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0, 10) !== value) {
|
|
26
|
+
throw new Error('Google Ads dates must be valid YYYY-MM-DD calendar dates.');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (range.startDate > range.endDate)
|
|
30
|
+
throw new Error('Google Ads startDate must not exceed endDate.');
|
|
31
|
+
}
|
|
7
32
|
// ─── 내부 헬퍼 ───────────────────────────────────────────────
|
|
8
33
|
async function getAccessToken(auth) {
|
|
9
34
|
const token = await auth.getAccessToken();
|
|
@@ -28,8 +53,10 @@ async function search(auth, cfg, query) {
|
|
|
28
53
|
const url = `${BASE}/customers/${cfg.customerId}/googleAds:search`;
|
|
29
54
|
const all = [];
|
|
30
55
|
let pageToken;
|
|
56
|
+
const seenTokens = new Set();
|
|
31
57
|
do {
|
|
32
|
-
|
|
58
|
+
// Google Ads controls page size; sending pageSize is rejected by current APIs.
|
|
59
|
+
const body = { query };
|
|
33
60
|
if (pageToken)
|
|
34
61
|
body.pageToken = pageToken;
|
|
35
62
|
const res = await fetchWithTimeout(url, {
|
|
@@ -39,27 +66,36 @@ async function search(auth, cfg, query) {
|
|
|
39
66
|
});
|
|
40
67
|
const text = await res.text();
|
|
41
68
|
if (!res.ok) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
69
|
+
throw googleAdsError(res.status, text, [accessToken, cfg.developerToken]);
|
|
70
|
+
}
|
|
71
|
+
let json;
|
|
72
|
+
try {
|
|
73
|
+
json = JSON.parse(text);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new Error('Google Ads returned invalid JSON.');
|
|
77
|
+
}
|
|
78
|
+
if (!isRecord(json) || 'error' in json ||
|
|
79
|
+
(json.results !== undefined && !Array.isArray(json.results)) ||
|
|
80
|
+
(json.nextPageToken !== undefined && typeof json.nextPageToken !== 'string') ||
|
|
81
|
+
(json.results ?? []).some((row) => !isRecord(row) || !isRecord(row.campaign))) {
|
|
82
|
+
throw new Error('Google Ads returned an invalid search response.');
|
|
52
83
|
}
|
|
53
|
-
const json = JSON.parse(text);
|
|
54
84
|
all.push(...(json.results ?? []));
|
|
55
85
|
pageToken = json.nextPageToken ?? undefined;
|
|
86
|
+
if (pageToken) {
|
|
87
|
+
if (seenTokens.has(pageToken))
|
|
88
|
+
throw new Error('Google Ads repeated a page token; refusing partial totals.');
|
|
89
|
+
seenTokens.add(pageToken);
|
|
90
|
+
}
|
|
56
91
|
} while (pageToken);
|
|
57
92
|
return all;
|
|
58
93
|
}
|
|
59
94
|
function microsToCurrency(micros) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
95
|
+
const value = metricNumber(micros);
|
|
96
|
+
if (!Number.isSafeInteger(value))
|
|
97
|
+
throw new Error('Google Ads returned unsafe or fractional currency micros.');
|
|
98
|
+
return value / 1_000_000;
|
|
63
99
|
}
|
|
64
100
|
// ─── 접근 가능한 고객 목록 (API 연결 확인용) ─────────────────────
|
|
65
101
|
export async function listAccessibleCustomers(auth, cfg) {
|
|
@@ -71,15 +107,7 @@ export async function listAccessibleCustomers(auth, cfg) {
|
|
|
71
107
|
});
|
|
72
108
|
const text = await res.text();
|
|
73
109
|
if (!res.ok) {
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
const err = JSON.parse(text);
|
|
77
|
-
msg += `: ${err?.error?.message ?? text.slice(0, 300)}`;
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
msg += `: ${text.slice(0, 300)}`;
|
|
81
|
-
}
|
|
82
|
-
throw new Error(msg);
|
|
110
|
+
throw googleAdsError(res.status, text, [accessToken, cfg.developerToken]);
|
|
83
111
|
}
|
|
84
112
|
return JSON.parse(text);
|
|
85
113
|
}
|
|
@@ -92,8 +120,8 @@ export async function listCampaigns(auth, cfg) {
|
|
|
92
120
|
campaign.status,
|
|
93
121
|
campaign.advertising_channel_type,
|
|
94
122
|
campaign.advertising_channel_sub_type,
|
|
95
|
-
campaign.
|
|
96
|
-
campaign.
|
|
123
|
+
campaign.start_date_time,
|
|
124
|
+
campaign.end_date_time,
|
|
97
125
|
campaign_budget.amount_micros
|
|
98
126
|
FROM campaign
|
|
99
127
|
WHERE campaign.status != 'REMOVED'
|
|
@@ -107,15 +135,19 @@ export async function listCampaigns(auth, cfg) {
|
|
|
107
135
|
status: r.campaign?.status,
|
|
108
136
|
channelType: r.campaign?.advertisingChannelType,
|
|
109
137
|
channelSubType: r.campaign?.advertisingChannelSubType,
|
|
110
|
-
|
|
111
|
-
|
|
138
|
+
// Keep date-only response fields compatible while querying the supported schema.
|
|
139
|
+
startDate: r.campaign?.startDateTime?.slice(0, 10),
|
|
140
|
+
endDate: r.campaign?.endDateTime?.slice(0, 10),
|
|
112
141
|
dailyBudget: microsToCurrency(r.campaignBudget?.amountMicros),
|
|
113
142
|
}));
|
|
114
143
|
}
|
|
115
144
|
// ─── 캠페인 성과 리포트 ────────────────────────────────────────
|
|
116
145
|
export async function getCampaignReport(auth, cfg, range) {
|
|
146
|
+
validateDateRange(range);
|
|
117
147
|
const query = `
|
|
118
148
|
SELECT
|
|
149
|
+
customer.currency_code,
|
|
150
|
+
customer.time_zone,
|
|
119
151
|
campaign.id,
|
|
120
152
|
campaign.name,
|
|
121
153
|
campaign.status,
|
|
@@ -130,30 +162,37 @@ export async function getCampaignReport(auth, cfg, range) {
|
|
|
130
162
|
metrics.ctr,
|
|
131
163
|
metrics.average_cpc
|
|
132
164
|
FROM campaign
|
|
133
|
-
WHERE
|
|
134
|
-
AND segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
|
|
165
|
+
WHERE segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
|
|
135
166
|
ORDER BY metrics.cost_micros DESC
|
|
136
|
-
LIMIT 500
|
|
137
167
|
`;
|
|
138
168
|
const rows = await search(auth, cfg, query);
|
|
169
|
+
for (const row of rows) {
|
|
170
|
+
if (!isRecord(row.metrics))
|
|
171
|
+
throw new Error('Google Ads report row is missing metrics.');
|
|
172
|
+
}
|
|
139
173
|
return rows.map((r) => ({
|
|
174
|
+
currencyCode: r.customer?.currencyCode,
|
|
175
|
+
timeZone: r.customer?.timeZone,
|
|
140
176
|
id: r.campaign?.id,
|
|
141
177
|
name: r.campaign?.name,
|
|
142
178
|
status: r.campaign?.status,
|
|
143
179
|
channelType: r.campaign?.advertisingChannelType,
|
|
144
180
|
channelSubType: r.campaign?.advertisingChannelSubType,
|
|
145
|
-
clicks:
|
|
146
|
-
impressions:
|
|
181
|
+
clicks: metricNumber(r.metrics?.clicks),
|
|
182
|
+
impressions: metricNumber(r.metrics?.impressions),
|
|
147
183
|
cost: microsToCurrency(r.metrics?.costMicros),
|
|
148
|
-
conversions:
|
|
149
|
-
conversionsValue:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
184
|
+
conversions: metricNumber(r.metrics?.conversions),
|
|
185
|
+
conversionsValue: metricNumber(r.metrics?.conversionsValue),
|
|
186
|
+
costPerConversion: metricNumber(r.metrics?.conversions) > 0
|
|
187
|
+
? metricNumber(r.metrics?.costPerConversion) / 1_000_000 : null,
|
|
188
|
+
cpi: metricNumber(r.metrics?.costPerConversion) / 1_000_000,
|
|
189
|
+
ctr: metricNumber(r.metrics?.ctr),
|
|
190
|
+
avgCpc: metricNumber(r.metrics?.averageCpc) / 1_000_000,
|
|
153
191
|
}));
|
|
154
192
|
}
|
|
155
193
|
// ─── UAC(앱 캠페인) 리포트 ─────────────────────────────────────
|
|
156
194
|
export async function getUacReport(auth, cfg, range) {
|
|
195
|
+
validateDateRange(range);
|
|
157
196
|
const query = `
|
|
158
197
|
SELECT
|
|
159
198
|
campaign.id,
|
|
@@ -173,21 +212,22 @@ export async function getUacReport(auth, cfg, range) {
|
|
|
173
212
|
AND campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT', 'APP_CAMPAIGN_FOR_PRE_REGISTRATION')
|
|
174
213
|
AND segments.date BETWEEN '${range.startDate}' AND '${range.endDate}'
|
|
175
214
|
ORDER BY segments.date DESC, metrics.cost_micros DESC
|
|
176
|
-
LIMIT 500
|
|
177
215
|
`;
|
|
178
216
|
const rows = await search(auth, cfg, query);
|
|
179
217
|
const byId = new Map();
|
|
180
218
|
for (const r of rows) {
|
|
219
|
+
if (!isRecord(r.metrics) || !r.campaign?.id)
|
|
220
|
+
throw new Error('Google Ads UAC row is missing campaign or metrics.');
|
|
181
221
|
const id = String(r.campaign?.id ?? '');
|
|
182
222
|
const existing = byId.get(id);
|
|
183
223
|
const cost = microsToCurrency(r.metrics?.costMicros);
|
|
184
|
-
const installs =
|
|
224
|
+
const installs = metricNumber(r.metrics?.conversions);
|
|
185
225
|
if (existing) {
|
|
186
|
-
existing.clicks +=
|
|
187
|
-
existing.impressions +=
|
|
226
|
+
existing.clicks += metricNumber(r.metrics?.clicks);
|
|
227
|
+
existing.impressions += metricNumber(r.metrics?.impressions);
|
|
188
228
|
existing.cost += cost;
|
|
189
229
|
existing.installs += installs;
|
|
190
|
-
existing.installsValue +=
|
|
230
|
+
existing.installsValue += metricNumber(r.metrics?.conversionsValue);
|
|
191
231
|
if (r.segments?.date)
|
|
192
232
|
existing.dates.push(r.segments.date);
|
|
193
233
|
}
|
|
@@ -197,11 +237,11 @@ export async function getUacReport(auth, cfg, range) {
|
|
|
197
237
|
name: r.campaign?.name ?? '',
|
|
198
238
|
status: r.campaign?.status ?? '',
|
|
199
239
|
subType: r.campaign?.advertisingChannelSubType ?? '',
|
|
200
|
-
clicks:
|
|
201
|
-
impressions:
|
|
240
|
+
clicks: metricNumber(r.metrics?.clicks),
|
|
241
|
+
impressions: metricNumber(r.metrics?.impressions),
|
|
202
242
|
cost,
|
|
203
243
|
installs,
|
|
204
|
-
installsValue:
|
|
244
|
+
installsValue: metricNumber(r.metrics?.conversionsValue),
|
|
205
245
|
dates: r.segments?.date ? [r.segments.date] : [],
|
|
206
246
|
});
|
|
207
247
|
}
|
|
@@ -214,6 +254,9 @@ export async function getUacReport(auth, cfg, range) {
|
|
|
214
254
|
clicks: c.clicks,
|
|
215
255
|
impressions: c.impressions,
|
|
216
256
|
cost: c.cost,
|
|
257
|
+
conversions: c.installs,
|
|
258
|
+
conversionsValue: c.installsValue,
|
|
259
|
+
costPerConversion: c.installs > 0 ? c.cost / c.installs : null,
|
|
217
260
|
installs: c.installs,
|
|
218
261
|
installsValue: c.installsValue,
|
|
219
262
|
cpi: c.installs > 0 ? c.cost / c.installs : 0,
|
package/dist/helpers.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export { ensureFreshAccessToken };
|
|
|
8
8
|
* 기존엔 만료 시 각 OAuth 도구(firebase/admob/iam/googleads/checks)가
|
|
9
9
|
* googleapis GaxiosError 를 그대로 노출 → 사용자는 "재로그인하라"는 안내를 못 받았다.
|
|
10
10
|
*/
|
|
11
|
-
export declare function requireAuth(requiredScope?: string): Promise<import("google-auth-library").OAuth2Client>;
|
|
11
|
+
export declare function requireAuth(requiredScope?: string, profile?: string): Promise<import("google-auth-library").OAuth2Client>;
|
|
12
12
|
export declare const PLAY_AUTH_HINT: string;
|
|
13
13
|
export declare const APPSTORE_AUTH_HINT: string;
|
|
14
14
|
/**
|
package/dist/helpers.js
CHANGED
|
@@ -4,13 +4,13 @@ import { getServiceAccountClient, getServiceAccountJson } from './auth/playstore
|
|
|
4
4
|
import { getAppStoreCredentials } from './appstore/auth.js';
|
|
5
5
|
export { ensureFreshAccessToken };
|
|
6
6
|
const REAUTH_CMD = ' npx -y @yoonion/mimi-seed-mcp mimi-seed-auth';
|
|
7
|
-
function formatAuthError(p) {
|
|
7
|
+
function formatAuthError(p, profile) {
|
|
8
8
|
// 재로그인 안내는 needsReauth 인 경우에만 — CONFIG_FETCH_FAILED 처럼 재로그인이
|
|
9
9
|
// 해법이 아닌 에러에 무조건 붙이면 같은 실패를 반복하게 만든다.
|
|
10
10
|
return [
|
|
11
11
|
`❌ [${p.code}] ${p.message}`,
|
|
12
12
|
p.hint ? `→ ${p.hint}` : '',
|
|
13
|
-
...(p.needsReauth ? ['', '터미널에서 재로그인:', REAUTH_CMD] : []),
|
|
13
|
+
...(p.needsReauth ? ['', '터미널에서 재로그인:', profile === undefined ? REAUTH_CMD : `${REAUTH_CMD} --profile ${profile}`] : []),
|
|
14
14
|
]
|
|
15
15
|
.filter((l) => l !== '')
|
|
16
16
|
.join('\n');
|
|
@@ -28,10 +28,10 @@ function formatAuthError(p) {
|
|
|
28
28
|
*
|
|
29
29
|
* OAuth 토큰에만 적용된다 — SA JWT 는 자체 scopes 로 토큰을 받으므로 이 검사 대상이 아니다.
|
|
30
30
|
*/
|
|
31
|
-
function assertStoredScope(requiredScope) {
|
|
31
|
+
function assertStoredScope(requiredScope, profile) {
|
|
32
32
|
if (!requiredScope)
|
|
33
33
|
return;
|
|
34
|
-
const scopeStr = getStoredTokens()?.scope;
|
|
34
|
+
const scopeStr = getStoredTokens(profile)?.scope;
|
|
35
35
|
const missing = scopeStr === undefined
|
|
36
36
|
? !isPreTrackingScope(requiredScope)
|
|
37
37
|
: !scopeStr.split(' ').filter(Boolean).includes(requiredScope);
|
|
@@ -42,7 +42,7 @@ function assertStoredScope(requiredScope) {
|
|
|
42
42
|
code: 'INSUFFICIENT_SCOPE',
|
|
43
43
|
message: `이 도구는 추가 권한이 필요해 (${requiredScope}). 현재 로그인에 그 권한이 없어.`,
|
|
44
44
|
hint: domainArg
|
|
45
|
-
? `mimi-seed-auth --domains ${domainArg} 로 재로그인하면 기존 권한은 유지한 채 이 권한만 추가돼.`
|
|
45
|
+
? `mimi-seed-auth${profile === undefined ? '' : ` --profile ${profile}`} --domains ${domainArg} 로 재로그인하면 기존 권한은 유지한 채 이 권한만 추가돼.`
|
|
46
46
|
: 'mimi-seed-auth 로 재로그인하면 새 권한이 부여돼.',
|
|
47
47
|
retriable: false,
|
|
48
48
|
needsReauth: true,
|
|
@@ -56,12 +56,12 @@ function assertStoredScope(requiredScope) {
|
|
|
56
56
|
* 기존엔 만료 시 각 OAuth 도구(firebase/admob/iam/googleads/checks)가
|
|
57
57
|
* googleapis GaxiosError 를 그대로 노출 → 사용자는 "재로그인하라"는 안내를 못 받았다.
|
|
58
58
|
*/
|
|
59
|
-
export async function requireAuth(requiredScope) {
|
|
60
|
-
const result = await ensureFreshAccessToken();
|
|
59
|
+
export async function requireAuth(requiredScope, profile) {
|
|
60
|
+
const result = await ensureFreshAccessToken(undefined, profile);
|
|
61
61
|
if (result.status === 'unauthenticated' || result.status === 'expired_refresh_failed') {
|
|
62
|
-
throw new Error(formatAuthError(result.error));
|
|
62
|
+
throw new Error(formatAuthError(result.error, profile));
|
|
63
63
|
}
|
|
64
|
-
const client = getAuthenticatedClient();
|
|
64
|
+
const client = getAuthenticatedClient(profile);
|
|
65
65
|
if (!client) {
|
|
66
66
|
throw new Error(formatAuthError({
|
|
67
67
|
code: 'UNAUTHENTICATED',
|
|
@@ -71,7 +71,7 @@ export async function requireAuth(requiredScope) {
|
|
|
71
71
|
needsReauth: true,
|
|
72
72
|
}));
|
|
73
73
|
}
|
|
74
|
-
assertStoredScope(requiredScope);
|
|
74
|
+
assertStoredScope(requiredScope, profile);
|
|
75
75
|
return client;
|
|
76
76
|
}
|
|
77
77
|
export const PLAY_AUTH_HINT = [
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type SocialConfigOptions } from '../social/profile-store.js';
|
|
2
|
+
export declare class InstagramLoginError extends Error {
|
|
3
|
+
readonly code: 'configuration' | 'unavailable' | 'denied' | 'timeout' | 'exchange' | 'validation';
|
|
4
|
+
constructor(code: 'configuration' | 'unavailable' | 'denied' | 'timeout' | 'exchange' | 'validation');
|
|
5
|
+
}
|
|
6
|
+
export interface BrowserLoginOptions extends SocialConfigOptions {
|
|
7
|
+
webBase?: string;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
openBrowser?: (url: string) => Promise<void>;
|
|
10
|
+
onUrl?: (url: string) => void;
|
|
11
|
+
}
|
|
12
|
+
/** The broker keeps the Meta app secret server-side; no token is passed through browser URLs. */
|
|
13
|
+
export declare function connectInstagramInBrowser(options?: BrowserLoginOptions): Promise<import("../facebook/setup.js").ConnectResult>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { fetchWithTimeout } from '../lib/http.js';
|
|
4
|
+
import { openSystemBrowser } from '../auth/browser.js';
|
|
5
|
+
import { connectInstagram } from './setup.js';
|
|
6
|
+
import { resolveSocialConfigTarget } from '../social/profile-store.js';
|
|
7
|
+
export class InstagramLoginError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code) {
|
|
10
|
+
super(`Instagram browser login: ${code}`);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** The broker keeps the Meta app secret server-side; no token is passed through browser URLs. */
|
|
15
|
+
export async function connectInstagramInBrowser(options = {}) {
|
|
16
|
+
// Freeze the selected profile before any asynchronous work / directory changes.
|
|
17
|
+
const target = resolveSocialConfigTarget('instagram', options);
|
|
18
|
+
const saveOptions = { ...options, profile: target.profile ?? undefined, startDir: options.startDir ?? process.cwd() };
|
|
19
|
+
const base = new URL(options.webBase ?? process.env.MIMI_SEED_WEB_BASE ?? 'https://mimi-seed.pryzm.gg');
|
|
20
|
+
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash || base.pathname !== '/') {
|
|
21
|
+
throw new InstagramLoginError('configuration');
|
|
22
|
+
}
|
|
23
|
+
const state = randomBytes(32).toString('hex');
|
|
24
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
25
|
+
const codeChallenge = createHash('sha256').update(verifier).digest('base64url');
|
|
26
|
+
const timeoutMs = options.timeoutMs ?? 600_000;
|
|
27
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 600_000)
|
|
28
|
+
throw new InstagramLoginError('configuration');
|
|
29
|
+
let finish;
|
|
30
|
+
let fail;
|
|
31
|
+
let settled = false;
|
|
32
|
+
const callback = new Promise((resolve, reject) => { finish = resolve; fail = reject; });
|
|
33
|
+
// A denial/timeout can arrive while opening the browser. Attach a handler immediately.
|
|
34
|
+
void callback.catch(() => { });
|
|
35
|
+
const server = createServer((req, res) => {
|
|
36
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
37
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
38
|
+
res.setHeader('Content-Security-Policy', "default-src 'none'");
|
|
39
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
40
|
+
if (req.method !== 'GET' || url.pathname !== '/instagram/callback') {
|
|
41
|
+
res.writeHead(404).end();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const actual = url.searchParams.get('state') ?? '';
|
|
45
|
+
if (!/^[a-f0-9]{64}$/.test(actual) || !timingSafeEqual(Buffer.from(actual), Buffer.from(state))) {
|
|
46
|
+
res.writeHead(400).end('Invalid login state.');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (settled) {
|
|
50
|
+
res.writeHead(409).end('Login already handled.');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (url.searchParams.has('error')) {
|
|
54
|
+
settled = true;
|
|
55
|
+
res.writeHead(400).end('Instagram login cancelled. Return to Mimi Seed.');
|
|
56
|
+
fail(new InstagramLoginError('denied'));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const code = url.searchParams.get('code');
|
|
60
|
+
const ticket = url.searchParams.get('ticket');
|
|
61
|
+
if (!code || code.length > 4096 || !ticket || ticket.length > 8192) {
|
|
62
|
+
res.writeHead(400).end('Invalid callback.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
settled = true;
|
|
66
|
+
res.end('Instagram approval received. Return to Mimi Seed to check the connection result.');
|
|
67
|
+
finish({ code, ticket });
|
|
68
|
+
});
|
|
69
|
+
let timer;
|
|
70
|
+
try {
|
|
71
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
72
|
+
const address = server.address();
|
|
73
|
+
if (!address || typeof address === 'string')
|
|
74
|
+
throw new InstagramLoginError('configuration');
|
|
75
|
+
timer = setTimeout(() => { settled = true; fail(new InstagramLoginError('timeout')); }, timeoutMs);
|
|
76
|
+
const request = async (path, body) => {
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetchWithTimeout(new URL(path, base), {
|
|
79
|
+
method: 'POST', redirect: 'error', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
80
|
+
}, 30_000);
|
|
81
|
+
if (!response.ok)
|
|
82
|
+
throw new InstagramLoginError(response.status === 503 || response.status === 404 ? 'unavailable' : 'exchange');
|
|
83
|
+
return await response.json();
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error instanceof InstagramLoginError)
|
|
87
|
+
throw error;
|
|
88
|
+
// Network/provider exceptions may contain codes or tokens. Do not echo them.
|
|
89
|
+
throw new InstagramLoginError('unavailable');
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const start = await request('/api/instagram-auth/start', { state, codeChallenge, callbackPort: address.port });
|
|
93
|
+
if (typeof start.authorizationUrl !== 'string')
|
|
94
|
+
throw new InstagramLoginError('configuration');
|
|
95
|
+
const authorize = new URL(start.authorizationUrl);
|
|
96
|
+
if (authorize.origin !== base.origin || authorize.pathname !== '/api/instagram-auth/authorize' || authorize.username || authorize.password) {
|
|
97
|
+
throw new InstagramLoginError('configuration');
|
|
98
|
+
}
|
|
99
|
+
options.onUrl?.(authorize.href);
|
|
100
|
+
try {
|
|
101
|
+
await (options.openBrowser ?? openSystemBrowser)(authorize.href);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
if (!options.onUrl)
|
|
105
|
+
throw new InstagramLoginError('unavailable');
|
|
106
|
+
}
|
|
107
|
+
const result = await callback;
|
|
108
|
+
const token = await request('/api/instagram-auth/exchange', { ...result, codeVerifier: verifier });
|
|
109
|
+
if (typeof token.accessToken !== 'string' || !token.accessToken || typeof token.userId !== 'string' || !token.userId ||
|
|
110
|
+
typeof token.expiresInSeconds !== 'number' || !Number.isFinite(token.expiresInSeconds) || token.expiresInSeconds <= 0 || token.expiresInSeconds > 60 * 24 * 3600) {
|
|
111
|
+
throw new InstagramLoginError('exchange');
|
|
112
|
+
}
|
|
113
|
+
// Resolve the canonical account ID from /me: numeric OAuth user_id values can lose precision.
|
|
114
|
+
const connected = await connectInstagram(token.accessToken, undefined, true, saveOptions, token.expiresInSeconds);
|
|
115
|
+
if (!connected.ok)
|
|
116
|
+
throw new InstagramLoginError('validation');
|
|
117
|
+
return connected;
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
if (timer)
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
server.close();
|
|
123
|
+
server.closeAllConnections();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -2,4 +2,4 @@ import type { ConnectResult } from '../facebook/setup.js';
|
|
|
2
2
|
import type { SocialConfigOptions } from '../social/profile-store.js';
|
|
3
3
|
/** IGAA… = Instagram Login(신규) · EAA… = Facebook Login(FB Page + IG Business 연결 필요) */
|
|
4
4
|
export declare function detectApiType(accessToken: string): string;
|
|
5
|
-
export declare function connectInstagram(accessToken: string, userId?: string, assumeIssuedNow?: boolean, options?: SocialConfigOptions): Promise<ConnectResult>;
|
|
5
|
+
export declare function connectInstagram(accessToken: string, userId?: string, assumeIssuedNow?: boolean, options?: SocialConfigOptions, expiresInSeconds?: number): Promise<ConnectResult>;
|
package/dist/instagram/setup.js
CHANGED
|
@@ -8,7 +8,10 @@ const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
|
|
|
8
8
|
export function detectApiType(accessToken) {
|
|
9
9
|
return accessToken.startsWith('IGAA') ? 'Instagram Login' : 'Facebook Login';
|
|
10
10
|
}
|
|
11
|
-
export async function connectInstagram(accessToken, userId, assumeIssuedNow = true, options = {}) {
|
|
11
|
+
export async function connectInstagram(accessToken, userId, assumeIssuedNow = true, options = {}, expiresInSeconds) {
|
|
12
|
+
if (expiresInSeconds !== undefined && (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0 || expiresInSeconds > 5_184_000)) {
|
|
13
|
+
return { ok: false, text: 'Invalid Instagram token lifetime; nothing was saved.' };
|
|
14
|
+
}
|
|
12
15
|
// 프로필 ID를 네트워크 호출 전에 검증하고, 성공 응답에 실제 저장 대상을 남긴다.
|
|
13
16
|
const target = resolveSocialConfigTarget('instagram', options);
|
|
14
17
|
const apiType = detectApiType(accessToken);
|
|
@@ -30,7 +33,7 @@ export async function connectInstagram(accessToken, userId, assumeIssuedNow = tr
|
|
|
30
33
|
}
|
|
31
34
|
}
|
|
32
35
|
const expiresAt = assumeIssuedNow
|
|
33
|
-
? new Date(Date.now() + SIXTY_DAYS_MS).toISOString()
|
|
36
|
+
? new Date(Date.now() + (expiresInSeconds === undefined ? SIXTY_DAYS_MS : expiresInSeconds * 1000)).toISOString()
|
|
34
37
|
: undefined;
|
|
35
38
|
try {
|
|
36
39
|
const account = await api.getAccount({ accessToken, userId: resolvedUserId });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type { youtube_v3 } from 'googleapis/build/src/apis/youtube/index.js';
|
|
2
|
+
export type { youtubeAnalytics_v2 } from 'googleapis/build/src/apis/youtubeAnalytics/index.js';
|
|
2
3
|
/**
|
|
3
4
|
* 기존 `google.<api>(...)` 호출부와 100% 동일하게 동작하는 지연 로딩 네임스페이스.
|
|
4
5
|
* 각 프로퍼티는 getter 라서, 실제로 그 API 를 쓰는 도구가 호출될 때까지 아무것도 로드하지 않는다.
|
|
@@ -19,4 +20,5 @@ export declare const google: {
|
|
|
19
20
|
readonly searchconsole: typeof import("googleapis/build/src/apis/searchconsole/index.js").searchconsole;
|
|
20
21
|
readonly serviceusage: typeof import("googleapis/build/src/apis/serviceusage/index.js").serviceusage;
|
|
21
22
|
readonly youtube: typeof import("googleapis/build/src/apis/youtube/index.js").youtube;
|
|
23
|
+
readonly youtubeAnalytics: typeof import("googleapis/build/src/apis/youtubeAnalytics/index.js").youtubeAnalytics;
|
|
22
24
|
};
|