Auto Interlinking for WordPress [set and forget]

wishme

Junior Member
Joined
Dec 4, 2014
Messages
149
Reaction score
161
So here is a small gift for WordPress bloggers who don't care that much about how much related article is to be interlinked as long as it's somewhat related (probably perfect for the auto bloggers cooking shit left and right like me).
What it can do for the most part while it's not perfect, it does create easy setting access under the settings tab in admin:

1743520402879.png
1743520488630.png

This interlinking strategy follows these guidelines:
  • Links are generally not placed in headings.
  • Single-keyword anchor texts are avoided; the aim is typically 2-3 word keywords.
  • The same anchor text (keyword phrase) is not used more than once within a single post.
  • The same destination URL is not used for different anchor texts within the same post.
Also not tested but you can create sitemap URLs beforehand, etc if you have less than 200 blogs, all of them fit in sitemap.xml, but if you cross 200 posts, you get a second post sitemap2.xml etc so my point is you can add sitemap1, sitemap2, sitemap3 before hand so when they gonna be there it would use them for you not even having to remember to add them.

Also, I added some stop words that should be ignored and not taken into consideration when adding links.


If anyone wants to improve this further, be my guest, but please share it here with the community.


To use this Auto Interlinker, all you need is a simple code snippet plugin "WPCode" or "Code Snippets" are perfect.


PHP:
// ===========================================
// Constants and Defaults
// ===========================================
define('MY_INTERLINKING_OPTION_NAME', 'my_interlinking_options'); // Key to store settings in wp_options

function my_interlinking_get_defaults() {
    // Default values for the settings
    return [
        'sitemap_urls'              => '', // Stores newline separated URLs
        'max_links'                 => 6,
        'max_per_paragraph'         => 1,
        'cache_expiry'              => 6 * HOUR_IN_SECONDS,
        'min_sentences_to_skip'     => 3,
    ];
}

// ===========================================
// Stop Word Definition
// ===========================================
function my_interlinking_get_stop_words() {
    // Returns the array of stop words
    return [
        'a', 'about', 'above', 'after', 'again', 'against', 'all', 'am', 'an', 'and', 'any', 'are', 'aren\'t', 'as', 'at', 'be', 'because', 'been', 'before', 'being', 'below', 'between', 'both', 'but', 'by', 'can\'t', 'cannot', 'could', 'couldn\'t', 'did', 'didn\'t', 'do', 'does', 'doesn\'t', 'doing', 'don\'t', 'down', 'during', 'each', 'few', 'for', 'from', 'further', 'had', 'hadn\'t', 'has', 'hasn\'t', 'have', 'haven\'t', 'having', 'he', 'he\'d', 'he\'ll', 'he\'s', 'her', 'here', 'here\'s', 'hers', 'herself', 'him', 'himself', 'his', 'how', 'how\'s', 'i', 'i\'d', 'i\'ll', 'i\'m', 'i\'ve', 'if', 'in', 'into', 'is', 'isn\'t', 'it', 'it\'s', 'its', 'itself', 'let\'s', 'me', 'more', 'most', 'mustn\'t', 'my', 'myself', 'no', 'nor', 'not', 'of', 'off', 'on', 'once', 'only', 'or', 'other', 'ought', 'our', 'ours', 'ourselves', 'out', 'over', 'own', 'same', 'shan\'t', 'she', 'she\'d', 'she\'ll', 'she\'s', 'should', 'shouldn\'t', 'so', 'some', 'such', 'than', 'that', 'that\'s', 'the', 'their', 'theirs', 'them', 'themselves', 'then', 'there', 'there\'s', 'these', 'they', 'they\'d', 'they\'ll', 'they\'re', 'they\'ve', 'this', 'those', 'through', 'to', 'too', 'under', 'until', 'up', 'very', 'was', 'wasn\'t', 'we', 'we\'d', 'we\'ll', 'we\'re', 'we\'ve', 'were', 'weren\'t', 'what', 'what\'s', 'when', 'when\'s', 'where', 'where\'s', 'which', 'while', 'who', 'who\'s', 'whom', 'why', 'why\'s', 'with', 'won\'t', 'would', 'wouldn\'t', 'you', 'you\'d', 'you\'ll', 'you\'re', 'you\'ve', 'your', 'yours', 'yourself', 'yourselves'
    ];
}


// ===========================================
// Settings Page Setup Functions
// ===========================================

/** Add admin menu */
function my_interlinking_add_admin_menu() {
    add_options_page('Auto Interlinking Settings', 'Auto Interlinking', 'manage_options', 'my_interlinking_options_page', 'my_interlinking_options_page_html');
}

/** Register settings */
function my_interlinking_settings_init() {
    register_setting('my_interlinking_settings_group', MY_INTERLINKING_OPTION_NAME, 'my_interlinking_options_sanitize');
    add_settings_section('my_interlinking_main_section', 'Main Settings', 'my_interlinking_section_callback', 'my_interlinking_options_page');
    add_settings_field('sitemap_urls', 'Sitemap URLs (One per line)', 'my_interlinking_render_sitemap_urls_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('max_links', 'Max Links per Post', 'my_interlinking_render_max_links_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('max_per_paragraph', 'Max Links per Paragraph', 'my_interlinking_render_max_per_paragraph_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('min_sentences_to_skip', 'Sentences to Skip at Start', 'my_interlinking_render_min_sentences_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('cache_expiry', 'Sitemap Cache Expiry', 'my_interlinking_render_cache_expiry_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
}

/** Section callback */
function my_interlinking_section_callback() { echo '<p>Configure the settings for the automatic internal linking process.</p>'; }

/** Render Sitemap URLs Textarea */
function my_interlinking_render_sitemap_urls_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['sitemap_urls']) ? $options['sitemap_urls'] : ''; ?> <textarea rows="5" cols="50" class='regular-text' name='<?php echo MY_INTERLINKING_OPTION_NAME; ?>[sitemap_urls]'><?php echo esc_textarea($value); ?></textarea> <p class="description">Enter one full XML Sitemap URL per line. Required.</p> <?php }
/** Render Max Links Input */
function my_interlinking_render_max_links_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['max_links']) ? $options['max_links'] : my_interlinking_get_defaults()['max_links']; ?> <input type='number' step='1' min='0' max='20' class='small-text' name='<?php echo MY_INTERLINKING_OPTION_NAME; ?>[max_links]' value='<?php echo esc_attr($value); ?>'> <p class="description">Maximum number of internal links per post (0-20).</p> <?php }
/** Render Max Per Paragraph Input */
function my_interlinking_render_max_per_paragraph_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['max_per_paragraph']) ? $options['max_per_paragraph'] : my_interlinking_get_defaults()['max_per_paragraph']; ?> <input type='number' step='1' min='1' max='5' class='small-text' name='<?php echo MY_INTERLINKING_OPTION_NAME; ?>[max_per_paragraph]' value='<?php echo esc_attr($value); ?>'> <p class="description">Maximum number of links allowed in a single paragraph (1-5).</p> <?php }
/** Render Min Sentences Input */
function my_interlinking_render_min_sentences_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['min_sentences_to_skip']) ? $options['min_sentences_to_skip'] : my_interlinking_get_defaults()['min_sentences_to_skip']; ?> <input type='number' step='1' min='0' max='10' class='small-text' name='<?php echo MY_INTERLINKING_OPTION_NAME; ?>[min_sentences_to_skip]' value='<?php echo esc_attr($value); ?>'> <p class="description">Sentences at start where links should NOT be added (0-10).</p> <?php }
/** Render Cache Expiry Select */
function my_interlinking_render_cache_expiry_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $current_value = isset($options['cache_expiry']) ? $options['cache_expiry'] : my_interlinking_get_defaults()['cache_expiry']; $expiry_options = [ '3600' => '1 Hour', '10800' => '3 Hours', '21600' => '6 Hours', '43200' => '12 Hours', '86400' => '1 Day', '604800' => '1 Week' ]; ?> <select name='<?php echo MY_INTERLINKING_OPTION_NAME; ?>[cache_expiry]'><?php foreach ($expiry_options as $seconds => $label) : ?><option value="<?php echo esc_attr($seconds); ?>" <?php selected($current_value, $seconds); ?>><?php echo esc_html($label); ?></option><?php endforeach; ?></select><p class="description">How long sitemap data should be cached.</p> <?php }

/** Sanitize options */
function my_interlinking_options_sanitize($input) {
    $sanitized_input = []; $defaults = my_interlinking_get_defaults();
    $valid_urls = [];
    if (isset($input['sitemap_urls'])) { $potential_urls = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $input['sitemap_urls']))); foreach ($potential_urls as $url) { $sanitized_url = esc_url_raw($url); if (!empty($sanitized_url)) { $valid_urls[] = $sanitized_url; } } }
    $sanitized_input['sitemap_urls'] = implode("\n", $valid_urls);
    $sanitized_input['max_links'] = isset($input['max_links']) ? min(20, absint($input['max_links'])) : $defaults['max_links']; $sanitized_input['max_per_paragraph'] = isset($input['max_per_paragraph']) ? max(1, min(5, absint($input['max_per_paragraph']))) : $defaults['max_per_paragraph']; $sanitized_input['min_sentences_to_skip'] = isset($input['min_sentences_to_skip']) ? min(10, absint($input['min_sentences_to_skip'])) : $defaults['min_sentences_to_skip']; $valid_expiries = [3600, 10800, 21600, 43200, 86400, 604800]; $sanitized_input['cache_expiry'] = isset($input['cache_expiry']) && in_array(intval($input['cache_expiry']), $valid_expiries) ? intval($input['cache_expiry']) : $defaults['cache_expiry'];
    return $sanitized_input;
}

/** Render options page HTML */
function my_interlinking_options_page_html() { if (!current_user_can('manage_options')) return; ?> <div class="wrap"><h1><?php echo esc_html(get_admin_page_title()); ?></h1><form action="options.php" method="post"><?php settings_fields('my_interlinking_settings_group'); do_settings_sections('my_interlinking_options_page'); submit_button('Save Settings'); ?></form></div> <?php }


// ===========================================
// Core Interlinking Logic Functions
// ===========================================

/**
 * Fetches, parses, merges, and caches data from multiple sitemaps.
 * @return array|null Combined array of [url => slug] pairs or null on failure.
 */
function my_get_sitemap_data() {
    // Multi-sitemap fetch logic from V2.7/V2.8
    $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults());
    $sitemap_urls_string = isset($options['sitemap_urls']) ? $options['sitemap_urls'] : '';
    $cache_expiry = isset($options['cache_expiry']) ? absint($options['cache_expiry']) : (6 * HOUR_IN_SECONDS);
    $sitemap_urls = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $sitemap_urls_string)));
    if (empty($sitemap_urls)) return null;

    $cache_key_part = implode(',', $sitemap_urls);
    $cache_key = 'my_interlinking_sitemap_data_v2_multi_' . md5($cache_key_part);
    $cached_data = get_transient($cache_key);
    if (false !== $cached_data) return $cached_data;

    $combined_sitemap_links = []; $fetch_errors = 0;
    foreach ($sitemap_urls as $sitemap_url) {
        $response = wp_remote_get($sitemap_url, ['timeout' => 15]);
        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { error_log('Auto Interlinking Error: Failed to fetch sitemap (' . $sitemap_url . '). ' . (is_wp_error($response) ? $response->get_error_message() : 'Code: ' . wp_remote_retrieve_response_code($response))); $fetch_errors++; continue; }
        $xml_body = wp_remote_retrieve_body($response); if (empty($xml_body)) { error_log('Auto Interlinking Error: Sitemap body is empty (' . $sitemap_url . ').'); $fetch_errors++; continue; }
        libxml_use_internal_errors(true); $sitemap_xml = simplexml_load_string($xml_body); $xml_errors = libxml_get_errors(); libxml_clear_errors(); libxml_use_internal_errors(false); if (false === $sitemap_xml) { error_log('Auto Interlinking Error: Failed to parse sitemap XML (' . $sitemap_url . '). Errors: ' . print_r($xml_errors, true)); $fetch_errors++; continue; }
        if (isset($sitemap_xml->url)) { foreach ($sitemap_xml->url as $url_entry) { if (isset($url_entry->loc)) { $url = (string)$url_entry->loc; $url_normalized = rtrim(esc_url($url), '/'); $path = trailingslashit(parse_url($url_normalized, PHP_URL_PATH)); $slug = basename($path); if (!empty($url_normalized) && !empty($slug) && !isset($combined_sitemap_links[$url_normalized])) { $combined_sitemap_links[$url_normalized] = strtolower(str_replace('-', ' ', $slug)); } } } } elseif(isset($sitemap_xml->sitemap)) { error_log('Auto Interlinking Info: Sitemap index file (' . $sitemap_url . ') found. Sub-parsing not implemented.'); } else { error_log('Auto Interlinking Warning: No <url> or <sitemap> elements found in (' . $sitemap_url . ').'); }
    } // End foreach
    if ($fetch_errors < count($sitemap_urls) && !empty($combined_sitemap_links)) { set_transient($cache_key, $combined_sitemap_links, $cache_expiry); } elseif (empty($combined_sitemap_links) && $fetch_errors == count($sitemap_urls)) { error_log('Auto Interlinking Error: Failed to get any links from provided sitemaps.'); return null; }
    return $combined_sitemap_links;
}

/**
 * Check if a phrase contains stop words.
 */
