くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

Node.jsで画像/動画つきツイートをTwitterに投稿すると大変だった...

JavaScriotでツイートしたいなと思って、いろいろ試していたら、
30秒以上動画つきツイートが結構めんどくさかったので、その時の備忘録。

Node.jsでTwitter APIを使う

Node.jsでTwitter APIを使うときは、desmondmorris/node-twitterを使うのが良さそう

インストール

$ npm install twitter

ツイートしてみる

文字だけをツイートするのは、こんな感じ。

import Twitter from "twitter";

// 初期化
const client = new Twitter({
  consumer_key: TWITTER_CONSUMER_KEY,
  consumer_secret: TWITTER_CONSUMER_SECLET,
  access_token_key: ACCESS_TOKEN_KEY,
  access_token_secret: ACCESS_TOKEN_SECRET
});

// 文字だけをツイート
async function tweet(text: string) {
  const tweet = await client.post("statuses/update", { status: text });
}

tweet("ツイート").then();

Twitterクラスに.post()や、.get()が用意されているので、
Twitter APIのドキュメントを見ながら、呼び出していく感じ。

ツイートするのはPOST statuses/updateなのでドキュメントを参照。

画像つきでツイートしてみる

画像とか動画とかメディアつきだとちょっとめんどくさく...

ツイートと一緒に画像をアップロードできないので、

  1. 最初に画像をアップロードしてからmediaIdを取得し、
  2. mediaIdと一緒にstatuses/updateでツイート

という段階的な感じになる。

import Twitter from "twitter";
const client = // 略


async function tweetWithImage(text: string, filePath: string) {
  const data = require('fs').readFileSync(filePath);

  // 画像をアップロード
  const media = await client.post('media/upload', {media: data});
  
  // mediaIdをパラメタに追加して、ツイート
  const params = { status: text, media_ids: media.media_id_string };
  const tweet = await client.post("statuses/update", params);
}

tweetWithImage("ツイート", "./imange.jpg").then();

複数の画像をつけたい場合は、それぞれアップロードして、
media_idsにカンマ区切りでmediaIdを指定する。

ただ、このmedia/uploadを1度だけ呼び出すシンプルな方法には制限があり、
GIFや動画はアップロードできない...

30秒以下の動画付きツイートをしてみる

動画やGIFをアップロードしたい場合は、Chunked media uploadという形でアップロードする必要がある。

この方法は、大きく3ステップに分かれている

  1. 初期化: command=INIT
  2. アップロード: command=APPEND
  3. 完了: commaind=FINALIZE
import Twitter from "twitter";
const client = // 略


async function tweetWithChunkedMedia(text: string, filePath: string) {
  const mediaType = 'video/mp4';
  const mediaData = require('fs').readFileSync(filePath);
  const mediaSize = require('fs').statSync(filePath).size;

  // 動画をアップロード: INIT
  const media = await client.post('media/upload', {
    command    : 'INIT',
    total_bytes: mediaSize,
    media_type : mediaType
  });
  // INITでmediaIdが発行されるので、取得しておく
  const mediaId = media.media_id_string;
  
  // 動画をアップロード: UPLOAD
  await client.post('media/upload', {
    command      : 'APPEND',
    media_id     : mediaId,
    media        : mediaData,
    segment_index: 0
  });
  
  // 動画をアップロード: FINALIZE
  await client.post('media/upload', {
    command : 'FINALIZE',
    media_id: mediaId
  });
  
  // mediaIdをパラメタに追加して、ツイート
  const params = { status: text, media_ids: mediaId };
  const tweet = await client.post("statuses/update", params);
}

tweetWithChunkedMedia("ツイート", "./video.mp4").then();

動画やGIFのような大きいサイズのメディアは、分割してアップロードできるこの仕組みを使うっぽい。

ただ、30秒以上の動画や1MB(チャンクサイズ上限)を超える場合は、
INIT時にmedia_categoryを指定して、非同期アップロードをしないといけない。

30秒を超える動画付きツイートをしてみる

30秒を超える動画は、media_categoryをつけ、非同期アップロードで対応しないといけない。

media_categoryは、tweet_image, tweet_gif, tweet_videoを指定できるので、
アップロードするメディアに合わせて指定する。

また、チャンクサイズの上限が1MBなので、APPENDでデータをPOSTする際には注意。
1MB以上の場合は、1MB以下になるように分割し、segment_indexでindexを指定する。

import Twitter from "twitter";
const client = // 略


async function tweetWithChunkedMedia(text: string, filePath: string) {
  const mediaType = 'video/mp4';
  const mediaData = require('fs').readFileSync(filePath);
  const mediaSize = require('fs').statSync(filePath).size;

  // 動画をアップロード: INIT
  const media = await client.post('media/upload', {
    command    : 'INIT',
    total_bytes: mediaSize,
    media_type : mediaType,
    media_category: "tweet_video" // media_categoryを指定
  });

  const mediaId = media.media_id_string;
  
  // 動画をアップロード: UPLOAD
  await client.post('media/upload', {
    command      : 'APPEND',
    media_id     : mediaId,
    media        : mediaData,
    segment_index: 0
  });
  
  // 動画をアップロード: FINALIZE
  await client.post('media/upload', {
    command : 'FINALIZE',
    media_id: mediaId
  });
  
  // 動画をアップロード: STATUS
  while(true) {
    // アップロードのステータスをポーリング
    const status = await client.get('media/upload', {
      command : 'STATUS',
      media_id: mediaId
    });
    
    if (status.processing_info.state == "succeeded") {
      // 完了したら、ポーリングを終了
      break;
    } else if (status.processing_info.state == "failed") {
      // エラーになったら、例外を投げる
      throw new Error(status.processing_info.error.message);
    } else {
      // 処理中(in_progress)の場合は、指定された秒数分待つ
      await sleep(status.processing_info.check_after_secs + 1);
    }
  }
  
  // mediaIdをパラメタに追加して、ツイート
  const params = { status: text, media_ids: mediaId };
  const tweet = await client.post("statuses/update", params);
}

