Home > Mobile, Open Data, Progetti, Telegram, Web Mapping > OpenDistributoriCarburantiBot: come è fatto “dentro”

OpenDistributoriCarburantiBot: come è fatto “dentro”


In questo post vedo di fornire qualche dettaglio su quelle che sono le caratteristiche e soluzioni tecniche che stanno alla base di OpenDistributoriCarburantiBot.

Non mi dilungo qui a spiegare nel dettaglio la piattaforma Telegram e la basi dei bot: rimando coloro che fossero interessati ad approfondire ad attingere dalle innumerevoli fonti presenti in rete.

Schematicamente l’architettura della soluzione è la seguente:

OpenDistributoriCarburantiBot

Nel diagramma sono citati i vari componenti del sistema che:

Si tratta di una soluzione che utilizza tutti software open source e attinge da servizi disponibili in rete.

Per una soluzione più scalabile è possibile passare ad utilizzare POSTGRESQL con la sua estensione POSTGIS, non cambiando la logica di funzionamento.

Nel diagramma sono anche illustrati i passi delle diverse operazioni coinvolte e precisamente:

  • giornalmente il sistema scarica dal MISE (0a) i dati e li memorizza sul db (0b)
    l’utente invia una richiesta dal proprio client (1)
  • il sistema recupera le richieste con un’operazione di getUpdates (2)
  • effettua le ricerche su DB (3)
  • nell’elaborazione della risposta invoca i servizi di Google Shortlink per abbreviare le url (4)
  • con un’operazione di sendMessage risponde (5)
  • Telegram inoltra la risposta al client (6)
  • l’utente può fruire dei diversi link contenuti nella risposta (7, 7a, 7b e 7c)

Il “cuore” del sistema sono i dati: questi sono tratti giornalmente dagli open del Ministero dello Sviluppo Economico (MISE).

I dati sono forniti nella forma di due file .csv che riportano le anagrafiche dei distributori ed i prezzi dei carburanti. Tra i dati delle anagrafiche dei distributori, informazione importante, sono disponibili anche le coordinate, espresse in latitudine e longitudine.

NOTA: negli esempi si suppone che il sw sia installato in /var/www/html/Telegram per cui la variabile <base_path> = /var/www/html/Telegram

Tutte le mattine, con un crontab schedulato, i dati sono prelevati via wget,

wget -U "Opera" -O <base_path>/OpenDistributoriCarburantiBot/Dati/prezzo_alle_8.csv http://www.sviluppoeconomico.gov.it/images/exportCSV/prezzo_alle_8.csv

wget -U "Opera" -O <base_path>/OpenDistributoriCarburantiBot/Dati/anagrafica_impianti_attivi.csv http://www.sviluppoeconomico.gov.it/images/exportCSV/anagrafica_impianti_attivi.csv

Si provvede ad eliminare la prima riga dei files

sed '1d' <base_path>/OpenDistributoriCarburantiBot/Dati/anagrafica_impianti_attivi.csv > <base_path>/OpenDistributoriCarburantiBot/Dati/anagrafica-temp.csv

sed '1d' <base_path>/OpenDistributoriCarburantiBot/Dati/prezzo_alle_8.csv > <base_path>/OpenDistributoriCarburantiBot/Dati/prezzo.csv

Nel file delle anagrafiche è necessario eliminare il carattere “doppi apici” che influisce negativamente nell’import in Spatialite

sed 's/"//g' <base_path>/OpenDistributoriCarburantiBot/Dati/anagrafica-temp.csv > <base_path>/OpenDistributoriCarburantiBot/Dati/anagrafica.csv

A questo punto è possibile creare il data base Spatialite ….

/usr/local/bin/spatialite <base_path>/OpenDistributoriCarburantiBot/Dati/PrezziCarburanti < <base_path>/OpenDistributoriCarburantiBot/Dati/CreaPrezziCarburanti.txt

….. con i seguenti dettagli …

