There are two kinds of SEO problem. One needs a writer, a week and an argument about what the page is for. The other is a missing meta description.
This finds the second kind. All of it, every page, in about ninety seconds.
What it checks
Seven things, all of them unambiguous, all of them fixable in minutes once you know:
- Title missing, or over about 60 characters
- Meta description missing, over about 160 characters, or duplicated on another page
- No
<h1>, or more than one - Images with no
altattribute - Internal links that return anything other than a 200
- Pages with fewer than 300 words of body text
- Canonical tag missing, or pointing somewhere unexpected
None of that is judgement. Every one is either true or it is not, which is exactly what makes it worth handing to a machine.
The build, eight nodes
1. Schedule Trigger. Weekly. Sunday night is as good a time as any.
2. HTTP Request, called Sitemap. Fetch https://yoursite.co.uk/sitemap.xml.
If yours is a sitemap index pointing at other sitemaps, which most CMS platforms produce, you need one extra fetch. An IF node checking whether the XML contains <sitemapindex handles it, with the true branch fetching each child sitemap first.
3. XML node. Converts the response into something you can work with. n8n has this built in and it saves you writing a parser.
4. Split Out. Field urlset.url. One item per page.
5. HTTP Request, called Fetch Page. Get each URL. Response format: string, so you get the raw HTML rather than n8n trying to interpret it.
Turn on Continue on Fail, and set batching to about five requests with a short interval between batches. You are crawling your own server and it will cope, but there is no reason to hit it forty times a second, and if the site is on shared hosting there is a real reason not to.
6. Code node, called Checks. The whole audit, in one node. Regex against the HTML rather than a DOM parser, because for these seven checks it is quicker and there is nothing to install:
const html = $json.data || '';
const url = $json.loc;
const problems = [];
const title = (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '';
if (!title.trim()) problems.push('no title');
else if (title.length > 60) problems.push(`title ${title.length} chars`);
const desc = (html.match(
/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i
) || [])[1] || '';
if (!desc.trim()) problems.push('no meta description');
else if (desc.length > 160) problems.push(`description ${desc.length} chars`);
const h1s = html.match(/<h1[\s>]/gi) || [];
if (h1s.length === 0) problems.push('no h1');
if (h1s.length > 1) problems.push(`${h1s.length} h1 tags`);
const imgs = html.match(/<img\b[^>]*>/gi) || [];
const noAlt = imgs.filter((t) => !/\salt\s*=/i.test(t));
if (noAlt.length) problems.push(`${noAlt.length} images without alt`);
const words = html
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.split(/\s+/)
.filter(Boolean).length;
if (words < 300) problems.push(`thin: ${words} words`);
if (!/rel=["']canonical["']/i.test(html)) problems.push('no canonical');
return [{ json: { url, title, desc, words, problems, count: problems.length } }];One warning about that meta description regex. It only matches when the attributes appear in that order and are double or single quoted. Most templates output them consistently so it works, but a minifier that reorders attributes or strips quotes will make it find nothing, and a check that finds nothing looks exactly like a site with no problems. Test it against a page you know is broken before you believe a clean result.
We learned that one the hard way on this site, twice, on this exact check.
7. Filter. Keep only items where count is greater than zero. A sheet listing every page including the fine ones is a sheet nobody opens.
8. Google Sheets. Clear the sheet, then append. Clearing rather than appending matters here: this is a current state, not a log, and a fixed page should stop appearing rather than sitting there forever next to its own fix.
Reading the output
Sort by count descending and start at the top. A page with five problems is usually a page built from a broken template, and fixing the template fixes forty pages at once.
Then work through by type rather than by page. Doing every missing alt attribute in one sitting is faster than doing one page completely, because you stay in the same file and the same frame of mind.
The thin flag is the one to treat with suspicion. Under 300 words is a signal, not a verdict. A contact page is meant to be short, and so is a category page whose job is to point elsewhere. Look at the URL before you assume there is a problem.
Add the internal link check second
It is the most useful check on the list and it doubles the build time, which is why it is not in the seven nodes above.
Pull every href starting with / or your own domain out of each page, deduplicate the lot, fetch each one with method HEAD, and record anything that does not come back 200. A HEAD request gets you the status without the body, so it is fast enough to run across a few hundred links.
What it finds is nearly always the same thing: a page renamed six months ago with four links still pointing at the old address. Nobody notices, because nobody clicks their own site’s links. Google does.
What it will not tell you
Whether the page deserves to rank.
Every check here is about whether the page is put together properly. That is table stakes rather than an advantage, and a technically perfect page about nothing anybody searches for will rank for nothing.
The useful way to think about it: this workflow removes reasons not to rank. Finding reasons to is a different job, and the content gap build is closer to it.
Published 28 August 2026. Written by the people who run this sort of thing for clients, on n8n, including our own lead pipeline.
Next in getting found online: One post, published out to everywhere. Or go back to all 4 in this category.