• ✓ individuelle SEO-Beratung & -Betreuung
  • ✓ keine Abnahmeverpflichtung
  • ✓ zertifizierte SEO-Experten
  • ✓ transparente & faire Arbeitsweise
  • ✓ nachhaltige SEO-Strategien
JavaScript SEO: Pre- vs. Post-Rendering-Abgleich von Screaming-Frog-Crawls mit R

JavaScript SEO: Pre- vs. Post-Rendering-Abgleich von Screaming-Frog-Crawls mit R

Inhaltsverzeichnis

tl;dr

  • Der Blogbeitrag beschreibt ein Framework in Form eines R-Skripts, das ich zum Analysieren von Screaming-Frog-Crawls verwende.
  • Dazu wird die entsprechende Website mit aktiviertem und deaktiviertem JavaScript-Rendering gecrawlt.
  • Die Crawls werden dann in R eingelesen und um einige Metriken angereichert:
      • Indexierbarkeit von Seiten
      • Segmentierung der URLs
    • Google-Analytics-Daten (hier: PageViews)
    • Google-Search-Appearance-Daten (hier: Impressions, Clicks)
    • Interner PageRank der URLs
  • Interne Links werden in eine SQLite-Datenbank geschrieben.
  • Der gerenderte und der nicht gerenderte Crawl werden verglichen:
    • Welche URLs kommen nur in einem der beiden Crawls vor?
    • Für URLs, die in beiden Crawls vorkommen, wird überprüft, ob sich bspw. die Titles & Descriptions, die Wortzahl und die Crawl-Tiefe ändern.
  • Im Rahmen der anschließenden Analyse werden Diagramme generiert, die einen ersten Überblick zur Beantwortung der folgenden Fragen geben:
    • Wie ist der Anteil der (nicht) indexierbaren Seiten?
    • Liegen meine wichtigen Seiten hinsichtlich der Klicktiefe auf einer der vorderen Ebenen?
    • Welche Unterschiede bestehen auf meiner Website, wenn JavaScript aktiviert resp. deaktiviert ist?
    • Welche Verzeichnisse weisen die meisten URLs auf?
    • Welche Segmente weisen die meisten URLs auf?
    • Sind meine wichtigsten Seiten auch aus Nutzersicht die wichtigsten?


Der Screaming-Frog-Spider bietet von Haus aus bereits eine gute Übersicht über die wichtigsten Kennzahlen eines Crawls. Bei meinen Crawl-Analysen fehlen mir jedoch einige wichtige Metriken, anhand derer ich mir einen genaueren Einblick in den Crawl verschaffen kann. So fehlen bspw. Flag-Spalten, ob eine URL laut Meta-Robots-Tag indexierbar ist oder nicht oder ob die Seite einen selbstreferenzierenden Canonical-Tag aufweist. Außerdem weisen wir bei get:traction die URLs einer Website basierend auf bestimmten URL-Mustern immer Segmenten zu. Dadurch kann bei der Analyse einfacher auf bestimmte Bereiche des Crawls gefiltert und diese dann im Detail betrachtet werden. Exemplarisch ist hier eine grobe Segmentierung nach Seitentypen wie Übersichtsseiten bzw. Produktdetailseiten oder nach Bereichen wie Archiv, Blogbereich etc. zu nennen.
Möchte ich dann auch noch verschiedene Metriken miteinander kombinieren, um mit einer bestimmten Sicht auf die Daten zu sehen, führt im Rahmen meiner Analysen für mich daher kein Weg daran vorbei, die Daten aus dem Screaming Frog zu exportieren und aufzubereiten. Am Schluss habe ich dann die Möglichkeit, relativ einfach Diagramme zu plotten, die mir einen ersten Überblick über die Struktur der Website bieten. Ein Beispiel dafür ist das nachfolgende Diagramm, dass die Verteilung der Segmente auf die einzelnen Klicktiefen darstellt.
JavaScript SEO: Anzahl URLs je Klicktiefe ~ Segment 1
Dazu aber später mehr. Bevor wir loslegen, müssen die Daten aus dem SF exportiert und aufbereitet resp. angereichert werden.

Hier vorweg ein Hinweis:
Das nachfolgende Skript ist nur ein grobes Framework. Es bildet zwar die meisten Schritte ab, die ich bei der Crawl-Aufbereitung durchführe. Dennoch muss es häufig an den jeweiligen Crawl bzw. das Analyseziel angepasst werden. So ist es nicht immer nötig, einen Crawl mit und ohne Ausführung von JavaScript zu vergleichen. Oder es liegt kein Zugriff auf Google Analytics und / oder die Google Search Console vor, sodass die Daten nicht mit dem Crawl verbunden werden können. Die einzelnen Schritte des Frameworks sind daher eher als Module anzusehen, die fallweise miteinander kombiniert werden können.

Genug der Vorrede, legen wir los! 💪

Crawl-Aufbereitung und -Anreicherung

Screaming-Frog-Crawls starten & RStudio-Projekt anlegen

Zunächst wird die entsprechende Website gecrawlt, einmal mit aktiviertem und einmal mit deaktiviertem JavaScript-Rendering (Configuration → Spider → Rendering).
Währenddessen lege ich schon einmal ein neues Projekt in RStudio an, das die nachfolgende Struktur hat (s. Box). Im vorliegenden Beispiel habe ich mein Projekt _crawl_analyse genannt, befinde mich also im gleichnamigen Ordner. Innerhalb des Ordners lege ich folgende Ordner bzw. Dateien an:

_crawl_analyse
	├ _data
	│  ├ all_outlinks_render.csv
	│  ├ internal_all_no_render.csv
	│  └ internal_all_render.csv
	│
	├ _exports
	│
	├ 01_etl.R
	└ 02_crawl_analyse.Rmd

Im Ordner _data werden die drei SF-Exporte mit der angegebenen Benennung gespeichert. all_outlinks_render.csv enthält alle ausgehenden Links des gerenderten Crawls (Bulk Export → All Outlinks). internall_all_no_render.csv und internall_all_render.csv sind die Standard-Exporte, wenn man sich auf dem Reiter Internal befindet und dort den Export-Button drückt.
Der Ordner _exports ist aktuell noch leer. Hier werden im Laufe des Skripts DataFrames zwischengespeichert, um bspw. die aus GA oder der GSC via API heruntergeladenen Daten nicht erneut laden zu müssen, sollte das Skript zur Crawl-Aufbereitung erneut ausgeführt werden müssen.
01_etl.R ist das Skript zum Einlesen und Aufbereiten des Crawls. Hier steigen wir gleich ein.
02_crawl_analyse.Rmd ist das RMarkdown-Dokument, dass die Plots enthalten wird.

Download:
Die Ordnerstruktur und die Skripte könnt Ihr hier herunterladen.

Crawl-Aufbereitung

Liegen die Crawls ab, beginnt die Aufbereitung.
Als Erstes habe ich mir eine Funktion geschrieben, die einen Crawl unter Angabe des Pfades aufnimmt und ihn wie folgt transformiert:

  • Die Spaltennamen werden normalisiert, d. h. alles wird in Kleinschreibung umgewandelt, Leerzeichen werden durch Unterstriche ersetzt etc. Das ist ein reiner Komfortfaktor, um die Spalten des DataFrames leichter schreiben zu können. Nachdem man das dritte Mal crawl$´Canonical Link Element 1´ geschrieben hat, weiß man das zu schätzen. 😉
  • Die Spalte content_type wird hinzugefügt. Sie leitet sich aus der Spalte content ab, verdichtet allerdings die Angaben. So ist es mir in der Regel egal, welches Character-Encoding eine Seite aufweist. Daher werden text/html;charset=UTF-8, text/html;charset=UTF-16 etc. zu HTML verdichtet.
  • Die Spalte meta_index dient als Flag-Spalte. Ist die Seite entsprechend der Meta-Robots-Angabe indexierbar oder nicht?
  • Die Spalte meta_follow übernimmt die gleiche Funktion für die Follow-Anweisung.
  • Ebenfalls eine Flag-Spalte ist canonical_selfreferential, sprich: ob die Seite einen selbst- oder fremdreferenzierenden Canonical-Tag aufweist.
  • Zum Schluss wird basierend auf den Spalten meta_index und canonical_selfreferential überprüft, ob die Seite tatsächlich indexierbar ist oder nicht. So kann eine Seite zwar auf index stehen, durch einen fremdreferenzierenden Canonical-Tag aber zugunsten der kanonischen URL von der Indexierung ausgenommen sein.

