空きっ腹のブルース

お腹が空いた冬

【Ruby】指定したチャンネルのYoutube動画をダウンロードする

次改善するとしたら

API を叩けば出てくる nextPageToken というのを使えば、自動で次の50件を取得できるらしい。 修了条件とか気にしなくなるからこれを使おう。

youtube-dl というツールを使えばYoutubeから動画を簡単にダウンロードできるのですが、毎回URLを指定しなければなりませんし、サムネやタイトルまでもダウンロードすることはできません。なのでRubyで簡単に動画情報&動画をダウンロードするスクリプトを作ったので備忘録代わりに書き残しておきます。つまづいた情報も書き残すので冗長になるので結果だけ欲しい方は一番下までスクロールしてください。

チャンネルを指定すると動画情報が得られるスクリプト

require 'net/http'
require 'uri'
require 'json'
require 'csv'
require 'pry'
require 'time'

API_KEY    = "AAAAAAAAAAAAAAAAAAAA" # YoutubeDataAPIのAPI_KEY
CHANNEL_ID = "HikakinTV" # 投稿者のチャンネルID
MAX_RESULT = 50 # 一度に取得できる動画の情報の数は50まで

@video_infos = []

# 引数の日時より前に投稿された動画の情報を取得しハッシュで返す
def get_json(published_before)
    base_url = "https://www.googleapis.com/youtube/v3/search?key=#{API_KEY}&channelId=#{CHANNEL_ID}&part=snippet&order=date&maxResults=#{MAX_RESULT}&publishedBefore="
    url = base_url + published_before
    uri = URI.parse(url)
    http = Net::HTTP.new(uri.host, uri.port)
    json = Net::HTTP.get(uri)
    result = JSON.parse(json)
    result
end

def rfc3339_to_date(rfc3339)
    time = rfc3339.scan(/\d+/)
    Time.parse("#{time[0]}-#{time[1]}-#{time[2]} #{time[3]}:#{time[4]}:#{time[5]}")
end

def date_to_rfc3339(date)
    date.utc.strftime('%FT%TZ')
end

# 一度のリクエストで最大50件しか動画の情報を取得できないので、publishedBefore パラメーターに
# 取得した最後の動画の投稿日+1秒を代入し、それ以前の動画を50件取得する。これを再帰関数で繰り返す
def retrieve_data(published_before)
    data = get_json(published_before)
      
    if data.dig('error', 'errors', 0, 'reason') == 'quotaExceeded'
        puts "アクセスできる回数を超えています。24時間お待ちください"
        exit
    end

   # ベースケースの処理。Youtubeの仕様でresultsPerPage==0 は動画を全て取得したときのサイン
    if data['pageInfo']['resultsPerPage'] == 0
        puts "動画の情報の収集が完了しました"
        return
    end
    
    data['items'].each do |video|
        title        = video['snippet']['title']
        uploader     = video['snippet']['channelTitle']
        video_id     = video['id']['videoId']
        video_url    = "https://www.youtube.com/watch?v=#{video_id}"
        thumbnails   = video['snippet']['thumbnails']['high']['url']
        published_at = video['snippet']['publishedAt']

       if video_id.nil?
            if video['id']['kind'] == "youtube#video"
                puts "非公開の動画なのでスキップ"
                next
            end
            if video['id']['kind'] == "youtube#channel"
                puts "チャンネル情報なのでスキップ"
                next
            end
        end        

        video_info = {title: title,  uploader: uploader, video_url: video_url, thumbnails: thumbnails, published_at: published_at}
        @video_infos.push(video_info)
    end
    
    oldest_published_at  = rfc3339_to_date(@video_infos.last[:published_at]) # rfc3339はStringなので時間操作するためにTime型に変更
    next_designated_time = date_to_rfc3339(oldest_published_at + 1) # 投稿時間+1秒に指定することで現在の動画を検索結果に含まない
    retrieve_data(next_designated_time)
end

now = date_to_rfc3339(Time.now) # 日時のフォーマットはRFC3339形式
retrieve_data(now)

File.open("youtube-list.json", "w") do |f|
    JSON.dump(@video_infos, f)
end

