feat: add whatsapp_modular_v3_6 - envio automatizado WhatsApp Web con busqueda por nombre, deteccion de ticks via msg-meta/SVG title, y validacion de selectores
This commit is contained in:
434
Python/whatsapp_modular_v3_6/whatsapp_sender/whatsapp.py
Normal file
434
Python/whatsapp_modular_v3_6/whatsapp_sender/whatsapp.py
Normal file
@@ -0,0 +1,434 @@
|
||||
import re
|
||||
import time
|
||||
|
||||
import pyperclip
|
||||
from selenium.common.exceptions import StaleElementReferenceException, TimeoutException, WebDriverException
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
|
||||
def es_numero_telefono(destino):
|
||||
return bool(re.fullmatch(r"\+?\d{7,15}", destino.strip()))
|
||||
|
||||
|
||||
def elementos_visibles(driver, by, selector):
|
||||
try:
|
||||
elementos = driver.find_elements(by, selector)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
visibles = []
|
||||
for elemento in elementos:
|
||||
try:
|
||||
if elemento.is_displayed():
|
||||
visibles.append(elemento)
|
||||
except (StaleElementReferenceException, WebDriverException):
|
||||
continue
|
||||
return visibles
|
||||
|
||||
|
||||
def primer_visible(driver, by, selector):
|
||||
elementos = elementos_visibles(driver, by, selector)
|
||||
return elementos[0] if elementos else None
|
||||
|
||||
|
||||
def esperar_primer_visible(driver, candidatos, timeout=15):
|
||||
fin = time.time() + timeout
|
||||
while time.time() < fin:
|
||||
for by, selector in candidatos:
|
||||
elemento = primer_visible(driver, by, selector)
|
||||
if elemento:
|
||||
return elemento
|
||||
time.sleep(0.3)
|
||||
return None
|
||||
|
||||
|
||||
def click_seguro(driver, elemento):
|
||||
try:
|
||||
driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", elemento)
|
||||
time.sleep(0.1)
|
||||
elemento.click()
|
||||
except Exception:
|
||||
try:
|
||||
driver.execute_script("arguments[0].click();", elemento)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def limpiar_campo(driver, elemento):
|
||||
click_seguro(driver, elemento)
|
||||
ActionChains(driver).key_down(Keys.CONTROL).send_keys("a").key_up(Keys.CONTROL).send_keys(Keys.DELETE).perform()
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
def buscar_caja_mensaje(driver, timeout=20):
|
||||
candidatos = [
|
||||
(By.CSS_SELECTOR, "[data-testid='conversation-compose-box-input']"),
|
||||
(By.CSS_SELECTOR, "footer div[contenteditable='true'][role='textbox']"),
|
||||
(By.CSS_SELECTOR, "footer div[contenteditable='true'][data-lexical-editor='true']"),
|
||||
(By.CSS_SELECTOR, "footer div[contenteditable='true']"),
|
||||
(By.CSS_SELECTOR, "div[contenteditable='true'][data-tab='10']"),
|
||||
(By.CSS_SELECTOR, "div[contenteditable='true'][data-tab='9']"),
|
||||
(By.CSS_SELECTOR, "footer div[aria-placeholder*='mensaje' i]"),
|
||||
(By.CSS_SELECTOR, "footer div[aria-placeholder*='message' i]"),
|
||||
(By.CSS_SELECTOR, "footer div[aria-label*='mensaje' i]"),
|
||||
(By.CSS_SELECTOR, "footer div[aria-label*='message' i]"),
|
||||
(By.XPATH, "//footer//*[@contenteditable='true']"),
|
||||
]
|
||||
return esperar_primer_visible(driver, candidatos, timeout=timeout)
|
||||
|
||||
|
||||
def abrir_buscador(driver, timeout=15):
|
||||
try:
|
||||
WebDriverWait(driver, 15).until(
|
||||
EC.presence_of_element_located(
|
||||
(
|
||||
By.CSS_SELECTOR,
|
||||
"#side, #pane-side, [data-testid='chat-list'], div[role='grid']",
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
candidatos = [
|
||||
(By.CSS_SELECTOR, "input[role='textbox'][data-tab='3']"),
|
||||
(By.CSS_SELECTOR, "input[aria-label*='Buscar' i]"),
|
||||
(By.CSS_SELECTOR, "input[placeholder*='Buscar' i]"),
|
||||
(By.CSS_SELECTOR, "input[aria-label*='Search' i]"),
|
||||
(By.CSS_SELECTOR, "input[placeholder*='Search' i]"),
|
||||
(By.CSS_SELECTOR, "[data-testid='chat-list-search']"),
|
||||
(By.CSS_SELECTOR, "div[contenteditable='true'][data-tab='3']"),
|
||||
(By.CSS_SELECTOR, "div[contenteditable='true'][role='textbox'][aria-label*='buscar' i]"),
|
||||
(By.CSS_SELECTOR, "div[contenteditable='true'][role='textbox'][aria-label*='search' i]"),
|
||||
(By.CSS_SELECTOR, "div[contenteditable='true'][role='textbox'][aria-placeholder*='buscar' i]"),
|
||||
(By.CSS_SELECTOR, "div[contenteditable='true'][role='textbox'][aria-placeholder*='search' i]"),
|
||||
(
|
||||
By.XPATH,
|
||||
"//*[@contenteditable='true' and ancestor::*[@id='side' or @id='pane-side']]",
|
||||
),
|
||||
(
|
||||
By.XPATH,
|
||||
"//*[@contenteditable='true' and contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'buscar')]",
|
||||
),
|
||||
(
|
||||
By.XPATH,
|
||||
"//*[@contenteditable='true' and contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'search')]",
|
||||
),
|
||||
]
|
||||
|
||||
fin = time.time() + timeout
|
||||
while time.time() < fin:
|
||||
for by, selector in candidatos:
|
||||
for elemento in elementos_visibles(driver, by, selector):
|
||||
try:
|
||||
rect = elemento.rect
|
||||
if rect and rect.get("y", 9999) > 350:
|
||||
continue
|
||||
click_seguro(driver, elemento)
|
||||
if (elemento.tag_name or "").lower() == "input":
|
||||
return elemento
|
||||
return elemento
|
||||
except (StaleElementReferenceException, WebDriverException):
|
||||
continue
|
||||
time.sleep(0.5)
|
||||
|
||||
botones_busqueda = [
|
||||
(By.CSS_SELECTOR, "button[aria-label*='buscar' i]"),
|
||||
(By.CSS_SELECTOR, "button[aria-label*='search' i]"),
|
||||
(By.CSS_SELECTOR, "div[role='button'][aria-label*='buscar' i]"),
|
||||
(By.CSS_SELECTOR, "div[role='button'][aria-label*='search' i]"),
|
||||
(By.CSS_SELECTOR, "span[data-icon='search']"),
|
||||
]
|
||||
boton = esperar_primer_visible(driver, botones_busqueda, timeout=3)
|
||||
if boton:
|
||||
click_seguro(driver, boton)
|
||||
return esperar_primer_visible(driver, candidatos, timeout=5)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def obtener_nombre_resultado(chat):
|
||||
selectores_nombre = [
|
||||
"span[title]",
|
||||
"span[dir='auto']",
|
||||
"div[role='gridcell'] span[dir='auto']",
|
||||
]
|
||||
for selector in selectores_nombre:
|
||||
try:
|
||||
for span in chat.find_elements(By.CSS_SELECTOR, selector):
|
||||
texto = (span.get_attribute("title") or span.text or "").strip()
|
||||
if texto:
|
||||
return texto
|
||||
except (StaleElementReferenceException, WebDriverException):
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def buscar_resultados_chat(driver):
|
||||
candidatos = [
|
||||
"[data-testid='cell-frame-container']",
|
||||
"#pane-side div[tabindex='-1']",
|
||||
"#side div[tabindex='-1']",
|
||||
"div[role='grid'] div[role='row']",
|
||||
"div[aria-label][role='grid'] div[role='row']",
|
||||
]
|
||||
|
||||
resultados = []
|
||||
vistos = set()
|
||||
for selector in candidatos:
|
||||
for elemento in elementos_visibles(driver, By.CSS_SELECTOR, selector):
|
||||
try:
|
||||
key = elemento.id
|
||||
except Exception:
|
||||
key = id(elemento)
|
||||
if key not in vistos:
|
||||
resultados.append(elemento)
|
||||
vistos.add(key)
|
||||
return resultados
|
||||
|
||||
|
||||
def abrir_chat(driver, tipo, destino):
|
||||
try:
|
||||
if es_numero_telefono(destino):
|
||||
numero = destino.replace("+", "").strip()
|
||||
print(f"Abriendo chat directo con numero {numero}...")
|
||||
driver.get(f"https://web.whatsapp.com/send?phone={numero}")
|
||||
|
||||
if buscar_caja_mensaje(driver, timeout=35):
|
||||
print("Chat directo abierto.")
|
||||
return True
|
||||
|
||||
print("No se pudo confirmar la apertura del chat directo.")
|
||||
return False
|
||||
|
||||
print(f"Buscando {tipo} '{destino}'...")
|
||||
buscador = abrir_buscador(driver)
|
||||
if not buscador:
|
||||
print("No se encontro buscador.")
|
||||
return False
|
||||
|
||||
limpiar_campo(driver, buscador)
|
||||
time.sleep(0.5)
|
||||
for caracter in destino:
|
||||
buscador.send_keys(caracter)
|
||||
time.sleep(0.05)
|
||||
|
||||
try:
|
||||
WebDriverWait(driver, 15).until(lambda d: len(buscar_resultados_chat(d)) > 0)
|
||||
except TimeoutException:
|
||||
print("No aparecieron resultados de busqueda.")
|
||||
|
||||
time.sleep(1)
|
||||
destino_norm = destino.strip().lower()
|
||||
chat_exacto = None
|
||||
chat_parcial = None
|
||||
|
||||
for chat in buscar_resultados_chat(driver):
|
||||
try:
|
||||
nombre_chat = obtener_nombre_resultado(chat)
|
||||
if not nombre_chat:
|
||||
continue
|
||||
|
||||
nombre_norm = nombre_chat.strip().lower()
|
||||
print(f"Resultado encontrado: {nombre_chat}")
|
||||
|
||||
if nombre_norm == destino_norm:
|
||||
chat_exacto = chat
|
||||
break
|
||||
if destino_norm in nombre_norm or nombre_norm in destino_norm:
|
||||
chat_parcial = chat
|
||||
except (StaleElementReferenceException, WebDriverException):
|
||||
continue
|
||||
|
||||
chat_objetivo = chat_exacto or chat_parcial
|
||||
if chat_objetivo:
|
||||
click_seguro(driver, chat_objetivo)
|
||||
if buscar_caja_mensaje(driver, timeout=15):
|
||||
print(f"{tipo.capitalize()} abierto.")
|
||||
return True
|
||||
|
||||
print("Se selecciono el chat pero no se detecto caja mensaje.")
|
||||
return False
|
||||
|
||||
try:
|
||||
buscador.send_keys(Keys.ENTER)
|
||||
if buscar_caja_mensaje(driver, timeout=8):
|
||||
print(f"{tipo.capitalize()} '{destino}' abierto usando el primer resultado.")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"No se encontro {tipo} '{destino}'.")
|
||||
return False
|
||||
except TimeoutException:
|
||||
print(f"Timeout abriendo chat '{destino}'.")
|
||||
return False
|
||||
except WebDriverException as exc:
|
||||
print(f"Error navegador: {exc}")
|
||||
return False
|
||||
except Exception as exc:
|
||||
print(f"Error inesperado: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def enviar_mensaje(driver, mensaje):
|
||||
try:
|
||||
caja = buscar_caja_mensaje(driver, timeout=20)
|
||||
if not caja:
|
||||
raise Exception("No se encontro caja texto")
|
||||
|
||||
pyperclip.copy(mensaje.replace("\\n", "\n"))
|
||||
limpiar_campo(driver, caja)
|
||||
time.sleep(0.2)
|
||||
caja.send_keys(Keys.CONTROL, "v")
|
||||
time.sleep(0.3)
|
||||
|
||||
botones_envio = [
|
||||
(By.CSS_SELECTOR, "button[aria-label*='enviar' i]"),
|
||||
(By.CSS_SELECTOR, "button[aria-label*='send' i]"),
|
||||
(By.CSS_SELECTOR, "[data-icon*='send']"),
|
||||
(By.CSS_SELECTOR, "span[data-testid*='send']"),
|
||||
]
|
||||
boton = esperar_primer_visible(driver, botones_envio, timeout=2)
|
||||
if boton:
|
||||
click_seguro(driver, boton)
|
||||
else:
|
||||
caja.send_keys(Keys.ENTER)
|
||||
|
||||
print("Mensaje enviado.")
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f"No se pudo enviar: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def obtener_nombre_chat_actual(driver):
|
||||
try:
|
||||
nombre_elem = driver.find_element(By.XPATH, "//header//span[@dir='auto' and @title]")
|
||||
return nombre_elem.get_attribute("title").strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
encabezado = driver.find_element(By.CSS_SELECTOR, "header")
|
||||
for selector in ["span[title]", "span[dir='auto']", "div[role='button'] span"]:
|
||||
for elemento in encabezado.find_elements(By.CSS_SELECTOR, selector):
|
||||
texto = (elemento.get_attribute("title") or elemento.text or "").strip()
|
||||
if texto:
|
||||
return texto
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "chat_desconocido"
|
||||
|
||||
|
||||
def verificar_ticks(driver):
|
||||
try:
|
||||
metas = driver.find_elements(By.CSS_SELECTOR, "[data-testid='msg-meta']")
|
||||
ultimo_con_status = None
|
||||
for meta in reversed(metas):
|
||||
if meta.find_elements(By.CSS_SELECTOR, "svg title, span[aria-label], [data-icon]"):
|
||||
ultimo_con_status = meta
|
||||
break
|
||||
|
||||
if not ultimo_con_status:
|
||||
containers = driver.find_elements(By.CSS_SELECTOR, "[data-testid='msg-container']")
|
||||
if containers and containers[-1].find_elements(By.CSS_SELECTOR, "[data-icon='message-fail']"):
|
||||
return "FALLIDO"
|
||||
return "ENVIADO (SIN CONFIRMACION)"
|
||||
|
||||
titles = ultimo_con_status.find_elements(By.CSS_SELECTOR, "svg title")
|
||||
if titles:
|
||||
titulo = (titles[-1].get_attribute("textContent") or titles[-1].text).strip()
|
||||
mapping = {
|
||||
"wds-ic-read": "LEIDO (2 ticks azules)",
|
||||
"wds-ic-status-pending": "PENDIENTE (reloj)",
|
||||
"wds-ic-status-sent": "ENVIADO (1 tick)",
|
||||
"wds-ic-sent": "ENVIADO (1 tick)",
|
||||
"wds-ic-status-delivered": "ENTREGADO (2 ticks)",
|
||||
"wds-ic-delivered": "ENTREGADO (2 ticks)",
|
||||
}
|
||||
return mapping.get(titulo, f"DESCONOCIDO ({titulo})")
|
||||
|
||||
aria_spans = ultimo_con_status.find_elements(By.CSS_SELECTOR, "span[aria-label]")
|
||||
if aria_spans:
|
||||
label = aria_spans[-1].get_attribute("aria-label").strip()
|
||||
aria_map = {
|
||||
"Pendiente": "PENDIENTE (reloj)",
|
||||
"Enviado": "ENVIADO (1 tick)",
|
||||
"Entregado": "ENTREGADO (2 ticks)",
|
||||
"Leído": "LEIDO (2 ticks azules)",
|
||||
"Read": "LEIDO (2 ticks azules)",
|
||||
}
|
||||
return aria_map.get(label, f"DESCONOCIDO ({label})")
|
||||
|
||||
status_icons = ultimo_con_status.find_elements(By.CSS_SELECTOR, "[data-icon]")
|
||||
if status_icons:
|
||||
icono = status_icons[-1].get_attribute("data-icon")
|
||||
legacy = {
|
||||
"msg-time": "PENDIENTE (reloj)",
|
||||
"msg-check": "ENVIADO (1 tick)",
|
||||
"msg-dblcheck": "ENTREGADO (2 ticks)",
|
||||
"msg-dblcheck-ack": "LEIDO (2 ticks azules)",
|
||||
}
|
||||
return legacy.get(icono, f"DESCONOCIDO ({icono})")
|
||||
|
||||
return "ENVIADO (SIN DETALLE)"
|
||||
except Exception:
|
||||
return "ERROR_VERIFICACION_STATUS"
|
||||
|
||||
|
||||
def get_last_outgoing_text(driver):
|
||||
try:
|
||||
elems = driver.find_elements(
|
||||
By.CSS_SELECTOR,
|
||||
"[data-testid='msg-container'] span.selectable-text, "
|
||||
"[data-testid='msg-container'] [data-testid='selectable-text']",
|
||||
)
|
||||
if elems:
|
||||
texto = elems[-1].text.strip()
|
||||
if texto:
|
||||
return texto
|
||||
|
||||
elems = driver.find_elements(By.CSS_SELECTOR, "span.selectable-text")
|
||||
for elem in reversed(elems[-10:]):
|
||||
texto = elem.text.strip()
|
||||
if texto:
|
||||
return texto
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def validate_sent(driver, mensaje, timeout=8):
|
||||
short = (mensaje.strip().replace("\n", " "))[:40].strip().lower()
|
||||
if not short:
|
||||
return False
|
||||
|
||||
inicio = time.time()
|
||||
while time.time() - inicio < timeout:
|
||||
try:
|
||||
if driver.find_elements(
|
||||
By.CSS_SELECTOR,
|
||||
"canvas[aria-label*='Scan'], canvas[aria-label*='escan' i], div[data-ref] canvas",
|
||||
):
|
||||
print("Se detecto QR durante la validacion: sesion perdida.")
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last = get_last_outgoing_text(driver)
|
||||
if last:
|
||||
last_norm = last.replace("\n", " ").strip().lower()
|
||||
if short in last_norm or last_norm in short:
|
||||
return True
|
||||
if last_norm.startswith(short[:20]):
|
||||
return True
|
||||
time.sleep(0.8)
|
||||
|
||||
return False
|
||||
Reference in New Issue
Block a user