Googleショッピングの商品データをスクレイピングで自動更新

:::note warn
本記事は2021年3月時点での内容になります。最新情報については公式サイトなどを確認願います。
:::

Googleショッピングは今ではECでは欠かせない広告の1つになってきました。ですが、そこに掲載する商品を常に最新な状態に保っておくのはとても面倒です。

そこで今回は、Google Sheetsを使ってECサイト上の商品ページからデータを取得して、Googleマーチャントセンターに商品データを自動で定期的に更新する方法を紹介します。

大まかな流れは以下のとおりです。

1. Googleマーチャントセンターにフィードの登録
2. Google Sheetsの修正
3. スクレイピングしてデータフィードを作成
4. スクレイピングを定期的に実行

## Googleマーチャントセンターにフィードの登録
まずは、[Googleマーチャントセンター](https://www.google.com/retail/solutions/merchant-center/)でフィードを登録します。左側のメニューから「商品」→「フィード」を選択肢、右側の＋ボタンを押してフィードを追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939399/88499437-5331-5e54-dc3f-0a871b2d1650.png)

住所情報などを入力した後、フィード名を入力し、フィードの選択で「Google Sheets」を選びします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939399/b82ef5ea-9fb4-3bda-f667-525103bc73f2.png)

続いて、Google Sheetsの新規作成を選択して、オプションの更新頻度を指定します。決まりはありませんが、毎日午前中が良いです。（あまり早すぎると後で説明するスクレイピングのタイミングと重複して最新データをきちんと送れない可能性があるので多少遅らせると良いです）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939399/bf76d5dc-c574-8d34-0007-07d5f97bae55.png)

これで新規にGoogle Sheetsが作成されます。

## Google Sheetsの修正

次に、新規に作成されたGoogle Sheetsを修正します。まずはSheet1というシートのD列（link）に商品ページのURLを貼り付けます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939399/795c4286-2aeb-53ee-94bf-ade215c74344.png)

続いて、「log」シートを作成し、1行目に「Date」、「Latest Row」（名前は任意）と入れ、B2に「2」を入力します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939399/3a47d5e3-b7f1-ef65-8f0d-34f5e03db172.png)

## スクレイピングしてデータフィードを作成

いよいよ本題に入ります。Google Sheetsに書き込むためのデータWebサイト上の商品ページから取得（＝スクレイピング）します。

まず最初に、Googleマーチャントセンターに送る必要なデータは以下になります。

- id:商品の固有のID（SKUなど）
- title:商品の名前
- description:商品の説明
- link:商品ページ
- condition:new（新品）、used（中古）、refurbished（整備済製品）のいずれか
- price:商品の価格
- availability:商品の在庫状況。in stock （在庫有り）、out of stock （在庫なし）、preorder （予約商品）のいずれか
- image_link:商品のメインの画像のURL
- gtin:商品の国際取引商品コード（ GTIN ）。空欄可
- mpn:商品の製品番号（ MPN ）。空欄可
- brand:商品のブランド名
- google_product_category:ショップの商品向けに Google が定めた商品カテゴリ（オプション）

### JS pathでデータの在り処を指定
Webページからデータを取得する際、金額や商品名などの場所を特定する必要があります。Chromeの場合、商品名上で右クリック ＞ 検証を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939399/acc64cf6-07b0-0e17-bb99-aff555de80ab.png)

次に、右側のElementタブ上で再び右クリック＞コピー＞コピー JSを選択します。コピーしたものをテキストエディタへ貼り付けると、document.querySelector(“.maincontent > div > div.page-title-wrapper.product > h1 > span”) といった感じになりますので、”wrapper.product > h1 > span”や”h1 > span”、 “h1″など他の箇所とかぶらないものだけを抜き取ります。※この例だと”span”としてしまうと、他の箇所と被るので正しい値が取れません。

### Google Apps Scriptでスクレイピングのコードを作成
いよいよ本題のコードの作成です。コードはツール＞スクリプトエディターを選択しして、Google Apps Scriptを開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939399/8bf69096-fa0a-597b-fe03-9c1d3336deb4.png)

すると右側に「my function()」と書かれた領域が出てきますので、そこに下記のコードをコピー＆ペーストします。

