TwitterのTLで投稿されたSpotifyの曲を自動的にプレイリストに追加するのは大変だった

この記事は [Livesense not engineers Advent Calendar 2018](https://qiita.com/advent-calendar/2018/livesense-not-engineers) の15日目の記事です。

# はじめに
初めまして。リブセンスで管理部門に所属している[yuya_s](https://qiita.com/yuya_s)といいます。
普段はGoogle SpreadsheetやExcelで数字をいじったり資料を作ったりしていることが多く、プログラムに触れる機会はほぼありません。
今日は、そんな私が図らずもコードを書くことになった話です。

突然ですが、皆さんはどんな趣味をお持ちでしょうか？
私は音楽が好きです。ライブやフェスにも行きますし、普段の生活でも音楽は欠かせません。

そして、音楽好きの方であれば何らかのストリーミングサービスを利用されている方も多いのではないでしょうか。私は[Spotify](https://www.spotify.com/jp/)派です。プレミアム会員になってから1年経ち、Spotifyのない生活は考えられなくなりました。
Spotifyはリコメンドも優秀ですし、膨大な新譜も追加されていくので新しい音楽との出会いには事欠きません。
…と言いたいのですが、やはり自分の趣味をベースとしたリコメンドだとどうしても似たようなものが多くなり、全く新しい音楽、未知のジャンルに出会う機会はそこまで多くはありません。一方で、全ての新譜を聴いて探していくというのも現実的ではありません。

**そうだ、自分だけで探せないなら、人が探してくれたものを聴こう。**
私の周りには音楽の趣味で出会った友人が多く、直接の知り合いではない音楽系のユーザーも含め、TwitterのTLには日々Spotifyのリンクが流れてきます。感度の高い彼らの勧めてくれる音楽なら私の好みにヒットする確率が高く、かつ幅広く新しい音楽に出会えるはずです。
というわけで、「**TwitterのTLで投稿されたSpotifyの曲を自動的にプレイリストに追加して聴けるようにする**」というのが今回のお話のテーマです。
もう少し広く言えば、「自分のTLに流れてくるURLを自動で収集し、条件に合うものをピックアップして加工する方法」とも言えます。

# 手順
## IFTTT
異なるWebサービス間の連携といえば、まず思いつくのは[IFTTT](https://ifttt.com/)です。
IFTTTでレシピを作ればサクッと連携が完成して快適なSpotifyライフが送れてしまいます。便利な世の中です。

**結論：そんなに甘くはありませんでした**

色々と調べた結果、単純にIFTTTを使うだけでは以下のような問題があることがわかりました。

- 自分のフォローしているアカウントに限定して検索/TL取得をすることができない
    - IFTTTで検索をするとTwitter上で呟かれた全てのSpotifyのURLがHITしてしまう
        - Twitterには検索結果を自分のフォローしているアカウントに限定する検索オプション `filter:follows` があるのですが、これも効きませんでした。どうやらログイン状態で検索をしているわけではないようです。
- 取得したTweetからURLを抽出する方法がない
    - 仮に抽出できても、Tweet内のURLは全て短縮URL `https://t.co/xxxx` 形式になっているため、どれがSpotifyのURLかわかりません
- Spotifyへのプレイリスト登録の方法が「アーティスト名やトラック名で検索し、一番上でHITしたものをプレイリストに登録する」という微妙な仕様
    - URLを取得しても直接登録ができず、何らかの形で検索ワードを引っ張ってくる必要があります
    - 上記を実現しても、登録されたトラックが本当にTwitter上でお勧めされたものかわかりません（同名の別の曲かもしれません）

ということでIFTTTの利用は諦め、他の方法を模索することにしました。

## Microsoft Flow → Google Apps Script (GAS) → Zapier
最終的にはこのような手順になりました。
<img width="600" src="https://qiita-image-store.s3.amazonaws.com/0/319838/6076a903-9ad4-a4dc-79fe-8a37ab9946e3.png">
「えっ、めんどくさそう」と思った方、安心してください。~~確かにめんどくさかったけど~~頑張ったら私でもできました。
本職のエンジニアの方なら各種APIを使ったりして最初から最後まで綺麗に1本のプログラムとして実現できるのかもしれませんが、私は素人です。外部のサービスを使えるならできる限り使っていきましょう。自分で全てのコードを書くより楽です。たぶん。
というわけで、ここからはひとつひとつの設定を説明します。

### Microsoft Flow
まずは[Microsoft Flow](https://flow.microsoft.com/ja-jp/)を使い、TwitterのTLを取得します。
Webサービスの連携ツールはいくつかありますが、ここでMicrosoft Flowを選択したのは、「自分のアカウントのTLを取得する」アクションがMicrosoft Flowにしかなかったためです。
設定内容はこちらです。
![MicrosoftFlow.png](https://qiita-image-store.s3.amazonaws.com/0/319838/7266a947-b45b-1a04-bed5-3df285c445a2.png)
詳細な説明は割愛しますが、以下のような処理を行っています。

1. 60分ごとにFlowを実行する
2. 自分のTLから最新のTweet200件を取得する
3. 取得したTweetのうち、本文内に `https://t.co/` があるもののみを抽出する
4. 抽出したTweetをSpreadsheetの最終行に追加する

3の抽出処理は後続のGASで行っても良いのですが、Spreadsheetへの転記数には制限がある[^1]ため、ここで数を絞っています。

なお、上記の方法だと、60分以内に200件以上の投稿があった場合は取得漏れが発生します[^2]。投稿の多い時間帯だと心許ないのですが、今回は「漏れなく拾う」ことが目的ではないので、そのあたりは割り切って使うことにしました。
また、Retweetに関しては `RT @ユーザー名 本文` という取得の仕方になり、文字数の多い投稿だと途中でテキストやURLが切れることがあります。ここも諦めました。人生には諦めも重要です。

### GAS
できればコードは書かずに済ませようと色々調べたのですが、やはりそういうわけにはいかないようです。
よしわかった、リブセンスは[営業もSQLを書く会社](https://www.slideshare.net/livesense/150225-sql-foreveryone-45695818)だ。管理部門もコードを書こうじゃないか（仕事じゃないけど）。

普段からGoogle Spreadsheetを使っていること、連携ツールとの相性の良さを考え、今回は[Google Apps Script](https://developers.google.com/apps-script/)（Officeユーザーの方はVBAのようなものだと思ってください）で処理をすることにしました。
ちなみに私のプログラミング経験は十数年前の大学時代にPHPを少し、社会人になってからVBAを少しくらいです。ドラゴンボールで言えばチャオズくらいでしょうか。例えが古いですね。

完全な独学で作成したため、間違いやお見苦しい内容がありましたらご指摘をいただけると嬉しいです。
GASの基本的な書き方は下記のサイトなどにお世話になりました。
[【保存版】Google Apps Scriptリファレンス～キーワード別インデックス](https://tonari-it.com/google-apps-script-reference/)
ただ、GASは日本語のまとまったドキュメントが少ないようなので、これから覚える方はまず本を読んだりしたほうが効率が良いかもしれません。

#### Spreadsheetの構成
Microsoft Flowの実行がうまくいくと、Spreadsheetには自分のTLから取得したtweetが記録されていきます。
（`__PowerAppsId__`列はMicrosoft Flowによって勝手に付与されるようです）
![Spreadsheet1.png](https://qiita-image-store.s3.amazonaws.com/0/319838/d0dfca79-bacf-a5a4-f48d-1947d436998a.png)

Spreadsheetのシート構成は以下のようにしました。
![Spreadsheet2.png](https://qiita-image-store.s3.amazonaws.com/0/319838/a027752a-7adf-ba01-f393-368051e00b0e.png)
tweetを取得してZapierに渡すデータを記録するだけなら`1.TL取得`と`3.Spotify登録用`だけあれば用は足りるのですが、何度か試行錯誤した結果この構成に落ち着きました。
それぞれのシートは以下のような目的で使っています。

| シート | 目的 |
|:--|:--|
| 1.TL取得  | Microsoft Flowから取得したtweetを記録する  |
| 2.URL記録  | 1から`https://t.co/xxxx`のURLを抽出し、記録する  |
| 3.Spotify登録用  | 2のURLを展開し、Spotifyの曲へのリンクならURLとTrack IDを記録する  |
| 4.Track以外  | 2のURLを展開し、曲以外のSpotifyへのリンク（アルバム等）ならURLを記録する  |

GASではそれほど難しい処理はしていません（私のスキル的にできません）が、いくつかのポイントを抜粋して紹介します。

#### 短縮URLの展開
前述の通り、取得したURLはすべて `https://t.co/xxxx` という形式になっているため、リンク先がSpotifyかどうか確かめるには短縮URLを展開する必要があります。
展開の方法については[こちらの記事](https://aqpolo.hateblo.jp/entry/2018/08/01/003000)を参考にさせていただきました。
バックグラウンドで定期実行すること、取得漏れはある程度割り切っていることから、エラー処理を追加して展開時にエラーが起きたときも無視して処理を続けるようにしています。

```js
function expandUrl(url) {
  var options = {
    "method" : "GET",
    "followRedirects" : false,
    "validateHttpsCertificates" : false,
    'muteHttpExceptions': true
  };
  Utilities.sleep(1000); // 1sec wait
  try{
    var redirect_url = UrlFetchApp.fetch(url,options).getAllHeaders()[ 'Location' ];
    if( typeof redirect_url === 'undefined'){
      return "";
    }else{
      return redirect_url;
    }
  }catch(e){
    return "";
  }
}
```

また、この処理は対象のURLを1件ずつ開いていくため、実行に時間がかかります。
できる限り展開するURLを減らすため、過去に取得したことのあるURLは全て `2.URL記録` シートに記録し、初出のURLのみを処理するようにしました。
重複の処理については[こちら](https://lab.syncer.jp/Web/JavaScript/Snippet/75/)や[こちら](https://oki2a24.com/2015/09/16/how-to-convert-2-dimensional-array-to-1-dimensional-array-in-javascript/)の記事を参考にさせていただきました。

```js
//抽出した短縮URLから重複しているURLを削除
var uniqueUrls = Urls.filter(function (value, index, array) {
                   return array.indexOf(value) === index;
                 });

//「2.URL記録」シートに記載しているURLを取得
var twitterUrlLogSheet = ss.getSheetByName("2.URL記録");
var twitterUrlLastRow = twitterUrlLogSheet.getLastRow();
var twitterUrlTargetRange = twitterUrlLogSheet.getRange(1, 1, twitterUrlLastRow, 1).getValues();

//記載済みURLと今回取得したURLが重複していないか確認し、初出のURLのみ配列に格納
var twitterUrls1dArray = Array.prototype.concat.apply([],twitterUrlTargetRange); //二次元配列を一次元配列に変換
var uniqueUrls2 = [];
for(var i = 0; i < uniqueUrls.length; i++){
  twitterUrls1dArray.indexOf(uniqueUrls[i]));
  if(twitterUrls1dArray.indexOf(uniqueUrls[i]) < 0){
    uniqueUrls2.push(uniqueUrls1[i]);
  }
}
```

#### SpotifyのURLの判別
Spotifyのリンクをシェアする際には「曲」「アルバム」「アーティスト」「プレイリスト」といった単位でシェアをすることが可能です。
一方、Zapier経由でプレイリストに追加できるのは曲のみのため、「曲」と「それ以外」で記録するシートを分けるようにしました。
SpotifyのそれぞれのURLは以下のような仕様になっているようです。
プレイリストだけは他のURLと仕様が違うため、正規表現で取得する際には `/` を含める必要があります。

| 種別 | URL |
|:--|:--|
| 曲  | https\://open.spotify.com/track/_Track ID_?si=_パラメータ_ |
| アルバム  | https\://open.spotify.com/album/_Album ID_?si=_パラメータ_ |
| アーティスト  | https\://open.spotify.com/artist/_Artist ID_?si=_パラメータ_ |
| プレイリスト  | https\://open.spotify.com/user/_User ID_/playlist/_Playlist ID_?si=_パラメータ_ |

```js
//URLを展開してhttps://open.spotify.com/track/だったらspotifyTrackUrlsに、それ以外のspotifyのURLだったらspotifyOtherUrlsに格納
var rootSpotifyTrackUrl = "https://open.spotify.com/track/";
var regSpotifyTrackUrl = new RegExp(rootSpotifyTrackUrl + "[a-zA-Z0-9]+");
var rootSpotifyOtherUrl = "https://open.spotify.com/";
var regSpotifyOtherUrl = new RegExp(rootSpotifyOtherUrl + "[a-zA-Z0-9\/]+");
var spotifyTrackUrls = [];
var spotifyOtherUrls = [];

//短縮URLをひとつずつ展開してSpotifyのURLか確認
for(var i = 0; i < uniqueUrls2.length; i++){
  //URLを展開
  var checkUrl = expandUrl(uniqueUrls2[k]);

  if(checkUrl.match(regSpotifyTrackUrl) != null){         //Trackかどうか確認
    var checkUrl2 = checkUrl.match(regSpotifyTrackUrl);
    spotifyTrackUrls.push(checkUrl2[0]);
  }else if(checkUrl.match(regSpotifyOtherUrl) != null){   //Track以外のSpotify URLか確認
    var checkUrl2 = checkUrl.match(regSpotifyOtherUrl);
    spotifyOtherUrls.push(checkUrl2[0]);
  }
}
```

ZapierでSpotifyと連携するときにはTrack IDを使うため、 `3.Spotify登録用` シートにはTrack IDとURLを記録しています。
なお、URLのうち `?` 以降のパラメータは不要なので、Spreadsheetに記録するときには削除しています。

```js
//TrackIDとURLを「3.Spotify登録用」シートに追記
var spotifyTrackSheet = ss.getSheetByName("3.Spotify登録用");
var spotifyTrackLastRow = spotifyTrackSheet.getLastRow();
var outputTrackData = [];
var trackId = "";
if(spotifyTrackUrls.length > 0){
  for(var i = 0; i < spotifyTrackUrls.length; i++){
    trackId = spotifyTrackUrls[i].replace(rootSpotifyTrackUrl, "");
    outputTrackData.push([trackId, spotifyTrackUrls[i]]);
  }
  spotifyTrackSheet.getRange(spotifyTrackLastRow + 1, 1, outputTrackData.length, 2).setValues(outputTrackData);
}
```

#### トリガーの設定
GASは定期実行のトリガーを設定することができます。
設定はスクリプトエディタの `編集－現在のプロジェクトのトリガー` から行います。
Microsoft Flowの実行タイミングに合わせ、60分に1回ずつ実行します。
<img width="500" src="https://qiita-image-store.s3.amazonaws.com/0/319838/b860aaa0-d150-2bc6-1268-a44edba3ee9d.png">

#### 実行結果
GASが正常に実行されると、 `3.Spotify登録用` にTLに投稿されたSpotifyのURLと、そこから抽出したTrack IDが記録されていきます。
![Spreadsheet3.png](https://qiita-image-store.s3.amazonaws.com/0/319838/6c7cbd6c-167d-98e4-f031-d9ba4b78e53b.png)

### Zapier
[Zapier](https://zapier.com/)はIFTTTやMicrosoft Flowと同じWebサービスの連携ツールです。
Zapierを選んだ理由は、「Track IDを指定してプレイリストへ追加する」アクションがZapierにしかなかったためです。なんでサービスごとにこんなに差があるんだろう…
設定内容は以下の通りです。
<img width="300" src="https://qiita-image-store.s3.amazonaws.com/0/319838/8a09e883-950c-ff87-5f9c-9885104d3f76.png">
<img width="500" src="https://qiita-image-store.s3.amazonaws.com/0/319838/9dbf7536-b3f1-683a-6b47-d10038f6d6bb.png">
<img width="500" src="https://qiita-image-store.s3.amazonaws.com/0/319838/0cf9be2c-2735-f556-b8d3-5d8bfc1f7330.png">

1.Google Spreadsheetの `3.Spotify登録用` シートに新しい行が追加されると起動
2.追加された行の1列目にあるTrack IDを `Playlist` で指定したSpotifyのプレイリストに追加
という処理を行っています。Track IDの記載されている列は `Custome Value for Track Track ID` で指定します。

### Spotify
全ての処理が正常に行われると、下記の通りSpotifyのプレイリストに新たな曲が追加されます。やったー！
<img width="700" src="https://qiita-image-store.s3.amazonaws.com/0/319838/59578c11-5b0e-b967-46b9-f9cc97d74f6f.png">

# これからやりたいこと
一旦は上記の方法で連携が実現していますが、まだ改善すべき点はいくつかありそうです。
良い方法をご存知の方がいらっしゃいましたら教えてください。

- Track以外のSpotify URLへの対応
    - 前述の通り、現在のZapierの仕様ではplaylistに取り込めるのは曲(Track)のみです。アルバム、アーティスト、プレイリスト等のURLは別途保存して手動で確認できるようにしていますが、このあたりも自動化できたら素敵です。
- Microsoft FlowからのTL取り込み方法の改善
    - Microsoft Flow→Spreadsheetへの書き込みはTweetひとつごとに行われます。これにより以下のふたつの問題が生じているため、どうにか改善したいです。
        - Spreadsheetに1件ずつ書き込んでいるため、実行速度が遅い
        - 1件ごとにAPIを消費しているらしく、API利用上限エラーが発生することがある
    - 対象Tweetの量が多いと全ての書き込みができず、5分半くらいで強制的に終了しているっぽいです（エラーではなく、実行完了として記録される）。原因は不明
- GASの実行時間制限への対応
    - GASには1スクリプトあたり6分の実行時間制限があり、この制限を超過すると強制的にスクリプトの実行が止まります。今回のコードでは短縮URLの展開に時間がかかるため、対象URLが多いと6分を超える可能性があります（今のところは概ね1分以内で処理できていそうです）

# 学び
~~正直めんどくさかった~~当初思っていたよりも遥かに大変でしたが、結果として非常に勉強になりつつ、ほぼ思い描いていた通りの連携を実現することができました。
やはり多少なりともコードが書けると実現できる幅が広がりますね。
エンジニアではなくても、今後は様々なツールと並ぶ選択肢のひとつに「自分でコードを書く」を入れていきたいなと思います。

# おわりに
teto「忘れた」([Spotify](https://open.spotify.com/track/47iqHXYP7dRakb7CpplCEw)) ([YouTube](https://www.youtube.com/watch?v=ngKSRlSyrJg))は名曲なのでよろしくお願いします。

それでは皆様、良いSpotifyライフを。

[^1]: Spreadsheetへの書き込みには100件/100秒間のAPI利用上限があるらしいです
[^2]: Microsoft Flowの無料プランでは750回/月（≒24回/日）のFlow実行回数制限と200件/回のTweet取得数制限があるため、これ以上は難しそうです