squirrelscan
GitHub

The entity map format

The version 1 JSON document, the JSON-LD export and the change set, with a real sample of each.

Three documents come out of the entity map, and they are separate on purpose.

DocumentWhat it isWhere it comes from
The mapOne audit’s graph, collapsed across every crawled pagesquirrel entities -f json, the report’s entities field, GET /v1/reports/{id}/entities
The JSON-LD exportThe same graph as a schema.org @graphsquirrel entities -f jsonld, ?format=jsonld
The change setWhat moved between two auditssquirrel entities --diff -f json

Entity names, @ids, types and property values are declared by the audited site. Treat those as untrusted input: escape before rendering, and never bracket-assign a @type onto a plain object. The structure around them is squirrelscan’s: the discriminators, the timestamps, the key prefixes, the counts, and any @id the JSON-LD export invented.

The map

{
  "format": "squirrelscan/entity-map",
  "version": 1,
  "site": "https://docs.squirrelscan.com/",
  "generatedAt": "2026-09-13T05:23:00.000Z",
  "summary": { },
  "nodes": [ ],
  "edges": [ ],
  "pages": [ ]
}

site is the URL the audit was run on, with the crawl’s origin standing in for an audit stored before squirrelscan recorded that URL.

format and version are the discriminator. Check both before reading the rest: version is bumped whenever a change would make an already-stored document invalid, and stored documents exist in the wild.

truncated

Absent from the document squirrel entities -f json writes, which always carries the whole crawl. Present on the two copies that travel: the map published to the cloud, and the map inlined into an HTML report for the interactive viewer. Both are bounded, because a 5,000-page site declaring four entities per page is 20,001 nodes and 15MB of JSON.

{
  "reason": "viewer",
  "nodes": 18001,
  "edges": 18002,
  "pages": 5000
}

reason is publish or viewer. The three counts are what that copy left out, and pages is dropped whole.

What survives is a sample chosen to look like the site rather than the top of one list. Entities declared on more than one page go first: the Organization, the WebSite, the Brand every product points at. The rest of the budget is split evenly between the types that are left, so a 5,000-product catalogue cannot crowd out its own Offers, and within a type the better-connected entity wins. Entities that describe a single page, such as a per-page BreadcrumbList or WebPage, are kept last and capped at a tenth of the copy, since there is one of them per page and a graph hides them by default. Ranking on declaration count alone is what this replaced: on a store that declares its entities per page every entity occurs exactly once, so the copy became whichever type sorted first, either a pile of breadcrumbs when the theme gives its per-page entities no @id, or an alphabetical slice of the catalogue when it does. Both left whole types out.

summary is never recomputed, so a truncated copy still reports the site’s real nodeCount, edgeCount and pagesTotal. Read totals from summary rather than from the length of nodes: on a large site those are different numbers, which is the whole reason this field exists. For the complete graph, run squirrel entities -f json locally.

summary

Every field describes the audit, not a sample. From an audit of docs.squirrelscan.com:

{
  "nodeCount": 183,
  "edgeCount": 488,
  "danglingCount": 0,
  "pagesTotal": 60,
  "pagesWithoutEntities": 0,
  "nodesWithStableId": 183,
  "stableIdShare": 1,
  "pageLocalCount": 121,
  "nodesWithoutIdCount": 0,
  "conflictCount": 0,
  "countsByType": {
    "BreadcrumbList": 60,
    "ImageObject": 60,
    "Organization": 60,
    "TechArticle": 60,
    "WebPage": 60,
    "WebSite": 60
  }
}

countsByType counts declarations, not entities, and an entity carrying two types is counted under both. Sixty Organization declarations could be one entity declared on every page or sixty declared once each, and the type count cannot tell you which. nodeCount can: this audit has 183 entities behind those six type counts.

nodesWithoutIdCount counts only entities declared on more than one page. An entity on a single page with no @id has nothing to reconcile across pages, so it is not the finding.

nodes

One entity, collapsed across every page that declares it.

{
  "key": "id:https://squirrelscan.com/#organization",
  "id": "https://squirrelscan.com/#organization",
  "types": ["Organization"],
  "name": "squirrelscan",
  "properties": {
    "name": "squirrelscan",
    "url": "https://squirrelscan.com/",
    "logo": "https://docs.squirrelscan.com/logo/light.svg",
    "sameAs": [
      "https://github.com/squirrelscan",
      "https://www.linkedin.com/company/squirrelscan/",
      "https://x.com/squirrelscan_"
    ]
  },
  "occurrences": 60,
  "pages": ["https://docs.squirrelscan.com/", "…"],
  "morePages": 10,
  "conflicts": [],
  "danglingRefs": 0,
  "pageLocal": false
}

key is the identity the builder settled on, and it takes one of four shapes:

ShapeWhenExample
id:<resolved @id>The site declared an @idid:https://squirrelscan.com/#organization
syn:<types>|<property>:<value>No @id, but a distinguishing propertysyn:Organization|name:acme
blank:<page>:<raw id>A JSON-LD blank node, _:b0blank:https://site/about:_:b0
anon:<page>#<n>No @id and nothing to tell it apartanon:https://site/about#3