CREATE TABLE anagrafica_impianti_attivi( idImpianto TEXT,
Gestore TEXT,
Bandiera TEXT,
Tipo Impianto TEXT,
Nome Impianto TEXT,
Indirizzo TEXT,
Comune TEXT,
Provincia TEXT,
Latitudine DOUBLE,
Longitudine DOUBLE );
CREATE TABLE prezzo_alle_8( idImpianto TEXT,
descCarburante TEXT,
prezzo TEXT,
isSelf TEXT,
dtComu TEXT );
.mode csv

.separator ;

.import <BASE_PATH>/OpenDistributoriCarburantiBot/Dati/anagrafica.csv anagrafica_impianti_attivi

SELECT AddGeometryColumn('anagrafica_impianti_attivi','geometry',4326,'POINT',2); UPDATE anagrafica_impianti_attivi SET geometry = GeomFromText('POINT('||"Longitudine"||' '||"Latitudine"||')',4326);

.import <BASE_PATH>/OpenDistributoriCarburantiBot/Dati/prezzo.csv prezzo_alle_8

.quit

La logica di business è stata implementata in php sebbene questa non sia una scelta esclusiva od obbligata. E’ possibile scegliere linguaggi di programmazione diversi: nel mio caso è stata una scelta di opportunità visto che in PHP avevo la possibilità di trovare maggiore esempi e supporto.

Di potenziale interesse riporto come sono implementate le ricerche, ad esempio quella per i distributori di un comune

SELECT distr.Comune, distr.Gestore, distr.Indirizzo, distr.Bandiera, distr.Latitudine, distr.Longitudine, prz.descCarburante, prz.prezzo, prz.dtComu FROM anagrafica_impianti_attivi as distr JOIN prezzo_alle_8 as prz ON (prz.idImpianto = distr.IdImpianto) WHERE distr.Comune = :Comune ORDER BY prz.prezzo ASC

dove ovviamente il parametro :Comune sarà poi valorizzato dal nome del comune corrente indicato dall’utente.

Analogamente, una ricerca spaziale che individua i distributori di un certo carburante, nell’intorno di una certa distanza da un punto, caratterizzato da una determinata coppia di coordinate, sarà

SELECT distr.Comune, distr.Gestore, distr.Indirizzo, distr.Bandiera, distr.Latitudine, distr.Longitudine, prz.descCarburante, prz.prezzo, prz.dtComu, ST_Distance(distr.geometry, MakePoint('.$lon.', '.$lat.', 4326), 1) AS dist FROM anagrafica_impianti_attivi as distr JOIN prezzo_alle_8 as prz ON (prz.idImpianto = distr.IdImpianto) WHERE dist <= '.$dist.' AND prz.descCarburante = \''.$carburante.'\' ORDER BY prz.prezzo ASC, dist ASC

Ovviamente i risultati delle query sono poi parsificati opportunamente per andare a costruire il messaggio finale che arriverà sul dispositivo dell’utente finale.

Ecco come …..

try {
$stmt = $db->prepare($q);
$results = $stmt->execute();
$count = 0;
while (($row = $results->fetchArray(SQLITE3_ASSOC)) AND ($count < $limit_search)){
$data .= "Marca: ".$row['Bandiera'];
$data .= "\n";
$data .= "Carburante: ".$row['descCarburante'];
$data .= "\n";
$data .= "Prezzo: ".$row['prezzo'];
$data .= "\n";
$data .= "Data: ".$row['dtComu'];
$data .= "\n";

$longUrl = "http://www.openstreetmap.org/?mlat=".$row['Latitudine']."&mlon=".$row['Longitudine']."&zoom=18";
$shortUrl = CompactUrl($longUrl);
$data .= "Mappa: ".$shortUrl;
$data .= "\n";

$longUrl = $base_url."/Telegram/DistributoriCarburanti/RenderRoute.php?lat_from=".$lat."&lon_from=".$lon."&lat_to=".$row['Latitudine']."&lon_to=".$row['Longitudine']."&map_type=0";
$shortUrl = CompactUrl($longUrl);
$data .= "Descrizione Percorso: ".$shortUrl;
$data .= "\n";

$longUrl = $base_url."/Telegram/DistributoriCarburanti/RenderRoute.php?lat_from=".$lat."&lon_from=".$lon."&lat_to=".$row['Latitudine']."&lon_to=".$row['Longitudine']."&map_type=2";
$shortUrl = CompactUrl($longUrl);
$data .= "Percorso su mappa 2D: ".$shortUrl;
$data .= "\n";

$longUrl = $base_url."/Telegram/DistributoriCarburanti/RenderRoute.php?lat_from=".$lat."&lon_from=".$lon."&lat_to=".$row['Latitudine']."&lon_to=".$row['Longitudine']."&map_type=3";
$shortUrl = CompactUrl($longUrl);
$data .= "Percorso su mappa 3D: ".$shortUrl;
$data .= "\n";

$data .= "\n";
$count = $count + 1;
}
if ($count >= $limit_search){
$data .= "La ricerca ha prodotto troppi risultati !!! Si presentano solo i primi ".$limit_search.". Per maggiori dettagli provare a cambiare comune, tipo di carburante, posizione o raggio di ricerca";
$data .= "\n";
}
return $data;
}
catch(PDOException $e) {
print "Something went wrong or Connection to database failed! ".$e->getMessage();
}
}
}

