From 4c8f1f82999b297fa641bb3979b57d6337176457 Mon Sep 17 00:00:00 2001 From: Silas Brito Date: Wed, 26 Aug 2026 17:33:07 -0300 Subject: [PATCH] feat: selecao individual de leads antes de salvar, dark mode global e extracao 100% real do Google Maps --- backend/app/routes/leads.py | 83 +++++++++++----- backend/app/services/scraper_service.py | 99 ++++++++++--------- backend/tests/test_leads.py | 91 ++++++++++++------ backend/tests/test_webhooks.py | 8 +- frontend/.streamlit/config.toml | 11 +++ frontend/api_client.py | 30 +++++- frontend/styles/style.css | 22 ++++- frontend/views/radar_busca.py | 121 ++++++++++++++++-------- 8 files changed, 314 insertions(+), 151 deletions(-) create mode 100644 frontend/.streamlit/config.toml diff --git a/backend/app/routes/leads.py b/backend/app/routes/leads.py index 7ef53f1..f1572cc 100644 --- a/backend/app/routes/leads.py +++ b/backend/app/routes/leads.py @@ -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() diff --git a/backend/app/services/scraper_service.py b/backend/app/services/scraper_service.py index f7b1c00..fac7f6c 100644 --- a/backend/app/services/scraper_service.py +++ b/backend/app/services/scraper_service.py @@ -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,70 +62,72 @@ 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") - - # Extrair rating e contagem de avaliações + nome_empresa = aria_label.strip() if aria_label else (lines[0] if lines else "") + if not nome_empresa: + continue + + # 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(',', '.')) - total_avaliacoes = int(re.sub(r'\D', '', rating_match.group(2))) + 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º']): - endereco = line - break + 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 results.append({ 'nome_empresa': nome_empresa, @@ -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) diff --git a/backend/tests/test_leads.py b/backend/tests/test_leads.py index c542320..ef4d3e3 100644 --- a/backend/tests/test_leads.py +++ b/backend/tests/test_leads.py @@ -1,37 +1,75 @@ 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 }) - + 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'}) + + 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'}] + }) - # Listar leads 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) diff --git a/backend/tests/test_webhooks.py b/backend/tests/test_webhooks.py index 9db01bc..d6f12b2 100644 --- a/backend/tests/test_webhooks.py +++ b/backend/tests/test_webhooks.py @@ -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 diff --git a/frontend/.streamlit/config.toml b/frontend/.streamlit/config.toml new file mode 100644 index 0000000..6cd3248 --- /dev/null +++ b/frontend/.streamlit/config.toml @@ -0,0 +1,11 @@ +[theme] +primaryColor = "#58A6FF" +backgroundColor = "#0D1117" +secondaryBackgroundColor = "#161B22" +textColor = "#E6EDF3" +font = "sans serif" + +[server] +headless = true +enableCORS = false +enableXsrfProtection = false diff --git a/frontend/api_client.py b/frontend/api_client.py index 0b3d94f..f5422e3 100644 --- a/frontend/api_client.py +++ b/frontend/api_client.py @@ -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" diff --git a/frontend/styles/style.css b/frontend/styles/style.css index c4a13cf..845ddc7 100644 --- a/frontend/styles/style.css +++ b/frontend/styles/style.css @@ -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 { diff --git a/frontend/views/radar_busca.py b/frontend/views/radar_busca.py index 933c3af..ff1957c 100644 --- a/frontend/views/radar_busca.py +++ b/frontend/views/radar_busca.py @@ -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.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: - st.info("👈 Preencha os parâmetros no painel ao lado e clique em **Disparar Prospecção Ativa** para iniciar.") + 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.")