← Back to list

Use htmlparser2 to Extract Social Preview Cards

Recently, I set out to build a system that can make preview cards for websites. These are the things that pop out when you paste a URL into…

Ryan Balsdon · 2024-03-26 00:03 · 1 claps · 6.5 min read
#npm #html-parser #javascript #web-development #optimization
Open on Medium ↗
Wiki topics: ML · Machine Learning GEN · Genomics & Sequencing 🌐 · Web Development 🔒 · Cybersecurity

Use htmlparser2 to Extract Social Preview Cards

Recently, I set out to build a system that can make preview cards for websites. These are the things that pop out when you paste a URL into Facebook, Twitter, LinkedIn, Pinterest, Discord, etc. They’re a great way to get the gist of what a page is about without having to load up the whole thing.

Preview cards have a title, image, and description

Preview cards have a title, image, and description

This article will give a quick rundown of what those preview cards are, how they’re defined in the website, and, finally, how to use htmlparser2 to pull them out of a fetch request. We’re going to assume here that you’ve already got that fetch part figured out and focus entirely on how to finagle htmlparser2 into cooperating.

What are web preview cards?

When a URL is posted into a social app, they’re able to pull together some information about the page like a title, an image and a description. They’ll use this to build a richer link to it than the boring old vinculum. Here’s an example of one below, taken from Twitter’s developer documentation.

Example preview card from the Twitter Developer Documentation

Example preview card from the Twitter Developer Documentation

There are two competing standards for how a website can make this information available: Twitter Cards and Facebook’s Open Graph. As we’ll see shortly, these two protocols are not compatible and websites are generally expected to support both. They both work through meta tags in the website’s head but use different names for the same properties.

Starting with Twitter Cards, the entry point is a meta tag called twitter:card that defines the style of card. We’ll focus in on the summary cards here but note that there is support for audio/video players, app installers, and a larger summary. The only other required tag in this mode is twitter:title but we’ll also try to find the optional twitter:description and twitter:image tags while parsing the HTML document.

Next up is Open Graph, where the entry point is a meta tag called og:type that defines the style of card. The equivalent type for Twitter-style summary cards is probably website. Regardless of the type used though, Open Graph has three more required tags: og:title, og:image and og:url. To match the twitter summary card above, there is also an optional og:description tag.

To pull this all together, let’s look at an example of these meta tags in use. Below is a snippet of the HTML for MDN’s documentation of the meta tag. There are two very important details to note here that might not have been clear from the above summary of the docs: Open Graph key/values are property and content attributes where Twitter Cards use name and content attributes, and, Twitter Cards will use the Open Graph properties when the Twitter variant isn’t present.

<!doctype html>
<html lang="en-US" prefix="og: https://ogp.me/ns#">
    <head>
        <meta property="og:url" content="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta"/>
        <meta property="og:title" content="&lt;meta&gt;: The metadata element - HTML: HyperText Markup Language | MDN"/>
        <meta property="og:type" content="website"/>
        <meta property="og:locale" content="en_US"/>
        <meta property="og:description" content="The &lt;meta&gt; HTML element represents metadata that cannot be represented by other HTML meta-related elements, like &lt;base&gt;, &lt;link&gt;, &lt;script&gt;, &lt;style&gt; or &lt;title&gt;."/>
        <meta property="og:image" content="https://developer.mozilla.org/mdn-social-share.cd6c4a5a.png"/>
        <meta property="og:image:type" content="image/png"/>
        <meta property="og:image:height" content="1080"/>
        <meta property="og:image:width" content="1920"/>
        <meta property="og:image:alt" content="The MDN Web Docs logo, featuring a blue accent color, displayed on a solid black background."/>
        <meta property="og:site_name" content="MDN Web Docs"/>
        <meta name="twitter:card" content="summary_large_image"/>
        <meta name="twitter:creator" content="MozDevNet"/>
    </head>
</html>

How htmlparser2 works

The htmlparer2 library is a very popular package on NPM. It can be a bit of a process to use because it doesn’t provide a fully-formed object tree, instead sending notifications when it finds a specific element. For our use-case, where the metadata tags we need are both unique and fully contain their data, we can leverage this behaviour for some large performance boosts. Before trying to implement that though, let’s learn a bit about what this library is doing under the hood.

Htmlparser2 comes with two main components: a Tokenizer and a Parser. The Tokenizer is responsible for looping through the entire document, character-by-character, and find the tags, attributes, declarations, comments, etc. The Parser takes the output of the Tokenizer and applies some logic to it to handle odds quirks in HTML, like that line breaks don’t come with open tags.

The Parser was especially fun to read through because it really highlights how weird HTML can be sometimes. Here’s some highlights to start you off on your journey:

Getting htmlparser2 to cooperate

The README for htmlparser2 shows us that we need to register a bunch of callbacks and then test the objects returned to see if it’s the one we want. It, unfortunately, doesn’t explain much about each individual callback for us. The project’s documentation doesn’t give us much more about the use of each one, other than the name.

So, to get a better feel for how the thing works, we will code up a callback for every single option. Then run our example HTML from above that contains the meta tags we’re looking for. The test rig looks like the code below.

