Spring naar hoofdtekst

Actuele brandstofprijzen met comfort

Geplaatst op door .
Laatste aanpassing op .

Inleiding

Op lange termijn is het de bedoeling dat onze maatschappij qua mobiliteit de duurzaamheid voorop gaat zetten; dat we minder eigen, persoonlijk vervoer gebruiken, en daarvoor in de plaats veel meer delen met elkaar. Dat is niet alleen veel beter voor het milieu, de aarde, het klimaat en dus de mensheid, maar het maakt je als mens ook bewuster van wat je doet en waarom.

Tot zover het toekomstbeeld; terug naar de droevige werkelijkheid van 2023. We verbruiken nog steeds miljoenen liters fossiele brandstoffen om onszelf te verplaatsen. Totdat de gezinsauto wordt vervangen door een (elektrische) deelauto blijft het zoeken naar de voordeligste manier om aan die brandstof te komen.

Concurrentie

Sommige leveranciers publiceren de brandstofprijzen online, anderen houden die data achter gesloten deuren. Je moet dan bij het tankstation zelf gaan kijken – en loopt het risico dat je daar, eenmaal aangekomeen, dan ook maar meteen tankt; en onbewust meer betaalt dan nodig.

Uitdaging

Het is een behoorlijke uitdaging om actuele brandstofprijzen te verzamelen van tankstations in de buurt; zeker in de grensstreek, waar je te maken hebt met Nederlandse, Duitse en Belgische pompstations.

Dit project begon met een aantal aan mezelf gestuurde memo-berichtjes met URLs van de verschillende websites. Als ik ze met elkaar wilde vergelijken, moest ik elke pagina oproepen en de huidige prijs onthouden. Daarna kon ik ze met elkaar vergelijken en een bewuste keuze maken. Dat moest toch te automatiseren zijn?

Data verzamelen

Aldus ging ik op zoek naar actuele prijsinformatie van de tankstations in onze buurt: Nederlands Zuid-Limburg. Over de Duitse grens is Aral een veel voorkomend brandstofmerk; zij hebben voor elk station een eigen pagina die de actuele prijzen in JSON-formaat ophaalt bij een openbare API op api.tankstelle.aral.de. Dit was alvast één kandidaat die ik makkelijk met PHP zou kunnen uitlezen!

Een aantal anderen (Tango, Lukoil en Argos in Nederland, Carbu in België) maakten het mij niet zo eenvoudig. De prijzen waren in de HTML van de betreffende pagina's verweven en ik zou grondig moeten graven om ze er uit te kunnen destileren. Aldus geschiedde :-).

Aanpak

De eerste stap van elke opvraging is een cURL-request naar de betreffende URL. Daarna wordt het antwoord op deze request geanalyseerd op de daadwerkelijke prijzen en een datum-tijd van laatste update. Elke implementatie is maatwerk; geen enkele aanbieder gebruikt dezelfde opmaak of structuur. Ook het gebruik van punt of komma als decimaal scheidingsteken kan per site verschillen.

<?php
$ch = curl_init();
curl_setopt_array(
    $ch, [
        CURLOPT_URL => 'https://...',
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true
    ]
);
$output = curl_exec($ch);
$info = curl_getinfo($ch);
curl_close($ch);

if ($info['http_code'] !== 200) {
    return null;
}
// Do stuff with $output

Aral (JSON)

Binnen de response van de API heeft elke brandstof zijn eigen code. De prijzen worden opgegeven in eurocenten. In mijn applicatie koos ik voor de codes E5 (Euro 98), E10 (Euro 95) en B7 (Diesel) en prijzen in Euro's. Ook de datum en tijd van laatste update wordt door de API aangeleverd.

<?php
$json = json_decode($output);

if (json_last_error() !== JSON_ERROR_NONE) {
    return null;
}
$o = [];

foreach ($json->data as $x) {
    switch ($x->aral_id) {

    case 'F00104': // Super E5
        $o['E5'] = bcdiv($x->price->price, '100');
        break;

    case 'F00113': // Super E10
        $o['E10'] = bcdiv($x->price->price, '100');
        $o['datetime'] = $x->price->valid_from;
        break;

    case 'F00400': // Diesel
        $o['B7'] = bcdiv($x->price->price, '100');
        break;
    }
}
// Do something with $o

