458 lines
12 KiB
PHP
458 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* SCT incident fetcher and parser: DATEX2 primary, RSS enricher/fallback.
|
|
*
|
|
* @package Fontdelbosc_Transit
|
|
*/
|
|
|
|
defined( 'ABSPATH' ) || exit;
|
|
|
|
/**
|
|
* Fetches and parses SCT incidents.
|
|
*/
|
|
class Fontdelbosc_Transit_Api_Sct {
|
|
|
|
public const DATEX2_URL = 'https://infocar.dgt.es/datex2/sct/SituationPublication/all/content.xml';
|
|
public const RSS_URL = 'https://www.gencat.cat/transit/opendata/incidenciesRSS.xml';
|
|
|
|
private const NS = 'http://datex2.eu/schema/1_0/1_0';
|
|
|
|
/**
|
|
* Fetch and parse incidents: DATEX2 for structure, RSS for text.
|
|
*
|
|
* @return array{incidents: array<int, array<string, mixed>>, source: string, publication_time: string}|null
|
|
*/
|
|
public static function get_incidents(): ?array {
|
|
$datex2 = self::fetch_datex2();
|
|
$rss = self::fetch_rss();
|
|
|
|
if ( null === $datex2 ) {
|
|
if ( null === $rss ) {
|
|
return null;
|
|
}
|
|
|
|
return array(
|
|
'incidents' => array_values( $rss['items'] ),
|
|
'source' => 'rss',
|
|
'publication_time' => $rss['publication_time'],
|
|
);
|
|
}
|
|
|
|
$by_reference = array();
|
|
|
|
if ( null !== $rss ) {
|
|
foreach ( $rss['items'] as $item ) {
|
|
$by_reference[ Fontdelbosc_Transit_Payload::str( $item['reference'] ?? null ) ?? '' ] = $item;
|
|
}
|
|
}
|
|
|
|
foreach ( $datex2['incidents'] as &$incident ) {
|
|
$match = $by_reference[ Fontdelbosc_Transit_Payload::str( $incident['reference'] ?? null ) ?? '' ] ?? null;
|
|
if ( null !== $match ) {
|
|
$incident['title'] = $match['title'];
|
|
$incident['description'] = $match['description'];
|
|
$incident['km'] = $match['km'];
|
|
$incident['link'] = $match['link'];
|
|
}
|
|
}
|
|
unset( $incident );
|
|
|
|
return array(
|
|
'incidents' => array_values( $datex2['incidents'] ),
|
|
'source' => 'datex2',
|
|
'publication_time' => $datex2['publication_time'],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch and parse the DATEX2 SituationPublication.
|
|
*
|
|
* @return array{incidents: array<int, array<string, mixed>>, publication_time: string}|null
|
|
*/
|
|
private static function fetch_datex2(): ?array {
|
|
$body = self::http_get( self::DATEX2_URL );
|
|
if ( null === $body ) {
|
|
return null;
|
|
}
|
|
|
|
$xml = self::parse_xml( $body );
|
|
if ( null === $xml ) {
|
|
return null;
|
|
}
|
|
|
|
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DATEX2 XML tag name.
|
|
$payload = $xml->payloadPublication;
|
|
if ( null === $payload ) {
|
|
return null;
|
|
}
|
|
|
|
$incidents = array();
|
|
$publication_time = '';
|
|
|
|
foreach ( $payload->children( self::NS ) as $child ) {
|
|
if ( 'situation' !== $child->getName() ) {
|
|
if ( 'publicationTime' === $child->getName() ) {
|
|
$publication_time = (string) $child;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
foreach ( $child->children( self::NS ) as $record ) {
|
|
if ( 'situationRecord' !== $record->getName() ) {
|
|
continue;
|
|
}
|
|
|
|
$incident = self::parse_record( $record );
|
|
if ( null !== $incident ) {
|
|
$incidents[] = $incident;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array(
|
|
'incidents' => $incidents,
|
|
'publication_time' => $publication_time,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse one DATEX2 situationRecord into a normalized incident.
|
|
*
|
|
* @param SimpleXMLElement $record Situation record element.
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private static function parse_record( SimpleXMLElement $record ): ?array {
|
|
$type = self::type_of( $record );
|
|
$attrs = $record->attributes();
|
|
|
|
$incident = array(
|
|
'id' => isset( $attrs['id'] ) ? (string) $attrs['id'] : '',
|
|
'reference' => self::child_value( $record, 'situationRecordCreationReference' ),
|
|
'type' => $type,
|
|
'status' => '',
|
|
'start_time' => '',
|
|
'end_time' => '',
|
|
'road_name' => '',
|
|
'town' => '',
|
|
'direction' => '',
|
|
'lat' => null,
|
|
'lon' => null,
|
|
'impact' => '',
|
|
'title' => '',
|
|
'description' => '',
|
|
'km' => null,
|
|
'link' => '',
|
|
);
|
|
|
|
$validity = self::child_value_nested( $record, array( 'validity', 'validityStatus' ) );
|
|
if ( null !== $validity ) {
|
|
$incident['status'] = $validity;
|
|
}
|
|
|
|
$start = self::child_value_nested( $record, array( 'validity', 'validityTimeSpecification', 'overallStartTime' ) );
|
|
if ( null !== $start ) {
|
|
$incident['start_time'] = $start;
|
|
}
|
|
|
|
$end = self::child_value_nested( $record, array( 'validity', 'validityTimeSpecification', 'overallEndTime' ) );
|
|
if ( null !== $end ) {
|
|
$incident['end_time'] = $end;
|
|
}
|
|
|
|
$impact = self::child_value_nested( $record, array( 'impact', 'impactOnTraffic' ) );
|
|
if ( null !== $impact ) {
|
|
$incident['impact'] = $impact;
|
|
}
|
|
|
|
self::parse_location( $record, $incident );
|
|
|
|
if ( '' === $incident['id'] && '' === $incident['road_name'] && '' === $incident['start_time'] ) {
|
|
return null;
|
|
}
|
|
|
|
return $incident;
|
|
}
|
|
|
|
/**
|
|
* Extract location data (road, town, direction, coordinates) from the record.
|
|
*
|
|
* @param SimpleXMLElement $record Situation record element.
|
|
* @param array<string, mixed> $incident Incident array, modified by reference.
|
|
*/
|
|
private static function parse_location( SimpleXMLElement $record, array &$incident ): void {
|
|
$tpeg = self::find_child_path(
|
|
$record,
|
|
array( 'groupOfLocations', 'locationContainedInGroup', 'tpeglinearLocation' )
|
|
);
|
|
|
|
if ( null === $tpeg ) {
|
|
return;
|
|
}
|
|
|
|
$direction = self::find_child( $tpeg, 'tpegDirection' );
|
|
if ( null !== $direction ) {
|
|
$incident['direction'] = trim( (string) $direction );
|
|
}
|
|
|
|
$from = self::find_child( $tpeg, 'from' );
|
|
if ( null !== $from ) {
|
|
self::parse_point( $from, $incident );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a TPEG point (road, town, coordinates).
|
|
*
|
|
* @param SimpleXMLElement $point TPEG point element.
|
|
* @param array<string, mixed> $incident Incident array, modified by reference.
|
|
*/
|
|
private static function parse_point( SimpleXMLElement $point, array &$incident ): void {
|
|
$coords = self::find_child( $point, 'pointCoordinates' );
|
|
|
|
if ( null !== $coords ) {
|
|
$lat = self::find_child( $coords, 'latitude' );
|
|
$lon = self::find_child( $coords, 'longitude' );
|
|
if ( null !== $lat && null !== $lon ) {
|
|
$incident['lat'] = (float) (string) $lat;
|
|
$incident['lon'] = (float) (string) $lon;
|
|
}
|
|
}
|
|
|
|
foreach ( $point->children( self::NS ) as $name ) {
|
|
if ( 'name' !== $name->getName() ) {
|
|
continue;
|
|
}
|
|
|
|
$type = self::find_child( $name, 'tpegDescriptorType' );
|
|
$value = self::find_child_path( $name, array( 'descriptor', 'value' ) );
|
|
|
|
if ( null === $type || null === $value ) {
|
|
continue;
|
|
}
|
|
|
|
$type_value = trim( (string) $type );
|
|
$name_value = trim( (string) $value );
|
|
|
|
if ( 'linkName' === $type_value && '' === $incident['road_name'] ) {
|
|
$incident['road_name'] = $name_value;
|
|
} elseif ( 'townName' === $type_value && '' === $incident['town'] ) {
|
|
$incident['town'] = $name_value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract the DATEX2 record type from the xsi:type attribute.
|
|
*
|
|
* @param SimpleXMLElement $record Situation record element.
|
|
* @return string
|
|
*/
|
|
private static function type_of( SimpleXMLElement $record ): string {
|
|
$attrs = $record->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 );
|
|
}
|
|
|
|
/**
|
|
* Fetch and parse the SCT RSS feed.
|
|
*
|
|
* @return array{items: array<int, array<string, mixed>>, publication_time: string}|null
|
|
*/
|
|
private static function fetch_rss(): ?array {
|
|
$body = self::http_get( self::RSS_URL );
|
|
if ( null === $body ) {
|
|
return null;
|
|
}
|
|
|
|
$xml = self::parse_xml( $body );
|
|
if ( null === $xml ) {
|
|
return null;
|
|
}
|
|
|
|
$items = array();
|
|
$channel = $xml->channel;
|
|
$publication_time = isset( $channel->pubDate ) ? (string) $channel->pubDate : ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- RSS tag name.
|
|
|
|
foreach ( $xml->channel->item as $item ) {
|
|
$guid = (string) $item->guid;
|
|
$items[] = array(
|
|
'reference' => '' !== $guid ? self::reference_from_guid( $guid ) : '',
|
|
'title' => trim( (string) $item->title ),
|
|
'description' => trim( (string) $item->description ),
|
|
'town' => self::town_from_description( (string) $item->description ),
|
|
'km' => self::km_from_description( (string) $item->description ),
|
|
'link' => (string) $item->link,
|
|
'pub_time' => (string) $item->pubDate, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- RSS tag name.
|
|
);
|
|
}
|
|
|
|
return array(
|
|
'items' => $items,
|
|
'publication_time' => $publication_time,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Derive the DATEX2 creation reference from an RSS guid (e.g. 151732601 → 1517326).
|
|
*
|
|
* @param string $guid RSS guid.
|
|
* @return string
|
|
*/
|
|
public static function reference_from_guid( string $guid ): string {
|
|
return '' === $guid ? '' : substr( $guid, 0, -2 );
|
|
}
|
|
|
|
/**
|
|
* Extract the town from an RSS description ("C-15 | CABRERA D' ANOIA | Sentit ...").
|
|
*
|
|
* @param string $description RSS item description.
|
|
* @return string
|
|
*/
|
|
public static function town_from_description( string $description ): string {
|
|
$parts = explode( '|', $description );
|
|
|
|
if ( count( $parts ) < 2 ) {
|
|
return '';
|
|
}
|
|
|
|
$town = trim( $parts[1] );
|
|
|
|
return '' === $town ? '' : $town;
|
|
}
|
|
|
|
/**
|
|
* Extract the kilometre point from an RSS description (e.g. "Punt km. 32.3-32.6").
|
|
*
|
|
* @param string $description RSS item description.
|
|
* @return string
|
|
*/
|
|
public static function km_from_description( string $description ): string {
|
|
if ( 1 === preg_match( '/km\.\s*([0-9]+(?:\.[0-9]+)?)/u', $description, $m ) ) {
|
|
return $m[1];
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Parse an XML string with error suppression, or null on failure.
|
|
*
|
|
* @param string $body Raw XML body.
|
|
* @return SimpleXMLElement|null
|
|
*/
|
|
private static function parse_xml( string $body ): ?SimpleXMLElement {
|
|
$previous = libxml_use_internal_errors( true );
|
|
|
|
try {
|
|
$xml = simplexml_load_string( $body );
|
|
} finally {
|
|
libxml_clear_errors();
|
|
libxml_use_internal_errors( $previous );
|
|
}
|
|
|
|
return false === $xml ? null : $xml;
|
|
}
|
|
|
|
/**
|
|
* Get the string value of a direct namespaced child.
|
|
*
|
|
* @param SimpleXMLElement $element Parent element.
|
|
* @param string $name Child name.
|
|
* @return string|null
|
|
*/
|
|
private static function child_value( SimpleXMLElement $element, string $name ): ?string {
|
|
$child = self::find_child( $element, $name );
|
|
if ( null === $child ) {
|
|
return null;
|
|
}
|
|
|
|
$value = trim( (string) $child );
|
|
|
|
return '' === $value ? null : $value;
|
|
}
|
|
|
|
/**
|
|
* Find the first namespaced child element by name.
|
|
*
|
|
* @param SimpleXMLElement $element Parent element.
|
|
* @param string $name Child name.
|
|
* @return SimpleXMLElement|null
|
|
*/
|
|
private static function find_child( SimpleXMLElement $element, string $name ): ?SimpleXMLElement {
|
|
foreach ( $element->children( self::NS ) 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.
|
|
* @return SimpleXMLElement|null
|
|
*/
|
|
private static function find_child_path( SimpleXMLElement $element, array $path ): ?SimpleXMLElement {
|
|
$current = $element;
|
|
|
|
foreach ( $path as $name ) {
|
|
$child = self::find_child( $current, $name );
|
|
if ( null === $child ) {
|
|
return null;
|
|
}
|
|
|
|
$current = $child;
|
|
}
|
|
|
|
return $current;
|
|
}
|
|
|
|
/**
|
|
* Walk a path of namespaced children and return the leaf string value.
|
|
*
|
|
* @param SimpleXMLElement $element Parent element.
|
|
* @param array<int, string> $path Child names, outermost first.
|
|
* @return string|null
|
|
*/
|
|
private static function child_value_nested( SimpleXMLElement $element, array $path ): ?string {
|
|
$leaf = self::find_child_path( $element, $path );
|
|
if ( null === $leaf ) {
|
|
return null;
|
|
}
|
|
|
|
$value = trim( (string) $leaf );
|
|
|
|
return '' === $value ? null : $value;
|
|
}
|
|
}
|