function sleep(time: number) {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(), time * 1000);
  });
}

tweetWithChunkedMedia("ツイート", "./video.mp4").then();

かなりハマったのが以下の2点。

  1. STATUSはFINALIZEしてからじゃないと、404が返ってくる
  2. FINALIZEのレスポンスにもprocessing_infoがあるが、
    STATUSをしないと永遠にpending状態。(STATUSを呼ぶと処理が始まる)

このあたり、ドキュメントに詳しい説明がなくて、かなりハマった...

axiosを使って外部URLのメディアをツイートする

Cloud Storageにある画像/動画を含めてツイートしたかったので、
axiosを使って外部URLを取得する処理を加えてみたのがこれ。

new TwitterApi().postTweet("ツイート", ["https://..."]);
みたいに呼び出すと、ダウンロード/アップロード/ツイートできる。(はず...)

import Twitter from "twitter";
import axios from "axios";

/**
 * スリープ処理
 * @param time スリープする秒数
 */
function sleep(time: number) {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(), time * 1000);
  });
}

export default class TwitterApi {
  private client: Twitter;

  constructor() {
    this.client = new Twitter({
      consumer_key: // TWITTER_CONSUMER_KEY,
      consumer_secret: // TWITTER_CONSUMER_SECLET,
      access_token_key: // ACCESS_TOKEN_KEY,
      access_token_secret: // ACCESS_TOKEN_SECRET
    });
  }

  /**
   * ツイートするメイン処理
   * @param text ツイート文
   * @param medias 添付する外部URLのリスト
   */
  public async postTweet(text: string, medias: string[] = []) {
    let mediaIds: string[] = [];
    if (medias.length > 0) {
      // メディアファイルがあれば、アップロードしてmediaIdを取得
      mediaIds = await Promise.all(
        medias.map(async v => await this.uploadMedia(v.url))
      );
    }

    const res = await this.tweet(text, mediaIds);
  }

  /**
   * メディアのアップロード処理
   * @param url メディアのURL
   */
  private async uploadMedia(url: string) {
    // axiosを使って、メディアのデータを取得
    const res = await axios.create({ responseType: "arraybuffer" }).get(url);

    const mediaData: ArrayBuffer = res.data;
    const mediaSize = res.headers["content-length"];
    const mediaType = res.headers["content-type"];

    // INIT: mp4かgifなら、media_categoryを指定する
    const initParams = {
      command: "INIT",
      total_bytes: mediaSize,
      media_type: mediaType
    };
    if (mediaType == "video/mp4") {
      initParams["media_category"] = "tweet_video";
    } else if (mediaType == "image/gif") {
      initParams["media_category"] = "tweet_gif";
    }
    const data = await this.client.post("media/upload", initParams);

    const mediaId = data.media_id_string;

    // APPEND: 500Bくらいにチャンクを分けてアップロードする
    const chunkSize = 500000;
    const chunkNum = Math.ceil(mediaSize / chunkSize);

    for (let index = 0; index < chunkNum; index++) {
      const chunk = mediaData.slice(chunkSize * index, chunkSize * (index + 1));
      const resAppend = await this.client.post("media/upload", {
        command: "APPEND",
        media_id: mediaId,
        media: mediaData.slice(chunkSize * index, chunkSize * (index + 1)),
        segment_index: index
      });
    }

    // FINALIZE
    const resFinalize = await this.client.post("media/upload", {
      command: "FINALIZE",
      media_id: mediaId
    });
    if (!resFinalize.processing_info) {
      // media_categoryをしていないと、processing_infoがない
      return mediaId;
    } else if (resFinalize.processing_info.state == "succeeded") {
      return mediaId;
    } else if (resFinalize.processing_info.state == "failed") {
      throw new Error(resFinalize.processing_info.error.message);
    }

    // STATUS
    while (true) {
      const resStatus = await this.client.get("media/upload", {
        command: "STATUS",
        media_id: mediaId
      });
      if (resStatus.processing_info.state == "succeeded") {
        return mediaId;
      } else if (resStatus.processing_info.state == "failed") {
        throw new Error(resStatus.processing_info.error.message);
      } else {
        await sleep(resStatus.processing_info.check_after_secs + 1);
      }
    }
  }
  
  /**
   * ツイート処理
   * @param text ツイート文
   * @param mediaIds メディアIDのリスト
   */
  private async tweet(text: string, mediaIds: string[] = []) {
    const params = { status: text };

    if (mediaIds.length > 0) params["media_ids"] = mediaIds.join(",");

    const tweet = await this.client.post("statuses/update", params);
    return tweet;
  }
}

若干ハマったのが、以下の2点

  1. axiosで取得する場合は、{ responseType: "arraybuffer" }でcreateしないといけない
  2. cloudStrageでデータを取得できないので、downloadURLを取得しておかないといけない

Twitter APIむずい...

以上!!

参考にしたサイト様