1import { createPlaywrightRouter, Dataset, Log } from 'crawlee';
2import { Actor } from 'apify';
3
4// Types pour la gestion des données
5interface DomainEmailsMap {
6 [domain: string]: string[];
7}
8
9// Configuration globale pour la recherche d'emails
10const EMAIL_PATTERNS = {
11 // Expressions régulières pour les emails standards
12 standard: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
13
14 // Expressions régulières pour les emails obfusqués
15 obfuscated: {
16 atReplacements: [' at ', '[at]', '(at)', '@'],
17 dotReplacements: [' dot ', '[dot]', '(dot)', '.']
18 },
19
20 // Liste des classes et IDs à explorer en priorité
21 prioritySelectors: [
22 '.contact', '.contact-info', '.footer', 'footer', '.address', 'address',
23 '.email', '#email', '[class*="contact"]', '[id*="contact"]',
24 '.legal', '.mentions', '#legal', '.support', '.team', '.about', '.staff',
25 '#footer', '.footer-container', '.site-info', '.copyright'
26 ]
27};
28
29// Mots-clés pour identifier les pages importantes
30const PRIORITY_KEYWORDS = {
31 highest: ['contact', 'nous contacter', 'contactez-nous', 'contactez nous', 'service client', 'customer service'],
32 high: ['legal', 'mentions légales', 'cgv', 'conditions générales', 'privacy', 'confidentialité'],
33 medium: ['about', 'à propos', 'qui sommes-nous', 'équipe', 'team', 'support']
34};
35
36// Création du router Playwright
37export const router = createPlaywrightRouter();
38
39// Route par défaut pour gérer toutes les pages
40router.addDefaultHandler(async ({ request, page, enqueueLinks, log }) => {
41 const url = request.url;
42 const userData = request.userData;
43 const currentDepth = userData.depth as number || 0;
44 const maxDepth = userData.maxDepth as number || 3; // Profondeur par défaut augmentée
45
46 log.info(`Traitement de ${url} (profondeur: ${currentDepth}/${maxDepth})`);
47
48 // Récupération des ensembles pour stocker les résultats
49 const storedEmails = await Actor.getValue('foundEmails');
50 const foundEmails = new Set<string>(Array.isArray(storedEmails) ? storedEmails : []);
51
52 const storedUrls = await Actor.getValue('visitedUrls');
53 const visitedUrls = new Set<string>(Array.isArray(storedUrls) ? storedUrls : []);
54
55 // Récupérer les domaines crawlés et les emails trouvés par domaine
56 const storedDomainEmails = await Actor.getValue<DomainEmailsMap>('domainEmails') || {};
57 const domainEmails: DomainEmailsMap = { ...storedDomainEmails };
58
59 // Marquer l'URL comme visitée
60 visitedUrls.add(url);
61 await Actor.setValue('visitedUrls', Array.from(visitedUrls));
62
63 // Identifier le domaine du site actuel
64 const currentDomain = new URL(url).hostname;
65
66 try {
67 // Attendre que la page soit complètement chargée
68 await page.waitForLoadState('networkidle', { timeout: 30000 });
69 } catch (e) {
70 log.warning(`Timeout en attendant que la page soit chargée: ${url}`);
71 // On continue malgré le timeout
72 }
73
74 // Extraction des emails trouvés dans la page avec des méthodes avancées
75 const emails = await extractEmailsAdvanced(page, log);
76
77 // Si des emails sont trouvés, les traiter
78 if (emails.length > 0) {
79 // Filtrer les doublons globaux
80 const newEmails = emails.filter(email => !foundEmails.has(email));
81
82 // Ajouter les nouveaux emails à l'ensemble
83 newEmails.forEach(email => foundEmails.add(email));
84 await Actor.setValue('foundEmails', Array.from(foundEmails));
85
86 // Mettre à jour le compteur d'emails par domaine
87 if (!domainEmails[currentDomain]) {
88 domainEmails[currentDomain] = [];
89 }
90
91 // Ajouter uniquement les nouveaux emails pour ce domaine
92 emails.forEach(email => {
93 if (!domainEmails[currentDomain].includes(email)) {
94 domainEmails[currentDomain].push(email);
95 }
96 });
97
98 await Actor.setValue('domainEmails', domainEmails);
99
100 // Si de nouveaux emails sont trouvés, enregistrer les résultats
101 if (newEmails.length > 0) {
102 await Dataset.pushData({
103 url: url,
104 domain: currentDomain,
105 emails: newEmails
106 });
107
108 log.info(`Trouvé ${newEmails.length} nouveaux emails sur ${url}`);
109 log.info(`Total pour ${currentDomain}: ${domainEmails[currentDomain].length} emails`);
110 }
111 }
112
113 // Vérifier si nous devons continuer à crawler plus profondément
114 const shouldContinueCrawling = shouldContinue(currentDepth, maxDepth, domainEmails[currentDomain]?.length || 0);
115
116 if (shouldContinueCrawling) {
117 // 1. Rechercher d'abord les liens de pied de page et de contact
118 const footerAndContactLinks = await findFooterAndContactLinks(page, url);
119 if (footerAndContactLinks.length > 0) {
120 log.info(`Trouvé ${footerAndContactLinks.length} liens importants (footer/contact)`);
121
122 // Enqueue uniquement les liens non visités
123 const notVisitedLinks = footerAndContactLinks.filter(link => !visitedUrls.has(link.url));
124
125 if (notVisitedLinks.length > 0) {
126 // Trier par priorité
127 notVisitedLinks.sort((a, b) => b.priority - a.priority);
128
129 // Construire un tableau d'URLs pour l'enqueue
130 const priorityUrls = notVisitedLinks.map(link => link.url);
131
132 // Enqueue avec priorité maximale
133 await enqueueLinks({
134 urls: priorityUrls,
135 transformRequestFunction: (req) => {
136 req.userData = {
137 ...userData,
138 depth: currentDepth + 1,
139 maxDepth,
140 isPriorityLink: true,
141 linkPriority: 'high'
142 };
143 return req;
144 }
145 });
146 log.info(`Ajout prioritaire de ${notVisitedLinks.length} liens importants`);
147 }
148 }
149
150 // 2. Ensuite, chercher les liens à explorer plus tard avec priorité standard
151 log.info(`Enqueue des liens standard à partir de ${url} (profondeur actuelle: ${currentDepth})`);
152
153 // Créer un ensemble pour suivre les liens prioritaires déjà ajoutés
154 const priorityLinkUrls = new Set(footerAndContactLinks.map(link => link.url));
155
156 // Ajouter d'autres liens avec priorité standard
157 await enqueueLinks({
158 globs: [`${new URL(url).origin}/**`], // Rester sur le même domaine
159 transformRequestFunction: (req) => {
160 // Ne pas revisiter les URLs déjà traitées ou prioritaires
161 if (visitedUrls.has(req.url) || priorityLinkUrls.has(req.url)) {
162 return false;
163 }
164
165 // Ignorer certains types de fichiers et URLs
166 const parsedUrl = new URL(req.url);
167 const path = parsedUrl.pathname.toLowerCase();
168
169 // Ignorer le panier, le compte utilisateur et autres pages non pertinentes
170 // sauf si ce sont des pages prioritaires (qui peuvent contenir "contact", etc.)
171 const isLikelyImportant = isPriorityPath(path);
172
173 if (!isLikelyImportant && (
174 path.includes('/cart') || path.includes('/account') ||
175 path.includes('/login') || path.includes('/signin') ||
176 path.includes('/products/')
177 )) {
178 return false;
179 }
180
181 // Ignorer les fichiers non HTML
182 const ext = path.split('.').pop() || '';
183 const skipExtensions = [
184 'pdf', 'jpg', 'jpeg', 'png', 'gif', 'zip', 'rar',
185 'mp3', 'mp4', 'avi', 'mov', 'webm', 'css', 'js'
186 ];
187
188 if (skipExtensions.includes(ext)) {
189 return false;
190 }
191
192 // Donner une priorité plus élevée aux pages qui semblent importantes
193 const linkPriority = isLikelyImportant ? 'medium' : 'normal';
194
195 // Ajouter la profondeur pour les nouvelles requêtes
196 req.userData = {
197 ...userData,
198 depth: currentDepth + 1,
199 maxDepth,
200 isPriorityLink: isLikelyImportant,
201 linkPriority
202 };
203
204 return req;
205 },
206 });
207 }
208});
209
210/**
211 * Détermine si le crawler doit continuer à explorer plus profondément
212 */
213function shouldContinue(currentDepth: number, maxDepth: number, emailsFoundForDomain: number): boolean {
214 // Toujours continuer si nous n'avons pas atteint la profondeur maximale
215 if (currentDepth < maxDepth) {
216 return true;
217 }
218
219 // Si nous sommes à la profondeur maximale mais n'avons pas trouvé d'emails,
220 // augmenter légèrement la profondeur (bonus de recherche)
221 if (currentDepth === maxDepth && emailsFoundForDomain === 0) {
222 return true;
223 }
224
225 // Sinon, arrêter à la profondeur maximale
226 return false;
227}
228
229/**
230 * Vérifie si un chemin URL est probablement important
231 */
232function isPriorityPath(path: string): boolean {
233 const lowerPath = path.toLowerCase();
234
235 // Vérifier les chemins qui sont généralement importants
236 return (
237 lowerPath.includes('contact') ||
238 lowerPath.includes('about') ||
239 lowerPath.includes('apropos') ||
240 lowerPath.includes('legal') ||
241 lowerPath.includes('mention') ||
242 lowerPath.includes('cgv') ||
243 lowerPath.includes('terms') ||
244 lowerPath.includes('condition') ||
245 lowerPath.includes('privacy') ||
246 lowerPath.includes('team') ||
247 lowerPath.includes('equipe')
248 );
249}
250
251/**
252 * Fonction pour identifier les liens dans le footer et pages de contact
253 * Ces liens sont souvent les plus pertinents pour les informations de contact
254 */
255async function findFooterAndContactLinks(page: any, baseUrl: string): Promise<{ url: string, priority: number }[]> {
256 return await page.evaluate((baseUrl: string, keywordsObj: typeof PRIORITY_KEYWORDS) => {
257 const links: { url: string, priority: number }[] = [];
258
259 // Sélecteurs potentiels pour les pieds de page
260 const footerSelectors = [
261 'footer', '.footer', '.site-footer', '#footer',
262 '[class*="footer"]', '[id*="footer"]',
263 '.bottom', '.bottom-bar', '.copyright',
264 '.legals', '.legal-links'
265 ];
266
267 // Extraire les mots-clés des différentes priorités
268 const highestPriorityKeywords = keywordsObj.highest;
269 const highPriorityKeywords = keywordsObj.high;
270 const mediumPriorityKeywords = keywordsObj.medium;
271
272 // Chercher dans les éléments de footer
273 let footerElements: Element[] = [];
274 for (const selector of footerSelectors) {
275 const elements = document.querySelectorAll(selector);
276 if (elements.length > 0) {
277 footerElements = [...footerElements, ...Array.from(elements)];
278 }
279 }
280
281 // Fonction pour déterminer la priorité d'un lien
282 const getLinkPriority = (text: string, href: string): number => {
283 const lowerText = text.toLowerCase();
284 const lowerHref = href.toLowerCase();
285
286 // Priorité maximale pour les liens de contact, support, etc.
287 if (highestPriorityKeywords.some((kw: string) => lowerText.includes(kw) || lowerHref.includes(kw))) {
288 return 10;
289 }
290
291 // Priorité élevée pour les mentions légales, CGV, etc.
292 if (highPriorityKeywords.some((kw: string) => lowerText.includes(kw) || lowerHref.includes(kw))) {
293 return 8;
294 }
295
296 // Priorité moyenne pour les pages à propos, équipe, etc.
297 if (mediumPriorityKeywords.some((kw: string) => lowerText.includes(kw) || lowerHref.includes(kw))) {
298 return 6;
299 }
300
301 // Priorité de base pour les liens dans le footer
302 return 3;
303 };
304
305 // Chercher les liens dans ces éléments de footer
306 footerElements.forEach(footer => {
307 const footerLinks = footer.querySelectorAll('a');
308 footerLinks.forEach(link => {
309 let href = link.getAttribute('href');
310 if (!href) return;
311
312 // Convertir en URL absolue si nécessaire
313 if (href.startsWith('/')) {
314 const baseUrlObj = new URL(baseUrl);
315 href = `${baseUrlObj.origin}${href}`;
316 } else if (!href.startsWith('http')) {
317 // Ignorer les liens javascript, etc. mais traiter les mailto
318 if (href.startsWith('mailto:')) {
319 // Extraire l'email des liens mailto
320 const email = href.replace('mailto:', '').trim();
321 if (email) {
322 console.log(`Found email in mailto link: ${email}`);
323 }
324 }
325 return;
326 }
327
328 // Ignorer les liens externes
329 if (!href.includes(new URL(baseUrl).hostname)) return;
330
331 const linkText = link.textContent?.toLowerCase().trim() || '';
332 const priority = getLinkPriority(linkText, href);
333
334 links.push({ url: href, priority });
335 });
336 });
337
338 // Chercher les liens de contact potentiels en dehors du footer également
339 document.querySelectorAll('a').forEach(link => {
340 let href = link.getAttribute('href');
341 if (!href) return;
342
343 // Convertir en URL absolue si nécessaire
344 if (href.startsWith('/')) {
345 const baseUrlObj = new URL(baseUrl);
346 href = `${baseUrlObj.origin}${href}`;
347 } else if (!href.startsWith('http')) {
348 return;
349 }
350
351 // Ignorer les liens externes et ceux déjà trouvés
352 if (!href.includes(new URL(baseUrl).hostname) || links.some(l => l.url === href)) return;
353
354 const linkText = link.textContent?.toLowerCase().trim() || '';
355 const priority = getLinkPriority(linkText, href);
356
357 // N'ajouter que les liens avec une priorité significative
358 if (priority > 3) {
359 links.push({ url: href, priority });
360 }
361 });
362
363 // Trier par priorité décroissante
364 return links.sort((a, b) => b.priority - a.priority);
365 }, baseUrl, PRIORITY_KEYWORDS);
366}
367
368/**
369 * Fonction avancée pour extraire tous les emails d'une page avec plusieurs méthodes
370 */
371async function extractEmailsAdvanced(page: any, log: Log): Promise<string[]> {
372 try {
373 // Extraction du contenu HTML complet
374 const html = await page.content();
375
376 // Recherche des emails avec une regex dans le HTML
377 const emailRegex = EMAIL_PATTERNS.standard;
378 const htmlEmails = html.match(emailRegex) || [];
379
380 // Recherche des liens mailto dans le HTML
381 const mailtoRegex = /mailto:([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
382 let match;
383 const mailtoEmails: string[] = [];
384
385 while ((match = mailtoRegex.exec(html)) !== null) {
386 if (match[1]) {
387 mailtoEmails.push(match[1]);
388 }
389 }
390
391 // Recherche avancée des emails dans le DOM
392 const domEmails = await page.evaluate((patterns: typeof EMAIL_PATTERNS) => {
393 const results: string[] = [];
394 const regex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
395
396 try {
397 // 1. Rechercher dans les attributs des éléments
398 document.querySelectorAll('*').forEach(element => {
399 Array.from(element.attributes).forEach(attr => {
400 const matches = attr.value.match(regex);
401 if (matches) {
402 results.push(...matches);
403 }
404 });
405 });
406
407 // 2. Rechercher dans les scripts
408 document.querySelectorAll('script').forEach(script => {
409 const matches = script.textContent?.match(regex);
410 if (matches) {
411 results.push(...matches);
412 }
413 });
414
415 // 3. Rechercher les emails masqués avec différentes méthodes d'obfuscation
416 // Collecter tous les textes qui pourraient contenir des emails obfusqués
417 const potentialEmailTexts = Array.from(document.querySelectorAll('*'))
418 .map(el => el.textContent?.trim())
419 .filter(text => text && (
420 patterns.obfuscated.atReplacements.some((r: string) => text.includes(r)) ||
421 patterns.obfuscated.dotReplacements.some((r: string) => text.includes(r))
422 ));
423
424 // Traiter les textes avec des remplacements possibles
425 potentialEmailTexts.forEach(text => {
426 if (!text) return;
427
428 // Essayer différentes combinaisons de remplacement
429 const combinations: string[] = [text];
430
431 for (const atReplacement of patterns.obfuscated.atReplacements) {
432 for (const dotReplacement of patterns.obfuscated.dotReplacements) {
433 if (atReplacement !== '@' || dotReplacement !== '.') {
434 let normalizedText = text
435 .replace(new RegExp(atReplacement.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '@')
436 .replace(new RegExp(dotReplacement.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '.');
437 combinations.push(normalizedText);
438 }
439 }
440 }
441
442 // Vérifier chaque combinaison
443 combinations.forEach(combinedText => {
444 const matches = combinedText.match(regex);
445 if (matches) {
446 results.push(...matches);
447 }
448 });
449 });
450
451 // 4. Recherche ciblée dans les éléments susceptibles de contenir des emails
452 patterns.prioritySelectors.forEach((selector: string) => {
453 document.querySelectorAll(selector).forEach(element => {
454 const text = element.textContent || '';
455
456 // Recherche standard
457 const matches = text.match(regex);
458 if (matches) {
459 results.push(...matches);
460 }
461
462 // Recherche dans le HTML en cas d'obfuscation par HTML
463 if (element.innerHTML) {
464 const html = element.innerHTML;
465 // Rechercher des patterns comme nom<span>@</span>domaine.com
466 const strippedHtml = html.replace(/<[^>]*>/g, '');
467 if (strippedHtml !== text) {
468 const matches = strippedHtml.match(regex);
469 if (matches) {
470 results.push(...matches);
471 }
472 }
473 }
474 });
475 });
476
477 // 5. Recherche dans les commentaires HTML
478 const nodeIterator = document.createNodeIterator(
479 document.documentElement,
480 NodeFilter.SHOW_COMMENT
481 );
482 let currentNode;
483 while (currentNode = nodeIterator.nextNode()) {
484 const commentText = currentNode.nodeValue || '';
485 const matches = commentText.match(regex);
486 if (matches) {
487 results.push(...matches);
488 }
489 }
490
491 } catch (e) {
492 // Ignorer les erreurs pendant l'évaluation
493 console.error('Erreur lors de l\'extraction des emails:', e);
494 }
495
496 return results;
497 }, EMAIL_PATTERNS);
498
499 // Combiner et dédupliquer les emails
500 const allEmails = [...new Set([...htmlEmails, ...mailtoEmails, ...domEmails])];
501
502 // Exclure les emails invalides ou exemples courants
503 const validEmails = allEmails.filter(email => {
504 // Exclure les domaines d'exemple
505 const excludeDomains = ['example.com', 'domain.com', 'email.com', 'yoursite.com', 'yourdomain.com'];
506 const parts = email.split('@');
507
508 if (parts.length !== 2) return false;
509
510 const domain = parts[1];
511
512 // Vérifier si c'est un domaine d'exemple
513 if (excludeDomains.includes(domain)) {
514 return false;
515 }
516
517 // Vérifier si l'email contient des caractères valides
518 return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email);
519 });
520
521 return validEmails;
522 } catch (error) {
523 log.error(`Erreur lors de l'extraction des emails: ${error}`);
524 return [];
525 }
526}