```
const FILEKEY = 'xxxxxxxxxxxxxx'; 
const BOOK = SpreadsheetApp.openById(FILEKEY);
const SHEETNAME ='Sheet1';
const LOGSHEET = 'log';
const MAXROWS = 1000;
const DEFAULT_STARTROW = 2;
const PARAMS = [
    {'key':'id', 'path':'.id'},
    {'key':'title', 'path':'h1.title'},
    {'key':'description', 'path':'.productDescription'},
    {'key':'link', 'path':'h1.title'},
    {'key':'condition', 'path':'.productCondition'},
    {'key':'price', 'path':'.priceValue'},
    {'key':'availability', 'path':'.productStock'},
    {'key':'image', 'path':'img.src'},
    {'key':'gtin','path':'test'},
    {'key':'mpn','path':'test'},
    {'key':'brand','path':'.productBrand'}
  ];

function main (){
  // 結果を書き込むシートのオブジェクトを取得
  var sheetData = book.getSheetByName(SHEETNAME);
  // スクレイピング開始行を取得
  var startRow = getLatestRow();
  startRow = (startRow >= sheetData.getLastRow()) ? DEFAULT_STARTROW : startRow;
  // スクレイピング開始行以降のURLを取得
  var urls = getURLs(sheetData);
  // スクレイピングを実行し、結果を配列resultsに格納
  var results =[]; 
  for(var i = 0; i < urls.length; i++){
    var result = scrape(urls[i]);
    results.push(result);
  }
  // 結果をシートに上書き
  writeResult(sheetData,results, startRow, 1);
}

function getLatestRow(){
  var sheet =book.getSheetByName(LOGSHEET);
  var row = sheet.getRange(sheet.getDataRange().getLastRow(),2,1,1).getValue();
  return row;
}

function setLatestRow(row){
  var sheet =book.getSheetByName(LOGSHEET);
  var result = [[formatDateTimeAsString(new Date()),row]];
  sheet.getRange(sheet.getDataRange().getLastRow()+1,1,result.length, result[0].length).setValues(result);
}

function getURLs(sheetData){
  var ret = [];
  var urls = sheetData.getRange(2,4,sheetData.getDataRange().getLastRow(),1).getValues();
  for(var i = 0; i < urls.length; i++){
    if(i < MAXROWS){
      ret.push(urls[i][0]);
    }else{
      break;
    }
  }
  return ret; 
}

function scrape(url){
	// Scraping, you need a header.
  const postheader = {
    "useragent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36",
    "accept":"gzip, */*",
    "timeout":"20000"
  }  

  var result = [];
  if(!(url.length> 0 && url.indexOf('http') >= 0)){
      result = ['','','',url,'','','',''];
      Logger.log("url =" +  url.length +"\t" + url.indexOf('http') +"\t" + url);
      return result;
  }
  try{
    var content = UrlFetchApp.fetch(url).getContentText("UTF-8");
    var $ = Cheerio.load(content);
  }catch(e){
    result = ['','','',url,'','','',''];
    Logger.log("url ="+ url + "\t" + e);
    return result;
  }

  PARAMS.forEach(function(param){
    var attribute = '';
    try{
      key = param['key'];
      path = param['path'];
      var element = $(path);
      // if you need transform or replace the result of specific column (e.g image link, price etc), use if condition statement.
      if(key == 'link'){
        attribute = url;
      }else if(key == 'image'){ 
        attribute = element.attr('src'); 
      }else if(key == 'price'){
          // Price: $49.00 NZD
          attribute = element.text();
          attribute = attribute.replace('Price:','').replace('$','').replace('NZD','').trim();
      }else if(path == 'test'){
	  attribute = '';
      }else{
        attribute = element.text();
      }
    }catch(e){
      Logger.log(e);
    }
    result.push(attribute);
  });
  return result;
}

// Put the scraped data into the sheet.
function writeResult(sheetData,results, startRow, startColumn){
  // results is an array 
  if(results.length > 0){
    var outputRange = sheetData.getRange(startRow, startColumn, results.length, results[0].length);
    outputRange.setValues(results);
    setLatestRow(startRow+results.length);
  }
}

function formatDateTimeAsString(d) {
  var dateString = Utilities.formatDate(d, 'GMT+12:00', 'yyyy/MM/dd HH:mm:ss');
  return dateString;
}
```

#### 定数の設定

