Docs
Dashboard
Examples / Data outBeta

SEO and metadata audit

Check each page's title, meta description, headings, Open Graph tags (title, description, image), Twitter/X card, canonical link, noindex, page language and image alt texts, list what to fix, and have a model suggest a description for pages that have none.

When to use it: You want to check your pages' titles, descriptions, headings and image alt texts before a release.

Usescrawl with format: "html", a small tag parser, extract for the suggestions
Needsa model on the API (only for the suggested descriptions)
Siteyour site; the default is books.toscrape.com. The runner uses its stand-in: one page with six problems and one clean page
Outputoutput/report.json: each page's tags and fixes
LanguagesNode and Python

The code

The example as it is in its folder. Both versions do the same job and write the same output/, so one result check covers both.

/** * SEO and metadata audit: titles, descriptions, headings, Open Graph and canonical tags, page by page, with a * suggested description (written by a model) for pages that have none. * *   npx tsx node/index.ts            (BOXLINE_API_KEY; SITE_URL for your own site, MAX_PAGES) * * Writes output/report.json: each page's tags and what to fix. */import { mkdirSync, writeFileSync } from "node:fs";import { join } from "node:path";import { Boxline } from "@boxline/sdk";const out = process.env.OUTPUT_DIR ?? "output";const start = process.env.SITE_URL ?? "https://books.toscrape.com/";const maxPages = Number(process.env.MAX_PAGES ?? 10);const bx = new Boxline();// Every tag with its attributes, e.g. <meta property="og:title" content="…">.function tags(html: string, name: string): Record<string, string>[] {  return [...html.matchAll(new RegExp(`<${name}\\b([^>]*)>`, "gi"))].map((m) =>    Object.fromEntries([...m[1]!.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g)].map((a) => [a[1]!.toLowerCase(), a[2] ?? a[3] ?? a[4] ?? ""])),  );}const metaContent = (html: string, key: string) => tags(html, "meta").find((t) => t.name === key || t.property === key)?.content?.trim() ?? "";function audit(url: string, html: string) {  const title = (/<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? "").replace(/\s+/g, " ").trim();  const page = {    url,    title,    description: metaContent(html, "description"),    h1: (html.match(/<h1\b/gi) ?? []).length,    ogTitle: metaContent(html, "og:title"),    ogDescription: metaContent(html, "og:description"),    // How the page looks when shared (Open Graph image, Twitter/X card), whether it may be listed, and its language.    ogImage: metaContent(html, "og:image"),    twitterCard: metaContent(html, "twitter:card"),    robots: metaContent(html, "robots"),    lang: tags(html, "html")[0]?.lang ?? "",    canonical: tags(html, "link").find((t) => (t.rel ?? "").split(/\s+/).includes("canonical"))?.href ?? "",    imagesWithoutAlt: tags(html, "img").filter((t) => !("alt" in t)).length,  };  const fixes: string[] = [];  if (!page.title) fixes.push("title missing");  else if (page.title.length > 60) fixes.push(`title over 60 characters (${page.title.length})`);  if (!page.description) fixes.push("no meta description");  else if (page.description.length > 160) fixes.push(`meta description over 160 characters (${page.description.length})`);  if (page.h1 !== 1) fixes.push(`${page.h1} H1 headings (want 1)`);  if (!page.ogTitle) fixes.push("no og:title");  if (!page.ogDescription) fixes.push("no og:description");  if (!page.ogImage) fixes.push("no og:image (shared links show no picture)");  if (!page.twitterCard) fixes.push("no twitter:card");  if (/noindex/i.test(page.robots)) fixes.push("noindex: the page asks search engines not to list it");  if (!page.lang) fixes.push("no lang attribute on <html>");  if (!page.canonical) fixes.push("no canonical link");  if (page.imagesWithoutAlt) fixes.push(`${page.imagesWithoutAlt} images without alt text`);  return { ...page, fixes, suggestedDescription: null as string | null };}// 1. Crawl the site's pages as HTML (same host, the start page and the pages it links to).const job = await bx.crawl.start({ url: start, maxPages, maxDepth: 1, format: "html" });const crawl = await bx.crawl.wait(job.id, { pollMs: 1500, timeoutMs: 10 * 60_000 });const report = crawl.data.filter((p) => p.content && (p.status ?? 0) < 400).map((p) => audit(p.finalUrl ?? p.url, p.content!));// 2. A model suggests a description for up to 3 pages that have none.let modelUsd = 0;for (const page of report.filter((p) => !p.description).slice(0, 3)) {  const r = await bx.extract<{ description: string }>({    url: page.url,    prompt: "Write a meta description for this page: one plain, specific sentence of at most 155 characters.",    schema: { type: "object", properties: { description: { type: "string" } }, required: ["description"] },  });  page.suggestedDescription = r.data.description;  modelUsd += r.usage.costUsd;}for (const p of report) {  console.log(`${p.url}\n  ${p.fixes.length ? p.fixes.join("; ") : "nothing to fix"}${p.suggestedDescription ? `\n  suggested description: ${p.suggestedDescription}` : ""}`);}mkdirSync(out, { recursive: true });writeFileSync(join(out, "report.json"), JSON.stringify(report, null, 2));writeFileSync(join(out, "result.json"), JSON.stringify({ start, pages: report.length, report, usage: { modelUsd } }, null, 2));

Inputs

Environment variables the example reads:

  • SITE_URL: the start page (default https://books.toscrape.com/).
  • MAX_PAGES: how many pages at most (default 10).

Run it

Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/seo-audit. Put your API key in BOXLINE_API_KEY (and BOXLINE_API_URL for another API), then run either version from the example’s folder. Both write to output/ and stop their sessions when they finish.

# Node 18 or newer, in the example's folderexport BOXLINE_API_KEY=…   # your API keynpm install @boxline/sdk tsxnpx tsx node/index.ts

Then check the result, from the same folder: npx tsx check.ts output. It exits with 0 only when the result is right.

What its check proves

check.ts reads what the example wrote and passes only when the result is right, not merely when the example ran.

On the stand-in, all nine problems of /seo are found (long title, no description, 2 H1, no og:description, no og:image, no twitter:card, noindex, no canonical, an image without alt), /seo/about is clean, and the suggested description is a short sentence about the page.

Source

github.com/Boxline-dev/examples/seo-audit on GitHub, with its README.md, check.ts and .env.example. The examples are open source. See all examples.

These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.