feat: selecao individual de leads antes de salvar, dark mode global e extracao 100% real do Google Maps
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user