🌐

VRChat APIを使用して複数のワールドをお気に入りに入れるサンプル

に公開

筆者はVRChat上でワールドツアー系のイベントを運営しています。
ワールドツアーの関係上、訪れる予定のワールドを一括で順番通りにお気に入りに入れたい場合が多々あります。
今回は、ワールドの一覧をJSONで管理し、一括でお気に入り登録できる仕組みを考えてみます。
2025/09/01追記 vrchat 1.20.2 更新に伴いSystemApiの項目を削除

前提

VRChatのWebやクライアント内部で使用されているAPIは、ユーザーがBOTやアプリケーションの制作で使用してもよいとされており、実際VRChat公式の文書であるクリエイターガイドラインには、非公式ながら存在するドキュメントにリンクが貼られ、ガイドラインを守る限りAPIを使用してよい、との旨が明記されています。

本稿では、このVRChat APIを使用し、認証の自動化からあらかじめ決めておいた複数のワールドをお気に入り登録するまでのサンプルコードを、javascript(Node.js)で掲示します。

VRChat APIはドキュメントが非公式なうえ日本語での解説も少ないことから、対応アプリケーション開発はかなりハードルの高いものとなっています。とはいえ各言語に対応したライブラリ等も存在し、わかってしまえばそれほど難解なものではありません。

本稿が皆様のアプリケーション開発にお役立ていただければ幸いです。

注意点

本稿では、VRChat APIを使用するに際して、利用される方のVRChatの認証情報を使用します。
本来であれば、そういった機密性の高い情報は暗号化するなどして安全性を担保すべきですが、本稿はあくまでVRChat APIを使用するためのサンプルであり、暗号化といった内容は本稿の趣旨ではないため省いています。

また、クリエイターガイドラインには、

Do not request log-in information from users in any situation.
You should never ask for or store someone's VRChat credentials. This includes usernames, passwords, login or auth tokens, and/or session data.
(日本語訳)いかなる状況においても、ユーザーにログイン情報を要求しないでください。
VRChatの認証情報を尋ねたり、保存したりしないでください。これには、ユーザー名、パスワード、ログイントークン、認証トークン、セッションデータなどが含まれます。

と明記されております。すなわちガイドラインを遵守するには、ユーザーにアカウント情報の入力を求める機構を作ってはならず、自分で作ったアプリケーションに自分のアカウントをハードコートするしかない。となります。
(要は「外部サービスにアカウント丸ごと入れさせるなんて危なくて仕方がねーよ。そんなもん作るんじゃねーよ」と書いてあるわけで。まあ当然ですよね)

上記2点を踏まえたうえで、あくまで本稿で掲示されるコードはサンプルであり、エラー処理なども甘く、そのままの動作を想定したものではありません。VRChat APIならびにその認証情報を使用される場合は、必ずご自身作成のアプリケーションを、ご自身のアカウントに限り、ご自身の責任において実行していただく必要がある事を念頭に置いていただければ幸いです。

免責事項

本稿を参考にしたことによって生じる問題について、筆者は一切の責任を負いません。

環境

Windows11
Node.js v20.19.1 (Javascript実行環境)
vrchat v1.20.2 (VRChat APIライブラリ)
speakeasy v2.0.0 (後述する2fa認証用)
dotenv v16.5.0

準備

準備に関しては、前記事VRChat APIを使用して複数ユーザーにInviteを送るサンプルと全く同じなため割愛します。認証関数も全く同じですが、初期化部分のクラス定義の部分のみ、以下のfavorites: new vrchat.FavoritesApi(this.configuration)を追加し、FavoritesApiを定義しておきます。