function my_interlinking_contains_stop_words_v2( $phrase, $stop_words, $post_id_debug = 0 ) {
    // Logic confirmed working in user's V2.6 reference
    if ( empty( $stop_words ) ) return false; $words = preg_split( '/\s+/u', strtolower( $phrase ) );
    foreach ( $words as $word ) { $trimmed_word = trim( $word, " \t\n\r\0\x0B.,?!():;\"'" ); if ( ! empty( $trimmed_word ) ) { if ( in_array( $trimmed_word, $stop_words, true ) ) { return true; } } }
    return false;
}

/** Count sentences */
function my_interlinking_count_sentences($text) { return preg_match_all('/[.?!](\s+|$)/u', $text); }


/**
 * Main Interlinking Function using DOM V2.9
 * (Logic identical to user's working V2.6 reference)
 */
function my_auto_interlink_post_dom($post_id, $post) {
    // Safety checks
    if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; if (wp_is_post_revision($post_id)) return; if (wp_is_post_autosave($post_id)) return; if (!in_array($post->post_status, ['publish', 'future'])) return; if (isset($_POST['prev_status']) && !in_array($_POST['prev_status'], ['publish', 'draft', 'pending', 'private', 'future'])) return; if ($post->post_type !== 'post') return;

    // Get Settings
    $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults());
    $max_links = isset($options['max_links']) ? absint($options['max_links']) : 6;
    $max_per_p = isset($options['max_per_paragraph']) ? absint($options['max_per_paragraph']) : 1;
    $skip_sentences_threshold = isset($options['min_sentences_to_skip']) ? absint($options['min_sentences_to_skip']) : 3;
    if ($max_links <= 0) return;

    // Get Data (uses the multi-sitemap function)
    $sitemap_data = my_get_sitemap_data();
    $stop_words_list = my_interlinking_get_stop_words();
    if (empty($sitemap_data)) return;
    $current_post_url_normalized = rtrim(get_permalink($post_id), '/'); if (isset($sitemap_data[$current_post_url_normalized])) { unset($sitemap_data[$current_post_url_normalized]); } if (empty($sitemap_data)) return;

    // Prepare Content & Keywords (with stop word check)
    $original_content = $post->post_content; if (strlen($original_content) < 100) return;
    $text_content = wp_strip_all_tags($original_content); $text_content = preg_replace('/\s+/', ' ', $text_content); $words = preg_split('/\s+/u', $text_content); $potential_keywords = []; $word_count = count($words);
    for ($i = 0; $i < $word_count; $i++) {
        if ($i + 1 < $word_count) { $phrase2 = trim($words[$i] . ' ' . $words[$i + 1]); if (strlen($phrase2) > 5 && !is_numeric(substr($phrase2, 0, 1)) && !my_interlinking_contains_stop_words_v2($phrase2, $stop_words_list, $post_id)) { $potential_keywords[strtolower($phrase2)] = $phrase2; } }
        if ($i + 2 < $word_count) { $phrase3 = trim($words[$i] . ' ' . $words[$i + 1] . ' ' . $words[$i + 2]); if (strlen($phrase3) > 8 && !is_numeric(substr($phrase3, 0, 1)) && !my_interlinking_contains_stop_words_v2($phrase3, $stop_words_list, $post_id)) { $potential_keywords[strtolower($phrase3)] = $phrase3; } }
    }
    if (empty($potential_keywords)) return;
    $shuffled_keywords = array_keys($potential_keywords); shuffle($shuffled_keywords);

    // DOM Setup
    $links_added = 0; $used_urls = []; $used_keywords = []; $modified = false; $links_per_paragraph = [];
    $dom = new DOMDocument(); libxml_use_internal_errors(true); if (!$dom->loadHTML('<?xml encoding="UTF-8">' . $original_content, LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED )) { libxml_clear_errors(); return; } libxml_clear_errors(); libxml_use_internal_errors(false);
    $xpath = new DOMXPath($dom); $text_nodes = $xpath->query('//text()[normalize-space(.) != ""][not(ancestor::h1) and not(ancestor::h2) and not(ancestor::h3) and not(ancestor::h4) and not(ancestor::h5) and not(ancestor::h6) and not(ancestor::a) and not(ancestor::script) and not(ancestor::style)]');
    if ($text_nodes === false || $text_nodes->length === 0) return;
    $sentences_processed = 0;

    // Main Processing Loop (Identical logic to your V2.6 reference)
    foreach ($text_nodes as $node) {
        if ($links_added >= $max_links) break; $node_text = $node->nodeValue;
        $process_this_node = false; if ($sentences_processed >= $skip_sentences_threshold) { $process_this_node = true; } else { $sentences_in_node = my_interlinking_count_sentences($node_text); $sentences_processed += $sentences_in_node; if ($sentences_processed >= $skip_sentences_threshold) { $process_this_node = true; } } if (!$process_this_node) continue;
        $current_p_node = $xpath->query('ancestor::p[1]', $node)->item(0); $p_hash = $current_p_node ? spl_object_hash($current_p_node) : 'no_paragraph'; $count_in_p = isset($links_per_paragraph[$p_hash]) ? $links_per_paragraph[$p_hash] : 0; if ($count_in_p >= $max_per_p) continue;
        $node_modified_in_loop = false;
        foreach ($shuffled_keywords as $lower_keyword) {
            if ($node_modified_in_loop || $links_added >= $max_links) break;
            if (in_array($lower_keyword, $used_keywords, true)) continue; // Skip used keyword
            $original_case_keyword = $potential_keywords[$lower_keyword]; $position = mb_stripos($node_text, $original_case_keyword, 0, 'UTF-8');
            if ($position !== false) {
                $char_before = ($position > 0) ? mb_substr($node_text, $position - 1, 1, 'UTF-8') : ' '; $char_after_pos = $position + mb_strlen($original_case_keyword, 'UTF-8'); $char_after = ($char_after_pos < mb_strlen($node_text, 'UTF-8')) ? mb_substr($node_text, $char_after_pos, 1, 'UTF-8') : ' ';
                if (preg_match('/\W|^\s*$/u', $char_before) && preg_match('/\W|^\s*$/u', $char_after)) { // Word boundary
                    foreach ($sitemap_data as $url => $slug) {
                        if (in_array($url, $used_urls, true)) continue; // Skip used URL
                        if (strpos($slug, $lower_keyword) !== false) { // Relevance check
                            // DOM replacement
                            $text_before = mb_substr($node_text, 0, $position, 'UTF-8'); $text_after = mb_substr($node_text, $position + mb_strlen($original_case_keyword, 'UTF-8'), null, 'UTF-8'); $link = $dom->createElement('a', $original_case_keyword); $link->setAttribute('href', esc_url($url)); $node_before = !empty($text_before) ? $dom->createTextNode($text_before) : null; $node_after = !empty($text_after) ? $dom->createTextNode($text_after) : null; $parent = $node->parentNode; if (!$parent) continue;
                            if ($node_before) $parent->insertBefore($node_before, $node); $parent->insertBefore($link, $node); if ($node_after) $parent->insertBefore($node_after, $node); $parent->removeChild($node);
                            // Update state
                            $links_added++; $used_urls[] = $url; $used_keywords[] = $lower_keyword; $modified = true; $node_modified_in_loop = true; $links_per_paragraph[$p_hash] = $count_in_p + 1;
                            break 2; // Exit loops
                        }
                    } // End sitemap URL loop
                } // End word boundary check
            } // End keyword position check
        } // End keyword loop
    } // End text node loop

    // Update Post
    if ($modified) {
        $new_content = ''; foreach ($dom->childNodes as $child) { $new_content .= $dom->saveHTML($child); } if(empty(trim($new_content))) { $bodyNode = $xpath->query('//body')->item(0); if ($bodyNode) { foreach ($bodyNode->childNodes as $child) { $new_content .= $dom->saveHTML($child); } } }
        if (empty($new_content) || strlen($new_content) < strlen($original_content) / 3) { error_log("Auto Interlinking DOM Error ($post_id): New content invalid after saveHTML (V2.9). Aborting update."); return; }
        remove_action('save_post_post', 'my_auto_interlink_post_dom', 20); wp_update_post(['ID' => $post_id, 'post_content' => $new_content]); add_action('save_post_post', 'my_auto_interlink_post_dom', 20, 2);
        error_log("Auto Interlinking DOM ($post_id): Updated post with {$links_added} links (V2.9).");
    }
}

// ===========================================
// Action Hooks
// ===========================================
add_action('admin_menu', 'my_interlinking_add_admin_menu');
add_action('admin_init', 'my_interlinking_settings_init');
add_action('save_post_post', 'my_auto_interlink_post_dom', 20, 2);

?> // Added closing PHP tag just in case it was expected
 
Last edited:
Here is a bit more updated code, improved stop word filter and sentence skipping at the start of the blog posts.

PHP:
// ===========================================
// Constants and Defaults
// ===========================================
define('MY_INTERLINKING_OPTION_NAME', 'my_interlinking_options'); // Key to store settings in wp_options

function my_interlinking_get_defaults() {
    // Default values for the settings
    return [
        'sitemap_urls'          => '', // Stores newline separated URLs
        'max_links'             => 6,
        'max_per_paragraph'     => 1,
        'cache_expiry'          => 6 * HOUR_IN_SECONDS, // Default 6 hours
        'min_sentences_to_skip' => 3,
    ];
}

// ===========================================
// Stop Word Definition
// ===========================================
function my_interlinking_get_stop_words() {
    // *** VERIFY THIS LIST IS COMPLETE FOR YOUR NEEDS ***
    // Returns the array of stop words. Add any missing words here.
    return [
        'a', 'about', 'above', 'after', 'again', 'against', 'all', 'am', 'an', 'and', 'any', 'are', 'aren\'t', 'as', 'at', 'be', 'because', 'been', 'before', 'being', 'below', 'between', 'both', 'but', 'by', 'can\'t', 'cannot', 'could', 'couldn\'t', 'did', 'didn\'t', 'do', 'does', 'doesn\'t', 'doing', 'don\'t', 'down', 'during', 'each', 'few', 'for', 'from', 'further', 'had', 'hadn\'t', 'has', 'hasn\'t', 'have', 'haven\'t', 'having', 'he', 'he\'d', 'he\'ll', 'he\'s', 'her', 'here', 'here\'s', 'hers', 'herself', 'him', 'himself', 'his', 'how', 'how\'s', 'i', 'i\'d', 'i\'ll', 'i\'m', 'i\'ve', 'if', 'in', 'into', 'is', 'isn\'t', 'it', 'it\'s', 'its', 'itself', 'let\'s', 'me', 'more', 'most', 'mustn\'t', 'my', 'myself', 'no', 'nor', 'not', 'of', 'off', 'on', 'once', 'only', 'or', 'other', 'ought', 'our', 'ours', 'ourselves', 'out', 'over', 'own', 'same', 'shan\'t', 'she', 'she\'d', 'she\'ll', 'she\'s', 'should', 'shouldn\'t', 'so', 'some', 'such', 'than', 'that', 'that\'s', 'the', 'their', 'theirs', 'them', 'themselves', 'then', 'there', 'there\'s', 'these', 'they', 'they\'d', 'they\'ll', 'they\'re', 'they\'ve', 'this', 'those', 'through', 'to', 'too', 'under', 'until', 'up', 'very', 'was', 'wasn\'t', 'we', 'we\'d', 'we\'ll', 'we\'re', 'we\'ve', 'were', 'weren\'t', 'what', 'what\'s', 'when', 'when\'s', 'where', 'where\'s', 'which', 'while', 'who', 'who\'s', 'whom', 'why', 'why\'s', 'with', 'won\'t', 'would', 'wouldn\'t', 'you', 'you\'d', 'you\'ll', 'you\'re', 'you\'ve', 'your', 'yours', 'yourself', 'yourselves'
        // Add other words like: 'get', 'like', 'just', 'make', 'see', etc. if needed
    ];
}


// ===========================================
// Settings Page Setup Functions
// ===========================================

/** Add admin menu */
function my_interlinking_add_admin_menu() {
    add_options_page('Auto Interlinking Settings', 'Auto Interlinking', 'manage_options', 'my_interlinking_options_page', 'my_interlinking_options_page_html');
}

