A score, and three files it can write for you
Added
-
A score out of 100, and the checks that passed to earn it. Every report
now opens with a number, a grade and a ring:81/100 (B), followed by the
arithmetic that produced it. A run starts at 100 and pays for what is wrong
with it — an error-level check costs 12 points, a warning 4, spread across
the pages it is on — so what a check costs is exactly what fixing it is
worth, and every piece of work in Start here now carries the points it
returns.This project has refused a score everywhere else and the refusal still
stands for the thing people usually mean by one: nothing here predicts a
ranking, estimates traffic, or grades a site against its competitors. The
weights are not a new judgement either — every check already carries a level,
argued over check by check when it was written, andscripts/check-levels.mjs
reads those levels back out of the source so a check promoted from warning to
error cannot keep its old weight. A test asserts it on the machine of whoever
changes one. -
What passed, and what was never checked. A report that only lists faults
gives no way to tell a check that passed from one that never ran, and a
missing finding reads exactly like a passing one. Both are now named: a
Passing section listing every check the site cleared, in its own words
("Every page has an og:image", notog-image-missing), and a Not checked
section saying why the rest did not apply — no page carries an image, no
redirect map was given, PageSpeed was not asked.A check that could not run is left out of the score entirely rather than
counted as passed. A site with no images has not passed the alt-text check,
and scoring it as though it had would hand out points for doing less. -
Errors and warnings first, notes after them. Findings used to arrive
interleaved, and a note is "worth knowing, may be deliberate" — reading
forty of them mixed into the faults is how a reader loses track of which is
which. The terminal, the Markdown and the HTML now put what is wrong first
and everything only worth knowing under its own heading, which says out loud
that none of it costs the score anything. -
--write-schema <file>— the JSON-LD this site could add:WebSite,
OrganizationandBreadcrumbList. Every schema generator on the internet
asks you to type the answers in; this one refuses to ask, because the moment
it asks it is no longer describing the site — and structured data that
describes a site inaccurately is worse than none. It is a machine-readable
claim the page does not support, which is a manual-action category at Google.So the rule is narrower than the rest of this project's and it is absolute:
every value emitted is a string this crawl read off this site. An
organisation is named only when the site names itself inog:site_name,
because the home page's title with its tagline still attached is a claim
about a company's name.The first live run made the point better than any test: a breadcrumb built
from<title>produced "Assets | Jekyll • Simple, blog-aware, static
sites" as a step. A title is not a breadcrumb name. Now a step is named by
the page's<h1>when it has exactly one, or by what the site's own
navigation calls it when the links agree and the words are not "read more" —
and by nothing else.Jekyll › Docs › Assets. On that site 142 pages were
skipped rather than given a name invented from a slug.The refusal can be the good answer here, so it says which it is: a site that
already declares everything this could write gets told exactly that, with the
counts, rather than the same sentence a site with no evidence gets. -
--write-llms <file>— thellms.txtthis site should have had.
Sibling to--write-sitemap, on the same premise: the crawl has already read
every page's title, description, section and indexing directives, which is
exactly what the file is made of. Every line is a string the site already
serves — a page that gave us no description gets a line without one rather
than a sentence somebody made up about it, and the H1 is the site's own name
for itself rather than a guess.It refuses for the same reasons the sitemap does, and the refusals matter
more than the file: this is a document handed to an assistant as the
authoritative summary of a site, and one built from a third of the site is
worse than none because it looks complete. A truncated crawl writes nothing
and names the run that would work.Reachable everywhere: the flag,
llms-out=1on the Worker, the macOS Export
menu (which says why when the engine refused), the Raycast extension, and
awrite-llmsinput on the Action.--write-sitemapgained the Action input
it should always have had while the wiring was open. -
Search Console now answers where you rank, and what for. The same
connection, asked one more question — no second account, no scrape, no
keyword provider, and no number that anybody here worked out.Every response Google has ever sent carried
position, and it was being
thrown away. It now travels with each finding, so a template on page two can
be ordered ahead of one nobody has been shown, and the scope line says "12
pages, 4,300 impressions, best at position 6.4".search-console-strikingnames the crawled pages at positions 11 to 20 —
page two, where the click-through rate is roughly nothing and the ranking is
already earned — with the query each is closest on, most-shown first. It is
the only list in this tool that is an opportunity rather than a fault, and
moving one of them up two places is usually less work than a new page.A live property found the part no test would have: Google returns HTTP 200
and zero query rows for a site under its anonymity threshold — 99
impressions over 28 days was well under. Silence there reads exactly like
"this site is found for nothing", so the report says which it is. The query
call is best-effort besides: if it fails, the positions still stand. -
Which AI crawlers a site lets in. GPTBot, OAI-SearchBot, ClaudeBot,
Claude-SearchBot, PerplexityBot, Google-Extended, Applebot-Extended, CCBot
and five more, asked ofrobots.txtthrough the samerobotsVerdict()every
other robots question in this project goes through — so a rule this reads and
a rule Google reads are the same rule, longer-Allow-wins included. Nothing
is fetched that was not already being fetched and nothing is estimated: a
site's position on being read by the answer engines is already written down
in a file it already serves.It is a note, and stays one. A publisher who does not want their work in a
training set and says so has done the correct thing correctly, and a check
that cries wolf gets the whole report ignored. What the finding adds is the
distinction everybody gets wrong — an answering crawler fetches a page
because somebody asked a question just now, so blocking it removes the site
from that answer today; a training crawler does not, and blocking it changes
nothing about being cited. And it says whether anybody actually decided: a
block that arrives throughUser-agent: *rather than the agent's own name is
usually a CDN or plugin default nobody has seen.The one thing here that is a fault gets a warning:
ai-crawler-conflict, a
site servingllms.txt— a file whose only purpose is to tell an assistant
what to read — whilerobots.txtturns that assistant away. The invitation
never gets read, and unlike the block itself, nobody chose it.New area, AI & answer engines, which
llms-missingmoves into: it is
addressed to assistants rather than to crawlers and belongs beside the agents
that read it. -
/checks, served beside/options: every scored check with its weight,
its area and what it says when it passes. "What does this thing actually
check" is now a question with a fetchable answer rather than one that needs a
source file read.
Changed
-
The CSV is the whole checklist, not only the faults. A
pointscolumn
carries what each failing check is taking off the score, so a spreadsheet
sorts by what fixing something is worth rather than only by how often it
occurs. Checks that passed arrive as rows at levelpass, and ones that did
not apply asnot-checkedwith the reason indetail.Additive on purpose:
pointsis appended afterdetailrather than put
besideindexablewhere it reads better, so every column keeps the index it
has had since the file shipped. Anything filtering onerror,warnor
infois untouched. -
The portfolio table has a score column, and orders by it where every run
has one. "Which of my twenty sites is worst" is the only question a portfolio
exists to answer, and counting errors weighs a site-wide failure the same as a
warning on one page of four hundred. A run that never answered prints a dash,
never a zero.
Fixed
-
<title>and every heading arrived with their HTML entities undecoded.
attr()has decoded since it was written, so meta descriptions were always
fine — but a title and an<h1>are element text and went through none of
it. SoWidgets & Cowas the title in the terminal, in the CSV, in the
JSON, and in the lengthtitle-longmeasures against what Google shows,
where it was five characters too long. Hexadecimal numeric references were
not handled at all, and’is what a CMS emits for the apostrophe in
Widget's.Found by generating an
llms.txtfor a real site and reading the first line
of it — not by the test suite, which is the usual way round here. -
Every export dropped the score except the two that were written first.
It reached the terminal and the HTML and stopped there: the CSV had no column
for it, the portfolio had no column for it, the Raycast extension's exports
did not pass it to the writers, and the PDF the macOS app draws itself did not
know about it.Worst of the four: the window re-encoded the report from its own Swift models
on the way to/render, which silently dropped every field it had not been
taught about — so an HTML report exported from the window lost the score panel
the window itself was showing. The JSON export has written the engine's bytes
verbatim since it shipped, with a comment saying exactly why; the other three
formats now do the same. There is a test per writer. -
--againstnever ignored hosts, though it said it did. Comparing a
rebuild against the site it replaces is the same question as comparing
yesterday against today — did this change anything — butdiff()keys a
finding on its whole URL, and every URL onnew.example.comdiffers from its
twin onexample.comby the host. The flag has passed{ ignoreHost: true }
since it shipped anddiff()has never read a third argument, so the answer
came back with every finding fixed and every finding added at once, which is
no answer at all.Two runs of different origins are now matched by path — trailing slash
ignored, query kept, since/search?q=aand/search?q=bare two pages on
any host. It decides for itself when it is not told, so the hosted/diff
and the macOS window get it without either having to know the rule. -
The macOS window could only compare two runs of the same site. Its
Compare menu filtered the library by host, so a rebuild kept beside the site
it replaces greyed the button out — the exact comparison somebody keeps two
reports around to make. Runs of other sites are now offered under their own
heading, and the sheet says it matched by path when the hosts differ.