Geonode logo
Geonode Team

Geonode Team

Atualizado: 16 de outubro de 2026

Publicado: 16 de outubro de 2026

Detecção de alterações em sites com Python: monitorizar atualizações de documentação e políticas

A deteção de alterações em sites permite-lhe descobrir que um fornecedor reescreveu uma cláusula dos termos de serviço, deixou de suportar um ponto de acesso na sua documentação ou editou discretamente uma política de privacidade antes de alguém o anunciar. No final deste guia, terá um script em Python que captura uma lista de páginas em formato Markdown através do Geonode Scraper API, elimina o ruído, armazena todas as versões no SQLite, produz uma comparação legível e publica no Slack apenas quando o conteúdo realmente mudou. Funciona a partir do cron ou do GitHub Actions e resiste a recuperações instáveis. Isto não é, deliberadamente, um monitor de preços. Os preços são números num local conhecido; os documentos são textos em que a localização e a forma de uma alteração são desconhecidas antecipadamente. As escolhas de design abaixo decorrem disso.

A deteção de alterações em sites permite-lhe descobrir que um fornecedor reescreveu uma cláusula dos termos de serviço, deixou de suportar um ponto de acesso na sua documentação ou editou discretamente uma política de privacidade antes de alguém o anunciar. No final deste guia, terá um script em Python que captura uma lista de páginas em formato Markdown através do Geonode Scraper API, elimina o ruído, armazena todas as versões no SQLite, produz uma comparação legível e publica no Slack apenas quando o conteúdo realmente mudou. Funciona a partir do cron ou do GitHub Actions e resiste a recuperações instáveis.

Isto não é, deliberadamente, um monitor de preços. Os preços são números num local conhecido; os documentos são textos em que a localização e a forma de uma alteração são desconhecidas antecipadamente. As escolhas de design abaixo decorrem disso.

O que está a monitorizar e porquê o Markdown

As páginas que vale a pena acompanhar têm um perfil comum: são públicas, contêm muito texto, são atualizadas com pouca frequência e têm um impacto significativo quando são atualizadas. Páginas de referência de API, guias de autenticação, tabelas de limitação de taxa, termos e condições, acordos de tratamento de dados, políticas de utilização aceitável, registos de alterações e históricos de incidentes nas páginas de estado são todos exemplos que se enquadram nesta categoria.

Comparar o HTML bruto destas páginas é uma tarefa árdua. Os hash incorporados nos URLs dos recursos, os tokens CSRF, os nomes das classes de testes A/B e as análises inseridas mudam a cada carregamento. A conversão para Markdown descarta quase tudo isso e deixa apenas as frases. O endpoint /v1/extract devolve Markdown diretamente, pelo que o script nunca recorre a um analisador de HTML.

Instale a única dependência com pip install requests. Todo o resto faz parte da biblioteca padrão.

Capturar um instantâneo

O fetcher solicita Markdown, mantém a renderização em JavaScript desativada por predefinição e ativa-a página a página para sites de documentação que fazem a renderização do lado do cliente. O bloco wait_config só é necessário quando uma página preenche o seu conteúdo após o carregamento; ele ativa automaticamente a renderização quando está presente.

import os
import requests

API = "https://scraper.geonode.io"
HEADERS = {"X-Api-Key": os.environ["GEONODE_API_KEY"]}

def snapshot(session: requests.Session, url: str, render_js: bool = False,
             wait_for: str | None = None) -> tuple[int, str]:
    """Return (origin_http_status, markdown). Raises on API-level errors."""
    body = {"url": url, "formats": ["markdown"], "render_js": render_js,
            "processing_mode": "sync", "timeout": 60000}
    if wait_for:
        body["wait_config"] = {"wait_until": "domcontentloaded", "wait_for": wait_for,
                               "wait_timeout": 2000}
        body.pop("render_js")          # wait_config implies rendering; sending false is rejected
    r = session.post(f"{API}/v1/extract", json=body, headers=HEADERS, timeout=90)
    if r.status_code == 429:
        err = r.json().get("error", r.json())
        raise RuntimeError(f"rate limited ({err.get('code')}): {err.get('message')}")
    r.raise_for_status()
    payload = r.json()
    return payload["metadata"]["http_status"], (payload.get("data") or {}).get("markdown") or ""

