×
Startseite RSS-Feed Info
Schema.org Typ WebSite und BlogPosting - strukturierte Daten für eine bessere SEO meiner Hugo Website.

Hugo - Strukturierte Schema Daten für eine bessere SEO

Hugo - Strukturierte Schema Daten für eine bessere SEO

Strukturierte Daten ermöglichen Suchmaschinen die Daten auf der Website besser zu verstehen und dadurch die Suchergebnisse für die Nutzer ansprechender darzustellen. Dieser Beitrag erklärt mein Hugo Partial für die Schema.org Typen WebSite und BlogPosting. Mit i18n Einträgen, Erklärung des Sourcecodes und Testen der JSON-LD Struktur.

Beitrag aktualisiert am 03.06.2023

Ich habe diesen Beitrag etwas überarbeitet und an die Hugo-Änderungen der Version 0.112 angepasst.

Allgemeine Informationen zu strukturierten Daten

Google stellt für Entwickler gute Informationen zur Verfügung. Nachfolgend einige Links zum Thema:

Die von Google, Microsoft, Yahoo und Yandex gegründeten Schema.org-Vokabularien für strukturierte Daten werden in einem offenen Gemeinschaftsprozess entwickelt und von den Suchmaschinen entsprechend berücksichtigt.

Bedanken möchte ich mich bei Ashish Lahoti. Sein Blogbeitrag - Add Structure Data JSON-LD in Hugo Website Pages - hat mir beim programmieren meines Partial sehr geholfen.

Relevante hugo.toml Einträge

Nachfolgend die im Schema benutzten Einträge in der Konfigurationsdatei von Hugo:

[author]
  name = "Frank Kunert"
  license = "Frank Kunert"
..
[languages]
  [languages.de]
    languageName = "Deutsch"
    weight = 1
    title = "Hugo, Webdev, SEO, Tools"
    [languages.de.params]
      description = "Blog über Hugo, Webdev, SEO, Tools"
      mydomain = "tekki-tipps.de 🇩🇪"
    ..
  [languages.en]
    languageName = "English"
    weight = 2
    title = "Hugo, Webdev, SEO, Tools"
    [languages.en.params]
      description = "Blog about Hugo, Webdev, SEO, Tools"
      mydomain = "tekki-tipps.de/en/ 🇬🇧"
    ..

Die Einträge in der hugo.toml wurden von mir nachträglich an die Hugo-Version 0.112 angepasst.

Relevante Front Matter Einträge

Folgende Front Matter Einträge werden im Partial site-schema.html verwendet:

title: "Hugo - Strukturierte Schema Daten für eine bessere SEO"
description: "Hugo Partial für die Schema.org Typen WebSite und Blog mit i18n Einträgen. Erklärung des Sourcecodes und Testen der JSON-LD Struktur. Für eine bessere SEO."
subtitle: "Schema.org Typ WebSite und Blog - strukturierte Daten für eine bessere SEO meiner Hugo Website."
author: "Frank Kunert"

Partial site-schema.html

Das Partial wird im head Tag der baseof.html eingebunden:

<!DOCTYPE html>
<html lang="{{- .Site.Language.Lang -}}">
  <head>
    <meta charset="UTF-8">
    ...
    ...
    {{ partial "site-schema.html" . }}
  </head>
  <body>
  </body>
</html>

Ein Partial für verschiedene Bereiche der Website:

  • Startseite inklusive der Pagination Seiten - page/2, page/3 …
  • Tag-Listen inklusive der Pagination Seiten - page/2, page/3 …
  • Blog Webseiten

Der Sourcecode von themes/tekki/layouts/partials/site-schema.html sieht wie folgt aus:

{{ if or ( .IsHome ) ( not .Description ) -}}
  <script type="application/ld+json">
  {
    "@context": "http://schema.org",
    "@type": "WebSite",
    "name": "{{ .Site.Params.mydomain }}",
    "url": "{{ printf "%s%s" .Site.BaseURL (substr .Paginator.URL 1) }}",
    {{ if .IsHome }}
      "description": "{{ .Site.Params.description }}",
    {{ else }}
      {{ if eq .Site.Language.Lang "de" }}
        {{ $u := urls.Parse .Permalink }}
        {{ $u := strings.TrimRight "/" $u.Path }}
        {{ $u := substr $u 6 }}
        "description": "Tag {{ $u }} - {{ .Site.Params.description }}",
      {{ else }}
        {{ $u := urls.Parse .Permalink }}
        {{ $u := strings.TrimRight "/" $u.Path }}
        {{ $u := substr $u 9 }}
        "description": "Tag {{ $u }} - {{ .Site.Params.description }}",
      {{ end }}
    {{ end }}
    "inLanguage": {{ if eq .Site.Language.Lang "de" }}{{"de"}}{{ else }}{{"en"}}{{ end }},
    "thumbnailUrl": "{{ "/icon-144.png" | absURL }}",
    "image": {
      "@type": "ImageObject",
      "@id": "{{ "/tekki-tipps-og.png" | absURL }}",
      "url": "{{ "/tekki-tipps-og.png" | absURL }}",
      "height": "630",
      "width": "1200"
    },
    "license": "{{ .Site.Author.license }}"
  }
  </script>
{{ else if .IsPage }}
  {{ if eq .Section "blog" }}
    {{ $author :=  or (.Params.author) (.Site.Author.name) }}
    {{ $org_name :=  .Site.Params.mydomain }}
    <script type="application/ld+json">
    {
      "@context": "http://schema.org",
      "@type": "BlogPosting",
      "articleSection": "{{ .Section }}",
      "name": "{{ .Title | safeJS }}",
      "headline": "{{ .Title | safeJS }}",
      "alternativeHeadline": "{{ .Params.subtitle }}",
      "description": "{{if .IsPage}}{{ .Summary | markdownify }}{{ else }}{{ if .Description }}{{ .Description | safeJS }}{{ end }}{{ end }}",
      "inLanguage": {{ if eq .Site.Language.Lang "de" }}{{"de"}}{{ else }}{{"en"}}{{ end }},
      "isFamilyFriendly": "true",
      "mainEntityOfPage": {
        "@type": "WebPage",
        "@id": "{{ .Permalink }}"
      },
      "author" : {
        "@type": "Person",
        "name": "{{ $author }}",
        {{ with .Site.GetPage "/ueber-mich" -}}"url": "{{- .Permalink -}}"{{ end }}
      },
      "creator" : {
      "@type": "Person",
        "name": "{{ $author }}"
      },
      "accountablePerson" : {
        "@type": "Person",
        "name": "{{ $author }}"
      },
      "copyrightHolder": "{{ $org_name }}",
      "copyrightYear": "{{ .Date.Format "2006" }}",
      "dateCreated": "{{ .Date.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
      "datePublished": "{{ .PublishDate.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
      "dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
      "publisher":{
        "@type":"Organization",
        "name": {{ $org_name }},
        "url": {{ .Permalink }},
        "logo": {
          "@type": "ImageObject",
          "url": "{{ "/icon-144.png" | absURL }}",
          "width": "144",
          "height": "144"
        }
      },
      {{ with .Resources.GetMatch "article-img" }}{{ $banner := $.Page.Resources.GetMatch "article-img" }}
        "image": {
          "@type": "ImageObject",
          "@id": "{{ $banner.Permalink }}",
          "url": "{{ $banner.Permalink }}",
          "height": "{{ $banner.Height }}",
          "width": "{{ $banner.Width}}"
        },
      {{ end }}
      "url": "{{ .Permalink }}",
      "wordCount": "{{ .WordCount }}",
      "genre": [ {{ range $index, $tag := .Params.tags }}{{ if $index }}, {{ end }}"{{ $tag }}" {{ end }}]
    }
    </script>
  {{ end }}
{{ end }}

