【GAS】Steamの積みゲーをTrelloで視える化した話

# イントロダクション

## この記事の概要
SteamのライブラリをTrelloのボードにまとめて、積みゲーの可視化を図ったときの話。

## 背景
セールのたびに調子に乗ってゲームを買い続けた結果、ライブラリにはゲームがあふれ、どれから手をつけるべきかわからなくなる。ちょっとずつ遊んで積んでいくうちにどれが遊んだゲームなのかわからなくなる。ゲーマーたるもの買ったゲームは遊ぶべき。コレクターになってはいけない。

## ざっくりアーキテクチャ

![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/41b4a7b3-4b6a-ae07-8f50-b969758b8292.png)

# 0. 前提

事前に下記を用意する。
・Steam Web APIキー[^1]
・Trello Web APIキー[^2]
・Trelloトークン[^2]

また、Trelloのボードも事前に準備しておく。リストは下記のとおり、五種類用意した。
APIを叩くときはリストID[^3]を指定するため、リスト名は好きに決めて良い。

・Stacking（ライブラリにあるけどプレイ時間ゼロ）
・Pending（遊んだことあるけど直近２週間のプレイ時間ゼロ）
・Playing（直近２週間のプレイ実績あり）
・Played（全トロフィー獲得済み）
・Wanna Play（ウィッシュリスト）

# 1. GASコード全容

