楽天の検索結果を取得してGoogleスプレッドシートに取り込む(スクレイピング)【GAS/JavaScript】

JavaScript

以前、VBAで楽天の検索結果を取得してエクセルに取り込みました。

今回、Googleが提供しているプログラミング言語であるGoogle Apps Script(GAS)での取得に挑戦しました。GASならinternet explorerがインストールされてなくてもスクレイピングができます!

今回も、我が家の猫エサを検索してスプレッドシートに取り込みたいと思います。

GASはプログラムの実行時間制限が6分という制約があり、6分を超えると処理が強制終了します。取得するデータの量によっては実行できない可能性があります。

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などのメソッドが使えません。。

商品情報HTML構造

例えば、上記の構造から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&amp;_cks=39ebc78308a97068c9584a25105c051c6c10087&amp;_pgid=ac8576327db98cd1&amp;_pgl=pc&amp;_ml=pc.main.middle.gridSearchResults&amp;_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&amp;_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でスクレイピングは実用的じゃないなぁ。というのが正直な感想です。他にもっと効率の良い方法があれば是非知りたいです。

コメント

  1. 山口 より:

    とても参考になる記事ありがとうございます。

    ポイント部分の

    部分の取得は難しいでしょうか?

    • どんぐり donguri より:

      確認が遅くなり申し訳ありません。ポイント部分の取得ということですが、具体的にどの部分になるでしょうか。