Google Apps ScriptでWithings APIを使ってデータを取得する【GAS】

AppSheetと連携するアプリの1つとして、手持ちのWithingsの体重計とスマートウォッチでの計測データを取得し、別途Gemini APIで写真からカロリー計算をあわせて健康管理アプリを構想中です。

まずは、体重計データとスマートウォッチデータを計測した結果をREST APIを使って自動取得をしようと、Google Apps Scriptでそれらを自動的に定期的に取得をしようと考え、今回そこから整備を始めようとおもいまとめました。

今回利用する素材

現在は時を経て、ウォッチに関してはScanWatch Light、体重計に関してはBody Smartと名前も機能も変えてアップグレードして販売されています。自分のはだいぶ昔の製品で、スマートフォンで管理することができるスグレモノスマートデバイスです。

スマートフォン向けの管理アプリはHealthmateというアプリでしたが、紆余曲折を経て現在はWithingsという名前のアプリとして登場し管理ができるようになっています。スマートウォッチの使い方に関するエントリーは以下を参照してください。

Withingsのダッシュボードページはこちらです。

スマートウォッチ Withings Steel HR Sportを使ってみた

事前準備

GAS側の事前準備

コールバックURLを取得する

GAS側で以下の作業をしてコールバックURLを取得しておきます。デベロッパーアカウント登録する際に必要になります。

コールバックURLとは、認証を完了しAccess Tokenを取得したら戻るべきURLを指定するものです。これは、スクリプトIDをもとに作られているので、スクリプトIDを取得して組み立てます。

  1. スクリプトエディタのメニューより「ファイル」⇒「プロジェクトのプロパティ」を開く
  2. 情報の中にある「スクリプトID」を控えておく。
  3. https://script.google.com/macros/d/スクリプトID/usercallback」として組み立てる。これがコールバックURLとなる。

図:スクリプトIDはファイル毎に異なるのです。

ライブラリの追加

以下の手順でOAuth2 for Apps Scriptライブラリを追加しましょう。

  1. スクリプトエディタを開きます。
  2. メニューより「リソース」⇒「ライブラリ」を開きます。
  3. ライブラリを追加欄に「1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF」を追加します。
  4. 今回はバージョンは43を選択してみます。
  5. 保存ボタンを押して完了

ただし今回のAPIに関しては、このライブラリは殆ど役に立たないので、後述の注意点をよく把握したうえで利用することになります。

図:ライブラリを追加した様子

アカウント登録

既に時計や体重計の計測データをスマフォで取得してる人は取得済みだと思いますが、こちらのサイトでWithingsに対してアカウントを作成する必要があります。

アカウントを作成しログインできると、スマフォで同じアカウントを使っていれば、腕時計や体重計の計測データが同期されていて確認したり、データをダウンロードすることが可能です。

最近は出張の時にだけ家を出ている完全運動不足状態なので、これを機会に体重計と時計を復活させて、トレーニングをしようと考えています。スタミナが本当になくて辛い。

図:Withingsの測定データを確認できる

デベロッパー登録

前述の通常のアカウントとは別にデベロッパー登録することで、GASで利用する際の認証で使うClient IDおよびSecretを取得することが可能です。これを利用してOAuth2.0認証をしてAccess Tokenを取得します。登録データはこちらから常に確認することが可能です。

登録手順は以下の通りです。

  1. こちらのサイトにアクセスする
  2. Create Organizationをクリックする
  3. 組織名とメールアドレスを入力して、Nextをクリックする
  4. すぐに作成が完了するので、Create Applicationをクリックする
  5. Public API integrationおよび利用規約に同意しますにチェックを入れて、Nextをクリックする
  6. Application Nameには適当なアプリ名を入れ、Registered URLsには前述で取得したCallback URLを入れます。
  7. ロゴをここで変更も可能です。
  8. Nextをクリックすると、Client IDおよびClient Secretが取得できます。

図:認証情報を作成する

スクリプトプロパティに登録

