Programa escrito en Free Pascal orientado a la captura automatizada de datos meteorológicos publicados por un bot en una instancia de Mastodon. Su función principal es consultar la red social mediante su API REST, extraer el contenido HTML del último estado publicado, limpiar las etiquetas, procesar los valores numéricos de temperatura, presión y humedad utilizando expresiones regulares, convertir la marca temporal UTC a hora local y grabar la información en una base de datos local SQLite si se trata de un nuevo registro.
program captadormedidasmastodon; {$mode objfpc}{$H+} uses // Soporte para UTF-8 {$IFDEF UNIX} cwstring, {$ENDIF} // Soporte para Windows {$IFDEF WINDOWS} Windows, {$ENDIF} // SysUtils Siempre detrás de cwstring para evitar errores SysUtils, // Soporte de OOP Classes, // Soporte HTTP, SSL y JSON fphttpclient, opensslsockets, fpjson, jsonparser, // Procesado de expresiones regulares RegExpr, // Soporte SQL y SQLite sqlite3conn, sqlite3dyn, sqldb, // Soporte de fecha/hora DateUtils; const // Constantes para leer el contenido del usuario bot (@meteo) en Mastodon INSTANCIA_MASTODON = 'social.hispa.net'; USUARIO_MASTODON = 'meteo'; USER_AGENT = 'MastodonWeatherReader/1.0 (FreePascal)'; // Directorio para su uso en el sobremesa (testing) // DB_FILE = '/home/hispa/Nextcloud/Data_1/Documentos/Jorge/Programas/Pascal/captador_medidas_mastodon/clima.db'; // Directorio para su uso en el servidor (definitivo) DB_FILE = '/media/Data_1/Documentos/Jorge/Programas/Pascal/captador_medidas_mastodon/clima.db'; type TMastodonPost = record Content: string; CreatedAt: string; // ISO 8601 UTC end; TDatosClima = record Temperatura: Double; Presion: Double; Humedad: Double; end; { ============================================================================ } { MÓDULO DE RED Y API MASTODON } { ============================================================================ } // Realiza una petición GET HTTPS configurando el User-Agent function HttpGet(const URL: string): string; var Client: TFPHTTPClient = nil; begin Result := ''; try Client := TFPHTTPClient.Create(nil); Client.AddHeader('User-Agent', USER_AGENT); Result := Client.Get(URL); finally Client.Free; end; end; // Obtiene el ID numérico interno de un usuario de Mastodon function ObtenerAccountId(const Instancia, Usuario: string): string; var URL, Response: string; JsonData: TJSONData = nil; IdNode: TJSONData = nil; begin Result := ''; URL := Format('https://%s/api/v1/accounts/lookup?acct=%s', [Instancia, Usuario]); try Response := HttpGet(URL); JsonData := GetJSON(Response); if Assigned(JsonData) then begin IdNode := JsonData.FindPath('id'); if Assigned(IdNode) then Result := IdNode.AsString; end; except on E: Exception do WriteLn('[Error HTTP/JSON] Al buscar usuario: ', E.Message); end; JsonData.Free; end; // Obtiene el contenido y fecha del último estado publicado por la cuenta function ObtenerUltimoPost(const Instancia, AccountId: string; out Post: TMastodonPost): Boolean; var URL, Response: string; JsonData: TJSONData = nil; JsonArray: TJSONArray; PostObj: TJSONObject; begin Result := False; Post.Content := ''; Post.CreatedAt := ''; URL := Format('https://%s/api/v1/accounts/%s/statuses?limit=1', [Instancia, AccountId]); try Response := HttpGet(URL); JsonData := GetJSON(Response); if (JsonData is TJSONArray) and (TJSONArray(JsonData).Count > 0) then begin JsonArray := TJSONArray(JsonData); PostObj := JsonArray.Objects[0]; Post.Content := PostObj.Get('content', ''); Post.CreatedAt := PostObj.Get('created_at', ''); Result := True; end; except on E: Exception do WriteLn('[Error HTTP/JSON] Al obtener publicaciones: ', E.Message); end; JsonData.Free; end; { ============================================================================ } { MÓDULO DE TRATAMIENTO DE TEXTO Y VALORES METEOROLÓGICOS } { ============================================================================ } // Remueve etiquetas HTML y espacios múltiples sobrantes function LimpiarTextoHTML(const TextoHTML: string): string; var Regex: TRegExpr = nil; begin try Regex := TRegExpr.Create; Regex.Expression := '<[^>]+>'; Result := Regex.Replace(TextoHTML, ' ', False); // Normalizar espacios múltiples a uno solo Regex.Expression := '\s+'; Result := Trim(Regex.Replace(Result, ' ', False)); finally Regex.Free; end; end; // Convierte fecha ISO 8601 UTC a hora local (formato YYYY-MM-DD HH:NN:SS) function UTCaFechaLocal(const FechaUTC: string): string; var DT_UTC, DT_Local: TDateTime; begin try DT_UTC := ISO8601ToDate(FechaUTC, True); DT_Local := UniversalTimeToLocal(DT_UTC); Result := FormatDateTime('yyyy-mm-dd hh:nn:ss', DT_Local); except Result := FechaUTC; end; end; // Extrae valor numérico mediante Regex independientemente del separador (coma/punto) function ExtraerValor(const Texto, Patron: string): Double; var Regex: TRegExpr = nil; ValStr: string; FS: TFormatSettings; begin Result := 0.0; // Configuración de formato independiente del locale FS := DefaultFormatSettings; FS.DecimalSeparator := '.'; try Regex := TRegExpr.Create; Regex.ModifierI := True; // Insensible a mayúsculas Regex.Expression := Patron; if Regex.Exec(Texto) then begin ValStr := StringReplace(Regex.Match[1], ',', '.', [rfReplaceAll]); Result := StrToFloatDef(ValStr, 0.0, FS); end; finally Regex.Free; end; end; // Parsea las tres magnitudes principales del post function ParsearDatosClima(const Texto: string): TDatosClima; begin Result.Temperatura := ExtraerValor(Texto, '(?:temp|temperatura)[^\d]*(-?\d+(?:[\.,]\d+)?)'); Result.Presion := ExtraerValor(Texto, '(?:presi|barom)[^\d]*(\d+(?:[\.,]\d+)?)'); Result.Humedad := ExtraerValor(Texto, '(?:hum|humedad)[^\d]*(\d+(?:[\.,]\d+)?)'); end; { ============================================================================ } { MÓDULO DE BASE DE DATOS (SQLITE) } { ============================================================================ } // Prepara la conexión SQL asegurando el estado abierto procedure AbrirConexion(Conn: TSQLite3Connection); begin if Conn.DatabaseName = '' then Conn.DatabaseName := DB_FILE; if not Conn.Connected then Conn.Open; end; // Crea la estructura de tablas inicial procedure InicializarBaseDatos(Conn: TSQLite3Connection; Trans: TSQLTransaction); var Query: TSQLQuery = nil; begin AbrirConexion(Conn); Trans.StartTransaction; try Query := TSQLQuery.Create(nil); Query.DataBase := Conn; Query.Transaction := Trans; Query.SQL.Text := 'CREATE TABLE IF NOT EXISTS mediciones (' + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + ' fecha TEXT NOT NULL,' + ' temperatura REAL,' + ' presion REAL,' + ' humedad REAL' + ');'; Query.ExecSQL; Trans.Commit; except if Trans.Active then Trans.Rollback; Query.Free; raise; end; Query.Free; end; // Devuelve la última fecha registrada en SQLite o cadena vacía si no hay registros function ObtenerUltimaFechaBD(Conn: TSQLite3Connection; Trans: TSQLTransaction): string; var Query: TSQLQuery = nil; begin Result := ''; AbrirConexion(Conn); try Query := TSQLQuery.Create(nil); Query.DataBase := Conn; Query.Transaction := Trans; Query.SQL.Text := 'SELECT MAX(fecha) FROM mediciones;'; Query.Open; if not Query.EOF and not Query.Fields[0].IsNull then Result := Query.Fields[0].AsString; finally Query.Free; end; end; // Guarda los valores parseados en la base de datos procedure GuardarLectura(Conn: TSQLite3Connection; Trans: TSQLTransaction; const Fecha: string; const Datos: TDatosClima); var Query: TSQLQuery = nil; begin AbrirConexion(Conn); Trans.StartTransaction; try Query := TSQLQuery.Create(nil); Query.DataBase := Conn; Query.Transaction := Trans; Query.SQL.Text := 'INSERT INTO mediciones (fecha, temperatura, presion, humedad) ' + 'VALUES (:fecha, :temp, :pres, :hum);'; Query.Params.ParamByName('fecha').AsString := Fecha; Query.Params.ParamByName('temp').AsFloat := Datos.Temperatura; Query.Params.ParamByName('pres').AsFloat := Datos.Presion; Query.Params.ParamByName('hum').AsFloat := Datos.Humedad; Query.ExecSQL; Trans.Commit; WriteLn('-> Nueva medida guardada con éxito.'); except if Trans.Active then Trans.Rollback; Query.Free; raise; end; Query.Free; end; { ============================================================================ } { BLOQUE PRINCIPAL } { ============================================================================ } var Connection: TSQLite3Connection = nil; Transaction: TSQLTransaction = nil; AccountId, UltimaFechaBD, FechaLocal, TextoLimpio: string; Post: TMastodonPost; Clima: TDatosClima; begin {$IFDEF UNIX} SQLiteDefaultLibrary := 'libsqlite3.so.0'; {$ENDIF} {$IFDEF WINDOWS} SQLiteDefaultLibrary := 'sqlite3.dll'; {$ENDIF} Connection := TSQLite3Connection.Create(nil); Transaction := TSQLTransaction.Create(nil); Connection.Transaction := Transaction; try WriteLn('Base de datos: ', DB_FILE); InicializarBaseDatos(Connection, Transaction); WriteLn(Format('Buscando cuenta "@%s@%s"...', [USUARIO_MASTODON, INSTANCIA_MASTODON])); AccountId := ObtenerAccountId(INSTANCIA_MASTODON, USUARIO_MASTODON); if AccountId = '' then begin WriteLn('Error: No se pudo resolver la cuenta del usuario.'); Exit; end; WriteLn('Consultando último post...'); if not ObtenerUltimoPost(INSTANCIA_MASTODON, AccountId, Post) then begin WriteLn('No se encontraron publicaciones.'); Exit; end; TextoLimpio := LimpiarTextoHTML(Post.Content); FechaLocal := UTCaFechaLocal(Post.CreatedAt); WriteLn('Fecha UTC: ', Post.CreatedAt); WriteLn('Fecha Local: ', FechaLocal); WriteLn('Contenido: ', TextoLimpio); UltimaFechaBD := ObtenerUltimaFechaBD(Connection, Transaction); if UltimaFechaBD <> '' then WriteLn('Última fecha registrada en BD: ', UltimaFechaBD) else WriteLn('Última fecha registrada en BD: Ninguna (BD vacía)'); // Verificar si la fecha actual es posterior a la almacenada if (UltimaFechaBD = '') or (FechaLocal > UltimaFechaBD) then begin Clima := ParsearDatosClima(TextoLimpio); WriteLn(Format('Extraído -> Temp: %.2f °C | Presión: %.2f hPa | Humedad: %.2f%%', [Clima.Temperatura, Clima.Presion, Clima.Humedad])); GuardarLectura(Connection, Transaction, FechaLocal, Clima); end; finally Transaction.Free; Connection.Free; end; end.
La persistencia se realiza en una base de datos SQLite con la siguiente estructura de tabla: