Kanalarbeiter (Linux-Magazin, Januar 2024)

Um den Erfolg seiner Youtube-Videos im Auge zu behalten, schreibt Mike Schilli ein Go-Programm, das die Zuschauerzahlen des Youtube-Kanals über die Google-API einholt und grafisch aufmöbelt.

Begleitend zu diese Kolumne erscheint ja ein Youtube-Kanal, auf dem zu jeder Ausgabe des Programmier-Snapshots ein Screencast läuft (Abbildung 1). Dort erklärt der werte Autor die wesentlichen Schritte zum Zusammenbauen und Ausprobieren der vorgestellten Go-Programme. Video schauen ist ja bequemer als Lesen, und wir machen es unseren Lesern ja so einfach wie möglich. Wie wäre es heute mal mit einem Go-Programm, das die Zuschauerzahlen des Kanals dort abfrägt?

Abbildung 1: Der Youtube-Channel des Linux-Magazins

Registrieren, dann Probieren

Vor das Einsammeln der kostbaren Zuschauerzahlen per API hat Google allerdings die Registrierung ([2]) gestellt, um Bot-Aktivitäten im Auge zu behalten und notfalls per Quota-Regelung zu beschränken. Dabei unterscheidet Google zweierlei Mechanismen zur Authentifizierung eingehender API-Anfragen. Lesen Requests private Userdaten oder wollen gar im Auftrag des Users den Server beschreiben, müssen Oauth-Credentials her. Aber für das Lesen von Daten, die sowieso schon öffentlich verfügbar sind, wie zum Beispiel die Anzahl der Aufrufe eines Videos, genügt ein API-Key zu einem flugs registrierten Projekt auf der Google-Konsole (console.cloud.google.com), das die Youtube-API angeknipst hat (Abbildung 2).

Abbildung 2: Anschalten der Youtube-v3-API

Ein API-Key ist auch schnell erzeugt (Abbildungen 3 und 4) und dieser erlaubt es der App, auf den öffentlichen Daten herumzuorgeln. Um Missbrauch mit gestohlenen Keys einzuschränken, empfiehlt es sich aber, die Gültigkeit erzeugter Schlüssel unter "Restrict API-Key" auf die tatsächlich benötigten Bereiche einzuschränken. Im vorliegenden Fall also auf die Youtube-API (Abbildung 5).

Abbildung 3: API-Key generieren

Abbildung 4: API wurde erzeugt, einfach kopieren.

Abbildung 5: Der API-Key ist auf Youtube beschränkt.

Zwar dürfte, wie gesagt, die unartige App eines Schlüsselräubers mit der Beute keine Daten modifizieren, doch sie könnte böswilig die Quota-Zuteilung des Bestohlenen aufbrauchen, deswegen ist es eine gute Idee, die Reichweite zu beschränken.

Fragen statt Scrapen

Bevor es nun ans Auslesen der Videos eines Youtube-Channels geht, benötigen API-Abfragen dessen interne Channel-ID. Dies ist nicht, wie man meinen könnte, der Benutzername des Users, sondern ein Hex-String, den Google im Source-Code der Webseite des Channels unter dem Eintrag "externalId" versteckt (Abbildung 6). Allerdings verurteilen Googles Nutzungsbedingungen derartiges Screen-Scraping aufs Schärfste, und als mustergültiger Netzteilnehmer holt Listing 1 die ID deswegen mittels einer Suchanfrage an den Youtube-API-Server aus dem "Search"-Bereich der offiziellen Client-API ein.

Abbildung 6: Die Channel-ID steht versteckt in der Channel-Seite.

Listing 1: ytsearch.go

    01 package main
    02 import (
    03   "fmt"
    04   "log"
    05 )
    06 func main() {
    07   service, err := apiInit()
    08   if err != nil {
    09     log.Fatalf("%v", err)
    10   }
    11   query := "Linux Magazin"
    12   resp, err := service.Search.List([]string{"snippet"}).Q(query).Type("channel").Do()
    13   if err != nil {
    14     log.Fatalf("%v", err)
    15   }
    16   for i := 0; i < len(resp.Items); i++ {
    17     snippet := resp.Items[i].Snippet
    18     fmt.Printf("%20s: %s\n", snippet.ChannelTitle, snippet.ChannelId)
    19   }
    20 }

