feat: selecao individual de leads antes de salvar, dark mode global e extracao 100% real do Google Maps
This commit is contained in:
+59
-24
@@ -11,9 +11,13 @@ from app.utils.rbac import admin_required, get_current_user
|
||||
|
||||
leads_bp = Blueprint('leads', __name__, url_prefix='/api/v1/leads')
|
||||
|
||||
@leads_bp.route('/search-maps', methods=['POST'])
|
||||
@leads_bp.route('/scrape-preview', methods=['POST'])
|
||||
@jwt_required()
|
||||
def search_maps():
|
||||
def scrape_preview():
|
||||
"""
|
||||
Realiza a varredura no Google Maps e retorna os estabelecimentos encontrados
|
||||
para visualização prévia e seleção ANTES de salvar no banco de dados.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
cep = data.get('cep')
|
||||
ramo = data.get('ramo')
|
||||
@@ -22,31 +26,66 @@ def search_maps():
|
||||
if not cep or not ramo:
|
||||
return jsonify({'error': 'CEP e Ramo de Atividade são campos obrigatórios.'}), 400
|
||||
|
||||
# 1. Consulta CEP via ViaCEP
|
||||
location_info = GeocodingService.get_location_by_cep(cep)
|
||||
if 'error' in location_info and not location_info.get('cidade'):
|
||||
return jsonify({'error': location_info['error']}), 400
|
||||
|
||||
# 2. Monta query de busca e dispara Playwright Scraper
|
||||
search_query = GeocodingService.build_search_query(ramo, location_info)
|
||||
scraped_data = ScraperService.scrape_google_maps(search_query, max_results=max_results)
|
||||
|
||||
# Se Playwright não retornar dados em ambiente sem display ou se for mock em desenvolvimento
|
||||
if not scraped_data:
|
||||
# Tenta fallback estruturado mock para garantir funcionalidade se não houver display
|
||||
scraped_data = [
|
||||
{
|
||||
'nome_empresa': f"{ramo.title()} {location_info.get('bairro', 'Centro').title()}",
|
||||
'telefone': '(11) 98888-7777',
|
||||
'endereco': f"{location_info.get('logradouro', 'Rua Principal')}, {location_info.get('bairro', 'Bairro')}",
|
||||
'google_rating': 4.8,
|
||||
'total_avaliacoes': 42,
|
||||
'google_maps_url': 'https://maps.google.com',
|
||||
'website': ''
|
||||
}
|
||||
]
|
||||
return jsonify({
|
||||
'search_query': search_query,
|
||||
'location': location_info,
|
||||
'count': len(scraped_data),
|
||||
'results': scraped_data
|
||||
}), 200
|
||||
|
||||
@leads_bp.route('/import-selected', methods=['POST'])
|
||||
@jwt_required()
|
||||
def import_selected():
|
||||
"""
|
||||
Persiste no banco de dados APENAS os leads selecionados pelo usuário na tela.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
cep = data.get('cep')
|
||||
ramo = data.get('ramo')
|
||||
selected_leads = data.get('selected_leads', [])
|
||||
|
||||
if not cep or not ramo:
|
||||
return jsonify({'error': 'CEP e Ramo de Atividade são obrigatórios.'}), 400
|
||||
|
||||
if not selected_leads or not isinstance(selected_leads, list):
|
||||
return jsonify({'error': 'Nenhum lead selecionado para importação.'}), 400
|
||||
|
||||
user_id = get_jwt_identity()
|
||||
summary = ScraperService.process_and_persist_leads(cep, ramo, selected_leads, usuario_id=user_id)
|
||||
|
||||
return jsonify({
|
||||
'message': f'{summary["created_count"]} novos leads importados e {summary["updated_count"]} atualizados com sucesso!',
|
||||
'summary': summary
|
||||
}), 200
|
||||
|
||||
@leads_bp.route('/search-maps', methods=['POST'])
|
||||
@jwt_required()
|
||||
def search_maps():
|
||||
"""
|
||||
Endpoint legados de busca direta com salvamento automático.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
cep = data.get('cep')
|
||||
ramo = data.get('ramo')
|
||||
max_results = int(data.get('max_results', 15))
|
||||
|
||||
if not cep or not ramo:
|
||||
return jsonify({'error': 'CEP e Ramo de Atividade são campos obrigatórios.'}), 400
|
||||
|
||||
location_info = GeocodingService.get_location_by_cep(cep)
|
||||
if 'error' in location_info and not location_info.get('cidade'):
|
||||
return jsonify({'error': location_info['error']}), 400
|
||||
|
||||
search_query = GeocodingService.build_search_query(ramo, location_info)
|
||||
scraped_data = ScraperService.scrape_google_maps(search_query, max_results=max_results)
|
||||
|
||||
# 3. Processa e salva/deduplica no banco de dados
|
||||
user_id = get_jwt_identity()
|
||||
summary = ScraperService.process_and_persist_leads(cep, ramo, scraped_data, usuario_id=user_id)
|
||||
|
||||
@@ -63,7 +102,7 @@ def list_leads():
|
||||
ramo = request.args.get('ramo')
|
||||
search = request.args.get('search')
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = int(request.args.get('per_page', 50))
|
||||
per_page = int(request.args.get('per_page', 200))
|
||||
|
||||
query = Lead.query
|
||||
|
||||
@@ -197,7 +236,6 @@ def export_leads():
|
||||
if format_type == 'json':
|
||||
return jsonify([l.to_dict() for l in leads]), 200
|
||||
|
||||
# Formato CSV
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, delimiter=';')
|
||||
writer.writerow([
|
||||
@@ -235,9 +273,6 @@ def delete_lead(lead_id):
|
||||
@leads_bp.route('/bulk', methods=['DELETE'])
|
||||
@admin_required()
|
||||
def expunge_leads():
|
||||
"""
|
||||
Expurgo / Limpeza em lote do banco de dados (Exclusivo Admin).
|
||||
"""
|
||||
LeadInteracao.query.delete()
|
||||
num_deleted = Lead.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
@@ -15,37 +15,45 @@ 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.
|
||||
Executa o Playwright em modo headless para buscar no Google Maps com extração 100% real de estabelecimentos.
|
||||
"""
|
||||
results = []
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
logger.warning("Playwright não está instalado. Retornando lista vazia.")
|
||||
logger.error("Playwright não está instalado no ambiente.")
|
||||
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']
|
||||
args=[
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--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',
|
||||
viewport={'width': 1366, 'height': 768},
|
||||
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
locale='pt-BR'
|
||||
)
|
||||
page = context.new_page()
|
||||
|
||||
# Prevenir detecção do webdriver
|
||||
page.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
|
||||
|
||||
encoded_query = search_query.replace(' ', '+')
|
||||
url = f"https://www.google.com/maps/search/{encoded_query}"
|
||||
logger.info(f"Navegando para: {url}")
|
||||
logger.info(f"Prospecção no Maps: {url}")
|
||||
|
||||
page.goto(url, wait_until='networkidle', timeout=30000)
|
||||
time.sleep(2)
|
||||
page.goto(url, wait_until='domcontentloaded', timeout=40000)
|
||||
time.sleep(3)
|
||||
|
||||
# Aceitar cookies se houver modal
|
||||
# Fechar dialogs de consentimento de cookies do Google caso apareçam
|
||||
try:
|
||||
accept_btn = page.query_selector("button[aria-label*='Aceitar'], button[aria-label*='Accept']")
|
||||
if accept_btn:
|
||||
@@ -54,68 +62,70 @@ class ScraperService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tentar encontrar o container do feed de resultados no Google Maps
|
||||
# Aguardar feed de resultados ou container principal de busca
|
||||
feed_selector = "div[role='feed']"
|
||||
page.wait_for_selector(feed_selector, timeout=10000)
|
||||
try:
|
||||
page.wait_for_selector(feed_selector, timeout=12000)
|
||||
except Exception:
|
||||
# Se for redirecionado direto para 1 único resultado
|
||||
logger.info("Verificando se a busca caiu diretamente no painel de 1 lugar.")
|
||||
|
||||
# Scroll progressivo para carregar mais estabelecimentos
|
||||
for _ in range(min(5, max_results // 3 + 1)):
|
||||
# Scroll progressivo no feed de resultados
|
||||
for scroll_step in range(min(8, (max_results // 2) + 2)):
|
||||
page.evaluate(f"""
|
||||
const feed = document.querySelector("{feed_selector}");
|
||||
if (feed) {{
|
||||
feed.scrollBy(0, 1000);
|
||||
feed.scrollBy(0, 1200);
|
||||
}}
|
||||
""")
|
||||
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/']")
|
||||
time.sleep(1.2)
|
||||
|
||||
# Extrair os links dos lugares listados
|
||||
card_elements = page.query_selector_all("a[href*='/maps/place/']")
|
||||
seen_urls = set()
|
||||
|
||||
for card in cards:
|
||||
for card in card_elements:
|
||||
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
|
||||
# Extrair o nome da empresa via aria-label ou texto interno
|
||||
aria_label = card.get_attribute("aria-label") or ""
|
||||
|
||||
# Extração de texto dentro do card
|
||||
card_text = card.inner_text()
|
||||
card_text = card.inner_text() or ""
|
||||
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")
|
||||
nome_empresa = aria_label.strip() if aria_label else (lines[0] if lines else "")
|
||||
if not nome_empresa:
|
||||
continue
|
||||
|
||||
# Extrair rating e contagem de avaliações
|
||||
# Extrair avaliação e total de reviews
|
||||
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)
|
||||
# Procura regex por padroes como "4,8 (150)" ou "4.8"
|
||||
rating_match = re.search(r'([1-5][.,]\d)\s*(?:\(([\d.,]+)\))?', card_text)
|
||||
if rating_match:
|
||||
try:
|
||||
rating = float(rating_match.group(1).replace(',', '.'))
|
||||
if rating_match.group(2):
|
||||
total_avaliacoes = int(re.sub(r'\D', '', rating_match.group(2)))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extrair telefone
|
||||
# Extrair número de telefone do card text se presente
|
||||
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
|
||||
# Extrair trecho de endereço ou bairro das linhas
|
||||
endereco = ""
|
||||
for line in lines:
|
||||
if any(term in line.lower() for term in ['r.', 'rua', 'av.', 'avenida', 'alameda', 'bairro', 'praça', 'nº']):
|
||||
if any(term in line.lower() for term in ['r.', 'rua', 'av.', 'avenida', 'alameda', 'bairro', 'praça', 'nº', 'dr.', 'doutor', 'centro']):
|
||||
if line != nome_empresa:
|
||||
endereco = line
|
||||
break
|
||||
|
||||
@@ -129,12 +139,12 @@ class ScraperService:
|
||||
'website': ''
|
||||
})
|
||||
except Exception as item_err:
|
||||
logger.error(f"Erro ao extrair item do Maps: {item_err}")
|
||||
logger.error(f"Erro ao processar item do Maps: {item_err}")
|
||||
continue
|
||||
|
||||
browser.close()
|
||||
except Exception as err:
|
||||
logger.error(f"Erro na execução do Playwright Scraper: {err}")
|
||||
logger.error(f"Erro na execução do Playwright Scraper real: {err}")
|
||||
|
||||
return results
|
||||
|
||||
@@ -145,7 +155,6 @@ class ScraperService:
|
||||
"""
|
||||
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': '',
|
||||
@@ -168,7 +177,7 @@ class ScraperService:
|
||||
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)
|
||||
# Deduplicação: (nome_empresa, telefone_sanitizado, cidade)
|
||||
query = Lead.query.filter(
|
||||
Lead.nome_empresa.ilike(nome_empresa),
|
||||
Lead.cidade.ilike(cidade)
|
||||
@@ -179,7 +188,6 @@ class ScraperService:
|
||||
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
|
||||
@@ -203,7 +211,6 @@ class ScraperService:
|
||||
updated_count += 1
|
||||
persisted_leads.append(existing_lead)
|
||||
else:
|
||||
# Novo Lead
|
||||
new_lead = Lead(
|
||||
nome_empresa=nome_empresa,
|
||||
ramo_atividade=ramo,
|
||||
@@ -221,7 +228,7 @@ class ScraperService:
|
||||
google_maps_url=item.get('google_maps_url', ''),
|
||||
status_funil='novo',
|
||||
tags=[ramo],
|
||||
notas=f"Capturado via Radar de Busca em {cep}.",
|
||||
notas=f"Capturado no Google Maps em {cep}.",
|
||||
usuario_responsavel_id=usuario_id
|
||||
)
|
||||
db.session.add(new_lead)
|
||||
|
||||
+59
-30
@@ -1,17 +1,25 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
def test_search_maps_endpoint(client, user_headers):
|
||||
with patch('app.services.geocoding_service.GeocodingService.get_location_by_cep') as mock_geo:
|
||||
mock_geo.return_value = {
|
||||
'cep': '01310100',
|
||||
'logradouro': 'Avenida Paulista',
|
||||
'bairro': 'Bela Vista',
|
||||
'cidade': 'São Paulo',
|
||||
'uf': 'SP',
|
||||
'formatted_cep': '01310-100'
|
||||
}
|
||||
def test_scrape_preview_endpoint(client, user_headers):
|
||||
with patch('app.services.geocoding_service.GeocodingService.get_location_by_cep') as mock_geo, \
|
||||
patch('app.services.scraper_service.ScraperService.scrape_google_maps') as mock_scrape:
|
||||
|
||||
response = client.post('/api/v1/leads/search-maps', headers=user_headers, json={
|
||||
mock_geo.return_value = {
|
||||
'cep': '01310100', 'logradouro': 'Avenida Paulista', 'bairro': 'Bela Vista',
|
||||
'cidade': 'São Paulo', 'uf': 'SP', 'formatted_cep': '01310-100'
|
||||
}
|
||||
mock_scrape.return_value = [
|
||||
{
|
||||
'nome_empresa': 'Padaria Paulista Real',
|
||||
'telefone': '(11) 98888-7777',
|
||||
'endereco': 'Av. Paulista, 1000',
|
||||
'google_rating': 4.7,
|
||||
'total_avaliacoes': 120,
|
||||
'google_maps_url': 'https://maps.google.com'
|
||||
}
|
||||
]
|
||||
|
||||
response = client.post('/api/v1/leads/scrape-preview', headers=user_headers, json={
|
||||
'cep': '01310-100',
|
||||
'ramo': 'Padaria',
|
||||
'max_results': 5
|
||||
@@ -19,19 +27,49 @@ def test_search_maps_endpoint(client, user_headers):
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'summary' in data
|
||||
assert data['summary']['created_count'] >= 1
|
||||
assert data['count'] == 1
|
||||
assert data['results'][0]['nome_empresa'] == 'Padaria Paulista Real'
|
||||
|
||||
def test_import_selected_leads_endpoint(client, user_headers):
|
||||
selected_items = [
|
||||
{
|
||||
'nome_empresa': 'Padaria Paulista Real',
|
||||
'telefone': '(11) 98888-7777',
|
||||
'endereco': 'Av. Paulista, 1000',
|
||||
'google_rating': 4.7,
|
||||
'total_avaliacoes': 120,
|
||||
'google_maps_url': 'https://maps.google.com'
|
||||
}
|
||||
]
|
||||
|
||||
def test_update_lead_status_and_interaction(client, user_headers):
|
||||
# Primeiro dispara busca para criar lead
|
||||
with patch('app.services.geocoding_service.GeocodingService.get_location_by_cep') as mock_geo:
|
||||
mock_geo.return_value = {
|
||||
'cep': '01310100', 'logradouro': 'Avenida Paulista', 'bairro': 'Bela Vista',
|
||||
'cidade': 'São Paulo', 'uf': 'SP', 'formatted_cep': '01310-100'
|
||||
}
|
||||
client.post('/api/v1/leads/search-maps', headers=user_headers, json={'cep': '01310-100', 'ramo': 'Padaria'})
|
||||
|
||||
# Listar leads
|
||||
response = client.post('/api/v1/leads/import-selected', headers=user_headers, json={
|
||||
'cep': '01310-100',
|
||||
'ramo': 'Padaria',
|
||||
'selected_leads': selected_items
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['summary']['created_count'] == 1
|
||||
|
||||
def test_update_lead_status_and_interaction(client, user_headers):
|
||||
# Criar lead via import-selected
|
||||
with patch('app.services.geocoding_service.GeocodingService.get_location_by_cep') as mock_geo:
|
||||
mock_geo.return_value = {
|
||||
'cep': '01310100', 'logradouro': 'Avenida Paulista', 'bairro': 'Bela Vista',
|
||||
'cidade': 'São Paulo', 'uf': 'SP', 'formatted_cep': '01310-100'
|
||||
}
|
||||
client.post('/api/v1/leads/import-selected', headers=user_headers, json={
|
||||
'cep': '01310-100', 'ramo': 'Padaria',
|
||||
'selected_leads': [{'nome_empresa': 'Padaria Teste', 'telefone': '(11) 98888-7777'}]
|
||||
})
|
||||
|
||||
list_res = client.get('/api/v1/leads', headers=user_headers)
|
||||
assert list_res.status_code == 200
|
||||
leads = list_res.get_json()['leads']
|
||||
@@ -46,38 +84,29 @@ def test_update_lead_status_and_interaction(client, user_headers):
|
||||
assert update_res.status_code == 200
|
||||
assert update_res.get_json()['status_funil'] == 'contatado'
|
||||
|
||||
# Verificar historico de interacoes
|
||||
detail_res = client.get(f'/api/v1/leads/{lead_id}', headers=user_headers)
|
||||
assert detail_res.status_code == 200
|
||||
detail = detail_res.get_json()
|
||||
assert len(detail['interacoes']) >= 2
|
||||
types = [i['tipo'] for i in detail['interacoes']]
|
||||
assert 'status_change' in types
|
||||
|
||||
def test_opt_out_lgpd(client, user_headers):
|
||||
# Criar lead
|
||||
with patch('app.services.geocoding_service.GeocodingService.get_location_by_cep') as mock_geo:
|
||||
mock_geo.return_value = {
|
||||
'cep': '01310100', 'logradouro': 'Rua Teste', 'bairro': 'Centro',
|
||||
'cidade': 'São Paulo', 'uf': 'SP', 'formatted_cep': '01310-100'
|
||||
}
|
||||
client.post('/api/v1/leads/search-maps', headers=user_headers, json={'cep': '01310-100', 'ramo': 'Farmácia'})
|
||||
client.post('/api/v1/leads/import-selected', headers=user_headers, json={
|
||||
'cep': '01310-100', 'ramo': 'Farmácia',
|
||||
'selected_leads': [{'nome_empresa': 'Farmácia Teste', 'telefone': '(11) 97777-6666'}]
|
||||
})
|
||||
|
||||
list_res = client.get('/api/v1/leads', headers=user_headers)
|
||||
lead_id = list_res.get_json()['leads'][0]['id']
|
||||
|
||||
# Opt-out
|
||||
opt_res = client.post(f'/api/v1/leads/{lead_id}/opt-out', headers=user_headers)
|
||||
assert opt_res.status_code == 200
|
||||
assert opt_res.get_json()['lead']['status_funil'] == 'opt_out'
|
||||
|
||||
def test_export_leads(client, user_headers):
|
||||
# Exportar CSV
|
||||
csv_res = client.get('/api/v1/leads/export?format=csv', headers=user_headers)
|
||||
assert csv_res.status_code == 200
|
||||
assert 'text/csv' in csv_res.content_type
|
||||
|
||||
# Exportar JSON
|
||||
json_res = client.get('/api/v1/leads/export?format=json', headers=user_headers)
|
||||
assert json_res.status_code == 200
|
||||
assert isinstance(json_res.get_json(), list)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
def test_n8n_webhook_flow(client, user_headers):
|
||||
# Criar lead
|
||||
# Criar lead via import-selected
|
||||
with patch('app.services.geocoding_service.GeocodingService.get_location_by_cep') as mock_geo:
|
||||
mock_geo.return_value = {
|
||||
'cep': '01310100', 'logradouro': 'Rua Teste', 'bairro': 'Centro',
|
||||
'cidade': 'São Paulo', 'uf': 'SP', 'formatted_cep': '01310-100'
|
||||
}
|
||||
client.post('/api/v1/leads/search-maps', headers=user_headers, json={'cep': '01310-100', 'ramo': 'Supermercado'})
|
||||
client.post('/api/v1/leads/import-selected', headers=user_headers, json={
|
||||
'cep': '01310-100', 'ramo': 'Supermercado',
|
||||
'selected_leads': [{'nome_empresa': 'Supermercado Teste', 'telefone': '(11) 99999-0000'}]
|
||||
})
|
||||
|
||||
leads = client.get('/api/v1/leads', headers=user_headers).get_json()['leads']
|
||||
assert len(leads) > 0
|
||||
lead_id = leads[0]['id']
|
||||
|
||||
# Disparar webhook n8n
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[theme]
|
||||
primaryColor = "#58A6FF"
|
||||
backgroundColor = "#0D1117"
|
||||
secondaryBackgroundColor = "#161B22"
|
||||
textColor = "#E6EDF3"
|
||||
font = "sans serif"
|
||||
|
||||
[server]
|
||||
headless = true
|
||||
enableCORS = false
|
||||
enableXsrfProtection = false
|
||||
+26
-4
@@ -50,8 +50,11 @@ class APIClient:
|
||||
except Exception as e:
|
||||
return False, f"Erro ao conectar com API: {str(e)}"
|
||||
|
||||
def search_maps(self, cep: str, ramo: str, max_results: int = 15) -> tuple[bool, dict]:
|
||||
url = f"{self.base_url}/api/v1/leads/search-maps"
|
||||
def scrape_preview(self, cep: str, ramo: str, max_results: int = 15) -> tuple[bool, dict]:
|
||||
"""
|
||||
Solicita a varredura no Maps sem salvar no banco de dados ainda.
|
||||
"""
|
||||
url = f"{self.base_url}/api/v1/leads/scrape-preview"
|
||||
try:
|
||||
with httpx.Client(timeout=60.0) as client:
|
||||
res = client.post(url, headers=self._get_headers(), json={
|
||||
@@ -62,9 +65,28 @@ class APIClient:
|
||||
data = res.json()
|
||||
if res.status_code == 200:
|
||||
return True, data
|
||||
return False, {'error': data.get('error', 'Erro durante varredura no Google Maps.')}
|
||||
return False, {'error': data.get('error', 'Erro durante prospecção no Google Maps.')}
|
||||
except Exception as e:
|
||||
return False, {'error': f"Erro ao processar busca: {str(e)}"}
|
||||
return False, {'error': f"Erro ao conectar com API: {str(e)}"}
|
||||
|
||||
def import_selected_leads(self, cep: str, ramo: str, selected_leads: list) -> tuple[bool, str]:
|
||||
"""
|
||||
Envia a lista de leads selecionados para serem salvos no banco.
|
||||
"""
|
||||
url = f"{self.base_url}/api/v1/leads/import-selected"
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
res = client.post(url, headers=self._get_headers(), json={
|
||||
'cep': cep,
|
||||
'ramo': ramo,
|
||||
'selected_leads': selected_leads
|
||||
})
|
||||
data = res.json()
|
||||
if res.status_code == 200:
|
||||
return True, data.get('message', 'Leads importados com sucesso!')
|
||||
return False, data.get('error', 'Falha ao importar leads.')
|
||||
except Exception as e:
|
||||
return False, f"Erro ao conectar com API: {str(e)}"
|
||||
|
||||
def get_leads(self, status_funil: str = None, cidade: str = None, ramo: str = None, search: str = None) -> list:
|
||||
url = f"{self.base_url}/api/v1/leads"
|
||||
|
||||
@@ -20,10 +20,22 @@
|
||||
--status-optout: #8B949E;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
/* Global Dark Mode Overrides */
|
||||
html, body, [data-testid="stAppViewContainer"], [data-testid="stHeader"] {
|
||||
background-color: var(--bg-primary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
[data-testid="stSidebar"] {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
border-right: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
/* Dataframe and Tables Dark Styling */
|
||||
[data-testid="stDataFrame"] {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* Custom Metric Card */
|
||||
@@ -34,7 +46,7 @@ body {
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.metric-title {
|
||||
|
||||
@@ -3,14 +3,14 @@ import streamlit as st
|
||||
from api_client import APIClient
|
||||
|
||||
def render_radar_busca_view(api_client: APIClient):
|
||||
st.title("📡 Radar de Busca & Prospecção Geolocalizada")
|
||||
st.caption("Consulte estabelecimentos comerciais no Google Maps a partir da triangulação de CEP e ramo de atividade.")
|
||||
st.title("📡 Radar de Prospecção Geolocalizada")
|
||||
st.caption("Varra o Google Maps em tempo real por CEP e ramo de atividade. Selecione individualmente quais estabelecimentos deseja importar para a sua base de dados.")
|
||||
|
||||
c1, c2 = st.columns([1, 2])
|
||||
|
||||
with c1:
|
||||
with st.container(border=True):
|
||||
st.subheader("🔍 Parâmetros de Varredura")
|
||||
st.subheader("🔍 Parâmetros de Busca")
|
||||
|
||||
cep = st.text_input("CEP Alvo", value="01310-100", help="Digite o CEP com ou sem hífen (ex: 01310-100)")
|
||||
|
||||
@@ -22,55 +22,98 @@ def render_radar_busca_view(api_client: APIClient):
|
||||
custom_ramo = st.text_input("Ou digite um ramo personalizado:", value="", placeholder="Ex: Petshop 24h")
|
||||
|
||||
ramo_final = custom_ramo.strip() if custom_ramo.strip() else ramo
|
||||
max_results = st.slider("Quantidade máxima de leads", min_value=5, max_value=50, value=15, step=5)
|
||||
max_results = st.slider("Quantidade máxima de estabelecimentos", min_value=5, max_value=50, value=15, step=5)
|
||||
|
||||
if st.button("🛰️ Disparar Prospecção Ativa", type="primary", use_container_width=True):
|
||||
if st.button("🛰️ Buscar no Google Maps", type="primary", use_container_width=True):
|
||||
if not cep:
|
||||
st.error("Informe um CEP válido.")
|
||||
else:
|
||||
st.session_state.pop('preview_data', None)
|
||||
st.session_state.pop('import_success_msg', None)
|
||||
|
||||
progress_bar = st.progress(0, text="Iniciando triangulação de CEP via ViaCEP...")
|
||||
|
||||
with st.spinner("Extraindo estabelecimentos no Google Maps via Playwright Scraper..."):
|
||||
progress_bar.progress(30, text="Bairro e Município resolvidos. Abrindo Playwright Chromium...")
|
||||
success, data = api_client.search_maps(cep, ramo_final, max_results)
|
||||
progress_bar.progress(80, text="Deduplicando e persistindo leads no banco de dados...")
|
||||
with st.spinner("Navegando no Google Maps em tempo real via Playwright Chromium..."):
|
||||
progress_bar.progress(40, text="Executando scraper headless e rolando feed de estabelecimentos...")
|
||||
success, data = api_client.scrape_preview(cep, ramo_final, max_results)
|
||||
progress_bar.progress(100, text="Varredura concluída!")
|
||||
|
||||
if success:
|
||||
progress_bar.progress(100, text="Varredura concluída!")
|
||||
st.session_state['last_search_data'] = data
|
||||
st.success("Busca executada e leads atualizados no banco de dados!")
|
||||
st.session_state['preview_data'] = data
|
||||
st.session_state['search_cep'] = cep
|
||||
st.session_state['search_ramo'] = ramo_final
|
||||
else:
|
||||
progress_bar.empty()
|
||||
st.error(data.get('error', 'Falha ao executar prospecção.'))
|
||||
st.error(data.get('error', 'Falha ao executar varredura.'))
|
||||
|
||||
with c2:
|
||||
search_data = st.session_state.get('last_search_data')
|
||||
if search_data:
|
||||
summary = search_data.get('summary', {})
|
||||
loc = summary.get('location', {})
|
||||
leads = summary.get('leads', [])
|
||||
# Se houver mensagem de sucesso da importação recente
|
||||
if st.session_state.get('import_success_msg'):
|
||||
st.success(st.session_state['import_success_msg'])
|
||||
|
||||
st.subheader("📊 Resultado da Varredura")
|
||||
preview_data = st.session_state.get('preview_data')
|
||||
if preview_data:
|
||||
results = preview_data.get('results', [])
|
||||
location = preview_data.get('location', {})
|
||||
count = preview_data.get('count', 0)
|
||||
|
||||
mc1, mc2, mc3 = st.columns(3)
|
||||
mc1.metric("Localização", f"{loc.get('bairro', 'Bairro')}, {loc.get('cidade', 'Cidade')}-{loc.get('uf', '')}")
|
||||
mc2.metric("Novos Leads", summary.get('created_count', 0))
|
||||
mc3.metric("Reencontrados/Atualizados", summary.get('updated_count', 0))
|
||||
st.subheader(f"📋 Estabelecimentos Encontrados ({count})")
|
||||
st.caption(f"📍 **Localização:** {location.get('bairro', 'Bairro')}, {location.get('cidade', 'Cidade')}-{location.get('uf', '')} | Query: `{preview_data.get('search_query')}`")
|
||||
|
||||
if leads:
|
||||
st.markdown("### 📋 Preview dos Leads Obtidos")
|
||||
df_data = []
|
||||
for l in leads:
|
||||
df_data.append({
|
||||
'Empresa': l.get('nome_empresa'),
|
||||
'Ramo': l.get('ramo_atividade'),
|
||||
'Telefone': l.get('telefone'),
|
||||
'Avaliação': f"⭐ {l.get('google_rating')} ({l.get('total_avaliacoes')})",
|
||||
'Bairro/Cidade': f"{l.get('bairro', '')} / {l.get('cidade', '')}",
|
||||
'Status': l.get('status_funil')
|
||||
})
|
||||
df = pd.DataFrame(df_data)
|
||||
st.dataframe(df, use_container_width=True)
|
||||
st.info("💡 Acesse o menu **Funil CRM (Kanban)** para gerenciar estes leads!")
|
||||
if not results:
|
||||
st.warning("Nenhum estabelecimento foi encontrado no Google Maps para esta combinação de CEP e Ramo.")
|
||||
else:
|
||||
st.info("👈 Preencha os parâmetros no painel ao lado e clique em **Disparar Prospecção Ativa** para iniciar.")
|
||||
st.write("Marque as caixas de seleção abaixo para escolher quais leads deseja salvar no banco de dados:")
|
||||
|
||||
# Tabela com checkboxes de seleção individual
|
||||
df_preview = pd.DataFrame([
|
||||
{
|
||||
'Importar': True,
|
||||
'Empresa': r.get('nome_empresa'),
|
||||
'Telefone': r.get('telefone') or 'Não informado',
|
||||
'Avaliação': f"⭐ {r.get('google_rating')} ({r.get('total_avaliacoes')})",
|
||||
'Endereço / Trecho': r.get('endereco', ''),
|
||||
'_raw_item': r
|
||||
}
|
||||
for r in results
|
||||
])
|
||||
|
||||
edited_df = st.data_editor(
|
||||
df_preview.drop(columns=['_raw_item']),
|
||||
column_config={
|
||||
"Importar": st.column_config.CheckboxColumn("Importar?", default=True)
|
||||
},
|
||||
disabled=["Empresa", "Telefone", "Avaliação", "Endereço / Trecho"],
|
||||
use_container_width=True,
|
||||
hide_index=True
|
||||
)
|
||||
|
||||
# Identificar os itens marcados para importação
|
||||
selected_indices = edited_df[edited_df["Importar"]].index.tolist()
|
||||
selected_items = [results[i] for i in selected_indices if i < len(results)]
|
||||
|
||||
st.write(f"**Leads selecionados para importação:** {len(selected_items)} de {len(results)}")
|
||||
|
||||
btn_c1, btn_c2 = st.columns([2, 1])
|
||||
with btn_c1:
|
||||
if st.button(f"💾 Salvar {len(selected_items)} Lead(s) Selecionado(s) no Banco", type="primary", use_container_width=True, disabled=len(selected_items) == 0):
|
||||
with st.spinner("Persistindo e deduplicando leads selecionados no banco..."):
|
||||
imp_success, imp_msg = api_client.import_selected_leads(
|
||||
st.session_state.get('search_cep', cep),
|
||||
st.session_state.get('search_ramo', ramo_final),
|
||||
selected_items
|
||||
)
|
||||
if imp_success:
|
||||
st.session_state['import_success_msg'] = f"✅ {imp_msg}"
|
||||
st.session_state.pop('preview_data', None)
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(imp_msg)
|
||||
|
||||
with btn_c2:
|
||||
if st.button("❌ Cancelar", use_container_width=True):
|
||||
st.session_state.pop('preview_data', None)
|
||||
st.rerun()
|
||||
else:
|
||||
if not st.session_state.get('import_success_msg'):
|
||||
st.info("👈 Insira os parâmetros ao lado e clique em **Buscar no Google Maps** para visualizar e escolher os leads.")
|
||||
|
||||
Reference in New Issue
Block a user