IFTTT のタイムアウト対策(非同期処理)

 

もくじ
  1. IFTTT を使った Blogger と twitter 連携
  2. 画像つきツイートを投稿したい!
  3. Google Apps Script と Phantomjs Cloud の活用
  4. 画像ファイルのアップロード
  5. IFTTT のタイムアウト対策(非同期処理)

前回までのあらすじ

IFTTT が twitter をポーリングして tweet の URL を拾い、tweet の URL を Phantomjs Cloud(ヘッドレスブラウザ Phantomjs の Web 版)で展開して拾った画像ファイルの URL を Misskey ドライブにアップロードして「画像つきノート」を投稿することが可能になりました。

ところが、ここで厄介な問題が新たに持ち上がりました。IFTTT には「12 秒ルール」があり、Webhook を投げてから 12 秒以内にレスポンスが無いとエラー扱いになってしまいます。

何しろ Webhook を投げてから、これだけの処理を行っているのですが……
具体的にはこんなことをやっています。
  1. POST されたデータの展開(これは一瞬)
  2. 短縮 URL の復元(UrlFetchApp.fetch を実行)
  3. Phantomjs Cloud を利用した画像 URL の取得(UrlFetchApp.fetch を実行)
  4. 画像の取得(UrlFetchApp.fetch を実行)
  5. 取得した画像ファイル(の blob)を Misskey ドライブにアップロード(UrlFetchApp.fetch を実行)
  6. Misskey.io でノートを投稿(UrlFetchApp.fetch を実行)

これらの操作を 12 秒以内に完結させないといけないわけです。普通に curl を 5 回実行するだけでも数秒はかかりますし、Phantomjs は JavaScript を展開している(=裏で相当な数の http リクエストが発生している筈)ので、「12 秒以内」というのはかなりキツい話です。

仮に IFTTT でエラー扱いになったとしても、実際には全ての操作が行われているので問題ない……ようにも思えたのですが、エラー率が上がると IFTTT が「エラー率高いから実行をやめとくね」とサボり始めてしまいます(!)。これはかなり致命的なのでなんとかしないといけなくなったのですが……。

GAS の応答を非同期処理で高速化する

ありがたいことに、今回も似たような悩みを解決してノウハウを公開してくださった記事が見つかりました。

Webhookで起動したGAS(Google AppsScript)の応答を非同期処理で高速化する

つまり、これが……
  1. IFTTT: Webhook を投げる
  2. GAS: POST されたデータの展開
  3. GAS: 短縮 URL の復元
  4. GAS: Phantomjs Cloud を利用した画像 URL の取得
  5. GAS: 画像の取得
  6. GAS: 取得した画像ファイル(の blob)を Misskey ドライブにアップロード
  7. GAS: Misskey.io でノートを投稿
  8. GAS: 「終わったよ」と IFTTT に返す

こうなります
  1. IFTTT: Webhook を投げる
  2. GAS: POST されたデータを cache にコピる
  3. GAS: 「終わったよ」と IFTTT に返す

その後、どこかのタイミングで
  1. GAS: cache に溜まったデータを展開
  2. GAS: 短縮 URL の復元
  3. GAS: Phantomjs Cloud を利用した画像 URL の取得
  4. GAS: 画像の取得
  5. GAS: 取得した画像ファイル(の blob)を Misskey ドライブにアップロード
  6. GAS: Misskey.io でノートを投稿
(たっぷり時間をかけて)これらの処理を行えば良い、ということになりますね。GAS には「定期実行」の仕組みがあるので、「cache に溜まったデータを展開」以下の処理を定期実行することになります。

なお https://qiita.com/zensai3805/items/1dc8e6f1be0499add5a6 で公開されているサンプルは POST データを文字列に変換する際に ";" で連結するようになっているのですが、";" は html における文字実体参照で頻出するため、そのままでは誤動作を起こします。

ということで色々と試してみたのですが、今は "¶"(文字実体参照では ¶)を区切り文字にすることで安定動作しています。

最終的には 4 つのスクリプトを配置しました(いくつかはテスト用)。

main.gs

Webhook は doPost 関数をキックします。12 秒ルールがあるので極限まで短くしているのがミソ。

function doPost(e) {
  const jsonString = e.postData.getDataAsString();
  const data = JSON.parse(jsonString);

  const mode = "post";
  Logger.log("add queue……");
  data.text = data.text.replace(/¶/g, "¶");
  Logger.log(data.text);
  const res_main = addJobQueue_(data, mode);
  return res_main;
}

