Skip to main content
Metaist

Screenshot Thumbnails

How I used Firefox & ImageMagick to take screenshots for posts without a thumbnail.

Previously: Trying pagefind, Adventures in Eleventy

One of the difficulties I described in Adventures with Eleventy is having to find a catchy thumbnail for each post. Part of the problem is that I wanted it to look a certain way and another part is that I wanted images that were appropriately licensed. That's why I've largely not had thumbnails for any posts since the most recent reboot in May 2023.

However, I wanted to start pushing my posts out to twitter (and now bluesky) and the lack of a thumbnail was bugging me. A simple solution would be to just automatically take a screenshot of the post and use that as the thumbnail.

At first, I thought I'd use Simon Willison's shot-scraper, but I didn't want to install puppeteer and a whole separate browser just to take screenshots.

But because I recently switched back to Firefox after many years, I decided to try out it's screenshot capabilities. Turns out they're pretty great! The only caveat is that you can't have Firefox open and take a screenshot at the same time-- unless you use a different profile. So I went to about:profiles and made profile called screenshot. Now I can take screenshots from the command-line:

# Usage: firefox -P <profile> --screenshot <output> <url>
firefox -P screenshot --screenshot metaist.png "https://metaist.com/"

According to ChatGPT, the ideal OpenGraph thumbnail should be 1200x630. Using ImageMagick, I decided to also crop the top banner to focus on the main text.

# Usage: convert <input> -gravity north -crop 1200x630+0+100 +repage <output>
convert metaist.png -gravity north -crop 1200x630+0+100 +repage metaist.png

I put it all together using bun and docopt.

#!/usr/bin/env bun

import { $ } from "bun";
import { docopt } from "docopt";
import { resolve } from "bun:path";
import { unlinkSync } from "node:fs";

const doc = `\
Take cropped screenshots of URLs using Firefox.

Usage: ./screenshots.js
  [--help] [--version] [--debug]
  [--force] [--crop=<size>]
  [<slug>...]

Options:
  -h, --help        show this message and exit
  --version         show program version and exit
  --debug           show debug messages

  -f, --force       delete existing screenshot
  --crop=<size>     crop dimensions [default: 1200x630+0+100]
  <slug>            blog slug to screenshot
`;

/** Convert a slug to a local URL. */
const slug2url = (slug) =>
  `http://localhost:8080/blog/${slug.slice(0, 4)}/${slug.slice(
    5,
    7
  )}/${slug.slice(11)}.html`;

/** Return an absolute path to an image. */
const slug2img = (slug) => resolve(`./content/static/img/${slug}.png`);

/** Take a screenshot of a URL and save it to a path. */
const screenshot = async (url, path) =>
  $`firefox -P screenshot --screenshot ${path} ${url}`;

/** Crop an image. */
const crop = async (path, size = "1200x630+0+100") =>
  $`convert ${path} -gravity north -crop ${size} +repage ${path}`;

/** Get list of slugs from a file.  */
const getSlugs = async (path) =>
  (await Bun.file(path).text()).split("\n").reduce((result, line) => {
    line = line.trim();
    if (line && !line.startsWith("#")) result.push(line);
    return result;
  }, []);

/**
 * Return a valid javascript variable name from a docopt flag.
 * @param {string} name variable name
 */
function optvar(name) {
  let result = name.toLowerCase();
  const special = { "-": "stdin", "--": "__" };
  if (special[result]) return special[result];
  // special cases handled

  result = result.replace(/--/, "");
  if (result[0] === "-") result = result.slice(1);
  // leading hyphens removed

  result = result.replaceAll("-", "_").replaceAll("<", "").replaceAll(">", "");
  // hyphens become underscore; angle brackets removed

  return result;
}

/** Parse docopt vars into a javascript-friendly format. */
function parse_docopt(args) {
  const result = {};
  for (const [key, val] of Object.entries(args)) result[optvar(key)] = val;
  return result;
}

/** Main entry point. */
async function main() {
  const args = parse_docopt(docopt(doc, { version: "0.1.0" }));
  if (!args.slug.length) args.slug = await getSlugs("./screenshots.txt");
  console.log(`Found: ${args.slug.length}`);

  if (!args.crop) args.crop = "1200x630+0+100";
  if (args.crop.toLowerCase() === "none") args.crop = "";
  if (args.debug) console.log(args);

  for (var i = 0; i < args.slug.length; i++) {
    const slug = args.slug[i];
    const url = slug2url(slug);
    const img = slug2img(slug);

    if (await Bun.file(img).exists()) {
      console.log(`[found] ${img}`);
      if (!args.force) continue;

      console.log(`[delete] ${img}`);
      unlinkSync(img);
    }

    console.log(`[shot] ${url}`);
    await screenshot(url, img);

    if (args.crop) {
      let exists = false;
      while (!exists) {
        if (args.debug) console.log(".");
        await Bun.sleep(500);
        exists = await Bun.file(img).exists();
      }

      console.log(`[crop] ${img}`);
      await crop(img, args.crop);
    }
  }
}

main();