prism-design 2.13.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 (90) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/LICENSE +21 -0
  3. package/README.md +203 -0
  4. package/bin/clone-architect.mjs +476 -0
  5. package/bin/prism.mjs +467 -0
  6. package/catalog/index.json +1155 -0
  7. package/extractions/airbnb.com/DESIGN.md +1068 -0
  8. package/extractions/airbnb.com/tokens.json +507 -0
  9. package/extractions/attio.com/DESIGN.md +1295 -0
  10. package/extractions/attio.com/tokens.json +438 -0
  11. package/extractions/auroxdashboard.com/DESIGN.md +724 -0
  12. package/extractions/auroxdashboard.com/tokens.json +195 -0
  13. package/extractions/careerexplorer.com/DESIGN.md +1178 -0
  14. package/extractions/careerexplorer.com/tokens.json +141 -0
  15. package/extractions/chance.co/DESIGN.md +1209 -0
  16. package/extractions/chance.co/tokens.json +160 -0
  17. package/extractions/choisis-ton-avenir.com/DESIGN.md +1265 -0
  18. package/extractions/choisis-ton-avenir.com/tokens.json +227 -0
  19. package/extractions/example.com/DESIGN.md +436 -0
  20. package/extractions/example.com/tokens.json +91 -0
  21. package/extractions/getdesign.md/DESIGN.md +1009 -0
  22. package/extractions/getdesign.md/tokens.json +219 -0
  23. package/extractions/github.com/DESIGN.md +1130 -0
  24. package/extractions/github.com/tokens.json +2092 -0
  25. package/extractions/hello-charly.com/DESIGN.md +1146 -0
  26. package/extractions/hello-charly.com/tokens.json +322 -0
  27. package/extractions/hyperliquid.xyz/DESIGN.md +779 -0
  28. package/extractions/hyperliquid.xyz/tokens.json +598 -0
  29. package/extractions/instagram.com/DESIGN.md +996 -0
  30. package/extractions/instagram.com/tokens.json +1240 -0
  31. package/extractions/jobirl.com/DESIGN.md +1160 -0
  32. package/extractions/jobirl.com/tokens.json +139 -0
  33. package/extractions/life360.com/DESIGN.md +1133 -0
  34. package/extractions/life360.com/tokens.json +491 -0
  35. package/extractions/lifesum.com/DESIGN.md +965 -0
  36. package/extractions/lifesum.com/tokens.json +170 -0
  37. package/extractions/linear.app/DESIGN.md +1301 -0
  38. package/extractions/linear.app/tokens.json +732 -0
  39. package/extractions/mavoie.org/DESIGN.md +1148 -0
  40. package/extractions/mavoie.org/tokens.json +128 -0
  41. package/extractions/miro.com/DESIGN.md +1237 -0
  42. package/extractions/miro.com/tokens.json +401 -0
  43. package/extractions/notion.so/DESIGN.md +1319 -0
  44. package/extractions/notion.so/tokens.json +906 -0
  45. package/extractions/onetonline.org/DESIGN.md +909 -0
  46. package/extractions/onetonline.org/tokens.json +280 -0
  47. package/extractions/posthog.com/DESIGN.md +1024 -0
  48. package/extractions/posthog.com/tokens.json +197 -0
  49. package/extractions/revolut.com/DESIGN.md +1080 -0
  50. package/extractions/revolut.com/tokens.json +401 -0
  51. package/extractions/stripe.com/DESIGN.md +1272 -0
  52. package/extractions/stripe.com/tokens.json +794 -0
  53. package/extractions/switchcollective.com/DESIGN.md +1040 -0
  54. package/extractions/switchcollective.com/tokens.json +98 -0
  55. package/extractions/truity.com/DESIGN.md +970 -0
  56. package/extractions/truity.com/tokens.json +166 -0
  57. package/extractions/uniquekicks.be/DESIGN.md +1171 -0
  58. package/extractions/uniquekicks.be/tokens.json +237 -0
  59. package/package.json +122 -0
  60. package/scripts/analyze.ts +281 -0
  61. package/scripts/bank-register.ts +379 -0
  62. package/scripts/bank.ts +374 -0
  63. package/scripts/browser-stealth.ts +189 -0
  64. package/scripts/clone.ts +198 -0
  65. package/scripts/compare-vs-gd-final.ts +273 -0
  66. package/scripts/compare-vs-gd.ts +269 -0
  67. package/scripts/compare.ts +405 -0
  68. package/scripts/deploy-site.ts +181 -0
  69. package/scripts/diff-snapshots.ts +340 -0
  70. package/scripts/enrich-catalog.ts +212 -0
  71. package/scripts/extract.ts +2038 -0
  72. package/scripts/extractors/advanced.ts +524 -0
  73. package/scripts/extractors/widgets.ts +711 -0
  74. package/scripts/generate-design-md.ts +5775 -0
  75. package/scripts/generate-final-pdf.ts +274 -0
  76. package/scripts/generate-og-image.ts +87 -0
  77. package/scripts/generate-showcase.ts +1588 -0
  78. package/scripts/generate-site.ts +847 -0
  79. package/scripts/mass-extract.sh +91 -0
  80. package/scripts/post-process-all.sh +55 -0
  81. package/scripts/regen-catalog.ts +203 -0
  82. package/scripts/shared/cache.ts +149 -0
  83. package/scripts/shared/css-helpers.ts +263 -0
  84. package/scripts/shared/logger.ts +57 -0
  85. package/scripts/shared/named-colors.ts +355 -0
  86. package/scripts/shared/types.ts +220 -0
  87. package/scripts/sync-catalog.ts +105 -0
  88. package/scripts/tokenize.ts +988 -0
  89. package/templates/layout-template.md +52 -0
  90. package/templates/tokens-template.json +34 -0
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env ts-node
2
+ /**
3
+ * bank.ts — Prism Component Bank CLI
4
+ *
5
+ * Usage:
6
+ * npx tsx scripts/bank.ts register --all
7
+ * npx tsx scripts/bank.ts register --domain addictsneakers.com
8
+ * npx tsx scripts/bank.ts query --type button --tag dark
9
+ * npx tsx scripts/bank.ts query --domain addictsneakers.com
10
+ * npx tsx scripts/bank.ts stats
11
+ * npx tsx scripts/bank.ts show <component-id>
12
+ */
13
+
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import { registerDomain, BANK_DIR, BankIndex, BankCatalog, BankIndexEntry } from './bank-register.js';
17
+
18
+ const EXTRACTIONS_DIR = path.join(process.cwd(), 'extractions');
19
+
20
+ // ─── CLI Arg Parsing ──────────────────────────────────────────────────────────
21
+
22
+ function parseArgs(argv: string[]): { cmd: string; flags: Record<string, string | boolean> } {
23
+ const args = argv.slice(2);
24
+ const cmd = args[0] || 'stats';
25
+ const flags: Record<string, string | boolean> = {};
26
+
27
+ for (let i = 1; i < args.length; i++) {
28
+ const a = args[i];
29
+ if (a.startsWith('--')) {
30
+ const key = a.slice(2);
31
+ const next = args[i + 1];
32
+ if (next && !next.startsWith('--')) {
33
+ flags[key] = next;
34
+ i++;
35
+ } else {
36
+ flags[key] = true;
37
+ }
38
+ } else if (!a.startsWith('--')) {
39
+ // positional after command (e.g., bank show <id>)
40
+ if (!flags['_pos']) flags['_pos'] = a;
41
+ else if (!flags['_pos2']) flags['_pos2'] = a;
42
+ }
43
+ }
44
+ return { cmd, flags };
45
+ }
46
+
47
+ // ─── Load Bank ────────────────────────────────────────────────────────────────
48
+
49
+ function loadIndex(): BankIndex {
50
+ const p = path.join(BANK_DIR, '_index.json');
51
+ if (!fs.existsSync(p)) return { version: '1.0.0', updatedAt: '', components: [] };
52
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
53
+ }
54
+
55
+ function loadCatalog(): BankCatalog {
56
+ const p = path.join(BANK_DIR, '_catalog.json');
57
+ if (!fs.existsSync(p)) return { version: '1.0.0', updatedAt: '', byType: {}, byDomain: {}, byTags: {} };
58
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
59
+ }
60
+
61
+ // ─── Commands ────────────────────────────────────────────────────────────────
62
+
63
+ async function cmdRegister(flags: Record<string, string | boolean>): Promise<void> {
64
+ console.log('');
65
+ console.log('┌─────────────────────────────────────────┐');
66
+ console.log('│ 🏦 Prism — Component Bank │');
67
+ console.log('│ Command: register │');
68
+ console.log('└─────────────────────────────────────────┘');
69
+ console.log('');
70
+
71
+ if (flags['all']) {
72
+ // Register all domains that have tokens.json
73
+ const domains = fs.readdirSync(EXTRACTIONS_DIR).filter(d => {
74
+ const stat = fs.statSync(path.join(EXTRACTIONS_DIR, d));
75
+ if (!stat.isDirectory()) return false;
76
+ return fs.existsSync(path.join(EXTRACTIONS_DIR, d, 'tokens.json'));
77
+ });
78
+
79
+ console.log(`📦 ${domains.length} extractions trouvées — enregistrement en cours...\n`);
80
+ let total = 0;
81
+ for (const d of domains) {
82
+ total += await registerDomain(d, true);
83
+ }
84
+ console.log(`\n✅ Total : ${total} composants dans la banque`);
85
+ printBankSummary();
86
+
87
+ } else if (flags['domain']) {
88
+ const domain = flags['domain'] as string;
89
+ console.log(`📦 Enregistrement de ${domain}...\n`);
90
+ const count = await registerDomain(domain, true);
91
+ if (count > 0) {
92
+ console.log(`\n✅ ${count} composants enregistrés`);
93
+ }
94
+
95
+ } else {
96
+ console.log('Usage:');
97
+ console.log(' npx tsx scripts/bank.ts register --all');
98
+ console.log(' npx tsx scripts/bank.ts register --domain <domain>');
99
+ }
100
+ }
101
+
102
+ function cmdQuery(flags: Record<string, string | boolean>): void {
103
+ const index = loadIndex();
104
+
105
+ if (index.components.length === 0) {
106
+ console.log('⚠️ La banque est vide. Lance d\'abord: npx tsx scripts/bank.ts register --all');
107
+ return;
108
+ }
109
+
110
+ let results = [...index.components];
111
+
112
+ // Filters
113
+ if (flags['type']) results = results.filter(c => c.type === flags['type']);
114
+ if (flags['domain']) results = results.filter(c => c.domain === flags['domain'] || c.domain.includes(flags['domain'] as string));
115
+ if (flags['tag']) results = results.filter(c => c.tags.includes(flags['tag'] as string));
116
+ if (flags['dark']) results = results.filter(c => c.isDark === true);
117
+ if (flags['light']) results = results.filter(c => c.isDark === false);
118
+
119
+ const limit = flags['limit'] ? parseInt(flags['limit'] as string) : 30;
120
+ const total = results.length;
121
+ results = results.slice(0, limit);
122
+
123
+ console.log('');
124
+ console.log(`🔍 ${total} composant(s) trouvé(s)${results.length < total ? ` (affichage: ${results.length})` : ''}`);
125
+ if (flags['type'] || flags['domain'] || flags['tag'] || flags['dark'] || flags['light']) {
126
+ const applied = [];
127
+ if (flags['type']) applied.push(`type=${flags['type']}`);
128
+ if (flags['domain']) applied.push(`domain=${flags['domain']}`);
129
+ if (flags['tag']) applied.push(`tag=${flags['tag']}`);
130
+ if (flags['dark']) applied.push('dark');
131
+ if (flags['light']) applied.push('light');
132
+ console.log(` Filtres: ${applied.join(', ')}`);
133
+ }
134
+ console.log('');
135
+
136
+ if (results.length === 0) {
137
+ console.log(' Aucun résultat. Essaie sans filtres ou avec d\'autres valeurs.');
138
+ return;
139
+ }
140
+
141
+ for (const c of results) {
142
+ const darkLabel = c.isDark ? '🌑' : '☀️ ';
143
+ console.log(` ${darkLabel} [${c.id}]`);
144
+ console.log(` ${c.domain} / ${c.type} / "${c.label}"`);
145
+ console.log(` Accent: ${c.accent} | Tags: ${c.tags.filter(t => !['button','card','heading','input','badge','link'].includes(t)).join(', ')}`);
146
+ console.log('');
147
+ }
148
+
149
+ console.log(`─────────────────────────────────────────────────`);
150
+ console.log(`💡 Détail: npx tsx scripts/bank.ts show <id>`);
151
+ }
152
+
153
+ function cmdShow(flags: Record<string, string | boolean>): void {
154
+ const id = flags['_pos'] as string || flags['id'] as string;
155
+ if (!id) {
156
+ console.error('Usage: npx tsx scripts/bank.ts show <component-id>');
157
+ return;
158
+ }
159
+
160
+ const snapshotPath = path.join(BANK_DIR, id, 'snapshot.json');
161
+ if (!fs.existsSync(snapshotPath)) {
162
+ console.error(`❌ Composant introuvable: ${id}`);
163
+ const index = loadIndex();
164
+ const similar = index.components.filter(c => c.id.includes(id.split('--')[0]));
165
+ if (similar.length) {
166
+ console.log(' Suggestions:');
167
+ for (const s of similar.slice(0, 5)) console.log(` ${s.id}`);
168
+ }
169
+ return;
170
+ }
171
+
172
+ const snap = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
173
+ console.log('');
174
+ console.log(`┌── Component Snapshot ────────────────────────────`);
175
+ console.log(`│ ID: ${snap.id}`);
176
+ console.log(`│ Domain: ${snap.domain}`);
177
+ console.log(`│ Type: ${snap.componentType} (${snap.componentLabel})`);
178
+ console.log(`│ URL: ${snap.url}`);
179
+ console.log(`│ Dark: ${snap.siteTokens.isDark ? 'yes' : 'no'}`);
180
+ console.log(`│ Tags: ${snap.tags.join(', ')}`);
181
+ console.log(`└──────────────────────────────────────────────────`);
182
+ console.log('');
183
+ console.log('Site Tokens:');
184
+ console.log(` Background: ${snap.siteTokens.colors.background}`);
185
+ console.log(` Text: ${snap.siteTokens.colors.text}`);
186
+ console.log(` Accent: ${snap.siteTokens.colors.accent}`);
187
+ console.log(` Font: ${snap.siteTokens.typography.primary}`);
188
+ console.log(` Radius md: ${snap.siteTokens.borderRadius.md}`);
189
+ console.log('');
190
+ console.log('Component Styles:');
191
+ const styles = snap.styles;
192
+ const styleKeys = ['backgroundColor', 'color', 'padding', 'borderRadius', 'border', 'fontSize', 'fontWeight', 'fontFamily', 'boxShadow', 'width', 'height'];
193
+ for (const k of styleKeys) {
194
+ if (styles[k] && styles[k] !== 'none' && styles[k] !== 'normal') {
195
+ console.log(` ${k}: ${styles[k]}`);
196
+ }
197
+ }
198
+ if (snap.designMdPath) {
199
+ console.log('');
200
+ console.log(`📄 DESIGN.md: bank/${snap.designMdPath}`);
201
+ }
202
+ if (snap.screenshotPath) {
203
+ console.log(`🖼 Screenshot: bank/${snap.screenshotPath}`);
204
+ }
205
+ }
206
+
207
+ function cmdStats(): void {
208
+ const index = loadIndex();
209
+ const catalog = loadCatalog();
210
+
211
+ const total = index.components.length;
212
+ const domains = Object.keys(catalog.byDomain).length;
213
+
214
+ console.log('');
215
+ console.log('┌─────────────────────────────────────────────────┐');
216
+ console.log('│ 📊 Prism — Component Bank Stats │');
217
+ console.log('└─────────────────────────────────────────────────┘');
218
+ console.log('');
219
+
220
+ if (total === 0) {
221
+ console.log(' La banque est vide. Lance: npx tsx scripts/bank.ts register --all');
222
+ return;
223
+ }
224
+
225
+ console.log(` Total composants : ${total}`);
226
+ console.log(` Domaines : ${domains}`);
227
+ console.log(` Dernière màj : ${index.updatedAt ? new Date(index.updatedAt).toLocaleString('fr-FR') : 'N/A'}`);
228
+ console.log('');
229
+
230
+ // By type
231
+ console.log(' Par type:');
232
+ const typeEntries = Object.entries(catalog.byType).sort((a, b) => b[1].length - a[1].length);
233
+ const maxTypeCount = typeEntries[0]?.[1].length || 1;
234
+ for (const [type, ids] of typeEntries) {
235
+ const bar = '█'.repeat(Math.round((ids.length / maxTypeCount) * 20));
236
+ console.log(` ${type.padEnd(12)} ${String(ids.length).padStart(3)} ${bar}`);
237
+ }
238
+ console.log('');
239
+
240
+ // By domain
241
+ console.log(' Par domaine:');
242
+ const domainEntries = Object.entries(catalog.byDomain).sort((a, b) => b[1].length - a[1].length);
243
+ for (const [domain, ids] of domainEntries) {
244
+ const dark = index.components.find(c => c.domain === domain)?.isDark;
245
+ const themeIcon = dark ? '🌑' : '☀️ ';
246
+ console.log(` ${themeIcon} ${domain.padEnd(35)} ${String(ids.length).padStart(3)} composants`);
247
+ }
248
+ console.log('');
249
+
250
+ // By tag (top 10)
251
+ console.log(' Tags populaires:');
252
+ const tagEntries = Object.entries(catalog.byTags)
253
+ .filter(([t]) => !['button','card','heading','input','badge','link','form','typography'].includes(t))
254
+ .sort((a, b) => b[1].length - a[1].length)
255
+ .slice(0, 12);
256
+ for (const [tag, ids] of tagEntries) {
257
+ const bar = '▪'.repeat(Math.min(ids.length, 20));
258
+ console.log(` ${tag.padEnd(20)} ${String(ids.length).padStart(3)} ${bar}`);
259
+ }
260
+ console.log('');
261
+ }
262
+
263
+ function printBankSummary(): void {
264
+ const index = loadIndex();
265
+ console.log('');
266
+ console.log(`📊 Banque mise à jour — ${index.components.length} composants au total`);
267
+ console.log(` Commandes disponibles:`);
268
+ console.log(` npx tsx scripts/bank.ts stats`);
269
+ console.log(` npx tsx scripts/bank.ts query --type button`);
270
+ console.log(` npx tsx scripts/bank.ts inject <id> [--retheme domain] [--preset name]`);
271
+ console.log(` npx tsx scripts/bank.ts diff <domain1> <domain2>`);
272
+ }
273
+
274
+ // ─── Diff: compare tokens of 2 domains ───────────────────────────────────────
275
+
276
+ function cmdDiff(flags: Record<string, string | boolean>): void {
277
+ const domain1 = flags['_pos'] as string;
278
+ const domain2 = flags['_pos2'] as string;
279
+ if (!domain1 || !domain2) {
280
+ console.error('Usage: npx tsx scripts/bank.ts diff <domain1> <domain2>');
281
+ return;
282
+ }
283
+
284
+ const load = (domain: string) => {
285
+ const p = path.join(EXTRACTIONS_DIR, domain, 'tokens.json');
286
+ if (!fs.existsSync(p)) return null;
287
+ const t = JSON.parse(fs.readFileSync(p, 'utf-8'));
288
+ return {
289
+ bg: t.colors?.background?.primary || '?',
290
+ text: t.colors?.text?.primary || '?',
291
+ accent: t.colors?.accent?.primary || '?',
292
+ border: t.colors?.border || '?',
293
+ font: t.typography?.fontFamily?.primary || '?',
294
+ radiusMd: t.borderRadius?.md || '?',
295
+ spacingMd: t.spacing?.md || '?',
296
+ };
297
+ };
298
+
299
+ const t1 = load(domain1);
300
+ const t2 = load(domain2);
301
+ if (!t1) { console.error(`❌ Tokens not found: ${domain1}`); return; }
302
+ if (!t2) { console.error(`❌ Tokens not found: ${domain2}`); return; }
303
+
304
+ console.log('');
305
+ console.log(`┌─────────────────────────────────────────────────────────────┐`);
306
+ console.log(`│ 🔍 Token Diff: ${domain1} vs ${domain2}`);
307
+ console.log(`└─────────────────────────────────────────────────────────────┘`);
308
+ console.log('');
309
+
310
+ const rows: [string, string, string][] = [
311
+ ['Background', t1.bg, t2.bg],
312
+ ['Text', t1.text, t2.text],
313
+ ['Accent', t1.accent, t2.accent],
314
+ ['Border', t1.border, t2.border],
315
+ ['Font', t1.font, t2.font],
316
+ ['Radius (md)', t1.radiusMd, t2.radiusMd],
317
+ ['Spacing (md)', t1.spacingMd, t2.spacingMd],
318
+ ];
319
+
320
+ let changes = 0;
321
+ for (const [label, v1, v2] of rows) {
322
+ const same = v1 === v2;
323
+ const marker = same ? ' =' : ' ≠';
324
+ if (!same) changes++;
325
+ console.log(` ${label.padEnd(14)} ${v1.padEnd(30)} ${marker} ${v2}`);
326
+ }
327
+ console.log('');
328
+ console.log(` ${changes} différence(s) sur 7 tokens comparés`);
329
+ console.log('');
330
+ }
331
+
332
+ // ─── Main ─────────────────────────────────────────────────────────────────────
333
+
334
+ (async () => {
335
+ const { cmd, flags } = parseArgs(process.argv);
336
+
337
+ switch (cmd) {
338
+ case 'register':
339
+ await cmdRegister(flags);
340
+ break;
341
+ case 'query':
342
+ case 'search':
343
+ cmdQuery(flags);
344
+ break;
345
+ case 'show':
346
+ case 'get':
347
+ cmdShow(flags);
348
+ break;
349
+ case 'inject': {
350
+ // Delegate to bank-inject.ts
351
+ const { execSync } = await import('child_process');
352
+ const injectArgs = process.argv.slice(3).join(' ');
353
+ try {
354
+ execSync(`npx tsx scripts/bank-inject.ts ${injectArgs}`, { stdio: 'inherit', cwd: process.cwd() });
355
+ } catch { process.exit(1); }
356
+ break;
357
+ }
358
+ case 'retheme': {
359
+ // Alias: inject with --retheme or --preset
360
+ const { execSync } = await import('child_process');
361
+ const rethemeArgs = process.argv.slice(3).join(' ');
362
+ try {
363
+ execSync(`npx tsx scripts/bank-inject.ts ${rethemeArgs}`, { stdio: 'inherit', cwd: process.cwd() });
364
+ } catch { process.exit(1); }
365
+ break;
366
+ }
367
+ case 'diff':
368
+ cmdDiff(flags);
369
+ break;
370
+ case 'stats':
371
+ default:
372
+ cmdStats();
373
+ }
374
+ })();
@@ -0,0 +1,189 @@
1
+ /**
2
+ * browser-stealth.ts — Prism Phase 4
3
+ * Lazy-load playwright-extra + stealth plugin pour contourner Cloudflare/DataDome
4
+ * Fallback gracieux vers playwright standard si non disponible
5
+ */
6
+
7
+ import type { Browser, BrowserContext, Page } from 'playwright';
8
+ import { chromium as stdChromium } from 'playwright';
9
+
10
+ // Lazy init : ne charge stealth que si --stealth est demandé
11
+ let stealthChromium: any = null;
12
+ let stealthLoaded = false;
13
+
14
+ async function loadStealth(): Promise<any> {
15
+ if (stealthLoaded) return stealthChromium;
16
+ stealthLoaded = true;
17
+ try {
18
+ // @ts-ignore
19
+ const { chromium: chromiumExtra } = await import('playwright-extra');
20
+ // @ts-ignore
21
+ const stealthMod = await import('puppeteer-extra-plugin-stealth');
22
+ const StealthPlugin = stealthMod.default || stealthMod;
23
+
24
+ const stealth = StealthPlugin();
25
+ // Retirer le plugin iframe.contentWindow qui cause des issues en playwright
26
+ try { stealth.enabledEvasions.delete('iframe.contentWindow'); } catch {}
27
+
28
+ chromiumExtra.use(stealth);
29
+ stealthChromium = chromiumExtra;
30
+ return chromiumExtra;
31
+ } catch (err) {
32
+ console.warn(` ⚠️ Stealth mode unavailable: ${(err as Error).message}`);
33
+ console.warn(' Falling back to standard playwright');
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export interface StealthOptions {
39
+ stealth?: boolean;
40
+ viewport?: { width: number; height: number };
41
+ mobile?: boolean;
42
+ }
43
+
44
+ export async function launchBrowser(options: StealthOptions = {}): Promise<Browser> {
45
+ const launchArgs = [
46
+ '--no-sandbox',
47
+ '--disable-setuid-sandbox',
48
+ '--disable-blink-features=AutomationControlled',
49
+ '--disable-infobars',
50
+ '--disable-dev-shm-usage',
51
+ `--window-size=${options.viewport?.width || 1440},${options.viewport?.height || 900}`,
52
+ ];
53
+
54
+ if (options.stealth) {
55
+ const stealthBrowser = await loadStealth();
56
+ if (stealthBrowser) {
57
+ return stealthBrowser.launch({ headless: true, args: launchArgs });
58
+ }
59
+ }
60
+
61
+ return stdChromium.launch({ headless: true, args: launchArgs });
62
+ }
63
+
64
+ export async function createStealthContext(
65
+ browser: Browser,
66
+ options: StealthOptions = {},
67
+ ): Promise<BrowserContext> {
68
+ const vp = options.viewport || { width: 1440, height: 900 };
69
+ const mobile = options.mobile;
70
+
71
+ const context = await browser.newContext({
72
+ viewport: vp,
73
+ userAgent: mobile
74
+ ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1'
75
+ : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
76
+ deviceScaleFactor: mobile ? 3 : 2,
77
+ locale: 'fr-FR',
78
+ timezoneId: 'Europe/Paris',
79
+ extraHTTPHeaders: {
80
+ 'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
81
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
82
+ 'sec-ch-ua': '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
83
+ 'sec-ch-ua-mobile': mobile ? '?1' : '?0',
84
+ 'sec-ch-ua-platform': mobile ? '"Android"' : '"Windows"',
85
+ 'sec-fetch-dest': 'document',
86
+ 'sec-fetch-mode': 'navigate',
87
+ 'sec-fetch-site': 'none',
88
+ 'sec-fetch-user': '?1',
89
+ 'upgrade-insecure-requests': '1',
90
+ },
91
+ });
92
+
93
+ // Anti-detection script (applied even without stealth plugin)
94
+ await context.addInitScript(() => {
95
+ // navigator.webdriver
96
+ Object.defineProperty(navigator, 'webdriver', { get: () => false, configurable: true });
97
+
98
+ // navigator.plugins (make it look realistic)
99
+ Object.defineProperty(navigator, 'plugins', {
100
+ get: () => [
101
+ { name: 'PDF Viewer', description: 'Portable Document Format', filename: 'internal-pdf-viewer' },
102
+ { name: 'Chrome PDF Viewer', description: '', filename: 'internal-pdf-viewer' },
103
+ { name: 'Chromium PDF Viewer', description: '', filename: 'internal-pdf-viewer' },
104
+ ],
105
+ configurable: true,
106
+ });
107
+
108
+ // navigator.languages
109
+ Object.defineProperty(navigator, 'languages', {
110
+ get: () => ['fr-FR', 'fr', 'en-US', 'en'],
111
+ configurable: true,
112
+ });
113
+
114
+ // window.chrome
115
+ (window as any).chrome = {
116
+ runtime: {},
117
+ loadTimes: () => ({}),
118
+ csi: () => ({}),
119
+ app: {},
120
+ };
121
+
122
+ // Permissions API
123
+ const originalQuery = window.navigator.permissions?.query;
124
+ if (originalQuery) {
125
+ window.navigator.permissions.query = (parameters: any): Promise<any> =>
126
+ parameters.name === 'notifications'
127
+ ? Promise.resolve({ state: Notification.permission } as any)
128
+ : originalQuery(parameters);
129
+ }
130
+
131
+ // Hide CDP detection
132
+ try {
133
+ const nav = navigator as any;
134
+ delete nav.__proto__.webdriver;
135
+ } catch {}
136
+
137
+ // WebGL vendor/renderer spoofing (very common bot detection)
138
+ try {
139
+ const getParameter = WebGLRenderingContext.prototype.getParameter;
140
+ WebGLRenderingContext.prototype.getParameter = function(param: number) {
141
+ if (param === 37445) return 'Intel Inc.'; // UNMASKED_VENDOR_WEBGL
142
+ if (param === 37446) return 'Intel Iris OpenGL Engine'; // UNMASKED_RENDERER_WEBGL
143
+ return getParameter.call(this, param);
144
+ };
145
+ } catch {}
146
+ });
147
+
148
+ return context;
149
+ }
150
+
151
+ // ─── Cloudflare detection + wait ──────────────────────────────────────────────
152
+
153
+ export async function waitForCloudflare(page: Page, maxWait = 15000): Promise<boolean> {
154
+ const start = Date.now();
155
+ while (Date.now() - start < maxWait) {
156
+ const isChallenge = await page.evaluate(() => {
157
+ const title = document.title.toLowerCase();
158
+ const body = document.body?.innerText?.toLowerCase() || '';
159
+ return (
160
+ title.includes('just a moment') ||
161
+ title.includes('attendez') ||
162
+ body.includes('checking your browser') ||
163
+ body.includes('vérification') ||
164
+ !!document.querySelector('#cf-challenge-stage') ||
165
+ !!document.querySelector('[data-cf-challenge]') ||
166
+ !!document.querySelector('#turnstile-wrapper')
167
+ );
168
+ }).catch(() => false);
169
+
170
+ if (!isChallenge) return true;
171
+ await page.waitForTimeout(500);
172
+ }
173
+ return false;
174
+ }
175
+
176
+ export async function detectBotProtection(page: Page): Promise<string | null> {
177
+ return page.evaluate(() => {
178
+ const html = document.documentElement.outerHTML.toLowerCase();
179
+ const title = document.title.toLowerCase();
180
+
181
+ if (title.includes('just a moment') || document.querySelector('#cf-challenge-stage')) return 'cloudflare';
182
+ if (html.includes('datadome')) return 'datadome';
183
+ if (html.includes('perimeterx')) return 'perimeterx';
184
+ if (html.includes('imperva') || html.includes('incapsula')) return 'imperva';
185
+ if (document.querySelector('iframe[src*="recaptcha"]')) return 'recaptcha';
186
+ if (document.querySelector('.h-captcha, iframe[src*="hcaptcha"]')) return 'hcaptcha';
187
+ return null;
188
+ }).catch(() => null);
189
+ }