Client IDやSecretを直接コード内に記述せず、スクリプトプロパティに格納し、実行時に取り出して使うようにします。

  • clientid : クライアントIDの値を格納する
  • secret : クライアントシークレットの値を格納する

図:安全のためにスクリプトプロパティに格納する

ソースコード

注意点

Withings APIなのですが、実はAccess Token周りがちょっと特殊で今回のライブラリをそのまま使って認証をgetAccessToken()にて、Access Tokenを返しません。取得したToken周りの情報は以下のような形になっており、何故かBody以下に情報が入っている。故にこのままではAccess Tokenも取得できなければ、Refresh Tokenで更新も出来ない。

※普通はbodyなどにいれずに、直下にaccess_tokenやrefresh_tokenはいれるべきであり、それが守られていない。

{
  body: {  // ← トークンは 'body' の中にある
    access_token: "...",
    refresh_token: "...",
    expires_in: 10800.0
    ...
  },
  status: 0.0
}

よって、このあたりのおかしな仕様に対して、自分で対処が必要です。必要になる対処は以下の通り。

  • Access Tokenをbody以下にあるものを取り出して返すようにする
  • 初回取得時やリフレッシュ時にexpires_inの値をもって、トークンが失効する日時をこのbody以下に格納する
  • トークン失効日時と現在日時を比較して、過ぎていればrefresh_tokenを持って新しいAccess Tokenを取得する
  • このexpire_inの値は3時間である

初回の認証時以外殆どライブラリが役に立たない状況です。

認証フロー用のコード

GAS側コード

リクエストに利用するエンドポイントは自分はグローバル設定.gsに分離して固めてあります。

OAuth2.0認証の一連のコードです。これまでも同様のコードを使っていますが、Withingsの場合の注意点としては

  • setScopeでスコープ指定します。user.metrics, user.activityの2つを通常使用します。これ以外にもuser.info, user.sleepeventsといったものがあります。ここで指定したデータを取得することが可能です。スコープのドキュメントはこちら
  • setTokenPayloadHandlerというもので、requesttokenの文字列をヘッダに加える処理をつけています。

前述の注意点を踏まえて、自力で失効する時間やリフレッシュを実行するロジックを装備する必要があります。まずはアプリで使うグローバル設定は以下の通り。

//リクエストエンドポイント
const AUTH_URL = "https://account.withings.com/oauth2_user/authorize2";
const TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2";
const API_ENDPOINT = "https://wbsapi.withings.net/v2/";

//書き込み先シート名
const SHEET_NAME = 'withings';

//取得するデータの過去の期間(7で現在から7日前までという指定になる)
const pastday = 200;

//スプレッドシートのID
const ssid = "ここに読み書き先のスプシのIDを入れる";

次に、認証を実行したり、リフレッシュトークンで再取得するなどの一連のロジックを含めたコードは以下の通り。通常のOAuth2.0認証よりも改造点が増えています。ここが最も重要なポイントになります。

  • checkOAuthにてWithings APIで利用するスコープを今回は2つ指定しています。
  • .setTokenPayloadHandlerにてWithings APIで利用する特殊なヘッダを追加しています。
  • authCallbackにて認証実行後にexpire_inの値をもって、失効する日時を格納する(expire_dateとして格納)
  • getValidWithingsToken関数にて、通常getAccessTokenが担う関数を自作。Tokenが失効してるかどうかをチェックして、refresh_tokenを使ったリフレッシュを実施。
  • トークン失効の判定は、格納済みのexpire_dateの5分前としてセットした値で比較。失効していたらリフレッシュを実行する。
function startoauth(){
  //UIを取得する
  var ui = SpreadsheetApp.getUi();
  
  //認証済みかチェックする
  var service = checkOAuth();
  if (!service.hasAccess()) {
    //認証画面を出力
    var output = HtmlService.createHtmlOutputFromFile('template').setHeight(450).setWidth(500).setSandboxMode(HtmlService.SandboxMode.IFRAME);
    ui.showModalDialog(output, 'OAuth2.0認証');
  } else {
    //認証済みなので終了する
    ui.alert("すでに認証済みです。");
  }
}

