Pular para conteúdo

RSS Feed Integration — SafeStreets

Especificação técnica da integração com RSS Portal de Notícia Correio Braziliense


Propósito

Este documento descreve como o SafeStreets coleta, parseia e transforma notícias do feed RSS (Portal de Notícia Correio Braziliense) em Ocorrências estruturadas para visualização.


Feed RSS: Portal de Notícia Correio Braziliense

URL do Feed

https://www.correiobraziliense.com.br/rss/

Estrutura XML Esperada

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Correio Braziliense</title>
    <link>https://www.correiobraziliense.com.br</link>
    <description>Notícias do Correio Braziliense</description>

    <item>
      <title>Roubo em Taguatinga deixa dois feridos</title>
      <link>https://www.correiobraziliense.com.br/.../roubo-taguatinga-12345</link>
      <description>
        Dois homens foram assaltados na QNL 02 de Taguatinga na noite de ontem...
      </description>
      <pubDate>Fri, 06 Jun 2026 14:30:00 GMT</pubDate>
      <guid>https://www.correiobraziliense.com.br/.../roubo-taguatinga-12345</guid>
      <category>Segurança</category>
    </item>

    <!-- ... mais items ... -->
  </channel>
</rss>

Pipeline de Processamento

1. Coleta (Fetch RSS)

Frequência: A cada 1 hora (agendado) + sob demanda

Implementação:

# backend/app/services/rss_service.py

import feedparser
from datetime import datetime, timedelta

class RSSService:
    def fetch_feed(self, url: str, timeout: int = 30) -> list[dict]:
        """
        Fetch RSS feed com retry e timeout.

        Args:
            url: URL do feed RSS
            timeout: Timeout em segundos

        Returns:
            Lista de items parseados (titulo, link, descricao, pubDate, guid)

        Raises:
            TimeoutError: Se timeout excedido
            FeedParsingError: Se XML malformado
        """
        # Implementar com httpx + retry exponencial + circuit breaker
        pass

2. Parsing XML + Extração de Dados

Campos Extraídos:

Campo RSS Mapeamento Tipo
title titulo_noticia string
description descricao_detalhada string (nullable)
pubDate data_ocorrencia datetime
link fonte_original string (URL)
guid id_externo_rss string (unique)
category tipo_crime_candidato string (opcional)

Exemplo:

item_raw = {
    "title": "Roubo em Taguatinga deixa dois feridos",
    "description": "Dois homens foram assaltados na QNL 02 de Taguatinga na noite de ontem. Sem vítimas graves.",
    "pubDate": "Fri, 06 Jun 2026 14:30:00 GMT",
    "link": "https://www.correiobraziliense.com.br/.../roubo-taguatinga",
    "guid": "roubo-taguatinga-12345"
}

# Após parsing:
item_parsed = {
    "titulo_noticia": "Roubo em Taguatinga deixa dois feridos",
    "descricao_detalhada": "Dois homens foram assaltados na QNL 02 de Taguatinga...",
    "data_ocorrencia": datetime(2026, 6, 6, 14, 30, 0),
    "fonte_original": "https://www.correiobraziliense.com.br/.../roubo-taguatinga",
    "id_externo_rss": "roubo-taguatinga-12345"
}

3. Extração de Localização (NLP/Regex)

Objetivo: Converter "Taguatinga" em (latitude, longitude, RA)

Estratégia: Dois passos

Passo 1: Identificar Regiões Administrativas (RA)

# Mapeamento de nomes de RA para códigos + coordenadas
RA_MAPPING = {
    "Taguatinga": {"codigo": "RA-026", "lat": -15.7975, "long": -48.0473},
    "Ceilândia": {"codigo": "RA-009", "lat": -15.7942, "long": -48.2000},
    "Plano Piloto": {"codigo": "RA-001", "lat": -15.7902, "long": -47.8821},
    # ... mais 30+ regiões ...
}

