urls = $urls; } /** * Normalizes a feed's items: drops unusable and duplicate items and sorts newest first. * Items without a date keep their feed order after the dated ones. * * @param array $raw_items Raw items. * @param string $source Plain-text feed title. * @param int $now Current Unix time, used to reject dates far in the future. * @return array> */ public function normalize_items( array $raw_items, string $source, int $now ): array { $items = array(); $seen = array(); foreach ( array_slice( $raw_items, 0, self::MAX_ITEMS ) as $raw ) { $item = is_array( $raw ) ? $this->normalize_item( $raw, $source, $now ) : null; if ( null === $item || isset( $seen[ $item['id'] ] ) ) { continue; } $seen[ $item['id'] ] = true; $items[] = $item; } // usort is stable in PHP 8, so undated items keep feed order. usort( $items, static function ( array $a, array $b ): int { if ( 0 === $a['published'] || 0 === $b['published'] ) { return ( 0 === $a['published'] ) <=> ( 0 === $b['published'] ); } return $b['published'] <=> $a['published']; } ); return $items; } /** * Normalizes one item. * * @param array $raw Raw item. * @param string $source Plain-text feed title. * @param int $now Current Unix time. * @return array|null Null when the item has nothing displayable. */ public function normalize_item( array $raw, string $source, int $now ): ?array { $title_html = self::string_field( $raw, 'title' ); $summary_html = self::string_field( $raw, 'summary' ); $content_html = self::string_field( $raw, 'content' ); $guid = trim( self::string_field( $raw, 'guid' ) ); $link = trim( self::string_field( $raw, 'link' ) ); $title = self::plain_text( $title_html, self::MAX_TITLE ); $summary = self::plain_text( '' !== $summary_html ? $summary_html : $content_html, self::MAX_SUMMARY ); if ( '' === $title ) { $title = self::plain_text( $summary, 120 ); } if ( '' === $title ) { return null; } if ( $summary === $title ) { $summary = ''; } $url = $this->urls->article_url( $link ) ?? ''; return array( 'id' => substr( hash( 'sha256', '' !== $guid ? $guid : ( '' !== $link ? $link : $title ) ), 0, 20 ), 'title' => $title, 'summary' => $summary, 'url' => $url, 'image' => $this->pick_image( $raw, $summary_html . ' ' . $content_html ), 'source' => self::plain_text( $source, 120 ), 'published' => self::timestamp( $raw['date'] ?? null, $now ), ); } /** * Converts HTML or escaped text to safe plain text. * * Decodes entities exactly once, removes script and style blocks and every tag (before and * after decoding, since decoding can reveal tags), removes bidirectional and zero-width * control characters, collapses whitespace and truncates at a word boundary. * * @param string $html Input that may contain markup or entities. * @param int $max Maximum length in characters, including the ellipsis. */ public static function plain_text( string $html, int $max ): string { $text = self::scrub( $html ); $text = preg_replace( '#<(script|style)\b[^>]*>.*?#is', ' ', $text ) ?? ''; $text = preg_replace( '#<\s*(br|/p|/div|/li|/h[1-6])\b[^>]*>#i', ' ', $text ) ?? ''; // phpcs:disable WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- This class is deliberately WordPress-free so it can be unit tested. It also needs to strip twice around entity decoding. $text = strip_tags( $text ); $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = preg_replace( '#<(script|style)\b[^>]*>.*?#is', ' ', $text ) ?? ''; $text = strip_tags( $text ); // phpcs:enable $text = preg_replace( '/[\x{200B}\x{200E}\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}\x{FEFF}]/u', '', $text ) ?? ''; $text = preg_replace( '/[\p{Cc}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}\s]+/u', ' ', $text ) ?? ''; $text = trim( $text ); if ( $max > 1 && mb_strlen( $text ) > $max ) { $cut = mb_substr( $text, 0, $max - 1 ); $space = strrpos( $cut, ' ' ); if ( false !== $space && mb_strlen( substr( $cut, 0, $space ) ) > (int) ( $max * 0.6 ) ) { $cut = substr( $cut, 0, $space ); } $text = rtrim( $cut, " \t.,;:-" ) . '…'; } return $text; } /** * Picks the first usable image: explicit candidates first, then `` tags in the body. * * @param array $raw Raw item. * @param string $html Item body HTML. */ private function pick_image( array $raw, string $html ): string { $candidates = array(); foreach ( is_array( $raw['images'] ?? null ) ? $raw['images'] : array() as $candidate ) { if ( is_string( $candidate ) ) { $candidates[] = $candidate; } } if ( preg_match_all( '/]*?\bsrc\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', $html, $matches, PREG_SET_ORDER ) ) { foreach ( array_slice( $matches, 0, 3 ) as $match ) { $candidates[] = html_entity_decode( $match[1] . ( $match[2] ?? '' ) . ( $match[3] ?? '' ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } } foreach ( $candidates as $candidate ) { $valid = $this->urls->image_url( $candidate ); if ( null !== $valid ) { return $valid; } } return ''; } /** * Reads a string field, ignoring anything that is not a string. * * @param array $raw Raw item. * @param string $field Field name. */ private static function string_field( array $raw, string $field ): string { return is_string( $raw[ $field ] ?? null ) ? $raw[ $field ] : ''; } /** * Validates a timestamp: positive and not unreasonably far in the future. * * @param mixed $value Raw value. * @param int $now Current Unix time. */ private static function timestamp( $value, int $now ): int { if ( ! is_int( $value ) && ! ( is_string( $value ) && ctype_digit( $value ) ) ) { return 0; } $timestamp = (int) $value; return ( $timestamp > 0 && $timestamp <= $now + self::MAX_FUTURE_SKEW ) ? $timestamp : 0; } /** * Replaces invalid UTF-8 so later regular expressions with the `u` flag cannot fail. * * @param string $text Input. */ private static function scrub( string $text ): string { if ( 1 === preg_match( '//u', $text ) ) { return $text; } if ( function_exists( 'mb_scrub' ) ) { return mb_scrub( $text ); } return (string) iconv( 'UTF-8', 'UTF-8//IGNORE', $text ); } }