/** Register settings */
function my_interlinking_settings_init() {
    register_setting('my_interlinking_settings_group', MY_INTERLINKING_OPTION_NAME, 'my_interlinking_options_sanitize');
    add_settings_section('my_interlinking_main_section', 'Main Settings', 'my_interlinking_section_callback', 'my_interlinking_options_page');
    add_settings_field('sitemap_urls', 'Sitemap URLs (One per line)', 'my_interlinking_render_sitemap_urls_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('max_links', 'Max Links per Post', 'my_interlinking_render_max_links_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('max_per_paragraph', 'Max Links per Paragraph', 'my_interlinking_render_max_per_paragraph_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('min_sentences_to_skip', 'Sentences to Skip at Start', 'my_interlinking_render_min_sentences_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('cache_expiry', 'Sitemap Cache Expiry', 'my_interlinking_render_cache_expiry_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
}

/** Section callback */
function my_interlinking_section_callback() { echo '<p>Configure the settings for the automatic internal linking process.</p>'; }

/** Render Sitemap URLs Textarea */
function my_interlinking_render_sitemap_urls_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['sitemap_urls']) ? $options['sitemap_urls'] : ''; ?> <textarea rows="5" cols="50" class='regular-text' name='<?php echo esc_attr(MY_INTERLINKING_OPTION_NAME); ?>[sitemap_urls]'><?php echo esc_textarea($value); ?></textarea> <p class="description">Enter one full XML Sitemap URL per line. Required.</p> <?php }
/** Render Max Links Input */
function my_interlinking_render_max_links_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['max_links']) ? $options['max_links'] : my_interlinking_get_defaults()['max_links']; ?> <input type='number' step='1' min='0' max='20' class='small-text' name='<?php echo esc_attr(MY_INTERLINKING_OPTION_NAME); ?>[max_links]' value='<?php echo esc_attr($value); ?>'> <p class="description">Maximum number of internal links per post (0-20).</p> <?php }
/** Render Max Per Paragraph Input */
function my_interlinking_render_max_per_paragraph_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['max_per_paragraph']) ? $options['max_per_paragraph'] : my_interlinking_get_defaults()['max_per_paragraph']; ?> <input type='number' step='1' min='1' max='5' class='small-text' name='<?php echo esc_attr(MY_INTERLINKING_OPTION_NAME); ?>[max_per_paragraph]' value='<?php echo esc_attr($value); ?>'> <p class="description">Maximum number of links allowed in a single paragraph (1-5).</p> <?php }
/** Render Min Sentences Input */
function my_interlinking_render_min_sentences_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['min_sentences_to_skip']) ? $options['min_sentences_to_skip'] : my_interlinking_get_defaults()['min_sentences_to_skip']; ?> <input type='number' step='1' min='0' max='10' class='small-text' name='<?php echo esc_attr(MY_INTERLINKING_OPTION_NAME); ?>[min_sentences_to_skip]' value='<?php echo esc_attr($value); ?>'> <p class="description">Sentences at start where links should NOT be added (0-10).</p> <?php }
/** Render Cache Expiry Select */
function my_interlinking_render_cache_expiry_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $current_value = isset($options['cache_expiry']) ? $options['cache_expiry'] : my_interlinking_get_defaults()['cache_expiry']; $expiry_options = [ '3600' => '1 Hour', '10800' => '3 Hours', '21600' => '6 Hours', '43200' => '12 Hours', '86400' => '1 Day', '604800' => '1 Week' ]; ?> <select name='<?php echo esc_attr(MY_INTERLINKING_OPTION_NAME); ?>[cache_expiry]'><?php foreach ($expiry_options as $seconds => $label) : ?><option value="<?php echo esc_attr($seconds); ?>" <?php selected($current_value, $seconds); ?>><?php echo esc_html($label); ?></option><?php endforeach; ?></select><p class="description">How long sitemap data should be cached.</p> <?php }

/** Sanitize options */
function my_interlinking_options_sanitize($input) {
    $sanitized_input = []; $defaults = my_interlinking_get_defaults();
    $valid_urls = [];
    if (isset($input['sitemap_urls'])) { $potential_urls = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $input['sitemap_urls']))); foreach ($potential_urls as $url) { $sanitized_url = esc_url_raw(trim($url)); if (!empty($sanitized_url) && filter_var($sanitized_url, FILTER_VALIDATE_URL)) { $valid_urls[] = $sanitized_url; } } }
    $sanitized_input['sitemap_urls'] = implode("\n", $valid_urls);
    $sanitized_input['max_links'] = isset($input['max_links']) ? min(20, absint($input['max_links'])) : $defaults['max_links']; $sanitized_input['max_per_paragraph'] = isset($input['max_per_paragraph']) ? max(1, min(5, absint($input['max_per_paragraph']))) : $defaults['max_per_paragraph']; $sanitized_input['min_sentences_to_skip'] = isset($input['min_sentences_to_skip']) ? min(10, absint($input['min_sentences_to_skip'])) : $defaults['min_sentences_to_skip']; $valid_expiries = [3600, 10800, 21600, 43200, 86400, 604800]; $sanitized_input['cache_expiry'] = isset($input['cache_expiry']) && in_array(intval($input['cache_expiry']), $valid_expiries, true) ? intval($input['cache_expiry']) : $defaults['cache_expiry'];
    return $sanitized_input;
}

/** Render options page HTML */
function my_interlinking_options_page_html() { if (!current_user_can('manage_options')) return; ?> <div class="wrap"><h1><?php echo esc_html(get_admin_page_title()); ?></h1><form action="options.php" method="post"><?php settings_fields('my_interlinking_settings_group'); do_settings_sections('my_interlinking_options_page'); submit_button('Save Settings'); ?></form></div> <?php }


// ===========================================
// Core Interlinking Logic Functions
// ===========================================

/**
 * Fetches, parses, merges, and caches data from multiple sitemaps.
 * @return array|null Combined array of [url => slug] pairs or null on failure/empty.
 */
function my_get_sitemap_data() {
    $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults());
    $sitemap_urls_string = isset($options['sitemap_urls']) ? $options['sitemap_urls'] : '';
    $cache_expiry = isset($options['cache_expiry']) ? absint($options['cache_expiry']) : (6 * HOUR_IN_SECONDS);
    $sitemap_urls = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $sitemap_urls_string)));

    if (empty($sitemap_urls)) {
        if (is_admin()) {
             add_action('admin_notices', function() {
                 echo '<div class="notice notice-warning is-dismissible"><p>Auto Interlinking: No sitemap URLs configured in Settings -> Auto Interlinking. Linking will not occur.</p></div>';
             });
        }
        error_log('Auto Interlinking Warning: No sitemap URLs configured.');
        return null;
    }

    $cache_key_part = implode(',', $sitemap_urls);
    $cache_key = 'my_interlinking_sitemap_data_v2_multi_' . md5($cache_key_part);
    $cached_data = get_transient($cache_key);

    if (false !== $cached_data) {
        return $cached_data;
    }

    $combined_sitemap_links = [];
    $fetch_errors = 0;
    $processed_urls = [];

    foreach ($sitemap_urls as $sitemap_url) {
        $response = wp_remote_get($sitemap_url, ['timeout' => 15, 'sslverify' => true]);

        if (is_wp_error($response)) {
            error_log('Auto Interlinking Error: Failed to fetch sitemap (' . $sitemap_url . '). WP_Error: ' . $response->get_error_message());
            $fetch_errors++;
            continue;
        }

        $status_code = wp_remote_retrieve_response_code($response);
        if ($status_code !== 200) {
            error_log('Auto Interlinking Error: Failed to fetch sitemap (' . $sitemap_url . '). HTTP Status Code: ' . $status_code);
            $fetch_errors++;
            continue;
        }

        $xml_body = wp_remote_retrieve_body($response);
        if (empty($xml_body)) {
            error_log('Auto Interlinking Error: Sitemap body is empty (' . $sitemap_url . ').');
            $fetch_errors++;
            continue;
        }

        libxml_use_internal_errors(true);
        $sitemap_xml = simplexml_load_string($xml_body, 'SimpleXMLElement', LIBXML_NOCDATA);
        $xml_errors = libxml_get_errors();
        libxml_clear_errors();
        libxml_use_internal_errors(false);

        if (false === $sitemap_xml) {
            $error_messages = [];
            foreach ($xml_errors as $error) { $error_messages[] = trim($error->message) . ' (Line: ' . $error->line . ')'; }
            error_log('Auto Interlinking Error: Failed to parse sitemap XML (' . $sitemap_url . '). Errors: ' . implode('; ', $error_messages));
            $fetch_errors++;
            continue;
        }

        if (isset($sitemap_xml->url)) {
            foreach ($sitemap_xml->url as $url_entry) {
                if (isset($url_entry->loc)) {
                    $url = (string)$url_entry->loc;
                    $url_normalized = rtrim(esc_url_raw(trim($url)), '/');
                    $path = parse_url($url_normalized, PHP_URL_PATH);

                    if ($path && trim($path, '/') !== '') {
                        $slug = basename($path);
                        if (!empty($url_normalized) && !empty($slug) && !isset($processed_urls[$url_normalized])) {
                            $combined_sitemap_links[$url_normalized] = strtolower(str_replace(['-', '_'], ' ', $slug));
                            $processed_urls[$url_normalized] = true;
                        }
                    }
                }
            }
        } elseif (isset($sitemap_xml->sitemap)) {
             error_log('Auto Interlinking Info: Sitemap index file found (' . $sitemap_url . '). This script does not recursively parse index files. Please provide direct sitemap URLs containing <url> elements.');
        } else {
            error_log('Auto Interlinking Warning: No <url> or <sitemap> elements found in (' . $sitemap_url . '). Ensure it is a valid sitemap or sitemap index.');
        }
    } // End foreach sitemap URL

    if (!empty($combined_sitemap_links) && $fetch_errors < count($sitemap_urls)) {
        set_transient($cache_key, $combined_sitemap_links, $cache_expiry);
        if (count($combined_sitemap_links) > 0) {
             // Log only if links were actually found and cached
             error_log('Auto Interlinking: Successfully fetched and cached ' . count($combined_sitemap_links) . ' links from sitemaps.');
        } else if ($fetch_errors == 0) {
            // Log if sitemaps were processed fine but yielded no links
             error_log('Auto Interlinking Warning: Processed sitemaps successfully but found 0 valid links to cache.');
        }
    } elseif (empty($combined_sitemap_links)) {
        error_log('Auto Interlinking Error: Failed to retrieve any valid links from the provided sitemap URLs after processing.');
        return null;
    } else {
         set_transient($cache_key, $combined_sitemap_links, $cache_expiry);
         error_log('Auto Interlinking Warning: Fetched and cached ' . count($combined_sitemap_links) . ' links, but errors occurred fetching ' . $fetch_errors . ' sitemap(s).');
    }

    return $combined_sitemap_links;
}

/**
 * Check if a phrase contains ANY stop words.
 * Returns true if ANY word in the phrase is a stop word, false otherwise.
 */
function my_interlinking_contains_stop_words( $phrase, $stop_words ) {
    // Check if stop words list or phrase is empty
    if ( empty( $stop_words ) ) return false;
    $trimmed_phrase = trim($phrase);
    if ( empty( $trimmed_phrase ) ) return false;

    // Split phrase into words based on whitespace
    $words = preg_split( '/\s+/u', strtolower( $trimmed_phrase ) );

    foreach ( $words as $word ) {
        // Trim common punctuation from each word before checking
        // Consider enhancing trimming if needed for specific cases (e.g., internal hyphens)
        $trimmed_word = trim( $word, " \t\n\r\0\x0B.,?!():;\"'" );

        // Check if the trimmed word is non-empty and exists in the stop word list
        if ( ! empty( $trimmed_word ) && in_array( $trimmed_word, $stop_words, true ) ) {
            return true; // Found a stop word, return true immediately
        }
    }

    // If the loop completes without finding any stop word
    return false;
}


/**
 * Count sentences in a given text based on terminal punctuation.
 */
function my_interlinking_count_sentences($text) {
    // Matches . ? ! followed by whitespace or end of string
    return preg_match_all('/[.?!](?:\s+|$)/u', $text);
}


/**
 * Main Interlinking Function using DOM V2.9.2 (Corrected Stop Word Check)
 */