O estado da origem é tão importante quanto o corpo do conteúdo. Uma página de manutenção 503 converte-se em Markdown perfeitamente válido; sem verificar o http_status, iria armazená-la como uma nova versão e emitir um alerta sobre a mesma.

Remoção de ruído

Dois mecanismos, aplicados por ordem. Uma passagem com expressões regulares remove linhas que se sabe que geram ruído: datas, banners de «última atualização», avisos sobre cookies, rodapés de direitos de autor, links «editar esta página». Em seguida, uma lista de permissões mantém apenas os títulos que lhe interessam, o que elimina a navegação, as barras laterais e os blocos de marketing que o conversor Markdown manteve por se tratarem de texto real.

import re

NOISE = [
    re.compile(r"(?im)^.*\b(last (updated|modified|reviewed)|effective date|updated on)\b.*$"),
    re.compile(r"(?im)^.*\b(cookie|cookies)\b.*\b(accept|consent|preferences)\b.*$"),
    re.compile(r"(?im)^.*(©|copyright|all rights reserved).*$"),
    re.compile(r"(?im)^.*\b(edit this page|was this page helpful|on this page)\b.*$"),
    re.compile(r"\b(?:\d{1,2}\s)?(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}\b", re.I),
    re.compile(r"\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?\S*)?\b"),
    re.compile(r"!\[[^\]]*\]\([^)]*\)"),                 # images
]

def strip_noise(md: str, keep_sections: list[str] | None = None) -> str:
    for rx in NOISE:
        md = rx.sub("", md)
    if keep_sections:
        kept, keep = [], False
        for line in md.splitlines():
            if line.startswith("#"):
                title = line.lstrip("#").strip().lower()
                keep = any(s.lower() in title for s in keep_sections)
            if keep:
                kept.append(line)
        md = "\n".join(kept)
    return normalize_ws(md)

def normalize_ws(text: str) -> str:
    lines = (re.sub(r"[ \t]+", " ", ln).strip() for ln in text.splitlines())
    return "\n".join(ln for ln in lines if ln)

keep_sections é uma lista de subcadeias de caracteres que correspondem aos títulos; assim, ["rate limit", "authentication"] mantém esses blocos H2/H3 e tudo o que se encontra abaixo deles até ao título seguinte. Deixe-o como None para páginas de políticas em que qualquer cláusula é importante.

As expressões regulares de data são deliberadamente agressivas. A linha «Em vigor a partir de 1 de março de 2026» numa página de política é exatamente o tipo de alteração que se pretende ver; por isso, se isso for importante para si, remova a primeira entrada NOISE e deixe que a comparação a mostre.

Hash versus comparação de diferenças

O hash responde à pergunta «alguma coisa mudou?» em tempo constante. A comparação de diferenças responde à pergunta «o que mudou?» de uma forma legível para uma pessoa numa mensagem do Slack. A maioria das ferramentas de deteção de alterações em sites limita-se ao hash; utilize ambas: o hash para filtrar e a comparação de diferenças para relatar.

import difflib
import hashlib

