1"""
2ACTOR DE APIFY - Extractor de contactos (version con paralelismo)
3"""
4
5import asyncio
6import re
7from urllib.parse import urljoin, urlparse
8from apify import Actor
9from playwright.async_api import async_playwright
10from email_validator import validate_email, EmailNotValidError
11
12_cache_dominios_validados = {}
13
14PREFIJOS_GENERICOS = [
15 "info", "contacto", "contact", "soporte", "support", "ventas", "sales",
16 "administracion", "admin", "hola", "hello", "hi", "office", "recepcion",
17 "noreply", "no-reply", "webmaster", "hr", "rrhh", "marketing"
18]
19
20PREFIJOS_DECISOR = [
21 "ceo", "cto", "cfo", "director", "gerente", "manager", "founder",
22 "fundador", "owner", "dueno"
23]
24
25LIMITE_SITIOS_EN_PARALELO = 5
26
27
28def clasificar_email(email):
29 prefijo = email.split("@")[0].lower()
30 if any(palabra in prefijo for palabra in PREFIJOS_DECISOR):
31 return "decisor"
32 if any(palabra in prefijo for palabra in PREFIJOS_GENERICOS):
33 return "generico"
34 return "persona_probable"
35
36
37def es_email_valido(email):
38 patron = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
39 if not re.match(patron, email):
40 return False
41 basura = ["ejemplo@", "example@", "test@", "tunombre@", "youremail@", "email@email"]
42 return not any(email.lower().startswith(b) for b in basura)
43
44
45def email_dominio_existe_de_verdad(email):
46 dominio = email.split("@")[-1].lower()
47 if dominio in _cache_dominios_validados:
48 return _cache_dominios_validados[dominio]
49 try:
50 validate_email(email, check_deliverability=True)
51 resultado = True
52 except EmailNotValidError:
53 resultado = False
54 _cache_dominios_validados[dominio] = resultado
55 return resultado
56
57
58def es_telefono_valido(numero):
59 solo_digitos = re.sub(r'\D', '', numero)
60 if not (7 <= len(solo_digitos) <= 13):
61 return False
62 if re.match(r'^\d{4}-\d{2}-\d{2}', numero):
63 return False
64 tiene_formato_telefono = bool(re.search(r'[\s\-\(\)]', numero)) or numero.startswith('+')
65 if not tiene_formato_telefono:
66 return False
67 return True
68
69
70REDES_SOCIALES = {
71 "linkedin": r'linkedin\.com/(?:company|in)/[a-zA-Z0-9\-_]+',
72 "instagram": r'instagram\.com/[a-zA-Z0-9\_\.]+',
73 "twitter_x": r'(?:twitter|x)\.com/[a-zA-Z0-9\_]+',
74 "facebook": r'facebook\.com/[a-zA-Z0-9\.\-]+',
75}
76
77SUBPAGINAS_COMUNES = [
78 "contacto", "contact", "contact-us", "acerca-de", "about",
79 "about-us", "aviso-legal", "legal", "nosotros", "quienes-somos"
80]
81
82
83def encontrar_subpaginas(html_base, url_base):
84 links_encontrados = re.findall(r'href=["\']([^"\']+)["\']', html_base)
85 subpaginas = set()
86 for link in links_encontrados:
87 link_lower = link.lower()
88 if any(palabra in link_lower for palabra in SUBPAGINAS_COMUNES):
89 url_completa = urljoin(url_base, link)
90 if urlparse(url_completa).netloc == urlparse(url_base).netloc:
91 subpaginas.add(url_completa)
92 return list(subpaginas)[:3]
93
94
95def extraer_de_texto(texto):
96 patron_email = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
97 candidatos = [e for e in re.findall(patron_email, texto) if es_email_valido(e)]
98 emails = [e for e in candidatos if email_dominio_existe_de_verdad(e)]
99
100 patron_telefono = r'(?:\+\d{1,3}[\s\-]?)?\(?\d{2,4}\)?[\s\-]\d{2,4}[\s\-]?\d{2,4}(?:[\s\-]?\d{2,4})?'
101 candidatos_tel = [t for t in re.findall(patron_telefono, texto) if es_telefono_valido(t)]
102 candidatos_tel = list(dict.fromkeys(candidatos_tel))
103 con_prefijo_pais = [t for t in candidatos_tel if t.strip().startswith('+')]
104 telefonos = con_prefijo_pais if con_prefijo_pais else candidatos_tel[:3]
105
106 redes = {}
107 for nombre_red, patron in REDES_SOCIALES.items():
108 encontrados = re.findall(patron, texto)
109 if encontrados:
110 redes[nombre_red] = list(set(encontrados))[0]
111
112 return emails, telefonos, redes
113
114
115async def obtener_html_con_navegador(pagina, url):
116 response = await pagina.goto(url, timeout=45000)
117 Actor.log.info(f"{url} -> status: {response.status if response else 'sin respuesta'}")
118 await pagina.wait_for_load_state("load", timeout=15000)
119 await pagina.wait_for_timeout(3000)
120 try:
121 return await pagina.content()
122 except Exception:
123 await pagina.wait_for_timeout(3000)
124 return await pagina.content()
125
126
127async def procesar_un_sitio(navegador, url, semaforo_sitios):
128 async with semaforo_sitios:
129 contexto = await navegador.new_context()
130 pagina = await contexto.new_page()
131
132 todos_emails, todos_telefonos, todas_redes = set(), set(), {}
133
134 try:
135 Actor.log.info(f"Procesando: {url}")
136 html_home = await obtener_html_con_navegador(pagina, url)
137 except Exception as e:
138 Actor.log.warning(f"Fallo al cargar {url}: {type(e).__name__}: {e}")
139 await contexto.close()
140 return [{"sitio": url, "email": None, "clasificacion_email": None,
141 "telefonos": "", "linkedin": "", "instagram": "",
142 "twitter_x": "", "facebook": "", "error": str(e)}]
143
144 emails, telefonos, redes = extraer_de_texto(html_home)
145 Actor.log.info(f"{url} -> emails: {emails}, telefonos: {telefonos}")
146 todos_emails.update(emails)
147 todos_telefonos.update(telefonos)
148 todas_redes.update(redes)
149
150 subpaginas = encontrar_subpaginas(html_home, url)
151 for sub_url in subpaginas:
152 await asyncio.sleep(1.5)
153 try:
154 html_sub = await obtener_html_con_navegador(pagina, sub_url)
155 e2, t2, r2 = extraer_de_texto(html_sub)
156 Actor.log.info(f"{sub_url} -> emails: {e2}, telefonos: {t2}")
157 todos_emails.update(e2)
158 todos_telefonos.update(t2)
159 todas_redes.update(r2)
160 except Exception as e:
161 Actor.log.warning(f"Fallo subpagina {sub_url}: {e}")
162 continue
163
164 await contexto.close()
165
166 if not todos_emails:
167 return [{
168 "sitio": url, "email": None, "clasificacion_email": None,
169 "telefonos": ", ".join(sorted(todos_telefonos)),
170 "linkedin": todas_redes.get("linkedin", ""),
171 "instagram": todas_redes.get("instagram", ""),
172 "twitter_x": todas_redes.get("twitter_x", ""),
173 "facebook": todas_redes.get("facebook", ""),
174 }]
175
176 filas = []
177 for email in sorted(todos_emails):
178 filas.append({
179 "sitio": url, "email": email,
180 "clasificacion_email": clasificar_email(email),
181 "telefonos": ", ".join(sorted(todos_telefonos)),
182 "linkedin": todas_redes.get("linkedin", ""),
183 "instagram": todas_redes.get("instagram", ""),
184 "twitter_x": todas_redes.get("twitter_x", ""),
185 "facebook": todas_redes.get("facebook", ""),
186 })
187 return filas
188
189
190async def main():
191 async with Actor:
192 input_data = await Actor.get_input() or {}
193 lista_urls = input_data.get("urls", [])
194
195 if not lista_urls:
196 Actor.log.warning("No se recibieron URLs en el input.")
197 return
198
199 semaforo_sitios = asyncio.Semaphore(LIMITE_SITIOS_EN_PARALELO)
200
201 async with async_playwright() as p:
202 navegador = await p.chromium.launch(headless=True)
203 Actor.log.info("Navegador lanzado correctamente")
204
205 tareas = [procesar_un_sitio(navegador, url, semaforo_sitios) for url in lista_urls]
206 resultados = await asyncio.gather(*tareas)
207
208 await navegador.close()
209
210 filas_finales = []
211 for resultado in resultados:
212 filas_finales.extend(resultado)
213
214 await Actor.push_data(filas_finales)
215
216
217if __name__ == "__main__":
218 asyncio.run(main())