Die Funtion wird auf die beiden Crawls internal_all_render.csv und internal_all_no_render.csv angewendet. Nach dem Durchlaufen der Funktion werden die beiden aufbereiteten Crawls als .rds-Dateien gespeichert. Der nachfolgende Skript-Abschnitt Segmentierung wird dann diese .rds verwenden. Gerade bei der Segmentierung muss ich häufig die segmentierten Crawls wieder verwerfen und die Segmente anpassen. Da spart es einfach Zeit, wenn die Crawls bereits aufbereitet sind, sodass sie den ersten Prozessschritt nicht erneut durchlaufen müssen.
In Gänze sieht der Abschnitt Crawl-Aufbereitung dann folgendermaßen aus:

# 01 Einlesen und Aufbereiten des Crawls -----------------------------------
library(tidyverse)
load_crawl <- function(path_to_crawl) {
	# Einlesen
	crawl <- read_csv(path_to_crawl, skip = 1)
	# Spaltennamen normalisieren
	crawl <- janitor::clean_names(crawl)
	# Content Type extrahieren
	cnt <- function(pattern) {
		return(str_detect(crawl$content, pattern))
	}
	pattern_content_type <- list(
		cnt("html") ~ "HTML",
		cnt("javascript") ~ "JavaScript",
		cnt("css") ~ "CSS",
		cnt("image") ~ "Image",
		cnt("pdf") ~ "PDF",
		cnt("flash") ~ "Flash",
		TRUE ~ "Other"
	)
	crawl <- crawl	%>%
		mutate(content_type = case_when(!!!pattern_content_type))
	# Meta-Robots extrahieren
	crawl <- crawl %>%
		mutate(meta_index = str_extract(meta_robots_1, regex("(no)?index", ignore_case = T)),
			meta_index = str_detect(meta_index, regex("^index", ignore_case = T)) | is.na(meta_index))
	crawl$meta_index[crawl$status_code != 200 | crawl$content_type != "HTML"] <- NA
	crawl <- crawl %>%
		mutate(meta_follow = str_extract(meta_robots_1, regex("(no)?follow", ignore_case = T)),
			meta_follow = str_detect(meta_follow, regex("^follow", ignore_case = T)) | is.na(meta_follow))
	crawl$meta_follow[crawl$status_code != 200 | crawl$content_type != "HTML"] <- NA
	# Canonical selbstreferenzierend?
	pattern_canonical_selfreferential <- list(
		crawl$address == crawl$canonical_link_element_1 | is.na(crawl$canonical_link_element_1) ~ TRUE,
		crawl$address != crawl$canonical_link_element_1 ~ FALSE
	)
	crawl <- crawl %>%
		mutate(canonical_selfreferential = case_when(!!!pattern_canonical_selfreferential))
	crawl$canonical_selfreferential[crawl$status_code != 200 | crawl$content_type != "HTML"] <- NA
	# Seite ist indexierbar?
	pattern_is_indexable <- list(
		crawl$meta_index == TRUE & crawl$canonical_selfreferential == TRUE ~ TRUE,
		crawl$meta_index == FALSE | crawl$canonical_selfreferential == FALSE ~ FALSE
	)
	crawl <- crawl %>%
		mutate(is_indexable = case_when(!!!pattern_is_indexable))
	# factors setzen
	crawl <- crawl %>%
		mutate_at(c("content",
			   "status_code",
			   "status",
			   "meta_robots_1",
			   "content_type",
			   "crawl_depth"),
			factor)
	return(crawl)
}
# Ausführen und Zwischenspeichern
crawl_render <- load_crawl("_data/internal_all_render.csv")
write_rds(crawl_render, "_exports/01_crawl_render.rds")
crawl_no_render <- load_crawl("_data/internal_all_no_render.csv")
write_rds(crawl_no_render, "_exports/01_crawl_no_render.rds")

Eine Detailanmerkung:
Die Funktion cnt() ist eine kleine Hilfsfunktion, um bei der Definition der Patterns zur Extraktion des Content-Types nicht jedes Mal str_detect(crawl$content, PATTERN) schreiben zu müssen.
Damit ist die Aufbereitung des Crawls geschafft. Die sich anschließenden Schritte ergänzen die Crawl-Daten um weitere Metriken. Als Erstes folgt die Segmentierung der URLs.

URL-Segmentierung

In der Regel segmentieren wir bei get:traction URLs basierend auf URL-Mustern…ist auch sinnvoll. Denkbar ist aber auch eine Segmentierung basierend auf anderen Spalten wie bspw. dem Title oder der H1, wenn sie Informationen zur Segmentbildung enthalten. So ist nicht bei allen Websites sichergestellt, dass die Produkte in einem bestimmten Verzeichnis liegen, aber vielleicht weisen alle einen spezifischen Slug im Title auf, sodass sie daran identifiziert werden können. In solchen Fällen muss die Hilfsfunktion sgm() angepasst oder eine neue Funktion definiert werden.
Eine Segmentierung für unsere Website könnte dann wie folgt aussehen:

# 02 Segmentierung ---------------------------------------------------------
library(tidyverse)
# Einlesen
crawl_render <- read_rds("_exports/01_crawl_render.rds")
crawl_no_render <- read_rds("_exports/01_crawl_no_render.rds")
# TODO!!! HIER SEGMENTE DEFINIEREN! ========================================
seg_crawl <- function(crawl) {
	# Helper function zum Definieren der patterns
	# Ggf. anpassen, wenn nicht nur HTML segmentiert werden soll
	sgm <- function (pattern) {
		return(str_detect(crawl$address, pattern) & crawl$content_type == "HTML")
	}
	pattern_seg1 <- list(
		sgm("/$") ~ "Startseite",
		sgm("/ueber-uns") ~ "Über Uns",
		sgm("/get-seo-success") ~ "Get SEO Success",
		sgm("/get-seo-intelligence") ~ "Get SEO Intelligence",
		sgm("/blog") ~ "Blog"
		crawl$content_type == "HTML" ~ "Sonstige"
	)
	crawl <- crawl %>%
		mutate(segment_1 = case_when(!!!pattern_seg1))
	pattern_seg2 <- list(
		sgm("/ueber-uns/team") ~ "Team",
		sgm("/ueber-uns/standorte") ~ "Standorte",
		sgm("/ueber-uns/bvdw") ~ "BVDW",
		# usw.
		sgm("/get-seo-success/seo-im-relaunch") ~ "SEO im Relaunch",
		sgm("/get-seo-success/seo-maintenance") ~ "SEO Maintenance",
		sgm("/get-seo-success/") ~ "Sonstige",
		# usw.
		crawl$content_type == "HTML" ~ "Sonstige"
	)
	crawl <- crawl %>%
		mutate(segment_2 = case_when(!!!pattern_seg2))
}
# Segmentieren und Zwischenspeichern
crawl_no_render <- seg_crawl(crawl_no_render)
write_rds(crawl_no_render, "_exports/02_crawl_no_render_seg.rds")
crawl_render <- seg_crawl(crawl_render)
write_rds(crawl_render, "_exports/02_crawl_render_seg.rds")

