feat: implementacao inicial do sistema LeadRadar
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
from flask_jwt_extended import create_access_token, create_refresh_token
|
||||
from app.extensions import db
|
||||
from app.models.user import User
|
||||
from app.utils.sanitizers import validate_email
|
||||
|
||||
class AuthService:
|
||||
|
||||
@staticmethod
|
||||
def login(email: str, password: str):
|
||||
if not email or not password:
|
||||
return None, "E-mail e senha são obrigatórios."
|
||||
|
||||
user = User.query.filter_by(email=email.strip().lower()).first()
|
||||
if not user or not user.check_password(password):
|
||||
return None, "Credenciais inválidas."
|
||||
|
||||
if not user.ativo:
|
||||
return None, "Usuário desativado pelo administrador."
|
||||
|
||||
access_token = create_access_token(identity=user.id, additional_claims={'role': user.role, 'nome': user.nome})
|
||||
refresh_token = create_refresh_token(identity=user.id)
|
||||
|
||||
return {
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'user': user.to_dict()
|
||||
}, None
|
||||
|
||||
@staticmethod
|
||||
def create_user(nome: str, email: str, password: str, role: str = 'user'):
|
||||
email_clean = email.strip().lower() if email else ''
|
||||
if not nome or not email_clean or not password:
|
||||
return None, "Nome, e-mail e senha são obrigatórios."
|
||||
|
||||
if not validate_email(email_clean):
|
||||
return None, "Formato de e-mail inválido."
|
||||
|
||||
if len(password) < 6:
|
||||
return None, "A senha deve conter no mínimo 6 caracteres."
|
||||
|
||||
if User.query.filter_by(email=email_clean).first():
|
||||
return None, "E-mail já cadastrado no sistema."
|
||||
|
||||
if role not in ('admin', 'user'):
|
||||
role = 'user'
|
||||
|
||||
user = User(
|
||||
nome=nome.strip(),
|
||||
email=email_clean,
|
||||
role=role,
|
||||
ativo=True
|
||||
)
|
||||
user.set_password(password)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user.to_dict(), None
|
||||
|
||||
@staticmethod
|
||||
def change_password(user_id: str, old_password: str, new_password: str):
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
return False, "Usuário não encontrado."
|
||||
|
||||
if not user.check_password(old_password):
|
||||
return False, "Senha atual incorreta."
|
||||
|
||||
if len(new_password) < 6:
|
||||
return False, "A nova senha deve ter no mínimo 6 caracteres."
|
||||
|
||||
user.set_password(new_password)
|
||||
db.session.commit()
|
||||
return True, "Senha alterada com sucesso."
|
||||
|
||||
@staticmethod
|
||||
def admin_reset_password(target_user_id: str, new_password: str):
|
||||
user = db.session.get(User, target_user_id)
|
||||
if not user:
|
||||
return False, "Usuário não encontrado."
|
||||
|
||||
if len(new_password) < 6:
|
||||
return False, "A nova senha deve ter no mínimo 6 caracteres."
|
||||
|
||||
user.set_password(new_password)
|
||||
db.session.commit()
|
||||
return True, f"Senha do usuário {user.email} redefinida com sucesso."
|
||||
@@ -0,0 +1,45 @@
|
||||
import httpx
|
||||
from app.utils.sanitizers import sanitize_cep
|
||||
|
||||
class GeocodingService:
|
||||
|
||||
@staticmethod
|
||||
def get_location_by_cep(cep_str: str) -> dict:
|
||||
clean_cep = sanitize_cep(cep_str)
|
||||
if len(clean_cep) != 8:
|
||||
return {'error': 'CEP inválido. Deve conter exatamente 8 dígitos.'}
|
||||
|
||||
url = f"https://viacep.com.br/ws/{clean_cep}/json/"
|
||||
try:
|
||||
with httpx.Client(timeout=8.0) as client:
|
||||
response = client.get(url)
|
||||
if response.status_code != 200:
|
||||
return {'error': f'Falha ao consultar API ViaCEP (HTTP {response.status_code}).'}
|
||||
|
||||
data = response.json()
|
||||
if data.get('erro') is True or data.get('erro') == 'true':
|
||||
return {'error': 'CEP não encontrado na base do ViaCEP.'}
|
||||
|
||||
return {
|
||||
'cep': clean_cep,
|
||||
'logradouro': data.get('logradouro', ''),
|
||||
'bairro': data.get('bairro', ''),
|
||||
'cidade': data.get('localidade', ''),
|
||||
'uf': data.get('uf', ''),
|
||||
'ibge': data.get('ibge', ''),
|
||||
'formatted_cep': f"{clean_cep[:5]}-{clean_cep[5:]}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {'error': f'Erro na comunicação com serviço de CEP: {str(e)}'}
|
||||
|
||||
@staticmethod
|
||||
def build_search_query(ramo: str, location_info: dict) -> str:
|
||||
bairro = location_info.get('bairro', '').strip()
|
||||
cidade = location_info.get('cidade', '').strip()
|
||||
uf = location_info.get('uf', '').strip()
|
||||
|
||||
if bairro:
|
||||
return f"{ramo.strip()} em {bairro}, {cidade} - {uf}"
|
||||
elif cidade and uf:
|
||||
return f"{ramo.strip()} em {cidade} - {uf}"
|
||||
return ramo.strip()
|
||||
@@ -0,0 +1,249 @@
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import List, Dict, Any
|
||||
from app.extensions import db
|
||||
from app.models.lead import Lead
|
||||
from app.models.lead_interacao import LeadInteracao
|
||||
from app.utils.sanitizers import sanitize_phone, sanitize_cep
|
||||
from app.services.geocoding_service import GeocodingService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ScraperService:
|
||||
|
||||
@staticmethod
|
||||
def scrape_google_maps(search_query: str, max_results: int = 15, headless: bool = True) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Executa o Playwright em modo headless para buscar no Google Maps.
|
||||
Retorna uma lista de dicionários com dados dos estabelecimentos encontrados.
|
||||
"""
|
||||
results = []
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
logger.warning("Playwright não está instalado. Retornando lista vazia.")
|
||||
return results
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=headless,
|
||||
args=['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--lang=pt-BR']
|
||||
)
|
||||
context = browser.new_context(
|
||||
viewport={'width': 1280, 'height': 800},
|
||||
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
locale='pt-BR'
|
||||
)
|
||||
page = context.new_page()
|
||||
|
||||
encoded_query = search_query.replace(' ', '+')
|
||||
url = f"https://www.google.com/maps/search/{encoded_query}"
|
||||
logger.info(f"Navegando para: {url}")
|
||||
|
||||
page.goto(url, wait_until='networkidle', timeout=30000)
|
||||
time.sleep(2)
|
||||
|
||||
# Aceitar cookies se houver modal
|
||||
try:
|
||||
accept_btn = page.query_selector("button[aria-label*='Aceitar'], button[aria-label*='Accept']")
|
||||
if accept_btn:
|
||||
accept_btn.click()
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tentar encontrar o container do feed de resultados no Google Maps
|
||||
feed_selector = "div[role='feed']"
|
||||
page.wait_for_selector(feed_selector, timeout=10000)
|
||||
|
||||
# Scroll progressivo para carregar mais estabelecimentos
|
||||
for _ in range(min(5, max_results // 3 + 1)):
|
||||
page.evaluate(f"""
|
||||
const feed = document.querySelector("{feed_selector}");
|
||||
if (feed) {{
|
||||
feed.scrollBy(0, 1000);
|
||||
}}
|
||||
""")
|
||||
time.sleep(1.5)
|
||||
|
||||
# Selecionar os elementos de resultado
|
||||
cards = page.query_selector_all("div[role='feed'] > div > div[a]")
|
||||
if not cards:
|
||||
cards = page.query_selector_all("a[href*='/maps/place/']")
|
||||
|
||||
seen_urls = set()
|
||||
|
||||
for card in cards:
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
|
||||
try:
|
||||
# Link do Maps
|
||||
href = card.get_attribute("href")
|
||||
if not href or href in seen_urls:
|
||||
continue
|
||||
seen_urls.add(href)
|
||||
|
||||
# aria-label geralmente contem o nome da empresa
|
||||
aria_label = card.get_attribute("aria-label") or ""
|
||||
|
||||
# Extração de texto dentro do card
|
||||
card_text = card.inner_text()
|
||||
lines = [l.strip() for l in card_text.split('\n') if l.strip()]
|
||||
|
||||
nome_empresa = aria_label.strip() if aria_label else (lines[0] if lines else "Estabelecimento Comercial")
|
||||
|
||||
# Extrair rating e contagem de avaliações
|
||||
rating = 0.0
|
||||
total_avaliacoes = 0
|
||||
|
||||
# Exemplo de regex para extrair "4,8 (120)" ou "4.8 (120)"
|
||||
rating_match = re.search(r'([1-5][.,]\d)\s*\(([\d.,]+)\)', card_text)
|
||||
if rating_match:
|
||||
try:
|
||||
rating = float(rating_match.group(1).replace(',', '.'))
|
||||
total_avaliacoes = int(re.sub(r'\D', '', rating_match.group(2)))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extrair telefone
|
||||
phone_match = re.search(r'(\(?\d{2}\)?\s*9?\d{4}[-.\s]?\d{4})', card_text)
|
||||
telefone_raw = phone_match.group(1) if phone_match else ""
|
||||
|
||||
# Extrair endereço ou trecho do card
|
||||
endereco = ""
|
||||
for line in lines:
|
||||
if any(term in line.lower() for term in ['r.', 'rua', 'av.', 'avenida', 'alameda', 'bairro', 'praça', 'nº']):
|
||||
endereco = line
|
||||
break
|
||||
|
||||
results.append({
|
||||
'nome_empresa': nome_empresa,
|
||||
'telefone': telefone_raw,
|
||||
'endereco': endereco,
|
||||
'google_rating': rating,
|
||||
'total_avaliacoes': total_avaliacoes,
|
||||
'google_maps_url': href,
|
||||
'website': ''
|
||||
})
|
||||
except Exception as item_err:
|
||||
logger.error(f"Erro ao extrair item do Maps: {item_err}")
|
||||
continue
|
||||
|
||||
browser.close()
|
||||
except Exception as err:
|
||||
logger.error(f"Erro na execução do Playwright Scraper: {err}")
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def process_and_persist_leads(cep: str, ramo: str, scraped_items: List[Dict[str, Any]], usuario_id: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Gera leads enriquecidos a partir dos dados do geocoding + scraping, aplicando deduplicação.
|
||||
"""
|
||||
location = GeocodingService.get_location_by_cep(cep)
|
||||
if 'error' in location:
|
||||
# Fallback se o CEP não retornar endereço detalhado
|
||||
location = {
|
||||
'cep': sanitize_cep(cep),
|
||||
'logradouro': '',
|
||||
'bairro': '',
|
||||
'cidade': 'Não especificada',
|
||||
'uf': '',
|
||||
'formatted_cep': cep
|
||||
}
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
persisted_leads = []
|
||||
|
||||
for item in scraped_items:
|
||||
nome_empresa = item.get('nome_empresa', '').strip()
|
||||
if not nome_empresa:
|
||||
continue
|
||||
|
||||
tel_raw = item.get('telefone', '')
|
||||
tel_sanitizado = sanitize_phone(tel_raw)
|
||||
cidade = location.get('cidade') or item.get('cidade') or ''
|
||||
|
||||
# Checar regra de deduplicação: (nome_empresa, telefone_sanitizado, cidade)
|
||||
query = Lead.query.filter(
|
||||
Lead.nome_empresa.ilike(nome_empresa),
|
||||
Lead.cidade.ilike(cidade)
|
||||
)
|
||||
if tel_sanitizado:
|
||||
query = query.filter(Lead.telefone_sanitizado == tel_sanitizado)
|
||||
|
||||
existing_lead = query.first()
|
||||
|
||||
if existing_lead:
|
||||
# Atualiza dados sem resetar status_funil
|
||||
if tel_raw and not existing_lead.telefone:
|
||||
existing_lead.telefone = tel_raw
|
||||
existing_lead.telefone_sanitizado = tel_sanitizado
|
||||
if item.get('google_rating') and item['google_rating'] > 0:
|
||||
existing_lead.google_rating = item['google_rating']
|
||||
if item.get('total_avaliacoes'):
|
||||
existing_lead.total_avaliacoes = item['total_avaliacoes']
|
||||
if item.get('google_maps_url'):
|
||||
existing_lead.google_maps_url = item['google_maps_url']
|
||||
|
||||
existing_lead.atualizado_em = db.func.now()
|
||||
|
||||
interacao = LeadInteracao(
|
||||
lead_id=existing_lead.id,
|
||||
usuario_id=usuario_id,
|
||||
tipo='lead_atualizado',
|
||||
descricao='Lead re-encontrado em nova busca e atualizado.',
|
||||
metadados={'ramo_busca': ramo, 'cep_busca': cep}
|
||||
)
|
||||
db.session.add(interacao)
|
||||
updated_count += 1
|
||||
persisted_leads.append(existing_lead)
|
||||
else:
|
||||
# Novo Lead
|
||||
new_lead = Lead(
|
||||
nome_empresa=nome_empresa,
|
||||
ramo_atividade=ramo,
|
||||
cep_busca=location.get('formatted_cep', cep),
|
||||
logradouro=location.get('logradouro') or item.get('endereco', ''),
|
||||
bairro=location.get('bairro', ''),
|
||||
cidade=cidade,
|
||||
uf=location.get('uf', ''),
|
||||
telefone=tel_raw,
|
||||
telefone_sanitizado=tel_sanitizado,
|
||||
whatsapp_valido=True if tel_sanitizado else False,
|
||||
website=item.get('website', ''),
|
||||
google_rating=item.get('google_rating', 0.0),
|
||||
total_avaliacoes=item.get('total_avaliacoes', 0),
|
||||
google_maps_url=item.get('google_maps_url', ''),
|
||||
status_funil='novo',
|
||||
tags=[ramo],
|
||||
notas=f"Capturado via Radar de Busca em {cep}.",
|
||||
usuario_responsavel_id=usuario_id
|
||||
)
|
||||
db.session.add(new_lead)
|
||||
db.session.flush()
|
||||
|
||||
interacao = LeadInteracao(
|
||||
lead_id=new_lead.id,
|
||||
usuario_id=usuario_id,
|
||||
tipo='lead_criado',
|
||||
descricao=f'Lead capturado no Maps para ramo "{ramo}" e CEP {cep}.',
|
||||
metadados={'query_cep': cep, 'ramo': ramo}
|
||||
)
|
||||
db.session.add(interacao)
|
||||
created_count += 1
|
||||
persisted_leads.append(new_lead)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'location': location,
|
||||
'created_count': created_count,
|
||||
'updated_count': updated_count,
|
||||
'total_processed': len(scraped_items),
|
||||
'leads': [l.to_dict() for l in persisted_leads]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
from typing import Dict, Any, Tuple
|
||||
from app.extensions import db
|
||||
from app.models.lead import Lead
|
||||
from app.models.lead_interacao import LeadInteracao
|
||||
from app.utils.sanitizers import sanitize_phone
|
||||
|
||||
class WebhookService:
|
||||
|
||||
@staticmethod
|
||||
def process_n8n_event(payload: Dict[str, Any]) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
"""
|
||||
Processa eventos recebidos do n8n para atualizar leads e gerar logs de auditoria.
|
||||
"""
|
||||
if not payload:
|
||||
return False, "Payload vazio.", {}
|
||||
|
||||
lead_id = payload.get('lead_id')
|
||||
telefone = payload.get('telefone')
|
||||
event_type = payload.get('event_type', 'webhook_n8n')
|
||||
new_status = payload.get('new_status')
|
||||
mensagem = payload.get('mensagem') or payload.get('descricao', 'Evento recebido via Webhook n8n')
|
||||
|
||||
lead = None
|
||||
if lead_id:
|
||||
lead = db.session.get(Lead, lead_id)
|
||||
|
||||
if not lead and telefone:
|
||||
sanitized = sanitize_phone(telefone)
|
||||
if sanitized:
|
||||
lead = Lead.query.filter_by(telefone_sanitizado=sanitized).first()
|
||||
|
||||
if not lead:
|
||||
return False, "Lead correspondente não foi localizado no banco.", {}
|
||||
|
||||
# Se houver atualização de status
|
||||
if new_status and new_status in ['novo', 'contatado', 'respondeu', 'negociacao', 'ganho', 'perdido', 'opt_out']:
|
||||
old_status = lead.status_funil
|
||||
lead.status_funil = new_status
|
||||
lead.atualizado_em = db.func.now()
|
||||
|
||||
interacao_status = LeadInteracao(
|
||||
lead_id=lead.id,
|
||||
tipo='status_change',
|
||||
descricao=f'Status alterado via webhook n8n de "{old_status}" para "{new_status}".',
|
||||
metadados={'origem': 'n8n', 'old_status': old_status, 'new_status': new_status}
|
||||
)
|
||||
db.session.add(interacao_status)
|
||||
|
||||
# Registra a interação principal do webhook
|
||||
interacao_webhook = LeadInteracao(
|
||||
lead_id=lead.id,
|
||||
tipo='webhook_n8n',
|
||||
descricao=f"Webhook [{event_type}]: {mensagem}",
|
||||
metadados=payload
|
||||
)
|
||||
db.session.add(interacao_webhook)
|
||||
db.session.commit()
|
||||
|
||||
return True, "Webhook processado com sucesso.", {
|
||||
'lead_id': lead.id,
|
||||
'lead_nome': lead.nome_empresa,
|
||||
'status_atual': lead.status_funil
|
||||
}
|
||||
Reference in New Issue
Block a user