I messaggi di Telegram non possono superare i 4096 caratteri e dovendo fornire le url per la visualizzazione dei dettagli del percorso o della sua rappresentazione su mappa, ho dovuto “restringere” le url stesse avvalendomi del servizio di Google URL Shortener.

Il tutto avviene invocando un’apposita funzione …

function CompactUrl($longUrl)
{
$apiKey = API;

$postData = array('longUrl' => $longUrl);
$jsonData = json_encode($postData);

$curlObj = curl_init();

curl_setopt($curlObj, CURLOPT_URL, 'https://www.googleapis.com/urlshortener/v1/url?key='.$apiKey.'&fields=id');
curl_setopt($curlObj, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curlObj, CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($curlObj, CURLOPT_HEADER, 0);
curl_setopt($curlObj, CURLOPT_HTTPHEADER, array('Content-type:application/json'));
curl_setopt($curlObj, CURLOPT_POST, 1);
curl_setopt($curlObj, CURLOPT_POSTFIELDS, $jsonData);

$response = curl_exec($curlObj);

// Change the response json string to object
$json = json_decode($response);

curl_close($curlObj);
$shortLink = get_object_vars($json);

return $shortLink['id'];
}

Siccome tutte le comunicazioni sono “stateless”, mentre per alcuni passaggi necessitavo di mantenere traccia delle opzioni selezionate ai passi precedenti, ho implementato una piccola gestione di sessione sul db SQLite 3.

In questo modo aggiorno i dati quando necessario …

try {
$base_path = BASE_PATH;
//## Preparo di dati di sessione da memorizzare nel data base per il'id di chat ...
$session_data = array(
array('Chat_id' => $chat_id,
'Comune' => $comune,
'My_Lat' => $lat,
'My_Lon' => $lon,
'Search_Distance' => $search_distance
)
);
//## Accedo al db di sessione per memorizzare i dati per il'id di chat ...
$db_data_sessions = new SQLite3($base_path.'/DistributoriCarburanti/DataSessionsDB');
//## Accedo al db di sessione per eliminare i dati di sessione per di interesse per l'id di chat ...
$q="DELETE FROM data_sessions WHERE Chat_id = :Chat_id";
try {
$stmt = $db_data_sessions->prepare($q);
$stmt->bindvalue(':Chat_id', $chat_id, SQLITE3_TEXT);
$results = $stmt->execute();
}
catch(PDOException $e) {
print "Something went wrong or Connection to database failed! ".$e->getMessage();
}

//## Prepare INSERT statement to SQLite3 file db ...
$insert = "INSERT INTO data_sessions (Chat_id, Comune, My_Lat, My_Lon, Search_Distance) VALUES (:Chat_id, :Comune, :My_Lat, :My_Lon, :Search_Distance)";
$stmt = $db_data_sessions->prepare($insert);

//## Bind parameters to statement variables ...
$stmt->bindParam(':Chat_id', $Chat_id);
$stmt->bindParam(':Comune', $Comune);
$stmt->bindParam(':My_Lat', $My_Lat);
$stmt->bindParam(':My_Lon', $My_Lon);
$stmt->bindParam(':Search_Distance', $Search_Distance);

//## Loop thru all messages and execute prepared insert statement ...
foreach ($session_data as $data) {
//## Set values to bound variables ...
$Chat_id = $data['Chat_id'];
$Comune = $data['Comune'];
$My_Lat = $data['My_Lat'];
$My_Lon = $data['My_Lon'];
$Search_Distance = $data['Search_Distance'];

//## Execute statement ...
$stmt->execute();

$db_data_sessions = null;
}

}
catch(PDOException $e) {
print "Something went wrong or Connection to database failed! ".$e->getMessage();
}

ed in questo modo recupero i dati quando necessario ….

$db_data_sessions = new SQLite3($base_path.’/DistributoriCarburanti/DataSessionsDB’);
$q="SELECT ds.Chat_id, ds.Comune, ds.My_Lat, ds.My_Lon, ds.Search_Distance
FROM data_sessions as ds
WHERE ds.Chat_id = :Chat_id";
try {
$stmt = $db_data_sessions->prepare($q);
$stmt->bindvalue(':Chat_id', $chat_id, SQLITE3_TEXT);
$results = $stmt->execute();
while ($row = $results->fetchArray(SQLITE3_ASSOC)){
$comune = $row['Comune'];
$my_lat = $row['My_Lat'];
$my_lon = $row['My_Lon'];
$search_distance = $row['Search_Distance'];
}
}
catch(PDOException $e) {
print "Something went wrong or Connection to database failed! ".$e->getMessage();
}

Per il calcolo dei percorsi utilizzo il servizio di routing di MapQuest (rif. https://developer.mapquest.com/products/directions): tale servizio, gratuito sino a 15.000 chiamate al giorno (non credo di superare tali limite …. ne sarei piacevolmente stupito ….), si basa sui dati di OpenStreetMap e permette di ritornare sia la semplice descrizione testuale del percorso, sia i suoi dettagli geografici in formato JSON.

Ecco un esempio di chiamata ….

http://www.mapquestapi.com/directions/v2/route?key=YOUR_KEY_HERE&from=Lancaster,PA&to=York,PA&callback=renderNarrative

Il richiamo del servizio avviene via CURL

$url = 'http://open.mapquestapi.com/directions/v2/route?key=&outFormat=json&routeType=fastest&timeType=1&narrativeType=html&enhancedNarrative=true&shapeFormat=raw&generalize=0&locale=it_IT&unit=k&from='.$lat_from.','.$lon_from.'&to='.$lat_to.','.$lon_to.'&drivingStyle=2&highwayEfficiency=21.0';

//#Set CURL parameters
$ch = curl_init();
curl_setopt($ch, CURLOPT_AUTOREFERER, TRUE);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, TRUE);
curl_setopt($ch, CURLOPT_PROXY, '');
$data = curl_exec($ch);
curl_close($ch);

//#Convert to string (json) the route ...
$json = json_decode($data);

ed ecco come aggiungere il json del percorso sulla mappa Leaflet …..

var json_route = <?php echo json_encode($json); ?>;

//# Calculate the new map center based on the route bounding box ...
lng_ul = json_route.route.boundingBox.ul.lng;
lat_ul = json_route.route.boundingBox.ul.lat;
lng_lr = json_route.route.boundingBox.lr.lng;
lat_lr = json_route.route.boundingBox.lr.lat;
lng_center = (lng_lr - lng_ul)/2 + lng_ul;
lat_center = (lat_ul - lat_lr)/2 + lat_lr;


var map = L.map('map').setView([lat_center, lng_center], 15);

L.tileLayer('https://api.tiles.mapbox.com/v4/{id}
/{z}/{x}/{y}.png?access_token=’, {
maxZoom: 18,
attribution: 'Map data © OpenStreetMap contributors, ' +
'CC-BY-SA, ' +
'Imagery © Mapbox',
id: 'mapbox.light'
}).addTo(map);


the_coords = '';

for (i = 0; i < json_route.route.shape.shapePoints.length;
i++) {

if (i == 0) {
the_coords = the_coords + '[' + json_route.route.shape.shapePoints[i+1] + ',' + json_route.route.shape.shapePoints[i] + ']';
}
else {
if ((i % 2) == 0) {
the_coords = the_coords + ',[' + json_route.route.shape.shapePoints[i+1] + ',' + json_route.route.shape.shapePoints[i] + ']';
}
}
}

//# Create the lineString json (text) of the route ...
var my_lineString = '{\"type\": \"Feature\", \"properties\": {
}, \”geometry\”: {\”type\”: \”LineString\”, \”coordinates\”: [‘ + the_coords + ‘]}}’;