segment_1 bildet im obigen Beispiel die erste Verzeichnisebene unserer Website ab – sie steht aber auch repräsentativ für die Themenfelder, die wir besetzen.
Das segment_2 ergibt bei uns jetzt ehrlich gesagt nicht mehr viel Sinn, denn die Patterns treffen immer nur genau eine URL. Innerhalb der Themenbereiche liegen nur noch eigenständige Seiten und keine weiteren Bereiche. Aber ich denke, als Beispiel wird klar, worum es geht.
Anhand des segment_1 kann ich in der späteren Analyse nur auf diesen Website-Bereich filtern. Fällt mir in diesem etwas auf, kann ich mittels segment_2 den Bereich noch weiter eingrenzen, um wiederrum nur einen Ausschnitt zu betrachten. Gerade bei der Suche nach nicht indexierbaren Bereichen oder Bereichen mit wenigen PageViews ist eine gute und vor allem detaillierte Segmentierung unabdingbar.

Richtig coole Einblicke bekommt man bei Websites von Verlagen, wenn man auf zwei Ebenen segmentiert: einmal auf der Ebene der Seitentypen und einmal auf der Ebene des Inhalts. Seitentypen sind dabei klassisch die Überssichtsseiten der einzelnen Ressorts und die Artikelseiten. Inhaltstypen sind die einzelnen Ressorts wie Politik, Wirtschaft etc. Werden die beiden Segmente in der Analyse miteinander kombiniert, sieht man sehr schnell, welche Artikel (Segment: Seitentyp) des Ressorts Wirtschaft (Segment: Inhaltstyp) in den letzten 12 Monaten weniger als 100 PageViews (Metrik: Google Analytics; die fügen wir im nächsten Schritt hinzu) generiert haben – und das mit drei Zeilen Code:

crawl %>%
   filter(segment_typ == "artikelseite", segment_inhalt == "wirtschaft", page_views <= 100) %>%
   View()

Die definierte Funktion seg_crawl() wird wieder auf die aus dem Zwischenspeicher geladenen Crawls angewendet und die segmentierten Crawls erneut gespeichert. Auch hier erneut der Hinweis, dass die Zwischenspeicherung nicht zwingend ist. Da ich aber durchaus dazu neige, sehr viele Segmente anzulegen, um später eine Vielzahl von Filteroptionen zu haben, kann so eine Segmentierung bei vielen URLs auch schon mal 15 bis 30 Minuten laufen.
Weiter geht’s mit der Abfrage der Google Analytics- und Google Search Console-API.

Google Analytics- und Google Search Console-API

Hier muss ich eigentlich nicht viel beschreiben. Zur Abfrage der Daten von den beiden APIs hat Mark Edmondson mit googleAnalyticsR und searchConsoleR zwei Libraries gebastelt, die einen annährend wunschlos zurücklassen (wie glücklich man sich als R-Nutzer mit diesen beiden Libraries schätzen kann, fällt einem spätestens auf, wenn man „mal eben“ die APIs mittels Python abfragen will. Danke, Mark! 🙇🙌).

# 03 Google Daten ----------------------------------------------------------
library(googleAnalyticsR)
library(googleAuthR)
library(searchConsoleR)
# Ggf. ID, Secret und Scope setzen
options(googleAuthR.client_id = "XXXX",
	googleAuthR.client_secret = "XXXX",
	googleAuthR.scopes.selected = c("https://www.googleapis.com/auth/analytics.readonly",
					"https://www.googleapis.com/auth/webmasters.readonly"))
# Authentifizierung
if (file.exists("sc.oauth")) {
	gar_auth(token = "sc.oauth")
} else {
	gar_auth(new_user = T)
}
# GA .......................................................................
## Daten abrufen
# TODO!!! DATENSICHT-ID DEFINIEREN =========================================
ga_accs <- ga_account_list()
view_id <- # Aus ga_accs entnehmen
# TODO!!! ZEITRAUM, DIMENSIONEN UND METRIKEN DEFINIEREN ====================
ga <- google_analytics(viewId = view_id,
						date_range = c("2017-06-01", "2018-06-02"),
						metrics = c("pageViews"),
						dimensions = c("pagePath"),
						anti_sample = TRUE)
# Zwischenspeichern
write_rds(ga, "_exports/ga.rds")
## Daten aggregieren
ga <- read_rds("_exports/ga.rds")
crawl <- read_rds("_exports/02_crawl_render_seg.rds")
# TODO!!! DOMAIN DEFINIEREN ================================================
domain <- "https://www.example.com"
ga_agg <- ga %>%
	group_by(pagePath) %>%
	summarise(page_views = sum(pageViews)) %>%
	mutate(pagePath = paste0(domain, pagePath)) %>%
	rename(address = pagePath)
crawl <- crawl %>%
	left_join(ga_agg, by = "address") %>%
	mutate(page_views = case_when(content_type == "HTML" & is.na(page_views) ~ 0,
									TRUE ~ page_views),
		is_active = case_when(content_type == "HTML" & page_views > 0 ~ TRUE,
								content_type == "HTML" & page_views < 1 ~ FALSE))
# Zwischenspeichern
write_rds(crawl, "_exports/03a_crawl_render_seg_ga.rds")
# GSA ......................................................................
## Daten abrufen
# TODO!!! PROPERTY DEFINIEREN ==============================================
gsa_props <- list_websites()
prop <- ""  # Aus gsc_props entnehmen
# TODO!!! ZEITRAUM, DIMENSIONEN, SEARCHTYPE DEFINIEREN =====================
gsa <- search_analytics(siteURL = prop,
						startDate = "2017-05-01",
						endDate = "2018-05-31",
						dimensions = c("date", "page"),
						searchType = "web",
						walk_data = "byDate")
# Zwischenspeichern
write_rds(gsa, "_exports/gsa.rds")
## Daten aggregieren
gsa <- read_rds("_exports/gsa.rds")
crawl <- read_rds("_exports/03a_crawl_render_seg_ga.rds")
gsa_agg <- gsa %>%
	group_by(page) %>%
	summarise(clicks = sum(clicks),
			impressions = sum(impressions),
			position = mean(position)) %>%
	rename(address = page)
crawl <- crawl %>%
	left_join(gsa_agg, by = "address")
# Zwischenspeichern
write_rds(crawl, "_exports/03b_crawl_render_seg_ga_gsa.rds")

In den options() müssen natürlich die Platzhalter durch Eure client_id und Euer client_secret ersetzt werden.
Das if-else-Statement überprüft, ob bereits eine Authentifizierungsdatei vorliegt. Andernfalls müsst Ihr der Library den Zugriff im sich öffnenden Browser gestatten. Beim nächsten Ausführen des Codes wird dann automatisch die gespeicherte sc.oauth-Datei verwendet.
Ansonsten müsst Ihr an den jeweiligen stellen (TODO!!!) die ID Eurer GA-Datensicht, die GSC-Property und Eure Domain eintragen. Ich frage hier nur die Standard-Metriken von GA und GSC ab. Ihr könnt natürlich nach Belieben den Code anpassen, um noch mehr bzw. andere Daten zu bekommen. Wie die Libraries konfiguriert werden, hat Mark sehr detailliert in den beiden oben verlinkten Library-How-Tos beschrieben.
Die Daten werden im Skript auf Tagesbasis erhoben, auf URL-Ebene aggregiert und dann an den Crawl geschrieben. Bei den GA-Daten wird noch überprüft, ob die jeweilige Seite aktiv ist, d. h., ob sie im Berichtszeitraum mindestens einen PageView verzeichnet hat. Sowohl die Rohdaten der API-Abfragen als auch die angreicherten Crawls werden wieder zwischengespeichert. Gerade das Zwischenspeichern der GA-/GSC-Rohdaten ist hier wichtig, da man im Rahmen der Analyse doch immer mal wieder im Detail gucken will, wie eine bestimmte URL im zeitlichen Verlauf performt hat. Dazu können dann die zwischengespeicherten DataFrames geladen und auf die jeweilige URL gefiltert werden. Das ist allemal einfacher und schneller als jedes Mal für einzelne URLs erneut die API knechten zu müssen.
Damit sind wir auch schon mit der Datenerhebung fertig. Grundsätzlich lassen sich natürlich auch noch weitere Metriken an den Crawl anhängen. Im Rahmen von Content-Audits hole ich mir bspw. gerne die Backlinks und/oder Social-Media-Daten über die SEMrush-API oder die ahrefs-API.

