Skip to content
Fondazione ITS INCOM

Examples · Web Development

SEO tags that fit

The HTML of a page goes in, and a title and a meta description come out as JSON. Your code checks their length and the main keyword, and when a check fails it asks once more, with the reason.

Beginner · 25 min

The idea

In their results, search engines show the <title> of your page, and often its meta description. A good title fits in about 60 characters, a good description in about 155, and both contain the words people search for: the main keyword of the page.

A model writes both in seconds. It is not good at counting characters, and it does not always do what you ask. So:

  1. the model reads the page and proposes a title and a description, as JSON;
  2. your code checks the lengths and the keyword;
  3. if a check fails, the program sends the model its own answer and the reason, once.

What you need

Node.js 22 or newer, a key and the openai package, as in Your first API call in JavaScript.

The page

The course page of a climbing gym in Busto Arsizio (the gym is invented). Its title is just “Courses”, and it has no description. Save it as course.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Courses</title>
</head>
<body>
  <nav><a href="/">Home</a> <a href="/courses">Courses</a> <a href="/prices">Prices</a> <a href="/contact">Contact</a></nav>
  <main>
    <h1>Bouldering for beginners</h1>
    <p>Four evenings to learn bouldering from scratch, at Palestra Verticale in Busto Arsizio.
       No experience needed: shoes and chalk are included.</p>
    <h2>What you learn</h2>
    <ul>
      <li>How to fall safely on the mats</li>
      <li>Footwork, grips and reading a route</li>
      <li>Warming up and avoiding injuries</li>
    </ul>
    <h2>When and how much</h2>
    <p>Tuesdays from 19:00 to 20:30, starting on 3 November 2026. Groups of 8 people at most.
       The course costs 95 euros, entry to the gym included.</p>
    <p><a href="/contact">Book your place</a> or call us on 0331 000 000.</p>
  </main>
  <footer>Palestra Verticale · Via dell'Arrampicata 12, Busto Arsizio · Open every day from 10:00 to 22:00</footer>
</body>
</html>

The program

Save it as seo.mjs, next to the page:

import { readFileSync } from "node:fs";
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.ai.itsincom.org/v1",
  apiKey: process.env.API_KEY,
});
const [file, keyword, model = "qwen3.8-27b"] = process.argv.slice(2);
const html = readFileSync(file, "utf8");

const schema = {
  type: "object",
  properties: {
    title: { type: "string" },
    description: { type: "string" },
  },
  required: ["title", "description"],
};

const messages = [
  {
    role: "system",
    content: "You write the <title> and the meta description of web pages, in the language of the page. " +
      "The title: at most 60 characters. The description: at most 155 characters, one or two sentences " +
      `that make people want to click. Both must contain the main keyword exactly as given: «${keyword}».`,
  },
  { role: "user", content: html },
];

async function ask() {
  const answer = await client.chat.completions.create({
    model,
    messages,
    response_format: { type: "json_schema", json_schema: { name: "seo_tags", schema } },
  });
  // No max_tokens: qwen3.8-27b thinks before it answers, and the thinking counts there too.
  if (answer.choices[0].finish_reason !== "stop") throw new Error("The answer was cut off.");
  const content = answer.choices[0].message.content.trim();
  messages.push({ role: "assistant", content }); // the conversation remembers the first try
  return JSON.parse(content);
}

// The rules are in code: a model cannot count characters reliably, a program can.
function problems({ title, description }) {
  const found = [];
  const length = (text) => [...text].length; // characters, so «à» counts as one
  if (length(title) > 60) found.push(`the title is ${length(title)} characters, the limit is 60`);
  if (length(description) > 155) found.push(`the description is ${length(description)} characters, the limit is 155`);
  for (const [name, text] of [["title", title], ["description", description]]) {
    if (!text.toLowerCase().includes(keyword.toLowerCase())) found.push(`the ${name} does not contain «${keyword}»`);
  }
  return found;
}

let tags = await ask();
let found = problems(tags);
console.log(`First answer (${model}):`, tags);
if (found.length > 0) {
  console.log("  NO:", found.join("; "));
  // One more try, with the reason: the model sees its own answer and what was wrong with it.
  messages.push({ role: "user", content: `This does not fit: ${found.join("; ")}. Write both again.` });
  tags = await ask();
  found = problems(tags);
  console.log("Second answer:", tags);
}
if (found.length > 0) {
  console.log("  NO:", found.join("; "), "\nStill wrong: write these two by hand.");
  process.exit(1);
}

const escape = (text) => text.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
console.log(`\n<title>${escape(tags.title)}</title>`);
console.log(`<meta name="description" content="${escape(tags.description)}">`);

Run it with the page and its keyword:

node seo.mjs course.html "bouldering course"
node seo.mjs course.html "bouldering course" gemma-4-26b

qwen3.8-27b takes 10 to 30 seconds, gemma-4-26b about one.

What you should see

Usually the first answer fits. This is a run with qwen3.8-27b where it did not:

First answer (qwen3.8-27b): {
  title: 'Bouldering Course for Beginners | Palestra Verticale',
  description: 'Join our 4-week bouldering course for beginners at Palestra Verticale, Busto Arsizio. No experience needed – shoes and chalk included. Book your spot today!'
}
  NO: the description is 156 characters, the limit is 155
Second answer: {
  title: 'bouldering course for beginners – Palestra Verticale',
  description: 'Join our 4-week bouldering course for beginners in Busto Arsizio. No experience needed – shoes and chalk included. Book your spot today!'
}

<title>bouldering course for beginners – Palestra Verticale</title>
<meta name="description" content="Join our 4-week bouldering course for beginners in Busto Arsizio. No experience needed – shoes and chalk included. Book your spot today!">

One character too many, and the second answer fixed it. Copy the two lines into the <head> of the page.

How often the first answer fit

We ran the program ten times with each model, keyword “bouldering course”:

first answer fit after one more try time for one answer
gemma-4-26b 9 of 10 10 of 10 about 1 s
qwen3.8-27b 9 of 10 10 of 10 10 to 27 s

Both failures were a description of 156 characters: one too many. The first descriptions were between 142 and 156 characters for Gemma and between 128 and 156 for Qwen. The models aim at the limit, and now and then they miss it by one. With the longer keyword “bouldering course for beginners”, the first answer fit in 3 runs out of 3 for each model.

The keyword was always there: in all 20 runs, the problem was the length.

Gemma is just as good here and much faster. qwen3.8-27b thinks before it answers: in our runs it used 900 to 2,600 completion tokens for two lines of text, against 63 for Gemma. The thinking is not shown, but it counts in completion_tokens and in max_tokens.

Why the limit is in code, not in the schema

JSON schema has maxLength, and the school's machines respect it: they stop the string when it reaches the limit. They do not make the model write a shorter sentence. We tried maxLength: 30 for the title and 80 for the description, twice with Gemma:

title: 'Beginner Bouldering Course | B',
description: 'Join our beginner bouldering course in Busto Arsizio! Learn safe falling, foot-f'

The other run ended the description with “foot” and two Korean characters. A title cut after “| B” passes every check of length. Keep the schema for the shape, and check the length in code, where a failure can be sent back as a reason.

What the checks do not see

Good to know

Exercise

All examples