Twitterでふぁぼった画像を自動的に保存したかった


## まえおき
TLに流れてるいい感じの画像をふぁぼるじゃないですか？ 中でも気になったやつはDLしたりなんかして。
理由は何であれ気軽にふぁぼってると過去のふぁぼが見返せなかったり、どっかに紛れて見つからなくなったりってのがよくあります。多くなるといちいちDLするのも面倒だったり。

特にオタクまつり夏の陣・冬の陣が近づくとサークルチェックも兼ねてよくふぁぼるんですが、いざリスト作るときにとか終わった後に抜け漏れに気付くんですよ。
フォローしているユーザからリスト作ったりするサービスとかもあるんですが、無節操にフォローしてるとそれはそれでカオスで…

そんな時に見つけたのがこの記事でした。

<b>
GoogleAppsScriptでTwitterの画像を収集する
https://qiita.com/w_cota/items/a87b421ba8bc2b90a938
</b>

神か

こちらGoogleAppScriptを使って、ツイートに付随する画像を保存するってやつです。
GoogleDriveに保存するので当然連携も容易、無料でも15GBあるので気楽に使えます。
記事では特定のユーザが投稿した画像を収集していましたが、TLだけじゃなくSearchAPIのクエリ次第で色々なパターンに対応できますし、自分のFavリストから集めれば見失うことも無くなります。（たぶん
ハッシュタグ＋Fav・RTでフィルタすれば、気になる話題の画像を収集する事もできます。

例えばFGOで人気？の画像なら

https://api.twitter.com/1.1/search/tweets.json?q=%23FateGO+filter%3Aimages+exclude%3Anativeretweets+exclude%3Aretweets+min_retweets%3A1000+min_faves%3A3000&ref_src=twsrc%5Etfw&count=200

というクエリで取得できます。
実際に取得した画像はこんな感じでDrive上に保存されます。

<img width="950" alt="スクリーンショット 2018-11-27 12.37.34.png" src="https://qiita-image-store.s3.amazonaws.com/0/44384/e4bad7cc-847f-7ea4-97eb-cf72a32fd591.png">





## 使い方
GoogleAppScriptの使い方とか設定、TwitterAPIについては長くなるので説明しません。
ログ用のシートと掲載しているScriptを用意したので、興味があればこちらを自分のGoogleDriveにコピーして、適宜修正してください。
https://docs.google.com/spreadsheets/d/15mTdLQgplFqpNlOPmrqvMWrBjMMjpmWxpzhjup9Is9s/edit?usp=sharing

保存のログはこんな感じでSpreadsheetに出力されます。
<img width="1273" alt="スクリーンショット 2018-11-30 15.59.27.png" src="https://qiita-image-store.s3.amazonaws.com/0/44384/dabd4246-3fa2-8d31-f336-ac60127a1092.png">

画像の保存はこんな感じ。

<img width="1217" alt="スクリーンショット 2018-11-30 16.41.39.png" src="https://qiita-image-store.s3.amazonaws.com/0/44384/7b1fb580-c455-d26e-b284-8ae4998b7cb7.png">

### よういするもの
- Twアカ
- Googleアカ
  - 保存先のGoogleDriveとそのID
    - 保存先のフォルダIDはDrive上でURLに表示されます。
        - ttps://drive.google.com/drive/folders/”ここがID” 
  - Scriptとか保存ログを書くスプレッドシート。出力先のシートを特定するためにシート名を使うので注意してください。


TwitterAPIを使うのでDeveloper登録してない方は登録してAPIキーを発行しましょう。
API利用の手続きが変わって面倒になったようなので登録していない人は注意です。

参考
https://masatoshihanai.com/php-twitter-bot-01/

利用にあたっては

- あなたのCONSUMER_KEY
- あなたのONSUMER_SECRET
- 保存のクエリと対応する保存するときのフォルダID

の３つを設定すれば使えるはずです。