Internen PageRank berechnen

Der Screaming Frog gibt zwar für jede URL die Anzahl der eingehenden Links an, diese sind aber nicht gewichtet. Eine verlässlichere Metrik ist der interne PageRank. Paul Shapiro hat auf Search Engline Land einen tollen Beitrag geschrieben, wie man diesen mit R berechnet, um die interne Verlinkung zu verbessern. Der nachfolgende Skript-Abschnitt ist dann auch nur eine Adaption seines Skripts.

# 04 Page Rank -------------------------------------------------------------
library(igraph)
library(tidyverse)
# TODO!!! DOMAIN DEFINIEREN ================================================
domain <- "https://www.example.com"
links <- read_csv("_data/all_outlinks_render.csv", skip = 1)
links <- links %>%
	# filter(Type == "AHREF" & Follow == "true") %>%
	filter(Type == "AHREF") %>%
	select(Source,Destination)
g <- graph_from_data_frame(links)
pr <- page_rank(g, algo = "prpack", vids = V(g), directed = TRUE, damping = 0.85)
values <- data.frame(pr$vector)
values$names <- rownames(values)
row.names(values) <- NULL
values <- values[c(2,1)]
names(values)[1] <- "url"
names(values)[2] <- "pr"
values <- values[grepl(paste0(domain, ".*"), values$url),]
crawl <- read_rds("_exports/03b_crawl_render_seg_ga_gsa.rds")
crawl <- crawl %>%
	left_join(values, by = c("address" = "url"))
# Zwischenspeichern
write_rds(crawl, "_exports/04_crawl_render_seg_ga_gsa_pr.rds")

Viel gibt es hier für Euch nicht zu tun. Tragt einfach nur Eure Domain ein, das war’s auch schon. Gegebenenfalls könnt Ihr noch definieren, ob der PageRank nur für follow-Links berechnet werden soll.
Der PageRank wird an die URLs des DataFrames geschrieben und dieser – Überraschung! – zwischengespeichert.

Interne Links in eine SQLite-Datenbank speichern

Dieser Schritt ist vollkommen optional. Hier kommt es wirklich auf die Größe der Website an. Wer aber schon mal alle internen Links einer Website aus dem SF exportiert hat, weiß, dass auch bei kleinen Websites dieser Export sehr schnell sehr groß werden kann – je nachdem wie ausgiebig intern verlinkt wird.
Für eine Crawl-Anaylse ist ein DataFrame mit den internen Links – also welche Seite wird von welchen Seiten verlinkt – jedoch unabdingbar. Nur so lassen sich zum Beispiel 404er schnell auf Template-basierte Links zurückführen. Das folgende Code-Snippet zählt alle Vorkommen der Kombinationen aus verlinkter URL, Ankertext sowie Status Code, filtert auf die verlinkten URLs, die mit einem Client-Fehler geantwortet haben, und sortiert sie absteigend nach Häufigkeit.

all_outlinks_render %>%
   count(destination, anchor, status_code) %>%
   filter(status_code >= 400, status_code < 500) %>%
   arrange(desc(n)) %>%
   View()

Bei internen Links, die mit dem gleichen Ankertext von vielen Seiten verlinkt werden, handelt es sich meistens um Template-Links – beispielsweise aus dem Footer, der Sitebar o. Ä. Solche defekten Verlinkungen lassen sich meist am einfachsten korrigieren und sollten entsprechend priorisiert werden.
Davon abgesehen ist es grundsätzlich immer hilfreich, wenn man on-the-fly nachsehen kann, woher eine URL verlinkt wird. Wie aber bereits angesprochen kann die Datei bzw. der DataFrame mit der internen Verlinkung sehr groß sein. Um nicht den gesamten RAM mit diesem einen DataFrame zu belegen, schreibe ich mir die Links standardmäßig in eine SQLite-Datenbank.

# 05 Intern Links in SQLite ------------------------------------------------
library(DBI)
library(tidyverse)
# Datenbank initialiseren
mydb <- dbConnect(RSQLite::SQLite(), "_data/all_outlinks.sqlite")
# Tabelle erstellen und befüllen
outlinks <- read_csv("_data/all_outlinks_render.csv", skip = 1) %>%
	janitor::clean_names()
dbWriteTable(mydb, "outlinks", outlinks)
dbDisconnect(mydb)

Hier müsst Ihr nichts machen. Sollte die angegebene Datenbank noch nicht vorhanden sein, wird sie durch dbConnect() automatisch erzeugt. Beim Schreiben des DataFrames in die DB wird die Tabelle ebenfalls automatisch erzeugt. In Eurem Projekt-Ordner liegt dann eine Datei all_outlinks.sqlite. Wollt Ihr die Datenbank löschen und neu erstellen, müsst Ihr einfach nur diese Datei löschen.
Zur Abfrage der Datenbank schreibe ich mir immer ein paar Hilfsfunktionen. Auf diese gehe ich weiter unten ein, da ich sie im Skript zur Crawl-Analyse definiere.

Javascript SEO: Abgleich des gerenderten und des nicht gerenderten Crawls

So, wir haben’s fast geschafft. Zum Schluss kommt noch der Abgleich des gerendertern bzw. nicht gerenderten Crawls. Wer meinen Blogpost zum Abgleich von Screaming-Frog-Crawls gelesen hat, um Website-Veränderungen zu ermitteln, weiß schon, was hier passiert.
Die beiden Crawls werden eingelesen und dann dahingehend überprüft, welche URLs nur im gerenderten resp. nur im nicht gerenderten Crawl vorkommen. Für URLs, die in beiden Crawls vorkommen, wird nachgesehen, ob der Status Code, der Canonical-Link, die Indexanweisung, der Title, die Description, die Crawl-Tiefe, die H1, die Größe und die Wortzahl identisch sind.

# 06 Abgleich Render vs No Render ------------------------------------------
## Einlesen & Aufbereiten ..................................................
# Nicht gerenderter Crawl
crawl_no_render <- read_rds("_exports/02_crawl_no_render_seg.rds")
crawl_no_render <- crawl_no_render %>%
	mutate_if(is.factor, fct_expand, "") # Hinzufügen von "" als Factor-Level, um nachfolgend NA durch "" zu ersetzen
crawl_no_render[is.na(crawl_no_render)] <- "" # NA durch "" ersetzen, um diese Felder vergleichbar zu machen
crawl_no_render <- crawl_no_render %>%
	filter(!content_type %in% c("JavaScript", "CSS"))
# Gerenderter Crawl
crawl_render <- read_rds("_exports/02_crawl_render_seg.rds")
crawl_render <- crawl_render %>%
	mutate_if(is.factor, fct_expand, "")
crawl_render[is.na(crawl_render)] <- ""
crawl_render <- crawl_render %>%
	filter(!content_type %in% c("JavaScript", "CSS"))