import * as htmlparser2 from "htmlparser2";
const parser = new htmlparser2.Parser({
    onparserinit(parser) {console.log('parser', parser)},
    onreset() {console.log('onreset')},
    onend() {console.log('onend')},
    onerror(error) {console.log('onerror', error)},
    onclosetag(name, isImplied) {console.log('onclosetag', name, isImplied)},
    onopentagname(name) {console.log('onopentagname', name)},
    onattribute(name, value, quote) {console.log('onattribute', name, value, quote)},
    onopentag(name, attribs, isImplied) {console.log('onopentag', name, attribs, isImplied)},
    ontext(data) {console.log('ontext', data)},
    oncomment(data) {console.log('oncomment', data)},
    oncdatastart() {console.log('oncdatastart')},
    oncdataend() {console.log('oncdataend')},
    oncommentend() {console.log('oncommentend')},
    onprocessinginstruction(name, data) {console.log('onprocessinginstruction', name, data)},
});
parser.write(testHtml);
parser.end();

A few interesting things come out of this test and a small snippet of the output is below. The one that stood out most to me is that the quote parameter of a few of these callbacks is literally a quote character. This is by design though and probably used to reconstruct the original document by telling us whether double-quote or single-quote was used. Also interesting is that we get ontext callbacks with newline and tab characters between each tag, also reflecting the actual document.

onopentagname meta
onattribute property og:title "
onattribute content <meta>: The metadata element - HTML: HyperText Markup Language | MDN "
onopentag meta {
  property: 'og:title',
  content: '<meta>: The metadata element - HTML: HyperText Markup Language | MDN'
} false
onclosetag meta true
ontext

Jumping ahead a bit, the callback we’re looking for is onopentag with name of meta and the attribs set for the data we’re looking for. As a quick reminder, Open Graph keys off an attribute called property and Twitter Card keys off an attribute called name. Here is a parser implementation that does just that.

let title = "";
let description = "";
let image = "";
const parser = new htmlparser2.Parser({
    onopentag(name, attribs, isImplied) {
        if (name == "meta" && attribs["content"]) {
            if (attribs["property"] == "og:title") title ||= attribs["content"];
            if (attribs["property"] == "og:description") description ||= attribs["content"];
            if (attribs["property"] == "og:image") image ||= attribs["content"];
            if (attribs["name"] == "twitter:title") title ||= attribs["content"];
            if (attribs["name"] == "twitter:description") description ||= attribs["content"];
            if (attribs["name"] == "twitter:image") image ||= attribs["content"];

        }
    },
});

This code above will prefer the first value if multiple are present. For example, if both twitter:title and og:title are present, it will use whichever comes first in the document. This is what the or-equals operator (||=) is doing in the assignments.

Squeezing out a touch more performance

As a quick benchmark, we’ve grabbed a full copy of the MDN page for the meta element and run that parser above against it 1000 times. This gives us a starting benchmark of 1.6s which would be around 1.6ms per page. It take 35ms on that same machine to download the page itself (of which, 26ms is ping time). Even though our performance metrics are well within tolerances, adding 1.6ms of pure CPU time to each request does limit scalability a lot.

Knowing a bit about the data itself will help us limit that CPU bottleneck a bit though. The meta tags we need for our social preview cards are all in the head element, which is always the first element in the document. Our parser is walking through the entire document though so we need to find a way to abort or early-return from the parser.

This early-return can be done forcefully by throwing and catching an error but the library does have a built-in feature to do something similar: the parser’s reset method. The way the reset methods works means it probably won’t crash if called from within a callback. The implementation below returns early in two cases: if we’ve found all three points of data we need, or, if we find the end of the head element without them.

function extractSocials(htmlFile) {
    let title = "";
    let description = "";
    let image = "";

    const parser = new htmlparser2.Parser({
        onopentag(name, attribs, isImplied) {
            if (name == "meta" && attribs["content"]) {
                if (attribs["property"] == "og:title") title ||= attribs["content"];
                if (attribs["property"] == "og:description") description ||= attribs["content"];
                if (attribs["property"] == "og:image") image ||= attribs["content"];
                if (attribs["name"] == "twitter:title") title ||= attribs["content"];
                if (attribs["name"] == "twitter:description") description ||= attribs["content"];
                if (attribs["name"] == "twitter:image") image ||= attribs["content"];
                if (title && description && image) parser.reset();
            }
        },
        onclosetag(name, isImplied) {
            if (name == "head") parser.reset();
        },
    });
    parser.write(html);
    return { title, description, image };
}

This small change drops our benchmark time from 1.6s (approx. 1.6ms per file) down to 42ms (approx. 42us per file) which is even more within tolerances than it was before.


메타데이터
post_id
0bb312dcc44b
slug
extracting-social-preview-cards-from-websites-0bb312dcc44b
url
https://medium.com/@ryan_50436/extracting-social-preview-cards-from-websites-0bb312dcc44b
canonical_url
https://medium.com/@ryan_50436/extracting-social-preview-cards-from-websites-0bb312dcc44b
author_url
https://medium.com/@ryan_50436
status
ok
fetched_at
2026-08-12 22:58:33