Un microservicio de comentarios en node.js

05 de septiembre 2021ComentariosjavascriptDavid Poza SuárezComentarios

Continuando con el blog en gatsby que preparé este verano quería poder disponer de un sistema que permita a los usuarios visitantes dejar sus comentarios, al estilo wordpress.

Como ya comenté, Gatsby está orientado a la creación de sites estáticos, y por tanto, algo como un sistema de comentarios, que permite la creación dinámica de contenido, no es un caso típico de aplicación. No obstante no hay problema en conectarse a una api para resolver esta necesidad.

Objetivos

Los puntos destacables que he querido incorporar en el proyecto son los siguientes:

  • usaré node.js con el framework express
  • api rest, con autenticación basada en tokens jwt.
  • login social basado en cuentas de google, para ahorrarle al usuario el proceso de registro. Usaré la librería passport y la estrategia passport-google-oauth20.
  • persistencia usando una base de datos relacional, en este caso y dadas las dimensiones del proyecto usaré una sqlite.
  • respuestas anidadas, pues ayuda bastante a organizar el contenido.
  • paginación de resultados en el backend y carga de tipo "scroll infinito" en el frontal.
  • lazy load del componente React que carga los comentarios y el formulario, para no penalizar la optimización realizada y la buena puntuación en pagespeed. Para ello haré uso de los import dinámicos y de la api intersection observer.
  • limitación del post rate / ip, para evitar spam. Usaré el middleware express-rate-limit.
  • clasificador bayesiano para poder etiquetar automáticamente los comentarios que contienen spam, para lo que usaré la librería naturalnode.
  • un webhook para llamar a un bot de telegram que me notifique de nuevos mensajes o de spam.
  • totalmente responsive y adaptado al tema oscuro.
  • despliegue via pipeline con github actions

Modelo de datos

Además, la principal idea es que sea un microservicio que asocie una url por hilo de mensajes. De este modo podré reutilizar este mismo microservicio en cualquier otra web o aplicación que requiera de sistema de comentarios.

Este enfoque me permite crear hilo de comentarios tan solo en aquellas entradas que yo considere.

Entrenando el clasificador

Ya he sufrido el spam en mis propias carnes y es MUY cansino. Suelo usar la solución comercial que ofrece google: recaptchav1,v2, v3... Pero esta vez quería algo deferente, asi que probaré a entrenar el clásico clasificador de bayes.

Su funcionamiento, resumido en una frase, consiste en ir asignando una probabilidad a cada palabra de pertenecer a cada una de las categorias definidas por nosotros (en este caso tendremos spam/ham), para luego obtener probabilidad para el mensaje como conjunto de palabras.

La idea es que si "entrenamos" el clasificador con el suficiente número de muestras de spam y ham, obtendremos unos resultados bastante fiables.

Sé que no es infalible, pero es de las mejores opciones que tenemos si queremos algo self-hosted.

Te dejo el script para entrar el clasificador en base a un fichero csv con las columnas text y label. Una vez entrenado guardo el resultado en un fichero json que nos permitirá restaurar su estado su lo subimos al servidor. He dispuesto la carpeta src/config/bayes_state.json para tal efecto.

Por cierto, estoy usando la librería minimist para el parseo de argumentos en la CLI, y es muy recomendable.

import natural from 'natural';
import { dirname } from 'path';
import csv from 'csv-parser';
import fs from 'fs';
import minimist from 'minimist';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const stateFilepath = `${__dirname}/state.json`;
const args = minimist(process.argv.slice(2));
const trainingFile = `${__dirname}/datasets/${args?.dataset}`;
let classifier;

if (fs.existsSync(stateFilepath)) {
  classifier = natural.BayesClassifier.restore(JSON.parse(fs.readFileSync(stateFilepath)));
  console.log('✅ presaved state loaded from state.json!');
} else {
  classifier = new natural.BayesClassifier();
}

if (args?.dataset && fs.existsSync(trainingFile)) {
  console.log('✅ training begins!. Please wait.');
  fs.createReadStream(trainingFile)
  .pipe(csv())
  .on('data', (row) => {
    if (row.text && row.label) {
      classifier.addDocument(row.text, row.label)
    }
  })
  .on('end', () => {
    classifier.train();
    console.log('✅ training completed!');
    if (args?.test) {
      console.log(classifier.classify(args?.test));

    }
    classifier.save(stateFilepath, function(err, classifier) {
      console.log("✅ classifier saved")
    });
  });
} else if (args?.test) {
  console.log(classifier.classify(args?.test));
}

Ejemplo de uso:

Comprobar la clasificación de un string
node scripts/trainer.js --test "una frase de prueba"

Podemos entrenar el clasificador indicando el nombre del fichero con el dataset, que debe estar en el path scripts/datasets/spam.csv

node scripts/trainer.js --dataset "spam.csv"

API endpoints

/messages

  • GET. /messages/{{id}: Permite recuperar un mensaje usando su identificador único
  • POST. /messages: Body:

    • threadUrl*: identifica el thread al que pertenece usando la url, que es única por constraint.
    • content*
  • PATCH. /messages/{{id}: Permite modificar un mensaje existente. Operación limitada al propietario del mensaje. Body:

    • content
  • DELETE. /messages/{{id}: Permite eliminar un mensaje usando su id. Operación solo permitida a usuarios administradores.

/threads

  • GET. /threads/{{id}: Permite recuperar un hilo usando su identificador
  • GET. /threads?threadUrl={{threadUrl}}: Permite recuperar un hilo usando su url
  • POST. /threads: Body:

    • url*: identifica el thread de forma es única. Operación solo permitida a usuarios administradores.
  • PATCH. /threads/{{id}: Permite modificar un thread existente. Operación solo permitida a usuarios administradores. Body:

    • url
  • DELETE. /threads/{{id}: Permite eliminar un hilo usando su id. Operación solo permitida a usuarios administradores.

/auth

  • GET. /auth/google: Inicia el proceso de login social usando google
  • GET. /auth/google/validate?auth={{token}}: Valida un token

Código fuente

Como siempre dejo el código disponible en mi cuenta de github: https://github.com/davidpoza/dps-comments

Conclusiones

Ha sido un proyecto de fin de semana, y aunque no tiene gran dificultad, me ha permitido seguir mejorando mis conocimientos de backend, asi como poner en práctica algunas técnicas de lazy loading de React, que son muy interesantes a la hora de optmizar las aplicaciones.

También me ha permitido probar el login social con google, que hasta ahora no había tenido oportunidad de probar.

Y por supuesto he disfrutado y encima he creado algo útil, como siempre intento😄.

Un saludo y nos vamos leyendo!