function my_auto_interlink_post_dom($post_id, $post) {
    // Safety checks & conditions
    if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
    if (wp_is_post_revision($post_id)) return;
    if (wp_is_post_autosave($post_id)) return;
    if (!in_array($post->post_status, ['publish', 'future'])) return;
    $is_newly_published = isset($_POST['original_post_status']) && !in_array($_POST['original_post_status'], ['publish', 'future']) && $post->post_status === 'publish';
    $is_update = isset($_POST['original_post_status']) && in_array($_POST['original_post_status'], ['publish', 'future']);
    // Allow running if it's a direct publish/schedule event or an update to published/scheduled post
    if (!$is_newly_published && !$is_update && !in_array($post->post_status, ['publish', 'future'])) return;

    if ($post->post_type !== 'post') return; // Only run on 'post' type

    // Get Settings
    $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults());
    $max_links = isset($options['max_links']) ? absint($options['max_links']) : 6;
    $max_per_p = isset($options['max_per_paragraph']) ? absint($options['max_per_paragraph']) : 1;
    $skip_sentences_threshold = isset($options['min_sentences_to_skip']) ? absint($options['min_sentences_to_skip']) : 3;

    if ($max_links <= 0) return; // Linking disabled

    // Get Data
    $sitemap_data = my_get_sitemap_data();
    $stop_words_list = my_interlinking_get_stop_words(); // Get the list of stop words

    if (empty($sitemap_data)) {
        // Error logged within my_get_sitemap_data if fetching failed
        return;
    }

    // Remove current post from targets
    $current_post_url_normalized = rtrim(get_permalink($post_id), '/');
    if (isset($sitemap_data[$current_post_url_normalized])) {
        unset($sitemap_data[$current_post_url_normalized]);
    }

    if (empty($sitemap_data)) {
        // Log if only self-link was available
        error_log("Auto Interlinking ($post_id): No potential link targets remain after removing self. Aborting.");
        return;
    }

    // Prepare Content & Extract Potential Keywords
    $original_content = $post->post_content;
    // Check minimum length based on stripped tags content
    if (mb_strlen(strip_tags($original_content)) < 100) {
         return; // Skip very short posts
    }

    $text_content = wp_strip_all_tags($original_content);
    $text_content = preg_replace('/\s+/u', ' ', $text_content); // Normalize whitespace
    $words = preg_split('/\s+/u', $text_content, -1, PREG_SPLIT_NO_EMPTY); // Split into words
    $potential_keywords = [];
    $word_count = count($words);

    // Define min/max length for keyword phrases
    $min_phrase_len = 5;
    $max_phrase_len = 35;

    // Loop through words to create 2 and 3-word phrases
    for ($i = 0; $i < $word_count; $i++) {
        // 2-word phrases
        if ($i + 1 < $word_count) {
            $phrase2 = trim($words[$i] . ' ' . $words[$i + 1]);
            $len2 = mb_strlen($phrase2, 'UTF-8');
            // Check: length, not numeric, AND DOES NOT contain stop words
            if ($len2 >= $min_phrase_len && $len2 <= $max_phrase_len && !is_numeric(mb_substr($phrase2, 0, 1, 'UTF-8')) && !my_interlinking_contains_stop_words($phrase2, $stop_words_list)) {
                 $potential_keywords[strtolower($phrase2)] = $phrase2; // Store original case, key is lowercase
            }
        }
        // 3-word phrases
        if ($i + 2 < $word_count) {
            $phrase3 = trim($words[$i] . ' ' . $words[$i + 1] . ' ' . $words[$i + 2]);
            $len3 = mb_strlen($phrase3, 'UTF-8');
             // Check: length, not numeric, AND DOES NOT contain stop words
            if ($len3 >= $min_phrase_len && $len3 <= $max_phrase_len && !is_numeric(mb_substr($phrase3, 0, 1, 'UTF-8')) && !my_interlinking_contains_stop_words($phrase3, $stop_words_list)) {
                $potential_keywords[strtolower($phrase3)] = $phrase3; // Store original case, key is lowercase
            }
        }
    }

    if (empty($potential_keywords)) {
        // Log if no suitable keywords were found after filtering
        error_log("Auto Interlinking ($post_id): No suitable keywords found after filtering stop words and checking criteria. Skipping linking.");
        return;
    }

    // Shuffle keywords for random selection
    $shuffled_keywords = array_keys($potential_keywords);
    shuffle($shuffled_keywords);

    // DOM Setup
    $links_added = 0;
    $used_urls = []; // Track used destination URLs
    $used_keywords = []; // Track used keyword phrases (lowercase)
    $modified = false;
    $links_per_paragraph = [];

    $dom = new DOMDocument();
    libxml_use_internal_errors(true); // Suppress HTML5 parsing warnings
    // Load HTML content with UTF-8 encoding hint
    if (!$dom->loadHTML('<?xml encoding="UTF-8">' . $original_content, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD)) {
        error_log("Auto Interlinking DOM Error ($post_id): Failed to load post content into DOMDocument.");
        libxml_clear_errors();
        return;
    }
    libxml_clear_errors(); // Clear any loading errors
    libxml_use_internal_errors(false); // Restore error handling
    $dom->encoding = 'UTF-8'; // Ensure DOM encoding is set

    $xpath = new DOMXPath($dom);
    // Query for text nodes not inside excluded elements
    $text_nodes = $xpath->query('//text()[normalize-space(.) != ""][not(ancestor::h1) and not(ancestor::h2) and not(ancestor::h3) and not(ancestor::h4) and not(ancestor::h5) and not(ancestor::h6) and not(ancestor::a) and not(ancestor::script) and not(ancestor::style) and not(ancestor::pre) and not(ancestor::blockquote)]');

    if ($text_nodes === false || $text_nodes->length === 0) {
        // Log if no processable text nodes found
        error_log("Auto Interlinking ($post_id): No suitable text nodes found for processing. Skipping linking.");
        return;
    }

    $sentences_processed = 0; // Counter for skipping initial sentences

    // Main Processing Loop over text nodes
    foreach ($text_nodes as $node) {
        if ($links_added >= $max_links) {
            break; // Stop if max links limit reached
        }
        $node_text = $node->nodeValue;

        // Sentence Skipping Logic
        if ($skip_sentences_threshold > 0 && $sentences_processed < $skip_sentences_threshold) {
            $sentences_in_node = my_interlinking_count_sentences($node_text);
            $sentences_processed += $sentences_in_node;
            continue; // Skip this node if still within the initial sentences to skip
        }

        // Paragraph Link Limit Logic
        $current_p_node = $xpath->query('ancestor::p[1]', $node)->item(0);
        $p_hash = $current_p_node ? spl_object_hash($current_p_node) : 'no_p_hash_' . crc32($node->getNodePath()); // Hash for paragraph tracking
        $count_in_p = isset($links_per_paragraph[$p_hash]) ? $links_per_paragraph[$p_hash] : 0;

        if ($max_per_p > 0 && $count_in_p >= $max_per_p) {
            continue; // Skip if max links for this paragraph reached
        }

        $node_modified_in_loop = false; // Flag to prevent multiple links in the same original text node segment

        // Loop through shuffled keywords
        foreach ($shuffled_keywords as $lower_keyword) {
            if ($node_modified_in_loop) break; // Stop if node already modified
            if ($links_added >= $max_links) break; // Stop if global max links reached

            if (in_array($lower_keyword, $used_keywords, true)) {
                continue; // Skip if this keyword phrase was already used
            }

            $original_case_keyword = $potential_keywords[$lower_keyword];
            // Case-insensitive search for the keyword in the current text node
            $position = mb_stripos($node_text, $original_case_keyword, 0, 'UTF-8');

            if ($position !== false) { // Keyword found
                // Check word boundaries
                $char_before = ($position > 0) ? mb_substr($node_text, $position - 1, 1, 'UTF-8') : ' ';
                $keyword_len = mb_strlen($original_case_keyword, 'UTF-8');
                $char_after_pos = $position + $keyword_len;
                $char_after = ($char_after_pos < mb_strlen($node_text, 'UTF-8')) ? mb_substr($node_text, $char_after_pos, 1, 'UTF-8') : ' ';

                if (preg_match('/(^|\W)/u', $char_before) && preg_match('/(\W|$)/u', $char_after)) { // Boundaries are good

                    // Check against sitemap data for relevance
                    foreach ($sitemap_data as $url => $slug) {
                        if (in_array($url, $used_urls, true)) {
                            continue; // Skip if this destination URL was already used
                        }

                        // Relevance check: keyword appears in target slug
                        if (strpos($slug, $lower_keyword) !== false) {

                            // --- Perform DOM Replacement ---
                            $text_before = mb_substr($node_text, 0, $position, 'UTF-8');
                            $matched_text = mb_substr($node_text, $position, $keyword_len, 'UTF-8'); // Get actual matched text
                            $text_after = mb_substr($node_text, $char_after_pos, null, 'UTF-8');

                            $link = $dom->createElement('a', $matched_text); // Use matched text for link content
                            $link->setAttribute('href', esc_url($url));

                            $node_before = !empty($text_before) ? $dom->createTextNode($text_before) : null;
                            $node_after = !empty($text_after) ? $dom->createTextNode($text_after) : null;
                            $parent = $node->parentNode;

                            if (!$parent) {
                                error_log("Auto Interlinking DOM Error ($post_id): Parent node not found for text node replacement. Path: " . $node->getNodePath());
                                continue; // Skip this replacement if parent is missing
                            }

                            // Insert new nodes and remove original
                            if ($node_before) $parent->insertBefore($node_before, $node);
                            $parent->insertBefore($link, $node);
                            if ($node_after) $parent->insertBefore($node_after, $node);
                            $parent->removeChild($node);

                            // Update trackers
                            $links_added++;
                            $used_urls[] = $url;
                            $used_keywords[] = $lower_keyword;
                            $modified = true;
                            $node_modified_in_loop = true;
                            $links_per_paragraph[$p_hash] = $count_in_p + 1;

                            break 2; // Exit sitemap and keyword loops for this node
                        } // End relevance check
                    } // End sitemap loop
                } // End word boundary check
            } // End keyword found check
        } // End keyword loop
    } // End text node loop

    // Update Post Content if Modified
    if ($modified) {
        $new_content = '';
        $temp_doc = new DOMDocument();
        $temp_doc->encoding = 'UTF-8'; // Ensure encoding
        // Import nodes from the modified DOM to the temp doc
        foreach ($dom->childNodes as $child) {
             $importedNode = @$temp_doc->importNode($child, true); // Use @ to suppress potential import errors if node types are incompatible
             if ($importedNode) {
                 $temp_doc->appendChild($importedNode);
             } else {
                  // Log if import fails, might indicate complex/unusual DOM structure
                  error_log("Auto Interlinking DOM Warning ($post_id): Failed to import node during saveHTML process. Node Path: " . $child->getNodePath());
             }
        }
        // Save the HTML from the temporary document
        $new_content = trim($temp_doc->saveHTML());

        // Sanity check the new content
        if (empty($new_content) || mb_strlen($new_content) < mb_strlen($original_content) / 3) {
            error_log("Auto Interlinking DOM Error ($post_id): New content invalid or significantly shorter after saveHTML. Original length: " . mb_strlen($original_content) . ", New length: " . mb_strlen($new_content) . ". Aborting update.");
            return; // Prevent saving potentially broken content
        }

        // Prevent infinite loop during update
        remove_action('save_post_post', 'my_auto_interlink_post_dom', 20);

        // Update the post
        $update_result = wp_update_post([
            'ID'           => $post_id,
            'post_content' => $new_content // Use the reconstructed HTML
        ], true); // Return WP_Error on failure

         // Log success or failure of update
         if (is_wp_error($update_result)) {
              error_log("Auto Interlinking Error ($post_id): Failed to update post. WP_Error: " . $update_result->get_error_message());
         } else {
              error_log("Auto Interlinking ($post_id): Successfully updated post with {$links_added} link(s).");
         }

        // Re-add the hook
        add_action('save_post_post', 'my_auto_interlink_post_dom', 20, 2);

    } else {
        // Log if no modifications were made (optional)
        // error_log("Auto Interlinking ($post_id): Processing complete. No links added or content modified.");
    }
}

// ===========================================
// Action Hooks
// ===========================================
add_action('admin_menu', 'my_interlinking_add_admin_menu');
add_action('admin_init', 'my_interlinking_settings_init');
add_action('save_post_post', 'my_auto_interlink_post_dom', 20, 2); // Hook for 'post' type updates

// Note: No closing ?> tag
 
So here is a small gift for WordPress bloggers who don't care that much about how much related article is to be interlinked as long as it's somewhat related (probably perfect for the auto bloggers cooking shit left and right like me).
What it can do for the most part while it's not perfect, it does create easy setting access under the settings tab in admin:

View attachment 434094
View attachment 434095

This interlinking strategy follows these guidelines:
  • Links are generally not placed in headings.
  • Single-keyword anchor texts are avoided; the aim is typically 2-3 word keywords.
  • The same anchor text (keyword phrase) is not used more than once within a single post.
  • The same destination URL is not used for different anchor texts within the same post.
Also not tested but you can create sitemap URLs beforehand, etc if you have less than 200 blogs, all of them fit in sitemap.xml, but if you cross 200 posts, you get a second post sitemap2.xml etc so my point is you can add sitemap1, sitemap2, sitemap3 before hand so when they gonna be there it would use them for you not even having to remember to add them.

Also, I added some stop words that should be ignored and not taken into consideration when adding links.


If anyone wants to improve this further, be my guest, but please share it here with the community.


To use this Auto Interlinker, all you need is a simple code snippet plugin "WPCode" or "Code Snippets" are perfect.


PHP:
// ===========================================
// Constants and Defaults
// ===========================================
define('MY_INTERLINKING_OPTION_NAME', 'my_interlinking_options'); // Key to store settings in wp_options

function my_interlinking_get_defaults() {
    // Default values for the settings
    return [
        'sitemap_urls'              => '', // Stores newline separated URLs
        'max_links'                 => 6,
        'max_per_paragraph'         => 1,
        'cache_expiry'              => 6 * HOUR_IN_SECONDS,
        'min_sentences_to_skip'     => 3,
    ];
}

// ===========================================
// Stop Word Definition
// ===========================================
function my_interlinking_get_stop_words() {
    // Returns the array of stop words
    return [
        'a', 'about', 'above', 'after', 'again', 'against', 'all', 'am', 'an', 'and', 'any', 'are', 'aren\'t', 'as', 'at', 'be', 'because', 'been', 'before', 'being', 'below', 'between', 'both', 'but', 'by', 'can\'t', 'cannot', 'could', 'couldn\'t', 'did', 'didn\'t', 'do', 'does', 'doesn\'t', 'doing', 'don\'t', 'down', 'during', 'each', 'few', 'for', 'from', 'further', 'had', 'hadn\'t', 'has', 'hasn\'t', 'have', 'haven\'t', 'having', 'he', 'he\'d', 'he\'ll', 'he\'s', 'her', 'here', 'here\'s', 'hers', 'herself', 'him', 'himself', 'his', 'how', 'how\'s', 'i', 'i\'d', 'i\'ll', 'i\'m', 'i\'ve', 'if', 'in', 'into', 'is', 'isn\'t', 'it', 'it\'s', 'its', 'itself', 'let\'s', 'me', 'more', 'most', 'mustn\'t', 'my', 'myself', 'no', 'nor', 'not', 'of', 'off', 'on', 'once', 'only', 'or', 'other', 'ought', 'our', 'ours', 'ourselves', 'out', 'over', 'own', 'same', 'shan\'t', 'she', 'she\'d', 'she\'ll', 'she\'s', 'should', 'shouldn\'t', 'so', 'some', 'such', 'than', 'that', 'that\'s', 'the', 'their', 'theirs', 'them', 'themselves', 'then', 'there', 'there\'s', 'these', 'they', 'they\'d', 'they\'ll', 'they\'re', 'they\'ve', 'this', 'those', 'through', 'to', 'too', 'under', 'until', 'up', 'very', 'was', 'wasn\'t', 'we', 'we\'d', 'we\'ll', 'we\'re', 'we\'ve', 'were', 'weren\'t', 'what', 'what\'s', 'when', 'when\'s', 'where', 'where\'s', 'which', 'while', 'who', 'who\'s', 'whom', 'why', 'why\'s', 'with', 'won\'t', 'would', 'wouldn\'t', 'you', 'you\'d', 'you\'ll', 'you\'re', 'you\'ve', 'your', 'yours', 'yourself', 'yourselves'
    ];
}


