prepare(): values and ID lists as placeholders, table and // column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared // where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins). /** * PageReader (0.98.177) * * Reads an article's published page the way a visitor — and a search engine — * gets it, for the post types ticked under Settings → Content → "Read the * published pages". Many themes add links outside the article text: lists of * the places in an area, cards, "more in this category", related articles. * Templates build them from custom fields or queries, so they are never in * post_content, and without this such pages look like orphans. * * Only the page's main content counts (
, role="main", #main …). Menus, * breadcrumbs, sidebars and the footer link to everything from every page and * would drown the signal, so they are left out. Links inside the main content * that are not in the article text are stored as link_type 'template'. * * A page that can't be read (a firewall, a login wall, a timeout) falls back * to the article text alone; the scan reports how many pages failed and why. * * 0.98.178 — without waiting: * - Settings → Content → "Use cached pages" reads the page as the page cache * serves it (no throwaway query argument). An answer that doesn't show the * article is fetched again, fresh. * - A scan from the Scan button reads the text only and queues the pages * (defer()); run_queue() reads them in slices of ~20 s — from the open * Clusterlane screen (POST clusterlane/v1/scan/pages) or WP-Cron — and updates the * metrics when the last page is read. Until then the pages' links, words, * subheadings and images from the previous reading are kept. * * 1.3.2 — every page is fetched through the WordPress HTTP API (wp_remote_get), * one after another, so the site's proxy, transport and HTTP filters always * apply. (Earlier versions fetched a few pages side by side with the Requests * library directly, which bypasses those settings.) */ final class PageReader { /** Seconds allowed per page. */ const TIMEOUT = 12; /** Option: pages waiting to be read after a scan (0.98.178). */ const QUEUE_OPTION = 'clusterlane_page_queue'; /** Option: held while a slice of the queue is being read. */ const LOCK_OPTION = 'clusterlane_page_lock'; /** WP-Cron hook that reads the queue when no Clusterlane screen is open. */ const CRON_HOOK = 'clusterlane_read_pages'; /** Post meta: words, subheadings and images on the page when last read. */ const STATS_META = '_clusterlane_page_stats'; /** Seconds one slice of the queue may take. */ const SLICE = 20; /** Option: what the latest scan could read — ok/failed counts, reasons, an example. */ const LOG_OPTION = 'clusterlane_render_log'; private const LOWER = 'abcdefghijklmnopqrstuvwxyz'; private const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; /** Counts for the batch being scanned; written to LOG_OPTION by flush_log(). */ private static array $batch = [ 'ok' => 0, 'failed' => 0, 'reasons' => [], 'example' => null ]; /** Scan the text now, read the page later (the Scan button). */ private static bool $defer = false; /** Post IDs queued by this request; written by flush_queue(). */ private static array $queued = []; private static ?Plugin $plugin = null; public static function register_hooks( Plugin $plugin ): void { self::$plugin = $plugin; add_action( self::CRON_HOOK, [ self::class, 'on_cron' ] ); } public static function enabled_for( ?\WP_Post $post ): bool { if ( ! $post ) return false; $s = Settings::get_instance(); return $s && in_array( $post->post_type, $s->rendered_types(), true ); } public static function any_enabled(): bool { $s = Settings::get_instance(); return $s && (bool) $s->rendered_types(); } /** Read cached pages (Settings → Content → "Use cached pages"). */ public static function use_cache(): bool { $s = Settings::get_instance(); return $s && (bool) $s->get( 'rendered_cached', false ); } /** * Fetch and read one article's published page. With cached pages on, an * answer that doesn't show the article is fetched once more, fresh. * * @return array{links: array, words: int, h2: int, images: int}|\WP_Error */ public static function read( \WP_Post $post ) { if ( ! class_exists( '\DOMDocument' ) ) { return self::fail( $post, new \WP_Error( 'no_dom', __( 'The PHP DOM extension is not installed on this server.', 'clusterlane' ) ) ); } $url = get_permalink( $post ); if ( ! $url ) return self::fail( $post, new \WP_Error( 'no_url', __( 'The article has no address.', 'clusterlane' ) ) ); $cached = self::use_cache(); $page = self::attempt( $post, $cached ); if ( is_wp_error( $page ) && $cached ) { $fresh = self::attempt( $post, false ); if ( ! is_wp_error( $fresh ) ) $page = $fresh; } if ( is_wp_error( $page ) ) return self::fail( $post, $page, (string) $url ); self::$batch['ok']++; update_post_meta( $post->ID, self::STATS_META, [ 'words' => $page['words'], 'h2' => $page['h2'], 'images' => $page['images'] ] ); return $page; } /** * Words, subheadings and images on the page when it was last read — used * while a page waits in the queue or couldn't be read this time. * * @return array{words: int, h2: int, images: int}|null */ public static function stored_stats( int $post_id ): ?array { $m = get_post_meta( $post_id, self::STATS_META, true ); if ( ! is_array( $m ) ) return null; return [ 'words' => (int) ( $m['words'] ?? 0 ), 'h2' => (int) ( $m['h2'] ?? 0 ), 'images' => (int) ( $m['images'] ?? 0 ) ]; } /** The address to fetch: as visitors get it (cached), or past the page cache. */ private static function fetch_url( \WP_Post $post, bool $cached ): string { $url = (string) get_permalink( $post ); // A throwaway query argument gets past page caches (plugins and CDNs cache // by the full address), so an edit shows at once — e.g. after Undo. return $cached ? $url : add_query_arg( 'clusterlane_read', (string) wp_rand( 100000, 999999 ), $url ); } /** @return array */ private static function fetch_headers( bool $cached ): array { return $cached ? [] : [ 'Cache-Control' => 'no-cache' ]; } private static function user_agent(): string { return 'Mozilla/5.0 (compatible; Clusterlane/' . CLUSTERLANE_VERSION . '; reading this site\'s own pages)'; } private static function ssl_verify(): bool { // WordPress core's own setting for requests to the site itself (cron, Site Health). return (bool) apply_filters( 'https_local_ssl_verify', false ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core filter } /** * One try: fetch the page through the WordPress HTTP API, then parse. * * @return array{links: array, words: int, h2: int, images: int}|\WP_Error */ private static function attempt( \WP_Post $post, bool $cached ) { $res = wp_remote_get( self::fetch_url( $post, $cached ), [ 'timeout' => self::TIMEOUT, 'redirection' => 3, 'sslverify' => self::ssl_verify(), 'user-agent' => self::user_agent(), 'headers' => self::fetch_headers( $cached ), ] ); $raw = is_wp_error( $res ) ? new \WP_Error( 'http', $res->get_error_message() ) : [ 'code' => (int) wp_remote_retrieve_response_code( $res ), 'body' => (string) wp_remote_retrieve_body( $res ) ]; if ( is_wp_error( $raw ) ) return $raw; if ( $raw['code'] !== 200 ) { /* translators: %d: HTTP status code */ return new \WP_Error( 'status_' . $raw['code'], sprintf( __( 'The page answered with HTTP %d.', 'clusterlane' ), $raw['code'] ) ); } return self::parse( $raw['body'], $post ); } /* ------------------------------------------------------------------ Reading later (0.98.178) ------------------------------------------------------------------ */ /** Scan the text now and queue the page (the Scan button's scan). */ public static function defer( bool $on ): void { self::$defer = $on; } public static function deferring(): bool { return self::$defer; } public static function queue( int $post_id ): void { self::$queued[] = $post_id; } /** * @return array{ids: int[], total: int, done: int, started: int, finished: int} */ public static function queue_state(): array { $q = get_option( self::QUEUE_OPTION, [] ); $q = is_array( $q ) ? $q : []; return [ 'ids' => array_values( array_map( 'intval', (array) ( $q['ids'] ?? [] ) ) ), 'total' => (int) ( $q['total'] ?? 0 ), 'done' => (int) ( $q['done'] ?? 0 ), 'started' => (int) ( $q['started'] ?? 0 ), 'finished' => (int) ( $q['finished'] ?? 0 ), ]; } /** A full scan starts: nothing queued. */ public static function reset_queue(): void { delete_option( self::QUEUE_OPTION ); self::$queued = []; } /** Add this request's queued pages to the queue and have it read. */ public static function flush_queue(): int { if ( ! self::$queued ) return 0; $q = self::queue_state(); $new = array_values( array_diff( array_unique( self::$queued ), $q['ids'] ) ); $q['ids'] = array_merge( $q['ids'], $new ); $q['total'] = $q['done'] + count( $q['ids'] ); $q['started'] = $q['started'] ?: time(); $q['finished'] = 0; update_option( self::QUEUE_OPTION, $q, false ); self::$queued = []; self::schedule(); return count( $new ); } /** WP-Cron picks the queue up when no Clusterlane screen reads it. */ public static function schedule( int $delay = 60 ): void { if ( ! wp_next_scheduled( self::CRON_HOOK ) ) wp_schedule_single_event( time() + $delay, self::CRON_HOOK ); } public static function on_cron(): void { $st = self::run_queue(); if ( $st['left'] ) self::schedule( 5 ); } /** * Read the next pages in the queue for up to SLICE seconds; when the last * one is read, update the metrics. One slice at a time (a lock), so the * open screen and WP-Cron never read the same pages twice. * * @return array{active: bool, total: int, done: int, left: int, finished: int, busy?: bool, ok: int, failed: int, example: ?array} */ public static function run_queue(): array { $q = self::queue_state(); if ( ! $q['ids'] || ! self::$plugin ) return self::status(); if ( ! self::lock() ) return self::status() + [ 'busy' => true ]; if ( function_exists( 'set_time_limit' ) ) @set_time_limit( 120 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- background batch of page reads; capped, and harmless where disabled ignore_user_abort( true ); $scanner = self::$plugin->get( 'scanner' ); $start = microtime( true ); $was = self::$defer; self::$defer = false; try { // A couple of pages at a time, started only well inside the slice. while ( $q['ids'] && microtime( true ) - $start < self::SLICE - 6 ) { $window = array_slice( $q['ids'], 0, 2 ); foreach ( $window as $id ) { if ( $scanner ) $scanner->scan_post( $id, true ); } // Meanwhile a new scan may have started (reset) or added pages. $cur = self::queue_state(); if ( $cur['started'] !== $q['started'] ) { $q = $cur; break; } $q = $cur; $q['ids'] = array_values( array_diff( $cur['ids'], $window ) ); $q['done'] = $cur['done'] + count( $window ); $q['total'] = max( $cur['total'], $q['done'] + count( $q['ids'] ) ); update_option( self::QUEUE_OPTION, $q, false ); } self::flush_log(); if ( ! $q['ids'] && ! $q['finished'] && $q['total'] ) { $q['finished'] = time(); update_option( self::QUEUE_OPTION, $q, false ); $engine = self::$plugin->get( 'cluster_engine' ); if ( $engine ) $engine->compute_all(); } } finally { self::$defer = $was; self::unlock(); } return self::status(); } /** * Where the reading stands, for the screens. * * @return array{active: bool, total: int, done: int, left: int, finished: int, ok: int, failed: int, example: ?array} */ public static function status(): array { $q = self::queue_state(); $log = self::log(); return [ 'active' => (bool) $q['ids'], 'total' => $q['total'], 'done' => $q['done'], 'left' => count( $q['ids'] ), 'finished' => $q['finished'], 'ok' => (int) ( $log['ok'] ?? 0 ), 'failed' => (int) ( $log['failed'] ?? 0 ), 'example' => $log['example'] ?? null, ]; } private static function lock(): bool { if ( add_option( self::LOCK_OPTION, time(), '', false ) ) return true; $t = (int) get_option( self::LOCK_OPTION, 0 ); if ( $t && time() - $t < self::SLICE + 100 ) return false; // someone is reading update_option( self::LOCK_OPTION, time(), false ); // a slice that died: take over return true; } private static function unlock(): void { delete_option( self::LOCK_OPTION ); } /** * Read a page's HTML: links, words, subheadings and images in its main content. * Words, subheadings and images inside links (cards, teasers of other * articles) are not counted as this article's own. * * @return array{links: array, words: int, h2: int, images: int}|\WP_Error */ public static function parse( string $html, ?\WP_Post $post = null ) { if ( trim( $html ) === '' ) return new \WP_Error( 'empty', __( 'The page was empty.', 'clusterlane' ) ); $doc = new \DOMDocument(); $prev = libxml_use_internal_errors( true ); $ok = $doc->loadHTML( '' . $html, LIBXML_NONET | LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_COMPACT ); libxml_clear_errors(); libxml_use_internal_errors( $prev ); if ( ! $ok ) return new \WP_Error( 'parse', __( 'The page could not be read.', 'clusterlane' ) ); $xp = new \DOMXPath( $doc ); $main = self::main_node( $xp ); if ( ! $main ) { return new \WP_Error( 'no_main', __( 'No main content area was found on the page (a
element or role="main").', 'clusterlane' ) ); } // Not content: navigation and breadcrumbs, scripts, forms, comments, sidebars, // and Clusterlane's own pillar box (stored separately as 'auto' links). $ci = fn( string $attr ): string => 'translate(' . $attr . ', "' . self::UPPER . '", "' . self::LOWER . '")'; $has = fn( string $attr, string $word ): string => 'contains(' . $ci( $attr ) . ', "' . $word . '")'; $drop = './/nav | .//script | .//style | .//noscript | .//template | .//form | .//iframe | .//svg | .//select' . ' | .//*[contains(concat(" ", normalize-space(@class), " "), " clusterlane-pillar-box ")]' . ' | .//*[' . $has( '@class', 'breadcrumb' ) . ' or ' . $has( '@id', 'breadcrumb' ) . ' or ' . $has( '@aria-label', 'breadcrumb' ) . ']' . ' | .//*[@id="comments" or @id="respond" or contains(concat(" ", normalize-space(@class), " "), " comments-area ")]' . ' | .//*[@id="secondary" or @role="complementary" or ' . $has( '@class', 'widget-area' ) . ' or contains(concat(" ", normalize-space(@class), " "), " sidebar ")]'; $nodes = $xp->query( $drop, $main ); foreach ( $nodes ? iterator_to_array( $nodes ) : [] as $n ) { if ( $n->parentNode ) $n->parentNode->removeChild( $n ); } if ( $post && ! self::looks_like( $post, $xp, $main ) ) { return new \WP_Error( 'not_article', __( 'The page did not show the article (a login, maintenance or “coming soon” page?).', 'clusterlane' ) ); } $links = []; foreach ( $xp->query( './/a[@href]', $main ) ?: [] as $a ) { if ( ! $a instanceof \DOMElement ) continue; $href = trim( (string) $a->getAttribute( 'href' ) ); if ( $href === '' || $href[0] === '#' || preg_match( '#^(mailto|tel|javascript|data):#i', $href ) ) continue; $links[] = [ html_entity_decode( $href, ENT_QUOTES, 'UTF-8' ), trim( preg_replace( '/\s+/u', ' ', (string) $a->textContent ) ) ]; } $text = ''; foreach ( $xp->query( './/text()[not(ancestor::a)]', $main ) ?: [] as $t ) $text .= ' ' . $t->nodeValue; $text = trim( preg_replace( '/\s+/u', ' ', $text ) ); $words = $text === '' ? 0 : str_word_count( $text, 0, 'åäöÅÄÖéèêëàâæœùûüïîæçñáíóúÑÁÉÍÓÚ' ); $h2 = ( $l = $xp->query( './/h2[not(ancestor::a)]', $main ) ) ? $l->length : 0; $imgs = ( $l = $xp->query( './/img[not(ancestor::a)]', $main ) ) ? $l->length : 0; return [ 'links' => $links, 'words' => (int) $words, 'h2' => (int) $h2, 'images' => (int) $imgs ]; } /** The page's main content:
, role="main", then the usual theme ids, then the largest
. */ private static function main_node( \DOMXPath $xp ): ?\DOMNode { foreach ( [ '//main', '//*[@role="main"]', '//*[@id="main"]', '//*[@id="primary"]', '//*[@id="content"]', '//article' ] as $q ) { $best = null; $len = 0; foreach ( $xp->query( $q ) ?: [] as $n ) { $l = strlen( trim( (string) $n->textContent ) ); if ( $l > $len ) { $best = $n; $len = $l; } } if ( $best ) return $best; } return null; } /** * Does the page show this article? Most of the title's words must be on it * (in the main content or the document title) — a login wall, a maintenance * or "coming soon" page doesn't have them. */ private static function looks_like( \WP_Post $post, \DOMXPath $xp, \DOMNode $main ): bool { // WordPress's body classes say which page this is (themes print them with // body_class()): "postid-123" / "page-id-45" must be this article, and a // home, blog, archive or 404 page never is — a home page listing the // articles has their titles too, so the title test alone let it pass (0.98.179). $cls = $xp->query( '//body/@class' ); if ( $cls && $cls->length ) { $classes = preg_split( '/\s+/', strtolower( (string) $cls->item( 0 )->nodeValue ) ) ?: []; foreach ( $classes as $c ) { if ( preg_match( '/^(?:postid|page-id)-(\d+)$/', $c, $m ) ) return (int) $m[1] === (int) $post->ID; } if ( array_intersect( $classes, [ 'home', 'blog', 'archive', 'search', 'error404' ] ) && ! array_intersect( $classes, [ 'single', 'page', 'singular', 'wp-singular' ] ) ) return false; } $norm = fn( string $s ): string => mb_strtolower( remove_accents( html_entity_decode( wp_strip_all_tags( $s ), ENT_QUOTES, 'UTF-8' ) ), 'UTF-8' ); // "Arguineguin" in the title, "Arguineguín" on the page $words = array_values( array_filter( preg_split( '/[^\p{L}\p{N}]+/u', $norm( get_the_title( $post ) ) ) ?: [], fn( $w ) => mb_strlen( $w, 'UTF-8' ) >= 3 ) ); if ( ! $words ) return true; $title = $xp->query( '//title' ); $page = $norm( (string) $main->textContent . ' ' . ( $title && $title->length ? (string) $title->item( 0 )->textContent : '' ) ); $found = 0; foreach ( $words as $w ) if ( str_contains( $page, $w ) ) $found++; return $found / count( $words ) >= 0.6; } /** Record a failure for the scan report. */ private static function fail( \WP_Post $post, \WP_Error $e, string $url = '' ): \WP_Error { self::$batch['failed']++; $code = (string) $e->get_error_code(); self::$batch['reasons'][ $code ] = ( self::$batch['reasons'][ $code ] ?? 0 ) + 1; if ( ! self::$batch['example'] ) { self::$batch['example'] = [ 'post' => (int) $post->ID, 'url' => $url ?: (string) get_permalink( $post ), 'message' => $e->get_error_message() ]; } return $e; } /** This batch's counts (for the scan response), then add them to the scan's log. */ public static function flush_log(): array { $b = self::$batch; self::$batch = [ 'ok' => 0, 'failed' => 0, 'reasons' => [], 'example' => null ]; if ( ! $b['ok'] && ! $b['failed'] ) return $b; $log = get_option( self::LOG_OPTION, [] ); $log = is_array( $log ) ? $log : []; $log['ok'] = (int) ( $log['ok'] ?? 0 ) + $b['ok']; $log['failed'] = (int) ( $log['failed'] ?? 0 ) + $b['failed']; foreach ( $b['reasons'] as $k => $n ) $log['reasons'][ $k ] = (int) ( $log['reasons'][ $k ] ?? 0 ) + $n; if ( empty( $log['example'] ) && $b['example'] ) $log['example'] = $b['example']; $log['at'] = time(); update_option( self::LOG_OPTION, $log, false ); return $b; } /** A full scan starts: forget the previous one's counts. */ public static function reset_log(): void { delete_option( self::LOG_OPTION ); } /** @return array{ok?: int, failed?: int, reasons?: array, example?: array, at?: int} */ public static function log(): array { $log = get_option( self::LOG_OPTION, [] ); return is_array( $log ) ? $log : []; } /** * Links in the article TEXT per page, by post type — the signal that a theme * links its pages through templates instead. Counted from the stored text, so * it works before anything is scanned (onboarding, Settings). * * @param string[] $types * @return array */ public static function text_link_density( array $types ): array { global $wpdb; $types = array_values( array_filter( array_map( 'sanitize_key', $types ) ) ); if ( ! $types ) return []; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_type, COUNT(*) AS posts, SUM( ( CHAR_LENGTH( LOWER( post_content ) ) - CHAR_LENGTH( REPLACE( LOWER( post_content ), 'posts} WHERE post_status = 'publish' AND post_password = '' AND post_type IN (" . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ') GROUP BY post_type', $types ) ); $out = []; foreach ( (array) $rows as $r ) { $n = (int) $r->posts; $out[ $r->post_type ] = [ 'posts' => $n, 'per_page' => $n ? round( (float) $r->links / $n, 1 ) : 0.0 ]; } return $out; } /** * Post types whose pages have almost no links in their text (under two per * page, at least 5 pages): reading the published page is recommended. * * @param string[] $types * @return string[] */ public static function recommended( array $types ): array { $out = []; foreach ( self::text_link_density( $types ) as $pt => $d ) { if ( self::is_recommended( $d ) ) $out[] = $pt; } return $out; } /** * At least 5 pages and under two links per page in the text. (Under one * missed a theme's island pages with exactly one link each in the text, * whose area cards were the only links to 40 area pages.) * * @param array{posts: int, per_page: float} $d */ public static function is_recommended( array $d ): bool { return $d['posts'] >= 5 && $d['per_page'] < 2; } }