7  Data Linkage

7.1 Datenspenden einlesen

Wir wollen nun unsere Datenspenden mit den Befragungsdaten verbinden. Hierzu müssen wir zunächst alle Datenspenden, die in separaten .csv-Dateien abgespeichert sind, einlesen. Im folgenden illustrieren wir dies anhand der YouTube-Datenspenden. Weiterhin nehmen wir an, dass der Befragungsdatensatz in einem Objekt mit der Bezeichnung d bereits in das Environment geladen wurde.

# in das Objekt "youtube" speichern wir die Datenspenden
youtube <-
  # zunächst listen wir alle Dateien in unserem Datenspende-Ordner auf und lassen die Funktion
  # read_csv() über alle Dateien in diesem Ordner laufen
  # und verbinden alles zu einem tibble mit der Funktion list_rbind()
  map(list.files("../dd_data/youtube/", full.names = TRUE), ~ read_csv(.x, show_col_types = FALSE)) |>
  set_names(list.files("../dd_data/youtube/") |> str_extract(".*(?=_2025)")) |>
  list_rbind(names_to = "participant") |>
  distinct() |>
  # anschließend stellen wir sicher, dass der Zeitstempel in den Datenspenden in der
  # gleichen Zeitzone codiert ist wie unsere Befragungsdaten
  mutate(time = lubridate::as_datetime(timestamp) |> with_tz("UTC") + hours(2)) |>
  select(-day, -hour, -weekday) |>
  rowwise() |>
  # auch hier wird zur Wahrung der Anonymität der participant code pseudonymisiert
  mutate(participant = digest::digest(participant, algo = "md5")) |>
  ungroup() |>
  # zuletzt stellen wir noch sicher, dass wir nur Daten mit korrekten participant codes
  # beibehalten (also dieser code auch in den Befragunsgdaten vorkommt)
  filter(participant %in% d$participant)

7.2 Datensätze verbinden und aggregieren

In einem zweiten Schritt erfolgt nun das verbinden der Datensätze:

# wir erstellen ein neues Objekt "d_dd", in welches wir den verbundenen Datensatz speichern
d_dd <-
  # zunächst gruppieren wir unseren Befragungsdatensatz nach participant und sortieren ihn nach Zeit
  d |>
  group_by(participant) |>
  arrange(time) |>
  mutate(
    # anschließend erstellen wir eine neue Spalte im Datensatz mit dem Namen youtube_session
    # diese ist eine Liste von tibbles mit der gleichen Länge wie unser Befragungssatz
    # (also pro Zeile ein tibble)
    # Im ersten Schritt speichern wir in jedem tibble alle Youtube Videos, die zeitlich nach der
    # vorherigen Befragung, aber vor der aktuellen Befragung angeschaut wurden
    youtube_session = map2(lag(time), time, ~ filter(youtube, time >= .x & time <= .y), .progress = TRUE),
    # Bislang enthält dieses tibble jedoch noch alle Videos, auch solche, die zwar zeitlich passen,
    # allerdings von einer anderen Person geschaut wurden
    # Daher filtern wir im zweiten Schritt jedes tibble, sodass darin nur noch solche Videos enthalten sind,
    # die auch von der Person geschaut wurden, zu der die jeweilige Zeile gehört
    youtube_session = map2(youtube_session, participant, ~ filter(.x, participant == .y), .progress = TRUE),
    # Als letzten Schritt könnten wir nun noch, auf der Grundlage von zusätzlich erhobenen Metadaten,
    # wie z.B. Genres der jeweiligen Videos, Informationen auf Session-Ebene aggregieren.
    # Wenn wir z.B. im Vorfeld eine Funktion compute_session() definieren, die uns einen Mittelwert für
    # die Variable genre_action berechnet, welche für jedes Video TRUE oder FALSE sein kann, und diese
    # Funktion auf jedes tibble in der Spalte youtube_session anwenden, dann erhalten wir in dem neuen Objekt
    # session data für jede Session den prozentualen Anteil an Action Videos in dieser Session.
    # session_data = map(youtube_session, ~ compute_session(.x), .progress = TRUE),
    .after = time
  ) |>
  ungroup() |>
  # wenn wir ein aggregiertes Objekt session_data erstellt haben,
  # können wir dieses mit der folgenden Funktion in den Datensatz einbinden
  # unnest_wider(session_data) |>
  distinct() |>
  arrange(participant, time)