- FILEKEY: 今回作成したGoogle SheetsのURLの/d/～/の部分（https://docs.google.com/spreadsheets/d/xxxxxxxxxx/edit
- BOOK:今回作成したGoogle Sheetsを操作するのに必要なオブジェクト
- SHEETNAME:スクレイピングした結果を格納するシート名
- LOGSHEET:最後にスクレイピングした行を格納するシート名
- MAXROWS:1回の実行での最大スクレイピングページ数
- DEFAULT_STARTROW:スクレイピングを開始する行（初期設定）
- PARAMS:今回スクレイピングするデータがあるCSSクラス名（前述のJS PATHで取得したクラス名）

PARAMSのpathの値（例：’.id’, ‘h1.title’)はページに合わせて修正してください。また、keyの部分は必要がれば追加、削除して構いません。

#### main

このコードのメイン部分はmain関数になります。手順は以下のとおりです。ここでポイントが「スクレイピング開始行を取得」になります。Google Apps Scriptは1回あたりのプログラム実行時間が長すぎると途中で強制終了してしまいます。そのため、スクレイピング件数をこちらで制限しなければなりません。

そして、次回の実行時にはその次の行からスクレイピングを再開する、というアルゴリズムにする必要があります。そのために最終のスクレイピングをlogシートで管理しています。

- 結果を書き込むシートのオブジェクトを取得
- スクレイピング開始行を取得
- スクレイピング開始行以降のURLを取得
- スクレイピングを実行し、結果を配列resultsに格納（最終スクレイピング行を格納）


```
var startRow = getLatestRow();
startRow = (startRow >= sheetData.getLastRow()) ? DEFAULT_STARTROW : startRow;
```

では、まずgetLatestRow()で今回のスクレイピング開始行を取得してstartRowに入れています。次に、この結果がスクレイピングの最終行よりも大きい場合は、最初（DEFAULT_STARTROW）にするようにしています。DEFAULT_STARTROW＝2としているのは、Sheet1シートの1行目はヘッダーが入っているためです。

#### getLatestRow / setLatestRow

その最終スクレイピング行の読み書きがgetLatestRow / setLatestRow関数になります。

getLatestRow関数内の book.getSheetByName(LOGSHEET) はシートのオブジェクトを取得、sheet.getRange(行番号、列番号、行数、列数)はシートの箇所を指定、getValue()はその箇所の値を取得するためのGoogle Apps Script関数になります。

また、sheet.getDataRange().getLastRow()はソノシートの最終行を返す関数です。いづれもGoogle Apps Scriptではよく使う関数なのでこうすうるものだと覚えておくと良いでしょう。

#### getURLs

getURLs関数はシートからURLを取得します。こちらも前述のgetLatestRowと同様にgetValue関数を使いますが、異なるのはgetValue**s**です。getValues()は行×列を配列として返してくれます。

前述したように、Google Apps Scriptは実行の制限時間がありますので、URLを全件取得するのではなく、上限（MAXROWS）を決めています。

#### scrape
いよいよ、このコードの本丸のスクレイピング部分になります。この関数は引数にURLを持ってきており、このURL先のページをスクレピングするのが目的です。

最初のpostheaderはスクレイピングするときに必要なユーザーエージェントになります。これはサイトによって必須だったり不要だったりしますのが、中身はこのままで問題ないのでそのまま使います。

```
var result = [];
  if(!(url.length> 0 && url.indexOf('http') >= 0)){
      result = ['','','',url,'','','',''];
      Logger.log("url =" +  url.length +"\t" + url.indexOf('http') +"\t" + url);
      return result;
  }
```

次に、結果を格納する配列resultを宣言します。その後で、そもそもURLがおかしな場合はその配列に空の値（URLだけは入れます）を入れて返します。return nullとしてしまうと、結果をSheetに書き込む際に1行ずつずれてしまうのでそれを防ぐために行っています。

不適切なURLを除外したい場合はここでreturn nullをしても問題ないのですが、その場合はmain関数の方で「nullだったら結果に入れない」という条件文を入れる事をおすすめします。

ちなみに、Logger.log関数はGoogle Apps Scriptのログに書き出す関数で、実行時にコードの下に表示されますし、過去分は左側の「実行」メニューをクリックすると見れます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939399/832713f0-c0a6-8fe9-3b75-efb04e23465a.png)