<types> in a synthetic key is the deduped, sorted type set joined with +, so ["LocalBusiness","Organization"] and the reverse produce one key rather than two. The discriminator is the first of name, url or sameAs the entity has.

The @id is resolved against the page it was declared on. A relative #organization therefore becomes a different @id on every page, which is why one real organization can occupy sixty nodes. That is the split identity the rules flag, not an artifact of this format.

id is null whenever the site declared none, and also for a JSON-LD blank node (_:b0), because a blank node identifier is document-scoped and names a different thing on every page. The key stays page-scoped in that case so two pages’ _:b0 never merge.

occurrences is declarations, pages is where. They differ: a page declaring the same Organization twice counts twice in occurrences and once in pages.

pages is capped at 50, with the rest in morePages. In the sample above the Organization is on all 60 pages: 50 listed, 10 counted. The complete set is still in the document, in the top-level pages[].declares index described below, which is not capped. What you must not do is read the node’s own array as the answer: a 50-entry list looks exactly like a complete one, and that is the single most common source of a wrong answer about an entity map.

properties keeps nine consistency-relevant properties and nothing else: name, url, logo, image, sameAs, telephone, email, address, description. Objects are flattened to one display string, and only image and sameAs stay arrays. A Product’s sku is not here; the page’s own JSON-LD still has it.

conflicts

Populated when two declarations of one entity disagree on a kept property, or on @type.

"conflicts": [
  {
    "property": "logo",
    "values": [
      { "value": "https://site/logo-a.png", "pages": ["https://site/"], "morePages": 0 },
      { "value": "https://site/logo-b.png", "pages": ["https://site/about"], "morePages": 0 }
    ]
  }
]

At least two entries, sorted by value, each capped at 10 pages. Conflict detection covers the nine kept properties plus @type, so a disagreement anywhere else is invisible here.

edges

One typed reference between two entities.

{
  "source": "id:https://docs.squirrelscan.com/",
  "predicate": "isPartOf",
  "target": "id:https://docs.squirrelscan.com/#website",
  "dangling": false,
  "occurrences": 1,
  "pages": ["https://docs.squirrelscan.com/"],
  "morePages": 0
}

dangling: true means no node in the map has target as its key: the site referenced an @id that nothing declares. That is the finding, not an error in the export.

pages are the pages making the reference, capped at 50 with the rest in morePages, the same as a node’s. A map the CLI reassembled from its store carries them too, except for an audit stored by squirrelscan 0.0.104 or earlier, which did not keep them: there pages is empty until the site is audited again or squirrel analyze re-runs over that audit.

Only a fixed set of predicates produces edges:

about, author, brand, creator, hasPart, image, isPartOf, itemListElement, itemReviewed, logo, mainEntity, mainEntityOfPage, member, mentions, offers, provider, publisher, review, sameAs, subjectOf, worksFor.

How a predicate’s value is treated depends on its shape, and the difference decides what can dangle:

ValueBecomesCan dangle
A nested object with an @typeIts own node, plus an edge to itNo
An explicit {"@id": "…"} referenceAn edge to that keyYes
A bare string, like a sameAs profile URLAn edge only if some page really declares a node under that @idNo

That last row is why a sameAs listing five social profiles does not produce five dangling references. A bare string is a value, not a promise. Only an explicit {"@id": …} claims that something exists, so only an explicit reference can be broken.

A property that is neither a followed predicate nor one of the nine the map keeps is discarded when the map is built. A Product’s sku is not stored anywhere in this document; the page’s own JSON-LD still has it.

pages

One row per successfully crawled page, including pages that declared nothing. 2xx only and keyed by the final URL, so a redirect is one row under its destination and a 404’s markup is not part of the graph at all.

{
  "url": "https://docs.squirrelscan.com/",
  "declares": [
    "id:https://docs.squirrelscan.com/",
    "id:https://docs.squirrelscan.com/#article",
    "id:https://docs.squirrelscan.com/#breadcrumb",
    "id:https://docs.squirrelscan.com/#website",
    "id:https://squirrelscan.com/#logo",
    "id:https://squirrelscan.com/#organization"
  ],
  "references": [],
  "entityCount": 6
}

This array is the authoritative page index, and it is what a comparison uses to tell “this entity is gone” from “that page was not crawled again”. A node’s own pages is a 50-entry sample; this is not.

It is also the largest array in the document, and it is empty on both truncated copies: neither the published map nor the HTML viewer reads it, and on a 5,000-page site it alone is close to 2MB. summary.pagesTotal and summary.pagesWithoutEntities carry what those copies need. A comparison therefore has a real page index only when both sides come from squirrel entities -f json.

references is empty in a map the CLI reassembled from its store, because the store does not keep per-page reference lists. Empty here means not recorded, not “nothing matched”.

The JSON-LD export