Das fertig kompilierte Binary aus den Sourcen von Listing 1 und Listing 2 erzeugt die Ausgabe nach Abbildung 7. Sie zeigt, dass der erste Treffer auf die Suchanfrage nach "Linux Magazin" tatsächlich die in Abbildung 6 untermalte Channel-ID daherbringt, also ist den Terms Of Service Genüge getan. Dazu ruft Zeile 12 in Listing 1 die Methode List() des in Listing 2 erzeugten API-Clients auf und übergibt ihr einen Array-Slice mit dem String "snippet" als einziges Element. Die Dokumentation unter [3] beschreibt die bereitgestellten Datentypen der API im Detail, und in snippet liegen für gefundene Channels im Ergebnis dann Titel und Beschreibung des Kanals, sowie Referenzen auf Playlisten, um die dort vorgestellten Videos abzuspielen.

URL Stück für Stück

Bevor es ans Absetzen der Anfrage an den Server geht, hilft die Client-API beim Aufbau der dafür notwendigen URL. Die Methoden des API-Clients (List(), Q(), Type() und so weiter) geben jeweils ein Objekt mit der bis dato zusammenstöpselten URL für die Anfrage zurück. So hängt Zeile 12 zum Beispiel mit .Q() den Suchstring an und setzt anschließend mit Type() den URL-Parameter für den Typus gewünschter Suchergebnisse auf "channel". Das abschließende .Do() führt die Webanfrage aus und liefert die Anwort des Servers in eine Go-Datenstruktur verpackt in der Variablen resp zurück.

Diese wiederum enthält eine Struktur, die im Attribut Items die Treffer als Array-Slice enthält, die wiederum auf Strukturen zeigen. Unter Snippet finden sich dort dann Details zu den Treffern, und darunter wiederum unter ChannelTitle und ChannelId der Titel und der gesuchte Hex-String der Channel-ID.

Abbildung 7: Die Channel-IDs Linux-Magazin-ähnlicher Treffer

Listing 2: api-init.go

    01 package main
    02 import (
    03   "context"
    04   "flag"
    05   "log"
    06   "google.golang.org/api/option"
    07   "google.golang.org/api/youtube/v3"
    08 )
    09 func apiInit() (*youtube.Service, error) {
    10   apiKey := flag.String("api-key", "", "API Key")
    11   flag.Parse()
    12   if *apiKey == "" {
    13     log.Fatalf("Provide an API Key")
    14   }
    15   ctx := context.Background()
    16   service, err := youtube.NewService(ctx, option.WithAPIKey(*apiKey))
    17   return service, err
    18 }

Bequemer Service

Listing 1 greift für Abfragen mit service bequem auf ein Service-Objekt zur Client-API zu, verlässt sich aber für dessen Aufbau auf die Funktion apiInit() in Listing 2. Damit Anfragen auch den API-Token an den Server übermitteln, ohne den letzterer nur Fehlermeldungen zurückgäbe, nimmt apiInit() den Token vom User auf der Kommandozeile entgegen. Dies passiert mit der Option --api-key beim Aufruf des kompilierten Binaries. Gos Kommandozeilenparser schnappt ihn sich in Zeile 11 mit Parse() und legt ihn als Pointer in der Variablen apiKey ab. Das in Zeile 16 erzeugte Service-Objekt schluckt den Token für später und erhält zusätzlich noch ein Context-Objekt spendiert. Letzteres könnte das Programm dazu nutzen, um angestoßene Requests per Fernsteuerung abzubrechen, davon macht Listing 2 aber keinen Gebrauch. Am Ende der Funktion apiInit() gibt Zeile 17 die fertige service-Variable ans Hauptprogramm zurück, das damit autorisierte Requests an Googles API-Server abfeuern kann.

Listing 3: three.cmd

    1 $ go mod init ytsearch
    2 $ go mod tidy
    3 $ go build ytsearch.go api-init.go

Listing 3 zeigt den mit Go üblichen Dreisprung, um aus den Sourcen von Listing 1 und 2 das Binary ytsearch zu bauen. Damit zieht der Go-Compiler unter anderem die Sourcen des Youtube-API-Clients vom Google-Server, compiliert sie und linkt alles zu einem ausführbares Programm zusammen, dessen Ausgabe (mit einem wie vorher beschrieben eingeholten gültigen API-Key) Abbildung 7 zeigt.

Universale Playlist

Gewappnet mit dieser Channel-ID kann nun Listing 4 daran gehen, die Metadaten einzelner Videos des Channels einzuholen. Das ist gar nicht mal so einfach, denn zuerst ruft Zeile 13 die Eckdaten des Channels ab, indem sie der API-Funktion List den Parameter contentDetails mitgibt. In der Antwort steht unter "RelatedPlaylists" der Eintrag "Uploads", der die ID einer Playlist angibt, die alle Videos des Channels enthält.

