Node.js + ExpressでTwitterBot作成 #2 『実装』

## 仕様の検討

作成するBotの仕様を考えます。
サッカー関係で多数RTされているツイートを自動でRTしたいので、以下のようになりました。

:::note warn 
<strong>事前準備</strong>
- 事前にサッカー関係のインフルエンサーをBotのアカウントで複数フォローしておく
:::

:::note 
<strong>以下を定期的に実行</strong>
- タイムライン上でRT数が一定以上のツイートをRT
- 特定のキーワード（ワールドカップなど）で検索し、一定以上のRT、かつ24時間以内に投稿されたツイートをRT
- トレンドのキーワードのうち、TLのツイートに含まれるものをサッカーネタとみなし、該当キーワードで検索。24時間以内、かつ一定以上のツイートをRT
:::

この機能を満たすように実装していきたいと思います。



## フォルダ構成

現状では以下のような構成になっています。
<img src ="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200881/60b5f111-4365-8f95-fd1e-1e53b077cb7c.png" width = "210" />

MVCライクにしたいのですが、ビジネスロジックをどこに置くべきか悩みます。
「prisma」はDBスキーマやマイグレーションの配置用なので、新たに「models」フォルダを追加。
また、設定ファイルなどを置く「etc」フォルダ、ログ出力用の「logs」も作成。
以下のように使い分けたいと思います。

:::note warn 
<strong>フォルダの用途</strong>
- <strong>routes</strong> … コントローラを配置
- <strong>models</strong> … モデル（ビジネスロジック）を配置
- <strong>prisma</strong> … Prismaで使用するDBスキーマ、SQLiteDBファイルを配置
- <strong>logs</strong> … ログを配置
- <strong>etc</strong> … 定数、アプリケーション固有の設定ファイルを配置
:::


ついでにgitのリポジトリ生成とREADME.mdも追加。
フォルダ構成は以下のようになりました。

<img src ="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200881/f2873747-a05a-c3f6-3f2a-03b0408991c8.png" width = "300" />

　
## ファイル構成

実装するファイルの構成は以下のようにしたいと思います。
app.jsからtweetControllerをルーティングで呼び出し、そこからビジネスロジックを呼び出す形です。

```
- etc/
    - constants.js 　…　 定数定義用（複数Twitterアカウントで使い回せる値）
    - appConfig.js 　…　　　各Twitterアカウント固有の設定定義用
- models/
    - dbAccessor.js 　…　　　DB操作用のビジネスロジック
    - twitterAPIAccessor.js　 … 　TwitterAPI操作用のビジネスロジック
- routes/
    - tweetController.js　 … 　コントローラ
```
　
　
## スキーマの定義

prismaフォルダ内にあるschema.prismaでスキーマを定義します。
RTしたツイートを保存するテーブルを作成します。

```
//======================================================
//
// Prismaスキーマファイル
//
// [索引]
//  □ 1. モデル定義 Tweet
//
//======================================================

// クライアント生成
generator client {
  provider = "prisma-client-js"
}

// DB設定
datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

//======================================================
//
// 1. モデル定義 Tweet
//
//======================================================	

// Tweetモデル
//　　　　・ツイート1件分のデータ
//
model Tweet {

  // DB内のID。連番
  id  Int  @id @default (autoincrement())

  // Twitter内の数値形式のツイートID  例'101010101011000'
  id_str_in_twitter String

  // Twitter内のユーザ名 	例：田中
  user_name	 String 
  
  // Twitter内のユーザの文字列形式のID 	例：@test
  user_screen_name  String
  
  // ツイート本文
  tweet_text  String
  
  // RT数
  rt_count  Int

  // クライアント名（source）　　例：TweetBot
  client_name String

  // 投稿日時
  posted_date  DateTime	

  // DBデータ更新日時
  udate_date  DateTime	@updatedAt  	
}

```

以下コマンドでマイグレーション実施。SQLite用のDBが生成され、テーブルも自動作成されます。
```
prisma migrate dev --name initial
```

　

## 実装

実際の実装内容はGitHubを参照ください。
肝の部分だけ書いておきます。

