fontdelbosc-transit/includes/class-fontdelbosc-transit-api-zbe.php
2026-10-12 15:30:04 +00:00

446 lines
12 KiB
PHP

<?php
/**
* DGT NAP ZBE fetcher and parser (DATEX2 v3 controlled zones).
*
* @package Fontdelbosc_Transit
*/
defined( 'ABSPATH' ) || exit;
/**
* Fetches and parses Low Emission Zone data.
*/
class Fontdelbosc_Transit_Api_Zbe {
public const BASE_URL = 'https://nap.dgt.es/datex2/v3/dgt/zbe/ControledZonePublication/';
private const NS_CONZ = 'http://levelC/schema/3/controlledZone';
private const NS_COM = 'http://levelC/schema/3/common';
private const NS_TRA = 'http://levelC/schema/3/trafficRegulation';
/**
* Catalan zones available at the NAP index, pre-selected by default.
*
* @var array<int, string>
*/
public const DEFAULT_CATALAN_ZONES = array(
'Barcelona',
'CerdanyolaDelValles',
'ElPratDeLlobregat',
'Gava',
'Girona',
'Granollers',
'Lleida',
'Manlleu',
'Mataro',
'Reus',
'RondasDeBarcelona',
'Rubi',
'Sabadell',
'SantBoiDeLlobregat',
'SantCugatDelValles',
'SantJoanDespi',
'Tarragona',
'Terrasa',
'Viladecans',
);
/**
* Sanitize a zone slug keeping its case (NAP endpoints are case-sensitive).
*
* @param string $slug Raw slug.
* @return string
*/
public static function sanitize_slug( string $slug ): string {
return preg_replace( '/[^A-Za-z0-9\-]/', '', $slug ) ?? '';
}
/**
* Fetch the zone index (HTML directory listing) and return zone slugs.
*
* @return array<int, string>|null
*/
public static function get_index(): ?array {
$body = self::http_get( self::BASE_URL );
if ( null === $body ) {
return null;
}
if ( 0 === preg_match_all( '/href="([A-Za-z0-9\-]+)\.xml"/', $body, $m ) ) {
return null;
}
$slugs = array_values( array_unique( $m[1] ) );
sort( $slugs );
return $slugs;
}
/**
* Fetch and parse one zone by slug.
*
* @param string $slug Zone slug (endpoint file name without .xml).
* @return array<string, mixed>|null
*/
public static function get_zone( string $slug ): ?array {
$body = self::http_get( self::BASE_URL . rawurlencode( $slug ) . '.xml' );
if ( null === $body ) {
return null;
}
$previous = libxml_use_internal_errors( true );
try {
$xml = simplexml_load_string( $body );
} finally {
libxml_clear_errors();
libxml_use_internal_errors( $previous );
}
if ( false === $xml ) {
return null;
}
$publication_time = '';
foreach ( $xml->children( self::NS_COM ) as $child ) {
if ( 'publicationTime' === $child->getName() ) {
$publication_time = (string) $child;
}
}
$table = self::find_child( $xml, 'controlledZoneTable', self::NS_CONZ );
if ( null === $table ) {
return null;
}
$zones = array();
foreach ( $table->children( self::NS_CONZ ) as $zone ) {
if ( 'controlledZone' !== $zone->getName() ) {
continue;
}
$parsed = self::parse_zone( $zone );
if ( null !== $parsed ) {
$parsed['publication_time'] = $publication_time;
$zones[] = $parsed;
}
}
if ( array() === $zones ) {
return null;
}
$result = $zones[0];
if ( count( $zones ) > 1 ) {
$result['extra_zones'] = array_slice( $zones, 1 );
}
return $result;
}
/**
* Parse one controlledZone element into a normalized zone array.
*
* @param SimpleXMLElement $zone Controlled zone element.
* @return array<string, mixed>|null
*/
private static function parse_zone( SimpleXMLElement $zone ): ?array {
$result = array(
'slug' => '',
'name' => '',
'description' => '',
'url' => '',
'status' => '',
'restriction_type' => '',
'sticker_negate' => false,
'stickers' => array(),
'periods' => array(),
'publication_time' => '',
);
$result['name'] = self::localized_value( $zone, 'name' );
$description = self::localized_value( $zone, 'controlledZoneDescription' );
if ( null !== $description ) {
$result['description'] = $description;
}
$url = self::direct_value( $zone, 'urlForFurtherInformation' );
if ( null !== $url ) {
$result['url'] = $url;
}
$status = self::direct_value( $zone, 'status' );
if ( null !== $status ) {
$result['status'] = $status;
}
$order = self::find_child( $zone, 'trafficRegulationOrder' );
if ( null === $order ) {
return null;
}
foreach ( $order->children( self::NS_TRA ) as $regulation ) {
if ( 'trafficRegulation' !== $regulation->getName() ) {
continue;
}
self::parse_regulation( $regulation, $result );
}
return $result;
}
/**
* Extract restriction type, sticker conditions and periods from a regulation.
*
* @param SimpleXMLElement $regulation Traffic regulation element.
* @param array<string, mixed> $result Zone array, modified by reference.
*/
private static function parse_regulation( SimpleXMLElement $regulation, array &$result ): void {
$type = self::find_child( $regulation, 'typeOfRegulation', self::NS_TRA );
if ( null !== $type ) {
$restriction = self::direct_value( $type, 'accessRestrictionType', self::NS_TRA );
if ( null !== $restriction && '' === $result['restriction_type'] ) {
$result['restriction_type'] = $restriction;
}
}
foreach ( $regulation->children( self::NS_TRA ) as $child ) {
if ( 'condition' !== $child->getName() ) {
continue;
}
if ( 'ConditionSet' === self::type_of( $child ) ) {
self::parse_condition_set( $child, $result );
}
}
}
/**
* Parse the conditions inside a ConditionSet (stickers and validity periods).
*
* ConditionSets can nest (outer set with location, sticker set, validity),
* so this method recurses into nested sets.
*
* @param SimpleXMLElement $set Condition set element.
* @param array<string, mixed> $result Zone array, modified by reference.
*/
private static function parse_condition_set( SimpleXMLElement $set, array &$result ): void {
$negate = self::direct_value( $set, 'negate', self::NS_TRA );
if ( 'true' === $negate ) {
$result['sticker_negate'] = true;
}
foreach ( $set->children( self::NS_TRA ) as $child ) {
if ( 'conditions' !== $child->getName() ) {
continue;
}
$type_name = self::type_of( $child );
if ( 'NonCodableCondition' === $type_name ) {
$condition = self::find_child( $child, 'condition' );
if ( null === $condition ) {
continue;
}
$values = self::find_child_path( $condition, array( 'values', 'value' ), self::NS_COM );
if ( null !== $values ) {
$value = trim( (string) $values );
if ( '' !== $value ) {
$stickers = Fontdelbosc_Transit_Payload::arr( $result['stickers'] ?? null );
$stickers[] = $value;
$result['stickers'] = $stickers;
}
}
} elseif ( 'ConditionSet' === $type_name ) {
self::parse_condition_set( $child, $result );
} elseif ( 'ValidityCondition' === $type_name ) {
self::parse_validity( $child, $result );
}
}
}
/**
* Parse validity periods out of a ValidityCondition.
*
* @param SimpleXMLElement $element ValidityCondition element.
* @param array<string, mixed> $result Zone array, modified by reference.
*/
private static function parse_validity( SimpleXMLElement $element, array &$result ): void {
$implemented = self::find_child( $element, 'implementedValidity', self::NS_TRA );
if ( null === $implemented ) {
return;
}
$spec = self::find_child( $implemented, 'validityTimeSpecification', self::NS_COM );
if ( null === $spec ) {
return;
}
foreach ( $spec->children( self::NS_COM ) as $valid_period ) {
if ( 'validPeriod' !== $valid_period->getName() ) {
continue;
}
$days = self::find_child_path( $valid_period, array( 'periodName', 'values', 'value' ), self::NS_COM );
$hours = self::find_child( $valid_period, 'recurringTimePeriodOfDay', self::NS_COM );
if ( null === $days || null === $hours ) {
continue;
}
$start = self::direct_value( $hours, 'startTimeOfPeriod', self::NS_COM );
$end = self::direct_value( $hours, 'endTimeOfPeriod', self::NS_COM );
$periods = Fontdelbosc_Transit_Payload::arr( $result['periods'] ?? null );
$periods[] = array(
'days' => trim( (string) $days ),
'start' => null !== $start ? self::short_time( $start ) : '',
'end' => null !== $end ? self::short_time( $end ) : '',
);
$result['periods'] = $periods;
}
}
/**
* Extract the local part of the xsi:type attribute.
*
* @param SimpleXMLElement $element Element with an xsi:type attribute.
* @return string
*/
private static function type_of( SimpleXMLElement $element ): string {
$attrs = $element->attributes( 'http://www.w3.org/2001/XMLSchema-instance' );
$type = isset( $attrs['type'] ) ? (string) $attrs['type'] : '';
$pos = strrpos( $type, ':' );
return false === $pos ? $type : substr( $type, $pos + 1 );
}
/**
* Shorten an xs:time value (07:00:00Z) to HH:MM.
*
* @param string $time Raw time.
* @return string
*/
private static function short_time( string $time ): string {
return strlen( $time ) >= 5 ? substr( $time, 0, 5 ) : $time;
}
/**
* Get a localized com:value from a conz element (name, description...).
*
* @param SimpleXMLElement $element Parent element.
* @param string $child_name Child name in the conz namespace.
* @return string|null
*/
private static function localized_value( SimpleXMLElement $element, string $child_name ): ?string {
$child = self::find_child( $element, $child_name );
if ( null === $child ) {
return null;
}
$values = self::find_child_path( $child, array( 'values', 'value' ), self::NS_COM );
if ( null === $values ) {
return null;
}
$value = trim( (string) $values );
return '' === $value ? null : $value;
}
/**
* Get the direct (unprefixed-value) text of a namespaced child.
*
* @param SimpleXMLElement $element Parent element.
* @param string $child_name Child name.
* @param string|null $ns Namespace URI, null for the conz namespace.
* @return string|null
*/
private static function direct_value( SimpleXMLElement $element, string $child_name, ?string $ns = null ): ?string {
$child = self::find_child( $element, $child_name, $ns ?? self::NS_CONZ );
if ( null === $child ) {
return null;
}
$value = trim( (string) $child );
return '' === $value ? null : $value;
}
/**
* Find the first child element by name within a namespace.
*
* @param SimpleXMLElement $element Parent element.
* @param string $name Child name.
* @param string|null $ns Namespace URI, null for the conz namespace.
* @return SimpleXMLElement|null
*/
private static function find_child( SimpleXMLElement $element, string $name, ?string $ns = null ): ?SimpleXMLElement {
$uri = $ns ?? self::NS_CONZ;
foreach ( $element->children( $uri ) as $child ) {
if ( $name === $child->getName() ) {
return $child;
}
}
return null;
}
/**
* Walk a path of namespaced children and return the last element.
*
* @param SimpleXMLElement $element Parent element.
* @param array<int, string> $path Child names, outermost first.
* @param string|null $ns Namespace URI, null for the conz namespace.
* @return SimpleXMLElement|null
*/
private static function find_child_path( SimpleXMLElement $element, array $path, ?string $ns = null ): ?SimpleXMLElement {
$current = $element;
foreach ( $path as $name ) {
$child = self::find_child( $current, $name, $ns );
if ( null === $child ) {
return null;
}
$current = $child;
}
return $current;
}
/**
* Perform an HTTP GET and return the body, or null on failure.
*
* @param string $url Endpoint URL.
* @return string|null
*/
private static function http_get( string $url ): ?string {
$response = wp_remote_get(
$url,
array(
'timeout' => 15,
'headers' => array(
'Accept' => 'application/xml, text/xml',
),
)
);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$body = wp_remote_retrieve_body( $response );
return '' === $body ? null : $body;
}
}