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
4 changes: 4 additions & 0 deletions frontend/src/domains/new_dashboard/NewDashboardPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,6 +163,7 @@ export default function NewDashboardPage() {
const [countryFilter, setCountryFilter] = useState<CountryFilter>("Todos");
const [matchSort, setMatchSort] = useState<MatchSort>("default");
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [timelineVersion, setTimelineVersion] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const [isAddJobOpen, setIsAddJobOpen] = useState(false);
const [toast, setToast] = useState("");
Expand DownExpand Up@@ -357,6 +358,7 @@ export default function NewDashboardPage() {
const updatedJob = await changeJobStatus(jobId, status);
if (updatedJob && selectedJobId === jobId) {
setSelectedJobId(updatedJob.id);
setTimelineVersion((current) => current + 1);
}
showToast(`Vaga atualizada para: ${jobStatuses[status]}`);
} catch {
Expand DownExpand Up@@ -572,6 +574,8 @@ export default function NewDashboardPage() {
onClose={handleCloseJob}
onStatusChange={handleStatusChange}
onNotesChange={handleNotesChange}
isTracked={trackedJobs.some((job) => job.id === selectedJob.id)}
timelineVersion={timelineVersion}
/>
) : null}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
import { jobStatusClasses, jobStatuses } from "../../constants";
import type { Job, JobStatus } from "../../types";
import type { Job, JobStatus, JobTimelineEvent } from "../../types";
import { getDashboardSavedJobEvents } from "../../infrastructure/dashboardJobsApi";
import { Modal } from "../shared/Modal";
import { FormattedJobDescription } from "./FormattedJobDescription";

Expand All@@ -9,6 +11,8 @@ interface JobDetailModalProps {
onClose: () => void;
onStatusChange: (jobId: string, status: JobStatus) => void;
onNotesChange: (jobId: string, notes: string) => void;
isTracked?: boolean;
timelineVersion?: number;
}

const payloadLabels: Record<string, string> = {
Expand DownExpand Up@@ -44,12 +48,69 @@ function isExternalUrl(value: string) {
return /^https?:\/\//i.test(value);
}

function formatTimelineDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Data não informada";

return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "short",
timeStyle: "short",
}).format(date);
}

function orderTimeline(events: JobTimelineEvent[]) {
return [...events].sort(
(first, second) =>
new Date(first.createdAt).getTime() - new Date(second.createdAt).getTime(),
);
}

function timelineMetadataText(metadata: Record<string, unknown> | null) {
if (!metadata) return "";

return Object.entries(metadata)
.map(([key, value]) => `${key}: ${payloadValueToText(value)}`)
.join(" • ");
}

export function JobDetailModal({
job,
onClose,
onStatusChange,
onNotesChange,
isTracked = false,
timelineVersion = 0,
}: JobDetailModalProps) {
const [timeline, setTimeline] = useState<JobTimelineEvent[]>([]);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineError, setTimelineError] = useState(false);

useEffect(() => {
if (!isTracked) return;

let active = true;
void Promise.resolve()
.then(() => {
if (!active) return null;
setIsTimelineLoading(true);
setTimelineError(false);
return getDashboardSavedJobEvents(job.id);
})
.then((events) => {
if (active && events) setTimeline(orderTimeline(events));
})
.catch(() => {
if (active) setTimelineError(true);
})
.finally(() => {
if (active) setIsTimelineLoading(false);
});

return () => {
active = false;
};
}, [isTracked, job.id, timelineVersion]);