squirrel entities -f jsonld re-emits the collapsed graph as something a validator will read.

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@id": "https://squirrelscan.com/#organization",
      "@type": "Organization",
      "name": "squirrelscan",
      "url": "https://squirrelscan.com/",
      "logo": "https://docs.squirrelscan.com/logo/light.svg",
      "sameAs": ["https://github.com/squirrelscan"],
      "publisher": { "@id": "https://docs.squirrelscan.com/#website" }
    }
  ]
}

Three things to know before you trust it as a picture of the site.

Every member has an @id, including the ones that did not. An entity the site left anonymous is given <site>#entity-<slug>-<hash>, because a @graph member with no identifier is not much use in a validator. JSON-LD has nowhere to mark an identifier as invented, so the surfaces that hand you this export list them separately: the get_entity_graph tool returns generatedIds with the entity key and the placeholder it used. If you are looking at this export to answer “are this site’s entities identified”, read that list first, because the export’s own answer is always yes.

A single @type is emitted as a string, several as an array. "@type": "Organization" and "@type": ["Organization", "LocalBusiness"] are both produced.

Edges become {"@id": …} references, and a dangling target is emitted too, so the export reproduces a broken reference rather than quietly dropping it. Its identifier is the resolved one rather than the site’s raw spelling, and a dangling blank node keeps its page-scoped key. sameAs is the exception: it stays the plain string array the site declared rather than becoming reference objects.

The change set

squirrel entities --diff -f json compares two maps.

{
  "format": "squirrelscan/entity-map-diff",
  "version": 1,
  "generatedAt": "2026-09-13T06:00:00.000Z",
  "older": { "site": "…", "generatedAt": "…", "nodeCount": 170, "pagesTotal": 60 },
  "newer": { "site": "…", "generatedAt": "…", "nodeCount": 183, "pagesTotal": 60 },
  "added": [],
  "removed": [],
  "notCrawled": [],
  "gainedId": [],
  "lostId": [],
  "occurrenceDeltas": [],
  "newConflicts": [],
  "resolvedConflicts": [],
  "newDangling": [],
  "resolvedDangling": [],
  "summaryDelta": { },
  "pagesOnlyInOlder": [],
  "pagesOnlyInNewer": []
}

Every list is sorted by a stable key, so two runs over the same pair produce identical bytes apart from generatedAt.

removed versus notCrawled

The distinction the whole document is built around. Two audits of a site rarely cover the same pages, and a naive set difference reports every entity on a page that simply was not visited as removed, which reads as “the site deleted its structured data”.

An entity is removed only when every page that declared it was crawled again. Everything else is notCrawled. The test is conservative in one direction only: it can call a genuinely removed entity “not crawled”, never the reverse.

The same rule governs resolvedConflicts and resolvedDangling. A conflict needs two declarations to exist, so seeing one of them again tells you nothing.

gainedId and lostId

An entity that gains an @id changes key, because the key is the @id when there is one. Reported as one transition rather than as an addition plus a removal:

{
  "beforeKey": "syn:Organization|name:acme",
  "afterKey": "id:https://example.com/#org",
  "types": ["Organization"],
  "name": "Acme",
  "id": "https://example.com/#org",
  "coverage": "proven"
}

Matching is on the sorted type set plus the normalized name, one-to-one. An ambiguous group, two identical-looking candidates on either side, stays unmatched and falls through to added and removed, which says less but says nothing false.

coverage is the field that says whether the change describes the whole site. proven requires both that every page which declared the old version was crawled again and that the replacement was found on all of them. partial means one of those could not be established. Both, because either alone is satisfied by a site that is not fixed: re-auditing the broken pages proves nothing if the markup was deleted there and an identified entity turned up elsewhere.

A missing entry is not proof of failure. The match needs the type and name unchanged, so changing the @id and the name in one edit appears as a removal plus an addition.

occurrenceDeltas

{
  "key": "id:https://example.com/#org",
  "name": "Acme",
  "types": ["Organization"],
  "before": 58,
  "after": 60,
  "delta": 2
}

Reported only for entities present in both maps under the same key, and only when |delta| reaches the threshold, which is 1 by default. Raise it on a site that publishes constantly, where a site-wide entity moves by one on every audit.

summaryDelta

Every numeric summary metric as { before, after, delta }, keyed by the summary’s own field names. countsByType is excluded, being a map rather than a number.

pagesOnlyInOlder and pagesOnlyInNewer

The pages each audit saw and the other did not, sorted. This is the evidence behind notCrawled: if you are checking whether a fix landed and notCrawled is non-empty, these say which pages to re-audit.

Both lists are a 25-entry sample, with morePagesOnlyInOlder and morePagesOnlyInNewer counting the rest. Two audits of a 5,000-page site that happened to cover different pages would otherwise put 10,000 URLs in one document. Add the count to the list length for the real size of the coverage gap; the lists themselves are sorted, so which 25 you get is decided by the URLs and not by the order the audits finished.

References

Type to search…

↑↓ navigate↵ openesc close