https://github.com/tsubame/FootRTBot-NodeJs

　
### TwitterAPI関連
　
TwitterAPIでTLから一定以上のRTのツイートを取得する処理です。
APIv1のhomeTimeLine関数を使用し、そこから一定以上のRT数のものを取り出します。

各関数に渡すパラメータ、受け取る値は以下を参照ください。

https://github.com/PLhery/node-twitter-api-v2/tree/master/doc


```twitterAPIAccessor.js
//======================================================
//
// TwitterAPI操作用モジュール
//
// [必要ライブラリ]
//  ・twitter-api-v2
//  ・log4js
//
// [索引]
//    □ 1.   ホームタイムラインから一定のRT数以上のツイートを取得
//    □ 2.   複数件のツイートをRT
//    □ 3.   対象キーワードで検索を実行し、一定のRT以上のツイートIDのリストを返す　
//    □ 4.   トレンドのキーワードのうち、ホームタイムラインでつぶやかれているキーワード一覧を取得
//    □ 5.   対象ツイートの情報を取得して投稿日時をセット
//    □ 6.   自分がフォローしているユーザのIDを全件取得
//
//======================================================

//======================================================
// モジュール読込
//======================================================

// 定数
const _constants = require('../etc/constants');

// twitter-api-v2ライブラリ
const _twitterAPI = require('twitter-api-v2');

// ロギング用ライブラリ
const _log4js = require('log4js');

// DB操作用
const _dbAccessor = require('./dbAccessor');

//======================================================
// 変数宣言
//======================================================

// ロギング用設定
_log4js.configure(_constants.LOG4JS_CONFIG_VALS_DICT);

// ロギング用
const _logger = _log4js.getLogger();

// TwitterAPI生成
const _twAPI = new _twitterAPI.TwitterApi({
  appKey: _constants.CONSUMER_KEY,
  appSecret: _constants.CONSUMER_SECRET,
  accessToken: _constants.ACCESS_TOKEN_KEY,
  accessSecret: _constants.ACCESS_TOKEN_SECRET
});

// Twitter操作用クライアント生成
const _twClient = _twAPI.readWrite;

// 自分のツイッターID
var _myTwitterID = '';


//======================================================
//
// 1. ホームタイムラインから一定のRT数以上のツイートを取得
//
//======================================================

/**
 * ホームタイムラインから一定のRT数以上のツイートを取得
 * 　・タイムラインから200件分のツイートを取得
 * 　・自分のアカウントでRT済のものはスキップ
 * 　・RT数が一定のものをPrismaのTweetモデルデータに変換し、配列に格納して返す
 * 
 * @return {array} Tweetモデルデータの配列
 */
module.exports.getManyRTTweetsFromTimeLine = async function() {
  var manyRtTweets = [];

  try {        
    // タイムラインからツイート取得
    const homeTimeline = await _twClient.v1.homeTimeline({'count': _constants.TWEET_GET_COUNT_FROM_TL});
    console.log(homeTimeline.tweets.length, 'tweet fetched from timeline.');

    // ツイートを走査
    for (const tObj of homeTimeline.tweets) {
      // RT済はスキップ
      if (tObj.retweeted) {
        continue;
      }

      // APIのツイートデータをDB保存用のツイートデータに変換
      const tw = _dbAccessor.getTweetModelFromTweetObj(tObj);
      // RT数が一定以上なら配列に追加
      if (_constants.RETWEET_LEAST_RT < tw.rt_count) {    
        manyRtTweets.push(tw);
      }
    }
  } catch (error) {
    _logger.error(error);
  }  

  return manyRtTweets;
}
```

続けてリツイート処理。v2のretweet関数を使用。
APIでのRT時には自分のTwitterID（数値形式）が必要なので、APIv2でIDを取得してRT前にセットしています。

