dom = new DOMDocument(); libxml_use_internal_errors( true ); $encoded = mb_encode_numericentity( $html_content, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8' ); @$this->dom->loadHTML( "
$encoded
", LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); $this->build_xpath_exclusion( $apply_exclusions ); $this->build_map(); } private function build_xpath_exclusion( $apply_user_exclusions = true ) { $excluded_tags = ['a', 'script', 'style']; $user_excluded = $apply_user_exclusions ? get_option( 'ailm_excluded_html', ['h1-h6', 'code', 'blockquote'] ) : []; if ( in_array( 'h1-h6', $user_excluded ) ) $excluded_tags = array_merge( $excluded_tags, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] ); if ( in_array( 'strong', $user_excluded ) ) $excluded_tags = array_merge( $excluded_tags, ['strong', 'b'] ); if ( in_array( 'em', $user_excluded ) ) $excluded_tags = array_merge( $excluded_tags, ['em', 'i'] ); if ( in_array( 'blockquote', $user_excluded ) ) $excluded_tags[] = 'blockquote'; if ( in_array( 'code', $user_excluded ) ) $excluded_tags = array_merge( $excluded_tags, ['code', 'pre'] ); if ( in_array( 'ul-ol', $user_excluded ) ) $excluded_tags = array_merge( $excluded_tags, ['ul', 'ol', 'li'] ); $excluded_tags = array_unique( $excluded_tags ); $conditions = []; foreach ( $excluded_tags as $tag ) { $conditions[] = "not(ancestor::$tag)"; } $this->xpath_exclusion = implode( ' and ', $conditions ); } private function get_block_path( $node ) { $block_tags = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'table', 'tr', 'td', 'th', 'figure', 'article', 'section', 'header', 'footer', 'aside', 'nav', 'main']; while ( $node ) { if ( $node->nodeType === XML_ELEMENT_NODE ) { if ( in_array( strtolower( $node->nodeName ), $block_tags, true ) ) { return $node->getNodePath(); } } $node = $node->parentNode; } return ''; } private function build_map() { $xpath = new DOMXPath( $this->dom ); $query = '//text()[' . $this->xpath_exclusion . ']'; $nodes = $xpath->query( $query ); $current_offset = 0; $prev_block_path = null; foreach ( $nodes as $node ) { $text = $node->nodeValue; $length = mb_strlen( $text, 'UTF-8' ); if ( $length > 0 ) { $curr_block_path = $this->get_block_path( $node ); if ( $prev_block_path !== null && $curr_block_path !== $prev_block_path ) { $this->virtual_text .= "\n\n"; $current_offset += 2; } $prev_block_path = $curr_block_path; $this->node_map[] = [ 'node' => $node, 'start' => $current_offset, 'end' => $current_offset + $length, 'length' => $length, 'text' => $text ]; $this->virtual_text .= $text; $current_offset += $length; } } } public function get_manual_links_positions() { $xpath = new DOMXPath( $this->dom ); $a_nodes = $xpath->query( '//a' ); $positions = []; $valid_text_query = 'preceding::text()[' . $this->xpath_exclusion . ']'; foreach ( $a_nodes as $a ) { $preceding_texts = $xpath->query( $valid_text_query, $a ); $offset = 0; $prev_block_path = null; foreach ( $preceding_texts as $pt ) { $length = mb_strlen( $pt->nodeValue, 'UTF-8' ); if ( $length > 0 ) { $curr_block_path = $this->get_block_path( $pt ); if ( $prev_block_path !== null && $curr_block_path !== $prev_block_path ) { $offset += 2; } $prev_block_path = $curr_block_path; $offset += $length; } } $positions[] = $offset; } return $positions; } public function get_virtual_text() { return $this->virtual_text; } public function get_dom() { return $this->dom; } public function queue_link( $start_pos, $length, $url, $full_anchor, $origin_type = 'ki' ) { $this->queued_links[] = [ 'start' => $start_pos, 'end' => $start_pos + $length, 'url' => $url, 'anchor' => $full_anchor, 'origin' => $origin_type ]; } public function apply_links() { if ( empty( $this->queued_links ) ) return; $node_modifications = []; foreach ( $this->queued_links as $link ) { foreach ( $this->node_map as $map_index => $map_item ) { if ( $link['start'] < $map_item['end'] && $link['end'] > $map_item['start'] ) { $overlap_start = max( $link['start'], $map_item['start'] ); $overlap_end = min( $link['end'], $map_item['end'] ); $rel_start = $overlap_start - $map_item['start']; $rel_length = $overlap_end - $overlap_start; if ( $rel_length > 0 ) { if ( ! isset( $node_modifications[ $map_index ] ) ) $node_modifications[ $map_index ] = []; $node_modifications[ $map_index ][] = [ 'rel_start' => $rel_start, 'rel_length' => $rel_length, 'url' => $link['url'], 'anchor' => $link['anchor'], 'origin' => $link['origin'] ]; } } } } foreach ( $node_modifications as $map_index => $mods ) { $map_item = $this->node_map[ $map_index ]; $node = $map_item['node']; $original_text = $map_item['text']; usort( $mods, function( $a, $b ) { return $b['rel_start'] <=> $a['rel_start']; } ); $fragments = []; $last_cut = mb_strlen( $original_text, 'UTF-8' ); foreach ( $mods as $mod ) { $mod_end = $mod['rel_start'] + $mod['rel_length']; if ( $last_cut > $mod_end ) { $after_text = mb_substr( $original_text, $mod_end, $last_cut - $mod_end, 'UTF-8' ); array_unshift( $fragments, $this->dom->createTextNode( $after_text ) ); } $link_text = mb_substr( $original_text, $mod['rel_start'], $mod['rel_length'], 'UTF-8' ); $a = $this->dom->createElement( 'a' ); $a->setAttribute( 'href', esc_url( $mod['url'] ) ); $a->setAttribute( 'class', 'ailm-internal-link' ); $a->setAttribute( 'data-ailm', esc_attr( $mod['origin'] ) ); // Маркер для сканера $a->appendChild( $this->dom->createTextNode( $link_text ) ); array_unshift( $fragments, $a ); $last_cut = $mod['rel_start']; } if ( $last_cut > 0 ) { $before_text = mb_substr( $original_text, 0, $last_cut, 'UTF-8' ); array_unshift( $fragments, $this->dom->createTextNode( $before_text ) ); } $parent = $node->parentNode; if ( $parent ) { $frag_doc = $this->dom->createDocumentFragment(); foreach ( $fragments as $f ) { $frag_doc->appendChild( $f ); } $parent->replaceChild( $frag_doc, $node ); } } } /** * НОВОЕ: Хирургическое удаление конкретной ссылки из HTML (оставляет только текст) */ public function unwrap_link( $target_url, $anchor ) { $xpath = new DOMXPath( $this->dom ); $links = $xpath->query( '//a' ); $modified = false; $target_path = trim( wp_parse_url( urldecode( $target_url ), PHP_URL_PATH ), '/' ); $anchor_clean = mb_strtolower( trim( preg_replace('/\s+/u', ' ', str_replace("\xC2\xA0", ' ', $anchor)) ), 'UTF-8' ); $links_array = []; foreach ( $links as $a ) { $links_array[] = $a; } foreach ( $links_array as $a ) { $href = $a->getAttribute( 'href' ); if ( empty( $href ) ) continue; $href_path = trim( wp_parse_url( urldecode( $href ), PHP_URL_PATH ), '/' ); $text_clean = mb_strtolower( trim( preg_replace('/\s+/u', ' ', str_replace("\xC2\xA0", ' ', $a->textContent)) ), 'UTF-8' ); if ( $href_path === $target_path && $text_clean === $anchor_clean ) { $fragment = $this->dom->createDocumentFragment(); while ( $a->childNodes->length > 0 ) { $fragment->appendChild( $a->childNodes->item(0) ); } $a->parentNode->replaceChild( $fragment, $a ); $modified = true; } } return $modified; } public function get_html() { $html = $this->dom->saveHTML(); $html = preg_replace( [ '/^
/', '/<\/div>$/' ], '', $html ); libxml_clear_errors(); return trim( $html ); } /** * Find a paragraph/li/blockquote node by matching its text content. * * @param string $search_text The raw donor chunk text. * @return DOMElement|null */ public function find_paragraph_node( $search_text ) { $xpath = new DOMXPath( $this->dom ); $paragraphs = $xpath->query( '//p | //li | //blockquote' ); $search_normalized = $this->normalize_text_for_search( $search_text ); if ( $search_normalized === '' ) { return null; } $search_prefix = mb_substr( $search_normalized, 0, 150 ); $best_match = null; $highest_percent = 0; foreach ( $paragraphs as $p ) { $p_text = $this->normalize_text_for_search( $p->textContent ); if ( mb_strpos( $p_text, $search_prefix ) !== false ) { // Prefer the deepest matching node: if this node contains // child

or

  • elements that also match, return the // child instead. This handles nested structures like //

    text

    where both the // outer blockquote and inner p match the search text. $child_blocks = $xpath->query( './/p | .//li', $p ); foreach ( $child_blocks as $child ) { $child_text = $this->normalize_text_for_search( $child->textContent ); if ( mb_strpos( $child_text, $search_prefix ) !== false ) { return $child; } } return $p; } similar_text( mb_substr( $p_text, 0, 200 ), mb_substr( $search_normalized, 0, 200 ), $percent ); if ( $percent > $highest_percent ) { $highest_percent = $percent; $best_match = $p; } } if ( $best_match && $highest_percent > 75 ) { // Same deepest-node preference for fuzzy matches. $child_blocks = $xpath->query( './/p | .//li', $best_match ); foreach ( $child_blocks as $child ) { $child_text = $this->normalize_text_for_search( $child->textContent ); $child_percent = 0; similar_text( mb_substr( $child_text, 0, 200 ), mb_substr( $search_normalized, 0, 200 ), $child_percent ); if ( $child_percent >= $highest_percent ) { return $child; } } return $best_match; } return null; } /** * Check if a node path is an ancestor of another node path. * E.g. "/div/blockquote" is an ancestor of "/div/blockquote/p". * * @param string $ancestor_path * @param string $descendant_path * @return bool */ private function is_path_ancestor( $ancestor_path, $descendant_path ) { if ( $ancestor_path === '' || $descendant_path === '' ) { return false; } // Ancestor if descendant path starts with ancestor path + "/" return strpos( $descendant_path, $ancestor_path . '/' ) === 0; } /** * Get the virtual_text start offset of a given paragraph node. * * Handles nested block structures (e.g.

    text

    ) * by falling back to descendant nodes when an exact path match fails. * * @param DOMElement $paragraph_node * @return int|null */ public function get_paragraph_virtual_start( $paragraph_node ) { $para_path = $paragraph_node->getNodePath(); // 1. Try exact path match first (normal case). foreach ( $this->node_map as $map_item ) { $node_path = $this->get_block_path( $map_item['node'] ); if ( $node_path === $para_path ) { return $map_item['start']; } } // 2. Fallback: the paragraph node may be an ancestor of mapped nodes // (e.g. find_paragraph_node returned
    but map has

    // paths inside it). Return the start of the first descendant. foreach ( $this->node_map as $map_item ) { $node_path = $this->get_block_path( $map_item['node'] ); if ( $this->is_path_ancestor( $para_path, $node_path ) ) { return $map_item['start']; } } return null; } /** * Reconstruct the virtual_text fragment for a given paragraph node. * * Handles nested block structures by including text from descendant * nodes when an exact path match fails. * * @param DOMElement $paragraph_node * @return string */ public function get_paragraph_virtual_text( $paragraph_node ) { $para_path = $paragraph_node->getNodePath(); $text = ''; // 1. Try exact path match first (normal case). $has_exact = false; foreach ( $this->node_map as $map_item ) { $node_path = $this->get_block_path( $map_item['node'] ); if ( $node_path === $para_path ) { $text .= $map_item['text']; $has_exact = true; } } // 2. If exact match found, return it. if ( $has_exact ) { return $text; } // 3. Fallback: include text from all descendant nodes. foreach ( $this->node_map as $map_item ) { $node_path = $this->get_block_path( $map_item['node'] ); if ( $this->is_path_ancestor( $para_path, $node_path ) ) { $text .= $map_item['text']; } } return $text; } /** * Normalize text for paragraph search. * * @param string $text * @return string */ private function normalize_text_for_search( $text ) { $text = preg_replace( '/<\/(p|li|ul|ol|blockquote|h[1-6])>/i', '$0 ', $text ); $text = wp_strip_all_tags( $text ); $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = preg_replace( '/\s+/u', ' ', $text ); $text = preg_replace( '/[^\p{L}\p{N}\s]/u', '', $text ); return trim( mb_strtolower( $text, 'UTF-8' ) ); } }