以前、VBAで楽天の検索結果を取得してエクセルに取り込みました。
今回、Googleが提供しているプログラミング言語であるGoogle Apps Script(GAS)での取得に挑戦しました。GASならinternet explorerがインストールされてなくてもスクレイピングができます!
今回も、我が家の猫エサを検索してスプレッドシートに取り込みたいと思います。
GASには上記のような制限があるので、検索結果が100件以内のデータを取り込みたいと思います。今回「猫 餌 15歳 パウチ グルメ」の検索結果(91件)を取り込んでみます。
取り込むデータの確認
楽天の検索結果
スプレッドシートに取り込んだデータ
このような形でデータを取り込みます。VBAの時と比べて取得する項目が少ないですが、それは後ほどご説明します。。最低限これだけ取得できれば十分かなぁと思います。
取り込む際の注意事項
VBAの時と同じですが、楽天ページを取り込む際、いくつか注意事項があります。
- 楽天のリニューアル等でHTML構造に変更があった場合、データをうまく取り込めなくなります。(最終検証:2020年5月)
- 取り込めるのは楽天の検索結果ページのみです。具体的にはURLが「https://search.rakuten.co.jp/search/mall/~」から始まるページです。
- サムネイル画像も一緒に取得するので、すべてのデータを取り込むまで時間がかかります。
GASの実行制限時間は6分です。検索結果が多いとプログラムが完了できない場合があります。
完成したコード(全体)
完成したコードはこちらです!今回も無駄に長いです。
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('商品一覧');
const ui = SpreadsheetApp.getUi();
let totalRowNum = 2;
// シートを全てクリア
sheet.clear();
// タイトル設定
sheet.getRange(1,1).setValue("No.");
sheet.getRange(1,2).setValue("商品画像");
sheet.getRange(1,3).setValue("商品名");
sheet.getRange(1,4).setValue("価格");
sheet.getRange(1,5).setValue("URL");
// 最初の行を20ピクセルの高さに設定
sheet.setRowHeight(1, 30);
// 横幅の設定
sheet.setColumnWidth(1, 40);
sheet.setColumnWidth(2, 80);
sheet.setColumnWidth(3, 200);
sheet.setColumnWidth(4, 100);
sheet.setColumnWidth(5, 280);
//HTML取得
function getpage(targetURL) {
let getUrl = targetURL;
let htmlContent = UrlFetchApp.fetch(getUrl).getContentText('utf-8');
return htmlContent;
}
//総ページURL取得
function getTotalPageURL() {
let targetURL = 'https://search.rakuten.co.jp/search/mall/%E7%8C%AB%E3%80%80%E9%A4%8C+15%E6%AD%B3+%E3%83%91%E3%82%A6%E3%83%81%E3%80%80%E3%82%B0%E3%83%AB%E3%83%A1/';
const CountMedium = new RegExp(/<span class="count _medium">(\s|\S)*?\/span>/g);
const goodsNum = new RegExp(/〜\d{1,}件/g);
const totalNum = new RegExp(/\(\d{1,}件\)/g);
const htmlContent = getpage(targetURL);
let Flag = true;
let CountMediumBox;
let goodsNumBox;
let totalNumBox;
let pagiNation;
let pagiNationNum;
try{
CountMediumBox = htmlContent.match(CountMedium);
goodsNumBox = CountMediumBox[0].match(goodsNum)[0].replace(/[^0-9]/g,"");
totalNumBox = CountMediumBox[0].match(totalNum)[0].replace(/[^0-9]/g,"");
pagiNation = totalNumBox / goodsNumBox;
pagiNationNum = (Number.isInteger(pagiNation)) ? Math.floor(pagiNation) : Math.floor(pagiNation) + 1;
}catch(e){
ui.alert('楽天の商品一覧が取得できませんでした。URLを確認してください');
return false;
}
if(totalNumBox > 100) {
const prompt = `検索結果は${totalNumBox}件です。\n100件を超えたのでデータ取得に時間がかかります。\nそれでも実行しますか?`;
const title = 'スクリプト実行確認';
let answer = ui.alert(title, prompt, ui.ButtonSet.OK_CANCEL);
Flag = (answer == 'OK') ? true : false;
}
if(Flag) {
for(let i = 1; i<=pagiNationNum; i++) {
getItem(`${targetURL}?p=${i}`);
}
}
ui.alert('処理が完了しました!');
}
//商品情報取得
function getItem(currentURL) {
const searchResultItemReg = new RegExp(/<div class="dui-card searchresultitem"(\s|\S)*?<div class="content merchant _ellipsis">(\s|\S)*?<\/div>/g);
const priceReg = new RegExp(/<div class="content description price">(\s|\S)*?<\/div>/g);
const linkReg = new RegExp(/<div class="image">(\s|\S)*?<a target="_top"(\s|\S)*?<\/a>/g);
const htmlContent = getpage(currentURL);
let searchResultItemBox = htmlContent.match(searchResultItemReg);
for(let i = 0; i < searchResultItemBox.length; i++) {
let linkBox = searchResultItemBox[i].match(linkReg);
let imgSRC = linkBox[0].replace(/<div class="image">(\s|\S)*?<a(\s|\S)*?src\="(.*?)"(\s|\S)*?<\/a>/g,'$3');
let linkHREF = linkBox[0].replace(/<div class="image">(\s|\S)*?<a(\s|\S)*?href\="(.*?)"(\s|\S)*?<\/a>/g,'$3');
let itemName = linkBox[0].replace(/<div class="image">(\s|\S)*?<a(\s|\S)*?alt\="(.*?)"(\s|\S)*?<\/a>/g,'$3');
let priceBox = searchResultItemBox[i].match(priceReg);
let priceNum = priceBox[0].replace(/<div class="content description price(\s|\S)*?<span class="important">(.*?)<small>(\s|\S)*?<\/div>/g,'$2');
sheet.setRowHeight(totalRowNum, 80);
sheet.getRange(totalRowNum,1).setValue(totalRowNum - 1).setHorizontalAlignment("left").setVerticalAlignment("middle");
sheet.getRange(totalRowNum,2).setFormula(`=image("${imgSRC}")`);
sheet.getRange(totalRowNum,3).setValue(itemName).setHorizontalAlignment("left").setVerticalAlignment("middle").setWrap(true);
sheet.getRange(totalRowNum,4).setValue(priceNum).setHorizontalAlignment("right").setVerticalAlignment("middle");
sheet.getRange(totalRowNum,5).setValue(linkHREF).setHorizontalAlignment("left").setVerticalAlignment("middle");
totalRowNum++;
}
}
関数「getTotalPageURL」を実行するとスプレッドシートにデータが入力されます。
上記のコードでは、楽天の「猫 餌 15歳 パウチ グルメ」の検索結果を取得するように設定されているので、他の結果を取得する場合、変数「targetURL」の値を変更してください!
URLを設定するところ
let targetURL =”https://search.rakuten.co.jp/search/mall/%E7%8C%AB%E3%80%80%E9%A4%8C+15%E6%AD%B3/“;
→取り込みたい楽天の検索結果のURLを設定する
HTMLデータの取得方法
今回も長いコードになったので、ポイントだけメモしておきます。
GASでデータを取得するには、UrlFetchAppのfetchメソッドを使用します。
fetchの引数には、対象となるURLを入れます。
これで変数「htmlContent」にHTMLの内容が代入されます。
let htmlContent = UrlFetchApp.fetch(getUrl).getContentText('utf-8');
ここで少し困ったことが。
取得したHTMLはオブジェクトではなく、文字列での取得になります。
よく使うgetElementByIDなどのメソッドが使えません。。
例えば、上記の構造からsearchresultitemを抜き出すにはStringオブジェクトのmatchメソッドを使います。
let searchResultItemBox = htmlContent.match(searchResultItemReg);
引数(searchResultItemReg)の部分は正規表現となっており、中身は次のようになります。
new RegExp(/<div class="dui-card searchresultitem"(\s|\S)*?<div class="content merchant _ellipsis">(\s|\S)*?<\/div>/g);
なんとも分かりにくくエグいです。。
分かりやすくするために簡略化したのがこちらです。
<div class="dui-card searchresultitem"(\s|\S)*?<div class="content
正規表現「(\s|\S)*?」は「<div class=”content・・・」が出てくるまで、英数字はもちろん改行や空白、全ての文字に一致します。
そして、gオプションをつけているので、複数の値にマッチする可能性もあります。その場合、配列となって値が返ってきます。
配列のデータはfor文で1つずつ取り出し、必要ならreplaceメソッドで不要な文字を取り除いていきます。なんとも根気のいる作業です。
例えば、replaceを使って次のHTMLから画像のURLだけを取り出す場合、
HTML
<div class="image">
<a target="_top" href="https://search.rakuten.co.jp/redirect?_url=https%3A%2F%2Fitem.rakuten.co.jp%2Fwataraseshop%2Fad05-4520699617748%2F&_cks=39ebc78308a97068c9584a25105c051c6c10087&_pgid=ac8576327db98cd1&_pgl=pc&_ml=pc.main.middle.gridSearchResults&_mp=%7B%22action%22%3A%22img%22%2C%22card%22%3A%22search%22%2C%22abs%22%3A1%2C%22rel%22%3A1%2C%22type%22%3A%22item%22%2C%22itemid%22%3A%22305603%2F10049500%22%7D&_mn=searchResultItem" data-track-action="img">
<img class="_verticallyaligned" src="https://tshop.r10s.jp/wataraseshop/cabinet/7/4520699617748-1.jpg?fitin=275:275" alt="銀のスプーン 三ツ星グルメ パウチ フレーク 15歳頃から まぐろ入りかつお 35g レトルト 猫 キャットフード えさ 餌 ウェット ◆賞味期限 2021年9月">
</a>
</div>
replaceメソッドの正規表現は次のように書きます。
replaceメソッド
replace(/<div class="image">(\s|\S)*?<img(\s|\S)*?src\="(.*?)"(\s|\S)*?<\/div>/g,'$3');
こうやってStringオブジェクトのmatchとreplaceメソッドを駆使して、目的の項目を抜き出していきます。
さっき、VBAに比べて取得する項目が少ないとありましたが、正直この方法だと労力が半端ないためです。あまり複雑な構造を抜き出すのは難しそうです。。
他にもっと良い方法でライブラリを使う方法もあるそうです。
そっちの方が早いかもしれないです。。
検索結果が多い場合の対応
楽天の検索結果ですが、キーワードによっては何万という商品がヒットします。
GASの実行制限もあるので検索結果は100件以内が良さそうです。
そこで、検索結果が100件以上の場合プログラムを実行するか、確認するダイアログを表示させます。
コードがあちこちに散らかってるので必要な箇所を抜き出しました。
const CountMedium = new RegExp(/<span class="count _medium">(\s|\S)*?\/span>/g);
const totalNum = new RegExp(/\(\d{1,}件\)/g);
let Flag = true;
let CountMediumBox = htmlContent.match(CountMedium);
let totalNumBox = CountMediumBox[0].match(totalNum)[0].replace(/[^0-9]/g,"");
if(totalNumBox > 100) {
const ui = SpreadsheetApp.getUi();
const prompt = `検索結果は${totalNumBox}件です。\n100件を超えたのでデータ取得に時間がかかります。\nそれでも実行しますか?`;
const title = 'スクリプト実行確認';
let answer = ui.alert(title, prompt, ui.ButtonSet.OK_CANCEL);
Flag = (answer == 'OK') ? true : false;
}
検索結果に表示された商品の総数を取得して、100件以上だったらアラートを表示させます。
今回の検索結果の総数は91件でした。下記コードから91だけを抜き出します。
<span class="count _medium">1〜45件 (91件)</span>
抜き出すには次のようにしました。
const CountMedium = new RegExp(/<span class="count _medium">(\s|\S)*?\/span>/g);
const totalNum = new RegExp(/\(\d{1,}件\)/g);
let CountMediumBox = htmlContent.match(CountMedium);
let totalNumBox = CountMediumBox[0].match(totalNum)[0].replace(/[^0-9]/g,"");
変数「CountMediumBox」にはmatchメソッドで取得した<span class=”count _medium”>〜</span>までの文字列が配列で代入されます。この要素はページに1つしか存在しなかったので、CountMediumBox[0]で操作します。
最終的に変数「totalNumBox」に代入するこの部分ですが、
let totalNumBox = CountMediumBox[0].match(totalNum)[0].replace(/[^0-9]/g,"");
文字列の(91件)だけをmatchメソッドで取得して、
<span class=”count _medium”>1〜45件 (91件)</span>
replaceメソッドで、数字以外を置換して削除しています。
数字以外の正規表現は[^0-9]です。
これで91の数字だけ抜き出したので、if文で条件分岐するだけです。
検索結果が100件以上だと、処理を継続するかのダイアログが表示され、キャンセルをクリックすると、処理が終了します。
複数ページある場合に対応する
複数ページあるかは、先ほど取得したコードから判定します。
<span class=”count _medium”>1〜45件 (91件)</span>
前提として、複数ページのURLは最後に2ページ目なら「?p=2」のパラメータがつきます。
今回の場合、検索結果は全部で3ページありました。
先ほど、総数91件の数を取得しましたが、同じ要領で1ページに掲載されている商品数を取得します。今回の場合45件です。
そこまで取得できたら総数91件を1ページに掲載されている商品45件で割ります。
計算結果は22.75ですが、端数がある場合はプラス1して端数を丸めます。
これをコードで表すとこんな感じです。
let pagiNation = totalNumBox / goodsNumBox;
let pagiNationNum = (Number.isInteger(pagiNation)) ? Math.floor(pagiNation) : Math.floor(pagiNation) + 1;
この値を元にURLをfor文で作成して、商品要素を取得する関数「getItem()」の引数に渡します。
商品詳細の要素を取得する
1ページごとのURLが取得できたので、ページごとに商品詳細を抜き出していきます。
抜き出したデータはスプレッドシートに反映していきます。
//商品情報取得
function getItem(currentURL) {
const searchResultItemReg = new RegExp(/<div class="dui-card searchresultitem"(\s|\S)*?<div class="content merchant _ellipsis">(\s|\S)*?<\/div>/g);
const priceReg = new RegExp(/<div class="content description price">(\s|\S)*?<\/div>/g);
const linkReg = new RegExp(/<div class="image">(\s|\S)*?<a target="_top"(\s|\S)*?<\/a>/g);
const htmlContent = getpage(currentURL);
let searchResultItemBox = htmlContent.match(searchResultItemReg);
for(let i = 0; i < searchResultItemBox.length; i++) {
let linkBox = searchResultItemBox[i].match(linkReg);
let imgSRC = linkBox[0].replace(/<div class="image">(\s|\S)*?<a(\s|\S)*?src\="(.*?)"(\s|\S)*?<\/a>/g,'$3');
let linkHREF = linkBox[0].replace(/<div class="image">(\s|\S)*?<a(\s|\S)*?href\="(.*?)"(\s|\S)*?<\/a>/g,'$3');
let itemName = linkBox[0].replace(/<div class="image">(\s|\S)*?<a(\s|\S)*?alt\="(.*?)"(\s|\S)*?<\/a>/g,'$3');
let priceBox = searchResultItemBox[i].match(priceReg);
let priceNum = priceBox[0].replace(/<div class="content description price(\s|\S)*?<span class="important">(.*?)<small>(\s|\S)*?<\/div>/g,'$2');
sheet.setRowHeight(totalRowNum, 80);
sheet.getRange(totalRowNum,1).setValue(totalRowNum - 1).setHorizontalAlignment("left").setVerticalAlignment("middle");
sheet.getRange(totalRowNum,2).setFormula(`=image("${imgSRC}")`);
sheet.getRange(totalRowNum,3).setValue(itemName).setHorizontalAlignment("left").setVerticalAlignment("middle").setWrap(true);
sheet.getRange(totalRowNum,4).setValue(priceNum).setHorizontalAlignment("right").setVerticalAlignment("middle");
sheet.getRange(totalRowNum,5).setValue(linkHREF).setHorizontalAlignment("left").setVerticalAlignment("middle");
totalRowNum++;
}
}
サムネイル画像はimage関数で表示します。
URLで参照してセル内に表示させているようで、読み込みのタイミングによっては画像がうまく表示されません。直接画像を挿入したいのですが、難しそうです。
まとめ
なんとかGASでウェブのデータを取得できました。
突っ込みどころが多そうなコードですが、結果が出ればそれでオーケーとします。
残念ながらGASでスクレイピングは実用的じゃないなぁ。というのが正直な感想です。他にもっと効率の良い方法があれば是非知りたいです。
コメント
とても参考になる記事ありがとうございます。
ポイント部分の
部分の取得は難しいでしょうか?
確認が遅くなり申し訳ありません。ポイント部分の取得ということですが、具体的にどの部分になるでしょうか。