Initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
+67
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user