Este documento descreve o scraper implementado em Go localizado em scraper-go/.
O scraper Go concentra a coleta e normalização de vagas. Ele recebe configurações de busca do backend, executa fontes externas habilitadas, aplica deduplicação e classificação local, persiste vagas no Valkey e publica índices para a API consultar com baixa latência.
Abaixo há exemplos simplificados do payload esperado internamente e de como cada adaptador normalmente formata/retorna vagas.
- LinkedIn
- Comportamento: consulta o endpoint público
jobs-gueste faz parsing HTML comgoquery. - Exemplo (Job individual retornado pelo adaptador):
- Comportamento: consulta o endpoint público
{
"id": "24a1b2c3d4e5f6a7b8c9d0e1",
"title": "Frontend Engineer",
"company": "Empresa X",
"location": "São Paulo, SP",
"url": "https://www.linkedin.com/jobs/view/123456789/",
"source": "LinkedIn",
"sources": ["LinkedIn"],
"keyword": "react",
"keywords": ["react"]
}- Adzuna
- Comportamento: usa API oficial (quando
ADZUNA_APP_ID/ADZUNA_APP_KEYconfigurados) e retorna JSON com campos estruturados. - Operação: suporta
SearchBatchcom slot rotativo de keywords e limite padrão de 5 páginas por keyword. - Exemplo (Job adaptado):
- Comportamento: usa API oficial (quando
{
"id": "adzuna-987654",
"title": "Desenvolvedor Backend",
"company": "ACME",
"location": "Remoto",
"url": "https://www.adzuna.com/descricao/987654",
"source": "Adzuna",
"sources": ["Adzuna"],
"keyword": "node",
"keywords": ["node"]
}- Jooble
- Comportamento: integra com Jooble API quando
JOOBLE_API_KEYpresente; pode usar Redis para controle de cota. - Operação: usa slot rotativo, cota diária e cadência de 12h para proteger a integração.
- Exemplo (Job adaptado):
- Comportamento: integra com Jooble API quando
{
"id": "jooble-13579",
"title": "Data Engineer",
"company": "Empresa Y",
"location": "Curitiba, PR",
"url": "https://jooble.org/vaga/13579",
"source": "Jooble",
"sources": ["Jooble"],
"keyword": "data",
"keywords": ["data"]
}- Greenhouse / Lever / TheMuse
- Comportamento: adaptadores per-company que fazem scraping/parsing da vaga ou consomem endpoints públicos por empresa.
- Exemplo (job adaptado genérico):
{
"id": "greenhouse-abc123",
"title": "Product Manager",
"company": "Startup Z",
"location": "Los Angeles, CA",
"url": "https://boards.greenhouse.io/companyz/jobs/321",
"source": "Greenhouse",
"sources": ["Greenhouse"],
"keyword": "product",
"keywords": ["product"]
}O board_token é o trecho final da URL pública da Greenhouse. Por exemplo:
https://job-boards.greenhouse.io/reddit→ tokenreddithttps://job-boards.greenhouse.io/gitlab→ tokengitlab
Para validar tokens ou testar nomes de empresas, use:
cd scraper-go
go run ./cmd/greenhouse-discover -names Reddit,GitLabSaída esperada:
OK gitlab 186 vagas https://job-boards.greenhouse.io/gitlab
OK reddit 195 vagas https://job-boards.greenhouse.io/reddit
Também é possível validar o JSON atual:
cd scraper-go
go run ./cmd/greenhouse-discover -file internal/interfaces/greenhouseCompanies.jsonTokens com OK podem entrar em internal/interfaces/greenhouseCompanies.json.
Tokens com MISS status=404 devem ser removidos ou substituídos.
Esses exemplos ilustram o contrato interno entre adaptadores e pipeline: o pipeline espera domain.Job com campos normalizados (URL limpa, título/empresa/local, Source e StableID calculável). O jobstore.StableID deriva o ID a partir de título+empresa+local ou URL.
Criei uma especificação OpenAPI mínima em openapi.yaml descrevendo os endpoints HTTP públicos do serviço Go (/scrape, /api/keywords). Ela pode ser usada para gerar clientes ou documentação interativa.
Arquivo: scraper-go/openapi.yaml (no repositório)
O scraper é um serviço HTTP em Go que consulta múltiplas fontes de vagas (LinkedIn, Adzuna, Greenhouse, TheMuse, Lever, Jooble, etc.), agrega os resultados, remove duplicatas e persiste/retorna as vagas via cache e índice Redis/Valkey. Ele foi projetado para ser usado internamente pelo backend Node.js, que delega buscas ao serviço Go.
Componentes principais:
cmd/server— inicializador e ponto de entrada.internal/domain— modelos centrais do scraper (Job,ScrapeRequest,ScrapeResponse, classificação).internal/ports— contratos internos da aplicação, como fontes de vagas, repositórios, cache e métricas.internal/adapters— composição de adapters concretos e registry das fontes habilitadas.internal/adapters/<fonte>— implementação isolada de cada fonte externa (gupy,inhire,greenhouse,lever,jooble,linkedin,themuse,adzuna).internal/adapters/adapterutil— helpers compartilhados entre adapters concretos.internal/pipeline— camada de aplicação do pipeline de scraping, orquestra execução concorrente e indexação.internal/jobstore— persistência em Redis (valkey) para jobs, índices e IDs estáveis.internal/cache— abstração de cache com implementação Redis e memória (fallback).internal/dedup— regras para deduplicação/merge de vagas.internal/keywords— carregamento e persistência de keywords (configuração).internal/inflight— deduplicador de requisições concorrentes (singleflight).internal/cronjob— scheduler de scraping em background e execução manual.internal/metrics— métricas Prometheus por fonte/execução.cmd/greenhouse-discover— utilitário Go para validar tokens de empresas Greenhouse antes de atualizarinternal/interfaces/greenhouseCompanies.json.
O scraper evolui para uma arquitetura hexagonal de forma incremental. O domínio fica em internal/domain, as portas em internal/ports e os adapters concretos ficam em subpastas de internal/adapters.
A fronteira principal é a porta ports.JobSource: cada fonte externa implementa SourceName, Search e, opcionalmente, SearchBatch. O pipeline recebe uma lista de ports.JobSource já montada pelo servidor/registry, evitando que a camada de aplicação conheça diretamente as implementações concretas.
Desenho atual:
cmd/server
monta cache, Valkey, stores, scheduler e adapters
internal/domain
modelos centrais do scraper
internal/ports
contratos que a aplicação consome
internal/pipeline
orquestra scraping, dedupe, classificação e indexação
internal/adapters
registry e implementações concretas por fonte
Ainda há pontos a evoluir: pipeline e cronjob continuam recebendo *redis.Client em fluxos de indexação/cadência, e métricas Prometheus ainda são chamadas diretamente. Os próximos passos naturais são criar adapters outbound para Valkey e Prometheus por trás de portas específicas, reduzindo ainda mais o acoplamento de infraestrutura.
Requisitos: Go >= 1.26, Redis (opcional, mas recomendado)
Construir e executar:
cd scraper-go
go build ./cmd/server
./serverRodar em desenvolvimento (carregando .env):
cd scraper-go
go run ./cmd/serverDocker: há um Dockerfile em scraper-go/. No Docker Compose, configure VALKEY_URL=redis://valkey:6379/0 no .env da raiz para que o scraper acesse o Valkey pelo nome do serviço na rede Docker.
No Compose da raiz, o serviço escuta em http://localhost:8081.
O scraper possui um orçamento global de concorrência por execução controlado por SCRAPER_MAX_CONCURRENCY.
- Padrão interno:
12, usado quando a variável não está definida. - Valor válido: inteiro positivo.
- Valores inválidos explícitos (
"",0, negativo ou não numérico) fazem a aplicação falhar no startup, sem fallback silencioso. - Cron e
POST /admin/scrapeusam o limite global configurado. POST /scrapepreserva o contrato atual: quandomaxConcurrencynão é informado, ou vem como0/negativo, usa o limite global; quando vem positivo abaixo do teto, usa o valor solicitado; quando vem acima do teto, usa o teto global.- A concorrência efetiva é calculada antes da chave de cache e é o mesmo valor usado pelo pipeline, logs e semáforo.
O semáforo global atual é criado uma vez por chamada do pipeline. Portanto, o limite é por execução: duas execuções simultâneas ainda podem possuir dois semáforos independentes com a mesma capacidade. Lock entre cron/manual, prevenção de simultaneidade e limites por provider pertencem às próximas sub-issues.
No Docker Compose de produção, o serviço scraper-go também define:
GOMAXPROCS=2: limita a quantidade de threads do Go executando código simultaneamente.GOMEMLIMIT=1500MiB: define uma meta de memória para o runtime e influencia o garbage collector.mem_limit: 2g: limite externo do container.GOMEMLIMITnão substitui esse limite; ele fica abaixo de2gpara preservar margem operacional.cpus: 1.5: limita o container a 1,5 CPU.
O serviço expõe endpoints HTTP (implementação em cmd/server e arquivos associados). Principais rotas:
- POST
/scrape— body JSON comScrapeRequestpara disparar uma busca em todas as fontes configuradas. RetornaScrapeResponsecomjobs,total,cachedAtefromCache. - GET
/health— verifica se o scraper está online e qual cache está em uso. - GET
/metrics— métricas Prometheus. - GET
/api/keywords— retorna as keywords atualmente carregadas. - POST
/api/keywords— atualiza/persiste as keywords (aceitakeywords: string[]). - POST
/admin/scrape— dispara uma execução manual em background; retorna 409 se já houver execução em andamento. - GET
/admin/scrape/status— informa se existe uma execução em andamento. - GET
/admin/jobs/count— retorna a quantidade de vagas persistidas no Valkey. - GET
/admin/jobs— lista uma amostra das vagas persistidas no Valkey; aceitalimit.
Exemplo de ScrapeRequest (JSON):
{
"keywords": ["react", "node"],
"searchLocation": "Brasil",
"searchGeoId": "106057199",
"searchLanguage": "pt",
"jobTypes": "C,F",
"timeFilter": "r604800",
"remoteOnly": true,
"resultsPerPage": 25,
"maxPagesPerKeyword": 5,
"waitBetweenSearchesMs": 3000,
"pageTimeoutMs": 15000,
"maxConcurrency": 50
}Exemplo de ScrapeResponse (JSON):
{
"jobs": [
{
"id": "...",
"title": "Frontend Engineer",
"company": "Empresa X",
"location": "São Paulo, SP",
"url": "https://...",
"source": "LinkedIn",
"sources": ["LinkedIn"],
"keyword": "react",
"keywords": ["react"]
}
],
"total": 1,
"cachedAt": "2026-05-26T10:00:00Z",
"fromCache": false
}Observação: os endpoints acima refletem a implementação atual em
scraper-go/cmd/server.
Fluxo principal:
- Recebe
ScrapeRequestcom keywords e configuração. - Verifica cache (
internal/cache). Se encontrado, retorna resultado cacheado. - Caso contrário, executa
pipeline.ScrapeAllSourcesque:- Recebe a lista de fontes já montada pelo servidor/registry.
- Cria uma tarefa por fonte batch (
SearchBatch) ou uma tarefa por keyword para fontes sem batch, sempre respeitandoMaxConcurrency. - Cada adaptador realiza requisições HTTP específicas, parseia HTML/JSON quando necessário e retorna
domain.Job. - Agrega resultados e aplica deduplicação (
dedup.DedupeJobs). - Classifica vagas por família, tecnologias e senioridade antes da indexação.
- Persiste vagas novas no
jobstore(Redis) e atualiza índices invertidos (funçãoIndexJobsInValkey). - Escreve resultado no cache para próximas requisições.
Concorrência e resiliência:
- Semáforos por adaptador (ex.: LinkedIn usa um semáforo de 5 simultâneos para proteção).
- Orçamento global por execução via
SCRAPER_MAX_CONCURRENCY, aplicado antes do cache e do pipeline. - Tratamento de status 429 com backoff; aborta apenas a keyword afetada em caso de falhas persistentes.
- Uso de
inflightpara evitar que múltiplas requisições idênticas disparem scrapes simultâneos. - Slots rotativos reduzem o número de keywords/queries por rodada em fontes caras, preservando cobertura progressiva em execuções futuras.
Cada adaptador em internal/adapters/<fonte> implementa a porta ports.JobSource com SourceName() e Search(ctx, keyword, req). Quando a fonte consegue buscar várias keywords em uma chamada/lote, ela também pode implementar ports.BatchJobSource.
Implementações incluem:
internal/adapters/linkedin— busca via endpoint públicojobs-guestdo LinkedIn; parsing comgoquery.internal/adapters/adzuna— integra via API Adzuna (se configurada comADZUNA_APP_ID/ADZUNA_APP_KEY).internal/adapters/jooble— integra com Jooble API (seJOOBLE_API_KEYconfigurada); pode usar Redis para quota.internal/adapters/greenhouse,internal/adapters/lever,internal/adapters/themuse— adaptadores por empresa/plataforma com parsing/integração próprios.internal/adapters/gupy,internal/adapters/inhire— adaptadores para fontes usadas no fluxo atual de vagas.
- LinkedIn: sempre habilitado;
SearchBatchseleciona um slot rotativo de keywords por execução. Defaults atuais: 5 páginas por keyword e 30 keywords por rodada. - Adzuna: habilitado quando
ADZUNA_APP_IDeADZUNA_APP_KEYexistem;SearchBatchusa slot rotativo. Defaults atuais: 5 páginas por keyword e 30 keywords por rodada. - Gupy: habilitado com
GUPY_ENABLED=true; usa queries expandidas, descoberta por termos amplos e sweep opcional. O default atual limita o sweep a offset 10000 e processa 60 queries por rodada. - Jooble: habilitado com
JOOBLE_API_KEY; usa cota diária, slot rotativo e cadência de 12h. - Greenhouse: habilitado com
GREENHOUSE_ENABLED=true; cria um adapter por empresa listada eminternal/interfaces/greenhouseCompanies.json. - Lever: habilitado com
LEVER_ENABLED=true; cria adapters a partir deinternal/interfaces/leverCompanies.json. - InHire: habilitado com
INHIRE_ENABLED=true; consulta tenants deinternal/interfaces/inhireTenants.jsone só enriquece detalhes quandoINHIRE_ENRICH_DETAILS=true.
Boas práticas nos adaptadores:
- Normalização de campos (URL, título, empresa, localização).
- Derivação de
IDestável viajobstore.StableID(título+empresa+local ou URL normalizada). - Dedupe local antes de retornar ao pipeline.
jobstore.SaveBatchpersiste vagas no Redis com TTL e mantém um índice global (scraper:jobs:index).pipeline.IndexJobsInValkeycria índices invertidos por keyword e sub-termos (scraper:jobs:keyword:<term>), além de manter TTL para índices.- A classificação local também gera índices estruturados:
scraper:jobs:family:<family>,scraper:jobs:technology:<technology>escraper:jobs:seniority:<seniority>. - Filtros estruturados de localização, modelo, contrato e senioridade continuam em chaves como
scraper:jobs:country:<value>,scraper:jobs:model:<value>escraper:jobs:contract:<value>. jobstore.StableIDgarante IDs determinísticos para permitir identificação e deduplicação entre execuções.
- Cache é abstraído por
internal/cachecom implementações Redis (NewRedisCache) e memória (fallback para testes). - Chave de cache do scraper é construída por
pipeline.BuildCacheKey(consistente com os parâmetros de busca).
- Há testes e fixtures (ex.:
internal/keywords/keywords.test.json) para validar normalização. - Recomenda-se executar
go test ./...dentro descraper-go. - Para validar a configuração final do Compose, execute na raiz:
docker compose \
-f docker-compose.infra.yml \
-f docker-compose.yml \
-f docker-compose.migrate.yml \
configConfirme no serviço scraper-go os equivalentes de SCRAPER_MAX_CONCURRENCY=12, GOMAXPROCS=2, GOMEMLIMIT=1500MiB, cpus: 1.5 e mem_limit: 2g.
VALKEY_URL— conexão Redis/Valkey. Em Docker Compose, useredis://valkey:6379/0; em execução local fora do Docker, use uma URL acessível pelo host, por exemploredis://localhost:6379/0.SCRAPER_MAX_CONCURRENCY— teto global de concorrência por execução. Padrão:12. Configuração explícita inválida impede a inicialização.GOMAXPROCS— limite efetivo de threads executando código Go simultaneamente. Valor inicial no Compose:2.GOMEMLIMIT— meta de memória do runtime/GC. Valor inicial no Compose:1500MiB; não substituimem_limitdo container.JOOBLE_API_KEY— Jooble integration.ADZUNA_APP_ID/ADZUNA_APP_KEY— Adzuna API.LINKEDIN_KEYWORD_SLOT_SIZE— quantidade máxima de keywords do LinkedIn por execução quando a busca vier com uma lista grande. Padrão:30.ADZUNA_KEYWORD_SLOT_SIZE— quantidade máxima de keywords do Adzuna por execução. Padrão:30.GUPY_ENABLED— habilita o adapter Gupy.GUPY_RAW_DISCOVERY_ENABLED— adiciona queries amplas de tecnologia na Gupy.GUPY_FULL_SWEEP_ENABLED/GUPY_FULL_REMOTE_SWEEP_ENABLED— controla sweeps amplos na Gupy.GUPY_QUERY_LIMIT— limita quantas queries expandidas da Gupy rodam por execução. Padrão:60.INHIRE_ENABLED,INHIRE_TENANTS_FILE,INHIRE_ENRICH_DETAILS,INHIRE_DETAILS_MODE,INHIRE_DETAILS_CONCURRENCY,INHIRE_DETAILS_TIMEOUT_MS— controlam fonte e enriquecimento InHire.GREENHOUSE_ENABLED,GREENHOUSE_COMPANIES_FILE— controlam fonte Greenhouse.LEVER_ENABLED,LEVER_COMPANIES_FILE,LEVER_INCLUDE_ALL_JOBS— controlam fonte Lever.- Configurações de logging, quota e performance podem ser definidas via
.env.
- Projetado para rodar frequentemente; use caching e indexação para reduzir chamadas repetidas.
- Monitorar erros 429 e ajustar
WaitBetweenSearchesMs/ semáforos por adaptador. - Verifique logs estruturados (slog JSON) e
/metricspara métricas de sucesso/falhas por adaptador.