Ir para conteúdo

Como criar um rastreador de vagas de TI com Python e IA (código completo)

Tela de computador exibindo código de programação, representando um script em Python para rastrear vagas de TI com IA

Quem procura vaga ou projeto freelance em tecnologia conhece a rotina: abrir dez abas, filtrar anúncios com requisito desatualizado, descobrir que a “vaga Python” pedia cinco anos de uma stack que nem existe e repetir tudo no dia seguinte. É tempo de gente que sabe programar gastando horas em tarefa de máquina.

A boa notícia é que esse problema se resolve com um script de poucas centenas de linhas. Neste guia você vai montar um rastreador de vagas de TI com Python e IA que coleta anúncios, limpa o texto, dá uma nota de compatibilidade para cada vaga usando embeddings, ignora o que você já viu e manda só o que vale a pena para o seu Telegram.

O código abaixo roda de verdade, e não é só ilustrativo. Também explico as decisões por trás dele, os erros que costumam derrubar esse tipo de projeto e como adaptar a ideia para RH e para quem vende prospecção.

O que é um rastreador de oportunidades (e por que vale automatizar)

Um rastreador de oportunidades é um pipeline que faz quatro coisas em loop: coleta anúncios de várias fontes, normaliza o texto, pontua cada item contra o seu perfil e avisa você quando aparece algo bom.

A diferença para um alerta de vagas comum é a etapa de pontuação. Alerta de site filtra por palavra-chave: se a vaga diz “Python”, ela passa. Um rastreador com IA entende que “desenvolvedor de automações com FastAPI e integração de APIs” combina com o seu perfil mesmo sem repetir as mesmas palavras. Isso é busca semântica aplicada a vagas.

AbordagemResultado prático
Alerta por palavra-chaveMuito ruído, perde vaga escrita com outros termos
Rastreador com embeddingsRanking por compatibilidade, poucas notificações e mais relevantes

Arquitetura do pipeline

Antes de escrever código, vale enxergar o desenho. Cada etapa é independente, então você pode trocar uma sem mexer nas outras.

EtapaO que faz
1. IngestãoBusca os anúncios em APIs, RSS ou páginas permitidas
2. NormalizaçãoRemove HTML, espaços e ruído do texto
3. Filtro heurísticoDescarta o que obviamente não serve, antes de gastar processamento
4. Pontuação semânticaCompara cada vaga com o seu perfil usando embeddings
5. DeduplicaçãoGuarda no SQLite o que já foi enviado
6. AlertaDispara mensagem por webhook ou API do Telegram

Esse desenho segue a lógica de qualquer pipeline de IA: dado entra sujo, sai classificado e acionável.

Antes de coletar: o que pode e o que não pode

Este é o ponto que a maioria dos tutoriais pula, e é onde projeto de scraping morre.

  • Prefira API e RSS. Muitos agregadores de vagas oferecem API pública ou feed justamente para isso. É mais estável e não quebra quando o layout muda.
  • Leia os termos de uso. Plataformas como o LinkedIn proíbem coleta automatizada nos termos. Se a fonte não permite, fica de fora.
  • Respeite o robots.txt. Se a página pede para robôs não entrarem, não entre.
  • Não martele o servidor. Poucas execuções por dia bastam. Vaga não muda a cada minuto.
  • Cuidado com dado pessoal. Nome e contato de recrutador são dados pessoais. Se for armazenar, trate sob a LGPD ou simplesmente não guarde.

Se quiser entender melhor a técnica, o glossário tem uma explicação de web scraping e de extração de dados.

Preparando o ambiente

Você precisa de Python 3.10 ou superior. Crie um ambiente virtual e instale as dependências:

python -m venv .venv
source .venv/bin/activate
pip install aiohttp sentence-transformers

O sqlite3 já vem com o Python. A biblioteca sentence-transformers baixa o modelo de embeddings na primeira execução, então a primeira rodada demora mais.

Para o alerta, crie um bot no Telegram conversando com o @BotFather, copie o token e descubra o seu chat_id mandando uma mensagem para o bot e abrindo https://api.telegram.org/bot<TOKEN>/getUpdates. Guarde os dois em variáveis de ambiente:

export TELEGRAM_TOKEN="seu_token_aqui"
export TELEGRAM_CHAT_ID="seu_chat_id_aqui"

Nunca coloque token direto no código nem suba para o GitHub.

O código completo, passo a passo

