タニタHealthPlanet(体組成計RD-907)のデータをFitbitとGoogle Fitに同期する

#要点
- タニタ体組成計RD-907で毎日体組成を測定しているが、fitbitやGoogleFitにもデータを同期したい
- Google Apps Scriptを毎日定期実行し、タニタ体組成計のデータを取得できるHealthPlanet APIとfitbit Web-APIを利用してHealthPlanet→fitbitのデータ転送を実現
- タニタの体組成計のデータはスプレッドシートに保存
- fitbit→GoogleFitはAndroidアプリのFitToFitを利用


#使用ツール
- [HealthPlanet API](https://www.healthplanet.jp/apis/api.html)
- [Fitbit Web-API](https://dev.fitbit.com/build/reference/)
- [Fitbitはこっちも便利](https://dev.fitbit.com/build/reference/web-api/explore/)
- [Google Apps Script](https://developers.google.com/apps-script/)
- [FitToFit](https://play.google.com/store/apps/details?id=fitapp.fittofit&hl=ja)

#ツールの用意
### 1. HealthPlanet API利用の準備
HealthPlanet APIを利用するにあたって、HealthPlanetにてClient IDとClient secretを取得する必要がある。
[こちらのページ](https://mana.bi/wiki.cgi?page=%A5%BF%A5%CB%A5%BF%C2%CE%C1%C8%C0%AE%B7%D7%A4%AB%A4%E9%A5%C7%A1%BC%A5%BF%A4%F2%BC%E8%C6%C0%A4%B9%A4%EB)を参考にさせていただいた。

[HealthPlanet APIのページ](https://www.healthplanet.jp/apis_account.do)にて下図のようにClient IDとClient secretを取得できれば完了。下図では該当部は白抜きしている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/439094/d078a401-f63d-acdc-bf61-c735cf3e5615.png)

### 2. Fitbit API利用の準備
HealthPlanetと同様にFitbitのAPI利用登録を行う。
[こちらのページ](https://qiita.com/makopo/items/32f41128c2e055cec68f)を参考にさせていただいた。
[Fitbit dev](https://dev.fitbit.com/apps)にてAPIの利用登録ができれば完了。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/439094/5ab3fd42-7bca-730d-fcb8-c17cafe92318.png)

### 3. Google Apps ScriptへのOAuthライブラリの登録
API利用のためのOAuth周りについては全くの無知なのでライブラリを利用する。
[こちらのOauth2ライブラリ](https://github.com/gsuitedevs/apps-script-oauth2)を利用した。
Google Apps Scriptにライブラリを登録するにはこちらのコードをライブラリに追加すれば良い。

```
1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/439094/441eb244-2cca-74e0-eafd-1fc4995b25e1.png)

### 4. スプレッドシートの用意
Google Apps Scriptと連携したスプレッドシートはこのようなフォーマットにした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/439094/1627132d-0b6a-d9dd-d6db-d570b5d9f986.png)



#実装
### 1. OAuthに必要な情報

```javascript:code.js
//HealthPlanet用
var HP_CLIENT_ID = '*****'; //Health Planet の Client ID
var HP_CLIENT_SECRET = '*****'; //Health Planet の Client Secret
var HP_OAUTH_URL = 'https://www.healthplanet.jp/oauth/auth';
var HP_TOKEN_URL = 'https://www.healthplanet.jp/oauth/token';
var HP_CODE = '*****'; //リダイレクトで返ってきたCODEを手動でコピペ
var HP_ACCESS_TOKEN = '*****'; //取得したアクセストークンを手動でコピペ
var HP_REFRESH_TOKEN = '*****'; //使用しない？

//Fitbit用
var FB_CLIENT_ID = '*****'; //Fitbit の Client ID
var FB_CLIENT_SECRET = '*****'; //FitBit の Client Secret
var FB_OAUTH_URL = 'https://www.fitbit.com/oauth2/authorize';
var FB_TOKEN_URL = 'https://api.fitbit.com/oauth2/token';
var FB_USERID = '*****'; //Fitbit devに記載されている

```

### 2. HealthPlanetからデータを取得する
今回同期したいのは体重と体脂肪率だけだが、一通りデータを取得してスプレッドシートに追記する。既にスプレッドシートにデータが有るかどうかは日付で判定する。HealthPlanetから直近の2ヶ月分のデータを取得する。OAuth処理が終わったあとはhpGetData()を定期実行する。

OAuth周りのことがよくわからないので、HealthPlanetの場合はライブラリがうまく機能せず(具体的にはCallback関数が呼ばれない、リフレッシュトークンを利用したアクセストークン更新方法がわからない)、アクセスコードやアクセストークンを手動でフェッチして「1. OAuthに必要な情報」に転記している。
良い方法があったら教えていただきたい。


```javascript:code.js
// 本来であればリフレッシュトークンを利用したアクセストークンの更新によってhasAccessがtrueになるはずだが、HealthPlanetの仕様のために常にfalse
function hpRun() {
  var service = hpGetService();
  
  // アクセストークンが有効か調べる
  if (service.hasAccess()) { 
    // 本当はここに実行コードを書きたいが、hasAccessがtrueにならない
    Logger.log('hp hasAccess OK')
  }else{
    var authorizationUrl = service.getAuthorizationUrl();
    Logger.log(authorizationUrl);
  }
}

// Callbackで戻ってきたときに呼ばれる→HealthPlanetでは呼ばれない
function hpAuthCallback(request) {
  logger.Log('hpAuthCallback')
  var service = hpGetService();
  var isAuthorized = service.handleCallback(request);
  if (isAuthorized) {
    return HtmlService.createHtmlOutput('Success!');
  } else {
    return HtmlService.createHtmlOutput('Denied.');
  }
}


// Authorizationをリセット
function hpClearService(){
  hpGetService().reset();
}


// OAuth2の設定
function hpGetService(){
  return OAuth2.createService('HealthPlanet') // 任意の名前
  .setAuthorizationBaseUrl(HP_OAUTH_URL)
  .setTokenUrl(HP_TOKEN_URL)
  .setClientId(HP_CLIENT_ID)
  .setClientSecret(HP_CLIENT_SECRET)
  .setCallbackFunction('hpAuthCallback')
  .setPropertyStore(PropertiesService.getUserProperties())
  .setScope('innerscan')
}

/*
  CODEを元にアクセストークンを取得する
*/
function hpGetToken(){
    url = HP_TOKEN_URL;
    
    var payload = {
      'client_id' : HP_CLIENT_ID, 
      'client_secret' : HP_CLIENT_SECRET,
      'redirect_uri' : 'https://www.healthplanet.jp/success.html',
      'code' : HP_CODE,
      'grant_type' : 'authorization_code'
    };
    
    var options = {
      'method' : 'POST',
      'payload': payload,
    };
    
    // トークンの取得
    var response = UrlFetchApp.fetch(url, options);
    var data = JSON.parse(response)
    Logger.log(data)
    
}

/*
  HealthPlanetから直近の2ヶ月分のデータを取得する
*/
function hpGetData(){

    // スプレッドシートを取得する
    var hpSS = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("HealthPlanet");
    var hpSS_Date = hpSS.getRange(2,1,hpSS.getLastRow(),1).getValues();

    // スプレッドシート上のデータの日付をフォーマットしてリスト化
    hpDateList = []
    for(var i = 0; i < hpSS_Date.length; i++){
      var ssDateFormatted = new Date(hpSS_Date[i]);
      hpDateList.push(ssDateFormatted.getYear() + '/' + ('0' + String(Number(ssDateFormatted.getMonth()) + 1)).slice(-2) + '/' + ('0' + ssDateFormatted.getDate()).slice(-2));
    }

    url = 'https://www.healthplanet.jp/status/innerscan.json';
    
    // 2ヶ月前の日付を取得
    fromDate = new Date()
    fromDate.setMonth(fromDate.getMonth() - 2)
    fromDate = Utilities.formatDate(fromDate, 'Asia/Tokyo', 'yyyyMMdd') + '000000';
    
    // payloadに必要な情報
    /*
    date (必須)
    from to の日付タイプを指定する。
    0 : 登録日付
    1 : 測定日付
    
    from
    取得期間 from を指定する。yyyyMMddHHmmss 形式で指定し、必ず from < to でなければならない
    未指定の場合は3ヶ月前が指定される
    
    to
    取得期間 to を指定する。yyyyMMddHHmmss 形式で指定し、必ず from < to でなければならない
    未指定の場合は現時刻が指定される
    
    tag
    取得する測定部位を指定する。未指定の場合はデフォルトの値を取得する。カンマ区切りで指定する
    6021 : 体重 (kg)
    6022 : 体脂肪率 (%)
    6023 : 筋肉量 (kg)
    6024 : 筋肉スコア
    6025 : 内臓脂肪レベル2(小数点有り、手入力含まず)
    6026 : 内臓脂肪レベル(小数点無し、手入力含む)　→　使用されていない
    6027 : 基礎代謝量 (kcal)
    6028 : 体内年齢 (才)
    6029 : 推定骨量 (kg)
    */
    var payload = {
      'access_token' : HP_ACCESS_TOKEN, 
      'date' : '1',
      'tag' : '6021,6022,6023,6024,6025,6027,6028,6029',//6026を除く
      'from' : fromDate
    };
    
    var options = {
      'method' : 'POST',
      'payload': payload,
    };
    
    // データを取得する
    var response = UrlFetchApp.fetch(url, options);
    var data = JSON.parse(response); //このdataは日付に対して6021~6029のうちのデータを1つもつため、1日分のデータが分かれている
        
    oneday_datalist = []; // 1日分のデータを保持するリスト
    prev_date = ''; // 前日の日付
    
    // 取得したデータのうち、スプレッドシートの日付リストhpDateListに日付が存在しないものをスプレッドシートに追記
    for(var i = 0; i < data['data'].length; i++){
      date = data['data'][i].date;
      dateTimeFormatted = date.substr(0,4) + '/' + date.substr(4,2) + '/' + date.substr(6,2) + ' ' + date.substr(8,2) + ':' + date.substr(10,2)
      dateFormatted = date.substr(0,4) + '/' + date.substr(4,2) + '/' + date.substr(6,2)

      // 日付で既存有無の判断
      if(hpDateList.indexOf(dateFormatted)==-1.0){
        if(prev_date == ''){ // for文の初回のみ呼ばれる
          oneday_datalist.push(dateTimeFormatted);
        }else if(date != prev_date){ // dataの日付が変わった場合にoneday_datalistにまとめたデータをスプレッドシートに追記する
          hpSS.appendRow(oneday_datalist);
          oneday_datalist = [];
          oneday_datalist.push(dateTimeFormatted);
        }
        
        if(oneday_datalist.length < 9){ // 同じ日時に複数回測定していると9行目以降に追記されるが、そのデータは不要なので無視する
          oneday_datalist.push(data['data'][i].keydata);
        };
        prev_date = date;
      };
    };
    
    // 日付で昇順に並び替え
    ssHP.getRange(2,1,ssHP.getLastRow() - 1,ssHP.getLastColumn()).sort({column:1, ascending:true})
    
}



```

### 3. Fitbitにデータを転送する
Fitbitから過去1年分の体重データを取得し、Fitbitに体重データが無いものをHealthPlanetから転送する。判定は日付で実行している。fbRun()を定期実行する。

```javascript:code.js
function fbRun() {
  var service = fbGetService();
  
  // アクセストークンを持ってるか調べる
  if (service.hasAccess()) {
    Logger.log('fbRun already authorized! Start processing!');
    
    // 今日から1年間遡ってfitbitの体重データをリストで取得する
    fbWeightList = fbGetWeightList(service);
    
    /*
      HealthPlanetのデータのうち、fitbitのデータに存在しないものを日付を元に探す
    */
    // HealthPlanetのスプレッドシートからデータを取得する
    var hpSS = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("HealthPlanet");
    var hpData = hpSS.getRange(2,1,hpSS.getLastRow() - 1,hpSS.getLastColumn()).getValues();
    
    // fitbitにデータの存在する日付をリストで保有する
    fbDateList = [];
    for(var i = 0; i < fbWeightList.length; i++){
      fbDateList.push(fbWeightList[i]['date']);
    }
    
    // HealthPlanetのスプレッドシートデータの日付がfitbitデータの日付にあるかどうかを判定する
    for(var i = 0; i < hpData.length; i++){
      var hpDate = new Date(hpData[i][0]);
      hpDateFormatted = hpDate.getYear() + '-' + ('0' + String(Number(hpDate.getMonth()) + 1)).slice(-2) + '-' + ('0' + hpDate.getDate()).slice(-2);
      hpTimeFormatted = ('0' + hpDate.getHours()).slice(-2) + ':' + ('0' + hpDate.getMinutes()).slice(-2) + ':00';

      // fitbitに存在しない場合
      if(fbDateList.indexOf(hpDateFormatted)==-1.0){
        fbPostWeight(service, hpDateFormatted, hpTimeFormatted, hpData[i][1], hpData[i][2]); // 日付、時刻、体重、体脂肪率を引き数で渡す
        hpSS.getRange(i + 2, 10).setValue(new Date()); // fitbitに同期した日付をスプレッドシートに追記する
      }
    }
    
  }else{
    var authorizationUrl = service.getAuthorizationUrl();
    Logger.log(authorizationUrl);
  }
}

//Callbackで戻ってきたときに呼ばれる
function fbAuthCallback(request) {
  var service = fbGetService();
  var isAuthorized = service.handleCallback(request);
  if (isAuthorized) {
    return HtmlService.createHtmlOutput('Success!');
  } else {
    return HtmlService.createHtmlOutput('Denied.');
  }
}

// リセット
function fbClearService(){
  fbGetService().reset();
}

// OAuth2の設定
function fbGetService(){
  return OAuth2.createService('Fitbit') // 任意の名前
  .setAuthorizationBaseUrl(FB_OAUTH_URL)
  .setTokenUrl(FB_TOKEN_URL)
  .setClientId(FB_CLIENT_ID)
  .setClientSecret(FB_CLIENT_SECRET)
  .setCallbackFunction('fbAuthCallback')
  .setPropertyStore(PropertiesService.getUserProperties())
  .setScope('weight')
  .setTokenHeaders({'Authorization': 'Basic ' + Utilities.base64Encode(FB_CLIENT_ID + ':' + FB_CLIENT_SECRET)});
}


function logRedirectUri() {
  Logger.log(OAuth2.getRedirectUri());
}


// 今日の日付から1年分遡って体重データを取得する
function fbGetWeightList(service){
  dateList = [];
  weightList = [];
  
  // 1年分の日付を1ヶ月毎に分けてリスト化
  for(var i = 0; i < 12; i++){
    fetchDate = new Date();
    fetchDate.setMonth(fetchDate.getMonth() - i)
    fetchDate = Utilities.formatDate(fetchDate, 'Asia/Tokyo', 'yyyy-MM-dd');
    dateList.push(fetchDate);
  }
  
  // 1ヶ月分ごとにデータを取得
  for(var i = 0; i < dateList.length; i++){
    url = 'https://api.fitbit.com/1/user/' + FB_USERID + '/body/log/weight/date/' + dateList[i] + '/1m.json';
    
    var headers = {
      'Authorization': 'Bearer ' + service.getAccessToken()
    };
    
    var options = {
      'method' : 'GET',
      'headers' : headers,
      'muteHttpExceptions': true
    };
    
    // データ取得
    var response = UrlFetchApp.fetch(url, options);
    var data = JSON.parse(response);
    Array.prototype.push.apply(weightList, data['weight'])
  }

  return weightList
}

//渡された日時の体重と体脂肪率をfitbitに同期する
function fbPostWeight(service, date, time, weight, fat){
  urlWeight = 'https://api.fitbit.com/1/user/' + FB_USERID + '/body/log/weight.json';
  urlFat = 'https://api.fitbit.com/1/user/' + FB_USERID + '/body/log/fat.json';
  
  // アクセストークンをheadersで渡す
  var headers = {
    'Authorization': 'Bearer ' + service.getAccessToken()
  };
  
  var payloadWeight = {
    'weight' : weight,
    'date' : date,
    'time' : time
  };
  
  var payloadFat = {
    'fat' : fat,
    'date' : date,
    'time' : time
  };
  
  var optionsWeight = {
    'method' : 'POST',
    'payload': payloadWeight,
    'headers' : headers,
    'muteHttpExceptions': true
  };
  
  var optionsFat = {
    'method' : 'POST',
    'payload': payloadFat,
    'headers' : headers,
    'muteHttpExceptions': true
  };
  
  // データ投稿
  var responseWeight = UrlFetchApp.fetch(urlWeight, optionsWeight);
  Logger.log(JSON.parse(responseWeight))
  
  var responseFat = UrlFetchApp.fetch(urlFat, optionsFat);
  Logger.log(JSON.parse(responseFat))
}
```

### 4. Google Apps Scriptを定期実行する
hpGetData()とfbRun()を1日に1度定期実行する。スプレッドシートのデータ数が増えすぎても困るので、あとは1年より前のデータは自動削除するスクリプトを作れば完璧。