// ===========================================
// Settings Page Setup Functions
// ===========================================

/** Add admin menu */
function my_interlinking_add_admin_menu() {
    add_options_page('Auto Interlinking Settings', 'Auto Interlinking', 'manage_options', 'my_interlinking_options_page', 'my_interlinking_options_page_html');
}

/** Register settings */
function my_interlinking_settings_init() {
    register_setting('my_interlinking_settings_group', MY_INTERLINKING_OPTION_NAME, 'my_interlinking_options_sanitize');
    add_settings_section('my_interlinking_main_section', 'Main Settings', 'my_interlinking_section_callback', 'my_interlinking_options_page');
    add_settings_field('sitemap_urls', 'Sitemap URLs (One per line)', 'my_interlinking_render_sitemap_urls_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('max_links', 'Max Links per Post', 'my_interlinking_render_max_links_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('max_per_paragraph', 'Max Links per Paragraph', 'my_interlinking_render_max_per_paragraph_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('min_sentences_to_skip', 'Sentences to Skip at Start', 'my_interlinking_render_min_sentences_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('cache_expiry', 'Sitemap Cache Expiry', 'my_interlinking_render_cache_expiry_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
}

/** Section callback */
function my_interlinking_section_callback() { echo '<p>Configure the settings for the automatic internal linking process.</p>'; }

/** Render Sitemap URLs Textarea */
function my_interlinking_render_sitemap_urls_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['sitemap_urls']) ? $options['sitemap_urls'] : ''; ?> <textarea rows="5" cols="50" class='regular-text' name='<?php echo MY_INTERLINKING_OPTION_NAME; ?>[sitemap_urls]'><?php echo esc_textarea($value); ?></textarea> <p class="description">Enter one full XML Sitemap URL per line. Required.</p> <?php }
/** Render Max Links Input */
function my_interlinking_render_max_links_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['max_links']) ? $options['max_links'] : my_interlinking_get_defaults()['max_links']; ?> <input type='number' step='1' min='0' max='20' class='small-text' name='<?php echo MY_INTERLINKING_OPTION_NAME; ?>[max_links]' value='<?php echo esc_attr($value); ?>'> <p class="description">Maximum number of internal links per post (0-20).</p> <?php }
/** Render Max Per Paragraph Input */
function my_interlinking_render_max_per_paragraph_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['max_per_paragraph']) ? $options['max_per_paragraph'] : my_interlinking_get_defaults()['max_per_paragraph']; ?> <input type='number' step='1' min='1' max='5' class='small-text' name='<?php echo MY_INTERLINKING_OPTION_NAME; ?>[max_per_paragraph]' value='<?php echo esc_attr($value); ?>'> <p class="description">Maximum number of links allowed in a single paragraph (1-5).</p> <?php }
/** Render Min Sentences Input */
function my_interlinking_render_min_sentences_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['min_sentences_to_skip']) ? $options['min_sentences_to_skip'] : my_interlinking_get_defaults()['min_sentences_to_skip']; ?> <input type='number' step='1' min='0' max='10' class='small-text' name='<?php echo MY_INTERLINKING_OPTION_NAME; ?>[min_sentences_to_skip]' value='<?php echo esc_attr($value); ?>'> <p class="description">Sentences at start where links should NOT be added (0-10).</p> <?php }
/** Render Cache Expiry Select */
function my_interlinking_render_cache_expiry_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $current_value = isset($options['cache_expiry']) ? $options['cache_expiry'] : my_interlinking_get_defaults()['cache_expiry']; $expiry_options = [ '3600' => '1 Hour', '10800' => '3 Hours', '21600' => '6 Hours', '43200' => '12 Hours', '86400' => '1 Day', '604800' => '1 Week' ]; ?> <select name='<?php echo MY_INTERLINKING_OPTION_NAME; ?>[cache_expiry]'><?php foreach ($expiry_options as $seconds => $label) : ?><option value="<?php echo esc_attr($seconds); ?>" <?php selected($current_value, $seconds); ?>><?php echo esc_html($label); ?></option><?php endforeach; ?></select><p class="description">How long sitemap data should be cached.</p> <?php }

/** Sanitize options */
function my_interlinking_options_sanitize($input) {
    $sanitized_input = []; $defaults = my_interlinking_get_defaults();
    $valid_urls = [];
    if (isset($input['sitemap_urls'])) { $potential_urls = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $input['sitemap_urls']))); foreach ($potential_urls as $url) { $sanitized_url = esc_url_raw($url); if (!empty($sanitized_url)) { $valid_urls[] = $sanitized_url; } } }
    $sanitized_input['sitemap_urls'] = implode("\n", $valid_urls);
    $sanitized_input['max_links'] = isset($input['max_links']) ? min(20, absint($input['max_links'])) : $defaults['max_links']; $sanitized_input['max_per_paragraph'] = isset($input['max_per_paragraph']) ? max(1, min(5, absint($input['max_per_paragraph']))) : $defaults['max_per_paragraph']; $sanitized_input['min_sentences_to_skip'] = isset($input['min_sentences_to_skip']) ? min(10, absint($input['min_sentences_to_skip'])) : $defaults['min_sentences_to_skip']; $valid_expiries = [3600, 10800, 21600, 43200, 86400, 604800]; $sanitized_input['cache_expiry'] = isset($input['cache_expiry']) && in_array(intval($input['cache_expiry']), $valid_expiries) ? intval($input['cache_expiry']) : $defaults['cache_expiry'];
    return $sanitized_input;
}

/** Render options page HTML */
function my_interlinking_options_page_html() { if (!current_user_can('manage_options')) return; ?> <div class="wrap"><h1><?php echo esc_html(get_admin_page_title()); ?></h1><form action="options.php" method="post"><?php settings_fields('my_interlinking_settings_group'); do_settings_sections('my_interlinking_options_page'); submit_button('Save Settings'); ?></form></div> <?php }


// ===========================================
// Core Interlinking Logic Functions
// ===========================================

/**
 * Fetches, parses, merges, and caches data from multiple sitemaps.
 * @return array|null Combined array of [url => slug] pairs or null on failure.
 */
function my_get_sitemap_data() {
    // Multi-sitemap fetch logic from V2.7/V2.8
    $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults());
    $sitemap_urls_string = isset($options['sitemap_urls']) ? $options['sitemap_urls'] : '';
    $cache_expiry = isset($options['cache_expiry']) ? absint($options['cache_expiry']) : (6 * HOUR_IN_SECONDS);
    $sitemap_urls = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $sitemap_urls_string)));
    if (empty($sitemap_urls)) return null;

    $cache_key_part = implode(',', $sitemap_urls);
    $cache_key = 'my_interlinking_sitemap_data_v2_multi_' . md5($cache_key_part);
    $cached_data = get_transient($cache_key);
    if (false !== $cached_data) return $cached_data;

    $combined_sitemap_links = []; $fetch_errors = 0;
    foreach ($sitemap_urls as $sitemap_url) {
        $response = wp_remote_get($sitemap_url, ['timeout' => 15]);
        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { error_log('Auto Interlinking Error: Failed to fetch sitemap (' . $sitemap_url . '). ' . (is_wp_error($response) ? $response->get_error_message() : 'Code: ' . wp_remote_retrieve_response_code($response))); $fetch_errors++; continue; }
        $xml_body = wp_remote_retrieve_body($response); if (empty($xml_body)) { error_log('Auto Interlinking Error: Sitemap body is empty (' . $sitemap_url . ').'); $fetch_errors++; continue; }
        libxml_use_internal_errors(true); $sitemap_xml = simplexml_load_string($xml_body); $xml_errors = libxml_get_errors(); libxml_clear_errors(); libxml_use_internal_errors(false); if (false === $sitemap_xml) { error_log('Auto Interlinking Error: Failed to parse sitemap XML (' . $sitemap_url . '). Errors: ' . print_r($xml_errors, true)); $fetch_errors++; continue; }
        if (isset($sitemap_xml->url)) { foreach ($sitemap_xml->url as $url_entry) { if (isset($url_entry->loc)) { $url = (string)$url_entry->loc; $url_normalized = rtrim(esc_url($url), '/'); $path = trailingslashit(parse_url($url_normalized, PHP_URL_PATH)); $slug = basename($path); if (!empty($url_normalized) && !empty($slug) && !isset($combined_sitemap_links[$url_normalized])) { $combined_sitemap_links[$url_normalized] = strtolower(str_replace('-', ' ', $slug)); } } } } elseif(isset($sitemap_xml->sitemap)) { error_log('Auto Interlinking Info: Sitemap index file (' . $sitemap_url . ') found. Sub-parsing not implemented.'); } else { error_log('Auto Interlinking Warning: No <url> or <sitemap> elements found in (' . $sitemap_url . ').'); }
    } // End foreach
    if ($fetch_errors < count($sitemap_urls) && !empty($combined_sitemap_links)) { set_transient($cache_key, $combined_sitemap_links, $cache_expiry); } elseif (empty($combined_sitemap_links) && $fetch_errors == count($sitemap_urls)) { error_log('Auto Interlinking Error: Failed to get any links from provided sitemaps.'); return null; }
    return $combined_sitemap_links;
}

/**
 * Check if a phrase contains stop words.
 */
function my_interlinking_contains_stop_words_v2( $phrase, $stop_words, $post_id_debug = 0 ) {
    // Logic confirmed working in user's V2.6 reference
    if ( empty( $stop_words ) ) return false; $words = preg_split( '/\s+/u', strtolower( $phrase ) );
    foreach ( $words as $word ) { $trimmed_word = trim( $word, " \t\n\r\0\x0B.,?!():;\"'" ); if ( ! empty( $trimmed_word ) ) { if ( in_array( $trimmed_word, $stop_words, true ) ) { return true; } } }
    return false;
}

/** Count sentences */
function my_interlinking_count_sentences($text) { return preg_match_all('/[.?!](\s+|$)/u', $text); }


/**
 * Main Interlinking Function using DOM V2.9
 * (Logic identical to user's working V2.6 reference)
 */
