array( 'this', 'that', 'these', 'those', 'as mentioned', 'as noted', 'as we', 'as you can see', 'however', 'above' ), 'hr' => array( 'to', 'ovo', 'ono', 'oni', 'kao što je spomenuto', 'kao što smo', 'međutim', 'također' ), 'de' => array( 'dies', 'diese', 'dieser', 'dieses', 'wie erwähnt', 'wie oben', 'außerdem', 'jedoch' ), ); /** Verbs that make a first sentence definitional ("X is …", "The best … are …"). */ const DEFINITIONAL = array( 'en' => '/(? '/(? '/(? '/^(what|why|how|when|where|who|which|can|do|does|is|are|should|will)(?![\p{L}\p{N}])/iu', 'hr' => '/^(što|sto|zašto|kako|kada|gdje|tko|koji|koja|koje|može|mogu|je li|jesu li|trebam|treba)(?![\p{L}\p{N}])/iu', 'de' => '/^(was|warum|wie|wann|wo|wer|welche|welcher|welches|kann|können|ist|sind|soll)(?![\p{L}\p{N}])/iu', ); /** Language that words compare things in, so a table would help. */ const COMPARISON = array( 'en' => '/(? '/(? '/(?]*>.*?<\/\1>/is', ' ', (string) $html ); $t = preg_replace( '//i', ' ', $t ); $t = html_entity_decode( wp_strip_all_tags( $t ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); return trim( preg_replace( '/\s+/u', ' ', $t ) ); } /** * Words of a text. * * @param string $text Text. * @return string[] */ public static function words( $text ) { preg_match_all( "/[\p{L}\p{N}][\p{L}\p{N}'’\-]*/u", (string) $text, $m ); return $m[0]; } /** * Sentences of a text. * * @param string $text Text. * @return string[] */ public static function sentences( $text ) { $s = preg_split( '/(?<=[.!?])\s+(?=[\p{Lu}\p{N}"„“])/u', trim( (string) $text ) ); return array_values( array_filter( (array) $s, 'strlen' ) ); } /** * The blocks of a page: headings and text passages, in order. * * @param string $html HTML. * @return array */ public static function blocks( $html ) { preg_match_all( '/<(h[1-6]|p|li|blockquote|dd|dt|figcaption|td)\b[^>]*>(.*?)<\/\1>/is', (string) $html, $m, PREG_SET_ORDER ); $out = array(); foreach ( $m as $b ) { $tag = strtolower( $b[1] ); $text = self::text( $b[2] ); if ( '' === $text ) { continue; } $heading = 'h' === $tag[0]; $out[] = array( 'type' => $heading ? 'heading' : 'text', 'level' => $heading ? (int) $tag[1] : 0, 'text' => $text, ); } return $out; } /** * Whether a heading is a question. * * @param string $text Heading. * @param string $lang en | hr | de. */ public static function is_question( $text, $lang ) { return '?' === substr( rtrim( $text ), -1 ) || (bool) preg_match( self::QUESTION_WORDS[ $lang ], ltrim( $text ) ); } /** * Specific facts in a text: numbers and proper nouns (capitalised words that don't start a * sentence), plus currency and unit symbols. * * @param string $text Text. */ public static function facts( $text ) { $n = 0; foreach ( self::sentences( $text ) as $s ) { $w = self::words( $s ); foreach ( $w as $i => $word ) { if ( preg_match( '/\p{N}/u', $word ) ) { ++$n; } elseif ( $i > 0 && preg_match( '/^\p{Lu}/u', $word ) ) { ++$n; } } } return $n + preg_match_all( '/[€$£%]|(? 6 ) { ++$long; } } $lix = $nw / $ns + 100 * $long / $nw; return round( max( 0, ( $lix - 10 ) / 3 ), 1 ); } /** * Quotability of one passage, 0-100, from the spec §4.2 weights. * * @param array $p Passage: text, pos (0-1), under_question. * @param array $w Weights (GetReport_Registry::quotability()). * @param string $lang en | hr | de. * @return array{score:int, features:array} */ public static function quotability( $p, $w, $lang ) { $words = self::words( $p['text'] ); $nw = max( 1, count( $words ) ); $first = self::sentences( $p['text'] )[0] ?? $p['text']; $grade = self::grade( $p['text'], $lang ); $best = $w['readability_best_grade'] ?? array( 12, 16 ); $worst = (float) ( $w['readability_penalty_above'] ?? 19 ); $target = (float) ( $w['entity_density_target'] ?? 0.2 ); $lower = mb_strtolower( $p['text'] ); $ana = false; foreach ( self::ANAPHORA[ $lang ] as $a ) { if ( 0 === strpos( $lower, $a . ' ' ) || 0 === strpos( $lower, $a . ',' ) ) { $ana = true; break; } } $f = array( 'position_first_third' => $p['pos'] < 0.33 ? 1.0 : 0.0, 'definitional_form' => ( count( self::words( $first ) ) <= 40 && preg_match( self::DEFINITIONAL[ $lang ], $first ) ) ? 1.0 : 0.0, 'under_question_heading' => ! empty( $p['under_question'] ) ? 1.0 : 0.0, 'fact_density' => min( 1.0, self::facts( $p['text'] ) / $nw / $target ), 'self_contained' => $ana ? 0.0 : 1.0, 'readability' => $grade <= $best[1] ? 1.0 : ( $grade >= $worst ? 0.0 : ( $worst - $grade ) / ( $worst - $best[1] ) ), 'freshness_cue' => preg_match( self::FRESHNESS, $p['text'] ) ? 1.0 : 0.0, ); $score = 0.0; foreach ( $f as $k => $v ) { $score += ( $w[ $k ] ?? 0 ) * $v; } return array( 'score' => (int) round( 100 * $score ), 'features' => $f, ); } /** * How well a passage matches a question: the share of the question's content words in it. * * @param string $text Passage. * @param string[] $terms Question terms (lowercase, stop words removed). */ public static function match( $text, $terms ) { if ( ! $terms ) { return 0.0; } $have = array_map( 'mb_strtolower', self::words( $text ) ); $hit = 0; foreach ( $terms as $t ) { // A shared stem counts, so "ceramics" matches "ceramic" and "keramiku" matches "keramika". $stem = mb_substr( $t, 0, max( 4, mb_strlen( $t ) - 2 ) ); foreach ( $have as $w ) { if ( $w === $t || ( mb_strlen( $stem ) >= 4 && 0 === strpos( $w, $stem ) ) ) { ++$hit; break; } } } return $hit / count( $terms ); } /** * Content words of a question. * * @param string $q Question. * @return string[] */ public static function terms( $q ) { $out = array(); foreach ( self::words( $q ) as $w ) { $w = mb_strtolower( $w ); if ( mb_strlen( $w ) > 2 && ! in_array( $w, self::STOP, true ) ) { $out[] = $w; } } return array_values( array_unique( $out ) ); } /** * The full analysis of one page. * * @param array $doc html, title, slug, question, lang, post_type, modified (timestamp), now, * stale_months, and site-level facts in 'site' (see GetReport_Audit::site_facts()). * @return array{score:int, subs:array, fails:string[], passed:string[], passages:array, quotability:int, words:int, grade:float, answer_pos:float|null, details:array} */ public static function analyze( $doc ) { $lang = self::lang( $doc['lang'] ?? 'en' ); $w = GetReport_Registry::quotability(); $blocks = self::blocks( $doc['html'] ); $texts = array_values( array_filter( $blocks, function ( $b ) { return 'text' === $b['type']; } ) ); $all = implode( ' ', array_column( $texts, 'text' ) ); $total = max( 1, count( self::words( $all ) ) ); // Passages with their position (share of words before them) and heading context. $passages = array(); $before = 0; $question = false; $headings = array(); foreach ( $blocks as $b ) { if ( 'heading' === $b['type'] ) { $question = self::is_question( $b['text'], $lang ); if ( $b['level'] >= 2 && $b['level'] <= 3 ) { $headings[] = $b['text']; } continue; } $n = count( self::words( $b['text'] ) ); if ( $n >= 8 ) { $p = array( 'text' => $b['text'], 'pos' => $before / $total, 'under_question' => $question, ); $q = self::quotability( $p, $w, $lang ); $passages[] = $p + $q; } $before += $n; } $site = $doc['site'] ?? array(); $fails = array(); $ran = array(); $details = array(); $check = function ( $id, $failed, $detail = '' ) use ( &$fails, &$ran, &$details ) { $ran[] = $id; if ( $failed ) { $fails[] = $id; if ( '' !== $detail ) { $details[ $id ] = $detail; } } }; // Answer quality. $question_text = trim( (string) ( $doc['question'] ?? '' ) ); $terms = self::terms( '' !== $question_text ? $question_text : (string) ( $doc['title'] ?? '' ) ); $answer_pos = null; if ( $terms && $passages ) { $best = null; foreach ( $passages as $p ) { $m = self::match( $p['text'], $terms ); if ( null === $best || $m > $best[0] + 0.0001 ) { $best = array( $m, $p['pos'] ); } } if ( $best[0] > 0 ) { $answer_pos = $best[1]; /* translators: %d: percent of the page. */ $check( 'answer-not-first-third', $best[1] > 0.33, sprintf( __( 'The best answer starts at %d %% of the page.', 'getreport' ), (int) round( 100 * $best[1] ) ) ); } } if ( $passages ) { $first3 = array_slice( $passages, 0, 3 ); $check( 'no-definitional-sentence', 0 === count( array_filter( array_column( array_column( $first3, 'features' ), 'definitional_form' ) ) ) ); } if ( count( $headings ) >= 2 ) { $q = count( array_filter( $headings, function ( $h ) use ( $lang ) { return self::is_question( $h, $lang ); } ) ); /* translators: 1: question headings, 2: all headings. */ $check( 'no-question-headings', 0 === $q, sprintf( __( '%1$d of %2$d headings are questions.', 'getreport' ), $q, count( $headings ) ) ); } if ( $total >= 80 ) { $check( 'low-fact-density', self::facts( $all ) / $total < 0.05 ); } if ( count( $passages ) >= 2 ) { $dependent = array_filter( $passages, function ( $p ) { return 0.0 === $p['features']['self_contained']; } ); $first = reset( $dependent ); $check( 'passages-not-self-contained', count( $dependent ) / count( $passages ) >= 0.25, $first ? '“' . wp_html_excerpt( $first['text'], 40, '…' ) . '”' : '' ); } $grade = self::grade( $all, $lang ); if ( $total >= 50 ) { $check( 'reading-level-too-hard', $grade > (float) ( $w['readability_penalty_above'] ?? 19 ) ); } $slug = (string) ( $doc['slug'] ?? '' ); if ( '' !== $slug ) { $check( 'opaque-url-slug', (bool) preg_match( '/^(\d+|(p|page|post|untitled|auto-draft)-?\d*)$/', $slug ) || ! preg_match( '/\p{L}{3,}/u', urldecode( $slug ) ) ); } $check( 'no-tables-or-lists', preg_match( self::COMPARISON[ $lang ], $all ) && ! preg_match( '/<(table|ul|ol)\b/i', $doc['html'] ) ); if ( 'product' !== ( $doc['post_type'] ?? '' ) ) { /* translators: %d: number of words. */ $check( 'thin-content', $total < 150, sprintf( __( '%d words.', 'getreport' ), $total ) ); } // Freshness and trust. if ( isset( $site['shows_date'] ) ) { $check( 'no-visible-date', ! $site['shows_date'] ); } if ( ! empty( $doc['modified'] ) ) { $months = (int) floor( ( (int) ( $doc['now'] ?? time() ) - (int) $doc['modified'] ) / ( 30.44 * DAY_IN_SECONDS ) ); /* translators: %d: months. */ $check( 'stale-content', $months >= (int) ( $doc['stale_months'] ?? 18 ), sprintf( __( 'Last changed %d months ago.', 'getreport' ), $months ) ); } if ( isset( $site['shows_author'] ) ) { $check( 'no-author', ! $site['shows_author'] ); } if ( preg_match_all( '/\p{N}+/u', $all ) >= 3 ) { $home = strtolower( (string) ( $site['host'] ?? '' ) ); $ext = false; if ( preg_match_all( '/]*href=["\']https?:\/\/([^\/"\':]+)/i', $doc['html'], $links ) ) { foreach ( $links[1] as $h ) { $h = strtolower( $h ); if ( ! in_array( $home, array( $h, 'www.' . $h ), true ) && 'www.' . $home !== $h ) { $ext = true; break; } } } $check( 'no-outbound-sources', ! $ext ); } if ( isset( $site['about_contact'] ) ) { $check( 'no-about-contact', ! $site['about_contact'] ); } // Access, readable without JavaScript, entity: site-level facts, per page where it matters. if ( isset( $site['robots_blocked'] ) ) { $check( 'ai-bot-robots-blocked', (bool) $site['robots_blocked'], (string) ( $site['robots_detail'] ?? '' ) ); } if ( isset( $doc['noindex'] ) ) { $check( 'noindex-or-x-robots', (bool) $doc['noindex'] ); } if ( isset( $doc['nojs_missing'] ) && null !== $doc['nojs_missing'] ) { /* translators: %d: percent of the words. */ $check( 'nojs-main-content-missing', $doc['nojs_missing'] >= 0.4, sprintf( __( '%d %% of the text is missing from the HTML crawlers get.', 'getreport' ), (int) round( 100 * $doc['nojs_missing'] ) ) ); } if ( isset( $site['same_as'] ) ) { $check( 'missing-sameas', ! $site['same_as'] ); } if ( isset( $site['schema'] ) ) { $check( 'schema-missing', ! $site['schema'] ); } if ( isset( $site['llms_txt'] ) ) { $check( 'llms-txt-missing', ! $site['llms_txt'] ); } $scores = self::score( $ran, $fails ); $quote = self::page_quotability( $passages ); return array( 'score' => $scores['score'], 'subs' => $scores['subs'], 'fails' => $fails, 'passed' => array_values( array_diff( $ran, $fails ) ), 'passages' => $passages, 'quotability' => $quote, 'words' => $total, 'grade' => $grade, 'answer_pos' => $answer_pos, 'details' => $details, ); } /** * A page's quotability: the mean of its three best passages (what an AI would lift). * * @param array $passages Passages with a score. */ public static function page_quotability( $passages ) { if ( ! $passages ) { return 0; } $s = array_column( $passages, 'score' ); rsort( $s ); $top = array_slice( $s, 0, 3 ); return (int) round( array_sum( $top ) / count( $top ) ); } /** * Readiness score and sub-scores from the checks that ran (spec §5.1): each sub-score is the * share of its check weight that passed; the score is the weighted mean of the sub-scores that * had checks; weak-evidence checks take off at most 2 points in total. * * @param string[] $ran Checks that ran. * @param string[] $fails Checks that failed. * @return array{score:int, subs:array} */ public static function score( $ran, $fails ) { $checks = GetReport_Registry::checks(); $subs = array(); $tot = array(); $bad = array(); $weak = 0.0; foreach ( $ran as $id ) { $c = $checks[ $id ] ?? null; if ( ! $c || empty( $c['weight'] ) || 'observed' === $c['subscore'] ) { continue; } if ( 'weak' === $c['evidence'] ) { $weak += in_array( $id, $fails, true ) ? (float) $c['weight'] : 0; continue; } $tot[ $c['subscore'] ] = ( $tot[ $c['subscore'] ] ?? 0 ) + $c['weight']; if ( in_array( $id, $fails, true ) ) { $bad[ $c['subscore'] ] = ( $bad[ $c['subscore'] ] ?? 0 ) + $c['weight']; } } $sum = 0.0; $wsum = 0.0; foreach ( GetReport_Registry::subscores() as $key => $s ) { if ( empty( $tot[ $key ] ) ) { $subs[ $key ] = null; continue; } $subs[ $key ] = (int) round( 100 * ( 1 - ( $bad[ $key ] ?? 0 ) / $tot[ $key ] ) ); $sum += $s['weight'] * $subs[ $key ]; $wsum += $s['weight']; } $score = $wsum > 0 ? $sum / $wsum : 0; return array( 'score' => (int) max( 0, round( $score - min( 2.0, $weak ) ) ), 'subs' => $subs, ); } }