@yoonion/mimi-seed-mcp 0.19.1 → 0.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/appstore/analytics.d.ts +34 -0
- package/dist/appstore/analytics.js +95 -28
- package/dist/checks/billing.d.ts +7 -1
- package/dist/checks/billing.js +114 -32
- package/dist/checks/risks.js +27 -4
- package/dist/firebase/remote-config.d.ts +64 -33
- package/dist/firebase/remote-config.js +98 -19
- package/package.json +1 -1
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
import { type ReportRow } from './sales.js';
|
|
2
|
+
interface JsonApiRow<T> {
|
|
3
|
+
id: string;
|
|
4
|
+
attributes?: T;
|
|
5
|
+
}
|
|
6
|
+
interface AnalyticsReportAttributes {
|
|
7
|
+
name?: string;
|
|
8
|
+
category?: 'APP_USAGE' | 'APP_STORE_ENGAGEMENT' | 'COMMERCE' | 'FRAMEWORK_USAGE' | 'PERFORMANCE';
|
|
9
|
+
}
|
|
10
|
+
export declare function selectWeeklyAnalyticsReports(reports: Array<JsonApiRow<AnalyticsReportAttributes>>): {
|
|
11
|
+
engagement: JsonApiRow<AnalyticsReportAttributes> | undefined;
|
|
12
|
+
downloads: JsonApiRow<AnalyticsReportAttributes> | undefined;
|
|
13
|
+
purchases: JsonApiRow<AnalyticsReportAttributes> | undefined;
|
|
14
|
+
};
|
|
2
15
|
interface WeeklyMetrics {
|
|
3
16
|
date: string;
|
|
4
17
|
impressions: number;
|
|
5
18
|
productPageViews: number;
|
|
6
19
|
downloads: number;
|
|
7
20
|
proceeds: number;
|
|
21
|
+
available: {
|
|
22
|
+
engagement: boolean;
|
|
23
|
+
downloads: boolean;
|
|
24
|
+
purchases: boolean;
|
|
25
|
+
};
|
|
8
26
|
}
|
|
27
|
+
export declare function selectLatestInstanceRows(batches: Array<{
|
|
28
|
+
processingDate: string;
|
|
29
|
+
rows: ReportRow[];
|
|
30
|
+
}>): ReportRow[];
|
|
9
31
|
export declare function buildWeeklyInsight(input: {
|
|
10
32
|
engagement: ReportRow[];
|
|
11
33
|
downloads: ReportRow[];
|
|
@@ -34,7 +56,13 @@ export declare function buildWeeklyInsight(input: {
|
|
|
34
56
|
insight: {
|
|
35
57
|
area: string;
|
|
36
58
|
changePercent: number | null;
|
|
59
|
+
trend: string;
|
|
37
60
|
recommendation: string;
|
|
61
|
+
} | {
|
|
62
|
+
area: string;
|
|
63
|
+
changePercent: null;
|
|
64
|
+
recommendation: string;
|
|
65
|
+
trend?: undefined;
|
|
38
66
|
};
|
|
39
67
|
weeks?: undefined;
|
|
40
68
|
recommendation?: undefined;
|
|
@@ -90,7 +118,13 @@ export declare function getWeeklyInsight(input: {
|
|
|
90
118
|
insight: {
|
|
91
119
|
area: string;
|
|
92
120
|
changePercent: number | null;
|
|
121
|
+
trend: string;
|
|
122
|
+
recommendation: string;
|
|
123
|
+
} | {
|
|
124
|
+
area: string;
|
|
125
|
+
changePercent: null;
|
|
93
126
|
recommendation: string;
|
|
127
|
+
trend?: undefined;
|
|
94
128
|
};
|
|
95
129
|
weeks?: undefined;
|
|
96
130
|
recommendation?: undefined;
|
|
@@ -4,7 +4,21 @@ import { getReportsAuthHeaders } from './auth.js';
|
|
|
4
4
|
import { V1_BASE, apiRequest, authHeadersOrThrow } from './http.js';
|
|
5
5
|
import { fetchWithTimeout, HTTP_TRANSFER_TIMEOUT_MS } from '../lib/http.js';
|
|
6
6
|
import { parseTsv } from './sales.js';
|
|
7
|
+
export function selectWeeklyAnalyticsReports(reports) {
|
|
8
|
+
const standard = reports.filter((row) => !/Detailed/i.test(row.attributes?.name ?? ''));
|
|
9
|
+
const detailed = reports.filter((row) => /Detailed/i.test(row.attributes?.name ?? ''));
|
|
10
|
+
return {
|
|
11
|
+
engagement: standard.find((row) => /Discovery and Engagement/i.test(row.attributes?.name ?? '')),
|
|
12
|
+
// Apple only exposes weekly App Store Downloads as a detailed report. Selecting the
|
|
13
|
+
// standard report here silently yields no WEEKLY instances even when analytics is ready.
|
|
14
|
+
downloads: detailed.find((row) => /App Store Downloads/i.test(row.attributes?.name ?? ''))
|
|
15
|
+
?? standard.find((row) => /App Store Downloads/i.test(row.attributes?.name ?? '')),
|
|
16
|
+
purchases: standard.find((row) => /App Store Purchases/i.test(row.attributes?.name ?? '')),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
7
19
|
const MAX_SEGMENT_BYTES = 50 * 1024 * 1024;
|
|
20
|
+
const MAX_DECOMPRESSED_BYTES = 100 * 1024 * 1024;
|
|
21
|
+
const MAX_WEEKLY_INSTANCES = 8;
|
|
8
22
|
async function reportHeaders() {
|
|
9
23
|
const auth = await getReportsAuthHeaders();
|
|
10
24
|
if (!auth)
|
|
@@ -39,35 +53,64 @@ async function downloadSegment(segment) {
|
|
|
39
53
|
const response = await fetchWithTimeout(attributes.url, {}, HTTP_TRANSFER_TIMEOUT_MS);
|
|
40
54
|
if (!response.ok)
|
|
41
55
|
throw new Error(`Analytics segment download failed (${response.status}).`);
|
|
56
|
+
const contentLength = Number(response.headers.get('content-length') ?? 0);
|
|
57
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_SEGMENT_BYTES) {
|
|
58
|
+
throw new Error(`Analytics segment ${segment.id} response is larger than the 50 MB safety limit.`);
|
|
59
|
+
}
|
|
42
60
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
61
|
+
if (bytes.length > MAX_SEGMENT_BYTES) {
|
|
62
|
+
throw new Error(`Analytics segment ${segment.id} downloaded more than the 50 MB safety limit.`);
|
|
63
|
+
}
|
|
43
64
|
if (attributes.checksum) {
|
|
44
65
|
const actual = crypto.createHash('md5').update(bytes).digest('hex');
|
|
45
66
|
if (actual !== attributes.checksum.toLowerCase()) {
|
|
46
67
|
throw new Error(`Analytics segment checksum mismatch for ${segment.id}.`);
|
|
47
68
|
}
|
|
48
69
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
catch {
|
|
54
|
-
text = bytes.toString('utf8');
|
|
55
|
-
}
|
|
70
|
+
const isGzip = bytes[0] === 0x1f && bytes[1] === 0x8b;
|
|
71
|
+
const text = isGzip
|
|
72
|
+
? zlib.gunzipSync(bytes, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString('utf8')
|
|
73
|
+
: bytes.toString('utf8');
|
|
56
74
|
return parseTsv(text);
|
|
57
75
|
}
|
|
76
|
+
export function selectLatestInstanceRows(batches) {
|
|
77
|
+
const latestByDate = new Map();
|
|
78
|
+
for (const batch of batches) {
|
|
79
|
+
const rowsByDate = new Map();
|
|
80
|
+
for (const row of batch.rows) {
|
|
81
|
+
const date = dateOf(row);
|
|
82
|
+
if (!date)
|
|
83
|
+
continue;
|
|
84
|
+
const rows = rowsByDate.get(date) ?? [];
|
|
85
|
+
rows.push(row);
|
|
86
|
+
rowsByDate.set(date, rows);
|
|
87
|
+
}
|
|
88
|
+
for (const [date, rows] of rowsByDate) {
|
|
89
|
+
const current = latestByDate.get(date);
|
|
90
|
+
if (!current || batch.processingDate > current.processingDate) {
|
|
91
|
+
latestByDate.set(date, { processingDate: batch.processingDate, rows });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return [...latestByDate.entries()]
|
|
96
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
97
|
+
.flatMap(([, value]) => value.rows);
|
|
98
|
+
}
|
|
58
99
|
async function rowsForReport(reportId) {
|
|
59
100
|
const instances = await reportGet(`/analyticsReports/${reportId}/instances`, { 'filter[granularity]': 'WEEKLY', limit: '200' });
|
|
60
101
|
const latest = (instances.data ?? [])
|
|
61
102
|
.filter((row) => row.attributes?.processingDate)
|
|
62
103
|
.sort((a, b) => (b.attributes?.processingDate ?? '').localeCompare(a.attributes?.processingDate ?? ''))
|
|
63
|
-
.slice(0,
|
|
64
|
-
const
|
|
104
|
+
.slice(0, MAX_WEEKLY_INSTANCES);
|
|
105
|
+
const batches = [];
|
|
65
106
|
for (const instance of latest) {
|
|
66
107
|
const segments = await reportGet(`/analyticsReportInstances/${instance.id}/segments`, { limit: '200' });
|
|
108
|
+
const rows = [];
|
|
67
109
|
for (const segment of segments.data ?? [])
|
|
68
|
-
|
|
110
|
+
rows.push(...await downloadSegment(segment));
|
|
111
|
+
batches.push({ processingDate: instance.attributes.processingDate, rows });
|
|
69
112
|
}
|
|
70
|
-
return
|
|
113
|
+
return selectLatestInstanceRows(batches);
|
|
71
114
|
}
|
|
72
115
|
function numberFrom(row, names) {
|
|
73
116
|
for (const name of names) {
|
|
@@ -86,7 +129,14 @@ function dateOf(row) {
|
|
|
86
129
|
function ensureWeek(map, date) {
|
|
87
130
|
let week = map.get(date);
|
|
88
131
|
if (!week) {
|
|
89
|
-
week = {
|
|
132
|
+
week = {
|
|
133
|
+
date,
|
|
134
|
+
impressions: 0,
|
|
135
|
+
productPageViews: 0,
|
|
136
|
+
downloads: 0,
|
|
137
|
+
proceeds: 0,
|
|
138
|
+
available: { engagement: false, downloads: false, purchases: false },
|
|
139
|
+
};
|
|
90
140
|
map.set(date, week);
|
|
91
141
|
}
|
|
92
142
|
return week;
|
|
@@ -103,9 +153,10 @@ export function buildWeeklyInsight(input) {
|
|
|
103
153
|
if (!date)
|
|
104
154
|
continue;
|
|
105
155
|
const week = ensureWeek(weeks, date);
|
|
156
|
+
week.available.engagement = true;
|
|
106
157
|
const event = (row.Event ?? '').toLowerCase();
|
|
107
158
|
const pageType = (row['Page Type'] ?? '').toLowerCase();
|
|
108
|
-
const count = numberFrom(row, ['Counts', 'Count']);
|
|
159
|
+
const count = numberFrom(row, ['Unique Counts', 'Counts', 'Count']);
|
|
109
160
|
if (event === 'impression')
|
|
110
161
|
week.impressions += count;
|
|
111
162
|
if (event === 'page view' && (pageType === 'product page' || !pageType))
|
|
@@ -115,20 +166,27 @@ export function buildWeeklyInsight(input) {
|
|
|
115
166
|
const date = dateOf(row);
|
|
116
167
|
if (!date)
|
|
117
168
|
continue;
|
|
118
|
-
ensureWeek(weeks, date)
|
|
169
|
+
const week = ensureWeek(weeks, date);
|
|
170
|
+
week.available.downloads = true;
|
|
171
|
+
week.downloads += numberFrom(row, ['Counts', 'Count', 'Downloads', 'Units']);
|
|
119
172
|
}
|
|
120
173
|
for (const row of input.purchases) {
|
|
121
174
|
const date = dateOf(row);
|
|
122
175
|
if (!date)
|
|
123
176
|
continue;
|
|
124
|
-
ensureWeek(weeks, date)
|
|
177
|
+
const week = ensureWeek(weeks, date);
|
|
178
|
+
week.available.purchases = true;
|
|
179
|
+
week.proceeds += numberFrom(row, [
|
|
180
|
+
'Proceeds in USD',
|
|
125
181
|
'Developer Proceeds',
|
|
126
182
|
'Proceeds',
|
|
127
183
|
'Estimated Proceeds',
|
|
128
184
|
'Sales',
|
|
129
185
|
]);
|
|
130
186
|
}
|
|
131
|
-
const ordered = [...weeks.values()]
|
|
187
|
+
const ordered = [...weeks.values()]
|
|
188
|
+
.filter((week) => week.available.engagement && week.available.downloads)
|
|
189
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
132
190
|
if (ordered.length < 2) {
|
|
133
191
|
return {
|
|
134
192
|
status: 'collecting',
|
|
@@ -148,17 +206,22 @@ export function buildWeeklyInsight(input) {
|
|
|
148
206
|
{
|
|
149
207
|
area: 'product_page',
|
|
150
208
|
changePercent: percentageChange(currentPageRate, previousPageRate),
|
|
151
|
-
|
|
209
|
+
declineRecommendation: '제품 페이지 진입률이 하락했습니다. 아이콘·스크린샷 첫 장·부제 중 하나를 바꿔 Product Page Optimization 테스트를 시작하세요.',
|
|
210
|
+
healthyRecommendation: '핵심 전환 지표의 주간 하락은 없습니다. 가장 개선 폭이 작은 제품 페이지 진입률을 다음 실험 대상으로 삼으세요.',
|
|
152
211
|
},
|
|
153
212
|
{
|
|
154
213
|
area: 'acquisition',
|
|
155
214
|
changePercent: percentageChange(currentDownloadRate, previousDownloadRate),
|
|
156
|
-
|
|
215
|
+
declineRecommendation: '제품 페이지 조회 대비 다운로드 전환이 하락했습니다. 유입 소스별 전환을 나누고 가장 큰 하락 소스에 맞춘 커스텀 제품 페이지를 만드세요.',
|
|
216
|
+
healthyRecommendation: '핵심 전환 지표의 주간 하락은 없습니다. 가장 개선 폭이 작은 다운로드 전환을 유입 소스별로 나눠 다음 실험을 정하세요.',
|
|
157
217
|
},
|
|
158
218
|
{
|
|
159
219
|
area: 'monetization',
|
|
160
|
-
changePercent:
|
|
161
|
-
|
|
220
|
+
changePercent: previous.available.purchases && current.available.purchases
|
|
221
|
+
? percentageChange(currentRevenuePerDownload, previousRevenuePerDownload)
|
|
222
|
+
: null,
|
|
223
|
+
declineRecommendation: '다운로드당 수익이 하락했습니다. 구매 리포트에서 상품별 하락을 확인하고 가격·오퍼·구독 전환 중 한 가지를 실험하세요.',
|
|
224
|
+
healthyRecommendation: '핵심 전환 지표의 주간 하락은 없습니다. 가장 개선 폭이 작은 다운로드당 수익을 상품별로 나눠 다음 가격·오퍼 실험을 정하세요.',
|
|
162
225
|
},
|
|
163
226
|
].filter((candidate) => candidate.changePercent !== null)
|
|
164
227
|
.sort((a, b) => (a.changePercent ?? 0) - (b.changePercent ?? 0));
|
|
@@ -171,21 +234,28 @@ export function buildWeeklyInsight(input) {
|
|
|
171
234
|
impressions: percentageChange(current.impressions, previous.impressions),
|
|
172
235
|
productPageViews: percentageChange(current.productPageViews, previous.productPageViews),
|
|
173
236
|
downloads: percentageChange(current.downloads, previous.downloads),
|
|
174
|
-
proceeds:
|
|
237
|
+
proceeds: previous.available.purchases && current.available.purchases
|
|
238
|
+
? percentageChange(current.proceeds, previous.proceeds)
|
|
239
|
+
: null,
|
|
175
240
|
productPageRate: percentageChange(currentPageRate, previousPageRate),
|
|
176
241
|
downloadRate: percentageChange(currentDownloadRate, previousDownloadRate),
|
|
177
|
-
revenuePerDownload:
|
|
242
|
+
revenuePerDownload: previous.available.purchases && current.available.purchases
|
|
243
|
+
? percentageChange(currentRevenuePerDownload, previousRevenuePerDownload)
|
|
244
|
+
: null,
|
|
178
245
|
},
|
|
179
246
|
insight: selected
|
|
180
247
|
? {
|
|
181
248
|
area: selected.area,
|
|
182
249
|
changePercent: selected.changePercent,
|
|
183
|
-
|
|
250
|
+
trend: (selected.changePercent ?? 0) < 0 ? 'declining' : 'stable_or_improving',
|
|
251
|
+
recommendation: (selected.changePercent ?? 0) < 0
|
|
252
|
+
? selected.declineRecommendation
|
|
253
|
+
: selected.healthyRecommendation,
|
|
184
254
|
}
|
|
185
255
|
: {
|
|
186
256
|
area: 'data_quality',
|
|
187
257
|
changePercent: null,
|
|
188
|
-
recommendation: '비교 가능한 분모 데이터가 부족합니다.
|
|
258
|
+
recommendation: '비교 가능한 분모 데이터가 부족합니다. Engagement·주간 Downloads Detailed·Purchases 리포트가 생성되는지 확인하세요.',
|
|
189
259
|
},
|
|
190
260
|
};
|
|
191
261
|
}
|
|
@@ -214,10 +284,7 @@ export async function getWeeklyInsight(input) {
|
|
|
214
284
|
};
|
|
215
285
|
}
|
|
216
286
|
const reports = await reportGet(`/analyticsReportRequests/${active.id}/reports`, { limit: '200' });
|
|
217
|
-
const
|
|
218
|
-
const engagement = standard.find((row) => /Discovery and Engagement/i.test(row.attributes?.name ?? ''));
|
|
219
|
-
const downloads = standard.find((row) => /App Store Downloads/i.test(row.attributes?.name ?? ''));
|
|
220
|
-
const purchases = standard.find((row) => /App Store Purchases/i.test(row.attributes?.name ?? ''));
|
|
287
|
+
const { engagement, downloads, purchases } = selectWeeklyAnalyticsReports(reports.data ?? []);
|
|
221
288
|
if (!engagement && !downloads && !purchases) {
|
|
222
289
|
return {
|
|
223
290
|
status: 'collecting',
|
package/dist/checks/billing.d.ts
CHANGED
|
@@ -13,10 +13,16 @@ export interface BillingComplianceResult {
|
|
|
13
13
|
detectedVersions: string[];
|
|
14
14
|
evidence: BillingEvidence[];
|
|
15
15
|
policy: {
|
|
16
|
-
minimumSupportedMajor: number;
|
|
16
|
+
minimumSupportedMajor: number | null;
|
|
17
17
|
submissionDeadline: string;
|
|
18
18
|
extensionDeadline: string;
|
|
19
19
|
latestKnownMajor: number;
|
|
20
|
+
scheduleCurrent: boolean;
|
|
21
|
+
knownSchedule: Array<{
|
|
22
|
+
major: number;
|
|
23
|
+
submissionDeadline: string;
|
|
24
|
+
extensionDeadline: string;
|
|
25
|
+
}>;
|
|
20
26
|
sourceUrl: string;
|
|
21
27
|
};
|
|
22
28
|
summary: string;
|
package/dist/checks/billing.js
CHANGED
|
@@ -4,6 +4,13 @@ const BILLING_MODULE = /com\.android\.billingclient:billing(?:-ktx)?/;
|
|
|
4
4
|
const LITERAL_DEPENDENCY = /com\.android\.billingclient:billing(?:-ktx)?:([0-9]+(?:\.[0-9A-Za-z_-]+){0,3})/g;
|
|
5
5
|
const VARIABLE_DEPENDENCY = /com\.android\.billingclient:billing(?:-ktx)?:\$\{?([A-Za-z_][A-Za-z0-9_.-]*)\}?/g;
|
|
6
6
|
const VERSION_ASSIGNMENT = /(?:^|\s)([A-Za-z_][A-Za-z0-9_.-]*)\s*(?:=|:)\s*["']([0-9]+(?:\.[0-9A-Za-z_-]+){0,3})["']/gm;
|
|
7
|
+
const BILLING_SUPPORT_SCHEDULE = [
|
|
8
|
+
{ major: 5, submissionDeadline: '2024-08-31', extensionDeadline: '2024-11-01' },
|
|
9
|
+
{ major: 6, submissionDeadline: '2025-08-31', extensionDeadline: '2025-11-01' },
|
|
10
|
+
{ major: 7, submissionDeadline: '2026-08-31', extensionDeadline: '2026-11-01' },
|
|
11
|
+
{ major: 8, submissionDeadline: '2027-08-31', extensionDeadline: '2027-11-01' },
|
|
12
|
+
{ major: 9, submissionDeadline: '2028-08-31', extensionDeadline: '2028-11-01' },
|
|
13
|
+
];
|
|
7
14
|
const SKIP_DIRS = new Set([
|
|
8
15
|
'.git',
|
|
9
16
|
'.gradle',
|
|
@@ -45,6 +52,7 @@ async function walk(root, maxDepth = 7) {
|
|
|
45
52
|
function parseCatalog(text) {
|
|
46
53
|
const versions = new Map();
|
|
47
54
|
const libraries = new Map();
|
|
55
|
+
const bundles = new Map();
|
|
48
56
|
let section = '';
|
|
49
57
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
50
58
|
const line = rawLine.replace(/\s+#.*$/, '').trim();
|
|
@@ -61,18 +69,37 @@ function parseCatalog(text) {
|
|
|
61
69
|
versions.set(match[1], match[2]);
|
|
62
70
|
continue;
|
|
63
71
|
}
|
|
72
|
+
if (section === 'bundles') {
|
|
73
|
+
const match = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*\[([^\]]*)]/);
|
|
74
|
+
if (match) {
|
|
75
|
+
bundles.set(match[1], [...match[2].matchAll(/["']([^"']+)["']/g)].map((item) => item[1]));
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
64
79
|
if (section !== 'libraries')
|
|
65
80
|
continue;
|
|
81
|
+
const shorthand = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*["']([^"']+)["']/);
|
|
82
|
+
if (shorthand) {
|
|
83
|
+
const coordinates = shorthand[2].split(':');
|
|
84
|
+
libraries.set(shorthand[1], {
|
|
85
|
+
module: coordinates.length >= 2 ? `${coordinates[0]}:${coordinates[1]}` : undefined,
|
|
86
|
+
version: coordinates.length >= 3 ? coordinates.slice(2).join(':') : undefined,
|
|
87
|
+
});
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
66
90
|
const match = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*\{(.+)}\s*$/);
|
|
67
91
|
if (!match)
|
|
68
92
|
continue;
|
|
69
93
|
const body = match[2];
|
|
70
|
-
const
|
|
94
|
+
const explicitModule = body.match(/module\s*=\s*["']([^"']+)["']/)?.[1];
|
|
95
|
+
const group = body.match(/group\s*=\s*["']([^"']+)["']/)?.[1];
|
|
96
|
+
const name = body.match(/name\s*=\s*["']([^"']+)["']/)?.[1];
|
|
97
|
+
const module = explicitModule ?? (group && name ? `${group}:${name}` : undefined);
|
|
71
98
|
const version = body.match(/(?:^|,)\s*version\s*=\s*["']([^"']+)["']/)?.[1];
|
|
72
99
|
const versionRef = body.match(/version\.ref\s*=\s*["']([^"']+)["']/)?.[1];
|
|
73
100
|
libraries.set(match[1], { module, version, versionRef });
|
|
74
101
|
}
|
|
75
|
-
return { versions, libraries };
|
|
102
|
+
return { versions, libraries, bundles };
|
|
76
103
|
}
|
|
77
104
|
function collectVariables(text) {
|
|
78
105
|
const result = new Map();
|
|
@@ -88,51 +115,72 @@ function majorOf(version) {
|
|
|
88
115
|
return Number.isFinite(major) ? major : null;
|
|
89
116
|
}
|
|
90
117
|
function policyAt(now) {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
118
|
+
const deadlineEnd = (date) => new Date(`${date}T23:59:59.999Z`);
|
|
119
|
+
const next = BILLING_SUPPORT_SCHEDULE.find((row) => now <= deadlineEnd(row.submissionDeadline));
|
|
120
|
+
const lastExpired = [...BILLING_SUPPORT_SCHEDULE]
|
|
121
|
+
.filter((row) => now > deadlineEnd(row.submissionDeadline))
|
|
122
|
+
.at(-1);
|
|
95
123
|
return {
|
|
96
|
-
minimumSupportedMajor,
|
|
97
|
-
submissionDeadline:
|
|
98
|
-
extensionDeadline:
|
|
99
|
-
latestKnownMajor:
|
|
124
|
+
minimumSupportedMajor: next?.major ?? null,
|
|
125
|
+
submissionDeadline: lastExpired?.submissionDeadline ?? BILLING_SUPPORT_SCHEDULE[0].submissionDeadline,
|
|
126
|
+
extensionDeadline: lastExpired?.extensionDeadline ?? BILLING_SUPPORT_SCHEDULE[0].extensionDeadline,
|
|
127
|
+
latestKnownMajor: BILLING_SUPPORT_SCHEDULE.at(-1).major,
|
|
128
|
+
scheduleCurrent: Boolean(next),
|
|
129
|
+
knownSchedule: BILLING_SUPPORT_SCHEDULE.map((row) => ({ ...row })),
|
|
100
130
|
sourceUrl: 'https://developer.android.com/google/play/billing/deprecation-faq',
|
|
101
131
|
};
|
|
102
132
|
}
|
|
133
|
+
function scheduleForMajor(major) {
|
|
134
|
+
return BILLING_SUPPORT_SCHEDULE.find((row) => row.major === major);
|
|
135
|
+
}
|
|
136
|
+
function catalogScope(file) {
|
|
137
|
+
const parent = path.dirname(file);
|
|
138
|
+
return path.basename(parent) === 'gradle' ? path.dirname(parent) : parent;
|
|
139
|
+
}
|
|
140
|
+
function isWithin(scope, file) {
|
|
141
|
+
const relative = path.relative(scope, file);
|
|
142
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
143
|
+
}
|
|
103
144
|
export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
104
145
|
const root = path.resolve(projectPath);
|
|
146
|
+
let rootStat;
|
|
147
|
+
try {
|
|
148
|
+
rootStat = await fs.stat(root);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new Error(`Android project path does not exist or is not readable: ${root}`);
|
|
152
|
+
}
|
|
153
|
+
if (!rootStat.isDirectory())
|
|
154
|
+
throw new Error(`Android project path is not a directory: ${root}`);
|
|
105
155
|
const files = await walk(root);
|
|
106
156
|
const texts = new Map();
|
|
107
157
|
for (const file of files)
|
|
108
158
|
texts.set(file, await fs.readFile(file, 'utf8'));
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
159
|
+
const catalogs = files
|
|
160
|
+
.filter((file) => path.basename(file) === 'libs.versions.toml')
|
|
161
|
+
.map((file) => ({ file, scope: catalogScope(file), info: parseCatalog(texts.get(file) ?? '') }));
|
|
162
|
+
const variablesByFile = new Map();
|
|
112
163
|
for (const [file, text] of texts) {
|
|
113
164
|
if (path.basename(file) === 'libs.versions.toml')
|
|
114
165
|
continue;
|
|
115
|
-
|
|
116
|
-
allVariables.set(key, value);
|
|
166
|
+
variablesByFile.set(file, collectVariables(text));
|
|
117
167
|
}
|
|
168
|
+
const catalogFor = (file) => catalogs
|
|
169
|
+
.filter((entry) => isWithin(entry.scope, file))
|
|
170
|
+
.sort((left, right) => right.scope.length - left.scope.length)[0]?.info
|
|
171
|
+
?? (catalogs.length === 1 ? catalogs[0].info : undefined);
|
|
172
|
+
const variableFor = (file, key) => [...variablesByFile.entries()]
|
|
173
|
+
.filter(([candidate]) => candidate === file || isWithin(path.dirname(candidate), file))
|
|
174
|
+
.sort(([left], [right]) => path.dirname(right).length - path.dirname(left).length)
|
|
175
|
+
.map(([, variables]) => variables.get(key))
|
|
176
|
+
.find((value) => value !== undefined);
|
|
118
177
|
const evidence = [];
|
|
119
178
|
for (const [file, text] of texts) {
|
|
120
179
|
const relative = path.relative(root, file).replace(/\\/g, '/');
|
|
121
180
|
if (path.basename(file) === 'libs.versions.toml') {
|
|
122
|
-
for (const [alias, lib] of catalog.libraries) {
|
|
123
|
-
if (!lib.module || !BILLING_MODULE.test(lib.module))
|
|
124
|
-
continue;
|
|
125
|
-
const version = lib.version ?? (lib.versionRef ? catalog.versions.get(lib.versionRef) : undefined);
|
|
126
|
-
evidence.push({
|
|
127
|
-
file: relative,
|
|
128
|
-
module: lib.module,
|
|
129
|
-
version,
|
|
130
|
-
expression: `libs.${alias.replace(/-/g, '.')}`,
|
|
131
|
-
source: version ? 'version_catalog' : 'unresolved',
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
181
|
continue;
|
|
135
182
|
}
|
|
183
|
+
const catalog = catalogFor(file);
|
|
136
184
|
for (const match of text.matchAll(LITERAL_DEPENDENCY)) {
|
|
137
185
|
evidence.push({
|
|
138
186
|
file: relative,
|
|
@@ -142,7 +190,7 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
|
142
190
|
});
|
|
143
191
|
}
|
|
144
192
|
for (const match of text.matchAll(VARIABLE_DEPENDENCY)) {
|
|
145
|
-
const version =
|
|
193
|
+
const version = variableFor(file, match[1]);
|
|
146
194
|
evidence.push({
|
|
147
195
|
file: relative,
|
|
148
196
|
module: match[0].slice(0, match[0].lastIndexOf(':')),
|
|
@@ -151,12 +199,34 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
|
151
199
|
source: version ? 'variable' : 'unresolved',
|
|
152
200
|
});
|
|
153
201
|
}
|
|
202
|
+
for (const bundleMatch of text.matchAll(/\blibs\.bundles\.([A-Za-z0-9_.-]+)/g)) {
|
|
203
|
+
const bundleAlias = normalizeAlias(bundleMatch[1]);
|
|
204
|
+
const libraryAliases = catalog?.bundles.get(bundleAlias) ?? catalog?.bundles.get(bundleMatch[1]) ?? [];
|
|
205
|
+
for (const libraryAlias of libraryAliases) {
|
|
206
|
+
const normalizedLibraryAlias = normalizeAlias(libraryAlias);
|
|
207
|
+
const lib = catalog?.libraries.get(normalizedLibraryAlias) ?? catalog?.libraries.get(libraryAlias);
|
|
208
|
+
if (!lib?.module || !BILLING_MODULE.test(lib.module))
|
|
209
|
+
continue;
|
|
210
|
+
const version = lib.version ?? (lib.versionRef ? catalog?.versions.get(lib.versionRef) : undefined);
|
|
211
|
+
if (!evidence.some((row) => row.file === relative && row.expression === bundleMatch[0] && row.module === lib.module)) {
|
|
212
|
+
evidence.push({
|
|
213
|
+
file: relative,
|
|
214
|
+
module: lib.module,
|
|
215
|
+
version,
|
|
216
|
+
expression: bundleMatch[0],
|
|
217
|
+
source: version ? 'version_catalog' : 'unresolved',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
154
222
|
for (const aliasMatch of text.matchAll(/\blibs\.([A-Za-z0-9_.-]+)/g)) {
|
|
223
|
+
if (aliasMatch[1].startsWith('bundles.'))
|
|
224
|
+
continue;
|
|
155
225
|
const alias = normalizeAlias(aliasMatch[1]);
|
|
156
|
-
const lib = catalog
|
|
226
|
+
const lib = catalog?.libraries.get(alias) ?? catalog?.libraries.get(aliasMatch[1]);
|
|
157
227
|
if (!lib?.module || !BILLING_MODULE.test(lib.module))
|
|
158
228
|
continue;
|
|
159
|
-
const version = lib.version ?? (lib.versionRef ? catalog
|
|
229
|
+
const version = lib.version ?? (lib.versionRef ? catalog?.versions.get(lib.versionRef) : undefined);
|
|
160
230
|
if (!evidence.some((row) => row.file === relative && row.expression === aliasMatch[0])) {
|
|
161
231
|
evidence.push({
|
|
162
232
|
file: relative,
|
|
@@ -187,11 +257,21 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
|
187
257
|
status = 'not_used';
|
|
188
258
|
summary = 'Google Play Billing dependency was not found in the scanned Gradle project.';
|
|
189
259
|
}
|
|
260
|
+
else if (!policy.scheduleCurrent || policy.minimumSupportedMajor === null) {
|
|
261
|
+
status = 'unresolved';
|
|
262
|
+
summary = `The official schedule embedded in this release ends at Billing Library ${policy.latestKnownMajor}; current policy must be refreshed from the source.`;
|
|
263
|
+
actions.push('Check the official Billing deprecation table and update Mimi Seed before relying on this result.');
|
|
264
|
+
}
|
|
190
265
|
else if (majors.some((major) => major < policy.minimumSupportedMajor)) {
|
|
191
266
|
status = 'blocker';
|
|
192
267
|
summary = `Billing Library ${detectedVersions.join(', ')} is below the submission minimum major ${policy.minimumSupportedMajor}.`;
|
|
193
268
|
actions.push(`Upgrade to a supported Billing Library before submitting a new app or update.`);
|
|
194
|
-
|
|
269
|
+
for (const major of [...new Set(majors.filter((value) => value < policy.minimumSupportedMajor))].sort()) {
|
|
270
|
+
const schedule = scheduleForMajor(major);
|
|
271
|
+
actions.push(schedule
|
|
272
|
+
? `Billing Library ${major}: standard deadline ${schedule.submissionDeadline}; extension deadline ${schedule.extensionDeadline} only if Google granted it in Play Console.`
|
|
273
|
+
: `Billing Library ${major}: its deadline predates the embedded official table; no active extension should be assumed.`);
|
|
274
|
+
}
|
|
195
275
|
}
|
|
196
276
|
else if (unresolved || majors.length === 0) {
|
|
197
277
|
status = 'unresolved';
|
|
@@ -201,7 +281,9 @@ export async function checkBillingCompliance(projectPath, now = new Date()) {
|
|
|
201
281
|
else if (majors.some((major) => major === policy.minimumSupportedMajor)) {
|
|
202
282
|
status = 'warning';
|
|
203
283
|
summary = `Billing Library ${detectedVersions.join(', ')} is currently supported but is the next major scheduled for deprecation.`;
|
|
204
|
-
|
|
284
|
+
const nextDeadline = scheduleForMajor(policy.minimumSupportedMajor);
|
|
285
|
+
if (nextDeadline)
|
|
286
|
+
actions.push(`Plan an upgrade before ${nextDeadline.submissionDeadline}.`);
|
|
205
287
|
}
|
|
206
288
|
else {
|
|
207
289
|
status = 'pass';
|
package/dist/checks/risks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { publisher, withEdit } from '../playstore/tools.js';
|
|
2
2
|
import { apiGet } from '../appstore/tools.js';
|
|
3
|
+
import { isNotFound } from '../appstore/http.js';
|
|
3
4
|
const APPSTORE_EDITABLE_STATES = 'PREPARE_FOR_SUBMISSION,DEVELOPER_REJECTED,METADATA_REJECTED,REJECTED';
|
|
4
5
|
export async function checkPlayStoreRisks(auth, packageName, language = 'ko-KR') {
|
|
5
6
|
const risks = [];
|
|
@@ -145,9 +146,19 @@ export async function checkAppStoreRisks(appId, expected = {}) {
|
|
|
145
146
|
'fields[appInfoLocalizations]': 'locale,privacyPolicyUrl,privacyPolicyText',
|
|
146
147
|
'limit': '200',
|
|
147
148
|
}), risks, 'PRIVACY', '개인정보 로컬라이제이션'),
|
|
148
|
-
safeGet(() =>
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
safeGet(async () => {
|
|
150
|
+
try {
|
|
151
|
+
return await apiGet(`/appInfos/${editable.id}/ageRatingDeclaration`, {
|
|
152
|
+
'fields[ageRatingDeclarations]': 'userGeneratedContent,socialMedia,socialMediaAgeRestricted,ageAssurance',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
// A 404 means the declaration is genuinely absent, not that the check is unknown.
|
|
157
|
+
if (isNotFound(error))
|
|
158
|
+
return { data: null };
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}, risks, 'AGE_RATING_SOCIAL', '연령등급 소셜 미디어 응답'),
|
|
151
162
|
]);
|
|
152
163
|
if (localizations) {
|
|
153
164
|
const locs = (localizations.data ?? []);
|
|
@@ -175,7 +186,18 @@ export async function checkAppStoreRisks(appId, expected = {}) {
|
|
|
175
186
|
detail: 'socialMediaAgeRestricted=true는 socialMedia=true인 앱에서만 의미가 있습니다.',
|
|
176
187
|
});
|
|
177
188
|
}
|
|
178
|
-
if (
|
|
189
|
+
if (declaration.socialMediaAgeRestricted === true && declaration.ageAssurance !== true) {
|
|
190
|
+
risks.push({
|
|
191
|
+
level: 'blocker',
|
|
192
|
+
code: 'SOCIAL_MEDIA_AGE_ASSURANCE_MISSING',
|
|
193
|
+
title: '13세 미만 소셜 기능 제한에 연령 확인이 없음',
|
|
194
|
+
detail: 'Apple은 socialMediaAgeRestricted=true인 경우 최소한 Declared Age Range API로 연령 범위를 확인하도록 요구합니다. ageAssurance 응답과 실제 구현을 확인하세요.',
|
|
195
|
+
fixUrl: 'https://developer.apple.com/help/app-store-connect/reference/app-information/age-ratings-values-and-definitions',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (expected.socialMedia !== undefined
|
|
199
|
+
&& declaration.socialMedia !== undefined
|
|
200
|
+
&& declaration.socialMedia !== expected.socialMedia) {
|
|
179
201
|
risks.push({
|
|
180
202
|
level: 'blocker',
|
|
181
203
|
code: 'SOCIAL_MEDIA_CAPABILITY_MISMATCH',
|
|
@@ -184,6 +206,7 @@ export async function checkAppStoreRisks(appId, expected = {}) {
|
|
|
184
206
|
});
|
|
185
207
|
}
|
|
186
208
|
if (expected.socialMediaAgeRestricted !== undefined
|
|
209
|
+
&& declaration.socialMediaAgeRestricted !== undefined
|
|
187
210
|
&& declaration.socialMediaAgeRestricted !== expected.socialMediaAgeRestricted) {
|
|
188
211
|
risks.push({
|
|
189
212
|
level: 'blocker',
|
|
@@ -18,26 +18,14 @@ interface RemoteConfigTemplate {
|
|
|
18
18
|
updateType?: string;
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
-
interface
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
startTime?: string;
|
|
26
|
-
endTime?: string;
|
|
27
|
-
lastUpdateTime?: string;
|
|
28
|
-
definition?: unknown;
|
|
29
|
-
}>;
|
|
30
|
-
}
|
|
31
|
-
interface RolloutList {
|
|
32
|
-
rollouts?: Array<{
|
|
33
|
-
name?: string;
|
|
34
|
-
state?: string;
|
|
35
|
-
startTime?: string;
|
|
36
|
-
endTime?: string;
|
|
37
|
-
lastUpdateTime?: string;
|
|
38
|
-
definition?: unknown;
|
|
39
|
-
}>;
|
|
21
|
+
interface PagedItems<T> {
|
|
22
|
+
items: T[];
|
|
23
|
+
pages: number;
|
|
24
|
+
truncated: boolean;
|
|
40
25
|
}
|
|
26
|
+
export declare function countStates(items: Array<{
|
|
27
|
+
state?: string;
|
|
28
|
+
}>): Record<string, number>;
|
|
41
29
|
export declare function estimateRemoteConfigDailyCost(fetches: number): number;
|
|
42
30
|
export declare function remoteConfigUsageLevel(fetches: number): 'ok' | 'warning' | 'critical';
|
|
43
31
|
export declare function getRemoteConfigOverview(auth: OAuth2Client, input: {
|
|
@@ -87,6 +75,13 @@ export declare function getRemoteConfigOverview(auth: OAuth2Client, input: {
|
|
|
87
75
|
date: string;
|
|
88
76
|
fetches: number;
|
|
89
77
|
} | null;
|
|
78
|
+
peak: {
|
|
79
|
+
utilizationPercent: number;
|
|
80
|
+
level: "ok" | "warning" | "critical";
|
|
81
|
+
projectedStandardDailyCostUsd: number;
|
|
82
|
+
date: string;
|
|
83
|
+
fetches: number;
|
|
84
|
+
} | null;
|
|
90
85
|
};
|
|
91
86
|
template: OptionalResult<RemoteConfigTemplate> | {
|
|
92
87
|
available: boolean;
|
|
@@ -103,29 +98,65 @@ export declare function getRemoteConfigOverview(auth: OAuth2Client, input: {
|
|
|
103
98
|
parameterGroupCount: number;
|
|
104
99
|
conditionCount: number;
|
|
105
100
|
};
|
|
106
|
-
experiments: OptionalResult<
|
|
101
|
+
experiments: OptionalResult<PagedItems<{
|
|
102
|
+
name?: string;
|
|
103
|
+
state?: string;
|
|
104
|
+
startTime?: string;
|
|
105
|
+
endTime?: string;
|
|
106
|
+
lastUpdateTime?: string;
|
|
107
|
+
definition?: unknown;
|
|
108
|
+
}>> | {
|
|
107
109
|
available: boolean;
|
|
108
110
|
total: number;
|
|
111
|
+
stateCounts: Record<string, number>;
|
|
109
112
|
active: {
|
|
110
|
-
name
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
name: string | undefined;
|
|
114
|
+
displayName: string | undefined;
|
|
115
|
+
state: string | undefined;
|
|
116
|
+
startTime: string | undefined;
|
|
117
|
+
endTime: string | undefined;
|
|
118
|
+
lastUpdateTime: string | undefined;
|
|
116
119
|
}[];
|
|
120
|
+
recent: {
|
|
121
|
+
name: string | undefined;
|
|
122
|
+
displayName: string | undefined;
|
|
123
|
+
state: string | undefined;
|
|
124
|
+
startTime: string | undefined;
|
|
125
|
+
endTime: string | undefined;
|
|
126
|
+
lastUpdateTime: string | undefined;
|
|
127
|
+
}[];
|
|
128
|
+
pages: number;
|
|
129
|
+
truncated: boolean;
|
|
117
130
|
};
|
|
118
|
-
rollouts: OptionalResult<
|
|
131
|
+
rollouts: OptionalResult<PagedItems<{
|
|
132
|
+
name?: string;
|
|
133
|
+
state?: string;
|
|
134
|
+
startTime?: string;
|
|
135
|
+
endTime?: string;
|
|
136
|
+
lastUpdateTime?: string;
|
|
137
|
+
definition?: unknown;
|
|
138
|
+
}>> | {
|
|
119
139
|
available: boolean;
|
|
120
140
|
total: number;
|
|
141
|
+
stateCounts: Record<string, number>;
|
|
121
142
|
active: {
|
|
122
|
-
name
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
143
|
+
name: string | undefined;
|
|
144
|
+
displayName: string | undefined;
|
|
145
|
+
state: string | undefined;
|
|
146
|
+
startTime: string | undefined;
|
|
147
|
+
endTime: string | undefined;
|
|
148
|
+
lastUpdateTime: string | undefined;
|
|
149
|
+
}[];
|
|
150
|
+
recent: {
|
|
151
|
+
name: string | undefined;
|
|
152
|
+
displayName: string | undefined;
|
|
153
|
+
state: string | undefined;
|
|
154
|
+
startTime: string | undefined;
|
|
155
|
+
endTime: string | undefined;
|
|
156
|
+
lastUpdateTime: string | undefined;
|
|
128
157
|
}[];
|
|
158
|
+
pages: number;
|
|
159
|
+
truncated: boolean;
|
|
129
160
|
};
|
|
130
161
|
warnings: string[];
|
|
131
162
|
}>;
|
|
@@ -3,6 +3,14 @@ import { getBillingInfo } from '../billing/tools.js';
|
|
|
3
3
|
const FREE_DAILY_FETCHES = 100_000;
|
|
4
4
|
const FIRST_PAID_TIER_END = 10_000_000;
|
|
5
5
|
const REMOTE_CONFIG_BASE = 'https://firebaseremoteconfig.googleapis.com/v1';
|
|
6
|
+
export function countStates(items) {
|
|
7
|
+
const counts = {};
|
|
8
|
+
for (const item of items) {
|
|
9
|
+
const state = item.state ?? 'UNSPECIFIED';
|
|
10
|
+
counts[state] = (counts[state] ?? 0) + 1;
|
|
11
|
+
}
|
|
12
|
+
return counts;
|
|
13
|
+
}
|
|
6
14
|
async function optionalRequest(auth, url) {
|
|
7
15
|
try {
|
|
8
16
|
const response = await auth.request({ url });
|
|
@@ -15,6 +23,31 @@ async function optionalRequest(auth, url) {
|
|
|
15
23
|
};
|
|
16
24
|
}
|
|
17
25
|
}
|
|
26
|
+
async function optionalPagedRequest(auth, url, field) {
|
|
27
|
+
try {
|
|
28
|
+
const items = [];
|
|
29
|
+
let pageToken;
|
|
30
|
+
let pages = 0;
|
|
31
|
+
do {
|
|
32
|
+
const requestUrl = new URL(url);
|
|
33
|
+
requestUrl.searchParams.set('pageSize', '100');
|
|
34
|
+
if (pageToken)
|
|
35
|
+
requestUrl.searchParams.set('pageToken', pageToken);
|
|
36
|
+
const response = await auth.request({ url: requestUrl.toString() });
|
|
37
|
+
const data = response.data;
|
|
38
|
+
items.push(...(data[field] ?? []));
|
|
39
|
+
pageToken = data.nextPageToken;
|
|
40
|
+
pages += 1;
|
|
41
|
+
} while (pageToken && pages < 10);
|
|
42
|
+
return { available: true, data: { items, pages, truncated: Boolean(pageToken) } };
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
return {
|
|
46
|
+
available: false,
|
|
47
|
+
error: error instanceof Error ? error.message : String(error),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
18
51
|
function numericPoint(point) {
|
|
19
52
|
const raw = point.value?.int64Value ?? point.value?.doubleValue ?? 0;
|
|
20
53
|
const value = typeof raw === 'string' ? Number.parseInt(raw, 10) : raw;
|
|
@@ -52,12 +85,17 @@ async function fetchDailyUsage(auth, projectId, days) {
|
|
|
52
85
|
view: 'FULL',
|
|
53
86
|
pageSize: Math.max(days + 2, 10),
|
|
54
87
|
});
|
|
55
|
-
const
|
|
88
|
+
const points = (response.data.timeSeries ?? [])
|
|
56
89
|
.flatMap((series) => series.points ?? [])
|
|
57
90
|
.map((point) => ({
|
|
58
91
|
date: (point.interval?.endTime ?? end.toISOString()).slice(0, 10),
|
|
59
92
|
fetches: numericPoint(point),
|
|
60
|
-
}))
|
|
93
|
+
}));
|
|
94
|
+
const byDate = new Map();
|
|
95
|
+
for (const point of points)
|
|
96
|
+
byDate.set(point.date, (byDate.get(point.date) ?? 0) + point.fetches);
|
|
97
|
+
const rows = [...byDate.entries()]
|
|
98
|
+
.map(([date, fetches]) => ({ date, fetches }))
|
|
61
99
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
62
100
|
return { available: true, data: rows };
|
|
63
101
|
}
|
|
@@ -76,8 +114,8 @@ export async function getRemoteConfigOverview(auth, input) {
|
|
|
76
114
|
const parent = `${REMOTE_CONFIG_BASE}/projects/${encodedProject}/namespaces/${encodedNamespace}`;
|
|
77
115
|
const [template, experiments, rollouts, usage, billing] = await Promise.all([
|
|
78
116
|
optionalRequest(auth, `${parent}/remoteConfig`),
|
|
79
|
-
|
|
80
|
-
|
|
117
|
+
optionalPagedRequest(auth, `${parent}/experiments`, 'experiments'),
|
|
118
|
+
optionalPagedRequest(auth, `${parent}/rollouts`, 'rollouts'),
|
|
81
119
|
fetchDailyUsage(auth, input.projectId, days),
|
|
82
120
|
getBillingInfo(auth, input.projectId)
|
|
83
121
|
.then((data) => ({ available: true, data }))
|
|
@@ -88,8 +126,45 @@ export async function getRemoteConfigOverview(auth, input) {
|
|
|
88
126
|
]);
|
|
89
127
|
const daily = usage.data ?? [];
|
|
90
128
|
const latest = daily.at(-1);
|
|
91
|
-
const
|
|
92
|
-
const
|
|
129
|
+
const peak = daily.reduce((current, point) => !current || point.fetches > current.fetches ? point : current, undefined);
|
|
130
|
+
const withUsageProjection = (point) => ({
|
|
131
|
+
...point,
|
|
132
|
+
utilizationPercent: Number(((point.fetches / FREE_DAILY_FETCHES) * 100).toFixed(2)),
|
|
133
|
+
level: remoteConfigUsageLevel(point.fetches),
|
|
134
|
+
projectedStandardDailyCostUsd: Number(estimateRemoteConfigDailyCost(point.fetches).toFixed(4)),
|
|
135
|
+
});
|
|
136
|
+
const summarizeExperiment = (item) => ({
|
|
137
|
+
name: item.name,
|
|
138
|
+
displayName: item.definition?.displayName,
|
|
139
|
+
state: item.state,
|
|
140
|
+
startTime: item.startTime,
|
|
141
|
+
endTime: item.endTime,
|
|
142
|
+
lastUpdateTime: item.lastUpdateTime,
|
|
143
|
+
});
|
|
144
|
+
const summarizeRollout = (item) => ({
|
|
145
|
+
name: item.name,
|
|
146
|
+
displayName: item.definition?.displayName,
|
|
147
|
+
state: item.state,
|
|
148
|
+
startTime: item.startTime,
|
|
149
|
+
endTime: item.endTime,
|
|
150
|
+
lastUpdateTime: item.lastUpdateTime,
|
|
151
|
+
});
|
|
152
|
+
const activeExperiments = experiments.data?.items
|
|
153
|
+
.filter((item) => item.state === 'RUNNING')
|
|
154
|
+
.map(summarizeExperiment) ?? [];
|
|
155
|
+
const activeRollouts = rollouts.data?.items
|
|
156
|
+
.filter((item) => item.state === 'RUNNING')
|
|
157
|
+
.map(summarizeRollout) ?? [];
|
|
158
|
+
const recentExperiments = [...(experiments.data?.items ?? [])]
|
|
159
|
+
.sort((left, right) => (right.lastUpdateTime ?? right.endTime ?? right.startTime ?? '')
|
|
160
|
+
.localeCompare(left.lastUpdateTime ?? left.endTime ?? left.startTime ?? ''))
|
|
161
|
+
.slice(0, 20)
|
|
162
|
+
.map(summarizeExperiment);
|
|
163
|
+
const recentRollouts = [...(rollouts.data?.items ?? [])]
|
|
164
|
+
.sort((left, right) => (right.lastUpdateTime ?? right.endTime ?? right.startTime ?? '')
|
|
165
|
+
.localeCompare(left.lastUpdateTime ?? left.endTime ?? left.startTime ?? ''))
|
|
166
|
+
.slice(0, 20)
|
|
167
|
+
.map(summarizeRollout);
|
|
93
168
|
return {
|
|
94
169
|
projectId: input.projectId,
|
|
95
170
|
namespace,
|
|
@@ -113,14 +188,8 @@ export async function getRemoteConfigOverview(auth, input) {
|
|
|
113
188
|
available: true,
|
|
114
189
|
metric: 'firebaseremoteconfig.googleapis.com/project/fetch_request_count',
|
|
115
190
|
days: daily,
|
|
116
|
-
latest: latest
|
|
117
|
-
|
|
118
|
-
...latest,
|
|
119
|
-
utilizationPercent: Number(((latest.fetches / FREE_DAILY_FETCHES) * 100).toFixed(2)),
|
|
120
|
-
level: remoteConfigUsageLevel(latest.fetches),
|
|
121
|
-
projectedStandardDailyCostUsd: Number(estimateRemoteConfigDailyCost(latest.fetches).toFixed(4)),
|
|
122
|
-
}
|
|
123
|
-
: null,
|
|
191
|
+
latest: latest ? withUsageProjection(latest) : null,
|
|
192
|
+
peak: peak ? withUsageProjection(peak) : null,
|
|
124
193
|
}
|
|
125
194
|
: usage,
|
|
126
195
|
template: template.available
|
|
@@ -135,26 +204,36 @@ export async function getRemoteConfigOverview(auth, input) {
|
|
|
135
204
|
experiments: experiments.available
|
|
136
205
|
? {
|
|
137
206
|
available: true,
|
|
138
|
-
total: experiments.data?.
|
|
207
|
+
total: experiments.data?.items.length ?? 0,
|
|
208
|
+
stateCounts: countStates(experiments.data?.items ?? []),
|
|
139
209
|
active: activeExperiments,
|
|
210
|
+
recent: recentExperiments,
|
|
211
|
+
pages: experiments.data?.pages ?? 0,
|
|
212
|
+
truncated: experiments.data?.truncated ?? false,
|
|
140
213
|
}
|
|
141
214
|
: experiments,
|
|
142
215
|
rollouts: rollouts.available
|
|
143
216
|
? {
|
|
144
217
|
available: true,
|
|
145
|
-
total: rollouts.data?.
|
|
218
|
+
total: rollouts.data?.items.length ?? 0,
|
|
219
|
+
stateCounts: countStates(rollouts.data?.items ?? []),
|
|
146
220
|
active: activeRollouts,
|
|
221
|
+
recent: recentRollouts,
|
|
222
|
+
pages: rollouts.data?.pages ?? 0,
|
|
223
|
+
truncated: rollouts.data?.truncated ?? false,
|
|
147
224
|
}
|
|
148
225
|
: rollouts,
|
|
149
226
|
warnings: [
|
|
150
|
-
...(
|
|
151
|
-
? [`
|
|
227
|
+
...(peak && remoteConfigUsageLevel(peak.fetches) !== 'ok'
|
|
228
|
+
? [`Peak daily fetch usage is ${remoteConfigUsageLevel(peak.fetches)} at ${peak.fetches.toLocaleString()} requests on ${peak.date}.`]
|
|
152
229
|
: []),
|
|
153
|
-
...(
|
|
230
|
+
...(peak && peak.fetches >= FREE_DAILY_FETCHES && billing.available && !billing.data.billingEnabled
|
|
154
231
|
? ['Spark projects risk throttling above 100,000 daily fetches after the applicable grace period; upgrade to Blaze for uninterrupted overage.']
|
|
155
232
|
: []),
|
|
156
233
|
...(!usage.available ? ['Cloud Monitoring usage is unavailable; no fetch count was estimated.'] : []),
|
|
157
234
|
...(!billing.available ? ['Billing plan could not be verified; cost/throttling interpretation is incomplete.'] : []),
|
|
235
|
+
...(experiments.data?.truncated ? ['Experiment list exceeded 1,000 items and was truncated.'] : []),
|
|
236
|
+
...(rollouts.data?.truncated ? ['Rollout list exceeded 1,000 items and was truncated.'] : []),
|
|
158
237
|
],
|
|
159
238
|
};
|
|
160
239
|
}
|
package/package.json
CHANGED