Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@ SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner

# Scraping behavior
SCRAPER_MAX_CONCURRENCY=12
SCRAPER_PROVIDER_MAX_CONCURRENCY=2
SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=
SCRAPER_RUN_LOCK_TTL=120s
SCRAPER_RUN_LOCK_RENEW_INTERVAL=30s
GOMAXPROCS=2
Expand DownExpand Up@@ -87,7 +89,6 @@ INHIRE_ENABLED=true
INHIRE_TENANTS_FILE=./internal/interfaces/inhireTenants.json
INHIRE_ENRICH_DETAILS=false
INHIRE_DETAILS_MODE=ambiguous
INHIRE_DETAILS_CONCURRENCY=8
INHIRE_DETAILS_TIMEOUT_MS=10000
GREENHOUSE_ENABLED=true
GREENHOUSE_COMPANIES_FILE=./internal/interfaces/greenhouseCompanies.json
Expand Down
47 changes: 29 additions & 18 deletions SCRAPER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,16 +197,19 @@ No Compose da raiz, a porta `8081` fica exposta apenas na rede interna `vagas-ne

### Limites globais de execução

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.
O scraper possui um orçamento global e limites por provider controlados pelo scheduler do pipeline:

- `SCRAPER_MAX_CONCURRENCY`: teto global. Padrão interno `12`.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY`: limite padrão por provider. Padrão interno `2`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES`: overrides separados por vírgula no formato `provider=limite`, por exemplo `linkedin=1,gupy=3`.
- IDs aceitos nos overrides: `linkedin`, `adzuna`, `themuse`, `gupy`, `inhire`, `jooble`, `greenhouse` e `lever`.
- Limites devem ser inteiros positivos e não podem superar o teto global. Provider desconhecido, entrada malformada ou duplicada impede o startup.
- Valores inválidos explícitos nas variáveis numéricas (`""`, `0`, negativo ou não numérico) fazem a aplicação falhar no startup, sem fallback silencioso; overrides vazios significam que nenhum provider foi sobrescrito.
- Cron e `POST /admin/scrape` usam o limite global configurado.
- `POST /scrape` preserva o contrato atual: quando `maxConcurrency` não é informado, ou vem como `0`/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. O lock distribuído abaixo impede que duas execuções mantenham semáforos independentes ao mesmo tempo; limites específicos por provider permanecem para uma sub-issue posterior.
- A concorrência global efetiva continua fazendo parte da chave de cache. Limites por provider são parâmetros operacionais e não alteram a chave.
- Cada tarefa adquire primeiro o permit do provider e depois o permit global; o limite efetivo do provider é sempre o menor entre seu limite configurado e o global da requisição.
- O lock distribuído abaixo impede que duas execuções mantenham orçamentos independentes ao mesmo tempo.

### Lock distribuído de execução

Expand DownExpand Up@@ -302,7 +305,9 @@ Fluxo principal:
2. Verifica cache (`internal/cache`). Se encontrado, retorna resultado cacheado.
3. Caso contrário, executa `pipeline.ScrapeAllSources` que:
- 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 respeitando `MaxConcurrency`.
- Valida o ID, o modo de descoberta e a interface declarada por cada fonte antes de iniciar workers.
- Produz tarefas sob demanda em round-robin para uma fila limitada, consumida por um conjunto fixo de workers.
- Cria uma tarefa por keyword no modo `keyword`, uma tarefa com o conjunto controlado no modo `batch` e uma tarefa por catálogo/instância no modo `catalog`.
- 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.
Expand All@@ -311,15 +316,17 @@ Fluxo principal:

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.
- Orçamento global e por provider aplicado pelo pipeline; adapters não criam fan-out concorrente independente.
- Fila de tarefas limitada a duas vezes o teto global e workers fixos evitam materializar `adapters × keywords` ou abrir uma goroutine por tarefa.
- O produtor round-robin evita que um provider com muitas instâncias monopolize a fila.
- Cancelamento interrompe produção, espera por permits, paginação, retries e requisições HTTP; tarefas novas não começam após a perda do contexto/lock.
- Tratamento de status 429 com backoff; aborta apenas a keyword afetada em caso de falhas persistentes.
- Uso de `inflight` para 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.

## Adaptadores

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`.
Cada adaptador em `internal/adapters/<fonte>` implementa `ports.JobSource` e declara `ProviderID` e `DiscoveryMode`. Fontes `batch` implementam `ports.BatchJobSource`; fontes `catalog` implementam `ports.CatalogJobSource`. Fontes legadas sem capacidade explícita usam o fallback `keyword`.
Implementações incluem:

- `internal/adapters/linkedin` — busca via endpoint público `jobs-guest` do LinkedIn; parsing com `goquery`.
Expand All@@ -334,9 +341,10 @@ Implementações incluem:
- Adzuna: habilitado quando `ADZUNA_APP_ID` e `ADZUNA_APP_KEY` existem; `SearchBatch` usa 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 em `internal/interfaces/greenhouseCompanies.json`.
- Lever: habilitado com `LEVER_ENABLED=true`; cria adapters a partir de `internal/interfaces/leverCompanies.json`.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants de `internal/interfaces/inhireTenants.json` e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.
- Greenhouse: habilitado com `GREENHOUSE_ENABLED=true`; cria um catálogo por empresa listada em `internal/interfaces/greenhouseCompanies.json`, consulta cada catálogo uma vez e agrega todas as keywords correspondentes sem duplicar a vaga.
- Lever: habilitado com `LEVER_ENABLED=true`; cria catálogos por empresa a partir de `internal/interfaces/leverCompanies.json`.
- The Muse: consulta o catálogo uma vez por execução e filtra todas as keywords localmente.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants serialmente e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.

Boas práticas nos adaptadores:

Expand DownExpand Up@@ -371,12 +379,14 @@ docker compose \
config
```

Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.
Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `SCRAPER_PROVIDER_MAX_CONCURRENCY=2`, `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=""`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.

## Variáveis de ambiente importantes

- `VALKEY_URL` — conexão Redis/Valkey. Em Docker Compose, use `redis://valkey:6379/0`; em execução local fora do Docker, use uma URL acessível pelo host, por exemplo `redis://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.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY` — limite padrão por provider. Padrão: `2`. Deve ser positivo e não pode superar `SCRAPER_MAX_CONCURRENCY`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES` — lista opcional `provider=limite`, separada por vírgulas. Vazio significa nenhum override; entrada inválida, duplicada, desconhecida ou acima do teto global impede a inicialização.
- `SCRAPER_RUN_LOCK_TTL` — duração do lock distribuído. Padrão: `120s`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_TTL-120s}` (mesmo padrão fail-fast de `SCRAPER_MAX_CONCURRENCY`).
- `SCRAPER_RUN_LOCK_RENEW_INTERVAL` — intervalo de renovação. Padrão: `30s`; deve ser menor que `SCRAPER_RUN_LOCK_TTL`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}`.
- `GOMAXPROCS` — limite efetivo de threads executando código Go simultaneamente. Valor inicial no Compose: `2`.
Expand All@@ -389,16 +399,17 @@ Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12
- `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.
- `INHIRE_ENABLED`, `INHIRE_TENANTS_FILE`, `INHIRE_ENRICH_DETAILS`, `INHIRE_DETAILS_MODE`, `INHIRE_DETAILS_TIMEOUT_MS` — controlam fonte e enriquecimento InHire. `INHIRE_DETAILS_CONCURRENCY` foi removida; detalhes seguem o orçamento do provider.
- `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`.

## Observações operacionais

- Projetado para rodar frequentemente; use caching e indexação para reduzir chamadas repetidas.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` / semáforos por adaptador.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` e os limites globais/por provider.
- Verifique logs estruturados (slog JSON) e `/metrics` para métricas de sucesso/falhas por adaptador.
- O log `scraper concurrency budget` registra uma vez por execução o teto global, o default por provider e os overrides; `scraper provider execution summary` registra modo, tarefas produzidas/concluídas/canceladas, erros, timeouts e duração agregada.
- Logs `scraper run lock acquired`, `scraper execution skipped`, `scraper run lock lost` e `scraper run lock released` identificam `source` e `run_id`.

### Verificação operacional do lock
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ services:
- GO_SCRAPER_ADDR=:8081
- VALKEY_URL=redis://valkey:6379/0
- SCRAPER_MAX_CONCURRENCY=${SCRAPER_MAX_CONCURRENCY-12}
- SCRAPER_PROVIDER_MAX_CONCURRENCY=${SCRAPER_PROVIDER_MAX_CONCURRENCY-2}
- SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=${SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES-}
- SCRAPER_RUN_LOCK_TTL=${SCRAPER_RUN_LOCK_TTL-120s}
- SCRAPER_RUN_LOCK_RENEW_INTERVAL=${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}
- GOMAXPROCS=${GOMAXPROCS:-2}
Expand All@@ -26,7 +28,6 @@ services:
- INHIRE_TENANTS_FILE=/app/internal/interfaces/inhireTenants.json
- INHIRE_ENRICH_DETAILS=${INHIRE_ENRICH_DETAILS:-false}
- INHIRE_DETAILS_MODE=${INHIRE_DETAILS_MODE:-ambiguous}
- INHIRE_DETAILS_CONCURRENCY=${INHIRE_DETAILS_CONCURRENCY:-8}
- INHIRE_DETAILS_TIMEOUT_MS=${INHIRE_DETAILS_TIMEOUT_MS:-10000}
- GREENHOUSE_ENABLED=${GREENHOUSE_ENABLED:-true}
- GREENHOUSE_COMPANIES_FILE=/app/internal/interfaces/greenhouseCompanies.json
Expand Down
40 changes: 26 additions & 14 deletions scraper-go/cmd/server/handlers.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ func handleScrape(
ctx, cancel := context.WithTimeout(r.Context(), scrapeTimeout)
defer cancel()

searchConfig := searchConfigFromRequest(req, runtimeCfg.MaxConcurrency)
searchConfig := searchConfigFromRuntime(req, runtimeCfg)
slogScrapeStart("public_endpoint", runtimeCfg.MaxConcurrency, req.MaxConcurrency, searchConfig.MaxConcurrency, len(searchConfig.Keywords), len(adapterList))

start := time.Now()
Expand DownExpand Up@@ -85,20 +85,32 @@ func handleScrape(
}

func searchConfigFromRequest(req domain.ScrapeRequest, globalMaxConcurrency int) pipeline.SearchConfig {
return searchConfigFromRuntime(req, config.RuntimeConfig{
MaxConcurrency: globalMaxConcurrency,
ProviderMaxConcurrency: min(config.DefaultProviderMaxConcurrency, globalMaxConcurrency),
})
}

func searchConfigFromRuntime(
req domain.ScrapeRequest,
runtimeCfg config.RuntimeConfig,
) pipeline.SearchConfig {
return pipeline.SearchConfig{
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, globalMaxConcurrency),
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency),
ProviderMaxConcurrency: min(runtimeCfg.ProviderMaxConcurrency, config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency)),
ProviderConcurrencyOverrides: runtimeCfg.ProviderConcurrencyOverrides,
}
}

Expand Down
17 changes: 17 additions & 0 deletions scraper-go/cmd/server/handlers_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ package main
import (
"testing"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/config"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/pipeline"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/ports"
"github.com/stretchr/testify/assert"
)

Expand DownExpand Up@@ -54,3 +56,18 @@ func TestSearchConfigFromRequestUsesSameCacheKeyForRequestsAboveLimit(t *testing

assert.Equal(t, key40, key100)
}

func TestSearchConfigFromRuntimePropagatesProviderLimits(t *testing.T) {
cfg := searchConfigFromRuntime(
domain.ScrapeRequest{MaxConcurrency: 4},
config.RuntimeConfig{
MaxConcurrency: 12,
ProviderMaxConcurrency: 3,
ProviderConcurrencyOverrides: map[ports.ProviderID]int{ports.ProviderGupy: 8},
},
)

assert.Equal(t, 4, cfg.MaxConcurrency)
assert.Equal(t, 3, cfg.ProviderMaxConcurrency)
assert.Equal(t, 8, cfg.ProviderConcurrencyOverrides[ports.ProviderGupy])
}
3 changes: 3 additions & 0 deletions scraper-go/cmd/server/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,9 @@ func logRuntimeConfig(cfg config.RuntimeConfig) {
slog.Info("scraper runtime configurado",
"max_concurrency", cfg.MaxConcurrency,
"max_concurrency_source", cfg.MaxConcurrencySource,
"provider_max_concurrency", cfg.ProviderMaxConcurrency,
"provider_max_concurrency_source", cfg.ProviderMaxConcurrencySource,
"provider_concurrency_overrides", cfg.ProviderConcurrencyOverrides,
"run_lock_ttl", cfg.RunLockTTL,
"run_lock_renew_interval", cfg.RunLockRenewInterval,
"gomaxprocs_effective", runtime.GOMAXPROCS(0),
Expand Down
2 changes: 2 additions & 0 deletions scraper-go/cmd/server/server.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,8 @@ func run(adapterList []ports.JobSource, runtimeCfg config.RuntimeConfig) {
// ── Scheduler (cronjob) ──
schedulerCfg := cronjob.DefaultConfig()
schedulerCfg.MaxConcurrency = runtimeCfg.MaxConcurrency
schedulerCfg.ProviderMaxConcurrency = runtimeCfg.ProviderMaxConcurrency
schedulerCfg.ProviderConcurrencyOverrides = runtimeCfg.ProviderConcurrencyOverrides
scheduler := cronjob.New(schedulerCfg, kwStore, jobStore, adapterList, rdb, runLock)

scheduler.OnComplete = func(kws []string, scraped, saved int, duration time.Duration) {
Expand Down
1 change: 1 addition & 0 deletions scraper-go/go.mod
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
go.uber.org/goleak v1.3.0
)

require (
Expand Down
20 changes: 20 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers.go
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
package adapterutil

import (
"context"
"html"
"strings"
"time"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
)

func Wait(ctx context.Context, duration time.Duration) error {
if cause := context.Cause(ctx); cause != nil {
return cause
}
if duration <= 0 {
return nil
}

timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return context.Cause(ctx)
}
}

func NonEmptyStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
Expand Down
22 changes: 22 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
package adapterutil

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWaitReturnsCancellationCauseWithoutWaitingForTimer(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cause := assert.AnError
cancel(cause)
started := time.Now()

err := Wait(ctx, time.Minute)

require.ErrorIs(t, err, cause)
assert.Less(t, time.Since(started), 100*time.Millisecond)
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@ SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner

# Scraping behavior
SCRAPER_MAX_CONCURRENCY=12
SCRAPER_PROVIDER_MAX_CONCURRENCY=2
SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=
SCRAPER_RUN_LOCK_TTL=120s
SCRAPER_RUN_LOCK_RENEW_INTERVAL=30s
GOMAXPROCS=2
Expand DownExpand Up@@ -87,7 +89,6 @@ INHIRE_ENABLED=true
INHIRE_TENANTS_FILE=./internal/interfaces/inhireTenants.json
INHIRE_ENRICH_DETAILS=false
INHIRE_DETAILS_MODE=ambiguous
INHIRE_DETAILS_CONCURRENCY=8
INHIRE_DETAILS_TIMEOUT_MS=10000
GREENHOUSE_ENABLED=true
GREENHOUSE_COMPANIES_FILE=./internal/interfaces/greenhouseCompanies.json
Expand Down
47 changes: 29 additions & 18 deletions SCRAPER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,16 +197,19 @@ No Compose da raiz, a porta `8081` fica exposta apenas na rede interna `vagas-ne

