Open8

MastodonからTwitter (X) への自動投稿をPipedreamを使って無料で実現する、ついでにNotionにも記録する(不完全版)

さとうあまみさとうあまみ

MastodonからTwitterへの自動投稿をPipedreamを使って無料で実現する方法について、もしかしたら誰かの役に立つかも知れないので投稿しておく

  • Twitter API のキーが必要
  • 5分に1回連携
  • 画像つき投稿は画像1枚まで対応(2枚以上は非対応)
  • 元投稿が140字を超える場合は、140字以上の部分を切り落として、元投稿へのリンクを追加
  • ついでにNotionデータベースにも記録する
さとうあまみさとうあまみ

なぜ Mastodon から Twitterにクロスポストするのか

元々 長らく Twitter を使ってきたのですが、今年に入ってからのゴタゴタ(開発者軽視、家賃や退職金を払わないなどの倫理的な面)で、すっかり、使いたくなくなってしまった。心がつらい。

ほかのプラットフォームを使いたいけど、やはり、どこも参加している人が少ない。
届かないと意味がないよね〜というところで、自分自身の身は他のプラットフォームに置きつつ、Twitterに参加している人にも自分の投稿を見えるようにしておきたいと思いました。

Mastodon/Hackyderm

※私はMastodonに詳しくありません

文化はまったく異なるものの、Twitterの代替としては真っ先に候補に挙がるのがMastodon。
Mastodonはサーバーごとにテーマ(?)や、使える機能が異なるようです。
Mastodon同士であれば参加するサーバーをいつでも変更でき、また、サーバーが違っても相互に繋がれるということで、気軽に参加してみました。

私はMastodonのHachydermというサーバーに参加しています。

Hachyderm is a safe space, LGBTQIA+ and BLM, primarily comprised of tech industry professionals world wide. Note that many non-user account types have restrictions - please see our About page.