```twitterAPIAccessor.js
//======================================================
//
// 2. 複数件のツイートをRT
//
//======================================================

/**
 * 複数件のツイートをRT
 *  ・自分のTwitterIDをセット
 *  ・対象ツイートの件数、RT実行
 * 
 * @param {array} tws RT対象のTweetモデルデータの配列
 */
module.exports.retweetTargetTweets = async function(tws) {
  try {
    // 自分のTwitterIDをセット
    await setMyTwitterID();
    
    // 各ツイートをRT
    for (const d of tws) {      
      retweetTargetIDTweet(d.id_str_in_twitter);
      _logger.debug('[RT実行] ' + d.rt_count + 'RT ' + d.user_screen_name + ' ' + d.tweet_text);
    }
  } catch (error) {
    _logger.error(error);
  }
}

//======================================================
// 自分のTwitterIDをセット
//======================================================

/** 
 * 自分のTwitterIDをセット
 * 　・APIで自分のTwitterIDを取得し、_myTwitterIDにセット
 */
async function setMyTwitterID() {
  try {
    // 自分のTwitterIDをセット
    const mDict = await _twClient.v2.me({ expansions: ['pinned_tweet_id'] });
    _myTwitterID = mDict.data.id.toString();    
  } catch (error) {
    _logger.error(error);
  }
}

//======================================================
// 1件のツイートのRTを実行
//======================================================

/**
 * 1件のツイートのRTを実行
 * 
 * @param tidStr RTするツイートのID
 */
async function retweetTargetIDTweet(tidStr) {
  try {  
    // RT実行    
    await _twClient.v2.retweet(_myTwitterID, tidStr);    
    console.log('retweeted. ' + tidStr);
  } catch (error) {
    _logger.error(error);
  }
}
```

続けて検索処理。APIv2使用。
こちらもAPI発行時にはTwitterIDが必要です。

対象キーワードで検索して一定のRT以上のツイートを取得します。

```twitterAPIAccessor.js
//======================================================
//
// 3. 対象キーワードで検索を実行し、一定のRT以上のツイートIDのリストを返す　
//
//======================================================

/**
 * 対象キーワードで検索を実行し、一定のRT以上のツイートIDのリストを返す　
 *  ・APIで検索を実施
 *  ・検索結果の各ツイートデータを走査
 *  ・API検索結果のツイートオブジェクトをDB保存用のTweetモデルに変換
 *  ・配列に該当ツイートのIDを保存済ならスキップ（複数ユーザにRTされたツイートは複数件引っかかるため）
 *  ・アカウント名も検索の対象になるため、本文に検索キーワードが含まれていない場合はスキップ
 *  ・RT数が一定以上なら配列に追加
 * 
 * @param {string} q 検索キーワード
 * @returns {array}  Tweetモデルデータの配列
 */
 module.exports.getManyRTTweetsBySearch = async function(q) {
  var tweets = [];
  var savedTweetIds = [];

  try {
    // APIで対象キーワードの検索を実施
    const searchResObj = await getSearchResultObj(q);

    // 検索結果のツイートを走査
    for (const twObj of searchResObj._realData.data) {
      // API検索結果オブジェクトをTweetモデルに変換
      const tw = _dbAccessor.getTweetModelFromTweetSearchObj(twObj);

      // 配列に該当ツイートのIDを保存済ならスキップ
      if (savedTweetIds.indexOf(tw.id_str_in_twitter) !== -1) {
        continue;
      // ツイート本文にキーワードが含まれていなければスキップ
      } else if (tw.tweet_text.indexOf(q) === -1) {
        continue;
      }

      // RT数が一定以上なら配列に追加
      if (_constants.RETWEET_LEAST_RT <= tw.rt_count) {        
        tweets.push(tw);
        savedTweetIds.push(tw.id_str_in_twitter);
      }
    }
  } catch (error) {
    _logger.error(error);
  }

  return tweets
}

//======================================================
// APIの検索結果オブジェクトを取得
//======================================================

/**
 * APIの検索結果オブジェクトを取得
 * 　・定数の値に合わせて新着ツイート、または関連性の高いツイートを検索するかをセット
 * 　・24時間以内のツイートのみを100件取得
 * 
 * @param {string} q 検索キーワード
 * @returns {Object} API検索結果のTwitterObject
 */
async function getSearchResultObj(q) {
  const SORT_ORDER_RECENCY   = 'recency';
  const SORT_ORDER_RELEVANCY = 'relevancy';
  const SEARCH_COUNT = 100;

  try {
    // 自分のTwitterIDをセット
    await setMyTwitterID();

    // 新着ツイート、または関連性の高いツイートを検索するかをセット
    var sortOrder = SORT_ORDER_RELEVANCY;
    if (_constants.SEARCH_TWEET_BY_RECENCY) {
      sortOrder = SORT_ORDER_RECENCY;
    }
    
    // 検索対象の開始時刻（24時間前）をセット
    var startTime = new Date()
    startTime.setHours(startTime.getHours() - _constants.SKIP_PAST_HOUR);

    // 検索実行
    const searchResObj = await _twClient.v2.search(q, 
        {'tweet.fields': ['public_metrics', 'referenced_tweets', 'created_at', 'source'], 
         'start_time':  startTime.toISOString(), 
         'user.fields': ['public_metrics'], 
         'sort_order':  sortOrder, 
         'max_results': SEARCH_COUNT, 
         'expansions':  ['referenced_tweets.id']});
    
    return searchResObj;
  } catch (error) {
    _logger.error(error);
  }
}
```