Vou mostrar o script em blocos e, no fim, ele fica em um único arquivo rastreador.py.

1. Configuração e perfil

Aqui está a decisão mais importante do projeto: o texto do seu perfil. Ele é a régua contra a qual toda vaga será medida, então descreva o trabalho que você quer fazer, não só uma lista de tecnologias.

import asyncio
import html
import os
import re
import sqlite3

import aiohttp
from sentence_transformers import SentenceTransformer, util

# Modelo multilíngue: entende português e inglês no mesmo espaço vetorial
MODEL_NAME = "paraphrase-multilingual-MiniLM-L12-v2"

PERFIL = (
    "Desenvolvedor Python com experiência em FastAPI, automação de processos, "
    "integração de APIs, web scraping e aplicações de IA generativa"
)

# Fontes de vagas. Exemplo com a API pública da Remotive; confira os termos de uso
FONTES = [
    "https://remotive.com/api/remote-jobs?category=software-dev&limit=50",
]

# Só passa para a IA o que tiver ao menos um destes termos
PALAVRAS_OBRIGATORIAS = ["python", "automation", "automação", "api", "backend", "ia", "ai"]

# Descarta direto o que contiver algum destes termos
PALAVRAS_BLOQUEADAS = ["estágio", "intern", "unpaid"]

LIMIAR = 0.40  # ajuste com base nos seus próprios testes (veja a seção de calibração)
DB_PATH = "vagas.db"

Repare que usei um modelo multilíngue. Vários tutoriais usam o all-MiniLM-L6-v2, que foi treinado majoritariamente em inglês e rende mal com vaga em português. O paraphrase-multilingual-MiniLM-L12-v2 resolve isso e ainda cobre vagas internacionais.

2. Coleta assíncrona com retry

Com aiohttp você busca todas as fontes ao mesmo tempo. O detalhe que falta em muito exemplo é tratar timeout e falha com backoff exponencial: se a fonte der erro, espera um pouco e tenta de novo, em vez de derrubar o script inteiro.

async def buscar_json(sessao: aiohttp.ClientSession, url: str, tentativas: int = 3):
    for tentativa in range(tentativas):
        try:
            async with sessao.get(url, timeout=aiohttp.ClientTimeout(total=20)) as resposta:
                if resposta.status == 200:
                    return await resposta.json()
                if resposta.status in (429, 500, 502, 503, 504):
                    await asyncio.sleep(2 ** tentativa)  # 1s, 2s, 4s
                    continue
                return None  # erro definitivo (403, 404...): não adianta insistir
        except (aiohttp.ClientError, asyncio.TimeoutError):
            await asyncio.sleep(2 ** tentativa)
    return None


async def coletar(fontes: list[str]) -> list[dict]:
    headers = {"User-Agent": "rastreador-pessoal/1.0 (uso pessoal)"}
    async with aiohttp.ClientSession(headers=headers) as sessao:
        respostas = await asyncio.gather(*(buscar_json(sessao, url) for url in fontes))

    vagas = []
    for dados in respostas:
        if not dados:
            continue
        for item in dados.get("jobs", []):
            vagas.append(
                {
                    "id": str(item.get("id")),
                    "titulo": item.get("title", ""),
                    "empresa": item.get("company_name", ""),
                    "descricao": item.get("description", ""),
                    "url": item.get("url", ""),
                }
            )
    return vagas

Cada API tem seu próprio formato de JSON. Se você trocar a fonte, só esta função muda: o resto do pipeline recebe sempre a mesma estrutura de dicionário. Se precisar relembrar o que é esse formato, veja o que é JSON e o que é uma API.

3. Normalização e filtro heurístico

APIs de vagas costumam devolver a descrição em HTML. Se você mandar isso cru para o modelo, ele gasta capacidade lendo tag. Limpe antes:

def limpar_texto(bruto: str) -> str:
    sem_tags = re.sub(r"<[^>]+>", " ", bruto)
    texto = html.unescape(sem_tags)
    return re.sub(r"\s+", " ", texto).strip()


def passa_no_filtro(vaga: dict) -> bool:
    texto = f"{vaga['titulo']} {vaga['descricao']}".lower()
    if any(p in texto for p in PALAVRAS_BLOQUEADAS):
        return False
    return any(p in texto for p in PALAVRAS_OBRIGATORIAS)