vrc_favorite_test.js
class Vrc {
constructor(params)
{
  this.configuration = new vrchat.Configuration({
    username: params.username,
    password: params.password,
    baseOptions: {
      headers: { 'User-Agent': params.useragent }
    }
  });
  this.mfaSecret = params.mfaSecret;
  
  this.api = {
    authentication: new vrchat.AuthenticationApi(this.configuration),
    favorites:      new vrchat.FavoritesApi(this.configuration),
    users:          new vrchat.UsersApi(this.configuration),
  },
  this.isLogin = false;
}

お気に入り登録

お気に入り登録用のメンバ関数を定義します。

vrc_favorite_test.js
////////////////////////////////////////////////////
// ワールドお気に入り登録処理
async favoriteWorlds(worlds, tag)
{
  for( let i = 0; i < worlds.length; i++ ){
    const world = worlds[i];
    console.log(`\t登録しています: ${world.Name} (${world.ID})`);
    
    try{
      await this.api.favorites.addFavorite({
        type:       'world',
        favoriteId: world.ID,
        tags:       [tag]
      });
    }

    catch(err){
      console.log(`\t- ERROR! Status: ${err.response.status}, ${err.response.data.error.message}`);
      
      if( await prompt('リトライしますか?(y/n): ') === 'y' ){
        i--;
        continue;
      }
    }

    // 短時間にAPIを叩きすぎると良くないので1秒のディレイ
    await delay(1000);
  }
  console.log('完了');
}

worlds配列の分、ループを回し、各ワールドをお気に入りに登録します。

try{
  await this.api.favorites.addFavorite({
    type:       'world',
    favoriteId: world.ID,
    tags:       [tag]
  });
}

addFavorite()を呼び出し、お気に入りに登録します。
addFavorite()はワールドだけではなく、フレンドやアバターのお気に入り登録にも対応します。その場合、パラメータのtypeにアバターの場合は'avatar'、フレンドの場合は'friend'を指定し、favoriteIdにそれぞれアバターのID(avtr_xxx)やユーザーID(usr_xxx)を指定します。

tagsには、お気に入りの登録欄のどこに登録するかを選択します。