```
try{
    var content = UrlFetchApp.fetch(url).getContentText("UTF-8");
    var $ = Cheerio.load(content);
  }catch(e){
    result = ['','','',url,'','','',''];
    Logger.log("url ="+ url + "\t" + e);
    return result;
  }
```


スクレイピングを開始します。今回は[Cheerio](https://github.com/tani/cheeriogs)という機能を使ってページを取得します。ここで何かしらの例外（問題）が発生したら先ほどのURLがおかしい場合と同様に空の結果を返すようにします。

```
PARAMS.forEach(function(param){
    var attribute = '';
    try{
      key = param['key'];
      path = param['path'];
      var element = $(path);
      if(key == 'link'){
        attribute = url;
      }else if(key == 'image'){ 
        attribute = element.attr('src'); 
      }else if(key == 'price'){
        // Price: $49.00 NZD
        attribute = element.text();
        attribute = attribute.replace('Price:','').replace('$','').replace('NZD','').trim();
      }else if(path == 'test'){
        attribute = '';
      }else{
        attribute = element.text();
      }
    }catch(e){
      Logger.log(e);
    }
    result.push(attribute);
  });
  return result;
```

PARAMS.forEach関数で今回取得対象の各要素（id, title, nameなど）を1つずつ順番に抽出していきます。var element = $(path);に抽出結果がはいりますが、この時点では表示されているテキストではなく要素（タグ）として入っているため、必要な情報だけをこのあと抜き取ります。

例えば表示されている文字列が必要な場合は、attribute = element.text();として文字列を取得します。また、画像URLが必要な場合はsrc属性を取得すれば良いので、attribute = element.attr(‘src’);として取得します。

そして、取得した文字列を加工したい場合はreplaceやtrim、indexOf、split関数などを使って必要な部分だけを抽出してください。

今回、URLは引数の値をそのまま使いますので、pathがurlのときはスクレイピングを行わずにそのままurlを格納しています。

また、抽出が失敗した場合や見つからない場合は空文字として配列arrayに格納します。ここで空文字を入れないとSheetの書き込みがずれてしまうため必須です。

最後に配列arrayを返して終了です。

#### writeResult

スクレイピング結果をSheetに書き込みます。Google Apps ScriptのgetRangeで書き込む箇所を指定し、setValues関数で書き込みます。

また、setLatestRow関数で今回のスクレイピングの最終行をlogシートに書き込み次回はこの次の行から開始します


#### ライブラリの追加

最後に、Google Apps Scriptにコードを記入したら、ライブラリの追加を行います。これは外部の機能を利用するときに使います。スクレイピング時に使用する[Cheerio](https://github.com/tani/cheeriogs)をライブラリに追加します。
追加方法は簡単です。

1. 左側の「ライブラリ」をクリック
2. Script IDフォームにCheerioに記載されているScript IDをコピー＆ペースト
3. 「追加」ボタンを押す

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939399/a830803b-9ee2-ecb1-3356-39afa5afd59c.png)


以上で設定は完了です。実行は「main」を選択して実行ボタンを押します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939399/755da287-bdb2-c116-27ee-222378ba41c0.png)

## スクレイピングを定期的に実行

スクレイピングのコードは作成しましたが、最後にこのコードを定期的に実行させます。左側の「トリガー」をクリックし、main関数を毎時実行させます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939399/fef8da73-6cd9-53b5-1d8a-01f32f07103e.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3939399/b187b8a9-7c96-d577-92f5-a47710a2b7f3.png)

これで毎時間設定した行数分スクレイピングされます。スクレイピングするページ数が多い場合はこれで構わないのですが、少ない場合はたくさんスクレイピングする必要性がないので、毎時実行ではなく、時間帯を指定して実行するトリガーを複数作成して、0～1時、1～2時といったように指定すると良いでしょう。

他にも、最終行まで更新をかけたらその日はもう更新をかけないようにコードを修正するのもありですが、今回は省略します。


## Google Apps Scriptは便利

今回はスクレイピングでデータフィードを自動更新するプログラムを紹介しました。Google Apps Scriptはあまり馴染みがありませんが、Javascript的な要素を持ちながらGoogle SheetsやGmail、Google Analyticsなどにも容易にアクセスができるのでとても使い勝手が良いです。