PluginProbe
BeyondWords – AI audio for publishers / 4.2.4
BeyondWords – AI audio for publishers v4.2.4
7.2.0 7.1.0 trunk 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.1.0 4.1.1 4.1.2 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.3.0 4.4.0 4.5.0 4.5.1 4.6.0 4.6.1 4.6.2 All 44 releases
speechkit / vendor / symfony / dom-crawler / Crawler.php

Crawler.php in BeyondWords – AI audio for publishers 4.2.4, at vendor/symfony/dom-crawler/Crawler.php

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