// ===========================================
// 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