Email avatar
Email

Under maintenance

Pricing

Pay per usage

Go to Store
Email

Email

Under maintenance

Developed by

TML

TML

Maintained by Community

0.0 (0)

Pricing

Pay per usage

1

Total users

2

Monthly users

2

Runs succeeded

>99%

Last modified

19 days ago

.actor/Dockerfile

# Specify the base Docker image. You can read more about
# the available images at https://crawlee.dev/docs/guides/docker-images
# You can also use any other image from Docker Hub.
FROM apify/actor-node-playwright-chrome:20 AS builder
# Check preinstalled packages
RUN npm ls crawlee apify puppeteer playwright
# Copy just package.json and package-lock.json
# to speed up the build using Docker layer cache.
COPY --chown=myuser package*.json ./
# Install all dependencies. Don't audit to speed up the installation.
RUN npm install --include=dev --audit=false
# Next, copy the source files using the user set
# in the base image.
COPY --chown=myuser . ./
# Install all dependencies and build the project.
# Don't audit to speed up the installation.
RUN npm run build
# Create final image
FROM apify/actor-node-playwright-chrome:20
# Check preinstalled packages
RUN npm ls crawlee apify puppeteer playwright
# Copy just package.json and package-lock.json
# to speed up the build using Docker layer cache.
COPY --chown=myuser package*.json ./
# Install NPM packages, skip optional and development dependencies to
# keep the image small. Avoid logging too much and print the dependency
# tree for debugging
RUN npm --quiet set progress=false \
&& npm install --omit=dev --omit=optional \
&& echo "Installed NPM packages:" \
&& (npm list --omit=dev --all || true) \
&& echo "Node.js version:" \
&& node --version \
&& echo "NPM version:" \
&& npm --version \
&& rm -r ~/.npm
# Copy built JS files from builder image
COPY --from=builder --chown=myuser /home/myuser/dist ./dist
# Next, copy the remaining files and directories with the source code.
# Since we do this after NPM install, quick build will be really fast
# for most source file changes.
COPY --chown=myuser . ./
# Run the image. If you know you won't need headful browsers,
# you can remove the XVFB start script for a micro perf gain.
CMD ./start_xvfb_and_run_cmd.sh && npm run start:prod --silent

.actor/actor.json

{
"actorSpecification": 1,
"name": "my-actor-2",
"title": "Project Playwright Crawler Typescript",
"description": "Crawlee and Playwright project in typescript.",
"version": "0.0",
"meta": {
"templateId": "ts-crawlee-playwright-chrome"
},
"input": "./input_schema.json",
"dockerfile": "./Dockerfile"
}

.actor/input_schema.json

{
"title": "Email Extractor Crawler",
"type": "object",
"schemaVersion": 1,
"properties": {
"startUrls": {
"title": "Start URLs",
"type": "array",
"description": "URLs à partir desquelles commencer le crawling",
"editor": "requestListSources",
"prefill": [
{
"url": "https://www.example.com"
}
]
},
"maxRequestsPerCrawl": {
"title": "Nombre maximum de requêtes",
"type": "integer",
"description": "Nombre maximum de requêtes pouvant être effectuées par ce crawler",
"default": 20
},
"maxDepth": {
"title": "Profondeur maximale",
"type": "integer",
"description": "Profondeur maximale de crawling (1 signifie seulement la page d'accueil et ses liens directs)",
"default": 1
}
},
"required": ["startUrls"]
}

src/main.ts

1/**
2 * Crawler pour extraire des adresses emails à partir d'une URL
3 * Utilise Crawlee, Playwright et Chrome headless pour une extraction avancée.
4 */
5
6// Pour plus d'information, voir https://docs.apify.com/sdk/js
7import { Actor } from 'apify';
8// Pour plus d'information, voir https://crawlee.dev
9import { PlaywrightCrawler } from 'crawlee';
10// this is ESM project, and as such, it requires you to specify extensions
11// read more about this here: https://nodejs.org/docs/latest-v18.x/api/esm
12// note that we need to use `.js` even when inside TS files
13import { router } from './routes.js';
14
15// Définition des types d'entrée
16interface CrawlerInput {
17 startUrls: any[]; // Accepter différents formats d'URL
18 maxRequestsPerCrawl: number;
19 maxDepth?: number;
20 maxPagesPerDomain?: number;
21}
22
23// Fonction principale d'exécution
24await Actor.main(async () => {
25 // Récupération et validation des inputs
26 const input = await Actor.getInput<CrawlerInput>();
27
28 if (!input || !Array.isArray(input.startUrls) || input.startUrls.length === 0) {
29 throw new Error('La configuration "startUrls" est requise et doit être un tableau non-vide d\'URLs');
30 }
31
32 // Initialiser l'état global pour stocker les emails trouvés
33 await Actor.setValue('foundEmails', [] as string[]);
34 await Actor.setValue('visitedUrls', [] as string[]);
35 await Actor.setValue('domainEmails', {} as Record<string, string[]>);
36
37 console.log('Démarrage du crawler avec les URLs suivantes:', input.startUrls);
38
39 // Configuration des valeurs par défaut
40 const maxDepth = input.maxDepth || 1; // Réduire la profondeur par défaut
41 const maxPagesPerDomain = input.maxPagesPerDomain || 20; // Réduire le nombre de pages par domaine
42
43 // Création du crawler Playwright
44 const crawler = new PlaywrightCrawler({
45 // Utiliser le router importé pour la gestion des requêtes
46 requestHandler: router,
47
48 // Utiliser un navigateur Chrome headless
49 launchContext: {
50 launchOptions: {
51 headless: true,
52 },
53 },
54
55 // Limiter le nombre de requêtes
56 maxRequestsPerCrawl: input.maxRequestsPerCrawl || maxPagesPerDomain,
57
58 // Définir un timeout plus court pour éviter les blocages
59 navigationTimeoutSecs: 15,
60
61 // Limiter les requêtes simultanées pour éviter la surcharge
62 maxConcurrency: 2,
63 });
64
65 // Préparation des requêtes initiales avec les bons paramètres
66 const initialRequests = input.startUrls.map((urlData) => {
67 // Si c'est déjà un objet avec une propriété url, on l'utilise tel quel
68 // mais on ajoute les paramètres userData
69 if (typeof urlData === 'object' && urlData.url) {
70 return {
71 ...urlData,
72 userData: {
73 depth: 0,
74 maxDepth
75 }
76 };
77 }
78
79 // Si c'est juste une chaîne, on la convertit en objet
80 if (typeof urlData === 'string') {
81 return {
82 url: urlData,
83 userData: {
84 depth: 0,
85 maxDepth
86 }
87 };
88 }
89
90 // Sinon, on retourne l'objet tel quel (cela pourrait causer des erreurs)
91 return urlData;
92 });
93
94 try {
95 // Lancement du crawler avec les requêtes préparées
96 await crawler.run(initialRequests);
97
98 // Récupération des données finales
99 const foundEmails = await Actor.getValue('foundEmails') as string[] || [];
100 const visitedUrls = await Actor.getValue('visitedUrls') as string[] || [];
101
102 // Résumé final
103 console.log(`Crawling terminé. ${foundEmails.length} emails uniques ont été trouvés sur ${visitedUrls.length} pages.`);
104
105 // Création d'un objet de résultat final
106 const finalResult = {
107 totalEmailsFound: foundEmails.length,
108 emails: foundEmails,
109 urlsVisited: visitedUrls.length
110 };
111
112 // Enregistrer le résultat final
113 await Actor.setValue('OUTPUT', finalResult);
114
115 console.log('Traitement terminé avec succès !');
116 } catch (error) {
117 console.error('Erreur lors du crawling:', error);
118 // S'assurer de toujours avoir un résultat même en cas d'erreur
119 const foundEmails = await Actor.getValue('foundEmails') as string[] || [];
120 const visitedUrls = await Actor.getValue('visitedUrls') as string[] || [];
121
122 const finalResult = {
123 totalEmailsFound: foundEmails.length,
124 emails: foundEmails,
125 urlsVisited: visitedUrls.length,
126 error: (error as Error).message || String(error)
127 };
128
129 await Actor.setValue('OUTPUT', finalResult);
130
131 console.log('Traitement terminé avec erreur, résultats partiels sauvegardés.');
132 }
133});

src/routes.ts

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 || 1; // Profondeur par défaut réduite
45
46 log.info(`Traitement de ${url} (profondeur: ${currentDepth}/${maxDepth})`);
47
48 // Récupération des ensembles pour stocker les résultats
49 const foundEmails = (await Actor.getValue('foundEmails') as string[]) || [];
50 const visitedUrls = (await Actor.getValue('visitedUrls') as string[]) || [];
51 const domainEmails = (await Actor.getValue('domainEmails') as DomainEmailsMap) || {};
52
53 // Identifier le domaine du site actuel
54 const currentDomain = new URL(url).hostname;
55
56 // Marquer l'URL comme visitée
57 if (!visitedUrls.includes(url)) {
58 visitedUrls.push(url);
59 await Actor.setValue('visitedUrls', visitedUrls);
60 }
61
62 try {
63 // Attendre que la page soit chargée, mais avec un timeout plus court
64 await page.waitForLoadState('domcontentloaded', { timeout: 10000 });
65
66 // Extraction des emails trouvés dans la page avec des méthodes avancées
67 const emails = await extractEmailsAdvanced(page, log);
68
69 // Si des emails sont trouvés, les traiter
70 if (emails.length > 0) {
71 // Filtrer les doublons globaux
72 const newEmails = emails.filter(email => !foundEmails.includes(email));
73
74 // Ajouter les nouveaux emails à l'ensemble
75 if (newEmails.length > 0) {
76 foundEmails.push(...newEmails);
77 await Actor.setValue('foundEmails', foundEmails);
78
79 // Mettre à jour le compteur d'emails par domaine
80 if (!domainEmails[currentDomain]) {
81 domainEmails[currentDomain] = [];
82 }
83
84 // Ajouter uniquement les nouveaux emails pour ce domaine
85 emails.forEach(email => {
86 if (!domainEmails[currentDomain].includes(email)) {
87 domainEmails[currentDomain].push(email);
88 }
89 });
90
91 await Actor.setValue('domainEmails', domainEmails);
92
93 // Enregistrer les résultats
94 await Dataset.pushData({
95 url: url,
96 domain: currentDomain,
97 emails: newEmails
98 });
99
100 log.info(`Trouvé ${newEmails.length} nouveaux emails sur ${url}`);
101 }
102 }
103
104 // Vérifier si nous devons continuer à crawler plus profondément
105 if (currentDepth < maxDepth) {
106 // 1. Rechercher d'abord les liens de pied de page et de contact
107 const footerAndContactLinks = await findFooterAndContactLinks(page, url);
108
109 if (footerAndContactLinks.length > 0) {
110 log.info(`Trouvé ${footerAndContactLinks.length} liens importants (footer/contact)`);
111
112 // Enqueue uniquement les liens non visités
113 const notVisitedLinks = footerAndContactLinks.filter(link => !visitedUrls.includes(link.url));
114
115 if (notVisitedLinks.length > 0) {
116 // Limiter le nombre de liens à explorer pour respecter les contraintes de temps
117 const priorityUrls = notVisitedLinks
118 .sort((a, b) => b.priority - a.priority)
119 .slice(0, 3) // Limiter à 3 liens prioritaires maximum
120 .map(link => link.url);
121
122 // Enqueue avec priorité maximale
123 await enqueueLinks({
124 urls: priorityUrls,
125 transformRequestFunction: (req) => {
126 req.userData = {
127 ...userData,
128 depth: currentDepth + 1,
129 maxDepth,
130 isPriorityLink: true
131 };
132 return req;
133 }
134 });
135 }
136 }
137
138 // 2. Ensuite, chercher les liens à explorer plus tard (limiter pour respecter les contraintes)
139 // Seulement si nous n'avons pas trop de liens déjà visités
140 if (visitedUrls.length < 20) {
141 await enqueueLinks({
142 globs: [`${new URL(url).origin}/**`], // Rester sur le même domaine
143 transformRequestFunction: (req) => {
144 // Ne pas revisiter les URLs déjà traitées
145 if (visitedUrls.includes(req.url)) {
146 return false;
147 }
148
149 // Ignorer certains types de fichiers et URLs
150 const parsedUrl = new URL(req.url);
151 const path = parsedUrl.pathname.toLowerCase();
152
153 // Ignorer les fichiers non HTML et les routes non pertinentes
154 const ext = path.split('.').pop() || '';
155 const skipExtensions = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'css', 'js'];
156
157 if (skipExtensions.includes(ext) ||
158 path.includes('/cart') ||
159 path.includes('/account') ||
160 path.includes('/login')) {
161 return false;
162 }
163
164 // Ajouter la profondeur pour les nouvelles requêtes
165 req.userData = {
166 ...userData,
167 depth: currentDepth + 1,
168 maxDepth
169 };
170
171 return req;
172 },
173 limit: 5, // Limiter à 5 liens par page pour ne pas déborder
174 });
175 }
176 }
177 } catch (error) {
178 log.error(`Erreur lors du traitement de la page ${url}: ${error}`);
179 // Continuer avec les autres requêtes malgré l'erreur
180 }
181});
182
183/**
184 * Fonction pour identifier les liens dans le footer et pages de contact
185 * Version simplifiée pour améliorer les performances
186 */
187async function findFooterAndContactLinks(page: any, baseUrl: string): Promise<{ url: string, priority: number }[]> {
188 try {
189 return await page.evaluate((baseUrl: string, keywordsObj: typeof PRIORITY_KEYWORDS) => {
190 const links: { url: string, priority: number }[] = [];
191
192 // Sélecteurs potentiels pour les pieds de page
193 const footerSelectors = [
194 'footer', '.footer', '#footer',
195 '.copyright', '.legals'
196 ];
197
198 // Fonction pour déterminer la priorité d'un lien
199 const getLinkPriority = (text: string, href: string): number => {
200 const lowerText = text.toLowerCase();
201 const lowerHref = href.toLowerCase();
202
203 // Vérifier les mots-clés de priorité
204 for (const kw of keywordsObj.highest) {
205 if (lowerText.includes(kw) || lowerHref.includes(kw)) return 10;
206 }
207
208 for (const kw of keywordsObj.high) {
209 if (lowerText.includes(kw) || lowerHref.includes(kw)) return 8;
210 }
211
212 return 5;
213 };
214
215 // Chercher les liens dans les footers et éléments prioritaires
216 document.querySelectorAll(footerSelectors.join(', ')).forEach(footer => {
217 const footerLinks = footer.querySelectorAll('a');
218 footerLinks.forEach(link => {
219 let href = link.getAttribute('href');
220 if (!href || href.startsWith('javascript:') || href.startsWith('#')) return;
221
222 // Convertir en URL absolue si nécessaire
223 if (href.startsWith('/')) {
224 const baseUrlObj = new URL(baseUrl);
225 href = `${baseUrlObj.origin}${href}`;
226 } else if (href.startsWith('mailto:')) {
227 return;
228 } else if (!href.startsWith('http')) {
229 return;
230 }
231
232 // Ignorer les liens externes
233 if (!href.includes(new URL(baseUrl).hostname)) return;
234
235 const linkText = link.textContent?.trim() || '';
236 const priority = getLinkPriority(linkText, href);
237
238 // N'ajouter que si le lien a une priorité significative
239 if (priority > 5) {
240 links.push({ url: href, priority });
241 }
242 });
243 });
244
245 // Limiter le nombre de liens retournés
246 return links.slice(0, 5);
247 }, baseUrl, PRIORITY_KEYWORDS);
248 } catch (error) {
249 console.error('Erreur lors de la recherche des liens de contact:', error);
250 return [];
251 }
252}
253
254/**
255 * Fonction avancée pour extraire les emails d'une page
256 * Version simplifiée et optimisée pour les performances
257 */
258async function extractEmailsAdvanced(page: any, log: Log): Promise<string[]> {
259 try {
260 // Extraction du contenu HTML (optimisé)
261 const html = await page.evaluate(() => document.documentElement.outerHTML);
262
263 // Recherche des emails avec une regex dans le HTML
264 const emailRegex = EMAIL_PATTERNS.standard;
265 const htmlEmails = html.match(emailRegex) || [];
266
267 // Recherche des liens mailto dans le HTML
268 const mailtoRegex = /mailto:([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
269 let match;
270 const mailtoEmails: string[] = [];
271
272 while ((match = mailtoRegex.exec(html)) !== null) {
273 if (match[1]) {
274 mailtoEmails.push(match[1]);
275 }
276 }
277
278 // Récupération des emails du DOM (version simplifiée)
279 const domEmails = await page.evaluate(() => {
280 const results: string[] = [];
281 const regex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
282
283 try {
284 // Recherche dans le texte des éléments clés
285 const contactSelectors = [
286 '.contact', '.contact-info', '.footer', 'footer', '.address', 'address',
287 '.email', '#email', '.legal', '.mentions', '#footer'
288 ];
289
290 document.querySelectorAll(contactSelectors.join(', ')).forEach(element => {
291 const text = element.textContent || '';
292 const matches = text.match(regex);
293 if (matches) {
294 results.push(...matches);
295 }
296 });
297 } catch (e) {
298 console.error('Erreur lors de l\'extraction des emails:', e);
299 }
300
301 return results;
302 });
303
304 // Combiner et dédupliquer les emails
305 const allEmails = [...new Set([...htmlEmails, ...mailtoEmails, ...domEmails])];
306
307 // Filtrer les emails invalides ou exemples courants
308 return allEmails.filter(email => {
309 const excludeDomains = ['example.com', 'domain.com', 'email.com', 'yoursite.com', 'yourdomain.com'];
310 const parts = email.split('@');
311
312 if (parts.length !== 2) return false;
313
314 const domain = parts[1];
315
316 if (excludeDomains.includes(domain)) {
317 return false;
318 }
319
320 return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email);
321 });
322 } catch (error) {
323 log.error(`Erreur lors de l'extraction des emails: ${error}`);
324 return [];
325 }
326}

.dockerignore

# configurations
.idea
.vscode
# crawlee and apify storage folders
apify_storage
crawlee_storage
storage
# installed files
node_modules
# git folder
.git

.editorconfig

root = true
[*]
indent_style = space
indent_size = 4
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf

.eslintrc

{
"root": true,
"env": {
"browser": true,
"es2020": true,
"node": true
},
"extends": [
"@apify/eslint-config-ts"
],
"parserOptions": {
"project": "./tsconfig.json",
"ecmaVersion": 2020
},
"ignorePatterns": [
"node_modules",
"dist",
"**/*.d.ts"
]
}

.gitignore

# This file tells Git which files shouldn't be added to source control
.DS_Store
.idea
.vscode
dist
node_modules
apify_storage
storage

package.json

{
"name": "crawlee-playwright-typescript",
"version": "0.0.1",
"type": "module",
"description": "This is an example of an Apify actor.",
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"apify": "^3.2.6",
"crawlee": "^3.11.5",
"playwright": "*"
},
"devDependencies": {
"@apify/eslint-config-ts": "^0.3.0",
"@apify/tsconfig": "^0.1.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.50.0",
"tsx": "^4.6.2",
"typescript": "^5.3.3"
},
"scripts": {
"start": "npm run start:dev",
"start:prod": "node dist/main.js",
"start:dev": "tsx src/main.ts",
"build": "tsc",
"lint": "eslint ./src --ext .ts",
"lint:fix": "eslint ./src --ext .ts --fix",
"test": "echo \"Error: oops, the actor has no tests yet, sad!\" && exit 1",
"postinstall": "npx crawlee install-playwright-browsers"
},
"author": "It's not you it's me",
"license": "ISC"
}

tsconfig.json

{
"extends": "@apify/tsconfig",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"outDir": "dist",
"noUnusedLocals": false,
"skipLibCheck": true,
"lib": ["DOM"]
},
"include": [
"./src/**/*"
]
}