Projeto 3: Bot de Automação com Scraping e Notificações

Projeto 3: Bot de Automação com Scraping e Notificações

Um bot de vagas com coleta pela API do RemoteOK, filtro por pontuação, SQLite, Telegram, e-mail e agendamento — conferido contra a API real, com os defeitos de um processo que roda sozinho: a falha de rede engolida, o salário na unidade errada e o .env dentro da imagem.
Python

• • 30 min de leitura

O Projeto 3 é um bot de automação completo — monitora fontes de dados na web, processa as informações coletadas e envia notificações inteligentes por múltiplos canais. Ele junta Web Scraping com BeautifulSoup e Selenium, Automação com Python e Envio de E-mails e Integração com APIs de Comunicação. O sistema monitora vagas de emprego em tecnologia, filtra por critérios do usuário e notifica em tempo real via Telegram e e-mail.

Visão Geral do Projeto

job-bot/
├── src/
│   └── jobbot/
│       ├── __init__.py
│       ├── config.py              ← configurações e critérios
│       ├── modelos.py             ← a Vaga
│       ├── agendador.py           ← schedule + execução
│       ├── coletores/
│       │   ├── __init__.py
│       │   ├── base.py            ← classe base de coleta
│       │   ├── remoteok.py        ← API RemoteOK
│       │   └── linkedin.py        ← scraping (ver aviso adiante)
│       ├── processadores/
│       │   ├── __init__.py
│       │   ├── filtro.py          ← filtragem e pontuação
│       │   └── deduplicador.py    ← remoção de duplicatas
│       ├── armazenamento/
│       │   ├── __init__.py
│       │   └── repositorio.py     ← SQLite para vagas
│       └── notificadores/
│           ├── __init__.py
│           ├── telegram.py        ← bot Telegram
│           └── email.py           ← notificações por e-mail
├── tests/
│   ├── conftest.py
│   ├── test_filtro.py
│   └── test_deduplicador.py
├── data/
├── logs/
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── .env.example

Configuração

# src/jobbot/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
from typing import Optional
from functools import lru_cache


class Configuracoes(BaseSettings):
    # Bot Telegram
    telegram_token:  str
    telegram_chat_id: str

    # E-mail
    email_usuario:   str
    email_senha:     str
    email_destino:   str
    smtp_host:       str  = "smtp.gmail.com"
    smtp_porta:      int  = 587

    # Critérios de busca
    palavras_chave:  list[str] = ["Python", "FastAPI", "Django", "Data Science"]
    tecnologias:     list[str] = ["Python", "FastAPI", "Django", "pandas", "scikit-learn"]
    # na unidade do RemoteOK: dólares por ano (US$ 60 mil ≈ US$ 5 mil/mês)
    salario_minimo:  float     = 60000.0
    remoto_apenas:   bool      = True
    senioridade:     list[str] = ["junior", "pleno", "senior", ""]

    # Agendamento
    intervalo_minutos: int = 60
    horario_resumo:    str = "08:00"   # resumo diário

    # Armazenamento
    db_path:    str = "data/vagas.db"
    max_vagas_notificadas: int = 5   # por execução

    model_config = SettingsConfigDict(env_file=".env")


@lru_cache
def get_config() -> Configuracoes:
    return Configuracoes()

O salário mínimo está em dólares por ano porque é essa a unidade que o RemoteOK devolve: consultada em setembro de 2026, a API trazia, por exemplo, salary_min: 70000 e salary_max: 80000. A primeira versão comparava esses valores com 5000, pensados como reais por mês, e qualquer vaga com salário informado passava no critério.

Domínio: Vaga de Emprego

# src/jobbot/modelos.py
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
import hashlib


@dataclass
class Vaga:
    titulo:        str
    empresa:       str
    descricao:     str
    url:           str
    fonte:         str
    localidade:    str          = "Remoto"
    salario_min:   Optional[float] = None
    salario_max:   Optional[float] = None
    tecnologias:   list[str]    = field(default_factory=list)
    senioridade:   str          = ""
    remoto:        bool         = True
    coletado_em:   datetime     = field(
        default_factory=lambda: datetime.now(timezone.utc)
    )
    pontuacao:     float        = 0.0
    notificada:    bool         = False

    @property
    def id(self) -> str:
        """ID único baseado no conteúdo — detecta duplicatas."""
        conteudo = f"{self.titulo}{self.empresa}{self.url}"
        return hashlib.md5(conteudo.encode()).hexdigest()[:12]

    @property
    def salario_display(self) -> str:
        if self.salario_min and self.salario_max:
            return f"US$ {self.salario_min:,.0f} – {self.salario_max:,.0f}/ano"
        if self.salario_min:
            return f"A partir de US$ {self.salario_min:,.0f}/ano"
        return "Salário não informado"

    def __str__(self):
        return (
            f"[{self.fonte}] {self.titulo} @ {self.empresa}\n"
            f"  {self.localidade} | {self.salario_display}\n"
            f"  {self.url}"
        )

Coletores

# src/jobbot/coletores/base.py
from abc import ABC, abstractmethod
from typing import Iterator
from jobbot.modelos import Vaga
import logging
import time

logger = logging.getLogger("jobbot.coletor")


class ColetorBase(ABC):
    """Classe base para todos os coletores."""

    nome: str = "base"
    intervalo_requisicoes: float = 2.0   # segundos entre requisições

    def __init__(self):
        self._session = None

    @abstractmethod
    def coletar(self, termos: list[str]) -> Iterator[Vaga]:
        """Coleta vagas para os termos fornecidos."""
        ...

    def _aguardar(self):
        time.sleep(self.intervalo_requisicoes)

    def _criar_session(self):
        import requests
        from requests.adapters import HTTPAdapter
        from urllib3.util.retry import Retry

        session = requests.Session()
        session.headers.update({
            "User-Agent": (
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
            ),
            "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8"
        })

        retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[429, 500, 503])
        session.mount("https://", HTTPAdapter(max_retries=retry))
        return session
