base = $base; } public function extract(string $html, string $selector = '', array $exclude_selectors = []): array { $this->block_links = 0; $this->tables = 0; $this->disclosures = 0; $this->contexts = 0; $this->decorative_headings = 0; $this->decorative_symbols = 0; if (!class_exists('DOMDocument')) { throw new RuntimeException('PHP DOM/XML extension is required. Ask your host to enable it.'); } $previous = libxml_use_internal_errors(true); $dom = new DOMDocument(); $dom->loadHTML('' . $html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); libxml_clear_errors(); libxml_use_internal_errors($previous); $xp = new DOMXPath($dom); $seo_title = ''; $title_node = $dom->getElementsByTagName('title')->item(0); if ($title_node) { $seo_title = trim((string) preg_replace('/\s+/u', ' ', $title_node->textContent)); } $seo_description = ''; $robots = []; foreach ($xp->query('//meta[@name]') as $meta) { $meta_name = strtolower($meta->getAttribute('name')); if (in_array($meta_name, ['robots','googlebot','bingbot'], true)) { $robots[] = strtolower($meta->getAttribute('content')); } if ($seo_description === '' && $meta_name === 'description') { $seo_description = trim((string) preg_replace('/\s+/u', ' ', $meta->getAttribute('content'))); } } if ($seo_title === '') { $og_title = $xp->query('//meta[translate(@property,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="og:title"]')->item(0); if ($og_title) { $seo_title = trim((string) preg_replace('/\s+/u', ' ', $og_title->getAttribute('content'))); } } if ($seo_description === '') { $og_description = $xp->query('//meta[translate(@property,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="og:description"]')->item(0); if ($og_description) { $seo_description = trim((string) preg_replace('/\s+/u', ' ', $og_description->getAttribute('content'))); } } if (preg_match('/\b(noindex|none)\b/', implode(' ', $robots))) { throw new RuntimeException('Source HTML declares noindex. Publishing is blocked.'); } $canonical = ''; foreach ($xp->query('//link[@href]') as $link) { if (preg_match('/\bcanonical\b/i', $link->getAttribute('rel'))) { $canonical = $this->url($link->getAttribute('href')); break; } } $root = null; if ($selector !== '') { // A single tag, #id or .class only: no arbitrary XPath execution. if (!preg_match('/^(?:[a-z][a-z0-9-]*|[.#][a-zA-Z_][a-zA-Z0-9_-]*)$/', $selector)) { throw new RuntimeException('Use one selector: main, article, #content or .entry-content.'); } $query = $selector[0] === '#' ? '//*[@id="' . substr($selector, 1) . '"]' : ($selector[0] === '.' ? '//*[contains(concat(" ",normalize-space(@class)," ")," ' . substr($selector, 1) . ' ")]' : '//' . $selector); $root = $xp->query($query)->item(0); if (!$root) { throw new RuntimeException('Content selector did not match. Nothing has been published.'); } } else { foreach (['//main','//*[@role="main"]','//article','//*[contains(concat(" ",normalize-space(@class)," ")," entry-content ")]'] as $query) { $root = $xp->query($query)->item(0); if ($root) { break; } } if (!$root) { throw new RuntimeException('No main content container found. Set a content selector; the plugin will not export the whole page blindly.'); } } if (count($exclude_selectors) > 30) { throw new RuntimeException('Use no more than 30 exclusion selectors.'); } // Headers inside articles may contain real title/byline content and are retained. $remove = []; foreach ($xp->query('.//script|.//style|.//nav|.//footer|.//form|.//iframe|.//svg|.//noscript|.//template|.//button|.//*[@hidden]|.//*[@aria-hidden="true"]|.//*[@data-aeohelper-exclude]|.//*[@data-zire-ai-exclude]', $root) as $node) { $remove[] = $node; } foreach ($exclude_selectors as $exclude_selector) { $exclude_selector = trim((string) $exclude_selector); if (!preg_match('/^[.#][a-zA-Z_][a-zA-Z0-9_-]*$/', $exclude_selector)) { throw new RuntimeException('Exclusion selectors must be one simple .class or #id per line.'); } $query = $exclude_selector[0] === '#' ? './/*[@id="' . substr($exclude_selector, 1) . '"]' : './/*[contains(concat(" ",normalize-space(@class)," ")," ' . substr($exclude_selector, 1) . ' ")]'; foreach ($xp->query($query, $root) as $node) { $remove[] = $node; } } foreach ($xp->query('.//*[@style]', $root) as $node) { if (preg_match('/(?:display\s*:\s*none|visibility\s*:\s*hidden)/i', $node->getAttribute('style'))) { $remove[] = $node; } } foreach ($remove as $node) { if ($node->parentNode) { $node->parentNode->removeChild($node); } } $duplicate_h1_removed = $this->deduplicate_h1($root); $headings = []; foreach ($xp->query('.//h1|.//h2|.//h3|.//h4|.//h5|.//h6', $root) as $heading) { $heading_text = trim((string) preg_replace('/\s+/u', ' ', $heading->textContent)); if ($this->heading_key($heading_text) !== '') { $headings[] = strtoupper($heading->nodeName) . ': ' . $heading_text; } } $markdown = trim(preg_replace('/\n{3,}/', "\n\n", $this->node($root))); if (strlen($markdown) < 80) { throw new RuntimeException('Extracted content is too short. Check the selector or whether content requires JavaScript.'); } return ['markdown' => $markdown, 'headings' => $headings, 'canonical' => $canonical, 'seo_title' => $seo_title, 'seo_description' => $seo_description, 'bytes' => strlen($markdown), 'words' => count(preg_split('/\s+/u', $markdown, -1, PREG_SPLIT_NO_EMPTY)), 'selector' => $selector ?: $root->nodeName, 'exclude_selectors' => array_values($exclude_selectors), 'diagnostics' => ['duplicate_h1_removed'=>$duplicate_h1_removed,'decorative_headings_removed'=>$this->decorative_headings,'decorative_symbols_removed'=>$this->decorative_symbols,'block_links_rewritten'=>$this->block_links,'tables'=>$this->tables,'disclosures'=>$this->disclosures,'contexts'=>$this->contexts]]; } private int $block_links = 0; private int $tables = 0; private int $disclosures = 0; private int $contexts = 0; private int $decorative_headings = 0; private int $decorative_symbols = 0; private function children(DOMNode $node): string { $out = ''; foreach ($node->childNodes as $child) { $out .= $this->node($child); } return $out; } private function node(DOMNode $node): string { if ($node instanceof DOMText) { return $this->escape_text($node->textContent); } if (!($node instanceof DOMElement)) { return ''; } $tag = strtolower($node->tagName); $plain_text = trim((string) preg_replace('/\s+/u', ' ', $node->textContent)); if ($plain_text === '#' && !in_array($tag, ['pre','code'], true)) { $this->decorative_symbols++; return ''; } $context_prefix = $this->context_prefix($node); $result = ''; if ($tag === 'pre') { $code = trim($node->textContent, "\r\n"); preg_match_all('/`+/', $code, $runs); $length = 3; foreach ($runs[0] as $run) { $length = max($length, strlen($run) + 1); } $fence = str_repeat('`', $length); $result = "\n\n" . $fence . "\n" . $code . "\n" . $fence . "\n\n"; return $this->with_context($context_prefix, $result); } if ($node->hasAttribute('data-aeohelper-table') || $node->hasAttribute('data-zire-ai-table')) { $this->tables++; return $this->with_context($context_prefix, $this->structured_table($node)); } if ($tag === 'table') { $this->tables++; return $this->with_context($context_prefix, $this->table($node)); } if ($tag === 'details') { $this->disclosures++; return $this->with_context($context_prefix, $this->details($node)); } if ($tag === 'ul' || $tag === 'ol') { $out = "\n\n"; $i = $node->hasAttribute('start') ? (int) $node->getAttribute('start') : 1; foreach ($node->childNodes as $li) { if (!($li instanceof DOMElement) || $li->tagName !== 'li') { continue; } $list_prefix = $tag === 'ol' ? ($i++) . '. ' : '- '; $text = trim($this->children($li)); $out .= $list_prefix . str_replace("\n", "\n" . str_repeat(' ', strlen($list_prefix)), $text) . "\n"; } return $this->with_context($context_prefix, $out . "\n"); } $text = $this->children($node); if (preg_match('/^h([1-6])$/', $tag, $m)) { $heading = trim((string) preg_replace('/\s+/u', ' ', $text)); if ($heading === '' || !preg_match('/[\p{L}\p{N}]/u', $heading)) { $this->decorative_headings++; return ''; } $result = "\n\n" . str_repeat('#', (int) $m[1]) . ' ' . $heading . "\n\n"; } elseif ($tag === 'a') { $result = $this->anchor($node, $text); } elseif ($tag === 'img') { $alt = trim($node->getAttribute('alt')); $url = $this->url($node->getAttribute('src')); $result = $alt && $url ? '![' . str_replace([']','['], '', $alt) . '](' . str_replace([' ',')','('], ['%20','%29','%28'], $url) . ')' : ''; } elseif ($tag === 'strong' || $tag === 'b') { $text = trim($text); $result = $text === '' ? '' : ($this->has_ancestor_tag($node, ['strong','b']) ? $text : '**' . $text . '**'); } elseif ($tag === 'em' || $tag === 'i') { $text = trim($text); $result = $text === '' ? '' : ($this->has_ancestor_tag($node, ['em','i']) ? $text : '*' . $text . '*'); } elseif ($tag === 'code') { $result = '`` ' . str_replace('``', '', $node->textContent) . ' ``'; } elseif ($tag === 'br') { $result = "\n"; } elseif ($tag === 'hr') { $result = "\n\n---\n\n"; } elseif ($tag === 'blockquote') { $result = "\n\n> " . str_replace("\n", "\n> ", trim($text)) . "\n\n"; } elseif ($tag === 'summary') { $result = trim($this->summary_text($node)); } elseif (in_array($tag, ['p','div','section','article','main','header','aside','figure','figcaption','dt','dd'], true)) { $result = "\n\n" . trim($text) . "\n\n"; } else { $result = $text; } return $this->with_context($context_prefix, $result); } private function escape_text(string $text): string { $text = (string) preg_replace('/\s+/u', ' ', $text); return (string) preg_replace('/([\\\\`*\[\]<>])/', '\\\\$1', $text); } private function context_prefix(DOMElement $node): string { if (!$node->hasAttribute('data-aeohelper-context') && !$node->hasAttribute('data-zire-ai-context')) { return ''; } $attribute = $node->hasAttribute('data-aeohelper-context') ? 'data-aeohelper-context' : 'data-zire-ai-context'; $context = trim((string) preg_replace('/\s+/u', ' ', $node->getAttribute($attribute))); if ($context === '') { return ''; } return "\n\n**" . trim($this->escape_text($context)) . "**\n\n"; } private function with_context(string $context, string $content): string { if (trim($content) === '') { return ''; } if ($context !== '') { $this->contexts++; } return $context . $content; } private function has_ancestor_tag(DOMElement $node, array $tags): bool { $parent = $node->parentNode; while ($parent instanceof DOMElement) { if (in_array(strtolower($parent->tagName), $tags, true)) { return true; } $parent = $parent->parentNode; } return false; } private function anchor(DOMElement $anchor, string $text): string { $url = $this->url($anchor->getAttribute('href')); if (!$url || trim($text) === '') { return $text; } $target = str_replace([' ',')','('], ['%20','%29','%28'], $url); foreach (['h1','h2','h3','h4','h5','h6','p','div','section','article','ul','ol','table','details'] as $block_tag) { if ($anchor->getElementsByTagName($block_tag)->length < 1) { continue; } $this->block_links++; $label = ''; foreach (['h1','h2','h3','h4','h5','h6'] as $heading_tag) { $heading = $anchor->getElementsByTagName($heading_tag)->item(0); if ($heading) { $label = trim(preg_replace('/\s+/u', ' ', $heading->textContent)); break; } } if ($label === '') { $label = 'View linked page'; } $label = trim(str_replace(['[',']'], '', $label)); return "\n\n" . trim($text) . "\n\n[" . $label . " →](" . $target . ")\n\n"; } return '[' . trim($text) . '](' . $target . ')'; } private function deduplicate_h1(DOMNode $root): int { $headings = []; if ($root instanceof DOMElement && strtolower($root->tagName) === 'h1') { $headings[] = $root; } if ($root instanceof DOMElement) { foreach ($root->getElementsByTagName('h1') as $heading) { $headings[] = $heading; } } $keep = []; $remove = []; for ($i = count($headings) - 1; $i >= 0; $i--) { $key = $this->heading_key($headings[$i]->textContent); if ($key === '' || !isset($keep[$key])) { $keep[$key] = true; continue; } $remove[] = $headings[$i]; } foreach ($remove as $heading) { if ($heading->parentNode) { $heading->parentNode->removeChild($heading); } } return count($remove); } private function heading_key(string $text): string { $text = function_exists('mb_strtolower') ? mb_strtolower($text, 'UTF-8') : strtolower($text); return trim((string) preg_replace('/[^\p{L}\p{N}]+/u', '', $text)); } private function details(DOMElement $details): string { $summary = null; foreach ($details->childNodes as $child) { if ($child instanceof DOMElement && strtolower($child->tagName) === 'summary') { $summary = $child; break; } } if (!$summary) { return "\n\n" . trim($this->children($details)) . "\n\n"; } $question = trim($this->summary_text($summary)); $answer = ''; foreach ($details->childNodes as $child) { if ($child === $summary) { continue; } $answer .= $this->node($child); } $answer = trim($answer); if ($question === '') { return $answer === '' ? '' : "\n\n" . $answer . "\n\n"; } return "\n\n### " . $question . ($answer === '' ? '' : "\n\n" . $answer) . "\n\n"; } private function summary_text(DOMNode $summary): string { $out = ''; foreach ($summary->childNodes as $child) { if ($child instanceof DOMText) { $out .= $this->node($child); continue; } if (!($child instanceof DOMElement)) { continue; } // Themes commonly add a span containing only a +/minus/chevron as the visual toggle. $plain = trim(preg_replace('/\s+/u', ' ', $child->textContent)); if ($plain !== '' && preg_match('/^[+\x{2212}\x{2013}\x{2014}\x{203A}\x{2039}\x{2303}\x{2304}\x{25B2}\x{25BC}\x{276F}\x{2191}\x{2193}]$/u', $plain)) { continue; } $out .= $this->node($child); } return trim($out); } private function table(DOMElement $table): string { $rows = []; $width = 0; foreach ($table->getElementsByTagName('tr') as $row) { $cells = []; foreach ($row->childNodes as $cell) { if ($cell instanceof DOMElement && in_array($cell->tagName, ['th','td'], true)) { $cells[] = str_replace(['|',"\n"], ['\\|',' '], trim($this->children($cell))); } } if ($cells) { $rows[] = $cells; $width = max($width, count($cells)); } } if (!$rows) { return ''; } $out = "\n\n"; foreach ($rows as $i => $cells) { $out .= '| ' . implode(' | ', array_pad($cells, $width, '')) . " |\n"; if ($i === 0) { $out .= '| ' . implode(' | ', array_fill(0, $width, '---')) . " |\n"; } } return $out . "\n\n"; } /** * Convert an explicitly marked visual grid into a Markdown table. * * The first direct child is treated as the header row. * Each subsequent direct child is treated as one table row. * Direct child elements inside each row are treated as cells. * A row containing only text becomes a labelled group row. * * Usage: *