Weiter unten teile ich den Sourcecode nach den einzelnen Bereichen auf und erkläre einige Dinge.

Startseite und Tag-Listen inkl. der Pagination Seiten - page/2 und folgende

{{ if or ( .IsHome ) ( not .Description ) -}}
  <script type="application/ld+json">
  {
    "@context": "http://schema.org",
    "@type": "WebSite",
    "name": "{{ .Site.Params.mydomain }}",
    "url": "{{ printf "%s%s" .Site.BaseURL (substr .Paginator.URL 1) }}",
    {{ if .IsHome }}
      "description": "{{ .Site.Params.description }}",
    {{ else }}
      {{/* Tag-Listen */}}
      {{ if eq .Site.Language.Lang "de" }}
        {{ $u := urls.Parse .Permalink }}
        {{ $u := strings.TrimRight "/" $u.Path }}
        {{ $u := substr $u 6 }}
        "description": "Tag {{ $u }} - {{ .Site.Params.description }}",
      {{ else }}
        {{ $u := urls.Parse .Permalink }}
        {{ $u := strings.TrimRight "/" $u.Path }}
        {{ $u := substr $u 9 }}
        "description": "Tag {{ $u }} - {{ .Site.Params.description }}",
      {{ end }}
    {{ end }}
    "inLanguage": {{ if eq .Site.Language.Lang "de" }}{{"de"}}{{ else }}{{"en"}}{{ end }},
    "thumbnailUrl": "{{ "/icon-144.png" | absURL }}",
    "image": {
      "@type": "ImageObject",
      "@id": "{{ "/tekki-tipps-og.png" | absURL }}",
      "url": "{{ "/tekki-tipps-og.png" | absURL }}",
      "height": "630",
      "width": "1200"
    },
    "license": "{{ .Site.Author.license }}"
  }
  </script>
  ..

In diesem Sourcecode Abschnitt ist auch der Code für die Tag-Listen enthalten. Der einzige Unterschied ist das Erstellen der "description". Aber eins nach dem anderen.

Mit {{ if or ( .IsHome ) ( not .Description ) -}} frage ich ab, ob die zu generierende Seite die Startseite oder Pagination Folgeseite der Startseite ist. Oder ob die Seite keine ( not .Description ) hat. Die Startseite hat auch keine Description, wird aber über .IsHome erkannt. Die Tag-Listen sind die einzigen Seiten auf meiner Website die ebenfalls keine Description haben. Mit diesem Trick kann ich auf die Tag-Listen zugreifen. Dieses Partial ist ja in der baseof.html eingebunden, also zu einem ganz frühen Zeitpunkt. Nur dort kann auf das head HTML-Tag zugegriffen und die json-Datei erstellt werden.

Bei der Zuweisung von "name": "{{ .Site.Params.mydomain }}", greife ich auf die hugo.toml und dort auf den Parameter mydomain zu. Je nach Sprache der aktuellen Webseite wird der Inhalt zugewiesen.

— UPDATE 03.06.2023
"url": "{{ printf “%s%s” .Site.BaseURL (substr .Paginator.URL 1) }}" weist die aktuelle URL zu, einschließlich der Folgeseitennummer, falls vorhanden. Die URL setzt sich aus den Komponenten .Site.BaseURL, die sich in der hugo.toml befindet, und .Paginator.URL zusammen. (substr .Paginator.URL 1) entfernt den führenden Backslash aus der .Paginator.URL. Andernfalls würde dieser doppelt vorkommen.
— Ende UPDATE 03.06.2023

Jetzt kommen wir zur description. Mit {{ if .IsHome }} frage ich wieder ab ob die aktuelle Webseite eine Start- oder Folgeseite der Startseite ist. Wenn ja, dann weise ich die sprachspezifische Description aus der hugo.toml zu.