テック業界のプロフェッショナルのためのサーバーで、日本語投稿はほとんどなく(私以外に日本語投稿は見ない)、「いいヤツでいよう(Don't Be A Dick)」というようないくつかのルールのある、まったりとしたサーバーです。
タグをフォローできる機能が地味にお気に入りです。

さとうあまみさとうあまみ

全体の構成

  1. MastodonのRSSを定期的に読み込んで、新しい投稿があるかチェックする
  2. 新しい投稿の内容を整形する
  3. 文字だけのツイート、画像つきツイートで振り分ける
  4. 振り分けた条件に合わせてTwitterに投稿する
  5. ついでにNotionにも投稿内容を記録する

とりあえずコードを残しておきますので、気が向いたら説明など追記します…
もっと良いやり方があるよ、という場合はコメントや投稿などお待ちしております。

さとうあまみさとうあまみ

Mastodon の RSSを取得してフィルターする

トリガー

Mastodonでは投稿をRSSで配信できます。これを利用して、新しい投稿をRSSで取得します。
参加するサーバーによって配信URLが変わりますが、私の場合は https://hachyderm.io/@ayumisato.rssになります。

コード

  • RSSの投稿内容がHTMLコードになっているので、プレーンテキストに変換する
  • 140字以上の場合は短縮して元URLをつける
  • 画像付きツイートかどうかを判定する
  • 判定したら、それぞれ「画像付き投稿用」もしくは「文字だけ投稿用」ワークフローにPOSTする
import he from 'he';
import https from 'https';
const urlPattern = /(https?:\/\/[^\s]+)/g;

function httpRequest(options, data) {
  return new Promise((resolve, reject) => {
    const req = https.request(options, (res) => {
      let body = '';

      if (res.statusCode < 200 || res.statusCode >= 300) {
        return reject(new Error(`Status Code: ${res.statusCode}`));
      }

      res.on('data', chunk => body += chunk);
      res.on('end', () => resolve(body));
    });

    req.on('error', reject);
    req.write(data);
    req.end();
  });
}

function createRequestOptions(hostname) {
  return {
    hostname,
    port: 443,
    path: "/",
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
  }
}

function countUrls(string) {
  return (string.match(urlPattern) || []).length;
}

function countTweetLength(tweet) {
    let urlCount = countUrls(tweet);
    let tweetLengthWithoutUrls = tweet.replace(urlPattern, '').length;

    return tweetLengthWithoutUrls + urlCount * 22;
}

function trimTweet(tweet, urlToAdd) {
  const urlLength = 22;
  const ellipsisLength = 1;
  const maxTweetLength = 140;
  tweet = tweet.replace(urlPattern, '');

  let trimmedTweetLength = maxTweetLength - urlLength - ellipsisLength;

  if (tweet.length > trimmedTweetLength) {
      tweet = tweet.substring(0, trimmedTweetLength) + "…";
  }

  return tweet + urlToAdd;
}

function decodeAndCleanHtml(htmlString) {
  // Decode HTML entities
  const decodedString = he.decode(htmlString);
  // Convert HTML break tags to new lines
  const stringWithNewLines = decodedString.replace(/<br\s*\/?>/gi, '\n');
  // Remove HTML tags
  return stringWithNewLines.replace(/<[^>]+>/g, '');
}

export default defineComponent({
  async run({ steps, $ }) {
    let textString = decodeAndCleanHtml(steps.trigger.event.description);

    if (countTweetLength(textString) > 140) {
      textString = trimTweet(textString, steps.trigger.event.permalink)
    } 

    let payload = {
      textString,
    };

    if (steps.trigger.event["media:content"] && steps.trigger.event["media:content"]["@"]) {
      payload.media = steps.trigger.event["media:content"]["@"];
    }

    const data = JSON.stringify(payload);
    const hostname = payload.media 
      ? "○○画像つきツイート用○○.m.pipedream.net" 
      : "○○文字のみツイート用○○.m.pipedream.net";
    const options = createRequestOptions(hostname);

    return httpRequest(options, data);
  },
})

※たまに文字の改行が反映されていない時があります。理由はわかりません。

ついでに Notionに記録するためのワークフローへのPOST送信を行なう

3つめのステップでは、Notionに記録するためのワークフローに post_request を送信しています。
振り分けが不要で、ワークフローの送信先がひとつだけであれば、こんな感じで分かりやすいUIでPOST送信できます。

PipedreamにもGUIの条件振り分けがあったらいいな…

さとうあまみさとうあまみ

画像付きツイートを送信する

POSTされた投稿を受け取ったら、画像をアップロードし、画像のIDつきでツイートします。

トリガー

こんな感じでPOST用のURLが発行できます。ここにPOSTしてあげるとワークフローで受け取れるんですね。おもしろーい。

画像アップロード

Twiiter の upload_media のワークフローを利用します。
ここで TwitterのAPIキーなどが必要になります。APIキー等の入力後に、つぶやきたいアカウントを連携できます。
File Pathに前段のトリガーで受け取ったmedia_urlを指定します。

1 Twitterアカウントにつき無料で利用できるAPIは1つまで。
私はメインのアカウントのAPIは Kanau.app の Twitter ログインのために使ってしまっているので、サブアカウントにて新しくAPI利用を申請しました。
つぶやきたいアカウントと、API 利用申請するアカウントは異なっていてもOKです。

つぶやく

Twiiter の create_tweet ワークフローを利用します。
Textにはつぶやきたい文章、Media IDsにアップロードした画像を指定すると、画像付きで投稿できあます。

さとうあまみさとうあまみ

文章のみのツイートを送信する

文章のみのツイートの場合はもっと簡単です
Twiiter の create_tweet ワークフローのみを利用し、同じようにTextに文章を指定します。

さとうあまみさとうあまみ

ついでに Notionにも記録する

私は原則として古いツイート(投稿)は適宜、削除する主義なので、自分のためだけの記録用として Notion に投稿内容を記録しています。

インターネッツの世界ではその瞬間だけを共有できれば良いですし、あとからその時代のムードも知らぬ方に突っ込まれたりするのが微妙にイヤというのもあります。
無駄に他者様のサーバーの容量を圧迫するのもなんだかなと思うので。
※Hackydermでは、一定期間経った投稿を自動で削除できます。

トリガー

POST送信を受け取ります。

コード

投稿内容からHTMLタグを除去します。
本当はタグをそのままNotionデータベースに投げられたら良さそう、と思ったのですが、Notionの本文の構造を作るのがあまりに大変で対応させるのを考えるだけで頭が沸騰しそうなので、シンプルにテキストで投げることとします。

import he from 'he';

export default defineComponent({
  async run({ steps, $ }) {
    let htmlString = steps.trigger.event.query.description

    let decodedString = he.decode(htmlString);
    let textString = decodedString.replace(/<[^>]+>/g, '');

    return textString
  },
})

データベースに送信する

投稿の情報をNotionデータベースに送信し、新しいページを作成します。
Notionの create_page_from_database ワークフローを利用します。

PipedreamのUI上でNotionと連携し、親のデータベースIDなどを指定します。
プロパティなどの情報をデータベースにあわせてよしなに設定するだけで、新しいページを作成できます。

さとうあまみさとうあまみ

現状ではこんな感じですが、やろうと思えば(コードを書けば)もっと便利に多機能に使えそうです。
Pipedreamで夢が広がりんぐですね #インターネット老人会