* }> Ranked by showings, heaviest split first. */ public static function build( array $rows, array $set_aside = array(), array $set_aside_urls = array() ) { $set_aside = array_map( 'intval', $set_aside ); $set_aside_urls = array_map( 'strval', $set_aside_urls ); // 1. Page-attributed rows only, grouped by the query as the engine // reported it (case-folded; never stemmed — merging "llms txt" with // "llms.txt file" would assert a collision the engine never showed). $by_query = array(); foreach ( $rows as $row ) { $url = isset( $row['page_url'] ) ? (string) $row['page_url'] : ''; if ( '' === $url ) { continue; // Site-wide rows describe the same traffic without a page. } $query = strtolower( trim( isset( $row['query'] ) ? (string) $row['query'] : '' ) ); if ( '' === $query || Noise::is_noise( $query ) ) { continue; } $id = isset( $row['page_id'] ) ? (int) $row['page_id'] : 0; if ( ( $id > 0 && in_array( $id, $set_aside, true ) ) || in_array( Pages::key( $url ), $set_aside_urls, true ) ) { continue; // A page set apart on purpose stops being counted against a search. } $key = Pages::key( $url ); if ( ! isset( $by_query[ $query ][ $key ] ) ) { $by_query[ $query ][ $key ] = array( 'url' => $url, 'page_id' => $id, 'clicks' => 0, 'impressions' => 0, 'pos_weight' => 0.0, ); } $c = (int) ( isset( $row['clicks'] ) ? $row['clicks'] : 0 ); $i = (int) ( isset( $row['impressions'] ) ? $row['impressions'] : 0 ); $p = (float) ( isset( $row['position'] ) ? $row['position'] : 0.0 ); $by_query[ $query ][ $key ]['clicks'] += $c; $by_query[ $query ][ $key ]['impressions'] += $i; $by_query[ $query ][ $key ]['pos_weight'] += $p * $i; if ( $id > 0 && 0 === $by_query[ $query ][ $key ]['page_id'] ) { $by_query[ $query ][ $key ]['page_id'] = $id; } } // 2. Judge each query. $out = array(); foreach ( $by_query as $query => $pages ) { if ( count( $pages ) < 2 ) { continue; } $shown = 0; foreach ( $pages as $page ) { $shown += $page['impressions']; } if ( $shown < self::MIN_SHOWN ) { continue; } // Only pages holding a real share compete; the rest are stray rows. $competing = array(); foreach ( $pages as $page ) { $share = $page['impressions'] / $shown; if ( $share >= self::MIN_SHARE ) { $page['share'] = round( $share, 3 ); $page['position'] = $page['impressions'] > 0 ? round( $page['pos_weight'] / $page['impressions'], 1 ) : 0.0; unset( $page['pos_weight'] ); $competing[] = $page; } } if ( count( $competing ) < 2 ) { continue; } // One page already owning the query is a result, not a problem. $top_share = 0.0; foreach ( $competing as $page ) { $top_share = max( $top_share, $page['share'] ); } if ( $top_share >= self::OWNED ) { continue; } // The winner earns the click: most clicks, then the better position. usort( $competing, static function ( $a, $b ) { if ( $a['clicks'] !== $b['clicks'] ) { return $b['clicks'] - $a['clicks']; } return $a['position'] <=> $b['position']; } ); $clicks = 0; $best = null; $worst = null; foreach ( $competing as $k => $page ) { $competing[ $k ]['winner'] = 0 === $k; $clicks += $page['clicks']; $best = null === $best ? $page['position'] : min( $best, $page['position'] ); $worst = null === $worst ? $page['position'] : max( $worst, $page['position'] ); } $out[] = array( 'query' => $query, 'shown' => $shown, 'clicks' => $clicks, 'best' => (float) $best, 'worst' => (float) $worst, 'pages' => $competing, ); } // 3. Heaviest split first — the ranking IS the priority. usort( $out, static function ( $a, $b ) { return $b['shown'] - $a['shown']; } ); return $out; } /** * The (page, search) pairs a page is LOSING — every competing page in a * split except the one earning the click. * * ⭐⭐ Exists because the front door contradicted itself. A page sitting at * #13.8 for a search its own sibling holds at #6.1 was counted under * "one push from page one" — advice to climb — on the same screen as the * split row saying to point it at the winner instead. Both rows were true of * what they measured, and following the first makes the second worse. * * The mirror of the law already written into {@see wire()}: never advise * editing the winner, and never advise a loser to climb the search it is * losing. The split row owns that search; the climb list must not. * * ⚠️ Keyed BOTH ways — by post id and by URL — because a snapshot can carry * the same page with and without an id, and a pair that fails to match is a * contradiction that quietly comes back. * * @param array $collisions {@see build()} rows. * @return array Lookup keys, {@see lost_key()}. */ public static function losing_pairs( array $collisions ) { $out = array(); foreach ( $collisions as $collision ) { $query = strtolower( trim( (string) $collision['query'] ) ); foreach ( (array) $collision['pages'] as $page ) { if ( ! empty( $page['winner'] ) ) { continue; // The page earning the click has nothing to answer for. } $id = (int) ( isset( $page['page_id'] ) ? $page['page_id'] : 0 ); $url = (string) ( isset( $page['url'] ) ? $page['url'] : '' ); if ( $id > 0 ) { $out[ self::lost_key( Opportunities::page_key( $id, '' ), $query ) ] = true; } if ( '' !== $url ) { $out[ self::lost_key( Opportunities::page_key( 0, $url ), $query ) ] = true; } } } return $out; } /** * One lookup key for {@see losing_pairs()} — the page key a worklist groups * by, and the search as the engine reported it (case-folded, never stemmed, * the same rule the split detector itself follows). * * @param string $page_key {@see Opportunities::page_key()}. * @param string $query The search. * @return string */ public static function lost_key( $page_key, $query ) { return $page_key . "\n" . strtolower( trim( (string) $query ) ); } /** * One split search, flattened for the wire — titles resolved the way the * opportunity cards resolve theirs, so both doors (the admin screen's REST * and the Findings report) tell the same story in the same words. * * @param array $collision A {@see build()} row. * @return array */ public static function wire( array $collision ) { return array( 'query' => (string) $collision['query'], 'shown' => (int) $collision['shown'], 'clicks' => (int) $collision['clicks'], 'best' => (float) $collision['best'], 'worst' => (float) $collision['worst'], 'pages' => array_map( static function ( $page ) { $id = (int) $page['page_id']; return array( 'title' => Pages::title( $id, (string) $page['url'] ), 'url' => (string) $page['url'], 'postId' => $id, 'editUrl' => $id > 0 ? (string) get_edit_post_link( $id, 'raw' ) : '', 'clicks' => (int) $page['clicks'], 'impressions' => (int) $page['impressions'], 'position' => (float) $page['position'], 'share' => (float) $page['share'], 'winner' => ! empty( $page['winner'] ), // A tag or category page only lists posts. Splitting a // search with a post it lists, it is never the answer, // whatever the share says — both doors need to know. 'archive' => 0 === $id && null !== Pages::term( (string) $page['url'] ), ); }, $collision['pages'] ), ); } }