def extrair_regiao(texto: str) -> Optional[dict]:
    """
    Busca nome de RA no texto da notícia.

    Estratégia:
    1. Regex case-insensitive por nomes de RA
    2. Se não encontrado, retorna None
    3. Se múltiplas regiões encontradas, usa a primeira
    """
    for ra_nome, ra_info in RA_MAPPING.items():
        if ra_nome.lower() in texto.lower():
            return ra_info
    return None

Passo 2: Geocodificação (se necessário)

Se o texto contiver endereço mas não RA:

# Usar biblioteca geocodificadora (ex: geopy + Google Maps API)
# para converter "QNL 02, Taguatinga" → lat/long
# Fallback: usar centroide do DF se falhar

Exemplo de Fluxo:

"Roubo em Taguatinga deixa dois feridos"
    ↓
extrair_regiao(texto)
    ↓
RA_MAPPING["Taguatinga"]
    ↓
{
  "regiao_administrativa": "RA-026",
  "latitude": -15.7975,
  "longitude": -48.0473
}

4. Classificação de Tipo de Crime

Estratégia: Regex + keyword matching

CRIME_TYPES = {
    "roubo": ["roubo", "assalto", "furto"],
    "homicídio": ["homicídio", "morte", "assassinato"],
    "agressão": ["agressão", "briga", "violência"],
    "tráfico": ["tráfico", "droga", "entorpecente"],
    "acidente": ["acidente", "colisão", "capotamento"],
    # ... mais tipos ...
}

def classificar_crime(titulo: str, descricao: str) -> str:
    """Detecta tipo de crime no texto."""
    texto = f"{titulo} {descricao}".lower()
    for crime_type, keywords in CRIME_TYPES.items():
        if any(kw in texto for kw in keywords):
            return crime_type
    return "outro"  # fallback

5. Validação (Pydantic)

from pydantic import BaseModel, validator
from datetime import datetime

class NoticiaRSSSchema(BaseModel):
    titulo_noticia: str
    descricao_detalhada: Optional[str]
    data_ocorrencia: datetime
    fonte_original: str (URL válida)
    id_externo_rss: str (unique)
    tipo_crime: str (enum: roubo, homicídio, ...)
    regiao_administrativa: str (RA code: RA-001, RA-026, ...)
    latitude: float (range: -90 a 90)
    longitude: float (range: -180 a 180)

    @validator("data_ocorrencia")
    def data_nao_futura(cls, v):
        if v > datetime.now():
            raise ValueError("Data não pode ser no futuro")
        return v

    @validator("latitude", "longitude")
    def validar_coordenadas(cls, v):
        # Garantir precision de 6 decimais (~0.11m)
        return round(v, 6)

6. Transformação em ORM (SQLAlchemy)

from sqlalchemy import Column, String, Float, DateTime, Integer, ForeignKey

class Ocorrencia(Base):
    __tablename__ = "ocorrencias_criminais"

    id = Column(Integer, primary_key=True)
    id_externo_rss = Column(String, unique=True, index=True)
    titulo_noticia = Column(String, index=True)
    descricao_detalhada = Column(String, nullable=True)
    tipo_crime = Column(String, index=True)
    data_ocorrencia = Column(DateTime, index=True)
    fonte_original = Column(String)

    locais_pin_id = Column(Integer, ForeignKey("locais_pin.id"))
    latitude = Column(Float)
    longitude = Column(Float)
    regiao_administrativa = Column(String, index=True)

    resumo_gemini = Column(String, nullable=True)
    resumo_status = Column(String, default="PENDENTE")  # COMPLETO, PENDENTE, ERRO, FALLBACK

    criado_em = Column(DateTime, default=datetime.now)
    atualizado_em = Column(DateTime, default=datetime.now, onupdate=datetime.now)

7. Persistência Dupla

# PostgreSQL (principal)
db.session.add(ocorrencia_orm)
db.session.commit()

# Redis/Cache (opcional, se ADR-003 = Redis obrigatório)
cache.set(f"ocorrencia:{ocorrencia_orm.id}", ocorrencia_orm.to_dict(), ttl=86400)  # 24h

Tratamento de Erros

Cenário 1: Feed Indisponível

Timeout/Connection Error
    ↓
Retry com backoff: 1s → 2s → 4s → 8s
    ↓
Após 5 tentativas: Circuit breaker abre
    ↓
Retorna últimas Ocorrências cacheadas (stale)
    ↓
Log warning + alertar equipe operacional

Cenário 2: Item RSS Malformado

"title": null  ou pubDate em formato inválido
    ↓
Validação Pydantic falha
    ↓
Skip item + log debug
    ↓
Continuar processando próximos items

Cenário 3: Localização não Encontrada

Texto não contém nome de RA
    ↓
Geocodificação opcional: Google Maps (se configurada)
    ↓
Se falhar: usar centroide do DF (-15.8, -48.0)
    ↓
Marcador no mapa aparece no centro; card avisa "localização aproximada"

Agendamento: Scheduler (Celery/APScheduler)

from apscheduler.schedulers.background import BackgroundScheduler

scheduler = BackgroundScheduler()

@scheduler.scheduled_job('interval', hours=1)
def atualizar_feed_rss():
    """Executa a cada 1 hora."""
    try:
        rss_service.fetch_and_process()
    except Exception as e:
        logger.error(f"Erro ao processar RSS: {e}")

scheduler.start()

Deduplicação (id_externo_rss)

Para evitar notícias duplicadas:

# Verificar se item já existe
existing = db.query(Ocorrencia).filter_by(
    id_externo_rss=item_parsed["id_externo_rss"]
).first()

if existing:
    # Item já processado; skip
    logger.debug(f"Item {item_parsed['id_externo_rss']} já existe")
else:
    # Novo item; processar
    ocorrencia = Ocorrencia(**item_parsed)
    db.session.add(ocorrencia)

Performance & Otimizações

Aspecto Otimização
Parser XML Usar feedparser (otimizado) vs xml.etree (lento)
Extração RA Cache de RA_MAPPING em memória; não recarregar a cada item
Gemini Async Processar resumos em background job (não bloquear feed)
Índices DB (id_externo_rss, data_ocorrencia) compound index
TTL Cache 24h para evitar re-parsing de feed

Testes

Fixture: Sample RSS Feed

# tests/fixtures/sample_rss.xml
<?xml version="1.0"?>
<rss version="2.0">
  <channel>
    <item>
      <title>Roubo em Taguatinga</title>
      <description>Assalto na QNL 02...</description>
      <pubDate>Fri, 06 Jun 2026 14:30:00 GMT</pubDate>
      <link>https://correiobraziliense.com.br/roubo-taguatinga</link>
      <guid>roubo-taguatinga-12345</guid>
    </item>
  </channel>
</rss>

Casos de Teste

# tests/test_rss_service.py

def test_parse_feed_valido():
    """Parser extrai campos corretamente."""
    items = rss_service.fetch_feed(SAMPLE_RSS)
    assert len(items) > 0
    assert items[0]["titulo_noticia"] == "Roubo em Taguatinga"

def test_extracao_regiao():
    """Extração de RA funciona."""
    regiao = extracao.extrair_regiao("Roubo em Taguatinga")
    assert regiao["codigo"] == "RA-026"
    assert regiao["lat"] == -15.7975

def test_classificacao_crime():
    """Classificação de tipo de crime funciona."""
    tipo = classificacao.classificar_crime("Homicídio em Ceilândia", "...")
    assert tipo == "homicídio"

def test_deduplicacao():
    """Itens duplicados são skipados."""
    rss_service.process_item(item1)
    rss_service.process_item(item1)  # Mesmo item
    assert db.query(Ocorrencia).count() == 1

def test_resilience_timeout():
    """Feed timeout → retry → circuit breaker."""
    # Mock timeout; verificar retry
    # Mock 5 falhas; verificar circuit breaker abre

Referências