O filtro heurístico parece um passo menor, mas é ele que mantém o rastreador barato. Rodar embedding em 2 mil vagas é rápido; rodar um LLM em 2 mil vagas não é. Quanto antes você descarta o que é claramente irrelevante, menos você gasta nas etapas caras.

4. Pontuação semântica com embeddings

Aqui está o coração do projeto. O modelo transforma o seu perfil e cada vaga em vetores, e a similaridade de cosseno diz o quanto os dois “apontam para o mesmo lugar”.

def pontuar(modelo: SentenceTransformer, vagas: list[dict]) -> list[dict]:
    if not vagas:
        return []

    perfil_emb = modelo.encode(PERFIL, convert_to_tensor=True)

    # O modelo lê só os primeiros tokens, então usamos título + início da descrição
    textos = [f"{v['titulo']}. {v['descricao'][:1000]}" for v in vagas]
    vagas_emb = modelo.encode(textos, convert_to_tensor=True, batch_size=32)

    notas = util.cos_sim(perfil_emb, vagas_emb)[0].tolist()
    for vaga, nota in zip(vagas, notas):
        vaga["nota"] = round(nota, 3)

    return sorted(vagas, key=lambda v: v["nota"], reverse=True)

Dois detalhes que fazem diferença:

  1. Processamento em lote. Codificar a lista inteira de uma vez com batch_size é muito mais rápido do que chamar encode dentro de um loop, vaga por vaga, como aparece em muito exemplo.
  2. Texto truncado. Esse modelo tem janela curta de entrada. Mandar 10 mil caracteres não melhora a nota, só desperdiça tempo. Título mais o começo da descrição carregam a maior parte do sinal.

5. Deduplicação com SQLite

Sem isso, você receberia a mesma vaga a cada execução. O banco guarda o ID de cada vaga já enviada:

def abrir_banco() -> sqlite3.Connection:
    conexao = sqlite3.connect(DB_PATH)
    conexao.execute(
        "CREATE TABLE IF NOT EXISTS enviadas (id TEXT PRIMARY KEY, titulo TEXT, nota REAL)"
    )
    return conexao


def ja_enviada(conexao: sqlite3.Connection, vaga_id: str) -> bool:
    linha = conexao.execute("SELECT 1 FROM enviadas WHERE id = ?", (vaga_id,)).fetchone()
    return linha is not None


def marcar_enviada(conexao: sqlite3.Connection, vaga: dict) -> None:
    conexao.execute(
        "INSERT OR IGNORE INTO enviadas (id, titulo, nota) VALUES (?, ?, ?)",
        (vaga["id"], vaga["titulo"], vaga["nota"]),
    )
    conexao.commit()

Como bônus, esse banco vira um histórico seu: dá para consultar depois quais empresas apareceram mais, que notas as vagas tiveram e como o mercado mudou.

6. Alerta no Telegram

A API de bots do Telegram é um simples POST. Nada de biblioteca extra:

async def avisar_telegram(sessao: aiohttp.ClientSession, vaga: dict) -> None:
    token = os.environ["TELEGRAM_TOKEN"]
    chat_id = os.environ["TELEGRAM_CHAT_ID"]
    texto = (
        f"Nota {vaga['nota']:.2f}\n"
        f"{vaga['titulo']} | {vaga['empresa']}\n"
        f"{vaga['url']}"
    )
    url = f"https://api.telegram.org/bot{token}/sendMessage"
    async with sessao.post(url, json={"chat_id": chat_id, "text": texto}) as resposta:
        resposta.raise_for_status()

Quer Discord, Slack ou e-mail? A troca é só esta função. O modelo mental é o mesmo de qualquer integração por webhook.

7. Juntando tudo

async def principal() -> None:
    modelo = SentenceTransformer(MODEL_NAME)
    conexao = abrir_banco()

    vagas = await coletar(FONTES)
    for v in vagas:
        v["descricao"] = limpar_texto(v["descricao"])

    candidatas = [v for v in vagas if passa_no_filtro(v) and not ja_enviada(conexao, v["id"])]
    ranqueadas = pontuar(modelo, candidatas)
    boas = [v for v in ranqueadas if v["nota"] >= LIMIAR]

    print(f"{len(vagas)} coletadas, {len(candidatas)} candidatas, {len(boas)} acima do limiar")

    if boas:
        async with aiohttp.ClientSession() as sessao:
            for vaga in boas[:10]:  # no máximo 10 alertas por execução
                await avisar_telegram(sessao, vaga)
                marcar_enviada(conexao, vaga)


