Initial commit

This commit is contained in:
tiago
2026-10-01 11:00:47 -03:00
commit 9d2ab731ee
149 changed files with 11351 additions and 0 deletions
@@ -0,0 +1,14 @@
package com.seconectai.leadscraper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@EnableScheduling
@SpringBootApplication
public class LeadScraperApplication {
public static void main(String[] args) {
SpringApplication.run(LeadScraperApplication.class, args);
}
}
@@ -0,0 +1,16 @@
package com.seconectai.leadscraper.config;
import com.seconectai.leadscraper.scraper.GoogleMapsScraperProperties;
import com.seconectai.leadscraper.scraper.OverpassScraperProperties;
import com.seconectai.leadscraper.proxy.ProxySettingsProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties({
GoogleMapsScraperProperties.class,
OverpassScraperProperties.class,
ProxySettingsProperties.class
})
public class ScraperConfig {
}
@@ -0,0 +1,45 @@
package com.seconectai.leadscraper.config;
import com.seconectai.leadscraper.security.JwtAuthenticationFilter;
import com.seconectai.leadscraper.security.JwtProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter)
throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration)
throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -0,0 +1,47 @@
package com.seconectai.leadscraper.controller;
import com.seconectai.leadscraper.repository.UsuarioRepository;
import com.seconectai.leadscraper.security.JwtService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UsuarioRepository usuarioRepository;
private final JwtService jwtService;
public AuthController(
AuthenticationManager authenticationManager,
UsuarioRepository usuarioRepository,
JwtService jwtService
) {
this.authenticationManager = authenticationManager;
this.usuarioRepository = usuarioRepository;
this.jwtService = jwtService;
}
@PostMapping("/login")
public LoginResponse login(@Valid @RequestBody LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.senha())
);
var usuario = usuarioRepository.findByEmail(request.email()).orElseThrow();
return new LoginResponse(jwtService.generateToken(usuario.getEmail(), usuario.getPapel()), "Bearer");
}
public record LoginRequest(@Email @NotBlank String email, @NotBlank String senha) {
}
public record LoginResponse(String token, String tipo) {
}
}
@@ -0,0 +1,124 @@
package com.seconectai.leadscraper.controller;
import com.seconectai.leadscraper.domain.Busca;
import com.seconectai.leadscraper.domain.Ramo;
import com.seconectai.leadscraper.domain.StatusBusca;
import com.seconectai.leadscraper.repository.BuscaRepository;
import com.seconectai.leadscraper.repository.LeadRepository;
import com.seconectai.leadscraper.repository.RamoRepository;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.time.OffsetDateTime;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/buscas")
public class BuscaController {
private final BuscaRepository buscaRepository;
private final LeadRepository leadRepository;
private final RamoRepository ramoRepository;
public BuscaController(BuscaRepository buscaRepository, LeadRepository leadRepository, RamoRepository ramoRepository) {
this.buscaRepository = buscaRepository;
this.leadRepository = leadRepository;
this.ramoRepository = ramoRepository;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public BuscaResponse criar(@Valid @RequestBody CriarBuscaRequest request) {
Busca busca = new Busca();
busca.setTermoBusca(request.termoBusca());
busca.setLocalizacao(request.localizacao());
busca.setRamo(resolveRamo(request.ramoId()));
busca.setExpandirPorBairros(request.expandirPorBairros() == null || request.expandirPorBairros());
busca.setStatus(StatusBusca.pendente);
return toResponse(buscaRepository.save(busca));
}
@GetMapping
public Page<BuscaResponse> listar(@RequestParam(required = false) StatusBusca status, Pageable pageable) {
Specification<Busca> spec = (root, query, cb) -> status == null
? cb.conjunction()
: cb.equal(root.get("status"), status);
return buscaRepository.findAll(spec, pageable).map(this::toResponse);
}
@GetMapping("/{id}")
public BuscaResponse detalhar(@PathVariable Long id) {
return toResponse(buscaRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Busca nao encontrada")));
}
private BuscaResponse toResponse(Busca busca) {
long telefonesCapturados = busca.getId() == null ? 0 : leadRepository.countByBuscaIdAndTelefoneE164IsNotNull(busca.getId());
return BuscaResponse.from(busca, telefonesCapturados);
}
private Ramo resolveRamo(Long ramoId) {
if (ramoId == null) {
return null;
}
return ramoRepository.findById(ramoId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ramo nao encontrado"));
}
public record CriarBuscaRequest(
@NotBlank String termoBusca,
@NotBlank String localizacao,
Long ramoId,
Boolean expandirPorBairros
) {
}
public record BuscaResponse(
Long id,
String termoBusca,
String localizacao,
Long ramoId,
StatusBusca status,
Integer totalEncontrados,
long telefonesCapturados,
Integer regioesTotal,
Integer regioesSucesso,
Integer regioesErro,
String detalheRegioes,
String detalheErro,
OffsetDateTime iniciadoEm,
OffsetDateTime finalizadoEm,
OffsetDateTime criadoEm
) {
static BuscaResponse from(Busca busca, long telefonesCapturados) {
return new BuscaResponse(
busca.getId(),
busca.getTermoBusca(),
busca.getLocalizacao(),
busca.getRamo() == null ? null : busca.getRamo().getId(),
busca.getStatus(),
busca.getTotalEncontrados(),
telefonesCapturados,
busca.getRegioesTotal(),
busca.getRegioesSucesso(),
busca.getRegioesErro(),
busca.getDetalheRegioes(),
busca.getDetalheErro(),
busca.getIniciadoEm(),
busca.getFinalizadoEm(),
busca.getCriadoEm()
);
}
}
}
@@ -0,0 +1,229 @@
package com.seconectai.leadscraper.controller;
import com.seconectai.leadscraper.domain.Lead;
import com.seconectai.leadscraper.domain.Ramo;
import com.seconectai.leadscraper.domain.StatusBusca;
import com.seconectai.leadscraper.domain.StatusLead;
import com.seconectai.leadscraper.repository.BuscaRepository;
import com.seconectai.leadscraper.repository.LeadRepository;
import com.seconectai.leadscraper.repository.RamoRepository;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/leads")
public class LeadController {
private final LeadRepository leadRepository;
private final RamoRepository ramoRepository;
private final BuscaRepository buscaRepository;
public LeadController(LeadRepository leadRepository, RamoRepository ramoRepository, BuscaRepository buscaRepository) {
this.leadRepository = leadRepository;
this.ramoRepository = ramoRepository;
this.buscaRepository = buscaRepository;
}
@GetMapping
public Page<LeadResponse> listar(LeadFilter filter, Pageable pageable) {
return leadRepository.findAll(toSpec(filter), pageable).map(LeadResponse::from);
}
@GetMapping("/{id}")
public LeadResponse detalhar(@PathVariable Long id) {
return LeadResponse.from(findLead(id));
}
@PatchMapping("/{id}/status")
@Transactional
public LeadResponse atualizarStatus(@PathVariable Long id, @Valid @RequestBody AtualizarStatusRequest request) {
Lead lead = findLead(id);
lead.setStatus(request.status());
return LeadResponse.from(leadRepository.save(lead));
}
@PatchMapping("/{id}/ramo")
@Transactional
public LeadResponse atualizarRamo(@PathVariable Long id, @Valid @RequestBody AtualizarRamoRequest request) {
Lead lead = findLead(id);
Ramo ramo = ramoRepository.findById(request.ramoId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ramo nao encontrado"));
lead.setRamo(ramo);
return LeadResponse.from(leadRepository.save(lead));
}
@GetMapping("/resumo")
public DashboardResumoResponse resumo() {
List<Lead> leads = leadRepository.findAll();
Map<String, Long> porRamo = leads.stream()
.collect(Collectors.groupingBy(
lead -> lead.getRamo() == null ? "Sem ramo" : lead.getRamo().getNomeCanonico(),
Collectors.counting()
));
List<RamoResumoResponse> leadsPorRamo = porRamo.entrySet().stream()
.map(entry -> new RamoResumoResponse(entry.getKey(), entry.getValue()))
.sorted((left, right) -> Long.compare(right.total(), left.total()))
.toList();
long buscasEmExecucao = buscaRepository.findByStatus(StatusBusca.em_execucao).size();
long telefonesCapturados = leadRepository.countByTelefoneE164IsNotNull();
long telefonesUnicos = leadRepository.countDistinctTelefonesCapturados();
long aguardandoCuradoria = leads.stream().filter(lead -> lead.getRamo() == null).count();
long convertidos = leads.stream().filter(lead -> lead.getStatus() == StatusLead.convertido).count();
return new DashboardResumoResponse(leads.size(), telefonesCapturados, telefonesUnicos, buscasEmExecucao, aguardandoCuradoria, convertidos, leadsPorRamo);
}
@GetMapping("/export")
public void exportar(LeadFilter filter, HttpServletResponse response) throws IOException {
response.setContentType("text/csv;charset=UTF-8");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=leads.csv");
try (PrintWriter writer = response.getWriter()) {
writer.println("id,nome_fantasia,telefone_e164,telefone_bruto,site,cidade,uf,ramo_id,status,busca_id");
leadRepository.findAll(toSpec(filter)).forEach(lead -> writer.println(csv(lead)));
}
}
@GetMapping("/sem-ramo")
public Page<LeadResponse> semRamo(Pageable pageable) {
Specification<Lead> spec = (root, query, cb) -> cb.isNull(root.get("ramo"));
return leadRepository.findAll(spec, pageable).map(LeadResponse::from);
}
private Lead findLead(Long id) {
return leadRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Lead nao encontrado"));
}
private Specification<Lead> toSpec(LeadFilter filter) {
return Specification.allOf(
equalsIfPresent("ramo.id", filter.ramoId()),
likeIfPresent("cidade", filter.cidade()),
likeIfPresent("uf", filter.uf()),
equalsIfPresent("status", filter.status()),
equalsIfPresent("busca.id", filter.buscaId())
);
}
private Specification<Lead> equalsIfPresent(String path, Object value) {
if (value == null) {
return null;
}
return (root, query, cb) -> cb.equal(resolvePath(root, path), value);
}
private Specification<Lead> likeIfPresent(String field, String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return (root, query, cb) -> cb.like(cb.lower(root.get(field)), "%" + value.toLowerCase() + "%");
}
private jakarta.persistence.criteria.Path<?> resolvePath(jakarta.persistence.criteria.Root<Lead> root, String path) {
jakarta.persistence.criteria.Path<?> current = root;
for (String part : path.split("\\.")) {
current = current.get(part);
}
return current;
}
private String csv(Lead lead) {
return String.join(",",
escape(lead.getId()),
escape(lead.getNomeFantasia()),
escape(lead.getTelefoneE164()),
escape(lead.getTelefoneBruto()),
escape(lead.getSite()),
escape(lead.getCidade()),
escape(lead.getUf()),
escape(lead.getRamo() == null ? null : lead.getRamo().getId()),
escape(lead.getStatus()),
escape(lead.getBusca() == null ? null : lead.getBusca().getId())
);
}
private String escape(Object value) {
if (value == null) {
return "";
}
String text = value.toString().replace("\"", "\"\"");
return "\"" + text + "\"";
}
public record LeadFilter(Long ramoId, String cidade, String uf, StatusLead status, Long buscaId) {
}
public record AtualizarStatusRequest(@NotNull StatusLead status) {
}
public record AtualizarRamoRequest(@NotNull Long ramoId) {
}
public record RamoResumoResponse(String ramo, Long total) {
}
public record DashboardResumoResponse(
long totalLeads,
long telefonesCapturados,
long telefonesUnicos,
long buscasEmExecucao,
long aguardandoCuradoria,
long convertidos,
List<RamoResumoResponse> leadsPorRamo
) {
}
public record LeadResponse(
Long id,
String nomeFantasia,
String telefoneE164,
String telefoneBruto,
String site,
String enderecoCompleto,
String cidade,
String uf,
Long ramoId,
String categoriaBruta,
StatusLead status,
Long buscaId,
OffsetDateTime criadoEm
) {
static LeadResponse from(Lead lead) {
return new LeadResponse(
lead.getId(),
lead.getNomeFantasia(),
lead.getTelefoneE164(),
lead.getTelefoneBruto(),
lead.getSite(),
lead.getEnderecoCompleto(),
lead.getCidade(),
lead.getUf(),
lead.getRamo() == null ? null : lead.getRamo().getId(),
lead.getCategoriaBruta(),
lead.getStatus(),
lead.getBusca() == null ? null : lead.getBusca().getId(),
lead.getCriadoEm()
);
}
}
}
@@ -0,0 +1,67 @@
package com.seconectai.leadscraper.controller;
import com.seconectai.leadscraper.domain.Ramo;
import com.seconectai.leadscraper.repository.RamoRepository;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.util.ArrayList;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/ramos")
public class RamoController {
private final RamoRepository ramoRepository;
public RamoController(RamoRepository ramoRepository) {
this.ramoRepository = ramoRepository;
}
@GetMapping
public List<RamoResponse> listar() {
return ramoRepository.findAll().stream().map(RamoResponse::from).toList();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public RamoResponse criar(@Valid @RequestBody CriarRamoRequest request) {
Ramo ramo = new Ramo();
ramo.setNomeCanonico(request.nomeCanonico());
ramo.setAliases(request.aliases() == null ? new ArrayList<>() : new ArrayList<>(request.aliases()));
return RamoResponse.from(ramoRepository.save(ramo));
}
@PatchMapping("/{id}/aliases")
public RamoResponse adicionarAlias(@PathVariable Long id, @Valid @RequestBody AdicionarAliasRequest request) {
Ramo ramo = ramoRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ramo nao encontrado"));
List<String> aliases = new ArrayList<>(ramo.getAliases());
if (!aliases.contains(request.alias())) {
aliases.add(request.alias());
}
ramo.setAliases(aliases);
return RamoResponse.from(ramoRepository.save(ramo));
}
public record CriarRamoRequest(@NotBlank String nomeCanonico, List<String> aliases) {
}
public record AdicionarAliasRequest(@NotBlank String alias) {
}
public record RamoResponse(Long id, String nomeCanonico, List<String> aliases) {
static RamoResponse from(Ramo ramo) {
return new RamoResponse(ramo.getId(), ramo.getNomeCanonico(), ramo.getAliases());
}
}
}
@@ -0,0 +1,85 @@
package com.seconectai.leadscraper.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "buscas")
public class Busca {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "termo_busca", nullable = false, length = 150)
private String termoBusca;
@Column(nullable = false, length = 150)
private String localizacao;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ramo_id")
private Ramo ramo;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "proxy_id")
private Proxy proxy;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private StatusBusca status = StatusBusca.pendente;
@Column(name = "total_encontrados")
private Integer totalEncontrados = 0;
@Column(name = "detalhe_erro", columnDefinition = "text")
private String detalheErro;
@Column(name = "expandir_por_bairros")
private Boolean expandirPorBairros = true;
@Column(name = "regioes_total")
private Integer regioesTotal = 0;
@Column(name = "regioes_sucesso")
private Integer regioesSucesso = 0;
@Column(name = "regioes_erro")
private Integer regioesErro = 0;
@Column(name = "detalhe_regioes", columnDefinition = "text")
private String detalheRegioes;
@Column(name = "iniciado_em")
private OffsetDateTime iniciadoEm;
@Column(name = "finalizado_em")
private OffsetDateTime finalizadoEm;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "criado_por")
private Usuario criadoPor;
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
private OffsetDateTime criadoEm;
@OneToMany(mappedBy = "busca")
private List<Lead> leads = new ArrayList<>();
}
@@ -0,0 +1,41 @@
package com.seconectai.leadscraper.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "historico_contato")
public class HistoricoContato {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lead_id", nullable = false)
private Lead lead;
@Column(length = 30)
private String canal;
@Column(length = 50)
private String resultado;
@Column(columnDefinition = "text")
private String observacao;
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
private OffsetDateTime criadoEm;
}
@@ -0,0 +1,119 @@
package com.seconectai.leadscraper.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@Getter
@Setter
@Entity
@Table(name = "leads")
public class Lead {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "place_id", length = 150)
private String placeId;
@Column(name = "razao_social", length = 200)
private String razaoSocial;
@Column(name = "nome_fantasia", nullable = false, length = 200)
private String nomeFantasia;
@Column(columnDefinition = "char(14)")
@JdbcTypeCode(SqlTypes.CHAR)
private String cnpj;
@Column(name = "telefone_e164", length = 20)
private String telefoneE164;
@Column(name = "telefone_bruto", length = 40)
private String telefoneBruto;
@Column(length = 150)
private String email;
private String site;
@Column(name = "endereco_completo", columnDefinition = "text")
private String enderecoCompleto;
@Column(length = 200)
private String logradouro;
@Column(length = 20)
private String numero;
@Column(length = 100)
private String bairro;
@Column(length = 100)
private String cidade;
@Column(columnDefinition = "char(2)")
@JdbcTypeCode(SqlTypes.CHAR)
private String uf;
@Column(columnDefinition = "char(8)")
@JdbcTypeCode(SqlTypes.CHAR)
private String cep;
@Column(precision = 10, scale = 7)
private BigDecimal latitude;
@Column(precision = 10, scale = 7)
private BigDecimal longitude;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ramo_id")
private Ramo ramo;
@Column(name = "categoria_bruta", length = 150)
private String categoriaBruta;
@Column(nullable = false, length = 30)
private String fonte = "google_maps";
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "busca_id")
private Busca busca;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private StatusLead status = StatusLead.novo;
@Column(name = "hash_dedup", nullable = false, unique = true, length = 64)
private String hashDedup;
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
private OffsetDateTime criadoEm;
@Column(name = "atualizado_em", nullable = false, insertable = false, updatable = false)
private OffsetDateTime atualizadoEm;
@OneToMany(mappedBy = "lead")
private List<Telefone> telefones = new ArrayList<>();
@OneToMany(mappedBy = "lead")
private List<HistoricoContato> historicoContatos = new ArrayList<>();
}
@@ -0,0 +1,52 @@
package com.seconectai.leadscraper.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "proxies")
public class Proxy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String endereco;
@Column(length = 20)
private String tipo = "residencial";
@Enumerated(EnumType.STRING)
@Column(length = 20)
private StatusProxy status = StatusProxy.ativo;
@Column(name = "total_bloqueios")
private Integer totalBloqueios = 0;
@Column(name = "em_uso")
private Boolean emUso = false;
@Column(name = "ultima_utilizacao")
private OffsetDateTime ultimaUtilizacao;
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
private OffsetDateTime criadoEm;
@OneToMany(mappedBy = "proxy")
private List<Busca> buscas = new ArrayList<>();
}
@@ -0,0 +1,53 @@
package com.seconectai.leadscraper.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@Getter
@Setter
@Entity
@Table(name = "ramos")
public class Ramo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "nome_canonico", nullable = false, unique = true, length = 100)
private String nomeCanonico;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "categoria_pai_id")
private Ramo categoriaPai;
@JdbcTypeCode(SqlTypes.JSON)
@Column(nullable = false, columnDefinition = "jsonb")
private List<String> aliases = new ArrayList<>();
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
private OffsetDateTime criadoEm;
@OneToMany(mappedBy = "categoriaPai")
private List<Ramo> subcategorias = new ArrayList<>();
@OneToMany(mappedBy = "ramo")
private List<Busca> buscas = new ArrayList<>();
@OneToMany(mappedBy = "ramo")
private List<Lead> leads = new ArrayList<>();
}
@@ -0,0 +1,8 @@
package com.seconectai.leadscraper.domain;
public enum StatusBusca {
pendente,
em_execucao,
concluida,
erro
}
@@ -0,0 +1,9 @@
package com.seconectai.leadscraper.domain;
public enum StatusLead {
novo,
contatado,
qualificado,
convertido,
descartado
}
@@ -0,0 +1,7 @@
package com.seconectai.leadscraper.domain;
public enum StatusProxy {
ativo,
degradado,
bloqueado
}
@@ -0,0 +1,41 @@
package com.seconectai.leadscraper.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.util.Objects;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "telefones")
public class Telefone {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lead_id", nullable = false)
private Lead lead;
@Column(name = "numero_e164", nullable = false, length = 20)
private String numeroE164;
@Column(length = 20)
private String tipo = "principal";
public void setLead(Lead lead) {
if (!Objects.equals(this.lead, lead)) {
this.lead = lead;
}
}
}
@@ -0,0 +1,43 @@
package com.seconectai.leadscraper.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "usuarios")
public class Usuario {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 150)
private String nome;
@Column(nullable = false, unique = true, length = 150)
private String email;
@Column(name = "senha_hash", nullable = false)
private String senhaHash;
@Column(nullable = false, length = 20)
private String papel = "admin";
@Column(name = "criado_em", nullable = false, insertable = false, updatable = false)
private OffsetDateTime criadoEm;
@OneToMany(mappedBy = "criadoPor")
private List<Busca> buscas = new ArrayList<>();
}
@@ -0,0 +1,33 @@
package com.seconectai.leadscraper.normalizer;
import org.springframework.stereotype.Component;
@Component
public class CnpjValidator {
public String normalize(String cnpjBruto) {
if (cnpjBruto == null) {
return null;
}
String digits = cnpjBruto.replaceAll("\\D", "");
if (!digits.matches("\\d{14}") || digits.chars().distinct().count() == 1) {
return null;
}
return isValid(digits) ? digits : null;
}
private boolean isValid(String cnpj) {
int firstDigit = calculateDigit(cnpj.substring(0, 12), new int[]{5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2});
int secondDigit = calculateDigit(cnpj.substring(0, 12) + firstDigit, new int[]{6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2});
return cnpj.equals(cnpj.substring(0, 12) + firstDigit + secondDigit);
}
private int calculateDigit(String base, int[] weights) {
int sum = 0;
for (int index = 0; index < weights.length; index++) {
sum += Character.digit(base.charAt(index), 10) * weights[index];
}
int remainder = sum % 11;
return remainder < 2 ? 0 : 11 - remainder;
}
}
@@ -0,0 +1,29 @@
package com.seconectai.leadscraper.normalizer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import org.springframework.stereotype.Component;
@Component
public class DedupHashCalculator {
public String calculate(String nomeNormalizado, String telefoneE164) {
String input = nullToEmpty(nomeNormalizado) + "|" + nullToEmpty(telefoneE164);
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte value : hash) {
builder.append(String.format("%02x", value));
}
return builder.toString();
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 indisponivel", exception);
}
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
}
@@ -0,0 +1,15 @@
package com.seconectai.leadscraper.normalizer;
public record LeadNormalizadoDTO(
String nomeFantasia,
String categoriaBruta,
Long ramoId,
String telefoneE164,
String telefoneBruto,
String cnpj,
String site,
String enderecoCompleto,
String placeId,
String hashDedup
) {
}
@@ -0,0 +1,49 @@
package com.seconectai.leadscraper.normalizer;
import com.seconectai.leadscraper.scraper.LeadBrutoDTO;
import org.springframework.stereotype.Service;
@Service
public class LeadNormalizerService {
private final NomeNormalizer nomeNormalizer;
private final TelefoneNormalizer telefoneNormalizer;
private final CnpjValidator cnpjValidator;
private final RamoMapper ramoMapper;
private final DedupHashCalculator dedupHashCalculator;
public LeadNormalizerService(
NomeNormalizer nomeNormalizer,
TelefoneNormalizer telefoneNormalizer,
CnpjValidator cnpjValidator,
RamoMapper ramoMapper,
DedupHashCalculator dedupHashCalculator
) {
this.nomeNormalizer = nomeNormalizer;
this.telefoneNormalizer = telefoneNormalizer;
this.cnpjValidator = cnpjValidator;
this.ramoMapper = ramoMapper;
this.dedupHashCalculator = dedupHashCalculator;
}
public LeadNormalizadoDTO normalize(LeadBrutoDTO leadBruto) {
String nome = nomeNormalizer.normalize(leadBruto.nome());
String telefoneE164 = telefoneNormalizer.normalize(leadBruto.telefoneBruto());
String cnpj = cnpjValidator.normalize(null);
Long ramoId = ramoMapper.mapToRamoId(leadBruto.categoriaBruta()).orElse(null);
String hashDedup = dedupHashCalculator.calculate(nome, telefoneE164);
return new LeadNormalizadoDTO(
nome,
leadBruto.categoriaBruta(),
ramoId,
telefoneE164,
leadBruto.telefoneBruto(),
cnpj,
leadBruto.site(),
leadBruto.enderecoBruto(),
leadBruto.placeId(),
hashDedup
);
}
}
@@ -0,0 +1,54 @@
package com.seconectai.leadscraper.normalizer;
import java.text.Normalizer;
import java.util.Locale;
import java.util.Set;
import org.springframework.stereotype.Component;
@Component
public class NomeNormalizer {
private static final Set<String> SIGLAS = Set.of("LTDA", "ME", "EIRELI", "S/A", "S.A.");
public String normalize(String nome) {
if (nome == null) {
return null;
}
String cleaned = nome.codePoints()
.filter(this::isAllowedTextCodePoint)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
.toString()
.trim()
.replaceAll("\\s+", " ");
if (cleaned.isBlank()) {
return null;
}
String[] parts = cleaned.split(" ");
for (int index = 0; index < parts.length; index++) {
parts[index] = normalizeWord(parts[index]);
}
return String.join(" ", parts);
}
private String normalizeWord(String word) {
String upper = word.toUpperCase(Locale.ROOT);
if (SIGLAS.contains(upper)) {
return upper;
}
if (word.length() == 1) {
return word.toUpperCase(Locale.ROOT);
}
return word.substring(0, 1).toUpperCase(Locale.ROOT)
+ word.substring(1).toLowerCase(Locale.ROOT);
}
private boolean isAllowedTextCodePoint(int codePoint) {
int type = Character.getType(codePoint);
if (type == Character.CONTROL || type == Character.FORMAT || type == Character.SURROGATE) {
return false;
}
String value = new String(Character.toChars(codePoint));
return Normalizer.normalize(value, Normalizer.Form.NFKC).codePoints()
.noneMatch(cp -> Character.getType(cp) == Character.OTHER_SYMBOL);
}
}
@@ -0,0 +1,49 @@
package com.seconectai.leadscraper.normalizer;
import com.seconectai.leadscraper.domain.Ramo;
import com.seconectai.leadscraper.repository.RamoRepository;
import java.text.Normalizer;
import java.util.Locale;
import java.util.Optional;
import org.springframework.stereotype.Component;
@Component
public class RamoMapper {
private final RamoRepository ramoRepository;
public RamoMapper(RamoRepository ramoRepository) {
this.ramoRepository = ramoRepository;
}
public Optional<Long> mapToRamoId(String categoriaBruta) {
String normalized = normalize(categoriaBruta);
if (normalized == null) {
return Optional.empty();
}
return ramoRepository.findAll().stream()
.filter(ramo -> aliasesContain(ramo, normalized))
.map(Ramo::getId)
.findFirst();
}
private boolean aliasesContain(Ramo ramo, String categoriaNormalizada) {
if (normalize(ramo.getNomeCanonico()).equals(categoriaNormalizada)) {
return true;
}
return ramo.getAliases() != null && ramo.getAliases().stream()
.map(this::normalize)
.anyMatch(categoriaNormalizada::equals);
}
private String normalize(String value) {
if (value == null) {
return null;
}
String normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFD)
.replaceAll("\\p{M}", "")
.replaceAll("\\s+", " ")
.toLowerCase(Locale.ROOT);
return normalized.isBlank() ? null : normalized;
}
}
@@ -0,0 +1,29 @@
package com.seconectai.leadscraper.normalizer;
import org.springframework.stereotype.Component;
@Component
public class TelefoneNormalizer {
public String normalize(String telefoneBruto) {
if (telefoneBruto == null) {
return null;
}
String digits = telefoneBruto.replaceAll("\\D", "");
if (digits.startsWith("00")) {
digits = digits.substring(2);
}
if (digits.startsWith("55")) {
digits = digits.substring(2);
}
if (!digits.matches("\\d{10,11}")) {
return null;
}
String ddd = digits.substring(0, 2);
String numero = digits.substring(2);
if (!ddd.matches("[1-9]{2}") || !(numero.length() == 8 || numero.length() == 9)) {
return null;
}
return "+55" + ddd + numero;
}
}
@@ -0,0 +1,30 @@
package com.seconectai.leadscraper.proxy;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.stereotype.Service;
@Service
public class BackoffService {
private final ProxySettingsProperties properties;
private final AtomicInteger consecutiveBlocks = new AtomicInteger();
public BackoffService(ProxySettingsProperties properties) {
this.properties = properties;
}
public long registrarBloqueioECalcularEspera() {
int failures = consecutiveBlocks.incrementAndGet();
double multiplier = Math.pow(properties.backoff().multiplier(), failures - 1);
long calculated = (long) (properties.backoff().initialMs() * multiplier);
return Math.min(calculated, properties.backoff().maxMs());
}
public void registrarSucesso() {
consecutiveBlocks.set(0);
}
public int limiteBuscasHoraPorProxy() {
return properties.limiteBuscasHora();
}
}
@@ -0,0 +1,45 @@
package com.seconectai.leadscraper.proxy;
import com.microsoft.playwright.Page;
import com.seconectai.leadscraper.domain.Proxy;
import com.seconectai.leadscraper.domain.StatusProxy;
import com.seconectai.leadscraper.repository.ProxyRepository;
import java.util.Locale;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BloqueioDetector {
private final ProxyRepository proxyRepository;
private final ProxySettingsProperties properties;
public BloqueioDetector(ProxyRepository proxyRepository, ProxySettingsProperties properties) {
this.proxyRepository = proxyRepository;
this.properties = properties;
}
public boolean isBlocked(Page page) {
String content = page.content().toLowerCase(Locale.ROOT);
return content.contains("unusual traffic")
|| content.contains("trafego incomum")
|| content.contains("tráfego incomum")
|| content.contains("captcha")
|| content.contains("/sorry/");
}
@Transactional
public void registrarBloqueio(Proxy proxy) {
if (proxy == null || proxy.getId() == null) {
return;
}
proxyRepository.findById(proxy.getId()).ifPresent(atual -> {
int total = atual.getTotalBloqueios() == null ? 1 : atual.getTotalBloqueios() + 1;
atual.setTotalBloqueios(total);
atual.setEmUso(false);
atual.setStatus(total >= properties.maxBloqueios() ? StatusProxy.bloqueado : StatusProxy.degradado);
proxyRepository.save(atual);
});
}
}
@@ -0,0 +1,8 @@
package com.seconectai.leadscraper.proxy;
public class BloqueioException extends RuntimeException {
public BloqueioException(String message) {
super(message);
}
}
@@ -0,0 +1,41 @@
package com.seconectai.leadscraper.proxy;
import com.seconectai.leadscraper.domain.Proxy;
import com.seconectai.leadscraper.repository.ProxyRepository;
import java.time.OffsetDateTime;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProxyPoolService {
private final ProxyRepository proxyRepository;
public ProxyPoolService(ProxyRepository proxyRepository) {
this.proxyRepository = proxyRepository;
}
@Transactional
public Optional<Proxy> reservarDisponivel() {
Optional<Proxy> proxy = proxyRepository.findNextAvailableForUpdate();
proxy.ifPresent(disponivel -> {
disponivel.setEmUso(true);
proxyRepository.save(disponivel);
});
return proxy;
}
@Transactional
public void liberar(Proxy proxy) {
if (proxy == null || proxy.getId() == null) {
return;
}
proxyRepository.findById(proxy.getId()).ifPresent(atual -> {
atual.setEmUso(false);
atual.setUltimaUtilizacao(OffsetDateTime.now());
proxyRepository.save(atual);
});
}
}
@@ -0,0 +1,18 @@
package com.seconectai.leadscraper.proxy;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "scraper.proxy")
public record ProxySettingsProperties(
int maxBloqueios,
int limiteBuscasHora,
Backoff backoff
) {
public record Backoff(
long initialMs,
long maxMs,
double multiplier
) {
}
}
@@ -0,0 +1,12 @@
package com.seconectai.leadscraper.repository;
import com.seconectai.leadscraper.domain.Busca;
import com.seconectai.leadscraper.domain.StatusBusca;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface BuscaRepository extends JpaRepository<Busca, Long>, JpaSpecificationExecutor<Busca> {
List<Busca> findByStatus(StatusBusca status);
}
@@ -0,0 +1,10 @@
package com.seconectai.leadscraper.repository;
import com.seconectai.leadscraper.domain.HistoricoContato;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface HistoricoContatoRepository extends JpaRepository<HistoricoContato, Long> {
List<HistoricoContato> findByLeadId(Long leadId);
}
@@ -0,0 +1,32 @@
package com.seconectai.leadscraper.repository;
import com.seconectai.leadscraper.domain.Lead;
import com.seconectai.leadscraper.domain.StatusLead;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
public interface LeadRepository extends JpaRepository<Lead, Long>, JpaSpecificationExecutor<Lead> {
Optional<Lead> findByHashDedup(String hashDedup);
Optional<Lead> findByCnpj(String cnpj);
Optional<Lead> findByPlaceId(String placeId);
List<Lead> findByStatus(StatusLead status);
List<Lead> findByBuscaId(Long buscaId);
long countByBuscaId(Long buscaId);
long countByTelefoneE164IsNotNull();
long countByBuscaIdAndTelefoneE164IsNotNull(Long buscaId);
@Query("select count(distinct lead.telefoneE164) from Lead lead where lead.telefoneE164 is not null and lead.telefoneE164 <> ''")
long countDistinctTelefonesCapturados();
}
@@ -0,0 +1,23 @@
package com.seconectai.leadscraper.repository;
import com.seconectai.leadscraper.domain.Proxy;
import com.seconectai.leadscraper.domain.StatusProxy;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface ProxyRepository extends JpaRepository<Proxy, Long> {
List<Proxy> findByStatus(StatusProxy status);
@Query(value = """
select * from proxies
where status = 'ativo'
and coalesce(em_uso, false) = false
order by ultima_utilizacao asc nulls first, id asc
for update skip locked
limit 1
""", nativeQuery = true)
Optional<Proxy> findNextAvailableForUpdate();
}
@@ -0,0 +1,10 @@
package com.seconectai.leadscraper.repository;
import com.seconectai.leadscraper.domain.Ramo;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RamoRepository extends JpaRepository<Ramo, Long> {
Optional<Ramo> findByNomeCanonico(String nomeCanonico);
}
@@ -0,0 +1,10 @@
package com.seconectai.leadscraper.repository;
import com.seconectai.leadscraper.domain.Telefone;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TelefoneRepository extends JpaRepository<Telefone, Long> {
List<Telefone> findByLeadId(Long leadId);
}
@@ -0,0 +1,10 @@
package com.seconectai.leadscraper.repository;
import com.seconectai.leadscraper.domain.Usuario;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UsuarioRepository extends JpaRepository<Usuario, Long> {
Optional<Usuario> findByEmail(String email);
}
@@ -0,0 +1,194 @@
package com.seconectai.leadscraper.scraper;
import com.seconectai.leadscraper.domain.Busca;
import com.seconectai.leadscraper.domain.Proxy;
import com.seconectai.leadscraper.proxy.BackoffService;
import com.seconectai.leadscraper.proxy.BloqueioDetector;
import com.seconectai.leadscraper.proxy.BloqueioException;
import com.seconectai.leadscraper.proxy.ProxyPoolService;
import com.seconectai.leadscraper.service.BuscaProgressService;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
@Service
public class BuscaOrchestrator {
private static final Logger log = LoggerFactory.getLogger(BuscaOrchestrator.class);
private final GoogleMapsScraperService googleMapsScraperService;
private final ScrollExtractorService scrollExtractorService;
private final ProxyPoolService proxyPoolService;
private final BloqueioDetector bloqueioDetector;
private final BackoffService backoffService;
private final ScraperFingerprintService fingerprintService;
private final OverpassScraperService overpassScraperService;
private final NeighborhoodExpansionService neighborhoodExpansionService;
private final BuscaProgressService buscaProgressService;
public BuscaOrchestrator(
GoogleMapsScraperService googleMapsScraperService,
ScrollExtractorService scrollExtractorService,
ProxyPoolService proxyPoolService,
BloqueioDetector bloqueioDetector,
BackoffService backoffService,
ScraperFingerprintService fingerprintService,
OverpassScraperService overpassScraperService,
NeighborhoodExpansionService neighborhoodExpansionService,
BuscaProgressService buscaProgressService
) {
this.googleMapsScraperService = googleMapsScraperService;
this.scrollExtractorService = scrollExtractorService;
this.proxyPoolService = proxyPoolService;
this.bloqueioDetector = bloqueioDetector;
this.backoffService = backoffService;
this.fingerprintService = fingerprintService;
this.overpassScraperService = overpassScraperService;
this.neighborhoodExpansionService = neighborhoodExpansionService;
this.buscaProgressService = buscaProgressService;
}
public List<LeadBrutoDTO> executar(Busca busca) {
Assert.notNull(busca, "busca nao pode ser nula");
Assert.hasText(busca.getTermoBusca(), "termoBusca e obrigatorio");
Assert.hasText(busca.getLocalizacao(), "localizacao e obrigatoria");
Optional<Proxy> proxyReservado = proxyPoolService.reservarDisponivel();
proxyReservado.ifPresent(busca::setProxy);
try {
List<LeadBrutoDTO> googleLeads = new ArrayList<>();
List<String> googleErrors = new ArrayList<>();
List<String> regioes = neighborhoodExpansionService.locationsFor(
busca.getLocalizacao(),
busca.getExpandirPorBairros() == null || busca.getExpandirPorBairros()
);
List<String> detalhesRegioes = new ArrayList<>();
int regioesSucesso = 0;
int regioesErro = 0;
busca.setRegioesTotal(regioes.size());
busca.setRegioesSucesso(0);
busca.setRegioesErro(0);
busca.setDetalheRegioes(null);
if (busca.getId() != null) {
buscaProgressService.iniciarRegioes(busca.getId(), regioes.size());
}
for (String localizacao : regioes) {
try {
List<LeadBrutoDTO> leadsRegiao = executarGoogleMaps(busca, localizacao, proxyReservado);
googleLeads.addAll(leadsRegiao);
regioesSucesso++;
String detalhe = "OK | " + localizacao + " | " + leadsRegiao.size() + " retornos brutos";
detalhesRegioes.add(detalhe);
if (busca.getId() != null) {
buscaProgressService.registrarRegiao(busca.getId(), true, detalhe);
}
} catch (RuntimeException ex) {
String erro = shortMessage(ex);
regioesErro++;
googleErrors.add(localizacao + ": " + erro);
String detalhe = "ERRO | " + localizacao + " | " + erro;
detalhesRegioes.add(detalhe);
if (busca.getId() != null) {
buscaProgressService.registrarRegiao(busca.getId(), false, detalhe);
}
log.warn("Falha ao buscar Google Maps em {}: {}", localizacao, ex.getMessage());
}
}
busca.setRegioesSucesso(regioesSucesso);
busca.setRegioesErro(regioesErro);
busca.setDetalheRegioes(String.join("\n", detalhesRegioes));
List<LeadBrutoDTO> leads = mergeLeads(
googleLeads,
overpassScraperService.buscar(busca.getTermoBusca(), busca.getLocalizacao())
);
busca.setTotalEncontrados(leads.size());
if (!googleErrors.isEmpty()) {
busca.setDetalheErro("Google Maps falhou em algumas regioes; resultados consolidados com fontes disponiveis. "
+ String.join(" | ", googleErrors));
}
if (leads.isEmpty() && !googleErrors.isEmpty()) {
throw new IllegalStateException(busca.getDetalheErro());
}
backoffService.registrarSucesso();
return leads;
} catch (RuntimeException ex) {
if (busca.getDetalheErro() == null || busca.getDetalheErro().isBlank()) {
busca.setDetalheErro(ex.getMessage());
}
throw ex;
} finally {
proxyReservado.ifPresent(proxyPoolService::liberar);
}
}
private List<LeadBrutoDTO> executarGoogleMaps(Busca busca, String localizacao, Optional<Proxy> proxyReservado) {
try (ScraperSession session = googleMapsScraperService.openSearch(
busca.getTermoBusca(),
localizacao,
proxyReservado.orElse(null),
fingerprintService.next()
)) {
validarBloqueio(session, proxyReservado, busca);
googleMapsScraperService.waitForResults(session.page());
validarBloqueio(session, proxyReservado, busca);
return scrollExtractorService.scrollAndExtractUntilExhausted(session.page());
}
}
private void validarBloqueio(ScraperSession session, Optional<Proxy> proxyReservado, Busca busca) {
if (bloqueioDetector.isBlocked(session.page())) {
proxyReservado.ifPresent(bloqueioDetector::registrarBloqueio);
long esperaMs = backoffService.registrarBloqueioECalcularEspera();
busca.setDetalheErro("Bloqueio detectado pelo Google Maps. Proxima tentativa em " + esperaMs + "ms.");
throw new BloqueioException(busca.getDetalheErro());
}
}
private List<LeadBrutoDTO> mergeLeads(List<LeadBrutoDTO> googleLeads, List<LeadBrutoDTO> overpassLeads) {
Map<String, LeadBrutoDTO> merged = new LinkedHashMap<>();
addAll(merged, googleLeads);
addAll(merged, overpassLeads);
return new ArrayList<>(merged.values());
}
private void addAll(Map<String, LeadBrutoDTO> merged, List<LeadBrutoDTO> leads) {
for (LeadBrutoDTO lead : leads) {
merged.putIfAbsent(dedupKey(lead), lead);
}
}
private String dedupKey(LeadBrutoDTO lead) {
if (lead.placeId() != null && !lead.placeId().isBlank()) {
return "place:" + lead.placeId();
}
return "lead:%s|%s|%s".formatted(
normalize(lead.nome()),
normalize(lead.telefoneBruto()),
normalize(lead.enderecoBruto())
);
}
private String normalize(String value) {
return value == null ? "" : value.trim().toLowerCase();
}
private String shortMessage(RuntimeException ex) {
String message = ex.getMessage();
if (message == null || message.isBlank()) {
return ex.getClass().getSimpleName();
}
String singleLine = message.replaceAll("\\s+", " ").trim();
return singleLine.length() > 180 ? singleLine.substring(0, 180) + "..." : singleLine;
}
}
@@ -0,0 +1,30 @@
package com.seconectai.leadscraper.scraper;
import com.microsoft.playwright.Page;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.stereotype.Service;
@Service
public class DelayHumanizadoService {
private final GoogleMapsScraperProperties properties;
public DelayHumanizadoService(GoogleMapsScraperProperties properties) {
this.properties = properties;
}
public void aguardarEntreAcoes(Page page) {
page.waitForTimeout(randomBetween(properties.delays().minMs(), properties.delays().maxMs()));
}
public int proximoPassoScroll() {
return randomBetween(properties.scroll().minPixels(), properties.scroll().maxPixels());
}
private int randomBetween(int min, int max) {
if (max <= min) {
return min;
}
return ThreadLocalRandom.current().nextInt(min, max + 1);
}
}
@@ -0,0 +1,27 @@
package com.seconectai.leadscraper.scraper;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "scraper.google-maps")
public record GoogleMapsScraperProperties(
String baseUrl,
boolean headless,
double timeoutMs,
int maxResults,
Scroll scroll,
Delays delays
) {
public record Scroll(
int stagnantAttempts,
int minPixels,
int maxPixels
) {
}
public record Delays(
int minMs,
int maxMs
) {
}
}
@@ -0,0 +1,73 @@
package com.seconectai.leadscraper.scraper;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.options.WaitUntilState;
import com.seconectai.leadscraper.domain.Proxy;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.stereotype.Service;
@Service
public class GoogleMapsScraperService {
private static final String RESULTS_SELECTOR = "[role='feed'], div[aria-label][role='main']";
private final GoogleMapsScraperProperties properties;
public GoogleMapsScraperService(GoogleMapsScraperProperties properties) {
this.properties = properties;
}
public ScraperSession openSearch(
String termoBusca,
String localizacao,
Proxy proxy,
ScraperFingerprint fingerprint
) {
Playwright playwright = Playwright.create();
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
.setHeadless(properties.headless());
browserExecutablePath().ifPresent(launchOptions::setExecutablePath);
if (proxy != null && proxy.getEndereco() != null && !proxy.getEndereco().isBlank()) {
launchOptions.setProxy(new com.microsoft.playwright.options.Proxy(proxy.getEndereco()));
}
Browser browser = playwright.chromium().launch(launchOptions);
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setLocale("pt-BR")
.setTimezoneId(fingerprint.timezoneId())
.setUserAgent(fingerprint.userAgent())
.setViewportSize(fingerprint.viewportWidth(), fingerprint.viewportHeight()));
Page page = context.newPage();
page.setDefaultTimeout(properties.timeoutMs());
page.navigate(buildSearchUrl(termoBusca, localizacao),
new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
return new ScraperSession(playwright, browser, context, page);
}
public void waitForResults(Page page) {
page.waitForSelector(RESULTS_SELECTOR, new Page.WaitForSelectorOptions().setTimeout(properties.timeoutMs()));
}
private String buildSearchUrl(String termoBusca, String localizacao) {
String query = URLEncoder.encode(termoBusca + " " + localizacao, StandardCharsets.UTF_8);
return properties.baseUrl() + "/search/" + query;
}
private java.util.Optional<Path> browserExecutablePath() {
String configured = System.getenv("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH");
if (configured != null && !configured.isBlank()) {
Path path = Path.of(configured);
if (Files.exists(path)) {
return java.util.Optional.of(path);
}
}
Path dockerPath = Path.of("/ms-playwright/chromium-1140/chrome-linux/chrome");
return Files.exists(dockerPath) ? java.util.Optional.of(dockerPath) : java.util.Optional.empty();
}
}
@@ -0,0 +1,13 @@
package com.seconectai.leadscraper.scraper;
public record LeadBrutoDTO(
String nome,
String categoriaBruta,
String enderecoBruto,
String telefoneBruto,
String site,
String placeId,
String avaliacao,
String profileUrl
) {
}
@@ -0,0 +1,240 @@
package com.seconectai.leadscraper.scraper;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
@Service
public class LeadRawExtractor {
private static final String CARD_LINK_SELECTOR = "a[href*='/maps/place/']";
private static final Pattern PHONE_PATTERN = Pattern.compile("(?:\\+?55\\s*)?(?:\\(?\\d{2}\\)?\\s*)?9?\\d{4}[-\\s]?\\d{4}");
private static final Pattern RATING_PATTERN = Pattern.compile("\\b\\d(?:[,.]\\d)?\\b");
private static final Pattern PLACE_ID_PATTERN = Pattern.compile("!1s([^!]+)");
public List<LeadBrutoDTO> extract(Page page) {
Locator links = page.locator(CARD_LINK_SELECTOR);
int total = links.count();
List<LeadBrutoDTO> leads = new ArrayList<>();
for (int index = 0; index < total; index++) {
try {
Locator link = links.nth(index);
String href = link.getAttribute("href");
String ariaLabel = clean(link.getAttribute("aria-label"));
String cardText = clean(cardText(link));
LeadBrutoDTO lead = toLeadBruto(href, ariaLabel, cardText);
if (lead != null) {
leads.add(lead);
}
} catch (RuntimeException ignored) {
// A single unstable card should not abort the whole extraction.
}
}
return leads;
}
LeadBrutoDTO toLeadBruto(String href, String ariaLabel, String cardText) {
String nome = firstNonBlank(clean(ariaLabel), firstLine(cardText));
if (nome == null) {
return null;
}
return new LeadBrutoDTO(
nome,
inferCategoria(cardText),
inferEndereco(cardText),
inferTelefone(cardText),
inferSite(cardText),
extractPlaceId(href),
inferAvaliacao(cardText),
href
);
}
public LeadBrutoDTO enrichFromPlaceDetails(Page page, LeadBrutoDTO lead) {
String detailsText = clean(detailsText(page));
if (detailsText == null) {
return lead;
}
return new LeadBrutoDTO(
lead.nome(),
firstNonBlank(lead.categoriaBruta(), inferCategoria(detailsText)),
firstNonBlank(lead.enderecoBruto(), inferEndereco(detailsText)),
firstNonBlank(lead.telefoneBruto(), inferTelefone(detailsText)),
firstNonBlank(lead.site(), inferSite(detailsText)),
lead.placeId(),
firstNonBlank(lead.avaliacao(), inferAvaliacao(detailsText)),
lead.profileUrl()
);
}
private String detailsText(Page page) {
return (String) page.locator("body").evaluate("""
body => {
const seen = new Set();
const parts = [];
const push = value => {
const text = (value || '').replace(/\s+/g, ' ').trim();
if (!text || seen.has(text)) return;
seen.add(text);
parts.push(text);
};
push(body.innerText);
body.querySelectorAll('[aria-label], [data-tooltip], [data-item-id], a[href], button').forEach(node => {
push(node.getAttribute('aria-label'));
push(node.getAttribute('data-tooltip'));
push(node.getAttribute('data-item-id'));
push(node.textContent);
const href = node.getAttribute('href');
if (href && href.startsWith('tel:')) push(href.replace(/^tel:/, ''));
});
return parts.join('\\n');
}
""");
}
private String cardText(Locator link) {
return (String) link.evaluate("""
element => {
const card = element.closest('[role="article"]') || element.closest('.Nv2PK') || element.parentElement;
const root = card || element;
const seen = new Set();
const parts = [];
const push = value => {
const text = (value || '').replace(/\\s+/g, ' ').trim();
if (!text || seen.has(text)) return;
seen.add(text);
parts.push(text);
};
push(root.getAttribute('aria-label'));
push(root.innerText);
root.querySelectorAll('[aria-label], [data-tooltip], a[href], button').forEach(node => {
push(node.getAttribute('aria-label'));
push(node.getAttribute('data-tooltip'));
push(node.textContent);
const href = node.getAttribute('href');
if (href && href.startsWith('tel:')) push(href.replace(/^tel:/, ''));
});
return parts.join('\\n');
}
""");
}
private String inferCategoria(String cardText) {
if (cardText == null) {
return null;
}
String[] lines = cardText.split("\\R");
for (int index = 1; index < lines.length; index++) {
String line = lines[index];
String cleaned = clean(line);
if (cleaned != null && !looksLikePhone(cleaned) && !looksLikeAddress(cleaned) && !looksLikeRating(cleaned)) {
return cleaned;
}
}
return null;
}
private String inferEndereco(String cardText) {
if (cardText == null) {
return null;
}
for (String line : cardText.split("\\R")) {
String cleaned = clean(line);
if (looksLikeAddress(cleaned)) {
return cleaned;
}
}
return null;
}
private String inferTelefone(String cardText) {
if (cardText == null) {
return null;
}
Matcher matcher = PHONE_PATTERN.matcher(cardText);
return matcher.find() ? clean(matcher.group()) : null;
}
private String inferSite(String cardText) {
if (cardText == null) {
return null;
}
for (String line : cardText.split("\\R")) {
String cleaned = clean(line);
if (cleaned != null && (cleaned.startsWith("http") || cleaned.matches("(?i).+\\.(com|com\\.br|br|net|org).*"))) {
return cleaned;
}
}
return null;
}
private String inferAvaliacao(String cardText) {
if (cardText == null) {
return null;
}
Matcher matcher = RATING_PATTERN.matcher(cardText);
return matcher.find() ? matcher.group().replace(',', '.') : null;
}
private String extractPlaceId(String href) {
if (href == null) {
return null;
}
Matcher matcher = PLACE_ID_PATTERN.matcher(href);
if (matcher.find()) {
return URLDecoder.decode(matcher.group(1), StandardCharsets.UTF_8);
}
int placeIndex = href.indexOf("/maps/place/");
if (placeIndex < 0) {
return null;
}
String slug = href.substring(placeIndex + "/maps/place/".length());
int nextSlash = slug.indexOf('/');
if (nextSlash >= 0) {
slug = slug.substring(0, nextSlash);
}
return URLDecoder.decode(slug, StandardCharsets.UTF_8);
}
private String firstLine(String value) {
if (value == null) {
return null;
}
return clean(value.split("\\R", 2)[0]);
}
private String firstNonBlank(String first, String second) {
return first != null ? first : second;
}
private boolean looksLikePhone(String value) {
return value != null && PHONE_PATTERN.matcher(value).find();
}
private boolean looksLikeAddress(String value) {
return value != null && value.matches("(?i).*(rua|av\\.?|avenida|rodovia|estrada|praça|r\\.).*\\d+.*");
}
private boolean looksLikeRating(String value) {
return value != null && value.matches(".*\\d[,.]\\d.*");
}
private String clean(String value) {
if (value == null) {
return null;
}
String cleaned = value.replace('\u00a0', ' ').trim().replaceAll("\\s+", " ");
return cleaned.isBlank() ? null : cleaned;
}
}
@@ -0,0 +1,135 @@
package com.seconectai.leadscraper.scraper;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class NeighborhoodExpansionService {
private static final Map<String, List<String>> PRESETS = Map.of(
"sao paulo, sp", List.of(
"Centro, Sao Paulo, SP",
"Pinheiros, Sao Paulo, SP",
"Vila Madalena, Sao Paulo, SP",
"Jardins, Sao Paulo, SP",
"Itaim Bibi, Sao Paulo, SP",
"Moema, Sao Paulo, SP",
"Vila Mariana, Sao Paulo, SP",
"Tatuape, Sao Paulo, SP",
"Santana, Sao Paulo, SP",
"Lapa, Sao Paulo, SP",
"Barra Funda, Sao Paulo, SP",
"Santo Amaro, Sao Paulo, SP",
"Brooklin, Sao Paulo, SP",
"Morumbi, Sao Paulo, SP",
"Butanta, Sao Paulo, SP",
"Ipiranga, Sao Paulo, SP",
"Mooca, Sao Paulo, SP",
"Perdizes, Sao Paulo, SP",
"Vila Olimpia, Sao Paulo, SP",
"Bela Vista, Sao Paulo, SP"
),
"campinas, sp", List.of(
"Cambui, Campinas, SP",
"Centro, Campinas, SP",
"Taquaral, Campinas, SP",
"Barao Geraldo, Campinas, SP",
"Sousas, Campinas, SP",
"Nova Campinas, Campinas, SP",
"Jardim Chapadao, Campinas, SP",
"Catedral, Campinas, SP"
),
"londrina, pr", List.of(
"Centro, Londrina, PR",
"Gleba Palhano, Londrina, PR",
"Jardim Higienopolis, Londrina, PR",
"Bandeirantes, Londrina, PR",
"Vila Brasil, Londrina, PR",
"Jardim Quebec, Londrina, PR",
"Zona Norte, Londrina, PR",
"Zona Sul, Londrina, PR"
)
);
private static final List<String> FALLBACK_BAIRROS_BRASIL = List.of(
"Centro",
"Zona Norte",
"Zona Sul",
"Zona Leste",
"Zona Oeste",
"Setor Central",
"Jardim",
"Vila",
"Industrial",
"Comercial"
);
public List<String> locationsFor(String localizacao, boolean expanded) {
if (!expanded) {
return List.of(localizacao);
}
String normalized = normalize(localizacao);
List<String> preset = PRESETS.get(normalized);
if (preset != null) {
return preset;
}
LocationParts parts = parseLocation(localizacao);
if (parts == null) {
return List.of(localizacao);
}
List<String> regioes = new ArrayList<>();
for (String bairro : FALLBACK_BAIRROS_BRASIL) {
regioes.add("%s, %s, %s".formatted(bairro, parts.cidade(), parts.uf()));
}
return regioes;
}
private LocationParts parseLocation(String localizacao) {
if (!StringUtils.hasText(localizacao)) {
return null;
}
String cleaned = localizacao.replaceAll("\\s+", " ").trim();
String[] commaParts = cleaned.split(",");
if (commaParts.length >= 2) {
String cidade = commaParts[0].trim();
String uf = commaParts[1].trim().toUpperCase(Locale.ROOT);
if (StringUtils.hasText(cidade) && uf.matches("[A-Z]{2}")) {
return new LocationParts(cidade, uf);
}
}
String[] parts = cleaned.split(" ");
if (parts.length >= 2) {
String uf = parts[parts.length - 1].trim().toUpperCase(Locale.ROOT);
if (uf.matches("[A-Z]{2}")) {
String cidade = cleaned.substring(0, cleaned.length() - parts[parts.length - 1].length()).trim().replaceAll(",+$", "").trim();
if (StringUtils.hasText(cidade)) {
return new LocationParts(cidade, uf);
}
}
}
return null;
}
private record LocationParts(String cidade, String uf) {
}
private String normalize(String value) {
if (!StringUtils.hasText(value)) {
return "";
}
return Normalizer.normalize(value, Normalizer.Form.NFD)
.replaceAll("\\p{M}", "")
.toLowerCase(Locale.ROOT)
.replaceAll("\\s+", " ")
.trim();
}
}
@@ -0,0 +1,12 @@
package com.seconectai.leadscraper.scraper;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "scraper.overpass")
public record OverpassScraperProperties(
boolean enabled,
String endpoint,
int timeoutSeconds,
int maxResults
) {
}
@@ -0,0 +1,234 @@
package com.seconectai.leadscraper.scraper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class OverpassScraperService {
private static final Map<String, String> TERM_TAG_FILTERS = Map.ofEntries(
Map.entry("advocacia", """
nwr["office"="lawyer"](area.searchArea);
nwr["name"~"advocacia|advogado|advogada|advogados|juridico|juridica", i](area.searchArea);
"""),
Map.entry("advogado", """
nwr["office"="lawyer"](area.searchArea);
nwr["name"~"advocacia|advogado|advogada|advogados|juridico|juridica", i](area.searchArea);
"""),
Map.entry("contabilidade", """
nwr["office"="accountant"](area.searchArea);
nwr["name"~"contabilidade|contador|contabil|contabeis", i](area.searchArea);
"""),
Map.entry("dentista", """
nwr["amenity"="dentist"](area.searchArea);
nwr["healthcare"="dentist"](area.searchArea);
nwr["name"~"dentista|odontologia|odontologico|odontologica", i](area.searchArea);
"""),
Map.entry("odontologia", """
nwr["amenity"="dentist"](area.searchArea);
nwr["healthcare"="dentist"](area.searchArea);
nwr["name"~"dentista|odontologia|odontologico|odontologica", i](area.searchArea);
"""),
Map.entry("farmacia", """
nwr["amenity"="pharmacy"](area.searchArea);
nwr["shop"="chemist"](area.searchArea);
nwr["name"~"farmacia|drogaria", i](area.searchArea);
"""),
Map.entry("restaurante", """
nwr["amenity"="restaurant"](area.searchArea);
nwr["name"~"restaurante|restaurant", i](area.searchArea);
"""),
Map.entry("restaurantes", """
nwr["amenity"="restaurant"](area.searchArea);
nwr["name"~"restaurante|restaurant", i](area.searchArea);
"""),
Map.entry("padaria", """
nwr["shop"="bakery"](area.searchArea);
nwr["name"~"padaria|panificadora|bakery", i](area.searchArea);
"""),
Map.entry("pizzaria", """
nwr["cuisine"="pizza"](area.searchArea);
nwr["name"~"pizzaria|pizza", i](area.searchArea);
""")
);
private final OverpassScraperProperties properties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
public OverpassScraperService(OverpassScraperProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(properties.timeoutSeconds()))
.build();
}
public List<LeadBrutoDTO> buscar(String termoBusca, String localizacao) {
if (!properties.enabled()) {
return List.of();
}
LocationParts location = LocationParts.parse(localizacao);
if (!StringUtils.hasText(location.city())) {
return List.of();
}
try {
String query = buildQuery(termoBusca, location);
HttpRequest request = HttpRequest.newBuilder(URI.create(properties.endpoint()))
.timeout(Duration.ofSeconds(properties.timeoutSeconds() + 10L))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString("data=" + URLEncoder.encode(query, StandardCharsets.UTF_8)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
return List.of();
}
return parseElements(response.body(), location);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return List.of();
} catch (RuntimeException | java.io.IOException ex) {
return List.of();
}
}
private String buildQuery(String termoBusca, LocationParts location) {
String term = normalizeTerm(termoBusca);
String filters = TERM_TAG_FILTERS.getOrDefault(term, genericNameFilter(termoBusca));
return """
[out:json][timeout:%d];
area["boundary"="administrative"]["name"="%s"]->.searchArea;
(
%s
);
out center tags %d;
""".formatted(
properties.timeoutSeconds(),
escapeOverpassString(location.city()),
filters,
properties.maxResults()
);
}
private String genericNameFilter(String termoBusca) {
String regex = Pattern.quote(StringUtils.hasText(termoBusca) ? termoBusca.trim() : "");
return "nwr[\"name\"~\"" + escapeOverpassString(regex) + "\", i](area.searchArea);";
}
private List<LeadBrutoDTO> parseElements(String responseBody, LocationParts location) throws java.io.IOException {
JsonNode root = objectMapper.readTree(responseBody);
JsonNode elements = root.path("elements");
if (!elements.isArray()) {
return List.of();
}
List<LeadBrutoDTO> leads = new ArrayList<>();
for (JsonNode element : elements) {
JsonNode tags = element.path("tags");
String name = text(tags, "name");
if (!StringUtils.hasText(name)) {
continue;
}
leads.add(new LeadBrutoDTO(
name,
categoria(tags),
endereco(tags, location),
firstText(tags, "contact:phone", "phone"),
firstText(tags, "contact:website", "website"),
"osm:" + element.path("type").asText() + ":" + element.path("id").asText(),
null,
null
));
if (leads.size() >= properties.maxResults()) {
break;
}
}
return leads;
}
private String categoria(JsonNode tags) {
return firstText(tags, "office", "amenity", "shop", "craft", "healthcare", "cuisine");
}
private String endereco(JsonNode tags, LocationParts location) {
String street = text(tags, "addr:street");
String number = text(tags, "addr:housenumber");
String suburb = firstText(tags, "addr:suburb", "addr:neighbourhood");
String city = firstText(tags, "addr:city", "addr:municipality");
List<String> parts = new ArrayList<>();
if (StringUtils.hasText(street)) {
parts.add(StringUtils.hasText(number) ? street + ", " + number : street);
}
if (StringUtils.hasText(suburb)) {
parts.add(suburb);
}
parts.add(StringUtils.hasText(city) ? city : location.city());
if (StringUtils.hasText(location.state())) {
parts.add(location.state());
}
return String.join(" - ", parts);
}
private String firstText(JsonNode tags, String... keys) {
for (String key : keys) {
String value = text(tags, key);
if (StringUtils.hasText(value)) {
return value;
}
}
return null;
}
private String text(JsonNode tags, String key) {
JsonNode value = tags.path(key);
return value.isTextual() ? value.asText() : null;
}
private String normalizeTerm(String value) {
if (value == null) {
return "";
}
return java.text.Normalizer.normalize(value, java.text.Normalizer.Form.NFD)
.replaceAll("\\p{M}", "")
.toLowerCase(Locale.ROOT)
.trim();
}
private String escapeOverpassString(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
private record LocationParts(String city, String state) {
static LocationParts parse(String localizacao) {
if (!StringUtils.hasText(localizacao)) {
return new LocationParts("", "");
}
String[] parts = localizacao.split(",");
String city = parts[0].trim();
String state = parts.length > 1 ? parts[1].trim() : "";
return new LocationParts(city, state);
}
}
}
@@ -0,0 +1,9 @@
package com.seconectai.leadscraper.scraper;
public record ScraperFingerprint(
String userAgent,
int viewportWidth,
int viewportHeight,
String timezoneId
) {
}
@@ -0,0 +1,19 @@
package com.seconectai.leadscraper.scraper;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.stereotype.Service;
@Service
public class ScraperFingerprintService {
private static final List<ScraperFingerprint> FINGERPRINTS = List.of(
new ScraperFingerprint("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", 1366, 768, "America/Sao_Paulo"),
new ScraperFingerprint("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", 1440, 900, "America/Sao_Paulo"),
new ScraperFingerprint("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", 1536, 864, "America/Sao_Paulo")
);
public ScraperFingerprint next() {
return FINGERPRINTS.get(ThreadLocalRandom.current().nextInt(FINGERPRINTS.size()));
}
}
@@ -0,0 +1,20 @@
package com.seconectai.leadscraper.scraper;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
public record ScraperSession(
Playwright playwright,
Browser browser,
BrowserContext context,
Page page
) implements AutoCloseable {
@Override
public void close() {
browser.close();
playwright.close();
}
}
@@ -0,0 +1,118 @@
package com.seconectai.leadscraper.scraper;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.WaitUntilState;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class ScrollExtractorService {
private static final Logger log = LoggerFactory.getLogger(ScrollExtractorService.class);
private static final String FEED_SELECTOR = "[role='feed']";
private static final String CARD_SELECTOR = "a[href*='/maps/place/']";
private final GoogleMapsScraperProperties properties;
private final DelayHumanizadoService delayHumanizadoService;
private final LeadRawExtractor leadRawExtractor;
public ScrollExtractorService(
GoogleMapsScraperProperties properties,
DelayHumanizadoService delayHumanizadoService,
LeadRawExtractor leadRawExtractor
) {
this.properties = properties;
this.delayHumanizadoService = delayHumanizadoService;
this.leadRawExtractor = leadRawExtractor;
}
public List<LeadBrutoDTO> scrollAndExtractUntilExhausted(Page page) {
Locator feed = page.locator(FEED_SELECTOR).first();
Map<String, LeadBrutoDTO> accumulated = new LinkedHashMap<>();
int stagnantAttempts = 0;
collectVisible(page, accumulated);
while (accumulated.size() < properties.maxResults()
&& stagnantAttempts < properties.scroll().stagnantAttempts()) {
feed.evaluate("(element, pixels) => element.scrollBy(0, pixels)", delayHumanizadoService.proximoPassoScroll());
delayHumanizadoService.aguardarEntreAcoes(page);
int previousTotal = accumulated.size();
collectVisible(page, accumulated);
if (accumulated.size() <= previousTotal) {
stagnantAttempts++;
} else {
stagnantAttempts = 0;
}
}
return enrichFromPlaceDetails(page, new ArrayList<>(accumulated.values()));
}
private List<LeadBrutoDTO> enrichFromPlaceDetails(Page page, List<LeadBrutoDTO> leads) {
List<LeadBrutoDTO> enriched = new ArrayList<>();
int perfisVisitados = 0;
int telefonesEncontrados = 0;
for (LeadBrutoDTO lead : leads) {
if (lead.profileUrl() == null || lead.profileUrl().isBlank()) {
enriched.add(lead);
continue;
}
try {
page.navigate(lead.profileUrl(), new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
page.waitForSelector("body", new Page.WaitForSelectorOptions().setTimeout(properties.timeoutMs()));
delayHumanizadoService.aguardarEntreAcoes(page);
LeadBrutoDTO detalhado = leadRawExtractor.enrichFromPlaceDetails(page, lead);
perfisVisitados++;
if (detalhado.telefoneBruto() != null && !detalhado.telefoneBruto().isBlank()) {
telefonesEncontrados++;
}
enriched.add(detalhado);
} catch (RuntimeException ex) {
log.warn("Nao foi possivel abrir detalhe do lead '{}' em {}: {}", lead.nome(), lead.profileUrl(), shortMessage(ex));
enriched.add(lead);
}
}
log.info("Enriquecimento Google Maps finalizado: {} perfis visitados, {} telefones encontrados, {} leads totais",
perfisVisitados, telefonesEncontrados, leads.size());
return enriched;
}
private String shortMessage(RuntimeException ex) {
String message = ex.getMessage();
if (message == null || message.isBlank()) {
return ex.getClass().getSimpleName();
}
String singleLine = message.replaceAll("\\s+", " ").trim();
return singleLine.length() > 180 ? singleLine.substring(0, 180) + "..." : singleLine;
}
private void collectVisible(Page page, Map<String, LeadBrutoDTO> accumulated) {
for (LeadBrutoDTO lead : leadRawExtractor.extract(page)) {
if (accumulated.size() >= properties.maxResults()) {
return;
}
accumulated.putIfAbsent(dedupKey(lead), lead);
}
}
private String dedupKey(LeadBrutoDTO lead) {
if (lead.placeId() != null && !lead.placeId().isBlank()) {
return "place:" + lead.placeId();
}
return "lead:" + normalize(lead.nome()) + "|" + normalize(lead.enderecoBruto());
}
private String normalize(String value) {
return value == null ? "" : value.toLowerCase().replaceAll("\\s+", " ").trim();
}
}
@@ -0,0 +1,32 @@
package com.seconectai.leadscraper.security;
import com.seconectai.leadscraper.domain.Usuario;
import com.seconectai.leadscraper.repository.UsuarioRepository;
import java.util.List;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class DatabaseUserDetailsService implements UserDetailsService {
private final UsuarioRepository usuarioRepository;
public DatabaseUserDetailsService(UsuarioRepository usuarioRepository) {
this.usuarioRepository = usuarioRepository;
}
@Override
public UserDetails loadUserByUsername(String email) {
Usuario usuario = usuarioRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("Usuario nao encontrado"));
return new User(
usuario.getEmail(),
usuario.getSenhaHash(),
List.of(new SimpleGrantedAuthority("ROLE_" + usuario.getPapel().toUpperCase()))
);
}
}
@@ -0,0 +1,55 @@
package com.seconectai.leadscraper.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final DatabaseUserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService, DatabaseUserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = header.substring(7);
try {
String email = jwtService.extractSubject(token);
if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
if (jwtService.isValid(token, userDetails.getUsername())) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
} catch (RuntimeException ignored) {
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,7 @@
package com.seconectai.leadscraper.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "security.jwt")
public record JwtProperties(String secret, long expirationMinutes) {
}
@@ -0,0 +1,51 @@
package com.seconectai.leadscraper.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.stereotype.Service;
@Service
public class JwtService {
private final JwtProperties properties;
private final SecretKey key;
public JwtService(JwtProperties properties) {
this.properties = properties;
this.key = Keys.hmacShaKeyFor(properties.secret().getBytes(StandardCharsets.UTF_8));
}
public String generateToken(String email, String papel) {
Instant now = Instant.now();
Instant expiresAt = now.plusSeconds(properties.expirationMinutes() * 60);
return Jwts.builder()
.subject(email)
.claim("papel", papel)
.issuedAt(Date.from(now))
.expiration(Date.from(expiresAt))
.signWith(key)
.compact();
}
public String extractSubject(String token) {
return claims(token).getSubject();
}
public boolean isValid(String token, String email) {
Claims claims = claims(token);
return claims.getSubject().equals(email) && claims.getExpiration().after(new Date());
}
private Claims claims(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
@@ -0,0 +1,39 @@
package com.seconectai.leadscraper.service;
import com.seconectai.leadscraper.domain.Busca;
import com.seconectai.leadscraper.repository.BuscaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BuscaProgressService {
private final BuscaRepository buscaRepository;
public BuscaProgressService(BuscaRepository buscaRepository) {
this.buscaRepository = buscaRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void iniciarRegioes(Long buscaId, int total) {
buscaRepository.findById(buscaId).ifPresent(busca -> {
busca.setRegioesTotal(total);
busca.setRegioesSucesso(0);
busca.setRegioesErro(0);
busca.setDetalheRegioes(null);
buscaRepository.save(busca);
});
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void registrarRegiao(Long buscaId, boolean sucesso, String detalhe) {
buscaRepository.findById(buscaId).ifPresent(busca -> {
busca.setRegioesSucesso((busca.getRegioesSucesso() == null ? 0 : busca.getRegioesSucesso()) + (sucesso ? 1 : 0));
busca.setRegioesErro((busca.getRegioesErro() == null ? 0 : busca.getRegioesErro()) + (sucesso ? 0 : 1));
String atual = busca.getDetalheRegioes();
busca.setDetalheRegioes(atual == null || atual.isBlank() ? detalhe : atual + "\n" + detalhe);
buscaRepository.save(busca);
});
}
}
@@ -0,0 +1,65 @@
package com.seconectai.leadscraper.service;
import com.seconectai.leadscraper.domain.Busca;
import com.seconectai.leadscraper.domain.Lead;
import com.seconectai.leadscraper.domain.Ramo;
import com.seconectai.leadscraper.normalizer.LeadNormalizadoDTO;
import com.seconectai.leadscraper.repository.LeadRepository;
import com.seconectai.leadscraper.repository.RamoRepository;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@Service
public class LeadPersistenceService {
private final LeadRepository leadRepository;
private final RamoRepository ramoRepository;
public LeadPersistenceService(LeadRepository leadRepository, RamoRepository ramoRepository) {
this.leadRepository = leadRepository;
this.ramoRepository = ramoRepository;
}
@Transactional
public Lead salvarOuAtualizar(Busca busca, LeadNormalizadoDTO normalizado) {
Lead lead = findDuplicado(normalizado).orElseGet(Lead::new);
lead.setBusca(busca);
lead.setNomeFantasia(limit(normalizado.nomeFantasia(), 200));
lead.setCategoriaBruta(limit(normalizado.categoriaBruta(), 150));
lead.setTelefoneE164(limit(normalizado.telefoneE164(), 20));
lead.setTelefoneBruto(limit(normalizado.telefoneBruto(), 40));
lead.setCnpj(normalizado.cnpj());
lead.setSite(limit(normalizado.site(), 255));
lead.setEnderecoCompleto(normalizado.enderecoCompleto());
lead.setPlaceId(limit(normalizado.placeId(), 150));
lead.setHashDedup(normalizado.hashDedup());
lead.setRamo(resolveRamo(normalizado.ramoId()));
return leadRepository.save(lead);
}
private Optional<Lead> findDuplicado(LeadNormalizadoDTO normalizado) {
if (StringUtils.hasText(normalizado.placeId())) {
Optional<Lead> porPlaceId = leadRepository.findByPlaceId(normalizado.placeId());
if (porPlaceId.isPresent()) {
return porPlaceId;
}
}
return leadRepository.findByHashDedup(normalizado.hashDedup());
}
private String limit(String value, int maxLength) {
if (!StringUtils.hasText(value) || value.length() <= maxLength) {
return value;
}
return value.substring(0, maxLength);
}
private Ramo resolveRamo(Long ramoId) {
if (ramoId == null) {
return null;
}
return ramoRepository.findById(ramoId).orElse(null);
}
}
@@ -0,0 +1,70 @@
package com.seconectai.leadscraper.worker;
import com.seconectai.leadscraper.domain.Busca;
import com.seconectai.leadscraper.domain.StatusBusca;
import com.seconectai.leadscraper.normalizer.LeadNormalizerService;
import com.seconectai.leadscraper.repository.BuscaRepository;
import com.seconectai.leadscraper.repository.LeadRepository;
import com.seconectai.leadscraper.scraper.BuscaOrchestrator;
import com.seconectai.leadscraper.service.LeadPersistenceService;
import java.time.OffsetDateTime;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@ConditionalOnProperty(prefix = "worker.busca", name = "enabled", havingValue = "true", matchIfMissing = true)
public class BuscaWorker {
private final BuscaRepository buscaRepository;
private final BuscaOrchestrator buscaOrchestrator;
private final LeadNormalizerService leadNormalizerService;
private final LeadPersistenceService leadPersistenceService;
private final LeadRepository leadRepository;
public BuscaWorker(
BuscaRepository buscaRepository,
BuscaOrchestrator buscaOrchestrator,
LeadNormalizerService leadNormalizerService,
LeadPersistenceService leadPersistenceService,
LeadRepository leadRepository
) {
this.buscaRepository = buscaRepository;
this.buscaOrchestrator = buscaOrchestrator;
this.leadNormalizerService = leadNormalizerService;
this.leadPersistenceService = leadPersistenceService;
this.leadRepository = leadRepository;
}
@Scheduled(fixedDelayString = "${worker.busca.fixed-delay-ms:15000}")
public void processarPendentes() {
buscaRepository.findAll((root, query, cb) -> cb.equal(root.get("status"), StatusBusca.pendente),
PageRequest.of(0, 1))
.stream()
.findFirst()
.ifPresent(this::processar);
}
public void processar(Busca busca) {
busca.setStatus(StatusBusca.em_execucao);
busca.setIniciadoEm(OffsetDateTime.now());
busca.setDetalheErro(null);
buscaRepository.saveAndFlush(busca);
try {
var leadsBrutos = buscaOrchestrator.executar(busca);
leadsBrutos.stream()
.map(leadNormalizerService::normalize)
.forEach(normalizado -> leadPersistenceService.salvarOuAtualizar(busca, normalizado));
busca.setTotalEncontrados(Math.toIntExact(leadRepository.countByBuscaId(busca.getId())));
busca.setStatus(StatusBusca.concluida);
} catch (RuntimeException ex) {
busca.setStatus(StatusBusca.erro);
busca.setDetalheErro(ex.getMessage());
} finally {
busca.setFinalizadoEm(OffsetDateTime.now());
buscaRepository.save(busca);
}
}
}
+56
View File
@@ -0,0 +1,56 @@
spring:
application:
name: leadscraper
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5433/leadscraper}
username: ${SPRING_DATASOURCE_USERNAME:leadscraper}
password: ${SPRING_DATASOURCE_PASSWORD:leadscraper}
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
format_sql: true
flyway:
enabled: true
locations: classpath:db/migration
server:
port: ${SERVER_PORT:8082}
security:
jwt:
secret: ${JWT_SECRET}
expiration-minutes: ${JWT_EXPIRATION_MINUTES:480}
worker:
busca:
enabled: ${BUSCA_WORKER_ENABLED:true}
fixed-delay-ms: ${BUSCA_WORKER_FIXED_DELAY_MS:15000}
scraper:
google-maps:
base-url: https://www.google.com/maps
headless: ${SCRAPER_HEADLESS:true}
timeout-ms: ${SCRAPER_TIMEOUT_MS:300000}
max-results: ${SCRAPER_MAX_RESULTS:120}
scroll:
stagnant-attempts: ${SCRAPER_SCROLL_STAGNANT_ATTEMPTS:5}
min-pixels: ${SCRAPER_SCROLL_MIN_PIXELS:900}
max-pixels: ${SCRAPER_SCROLL_MAX_PIXELS:1800}
delays:
min-ms: ${SCRAPER_DELAY_MIN_MS:800}
max-ms: ${SCRAPER_DELAY_MAX_MS:2200}
overpass:
enabled: ${OVERPASS_ENABLED:true}
endpoint: ${OVERPASS_ENDPOINT:https://overpass-api.de/api/interpreter}
timeout-seconds: ${OVERPASS_TIMEOUT_SECONDS:90}
max-results: ${OVERPASS_MAX_RESULTS:1000}
proxy:
max-bloqueios: ${SCRAPER_PROXY_MAX_BLOQUEIOS:3}
limite-buscas-hora: ${SCRAPER_PROXY_LIMITE_BUSCAS_HORA:20}
backoff:
initial-ms: ${SCRAPER_PROXY_BACKOFF_INITIAL_MS:5000}
max-ms: ${SCRAPER_PROXY_BACKOFF_MAX_MS:60000}
multiplier: ${SCRAPER_PROXY_BACKOFF_MULTIPLIER:2.0}
@@ -0,0 +1,8 @@
INSERT INTO usuarios (nome, email, senha_hash, papel)
VALUES (
'Administrador',
'admin@seconectai.local',
'$2a$10$8YmPnML9SfWnScN7TAQTuOHMlXz.O5/L0uOyN3BgVdoJRuKE7BU2m',
'admin'
)
ON CONFLICT (email) DO NOTHING;
@@ -0,0 +1,3 @@
UPDATE usuarios
SET senha_hash = '$2a$10$9YzUyAdW9Y9lVZYgMPnT/uFUHJsV5zdZlnXAxyp36A5CaAv1T37c2'
WHERE email = 'admin@seconectai.local';
@@ -0,0 +1,12 @@
UPDATE usuarios
SET
email = 'tiago27.dossantos@gmail.com',
senha_hash = '$2a$10$EpTCRi5BLnBioKsi2zvDD.OsKTrNvcjxlibA/gyJQAViTuCWAPhb.',
nome = 'Tiago dos Santos'
WHERE email = 'admin@seconectai.local';
UPDATE usuarios
SET
senha_hash = '$2a$10$EpTCRi5BLnBioKsi2zvDD.OsKTrNvcjxlibA/gyJQAViTuCWAPhb.',
nome = 'Tiago dos Santos'
WHERE email = 'tiago27.dossantos@gmail.com';
@@ -0,0 +1,2 @@
ALTER TABLE buscas
ADD COLUMN expandir_por_bairros BOOLEAN NOT NULL DEFAULT TRUE;
@@ -0,0 +1,5 @@
ALTER TABLE buscas
ADD COLUMN regioes_total INTEGER NOT NULL DEFAULT 0,
ADD COLUMN regioes_sucesso INTEGER NOT NULL DEFAULT 0,
ADD COLUMN regioes_erro INTEGER NOT NULL DEFAULT 0,
ADD COLUMN detalhe_regioes TEXT;
@@ -0,0 +1 @@
-- Initial Flyway migration. Schema tables start in the next project stage.
@@ -0,0 +1,8 @@
CREATE TABLE usuarios (
id BIGSERIAL PRIMARY KEY,
nome VARCHAR(150) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
senha_hash VARCHAR(255) NOT NULL,
papel VARCHAR(20) NOT NULL DEFAULT 'admin',
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,7 @@
CREATE TABLE ramos (
id BIGSERIAL PRIMARY KEY,
nome_canonico VARCHAR(100) NOT NULL UNIQUE,
categoria_pai_id BIGINT REFERENCES ramos(id),
aliases JSONB NOT NULL DEFAULT '[]',
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,9 @@
CREATE TABLE proxies (
id BIGSERIAL PRIMARY KEY,
endereco VARCHAR(255) NOT NULL,
tipo VARCHAR(20) DEFAULT 'residencial',
status VARCHAR(20) DEFAULT 'ativo',
total_bloqueios INTEGER DEFAULT 0,
ultima_utilizacao TIMESTAMPTZ,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,14 @@
CREATE TABLE buscas (
id BIGSERIAL PRIMARY KEY,
termo_busca VARCHAR(150) NOT NULL,
localizacao VARCHAR(150) NOT NULL,
ramo_id BIGINT REFERENCES ramos(id),
proxy_id BIGINT REFERENCES proxies(id),
status VARCHAR(20) NOT NULL DEFAULT 'pendente',
total_encontrados INTEGER DEFAULT 0,
detalhe_erro TEXT,
iniciado_em TIMESTAMPTZ,
finalizado_em TIMESTAMPTZ,
criado_por BIGINT REFERENCES usuarios(id),
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,33 @@
CREATE TABLE leads (
id BIGSERIAL PRIMARY KEY,
place_id VARCHAR(150),
razao_social VARCHAR(200),
nome_fantasia VARCHAR(200) NOT NULL,
cnpj CHAR(14),
telefone_e164 VARCHAR(20),
telefone_bruto VARCHAR(40),
email VARCHAR(150),
site VARCHAR(255),
endereco_completo TEXT,
logradouro VARCHAR(200),
numero VARCHAR(20),
bairro VARCHAR(100),
cidade VARCHAR(100),
uf CHAR(2),
cep CHAR(8),
latitude NUMERIC(10,7),
longitude NUMERIC(10,7),
ramo_id BIGINT REFERENCES ramos(id),
categoria_bruta VARCHAR(150),
fonte VARCHAR(30) NOT NULL DEFAULT 'google_maps',
busca_id BIGINT REFERENCES buscas(id),
status VARCHAR(20) NOT NULL DEFAULT 'novo',
hash_dedup VARCHAR(64) NOT NULL UNIQUE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_leads_ramo ON leads(ramo_id);
CREATE INDEX idx_leads_cidade_uf ON leads(cidade, uf);
CREATE INDEX idx_leads_busca ON leads(busca_id);
CREATE UNIQUE INDEX idx_leads_cnpj_unique ON leads(cnpj) WHERE cnpj IS NOT NULL;
@@ -0,0 +1,7 @@
CREATE TABLE telefones (
id BIGSERIAL PRIMARY KEY,
lead_id BIGINT NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
numero_e164 VARCHAR(20) NOT NULL,
tipo VARCHAR(20) DEFAULT 'principal',
UNIQUE (lead_id, numero_e164)
);
@@ -0,0 +1,8 @@
CREATE TABLE historico_contato (
id BIGSERIAL PRIMARY KEY,
lead_id BIGINT NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
canal VARCHAR(30),
resultado VARCHAR(50),
observacao TEXT,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,2 @@
ALTER TABLE proxies
ADD COLUMN em_uso BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,11 @@
package com.seconectai.leadscraper;
import org.junit.jupiter.api.Test;
class LeadScraperApplicationTests {
@Test
void applicationClassExists() {
new LeadScraperApplication();
}
}
@@ -0,0 +1,120 @@
package com.seconectai.leadscraper;
import static org.assertj.core.api.Assertions.assertThat;
import com.seconectai.leadscraper.domain.Busca;
import com.seconectai.leadscraper.domain.HistoricoContato;
import com.seconectai.leadscraper.domain.Lead;
import com.seconectai.leadscraper.domain.Proxy;
import com.seconectai.leadscraper.domain.Ramo;
import com.seconectai.leadscraper.domain.StatusBusca;
import com.seconectai.leadscraper.domain.StatusLead;
import com.seconectai.leadscraper.domain.StatusProxy;
import com.seconectai.leadscraper.domain.Telefone;
import com.seconectai.leadscraper.domain.Usuario;
import com.seconectai.leadscraper.repository.BuscaRepository;
import com.seconectai.leadscraper.repository.HistoricoContatoRepository;
import com.seconectai.leadscraper.repository.LeadRepository;
import com.seconectai.leadscraper.repository.ProxyRepository;
import com.seconectai.leadscraper.repository.RamoRepository;
import com.seconectai.leadscraper.repository.TelefoneRepository;
import com.seconectai.leadscraper.repository.UsuarioRepository;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest
@TestPropertySource(properties = {
"security.jwt.secret=test-secret-with-at-least-32-characters",
"worker.busca.enabled=false"
})
@Transactional
class RepositoryPersistenceIntegrationTests {
@Autowired
private UsuarioRepository usuarioRepository;
@Autowired
private RamoRepository ramoRepository;
@Autowired
private ProxyRepository proxyRepository;
@Autowired
private BuscaRepository buscaRepository;
@Autowired
private LeadRepository leadRepository;
@Autowired
private TelefoneRepository telefoneRepository;
@Autowired
private HistoricoContatoRepository historicoContatoRepository;
@Test
void savesAndFindsSchemaEntities() {
String suffix = UUID.randomUUID().toString();
Usuario usuario = new Usuario();
usuario.setNome("Operador Teste");
usuario.setEmail("operador-" + suffix + "@example.com");
usuario.setSenhaHash("hash");
usuario = usuarioRepository.saveAndFlush(usuario);
Ramo ramo = new Ramo();
ramo.setNomeCanonico("Restaurantes " + suffix);
ramo.setAliases(List.of("restaurante", "comida"));
ramo = ramoRepository.saveAndFlush(ramo);
Proxy proxy = new Proxy();
proxy.setEndereco("http://127.0.0.1:8082/" + suffix);
proxy = proxyRepository.saveAndFlush(proxy);
Busca busca = new Busca();
busca.setTermoBusca("restaurante");
busca.setLocalizacao("Londrina PR");
busca.setRamo(ramo);
busca.setProxy(proxy);
busca.setCriadoPor(usuario);
busca = buscaRepository.saveAndFlush(busca);
Lead lead = new Lead();
lead.setNomeFantasia("Restaurante Teste");
lead.setCnpj("12345678000195");
lead.setTelefoneE164("+5543999999999");
lead.setTelefoneBruto("(43) 99999-9999");
lead.setCidade("Londrina");
lead.setUf("PR");
lead.setRamo(ramo);
lead.setBusca(busca);
lead.setHashDedup(UUID.randomUUID().toString().replace("-", ""));
lead = leadRepository.saveAndFlush(lead);
Telefone telefone = new Telefone();
telefone.setLead(lead);
telefone.setNumeroE164("+5543999999999");
telefoneRepository.saveAndFlush(telefone);
HistoricoContato historicoContato = new HistoricoContato();
historicoContato.setLead(lead);
historicoContato.setCanal("whatsapp");
historicoContato.setResultado("enviado");
historicoContatoRepository.saveAndFlush(historicoContato);
assertThat(usuarioRepository.findByEmail(usuario.getEmail())).isPresent();
assertThat(ramoRepository.findByNomeCanonico(ramo.getNomeCanonico())).isPresent();
assertThat(proxyRepository.findByStatus(StatusProxy.ativo)).extracting(Proxy::getId).contains(proxy.getId());
assertThat(buscaRepository.findByStatus(StatusBusca.pendente)).extracting(Busca::getId).contains(busca.getId());
assertThat(leadRepository.findByStatus(StatusLead.novo)).extracting(Lead::getId).contains(lead.getId());
assertThat(leadRepository.findByHashDedup(lead.getHashDedup())).isPresent();
assertThat(leadRepository.findByCnpj("12345678000195")).isPresent();
assertThat(leadRepository.findByBuscaId(busca.getId())).extracting(Lead::getId).contains(lead.getId());
assertThat(telefoneRepository.findByLeadId(lead.getId())).hasSize(1);
assertThat(historicoContatoRepository.findByLeadId(lead.getId())).hasSize(1);
}
}
@@ -0,0 +1,57 @@
package com.seconectai.leadscraper.controller;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = {
"security.jwt.secret=test-secret-with-at-least-32-characters",
"worker.busca.enabled=false"
})
class AuthControllerIntegrationTests {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void rejectsProtectedEndpointWithoutToken() throws Exception {
mockMvc.perform(get("/leads"))
.andExpect(status().isForbidden());
}
@Test
void loginReturnsJwtAndTokenAllowsProtectedEndpoint() throws Exception {
String body = objectMapper.writeValueAsString(Map.of(
"email", "tiago27.dossantos@gmail.com",
"senha", "10031992"
));
String token = objectMapper.readTree(mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").isNotEmpty())
.andReturn()
.getResponse()
.getContentAsString()).get("token").asText();
mockMvc.perform(get("/ramos").header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
}
@@ -0,0 +1,21 @@
package com.seconectai.leadscraper.normalizer;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class CnpjValidatorTests {
private final CnpjValidator validator = new CnpjValidator();
@Test
void keepsOnlyValidCnpjDigits() {
assertThat(validator.normalize("11.222.333/0001-81")).isEqualTo("11222333000181");
}
@Test
void discardsInvalidCnpj() {
assertThat(validator.normalize("11.222.333/0001-82")).isNull();
assertThat(validator.normalize("00.000.000/0000-00")).isNull();
}
}
@@ -0,0 +1,19 @@
package com.seconectai.leadscraper.normalizer;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class DedupHashCalculatorTests {
private final DedupHashCalculator calculator = new DedupHashCalculator();
@Test
void calculatesDeterministicSha256Hash() {
String first = calculator.calculate("Padaria Sol", "+5543999999999");
String second = calculator.calculate("Padaria Sol", "+5543999999999");
assertThat(first).isEqualTo(second);
assertThat(first).hasSize(64);
}
}
@@ -0,0 +1,67 @@
package com.seconectai.leadscraper.normalizer;
import static org.assertj.core.api.Assertions.assertThat;
import com.seconectai.leadscraper.domain.Ramo;
import com.seconectai.leadscraper.repository.RamoRepository;
import com.seconectai.leadscraper.scraper.LeadBrutoDTO;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest
@TestPropertySource(properties = {
"security.jwt.secret=test-secret-with-at-least-32-characters",
"worker.busca.enabled=false"
})
@Transactional
class LeadNormalizerServiceIntegrationTests {
@Autowired
private RamoRepository ramoRepository;
@Autowired
private LeadNormalizerService leadNormalizerService;
@Test
void mapsKnownRamoAliasAndKeepsUnknownCategoryPending() {
String suffix = UUID.randomUUID().toString();
Ramo ramo = new Ramo();
ramo.setNomeCanonico("Restaurantes " + suffix);
ramo.setAliases(List.of("Restaurante", "Comida"));
ramo = ramoRepository.saveAndFlush(ramo);
LeadNormalizadoDTO mapped = leadNormalizerService.normalize(new LeadBrutoDTO(
"restaurante bom ltda",
" restaurante ",
"Rua das Flores, 123",
"(43) 99999-9999",
"restaurante.com.br",
"place-id",
"4,8",
"https://www.google.com/maps/place/teste"
));
LeadNormalizadoDTO unknown = leadNormalizerService.normalize(new LeadBrutoDTO(
"loja sem ramo",
"categoria nova",
"Rua A, 10",
"(43) 3333-4444",
null,
null,
null,
null
));
assertThat(mapped.nomeFantasia()).isEqualTo("Restaurante Bom LTDA");
assertThat(mapped.telefoneE164()).isEqualTo("+5543999999999");
assertThat(mapped.ramoId()).isEqualTo(ramo.getId());
assertThat(mapped.hashDedup()).hasSize(64);
assertThat(unknown.ramoId()).isNull();
assertThat(unknown.categoriaBruta()).isEqualTo("categoria nova");
}
}
@@ -0,0 +1,22 @@
package com.seconectai.leadscraper.normalizer;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class NomeNormalizerTests {
private final NomeNormalizer normalizer = new NomeNormalizer();
@Test
void normalizesSpacingCaseAndCorporateAcronyms() {
assertThat(normalizer.normalize(" mercado central ltda ME "))
.isEqualTo("Mercado Central LTDA ME");
}
@Test
void removesControlAndEmojiCharacters() {
assertThat(normalizer.normalize("padaria\u0000 sol \uD83C\uDF5E"))
.isEqualTo("Padaria Sol");
}
}
@@ -0,0 +1,22 @@
package com.seconectai.leadscraper.normalizer;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class TelefoneNormalizerTests {
private final TelefoneNormalizer normalizer = new TelefoneNormalizer();
@Test
void formatsValidBrazilianPhoneAsE164() {
assertThat(normalizer.normalize("(43) 99999-9999")).isEqualTo("+5543999999999");
assertThat(normalizer.normalize("+55 43 3333-4444")).isEqualTo("+554333334444");
}
@Test
void returnsNullWhenPhoneHasNoDddOrInvalidLength() {
assertThat(normalizer.normalize("99999-9999")).isNull();
assertThat(normalizer.normalize("123")).isNull();
}
}
@@ -0,0 +1,27 @@
package com.seconectai.leadscraper.proxy;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class BackoffServiceTests {
@Test
void increasesBackoffUntilConfiguredLimitAndResetsAfterSuccess() {
ProxySettingsProperties properties = new ProxySettingsProperties(
3,
20,
new ProxySettingsProperties.Backoff(1000, 3000, 2.0)
);
BackoffService service = new BackoffService(properties);
assertThat(service.registrarBloqueioECalcularEspera()).isEqualTo(1000);
assertThat(service.registrarBloqueioECalcularEspera()).isEqualTo(2000);
assertThat(service.registrarBloqueioECalcularEspera()).isEqualTo(3000);
service.registrarSucesso();
assertThat(service.registrarBloqueioECalcularEspera()).isEqualTo(1000);
assertThat(service.limiteBuscasHoraPorProxy()).isEqualTo(20);
}
}
@@ -0,0 +1,43 @@
package com.seconectai.leadscraper.scraper;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class LeadRawExtractorTests {
private final LeadRawExtractor extractor = new LeadRawExtractor();
@Test
void parsesRawLeadFromCardText() {
LeadBrutoDTO lead = extractor.toLeadBruto(
"https://www.google.com/maps/place/Padaria+Sol/@-23,-51,17z/data=!1s0xabc123!3m1",
"Padaria Sol",
"""
Padaria Sol
Padaria
Rua das Flores, 123 - Centro
4,7
(43) 3333-4444
padariasol.com.br
"""
);
assertThat(lead).isNotNull();
assertThat(lead.nome()).isEqualTo("Padaria Sol");
assertThat(lead.categoriaBruta()).isEqualTo("Padaria");
assertThat(lead.enderecoBruto()).isEqualTo("Rua das Flores, 123 - Centro");
assertThat(lead.telefoneBruto()).isEqualTo("(43) 3333-4444");
assertThat(lead.site()).isEqualTo("padariasol.com.br");
assertThat(lead.placeId()).isEqualTo("0xabc123");
assertThat(lead.avaliacao()).isEqualTo("4.7");
assertThat(lead.profileUrl()).contains("/maps/place/Padaria+Sol/");
}
@Test
void ignoresCardWithoutName() {
LeadBrutoDTO lead = extractor.toLeadBruto(null, null, null);
assertThat(lead).isNull();
}
}
@@ -0,0 +1,18 @@
package com.seconectai.leadscraper.scraper;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class ScraperFingerprintServiceTests {
@Test
void returnsCoherentBrowserFingerprint() {
ScraperFingerprint fingerprint = new ScraperFingerprintService().next();
assertThat(fingerprint.userAgent()).contains("Mozilla/5.0");
assertThat(fingerprint.viewportWidth()).isGreaterThan(1000);
assertThat(fingerprint.viewportHeight()).isGreaterThan(700);
assertThat(fingerprint.timezoneId()).isEqualTo("America/Sao_Paulo");
}
}