# Abgleich .................................................................
# Nur in no_render enthaltene URLs
only_no_render_urls <- crawl_no_render %>%
	anti_join(crawl_render, by = "address") %>%
	mutate(render_vs_no_render = "only no render") %>%
	select(address, render_vs_no_render, is_indexable, content_type)
# Nur in render enthaltene URLs
only_render_urls <- crawl_render %>%
	anti_join(crawl_no_render, by = "address") %>%
	mutate(render_vs_no_render = "only render") %>%
	select(address, render_vs_no_render, is_indexable, content_type)
# Identische URLs
identical_urls <- crawl_render %>%
	inner_join(crawl_no_render, by = "address", suffix = c("_render", "_no_render")) %>%
	mutate(render_vs_no_render = "both",
		status_code_identical = status_code_render == status_code_no_render,
		canonical_identical = canonical_link_element_1_render == canonical_link_element_1_no_render,
		index_identical = meta_robots_1_render == meta_robots_1_no_render,
		title_identical = title_1_render == title_1_no_render,
		description_identical = meta_description_1_render == meta_description_1_no_render,
		crawl_depth_identical = crawl_depth_render == crawl_depth_no_render,
		h1_identical = h1_1_render == h1_1_no_render,
		size_identical = round(abs(as.numeric(size_render) / as.numeric(size_no_render) - 1),2) <= 0.1, # Grenzwert, size identisch, wenn nur 10% Veränderung
		word_count_identical = round(abs(word_count_render / word_count_no_render - 1), 2) <= 0.1) %>%  # Grenzwert, word_count identisch wenn nur 10% Veränderung
	select(address, render_vs_no_render, matches("identical"), is_indexable_render, content_type_render) %>%
	rename(is_indexable = is_indexable_render,
				 content_type = content_type_render)
render_vs_no_render <- bind_rows(only_no_render_urls, only_render_urls, identical_urls) %>%
	mutate(render_vs_no_render = as.factor(render_vs_no_render),
				 render_vs_no_render = fct_relevel(render_vs_no_render, "only no render", "both", "only render")) %>%
	gather(key = "metric", value = "value", -c(address, render_vs_no_render, content_type, is_indexable))
render_vs_no_render[render_vs_no_render == ""] <- NA
write_rds(render_vs_no_render, "_exports/06_render_vs_no_render.rds")

Bei sehr JavaScript-lastigen Seiten bietet der Abgleich einen guten Überblick über die URL-Mengengerüste – was sieht der Crawler minimal, was sieht er maximal? Der Abgleich der identischen URLs ist immer sehr spannend, um zu sehen, was da eigentlich mit JavaScript alles so verändert wird. Da hatte ich schon das ein oder andere Aha-Erlebnis, warum bei Kunden bspw. Änderungen an den Titles und Descriptions nicht bei Google ankommen. Oder der Klassiker: SEO-Texte werden mittels JavaScript nachgeladen. Das sieht man dann immer sehr schön beim Abgleich der Wortzahl.
Als Resultat dieses Abschnitts wird ein neuer DataFrame generiert und zwischengespeichert.
Und damit haben wir den ersten Teil – die Crawl-Aufbereitung und -Anreicherung – geschafft. Jetzt kommen wir endlich zu dem Teil, der am meisten Spaß macht und am interessantesten ist: Die eigentliche Datenanalyse. 🤓

Crawl-Analyse

Direkt vorweg noch einmal ein Hinweis:
Die nachfolgenden Diagramme und Tabellen sind nur ein erster Ansatzpunkt, um sich einen Überblick zu verschaffen. Wie der Name Explorative Datenanalyse schon sagt, weiß auch ich nie, wo die Reise bei einer Crawl-Analyse hingehen wird. Je nachdem, ob und welche Auffälligkeiten mir durch die Diagramme ins Auge fallen, bohre ich mich an den entsprechenden Stellen individuell tiefer in die Daten hinein. Mit jedem neuen Kunden lerne ich daher auch heute noch neue Kombinationsmöglichkeiten der Daten, die mir neue Einblicke ermöglichen. Dafür ein standardisiertes Vorgehen zu definieren, ist also nahezu unmöglich – oder würde vom Umfang her eher ein Buch als einen Blogbeitrag erfordern. Hier sind Euer Know-How, Eure Kreativität und Eure Vorstellungskraft gefragt – also genau das, was Analysen immer wieder aufs Neue so spannend und interessant macht.

Die Aufbereitung des Crawls war bisher ein normales R-Skript. Bei meinen Analysen benutze ich gerne RMarkdown, um direkt im Skript das Vorgehen und die Analyse-Ergebisse zu dokumentieren. Vor allem dann, wenn die Analysen nur intern für einen unserer Consultants durchgeführt werden und es nicht allzusehr auf die Form ankommt, kann ich das RMarkdown-Dokument einfach als PDF speichern und verschicken.
Legen wir los!

Konfiguration, Hilfsfunktionen und Daten laden

Zunächst werden die benötigten Libraries geladen, einige Variablen und Hilfsfunktionen definiert sowie die zuvor aufbereiteten Crawls importiert.

```{r message=FALSE, warning=FALSE}
# Libraries
library(tidyverse)
library(DBI)
```
```{r}
# Host
host <- "www.example.com"
# Config
theme_set(new = theme_light()) #https://ggplot2.tidyverse.org/reference/ggtheme.html
color_scheme <- c("#1B9E77","#D95F02","#7570B3","#E7298A","#66A61E","#E6AB02","#A6761D","#666666")
color_pos <- "#377EB8"
color_neg <- "#FF7F00"
```
```{r}
# helper function zur Abfrage der internen Links aus der SQLite-DB
query_db <- function(stmt) {
	mydb <- dbConnect(RSQLite::SQLite(), "_data/all_outlinks.sqlite")
	r <- dbGetQuery(mydb, stmt)
	dbDisconnect(mydb)
	return(r)
}
query_source <- function(source_url) {
	mydb <- dbConnect(RSQLite::SQLite(), "_data/all_outlinks.sqlite")
	r <- dbGetQuery(mydb, paste0("SELECT * FROM outlinks WHERE source = '", source_url, "'"))
	dbDisconnect(mydb)
	return(r)
}
query_destination <- function(destination_url) {
	mydb <- dbConnect(RSQLite::SQLite(), "_data/all_outlinks.sqlite")
	r <- dbGetQuery(mydb, paste0("SELECT * FROM outlinks WHERE destination = '", destination_url, "'"))
	dbDisconnect(mydb)
	return(r)
}
# DF in Clipboard speicher
save_to_clipboard <- function(x, row.names = FALSE, col.names = TRUE, ...) {
	write.table(x,"clipboard",sep = "\t",
				row.names = row.names,
				col.names = col.names, ...)
}
```
```{r}
crawl <- read_rds("_exports/04_crawl_render_seg_ga_gsa_pr.rds")
render_vs_no_render <- read_rds("_exports/06_render_vs_no_render.rds")
```

Den host benötige ich, wenn später die URLs nach ihren Verzeichnissen aufgesplittet werden.
Beim Theme (theme_set()) für die Plots entscheide ich mich meistens für ein schlichtes. Eine Übersicht aller Standard-Themes von ggplot2 gibt es hier.
Das color_scheme definiert die Farbwerte, die ich später bei Plots von kategorialen Daten verwende. color_pos und color_neg definieren entsprechend die Farbwerte für Diagrammbalken, die positive oder negative Werte darstellen.