8 Metadaten scrapen (Beispiel Netflix)

Doch wie erhalten wir entsprechende Meta-Daten, die wir auf Session-Ebene aggregieren können? Für YouTube haben wir uns dies bereits angesehen, wir können dazu bspw. die YouTube Data API oder das Programm yt-dlp nutzen (siehe hierzu Materialien zur Sitzung Digitale Verhaltensdaten).

Eine weitere Möglichkeit wäre das sogenannte Webscraping, also das Herunterladen von Informationen aus dem Internet. Das schauen wir uns im Folgenden kurz für unsere Netflix-Datenspenden an (dieses Verfahren haben wir bereits in einem Projekt angewendet, für weitere Informationen, siehe https://osf.io/5updj/).

# angenommen, wir haben unsere Netflix-Datenspenden eingelesen, wie oben anhand des YouTube-Beispiels illustriert, dann können wir nun alle Titel der Filme bzw. Serien in einem Objekt abspeichern:
unique_titles <- netflix_data_donations |>
  distinct(main_title)

# Wir ersellen nun eine neue csv-Datei, in der wir die Metdadaten später abspeichern werden
if (!file.exists("data/03_movie_data/jw_movie_info.csv")) {
  unique_titles |>
    mutate(
      # mit dieser URL können wir die Titel auf der Filmseite justwatch finden
      jw_search_url = str_c(
        "https://www.justwatch.com/de/Suche?q=",
        str_replace_all(main_title, pattern = " ", replacement = "%20")
      ),
      jw_item_url = "TBD",
      jw_is_on_netflix = "TBD"
    ) |>
    write_csv("data/03_movie_data/jw_movie_info.csv")
}

# Error Handling
safe_read_html <- possibly(rvest::read_html, otherwise = NA)
safe_html_attr <- possibly(rvest::html_attr, otherwise = NA)
safe_html_nodes <- possibly(rvest::html_nodes, otherwise = NA)

# Scraping Function definieren
scrape_jw <- function(data) {
  # Eine URL aus dem Datensatz auswählen
  one_url <- data |>
    sample(1)

  # Suche auf der Webseite durchführen
  html <- safe_read_html(one_url)

  # Die URL des ersten Treffers auswählen
  jw_item_url_x <-
    html |>
    safe_html_nodes(".title-list-row__column-header") |>
    pluck(1) |>
    safe_html_attr("href") |>
    str_c("https://www.justwatch.com", .)

  # Prüfen, ob das gefundene Item auf Netflix verfügbar ist
  jw_is_on_netflix_x <-
    html |>
    safe_html_nodes(".price-comparison__grid__row--stream") |>
    safe_html_nodes(".price-comparison__grid__row__element__icon") |>
    safe_html_nodes(".price-comparison__grid__row__icon") |>
    safe_html_attr("title") |>
    str_detect("Netflix") |>
    as_tibble() |>
    summarise(
      sum = sum(value),
      jw_is_on_netflix = if_else(sum > 0, TRUE, FALSE)
    ) |>
    pull(jw_is_on_netflix)

  # Im Datensatz abspeichern
  read_csv("data/03_movie_data/jw_movie_info.csv",
    show_col_types = FALSE,
    col_types = "cccc",
    progress = FALSE
  ) |>
    mutate(
      jw_item_url = if_else(jw_search_url == one_url, jw_item_url_x, jw_item_url),
      jw_is_on_netflix = if_else(jw_search_url == one_url, as.character(jw_is_on_netflix_x), jw_is_on_netflix)
    ) |>
    write_csv("data/03_movie_data/jw_movie_info.csv", progress = FALSE)

  # höfliches Warten
  Sys.sleep(sample(1:5, 1))
}

# Eine zufälle Stichprobe von n = 100 scrapen
n <- 100
read_csv("data/03_movie_data/jw_movie_info.csv",
  show_col_types = FALSE,
  col_types = "cccc"
) |>
  filter(jw_item_url == "TBD") |>
  sample_n(n) |>
  pull(jw_search_url) |>
  walk(scrape_jw, .progress = TRUE)

Wir haben nun für jeden Titel eine (hoffentlich) passende JustWatch-Seite gefunden. Im zweiten Schritt können wir nun die Metadaten scrapen:

# Neue Datei für zweiten Schritt anlegen
if (!file.exists("data/03_movie_data/jw_movie_info_2.csv")) {
  read_csv("data/03_movie_data/jw_movie_info.csv", show_col_types = FALSE) |>
    filter(!is.na(jw_item_url)) |>
    mutate(
      jw_desc = "TBD",
      jw_genre = "TBD",
      jw_runtime = "TBD",
      jw_fsk = "TBD",
      imdb_id = "TBD"
    ) |>
    write_csv("data/03_movie_data/jw_movie_info_2.csv")
}

# noch mehr error handling
safe_read_html <- possibly(rvest::read_html, otherwise = NA)
safe_html_attr <- possibly(rvest::html_attr, otherwise = NA)
safe_html_nodes <- possibly(rvest::html_nodes, otherwise = NA)
safe_html_text <- possibly(rvest::html_text, otherwise = NA)
safe_html_children <- possibly(rvest::html_children, otherwise = NA)

# Zweite Scraping Funktion definieren
scrape_jw2 <- function(data) {
  # Eine URL auswählen
  one_url <- data |>
    sample(1)

  # Seite laden
  html <- safe_read_html(one_url)

  # Filmbeschreibung extrahieren
  jw_desc_x <-
    html |>
    safe_html_nodes(".text-wrap-pre-line[data-v-45036d4d]") |>
    safe_html_text() |>
    ifelse(length(.) == 0, NA, .)

  # Genres extrahieren
  jw_genre_x <-
    html |>
    safe_html_nodes(".detail-infos__value") |>
    pluck(2) |>
    safe_html_text() |>
    ifelse(length(.) == 0, NA, .)

  # Laufzeit extrahieren
  jw_runtime_x <-
    html |>
    safe_html_nodes(".detail-infos__value") |>
    pluck(3) |>
    safe_html_text() |>
    ifelse(length(.) == 0, NA, .)

  # FSK-Rating extrahieren
  jw_fsk_x <-
    html |>
    safe_html_nodes(".detail-infos__value") |>
    pluck(4) |>
    safe_html_text() |>
    ifelse(length(.) == 0, NA, .)

  # IMDB ID extrahieren
  imdb_id_x <-
    html |>
    safe_html_nodes(".jw-scoring-listing__rating") |>
    pluck(2) |>
    safe_html_children() |>
    safe_html_attr("href") |>
    str_extract("(?<=https://www.imdb.com/title/)(.*)(?=/)") |>
    ifelse(length(.) == 0, NA, .)

  # In Datensatz speichern
  read_csv("data/03_movie_data/jw_movie_info_2.csv",
    show_col_types = FALSE,
    col_types = "ccccccccc",
    progress = FALSE
  ) |>
    mutate(
      jw_desc = if_else(jw_item_url == one_url, jw_desc_x, jw_desc),
      jw_genre = if_else(jw_item_url == one_url, jw_genre_x, jw_genre),
      jw_runtime = if_else(jw_item_url == one_url, jw_runtime_x, jw_runtime),
      jw_fsk = if_else(jw_item_url == one_url, jw_fsk_x, jw_fsk),
      imdb_id = if_else(jw_item_url == one_url, imdb_id_x, imdb_id)
    ) |>
    write_csv("data/03_movie_data/jw_movie_info_2.csv", progress = FALSE)

  # höfliches Warten
  Sys.sleep(sample(1:5, 1))
}

# Eine zufälle Stichprobe von n = 100 scrapen
n <- 100
read_csv("data/03_movie_data/jw_movie_info_2.csv",
  show_col_types = FALSE,
  col_types = "ccccccccc"
) |>
  filter(jw_desc == "TBD") |>
  sample_n(n) |>
  pull(jw_item_url) |>
  walk(scrape_jw2, .progress = TRUE)