# src/jobbot/coletores/remoteok.py
from jobbot.coletores.base import ColetorBase
from jobbot.modelos import Vaga
from typing import Iterator
import logging

logger = logging.getLogger("jobbot.remoteok")


class ColetorRemoteOK(ColetorBase):
    """
    Coleta vagas da API pública do RemoteOK.
    Documentação: https://remoteok.com/api
    """

    nome    = "RemoteOK"
    API_URL = "https://remoteok.com/api"

    def coletar(self, termos: list[str]) -> Iterator[Vaga]:
        session = self._criar_session()

        # Falha de rede ou HTTP sobe para o agendador, que avisa no Telegram;
        # engolida aqui, o bot passaria a "encontrar 0 vagas" em silêncio
        logger.info("Coletando vagas do RemoteOK...")
        resposta = session.get(self.API_URL, timeout=15)
        resposta.raise_for_status()

        dados = resposta.json()
        # o primeiro item é o aviso legal (termos de uso), sem "id"
        vagas_raw = [d for d in dados if isinstance(d, dict) and "id" in d]

        logger.info(f"RemoteOK: {len(vagas_raw)} vagas brutas")

        for raw in vagas_raw:
            try:
                vaga = self._parsear(raw, termos)
                if vaga:
                    yield vaga
            except Exception as e:
                logger.debug(f"Erro ao parsear vaga: {e}")

    def _parsear(self, raw: dict, termos: list[str]) -> Vaga | None:
        titulo    = raw.get("position", "")
        empresa   = raw.get("company",  "")
        descricao = raw.get("description", "")
        url       = raw.get("url", "")
        tags      = raw.get("tags", []) or []

        # Filtra por termos relevantes
        texto_busca = f"{titulo} {descricao} {' '.join(tags)}".lower()
        termo_encontrado = any(t.lower() in texto_busca for t in termos)
        if not termo_encontrado:
            return None

        # Salário: a API traz salary_min e salary_max numéricos, em dólares
        # por ano, e 0 quando a vaga não informa
        salario_min = float(raw.get("salary_min") or 0) or None
        salario_max = float(raw.get("salary_max") or 0) or None

        # Tecnologias detectadas
        techs_detectadas = [
            t for t in [
                "Python", "FastAPI", "Django", "Flask",
                "Docker", "Kubernetes", "PostgreSQL", "Redis",
                "React", "TypeScript", "Go", "Rust"
            ]
            if t.lower() in texto_busca
        ]

        return Vaga(
            titulo=      titulo,
            empresa=     empresa,
            descricao=   descricao[:500],
            url=         url,
            fonte=       self.nome,
            salario_min= salario_min,
            salario_max= salario_max,
            tecnologias= techs_detectadas,
            remoto=      True,
            localidade=  "Remoto"
        )

Dois ajustes vieram de consultar a API de verdade. Ela não tem campo salary em texto, como a primeira versão supunha — tem salary_min e salary_max numéricos —, e a expressão regular nunca encontrava nada: todo salário saía vazio. Com os campos certos, 14 das 79 vagas coletadas num teste vieram com salário. O outro ajuste é deixar a falha de rede subir: antes, o try em volta de tudo registrava o erro e devolvia zero vagas, e o aviso de erro do agendador, que manda uma mensagem ao Telegram, nunca era disparado — medido com a rede derrubada de propósito. O primeiro item da resposta é o aviso de termos de uso: o RemoteOK pede que quem usa a API cite a fonte e aponte um link para a vaga no site deles, sob pena de suspender o acesso. O link "Ver vaga" das notificações cumpre a segunda parte, e o e-mail passou a mostrar a fonte.

# src/jobbot/coletores/linkedin.py
from jobbot.coletores.base import ColetorBase
from jobbot.modelos import Vaga
from typing import Iterator
from bs4 import BeautifulSoup
import logging

logger = logging.getLogger("jobbot.linkedin")


class ColetorLinkedIn(ColetorBase):
    """
    Coleta vagas públicas do LinkedIn sem autenticação.
    Usa a URL de busca pública de empregos.
    """

    nome    = "LinkedIn"
    BASE_URL = "https://www.linkedin.com/jobs/search"

    def coletar(self, termos: list[str]) -> Iterator[Vaga]:
        session = self._criar_session()

        for termo in termos:
            try:
                yield from self._coletar_termo(session, termo)
                self._aguardar()
            except Exception as e:
                logger.error(f"Erro LinkedIn para '{termo}': {e}")

    def _coletar_termo(self, session, termo: str) -> Iterator[Vaga]:
        params = {
            "keywords":    termo,
            "location":    "Brazil",
            "f_WT":        "2",      # remote
            "f_TPR":       "r86400", # últimas 24h
            "start":       "0"
        }

        logger.info(f"LinkedIn: buscando '{termo}'...")
        resposta = session.get(self.BASE_URL, params=params, timeout=15)

        if resposta.status_code != 200:
            logger.warning(f"LinkedIn retornou {resposta.status_code}")
            return

        soup  = BeautifulSoup(resposta.text, "lxml")
        cards = soup.select("div.base-card")

        logger.info(f"LinkedIn: {len(cards)} vagas para '{termo}'")

        for card in cards[:10]:   # limita para não sobrecarregar
            try:
                vaga = self._parsear_card(card)
                if vaga:
                    yield vaga
            except Exception as e:
                logger.debug(f"Erro ao parsear card: {e}")

    def _parsear_card(self, card) -> Vaga | None:
        titulo_el  = card.select_one("h3.base-search-card__title")
        empresa_el = card.select_one("h4.base-search-card__subtitle")
        local_el   = card.select_one("span.job-search-card__location")
        link_el    = card.select_one("a.base-card__full-link")

        if not all([titulo_el, empresa_el, link_el]):
            return None

        titulo  = titulo_el.get_text(strip=True)
        empresa = empresa_el.get_text(strip=True)
        local   = local_el.get_text(strip=True) if local_el else "N/I"
        url     = link_el.get("href", "").split("?")[0]

        if not url:
            return None

        return Vaga(
            titulo=    titulo,
            empresa=   empresa,
            descricao= f"{titulo} em {empresa}",
            url=       url,
            fonte=     self.nome,
            localidade= local,
            remoto=    "remoto" in local.lower() or "remote" in local.lower()
        )

