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; 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; $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; } $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 ); } } public function apply_links( $content, $post_id ) { // ── 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 ); $insertados = 0; $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 ) { $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 if ( trailingslashit( $current_url ) === trailingslashit( $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 } } } return $content; } // ───────────────────────────────────────────────────────────── // 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; // 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; } // 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; foreach ( $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; $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; foreach ( $posts as $post ) { $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++; } // 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) } } 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++; } } return $updated; } /** * 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 ); } /** * 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