mobile-snap 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.html +35 -5
- package/README.md +17 -2
- package/bin/cli.js +173 -22
- package/package.json +7 -2
- package/__pycache__/setup.cpython-312.pyc +0 -0
- package/mobilesnap/__init__.py +0 -1
- package/mobilesnap/__pycache__/main.cpython-312.pyc +0 -0
- package/mobilesnap/main.py +0 -192
- package/mobilesnap.egg-info/PKG-INFO +0 -9
- package/mobilesnap.egg-info/SOURCES.txt +0 -10
- package/mobilesnap.egg-info/dependency_links.txt +0 -1
- package/mobilesnap.egg-info/entry_points.txt +0 -2
- package/mobilesnap.egg-info/requires.txt +0 -3
- package/mobilesnap.egg-info/top_level.txt +0 -1
- package/requirements.txt +0 -3
- package/setup.py +0 -18
package/README.html
CHANGED
|
@@ -721,15 +721,17 @@
|
|
|
721
721
|
<p style="color: var(--text-muted);">Gunakan builder interaktif ini untuk membuat dan menyalin perintah sesuai kebutuhan pengembangan Anda:</p>
|
|
722
722
|
|
|
723
723
|
<div class="builder-container">
|
|
724
|
-
<div class="grid-
|
|
724
|
+
<div class="grid-2">
|
|
725
725
|
<div class="form-group">
|
|
726
726
|
<label for="input-url">URL Dev Server Lokal</label>
|
|
727
727
|
<input type="text" id="input-url" class="form-control" value="http://localhost:4321" oninput="updateCommand()">
|
|
728
728
|
</div>
|
|
729
729
|
<div class="form-group">
|
|
730
730
|
<label for="input-paths">Rute / Paths (pisahkan koma)</label>
|
|
731
|
-
<input type="text" id="input-paths" class="form-control" value="
|
|
731
|
+
<input type="text" id="input-paths" class="form-control" value="/" oninput="updateCommand()">
|
|
732
732
|
</div>
|
|
733
|
+
</div>
|
|
734
|
+
<div class="grid-3">
|
|
733
735
|
<div class="form-group">
|
|
734
736
|
<label for="input-platform">Platform Target</label>
|
|
735
737
|
<select id="input-platform" class="form-control" onchange="updateCommand()">
|
|
@@ -738,14 +740,22 @@
|
|
|
738
740
|
<option value="both">Both (iOS & Android Sekaligus)</option>
|
|
739
741
|
</select>
|
|
740
742
|
</div>
|
|
743
|
+
<div class="form-group" style="display: flex; align-items: center; gap: 0.5rem; margin-top: 1.5rem;">
|
|
744
|
+
<input type="checkbox" id="input-crawl" onchange="updateCommand()" style="width: 20px; height: 20px; accent-color: var(--primary);">
|
|
745
|
+
<label for="input-crawl" style="margin-bottom: 0; cursor: pointer; color: var(--text-main); font-size: 0.85rem;">Auto-Crawl Links (-c)</label>
|
|
746
|
+
</div>
|
|
747
|
+
<div class="form-group" style="display: flex; align-items: center; gap: 0.5rem; margin-top: 1.5rem;">
|
|
748
|
+
<input type="checkbox" id="input-detect" onchange="updateCommand()" style="width: 20px; height: 20px; accent-color: var(--primary);">
|
|
749
|
+
<label for="input-detect" style="margin-bottom: 0; cursor: pointer; color: var(--text-main); font-size: 0.85rem;">Detect Astro Pages (-d)</label>
|
|
750
|
+
</div>
|
|
741
751
|
</div>
|
|
742
|
-
<div class="form-group">
|
|
752
|
+
<div class="form-group" style="margin-top: 1rem;">
|
|
743
753
|
<label for="input-output">Folder Output</label>
|
|
744
754
|
<input type="text" id="input-output" class="form-control" value="mobilesnap_output" oninput="updateCommand()">
|
|
745
755
|
</div>
|
|
746
756
|
|
|
747
757
|
<div class="builder-output">
|
|
748
|
-
<div class="builder-cmd" id="cmd-preview">npx mobile-snap --url http://localhost:4321
|
|
758
|
+
<div class="builder-cmd" id="cmd-preview">npx mobile-snap --url http://localhost:4321</div>
|
|
749
759
|
<button class="btn-action-copy" onclick="copyText('cmd-preview', true)">Copy Command</button>
|
|
750
760
|
</div>
|
|
751
761
|
</div>
|
|
@@ -788,6 +798,18 @@
|
|
|
788
798
|
<td>Folder tujuan untuk menaruh berkas gambar hasil tangkapan.</td>
|
|
789
799
|
<td><code style="color: var(--secondary);">"mobilesnap_output"</code></td>
|
|
790
800
|
</tr>
|
|
801
|
+
<tr>
|
|
802
|
+
<td><span class="param-tag">--crawl</span></td>
|
|
803
|
+
<td><span class="param-tag">-c</span></td>
|
|
804
|
+
<td>Menelusuri secara otomatis semua link internal dari halaman beranda.</td>
|
|
805
|
+
<td><code style="color: var(--secondary);">false</code></td>
|
|
806
|
+
</tr>
|
|
807
|
+
<tr>
|
|
808
|
+
<td><span class="param-tag">--detect-pages</span></td>
|
|
809
|
+
<td><span class="param-tag">-d</span></td>
|
|
810
|
+
<td>Memindai direktori folder proyek lokal (Astro/Next.js) untuk rute statis.</td>
|
|
811
|
+
<td><code style="color: var(--secondary);">false</code></td>
|
|
812
|
+
</tr>
|
|
791
813
|
</tbody>
|
|
792
814
|
</table>
|
|
793
815
|
</section>
|
|
@@ -832,9 +854,11 @@
|
|
|
832
854
|
const paths = document.getElementById('input-paths').value.trim();
|
|
833
855
|
const output = document.getElementById('input-output').value.trim() || 'mobilesnap_output';
|
|
834
856
|
const platform = document.getElementById('input-platform').value;
|
|
857
|
+
const crawl = document.getElementById('input-crawl').checked;
|
|
858
|
+
const detect = document.getElementById('input-detect').checked;
|
|
835
859
|
|
|
836
860
|
let command = `npx mobile-snap --url ${url}`;
|
|
837
|
-
if (paths) {
|
|
861
|
+
if (paths && paths !== '/') {
|
|
838
862
|
command += ` --paths "${paths}"`;
|
|
839
863
|
}
|
|
840
864
|
if (platform !== 'ios') {
|
|
@@ -843,6 +867,12 @@
|
|
|
843
867
|
if (output !== 'mobilesnap_output') {
|
|
844
868
|
command += ` --output ${output}`;
|
|
845
869
|
}
|
|
870
|
+
if (crawl) {
|
|
871
|
+
command += ` --crawl`;
|
|
872
|
+
}
|
|
873
|
+
if (detect) {
|
|
874
|
+
command += ` --detect-pages`;
|
|
875
|
+
}
|
|
846
876
|
|
|
847
877
|
document.getElementById('cmd-preview').innerText = command;
|
|
848
878
|
}
|
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ npm install -g mobile-snap
|
|
|
77
77
|
|
|
78
78
|
## 💻 Panduan Penggunaan CLI
|
|
79
79
|
|
|
80
|
-
Aplikasi ini menerima
|
|
80
|
+
Aplikasi ini menerima opsi utama berikut:
|
|
81
81
|
|
|
82
82
|
| Parameter | Singkatan | Deskripsi | Standar (Default) | Pilihan |
|
|
83
83
|
| :--- | :--- | :--- | :--- | :--- |
|
|
@@ -85,6 +85,8 @@ Aplikasi ini menerima 4 opsi utama:
|
|
|
85
85
|
| `--paths` | `-p` | Jalur/rute halaman yang dipisahkan tanda koma. | `/` | - |
|
|
86
86
|
| `--output`| `-o` | Nama direktori tempat menyimpan gambar. | `mobilesnap_output` | - |
|
|
87
87
|
| `--platform`| `-l`| Platform target tangkapan layar. | `ios` | `ios`, `android`, `both` |
|
|
88
|
+
| `--crawl` | `-c` | Mengaktifkan penelusuran (crawl) otomatis tautan internal di halaman beranda. | `false` | - |
|
|
89
|
+
| `--detect-pages` | `-d` | Memindai direktori halaman lokal (`src/pages` atau `pages`) untuk rute statis. | `false` | - |
|
|
88
90
|
|
|
89
91
|
### Contoh Perintah
|
|
90
92
|
|
|
@@ -98,8 +100,21 @@ npx mobile-snap --url http://localhost:4321
|
|
|
98
100
|
npx mobile-snap --url http://localhost:4321 --platform android
|
|
99
101
|
```
|
|
100
102
|
|
|
101
|
-
#### 3. Pengambilan 2 Platform Sekaligus
|
|
103
|
+
#### 3. Pengambilan Rute Tertentu untuk 2 Platform Sekaligus
|
|
102
104
|
Mengambil gambar halaman utama `/` dan halaman `/scan` untuk kedua platform sekaligus ke folder `hasil_store`:
|
|
103
105
|
```powershell
|
|
104
106
|
npx mobile-snap --url http://localhost:4321 --paths "/, /scan" --platform both --output hasil_store
|
|
105
107
|
```
|
|
108
|
+
|
|
109
|
+
#### 4. Auto-Crawl Halaman Web
|
|
110
|
+
Menelusuri semua tautan internal secara otomatis dari beranda dan memotret setiap halaman yang ditemukan untuk iOS & Android:
|
|
111
|
+
```powershell
|
|
112
|
+
npx mobile-snap --url http://localhost:4321 --crawl --platform both
|
|
113
|
+
```
|
|
114
|
+
|
|
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`:
|
|
117
|
+
```powershell
|
|
118
|
+
npx mobile-snap --url http://localhost:4321 --detect-pages --platform both
|
|
119
|
+
```
|
|
120
|
+
|
package/bin/cli.js
CHANGED
|
@@ -7,19 +7,19 @@ import ora from 'ora';
|
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
|
|
10
|
-
// Device configurations
|
|
10
|
+
// Device configurations dengan ukuran logis (CSS pixels) dan scale factor untuk resolusi fisik presisi
|
|
11
11
|
const DEVICE_CONFIGS = {
|
|
12
12
|
ios: {
|
|
13
13
|
devices: {
|
|
14
|
-
"6.7_inch": { width:
|
|
15
|
-
"6.5_inch": { width:
|
|
14
|
+
"6.7_inch": { logical: { width: 430, height: 932 }, scale: 3 }, // Fisik: 1290 x 2796 (iPhone 14/15 Pro Max)
|
|
15
|
+
"6.5_inch": { logical: { width: 414, height: 896 }, scale: 3 } // Fisik: 1242 x 2688 (iPhone Xs Max/11 Pro Max)
|
|
16
16
|
},
|
|
17
17
|
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1"
|
|
18
18
|
},
|
|
19
19
|
android: {
|
|
20
20
|
devices: {
|
|
21
|
-
"android_phone": { width:
|
|
22
|
-
"android_tablet": { width:
|
|
21
|
+
"android_phone": { logical: { width: 360, height: 800 }, scale: 3 }, // Fisik: 1080 x 2400 (Pixel 7 dll)
|
|
22
|
+
"android_tablet": { logical: { width: 800, height: 1280 }, scale: 2 } // Fisik: 1600 x 2560
|
|
23
23
|
},
|
|
24
24
|
userAgent: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36"
|
|
25
25
|
}
|
|
@@ -31,7 +31,101 @@ function safeFilename(route) {
|
|
|
31
31
|
return cleanPath.replace(/[^a-zA-Z0-9_\-]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// Fungsi pembantu untuk memindai folder src/pages atau pages (Astro/Next.js)
|
|
35
|
+
function detectLocalPages(dir = process.cwd()) {
|
|
36
|
+
const pagesDirs = [
|
|
37
|
+
path.join(dir, 'src', 'pages'),
|
|
38
|
+
path.join(dir, 'pages')
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
let pagesDir = null;
|
|
42
|
+
for (const p of pagesDirs) {
|
|
43
|
+
if (fs.existsSync(p)) {
|
|
44
|
+
pagesDir = p;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!pagesDir) return null;
|
|
50
|
+
|
|
51
|
+
const routes = [];
|
|
52
|
+
function scan(currentDir, baseRoute = '') {
|
|
53
|
+
const files = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
const fullPath = path.join(currentDir, file.name);
|
|
56
|
+
if (file.isDirectory()) {
|
|
57
|
+
scan(fullPath, `${baseRoute}/${file.name}`);
|
|
58
|
+
} else if (file.isFile()) {
|
|
59
|
+
const ext = path.extname(file.name);
|
|
60
|
+
const name = path.basename(file.name, ext);
|
|
61
|
+
if (['.astro', '.md', '.mdx', '.html', '.js', '.jsx', '.ts', '.tsx'].includes(ext.toLowerCase())) {
|
|
62
|
+
let route = `${baseRoute}/${name}`;
|
|
63
|
+
if (name === 'index') {
|
|
64
|
+
route = baseRoute || '/';
|
|
65
|
+
}
|
|
66
|
+
// Abaikan rute dinamis yang mengandung [ atau ]
|
|
67
|
+
if (!route.includes('[') && !route.includes(']')) {
|
|
68
|
+
// Pastikan diawali /
|
|
69
|
+
let finalRoute = '/' + route.replace(/^\/+/, '');
|
|
70
|
+
// Abaikan rute API (server endpoint)
|
|
71
|
+
if (!finalRoute.startsWith('/api/')) {
|
|
72
|
+
routes.push(finalRoute);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
scan(pagesDir);
|
|
81
|
+
return [...new Set(routes)];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Fungsi pembantu untuk melakukan crawling internal links dari halaman utama
|
|
85
|
+
async function discoverLinks(browser, baseUrl) {
|
|
86
|
+
const context = await browser.newContext();
|
|
87
|
+
const page = await context.newPage();
|
|
88
|
+
const links = new Set(['/']);
|
|
89
|
+
|
|
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);
|
|
99
|
+
});
|
|
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);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
// Href tidak valid (seperti tel:, mailto:, javascript:) diabaikan
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Abaikan jika gagal memuat sebagian halaman utama, return '/'
|
|
121
|
+
} finally {
|
|
122
|
+
await context.close();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return Array.from(links);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function captureScreenshots(url, paths, outputDir, platform, crawl, detectPages) {
|
|
35
129
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
36
130
|
url = 'http://' + url;
|
|
37
131
|
}
|
|
@@ -44,16 +138,30 @@ async function captureScreenshots(url, paths, outputDir, platform) {
|
|
|
44
138
|
targetPlatforms = [platform];
|
|
45
139
|
}
|
|
46
140
|
|
|
141
|
+
let finalPaths = [...paths];
|
|
142
|
+
|
|
143
|
+
// 1. Auto-detect halaman lokal jika diaktifkan
|
|
144
|
+
if (detectPages) {
|
|
145
|
+
const localRoutes = detectLocalPages();
|
|
146
|
+
if (localRoutes && localRoutes.length > 0) {
|
|
147
|
+
console.log(pc.green(`🔍 Mendeteksi folder halaman lokal. Menambahkan ${localRoutes.length} rute statis.`));
|
|
148
|
+
finalPaths = [...new Set([...finalPaths, ...localRoutes])];
|
|
149
|
+
} else {
|
|
150
|
+
console.log(pc.yellow(`⚠ Folder 'src/pages' atau 'pages' tidak ditemukan di direktori saat ini.`));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
47
154
|
console.log(pc.bold(pc.blue('Starting MobileSnap screenshot automation...')));
|
|
48
155
|
console.log(`Target Server: ${pc.cyan(url)}`);
|
|
49
156
|
console.log(`Platform(s): ${pc.cyan(targetPlatforms.join(', ').toUpperCase())}`);
|
|
50
157
|
console.log(`Output Directory: ${pc.cyan(path.resolve(outputDir))}\n`);
|
|
51
158
|
|
|
52
|
-
//
|
|
159
|
+
// Pastikan direktori output ada
|
|
53
160
|
if (!fs.existsSync(outputDir)) {
|
|
54
161
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
55
162
|
}
|
|
56
163
|
|
|
164
|
+
// Luncurkan browser
|
|
57
165
|
let browser;
|
|
58
166
|
const launchSpinner = ora('Launching Chromium browser...').start();
|
|
59
167
|
try {
|
|
@@ -66,45 +174,76 @@ async function captureScreenshots(url, paths, outputDir, platform) {
|
|
|
66
174
|
process.exit(1);
|
|
67
175
|
}
|
|
68
176
|
|
|
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
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
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);
|
|
197
|
+
|
|
198
|
+
// Hilangkan duplikasi rute
|
|
199
|
+
finalPaths = [...new Set(finalPaths)];
|
|
200
|
+
|
|
201
|
+
if (finalPaths.length === 0) {
|
|
202
|
+
finalPaths = ['/'];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log(pc.bold(`\nRute yang akan dipotret (${finalPaths.length}):`));
|
|
206
|
+
finalPaths.forEach(p => console.log(` - ${pc.cyan(p)}`));
|
|
207
|
+
console.log('');
|
|
208
|
+
|
|
69
209
|
for (const plat of targetPlatforms) {
|
|
70
210
|
const config = DEVICE_CONFIGS[plat];
|
|
71
211
|
console.log(pc.bold(pc.blue(`💻 Platform: ${plat.toUpperCase()}`)));
|
|
72
212
|
|
|
73
213
|
for (const [deviceName, size] of Object.entries(config.devices)) {
|
|
74
|
-
console.log(pc.magenta(` 📱 Processing ${deviceName} (${size.width}x${size.height}px)...`));
|
|
214
|
+
console.log(pc.magenta(` 📱 Processing ${deviceName} (${size.logical.width * size.scale}x${size.logical.height * size.scale}px)...`));
|
|
75
215
|
|
|
76
216
|
const context = await browser.newContext({
|
|
77
|
-
viewport:
|
|
217
|
+
viewport: size.logical,
|
|
78
218
|
userAgent: config.userAgent,
|
|
79
|
-
deviceScaleFactor:
|
|
219
|
+
deviceScaleFactor: size.scale,
|
|
80
220
|
isMobile: true,
|
|
81
221
|
hasTouch: true
|
|
82
222
|
});
|
|
83
223
|
|
|
84
224
|
const page = await context.newPage();
|
|
85
225
|
|
|
86
|
-
for (const route of
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const nameSnippet = safeFilename(normalizedPath);
|
|
226
|
+
for (const route of finalPaths) {
|
|
227
|
+
const targetUrl = `${url}${route}`;
|
|
228
|
+
const nameSnippet = safeFilename(route);
|
|
90
229
|
const filename = `${deviceName}_${nameSnippet}.png`;
|
|
91
230
|
const outputPath = path.join(outputDir, filename);
|
|
92
231
|
|
|
93
|
-
const pageSpinner = ora(` Navigating to ${
|
|
232
|
+
const pageSpinner = ora(` Navigating to ${route}...`).start();
|
|
94
233
|
|
|
95
234
|
try {
|
|
96
235
|
await page.goto(targetUrl, { timeout: 30000 });
|
|
97
|
-
pageSpinner.text = ` Waiting for network idle on ${
|
|
236
|
+
pageSpinner.text = ` Waiting for network idle on ${route}...`;
|
|
98
237
|
await page.waitForLoadState('networkidle', { timeout: 15000 });
|
|
99
238
|
|
|
100
|
-
//
|
|
101
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
239
|
+
// Tunggu sebentar untuk layout stabil
|
|
240
|
+
await new Promise(resolve => setTimeout(resolve, 800));
|
|
102
241
|
|
|
103
242
|
pageSpinner.text = ` Saving screenshot ${filename}...`;
|
|
104
243
|
await page.screenshot({ path: outputPath, fullPage: false });
|
|
105
244
|
pageSpinner.succeed(pc.green(` ✔ Saved ${filename}`));
|
|
106
245
|
} catch (err) {
|
|
107
|
-
pageSpinner.fail(pc.red(` ✘ Failed to capture ${
|
|
246
|
+
pageSpinner.fail(pc.red(` ✘ Failed to capture ${route}: ${err.message}`));
|
|
108
247
|
}
|
|
109
248
|
}
|
|
110
249
|
|
|
@@ -124,9 +263,13 @@ program
|
|
|
124
263
|
.option('-p, --paths <paths>', 'Comma-separated list of routes to capture', '/')
|
|
125
264
|
.option('-o, --output <output>', 'Output directory to save screenshots', 'mobilesnap_output')
|
|
126
265
|
.option('-l, --platform <platform>', 'Target platform: "ios", "android", or "both"', 'ios')
|
|
266
|
+
.option('-c, --crawl', 'Discover and screenshot all internal links automatically', false)
|
|
267
|
+
.option('-d, --detect-pages', 'Scan local project pages directory (src/pages or pages) for static routes', false)
|
|
127
268
|
.action((options) => {
|
|
128
|
-
|
|
129
|
-
|
|
269
|
+
let pathList = [];
|
|
270
|
+
if (options.paths) {
|
|
271
|
+
pathList = options.paths.split(',').map(p => p.trim()).filter(Boolean);
|
|
272
|
+
}
|
|
130
273
|
|
|
131
274
|
const platformVal = options.platform.toLowerCase();
|
|
132
275
|
if (!['ios', 'android', 'both'].includes(platformVal)) {
|
|
@@ -134,10 +277,18 @@ program
|
|
|
134
277
|
process.exit(1);
|
|
135
278
|
}
|
|
136
279
|
|
|
137
|
-
captureScreenshots(
|
|
280
|
+
captureScreenshots(
|
|
281
|
+
options.url,
|
|
282
|
+
pathList,
|
|
283
|
+
options.output,
|
|
284
|
+
platformVal,
|
|
285
|
+
options.crawl,
|
|
286
|
+
options.detectPages
|
|
287
|
+
).catch(err => {
|
|
138
288
|
console.error(pc.red(`Terjadi kesalahan tidak terduga: ${err.message}`));
|
|
139
289
|
process.exit(1);
|
|
140
290
|
});
|
|
141
291
|
});
|
|
142
292
|
|
|
143
293
|
program.parse(process.argv);
|
|
294
|
+
|
package/package.json
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mobile-snap",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
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
|
-
"mobile-snap": "
|
|
8
|
+
"mobile-snap": "bin/cli.js"
|
|
9
9
|
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"README.md",
|
|
13
|
+
"README.html"
|
|
14
|
+
],
|
|
10
15
|
"keywords": [
|
|
11
16
|
"screenshot",
|
|
12
17
|
"app store",
|
|
Binary file
|
package/mobilesnap/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# MobileSnap package
|
|
Binary file
|
package/mobilesnap/main.py
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import os
|
|
3
|
-
import re
|
|
4
|
-
from enum import Enum
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import List, Optional
|
|
7
|
-
import typer
|
|
8
|
-
from playwright.async_api import async_playwright, Error as PlaywrightError
|
|
9
|
-
from rich.console import Console
|
|
10
|
-
|
|
11
|
-
# Initialize Typer app and Rich Console
|
|
12
|
-
app = typer.Typer(
|
|
13
|
-
name="mobilesnap",
|
|
14
|
-
help="⚡ MobileSnap: Automate pixel-precise App Store & Google Play screenshots from local web servers.",
|
|
15
|
-
add_completion=False,
|
|
16
|
-
)
|
|
17
|
-
console = Console()
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
# Enum for Platform choices
|
|
21
|
-
class Platform(str, Enum):
|
|
22
|
-
ios = "ios"
|
|
23
|
-
android = "android"
|
|
24
|
-
both = "both"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
# Device configurations grouped by platform
|
|
28
|
-
DEVICE_CONFIGS = {
|
|
29
|
-
"ios": {
|
|
30
|
-
"devices": {
|
|
31
|
-
"6.7_inch": {"width": 1290, "height": 2796},
|
|
32
|
-
"6.5_inch": {"width": 1242, "height": 2688},
|
|
33
|
-
},
|
|
34
|
-
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1",
|
|
35
|
-
},
|
|
36
|
-
"android": {
|
|
37
|
-
"devices": {
|
|
38
|
-
"android_phone": {"width": 1080, "height": 2400},
|
|
39
|
-
"android_tablet": {"width": 1600, "height": 2560},
|
|
40
|
-
},
|
|
41
|
-
"user_agent": "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36",
|
|
42
|
-
},
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def safe_filename(path: str) -> str:
|
|
47
|
-
"""Converts a URL path into a safe, descriptive file name snippet."""
|
|
48
|
-
# Strip leading/trailing slashes
|
|
49
|
-
clean_path = path.strip("/")
|
|
50
|
-
if not clean_path:
|
|
51
|
-
return "home"
|
|
52
|
-
# Replace non-alphanumeric characters with underscores
|
|
53
|
-
clean_path = re.sub(r"[^a-zA-Z0-9_\-]", "_", clean_path)
|
|
54
|
-
# Replace multiple consecutive underscores with a single one
|
|
55
|
-
clean_path = re.sub(r"_+", "_", clean_path)
|
|
56
|
-
return clean_path.strip("_")
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
async def capture_screenshots(url: str, paths: List[str], output_dir: Path, platform: Platform):
|
|
60
|
-
"""Core async capture logic using Playwright."""
|
|
61
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
-
|
|
63
|
-
# Normalize url (ensure it has a scheme)
|
|
64
|
-
if not url.startswith(("http://", "https://")):
|
|
65
|
-
url = "http://" + url
|
|
66
|
-
url = url.rstrip("/")
|
|
67
|
-
|
|
68
|
-
# Determine which platforms to target
|
|
69
|
-
if platform == Platform.both:
|
|
70
|
-
target_platforms = ["ios", "android"]
|
|
71
|
-
else:
|
|
72
|
-
target_platforms = [platform.value]
|
|
73
|
-
|
|
74
|
-
console.print(f"[bold blue]Starting MobileSnap screenshot automation...[/bold blue]")
|
|
75
|
-
console.print(f"Target Server: [cyan]{url}[/cyan]")
|
|
76
|
-
console.print(f"Platform(s): [cyan]{', '.join(target_platforms).upper()}[/cyan]")
|
|
77
|
-
console.print(f"Output Directory: [cyan]{output_dir.resolve()}[/cyan]\n")
|
|
78
|
-
|
|
79
|
-
async with async_playwright() as p:
|
|
80
|
-
# Launch headless Chromium
|
|
81
|
-
with console.status("[yellow]Launching Chromium browser...[/yellow]") as status:
|
|
82
|
-
try:
|
|
83
|
-
browser = await p.chromium.launch(headless=True)
|
|
84
|
-
except Exception as e:
|
|
85
|
-
console.print(f"[bold red]Failed to launch Chromium browser: {e}[/bold red]")
|
|
86
|
-
raise typer.Exit(code=1)
|
|
87
|
-
|
|
88
|
-
# Loop through each target platform
|
|
89
|
-
for plat in target_platforms:
|
|
90
|
-
config = DEVICE_CONFIGS[plat]
|
|
91
|
-
user_agent = config["user_agent"]
|
|
92
|
-
devices = config["devices"]
|
|
93
|
-
|
|
94
|
-
console.print(f"[bold blue]💻 Platform: {plat.upper()}[/bold blue]")
|
|
95
|
-
|
|
96
|
-
for device_name, size in devices.items():
|
|
97
|
-
width, height = size["width"], size["height"]
|
|
98
|
-
console.print(f" [bold magenta]📱 Processing {device_name} ({width}x{height}px)...[/bold magenta]")
|
|
99
|
-
|
|
100
|
-
# Set up context
|
|
101
|
-
context = await browser.new_context(
|
|
102
|
-
viewport={"width": width, "height": height},
|
|
103
|
-
user_agent=user_agent,
|
|
104
|
-
device_scale_factor=3, # High DPI for crisp screenshots
|
|
105
|
-
is_mobile=True,
|
|
106
|
-
has_touch=True,
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
page = await context.new_page()
|
|
110
|
-
|
|
111
|
-
for path in paths:
|
|
112
|
-
normalized_path = "/" + path.lstrip("/")
|
|
113
|
-
target_url = f"{url}{normalized_path}"
|
|
114
|
-
name_snippet = safe_filename(normalized_path)
|
|
115
|
-
filename = f"{device_name}_{name_snippet}.png"
|
|
116
|
-
output_path = output_dir / filename
|
|
117
|
-
|
|
118
|
-
with console.status(f" Navigating to {normalized_path}...") as status:
|
|
119
|
-
try:
|
|
120
|
-
await page.goto(target_url, timeout=30000)
|
|
121
|
-
status.update(f" Waiting for network idle on {normalized_path}...")
|
|
122
|
-
await page.wait_for_load_state("networkidle", timeout=15000)
|
|
123
|
-
await asyncio.sleep(0.5)
|
|
124
|
-
|
|
125
|
-
status.update(f" Saving screenshot {filename}...")
|
|
126
|
-
await page.screenshot(path=str(output_path), full_page=False)
|
|
127
|
-
|
|
128
|
-
console.print(
|
|
129
|
-
f" [green]✔[/green] Saved [bold green]{filename}[/bold green]"
|
|
130
|
-
)
|
|
131
|
-
except PlaywrightError as err:
|
|
132
|
-
console.print(
|
|
133
|
-
f" [red]✘[/red] Failed to capture [cyan]{normalized_path}[/cyan]: {err.message}"
|
|
134
|
-
)
|
|
135
|
-
except Exception as err:
|
|
136
|
-
console.print(
|
|
137
|
-
f" [red]✘[/red] Error on [cyan]{normalized_path}[/cyan]: {err}"
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
await context.close()
|
|
141
|
-
|
|
142
|
-
await browser.close()
|
|
143
|
-
|
|
144
|
-
console.print(f"\n[bold green]🎉 Selesai! Semua tangkapan layar disimpan di '{output_dir}'.[/bold green]")
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
@app.command()
|
|
148
|
-
def main(
|
|
149
|
-
url: str = typer.Option(
|
|
150
|
-
...,
|
|
151
|
-
"--url",
|
|
152
|
-
"-u",
|
|
153
|
-
help="Base URL of the local development server (e.g., localhost:3000 or http://127.0.0.1:4321)",
|
|
154
|
-
),
|
|
155
|
-
paths: str = typer.Option(
|
|
156
|
-
"/",
|
|
157
|
-
"--paths",
|
|
158
|
-
"-p",
|
|
159
|
-
help="Comma-separated list of paths/routes to capture (e.g., '/, /scan, /profile')",
|
|
160
|
-
),
|
|
161
|
-
output: Path = typer.Option(
|
|
162
|
-
Path("mobilesnap_output"),
|
|
163
|
-
"--output",
|
|
164
|
-
"-o",
|
|
165
|
-
help="Output directory to save the screenshots",
|
|
166
|
-
),
|
|
167
|
-
platform: Platform = typer.Option(
|
|
168
|
-
Platform.ios,
|
|
169
|
-
"--platform",
|
|
170
|
-
"-l",
|
|
171
|
-
help="Target platform: 'ios', 'android', or 'both'",
|
|
172
|
-
),
|
|
173
|
-
):
|
|
174
|
-
"""
|
|
175
|
-
⚡ MobileSnap CLI: Generate pixel-precise App Store & Google Play screenshots from a local web server automatically.
|
|
176
|
-
"""
|
|
177
|
-
# Parse paths from comma-separated string
|
|
178
|
-
path_list = [p.strip() for p in paths.split(",") if p.strip()]
|
|
179
|
-
if not path_list:
|
|
180
|
-
path_list = ["/"]
|
|
181
|
-
|
|
182
|
-
try:
|
|
183
|
-
# Run the async core loop
|
|
184
|
-
asyncio.run(capture_screenshots(url=url, paths=path_list, output_dir=output, platform=platform))
|
|
185
|
-
except Exception as e:
|
|
186
|
-
console.print(f"[bold red]An unexpected error occurred during execution: {e}[/bold red]")
|
|
187
|
-
raise typer.Exit(code=1)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if __name__ == "__main__":
|
|
191
|
-
app()
|
|
192
|
-
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
README.md
|
|
2
|
-
setup.py
|
|
3
|
-
mobilesnap/__init__.py
|
|
4
|
-
mobilesnap/main.py
|
|
5
|
-
mobilesnap.egg-info/PKG-INFO
|
|
6
|
-
mobilesnap.egg-info/SOURCES.txt
|
|
7
|
-
mobilesnap.egg-info/dependency_links.txt
|
|
8
|
-
mobilesnap.egg-info/entry_points.txt
|
|
9
|
-
mobilesnap.egg-info/requires.txt
|
|
10
|
-
mobilesnap.egg-info/top_level.txt
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
mobilesnap
|
package/requirements.txt
DELETED
package/setup.py
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
from setuptools import setup, find_packages
|
|
2
|
-
|
|
3
|
-
setup(
|
|
4
|
-
name="mobilesnap",
|
|
5
|
-
version="0.1.0",
|
|
6
|
-
packages=find_packages(),
|
|
7
|
-
install_requires=[
|
|
8
|
-
"typer>=0.9.0",
|
|
9
|
-
"playwright>=1.40.0",
|
|
10
|
-
"rich>=13.0.0",
|
|
11
|
-
],
|
|
12
|
-
entry_points={
|
|
13
|
-
"console_scripts": [
|
|
14
|
-
"mobilesnap=mobilesnap.main:app",
|
|
15
|
-
],
|
|
16
|
-
},
|
|
17
|
-
python_requires=">=3.8",
|
|
18
|
-
)
|