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
70def formatear_telefonos_para_sheets(lista_telefonos):
71 """
72 Junta los telefonos en un solo texto. Si alguno empieza con '+',
73 le agrega un apostrofe adelante para que Google Sheets no lo
74 interprete como una formula matematica (evita el #ERROR!).
75 """
76 texto = ", ".join(sorted(lista_telefonos))
77 if texto.startswith('+'):
78 return "'" + texto
79 return texto
80
81
82RUTAS_FACEBOOK_NO_VALIDAS = [
83 "tr", "sharer", "plugins", "dialog", "l.php", "share", "login",
84 "help", "policies", "ads", "business"
85]
86
87REDES_SOCIALES = {
88 "linkedin": r'linkedin\.com/(?:company|in)/[a-zA-Z0-9\-_]+',
89 "instagram": r'instagram\.com/[a-zA-Z0-9\_\.]+',
90 "twitter_x": r'(?:twitter|x)\.com/[a-zA-Z0-9\_]+',
91 "facebook": r'facebook\.com/[a-zA-Z0-9\.\-]+',
92}
93
94
95def es_facebook_valido(url_facebook):
96 ruta = url_facebook.split("facebook.com/")[-1].split("/")[0].lower()
97 return ruta not in RUTAS_FACEBOOK_NO_VALIDAS
98
99FIRMAS_TECNOLOGIA = {
100 "WordPress": r'wp-content|wp-includes',
101 "Shopify": r'cdn\.shopify\.com|shopify\.com',
102 "Wix": r'wix\.com|wixstatic',
103 "Squarespace": r'squarespace\.com',
104 "Webflow": r'webflow\.com|webflow\.io',
105 "HubSpot": r'hubspot\.com|hs-scripts',
106 "Google Analytics": r'google-analytics\.com|gtag\(',
107 "Meta Pixel": r'connect\.facebook\.net.*fbevents',
108}
109
110
111def detectar_tecnologia(html):
112 encontradas = []
113 for nombre, patron in FIRMAS_TECNOLOGIA.items():
114 if re.search(patron, html, re.IGNORECASE):
115 encontradas.append(nombre)
116 return ", ".join(encontradas) if encontradas else "No detectada"
117
118
119def estimar_tamano_negocio(html):
120 """
121 Heuristica simple: cuenta senales de equipo/oficinas multiples
122 para estimar si es un negocio pequeno o mediano/grande.
123 """
124 texto = html.lower()
125 senales_grande = ["nuestras sucursales", "our offices", "careers", "trabaja con nosotros",
126 "nuestro equipo", "our team", "sedes", "branches"]
127 conteo = sum(1 for s in senales_grande if s in texto)
128 if conteo >= 3:
129 return "mediano-grande"
130 elif conteo >= 1:
131 return "pequeno-mediano"
132 return "pequeno"
133
134
135def detectar_senales_de_oportunidad(html):
136 """
137 Detecta senales que indican que un negocio podria necesitar
138 servicios digitales (util para agencias que venden webs/marketing).
139 """
140 texto = html.lower()
141
142 patrones_reserva = ["book now", "reservar cita", "agendar cita", "booking.com",
143 "calendly", "acuity", "reserva online", "book an appointment"]
144 tiene_reservas = any(p in texto for p in patrones_reserva)
145
146 patrones_ads = ["gtag(", "google-analytics", "fbevents", "facebook pixel",
147 "googletagmanager", "hotjar", "google ads"]
148 tiene_publicidad_digital = any(p in texto for p in patrones_ads)
149
150 return {
151 "tiene_sistema_de_reservas": tiene_reservas,
152 "tiene_publicidad_digital": tiene_publicidad_digital,
153 }
154
155
156def es_mobile_friendly(html):
157 return bool(re.search(r'<meta[^>]+name=["\']viewport["\']', html, re.IGNORECASE))
158
159
160def estimar_antiguedad_web(html):
161 anos_encontrados = re.findall(r'(?:©|copyright)[^\d]{0,10}(\d{4})', html, re.IGNORECASE)
162 if not anos_encontrados:
163 return None
164 return min(int(a) for a in anos_encontrados if 2000 <= int(a) <= 2026)
165
166
167def calcular_puntaje_oportunidad(senales, mobile_friendly, ano_copyright):
168 puntaje = 0
169 if not senales["tiene_sistema_de_reservas"]:
170 puntaje += 25
171 if not senales["tiene_publicidad_digital"]:
172 puntaje += 20
173 if not mobile_friendly:
174 puntaje += 30
175 if ano_copyright is not None and ano_copyright <= 2021:
176 puntaje += 25
177 return min(puntaje, 100)
178
179
180def generar_razon_de_contacto(senales, mobile_friendly, ano_copyright, puntaje):
181 """
182 Convierte las senales sueltas en un argumento de venta concreto,
183 listo para usar en un mensaje de contacto real.
184 """
185 problemas = []
186 if not senales["tiene_sistema_de_reservas"]:
187 problemas.append("no tiene sistema de reservas online")
188 if not mobile_friendly:
189 problemas.append("su web no se ve bien en celular")
190 if ano_copyright is not None and ano_copyright <= 2021:
191 problemas.append(f"su web parece no actualizarse desde {ano_copyright}")
192 if senales["tiene_publicidad_digital"] and not senales["tiene_sistema_de_reservas"]:
193 problemas.append("invierte en publicidad digital pero no puede convertir esas visitas en citas/reservas")
194
195 if not problemas:
196 return "Sin oportunidad clara detectada"
197
198 if puntaje >= 70:
199 prioridad = "Alta prioridad"
200 elif puntaje >= 40:
201 prioridad = "Prioridad media"
202 else:
203 prioridad = "Prioridad baja"
204
205 razon = f"{prioridad}: " + "; ".join(problemas) + "."
206 return razon
207
208
209SUBPAGINAS_COMUNES = [
210 "contacto", "contact", "contact-us", "acerca-de", "about",
211 "about-us", "aviso-legal", "legal", "nosotros", "quienes-somos"
212]
213
214DOMINIOS_A_IGNORAR_EN_BUSQUEDA = [
215 "facebook.com", "instagram.com", "linkedin.com", "twitter.com", "x.com",
216 "yelp.com", "tripadvisor.com", "wikipedia.org", "youtube.com",
217 "paginasamarillas", "guiatelefonica", "maps.google", "google.com"
218]
219
220
221async def buscar_webs_por_nicho_y_ciudad(pagina, nicho, ciudad, max_resultados=20):
222 """
223 Busca sitios web de negocios de un nicho en una ciudad usando DuckDuckGo
224 (no requiere API key). Devuelve una lista de URLs candidatas, filtrando
225 redes sociales y directorios que no son webs propias del negocio.
226 """
227 from urllib.parse import unquote, parse_qs
228
229 consulta = f"{nicho} {ciudad}"
230 url_busqueda = f"https://duckduckgo.com/html/?q={consulta.replace(' ', '+')}"
231
232 Actor.log.info(f"Buscando webs para: '{consulta}'")
233 await pagina.goto(url_busqueda, timeout=30000)
234 await pagina.wait_for_timeout(2000)
235 html = await pagina.content()
236
237 enlaces_encontrados = re.findall(r'href="([^"]+)"', html)
238
239 urls_candidatas = []
240 vistos = set()
241 for enlace in enlaces_encontrados:
242 url_real = enlace
243
244 if "duckduckgo.com/l/" in enlace or enlace.startswith("/l/"):
245 parametros = parse_qs(enlace.split("?", 1)[-1]) if "?" in enlace else {}
246 if "uddg" in parametros:
247 url_real = unquote(parametros["uddg"][0])
248 else:
249 continue
250 elif not enlace.startswith("http"):
251 continue
252
253 dominio = urlparse(url_real).netloc.lower()
254 if not dominio or dominio in vistos:
255 continue
256 if any(ignorado in dominio for ignorado in DOMINIOS_A_IGNORAR_EN_BUSQUEDA):
257 continue
258 if "duckduckgo.com" in dominio:
259 continue
260
261 vistos.add(dominio)
262 urls_candidatas.append(url_real)
263 if len(urls_candidatas) >= max_resultados:
264 break
265
266 Actor.log.info(f"Encontradas {len(urls_candidatas)} webs candidatas para '{consulta}'")
267 return urls_candidatas
268
269
270def encontrar_subpaginas(html_base, url_base):
271 links_encontrados = re.findall(r'href=["\']([^"\']+)["\']', html_base)
272 subpaginas = set()
273 for link in links_encontrados:
274 link_lower = link.lower()
275 if any(palabra in link_lower for palabra in SUBPAGINAS_COMUNES):
276 url_completa = urljoin(url_base, link)
277 if urlparse(url_completa).netloc == urlparse(url_base).netloc:
278 subpaginas.add(url_completa)
279 return list(subpaginas)[:3]
280
281
282def extraer_de_texto(texto):
283 patron_email = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
284 candidatos = [e for e in re.findall(patron_email, texto) if es_email_valido(e)]
285 emails = [e for e in candidatos if email_dominio_existe_de_verdad(e)]
286
287 patron_telefono = r'(?:\+\d{1,3}[\s\-]?)?\(?\d{2,4}\)?[\s\-]\d{2,4}[\s\-]?\d{2,4}(?:[\s\-]?\d{2,4})?'
288 candidatos_tel = [t for t in re.findall(patron_telefono, texto) if es_telefono_valido(t)]
289 candidatos_tel = list(dict.fromkeys(candidatos_tel))
290 con_prefijo_pais = [t for t in candidatos_tel if t.strip().startswith('+')]
291 telefonos = con_prefijo_pais if con_prefijo_pais else candidatos_tel[:3]
292
293 redes = {}
294 for nombre_red, patron in REDES_SOCIALES.items():
295 encontrados = re.findall(patron, texto)
296 if nombre_red == "facebook":
297 encontrados = [e for e in encontrados if es_facebook_valido(e)]
298 if encontrados:
299 redes[nombre_red] = list(set(encontrados))[0]
300
301 return emails, telefonos, redes
302
303
304async def obtener_html_con_navegador(pagina, url):
305 response = await pagina.goto(url, timeout=45000)
306 Actor.log.info(f"{url} -> status: {response.status if response else 'sin respuesta'}")
307 await pagina.wait_for_load_state("load", timeout=15000)
308 await pagina.wait_for_timeout(5000)
309 try:
310 return await pagina.content()
311 except Exception:
312 await pagina.wait_for_timeout(3000)
313 return await pagina.content()
314
315
316async def procesar_un_sitio(navegador, url, semaforo_sitios):
317 async with semaforo_sitios:
318 contexto = await navegador.new_context()
319 pagina = await contexto.new_page()
320
321 todos_emails, todos_telefonos, todas_redes = set(), set(), {}
322
323 try:
324 Actor.log.info(f"Procesando: {url}")
325 html_home = await obtener_html_con_navegador(pagina, url)
326 except Exception as e:
327 Actor.log.warning(f"Fallo al cargar {url}: {type(e).__name__}: {e}")
328 await contexto.close()
329 return [{"sitio": url, "email": None, "clasificacion_email": None,
330 "telefonos": "", "linkedin": "", "instagram": "",
331 "twitter_x": "", "facebook": "", "error": str(e)}]
332
333 emails, telefonos, redes = extraer_de_texto(html_home)
334 Actor.log.info(f"{url} -> emails: {emails}, telefonos: {telefonos}")
335 todos_emails.update(emails)
336 todos_telefonos.update(telefonos)
337 todas_redes.update(redes)
338 tecnologia_detectada = detectar_tecnologia(html_home)
339 tamano_estimado = estimar_tamano_negocio(html_home)
340 senales = detectar_senales_de_oportunidad(html_home)
341 mobile_friendly = es_mobile_friendly(html_home)
342 ano_copyright = estimar_antiguedad_web(html_home)
343 puntaje_oportunidad = calcular_puntaje_oportunidad(senales, mobile_friendly, ano_copyright)
344 razon_de_contacto = generar_razon_de_contacto(senales, mobile_friendly, ano_copyright, puntaje_oportunidad)
345
346 subpaginas = encontrar_subpaginas(html_home, url)
347 for sub_url in subpaginas:
348 await asyncio.sleep(1.5)
349 try:
350 html_sub = await obtener_html_con_navegador(pagina, sub_url)
351 e2, t2, r2 = extraer_de_texto(html_sub)
352 Actor.log.info(f"{sub_url} -> emails: {e2}, telefonos: {t2}")
353 todos_emails.update(e2)
354 todos_telefonos.update(t2)
355 todas_redes.update(r2)
356 except Exception as e:
357 Actor.log.warning(f"Fallo subpagina {sub_url}: {e}")
358 continue
359
360 await contexto.close()
361
362 if not todos_emails:
363 return [{
364 "sitio": url, "email": None, "clasificacion_email": None,
365 "telefonos": formatear_telefonos_para_sheets(todos_telefonos),
366 "linkedin": todas_redes.get("linkedin", ""),
367 "instagram": todas_redes.get("instagram", ""),
368 "twitter_x": todas_redes.get("twitter_x", ""),
369 "facebook": todas_redes.get("facebook", ""),
370 "tecnologia": tecnologia_detectada,
371 "tamano_estimado": tamano_estimado,
372 "tiene_reservas_online": senales["tiene_sistema_de_reservas"],
373 "tiene_publicidad_digital": senales["tiene_publicidad_digital"],
374 "mobile_friendly": mobile_friendly,
375 "ano_copyright_detectado": ano_copyright,
376 "puntaje_oportunidad": puntaje_oportunidad,
377 "razon_de_contacto": razon_de_contacto,
378 }]
379
380 filas = []
381 for email in sorted(todos_emails):
382 filas.append({
383 "sitio": url, "email": email,
384 "clasificacion_email": clasificar_email(email),
385 "telefonos": formatear_telefonos_para_sheets(todos_telefonos),
386 "linkedin": todas_redes.get("linkedin", ""),
387 "instagram": todas_redes.get("instagram", ""),
388 "twitter_x": todas_redes.get("twitter_x", ""),
389 "facebook": todas_redes.get("facebook", ""),
390 "tecnologia": tecnologia_detectada,
391 "tamano_estimado": tamano_estimado,
392 "tiene_reservas_online": senales["tiene_sistema_de_reservas"],
393 "tiene_publicidad_digital": senales["tiene_publicidad_digital"],
394 "mobile_friendly": mobile_friendly,
395 "ano_copyright_detectado": ano_copyright,
396 "puntaje_oportunidad": puntaje_oportunidad,
397 "razon_de_contacto": razon_de_contacto,
398 })
399 return filas
400
401
402async def main():
403 async with Actor:
404 input_data = await Actor.get_input() or {}
405 lista_urls = input_data.get("urls", [])
406 nicho = input_data.get("nicho", "").strip()
407 ciudad = input_data.get("ciudad", "").strip()
408
409 semaforo_sitios = asyncio.Semaphore(LIMITE_SITIOS_EN_PARALELO)
410
411 async with async_playwright() as p:
412 navegador = await p.chromium.launch(headless=True)
413 Actor.log.info("Navegador lanzado correctamente")
414
415 if not lista_urls and nicho and ciudad:
416 contexto_busqueda = await navegador.new_context()
417 pagina_busqueda = await contexto_busqueda.new_page()
418 lista_urls = await buscar_webs_por_nicho_y_ciudad(pagina_busqueda, nicho, ciudad)
419 await contexto_busqueda.close()
420
421 if not lista_urls:
422 Actor.log.warning("No se recibieron URLs, ni nicho+ciudad validos para buscar.")
423 await navegador.close()
424 return
425
426 tareas = [procesar_un_sitio(navegador, url, semaforo_sitios) for url in lista_urls]
427 resultados = await asyncio.gather(*tareas)
428
429 await navegador.close()
430
431 filas_finales = []
432 for resultado in resultados:
433 filas_finales.extend(resultado)
434
435 await Actor.push_data(filas_finales)
436
437
438if __name__ == "__main__":
439 asyncio.run(main())