Features
Page analysis
Enter a URL (+ optional keyword), pick an objective, and RUN. The engine fetches the page, parses the HTML, and produces a set of weighted checks across six categories, plus an overall score (0–100) and grade (A–F).
| Category | Examples |
|---|---|
| Meta | title length, meta description, lang, keyword in title/description |
| Structure | single H1, subheadings, content depth, image alt text |
| Social | OpenGraph (title/description/image), Twitter Card |
| GEO / AI | AI crawler access (robots.txt per crawler, and the page fetched as an AI crawler to catch CDN blocks and bot challenges), content readable without JavaScript, snippet directives, JSON-LD validity and completeness (required and recommended properties per type, FAQ questions checked one by one), a resolvable publisher entity (Organization with url, logo, sameAs), dates that agree across schema, meta, <time> and Last-Modified, llms.txt quality and whether it lists the page, quotable passages and answer position (see What the model sees), answer-style headings, citable schema (Article/FAQ/HowTo) |
| Indexing | noindex, canonical, robots.txt, internal linking |
| International | hreflang syntax, self-reference, x-default, canonical agreement |
A category that measured nothing is not reported as a score. A page with no
hreflang gets a neutral note rather than a fake 100 for International, and the
same applies to Performance when PageSpeed can't be reached.
Objectives bias the weighting (e.g. Improve AI visibility emphasizes GEO).
The origin's robots.txt and llms.txt are also probed.
Performance / Core Web Vitals
Enable PageSpeed in Settings to add a Performance category via Google PageSpeed Insights: performance score, LCP, CLS, TBT (an INP proxy), and FCP. It runs on single-page analysis only (crawls stay fast). If the API is unavailable it degrades to a neutral note without hurting the score.
What the model sees
Answer engines do not read pages. They fetch the HTML, extract the main content, split it into chunks, keep what fits a reading budget, and quote passages. A page can pass every whole-page check and still have its one good answer buried after 3 000 tokens of navigation, or spread across paragraphs that each begin "This means…". So every report carries the page as the engine receives it:
- The extracted text, in reading order, taken from
<main>/<article>/role=main(or the body when a page has no landmark), with navigation, footers, cookie banners, sidebars and forms removed the way a readability extractor removes them. The report states how many visible words were discarded as boilerplate. - A token budget drawn across it. A dashed line marks where roughly 2 000 tokens end — the order of magnitude engines keep of one page. Blocks below it are greyed: what an engine never reads, it never quotes. Counts are estimates (≈4 characters per token) and say so.
- Question headings are tagged, so you can see where the page's Q&A actually lands.
From that text, each paragraph is judged as a quote, and the five best are shown with a 0–100 score and the reasons on both sides: the length engines quote whole (40–90 words), whether it stands on its own or opens with a "This"/"It"/"However" that leans on the previous paragraph, whether a heading — ideally a question — sits directly above it, whether it carries a concrete figure or date, whether it is a complete sentence, and whether it starts inside the budget. The gaps are the actionable half: "opens with «this»" or "no number, date or measurable fact" tells a writer exactly what to change.
Three GEO checks are derived from it: Quotable passages (is there at least one
passage scoring 70+), Answer within the reading budget (does the first question
heading or quotable passage start before the budget runs out), and Signal to
noise (what share of the visible text survives extraction). The PDF export lists
the top three passages; the MCP server and the CLI's --json report them too, so an
assistant editing the page works on the exact text the engine will see.
Cited by answer engines? (citation probe)
Everything else in the GEO category predicts. This measures the outcome. From a page's report, type the question a user would ask and probe: Sjá asks ChatGPT, Claude and Perplexity through their public APIs with web search on, using your own keys, and reports per engine:
- whether the answer cites the page's site (apex and
www.count as one), and at which position among the cited sources; - who was cited instead — the distinct competitor hosts, which is usually the most useful line in the whole report;
- the start of the answer, so you can see what was actually said.
Every probe is stored (including failures, so a gap in the history is visible as a gap), the report shows the history for the page, and the Trends view shows the latest verdict per page and engine with the earlier ones as dots.
Two honesty notes, both stated in the UI. First, the APIs are close to, not identical with, the consumer products: models, search backends and ranking differ by surface. Second, a probe is one sample of a non-deterministic system. Read the trend and the competitor list, not a single yes or no. Each probe costs a few cents per engine on your own account; the default models are the current flagships with web search, and can be changed in Settings.
Structured data, entity and dates
Counting @type values says a page has structured data. It does not say whether
an engine can use it, so the report now reads the JSON-LD for what the engine needs:
- Validity. A
<script type="application/ld+json">block that is not valid JSON is reported as such, not as "no structured data" — the outcome for the engine is the same, but the fix is different. - Completeness per type. Article and its kin need a
headline(and are weaker withoutimage,author,datePublished,dateModified); HowTo needsnameandstep; Product needsnameand one of offers, rating or review; FAQPage and QAPage are checked question by question — a Question without anameor without an answertextis called out. Required gaps fail the check, recommended gaps warn. - The publisher entity. Engines resolve who is behind the page from a
stable Organization name, a canonical
url, alogoandsameAslinks to real profiles. The report says which of those the page's Organization carries, or that only a WebSite (or nothing) is declared. Across a site crawl, every distinct Organization name the pages use is listed with its page count: three spellings of the company read to an engine as three weak entities. - Dates that agree.
datePublished/dateModifiedin schema,article:published_time/article:modified_timemeta tags, the first<time datetime>element and theLast-Modifiedheader are collected. A page that declares no dates gets a note, not a penalty — not every page type has one. A page that does declare them is held to them: a modified date before the published date fails, two modified dates that disagree warn, a future date or a date more than two years old warns, dates that are not ISO 8601 warn.
llms.txt is likewise graded by content, not presence: it needs a # Title and at
least one link to be usable, the report says how many entries carry a description,
and a separate check says whether the page being analyzed is one of them.
Fix-prompt builder
Every warn/fail check is clickable. Clicking appends a context-aware, copy-paste-ready fix prompt (using the page's URL, keyword, and actual values) to the Fix prompt panel, with an expert preamble. Stack several, then Copy or Run with AI.
AI fixes
Run with AI sends the prompt to your configured model (OpenAI / Anthropic)
via elyra::ai. If the current project has a local code path:
- The agent gets sandboxed file tools (
list_dir,read_file,write_file) scoped to that folder — it cannot escape it. - It explores, reads, and edits the real files, streaming its activity live.
- With Ask before the AI writes files on (default), each write pauses for a preview + Approve/Decline.
Without a local path, it returns copy-paste-ready corrections instead.
Character encodings
Pages are decoded by their declared charset — Content-Type, else a
<meta charset> sniff, else UTF-8 — so ISO-8859-1, Shift_JIS, GBK and
Windows-1252 pages produce correct titles, word counts and keyword matches. This
matters for non-ASCII keywords in particular: against a mis-decoded page they can
never match, and the keyword checks would fail for a reason that isn't real.
Headless mode (CI)
seo.rs has always been GUI-free; there is now a command line in front of it:
elyra-sja check https://example.com/page --keyword widgets --budget 80 --budget-geo 70
Exit codes are the interface — and 1 and 2 are deliberately different, because "the score dropped" and "the site is down" call for different reactions in a pipeline:
| code | meaning |
|---|---|
| 0 | every budget met |
| 1 | analysis ran, a budget was missed |
| 2 | could not run: usage error, fetch failure, missing licence |
Budgets can be per category, not just overall (--budget-geo,
--budget-indexing, …): a GEO regression otherwise hides behind a healthy total.
A budget on a category that was never measured fails rather than passes — a gate
that did not run must never report success.
--json gives machine-readable output whose field names are pinned by a test,
since a pipeline parses them.
No window, no database, nothing written to disk. Headless mode exits before the
app data directory is created, which is asserted by a test that points HOME at an
empty directory and checks nothing appears in it. That also means no Settings are
read: PageSpeed is off and everything comes from flags.
Licence: headless mode needs a valid signed code. CI has no Keychain and no
browser, so pass it in ELYRA_SJA_LICENSE; on your own Mac the activated code in
the Keychain is used automatically.
MCP server (use Sjá from your own AI assistant)
elyra-sja mcp
Speaks JSON-RPC 2.0 over stdio, so Claude Desktop, Zed or Cursor can call the engine
while someone edits content — a model driving Sjá instead of the other way round.
Two read-only tools: analyze_page (score plus every failing check with its
evidence) and robots_check (whether a named AI crawler may fetch a URL, and what
that crawler is for).
Nothing that writes files or spends an AI key is exposed, calls are rate limited to 30/minute, and nothing is written to disk.
→ Full guide: client configuration, tool reference, worked examples and troubleshooting
Market locale
The launcher's Language and Market pickers set a BCP 47 locale that is
sent as Accept-Language when the page is fetched. On a site that negotiates
content by language this is what makes "audit the German variant" mean
something: the analysis runs on the page that market actually receives, and the
locale is recorded on the report (and in the exported PDF) so a stored result says
which market it describes.
- As served (the default) sends no
Accept-Languageat all — the page is analyzed exactly as the site returns it, with no negotiation. - A language alone is a valid locale (
ja); the market is an optional country variant (pt+ Brazil →pt-BR). - The lists are built from the platform's own ICU data, so coverage is every
language and territory it knows, with names in your UI language — not a
hand-picked shortlist of markets. Codes search engines don't honour (
UK,EU) are excluded, matching the hreflang validator. - An invalid locale never becomes a request header.
Monitors have no locale of their own: a scheduled re-scan uses the default from Settings.
International targeting (hreflang)
Misconfigured hreflang is among the most expensive mistakes on a multi-market
site, because it fails silently — search engines discard the whole annotation
set without reporting an error anywhere. Sjá checks, from the page itself:
- Syntax of every language tag (BCP 47: ISO 639 language, optional script,
ISO 3166-1 alpha-2 or UN M.49 region), catching
en_US,english,en-GBRand the like.en-UKanden-EUget specific advice — they look right but are not codes search engines honour (the UK isGB; the EU is not a country). - Self-reference — every page in a set must list itself, or the set is dropped.
- Absolute URLs —
hreflangrequires fully-qualified hrefs. - Conflicts — one language tag pointing at two different URLs invalidates the set; identical duplicates only warn.
x-default— expected once two or more language variants exist, so visitors matching none of them have a declared fallback.- Canonical agreement —
hreflangpointing at a URL that is canonicalized elsewhere is ignored without warning. <html lang>vshreflang— when the two disagree, one of them is wrong.
A single-market page is never penalized for having no hreflang: it gets a
neutral note, weight zero.
Not checked yet: whether each alternate links back (return-link
reciprocity). That requires fetching the other pages, so it belongs to the site
crawl — the annotations are stored per page so a crawl-level check can be added
without re-analyzing anything. hreflang in HTTP headers or XML sitemaps is also
out of scope; only the HTML <link> form is read.
AI crawlers (server-log analysis)
Every other GEO signal in Sjá is a prediction about how an answer engine will read a page. This one is evidence: point it at a server access log and it reports which AI crawlers actually came.
Drop in an nginx/Apache access log (combined or common format) and you get, per
crawler: hits, distinct pages, status-code split, first and last seen, the most
requested paths — and the robots.txt verdict for that specific crawler.
Why the last part matters: a site that allows * and disallows GPTBot is the
most common way to be invisible to AI answer engines, and reading only the *
group would report it as "not blocked". Sjá parses every user-agent group and
resolves the most specific match, exactly as a crawler would.
The report distinguishes three kinds of crawler, because blocking them has very different costs:
| Purpose | Example | Cost of blocking |
|---|---|---|
| answers | OAI-SearchBot, Claude-User, Perplexity-User | Absent from AI answers today |
| training | GPTBot, ClaudeBot, CCBot, Bytespider | No visibility cost now; a licensing/values choice |
| search | Googlebot, Bingbot, PerplexityBot | Absent from classic search |
Two things it deliberately says out loud:
- Robots-only tokens.
Google-ExtendedandApplebot-Extendednever appear as a visiting user-agent — they only control training use of what Googlebot and Applebot already fetched. Reporting them as "never seen" would be meaningless, so they are marked as controls rather than absentees. - Blocked but visiting. When a crawler
robots.txtblocks shows up in the log anyway, that is flagged: either the rule is newer than the log, or the crawler ignores it. Neither is visible fromrobots.txtalone.
Unrecognised bot-like agents are listed separately, so a new crawler can be spotted before the registry knows about it.
Visits over time
The analysis keeps visits per crawler per day, shown as a sparkline per crawler for the days the log covers, and — when a project is selected — stored, so the Trends view shows crawler visits across every log analyzed for that project. Rows are keyed per log file: two servers' logs add up, and re-analyzing the same file replaces the days it covers instead of double counting them. Quiet days are zero, not missing. This answers the question the rest of GEO cannot: did the crawlers come back after we changed something?
Verified addresses
A User-Agent is a string anyone can send; scrapers borrow GPTBot's to get past
the rules people write for it. Verify addresses compares the log's client
addresses with the ranges each operator publishes (OpenAI, Google, Bing,
Perplexity, Apple) and reports, per crawler, whether every visit came from those
ranges, some did, or none did — with a few of the offending addresses, enough to
look up or block. Operators that publish no list, Anthropic among them, are
reported as unverifiable, never as verified. The published lists are
downloaded and cached locally for a week; your log's addresses never leave the
machine.
Privacy: the log is read where it lies. It is never copied into the app's data directory and never uploaded — access logs contain visitor IP addresses. Parsing streams line by line, so a multi-gigabyte file never lands in memory, and the report states what share of lines it could actually parse rather than presenting a partially-read file as a quiet site.
Limitation: user-agents are trivially spoofed. This reports what the log claims; confirming a visitor really was GPTBot needs a reverse-DNS check, which Sjá does not do.
Generating llms.txt and structured data
Sjá flags a missing llms.txt or missing JSON-LD. It can also write them — from
crawl data, not from a model's imagination.
llms.txtfrom a crawl (on the site-report): an H1 site name, a summary blockquote taken from the home page's own description, then sections grouped by the first path segment, each entry a real URL with its real title and description. Pages that returned an error, have no title, or are marked noindex are left out — advertising a page the site tells search engines to ignore would contradict the site itself. Output is deterministic, so re-running produces a diff of content changes rather than reshuffled sections.- FAQPage schema from a page (on the page report): built from the page's own question-style headings and the prose beneath them. A heading with no answer under it is skipped, and a page with no Q&A produces nothing — with an explanation — rather than invented questions.
- Article schema from a page: title, description, language and (optionally) the
agency name from Settings. No
datePublishedis emitted, because the crawl never saw one; claiming a date the page does not state would be a fabrication.
Everything can be copied, or saved into the project's checkout — through the same sandbox and the same confirmation dialog the AI agent uses. There is one write path, not a quieter second one.
Why deterministic and not AI-written: a generated llms.txt listing real URLs
is verifiable; a hallucinated one is a liability on a client's site. Structured data
invented for a page contradicts its content, which is a manual-action risk rather
than a quality issue. The Run with AI prompts now point at these generators and
tell the model not to invent fields.
Site crawl
Crawl a whole site from a seed URL:
- Discovery from
robots.txtSitemap:directives +/sitemap.xml(including sitemap indexes), then internal-link BFS. - Respects
robots.txtdisallow rules, with a politeness delay. - Aggregates per-page scores into a site score, category averages, and an issue inventory (how many pages each issue affects).
- Flags broken pages (4xx/5xx) and duplicate titles/descriptions. Broken pages are reported but not scored — a 404 scores near zero in every category, so averaging them in would lower the site score for a reason the report attributes elsewhere. When the counts differ, the header reads "N crawled · M scored". Duplicate detection skips them too, so a site full of "404 Not Found" doesn't produce a phantom duplicate-title finding.
- Honours
Crawl-delayas an actual rate limit: when a host declares one, the crawler drops to a single request at a time instead of spacing the starts of four parallel ones. - Stoppable: hit Stop while a crawl runs. No new pages are started, the requests already in flight are dropped, and the pages finished so far are aggregated and saved as a normal crawl — marked stopped early in the report. Stopping before the first page finishes saves nothing and reports an error.
Click any page in the results to open its full report (and fix it with AI).
Trends & comparison
For each URL in a project, Trends shows score-over-time with a sparkline and the delta vs the first run. Pick two runs to compare — which checks were fixed and which regressed, plus the score change.
Monitors
Scheduled re-scans that run while the app is open (checked every minute):
- Add a monitor (URL, keyword, objective, interval: hourly/6h/daily/weekly).
- Each due run analyzes, saves to history/trends, and raises an alert on regression (score drop ≥ 5) or fetch error — with a native notification.
- The bell in the toolbar and the Monitors sidebar badge show unread alerts.
Reports
Client-ready, print-optimized PDF exports, branded with your agency name (from Settings):
- Page report — score, category scores, and all findings.
- Site report — average score, issue inventory, broken pages, duplicates, and the full page table.
Export opens the report in your browser; use ⌘P → Save as PDF. JSON export is also available for a page report.