トレンドからサッカージャンルのキーワードを取得する処理。APIv1を使用。
現在の日本のトレンドのワードのうち、自分のアカウントのTLでつぶやかれているものをサッカーネタとみなしてセットします。

```twitterAPIAccessor.js
//======================================================
//
// 4. トレンドのキーワードのうち、ホームタイムラインでつぶやかれているキーワード一覧を取得
//
//======================================================

/**
 * トレンドのキーワードのうち、ホームタイムラインでつぶやかれているキーワード一覧を取得
 * 　・日本のトレンドのキーワード一覧を取得
 * 　・ホームタイムラインのツイートに含まれているキーワード一覧を返す
 * 
 * @return {array} キーワードの配列
 */
 module.exports.getTrendKeywordsInHomeTimeLine = async function () {
  htTrWords = [];

  try {
    // 日本のトレンドキーワード一覧をセット 
    const allTrWords = await getJPTrendKeywords();
    // タイムラインからツイート取得
    const ht = await _twClient.v1.homeTimeline();

    // トレンドキーワードを走査
    for (trWord of allTrWords) {    
      // 該当キーワードがタイムラインのツイートに含まれれば配列に追加
      if (checkTargetKeywordExistInHomeTimeLine(ht, trWord)) {
        htTrWords.push(trWord);        
      }
    }  
  } catch (error) {
    _logger.error(error);
  }

  return htTrWords;
} 

//======================================================
// トレンドのキーワード一覧を取得
//======================================================

/**
 * トレンドのキーワード一覧を取得
 * 　・日本のトレンドのキーワードを取得
 * 
 * @return {array} キーワードの配列
 */
 async function getJPTrendKeywords() {
  trWords = [];

  try {
    const JAPAN_WOEID = 23424856;

    // トレンド取得
    const trVal = await _twClient.v1.trendsByPlace(JAPAN_WOEID);
    for (tr of trVal[0].trends) {
      trWords.push(tr.name);
    }
  } catch (error) {
    _logger.error(error);
  }

  return trWords;
}

//======================================================
// 対象キーワードがホームタイムラインのツイート内に含まれるかを返す
//======================================================

/**
 * 対象キーワードがホームタイムラインのツイート内に含まれるかを返す
 * 　・TLのツイートを走査
 * 　・フォローしているユーザがRTしたツイートはスキップ（対象ジャンル以外が含まれる可能性があるため）
 * 　・ツイート本文に該当ワードが含まれていればTrue
 * 
 * @param  {Object} ht ホームタイムラインオブジェクト
 * @param  {string} keyword 検索キーワード
 * @return {bool} 
 */
function checkTargetKeywordExistInHomeTimeLine(ht, keyword) {
  try {
    // タイムラインのツイートを走査
    for (const twObj of ht.tweets) {
      // RTされたツイートはスキップ
      if (twObj.retweeted_status) {
        continue;
      }

      // ツイート本文内に該当キーワードが含まれていれば
      if (twObj.full_text.indexOf(keyword) !== -1) {
        console.log('[トレンドが含まれるツイート]' + keyword);
        console.log(twObj.full_text);

        return true;
      }
    }
  } catch (error) {
    _logger.error(error);
  }

  return false;
}
```

### コントローラ

上記を呼び出すコントローラを実装します。

```tweetController.js
//======================================================
//
// ツイート用コントローラモジュール
//
// [必要ライブラリ]
//  ・log4js
//
// [索引]
//    □ 1-1. タイムライン上の一定数以上のツイートをRT
//    □ 1-2. トレンド内の対象ジャンルのキーワードを検索し、一定数以上のツイートをRT
//    □ 1-3. 特定のキーワードを検索し、一定数以上のツイートをRT
//
//======================================================

//======================================================
// require設定
//======================================================

// 定数
const _constants  = require('../etc/constants');

// log4js
const _log4js     = require('log4js');

// Twitter操作用モデル
const _twAccessor = require('../models/twitterAPIAccessor');

// DB操作用
const _dbAccessor = require('../models/dbAccessor');


//======================================================
// 変数宣言
//======================================================

// ロギング用設定
_log4js.configure(_constants.LOG4JS_CONFIG_VALS_DICT);

// ロギング用
const _logger = _log4js.getLogger();


//======================================================
//
// 1-1. タイムライン上の一定数以上のツイートをRT
//
//======================================================

/**
 * タイムライン上の一定数以上のツイートをRT
 *   ・タイムライン上から一定以上のRTのツイートを取得
 *   ・RT数の多いツイートのうち、RT対象のツイートをセット（DB未保存、投稿日時が一定時間以内）
 *   ・RT実行
 *   ・RTしたツイートをDB登録
 * 
 * @param {Object} req 
 * @param {Object} res 
 */
module.exports.retweetFromHomeTimeLine = async function(req, res) {
  try {
    // ホームタイムライン上から一定数以上のRTのツイートを取得
    var manyRTTweets = await _twAccessor.getManyRTTweetsFromTimeLine();
    console.log('TL内のRT数の多いツイート：' + manyRTTweets.length);

    // RT対象のツイートをセット
    const rtTargetTweets = await getRTTargetTweets(manyRTTweets);
    console.log('RT対象のツイート：' + rtTargetTweets.length);

    // RT実行
    _twAccessor.retweetTargetTweets(rtTargetTweets);
    // RTしたツイートをDB登録
    _dbAccessor.saveTweetDatas(rtTargetTweets);    
  } catch (error) {
    _logger.error(error);
  }

  // 結果を描画
  res.send("done.");  
}

//======================================================
// RT対象のツイートを返す
//======================================================

/**
 * RT対象のツイートを配列で返す
 * 　・DB未保存（未RT）
 * 　・投稿日時が一定時間以内
 * 　・NGワードを含まない
 * 
 * @param {array} tws 
 * @returns {array}
 */
async function getRTTargetTweets(tws) {
  var rtTargetTweets = [];

  try {
    // ツイートを走査
    for (tw of tws) {
      // DB保存済ならスキップ
      const isDBSaved = await _dbAccessor.isTargetTweetAlreadySaved(tw);
      if (isDBSaved) {        
        continue;
      }
      // 投稿日時が一定時間以前ならスキップ
      if (!checkPostedDateWithInTargetHours(tw)) {
        continue;
      }
      // NGワードを含めばスキップ
      if (checkTargetTweetContainsNGWord(tw)) {
        continue;
      }

      // 配列に追加
      rtTargetTweets.push(tw);
    }
  } catch (error) {
    _logger.error(error);
  }

  return rtTargetTweets;
}

/**
 * 投稿日時が一定時間以内かを返す
 * 
 * @param {Object} tw 
 * @returns 
 */
function checkPostedDateWithInTargetHours(tw) {
  try {
    // 24時間前をセット    
    var tdt = new Date();
    tdt.setHours(tdt.getHours() - _constants.SKIP_PAST_HOUR);

    // それ以降ならTrue
    if (tdt <= tw.posted_date) {
      return true
    } 
  } catch (error) {
    _logger.error(error);
  }

  return false;
}

/**
 * 対象のツイートがNGワードを含むかを返す
 *  ・本文、またはクライアント名に含めばTrue
 * 
 * @param {Object} tw 
 * @returns {Boolean}
 */
 function checkTargetTweetContainsNGWord(tw) {
  try {
    // NGワードを走査
    for (ngWord of _constants.RT_NG_KEYWORDS) { 
      if (tw.tweet_text.indexOf(ngWord) !== -1) {
        return true;
      }
      if (tw.client_name.indexOf(ngWord) !== -1) {
        return true;
      }
    }
  } catch (error) {
    _logger.error(error);
  }

  return false;
}

//======================================================
//
// 1-2. トレンド内の対象ジャンルのキーワードを検索し、一定数以上のツイートをRT
//
//======================================================

/**
 * トレンド内の対象ジャンルのキーワードを検索し、一定数以上のツイートをRT
 *   ・日本のトレンドのキーワードのうち、TLのツイート内に含まれるキーワードをピックアップ
 *   ・それぞれを検索し、RT数の多いツイートを取得
 *   ・RT数の多いツイートのうち、RT対象をピックアップ（DB未保存、投稿日時が一定時間以内）
 *   ・RTしたツイートをDB登録
 * 
 * @param {Object} req 
 * @param {Object} res 
 */
 module.exports.retweetFromTrendWord = async function(req, res) {
  try {
    // 日本のトレンドのキーワードのうち、TLのツイート内に含まれるキーワードをピックアップ
    const trWords = await _twAccessor.getTrendKeywordsInHomeTimeLine();

    // それぞれを検索し、RT数の多いツイートをRT
    for (tWord of trWords) {      
      _logger.debug('トレンド内の対象ジャンル関連キーワード' + tWord);

      // 検索結果からRTの多いツイートを取得
      var manyRTTweets = await _twAccessor.getManyRTTweetsBySearch(tWord);
      // 投稿日時をセット
      manyRTTweets = await _twAccessor.setTweetPostedDates(manyRTTweets);
      // RT対象のツイートをセット
      const rtTargetTweets = await getRTTargetTweets(manyRTTweets);
      // 0件ならスキップ
      if (rtTargetTweets.length == 0) {
        continue;
      }

      // ロギング
      _logger.debug('[トレンドキーワード ' + tWord + ' から取得したRT対象のツイート] ' + rtTargetTweets.length + '件');
      // RT実行
      _twAccessor.retweetTargetTweets(rtTargetTweets);
      // RTしたツイートをDB登録
      _dbAccessor.saveTweetDatas(rtTargetTweets);          
    }
  } catch (error) {
    _logger.error(error);
  }

  // 結果を描画
  res.send("done.");  
}


//======================================================
//
// 1-3. 特定のキーワードを検索し、一定数以上のツイートをRT
//
//======================================================

/**
 * 特定のキーワードを検索し、一定数以上のツイートをRT
 *   ・定数で設定されたキーワードを検索し、RT数の多いツイートをRT
 *  ・NGワードを含むツイートはRTしない
 *   ・RTしたツイートをDB登録
 *   ・RTしたツイートのうち、未フォローのユーザをDBに保存
 * 
 * @param {Object} req 
 * @param {Object} res 
 */
module.exports.retweetFromTargetSearchWords = async function(req, res) {
  try {
    // 対象のキーワードを走査
    for (tWord of _constants.SEARCH_TARGET_KEYWORDS) {
      // 対象のキーワードで検索し、RT数の多いツイートをセット
      var manyRTTweets = await _twAccessor.getManyRTTweetsBySearch(tWord);
      // 投稿日時をセット
      manyRTTweets = await _twAccessor.setTweetPostedDates(manyRTTweets);
      // RT対象のツイートをセット
      const rtTargetTweets = await getRTTargetTweets(manyRTTweets);    
      // 0件ならスキップ
      if (rtTargetTweets.length == 0) {
        continue;
      }

      // ロギング
      _logger.debug('[検索キーワード' + tWord + 'から取得したRT対象のツイート] ' + rtTargetTweets.length + '件');

      // RT実行
      _twAccessor.retweetTargetTweets(rtTargetTweets);
      // RTしたツイートをDB登録
      _dbAccessor.saveTweetDatas(rtTargetTweets);     
    }         
  } catch (error) {
    _logger.error(error);
  }

  // 結果を描画
  res.send("done.");  
}

```

### DB処理

DB関連のビジネスロジックを実装。
RTしたツイートのDBへの保存、TwiterAPIから取得したオブジェクトをPrismaのTweetモデルに変換する処理などを記述します。

```dbAccessor.js
//======================================================
//
// DB操作用モジュール
//
// [必要ライブラリ]
//  ・prisma
//  ・log4js
//
// [索引]
//  □ 1-1. TwitterAPIから取得したTweetObjectをDB保存用のTweetモデルに変換
//  □ 1-2. TwitterAPIから取得したTweet検索結果オブジェクトをDB保存用のTweetモデルに変換
//  □ 2.   対象ツイートがDB保存済かを返す
//  □ 3.   対象ツイートデータをDBに保存
//
//======================================================

//======================================================
// require設定
//======================================================

// 定数
const _constants = require('../etc/constants');

// log4js
const _log4js = require('log4js');

// prisma 
const _prisma = require('@prisma/client');

//======================================================
// 変数宣言
//======================================================

// ロギング用設定
_log4js.configure(_constants.LOG4JS_CONFIG_VALS_DICT);

// ロギング用
const _logger = _log4js.getLogger();

// PrismaClient
const _prismaClient = new _prisma.PrismaClient();

//======================================================
//
// 1-1. TwitterAPIから取得したTweetObjectをDB保存用のTweetモデルに変換
//
//======================================================

/**
 * APIから取得したTweetObjectをDB保存用のTweetモデルに変換
 * 　・RTの場合、RT数以外はretweeted_statusの値で各種値を上書き
 * 
 * @param {Object} twObj
 * @return {Object} 
 */
module.exports.getTweetModelFromTweetObj = function(twObj) {
  var d = {}

  try {
    // ツイートをセット
    var st = twObj;
    // RTの場合、retweeted_statusをセット
    if (twObj.retweeted_status) {
      st = twObj.retweeted_status;
    }

    d = {
      'id_str_in_twitter': st.id_str,
      'user_name':         st.user.name,
      'user_screen_name':  st.user.screen_name,
      'tweet_text':        st.full_text,
      'rt_count':          twObj.retweet_count,      
      'client_name':       st.source,
      'posted_date':       new Date(st.created_at)
    }
  } catch (error) {
    _logger.error(error);
  }    

  return d;
}

//======================================================
//
// 1-2. TwitterAPIから取得したTweet検索結果オブジェクトをDB保存用のTweetモデルに変換
//
//======================================================

/**
 * APIのTweet検索結果オブジェクトをDB保存用のTweetモデルに変換
 * 　・RTの場合、IDをRT対象に書き換える
 * 
 * @param {Object} sObj
 * @return {Object} 
 */
 module.exports.getTweetModelFromTweetSearchObj = function(sObj) {
  var d = {}

  try {
    d = {
      'id_str_in_twitter':  sObj.id,
      'user_name':          '',
      'user_screen_name':   '',
      'tweet_text':         sObj.text,
      'client_name':        sObj.source,      
      'rt_count':           sObj.public_metrics.retweet_count,
      'posted_date':        new Date(sObj.created_at)
    }

    // RTはID書き換え
    if (sObj['referenced_tweets']) {
      d.id_str_in_twitter = sObj['referenced_tweets'][0]['id'];
    }    
  } catch (error) {
    _logger.error(error);
  }    

  return d;
}

//======================================================
//
// 2. 対象ツイートがDB保存済かを返す
//
//======================================================

/**
 * 対象ツイートがDB保存済かを返す
 * 
 * @param {Object} tw 
 * @return {array}
 */
 module.exports.isTargetTweetAlreadySaved = async function(tw) { 
  try {
    // DBのデータを取得
    const res = await _prismaClient.tweet.findFirst({
      where: {
        id_str_in_twitter: tw.id_str_in_twitter,
      },
    });    
    
    // 結果があればtrue;
    if (res) {
      return true;
    }
  } catch (error) {
    _logger.error(error);
  }    

  return false;
}

//======================================================
//
// 3. 対象ツイートデータをDBに保存
//
//======================================================

/**
 * 対象ツイートデータをDBに保存
 * 
 * @param {array} tModels 
 */
 module.exports.saveTweetDatas = async function(tws) {
  try {
    for (d of tws) {
      await _prismaClient.tweet.create({
        data: {
          'id_str_in_twitter':     d.id_str_in_twitter,
          'user_name':             d.user_name,
          'user_screen_name':      d.user_screen_name,
          'tweet_text':            d.tweet_text,
          'rt_count':              d.rt_count,      
          'client_name':           d.client_name,               
          'posted_date':           d.posted_date,          
        }
      })
    }
  } catch (error) {
    _logger.error(error);
  }    
}
```

定数の定義です。
アカウント固有の設定やAPIのキーは別ファイルappConfig.jsに記述しています。

```constants.js
//======================================================
//
// 定数定義用モジュール
//　・各Twitterアカウントで使い回せる定数を定義
//　・各アカウント固有の設定はapp_configに定義したものを読み込む
//
//======================================================

//======================================================
// require設定
//======================================================

// app_configからアカウント独自の定数を読込
var _app_config = require('./appConfig');

//======================================================
// リツイート設定
//======================================================

// この時間(h)より以前のツイートは無視
module.exports.SKIP_PAST_HOUR = 24;

// TLから1度に取得するツイートの数
module.exports.TWEET_GET_COUNT_FROM_TL = 200;

// この数以上のRT数でリツイート
module.exports.RETWEET_LEAST_RT = 100; 

// 検索時、最新のツイートを検索するか　false = 関連性の高いツイートを検索
module.exports.SEARCH_TWEET_BY_RECENCY = false;

//======================================================
// 検索キーワード
//======================================================

// 検索キーワード　このキーワードで検索し、RT数の多いツイートをRT
module.exports.SEARCH_TARGET_KEYWORDS = _app_config.SEARCH_TARGET_KEYWORDS;

// RT時のNGワード　このキーワードを含むツイートはRTしない
module.exports.RT_NG_KEYWORDS = _app_config.RT_NG_KEYWORDS;

//======================================================
// ツイッターアカウント
//======================================================

// アカウント名
module.exports.SCREEN_NAME = _app_config.TWITTER_SCREEN_NAME;

// アカウントID
module.exports.ACCOUNT_ID = _app_config.TWITTER_ACCOUNT_ID;

// Consumer Key
module.exports.CONSUMER_KEY = _app_config.CONSUMER_KEY;

// Consumer Secret
module.exports.CONSUMER_SECRET = _app_config.CONSUMER_SECRET;

// Access Token
module.exports.ACCESS_TOKEN_KEY = _app_config.ACCESS_TOKEN_KEY;

// Access Token Secret
module.exports.ACCESS_TOKEN_SECRET = _app_config.ACCESS_TOKEN_SECRET;

//======================================================
// ロギング用
//======================================================

// log4jsのオプション
module.exports.LOG4JS_CONFIG_VALS_DICT = {
  appenders: {
    console: {
      type: 'console'
    },
    system: {
      type: 'file',
        'filename': './logs/log.txt',
        'maxLogSize': 104857600,
        'layout': {
          'type': 'pattern',
          'pattern': '%d [%p] %m'}      
    } 
  },
  categories: { 
    default: { 
      appenders: [
        'console',
        'system'
      ], 
      level: 'debug' } 
  }    
};


```

app.jsにはコントローラの3つの関数をルーティングに追加。

```app.js
//======================================================
//
// 1. ルーティング設定
//
//======================================================

//　タイムラインからのRT処理
app.get('/timeline', _twController.retweetFromHomeTimeLine);

//　トレンドからのRT処理
app.get('/trend', _twController.retweetFromTrendWord);

//　検索からのRT処理
app.get('/search', _twController.retweetFromTargetSearchWords);

```

　

### インフルエンサーをフォロー

最後にbotのアカウントでサッカー関係のインフルエンサーを数十人フォローしておきます。
以下の目的のために使用します。

- フォローしているユーザのツイートで多数RTされているものをRTする
- トレンドのキーワードのうち、フォローしているユーザがつぶやいているものをサッカーネタとみなす
　

## 定期的に実行

あとはタスクスケジューラやcronで定期的に上記3つのURLにアクセス。
curlを使うのが簡単ですね。

```
curl http://localhost:3000/timeline
```

　

## 他ジャンルへの対応

他ジャンル（野球、プログラミングなど）のツイートをRTするbotを作りたい場合、
アカウントをもう1つ作って該当ジャンルのインフルエンサーを何十人かフォローし、appConfigの検索キーワードを変えるだけで対応可能です。

割と汎用性が高いものが出来たかなと思います。