prefix . 'il_pilares') whose names and column lists
// are constants under plugin control. Generic WordPress checks
// for direct DB queries, caching and prepared-SQL interpolation
// don't apply meaningfully here. See docs/LICENSING.md for the
// full architecture rationale.
class FLINK_Linker {
private $pilares;
private $reglas;
/**
* Number of links inserted by the most recent apply_links() call.
* Lets callers (the Elementor walker) read the count without changing
* apply_links()'s return type, which other code relies on.
*/
private $last_inserted = 0;
public function init() {
$this->pilares = new FLINK_Pilares();
$this->reglas = new FLINK_Reglas();
add_action( 'save_post', array( $this, 'process_post' ), 20, 2 );
// Force rule-based linking after content generation (may be draft status)
add_action( 'flink_after_post_created', array( $this, 'force_apply_links' ), 10, 1 );
}
/** Initialise without registering WP hooks — for AJAX batch processing */
public function init_for_batch() {
$this->pilares = new FLINK_Pilares();
$this->reglas = new FLINK_Reglas();
}
/**
* Force apply links to a post regardless of status/caps.
* Called after content generation to ensure all rules are applied.
*/
public function force_apply_links( $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) return;
// Elementor-built posts keep their content in _elementor_data, not in
// post_content. Link that tree instead.
if ( $this->post_uses_elementor( $post_id ) ) {
$this->apply_links_elementor( $post_id, false );
return;
}
$content = $this->apply_links( $post->post_content, $post_id );
if ( $content !== $post->post_content ) {
global $wpdb;
$wpdb->update( $wpdb->posts, array( 'post_content' => $content ), array( 'ID' => $post_id ) );
clean_post_cache( $post_id );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post_id );
}
}
public function process_post( $post_id, $post ) {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
if ( wp_is_post_revision( $post_id ) ) return;
// Accept publish, future AND draft — content generator creates drafts
if ( ! in_array( $post->post_status, array( 'publish', 'future', 'draft', 'pending' ), true ) ) return;
// During AJAX/cron, skip capability check (the action was already authorized)
if ( ! defined( 'DOING_AJAX' ) && ! defined( 'DOING_CRON' ) ) {
if ( ! current_user_can( 'edit_post', $post_id ) ) return;
}
// Elementor-built posts: link the _elementor_data tree, not post_content.
if ( $this->post_uses_elementor( $post_id ) ) {
$this->apply_links_elementor( $post_id, false );
return;
}
$content = $post->post_content;
$content = $this->apply_links( $content, $post_id );
if ( $content !== $post->post_content ) {
remove_action( 'save_post', array( $this, 'process_post' ), 20 );
wp_update_post( array( 'ID' => $post_id, 'post_content' => $content ) );
add_action( 'save_post', array( $this, 'process_post' ), 20, 2 );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post_id );
}
}
/**
* Apply internal-linking rules to a chunk of content.
*
* @param string $content The HTML/text to process.
* @param int $post_id The post the content belongs to.
* @param int $budget_used Links already inserted elsewhere in the SAME
* post (e.g. in previous Elementor widgets).
* The per-post maximum is global, so when the
* content is split across several widgets we
* pass how many links are already spent so this
* call only inserts up to the remaining budget.
* Defaults to 0 -> identical to legacy behaviour
* when linking a single post_content blob.
*/
public function apply_links( $content, $post_id, $budget_used = 0 ) {
$this->last_inserted = 0;
// ── CLEAN SLATE ───────────────────────────────────────────────────────────
$content = $this->strip_all_plugin_links( $content );
// ── EARLY EXITS ──────────────────────────────────────────────────────────
// Origen filter: only insert links into post_types the user
// has selected as link sources. Default 'post' protects pillar
// pages from being modified by the rules engine.
$origen_tipo = get_option( 'flink_origen_tipo', 'post' );
if ( $origen_tipo !== 'any' ) {
$tipos = array_map( 'trim', explode( ',', $origen_tipo ) );
if ( ! in_array( get_post_type( $post_id ), $tipos, true ) ) {
return $content;
}
}
if ( FLINK_Exclusiones::is_post_excluded( $post_id ) ) {
return $content;
}
$reglas = $this->reglas->get_active_for_post( $post_id );
$pilares = $this->pilares->get_all( array( 'activo' => 1 ) );
if ( empty( $reglas ) || empty( $pilares ) ) {
return $content;
}
$pilares_map = array();
foreach ( $pilares as $p ) $pilares_map[ (int) $p->id ] = $p;
$max_total = (int) get_option( 'flink_max_enlaces_total', 5 );
// Start the counter at links already spent elsewhere in this post
// (Elementor multi-widget case). For a single post_content blob this
// is 0, so behaviour is unchanged.
$insertados = max( 0, (int) $budget_used );
$post_lang = get_post_meta( $post_id, '_flink_idioma', true );
if ( empty( $post_lang ) && function_exists( 'pll_get_post_language' ) ) {
$post_lang = pll_get_post_language( $post_id );
}
$current_url = get_permalink( $post_id );
// ── PHASE 1: Resolve all rules into candidates ──────────────────────────
// Each candidate has: keywords, dest_url, dest_nombre, pilar_id_log,
// max_enlaces, prioridad, regla_id
$candidates = array();
foreach ( $reglas as $regla ) {
// ── Rule scope: category filter ────────────────────────
// If this rule is scoped to specific categories (mode = 'include'
// with a non-empty id list), skip it when the current post is
// not in any of those categories. Rules with mode = 'all' or
// no ids apply everywhere as before.
//
// has_category() accepts either an id, an array of ids, or a
// comma-separated string, so we pass the array directly. Works
// on any post type that uses the standard 'category' taxonomy.
if ( isset( $regla->categorias_mode ) && $regla->categorias_mode === 'include' && ! empty( $regla->categorias_ids ) ) {
$rule_cat_ids = array_filter( array_map( 'absint', explode( ',', $regla->categorias_ids ) ) );
if ( $rule_cat_ids && ! has_category( $rule_cat_ids, $post_id ) ) {
continue;
}
}
$variante = null;
$variantes = $this->reglas->get_variantes( $regla );
if ( ! empty( $post_lang ) && ! empty( $variantes ) && isset( $variantes[ $post_lang ] ) ) {
$variante = $variantes[ $post_lang ];
}
// Resolve destination
$pilar = null;
$dest_url = $dest_nombre = '';
$pilar_id_log = null;
if ( $variante ) {
if ( ! empty( $variante['url'] ) ) {
$dest_url = $variante['url'];
$dest_nombre = $dest_url;
} elseif ( ! empty( $variante['pilar_id'] ) && isset( $pilares_map[ (int) $variante['pilar_id'] ] ) ) {
$pilar = $pilares_map[ (int) $variante['pilar_id'] ];
$dest_url = $pilar->url;
$dest_nombre = $pilar->nombre;
$pilar_id_log = $pilar->id;
} else {
continue;
}
} else {
if ( ! empty( $regla->pilar_id ) && isset( $pilares_map[ (int) $regla->pilar_id ] ) ) {
$pilar = $pilares_map[ (int) $regla->pilar_id ];
} elseif ( $regla->destino_tipo === 'pilar' ) {
$pilar = reset( $pilares );
}
if ( ! $pilar ) continue;
$dest_url = ! empty( $regla->destino_valor ) ? $regla->destino_valor : $pilar->url;
$dest_nombre = ! empty( $regla->destino_valor ) ? $regla->destino_valor : $pilar->nombre;
$pilar_id_log = ! empty( $regla->destino_valor ) ? null : $pilar->id;
}
// Skip self-links. Compare by post ID first (immune to URL
// cosmetics), then fall back to normalised URL/path matching.
if ( $this->is_self_link( $post_id, $current_url, $dest_url ) ) continue;
// Resolve keywords
if ( $variante ) {
$kw_regla = clone $regla;
if ( ! empty( $variante['keyword'] ) ) $kw_regla->nombre = $variante['keyword'];
if ( isset( $variante['sinonimos'] ) ) $kw_regla->descripcion = $variante['sinonimos'];
$keywords = $this->get_keywords_for_regla( $kw_regla );
} else {
$keywords = $this->get_keywords_for_regla( $regla );
}
if ( empty( $keywords ) ) continue;
$candidates[] = array(
'regla_id' => $regla->id,
'prioridad' => (int) $regla->prioridad,
'max_enlaces' => (int) $regla->max_enlaces,
'keywords' => $keywords,
'dest_url' => $dest_url,
'dest_nombre' => $dest_nombre,
'pilar_id_log' => $pilar_id_log,
'insertados' => 0,
'kw_index' => 0, // tracks which keyword to try next
);
}
if ( empty( $candidates ) ) return $content;
// ── PHASE 2: Round-robin insertion ───────────────────────────────────────
// Group candidates by priority. Process highest priority group first.
// Within each group, cycle through candidates one link at a time.
// This ensures fair distribution: Galicia(50) + Agua(50) with max_total=2
// → round 1: Galicia gets 1, Agua gets 1 → done.
// Sort by priority DESC (already sorted, but ensure)
usort( $candidates, function( $a, $b ) { return $b['prioridad'] - $a['prioridad']; } );
// Group by priority
$priority_groups = array();
foreach ( $candidates as $idx => $c ) {
$priority_groups[ $c['prioridad'] ][] = $idx;
}
krsort( $priority_groups ); // highest first
foreach ( $priority_groups as $prio => $group_indices ) {
if ( $insertados >= $max_total ) break;
// Round-robin within this priority group
$any_inserted = true;
while ( $any_inserted && $insertados < $max_total ) {
$any_inserted = false;
foreach ( $group_indices as $idx ) {
if ( $insertados >= $max_total ) break;
$c = &$candidates[ $idx ];
if ( $c['insertados'] >= $c['max_enlaces'] ) continue;
if ( $c['kw_index'] >= count( $c['keywords'] ) ) continue;
// Try to insert ONE link for this candidate.
// Try the current keyword first. If it can't insert (no more
// occurrences in text), advance to the next keyword.
$inserted_this_round = false;
while ( $c['kw_index'] < count( $c['keywords'] ) && ! $inserted_this_round ) {
$keyword = $c['keywords'][ $c['kw_index'] ];
$result = $this->insert_link( $content, $keyword, $c['dest_url'], 1 );
if ( $result['inserted'] > 0 ) {
$content = $result['content'];
$insertados++;
$c['insertados']++;
$inserted_this_round = true;
$any_inserted = true;
// Do NOT advance kw_index — same keyword may have
// more occurrences in the text for the next round.
} else {
// This keyword has no more linkable occurrences.
// Move to the next keyword for future rounds.
$c['kw_index']++;
}
}
unset( $c ); // release reference
}
}
}
// Links inserted in THIS call = final count minus the budget already
// spent before we started (Elementor multi-widget accounting).
$this->last_inserted = $insertados - max( 0, (int) $budget_used );
return $content;
}
/** Links inserted by the most recent apply_links() call. */
public function get_last_inserted_count() {
return (int) $this->last_inserted;
}
// ─────────────────────────────────────────────────────────────
// OPERACIONES DE PROCESADO EN MASA
// ─────────────────────────────────────────────────────────────
/**
* Apply a SINGLE rule to all eligible posts WITHOUT stripping existing links.
*
* Used when a rule is re-activated. Instead of the destructive clean-slate
* approach of apply_links(), this method:
* 1. Loads only the one rule.
* 2. For each post, checks whether the rule's keywords are already linked.
* 3. If not, inserts the link without touching any other tags.
*
* This guarantees that activating rule X never removes links from rule Y.
*/
public function apply_single_regla_to_all_posts( $regla_id ) {
global $wpdb;
// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running batch operations need to extend the execution window; we set a conservative cap, not unlimited.
@set_time_limit( 300 );
$regla = $this->reglas->get( (int) $regla_id );
if ( ! $regla || ! $regla->activo ) return 0;
$pilares = $this->pilares->get_all( array( 'activo' => 1 ) );
$pilares_map = array();
foreach ( $pilares as $p ) $pilares_map[ (int) $p->id ] = $p;
// Resolve destination for this rule
if ( ! empty( $regla->pilar_id ) && isset( $pilares_map[ (int) $regla->pilar_id ] ) ) {
$pilar = $pilares_map[ (int) $regla->pilar_id ];
} elseif ( $regla->destino_tipo === 'pilar' ) {
$pilar = reset( $pilares );
} else {
$pilar = null;
}
if ( ! $pilar && empty( $regla->destino_valor ) ) return 0;
$dest_url = ! empty( $regla->destino_valor ) ? $regla->destino_valor : $pilar->url;
$dest_nombre = ! empty( $regla->destino_valor ) ? $regla->destino_valor : $pilar->nombre;
$pilar_id_log = $pilar ? $pilar->id : null;
$keywords = $this->get_keywords_for_regla( $regla );
if ( empty( $keywords ) ) return 0;
// Iterate over posts that can be SOURCES of links (origen).
$origen_tipo = get_option( 'flink_origen_tipo', 'post' );
$type_where = $this->get_type_where( $wpdb, $origen_tipo );
$max_total = (int) get_option( 'flink_max_enlaces_total', 5 );
$posts = $wpdb->get_results(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type NOT IN ('revision','attachment','nav_menu_item')
{$type_where}
ORDER BY ID ASC"
);
$updated = 0;
foreach ( $posts as $post ) {
if ( FLINK_Exclusiones::is_post_excluded( $post->ID ) ) continue;
// Rule-level category scope (see the same check in apply_links).
if ( isset( $regla->categorias_mode ) && $regla->categorias_mode === 'include' && ! empty( $regla->categorias_ids ) ) {
$rule_cat_ids = array_filter( array_map( 'absint', explode( ',', $regla->categorias_ids ) ) );
if ( $rule_cat_ids && ! has_category( $rule_cat_ids, $post->ID ) ) {
continue;
}
}
// Skip if this post IS the destination
$permalink = get_permalink( $post->ID );
if ( trailingslashit( $permalink ) === trailingslashit( $dest_url ) ) continue;
// Language filter
$post_lang = get_post_meta( $post->ID, '_flink_idioma', true );
if ( empty( $post_lang ) && function_exists( 'pll_get_post_language' ) ) {
$post_lang = pll_get_post_language( $post->ID );
}
if ( ! empty( $post_lang ) && ! empty( $regla->idioma ) && $regla->idioma !== $post_lang ) {
continue;
}
// Elementor-built posts keep content in _elementor_data, not in
// post_content. The single-rule fast path can't work on them, so
// delegate to apply_links_elementor (which re-applies ALL active
// rules to the widget tree). Its own per-rule/global caps apply.
if ( $this->post_uses_elementor( $post->ID ) ) {
$r = $this->apply_links_elementor( $post->ID, false );
if ( ! empty( $r['saved'] ) ) {
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
continue;
}
// Count how many plugin links this post already has (respect global max)
$existing_count = preg_match_all( '/data-il=["\']1["\']/', $post->post_content );
if ( $existing_count >= $max_total ) continue;
$content = $post->post_content;
$regla_max = (int) $regla->max_enlaces;
$regla_insertados = 0;
$changed = false;
// SEO rotation: instead of always trying the main keyword
// first and only falling back to synonyms if there's room
// left (which means synonyms are rarely used in practice),
// we build a weighted, post-shuffled order so the main
// keyword still dominates (~50-60% of links) but synonyms
// get real chances. The main keyword is added to the queue
// multiple times (its "weight"), synonyms once each, then
// the queue is shuffled with a stable per-post seed.
//
// Why: Google penalises over-optimised anchor profiles where
// the same exact match repeats too often. Rotating between
// the main keyword and its synonyms improves semantic
// coverage (more search queries the pillar ranks for) and
// makes the internal-linking profile look natural.
//
// Stable per-post seed (post ID + rule ID) ensures the same
// post always gets the same rotation, so re-running the
// linker is idempotent and content doesn't churn.
$ordered_keywords = $keywords;
if ( count( $keywords ) > 1 ) {
$weighted = array();
$main = $keywords[0];
$synonyms = array_slice( $keywords, 1 );
// Weight of 2 for the main keyword keeps it dominant
// while letting synonyms appear. With 3 synonyms total
// (4 candidates), main = 2/5 ≈ 40%; with 5 synonyms,
// main = 2/7 ≈ 29%. Tune by changing $main_weight.
$main_weight = 2;
for ( $i = 0; $i < $main_weight; $i++ ) $weighted[] = $main;
foreach ( $synonyms as $s ) $weighted[] = $s;
// Stable shuffle: deterministic per (post, rule) pair.
$seed = crc32( 'flink-rot-' . $post->ID . '-' . $regla->id );
mt_srand( $seed );
for ( $i = count( $weighted ) - 1; $i > 0; $i-- ) {
$j = mt_rand( 0, $i );
$tmp = $weighted[ $i ]; $weighted[ $i ] = $weighted[ $j ]; $weighted[ $j ] = $tmp;
}
mt_srand(); // reset
// De-duplicate while keeping first-seen order: the
// queue might have the main keyword twice in a row
// after shuffling; insert_link() already prevents
// double-linking the same anchor, but de-duplicating
// here keeps the trace clean.
$ordered_keywords = array_values( array_unique( $weighted ) );
}
foreach ( $ordered_keywords as $keyword ) {
if ( $existing_count + $regla_insertados >= $max_total ) break;
if ( $regla_insertados >= $regla_max ) break;
$remaining = min( $regla_max - $regla_insertados, $max_total - $existing_count - $regla_insertados );
$result = $this->insert_link( $content, $keyword, $dest_url, $remaining );
if ( $result['inserted'] > 0 ) {
$content = $result['content'];
$regla_insertados += $result['inserted'];
$changed = true;
}
}
if ( $changed ) {
$wpdb->update( $wpdb->posts, array( 'post_content' => $content ), array( 'ID' => $post->ID ) );
clean_post_cache( $post->ID );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
}
return $updated;
}
/**
* Full reprocess of all eligible posts.
*
* For every post: strip ALL plugin links → reapply ALL active rules
* in priority order (highest first). This is the only correct way to
* handle any rule change (create / update / delete / toggle) because:
*
* - A new rule may apply to posts that already have other rules applied.
* - A modified rule may have changed keyword, URL or priority.
* - A deleted/disabled rule must have its links removed cleanly.
*
* The apply_links() method already does the clean-slate approach,
* so this is just a loop that calls it on every post.
*
* @param int $regla_id Unused — kept for backward compatibility.
*/
public function apply_regla_to_all_posts( $regla_id = 0 ) {
global $wpdb;
// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running batch operations need to extend the execution window; we set a conservative cap, not unlimited.
@set_time_limit( 300 );
// Iterate over posts that can be SOURCES (origen).
$origen_tipo = get_option( 'flink_origen_tipo', 'post' );
$type_where = $this->get_type_where( $wpdb, $origen_tipo );
$posts = $wpdb->get_results(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type NOT IN ('revision','attachment','nav_menu_item')
{$type_where}
ORDER BY ID ASC"
);
$updated = 0;
foreach ( $posts as $post ) {
if ( FLINK_Exclusiones::is_post_excluded( $post->ID ) ) continue;
// Elementor-built posts: link the _elementor_data tree instead of
// post_content. apply_links_elementor does its own clean-slate per
// widget (apply_links strips prior plugin links before reinserting).
if ( $this->post_uses_elementor( $post->ID ) ) {
$r = $this->apply_links_elementor( $post->ID, false );
if ( ! empty( $r['saved'] ) ) {
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
continue;
}
$new_content = $this->apply_links( $post->post_content, $post->ID );
if ( $new_content !== $post->post_content ) {
$wpdb->update( $wpdb->posts,
array( 'post_content' => $new_content ),
array( 'ID' => $post->ID )
);
clean_post_cache( $post->ID );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
}
return $updated;
}
/**
* REGLA MODIFICADA: en los posts que tienen enlaces del plugin
* a esta URL, elimina esos enlaces y reaplica TODAS las reglas activas.
* Esto añade keywords nuevas y elimina las suprimidas.
*/
public function reprocess_posts_for_url( $url ) {
global $wpdb;
$url = esc_url_raw( $url );
$url_trail = trailingslashit( $url );
$like_il = '%' . $wpdb->esc_like( 'data-il="1"' ) . '%';
$like1 = '%' . $wpdb->esc_like( 'href="' . $url . '"' ) . '%';
$like2 = '%' . $wpdb->esc_like( 'href="' . $url_trail . '"' ) . '%';
$posts = $wpdb->get_results( $wpdb->prepare(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type NOT IN ('revision','attachment','nav_menu_item')
AND post_content LIKE %s
AND ( post_content LIKE %s OR post_content LIKE %s )",
$like_il, $like1, $like2
) );
$updated = 0;
$processed = array(); // Guard against processing a post twice (classic + Elementor passes).
foreach ( $posts as $post ) {
// An Elementor post could, in edge cases, also carry plugin links in
// post_content (e.g. legacy content before the page was rebuilt in
// Elementor). Route it through the Elementor path, which re-applies
// all active rules to the widget tree with its own clean-slate.
if ( $this->post_uses_elementor( $post->ID ) ) {
$r = $this->apply_links_elementor( $post->ID, false );
if ( ! empty( $r['saved'] ) ) {
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
$processed[ (int) $post->ID ] = true;
continue;
}
$stripped_anchors = $this->extract_plugin_link_anchors( $post->post_content, $url );
// 1. Remove plugin-inserted links to this URL
$clean = $this->strip_links_for_url( $post->post_content, $url );
// 2. Reapply all active rules
$new_content = $this->apply_links( $clean, $post->ID );
if ( $new_content !== $post->post_content ) {
$wpdb->update( $wpdb->posts, array( 'post_content' => $new_content ), array( 'ID' => $post->ID ) );
clean_post_cache( $post->ID );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
$processed[ (int) $post->ID ] = true;
// Log keywords that were stripped and not reinserted
if ( ! empty( $stripped_anchors ) ) {
$reinserted = $this->extract_plugin_link_anchors( $new_content, $url );
$truly_stripped = array_diff( $stripped_anchors, $reinserted );
// (Log of stripped anchors removed — il_log was deprecated)
}
}
// SECOND PASS — Elementor pages keep their links inside _elementor_data,
// never in post_content, so the first query can't see them. We can't
// filter by href/data-il here either: inside _elementor_data the HTML is
// JSON-escaped (href=\"...\", data-il=\"1\"), so a naive LIKE never
// matches. Fetch every Elementor-built page and route it through
// apply_links_elementor, which does its own clean-slate + re-applies all
// active rules (the correct behaviour when a destination URL changes)
// and respects the origen/exclusion filters. It's a no-op when nothing
// applies, so over-fetching is safe.
$meta_posts = $wpdb->get_results(
"SELECT pm.post_id AS ID FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = '_elementor_edit_mode'
AND pm.meta_value = 'builder'
AND p.post_status = 'publish'
AND p.post_type NOT IN ('revision','attachment','nav_menu_item')"
);
foreach ( $meta_posts as $mp ) {
$pid = (int) $mp->ID;
if ( isset( $processed[ $pid ] ) ) continue; // Already handled in first pass.
if ( ! $this->post_uses_elementor( $pid ) ) continue; // Safety: confirm builder mode.
$r = $this->apply_links_elementor( $pid, false );
if ( ! empty( $r['saved'] ) ) {
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $pid );
$updated++;
}
$processed[ $pid ] = true;
}
return $updated;
}
/**
* Strip links created by a SPECIFIC rule from all posts.
*
* Identifies links by BOTH the destination URL AND the anchor text
* (keyword/synonyms of the rule). This is critical when multiple rules
* point to the same pilar URL — we must only remove the links whose
* anchor text belongs to THIS rule, leaving other rules' links intact.
*
* e.g. Rules "playa" and "excursiones" both point to the same pilar URL.
* Deactivating "excursiones" must only remove excursiones,
* NOT playa or playas.
*/
public function strip_regla_from_all_posts( $regla_id ) {
global $wpdb;
$regla = $this->reglas->get( $regla_id );
if ( ! $regla ) return 0;
// Build keyword list for this rule
$keywords = $this->get_keywords_for_regla( $regla );
if ( empty( $keywords ) ) return 0;
// Collect destination URLs (main + language variants)
$url_map = array(); // keyword => url (for variant support)
$default_url = '';
if ( ! empty( $regla->destino_valor ) ) {
$default_url = esc_url_raw( $regla->destino_valor );
} elseif ( ! empty( $regla->pilar_id ) ) {
$pilar = $this->pilares->get( (int) $regla->pilar_id );
if ( $pilar ) $default_url = esc_url_raw( $pilar->url );
}
// Language variant URLs (each variant may override URL + keywords)
$variantes = $this->reglas->get_variantes( $regla );
$variant_keywords = array();
foreach ( $variantes as $lang => $v ) {
$v_url = '';
if ( ! empty( $v['url'] ) ) {
$v_url = esc_url_raw( $v['url'] );
} elseif ( ! empty( $v['pilar_id'] ) ) {
$pilar = $this->pilares->get( (int) $v['pilar_id'] );
if ( $pilar ) $v_url = esc_url_raw( $pilar->url );
}
// Collect variant keywords
if ( ! empty( $v['keyword'] ) ) $variant_keywords[] = $v['keyword'];
if ( ! empty( $v['sinonimos'] ) ) {
foreach ( array_filter( array_map( 'trim', explode( "\n", $v['sinonimos'] ) ) ) as $s ) {
$variant_keywords[] = $s;
}
}
}
// All keywords to strip (main rule + all variants)
$all_keywords = array_unique( array_merge( $keywords, $variant_keywords ) );
if ( empty( $all_keywords ) || empty( $default_url ) ) return 0;
// Find posts that have plugin links (data-il="1")
$like_il = '%' . $wpdb->esc_like( 'data-il="1"' ) . '%';
$posts = $wpdb->get_results( $wpdb->prepare(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type NOT IN ('revision','attachment','nav_menu_item')
AND post_content LIKE %s",
$like_il
) );
$updated = 0;
foreach ( $posts as $post ) {
$content = $post->post_content;
$changed = false;
foreach ( $all_keywords as $keyword ) {
if ( empty( $keyword ) ) continue;
$kw_esc = preg_quote( $keyword, '/' );
// Pattern: KEYWORD
// We match by BOTH the anchor text AND the href to be surgical.
// The href is checked loosely (any il link containing this keyword).
// We use a pattern that captures the full tag and checks the text inside.
$pattern = '/]*data-il=["\']1["\'][^>]*>\s*' . $kw_esc . '\s*<\/a>/iu';
$new_content = preg_replace( $pattern, $keyword, $content );
if ( $new_content !== $content ) {
$content = $new_content;
$changed = true;
}
}
if ( $changed ) {
$wpdb->update( $wpdb->posts, array( 'post_content' => $content ), array( 'ID' => $post->ID ) );
clean_post_cache( $post->ID );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
}
// SECOND PASS — Elementor pages keep their links inside _elementor_data,
// never in post_content, so the query above can't see them and their
// rule links would survive deleting/deactivating the rule. Search
// postmeta and strip this rule's anchors (same keyword regex) from the
// text-editor / html widgets, writing the tree back byte-for-byte.
//
// NOTE: we do NOT filter the query by a 'data-il' LIKE. Inside
// _elementor_data the HTML is JSON-escaped (data-il=\"1\"), so a naive
// LIKE for data-il="1" never matches. Rather than guess the escaping,
// we fetch every Elementor-built page and let the per-keyword regex
// decide what (if anything) to strip — it's a no-op when nothing matches.
$processed = array();
$meta_posts = $wpdb->get_results(
"SELECT pm.post_id AS ID FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = '_elementor_edit_mode'
AND pm.meta_value = 'builder'
AND p.post_status = 'publish'
AND p.post_type NOT IN ('revision','attachment','nav_menu_item')"
);
foreach ( $meta_posts as $mp ) {
$pid = (int) $mp->ID;
if ( isset( $processed[ $pid ] ) ) continue;
if ( ! $this->post_uses_elementor( $pid ) ) continue;
$raw = get_post_meta( $pid, '_elementor_data', true );
if ( is_string( $raw ) ) {
$data = json_decode( $raw, true );
} elseif ( is_array( $raw ) ) {
$data = $raw;
} else {
$data = null;
}
if ( ! is_array( $data ) ) continue;
$stripped = 0;
$this->strip_keywords_from_elementor_tree( $data, $all_keywords, $stripped );
if ( $stripped > 0 ) {
$encoded = wp_json_encode( $data );
if ( $encoded !== false ) {
update_post_meta( $pid, '_elementor_data', wp_slash( $encoded ) );
$this->purge_elementor_cache( $pid );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $pid );
$updated++;
}
}
$processed[ $pid ] = true;
}
return $updated;
}
/**
* Recursively strip this rule's plugin links (KEYWORD)
* from the text-editor / html widgets of an Elementor tree. Uses the same
* surgical per-keyword regex as the post_content path, so it only removes
* anchors whose text matches one of the rule's keywords/synonyms — links
* from OTHER rules pointing to the same URL are left intact.
*
* @param array $elements Elementor tree (mutated in place).
* @param array $keywords Keywords/synonyms of the rule being removed.
* @param int $stripped Running count of anchors removed (by reference).
*/
private function strip_keywords_from_elementor_tree( array &$elements, array $keywords, &$stripped ) {
foreach ( $elements as &$el ) {
if ( isset( $el['widgetType'] ) ) {
$field = null;
if ( $el['widgetType'] === 'text-editor' && isset( $el['settings']['editor'] ) ) {
$field = 'editor';
} elseif ( $el['widgetType'] === 'html' && isset( $el['settings']['html'] ) ) {
$field = 'html';
}
if ( $field !== null ) {
$html = (string) $el['settings'][ $field ];
foreach ( $keywords as $keyword ) {
if ( empty( $keyword ) ) continue;
$kw_esc = preg_quote( $keyword, '/' );
$pattern = '/]*data-il=["\']1["\'][^>]*>\s*' . $kw_esc . '\s*<\/a>/iu';
$cnt = preg_match_all( $pattern, $html );
if ( $cnt ) {
$html = preg_replace( $pattern, $keyword, $html );
$stripped += $cnt;
}
}
$el['settings'][ $field ] = $html;
}
}
if ( ! empty( $el['elements'] ) && is_array( $el['elements'] ) ) {
$this->strip_keywords_from_elementor_tree( $el['elements'], $keywords, $stripped );
}
}
unset( $el );
}
/**
* Elimina todos los enlaces del plugin () que apuntan a $url.
* Registra en el log los anchors eliminados.
*/
public function strip_links_only( $url ) {
global $wpdb;
$url = esc_url_raw( $url );
$url_trail = trailingslashit( $url );
$like_il = '%' . $wpdb->esc_like( 'data-il="1"' ) . '%';
$like1 = '%' . $wpdb->esc_like( 'href="' . $url . '"' ) . '%';
$like2 = '%' . $wpdb->esc_like( 'href="' . $url_trail . '"' ) . '%';
$posts = $wpdb->get_results( $wpdb->prepare(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type NOT IN ('revision','attachment','nav_menu_item')
AND post_content LIKE %s
AND ( post_content LIKE %s OR post_content LIKE %s )",
$like_il, $like1, $like2
) );
$updated = 0;
foreach ( $posts as $post ) {
$stripped_anchors = $this->extract_plugin_link_anchors( $post->post_content, $url );
$new_content = $this->strip_links_for_url( $post->post_content, $url );
if ( $new_content !== $post->post_content ) {
$wpdb->update( $wpdb->posts, array( 'post_content' => $new_content ), array( 'ID' => $post->ID ) );
clean_post_cache( $post->ID );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
// (Log of stripped anchors removed — il_log was deprecated)
}
}
return $updated;
}
/**
* Strip ALL plugin-inserted links from content, regardless of destination.
* Builds the pattern from concatenated strings to avoid quote conflicts.
* Returns the cleaned content.
*/
public function strip_all_plugin_links( $content ) {
if ( strpos( $content, 'data-il=' ) === false ) {
return $content;
}
// Match text in any attribute order
$pat = '/]*data-il=["\']1["\'][^>]*>(.*?)<\/a>/is';
return preg_replace( $pat, '$1', $content );
}
/**
* Compare two URLs as pointing to the SAME target, tolerant of trailing
* slash, scheme (http/https) and host casing. Falls back to comparing the
* normalised path when full URLs differ only cosmetically. Used to detect
* self-links so a pilar page never links to itself.
*
* @param string $a
* @param string $b
* @return bool True if both URLs resolve to the same target.
*/
private function same_target( $a, $b ) {
$a = esc_url_raw( (string) $a );
$b = esc_url_raw( (string) $b );
if ( $a === '' || $b === '' ) return false;
// Fast path: identical once trailing slashes are normalised.
if ( trailingslashit( $a ) === trailingslashit( $b ) ) return true;
// Compare normalised paths (handles scheme/host/slash differences).
$pa = wp_parse_url( $a, PHP_URL_PATH );
$pb = wp_parse_url( $b, PHP_URL_PATH );
if ( $pa && $pb ) {
// Require same host when both expose one, so identical paths on
// different domains don't falsely match.
$ha = strtolower( (string) wp_parse_url( $a, PHP_URL_HOST ) );
$hb = strtolower( (string) wp_parse_url( $b, PHP_URL_HOST ) );
$host_ok = ( $ha === '' || $hb === '' || $ha === $hb );
if ( $host_ok && rtrim( $pa, '/' ) === rtrim( $pb, '/' ) ) {
return true;
}
}
return false;
}
/**
* Decide whether linking $post_id to $dest_url would be a self-link
* (the post pointing at itself), which must never happen — least of all on
* pilar pages, which are prime self-link candidates because every rule
* targets them.
*
* Layer 1 (authoritative): resolve $dest_url to a post ID via
* url_to_postid() and compare with $post_id. This ignores trailing slash,
* scheme and host entirely.
*
* Layer 2 (fallback): when url_to_postid() returns 0 (custom permalinks,
* unsaved drafts, external-looking URLs that are really internal), compare
* the destination against the post's own permalink with same_target().
*
* @param int $post_id Post being processed (the potential origin).
* @param string $current_url Permalink of $post_id (already resolved).
* @param string $dest_url Candidate destination URL.
* @return bool True if this would be a self-link and must be skipped.
*/
private function is_self_link( $post_id, $current_url, $dest_url ) {
$post_id = (int) $post_id;
// Layer 1: destination resolves to the same post.
if ( function_exists( 'url_to_postid' ) ) {
$dest_id = (int) url_to_postid( $dest_url );
if ( $dest_id > 0 && $dest_id === $post_id ) {
return true;
}
}
// Layer 2: normalised URL/path comparison.
return $this->same_target( $current_url, $dest_url );
}
/**
* Called when a URL is added to exclusions.
* Finds all posts that either:
* (a) contain a plugin link TO this URL (excluded as destination), or
* (b) ARE this URL (excluded as origin — they have outgoing plugin links)
* Then runs apply_links() on each, which now starts with a clean slate,
* stripping stale links and reinserting only what is currently valid.
*
* Returns the number of posts actually updated.
*/
public function strip_links_for_exclusion( $url ) {
global $wpdb;
$url = esc_url_raw( trim( $url ) );
$url_trail = trailingslashit( $url );
$url_path = wp_parse_url( $url, PHP_URL_PATH );
// Exclusion means: this page will NO LONGER GENERATE outgoing links.
// It can still RECEIVE incoming links from other pages.
// So we only need to find the post whose permalink matches $url
// and strip the outgoing plugin links it contains.
$posts = $wpdb->get_results(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type NOT IN ('revision','attachment','nav_menu_item')"
);
$updated = 0;
foreach ( $posts as $post ) {
$permalink = esc_url_raw( get_permalink( $post->ID ) );
$permalink_trail = trailingslashit( $permalink );
$permalink_path = wp_parse_url( $permalink, PHP_URL_PATH );
// Only process the post whose URL is being excluded
$is_the_url = ( $permalink === $url || $permalink_trail === $url_trail );
if ( ! $is_the_url && $url_path && $permalink_path ) {
$is_the_url = ( rtrim( $url_path, '/' ) === rtrim( $permalink_path, '/' ) );
}
if ( ! $is_the_url ) continue;
// Strip all outgoing plugin links from this post
$new_content = $this->strip_all_plugin_links( $post->post_content );
if ( $new_content !== $post->post_content ) {
$wpdb->update( $wpdb->posts, array( 'post_content' => $new_content ), array( 'ID' => $post->ID ) );
clean_post_cache( $post->ID );
if ( class_exists( 'FLINK_Link_Stats' ) ) FLINK_Link_Stats::update_post( $post->ID );
$updated++;
}
break; // Only one post can match
}
return $updated;
}
/**
* Elimina del HTML solo los que apuntan a $url.
* Los enlaces manuales del redactor (sin data-il) NO se tocan.
*/
public function strip_links_for_url( $content, $url ) {
$url = esc_url_raw( $url );
$url_trail = trailingslashit( $url );
foreach ( array( $url, $url_trail ) as $u ) {
$u_esc = preg_quote( $u, '/' );
// data-il before href
$content = preg_replace(
'/]*data-il=["\']1["\'][^>]*href=["\']' . $u_esc . '["\'][^>]*>(.*?)<\/a>/is',
'$1',
$content
);
// href before data-il
$content = preg_replace(
'/]*href=["\']' . $u_esc . '["\'][^>]*data-il=["\']1["\'][^>]*>(.*?)<\/a>/is',
'$1',
$content
);
}
return $content;
}
// ─────────────────────────────────────────────────────────────
// HELPERS PRIVADOS
// ─────────────────────────────────────────────────────────────
/** Returns all keywords for a regla: main keyword + synonyms */
private function get_keywords_for_regla( $regla ) {
$keywords = array();
$main_kw = ! empty( $regla->anchor_text ) ? $regla->anchor_text : $regla->nombre;
if ( $main_kw ) $keywords[] = $main_kw;
if ( ! empty( $regla->descripcion ) ) {
$lines = array_filter( array_map( 'trim', explode( "\n", $regla->descripcion ) ) );
foreach ( $lines as $line ) {
if ( $line && ! in_array( $line, $keywords, true ) ) $keywords[] = $line;
}
}
return $keywords;
}
/** Extracts anchor text from plugin-inserted links pointing to $url */
private function extract_plugin_link_anchors( $content, $url ) {
$url = esc_url_raw( $url );
$url_trail = trailingslashit( $url );
$anchors = array();
foreach ( array( $url, $url_trail ) as $u ) {
$u_esc = preg_quote( $u, '/' );
foreach ( array(
'/]*data-il=["\']1["\'][^>]*href=["\']' . $u_esc . '["\'][^>]*>(.*?)<\/a>/is',
'/]*href=["\']' . $u_esc . '["\'][^>]*data-il=["\']1["\'][^>]*>(.*?)<\/a>/is',
) as $pattern ) {
if ( preg_match_all( $pattern, $content, $m ) ) {
foreach ( $m[1] as $html ) {
$clean = trim( wp_strip_all_tags( $html ) );
if ( $clean ) $anchors[] = $clean;
}
}
}
}
return array_unique( $anchors );
}
/** Build SQL type WHERE clause from setting */
private function get_type_where( $wpdb, $origen_tipo ) {
if ( $origen_tipo !== 'any' ) {
$types = array_map( 'trim', explode( ',', $origen_tipo ) );
$placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
return $wpdb->prepare( "AND post_type IN ($placeholders)", $types );
}
return "AND post_type IN ('post','page')";
}
/**
* Inserts a link around $anchor in $content pointing to $url.
* Rules:
* - Only matches whole words (\b boundary): "salud" won't match inside "saludable"
* - Never inserts inside existing tags
* - Never inserts inside –
headings
* - Never inserts inside
, no H2) — // operate on the whole content as before. $safe_content = $this->replace_in_safe_segments( $content, $pattern, function( $matches ) use ( $url, $max, &$inserted ) { if ( $inserted >= $max ) return $matches[0]; $inserted++; return '' . esc_html( $matches[1] ) . ''; } ); return array( 'content' => $safe_content, 'inserted' => $inserted ); } /** * Locate where the "body" of the article starts, so we can avoid * linking from the intro (SEO best practice). * * Returns the byte offset where the body starts: * - At the position of the first