perl.gg / modern-perl

Mojo::DOM CSS Selectors for HTML Parsing

2026-03-31

This page walks through how to parse HTML in Perl using Mojo::DOM and CSS selectors. You will learn how to build a DOM object, run queries, extract data, and chain results without writing fragile regular expressions.
use Mojo::DOM; my $dom = Mojo::DOM->new($html); $dom->find('a[href]')->each(sub { say $_->{href}; });
That snippet grabs every link from an HTML document by using a CSS selector. You already know this syntax from jQuery, from your browser's DevTools, and from every stylesheet you have written.

Mojo::DOM comes bundled with the Mojolicious web framework. You do not need extra dependencies, you do not need to build a parse tree by hand, and you do not need to learn XPath. You just use CSS selectors and chain methods.

Part 1: CREATING A DOM OBJECT

You feed it raw HTML and you get back an object you can query:
use Mojo::DOM; my $dom = Mojo::DOM->new('<div><p>Hello</p><p>World</p></div>'); say $dom->at('p')->text; # Hello say $dom->find('p')->size; # 2
From a file:
use Mojo::File qw(path); use Mojo::DOM; my $html = path('page.html')->slurp; my $dom = Mojo::DOM->new($html);
From a web request (if you are using the full Mojolicious stack):
use Mojo::UserAgent; my $ua = Mojo::UserAgent->new; my $dom = $ua->get('https://example.com')->result->dom;
That final line fetches a page and hands you a DOM object in a single chain. It keeps the code short and readable.

Part 2: CSS SELECTOR BASICS

If you have written CSS, you already know how this works:
SELECTOR MATCHES -------- ------- div All <div> elements .classname Elements with class="classname" #myid Element with id="myid" a[href] <a> tags that have an href attribute input[type="text"] <input> with type="text" div > p <p> directly inside <div> div p <p> anywhere inside <div> ul > li:first-child First <li> in a <ul> h2 + p <p> immediately after <h2>
Mojo::DOM supports nearly the entire CSS3 selector specification. If a selector works in your browser's document.querySelectorAll() method, it will almost certainly work here.
my $dom = Mojo::DOM->new($html); # by tag $dom->find('p'); # by class $dom->find('.warning'); # by id $dom->at('#main-content'); # by attribute $dom->find('img[alt]'); # by attribute value $dom->find('a[href="https://perl.org"]'); # descendant $dom->find('div.content p'); # direct child $dom->find('ul > li');

Part 3: FIND VS AT

There are two main query methods, and you should learn the difference between them.

find() returns all matches as a Mojo::Collection:

my $links = $dom->find('a'); say $links->size; # number of matches
at() returns the first match as a single element, or undef if nothing matches:
my $title = $dom->at('title'); say $title->text; # page title
Use find when you expect multiple results. Use at when you only need one specific element.
# all paragraphs $dom->find('p')->each(sub { say $_->text }); # the one h1 on the page my $heading = $dom->at('h1'); say $heading->text if $heading;
Always check for undef when you use at. If no element matches your query, the method returns nothing, and calling ->text on undef will crash your script.

Part 4: EXTRACTING TEXT AND ATTRIBUTES

You can grab the text content of any element like this:
my $dom = Mojo::DOM->new('<p>Hello <b>World</b></p>'); say $dom->at('p')->text; # Hello say $dom->at('p')->all_text; # Hello World say $dom->at('b')->text; # World
The text method returns the direct text of that element, skipping nested tags. The all_text method returns everything, recursively.

You can read attributes with hash-style access:

my $dom = Mojo::DOM->new('<a href="https://perl.org" class="ext">Perl</a>'); my $link = $dom->at('a'); say $link->{href}; # https://perl.org say $link->{class}; # ext say $link->attr('href'); # same thing, method style
Both $element->{attr} and $element->attr('attr') work. The hash dereference is shorter and feels more Perlish.

Part 5: CHAINING WITH MAP AND GREP

Since find() returns a Mojo::Collection, you get chainable functional operations for free:
# extract all URLs from a page my @urls = $dom->find('a[href]') ->map(attr => 'href') ->each; # extract all image sources my @images = $dom->find('img[src]') ->map(attr => 'src') ->grep(sub { m~\.jpg$~i }) ->each;
The ->map(attr => 'href') shorthand calls ->attr('href') on each element. It is a Mojo::Collection trick for calling methods on every item in the collection.

You can filter and transform in a single pipeline:

# find all external links with their text $dom->find('a[href^="http"]')->each(sub { printf "%-30s %s\n", $_->text, $_->{href}; });
The [href^="http"] selector means "href attribute starts with http." CSS attribute selectors are quite powerful. The ^= operator checks for a prefix, $= checks for a suffix, and *= checks for a substring.

Part 6: REAL WEB SCRAPING