def digest(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

def unified(old: str, new: str, url: str, context: int = 2, max_lines: int = 120) -> str:
    diff = difflib.unified_diff(old.splitlines(), new.splitlines(),
                                fromfile=f"{url} (previous)", tofile=f"{url} (current)",
                                lineterm="", n=context)
    lines = list(diff)
    if len(lines) > max_lines:
        lines = lines[:max_lines] + [f"... {len(lines) - max_lines} more lines"]
    return "\n".join(lines)

O difflib.unified_diff encontra-se na biblioteca padrão e está documentado em docs.python.org. O argumento n define o número de linhas de contexto; duas são suficientes para texto narrativo. Limite a saída para que uma reescrita de página inteira não produza um alerta de 5 000 linhas.

Armazenamento de instantâneos

O SQLite mantém um histórico completo, não apenas a última versão. Isso permite comparar com os dois últimos instantâneos (próxima secção) e responder à pergunta «quando é que esta cláusula apareceu pela primeira vez» meses mais tarde.

import sqlite3
import time

SCHEMA = """
CREATE TABLE IF NOT EXISTS snapshots (
  id          INTEGER PRIMARY KEY,
  url         TEXT NOT NULL,
  taken_at    REAL NOT NULL,
  http_status INTEGER,
  hash        TEXT,
  content     TEXT
);
CREATE INDEX IF NOT EXISTS idx_url_time ON snapshots(url, taken_at DESC);
"""

def open_db(path: str = "changes.sqlite") -> sqlite3.Connection:
    db = sqlite3.connect(path)
    db.executescript(SCHEMA)
    return db

def last_snapshots(db: sqlite3.Connection, url: str, n: int = 2) -> list[tuple[str, str]]:
    """Most recent first: [(hash, content), ...] for successful (200) fetches only."""
    return db.execute("SELECT hash, content FROM snapshots WHERE url=? AND http_status=200 "
                      "ORDER BY taken_at DESC LIMIT ?", (url, n)).fetchall()

def save(db: sqlite3.Connection, url: str, status: int, h: str | None, content: str | None) -> None:
    db.execute("INSERT INTO snapshots (url, taken_at, http_status, hash, content) VALUES (?,?,?,?,?)",
               (url, time.time(), status, h, content))
    db.commit()

As páginas de políticas são pequenas, pelo que armazenar o conteúdo completo por execução é pouco dispendioso. Se monitorizar centenas de documentos longos de hora a hora, armazene o conteúdo apenas quando o hash for alterado e, caso contrário, mantenha apenas a linha do hash.

Lidar com recuperações instáveis

As recuperações instáveis são a principal fonte de falsos positivos na deteção de alterações em sites, pelo que existem três estratégias de defesa. Repita a recuperação com um período de espera antes de desistir. Considere qualquer código de estado que não seja 200 como «sem dados», registe o código de estado e não o compare. E compare o novo hash com os dois últimos instantâneos válidos, não apenas com o último: se a página alternou entre A, B, A, o B foi muito provavelmente uma variante transitória (uma página de erro que ainda devolveu um código de estado 200, um cache a servir uma versão desatualizada) e não merece um alerta.

def fetch_with_retry(session: requests.Session, url: str, attempts: int = 3, **kw) -> tuple[int, str]:
    delay = 5
    for i in range(attempts):
        try:
            status, md = snapshot(session, url, **kw)
            if status < 500 and md.strip():
                return status, md
        except (requests.RequestException, RuntimeError) as exc:
            if i == attempts - 1:
                raise
        time.sleep(delay)
        delay *= 3
    return status, md

def check(db: sqlite3.Connection, session: requests.Session, url: str,
          keep_sections: list[str] | None = None, **kw) -> str | None:
    """Returns a unified diff if a real change was detected, else None."""
    status, raw = fetch_with_retry(session, url, **kw)
    if status != 200:
        save(db, url, status, None, None)
        return None
    clean = strip_noise(raw, keep_sections)
    h = digest(clean)
    previous = last_snapshots(db, url, n=2)
    save(db, url, status, h, clean)
    if not previous:
        return None                                   # first run, nothing to compare
    if any(h == old_hash for old_hash, _ in previous):
        return None                                   # unchanged, or a revert of a blip
    return unified(previous[0][1], clean, url)

A regra do segundo instantâneo tem um custo: uma alteração genuína que seja revertida no decorrer de uma execução não é reportada. Para a documentação e a monitorização de políticas, essa é quase sempre a escolha certa.

Notificação

Os webhooks de entrada do Slack aceitam um corpo JSON com um campo text; coloque a comparação num bloco de código para que se mantenha com espaçamento fixo. O envio por e-mail através de smtplib tem mais algumas linhas e é útil nos casos em que o Slack não está disponível.

import json
import smtplib
from email.message import EmailMessage

SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL")

def notify_slack(url: str, diff: str) -> None:
    if not SLACK_WEBHOOK:
        return
    fence = "`" * 3                          # literal ``` would close the Markdown block
    text = f"*Change detected:* {url}\n{fence}\n{diff[:3500]}\n{fence}"
    requests.post(SLACK_WEBHOOK, data=json.dumps({"text": text}),
                  headers={"Content-Type": "application/json"}, timeout=15).raise_for_status()

def notify_email(url: str, diff: str, to: str) -> None:
    msg = EmailMessage()
    msg["Subject"] = f"Change detected: {url}"
    msg["From"] = os.environ["SMTP_FROM"]
    msg["To"] = to
    msg.set_content(diff)
    with smtplib.SMTP(os.environ["SMTP_HOST"], int(os.environ.get("SMTP_PORT", "587"))) as s:
        s.starttls()
        s.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
        s.send_message(msg)

O Slack trunca mensagens longas, daí o limite de 3 500 caracteres para o diff. No caso de diffs longos, publique a primeira página e anexe o texto completo noutro local, ou indique na mensagem um ID de instantâneo armazenado.

A lista de observação e o executor

A configuração está armazenada numa lista, pelo que pode mantê-la no repositório e rever as alterações nela tal como se fosse código. Cada entrada contém o URL, a indicação de se é necessária a renderização, um seletor opcional pelo qual se deve aguardar e uma lista de permissões opcional para a secção.

WATCHLIST = [
    {"url": "https://example.com/legal/terms"},
    {"url": "https://example.com/legal/privacy"},
    {"url": "https://example.com/docs/api/authentication",
     "keep_sections": ["authentication", "rate limit"]},
    {"url": "https://example.com/changelog", "render_js": True, "wait_for": "main article"},
]

def main() -> None:
    db = open_db()
    session = requests.Session()
    for item in WATCHLIST:
        url = item["url"]
        try:
            diff = check(db, session, url, keep_sections=item.get("keep_sections"),
                         render_js=item.get("render_js", False), wait_for=item.get("wait_for"))
        except Exception as exc:
            print(f"[skip] {url}: {exc}")
            continue
        if diff:
            print(f"[changed] {url}")
            notify_slack(url, diff)
        else:
            print(f"[ok] {url}")

if __name__ == "__main__":
    main()

A primeira execução preenche a base de dados e não reporta nada. A partir da segunda execução, cada alteração real gera uma mensagem no Slack com o diff.

Detecção de alterações no site

Duas opções que não requerem infraestrutura. Uma linha de cron em qualquer máquina que tenha Python e as variáveis de ambiente:

# daily at 06:10, log to a file
10 6 * * * cd /opt/change-monitor && /usr/bin/python3 monitor.py >> monitor.log 2>&1

Ou um fluxo de trabalho do GitHub Actions, o que é conveniente porque o ficheiro SQLite pode ser submetido de volta ao repositório como armazenamento de estado e os segredos ficam nas definições do repositório:

name: change-monitor
on:
  schedule:
    - cron: "10 6 * * *"
  workflow_dispatch:
jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install requests
      - run: python monitor.py
        env:
          GEONODE_API_KEY: ${{ secrets.GEONODE_API_KEY }}
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
      - run: |
          git config user.name "change-monitor"
          git config user.email "bot@users.noreply.github.com"
          git add changes.sqlite && git commit -m "snapshot $(date -u +%F)" || true
          git push

Agendamentos de hora a hora são adequados para registos de alterações; para páginas legais, a frequência diária já é mais do que a maioria das equipas necessita. A tabela abaixo fornece um ponto de partida razoável.

Tipo de páginaCadência sugeridarender_jskeep_sections
Termos, privacidade, PUAdiárianãonenhuma (página inteira)
Referência da APIa cada 6 hdepende do motor de documentaçãosim, os endpoints que utiliza
Registo de alterações / notas de lançamentode hora a horafrequentementenenhuma
Histórico de incidentes da página de estadode hora a horanormalmentenenhuma

Uma nota sobre os termos de serviço e os robôs

Ler uma documentação pública ou uma página de política uma vez por dia é uma utilização normal da Web, e é exatamente o que um navegador faz. Ainda assim, verifique os termos do site e o robots.txt antes de o adicionar à lista de observação, mantenha a frequência proporcional e nunca monitore páginas que exijam um login para o qual não tenha autorização. Se um site publicar um feed RSS ou uma API de registo de alterações, opte por essa opção: é mais económico para ambas as partes e não deixará de funcionar quando o layout da página for alterado.

Erros comuns

Comparar HTML bruto

Cada compilação de um site de documentação moderno reescreve os hashes dos recursos e os nomes das classes. As comparações tornam-se ilegíveis e cada implementação dispara um alerta. Converta para Markdown antes de mais nada, tal como faz o snapshot().

Emitir um alerta ao primeiro código de resposta diferente de 200

Um único código 503 ou uma página de verificação de bot que devolva um código 200 com a mensagem «Por favor, confirme que é humano» não deve ser guardado como versão nem dar origem a um alerta. Verifique o http_status, exija conteúdo não vazio e compare com os dois últimos instantâneos válidos.

Remoção insuficiente ou excessiva

Se a sua comparação mostrar apenas uma data, adicione-a a NOISE. Se uma alteração de cláusula tiver escapado porque keep_sections eliminou o título sob o qual se encontrava, alargue a lista de permissões. Execute o monitor durante uma semana e ajuste as duas listas com base nas comparações que produzir antes de confiar nele.

Deixar a lista de observação desviar-se da realidade

Um URL que redireciona para um novo caminho, ou uma página de documentação que foi movida para um prefixo versionado, transforma-se silenciosamente num erro 404 ou numa página diferente. Registe os códigos de estado que não sejam 200 de forma evidente e reveja as linhas «skip» na saída de vez em quando.

Próximo passo

Tudo o que foi referido acima consiste na biblioteca padrão do Python, mais uma chamada HTTP por página, o que é tudo o que é necessário para a deteção de alterações em documentos num site. A parte que é difícil de hospedar por conta própria é a própria recuperação de dados: uma conversão Markdown limpa que lida com sites de documentos renderizados em JavaScript, devolve o http_status real da origem e não é bloqueada quando se verificam quarenta páginas de fornecedores todas as manhãs. O Geonode Scraper API fornece exatamente essa chamada, com o render_js e o wait_config para páginas renderizadas pelo cliente e um plano gratuito de 1,5 mil pedidos por mês que cobre confortavelmente uma lista de observação diária. Consulte a página do Scraper API para conhecer os planos e o caso de utilização de web scraping para padrões relacionados.

Perguntas frequentes

O que é a deteção de alterações em sites e quando é útil?

A deteção de alterações em sites consiste em obter uma página periodicamente, compará-la com o instantâneo anterior e emitir um alerta quando o conteúdo relevante apresentar diferenças. É mais útil para páginas das quais depende, mas que não controla: documentação de API, termos de serviço, políticas de privacidade, condições de preços e registos de alterações de fornecedores, onde uma edição silenciosa pode comprometer uma integração ou uma suposição de conformidade.

Por que razão comparar Markdown em vez de HTML bruto?

O HTML contém layout, scripts, parâmetros de rastreamento e hashes de compilação que se alteram sem que o texto mude. A conversão para Markdown reduz primeiro a página ao seu conteúdo, pelo que uma comparação mostra a frase que foi editada, em vez de centenas de linhas de alterações nos atributos. O Geonode Scraper API devolve Markdown diretamente, o que mantém o pipeline curto.

Como evitar falsos alertas causados por datas, banners de cookies e navegação?

Elimine o ruído conhecido com um pequeno conjunto de expressões regulares (datas, linhas «última atualização», avisos de cookies, rodapés) e, em seguida, mantenha apenas as secções que lhe interessam utilizando uma lista de cabeçalhos permitidos. Normalize os espaços em branco antes de aplicar o hash. Por fim, compare com os dois últimos instantâneos para que uma página de erro transitória não desencadeie um alerta por si só.

Devo aplicar um hash à página ou comparar as diferenças?

Ambos. Um hash é uma verificação rápida de igualdade que determina se é necessário tomar alguma medida. Um diff unificado da biblioteca diflib é o que um utilizador lê no alerta. Aplique o hash primeiro, faça o diff apenas quando o hash for diferente e envie o diff juntamente com a notificação, para que o leitor possa avaliar a alteração sem abrir a página.

Com que frequência deve ser executada a deteção de alterações no site?

Adapte a frequência à importância e à rotatividade da página. As páginas jurídicas e de políticas raramente mudam mais do que algumas vezes por ano, pelo que uma verificação diária é suficiente. Documentação de API ativa e registos de alterações justificam verificações de hora a hora. Execute-a a partir do cron ou de uma programação do GitHub Actions e mantenha o intervalo suficientemente longo para que uma recuperação instável seja repetida antes de ser reportada.

Quais são as considerações relativas à conformidade e aos limites?

Monitorizar documentação pública e páginas de políticas com uma frequência moderada é uma utilização normal da Web, mas leia os termos de serviço e o ficheiro robots.txt do site e respeite-os; não monitorize páginas protegidas por autenticação às quais não tenha acesso. No que diz respeito à API, o nível gratuito Scraper API cobre 1,5 mil pedidos por mês; uma verificação diária de 40 páginas cabe dentro desse limite.