Listing 4: channel.go

    01 package main
    02 import (
    03   "google.golang.org/api/youtube/v3"
    04   "log"
    05 )
    06 type chStats struct {
    07   vid   string
    08   title string
    09   views uint64
    10 }
    11 func channelViews(service *youtube.Service, id string) ([]chStats, error) {
    12   stats := []chStats{}
    13   resp, err := service.Channels.List([]string{"contentDetails"}).Id(id).Do()
    14   if err != nil {
    15     log.Fatalf("%v", err)
    16   }
    17   if len(resp.Items) == 0 {
    18     log.Fatal("Channel not found")
    19   }
    20   plid := resp.Items[0].ContentDetails.RelatedPlaylists.Uploads
    21   pageToken := ""
    22   for {
    23     plResp, err := service.PlaylistItems.List([]string{"snippet"}).
    24       PlaylistId(plid).
    25       MaxResults(50).
    26       PageToken(pageToken).
    27       Do()
    28     if err != nil {
    29       log.Fatalf("%v", err)
    30     }
    31     for _, item := range plResp.Items {
    32       videoID := item.Snippet.ResourceId.VideoId
    33       videoResp, err := service.Videos.List([]string{"statistics"}).
    34         Id(videoID).
    35         Do()
    36       if err != nil {
    37         log.Fatalf("%v", err)
    38       }
    39       video := videoResp.Items[0]
    40       viewCount := video.Statistics.ViewCount
    41       stats = append(stats, chStats{vid: videoID, views: viewCount, title: item.Snippet.Title})
    42     }
    43     pageToken = plResp.NextPageToken
    44     if pageToken == "" {
    45       break
    46     }
    47   }
    48   return stats, nil
    49 }

Im Servicebereich PlaylistItems startet nun Zeile 23 eine Abfrage der Titel dieser Upload-Playlist. Auf unerfahrene Nutzer der Google-API wartet hier allerdings die Paging-Falle: Auch wer in MaxResults eine hohe Zahl erwarteter Videos angibt, wird feststellen, dass der API-Server immer nur maximal 50 pro Request ausliefert. Der Rest kommt später in nachfolgenden Anfragen, aber die müssen pageToken gesetzt haben. Der Wert dafür kommt in vorhergehenden Server-Antworten mit, falls der Server nicht alles eingepackt hat, sondern auf einen erneuten Aufruf für den Rest wartet.

Nun liegen die Eckdaten der Videos des Channels vor, allerdings fehlen noch die Zuschauerzahlen. Für diese iteriert die for-Schleife in Zeile 31 über alle Einträge der Playlist und ruft für jedes Video die List()-Funktion mit dem Parameter statistics auf. Zurück kommt für jeden Request unter anderem der Eintrag ViewCount, und Zeile 41 füllt damit ein Array-Slice mit Strukturen vom Typ chStats (definiert ab Zeile 6) mit den Ergebnissen.

Listing 5: ytfetch.go

    01 package main
    02 import (
    03   "log"
    04   "fmt"
    05   "encoding/csv"
    06   "os"
    07 )
    08 func main() {
    09   service, err := apiInit()
    10   if err != nil {
    11     log.Fatal("%v", err)
    12   }
    13   stats, err := channelViews(service, "UCWmOnWFWo_NJf_ZJqgf-LwQ")
    14   if err != nil {
    15     log.Fatalf("%v", err)
    16   }
    17   w := csv.NewWriter(os.Stdout)
    18   defer w.Flush()
    19   for _, stat := range stats {
    20     w.Write([]string{stat.vid, fmt.Sprintf("%d", stat.views), stat.title})
    21   }
    22 }

Abbildung 8: Die Youtube-Client-API holt Videos eines Channels

Mit Kommas separiert

Nun bleibt dem Hauptprogramm in Listing 5 nur noch, den Client-API zu initialisieren und channelViews() mit der Channel-ID des Kanals des Linux-Magazins in Zeile 13 aufzurufen, schon kommen Videotitel, IDs, und die Zuschauerzahlen zurück, die Zeile 20 zeilenweise im CSV-Format auf die Standardausgabe schreibt. Die Utility ytfetch compiliert sich mittels go build ytfetch.go api-init.go und Aufruf und Ausgabe des Binaries zeigt Abbildung 8. Mit der Shell lassen sich die Zeilen der Ausgabe in eine .csv-Datei umlenken, für später laufende Auswertungen.

Grafisch ausgeschmückt

Wie viele Videos im Channel sind nun heiß begehrt und welche dümpeln vor sich hin? Visuell lässt sich das leichter ablesen als in Zahlenkolonnen und Listing 6 schickt sich deshalb an, aus den .csv-Daten eine formschöne Balkengrafik zu erzeugen.