//アクセストークンURLを含んだHTMLを返す関数
function authpage(){
  var service = checkOAuth();
  var authorizationUrl = service.getAuthorizationUrl();
  var html = "<center><b><a href='" + authorizationUrl + "' target='_blank' onclick='closeMe();'>アクセス承認</a></b></center>"
  return html;
}

//WithingsのOAuth2サービスを設定し、そのオブジェクトを返す
function checkOAuth() {
  const prop = PropertiesService.getScriptProperties();
  const appid = prop.getProperty("clientid");
  const appsecret = prop.getProperty("secret");

  return OAuth2.createService("withings")
    .setAuthorizationBaseUrl(AUTH_URL)
    .setTokenUrl(TOKEN_URL)
    .setClientId(appid)
    .setClientSecret(appsecret)
    .setCallbackFunction('authCallback')
    .setPropertyStore(PropertiesService.getScriptProperties())
    .setScope('user.activity,user.metrics')
    
    // Withings API特有の 'action' パラメータをトークンリクエストに追加する
    .setTokenPayloadHandler(addWithingsTokenAction);
}

//認証後のコールバックを処理する(トークン失効日時を追加で格納する)
function authCallback(request) {
  const service = checkOAuth();
  const isAuthorized = service.handleCallback(request);
  
  if (isAuthorized) {
    const prop = PropertiesService.getScriptProperties();
    const tokenInfoString = prop.getProperty('oauth2.withings');
    const tokenData = JSON.parse(tokenInfoString);

    // 有効期限を計算
    // bodyオブジェクトにexpire_dateを追加
    tokenData.body.expire_date = getUnixTimeMillisPlus3Hours();

    // expire_dateを追加したオブジェクトを上書き保存
    prop.setProperty('oauth2.withings', JSON.stringify(tokenData));
    
    return HtmlService.createHtmlOutput('認証に成功しました。');
  } else {
    return HtmlService.createHtmlOutput('認証に失敗しました。');
  }
}

//ログアウト
function reset() {
  checkOAuth().reset();
  SpreadsheetApp.getUi().alert("ログアウトしました。")
}

//Withings APIのトークンリクエストに必要な 'action' パラメータをペイロードに追加する補助関数
function addWithingsTokenAction(payload) {
  payload.action = 'requesttoken';
  return payload;
}

/**
 * 有効なアクセストークンを取得する(期限切れなら自動更新も行う)
 * ※Withingsの特殊なデータ形式に対応する最終版
 */
function getValidWithingsToken() {
  const prop = PropertiesService.getScriptProperties();
  const tokenInfoString = prop.getProperty('oauth2.withings');

  if (!tokenInfoString) {
    console.log("トークン情報がありません。");
    return null;
  }

  //トークン情報からexpiredateを取り出す
  const tokenData = JSON.parse(tokenInfoString);

  //expireDateに5分のマージンを持たせる(期限よりも5分前にセット)
  const expireDate = getUnixTimeMinus5Minutes(tokenData.body.expire_date)

  //現在のUNIXTIMEを算出する
  const now = Date.now();

  // 有効期限が現在時刻を過ぎていたら、更新処理を呼び出す
  if (now >= expireDate) {
    //トークンリフレッシュを実行
    console.log("トークンが有効期限切れのため更新します。");
    const newAccessToken = tokenRefresh(tokenData.body.refresh_token);
    return newAccessToken;
  } else {
    //Access Tokenを返す
    console.log("トークンは有効")
    return tokenData.body.access_token;
  }
}

