@studiomeyer/mcp-video 1.0.0

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.
Files changed (184) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
  3. package/.github/workflows/ci.yml +34 -0
  4. package/CHANGELOG.md +24 -0
  5. package/CONTRIBUTING.md +75 -0
  6. package/LICENSE +21 -0
  7. package/README.md +198 -0
  8. package/USAGE.md +144 -0
  9. package/dist/handlers/capcut.d.ts +6 -0
  10. package/dist/handlers/capcut.js +229 -0
  11. package/dist/handlers/capcut.js.map +1 -0
  12. package/dist/handlers/editing.d.ts +6 -0
  13. package/dist/handlers/editing.js +242 -0
  14. package/dist/handlers/editing.js.map +1 -0
  15. package/dist/handlers/index.d.ts +2 -0
  16. package/dist/handlers/index.js +33 -0
  17. package/dist/handlers/index.js.map +1 -0
  18. package/dist/handlers/post-production.d.ts +5 -0
  19. package/dist/handlers/post-production.js +109 -0
  20. package/dist/handlers/post-production.js.map +1 -0
  21. package/dist/handlers/smart-screenshot.d.ts +5 -0
  22. package/dist/handlers/smart-screenshot.js +83 -0
  23. package/dist/handlers/smart-screenshot.js.map +1 -0
  24. package/dist/handlers/tts.d.ts +5 -0
  25. package/dist/handlers/tts.js +83 -0
  26. package/dist/handlers/tts.js.map +1 -0
  27. package/dist/handlers/video.d.ts +5 -0
  28. package/dist/handlers/video.js +127 -0
  29. package/dist/handlers/video.js.map +1 -0
  30. package/dist/lib/dual-transport.d.ts +42 -0
  31. package/dist/lib/dual-transport.js +208 -0
  32. package/dist/lib/dual-transport.js.map +1 -0
  33. package/dist/lib/logger.d.ts +12 -0
  34. package/dist/lib/logger.js +42 -0
  35. package/dist/lib/logger.js.map +1 -0
  36. package/dist/lib/types.d.ts +16 -0
  37. package/dist/lib/types.js +15 -0
  38. package/dist/lib/types.js.map +1 -0
  39. package/dist/schemas/capcut.d.ts +608 -0
  40. package/dist/schemas/capcut.js +411 -0
  41. package/dist/schemas/capcut.js.map +1 -0
  42. package/dist/schemas/editing.d.ts +822 -0
  43. package/dist/schemas/editing.js +466 -0
  44. package/dist/schemas/editing.js.map +1 -0
  45. package/dist/schemas/index.d.ts +2366 -0
  46. package/dist/schemas/index.js +15 -0
  47. package/dist/schemas/index.js.map +1 -0
  48. package/dist/schemas/post-production.d.ts +379 -0
  49. package/dist/schemas/post-production.js +268 -0
  50. package/dist/schemas/post-production.js.map +1 -0
  51. package/dist/schemas/smart-screenshot.d.ts +127 -0
  52. package/dist/schemas/smart-screenshot.js +122 -0
  53. package/dist/schemas/smart-screenshot.js.map +1 -0
  54. package/dist/schemas/tts.d.ts +220 -0
  55. package/dist/schemas/tts.js +194 -0
  56. package/dist/schemas/tts.js.map +1 -0
  57. package/dist/schemas/video.d.ts +236 -0
  58. package/dist/schemas/video.js +210 -0
  59. package/dist/schemas/video.js.map +1 -0
  60. package/dist/server.d.ts +11 -0
  61. package/dist/server.js +239 -0
  62. package/dist/server.js.map +1 -0
  63. package/dist/server.test.d.ts +1 -0
  64. package/dist/server.test.js +87 -0
  65. package/dist/server.test.js.map +1 -0
  66. package/dist/tools/engine/audio-mixer.d.ts +40 -0
  67. package/dist/tools/engine/audio-mixer.js +169 -0
  68. package/dist/tools/engine/audio-mixer.js.map +1 -0
  69. package/dist/tools/engine/audio.d.ts +22 -0
  70. package/dist/tools/engine/audio.js +73 -0
  71. package/dist/tools/engine/audio.js.map +1 -0
  72. package/dist/tools/engine/beat-sync.d.ts +31 -0
  73. package/dist/tools/engine/beat-sync.js +270 -0
  74. package/dist/tools/engine/beat-sync.js.map +1 -0
  75. package/dist/tools/engine/capture.d.ts +12 -0
  76. package/dist/tools/engine/capture.js +290 -0
  77. package/dist/tools/engine/capture.js.map +1 -0
  78. package/dist/tools/engine/chroma-key.d.ts +27 -0
  79. package/dist/tools/engine/chroma-key.js +154 -0
  80. package/dist/tools/engine/chroma-key.js.map +1 -0
  81. package/dist/tools/engine/concat.d.ts +49 -0
  82. package/dist/tools/engine/concat.js +149 -0
  83. package/dist/tools/engine/concat.js.map +1 -0
  84. package/dist/tools/engine/cursor.d.ts +26 -0
  85. package/dist/tools/engine/cursor.js +185 -0
  86. package/dist/tools/engine/cursor.js.map +1 -0
  87. package/dist/tools/engine/easing.d.ts +15 -0
  88. package/dist/tools/engine/easing.js +100 -0
  89. package/dist/tools/engine/easing.js.map +1 -0
  90. package/dist/tools/engine/editing.d.ts +158 -0
  91. package/dist/tools/engine/editing.js +541 -0
  92. package/dist/tools/engine/editing.js.map +1 -0
  93. package/dist/tools/engine/encoder.d.ts +31 -0
  94. package/dist/tools/engine/encoder.js +154 -0
  95. package/dist/tools/engine/encoder.js.map +1 -0
  96. package/dist/tools/engine/index.d.ts +30 -0
  97. package/dist/tools/engine/index.js +23 -0
  98. package/dist/tools/engine/index.js.map +1 -0
  99. package/dist/tools/engine/lut-presets.d.ts +25 -0
  100. package/dist/tools/engine/lut-presets.js +141 -0
  101. package/dist/tools/engine/lut-presets.js.map +1 -0
  102. package/dist/tools/engine/narrated-video.d.ts +63 -0
  103. package/dist/tools/engine/narrated-video.js +163 -0
  104. package/dist/tools/engine/narrated-video.js.map +1 -0
  105. package/dist/tools/engine/scenes.d.ts +17 -0
  106. package/dist/tools/engine/scenes.js +223 -0
  107. package/dist/tools/engine/scenes.js.map +1 -0
  108. package/dist/tools/engine/smart-screenshot.d.ts +80 -0
  109. package/dist/tools/engine/smart-screenshot.js +744 -0
  110. package/dist/tools/engine/smart-screenshot.js.map +1 -0
  111. package/dist/tools/engine/social-format.d.ts +66 -0
  112. package/dist/tools/engine/social-format.js +107 -0
  113. package/dist/tools/engine/social-format.js.map +1 -0
  114. package/dist/tools/engine/template-renderer.d.ts +45 -0
  115. package/dist/tools/engine/template-renderer.js +233 -0
  116. package/dist/tools/engine/template-renderer.js.map +1 -0
  117. package/dist/tools/engine/templates.d.ts +87 -0
  118. package/dist/tools/engine/templates.js +272 -0
  119. package/dist/tools/engine/templates.js.map +1 -0
  120. package/dist/tools/engine/text-animations.d.ts +33 -0
  121. package/dist/tools/engine/text-animations.js +192 -0
  122. package/dist/tools/engine/text-animations.js.map +1 -0
  123. package/dist/tools/engine/text-overlay.d.ts +27 -0
  124. package/dist/tools/engine/text-overlay.js +84 -0
  125. package/dist/tools/engine/text-overlay.js.map +1 -0
  126. package/dist/tools/engine/tts.d.ts +54 -0
  127. package/dist/tools/engine/tts.js +186 -0
  128. package/dist/tools/engine/tts.js.map +1 -0
  129. package/dist/tools/engine/types.d.ts +166 -0
  130. package/dist/tools/engine/types.js +13 -0
  131. package/dist/tools/engine/types.js.map +1 -0
  132. package/dist/tools/engine/voice-effects.d.ts +18 -0
  133. package/dist/tools/engine/voice-effects.js +215 -0
  134. package/dist/tools/engine/voice-effects.js.map +1 -0
  135. package/dist/tools/index.d.ts +32 -0
  136. package/dist/tools/index.js +23 -0
  137. package/dist/tools/index.js.map +1 -0
  138. package/package.json +56 -0
  139. package/scripts/check-deps.js +39 -0
  140. package/src/handlers/capcut.ts +245 -0
  141. package/src/handlers/editing.ts +260 -0
  142. package/src/handlers/index.ts +34 -0
  143. package/src/handlers/post-production.ts +136 -0
  144. package/src/handlers/smart-screenshot.ts +86 -0
  145. package/src/handlers/tts.ts +103 -0
  146. package/src/handlers/video.ts +137 -0
  147. package/src/lib/dual-transport.ts +272 -0
  148. package/src/lib/logger.ts +59 -0
  149. package/src/lib/types.ts +25 -0
  150. package/src/schemas/capcut.ts +418 -0
  151. package/src/schemas/editing.ts +476 -0
  152. package/src/schemas/index.ts +15 -0
  153. package/src/schemas/post-production.ts +273 -0
  154. package/src/schemas/smart-screenshot.ts +122 -0
  155. package/src/schemas/tts.ts +197 -0
  156. package/src/schemas/video.ts +211 -0
  157. package/src/server.test.ts +99 -0
  158. package/src/server.ts +289 -0
  159. package/src/tools/engine/audio-mixer.ts +244 -0
  160. package/src/tools/engine/audio.ts +115 -0
  161. package/src/tools/engine/beat-sync.ts +356 -0
  162. package/src/tools/engine/capture.ts +360 -0
  163. package/src/tools/engine/chroma-key.ts +202 -0
  164. package/src/tools/engine/concat.ts +242 -0
  165. package/src/tools/engine/cursor.ts +222 -0
  166. package/src/tools/engine/easing.ts +120 -0
  167. package/src/tools/engine/editing.ts +809 -0
  168. package/src/tools/engine/encoder.ts +208 -0
  169. package/src/tools/engine/index.ts +33 -0
  170. package/src/tools/engine/lut-presets.ts +235 -0
  171. package/src/tools/engine/narrated-video.ts +267 -0
  172. package/src/tools/engine/scenes.ts +309 -0
  173. package/src/tools/engine/smart-screenshot.ts +923 -0
  174. package/src/tools/engine/social-format.ts +146 -0
  175. package/src/tools/engine/template-renderer.ts +294 -0
  176. package/src/tools/engine/templates.ts +370 -0
  177. package/src/tools/engine/text-animations.ts +282 -0
  178. package/src/tools/engine/text-overlay.ts +143 -0
  179. package/src/tools/engine/tts.ts +284 -0
  180. package/src/tools/engine/types.ts +191 -0
  181. package/src/tools/engine/voice-effects.ts +258 -0
  182. package/src/tools/index.ts +67 -0
  183. package/tsconfig.json +19 -0
  184. package/vitest.config.ts +7 -0