Listing 6: views.go

    01 package main
    02 import (
    03   "encoding/csv"
    04   "os"
    05   "strconv"
    06   "github.com/wcharczuk/go-chart/v2"
    07 )
    08 func main() {
    09   file, err := os.Open("ytfetch.csv")
    10   if err != nil {
    11     panic(err)
    12   }
    13   defer file.Close()
    14   reader := csv.NewReader(file)
    15   records, err := reader.ReadAll()
    16   if err != nil {
    17     panic(err)
    18   }
    19   values := []chart.Value{}
    20   for _, record := range records {
    21     v, err := strconv.ParseFloat(record[1], 64)
    22     if err != nil {
    23       panic(err)
    24     }
    25     values = append(values, chart.Value{Value: v})
    26   }
    27   graph := chart.BarChart{
    28     Title: "Youtube Views By Video",
    29     Background: chart.Style{
    30       Padding: chart.Box{
    31         Top: 40,
    32       },
    33     },
    34     Height:   512,
    35     BarWidth: 60,
    36     Bars:     values,
    37   }
    38   f, _ := os.Create("views.png")
    39   defer f.Close()
    40   graph.Render(chart.PNG, f)
    41 }

Dazu nutzt es das Go-Paket go-chart von Github, liest die .csv-Daten mit dem Standardmodul encoding/csv aus der vorher erzeugten Datei aus und füttert sie ab Zeile 27 in eine Struktur vom Typ BarChart. Als Ergebnis malt Zeile 40 mit Render() den Grafen in die vorher geöffnete Datei views.png. Abbildung 9 zeigt anschaulich, dass es nur wenige Videos in der Beliebtheitsskala über 10.000 Views schaffen, die breite Masse erreicht nur zwei- oder dreistellige Werte.

Abbildung 9: Views der Videos im Channel

Hitparade

Welche Videos sind die erfolgreichsten? Dazu sortiert Listing 7 die ausgelesenen .csv-Daten absteigend nach Zuschauerzahlen. Die Standardfunktion sort.Slice() nimmt dazu einen Array entries mit den gesammelten Statistiken aller Videos entgegen und definiert in einem Callback, wie zwei Einträge miteinander zu vergleichen sind.

Das Ganze geht nicht so einfach wie in einer Scriptsprache wie Python, denn Go fordert strenge Typen. Also definiert Zeile 15 eine Struktur entry mit Videotitel und einem numerischen Zuschauerwert. Die for-Schleife ab Zeile 21 baut aus den CSV-Daten einen Array-Slice mit solchen Einträgen auf. Die absteigend nach Zuschauerzahlen sortierten Einträge beschränkt die Slice-Operation in Zeile 32 mit entries[0:6] auf die ersten sechs (und damit besten) und die nachfolgende For-Schleife ab Zeile 33 gibt sie aus.

Listing 7: yttop.go

    01 package main
    02 import (
    03   "encoding/csv"
    04   "fmt"
    05   "os"
    06   "sort"
    07   "strconv"
    08 )
    09 func main() {
    10   file, err := os.Open("ytfetch.csv")
    11   if err != nil {
    12     panic(err)
    13   }
    14   defer file.Close()
    15   type entry struct {
    16     title string
    17     views int64
    18   }
    19   entries := []entry{}
    20   r := csv.NewReader(file)
    21   for {
    22     e, err := r.Read()
    23     if err != nil {
    24       break
    25     }
    26     v, _ := strconv.ParseInt(e[1], 10, 64)
    27     entries = append(entries, entry{title: e[2], views: v})
    28   }
    29   sort.Slice(entries, func(i, j int) bool {
    30     return entries[i].views > entries[j].views
    31   })
    32   entries = entries[0:5]
    33   for _, e := range entries {
    34     fmt.Printf("%5d %s\n", e.views, e.title)
    35   }
    36 }

Abbildung 10: Die sechs meistgespielten Videos im Channel

Es stellt sich heraus, dass die erfolgreichsten Videos des Channels des Linux-Magazins einige Knoppix-Videos sind, gefolgt vom erfolgreichsten Programmier-Snapshot aller Zeiten, über die Vor- und Nachteile der Programmiersprache Go. Der zweitbeste Snapshot dreht sich um Gesichtserkennung in Fotos, und ist aus dem Jahr 2018. Bis zum Influenzer-Millionär ist es zwar noch ein weiter Weg, aber die Richtung stimmt.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2024/01

[2]

Youtube-Projekt registrieren: https://developers.google.com/youtube/registering_an_application

[3]

Ergebnisse von Suchanfragen mit der Google-API dokumentiert: https://developers.google.com/youtube/v3/docs

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.