vendor/symfony/dom-crawler/Crawler.php line 354

Open in your IDE?
  1. <?php
  2. /*
  3.  * This file is part of the Symfony package.
  4.  *
  5.  * (c) Fabien Potencier <fabien@symfony.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. namespace Symfony\Component\DomCrawler;
  11. use Masterminds\HTML5;
  12. use Symfony\Component\CssSelector\CssSelectorConverter;
  13. /**
  14.  * Crawler eases navigation of a list of \DOMNode objects.
  15.  *
  16.  * @author Fabien Potencier <fabien@symfony.com>
  17.  *
  18.  * @implements \IteratorAggregate<int, \DOMNode>
  19.  */
  20. class Crawler implements \Countable, \IteratorAggregate
  21. {
  22.     /**
  23.      * @var string|null
  24.      */
  25.     protected $uri;
  26.     /**
  27.      * The default namespace prefix to be used with XPath and CSS expressions.
  28.      *
  29.      * @var string
  30.      */
  31.     private $defaultNamespacePrefix 'default';
  32.     /**
  33.      * A map of manually registered namespaces.
  34.      *
  35.      * @var array<string, string>
  36.      */
  37.     private $namespaces = [];
  38.     /**
  39.      * A map of cached namespaces.
  40.      *
  41.      * @var \ArrayObject
  42.      */
  43.     private $cachedNamespaces;
  44.     /**
  45.      * The base href value.
  46.      *
  47.      * @var string|null
  48.      */
  49.     private $baseHref;
  50.     /**
  51.      * @var \DOMDocument|null
  52.      */
  53.     private $document;
  54.     /**
  55.      * @var list<\DOMNode>
  56.      */
  57.     private $nodes = [];
  58.     /**
  59.      * Whether the Crawler contains HTML or XML content (used when converting CSS to XPath).
  60.      *
  61.      * @var bool
  62.      */
  63.     private $isHtml true;
  64.     /**
  65.      * @var HTML5|null
  66.      */
  67.     private $html5Parser;
  68.     /**
  69.      * @param \DOMNodeList|\DOMNode|\DOMNode[]|string|null $node A Node to use as the base for the crawling
  70.      */
  71.     public function __construct($node nullstring $uri nullstring $baseHref null)
  72.     {
  73.         $this->uri $uri;
  74.         $this->baseHref $baseHref ?: $uri;
  75.         $this->html5Parser class_exists(HTML5::class) ? new HTML5(['disable_html_ns' => true]) : null;
  76.         $this->cachedNamespaces = new \ArrayObject();
  77.         $this->add($node);
  78.     }
  79.     /**
  80.      * Returns the current URI.
  81.      *
  82.      * @return string|null
  83.      */
  84.     public function getUri()
  85.     {
  86.         return $this->uri;
  87.     }
  88.     /**
  89.      * Returns base href.
  90.      *
  91.      * @return string|null
  92.      */
  93.     public function getBaseHref()
  94.     {
  95.         return $this->baseHref;
  96.     }
  97.     /**
  98.      * Removes all the nodes.
  99.      */
  100.     public function clear()
  101.     {
  102.         $this->nodes = [];
  103.         $this->document null;
  104.         $this->cachedNamespaces = new \ArrayObject();
  105.     }
  106.     /**
  107.      * Adds a node to the current list of nodes.
  108.      *
  109.      * This method uses the appropriate specialized add*() method based
  110.      * on the type of the argument.
  111.      *
  112.      * @param \DOMNodeList|\DOMNode|\DOMNode[]|string|null $node A node
  113.      *
  114.      * @throws \InvalidArgumentException when node is not the expected type
  115.      */
  116.     public function add($node)
  117.     {
  118.         if ($node instanceof \DOMNodeList) {
  119.             $this->addNodeList($node);
  120.         } elseif ($node instanceof \DOMNode) {
  121.             $this->addNode($node);
  122.         } elseif (\is_array($node)) {
  123.             $this->addNodes($node);
  124.         } elseif (\is_string($node)) {
  125.             $this->addContent($node);
  126.         } elseif (null !== $node) {
  127.             throw new \InvalidArgumentException(sprintf('Expecting a DOMNodeList or DOMNode instance, an array, a string, or null, but got "%s".'get_debug_type($node)));
  128.         }
  129.     }
  130.     /**
  131.      * Adds HTML/XML content.
  132.      *
  133.      * If the charset is not set via the content type, it is assumed to be UTF-8,
  134.      * or ISO-8859-1 as a fallback, which is the default charset defined by the
  135.      * HTTP 1.1 specification.
  136.      */
  137.     public function addContent(string $contentstring $type null)
  138.     {
  139.         if (empty($type)) {
  140.             $type str_starts_with($content'<?xml') ? 'application/xml' 'text/html';
  141.         }
  142.         // DOM only for HTML/XML content
  143.         if (!preg_match('/(x|ht)ml/i'$type$xmlMatches)) {
  144.             return;
  145.         }
  146.         $charset preg_match('//u'$content) ? 'UTF-8' 'ISO-8859-1';
  147.         // http://www.w3.org/TR/encoding/#encodings
  148.         // http://www.w3.org/TR/REC-xml/#NT-EncName
  149.         $content preg_replace_callback('/(charset *= *["\']?)([a-zA-Z\-0-9_:.]+)/i', function ($m) use (&$charset) {
  150.             if ('charset=' === $this->convertToHtmlEntities('charset='$m[2])) {
  151.                 $charset $m[2];
  152.             }
  153.             return $m[1].$charset;
  154.         }, $content1);
  155.         if ('x' === $xmlMatches[1]) {
  156.             $this->addXmlContent($content$charset);
  157.         } else {
  158.             $this->addHtmlContent($content$charset);
  159.         }
  160.     }
  161.     /**
  162.      * Adds an HTML content to the list of nodes.
  163.      *
  164.      * The libxml errors are disabled when the content is parsed.
  165.      *
  166.      * If you want to get parsing errors, be sure to enable
  167.      * internal errors via libxml_use_internal_errors(true)
  168.      * and then, get the errors via libxml_get_errors(). Be
  169.      * sure to clear errors with libxml_clear_errors() afterward.
  170.      */
  171.     public function addHtmlContent(string $contentstring $charset 'UTF-8')
  172.     {
  173.         $dom $this->parseHtmlString($content$charset);
  174.         $this->addDocument($dom);
  175.         $base $this->filterRelativeXPath('descendant-or-self::base')->extract(['href']);
  176.         $baseHref current($base);
  177.         if (\count($base) && !empty($baseHref)) {
  178.             if ($this->baseHref) {
  179.                 $linkNode $dom->createElement('a');
  180.                 $linkNode->setAttribute('href'$baseHref);
  181.                 $link = new Link($linkNode$this->baseHref);
  182.                 $this->baseHref $link->getUri();
  183.             } else {
  184.                 $this->baseHref $baseHref;
  185.             }
  186.         }
  187.     }
  188.     /**
  189.      * Adds an XML content to the list of nodes.
  190.      *
  191.      * The libxml errors are disabled when the content is parsed.
  192.      *
  193.      * If you want to get parsing errors, be sure to enable
  194.      * internal errors via libxml_use_internal_errors(true)
  195.      * and then, get the errors via libxml_get_errors(). Be
  196.      * sure to clear errors with libxml_clear_errors() afterward.
  197.      *
  198.      * @param int $options Bitwise OR of the libxml option constants
  199.      *                     LIBXML_PARSEHUGE is dangerous, see
  200.      *                     http://symfony.com/blog/security-release-symfony-2-0-17-released
  201.      */
  202.     public function addXmlContent(string $contentstring $charset 'UTF-8'int $options = \LIBXML_NONET)
  203.     {
  204.         // remove the default namespace if it's the only namespace to make XPath expressions simpler
  205.         if (!preg_match('/xmlns:/'$content)) {
  206.             $content str_replace('xmlns''ns'$content);
  207.         }
  208.         $internalErrors libxml_use_internal_errors(true);
  209.         if (\LIBXML_VERSION 20900) {
  210.             $disableEntities libxml_disable_entity_loader(true);
  211.         }
  212.         $dom = new \DOMDocument('1.0'$charset);
  213.         $dom->validateOnParse true;
  214.         if ('' !== trim($content)) {
  215.             @$dom->loadXML($content$options);
  216.         }
  217.         libxml_use_internal_errors($internalErrors);
  218.         if (\LIBXML_VERSION 20900) {
  219.             libxml_disable_entity_loader($disableEntities);
  220.         }
  221.         $this->addDocument($dom);
  222.         $this->isHtml false;
  223.     }
  224.     /**
  225.      * Adds a \DOMDocument to the list of nodes.
  226.      *
  227.      * @param \DOMDocument $dom A \DOMDocument instance
  228.      */
  229.     public function addDocument(\DOMDocument $dom)
  230.     {
  231.         if ($dom->documentElement) {
  232.             $this->addNode($dom->documentElement);
  233.         }
  234.     }
  235.     /**
  236.      * Adds a \DOMNodeList to the list of nodes.
  237.      *
  238.      * @param \DOMNodeList $nodes A \DOMNodeList instance
  239.      */
  240.     public function addNodeList(\DOMNodeList $nodes)
  241.     {
  242.         foreach ($nodes as $node) {
  243.             if ($node instanceof \DOMNode) {
  244.                 $this->addNode($node);
  245.             }
  246.         }
  247.     }
  248.     /**
  249.      * Adds an array of \DOMNode instances to the list of nodes.
  250.      *
  251.      * @param \DOMNode[] $nodes An array of \DOMNode instances
  252.      */
  253.     public function addNodes(array $nodes)
  254.     {
  255.         foreach ($nodes as $node) {
  256.             $this->add($node);
  257.         }
  258.     }
  259.     /**
  260.      * Adds a \DOMNode instance to the list of nodes.
  261.      *
  262.      * @param \DOMNode $node A \DOMNode instance
  263.      */
  264.     public function addNode(\DOMNode $node)
  265.     {
  266.         if ($node instanceof \DOMDocument) {
  267.             $node $node->documentElement;
  268.         }
  269.         if (null !== $this->document && $this->document !== $node->ownerDocument) {
  270.             throw new \InvalidArgumentException('Attaching DOM nodes from multiple documents in the same crawler is forbidden.');
  271.         }
  272.         if (null === $this->document) {
  273.             $this->document $node->ownerDocument;
  274.         }
  275.         // Don't add duplicate nodes in the Crawler
  276.         if (\in_array($node$this->nodestrue)) {
  277.             return;
  278.         }
  279.         $this->nodes[] = $node;
  280.     }
  281.     /**
  282.      * Returns a node given its position in the node list.
  283.      *
  284.      * @return static
  285.      */
  286.     public function eq(int $position)
  287.     {
  288.         if (isset($this->nodes[$position])) {
  289.             return $this->createSubCrawler($this->nodes[$position]);
  290.         }
  291.         return $this->createSubCrawler(null);
  292.     }
  293.     /**
  294.      * Calls an anonymous function on each node of the list.
  295.      *
  296.      * The anonymous function receives the position and the node wrapped
  297.      * in a Crawler instance as arguments.
  298.      *
  299.      * Example:
  300.      *
  301.      *     $crawler->filter('h1')->each(function ($node, $i) {
  302.      *         return $node->text();
  303.      *     });
  304.      *
  305.      * @param \Closure $closure An anonymous function
  306.      *
  307.      * @return array An array of values returned by the anonymous function
  308.      */
  309.     public function each(\Closure $closure)
  310.     {
  311.         $data = [];
  312.         foreach ($this->nodes as $i => $node) {
  313.             $data[] = $closure($this->createSubCrawler($node), $i);
  314.         }
  315.         return $data;
  316.     }
  317.     /**
  318.      * Slices the list of nodes by $offset and $length.
  319.      *
  320.      * @return static
  321.      */
  322.     public function slice(int $offset 0int $length null)
  323.     {
  324.         return $this->createSubCrawler(\array_slice($this->nodes$offset$length));
  325.     }
  326.     /**
  327.      * Reduces the list of nodes by calling an anonymous function.
  328.      *
  329.      * To remove a node from the list, the anonymous function must return false.
  330.      *
  331.      * @param \Closure $closure An anonymous function
  332.      *
  333.      * @return static
  334.      */
  335.     public function reduce(\Closure $closure)
  336.     {
  337.         $nodes = [];
  338.         foreach ($this->nodes as $i => $node) {
  339.             if (false !== $closure($this->createSubCrawler($node), $i)) {
  340.                 $nodes[] = $node;
  341.             }
  342.         }
  343.         return $this->createSubCrawler($nodes);
  344.     }
  345.     /**
  346.      * Returns the first node of the current selection.
  347.      *
  348.      * @return static
  349.      */
  350.     public function first()
  351.     {
  352.         return $this->eq(0);
  353.     }
  354.     /**
  355.      * Returns the last node of the current selection.
  356.      *
  357.      * @return static
  358.      */
  359.     public function last()
  360.     {
  361.         return $this->eq(\count($this->nodes) - 1);
  362.     }
  363.     /**
  364.      * Returns the siblings nodes of the current selection.
  365.      *
  366.      * @return static
  367.      *
  368.      * @throws \InvalidArgumentException When current node is empty
  369.      */
  370.     public function siblings()
  371.     {
  372.         if (!$this->nodes) {
  373.             throw new \InvalidArgumentException('The current node list is empty.');
  374.         }
  375.         return $this->createSubCrawler($this->sibling($this->getNode(0)->parentNode->firstChild));
  376.     }
  377.     public function matches(string $selector): bool
  378.     {
  379.         if (!$this->nodes) {
  380.             return false;
  381.         }
  382.         $converter $this->createCssSelectorConverter();
  383.         $xpath $converter->toXPath($selector'self::');
  384.         return !== $this->filterRelativeXPath($xpath)->count();
  385.     }
  386.     /**
  387.      * Return first parents (heading toward the document root) of the Element that matches the provided selector.
  388.      *
  389.      * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
  390.      *
  391.      * @throws \InvalidArgumentException When current node is empty
  392.      */
  393.     public function closest(string $selector): ?self
  394.     {
  395.         if (!$this->nodes) {
  396.             throw new \InvalidArgumentException('The current node list is empty.');
  397.         }
  398.         $domNode $this->getNode(0);
  399.         while (\XML_ELEMENT_NODE === $domNode->nodeType) {
  400.             $node $this->createSubCrawler($domNode);
  401.             if ($node->matches($selector)) {
  402.                 return $node;
  403.             }
  404.             $domNode $node->getNode(0)->parentNode;
  405.         }
  406.         return null;
  407.     }
  408.     /**
  409.      * Returns the next siblings nodes of the current selection.
  410.      *
  411.      * @return static
  412.      *
  413.      * @throws \InvalidArgumentException When current node is empty
  414.      */
  415.     public function nextAll()
  416.     {
  417.         if (!$this->nodes) {
  418.             throw new \InvalidArgumentException('The current node list is empty.');
  419.         }
  420.         return $this->createSubCrawler($this->sibling($this->getNode(0)));
  421.     }
  422.     /**
  423.      * Returns the previous sibling nodes of the current selection.
  424.      *
  425.      * @return static
  426.      *
  427.      * @throws \InvalidArgumentException
  428.      */
  429.     public function previousAll()
  430.     {
  431.         if (!$this->nodes) {
  432.             throw new \InvalidArgumentException('The current node list is empty.');
  433.         }
  434.         return $this->createSubCrawler($this->sibling($this->getNode(0), 'previousSibling'));
  435.     }
  436.     /**
  437.      * Returns the parent nodes of the current selection.
  438.      *
  439.      * @return static
  440.      *
  441.      * @throws \InvalidArgumentException When current node is empty
  442.      */
  443.     public function parents()
  444.     {
  445.         trigger_deprecation('symfony/dom-crawler''5.3''The %s() method is deprecated, use ancestors() instead.'__METHOD__);
  446.         return $this->ancestors();
  447.     }
  448.     /**
  449.      * Returns the ancestors of the current selection.
  450.      *
  451.      * @return static
  452.      *
  453.      * @throws \InvalidArgumentException When the current node is empty
  454.      */
  455.     public function ancestors()
  456.     {
  457.         if (!$this->nodes) {
  458.             throw new \InvalidArgumentException('The current node list is empty.');
  459.         }
  460.         $node $this->getNode(0);
  461.         $nodes = [];
  462.         while ($node $node->parentNode) {
  463.             if (\XML_ELEMENT_NODE === $node->nodeType) {
  464.                 $nodes[] = $node;
  465.             }
  466.         }
  467.         return $this->createSubCrawler($nodes);
  468.     }
  469.     /**
  470.      * Returns the children nodes of the current selection.
  471.      *
  472.      * @return static
  473.      *
  474.      * @throws \InvalidArgumentException When current node is empty
  475.      * @throws \RuntimeException         If the CssSelector Component is not available and $selector is provided
  476.      */
  477.     public function children(string $selector null)
  478.     {
  479.         if (!$this->nodes) {
  480.             throw new \InvalidArgumentException('The current node list is empty.');
  481.         }
  482.         if (null !== $selector) {
  483.             $converter $this->createCssSelectorConverter();
  484.             $xpath $converter->toXPath($selector'child::');
  485.             return $this->filterRelativeXPath($xpath);
  486.         }
  487.         $node $this->getNode(0)->firstChild;
  488.         return $this->createSubCrawler($node $this->sibling($node) : []);
  489.     }
  490.     /**
  491.      * Returns the attribute value of the first node of the list.
  492.      *
  493.      * @return string|null
  494.      *
  495.      * @throws \InvalidArgumentException When current node is empty
  496.      */
  497.     public function attr(string $attribute)
  498.     {
  499.         if (!$this->nodes) {
  500.             throw new \InvalidArgumentException('The current node list is empty.');
  501.         }
  502.         $node $this->getNode(0);
  503.         return $node->hasAttribute($attribute) ? $node->getAttribute($attribute) : null;
  504.     }
  505.     /**
  506.      * Returns the node name of the first node of the list.
  507.      *
  508.      * @return string
  509.      *
  510.      * @throws \InvalidArgumentException When current node is empty
  511.      */
  512.     public function nodeName()
  513.     {
  514.         if (!$this->nodes) {
  515.             throw new \InvalidArgumentException('The current node list is empty.');
  516.         }
  517.         return $this->getNode(0)->nodeName;
  518.     }
  519.     /**
  520.      * Returns the text of the first node of the list.
  521.      *
  522.      * Pass true as the second argument to normalize whitespaces.
  523.      *
  524.      * @param string|null $default             When not null: the value to return when the current node is empty
  525.      * @param bool        $normalizeWhitespace Whether whitespaces should be trimmed and normalized to single spaces
  526.      *
  527.      * @return string
  528.      *
  529.      * @throws \InvalidArgumentException When current node is empty
  530.      */
  531.     public function text(string $default nullbool $normalizeWhitespace true)
  532.     {
  533.         if (!$this->nodes) {
  534.             if (null !== $default) {
  535.                 return $default;
  536.             }
  537.             throw new \InvalidArgumentException('The current node list is empty.');
  538.         }
  539.         $text $this->getNode(0)->nodeValue;
  540.         if ($normalizeWhitespace) {
  541.             return trim(preg_replace("/(?:[ \n\r\t\x0C]{2,}+|[\n\r\t\x0C])/"' '$text), " \n\r\t\x0C");
  542.         }
  543.         return $text;
  544.     }
  545.     /**
  546.      * Returns only the inner text that is the direct descendent of the current node, excluding any child nodes.
  547.      */
  548.     public function innerText(): string
  549.     {
  550.         return $this->filterXPath('.//text()')->text();
  551.     }
  552.     /**
  553.      * Returns the first node of the list as HTML.
  554.      *
  555.      * @param string|null $default When not null: the value to return when the current node is empty
  556.      *
  557.      * @return string
  558.      *
  559.      * @throws \InvalidArgumentException When current node is empty
  560.      */
  561.     public function html(string $default null)
  562.     {
  563.         if (!$this->nodes) {
  564.             if (null !== $default) {
  565.                 return $default;
  566.             }
  567.             throw new \InvalidArgumentException('The current node list is empty.');
  568.         }
  569.         $node $this->getNode(0);
  570.         $owner $node->ownerDocument;
  571.         if (null !== $this->html5Parser && '<!DOCTYPE html>' === $owner->saveXML($owner->childNodes[0])) {
  572.             $owner $this->html5Parser;
  573.         }
  574.         $html '';
  575.         foreach ($node->childNodes as $child) {
  576.             $html .= $owner->saveHTML($child);
  577.         }
  578.         return $html;
  579.     }
  580.     public function outerHtml(): string
  581.     {
  582.         if (!\count($this)) {
  583.             throw new \InvalidArgumentException('The current node list is empty.');
  584.         }
  585.         $node $this->getNode(0);
  586.         $owner $node->ownerDocument;
  587.         if (null !== $this->html5Parser && '<!DOCTYPE html>' === $owner->saveXML($owner->childNodes[0])) {
  588.             $owner $this->html5Parser;
  589.         }
  590.         return $owner->saveHTML($node);
  591.     }
  592.     /**
  593.      * Evaluates an XPath expression.
  594.      *
  595.      * Since an XPath expression might evaluate to either a simple type or a \DOMNodeList,
  596.      * this method will return either an array of simple types or a new Crawler instance.
  597.      *
  598.      * @return array|Crawler
  599.      */
  600.     public function evaluate(string $xpath)
  601.     {
  602.         if (null === $this->document) {
  603.             throw new \LogicException('Cannot evaluate the expression on an uninitialized crawler.');
  604.         }
  605.         $data = [];
  606.         $domxpath $this->createDOMXPath($this->document$this->findNamespacePrefixes($xpath));
  607.         foreach ($this->nodes as $node) {
  608.             $data[] = $domxpath->evaluate($xpath$node);
  609.         }
  610.         if (isset($data[0]) && $data[0] instanceof \DOMNodeList) {
  611.             return $this->createSubCrawler($data);
  612.         }
  613.         return $data;
  614.     }
  615.     /**
  616.      * Extracts information from the list of nodes.
  617.      *
  618.      * You can extract attributes or/and the node value (_text).
  619.      *
  620.      * Example:
  621.      *
  622.      *     $crawler->filter('h1 a')->extract(['_text', 'href']);
  623.      *
  624.      * @return array
  625.      */
  626.     public function extract(array $attributes)
  627.     {
  628.         $count = \count($attributes);
  629.         $data = [];
  630.         foreach ($this->nodes as $node) {
  631.             $elements = [];
  632.             foreach ($attributes as $attribute) {
  633.                 if ('_text' === $attribute) {
  634.                     $elements[] = $node->nodeValue;
  635.                 } elseif ('_name' === $attribute) {
  636.                     $elements[] = $node->nodeName;
  637.                 } else {
  638.                     $elements[] = $node->getAttribute($attribute);
  639.                 }
  640.             }
  641.             $data[] = === $count $elements[0] : $elements;
  642.         }
  643.         return $data;
  644.     }
  645.     /**
  646.      * Filters the list of nodes with an XPath expression.
  647.      *
  648.      * The XPath expression is evaluated in the context of the crawler, which
  649.      * is considered as a fake parent of the elements inside it.
  650.      * This means that a child selector "div" or "./div" will match only
  651.      * the div elements of the current crawler, not their children.
  652.      *
  653.      * @return static
  654.      */
  655.     public function filterXPath(string $xpath)
  656.     {
  657.         $xpath $this->relativize($xpath);
  658.         // If we dropped all expressions in the XPath while preparing it, there would be no match
  659.         if ('' === $xpath) {
  660.             return $this->createSubCrawler(null);
  661.         }
  662.         return $this->filterRelativeXPath($xpath);
  663.     }
  664.     /**
  665.      * Filters the list of nodes with a CSS selector.
  666.      *
  667.      * This method only works if you have installed the CssSelector Symfony Component.
  668.      *
  669.      * @return static
  670.      *
  671.      * @throws \RuntimeException if the CssSelector Component is not available
  672.      */
  673.     public function filter(string $selector)
  674.     {
  675.         $converter $this->createCssSelectorConverter();
  676.         // The CssSelector already prefixes the selector with descendant-or-self::
  677.         return $this->filterRelativeXPath($converter->toXPath($selector));
  678.     }
  679.     /**
  680.      * Selects links by name or alt value for clickable images.
  681.      *
  682.      * @return static
  683.      */
  684.     public function selectLink(string $value)
  685.     {
  686.         return $this->filterRelativeXPath(
  687.             sprintf('descendant-or-self::a[contains(concat(\' \', normalize-space(string(.)), \' \'), %1$s) or ./img[contains(concat(\' \', normalize-space(string(@alt)), \' \'), %1$s)]]', static::xpathLiteral(' '.$value.' '))
  688.         );
  689.     }
  690.     /**
  691.      * Selects images by alt value.
  692.      *
  693.      * @return static
  694.      */
  695.     public function selectImage(string $value)
  696.     {
  697.         $xpath sprintf('descendant-or-self::img[contains(normalize-space(string(@alt)), %s)]', static::xpathLiteral($value));
  698.         return $this->filterRelativeXPath($xpath);
  699.     }
  700.     /**
  701.      * Selects a button by name or alt value for images.
  702.      *
  703.      * @return static
  704.      */
  705.     public function selectButton(string $value)
  706.     {
  707.         return $this->filterRelativeXPath(
  708.             sprintf('descendant-or-self::input[((contains(%1$s, "submit") or contains(%1$s, "button")) and contains(concat(\' \', normalize-space(string(@value)), \' \'), %2$s)) or (contains(%1$s, "image") and contains(concat(\' \', normalize-space(string(@alt)), \' \'), %2$s)) or @id=%3$s or @name=%3$s] | descendant-or-self::button[contains(concat(\' \', normalize-space(string(.)), \' \'), %2$s) or @id=%3$s or @name=%3$s]''translate(@type, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")', static::xpathLiteral(' '.$value.' '), static::xpathLiteral($value))
  709.         );
  710.     }
  711.     /**
  712.      * Returns a Link object for the first node in the list.
  713.      *
  714.      * @return Link
  715.      *
  716.      * @throws \InvalidArgumentException If the current node list is empty or the selected node is not instance of DOMElement
  717.      */
  718.     public function link(string $method 'get')
  719.     {
  720.         if (!$this->nodes) {
  721.             throw new \InvalidArgumentException('The current node list is empty.');
  722.         }
  723.         $node $this->getNode(0);
  724.         if (!$node instanceof \DOMElement) {
  725.             throw new \InvalidArgumentException(sprintf('The selected node should be instance of DOMElement, got "%s".'get_debug_type($node)));
  726.         }
  727.         return new Link($node$this->baseHref$method);
  728.     }
  729.     /**
  730.      * Returns an array of Link objects for the nodes in the list.
  731.      *
  732.      * @return Link[]
  733.      *
  734.      * @throws \InvalidArgumentException If the current node list contains non-DOMElement instances
  735.      */
  736.     public function links()
  737.     {
  738.         $links = [];
  739.         foreach ($this->nodes as $node) {
  740.             if (!$node instanceof \DOMElement) {
  741.                 throw new \InvalidArgumentException(sprintf('The current node list should contain only DOMElement instances, "%s" found.'get_debug_type($node)));
  742.             }
  743.             $links[] = new Link($node$this->baseHref'get');
  744.         }
  745.         return $links;
  746.     }
  747.     /**
  748.      * Returns an Image object for the first node in the list.
  749.      *
  750.      * @return Image
  751.      *
  752.      * @throws \InvalidArgumentException If the current node list is empty
  753.      */
  754.     public function image()
  755.     {
  756.         if (!\count($this)) {
  757.             throw new \InvalidArgumentException('The current node list is empty.');
  758.         }
  759.         $node $this->getNode(0);
  760.         if (!$node instanceof \DOMElement) {
  761.             throw new \InvalidArgumentException(sprintf('The selected node should be instance of DOMElement, got "%s".'get_debug_type($node)));
  762.         }
  763.         return new Image($node$this->baseHref);
  764.     }
  765.     /**
  766.      * Returns an array of Image objects for the nodes in the list.
  767.      *
  768.      * @return Image[]
  769.      */
  770.     public function images()
  771.     {
  772.         $images = [];
  773.         foreach ($this as $node) {
  774.             if (!$node instanceof \DOMElement) {
  775.                 throw new \InvalidArgumentException(sprintf('The current node list should contain only DOMElement instances, "%s" found.'get_debug_type($node)));
  776.             }
  777.             $images[] = new Image($node$this->baseHref);
  778.         }
  779.         return $images;
  780.     }
  781.     /**
  782.      * Returns a Form object for the first node in the list.
  783.      *
  784.      * @return Form
  785.      *
  786.      * @throws \InvalidArgumentException If the current node list is empty or the selected node is not instance of DOMElement
  787.      */
  788.     public function form(array $values nullstring $method null)
  789.     {
  790.         if (!$this->nodes) {
  791.             throw new \InvalidArgumentException('The current node list is empty.');
  792.         }
  793.         $node $this->getNode(0);
  794.         if (!$node instanceof \DOMElement) {
  795.             throw new \InvalidArgumentException(sprintf('The selected node should be instance of DOMElement, got "%s".'get_debug_type($node)));
  796.         }
  797.         $form = new Form($node$this->uri$method$this->baseHref);
  798.         if (null !== $values) {
  799.             $form->setValues($values);
  800.         }
  801.         return $form;
  802.     }
  803.     /**
  804.      * Overloads a default namespace prefix to be used with XPath and CSS expressions.
  805.      */
  806.     public function setDefaultNamespacePrefix(string $prefix)
  807.     {
  808.         $this->defaultNamespacePrefix $prefix;
  809.     }
  810.     public function registerNamespace(string $prefixstring $namespace)
  811.     {
  812.         $this->namespaces[$prefix] = $namespace;
  813.     }
  814.     /**
  815.      * Converts string for XPath expressions.
  816.      *
  817.      * Escaped characters are: quotes (") and apostrophe (').
  818.      *
  819.      *  Examples:
  820.      *
  821.      *     echo Crawler::xpathLiteral('foo " bar');
  822.      *     //prints 'foo " bar'
  823.      *
  824.      *     echo Crawler::xpathLiteral("foo ' bar");
  825.      *     //prints "foo ' bar"
  826.      *
  827.      *     echo Crawler::xpathLiteral('a\'b"c');
  828.      *     //prints concat('a', "'", 'b"c')
  829.      *
  830.      * @return string
  831.      */
  832.     public static function xpathLiteral(string $s)
  833.     {
  834.         if (!str_contains($s"'")) {
  835.             return sprintf("'%s'"$s);
  836.         }
  837.         if (!str_contains($s'"')) {
  838.             return sprintf('"%s"'$s);
  839.         }
  840.         $string $s;
  841.         $parts = [];
  842.         while (true) {
  843.             if (false !== $pos strpos($string"'")) {
  844.                 $parts[] = sprintf("'%s'"substr($string0$pos));
  845.                 $parts[] = "\"'\"";
  846.                 $string substr($string$pos 1);
  847.             } else {
  848.                 $parts[] = "'$string'";
  849.                 break;
  850.             }
  851.         }
  852.         return sprintf('concat(%s)'implode(', '$parts));
  853.     }
  854.     /**
  855.      * Filters the list of nodes with an XPath expression.
  856.      *
  857.      * The XPath expression should already be processed to apply it in the context of each node.
  858.      *
  859.      * @return static
  860.      */
  861.     private function filterRelativeXPath(string $xpath): object
  862.     {
  863.         $crawler $this->createSubCrawler(null);
  864.         if (null === $this->document) {
  865.             return $crawler;
  866.         }
  867.         $domxpath $this->createDOMXPath($this->document$this->findNamespacePrefixes($xpath));
  868.         foreach ($this->nodes as $node) {
  869.             $crawler->add($domxpath->query($xpath$node));
  870.         }
  871.         return $crawler;
  872.     }
  873.     /**
  874.      * Make the XPath relative to the current context.
  875.      *
  876.      * The returned XPath will match elements matching the XPath inside the current crawler
  877.      * when running in the context of a node of the crawler.
  878.      */
  879.     private function relativize(string $xpath): string
  880.     {
  881.         $expressions = [];
  882.         // An expression which will never match to replace expressions which cannot match in the crawler
  883.         // We cannot drop
  884.         $nonMatchingExpression 'a[name() = "b"]';
  885.         $xpathLen = \strlen($xpath);
  886.         $openedBrackets 0;
  887.         $startPosition strspn($xpath" \t\n\r\0\x0B");
  888.         for ($i $startPosition$i <= $xpathLen; ++$i) {
  889.             $i += strcspn($xpath'"\'[]|'$i);
  890.             if ($i $xpathLen) {
  891.                 switch ($xpath[$i]) {
  892.                     case '"':
  893.                     case "'":
  894.                         if (false === $i strpos($xpath$xpath[$i], $i 1)) {
  895.                             return $xpath// The XPath expression is invalid
  896.                         }
  897.                         continue 2;
  898.                     case '[':
  899.                         ++$openedBrackets;
  900.                         continue 2;
  901.                     case ']':
  902.                         --$openedBrackets;
  903.                         continue 2;
  904.                 }
  905.             }
  906.             if ($openedBrackets) {
  907.                 continue;
  908.             }
  909.             if ($startPosition $xpathLen && '(' === $xpath[$startPosition]) {
  910.                 // If the union is inside some braces, we need to preserve the opening braces and apply
  911.                 // the change only inside it.
  912.                 $j strspn($xpath"( \t\n\r\0\x0B"$startPosition 1);
  913.                 $parenthesis substr($xpath$startPosition$j);
  914.                 $startPosition += $j;
  915.             } else {
  916.                 $parenthesis '';
  917.             }
  918.             $expression rtrim(substr($xpath$startPosition$i $startPosition));
  919.             if (str_starts_with($expression'self::*/')) {
  920.                 $expression './'.substr($expression8);
  921.             }
  922.             // add prefix before absolute element selector
  923.             if ('' === $expression) {
  924.                 $expression $nonMatchingExpression;
  925.             } elseif (str_starts_with($expression'//')) {
  926.                 $expression 'descendant-or-self::'.substr($expression2);
  927.             } elseif (str_starts_with($expression'.//')) {
  928.                 $expression 'descendant-or-self::'.substr($expression3);
  929.             } elseif (str_starts_with($expression'./')) {
  930.                 $expression 'self::'.substr($expression2);
  931.             } elseif (str_starts_with($expression'child::')) {
  932.                 $expression 'self::'.substr($expression7);
  933.             } elseif ('/' === $expression[0] || '.' === $expression[0] || str_starts_with($expression'self::')) {
  934.                 $expression $nonMatchingExpression;
  935.             } elseif (str_starts_with($expression'descendant::')) {
  936.                 $expression 'descendant-or-self::'.substr($expression12);
  937.             } elseif (preg_match('/^(ancestor|ancestor-or-self|attribute|following|following-sibling|namespace|parent|preceding|preceding-sibling)::/'$expression)) {
  938.                 // the fake root has no parent, preceding or following nodes and also no attributes (even no namespace attributes)
  939.                 $expression $nonMatchingExpression;
  940.             } elseif (!str_starts_with($expression'descendant-or-self::')) {
  941.                 $expression 'self::'.$expression;
  942.             }
  943.             $expressions[] = $parenthesis.$expression;
  944.             if ($i === $xpathLen) {
  945.                 return implode(' | '$expressions);
  946.             }
  947.             $i += strspn($xpath" \t\n\r\0\x0B"$i 1);
  948.             $startPosition $i 1;
  949.         }
  950.         return $xpath// The XPath expression is invalid
  951.     }
  952.     /**
  953.      * @return \DOMNode|null
  954.      */
  955.     public function getNode(int $position)
  956.     {
  957.         return $this->nodes[$position] ?? null;
  958.     }
  959.     /**
  960.      * @return int
  961.      */
  962.     #[\ReturnTypeWillChange]
  963.     public function count()
  964.     {
  965.         return \count($this->nodes);
  966.     }
  967.     /**
  968.      * @return \ArrayIterator<int, \DOMNode>
  969.      */
  970.     #[\ReturnTypeWillChange]
  971.     public function getIterator()
  972.     {
  973.         return new \ArrayIterator($this->nodes);
  974.     }
  975.     /**
  976.      * @return array
  977.      */
  978.     protected function sibling(\DOMNode $nodestring $siblingDir 'nextSibling')
  979.     {
  980.         $nodes = [];
  981.         $currentNode $this->getNode(0);
  982.         do {
  983.             if ($node !== $currentNode && \XML_ELEMENT_NODE === $node->nodeType) {
  984.                 $nodes[] = $node;
  985.             }
  986.         } while ($node $node->$siblingDir);
  987.         return $nodes;
  988.     }
  989.     private function parseHtml5(string $htmlContentstring $charset 'UTF-8'): \DOMDocument
  990.     {
  991.         return $this->html5Parser->parse($this->convertToHtmlEntities($htmlContent$charset));
  992.     }
  993.     private function parseXhtml(string $htmlContentstring $charset 'UTF-8'): \DOMDocument
  994.     {
  995.         $htmlContent $this->convertToHtmlEntities($htmlContent$charset);
  996.         $internalErrors libxml_use_internal_errors(true);
  997.         if (\LIBXML_VERSION 20900) {
  998.             $disableEntities libxml_disable_entity_loader(true);
  999.         }
  1000.         $dom = new \DOMDocument('1.0'$charset);
  1001.         $dom->validateOnParse true;
  1002.         if ('' !== trim($htmlContent)) {
  1003.             @$dom->loadHTML($htmlContent);
  1004.         }
  1005.         libxml_use_internal_errors($internalErrors);
  1006.         if (\LIBXML_VERSION 20900) {
  1007.             libxml_disable_entity_loader($disableEntities);
  1008.         }
  1009.         return $dom;
  1010.     }
  1011.     /**
  1012.      * Converts charset to HTML-entities to ensure valid parsing.
  1013.      */
  1014.     private function convertToHtmlEntities(string $htmlContentstring $charset 'UTF-8'): string
  1015.     {
  1016.         set_error_handler(function () { throw new \Exception(); });
  1017.         try {
  1018.             return mb_encode_numericentity($htmlContent, [0x800x10FFFF00x1FFFFF], $charset);
  1019.         } catch (\Exception|\ValueError $e) {
  1020.             try {
  1021.                 $htmlContent iconv($charset'UTF-8'$htmlContent);
  1022.                 $htmlContent mb_encode_numericentity($htmlContent, [0x800x10FFFF00x1FFFFF], 'UTF-8');
  1023.             } catch (\Exception|\ValueError $e) {
  1024.             }
  1025.             return $htmlContent;
  1026.         } finally {
  1027.             restore_error_handler();
  1028.         }
  1029.     }
  1030.     /**
  1031.      * @throws \InvalidArgumentException
  1032.      */
  1033.     private function createDOMXPath(\DOMDocument $document, array $prefixes = []): \DOMXPath
  1034.     {
  1035.         $domxpath = new \DOMXPath($document);
  1036.         foreach ($prefixes as $prefix) {
  1037.             $namespace $this->discoverNamespace($domxpath$prefix);
  1038.             if (null !== $namespace) {
  1039.                 $domxpath->registerNamespace($prefix$namespace);
  1040.             }
  1041.         }
  1042.         return $domxpath;
  1043.     }
  1044.     /**
  1045.      * @throws \InvalidArgumentException
  1046.      */
  1047.     private function discoverNamespace(\DOMXPath $domxpathstring $prefix): ?string
  1048.     {
  1049.         if (\array_key_exists($prefix$this->namespaces)) {
  1050.             return $this->namespaces[$prefix];
  1051.         }
  1052.         if ($this->cachedNamespaces->offsetExists($prefix)) {
  1053.             return $this->cachedNamespaces[$prefix];
  1054.         }
  1055.         // ask for one namespace, otherwise we'd get a collection with an item for each node
  1056.         $namespaces $domxpath->query(sprintf('(//namespace::*[name()="%s"])[last()]'$this->defaultNamespacePrefix === $prefix '' $prefix));
  1057.         return $this->cachedNamespaces[$prefix] = ($node $namespaces->item(0)) ? $node->nodeValue null;
  1058.     }
  1059.     private function findNamespacePrefixes(string $xpath): array
  1060.     {
  1061.         if (preg_match_all('/(?P<prefix>[a-z_][a-z_0-9\-\.]*+):[^"\/:]/i'$xpath$matches)) {
  1062.             return array_unique($matches['prefix']);
  1063.         }
  1064.         return [];
  1065.     }
  1066.     /**
  1067.      * Creates a crawler for some subnodes.
  1068.      *
  1069.      * @param \DOMNodeList|\DOMNode|\DOMNode[]|string|null $nodes
  1070.      *
  1071.      * @return static
  1072.      */
  1073.     private function createSubCrawler($nodes): object
  1074.     {
  1075.         $crawler = new static($nodes$this->uri$this->baseHref);
  1076.         $crawler->isHtml $this->isHtml;
  1077.         $crawler->document $this->document;
  1078.         $crawler->namespaces $this->namespaces;
  1079.         $crawler->cachedNamespaces $this->cachedNamespaces;
  1080.         $crawler->html5Parser $this->html5Parser;
  1081.         return $crawler;
  1082.     }
  1083.     /**
  1084.      * @throws \LogicException If the CssSelector Component is not available
  1085.      */
  1086.     private function createCssSelectorConverter(): CssSelectorConverter
  1087.     {
  1088.         if (!class_exists(CssSelectorConverter::class)) {
  1089.             throw new \LogicException('To filter with a CSS selector, install the CssSelector component ("composer require symfony/css-selector"). Or use filterXpath instead.');
  1090.         }
  1091.         return new CssSelectorConverter($this->isHtml);
  1092.     }
  1093.     /**
  1094.      * Parse string into DOMDocument object using HTML5 parser if the content is HTML5 and the library is available.
  1095.      * Use libxml parser otherwise.
  1096.      */
  1097.     private function parseHtmlString(string $contentstring $charset): \DOMDocument
  1098.     {
  1099.         if ($this->canParseHtml5String($content)) {
  1100.             return $this->parseHtml5($content$charset);
  1101.         }
  1102.         return $this->parseXhtml($content$charset);
  1103.     }
  1104.     private function canParseHtml5String(string $content): bool
  1105.     {
  1106.         if (null === $this->html5Parser) {
  1107.             return false;
  1108.         }
  1109.         if (false === ($pos stripos($content'<!doctype html>'))) {
  1110.             return false;
  1111.         }
  1112.         $header substr($content0$pos);
  1113.         return '' === $header || $this->isValidHtml5Heading($header);
  1114.     }
  1115.     private function isValidHtml5Heading(string $heading): bool
  1116.     {
  1117.         return === preg_match('/^\x{FEFF}?\s*(<!--[^>]*?-->\s*)*$/u'$heading);
  1118.     }
  1119. }