### Limites globais de execução

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.
O scraper possui um orçamento global e limites por provider controlados pelo scheduler do pipeline:

- `SCRAPER_MAX_CONCURRENCY`: teto global. Padrão interno `12`.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY`: limite padrão por provider. Padrão interno `2`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES`: overrides separados por vírgula no formato `provider=limite`, por exemplo `linkedin=1,gupy=3`.
- IDs aceitos nos overrides: `linkedin`, `adzuna`, `themuse`, `gupy`, `inhire`, `jooble`, `greenhouse` e `lever`.
- Limites devem ser inteiros positivos e não podem superar o teto global. Provider desconhecido, entrada malformada ou duplicada impede o startup.
- Valores inválidos explícitos nas variáveis numéricas (`""`, `0`, negativo ou não numérico) fazem a aplicação falhar no startup, sem fallback silencioso; overrides vazios significam que nenhum provider foi sobrescrito.
- Cron e `POST /admin/scrape` usam o limite global configurado.
- `POST /scrape` preserva o contrato atual: quando `maxConcurrency` não é informado, ou vem como `0`/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. O lock distribuído abaixo impede que duas execuções mantenham semáforos independentes ao mesmo tempo; limites específicos por provider permanecem para uma sub-issue posterior.
- A concorrência global efetiva continua fazendo parte da chave de cache. Limites por provider são parâmetros operacionais e não alteram a chave.
- Cada tarefa adquire primeiro o permit do provider e depois o permit global; o limite efetivo do provider é sempre o menor entre seu limite configurado e o global da requisição.
- O lock distribuído abaixo impede que duas execuções mantenham orçamentos independentes ao mesmo tempo.

### Lock distribuído de execução

Expand DownExpand Up@@ -302,7 +305,9 @@ Fluxo principal:
2. Verifica cache (`internal/cache`). Se encontrado, retorna resultado cacheado.
3. Caso contrário, executa `pipeline.ScrapeAllSources` que:
- 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 respeitando `MaxConcurrency`.
- Valida o ID, o modo de descoberta e a interface declarada por cada fonte antes de iniciar workers.
- Produz tarefas sob demanda em round-robin para uma fila limitada, consumida por um conjunto fixo de workers.
- Cria uma tarefa por keyword no modo `keyword`, uma tarefa com o conjunto controlado no modo `batch` e uma tarefa por catálogo/instância no modo `catalog`.
- 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.
Expand All@@ -311,15 +316,17 @@ Fluxo principal:

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.
- Orçamento global e por provider aplicado pelo pipeline; adapters não criam fan-out concorrente independente.
- Fila de tarefas limitada a duas vezes o teto global e workers fixos evitam materializar `adapters × keywords` ou abrir uma goroutine por tarefa.
- O produtor round-robin evita que um provider com muitas instâncias monopolize a fila.
- Cancelamento interrompe produção, espera por permits, paginação, retries e requisições HTTP; tarefas novas não começam após a perda do contexto/lock.
- Tratamento de status 429 com backoff; aborta apenas a keyword afetada em caso de falhas persistentes.
- Uso de `inflight` para 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.

## Adaptadores

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`.
Cada adaptador em `internal/adapters/<fonte>` implementa `ports.JobSource` e declara `ProviderID` e `DiscoveryMode`. Fontes `batch` implementam `ports.BatchJobSource`; fontes `catalog` implementam `ports.CatalogJobSource`. Fontes legadas sem capacidade explícita usam o fallback `keyword`.
Implementações incluem:

- `internal/adapters/linkedin` — busca via endpoint público `jobs-guest` do LinkedIn; parsing com `goquery`.
Expand All@@ -334,9 +341,10 @@ Implementações incluem:
- Adzuna: habilitado quando `ADZUNA_APP_ID` e `ADZUNA_APP_KEY` existem; `SearchBatch` usa 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 em `internal/interfaces/greenhouseCompanies.json`.
- Lever: habilitado com `LEVER_ENABLED=true`; cria adapters a partir de `internal/interfaces/leverCompanies.json`.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants de `internal/interfaces/inhireTenants.json` e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.
- Greenhouse: habilitado com `GREENHOUSE_ENABLED=true`; cria um catálogo por empresa listada em `internal/interfaces/greenhouseCompanies.json`, consulta cada catálogo uma vez e agrega todas as keywords correspondentes sem duplicar a vaga.
- Lever: habilitado com `LEVER_ENABLED=true`; cria catálogos por empresa a partir de `internal/interfaces/leverCompanies.json`.
- The Muse: consulta o catálogo uma vez por execução e filtra todas as keywords localmente.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants serialmente e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.

Boas práticas nos adaptadores:

Expand DownExpand Up@@ -371,12 +379,14 @@ docker compose \
config
```

Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.
Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `SCRAPER_PROVIDER_MAX_CONCURRENCY=2`, `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=""`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.

## Variáveis de ambiente importantes

- `VALKEY_URL` — conexão Redis/Valkey. Em Docker Compose, use `redis://valkey:6379/0`; em execução local fora do Docker, use uma URL acessível pelo host, por exemplo `redis://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.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY` — limite padrão por provider. Padrão: `2`. Deve ser positivo e não pode superar `SCRAPER_MAX_CONCURRENCY`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES` — lista opcional `provider=limite`, separada por vírgulas. Vazio significa nenhum override; entrada inválida, duplicada, desconhecida ou acima do teto global impede a inicialização.
- `SCRAPER_RUN_LOCK_TTL` — duração do lock distribuído. Padrão: `120s`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_TTL-120s}` (mesmo padrão fail-fast de `SCRAPER_MAX_CONCURRENCY`).
- `SCRAPER_RUN_LOCK_RENEW_INTERVAL` — intervalo de renovação. Padrão: `30s`; deve ser menor que `SCRAPER_RUN_LOCK_TTL`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}`.
- `GOMAXPROCS` — limite efetivo de threads executando código Go simultaneamente. Valor inicial no Compose: `2`.
Expand All@@ -389,16 +399,17 @@ Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12
- `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.
- `INHIRE_ENABLED`, `INHIRE_TENANTS_FILE`, `INHIRE_ENRICH_DETAILS`, `INHIRE_DETAILS_MODE`, `INHIRE_DETAILS_TIMEOUT_MS` — controlam fonte e enriquecimento InHire. `INHIRE_DETAILS_CONCURRENCY` foi removida; detalhes seguem o orçamento do provider.
- `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`.

## Observações operacionais

- Projetado para rodar frequentemente; use caching e indexação para reduzir chamadas repetidas.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` / semáforos por adaptador.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` e os limites globais/por provider.
- Verifique logs estruturados (slog JSON) e `/metrics` para métricas de sucesso/falhas por adaptador.
- O log `scraper concurrency budget` registra uma vez por execução o teto global, o default por provider e os overrides; `scraper provider execution summary` registra modo, tarefas produzidas/concluídas/canceladas, erros, timeouts e duração agregada.
- Logs `scraper run lock acquired`, `scraper execution skipped`, `scraper run lock lost` e `scraper run lock released` identificam `source` e `run_id`.

### Verificação operacional do lock
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ services:
- GO_SCRAPER_ADDR=:8081
- VALKEY_URL=redis://valkey:6379/0
- SCRAPER_MAX_CONCURRENCY=${SCRAPER_MAX_CONCURRENCY-12}
- SCRAPER_PROVIDER_MAX_CONCURRENCY=${SCRAPER_PROVIDER_MAX_CONCURRENCY-2}
- SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=${SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES-}
- SCRAPER_RUN_LOCK_TTL=${SCRAPER_RUN_LOCK_TTL-120s}
- SCRAPER_RUN_LOCK_RENEW_INTERVAL=${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}
- GOMAXPROCS=${GOMAXPROCS:-2}
Expand All@@ -26,7 +28,6 @@ services:
- INHIRE_TENANTS_FILE=/app/internal/interfaces/inhireTenants.json
- INHIRE_ENRICH_DETAILS=${INHIRE_ENRICH_DETAILS:-false}
- INHIRE_DETAILS_MODE=${INHIRE_DETAILS_MODE:-ambiguous}
- INHIRE_DETAILS_CONCURRENCY=${INHIRE_DETAILS_CONCURRENCY:-8}
- INHIRE_DETAILS_TIMEOUT_MS=${INHIRE_DETAILS_TIMEOUT_MS:-10000}
- GREENHOUSE_ENABLED=${GREENHOUSE_ENABLED:-true}
- GREENHOUSE_COMPANIES_FILE=/app/internal/interfaces/greenhouseCompanies.json
Expand Down
40 changes: 26 additions & 14 deletions scraper-go/cmd/server/handlers.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ func handleScrape(
ctx, cancel := context.WithTimeout(r.Context(), scrapeTimeout)
defer cancel()

searchConfig := searchConfigFromRequest(req, runtimeCfg.MaxConcurrency)
searchConfig := searchConfigFromRuntime(req, runtimeCfg)
slogScrapeStart("public_endpoint", runtimeCfg.MaxConcurrency, req.MaxConcurrency, searchConfig.MaxConcurrency, len(searchConfig.Keywords), len(adapterList))

start := time.Now()
Expand DownExpand Up@@ -85,20 +85,32 @@ func handleScrape(
}

func searchConfigFromRequest(req domain.ScrapeRequest, globalMaxConcurrency int) pipeline.SearchConfig {
return searchConfigFromRuntime(req, config.RuntimeConfig{
MaxConcurrency: globalMaxConcurrency,
ProviderMaxConcurrency: min(config.DefaultProviderMaxConcurrency, globalMaxConcurrency),
})
}

func searchConfigFromRuntime(
req domain.ScrapeRequest,
runtimeCfg config.RuntimeConfig,
) pipeline.SearchConfig {
return pipeline.SearchConfig{
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, globalMaxConcurrency),
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency),
ProviderMaxConcurrency: min(runtimeCfg.ProviderMaxConcurrency, config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency)),
ProviderConcurrencyOverrides: runtimeCfg.ProviderConcurrencyOverrides,
}
}

Expand Down
17 changes: 17 additions & 0 deletions scraper-go/cmd/server/handlers_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ package main
import (
"testing"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/config"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/pipeline"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/ports"
"github.com/stretchr/testify/assert"
)

Expand DownExpand Up@@ -54,3 +56,18 @@ func TestSearchConfigFromRequestUsesSameCacheKeyForRequestsAboveLimit(t *testing

assert.Equal(t, key40, key100)
}