Um aviso sobre esse coletor: o Contrato do Usuário do LinkedIn proíbe coleta automatizada do site, e a empresa bloqueia ativamente esse tipo de acesso — a estrutura de classes usada aqui também muda sem aviso. O código fica como exemplo de técnica de scraping com BeautifulSoup; para um bot que vai rodar de verdade, prefira fontes que oferecem API ou feed, como o próprio RemoteOK, e confira os termos de cada site antes de coletar. Outro detalhe: numa vaga remota do LinkedIn, a localidade costuma ser a cidade da empresa, e com remoto_apenas ligado, o filtro por texto descartaria a vaga.

Processadores

# src/jobbot/processadores/filtro.py
from jobbot.modelos import Vaga
from jobbot.config import get_config
import logging

logger = logging.getLogger("jobbot.filtro")
config = get_config()


def pontuar_vaga(vaga: Vaga) -> float:
    """Calcula pontuação de relevância de uma vaga."""
    pontos = 0.0

    # Tecnologias relevantes
    techs_match = sum(
        1 for t in config.tecnologias
        if t.lower() in vaga.titulo.lower() or
           t.lower() in vaga.descricao.lower() or
           t in vaga.tecnologias
    )
    pontos += techs_match * 10

    # Remoto
    if vaga.remoto:
        pontos += 20

    # Salário
    if vaga.salario_min and vaga.salario_min >= config.salario_minimo:
        pontos += 15
    elif vaga.salario_min and vaga.salario_min >= config.salario_minimo * 0.8:
        pontos += 8

    # Senioridade
    titulo_lower = vaga.titulo.lower()
    if any(s in titulo_lower for s in ["senior", "sênior", "sr."]):
        pontos += 10
    elif any(s in titulo_lower for s in ["pleno", "mid", "middle"]):
        pontos += 8
    elif any(s in titulo_lower for s in ["junior", "júnior", "jr."]):
        pontos += 5

    # Palavras negativas
    palavras_negativas = ["estágio", "estagiário", "intern", "unpaid"]
    if any(p in titulo_lower for p in palavras_negativas):
        pontos -= 30

    return max(0.0, pontos)


def filtrar_vagas(vagas: list[Vaga]) -> list[Vaga]:
    """Filtra e pontua lista de vagas."""
    resultado = []

    for vaga in vagas:
        # Filtro obrigatório: remoto
        if config.remoto_apenas and not vaga.remoto:
            continue

        # Filtro obrigatório: palavras-chave
        texto = f"{vaga.titulo} {vaga.descricao}".lower()
        tem_keyword = any(
            kw.lower() in texto for kw in config.palavras_chave
        )
        if not tem_keyword:
            continue

        # Pontua
        vaga.pontuacao = pontuar_vaga(vaga)
        if vaga.pontuacao > 0:
            resultado.append(vaga)

    # Ordena por pontuação
    resultado.sort(key=lambda v: v.pontuacao, reverse=True)
    logger.info(f"Filtro: {len(vagas)} → {len(resultado)} vagas relevantes")
    return resultado
# src/jobbot/processadores/deduplicador.py
from jobbot.modelos import Vaga
from jobbot.armazenamento.repositorio import RepositorioVagas
import logging

logger = logging.getLogger("jobbot.deduplicador")


def deduplicar(vagas: list[Vaga], repo: RepositorioVagas) -> list[Vaga]:
    """Remove vagas já vistas no banco de dados."""
    novas = []
    for vaga in vagas:
        if not repo.existe(vaga.id):
            novas.append(vaga)

    logger.info(f"Deduplicação: {len(vagas)} → {len(novas)} vagas novas")
    return novas

Armazenamento

# src/jobbot/armazenamento/repositorio.py
import sqlite3
from pathlib import Path
from datetime import datetime
from jobbot.modelos import Vaga
import json
import logging

logger = logging.getLogger("jobbot.repo")


