>, 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>, 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|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 $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 $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>, 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 $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 $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; } }