### 実際のコード

``` javascript
TWITTER_CONSUMER_KEY = 'あなたのCONSUMER_KEY';
TWITTER_CONSUMER_SECRET = 'あなたのONSUMER_SECRET';
SPREAD_SHEET_ID = '取得したツイートの情報を出力するスプレッドシートのID';
NAME_IMAGE_SHEET = 'imageUrls'; // 保存した画像のツイート情報を保存しておくシート名
NAME_IGNORE_SHEET = 'ignore'; // 除外したいユーザIDを入力しておくシート名

TWEET_STATUS = "https://twitter.com/";
// sample " https://twitter.com/" + tweet.user.screen_name + "/status/" + tweet.id

SEARCH_API = 'https://api.twitter.com/1.1/search/tweets.json?q=';

// favしたツイートから取得する場合のリクエスト
// "screen_name=対象ユーザの名前"
FAV = 'https://api.twitter.com/1.1/favorites/list.json?screen_name=”ユーザID”&count=200&include_entities=true';

LOOP_FAV = 4;
FOLDER_FAV = '保存するときのフォルダID';
QUERY_A_FOLDER = '保存するときのフォルダID';
QUERY_B_FOLDER = '保存するときのフォルダID';
QUERY_C_FOLDER = '保存するときのフォルダID';

FILTER ='filter%3Aimages+exclude%3Anativeretweets+exclude%3Aretweets++min_retweets%3A500+min_faves%3A1200&ref_src=twsrc%5Etfw';

// 検索リクエストサンプル
// これらは特定のハッシュタグ＋画像・動画が付与されているツイート＋最小RT・Fav数でフィルタ＋RTされたツイートは含まない だったはず
QUERY_A = '%23艦これ+filter%3Aimages++min_retweets%3A600+min_faves%3A3000&ref_src=twsrc%5Etfw';
QUERY_B = '%23FateGO+filter%3Aimages+exclude%3Anativeretweets+exclude%3Aretweets++min_retweets%3A1000+min_faves%3A3000&ref_src=twsrc%5Etfw';
QUERY_C = '%23アズールレーン+filter%3Aimages+exclude%3Anativeretweets+exclude%3Aretweets+min_retweets%3A1000+min_faves%3A3000&ref_src=twsrc%5Etfw';

COUNT = '&count=200';
ID_COL = 2;
DATE_FORMAT = "yyyy-MM-dd HH:mm";

// RequestTypeを使ってまとめてリクエストする用。未使用
var requestTypes = [];

RequestType = function(url, name, folderId) {
  this.url = url;
  this.name = name;
  this.folderId = folderId
}

RequestType.prototype.getName = function() {
  return this.name;
}

RequestType.prototype.getFolderId = function() {
  return this.folderId;
}

RequestType.prototype.getUrl = function() {
  return this.url;
}

function getKancoll(){
  var statuses = [];
  // リクエストするURL、ログ用のタグ、保存先を設定してRequestTypeを生成
  var request = new RequestType(SEARCH_API + QUERY_A + COUNT, 'kancolle', QUERY_A_FOLDER);

  statuses = getRequestTweet(request.getUrl());
  checkingAndStoreImages(statuses, request);
}

function getFGO(){
  var statuses = [];
  var request = new RequestType(SEARCH_API + QUERY_B + COUNT, 'fgo', QUERY_B_FOLDER);

  statuses = getRequestTweet(request.getUrl());
  checkingAndStoreImages(statuses, request);
}

function getAzure(){
  var statuses = [];
  var request = new RequestType(SEARCH_API + QUERY_C + COUNT, 'azur', QUERY_C_FOLDER);

  statuses = getRequestTweet(request.getUrl());
  checkingAndStoreImages(statuses, request);
}

function getFavo(){
  var statuses = [];
  var request = new RequestType(FAV, 'fav', FOLDER_FAV);

  //statuses = getFavoriteTweets(request.getUrl());
  statuses = getRequestTweet(request.getUrl());
  checkingAndStoreImages(statuses, request);
}

function getRequestTweet(requestUrl) {
  var tweets = [];
  var token = getAccessToken();
  var apiOptions = {  headers: { Authorization: 'Bearer ' + token },"method": "get"  };

  var responseApi = UrlFetchApp.fetch(requestUrl, apiOptions);
  // バリデーション
  if (responseApi.getResponseCode() !== 200) return "";
  tweets = JSON.parse(responseApi.getContentText());
  if (!tweets) {
    return "";
  }
  return tweets.statuses;
}

// 過去のFavを取得するためにループ回す。通常は不要
function getFavoriteTweets(requestUrl) {
  var response = [];
  var token = getAccessToken();
  var apiOptions = {
    headers: { Authorization: 'Bearer ' + token },
    "method": "get"
  };
  for (var i = 0; i < LOOP_FAV; i++) {
    var tweets = [];

    var responseApi = UrlFetchApp.fetch(requestUrl, apiOptions);
    // バリデーション
    if (responseApi.getResponseCode() !== 200) return "";
    if (responseApi.getContentText() == '') return null;

    tweets = (JSON.parse(responseApi.getContentText()));
    if (tweets.length > 0) {
      response = tweets.concat(response);
    }
    requestUrl = createFavoriteRequestPrams(tweets);
  }
  return response;
}

function checkingAndStoreImages(tweets, requestType) {
  var ss = openSpreadSheet();
  var url, is_duplicate, tweet;
  var ignores = getIgnoreUsers();
  for (var i = 0; i < tweets.length - 1; i++) {
    is_duplicate = false;
    tweet = tweets[i];
    if (isIgnoreUser(tweet.user.id_str, ignores)) {  continue;  }
    if (!hasExtendedEntities(tweet)) {  continue; }
    checkDuplicate(tweet, ss, requestType);
  }
}

function checkDuplicate(tweet, sheet, requestType) {
  var urlEntities = [];
  urlEntities = getImageUrlExtendedEntities(tweet.extended_entities);

  var lastRow = sheet.getLastRow() + 1;
  var savedUrls = sheet.getRange(1, 1, lastRow).getValues();
  var savedTweetIds = sheet.getRange(1, 3, lastRow).getValues();

  for (var i = 0; i < urlEntities.length; i++) {
    var imageUrl = urlEntities[i];
    if (!isDuplicateId(tweet.id_str, savedTweetIds, lastRow) && !isDuplicateUrl(imageUrl, savedUrls, lastRow)) {
      try{
        storeImage(tweet, imageUrl, requestType)
        writeSpreadSheet(sheet, tweet, lastRow, imageUrl, requestType);
        lastRow++;
        savedUrls = sheet.getRange(1, 1, lastRow).getValues();
        savedTweetIds = sheet.getRange(1, 3, lastRow).getValues();
      }catch(e){
      }
    }
  }
}

function storeImage(tweet, imageUrl,reqeustType){
  var folder = openDriveFolder(requestType.getFolderId());
  if (folder == null) {  return; }

  var imageBlob = UrlFetchApp.fetch(imageUrl).getBlob();
  var file = folder.createFile(imageBlob);
  var statusURL = TWEET_STATUS + tweet.user.screen_name + "/status/" + tweet.id_str
  file.setDescription(statusURL);
  file.setName(tweet.user.screen_name + "_" + tweet.id_str +"_" + file.getName())
}
// 重複判定用のログ。なんかそれっぽい情報を吐き出す
function writeSpreadSheet(sheet, tweet, colNum, imageUrl, requestType) {
  var date = Utilities.formatDate(new Date(tweet.created_at), 'Asia/Tokyo', DATE_FORMAT);
  sheet.getRange(colNum, 1).setValue(imageUrl);
  sheet.getRange(colNum, 2).setValue(tweet.created_at);
  sheet.getRange(colNum, 3).setValue(tweet.id_str);
  sheet.getRange(colNum, 4).setValue(tweet.user.id_str);
  sheet.getRange(colNum, 5).setValue(tweet.user.name);
  sheet.getRange(colNum, 6).setValue(tweet.text);
  sheet.getRange(colNum, 7).setValue(date);
  sheet.getRange(colNum, 8).setValue(new Date());
  sheet.getRange(colNum, 9).setValue(requestType.getName());
  sheet.getRange(colNum, 10).setValue(tweet.favorite_count);
  sheet.getRange(colNum, 11).setValue(tweet.retweet_count);
  sheet.getRange(colNum, 12).setValue(tweet.user.screen_name);
  sheet.getRange(colNum, 13).setValue(TWEET_STATUS + tweet.user.screen_name + "/status/" + tweet.id_str)
}

// 画像などのContent付与されているものに限定
function hasExtendedEntities(tweet) {
  var entities = tweet.entities;
  var exEntities = tweet.extended_entities;
  if (exEntities != null) {
    return true;
  }
  return false;
}
// URLによる重複判定。URLが変わることは無いはず…
function isDuplicateUrl(imageUrl, urls, col) {
  for (var j = 0; j < col; j++) {
    if (urls[j][0] == imageUrl) {
      return true;
    }
  }
  return false;
}
// 重複判定
function isDuplicateId(newTweetId, savedTweetIds, col) {
  for (var j = 0; j < col; j++) {
    if (savedTweetIds[j][ID_COL] == newTweetId) {
      return true;
    }
  }
  return false;
}

//重複判定に使うシート
function openSpreadSheet() {
  var spreadSheet = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(NAME_IMAGE_SHEET);
  return spreadSheet;
}
//除外ユーザシート
function openIgnoreSheet() {
  var spreadSheet = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(NAME_IGNORE_SHEET);
  return spreadSheet;
}
// 除外ユーザ判定
function isIgnoreUser(userId, ignores) {
  for (var i = 0; i < ignores.length; i++) {
    if (userId === ignores[i]) {
      return true;
    }
  }
  return false;
}

// 画像を保存するドライブのフォルダを指定
function openDriveFolder(folderId) {
  var folder = DriveApp.getFolderById(folderId);
  return folder;
}

function getImageUrlExtendedEntities(exEntities) {
  var urls = [],
  image;
  var media = exEntities.media;
  for (var i = 0; i < media.length; i++) {
    urls.push(media[i].media_url_https);
  }
  return urls;
}

function getIgnoreUsers() {
  var ignoreSheet = openIgnoreSheet();
  var row = ignoreSheet.getLastRow() + 1;
  var ignores = ignoreSheet.getRange(1, 1, row).getValues();
  return ignores;
}

function getAccessToken() {
  // アクセストークンの取得
  var tokenUrl = "https://api.twitter.com/oauth2/token";
  var tokenCredential = Utilities.base64EncodeWebSafe(TWITTER_CONSUMER_KEY + ":" + TWITTER_CONSUMER_SECRET);
  var tokenOptions = {
    headers: {
      Authorization: "Basic " + tokenCredential,
      "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
    },
    method: "post",
    payload: "grant_type=client_credentials"
  };
  var responseToken = UrlFetchApp.fetch(tokenUrl, tokenOptions);
  var parsedToken = JSON.parse(responseToken);
  var token = parsedToken.access_token;
  return token;
}

```