Im Übrigen bin ich immer auf der Suche nach neuen Color-Sets, mit denen sich gut aussehende, aber vor allem gut lesbare Diagrame gestalten lassen. Wenn Ihr hier also etwas habt, immer her damit in den Kommentare. 😁 Standardmäßig nehme ich eines der Color-Brewer-Sets. Bei Diagrammen für Präsentationen oder Dokumentationen, die an den Kunden gehen, führt dann aber kein Weg mehr an spezifisch gestalteten Diagrammen vorbei, um die Story zu erzählen. Hier möchte ich jedem das Buch Storytelling with Data von Cole Nussbaumer Knaflic ans Herz legen! Und immer dran denken: Keine Pie Charts! ☝️

Die Hilfsfunktionen sind zum einen für die Abfrage der SQLite-DB mit den internen Links gedacht. Zum anderen bietet die Funktion save_to_clipboard() die Möglichkeit, DataFrames direkt in die Zwischenablage zu kopieren. Das ist praktisch, wenn ich die Diagramme dann doch wieder in Excel oder PowerPoint generieren muss. Dadurch kann ich die Datenaggregation schnell in R machen, sodass nur noch die Visualisierung in Excel bleibt…falls es dann doch unbedingt gewünscht ist.
Zum Schluss werden die beiden DataFrames mit dem aufbereiteten Crawl und dem Abgleich zwischen gerendertem und nicht gerendertem Crawl geladen.
So, und jetzt geht’s aber wirklich los!

Status Codes

# Mengengerüste
Insgesamt gecrawlte Seiten: **`r prettyNum(nrow(crawl), big.mark = ".", decimal.mark = ",")`**
## Status Codes
```{r}
tbl_count_status_code <- crawl %>%
	count(status_code)
```
```{r}
ggplot(tbl_count_status_code, aes(status_code, n, fill = status_code)) +
	geom_col() +
	xlab("Status Code") +
	ylab("Anzahl URLs") +
	guides(fill = F) +
	ggtitle("Anzahl URLs je Status Code") +
	scale_fill_manual(values = color_scheme)
```

JavaScript SEO: Anzahl URLs je Status Code
Keine aufregende Grafik, aber trotzdem ein wichtiger Blick auf die Daten, um erste Auffälligkeiten zu identifizieren. Auf dem Beispiel-Plot ist das Aufkommen der Status Codes im normalen Bereich. Die meisten URLs antworten korrekt mit 200 OK. Sollten jedoch viele URLs mit 3xx oder 4xx antworten, wäre jetzt der erste Zeitpunkt, tiefer in die Daten zu gehen. Welche URLs antworten mit diesen Status Codes? Woher werden sie verlinkt? (Hier kommen die oben definierten Funktionen für die SQLite-Abfrage ins Spiel.) Kann ich ein Muster erkennen?

Content Types

## Content Types
```{r}
tbl_count_content_types <- crawl %>%
	count(content_type)
```
```{r}
ggplot(tbl_count_content_types, aes(content_type, n, fill = content_type)) +
	geom_col() +
	xlab("Content Type") +
	ylab("Anzahl URLs") +
	guides(fill = F) +
	ggtitle("Anzahl URLs je Content Type") +
	scale_fill_manual(values = color_scheme)
```

JavaScript SEO: Anzahl URLs je Content Type
Verdächtig ist direkt die Vielzahl der JavaScript-Ressourcen. Im vorliegenden Crawl war es so, dass alle JS-Ressourcen eine Session-ID bekommen. So kann ich einen Crawler natürlich auch beschäftigen, indem ich ihm ständig neue JS-Links generiere.

Indexierbarkeit

## Indexierbarkeit
```{r}
tbl_count_is_indexable <- crawl %>%
	count(is_indexable)
```
```{r}
tbl_count_is_indexable %>%
	filter(!is.na(is_indexable)) %>%
	mutate(is_indexable = factor(is_indexable, levels = c("TRUE", "FALSE")),
				 is_indexable = fct_recode(is_indexable, ja = "TRUE", nein = "FALSE")) %>%
	ggplot(aes(is_indexable, n, fill = is_indexable)) +
		geom_col(width = 0.4) +
		xlab("Indexierbarkeit") +
		ylab("Anzahl URLs") +
		guides(fill = F) +
		ggtitle("Anzahl URLs je Indexierbarkeit") +
		scale_fill_manual(values = c(color_pos, color_neg))
```

Anzahl URLs je Indexierbarkeit
Auch hier springt mir direkt etwas ins Auge, denn generell will ich weniger nicht indexierbare URLs auf meinem System haben als indexierbare. Ich würde damit beginnen, auf die nicht indexierbaren URLs zu filtern und sie einfach zu überfliegen. Kann ich schon ein Muster erkennen? Deutlicher wird die Ursache dann zumeist, wenn ich mir die Indexierbarkeit in Bezug auf die einzelnen Segmente oder auf die Klicktiefe ansehe. Mit den Diagrammen auf dieser Detailebene möchte ich mir allerdings erst einmal nur einen groben Überblick verschaffen.

Klicktiefe ~ Indexierbarkeit

## Klicktiefe
```{r}
tbl_count_crawl_depth <- crawl %>%
	filter(content_type == "HTML", status_code == 200) %>%
	count(crawl_depth, is_indexable, is_active) %>%
	mutate(is_indexable = factor(is_indexable, levels = c("FALSE", "TRUE")),
				 is_indexable = fct_recode(is_indexable, ja = "TRUE", nein = "FALSE"),
				 is_active = factor(is_active, levels = c("FALSE", "TRUE")),
				 is_active = fct_recode(is_active, ja = "TRUE", nein = "FALSE"))
```
### ~ Indexierbarkeit
```{r}
ggplot(tbl_count_crawl_depth, aes(crawl_depth, n, fill = is_indexable)) +
	geom_col() +
	xlab("Klicktiefe") +
	ylab("Anzahl URLs") +
	ggtitle("Anzahl URLs je Klicktiefe ~ Indexierbarkeit") +
	scale_fill_manual(values = c(color_neg, color_pos), name = "Ist indexierbar")
```

JavaScript SEO: Anzahl URLs je Klicktiefe ~ Indexierbarkeit
Auch immer ein interessanter Bilck auf die Website: Schaffe ich es, schnellstmöglich einen Großteil meiner URLs auf den ersten drei Ebenen zu verlinken? Habe ich endlose Paginierungen? Wo liegen meine indexierbaren und damit für mich wichtigen Seiten? Hier sehen wir beispielsweise, dass auf der zweiten bis fünften Ebene ein unnötiger Ballast an nicht indexierbaren Seiten mitgeschleppt wird. Stattdessen sollte ich versuchen, die indexierbaren Seiten der vierten und fünften Ebene nach vorne zu holen.

Klicktiefe ~ Aktivität

### ~ Aktivität (Pageviews >  0)
```{r}
ggplot(tbl_count_crawl_depth, aes(crawl_depth, n, fill = is_active)) +
	geom_col() +
	xlab("Klicktiefe") +
	ylab("Anzahl URLs") +
	ggtitle("Anzahl URLs je Klicktiefe ~ Aktivität (Pageviews >  0)") +
	scale_fill_manual(values = c(color_neg, color_pos), name = "Ist aktiv")
```

Anzahl URLs je Klicktiefe ~ Aktivität
Entsprechend dem Grenzwert, den ich bei der Aufbereitung der GA-Daten definiert habe, gilt eine Seite als aktiv oder inaktiv. Im vorliegenden Beispiel muss eine Seite in den vergangenen zwölf Monaten mindestens einen PageView verzeichnet haben. Je nach Berichtszeitraum und Website ist dieser Schwellwert jedoch individuell zu definieren. Vergleichbar zum vorherigen Diagramm schaue ich an dieser Stelle, ob die aktiven Seiten möglichst auf den vorderen Ebenen liegen. Dann spiele ich auch immer etwas mit dem Grenzwert, um mir die Verteilung der URLs mit 10, 100, 1.000 etc. PageViews anzugucken, um ein Gefühl für die Aktivität der Seiten im Ganzen zu bekommen.

