Skip to content

Commit a1be1a5

Browse files
committed
feat: adiciona filtro de busca para vagas por data de publicacao
0 parents  commit a1be1a5

12 files changed

Lines changed: 519 additions & 0 deletions

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/node_modules/
2+
.DS_Store
3+
.env
4+
.vscode/
5+
npm-debug.log
6+
yarn-error.log
7+
yarn-debug.log
8+
package-lock.json

README.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Vagas LinkedIn Brasil (Remoto)
2+
3+
Bot de automacao para buscar vagas no LinkedIn com foco em Brasil remoto e exportar os resultados em Excel e PDF.
4+
5+
## Comportamento atual
6+
7+
- Escopo geografico: Brasil (geoId padrao `106057199`)
8+
- Modalidade: remoto apenas (`f_WT=2`)
9+
- Tipos de contrato padrao: PJ e CLT (`f_JT=C,F`)
10+
- Exportacao em dois formatos: `.xlsx` e `.pdf`
11+
- Deduplicacao por link (ou por titulo + empresa + local quando o link nao existe)
12+
13+
## Estrutura do projeto
14+
15+
- `index.js`: entrypoint e tratamento de falhas
16+
- `src/app.js`: fluxo principal (coleta e exportacao)
17+
- `src/config.js`: leitura das variaveis de ambiente
18+
- `src/linkedinScraper.js`: coleta HTTP, paginacao e extracao das vagas
19+
- `src/exporter.js`: geracao de Excel e PDF
20+
- `src/logger.js`: logs padronizados
21+
22+
## Requisitos
23+
24+
- Node.js 22+
25+
- npm
26+
27+
## Instalacao
28+
29+
1. Clone o projeto
30+
2. Rode `npm install`
31+
32+
## Execucao
33+
34+
- Producao: `npm start`
35+
- Desenvolvimento: `npm run dev`
36+
- Hot reload: `npm run run`
37+
38+
## Variaveis de ambiente
39+
40+
Todas sao opcionais.
41+
42+
- `WAIT_BETWEEN_SEARCHES_MS` (padrao: `5000`)
43+
- `PAGE_TIMEOUT_MS` (padrao: `10000`)
44+
- `MAX_PAGES_PER_KEYWORD` (padrao: `5`)
45+
- `OUTPUT_FILE` (padrao: `vagas_linkedin.xlsx`)
46+
- `PDF_FILE` (padrao: `vagas_linkedin.pdf`)
47+
- `SEARCH_LOCATION` (padrao: `Brasil`)
48+
- `SEARCH_GEO_ID` (padrao: `106057199`)
49+
- `SEARCH_LANGUAGE` (padrao: `pt`)
50+
- `JOB_TYPES` (padrao: `C,F`)
51+
Valores comuns: `C` (PJ), `F` (CLT), `C,F` (ambos)
52+
- `SEARCH_KEYWORDS` (lista separada por virgula)
53+
54+
Exemplo no Windows cmd (Brasil remoto, PJ+CLT):
55+
56+
```bat
57+
set SEARCH_GEO_ID=106057199&& set JOB_TYPES=C,F&& set MAX_PAGES_PER_KEYWORD=5&& npm start
58+
```
59+
60+
Exemplo com palavras-chave personalizadas:
61+
62+
```bat
63+
set SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner&& npm start
64+
```
65+
66+
## Saida
67+
68+
Arquivos gerados por padrao:
69+
70+
- `vagas_linkedin.xlsx`
71+
- `vagas_linkedin.pdf`
72+
73+
Colunas exportadas:
74+
75+
- `palavra`
76+
- `titulo`
77+
- `empresa`
78+
- `local`
79+
- `link`
80+
81+
No PDF, os links sao normalizados para uma versao curta do LinkedIn quando possivel, reduzindo risco de truncamento.
82+
83+
## Troubleshooting
84+
85+
Poucas ou nenhuma vaga retornada:
86+
87+
- Aumente `MAX_PAGES_PER_KEYWORD`
88+
- Aumente `WAIT_BETWEEN_SEARCHES_MS` para reduzir bloqueios temporarios
89+
- Reduza a quantidade de keywords por execucao
90+
91+
## Avisos
92+
93+
- Respeite os termos de uso do LinkedIn
94+
- Use o scraper de forma etica e responsavel
95+
- O HTML do LinkedIn pode mudar e exigir ajuste de seletores

index.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { run } from "./src/app.js";
2+
import { logError } from "./src/logger.js";
3+
4+
run().catch((error) => {
5+
logError(error?.stack || error?.message || "Falha inesperada na execucao");
6+
process.exitCode = 1;
7+
});