Wenn nein, ist die aktuelle Webseite eine Tag-Liste oder Folgeseite einer Tag-Liste. Nun wird es etwas komplizierter. Was ich machen möchte ist, vor der allgemeinen Description aus der hugo.toml das Wort Tag und dann den Tag-Namen mit Bindestrich auszugeben. Dazu muss ich den Tag-Namen aus der URL herausoperieren. Da die URL bei übersetzten Seiten nach dem Muster /en/tags/<tag-name>/ aufgebaut ist muss ich dort 3 Zeichen mehr rausschneiden. Deshalb die Abfrage nach der Language. Mit Scratch hätte ich noch mehr Code produziert, auch deshalb die Code-Wiederholung.

{{ $u := urls.Parse .Permalink }} der Variablen $u wird mit der Hugo Funktion - urls.Parse - der .Permalink zugewiesen. {{ $u := strings.TrimRight “/” $u.Path }} schneidet den letzten / aus dem String. Und {{ $u := substr $u 6 }} schneidet die ersten 6 Zeichen in der deutschen Version ab. In der englischen Variante sind es 9 Zeichen. Der restliche Code ist selbsterklärend.

Test-Ergebnis für die Startseite und Tag-Liste

Die Website schema.org stellt einen - Schema Markup Validator - zur Verfügung. Das Test-Ergebnis für die Startseite sieht wie folgt aus:

Schema.org Test Typ Website
Schema.org - Test der Schema JSON-LD Daten vom Typ Website für die Startseite.

Für die Tag-Liste sieht das Ergebnis so aus:

Schema.org Test Typ Website Tag-List
Schema.org - Test der Schema JSON-LD Daten vom Typ Website für die Tag-Liste.

Blog Webseiten

Da ich nur meine Blog Webseiten den Suchmaschinen per Schema anbieten möchte, filter ich auch nur diese Seiten durch {{ if eq .Section "blog" }} raus. Datenschutz und Impressum benötigen meiner Meinung nach kein Schema.

..
{{ else if .IsPage }}
  {{ if eq .Section "blog" }}
    {{ $author := or (.Params.author) (.Site.Author.name) }}
    {{ $org_name := .Site.Params.mydomain }}
    <script type="application/ld+json">
    {
      "@context": "http://schema.org",
      "@type": "BlogPosting",
      "articleSection": "{{ .Section }}",
      "name": "{{ .Title | safeJS }}",
      "headline": "{{ .Title | safeJS }}",
      "alternativeHeadline": "{{ .Params.subtitle }}",
      "description": "{{if .IsPage}}{{ .Summary | markdownify }}{{ else }}{{ if .Description }}{{ .Description | safeJS }}{{ end }}{{ end }}",
      "inLanguage": {{ if eq .Site.Language.Lang "de" }}{{"de"}}{{ else }}{{"en"}}{{ end }},
      "isFamilyFriendly": "true",
      "mainEntityOfPage": {
        "@type": "WebPage",
        "@id": "{{ .Permalink }}"
      },
      "author" : {
        "@type": "Person",
        "name": "{{ $author }}",
        {{ with .Site.GetPage "/ueber-mich" -}}"url": "{{- .Permalink -}}"{{ end }}
      },
      "creator" : {
      "@type": "Person",
        "name": "{{ $author }}"
      },
      "accountablePerson" : {
        "@type": "Person",
        "name": "{{ $author }}"
      },
      "copyrightHolder": "{{ $org_name }}",
      "copyrightYear": "{{ .Date.Format "2006" }}",
      "dateCreated": "{{ .Date.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
      "datePublished": "{{ .PublishDate.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
      "dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
      "publisher":{
        "@type":"Organization",
        "name": {{ $org_name }},
        "url": {{ .Permalink }},
        "logo": {
          "@type": "ImageObject",
          "url": "{{ "/icon-144.png" | absURL }}",
          "width": "144",
          "height": "144"
        }
      },
      {{ with .Resources.GetMatch "article-img" }}{{ $banner := $.Page.Resources.GetMatch "article-img" }}
        "image": {
          "@type": "ImageObject",
          "@id": "{{ $banner.Permalink }}",
          "url": "{{ $banner.Permalink }}",
          "height": "{{ $banner.Height }}",
          "width": "{{ $banner.Width}}"
        },
      {{ end }}
      "url": "{{ .Permalink }}",
      "wordCount": "{{ .WordCount }}",
      "genre": [ {{ range $index, $tag := .Params.tags }}{{ if $index }}, {{ end }}"{{ $tag }}" {{ end }}]
    }
    </script>
  {{ end }}
{{ end }}