//トークンリフレッシュを実行
function tokenRefresh(refreshToken) {
  // checkOAuth()を呼び出さず、スクリプトプロパティから直接値を取得
  const prop = PropertiesService.getScriptProperties();
  const clientId = prop.getProperty("clientid");
  const clientSecret = prop.getProperty("secret");

  try {
    // グローバル変数のTOKEN_URLを使用してリフレッシュ
    const response = UrlFetchApp.fetch(TOKEN_URL, {
      method: 'post',
      payload: {
        action: 'requesttoken',
        grant_type: 'refresh_token',
        client_id: clientId,       // 直接取得した値を使用
        client_secret: clientSecret,   // 直接取得した値を使用
        refresh_token: refreshToken
      },
      muteHttpExceptions: true
    });

    //リフレッシュ結果を取得する
    const result = JSON.parse(response.getContentText());
    if (result.status !== 0) {
      throw new Error("更新中にAPIがエラーを返しました: " + result.error);
    }
    
    //3時間後のexpire_dateの値をbodyに追加する
    const newTokenBody = result.body;
    newTokenBody.expire_date = getUnixTimeMillisPlus3Hours();
    
    //スクリプトプロパティに再格納
    const dataToStore = { status: 0, body: newTokenBody };
    prop.setProperty('oauth2.withings', JSON.stringify(dataToStore));
    
    console.log("トークンの更新に成功しました。");
    return newTokenBody.access_token;

  } catch (e) {
    console.log("トークンのリフレッシュに失敗しました。再認証が必要です。エラー: " + e.toString());
    //トークン情報をリセットする
    const service = checkOAuth();
    service.reset();
    return null;
  }
}

//現在時刻から3時間を加算したunixtimeを返す関数
function getUnixTimeMillisPlus3Hours() {
  const now = new Date(); // JST前提のローカル時刻
  const plus3Hours = now.getTime() + 3 * 60 * 60 * 1000; // ミリ秒で3時間加算
  return plus3Hours; // ← ミリ秒(13桁)で返す
}

//指定のリミットの5分前のUNIXTIMEを返す関数
function getUnixTimeMinus5Minutes(unixMillis) {
  const fiveMinutes = 5 * 60 * 1000; // 5分をミリ秒で表す
  return unixMillis - fiveMinutes;
}

HTML側コード

認証実行時にダイアログが出ます。そのダイアログ用のHTMLで、アクセス承認をクリックすると、Withingsにジャンプ。そこで許可を与えるとAccess Tokenが取得できます。取得したToken類はスクリプトプロパティの「oauth2.withings」に格納されます。

前述のstartoauth関数を実行して、認証を済ませておいてください。

図:認証用ダイアログが表示される

図:Withings側での認証許可

図:スクリプトプロパティに情報が格納

<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<script type="text/javascript" src="https://apis.google.com/js/api.js"></script>
<script>
  google.script.run.withSuccessHandler(onSuccess).authpage();
  
  function onSuccess(data){
    document.getElementById("kinoko").innerHTML = data;
  }

</script>

<style type="text/css">
  /* --- ボックス --- */
  div.section {
    width: 480px; /* ボックスの幅 */
    background-color: #ffffff; /* ボックスの背景色 */
    border: 1px #c0c0c0 solid; /* ボックスの境界線 */
    font-size: 100%; /* ボックスの文字サイズ */
  }
  
  /* --- 見出し --- */
  div.section h3 {
    margin: 0; /* 見出しのマージン */
    padding: 6px 10px; /* 見出しのパディング(上下、左右) */
    background-color: #f5f5f5; /* 見出しの背景色 */
    border-bottom: 1px #c0c0c0 solid; /* 見出しの下境界線 */
    font-size: 120%; /* 見出しの文字サイズ */
  }
  
  /* --- ボックス内の段落 --- */
  div.section p {
    margin: 1em 10px; /* 段落のマージン(上下、左右) */
  }
</style>

<div class='section' style="text-align: center">
  <img border="0" src="https://officeforest.org/library/oauth2.png" alt="oauth2">
  <h3 id='header'>OAuth認証の許可が必要です。</h3>
  <hr>
</div>
<div id="info">
<p>
このスクリプトは、Withings APIにアクセスするために、特別なログイン処理を利用しています。<br>
既に特別なログインに関する設定はなされており、承認がされるとプログラムを実行することが出来ます。この承認がなされない場合、プログラムの実行に制限が掛かり、
処理が続行できません。<br><br>

<div id="kinoko" style="text-align: center">
   <img border="0" src="https://officeforest.org/wp/library/ProgressSpinner.gif" width="30" height="30">
</div>
</div>
<p>
<script>
function closeMe(){
  if(google && google.script && google.script.host){
      google.script.host.close();
  } else if(window && window.close){
      window.close();
  } 
}
</script>
</div>

データを取得するコード

今回は、単純に日時・歩数・距離・消費カロリーの4点だけ取ってきています。体重計もあれば体重のデータも取得が可能であり、またスマートウォッチ側では心拍数データも取れているので、全部で6点ほど取れば、十分といえるのではないでしょうか?

pastdayは指定した日数で、今日から数えて何日前までのデータを取得するかを指定するものです。データが空っぽの場合にはその旨を返すように設定しており、最終的にスプレッドシートに書き出すようにしています。

//メイン関数:Withingsから活動量データを取得し、スプレッドシートに書き込む
function getWithingsActivityData() {
  const accessToken = getValidWithingsToken();

  if (!accessToken) {
    console.log('認証が必要です。メニューから認証を開始してください。');
    return;
  }

  // 取得する期間を指定
  const endDate = new Date();
  const startDate = new Date();
  startDate.setDate(endDate.getDate() - pastday); //指定の日数前の日付をセット
  const endYMD = formatDate(endDate);
  const startYMD = formatDate(startDate);

  //リクエストURLを構築する
  const apiUrl = `${API_ENDPOINT}measure?action=getactivity&startdateymd=${startYMD}&enddateymd=${endYMD}`;

  try {
    //リクエストオプション
    const options = {
      method: 'get',
      headers: { 'Authorization': 'Bearer ' + accessToken },
      muteHttpExceptions: true // APIエラー時もレスポンス内容を確認するため
    };

    //リクエストを送信
    const response = UrlFetchApp.fetch(apiUrl, options);
    const result = JSON.parse(response.getContentText());
    console.log(result)

    //APIのリクエストエラー
    if (result.status !== 0) {
      console.log(`APIエラー: ${JSON.stringify(result)}`);
      return;
    }
    
    //データが空っぽの場合
    if (!result.body.activities || result.body.activities.length === 0) {
      // データが0件の場合のメッセージ
      const message = `指定された期間(${startYMD} から ${endYMD})に記録されたデータはありませんでした。`;
      console.log(message);
      return; // データがないので処理を終了
    }
    
    // データがあった場合のみ、書き込み処理に進む
    console.log(`取得したアクティビティ件数: ${result.body.activities.length}件`);
    writeToSheet(result.body.activities);
    console.log('データの取得と書き込みが完了しました。');
    
  } catch (e) {
    console.log(`スクリプトの実行中にエラーが発生しました: ${e.toString()}`);
  }
}

//スプレッドシートに書き出す関数
function writeToSheet(activities) {
  const sheet = SpreadsheetApp.openById(ssid).getSheetByName(SHEET_NAME);
  if (!sheet) {
    console.log(`シート「${SHEET_NAME}」が見つかりませんでした。`);
    return;
  }

  //スプシに書き出す
  const lastRow = sheet.getLastRow();
  if (lastRow >= 2) sheet.getRange(`A2:D${lastRow}`).clearContent();
  if (!activities || activities.length === 0) return;
  const dataToWrite = activities.map(a => [a.date, a.steps, a.distance, a.calories]);
  sheet.getRange(2, 1, dataToWrite.length, 4).setValues(dataToWrite);
}

//日付のフォーマットを変換する関数
function formatDate(date) {
  const y = date.getFullYear();
  const m = ('0' + (date.getMonth() + 1)).slice(-2);
  const d = ('0' + date.getDate()).slice(-2);
  return `${y}-${m}-${d}`;
}

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)