Klicktiefe ~ Pagerank

### ~ PageRank
```{r}
crawl %>%
	filter(content_type == "HTML", status_code == 200) %>%
	mutate(is_indexable = fct_recode(as.factor(is_indexable), ja = "TRUE", nein = "FALSE")) %>%
	ggplot(aes(crawl_depth, pr, color = is_indexable)) +
		geom_jitter(alpha = 0.4, width = 0.4) +
		scale_y_log10() +
		xlab("Klicktiefe") +
		ylab("PageRank (log10)") +
		ggtitle("Klicktiefe ~ Pagerank") +
		scale_color_manual(values = c(color_neg, color_pos), name = "Ist indexierbar")
```

Klicktiefe ~ Pagerank
(Vermeintlich) wichtige URLs, also solche mit einem hohen PageRank, sollten logischerweise möglichst früh verlinkt werden. Im Idealfall beschreiben die Punkte eine von links nach rechts steil abfallende Kurve. Hinweis:  Da die Kurve so steil abnimmt, ist die y-Achse logarithmisch skaliert, da ansonsten die Punkte ab der zweiten Ebene alle ununterscheidbar nah an der x-Achse lägen.
Im obigen Beispiel ist die Idealkurve nahezu gegeben. Interessant sind jedoch die URLs auf erster Ebene, deren PageRank deutlich hinter dem der anderen URLs dieser Ebene zurückfällt. Das sind offensichtlich URLs, die zwar von der Startseite verlinkt werden, da ihnen eine bestimmte Bedeutung beigemessen wird. Auf der gesamten Website werden sie dann aber nicht mehr so stark verlinkt. Die URLs würde ich mir genauer ansehen, um dann zu entscheiden, ob die Verlinkung von der Startseite der Bedeutung der Seiten entsprechend ist. Wenn ja, sollte die interne Verlinkung der Seiten insgesamt gestärkt werden. Wenn nein, macht es Sinn, die Verlinkung von der Startseite zu entfernen und stattdessen wichtigere URLs zu verlinken.
Darüber hinaus sollten hier natürlich nicht indexierbare Seiten einen möglichst geringen PageRank aufweisen.

Abgleich gerenderter und nicht gerenderter Crawl

## Gerendert vs. nicht gerendert
```{r}
render_vs_no_render %>%
	mutate(render_vs_no_render = fct_recode(as.factor(render_vs_no_render), `gerendert` = "only render", `beide` = "both", `nicht gerendert` = "only no render")) %>%
ggplot(aes(render_vs_no_render, fill = render_vs_no_render)) +
	geom_bar() +
	xlab("Crawl") +
	ylab("Anzahl URLs") +
	ggtitle("Anzahl URLs je Crawl") +
	scale_fill_manual(values = color_scheme, name = "In welchem Crawl\nsind die URLs\nenthalten?")
```

Anzahl URLs je Crawl
Bei diesem Diagramm möchte ich nur ein Gefühl dafür bekommen, ob JavaScript zum Rendern von Verlinkungen o. Ä. im Einsatz ist und wenn ja, wie groß die Auswirkung auf die Gesamtzahl der URLs ist.

Abgleich gerenderter vs. nicht gerenderter Crawl ~ Content Types

### ~ Content Type
```{r}
render_vs_no_render %>%
	mutate(render_vs_no_render = fct_recode(as.factor(render_vs_no_render), `gerendert` = "only render", `beide` = "both", `nicht gerendert` = "only no render")) %>%
ggplot(aes(render_vs_no_render, fill = render_vs_no_render)) +
	geom_bar() +
	facet_wrap(~content_type) +
	xlab("Crawl") +
	ylab("Anzahl URLs") +
	ggtitle("Anzahl URLs je Crawl ~ Content Type") +
	scale_fill_manual(values = color_scheme, name = "In welchem Crawl\nsind die URLs\nenthalten?")
```

JavaScript SEO: Anzahl URLs je Crawl ~ Content Type
Dies ist quasi ein Drilldown des vorherigen Diagramms, das mir zeigt, welche Ressourcen durch das JavaScript-Rendering nachgeladen werden. Sollten viele HTML-Ressourcen nur über mittels JS gerenderte Links erreichbar sein, kann dies eine Hürde für den Crawler darstellen, da nicht sichergestellt ist, dass der Crawler immer JS ausführt. Insbesondere wichtige Seiten sollten dahingehend überprüft werden, ob sie auch im nicht gerenderten Crawl vorhanden sind.

Abgleich gerenderter vs. nicht gerenderter Crawl ~ Unterschiede der in beiden Crawls vorhandenen Seiten

### ~ Metrik-Unterschiede
```{r}
render_vs_no_render %>%
	filter(render_vs_no_render == "both", !is.na(value)) %>%
	mutate(value = factor(value, levels = c("TRUE", "FALSE")),
				 value = fct_recode(value, ja = "TRUE", nein = "FALSE")) %>%
	ggplot(aes(value, fill = value)) +
		geom_bar() +
		facet_wrap(~metric) +
		guides(fill = F) +
		ggtitle("Metrik-Unterschiede zwischen den Crawls") +
		xlab("Anzahl URLs") +
		ylab("") +
		scale_fill_manual(values = c(color_neg, color_pos))
```

Metrik-Unterschiede zwischen den Crawls
Wie bereits kurz erwähnt dient mir der Abgleich der identischen Seiten hinsichtlich Unterschieden bei Titles & Descriptions, H1 etc. dazu, zu überprüfen, ob diese Eigenschaften durch JavaScript im Nachhinein verändert werden. Nach Möglichkeit sollten Unterschiede nur bei der Seitengröße (klar, wenn Elemente mittels JS nachgeladen werden) und im insignifikanten Bereich bei der Klicktiefe (einige Links werden immer mittels JS nachgeladen und verschieben dann die Klicktiefe) vorhanden sein. Gibt es in den anderen Breichen Unterschiede, muss ich im Hinterkopf behalten, dass mein Title evtl. im Nachhinein noch manipuliert wird.

Verzeichnisse

## Verzeichnisse
```{r}
dirs <- splitstackshape::cSplit(crawl %>%
				mutate(address = str_replace(address, paste0("https?://", host, "/"), "")),
				splitCols = "address", sep = "/", direction = "wide", type.convert = F) %>%
			select(starts_with("address_"), content_type, is_indexable)
dirs <- as.data.frame(dirs)
dirs[,1][is.na(dirs[,2])] <- "/" # URL liegt im Root
```
```{r}
tbl_count_dir1 <- dirs %>%
	rename(dir1 = !!names(.[1])) %>%
	count(dir1) %>%
	arrange(desc(n)) %>%
	mutate(percent = n / sum(n),
		cum_percent = cumsum(percent))
```
```{r}
tbl_count_dir1 %>%
	filter(cum_percent <= .97) %>% # Schwellwert definieren: URLs die x% aller URLs ausmachen
	ggplot(aes(reorder(dir1, n), n, fill = as.factor(dir1))) +
		geom_col() +
		coord_flip() +
		xlab("Anzahl URLs") +
		ylab("Verzeichnis 1") +
		guides(fill = F) +
		ggtitle("Anzahl URLs je erster Verzeichnisebene") +
		scale_fill_manual(values = color_scheme)
```

JavaScript SEO: Anzahl URLs je erster Verzeichnisebene
Die Aufsplittung der URLs nach Verzeichnissen bietet immer einen guten Überblick – wenn die Verzeichnisse denn sinnvoll gewählt und benannt sind – darüber, welche Themen o. Ä. im Fokus der Website stehen. Es geht also wieder vorrangig darum, ein Gefühl für die Struktur der Website zu bekommen.

