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