Steamの一日のゲーム時間を詳しく知りたい

# はじめに

**自粛期間中なにしていますか？**

僕は就活鬱と重なり死んだように一日中ゲームをしていました．普段，一日中ゲームという事はなかったのでSteamの過去2週間のゲーム時間が130時間を超えたときは大変驚きました．

さて，このSteamのゲーム時間記録ですが過去二週間のすべてのゲームの積算しか表示してくれません．Nintendo Switchは小さい子供さんも使うことを考慮して，各ゲームの細かなプレイ時間を記録[^1]してくれるそうです．確かにSteamにそんな細かくゲーム時間を記録して監視するなんて機能不要ですよね．でも僕は自分でゲーム時間管理できないおこちゃまなのですごく欲しい．
[^1]: https://support.nintendo.co.jp/app/answers/detail/a_id/34111



そこで，いろいろと調べていると[Webスクレイピングを使ってSteamの各ゲームのプレイ時間を記録してみた記事](https://maruhoi.com/steam/record-playtime-as-google-app-script-and-google-spreadsheet/)を発見しました．この手法は，Steamのプロフィール上にあるオンラインステータスのhtmlソースからオンライン/オフライン状況や，プレイ中のゲームを抽出するスクリプトをトリガーを使用して1分ごとに実行しGoogle Drive上のSpread Sheetに記録します．
<img alt="img2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344227/56beb422-ebf6-80ee-8c29-2653e9a4c7c5.png">



**!?**
**こいつぁあったまいい！**

という事で，この手法をベースに拝借し，一日の各ゲームの
プレイ時間をメールでお知らせしてくれるように手を加えてみました．
Steamのゲーム時間を管理したいあなた，時間を決めて楽しくゲームしましょう．
(*当方GASさらにはJavaScriptの超絶初心者ですので汚いコードをご容赦ください(涙))

# WebスクレイピングによるSteamステータスの記録
[Webスクレイピング](https://ja.wikipedia.org/wiki/%E3%82%A6%E3%82%A7%E3%83%96%E3%82%B9%E3%82%AF%E3%83%AC%E3%82%A4%E3%83%94%E3%83%B3%E3%82%B0)とは，APIなどを介さずにWebページから直接情報を抽出し利用する技術です．
人がwebサイトを見る代わりに，コンピュータに見てもらうという感じですね．

さて，Steamのプロフィールには(**公開設定にしてあれば**)オンライン/オフライン状況，ゲーム中であればゲーム名が表示されます．そこで定期的にページにアクセスしアカウントのStatusを抽出することで事細かにゲーム状況を記録可能です．
<img width = "600" alt="status.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344227/0df2d262-48ff-da31-c304-b2e1642cdb1e.png">
上記のソースより，現在のStatus(オンライン・オフライン・ゲーム)を取得するには，HTMLソース上の`<div class="profile_in_game persona `と`>`に挟まれた部分から`online`・`offline`・`in-game`を抽出し，さらに`in-game`であった場合に`<div class="profile_in_game_name">`と`</div>`で挟まれた部分からゲーム名を抽出することとなります．

この抽出にはParse  Libraryを使用します．詳しい使い方やインポート方法については[こちら](https://www.kutil.org/2016/01/easy-data-scrapping-with-google-apps.html)を参照ください．

## Spread sheetの用意と行の内容
Google Drive上で記録用のSpread Sheetを作成します．
ほぼデータベースのような位置づけです．僕の場合は下記のように記録しています．
<img alt="spreadsheet.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344227/07cb3f33-58b2-71c1-b4c9-0ca8d2f7b5cd.png">
スクリプトの作成は，下記のToolsタブから行えます．
![gas.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344227/470ad0e5-10c9-afe8-9cc5-9c80c82c4f05.png)

各列の情報に頻繁にアクセスするため，スクリプト上であらかじめ変数として列番号を宣言しておきます．

```javascript
const DATE = 1; //A列
const STATUS = 2; //B列
const STATES = 3; //以下同
const UNIX_TIME = 4;
const ELAPSED_TIME = 5;
const DEBUG = 7;
```

## Statusの取得
Parse  Libraryを使用します．詳しい使い方やインポート方法については[こちら](https://www.kutil.org/2016/01/easy-data-scrapping-with-google-apps.html)を参照ください．
Parse libraryを使用して`context`中から`fromText`と`toText`で挟まれた部分のtextを抽出します．
先述の通りStatusを記載する`fromText`と`toText`は下記のとおりです．

```javascript
var url = "https://steamcommunity.com/id/your_steam_id";
var fromText = '<div class="profile_in_game persona ';
var toText = '">';
var content = UrlFetchApp.fetch(url).getContentText();

function startParse( content , fromText , toText )
{
  return Parser.data(content).from(fromText).to(toText).build();
}
```

### Game名を取得
ゲーム名はStatusが`in-game`の時にHTMLソース上で

```javascript
var g_fromText = '<div class="profile_in_game_name">';
var g_toText = '</div>';

var game_name = startParse( context , g_fromText , g_toText);
```

とすることでstatusがin-gameの時に，ゲーム名を取得できます．
こちらは後の関数内で使用しています．

## CellにStatusを記録
Parseを使用して取得してきた`state`(strings)に対応した内容をセルに記録する関数を用意します．
記録内容は[時刻][Status(online/offline or ゲーム名)][Statusに対応した数字(Offline=0, Online=1, Game=2][UNIX time]としています．

```javascript
var sheet = SpreadsheetApp.getActiveSheet();

function setActivityToCell( status )
{
  var content = UrlFetchApp.fetch(url).getContentText();
  var date = new Date();
  
  if( status == "online")
  {
　　// Onlineなので"online"，'1'をセルへ
    sheet.getRange(sheet.getLastRow() + 1 , 1 , 1 , 4).setValues([[getDate() , "On line" , 1 , date.getTime() ]]);
  }
  else if( status == "offline" )
  {
　　// Offlineなので"offline"，'0'をセルへ
    sheet.getRange(sheet.getLastRow() + 1 , 1 , 1 , 4).setValues([[getDate() , "Off line" , 0 , date.getTime() ]]);
  }
  else if( status == "in-game" )
  { 
    // ゲーム中なので"game_name"，'2'をセルへ
    // Parseにg_fromTextとg_toTextを与えればプレイ中のゲーム名の取得が可能
    sheet.getRange( sheet.getLastRow() + 1, 1 , 1 , 4).setValues([[getDate() , startParse( content , g_fromText , g_toText ) , 2 , date.getTime() ]]);
  }

  function getDate()
  {
    var date = new Date();
    return Utilities.formatDate(date, "JST", "yyyy/MM/dd HH:mm");
  }
}
```

## 記録タイミング
[記事](https://maruhoi.com/steam/record-playtime-as-google-app-script-and-google-spreadsheet/)の手法では1分毎に取得した情報を記録するため，一日で1440行分の記録がたまります．
しかし，欲しいデータはステータス(Online/Offline・各ゲーム)の時間なのでステータスが切り替わった点で記録するようにしました．

まず，StatusをStringsではなく数字として記録したほうが分岐を用いる際に都合が良いので，status(online,offline,in-game)を受け取って，それに対応した数字(online=1 , offline=0 , in-game=2)を返す関数を定義します．

```javascript
function returnStateNum( status )
{
  switch( status )
  {
    case "online":
      return 1;
      break;
    case "offline":
      return 0;
      break;
    case "in-game":
      return 2;
      break;
    default:
      break;
  }
}

```

続いて，この番号を前回の記録と比較して変更があったときのみ内容を記録する関数です．


```javascript
//変更があったときのみ記録する関数
function setActivityIfChanged()
{
  // Steamのプロフィールページのソースを取得
  var content = UrlFetchApp.fetch(url).getContentText();

  //一行目はstatusの切り替えがないのでそのまま記録
  if( sheet.getLastRow() == 1 )
  {
    // 先述の記録する関数
    setActivityToCell( startParse( content , fromText , toText ) );
　　//前日の行が何行目か保存(一行目なので初期値)
    sheet.getRange( 2 , DEBUG ).setValue( 2 );
  }
  //二行目以降(すなわち前回のStatusが記録されている場合)はこっち
  else
  {
    // Statusの数字が前回のStatus ID(offline=0 , online=1 , in-game=2)と異なった場合，Statusを記録
    if( sheet.getRange(sheet.getLastRow() , STATES ).getValue() !== returnStateNum( startParse( content , fromText , toText ) ) )
    { 
      setActivityToCell( startParse( content , fromText , toText ) );
　　　// 前回からの経過時間を記録
      calculateTime();
    }
    // Status番号が同じ場合(例:online->online , in-game->ingame , offline->offline)
    else
    {
      // ただしStatusがin-game中に違うゲームをプレイした場合には別で記録したいので
      // ゲーム名を比較して，ゲーム名が変わっていたら記録
      if( ( returnStateNum( startParse( content , fromText , toText ) ) == 2  ) && ( sheet.getRange( sheet.getLastRow() , STATUS ).getValue() !== startParse( content , g_fromText , g_toText ) ) )
      {
        // Status is "in-game" but played game is different.
        setActivityToCell( startParse( content , fromText , toText ) );
        calculateTime();
      }
      // ゲーム中ではない + 前回とStatusがおなじ　もしくは　同じゲームをプレイしている
      else
      {
        //console.log("Not changed");
      }
    }
  }
}

//　前回からの経過時間を記録する関数
function calculateTime()
{
  var t1 = sheet.getRange( sheet.getLastRow() , UNIX_TIME ).getValue();
  var t0 = sheet.getRange( sheet.getLastRow() - 1 , UNIX_TIME ).getValue();
  var elapsed_time = Math.abs( t1 - t0 ) / ( 1000 );
  sheet.getRange( sheet.getLastRow() - 1 , ELAPSED_TIME ).setValue( elapsed_time );
}
```

脳筋コードなのでひたすらif-elseで分岐させただけなので汚いです．
ゲーム名比較の場所は，違うゲームをプレイする際にトリガーで設定した時間以内にゲームの切り替えた場合があった時にStatusの更新が出来なくなるための処理です．
これで，Statusもしくはプレイしているゲームが変わったときのみ記録することが可能です．

関数`setActivityIfChanged()`を毎分もしくは5分毎くらいにトリガーします．これにより分毎にSteamのプロフィールページにアクセスし，Statusが更新されているときのみSpread Sheetに記録します．トリガーは下記から行えます．
![trigger.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344227/a9ecc464-69d0-24a3-0944-a0d366299642.png)

## 一日の記録の解析
最後に一日の記録時間を各項目(online/offline/in-gameの場合はゲーム名)ごとに積算します．

```javascript
function analysis()
{
　// 最終記録からセル(2 , DEBUG)に保存しておいた行番号までの行をiteration
  var status = [];
  for( let i = sheet.getLastRow() - 1 ; i >= sheet.getRange(2, DEBUG).getValue() ; i -- )
  {
    status.push( sheet.getRange( i , STATUS ).getValue() );
  }
  // Statusの重複を除く
  var status_name = status.filter(function (x, i, self) {
            return self.indexOf(x) === i;
        });

  // Statusごとの時間を連想配列で保存
  // 初期化
  var times = {};
  for( let j = 0 ; j < status.length ; j ++ )
  {
    times[ status_name[ j ] ] = 0.0;
  }
  // セル(2 , DEBUG)に保存しておいた行番号までの行をiterationして時間を対応するkeyに積算
  for( let k = sheet.getLastRow() - 1 ; k >= sheet.getRange(2, DEBUG).getValue() ; k -- )
  {
    times[ sheet.getRange( k , STATUS).getValue() ] = times[ sheet.getRange( k , STATUS).getValue() ] + sheet.getRange( k , ELAPSED_TIME ).getValue() ;
  }

  // 積算した内容をメール
  mailing( status_name , times );

  // 積算した最終行を次回積算時の開始点とする(記録しておく)
  sheet.getRange( 2 , DEBUG ).setValue( sheet.getLastRow() );
}
```

前回積算を行った行をセル(2, DEBUG)に記録しておき，最新行から前回の行までiterationします．
最初にStatus名(online/offline/ゲーム名)のユニークを取った配列`status_name`を作成します．
その後，連想配列`times`によってゲーム名と時間を記録します．JSあんまり分からなくて雑な方法な気がするので，もっといいやり方あれば教えていただきたいです...
最後に，積算した最終行番号をセル(2 , DEBUG)に記録し，次回の積算時の開始点とします．


## メール
積算した内容を整形してメールします．

```javascript
function mailing( status_name , times )
{
  const recipient = 'your_mail_adress';
  var date = new Date();
  const subject = '【steam game alart】' + Utilities.formatDate(date, "JST", "yyyy/MM/dd");
  var body = 'Hi me!\n\nTodays steam game play time analisys is here.\n\n\n';
  body = body + "━━━━━━━━━━━━━━━━━━━━━━━━\n";
  for( let i = 0; i < status_name.length ; i ++ )
  {
    var str_time = sec2str( times[ status_name[ i ] ] );
    body = body + '   - ' +  status_name[ i ] + ' : ' + str_time + '\n';
  }
  body = body + "━━━━━━━━━━━━━━━━━━━━━━━━\n\n";
  body = body + '\n\n You need to reduce time of game....\n';
  console.log(body);
  GmailApp.sendEmail(recipient, subject, body);
}
```
時間は秒で記録しているので，見やすくします

```javascript
function sec2str( sec )
{
  var t = Math.floor(sec);
  var str_time = '';
  var h = t / 3600 | 0;
  var m = t % 3600 / 60 | 0;
  var s = t % 60;
  
  if( h != 0 )
  {
    str_time = h + "時間" + padZero(m) + "分" + padZero(s) + "秒";
  }
  else if( m != 0 )
  {
    str_time = "00時間" + m + "分" + padZero(s) + "秒";
  }
  else
  {
    str_time = "00時間00分" + s + "秒";
  }
  return str_time;
  
  function padZero( v )
  {
    if( v < 10 )
    {
      return "0" + v;
    }
    else
    {
      return v;
    }
  }
}
``` 

## 全体のまとめ関数
最後に，日をまたぐタイミング等でトリガーを発生させる関数を作成して終了です．
日をまたいでもゲームしている可能性がある(というかほぼ毎回)ので強制的に行を更新してそこまでの情報で積算しメールを送信します．

```javascript
function totalization()
{
  setActivity();
  analysis();
}

function setActivity()
{   
  // Set Activity even no state change
  var content = UrlFetchApp.fetch(url).getContentText();
  setActivityToCell( startParse( content , fromText , toText ) );
  calculateTime();
}
```

関数`totalization()`を日ごとにトリガーします．


実行するとこんな感じのメールが届きます．
<img width="320" alt="IMG_6590.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344227/750f7ffa-be54-852f-2cd4-999b51aa608e.png">

**これで思う存分ゲームをしても叱ってくれるね！！！！**



# まとめ
GASを用いてSteamのプロフィールページからWebスクレイピングによってゲーム時間を記録し，メールするシステムを作ってみました．
Steamの細かいゲーム時間の記録って結構需要あると思うんですけど僕だけなんですかね?(震)

本当ならゲーム中にPCは起動しているわけだし，どうにかSteamのゲームが立ち上がっている情報をローカルで入手できそうなんですがどうなんでしょう．
将来的にはグラフ化等の解析ツールも組み込んでみたいですね．あと，SteamのサーバーやWebページが動いていることが前提です．たまーにSteamの鯖落ちを経験しているのでその時は記録は無理ですね．

GAS，初めて使いましたがとても便利そうです．もっと勉強します．

WHOもゲーム依存症については最近うるさい[^2]ですし，自己管理しっかりして思う存分ゲームを楽しみましょう．
さて，トロコンするか.....
[^2]: https://www.nikkei.com/article/DGXMZO45280950V20C19A5MM8000/