This commit is contained in:
Javier Casares 2026-10-12 15:30:04 +00:00
commit 1ef08bd731
27 changed files with 4854 additions and 0 deletions

View file

@ -0,0 +1,458 @@
<?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;
}
}