RuntripのジャーナルをSNS(X/Twitter)に投稿するアプリを作成した

Runtripというサービスをご存知でしょうか?

Runtripではランナー向けに様々なサービスを提供していますが、その一つにSNSがあります。
このSNSへの投稿を他のSNS(X/Twitter)にも投稿したいと思い、連携アプリを作ることにしました。

全体像



仕様

  • 定期的にRuntripのWebサイトにアクセスし、新規ジャーナルがあればX/Twitterに投稿する。
  • 投稿内容は以下とする。
    [ジャーナル本文]
    [ハッシュタグ]
    [ジャーナルへのURL]
  • X/Twitterに投稿する画像は、ジャーナルの最初の1枚のみとする。(※Runtrip無料会員は1枚のみ投稿可のため)
  • アプリケーションは自宅のサーバに配置する。

実装

1. アクセストークンの取得とAPI操作

連携したいX/Twitterアカウントで 開発者アカウント に登録し、開発者用のアクセストークンを取得します。
API操作には twitteroauth を利用します。

PHPでの実装例:

<?php
function tweet(string $text, string $image, string $accessToken, string $accessTokenSecret): array
{
    $connection = new Abraham\TwitterOAuth\TwitterOAuth(
        $_ENV['TWITTER_API_KEY'],
        $_ENV['TWITTER_API_KEY_SECRET'],
        $accessToken,
        $accessTokenSecret);
    $connection->setDecodeJsonAsArray(true);

    $parameters = ['text' => $text];

    if ($image) {
        $connection->setApiVersion('1.1');
        $media = $connection->upload('media/upload', ['media' => $image]);
        if (!isset($media['media_id_string'])) {
            return $media;
        }

        $parameters['media'] = ['media_ids' => [$media['media_id_string']]];
    }

    $connection->setApiVersion('2');
    return $connection->post('tweets', $parameters, ['jsonPayload' => true]);
}

2. 機密情報(アクセストークン等)の管理

以下を参考に、phpdotenv を利用するため、機密情報は .env ファイルに格納します。
qiita.com

3. ジャーナルの取得と新規投稿の検出

ジャーナルの取得

ジャーナルはRuntripのWebサイトから取得します。
ユーザページ: https://runtrip.jp/users/{ユーザID} にアクセスすると、HTML内にJSONデータが埋め込まれているためこれを利用します。

<script id="__NEXT_DATA__" type="application/json"> JSONデータ </script>

JSONデータの中身は以下の通り。

{
  "props": {
    "pageProps": {
      …省略…
      "swr": {
        "fallback": {
          "https://api.runtrip.jp/v1/users/me:$get": {
            …省略…
          },
          "https://api.runtrip.jp/v1/users/462:$get": {
            …省略…
          },
          "https://api.runtrip.jp/v1/users/462/courses?pageNumber=0&pageSize=9:$get": {
            …省略…
          },
          "https://api.runtrip.jp/v1/users/462/favorite_courses?pageNumber=0&pageSize=9:$get": {
            …省略…
          },
          "https://api.runtrip.jp/v1/users/462/visited_courses?pageNumber=0&pageSize=9:$get": {
            …省略…
          },
          "https://api.runtrip.jp/v1/users/462/journals?pageNumber=0&pageSize=9:$get": {
            …省略…
            "journals": [
              {
                "user": {
                  …省略…
                },
                "journal": {
                  "id": 1111663,
                  "date": "2023-09-21 00:00:00",
                  "createdAt": "2023-09-21 19:04:49",
                  "updatedAt": "2023-09-21 19:04:52",
                  "description": "Runtripアプリから、PREMIUMサービスを近日リリース予定です。詳細は後日公開、お楽しみに。",
                  "tags": [
                    "runtrippremium"
                  ],
                  "publicationScope": 0,
                  "viewCount": 501,
                  "likeCount": 223,
                  "commentCount": 1,
                  "status": 0,
                  "imageUrls": [
                    "https://d3304ij6n73kfg.cloudfront.net/prod/thumb/journal_main/1111663/1000_500"
                  ],
                  "videoUrl": null,
                  "videoThumbnailUrl": null,
                  "distance": 0,
                  "distanceUnit": 0,
                  "time": 0
                },
                "isLiked": false
              }
            ]
          }
        }
      },
      …省略…
    },
    …省略…
  },
  …省略…
}

キー: "https://api.runtrip.jp/v1/users/{ユーザID}/journals?pageNumber=0&pageSize=9:$get" にジャーナルが列挙されていることが確認できます。
なお、キーがAPIURIになっていますが、このAPIを直接呼び出しても残念ながら 403 Forbidden でエラーになります...

PHPでの実装例:

<?php
function getJournals(int $userId): array
{
    $html = file_get_contents('https://runtrip.jp/users/' . $userId);
    $json = substrBetween($html, '<script id="__NEXT_DATA__" type="application/json">', '</script>');
    $array = json_decode($json, true);

    $uri = 'https://api.runtrip.jp/v1/users/' . $userId . '/journals?pageNumber=0&pageSize=9:$get';
    return $array['props']['pageProps']['swr']['fallback'][$uri]['journals'];
}
新規投稿の検出

新規投稿の検出方法として、現在の最新のジャーナルIDを保存しておき、
定期的にジャーナルを取得して、保存しておいたジャーナルIDより新しいものがあるかによって判定します。

PHPでの実装例:

<?php
function getNewJournals(int $userId, int $journalId): Generator
{
    $journals = getJournals($userId);

    $i = count($journals);
    $take = false;

    while ($i) {
        $journal = $journals[--$i]['journal'];

        if ($take) {
            yield $journal;
        }

        if ($journal['id'] === $journalId) {
            $take= true;
        }
    }
}

4. 投稿内容の作成

ジャーナルのJSONデータから必要な情報を抽出し、X/Twitterに投稿するテキストを作成します。

ジャーナル本文:

    "description": "Runtripアプリから、PREMIUMサービスを近日リリース予定です。詳細は後日公開、お楽しみに。",

ハッシュタグ:

    "tags": [
      "runtrippremium"
    ],

画像URL:

    "imageUrls": [
      "https://d3304ij6n73kfg.cloudfront.net/prod/thumb/journal_main/1111663/1000_500"
    ],


ハッシュタグやテキストには、X/Twitterに投稿できない文字等が含まれている可能性があるため、バリデーションを行います。
また、テキストがX/Twitterに投稿できる長さを超える可能性があるため、最大長までジャーナル本文の一部を省略します。
これらについては、別途、以下の記事に纏めました。
appl-rot13.hatenablog.jp

5. 実行

本アプリでは最新のジャーナルIDを保存しておく必要があります。そのため、

  1. サーバ起動時に実行する常駐アプリとし、ジャーナルIDはメモリ上に保持する
  2. cron等で定期的に実行するアプリとし、ジャーナルIDはファイル等に入出力する

のように工夫をする必要があります。
私の場合、ファイルアクセスを減らしたいため、1. の方法を採用しました。

PHPでの実装例:

<?php
$userId = $_ENV['RUNTRIP_USER_ID'];
$journalId = getLatestJournal($userId)['id'];

while (true) {
    $journals = getNewJournals($userId, $journalId);
    foreach ($journals as $journal) {
        $journalId = $journal['id'];
        // 投稿処理
    }

    sleep($_ENV['CHECK_INTERVAL']);
}

成果物

GitHubに公開しています。ご参考まで。
github.com

余談

RuntripジャーナルをWeb上から自由に閲覧できることを問題視しているユーザがいらっしゃるため、
そのうちジャーナルの取得ができなくなるかもしれませんが、あしからずご承知おきください。