Under maintenance
Pricing
Pay per usage
Go to Store
Email
Under maintenance
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 packagesRUN npm ls crawlee apify puppeteer playwright
# Copy just package.json and package-lock.json# to speed up the build using Docker layer cache.COPY 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 . ./
# Install all dependencies and build the project.# Don't audit to speed up the installation.RUN npm run build
# Create final imageFROM apify/actor-node-playwright-chrome:20
# Check preinstalled packagesRUN npm ls crawlee apify puppeteer playwright
# Copy just package.json and package-lock.json# to speed up the build using Docker layer cache.COPY 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 debuggingRUN 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 imageCOPY /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 . ./
# 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 URL3 * Utilise Crawlee, Playwright et Chrome headless pour une extraction avancée.4 */5
6// Pour plus d'information, voir https://docs.apify.com/sdk/js7import { Actor } from 'apify';8// Pour plus d'information, voir https://crawlee.dev9import { PlaywrightCrawler } from 'crawlee';10// this is ESM project, and as such, it requires you to specify extensions11// read more about this here: https://nodejs.org/docs/latest-v18.x/api/esm12// note that we need to use `.js` even when inside TS files13import { router } from './routes.js';14
15// Définition des types d'entrée16interface CrawlerInput {17 startUrls: any[]; // Accepter différents formats d'URL18 maxRequestsPerCrawl: number;19 maxDepth?: number;20 maxPagesPerDomain?: number;21}22
23// Fonction principale d'exécution24await Actor.main(async () => {25 // Récupération et validation des inputs26 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és33 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éfaut40 const maxDepth = input.maxDepth || 1; // Réduire la profondeur par défaut41 const maxPagesPerDomain = input.maxPagesPerDomain || 20; // Réduire le nombre de pages par domaine42 43 // Création du crawler Playwright44 const crawler = new PlaywrightCrawler({45 // Utiliser le router importé pour la gestion des requêtes46 requestHandler: router,47 48 // Utiliser un navigateur Chrome headless49 launchContext: {50 launchOptions: {51 headless: true,52 },53 },54 55 // Limiter le nombre de requêtes56 maxRequestsPerCrawl: input.maxRequestsPerCrawl || maxPagesPerDomain,57 58 // Définir un timeout plus court pour éviter les blocages59 navigationTimeoutSecs: 15,60 61 // Limiter les requêtes simultanées pour éviter la surcharge62 maxConcurrency: 2,63 });64 65 // Préparation des requêtes initiales avec les bons paramètres66 const initialRequests = input.startUrls.map((urlData) => {67 // Si c'est déjà un objet avec une propriété url, on l'utilise tel quel68 // mais on ajoute les paramètres userData69 if (typeof urlData === 'object' && urlData.url) {70 return {71 ...urlData,72 userData: { 73 depth: 0,74 maxDepth75 }76 };77 }78 79 // Si c'est juste une chaîne, on la convertit en objet80 if (typeof urlData === 'string') {81 return {82 url: urlData,83 userData: { 84 depth: 0,85 maxDepth86 }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ées96 await crawler.run(initialRequests);97 98 // Récupération des données finales99 const foundEmails = await Actor.getValue('foundEmails') as string[] || [];100 const visitedUrls = await Actor.getValue('visitedUrls') as string[] || [];101 102 // Résumé final103 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 final106 const finalResult = {107 totalEmailsFound: foundEmails.length,108 emails: foundEmails,109 urlsVisited: visitedUrls.length110 };111 112 // Enregistrer le résultat final113 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'erreur119 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ées5interface DomainEmailsMap {6 [domain: string]: string[];7}8
9// Configuration globale pour la recherche d'emails10const EMAIL_PATTERNS = {11 // Expressions régulières pour les emails standards12 standard: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,13 14 // Expressions régulières pour les emails obfusqués15 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 importantes30const 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 Playwright37export const router = createPlaywrightRouter();38
39// Route par défaut pour gérer toutes les pages40router.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éduite45 46 log.info(`Traitement de ${url} (profondeur: ${currentDepth}/${maxDepth})`);47 48 // Récupération des ensembles pour stocker les résultats49 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 actuel54 const currentDomain = new URL(url).hostname;55 56 // Marquer l'URL comme visitée57 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 court64 await page.waitForLoadState('domcontentloaded', { timeout: 10000 });65 66 // Extraction des emails trouvés dans la page avec des méthodes avancées67 const emails = await extractEmailsAdvanced(page, log);68 69 // Si des emails sont trouvés, les traiter70 if (emails.length > 0) {71 // Filtrer les doublons globaux72 const newEmails = emails.filter(email => !foundEmails.includes(email));73
74 // Ajouter les nouveaux emails à l'ensemble75 if (newEmails.length > 0) {76 foundEmails.push(...newEmails);77 await Actor.setValue('foundEmails', foundEmails);78 79 // Mettre à jour le compteur d'emails par domaine80 if (!domainEmails[currentDomain]) {81 domainEmails[currentDomain] = [];82 }83 84 // Ajouter uniquement les nouveaux emails pour ce domaine85 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ésultats94 await Dataset.pushData({95 url: url,96 domain: currentDomain,97 emails: newEmails98 });99 100 log.info(`Trouvé ${newEmails.length} nouveaux emails sur ${url}`);101 }102 }103 104 // Vérifier si nous devons continuer à crawler plus profondément105 if (currentDepth < maxDepth) {106 // 1. Rechercher d'abord les liens de pied de page et de contact107 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és113 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 temps117 const priorityUrls = notVisitedLinks118 .sort((a, b) => b.priority - a.priority)119 .slice(0, 3) // Limiter à 3 liens prioritaires maximum120 .map(link => link.url);121 122 // Enqueue avec priorité maximale123 await enqueueLinks({124 urls: priorityUrls,125 transformRequestFunction: (req) => {126 req.userData = {127 ...userData,128 depth: currentDepth + 1,129 maxDepth,130 isPriorityLink: true131 };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és140 if (visitedUrls.length < 20) {141 await enqueueLinks({142 globs: [`${new URL(url).origin}/**`], // Rester sur le même domaine143 transformRequestFunction: (req) => {144 // Ne pas revisiter les URLs déjà traitées145 if (visitedUrls.includes(req.url)) {146 return false;147 }148 149 // Ignorer certains types de fichiers et URLs150 const parsedUrl = new URL(req.url);151 const path = parsedUrl.pathname.toLowerCase();152 153 // Ignorer les fichiers non HTML et les routes non pertinentes154 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êtes165 req.userData = {166 ...userData,167 depth: currentDepth + 1,168 maxDepth169 };170 171 return req;172 },173 limit: 5, // Limiter à 5 liens par page pour ne pas déborder174 });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'erreur180 }181});182
183/**184 * Fonction pour identifier les liens dans le footer et pages de contact185 * Version simplifiée pour améliorer les performances186 */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 page193 const footerSelectors = [194 'footer', '.footer', '#footer', 195 '.copyright', '.legals'196 ];197 198 // Fonction pour déterminer la priorité d'un lien199 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 prioritaires216 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écessaire223 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 externes233 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é significative239 if (priority > 5) {240 links.push({ url: href, priority });241 }242 });243 });244 245 // Limiter le nombre de liens retournés246 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 page256 * Version simplifiée et optimisée pour les performances257 */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 HTML264 const emailRegex = EMAIL_PATTERNS.standard;265 const htmlEmails = html.match(emailRegex) || [];266 267 // Recherche des liens mailto dans le HTML268 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és285 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 emails305 const allEmails = [...new Set([...htmlEmails, ...mailtoEmails, ...domEmails])];306 307 // Filtrer les emails invalides ou exemples courants308 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 foldersapify_storagecrawlee_storagestorage
# installed filesnode_modules
# git folder.git
.editorconfig
root = true
[*]indent_style = spaceindent_size = 4charset = utf-8trim_trailing_whitespace = trueinsert_final_newline = trueend_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.vscodedistnode_modulesapify_storagestorage
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/**/*" ]}