Making-of: My SEO and GEO Tool
How 25 years of SEO practice turned into a fast, free SEO/GEO analysis tool — with real code examples of how the score and checks actually compute.
by Jean Pierre Kolb ·
I always wanted an SEO tool that gets to the essentials fast and without detours — no subscription trap, no login, no twelve dashboards to click through just to answer a simple question: is this page technically clean, and is it understood by classic search engines and by AI systems? So I built it. This article is the making-of: the motivations, the architecture, and above all the real calculation logic behind it — with code you can lift verbatim. The finished tool runs for free at jpkc.com/tools/seo/.
Updated in October 2026: the tool has grown since June, and a check against the current code showed that a few parts of this article were out of date. The SSRF check now shows the hardened version with IPv6 resolution, a port allowlist and a pinned IP against DNS rebinding — I would no longer recommend lifting the first version of that snippet. The AI crawler list reflects today's 16 bots, the readability formula names all six language variants, and "What's deliberately missing" and the FAQ now account for the local history and the bulk checks in Expert Mode.
Why build my own tool at all
After a good 25 years on the web I know all the SEO suites. They can do a lot, cost money every month, and hide the two or three numbers that actually matter behind mountains of features I never touch 90 % of. For a quick look at a single URL — "is the foundation solid?" — that's like renting an excavator to dig a hole for a tomato plant.
What I was missing was a tool with three properties:
- Instant and account-free. URL in, analysis out. No registration, no tracking, no "14 days free, then €99/month".
- Honestly weighted. Not 200 micro-signals all equally loud, but the things up front that I judge — from experience — to matter for ranking and citation.
- SEO and GEO. Classic search engine optimization and Generative Engine Optimization in one pass — because in 2026 both decide visibility. What GEO even is, I covered in depth in What is GEO?.
The decisive advantage of "build it yourself": I get to expose how the scoring works. With the big tools the score is a black box. Here every point is traceable — and that's exactly what I'll show you now.
The architecture: proxy plus browser
The first fundamental decision was technical. An SEO analysis needs to see a page's raw HTTP traffic: status code, redirect chain, response headers, SSL certificate, timing. You can't do that from the browser — the same-origin policy and CORS forbid loading a foreign page including its headers. So it needed a server-side proxy in PHP that fetches the target page and hands the result back to the front end.
A proxy that fetches arbitrary URLs is, however, a classic SSRF risk (server-side request forgery): someone could request http://127.0.0.1/ or internal cloud metadata endpoints and reach things that were never meant to be public. That's why the proxy validates every URL — including every single redirect hop — before it touches it, and returns the validated target address along with the verdict:
// Web ports only — the proxy must not become a port scanner for foreign hosts
const PROXY_ALLOWED_PORTS = [ 80, 443, 3000, 5000, 8000, 8008, 8080, 8443 ];
function resolveProxyTarget( string $url, ?string &$error = null ): ?array {
if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
$error = 'Invalid URL format';
return null;
}
// http/https only — no file://, gopher://, dict:// etc.
if ( ! str_starts_with( $url, 'http://' ) && ! str_starts_with( $url, 'https://' ) ) {
$error = 'Only http/https URLs are allowed';
return null;
}
$parsed = parse_url( $url );
if ( empty( $parsed['host'] ) ) {
$error = 'Missing host in URL';
return null;
}
$port = $parsed['port'] ?? ( $parsed['scheme'] === 'https' ? 443 : 80 );
if ( ! in_array( $port, PROXY_ALLOWED_PORTS, true ) ) {
$error = 'Port ' . $port . ' is not allowed';
return null;
}
$bareHost = trim( $parsed['host'], '[]' ); // IPv6 arrives in [brackets]
// IP literal: check it directly, there is nothing to resolve
if ( filter_var( $bareHost, FILTER_VALIDATE_IP ) !== false ) {
if ( ! isPublicIpAddr( $bareHost ) ) {
$error = 'IP address is not publicly routable';
return null;
}
return [ 'host' => $bareHost, 'port' => $port, 'ip' => $bareHost, 'literal' => true ];
}
// Hostname: resolve IPv4 (A) AND IPv6 (AAAA)
$ips = @gethostbynamel( $bareHost ) ?: [];
foreach ( @dns_get_record( $bareHost, DNS_AAAA ) ?: [] as $rec ) {
if ( isset( $rec['ipv6'] ) ) {
$ips[] = $rec['ipv6'];
}
}
$ips = array_values( array_unique( $ips ) );
if ( ! $ips ) {
$error = 'Could not resolve host';
return null;
}
// EVERY address must be public — a single private answer is enough to block
foreach ( $ips as $ip ) {
if ( ! isPublicIpAddr( $ip ) ) { // blocks 127.0.0.0/8, 10/8, 192.168/16, ::1 …
$error = 'Host resolves to a private/reserved address';
return null;
}
}
return [ 'host' => $bareHost, 'port' => $port, 'ip' => $ips[0], 'literal' => false ];
}The important part isn't the happy path. Hostname resolution happens before the request, for IPv4 and IPv6 — check only the A record and you let through a host whose AAAA record points at ::1. All resolved IPs are checked, otherwise a DNS record that additionally points at 10.0.0.5 would slip past the block. isPublicIpAddr() uses PHP's FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE filter and also blocks the ranges those flags miss, such as CGNAT (100.64.0.0/10), NAT64 and 6to4.
That alone isn't enough, though, and the first version of this article left it out: between the check and the connection, cURL would otherwise resolve the name a second time on its own. An attacker with their own DNS server answers the check with a public IP and a second later with 127.0.0.1 — classic DNS rebinding. So cURL is handed the validated address explicitly:
$target = resolveProxyTarget( $url, $error );
if ( $target === null ) {
// abort and report $error
}
$ch = curl_init( $url );
curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, false ); // check redirects yourself, see below
if ( ! $target['literal'] ) {
// libcurl expects IPv6 addresses in [brackets]
$ip = str_contains( $target['ip'], ':' ) ? '[' . $target['ip'] . ']' : $target['ip'];
curl_setopt( $ch, CURLOPT_RESOLVE, [ $target['host'] . ':' . $target['port'] . ':' . $ip ] );
}With CURLOPT_RESOLVE, cURL connects to exactly the address that was just checked. The proxy follows the redirect chain itself, hop by hop, and every hop goes through the same check and pin again — because a harmless public URL must not point to something internal via a 302 redirect.
Everything that comes after — parsing the HTML, counting headings, reading schema — then happens client-side in the browser again, via the native DOMParser. That keeps the server lean: it only fetches bytes, the actual analysis engine is JavaScript. This split — a minimal, hardened PHP proxy plus a thick JS analyzer — is the backbone of the whole tool.
The core principle: a check is just four values
Before a single SEO value gets computed, I needed a shape that every check fits into — from the HTTPS tick to readability. I settled on four values, and that's perhaps the most important design decision in the whole tool, because it keeps everything else simple:
// A check always produces: points earned, a status, a hint.
function scoreCheck( name, maxPoints, earned, status, hint ) {
return { name, maxPoints, earned, status, hint: hint || "" };
}
// status ∈ { "pass", "partial", "fail" }status is deliberately three-valued, not binary. The half step partial is the reason the score feels "fair": a page with a 65-character title hasn't "failed", it's just not optimal — so it earns partial points, not zero. That's exactly what most pass/fail checkers get wrong.
With this building block every single check becomes a small, isolated function I can understand and test independently. Let me show you four of them in detail.
SEO score: four examples from practice
The SEO score consists of up to 36 such checks across seven categories (Technical, On-Page, Crawling, Social, Performance, Accessibility, Content). Here are four that illustrate the principle well.
Title length: partial credit instead of black and white
The page title is one of the strongest on-page signals. Too short wastes context, too long gets truncated in search. I score it in three tiers — optimal, acceptable, off:
function checkTitleLength( title ) {
const len = title ? title.length : 0;
const optimal = len >= 50 && len <= 60; // ideal display length
const acceptable = len >= 30 && len <= 70; // usable, but not ideal
return scoreCheck(
"Title (50–60 chars)",
8,
optimal ? 8 : acceptable ? 4 : 0,
optimal ? "pass" : acceptable ? "partial" : "fail",
title ? `${len} characters` : "No <title> tag found"
);
}| Title length | Status | Points (of 8) |
|---|---|---|
| 50–60 chars | pass | 8 |
| 30–70 chars | partial | 4 |
| anything else / missing | fail | 0 |
Eight points for the title, because it genuinely contributes a lot to click-through. The weight is an experience call, not a universal truth — but those are exactly the calls I wanted to make myself instead of leaving them to someone else's algorithm.
TTFB: tiered scoring instead of one threshold
Time to first byte shows the same idea even more clearly. A single cutoff would be brutal — 199 ms full, 201 ms nothing. Instead there are two tiers:
function checkTtfb( ttfbMs ) {
if ( ttfbMs === null ) {
return scoreCheck( "TTFB < 500 ms", 4, 0, "fail", "No timing data" );
}
const earned = ttfbMs < 200 ? 4 : ttfbMs < 500 ? 2 : 0;
const status = ttfbMs < 200 ? "pass" : ttfbMs < 500 ? "partial" : "fail";
return scoreCheck( "TTFB < 500 ms", 4, earned, status, `${ttfbMs} ms` );
}Under 200 ms is excellent (full 4 points), under 500 ms still fine (2 points), beyond that too slow. Why TTFB matters at all and how it connects to the Core Web Vitals is in Core Web Vitals & Performance.
Security headers: count instead of checking one by one
Some checks are a question of quantity. With security headers, what matters is less which one exactly is missing and more whether the page takes the topic seriously at all. So I count how many out of a list of relevant headers are set:
const SECURITY_HEADERS = [
"Strict-Transport-Security", "Content-Security-Policy",
"X-Frame-Options", "X-Content-Type-Options",
"Referrer-Policy", "Permissions-Policy",
"Cross-Origin-Opener-Policy", "Cross-Origin-Resource-Policy",
];
function checkSecurityHeaders( headers ) {
const present = SECURITY_HEADERS.filter( h => Boolean( headers[ h ] ) ).length;
const earned = present >= 4 ? 3 : present >= 2 ? 1 : 0;
const status = present >= 4 ? "pass" : present >= 2 ? "partial" : "fail";
return scoreCheck( "Security headers ≥ 4", 3, earned, status,
`${present}/8 security headers present` );
}Four or more out of eight: full point. Two to three: half credit. Below that: room to improve. This "count the good signals" logic shows up in several places in the tool — for Open Graph tags, for semantic HTML elements, for the AI crawlers.
Readability: the language-aware Flesch formula
This is my favorite part, because there's real linguistics in the code here. Readability can be measured — via the Flesch Reading Ease. The formula relates average sentence length to average syllables per word. The catch: the constants only hold for English. German words are longer and richer in syllables; with the English formula every German text would look artificially "hard".
That's why the tool detects the language from <html lang> and computes with the matching variant. For German that's the Amstad formula:
// Average sentence length (ASL) and syllables per word (ASW)
const asl = wordCount / Math.max( 1, sentenceCount );
const asw = syllableCount / Math.max( 1, wordCount );
let flesch;
if ( lang === "de" ) {
flesch = 180 - asl - 58.5 * asw; // Amstad (German)
} else if ( lang === "fr" ) {
flesch = 207 - 1.015 * asl - 73.6 * asw; // Kandel-Moles (French)
} else if ( lang === "es" ) {
flesch = 206.84 - 1.02 * asl - 60.0 * asw; // Fernández Huerta (Spanish)
} else if ( lang === "it" ) {
flesch = 217 - 1.3 * asl - 60.0 * asw; // Franchina-Vacca (Italian)
} else if ( lang === "nl" ) {
flesch = 206.835 - 0.93 * asl - 77.0 * asw; // Douma (Dutch)
} else {
flesch = 206.835 - 1.015 * asl - 84.6 * asw; // Original Flesch (English and all others)
}
const score = Math.round( Math.min( 100, Math.max( 0, flesch ) ) );Counting syllables is itself language-dependent. For German words, umlauts and ß are phonetically replaced first, then vowel groups are counted:
function countSyllablesDE( word ) {
word = word.toLowerCase();
if ( word.length <= 3 ) return 1;
// Resolve umlauts/ß phonetically so vowel groups are correct
word = word.replace( /ä/g, "ae" ).replace( /ö/g, "oe" )
.replace( /ü/g, "ue" ).replace( /ß/g, "ss" )
.replace( /[^a-z]/g, "" );
const groups = word.match( /[aeiouy]{1,2}/g ); // contiguous vowels ≈ one syllable
return groups ? Math.max( 1, groups.length ) : 1;
}It's a heuristic, not a perfect hyphenator — but for a relative readability assessment across a whole text it's entirely sufficient. The resulting value is translated into tiers (90+ "Very Easy", 60–69 "Standard", below 30 "Very Difficult") and feeds two points into the content score. Why readable writing matters specifically for AI systems is something I go into in Writing for AI.
In the same step the top keywords with density fall out as a byproduct — after subtracting stopwords in several languages:
const freq = {};
for ( const w of words ) {
const lower = w.toLowerCase();
if ( lower.length >= 3 && ! STOPWORDS[ lower ] ) {
freq[ lower ] = ( freq[ lower ] || 0 ) + 1;
}
}
const topKeywords = Object.keys( freq )
.sort( ( a, b ) => freq[ b ] - freq[ a ] )
.map( word => ( {
word,
count: freq[ word ],
density: Math.round( freq[ word ] / wordCount * 1000 ) / 10, // percent, 1 decimal
} ) );From check to score: the sum
Once all checks have run, the overall score is trivial — and that's the point. Because every check knows its own maxPoints and earned, the aggregation is a single loop:
function totalScore( checks ) {
let earned = 0, max = 0;
for ( const c of checks ) {
earned += c.earned;
max += c.maxPoints;
}
return Math.round( earned / max * 100 ); // 0–100
}From that, finally, comes a letter grade: A from 90, B from 80, C from 60, D from 40, otherwise F. No magic, no secret weighting in the background — all the "intelligence" lives in the individual, readable check functions. That's exactly how I wanted it: traceable down to the single point.
GEO score: why a second, independent value
This is where it gets interesting, because it's the actual reason I picked the tool up again in 2026. Classic SEO tells you whether Google likes your page. But whether Claude, ChatGPT or Perplexity can cleanly read, attribute and cite your content is a different question — with different signals. That's why there's a second, entirely independent GEO score that never influences the SEO score, and vice versa. A page can be technically excellent and AI-blind, or the other way around.
The GEO score uses the same scoreCheck building block but inspects different things.
Schema variety: recursing through the @graph
Structured data is gold for AI citations (background: Structured Data & Technical GEO). But it's not enough whether JSON-LD is there — what's interesting is how many different types are marked up. Modern pages nest this in a @graph, so I collect the @type values recursively:
function collectSchemaTypes( node, types = new Set() ) {
if ( ! node || typeof node !== "object" ) return types;
const t = node[ "@type" ];
if ( Array.isArray( t ) ) t.forEach( x => types.add( x ) );
else if ( t ) types.add( t );
for ( const key of Object.keys( node ) ) {
if ( key === "@context" ) continue;
const val = node[ key ];
if ( val && typeof val === "object" ) collectSchemaTypes( val, types ); // dig deeper
}
return types;
}
function checkSchemaVariety( jsonLdBlocks ) {
const types = new Set();
jsonLdBlocks.forEach( block => collectSchemaTypes( block, types ) );
const n = types.size;
return scoreCheck( "Schema variety", 3,
n >= 3 ? 3 : n >= 2 ? 2 : n >= 1 ? 1 : 0,
n >= 3 ? "pass" : n >= 1 ? "partial" : "fail",
n > 0 ? `${n} type(s): ${[ ...types ].slice( 0, 5 ).join( ", " )}` : "No schema types" );
}Three or more types — say Article plus Person plus BreadcrumbList — speak for a page that's been deliberately marked up to be machine-readable. That's exactly what the check rewards.
llms.txt and the AI crawlers
Two GEO checks revolve around whether you let AI systems in at all. The tool automatically fetches the llms.txt from the domain root (the Markdown file that explains a page's purpose and structure to an AI) and checks the robots.txt specifically against 16 known AI crawlers, one by one — User-agent: * only applies to a bot that has no group of its own:
const AI_CRAWLERS = [
// Training
"GPTBot", "ClaudeBot", "Google-Extended", "Applebot-Extended",
"meta-externalagent", "CCBot", "Bytespider", "Amazonbot",
// AI search
"OAI-SearchBot", "Claude-SearchBot", "PerplexityBot", "DuckAssistBot",
// Fetches on behalf of a user
"ChatGPT-User", "Claude-User", "Perplexity-User", "MistralAI-User",
];
function checkAiCrawlers( robotsTxt, pageUrl ) {
const blocked = AI_CRAWLERS.filter(
bot => ! isAllowedForAgent( robotsTxt, pageUrl, bot )
);
const n = blocked.length;
return scoreCheck( "AI crawlers allowed", 3,
n === 0 ? 3 : n <= 3 ? 2 : n <= 6 ? 1 : 0,
n === 0 ? "pass" : n <= 3 ? "partial" : "fail",
n ? `${n}/${AI_CRAWLERS.length} blocked: ${blocked.slice( 0, 3 ).join( ", " )}${n > 3 ? "…" : ""}`
: `All ${AI_CRAWLERS.length} major AI crawlers allowed` );
}The first version of this article listed eight bots here. The list is now grouped by purpose, anthropic-ai is gone, and Anthropic appears with three bots instead: ClaudeBot for training, Claude-SearchBot for search and Claude-User for fetches on behalf of a user. The point thresholds haven't changed, but with 16 bots they bite harder. I also corrected the status: from four blocked bots on, the check reports "fail", even though up to six still earn one point — the old snippet wrongly showed "partial" here. And the counter doesn't ask about purpose: if you lock out only the eight training crawlers and allow search and user fetches, you still end up with 0 out of 3 points.
It's a deliberately neutral check: it scores allowing them positively, because the tool is built from the "I want to appear in AI answers" perspective. Anyone who wants to protect content from training still reads, in black and white, which bots they're currently locking out — and that's just as valuable.
Short paragraphs: what LLMs prefer to read
One last, small but telling example: AI systems extract from short, focused paragraphs more reliably than from walls of text. So the tool measures the average paragraph length:
function checkParagraphLength( wordCount, paragraphCount ) {
if ( ! paragraphCount ) {
return scoreCheck( "Short paragraphs (avg < 150 words)", 2, 0, "fail", "No paragraphs detected" );
}
const avg = Math.round( wordCount / paragraphCount );
return scoreCheck( "Short paragraphs (avg < 150 words)", 2,
avg < 150 ? 2 : avg < 250 ? 1 : 0,
avg < 150 ? "pass" : avg < 250 ? "partial" : "fail",
`${paragraphCount} paragraphs, avg ${avg} words` );
}Under 150 words per paragraph: full point. It's the same recommendation I make in Writing for AI — just cast here as a measurable number.
What's deliberately missing
Just as important as what's in it is what I left out. There is no account, no tracking, no ongoing crawl monitoring across thousands of URLs, no rank-tracking history. That's intent, not laziness. The tool answers one question — "is this page clean for search and AI?" — and does it in seconds. In Expert Mode it can now also check the status of every URL from the sitemaps and the structured data of the internally linked pages. Those are one-off runs at the push of a button, though, not monitoring. Anything that doesn't directly serve that question would only have made it slower, more complicated and more in need of explanation. YAGNI as a feature: the omission is the product.
FAQ
Does the tool cost anything?
No. The SEO & GEO analyzer is free and usable without an account. There's no registration, no subscription and no tracking. Enter a URL, read the analysis — that was the whole point of the exercise from the start.
What's the difference between the SEO and the GEO score?
The SEO score rates classic search engine fitness: HTTPS, title, meta description, headings, load time, security headers and the like. The GEO score rates AI fitness: structured data, llms.txt, whether AI crawlers are allowed, clean heading hierarchy and short paragraphs. The two values are independent — a page can shine at one and fail the other.
How accurate is the score?
Every point is traceably tied to a concrete, tested rule — no black box. Thresholds like "title 50–60 chars" or "TTFB < 200 ms" are established best practices, and the weighting rests on 25 years of practice. The score is a well-founded assessment, not a law of nature: it reliably shows where a page stands and what to tackle next — but it doesn't replace editorial judgment about the content itself.
Why no login and no history on the server?
Because the point of the tool is speed and honesty. A login would be a hurdle in front of the actual task, and a history on the server would have meant tracking and data retention — both of which I explicitly did not want. What does exist stays in your browser: the URL field remembers the last ten analyzed URLs in localStorage, and Clear history deletes them again. Anyone who wants to keep an analysis can export it as JSON or as an HTML report.
Further reading
The frame for the AI side comes from What is GEO?; the technical depth for it is in Structured Data & Technical GEO. How to write content that search and AI both like is covered in Writing for AI, and the performance foundation is handled in Core Web Vitals & Performance. And the fastest way to understand the tool is to run one of your own URLs through it: jpkc.com/tools/seo/.