squirrelscan
GitHub

The entity map in the cloud

The dashboard Entities page, its filters and exports, and the API that serves the same map.

A hosted audit produces the same map a local one does. The dashboard draws it, and the API serves it.

The Entities page

At /<org>/website/<id>/entities, reached from the website sidebar or from the Entities button on a report.

It hangs off the website, not off one report, because the questions worth asking span audits: did this get better, is the split identity still there, when did that reference break. An audit picker at the top selects which report to draw, defaulting to the newest that produced a map.

The cards

Six summary numbers, each with its change against the previous audit: entities, types, pages without entities, stable @id share, conflicts, and dangling references.

Null and zero are kept apart. A first audit shows no delta at all rather than a change from nothing, and a metric that held still says “No change” rather than showing a zero you have to interpret.

The graph

The same behaviours as the report’s viewer, so the hosted graph and an emailed HTML report read the same way: wheel and pinch to zoom, drag to pan, fit to view, node size by occurrences, colour by primary type, hover to highlight an entity’s neighbours and dim the rest, click to select, dangling targets drawn hollow.

Labels are a budget rather than a zoom threshold: the top twelve by reach when fitted, growing with the square of the zoom, with repeated labels pushed behind every unique one. Without that last rule, forty unnamed Offer nodes spend the whole budget drawing the word “Offer” forty times.

Layout runs once and the scene is then static, which is what keeps it usable at two thousand nodes.

Filters

Free text, page or URL prefix, problem, and the legend, which doubles as the type filter. They apply to the graph and the table together and run client-side over the whole map, so clicking a legend chip is instant rather than a round trip.

The filter rules deliberately mirror the API’s own, so an export never disagrees with what is on screen.

Page-local entities, a page’s own WebPage, BreadcrumbList, ListItem, Question, Answer and unnamed ImageObject, are hidden by default behind a toggle. They usually outnumber everything else: on kinsta.com that is 137 of 188 nodes.

The table

Sortable, with conflicts and dangling references highlighted. Below md the graph is hidden and the table is the primary view. Selecting a row opens the detail panel, docked beside the graph on wider screens and as a sheet below it on narrow ones.

History

Below the table, and independent of which audit is drawn above it.

The metric series plots the six card metrics across every audit of the site that produced a map: entities, types, pages without entities, stable @id share, conflicts, and dangling references. The two audits being compared are drawn as larger points. A counter that had to be reconstructed is drawn hollow and labelled “at least”, for the same reason the card is.

The change list compares two audits. The newer side is the audit the picker at the top of the page selected, so there is one notion of “the audit you are looking at”; the older side is a second picker, and both survive in the URL as ?report= and ?from=. Each row opens that entity in the graph, switching audits if it has to.

The rows are grouped, worst first:

GroupWhat it means
@id replacedThe entity kept its identity but moved to a different @id. Every page still pointing at the old one now dangles. Usually a plugin swap.
Lost a stable @idIt is now identified by name and type alone.
New conflictsAn entity started carrying disagreeing values for a property across pages.
No longer seenIt was in the older audit and is not in the newer one.
Gained a stable @idThe fix the identity guide describes, landing.
Conflicts no longer reportedIt was conflicting and is not now.
AddedNew in the newer audit.
Occurrence changesDeclared on more or fewer pages than before.

An empty change list says “no tracked change”, not “nothing changed”. The hosted comparison follows entities appearing, disappearing, changing @id, moving occurrence count, and starting or stopping conflicting. It does not follow a conflict count moving between two non-zero values, which property was conflicting, or any property value.

A site with one audit shows the series and an explanation instead of a change list. That is not an error state, and neither is a site whose audits all predate the entity map; a history that fails to load says so and offers a retry.

If a site has no structured data

The page says so and links to Fixing structured data identity rather than showing an empty graph. A site with no JSON-LD and an audit that stored no map look identical from the outside, and only one of them is worth acting on.

The API

Three endpoints: two under a report, one under a website. Authentication is the same as the rest of the API.

Get the map

GET /v1/reports/{id}/entities
QueryMeaning
formatjson, jsonld, html, markdown, csv, dot, graphml, mermaid. Default json.
typeKeep only entities carrying this @type, matched against every type an entity declares. Case-insensitive.
exclude_typeDrop entities whose primary @type is exactly this one. Repeat it to exclude several. Case-sensitive.
pageKeep only entities declared on a matching page. An absolute http(s) URL or a value starting with / is a prefix (of the URL, or of the page path and query), anything else is a substring. Case-sensitive. The same rule as squirrel entities --page and the MCP tools.
problemno-id, conflict, dangling, single-page, split-identity.
qFree-text match over name, @id, key and types.
include_page_localInclude per-page types. Off by default.

type and exclude_type read different things on purpose. type matches any of an entity’s types and ignores case, so ?type=localbusiness finds an entity typed ["Organization", "LocalBusiness"]. exclude_type matches only the primary type, the one the entity leads with, and matches it exactly. That is the type an entity is listed under in a viewer showing one chip per entity, and comparing it as a string is what keeps a site that declares both Organization and organization from losing both when you name one. Together that is what makes a deny-list over the visible types expressible:

GET /v1/reports/{id}/entities?exclude_type=WebPage&exclude_type=BreadcrumbList

The JSON response is the map document plus a meta object:

{
  "format": "squirrelscan/entity-map",
  "version": 1,
  "site": "https://example.com/",
  "generatedAt": "2026-09-13T05:23:00.000Z",
  "summary": { },
  "nodes": [ ],
  "edges": [ ],
  "pages": [],
  "meta": {
    "reportId": "…",
    "websiteId": "…",
    "auditedAt": "2026-09-13T05:20:00.000Z",
    "filtered": true,
    "counts": { },
    "pageFilterIncomplete": false,
    "backfilled": []
  }
}

Three fields in meta, and one at the top level, change how you should read the rest.

  • filtered is true when a query filter removed at least one entity. summary still describes the whole audit; meta.counts carries the post-filter totals. Compare the two to see how much of the site you are looking at.
  • pageFilterIncomplete is true when a page filter ran against a map holding an entity whose declaring-page list was capped. It says the result may be missing entities that really are declared on that page, not that it is. Always false when no page filter was given.
  • backfilled names the summary counters that were reconstructed rather than stored, for a map published before they existed. A reconstructed counter can only be computed from the stored entities, and a published map is capped, so it reads low next to its un-reconstructed siblings. Render those as “at least N” rather than as site totals. Entity-level pageLocal is reconstructed exactly and is never listed here.
  • pages is empty on hosted maps. The published copy drops the per-page index for size, and the summary carries what a reader needs from it. Empty means not published, not “no pages”.

A hosted map is a projection

The local document is uncapped, because a file on disk costs nothing. The published copy is measured against a hard size gate, so it keeps the entities and references that carry the findings and drops the tail:

LimitPublished
Entities750
References1,500
Declaring pages per entity5
Distinct values per conflicting property5
Any single string property512 characters
The whole map512 KB

The page cap is the one to watch. Locally an entity lists up to 50 of its declaring pages; hosted it lists 5, with the rest in morePages. Anything that needs the real set has to count pages.length + morePages rather than reading the array, and a page filter over a hosted map is correspondingly more likely to set pageFilterIncomplete.

Which 750 entities survive is a sample, not the head of the list. A slice of the budget, maxProblemShare at 0.2 of the node budget and a ceiling rather than a target, is reserved first for entities that carry a finding the entity rules report, so a finding on the report always has an entity in the hosted graph to open. The classes are:

  • an entity with conflicting properties, including a type disagreement
  • an entity with references to an @id nothing declares
  • a multi-page Organization, LocalBusiness, Person or WebSite with no @id
  • the same entity declared under two @ids on one page
  • an identified entity nothing references, declared on one page
  • an @id that is not an absolute URL
  • the primary organization when it has no sameAs
  • a publisher that disagrees with a clear majority of the site (a balanced split between several publishers is not a finding)
  • a Person author with no url or sameAs
  • a LocalBusiness declared on nearly every crawled page

