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). /** * Scanner * * Scans published posts and populates clusterlane_links + clusterlane_post_metrics. */ class Scanner { public function __construct( private LinkGraph $graph ) {} /** * Scan a single post: extract links, save to DB, update metrics. * Returns number of outbound links found. */ /** * @param bool $keep_cluster Keep the article's primary cluster (single rescans after * Apply/Undo). A full scan clears it so Metrics reassigns it. */ public function scan_post( int $post_id, bool $keep_cluster = false ): int { $post = get_post( $post_id ); if ( Scope::excludes( $post ) ) { // unpublished or password-protected Scope::forget_post( $post_id ); return 0; } // Extract and save links: the article text, plus — where Settings → Content // says so — the links the theme adds on the published page (0.98.177). // A scan from the Scan button reads the text now and queues the page // (0.98.178): until it is read, its links and counts from the last // reading stay — as when a page can't be read this time. $links = $this->graph->extract_links( $post_id ); $page = null; $kept = null; // the page's words/H2s/images from its last reading if ( PageReader::enabled_for( $post ) ) { if ( PageReader::deferring() ) { PageReader::queue( $post_id ); $page = false; } else { $page = PageReader::read( $post ); } if ( ! is_array( $page ) ) $kept = PageReader::stored_stats( $post_id ); } if ( is_array( $page ) ) { $this->graph->save_links( $post_id, $links, $this->graph->resolve_links( $page['links'], $post_id ) ); } elseif ( $page !== null ) { $this->graph->save_links( $post_id, $links ); // queued, or unreadable this time: keep its stored page links } else { $this->graph->save_links( $post_id, $links, [] ); // not read: no page links } if ( ! is_array( $page ) && $kept ) $page = $kept + [ 'links' => [] ]; // Build metrics. $content = $post->post_content; $product = Shop::is_product( $post ); // WooCommerce (0.98.172): description + short description $text = wp_strip_all_tags( $content ) . ( $product ? ' ' . Shop::short_description( $post ) : '' ); $words = $text ? str_word_count( $text, 0, 'åäöÅÄÖéèêëàâæœùûüïîæç' ) : 0; $h2s = substr_count( strtolower( $content ), ' tags + common gallery/image shortcodes. // Many page builders (Envira, NextGEN, native [gallery]) store images as // shortcodes rather than tags in post_content. $img_tags = substr_count( strtolower( $content ), 'gallery_shortcodes() : []; $extra_pattern = $extra_sc ? '|' . implode( '|', array_map( fn($s) => preg_quote( $s, '/' ), $extra_sc ) ) : ''; $gallery_sc = preg_match_all( '/\[\s*(' . $built_in . $extra_pattern . ')\b/i', $content ); $images = $img_tags + (int) $gallery_sc + ( $product ? Shop::gallery_count( $post_id ) : 0 ); if ( is_array( $page ) ) $images = max( $images, $page['images'] ); $inbound = $this->graph->count_inbound( $post_id ); $outbound = $this->graph->count_outbound( $post_id ); // in-text + auto (pillar box) + template (page) // Derive content class. $class = $this->classify( $inbound, $outbound ); // Simple SEO score (can be enhanced later). $seo_score = $this->compute_seo_score( $post, $words, $product ); // Simple structure score. $structure_score = $product ? $this->compute_product_score( $post, $words, $images ) : $this->compute_structure_score( $words, $h2s, $images ); // Overall score — weighted average. A product is not expected to link out; what // matters is articles linking TO it (5 give full marks for that part). $overall = (int) round( $product ? $seo_score * 0.35 + $structure_score * 0.35 + min( 100, $inbound * 20 ) * 0.30 : $seo_score * 0.35 + $structure_score * 0.35 + min( 100, $inbound * 10 ) * 0.15 + min( 100, $outbound * 10 ) * 0.15 ); global $wpdb; // REPLACE rewrites the whole row. A single rescan after Apply/Undo // carries over primary_cluster (set by Metrics) — before 0.98.124 the // article's cluster went blank in lists until the next Metrics run. $primary = $keep_cluster ? $wpdb->get_var( $wpdb->prepare( "SELECT primary_cluster FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id = %d", $post_id ) ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery : null; $wpdb->replace( $wpdb->prefix . 'clusterlane_post_metrics', [ 'post_id' => $post_id, 'primary_cluster' => $primary, 'seo_score' => $seo_score, 'structure_score' => $structure_score, 'overall_score' => min( 100, $overall ), 'inbound_links' => $inbound, 'outbound_links' => $outbound, 'word_count' => $words, 'image_count' => $images, 'h2_count' => $h2s, 'content_class' => $class, 'last_scanned' => current_time( 'mysql', true ), // UTC — matches PHP time() ] ); // Keep the role from the article's clusters (the last Metrics) instead of // the first guess above — else a pillar showed as "support" after Apply. if ( class_exists( ClusterEngine::class ) ) ClusterEngine::sync_site_roles( [ $post_id ] ); return $outbound; } /** * Refresh every article's inbound and outbound counts from the link graph * (and, where they changed, its overall score and first-guess role). * * scan_post() counts an article's inbound links when it scans that article, * so links from articles scanned after it aren't counted until the next * scan. On a first scan (an empty link graph) that made the first articles * look like orphans, and a site without clusters kept those numbers, since * Metrics never ran for it (0.98.179). Metrics (ClusterEngine::compute_all) * calls this first; the scans end with Metrics. */ public static function refresh_counts(): int { global $wpdb; $shop = Shop::active() ? 1 : 0; // Same formulas as scan_post() (overall score, classify()); a product is scored on inbound links only. // i.n / o.n: inbound / outbound links counted from the link table (NULL → 0). return (int) $wpdb->query( $wpdb->prepare( "UPDATE %i m JOIN %i p ON p.ID = m.post_id LEFT JOIN ( SELECT target_id, COUNT(*) AS n FROM %i WHERE target_type = 'post' GROUP BY target_id ) i ON i.target_id = m.post_id LEFT JOIN ( SELECT source_id, COUNT(*) AS n FROM %i GROUP BY source_id ) o ON o.source_id = m.post_id SET m.overall_score = LEAST( 100, ROUND( IF( %d = 1 AND p.post_type = 'product', m.seo_score * 0.35 + m.structure_score * 0.35 + LEAST( 100, COALESCE( i.n, 0 ) * 20 ) * 0.30, m.seo_score * 0.35 + m.structure_score * 0.35 + LEAST( 100, COALESCE( i.n, 0 ) * 10 ) * 0.15 + LEAST( 100, COALESCE( o.n, 0 ) * 10 ) * 0.15 ) ) ), m.content_class = CASE WHEN COALESCE( i.n, 0 ) = 0 THEN 'orphan' WHEN m.content_class = 'orphan' THEN IF( COALESCE( i.n, 0 ) >= 5 OR COALESCE( o.n, 0 ) >= 3, 'support', 'light' ) ELSE m.content_class END, m.inbound_links = COALESCE( i.n, 0 ), m.outbound_links = COALESCE( o.n, 0 ) WHERE m.inbound_links <> COALESCE( i.n, 0 ) OR m.outbound_links <> COALESCE( o.n, 0 )", $wpdb->prefix . 'clusterlane_post_metrics', $wpdb->posts, $wpdb->prefix . 'clusterlane_links', $wpdb->prefix . 'clusterlane_links', $shop ) ); } /** * Get all published post IDs for the configured post types. * * @param string[] $post_types * @return int[] */ public function get_all_post_ids( array $post_types = ['post'] ): array { return get_posts( Scope::query_args( [ 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'orderby' => 'ID', 'order' => 'ASC', ] ) ); } /** * Quick summary stats from the metrics table. */ public function get_summary(): array { global $wpdb; $table = $wpdb->prefix . 'clusterlane_post_metrics'; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS total_scanned, ROUND(AVG(overall_score), 1) AS avg_score, ROUND(AVG(inbound_links), 1) AS avg_inbound, ROUND(AVG(outbound_links), 1) AS avg_outbound, SUM(inbound_links = 0) AS orphan_count, MAX(last_scanned) AS last_scan FROM %i', $table ), ARRAY_A ); // Same scope as the scan (published, not password-protected). // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $total_posts = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE ' . Scope::type_sql() . ' AND ' . Scope::sql(), $wpdb->posts ) ); // type_sql() is prepared; sql() has no values // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared return [ 'total_posts' => $total_posts, 'total_scanned' => (int) ( $row['total_scanned'] ?? 0 ), 'avg_score' => (float) ( $row['avg_score'] ?? 0 ), 'avg_inbound' => (float) ( $row['avg_inbound'] ?? 0 ), 'avg_outbound' => (float) ( $row['avg_outbound'] ?? 0 ), 'orphan_count' => (int) ( $row['orphan_count'] ?? 0 ), 'last_scan' => $row['last_scan'] ?? null, ]; } /* ------------------------------------------------------------------ Private helpers ------------------------------------------------------------------ */ /** * First guess until Metrics runs. Never "pillar": a pillar is decided per * cluster (at most one, or the one you chose), and Metrics then gives each * article its best role across its clusters (ClusterEngine::sync_site_roles). * Before 0.98.145 every article with 5+ inbound links was a "pillar" here. */ private function classify( int $inbound, int $outbound ): string { if ( $inbound === 0 ) return 'orphan'; if ( $inbound >= 5 || $outbound >= 3 ) return 'support'; return 'light'; } private function compute_seo_score( \WP_Post $post, int $words, bool $product = false ): int { $score = 0; // SEO title + meta description from the configured source (SeoMeta: // SEO plugin or custom/ACF fields, with post title/excerpt fallback). // Lengths in characters, not bytes — "å" counts once. $seo = \Clusterlane\Core\SeoMeta::get( $post ); // Title present and reasonable length. $title_len = \Clusterlane\Core\SeoMeta::len( $seo['title'] ); if ( $title_len >= 20 && $title_len <= 70 ) $score += 30; elseif ( $title_len > 0 ) $score += 15; // Meta description (or excerpt fallback). if ( \Clusterlane\Core\SeoMeta::len( $seo['description'] ) >= 50 ) $score += 25; // Word count (a product page needs far fewer words than an article). if ( $product ) { if ( $words >= 150 ) $score += 30; elseif ( $words >= 50 ) $score += 15; } elseif ( $words >= 600 ) $score += 30; elseif ( $words >= 300 ) $score += 15; // Has a featured image. if ( has_post_thumbnail( $post->ID ) ) $score += 15; return min( 100, $score ); } /** * Product page (0.98.172), 0–100: a description of 150+ words (40, 20 from 50), * a short description of 10+ words (30), and images beyond the main one — * gallery or in the text (30 for 2+, 15 for 1). */ private function compute_product_score( \WP_Post $post, int $words, int $images ): int { $score = 0; if ( $words >= 150 ) $score += 40; elseif ( $words >= 50 ) $score += 20; if ( str_word_count( Shop::short_description( $post ), 0, 'åäöÅÄÖéèêëàâæœùûüïîæç' ) >= 10 ) $score += 30; if ( $images >= 2 ) $score += 30; elseif ( $images >= 1 ) $score += 15; return min( 100, $score ); } private function compute_structure_score( int $words, int $h2s, int $images ): int { $score = 0; // Word count. if ( $words >= 800 ) $score += 30; elseif ( $words >= 400 ) $score += 20; elseif ( $words >= 200 ) $score += 10; // H2 headings relative to word count. $expected_h2s = max( 1, intdiv( $words, 200 ) ); if ( $h2s >= $expected_h2s ) $score += 40; elseif ( $h2s > 0 ) $score += 20; // Images. $img_per_1000 = $words > 0 ? ( $images / $words ) * 1000 : 0; if ( $img_per_1000 >= 2 ) $score += 30; elseif ( $img_per_1000 >= 0.5 ) $score += 15; return min( 100, $score ); } }