Técnico 2 de oct. de 2026 · 6 min de lectura

Moderar comentarios en Flask con el SDK de Python

Monta un muro de comentarios en Flask que publica, retiene o rechaza cada comentario con su motivo, verifica el webhook de revisión y pasa sus tests sin acceso a la red.

Eduardo Lázaro
Eduardo Lázaro
Fundador de ToxicFilter
Moderar comentarios en Flask con el SDK de Python

El formulario de comentarios es la primera puerta por la que entran el spam y el abuso en una web, y lo último que alguien quiere leerse entrada a entrada. En este artículo montamos un pequeño muro de comentarios en Flask y lo moderamos con ToxicFilter: cada comentario se comprueba antes de mostrarse, y pasa una de estas tres cosas.

  • Allow: se publica al momento. Son casi todos.
  • Review: se retiene, y una persona decide en tu panel de ToxicFilter. Un webhook firmado avisa a la aplicación, que lo publica o lo descarta.
  • Block: se rechaza, y al autor se le explica el motivo con palabras.

Tres resultados y no dos, a propósito. Si te obligan a elegir entre publicar y borrar, un umbral estricto borra comentarios legítimos y uno permisivo publica el abuso; en el resultado intermedio, los casos dudosos esperan a que los vea una persona. La aplicación completa está en toxicfilter/flask-example.

Este artículo forma parte de una serie de cuatro que monta la misma aplicación, cada vez con un cliente distinto: PHP sin framework, Express y Laravel con Laratox. Cada una tiene su propio repositorio, listo para clonar o para usar como plantilla: php-example, flask-example, express-example y laravel-example.

Instalación y configuración

El cliente no tiene dependencias: la biblioteca estándar se encarga del HTTP.

pip install flask toxicfilter-sdk

Crea una clave en tu panel; con el plan gratuito basta. Una clave tf_test_ no cuesta nada y ejecuta todas las comprobaciones gratuitas, que son las que resuelven la mayoría de los comentarios, pero nunca consulta al modelo: usa una clave live para ver lo que aporta el modelo. La clave da acceso a toda la cuenta, así que va en una variable de entorno del servidor y nunca en una página.

TOXICFILTER_KEY=tf_test_... TOXICFILTER_WEBHOOK_SECRET=whsec_... flask --app app run --port 8000

Los comentarios se guardan en un fichero JSON (store.py), así que no hay base de datos que configurar. Cámbialo por tu propio almacenamiento: nada de la moderación depende de él.

Moderar un comentario

Un único cliente para toda la aplicación, creado una vez con la clave del entorno:

tf = Client(os.environ["TOXICFILTER_KEY"])

Después, la ruta que recibe el formulario comprueba el comentario antes de guardarlo:

id = comments.next_id()

try:
    verdict = tf.text(
        body,
        surface="comment",
        reference=f"comment_{id}",  # how the webhook finds this comment later
    )
except ToxicFilterError:
    # Nobody could judge it: hold it rather than publish it unread.
    comments.add(id, name, body, "held", None)
    return redirect("/?held=1", code=303)

if verdict.blocked:
    return show_form(name, body, verdict.reason or "This comment cannot be published.")

comments.add(id, name, body, "held" if verdict.needs_review else "published", verdict.id)

return redirect("/?held=1" if verdict.needs_review else "/", code=303)
  • surface dice dónde aparece el texto, para que una política pueda tratar un comentario de forma distinta que un perfil.
  • reference es el id propio de la aplicación para el comentario. Vuelve en cada webhook que se refiera a él, así que la aplicación nunca tiene que guardar los ids de ToxicFilter para encontrar sus comentarios.
  • El veredicto responde con blocked, needs_review y allowed, nunca con un único booleano de "tóxico", y además trae las categorías marcadas, las puntuaciones y la evidencia.
  • Si la API no puede responder, el comentario se retiene en lugar de publicarse. ToxicFilterError es la base de todos los fallos que lanza el cliente.

Explicarle al autor el motivo

