pool_generator = $pool_generator; } /** * Public method specifically for the background freeze job. * This ensures links are calculated and the result is saved. * * @since 1.0 * @param WP_Post $post The post object to process. */ public function freeze_links_for_post( $post ) { // We pass true to the 3rd argument to force freezing the results. $this->calculate_and_inject_links( $post->post_content, $post, true ); } /** * Main method to apply internal links to content. * * @param string $content The post content. * @param WP_Post $post The post object. * @return string Modified content. */ public function build_links( $content, $post ) { $mode = get_option( 'ailm_mode', 'static' ); $calculate_now = false; if ( $mode === 'hybrid' ) { $threshold_days = get_option( 'ailm_hybrid_days', 30 ); $post_age_days = floor( ( time() - strtotime( $post->post_date ) ) / 86400 ); if ( $post_age_days >= $threshold_days ) { $mode = 'static'; } else { $calculate_now = true; } } if ( $mode === 'static' ) { $frozen_map = get_post_meta( $post->ID, '_ailm_frozen_map', true ); if ( ! empty( $frozen_map ) && is_array( $frozen_map ) ) { return $this->inject_links_from_map( $content, $frozen_map, $post ); } else { $calculate_now = true; } } if ( $calculate_now ) { return $this->calculate_and_inject_links( $content, $post, ( $mode === 'static' ) ); } return $content; } /** * Injects links based on a pre-calculated "frozen" map. * * @param string $content The post content. * @param array $map The stored map of words and URLs. * @param WP_Post $post The post object. * @return string Modified content. */ private function inject_links_from_map( $content, $map, $post ) { if ( empty( $map ) || ! class_exists( 'DOMDocument' ) ) { return $content; } $skip_chars = get_option( 'ailm_skip_chars', 700 ); $global_limit = get_option( 'ailm_global_limit', 10 ); $target_limit = get_option( 'ailm_target_limit', 10 ); $debug_mode = get_option( 'ailm_debug_mode', 0 ); $debug_log = []; libxml_use_internal_errors( true ); $dom = new DOMDocument(); // Hack to handle UTF-8 correctly in loadHTML $content = mb_convert_encoding($content, 'HTML-ENTITIES', 'UTF-8'); @$dom->loadHTML( "
$content
", LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); $xpath = new DOMXPath( $dom ); $text_nodes = $xpath->query( '//text()[not(ancestor::a) and not(ancestor::h1) and not(ancestor::h2) and not(ancestor::h3) and not(ancestor::script) and not(ancestor::style)]' ); usort( $map, function ( $a, $b ) { return mb_strlen( $b['word'] ) - mb_strlen( $a['word'] ); } ); $chars_processed = 0; $links_inserted = 0; $map_copy = $map; foreach ( $text_nodes as $node ) { if ( $links_inserted >= $global_limit ) break; $text = $node->nodeValue; $len = mb_strlen( $text, 'UTF-8' ); if ( $chars_processed < $skip_chars ) { $chars_processed += $len; continue; } $chars_processed += $len; $modified_text = $text; $node_changed = false; $replacements = []; foreach ( $map_copy as $key => $item ) { if ( $links_inserted >= $global_limit ) break; $kw = $item['word']; $url = $item['url']; $target_id = isset( $item['target_id'] ) ? $item['target_id'] : url_to_postid( $url ); if ( $target_id && $target_id === $post->ID ) continue; if ( $target_id ) { $inbound = (int) get_post_meta( $target_id, '_ailm_inbound_count', true ); if ( $inbound > $target_limit || get_post_status( $target_id ) !== 'publish' ) { continue; } } $pattern = '/\b(' . preg_quote( $kw, '/' ) . ')\b/u'; if ( preg_match( $pattern, $modified_text ) ) { $placeholder = 'AILMTOKEN' . md5( uniqid( wp_rand(), true ) ) . 'END'; $modified_text = preg_replace( $pattern, $placeholder, $modified_text, 1 ); $replacements[$placeholder] = '' . esc_html( $kw ) . ''; $node_changed = true; $links_inserted++; if ( $debug_mode ) $debug_log[] = "STATIC INJECT: " . $kw; unset( $map_copy[$key] ); } } if ( $node_changed ) { $frag = $dom->createDocumentFragment(); $parts = preg_split( '/(AILMTOKEN[a-f0-9]{32}END)/', $modified_text, -1, PREG_SPLIT_DELIM_CAPTURE ); foreach ( $parts as $part ) { if ( isset( $replacements[$part] ) ) { try { // Temporary XML container to import HTML fragment $temp_dom = new DOMDocument(); $temp_dom->loadHTML('' . $replacements[$part], LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); foreach ($temp_dom->childNodes as $child) { $frag->appendChild($dom->importNode($child, true)); } } catch ( Exception $e ) { $frag->appendChild( $dom->createTextNode( '' ) ); } } else { $frag->appendChild( $dom->createTextNode( $part ) ); } } $node->parentNode->replaceChild( $frag, $node ); } } $output = $dom->saveHTML(); $output = preg_replace( [ '/^
/', '/<\/div>$/' ], '', $output ); if ( $debug_mode && ! empty( $debug_log ) && current_user_can( 'manage_options' ) ) { $output .= '
ILM STATIC LOG:
' . implode( '
', $debug_log ) . '
'; } libxml_clear_errors(); return $output; } /** * Calculates links from scratch and injects them into the content. * * @param string $content The post content. * @param WP_Post $post The post object. * @param bool $freeze_result Whether to save the generated map to post meta. * @return string Modified content. */ private function calculate_and_inject_links( $content, $post, $freeze_result ) { $rules = get_option( 'ailm_rules', [] ); if ( empty( $rules ) || ! class_exists( 'DOMDocument' ) ) { return $content; } $debug_mode = get_option( 'ailm_debug_mode', 0 ); $global_limit = get_option( 'ailm_global_limit', 10 ); $skip_chars = get_option( 'ailm_skip_chars', 700 ); $pool_size = get_option( 'ailm_pool_size', 50 ); $target_limit = get_option( 'ailm_target_limit', 10 ); $enable_utm = get_option( 'ailm_enable_utm', 0 ); $utm_s = get_option( 'ailm_utm_source', 'auto_link' ); $utm_m = get_option( 'ailm_utm_medium', 'internal' ); $utm_c = get_option( 'ailm_utm_campaign', 'seo_auto' ); $post_age_days = floor( ( time() - strtotime( $post->post_date ) ) / 86400 ); $cats = wp_get_post_categories( $post->ID ); $active_rules = []; foreach ( $rules as $rule ) { if ( empty( trim( $rule['keywords'] ) ) ) continue; if ( $rule['cat_id'] != 0 && ! in_array( $rule['cat_id'], $cats ) ) continue; if ( $post_age_days < $rule['donor_age'] ) continue; $target_urls = $this->pool_generator->generate_pool( $rule, $pool_size, $debug_mode, false ); if ( empty( $target_urls ) ) continue; $rule['target_pool'] = $target_urls; $rule['pool_size'] = count( $target_urls ); $lines = preg_split( '/\r\n|\r|\n/', $rule['keywords'] ); $word_limit_map = []; $kw_regex = []; $default_limit = isset( $rule['limit_per_word_default'] ) ? $rule['limit_per_word_default'] : 1; foreach ( $lines as $line ) { $parts = explode( '|', $line ); $word = trim( $parts[0] ); if ( empty( $word ) ) continue; $key = mb_strtolower( $word, 'UTF-8' ); if ( ! isset( $word_limit_map[$key] ) ) { $word_limit_map[$key] = isset( $parts[1] ) ? intval( trim( $parts[1] ) ) : $default_limit; $kw_regex[] = preg_quote( $word, '/' ); } } if ( ! empty( $kw_regex ) ) { usort( $kw_regex, function ( $a, $b ) { return strlen( $b ) - strlen( $a ); } ); $rule['regex_pattern'] = sprintf( '/\b(%s)\b/iu', implode( '|', $kw_regex ) ); $rule['word_limits'] = $word_limit_map; $rule['counter_total'] = 0; $rule['counter_words'] = []; $rule['limit_total'] = isset( $rule['limit_total'] ) ? $rule['limit_total'] : 5; $active_rules[] = $rule; } } if ( empty( $active_rules ) ) return $content; libxml_use_internal_errors( true ); $dom = new DOMDocument(); // Hack to handle UTF-8 correctly $content = mb_convert_encoding($content, 'HTML-ENTITIES', 'UTF-8'); @$dom->loadHTML( "
$content
", LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); // Adjust the limit based on existing manual links in the content. $global_limit = $this->adjust_global_limit_for_manual_links( $post, $global_limit ); if ( $global_limit <= 0 ) { libxml_clear_errors(); return $content; } $xpath = new DOMXPath( $dom ); $text_nodes = $xpath->query( '//text()[not(ancestor::a) and not(ancestor::h1) and not(ancestor::h2) and not(ancestor::h3) and not(ancestor::script) and not(ancestor::style)]' ); $chars_processed = 0; $links_inserted = 0; $post_seed = $post->ID; $debug_log = []; $static_map = []; foreach ( $text_nodes as $node ) { if ( $links_inserted >= $global_limit ) break; $text = $node->nodeValue; $len = mb_strlen( $text, 'UTF-8' ); if ( $chars_processed < $skip_chars ) { $chars_processed += $len; continue; } $chars_processed += $len; $modified_text = $text; $node_changed = false; $replacements = []; foreach ( $active_rules as &$rule ) { if ( $links_inserted >= $global_limit || $rule['counter_total'] >= $rule['limit_total'] ) continue; $modified_text = preg_replace_callback( $rule['regex_pattern'], function( $match ) use ( &$rule, &$replacements, &$node_changed, &$links_inserted, $global_limit, &$debug_log, $debug_mode, &$static_map, $post, $target_limit, $post_seed, $enable_utm, $utm_s, $utm_m, $utm_c ) { if ( $links_inserted >= $global_limit ) return $match[0]; $found = $match[0]; $lower = mb_strtolower( $found, 'UTF-8' ); if ( ($rule['counter_words'][$lower] ?? 0) >= ($rule['word_limits'][$lower] ?? 1) ) return $found; if ( $rule['counter_total'] >= $rule['limit_total'] ) return $found; $pool_idx = ( $post_seed + $rule['counter_total'] ) % $rule['pool_size']; $selected_url = $rule['target_pool'][$pool_idx]; $target_id = url_to_postid( $selected_url ); if ( $target_id === $post->ID || get_post_status( $target_id ) !== 'publish' ) return $found; if ( (int) get_post_meta( $target_id, '_ailm_inbound_count', true ) >= $target_limit ) return $found; $url = $selected_url; if ( $enable_utm ) { $utm_params = http_build_query([ 'utm_source' => $utm_s, 'utm_medium' => $utm_m, 'utm_campaign' => $utm_c, 'utm_content' => $lower ]); $url = $selected_url . ( strpos( $selected_url, '?' ) === false ? '?' : '&' ) . $utm_params; } $placeholder = 'AILMTOKEN' . md5( uniqid( wp_rand(), true ) ) . 'END'; $replacements[$placeholder] = '' . esc_html( $found ) . ''; $node_changed = true; $links_inserted++; $rule['counter_total']++; $rule['counter_words'][$lower] = ($rule['counter_words'][$lower] ?? 0) + 1; if ($debug_mode) $debug_log[] = "CALC-LINK: $found -> $url"; $static_map[] = [ 'word' => $found, 'url' => $url, 'target_id' => $target_id ]; return $placeholder; }, $modified_text ); } unset($rule); if ( $node_changed ) { $frag = $dom->createDocumentFragment(); $parts = preg_split( '/(AILMTOKEN[a-f0-9]{32}END)/', $modified_text, -1, PREG_SPLIT_DELIM_CAPTURE ); foreach ( $parts as $part ) { if ( isset( $replacements[$part] ) ) { try { $temp_dom = new DOMDocument(); $temp_dom->loadHTML('' . $replacements[$part], LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); foreach ($temp_dom->childNodes as $child) { $frag->appendChild($dom->importNode($child, true)); } } catch ( Exception $e ) { $frag->appendChild( $dom->createTextNode( '' ) ); } } else { $frag->appendChild( $dom->createTextNode( $part ) ); } } $node->parentNode->replaceChild( $frag, $node ); } } if ( $freeze_result && ! empty( $static_map ) ) { update_post_meta( $post->ID, '_ailm_frozen_map', $static_map ); foreach ( $static_map as $link_item ) { if ( ! empty( $link_item['target_id'] ) ) { $count = (int) get_post_meta( $link_item['target_id'], '_ailm_inbound_count', true ); update_post_meta( $link_item['target_id'], '_ailm_inbound_count', $count + 1 ); } } } $output = $dom->saveHTML(); $output = preg_replace( [ '/^
/', '/<\/div>$/' ], '', $output ); if ( $debug_mode && ! empty( $debug_log ) && current_user_can( 'manage_options' ) ) { $output .= '
ILM CALC LOG:
' . implode( '
', $debug_log ) . '
'; } libxml_clear_errors(); return $output; } /** * Counts internal links in the original document content and subtracts them from the global limit. */ private function adjust_global_limit_for_manual_links( $post, $global_limit ) { if ( ! get_option( 'ailm_count_manual_links', 0 ) || empty( $post ) ) { return $global_limit; } $home_url = untrailingslashit( home_url() ); $manual_internal_count = 0; $raw_content = $post->post_content; // FIX: Escaped quotes in regex below if ( preg_match_all( '/]*href=[\'"]([^\'" ]+)[\'"][^>]*>/i', $raw_content, $matches ) ) { foreach ( $matches[1] as $href ) { if ( empty( $href ) ) continue; // An internal link starts with the site's domain or a relative slash (but not //) if ( strpos( $href, $home_url ) === 0 || ( strpos( $href, '/' ) === 0 && strpos( $href, '//' ) !== 0 ) ) { $manual_internal_count++; } } } // Return the remaining limit, but not less than 0 return max( 0, $global_limit - $manual_internal_count ); } }