# SEOntology — full content (version 0.1.0) # SEOntology **SEOntology** is an open, Schema.org-aligned OWL 2 ontology for modeling search engine optimization work as a knowledge graph: web pages and their URLs, the queries they rank for, links and link groups, content chunks, search and analytics metrics, quality scores, and structured-data profiles. Agents, applications and researchers can use it as a shared vocabulary to reason about, audit and optimize web content. !!! success "Best Student Paper at SEMANTiCS 2026" The paper *SEOntology: A Domain Ontology for Semantic Modeling of Search Engine Optimization Workflows* by Emilija Gjorgjevska, David Riccitelli, Milos Jovanovik and Andrea Volpini won [Best Student Paper at SEMANTiCS 2026](https://2026-eu.semantics.cc/page/awards.html) (Research Track). ![SEOntology overview](assets/images/seontology-doodle.png) ## At a glance | | | |---|---| | Namespace | `https://w3id.org/seovoc/` | | Preferred prefix | `seovoc:` | | Builds on | [Schema.org](https://schema.org/) | | License | MIT | | Source | [github.com/seontology/seontology](https://github.com/seontology/seontology) | ## Start here - **New to SEOntology?** Read [Getting started](guide/getting-started.md) for a first example in JSON-LD, Turtle and SPARQL. - **Modeling your data?** The [core model](guide/core-model.md) explains how pages, queries, links, chunks and metrics fit together. The [ontology reference](https://seontology.github.io/seontology/reference/) documents every class and property. - **Building an agent or tool?** See [For agents](for-agents.md) for the machine-readable files: the ontology in Turtle, RDF/XML and JSON-LD, a JSON-LD context, and `llms.txt`. - **Proposing a change?** Follow [Contributing ontology proposals](contributing-ontology-proposals.md). ## Use cases - AI-powered content summarization and metadata generation - Semantic internal linking via chunk and query similarity - Entity linking and disambiguation in web content - Graph-based internal linking optimization - Query clustering and intent modeling for content strategy - Training goal-relevant reasoning models using SEOntology and GRPO - Provenance tracking for SEO actions and content evolution - Multilingual SEO alignment and semantic consistency - SERP feature optimization for enhanced search visibility - Automated image captioning and SEO optimization ## Sponsor SEOntology was initially developed by [WordLift](https://wordlift.io/) and is enriched by contributions from SEO experts, knowledge engineers and developers. # Getting started SEOntology terms live in the namespace `https://w3id.org/seovoc/`, usually written with the prefix `seovoc:`. A term IRI such as `https://w3id.org/seovoc/WebPage` resolves to its documentation in a browser and to the ontology file for RDF clients. ## Get the ontology | Format | File | |---|---| | Turtle | [`seovoc.ttl`](https://seontology.github.io/seontology/seovoc.ttl) | | RDF/XML | [`seovoc.owl`](https://seontology.github.io/seontology/seovoc.owl) | | JSON-LD | [`seovoc.jsonld`](https://seontology.github.io/seontology/seovoc.jsonld) | | JSON-LD context | [`context.jsonld`](https://seontology.github.io/seontology/context.jsonld) | You can also open the ontology in [Protégé](https://protege.stanford.edu/) or explore it visually with [WebVOWL](https://service.tib.eu/webvowl/). ## A first example in JSON-LD The example below describes one page: its canonical URL, 28-day Search Console metrics, a query it ranks for, a content chunk, and two link groups: footer boilerplate and an editorial link whose destination is a page in the same graph. Every `Link` states its href with `linkTargetUrl`; `linkTarget` adds the resolved destination node once you have one. The SEOntology JSON-LD context maps short names such as `hasQuery` to their `seovoc:` IRIs and gives numeric metrics their XSD datatypes. ```json { "@context": "https://w3id.org/seovoc/context.jsonld", "@id": "https://example.com/blue-widgets/", "@type": "WebPage", "title": "Blue widgets", "metaDescription": "Compare our range of blue widgets.", "hasURL": { "@id": "https://example.com/blue-widgets/#url", "@type": "URL", "value": "https://example.com/blue-widgets/" }, "clicks28Days": 1240, "impressions28Days": 38110, "hasQuery": [ { "@id": "https://example.com/queries/blue-widgets", "@type": "Query", "name": "blue widgets", "clicks28Days": 320, "averagePosition28Days": 3.4 } ], "hasChunk": [ { "@type": "Chunk", "chunkPosition": 1, "chunkText": "Blue widgets come in three sizes..." } ], "hasLinkGroup": [ { "@type": "LinkGroup", "linkGroupName": "Footer links", "linkGroupType": "footer", "hasLink": [ { "@type": "Link", "linkText": "About us", "linkTargetUrl": "https://example.com/about/" } ] }, { "@type": "LinkGroup", "linkGroupName": "Related guides", "linkGroupType": "body", "hasLink": [ { "@type": "Link", "position": 1, "linkText": { "@value": "how to choose a widget", "@language": "en" }, "linkTargetUrl": "https://example.com/guides/choosing-widgets/", "linkTarget": "https://example.com/guides/choosing-widgets/" } ] } ] } ``` !!! note "Mixing with Schema.org" The context defines SEOntology terms only. Several of them (`name`, `about`, `mentions`, `text`) share a local name with Schema.org properties, and with this context they map to `seovoc:`. To use Schema.org properties in the same document, write them with a prefix, for example `"schema:headline"`. The context declares the `schema:` prefix. ## The same data in Turtle ```turtle @prefix seovoc: . @prefix xsd: . a seovoc:WebPage ; seovoc:title "Blue widgets" ; seovoc:metaDescription "Compare our range of blue widgets." ; seovoc:hasURL ; seovoc:clicks28Days "1240"^^xsd:long ; seovoc:impressions28Days "38110"^^xsd:long ; seovoc:hasQuery ; seovoc:hasChunk [ a seovoc:Chunk ; seovoc:chunkPosition "1"^^xsd:int ; seovoc:chunkText "Blue widgets come in three sizes..." ] ; seovoc:hasLinkGroup [ a seovoc:LinkGroup ; seovoc:linkGroupName "Footer links" ; seovoc:linkGroupType "footer" ; seovoc:hasLink [ a seovoc:Link ; seovoc:linkText "About us" ; seovoc:linkTargetUrl "https://example.com/about/" ] ] , [ a seovoc:LinkGroup ; seovoc:linkGroupName "Related guides" ; seovoc:linkGroupType "body" ; seovoc:hasLink [ a seovoc:Link ; seovoc:position 1 ; seovoc:linkText "how to choose a widget"@en ; seovoc:linkTargetUrl "https://example.com/guides/choosing-widgets/" ; seovoc:linkTarget ] ] . a seovoc:URL ; seovoc:value "https://example.com/blue-widgets/" . a seovoc:Query ; seovoc:name "blue widgets" ; seovoc:clicks28Days "320"^^xsd:long ; seovoc:averagePosition28Days "3.4"^^xsd:double . ``` ## Query it with SPARQL Find pages and the queries they rank for, ordered by the query's 28-day clicks: ```sparql PREFIX seovoc: SELECT ?page ?title ?query ?clicks ?position WHERE { ?page a seovoc:WebPage ; seovoc:title ?title ; seovoc:hasQuery ?q . ?q seovoc:name ?query ; seovoc:clicks28Days ?clicks . OPTIONAL { ?q seovoc:averagePosition28Days ?position } } ORDER BY DESC(?clicks) ``` Find the pages that link to a destination, with their anchor text and placement, excluding boilerplate navigation. The query joins on the `linkTargetUrl` string, so it also finds links whose destination was never resolved to a node: ```sparql PREFIX seovoc: SELECT ?page ?text ?placement WHERE { ?link seovoc:linkTargetUrl "https://example.com/guides/choosing-widgets/" . OPTIONAL { ?link seovoc:linkText ?text } { ?page seovoc:hasLinkGroup ?group . ?group seovoc:hasLink ?link . OPTIONAL { ?group seovoc:linkGroupType ?placement } } UNION { ?page seovoc:link ?link . OPTIONAL { ?link seovoc:linkLocation ?placement } } FILTER (!BOUND(?placement) || ?placement NOT IN ("header", "footer", "navigation")) } ``` ## Next steps - Read the [core model](core-model.md) to see how the main classes relate. - Look up any term in the [ontology reference](https://seontology.github.io/seontology/reference/). - See the [structured data profiles](../structured-data-profiles.md) for a complete worked example with SHACL shapes. # Core model SEOntology is centered on the **`WebPage`**: the pages a site publishes, the **queries** they rank for, the **links** between them, the **content** they contain, and the **measurements** taken about them. This page groups the main terms by area. The [ontology reference](https://seontology.github.io/seontology/reference/) lists every term with its domain, range and description. ## Pages and site structure | Term | Meaning | |---|---| | `WebPage` | A specific web page; extends Schema.org's `WebPage`. | | `URL` | The page's URL. `hasURL` / `isURLOf` binds a page to its canonical URL. | | `Domain` | A grouping of pages by domain or subdomain. | | `DirectorySegment` | A logical site segment derived from URL paths. | | `PageType` | A reusable classification of a page's purpose (for example product detail or editorial), assigned with `hasPageType`. | | `PageGroup` | An A/B-testing cohort, such as a control or test group. Use `PageType` for functional classifications. | | `Language` | The language of content, for multilingual SEO. | Page-level text properties include `title`, `metaDescription`, `bodyText`, `urlSlug` and `slugNgram`. ## Queries and intent | Term | Meaning | |---|---| | `Query` | A search query a page ranks for, usually from Google Search Console. `hasQuery` / `isQueryOf` link pages and queries. | | `hasPrimaryQuery` | The primary target query of a `WebPage` or `Chunk`. | | `QueryCluster` | A set of related queries grouped for content planning and gap analysis. Members are linked with `hasClusterQuery` / `isClusterQueryOf`. | | `ClusteringStrategy` | The method or model used to build a cluster (`clusteredBy`). | | `Intent` | Search intent, linked with `hasIntent`; a cluster's dominant intent uses `dominantSearchIntent`. | | `EntityGap` | A missing or unlinked entity relevant to a query (`hasEntityGap`). | | `Persona` | The target audience archetype of a page (`hasPersona`). | Query classification properties include `queryType`, `keywordType`, `queryCategory` and `intent`. Cluster metrics include `clusterVolume` and `keywordsCount`. ## Links Each hyperlink on a page is a **`Link`**. A page reaches its links in one of two ways: - **Grouped**: **`hasLinkGroup`** from the `WebPage` to a **`LinkGroup`**, then **`hasLink`** from the group to each `Link`. This is the canonical pattern for navigation, footers, related-content blocks and other clusters of links. - **Ungrouped**: the direct **`link`** property from `WebPage` to `Link`, for raw extracted links or a single inline link in body text. A `LinkGroup` carries `linkGroupName`, free text for display ("Footer links"), `linkGroupType`, the machine-checkable placement (`header`, `footer`, `body`, `sidebar` or `navigation`), and `linkGroupID`. A `Link` describes one hyperlink and where it points: | Term | Meaning | |---|---| | `linkTargetUrl` | The absolute href, as a string. Always set it: it is the destination fact you have from parsing the markup alone. | | `linkTarget` | The resolved destination node: a `WebPage` in the graph, a `URL` node, or the entity the destination represents. Set it only once the destination has been resolved. Its inverse is `isLinkTargetOf`. | | `linkText` | The anchor text. Accepts language-tagged literals. | | `linkRel` | One `rel` token per value: `nofollow`, `sponsored`, `ugc`, `noopener`, and so on. | | `linkLocation` | Placement of an ungrouped link, with the same values as `linkGroupType`. When a `LinkGroup` exists, its `linkGroupType` takes precedence. | | `position` | Order of the link within its group. | Internal versus external is not a property of the link. Compare domains instead: a link is internal when its `linkTarget` page shares a `Domain` (`hasDomain`) with the source page, or, for destinations that were not crawled, when the `linkTargetUrl` host matches the source page's `domainName`. `anchorResource`, `linkgroupID`, `noFollow`, `linkType` and the `AnchorText` class are deprecated. See [Compatibility notes](compatibility.md#links). The [Links](links.md) guide explains the model in depth, with worked examples and queries. ## Content and entities | Term | Meaning | |---|---| | `Chunk` | A self-contained section of a page, used for vector search and structured representation. Linked with `hasChunk` / `isChunkOf`; described by `chunkText`, `chunkPosition`, `tokenCount` and related properties. | | `Topic` | The central subject of a page. | | `schema:Thing` | Any entity. `about` links a page to its main entity and `mentions` to entities it mentions; both range over Schema.org's `Thing`. | | `ImageObject` | An image and its SEO metadata, with `aiGeneratedCaption` and `captionQualityScore`. | | `Model` | An AI or ML model used for summarization, classification, scoring or captioning. | Embedding inputs and outputs are recorded with `embeddingText` and `embeddingValue`. ## Search and analytics metrics - **Search Console**: `clicks`, `impressions` and `ctr`, plus windowed variants for 7 days, 28 days, 3 months and 6 months (for example `clicks28Days`, `impressions3Months`, `ctr6Months`, `averagePosition28Days`). Windowed metrics can be attached to a `Query` or a `WebPage`. - **GA4**: `users`, `sessions`, `engagedSessions`, `engagementRate`, `averageEngagementTime`, `conversions`, `trafficSource` and `landingPage`, interpreted in the measurement context of the dataset that produced them. - **Traffic sources**: a `TrafficSource` is a source as reported by analytics (`google`, `(direct)`, `chatgpt.com`). `hasTrafficChannel` assigns it to a `TrafficChannel` such as organic search, referral or AI assistant, so AI-assistant referrals can be told apart from generic referrals. - **Forecasts**: `forecastedTraffic`. ## Quality and evaluation Link a `WebPage` to a **`QualityScore`** with `hasQualityScore`, or a `Chunk` with `hasChunkQualityScore`, to record assessment results such as `contentAccuracyScore`, `contentDepthScore`, `seoScore` and `readabilityScore`. `evaluationID` identifies an evaluation run. `isThinContent` is a boolean thin-content classification. ## Structured data `Schema` represents the structured-data markup of a page (`hasSchemaMarkup` / `describesPage`). `StructuredDataProfile` describes a structured-data policy aligned with W3C PROF. Profiles can be applicable to a page type, selected for a page, or used to generate markup; these are separate states. See [Structured data profiles](../structured-data-profiles.md). # Links A **`Link`** is one hyperlink found on a `WebPage`: an `` element, with its anchor text, its `rel` tokens, its place in the layout and its destination. With destinations recorded, the links of a site form a graph, and the usual internal-linking work becomes a query over that graph: - Which pages link to a given page, with what anchor text, and from which part of the layout? - Which of those anchors are editorial, and which are site-wide navigation or footer boilerplate? - Which internal links point at pages that return an error? - Which internal links carry `nofollow`? !!! tip "Short version" Emit one `Link` per ``, with `linkTargetUrl` (the href) and `linkText` (the anchor). Put navigation, footer and other blocks of links in a `LinkGroup` with a `linkGroupType`. Add `linkTarget` (the destination node) only once you have resolved the href to a page or entity. On this page, the **href** is the URL written in the markup, recorded with `linkTargetUrl`, and the **destination** is the node that href resolves to, recorded with `linkTarget`. ## The model at a glance ```mermaid flowchart TB P[WebPage] -- hasLinkGroup --> G[LinkGroup] G -- hasLink --> L[Link] P -- link --> L L -- linkTarget --> T[WebPage, URL or entity] ``` A page reaches its links in one of two ways: - **Grouped**: `hasLinkGroup` from the `WebPage` to a **`LinkGroup`**, then `hasLink` from the group to each `Link`. Use this for blocks of related links: the main navigation, the footer, a sidebar, a "Related insights" block. - **Ungrouped**: `link` straight from the `WebPage` to the `Link`. Use this for raw extracted links before any grouping pass, and for single inline links in body text. ### Link | Term | Value | Notes | |---|---|---| | `linkTargetUrl` | string | The href, absolute and resolved against the page's base URL. Always set it. | | `linkTarget` | node | The destination: a `WebPage`, a `URL` node or an entity. Set it only once resolved. Inverse: `isLinkTargetOf`. | | `linkText` | literal | The anchor text. May carry a language tag. | | `linkRel` | string | One `rel` token per value: `nofollow`, `sponsored`, `ugc`, `noopener`, and so on. | | `linkLocation` | placement | Placement of an ungrouped link. | | `position` | number | Order of the link within its group. | ### LinkGroup | Term | Value | Notes | |---|---|---| | `hasLink` | `Link` | Each link in the group. Inverse: `isLinkOf`. | | `linkGroupType` | placement | Placement of the whole group. Takes precedence over `linkLocation`. | | `linkGroupName` | string | Display name, for example "Footer links". | | `linkGroupID` | string | Identifier of the group. | ### WebPage | Term | Value | Notes | |---|---|---| | `hasLinkGroup` | `LinkGroup` | Grouped links. Inverse: `isLinkGroupOf`. | | `link` | `Link` | Ungrouped links. | **placement** is one of `header`, `footer`, `body`, `sidebar` or `navigation`. The [ontology reference](https://seontology.github.io/seontology/reference/) lists the formal domain and range of each term. ## Why it is modeled this way **Two properties for where a link points.** A parser knows the href, a string. A resolver knows, later, the node the href stands for: a page in the graph, a `URL` node, or an entity. OWL 2 DL does not allow one property to take both a string and a node, so there are two: `linkTargetUrl` for the href, always present, and `linkTarget` for the destination, present once resolved. Because `linkTarget` ranges over `schema:Thing`, one property covers every kind of destination, and a query narrows it with a type filter. **The href belongs to the link, not to a page identity.** Many links share one href, and a page can link to the same URL several times. `linkTargetUrl` is a plain value on the `Link`, so two links to the same href simply share a value. It is deliberately not a `URL` node via `hasURL`: that property is functional and inverse-functional, which makes the `URL` node a key for *page* identity. **Placement is a fixed set of values.** `linkGroupType` and `linkLocation` share one enumeration, so a consumer can filter out boilerplate without guessing at free text. When a link sits in a `LinkGroup`, the group's `linkGroupType` applies and `linkLocation` is redundant. `linkLocation` exists for ungrouped links, which have no group to carry their placement. **Internal or external is derived, not stored.** A link is internal when its source page and its destination share a `Domain`. Comparing domains handles subdomains and multi-domain brands, which a stored `internal` / `external` flag cannot, and it never goes stale when a site's domain setup changes. **`rel` is a list of tokens.** HTML allows several `rel` tokens on one link, for example `nofollow noopener`. `linkRel` holds one token per value, so `sponsored` and `ugc` are as easy to express as `nofollow`, and no separate boolean can contradict them. ## Examples Each example is shown in three syntaxes that produce the same RDF graph. Pick one and every example on the page switches to it. - **JSON-LD** is what most producers emit. - **YAML-LD** is JSON-LD written as YAML ([W3C Working Draft](https://www.w3.org/TR/yaml-ld-10/), media type `application/ld+yaml`). It allows comments, so **comments explaining each property appear in the YAML-LD tab**. - **Turtle** is what you load into a triple store. The JSON-LD and YAML-LD examples use the SEOntology JSON-LD context, `https://w3id.org/seovoc/context.jsonld`, which maps short names such as `linkTargetUrl` to their `seovoc:` IRIs. The Turtle examples set `@base `, so `` stands for `https://example.com/outlook/`. ### 1. Minimal link The least a crawler can say about a link, and enough to start building a link graph: the page, the anchor text and the href. The link sits inline in body text, so it is ungrouped and states its own placement with `linkLocation`. === "JSON-LD" ```json { "@context": "https://w3id.org/seovoc/context.jsonld", "@id": "https://example.com/outlook/", "@type": "WebPage", "link": { "@type": "Link", "linkText": "where wealth managers see 2026 risk", "linkTargetUrl": "https://example.com/wealth/", "linkLocation": "body" } } ``` === "YAML-LD" ```yaml "@context": https://w3id.org/seovoc/context.jsonld "@id": https://example.com/outlook/ "@type": WebPage # An inline link in body text, so it is ungrouped link: "@type": Link # The anchor text, as it appears on the page linkText: where wealth managers see 2026 risk # The href, made absolute against the page's URL linkTargetUrl: https://example.com/wealth/ # Placement, because there is no LinkGroup to carry it linkLocation: body ``` === "Turtle" ```turtle @base . @prefix seovoc: . a seovoc:WebPage ; seovoc:link [ a seovoc:Link ; seovoc:linkText "where wealth managers see 2026 risk" ; seovoc:linkTargetUrl "https://example.com/wealth/" ; seovoc:linkLocation "body" ] . ``` ### 2. Navigation group The main navigation of the same page, as a `LinkGroup`. The first link has been resolved to a page in the graph, so it carries `linkTarget` as well as `linkTargetUrl`, and the destination's `status` is a single lookup away. The second link points at a page that has not been crawled, so it has an href and no destination. === "JSON-LD" ```json { "@context": "https://w3id.org/seovoc/context.jsonld", "@graph": [ { "@id": "https://example.com/outlook/", "@type": "WebPage", "hasLinkGroup": { "@id": "https://example.com/outlook/#nav", "@type": "LinkGroup", "linkGroupID": "main-nav", "linkGroupName": "Main navigation", "linkGroupType": "navigation", "hasLink": [ { "@type": "Link", "position": 1, "linkText": "Wealth Management", "linkTargetUrl": "https://example.com/wealth/", "linkTarget": "https://example.com/wealth/" }, { "@type": "Link", "position": 2, "linkText": "Investment Banking", "linkTargetUrl": "https://example.com/banking/" } ] } }, { "@id": "https://example.com/wealth/", "@type": "WebPage", "status": 200 } ] } ``` === "YAML-LD" ```yaml "@context": https://w3id.org/seovoc/context.jsonld "@graph": - "@id": https://example.com/outlook/ "@type": WebPage # A block of links on this page hasLinkGroup: "@id": https://example.com/outlook/#nav "@type": LinkGroup # Identifier of the group, a string linkGroupID: main-nav # Display name, usually a heading or landmark label linkGroupName: Main navigation # Placement of every link in the group linkGroupType: navigation hasLink: - "@type": Link # Order within the group position: 1 linkText: Wealth Management linkTargetUrl: https://example.com/wealth/ # Resolved: the destination is a page in this graph linkTarget: https://example.com/wealth/ - "@type": Link position: 2 linkText: Investment Banking # Not crawled yet: an href, but no destination linkTargetUrl: https://example.com/banking/ # The destination page, in the same graph - "@id": https://example.com/wealth/ "@type": WebPage # HTTP status of the destination status: 200 ``` === "Turtle" ```turtle @base . @prefix seovoc: . @prefix xsd: . a seovoc:WebPage ; seovoc:hasLinkGroup . a seovoc:LinkGroup ; seovoc:linkGroupID "main-nav" ; seovoc:linkGroupName "Main navigation" ; seovoc:linkGroupType "navigation" ; seovoc:hasLink [ a seovoc:Link ; seovoc:position 1 ; seovoc:linkText "Wealth Management" ; seovoc:linkTargetUrl "https://example.com/wealth/" ; seovoc:linkTarget ] , [ a seovoc:Link ; seovoc:position 2 ; seovoc:linkText "Investment Banking" ; seovoc:linkTargetUrl "https://example.com/banking/" ] . a seovoc:WebPage ; seovoc:status "200"^^xsd:int . ``` ### 3. Two anchors, one target The same page linked twice from one page: from the navigation with a generic anchor, and from an editorial "Related insights" block with a descriptive one. The two links are separate `Link` nodes with the same `linkTargetUrl` value, which is how you compare the anchors that point at one page. The editorial anchor carries a language tag. === "JSON-LD" ```json { "@context": "https://w3id.org/seovoc/context.jsonld", "@id": "https://example.com/outlook/", "@type": "WebPage", "hasLinkGroup": [ { "@type": "LinkGroup", "linkGroupName": "Main navigation", "linkGroupType": "navigation", "hasLink": { "@type": "Link", "linkText": "Wealth Management", "linkTargetUrl": "https://example.com/wealth/" } }, { "@type": "LinkGroup", "linkGroupName": "Related insights", "linkGroupType": "body", "hasLink": { "@type": "Link", "linkText": { "@value": "where wealth managers see 2026 risk", "@language": "en" }, "linkTargetUrl": "https://example.com/wealth/" } } ] } ``` === "YAML-LD" ```yaml "@context": https://w3id.org/seovoc/context.jsonld "@id": https://example.com/outlook/ "@type": WebPage hasLinkGroup: # Boilerplate: repeated on every page of the site - "@type": LinkGroup linkGroupName: Main navigation linkGroupType: navigation hasLink: "@type": Link # Generic anchor, chosen for navigation linkText: Wealth Management linkTargetUrl: https://example.com/wealth/ # Editorial: specific to this page - "@type": LinkGroup linkGroupName: Related insights linkGroupType: body hasLink: "@type": Link # Descriptive anchor, with a language tag linkText: "@value": where wealth managers see 2026 risk "@language": en # The same href as the navigation link linkTargetUrl: https://example.com/wealth/ ``` === "Turtle" ```turtle @base . @prefix seovoc: . a seovoc:WebPage ; seovoc:hasLinkGroup [ a seovoc:LinkGroup ; seovoc:linkGroupName "Main navigation" ; seovoc:linkGroupType "navigation" ; seovoc:hasLink [ a seovoc:Link ; seovoc:linkText "Wealth Management" ; seovoc:linkTargetUrl "https://example.com/wealth/" ] ] , [ a seovoc:LinkGroup ; seovoc:linkGroupName "Related insights" ; seovoc:linkGroupType "body" ; seovoc:hasLink [ a seovoc:Link ; seovoc:linkText "where wealth managers see 2026 risk"@en ; seovoc:linkTargetUrl "https://example.com/wealth/" ] ] . ``` ### 4. Outbound link with rel A link to another site, with two `rel` tokens. `linkRel` takes one value per token, so the full list is kept: `nofollow`, `sponsored` and `ugc` are hints search engines read, and `noopener` matters for security audits. There is no `linkTarget`: the destination is outside the graph, so the href is all there is to record. === "JSON-LD" ```json { "@context": "https://w3id.org/seovoc/context.jsonld", "@id": "https://example.com/outlook/", "@type": "WebPage", "link": { "@type": "Link", "linkText": "the Federal Reserve's policy statement", "linkTargetUrl": "https://www.federalreserve.gov/monetarypolicy.htm", "linkLocation": "body", "linkRel": ["nofollow", "noopener"] } } ``` === "YAML-LD" ```yaml "@context": https://w3id.org/seovoc/context.jsonld "@id": https://example.com/outlook/ "@type": WebPage link: "@type": Link linkText: the Federal Reserve's policy statement # Another site: an href, and no destination node linkTargetUrl: https://www.federalreserve.gov/monetarypolicy.htm linkLocation: body # Every rel token in the markup, one value each linkRel: - nofollow - noopener ``` === "Turtle" ```turtle @base . @prefix seovoc: . a seovoc:WebPage ; seovoc:link [ a seovoc:Link ; seovoc:linkText "the Federal Reserve's policy statement" ; seovoc:linkTargetUrl "https://www.federalreserve.gov/monetarypolicy.htm" ; seovoc:linkLocation "body" ; seovoc:linkRel "nofollow" , "noopener" ] . ``` ### 5. Internal vs external Whether a link is internal is not stored on the link. Give pages a `Domain` with `hasDomain`: a link is internal when its destination page has the same `Domain` as the source page. For a destination that was never crawled, compare the host of its `linkTargetUrl` with the source page's `domainName`. === "JSON-LD" ```json { "@context": "https://w3id.org/seovoc/context.jsonld", "@graph": [ { "@id": "https://example.com/outlook/", "@type": "WebPage", "hasDomain": "https://example.com/#domain", "link": [ { "@type": "Link", "linkText": "where wealth managers see 2026 risk", "linkTargetUrl": "https://example.com/wealth/", "linkTarget": "https://example.com/wealth/" }, { "@type": "Link", "linkText": "the Federal Reserve's policy statement", "linkTargetUrl": "https://www.federalreserve.gov/monetarypolicy.htm" } ] }, { "@id": "https://example.com/wealth/", "@type": "WebPage", "hasDomain": "https://example.com/#domain" }, { "@id": "https://example.com/#domain", "@type": "Domain", "domainName": "example.com" } ] } ``` === "YAML-LD" ```yaml "@context": https://w3id.org/seovoc/context.jsonld "@graph": - "@id": https://example.com/outlook/ "@type": WebPage # The source page's domain hasDomain: https://example.com/#domain link: # Internal: the destination has the same Domain - "@type": Link linkText: where wealth managers see 2026 risk linkTargetUrl: https://example.com/wealth/ linkTarget: https://example.com/wealth/ # External: no destination, and the href host # differs from the source page's domainName - "@type": Link linkText: the Federal Reserve's policy statement linkTargetUrl: https://www.federalreserve.gov/monetarypolicy.htm - "@id": https://example.com/wealth/ "@type": WebPage # The same Domain node: this is what "internal" means hasDomain: https://example.com/#domain - "@id": https://example.com/#domain "@type": Domain # The host, for comparing with hrefs that were not crawled domainName: example.com ``` === "Turtle" ```turtle @base . @prefix seovoc: . a seovoc:WebPage ; seovoc:hasDomain <#domain> ; seovoc:link [ a seovoc:Link ; seovoc:linkText "where wealth managers see 2026 risk" ; seovoc:linkTargetUrl "https://example.com/wealth/" ; seovoc:linkTarget ] , [ a seovoc:Link ; seovoc:linkText "the Federal Reserve's policy statement" ; seovoc:linkTargetUrl "https://www.federalreserve.gov/monetarypolicy.htm" ] . a seovoc:WebPage ; seovoc:hasDomain <#domain> . <#domain> a seovoc:Domain ; seovoc:domainName "example.com" . ``` ## Querying the link graph === "Inbound links" Pages that link to a page, with anchor text and placement, excluding navigation and footer boilerplate. The query matches on the `linkTargetUrl` value, so it also finds links whose destination was never resolved, and it covers both grouped and ungrouped links. ```sparql PREFIX seovoc: SELECT ?page ?text ?placement WHERE { ?link seovoc:linkTargetUrl "https://example.com/wealth/" . OPTIONAL { ?link seovoc:linkText ?text } { ?page seovoc:hasLinkGroup ?group . ?group seovoc:hasLink ?link . OPTIONAL { ?group seovoc:linkGroupType ?placement } } UNION { ?page seovoc:link ?link . OPTIONAL { ?link seovoc:linkLocation ?placement } } FILTER (!BOUND(?placement) || ?placement NOT IN ("header", "footer", "navigation")) } ``` === "Broken links" Links whose destination returns an HTTP error. ```sparql PREFIX seovoc: SELECT ?page ?href ?status WHERE { { ?page seovoc:link ?link } UNION { ?page seovoc:hasLinkGroup/seovoc:hasLink ?link } ?link seovoc:linkTargetUrl ?href ; seovoc:linkTarget ?target . ?target seovoc:status ?status . FILTER (?status >= 400) } ``` === "Nofollow internal" Links that carry `nofollow` and point at a page on the source page's own domain. ```sparql PREFIX seovoc: SELECT ?page ?href WHERE { { ?page seovoc:link ?link } UNION { ?page seovoc:hasLinkGroup/seovoc:hasLink ?link } ?link seovoc:linkRel "nofollow" ; seovoc:linkTargetUrl ?href ; seovoc:linkTarget ?target . ?page seovoc:hasDomain ?domain . ?target seovoc:hasDomain ?domain . } ``` ## Guidance These recommendations follow Google's documentation on [crawlable links](https://developers.google.com/search/docs/crawling-indexing/links-crawlable) and [qualifying outbound links](https://developers.google.com/search/docs/crawling-indexing/qualify-outbound-links), and common practice in crawlers that audit links. !!! tip "Capturing links" - **Record `` elements.** They are the links search engines reliably follow. Elements that act as links only through JavaScript, and `javascript:` hrefs, are not links in this sense. - **Always set `linkTargetUrl`**, absolute and resolved against the page's base URL. Otherwise keep it as the page wrote it: do not replace it with the canonical URL or the URL after redirects. That belongs in `linkTarget`, which points at the destination's `WebPage` node. - **Set `linkText` to the anchor as it appears on the page.** Keep empty or generic anchors ("click here", "read more") as they are: finding them is part of an audit. - **Split `rel` into tokens**, one `linkRel` value each, lowercased. HTML separates tokens with spaces; split on commas as well, because some sites use them. - **Use `position` for document order within a group.** It supports analysis; it does not imply that the first link to a URL counts more, which Google has not documented. !!! tip "Placement" - **Group links that belong together** (navigation, footer, a related-content block) in a `LinkGroup` and set its `linkGroupType`. Do not create a `LinkGroup` for one inline link in body text: use `link` and `linkLocation`. - **Placement is a heuristic.** Crawlers classify it differently, from HTML landmarks such as `