Fetch it with PulsarX, tear it apart with Quasar. A real CSS-selector engine and XPath, over the broken HTML the web actually ships.
CSS selectors · XPath · broken-HTML tolerant · fluent traversal · zero dependencies
⋆ ˚ 。 ⋆ ୨ ⋆ ˚ 。 ⋆
Why Quasar · Install · Quick start · Selectors · Traversal · Extraction · With PulsarX · API
| Feature | What it does | |
|---|---|---|
| ◍ | CSS engine | A genuine CSS-selector → XPath compiler — combinators, attribute operators and pseudo-classes, not just getElementById. |
| ⌗ | XPath too | Drop to raw XPath on the same document whenever CSS runs out of road. |
| ❍ | Broken-HTML tolerant | Built on libxml recovery mode — unclosed tags, missing <html>, real-world soup all parse. |
| ⟡ | Fluent API | find(), first(), text(), attr(), parent(), children(), next() — everything chains. |
| ✸ | Vectorised | Pull texts() / attr() from a whole match set at once, or each() / map() / filter() over it. |
| ◇ | UTF-8 clean | No mojibake, no deprecated HTML-ENTITIES hack — code points survive round-trips. |
| ⬡ | Zero deps | One class per file, a tiny autoloader, and optional Composer. Just PHP + ext-dom. |
PHP's HTML-scraping options are a tired bunch: the built-in DOMDocument is fast but
XPath-only and verbose, Simple HTML DOM is abandoned and leaks memory, and
Symfony DomCrawler drags a component tree behind it. Quasar is the middle path — a
clean, modern, zero-dependency scraper with a real CSS engine on top of libxml's speed.
| Quasar | DomCrawler | Simple HTML DOM | Raw DOMDocument | |
|---|---|---|---|---|
| CSS selectors | ✓ compiler | ✓ (needs css-selector) | partial | ✗ |
| XPath | ✓ | ✓ | ✗ | ✓ |
Combinators > + ~ |
✓ | ✓ | ✗ | manual |
:nth-child / :not / :contains |
✓ | partial | ✗ | manual |
| Broken-HTML recovery | ✓ | ✓ | ✓ | manual |
| Runtime dependencies | none | several | none | none |
| Actively maintained | ✓ | ✓ | ✗ | n/a |
With Composer
composer require vxsilisk/quasarWithout Composer — require the bundled autoloader:
require __DIR__ . '/autoload.php';Requirements: PHP ≥ 8.1 with the
dom,libxmlandmbstringextensions (all standard).
require __DIR__ . '/autoload.php';
$doc = Quasar::html($html);
$doc->title(); // "Nebula — Latest Posts"
$doc->first('h1')->text(); // heading text, whitespace-collapsed
$doc->find('article a[href]') // a QuasarList of matches
->attr('href'); // -> ['/a', '/b', ...]
foreach ($doc->find('article h2 a') as $link) {
echo $link->text(), ' -> ', $link->attr('href'), "\n";
}find() returns a QuasarList (countable, iterable, indexable). first() returns a
single QuasarNode or null — so ?-> is your friend.
Quasar ships a real selector compiler, not a lookup table. All of this works:
$doc->find('div.card > a[href^="https"]'); // child + attribute prefix
$doc->find('nav a.link ~ a'); // general sibling
$doc->find('ul.tags li:not(.hot)'); // negation
$doc->find('table tr:nth-child(odd) td'); // structural pseudo
$doc->find('h2 + p.lead'); // adjacent sibling
$doc->find('article, aside, footer'); // grouping◇ Everything the engine understands
| Group | Selectors |
|---|---|
| Simple | * · type · #id · .class · compound (div.a#b) |
| Attribute | [attr] [attr=v] [attr^=v] [attr$=v] [attr*=v] [attr~=v] [attr|=v] |
| Combinators | descendant (space) · child > · adjacent + · general sibling ~ |
| Pseudo | :first-child :last-child :only-child :empty :root :nth-child(n | odd | even | an+b) :not(...) |
| jQuery-style | :contains("text") |
| Grouping | a, b, c |
Need something the compiler doesn't cover? Every node speaks XPath directly:
$doc->xpath('//article[@data-id]//a[contains(@href, "ex.com")]');
$node->xpath('.//span[last()]'); // scoped to the nodeSelection is scoped — find() on a node only searches its descendants:
$post = $doc->first('article.post');
$post->first('h2 a')->text(); // within this article only
$post->find('.tags li')->count();
$node->parent(); // ?QuasarNode
$node->children(); // QuasarList (elements only)
$node->next(); // next element sibling, or null
$node->prev(); // previous element sibling, or null
$node->tag(); // 'a', 'div', ...Pull data from one node — or a whole match set — without a loop:
$node->text(); // "Lead paragraph." (whitespace-collapsed)
$node->text(trim: false); // raw textContent
$node->html(); // outer HTML
$node->innerHtml(); // inner HTML
$node->attr('href'); // attribute, or null
$node->attr('rel', 'noopener'); // with a fallback
$node->attrs(); // ['href' => '/a', 'class' => 'link', ...]
$node->classes(); // ['link', 'active']
$node->hasClass('active'); // true
$doc->find('ul.tags li')->texts(); // ['alpha', 'beta', 'gamma']
$doc->find('a')->attr('href'); // every href, in document order
$doc->find('a')->map(fn($n) => $n->text()); // any projection
$doc->find('article')->filter(fn($n) => $n->hasClass('draft'));
$doc->find('tr')->each(fn($row, $i) => print("{$i}: {$row->text()}\n"));Page-level shortcuts:
$doc->title(); // <title>, trimmed
$doc->body(); // <body> node
$doc->links(); // every href on the page
$doc->images(); // every img src
$doc->text(); // whole-document textQuasar is the second half of the PulsarX story — one fetches like a browser, the other reads like jQuery:
require __DIR__ . '/Pulsar/autoload.php';
require __DIR__ . '/Quasar/autoload.php';
$r = (new Pulsar())->impersonate('chrome')->get('https://news.ycombinator.com');
$doc = Quasar::html($r->getBody());
foreach ($doc->find('.titleline > a') as $story) {
echo $story->text(), "\n";
}Document — Quasar
| Method | Returns |
|---|---|
Quasar::html(string $html) |
Quasar — parse an HTML string |
find(string $css) |
QuasarList |
first(string $css) |
?QuasarNode |
xpath(string $expr) |
QuasarList |
title() / text() |
?string / string |
body() |
?QuasarNode |
links() / images() |
string[] |
outerHtml() |
string |
document() |
DOMDocument |
Element — QuasarNode
| Method | Returns |
|---|---|
find($css) / first($css) / xpath($expr) |
QuasarList / ?QuasarNode / QuasarList |
text(bool $trim = true) |
string |
html() / innerHtml() |
string |
attr($name, $default = null) / hasAttr($name) |
?string / bool |
attrs() / classes() / hasClass($c) |
array / array / bool |
tag() |
string |
parent() / next() / prev() |
?QuasarNode |
children() |
QuasarList |
raw() |
DOMNode |
Collection — QuasarList (implements Countable, IteratorAggregate, ArrayAccess)
| Method | Returns |
|---|---|
get($i) / first() / last() |
?QuasarNode |
all() / isEmpty() / count() |
array / bool / int |
text() / texts() |
string / string[] |
attr($name) |
string[] |
each($fn) / map($fn) / filter($fn) |
self / array / self |
Quasar/
├── autoload.php # zero-dependency autoloader
├── composer.json # classmap autoload of src/
├── example.php # runnable demo
├── tests/run.php # framework-free test suite
└── src/
├── Quasar.php # entry point + document
├── QuasarNode.php # a single element (traversal + extraction)
├── QuasarList.php # a match collection (vectorised helpers)
├── CssToXPath.php # the CSS-selector → XPath compiler
└── QuasarException.php
Run
php example.phpfor a live demo, orphp tests/run.phpfor the test suite.
⋆ ˚ 。 ⋆ ୨ ⋆ ˚ 。 ⋆
Quasar — made by Vxsilisk · MIT License
Part of the Nebula toolkit: PulsarX (fetch) · Quasar (parse)