O que automatizar primeiro: a decisão que se repete
Cansaço não é critério para automatizar. Veja como separar decisões previsíveis das que exigem julgamento e medir isso em uma semana, antes de escolher qualquer ferramenta.
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.
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.
| Abordagem | Resultado prático |
|---|---|
| Alerta por palavra-chave | Muito ruído, perde vaga escrita com outros termos |
| Rastreador com embeddings | Ranking por compatibilidade, poucas notificações e mais relevantes |
Antes de escrever código, vale enxergar o desenho. Cada etapa é independente, então você pode trocar uma sem mexer nas outras.
| Etapa | O que faz |
|---|---|
| 1. Ingestão | Busca os anúncios em APIs, RSS ou páginas permitidas |
| 2. Normalização | Remove HTML, espaços e ruído do texto |
| 3. Filtro heurístico | Descarta o que obviamente não serve, antes de gastar processamento |
| 4. Pontuação semântica | Compara cada vaga com o seu perfil usando embeddings |
| 5. Deduplicação | Guarda no SQLite o que já foi enviado |
| 6. Alerta | Dispara 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.
Este é o ponto que a maioria dos tutoriais pula, e é onde projeto de scraping morre.
Se quiser entender melhor a técnica, o glossário tem uma explicação de web scraping e de extração de dados.
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.
Vou mostrar o script em blocos e, no fim, ele fica em um único arquivo rastreador.py.
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.
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.
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.
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:
batch_size é muito mais rápido do que chamar encode dentro de um loop, vaga por vaga, como aparece em muito exemplo.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.
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.
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.
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:
LIMIAR = 0 e imprima as 50 primeiras vagas com suas notas.| Sintoma | Ajuste |
|---|---|
| Muita vaga irrelevante no alerta | Subir o limiar ou refinar o texto do perfil |
| Vaga boa não aparece | Baixar 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.
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.
Um rastreador que depende de você abrir o terminal não é um rastreador. Duas formas simples de agendar:
| Forma | Quando usar |
|---|---|
cron em um servidor ou VPS | Você já tem uma máquina ligada o tempo todo |
GitHub Actions com schedule | Quer 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.
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.
A mesma estrutura de coleta, normalização, pontuação semântica e alerta serve para problemas bem comerciais:
| Caso de uso | Como o pipeline se adapta |
|---|---|
| Triagem de currículos no RH | O perfil vira a descrição da vaga e os currículos são pontuados contra ela |
| Prospecção de clientes | Monitora editais, licitações ou anúncios e avisa quando surge oportunidade com o seu perfil |
| Monitoramento de concorrentes | Detecta mudanças de preço, novos produtos ou novas páginas |
| Triagem de e-mails e chamados | Classifica o que chega e encaminha para a pessoa certa |
| Radar de freelas | O 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.
O script deste artigo funciona bem para uso pessoal. Os problemas aparecem quando a coisa vira operação:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.