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:
- the model reads the page and proposes a title and a description, as JSON;
- your code checks the lengths and the keyword;
- 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("&", "&").replaceAll('"', """).replaceAll("<", "<");
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
- Capital letters. The system message says “exactly as given”, and Qwen took it literally: two of its ten first titles start with “bouldering course for Beginners”. The check ignores capitals, so it passed. Read the result before you publish it.
- Facts. The checks count characters and look for a keyword; they do not check what the text says. In one run with the longer keyword, Qwen promised “expert coaching”: the page does not say that. In Product pages from a catalog the code checks every number of the text against the data.
Good to know
- 60 and 155 are rules of thumb. Search engines cut by width in pixels, not by characters: “WWW” is wider than “iii”. And Google often writes its own snippet from the page, whatever your description says.
- One more try, not a loop. If the second answer is also wrong, a person writes the tags. A loop of retries costs credits and can go on for a long time.
- Escape what you write into HTML. One description had “shoes, chalk & gym entry”: in HTML,
&should become&, and inside an attribute"must become". That is whatescape()does. [...text].lengthcounts characters.text.lengthcounts UTF-16 units: most emoji count as 2.
Exercise
- Add “Capital letters in the keyword do not matter.” to the system message. Do Qwen's titles change?
- Add a check: every number in the description (95, 8, 4…) must be on the page. Look at how Alt text for your images does it with a regular expression.
- Write the page in Italian, with the keyword «corso di boulder», and run the program again. Italian words are longer: does the first answer still fit as often?
- Make the program read every
.htmlfile in a folder, and print a table: file, title, description, first answer fit yes or no.