Let us scrape something real and extract a table into a Perl data structure:
use Mojo::DOM; use Mojo::UserAgent; my $ua = Mojo::UserAgent->new; my $dom = $ua->get('https://example.com/data')->result->dom; my @rows; $dom->find('table.data-table tr')->each(sub { my @cells = $_->find('td')->map('text')->each; push @rows, \@cells if @cells; }); for my $row (@rows) { say join "\t", @$row; }
Each <tr> is found, and inside each row we find all <td> elements, extract their text, and collect it into an array. The job is done.

You can also extract a definition list:

my %glossary; $dom->find('dl dt')->each(sub { my $term = $_->text; my $def = $_->next->text; # next sibling (the <dd>) $glossary{$term} = $def; });
The ->next method returns the next sibling element. You also have ->previous, ->parent, ->children, and ->ancestors available for DOM traversal.

Part 7: HANDLING MALFORMED HTML

Real-world HTML is often messy. Missing closing tags, nested tables from the early two-thousands, and inline styles everywhere are common. Mojo::DOM handles all of that gracefully.
my $mess = '<p>Unclosed paragraph<p>Another one<div>Mixed up</p></div>'; my $dom = Mojo::DOM->new($mess); $dom->find('p')->each(sub { say $_->all_text; });
It does not crash. It does not die. It makes a best effort to parse the mess and lets you query it anyway. Is the parse tree exactly what the author intended? The author probably did not know either, but you can still extract data from it.

This is a major advantage over strict XML parsers that refuse to touch anything that is not well-formed.

Part 8: COMPARISON TO HTML::TREEBUILDER

The old-school way to parse HTML in Perl was HTML::TreeBuilder from the HTML::Tree distribution:
# HTML::TreeBuilder (the old way) use HTML::TreeBuilder; my $tree = HTML::TreeBuilder->new_from_content($html); my @links = $tree->look_down('_tag', 'a', sub { $_[0]->attr('href') }); for my $link (@links) { say $link->attr('href'); } $tree->delete; # manual cleanup! # Mojo::DOM (the new way) use Mojo::DOM; Mojo::DOM->new($html)->find('a[href]')->each(sub { say $_->{href}; });
Mojo::DOM wins on every front:
FEATURE HTML::TreeBuilder Mojo::DOM ------- ----------------- --------- Selector syntax look_down() CSS selectors Cleanup required Yes ($tree->delete) No (automatic) Chaining No Yes Collection methods No map, grep, each Dependencies HTML::Tree dist Mojolicious Learning curve Moderate Low (if you know CSS)
HTML::TreeBuilder is not a bad module. It served the community well for decades. Mojo::DOM is what you should reach for in 2026.

Part 9: ADVANCED SELECTORS

Mojo::DOM supports several powerful selectors that most developers never use:
# nth-child $dom->find('tr:nth-child(odd)'); # odd rows $dom->find('tr:nth-child(2n)'); # even rows $dom->find('li:nth-child(3n+1)'); # every third, starting at 1 # not $dom->find('p:not(.hidden)'); # paragraphs without .hidden # has (contains matching descendant) $dom->find('div:has(> img)'); # divs with direct img child # empty $dom->find('td:empty'); # empty table cells # multiple selectors (OR) $dom->find('h1, h2, h3'); # all top-level headings
You can combine them for surgical precision:
# external links in the main content area, excluding nav $dom->find('#content a[href^="http"]:not(.internal)')->each(sub { say "$_->{href}: " . $_->text; });
That single selector does what would otherwise take ten lines of manual tree walking.

Part 10: PRACTICAL DATA EXTRACTION

You can pull structured data from a page and convert it to a Perl data structure:
use Mojo::DOM; use Mojo::JSON qw(encode_json); my $dom = Mojo::DOM->new($html); my @products = $dom->find('div.product')->map(sub { { name => $_->at('h3')->text, price => $_->at('.price')->text, link => $_->at('a.details')->{href}, image => $_->at('img')->{src}, } })->each; say encode_json(\@products);
Each div.product is found, and inside each one we extract the name, price, link, and image using at() to grab specific child elements. The map block returns a hashref for each product, and the result is a clean Perl data structure ready for JSON export or database insertion.

Want it as CSV instead?

$dom->find('div.product')->each(sub { my $name = $_->at('h3')->text; my $price = $_->at('.price')->text; $price =~ s~[^\d.]~~g; # strip $ and commas say "$name,$price"; });
You can go from HTML to CSV in six lines. There is no regex-based HTML parsing and no fragile string matching. You just use CSS selectors to point at exactly what you want.
$html | Mojo::DOM | find('selector') | +---+---+---+ | | | | [1] [2] [3] [4] Mojo::Collection | map / grep / each | data jQuery for Perl. No browser required. .--. |o_o | |:_/ | // \ \ (| | ) /'\_ _/`\ \___)=(___/
perl.gg