@@ -0,0 +1,923 @@
1
+ /**
2
+ * Smart Screenshot Engine
3
+ *
4
+ * Element-aware screenshot system that can target specific page features.
5
+ * Instead of full-page screenshots, this finds and captures specific UI elements
6
+ * like chat widgets, booking forms, pricing sections, wizards, etc.
7
+ *
8
+ * Usage:
9
+ * smartScreenshot({ url: '...', targets: ['chat', 'pricing', 'booking'] })
10
+ * smartScreenshot({ url: '...', targets: [{ selector: '.hero-section' }] })
11
+ * smartScreenshot({ url: '...', targets: ['all'] }) // auto-detect all features
12
+ */
13
+
14
+ import { chromium } from 'playwright';
15
+ import type { Page, Browser, BrowserContext, ElementHandle } from 'playwright';
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import { logger } from '../../lib/logger.js';
19
+
20
+ const OUTPUT_DIR = process.env.VIDEO_OUTPUT_DIR || './output';
21
+
22
+ // ─── Feature Detection Patterns ─────────────────────────────────────
23
+
24
+ interface FeaturePattern {
25
+ /** Human-readable name */
26
+ name: string;
27
+ /** Keywords that map to this feature (user says "chat" → matches this) */
28
+ keywords: string[];
29
+ /** CSS selectors to try (in priority order) */
30
+ selectors: string[];
31
+ /** Text patterns to search for in visible text */
32
+ textPatterns: RegExp[];
33
+ /** ARIA role patterns */
34
+ ariaRoles: string[];
35
+ /** Minimum expected size (to filter out tiny elements) */
36
+ minSize?: { width: number; height: number };
37
+ /** Padding around element in px */
38
+ padding?: number;
39
+ /** Should we scroll to this element first? */
40
+ scrollTo?: boolean;
41
+ /** Should we wait for this element to appear? (e.g. chat popup) */
42
+ waitFor?: boolean;
43
+ /** Click something first to reveal the element? */
44
+ revealSelector?: string;
45
+ }
46
+
47
+ const FEATURE_PATTERNS: FeaturePattern[] = [
48
+ {
49
+ name: 'Hero Section',
50
+ keywords: ['hero', 'header', 'banner', 'above-fold', 'startseite', 'landing'],
51
+ selectors: [
52
+ '[class*="hero"]', '[id*="hero"]',
53
+ '[class*="Hero"]', '[id*="Hero"]',
54
+ 'section:first-of-type',
55
+ 'main > section:first-child',
56
+ 'main > div:first-child',
57
+ '[class*="banner"]:not([class*="cookie"])',
58
+ '[class*="landing"]',
59
+ '[class*="jumbotron"]',
60
+ ],
61
+ textPatterns: [],
62
+ ariaRoles: ['banner'],
63
+ minSize: { width: 600, height: 300 },
64
+ padding: 0,
65
+ },
66
+ {
67
+ name: 'Chat Widget',
68
+ keywords: ['chat', 'chatbot', 'messenger', 'live-chat', 'support-chat', 'bot'],
69
+ selectors: [
70
+ '[class*="chat"]', '[id*="chat"]',
71
+ '[class*="Chat"]', '[id*="Chat"]',
72
+ '[class*="chatbot"]', '[id*="chatbot"]',
73
+ '[class*="messenger"]',
74
+ '[class*="widget"]',
75
+ '[class*="intercom"]',
76
+ '[class*="crisp"]',
77
+ '[class*="tawk"]',
78
+ '[class*="zendesk"]',
79
+ '[data-testid*="chat"]',
80
+ 'iframe[src*="chat"]',
81
+ ],
82
+ textPatterns: [/chat/i, /nachricht/i, /fragen/i, /hilfe/i],
83
+ ariaRoles: ['dialog', 'complementary'],
84
+ minSize: { width: 200, height: 200 },
85
+ padding: 10,
86
+ scrollTo: false,
87
+ waitFor: true,
88
+ revealSelector: '[class*="chat"] button, [class*="Chat"] button, [class*="chat-trigger"], [class*="chat-toggle"], button[aria-label*="chat" i], button[aria-label*="Chat"]',
89
+ },
90
+ {
91
+ name: 'Booking System',
92
+ keywords: ['booking', 'buchung', 'reservation', 'reservierung', 'buchen', 'termin', 'appointment', 'kalender', 'calendar'],
93
+ selectors: [
94
+ '[class*="booking"]', '[id*="booking"]',
95
+ '[class*="Booking"]', '[id*="Booking"]',
96
+ '[class*="reservation"]', '[id*="reservation"]',
97
+ '[class*="calendar"]', '[id*="calendar"]',
98
+ '[class*="appointment"]',
99
+ '[class*="scheduler"]',
100
+ '[class*="datepicker"]',
101
+ 'form[action*="book"]',
102
+ 'form[action*="reserv"]',
103
+ ],
104
+ textPatterns: [/buchen/i, /reserv/i, /termin/i, /book/i, /appointment/i],
105
+ ariaRoles: ['form'],
106
+ minSize: { width: 300, height: 200 },
107
+ padding: 20,
108
+ scrollTo: true,
109
+ },
110
+ {
111
+ name: 'Pricing Section',
112
+ keywords: ['pricing', 'preise', 'preis', 'tarife', 'pakete', 'plans', 'packages', 'kosten', 'abo'],
113
+ selectors: [
114
+ '[class*="pricing"]', '[id*="pricing"]',
115
+ '[class*="Pricing"]', '[id*="Pricing"]',
116
+ '[class*="price"]', '[id*="price"]',
117
+ '[class*="plans"]', '[id*="plans"]',
118
+ '[class*="packages"]', '[id*="packages"]',
119
+ '[class*="tarif"]', '[id*="tarif"]',
120
+ ],
121
+ textPatterns: [/\d+\s*[€$£]/i, /pro monat/i, /per month/i, /\/mo/i, /pricing/i, /preise/i],
122
+ ariaRoles: [],
123
+ minSize: { width: 400, height: 200 },
124
+ padding: 20,
125
+ scrollTo: true,
126
+ },
127
+ {
128
+ name: 'Contact Form',
129
+ keywords: ['contact', 'kontakt', 'form', 'formular', 'anfrage', 'inquiry', 'nachricht', 'message'],
130
+ selectors: [
131
+ '[class*="contact"]', '[id*="contact"]',
132
+ '[class*="Contact"]', '[id*="Contact"]',
133
+ '[class*="kontakt"]', '[id*="kontakt"]',
134
+ 'form:not([class*="search"]):not([class*="login"]):not([class*="newsletter"])',
135
+ '[class*="inquiry"]',
136
+ '[class*="anfrage"]',
137
+ ],
138
+ textPatterns: [/kontakt/i, /contact/i, /nachricht senden/i, /send message/i, /anfrage/i],
139
+ ariaRoles: ['form'],
140
+ minSize: { width: 300, height: 200 },
141
+ padding: 20,
142
+ scrollTo: true,
143
+ },
144
+ {
145
+ name: 'Navigation',
146
+ keywords: ['nav', 'navigation', 'menu', 'header-nav', 'navbar', 'menubar'],
147
+ selectors: [
148
+ 'nav', 'header nav',
149
+ '[class*="nav"]', '[id*="nav"]',
150
+ '[class*="Nav"]', '[id*="Nav"]',
151
+ '[class*="navbar"]',
152
+ '[class*="menu"]:not([class*="footer"])',
153
+ '[role="navigation"]',
154
+ ],
155
+ textPatterns: [],
156
+ ariaRoles: ['navigation', 'menubar'],
157
+ minSize: { width: 600, height: 40 },
158
+ padding: 5,
159
+ scrollTo: false,
160
+ },
161
+ {
162
+ name: 'Footer',
163
+ keywords: ['footer', 'fusszeile'],
164
+ selectors: [
165
+ 'footer', '[class*="footer"]', '[id*="footer"]',
166
+ '[class*="Footer"]', '[id*="Footer"]',
167
+ '[role="contentinfo"]',
168
+ ],
169
+ textPatterns: [],
170
+ ariaRoles: ['contentinfo'],
171
+ minSize: { width: 600, height: 100 },
172
+ padding: 0,
173
+ scrollTo: true,
174
+ },
175
+ {
176
+ name: 'Gallery / Portfolio',
177
+ keywords: ['gallery', 'galerie', 'portfolio', 'showcase', 'projekte', 'projects', 'work', 'arbeiten'],
178
+ selectors: [
179
+ '[class*="gallery"]', '[id*="gallery"]',
180
+ '[class*="Gallery"]', '[id*="Gallery"]',
181
+ '[class*="portfolio"]', '[id*="portfolio"]',
182
+ '[class*="Portfolio"]', '[id*="Portfolio"]',
183
+ '[class*="showcase"]',
184
+ '[class*="projects"]', '[id*="projects"]',
185
+ '[class*="grid"]:has(img)',
186
+ ],
187
+ textPatterns: [/portfolio/i, /projekte/i, /projects/i, /galerie/i, /gallery/i, /unsere arbeit/i],
188
+ ariaRoles: [],
189
+ minSize: { width: 400, height: 300 },
190
+ padding: 20,
191
+ scrollTo: true,
192
+ },
193
+ {
194
+ name: 'Wizard / Stepper',
195
+ keywords: ['wizard', 'stepper', 'steps', 'schritte', 'onboarding', 'flow', 'multi-step', 'progress'],
196
+ selectors: [
197
+ '[class*="wizard"]', '[id*="wizard"]',
198
+ '[class*="Wizard"]', '[id*="Wizard"]',
199
+ '[class*="stepper"]', '[id*="stepper"]',
200
+ '[class*="steps"]', '[id*="steps"]',
201
+ '[class*="onboarding"]', '[id*="onboarding"]',
202
+ '[class*="progress-bar"]',
203
+ '[class*="step-indicator"]',
204
+ '[role="progressbar"]',
205
+ ],
206
+ textPatterns: [/schritt \d/i, /step \d/i],
207
+ ariaRoles: ['progressbar'],
208
+ minSize: { width: 300, height: 200 },
209
+ padding: 20,
210
+ scrollTo: true,
211
+ },
212
+ {
213
+ name: 'Testimonials / Reviews',
214
+ keywords: ['testimonial', 'review', 'bewertung', 'kundenstimmen', 'feedback', 'referenzen'],
215
+ selectors: [
216
+ '[class*="testimonial"]', '[id*="testimonial"]',
217
+ '[class*="Testimonial"]', '[id*="Testimonial"]',
218
+ '[class*="review"]', '[id*="review"]',
219
+ '[class*="feedback"]',
220
+ '[class*="quote"]',
221
+ ],
222
+ textPatterns: [/testimonial/i, /bewertung/i, /kundenstimm/i, /review/i],
223
+ ariaRoles: [],
224
+ minSize: { width: 300, height: 150 },
225
+ padding: 20,
226
+ scrollTo: true,
227
+ },
228
+ {
229
+ name: 'Services / Features',
230
+ keywords: ['services', 'leistungen', 'features', 'funktionen', 'angebot', 'offering'],
231
+ selectors: [
232
+ '[class*="services"]', '[id*="services"]',
233
+ '[class*="Services"]', '[id*="Services"]',
234
+ '[class*="features"]', '[id*="features"]',
235
+ '[class*="Features"]', '[id*="Features"]',
236
+ '[class*="leistung"]', '[id*="leistung"]',
237
+ '[class*="offering"]',
238
+ ],
239
+ textPatterns: [/leistungen/i, /services/i, /features/i, /was wir/i, /what we/i],
240
+ ariaRoles: [],
241
+ minSize: { width: 400, height: 200 },
242
+ padding: 20,
243
+ scrollTo: true,
244
+ },
245
+ {
246
+ name: 'CTA / Call-to-Action',
247
+ keywords: ['cta', 'call-to-action', 'button', 'action', 'jetzt-starten', 'get-started'],
248
+ selectors: [
249
+ '[class*="cta"]', '[id*="cta"]',
250
+ '[class*="CTA"]', '[id*="CTA"]',
251
+ '[class*="call-to-action"]',
252
+ 'a[class*="btn-primary"]',
253
+ 'a[class*="button-primary"]',
254
+ '[class*="hero"] a[class*="btn"]',
255
+ '[class*="hero"] button',
256
+ ],
257
+ textPatterns: [/jetzt starten/i, /get started/i, /kostenlos/i, /free trial/i, /jetzt buchen/i],
258
+ ariaRoles: [],
259
+ minSize: { width: 100, height: 30 },
260
+ padding: 30,
261
+ scrollTo: true,
262
+ },
263
+ {
264
+ name: 'Map / Location',
265
+ keywords: ['map', 'karte', 'standort', 'location', 'anfahrt', 'directions'],
266
+ selectors: [
267
+ '[class*="map"]', '[id*="map"]',
268
+ '[class*="Map"]', '[id*="Map"]',
269
+ '[class*="location"]', '[id*="location"]',
270
+ '[class*="standort"]',
271
+ 'iframe[src*="maps"]',
272
+ 'iframe[src*="google.com/maps"]',
273
+ '.mapboxgl-map',
274
+ '.leaflet-container',
275
+ ],
276
+ textPatterns: [/standort/i, /location/i, /anfahrt/i],
277
+ ariaRoles: [],
278
+ minSize: { width: 300, height: 200 },
279
+ padding: 10,
280
+ scrollTo: true,
281
+ },
282
+ {
283
+ name: 'Video Section',
284
+ keywords: ['video', 'player', 'media'],
285
+ selectors: [
286
+ 'video', '[class*="video"]', '[id*="video"]',
287
+ '[class*="Video"]', '[id*="Video"]',
288
+ '[class*="player"]',
289
+ 'iframe[src*="youtube"]',
290
+ 'iframe[src*="vimeo"]',
291
+ ],
292
+ textPatterns: [],
293
+ ariaRoles: [],
294
+ minSize: { width: 300, height: 200 },
295
+ padding: 10,
296
+ scrollTo: true,
297
+ },
298
+ {
299
+ name: 'Newsletter / Subscribe',
300
+ keywords: ['newsletter', 'subscribe', 'abonnieren', 'email-signup'],
301
+ selectors: [
302
+ '[class*="newsletter"]', '[id*="newsletter"]',
303
+ '[class*="Newsletter"]', '[id*="Newsletter"]',
304
+ '[class*="subscribe"]', '[id*="subscribe"]',
305
+ 'form:has(input[type="email"]):not(:has(input[type="password"]))',
306
+ ],
307
+ textPatterns: [/newsletter/i, /abonnieren/i, /subscribe/i],
308
+ ariaRoles: [],
309
+ minSize: { width: 200, height: 80 },
310
+ padding: 20,
311
+ scrollTo: true,
312
+ },
313
+ ];
314
+
315
+ // ─── Types ────────────────────────────────────────────────────────────
316
+
317
+ export interface SmartTarget {
318
+ /** Feature keyword (e.g. "chat", "pricing") or CSS selector (prefixed with .) or "all" for auto-detect */
319
+ feature: string;
320
+ /** Optional: custom CSS selector override */
321
+ selector?: string;
322
+ /** Optional: custom padding around element */
323
+ padding?: number;
324
+ /** Optional: click to reveal before screenshot */
325
+ revealFirst?: boolean;
326
+ }
327
+
328
+ export interface SmartScreenshotConfig {
329
+ /** URL to screenshot */
330
+ url: string;
331
+ /** What to capture - keywords, selectors, or "all" */
332
+ targets: (string | SmartTarget)[];
333
+ /** Output directory */
334
+ outputDir?: string;
335
+ /** Viewport size */
336
+ viewport?: { width: number; height: number };
337
+ /** Device scale factor for retina */
338
+ deviceScaleFactor?: number;
339
+ /** Dark mode */
340
+ darkMode?: boolean;
341
+ /** Wait time after page load (ms) */
342
+ waitAfterLoad?: number;
343
+ /** Also take a full-page screenshot */
344
+ includeFullPage?: boolean;
345
+ /** Maximum width for element screenshots (prevents ultra-wide captures) */
346
+ maxWidth?: number;
347
+ /** Maximum height for element screenshots */
348
+ maxHeight?: number;
349
+ }
350
+
351
+ export interface DetectedFeature {
352
+ name: string;
353
+ pattern: string;
354
+ selector: string;
355
+ bounds: { x: number; y: number; width: number; height: number };
356
+ matchMethod: 'selector' | 'text' | 'aria' | 'custom';
357
+ confidence: 'high' | 'medium' | 'low';
358
+ }
359
+
360
+ export interface SmartScreenshotResult {
361
+ success: boolean;
362
+ url: string;
363
+ screenshots: {
364
+ feature: string;
365
+ path: string;
366
+ bounds: { x: number; y: number; width: number; height: number };
367
+ matchMethod: string;
368
+ confidence: string;
369
+ }[];
370
+ detected: DetectedFeature[];
371
+ fullPage?: string;
372
+ totalTime: string;
373
+ }
374
+
375
+ // ─── Main Function ────────────────────────────────────────────────────
376
+
377
+ export async function smartScreenshot(config: SmartScreenshotConfig): Promise<SmartScreenshotResult> {
378
+ const {
379
+ url,
380
+ targets,
381
+ outputDir = path.join(OUTPUT_DIR, 'smart-screenshots'),
382
+ viewport = { width: 1920, height: 1080 },
383
+ deviceScaleFactor = 1,
384
+ darkMode = false,
385
+ waitAfterLoad = 2000,
386
+ includeFullPage = false,
387
+ maxWidth = 1920,
388
+ maxHeight = 2000,
389
+ } = config;
390
+
391
+ if (!fs.existsSync(outputDir)) {
392
+ fs.mkdirSync(outputDir, { recursive: true });
393
+ }
394
+
395
+ const startTime = Date.now();
396
+ let browser: Browser | undefined;
397
+
398
+ try {
399
+ logger.info(`Smart Screenshot: ${url}`);
400
+ logger.info(`Targets: ${targets.map(t => typeof t === 'string' ? t : t.feature).join(', ')}`);
401
+
402
+ // ─── 1. Launch Browser ──────────────────────────────────────
403
+ browser = await chromium.launch({
404
+ headless: true,
405
+ args: [
406
+ '--no-sandbox',
407
+ '--disable-setuid-sandbox',
408
+ '--disable-dev-shm-usage',
409
+ '--disable-gpu',
410
+ '--hide-scrollbars',
411
+ ],
412
+ });
413
+
414
+ const context: BrowserContext = await browser.newContext({
415
+ viewport,
416
+ deviceScaleFactor,
417
+ colorScheme: darkMode ? 'dark' : 'light',
418
+ });
419
+
420
+ const page: Page = await context.newPage();
421
+
422
+ // ─── 2. Navigate ────────────────────────────────────────────
423
+ logger.info(`Navigating to ${url}...`);
424
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(async () => {
425
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
426
+ });
427
+
428
+ await page.waitForTimeout(waitAfterLoad);
429
+
430
+ // ─── 3. Dismiss overlays ────────────────────────────────────
431
+ await dismissOverlays(page);
432
+ await page.waitForTimeout(500);
433
+
434
+ // ─── 4. Pre-scroll to trigger lazy loading ──────────────────
435
+ await preloadContent(page, viewport.height);
436
+
437
+ // Hide scrollbar
438
+ await page.addStyleTag({
439
+ content: `::-webkit-scrollbar { display: none !important; } * { scrollbar-width: none !important; }`,
440
+ });
441
+
442
+ // ─── 5. Detect features ─────────────────────────────────────
443
+ const normalizedTargets = normalizeTargets(targets);
444
+ const isAutoDetect = normalizedTargets.some(t => t.feature === 'all');
445
+
446
+ let detectedFeatures: DetectedFeature[];
447
+
448
+ if (isAutoDetect) {
449
+ logger.info('Auto-detecting all page features...');
450
+ detectedFeatures = await detectAllFeatures(page, viewport);
451
+ } else {
452
+ detectedFeatures = [];
453
+ for (const target of normalizedTargets) {
454
+ const features = await detectFeature(page, target, viewport);
455
+ detectedFeatures.push(...features);
456
+ }
457
+ }
458
+
459
+ logger.info(`Detected ${detectedFeatures.length} features`);
460
+ for (const f of detectedFeatures) {
461
+ logger.info(` ${f.name}: ${f.bounds.width}x${f.bounds.height} (${f.matchMethod}, ${f.confidence})`);
462
+ }
463
+
464
+ // ─── 6. Take screenshots ────────────────────────────────────
465
+ const screenshots: SmartScreenshotResult['screenshots'] = [];
466
+ const domain = new URL(url).hostname.replace(/^www\./, '').replace(/\./g, '-');
467
+
468
+ for (let i = 0; i < detectedFeatures.length; i++) {
469
+ const feature = detectedFeatures[i];
470
+ const safeName = feature.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-');
471
+ const ssPath = path.join(outputDir, `${domain}-${safeName}-${i}.png`);
472
+
473
+ try {
474
+ // Scroll element into view
475
+ await page.evaluate((sel) => {
476
+ const el = document.querySelector(sel);
477
+ if (el) {
478
+ el.scrollIntoView({ block: 'center', behavior: 'instant' });
479
+ }
480
+ }, feature.selector);
481
+ await page.waitForTimeout(300);
482
+
483
+ // Get VIEWPORT-RELATIVE bounds (getBoundingClientRect without scrollY)
484
+ const vpBounds = await page.evaluate((sel) => {
485
+ const el = document.querySelector(sel);
486
+ if (!el) return null;
487
+ const rect = el.getBoundingClientRect();
488
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
489
+ }, feature.selector);
490
+ if (!vpBounds || vpBounds.width < 10 || vpBounds.height < 10) {
491
+ logger.info(` Skipping ${feature.name}: element not found after scroll`);
492
+ continue;
493
+ }
494
+
495
+ // Apply padding
496
+ const padding = feature.pattern === 'custom' ? 20 : (findPattern(feature.pattern)?.padding ?? 20);
497
+ const clip = {
498
+ x: Math.max(0, vpBounds.x - padding),
499
+ y: Math.max(0, vpBounds.y - padding),
500
+ width: Math.min(vpBounds.width + padding * 2, maxWidth),
501
+ height: Math.min(vpBounds.height + padding * 2, maxHeight),
502
+ };
503
+
504
+ // Ensure clip doesn't exceed viewport dimensions
505
+ if (clip.x + clip.width > viewport.width) {
506
+ clip.width = viewport.width - clip.x;
507
+ }
508
+ if (clip.y + clip.height > viewport.height) {
509
+ clip.height = viewport.height - clip.y;
510
+ }
511
+ // Skip if clip is too small or invalid
512
+ if (clip.width < 20 || clip.height < 20) {
513
+ logger.info(` Skipping ${feature.name}: clipped area too small`);
514
+ continue;
515
+ }
516
+
517
+ await page.screenshot({
518
+ path: ssPath,
519
+ type: 'png',
520
+ clip,
521
+ });
522
+
523
+ screenshots.push({
524
+ feature: feature.name,
525
+ path: ssPath,
526
+ bounds: clip,
527
+ matchMethod: feature.matchMethod,
528
+ confidence: feature.confidence,
529
+ });
530
+
531
+ logger.info(` Screenshot: ${feature.name} → ${path.basename(ssPath)}`);
532
+ } catch (err) {
533
+ logger.error(` Failed to screenshot ${feature.name}: ${err instanceof Error ? err.message : String(err)}`);
534
+ }
535
+ }
536
+
537
+ // ─── 7. Full-page screenshot (optional) ─────────────────────
538
+ let fullPagePath: string | undefined;
539
+ if (includeFullPage) {
540
+ fullPagePath = path.join(outputDir, `${domain}-full-page.png`);
541
+ await page.evaluate(() => window.scrollTo(0, 0));
542
+ await page.waitForTimeout(300);
543
+ await page.screenshot({ path: fullPagePath, fullPage: true, type: 'png' });
544
+ logger.info(` Full-page screenshot → ${path.basename(fullPagePath)}`);
545
+ }
546
+
547
+ // ─── 8. Cleanup ─────────────────────────────────────────────
548
+ await context.close();
549
+ await browser.close();
550
+ browser = undefined;
551
+
552
+ const totalTime = `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
553
+ logger.info(`Smart Screenshot complete: ${screenshots.length} captures in ${totalTime}`);
554
+
555
+ return {
556
+ success: true,
557
+ url,
558
+ screenshots,
559
+ detected: detectedFeatures,
560
+ fullPage: fullPagePath,
561
+ totalTime,
562
+ };
563
+ } catch (error) {
564
+ if (browser) {
565
+ try { await browser.close(); } catch { /* ignore */ }
566
+ }
567
+ const message = error instanceof Error ? error.message : String(error);
568
+ logger.error(`Smart Screenshot failed: ${message}`);
569
+ throw new Error(`Smart Screenshot failed: ${message}`);
570
+ }
571
+ }
572
+
573
+ // ─── Feature Detection ────────────────────────────────────────────────
574
+
575
+ function normalizeTargets(targets: (string | SmartTarget)[]): SmartTarget[] {
576
+ return targets.map(t => {
577
+ if (typeof t === 'string') {
578
+ return { feature: t.toLowerCase().trim() };
579
+ }
580
+ return { ...t, feature: t.feature.toLowerCase().trim() };
581
+ });
582
+ }
583
+
584
+ function findPattern(keyword: string): FeaturePattern | undefined {
585
+ return FEATURE_PATTERNS.find(p =>
586
+ p.keywords.some(k => k === keyword) ||
587
+ p.name.toLowerCase() === keyword
588
+ );
589
+ }
590
+
591
+ function findPatternsByKeyword(keyword: string): FeaturePattern[] {
592
+ // Exact match first
593
+ const exact = FEATURE_PATTERNS.filter(p =>
594
+ p.keywords.some(k => k === keyword) ||
595
+ p.name.toLowerCase() === keyword
596
+ );
597
+ if (exact.length > 0) return exact;
598
+
599
+ // Fuzzy match: keyword is a substring of a pattern keyword or vice versa
600
+ return FEATURE_PATTERNS.filter(p =>
601
+ p.keywords.some(k => k.includes(keyword) || keyword.includes(k)) ||
602
+ p.name.toLowerCase().includes(keyword) ||
603
+ keyword.includes(p.name.toLowerCase())
604
+ );
605
+ }
606
+
607
+ async function detectFeature(
608
+ page: Page,
609
+ target: SmartTarget,
610
+ viewport: { width: number; height: number },
611
+ ): Promise<DetectedFeature[]> {
612
+ const results: DetectedFeature[] = [];
613
+
614
+ // Custom selector override
615
+ if (target.selector) {
616
+ const bounds = await getElementBounds(page, target.selector);
617
+ if (bounds && bounds.width > 10 && bounds.height > 10) {
618
+ results.push({
619
+ name: target.feature || 'Custom Element',
620
+ pattern: 'custom',
621
+ selector: target.selector,
622
+ bounds,
623
+ matchMethod: 'custom',
624
+ confidence: 'high',
625
+ });
626
+ }
627
+ return results;
628
+ }
629
+
630
+ // Feature keyword starts with . or # or [ → treat as CSS selector
631
+ if (/^[.#\[]/.test(target.feature)) {
632
+ const bounds = await getElementBounds(page, target.feature);
633
+ if (bounds && bounds.width > 10 && bounds.height > 10) {
634
+ results.push({
635
+ name: target.feature,
636
+ pattern: 'custom',
637
+ selector: target.feature,
638
+ bounds,
639
+ matchMethod: 'custom',
640
+ confidence: 'high',
641
+ });
642
+ }
643
+ return results;
644
+ }
645
+
646
+ // Click to reveal if needed (e.g. chat widget)
647
+ const patterns = findPatternsByKeyword(target.feature);
648
+ if (patterns.length === 0) {
649
+ logger.info(` No pattern found for "${target.feature}", trying as text search...`);
650
+ const textResults = await findByVisibleText(page, target.feature, viewport);
651
+ results.push(...textResults);
652
+ return results;
653
+ }
654
+
655
+ for (const pattern of patterns) {
656
+ // Try reveal selector first (e.g. click to open chat)
657
+ if ((target.revealFirst || pattern.revealSelector) && pattern.revealSelector) {
658
+ try {
659
+ const revealBtn = page.locator(pattern.revealSelector).first();
660
+ if (await revealBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
661
+ await revealBtn.click({ timeout: 2000 });
662
+ await page.waitForTimeout(1000);
663
+ logger.info(` Clicked reveal button for ${pattern.name}`);
664
+ }
665
+ } catch { /* no reveal button found, continue */ }
666
+ }
667
+
668
+ // Try CSS selectors
669
+ for (const selector of pattern.selectors) {
670
+ const bounds = await getElementBounds(page, selector);
671
+ if (bounds && meetsMinSize(bounds, pattern.minSize)) {
672
+ results.push({
673
+ name: pattern.name,
674
+ pattern: pattern.keywords[0],
675
+ selector,
676
+ bounds,
677
+ matchMethod: 'selector',
678
+ confidence: 'high',
679
+ });
680
+ break; // Found via selector, no need to check more
681
+ }
682
+ }
683
+
684
+ // If not found by selector, try text patterns
685
+ if (results.filter(r => r.pattern === pattern.keywords[0]).length === 0) {
686
+ for (const textPattern of pattern.textPatterns) {
687
+ const textResults = await findSectionByText(page, textPattern, pattern.name, pattern.keywords[0], viewport);
688
+ results.push(...textResults);
689
+ if (textResults.length > 0) break;
690
+ }
691
+ }
692
+
693
+ // Try ARIA roles
694
+ if (results.filter(r => r.pattern === pattern.keywords[0]).length === 0) {
695
+ for (const role of pattern.ariaRoles) {
696
+ const ariaSelector = `[role="${role}"]`;
697
+ const bounds = await getElementBounds(page, ariaSelector);
698
+ if (bounds && meetsMinSize(bounds, pattern.minSize)) {
699
+ results.push({
700
+ name: pattern.name,
701
+ pattern: pattern.keywords[0],
702
+ selector: ariaSelector,
703
+ bounds,
704
+ matchMethod: 'aria',
705
+ confidence: 'medium',
706
+ });
707
+ break;
708
+ }
709
+ }
710
+ }
711
+ }
712
+
713
+ return results;
714
+ }
715
+
716
+ async function detectAllFeatures(
717
+ page: Page,
718
+ viewport: { width: number; height: number },
719
+ ): Promise<DetectedFeature[]> {
720
+ const results: DetectedFeature[] = [];
721
+ const seenBounds = new Set<string>();
722
+
723
+ for (const pattern of FEATURE_PATTERNS) {
724
+ // Try each selector
725
+ for (const selector of pattern.selectors) {
726
+ const bounds = await getElementBounds(page, selector);
727
+ if (bounds && meetsMinSize(bounds, pattern.minSize)) {
728
+ const boundsKey = `${Math.round(bounds.x)}-${Math.round(bounds.y)}-${Math.round(bounds.width)}-${Math.round(bounds.height)}`;
729
+ if (!seenBounds.has(boundsKey)) {
730
+ seenBounds.add(boundsKey);
731
+ results.push({
732
+ name: pattern.name,
733
+ pattern: pattern.keywords[0],
734
+ selector,
735
+ bounds,
736
+ matchMethod: 'selector',
737
+ confidence: 'high',
738
+ });
739
+ break; // One match per pattern
740
+ }
741
+ }
742
+ }
743
+ }
744
+
745
+ return results;
746
+ }
747
+
748
+ async function findByVisibleText(
749
+ page: Page,
750
+ searchText: string,
751
+ viewport: { width: number; height: number },
752
+ ): Promise<DetectedFeature[]> {
753
+ const results: DetectedFeature[] = [];
754
+ const regex = new RegExp(searchText, 'i');
755
+
756
+ const found = await findSectionByText(page, regex, searchText, searchText, viewport);
757
+ results.push(...found);
758
+
759
+ return results;
760
+ }
761
+
762
+ async function findSectionByText(
763
+ page: Page,
764
+ textPattern: RegExp,
765
+ featureName: string,
766
+ patternKey: string,
767
+ _viewport: { width: number; height: number },
768
+ ): Promise<DetectedFeature[]> {
769
+ const results: DetectedFeature[] = [];
770
+
771
+ // Find the nearest section/container that contains this text
772
+ const element = await page.evaluate((pattern) => {
773
+ const re = new RegExp(pattern, 'i');
774
+ // Search headings first, then paragraphs, then sections
775
+ const candidates = [
776
+ ...Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')),
777
+ ...Array.from(document.querySelectorAll('section, [class*="section"], article')),
778
+ ...Array.from(document.querySelectorAll('p, span, div')),
779
+ ];
780
+
781
+ for (const el of candidates) {
782
+ const text = el.textContent?.trim() ?? '';
783
+ if (re.test(text)) {
784
+ // Walk up to find a meaningful container
785
+ let container: Element = el;
786
+ for (let i = 0; i < 5; i++) {
787
+ const parent = container.parentElement;
788
+ if (!parent || parent === document.body || parent === document.documentElement) break;
789
+ const tag = parent.tagName.toLowerCase();
790
+ if (tag === 'section' || tag === 'article' || tag === 'main' ||
791
+ parent.classList.toString().match(/section|container|wrapper|block|card/i)) {
792
+ container = parent;
793
+ break;
794
+ }
795
+ container = parent;
796
+ }
797
+
798
+ const rect = container.getBoundingClientRect();
799
+ if (rect.width > 100 && rect.height > 50) {
800
+ // Generate a unique selector for this element
801
+ let sel = container.tagName.toLowerCase();
802
+ if (container.id) sel = `#${container.id}`;
803
+ else if (container.className && typeof container.className === 'string') {
804
+ const cls = container.className.split(/\s+/).filter(c => c.length > 0 && !c.includes(':'))[0];
805
+ if (cls) sel = `.${cls}`;
806
+ }
807
+
808
+ return {
809
+ selector: sel,
810
+ bounds: {
811
+ x: rect.x + window.scrollX,
812
+ y: rect.y + window.scrollY,
813
+ width: rect.width,
814
+ height: rect.height,
815
+ },
816
+ };
817
+ }
818
+ }
819
+ }
820
+ return null;
821
+ }, textPattern.source);
822
+
823
+ if (element) {
824
+ results.push({
825
+ name: featureName,
826
+ pattern: patternKey,
827
+ selector: element.selector,
828
+ bounds: element.bounds,
829
+ matchMethod: 'text',
830
+ confidence: 'medium',
831
+ });
832
+ }
833
+
834
+ return results;
835
+ }
836
+
837
+ // ─── DOM Helpers ──────────────────────────────────────────────────────
838
+
839
+ async function getElementBounds(
840
+ page: Page,
841
+ selector: string,
842
+ ): Promise<{ x: number; y: number; width: number; height: number } | null> {
843
+ try {
844
+ return await page.evaluate((sel) => {
845
+ const el = document.querySelector(sel);
846
+ if (!el) return null;
847
+ const rect = el.getBoundingClientRect();
848
+ return {
849
+ x: rect.x + window.scrollX,
850
+ y: rect.y + window.scrollY,
851
+ width: rect.width,
852
+ height: rect.height,
853
+ };
854
+ }, selector);
855
+ } catch {
856
+ return null;
857
+ }
858
+ }
859
+
860
+ function meetsMinSize(
861
+ bounds: { width: number; height: number },
862
+ minSize?: { width: number; height: number },
863
+ ): boolean {
864
+ if (!minSize) return bounds.width > 10 && bounds.height > 10;
865
+ return bounds.width >= minSize.width && bounds.height >= minSize.height;
866
+ }
867
+
868
+ // ─── Page Preparation ─────────────────────────────────────────────────
869
+
870
+ async function dismissOverlays(page: Page): Promise<void> {
871
+ // Set consent cookies
872
+ await page.evaluate(() => {
873
+ localStorage.setItem('cookie-consent', 'accepted');
874
+ localStorage.setItem('cookieConsent', 'accepted');
875
+ localStorage.setItem('cookie_consent', 'true');
876
+ localStorage.setItem('cookies-accepted', 'true');
877
+ localStorage.setItem('gdpr-consent', 'true');
878
+ localStorage.setItem('CookieConsent', 'true');
879
+ document.cookie = 'cookie-consent=accepted; path=/; max-age=31536000';
880
+ window.dispatchEvent(new Event('cookie-consent-accepted'));
881
+ });
882
+
883
+ await page.waitForTimeout(300);
884
+
885
+ // Click accept buttons
886
+ for (const text of ['Akzeptieren', 'Accept', 'Alle akzeptieren', 'Accept all', 'OK', 'Verstanden']) {
887
+ try {
888
+ const btn = page.locator(`button:has-text("${text}")`).first();
889
+ if (await btn.isVisible({ timeout: 500 }).catch(() => false)) {
890
+ await btn.click({ timeout: 1000 });
891
+ break;
892
+ }
893
+ } catch { /* next */ }
894
+ }
895
+
896
+ // Force-hide common overlay selectors
897
+ await page.addStyleTag({
898
+ content: `
899
+ [class*="cookie"], [class*="Cookie"],
900
+ [class*="consent"], [class*="Consent"],
901
+ [id*="cookie"], [id*="consent"],
902
+ [role="dialog"],
903
+ .fixed.bottom-0.left-0.right-0.z-50 {
904
+ display: none !important;
905
+ visibility: hidden !important;
906
+ }
907
+ `,
908
+ });
909
+ }
910
+
911
+ async function preloadContent(page: Page, viewportHeight: number): Promise<void> {
912
+ const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight);
913
+ const steps = Math.ceil(scrollHeight / (viewportHeight * 0.7));
914
+
915
+ for (let i = 0; i <= steps; i++) {
916
+ const y = Math.min(i * viewportHeight * 0.7, scrollHeight);
917
+ await page.evaluate((scrollY) => window.scrollTo(0, scrollY), y);
918
+ await page.waitForTimeout(150);
919
+ }
920
+
921
+ await page.evaluate(() => window.scrollTo(0, 0));
922
+ await page.waitForTimeout(400);
923
+ }