package.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"name": "vagas-full",
3+
"version": "1.0.0",
4+
"description": "Bot HTTP para buscar vagas no LinkedIn (Brasil remoto) e exportar em Excel e PDF",
5+
"main": "index.js",
6+
"type": "module",
7+
"scripts": {
8+
"start": "node index.js",
9+
"dev": "node index.js",
10+
"run": "nodemon index.js",
11+
"test": "echo \"Error: no test specified\" && exit 1"
12+
},
13+
"keywords": [
14+
"linkedin",
15+
"jobs",
16+
"scraping",
17+
"http",
18+
"vagas",
19+
"emprego",
20+
"brasil",
21+
"tecnologia",
22+
"automation",
23+
"axios",
24+
"cheerio"
25+
],
26+
"author": "Bene Tesla Dev",
27+
"license": "ISC",
28+
"dependencies": {
29+
"axios": "^1.13.6",
30+
"cheerio": "^1.2.0",
31+
"pdfkit": "^0.18.0",
32+
"xlsx": "^0.18.5"
33+
},
34+
"engines": {
35+
"node": ">=22.0.0"
36+
},
37+
"devDependencies": {
38+
"nodemon": "^3.1.14"
39+
}
40+
}

src/app.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { getConfig } from "./config.js";
2+
import { exportToExcel, exportToPDF } from "./exporter.js";
3+
import { scrapeLinkedinJobs } from "./linkedinScraper.js";
4+
import { logInfo } from "./logger.js";
5+
6+
export async function run() {
7+
const config = getConfig();
8+
9+
logInfo(`Localizacao da busca: ${config.searchLocation}`);
10+
logInfo(`Total de palavras-chave: ${config.keywords.length}`);
11+
12+
const jobs = await scrapeLinkedinJobs(config);
13+
exportToExcel(jobs, config.outputFile);
14+
await exportToPDF(jobs, config.pdfFile);
15+
16+
logInfo(`Total de vagas unicas exportadas: ${jobs.length}`);
17+
}

src/browser.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { existsSync } from "node:fs";
2+
import puppeteer from "puppeteer-core";
3+
4+
const CANDIDATE_BROWSERS = [
5+
process.env.CHROME_PATH,
6+
process.env.PUPPETEER_EXECUTABLE_PATH,
7+
"C:/Program Files/Google/Chrome/Application/chrome.exe",
8+
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
9+
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
10+
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"
11+
].filter(Boolean);
12+
13+
export function findBrowserExecutable() {
14+
return CANDIDATE_BROWSERS.find((path) => existsSync(path));
15+
}
16+
17+
export async function openBrowser(config) {
18+
const executablePath = findBrowserExecutable();
19+
20+
if (!executablePath) {
21+
throw new Error(
22+
"Nenhum Chrome/Edge foi encontrado. Instale o navegador ou defina CHROME_PATH."
23+
);
24+
}
25+
26+
return puppeteer.launch({
27+
headless: config.headless,
28+
executablePath
29+
});
30+
}

src/config.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
const DEFAULT_KEYWORDS = [
2+
"Desenvolvedor .NET Júnior",
3+
"Developer .NET Junior",
4+
"Programador C# Júnior",
5+
"Backend .NET Junior",
6+
"Dotnet Junior",
7+
"C# Junior",
8+
"Desenvolvedor Backend Junior",
9+
".NET",
10+
"ASP.NET Core",
11+
"Web API",
12+
"Entity Framework",
13+
"Dapper",
14+
"SQL Server",
15+
"Clean Architecture",
16+
"DDD",
17+
"SOLID"
18+
];
19+
function parseBoolean(value, fallback) {
20+
if (value === undefined) {
21+
return fallback;
22+
}
23+
24+
const normalized = String(value).trim().toLowerCase();
25+
if (["1", "true", "yes", "y", "on"].includes(normalized)) {
26+
return true;
27+
}
28+
if (["0", "false", "no", "n", "off"].includes(normalized)) {
29+
return false;
30+
}
31+
return fallback;
32+
}
33+
34+
function parseNumber(value, fallback) {
35+
if (value === undefined) {
36+
return fallback;
37+
}
38+
39+
const parsed = Number(value);
40+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
41+
}
42+
43+
function parseKeywords(value) {
44+
if (!value) {
45+
return DEFAULT_KEYWORDS;
46+
}
47+
48+
const keywords = value
49+
.split(",")
50+
.map((item) => item.trim())
51+
.filter(Boolean);
52+
53+
return keywords.length > 0 ? keywords : DEFAULT_KEYWORDS;
54+
}
55+
56+
// ... (mantenha as funções auxiliares parseBoolean, parseNumber, etc.)
57+
58+
export function getConfig() {
59+
return {
60+
headless: parseBoolean(process.env.HEADLESS, false),
61+
waitBetweenSearchesMs: parseNumber(process.env.WAIT_BETWEEN_SEARCHES_MS, 5000),
62+
pageTimeoutMs: parseNumber(process.env.PAGE_TIMEOUT_MS, 10000),
63+
maxPagesPerKeyword: parseNumber(process.env.MAX_PAGES_PER_KEYWORD, 5),
64+
viewport: {
65+
width: parseNumber(process.env.VIEWPORT_WIDTH, 1280),
66+
height: parseNumber(process.env.VIEWPORT_HEIGHT, 800)
67+
},
68+
outputFile: process.env.OUTPUT_FILE || "vagas_linkedin.xlsx",
69+
pdfFile: process.env.PDF_FILE || "vagas_linkedin.pdf",
70+
searchLocation: process.env.SEARCH_LOCATION || "Brasil",
71+
searchGeoId: process.env.SEARCH_GEO_ID || "106057199",
72+
searchLanguage: process.env.SEARCH_LANGUAGE || "pt",
73+
remoteOnly: parseBoolean(process.env.REMOTE_ONLY, true),
74+
jobTypes: process.env.JOB_TYPES || "C,F",
75+
76+
// r604800 = Última semana 7 dias
77+
// r86400 = 24 hora
78+
// r2592000 = 30 dias
79+
80+
timeFilter: process.env.TIME_FILTER || "r604800",
81+
82+
keywords: parseKeywords(process.env.SEARCH_KEYWORDS)
83+
};
84+
}