function my_auto_interlink_post_dom($post_id, $post) {
    // Safety checks
    if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; if (wp_is_post_revision($post_id)) return; if (wp_is_post_autosave($post_id)) return; if (!in_array($post->post_status, ['publish', 'future'])) return; if (isset($_POST['prev_status']) && !in_array($_POST['prev_status'], ['publish', 'draft', 'pending', 'private', 'future'])) return; if ($post->post_type !== 'post') return;

    // Get Settings
    $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults());
    $max_links = isset($options['max_links']) ? absint($options['max_links']) : 6;
    $max_per_p = isset($options['max_per_paragraph']) ? absint($options['max_per_paragraph']) : 1;
    $skip_sentences_threshold = isset($options['min_sentences_to_skip']) ? absint($options['min_sentences_to_skip']) : 3;
    if ($max_links <= 0) return;

    // Get Data (uses the multi-sitemap function)
    $sitemap_data = my_get_sitemap_data();
    $stop_words_list = my_interlinking_get_stop_words();
    if (empty($sitemap_data)) return;
    $current_post_url_normalized = rtrim(get_permalink($post_id), '/'); if (isset($sitemap_data[$current_post_url_normalized])) { unset($sitemap_data[$current_post_url_normalized]); } if (empty($sitemap_data)) return;

    // Prepare Content & Keywords (with stop word check)
    $original_content = $post->post_content; if (strlen($original_content) < 100) return;
    $text_content = wp_strip_all_tags($original_content); $text_content = preg_replace('/\s+/', ' ', $text_content); $words = preg_split('/\s+/u', $text_content); $potential_keywords = []; $word_count = count($words);
    for ($i = 0; $i < $word_count; $i++) {
        if ($i + 1 < $word_count) { $phrase2 = trim($words[$i] . ' ' . $words[$i + 1]); if (strlen($phrase2) > 5 && !is_numeric(substr($phrase2, 0, 1)) && !my_interlinking_contains_stop_words_v2($phrase2, $stop_words_list, $post_id)) { $potential_keywords[strtolower($phrase2)] = $phrase2; } }
        if ($i + 2 < $word_count) { $phrase3 = trim($words[$i] . ' ' . $words[$i + 1] . ' ' . $words[$i + 2]); if (strlen($phrase3) > 8 && !is_numeric(substr($phrase3, 0, 1)) && !my_interlinking_contains_stop_words_v2($phrase3, $stop_words_list, $post_id)) { $potential_keywords[strtolower($phrase3)] = $phrase3; } }
    }
    if (empty($potential_keywords)) return;
    $shuffled_keywords = array_keys($potential_keywords); shuffle($shuffled_keywords);

    // DOM Setup
    $links_added = 0; $used_urls = []; $used_keywords = []; $modified = false; $links_per_paragraph = [];
    $dom = new DOMDocument(); libxml_use_internal_errors(true); if (!$dom->loadHTML('<?xml encoding="UTF-8">' . $original_content, LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED )) { libxml_clear_errors(); return; } libxml_clear_errors(); libxml_use_internal_errors(false);
    $xpath = new DOMXPath($dom); $text_nodes = $xpath->query('//text()[normalize-space(.) != ""][not(ancestor::h1) and not(ancestor::h2) and not(ancestor::h3) and not(ancestor::h4) and not(ancestor::h5) and not(ancestor::h6) and not(ancestor::a) and not(ancestor::script) and not(ancestor::style)]');
    if ($text_nodes === false || $text_nodes->length === 0) return;
    $sentences_processed = 0;

    // Main Processing Loop (Identical logic to your V2.6 reference)
    foreach ($text_nodes as $node) {
        if ($links_added >= $max_links) break; $node_text = $node->nodeValue;
        $process_this_node = false; if ($sentences_processed >= $skip_sentences_threshold) { $process_this_node = true; } else { $sentences_in_node = my_interlinking_count_sentences($node_text); $sentences_processed += $sentences_in_node; if ($sentences_processed >= $skip_sentences_threshold) { $process_this_node = true; } } if (!$process_this_node) continue;
        $current_p_node = $xpath->query('ancestor::p[1]', $node)->item(0); $p_hash = $current_p_node ? spl_object_hash($current_p_node) : 'no_paragraph'; $count_in_p = isset($links_per_paragraph[$p_hash]) ? $links_per_paragraph[$p_hash] : 0; if ($count_in_p >= $max_per_p) continue;
        $node_modified_in_loop = false;
        foreach ($shuffled_keywords as $lower_keyword) {
            if ($node_modified_in_loop || $links_added >= $max_links) break;
            if (in_array($lower_keyword, $used_keywords, true)) continue; // Skip used keyword
            $original_case_keyword = $potential_keywords[$lower_keyword]; $position = mb_stripos($node_text, $original_case_keyword, 0, 'UTF-8');
            if ($position !== false) {
                $char_before = ($position > 0) ? mb_substr($node_text, $position - 1, 1, 'UTF-8') : ' '; $char_after_pos = $position + mb_strlen($original_case_keyword, 'UTF-8'); $char_after = ($char_after_pos < mb_strlen($node_text, 'UTF-8')) ? mb_substr($node_text, $char_after_pos, 1, 'UTF-8') : ' ';
                if (preg_match('/\W|^\s*$/u', $char_before) && preg_match('/\W|^\s*$/u', $char_after)) { // Word boundary
                    foreach ($sitemap_data as $url => $slug) {
                        if (in_array($url, $used_urls, true)) continue; // Skip used URL
                        if (strpos($slug, $lower_keyword) !== false) { // Relevance check
                            // DOM replacement
                            $text_before = mb_substr($node_text, 0, $position, 'UTF-8'); $text_after = mb_substr($node_text, $position + mb_strlen($original_case_keyword, 'UTF-8'), null, 'UTF-8'); $link = $dom->createElement('a', $original_case_keyword); $link->setAttribute('href', esc_url($url)); $node_before = !empty($text_before) ? $dom->createTextNode($text_before) : null; $node_after = !empty($text_after) ? $dom->createTextNode($text_after) : null; $parent = $node->parentNode; if (!$parent) continue;
                            if ($node_before) $parent->insertBefore($node_before, $node); $parent->insertBefore($link, $node); if ($node_after) $parent->insertBefore($node_after, $node); $parent->removeChild($node);
                            // Update state
                            $links_added++; $used_urls[] = $url; $used_keywords[] = $lower_keyword; $modified = true; $node_modified_in_loop = true; $links_per_paragraph[$p_hash] = $count_in_p + 1;
                            break 2; // Exit loops
                        }
                    } // End sitemap URL loop
                } // End word boundary check
            } // End keyword position check
        } // End keyword loop
    } // End text node loop

    // Update Post
    if ($modified) {
        $new_content = ''; foreach ($dom->childNodes as $child) { $new_content .= $dom->saveHTML($child); } if(empty(trim($new_content))) { $bodyNode = $xpath->query('//body')->item(0); if ($bodyNode) { foreach ($bodyNode->childNodes as $child) { $new_content .= $dom->saveHTML($child); } } }
        if (empty($new_content) || strlen($new_content) < strlen($original_content) / 3) { error_log("Auto Interlinking DOM Error ($post_id): New content invalid after saveHTML (V2.9). Aborting update."); return; }
        remove_action('save_post_post', 'my_auto_interlink_post_dom', 20); wp_update_post(['ID' => $post_id, 'post_content' => $new_content]); add_action('save_post_post', 'my_auto_interlink_post_dom', 20, 2);
        error_log("Auto Interlinking DOM ($post_id): Updated post with {$links_added} links (V2.9).");
    }
}

// ===========================================
// Action Hooks
// ===========================================
add_action('admin_menu', 'my_interlinking_add_admin_menu');
add_action('admin_init', 'my_interlinking_settings_init');
add_action('save_post_post', 'my_auto_interlink_post_dom', 20, 2);

?> // Added closing PHP tag just in case it was expected
So I don't need Link whisper anymore. Good one.

Gems like this is why I don't ever miss the BHW Weekly Newsletter.
 
When I try to add the php snippet, and click save, I get "forbidden". I know this is a me issue and not an issue with the code but does anyone know how I can fix it?
 
So I don't need Link whisper anymore. Good one.

Gems like this is why I don't ever miss the BHW Weekly Newsletter.
Been using them, but I just don't get it... Not all users need the complexities that they offer. I guess like 80% of people just want links to be chained from blog post to blog post with somewhat relative anchor text used as related context. And there is nothing out there...
I have added the code using WPCode, added the post sitemap but I don't see any related posts when i update a post! Can you tell me what is wrong here?
Ok, so what you have to do is:

1. Install code sippet plugin (any of your choice)
2. Create a custom code snippet in there.
3. Copy and paste the code from my comment where I said, "I had done some updates".
4. In the admin menu of WordPress, hover or click on the "setting" tab that is on the right side of the admin menu.
5. There, you will see the "Auto Interlinking" tab; click on it.
6. Once you click it, you will be in "Auto Interlinking Settings".
7. In there, if not needed, do not change anything that you don't feel like understanding. All you need to do is just add your sitemap links there, etc.: "https://yoursite.com/post-sitemap.xml".
7.1 If you don't know what your sitemap links are, simply visit https://yoursite.com/robots.txt and look for a link containing something related to "sitemap".
7.2 If your site has a lot of posts, you will see multiple "post-sitemap.xml" numbered, etc.: "post-sitemap1.xml", "post-sitemap2.xml", and so on.
7.3 If you have a service or a product sitemap in there as well and you would like to have your service or products linked in your posts, you can take their (service/product) sitemaps and paste it in "Auto Interlinking" settings.
7.4 If you have just one "post-sitemap.xml" link in sitemap, you can still add multiple "feature" ones in "Auto Interlinking" settings, in that way you may rest assured when your blog post count crosses to another post sitemap it will be automatically used if you included it in "Auto Interlinking" settings.

Hopefully didn't forget to mention something important :D

When I try to add the php snippet, and click save, I get "forbidden". I know this is a me issue and not an issue with the code but does anyone know how I can fix it?

To be honest, it's hard to tell. Try another snippet plugin just in case it might work?


Will it increase server load? Or it sleep for a few seconds after adding links between posts?

It depends on if you pumping 500k articles in one go? I would say that might cause server overload, as each post is interlinked live on publishing. I initially mentioned that you could update your old posts and get them all interlinked, but depending on your server power, I would not go and update more than 20 posts at a time.



And a big thanks, not sure to whom, for pining this post in this forum! <3

 
Here is a bit more updated code, improved stop word filter and sentence skipping at the start of the blog posts.

PHP:
// ===========================================
// Constants and Defaults
// ===========================================
define('MY_INTERLINKING_OPTION_NAME', 'my_interlinking_options'); // Key to store settings in wp_options

function my_interlinking_get_defaults() {
    // Default values for the settings
    return [
        'sitemap_urls'          => '', // Stores newline separated URLs
        'max_links'             => 6,
        'max_per_paragraph'     => 1,
        'cache_expiry'          => 6 * HOUR_IN_SECONDS, // Default 6 hours
        'min_sentences_to_skip' => 3,
    ];
}

// ===========================================
// Stop Word Definition
// ===========================================
function my_interlinking_get_stop_words() {
    // *** VERIFY THIS LIST IS COMPLETE FOR YOUR NEEDS ***
    // Returns the array of stop words. Add any missing words here.
    return [
        'a', 'about', 'above', 'after', 'again', 'against', 'all', 'am', 'an', 'and', 'any', 'are', 'aren\'t', 'as', 'at', 'be', 'because', 'been', 'before', 'being', 'below', 'between', 'both', 'but', 'by', 'can\'t', 'cannot', 'could', 'couldn\'t', 'did', 'didn\'t', 'do', 'does', 'doesn\'t', 'doing', 'don\'t', 'down', 'during', 'each', 'few', 'for', 'from', 'further', 'had', 'hadn\'t', 'has', 'hasn\'t', 'have', 'haven\'t', 'having', 'he', 'he\'d', 'he\'ll', 'he\'s', 'her', 'here', 'here\'s', 'hers', 'herself', 'him', 'himself', 'his', 'how', 'how\'s', 'i', 'i\'d', 'i\'ll', 'i\'m', 'i\'ve', 'if', 'in', 'into', 'is', 'isn\'t', 'it', 'it\'s', 'its', 'itself', 'let\'s', 'me', 'more', 'most', 'mustn\'t', 'my', 'myself', 'no', 'nor', 'not', 'of', 'off', 'on', 'once', 'only', 'or', 'other', 'ought', 'our', 'ours', 'ourselves', 'out', 'over', 'own', 'same', 'shan\'t', 'she', 'she\'d', 'she\'ll', 'she\'s', 'should', 'shouldn\'t', 'so', 'some', 'such', 'than', 'that', 'that\'s', 'the', 'their', 'theirs', 'them', 'themselves', 'then', 'there', 'there\'s', 'these', 'they', 'they\'d', 'they\'ll', 'they\'re', 'they\'ve', 'this', 'those', 'through', 'to', 'too', 'under', 'until', 'up', 'very', 'was', 'wasn\'t', 'we', 'we\'d', 'we\'ll', 'we\'re', 'we\'ve', 'were', 'weren\'t', 'what', 'what\'s', 'when', 'when\'s', 'where', 'where\'s', 'which', 'while', 'who', 'who\'s', 'whom', 'why', 'why\'s', 'with', 'won\'t', 'would', 'wouldn\'t', 'you', 'you\'d', 'you\'ll', 'you\'re', 'you\'ve', 'your', 'yours', 'yourself', 'yourselves'
        // Add other words like: 'get', 'like', 'just', 'make', 'see', etc. if needed
    ];
}


// ===========================================
// Settings Page Setup Functions
// ===========================================

/** Add admin menu */
function my_interlinking_add_admin_menu() {
    add_options_page('Auto Interlinking Settings', 'Auto Interlinking', 'manage_options', 'my_interlinking_options_page', 'my_interlinking_options_page_html');
}

/** Register settings */
function my_interlinking_settings_init() {
    register_setting('my_interlinking_settings_group', MY_INTERLINKING_OPTION_NAME, 'my_interlinking_options_sanitize');
    add_settings_section('my_interlinking_main_section', 'Main Settings', 'my_interlinking_section_callback', 'my_interlinking_options_page');
    add_settings_field('sitemap_urls', 'Sitemap URLs (One per line)', 'my_interlinking_render_sitemap_urls_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('max_links', 'Max Links per Post', 'my_interlinking_render_max_links_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('max_per_paragraph', 'Max Links per Paragraph', 'my_interlinking_render_max_per_paragraph_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('min_sentences_to_skip', 'Sentences to Skip at Start', 'my_interlinking_render_min_sentences_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
    add_settings_field('cache_expiry', 'Sitemap Cache Expiry', 'my_interlinking_render_cache_expiry_field', 'my_interlinking_options_page', 'my_interlinking_main_section');
}

/** Section callback */
function my_interlinking_section_callback() { echo '<p>Configure the settings for the automatic internal linking process.</p>'; }

/** Render Sitemap URLs Textarea */
function my_interlinking_render_sitemap_urls_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['sitemap_urls']) ? $options['sitemap_urls'] : ''; ?> <textarea rows="5" cols="50" class='regular-text' name='<?php echo esc_attr(MY_INTERLINKING_OPTION_NAME); ?>[sitemap_urls]'><?php echo esc_textarea($value); ?></textarea> <p class="description">Enter one full XML Sitemap URL per line. Required.</p> <?php }
/** Render Max Links Input */
function my_interlinking_render_max_links_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['max_links']) ? $options['max_links'] : my_interlinking_get_defaults()['max_links']; ?> <input type='number' step='1' min='0' max='20' class='small-text' name='<?php echo esc_attr(MY_INTERLINKING_OPTION_NAME); ?>[max_links]' value='<?php echo esc_attr($value); ?>'> <p class="description">Maximum number of internal links per post (0-20).</p> <?php }
/** Render Max Per Paragraph Input */
function my_interlinking_render_max_per_paragraph_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['max_per_paragraph']) ? $options['max_per_paragraph'] : my_interlinking_get_defaults()['max_per_paragraph']; ?> <input type='number' step='1' min='1' max='5' class='small-text' name='<?php echo esc_attr(MY_INTERLINKING_OPTION_NAME); ?>[max_per_paragraph]' value='<?php echo esc_attr($value); ?>'> <p class="description">Maximum number of links allowed in a single paragraph (1-5).</p> <?php }
/** Render Min Sentences Input */
function my_interlinking_render_min_sentences_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $value = isset($options['min_sentences_to_skip']) ? $options['min_sentences_to_skip'] : my_interlinking_get_defaults()['min_sentences_to_skip']; ?> <input type='number' step='1' min='0' max='10' class='small-text' name='<?php echo esc_attr(MY_INTERLINKING_OPTION_NAME); ?>[min_sentences_to_skip]' value='<?php echo esc_attr($value); ?>'> <p class="description">Sentences at start where links should NOT be added (0-10).</p> <?php }
/** Render Cache Expiry Select */
function my_interlinking_render_cache_expiry_field() { $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults()); $current_value = isset($options['cache_expiry']) ? $options['cache_expiry'] : my_interlinking_get_defaults()['cache_expiry']; $expiry_options = [ '3600' => '1 Hour', '10800' => '3 Hours', '21600' => '6 Hours', '43200' => '12 Hours', '86400' => '1 Day', '604800' => '1 Week' ]; ?> <select name='<?php echo esc_attr(MY_INTERLINKING_OPTION_NAME); ?>[cache_expiry]'><?php foreach ($expiry_options as $seconds => $label) : ?><option value="<?php echo esc_attr($seconds); ?>" <?php selected($current_value, $seconds); ?>><?php echo esc_html($label); ?></option><?php endforeach; ?></select><p class="description">How long sitemap data should be cached.</p> <?php }