verdict.reason es el primer motivo, redactado para mostrárselo al autor, y verdict.reasons los tiene todos. El formulario se vuelve a mostrar, con un 422, con lo que escribió y el motivo por el que no se publicó. Jinja escapa todo lo que imprime, así que ni el comentario ni el motivo pueden inyectar HTML.

Cuando decide una persona: el webhook

Un comentario retenido espera en tu cola de revisión. Cuando alguien lo aprueba o lo rechaza, ToxicFilter envía moderation.resolved, firmado:

@app.post("/webhooks/toxicfilter")
def toxicfilter_webhook():
    """A person decided on a held comment in ToxicFilter: publish it or drop it."""
    event = webhooks.event(
        request.get_data(),  # the RAW body, before any parsing
        request.headers.get("X-ToxicFilter-Signature", ""),
        os.environ.get("TOXICFILTER_WEBHOOK_SECRET", ""),
    )

    if event is None:
        return "", 400

    match = re.fullmatch(r"comment_(\d+)", event["data"].get("reference") or "")

    if event["event"] == "moderation.resolved" and match:
        if event["data"]["action"] == "approved":
            comments.publish(int(match[1]))
        else:
            comments.remove(int(match[1]))

    return "", 204

request.get_data() es el cuerpo en bruto, que es lo que cubre la firma. webhooks.event() devuelve None para todo lo que no supera la verificación, así que una petición sin firmar o manipulada es un 400.

Para probarlo en tu máquina, expón la aplicación con un túnel (cloudflared tunnel --url http://localhost:8000, por ejemplo), añade la dirección del túnel seguida de /webhooks/toxicfilter como endpoint en Webhooks y pon el secreto de firma que te muestra en TOXICFILTER_WEBHOOK_SECRET. Luego publica un comentario que acabe en revisión, apruébalo desde la cola de revisión y verás cómo aparece.

Hay dos detalles que hacen que el handler no solo funcione, sino que sea correcto. La firma se comprueba sobre el cuerpo en bruto, porque un cuerpo parseado y vuelto a codificar ya es otra cadena y la firma nunca cuadraría. Y el comentario se encuentra por su reference, el id que la aplicación envió con la comprobación, porque el webhook lleva el veredicto y nunca el comentario: ToxicFilter no guarda lo que modera.

Tests sin acceso a la red

El cliente acepta un transport: cualquier cosa con un send(). Pásale uno que responda como la API y toda la aplicación se ejecuta en un test, sin clave y sin red:

class Api:
    def send(self, method, url, headers, body):
        return 200, json.dumps({
            "id": "mod_1",
            "decision": "block",
            "signals": [{"category": "spam", "score": 0.93, "reason": "Contains a referral link."}],
        })

app.tf = Client("tf_test_x", transport=Api())
response = app.app.test_client().post("/comments", data={"name": "Bo", "body": "..."})
assert b"Contains a referral link." in response.data

Lo que el cliente te da hecho

Si topa con el límite de peticiones o con un error del servidor, reintenta con una espera creciente, y nunca reintenta QuotaExhausted; envía una clave de idempotencia con cada llamada, así que un reintento se juzga y se factura una sola vez; y nunca toma la ausencia de respuesta por un allow. Un timeout de lectura es un ServerError reintentable como cualquier otro fallo, no un OSError que se escapa de tu except. Todo está en la documentación del SDK de Python.

Por dónde seguir

  • Tus propias reglas: una política mueve los umbrales por categoría, añade tus propias palabras prohibidas o mide temas como las apuestas o las criptomonedas, que no son dañinos pero puede que no encajen en tu web. Indica cuál en la llamada.
  • Varias webs en una cuenta: dale a cada una un proyecto, con su propia actividad, cola de revisión y webhooks.
  • Más allá del texto: el mismo cliente comprueba imágenes, nombres de usuario, registros completos y conversaciones, donde salen a la luz un acoso en grupo o un acercamiento que ningún mensaje deja ver por separado.

Pruébalo con tu tráfico real

Permitir, revisar o bloquear, y el motivo explicado. En el plan gratuito: 2.000 créditos al mes, sin tarjeta. Una comprobación cuesta 1 crédito, unos 8 si la lee el modelo y unos 10 por imagen.