function main_(data,mode) {
  let TweetText = data.text;
  const LinkToTweet = data.LinkToTweet;

  // ■ local.gs や test.gs からの動作テスト用のロジック。本来は不要
  TweetText = TweetText.replace(/<<</,"");
  TweetText = TweetText.replace(/>>>/,"");

  // ■ Misskey のトークンをチェック
  const iftttSec = data.i;
  if (MISSKEY_TOKEN !== iftttSec) {
    return JSON.stringify({});
  }

  // ■ RT を除外
  if(TweetText.match(/^RT |^@/)) {
    return JSON.stringify({});
  }

  // ■ html 実体参照を復元
  TweetText = TweetText.replace(/¥&lt;/g,"<");
  TweetText = TweetText.replace(/¥&gt;/g,">");
  TweetText = TweetText.replace(/¥&apos;/g,"¥'");
  TweetText = TweetText.replace(/¥&para;/g,"¶");
  TweetText = TweetText.replace(/¥&amp;/g,"&");

  // ■ 短縮 URL を復元
  let shortUrl;
  while(shortUrl = TweetText.match(/https:¥/¥/t.co¥/¥w{1,}/)) {
    let shortUrlRegex = new RegExp(shortUrl);
    let expandedUrl = expandURL_(shortUrl);
    if( expandedUrl.match(/^https:¥/¥/[A-Za-z0-9_¥.]{1,}$/) ) {
      expandedUrl = expandedUrl.replace(/^https:¥/¥//,"");
    }
    TweetText = TweetText.replace(shortUrlRegex,expandedUrl);
  }

  // ■ LinkToTweet をスクレイピング
  const fileIds = new Array();
  if(TweetText.match(/https:¥/¥/(twitter|x).com¥/¥w{1,}¥/status¥/[0-9]{1,}¥/photo¥/[0-9]{1}/)) {
    console.log('scraping……');
    var response = scrape_(LinkToTweet);
    const json_response = JSON.parse(response);
    let source = json_response["content"]["data"].toString();
    const imageUrls = source.match(/https:¥/¥/pbs.twimg.com¥/media¥/[A-Za-z0-9_¥-]{1,}¥?format=(jpg|png)¥&amp¥;name=[A-Za-z0-9]{1,}/g);
    let lastUrl;

    if (imageUrls != null && 0 < imageUrls.length) {
      for (let i = 0; i < imageUrls.length && i < 8; ++i) {
        if( lastUrl != imageUrls[i] ) {
          let imageUrl = imageUrls[i];
          lastUrl = imageUrl;
          imageUrl = imageUrl.replace(/¥&amp;/g,"¥&");
          Logger.log(imageUrl);
          var response = uploadFile_(imageUrl);
          let responseJSON = JSON.parse(response.getContentText());
          let fileId = responseJSON.id;
          fileIds.push(fileId);
          console.log(fileId);
        }
      }
    }
    Logger.log(fileIds);
    //TweetText = TweetText.replace(/https:¥/¥/t.co¥/[A-Za-z0-9_¥-]{1,}/,"");
    TweetText = TweetText.replace(/https:¥/¥/(twitter|x).com¥/¥w{1,}¥/status¥/[0-9]{1,}¥/photo¥/[0-9]{1}/,"");
  } else {
    //Utilities.sleep (8000);
  }

  // ■ http://www.bojan を https://www.bojan に修正
  TweetText = TweetText.replace("http://www.bojan","https://www.bojan");

  Logger.log(TweetText);
  //responseChecker(TweetText);
  
  // ■ Misskey に投稿
  if( mode == "post" ) {
    console.log("post");
    const res = postToMisskey_(TweetText,fileIds);
    return JSON.stringify(res);
  } else {
    console.log("Do not post");
    //const res = postToMisskey_(TweetText,fileIds);
    //return JSON.stringify(res);
  }
}

//
// ■ Misskey に投稿
// https://rmc-8.com/twitter-to-misskey
//
function postToMisskey_(noteText,notefileIds) {
  const ENDPOINT = `https://misskey.io/api/notes/create`;
  let options;
  if( notefileIds.length < 1 ) {
    options = {
      method: "post",
      contentType: "application/json",
      muteHttpExceptions: false,
      payload: JSON.stringify({
        "localOnly": false,
        "noExtractMentions": false,
        "noExtractHashtags": false,
        "noExtractEmojis": false,
        "text": noteText,
        "i": MISSKEY_TOKEN
      })
    };
  } else if( noteText.length < 1 ) {
    console.log(notefileIds);
    options = {
      method: "post",
      contentType: "application/json",
      muteHttpExceptions: false,
      payload: JSON.stringify({
        "localOnly": false,
        "noExtractMentions": false,
        "noExtractHashtags": false,
        "noExtractEmojis": false,
        "fileIds": notefileIds,
        "i": MISSKEY_TOKEN
      })
    };
  } else {
    console.log(notefileIds);
    options = {
      method: "post",
      contentType: "application/json",
      muteHttpExceptions: false,
      payload: JSON.stringify({
        "localOnly": false,
        "noExtractMentions": false,
        "noExtractHashtags": false,
        "noExtractEmojis": false,
        "text": noteText,
        "fileIds": notefileIds,
        "i": MISSKEY_TOKEN
      })
    };
  }

  let response = UrlFetchApp.fetch(ENDPOINT, options);
  return response.getContentText();
}

//
// ■ URL を Misskey Drive にアップロード
//
// Original by Amit Agarwal www.labnol.org
// https://www.labnol.org/code/20096-upload-files-multipart-post
//
function uploadFile_(imageUrl) {
  const boundary = "labnol";
  //var blob = DriveApp.getFileById(GOOGLE_DRIVE_FILE_ID).getBlob();
  let blob = UrlFetchApp.fetch(imageUrl).getBlob();

  var requestBody = Utilities.newBlob(
    "--"+boundary+"¥r¥n"
    + "Content-Disposition: form-data; name=¥"i¥"¥r¥n"
    + "Content-Type: text/plain¥r¥n¥r¥n"
    + MISSKEY_TOKEN + "¥r¥n"
    + "--"+boundary+"¥r¥n"
    + "Content-Disposition: form-data; name=¥"file¥"; filename=¥""+blob.getName()+"¥"¥r¥n"
    + "Content-Type: " + blob.getContentType()+"¥r¥n¥r¥n").getBytes()
  .concat(blob.getBytes())
  .concat(Utilities.newBlob("¥r¥n--"+boundary+"--¥r¥n").getBytes());
  
  var options = {
    method: "post",
    contentType: "multipart/form-data; boundary="+boundary,
    payload: requestBody,
    //muteHttpExceptions: true,
  };

  var request = UrlFetchApp.fetch("https://misskey.io/api/drive/files/create", options);

  //Logger.log(request.getContentText());
  return request;
}

//
// ■ 短縮 URL を展開
// https://aqpolo.hateblo.jp/entry/2018/08/01/003000
//
function expandURL_(url) {
  var options = {
    "method": "GET",
    "followRedirects": false,
    'muteHttpExceptions': false
  };
  var redirect_url = UrlFetchApp.fetch(url,options).getAllHeaders()[ 'Location' ];
  if( redirect_url.match(/^https:¥/¥/[^¥/]{1,}¥/$/) ) {
    redirect_url = redirect_url.replace("/¥/$/","")
  }
  Logger.log(redirect_url);
  return redirect_url
}

//
// ■ PhantomJSCloud を使用したスクレイピング
// https://qiita.com/i_tatte/items/bff4c0574740c9cc4a33
//
function scrape_(TwitterURL) {
  var key = '**-*****-*****-*****-*****-*****';
  var PJSURL = 'https://phantomjscloud.com/api/browser/v2/'+ key +'/?request=%7Burl:%22' + TwitterURL + '%22,renderType:%27HTML%27,outputAsJson:true%7D';
  return UrlFetchApp.fetch(PJSURL).getContentText();
}

//
// ■ 非同期処理(ジョブキューの追加)
// https://qiita.com/zensai3805/items/1dc8e6f1be0499add5a6
//
function addJobQueue_(a, b){
  //引数をオブジェクトとしてまとめる
  var newQueue = {
    "a": a,
    "b": b
  }
  console.log( newQueue.a );

  cache = CacheService.getScriptCache();
  var cachedata = cache.get("key");
  
  //cacheの中身がnullならば空配列に,nullでないならstrを配列に変換する.
  if( cachedata == null ){
    cachedata = [];
  } else {
    cachedata = cachedata.split('¶');
  }
  
  //オブジェクトであるnewDataをstrに変換して配列に追加.
  cachedata.push(JSON.stringify(newQueue));
  
  //配列を¶で分割するstrに変換.
  cache.put("key", cachedata.join('¶'), 60*2);

  return;
}

local.gs

Webhook でミスった時のリカバリー用。let data = 行には IFTTT の Activity から body の文字列("i": "……", "text": "<<<……>>>", "LinkToTweet": "……")をコピペし、local.gs を手動実行することで Misskey.io にノートが投稿されます。
定数 MISSKEY_TOKEN(Misskey.io のアプリパスワード)もここで定義します。

function localPost() {
  //const jsonString = e.postData.getDataAsString();
  //const data = JSON.parse(jsonString);
  let data = {
    "i": "********************************", "text": "<<<ここにテストデータを入力する>>>", "LinkToTweet": "https://twitter.com/**********/status/*******************"
    };
  const mode = "post";
  Logger.log("add queue……");
  data.text = data.text.replace(/¶/g, "&para;");
  Logger.log(data.text);
  const res_main = addJobQueue_(data, mode);
  return res_main;
}

const MISSKEY_TOKEN = "********************************";
const SECRET = "************"; // IFTTTでも同じ文字列を設定してパスワード代わりに使います

test.gs

local.gs と同じく手動実行で動作テストを行いますが、Misskey.io への投稿は行いません(デバッグや開発用)。

あと任意の内容を Google スプレッドシートに書き出す responseChecker 関数を定義しています(イベントログ代わりに使用する)。

function doTest() {
  //const jsonString = e.postData.getDataAsString();
  //const data = JSON.parse(jsonString);
  let data = {
    // こんな感じで特殊文字をテストデータに含めて、動作結果を Google スプレッドシートに書き出して動作確認を行う
    "i": "********************************", "text": "<<<すいませんでした(エラーになった)。今度こそ大丈夫だと思いたい®>>>", "LinkToTweet": "https://twitter.com/**********/status/*******************"
  };
  const mode = "no_post";
  Logger.log("add queue……");
  data.text = data.text.replace(/¶/g, "&para;");
  Logger.log(data.text);
  const res_main = addJobQueue_(data, mode);
  return res_main;
}

//
// ■ GAS ログ
// 引数を Google スプレッドシートに書き込むルーチン。イベントログの代わり
//
function responseChecker (responseString) {
  var date = new Date();
  date = Utilities.formatDate( date, 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss');
  var value = new Array();
  value[0] = date;
  value[1] = responseString;

  const spreadsheet = SpreadsheetApp.openById('********************************************');
  const sheet = spreadsheet.getSheetByName('*******');
  //sheet.getRange(cellName).setValue(JSON.stringify(responseString))
  sheet.appendRow(value);
}

timeDriven.gs

時間ベースで定期実行します。1 分間隔だと短すぎるけど 5 分間隔はちと長過ぎるのが悩み。

//
// ■ 非同期処理(ジョブキューの取得)
// https://qiita.com/zensai3805/items/1dc8e6f1be0499add5a6
//
function timeDriven() {
  //cacheを取得
  cache = CacheService.getScriptCache();
  var cachedata = cache.get("key");

  if( cachedata == null ){
    return;
  } else if( cachedata.length < 1 ) {
    return
  } else {
    //responseChecker("Step 1");
    //responseChecker(cachedata);
  }

  //cacheの読み書きの競合が怖いのでなるべく早く消しておく
  cache.remove("key");
  
  //cacheの中身がnullならば空配列に,nullでないならstrを配列に変換.
  if( cachedata == null ){
    return;
  } else {
    //responseChecker("Step 2");
    cachedata = cachedata.split('¶');
  }

  //配列の中身をstrからJSON(object)に戻し,処理を実行する
  for(var i=0; i<cachedata.length; i++){
    cachedata[i] = JSON.parse(cachedata[i]);
    const res_timer = main_(cachedata[i].a, cachedata[i].b);
    //responseChecker(cachedata[i].a);
  }
  //responseChecker("Step 3");

  Utilities.sleep(5000);
  return;
}

どれも公開されているサンプルスクリプトをベースに手を入れたものです。権利面での問題がありましたらお手数ですが https://www.bojan.net/p/about.html 記載の連絡先までご一報をお願いします。

前の記事続きを読む

www.bojan.net
Copyright © 1995- Bojan International

0 件のコメント:

新着記事