Tango (HTML)

De tot nu toe succesvolste manier om de HTML te analyseren is het parsen met behulp van Masterminds HTML5. Hieruit rolt dan een \DOMDocument dat je kan doorzoeken met \DOMXPath. Soms staat de gewenste informatie in een element met id-attribuut (@id), soms in een element met een bepaalde CSS-klasse (@class).

Daarna wordt de tekstuele inhoud ($node->textContent) op één regel gepropt en daarna met behulp van reguliere expressies ontleed. Tot slot worden de prijzen met bcmul() vermenigvuldigd en de datumtijd geparst met behulp van PHP's \IntlDateFormatter.

Code in-/uitklappen
<?php
use IntlDateFormatter as IDF;
use Masterminds\HTML5;

function _pluckForPrice(\DOMDocument &$dom, string $nodeId) : string
{
    $xp = new \DOMXPath($dom);
    $nodes = $xp->query("//*[contains(@id, '$nodeId')]");

    if ($nodes->length == 0) {
        return '';
    }
    // Compress all whitespaces into single spaces
    $node = $nodes->item(0);
    $txt = preg_replace('/\s+/u', ' ', $node->textContent);

    $m = [];
    $re = '!Pompprijs (?<PRICE>\d+\.\d+) EUR/L!i';

    if (preg_match($re, $txt, $m) !== 1) {
        return '';
    }
    return $m['PRICE'];
}
function _pluckForDatetime(\DOMDocument &$dom, string $c) : ?\DateTime
{
    $xp = new \DOMXPath($dom);
    $nodes = $xp->query(
        "//*[contains(concat(' ', normalize-space(@class), ' '), ' $c ')]"
    );
    if ($nodes->length == 0) {
        return null;
    }
    $node = $nodes->item(0);
    $txt = preg_replace('/\s+/u', ' ', $node->textContent);

    $m = [];
    $re = '!(?<DATETIME>\d\d:\d\d uur, \d\d-\d\d-\d\d\d\d)!i';

    if (preg_match($re, $txt, $m) !== 1) {
        return null;
    }
    $tz = new \DateTimeZone('Europe/Amsterdam');
    $dtfmt = new IDF(
        'nl_NL', IDF::LONG, IDF::NONE,
        $tz, IDF::GREGORIAN, 'HH:mm \'uur\', dd-MM-yyyy'
    );
    $dt = (new \DateTime('now', $tz))->setTimestamp(
        $dtfmt->parse($m['DATETIME'])
    );
    return $dt;
}
$html5 = new HTML5();
$dom = $html5->loadHTML($output);

// Parse for fuel datetime
$c = 'field--name-price-last-changed'; // class denoting last update
$dt = self::_pluckForDatetime($dom, $c);

// Parse for fuel prices
$e5 = self::_pluckForPrice($dom, 'super98');
$e10 = self::_pluckForPrice($dom, 'euro95');
$b7 = self::_pluckForPrice($dom, 'diesel');

$o = [
    'datetime' => $dt,
    'E5' => bcmul($e5, '1'),
    'E10' => bcmul($e10, '1'),
    'B7' => bcmul($b7, '1')
];
// Do something with $o

Carbu (HTML)

Ook de HTML van het Belgische Carbu wordt op dezelfde manier geanalyseerd. In dit geval staat bij elke prijs een eigen datum van laatste update.

Code in-/uitklappen
<?php
function _pluck(array $m) : array
{
    $tz = new \DateTimeZone('Europe/Amsterdam');
    $price = bcmul(str_replace(',', '.', $m['PRICE']), '1');
    $dtfmt = new IDF(
        'nl_NL', IDF::LONG, IDF::NONE,
        $tz, IDF::GREGORIAN, 'dd/MM/yy'
    );
    $dt = (new \DateTime('now', $tz))
        ->setTimestamp($dtfmt->parse($m['DATE']));

    return ['PRICE' => $price, 'DATETIME' => $dt];
}
$html5 = new HTML5();
$dom = $html5->loadHTML($html);
$xp = new \DOMXPath($dom);

$c = 'carburants'; // CSS class that denotes area with fuel prices
$nodes = $xp->query(
    "//*[contains(concat(' ', normalize-space(@class), ' '), ' $c ')]"
);
if ($nodes->length == 0) {
    return null;
}
$m = [];
$node = $nodes->item(0);

// Compress all whitespaces into single spaces
$txt = preg_replace('/\s+/u', ' ', $node->textContent);

// Match different fuel prices and their datetimes
$reE5 = '!\(E5\) (?<PRICE>[^\\s]+) €/L (?<DATE>\d\d/\d\d/\d\d) !';
$reE10 = '!\(E10\) (?<PRICE>[^\\s]+) €/L (?<DATE>\d\d/\d\d/\d\d) !';
$reB7 = '!\(B7\) (?<PRICE>[^\\s]+) €/L (?<DATE>\d\d/\d\d/\d\d) !';

if (preg_match($reE5, $txt, $m) == 1) {
    $pdt = self::_pluck($m);
    $o['E5'] = $pdt['PRICE'];
}
if (preg_match($reE10, $txt, $m) == 1) {
    $pdt = self::_pluck($m);
    $o['E10'] = $pdt['PRICE'];
    $o['datetime'] = $pdt['DATETIME'];
}
if (preg_match($reB7, $txt, $m) == 1) {
    $pdt = self::_pluck($m);
    $o['B7'] = $pdt['PRICE'];
}
// Do somthing with $o;

Shell (HTML + JSON)

Toen ik ook een tankstation van Shell wilde opnemen in mijn applicatie, bleek dat er bij Nederlandse pompen geen prijzen staan genoemd (bijvoobeeld in Utrecht). Bij Duitse pompen staan daarentagen wél prijzen (bijvoorbeeld in Keulen). OK, dan nemen we een Duits station net over de grens ter vergelijking.

<div
  data-react-class="pages/LocationPage"
  data-react-props="{&quot;config&quot;:{&quot;alwaysShowOpeningHours&quot;:false,..."
  data-react-cache-id="pages/LocationPage-0">
</div>

Toen ik de rauwe HTML binnenhaalde met cURL bleek er slechts één <div>-element te zijn met een aantal HTML attributen. Deze begonnen allemaal met data-react-; mijn eerste kennismaking met React.js. Alle data (en dus ook de prijzen die ik zocht) zaten verstopt in het data-react-props attribuut. De inhoud leek verdacht veel op JSON-data en dat bleek het ook te zijn!

Code in-/uitklappen
<?php
$html5 = new HTML5();
$dom = $html5->loadHTML($html);
$xp = new DOMXPath($dom);
$tz = new \DateTimeZone('Europe/Amsterdam');
$o = [];

$nodes = $xp->query('//*/@data-react-props');
if ($nodes->length == 0) {
    return null;
}
$props = $nodes->item(0);
$json = json_decode($props->nodeValue);

if (json_last_error() !== JSON_ERROR_NONE) {
    return null;
}
if (!property_exists($json, 'location')) {
    return null;
}
if (!property_exists($json->location, 'fuelPricing')) {
    return null;
}
$pricing = $json->location->fuelPricing;

if (!property_exists($pricing, 'updated')) {
    return null;
}
if (is_null($pricing->updated)) {
    // Return empty datetime and prices
    $o = [
        'datetimee' => null,
        'E5' => null,
        'E10' => null,
        'B7' => null
    ];
    // Do something with $o
}
$dt = new \DateTime($pricing->updated, $tz);

if (!property_exists($pricing, 'prices')) {
    return null;
}
$o['datetime'] = $dt;
$p = $pricing->prices;

if (property_exists($p, 'fuelsave_98')) {
    $o['E5'] = bcmul((string)$p->fuelsave_98, '1');
}
if (property_exists($p, 'fuelsave_midgrade_gasoline')) {
    $o['E10'] = bcmul((string)$p->fuelsave_midgrade_gasoline, '1');
}
if (property_exists($p, 'fuelsave_regular_diesel')) {
    $o['B7'] = bcmul((string)$p->fuelsave_regular_diesel, '1');
}
// Do something with $o

Buitenbeentje BP

