Google Apps Scriptを使って最新Kindle OSでも引用をtweetできるようにした

# tl;dr

- [最新のKindleデバイスでは共有先からtwitterが排除された](https://www.amazonforum.com/s/question/0D54P00007GOClK/why-i-cant-link-my-twitter-account-on-my-kindle-paperwhite-anymore)
- 残っているのはメールによる共有のみ
- 不便なのでKindleから共有メールを受けたら自動でtweetするGoogle Apps Scriptを書いた
- 気が向いたらWeb Application化しますが、誰かがやってくれてもOKです(自分の書いたコードはPublic Domainです)

# 免責

github化とか自動テストとかもっと解説入れるとかいくらでも改善点はあるのですが出社まで3時間を切った朝5時に書いているのでとりあえずコードだけ載せておきます。
適当にググってGASやtwitter API有効化、定期実行トリガーの設定、gmailで`tweet済み`ラベルの追加などやりつつペッと貼ったら動きます。

# コード

```javascript:main.gs
// Twitter APIの認証とレスポンス取得

const mock_body_regular = String.raw`今私が読んでいる本の一節を紹介します。

「このフレーミングの失敗は、外科医がチームづくりに──チームというのは放っておくと年功序列をもとに構造ができてしまう──いっさい関与しなかったために拍車がかかってしまった。」（『チームが機能するとはどういうことか ― 「学習力」と「実行力」を高める実践アプローチ』（エイミー・Ｃ・エドモンドソン, 野津智子 著）より）

この本を無料で読む: https://a.co/3sdW0Ks

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

無料のKindle for Android, iOS, PC, Macをダウンロードすればいつでもどこでも読書できます
http://amzn.to/1r0LubW `;

const mock_body_too_long_title = String.raw`今私が読んでいる本の一節を紹介します。

「Aこのフレーミングの失敗は、外科医がチームづくりに──チームというのは放っておくと年功序列をもとに構造ができてしまう──いっさい関与しなかったために拍車がかかってしまった。」（『over 140 characters titleeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee』（エイミー・Ｃ・エドモンドソン, 野津智子 著）より）

この本を無料で読む: https://a.co/3sdW0Ks

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

無料のKindle for Android, iOS, PC, Macをダウンロードすればいつでもどこでも読書できます
http://amzn.to/1r0LubW `;

const mock_body_too_long_authors = String.raw`今私が読んでいる本の一節を紹介します。

「Bこのフレーミングの失敗は、外科医がチームづくりに──チームというのは放っておくと年功序列をもとに構造ができてしまう──いっさい関与しなかったために拍車がかかってしまった。」（『チームが機能するとはどういうことか ― 「学習力」と「実行力」を高める実践アプローチ』（over 140 characters authorssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss）より）

この本を無料で読む: https://a.co/3sdW0Ks

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

無料のKindle for Android, iOS, PC, Macをダウンロードすればいつでもどこでも読書できます
http://amzn.to/1r0LubW `;

const mock_body_too_long_quote = String.raw`今私が読んでいる本の一節を紹介します。

「over 140 characters quoteeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee」（『チームが機能するとはどういうことか ― 「学習力」と「実行力」を高める実践アプローチ』（エイミー・Ｃ・エドモンドソン, 野津智子 著）より）

この本を無料で読む: https://a.co/3sdW0Ks

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

無料のKindle for Android, iOS, PC, Macをダウンロードすればいつでもどこでも読書できます
http://amzn.to/1r0LubW `;


// 返り値はquote, title, authors, urlプロパティを持つ
function parseBody(body) {
  // https://stackoverflow.com/a/43391072
  const body_regex_str = String.raw`^今私が読んでいる本の一節を紹介します。

「(?<quote>.+)」（『(?<title>.+)』（(?<authors>.+)）より）

この本を無料で読む: (?<url>\S+)

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

無料のKindle for Android, iOS, PC, Macをダウンロードすればいつでもどこでも読書できます
http://amzn.to/1r0LubW $`;
  const body_regex = RegExp(body_regex_str, 'sg');

  const match = body_regex.exec(body);
  if (match == null) {
    throw new Error(
      'メール本文のマッチングに失敗しました。\n' +
      '\n正規表現:\n' + body_regex +
      '\n来た文字列:\n' + body);
  }

  return match.groups;
}

function getLatestKindleQuoteThreads() {
  return GmailApp.search('from:(no-reply@amazon.com) subject:(おすすめの一節) -label:tweet済み', 0, 5);
}

// TODO
// 引用した本文に '> ' をつけたいが、改行がある場合の処理が手間で後回しにしている
function main() {
  // const body = getLatestKindleQuoteThreads().getMessages()[0].getBody();
  // const body = mock_body_regular;
  // const body = mock_body_too_long_title;
  // const body = mock_body_too_long_authors;
  // const body = mock_body_too_long_quote;

  const label = GmailApp.getUserLabelByName('tweet済み');
  getLatestKindleQuoteThreads().forEach((thread) => {
    const body = thread.getMessages()[0].getBody();
    const quote_info = parseBody(body);
    new QuoteTwitterClient(quote_info);
    thread.addLabel(label);
  });


}
```

```javascript:twitter.gs
// http://pineplanter.moo.jp/non-it-salaryman/2021/07/17/gas-twitter/ からコピペしたもの(たぶんさらに英語の元ネタがある)
// 認証情報をPropertiesへ移動したりとちょっと改変している。

function doGet() {
  return HtmlService.createHtmlOutput(ScriptApp.getService().getUrl());
}

// 認証リセット関数。デバッグ時の初期化用。
function reset() {
  var service = getTwitterService();
  service.reset();
}

// サービス設定
function getTwitterService() {
  var properties = PropertiesService.getScriptProperties();
  return OAuth1.createService('Twitter')
    .setConsumerKey(properties.getProperty('CONSUMER_KEY')) // コンシューマーキー＆シークレット
    .setConsumerSecret(properties.getProperty('CONSUMER_SECRET')) // コンシューマーシークレット
    .setAccessToken(properties.getProperty('TOKEN'), properties.getProperty('TOKEN_SECRET')) // アクセストークンキー＆シークレット

    // oAuthエンドポイントURL
    .setAccessTokenUrl('https://api.twitter.com/oauth/access_token')
    .setRequestTokenUrl('https://api.twitter.com/oauth/request_token')
    .setAuthorizationUrl('https://api.twitter.com/oauth/authorize')

    .setCallbackFunction('authCallback') // コールバック関数名 
}

// OAuthコールバック
function authCallback(request) {
  var service = getTwitterService();
  var authorized = service.handleCallback(request);
  if (authorized) {
    return HtmlService.createHtmlOutput('成功しました');
  } else {
    return HtmlService.createHtmlOutput('失敗しました');
  }
}

function getTwitterServiceWithCheck() {
  var service = getTwitterService();

  if (service.hasAccess()) {
    return service;
  } else {
    throw new Error('URLを確認してください: %s', service.authorize());
  }
}
```


```javascript:quote_twitter_client.gs

/**
 * リプライ周りのコードはここを参考にした https://stackoverflow.com/questions/54729379/twitter-api-reply-to-tweet
 */
class QuoteTwitterClient {

  constructor(quote_info) {
    // ここから
    this.service = getTwitterServiceWithCheck();
    Object.getPrototypeOf(this.service).tweet = function (status, in_reply_to_status_id) {
      // https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-update
      const url = 'https://api.twitter.com/1.1/statuses/update.json';
      const payload = {
        status: status,
        in_reply_to_status_id: in_reply_to_status_id,
      };
      return this.fetch(url, {
        method: 'post',
        payload: payload,
      });
    };

    Object.getPrototypeOf(this.service).getScreenName = function () {
      // https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/api-reference/get-account-settings
      const url = 'https://api.twitter.com/1.1/account/settings.json';
      const response = this.fetch(url, {
        method: 'get',
      });
      return JSON.parse(response.getContentText()).screen_name;
    }

    this.screen_name = this.service.getScreenName();

    const response = this.tweetQuote(quote_info);
    Utilities.sleep(1000); // = 1秒。書き方が微妙だが(本当はリクエストの間にだけ1秒待ちたい)面倒なので一旦これで
    this.tweetBookInfo(quote_info, response);
  }
  // ここまでのserviceにメソッドをinjectionしているところは激しくここにあるべきではない気がするが、どうするべきか思いつかなかった

  tweetQuote(quote_info) {
    const title = `\n\n『 ${quote_info.title.substring(0, 40)}』`; // タイトルが長過ぎると鬱陶しいので40文字に制限
    const rest_length = 140 - (title.length + this.screen_name.length);
    // ↑ 厳密には最初のtweetだけはreplyではないのでscreen_name長を削る必要はないが、処理が複雑になって面倒なのでそこには目をつぶっている

    // https://stackoverflow.com/a/7033662
    // https://stackoverflow.com/a/43391072
    // ↓ 端的に言うと 文字列リテラルなら正規表現内にも変数を展開できる。便利。
    const splitted_quotes = quote_info.quote.match(RegExp(String.raw`.{1,${rest_length}}`, 'g'));

    // ここはいかにも関数型フリークっぽい書き方だが、現状自分しか参照しないコードなのでヨシ
    return splitted_quotes.reduce((previous_response, quote) => {
      Utilities.sleep(1000); // = 1秒。書き方が微妙だが(本当はリクエストの間にだけ1秒待ちたい)面倒なので一旦これで
      if (previous_response == null) {
        return this.service.tweet(quote + title);
      } else {
        return this.service.tweet(quote + title, this.extract_tweet_id(previous_response));
      }
    }, null);
  }

  extract_tweet_id(replying_tweet_response) {
    return JSON.parse(replying_tweet_response.getContentText()).id_str;
  }

  tweetBookInfo(quote_info, replying_tweet_response) {
    // https://qiita.com/tentatsu/items/8ec2766361e70db2429a
    const replying_tweet_id_str = this.extract_tweet_id(replying_tweet_response);

    const reply = `@${this.screen_name} `;
    const url = `\n${quote_info.url}`;
    const rest_length = 140 - (reply.length + url.length);
    const title_and_authors = `『${quote_info.title}』\n（${quote_info.authors}）`.substring(0, rest_length);

    this.service.tweet(reply + title_and_authors + url, replying_tweet_id_str);
  }
}
```