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="{"config":{"alwaysShowOpeningHours":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
, DOMDocument
s en reguliere expressies. Het toetje was toch zeker het
aanspannen van de OCR-API voor het analyseren van (waarschijnlijk) beschermde
data.