Verzeichnisse ~ Content Type

### Verzeichnis ~ Content Type
```{r}
tbl_count_dir1_content_type <- dirs %>%
	rename(dir1 = !!names(.[1])) %>%
	count(dir1, content_type) %>%
	arrange(desc(n)) %>%
	mutate(percent = n / sum(n),
		cum_percent = cumsum(percent))
```
```{r}
tbl_count_dir1_content_type %>%
	filter(cum_percent <= .97) %>% # Schwellwert definieren: URLs die x% aller URLs ausmachen
	ggplot(aes(content_type, n, fill = content_type)) +
	geom_col() +
	facet_wrap(~dir1) +
	theme(axis.text.x = element_text(angle = 45, hjust = 1)) +
	xlab("Content Type") +
	ylab("Anzahl URLs") +
	ggtitle("Anzahl URLs je Content Type ~ erste Verzeichnisebene") +
	scale_fill_manual(values = color_scheme, name = "Content Type")
```

Anzahl URLs je Content Type ~ erste Verzeichnisebene
Dieses Diagramm ist erneut ein Drilldown des vorherigen, indem die URL-Verteilung innerhalb der Verzeichnisse noch einmal auf den Content Type heruntergebrochen wird. Dadurch bekomme ich schnell einen Einblick, ob beispielsweise Bilder und JS-Ressourcen in bestimmten Verzeichnissen liegen. Dass im obigen Beispiel die Bilder im Verzeichnis image bzw. img liegen, ist keine große Überraschung, aber es gibt da durchaus kreativere Verzeichnisbenennungen, die nicht so offensichtlich sind.

URL-Segemente

## Segmente
```{r}
tbl_count_seg1 <- crawl %>%
	count(segment_1, is_indexable, is_active, crawl_depth)
```
```{r}
ggplot(tbl_count_seg1 %>% filter(!is.na(segment_1)), aes(reorder(segment_1, -n), n, fill = segment_1)) +
	geom_col() +
	xlab("Segmente 1") +
	ylab("Anzahl URLs") +
	guides(fill = F) +
	ggtitle("Anzahl URLs je Segment 1") +
	scale_fill_manual(values = color_scheme)
```

JavaScript SEO: Anzahl URLs je Segment 1
Vergleichbar zum Aufsplitten der URLs nach Verzeichnissen dient der Plot der Segmente dazu, dass ich ein Gefühl für die Mengengerüste bekomme. Im obigen Beispiel sind die URL-Segemente nahezu identisch mit der ersten Verzeichnisebene, weshalb hier kaum ein Unterschied zu sehen ist. Aber gerade bei einer Segmentierung nach Seitentypen, also z. B. Übersichtsseiten und Artikel-/Produktdetailseiten, und Inhaltstypen, z. B. Ressorts oder Produktkategorien, lassen sich schnell Erkenntnisse gewinnen, vor allem, wenn die beiden Segmentarten miteinander kombiniert werden. Liegen in einer Produktkategorie nur sehr wenige Produkte, erzeugen aber überdurchschnittlich viele Übersichtsseiten? Die Produkte könnten dann sehr gut hinsichtlich ihrer Eigenschaften erschlossen sein, sodass eine unverhältnismäßig hohe Anzahl an Filter-Übersichtsseiten generiert wird. Möglicherweise kann ich dann Seiten einsparen, denn ich muss nicht Hunderte von Filterseiten anbieten, wenn dann eh nur noch ein Produkt auf der Übersichtsseite übrig bleibt.

URL-Segmente ~ Klicktiefe

#### ~ Klicktiefe
```{r}
ggplot(tbl_count_seg1 %>% filter(!is.na(segment_1)), aes(crawl_depth, n, fill = segment_1)) +
	geom_col() +
	xlab("Klicktiefe") +
	ylab("Anzahl URLs") +
	ggtitle("Anzahl URLs je Klicktiefe ~ Segment 1") +
	scale_fill_manual(values = color_scheme, name = "Segmente 1")
```

Anzahl URLs je Klicktiefe ~ Segment 1
Sollten bestimmte Segmente besonders wichtig sein, sollten sie in diesem Diagramm natürlich auf den vorderen Ebenen liegen. Gerade bei Websites von Verlagen oder Online-Shops kann man dann immer sehr schön sehen, wie sich die Segmente Archiv oder Pagination bis Ebene 100+ erstrecken.

PageViews ~ PageRank

### ~ PageRank ~ Segment 1
```{r}
crawl %>%
	filter(content_type == "HTML", status_code == 200, page_views != max(page_views, na.rm = T)) %>% # Ausschließen der Startseite, da sie immer die meisten Pageviews hat und die Darstellung verzerrt
	ggplot(aes(page_views, pr, color = segment_1)) +
		geom_point(alpha = .4) +
		xlab("PageViews") +
		ylab("PageRank") +
		ggtitle("PageViews ~ PageRank") +
		scale_color_manual(values = color_scheme, name = "Segment 1")
```

PageViews ~ PageRank
Bei diesem Diagramm gehe ich von der Prämisse aus, dass der PageRank ein mittelbarer Indikator dafür ist, wie viel Bedeutung einer Seite zukommt. Je häufiger eine Seite verlinkt wird, desto wichtiger scheint sie – wenn die Verlinkung denn gezielt erfolgt – für den Kunden zu sein. Die PageViews nehmen ich als mittelbaren Indikator für die Nachfrage der Nutzer. Je häufiger eine Seite angesehen wird, desto wichtiger bzw. interessanter ist sie für den Nutzer. Decken sich die Kundenannahme, welche Seiten besonders wichtig sind, und die Nutzernachfrage, sollten diese Seiten in der rechten oberen Ecke liegen. Am obigen Beispiel kann man sehr schön sehen, wie Eigenwahrnehmung und Nutzerinteresse vollkommen auseinander gehen. Links oben in der Ecke findet sich eine Seite mit dem höchsten PageRank, aber kaum PageViews. Hier versucht ein Kunde seine vorbildliche Transparenz in seinen Geschäftsprozessen zu kommunizieren und stellt diese Transparenz per Verlinkung von fast jeder Seite als die USP heraus. Interessiert halt nur irgendwie niemanden.
Das war’s erst einmal. Bis hierhin kann ich die Schritte einer Crawl-Analyse weitestgehend standardisiert abbilden. Ich hoffe, ich konnte Euch damit einen ersten Überblick verschaffen. Wenn Ihr Fragen, Anregungen oder Ergänzungen habt, hinterlasst doch einfach einen Kommentar.

Download:
Hier gibt’s die beiden Skripte und Ordnerstruktur des Projektes als .zip.

Bis dahin: Happy R! 👋

get:traction Partner Patrick Lürwer

Patrick Lürwer

Senior-Analyst & Partner

In meinem Studium des Bibliotheksmanagements habe ich mich von Anfang an mehr für die Metadaten als für die Bücher interessiert. Meine Leidenschaft für Daten — das Erfassen, Aufbereiten und Analysieren — habe ich anschließend durch mein Master-Studium der Informationswissenschaft weiter ausleben und vertiefen können. Bei get:traction kombiniere ich meine Data-Hacking-Skills und meine Online-Marketing-Expertise, um datengestützte Empfehlungen für Kunden zu formulieren und umgesetzte Maßnahmen zu messen. Mein Hauptaufgabenbereich liegt dabei in der Analyse von Crawls, Logfiles und Tracking-Daten, der Konzeption von Informationsarchitekturen und der Anreicherung von Webinhalten mittels semantischer Auszeichnungen.

4.5/5 - (28 votes)

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert