サーバーやサービスを使わないで定期的にTwitterの自動フォロー返ししてみる(2017)

# 定期的にTwitterフォロー返しをする

いくつかサービスとか実装例があるのですが、情報が古かったり
痒いところに手が届かない感じだったので自作しました。
サーバ上でTwitter APIのスクリプトを起動する方法もありますが、
サーバを立てるのもめんどくさかったので
Google Apps Scriptで実装しました。

## 前準備
新規にスプレッドシートをGoogle Drive上で作成します。
スプレッドシートのツール→スクリプトエディタからGoogle Apps Script(.gs)を作成します。
![スクリーンショット 2017-11-03 17.51.17.png](https://qiita-image-store.s3.amazonaws.com/0/55077/def1a31f-9338-3397-cd85-425be423f80d.png)

Google Apps ScriptのURLをコピーします。（あとで使います。）
![スクリーンショット 2017-11-03 18.08.57.png](https://qiita-image-store.s3.amazonaws.com/0/55077/55127362-723f-edd2-c069-40cde195d6c0.png)

Twitter APIを利用するために
[Twitter Application Management](https://apps.twitter.com/)で新規にアプリを作成します。
<img width="534" alt="スクリーンショット 2017-11-03 18.01.28.png" src="https://qiita-image-store.s3.amazonaws.com/0/55077/76ab60c2-efac-53a7-0c2d-fee7ec5e7fab.png">

新規にアプリを作成します。
Websiteがない場合は適当なURLでも大丈夫です。
Callback URLは先ほどコピーしたGoogle Apps ScriptのURLを指定します。
![スクリーンショット 2017-11-03 18.13.25.png](https://qiita-image-store.s3.amazonaws.com/0/55077/77a2de5e-29b6-cfef-3fd1-18ed1f8914d6.png)

Consumer KeyとConsumer Secretをメモしておきます（あとで使います。）
![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e616d617a6f6e6177732e636f6d2f302f35353037372f34623962343961302d393430382d363237622d613333612d3731396230333362316532642e706e67.png](https://qiita-image-store.s3.amazonaws.com/0/55077/633b3533-ae38-0959-655a-653a4aaca0aa.png)

Twitter API利用にはOAuth1認証をするためGoogle Apps Scriptのライブラリを追加します。
リソース→ライブラリを開きます。

<img width="474" alt="スクリーンショット 2017-11-03 18.26.45.png" src="https://qiita-image-store.s3.amazonaws.com/0/55077/68fcba57-21d7-8d2a-8a97-40d494b459e9.png">

次のScript IDで[OAuth1 for Apps Script](https://github.com/googlesamples/apps-script-oauth1)を追加します。

```
1CXDCY5sqT9ph64fFwSzVtXnbjpSfWdRymafDrtIZ7Z_hwysTY7IIhi7s
```
<img width="495" alt="スクリーンショット 2017-11-03 18.27.13.png" src="https://qiita-image-store.s3.amazonaws.com/0/55077/c0874cc6-735b-694d-f846-747a9f9e65d3.png">


## 実装
Google Apps Scriptに下記のスクリプトを記述します。

* consumerKey
* consumerSecret

は先程払い出したものを使用してください。

```Twitterフォロー返し.gs
// 最初にこの関数を実行し、ログに出力されたURLにアクセスしてOAuth認証する
function twitterAuthorizeUrl() {
  Twitter.oauth.showUrl();
}

// OAuth認証成功後のコールバック関数
function twitterAuthorizeCallback(request) {
  return Twitter.oauth.callback(request);
}

// OAuth認証のキャッシュをを削除する場合はこれを実行（実行後は再度認証が必要）
function twitterAuthorizeClear() {
  Twitter.oauth.clear();
}


var Twitter = {
  
  consumerKey: "（先程払い出したConsumer Key）",
  consumerSecret: "(先程払い出したConsumer Secret)",
  
  apiUrl: "https://api.twitter.com/1.1/",
  
  oauth: {
    name: "twitter",
    
    service: function(screen_name) {
      // 参照元：https://github.com/googlesamples/apps-script-oauth1
      
      return OAuth1.createService(this.name)
      // Set the endpoint URLs.
      .setAccessTokenUrl('https://api.twitter.com/oauth/access_token')
      .setRequestTokenUrl('https://api.twitter.com/oauth/request_token')
      .setAuthorizationUrl('https://api.twitter.com/oauth/authorize')
      
      // Set the consumer key and secret.
      .setConsumerKey(this.parent.consumerKey)
      .setConsumerSecret(this.parent.consumerSecret)
      
      
      // Set the name of the callback function in the script referenced
      // above that should be invoked to complete the OAuth flow.
      .setCallbackFunction('twitterAuthorizeCallback')
      
      // Set the property store where authorized tokens should be persisted.
      .setPropertyStore(PropertiesService.getUserProperties());
    },
    
    showUrl: function() {
      var service = this.service();
      if (!service.hasAccess()) {
        Logger.log(service.authorize());
      } else {
        Logger.log("認証済みです");
      }
    },
    
    callback: function (request) {
      var service = this.service();
      var isAuthorized = service.handleCallback(request);
      if (isAuthorized) {
        return HtmlService.createHtmlOutput("認証に成功しました！このタブは閉じてかまいません。");
      } else {
        return HtmlService.createHtmlOutput("認証に失敗しました・・・");
      }
    },
    
    clear: function(){
      OAuth1.createService(this.name)
      .setPropertyStore(PropertiesService.getUserProperties())
      .reset();
    }
  },
  
  api: function(path, data) {
    var that = this, service = this.oauth.service();
    if (!service.hasAccess()) {
      Logger.log("先にOAuth認証してください");
      return false;
    }
    
    path = path.toLowerCase().replace(/^\//, '').replace(/\.json$/, '');
    
    var method = (
         /^statuses\/(destroy\/\d+|update|retweet\/\d+)/.test(path)
      || /^media\/upload/.test(path)
      || /^direct_messages\/(destroy|new)/.test(path)
      || /^friendships\/(create|destroy|update)/.test(path)
      || /^account\/(settings|update|remove)/.test(path)
      || /^blocks\/(create|destroy)/.test(path)
      || /^mutes\/users\/(create|destroy)/.test(path)
      || /^favorites\/(destroy|create)/.test(path)
      || /^lists\/[^\/]+\/(destroy|create|update)/.test(path)
      || /^saved_searches\/(create|destroy)/.test(path)
      || /^geo\/place/.test(path)
      || /^users\/report_spam/.test(path)
      ) ? "post" : "get";
    
    var url = this.apiUrl + path + ".json";
    var options = {
      method: method,
      muteHttpExceptions: true
    };
    
    if ("get" === method) {
      if (!this.isEmpty(data)) {        
        url += '?' + Object.keys(data).map(function(key) {
          return that.encodeRfc3986(key) + '=' + that.encodeRfc3986(data[key]);
        }).join('&');
      }
    } else if ("post" == method) {
      if (!this.isEmpty(data)) {
        options.payload = Object.keys(data).map(function(key) {
          return that.encodeRfc3986(key) + '=' + that.encodeRfc3986(data[key]);
        }).join('&');
        
        if (data.media) {
          options.contentType = "multipart/form-data;charset=UTF-8";
        }
      }
    }

    try {
      var result = service.fetch(url, options);
      var json = JSON.parse(result.getContentText());
      if (json) {
        if (json.error) {
          throw new Error(json.error + " (" + json.request + ")");
        } else if (json.errors) {
          var err = [];
          for (var i = 0, l = json.errors.length; i < l; i++) {
            var error = json.errors[i];
            err.push(error.message + " (code: " + error.code + ")");
          }
          throw new Error(err.join("\n"));
        } else {
          return json;
        }
      }
    } catch(e) {
      this.error(e);
    }
    
    return false;
  },
  
  error: function(error) {
    var message = null;
    if ('object' === typeof error && error.message) {
      message = error.message + " ('" + error.fileName + '.gs:' + error.lineNumber +")";
    } else {
      message = error;
    }
    
    Logger.log(message);
  },
  
  isEmpty: function(obj) {
    if (obj == null) return true;
    if (obj.length > 0)    return false;
    if (obj.length === 0)  return true;
    for (var key in obj) {
        if (hasOwnProperty.call(obj, key)) return false;
    }
    return true;
  },
  
  encodeRfc3986: function(str) {
    return encodeURIComponent(str).replace(/[!'()]/g, function(char) {
      return escape(char);
    }).replace(/\*/g, "%2A");
  },
  
  init: function() {
    this.oauth.parent = this;
    return this;
  }
}.init();


/********************************************************************
以下はサポート関数
*/

// ツイート検索
Twitter.search = function(data) {
  if ("string" === typeof data) {
    data = {q: data};
  }
  
  return this.api("search/tweets", data);
};

// 自分のタイムライン取得
Twitter.tl = function(since_id) {
  var data = null;
  
  if ("number" === typeof since_id || /^\d+$/.test(''+since_id)) {
    data = {since_id: since_id};
  } else if("object" === typeof since_id) {
    data = since_id;
  }
  
  return this.api("statuses/home_timeline", data);
};

// ユーザーのタイムライン取得
Twitter.usertl = function(user, since_id) {
  var path = "statuses/user_timeline";
  var data = {};
  
  if (user) {
    if (/^\d+$/.test(user)) {
      data.user_id = user;
    } else {
      data.screen_name = user;
    }
  } else {
    var path = "statuses/home_timeline";
  }
  
  if (since_id) {
    data.since_id = since_id;
  }
  
  return this.api(path, data);
};

// ツイートする
Twitter.tweet = function(data, reply) {
  var path = "statuses/update";
  if ("string" === typeof data) {
    data = {status: data};
  } else if(data.media) {
    path = "statuses/update_with_media ";
  }
  
  if (reply) {
    data.in_reply_to_status_id = reply;
  }
  
  return this.api(path, data);
};

// トレンド取得（日本）
Twitter.trends = function(woeid) {
  data = {id: woeid || 1118108};
  var res = this.api("trends/place", data);
  return (res && res[0] && res[0].trends && res[0].trends.length) ? res[0].trends : null;
};

// トレンドのワードのみ取得
Twitter.trendWords = function(woeid) {
  data = {id: woeid || 1118108};
  var res = this.api("trends/place", data);
  if (res && res[0] && res[0].trends && res[0].trends.length) {
    var trends = res[0].trends;
    var words = [];
    for(var i = 0, l = trends.length; i < l; i++) {
      words.push(trends[i].name);
    }
    return words;
  }
};


Twitter.follower = function(screenName) {
  var data = { 
    screen_name: screenName,
    stringify_ids: true
  };
  return this.api("followers/ids",data);
};

Twitter.outgoing = function(screenName) {
  var data = { 
    screen_name: screenName,
    stringify_ids: true
  };
  return this.api("friendships/outgoing",data);
};


Twitter.friends = function(screenName) {
  var data = {
    "screen_name":screenName
  };
  return this.api("friends/list",data);
};


Twitter.follow = function(userId) {
  var data = {
    "follow":true,
    "user_id":userId
  };
  return this.api("friendships/create",data);
};


// 定期実行でフォロワー返し
function exec(){
  // フォロワー一覧
  var follower = Twitter.follower("meetsmore");
  Logger.log(follower)
  // フォロー許可待ち一覧
  var outgoings = Twitter.outgoing("meetsmore");
  Logger.log(outgoings)
  // フォロー済み一覧
  var friends = Twitter.friends("meetsmore");
  Logger.log(friends) 
   
  // フォロー済みとフォローリクエスト送信済みを除外
  var follow_ids = []
  for(var i = friends.users.length; i--;){
    follow_ids.push(friends.users[i].id_str)
  }
  for(var i = outgoings.ids.length; i--;){
    follow_ids.push(outgoings.ids[i])
  }
  
  // 未フォロワーにフォローリクエスト
  for(var i = 0; i < follow_ids.length; i++){
    var idx = follower.ids.indexOf(follow_ids[i]);
    if(idx !== -1){
      follower.ids.splice(idx,1);
    }
  }
  var ids = follower.ids;
  
  for(var i = ids.length; i--;) {
    var id = ids[i];
    // フォローする
    var result = Twitter.follow(id);
    // フォローしたユーザの情報を表示
    Logger.log(result);     
    // シートに書き込み    
    var Spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
    var Sheet = Spreadsheet.getSheetByName("Twitter");
    if(Sheet){
      var LastRow = Sheet.getLastRow();
      Sheet.getRange(LastRow + 1,1,1,1).setValue('@' + result.screen_name);
    }
  }
  
}
```

OAuth認証をする必要があるので
まず、twitterAuthorizeURL関数を起動します。

<img width="327" alt="スクリーンショット 2017-11-03 18.36.45.png" src="https://qiita-image-store.s3.amazonaws.com/0/55077/c1b0fd30-0cef-09a8-517a-ba0625751eb5.png">

表示→ログにてOAuth認証用URLのログが表示されます。

<img width="325" alt="スクリーンショット 2017-11-03 18.36.55.png" src="https://qiita-image-store.s3.amazonaws.com/0/55077/f2a8fe28-1697-ee00-8520-91b55ea35e75.png">

ブラウザにてURLにアクセスして
OAuth認証を完了させます。（TwitterアカウントとTwitterアプリ連携）

<img width="903" alt="スクリーンショット 2017-11-03 18.45.31.png" src="https://qiita-image-store.s3.amazonaws.com/0/55077/bccadfa4-8d64-05d5-f8fe-1e6590266c1f.png">

exec関数を実行するとフォローされていて未フォローのユーザを一括でフォローします。
フォローした結果のユーザはスプレッドシートに書き出しします。

## 定期的に実行する
編集→すべてのトリガーを選択します。

<img width="292" alt="スクリーンショット 2017-11-03 18.47.32.png" src="https://qiita-image-store.s3.amazonaws.com/0/55077/505a0c92-baba-6d4b-0bbd-8421cc6c9a5a.png">

定期実行する関数をexec
イベントを時間主導型に設定します。
時間は適宜決めてください（私は１時間ごとにしました）

<img width="681" alt="スクリーンショット 2017-11-03 18.50.07.png" src="https://qiita-image-store.s3.amazonaws.com/0/55077/43fd5a83-5cdc-e4b6-fbe6-f016f085b0a8.png">