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 +62 -2
- package/README.md +66 -8
- package/assets/android_example.png +0 -0
- package/assets/ios_example.png +0 -0
- package/bin/cli.js +801 -80
- package/package.json +6 -1
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-
|
|
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
|
+
|  |  |
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
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 --
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
await
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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.
|
|
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.
|
|
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
|
],
|