以前、YOUTUBE動画のタイトルとURL、チャンネルを指定して丸ごと取得するをやりました。このときは取得したタイトルとurlはExcelファイルに保存しましたが、今回はGoogle スプレッドシートに表示させます。デスクトップアプリの場合はC#でやればいいのですが、今回はNode.Jsを使います。そしてPuppeteerというライブラリを使います。
Contents
HerokuでPuppeteerを使う
Puppeteer はブラウザ自動化ライブラリです。C#で使えるようにしたものがPuppeteerSharpです。他の言語にも対応したものが作られていて、PHPならPuPHPeteer、PythonならPyppeteerがあります。
ということで今回は動画検索をして上位10件を表示させます。それから以下のNode.jsのスクリプトをエックスサーバーで実行しようとしたのですが、うまくいきませんでした。下記のサイトと同じところでつっかえてしまい、諦めることにしました。
そこで代わりの方法としてHerokuというサービスを使います。
puppeteerの他にrequestとexpressを使います。ではコードを書いていきましょう。
1 2 3 4 5 |
const puppeteer = require('puppeteer'); const request = require('request'); const express = require('express'); const app = express(); |
それから取得した動画タイトルとurlを管理するためのクラスをつくります。
1 2 3 4 5 6 |
class PageData { videoTitle = ""; videoUrl = ""; channelTitle = ""; channelurl = ""; } |
おおまかな処理について
最初に実行されるmain関数をつくります。トップページにはとくになにも表示させません。/search/keyword/検索したいキーワード/max/10にアクセスすると検索したいキーワードで検索して上位10件をSearch関数で検索して、その結果をスプレッドシートに書き込みます。もし引数が不正であれば(maxが数字でないとか)、ページに引数が不正である旨表示させます。
それからポート番号ですが、Herokuを使う場合はprocess.env.PORT || 3000でないとうまく動いてくれません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
const portNumber = process.env.PORT || 3000; async function main() { app.get('/', (req, res) => { res.send('ここはトップページです'); }); app.get('/search/keyword/:keyword/max/:max', async(req, res) => { try { let keyword = req.params.keyword; let max = req.params.max; if(keyword == undefined || isNaN(max)){ res.send('引数が不正'); console.log('引数が不正'); return; } console.log(keyword + ' で ' + max + ' 件 取得します。'); let datas = await Search (keyword, max); // 自作関数 let sendText = CreateSendText(datas); // 自作関数 try { SaveToSpreadsheet(sendText); // 自作関数 res.send(sendText); } catch(err){ res.send('SaveToSpreadsheetが失敗'); console.log('SaveToSpreadsheetが失敗 ' + err); } } catch(err){ res.send('引数が不正'); console.log('引数が不正 ' + err); } }); var server = app.listen(portNumber, function() { console.log("listening at port %s リッスン中!", server.address().port); }); return; } |
検索結果を取得するSearch関数
検索結果を取得するSearch関数を示します。
まずヘッドレスブラウザを起動します。そのときのオプションなのですが、puppeteer.launchに渡す引数は、args: [‘–no-sandbox’, ‘–disable-setuid-sandbox’]でなければなりません。Heroku環境かどうかはprocess.env.DYNOが真か偽かで判断できるので三項演算子をつかってLAUNCH_OPTIONにセットしています。
ヘッドレスブラウザを起動したら動画検索をしたときのurlは https://www.youtube.com/results?search_query=検索したいキーワード なので実際にそこにアクセスします。
HTMLを解析して「div.text-wrapper.style-scope.ytd-video-renderer」の要素を抜き出します。ここに動画のタイトルとurlがかかれた要素が存在します。ただし取得したい件数が増えるとスクロールしないと検索結果の一部しか取得できません。全部取得してみて取得したい数よりも少なければスクロールさせて、より多くの結果を取得しようとします。取得した要素数がmaxよりも多ければそれ以上の処理は必要ないので、ループから抜けます。
必要なだけ要素が取得できたら、ここから動画タイトルと動画urlを抜き出します。「div.text-wrapper.style-scope.ytd-video-renderer」のなかの「yt-formatted-string.style-scope.ytd-video-renderer」から動画のタイトルが、「a#video-title.yt-simple-endpoint.style-scope.ytd-video-renderer」から動画のurlが、「yt-formatted-string#text.style-scope.ytd-channel-name.complex-string」からチャンネル名が、「a.yt-simple-endpoint.style-scope.yt-formatted-string」からチャンネルのurlが取得できます。
取得したデータをPageDataクラスのインスタンス内に格納していきます。そしてこのインスタンスはdatasという配列に格納され、Search関数はdatasを返します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
async function Search (keyword, max){ console.log('ヘッドレスブラウザを起動しています'); // Heroku環境かどうかの判断 const LAUNCH_OPTION = process.env.DYNO ? { args: ['--no-sandbox', '--disable-setuid-sandbox'] } : { headless: true }; const browser = await puppeteer.launch(LAUNCH_OPTION); const page = await browser.newPage(); let datas = []; try { console.log('ヘッドレスブラウザが検索結果を表示するページにアクセスしようとしています'); let url = `https://www.youtube.com/results?search_query=${keyword}`; await page.goto(url); console.log('ヘッドレスブラウザが検索結果を表示するページにアクセスしました'); let scrollCount = 0; while(true){ const elms = await page.$$('div.text-wrapper.style-scope.ytd-video-renderer', divs1 => divs1); let lastElm = elms[elms.length-1]; await lastElm.hover(); console.log("スクロール処理を実行しています " + scrollCount++); if(elms.length > max) break; } const elms = await page.$$('div.text-wrapper.style-scope.ytd-video-renderer', divs => divs); console.log("これより動画urlを取得します。"); let count = elms.length > max ? max:elms.length; for(let i=0; i<count; i++) { let newData = new PageData(); datas.push(newData); // 動画のタイトルを取得 const elm1 = await elms[i].$('yt-formatted-string.style-scope.ytd-video-renderer', div => div); let text1 = await elm1.getProperty("textContent"); let videoTitle = text1.toString().slice(9); console.log("VideoTitle =" + videoTitle); newData.videoTitle = videoTitle != '' ? videoTitle : '取得できない'; // 動画のurlを取得 const elm2 = await elms[i].$('a#video-title.yt-simple-endpoint.style-scope.ytd-video-renderer', div => div); let link1 = await elm2.getProperty("href"); let videoUrl = link1.toString().slice(9); console.log("videoUrl =" + videoUrl); newData.videoUrl = videoUrl != '' ? videoUrl : '取得できない'; // チャンネル名を取得 const elm3 = await elms[i].$('yt-formatted-string#text.style-scope.ytd-channel-name.complex-string', div => div); let text2 = await elm3.getProperty("textContent"); let channelTitle = text2.toString().slice(9); console.log("channelTitle =" + channelTitle); newData.channelTitle = channelTitle != '' ? channelTitle : '取得できない'; // チャンネルのurlを取得 const elm4 = await elms[i].$('a.yt-simple-endpoint.style-scope.yt-formatted-string', div => div); let link2 = await elm4.getProperty("href"); let channelurl = link2.toString().slice(9); console.log("channelurl =" + channelurl); newData.channelurl = channelurl != '' ? channelurl : '取得できない'; } console.log('動画urlを ' + datas.length + '件 取得しました'); } catch (err) { // エラーが起きた際の処理 console.log("Search関数でエラー発生!!" + err); return; } finally { await page.close(); await browser.close(); } console.log("Search関数 処理完了"); return datas; } |
スプレッドシートに送る文字列の生成
次に取得されたデータをスプレッドシートに送ることができるように、文字列に変換します。スクレイピングしたデータをGASを使ってスプレッドシートに書き込むのは簡単と思っていたら意外に難儀した話にあるように二次元配列として送っても処理をしてくれないからです。
取得されたデータを文字列に変換する処理を示します。
改行を利用して文字列を格納する列と行をわけています。改行があれば次の列のデータであり、改行が連続して2つある場合は次の行のデータです。
そのため書き込むデータのなかに改行が存在しないことが前提です。最初に取得したデータから改行を取り除き、これを改行をあいだにいれて連結しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function CreateSendText(datas){ let array1 = []; for(let i=0; i<datas.length; i++){ // 取得したデータから改行を取り除く let videoTitle = datas[i].videoTitle.replace(/\n/g, '').replace(/\rr/g, ''); let channelTitle = datas[i].channelTitle.replace(/\n/g, '').replace(/\rr/g, ''); let videoUrl = datas[i].videoUrl.replace(/\n/g, '').replace(/\rr/g, ''); let channelurl = datas[i].channelurl.replace(/\n/g, '').replace(/\rr/g, ''); let str = videoTitle + '\n'; str += videoUrl + '\n'; str += channelTitle + '\n'; str += channelurl; array1.push(str); console.log(i + 'ページ目を処理しています。'); } console.log('CreateResultText関数 処理完了'); return array1.join('\n\n'); } |
最後に連結した文字列をスプレッドシートにPostで送ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function SaveToSpreadsheet(stringedArray){ console.log("これよりスプレッドシートに書き込みます。"); var options = { url: endPoint, // 後述 method: 'POST', form: { "functionName":"SetCellValuesStringedArray", "stringedArray": stringedArray } } request(options, (error, response, body) => { console.log('ここからはGASによる処理になるためタイムラグが発生する場合があります。'); console.log('書き込み処理をしましたが、念のため確認してください'); }); } |
スプレッドシート側での処理
これをうけとるスプレッドシート側ではスクリプトエディタでGASに以下のように書きます。そしてウェブアプリとしてデプロイし、発行されたurlをNode.jsのSaveToSpreadsheet関数のendPointに設定しなければなりません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
function doPost(e){ if (e == undefined || e.parameter == undefined) return ReturnResult("不正なパラメータ"); if(e.parameter.functionName == "SetCellValuesStringedArray"){ SetCellValuesStringedArray(e.parameter); return ReturnResult("完了:SetCellValuesStringedArray関数"); } return ReturnResult("不正なパラメータ"); } function SetCellValuesStringedArray(param){ let str = param.stringedArray; let rows = str.split('\n\n'); let array = []; for(let i=0; i<rows.length; i++){ let tempArray = rows[i].split('\n'); array.push(tempArray); } SetCellValues(array); } function SetCellValues(array){ const sheetName = 'シート1'; let spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); let sheet = spreadsheet.getSheetByName(sheetName); sheet.getRange(1,1, array.length, array[0].length).setValues(array); } |
ローカルで動作確認できたらHerokuにpush
あとはこれをHerokuにpushするだけです。コマンドプロンプトでindex.jsがあるフォルダに移動して、
1 2 3 4 5 6 7 8 9 10 11 |
$ git init $ MYAPPは自分が公開したいアプリの名前 $ heroku create MYAPP $ git push heroku master # メインビルドパックをNode.jsに設定 $ heroku buildpacks:set heroku/nodejs # Puppeteer用追加パケージのインストール $ heroku buildpacks:add https://github.com/CoffeeAndCode/puppeteer-heroku-buildpack |
次回以降は、
1 2 3 4 5 6 |
heroku login # ログインしたら git add . git commit -m "2nd commit" git push heroku master |
これで公開することができます。
またスプレッドシートにボタンを配置してボタンをおせば検索結果を表示させるようにするのも面白いかもしれません。
4行H列にキーワード、5行H列に取得したい件数を書いておき、ボタンがおされたらfunc1関数が実行されるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function func1(){ let spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); let sheet = spreadsheet.getSheetByName(sheetName); let key = sheet.getRange(4, 8).getValue(); // キーワード取得 let count = sheet.getRange(5, 8).getValue(); // 取得したい件数を取得 if(key == '') // キーワードが存在しない場合はなにもしない return; if(isNaN(count)) // 取得したい件数が数字でない場合はなにもしない return; // 公開されたアプリがあるページにアクセスすると検索結果がスプレッドシートに書き込まれる let url = 'https://boiling-spire-XXXX.herokuapp.com/search'; let keyword = '/keyword/' + key; let max = '/max/' + count; console.log(url + keyword + max); UrlFetchApp.fetch(url + keyword + max); } |