/** Sanitize options */
function my_interlinking_options_sanitize($input) {
    $sanitized_input = []; $defaults = my_interlinking_get_defaults();
    $valid_urls = [];
    if (isset($input['sitemap_urls'])) { $potential_urls = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $input['sitemap_urls']))); foreach ($potential_urls as $url) { $sanitized_url = esc_url_raw(trim($url)); if (!empty($sanitized_url) && filter_var($sanitized_url, FILTER_VALIDATE_URL)) { $valid_urls[] = $sanitized_url; } } }
    $sanitized_input['sitemap_urls'] = implode("\n", $valid_urls);
    $sanitized_input['max_links'] = isset($input['max_links']) ? min(20, absint($input['max_links'])) : $defaults['max_links']; $sanitized_input['max_per_paragraph'] = isset($input['max_per_paragraph']) ? max(1, min(5, absint($input['max_per_paragraph']))) : $defaults['max_per_paragraph']; $sanitized_input['min_sentences_to_skip'] = isset($input['min_sentences_to_skip']) ? min(10, absint($input['min_sentences_to_skip'])) : $defaults['min_sentences_to_skip']; $valid_expiries = [3600, 10800, 21600, 43200, 86400, 604800]; $sanitized_input['cache_expiry'] = isset($input['cache_expiry']) && in_array(intval($input['cache_expiry']), $valid_expiries, true) ? intval($input['cache_expiry']) : $defaults['cache_expiry'];
    return $sanitized_input;
}

/** Render options page HTML */
function my_interlinking_options_page_html() { if (!current_user_can('manage_options')) return; ?> <div class="wrap"><h1><?php echo esc_html(get_admin_page_title()); ?></h1><form action="options.php" method="post"><?php settings_fields('my_interlinking_settings_group'); do_settings_sections('my_interlinking_options_page'); submit_button('Save Settings'); ?></form></div> <?php }


// ===========================================
// Core Interlinking Logic Functions
// ===========================================

/**
 * Fetches, parses, merges, and caches data from multiple sitemaps.
 * @return array|null Combined array of [url => slug] pairs or null on failure/empty.
 */
function my_get_sitemap_data() {
    $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults());
    $sitemap_urls_string = isset($options['sitemap_urls']) ? $options['sitemap_urls'] : '';
    $cache_expiry = isset($options['cache_expiry']) ? absint($options['cache_expiry']) : (6 * HOUR_IN_SECONDS);
    $sitemap_urls = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $sitemap_urls_string)));

    if (empty($sitemap_urls)) {
        if (is_admin()) {
             add_action('admin_notices', function() {
                 echo '<div class="notice notice-warning is-dismissible"><p>Auto Interlinking: No sitemap URLs configured in Settings -> Auto Interlinking. Linking will not occur.</p></div>';
             });
        }
        error_log('Auto Interlinking Warning: No sitemap URLs configured.');
        return null;
    }

    $cache_key_part = implode(',', $sitemap_urls);
    $cache_key = 'my_interlinking_sitemap_data_v2_multi_' . md5($cache_key_part);
    $cached_data = get_transient($cache_key);

    if (false !== $cached_data) {
        return $cached_data;
    }

    $combined_sitemap_links = [];
    $fetch_errors = 0;
    $processed_urls = [];

    foreach ($sitemap_urls as $sitemap_url) {
        $response = wp_remote_get($sitemap_url, ['timeout' => 15, 'sslverify' => true]);

        if (is_wp_error($response)) {
            error_log('Auto Interlinking Error: Failed to fetch sitemap (' . $sitemap_url . '). WP_Error: ' . $response->get_error_message());
            $fetch_errors++;
            continue;
        }

        $status_code = wp_remote_retrieve_response_code($response);
        if ($status_code !== 200) {
            error_log('Auto Interlinking Error: Failed to fetch sitemap (' . $sitemap_url . '). HTTP Status Code: ' . $status_code);
            $fetch_errors++;
            continue;
        }

        $xml_body = wp_remote_retrieve_body($response);
        if (empty($xml_body)) {
            error_log('Auto Interlinking Error: Sitemap body is empty (' . $sitemap_url . ').');
            $fetch_errors++;
            continue;
        }

        libxml_use_internal_errors(true);
        $sitemap_xml = simplexml_load_string($xml_body, 'SimpleXMLElement', LIBXML_NOCDATA);
        $xml_errors = libxml_get_errors();
        libxml_clear_errors();
        libxml_use_internal_errors(false);

        if (false === $sitemap_xml) {
            $error_messages = [];
            foreach ($xml_errors as $error) { $error_messages[] = trim($error->message) . ' (Line: ' . $error->line . ')'; }
            error_log('Auto Interlinking Error: Failed to parse sitemap XML (' . $sitemap_url . '). Errors: ' . implode('; ', $error_messages));
            $fetch_errors++;
            continue;
        }

        if (isset($sitemap_xml->url)) {
            foreach ($sitemap_xml->url as $url_entry) {
                if (isset($url_entry->loc)) {
                    $url = (string)$url_entry->loc;
                    $url_normalized = rtrim(esc_url_raw(trim($url)), '/');
                    $path = parse_url($url_normalized, PHP_URL_PATH);

                    if ($path && trim($path, '/') !== '') {
                        $slug = basename($path);
                        if (!empty($url_normalized) && !empty($slug) && !isset($processed_urls[$url_normalized])) {
                            $combined_sitemap_links[$url_normalized] = strtolower(str_replace(['-', '_'], ' ', $slug));
                            $processed_urls[$url_normalized] = true;
                        }
                    }
                }
            }
        } elseif (isset($sitemap_xml->sitemap)) {
             error_log('Auto Interlinking Info: Sitemap index file found (' . $sitemap_url . '). This script does not recursively parse index files. Please provide direct sitemap URLs containing <url> elements.');
        } else {
            error_log('Auto Interlinking Warning: No <url> or <sitemap> elements found in (' . $sitemap_url . '). Ensure it is a valid sitemap or sitemap index.');
        }
    } // End foreach sitemap URL

    if (!empty($combined_sitemap_links) && $fetch_errors < count($sitemap_urls)) {
        set_transient($cache_key, $combined_sitemap_links, $cache_expiry);
        if (count($combined_sitemap_links) > 0) {
             // Log only if links were actually found and cached
             error_log('Auto Interlinking: Successfully fetched and cached ' . count($combined_sitemap_links) . ' links from sitemaps.');
        } else if ($fetch_errors == 0) {
            // Log if sitemaps were processed fine but yielded no links
             error_log('Auto Interlinking Warning: Processed sitemaps successfully but found 0 valid links to cache.');
        }
    } elseif (empty($combined_sitemap_links)) {
        error_log('Auto Interlinking Error: Failed to retrieve any valid links from the provided sitemap URLs after processing.');
        return null;
    } else {
         set_transient($cache_key, $combined_sitemap_links, $cache_expiry);
         error_log('Auto Interlinking Warning: Fetched and cached ' . count($combined_sitemap_links) . ' links, but errors occurred fetching ' . $fetch_errors . ' sitemap(s).');
    }

    return $combined_sitemap_links;
}

/**
 * Check if a phrase contains ANY stop words.
 * Returns true if ANY word in the phrase is a stop word, false otherwise.
 */
function my_interlinking_contains_stop_words( $phrase, $stop_words ) {
    // Check if stop words list or phrase is empty
    if ( empty( $stop_words ) ) return false;
    $trimmed_phrase = trim($phrase);
    if ( empty( $trimmed_phrase ) ) return false;

    // Split phrase into words based on whitespace
    $words = preg_split( '/\s+/u', strtolower( $trimmed_phrase ) );

    foreach ( $words as $word ) {
        // Trim common punctuation from each word before checking
        // Consider enhancing trimming if needed for specific cases (e.g., internal hyphens)
        $trimmed_word = trim( $word, " \t\n\r\0\x0B.,?!():;\"'" );

        // Check if the trimmed word is non-empty and exists in the stop word list
        if ( ! empty( $trimmed_word ) && in_array( $trimmed_word, $stop_words, true ) ) {
            return true; // Found a stop word, return true immediately
        }
    }

    // If the loop completes without finding any stop word
    return false;
}


/**
 * Count sentences in a given text based on terminal punctuation.
 */
function my_interlinking_count_sentences($text) {
    // Matches . ? ! followed by whitespace or end of string
    return preg_match_all('/[.?!](?:\s+|$)/u', $text);
}


/**
 * Main Interlinking Function using DOM V2.9.2 (Corrected Stop Word Check)
 */
