Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
59cdcff
chore(deps): bump framer-motion from 12.43.0 to 14.0.0
dependabot[bot] Oct 12, 2026
9809009
chore(deps-dev): bump concurrently from 9.2.5 to 10.0.6
dependabot[bot] Oct 12, 2026
5a80b8c
feat(PAV-178): adiciona baseline e observabilidade detalhada do proce…
hltav Oct 12, 2026
7f3ac75
perf(PAV-178): otimiza geração de chaves de indexação
hltav Oct 12, 2026
0389c1e
perf(PAV-178): otimiza classificação e detalha timings do pipeline
hltav Oct 12, 2026
dc00b61
perf(PAV-178): otimiza persistencia com deteccao de mudancas
hltav Oct 12, 2026
e291db0
chore(deps): bump github.com/lib/pq from 1.10.9 to 1.12.3 in /scraper-go
dependabot[bot] Oct 13, 2026
f5afac3
chore(deps-dev): bump concurrently from 9.2.5 to 10.0.6 (#322)
hltav Oct 18, 2026
33b6819
chore(deps): bump dotenv from 17.4.2 to 18.0.8
dependabot[bot] Oct 18, 2026
9eea320
chore(deps): bump framer-motion from 12.43.0 to 14.0.0 (#321)
Benevanio Oct 24, 2026
da42239
chore(deps): bump dotenv from 17.4.2 to 18.0.8 (#320)
Benevanio Oct 24, 2026
9ff1aa6
chore(deps): bump github.com/lib/pq from 1.10.9 to 1.12.3 in /scraper…
Benevanio Oct 24, 2026
01f79e8
chore(deps): bump github.com/prometheus/client_golang in /scraper-go
dependabot[bot] Oct 24, 2026
afffabf
chore(deps): bump github.com/prometheus/client_golang from 1.23.2 to …
Benevanio Oct 31, 2026
6ec6475
chore(deps): bump golang.org/x/sync from 0.23.0 to 0.24.0 in /scraper-go
dependabot[bot] Oct 31, 2026
d7429e0
chore(deps): bump github.com/alicebob/miniredis/v2 in /scraper-go
dependabot[bot] Nov 1, 2026
ab89022
chore(deps): bump the npm-minor-patch group with 27 updates
dependabot[bot] Nov 1, 2026
2d4a447
chore(deps): bump golang.org/x/sync from 0.23.0 to 0.24.0 in /scraper…
Benevanio Nov 1, 2026
7953c3a
chore(deps): bump github.com/alicebob/miniredis/v2 from 2.38.0 to 2.4…
Benevanio Nov 1, 2026
4b786c1
chore(deps): bump the npm-minor-patch group with 27 updates (#329)
Benevanio Nov 1, 2026
afb25b8
fix(PAV-178): implementa seleção efetiva por provedor
hltav Nov 1, 2026
fa6d658
feat(PAV-178): adiciona controle de ativação do scheduler automático
hltav Nov 1, 2026
4c47dfd
fix(PAV-178): preserva resultados parciais durante falhas de coleta
hltav Nov 1, 2026
a2c53ba
fix(PAV-178): bloqueia seeds ficticios no deploy
hltav Nov 1, 2026
4643420
fix(PAV-178): finaliza timeout configuravel e status parcial
hltav Nov 1, 2026
b394848
feat(PAV-178): agenda provedores e retoma lotes de Greenhouse
hltav Nov 1, 2026
07f8095
fix(PAV-178): respeita Retry-After e renova cache por execucao
hltav Nov 1, 2026
68d6ef1
chore(PAV-178): valida regressões e documenta publicação em DEV
hltav Nov 1, 2026
bf554a5
feat(PAV-178): implementa scheduler automático com 21 janelas
hltav Nov 2, 2026
284d1c9
feat(PAV-178): fixa grupos Greenhouse por janela
hltav Nov 2, 2026
85793e5
feat(PAV-178): implementa deadline e controle horario do scheduler
hltav Nov 2, 2026
814026a
feat(PAV-178): implementa cobertura completa de keywords por janela
hltav Nov 2, 2026
9fad872
feat(PAV-178): otimiza Gupy e fortalece controle dos providers
hltav Nov 2, 2026
95e4e34
fix(PAV-178): valida integracao e corrige admissao tardia do scheduler
hltav Nov 2, 2026
9c41b4f
PAV-178: [Jobs Processor] Otimizar agendamento e distribuição de carg…
hltav Nov 2, 2026
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
22 changes: 11 additions & 11 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,41 +34,41 @@
"author": "Projeto Open Source - Bene Tesla Dev",
"license": "ISC",
"dependencies": {
"@react-email/render": "^2.1.0",
"@react-email/render": "^2.2.0",
"argon2": "^0.45.1",
"axios": "^1.20.0",
"bullmq": "^6.3.11",
"axios": "^1.21.0",
"bullmq": "^6.5.1",
"cheerio": "^1.2.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"dotenv": "^18.0.9",
"drizzle-orm": "^0.45.4",
"express": "^5.2.1",
"ioredis": "^5.11.1",
"iron-session": "^8.0.4",
"pdfkit": "^0.20.2",
"prom-client": "^15.1.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"redis": "^6.3.0",
"resend": "^6.18.0",
"resend": "^6.35.0",
"swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1",
"xlsx": "^0.18.5",
"zod": "^4.6.5"
"zod": "^4.6.7"
},
"engines": {
"node": ">=22.0.0"
},
"devDependencies": {
"@types/node": "^26.6.4",
"@types/node": "^26.6.6",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.17",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "5.0.3",
"@vitest/coverage-v8": "5.0.5",
"drizzle-kit": "^0.31.10",
"supertest": "^7.2.2",
"tsx": "^4.22.4",
"typescript": "~5.9.3",
"vitest": "5.0.3"
"vitest": "5.0.5"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export const ProcessorSnapshotSchema = z.object({
status: z.enum(["ok", "partial"]),
timestamp: z.string().datetime(),
execution: z.object({
partial: z.boolean().optional(),
runId: z.string().min(1).max(128).nullable().optional().default(null),
status: z.enum(["idle", "running", "canceling", "failed", "completed"]),
source: z.enum(["manual", "cron"]).nullable(),
Expand Down
30 changes: 25 additions & 5 deletions backend/src/modules/admin/scrapers/scraperClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { JobsCountResultSchema } from "./scrapers.types";
import { JobsCountResultSchema, ScraperStatusSchema } from "./scrapers.types";
import { config } from "../../../config";
import type {
GetJobsResult,
Expand All @@ -10,6 +10,15 @@ import type {

const TIMEOUT_MS = 5000;

export class ScraperInvalidSelectionError extends Error {
readonly code = "INVALID_PROVIDER_SELECTION";
}

export class ScraperRateLimitedError extends Error {
readonly code = "PROVIDER_RATE_LIMITED";
constructor(message: string, readonly retryAfter: string | null) { super(message); }
}

/** Lançado quando o Go scraper responde 409 (já em execução) */
export class ScraperAlreadyRunningError extends Error {
readonly code = "SCRAPER_ALREADY_RUNNING";
Expand Down Expand Up @@ -41,6 +50,14 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
const body = await response.json().catch(() => null);
throw new ScraperAlreadyRunningError(body?.message);
}
if (response.status === 400) {
const body = await response.json().catch(() => null);
throw new ScraperInvalidSelectionError(body?.message ?? "Seleção de provedor inválida.");
}
if (response.status === 429) {
const body = await response.json().catch(() => null);
throw new ScraperRateLimitedError(body?.message ?? "Provedor aguardando Retry-After.", response.headers?.get("Retry-After") ?? null);
}

if (response.status === 503) {
const body = await response.json().catch(() => null);
Expand All @@ -55,12 +72,15 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}

export const scraperClient = {
triggerScrape(): Promise<TriggerScrapeResult> {
return request<TriggerScrapeResult>("/admin/scrape", { method: "POST" });
triggerScrape(sources?: string[]): Promise<TriggerScrapeResult> {
return request<TriggerScrapeResult>("/admin/scrape", {
method: "POST",
...(sources !== undefined ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sources }) } : {}),
});
},

getStatus(): Promise<ScraperStatus> {
return request<ScraperStatus>("/admin/scrape/status");
async getStatus(): Promise<ScraperStatus> {
return ScraperStatusSchema.parse(await request<unknown>("/admin/scrape/status"));
},

getJobs(limit?: number): Promise<GetJobsResult> {
Expand Down
17 changes: 16 additions & 1 deletion backend/src/modules/admin/scrapers/scrapers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import type { AuditService } from "../audit/audit.service";
import {
ScraperAlreadyRunningError,
ScraperRunLockUnavailableError,
ScraperInvalidSelectionError,
ScraperRateLimitedError,
} from "./scraperClient";
import { ScrapersService } from "./scrapers.service";
import { TriggerScrapeRequestSchema } from "./scrapers.types";

export class ScrapersController {
constructor(
Expand All @@ -13,13 +16,20 @@ export class ScrapersController {
) {}

async trigger(req: Request, res: Response): Promise<void> {
const selection = TriggerScrapeRequestSchema.safeParse(req.body ?? {});
if (!selection.success) { res.status(400).json({ ok: false, code: "INVALID_PROVIDER_SELECTION" }); return; }
try {
const result = await this.scrapersService.triggerScrape();
const result = await this.scrapersService.triggerScrape(selection.data.sources);

this.auditService.fromRequest(req, "scrapers.trigger");

res.status(202).json(result);
} catch (error) {
if (error instanceof ScraperRateLimitedError) {
if (error.retryAfter) res.setHeader("Retry-After", error.retryAfter);
res.status(429).json({ ok: false, code: error.code, message: error.message }); return;
}
if (error instanceof ScraperInvalidSelectionError) { res.status(400).json({ ok: false, code: error.code, message: error.message }); return; }
if (error instanceof ScraperAlreadyRunningError) {
res.status(409).json({
ok: false,
Expand Down Expand Up @@ -52,6 +62,11 @@ export class ScrapersController {

res.status(202).json({ ...result, scraper: scraperName });
} catch (error) {
if (error instanceof ScraperRateLimitedError) {
if (error.retryAfter) res.setHeader("Retry-After", error.retryAfter);
res.status(429).json({ ok: false, code: error.code, message: error.message }); return;
}
if (error instanceof ScraperInvalidSelectionError) { res.status(400).json({ ok: false, code: error.code, message: error.message }); return; }
if (error instanceof ScraperAlreadyRunningError) {
res.status(409).json({
ok: false,
Expand Down
13 changes: 9 additions & 4 deletions backend/src/modules/admin/scrapers/scrapers.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
ScraperAlreadyRunningError,
ScraperRunLockUnavailableError,
ScraperInvalidSelectionError,
ScraperRateLimitedError,
scraperClient,
} from "./scraperClient";
import type {
Expand All @@ -11,15 +13,16 @@ import type {
ScraperStatus,
TriggerScrapeResult,
} from "./scrapers.types";
import { TriggerScrapeRequestSchema } from "./scrapers.types";

export class ScrapersService {
async triggerScrape(): Promise<TriggerScrapeResult> {
async triggerScrape(sources?: string[]): Promise<TriggerScrapeResult> {
try {
return await scraperClient.triggerScrape();
return await scraperClient.triggerScrape(sources);
} catch (error) {
if (
error instanceof ScraperAlreadyRunningError ||
error instanceof ScraperRunLockUnavailableError
error instanceof ScraperRunLockUnavailableError || error instanceof ScraperInvalidSelectionError || error instanceof ScraperRateLimitedError
) {
// repropaga para o controller decidir o status HTTP (409)
throw error;
Expand All @@ -31,7 +34,9 @@ export class ScrapersService {
async triggerScraper(name: string): Promise<TriggerScrapeResult> {
const normalized = name.trim().toLowerCase();
if (normalized !== "go-scraper") {
throw new Error(`scraper desconhecido: ${name}`);
const selection = TriggerScrapeRequestSchema.safeParse({ sources: [normalized] });
if (!selection.success) throw new Error(`scraper desconhecido: ${name}`);
return this.triggerScrape(selection.data.sources);
}

return this.triggerScrape();
Expand Down
27 changes: 26 additions & 1 deletion backend/src/modules/admin/scrapers/scrapers.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,18 @@ export const TriggerScrapeResultSchema = z.object({
});
export type TriggerScrapeResult = z.infer<typeof TriggerScrapeResultSchema>;

export const TriggerScrapeRequestSchema = z.object({
sources: z.array(z.enum(["linkedin", "adzuna", "gupy", "inhire", "greenhouse", "lever", "jooble", "themuse"])).max(8).optional(),
}).strict();

export const TriggerScrapeErrorSchema = z.object({
ok: z.literal(false),
code: z.enum([
"SCRAPER_ALREADY_RUNNING",
"SCRAPER_RUN_LOCK_UNAVAILABLE",
"SCRAPER_RUN_LOCK_LOST",
"INVALID_PROVIDER_SELECTION",
"PROVIDER_RATE_LIMITED",
]),
message: z.string(),
});
Expand All @@ -40,8 +46,27 @@ export type TriggerScrapeError = z.infer<typeof TriggerScrapeErrorSchema>;
export const ScraperStatusSchema = z.object({
name: z.string().optional(),
running: z.boolean(),
lastRunAt: z.string().optional(),
lastRunAt: z.string().nullable().optional(),
jobsCollected: z.number().int().min(0).optional(),
lastRun: z.object({
runId: z.string(),
providers: z.array(z.string()).nullable().optional(),
status: z.enum(["success", "partial", "failed", "timeout", "canceled"]),
finishedAt: z.string().datetime(),
jobsSaved: z.number().int().nonnegative(),
jobsIndexed: z.number().int().nonnegative(),
errorType: z.string(),
}).nullable().optional(),
providerWindows: z.array(z.union([
z.object({
provider: z.string(), id: z.string().optional(), timezone: z.literal("UTC").optional(),
startMinuteUTC: z.number().int().min(0).max(1439), endMinuteUTC: z.number().int().min(1).max(1440),
}),
z.object({
provider: z.string(), id: z.string(), timezone: z.string().min(1),
startMinuteLocal: z.number().int().min(0).max(1439), endMinuteLocal: z.number().int().min(1).max(1440),
}),
])).nullable().optional(),
});
export type ScraperStatus = z.infer<typeof ScraperStatusSchema>;

Expand Down
2 changes: 2 additions & 0 deletions backend/src/scripts/seed.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as argon2 from "argon2";
import { assertLocalSeedAllowed } from "./seedPolicy";
import "dotenv/config";
import { and, eq } from "drizzle-orm";
import { db, pool } from "../db/client";
Expand Down Expand Up @@ -237,6 +238,7 @@ async function upsertApplicationEvent(


async function main() {
assertLocalSeedAllowed();
console.log("Iniciando seed de desenvolvimento local...");

const devUserSpec = SEED_USERS.find((spec) => spec.role === "user")!;
Expand Down
2 changes: 2 additions & 0 deletions backend/src/scripts/seedCatalogJobs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ProfessionalFamily } from "../modules/jobs/types/professionalTaxonomy";
import { assertLocalSeedAllowed } from "./seedPolicy";
import { createClient } from "redis";

export type SeedTechnologyExperience = {
Expand Down Expand Up @@ -391,6 +392,7 @@ async function connectSeedCache(url: string) {
}

export async function seedCatalogJobs(): Promise<void> {
assertLocalSeedAllowed();
const url = process.env.VALKEY_URL;

if (!url) {
Expand Down
13 changes: 13 additions & 0 deletions backend/src/scripts/seedPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Demo users, saved jobs and catalog fixtures are only permitted in an
// explicitly opted-in local environment. NODE_ENV=development also occurs in DEV.
export function assertLocalSeedAllowed(env = process.env): void {
if (
env.APP_ENV !== "local" ||
env.ALLOW_DEMO_SEED !== "true" ||
env.NODE_ENV === "production"
) {
throw new Error(
"Seed fictício bloqueado: exige APP_ENV=local, ALLOW_DEMO_SEED=true e NODE_ENV diferente de production.",
);
}
}
38 changes: 38 additions & 0 deletions backend/tests/unit/modules/admin/providerDispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { afterEach, expect, it, vi } from "vitest";
import { scraperClient, ScraperInvalidSelectionError, ScraperRateLimitedError } from "../../../../src/modules/admin/scrapers/scraperClient";
import { ScrapersService } from "../../../../src/modules/admin/scrapers/scrapers.service";
import { ScrapersController } from "../../../../src/modules/admin/scrapers/scrapers.controller";
import type { Request, Response } from "express";

afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });

it("preserves Retry-After when a provider is temporarily blocked", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 429, headers: { get: () => "120" }, json: async () => ({ message: "rate limited" }) }));
await expect(new ScrapersService().triggerScraper("greenhouse")).rejects.toBeInstanceOf(ScraperRateLimitedError);
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), setHeader: vi.fn() } as unknown as Response;
await new ScrapersController(new ScrapersService(), { fromRequest: vi.fn() } as never).triggerOne({ params: { id: "greenhouse" } } as unknown as Request, res);
expect(res.status).toHaveBeenCalledWith(429);
expect(res.setHeader).toHaveBeenCalledWith("Retry-After", "120");
});

it("forwards an individual ProviderID to Go without a global fallback", async () => {
const fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ ok: true, message: "started" }) });
vi.stubGlobal("fetch", fetch);
await new ScrapersService().triggerScraper("greenhouse");
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ sources: ["greenhouse"] });
});

it("preserves a Go rejection when the provider is disabled", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ message: "provider is not configured" }) }));
await expect(new ScrapersService().triggerScraper("greenhouse")).rejects.toBeInstanceOf(ScraperInvalidSelectionError);
});

it("rejects invalid selection before sending any request to Go", async () => {
const trigger = vi.spyOn(scraperClient, "triggerScrape");
const service = new ScrapersService();
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
const controller = new ScrapersController(service, { fromRequest: vi.fn() } as never);
await controller.trigger({ body: { sources: ["unknown"] } } as Request, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(trigger).not.toHaveBeenCalled();
});
24 changes: 24 additions & 0 deletions backend/tests/unit/modules/admin/providerSchedule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { ScraperStatusSchema } from "../../../../src/modules/admin/scrapers/scrapers.types";

describe("provider window status contract", () => {
it("preserves legacy UTC windows and manual status responses", () => {
expect(ScraperStatusSchema.parse({ running: false })).toEqual({ running: false });
const window = { provider: "gupy", startMinuteUTC: 60, endMinuteUTC: 120 };
expect(ScraperStatusSchema.parse({ running: false, providerWindows: [window] }).providerWindows).toEqual([window]);
});

it("preserves local window identity and timezone", () => {
const window = { provider: "greenhouse", id: "greenhouse-1", timezone: "America/Sao_Paulo", startMinuteLocal: 0, endMinuteLocal: 60 };
expect(ScraperStatusSchema.parse({ running: true, providerWindows: [window] }).providerWindows).toEqual([window]);
});

it("rejects incomplete local windows and invalid minute bounds", () => {
for (const window of [
{ provider: "gupy", startMinuteLocal: 0, endMinuteLocal: 60 },
{ provider: "gupy", id: "gupy-1", timezone: "America/Sao_Paulo", startMinuteLocal: 1440, endMinuteLocal: 1500 },
]) {
expect(ScraperStatusSchema.safeParse({ running: false, providerWindows: [window] }).success).toBe(false);
}
});
});
14 changes: 14 additions & 0 deletions backend/tests/unit/modules/admin/scraperResult.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { expect, it } from "vitest";
import { ScraperStatusSchema } from "../../../../src/modules/admin/scrapers/scrapers.types";

it("retains partial execution counts and accepts an empty initial history", () => {
expect(ScraperStatusSchema.parse({ running: false, lastRunAt: null, lastRun: null }).lastRun).toBeNull();
const result = ScraperStatusSchema.parse({
running: false,
lastRunAt: "2026-11-01T00:00:00Z",
jobsCollected: 210,
lastRun: { runId: "test", status: "partial", finishedAt: "2026-11-01T00:00:00Z", jobsSaved: 210, jobsIndexed: 210, errorType: "timeout" },
});
expect(result.lastRun?.status).toBe("partial");
expect(result.lastRun?.jobsIndexed).toBe(210);
});
4 changes: 3 additions & 1 deletion backend/tests/unit/modules/admin/scrapers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,9 @@ describe("ScrapersService", () => {
ok: true,
message: "started",
});
await expect(service.triggerScraper("lever")).rejects.toThrow(
await expect(service.triggerScraper("lever")).resolves.toEqual({ ok: true, message: "started" });
expect(scraperClient.triggerScrape).toHaveBeenLastCalledWith(["lever"]);
await expect(service.triggerScraper("invalid")).rejects.toThrow(
"scraper desconhecido",
);
});
Expand Down
Loading
Loading