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( "
/', '/<\/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 );
}
}