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.