= 400;
}
/** Read the stored state, shaped. */
public static function get_state() {
$raw = get_option( self::OPT, array() );
if ( ! is_array( $raw ) ) {
$raw = array();
}
$stored_broken = ( isset( $raw['broken'] ) && is_array( $raw['broken'] ) ) ? array_values( $raw['broken'] ) : array();
$broken = array();
$unverified = ( isset( $raw['unverified'] ) && is_array( $raw['unverified'] ) ) ? array_values( $raw['unverified'] ) : array();
// Older scans classified every 4xx/5xx as broken. Preserve the
// evidence, but withdraw its destructive action until it is rechecked.
foreach ( $stored_broken as $row ) {
if ( self::is_broken_status( (int) ( $row['code'] ?? 0 ) ) ) {
$broken[] = $row;
} else {
$unverified[] = $row;
}
}
return array(
'status' => (string) ( $raw['status'] ?? 'never' ), // never|scanning|done
'queue' => ( isset( $raw['queue'] ) && is_array( $raw['queue'] ) ) ? $raw['queue'] : array(),
'checked' => (int) ( $raw['checked'] ?? 0 ),
'total' => (int) ( $raw['total'] ?? 0 ),
'broken' => $broken,
'unverified' => $unverified,
'ignored' => ( isset( $raw['ignored'] ) && is_array( $raw['ignored'] ) ) ? $raw['ignored'] : array(),
'scanned_at' => (string) ( $raw['scanned_at'] ?? '' ),
);
}
private static function save_state( $state ) {
update_option( self::OPT, $state, false );
}
/**
* Start a scan: harvest every link from published posts/pages into the
* queue, keep prior ignores, reset results. Ignored URLs never re-enter
* the queue: they are not requested, counted or shown on later scans.
* Costs nothing; the actual checking happens batch by batch.
*/
public static function start_scan() {
$prev = self::get_state();
$posts = get_posts( array(
'post_type' => array( 'post', 'page' ),
'post_status' => 'publish',
'posts_per_page' => 500,
'suppress_filters' => true,
) );
$queue = array();
$seen = array();
foreach ( $posts as $post ) {
if ( ! preg_match_all( '#]*href=["\']([^"\']+)["\']#i', (string) $post->post_content, $m ) ) {
continue;
}
foreach ( array_unique( $m[1] ) as $url ) {
// Only checkable web links: skip anchors, mail, tel, relative
// fragments and shortcode-y placeholders.
if ( ! preg_match( '#^https?://#i', $url ) ) {
continue;
}
if ( in_array( md5( $url ), $prev['ignored'], true ) ) {
continue;
}
$key = md5( $url . '|' . $post->ID );
if ( isset( $seen[ $key ] ) ) {
continue;
}
$seen[ $key ] = true;
$queue[] = array(
'url' => $url,
'post_id' => $post->ID,
'post_title' => (string) $post->post_title,
);
}
}
$state = array(
'status' => empty( $queue ) ? 'done' : 'scanning',
'queue' => $queue,
'checked' => 0,
'total' => count( $queue ),
'broken' => array(),
'unverified' => array(),
'ignored' => $prev['ignored'],
'scanned_at' => current_time( 'mysql' ),
);
self::save_state( $state );
return self::public_state( $state );
}
/**
* Check the next batch of queued links. Same URL on several posts is
* checked once per batch thanks to a per-call memo. HEAD first, GET on
* 405/403 (some hosts reject HEAD), redirects followed and recorded so a
* moved page can offer "use the new URL" as a fix.
*/
public static function run_batch() {
$state = self::get_state();
if ( 'scanning' !== $state['status'] || empty( $state['queue'] ) ) {
return self::public_state( $state );
}
$batch = array_splice( $state['queue'], 0, self::BATCH );
$memo = array();
$own_host = wp_parse_url( home_url(), PHP_URL_HOST );
foreach ( $batch as $item ) {
$url = $item['url'];
if ( ! isset( $memo[ $url ] ) ) {
$before = self::$outbound;
// Internal URLs resolve against WordPress itself, never over
// HTTP: a loopback request from a server to its own domain
// flakes on plenty of hosts, and a health tool that cries
// wolf about the site's own live pages is worse than none.
$memo[ $url ] = ( wp_parse_url( $url, PHP_URL_HOST ) === $own_host )
? self::check_internal_url( $url )
: self::check_url( $url );
// Pace only what actually left the server. A memo hit or a
// link resolved out of the database has nobody to be polite
// to, and the internal check is the common case on most sites.
if ( self::$outbound > $before ) {
usleep( self::PACE_US );
}
}
$res = $memo[ $url ];
$state['checked']++;
if ( self::is_broken_status( $res['code'] ) && ! in_array( md5( $url ), $state['ignored'], true ) ) {
$state['broken'][] = array(
'url' => $url,
'code' => $res['code'],
'redirect' => $res['redirect'],
'internal' => ( wp_parse_url( $url, PHP_URL_HOST ) === $own_host ),
'post_id' => $item['post_id'],
'post_title' => $item['post_title'],
);
} elseif ( self::is_unverified_status( $res['code'] ) && ! in_array( md5( $url ), $state['ignored'], true ) ) {
$state['unverified'][] = array(
'url' => $url,
'code' => $res['code'],
'redirect' => $res['redirect'],
'internal' => ( wp_parse_url( $url, PHP_URL_HOST ) === $own_host ),
'post_id' => $item['post_id'],
'post_title' => $item['post_title'],
);
}
}
if ( empty( $state['queue'] ) ) {
$state['status'] = 'done';
}
self::save_state( $state );
return self::public_state( $state );
}
/**
* Resolve an internal URL against WordPress content instead of HTTP.
* Published post/page/attachment = fine. Known non-content routes
* (feeds, llms.txt, uploads, categories/tags via term lookup) = fine.
* Anything unresolvable = the 404 a visitor would get.
*/
private static function check_internal_url( $url ) {
$path = (string) wp_parse_url( $url, PHP_URL_PATH );
// Non-post routes that always exist on this site.
if ( preg_match( '#^/(wp-content|wp-json|feed|llms\.txt|robots\.txt|sitemap[^/]*\.xml)($|/)#i', $path ) || '/' === $path || '' === $path ) {
return array( 'code' => 200, 'redirect' => '' );
}
$post_id = url_to_postid( $url );
if ( $post_id && 'publish' === get_post_status( $post_id ) ) {
return array( 'code' => 200, 'redirect' => '' );
}
// Pages resolve by path, terms by slug; url_to_postid misses some.
$page = get_page_by_path( trim( $path, '/' ) );
if ( $page && 'publish' === $page->post_status ) {
return array( 'code' => 200, 'redirect' => '' );
}
$slug = basename( untrailingslashit( $path ) );
foreach ( array( 'category', 'post_tag' ) as $tax ) {
if ( get_term_by( 'slug', $slug, $tax ) ) {
return array( 'code' => 200, 'redirect' => '' );
}
}
// Custom routes from other plugins (shop pages, event calendars, member
// areas) resolve through the rewrite rules rather than through content
// lookups. Ask WordPress's own rules before calling anything dead: a
// false "your shop is broken" destroys trust in the whole feature.
global $wp_rewrite;
if ( $wp_rewrite ) {
$probe = ltrim( (string) $path, '/' );
foreach ( (array) $wp_rewrite->wp_rewrite_rules() as $pattern => $query ) {
// Skip the content catch-alls. WordPress's pagename/name rules
// match nearly every path, so trusting them would green-light
// every dead URL on the site. Those rules only resolve when the
// content exists, and the lookups above already proved it does
// not, so WordPress would serve a 404 here itself.
if ( preg_match( '#(^|&|\?)(pagename|name|category_name|tag|attachment|year)=#', (string) $query ) ) {
continue;
}
if ( @preg_match( "#^{$pattern}#", $probe ) ) {
return array( 'code' => 200, 'redirect' => '' );
}
}
}
// Nothing local claims this URL. One HTTP request settles it.
$res = self::check_url( $url );
if ( 599 !== $res['code'] ) {
return $res;
}
// The HTTP check could not run at all (host blocks loopback requests,
// or we are behind a proxy that cannot see our own hostname). We are
// NOT flying blind here: WordPress itself has already said no post,
// page, term or rewrite rule answers this path, and WordPress is what
// serves the URL. That is a 404. An earlier version returned 200 in
// this branch to avoid false alarms and quietly hid real dead links
// instead, which is the worse failure for a tool whose entire job is
// finding them.
return array( 'code' => 404, 'redirect' => '' );
}
/** One URL check. Returns final status code + redirect target when moved. */
private static function check_url( $url ) {
// Counted here rather than at the call site because check_internal_url()
// also lands here for paths WordPress cannot resolve, and those deserve
// the same pacing as any other outbound request.
self::$outbound++;
$args = array(
'timeout' => self::TIMEOUT,
'redirection' => 0,
'user-agent' => 'Mozilla/5.0 (compatible; AEOGodMode-LinkHealth/1.0; +' . home_url( '/' ) . ')',
);
$redirect_target = '';
$current = $url;
// Follow up to 5 redirects by hand so the FIRST hop's target is known.
for ( $hop = 0; $hop <= 5; $hop++ ) {
// Every hop must be a public http(s) URL, never a private/internal host.
if ( ! wp_http_validate_url( $current ) ) {
$code = 0;
break;
}
$resp = wp_safe_remote_head( $current, $args );
$code = is_wp_error( $resp ) ? 0 : (int) wp_remote_retrieve_response_code( $resp );
if ( in_array( $code, array( 403, 405, 0 ), true ) ) {
// Host dislikes HEAD (or errored): one GET attempt.
$resp = wp_safe_remote_get( $current, $args );
$code = is_wp_error( $resp ) ? 0 : (int) wp_remote_retrieve_response_code( $resp );
}
if ( $code >= 300 && $code < 400 ) {
$loc = wp_remote_retrieve_header( $resp, 'location' );
if ( ! $loc ) {
break;
}
if ( 0 === strpos( $loc, '/' ) ) {
$p = wp_parse_url( $current );
$loc = $p['scheme'] . '://' . $p['host'] . $loc;
}
if ( '' === $redirect_target ) {
$redirect_target = $loc;
}
$current = $loc;
continue;
}
// A network error counts as broken only when even GET failed.
if ( 0 === $code ) {
$code = 599;
}
return array( 'code' => $code, 'redirect' => $redirect_target );
}
return array( 'code' => 599, 'redirect' => $redirect_target );
}
/**
* One-click fix. 'unlink' removes the wrapper and keeps the words;
* 'redirect' rewrites the href to the recorded redirect target; 'ignore'
* hides the row from future scans. Every content edit is anchored to the
* exact stored URL, so nothing else in the post is touched.
*/
public static function apply_fix( $url, $post_id, $action, $new_url = '' ) {
$state = self::get_state();
if ( 'ignore' === $action ) {
$state['ignored'][] = md5( $url );
$state['ignored'] = array_values( array_unique( $state['ignored'] ) );
$state['broken'] = array_values( array_filter( $state['broken'], function ( $b ) use ( $url ) {
return $b['url'] !== $url;
} ) );
$state['unverified'] = array_values( array_filter( $state['unverified'], function ( $b ) use ( $url ) {
return $b['url'] !== $url;
} ) );
self::save_state( $state );
return array( 'success' => true, 'state' => self::public_state( $state ) );
}
// post_id 0 means "everywhere this URL appears". A dead URL used in
// eight posts is one decision, not eight, so the UI offers one button
// and this resolves it to the full set.
if ( 0 === (int) $post_id ) {
$targets = array();
foreach ( $state['broken'] as $b ) {
if ( $b['url'] === $url ) {
$targets[] = (int) $b['post_id'];
}
}
$targets = array_values( array_unique( $targets ) );
if ( empty( $targets ) ) {
return new \WP_Error( 'not_found', 'That link is no longer in the scan results.' );
}
$done = 0;
foreach ( $targets as $pid ) {
$res = self::apply_fix( $url, $pid, $action, $new_url );
if ( ! is_wp_error( $res ) ) {
$done++;
}
}
if ( 0 === $done ) {
return new \WP_Error( 'not_found', 'That link was not found in any post content (it may already be fixed).' );
}
return array( 'success' => true, 'fixed' => $done, 'state' => self::public_state() );
}
$post = get_post( (int) $post_id );
if ( ! $post ) {
return new \WP_Error( 'no_post', 'Source post not found.' );
}
$content = (string) $post->post_content;
$quoted = preg_quote( $url, '#' );
if ( 'unlink' === $action ) {
$updated = preg_replace( '#]*href=["\']' . $quoted . '["\'][^>]*>(.*?)#is', '$1', $content );
} elseif ( 'redirect' === $action ) {
$new_url = esc_url_raw( $new_url );
if ( '' === $new_url ) {
return new \WP_Error( 'no_target', 'No redirect target recorded for that link.' );
}
$updated = str_replace( $url, $new_url, $content );
} else {
return new \WP_Error( 'bad_action', 'Unknown fix action.' );
}
if ( null === $updated || $updated === $content ) {
return new \WP_Error( 'not_found', 'That link was not found in the post content (it may already be fixed).' );
}
wp_update_post( array( 'ID' => $post->ID, 'post_content' => $updated ) );
$state['broken'] = array_values( array_filter( $state['broken'], function ( $b ) use ( $url, $post ) {
return ! ( $b['url'] === $url && (int) $b['post_id'] === (int) $post->ID );
} ) );
self::save_state( $state );
return array( 'success' => true, 'state' => self::public_state( $state ) );
}
/**
* Best replacement for a dead INTERNAL link, when we can find one.
*
* Most internal 404s are a page that moved: /blog/get-cited-in-gemini when
* the post now lives at /how-to-get-cited-in-gemini/. Unlinking that throws
* away a link the author meant to make. Matching the dead slug against
* published content recovers the intent instead, which is the difference
* between a tidy-up tool and one that actually helps.
*
* Conservative on purpose: repointing a link at the WRONG page is far worse
* than leaving a dead link alone, because the author never finds out. So
* this refuses to guess, and a page it stays quiet about simply keeps the
* unlink button it already had.
*
* The test is nesting, not similarity: every word of one slug appears in
* the other. A page that moved keeps its words and may gain or lose a few
* ("get-cited-in-gemini" -> "how-to-get-cited-in-gemini"), while a genuinely
* different page always brings a word of its own that argues against the
* match ("...-in-claude" against "...-in-gemini", "turkish" against
* "pour-over"). A similarity score cannot tell those two apart, because
* both differ from their candidate by exactly one word, so every threshold
* either accepts both or rejects both.
*
* Measured by tools/coffee-kingz/seed/probe-link-suggest.php, which seeds a
* 44-slug corpus and asks for a suggestion on 41 labelled dead slugs (18
* real moves, 23 near-miss siblings that must stay silent). Re-run it before
* changing anything here. On that corpus:
*
* overlap >= 0.6 17 wrong, 18 right overlap >= 0.8 3 wrong, 17 right
* overlap >= 0.75 10 wrong, 17 right overlap >= 0.9 1 wrong, 17 right
* nesting 3 wrong, 16 right
*
* Read that honestly: nesting is not the most accurate rule on this corpus,
* and 0.75 (the rule this replaced) is plainly the worst of the four. What
* nesting buys is not a better score, it is a failure mode an author can
* live with. An overlap threshold divides by the DEAD slug's word count, so
* any short dead slug scores 1.0 against every longer slug containing its
* words, several candidates tie on that perfect score, and the old code
* silently took whichever post came back first. A suggestion decided by
* post order is not conservative at any threshold; it only looks accurate
* here because this corpus is small enough for the arbitrary pick to land
* well. Nesting bounds the size difference and refuses outright when two
* candidates fit, so the cases it cannot resolve become silence.
*
* The three it still gets wrong are all one shape: a qualifier that changes
* which page is meant but not which words appear (gemini-citation-tracking-
* setup, nitro-cold-brew-ratio-calculator-pro, coffee-brewing). Separating
* those needs to know that "gemini" and "nitro" narrow a topic while "guide"
* and "explained" do not, which is not knowable from slug words alone.
*
* @return array{url:string,title:string}|null
*/
private static function suggest_internal_target( $url ) {
$slug = basename( untrailingslashit( (string) wp_parse_url( $url, PHP_URL_PATH ) ) );
$slug = preg_replace( '/\.(html?|php)$/i', '', $slug );
if ( strlen( $slug ) < 6 ) {
return null;
}
$want = self::slug_tokens( $slug );
if ( count( $want ) < 2 ) {
return null;
}
$hits = array();
$posts = get_posts( array(
'post_type' => array( 'post', 'page' ),
'post_status' => 'publish',
'posts_per_page' => 300,
'fields' => 'ids',
'suppress_filters' => true,
) );
foreach ( (array) $posts as $pid ) {
$have = self::slug_tokens( get_post_field( 'post_name', $pid ) );
if ( empty( $have ) ) {
continue;
}
$shared = count( array_intersect( $want, $have ) );
// One word in common is a coincidence, not evidence: /agency-pricing
// is not /pricing and /guest-checkout is not /checkout.
if ( $shared < 2 ) {
continue;
}
if ( $shared !== count( $want ) && $shared !== count( $have ) ) {
continue;
}
if ( abs( count( $want ) - count( $have ) ) > self::SLUG_DRIFT ) {
continue;
}
$hits[] = $pid;
// Two live pages fit the dead slug equally well, so there is no
// way to tell which one the author meant. Picking either is a coin
// flip on the reader's behalf. The old code took whichever post
// was published most recently and said nothing about the tie.
if ( count( $hits ) > 1 ) {
return null;
}
}
if ( 1 !== count( $hits ) ) {
return null;
}
return array( 'url' => get_permalink( $hits[0] ), 'title' => get_the_title( $hits[0] ) );
}
/**
* Content words in a slug, deduplicated.
*
* Short words are dropped because "in", "of" and "to" are glue, not
* evidence. Deduplication matters because array_intersect() keeps repeats
* from its first argument: "cold-brew-vs-hot-brew" counted "brew" twice
* and scored itself a match it had not earned.
*/
private static function slug_tokens( $slug ) {
return array_values( array_unique( array_filter(
preg_split( '/[^a-z0-9]+/', strtolower( (string) $slug ), -1, PREG_SPLIT_NO_EMPTY ),
static function ( $t ) { return strlen( $t ) > 2; }
) ) );
}
/**
* Client-shaped state: everything except the raw queue.
*
* Broken links are grouped BY URL, not per occurrence. One dead URL used in
* eight posts is one problem with eight instances, and listing it eight
* times turns a 12-problem list into 43 rows of the same few URLs, which is
* how a maintenance tool becomes a chore nobody finishes.
*/
public static function public_state( $state = null ) {
$state = $state ?: self::get_state();
$group_rows = static function ( $rows ) {
$groups = array();
foreach ( $rows as $b ) {
$key = $b['url'];
if ( ! isset( $groups[ $key ] ) ) {
$groups[ $key ] = array(
'url' => $b['url'],
'code' => $b['code'],
'redirect' => $b['redirect'],
'internal' => ! empty( $b['internal'] ),
'occurrences' => array(),
);
}
$groups[ $key ]['occurrences'][] = array(
'post_id' => (int) $b['post_id'],
'post_title' => (string) $b['post_title'],
'edit_url' => get_edit_post_link( (int) $b['post_id'], 'raw' ),
);
}
return array_values( $groups );
};
$groups = $group_rows( $state['broken'] );
$unverified_groups = $group_rows( $state['unverified'] );
// Suggestions cost a query each, so only for the rows a user will see.
$groups = array_slice( array_values( $groups ), 0, 60 );
foreach ( $groups as &$g ) {
$g['count'] = count( $g['occurrences'] );
$g['suggestion'] = ( $g['internal'] && ! $g['redirect'] ) ? self::suggest_internal_target( $g['url'] ) : null;
}
unset( $g );
$unverified_groups = array_slice( $unverified_groups, 0, 60 );
foreach ( $unverified_groups as &$g ) {
$g['count'] = count( $g['occurrences'] );
}
unset( $g );
return array(
'status' => $state['status'],
'checked' => $state['checked'],
'total' => $state['total'],
'remaining' => count( $state['queue'] ),
'broken_count' => count( $state['broken'] ),
'url_count' => count( $groups ),
'broken' => $groups,
'unverified' => $unverified_groups,
'scanned_at' => $state['scanned_at'],
);
}
}