if __name__ == "__main__":
    asyncio.run(principal())

Rode com python rastreador.py. Na primeira execução você vai ver quantas vagas foram coletadas, quantas sobreviveram ao filtro e quantas passaram do limiar.

Calibrando o limiar (o passo que decide se o rastreador presta)

O número de corte não é mágico. O 0.40 do código é só um ponto de partida, e cada modelo tem uma escala própria de notas. Em vez de chutar, calibre assim:

  1. Rode o script com LIMIAR = 0 e imprima as 50 primeiras vagas com suas notas.
  2. Marque à mão quais você realmente se candidataria.
  3. Veja a nota da pior vaga “boa” e a da melhor vaga “ruim”. O limiar fica entre as duas.
  4. Reavalie depois de uma ou duas semanas de uso.
SintomaAjuste
Muita vaga irrelevante no alertaSubir o limiar ou refinar o texto do perfil
Vaga boa não apareceBaixar o limiar ou ampliar o perfil

Se o ranking ainda parecer estranho, o problema quase sempre está no texto do perfil. Escreva do jeito que um anúncio descreveria o trabalho, não como um currículo cheio de siglas.

Subindo o nível: uma segunda camada com LLM

Embeddings dizem “essa vaga se parece com você”. Eles não dizem “essa vaga exige 8 anos de experiência” ou “é presencial em outra cidade”. Para isso entra um modelo de linguagem, mas só nas vagas que já passaram do limiar, o que mantém o custo baixo.

A ideia é pedir ao LLM uma saída estruturada em JSON, algo como:

{
  "senioridade": "pleno",
  "regime": "remoto",
  "contrato": "PJ",
  "anos_experiencia_minimos": 3,
  "stack_principal": ["python", "fastapi", "postgresql"],
  "bate_com_meu_perfil": true
}

Com esse JSON você pode aplicar regras duras (por exemplo, descartar o que exige mais de 5 anos) em vez de depender só de uma nota. Isso é classificação automática de verdade, e o custo fica baixo porque só as 5 ou 10 melhores vagas do dia chegam até o modelo.

Rodando todo dia sem lembrar de rodar

Um rastreador que depende de você abrir o terminal não é um rastreador. Duas formas simples de agendar:

FormaQuando usar
cron em um servidor ou VPSVocê já tem uma máquina ligada o tempo todo
GitHub Actions com scheduleQuer rodar de graça, sem servidor próprio

Exemplo de cron para rodar a cada 6 horas:

0 */6 * * * cd /home/usuario/rastreador && .venv/bin/python rastreador.py >> log.txt 2>&1

Se preferir montar isso sem código, dá para fazer a coleta, o filtro e o alerta em uma ferramenta visual. Vale ler a comparação entre Make e n8n antes de escolher. A parte de embeddings e ranking, porém, fica bem mais flexível em Python.

Erros comuns que quebram esse tipo de projeto

  • Depender de uma fonte só. Se a API sai do ar ou muda o formato, o rastreador morre em silêncio. Use duas ou três fontes e registre erros em log.
  • Não deduplicar. Resultado: spam de alerta repetido e você silencia o bot em dois dias.
  • Perfil genérico demais. “Desenvolvedor de software” combina com tudo e, portanto, com nada.
  • Limiar fixo para sempre. O mercado e as fontes mudam. Reavalie de tempos em tempos.
  • Ignorar falha de rede. Sem retry e sem timeout, uma resposta lenta trava a execução inteira.
  • Rodar frequente demais. Além de desnecessário, pode gerar bloqueio de IP.

Escalando: quando o volume cresce

Para uso pessoal, o SQLite e a comparação direta resolvem. Quando o volume passa de dezenas de milhares de anúncios, ou quando você quer buscar por similaridade em um histórico grande, vale guardar os vetores em um banco vetorial, como Qdrant ou Chroma, e evitar recalcular embeddings do que já foi visto. Uma otimização mais simples: salvar o embedding de cada vaga processada junto com o ID, para nunca codificar duas vezes o mesmo texto.

Além de vagas: onde essa arquitetura rende dinheiro

A mesma estrutura de coleta, normalização, pontuação semântica e alerta serve para problemas bem comerciais:

