@yoonion/mimi-seed-mcp 0.2.0 → 0.3.1
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 +100 -20
- package/dist/ai/client.d.ts +4 -0
- package/dist/ai/client.js +30 -0
- package/dist/ai/notes.d.ts +22 -0
- package/dist/ai/notes.js +67 -0
- package/dist/ai/review.d.ts +15 -0
- package/dist/ai/review.js +60 -0
- package/dist/appstore/screenshots.d.ts +15 -0
- package/dist/appstore/screenshots.js +163 -0
- package/dist/appstore/tools.d.ts +15 -0
- package/dist/appstore/tools.js +48 -1
- package/dist/auth/google-auth.d.ts +15 -1
- package/dist/auth/google-auth.js +46 -7
- package/dist/checks/risks.d.ts +12 -0
- package/dist/checks/risks.js +123 -0
- package/dist/checks/screenshots.d.ts +68 -0
- package/dist/checks/screenshots.js +168 -0
- package/dist/index.js +260 -1
- package/dist/playstore/tools.d.ts +42 -7
- package/dist/playstore/tools.js +147 -9
- package/package.json +6 -5
package/dist/auth/google-auth.js
CHANGED
|
@@ -92,10 +92,23 @@ export function getAuthenticatedClient() {
|
|
|
92
92
|
});
|
|
93
93
|
return client;
|
|
94
94
|
}
|
|
95
|
+
// 동시 실행 방지용 — 활성 콜백 서버 참조
|
|
96
|
+
let activeAuthServer = null;
|
|
95
97
|
/**
|
|
96
|
-
*
|
|
98
|
+
* OAuth 플로우 시작.
|
|
99
|
+
* URL과 대기 Promise를 즉시 반환. localhost:9876 콜백 서버는 백그라운드로 실행.
|
|
100
|
+
* 호출자가 URL을 사용자에게 전달하거나 `open()`을 직접 호출.
|
|
101
|
+
* `wait` Promise: 토큰 저장 시 resolve, 타임아웃/에러 시 reject.
|
|
102
|
+
* 재호출 시 기존 세션 자동 정리.
|
|
97
103
|
*/
|
|
98
|
-
export
|
|
104
|
+
export function startAuth(clientId, clientSecret, options = {}) {
|
|
105
|
+
if (activeAuthServer) {
|
|
106
|
+
try {
|
|
107
|
+
activeAuthServer.close();
|
|
108
|
+
}
|
|
109
|
+
catch { /* noop */ }
|
|
110
|
+
activeAuthServer = null;
|
|
111
|
+
}
|
|
99
112
|
saveCredentials(clientId, clientSecret);
|
|
100
113
|
const oauth2Client = createOAuth2Client(clientId, clientSecret);
|
|
101
114
|
const authUrl = oauth2Client.generateAuthUrl({
|
|
@@ -103,7 +116,7 @@ export async function login(clientId, clientSecret) {
|
|
|
103
116
|
scope: SCOPES,
|
|
104
117
|
prompt: 'consent',
|
|
105
118
|
});
|
|
106
|
-
|
|
119
|
+
const wait = new Promise((resolve, reject) => {
|
|
107
120
|
const server = http.createServer(async (req, res) => {
|
|
108
121
|
try {
|
|
109
122
|
const url = new URL(req.url, `http://localhost:9876`);
|
|
@@ -139,21 +152,47 @@ export async function login(clientId, clientSecret) {
|
|
|
139
152
|
</body></html>
|
|
140
153
|
`);
|
|
141
154
|
server.close();
|
|
155
|
+
activeAuthServer = null;
|
|
142
156
|
resolve(stored);
|
|
143
157
|
}
|
|
144
158
|
catch (err) {
|
|
145
159
|
res.writeHead(500);
|
|
146
160
|
res.end('Auth error');
|
|
147
|
-
|
|
161
|
+
try {
|
|
162
|
+
server.close();
|
|
163
|
+
}
|
|
164
|
+
catch { /* noop */ }
|
|
165
|
+
activeAuthServer = null;
|
|
148
166
|
reject(err);
|
|
149
167
|
}
|
|
150
168
|
});
|
|
151
169
|
server.on('error', (err) => {
|
|
152
|
-
reject(new Error(`포트 9876이 이미 사용
|
|
170
|
+
reject(new Error(`포트 9876이 이미 사용 중이거나 에러: ${err.message}`));
|
|
153
171
|
});
|
|
154
172
|
server.listen(9876, () => {
|
|
155
|
-
|
|
156
|
-
open(authUrl);
|
|
173
|
+
activeAuthServer = server;
|
|
157
174
|
});
|
|
175
|
+
const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
if (server.listening) {
|
|
178
|
+
try {
|
|
179
|
+
server.close();
|
|
180
|
+
}
|
|
181
|
+
catch { /* noop */ }
|
|
182
|
+
activeAuthServer = null;
|
|
183
|
+
reject(new Error(`Auth timeout (${Math.round(timeoutMs / 1000)}s).`));
|
|
184
|
+
}
|
|
185
|
+
}, timeoutMs);
|
|
158
186
|
});
|
|
187
|
+
return { url: authUrl, wait };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Interactive login — opens browser, waits for callback.
|
|
191
|
+
* startAuth() 래퍼 — CLI에서 사용.
|
|
192
|
+
*/
|
|
193
|
+
export async function login(clientId, clientSecret) {
|
|
194
|
+
const { url, wait } = startAuth(clientId, clientSecret);
|
|
195
|
+
console.log('🔐 브라우저에서 Google 로그인 중...');
|
|
196
|
+
open(url);
|
|
197
|
+
return wait;
|
|
159
198
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { OAuth2Client } from 'google-auth-library';
|
|
2
|
+
export type RiskLevel = 'blocker' | 'warning' | 'info';
|
|
3
|
+
export interface SubmissionRisk {
|
|
4
|
+
level: RiskLevel;
|
|
5
|
+
code: string;
|
|
6
|
+
title: string;
|
|
7
|
+
detail: string;
|
|
8
|
+
fixUrl?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function checkPlayStoreRisks(auth: OAuth2Client, packageName: string, language?: string): Promise<SubmissionRisk[]>;
|
|
11
|
+
export declare function checkAppStoreRisks(appId: string): Promise<SubmissionRisk[]>;
|
|
12
|
+
export declare function formatRisks(risks: SubmissionRisk[], platform: string): string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { publisher, withEdit } from '../playstore/tools.js';
|
|
2
|
+
import { apiGet } from '../appstore/tools.js';
|
|
3
|
+
const APPSTORE_EDITABLE_STATES = 'PREPARE_FOR_SUBMISSION,WAITING_FOR_REVIEW';
|
|
4
|
+
export async function checkPlayStoreRisks(auth, packageName, language = 'ko-KR') {
|
|
5
|
+
const risks = [];
|
|
6
|
+
await withEdit(auth, packageName, async (editId) => {
|
|
7
|
+
const [listing, phoneScreenshots, icons, details, tracks] = await Promise.all([
|
|
8
|
+
publisher().edits.listings.get({ auth, packageName, editId, language }).catch(() => null),
|
|
9
|
+
publisher().edits.images.list({ auth, packageName, editId, language, imageType: 'phoneScreenshots' }).catch(() => null),
|
|
10
|
+
publisher().edits.images.list({ auth, packageName, editId, language, imageType: 'icon' }).catch(() => null),
|
|
11
|
+
publisher().edits.details.get({ auth, packageName, editId }).catch(() => null),
|
|
12
|
+
publisher().edits.tracks.list({ auth, packageName, editId }).catch(() => null),
|
|
13
|
+
]);
|
|
14
|
+
if (!listing?.data) {
|
|
15
|
+
risks.push({ level: 'blocker', code: 'NO_LISTING', title: `${language} 리스팅 없음`, detail: 'Play Console에서 스토어 정보를 입력하세요.', fixUrl: 'https://play.google.com/console/developers' });
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
const { title, shortDescription, fullDescription } = listing.data;
|
|
19
|
+
if (!title || title.length < 2) {
|
|
20
|
+
risks.push({ level: 'blocker', code: 'NO_TITLE', title: '앱 이름 없음', detail: '제목은 필수 항목입니다.' });
|
|
21
|
+
}
|
|
22
|
+
else if (title.length > 50) {
|
|
23
|
+
risks.push({ level: 'warning', code: 'TITLE_TOO_LONG', title: '앱 이름 50자 초과', detail: `현재 ${title.length}자. Google 가이드라인: 50자 이내.` });
|
|
24
|
+
}
|
|
25
|
+
if (!shortDescription || shortDescription.length < 10) {
|
|
26
|
+
risks.push({ level: 'warning', code: 'NO_SHORT_DESC', title: '짧은 설명 없음', detail: '짧은 설명은 검색 노출에 영향을 줍니다.' });
|
|
27
|
+
}
|
|
28
|
+
if (!fullDescription || fullDescription.length < 50) {
|
|
29
|
+
risks.push({ level: 'blocker', code: 'NO_FULL_DESC', title: '전체 설명 없음', detail: '전체 설명은 필수 항목입니다.' });
|
|
30
|
+
}
|
|
31
|
+
else if (fullDescription.length > 4000) {
|
|
32
|
+
risks.push({ level: 'warning', code: 'DESC_TOO_LONG', title: '설명 4000자 초과', detail: `현재 ${fullDescription.length}자. 4000자 이내로 줄이세요.` });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const screenCount = phoneScreenshots?.data?.images?.length ?? 0;
|
|
36
|
+
if (screenCount === 0) {
|
|
37
|
+
risks.push({ level: 'blocker', code: 'NO_SCREENSHOTS', title: '전화 스크린샷 없음', detail: '최소 2장 이상의 스크린샷이 필요합니다.' });
|
|
38
|
+
}
|
|
39
|
+
else if (screenCount < 2) {
|
|
40
|
+
risks.push({ level: 'warning', code: 'FEW_SCREENSHOTS', title: `스크린샷 ${screenCount}장 (권장: 4~8장)`, detail: '더 많은 스크린샷이 전환율을 높입니다.' });
|
|
41
|
+
}
|
|
42
|
+
if (!icons?.data?.images?.length) {
|
|
43
|
+
risks.push({ level: 'blocker', code: 'NO_ICON', title: '앱 아이콘 없음', detail: '512×512px PNG 아이콘이 필요합니다.' });
|
|
44
|
+
}
|
|
45
|
+
if (!details?.data?.contactEmail) {
|
|
46
|
+
risks.push({ level: 'warning', code: 'NO_CONTACT_EMAIL', title: '개발자 이메일 없음', detail: 'Play Console에서 개발자 연락처를 설정하세요.' });
|
|
47
|
+
}
|
|
48
|
+
const hasBuild = (tracks?.data?.tracks ?? []).some((t) => (t.releases ?? []).some((r) => r.versionCodes && r.versionCodes.length > 0));
|
|
49
|
+
if (!hasBuild) {
|
|
50
|
+
risks.push({ level: 'blocker', code: 'NO_BUILD', title: '업로드된 빌드 없음', detail: '내부 테스트 트랙에 APK/AAB를 먼저 업로드하세요.' });
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
});
|
|
54
|
+
return risks;
|
|
55
|
+
}
|
|
56
|
+
export async function checkAppStoreRisks(appId) {
|
|
57
|
+
const risks = [];
|
|
58
|
+
const versions = await apiGet(`/apps/${appId}/appStoreVersions`, {
|
|
59
|
+
'filter[appStoreState]': APPSTORE_EDITABLE_STATES,
|
|
60
|
+
'limit': '5',
|
|
61
|
+
}).catch(() => null);
|
|
62
|
+
if (!versions?.data?.length) {
|
|
63
|
+
risks.push({ level: 'warning', code: 'NO_EDITABLE_VERSION', title: '편집 가능한 버전 없음', detail: 'App Store Connect에서 새 버전을 만드세요.', fixUrl: 'https://appstoreconnect.apple.com' });
|
|
64
|
+
return risks;
|
|
65
|
+
}
|
|
66
|
+
const versionId = versions.data[0].id;
|
|
67
|
+
const [locs, builds, appInfo] = await Promise.all([
|
|
68
|
+
apiGet(`/appStoreVersions/${versionId}/appStoreVersionLocalizations`).catch(() => null),
|
|
69
|
+
apiGet(`/apps/${appId}/builds`, { 'limit': '5', 'sort': '-uploadedDate' }).catch(() => null),
|
|
70
|
+
apiGet(`/apps/${appId}`, { 'fields[apps]': 'privacyPolicyUrl' }).catch(() => null),
|
|
71
|
+
]);
|
|
72
|
+
if (!locs?.data?.length) {
|
|
73
|
+
risks.push({ level: 'blocker', code: 'NO_LOCALIZATIONS', title: '로컬라이제이션 없음', detail: '메타데이터를 입력하세요.' });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
for (const loc of locs.data) {
|
|
77
|
+
const { locale, description, whatsNew, keywords } = loc.attributes ?? {};
|
|
78
|
+
if (!description)
|
|
79
|
+
risks.push({ level: 'blocker', code: `NO_DESC_${locale}`, title: `${locale} 설명 없음`, detail: '앱 설명은 필수 항목입니다.' });
|
|
80
|
+
if (!whatsNew)
|
|
81
|
+
risks.push({ level: 'warning', code: `NO_WHATS_NEW_${locale}`, title: `${locale} 새로운 기능 없음`, detail: '릴리즈 노트를 입력하면 다운로드 전환율이 높아집니다.' });
|
|
82
|
+
if (!keywords)
|
|
83
|
+
risks.push({ level: 'warning', code: `NO_KEYWORDS_${locale}`, title: `${locale} 키워드 없음`, detail: '키워드는 검색 노출에 직접 영향을 줍니다.' });
|
|
84
|
+
}
|
|
85
|
+
// screenshots depend on locs being present
|
|
86
|
+
const locId = locs.data[0].id;
|
|
87
|
+
const screenshots = await apiGet(`/appStoreVersionLocalizations/${locId}/appScreenshotSets`).catch(() => null);
|
|
88
|
+
if (!screenshots?.data?.length) {
|
|
89
|
+
risks.push({ level: 'blocker', code: 'NO_SCREENSHOTS', title: '스크린샷 없음', detail: 'iPhone 6.5" 또는 6.9" 스크린샷이 필요합니다.' });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!builds?.data?.length) {
|
|
93
|
+
risks.push({ level: 'blocker', code: 'NO_BUILD', title: 'TestFlight 빌드 없음', detail: 'Xcode 또는 Fastlane으로 빌드를 업로드하세요.' });
|
|
94
|
+
}
|
|
95
|
+
if (!appInfo?.data?.attributes?.privacyPolicyUrl) {
|
|
96
|
+
risks.push({ level: 'blocker', code: 'NO_PRIVACY', title: '개인정보처리방침 URL 없음', detail: 'App Store 가이드라인 5.1.1 — 필수 항목입니다.' });
|
|
97
|
+
}
|
|
98
|
+
return risks;
|
|
99
|
+
}
|
|
100
|
+
export function formatRisks(risks, platform) {
|
|
101
|
+
if (risks.length === 0)
|
|
102
|
+
return `✅ ${platform} — 제출 위험 요소 없음`;
|
|
103
|
+
const blockers = risks.filter((r) => r.level === 'blocker');
|
|
104
|
+
const warnings = risks.filter((r) => r.level === 'warning');
|
|
105
|
+
const lines = [`📊 ${platform} 제출 위험 분석 (${risks.length}건)\n`];
|
|
106
|
+
if (blockers.length) {
|
|
107
|
+
lines.push(`🚫 블로커 ${blockers.length}건 — 반드시 수정해야 제출 가능:`);
|
|
108
|
+
for (const r of blockers) {
|
|
109
|
+
lines.push(` • [${r.code}] ${r.title}\n ${r.detail}${r.fixUrl ? `\n → ${r.fixUrl}` : ''}`);
|
|
110
|
+
}
|
|
111
|
+
lines.push('');
|
|
112
|
+
}
|
|
113
|
+
if (warnings.length) {
|
|
114
|
+
lines.push(`⚠ 경고 ${warnings.length}건 — 강력 권장:`);
|
|
115
|
+
for (const r of warnings) {
|
|
116
|
+
lines.push(` • [${r.code}] ${r.title}\n ${r.detail}`);
|
|
117
|
+
}
|
|
118
|
+
lines.push('');
|
|
119
|
+
}
|
|
120
|
+
if (blockers.length === 0)
|
|
121
|
+
lines.push('✅ 블로커 없음 — 경고 항목 검토 후 제출 가능');
|
|
122
|
+
return lines.join('\n');
|
|
123
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface ScreenshotSpec {
|
|
2
|
+
label: string;
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
minWidth?: number;
|
|
6
|
+
maxWidth?: number;
|
|
7
|
+
minHeight?: number;
|
|
8
|
+
maxHeight?: number;
|
|
9
|
+
maxBytes: number;
|
|
10
|
+
}
|
|
11
|
+
export interface ValidationResult {
|
|
12
|
+
filePath: string;
|
|
13
|
+
fileName: string;
|
|
14
|
+
valid: boolean;
|
|
15
|
+
issues: string[];
|
|
16
|
+
info: {
|
|
17
|
+
sizeBytes: number;
|
|
18
|
+
detectedWidth?: number;
|
|
19
|
+
detectedHeight?: number;
|
|
20
|
+
};
|
|
21
|
+
matchedSpec?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare const APPSTORE_SPECS: Record<string, ScreenshotSpec>;
|
|
24
|
+
export declare const PLAYSTORE_SPECS: {
|
|
25
|
+
phoneScreenshots: {
|
|
26
|
+
label: string;
|
|
27
|
+
minWidth: number;
|
|
28
|
+
maxWidth: number;
|
|
29
|
+
minHeight: number;
|
|
30
|
+
maxHeight: number;
|
|
31
|
+
minAspect: number;
|
|
32
|
+
maxAspect: number;
|
|
33
|
+
maxBytes: number;
|
|
34
|
+
};
|
|
35
|
+
sevenInchScreenshots: {
|
|
36
|
+
label: string;
|
|
37
|
+
minWidth: number;
|
|
38
|
+
maxWidth: number;
|
|
39
|
+
minHeight: number;
|
|
40
|
+
maxHeight: number;
|
|
41
|
+
minAspect: number;
|
|
42
|
+
maxAspect: number;
|
|
43
|
+
maxBytes: number;
|
|
44
|
+
};
|
|
45
|
+
tenInchScreenshots: {
|
|
46
|
+
label: string;
|
|
47
|
+
minWidth: number;
|
|
48
|
+
maxWidth: number;
|
|
49
|
+
minHeight: number;
|
|
50
|
+
maxHeight: number;
|
|
51
|
+
minAspect: number;
|
|
52
|
+
maxAspect: number;
|
|
53
|
+
maxBytes: number;
|
|
54
|
+
};
|
|
55
|
+
featureGraphic: {
|
|
56
|
+
label: string;
|
|
57
|
+
minWidth: number;
|
|
58
|
+
maxWidth: number;
|
|
59
|
+
minHeight: number;
|
|
60
|
+
maxHeight: number;
|
|
61
|
+
minAspect: number;
|
|
62
|
+
maxAspect: number;
|
|
63
|
+
maxBytes: number;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
export declare function validateAppStoreScreenshots(filePaths: string[], expectedDisplayType?: string): ValidationResult[];
|
|
67
|
+
export declare function validatePlayStoreScreenshots(filePaths: string[], imageType?: string): ValidationResult[];
|
|
68
|
+
export declare function formatValidationResults(results: ValidationResult[], platform: string): string;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const APPSTORE_SPECS = {
|
|
4
|
+
'APP_IPHONE_69': { label: 'iPhone 6.9"', width: 1320, height: 2868, maxBytes: 50 * 1024 * 1024 },
|
|
5
|
+
'APP_IPHONE_67': { label: 'iPhone 6.7"', width: 1290, height: 2796, maxBytes: 50 * 1024 * 1024 },
|
|
6
|
+
'APP_IPHONE_65': { label: 'iPhone 6.5"', width: 1242, height: 2688, maxBytes: 50 * 1024 * 1024 },
|
|
7
|
+
'APP_IPHONE_61': { label: 'iPhone 6.1"', width: 1170, height: 2532, maxBytes: 50 * 1024 * 1024 },
|
|
8
|
+
'APP_IPHONE_58': { label: 'iPhone 5.8"', width: 1125, height: 2436, maxBytes: 50 * 1024 * 1024 },
|
|
9
|
+
'APP_IPHONE_55': { label: 'iPhone 5.5"', width: 1242, height: 2208, maxBytes: 50 * 1024 * 1024 },
|
|
10
|
+
'APP_IPAD_PRO_3GEN_129': { label: 'iPad Pro 12.9" (3세대+)', width: 2064, height: 2752, maxBytes: 50 * 1024 * 1024 },
|
|
11
|
+
'APP_IPAD_PRO_3GEN_11': { label: 'iPad Pro 11"', width: 1668, height: 2388, maxBytes: 50 * 1024 * 1024 },
|
|
12
|
+
'APP_IPAD_PRO_129': { label: 'iPad Pro 12.9" (2세대)', width: 2048, height: 2732, maxBytes: 50 * 1024 * 1024 },
|
|
13
|
+
'APP_DESKTOP': { label: 'Mac', minWidth: 1280, maxWidth: 2560, minHeight: 800, maxHeight: 1600, width: 1440, height: 900, maxBytes: 50 * 1024 * 1024 },
|
|
14
|
+
};
|
|
15
|
+
export const PLAYSTORE_SPECS = {
|
|
16
|
+
phoneScreenshots: { label: '전화 스크린샷', minWidth: 320, maxWidth: 3840, minHeight: 320, maxHeight: 3840, minAspect: 0.5, maxAspect: 2.0, maxBytes: 8 * 1024 * 1024 },
|
|
17
|
+
sevenInchScreenshots: { label: '7인치 태블릿 스크린샷', minWidth: 320, maxWidth: 3840, minHeight: 320, maxHeight: 3840, minAspect: 0.5, maxAspect: 2.0, maxBytes: 8 * 1024 * 1024 },
|
|
18
|
+
tenInchScreenshots: { label: '10인치 태블릿 스크린샷', minWidth: 1080, maxWidth: 7680, minHeight: 1080, maxHeight: 7680, minAspect: 0.5, maxAspect: 2.0, maxBytes: 8 * 1024 * 1024 },
|
|
19
|
+
featureGraphic: { label: '특성 그래픽', minWidth: 1024, maxWidth: 1024, minHeight: 500, maxHeight: 500, minAspect: 2.048, maxAspect: 2.048, maxBytes: 1024 * 1024 },
|
|
20
|
+
};
|
|
21
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47];
|
|
22
|
+
function readPngDimensions(filePath) {
|
|
23
|
+
try {
|
|
24
|
+
const fd = fs.openSync(filePath, 'r');
|
|
25
|
+
const buf = Buffer.alloc(24);
|
|
26
|
+
fs.readSync(fd, buf, 0, 24, 0);
|
|
27
|
+
fs.closeSync(fd);
|
|
28
|
+
if (PNG_SIGNATURE.some((b, i) => buf[i] !== b))
|
|
29
|
+
return null;
|
|
30
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function readJpegDimensions(filePath) {
|
|
37
|
+
try {
|
|
38
|
+
// JPEG SOF markers typically appear within the first 64KB
|
|
39
|
+
const fd = fs.openSync(filePath, 'r');
|
|
40
|
+
const chunk = Buffer.alloc(65536);
|
|
41
|
+
const bytesRead = fs.readSync(fd, chunk, 0, chunk.length, 0);
|
|
42
|
+
fs.closeSync(fd);
|
|
43
|
+
const data = chunk.subarray(0, bytesRead);
|
|
44
|
+
if (data[0] !== 0xFF || data[1] !== 0xD8)
|
|
45
|
+
return null;
|
|
46
|
+
let i = 2;
|
|
47
|
+
while (i < data.length - 8) {
|
|
48
|
+
if (data[i] !== 0xFF)
|
|
49
|
+
break;
|
|
50
|
+
const marker = data[i + 1];
|
|
51
|
+
const length = data.readUInt16BE(i + 2);
|
|
52
|
+
if (marker === 0xC0 || marker === 0xC2) {
|
|
53
|
+
return { height: data.readUInt16BE(i + 5), width: data.readUInt16BE(i + 7) };
|
|
54
|
+
}
|
|
55
|
+
i += 2 + length;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function getImageDimensions(filePath) {
|
|
64
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
65
|
+
if (ext === '.png')
|
|
66
|
+
return readPngDimensions(filePath);
|
|
67
|
+
if (ext === '.jpg' || ext === '.jpeg')
|
|
68
|
+
return readJpegDimensions(filePath);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
export function validateAppStoreScreenshots(filePaths, expectedDisplayType) {
|
|
72
|
+
return filePaths.map((filePath) => {
|
|
73
|
+
const fileName = path.basename(filePath);
|
|
74
|
+
const issues = [];
|
|
75
|
+
if (!fs.existsSync(filePath)) {
|
|
76
|
+
return { filePath, fileName, valid: false, issues: ['파일 없음'], info: { sizeBytes: 0 } };
|
|
77
|
+
}
|
|
78
|
+
const sizeBytes = fs.statSync(filePath).size;
|
|
79
|
+
const dims = getImageDimensions(filePath);
|
|
80
|
+
const info = { sizeBytes, detectedWidth: dims?.width, detectedHeight: dims?.height };
|
|
81
|
+
if (!dims) {
|
|
82
|
+
return { filePath, fileName, valid: false, issues: ['PNG/JPEG가 아니거나 읽기 실패'], info };
|
|
83
|
+
}
|
|
84
|
+
let matchedSpec;
|
|
85
|
+
if (expectedDisplayType && APPSTORE_SPECS[expectedDisplayType]) {
|
|
86
|
+
const spec = APPSTORE_SPECS[expectedDisplayType];
|
|
87
|
+
if (sizeBytes > spec.maxBytes)
|
|
88
|
+
issues.push(`파일 크기 초과 (최대 ${spec.maxBytes / 1024 / 1024 | 0}MB)`);
|
|
89
|
+
const wOk = spec.minWidth ? dims.width >= spec.minWidth && dims.width <= (spec.maxWidth ?? Infinity) : dims.width === spec.width;
|
|
90
|
+
const hOk = spec.minHeight ? dims.height >= spec.minHeight && dims.height <= (spec.maxHeight ?? Infinity) : dims.height === spec.height;
|
|
91
|
+
if (!wOk || !hOk) {
|
|
92
|
+
const expected = spec.minWidth ? `${spec.minWidth}~${spec.maxWidth}×${spec.minHeight}~${spec.maxHeight}` : `${spec.width}×${spec.height}`;
|
|
93
|
+
issues.push(`해상도 불일치 (${spec.label}): 필요 ${expected}, 현재 ${dims.width}×${dims.height}`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
matchedSpec = spec.label;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
for (const [key, spec] of Object.entries(APPSTORE_SPECS)) {
|
|
101
|
+
// check both portrait and landscape orientations
|
|
102
|
+
const fw = dims.width, fh = dims.height;
|
|
103
|
+
const matches = (w, h) => spec.minWidth ? (w >= spec.minWidth && w <= (spec.maxWidth ?? Infinity) && h >= (spec.minHeight ?? 0) && h <= (spec.maxHeight ?? Infinity))
|
|
104
|
+
: (w === spec.width && h === spec.height) || (w === spec.height && h === spec.width);
|
|
105
|
+
if (matches(fw, fh)) {
|
|
106
|
+
matchedSpec = `${key} (${spec.label})`;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!matchedSpec) {
|
|
111
|
+
issues.push(`알 수 없는 해상도 ${dims.width}×${dims.height} — Apple 스펙과 일치하지 않음`);
|
|
112
|
+
}
|
|
113
|
+
if (sizeBytes > 50 * 1024 * 1024)
|
|
114
|
+
issues.push('파일 크기 50MB 초과');
|
|
115
|
+
}
|
|
116
|
+
return { filePath, fileName, valid: issues.length === 0, issues, info, matchedSpec };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
export function validatePlayStoreScreenshots(filePaths, imageType = 'phoneScreenshots') {
|
|
120
|
+
if (!(imageType in PLAYSTORE_SPECS)) {
|
|
121
|
+
return filePaths.map((fp) => ({
|
|
122
|
+
filePath: fp, fileName: path.basename(fp), valid: false,
|
|
123
|
+
issues: [`알 수 없는 imageType: ${imageType}. 유효값: ${Object.keys(PLAYSTORE_SPECS).join(', ')}`],
|
|
124
|
+
info: { sizeBytes: 0 },
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
const spec = PLAYSTORE_SPECS[imageType];
|
|
128
|
+
return filePaths.map((filePath) => {
|
|
129
|
+
const fileName = path.basename(filePath);
|
|
130
|
+
const issues = [];
|
|
131
|
+
if (!fs.existsSync(filePath)) {
|
|
132
|
+
return { filePath, fileName, valid: false, issues: ['파일 없음'], info: { sizeBytes: 0 } };
|
|
133
|
+
}
|
|
134
|
+
const sizeBytes = fs.statSync(filePath).size;
|
|
135
|
+
if (sizeBytes > spec.maxBytes) {
|
|
136
|
+
issues.push(`파일 크기 초과: ${(sizeBytes / 1024 / 1024).toFixed(1)}MB (최대 ${(spec.maxBytes / 1024 / 1024).toFixed(0)}MB)`);
|
|
137
|
+
}
|
|
138
|
+
const dims = getImageDimensions(filePath);
|
|
139
|
+
const info = { sizeBytes, detectedWidth: dims?.width, detectedHeight: dims?.height };
|
|
140
|
+
if (!dims)
|
|
141
|
+
return { filePath, fileName, valid: false, issues: ['PNG/JPEG가 아니거나 읽기 실패', ...issues], info };
|
|
142
|
+
if (dims.width < spec.minWidth || dims.width > spec.maxWidth) {
|
|
143
|
+
issues.push(`너비 범위 초과: ${dims.width}px (${spec.minWidth}~${spec.maxWidth}px 필요)`);
|
|
144
|
+
}
|
|
145
|
+
if (dims.height < spec.minHeight || dims.height > spec.maxHeight) {
|
|
146
|
+
issues.push(`높이 범위 초과: ${dims.height}px (${spec.minHeight}~${spec.maxHeight}px 필요)`);
|
|
147
|
+
}
|
|
148
|
+
const ar = dims.width / dims.height;
|
|
149
|
+
if (ar < spec.minAspect || ar > spec.maxAspect) {
|
|
150
|
+
issues.push(`종횡비 범위 초과: ${ar.toFixed(2)} (${spec.minAspect}~${spec.maxAspect} 필요)`);
|
|
151
|
+
}
|
|
152
|
+
return { filePath, fileName, valid: issues.length === 0, issues, info };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
export function formatValidationResults(results, platform) {
|
|
156
|
+
const passed = results.filter((r) => r.valid).length;
|
|
157
|
+
const lines = [`📐 ${platform} 스크린샷 검증: ${passed}/${results.length} 통과\n`];
|
|
158
|
+
for (const r of results) {
|
|
159
|
+
const dims = r.info.detectedWidth ? ` (${r.info.detectedWidth}×${r.info.detectedHeight})` : '';
|
|
160
|
+
const size = ` ${(r.info.sizeBytes / 1024).toFixed(0)}KB`;
|
|
161
|
+
lines.push(`${r.valid ? '✅' : '❌'} ${r.fileName}${dims}${size}`);
|
|
162
|
+
if (r.matchedSpec)
|
|
163
|
+
lines.push(` → ${r.matchedSpec}`);
|
|
164
|
+
for (const issue of r.issues)
|
|
165
|
+
lines.push(` ⚠ ${issue}`);
|
|
166
|
+
}
|
|
167
|
+
return lines.join('\n');
|
|
168
|
+
}
|