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