Caso de usoComo o pipeline se adapta
Triagem de currículos no RHO perfil vira a descrição da vaga e os currículos são pontuados contra ela
Prospecção de clientesMonitora editais, licitações ou anúncios e avisa quando surge oportunidade com o seu perfil
Monitoramento de concorrentesDetecta mudanças de preço, novos produtos ou novas páginas
Triagem de e-mails e chamadosClassifica o que chega e encaminha para a pessoa certa
Radar de freelasO próprio tema deste artigo, aplicado a plataformas de projetos

Em empresa, o ganho não é “economizar tempo”. É parar de depender de alguém lembrando de olhar. Vale ler também sobre o que um desenvolvedor fullstack freelancer consegue automatizar para ter ideia do alcance.

Quando vale contratar em vez de montar sozinho

O script deste artigo funciona bem para uso pessoal. Os problemas aparecem quando a coisa vira operação:

  • Várias fontes que mudam de formato e precisam de manutenção.
  • Dados de terceiros que exigem cuidado com LGPD.
  • Integração com CRM, ATS, planilhas ou o sistema interno da empresa.
  • Necessidade de painel, histórico e métricas de acerto.
  • Alertas que precisam chegar para um time, não para uma pessoa.

Se esse é o seu caso, eu desenvolvo esse tipo de automação sob medida, do pipeline em Python até a integração com as ferramentas que sua equipe já usa. Conheça o serviço de automação de tarefas ou, se o projeto for maior, o trabalho como desenvolvedor freelancer.

Perguntas frequentes

Dá para criar um rastreador de vagas sem saber programar?

Dá para uma versão simples, com ferramentas visuais como Make ou n8n, que leem RSS, filtram por palavra-chave e mandam alerta. Para pontuar por compatibilidade com embeddings e ter controle total do filtro, Python é o caminho mais flexível.

Preciso pagar por API de IA para isso funcionar?

Não. O modelo de embeddings usado aqui roda localmente, no seu computador, sem custo por chamada. Você só paga se adicionar a camada opcional com LLM por API, e mesmo assim o custo é baixo porque ela processa apenas as melhores vagas.

Posso usar esse script para coletar vagas do LinkedIn?

Não é recomendável. Os termos de uso do LinkedIn proíbem coleta automatizada, e a conta pode ser restringida. Prefira fontes com API pública, RSS ou que permitam a coleta explicitamente.

Qual a diferença entre filtrar por palavra-chave e usar embeddings?

A palavra-chave só encontra o termo exato. Embeddings comparam o significado dos textos, então uma vaga que fala em “automação de rotinas com scripts” pode combinar com um perfil que diz “Python para automação de processos”, mesmo sem repetir as mesmas palavras.

Por que usar um modelo multilíngue?

Porque muitas vagas de TI estão em inglês e outras em português. Um modelo multilíngue coloca os dois idiomas no mesmo espaço vetorial, então seu perfil em português consegue ser comparado com anúncios em inglês.

Quantas vezes por dia devo rodar o rastreador?

De duas a quatro vezes por dia costuma ser suficiente. Vagas não mudam a cada minuto, e rodar com muita frequência só aumenta o risco de bloqueio e de gastar recurso à toa.

Como evito receber a mesma vaga repetida?

Guardando o ID de cada vaga enviada em um banco, como o SQLite do exemplo. Antes de notificar, o script consulta se aquele ID já foi processado.

Essa mesma ideia serve para o RH de uma empresa?

Serve. Trocando o perfil pela descrição da vaga e as vagas pelos currículos, o pipeline faz uma triagem inicial por compatibilidade. O resultado deve apoiar a decisão humana, nunca substituí-la, principalmente por causa de vieses e de exigências legais.

Conclusão

Um rastreador de vagas de TI com Python e IA é um projeto pequeno que ensina muito: coleta assíncrona, limpeza de dados, embeddings, deduplicação e notificação. E o resultado é útil no dia seguinte: você para de caçar vaga e passa a receber só o que combina.

O que separa um script que funciona de um que você usa por meses são os detalhes que este guia cobriu: um modelo multilíngue, processamento em lote, retry com backoff, um limiar calibrado com dados reais e uma deduplicação que não deixa o bot virar spam.

Comece pequeno, com uma fonte e um perfil bem escrito, e vá refinando. Se o seu caso for mais ambicioso, como triagem de candidatos, monitoramento de oportunidades para um time ou integração com sistemas da empresa, é aí que uma automação sob medida compensa.

Referências

Leia também

Artigos Relacionados