您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add features into VRChat Web Pages and improve user experience.
当前为
// ==UserScript== // @name VRChat Web Pages Extender // @name:ja VRChat Webページ拡張 // @description Add features into VRChat Web Pages and improve user experience. // @description:ja VRChatのWebページに機能を追加し、また使い勝手を改善します。 // @namespace https://greasyfork.org/users/137 // @version 2.19.0 // @match https://vrchat.com/home // @match https://vrchat.com/home?* // @match https://vrchat.com/home#* // @match https://vrchat.com/home/* // @require https://greasyfork.org/scripts/19616/code/utilities.js?version=895049 // @license MPL-2.0 // @contributionURL https://pokemori.booth.pm/items/969835 // @compatible Edge // @compatible Firefox Firefoxを推奨 / Firefox is recommended // @compatible Opera // @compatible Chrome // @grant dummy // @run-at document-start // @icon https://images.squarespace-cdn.com/content/v1/5f0770791aaf57311515b23d/1599678606410-4QMTB25DHF87E8EFFKXY/ke17ZwdGBToddI8pDm48kGfiFqkITS6axXxhYYUCnlRZw-zPPgdn4jUwVcJE1ZvWQUxwkmyExglNqGp0IvTJZUJFbgE-7XRK3dMEBRBhUpxQ1ibo-zdhORxWnJtmNCajDe36aQmu-4Z4SFOss0oowgxUaachD66r8Ra2gwuBSqM/favicon.ico // @author 100の人 // @homepageURL https://greasyfork.org/scripts/371331 // ==/UserScript== /*global Gettext, _, h, GreasemonkeyUtils */ 'use strict'; // L10N Gettext.setLocalizedTexts({ /*eslint-disable quote-props, max-len */ 'en': { 'エラーが発生しました': 'Error occurred', }, /*eslint-enable quote-props, max-len */ }); Gettext.setLocale(navigator.language); if (typeof content !== 'undefined') { // For Greasemonkey 4 fetch = content.fetch.bind(content); //eslint-disable-line no-global-assign, no-native-reassign, no-undef } /** * ページ上部にエラー内容を表示します。 * @param {Error} exception * @returns {void} */ function showError(exception) { console.error(exception); try { const errorMessage = _('エラーが発生しました') + ': ' + exception + ('stack' in exception ? '\n\n' + exception.stack : ''); const homeContent = document.getElementsByClassName('home-content')[0]; if (homeContent) { homeContent.firstElementChild.firstElementChild.insertAdjacentHTML('afterbegin', h`<div class="row"> <div class="alert alert-danger fade show" role="alert" style="white-space: pre-wrap; font-size: 1rem; font-weight: normal;">${errorMessage}</div> </div>`); } else { alert(errorMessage); //eslint-disable-line no-alert } } catch (e) { alert(_('エラーが発生しました') + ': ' + e); //eslint-disable-line no-alert } } const ID = 'vrchat-web-pages-extender-137'; /** * 一度に取得できる最大の要素数。 * @constant {number} */ const MAX_ITEMS_COUNT = 100; /** * 一つのブックマークグループの最大登録数。 * @constant {Object.<number>} */ const MAX_FRIEND_FAVORITE_COUNT_PER_GROUP = 150; /** * @type {Function} * @access private */ let setUserDetails; /** * @type {Promise.<Object>} * @access private */ let userDetails = new Promise(function (resolve) { let settled = false; setUserDetails = function (details) { if (settled) { userDetails = Promise.resolve(details); } else { settled = true; resolve(details); } }; }); /** * キーにワールドIDを持つ連想配列。 * @type {Object.<(string|string[]|boolean|number|Object.<(string|number)>[]|(string|number)[][]|null)>} */ const worlds = { }; /** * キーにグループIDを持つ連想配列。 * @type {Object.<string,(string|string[]|number|boolean|Object.<string,(string|string[]|boolean)?>)?>[]} */ const groups = { }; addEventListener('message', function (event) { if (event.origin !== location.origin || typeof event.data !== 'object' || event.data === null || event.data.id !== ID) { return; } if (event.data.userDetails) { setUserDetails(event.data.userDetails); } else if (event.data.world) { worlds[event.data.world.id] = event.data.world; const locations = document.getElementsByClassName('locations')[0]; if (!locations) { return; } for (const [ instanceId ] of event.data.world.instances) { const locationLink = locations.querySelector(`.locations [href*=${CSS.escape(`/home/launch?worldId=${event.data.world.id}&instanceId=${instanceId}`)}]`); if (!locationLink) { continue; } insertInstanceUserCountAndCapacity(locationLink.closest('.locations > *'), event.data.world.id, instanceId); } } else if (event.data.group) { groups[event.data.group.id] = event.data.group; } }); /** * ログインしているユーザーの情報を取得します。 * @see [User Info — VRChat API Documentation]{@link https://vrchatapi.github.io/#/UserAPI/CurrentUserDetails} * @returns {Promise.<?Object.<(string|string[]|boolean|number|Object)>>} */ async function getUserDetails() { return await userDetails; } /** * JSONファイルをオブジェクトとして取得します。 * @param {string} url * @returns {Promise.<(Object|Array)>} OKステータスでなければ失敗します。 */ async function fetchJSON(url) { const response = await fetch(url, {credentials: 'same-origin'}); return response.ok ? response.json() : Promise.reject(new Error(`${response.status} ${response.statusText}\n${await response.text()}`)); } let friendFavoriteGroupNameDisplayNamePairs; /** * ログインしているユーザーのフレンドfavoriteのグループ名を取得します。 * @returns {Promise.<Object.<string>[]>} */ function getFriendFavoriteGroupNameDisplayNamePairs() { if (!friendFavoriteGroupNameDisplayNamePairs) { friendFavoriteGroupNameDisplayNamePairs = fetchJSON('/api/1/favorite/groups?type=friend', {credentials: 'same-origin'}).then(function (groups) { const groupNameDisplayNamePairs = {}; for (const group of groups) { groupNameDisplayNamePairs[group.name] = group.displayName; } return groupNameDisplayNamePairs; }); } return friendFavoriteGroupNameDisplayNamePairs; } /** * @type {Promise.<Object.<(string|string[])>[]>} * @access private */ let friendFavoritesPromise; /** * ブックマークを全件取得します。 * @see [List Favorites — VRChat API Documentation]{@link https://vrchatapi.github.io/#/FavoritesAPI/ListAllFavorites} * @returns {Promise.<Object.<(string|string[])>[]>} */ function getFriendFavorites() { return friendFavoritesPromise || (friendFavoritesPromise = async function () { const allFavorites = []; let offset = 0; while (true) { //eslint-disable-line no-constant-condition const favorites = await fetchJSON( `/api/1/favorites/?type=friend&n=${MAX_ITEMS_COUNT}&offset=${offset}`, ).catch(showError); allFavorites.push(...favorites); if (favorites.length < MAX_ITEMS_COUNT) { break; } offset += favorites.length; } return allFavorites; }()); } /** * 自分のユーザーページの編集ダイアログのステータスメッセージ入力欄へ履歴を追加します。 * @returns {Promise.<void>} */ async function insertStatusMessageHistory() { if (document.getElementById('input-status-message-history')) { // すでに挿入済みなら return; } const inputStatusMessage = document.getElementById('input-status-message'); if (!inputStatusMessage) { return; } // ステータスメッセージ入力欄へ履歴を追加 inputStatusMessage.insertAdjacentHTML('afterend', `<datalist id="input-status-message-history"> ${(await getUserDetails()) .statusHistory.map(statusDescription => h`<option>${statusDescription}</option>`).join('')} </datalist>`); inputStatusMessage.setAttribute('list', inputStatusMessage.nextElementSibling.id); } /** * フレンドのブックマーク登録/解除ボタンの登録数表示を更新します。 * @returns {Promise.<void>} */ async function updateFriendFavoriteCounts() { const counts = {}; for (const favorite of await getFriendFavorites()) { for (const tag of favorite.tags) { if (!(tag in counts)) { counts[tag] = 0; } counts[tag]++; } } for (const button of document.getElementsByName('favorite-friend')) { button.getElementsByClassName('count')[0].textContent = counts[button.value] || 0; } } /** * ユーザーページへブックマーク登録/解除ボタンを追加します。 * @returns {Promise.<void>} */ async function insertFriendFavoriteButtons() { const homeContent = document.getElementsByClassName('home-content')[0]; const unfriendButton = homeContent.querySelector('[aria-label="Unfriend"]'); if (!unfriendButton) { return; } const id = getUserIdFromLocation(); if (!id) { return; } const buttons = document.getElementsByName('favorite-friend'); const groupNameDisplayNamePairs = await getFriendFavoriteGroupNameDisplayNamePairs(); const groupNames = Object.keys(groupNameDisplayNamePairs); const buttonsParent = buttons[0] && buttons[0].closest('[role="group"]'); if (buttonsParent) { // 多重挿入の防止 if (buttonsParent.dataset.id === id) { return; } else { buttonsParent.remove(); } } unfriendButton.parentElement.parentElement.parentElement.parentElement.nextElementSibling.firstElementChild .insertAdjacentHTML('beforeend', `<div role="group" class="mx-2 btn-group-lg btn-group-vertical" style="margin-top: -60px;" data-id="${h(id)}"> ${groupNames.sort().map(tag => h`<button type="button" class="btn btn-secondary" name="favorite-friend" value="${tag}" disabled=""> <span aria-hidden="true" class="fa fa-star"></span>  <span class="name">${groupNameDisplayNamePairs[tag]}</span>  <span class="count">‒</span>⁄${MAX_FRIEND_FAVORITE_COUNT_PER_GROUP} </button>`).join('')} </div>`); await updateFriendFavoriteCounts(); const tags = [].concat( ...(await getFriendFavorites()).filter(favorite => favorite.favoriteId === id).map(favorite => favorite.tags), ); for (const button of buttons) { button.dataset.id = id; if (tags.includes(button.value)) { button.classList.remove('btn-secondary'); button.classList.add('btn-primary'); } if (button.classList.contains('btn-primary') || button.getElementsByClassName('count')[0].textContent < MAX_FRIEND_FAVORITE_COUNT_PER_GROUP) { button.disabled = false; } } buttons[0].closest('[role="group"]').addEventListener('click', async function (event) { const button = event.target.closest('button'); if (!button || button.name !== 'favorite-friend') { return; } const buttons = document.getElementsByName('favorite-friend'); for (const button of buttons) { button.disabled = true; } const id = button.dataset.id; const newTags = button.classList.contains('btn-secondary') ? [button.value] : []; const favorites = await getFriendFavorites(); for (let i = favorites.length - 1; i >= 0; i--) { if (favorites[i].favoriteId === id) { await fetch( '/api/1/favorites/' + favorites[i].id, {method: 'DELETE', credentials: 'same-origin'}, ); for (const button of buttons) { if (favorites[i].tags.includes(button.value)) { button.classList.remove('btn-primary'); button.classList.add('btn-secondary'); } } favorites.splice(i, 1); } } if (newTags.length > 0) { await fetch('/api/1/favorites', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({type: 'friend', favoriteId: id, tags: newTags}), }) .then(async response => response.ok ? response.json() : Promise.reject( new Error(`${response.status} ${response.statusText}\n${await response.text()}`), )) .then(function (favorite) { favorites.push(favorite); for (const button of buttons) { if (favorite.tags.includes(button.value)) { button.classList.remove('btn-secondary'); button.classList.add('btn-primary'); } } }) .catch(showError); } await updateFriendFavoriteCounts(); for (const button of buttons) { if (button.getElementsByClassName('count')[0].textContent < MAX_FRIEND_FAVORITE_COUNT_PER_GROUP) { button.disabled = false; } } }); } /** * ログイン中のユーザーのグループ一覧。 * @type {(string|boolean|number)[]?} */ let authUserGroups; /** * 指定したユーザーが参加しているグループを取得します。 * @param {*} userId * @returns {Promise.<(string|boolean|number)[]>} */ function fetchUserGroups(userId) { return fetchJSON(`https://vrchat.com/api/1/users/${userId}/groups`); } /** * {@link location} からユーザーIDを抽出します。 * @see {@link https://github.com/vrcx-team/VRCX/issues/429#issuecomment-1302920703} * @returns {string?} */ function getUserIdFromLocation() { return /\/home\/user\/(usr_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9A-Za-z]{10})/ .exec(location.pathname)?.[1]; } /** * ユーザーページへブグループへのinviteボタンを追加します。 * @returns {void} */ function insertInvitingToGroupButton() { const userId = getUserIdFromLocation(); if (!userId) { return; } const groupsHeading = Array.from(document.querySelectorAll('.home-content h2')) .find(heading => heading.lastChild?.data === '\'s Groups'); if (!groupsHeading) { return; } if (document.getElementsByName('open-inviting-to-group')[0]) { return; } const displayName = document.querySelector('.home-content h2').textContent; /*eslint-disable max-len */ groupsHeading.insertAdjacentHTML('beforeend', h` <button type="button" name="open-inviting-to-group" class="btn btn-primary"> <svg aria-hidden="true" class="svg-inline--fa fa-envelope" role="presentation" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <path fill="currentColor" d="M464 64C490.5 64 512 85.49 512 112C512 127.1 504.9 141.3 492.8 150.4L275.2 313.6C263.8 322.1 248.2 322.1 236.8 313.6L19.2 150.4C7.113 141.3 0 127.1 0 112C0 85.49 21.49 64 48 64H464zM217.6 339.2C240.4 356.3 271.6 356.3 294.4 339.2L512 176V384C512 419.3 483.3 448 448 448H64C28.65 448 0 419.3 0 384V176L217.6 339.2z"></path> </svg> Invite to Group </button> <div id="user-page-inviting-to-group-dialog" tabindex="-1" hidden="" style="font-size: 1rem; line-height: initial; position: relative; z-index: 1050; display: block;"><div> <div class="modal fade show" style="display: block;" role="dialog" tabindex="-1"> <div class="modal-dialog" role="document"><div class="modal-content"> <div class="modal-header"> <h5 class="modal-title"><h4 class="m-0">Invite to Group</h4></h5> <div><button name="close-inviting-to-group-dialog" aria-label="Close Button" style="padding: 5px; border-radius: 4px; border: 2px solid #333333; background: #333333; color: white;"> <svg role="presentation" aria-hidden="true" class="svg-inline--fa fa-xmark fa-fw" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" width="20"> <path fill="currentColor" d="M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z"></path> </svg> </button></div> </div> <div class="modal-body"></div> </div></div> </div> <div class="modal-backdrop fade show"></div> </div></div> `); /*eslint-enable max-len */ const dialog = document.getElementById('user-page-inviting-to-group-dialog'); groupsHeading.addEventListener('click', async function (event) { const button = event.target.closest('button'); if (!button) { return; } switch (button.name) { case 'open-inviting-to-group': { dialog.hidden = false; const modalBody = dialog.getElementsByClassName('modal-body')[0]; if (modalBody.firstElementChild) { break; } if (!authUserGroups) { authUserGroups = await fetchUserGroups((await getUserDetails()).id); } const groupIds = Array.from(groupsHeading.nextElementSibling.querySelectorAll('[aria-label="Group Card"]')) .map(groupCard => /grp_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ .exec(groupCard.pathname)[0]); if (!document.getElementById('invite-to-group-style')) { document.head.insertAdjacentHTML('beforeend', `<style id="invite-to-group-style"> [name="invite-to-group"] { --icon-size: 30px; --padding: 5px; padding: var(--padding) calc(var(--icon-size) + 2 * var(--padding)); font-size: 1.2em; border: 2px solid #064B5C; border-radius: 4px; position: relative; color: #6AE3F9; background: #064B5C; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%; } [name="invite-to-group"]:hover { border-color: #086C84; } [name="invite-to-group"]:disabled { border: 2px solid #333333; background: #333333; color: #999999; } [name="invite-to-group"] img { width: var(--icon-size); height: var(--icon-size); border-radius: 100%; border: 1px solid #181B1F; background-color: #181B1F; position: absolute; left: var(--padding); } [role="alert"] { display: flex; flex-direction: column; background-color: #541D22BF; margin-top: 10px; border-radius: 3px; padding: 10px; border-left: 3px solid red; } [role="alert"] > div:first-of-type { display: flex; align-items: center; } [role="alert"] > div:first-of-type > div:first-of-type { font-size: 1.2rem; font-weight: bold; } </style>`); } /*eslint-disable indent */ modalBody.innerHTML = authUserGroups.map(group => h`<div class="mt-2 mb-2 d-flex flex-column justify-content-center"> <div style="position: relative; border-radius: 4px;"> <button name="invite-to-group" value="${h(group.groupId)}"` + (groupIds.includes(group.groupId) ? h` disabled="" title="${displayName} is already a member of this group․"` : '') + h`> <img src="${group.iconUrl}"> ${group.name} </button> </div> </div>`).join(''); /*eslint-enable indent */ break; } case 'invite-to-group': { const enabledButtons = Array.from(dialog.querySelectorAll('button:enabled')); try { for (const button of enabledButtons) { button.disabled = true; } const response = await fetch(`/api/1/groups/${button.value}/invites`, { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ userId, confirmOverrideBlock: true }), }); if (!response.ok) { const { error: { message } } = await response.json(); /*eslint-disable max-len */ button.parentElement.insertAdjacentHTML('beforebegin', h`<div role="alert" aria-label="Couldn't invite user"> <div> <svg aria-hidden="true" class="svg-inline--fa fa-circle-exclamation me-2" role="presentation" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" color="red"> <path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM232 152C232 138.8 242.8 128 256 128s24 10.75 24 24v128c0 13.25-10.75 24-24 24S232 293.3 232 280V152zM256 400c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 385.9 273.4 400 256 400z"></path> </svg> <div>Couldn't invite user</div> </div> <div>${response.statusText}: ${message}</div> </div>`); /*eslint-enable max-len */ } enabledButtons.splice(enabledButtons.indexOf(button), 1); } finally { for (const button of enabledButtons) { button.disabled = false; } } break; } case 'close-inviting-to-group-dialog': dialog.hidden = true; break; } }); } /** * Friend Locationsページのインスタンスへ、現在のインスタンス人数と上限を表示します。 * @param {HTMLDivElement} location * @returns {void} */ function insertInstanceUserCountAndCapacity(location, worldId, instanceId) { const world = worlds[worldId]; const instanceUserCount = world?.instances?.find(([ id ]) => id === instanceId)?.[1]; /** @type {HTMLElement} */ let counts = location.getElementsByClassName('instance-user-count-and-capacity')[0]; if (!counts) { const button = location.querySelector('[aria-label="Invite Me"]'); const friendCount = location.querySelector('[aria-label="Invite Me"]').parentElement.previousElementSibling; counts = friendCount.cloneNode(); counts.classList.add('instance-user-count-and-capacity'); const reloadButton = button.cloneNode(); reloadButton.setAttribute('aria-label', 'Reload'); reloadButton.textContent = '↺'; reloadButton.addEventListener('click', async function (event) { const instance = await fetchJSON(`/api/1/instances/${worldId}:${instanceId}`, { credentials: 'same-origin' }); event.target.previousSibling.data = instance.userCount + ' / ' + instance.capacity; }); counts.append('', reloadButton); friendCount.before(counts); } counts.firstChild.data = (instanceUserCount ?? '?') + ' / ' + (world?.capacity ?? '?'); } /** * ページ読み込み後に一度だけ実行する処理をすでに行っていれば `true`。 * @type {boolean} * @access private */ let headChildrenInserted = false; const homeContents = document.getElementsByClassName('home-content'); new MutationObserver(function (mutations, observer) { if (document.head && !headChildrenInserted) { headChildrenInserted = true; document.head.insertAdjacentHTML('beforeend', `<style> /*==================================== Friend Locations */ .instance-user-count-and-capacity { white-space: nowrap; } .instance-user-count-and-capacity button { margin: 0 0 0 0.5em !important; padding: unset; line-height: 1; } /*==================================== フレンドのユーザーページ */ .btn[name^="favorite-"] { white-space: unset; } </style>`); // ユーザー情報・ワールド情報・グループ情報を取得 GreasemonkeyUtils.executeOnUnsafeContext(function (id) { Response.prototype.text = new Proxy(Response.prototype.text, { apply(get, thisArgument, argumentList) { const textPromise = Reflect.apply(get, thisArgument, argumentList); (async function () { const data = { id }; const pathname = new URL(thisArgument.url).pathname; if (pathname === '/api/1/auth/user') { data.userDetails = JSON.parse(await textPromise); } else if (pathname.startsWith('/api/1/worlds/wrld_')) { data.world = JSON.parse(await textPromise); } else if (pathname.startsWith('/api/1/groups/grp_')) { data.group = JSON.parse(await textPromise); } else { return; } postMessage(data, location.origin); })(); return textPromise; }, }); }, [ ID ]); } if (!homeContents[0]) { return; } const locationsList = homeContents[0].getElementsByClassName('locations'); const instanceUserCountAndCapacityList = homeContents[0].getElementsByClassName('instance-user-count-and-capacity'); new MutationObserver(async function (mutations) { for (const mutation of mutations) { if (locationsList[0]) { if (locationsList[0].children.length !== instanceUserCountAndCapacityList.length) { // Friend Locationsへインスタンス人数を追加 for (const location of locationsList[0].children) { if (location.getElementsByClassName('instance-user-count-and-capacity')[0]) { continue; } const launchLink = location.querySelector('[href*="/home/launch?"]'); if (!launchLink) { continue; } const params = new URLSearchParams(launchLink.search); insertInstanceUserCountAndCapacity(location, params.get('worldId'), params.get('instanceId')); } } } else if (mutation.addedNodes.length > 0 && mutation.target.nodeType === Node.ELEMENT_NODE && (/* ユーザーページを開いたとき */ mutation.target.classList.contains('home-content') || mutation.target.localName === 'div' && mutation.addedNodes.length === 1 && mutation.addedNodes[0].localName === 'div' && mutation.addedNodes[0] .querySelector('[aria-label="Add Friend"], [aria-label="Unfriend"]') || /* ワールドページを開いたとき */ mutation.target.parentElement.classList.contains('home-content') || /* グループページでタブ移動したとき */ mutation.target.parentElement.parentElement .classList.contains('home-content')) || /* ユーザーページ間を移動したとき */ mutation.type === 'characterData' && mutation.target.nextSibling?.data === '\'s Profile') { if (location.pathname.startsWith('/home/user/')) { // ユーザーページ await insertStatusMessageHistory(); insertInvitingToGroupButton(); await insertFriendFavoriteButtons('friend'); } else if (location.pathname.startsWith('/home/world/')) { // ワールドページ const heading = document.querySelector('.home-content h2'); const name = heading.firstChild.data; const author = heading.nextElementSibling.querySelector('[href^="/home/user/"]').firstChild.data; document.title = `${name} By ${author} - VRChat`; } else if (location.pathname.startsWith('/home/avatar/')) { // アバターページ const name = document.querySelector('.home-content h3').textContent; const author = document.querySelector('.home-content [href^="/home/user/"]').text; document.title = `${name} By ${author} - VRChat`; } else if (location.pathname.startsWith('/home/group/')) { // グループページ const name = document.querySelector('.home-content h2').textContent; const groupLink = document.querySelector('[href^="https://vrc.group/"]'); const shortCodeAndDiscriminator = groupLink.textContent; document.title = `${name} ⁂ ${shortCodeAndDiscriminator} - VRChat`; // グループオーナーへのリンクを追加 setTimeout(function () { if (!document.getElementById('group-owner-link')) { const groupLinkColumn = groupLink.closest('div'); groupLinkColumn.style.marginLeft = '1em'; const column = groupLinkColumn.cloneNode(); const ownerId = groups[/^\/home\/group\/([^/]+)/.exec(location.pathname)[1]].ownerId; column.innerHTML = h`<a id="group-owner-link" href="/home/user/${ownerId}"> Group Owner </a>`; groupLinkColumn.after(column); } }); } break; } } }).observe(homeContents[0], {childList: true, characterData: true, subtree: true }); observer.disconnect(); }).observe(document, {childList: true, subtree: true});