Each class present keeps at least one entity while the budget allows, even where that goes over the ceiling on a very small budget, taking turns so one class cannot crowd out another. The reserved entities still count against the 750 and the 512 KB, and truncated still reports every entity and reference dropped. Findings about something absent, such as a missing Organization or WebSite, have no entity to keep.

Entity and reference counts alone would not hold the size, because property values are site-controlled strings: fifty conflict values of a megabyte each would pass a node count and still blow the limit. The byte ceiling is the real backstop and the per-string caps are what usually keep it from engaging.

Get one entity

GET /v1/reports/{id}/entities/{key}

One node with its properties, occurrences, conflicts, declaring pages, and the edges in and out of it.

key accepts the node key, the entity’s @id, or its exact display name. An @id is a URL, so percent-encode it and escape / as %2F:

curl -H "Authorization: Bearer $SQUIRRELSCAN_API_KEY" \
  "https://api.squirrelscan.com/v1/reports/$REPORT/entities/https%3A%2F%2Fexample.com%2F%23organization"

Get the history

GET /v1/websites/{id}/entities/history

One summary point per audit that produced a map, plus the change set between two of them. It is what the dashboard’s history view draws.

QueryMeaning
fromReport id of the older side. Defaults to the audit immediately before to.
toReport id of the newer side. Defaults to the latest audit with a map.
limitPoints in the series, newest first. Default 50, maximum 200.
{
  "websiteId": "…",
  "series": [
    {
      "reportId": "…",
      "runId": "…",
      "auditedAt": "2026-09-13T05:20:00.000Z",
      "summary": { },
      "backfilled": []
    }
  ],
  "comparison": {
    "from": { "reportId": "…", "auditedAt": "2026-08-13T05:20:00.000Z" },
    "to": { "reportId": "…", "auditedAt": "2026-09-13T05:20:00.000Z" }
  },
  "changes": {
    "added": [], "removed": [], "gainedId": [], "lostId": [], "changedId": [],
    "occurrenceDeltas": [], "newConflicts": [], "resolvedConflicts": [],
    "summaryDelta": []
  }
}

Things worth knowing before you build on it:

  • limit bounds the series only. from and to resolve by their own lookup, so they can name an audit older than the window. A report id that is not one of this website’s snapshots is a 404, never a silent fall back to the default pair.
  • comparison and changes are null together when the site has fewer than two snapshots, or when from and to name the same one. That is a first audit, not an error.
  • from must be at or before to. An inverted pair is a 400 rather than a quiet swap, because the response labels the two sides and reversing your arguments would answer a different question.
  • backfilled is per point, not per series. Rows were written at different times, so an older snapshot can be missing a counter a newer one recorded, and one flag for the whole series would either over-claim or under-claim.
  • summaryDelta only names metrics that moved, and its metric list is not the same as the dashboard’s six cards: it has no conflictCount and no notion of a type count. Derive those from two points in series rather than expecting them here.
  • newConflicts and resolvedConflicts are per entity, as counts, carrying before and after. They cannot name the property, and they only fire on a transition through zero. A conflict count moving from 2 to 1 produces no row.
  • removed and resolvedConflicts are not proven. Same limitation as the dashboard above: the snapshot carries no page index, so absence cannot be told from a page that was not crawled again. Treat both as observations.
  • Dangling movement and not-crawled reconciliation are not here yet. squirrel entities --diff reports them from the local maps.

Untrusted content

Every entity name, @id, type and property value in the map is declared by the audited site. The structure around them is not: the discriminators, timestamps, key prefixes, counts and any generated @id are squirrelscan’s.

The html rendering escapes it and is safe to open. The markdown rendering escapes only table delimiters, which is enough while it is read as markdown: if you render it to HTML, sanitize it first, or take format=json and render the fields yourself.

Exports

The dashboard’s export menu offers the same formats as the API. Anything the hosted renderer does not implement is shown disabled rather than hidden, so the menu and the documented contract agree rather than quietly diverging.

References

Type to search…

↑↓ navigate↵ openesc close