```javascript:application.gs
// プロパティキー定数
var PROPERTY_KEY_STEAM_API_KEY = 'STEAM_API_KEY';
var PROPERTY_KEY_STEAM_ID = 'STEAM_ID';
var PROPERTY_KEY_TRELLO_API_KEY = 'TRELLO_API_KEY';
var PROPERTY_KEY_TRELLO_TOKEN = 'TRELLO_TOKEN';
var PROPERTY_KEY_SLACK_WEBHOOK_ENDPOINT = 'SLACK_WEBHOOK_ENDPOINT';

// エンドポイントフォーマット定数
var FORMAT_STEAM_ENDPOINT_GET_OWNED_GAMES = 'http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?include_appinfo=true&include_played_free_games=true&key={0}&steamid={1}';
var FORMAT_STEAM_ENDPOINT_GET_PLAYER_ACHIEVEMENTS = 'http://api.steampowered.com/ISteamUserStats/GetPlayerAchievements/v0001/?key={0}&steamid={1}&appid={2}';
var FORMAT_STEAM_ENDPOINT_GET_PLAYER_WISHLIST = 'https://store.steampowered.com/wishlist/profiles/{0}/wishlistdata';
var FORMAT_STEAM_IMAGE_LOGO_URL = 'http://media.steampowered.com/steamcommunity/public/images/apps/{0}/{1}.jpg';
var FORMAT_TRELLO_ENDPOINT_ARCHIVE_ALL_CARD_ON_LIST = 'https://api.trello.com/1/lists/{0}/archiveAllCards?key={1}&token={2}';
var FORMAT_TRELLO_ENDPOINT_CREATE_NEW_CARD = 'https://api.trello.com/1/cards?key={0}&token={1}&idList={2}&desc={3}&name={4}&urlSource={5}';

// TrelloリストID定数
var LIST_ID_STACKING = '604ed2027599ae2d423ec526';
var LIST_ID_PENDING = '604ebe8213443e361da34fc1';
var LIST_ID_PLAYING = '604ea6be71f9ad1d1ab2a75f';
var LIST_ID_PLAYED = '604ea6c355a39c738ccda7b3';
var LIST_ID_WANNA_PLAY = '604ea6cab083aa0be93b88ce';

// プロパティ
var properties = PropertiesService.getScriptProperties();

// メイン関数
// 日次でトリガ起動される
function _daily() {
    var steamKey = properties.getProperty(PROPERTY_KEY_STEAM_API_KEY);
    var steamId = properties.getProperty(PROPERTY_KEY_STEAM_ID);
    var trelloKey = properties.getProperty(PROPERTY_KEY_TRELLO_API_KEY);
    var trelloToken = properties.getProperty(PROPERTY_KEY_TRELLO_TOKEN);

    // Steamライブラリにあるゲーム一覧を取得する
    var games = getOwnedGames(steamKey, steamId);

    // トロフィー獲得状況を取得し、全トロフィー獲得済みフラグを立てる
    games = markCompletedGames(steamKey, steamId, games);

    // ウィッシュリストにあるゲーム一覧を取得する
    var wishList = getWishList(steamId);

    // Trelloボードの既存カードをすべてアーカイブする
    archiveAllCards(trelloKey, trelloToken);

    // ゲーム一覧からカードをつくり、Trelloへ登録する
    createNewCards(trelloKey, trelloToken, games);
    createNewCardsOnWannaPlay(trelloKey, trelloToken, wishList);
}

// Steamライブラリからゲーム一覧を取得する関数
function getOwnedGames(key, id) {
    var url = FORMAT_STEAM_ENDPOINT_GET_OWNED_GAMES.format(key, id);
    var options = {
        method: 'get'
    };

    var response = callExternalAPI(url, options);
    var rawGames = JSON.parse(response.getContentText()).response.games;

    var games = [];
    for (var i = 0; i < rawGames.length; i++) {
        var game = new Game(rawGames[i].appid, rawGames[i].name, rawGames[i].playtime_forever, rawGames[i].img_logo_url);

        // playtime_2weeksフィールドを持つゲームは直近２週間のプレイ実績があるので、「最近遊んだゲーム」としてフラグを立てる
        if (typeof rawGames[i].playtime_2weeks != 'undefined') {
            game.isRecentlyPlayed = true;
        }

        games.push(game);
    }

    return games;
}

// トロフィー獲得状況を取得し、全トロフィー獲得済みフラグを立てる関数
function markCompletedGames(key, id, games) {
    var options = {
        method: 'get',

        // ゲームによってはトロフィー獲得状況を問い合わせたときにエラーになる？
        // 処理は中断したくないのでtrueにしておく
        muteHttpExceptions: true
    };

    for (var i = 0; i < games.length; i++) {
        var url = FORMAT_STEAM_ENDPOINT_GET_PLAYER_ACHIEVEMENTS.format(key, id, games[i].appId);
        var response = callExternalAPI(url, options);
        var achievements = JSON.parse(response.getContentText()).playerstats.achievements;

        // ゲームによってはachievementsが未定義の場合がある？
        if (achievements) {
            for (var j = 0; j < achievements.length; j++) {
                if (achievements[j].achieved == 1) { // 全トロフィーが獲得済み(1)ならフラグを立てる
                    games[i].isCompleted = true;
                    continue;
                } else { // 未獲得のトロフィーがある時点でbreakする
                    games[i].isCompleted = false;
                    break;
                }
            }
        }
    }

    return games;
}

// ウィッシュリストにあるゲーム一覧を取得する関数
function getWishList(id) {
    var url = FORMAT_STEAM_ENDPOINT_GET_PLAYER_WISHLIST.format(id);
    var options = {
        method: 'get'
    };
    var response = callExternalAPI(url, options);
    var rawGames = JSON.parse(response.getContentText());
    var keys = Object.keys(rawGames);

    var games = [];
    for (var i = 0; i < keys.length; i++) {
        // ウィッシュリストのゲーム一覧は、ライブラリのゲーム一覧とJSONの構造が異なる
        // appidがキーになっており、その下に情報がぶら下がるイメージ。
        // ロゴURLもなぜか異なるのでいったん仮値で置く
        var game = new Game(keys[i], rawGames[keys[i]].name, 0, "DUMMY");

        // 仮値で置いていたロゴURLを設定する
        game.logoUrl = rawGames[keys[i]].capsule;

        games.push(game);
    }

    return games;
}

// Trelloボードの既存カードをすべてアーカイブする関数
function archiveAllCards(key, token) {
    var options = {
        method: 'post'
    };

    // Stackingリストのカードをすべてアーカイブ
    var url = FORMAT_TRELLO_ENDPOINT_ARCHIVE_ALL_CARD_ON_LIST.format(LIST_ID_STACKING, key, token);
    callExternalAPI(url, options);

    // Pendingリストのカードをすべてアーカイブ
    var url = FORMAT_TRELLO_ENDPOINT_ARCHIVE_ALL_CARD_ON_LIST.format(LIST_ID_PENDING, key, token);
    callExternalAPI(url, options);

    // Playingリストのカードをすべてアーカイブ
    var url = FORMAT_TRELLO_ENDPOINT_ARCHIVE_ALL_CARD_ON_LIST.format(LIST_ID_PLAYING, key, token);
    callExternalAPI(url, options);

    // Playedリストのカードをすべてアーカイブ
    var url = FORMAT_TRELLO_ENDPOINT_ARCHIVE_ALL_CARD_ON_LIST.format(LIST_ID_PLAYED, key, token);
    callExternalAPI(url, options);

    // Wanna Playリストのカードをすべてアーカイブ
    var url = FORMAT_TRELLO_ENDPOINT_ARCHIVE_ALL_CARD_ON_LIST.format(LIST_ID_WANNA_PLAY, key, token);
    callExternalAPI(url, options);
}

// ゲーム一覧からカードをつくり、Trelloへ登録する関数
function createNewCards(key, token, games) {
    var options = {
        method: 'post'
    };

    for (var i = 0; i < games.length; i++) {
        // プレイ時間ゼロのゲームをStackingリストへ登録する
        if (games[i].playtime == 0) {
            var url = FORMAT_TRELLO_ENDPOINT_CREATE_NEW_CARD.format(key, token, LIST_ID_STACKING, games[i].appId, encodeURIComponent(games[i].name), games[i].logoUrl);
            callExternalAPI(url, options);
            continue;
        }

        // 直近２週間でプレイ実績のあるゲームをPlayingリストへ登録する
        if (games[i].isRecentlyPlayed) {
            var url = FORMAT_TRELLO_ENDPOINT_CREATE_NEW_CARD.format(key, token, LIST_ID_PLAYING, games[i].appId, encodeURIComponent(games[i].name), games[i].logoUrl);
            callExternalAPI(url, options);
            continue;
        }

        // 全トロフィー獲得済みのゲームをPlayedリストへ登録する 
        if (games[i].isCompleted) {
            var url = FORMAT_TRELLO_ENDPOINT_CREATE_NEW_CARD.format(key, token, LIST_ID_PLAYED, games[i].appId, encodeURIComponent(games[i].name), games[i].logoUrl);
            callExternalAPI(url, options);
            continue;
        }

        // 上記のいずれにも該当しないゲームをPendingリストへ登録する
        var url = FORMAT_TRELLO_ENDPOINT_CREATE_NEW_CARD.format(key, token, LIST_ID_PENDING, games[i].appId, encodeURIComponent(games[i].name), games[i].logoUrl);
        callExternalAPI(url, options);
    }
}

// ゲーム一覧からカードをつくり、Trelloへ登録する関数（ウィッシュリスト用）
function createNewCardsOnWannaPlay(key, token, games) {
    var options = {
        method: 'post'
    };

    for (var i = 0; i < games.length; i++) {
        var url = FORMAT_TRELLO_ENDPOINT_CREATE_NEW_CARD.format(key, token, LIST_ID_WANNA_PLAY, games[i].appId, encodeURIComponent(games[i].name), games[i].logoUrl);
        callExternalAPI(url, options);
    }
}

// API呼び出し用汎用関数
function callExternalAPI(endpoint, options) {
    var response = UrlFetchApp.fetch(endpoint, options);
    return response;
}

// ゲームを表すエンティティクラス
var Game = (function () {
    function Game(appId, name, playtime, logoHash) {
        // Steamが付与するAppId
        this.appId = appId;

        // ゲームタイトル
        this.name = name;

        // プレイ時間トータル
        this.playtime = playtime;

        // ロゴ画像URL
        this.logoUrl = FORMAT_STEAM_IMAGE_LOGO_URL.format(this.appId, logoHash);

        // 全トロフィー獲得済みフラグ
        this.isCompleted = false;

        // 直近２週間のプレイ実績ありフラグ
        this.isRecentlyPlayed = false;
    }
    ;
    return Game;
}());
```

