*/ private static array $executed_rules = []; /** * Constructor. * * @since 1.0.0 */ public function __construct() { // Disable HTML namespace to make XPath work correctly $this->html5_parser = new HTML5([ 'disable_html_ns' => true ]); $this->logger = new Logger(); } /** * Process HTML content and hide elements matching XPath rules. * * @since 1.0.0 * @param string $html Input HTML content. * @param array> $rules Array of XPath rules. * @param bool $is_selection_mode Whether selection mode is active. * @return string Processed HTML content. */ public function process_html(string $html, array $rules, bool $is_selection_mode = false): string { $this->logger->debug('Processing HTML with rules', ['rules_count' => count($rules)]); if (empty($rules) || empty(trim($html))) { $this->logger->debug('No rules or empty HTML, skipping processing'); return $html; } // Extract XPath expressions from rules and check if already executed $xpaths = []; foreach ($rules as $rule) { if (!empty($rule['xpath']) && !empty($rule['active'])) { $xpath = $rule['xpath']; // Check if this rule was already executed if (isset(self::$executed_rules[$xpath])) { continue; } $xpaths[] = $xpath; } } if (empty($xpaths)) { $this->logger->debug('No new XPath rules to execute'); return $html; } // In selection mode, use visual hiding (CSS class) // In normal mode, use DOM deletion (placeholder elements) $processed_html = $this->apply_rules($html, $xpaths, !$is_selection_mode); // Track which rules failed to apply (element not found in server HTML) $failed_rule_ids = []; foreach ($rules as $rule) { $xpath = $rule['xpath'] ?? ''; if (!empty($xpath) && !empty($rule['active']) && !isset(self::$executed_rules[$xpath])) { $failed_rule_ids[] = $rule['id']; } } // Save to page-specific transient for InlineCssGenerator // ALWAYS save, even if empty (means all rules applied successfully) $page_key = $this->get_page_cache_key(); $transient_key = 'adsd_failed_rules_' . $page_key; set_transient($transient_key, $failed_rule_ids, 300); // 5 minutes $this->logger->debug('Failed rules saved to transient', [ 'page_key' => $page_key, 'failed_count' => count($failed_rule_ids), 'is_empty' => empty($failed_rule_ids) ]); return $processed_html; } /** * Apply XPath rules to provided HTML. * * @since 1.0.0 * @param string $html Input HTML content. * @param array $xpaths Array of XPath expressions. * @param bool $delete_nodes True to delete nodes, false to hide via inline CSS. * @return string Processed HTML content. */ public function apply_rules(string $html, array $xpaths, bool $delete_nodes = false): string { if (empty($xpaths) || empty(trim($html))) { return $html; } $this->logger->debug('Applying XPath rules', ['count' => count($xpaths), 'delete_nodes' => $delete_nodes]); // Ensure HTML has a root element for DOMDocument $has_html_tag = str_contains($html, ''; } try { // Use HTML5 parser for better HTML compatibility $dom = $this->html5_parser->loadHTML($html); if (!$dom) { return $html; } $xpath = new \DOMXPath($dom); $modified = false; // Sort XPath expressions by position indices in descending order // This ensures that removing div[2] doesn't affect div[1] $xpaths_original = $xpaths; usort($xpaths, function($a, $b) { // Extract all position indices from XPath preg_match_all('/\[(\d+)\]/', $a, $matches_a); preg_match_all('/\[(\d+)\]/', $b, $matches_b); $indices_a = array_map('intval', $matches_a[1] ?? []); $indices_b = array_map('intval', $matches_b[1] ?? []); // Compare from right to left (deepest index first) $max_depth = max(count($indices_a), count($indices_b)); for ($i = $max_depth - 1; $i >= 0; $i--) { $val_a = $indices_a[$i] ?? 0; $val_b = $indices_b[$i] ?? 0; if ($val_a !== $val_b) { return $val_b <=> $val_a; // Descending order } } return 0; }); if ($xpaths !== $xpaths_original) { $this->logger->debug('XPath expressions sorted for safe sibling removal', [ 'original' => $xpaths_original, 'sorted' => $xpaths ]); } // Apply each XPath rule foreach ($xpaths as $expr) { $expr = $this->sanitize_xpath($expr); if ($expr === '') { continue; } $result = $this->apply_xpath_expression($xpath, $expr, $delete_nodes); if ($result) { $modified = true; // Mark this rule as executed self::$executed_rules[$expr] = true; $this->logger->info('XPath rule applied successfully', ['expression' => $expr]); } } // Return modified HTML if changes were made if ($modified) { $result = $this->html5_parser->saveHTML($dom); // Note: Body class 'adsd-selection-mode-active' is now managed by JavaScript // in SelectionOverlay.toggle() for better responsiveness return $result; } } catch (\Exception $e) { // Log error but don't break the page $this->logger->error('XPath processing error', ['message' => $e->getMessage(), 'trace' => $e->getTraceAsString()]); error_log(sprintf('AdsDestroyer XPath Error: %s', $e->getMessage())); } return $html; } /** * Add selection mode CSS class to body element. * * @since 1.0.0 * @param string $html HTML content * @return string Modified HTML content */ private function add_selection_mode_class_to_body(string $html): string { // Add CSS class to body element if it exists $html = preg_replace_callback( '/]*)>/i', function ($matches) { $attributes = $matches[1]; // Check if class already exists if (preg_match('/class\s*=\s*["\']([^"\']*)["\']/', $attributes, $classMatches)) { $existingClasses = $classMatches[1]; if (strpos($existingClasses, 'adsd-selection-mode-active') === false) { $newClasses = $existingClasses . ' adsd-selection-mode-active'; $attributes = str_replace($classMatches[0], 'class="' . $newClasses . '"', $attributes); } } else { // Add class attribute $attributes .= ' class="adsd-selection-mode-active"'; } return ''; }, $html ); return $html; } /** * Apply a single XPath expression to hide matching elements. * * @since 1.0.0 * @param \DOMXPath $xpath XPath object. * @param string $xpath_expression XPath expression. * @param bool $delete_nodes True to delete nodes, false to hide via CSS. * @return bool True if elements were hidden, false otherwise. */ private function apply_xpath_expression(\DOMXPath $xpath, string $xpath_expression, bool $delete_nodes = false): bool { try { // Try multiple strategies to find the element $nodes = $this->find_nodes_with_fallback($xpath, $xpath_expression); if (!$nodes || $nodes->length === 0) { return false; } // Collect nodes and process deepest-first $collected = []; foreach ($nodes as $n) { if ($n instanceof \DOMElement || $n instanceof \DOMText) { $depth = 0; $p = $n; while ($p = $p->parentNode) { $depth++; } $collected[] = ['node' => $n, 'depth' => $depth]; } } usort($collected, static function($a, $b) { return $b['depth'] <=> $a['depth']; }); $count = 0; foreach ($collected as $item) { /** @var \DOMNode $node */ $node = $item['node']; if ($delete_nodes) { if ($node->parentNode) { // Replace with hidden placeholder element to preserve flow more safely than comments $placeholder = $xpath->document->createElement('span'); $placeholder->setAttribute('data-adsd-removed', '1'); $placeholder->setAttribute('aria-hidden', 'true'); $node->parentNode->replaceChild($placeholder, $node); } } else { // Hide via visual CSS class (non-destructive, can be shown on hover) if ($node instanceof \DOMElement) { $existing_classes = $node->getAttribute('class'); $node->setAttribute('class', trim($existing_classes . ' adsd-visually-hidden')); } elseif ($node instanceof \DOMText) { if ($node->parentNode instanceof \DOMElement) { $existing_classes = $node->parentNode->getAttribute('class'); $node->parentNode->setAttribute('class', trim($existing_classes . ' adsd-visually-hidden')); } } } $count++; if ($count > 2000) { break; // Prevent infinite loops } } return $count > 0; } catch (\Exception $e) { error_log(sprintf('AdsDestroyer XPath Rule Error: %s', $e->getMessage())); return false; } } /** * Sanitize XPath expression. * * @since 1.0.0 * @param string $xpath XPath expression. * @return string Sanitized XPath expression. */ private function sanitize_xpath(string $xpath): string { $xpath = trim($xpath); // Prevent XPath injection - check for dangerous patterns $dangerous_patterns = [ '/javascript:/i', // JavaScript protocol '/