query( './/h2 | .//h3', $root ); $max_report = 10; foreach ( $node_list as $node ) { $text = self::clean( $node->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property. if ( '' === $text ) { continue; } $is_question = self::is_question( $text ); if ( $is_question ) { ++$questions; } elseif ( count( $headings ) < $max_report ) { // Only the ones a rewrite would touch are worth carrying. $headings[] = $text; } } $paragraphs = 0; $long_paragraphs = array(); $long_sentences = array(); $pronouns = array(); $summary_words = 0; $summary = false; foreach ( $xpath->query( './/p', $root ) as $node ) { $text = self::clean( $node->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property. if ( '' === $text ) { continue; } $words = self::words( $text ); if ( $words < 4 ) { // Captions, bylines, "Share this" — not prose. continue; } ++$paragraphs; // The first real paragraph is the one an engine reads as the // answer to "what is this page". if ( 1 === $paragraphs ) { $summary_words = $words; $summary = ( $words >= self::PARAGRAPH_MIN && $words <= self::SUMMARY_MAX ); } if ( $words > self::PARAGRAPH_MAX && count( $long_paragraphs ) < $max_report ) { $long_paragraphs[] = array( 'words' => $words, 'excerpt' => self::excerpt( $text ), ); } $longest = self::longest_sentence( $text ); if ( $longest['words'] > self::SENTENCE_MAX && count( $long_sentences ) < $max_report ) { $long_sentences[] = array( 'words' => $longest['words'], 'excerpt' => self::excerpt( $longest['text'] ), ); } $opener = self::pronoun_opener( $text ); if ( '' !== $opener && count( $pronouns ) < $max_report ) { $pronouns[] = array( 'word' => $opener, 'excerpt' => self::excerpt( $text ), ); } } return array( 'headings' => $headings, 'questions' => $questions, 'heading_total' => $questions + count( $headings ), 'paragraphs' => $paragraphs, 'long_paragraphs' => $long_paragraphs, 'long_sentences' => $long_sentences, 'pronoun_openers' => $pronouns, 'lists' => $xpath->query( './/ul | .//ol', $root )->length, 'tables' => $xpath->query( './/table', $root )->length, 'summary' => $summary, 'summary_words' => $summary_words, ); } /** * The element the page's own content lives in. * * Headers, navigation and footers are on every URL; counting their * paragraphs and links would make every page look alike. Falls back to * body when a theme marks nothing, which is the honest failure — a page * with no content landmark really is harder to chunk. * * @param DOMXPath $xpath Parsed document. * @return DOMNode */ public static function content_root( DOMXPath $xpath ) { $candidates = array( '//main', '//*[@role="main"]', '//article', '//*[contains(concat(" ", normalize-space(@class), " "), " entry-content ")]', '//*[@id="content"]', ); /** * Filter the XPath queries used to find the main content region. * * @param string[] $candidates Ordered queries; first match wins. */ $candidates = apply_filters( 'oorank_content_root_queries', $candidates ); foreach ( $candidates as $query ) { $node = $xpath->query( $query )->item( 0 ); if ( $node ) { return $node; } } $body = $xpath->query( '//body' )->item( 0 ); return $body ? $body : $xpath->document->documentElement; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property. } /** * Whether a heading states a question. * * @param string $text Heading text. * @return bool */ public static function is_question( $text ) { if ( '?' === substr( trim( $text ), -1 ) ) { return true; } $words = array( 'how', 'what', 'why', 'when', 'where', 'who', 'which', 'can', 'do', 'does', 'is', 'are', 'should', 'will', 'was' ); /** * Filter the words that make a heading read as a question. * * @param string[] $words Lowercase leading words. */ $words = apply_filters( 'oorank_question_words', $words ); $first = strtolower( (string) strtok( trim( $text ), " \t\n" ) ); $first = preg_replace( '/[^\p{L}]+/u', '', $first ); return in_array( $first, $words, true ); } /** * The pronoun a paragraph opens with, if any. * * A paragraph starting "It also means…" cannot be lifted: the engine has * no antecedent for "it" once the paragraph is on its own. * * @param string $text Paragraph text. * @return string The opener, or '' when the paragraph stands alone. */ public static function pronoun_opener( $text ) { $words = array( 'it', 'this', 'that', 'they', 'them', 'these', 'those', 'he', 'she', 'there', 'such' ); /** * Filter the openers treated as context-dependent. * * @param string[] $words Lowercase leading words. */ $words = apply_filters( 'oorank_pronoun_openers', $words ); $first = strtolower( (string) strtok( trim( $text ), " \t\n" ) ); $clean = preg_replace( '/[^\p{L}]+/u', '', $first ); return in_array( $clean, $words, true ) ? $clean : ''; } /** * The longest sentence in a paragraph. * * @param string $text Paragraph text. * @return array{words:int, text:string} */ private static function longest_sentence( $text ) { $parts = preg_split( '/(?<=[.!?])\s+/u', $text ); $longest = array( 'words' => 0, 'text' => '', ); foreach ( (array) $parts as $part ) { $count = self::words( $part ); if ( $count > $longest['words'] ) { $longest = array( 'words' => $count, 'text' => $part, ); } } return $longest; } /** * Word count of a string. * * @param string $text Text. * @return int */ private static function words( $text ) { $text = trim( $text ); return ( '' === $text ) ? 0 : count( preg_split( '/\s+/u', $text ) ); } /** * Collapse whitespace. * * @param string $text Raw text. * @return string */ private static function clean( $text ) { return trim( preg_replace( '/\s+/u', ' ', (string) $text ) ); } /** * A short, safe excerpt for display in the admin. * * @param string $text Text. * @return string */ private static function excerpt( $text ) { return wp_html_excerpt( self::clean( $text ), 110, '…' ); } }