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 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-3">
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="/, /scan, /profile" oninput="updateCommand()">
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 --paths "/, /scan, /profile" --output mobilesnap_output</div>
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 4 opsi utama:
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 (iOS & Android)
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 grouped by platform
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: 1290, height: 2796 },
15
- "6.5_inch": { width: 1242, height: 2688 }
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: 1080, height: 2400 },
22
- "android_tablet": { width: 1600, height: 2560 }
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
- async function captureScreenshots(url, paths, outputDir, platform) {
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
- // Ensure directory exists
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: { width: size.width, height: size.height },
217
+ viewport: size.logical,
78
218
  userAgent: config.userAgent,
79
- deviceScaleFactor: 3, // High DPI for crisp screenshots
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 paths) {
87
- const normalizedPath = '/' + route.replace(/^\/+/, '');
88
- const targetUrl = `${url}${normalizedPath}`;
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 ${normalizedPath}...`).start();
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 ${normalizedPath}...`;
236
+ pageSpinner.text = ` Waiting for network idle on ${route}...`;
98
237
  await page.waitForLoadState('networkidle', { timeout: 15000 });
99
238
 
100
- // Wait a brief moment for layout/dynamic scripts to settle
101
- await new Promise(resolve => setTimeout(resolve, 500));
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 ${normalizedPath}: ${err.message}`));
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
- const pathList = options.paths.split(',').map(p => p.trim()).filter(Boolean);
129
- const finalPaths = pathList.length ? pathList : ['/'];
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(options.url, finalPaths, options.output, platformVal).catch(err => {
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.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": "./bin/cli.js"
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
@@ -1 +0,0 @@
1
- # MobileSnap package
@@ -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,9 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: mobilesnap
3
- Version: 0.1.0
4
- Requires-Python: >=3.8
5
- Requires-Dist: typer>=0.9.0
6
- Requires-Dist: playwright>=1.40.0
7
- Requires-Dist: rich>=13.0.0
8
- Dynamic: requires-dist
9
- Dynamic: requires-python
@@ -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,2 +0,0 @@
1
- [console_scripts]
2
- mobilesnap = mobilesnap.main:app
@@ -1,3 +0,0 @@
1
- typer>=0.9.0
2
- playwright>=1.40.0
3
- rich>=13.0.0
@@ -1 +0,0 @@
1
- mobilesnap
package/requirements.txt DELETED
@@ -1,3 +0,0 @@
1
- typer>=0.9.0
2
- playwright>=1.40.0
3
- rich>=13.0.0
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
- )