### 実行
エントリポイントは

- getFavo ・・・ふぁぼったツイートから画像を保存。
- getFGO ・・・検索クエリ、FGOのハッシュタグから
- getAzure・・・検索クエリ、アズールレーンのハッシュタグから

などです。
ハッシュタグと保存先を対応させるように作っているので、適宜そのあたりを変えていただければ。
あとは適当なタイミングでトリガーを設定しておけば、日々粛々と画像を集めてくれます。

### 追加されている機能とか使い方

- 除外ユーザ的なものを追加しています。無くても動くかもしれませんが試してません。
  - ハッシュタグベースで検索してた時に、公式アカウントや攻略サイト系のツイートなどFav・RT高いけど弾きたいってのがあったので、ユーザIDベースで弾くようにしています。
  - 弾きたい人のIDを"ignore"シートに適宜追記してください。
- 検索条件毎に保存先変更
    - リクエストと保存先、タグで対応する"RequestType"を作ってます
    - ハッシュタグで運用していた時に、同じフォルダだと見るのが面倒だなと思って追加しました

この他、画像からユーザを知りたいってのがあったので、保存するファイル名はユーザID+ツイートIDに変更してます。

## 展望というか願望、追加したい機能
- 二次メインで探してる時は三次(コスプレなど)は弾きたい。イラストレーションとフォトグラフィーの識別ってできるのだろうか？
- ゲームのスクショは弾きたい。識別ｒｙ
- 除外ユーザ以外にも除外キーワードとかあったら便利かも？
- 自動で分類、ラベルなどしたい
  - ゆくゆくはちゃんとしたViewつくりたい
- 運用初期に保存した画像のクロールし直し
  - 最近までTweetIdをStringの方で保存していなかったので再取得が困難に…
- レジューム機能
    - ちょっと面倒だったので重複判定を狭めて対応してます…


## 注意点
- 掲載にあたり一部修正・削除しています（もしかしたら余計なとこまで消して動かないかもしれません
- GASの特性上、5分以上かかると処理の途中でも終了されます。またレジューム機能は搭載していないので、確実に収集出来るわけではありません。
  - 保存済みの画像の判定にスプレッドシートを使っている関係上、長く使っていると処理時間が増えます。
  - TweetIDによる探索をするようにすればソート出来るので高速化がねらえますが、ぼくがIDの保存をミスったため改修予定はありません。（再取得に時間がかかる上、APIの制限とかファイル名の上書きとかいろいろ面倒）
    - もしかしたらJavascriptの桁数的に無理？
- ぼくの記憶が確かなら、GoogleSpreadSheetには２００Mセルだかの上限があるのでいつか死にます。早々ないと思いますが。

## あとがき
なにはともあれ基記事を参考に去年の6月くらいからちょいちょい修正しながら動かしています。ちょうど業務でGASにふれる機会があったので勉強がてらちょうどいいかなーと。

最初は上の例みたいに気になるハッシュタグ＋Fav・RTでフィルタしてたんですが、結局主観的な好みに対応できなかったので、結局自分がふぁぼったツイートからクロールするようになりました。
ハッシュタグで概ね問題ないんですがつけない人も居ますし、タグにゆらぎがあると厄介です。FGOとかFateGOとか。
最近だとアズールレーンやドールズフロントラインは母国語だったり略称がまちまちで、特に後者は本国と名前違いますからね…
ちなみに私はUMP9が好きです。あと９A-91ちゃん。

閑話休題。
去年から可動させてますが今のところ保存した画像はログベースで25k程度です。重複判定に問題があったせいで一時期タイムアウトしてましたが、重複判定の修正とチェック範囲を限定したら問題なくなりました。
この調子ならシートの上限は当分来ないでしょう（願望）。ハッシュタグの運用だともうちょい寿命が短くなりそうですが、その前にストレージの空き容量に限界が来そうです。


### その他

ローカルにDLするWebサービスありました
https://timg.azurewebsites.net

#### 参考リンク
- 元ネタ
    - https://qiita.com/w_cota/items/a87b421ba8bc2b90a938
- GASでのTwitter APIの利用、OAuthについて　はこちら
    - https://qiita.com/Ikanogo/items/1dce33c26559eac56a03
- TwitterAPIの設定
    - https://masatoshihanai.com/php-twitter-bot-01/

---------------------