APIキーを取得する

GoogleAPIを使うために、googleで登録作業が必要になります。この記事に従って取得して、API_KEYという変数に書き込んでください。

qiita.com

データを取得する

まずデータを取得する方法は2つあります。一つはHTMLを解析して要素をチマチマ取得する方法。もう一つは公式が提供しているAPIを利用する方法です。公式のAPIを利用するのが一番ラクなのですが、使用制限があったり取得できるデータが制限されていたりと不便な点も多々あります。しかしYoutubeのHTMLの構造が複雑であることと、短時間で作りたいという理由から今回はAPIを利用することにしました。

APIを利用するときはネットで検索して実装例と公式のAPIを調べるのが定石です。 Youtube API rubyYoutube 動画 ダウンロード api などで検索しましたがめぼしいものも見つからなかったので英語で検索します。 how to retrieve youtube videos channel api stackoverflow と検索したらそれっぽいものが出てきたのでこのサイトを参考にしました。

stackoverflow.com

この回答によると https://www.googleapis.com/youtube/v3/search?key={APIキー}&channelId={チャンネルID}&part=snippet,id&order=date&maxResults=20 というURLにするとアクセスできるそうです。パラメーターを見るとResults=20と書いてあるので一度に取得している動画数が20のようです。しかし20と言わず100くらい欲しいので数値を変えましたがどうやらAPIでは50に制限されているようでした。

さて、APIのURLが分かったところで次にAPIで得られるデータの全体把握に移りましょう。こればかりはドキュメントを眺めたりやってみたブログを見るよりも、返ってくるデータを眺めて慣れるのが一番です。それぞれのサイト特有の構造だったりルールだったりごちゃごちゃしているので手を動かし概要を把握しましょう。そこでデータを可視化するのに便利なアプリがPostmanです。

通常、APIにアクセスすると帰ってくるのはJSONなのでとても見づらいです。しかしPostmanを使うとAPIで取得したデータをとても見やすくしてくれたり、結果を保存できたり何かと便利なアプリです。Webエンジニア必須のアプリなのでぜひダウンロードしてください。 www.postman.com

APIの呼び出しをすると次のような構造のデータが返ってきます。