func TestSearchConfigFromRuntimePropagatesProviderLimits(t *testing.T) {
cfg := searchConfigFromRuntime(
domain.ScrapeRequest{MaxConcurrency: 4},
config.RuntimeConfig{
MaxConcurrency: 12,
ProviderMaxConcurrency: 3,
ProviderConcurrencyOverrides: map[ports.ProviderID]int{ports.ProviderGupy: 8},
},
)

assert.Equal(t, 4, cfg.MaxConcurrency)
assert.Equal(t, 3, cfg.ProviderMaxConcurrency)
assert.Equal(t, 8, cfg.ProviderConcurrencyOverrides[ports.ProviderGupy])
}
3 changes: 3 additions & 0 deletions scraper-go/cmd/server/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,9 @@ func logRuntimeConfig(cfg config.RuntimeConfig) {
slog.Info("scraper runtime configurado",
"max_concurrency", cfg.MaxConcurrency,
"max_concurrency_source", cfg.MaxConcurrencySource,
"provider_max_concurrency", cfg.ProviderMaxConcurrency,
"provider_max_concurrency_source", cfg.ProviderMaxConcurrencySource,
"provider_concurrency_overrides", cfg.ProviderConcurrencyOverrides,
"run_lock_ttl", cfg.RunLockTTL,
"run_lock_renew_interval", cfg.RunLockRenewInterval,
"gomaxprocs_effective", runtime.GOMAXPROCS(0),
Expand Down
2 changes: 2 additions & 0 deletions scraper-go/cmd/server/server.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,8 @@ func run(adapterList []ports.JobSource, runtimeCfg config.RuntimeConfig) {
// ── Scheduler (cronjob) ──
schedulerCfg := cronjob.DefaultConfig()
schedulerCfg.MaxConcurrency = runtimeCfg.MaxConcurrency
schedulerCfg.ProviderMaxConcurrency = runtimeCfg.ProviderMaxConcurrency
schedulerCfg.ProviderConcurrencyOverrides = runtimeCfg.ProviderConcurrencyOverrides
scheduler := cronjob.New(schedulerCfg, kwStore, jobStore, adapterList, rdb, runLock)

scheduler.OnComplete = func(kws []string, scraped, saved int, duration time.Duration) {
Expand Down
1 change: 1 addition & 0 deletions scraper-go/go.mod
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
go.uber.org/goleak v1.3.0
)

require (
Expand Down
20 changes: 20 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers.go
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
package adapterutil

import (
"context"
"html"
"strings"
"time"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
)

func Wait(ctx context.Context, duration time.Duration) error {
if cause := context.Cause(ctx); cause != nil {
return cause
}
if duration <= 0 {
return nil
}

timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return context.Cause(ctx)
}
}

func NonEmptyStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
Expand Down
22 changes: 22 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
package adapterutil

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWaitReturnsCancellationCauseWithoutWaitingForTimer(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cause := assert.AnError
cancel(cause)
started := time.Now()

err := Wait(ctx, time.Minute)

require.ErrorIs(t, err, cause)
assert.Less(t, time.Since(started), 100*time.Millisecond)
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@ SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner

# Scraping behavior
SCRAPER_MAX_CONCURRENCY=12
SCRAPER_PROVIDER_MAX_CONCURRENCY=2
SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=
SCRAPER_RUN_LOCK_TTL=120s
SCRAPER_RUN_LOCK_RENEW_INTERVAL=30s
GOMAXPROCS=2
Expand DownExpand Up@@ -87,7 +89,6 @@ INHIRE_ENABLED=true
INHIRE_TENANTS_FILE=./internal/interfaces/inhireTenants.json
INHIRE_ENRICH_DETAILS=false
INHIRE_DETAILS_MODE=ambiguous
INHIRE_DETAILS_CONCURRENCY=8
INHIRE_DETAILS_TIMEOUT_MS=10000
GREENHOUSE_ENABLED=true
GREENHOUSE_COMPANIES_FILE=./internal/interfaces/greenhouseCompanies.json
Expand Down
47 changes: 29 additions & 18 deletions SCRAPER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,16 +197,19 @@ No Compose da raiz, a porta `8081` fica exposta apenas na rede interna `vagas-ne

### Limites globais de execução

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.
O scraper possui um orçamento global e limites por provider controlados pelo scheduler do pipeline:

- `SCRAPER_MAX_CONCURRENCY`: teto global. Padrão interno `12`.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY`: limite padrão por provider. Padrão interno `2`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES`: overrides separados por vírgula no formato `provider=limite`, por exemplo `linkedin=1,gupy=3`.
- IDs aceitos nos overrides: `linkedin`, `adzuna`, `themuse`, `gupy`, `inhire`, `jooble`, `greenhouse` e `lever`.
- Limites devem ser inteiros positivos e não podem superar o teto global. Provider desconhecido, entrada malformada ou duplicada impede o startup.
- Valores inválidos explícitos nas variáveis numéricas (`""`, `0`, negativo ou não numérico) fazem a aplicação falhar no startup, sem fallback silencioso; overrides vazios significam que nenhum provider foi sobrescrito.
- Cron e `POST /admin/scrape` usam o limite global configurado.
- `POST /scrape` preserva o contrato atual: quando `maxConcurrency` não é informado, ou vem como `0`/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. O lock distribuído abaixo impede que duas execuções mantenham semáforos independentes ao mesmo tempo; limites específicos por provider permanecem para uma sub-issue posterior.
- A concorrência global efetiva continua fazendo parte da chave de cache. Limites por provider são parâmetros operacionais e não alteram a chave.
- Cada tarefa adquire primeiro o permit do provider e depois o permit global; o limite efetivo do provider é sempre o menor entre seu limite configurado e o global da requisição.
- O lock distribuído abaixo impede que duas execuções mantenham orçamentos independentes ao mesmo tempo.

### Lock distribuído de execução

Expand DownExpand Up@@ -302,7 +305,9 @@ Fluxo principal:
2. Verifica cache (`internal/cache`). Se encontrado, retorna resultado cacheado.
3. Caso contrário, executa `pipeline.ScrapeAllSources` que:
- 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 respeitando `MaxConcurrency`.
- Valida o ID, o modo de descoberta e a interface declarada por cada fonte antes de iniciar workers.
- Produz tarefas sob demanda em round-robin para uma fila limitada, consumida por um conjunto fixo de workers.
- Cria uma tarefa por keyword no modo `keyword`, uma tarefa com o conjunto controlado no modo `batch` e uma tarefa por catálogo/instância no modo `catalog`.
- 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.
Expand All@@ -311,15 +316,17 @@ Fluxo principal:

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.
- Orçamento global e por provider aplicado pelo pipeline; adapters não criam fan-out concorrente independente.
- Fila de tarefas limitada a duas vezes o teto global e workers fixos evitam materializar `adapters × keywords` ou abrir uma goroutine por tarefa.
- O produtor round-robin evita que um provider com muitas instâncias monopolize a fila.
- Cancelamento interrompe produção, espera por permits, paginação, retries e requisições HTTP; tarefas novas não começam após a perda do contexto/lock.
- Tratamento de status 429 com backoff; aborta apenas a keyword afetada em caso de falhas persistentes.
- Uso de `inflight` para 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.

## Adaptadores

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`.
Cada adaptador em `internal/adapters/<fonte>` implementa `ports.JobSource` e declara `ProviderID` e `DiscoveryMode`. Fontes `batch` implementam `ports.BatchJobSource`; fontes `catalog` implementam `ports.CatalogJobSource`. Fontes legadas sem capacidade explícita usam o fallback `keyword`.
Implementações incluem:

- `internal/adapters/linkedin` — busca via endpoint público `jobs-guest` do LinkedIn; parsing com `goquery`.
Expand All@@ -334,9 +341,10 @@ Implementações incluem:
- Adzuna: habilitado quando `ADZUNA_APP_ID` e `ADZUNA_APP_KEY` existem; `SearchBatch` usa 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 em `internal/interfaces/greenhouseCompanies.json`.
- Lever: habilitado com `LEVER_ENABLED=true`; cria adapters a partir de `internal/interfaces/leverCompanies.json`.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants de `internal/interfaces/inhireTenants.json` e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.
- Greenhouse: habilitado com `GREENHOUSE_ENABLED=true`; cria um catálogo por empresa listada em `internal/interfaces/greenhouseCompanies.json`, consulta cada catálogo uma vez e agrega todas as keywords correspondentes sem duplicar a vaga.
- Lever: habilitado com `LEVER_ENABLED=true`; cria catálogos por empresa a partir de `internal/interfaces/leverCompanies.json`.
- The Muse: consulta o catálogo uma vez por execução e filtra todas as keywords localmente.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants serialmente e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.

Boas práticas nos adaptadores:

Expand DownExpand Up@@ -371,12 +379,14 @@ docker compose \
config
```

Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.
Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `SCRAPER_PROVIDER_MAX_CONCURRENCY=2`, `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=""`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.

## Variáveis de ambiente importantes

- `VALKEY_URL` — conexão Redis/Valkey. Em Docker Compose, use `redis://valkey:6379/0`; em execução local fora do Docker, use uma URL acessível pelo host, por exemplo `redis://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.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY` — limite padrão por provider. Padrão: `2`. Deve ser positivo e não pode superar `SCRAPER_MAX_CONCURRENCY`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES` — lista opcional `provider=limite`, separada por vírgulas. Vazio significa nenhum override; entrada inválida, duplicada, desconhecida ou acima do teto global impede a inicialização.
- `SCRAPER_RUN_LOCK_TTL` — duração do lock distribuído. Padrão: `120s`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_TTL-120s}` (mesmo padrão fail-fast de `SCRAPER_MAX_CONCURRENCY`).
- `SCRAPER_RUN_LOCK_RENEW_INTERVAL` — intervalo de renovação. Padrão: `30s`; deve ser menor que `SCRAPER_RUN_LOCK_TTL`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}`.
- `GOMAXPROCS` — limite efetivo de threads executando código Go simultaneamente. Valor inicial no Compose: `2`.
Expand All@@ -389,16 +399,17 @@ Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12
- `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.
- `INHIRE_ENABLED`, `INHIRE_TENANTS_FILE`, `INHIRE_ENRICH_DETAILS`, `INHIRE_DETAILS_MODE`, `INHIRE_DETAILS_TIMEOUT_MS` — controlam fonte e enriquecimento InHire. `INHIRE_DETAILS_CONCURRENCY` foi removida; detalhes seguem o orçamento do provider.
- `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`.

## Observações operacionais

- Projetado para rodar frequentemente; use caching e indexação para reduzir chamadas repetidas.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` / semáforos por adaptador.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` e os limites globais/por provider.
- Verifique logs estruturados (slog JSON) e `/metrics` para métricas de sucesso/falhas por adaptador.
- O log `scraper concurrency budget` registra uma vez por execução o teto global, o default por provider e os overrides; `scraper provider execution summary` registra modo, tarefas produzidas/concluídas/canceladas, erros, timeouts e duração agregada.
- Logs `scraper run lock acquired`, `scraper execution skipped`, `scraper run lock lost` e `scraper run lock released` identificam `source` e `run_id`.

### Verificação operacional do lock
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ services:
- GO_SCRAPER_ADDR=:8081
- VALKEY_URL=redis://valkey:6379/0
- SCRAPER_MAX_CONCURRENCY=${SCRAPER_MAX_CONCURRENCY-12}
- SCRAPER_PROVIDER_MAX_CONCURRENCY=${SCRAPER_PROVIDER_MAX_CONCURRENCY-2}
- SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=${SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES-}
- SCRAPER_RUN_LOCK_TTL=${SCRAPER_RUN_LOCK_TTL-120s}
- SCRAPER_RUN_LOCK_RENEW_INTERVAL=${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}
- GOMAXPROCS=${GOMAXPROCS:-2}
Expand All@@ -26,7 +28,6 @@ services:
- INHIRE_TENANTS_FILE=/app/internal/interfaces/inhireTenants.json
- INHIRE_ENRICH_DETAILS=${INHIRE_ENRICH_DETAILS:-false}
- INHIRE_DETAILS_MODE=${INHIRE_DETAILS_MODE:-ambiguous}
- INHIRE_DETAILS_CONCURRENCY=${INHIRE_DETAILS_CONCURRENCY:-8}
- INHIRE_DETAILS_TIMEOUT_MS=${INHIRE_DETAILS_TIMEOUT_MS:-10000}
- GREENHOUSE_ENABLED=${GREENHOUSE_ENABLED:-true}
- GREENHOUSE_COMPANIES_FILE=/app/internal/interfaces/greenhouseCompanies.json
Expand Down
40 changes: 26 additions & 14 deletions scraper-go/cmd/server/handlers.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ func handleScrape(
ctx, cancel := context.WithTimeout(r.Context(), scrapeTimeout)
defer cancel()

searchConfig := searchConfigFromRequest(req, runtimeCfg.MaxConcurrency)
searchConfig := searchConfigFromRuntime(req, runtimeCfg)
slogScrapeStart("public_endpoint", runtimeCfg.MaxConcurrency, req.MaxConcurrency, searchConfig.MaxConcurrency, len(searchConfig.Keywords), len(adapterList))

start := time.Now()
Expand DownExpand Up@@ -85,20 +85,32 @@ func handleScrape(
}

func searchConfigFromRequest(req domain.ScrapeRequest, globalMaxConcurrency int) pipeline.SearchConfig {
return searchConfigFromRuntime(req, config.RuntimeConfig{
MaxConcurrency: globalMaxConcurrency,
ProviderMaxConcurrency: min(config.DefaultProviderMaxConcurrency, globalMaxConcurrency),
})
}

func searchConfigFromRuntime(
req domain.ScrapeRequest,
runtimeCfg config.RuntimeConfig,
) pipeline.SearchConfig {
return pipeline.SearchConfig{
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, globalMaxConcurrency),
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency),
ProviderMaxConcurrency: min(runtimeCfg.ProviderMaxConcurrency, config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency)),
ProviderConcurrencyOverrides: runtimeCfg.ProviderConcurrencyOverrides,
}
}

Expand Down
17 changes: 17 additions & 0 deletions scraper-go/cmd/server/handlers_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ package main
import (
"testing"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/config"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/pipeline"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/ports"
"github.com/stretchr/testify/assert"
)

Expand DownExpand Up@@ -54,3 +56,18 @@ func TestSearchConfigFromRequestUsesSameCacheKeyForRequestsAboveLimit(t *testing

assert.Equal(t, key40, key100)
}

func TestSearchConfigFromRuntimePropagatesProviderLimits(t *testing.T) {
cfg := searchConfigFromRuntime(
domain.ScrapeRequest{MaxConcurrency: 4},
config.RuntimeConfig{
MaxConcurrency: 12,
ProviderMaxConcurrency: 3,
ProviderConcurrencyOverrides: map[ports.ProviderID]int{ports.ProviderGupy: 8},
},
)

assert.Equal(t, 4, cfg.MaxConcurrency)
assert.Equal(t, 3, cfg.ProviderMaxConcurrency)
assert.Equal(t, 8, cfg.ProviderConcurrencyOverrides[ports.ProviderGupy])
}
3 changes: 3 additions & 0 deletions scraper-go/cmd/server/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,9 @@ func logRuntimeConfig(cfg config.RuntimeConfig) {
slog.Info("scraper runtime configurado",
"max_concurrency", cfg.MaxConcurrency,
"max_concurrency_source", cfg.MaxConcurrencySource,
"provider_max_concurrency", cfg.ProviderMaxConcurrency,
"provider_max_concurrency_source", cfg.ProviderMaxConcurrencySource,
"provider_concurrency_overrides", cfg.ProviderConcurrencyOverrides,
"run_lock_ttl", cfg.RunLockTTL,
"run_lock_renew_interval", cfg.RunLockRenewInterval,
"gomaxprocs_effective", runtime.GOMAXPROCS(0),
Expand Down
2 changes: 2 additions & 0 deletions scraper-go/cmd/server/server.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,8 @@ func run(adapterList []ports.JobSource, runtimeCfg config.RuntimeConfig) {
// ── Scheduler (cronjob) ──
schedulerCfg := cronjob.DefaultConfig()
schedulerCfg.MaxConcurrency = runtimeCfg.MaxConcurrency
schedulerCfg.ProviderMaxConcurrency = runtimeCfg.ProviderMaxConcurrency
schedulerCfg.ProviderConcurrencyOverrides = runtimeCfg.ProviderConcurrencyOverrides
scheduler := cronjob.New(schedulerCfg, kwStore, jobStore, adapterList, rdb, runLock)

scheduler.OnComplete = func(kws []string, scraped, saved int, duration time.Duration) {
Expand Down
1 change: 1 addition & 0 deletions scraper-go/go.mod
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
go.uber.org/goleak v1.3.0
)

require (
Expand Down
20 changes: 20 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers.go
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
package adapterutil

import (
"context"
"html"
"strings"
"time"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
)

func Wait(ctx context.Context, duration time.Duration) error {
if cause := context.Cause(ctx); cause != nil {
return cause
}
if duration <= 0 {
return nil
}

timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return context.Cause(ctx)
}
}

func NonEmptyStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
Expand Down
22 changes: 22 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
package adapterutil

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWaitReturnsCancellationCauseWithoutWaitingForTimer(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cause := assert.AnError
cancel(cause)
started := time.Now()

err := Wait(ctx, time.Minute)

require.ErrorIs(t, err, cause)
assert.Less(t, time.Since(started), 100*time.Millisecond)
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@ SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner

# Scraping behavior
SCRAPER_MAX_CONCURRENCY=12
SCRAPER_PROVIDER_MAX_CONCURRENCY=2
SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=
SCRAPER_RUN_LOCK_TTL=120s
SCRAPER_RUN_LOCK_RENEW_INTERVAL=30s
GOMAXPROCS=2
Expand DownExpand Up@@ -87,7 +89,6 @@ INHIRE_ENABLED=true
INHIRE_TENANTS_FILE=./internal/interfaces/inhireTenants.json
INHIRE_ENRICH_DETAILS=false
INHIRE_DETAILS_MODE=ambiguous
INHIRE_DETAILS_CONCURRENCY=8
INHIRE_DETAILS_TIMEOUT_MS=10000
GREENHOUSE_ENABLED=true
GREENHOUSE_COMPANIES_FILE=./internal/interfaces/greenhouseCompanies.json
Expand Down
47 changes: 29 additions & 18 deletions SCRAPER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,16 +197,19 @@ No Compose da raiz, a porta `8081` fica exposta apenas na rede interna `vagas-ne

### Limites globais de execução

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.
O scraper possui um orçamento global e limites por provider controlados pelo scheduler do pipeline:

- `SCRAPER_MAX_CONCURRENCY`: teto global. Padrão interno `12`.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY`: limite padrão por provider. Padrão interno `2`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES`: overrides separados por vírgula no formato `provider=limite`, por exemplo `linkedin=1,gupy=3`.
- IDs aceitos nos overrides: `linkedin`, `adzuna`, `themuse`, `gupy`, `inhire`, `jooble`, `greenhouse` e `lever`.
- Limites devem ser inteiros positivos e não podem superar o teto global. Provider desconhecido, entrada malformada ou duplicada impede o startup.
- Valores inválidos explícitos nas variáveis numéricas (`""`, `0`, negativo ou não numérico) fazem a aplicação falhar no startup, sem fallback silencioso; overrides vazios significam que nenhum provider foi sobrescrito.
- Cron e `POST /admin/scrape` usam o limite global configurado.
- `POST /scrape` preserva o contrato atual: quando `maxConcurrency` não é informado, ou vem como `0`/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. O lock distribuído abaixo impede que duas execuções mantenham semáforos independentes ao mesmo tempo; limites específicos por provider permanecem para uma sub-issue posterior.
- A concorrência global efetiva continua fazendo parte da chave de cache. Limites por provider são parâmetros operacionais e não alteram a chave.
- Cada tarefa adquire primeiro o permit do provider e depois o permit global; o limite efetivo do provider é sempre o menor entre seu limite configurado e o global da requisição.
- O lock distribuído abaixo impede que duas execuções mantenham orçamentos independentes ao mesmo tempo.

### Lock distribuído de execução

Expand DownExpand Up@@ -302,7 +305,9 @@ Fluxo principal:
2. Verifica cache (`internal/cache`). Se encontrado, retorna resultado cacheado.
3. Caso contrário, executa `pipeline.ScrapeAllSources` que:
- 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 respeitando `MaxConcurrency`.
- Valida o ID, o modo de descoberta e a interface declarada por cada fonte antes de iniciar workers.
- Produz tarefas sob demanda em round-robin para uma fila limitada, consumida por um conjunto fixo de workers.
- Cria uma tarefa por keyword no modo `keyword`, uma tarefa com o conjunto controlado no modo `batch` e uma tarefa por catálogo/instância no modo `catalog`.
- 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.
Expand All@@ -311,15 +316,17 @@ Fluxo principal:

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.
- Orçamento global e por provider aplicado pelo pipeline; adapters não criam fan-out concorrente independente.
- Fila de tarefas limitada a duas vezes o teto global e workers fixos evitam materializar `adapters × keywords` ou abrir uma goroutine por tarefa.
- O produtor round-robin evita que um provider com muitas instâncias monopolize a fila.
- Cancelamento interrompe produção, espera por permits, paginação, retries e requisições HTTP; tarefas novas não começam após a perda do contexto/lock.
- Tratamento de status 429 com backoff; aborta apenas a keyword afetada em caso de falhas persistentes.
- Uso de `inflight` para 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.

## Adaptadores

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`.
Cada adaptador em `internal/adapters/<fonte>` implementa `ports.JobSource` e declara `ProviderID` e `DiscoveryMode`. Fontes `batch` implementam `ports.BatchJobSource`; fontes `catalog` implementam `ports.CatalogJobSource`. Fontes legadas sem capacidade explícita usam o fallback `keyword`.
Implementações incluem:

- `internal/adapters/linkedin` — busca via endpoint público `jobs-guest` do LinkedIn; parsing com `goquery`.
Expand All@@ -334,9 +341,10 @@ Implementações incluem:
- Adzuna: habilitado quando `ADZUNA_APP_ID` e `ADZUNA_APP_KEY` existem; `SearchBatch` usa 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 em `internal/interfaces/greenhouseCompanies.json`.
- Lever: habilitado com `LEVER_ENABLED=true`; cria adapters a partir de `internal/interfaces/leverCompanies.json`.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants de `internal/interfaces/inhireTenants.json` e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.
- Greenhouse: habilitado com `GREENHOUSE_ENABLED=true`; cria um catálogo por empresa listada em `internal/interfaces/greenhouseCompanies.json`, consulta cada catálogo uma vez e agrega todas as keywords correspondentes sem duplicar a vaga.
- Lever: habilitado com `LEVER_ENABLED=true`; cria catálogos por empresa a partir de `internal/interfaces/leverCompanies.json`.
- The Muse: consulta o catálogo uma vez por execução e filtra todas as keywords localmente.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants serialmente e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.

Boas práticas nos adaptadores:

Expand DownExpand Up@@ -371,12 +379,14 @@ docker compose \
config
```

Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.
Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `SCRAPER_PROVIDER_MAX_CONCURRENCY=2`, `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=""`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.

## Variáveis de ambiente importantes

- `VALKEY_URL` — conexão Redis/Valkey. Em Docker Compose, use `redis://valkey:6379/0`; em execução local fora do Docker, use uma URL acessível pelo host, por exemplo `redis://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.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY` — limite padrão por provider. Padrão: `2`. Deve ser positivo e não pode superar `SCRAPER_MAX_CONCURRENCY`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES` — lista opcional `provider=limite`, separada por vírgulas. Vazio significa nenhum override; entrada inválida, duplicada, desconhecida ou acima do teto global impede a inicialização.
- `SCRAPER_RUN_LOCK_TTL` — duração do lock distribuído. Padrão: `120s`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_TTL-120s}` (mesmo padrão fail-fast de `SCRAPER_MAX_CONCURRENCY`).
- `SCRAPER_RUN_LOCK_RENEW_INTERVAL` — intervalo de renovação. Padrão: `30s`; deve ser menor que `SCRAPER_RUN_LOCK_TTL`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}`.
- `GOMAXPROCS` — limite efetivo de threads executando código Go simultaneamente. Valor inicial no Compose: `2`.
Expand All@@ -389,16 +399,17 @@ Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12
- `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.
- `INHIRE_ENABLED`, `INHIRE_TENANTS_FILE`, `INHIRE_ENRICH_DETAILS`, `INHIRE_DETAILS_MODE`, `INHIRE_DETAILS_TIMEOUT_MS` — controlam fonte e enriquecimento InHire. `INHIRE_DETAILS_CONCURRENCY` foi removida; detalhes seguem o orçamento do provider.
- `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`.

## Observações operacionais

- Projetado para rodar frequentemente; use caching e indexação para reduzir chamadas repetidas.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` / semáforos por adaptador.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` e os limites globais/por provider.
- Verifique logs estruturados (slog JSON) e `/metrics` para métricas de sucesso/falhas por adaptador.
- O log `scraper concurrency budget` registra uma vez por execução o teto global, o default por provider e os overrides; `scraper provider execution summary` registra modo, tarefas produzidas/concluídas/canceladas, erros, timeouts e duração agregada.
- Logs `scraper run lock acquired`, `scraper execution skipped`, `scraper run lock lost` e `scraper run lock released` identificam `source` e `run_id`.

### Verificação operacional do lock
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ services:
- GO_SCRAPER_ADDR=:8081
- VALKEY_URL=redis://valkey:6379/0
- SCRAPER_MAX_CONCURRENCY=${SCRAPER_MAX_CONCURRENCY-12}
- SCRAPER_PROVIDER_MAX_CONCURRENCY=${SCRAPER_PROVIDER_MAX_CONCURRENCY-2}
- SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=${SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES-}
- SCRAPER_RUN_LOCK_TTL=${SCRAPER_RUN_LOCK_TTL-120s}
- SCRAPER_RUN_LOCK_RENEW_INTERVAL=${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}
- GOMAXPROCS=${GOMAXPROCS:-2}
Expand All@@ -26,7 +28,6 @@ services:
- INHIRE_TENANTS_FILE=/app/internal/interfaces/inhireTenants.json
- INHIRE_ENRICH_DETAILS=${INHIRE_ENRICH_DETAILS:-false}
- INHIRE_DETAILS_MODE=${INHIRE_DETAILS_MODE:-ambiguous}
- INHIRE_DETAILS_CONCURRENCY=${INHIRE_DETAILS_CONCURRENCY:-8}
- INHIRE_DETAILS_TIMEOUT_MS=${INHIRE_DETAILS_TIMEOUT_MS:-10000}
- GREENHOUSE_ENABLED=${GREENHOUSE_ENABLED:-true}
- GREENHOUSE_COMPANIES_FILE=/app/internal/interfaces/greenhouseCompanies.json
Expand Down
40 changes: 26 additions & 14 deletions scraper-go/cmd/server/handlers.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ func handleScrape(
ctx, cancel := context.WithTimeout(r.Context(), scrapeTimeout)
defer cancel()

searchConfig := searchConfigFromRequest(req, runtimeCfg.MaxConcurrency)
searchConfig := searchConfigFromRuntime(req, runtimeCfg)
slogScrapeStart("public_endpoint", runtimeCfg.MaxConcurrency, req.MaxConcurrency, searchConfig.MaxConcurrency, len(searchConfig.Keywords), len(adapterList))

start := time.Now()
Expand DownExpand Up@@ -85,20 +85,32 @@ func handleScrape(
}

func searchConfigFromRequest(req domain.ScrapeRequest, globalMaxConcurrency int) pipeline.SearchConfig {
return searchConfigFromRuntime(req, config.RuntimeConfig{
MaxConcurrency: globalMaxConcurrency,
ProviderMaxConcurrency: min(config.DefaultProviderMaxConcurrency, globalMaxConcurrency),
})
}

func searchConfigFromRuntime(
req domain.ScrapeRequest,
runtimeCfg config.RuntimeConfig,
) pipeline.SearchConfig {
return pipeline.SearchConfig{
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, globalMaxConcurrency),
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency),
ProviderMaxConcurrency: min(runtimeCfg.ProviderMaxConcurrency, config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency)),
ProviderConcurrencyOverrides: runtimeCfg.ProviderConcurrencyOverrides,
}
}

Expand Down
17 changes: 17 additions & 0 deletions scraper-go/cmd/server/handlers_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ package main
import (
"testing"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/config"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/pipeline"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/ports"
"github.com/stretchr/testify/assert"
)

Expand DownExpand Up@@ -54,3 +56,18 @@ func TestSearchConfigFromRequestUsesSameCacheKeyForRequestsAboveLimit(t *testing

assert.Equal(t, key40, key100)
}

func TestSearchConfigFromRuntimePropagatesProviderLimits(t *testing.T) {
cfg := searchConfigFromRuntime(
domain.ScrapeRequest{MaxConcurrency: 4},
config.RuntimeConfig{
MaxConcurrency: 12,
ProviderMaxConcurrency: 3,
ProviderConcurrencyOverrides: map[ports.ProviderID]int{ports.ProviderGupy: 8},
},
)

assert.Equal(t, 4, cfg.MaxConcurrency)
assert.Equal(t, 3, cfg.ProviderMaxConcurrency)
assert.Equal(t, 8, cfg.ProviderConcurrencyOverrides[ports.ProviderGupy])
}
3 changes: 3 additions & 0 deletions scraper-go/cmd/server/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,9 @@ func logRuntimeConfig(cfg config.RuntimeConfig) {
slog.Info("scraper runtime configurado",
"max_concurrency", cfg.MaxConcurrency,
"max_concurrency_source", cfg.MaxConcurrencySource,
"provider_max_concurrency", cfg.ProviderMaxConcurrency,
"provider_max_concurrency_source", cfg.ProviderMaxConcurrencySource,
"provider_concurrency_overrides", cfg.ProviderConcurrencyOverrides,
"run_lock_ttl", cfg.RunLockTTL,
"run_lock_renew_interval", cfg.RunLockRenewInterval,
"gomaxprocs_effective", runtime.GOMAXPROCS(0),
Expand Down
2 changes: 2 additions & 0 deletions scraper-go/cmd/server/server.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,8 @@ func run(adapterList []ports.JobSource, runtimeCfg config.RuntimeConfig) {
// ── Scheduler (cronjob) ──
schedulerCfg := cronjob.DefaultConfig()
schedulerCfg.MaxConcurrency = runtimeCfg.MaxConcurrency
schedulerCfg.ProviderMaxConcurrency = runtimeCfg.ProviderMaxConcurrency
schedulerCfg.ProviderConcurrencyOverrides = runtimeCfg.ProviderConcurrencyOverrides
scheduler := cronjob.New(schedulerCfg, kwStore, jobStore, adapterList, rdb, runLock)

scheduler.OnComplete = func(kws []string, scraped, saved int, duration time.Duration) {
Expand Down
1 change: 1 addition & 0 deletions scraper-go/go.mod
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
go.uber.org/goleak v1.3.0
)

require (
Expand Down
20 changes: 20 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers.go
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
package adapterutil

import (
"context"
"html"
"strings"
"time"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
)

func Wait(ctx context.Context, duration time.Duration) error {
if cause := context.Cause(ctx); cause != nil {
return cause
}
if duration <= 0 {
return nil
}

timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return context.Cause(ctx)
}
}

func NonEmptyStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
Expand Down
22 changes: 22 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
package adapterutil

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWaitReturnsCancellationCauseWithoutWaitingForTimer(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cause := assert.AnError
cancel(cause)
started := time.Now()

err := Wait(ctx, time.Minute)

require.ErrorIs(t, err, cause)
assert.Less(t, time.Since(started), 100*time.Millisecond)
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@ SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner

# Scraping behavior
SCRAPER_MAX_CONCURRENCY=12
SCRAPER_PROVIDER_MAX_CONCURRENCY=2
SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=
SCRAPER_RUN_LOCK_TTL=120s
SCRAPER_RUN_LOCK_RENEW_INTERVAL=30s
GOMAXPROCS=2
Expand DownExpand Up@@ -87,7 +89,6 @@ INHIRE_ENABLED=true
INHIRE_TENANTS_FILE=./internal/interfaces/inhireTenants.json
INHIRE_ENRICH_DETAILS=false
INHIRE_DETAILS_MODE=ambiguous
INHIRE_DETAILS_CONCURRENCY=8
INHIRE_DETAILS_TIMEOUT_MS=10000
GREENHOUSE_ENABLED=true
GREENHOUSE_COMPANIES_FILE=./internal/interfaces/greenhouseCompanies.json
Expand Down
47 changes: 29 additions & 18 deletions SCRAPER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,16 +197,19 @@ No Compose da raiz, a porta `8081` fica exposta apenas na rede interna `vagas-ne

### Limites globais de execução

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.
O scraper possui um orçamento global e limites por provider controlados pelo scheduler do pipeline:

- `SCRAPER_MAX_CONCURRENCY`: teto global. Padrão interno `12`.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY`: limite padrão por provider. Padrão interno `2`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES`: overrides separados por vírgula no formato `provider=limite`, por exemplo `linkedin=1,gupy=3`.
- IDs aceitos nos overrides: `linkedin`, `adzuna`, `themuse`, `gupy`, `inhire`, `jooble`, `greenhouse` e `lever`.
- Limites devem ser inteiros positivos e não podem superar o teto global. Provider desconhecido, entrada malformada ou duplicada impede o startup.
- Valores inválidos explícitos nas variáveis numéricas (`""`, `0`, negativo ou não numérico) fazem a aplicação falhar no startup, sem fallback silencioso; overrides vazios significam que nenhum provider foi sobrescrito.
- Cron e `POST /admin/scrape` usam o limite global configurado.
- `POST /scrape` preserva o contrato atual: quando `maxConcurrency` não é informado, ou vem como `0`/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. O lock distribuído abaixo impede que duas execuções mantenham semáforos independentes ao mesmo tempo; limites específicos por provider permanecem para uma sub-issue posterior.
- A concorrência global efetiva continua fazendo parte da chave de cache. Limites por provider são parâmetros operacionais e não alteram a chave.
- Cada tarefa adquire primeiro o permit do provider e depois o permit global; o limite efetivo do provider é sempre o menor entre seu limite configurado e o global da requisição.
- O lock distribuído abaixo impede que duas execuções mantenham orçamentos independentes ao mesmo tempo.

### Lock distribuído de execução

Expand DownExpand Up@@ -302,7 +305,9 @@ Fluxo principal:
2. Verifica cache (`internal/cache`). Se encontrado, retorna resultado cacheado.
3. Caso contrário, executa `pipeline.ScrapeAllSources` que:
- 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 respeitando `MaxConcurrency`.
- Valida o ID, o modo de descoberta e a interface declarada por cada fonte antes de iniciar workers.
- Produz tarefas sob demanda em round-robin para uma fila limitada, consumida por um conjunto fixo de workers.
- Cria uma tarefa por keyword no modo `keyword`, uma tarefa com o conjunto controlado no modo `batch` e uma tarefa por catálogo/instância no modo `catalog`.
- 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.
Expand All@@ -311,15 +316,17 @@ Fluxo principal:

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.
- Orçamento global e por provider aplicado pelo pipeline; adapters não criam fan-out concorrente independente.
- Fila de tarefas limitada a duas vezes o teto global e workers fixos evitam materializar `adapters × keywords` ou abrir uma goroutine por tarefa.
- O produtor round-robin evita que um provider com muitas instâncias monopolize a fila.
- Cancelamento interrompe produção, espera por permits, paginação, retries e requisições HTTP; tarefas novas não começam após a perda do contexto/lock.
- Tratamento de status 429 com backoff; aborta apenas a keyword afetada em caso de falhas persistentes.
- Uso de `inflight` para 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.

## Adaptadores

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`.
Cada adaptador em `internal/adapters/<fonte>` implementa `ports.JobSource` e declara `ProviderID` e `DiscoveryMode`. Fontes `batch` implementam `ports.BatchJobSource`; fontes `catalog` implementam `ports.CatalogJobSource`. Fontes legadas sem capacidade explícita usam o fallback `keyword`.
Implementações incluem:

- `internal/adapters/linkedin` — busca via endpoint público `jobs-guest` do LinkedIn; parsing com `goquery`.
Expand All@@ -334,9 +341,10 @@ Implementações incluem:
- Adzuna: habilitado quando `ADZUNA_APP_ID` e `ADZUNA_APP_KEY` existem; `SearchBatch` usa 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 em `internal/interfaces/greenhouseCompanies.json`.
- Lever: habilitado com `LEVER_ENABLED=true`; cria adapters a partir de `internal/interfaces/leverCompanies.json`.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants de `internal/interfaces/inhireTenants.json` e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.
- Greenhouse: habilitado com `GREENHOUSE_ENABLED=true`; cria um catálogo por empresa listada em `internal/interfaces/greenhouseCompanies.json`, consulta cada catálogo uma vez e agrega todas as keywords correspondentes sem duplicar a vaga.
- Lever: habilitado com `LEVER_ENABLED=true`; cria catálogos por empresa a partir de `internal/interfaces/leverCompanies.json`.
- The Muse: consulta o catálogo uma vez por execução e filtra todas as keywords localmente.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants serialmente e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.

Boas práticas nos adaptadores:

Expand DownExpand Up@@ -371,12 +379,14 @@ docker compose \
config
```

Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.
Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `SCRAPER_PROVIDER_MAX_CONCURRENCY=2`, `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=""`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.

## Variáveis de ambiente importantes

- `VALKEY_URL` — conexão Redis/Valkey. Em Docker Compose, use `redis://valkey:6379/0`; em execução local fora do Docker, use uma URL acessível pelo host, por exemplo `redis://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.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY` — limite padrão por provider. Padrão: `2`. Deve ser positivo e não pode superar `SCRAPER_MAX_CONCURRENCY`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES` — lista opcional `provider=limite`, separada por vírgulas. Vazio significa nenhum override; entrada inválida, duplicada, desconhecida ou acima do teto global impede a inicialização.
- `SCRAPER_RUN_LOCK_TTL` — duração do lock distribuído. Padrão: `120s`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_TTL-120s}` (mesmo padrão fail-fast de `SCRAPER_MAX_CONCURRENCY`).
- `SCRAPER_RUN_LOCK_RENEW_INTERVAL` — intervalo de renovação. Padrão: `30s`; deve ser menor que `SCRAPER_RUN_LOCK_TTL`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}`.
- `GOMAXPROCS` — limite efetivo de threads executando código Go simultaneamente. Valor inicial no Compose: `2`.
Expand All@@ -389,16 +399,17 @@ Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12
- `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.
- `INHIRE_ENABLED`, `INHIRE_TENANTS_FILE`, `INHIRE_ENRICH_DETAILS`, `INHIRE_DETAILS_MODE`, `INHIRE_DETAILS_TIMEOUT_MS` — controlam fonte e enriquecimento InHire. `INHIRE_DETAILS_CONCURRENCY` foi removida; detalhes seguem o orçamento do provider.
- `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`.

## Observações operacionais

- Projetado para rodar frequentemente; use caching e indexação para reduzir chamadas repetidas.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` / semáforos por adaptador.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` e os limites globais/por provider.
- Verifique logs estruturados (slog JSON) e `/metrics` para métricas de sucesso/falhas por adaptador.
- O log `scraper concurrency budget` registra uma vez por execução o teto global, o default por provider e os overrides; `scraper provider execution summary` registra modo, tarefas produzidas/concluídas/canceladas, erros, timeouts e duração agregada.
- Logs `scraper run lock acquired`, `scraper execution skipped`, `scraper run lock lost` e `scraper run lock released` identificam `source` e `run_id`.

### Verificação operacional do lock
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ services:
- GO_SCRAPER_ADDR=:8081
- VALKEY_URL=redis://valkey:6379/0
- SCRAPER_MAX_CONCURRENCY=${SCRAPER_MAX_CONCURRENCY-12}
- SCRAPER_PROVIDER_MAX_CONCURRENCY=${SCRAPER_PROVIDER_MAX_CONCURRENCY-2}
- SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=${SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES-}
- SCRAPER_RUN_LOCK_TTL=${SCRAPER_RUN_LOCK_TTL-120s}
- SCRAPER_RUN_LOCK_RENEW_INTERVAL=${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}
- GOMAXPROCS=${GOMAXPROCS:-2}
Expand All@@ -26,7 +28,6 @@ services:
- INHIRE_TENANTS_FILE=/app/internal/interfaces/inhireTenants.json
- INHIRE_ENRICH_DETAILS=${INHIRE_ENRICH_DETAILS:-false}
- INHIRE_DETAILS_MODE=${INHIRE_DETAILS_MODE:-ambiguous}
- INHIRE_DETAILS_CONCURRENCY=${INHIRE_DETAILS_CONCURRENCY:-8}
- INHIRE_DETAILS_TIMEOUT_MS=${INHIRE_DETAILS_TIMEOUT_MS:-10000}
- GREENHOUSE_ENABLED=${GREENHOUSE_ENABLED:-true}
- GREENHOUSE_COMPANIES_FILE=/app/internal/interfaces/greenhouseCompanies.json
Expand Down
40 changes: 26 additions & 14 deletions scraper-go/cmd/server/handlers.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ func handleScrape(
ctx, cancel := context.WithTimeout(r.Context(), scrapeTimeout)
defer cancel()

searchConfig := searchConfigFromRequest(req, runtimeCfg.MaxConcurrency)
searchConfig := searchConfigFromRuntime(req, runtimeCfg)
slogScrapeStart("public_endpoint", runtimeCfg.MaxConcurrency, req.MaxConcurrency, searchConfig.MaxConcurrency, len(searchConfig.Keywords), len(adapterList))

start := time.Now()
Expand DownExpand Up@@ -85,20 +85,32 @@ func handleScrape(
}

func searchConfigFromRequest(req domain.ScrapeRequest, globalMaxConcurrency int) pipeline.SearchConfig {
return searchConfigFromRuntime(req, config.RuntimeConfig{
MaxConcurrency: globalMaxConcurrency,
ProviderMaxConcurrency: min(config.DefaultProviderMaxConcurrency, globalMaxConcurrency),
})
}

func searchConfigFromRuntime(
req domain.ScrapeRequest,
runtimeCfg config.RuntimeConfig,
) pipeline.SearchConfig {
return pipeline.SearchConfig{
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, globalMaxConcurrency),
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency),
ProviderMaxConcurrency: min(runtimeCfg.ProviderMaxConcurrency, config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency)),
ProviderConcurrencyOverrides: runtimeCfg.ProviderConcurrencyOverrides,
}
}

Expand Down
17 changes: 17 additions & 0 deletions scraper-go/cmd/server/handlers_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ package main
import (
"testing"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/config"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/pipeline"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/ports"
"github.com/stretchr/testify/assert"
)

Expand DownExpand Up@@ -54,3 +56,18 @@ func TestSearchConfigFromRequestUsesSameCacheKeyForRequestsAboveLimit(t *testing

assert.Equal(t, key40, key100)
}

func TestSearchConfigFromRuntimePropagatesProviderLimits(t *testing.T) {
cfg := searchConfigFromRuntime(
domain.ScrapeRequest{MaxConcurrency: 4},
config.RuntimeConfig{
MaxConcurrency: 12,
ProviderMaxConcurrency: 3,
ProviderConcurrencyOverrides: map[ports.ProviderID]int{ports.ProviderGupy: 8},
},
)

assert.Equal(t, 4, cfg.MaxConcurrency)
assert.Equal(t, 3, cfg.ProviderMaxConcurrency)
assert.Equal(t, 8, cfg.ProviderConcurrencyOverrides[ports.ProviderGupy])
}
3 changes: 3 additions & 0 deletions scraper-go/cmd/server/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,9 @@ func logRuntimeConfig(cfg config.RuntimeConfig) {
slog.Info("scraper runtime configurado",
"max_concurrency", cfg.MaxConcurrency,
"max_concurrency_source", cfg.MaxConcurrencySource,
"provider_max_concurrency", cfg.ProviderMaxConcurrency,
"provider_max_concurrency_source", cfg.ProviderMaxConcurrencySource,
"provider_concurrency_overrides", cfg.ProviderConcurrencyOverrides,
"run_lock_ttl", cfg.RunLockTTL,
"run_lock_renew_interval", cfg.RunLockRenewInterval,
"gomaxprocs_effective", runtime.GOMAXPROCS(0),
Expand Down
2 changes: 2 additions & 0 deletions scraper-go/cmd/server/server.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,8 @@ func run(adapterList []ports.JobSource, runtimeCfg config.RuntimeConfig) {
// ── Scheduler (cronjob) ──
schedulerCfg := cronjob.DefaultConfig()
schedulerCfg.MaxConcurrency = runtimeCfg.MaxConcurrency
schedulerCfg.ProviderMaxConcurrency = runtimeCfg.ProviderMaxConcurrency
schedulerCfg.ProviderConcurrencyOverrides = runtimeCfg.ProviderConcurrencyOverrides
scheduler := cronjob.New(schedulerCfg, kwStore, jobStore, adapterList, rdb, runLock)

scheduler.OnComplete = func(kws []string, scraped, saved int, duration time.Duration) {
Expand Down
1 change: 1 addition & 0 deletions scraper-go/go.mod
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
go.uber.org/goleak v1.3.0
)

require (
Expand Down
20 changes: 20 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers.go
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
package adapterutil

import (
"context"
"html"
"strings"
"time"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
)

func Wait(ctx context.Context, duration time.Duration) error {
if cause := context.Cause(ctx); cause != nil {
return cause
}
if duration <= 0 {
return nil
}

timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return context.Cause(ctx)
}
}

func NonEmptyStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
Expand Down
22 changes: 22 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
package adapterutil

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWaitReturnsCancellationCauseWithoutWaitingForTimer(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cause := assert.AnError
cancel(cause)
started := time.Now()

err := Wait(ctx, time.Minute)

require.ErrorIs(t, err, cause)
assert.Less(t, time.Since(started), 100*time.Millisecond)
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@ SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner

# Scraping behavior
SCRAPER_MAX_CONCURRENCY=12
SCRAPER_PROVIDER_MAX_CONCURRENCY=2
SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=
SCRAPER_RUN_LOCK_TTL=120s
SCRAPER_RUN_LOCK_RENEW_INTERVAL=30s
GOMAXPROCS=2
Expand DownExpand Up@@ -87,7 +89,6 @@ INHIRE_ENABLED=true
INHIRE_TENANTS_FILE=./internal/interfaces/inhireTenants.json
INHIRE_ENRICH_DETAILS=false
INHIRE_DETAILS_MODE=ambiguous
INHIRE_DETAILS_CONCURRENCY=8
INHIRE_DETAILS_TIMEOUT_MS=10000
GREENHOUSE_ENABLED=true
GREENHOUSE_COMPANIES_FILE=./internal/interfaces/greenhouseCompanies.json
Expand Down
47 changes: 29 additions & 18 deletions SCRAPER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,16 +197,19 @@ No Compose da raiz, a porta `8081` fica exposta apenas na rede interna `vagas-ne

### Limites globais de execução

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.
O scraper possui um orçamento global e limites por provider controlados pelo scheduler do pipeline:

- `SCRAPER_MAX_CONCURRENCY`: teto global. Padrão interno `12`.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY`: limite padrão por provider. Padrão interno `2`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES`: overrides separados por vírgula no formato `provider=limite`, por exemplo `linkedin=1,gupy=3`.
- IDs aceitos nos overrides: `linkedin`, `adzuna`, `themuse`, `gupy`, `inhire`, `jooble`, `greenhouse` e `lever`.
- Limites devem ser inteiros positivos e não podem superar o teto global. Provider desconhecido, entrada malformada ou duplicada impede o startup.
- Valores inválidos explícitos nas variáveis numéricas (`""`, `0`, negativo ou não numérico) fazem a aplicação falhar no startup, sem fallback silencioso; overrides vazios significam que nenhum provider foi sobrescrito.
- Cron e `POST /admin/scrape` usam o limite global configurado.
- `POST /scrape` preserva o contrato atual: quando `maxConcurrency` não é informado, ou vem como `0`/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. O lock distribuído abaixo impede que duas execuções mantenham semáforos independentes ao mesmo tempo; limites específicos por provider permanecem para uma sub-issue posterior.
- A concorrência global efetiva continua fazendo parte da chave de cache. Limites por provider são parâmetros operacionais e não alteram a chave.
- Cada tarefa adquire primeiro o permit do provider e depois o permit global; o limite efetivo do provider é sempre o menor entre seu limite configurado e o global da requisição.
- O lock distribuído abaixo impede que duas execuções mantenham orçamentos independentes ao mesmo tempo.

### Lock distribuído de execução

Expand DownExpand Up@@ -302,7 +305,9 @@ Fluxo principal:
2. Verifica cache (`internal/cache`). Se encontrado, retorna resultado cacheado.
3. Caso contrário, executa `pipeline.ScrapeAllSources` que:
- 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 respeitando `MaxConcurrency`.
- Valida o ID, o modo de descoberta e a interface declarada por cada fonte antes de iniciar workers.
- Produz tarefas sob demanda em round-robin para uma fila limitada, consumida por um conjunto fixo de workers.
- Cria uma tarefa por keyword no modo `keyword`, uma tarefa com o conjunto controlado no modo `batch` e uma tarefa por catálogo/instância no modo `catalog`.
- 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.
Expand All@@ -311,15 +316,17 @@ Fluxo principal:

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.
- Orçamento global e por provider aplicado pelo pipeline; adapters não criam fan-out concorrente independente.
- Fila de tarefas limitada a duas vezes o teto global e workers fixos evitam materializar `adapters × keywords` ou abrir uma goroutine por tarefa.
- O produtor round-robin evita que um provider com muitas instâncias monopolize a fila.
- Cancelamento interrompe produção, espera por permits, paginação, retries e requisições HTTP; tarefas novas não começam após a perda do contexto/lock.
- Tratamento de status 429 com backoff; aborta apenas a keyword afetada em caso de falhas persistentes.
- Uso de `inflight` para 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.

## Adaptadores

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`.
Cada adaptador em `internal/adapters/<fonte>` implementa `ports.JobSource` e declara `ProviderID` e `DiscoveryMode`. Fontes `batch` implementam `ports.BatchJobSource`; fontes `catalog` implementam `ports.CatalogJobSource`. Fontes legadas sem capacidade explícita usam o fallback `keyword`.
Implementações incluem:

- `internal/adapters/linkedin` — busca via endpoint público `jobs-guest` do LinkedIn; parsing com `goquery`.
Expand All@@ -334,9 +341,10 @@ Implementações incluem:
- Adzuna: habilitado quando `ADZUNA_APP_ID` e `ADZUNA_APP_KEY` existem; `SearchBatch` usa 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 em `internal/interfaces/greenhouseCompanies.json`.
- Lever: habilitado com `LEVER_ENABLED=true`; cria adapters a partir de `internal/interfaces/leverCompanies.json`.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants de `internal/interfaces/inhireTenants.json` e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.
- Greenhouse: habilitado com `GREENHOUSE_ENABLED=true`; cria um catálogo por empresa listada em `internal/interfaces/greenhouseCompanies.json`, consulta cada catálogo uma vez e agrega todas as keywords correspondentes sem duplicar a vaga.
- Lever: habilitado com `LEVER_ENABLED=true`; cria catálogos por empresa a partir de `internal/interfaces/leverCompanies.json`.
- The Muse: consulta o catálogo uma vez por execução e filtra todas as keywords localmente.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants serialmente e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.

Boas práticas nos adaptadores:

Expand DownExpand Up@@ -371,12 +379,14 @@ docker compose \
config
```

Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.
Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `SCRAPER_PROVIDER_MAX_CONCURRENCY=2`, `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=""`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.

## Variáveis de ambiente importantes

- `VALKEY_URL` — conexão Redis/Valkey. Em Docker Compose, use `redis://valkey:6379/0`; em execução local fora do Docker, use uma URL acessível pelo host, por exemplo `redis://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.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY` — limite padrão por provider. Padrão: `2`. Deve ser positivo e não pode superar `SCRAPER_MAX_CONCURRENCY`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES` — lista opcional `provider=limite`, separada por vírgulas. Vazio significa nenhum override; entrada inválida, duplicada, desconhecida ou acima do teto global impede a inicialização.
- `SCRAPER_RUN_LOCK_TTL` — duração do lock distribuído. Padrão: `120s`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_TTL-120s}` (mesmo padrão fail-fast de `SCRAPER_MAX_CONCURRENCY`).
- `SCRAPER_RUN_LOCK_RENEW_INTERVAL` — intervalo de renovação. Padrão: `30s`; deve ser menor que `SCRAPER_RUN_LOCK_TTL`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}`.
- `GOMAXPROCS` — limite efetivo de threads executando código Go simultaneamente. Valor inicial no Compose: `2`.
Expand All@@ -389,16 +399,17 @@ Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12
- `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.
- `INHIRE_ENABLED`, `INHIRE_TENANTS_FILE`, `INHIRE_ENRICH_DETAILS`, `INHIRE_DETAILS_MODE`, `INHIRE_DETAILS_TIMEOUT_MS` — controlam fonte e enriquecimento InHire. `INHIRE_DETAILS_CONCURRENCY` foi removida; detalhes seguem o orçamento do provider.
- `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`.

## Observações operacionais

- Projetado para rodar frequentemente; use caching e indexação para reduzir chamadas repetidas.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` / semáforos por adaptador.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` e os limites globais/por provider.
- Verifique logs estruturados (slog JSON) e `/metrics` para métricas de sucesso/falhas por adaptador.
- O log `scraper concurrency budget` registra uma vez por execução o teto global, o default por provider e os overrides; `scraper provider execution summary` registra modo, tarefas produzidas/concluídas/canceladas, erros, timeouts e duração agregada.
- Logs `scraper run lock acquired`, `scraper execution skipped`, `scraper run lock lost` e `scraper run lock released` identificam `source` e `run_id`.

### Verificação operacional do lock
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ services:
- GO_SCRAPER_ADDR=:8081
- VALKEY_URL=redis://valkey:6379/0
- SCRAPER_MAX_CONCURRENCY=${SCRAPER_MAX_CONCURRENCY-12}
- SCRAPER_PROVIDER_MAX_CONCURRENCY=${SCRAPER_PROVIDER_MAX_CONCURRENCY-2}
- SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=${SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES-}
- SCRAPER_RUN_LOCK_TTL=${SCRAPER_RUN_LOCK_TTL-120s}
- SCRAPER_RUN_LOCK_RENEW_INTERVAL=${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}
- GOMAXPROCS=${GOMAXPROCS:-2}
Expand All@@ -26,7 +28,6 @@ services:
- INHIRE_TENANTS_FILE=/app/internal/interfaces/inhireTenants.json
- INHIRE_ENRICH_DETAILS=${INHIRE_ENRICH_DETAILS:-false}
- INHIRE_DETAILS_MODE=${INHIRE_DETAILS_MODE:-ambiguous}
- INHIRE_DETAILS_CONCURRENCY=${INHIRE_DETAILS_CONCURRENCY:-8}
- INHIRE_DETAILS_TIMEOUT_MS=${INHIRE_DETAILS_TIMEOUT_MS:-10000}
- GREENHOUSE_ENABLED=${GREENHOUSE_ENABLED:-true}
- GREENHOUSE_COMPANIES_FILE=/app/internal/interfaces/greenhouseCompanies.json
Expand Down
40 changes: 26 additions & 14 deletions scraper-go/cmd/server/handlers.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ func handleScrape(
ctx, cancel := context.WithTimeout(r.Context(), scrapeTimeout)
defer cancel()

searchConfig := searchConfigFromRequest(req, runtimeCfg.MaxConcurrency)
searchConfig := searchConfigFromRuntime(req, runtimeCfg)
slogScrapeStart("public_endpoint", runtimeCfg.MaxConcurrency, req.MaxConcurrency, searchConfig.MaxConcurrency, len(searchConfig.Keywords), len(adapterList))

start := time.Now()
Expand DownExpand Up@@ -85,20 +85,32 @@ func handleScrape(
}

func searchConfigFromRequest(req domain.ScrapeRequest, globalMaxConcurrency int) pipeline.SearchConfig {
return searchConfigFromRuntime(req, config.RuntimeConfig{
MaxConcurrency: globalMaxConcurrency,
ProviderMaxConcurrency: min(config.DefaultProviderMaxConcurrency, globalMaxConcurrency),
})
}

func searchConfigFromRuntime(
req domain.ScrapeRequest,
runtimeCfg config.RuntimeConfig,
) pipeline.SearchConfig {
return pipeline.SearchConfig{
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, globalMaxConcurrency),
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency),
ProviderMaxConcurrency: min(runtimeCfg.ProviderMaxConcurrency, config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency)),
ProviderConcurrencyOverrides: runtimeCfg.ProviderConcurrencyOverrides,
}
}