src/exporter.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { createWriteStream } from "fs";
2+
import PDFDocument from "pdfkit";
3+
import XLSX from "xlsx";
4+
import { logInfo } from "./logger.js";
5+
6+
export function exportToExcel(rows, outputFile) {
7+
const worksheet = XLSX.utils.json_to_sheet(rows);
8+
const workbook = XLSX.utils.book_new();
9+
XLSX.utils.book_append_sheet(workbook, worksheet, "Vagas");
10+
XLSX.writeFile(workbook, outputFile);
11+
12+
logInfo(`Arquivo gerado: ${outputFile}`);
13+
}
14+
15+
function cleanJobUrl(url) {
16+
try {
17+
const match = url.match(/\/jobs\/view\/[^?#]*-(\d{6,})/);
18+
if (match) return `https://www.linkedin.com/jobs/view/${match[1]}`;
19+
} catch {}
20+
return url;
21+
}
22+
23+
export function exportToPDF(rows, outputFile) {
24+
return new Promise((resolve, reject) => {
25+
const doc = new PDFDocument({ margin: 30, size: "A4", layout: "landscape" });
26+
const stream = createWriteStream(outputFile);
27+
28+
doc.pipe(stream);
29+
stream.on("error", reject);
30+
stream.on("finish", resolve);
31+
32+
// Título
33+
doc
34+
.fontSize(14)
35+
.font("Helvetica-Bold")
36+
.text("Vagas LinkedIn – Brasil (Remoto)", { align: "center" });
37+
doc.moveDown(0.5);
38+
doc.fontSize(9).font("Helvetica").text(`Total: ${rows.length} vagas`, { align: "center" });
39+
doc.moveDown(1);
40+
41+
const cols = [
42+
{ key: "palavra", label: "Palavra-chave", width: 110 },
43+
{ key: "titulo", label: "Título", width: 160 },
44+
{ key: "empresa", label: "Empresa", width: 130 },
45+
{ key: "local", label: "Local", width: 100 },
46+
{ key: "link", label: "Link", width: 280 }
47+
];
48+
49+
const rowH = 18;
50+
const startX = doc.page.margins.left;
51+
52+
function drawRow(rowData, isHeader) {
53+
let x = startX;
54+
const y = doc.y;
55+
56+
if (isHeader) {
57+
doc.rect(x, y, cols.reduce((s, c) => s + c.width, 0), rowH).fill("#2c3e50");
58+
} else {
59+
doc.rect(x, y, cols.reduce((s, c) => s + c.width, 0), rowH).fillAndStroke("#f9f9f9", "#dddddd");
60+
}
61+
62+
doc.font(isHeader ? "Helvetica-Bold" : "Helvetica").fontSize(8);
63+
64+
for (const col of cols) {
65+
const raw = String(rowData[col.key] ?? "");
66+
const isLink = col.key === "link" && !isHeader;
67+
const text = isLink ? cleanJobUrl(raw) : raw;
68+
69+
if (isLink) {
70+
doc
71+
.fillColor("#0563C1")
72+
.text(text, x + 3, y + 4, { width: col.width - 6, lineBreak: false });
73+
doc.link(x + 3, y + 4, col.width - 6, 10, text);
74+
} else {
75+
doc
76+
.fillColor(isHeader ? "white" : "#222222")
77+
.text(text, x + 3, y + 4, { width: col.width - 6, lineBreak: false, ellipsis: true });
78+
}
79+
x += col.width;
80+
}
81+
82+
doc.y = y + rowH;
83+
}
84+
85+
drawRow({ palavra: "Palavra-chave", titulo: "Título", empresa: "Empresa", local: "Local", link: "Link" }, true);
86+
87+
for (let i = 0; i < rows.length; i++) {
88+
if (doc.y + rowH > doc.page.height - doc.page.margins.bottom) {
89+
doc.addPage();
90+
drawRow({ palavra: "Palavra-chave", titulo: "Título", empresa: "Empresa", local: "Local", link: "Link" }, true);
91+
}
92+
drawRow(rows[i], false);
93+
}
94+
95+
doc.end();
96+
logInfo(`PDF gerado: ${outputFile}`);
97+
});
98+
}

0 commit comments

Comments
 (0)