mobile-snap 1.0.1 → 1.0.4

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.html CHANGED
@@ -186,8 +186,14 @@
186
186
  gap: 1.5rem;
187
187
  }
188
188
 
189
+ .grid-4 {
190
+ display: grid;
191
+ grid-template-columns: repeat(4, 1fr);
192
+ gap: 1.5rem;
193
+ }
194
+
189
195
  @media (max-width: 768px) {
190
- .grid-2, .grid-3 {
196
+ .grid-2, .grid-3, .grid-4 {
191
197
  grid-template-columns: 1fr;
192
198
  }
193
199
  h1 {
@@ -597,6 +603,22 @@
597
603
  </header>
598
604
 
599
605
  <div class="container">
606
+ <!-- Contoh Output (Mockup Premium) -->
607
+ <section class="glass-card">
608
+ <h2>📱 Contoh Output (Mockup Premium)</h2>
609
+ <p style="color: var(--text-muted); margin-bottom: 1.5rem;">Visualisasi nyata dari tangkapan layar yang dibungkus otomatis ke dalam bingkai mockup perangkat premium menggunakan opsi <code>-m</code> atau <code>--mockup</code>:</p>
610
+ <div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap; margin-top: 1.5rem;">
611
+ <div style="text-align: center; max-width: 45%; min-width: 280px;">
612
+ <h4 style="margin-bottom: 0.5rem; color: #fff;">iOS (iPhone 6.7" Pro Max)</h4>
613
+ <img src="./assets/ios_example.png" alt="iOS Mockup Example" style="max-width: 100%; border-radius: 12px; border: 1px solid var(--border-card); box-shadow: 0 10px 30px rgba(0,0,0,0.5);" />
614
+ </div>
615
+ <div style="text-align: center; max-width: 45%; min-width: 280px;">
616
+ <h4 style="margin-bottom: 0.5rem; color: #fff;">Android Phone (Google Pixel 7)</h4>
617
+ <img src="./assets/android_example.png" alt="Android Mockup Example" style="max-width: 100%; border-radius: 12px; border: 1px solid var(--border-card); box-shadow: 0 10px 30px rgba(0,0,0,0.5);" />
618
+ </div>
619
+ </div>
620
+ </section>
621
+
600
622
  <!-- Arsitektur Utama -->
601
623
  <section class="glass-card">
602
624
  <h2>🏗️ Arsitektur Aliran Kerja</h2>
@@ -731,7 +753,7 @@
731
753
  <input type="text" id="input-paths" class="form-control" value="/" oninput="updateCommand()">
732
754
  </div>
733
755
  </div>
734
- <div class="grid-3">
756
+ <div class="grid-4">
735
757
  <div class="form-group">
736
758
  <label for="input-platform">Platform Target</label>
737
759
  <select id="input-platform" class="form-control" onchange="updateCommand()">
@@ -748,6 +770,10 @@
748
770
  <input type="checkbox" id="input-detect" onchange="updateCommand()" style="width: 20px; height: 20px; accent-color: var(--primary);">
749
771
  <label for="input-detect" style="margin-bottom: 0; cursor: pointer; color: var(--text-main); font-size: 0.85rem;">Detect Astro Pages (-d)</label>
750
772
  </div>
773
+ <div class="form-group" style="display: flex; align-items: center; gap: 0.5rem; margin-top: 1.5rem;">
774
+ <input type="checkbox" id="input-mockup" onchange="updateCommand()" style="width: 20px; height: 20px; accent-color: var(--primary);">
775
+ <label for="input-mockup" style="margin-bottom: 0; cursor: pointer; color: var(--text-main); font-size: 0.85rem;">Device Mockup Frame (-m)</label>
776
+ </div>
751
777
  </div>
752
778
  <div class="form-group" style="margin-top: 1rem;">
753
779
  <label for="input-output">Folder Output</label>
@@ -810,6 +836,36 @@
810
836
  <td>Memindai direktori folder proyek lokal (Astro/Next.js) untuk rute statis.</td>
811
837
  <td><code style="color: var(--secondary);">false</code></td>
812
838
  </tr>
839
+ <tr>
840
+ <td><span class="param-tag">--email</span></td>
841
+ <td>-</td>
842
+ <td>Email / Username untuk autentikasi otomatis jika halaman memerlukan login.</td>
843
+ <td>-</td>
844
+ </tr>
845
+ <tr>
846
+ <td><span class="param-tag">--password</span></td>
847
+ <td>-</td>
848
+ <td>Password untuk autentikasi otomatis jika halaman memerlukan login (input disensor).</td>
849
+ <td>-</td>
850
+ </tr>
851
+ <tr>
852
+ <td><span class="param-tag">--login-path</span></td>
853
+ <td>-</td>
854
+ <td>Jalur rute URL ke halaman login.</td>
855
+ <td><code style="color: var(--secondary);">"/login.html"</code></td>
856
+ </tr>
857
+ <tr>
858
+ <td><span class="param-tag">--html</span></td>
859
+ <td>-</td>
860
+ <td>Menambahkan ekstensi .html otomatis ke rute statis yang terdeteksi.</td>
861
+ <td><code style="color: var(--secondary);">false</code></td>
862
+ </tr>
863
+ <tr>
864
+ <td><span class="param-tag">--mockup</span></td>
865
+ <td><span class="param-tag">-m</span></td>
866
+ <td>Membungkus tangkapan layar dalam bingkai mockup perangkat (iPhone/Android) dengan status bar dan bayangan transparan.</td>
867
+ <td><code style="color: var(--secondary);">false</code></td>
868
+ </tr>
813
869
  </tbody>
814
870
  </table>
815
871
  </section>
@@ -856,6 +912,7 @@
856
912
  const platform = document.getElementById('input-platform').value;
857
913
  const crawl = document.getElementById('input-crawl').checked;
858
914
  const detect = document.getElementById('input-detect').checked;
915
+ const mockup = document.getElementById('input-mockup').checked;
859
916
 
860
917
  let command = `npx mobile-snap --url ${url}`;
861
918
  if (paths && paths !== '/') {
@@ -873,6 +930,9 @@
873
930
  if (detect) {
874
931
  command += ` --detect-pages`;
875
932
  }
933
+ if (mockup) {
934
+ command += ` --mockup`;
935
+ }
876
936
 
877
937
  document.getElementById('cmd-preview').innerText = command;
878
938
  }
package/README.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  MobileSnap adalah alat CLI (Command Line Interface) berbasis Node.js yang dirancang untuk mengotomatisasi pengambilan tangkapan layar (screenshot) App Store & Google Play Store dengan presisi piksel tinggi langsung dari server pengembangan lokal (seperti Astro, Next.js, React, atau Vue).
4
4
 
5
+ ### 📱 Contoh Output (Mockup Premium)
6
+
7
+ Berikut adalah visualisasi nyata dari tangkapan layar yang dibungkus otomatis ke dalam bingkai mockup perangkat premium (menggunakan opsi `-m` atau `--mockup`):
8
+
9
+ | iOS (iPhone 6.7" Pro Max) | Android Phone (Google Pixel 7) |
10
+ | :---: | :---: |
11
+ | ![iOS Mockup Example](./assets/ios_example.png) | ![Android Mockup Example](./assets/android_example.png) |
12
+
5
13
  ---
6
14
 
7
15
  ## 🏗️ Arsitektur Sistem
@@ -87,6 +95,11 @@ Aplikasi ini menerima opsi utama berikut:
87
95
  | `--platform`| `-l`| Platform target tangkapan layar. | `ios` | `ios`, `android`, `both` |
88
96
  | `--crawl` | `-c` | Mengaktifkan penelusuran (crawl) otomatis tautan internal di halaman beranda. | `false` | - |
89
97
  | `--detect-pages` | `-d` | Memindai direktori halaman lokal (`src/pages` atau `pages`) untuk rute statis. | `false` | - |
98
+ | `--email` | - | Email untuk autentikasi otomatis. | - | - |
99
+ | `--password` | - | Password untuk autentikasi otomatis (disensor di terminal). | - | - |
100
+ | `--login-path`| - | Jalur rute ke halaman login. | `/login.html` | - |
101
+ | `--html` | - | Otomatis menambahkan akhiran `.html` pada rute statis terdeteksi. | `false` | - |
102
+ | `--mockup` | `-m` | Membungkus tangkapan layar dalam bingkai mockup perangkat (iPhone/Android) yang premium dengan status bar dan bayangan transparan. | `false` | - |
90
103
 
91
104
  ### Contoh Perintah
92
105
 
@@ -95,9 +108,9 @@ Aplikasi ini menerima opsi utama berikut:
95
108
  npx mobile-snap --url http://localhost:4321
96
109
  ```
97
110
 
98
- #### 2. Pengambilan Halaman Android Saja
111
+ #### 2. Pengambilan Halaman Android Saja dengan Mockup Bingkai Perangkat
99
112
  ```powershell
100
- npx mobile-snap --url http://localhost:4321 --platform android
113
+ npx mobile-snap --url http://localhost:4321 --platform android --mockup
101
114
  ```
102
115
 
103
116
  #### 3. Pengambilan Rute Tertentu untuk 2 Platform Sekaligus
@@ -106,15 +119,60 @@ Mengambil gambar halaman utama `/` dan halaman `/scan` untuk kedua platform seka
106
119
  npx mobile-snap --url http://localhost:4321 --paths "/, /scan" --platform both --output hasil_store
107
120
  ```
108
121
 
109
- #### 4. Auto-Crawl Halaman Web
110
- Menelusuri semua tautan internal secara otomatis dari beranda dan memotret setiap halaman yang ditemukan untuk iOS & Android:
122
+ #### 4. Auto-Crawl Halaman Web & Login Interaktif dengan Mockup Bingkai Perangkat
123
+ Menelusuri semua tautan internal secara otomatis dari beranda dan memotret setiap halaman yang ditemukan dengan bingkai mockup iPhone/Android:
111
124
  ```powershell
112
- npx mobile-snap --url http://localhost:4321 --crawl --platform both
125
+ npx mobile-snap --url http://localhost:4321 --crawl --platform both --mockup
113
126
  ```
114
127
 
115
- #### 5. Auto-Detect Rute Proyek Lokal (Astro / Next.js)
116
- Jika Anda berada di dalam root direktori proyek Astro Anda, jalankan perintah ini untuk mendeteksi secara otomatis semua rute halaman statis dari folder `src/pages`:
128
+
129
+ #### 5. Auto-Detect Rute Proyek Lokal (Astro / Next.js) dengan Auto-Login
130
+ Jika Anda berada di dalam root direktori proyek Astro Anda, jalankan perintah ini untuk mendeteksi secara otomatis semua rute halaman statis dari folder `src/pages` dengan login otomatis:
117
131
  ```powershell
118
- npx mobile-snap --url http://localhost:4321 --detect-pages --platform both
132
+ npx mobile-snap --url http://localhost:4321 --detect-pages --html --email "user@email.com" --password "rahasia"
119
133
  ```
120
134
 
135
+ ---
136
+
137
+ ## 🔐 Autentikasi Otomatis & Interaktif
138
+
139
+ MobileSnap secara cerdas membedakan halaman publik dan halaman terproteksi (yang membutuhkan login) berdasarkan pengalihan client-side ke rute login.
140
+
141
+ Jika terdeteksi rute yang memerlukan login, MobileSnap akan:
142
+ 1. **Meminta Kredensial Secara Interaktif**: Jika opsi `--email` dan/atau `--password` tidak diberikan lewat CLI, sistem akan menanyakan email dan password secara interaktif di terminal dengan sensor password otomatis demi keamanan.
143
+ 2. **Auto-Login**: MobileSnap akan melakukan proses sign-in sebelum mengambil tangkapan layar untuk semua rute terproteksi.
144
+ 3. **Crawl Pasca-Login**: Jika opsi `--crawl` aktif, MobileSnap juga akan menjelajahi menu dan tautan internal yang baru muncul di dashboard pasca-login.
145
+
146
+ ---
147
+
148
+ ## ℹ️ Bantuan Perintah (`--help`)
149
+
150
+ Anda selalu dapat memanggil opsi bantuan langsung dari terminal dengan menjalankan:
151
+
152
+ ```powershell
153
+ npx mobile-snap --help
154
+ ```
155
+
156
+ Output bantuan resmi:
157
+ ```text
158
+ Usage: mobile-snap [options]
159
+
160
+ ⚡ MobileSnap CLI: Automate App Store & Google Play Store screenshots
161
+
162
+ Options:
163
+ -V, --version output the version number
164
+ -u, --url <url> Base URL of the local development server (e.g. localhost:3000)
165
+ -p, --paths <paths> Comma-separated list of routes to capture (default: "/")
166
+ -o, --output <output> Output directory to save screenshots (default: "mobilesnap_output")
167
+ -l, --platform <platform> Target platform: "ios", "android", or "both" (default: "ios")
168
+ -c, --crawl Discover and screenshot all internal links automatically (default: false)
169
+ -d, --detect-pages Scan local project pages directory (src/pages or pages) for static routes (default: false)
170
+ --email <email> Email for automatic login authentication
171
+ --password <password> Password for automatic login authentication
172
+ --login-path <path> Path to the login page (default: "/login.html")
173
+ --html Auto append .html extension to detected routes (default: false)
174
+ -m, --mockup Wrap screenshots in a beautiful iPhone/Android device mockup frame (default: false)
175
+ -h, --help display help for command
176
+ ```
177
+
178
+
Binary file
Binary file
package/bin/cli.js CHANGED
@@ -6,6 +6,7 @@ import pc from 'picocolors';
6
6
  import ora from 'ora';
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
+ import readline from 'readline';
9
10
 
10
11
  // Device configurations dengan ukuran logis (CSS pixels) dan scale factor untuk resolusi fisik presisi
11
12
  const DEVICE_CONFIGS = {
@@ -25,6 +26,42 @@ const DEVICE_CONFIGS = {
25
26
  }
26
27
  };
27
28
 
29
+ function promptUser(query, isPassword = false) {
30
+ return new Promise((resolve) => {
31
+ const rl = readline.createInterface({
32
+ input: process.stdin,
33
+ output: process.stdout
34
+ });
35
+
36
+ if (!isPassword) {
37
+ rl.question(query, (answer) => {
38
+ rl.close();
39
+ resolve(answer.trim());
40
+ });
41
+ } else {
42
+ let muted = false;
43
+ const oldWrite = rl._writeToOutput;
44
+
45
+ rl._writeToOutput = function _writeToOutput(stringToWrite) {
46
+ if (muted) {
47
+ if (stringToWrite === '\r' || stringToWrite === '\n' || stringToWrite === '\r\n') {
48
+ oldWrite.call(rl, stringToWrite);
49
+ }
50
+ } else {
51
+ oldWrite.call(rl, stringToWrite);
52
+ }
53
+ };
54
+
55
+ rl.question(query, (answer) => {
56
+ rl.close();
57
+ resolve(answer);
58
+ });
59
+
60
+ muted = true;
61
+ }
62
+ });
63
+ }
64
+
28
65
  function safeFilename(route) {
29
66
  const cleanPath = route.replace(/^\/+|\/+$/g, '');
30
67
  if (!cleanPath) return 'home';
@@ -82,50 +119,554 @@ function detectLocalPages(dir = process.cwd()) {
82
119
  }
83
120
 
84
121
  // Fungsi pembantu untuk melakukan crawling internal links dari halaman utama
85
- async function discoverLinks(browser, baseUrl) {
122
+ async function analyzeRoutes(browser, baseUrl, initialPaths, email, password, loginPath, addHtml, crawl) {
123
+ const normLoginPath = '/' + loginPath.replace(/^\/+/, '');
124
+ const publicRoutes = new Set();
125
+ const authRoutes = new Set();
126
+ const allDetectedRoutes = new Set(initialPaths);
127
+
86
128
  const context = await browser.newContext();
87
129
  const page = await context.newPage();
88
- const links = new Set(['/']);
130
+
131
+ page.on('console', msg => {
132
+ const text = msg.text();
133
+ if (text.includes('Failed to fetch') || text.includes('TypeError: Failed to fetch')) {
134
+ return;
135
+ }
136
+ if (text.includes('[ERROR]') || text.includes('[WARN]')) {
137
+ console.log(pc.dim(` [Crawler Log] ${text}`));
138
+ }
139
+ });
140
+ page.on('pageerror', err => {
141
+ if (err.message.includes('Failed to fetch') || err.message.includes('TypeError: Failed to fetch')) return;
142
+ console.error(pc.red(` [Crawler Error] ${err.message}`));
143
+ });
144
+
145
+ const nonAuthSpinner = ora('Menganalisis rute publik dan mendeteksi kebutuhan autentikasi...').start();
146
+ const routesToCheck = Array.from(allDetectedRoutes);
147
+
148
+ // Jika daftar awal kosong, tambahkan root '/'
149
+ if (routesToCheck.length === 0) {
150
+ routesToCheck.push('/');
151
+ allDetectedRoutes.add('/');
152
+ }
153
+
154
+ for (let i = 0; i < routesToCheck.length; i++) {
155
+ const route = routesToCheck[i];
156
+ let cleanRoute = route;
157
+ if (addHtml && cleanRoute !== '/' && !cleanRoute.endsWith('.html')) {
158
+ cleanRoute += '.html';
159
+ }
160
+
161
+ const targetUrl = `${baseUrl}${cleanRoute}`;
162
+ try {
163
+ await page.goto(targetUrl, { timeout: 15000 });
164
+ await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {});
165
+
166
+ // Tunggu client-side redirect (seperti splash screen ke login) selesai secara dinamis
167
+ if (cleanRoute === '/' || cleanRoute.includes('splash')) {
168
+ await page.waitForURL(u => {
169
+ const pathname = u.pathname;
170
+ return pathname !== '/' && !pathname.includes('splash');
171
+ }, { timeout: 15000 }).catch(() => {});
172
+ } else {
173
+ await new Promise(resolve => setTimeout(resolve, 1000));
174
+ }
175
+
176
+ const finalUrl = page.url();
177
+ let finalPath = '';
178
+ try {
179
+ finalPath = new URL(finalUrl).pathname;
180
+ } catch (err) {
181
+ finalPath = finalUrl;
182
+ }
183
+
184
+ // Bersihkan finalPath agar berakhiran .html jika flag aktif
185
+ if (addHtml && finalPath !== '/' && !finalPath.endsWith('.html') && !/\.[a-z0-9]+$/i.test(finalPath)) {
186
+ finalPath += '.html';
187
+ }
188
+
189
+ // Cek apakah ada form login atau input kredensial di DOM halaman saat ini
190
+ const hasLoginForm = await page.evaluate(() => {
191
+ const hasUser = !!document.querySelector('#username, input[type="email"], input[name="username"], input[name="login"]');
192
+ const hasPass = !!document.querySelector('#password, input[type="password"], input[name="password"]');
193
+ const hasForm = !!document.querySelector('#loginForm, form.login-form, form[action*="login"]');
194
+ return (hasUser && hasPass) || hasForm;
195
+ });
196
+
197
+ const isRedirectToLogin = hasLoginForm ||
198
+ finalPath.includes('login') ||
199
+ finalPath.includes('splash') ||
200
+ finalPath === normLoginPath ||
201
+ finalUrl.includes('login') ||
202
+ finalUrl.includes('splash');
203
+
204
+ if (isRedirectToLogin) {
205
+ if (cleanRoute !== '/' && cleanRoute !== normLoginPath && !cleanRoute.includes('splash') && !cleanRoute.includes('login')) {
206
+ authRoutes.add(cleanRoute);
207
+ } else {
208
+ publicRoutes.add(cleanRoute);
209
+ }
210
+
211
+ // Tambahkan rute login itu sendiri (tujuan redirect) ke rute publik untuk dianalisis/di-crawl
212
+ if (!allDetectedRoutes.has(finalPath)) {
213
+ routesToCheck.push(finalPath);
214
+ allDetectedRoutes.add(finalPath);
215
+ }
216
+ } else {
217
+ publicRoutes.add(cleanRoute);
218
+ }
219
+
220
+ // Selalu rayapi link publik jika crawl aktif dan halaman saat ini tidak memerlukan login (tidak ter-redirect)
221
+ if (crawl && !authRoutes.has(cleanRoute)) {
222
+ const hrefs = await page.evaluate(() => {
223
+ return Array.from(document.querySelectorAll('a'))
224
+ .map(a => a.getAttribute('href'))
225
+ .filter(Boolean);
226
+ });
227
+
228
+ const baseOrigin = new URL(baseUrl).origin;
229
+ for (const href of hrefs) {
230
+ try {
231
+ const resolvedUrl = new URL(href, baseUrl);
232
+ if (resolvedUrl.origin === baseOrigin) {
233
+ let r = resolvedUrl.pathname;
234
+ if (!/\.(pdf|png|jpg|jpeg|gif|css|js|svg|ico|woff|woff2|json)$/i.test(r)) {
235
+ let normalizedR = '/' + r.replace(/^\/+|\/+$/g, '');
236
+ if (normalizedR === '//') normalizedR = '/';
237
+
238
+ if (addHtml && normalizedR !== '/' && !normalizedR.endsWith('.html')) {
239
+ normalizedR += '.html';
240
+ }
241
+
242
+ if (/logout|signout/i.test(normalizedR)) {
243
+ continue;
244
+ }
245
+
246
+ if (!allDetectedRoutes.has(normalizedR)) {
247
+ routesToCheck.push(normalizedR);
248
+ allDetectedRoutes.add(normalizedR);
249
+ }
250
+ }
251
+ }
252
+ } catch (e) {}
253
+ }
254
+ }
255
+ } catch (err) {
256
+ ora().warn(pc.yellow(`Gagal menganalisis rute ${cleanRoute}: ${err.message}`));
257
+ publicRoutes.add(cleanRoute);
258
+ }
259
+ }
89
260
 
90
- try {
91
- await page.goto(baseUrl, { timeout: 15000 });
92
- // Tunggu sampai halaman tenang
93
- await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
94
-
95
- const hrefs = await page.evaluate(() => {
96
- return Array.from(document.querySelectorAll('a'))
97
- .map(a => a.getAttribute('href'))
98
- .filter(Boolean);
261
+ await context.close();
262
+ nonAuthSpinner.succeed(`Analisis awal selesai. Terdeteksi ${publicRoutes.size} rute publik dan ${authRoutes.size} rute membutuhkan autentikasi.`);
263
+
264
+ // 2. Tanya Kredensial secara interaktif jika ada rute auth dan email/pass kosong
265
+ let finalEmail = email;
266
+ let finalPassword = password;
267
+
268
+ if (authRoutes.size > 0 && (!finalEmail || !finalPassword)) {
269
+ console.log(pc.yellow(`\n🔑 Mendeteksi ${authRoutes.size} halaman yang membutuhkan login.`));
270
+ if (!finalEmail) {
271
+ finalEmail = await promptUser('👉 Masukkan Email/Username: ');
272
+ }
273
+ if (!finalPassword) {
274
+ finalPassword = await promptUser('👉 Masukkan Password (input tersembunyi): ', true);
275
+ console.log(''); // baris baru setelah menekan enter
276
+ }
277
+ }
278
+
279
+ // 3. Crawl Fase Kedua (Login) untuk merayapi halaman dashboard internal
280
+ if (authRoutes.size > 0 && finalEmail && finalPassword) {
281
+ const authCrawlSpinner = ora('Melakukan login dan memindai halaman internal...').start();
282
+ const authContext = await browser.newContext();
283
+ const authPage = await authContext.newPage();
284
+
285
+ authPage.on('console', msg => {
286
+ const text = msg.text();
287
+ if (text.includes('Failed to fetch') || text.includes('TypeError: Failed to fetch')) {
288
+ return;
289
+ }
290
+ if (text.includes('[ERROR]') || text.includes('[WARN]')) {
291
+ console.log(pc.dim(` [Auth-Crawler Log] ${text}`));
292
+ }
99
293
  });
100
-
101
- const baseOrigin = new URL(baseUrl).origin;
102
-
103
- for (const href of hrefs) {
104
- try {
105
- const resolvedUrl = new URL(href, baseUrl);
106
- if (resolvedUrl.origin === baseOrigin) {
107
- let route = resolvedUrl.pathname;
108
- // Abaikan resource file statis
109
- if (!/\.(pdf|png|jpg|jpeg|gif|css|js|svg|ico|woff|woff2|json)$/i.test(route)) {
110
- let normalizedRoute = '/' + route.replace(/^\/+|\/+$/g, '');
111
- if (normalizedRoute === '//') normalizedRoute = '/';
112
- links.add(normalizedRoute);
294
+ authPage.on('pageerror', err => {
295
+ if (err.message.includes('Failed to fetch') || err.message.includes('TypeError: Failed to fetch')) return;
296
+ console.error(pc.red(` [Auth-Crawler Error] ${err.message}`));
297
+ });
298
+
299
+ try {
300
+ const targetLoginUrl = `${baseUrl}${normLoginPath}`;
301
+ await authPage.goto(targetLoginUrl, { timeout: 20000 });
302
+ await authPage.waitForSelector('#username', { timeout: 10000 });
303
+ await authPage.waitForSelector('#password', { timeout: 10000 });
304
+ await authPage.fill('#username', finalEmail);
305
+ await authPage.fill('#password', finalPassword);
306
+
307
+ const submitBtn = await authPage.locator('button[type="submit"], button.save-btn').first();
308
+ await submitBtn.click();
309
+
310
+ // Tunggu hingga login berhasil (URL berubah) atau ada error message
311
+ let loginSuccess = false;
312
+ let loginError = '';
313
+ for (let i = 0; i < 40; i++) {
314
+ await new Promise(resolve => setTimeout(resolve, 250));
315
+ const currentUrl = authPage.url();
316
+ if (!currentUrl.includes('login') && !currentUrl.includes('splash')) {
317
+ loginSuccess = true;
318
+ break;
319
+ }
320
+ let errorText = null;
321
+ try {
322
+ errorText = await authPage.evaluate(() => {
323
+ const el = document.getElementById('errorMessage');
324
+ return el && !el.classList.contains('hidden') ? el.textContent : null;
325
+ });
326
+ } catch (e) {
327
+ // Ignore context destruction error during redirect/navigation
328
+ }
329
+ if (errorText) {
330
+ loginError = errorText.trim();
331
+ break;
332
+ }
333
+ }
334
+
335
+ if (!loginSuccess) {
336
+ throw new Error(loginError || 'Timeout: URL halaman tidak berubah dari halaman login setelah 10 detik.');
337
+ }
338
+
339
+ if (crawl) {
340
+ const authRoutesArray = Array.from(authRoutes);
341
+ for (const route of authRoutesArray) {
342
+ const targetUrl = `${baseUrl}${route}`;
343
+ try {
344
+ await authPage.goto(targetUrl, { timeout: 15000 });
345
+ await authPage.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
346
+
347
+ const hrefs = await authPage.evaluate(() => {
348
+ return Array.from(document.querySelectorAll('a'))
349
+ .map(a => a.getAttribute('href'))
350
+ .filter(Boolean);
351
+ });
352
+
353
+ const baseOrigin = new URL(baseUrl).origin;
354
+ for (const href of hrefs) {
355
+ try {
356
+ const resolvedUrl = new URL(href, baseUrl);
357
+ if (resolvedUrl.origin === baseOrigin) {
358
+ let r = resolvedUrl.pathname;
359
+
360
+ // Filter out logout/signout
361
+ if (/logout|signout/i.test(r)) {
362
+ continue;
363
+ }
364
+
365
+ if (!/\.(pdf|png|jpg|jpeg|gif|css|js|svg|ico|woff|woff2|json)$/i.test(r)) {
366
+ let normalizedR = '/' + r.replace(/^\/+|\/+$/g, '');
367
+ if (normalizedR === '//') normalizedR = '/';
368
+
369
+ if (addHtml && normalizedR !== '/' && !normalizedR.endsWith('.html')) {
370
+ normalizedR += '.html';
371
+ }
372
+
373
+ if (!publicRoutes.has(normalizedR) && !authRoutes.has(normalizedR)) {
374
+ authRoutes.add(normalizedR);
375
+ }
376
+ }
377
+ }
378
+ } catch (e) {}
379
+ }
380
+ } catch (routeErr) {
381
+ // Abaikan jika rute tertentu gagal muat
113
382
  }
114
383
  }
115
- } catch (e) {
116
- // Href tidak valid (seperti tel:, mailto:, javascript:) diabaikan
117
384
  }
385
+ authCrawlSpinner.succeed(`Crawl pasca-login selesai. Menemukan total ${authRoutes.size} rute terotentikasi.`);
386
+ } catch (err) {
387
+ authCrawlSpinner.fail(`Gagal merayapi halaman terotentikasi: ${err.message}`);
388
+ } finally {
389
+ await authContext.close();
118
390
  }
119
- } catch (err) {
120
- // Abaikan jika gagal memuat sebagian halaman utama, return '/'
121
- } finally {
122
- await context.close();
123
391
  }
392
+
393
+ return {
394
+ publicRoutes: Array.from(publicRoutes),
395
+ authRoutes: Array.from(authRoutes),
396
+ email: finalEmail,
397
+ password: finalPassword
398
+ };
399
+ }
400
+
401
+ async function applyMockupBorder(browser, imageBuffer, deviceName, size, platform, isDarkTheme = false) {
402
+ const base64Image = imageBuffer.toString('base64');
124
403
 
125
- return Array.from(links);
404
+ const now = new Date();
405
+ const hours = String(now.getHours()).padStart(2, '0');
406
+ const minutes = String(now.getMinutes()).padStart(2, '0');
407
+ const timeString = `${hours}:${minutes}`;
408
+
409
+ const textColor = isDarkTheme ? '#ffffff' : '#000000';
410
+ const svgFill = isDarkTheme ? '#ffffff' : '#000000';
411
+ const batteryBorder = isDarkTheme ? '#ffffff' : '#000000';
412
+ const batteryLevelBg = isDarkTheme ? '#ffffff' : '#000000';
413
+ const indicatorBg = isDarkTheme ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.8)';
414
+ const androidIndicatorBg = isDarkTheme ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.5)';
415
+ const tabletIndicatorBg = isDarkTheme ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)';
416
+
417
+ let frameClass = 'ios';
418
+ let topElementHTML = '<div class="dynamic-island"></div>';
419
+ let statusBarHTML = `
420
+ <div class="status-bar" style="height: 44px; padding: 0 32px; color: ${textColor};">
421
+ <div class="time">${timeString}</div>
422
+ <div class="status-right">
423
+ <svg width="17" height="11" viewBox="0 0 17 11" fill="none" xmlns="http://www.w3.org/2000/svg">
424
+ <rect x="0.5" y="8" width="2.5" height="3" rx="0.5" fill="${svgFill}"/>
425
+ <rect x="4.5" y="6" width="2.5" height="5" rx="0.5" fill="${svgFill}"/>
426
+ <rect x="8.5" y="4" width="2.5" height="7" rx="0.5" fill="${svgFill}"/>
427
+ <rect x="12.5" y="1" width="2.5" height="10" rx="0.5" fill="${svgFill}"/>
428
+ </svg>
429
+ <svg width="15" height="11" viewBox="0 0 15 11" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-left: 2px;">
430
+ <path d="M7.5 11C8.32843 11 9 10.3284 9 9.5C9 8.67157 8.32843 8 7.5 8C6.67157 8 6 8.67157 6 9.5C6 10.3284 6.67157 11 7.5 11Z" fill="${svgFill}"/>
431
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M7.5 0C4.33806 0 1.50341 1.25414 0.556274 3.25056C0.370701 3.6417 0.53606 4.10842 0.926066 4.29815L1.87955 4.762C2.2612 4.94766 2.72124 4.7954 2.91572 4.41724C3.6067 3.07342 5.37895 2 7.5 2C9.62105 2 11.3933 3.07342 12.0843 4.41724C12.2788 4.7954 12.7388 4.94766 13.1205 4.762L14.0739 4.29815C14.4639 4.10842 14.6293 3.6417 14.4437 3.25056C13.4966 1.25414 10.6619 0 7.5 0ZM7.5 4C5.77259 4 4.22699 4.67499 3.6705 5.76077C3.47953 6.13333 3.62649 6.58988 3.99878 6.78168L4.95227 7.27282C5.33027 7.46752 5.79287 7.32483 5.99221 6.95353C6.26241 6.45028 6.83756 6 7.5 6C8.16244 6 8.73759 6.45028 9.00779 6.95353C9.20713 7.32483 9.66973 7.46752 10.0477 7.27282L11.0012 6.78168C11.3735 6.58988 11.5205 6.13333 11.3295 5.76077C10.773 4.67499 9.22741 4 7.5 4Z" fill="${svgFill}"/>
432
+ </svg>
433
+ <div class="battery" style="margin-left: 2px; border-color: ${batteryBorder};"><div class="battery-level" style="background-color: ${batteryLevelBg};"></div></div>
434
+ </div>
435
+ </div>
436
+ `;
437
+ let bottomElementHTML = `<div class="home-indicator" style="background: ${indicatorBg};"></div>`;
438
+
439
+ if (platform === 'android') {
440
+ if (deviceName.includes('tablet')) {
441
+ frameClass = 'android-tablet';
442
+ topElementHTML = '';
443
+ statusBarHTML = `
444
+ <div class="status-bar" style="height: 32px; padding: 0 20px; font-size: 10px; line-height: 32px; color: ${textColor};">
445
+ <div class="time">${timeString}</div>
446
+ <div class="status-right">
447
+ <span>🛜</span>
448
+ <span style="margin-left: 4px; color: ${textColor};">🔋 100%</span>
449
+ </div>
450
+ </div>
451
+ `;
452
+ bottomElementHTML = `<div class="home-indicator" style="width: 160px; height: 4px; bottom: 4px; background: ${tabletIndicatorBg};"></div>`;
453
+ } else {
454
+ frameClass = 'android-phone';
455
+ topElementHTML = '<div class="punch-hole"></div>';
456
+ statusBarHTML = `
457
+ <div class="status-bar" style="height: 38px; padding: 0 24px; font-size: 11px; line-height: 38px; color: ${textColor};">
458
+ <div class="time">${timeString}</div>
459
+ <div class="status-right">
460
+ <svg width="15" height="11" viewBox="0 0 17 11" fill="none" xmlns="http://www.w3.org/2000/svg" style="transform: scale(0.9); margin-left: 2px;">
461
+ <rect x="0.5" y="8" width="2.5" height="3" rx="0.5" fill="${svgFill}"/>
462
+ <rect x="4.5" y="6" width="2.5" height="5" rx="0.5" fill="${svgFill}"/>
463
+ <rect x="8.5" y="4" width="2.5" height="7" rx="0.5" fill="${svgFill}"/>
464
+ <rect x="12.5" y="1" width="2.5" height="10" rx="0.5" fill="${svgFill}"/>
465
+ </svg>
466
+ <svg width="13" height="11" viewBox="0 0 15 11" fill="none" xmlns="http://www.w3.org/2000/svg" style="transform: scale(0.9); margin-left: 2px;">
467
+ <path d="M7.5 11C8.32843 11 9 10.3284 9 9.5C9 8.67157 8.32843 8 7.5 8C6.67157 8 6 8.67157 6 9.5C6 10.3284 6.67157 11 7.5 11Z" fill="${svgFill}"/>
468
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M7.5 0C4.33806 0 1.50341 1.25414 0.556274 3.25056C0.370701 3.6417 0.53606 4.10842 0.926066 4.29815L1.87955 4.762C2.2612 4.94766 2.72124 4.7954 2.91572 4.41724C3.6067 3.07342 5.37895 2 7.5 2C9.62105 2 11.3933 3.07342 12.0843 4.41724C12.2788 4.7954 12.7388 4.94766 13.1205 4.762L14.0739 4.29815C14.4639 4.10842 14.6293 3.6417 14.4437 3.25056C13.4966 1.25414 10.6619 0 7.5 0ZM7.5 4C5.77259 4 4.22699 4.67499 3.6705 5.76077C3.47953 6.13333 3.62649 6.58988 3.99878 6.78168L4.95227 7.27282C5.33027 7.46752 5.79287 7.32483 5.99221 6.95353C6.26241 6.45028 6.83756 6 7.5 6C8.16244 6 8.73759 6.45028 9.00779 6.95353C9.20713 7.32483 9.66973 7.46752 10.0477 7.27282L11.0012 6.78168C11.3735 6.58988 11.5205 6.13333 11.3295 5.76077C10.773 4.67499 9.22741 4 7.5 4Z" fill="${svgFill}"/>
469
+ </svg>
470
+ <div class="battery" style="border-radius: 2px; margin-left: 2px; border-color: ${batteryBorder};"><div class="battery-level" style="background-color: ${batteryLevelBg};"></div></div>
471
+ </div>
472
+ </div>
473
+ `;
474
+ bottomElementHTML = `<div class="home-indicator" style="background: ${androidIndicatorBg}; width: 120px; bottom: 6px;"></div>`;
475
+ }
476
+ }
477
+
478
+ const htmlContent = `
479
+ <!DOCTYPE html>
480
+ <html>
481
+ <head>
482
+ <meta charset="utf-8">
483
+ <style>
484
+ * {
485
+ box-sizing: border-box;
486
+ margin: 0;
487
+ padding: 0;
488
+ overflow: hidden;
489
+ }
490
+ body {
491
+ background: transparent;
492
+ display: flex;
493
+ justify-content: center;
494
+ align-items: center;
495
+ min-height: 100vh;
496
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
497
+ }
498
+ .device-wrapper {
499
+ position: relative;
500
+ padding: 40px; /* space for shadow */
501
+ display: inline-block;
502
+ background: transparent;
503
+ }
504
+ .device-frame {
505
+ position: relative;
506
+ background: #000;
507
+ box-shadow:
508
+ 0 0 0 12px #1f1f21,
509
+ 0 0 0 13px #2f2f31,
510
+ 0 20px 50px rgba(0,0,0,0.6);
511
+ overflow: hidden;
512
+ }
513
+ .device-frame.ios {
514
+ border-radius: 54px;
515
+ }
516
+ .device-frame.ios .screenshot-img {
517
+ border-radius: 42px;
518
+ }
519
+ .device-frame.android-phone {
520
+ border-radius: 40px;
521
+ box-shadow:
522
+ 0 0 0 10px #2a2a2c,
523
+ 0 0 0 11px #3a3a3c,
524
+ 0 20px 50px rgba(0,0,0,0.6);
525
+ }
526
+ .device-frame.android-phone .screenshot-img {
527
+ border-radius: 30px;
528
+ }
529
+ .device-frame.android-tablet {
530
+ border-radius: 24px;
531
+ box-shadow:
532
+ 0 0 0 14px #2a2a2c,
533
+ 0 20px 50px rgba(0,0,0,0.6);
534
+ }
535
+ .device-frame.android-tablet .screenshot-img {
536
+ border-radius: 12px;
537
+ }
538
+ .screenshot-img {
539
+ width: 100%;
540
+ height: 100%;
541
+ object-fit: cover;
542
+ display: block;
543
+ }
544
+ .dynamic-island {
545
+ position: absolute;
546
+ top: 18px;
547
+ left: 50%;
548
+ transform: translateX(-50%);
549
+ width: 110px;
550
+ height: 28px;
551
+ background: #000;
552
+ border-radius: 20px;
553
+ z-index: 100;
554
+ border: 0.5px solid rgba(255, 255, 255, 0.08);
555
+ }
556
+ .dynamic-island::after {
557
+ content: '';
558
+ position: absolute;
559
+ right: 25px;
560
+ top: 10px;
561
+ width: 8px;
562
+ height: 8px;
563
+ background: #111124;
564
+ border-radius: 50%;
565
+ box-shadow: inset 0 0 2px rgba(255,255,255,0.2);
566
+ }
567
+ .punch-hole {
568
+ position: absolute;
569
+ top: 14px;
570
+ left: 50%;
571
+ transform: translateX(-50%);
572
+ width: 12px;
573
+ height: 12px;
574
+ background: #000;
575
+ border-radius: 50%;
576
+ z-index: 100;
577
+ border: 1px solid rgba(255,255,255,0.15);
578
+ }
579
+ .status-bar {
580
+ position: absolute;
581
+ top: 0;
582
+ left: 0;
583
+ width: 100%;
584
+ display: flex;
585
+ justify-content: space-between;
586
+ align-items: center;
587
+ color: #fff;
588
+ font-weight: 600;
589
+ z-index: 99;
590
+ letter-spacing: -0.2px;
591
+ }
592
+ .status-right {
593
+ display: flex;
594
+ gap: 6px;
595
+ align-items: center;
596
+ }
597
+ .battery {
598
+ width: 20px;
599
+ height: 10.5px;
600
+ border: 1px solid #fff;
601
+ border-radius: 3px;
602
+ position: relative;
603
+ padding: 1px;
604
+ }
605
+ .battery::after {
606
+ content: '';
607
+ position: absolute;
608
+ right: -3px;
609
+ top: 2px;
610
+ width: 2px;
611
+ height: 4.5px;
612
+ background: #fff;
613
+ border-radius: 0 1px 1px 0;
614
+ }
615
+ .battery-level {
616
+ width: 100%;
617
+ height: 100%;
618
+ background: #fff;
619
+ border-radius: 1px;
620
+ }
621
+ .home-indicator {
622
+ position: absolute;
623
+ bottom: 8px;
624
+ left: 50%;
625
+ transform: translateX(-50%);
626
+ width: 140px;
627
+ height: 5px;
628
+ background: rgba(255, 255, 255, 0.85);
629
+ border-radius: 10px;
630
+ z-index: 100;
631
+ }
632
+ </style>
633
+ </head>
634
+ <body>
635
+ <div class="device-wrapper">
636
+ <div class="device-frame ${frameClass}" style="width: ${size.logical.width}px; height: ${size.logical.height}px;">
637
+ ${topElementHTML}
638
+ ${statusBarHTML}
639
+ <img src="data:image/png;base64,${base64Image}" class="screenshot-img" />
640
+ ${bottomElementHTML}
641
+ </div>
642
+ </div>
643
+ </body>
644
+ </html>
645
+ `;
646
+
647
+ const extraWidth = 150;
648
+ const extraHeight = 150;
649
+ const context = await browser.newContext({
650
+ viewport: {
651
+ width: size.logical.width + extraWidth,
652
+ height: size.logical.height + extraHeight
653
+ },
654
+ deviceScaleFactor: size.scale
655
+ });
656
+
657
+ const page = await context.newPage();
658
+ await page.setContent(htmlContent);
659
+ await page.waitForLoadState('networkidle');
660
+
661
+ const buffer = await page.locator('.device-wrapper').screenshot({
662
+ omitBackground: true
663
+ });
664
+
665
+ await context.close();
666
+ return buffer;
126
667
  }
127
668
 
128
- async function captureScreenshots(url, paths, outputDir, platform, crawl, detectPages) {
669
+ async function captureScreenshots(url, paths, outputDir, platform, crawl, detectPages, email, password, loginPath, addHtml, mockup) {
129
670
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
130
671
  url = 'http://' + url;
131
672
  }
@@ -165,7 +706,10 @@ async function captureScreenshots(url, paths, outputDir, platform, crawl, detect
165
706
  let browser;
166
707
  const launchSpinner = ora('Launching Chromium browser...').start();
167
708
  try {
168
- browser = await chromium.launch({ headless: true });
709
+ browser = await chromium.launch({
710
+ headless: true,
711
+ args: ['--disable-web-security']
712
+ });
169
713
  launchSpinner.succeed('Chromium browser launched successfully');
170
714
  } catch (err) {
171
715
  launchSpinner.fail(pc.red('Failed to launch Chromium browser'));
@@ -174,38 +718,33 @@ async function captureScreenshots(url, paths, outputDir, platform, crawl, detect
174
718
  process.exit(1);
175
719
  }
176
720
 
177
- // 2. Lakukan auto-crawl jika diaktifkan
178
- if (crawl) {
179
- const crawlSpinner = ora('Crawling internal links dari beranda...').start();
180
- try {
181
- const crawledRoutes = await discoverLinks(browser, url);
182
- crawlSpinner.succeed(`Crawling selesai. Menemukan ${crawledRoutes.length} rute unik.`);
183
- finalPaths = [...new Set([...finalPaths, ...crawledRoutes])];
184
- } catch (err) {
185
- crawlSpinner.fail(`Gagal melakukan crawling: ${err.message}`);
186
- }
721
+ // Panggil analyzeRoutes untuk memisahkan rute publik dan terotentikasi
722
+ let result;
723
+ try {
724
+ result = await analyzeRoutes(browser, url, finalPaths, email, password, loginPath, addHtml, crawl);
725
+ } catch (err) {
726
+ console.error(pc.red(`Gagal menganalisis rute: ${err.message}`));
727
+ await browser.close();
728
+ process.exit(1);
187
729
  }
188
730
 
189
- // Normalisasi semua path
190
- finalPaths = finalPaths
191
- .map(p => {
192
- let clean = p.trim();
193
- if (!clean) return null;
194
- return '/' + clean.replace(/^\/+/, '');
195
- })
196
- .filter(Boolean);
731
+ const { publicRoutes, authRoutes, email: finalEmail, password: finalPassword } = result;
197
732
 
198
- // Hilangkan duplikasi rute
199
- finalPaths = [...new Set(finalPaths)];
200
-
201
- if (finalPaths.length === 0) {
202
- finalPaths = ['/'];
733
+ console.log(pc.bold(`\nTerdeteksi Rute Publik (${publicRoutes.length}):`));
734
+ publicRoutes.forEach(p => console.log(` - ${pc.green(p)}`));
735
+
736
+ if (authRoutes.length > 0) {
737
+ console.log(pc.bold(`\nTerdeteksi Rute Membutuhkan Autentikasi (${authRoutes.length}):`));
738
+ authRoutes.forEach(p => console.log(` - ${pc.cyan(p)}`));
203
739
  }
204
-
205
- console.log(pc.bold(`\nRute yang akan dipotret (${finalPaths.length}):`));
206
- finalPaths.forEach(p => console.log(` - ${pc.cyan(p)}`));
207
740
  console.log('');
208
741
 
742
+ if (publicRoutes.length === 0 && authRoutes.length === 0) {
743
+ console.log(pc.yellow('Tidak ada rute yang ditemukan untuk dipotret.'));
744
+ await browser.close();
745
+ return;
746
+ }
747
+
209
748
  for (const plat of targetPlatforms) {
210
749
  const config = DEVICE_CONFIGS[plat];
211
750
  console.log(pc.bold(pc.blue(`💻 Platform: ${plat.toUpperCase()}`)));
@@ -213,6 +752,11 @@ async function captureScreenshots(url, paths, outputDir, platform, crawl, detect
213
752
  for (const [deviceName, size] of Object.entries(config.devices)) {
214
753
  console.log(pc.magenta(` 📱 Processing ${deviceName} (${size.logical.width * size.scale}x${size.logical.height * size.scale}px)...`));
215
754
 
755
+ const deviceOutputDir = path.join(outputDir, plat, deviceName);
756
+ if (!fs.existsSync(deviceOutputDir)) {
757
+ fs.mkdirSync(deviceOutputDir, { recursive: true });
758
+ }
759
+
216
760
  const context = await browser.newContext({
217
761
  viewport: size.logical,
218
762
  userAgent: config.userAgent,
@@ -223,27 +767,194 @@ async function captureScreenshots(url, paths, outputDir, platform, crawl, detect
223
767
 
224
768
  const page = await context.newPage();
225
769
 
226
- for (const route of finalPaths) {
227
- const targetUrl = `${url}${route}`;
228
- const nameSnippet = safeFilename(route);
229
- const filename = `${deviceName}_${nameSnippet}.png`;
230
- const outputPath = path.join(outputDir, filename);
231
-
232
- const pageSpinner = ora(` Navigating to ${route}...`).start();
770
+ const saveScreenshot = async (outputPath) => {
771
+ if (mockup) {
772
+ const isDarkTheme = await page.evaluate(() => {
773
+ const bodyBg = window.getComputedStyle(document.body).backgroundColor;
774
+ const match = bodyBg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
775
+ if (match) {
776
+ const r = parseInt(match[1]), g = parseInt(match[2]), b = parseInt(match[3]);
777
+ const brightness = (r * 299 + g * 587 + b * 114) / 1000;
778
+ const alphaMatch = bodyBg.match(/rgba?\(\d+,\s*\d+,\s*\d+,\s*([\d.]+)/);
779
+ if (alphaMatch && parseFloat(alphaMatch[1]) < 0.1) {
780
+ // semi-transparent, fall back to text color check
781
+ } else {
782
+ return brightness < 128;
783
+ }
784
+ }
785
+ const bodyColor = window.getComputedStyle(document.body).color;
786
+ const colorMatch = bodyColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
787
+ if (colorMatch) {
788
+ const r = parseInt(colorMatch[1]), g = parseInt(colorMatch[2]), b = parseInt(colorMatch[3]);
789
+ const brightness = (r * 299 + g * 587 + b * 114) / 1000;
790
+ return brightness > 128;
791
+ }
792
+ return false;
793
+ });
233
794
 
234
- try {
235
- await page.goto(targetUrl, { timeout: 30000 });
236
- pageSpinner.text = ` Waiting for network idle on ${route}...`;
237
- await page.waitForLoadState('networkidle', { timeout: 15000 });
238
-
239
- // Tunggu sebentar untuk layout stabil
240
- await new Promise(resolve => setTimeout(resolve, 800));
241
-
242
- pageSpinner.text = ` Saving screenshot ${filename}...`;
795
+ const raw = await page.screenshot({ fullPage: false });
796
+ const framed = await applyMockupBorder(browser, raw, deviceName, size, plat, isDarkTheme);
797
+ fs.writeFileSync(outputPath, framed);
798
+ } else {
243
799
  await page.screenshot({ path: outputPath, fullPage: false });
244
- pageSpinner.succeed(pc.green(` ✔ Saved ${filename}`));
245
- } catch (err) {
246
- pageSpinner.fail(pc.red(` ✘ Failed to capture ${route}: ${err.message}`));
800
+ }
801
+ };
802
+
803
+ // Forward browser console logs and errors to terminal for debugging
804
+ page.on('console', msg => {
805
+ const text = msg.text();
806
+ if (text.includes('Failed to fetch') || text.includes('TypeError: Failed to fetch')) {
807
+ return;
808
+ }
809
+ // filter out noisy logs but keep useful ones
810
+ if (text.includes('[ERROR]') || text.includes('[WARN]')) {
811
+ console.log(pc.dim(` [Browser Log] ${text}`));
812
+ }
813
+ });
814
+ page.on('pageerror', err => {
815
+ if (err.message.includes('Failed to fetch') || err.message.includes('TypeError: Failed to fetch')) return;
816
+ console.error(pc.red(` [Browser Error] ${err.message}`));
817
+ });
818
+
819
+ // --- Tahap 1: Memotret Halaman Publik (Tanpa Login) ---
820
+ if (publicRoutes.length > 0) {
821
+ console.log(` 📸 Memotret halaman publik...`);
822
+ for (const route of publicRoutes) {
823
+ const targetUrl = `${url}${route}`;
824
+ const nameSnippet = safeFilename(route);
825
+ const filename = `${nameSnippet}.png`;
826
+ const outputPath = path.join(deviceOutputDir, filename);
827
+
828
+ const pageSpinner = ora(` Navigating to ${route}...`).start();
829
+ try {
830
+ await page.goto(targetUrl, { timeout: 30000 });
831
+ pageSpinner.text = ` Waiting for network idle on ${route}...`;
832
+ await page.waitForLoadState('networkidle', { timeout: 15000 });
833
+ await new Promise(resolve => setTimeout(resolve, 800));
834
+
835
+ pageSpinner.text = ` Saving screenshot ${filename}...`;
836
+ await saveScreenshot(outputPath);
837
+ pageSpinner.succeed(pc.green(` ✔ Saved ${plat}/${deviceName}/${filename}`));
838
+ } catch (err) {
839
+ pageSpinner.fail(pc.red(` ✘ Failed to capture ${route}: ${err.message}`));
840
+ }
841
+ }
842
+ }
843
+
844
+ // --- Tahap 2: Memotret Halaman Terotentikasi ---
845
+ if (authRoutes.length > 0) {
846
+ if (finalEmail && finalPassword) {
847
+ const loginSpinner = ora(` Logging in to session for ${deviceName}...`).start();
848
+ try {
849
+ const targetLoginUrl = `${url}/${loginPath.replace(/^\/+/, '')}`;
850
+ await page.goto(targetLoginUrl, { timeout: 30000 });
851
+ await page.waitForSelector('#username', { timeout: 10000 });
852
+ await page.waitForSelector('#password', { timeout: 10000 });
853
+ await page.fill('#username', finalEmail);
854
+ await page.fill('#password', finalPassword);
855
+
856
+ const submitBtn = await page.locator('button[type="submit"], button.save-btn').first();
857
+ await submitBtn.click();
858
+
859
+ // Tunggu hingga login berhasil (URL berubah) atau ada error message
860
+ let loginSuccess = false;
861
+ let loginError = '';
862
+ for (let i = 0; i < 40; i++) {
863
+ await new Promise(resolve => setTimeout(resolve, 250));
864
+ const currentUrl = page.url();
865
+ if (!currentUrl.includes('login') && !currentUrl.includes('splash')) {
866
+ loginSuccess = true;
867
+ break;
868
+ }
869
+ let errorText = null;
870
+ try {
871
+ errorText = await page.evaluate(() => {
872
+ const el = document.getElementById('errorMessage');
873
+ return el && !el.classList.contains('hidden') ? el.textContent : null;
874
+ });
875
+ } catch (e) {
876
+ // Ignore context destruction error during redirect/navigation
877
+ }
878
+ if (errorText) {
879
+ loginError = errorText.trim();
880
+ break;
881
+ }
882
+ }
883
+
884
+ if (!loginSuccess) {
885
+ throw new Error(loginError || 'Timeout: URL halaman tidak berubah dari halaman login setelah 10 detik.');
886
+ }
887
+
888
+ loginSpinner.succeed(` Logged in successfully for ${deviceName}`);
889
+
890
+ console.log(` 📸 Memotret halaman terotentikasi...`);
891
+ for (const route of authRoutes) {
892
+ const targetUrl = `${url}${route}`;
893
+ const nameSnippet = safeFilename(route);
894
+ const filename = `${nameSnippet}.png`;
895
+ const outputPath = path.join(deviceOutputDir, filename);
896
+
897
+ const pageSpinner = ora(` Navigating to ${route}...`).start();
898
+ try {
899
+ await page.goto(targetUrl, { timeout: 30000 });
900
+ pageSpinner.text = ` Waiting for network idle on ${route}...`;
901
+ await page.waitForLoadState('networkidle', { timeout: 15000 });
902
+ await new Promise(resolve => setTimeout(resolve, 800));
903
+
904
+ pageSpinner.text = ` Saving screenshot ${filename}...`;
905
+ await saveScreenshot(outputPath);
906
+ pageSpinner.succeed(pc.green(` ✔ Saved ${plat}/${deviceName}/${filename}`));
907
+ } catch (err) {
908
+ pageSpinner.fail(pc.red(` ✘ Failed to capture ${route}: ${err.message}`));
909
+ }
910
+ }
911
+ } catch (loginErr) {
912
+ loginSpinner.fail(pc.red(` Auto-login failed for ${deviceName}: ${loginErr.message}`));
913
+ console.log(pc.yellow(` 💡 Melanjutkan capture auth routes tanpa login (mungkin ter-redirect ke login/splash).`));
914
+
915
+ for (const route of authRoutes) {
916
+ const targetUrl = `${url}${route}`;
917
+ const nameSnippet = safeFilename(route);
918
+ const filename = `${nameSnippet}.png`;
919
+ const outputPath = path.join(deviceOutputDir, filename);
920
+
921
+ const pageSpinner = ora(` Navigating to ${route}...`).start();
922
+ try {
923
+ await page.goto(targetUrl, { timeout: 30000 });
924
+ pageSpinner.text = ` Waiting for network idle on ${route}...`;
925
+ await page.waitForLoadState('networkidle', { timeout: 15000 });
926
+ await new Promise(resolve => setTimeout(resolve, 800));
927
+
928
+ pageSpinner.text = ` Saving screenshot ${filename}...`;
929
+ await saveScreenshot(outputPath);
930
+ pageSpinner.succeed(pc.green(` ✔ Saved ${plat}/${deviceName}/${filename}`));
931
+ } catch (err) {
932
+ pageSpinner.fail(pc.red(` ✘ Failed to capture ${route}: ${err.message}`));
933
+ }
934
+ }
935
+ }
936
+ } else {
937
+ console.log(pc.yellow(` ⚠ Skip login (kredensial kosong). Memotret auth routes tanpa login.`));
938
+ for (const route of authRoutes) {
939
+ const targetUrl = `${url}${route}`;
940
+ const nameSnippet = safeFilename(route);
941
+ const filename = `${nameSnippet}.png`;
942
+ const outputPath = path.join(deviceOutputDir, filename);
943
+
944
+ const pageSpinner = ora(` Navigating to ${route}...`).start();
945
+ try {
946
+ await page.goto(targetUrl, { timeout: 30000 });
947
+ pageSpinner.text = ` Waiting for network idle on ${route}...`;
948
+ await page.waitForLoadState('networkidle', { timeout: 15000 });
949
+ await new Promise(resolve => setTimeout(resolve, 800));
950
+
951
+ pageSpinner.text = ` Saving screenshot ${filename}...`;
952
+ await saveScreenshot(outputPath);
953
+ pageSpinner.succeed(pc.green(` ✔ Saved ${plat}/${deviceName}/${filename}`));
954
+ } catch (err) {
955
+ pageSpinner.fail(pc.red(` ✘ Failed to capture ${route}: ${err.message}`));
956
+ }
957
+ }
247
958
  }
248
959
  }
249
960
 
@@ -258,13 +969,18 @@ async function captureScreenshots(url, paths, outputDir, platform, crawl, detect
258
969
  program
259
970
  .name('mobile-snap')
260
971
  .description('⚡ MobileSnap CLI: Automate App Store & Google Play Store screenshots')
261
- .version('1.0.0')
972
+ .version('1.0.4')
262
973
  .requiredOption('-u, --url <url>', 'Base URL of the local development server (e.g. localhost:3000)')
263
974
  .option('-p, --paths <paths>', 'Comma-separated list of routes to capture', '/')
264
975
  .option('-o, --output <output>', 'Output directory to save screenshots', 'mobilesnap_output')
265
976
  .option('-l, --platform <platform>', 'Target platform: "ios", "android", or "both"', 'ios')
266
977
  .option('-c, --crawl', 'Discover and screenshot all internal links automatically', false)
267
978
  .option('-d, --detect-pages', 'Scan local project pages directory (src/pages or pages) for static routes', false)
979
+ .option('--email <email>', 'Email for automatic login authentication')
980
+ .option('--password <password>', 'Password for automatic login authentication')
981
+ .option('--login-path <path>', 'Path to the login page', '/login.html')
982
+ .option('--html', 'Auto append .html extension to detected routes', false)
983
+ .option('-m, --mockup', 'Wrap screenshots in a beautiful iPhone/Android device mockup frame', false)
268
984
  .action((options) => {
269
985
  let pathList = [];
270
986
  if (options.paths) {
@@ -283,7 +999,12 @@ program
283
999
  options.output,
284
1000
  platformVal,
285
1001
  options.crawl,
286
- options.detectPages
1002
+ options.detectPages,
1003
+ options.email,
1004
+ options.password,
1005
+ options.loginPath,
1006
+ options.html,
1007
+ options.mockup
287
1008
  ).catch(err => {
288
1009
  console.error(pc.red(`Terjadi kesalahan tidak terduga: ${err.message}`));
289
1010
  process.exit(1);
package/package.json CHANGED
@@ -1,14 +1,19 @@
1
1
  {
2
2
  "name": "mobile-snap",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "Automate pixel-precise App Store & Google Play screenshots from a local web server",
5
5
  "type": "module",
6
6
  "main": "bin/cli.js",
7
7
  "bin": {
8
8
  "mobile-snap": "bin/cli.js"
9
9
  },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/firstryan/mobile-snap.git"
13
+ },
10
14
  "files": [
11
15
  "bin",
16
+ "assets",
12
17
  "README.md",
13
18
  "README.html"
14
19
  ],