Da $author und $org_name an mehr als einer Stelle benötigt werden, habe ich dafür diese Variablen mit dem entsprechenden Inhalt gefüllt. Jeder Blog Beitrag hat bei mir einen Subtitel. Mit "alternativeHeadline": "{{ .Params.subtitle }}" weise ich den Subtitel dem Property zu.

Der Description weise ich mit "description": "{{if .IsPage}}{{ .Summary | markdownify }}{{ else }}{{ if .Description }}{{ .Description | safeJS }}{{ end }}{{ end }}" die Summary des Blog Beitrags zu. Mit | markdownify filter ich das p HTML-Tag raus. Falls keine Summary vorhanden - kommt bei mir nicht vor - wird die Description aus dem Front Matter der aktuellen Seite entnommen.

Das Test-Tool von Google hat mich bei dem Property author darauf hingewiesen, dass eine URL zu einer Webseite über den Autor hinzugefügt werden sollte. Aus diesem Grund habe ich meine "Ich über mich" Webseite auf dieser Site angegeben. Mit {{ with .Site.GetPage "/ueber-mich" -}}"url": "{{- .Permalink -}}"{{ end }} suche ich die Seite und füge deren Permalink hinzu. Dies funktioniert ebenfalls in der englische Version. "/ueber-mich" ist der Page Bundle Verzeichnisname meiner "Ich über mich" Webseite.

Meinen Blog Beiträgen weise ich im Page Bundle ein Bild zu. Dies hat immer den gleichen Resourcenamen. Mit {{ with .Resources.GetMatch "article-img" }}{{ $banner := $.Page.Resources.GetMatch "article-img" }} .. {{ end }} hole ich das Bild und weise es dem Property "image": zu.

Test-Ergebnis für die Blog Webseiten

Das Test-Ergebnis für die Blog Webseiten sieht so aus:

Schema.org Test Typ Blog
Schema.org - Test der Schema JSON-LD Daten vom Typ BlogPosting.

Test der JSON-LD Schema Daten

Neben dem schon weiter oben beschriebenen Schema.org Validator gibt es auch von Google einen entsprechenden Validator für Rich-Suchergebnisse. Schema.org prüft ob die Schemas eingehalten werden. Googles Test für Rich-Suchergebnisse prüft ob die zusätzlichen Kriterien von Google eingehalten werden.

Auch wenn auf der eigenen Website keine oder nur wenige Rich-Results möglich sind, werden den Suchmaschinen mit den Schemas eine zusätzliche Möglichkeit zur Verfügung gestellt, die Webseite besser zu verstehen. Ob sich das dann auch auf die Suchergebnis-Position auswirkt ist nicht garantiert.

Fazit

Auf den ersten Blick sieht der Partial Sourcecode etwas kompliziert aus, aber bei näherer Betrachtung ist dies gar nicht so schlimm. Die Properties für die einzelnen Schema.org Typen werden auf Schema.org ganz gut erklärt. Erste Auswirkungen habe ich in der Google Suche schon bemerkt. Es wird jetzt teilweise ein kleines Bild neben den Sucheinträgen meiner Blog Beiträge angezeigt.

Linkliste zu diesem Beitrag

Das könnte Sie auch interessieren

Update:  |
12 Minuten Lesezeit
0
Dieser Beitrag wurde mit der Hugo-Version 0.115.2 erstellt.

Kommentare werden bei deutscher Spracheinstellung nicht in der englischen Variante der Webseite angezeigt und umgekehrt.

© 2023 - Frank Kunert  -  Ich über mich