  • フレンドであれば'group_0'~'group_2'
  • アバターであれば'avatar1'~'avatar6'
  • ワールドであれば'worlds1'~'worlds4'

をそれぞれ指定します。
tagsとなっている通り配列で指定しているため、登録先を複数指定もできるようですが、Webからもそういったことはできないはずなので、今回は割愛します。

お気に入りを削除する

このままでもお気に入りを登録するだけなら利用可能ですが、実運用上ではちょいちょいエラーを起こします。Publicワールドではない、お気に入りが満杯、または既に登録されているのいずれかが多いです。

満杯はWebから消していただくとして、既に登録済みの場合は、一度消して順番通り登録し直した方がワールドツアーの都合上便利です。

そこでcatch句内を以下のように変更します。

catch(err){
  console.log(`\t- ERROR! Status: ${err.response.status}, ${err.response.data.error.message}`);
  
  // 既に登録済み
  if( err.response.status === 400 && err.response.data.error.message === 'You already have that world favorited' ){
    if( await prompt('このワールドは既に登録済みです。削除して登録し直しますか?(y/n): ') === 'y' ){
      
      console.log(`\tお気に入りを削除しています。worldId:${world.ID}`);
      await this.api.favorites.removeFavorite(world.ID);
      
      i--;
    }
  }
  
  else if( await prompt('リトライしますか?(y/n): ') === 'y' ){
    i--;
    continue;
  }
}

もし、お気に入りが満杯か既に登録済みの場合、Status: 400を返し、addFavorite()が例外をスローします。どちらであるかを判断するにはerr.response.data.error.messageを見る必要があります。
満杯の場合、文字列You already have 100 favorite worlds in group 'worlds?'が(?には数字が入ります)、既に登録済みの場合はYou already have that world favoritedが返されます。

既に登録済みの場合、removeFavorite()APIを呼び出し、当該のお気に入りを削除します。

登録するワールドを決める

まずワールドIDを取得します。ワールドIDは検索などで表示されるワールドのページを開き、URL欄に表示されるwrld_から始まるUUIDがワールドIDです。例えばVRChat HomeのワールドIDはwrld_4432ea9b-729c-46e3-8eaf-846aa0a37fddです。

JSONデータの作成

サンプルとして以下のようなJSON文字列を作成し、コマンドラインから入力するために改行を取り除いておきます。

{
  "Categorys": [
    {
      "Worlds": [
        {
          "ID": "wrld_4432ea9b-729c-46e3-8eaf-846aa0a37fdd",
          "Name": "VRChat Home"
        }
      ]
    }
  ]
}
{"Categorys": [{"Worlds": [{"ID": "wrld_4432ea9b-729c-46e3-8eaf-846aa0a37fdd", "Name": "VRChat Home"}]}]}

Worlds配列下にあるNameに関しては、単に表示を行っているだけであり、作らなくても構いません。また、IDが示すワールドIDは、Publicワールドである必要があります。Privateなワールドはお気に入りに入れることはできません。

また、このJSON構造は、弊社が公開しているPortalLibrarySystem(WPPLS)と互換性があります。

JSON文字列の入力とパース

vrc_favorite_test.js
////////////////////////////////////////////////////
// 引数または入力からのJSON文字列をパースします
async function inputJson(json)
{
  while( 1 ){
    try{
      const data = JSON.parse(json || await prompt('JSONコードを入力してください: '));
      
      let category = data.Categorys[0];
      
      // カテゴリーが2個以上ある場合は選択してもらう
      if( data.Categorys.length >= 2 ){
        data.Categorys.forEach((cat, idx) => {
          console.log(`${idx}: ${cat.Category}`);
        });
        category = data.Categorys[Number(await prompt('カテゴリーを選択してください: '))];
      }

      if( category.Category ){
        console.log(category.Category);
      }
      const worlds = category.Worlds;
      console.log('ワールド -------------------------------------------------');
      worlds.forEach(world => {
        console.log(`\t${world.Name} (${world.ID})`);
      });
      console.log('---------------------------------------------------------');
      console.log(`上記ワールド${worlds.length}個をお気に入りに登録します。`);
      
      const target = Number(await prompt('どこに登録しますか? 1~4: worlds1~4, 0: やめる> '));
      if( target >= 1 && target <= 4 ){
        return {
          worlds,
          target: 'worlds' + target
        };
      }
    }
    catch(err){
      console.log(`JSONパースエラー`);
    }
    json = null;
  }
}

inputJson()関数は引数で与えられたJSON文字列をパースし、確認のため表示も行います。引数がない場合はコマンドラインからJSON文字列を受け取ります。JSONとして解釈できない文字列であった場合は例外をスローするため、全体をtry~catch句で囲み、さらにやり直しできるようにwhile句で囲んであります。

main関数

vrc_favorite_test.js
////////////////////////////////////////////////////
// main処理
async function main()
{
  const vrc = new Vrc({
    username: process.env.VRC_USERNAME,
    password: process.env.VRC_PASSWORD,
    useragent:process.env.VRC_USERAGENT,
    mfaSecret:process.env.VRC_MFA_SECRET
  });
  
  let json = process.argv[2] ? fs.readFileSync(process.argv[2]) : null;
  
  while( 1 ){
    const data = await inputJson(json);
    
    await vrc.login();
    await vrc.favoriteWorlds(data.worlds, data.target);
    
    if( await prompt('別のワールドを登録しますか?(y/n): ') !== 'y' ){
      break;
    }
    
    json = null;
  }
  
  await vrc.logout();
}

main();

上記すべての処理をまとめたメイン処理部分です。

まず、Vrcクラスをnewしてインスタンス化し、APIの使用準備をします。

引数が与えられて起動した場合はそれをJSONファイルであるとしてfs.readFileSync()で読み込み、inputJson()に渡します。

inputJson()は引数が与えられている場合はそれを、与えられていない場合はコマンドラインからJSON文字列を取得し、必要なデータをパースします。

データが揃い次第login()でログイン処理を行います。この関数はログインに失敗するとアプリケーションそのものを終了させるため、戻ってきたときはログインが二段階認証含めて成功していることを意味します。

後はfavoriteWorlds()でお気に入りの登録処理を行い、完了です。

vrc_favorite_test.batを作る

毎回コマンドプロンプトを開くのも面倒のため、バッチファイルを作っておきます。バッチファイルを作っておくとダブルクリックするだけで開いてくれるようになります。またJSONファイルをD&Dすることでファイルパスを引数として与えることができます。

vrc_favorite_test.bat
%~d0
cd "%~p0"
node vrc_favorite_test.js %1
pause

Discussion