TANITA HealthPlanetの体組成データをスプレッドシートに記録する方法【ChatGPTペアプロ】

# はじめに

TANITAのスマホ連携体組成計では公式のスマホアプリ（[HealthPlanet](https://www.healthplanet.jp/)）を用いて過去の体重や体脂肪率などのデータを時系列グラフで見ることができますが、アプリ仕様上の制約が大きいと感じていました。具体的には、以下のような点です。
- 1画面でグラフ表示できる期間に限界（最長1年間）があり、長期的なトレンドを把握できない
- 日次の計測値にばらつきがありグラフが見ずらい。移動平均でスムージングしたい。
- 歩数計など任意のデータとの相関を分析したい
- 10年後、20年後もデータにアクセスできるかわからない

データをエクスポートできれば、エクセルなどの表計算ソフトを使って好きなように加工できますが、残念ながらHealthPlanetではデータのエクスポート機能はありません。

そこで、[HealthPlanetのAPI](https://www.healthplanet.jp/apis/api.html)を **Google Apps Script (GAS)** から呼び出してスプレッドシートに書き出せるようにしたので、その方法を解説します。

スマホ連携体組成計を検討中の方にも、参考になれば嬉しいです。


なお、ネットを検索すると類似の内容の記事がいくつか見つかりますが、情報が古かったりでうまく行かなかったので、今回は **ChatGPT** の力をフル活用しています。ChatGPTを使った開発の勘所みたいなものもご紹介できればと思います。


## この記事でできるようになること
まず、スプレッドシートに紐付けられたスクリプトからHelthPlanetの認証を行います。その後は好きなタイミングで、体重と体脂肪の時系列データをスプレッドシートに出力できるようになります。


## 前提・注意事項
- 本記事はHealthPlanetに体組成計のデータを連携している方向けです。HealthPlanetの登録方法や基本的な使い方は公式ページを参照ください

- HealthPlanet APIやOAuth2ライブラリは2025年4月29日時点の情報です。必要に応じて最新情報をご確認ください。

---

# 実装

## ステップ 1: HealthPlanetのAPI設定

1. HealthPlanetにログイン後、［登録情報］→［サービス連携］→［アプリケーション開発者の方はこちら］→［新規登録］からクライアントIDとシークレットを登録
    - サービス名：任意
    - ウェブサイト：https://script.google.com
    - ホストドメイン：script.google.com
    - メールアドレス：自身のメールアドレスを指定
    - アプリケーションタイプ：Webアプリケーション
2. 登録後、Client IDとClient secretをメモしておく
---

## ステップ 2: GASでOAuth2の設定
HealthPlanetはAuth2を使って認証します。認証に必要な関数群を用意します。

1. 新規googleスプレッドシートを作成

1. ［拡張機能］→［Apps Script］でGASプロジェクトを作成（別タブでScriptエディタが開く）

1. [OAuth2 GAS用ライブラリ](https://github.com/gsuitedevs/apps-script-oauth2) を追加
    1. 左の縦リボンの［ライブラリ　＋］をクリック
    1. スクリプトIDに下記のOAuth2ライブラリのIDを入力
    1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF
    1. ［追加］をクリック

1. スクリプトをデプロイ
    1. スクリプトエディタの右上［デプロイ］→［新しいデプロイ］からデプロイ
    1. 設定→ウェブアプリを選択
    1. 入力項目は以下の通り
        - 説明：任意
        - ウェブアプリ：自分
        - アクセスできるユーザー：自分のみ
    1. ウェブアプリのURLをメモ（以下の書式になっているはず）
    https://script.google.com/macros/s/YOUR_DEPLOY_ID/exec
    
    【補足】デプロイはバックエンド側（HelthPlanet側）からGASにアクセスするために必要です。アクセスできるユーザを「自分のみ」に制限することでセキュリティリスクを最小限にできます。（自分のGoogleアカウントでログインしない限り、URLだけではアクセス不可）

1. `getHealthPlanetService()` 関数を作成


    ```javascript
    function getHealthPlanetService() {
      var clientId = 'YOUR_CLIENT_ID';
      var clientSecret = 'YOUR_CLIENT_SECRET';
      return OAuth2.createService('HealthPlanet')
        .setAuthorizationBaseUrl('https://www.healthplanet.jp/oauth/auth')
        .setTokenUrl('https://www.healthplanet.jp/oauth/token')
        .setClientId(clientId)
        .setClientSecret(clientSecret)
        .setCallbackFunction('authCallback')
        .setRedirectUri('https://script.google.com/macros/s/YOUR_DEPLOY_ID/exec')
        .setPropertyStore(PropertiesService.getUserProperties())
        .setScope('innerscan')
        .setTokenHeaders({
          'Authorization': 'Basic ' + Utilities.base64Encode(clientId + ':' + clientSecret)
        });
    }
    ```
    以下３箇所は適宜書き換えが必要です。
    - YOUR_CLIENT_ID
    - YOUR_CLIENT_SECRET
    - YOUR_DEPLOY_ID

1. OAuth認証コールバック関数の実装
    
    Helath Planet側で認証ボタンが押されたときに実行される関数を定義します。
    getHealthPlanetService内では、setCallbackFunction('authCallback') として指定されている部分です。細かいところはAuth2ライブラリがやってくれます。
    
    ```javascript
    function authCallback(request) {
      var service = getHealthPlanetService();
      var isAuthorized = service.handleCallback(request);
      if (isAuthorized) {
        return HtmlService.createHtmlOutput('認証が成功しました。');
      } else {
        return HtmlService.createHtmlOutput('認証が拒否されました。');
      }
    }
    
    ```

---
## ステップ 3: 認証の実行

1. ここで、認証→データ取得→スプレッドシートに書き込み　を一気に行う関数を定義します。

    ```javascript
    // データ取得関数
    function fetchData() {
      var service = getHealthPlanetService();
      if (!service.hasAccess()) {
        var authorizationUrl = service.getAuthorizationUrl();
        Logger.log('認証が必要です。URLにアクセスしてください: %s', authorizationUrl);
        return;
      }
    
      var accessToken = service.getAccessToken();
      Logger.log('取得したアクセストークン: %s', accessToken);
    
      Logger.log('認証済み。データ取得を開始します。');
    
      var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
      sheet.clearContents();
      sheet.appendRow(['測定日', '体重(kg)', '体脂肪率(%)']);
    
      var startDate = new Date(2023, 10, 1); // 適宜編集してください
      var now = new Date();
      var chunkMonths = 3;
    
      var allRecords = {};
    
      while (startDate < now) {
        var endDate = new Date(startDate);
        endDate.setMonth(endDate.getMonth() + chunkMonths);
        if (endDate > now) {
          endDate = now;
        }
    
        var from = Utilities.formatDate(startDate, 'Asia/Tokyo', 'yyyyMMddHHmmss');
        var to = Utilities.formatDate(endDate, 'Asia/Tokyo', 'yyyyMMddHHmmss');
    
        Logger.log('取得期間: ' + from + ' ～ ' + to);
    
        var url = 'https://www.healthplanet.jp/status/innerscan.json?access_token=' + encodeURIComponent(accessToken)
                  + '&tag=6021,6022'
                  + '&from=' + from
                  + '&to=' + to;
    
        var options = {
          'muteHttpExceptions': true,
          'method': 'get',
          'followRedirects': false
        };
    
        var response = UrlFetchApp.fetch(url, options);
        var code = response.getResponseCode();
        Logger.log('レスポンスコード: ' + code);
    
        if (code !== 200) {
          Logger.log('レスポンスボディ: ' + response.getContentText());
          throw new Error('API呼び出しに失敗しました。レスポンスコード: ' + code);
        }
    
        var json = JSON.parse(response.getContentText());
        if (json.data && json.data.length > 0) {
          json.data.forEach(function(entry) {
            var dateStr = entry.date;
            var dateObj = new Date(
              Number(dateStr.substring(0, 4)),
              Number(dateStr.substring(4, 6)) - 1,
              Number(dateStr.substring(6, 8))
            );
    
            var dateKey = Utilities.formatDate(dateObj, 'Asia/Tokyo', 'yyyy-MM-dd'); // ←日付だけに整形
    
            if (!allRecords[dateKey]) {
              allRecords[dateKey] = { date: dateKey };
            }
    
            if (entry.tag == "6021") {
              allRecords[dateKey].weight = parseFloat(entry.keydata);
            } else if (entry.tag == "6022") {
              allRecords[dateKey].bodyFat = parseFloat(entry.keydata);
            }
          });
        }
    ```

2. スクリプトエディタからfetchDataを実行します
    この時点では以下の if 文の中に入って return されるだけです。データの取得やスプレッドシートの書き込みまでは進みません。
    ```javascript
          if (!service.hasAccess()) {
        var authorizationUrl = service.getAuthorizationUrl();
        Logger.log('認証が必要です。URLにアクセスしてください: %s', authorizationUrl);
        return;
      }
    ```
2. 実行ログに図のように認証用URLが出力されます

    ![スクリーンショット 2025-04-30 10.33.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196595/19c752c5-4ce2-4fd1-ab84-5716432c4a24.png)

2. アドレスバーにコピペすると以下の画面になりますので、HelthPlanetの認証を行います
    <img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196595/09801a48-a01d-41eb-a0ff-ac1cece0ea62.png">


1. 「GASが次の許可を求めています。体組成情報へのアクセス」という画面が表示されるのでアクセスを許可します。その後、以下の画面が表示されればOKです。 （エラーのように見えますが、fetchDataが返り値を持たないことによるエラーです。認証やスプレッドシートへの書き込みには影響ないので問題ありません。）
    ![スクリーンショット 2025-04-29 22.34.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196595/86d23ed1-9f00-4d0b-b90d-1d63749004c6.png)

    【補足】[ステップ2](#ステップ-2-gasでoauth2の設定)でデプロイしていないと「現在、ファイルを開くことができません。」というエラーページが表示されています。ChatGPTとのやり取り序盤では、このデプロイの手順が抜けていて躓きましたが、エラーの内容を教えるとちゃんと解決策も教えてくれました。

---

## ステップ 4: データの取得 & シートへの書き込み

1. 認証完了後、再度fetchDataを実行します
1. 以下のように、ログに取得したトークンが表示され、体組成データの取得が行われます

    ![スクリーンショット 2025-05-01 8.23.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196595/73c85f30-7b35-47b2-94c4-62b5e4e1aee7.png)

1. スプレットシートを確認すると以下のように書き込みされています。（日数が多いと時間がかかります。）

    <img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196595/82bc81e1-5e56-4535-b1c9-581ea2eb271b.png">

ここまでで実装は完了です。
あとはデータを取得したいタイミングでfetchDataを実行すればOKです。

## データ取得の仕様
HealthPlanet API は一度に3ヶ月分のデータしか取得できないため、3ヶ月ごとに分割してリクエストしています。取得開始日は以下の行で指定しています。

```javascript
      var startDate = new Date(2023, 10, 1); // 適宜編集してください
```

私の体組成計の購入時期に合わせて2023/10/1を開始日にしていますが、適宜修正してください。

また、日時は時刻まで取得可能ですが、表示簡素化のためスプレッドシートにはあえて年月日(yyyy-MM-dd)のみ書き込んでいます。

（定期トリガーを設定して、日次の体重ログを全自動化することも可能です。）

ちなみに、データ整形＆スプレッドシートへの書き込み部分のコードは完全にChatGPT任せです。分割リクエスト&結果マージなど結構ややこしい処理ですが、エラーなど何もなく期待通りの動作をしてくれました。

## ChatGPTハマりポイント：アクセストークンの渡し方

OAuth2では、アクセストークンをAuthorizationヘッダとして渡すやり方を標準としていますが、HealthPlanetではURLパラメータで渡す方式を採っています。ChatGPTを使う上で、ここで一番ハマりました。

当初、ChatGPTは以下のようにOAuth2標準方式のコードを生成しました。

```javascript
function fetchData() {
    ...    
    var url = 'https://www.healthplanet.jp/status/innerscan.json?tag=6021,6022'
              + '&from=' + from
              + '&to=' + to;
    
    var options = {
        'headers': {
          'Authorization': 'Bearer ' + service.getAccessToken()
        },
      'muteHttpExceptions': true,
      'method': 'get',
      'followRedirects': false
    };
    ...
}
```

このコードでは認証失敗のエラーが返ってきます。数回のやり取りでも解決せず、APIリファレンスを見てみると、リクエストのURL例がどうやらChatGPTの生成したコードとは異なることに気が付きました。

そこで、以下のようにChatGPTに問い合わせることで問題が解決しました。

![スクリーンショット 2025-05-01 10.00.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196595/cf12a206-3484-48bc-8064-f4dd0b30ad37.png)

ChatGPTの返答はこのあともつらつら次ぐ来ますが、最終的に、以下の正しいコードが得られました。

```javascript
function fetchData() {
    ...    
    var url = 'https://www.healthplanet.jp/status/innerscan.json?access_token=' + encodeURIComponent(accessToken)
              + '&tag=6021,6022'
              + '&from=' + from
              + '&to=' + to;

    var options = {
      'muteHttpExceptions': true,
      'method': 'get',
      'followRedirects': false
    };
    ...
}
```
この方式はトークンがURLにむき出しになるのでセキュリティは劣ります。しかし、バックエンド側の実装コストを抑えることができるメリットを優先して、HealthPlanetではこの方式を採用したと思われます。

---

# 体組成データ移動平均の可視化
スプレッドシートに出力した体重と体脂肪をグラフで可視化します。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196595/335eefe5-8ec4-4298-b096-e8977246e870.png">

ここまではHelathPlanetのグラフ表示機能とほぼ同じですが、Helath Planetでは1画面に表示できるのが1年分までで、前後のデータを見るにはグラフをスクロールする必要があり、前年同時期との体重比較がパッと見分かりづらくなっています。スプレッドシートの可視化では、そのような問題はなくひと目で全体像を把握できます。

次に、せっかくなのでスムージング（14点移動平均）して表示してみました。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196595/2dcdd936-315c-4ed4-a8c8-01baa843d61f.png">

非常に見やすくなっています。筆者の体重・体脂肪率の増減パターンとして、1月に正月太りで体重がピークを迎え、1〜2月に体調を壊すことが多く体重が減る、というサイクルが確認できます。（縦軸メモリは消していますが、体重は±5kg、体脂肪は±4%程度変動があります）

将来的には歩数系などの外部データとも連携して分析したいです。

# ChatGPTペアプロの知見

今回ChatGPTとペアプロ（ペアプログラミング）して得られた知見です。

- データの整形など単純作業は得意
- APIリファレンスのURLを与えるだけで、だいたいやりたいことはできる
- しかし、標準のお作法に則らない**独自仕様がある場合は要注意**。今回はアクセストークンの渡し方（[ChatGPTハマりポイント](#chatgptハマりポイントアクセストークンの渡し方)参照）
- ChatGPTペアプロで困ったときの対応手順
    1. エラー内容で聞いてみる
    1. 2~3回のやり取りで解決しない場合、自分のコードをコピペしてみる（バグが見つかる可能性あり）
    1. それでも解決しない場合、独自仕様がないか、APIリファレンスなどを確認

## おわりに

Google Apps Scriptを使うことで、HealthPlanetの体組成データを、手間かけずにGoogleスプレッドシートに自動出力できるようになりました。

10年、20年とデータを蓄積していけば、思わぬところで役に立つかもしれません。

---

よければ、コメントやLGTMお願いします！