class RepositorioVagas:

    def __init__(self, db_path: str = "data/vagas.db"):
        Path(db_path).parent.mkdir(parents=True, exist_ok=True)
        self.db_path = db_path
        self._criar_tabelas()

    def _conn(self) -> sqlite3.Connection:
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        return conn

    def _criar_tabelas(self):
        with self._conn() as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS vagas (
                    id            TEXT PRIMARY KEY,
                    titulo        TEXT NOT NULL,
                    empresa       TEXT NOT NULL,
                    descricao     TEXT,
                    url           TEXT UNIQUE,
                    fonte         TEXT,
                    localidade    TEXT,
                    salario_min   REAL,
                    salario_max   REAL,
                    tecnologias   TEXT,
                    remoto        INTEGER,
                    pontuacao     REAL DEFAULT 0,
                    notificada    INTEGER DEFAULT 0,
                    coletado_em   TEXT
                )
            """)
            conn.execute("""
                CREATE TABLE IF NOT EXISTS execucoes (
                    id          INTEGER PRIMARY KEY AUTOINCREMENT,
                    inicio      TEXT,
                    fim         TEXT,
                    n_coletadas INTEGER,
                    n_novas     INTEGER,
                    n_notificadas INTEGER
                )
            """)

    def salvar(self, vaga: Vaga) -> bool:
        try:
            with self._conn() as conn:
                conn.execute("""
                    INSERT OR IGNORE INTO vagas
                    (id, titulo, empresa, descricao, url, fonte,
                     localidade, salario_min, salario_max, tecnologias,
                     remoto, pontuacao, notificada, coletado_em)
                    VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
                """, (
                    vaga.id, vaga.titulo, vaga.empresa,
                    vaga.descricao, vaga.url, vaga.fonte,
                    vaga.localidade, vaga.salario_min, vaga.salario_max,
                    json.dumps(vaga.tecnologias),
                    int(vaga.remoto), vaga.pontuacao,
                    int(vaga.notificada),
                    vaga.coletado_em.isoformat()
                ))
            return True
        except Exception as e:
            logger.error(f"Erro ao salvar vaga {vaga.id}: {e}")
            return False

    def existe(self, vaga_id: str) -> bool:
        with self._conn() as conn:
            row = conn.execute(
                "SELECT 1 FROM vagas WHERE id = ?", (vaga_id,)
            ).fetchone()
        return row is not None

    def marcar_notificada(self, vaga_id: str):
        with self._conn() as conn:
            conn.execute(
                "UPDATE vagas SET notificada = 1 WHERE id = ?",
                (vaga_id,)
            )

    def nao_notificadas(self, limite: int = 5) -> list[dict]:
        with self._conn() as conn:
            rows = conn.execute("""
                SELECT * FROM vagas
                WHERE notificada = 0
                ORDER BY pontuacao DESC
                LIMIT ?
            """, (limite,)).fetchall()
        return [dict(r) for r in rows]

    def estatisticas(self) -> dict:
        with self._conn() as conn:
            total      = conn.execute("SELECT COUNT(*) FROM vagas").fetchone()[0]
            notificadas = conn.execute(
                "SELECT COUNT(*) FROM vagas WHERE notificada = 1"
            ).fetchone()[0]
            por_fonte  = conn.execute(
                "SELECT fonte, COUNT(*) as n FROM vagas GROUP BY fonte"
            ).fetchall()
        return {
            "total":       total,
            "notificadas": notificadas,
            "pendentes":   total - notificadas,
            "por_fonte":   {r["fonte"]: r["n"] for r in por_fonte}
        }

    def registrar_execucao(self, inicio, fim, coletadas, novas, notificadas):
        with self._conn() as conn:
            conn.execute("""
                INSERT INTO execucoes (inicio, fim, n_coletadas, n_novas, n_notificadas)
                VALUES (?,?,?,?,?)
            """, (inicio.isoformat(), fim.isoformat(), coletadas, novas, notificadas))

Notificadores

# src/jobbot/notificadores/telegram.py
import html
import httpx
import asyncio
from jobbot.modelos import Vaga
from jobbot.config import get_config
import logging

logger = logging.getLogger("jobbot.telegram")
config = get_config()


class NotificadorTelegram:

    BASE_URL = "https://api.telegram.org/bot{token}"

    def __init__(self):
        self._url = f"https://api.telegram.org/bot{config.telegram_token}"

    async def enviar_mensagem(self, texto: str, parse_mode: str = "HTML") -> bool:
        async with httpx.AsyncClient(timeout=10.0) as client:
            try:
                resp = await client.post(
                    f"{self._url}/sendMessage",
                    json={
                        "chat_id":    config.telegram_chat_id,
                        "text":       texto,
                        "parse_mode": parse_mode,
                        "disable_web_page_preview": False
                    }
                )
                return resp.status_code == 200
            except Exception as e:
                logger.error(f"Erro Telegram: {e}")
                return False

    def formatar_vaga(self, vaga: Vaga) -> str:
        # parse_mode HTML: <, > e & no texto precisam ser escapados, senão o
        # Telegram recusa a mensagem inteira ("can't parse entities")
        e = html.escape
        techs = ", ".join(vaga.tecnologias[:5]) if vaga.tecnologias else "N/I"
        return (
            f"🎯 <b>{e(vaga.titulo)}</b>\n"
            f"🏢 {e(vaga.empresa)}\n"
            f"📍 {e(vaga.localidade)}\n"
            f"💰 {vaga.salario_display}\n"
            f"🛠️ {e(techs)}\n"
            f"⭐ Score: {vaga.pontuacao:.0f}\n"
            f"🔗 <a href=\"{e(vaga.url)}\">Ver vaga</a>"
        )

    async def notificar_vaga(self, vaga: Vaga) -> bool:
        texto = self.formatar_vaga(vaga)
        return await self.enviar_mensagem(texto)

    async def notificar_resumo(self, stats: dict) -> bool:
        texto = (
            f"📊 <b>Resumo Diário — JobBot</b>\n\n"
            f"Total coletadas: {stats['total']}\n"
            f"Notificadas hoje: {stats['notificadas']}\n"
            f"Pendentes: {stats['pendentes']}\n\n"
            f"<b>Por fonte:</b>\n"
        )
        for fonte, n in stats.get("por_fonte", {}).items():
            texto += f"  • {fonte}: {n}\n"
        return await self.enviar_mensagem(texto)

    async def notificar_erro(self, mensagem: str) -> bool:
        texto = f"⚠️ <b>Erro no JobBot</b>\n\n{mensagem}"
        return await self.enviar_mensagem(texto)
# src/jobbot/notificadores/email.py
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from jinja2 import Template
from jobbot.modelos import Vaga
from jobbot.config import get_config
import logging

logger = logging.getLogger("jobbot.email")
config = get_config()

TEMPLATE_EMAIL = """
<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8">
  <style>
    body   { font-family: Arial, sans-serif; background: #f4f4f4; }
    .card  { background: white; border-radius: 8px; padding: 20px;
             margin: 10px 0; border-left: 4px solid #2E75B6; }
    .titulo { font-size: 1.1rem; font-weight: bold; color: #1F4E79; }
    .info   { color: #666; font-size: 0.9rem; margin: 4px 0; }
    .badge  { display: inline-block; background: #E8F4FD; color: #2E75B6;
              padding: 2px 8px; border-radius: 12px; font-size: 0.8rem;
              margin: 2px; }
    .btn    { display: inline-block; background: #2E75B6; color: white;
              padding: 8px 16px; border-radius: 4px; text-decoration: none; }
  </style>
</head>
<body>
  <h2>🎯 {{ n_vagas }} nova(s) vaga(s) encontrada(s)</h2>

  {% for vaga in vagas %}
  <div class="card">
    <div class="titulo">{{ vaga.titulo }}</div>
    <div class="info">🏢 {{ vaga.empresa }}</div>
    <div class="info">📍 {{ vaga.localidade }}</div>
    <div class="info">💰 {{ vaga.salario_display }}</div>
    <div class="info">⭐ Score: {{ "%.0f"|format(vaga.pontuacao) }}</div>
    <div class="info">Fonte: {{ vaga.fonte }}</div>
    <div style="margin: 8px 0">
      {% for tech in vaga.tecnologias[:6] %}
        <span class="badge">{{ tech }}</span>
      {% endfor %}
    </div>
    <a href="{{ vaga.url }}" class="btn">Ver Vaga →</a>
  </div>
  {% endfor %}

  <p style="color:#888;font-size:0.8rem">
    JobBot — enviado automaticamente em {{ agora }}
  </p>
</body>
</html>
"""


class NotificadorEmail:

    def enviar_vagas(self, vagas: list[Vaga]) -> bool:
        from datetime import datetime

        # autoescape: título e empresa vêm de sites de terceiros
        template = Template(TEMPLATE_EMAIL, autoescape=True)
        html = template.render(
            vagas=vagas,
            n_vagas=len(vagas),
            agora=datetime.now().strftime("%d/%m/%Y %H:%M")
        )

        msg = MIMEMultipart("alternative")
        msg["Subject"] = f"🎯 {len(vagas)} nova(s) vaga(s) Python — JobBot"
        msg["From"]    = config.email_usuario
        msg["To"]      = config.email_destino
        msg.attach(MIMEText(html, "html", "utf-8"))

        try:
            with smtplib.SMTP(config.smtp_host, config.smtp_porta) as smtp:
                smtp.ehlo()
                smtp.starttls()
                smtp.login(config.email_usuario, config.email_senha)
                smtp.sendmail(
                    config.email_usuario,
                    config.email_destino,
                    msg.as_string()
                )
            logger.info(f"E-mail enviado: {len(vagas)} vagas")
            return True
        except Exception as e:
            logger.error(f"Erro ao enviar e-mail: {e}")
            return False

Título, empresa e localidade vêm de sites de terceiros, e os dois notificadores precisam tratá-los como texto. No Telegram, com parse_mode="HTML", um título como "Dev C++ & Python <Sr>" sem escape faz a API recusar a mensagem inteira, e a vaga nunca é marcada como notificada. No e-mail, o Template do Jinja não escapa nada por padrão: medido, um título com <b> entrava como HTML de verdade no corpo da mensagem. Com html.escape no Telegram e autoescape=True no Jinja, o mesmo título sai como texto nos dois canais.

Agendador: Orquestrando Tudo

# src/jobbot/agendador.py
import asyncio
import schedule
import time
import json
import logging
from datetime import datetime, timezone
from jobbot.config import get_config
from jobbot.coletores.remoteok import ColetorRemoteOK
from jobbot.coletores.linkedin import ColetorLinkedIn
from jobbot.processadores.filtro import filtrar_vagas
from jobbot.processadores.deduplicador import deduplicar
from jobbot.armazenamento.repositorio import RepositorioVagas
from jobbot.notificadores.telegram import NotificadorTelegram
from jobbot.notificadores.email import NotificadorEmail

logger = logging.getLogger("jobbot.agendador")
config = get_config()


class JobBot:
    """Orquestrador principal do bot de vagas."""

    def __init__(self):
        self.repo         = RepositorioVagas(config.db_path)
        self.coletores    = [ColetorRemoteOK(), ColetorLinkedIn()]
        self.telegram     = NotificadorTelegram()
        self.email        = NotificadorEmail()

    async def executar_ciclo(self):
        """Um ciclo completo: coletar → filtrar → deduplicar → notificar."""
        inicio = datetime.now(timezone.utc)
        logger.info(f"=== Ciclo iniciado: {inicio.strftime('%H:%M')} ===")

        # 1. Coleta
        todas_vagas = []
        for coletor in self.coletores:
            try:
                vagas = list(coletor.coletar(config.palavras_chave))
                todas_vagas.extend(vagas)
                logger.info(f"{coletor.nome}: {len(vagas)} vagas coletadas")
            except Exception as e:
                logger.error(f"Erro em {coletor.nome}: {e}")
                await self.telegram.notificar_erro(
                    f"Falha no coletor {coletor.nome}: {e}"
                )

        # 2. Filtro
        vagas_filtradas = filtrar_vagas(todas_vagas)

        # 3. Deduplicação
        vagas_novas = deduplicar(vagas_filtradas, self.repo)

        # 4. Persistência
        for vaga in vagas_novas:
            self.repo.salvar(vaga)

        # 5. Notificação — top vagas não notificadas
        nao_notificadas = self.repo.nao_notificadas(
            limite=config.max_vagas_notificadas
        )

        notificadas = 0
        if nao_notificadas:
            from jobbot.modelos import Vaga

            vagas_obj = [
                Vaga(
                    titulo=    r["titulo"],
                    empresa=   r["empresa"],
                    descricao= r["descricao"] or "",
                    url=       r["url"],
                    fonte=     r["fonte"],
                    localidade= r["localidade"],
                    salario_min= r["salario_min"],
                    salario_max= r["salario_max"],
                    pontuacao=  r["pontuacao"],
                    # o banco guarda tudo; sem estas linhas, a mensagem saía
                    # sem tecnologias e toda vaga era tratada como remota
                    tecnologias= json.loads(r["tecnologias"] or "[]"),
                    remoto=      bool(r["remoto"]),
                )
                for r in nao_notificadas
            ]

            # Telegram — uma por uma
            for vaga in vagas_obj:
                sucesso = await self.telegram.notificar_vaga(vaga)
                if sucesso:
                    self.repo.marcar_notificada(
                        next(r["id"] for r in nao_notificadas
                             if r["url"] == vaga.url)
                    )
                    notificadas += 1
                await asyncio.sleep(1)   # evita flood

            # E-mail — lote
            self.email.enviar_vagas(vagas_obj)

        # 6. Registro da execução
        fim = datetime.now(timezone.utc)
        duracao = (fim - inicio).seconds
        self.repo.registrar_execucao(
            inicio, fim,
            len(todas_vagas), len(vagas_novas), notificadas
        )

        logger.info(
            f"Ciclo concluído em {duracao}s — "
            f"coletadas={len(todas_vagas)} "
            f"novas={len(vagas_novas)} "
            f"notificadas={notificadas}"
        )

    async def resumo_diario(self):
        """Envia resumo das estatísticas do dia."""
        stats = self.repo.estatisticas()
        await self.telegram.notificar_resumo(stats)

    def iniciar(self):
        """Inicia o bot com agendamento."""
        logger.info("JobBot iniciado.")

        # Agendamento
        schedule.every(config.intervalo_minutos).minutes.do(
            lambda: asyncio.run(self.executar_ciclo())
        )
        schedule.every().day.at(config.horario_resumo).do(
            lambda: asyncio.run(self.resumo_diario())
        )

        # Executa imediatamente na primeira vez
        asyncio.run(self.executar_ciclo())

        while True:
            schedule.run_pending()
            time.sleep(30)


if __name__ == "__main__":
    import logging
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
    )
    bot = JobBot()
    bot.iniciar()

Na primeira versão, as vagas relidas do banco para notificação eram reconstruídas sem tecnologias e sem remoto, embora os dois estivessem gravados. Medido: a mensagem do Telegram saía com "N/I" no lugar das tecnologias de toda vaga. O json.loads desfaz o json.dumps do repositório.

Testes

# tests/conftest.py
import os

# O config exige credenciais; nos testes, valores falsos bastam.
# Precisa rodar antes de qualquer import de jobbot.
for var, valor in {
    "TELEGRAM_TOKEN": "teste", "TELEGRAM_CHAT_ID": "0",
    "EMAIL_USUARIO": "bot@exemplo.com", "EMAIL_SENHA": "teste",
    "EMAIL_DESTINO": "voce@exemplo.com",
}.items():
    os.environ.setdefault(var, valor)


# tests/test_filtro.py
import pytest
from jobbot.modelos import Vaga
from jobbot.processadores.filtro import pontuar_vaga, filtrar_vagas


@pytest.fixture
def vaga_python_remoto():
    return Vaga(
        titulo="Python Developer Senior",
        empresa="TechCorp",
        descricao="Vaga para desenvolvedor Python com FastAPI",
        url="https://exemplo.com/vaga/1",
        fonte="RemoteOK",
        remoto=True,
        salario_min=80000,
        tecnologias=["Python", "FastAPI", "Docker"]
    )


@pytest.fixture
def vaga_java_presencial():
    return Vaga(
        titulo="Java Developer",
        empresa="OldCorp",
        descricao="Vaga para desenvolvedor Java",
        url="https://exemplo.com/vaga/2",
        fonte="LinkedIn",
        remoto=False,
        tecnologias=["Java", "Spring"]
    )


class TestPontuacao:

    def test_vaga_python_tem_pontuacao_positiva(self, vaga_python_remoto):
        pontos = pontuar_vaga(vaga_python_remoto)
        assert pontos > 0

    def test_remoto_adiciona_pontos(self, vaga_python_remoto):
        vaga_python_remoto.remoto = False
        pontos_sem_remoto = pontuar_vaga(vaga_python_remoto)
        vaga_python_remoto.remoto = True
        pontos_com_remoto = pontuar_vaga(vaga_python_remoto)
        assert pontos_com_remoto > pontos_sem_remoto

    def test_estagio_penalizado(self):
        vaga = Vaga(
            titulo="Estágio Python",
            empresa="Empresa",
            descricao="Vaga de estágio",
            url="https://ex.com/3",
            fonte="test",
            tecnologias=["Python"]
        )
        pontos = pontuar_vaga(vaga)
        assert pontos == 0.0


class TestFiltro:

    def test_filtra_presencial_quando_remoto_obrigatorio(
        self, vaga_java_presencial
    ):
        resultado = filtrar_vagas([vaga_java_presencial])
        assert len(resultado) == 0

    def test_mantem_vaga_relevante(self, vaga_python_remoto):
        resultado = filtrar_vagas([vaga_python_remoto])
        assert len(resultado) == 1

    def test_ordena_por_pontuacao(self):
        vagas = [
            Vaga("Junior Python Dev", "A", "Python", "https://a.com/1",
                 "t", remoto=True, tecnologias=["Python"]),
            Vaga("Senior Python FastAPI Docker", "B", "Python FastAPI Docker",
                 "https://b.com/2", "t", remoto=True,
                 tecnologias=["Python", "FastAPI", "Docker"],
                 salario_min=90000),
        ]
        resultado = filtrar_vagas(vagas)
        assert len(resultado) == 2
        assert resultado[0].titulo.startswith("Senior")



# tests/test_deduplicador.py
import pytest
from unittest.mock import MagicMock
from jobbot.processadores.deduplicador import deduplicar
from jobbot.modelos import Vaga


def test_remove_vagas_ja_vistas():
    vaga1 = Vaga("Vaga 1", "Empresa", "desc", "https://ex.com/1", "t")
    vaga2 = Vaga("Vaga 2", "Empresa", "desc", "https://ex.com/2", "t")

    repo = MagicMock()
    repo.existe.side_effect = lambda id: id == vaga1.id

    resultado = deduplicar([vaga1, vaga2], repo)
    assert len(resultado) == 1
    assert resultado[0].url == vaga2.url


def test_retorna_tudo_quando_nada_visto():
    vagas = [
        Vaga("V1", "E", "d", "https://ex.com/1", "t"),
        Vaga("V2", "E", "d", "https://ex.com/2", "t"),
    ]
    repo = MagicMock()
    repo.existe.return_value = False

    resultado = deduplicar(vagas, repo)
    assert len(resultado) == 2

O conftest.py entrou porque, sem ele, nenhum teste rodava: o módulo de filtro importa o config, que exige o token do Telegram e as credenciais de e-mail, e o pytest parava na coleta com cinco erros de validação do Pydantic. Valores falsos no ambiente bastam para testar a lógica. O teste de ordenação também mudou: a primeira versão só conferia a ordem if len(resultado) >= 2, e passaria sem verificar nada se o filtro descartasse uma das vagas.

Docker

# Dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \
    PYTHONPATH=/app/src
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
# O .env NÃO entra na imagem: as credenciais chegam pelo compose
RUN adduser --disabled-password --gecos "" appuser \
    && mkdir -p /app/data /app/logs \
    && chown appuser /app/data /app/logs
USER appuser
CMD ["python", "-m", "jobbot.agendador"]
# docker-compose.yml
services:
  jobbot:
    build: .
    container_name: job-bot
    volumes:
      - ./data:/app/data
      - ./logs:/app/logs
    environment:
      - TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
      - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
      - EMAIL_USUARIO=${EMAIL_USUARIO}
      - EMAIL_SENHA=${EMAIL_SENHA}
      - EMAIL_DESTINO=${EMAIL_DESTINO}
    restart: unless-stopped

A primeira versão do Dockerfile fazia COPY .env ., e isso grava o token do Telegram e a senha do e-mail dentro da imagem, em texto, visíveis para quem tiver acesso a ela ou ao registro onde for publicada. As credenciais já chegam pelo environment do compose, então a linha saiu. O PYTHONPATH=/app/src faz o python -m jobbot.agendador encontrar o pacote, e as pastas de dados e logs passam a pertencer ao usuário que roda o bot.

O bot junta num ciclo só as peças de automação da série: coletores que buscam vagas numa API e numa página, um filtro que pontua cada uma pelos critérios do usuário, um repositório SQLite que lembra o que já foi visto, notificadores para Telegram e e-mail e um agendador que repete tudo a cada hora. É o mesmo desenho em camadas dos projetos anteriores, como Projeto 2: Pipeline de Data Science do Zero ao Deploy, aplicado a um processo que roda sozinho.

Um bot que roda sozinho tem uma exigência a mais: quando algo falha, ninguém está olhando. Por isso os defeitos desta página pesam mais do que pareceriam num script. Um coletor que engole a falha de rede faz o bot "não encontrar vagas" por dias; um salário comparado na unidade errada deixa passar tudo; um título com & faz o Telegram recusar a mensagem e a vaga voltar a cada ciclo; um .env copiado para a imagem espalha a senha junto com ela. A regra que resume todos: falhas precisam chegar a alguém, dados de terceiros entram como texto, e credenciais nunca viajam dentro do artefato.

Fontes e leituras recomendadas

Exercícios

Exercício 1

O bot passou uma semana sem notificar nenhuma vaga, e ninguém percebeu: nenhuma mensagem de erro chegou ao Telegram. O log mostra, a cada hora, "Erro ao coletar RemoteOK: ..." seguido de "RemoteOK: 0 vagas coletadas". O agendador tem um try/except que avisa no Telegram quando um coletor falha. Por que o aviso nunca saiu?

Ver resposta

✓ Resposta: Porque a exceção nunca chegou ao agendador. Na primeira versão, o método coletar do RemoteOK tinha um try em volta de tudo, que registrava o erro no log e terminava o gerador normalmente. Para o agendador, a coleta tinha dado certo, com zero vagas, e o except dele nunca era acionado. Medido com a rede derrubada de propósito: o coletor devolvia [], sem exceção, e nenhum aviso era enviado; depois da correção, a mesma falha gera "Falha no coletor RemoteOK: rede fora". A regra é tratar a exceção no nível que sabe o que fazer com ela. O coletor não sabe avisar ninguém, então deixa a falha subir; o erro de uma vaga individual malformada, esse sim, ele trata e segue adiante. E "zero resultados" merece atenção por si só: numa fonte que costuma trazer dezenas de vagas por hora, zero por um dia inteiro já é sinal de problema.

Exercício 2

O usuário configurou salario_minimo = 5000, pensando em reais por mês, e reclama que o bot só manda vagas "sem salário informado". Consultada, a API do RemoteOK devolve, numa vaga, "salary_min": 70000, "salary_max": 80000. O coletor da primeira versão lia raw.get("salary", "") e procurava números no texto. Quantos problemas há aqui?

Ver resposta

✓ Resposta: Dois, empilhados. O primeiro é o campo: a API não tem salary em texto, tem salary_min e salary_max numéricos, com 0 quando a vaga não informa. A leitura devolvia sempre texto vazio, e todo salário saía None — por isso "sem salário informado" em tudo. O segundo é a unidade: os valores são dólares por ano. Se o campo estivesse certo, a comparação com 5000 aprovaria qualquer vaga com salário, porque 70 000 é maior que 5000. O bot precisava das duas correções para o critério funcionar: ler os campos numéricos e expressar o mínimo na mesma unidade, US$ 60 mil por ano, em vez de misturar moedas e períodos. A lição geral é consultar a resposta real da API antes de escrever o parser, e não a documentação de memória. Numa consulta de setembro de 2026, 16 das 99 vagas traziam salário.

Exercício 3

Uma vaga com o título "Dev C++ & Python <Sênior>" é coletada, filtrada e salva, mas nunca chega ao Telegram. Pior: a cada hora o bot tenta de novo. O notificador envia com parse_mode="HTML" e monta o texto com f"🎯 <b>{vaga.titulo}</b>". Explique o ciclo e a correção.

Ver resposta

✓ Resposta: No modo HTML, o Telegram interpreta a mensagem como marcação e só aceita um conjunto pequeno de tags. O "&" solto e o "<Sênior>", que parece uma tag desconhecida, fazem a API recusar a mensagem inteira com erro 400, informando que não conseguiu interpretar as entidades. O enviar_mensagem devolve False, a vaga não é marcada como notificada, e no ciclo seguinte ela volta ao topo da lista de pendentes e falha de novo, para sempre. A correção é escapar tudo o que vem de fora: html.escape(vaga.titulo) transforma o título em "Dev C++ &amp; Python &lt;Sênior&gt;", que o Telegram mostra como o texto original. O mesmo vale para empresa, localidade e a URL do link. O e-mail tinha o problema equivalente: o Template do Jinja não escapa por padrão, e medido, um <b> no título virava negrito de verdade. O autoescape=True resolve.

Exercício 4

Na mensagem do Telegram, toda vaga aparece com "🛠️ N/I" no lugar das tecnologias, embora o coletor detecte "Python", "Docker" e outras, e o repositório as grave numa coluna tecnologias. Onde elas se perdem?

Ver resposta

✓ Resposta: Na volta do banco. O agendador não notifica os objetos que acabou de coletar: ele consulta as vagas ainda não notificadas no SQLite e reconstrói um Vaga para cada linha. Na primeira versão, essa reconstrução passava título, empresa, URL, salário e pontuação, mas não tecnologias nem remoto. Os dois ficavam com o valor padrão: a lista vazia, que o formatador mostra como "N/I", e True. Medido: uma vaga salva com ["Python", "FastAPI"] chegava ao notificador com []. A correção lê as duas colunas: json.loads(r["tecnologias"]), que desfaz o json.dumps da gravação, e bool(r["remoto"]), que desfaz o int. Esse tipo de perda aparece sempre que um objeto é convertido para outro formato e de volta à mão. Uma função Vaga.de_linha(row), ao lado do código que grava, mantém as duas direções juntas e facilita um teste de ida e volta.

Exercício 5

Para facilitar o deploy, o Dockerfile da primeira versão tinha COPY .env .. A imagem foi publicada num registro para ser baixada no servidor. Que risco isso cria, se o compose já passa as mesmas variáveis em environment?

Ver resposta

✓ Resposta: O .env vira uma camada da imagem, com o token do bot do Telegram e a senha do e-mail em texto. Qualquer pessoa que consiga baixar a imagem pode extraí-lo, e apagá-lo numa instrução posterior do Dockerfile não resolve, porque a camada anterior continua existindo. Com o token, alguém controla o bot; com a senha, envia e-mail em nome da conta. E a cópia não servia para nada, já que o compose entrega as mesmas variáveis no environment, e elas têm precedência sobre o arquivo. A correção é tirar a linha e incluir .env no .dockerignore, para que nem um COPY . . futuro o leve. Se uma imagem com segredos já foi publicada, as credenciais devem ser trocadas: o token é revogado no BotFather, e a senha de aplicativo, no provedor de e-mail. Remover a imagem do registro não desfaz o que já foi baixado.

Comentários

Mais em Python

Autenticação e Segurança em APIs Python
Autenticação e Segurança em APIs Python

Senhas com bcrypt, API Keys, JWT e OAuth2 no FastAPI, e as brechas que o…

Consumindo APIs REST com requests e httpx
Consumindo APIs REST com requests e httpx

Consumindo APIs REST em Python com requests e httpx, do GET simples ao cliente…

Celery: Filas de Tarefas e Processamento Assíncrono
Celery: Filas de Tarefas e Processamento Assíncrono

Tarefas pesadas fora da requisição HTTP com Celery e Redis: filas separadas…