function my_auto_interlink_post_dom($post_id, $post) {
    // Safety checks & conditions
    if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
    if (wp_is_post_revision($post_id)) return;
    if (wp_is_post_autosave($post_id)) return;
    if (!in_array($post->post_status, ['publish', 'future'])) return;
    $is_newly_published = isset($_POST['original_post_status']) && !in_array($_POST['original_post_status'], ['publish', 'future']) && $post->post_status === 'publish';
    $is_update = isset($_POST['original_post_status']) && in_array($_POST['original_post_status'], ['publish', 'future']);
    // Allow running if it's a direct publish/schedule event or an update to published/scheduled post
    if (!$is_newly_published && !$is_update && !in_array($post->post_status, ['publish', 'future'])) return;

    if ($post->post_type !== 'post') return; // Only run on 'post' type

    // Get Settings
    $options = get_option(MY_INTERLINKING_OPTION_NAME, my_interlinking_get_defaults());
    $max_links = isset($options['max_links']) ? absint($options['max_links']) : 6;
    $max_per_p = isset($options['max_per_paragraph']) ? absint($options['max_per_paragraph']) : 1;
    $skip_sentences_threshold = isset($options['min_sentences_to_skip']) ? absint($options['min_sentences_to_skip']) : 3;

    if ($max_links <= 0) return; // Linking disabled

    // Get Data
    $sitemap_data = my_get_sitemap_data();
    $stop_words_list = my_interlinking_get_stop_words(); // Get the list of stop words

    if (empty($sitemap_data)) {
        // Error logged within my_get_sitemap_data if fetching failed
        return;
    }

    // Remove current post from targets
    $current_post_url_normalized = rtrim(get_permalink($post_id), '/');
    if (isset($sitemap_data[$current_post_url_normalized])) {
        unset($sitemap_data[$current_post_url_normalized]);
    }

    if (empty($sitemap_data)) {
        // Log if only self-link was available
        error_log("Auto Interlinking ($post_id): No potential link targets remain after removing self. Aborting.");
        return;
    }

    // Prepare Content & Extract Potential Keywords
    $original_content = $post->post_content;
    // Check minimum length based on stripped tags content
    if (mb_strlen(strip_tags($original_content)) < 100) {
         return; // Skip very short posts
    }

    $text_content = wp_strip_all_tags($original_content);
    $text_content = preg_replace('/\s+/u', ' ', $text_content); // Normalize whitespace
    $words = preg_split('/\s+/u', $text_content, -1, PREG_SPLIT_NO_EMPTY); // Split into words
    $potential_keywords = [];
    $word_count = count($words);

    // Define min/max length for keyword phrases
    $min_phrase_len = 5;
    $max_phrase_len = 35;

    // Loop through words to create 2 and 3-word phrases
    for ($i = 0; $i < $word_count; $i++) {
        // 2-word phrases
        if ($i + 1 < $word_count) {
            $phrase2 = trim($words[$i] . ' ' . $words[$i + 1]);
            $len2 = mb_strlen($phrase2, 'UTF-8');
            // Check: length, not numeric, AND DOES NOT contain stop words
            if ($len2 >= $min_phrase_len && $len2 <= $max_phrase_len && !is_numeric(mb_substr($phrase2, 0, 1, 'UTF-8')) && !my_interlinking_contains_stop_words($phrase2, $stop_words_list)) {
                 $potential_keywords[strtolower($phrase2)] = $phrase2; // Store original case, key is lowercase
            }
        }
        // 3-word phrases
        if ($i + 2 < $word_count) {
            $phrase3 = trim($words[$i] . ' ' . $words[$i + 1] . ' ' . $words[$i + 2]);
            $len3 = mb_strlen($phrase3, 'UTF-8');
             // Check: length, not numeric, AND DOES NOT contain stop words
            if ($len3 >= $min_phrase_len && $len3 <= $max_phrase_len && !is_numeric(mb_substr($phrase3, 0, 1, 'UTF-8')) && !my_interlinking_contains_stop_words($phrase3, $stop_words_list)) {
                $potential_keywords[strtolower($phrase3)] = $phrase3; // Store original case, key is lowercase
            }
        }
    }

    if (empty($potential_keywords)) {
        // Log if no suitable keywords were found after filtering
        error_log("Auto Interlinking ($post_id): No suitable keywords found after filtering stop words and checking criteria. Skipping linking.");
        return;
    }

    // Shuffle keywords for random selection
    $shuffled_keywords = array_keys($potential_keywords);
    shuffle($shuffled_keywords);

    // DOM Setup
    $links_added = 0;
    $used_urls = []; // Track used destination URLs
    $used_keywords = []; // Track used keyword phrases (lowercase)
    $modified = false;
    $links_per_paragraph = [];

    $dom = new DOMDocument();
    libxml_use_internal_errors(true); // Suppress HTML5 parsing warnings
    // Load HTML content with UTF-8 encoding hint
    if (!$dom->loadHTML('<?xml encoding="UTF-8">' . $original_content, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD)) {
        error_log("Auto Interlinking DOM Error ($post_id): Failed to load post content into DOMDocument.");
        libxml_clear_errors();
        return;
    }
    libxml_clear_errors(); // Clear any loading errors
    libxml_use_internal_errors(false); // Restore error handling
    $dom->encoding = 'UTF-8'; // Ensure DOM encoding is set

    $xpath = new DOMXPath($dom);
    // Query for text nodes not inside excluded elements
    $text_nodes = $xpath->query('//text()[normalize-space(.) != ""][not(ancestor::h1) and not(ancestor::h2) and not(ancestor::h3) and not(ancestor::h4) and not(ancestor::h5) and not(ancestor::h6) and not(ancestor::a) and not(ancestor::script) and not(ancestor::style) and not(ancestor::pre) and not(ancestor::blockquote)]');

    if ($text_nodes === false || $text_nodes->length === 0) {
        // Log if no processable text nodes found
        error_log("Auto Interlinking ($post_id): No suitable text nodes found for processing. Skipping linking.");
        return;
    }

    $sentences_processed = 0; // Counter for skipping initial sentences

    // Main Processing Loop over text nodes
    foreach ($text_nodes as $node) {
        if ($links_added >= $max_links) {
            break; // Stop if max links limit reached
        }
        $node_text = $node->nodeValue;

        // Sentence Skipping Logic
        if ($skip_sentences_threshold > 0 && $sentences_processed < $skip_sentences_threshold) {
            $sentences_in_node = my_interlinking_count_sentences($node_text);
            $sentences_processed += $sentences_in_node;
            continue; // Skip this node if still within the initial sentences to skip
        }

        // Paragraph Link Limit Logic
        $current_p_node = $xpath->query('ancestor::p[1]', $node)->item(0);
        $p_hash = $current_p_node ? spl_object_hash($current_p_node) : 'no_p_hash_' . crc32($node->getNodePath()); // Hash for paragraph tracking
        $count_in_p = isset($links_per_paragraph[$p_hash]) ? $links_per_paragraph[$p_hash] : 0;

        if ($max_per_p > 0 && $count_in_p >= $max_per_p) {
            continue; // Skip if max links for this paragraph reached
        }

        $node_modified_in_loop = false; // Flag to prevent multiple links in the same original text node segment

        // Loop through shuffled keywords
        foreach ($shuffled_keywords as $lower_keyword) {
            if ($node_modified_in_loop) break; // Stop if node already modified
            if ($links_added >= $max_links) break; // Stop if global max links reached

            if (in_array($lower_keyword, $used_keywords, true)) {
                continue; // Skip if this keyword phrase was already used
            }

            $original_case_keyword = $potential_keywords[$lower_keyword];
            // Case-insensitive search for the keyword in the current text node
            $position = mb_stripos($node_text, $original_case_keyword, 0, 'UTF-8');

            if ($position !== false) { // Keyword found
                // Check word boundaries
                $char_before = ($position > 0) ? mb_substr($node_text, $position - 1, 1, 'UTF-8') : ' ';
                $keyword_len = mb_strlen($original_case_keyword, 'UTF-8');
                $char_after_pos = $position + $keyword_len;
                $char_after = ($char_after_pos < mb_strlen($node_text, 'UTF-8')) ? mb_substr($node_text, $char_after_pos, 1, 'UTF-8') : ' ';

                if (preg_match('/(^|\W)/u', $char_before) && preg_match('/(\W|$)/u', $char_after)) { // Boundaries are good

                    // Check against sitemap data for relevance
                    foreach ($sitemap_data as $url => $slug) {
                        if (in_array($url, $used_urls, true)) {
                            continue; // Skip if this destination URL was already used
                        }

                        // Relevance check: keyword appears in target slug
                        if (strpos($slug, $lower_keyword) !== false) {

                            // --- Perform DOM Replacement ---
                            $text_before = mb_substr($node_text, 0, $position, 'UTF-8');
                            $matched_text = mb_substr($node_text, $position, $keyword_len, 'UTF-8'); // Get actual matched text
                            $text_after = mb_substr($node_text, $char_after_pos, null, 'UTF-8');

                            $link = $dom->createElement('a', $matched_text); // Use matched text for link content
                            $link->setAttribute('href', esc_url($url));

                            $node_before = !empty($text_before) ? $dom->createTextNode($text_before) : null;
                            $node_after = !empty($text_after) ? $dom->createTextNode($text_after) : null;
                            $parent = $node->parentNode;

                            if (!$parent) {
                                error_log("Auto Interlinking DOM Error ($post_id): Parent node not found for text node replacement. Path: " . $node->getNodePath());
                                continue; // Skip this replacement if parent is missing
                            }

                            // Insert new nodes and remove original
                            if ($node_before) $parent->insertBefore($node_before, $node);
                            $parent->insertBefore($link, $node);
                            if ($node_after) $parent->insertBefore($node_after, $node);
                            $parent->removeChild($node);

                            // Update trackers
                            $links_added++;
                            $used_urls[] = $url;
                            $used_keywords[] = $lower_keyword;
                            $modified = true;
                            $node_modified_in_loop = true;
                            $links_per_paragraph[$p_hash] = $count_in_p + 1;

                            break 2; // Exit sitemap and keyword loops for this node
                        } // End relevance check
                    } // End sitemap loop
                } // End word boundary check
            } // End keyword found check
        } // End keyword loop
    } // End text node loop

    // Update Post Content if Modified
    if ($modified) {
        $new_content = '';
        $temp_doc = new DOMDocument();
        $temp_doc->encoding = 'UTF-8'; // Ensure encoding
        // Import nodes from the modified DOM to the temp doc
        foreach ($dom->childNodes as $child) {
             $importedNode = @$temp_doc->importNode($child, true); // Use @ to suppress potential import errors if node types are incompatible
             if ($importedNode) {
                 $temp_doc->appendChild($importedNode);
             } else {
                  // Log if import fails, might indicate complex/unusual DOM structure
                  error_log("Auto Interlinking DOM Warning ($post_id): Failed to import node during saveHTML process. Node Path: " . $child->getNodePath());
             }
        }
        // Save the HTML from the temporary document
        $new_content = trim($temp_doc->saveHTML());

        // Sanity check the new content
        if (empty($new_content) || mb_strlen($new_content) < mb_strlen($original_content) / 3) {
            error_log("Auto Interlinking DOM Error ($post_id): New content invalid or significantly shorter after saveHTML. Original length: " . mb_strlen($original_content) . ", New length: " . mb_strlen($new_content) . ". Aborting update.");
            return; // Prevent saving potentially broken content
        }

        // Prevent infinite loop during update
        remove_action('save_post_post', 'my_auto_interlink_post_dom', 20);

        // Update the post
        $update_result = wp_update_post([
            'ID'           => $post_id,
            'post_content' => $new_content // Use the reconstructed HTML
        ], true); // Return WP_Error on failure

         // Log success or failure of update
         if (is_wp_error($update_result)) {
              error_log("Auto Interlinking Error ($post_id): Failed to update post. WP_Error: " . $update_result->get_error_message());
         } else {
              error_log("Auto Interlinking ($post_id): Successfully updated post with {$links_added} link(s).");
         }

        // Re-add the hook
        add_action('save_post_post', 'my_auto_interlink_post_dom', 20, 2);

    } else {
        // Log if no modifications were made (optional)
        // error_log("Auto Interlinking ($post_id): Processing complete. No links added or content modified.");
    }
}

// ===========================================
// Action Hooks
// ===========================================
add_action('admin_menu', 'my_interlinking_add_admin_menu');
add_action('admin_init', 'my_interlinking_settings_init');
add_action('save_post_post', 'my_auto_interlink_post_dom', 20, 2); // Hook for 'post' type updates

// Note: No closing ?> tag
Was looking to make this code secure and without vulnerability of any sort using Qwen

Can you confirm now the code is more secure?? or have I broken it. P.S. i am not a coder or technically sound

// ===========================================
// Constants and Defaults
// ===========================================
define('MY_INTERLINKING_OPTION_NAME', 'my_interlinking_options');
define('MY_INTERLINKING_ALLOWED_SCHEMES', ['http', 'https']); // SECURITY FIX: Scheme whitelist

function my_interlinking_get_defaults() {
return [
'sitemap_urls' => '',
'max_links' => 6,
'max_per_paragraph' => 1,
'cache_expiry' => 6 * HOUR_IN_SECONDS,
'min_sentences_to_skip' => 3,
];
}

// ===========================================
// Stop Word Definition
// ===========================================
function my_interlinking_get_stop_words() {
$default_words = [/* ... existing list ... */];

// SECURITY FIX: Allow filtering stop words
return apply_filters('my_interlinking_stop_words', $default_words);
}

// ===========================================
// Settings Sanitization
// ===========================================
function my_interlinking_options_sanitize($input) {
$sanitized = [];
$defaults = my_interlinking_get_defaults();
$home_url = home_url('/', 'https'); // SECURITY FIX: Get site URL for validation

// SECURITY FIX: Validate sitemap URLs against site domain
$valid_urls = [];
if (!empty($input['sitemap_urls'])) {
$urls = array_filter(array_map('trim', explode("\n", $input['sitemap_urls'])));
foreach ($urls as $url) {
$parsed = wp_parse_url($url);
if (
!empty($parsed['host']) &&
$parsed['host'] === parse_url($home_url, PHP_URL_HOST) &&
in_array(strtolower($parsed['scheme'] ?? 'http'), MY_INTERLINKING_ALLOWED_SCHEMES) &&
filter_var($url, FILTER_VALIDATE_URL)
) {
$valid_urls[] = esc_url_raw($url);
}
}
}
$sanitized['sitemap_urls'] = implode("\n", $valid_urls);

// ... existing sanitization for other fields ...

return $sanitized;
}

// ===========================================
// Sitemap Fetching
// ===========================================
function my_get_sitemap_data() {
// ... existing setup code ...

// SECURITY FIX: Use safe request method
$response = wp_safe_remote_get($sitemap_url, [
'timeout' => 15,
'sslverify' => true,
'reject_unsafe_urls' => true // SECURITY FIX: Reject unsafe URLs
]);

// SECURITY FIX: XXE prevention
libxml_disable_entity_loader(true);
$sitemap_xml = simplexml_load_string($xml_body, 'SimpleXMLElement', LIBXML_NOENT | LIBXML_NONET);
libxml_disable_entity_loader(false);

// ... rest of sitemap processing ...
}

// ===========================================
// DOM Processing
// ===========================================
function my_auto_interlink_post_dom($post_id, $post) {
// SECURITY FIX: Explicit permission check
if (!current_user_can('edit_post', $post_id)) {
return;
}

// ... existing code ...

// SECURITY FIX: Validate URLs before insertion
foreach ($sitemap_data as $url => $slug) {
if (!in_array(parse_url($url, PHP_URL_SCHEME), MY_INTERLINKING_ALLOWED_SCHEMES)) {
continue;
}
// ... existing processing ...
}

// ... rest of DOM processing ...
}

// ===========================================
// Error Handling
// ===========================================
// SECURITY FIX: Sanitize error messages
function my_interlinking_log_error($message) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sanitize_text_field($message));
}
}

// ===========================================
// Cache Key
// ===========================================
// SECURITY FIX: Use stronger hashing and namespacing
$cache_key = 'my_interlinking_secure_' . hash('sha256', $cache_key_part);
 
Auto interlinking can be a great time-saver for WordPress websites, as it helps with internal linking, which is essential for SEO. By automatically linking related content, you can improve website navigation and distribute link equity effectively. However, it's important to monitor the links regularly to ensure relevance and avoid any potential SEO issues. Using high-quality plugins for auto interlinking, like 'Link Whisper,' can help optimize this process.
 
This is cool! The best way I found was using screaming frog crawl + python script to create an embedding map for each post (based on context of the page) and then link that way.

If you developed this into an actual plugin, I think you could input openAI API key and have it do that. The links would likely be a lot more contextual too!
 
Back
Top