Expand Down
17 changes: 17 additions & 0 deletions scraper-go/cmd/server/handlers_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ package main
import (
"testing"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/config"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/pipeline"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/ports"
"github.com/stretchr/testify/assert"
)

Expand DownExpand Up@@ -54,3 +56,18 @@ func TestSearchConfigFromRequestUsesSameCacheKeyForRequestsAboveLimit(t *testing

assert.Equal(t, key40, key100)
}

func TestSearchConfigFromRuntimePropagatesProviderLimits(t *testing.T) {
cfg := searchConfigFromRuntime(
domain.ScrapeRequest{MaxConcurrency: 4},
config.RuntimeConfig{
MaxConcurrency: 12,
ProviderMaxConcurrency: 3,
ProviderConcurrencyOverrides: map[ports.ProviderID]int{ports.ProviderGupy: 8},
},
)

assert.Equal(t, 4, cfg.MaxConcurrency)
assert.Equal(t, 3, cfg.ProviderMaxConcurrency)
assert.Equal(t, 8, cfg.ProviderConcurrencyOverrides[ports.ProviderGupy])
}
3 changes: 3 additions & 0 deletions scraper-go/cmd/server/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,9 @@ func logRuntimeConfig(cfg config.RuntimeConfig) {
slog.Info("scraper runtime configurado",
"max_concurrency", cfg.MaxConcurrency,
"max_concurrency_source", cfg.MaxConcurrencySource,
"provider_max_concurrency", cfg.ProviderMaxConcurrency,
"provider_max_concurrency_source", cfg.ProviderMaxConcurrencySource,
"provider_concurrency_overrides", cfg.ProviderConcurrencyOverrides,
"run_lock_ttl", cfg.RunLockTTL,
"run_lock_renew_interval", cfg.RunLockRenewInterval,
"gomaxprocs_effective", runtime.GOMAXPROCS(0),
Expand Down
2 changes: 2 additions & 0 deletions scraper-go/cmd/server/server.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,8 @@ func run(adapterList []ports.JobSource, runtimeCfg config.RuntimeConfig) {
// ── Scheduler (cronjob) ──
schedulerCfg := cronjob.DefaultConfig()
schedulerCfg.MaxConcurrency = runtimeCfg.MaxConcurrency
schedulerCfg.ProviderMaxConcurrency = runtimeCfg.ProviderMaxConcurrency
schedulerCfg.ProviderConcurrencyOverrides = runtimeCfg.ProviderConcurrencyOverrides
scheduler := cronjob.New(schedulerCfg, kwStore, jobStore, adapterList, rdb, runLock)

scheduler.OnComplete = func(kws []string, scraped, saved int, duration time.Duration) {
Expand Down
1 change: 1 addition & 0 deletions scraper-go/go.mod
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
go.uber.org/goleak v1.3.0
)

require (
Expand Down
20 changes: 20 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers.go
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
package adapterutil

import (
"context"
"html"
"strings"
"time"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
)

func Wait(ctx context.Context, duration time.Duration) error {
if cause := context.Cause(ctx); cause != nil {
return cause
}
if duration <= 0 {
return nil
}

timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return context.Cause(ctx)
}
}

func NonEmptyStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
Expand Down
22 changes: 22 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
package adapterutil

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWaitReturnsCancellationCauseWithoutWaitingForTimer(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cause := assert.AnError
cancel(cause)
started := time.Now()

err := Wait(ctx, time.Minute)

require.ErrorIs(t, err, cause)
assert.Less(t, time.Since(started), 100*time.Millisecond)
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@ SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner

# Scraping behavior
SCRAPER_MAX_CONCURRENCY=12
SCRAPER_PROVIDER_MAX_CONCURRENCY=2
SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=
SCRAPER_RUN_LOCK_TTL=120s
SCRAPER_RUN_LOCK_RENEW_INTERVAL=30s
GOMAXPROCS=2
Expand DownExpand Up@@ -87,7 +89,6 @@ INHIRE_ENABLED=true
INHIRE_TENANTS_FILE=./internal/interfaces/inhireTenants.json
INHIRE_ENRICH_DETAILS=false
INHIRE_DETAILS_MODE=ambiguous
INHIRE_DETAILS_CONCURRENCY=8
INHIRE_DETAILS_TIMEOUT_MS=10000
GREENHOUSE_ENABLED=true
GREENHOUSE_COMPANIES_FILE=./internal/interfaces/greenhouseCompanies.json
Expand Down
47 changes: 29 additions & 18 deletions SCRAPER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,16 +197,19 @@ No Compose da raiz, a porta `8081` fica exposta apenas na rede interna `vagas-ne

### Limites globais de execução

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.
O scraper possui um orçamento global e limites por provider controlados pelo scheduler do pipeline:

- `SCRAPER_MAX_CONCURRENCY`: teto global. Padrão interno `12`.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY`: limite padrão por provider. Padrão interno `2`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES`: overrides separados por vírgula no formato `provider=limite`, por exemplo `linkedin=1,gupy=3`.
- IDs aceitos nos overrides: `linkedin`, `adzuna`, `themuse`, `gupy`, `inhire`, `jooble`, `greenhouse` e `lever`.
- Limites devem ser inteiros positivos e não podem superar o teto global. Provider desconhecido, entrada malformada ou duplicada impede o startup.
- Valores inválidos explícitos nas variáveis numéricas (`""`, `0`, negativo ou não numérico) fazem a aplicação falhar no startup, sem fallback silencioso; overrides vazios significam que nenhum provider foi sobrescrito.
- Cron e `POST /admin/scrape` usam o limite global configurado.
- `POST /scrape` preserva o contrato atual: quando `maxConcurrency` não é informado, ou vem como `0`/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. O lock distribuído abaixo impede que duas execuções mantenham semáforos independentes ao mesmo tempo; limites específicos por provider permanecem para uma sub-issue posterior.
- A concorrência global efetiva continua fazendo parte da chave de cache. Limites por provider são parâmetros operacionais e não alteram a chave.
- Cada tarefa adquire primeiro o permit do provider e depois o permit global; o limite efetivo do provider é sempre o menor entre seu limite configurado e o global da requisição.
- O lock distribuído abaixo impede que duas execuções mantenham orçamentos independentes ao mesmo tempo.

### Lock distribuído de execução

Expand DownExpand Up@@ -302,7 +305,9 @@ Fluxo principal:
2. Verifica cache (`internal/cache`). Se encontrado, retorna resultado cacheado.
3. Caso contrário, executa `pipeline.ScrapeAllSources` que:
- 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 respeitando `MaxConcurrency`.
- Valida o ID, o modo de descoberta e a interface declarada por cada fonte antes de iniciar workers.
- Produz tarefas sob demanda em round-robin para uma fila limitada, consumida por um conjunto fixo de workers.
- Cria uma tarefa por keyword no modo `keyword`, uma tarefa com o conjunto controlado no modo `batch` e uma tarefa por catálogo/instância no modo `catalog`.
- 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.
Expand All@@ -311,15 +316,17 @@ Fluxo principal:

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.
- Orçamento global e por provider aplicado pelo pipeline; adapters não criam fan-out concorrente independente.
- Fila de tarefas limitada a duas vezes o teto global e workers fixos evitam materializar `adapters × keywords` ou abrir uma goroutine por tarefa.
- O produtor round-robin evita que um provider com muitas instâncias monopolize a fila.
- Cancelamento interrompe produção, espera por permits, paginação, retries e requisições HTTP; tarefas novas não começam após a perda do contexto/lock.
- Tratamento de status 429 com backoff; aborta apenas a keyword afetada em caso de falhas persistentes.
- Uso de `inflight` para 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.

## Adaptadores

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`.
Cada adaptador em `internal/adapters/<fonte>` implementa `ports.JobSource` e declara `ProviderID` e `DiscoveryMode`. Fontes `batch` implementam `ports.BatchJobSource`; fontes `catalog` implementam `ports.CatalogJobSource`. Fontes legadas sem capacidade explícita usam o fallback `keyword`.
Implementações incluem:

- `internal/adapters/linkedin` — busca via endpoint público `jobs-guest` do LinkedIn; parsing com `goquery`.
Expand All@@ -334,9 +341,10 @@ Implementações incluem:
- Adzuna: habilitado quando `ADZUNA_APP_ID` e `ADZUNA_APP_KEY` existem; `SearchBatch` usa 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 em `internal/interfaces/greenhouseCompanies.json`.
- Lever: habilitado com `LEVER_ENABLED=true`; cria adapters a partir de `internal/interfaces/leverCompanies.json`.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants de `internal/interfaces/inhireTenants.json` e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.
- Greenhouse: habilitado com `GREENHOUSE_ENABLED=true`; cria um catálogo por empresa listada em `internal/interfaces/greenhouseCompanies.json`, consulta cada catálogo uma vez e agrega todas as keywords correspondentes sem duplicar a vaga.
- Lever: habilitado com `LEVER_ENABLED=true`; cria catálogos por empresa a partir de `internal/interfaces/leverCompanies.json`.
- The Muse: consulta o catálogo uma vez por execução e filtra todas as keywords localmente.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants serialmente e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.

Boas práticas nos adaptadores:

Expand DownExpand Up@@ -371,12 +379,14 @@ docker compose \
config
```

Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.
Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `SCRAPER_PROVIDER_MAX_CONCURRENCY=2`, `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=""`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.

## Variáveis de ambiente importantes

- `VALKEY_URL` — conexão Redis/Valkey. Em Docker Compose, use `redis://valkey:6379/0`; em execução local fora do Docker, use uma URL acessível pelo host, por exemplo `redis://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.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY` — limite padrão por provider. Padrão: `2`. Deve ser positivo e não pode superar `SCRAPER_MAX_CONCURRENCY`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES` — lista opcional `provider=limite`, separada por vírgulas. Vazio significa nenhum override; entrada inválida, duplicada, desconhecida ou acima do teto global impede a inicialização.
- `SCRAPER_RUN_LOCK_TTL` — duração do lock distribuído. Padrão: `120s`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_TTL-120s}` (mesmo padrão fail-fast de `SCRAPER_MAX_CONCURRENCY`).
- `SCRAPER_RUN_LOCK_RENEW_INTERVAL` — intervalo de renovação. Padrão: `30s`; deve ser menor que `SCRAPER_RUN_LOCK_TTL`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}`.
- `GOMAXPROCS` — limite efetivo de threads executando código Go simultaneamente. Valor inicial no Compose: `2`.
Expand All@@ -389,16 +399,17 @@ Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12
- `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.
- `INHIRE_ENABLED`, `INHIRE_TENANTS_FILE`, `INHIRE_ENRICH_DETAILS`, `INHIRE_DETAILS_MODE`, `INHIRE_DETAILS_TIMEOUT_MS` — controlam fonte e enriquecimento InHire. `INHIRE_DETAILS_CONCURRENCY` foi removida; detalhes seguem o orçamento do provider.
- `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`.

## Observações operacionais

- Projetado para rodar frequentemente; use caching e indexação para reduzir chamadas repetidas.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` / semáforos por adaptador.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` e os limites globais/por provider.
- Verifique logs estruturados (slog JSON) e `/metrics` para métricas de sucesso/falhas por adaptador.
- O log `scraper concurrency budget` registra uma vez por execução o teto global, o default por provider e os overrides; `scraper provider execution summary` registra modo, tarefas produzidas/concluídas/canceladas, erros, timeouts e duração agregada.
- Logs `scraper run lock acquired`, `scraper execution skipped`, `scraper run lock lost` e `scraper run lock released` identificam `source` e `run_id`.

### Verificação operacional do lock
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ services:
- GO_SCRAPER_ADDR=:8081
- VALKEY_URL=redis://valkey:6379/0
- SCRAPER_MAX_CONCURRENCY=${SCRAPER_MAX_CONCURRENCY-12}
- SCRAPER_PROVIDER_MAX_CONCURRENCY=${SCRAPER_PROVIDER_MAX_CONCURRENCY-2}
- SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=${SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES-}
- SCRAPER_RUN_LOCK_TTL=${SCRAPER_RUN_LOCK_TTL-120s}
- SCRAPER_RUN_LOCK_RENEW_INTERVAL=${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}
- GOMAXPROCS=${GOMAXPROCS:-2}
Expand All@@ -26,7 +28,6 @@ services:
- INHIRE_TENANTS_FILE=/app/internal/interfaces/inhireTenants.json
- INHIRE_ENRICH_DETAILS=${INHIRE_ENRICH_DETAILS:-false}
- INHIRE_DETAILS_MODE=${INHIRE_DETAILS_MODE:-ambiguous}
- INHIRE_DETAILS_CONCURRENCY=${INHIRE_DETAILS_CONCURRENCY:-8}
- INHIRE_DETAILS_TIMEOUT_MS=${INHIRE_DETAILS_TIMEOUT_MS:-10000}
- GREENHOUSE_ENABLED=${GREENHOUSE_ENABLED:-true}
- GREENHOUSE_COMPANIES_FILE=/app/internal/interfaces/greenhouseCompanies.json
Expand Down
40 changes: 26 additions & 14 deletions scraper-go/cmd/server/handlers.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ func handleScrape(
ctx, cancel := context.WithTimeout(r.Context(), scrapeTimeout)
defer cancel()

searchConfig := searchConfigFromRequest(req, runtimeCfg.MaxConcurrency)
searchConfig := searchConfigFromRuntime(req, runtimeCfg)
slogScrapeStart("public_endpoint", runtimeCfg.MaxConcurrency, req.MaxConcurrency, searchConfig.MaxConcurrency, len(searchConfig.Keywords), len(adapterList))

start := time.Now()
Expand DownExpand Up@@ -85,20 +85,32 @@ func handleScrape(
}

func searchConfigFromRequest(req domain.ScrapeRequest, globalMaxConcurrency int) pipeline.SearchConfig {
return searchConfigFromRuntime(req, config.RuntimeConfig{
MaxConcurrency: globalMaxConcurrency,
ProviderMaxConcurrency: min(config.DefaultProviderMaxConcurrency, globalMaxConcurrency),
})
}

func searchConfigFromRuntime(
req domain.ScrapeRequest,
runtimeCfg config.RuntimeConfig,
) pipeline.SearchConfig {
return pipeline.SearchConfig{
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, globalMaxConcurrency),
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency),
ProviderMaxConcurrency: min(runtimeCfg.ProviderMaxConcurrency, config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency)),
ProviderConcurrencyOverrides: runtimeCfg.ProviderConcurrencyOverrides,
}
}

Expand Down
17 changes: 17 additions & 0 deletions scraper-go/cmd/server/handlers_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ package main
import (
"testing"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/config"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/pipeline"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/ports"
"github.com/stretchr/testify/assert"
)

Expand DownExpand Up@@ -54,3 +56,18 @@ func TestSearchConfigFromRequestUsesSameCacheKeyForRequestsAboveLimit(t *testing

assert.Equal(t, key40, key100)
}

func TestSearchConfigFromRuntimePropagatesProviderLimits(t *testing.T) {
cfg := searchConfigFromRuntime(
domain.ScrapeRequest{MaxConcurrency: 4},
config.RuntimeConfig{
MaxConcurrency: 12,
ProviderMaxConcurrency: 3,
ProviderConcurrencyOverrides: map[ports.ProviderID]int{ports.ProviderGupy: 8},
},
)

assert.Equal(t, 4, cfg.MaxConcurrency)
assert.Equal(t, 3, cfg.ProviderMaxConcurrency)
assert.Equal(t, 8, cfg.ProviderConcurrencyOverrides[ports.ProviderGupy])
}
3 changes: 3 additions & 0 deletions scraper-go/cmd/server/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,9 @@ func logRuntimeConfig(cfg config.RuntimeConfig) {
slog.Info("scraper runtime configurado",
"max_concurrency", cfg.MaxConcurrency,
"max_concurrency_source", cfg.MaxConcurrencySource,
"provider_max_concurrency", cfg.ProviderMaxConcurrency,
"provider_max_concurrency_source", cfg.ProviderMaxConcurrencySource,
"provider_concurrency_overrides", cfg.ProviderConcurrencyOverrides,
"run_lock_ttl", cfg.RunLockTTL,
"run_lock_renew_interval", cfg.RunLockRenewInterval,
"gomaxprocs_effective", runtime.GOMAXPROCS(0),
Expand Down
2 changes: 2 additions & 0 deletions scraper-go/cmd/server/server.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,8 @@ func run(adapterList []ports.JobSource, runtimeCfg config.RuntimeConfig) {
// ── Scheduler (cronjob) ──
schedulerCfg := cronjob.DefaultConfig()
schedulerCfg.MaxConcurrency = runtimeCfg.MaxConcurrency
schedulerCfg.ProviderMaxConcurrency = runtimeCfg.ProviderMaxConcurrency
schedulerCfg.ProviderConcurrencyOverrides = runtimeCfg.ProviderConcurrencyOverrides
scheduler := cronjob.New(schedulerCfg, kwStore, jobStore, adapterList, rdb, runLock)

scheduler.OnComplete = func(kws []string, scraped, saved int, duration time.Duration) {
Expand Down
1 change: 1 addition & 0 deletions scraper-go/go.mod
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
go.uber.org/goleak v1.3.0
)

require (
Expand Down
20 changes: 20 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers.go
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
package adapterutil

import (
"context"
"html"
"strings"
"time"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
)

func Wait(ctx context.Context, duration time.Duration) error {
if cause := context.Cause(ctx); cause != nil {
return cause
}
if duration <= 0 {
return nil
}

timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return context.Cause(ctx)
}
}

func NonEmptyStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
Expand Down
22 changes: 22 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
package adapterutil

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWaitReturnsCancellationCauseWithoutWaitingForTimer(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cause := assert.AnError
cancel(cause)
started := time.Now()

err := Wait(ctx, time.Minute)

require.ErrorIs(t, err, cause)
assert.Less(t, time.Since(started), 100*time.Millisecond)
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,8 @@ SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner

# Scraping behavior
SCRAPER_MAX_CONCURRENCY=12
SCRAPER_PROVIDER_MAX_CONCURRENCY=2
SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=
SCRAPER_RUN_LOCK_TTL=120s
SCRAPER_RUN_LOCK_RENEW_INTERVAL=30s
GOMAXPROCS=2
Expand DownExpand Up@@ -87,7 +89,6 @@ INHIRE_ENABLED=true
INHIRE_TENANTS_FILE=./internal/interfaces/inhireTenants.json
INHIRE_ENRICH_DETAILS=false
INHIRE_DETAILS_MODE=ambiguous
INHIRE_DETAILS_CONCURRENCY=8
INHIRE_DETAILS_TIMEOUT_MS=10000
GREENHOUSE_ENABLED=true
GREENHOUSE_COMPANIES_FILE=./internal/interfaces/greenhouseCompanies.json
Expand Down
47 changes: 29 additions & 18 deletions SCRAPER.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,16 +197,19 @@ No Compose da raiz, a porta `8081` fica exposta apenas na rede interna `vagas-ne

### Limites globais de execução

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.
O scraper possui um orçamento global e limites por provider controlados pelo scheduler do pipeline:

- `SCRAPER_MAX_CONCURRENCY`: teto global. Padrão interno `12`.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY`: limite padrão por provider. Padrão interno `2`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES`: overrides separados por vírgula no formato `provider=limite`, por exemplo `linkedin=1,gupy=3`.
- IDs aceitos nos overrides: `linkedin`, `adzuna`, `themuse`, `gupy`, `inhire`, `jooble`, `greenhouse` e `lever`.
- Limites devem ser inteiros positivos e não podem superar o teto global. Provider desconhecido, entrada malformada ou duplicada impede o startup.
- Valores inválidos explícitos nas variáveis numéricas (`""`, `0`, negativo ou não numérico) fazem a aplicação falhar no startup, sem fallback silencioso; overrides vazios significam que nenhum provider foi sobrescrito.
- Cron e `POST /admin/scrape` usam o limite global configurado.
- `POST /scrape` preserva o contrato atual: quando `maxConcurrency` não é informado, ou vem como `0`/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. O lock distribuído abaixo impede que duas execuções mantenham semáforos independentes ao mesmo tempo; limites específicos por provider permanecem para uma sub-issue posterior.
- A concorrência global efetiva continua fazendo parte da chave de cache. Limites por provider são parâmetros operacionais e não alteram a chave.
- Cada tarefa adquire primeiro o permit do provider e depois o permit global; o limite efetivo do provider é sempre o menor entre seu limite configurado e o global da requisição.
- O lock distribuído abaixo impede que duas execuções mantenham orçamentos independentes ao mesmo tempo.

### Lock distribuído de execução

Expand DownExpand Up@@ -302,7 +305,9 @@ Fluxo principal:
2. Verifica cache (`internal/cache`). Se encontrado, retorna resultado cacheado.
3. Caso contrário, executa `pipeline.ScrapeAllSources` que:
- 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 respeitando `MaxConcurrency`.
- Valida o ID, o modo de descoberta e a interface declarada por cada fonte antes de iniciar workers.
- Produz tarefas sob demanda em round-robin para uma fila limitada, consumida por um conjunto fixo de workers.
- Cria uma tarefa por keyword no modo `keyword`, uma tarefa com o conjunto controlado no modo `batch` e uma tarefa por catálogo/instância no modo `catalog`.
- 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.
Expand All@@ -311,15 +316,17 @@ Fluxo principal:

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.
- Orçamento global e por provider aplicado pelo pipeline; adapters não criam fan-out concorrente independente.
- Fila de tarefas limitada a duas vezes o teto global e workers fixos evitam materializar `adapters × keywords` ou abrir uma goroutine por tarefa.
- O produtor round-robin evita que um provider com muitas instâncias monopolize a fila.
- Cancelamento interrompe produção, espera por permits, paginação, retries e requisições HTTP; tarefas novas não começam após a perda do contexto/lock.
- Tratamento de status 429 com backoff; aborta apenas a keyword afetada em caso de falhas persistentes.
- Uso de `inflight` para 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.

## Adaptadores

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`.
Cada adaptador em `internal/adapters/<fonte>` implementa `ports.JobSource` e declara `ProviderID` e `DiscoveryMode`. Fontes `batch` implementam `ports.BatchJobSource`; fontes `catalog` implementam `ports.CatalogJobSource`. Fontes legadas sem capacidade explícita usam o fallback `keyword`.
Implementações incluem:

- `internal/adapters/linkedin` — busca via endpoint público `jobs-guest` do LinkedIn; parsing com `goquery`.
Expand All@@ -334,9 +341,10 @@ Implementações incluem:
- Adzuna: habilitado quando `ADZUNA_APP_ID` e `ADZUNA_APP_KEY` existem; `SearchBatch` usa 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 em `internal/interfaces/greenhouseCompanies.json`.
- Lever: habilitado com `LEVER_ENABLED=true`; cria adapters a partir de `internal/interfaces/leverCompanies.json`.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants de `internal/interfaces/inhireTenants.json` e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.
- Greenhouse: habilitado com `GREENHOUSE_ENABLED=true`; cria um catálogo por empresa listada em `internal/interfaces/greenhouseCompanies.json`, consulta cada catálogo uma vez e agrega todas as keywords correspondentes sem duplicar a vaga.
- Lever: habilitado com `LEVER_ENABLED=true`; cria catálogos por empresa a partir de `internal/interfaces/leverCompanies.json`.
- The Muse: consulta o catálogo uma vez por execução e filtra todas as keywords localmente.
- InHire: habilitado com `INHIRE_ENABLED=true`; consulta tenants serialmente e só enriquece detalhes quando `INHIRE_ENRICH_DETAILS=true`.

Boas práticas nos adaptadores:

Expand DownExpand Up@@ -371,12 +379,14 @@ docker compose \
config
```

Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.
Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12`, `SCRAPER_PROVIDER_MAX_CONCURRENCY=2`, `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=""`, `GOMAXPROCS=2`, `GOMEMLIMIT=1500MiB`, `cpus: 1.5` e `mem_limit: 2g`.

## Variáveis de ambiente importantes

- `VALKEY_URL` — conexão Redis/Valkey. Em Docker Compose, use `redis://valkey:6379/0`; em execução local fora do Docker, use uma URL acessível pelo host, por exemplo `redis://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.
- `SCRAPER_PROVIDER_MAX_CONCURRENCY` — limite padrão por provider. Padrão: `2`. Deve ser positivo e não pode superar `SCRAPER_MAX_CONCURRENCY`.
- `SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES` — lista opcional `provider=limite`, separada por vírgulas. Vazio significa nenhum override; entrada inválida, duplicada, desconhecida ou acima do teto global impede a inicialização.
- `SCRAPER_RUN_LOCK_TTL` — duração do lock distribuído. Padrão: `120s`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_TTL-120s}` (mesmo padrão fail-fast de `SCRAPER_MAX_CONCURRENCY`).
- `SCRAPER_RUN_LOCK_RENEW_INTERVAL` — intervalo de renovação. Padrão: `30s`; deve ser menor que `SCRAPER_RUN_LOCK_TTL`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}`.
- `GOMAXPROCS` — limite efetivo de threads executando código Go simultaneamente. Valor inicial no Compose: `2`.
Expand All@@ -389,16 +399,17 @@ Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12
- `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.
- `INHIRE_ENABLED`, `INHIRE_TENANTS_FILE`, `INHIRE_ENRICH_DETAILS`, `INHIRE_DETAILS_MODE`, `INHIRE_DETAILS_TIMEOUT_MS` — controlam fonte e enriquecimento InHire. `INHIRE_DETAILS_CONCURRENCY` foi removida; detalhes seguem o orçamento do provider.
- `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`.

## Observações operacionais

- Projetado para rodar frequentemente; use caching e indexação para reduzir chamadas repetidas.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` / semáforos por adaptador.
- Monitorar erros 429 e ajustar `WaitBetweenSearchesMs` e os limites globais/por provider.
- Verifique logs estruturados (slog JSON) e `/metrics` para métricas de sucesso/falhas por adaptador.
- O log `scraper concurrency budget` registra uma vez por execução o teto global, o default por provider e os overrides; `scraper provider execution summary` registra modo, tarefas produzidas/concluídas/canceladas, erros, timeouts e duração agregada.
- Logs `scraper run lock acquired`, `scraper execution skipped`, `scraper run lock lost` e `scraper run lock released` identificam `source` e `run_id`.

### Verificação operacional do lock
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ services:
- GO_SCRAPER_ADDR=:8081
- VALKEY_URL=redis://valkey:6379/0
- SCRAPER_MAX_CONCURRENCY=${SCRAPER_MAX_CONCURRENCY-12}
- SCRAPER_PROVIDER_MAX_CONCURRENCY=${SCRAPER_PROVIDER_MAX_CONCURRENCY-2}
- SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES=${SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES-}
- SCRAPER_RUN_LOCK_TTL=${SCRAPER_RUN_LOCK_TTL-120s}
- SCRAPER_RUN_LOCK_RENEW_INTERVAL=${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}
- GOMAXPROCS=${GOMAXPROCS:-2}
Expand All@@ -26,7 +28,6 @@ services:
- INHIRE_TENANTS_FILE=/app/internal/interfaces/inhireTenants.json
- INHIRE_ENRICH_DETAILS=${INHIRE_ENRICH_DETAILS:-false}
- INHIRE_DETAILS_MODE=${INHIRE_DETAILS_MODE:-ambiguous}
- INHIRE_DETAILS_CONCURRENCY=${INHIRE_DETAILS_CONCURRENCY:-8}
- INHIRE_DETAILS_TIMEOUT_MS=${INHIRE_DETAILS_TIMEOUT_MS:-10000}
- GREENHOUSE_ENABLED=${GREENHOUSE_ENABLED:-true}
- GREENHOUSE_COMPANIES_FILE=/app/internal/interfaces/greenhouseCompanies.json
Expand Down
40 changes: 26 additions & 14 deletions scraper-go/cmd/server/handlers.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,7 +49,7 @@ func handleScrape(
ctx, cancel := context.WithTimeout(r.Context(), scrapeTimeout)
defer cancel()

searchConfig := searchConfigFromRequest(req, runtimeCfg.MaxConcurrency)
searchConfig := searchConfigFromRuntime(req, runtimeCfg)
slogScrapeStart("public_endpoint", runtimeCfg.MaxConcurrency, req.MaxConcurrency, searchConfig.MaxConcurrency, len(searchConfig.Keywords), len(adapterList))

start := time.Now()
Expand DownExpand Up@@ -85,20 +85,32 @@ func handleScrape(
}

func searchConfigFromRequest(req domain.ScrapeRequest, globalMaxConcurrency int) pipeline.SearchConfig {
return searchConfigFromRuntime(req, config.RuntimeConfig{
MaxConcurrency: globalMaxConcurrency,
ProviderMaxConcurrency: min(config.DefaultProviderMaxConcurrency, globalMaxConcurrency),
})
}

func searchConfigFromRuntime(
req domain.ScrapeRequest,
runtimeCfg config.RuntimeConfig,
) pipeline.SearchConfig {
return pipeline.SearchConfig{
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, globalMaxConcurrency),
Keywords: req.Keywords,
SearchLocation: req.SearchLocation,
SearchGeoID: req.SearchGeoID,
SearchLanguage: req.SearchLanguage,
JobTypes: req.JobTypes,
TimeFilter: req.TimeFilter,
RemoteOnly: req.RemoteOnly,
Sources: req.Sources,
ResultsPerPage: req.ResultsPerPage,
MaxPagesPerKeyword: req.MaxPagesPerKeyword,
WaitBetweenSearchesMs: req.WaitBetweenSearchesMs,
PageTimeoutMs: req.PageTimeoutMs,
MaxConcurrency: config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency),
ProviderMaxConcurrency: min(runtimeCfg.ProviderMaxConcurrency, config.ResolveEffectiveConcurrency(req.MaxConcurrency, runtimeCfg.MaxConcurrency)),
ProviderConcurrencyOverrides: runtimeCfg.ProviderConcurrencyOverrides,
}
}

Expand Down
17 changes: 17 additions & 0 deletions scraper-go/cmd/server/handlers_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,10 @@ package main
import (
"testing"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/config"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/pipeline"
"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/ports"
"github.com/stretchr/testify/assert"
)

Expand DownExpand Up@@ -54,3 +56,18 @@ func TestSearchConfigFromRequestUsesSameCacheKeyForRequestsAboveLimit(t *testing

assert.Equal(t, key40, key100)
}

func TestSearchConfigFromRuntimePropagatesProviderLimits(t *testing.T) {
cfg := searchConfigFromRuntime(
domain.ScrapeRequest{MaxConcurrency: 4},
config.RuntimeConfig{
MaxConcurrency: 12,
ProviderMaxConcurrency: 3,
ProviderConcurrencyOverrides: map[ports.ProviderID]int{ports.ProviderGupy: 8},
},
)

assert.Equal(t, 4, cfg.MaxConcurrency)
assert.Equal(t, 3, cfg.ProviderMaxConcurrency)
assert.Equal(t, 8, cfg.ProviderConcurrencyOverrides[ports.ProviderGupy])
}
3 changes: 3 additions & 0 deletions scraper-go/cmd/server/main.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,9 @@ func logRuntimeConfig(cfg config.RuntimeConfig) {
slog.Info("scraper runtime configurado",
"max_concurrency", cfg.MaxConcurrency,
"max_concurrency_source", cfg.MaxConcurrencySource,
"provider_max_concurrency", cfg.ProviderMaxConcurrency,
"provider_max_concurrency_source", cfg.ProviderMaxConcurrencySource,
"provider_concurrency_overrides", cfg.ProviderConcurrencyOverrides,
"run_lock_ttl", cfg.RunLockTTL,
"run_lock_renew_interval", cfg.RunLockRenewInterval,
"gomaxprocs_effective", runtime.GOMAXPROCS(0),
Expand Down
2 changes: 2 additions & 0 deletions scraper-go/cmd/server/server.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,8 @@ func run(adapterList []ports.JobSource, runtimeCfg config.RuntimeConfig) {
// ── Scheduler (cronjob) ──
schedulerCfg := cronjob.DefaultConfig()
schedulerCfg.MaxConcurrency = runtimeCfg.MaxConcurrency
schedulerCfg.ProviderMaxConcurrency = runtimeCfg.ProviderMaxConcurrency
schedulerCfg.ProviderConcurrencyOverrides = runtimeCfg.ProviderConcurrencyOverrides
scheduler := cronjob.New(schedulerCfg, kwStore, jobStore, adapterList, rdb, runLock)

scheduler.OnComplete = func(kws []string, scraped, saved int, duration time.Duration) {
Expand Down
1 change: 1 addition & 0 deletions scraper-go/go.mod
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
go.uber.org/goleak v1.3.0
)

require (
Expand Down
20 changes: 20 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers.go
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
package adapterutil

import (
"context"
"html"
"strings"
"time"

"github.com/Benevanio/Jobs_Scraper_Global/scraper-go/internal/domain"
)

func Wait(ctx context.Context, duration time.Duration) error {
if cause := context.Cause(ctx); cause != nil {
return cause
}
if duration <= 0 {
return nil
}

timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return context.Cause(ctx)
}
}

func NonEmptyStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
Expand Down
22 changes: 22 additions & 0 deletions scraper-go/internal/adapters/adapterutil/helpers_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
package adapterutil

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWaitReturnsCancellationCauseWithoutWaitingForTimer(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cause := assert.AnError
cancel(cause)
started := time.Now()

err := Wait(ctx, time.Minute)

require.ErrorIs(t, err, cause)
assert.Less(t, time.Since(started), 100*time.Millisecond)
}
Loading
Loading