{
  "kind": "youtube#searchListResponse",
  "etag": etag,
  "nextPageToken": string,
  "prevPageToken": string,
  "pageInfo": {
    "totalResults": 結果セット内の結果の総数,
    "resultsPerPage": API レスポンスに含まれる結果の数(今回のコードでは動画情報だけとは限らない)
  },
  "items": [
     "items": [
        {
            "kind": "youtube#searchResult",
            "etag": etag
            "id": {
                "kind": "youtube#video",
                "videoId": 1つの動画に割り当てられたid
            },
            "snippet": {
                "publishedAt": 投稿時間
                "channelId": チャンネルのID
                "title": 動画タイトル
                "description": 概要欄
                "thumbnails": {
                    "default": {
                        "url": サムネのURL
                        "width": 120,
                        "height": 90
                    },
                    "medium": {
                        "url": サムネのURL
                        "width": 320,
                        "height": 180
                    },
                    "high": {
                        "url": サムネのURL
                        "width": 480,
                        "height": 360
                    }
                },
                "channelTitle": チャンネル名
                "liveBroadcastContent": ライブ配信か否か
                "publishTime": 投稿時間
            }
        },

 
      (以下同様に49個続く)

  ]
}

当然この日本語で書いてる部分は実際のデータが入っています。初見の場合はこれらAPIをじっくり眺めて、どこになんの情報があるかを調べるわけです。自分が欲しかった情報は動画リンク、タイトル、投稿時間、サムネなのでvideoid、publishedAt、title、thumbnailのdefaultのurlを使いました(コード参照)。videoidだけでは動画のリンクになりません。'https://www.youtube.com/watch?v='の後にvideoidをつけるとリンクになるので、そこは変数の足し算で作りました。

再起関数

繰り返し処理は'each'を使って一発でサクッと行きたいところですが、APIの使用上、一度に取得できるレスポンス数は最大で50でした。なので再起関数を使うこと、で繰り返しの繰り返しを実装しました。

再起関数はややこしいのですがこちらの記事のテンプレートを参考にして作りました。ちなみに、この人が書いた本はよかったのでおすすめです。けんちょん本で検索。このコードの概要は次のようなものです。

retrieve_data(引数) {
  if (ベースケース) {
    return ベースケースに対する値;
  }

  retrieve_data(次の引数)
  return (答え)
}

ベースケースとは再起関数を終了するときの条件のことです。今回の流れを考えると、一度のリクエストごとに50本の動画を呼び出し、それをチャンネルの全動画を取得するまで繰り返し、呼び出される動画情報が0になった場合がベースケースです。上に書いたAPI構造を元に考えると

    if data['pageInfo']['resultsPerPage'] == 0
        puts "動画の情報の収集が完了しました"
        return
    end

これがベースケースに対する処理になります。

再帰処理でややこしくなってしまったコードが次の3行です

    oldest_published_at  = rfc3339_to_date(@video_infos.last[:published_at])
    next_designated_time = date_to_rfc3339(oldest_published_at + 1) 
    retrieve_data(next_designated_time)

このコードの意味は再起関数を呼び出すとき、取り出した50の動画のうち、一番最後の動画を取り出し、投稿時間に+1秒を加えることで、重複なく呼び出すようにしていることです。 Youtubeで使われている時間はRFC3339形式というもので保存されています。しかしRubyで時間を取り扱うにはTimeクラスが便利です。なのでこの2つの形式を行き来する関数があると便利です。このような関数は次の節で解説しているのでここでは書きません。 とにかく、取り出した動画で一番最後の動画の投稿時間に1秒加え、再起関数を呼び出し、APIを呼び出しているのを把握してください。

時間のとり扱い

時間を取り扱うときはその形式を確認することが大切です。Rubyでは標準でDateクラス、Timeクラス、DateTimeクラスの3つが提供されていますがYoutubeではRFC 3339という形式が使われています。Rubyでは標準でRFC 3339形式に変換するメソッドが提供されていないので自作しましょう。 RFC3339形式というのは具体的にこのようなものです。

2006-04-13T14:12:53.4242+05:30

これの意味するものは

UTC から5時間30分進んだ地方時における、 2006年4月13日14時12分53秒4242 を表しています。

https://wiki.suikawiki.org/n/RFC%203339%E3%81%AE%E6%97%A5%E4%BB%98%E5%BD%A2%E5%BC%8F

とのことですが僕には理解できませんでした。なのでStackOverflowのコードをそのままコピペします😤

stackoverflow.com

#DateクラスからRFC3339形式に変換
def date_to_rfc3339(date)
    date.utc.strftime('%FT%TZ')
end

次に時間の形式を逆戻しするメソッドも作っておきましょう。TimeクラスをRFC3339形式に変換するメソッドを使ったのはYoutubeの仕様に合わせるためなのですが、Rubyでこの形式を扱うのは不便です。そこでRFC 3339形式からTimeクラスに変換するメソッドも作っておきましょう。

#RFC3339形式からTimeクラスに変換
def rfc3339_to_date(rfc3339)
    time = rfc3339.scan(/\d+/)
    Time.parse("#{time[0]}-#{time[1]}-#{time[2]} #{time[3]}:#{time[4]}:#{time[5]}")
end

こちらは簡単で正規表現をつかいRFC3339形式から日付と時間を抜き出しておきましょう。scanメソッドを使うと条件にマッチした文字列を配列で返してくれます。その数字をTime.parse の引数に代入し、RFC3339形式からTimeクラスへ変換しようとする腹です。

Timeクラスで新しい日付を制作する方法はいろいろあるのですが、 (年)-(月)-(日) (時):(分):(秒) という引数にするのがオーソドックスです。Webクローラーを作るときはこうやって日付の変数を作ることがよくあるので覚えておきましょう。

文字列から時刻オブジェクト (Time) に変換する - まくまくRubyノート

エラー処理

再起関数をグルグル回して全部のデータを収集できたらそれが一番ラクなのですが、それではエラーが出たり、不要なデータまで収集してしまうのでif文で弾く必要があります。今回は2つありました。

  1. アクセス制限を超えてしまった
  2. 再生リスト、 投稿者情報を含めそうになった
アクセス制限を超えてしまったときの処理
if data.dig('error', 'errors', 0, 'reason') == 'quotaExceeded'
    puts "アクセスできる回数を超えています。しばらくしてから試してください"
    exit
end

APIを使っている以上、何度もデータを収集しているとアクセス制限に引っかかります。そのときに出るのが'quotaExceeded'というエラーです。対処法はなく、24時間待つしかありません。どうしても急いでる人はGoogleアカウントを複数作って、APIキーを複数使いまわしてみるといいと思います。何回アクセスしたらエラーが出るのかという計算方法は、以下のURLに書いてある通り単純じゃないので調べられませんでした。 https://developers.google.com/youtube/v3/getting-started?hl=ja#quota

まあ、アクセス制限に引っかかるときは大半の場合、コードの書き間違いで無限ループに引っかかりアクセス回数を無駄に消費してしまったときでした。なので完成するまでは、再起関数を呼び出すごとに取得した最新の動画のタイトルを1つ表示させるなどして、無限ループに気づけるように工夫してました。同じタイトルが何度も並んでいるとループにはまっていると気づけます。また、一度のAPIの呼び出しで引っ張ってくる動画数を50から10にすることでアクセス回数を節約してました。無限ループにハマった時は急いで'Ctrl + C' を押してプログラムの実行を強制終了しましょう。

再生リスト、投稿者情報を含めそうになった
if video_id.nil?
    if video['id']['kind'] == "youtube#video"
        puts "再生リストの動画なのでスキップ"
            next
    end
    if video['id']['kind'] == "youtube#channel"
        puts "チャンネル情報なのでスキップ"
        next
    end
end

APIの呼び出しで投稿されている動画だけ収集できればいいのですが、再生リストや投稿者の情報まで呼び出されてしまいました。CSVに入れるには邪魔なので'if'文で弾いておく必要があります。チビチビ試したのでこんな愚直で汚いコードになりましたが、APIリクエストで一手間工夫を加えればこんなことしなくても済んだのかなと思います。公式サイトのHTTPリクエストの仕様には'type'というパラメーターがあるのでこれを'video'にする、すなわちURLの末尾に'&type=video'みたいにすれば(この記法がわからない人はクエリパラメーターで検索)、上に書いてあるチャンネル情報の分岐はいらなかったのかな、なんて。今回のコードには反映してませんがしたい人はどうぞ。

データを格納する

Webクローラーで収集したデータは配列の中にハッシュを入れる構造がいいと個人的に思ってます。ハッシュだとデータのラベルも付けれるし、配列に入れておくことでハッシュを格納できるからです。また、配列になっているのでeach文を使って取得したデータを繰り返し同じ処理をすることができることもこの形にするメリットです。

配列の中にハッシュを入れるとは、次のような構造になっています。

[
  { title: "歌ってみた",
    url: "",
    thumb_url: ""
  },
  { title: "食べてみた",
    url: "",
    thumb_url: ""
  },
      :
      :
      :
      :
]

謝罪

申し訳ありません。チャンネルを指定すると動画情報をcsvに変換できるファイルは作れたのですが、そこから先のurlからmp4でダウンロードするコードがわからなかったのでこちらの方の記事を丸々コピペします。

qiita.com

コードの使い方

コードが長くなってしまったので、コードはファイルごとにpastebinに貼り付けました。 そのままファイル名とコードをコピペしてお使いください

  1. video-info-dl.rb を実行する。これでyoutube-list.jsonというファイルができる require 'net/http'require 'uri'require 'json'require 'csv'require 'pry' - Pastebin.com

  2. scrape-download-urls.rb を実行。これで youtube-list.jsonから動画のurlだけをまとめたdownload_url.txtを作る scrape-download-urls.rb - Pastebin.com

  3. video-dl.rb を実行する。これで動画がダウンロードが開始される。 video-dl.rb - Pastebin.com

  4. thumbnails-dl.rbを実行。サムネイル画像をダウンロード

thumbnails-dl.rb - Pastebin.com