mobile-snap 1.0.5 ā 1.0.7
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/bin/cli.js +1038 -1015
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -1,1015 +1,1038 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { program } from 'commander';
|
|
4
|
-
import { chromium } from 'playwright';
|
|
5
|
-
import pc from 'picocolors';
|
|
6
|
-
import ora from 'ora';
|
|
7
|
-
import fs from 'fs';
|
|
8
|
-
import path from 'path';
|
|
9
|
-
import readline from 'readline';
|
|
10
|
-
|
|
11
|
-
// Device configurations with logical dimensions (CSS pixels) and device scale factor for precise physical resolution
|
|
12
|
-
const DEVICE_CONFIGS = {
|
|
13
|
-
ios: {
|
|
14
|
-
devices: {
|
|
15
|
-
"6.7_inch": { logical: { width:
|
|
16
|
-
"6.5_inch": { logical: { width: 414, height: 896 }, scale: 3 }
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
path.join(dir, 'pages')
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
await page.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
finalPath.includes('
|
|
200
|
-
finalPath
|
|
201
|
-
|
|
202
|
-
finalUrl.includes('
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
let
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
await authPage.
|
|
303
|
-
await authPage.waitForSelector('#
|
|
304
|
-
await authPage.
|
|
305
|
-
await authPage.fill('#
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
await
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
let
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
await authPage.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const
|
|
411
|
-
const
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
const
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
let
|
|
419
|
-
let
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
<div class="
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
<rect x="
|
|
426
|
-
<rect x="
|
|
427
|
-
<rect x="
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
<path
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
<div class="
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
<
|
|
481
|
-
<
|
|
482
|
-
<
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
513
|
-
.device-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
.
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
border-radius:
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
top:
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
width:
|
|
627
|
-
height: 5px;
|
|
628
|
-
|
|
629
|
-
border-radius:
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
await page.
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
console.log(
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
const
|
|
917
|
-
const
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
await
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from 'commander';
|
|
4
|
+
import { chromium } from 'playwright';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import readline from 'readline';
|
|
10
|
+
|
|
11
|
+
// Device configurations with logical dimensions (CSS pixels) and device scale factor for precise physical resolution
|
|
12
|
+
const DEVICE_CONFIGS = {
|
|
13
|
+
ios: {
|
|
14
|
+
devices: {
|
|
15
|
+
"6.7_inch": { logical: { width: 428, height: 926 }, scale: 3 }, // Fisik: 1284 x 2778 (iPhone 12/13/14 Pro Max)
|
|
16
|
+
"6.5_inch": { logical: { width: 414, height: 896 }, scale: 3 }, // Fisik: 1242 x 2688 (iPhone Xs Max/11 Pro Max)
|
|
17
|
+
"ipad_12.9": { logical: { width: 1024, height: 1366 }, scale: 2 } // Fisik: 2048 x 2732 (iPad Pro 12.9-inch)
|
|
18
|
+
},
|
|
19
|
+
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"
|
|
20
|
+
},
|
|
21
|
+
android: {
|
|
22
|
+
devices: {
|
|
23
|
+
"android_phone": { logical: { width: 360, height: 800 }, scale: 3 }, // Fisik: 1080 x 2400 (Pixel 7 dll)
|
|
24
|
+
"android_tablet": { logical: { width: 800, height: 1280 }, scale: 2 } // Fisik: 1600 x 2560
|
|
25
|
+
},
|
|
26
|
+
userAgent: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36"
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function promptUser(query, isPassword = false) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const rl = readline.createInterface({
|
|
33
|
+
input: process.stdin,
|
|
34
|
+
output: process.stdout
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!isPassword) {
|
|
38
|
+
rl.question(query, (answer) => {
|
|
39
|
+
rl.close();
|
|
40
|
+
resolve(answer.trim());
|
|
41
|
+
});
|
|
42
|
+
} else {
|
|
43
|
+
let muted = false;
|
|
44
|
+
const oldWrite = rl._writeToOutput;
|
|
45
|
+
|
|
46
|
+
rl._writeToOutput = function _writeToOutput(stringToWrite) {
|
|
47
|
+
if (muted) {
|
|
48
|
+
if (stringToWrite === '\r' || stringToWrite === '\n' || stringToWrite === '\r\n') {
|
|
49
|
+
oldWrite.call(rl, stringToWrite);
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
oldWrite.call(rl, stringToWrite);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
rl.question(query, (answer) => {
|
|
57
|
+
rl.close();
|
|
58
|
+
resolve(answer);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
muted = true;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function safeFilename(route) {
|
|
67
|
+
const cleanPath = route.replace(/^\/+|\/+$/g, '');
|
|
68
|
+
if (!cleanPath) return 'home';
|
|
69
|
+
return cleanPath.replace(/[^a-zA-Z0-9_\-]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Helper function to scan the src/pages or pages directory (for Astro, Next.js, etc.)
|
|
73
|
+
function detectLocalPages(dir = process.cwd()) {
|
|
74
|
+
const pagesDirs = [
|
|
75
|
+
path.join(dir, 'src', 'pages'),
|
|
76
|
+
path.join(dir, 'pages')
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
let pagesDir = null;
|
|
80
|
+
for (const p of pagesDirs) {
|
|
81
|
+
if (fs.existsSync(p)) {
|
|
82
|
+
pagesDir = p;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!pagesDir) return null;
|
|
88
|
+
|
|
89
|
+
const routes = [];
|
|
90
|
+
function scan(currentDir, baseRoute = '') {
|
|
91
|
+
const files = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
const fullPath = path.join(currentDir, file.name);
|
|
94
|
+
if (file.isDirectory()) {
|
|
95
|
+
scan(fullPath, `${baseRoute}/${file.name}`);
|
|
96
|
+
} else if (file.isFile()) {
|
|
97
|
+
const ext = path.extname(file.name);
|
|
98
|
+
const name = path.basename(file.name, ext);
|
|
99
|
+
if (['.astro', '.md', '.mdx', '.html', '.js', '.jsx', '.ts', '.tsx'].includes(ext.toLowerCase())) {
|
|
100
|
+
let route = `${baseRoute}/${name}`;
|
|
101
|
+
if (name === 'index') {
|
|
102
|
+
route = baseRoute || '/';
|
|
103
|
+
}
|
|
104
|
+
// Ignore dynamic routes containing [ or ]
|
|
105
|
+
if (!route.includes('[') && !route.includes(']')) {
|
|
106
|
+
// Ensure it starts with /
|
|
107
|
+
let finalRoute = '/' + route.replace(/^\/+/, '');
|
|
108
|
+
// Ignore API routes (server endpoints)
|
|
109
|
+
if (!finalRoute.startsWith('/api/')) {
|
|
110
|
+
routes.push(finalRoute);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
scan(pagesDir);
|
|
119
|
+
return [...new Set(routes)];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Helper function to crawl internal links starting from the home page
|
|
123
|
+
async function analyzeRoutes(browser, baseUrl, initialPaths, email, password, loginPath, addHtml, crawl) {
|
|
124
|
+
const normLoginPath = '/' + loginPath.replace(/^\/+/, '');
|
|
125
|
+
const publicRoutes = new Set();
|
|
126
|
+
const authRoutes = new Set();
|
|
127
|
+
const allDetectedRoutes = new Set(initialPaths);
|
|
128
|
+
|
|
129
|
+
const context = await browser.newContext();
|
|
130
|
+
const page = await context.newPage();
|
|
131
|
+
|
|
132
|
+
page.on('console', msg => {
|
|
133
|
+
const text = msg.text();
|
|
134
|
+
if (text.includes('Failed to fetch') || text.includes('TypeError: Failed to fetch')) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (text.includes('[ERROR]') || text.includes('[WARN]')) {
|
|
138
|
+
console.log(pc.dim(` [Crawler Log] ${text}`));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
page.on('pageerror', err => {
|
|
142
|
+
if (err.message.includes('Failed to fetch') || err.message.includes('TypeError: Failed to fetch')) return;
|
|
143
|
+
console.error(pc.red(` [Crawler Error] ${err.message}`));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const nonAuthSpinner = ora('Analyzing public routes and detecting authentication requirements...').start();
|
|
147
|
+
const routesToCheck = Array.from(allDetectedRoutes);
|
|
148
|
+
|
|
149
|
+
// If the initial path list is empty, default to the root path '/'
|
|
150
|
+
if (routesToCheck.length === 0) {
|
|
151
|
+
routesToCheck.push('/');
|
|
152
|
+
allDetectedRoutes.add('/');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < routesToCheck.length; i++) {
|
|
156
|
+
const route = routesToCheck[i];
|
|
157
|
+
let cleanRoute = route;
|
|
158
|
+
if (addHtml && cleanRoute !== '/' && !cleanRoute.endsWith('.html')) {
|
|
159
|
+
cleanRoute += '.html';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const targetUrl = `${baseUrl}${cleanRoute}`;
|
|
163
|
+
try {
|
|
164
|
+
await page.goto(targetUrl, { timeout: 15000 });
|
|
165
|
+
await page.waitForLoadState('networkidle', { timeout: 3000 }).catch(() => {});
|
|
166
|
+
|
|
167
|
+
// Wait dynamically for client-side redirects (such as splash screen to login) to complete
|
|
168
|
+
if (cleanRoute === '/' || cleanRoute.includes('splash')) {
|
|
169
|
+
await page.waitForURL(u => {
|
|
170
|
+
const pathname = u.pathname;
|
|
171
|
+
return pathname !== '/' && !pathname.includes('splash');
|
|
172
|
+
}, { timeout: 15000 }).catch(() => {});
|
|
173
|
+
} else {
|
|
174
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const finalUrl = page.url();
|
|
178
|
+
let finalPath = '';
|
|
179
|
+
try {
|
|
180
|
+
finalPath = new URL(finalUrl).pathname;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
finalPath = finalUrl;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Normalize finalPath to end with .html if the flag is active
|
|
186
|
+
if (addHtml && finalPath !== '/' && !finalPath.endsWith('.html') && !/\.[a-z0-9]+$/i.test(finalPath)) {
|
|
187
|
+
finalPath += '.html';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check if a login form or credential inputs are present in the current page DOM
|
|
191
|
+
const hasLoginForm = await page.evaluate(() => {
|
|
192
|
+
const hasUser = !!document.querySelector('#username, input[type="email"], input[name="username"], input[name="login"]');
|
|
193
|
+
const hasPass = !!document.querySelector('#password, input[type="password"], input[name="password"]');
|
|
194
|
+
const hasForm = !!document.querySelector('#loginForm, form.login-form, form[action*="login"]');
|
|
195
|
+
return (hasUser && hasPass) || hasForm;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const isRedirectToLogin = hasLoginForm ||
|
|
199
|
+
finalPath.includes('login') ||
|
|
200
|
+
finalPath.includes('splash') ||
|
|
201
|
+
finalPath === normLoginPath ||
|
|
202
|
+
finalUrl.includes('login') ||
|
|
203
|
+
finalUrl.includes('splash');
|
|
204
|
+
|
|
205
|
+
if (isRedirectToLogin) {
|
|
206
|
+
if (cleanRoute !== '/' && cleanRoute !== normLoginPath && !cleanRoute.includes('splash') && !cleanRoute.includes('login')) {
|
|
207
|
+
authRoutes.add(cleanRoute);
|
|
208
|
+
} else {
|
|
209
|
+
publicRoutes.add(cleanRoute);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Tambahkan rute login itu sendiri (tujuan redirect) ke rute publik untuk dianalisis/di-crawl
|
|
213
|
+
if (!allDetectedRoutes.has(finalPath)) {
|
|
214
|
+
routesToCheck.push(finalPath);
|
|
215
|
+
allDetectedRoutes.add(finalPath);
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
publicRoutes.add(cleanRoute);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Always crawl public links if crawling is active and the current page does not require authentication
|
|
222
|
+
if (crawl && !authRoutes.has(cleanRoute)) {
|
|
223
|
+
const hrefs = await page.evaluate(() => {
|
|
224
|
+
return Array.from(document.querySelectorAll('a'))
|
|
225
|
+
.map(a => a.getAttribute('href'))
|
|
226
|
+
.filter(Boolean);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const baseOrigin = new URL(baseUrl).origin;
|
|
230
|
+
for (const href of hrefs) {
|
|
231
|
+
try {
|
|
232
|
+
const resolvedUrl = new URL(href, baseUrl);
|
|
233
|
+
if (resolvedUrl.origin === baseOrigin) {
|
|
234
|
+
let r = resolvedUrl.pathname;
|
|
235
|
+
if (!/\.(pdf|png|jpg|jpeg|gif|css|js|svg|ico|woff|woff2|json)$/i.test(r)) {
|
|
236
|
+
let normalizedR = '/' + r.replace(/^\/+|\/+$/g, '');
|
|
237
|
+
if (normalizedR === '//') normalizedR = '/';
|
|
238
|
+
|
|
239
|
+
if (addHtml && normalizedR !== '/' && !normalizedR.endsWith('.html')) {
|
|
240
|
+
normalizedR += '.html';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (/logout|signout/i.test(normalizedR)) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!allDetectedRoutes.has(normalizedR)) {
|
|
248
|
+
routesToCheck.push(normalizedR);
|
|
249
|
+
allDetectedRoutes.add(normalizedR);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch (e) {}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} catch (err) {
|
|
257
|
+
ora().warn(pc.yellow(`Failed to analyze route ${cleanRoute}: ${err.message}`));
|
|
258
|
+
publicRoutes.add(cleanRoute);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
await context.close();
|
|
263
|
+
nonAuthSpinner.succeed(`Initial analysis complete. Detected ${publicRoutes.size} public routes and ${authRoutes.size} routes requiring authentication.`);
|
|
264
|
+
|
|
265
|
+
// 2. Prompt for credentials interactively if there are authenticated routes and email/password are empty
|
|
266
|
+
let finalEmail = email;
|
|
267
|
+
let finalPassword = password;
|
|
268
|
+
|
|
269
|
+
if (authRoutes.size > 0 && (!finalEmail || !finalPassword)) {
|
|
270
|
+
console.log(pc.yellow(`\nš Detected ${authRoutes.size} pages requiring login.`));
|
|
271
|
+
if (!finalEmail) {
|
|
272
|
+
finalEmail = await promptUser('š Enter Email/Username: ');
|
|
273
|
+
}
|
|
274
|
+
if (!finalPassword) {
|
|
275
|
+
finalPassword = await promptUser('š Enter Password (hidden input): ', true);
|
|
276
|
+
console.log(''); // new line after pressing enter
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 3. Phase Two Crawl (Post-Login) to discover internal dashboard pages
|
|
281
|
+
if (authRoutes.size > 0 && finalEmail && finalPassword) {
|
|
282
|
+
const authCrawlSpinner = ora('Performing login and crawling authenticated internal pages...').start();
|
|
283
|
+
const authContext = await browser.newContext();
|
|
284
|
+
const authPage = await authContext.newPage();
|
|
285
|
+
|
|
286
|
+
authPage.on('console', msg => {
|
|
287
|
+
const text = msg.text();
|
|
288
|
+
if (text.includes('Failed to fetch') || text.includes('TypeError: Failed to fetch')) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (text.includes('[ERROR]') || text.includes('[WARN]')) {
|
|
292
|
+
console.log(pc.dim(` [Auth-Crawler Log] ${text}`));
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
authPage.on('pageerror', err => {
|
|
296
|
+
if (err.message.includes('Failed to fetch') || err.message.includes('TypeError: Failed to fetch')) return;
|
|
297
|
+
console.error(pc.red(` [Auth-Crawler Error] ${err.message}`));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const targetLoginUrl = `${baseUrl}${normLoginPath}`;
|
|
302
|
+
await authPage.goto(targetLoginUrl, { timeout: 20000 });
|
|
303
|
+
await authPage.waitForSelector('#username', { timeout: 10000 });
|
|
304
|
+
await authPage.waitForSelector('#password', { timeout: 10000 });
|
|
305
|
+
await authPage.fill('#username', finalEmail);
|
|
306
|
+
await authPage.fill('#password', finalPassword);
|
|
307
|
+
|
|
308
|
+
const submitBtn = await authPage.locator('button[type="submit"], button.save-btn').first();
|
|
309
|
+
await submitBtn.click();
|
|
310
|
+
|
|
311
|
+
// Tunggu hingga login berhasil (URL berubah) atau ada error message
|
|
312
|
+
let loginSuccess = false;
|
|
313
|
+
let loginError = '';
|
|
314
|
+
for (let i = 0; i < 40; i++) {
|
|
315
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
316
|
+
const currentUrl = authPage.url();
|
|
317
|
+
if (!currentUrl.includes('login') && !currentUrl.includes('splash')) {
|
|
318
|
+
loginSuccess = true;
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
let errorText = null;
|
|
322
|
+
try {
|
|
323
|
+
errorText = await authPage.evaluate(() => {
|
|
324
|
+
const el = document.getElementById('errorMessage');
|
|
325
|
+
return el && !el.classList.contains('hidden') ? el.textContent : null;
|
|
326
|
+
});
|
|
327
|
+
} catch (e) {
|
|
328
|
+
// Ignore context destruction error during redirect/navigation
|
|
329
|
+
}
|
|
330
|
+
if (errorText) {
|
|
331
|
+
loginError = errorText.trim();
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!loginSuccess) {
|
|
337
|
+
throw new Error(loginError || 'Timeout: Page URL did not change from the login page after 10 seconds.');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (crawl) {
|
|
341
|
+
const authRoutesArray = Array.from(authRoutes);
|
|
342
|
+
for (const route of authRoutesArray) {
|
|
343
|
+
const targetUrl = `${baseUrl}${route}`;
|
|
344
|
+
try {
|
|
345
|
+
await authPage.goto(targetUrl, { timeout: 15000 });
|
|
346
|
+
await authPage.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
|
|
347
|
+
|
|
348
|
+
const hrefs = await authPage.evaluate(() => {
|
|
349
|
+
return Array.from(document.querySelectorAll('a'))
|
|
350
|
+
.map(a => a.getAttribute('href'))
|
|
351
|
+
.filter(Boolean);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const baseOrigin = new URL(baseUrl).origin;
|
|
355
|
+
for (const href of hrefs) {
|
|
356
|
+
try {
|
|
357
|
+
const resolvedUrl = new URL(href, baseUrl);
|
|
358
|
+
if (resolvedUrl.origin === baseOrigin) {
|
|
359
|
+
let r = resolvedUrl.pathname;
|
|
360
|
+
|
|
361
|
+
// Filter out logout/signout
|
|
362
|
+
if (/logout|signout/i.test(r)) {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!/\.(pdf|png|jpg|jpeg|gif|css|js|svg|ico|woff|woff2|json)$/i.test(r)) {
|
|
367
|
+
let normalizedR = '/' + r.replace(/^\/+|\/+$/g, '');
|
|
368
|
+
if (normalizedR === '//') normalizedR = '/';
|
|
369
|
+
|
|
370
|
+
if (addHtml && normalizedR !== '/' && !normalizedR.endsWith('.html')) {
|
|
371
|
+
normalizedR += '.html';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!publicRoutes.has(normalizedR) && !authRoutes.has(normalizedR)) {
|
|
375
|
+
authRoutes.add(normalizedR);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} catch (e) {}
|
|
380
|
+
}
|
|
381
|
+
} catch (routeErr) {
|
|
382
|
+
// Ignore if a specific route fails to load
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
authCrawlSpinner.succeed(`Post-login crawl complete. Found a total of ${authRoutes.size} authenticated routes.`);
|
|
387
|
+
} catch (err) {
|
|
388
|
+
authCrawlSpinner.fail(`Failed to crawl authenticated pages: ${err.message}`);
|
|
389
|
+
} finally {
|
|
390
|
+
await authContext.close();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
publicRoutes: Array.from(publicRoutes),
|
|
396
|
+
authRoutes: Array.from(authRoutes),
|
|
397
|
+
email: finalEmail,
|
|
398
|
+
password: finalPassword
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function applyMockupBorder(browser, imageBuffer, deviceName, size, platform, isDarkTheme = false) {
|
|
403
|
+
const base64Image = imageBuffer.toString('base64');
|
|
404
|
+
|
|
405
|
+
const now = new Date();
|
|
406
|
+
const hours = String(now.getHours()).padStart(2, '0');
|
|
407
|
+
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
408
|
+
const timeString = `${hours}:${minutes}`;
|
|
409
|
+
|
|
410
|
+
const textColor = isDarkTheme ? '#ffffff' : '#000000';
|
|
411
|
+
const svgFill = isDarkTheme ? '#ffffff' : '#000000';
|
|
412
|
+
const batteryBorder = isDarkTheme ? '#ffffff' : '#000000';
|
|
413
|
+
const batteryLevelBg = isDarkTheme ? '#ffffff' : '#000000';
|
|
414
|
+
const indicatorBg = isDarkTheme ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.8)';
|
|
415
|
+
const androidIndicatorBg = isDarkTheme ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.5)';
|
|
416
|
+
const tabletIndicatorBg = isDarkTheme ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)';
|
|
417
|
+
|
|
418
|
+
let frameClass = 'ios';
|
|
419
|
+
let topElementHTML = '<div class="dynamic-island"></div>';
|
|
420
|
+
let statusBarHTML = `
|
|
421
|
+
<div class="status-bar" style="height: 44px; padding: 0 32px; color: ${textColor};">
|
|
422
|
+
<div class="time">${timeString}</div>
|
|
423
|
+
<div class="status-right">
|
|
424
|
+
<svg width="17" height="11" viewBox="0 0 17 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
425
|
+
<rect x="0.5" y="8" width="2.5" height="3" rx="0.5" fill="${svgFill}"/>
|
|
426
|
+
<rect x="4.5" y="6" width="2.5" height="5" rx="0.5" fill="${svgFill}"/>
|
|
427
|
+
<rect x="8.5" y="4" width="2.5" height="7" rx="0.5" fill="${svgFill}"/>
|
|
428
|
+
<rect x="12.5" y="1" width="2.5" height="10" rx="0.5" fill="${svgFill}"/>
|
|
429
|
+
</svg>
|
|
430
|
+
<svg width="15" height="11" viewBox="0 0 15 11" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-left: 2px;">
|
|
431
|
+
<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}"/>
|
|
432
|
+
<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}"/>
|
|
433
|
+
</svg>
|
|
434
|
+
<div class="battery" style="margin-left: 2px; border-color: ${batteryBorder};"><div class="battery-level" style="background-color: ${batteryLevelBg};"></div></div>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
`;
|
|
438
|
+
let bottomElementHTML = `<div class="home-indicator" style="background: ${indicatorBg};"></div>`;
|
|
439
|
+
|
|
440
|
+
if (platform === 'ios' && deviceName.includes('ipad')) {
|
|
441
|
+
frameClass = 'ios-tablet';
|
|
442
|
+
topElementHTML = '';
|
|
443
|
+
statusBarHTML = `
|
|
444
|
+
<div class="status-bar" style="height: 28px; padding: 0 24px; font-size: 11px; line-height: 28px; 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: 220px; height: 5px; bottom: 6px; background: ${indicatorBg};"></div>`;
|
|
453
|
+
} else if (platform === 'android') {
|
|
454
|
+
if (deviceName.includes('tablet')) {
|
|
455
|
+
frameClass = 'android-tablet';
|
|
456
|
+
topElementHTML = '';
|
|
457
|
+
statusBarHTML = `
|
|
458
|
+
<div class="status-bar" style="height: 32px; padding: 0 20px; font-size: 10px; line-height: 32px; color: ${textColor};">
|
|
459
|
+
<div class="time">${timeString}</div>
|
|
460
|
+
<div class="status-right">
|
|
461
|
+
<span>š</span>
|
|
462
|
+
<span style="margin-left: 4px; color: ${textColor};">š 100%</span>
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
`;
|
|
466
|
+
bottomElementHTML = `<div class="home-indicator" style="width: 160px; height: 4px; bottom: 4px; background: ${tabletIndicatorBg};"></div>`;
|
|
467
|
+
} else {
|
|
468
|
+
frameClass = 'android-phone';
|
|
469
|
+
topElementHTML = '<div class="punch-hole"></div>';
|
|
470
|
+
statusBarHTML = `
|
|
471
|
+
<div class="status-bar" style="height: 38px; padding: 0 24px; font-size: 11px; line-height: 38px; color: ${textColor};">
|
|
472
|
+
<div class="time">${timeString}</div>
|
|
473
|
+
<div class="status-right">
|
|
474
|
+
<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;">
|
|
475
|
+
<rect x="0.5" y="8" width="2.5" height="3" rx="0.5" fill="${svgFill}"/>
|
|
476
|
+
<rect x="4.5" y="6" width="2.5" height="5" rx="0.5" fill="${svgFill}"/>
|
|
477
|
+
<rect x="8.5" y="4" width="2.5" height="7" rx="0.5" fill="${svgFill}"/>
|
|
478
|
+
<rect x="12.5" y="1" width="2.5" height="10" rx="0.5" fill="${svgFill}"/>
|
|
479
|
+
</svg>
|
|
480
|
+
<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;">
|
|
481
|
+
<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}"/>
|
|
482
|
+
<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}"/>
|
|
483
|
+
</svg>
|
|
484
|
+
<div class="battery" style="border-radius: 2px; margin-left: 2px; border-color: ${batteryBorder};"><div class="battery-level" style="background-color: ${batteryLevelBg};"></div></div>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
`;
|
|
488
|
+
bottomElementHTML = `<div class="home-indicator" style="background: ${androidIndicatorBg}; width: 120px; bottom: 6px;"></div>`;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const htmlContent = `
|
|
493
|
+
<!DOCTYPE html>
|
|
494
|
+
<html>
|
|
495
|
+
<head>
|
|
496
|
+
<meta charset="utf-8">
|
|
497
|
+
<style>
|
|
498
|
+
* {
|
|
499
|
+
box-sizing: border-box;
|
|
500
|
+
margin: 0;
|
|
501
|
+
padding: 0;
|
|
502
|
+
overflow: hidden;
|
|
503
|
+
}
|
|
504
|
+
body {
|
|
505
|
+
background: linear-gradient(135deg, #0d1b2a 0%, #1b263b 100%);
|
|
506
|
+
display: flex;
|
|
507
|
+
justify-content: center;
|
|
508
|
+
align-items: center;
|
|
509
|
+
width: 100vw;
|
|
510
|
+
height: 100vh;
|
|
511
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
512
|
+
}
|
|
513
|
+
.device-wrapper {
|
|
514
|
+
position: relative;
|
|
515
|
+
display: flex;
|
|
516
|
+
justify-content: center;
|
|
517
|
+
align-items: center;
|
|
518
|
+
transform: scale(0.85);
|
|
519
|
+
transform-origin: center;
|
|
520
|
+
background: transparent;
|
|
521
|
+
}
|
|
522
|
+
.device-frame {
|
|
523
|
+
position: relative;
|
|
524
|
+
background: #000;
|
|
525
|
+
box-shadow:
|
|
526
|
+
0 0 0 12px #1f1f21,
|
|
527
|
+
0 0 0 13px #2f2f31,
|
|
528
|
+
0 20px 50px rgba(0,0,0,0.6);
|
|
529
|
+
overflow: hidden;
|
|
530
|
+
}
|
|
531
|
+
.device-frame.ios {
|
|
532
|
+
border-radius: 54px;
|
|
533
|
+
}
|
|
534
|
+
.device-frame.ios .screenshot-img {
|
|
535
|
+
border-radius: 42px;
|
|
536
|
+
}
|
|
537
|
+
.device-frame.ios-tablet {
|
|
538
|
+
border-radius: 32px;
|
|
539
|
+
box-shadow:
|
|
540
|
+
0 0 0 14px #1f1f21,
|
|
541
|
+
0 0 0 15px #2f2f31,
|
|
542
|
+
0 20px 50px rgba(0,0,0,0.6);
|
|
543
|
+
}
|
|
544
|
+
.device-frame.ios-tablet .screenshot-img {
|
|
545
|
+
border-radius: 20px;
|
|
546
|
+
}
|
|
547
|
+
.device-frame.android-phone {
|
|
548
|
+
border-radius: 40px;
|
|
549
|
+
box-shadow:
|
|
550
|
+
0 0 0 10px #2a2a2c,
|
|
551
|
+
0 0 0 11px #3a3a3c,
|
|
552
|
+
0 20px 50px rgba(0,0,0,0.6);
|
|
553
|
+
}
|
|
554
|
+
.device-frame.android-phone .screenshot-img {
|
|
555
|
+
border-radius: 30px;
|
|
556
|
+
}
|
|
557
|
+
.device-frame.android-tablet {
|
|
558
|
+
border-radius: 24px;
|
|
559
|
+
box-shadow:
|
|
560
|
+
0 0 0 14px #2a2a2c,
|
|
561
|
+
0 20px 50px rgba(0,0,0,0.6);
|
|
562
|
+
}
|
|
563
|
+
.device-frame.android-tablet .screenshot-img {
|
|
564
|
+
border-radius: 12px;
|
|
565
|
+
}
|
|
566
|
+
.screenshot-img {
|
|
567
|
+
width: 100%;
|
|
568
|
+
height: 100%;
|
|
569
|
+
object-fit: cover;
|
|
570
|
+
display: block;
|
|
571
|
+
}
|
|
572
|
+
.dynamic-island {
|
|
573
|
+
position: absolute;
|
|
574
|
+
top: 18px;
|
|
575
|
+
left: 50%;
|
|
576
|
+
transform: translateX(-50%);
|
|
577
|
+
width: 110px;
|
|
578
|
+
height: 28px;
|
|
579
|
+
background: #000;
|
|
580
|
+
border-radius: 20px;
|
|
581
|
+
z-index: 100;
|
|
582
|
+
border: 0.5px solid rgba(255, 255, 255, 0.08);
|
|
583
|
+
}
|
|
584
|
+
.dynamic-island::after {
|
|
585
|
+
content: '';
|
|
586
|
+
position: absolute;
|
|
587
|
+
right: 25px;
|
|
588
|
+
top: 10px;
|
|
589
|
+
width: 8px;
|
|
590
|
+
height: 8px;
|
|
591
|
+
background: #111124;
|
|
592
|
+
border-radius: 50%;
|
|
593
|
+
box-shadow: inset 0 0 2px rgba(255,255,255,0.2);
|
|
594
|
+
}
|
|
595
|
+
.punch-hole {
|
|
596
|
+
position: absolute;
|
|
597
|
+
top: 14px;
|
|
598
|
+
left: 50%;
|
|
599
|
+
transform: translateX(-50%);
|
|
600
|
+
width: 12px;
|
|
601
|
+
height: 12px;
|
|
602
|
+
background: #000;
|
|
603
|
+
border-radius: 50%;
|
|
604
|
+
z-index: 100;
|
|
605
|
+
border: 1px solid rgba(255,255,255,0.15);
|
|
606
|
+
}
|
|
607
|
+
.status-bar {
|
|
608
|
+
position: absolute;
|
|
609
|
+
top: 0;
|
|
610
|
+
left: 0;
|
|
611
|
+
width: 100%;
|
|
612
|
+
display: flex;
|
|
613
|
+
justify-content: space-between;
|
|
614
|
+
align-items: center;
|
|
615
|
+
color: #fff;
|
|
616
|
+
font-weight: 600;
|
|
617
|
+
z-index: 99;
|
|
618
|
+
letter-spacing: -0.2px;
|
|
619
|
+
}
|
|
620
|
+
.status-right {
|
|
621
|
+
display: flex;
|
|
622
|
+
gap: 6px;
|
|
623
|
+
align-items: center;
|
|
624
|
+
}
|
|
625
|
+
.battery {
|
|
626
|
+
width: 20px;
|
|
627
|
+
height: 10.5px;
|
|
628
|
+
border: 1px solid #fff;
|
|
629
|
+
border-radius: 3px;
|
|
630
|
+
position: relative;
|
|
631
|
+
padding: 1px;
|
|
632
|
+
}
|
|
633
|
+
.battery::after {
|
|
634
|
+
content: '';
|
|
635
|
+
position: absolute;
|
|
636
|
+
right: -3px;
|
|
637
|
+
top: 2px;
|
|
638
|
+
width: 2px;
|
|
639
|
+
height: 4.5px;
|
|
640
|
+
background: #fff;
|
|
641
|
+
border-radius: 0 1px 1px 0;
|
|
642
|
+
}
|
|
643
|
+
.battery-level {
|
|
644
|
+
width: 100%;
|
|
645
|
+
height: 100%;
|
|
646
|
+
background: #fff;
|
|
647
|
+
border-radius: 1px;
|
|
648
|
+
}
|
|
649
|
+
.home-indicator {
|
|
650
|
+
position: absolute;
|
|
651
|
+
bottom: 8px;
|
|
652
|
+
left: 50%;
|
|
653
|
+
transform: translateX(-50%);
|
|
654
|
+
width: 140px;
|
|
655
|
+
height: 5px;
|
|
656
|
+
background: rgba(255, 255, 255, 0.85);
|
|
657
|
+
border-radius: 10px;
|
|
658
|
+
z-index: 100;
|
|
659
|
+
}
|
|
660
|
+
</style>
|
|
661
|
+
</head>
|
|
662
|
+
<body>
|
|
663
|
+
<div class="device-wrapper">
|
|
664
|
+
<div class="device-frame ${frameClass}" style="width: ${size.logical.width}px; height: ${size.logical.height}px;">
|
|
665
|
+
${topElementHTML}
|
|
666
|
+
${statusBarHTML}
|
|
667
|
+
<img src="data:image/png;base64,${base64Image}" class="screenshot-img" />
|
|
668
|
+
${bottomElementHTML}
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
</body>
|
|
672
|
+
</html>
|
|
673
|
+
`;
|
|
674
|
+
|
|
675
|
+
const context = await browser.newContext({
|
|
676
|
+
viewport: size.logical,
|
|
677
|
+
deviceScaleFactor: size.scale
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
const page = await context.newPage();
|
|
681
|
+
await page.setContent(htmlContent);
|
|
682
|
+
await page.waitForLoadState('networkidle');
|
|
683
|
+
|
|
684
|
+
const buffer = await page.screenshot({
|
|
685
|
+
omitBackground: false
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
await context.close();
|
|
689
|
+
return buffer;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function captureScreenshots(url, paths, outputDir, platform, crawl, detectPages, email, password, loginPath, addHtml, mockup) {
|
|
693
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
694
|
+
url = 'http://' + url;
|
|
695
|
+
}
|
|
696
|
+
url = url.replace(/\/+$/, '');
|
|
697
|
+
|
|
698
|
+
let targetPlatforms = [];
|
|
699
|
+
if (platform === 'both') {
|
|
700
|
+
targetPlatforms = ['ios', 'android'];
|
|
701
|
+
} else {
|
|
702
|
+
targetPlatforms = [platform];
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
let finalPaths = [...paths];
|
|
706
|
+
|
|
707
|
+
// 1. Auto-detect local pages if enabled
|
|
708
|
+
if (detectPages) {
|
|
709
|
+
const localRoutes = detectLocalPages();
|
|
710
|
+
if (localRoutes && localRoutes.length > 0) {
|
|
711
|
+
console.log(pc.green(`š Detected local pages folder. Adding ${localRoutes.length} static routes.`));
|
|
712
|
+
finalPaths = [...new Set([...finalPaths, ...localRoutes])];
|
|
713
|
+
} else {
|
|
714
|
+
console.log(pc.yellow(`ā Folder 'src/pages' or 'pages' not found in the current directory.`));
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
console.log(pc.bold(pc.blue('Starting MobileSnap screenshot automation...')));
|
|
719
|
+
console.log(`Target Server: ${pc.cyan(url)}`);
|
|
720
|
+
console.log(`Platform(s): ${pc.cyan(targetPlatforms.join(', ').toUpperCase())}`);
|
|
721
|
+
console.log(`Output Directory: ${pc.cyan(path.resolve(outputDir))}\n`);
|
|
722
|
+
|
|
723
|
+
// Ensure the output directory exists
|
|
724
|
+
if (!fs.existsSync(outputDir)) {
|
|
725
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Launch browser
|
|
729
|
+
let browser;
|
|
730
|
+
const launchSpinner = ora('Launching Chromium browser...').start();
|
|
731
|
+
try {
|
|
732
|
+
browser = await chromium.launch({
|
|
733
|
+
headless: true,
|
|
734
|
+
args: ['--disable-web-security']
|
|
735
|
+
});
|
|
736
|
+
launchSpinner.succeed('Chromium browser launched successfully');
|
|
737
|
+
} catch (err) {
|
|
738
|
+
launchSpinner.fail(pc.red('Failed to launch Chromium browser'));
|
|
739
|
+
console.error(pc.red(err.message));
|
|
740
|
+
console.log(pc.yellow('\nš” Tip: Run "npx playwright install chromium" to download browser binaries.'));
|
|
741
|
+
process.exit(1);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Call analyzeRoutes to separate public and authenticated routes
|
|
745
|
+
let result;
|
|
746
|
+
try {
|
|
747
|
+
result = await analyzeRoutes(browser, url, finalPaths, email, password, loginPath, addHtml, crawl);
|
|
748
|
+
} catch (err) {
|
|
749
|
+
console.error(pc.red(`Failed to analyze routes: ${err.message}`));
|
|
750
|
+
await browser.close();
|
|
751
|
+
process.exit(1);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const { publicRoutes, authRoutes, email: finalEmail, password: finalPassword } = result;
|
|
755
|
+
|
|
756
|
+
console.log(pc.bold(`\nDetected Public Routes (${publicRoutes.length}):`));
|
|
757
|
+
publicRoutes.forEach(p => console.log(` - ${pc.green(p)}`));
|
|
758
|
+
|
|
759
|
+
if (authRoutes.length > 0) {
|
|
760
|
+
console.log(pc.bold(`\nDetected Routes Requiring Authentication (${authRoutes.length}):`));
|
|
761
|
+
authRoutes.forEach(p => console.log(` - ${pc.cyan(p)}`));
|
|
762
|
+
}
|
|
763
|
+
console.log('');
|
|
764
|
+
|
|
765
|
+
if (publicRoutes.length === 0 && authRoutes.length === 0) {
|
|
766
|
+
console.log(pc.yellow('No routes were found to capture.'));
|
|
767
|
+
await browser.close();
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
for (const plat of targetPlatforms) {
|
|
772
|
+
const config = DEVICE_CONFIGS[plat];
|
|
773
|
+
console.log(pc.bold(pc.blue(`š» Platform: ${plat.toUpperCase()}`)));
|
|
774
|
+
|
|
775
|
+
for (const [deviceName, size] of Object.entries(config.devices)) {
|
|
776
|
+
console.log(pc.magenta(` š± Processing ${deviceName} (${size.logical.width * size.scale}x${size.logical.height * size.scale}px)...`));
|
|
777
|
+
|
|
778
|
+
const deviceOutputDir = path.join(outputDir, plat, deviceName);
|
|
779
|
+
if (!fs.existsSync(deviceOutputDir)) {
|
|
780
|
+
fs.mkdirSync(deviceOutputDir, { recursive: true });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const context = await browser.newContext({
|
|
784
|
+
viewport: size.logical,
|
|
785
|
+
userAgent: config.userAgent,
|
|
786
|
+
deviceScaleFactor: size.scale,
|
|
787
|
+
isMobile: true,
|
|
788
|
+
hasTouch: true
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
const page = await context.newPage();
|
|
792
|
+
|
|
793
|
+
const saveScreenshot = async (outputPath) => {
|
|
794
|
+
if (mockup) {
|
|
795
|
+
const isDarkTheme = await page.evaluate(() => {
|
|
796
|
+
const bodyBg = window.getComputedStyle(document.body).backgroundColor;
|
|
797
|
+
const match = bodyBg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
798
|
+
if (match) {
|
|
799
|
+
const r = parseInt(match[1]), g = parseInt(match[2]), b = parseInt(match[3]);
|
|
800
|
+
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
|
801
|
+
const alphaMatch = bodyBg.match(/rgba?\(\d+,\s*\d+,\s*\d+,\s*([\d.]+)/);
|
|
802
|
+
if (alphaMatch && parseFloat(alphaMatch[1]) < 0.1) {
|
|
803
|
+
// semi-transparent, fall back to text color check
|
|
804
|
+
} else {
|
|
805
|
+
return brightness < 128;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const bodyColor = window.getComputedStyle(document.body).color;
|
|
809
|
+
const colorMatch = bodyColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
810
|
+
if (colorMatch) {
|
|
811
|
+
const r = parseInt(colorMatch[1]), g = parseInt(colorMatch[2]), b = parseInt(colorMatch[3]);
|
|
812
|
+
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
|
813
|
+
return brightness > 128;
|
|
814
|
+
}
|
|
815
|
+
return false;
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const raw = await page.screenshot({ fullPage: false });
|
|
819
|
+
const framed = await applyMockupBorder(browser, raw, deviceName, size, plat, isDarkTheme);
|
|
820
|
+
fs.writeFileSync(outputPath, framed);
|
|
821
|
+
} else {
|
|
822
|
+
await page.screenshot({ path: outputPath, fullPage: false });
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
// Forward browser console logs and errors to terminal for debugging
|
|
827
|
+
page.on('console', msg => {
|
|
828
|
+
const text = msg.text();
|
|
829
|
+
if (text.includes('Failed to fetch') || text.includes('TypeError: Failed to fetch')) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
// filter out noisy logs but keep useful ones
|
|
833
|
+
if (text.includes('[ERROR]') || text.includes('[WARN]')) {
|
|
834
|
+
console.log(pc.dim(` [Browser Log] ${text}`));
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
page.on('pageerror', err => {
|
|
838
|
+
if (err.message.includes('Failed to fetch') || err.message.includes('TypeError: Failed to fetch')) return;
|
|
839
|
+
console.error(pc.red(` [Browser Error] ${err.message}`));
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// --- Phase 1: Capturing Public Pages (No Login) ---
|
|
843
|
+
if (publicRoutes.length > 0) {
|
|
844
|
+
console.log(` šø Capturing public pages...`);
|
|
845
|
+
for (const route of publicRoutes) {
|
|
846
|
+
const targetUrl = `${url}${route}`;
|
|
847
|
+
const nameSnippet = safeFilename(route);
|
|
848
|
+
const filename = `${nameSnippet}.png`;
|
|
849
|
+
const outputPath = path.join(deviceOutputDir, filename);
|
|
850
|
+
|
|
851
|
+
const pageSpinner = ora(` Navigating to ${route}...`).start();
|
|
852
|
+
try {
|
|
853
|
+
await page.goto(targetUrl, { timeout: 30000 });
|
|
854
|
+
pageSpinner.text = ` Waiting for network idle on ${route}...`;
|
|
855
|
+
await page.waitForLoadState('networkidle', { timeout: 15000 });
|
|
856
|
+
await new Promise(resolve => setTimeout(resolve, 800));
|
|
857
|
+
|
|
858
|
+
pageSpinner.text = ` Saving screenshot ${filename}...`;
|
|
859
|
+
await saveScreenshot(outputPath);
|
|
860
|
+
pageSpinner.succeed(pc.green(` ā Saved ${plat}/${deviceName}/${filename}`));
|
|
861
|
+
} catch (err) {
|
|
862
|
+
pageSpinner.fail(pc.red(` ā Failed to capture ${route}: ${err.message}`));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// --- Phase 2: Capturing Authenticated Pages ---
|
|
868
|
+
if (authRoutes.length > 0) {
|
|
869
|
+
if (finalEmail && finalPassword) {
|
|
870
|
+
const loginSpinner = ora(` Logging in to session for ${deviceName}...`).start();
|
|
871
|
+
try {
|
|
872
|
+
const targetLoginUrl = `${url}/${loginPath.replace(/^\/+/, '')}`;
|
|
873
|
+
await page.goto(targetLoginUrl, { timeout: 30000 });
|
|
874
|
+
await page.waitForSelector('#username', { timeout: 10000 });
|
|
875
|
+
await page.waitForSelector('#password', { timeout: 10000 });
|
|
876
|
+
await page.fill('#username', finalEmail);
|
|
877
|
+
await page.fill('#password', finalPassword);
|
|
878
|
+
|
|
879
|
+
const submitBtn = await page.locator('button[type="submit"], button.save-btn').first();
|
|
880
|
+
await submitBtn.click();
|
|
881
|
+
|
|
882
|
+
// Wait for login success (URL change) or an error message to appear
|
|
883
|
+
let loginSuccess = false;
|
|
884
|
+
let loginError = '';
|
|
885
|
+
for (let i = 0; i < 40; i++) {
|
|
886
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
887
|
+
const currentUrl = page.url();
|
|
888
|
+
if (!currentUrl.includes('login') && !currentUrl.includes('splash')) {
|
|
889
|
+
loginSuccess = true;
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
let errorText = null;
|
|
893
|
+
try {
|
|
894
|
+
errorText = await page.evaluate(() => {
|
|
895
|
+
const el = document.getElementById('errorMessage');
|
|
896
|
+
return el && !el.classList.contains('hidden') ? el.textContent : null;
|
|
897
|
+
});
|
|
898
|
+
} catch (e) {
|
|
899
|
+
// Ignore context destruction error during redirect/navigation
|
|
900
|
+
}
|
|
901
|
+
if (errorText) {
|
|
902
|
+
loginError = errorText.trim();
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (!loginSuccess) {
|
|
908
|
+
throw new Error(loginError || 'Timeout: Page URL did not change from the login page after 10 seconds.');
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
loginSpinner.succeed(` Logged in successfully for ${deviceName}`);
|
|
912
|
+
|
|
913
|
+
console.log(` šø Capturing authenticated pages...`);
|
|
914
|
+
for (const route of authRoutes) {
|
|
915
|
+
const targetUrl = `${url}${route}`;
|
|
916
|
+
const nameSnippet = safeFilename(route);
|
|
917
|
+
const filename = `${nameSnippet}.png`;
|
|
918
|
+
const outputPath = path.join(deviceOutputDir, filename);
|
|
919
|
+
|
|
920
|
+
const pageSpinner = ora(` Navigating to ${route}...`).start();
|
|
921
|
+
try {
|
|
922
|
+
await page.goto(targetUrl, { timeout: 30000 });
|
|
923
|
+
pageSpinner.text = ` Waiting for network idle on ${route}...`;
|
|
924
|
+
await page.waitForLoadState('networkidle', { timeout: 15000 });
|
|
925
|
+
await new Promise(resolve => setTimeout(resolve, 800));
|
|
926
|
+
|
|
927
|
+
pageSpinner.text = ` Saving screenshot ${filename}...`;
|
|
928
|
+
await saveScreenshot(outputPath);
|
|
929
|
+
pageSpinner.succeed(pc.green(` ā Saved ${plat}/${deviceName}/${filename}`));
|
|
930
|
+
} catch (err) {
|
|
931
|
+
pageSpinner.fail(pc.red(` ā Failed to capture ${route}: ${err.message}`));
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
} catch (loginErr) {
|
|
935
|
+
loginSpinner.fail(pc.red(` Auto-login failed for ${deviceName}: ${loginErr.message}`));
|
|
936
|
+
console.log(pc.yellow(` š” Continuing to capture authenticated routes without login (may be redirected to login/splash).`));
|
|
937
|
+
|
|
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
|
+
}
|
|
958
|
+
}
|
|
959
|
+
} else {
|
|
960
|
+
console.log(pc.yellow(` ā Skipping login (empty credentials). Capturing authenticated routes without login.`));
|
|
961
|
+
for (const route of authRoutes) {
|
|
962
|
+
const targetUrl = `${url}${route}`;
|
|
963
|
+
const nameSnippet = safeFilename(route);
|
|
964
|
+
const filename = `${nameSnippet}.png`;
|
|
965
|
+
const outputPath = path.join(deviceOutputDir, filename);
|
|
966
|
+
|
|
967
|
+
const pageSpinner = ora(` Navigating to ${route}...`).start();
|
|
968
|
+
try {
|
|
969
|
+
await page.goto(targetUrl, { timeout: 30000 });
|
|
970
|
+
pageSpinner.text = ` Waiting for network idle on ${route}...`;
|
|
971
|
+
await page.waitForLoadState('networkidle', { timeout: 15000 });
|
|
972
|
+
await new Promise(resolve => setTimeout(resolve, 800));
|
|
973
|
+
|
|
974
|
+
pageSpinner.text = ` Saving screenshot ${filename}...`;
|
|
975
|
+
await saveScreenshot(outputPath);
|
|
976
|
+
pageSpinner.succeed(pc.green(` ā Saved ${plat}/${deviceName}/${filename}`));
|
|
977
|
+
} catch (err) {
|
|
978
|
+
pageSpinner.fail(pc.red(` ā Failed to capture ${route}: ${err.message}`));
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
await context.close();
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
await browser.close();
|
|
989
|
+
console.log(pc.bold(pc.green(`\nš Finished! All screenshots successfully saved in '${outputDir}'.`)));
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
program
|
|
993
|
+
.name('mobile-snap')
|
|
994
|
+
.description('ā” MobileSnap CLI: Automate App Store & Google Play Store screenshots')
|
|
995
|
+
.version('1.0.5')
|
|
996
|
+
.requiredOption('-u, --url <url>', 'Base URL of the local development server (e.g. localhost:3000)')
|
|
997
|
+
.option('-p, --paths <paths>', 'Comma-separated list of routes to capture', '/')
|
|
998
|
+
.option('-o, --output <output>', 'Output directory to save screenshots', 'mobilesnap_output')
|
|
999
|
+
.option('-l, --platform <platform>', 'Target platform: "ios", "android", or "both"', 'ios')
|
|
1000
|
+
.option('-c, --crawl', 'Discover and screenshot all internal links automatically', false)
|
|
1001
|
+
.option('-d, --detect-pages', 'Scan local project pages directory (src/pages or pages) for static routes', false)
|
|
1002
|
+
.option('--email <email>', 'Email for automatic login authentication')
|
|
1003
|
+
.option('--password <password>', 'Password for automatic login authentication')
|
|
1004
|
+
.option('--login-path <path>', 'Path to the login page', '/login.html')
|
|
1005
|
+
.option('--html', 'Auto append .html extension to detected routes', false)
|
|
1006
|
+
.option('-m, --mockup', 'Wrap screenshots in a beautiful iPhone/Android device mockup frame', false)
|
|
1007
|
+
.action((options) => {
|
|
1008
|
+
let pathList = [];
|
|
1009
|
+
if (options.paths) {
|
|
1010
|
+
pathList = options.paths.split(',').map(p => p.trim()).filter(Boolean);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const platformVal = options.platform.toLowerCase();
|
|
1014
|
+
if (!['ios', 'android', 'both'].includes(platformVal)) {
|
|
1015
|
+
console.error(pc.red(`Error: Invalid platform '${options.platform}'. Choose 'ios', 'android', or 'both'.`));
|
|
1016
|
+
process.exit(1);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
captureScreenshots(
|
|
1020
|
+
options.url,
|
|
1021
|
+
pathList,
|
|
1022
|
+
options.output,
|
|
1023
|
+
platformVal,
|
|
1024
|
+
options.crawl,
|
|
1025
|
+
options.detectPages,
|
|
1026
|
+
options.email,
|
|
1027
|
+
options.password,
|
|
1028
|
+
options.loginPath,
|
|
1029
|
+
options.html,
|
|
1030
|
+
options.mockup
|
|
1031
|
+
).catch(err => {
|
|
1032
|
+
console.error(pc.red(`An unexpected error occurred: ${err.message}`));
|
|
1033
|
+
process.exit(1);
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
program.parse(process.argv);
|
|
1038
|
+
|