Tot slot wilde ik ook een tankstation van BP toevoegen, maar deze leverancier heeft alleen maar generieke adviesprijzen online staan. Die zijn natuurlijk ook uit de HTML te destileren, maar deze zijn niet echt relevant voor dit scenario. De daadwerkelijke (actuele) prijs aan een specifieke pomp kon ik bij BP zelf niet vinden.

Een externe site, DirectLease, beweerde wel de actuele prijzen te hebben. Deze worden, waarschijnlijk om het geatomatiseerd 'lenen' van de data te bemoeilijken, als PNG-afbeelding getoond bij elk station. Ook hier kwam de data vanuit een API, hier genaamd tankservice.app-it-up.com.

OCR

Aldus kwam het idee om met Optical Character Recognition (OCR) de afbeelding te analyseren en daaruit de prijzen te destileren. Ik zocht en vond in Free OCR een gratis API die een bestaande afbeelding (of een URL daarvan) kon analyseren en een JSON-antwoord terugstuurde met de herkende tekst.

Code in-/uitklappen
<?php
$url = 'https://api.ocr.space/parse/imageurl?' .
    'url=https://tankservice.app-it-up.com/Tankservice/v2/places/' .
    '11509.png?lang=nl&language=dut&filetype=PNG&OCREngine=3';
$curl = curl_init();

curl_setopt_array(
    $curl, array(
        CURLOPT_URL => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_CUSTOMREQUEST => 'GET',
        CURLOPT_HTTPHEADER => array(
            'apikey: <PUT_YOUR_API_KEY_HERE>'
        ),
    )
);
$json = curl_exec($curl);
curl_close($curl);
$json = json_decode($json);

if (json_last_error() !== JSON_ERROR_NONE) {
    return null;
}
if (!property_exists($json, 'ParsedResults')) {
    return null;
}
if (!is_array($json->ParsedResults) || empty($json->ParsedResults)) {
    return null;
} 
if (!property_exists($json->ParsedResults[0], 'ParsedText')) {
    return null;
}
$s = $json->ParsedResults[0]->ParsedText;

// Compress all whitespaces into single spaces
$s = preg_replace('/\s+/u', ' ', $s);

$m = [];
$re = '!(?:' . 
    '(?<E10>\d+\,\d+).*?' .
    '(?<B7>\d+\,\d+).*?' .
    '(?<E5>\d+\,\d+)' .
    ')' .
    '!u';
if (preg_match($re, $s, $m) == 0) {
    return null;
}
$o['E10'] = str_replace(',', '.', $m['E10']);
$o['B7'] = str_replace(',', '.', $m['B7']);
$o['E5'] = str_replace(',', '.', $m['E5']);
$o['datetime'] = new \DateTime('now', $tz);
// Do something with $o

Conclusie

Voor mijn applicatie heb ik bovenstaande stukken code gecombineerd met een mini-webapplicatie. De opgehaalde prijzen worden opgeslagen in een database en met een geautomatiseerde cronjob elk uur bijgewerkt. Ook kan met een druk op de knop één specifieke prijs worden ververst, of alle prijzen tegelijk. De prijzen worden automatisch gesorteerd zodat de goedkoopste leverancier bovenaan staat.

In het dagelijks gebruik van deze applicatie blijkt dat sommige pompstations met de prijzen stunten: in de ochtend is de prijs wel 20 cent hoger dan later op de dag. Uiteindelijk heb ik het BP-station uit de lijst verwijderd; de prijs via de externe site is al dagen niet ververst en de OCR-aanpak is niet echt betrouwbaar. Dan maar geen Brits petroleum.

Het hele project was buitengewoon leerzaam. Ik kon lekker aan de slag met cURL, JSON, DOMDocuments en reguliere expressies. Het toetje was toch zeker het aanspannen van de OCR-API voor het analyseren van (waarschijnlijk) beschermde data.

Terug naar boven

Inhoudsopgave

Delen

Met de deel-knop van uw browser, of met onderstaande koppelingen deelt u deze pagina via sociale media of e-mail.

Atom-feed van FWiePs weblog

Artikelen


Categorieën

Doorzoek de onderstaande categorieën om de lijst met artikelen te filteren.


Terug naar boven