# 2. 結果
**うむ！積みゲーばっかりだな！**
[https://trello.com/b/90dnRQH5/gamers-sai-no-kawara](https://trello.com/b/90dnRQH5/gamers-sai-no-kawara)

![キャプチャ.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/c47c1963-5f7d-1c5c-0cd9-5e737b799acd.png)

# 課題
GASで自動化したことで、ありがちな「視える化したけどメンテナンスされなくなって陳腐化する」事態は避けられた。

ただ、
・いったんカードを全てアーカイブしてから再登録する現状の仕様だと、Steam以外のプラットフォームで積んでるゲームソフトは手動で登録するみたいなことはできない
・上記と同じ理由で、自動登録されたカードを手動更新することはできない
・アーカイブしたカードが溜まりっぱなし[^4]
などの課題は残る。

Trello側で登録／更新されたカードの差分がとれれば復元できるんだけどなあ。

[![syake-salmon/gamers-library-on-trello - GitHub](https://gh-card.dev/repos/syake-salmon/gamers-library-on-trello.svg?fullname=)](https://github.com/syake-salmon/gamers-library-on-trello)

[^1]: Steamのライブラリ情報などをAPIを通して取得するときに使う。[https://steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey)から発行する。
[^2]: TrelloのボードをAPIを通して更新するときに使う。[https://trello.com/app-key](https://trello.com/app-key)から発行する。
[^3]: [https://api.trello.com/1/boards/{ボードID}/lists?key={APIキー}&token={トークン}](https://api.trello.com/1/boards/{ボードID}/lists?key={APIキー}&token={トークン})にブラウザからアクセスすればリストIDがわかる。
[^4]: APIはあるので消せばいい。ただの手抜き。