const payloadEntries = Object.entries(job.rawPayload ?? {}).filter(
([key]) => key !== "description",
);
Expand DownExpand Up@@ -199,6 +260,51 @@ export function JobDetailModal({
className="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-ring"
/>
</label>

{isTracked ? (
<section className="space-y-2" aria-labelledby="timeline-title">
<h3
id="timeline-title"
className="text-xs font-bold uppercase text-muted-foreground"
>
Histórico da candidatura
</h3>
{isTimelineLoading ? (
<p className="text-sm text-muted-foreground">Carregando histórico...</p>
) : null}
{timelineError ? (
<p className="text-sm text-destructive">
Não foi possível carregar o histórico.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhuma mudança de status registrada.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length > 0 ? (
<ol className="space-y-2 border-l border-border pl-4">
{timeline.map((event) => (
<li key={event.id} className="relative text-sm">
<span className="absolute -left-[21px] top-1.5 h-2 w-2 rounded-full bg-primary" />
<p className="font-semibold">
Status alterado de {jobStatuses[event.fromStatus]} para{" "}
{jobStatuses[event.toStatus]}
</p>
<time className="text-xs text-muted-foreground" dateTime={event.createdAt}>
{formatTimelineDate(event.createdAt)}
</time>
{timelineMetadataText(event.metadata) ? (
<p className="text-xs text-muted-foreground">
{timelineMetadataText(event.metadata)}
</p>
) : null}
</li>
))}
</ol>
) : null}
</section>
) : null}
</div>
</Modal>
);
Expand Down
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
4 changes: 4 additions & 0 deletions frontend/src/domains/new_dashboard/NewDashboardPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,6 +163,7 @@ export default function NewDashboardPage() {
const [countryFilter, setCountryFilter] = useState<CountryFilter>("Todos");
const [matchSort, setMatchSort] = useState<MatchSort>("default");
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [timelineVersion, setTimelineVersion] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const [isAddJobOpen, setIsAddJobOpen] = useState(false);
const [toast, setToast] = useState("");
Expand DownExpand Up@@ -357,6 +358,7 @@ export default function NewDashboardPage() {
const updatedJob = await changeJobStatus(jobId, status);
if (updatedJob && selectedJobId === jobId) {
setSelectedJobId(updatedJob.id);
setTimelineVersion((current) => current + 1);
}
showToast(`Vaga atualizada para: ${jobStatuses[status]}`);
} catch {
Expand DownExpand Up@@ -572,6 +574,8 @@ export default function NewDashboardPage() {
onClose={handleCloseJob}
onStatusChange={handleStatusChange}
onNotesChange={handleNotesChange}
isTracked={trackedJobs.some((job) => job.id === selectedJob.id)}
timelineVersion={timelineVersion}
/>
) : null}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
import { jobStatusClasses, jobStatuses } from "../../constants";
import type { Job, JobStatus } from "../../types";
import type { Job, JobStatus, JobTimelineEvent } from "../../types";
import { getDashboardSavedJobEvents } from "../../infrastructure/dashboardJobsApi";
import { Modal } from "../shared/Modal";
import { FormattedJobDescription } from "./FormattedJobDescription";

Expand All@@ -9,6 +11,8 @@ interface JobDetailModalProps {
onClose: () => void;
onStatusChange: (jobId: string, status: JobStatus) => void;
onNotesChange: (jobId: string, notes: string) => void;
isTracked?: boolean;
timelineVersion?: number;
}

const payloadLabels: Record<string, string> = {
Expand DownExpand Up@@ -44,12 +48,69 @@ function isExternalUrl(value: string) {
return /^https?:\/\//i.test(value);
}

function formatTimelineDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Data não informada";

return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "short",
timeStyle: "short",
}).format(date);
}

function orderTimeline(events: JobTimelineEvent[]) {
return [...events].sort(
(first, second) =>
new Date(first.createdAt).getTime() - new Date(second.createdAt).getTime(),
);
}

function timelineMetadataText(metadata: Record<string, unknown> | null) {
if (!metadata) return "";

return Object.entries(metadata)
.map(([key, value]) => `${key}: ${payloadValueToText(value)}`)
.join(" • ");
}

export function JobDetailModal({
job,
onClose,
onStatusChange,
onNotesChange,
isTracked = false,
timelineVersion = 0,
}: JobDetailModalProps) {
const [timeline, setTimeline] = useState<JobTimelineEvent[]>([]);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineError, setTimelineError] = useState(false);

useEffect(() => {
if (!isTracked) return;

let active = true;
void Promise.resolve()
.then(() => {
if (!active) return null;
setIsTimelineLoading(true);
setTimelineError(false);
return getDashboardSavedJobEvents(job.id);
})
.then((events) => {
if (active && events) setTimeline(orderTimeline(events));
})
.catch(() => {
if (active) setTimelineError(true);
})
.finally(() => {
if (active) setIsTimelineLoading(false);
});

return () => {
active = false;
};
}, [isTracked, job.id, timelineVersion]);

const payloadEntries = Object.entries(job.rawPayload ?? {}).filter(
([key]) => key !== "description",
);
Expand DownExpand Up@@ -199,6 +260,51 @@ export function JobDetailModal({
className="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-ring"
/>
</label>

{isTracked ? (
<section className="space-y-2" aria-labelledby="timeline-title">
<h3
id="timeline-title"
className="text-xs font-bold uppercase text-muted-foreground"
>
Histórico da candidatura
</h3>
{isTimelineLoading ? (
<p className="text-sm text-muted-foreground">Carregando histórico...</p>
) : null}
{timelineError ? (
<p className="text-sm text-destructive">
Não foi possível carregar o histórico.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhuma mudança de status registrada.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length > 0 ? (
<ol className="space-y-2 border-l border-border pl-4">
{timeline.map((event) => (
<li key={event.id} className="relative text-sm">
<span className="absolute -left-[21px] top-1.5 h-2 w-2 rounded-full bg-primary" />
<p className="font-semibold">
Status alterado de {jobStatuses[event.fromStatus]} para{" "}
{jobStatuses[event.toStatus]}
</p>
<time className="text-xs text-muted-foreground" dateTime={event.createdAt}>
{formatTimelineDate(event.createdAt)}
</time>
{timelineMetadataText(event.metadata) ? (
<p className="text-xs text-muted-foreground">
{timelineMetadataText(event.metadata)}
</p>
) : null}
</li>
))}
</ol>
) : null}
</section>
) : null}
</div>
</Modal>
);
Expand Down
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
4 changes: 4 additions & 0 deletions frontend/src/domains/new_dashboard/NewDashboardPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,6 +163,7 @@ export default function NewDashboardPage() {
const [countryFilter, setCountryFilter] = useState<CountryFilter>("Todos");
const [matchSort, setMatchSort] = useState<MatchSort>("default");
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [timelineVersion, setTimelineVersion] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const [isAddJobOpen, setIsAddJobOpen] = useState(false);
const [toast, setToast] = useState("");
Expand DownExpand Up@@ -357,6 +358,7 @@ export default function NewDashboardPage() {
const updatedJob = await changeJobStatus(jobId, status);
if (updatedJob && selectedJobId === jobId) {
setSelectedJobId(updatedJob.id);
setTimelineVersion((current) => current + 1);
}
showToast(`Vaga atualizada para: ${jobStatuses[status]}`);
} catch {
Expand DownExpand Up@@ -572,6 +574,8 @@ export default function NewDashboardPage() {
onClose={handleCloseJob}
onStatusChange={handleStatusChange}
onNotesChange={handleNotesChange}
isTracked={trackedJobs.some((job) => job.id === selectedJob.id)}
timelineVersion={timelineVersion}
/>
) : null}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
import { jobStatusClasses, jobStatuses } from "../../constants";
import type { Job, JobStatus } from "../../types";
import type { Job, JobStatus, JobTimelineEvent } from "../../types";
import { getDashboardSavedJobEvents } from "../../infrastructure/dashboardJobsApi";
import { Modal } from "../shared/Modal";
import { FormattedJobDescription } from "./FormattedJobDescription";

Expand All@@ -9,6 +11,8 @@ interface JobDetailModalProps {
onClose: () => void;
onStatusChange: (jobId: string, status: JobStatus) => void;
onNotesChange: (jobId: string, notes: string) => void;
isTracked?: boolean;
timelineVersion?: number;
}

const payloadLabels: Record<string, string> = {
Expand DownExpand Up@@ -44,12 +48,69 @@ function isExternalUrl(value: string) {
return /^https?:\/\//i.test(value);
}

function formatTimelineDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Data não informada";

return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "short",
timeStyle: "short",
}).format(date);
}

function orderTimeline(events: JobTimelineEvent[]) {
return [...events].sort(
(first, second) =>
new Date(first.createdAt).getTime() - new Date(second.createdAt).getTime(),
);
}

function timelineMetadataText(metadata: Record<string, unknown> | null) {
if (!metadata) return "";

return Object.entries(metadata)
.map(([key, value]) => `${key}: ${payloadValueToText(value)}`)
.join(" • ");
}

export function JobDetailModal({
job,
onClose,
onStatusChange,
onNotesChange,
isTracked = false,
timelineVersion = 0,
}: JobDetailModalProps) {
const [timeline, setTimeline] = useState<JobTimelineEvent[]>([]);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineError, setTimelineError] = useState(false);

useEffect(() => {
if (!isTracked) return;

let active = true;
void Promise.resolve()
.then(() => {
if (!active) return null;
setIsTimelineLoading(true);
setTimelineError(false);
return getDashboardSavedJobEvents(job.id);
})
.then((events) => {
if (active && events) setTimeline(orderTimeline(events));
})
.catch(() => {
if (active) setTimelineError(true);
})
.finally(() => {
if (active) setIsTimelineLoading(false);
});

return () => {
active = false;
};
}, [isTracked, job.id, timelineVersion]);

const payloadEntries = Object.entries(job.rawPayload ?? {}).filter(
([key]) => key !== "description",
);
Expand DownExpand Up@@ -199,6 +260,51 @@ export function JobDetailModal({
className="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-ring"
/>
</label>

{isTracked ? (
<section className="space-y-2" aria-labelledby="timeline-title">
<h3
id="timeline-title"
className="text-xs font-bold uppercase text-muted-foreground"
>
Histórico da candidatura
</h3>
{isTimelineLoading ? (
<p className="text-sm text-muted-foreground">Carregando histórico...</p>
) : null}
{timelineError ? (
<p className="text-sm text-destructive">
Não foi possível carregar o histórico.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhuma mudança de status registrada.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length > 0 ? (
<ol className="space-y-2 border-l border-border pl-4">
{timeline.map((event) => (
<li key={event.id} className="relative text-sm">
<span className="absolute -left-[21px] top-1.5 h-2 w-2 rounded-full bg-primary" />
<p className="font-semibold">
Status alterado de {jobStatuses[event.fromStatus]} para{" "}
{jobStatuses[event.toStatus]}
</p>
<time className="text-xs text-muted-foreground" dateTime={event.createdAt}>
{formatTimelineDate(event.createdAt)}
</time>
{timelineMetadataText(event.metadata) ? (
<p className="text-xs text-muted-foreground">
{timelineMetadataText(event.metadata)}
</p>
) : null}
</li>
))}
</ol>
) : null}
</section>
) : null}
</div>
</Modal>
);
Expand Down
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
4 changes: 4 additions & 0 deletions frontend/src/domains/new_dashboard/NewDashboardPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,6 +163,7 @@ export default function NewDashboardPage() {
const [countryFilter, setCountryFilter] = useState<CountryFilter>("Todos");
const [matchSort, setMatchSort] = useState<MatchSort>("default");
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [timelineVersion, setTimelineVersion] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const [isAddJobOpen, setIsAddJobOpen] = useState(false);
const [toast, setToast] = useState("");
Expand DownExpand Up@@ -357,6 +358,7 @@ export default function NewDashboardPage() {
const updatedJob = await changeJobStatus(jobId, status);
if (updatedJob && selectedJobId === jobId) {
setSelectedJobId(updatedJob.id);
setTimelineVersion((current) => current + 1);
}
showToast(`Vaga atualizada para: ${jobStatuses[status]}`);
} catch {
Expand DownExpand Up@@ -572,6 +574,8 @@ export default function NewDashboardPage() {
onClose={handleCloseJob}
onStatusChange={handleStatusChange}
onNotesChange={handleNotesChange}
isTracked={trackedJobs.some((job) => job.id === selectedJob.id)}
timelineVersion={timelineVersion}
/>
) : null}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
import { jobStatusClasses, jobStatuses } from "../../constants";
import type { Job, JobStatus } from "../../types";
import type { Job, JobStatus, JobTimelineEvent } from "../../types";
import { getDashboardSavedJobEvents } from "../../infrastructure/dashboardJobsApi";
import { Modal } from "../shared/Modal";
import { FormattedJobDescription } from "./FormattedJobDescription";

Expand All@@ -9,6 +11,8 @@ interface JobDetailModalProps {
onClose: () => void;
onStatusChange: (jobId: string, status: JobStatus) => void;
onNotesChange: (jobId: string, notes: string) => void;
isTracked?: boolean;
timelineVersion?: number;
}

const payloadLabels: Record<string, string> = {
Expand DownExpand Up@@ -44,12 +48,69 @@ function isExternalUrl(value: string) {
return /^https?:\/\//i.test(value);
}

function formatTimelineDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Data não informada";

return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "short",
timeStyle: "short",
}).format(date);
}

function orderTimeline(events: JobTimelineEvent[]) {
return [...events].sort(
(first, second) =>
new Date(first.createdAt).getTime() - new Date(second.createdAt).getTime(),
);
}

function timelineMetadataText(metadata: Record<string, unknown> | null) {
if (!metadata) return "";

return Object.entries(metadata)
.map(([key, value]) => `${key}: ${payloadValueToText(value)}`)
.join(" • ");
}

export function JobDetailModal({
job,
onClose,
onStatusChange,
onNotesChange,
isTracked = false,
timelineVersion = 0,
}: JobDetailModalProps) {
const [timeline, setTimeline] = useState<JobTimelineEvent[]>([]);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineError, setTimelineError] = useState(false);

useEffect(() => {
if (!isTracked) return;

let active = true;
void Promise.resolve()
.then(() => {
if (!active) return null;
setIsTimelineLoading(true);
setTimelineError(false);
return getDashboardSavedJobEvents(job.id);
})
.then((events) => {
if (active && events) setTimeline(orderTimeline(events));
})
.catch(() => {
if (active) setTimelineError(true);
})
.finally(() => {
if (active) setIsTimelineLoading(false);
});

return () => {
active = false;
};
}, [isTracked, job.id, timelineVersion]);

const payloadEntries = Object.entries(job.rawPayload ?? {}).filter(
([key]) => key !== "description",
);
Expand DownExpand Up@@ -199,6 +260,51 @@ export function JobDetailModal({
className="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-ring"
/>
</label>

{isTracked ? (
<section className="space-y-2" aria-labelledby="timeline-title">
<h3
id="timeline-title"
className="text-xs font-bold uppercase text-muted-foreground"
>
Histórico da candidatura
</h3>
{isTimelineLoading ? (
<p className="text-sm text-muted-foreground">Carregando histórico...</p>
) : null}
{timelineError ? (
<p className="text-sm text-destructive">
Não foi possível carregar o histórico.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhuma mudança de status registrada.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length > 0 ? (
<ol className="space-y-2 border-l border-border pl-4">
{timeline.map((event) => (
<li key={event.id} className="relative text-sm">
<span className="absolute -left-[21px] top-1.5 h-2 w-2 rounded-full bg-primary" />
<p className="font-semibold">
Status alterado de {jobStatuses[event.fromStatus]} para{" "}
{jobStatuses[event.toStatus]}
</p>
<time className="text-xs text-muted-foreground" dateTime={event.createdAt}>
{formatTimelineDate(event.createdAt)}
</time>
{timelineMetadataText(event.metadata) ? (
<p className="text-xs text-muted-foreground">
{timelineMetadataText(event.metadata)}
</p>
) : null}
</li>
))}
</ol>
) : null}
</section>
) : null}
</div>
</Modal>
);
Expand Down
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
4 changes: 4 additions & 0 deletions frontend/src/domains/new_dashboard/NewDashboardPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,6 +163,7 @@ export default function NewDashboardPage() {
const [countryFilter, setCountryFilter] = useState<CountryFilter>("Todos");
const [matchSort, setMatchSort] = useState<MatchSort>("default");
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [timelineVersion, setTimelineVersion] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const [isAddJobOpen, setIsAddJobOpen] = useState(false);
const [toast, setToast] = useState("");
Expand DownExpand Up@@ -357,6 +358,7 @@ export default function NewDashboardPage() {
const updatedJob = await changeJobStatus(jobId, status);
if (updatedJob && selectedJobId === jobId) {
setSelectedJobId(updatedJob.id);
setTimelineVersion((current) => current + 1);
}
showToast(`Vaga atualizada para: ${jobStatuses[status]}`);
} catch {
Expand DownExpand Up@@ -572,6 +574,8 @@ export default function NewDashboardPage() {
onClose={handleCloseJob}
onStatusChange={handleStatusChange}
onNotesChange={handleNotesChange}
isTracked={trackedJobs.some((job) => job.id === selectedJob.id)}
timelineVersion={timelineVersion}
/>
) : null}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
import { jobStatusClasses, jobStatuses } from "../../constants";
import type { Job, JobStatus } from "../../types";
import type { Job, JobStatus, JobTimelineEvent } from "../../types";
import { getDashboardSavedJobEvents } from "../../infrastructure/dashboardJobsApi";
import { Modal } from "../shared/Modal";
import { FormattedJobDescription } from "./FormattedJobDescription";

Expand All@@ -9,6 +11,8 @@ interface JobDetailModalProps {
onClose: () => void;
onStatusChange: (jobId: string, status: JobStatus) => void;
onNotesChange: (jobId: string, notes: string) => void;
isTracked?: boolean;
timelineVersion?: number;
}

const payloadLabels: Record<string, string> = {
Expand DownExpand Up@@ -44,12 +48,69 @@ function isExternalUrl(value: string) {
return /^https?:\/\//i.test(value);
}

function formatTimelineDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Data não informada";

return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "short",
timeStyle: "short",
}).format(date);
}

function orderTimeline(events: JobTimelineEvent[]) {
return [...events].sort(
(first, second) =>
new Date(first.createdAt).getTime() - new Date(second.createdAt).getTime(),
);
}

function timelineMetadataText(metadata: Record<string, unknown> | null) {
if (!metadata) return "";

return Object.entries(metadata)
.map(([key, value]) => `${key}: ${payloadValueToText(value)}`)
.join(" • ");
}

export function JobDetailModal({
job,
onClose,
onStatusChange,
onNotesChange,
isTracked = false,
timelineVersion = 0,
}: JobDetailModalProps) {
const [timeline, setTimeline] = useState<JobTimelineEvent[]>([]);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineError, setTimelineError] = useState(false);

useEffect(() => {
if (!isTracked) return;

let active = true;
void Promise.resolve()
.then(() => {
if (!active) return null;
setIsTimelineLoading(true);
setTimelineError(false);
return getDashboardSavedJobEvents(job.id);
})
.then((events) => {
if (active && events) setTimeline(orderTimeline(events));
})
.catch(() => {
if (active) setTimelineError(true);
})
.finally(() => {
if (active) setIsTimelineLoading(false);
});

return () => {
active = false;
};
}, [isTracked, job.id, timelineVersion]);

const payloadEntries = Object.entries(job.rawPayload ?? {}).filter(
([key]) => key !== "description",
);
Expand DownExpand Up@@ -199,6 +260,51 @@ export function JobDetailModal({
className="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-ring"
/>
</label>

{isTracked ? (
<section className="space-y-2" aria-labelledby="timeline-title">
<h3
id="timeline-title"
className="text-xs font-bold uppercase text-muted-foreground"
>
Histórico da candidatura
</h3>
{isTimelineLoading ? (
<p className="text-sm text-muted-foreground">Carregando histórico...</p>
) : null}
{timelineError ? (
<p className="text-sm text-destructive">
Não foi possível carregar o histórico.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhuma mudança de status registrada.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length > 0 ? (
<ol className="space-y-2 border-l border-border pl-4">
{timeline.map((event) => (
<li key={event.id} className="relative text-sm">
<span className="absolute -left-[21px] top-1.5 h-2 w-2 rounded-full bg-primary" />
<p className="font-semibold">
Status alterado de {jobStatuses[event.fromStatus]} para{" "}
{jobStatuses[event.toStatus]}
</p>
<time className="text-xs text-muted-foreground" dateTime={event.createdAt}>
{formatTimelineDate(event.createdAt)}
</time>
{timelineMetadataText(event.metadata) ? (
<p className="text-xs text-muted-foreground">
{timelineMetadataText(event.metadata)}
</p>
) : null}
</li>
))}
</ol>
) : null}
</section>
) : null}
</div>
</Modal>
);
Expand Down
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
4 changes: 4 additions & 0 deletions frontend/src/domains/new_dashboard/NewDashboardPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,6 +163,7 @@ export default function NewDashboardPage() {
const [countryFilter, setCountryFilter] = useState<CountryFilter>("Todos");
const [matchSort, setMatchSort] = useState<MatchSort>("default");
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [timelineVersion, setTimelineVersion] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const [isAddJobOpen, setIsAddJobOpen] = useState(false);
const [toast, setToast] = useState("");
Expand DownExpand Up@@ -357,6 +358,7 @@ export default function NewDashboardPage() {
const updatedJob = await changeJobStatus(jobId, status);
if (updatedJob && selectedJobId === jobId) {
setSelectedJobId(updatedJob.id);
setTimelineVersion((current) => current + 1);
}
showToast(`Vaga atualizada para: ${jobStatuses[status]}`);
} catch {
Expand DownExpand Up@@ -572,6 +574,8 @@ export default function NewDashboardPage() {
onClose={handleCloseJob}
onStatusChange={handleStatusChange}
onNotesChange={handleNotesChange}
isTracked={trackedJobs.some((job) => job.id === selectedJob.id)}
timelineVersion={timelineVersion}
/>
) : null}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
import { jobStatusClasses, jobStatuses } from "../../constants";
import type { Job, JobStatus } from "../../types";
import type { Job, JobStatus, JobTimelineEvent } from "../../types";
import { getDashboardSavedJobEvents } from "../../infrastructure/dashboardJobsApi";
import { Modal } from "../shared/Modal";
import { FormattedJobDescription } from "./FormattedJobDescription";

Expand All@@ -9,6 +11,8 @@ interface JobDetailModalProps {
onClose: () => void;
onStatusChange: (jobId: string, status: JobStatus) => void;
onNotesChange: (jobId: string, notes: string) => void;
isTracked?: boolean;
timelineVersion?: number;
}

const payloadLabels: Record<string, string> = {
Expand DownExpand Up@@ -44,12 +48,69 @@ function isExternalUrl(value: string) {
return /^https?:\/\//i.test(value);
}

function formatTimelineDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Data não informada";

return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "short",
timeStyle: "short",
}).format(date);
}

function orderTimeline(events: JobTimelineEvent[]) {
return [...events].sort(
(first, second) =>
new Date(first.createdAt).getTime() - new Date(second.createdAt).getTime(),
);
}

function timelineMetadataText(metadata: Record<string, unknown> | null) {
if (!metadata) return "";

return Object.entries(metadata)
.map(([key, value]) => `${key}: ${payloadValueToText(value)}`)
.join(" • ");
}

export function JobDetailModal({
job,
onClose,
onStatusChange,
onNotesChange,
isTracked = false,
timelineVersion = 0,
}: JobDetailModalProps) {
const [timeline, setTimeline] = useState<JobTimelineEvent[]>([]);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineError, setTimelineError] = useState(false);

useEffect(() => {
if (!isTracked) return;

let active = true;
void Promise.resolve()
.then(() => {
if (!active) return null;
setIsTimelineLoading(true);
setTimelineError(false);
return getDashboardSavedJobEvents(job.id);
})
.then((events) => {
if (active && events) setTimeline(orderTimeline(events));
})
.catch(() => {
if (active) setTimelineError(true);
})
.finally(() => {
if (active) setIsTimelineLoading(false);
});

return () => {
active = false;
};
}, [isTracked, job.id, timelineVersion]);

const payloadEntries = Object.entries(job.rawPayload ?? {}).filter(
([key]) => key !== "description",
);
Expand DownExpand Up@@ -199,6 +260,51 @@ export function JobDetailModal({
className="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-ring"
/>
</label>

{isTracked ? (
<section className="space-y-2" aria-labelledby="timeline-title">
<h3
id="timeline-title"
className="text-xs font-bold uppercase text-muted-foreground"
>
Histórico da candidatura
</h3>
{isTimelineLoading ? (
<p className="text-sm text-muted-foreground">Carregando histórico...</p>
) : null}
{timelineError ? (
<p className="text-sm text-destructive">
Não foi possível carregar o histórico.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhuma mudança de status registrada.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length > 0 ? (
<ol className="space-y-2 border-l border-border pl-4">
{timeline.map((event) => (
<li key={event.id} className="relative text-sm">
<span className="absolute -left-[21px] top-1.5 h-2 w-2 rounded-full bg-primary" />
<p className="font-semibold">
Status alterado de {jobStatuses[event.fromStatus]} para{" "}
{jobStatuses[event.toStatus]}
</p>
<time className="text-xs text-muted-foreground" dateTime={event.createdAt}>
{formatTimelineDate(event.createdAt)}
</time>
{timelineMetadataText(event.metadata) ? (
<p className="text-xs text-muted-foreground">
{timelineMetadataText(event.metadata)}
</p>
) : null}
</li>
))}
</ol>
) : null}
</section>
) : null}
</div>
</Modal>
);
Expand Down
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
4 changes: 4 additions & 0 deletions frontend/src/domains/new_dashboard/NewDashboardPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,6 +163,7 @@ export default function NewDashboardPage() {
const [countryFilter, setCountryFilter] = useState<CountryFilter>("Todos");
const [matchSort, setMatchSort] = useState<MatchSort>("default");
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [timelineVersion, setTimelineVersion] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const [isAddJobOpen, setIsAddJobOpen] = useState(false);
const [toast, setToast] = useState("");
Expand DownExpand Up@@ -357,6 +358,7 @@ export default function NewDashboardPage() {
const updatedJob = await changeJobStatus(jobId, status);
if (updatedJob && selectedJobId === jobId) {
setSelectedJobId(updatedJob.id);
setTimelineVersion((current) => current + 1);
}
showToast(`Vaga atualizada para: ${jobStatuses[status]}`);
} catch {
Expand DownExpand Up@@ -572,6 +574,8 @@ export default function NewDashboardPage() {
onClose={handleCloseJob}
onStatusChange={handleStatusChange}
onNotesChange={handleNotesChange}
isTracked={trackedJobs.some((job) => job.id === selectedJob.id)}
timelineVersion={timelineVersion}
/>
) : null}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
import { jobStatusClasses, jobStatuses } from "../../constants";
import type { Job, JobStatus } from "../../types";
import type { Job, JobStatus, JobTimelineEvent } from "../../types";
import { getDashboardSavedJobEvents } from "../../infrastructure/dashboardJobsApi";
import { Modal } from "../shared/Modal";
import { FormattedJobDescription } from "./FormattedJobDescription";

Expand All@@ -9,6 +11,8 @@ interface JobDetailModalProps {
onClose: () => void;
onStatusChange: (jobId: string, status: JobStatus) => void;
onNotesChange: (jobId: string, notes: string) => void;
isTracked?: boolean;
timelineVersion?: number;
}

const payloadLabels: Record<string, string> = {
Expand DownExpand Up@@ -44,12 +48,69 @@ function isExternalUrl(value: string) {
return /^https?:\/\//i.test(value);
}

function formatTimelineDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Data não informada";

return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "short",
timeStyle: "short",
}).format(date);
}

function orderTimeline(events: JobTimelineEvent[]) {
return [...events].sort(
(first, second) =>
new Date(first.createdAt).getTime() - new Date(second.createdAt).getTime(),
);
}

function timelineMetadataText(metadata: Record<string, unknown> | null) {
if (!metadata) return "";

return Object.entries(metadata)
.map(([key, value]) => `${key}: ${payloadValueToText(value)}`)
.join(" • ");
}

export function JobDetailModal({
job,
onClose,
onStatusChange,
onNotesChange,
isTracked = false,
timelineVersion = 0,
}: JobDetailModalProps) {
const [timeline, setTimeline] = useState<JobTimelineEvent[]>([]);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineError, setTimelineError] = useState(false);

useEffect(() => {
if (!isTracked) return;

let active = true;
void Promise.resolve()
.then(() => {
if (!active) return null;
setIsTimelineLoading(true);
setTimelineError(false);
return getDashboardSavedJobEvents(job.id);
})
.then((events) => {
if (active && events) setTimeline(orderTimeline(events));
})
.catch(() => {
if (active) setTimelineError(true);
})
.finally(() => {
if (active) setIsTimelineLoading(false);
});

return () => {
active = false;
};
}, [isTracked, job.id, timelineVersion]);

const payloadEntries = Object.entries(job.rawPayload ?? {}).filter(
([key]) => key !== "description",
);
Expand DownExpand Up@@ -199,6 +260,51 @@ export function JobDetailModal({
className="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-ring"
/>
</label>

{isTracked ? (
<section className="space-y-2" aria-labelledby="timeline-title">
<h3
id="timeline-title"
className="text-xs font-bold uppercase text-muted-foreground"
>
Histórico da candidatura
</h3>
{isTimelineLoading ? (
<p className="text-sm text-muted-foreground">Carregando histórico...</p>
) : null}
{timelineError ? (
<p className="text-sm text-destructive">
Não foi possível carregar o histórico.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhuma mudança de status registrada.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length > 0 ? (
<ol className="space-y-2 border-l border-border pl-4">
{timeline.map((event) => (
<li key={event.id} className="relative text-sm">
<span className="absolute -left-[21px] top-1.5 h-2 w-2 rounded-full bg-primary" />
<p className="font-semibold">
Status alterado de {jobStatuses[event.fromStatus]} para{" "}
{jobStatuses[event.toStatus]}
</p>
<time className="text-xs text-muted-foreground" dateTime={event.createdAt}>
{formatTimelineDate(event.createdAt)}
</time>
{timelineMetadataText(event.metadata) ? (
<p className="text-xs text-muted-foreground">
{timelineMetadataText(event.metadata)}
</p>
) : null}
</li>
))}
</ol>
) : null}
</section>
) : null}
</div>
</Modal>
);
Expand Down
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
4 changes: 4 additions & 0 deletions frontend/src/domains/new_dashboard/NewDashboardPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,6 +163,7 @@ export default function NewDashboardPage() {
const [countryFilter, setCountryFilter] = useState<CountryFilter>("Todos");
const [matchSort, setMatchSort] = useState<MatchSort>("default");
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [timelineVersion, setTimelineVersion] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const [isAddJobOpen, setIsAddJobOpen] = useState(false);
const [toast, setToast] = useState("");
Expand DownExpand Up@@ -357,6 +358,7 @@ export default function NewDashboardPage() {
const updatedJob = await changeJobStatus(jobId, status);
if (updatedJob && selectedJobId === jobId) {
setSelectedJobId(updatedJob.id);
setTimelineVersion((current) => current + 1);
}
showToast(`Vaga atualizada para: ${jobStatuses[status]}`);
} catch {
Expand DownExpand Up@@ -572,6 +574,8 @@ export default function NewDashboardPage() {
onClose={handleCloseJob}
onStatusChange={handleStatusChange}
onNotesChange={handleNotesChange}
isTracked={trackedJobs.some((job) => job.id === selectedJob.id)}
timelineVersion={timelineVersion}
/>
) : null}

Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
import { ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
import { jobStatusClasses, jobStatuses } from "../../constants";
import type { Job, JobStatus } from "../../types";
import type { Job, JobStatus, JobTimelineEvent } from "../../types";
import { getDashboardSavedJobEvents } from "../../infrastructure/dashboardJobsApi";
import { Modal } from "../shared/Modal";
import { FormattedJobDescription } from "./FormattedJobDescription";

Expand All@@ -9,6 +11,8 @@ interface JobDetailModalProps {
onClose: () => void;
onStatusChange: (jobId: string, status: JobStatus) => void;
onNotesChange: (jobId: string, notes: string) => void;
isTracked?: boolean;
timelineVersion?: number;
}

const payloadLabels: Record<string, string> = {
Expand DownExpand Up@@ -44,12 +48,69 @@ function isExternalUrl(value: string) {
return /^https?:\/\//i.test(value);
}

function formatTimelineDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Data não informada";

return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "short",
timeStyle: "short",
}).format(date);
}

function orderTimeline(events: JobTimelineEvent[]) {
return [...events].sort(
(first, second) =>
new Date(first.createdAt).getTime() - new Date(second.createdAt).getTime(),
);
}

function timelineMetadataText(metadata: Record<string, unknown> | null) {
if (!metadata) return "";

return Object.entries(metadata)
.map(([key, value]) => `${key}: ${payloadValueToText(value)}`)
.join(" • ");
}

export function JobDetailModal({
job,
onClose,
onStatusChange,
onNotesChange,
isTracked = false,
timelineVersion = 0,
}: JobDetailModalProps) {
const [timeline, setTimeline] = useState<JobTimelineEvent[]>([]);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineError, setTimelineError] = useState(false);

useEffect(() => {
if (!isTracked) return;

let active = true;
void Promise.resolve()
.then(() => {
if (!active) return null;
setIsTimelineLoading(true);
setTimelineError(false);
return getDashboardSavedJobEvents(job.id);
})
.then((events) => {
if (active && events) setTimeline(orderTimeline(events));
})
.catch(() => {
if (active) setTimelineError(true);
})
.finally(() => {
if (active) setIsTimelineLoading(false);
});

return () => {
active = false;
};
}, [isTracked, job.id, timelineVersion]);

const payloadEntries = Object.entries(job.rawPayload ?? {}).filter(
([key]) => key !== "description",
);
Expand DownExpand Up@@ -199,6 +260,51 @@ export function JobDetailModal({
className="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-ring"
/>
</label>

{isTracked ? (
<section className="space-y-2" aria-labelledby="timeline-title">
<h3
id="timeline-title"
className="text-xs font-bold uppercase text-muted-foreground"
>
Histórico da candidatura
</h3>
{isTimelineLoading ? (
<p className="text-sm text-muted-foreground">Carregando histórico...</p>
) : null}
{timelineError ? (
<p className="text-sm text-destructive">
Não foi possível carregar o histórico.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhuma mudança de status registrada.
</p>
) : null}
{!isTimelineLoading && !timelineError && timeline.length > 0 ? (
<ol className="space-y-2 border-l border-border pl-4">
{timeline.map((event) => (
<li key={event.id} className="relative text-sm">
<span className="absolute -left-[21px] top-1.5 h-2 w-2 rounded-full bg-primary" />
<p className="font-semibold">
Status alterado de {jobStatuses[event.fromStatus]} para{" "}
{jobStatuses[event.toStatus]}
</p>
<time className="text-xs text-muted-foreground" dateTime={event.createdAt}>
{formatTimelineDate(event.createdAt)}
</time>
{timelineMetadataText(event.metadata) ? (
<p className="text-xs text-muted-foreground">
{timelineMetadataText(event.metadata)}
</p>
) : null}
</li>
))}
</ol>
) : null}
</section>
) : null}
</div>
</Modal>
);
Expand Down
Loading
Loading