//# Create le lineString json (object) of the route ...
var lineString = JSON.parse(my_lineString);

//# add the lineString json (object) of the route to the map ...
L.geoJson(lineString).addTo(map);

Per la rappresentazione su mappa 3D l’approccio è del tutto analogo, con una unica eccezione: non essendo possibile sovrapporre alla mappa OSM Buildings un oggetto Linestring il percorso viene trasformato in un oggetto poligonale calcolandone un piccolo buffer di 1 metro. Il calcolo del buffer avviene avvalendosi della libreria Turf.js (rif. http://turfjs.org/).

Ecco come avviene il tutto ….

//# Extract the route coordinates ...
the_coords = '';

for (i = 0; i < json_route.route.shape.shapePoints.length; i++) {
if (i == 0) {
the_coords = the_coords + '[' + json_route.route.shape.shapePoints[i+1] + ',' + json_route.route.shape.shapePoints[i] + ']';
}
else {
if ((i % 2) == 0) {
the_coords = the_coords + ',[' + json_route.route.shape.shapePoints[i+1] + ',' + json_route.route.shape.shapePoints[i] + ']';
}
}
}

//# Create the lineString json (text) of the route ...
var my_lineString = '{\”type\”: \”Feature\”, \”properties\”: {}, \”geometry\”: {\”type\”: \”LineString\”, \”coordinates\”: [‘ + the_coords + ‘]}}’;

//# Create le lineString json (object) of the route ...
var lineString = JSON.parse(my_lineString);

//# Set the unit measure and calculate the buffer using Turf.js ....
var unit = 'meters';
var buffered = turf.buffer(lineString, 1, unit);
var result = turf.featurecollection([buffered]);

//# Convert to string the buffered json ....
var my_json = JSON.parse(JSON.stringify(result));

//# Create an empty Polygon json to put in OSM Building map ....
var geojson_route = {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: {
color: '#FF0080',
roofColor: '#ff99cc',
height: 0,
minHeight: 0
},
geometry: {
type: 'Polygon',
coordinates: [
[
]
]
}
}]
};

//# Add the coordinates at the Polygon json bringing them from the buffered json ....
for (var i = 0; i < my_json.features[0].features[0].geometry.coordinates[0].length; i++) {
geojson_route.features[0].geometry.coordinates[0][i] = my_json.features[0].features[0].geometry.coordinates[0][i];
}

//# Add the Polygon json to the map ....
osmb.addGeoJSON(geojson_route);

//# Refresh of the map to show the labels ...
map.setPosition(map.getPosition());

Ovviamente per maggiori dettagli mi potete contattare direttamente.

  1. Non c'è ancora nessun commento.
  1. No trackbacks yet.

Lascia un commento

Inserisci i tuoi dati qui sotto o clicca su un'icona per effettuare l'accesso:

Logo WordPress.com

Stai commentando usando il tuo account WordPress.com. Chiudi sessione / Modifica )

Foto Twitter

Stai commentando usando il tuo account Twitter. Chiudi sessione / Modifica )

Foto di Facebook

Stai commentando usando il tuo account Facebook. Chiudi sessione / Modifica )

Google+ photo

Stai commentando usando il tuo account Google+. Chiudi sessione / Modifica )

Connessione a %s...

%d blogger cliccano Mi Piace per questo: