您需要先安装一个扩展,例如 篡改猴、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.8.0 // @match https://www.vrchat.com/* // @match https://vrchat.com/* // @match https://api.vrchat.cloud/* // @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://pokemori.booth.pm/items/969835 // ==/UserScript== 'use strict'; // L10N Gettext.setLocalizedTexts({ /*eslint-disable quote-props, max-len */ 'en': { 'エラーが発生しました': 'Error occurred', '$LENGTH$ 文字まで表示可能です。': 'This text is displayed up to $LENGTH$ characters.', }, /*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); } } catch (e) { alert(_('エラーが発生しました') + ': ' + e); } } const ID = 'vrchat-web-pages-extender-137'; /** * 一度に取得できる最大の要素数。 * @constant {number} */ const MAX_ITEMS_COUNT = 100; /** * Statusの種類。 * @constant {number} */ const STATUSES = { 'join me': { label: 'Join Me: Auto-accept join requests.', color: '--status-joinme', }, active: { label: 'Online: See join requests.', color: '--status-online', }, 'ask me': { label: 'Ask Me: Hide location, see join requests.', color: '--status-askme', }, busy: { label: 'Do Not Disturb: Hide location, hide join requests.', color: '--status-busy', }, }; /** * Status Descriptionの最大文字数。 * @constant {number} */ const MAX_STATUS_DESCRIPTION_LENGTH = 32; /** * 一つのブックマークグループの最大登録数。 * @constant {number} */ const MAX_FAVORITES_COUNT_PER_GROUP = 64; /** * @type {Function} * @access private */ let resolveUserDetails; /** * @type {Promise.<Object>} * @access private */ const userDetails = new Promise(function (resolve) { resolveUserDetails = resolve; }); addEventListener('message', function receiveUserDetails(event) { if (event.origin !== location.origin || typeof event.data !== 'object' || event.data === null || event.data.id !== ID || !event.data.userDetails) { return; } event.currentTarget.removeEventListener(event.type, receiveUserDetails); resolveUserDetails(event.data.userDetails); }); /** * ログインしているユーザーの情報を取得します。 * @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()}`)); } /** * スクリプトで扱うブックマークの種類。 * @constant {string[]} */ const FAVORITE_TYPES = ['friend', 'world']; let favoriteTypeGroupsPairsPromise; /** * ログインしているユーザーのfavoriteのグループ名を取得します。 * @returns {Object.<Object.<string>[]>} {@link FAVORITE_TYPES}の要素をキーに持つ連想配列。 */ function getFavoriteTypeGroupsPairs() { if (!favoriteTypeGroupsPairsPromise) { favoriteTypeGroupsPairsPromise = fetchJSON('/api/1/favorite/groups', {credentials: 'same-origin'}) .then(function (groups) { const favoriteTypeGroupsPairs = {}; for (const group of groups.filter(group => FAVORITE_TYPES.includes(group.type))) { if (!(group.type in favoriteTypeGroupsPairs)) { favoriteTypeGroupsPairs[group.type] = {}; } favoriteTypeGroupsPairs[group.type][group.name] = group.displayName; } return favoriteTypeGroupsPairs; }); } return favoriteTypeGroupsPairsPromise; } /** * @type {Promise.<Object.<(string|string[])[]>[]>} * @access private */ let favorites; /** * ブックマークを全件取得します。 * @see [List Favorites — VRChat API Documentation]{@link https://vrchatapi.github.io/#/FavoritesAPI/ListAllFavorites} * @returns {Promise.<Object.<(string|string[])[]>[]>} {@link FAVORITE_TYPES}の要素をキーに持つ連想配列。 */ function getFavorites() { return favorites || (favorites = async function () { const allFavorites = { }; for (const type of FAVORITE_TYPES) { allFavorites[type] = []; } let offset = 0; while (true) { const favorites = await fetchJSON(`/api/1/favorites/?n=${MAX_ITEMS_COUNT}&offset=${offset}`).catch(showError); for (const favorite of favorites) { if (!FAVORITE_TYPES.includes(favorite.type)) { continue; } allFavorites[favorite.type].push(favorite); } if (favorites.length < MAX_ITEMS_COUNT) { break; } offset += favorites.length; } return allFavorites; }()); } /** * 「Edit Profile」ページに、ステータス文変更フォームを挿入します。 * @returns {Promise.<void>} */ async function insertUpdateStatusForm() { if ('update-status' in document.forms) { return; } const sidebarStatus = document.querySelector('.leftbar .user-info h6 span[title]'); const sidebarStatusDescription = document.querySelector('.leftbar .statusDescription small'); const templateCard = document.getElementById('name-change-submit').closest('.card'); const card = templateCard.cloneNode(true); card.getElementsByClassName('card-header')[0].textContent = 'Status'; const form = card.getElementsByTagName('form')[0]; form.name = 'update-status'; form.action = '/api/1/users/' + document.querySelector('[href*="/home/user/usr_"]').pathname.replace(/.+\//u, ''); const description = form.displayName; description.id = 'status-description'; description.type = 'text'; description.name = 'statusDescription'; description.value = sidebarStatusDescription.textContent; description.placeholder = ''; description.pattern = `.{0,${MAX_STATUS_DESCRIPTION_LENGTH}}`; description.title = _('$LENGTH$ 文字まで表示可能です。').replace('$LENGTH$', MAX_STATUS_DESCRIPTION_LENGTH); description.parentElement.getElementsByClassName('alert')[0].remove(); const descriptionContainer = description.closest('.col-10'); descriptionContainer.classList.replace('col-10', 'col'); descriptionContainer.parentElement.classList.add('mb-2'); const statusContainer = descriptionContainer.previousElementSibling; statusContainer.outerHTML = '<div class="col-auto"><select name="status" class="form-control">' + Object.keys(STATUSES).map(status => h`<option value="${status}">⬤ ${STATUSES[status].label}</option>`) .join('') + '</select></div>'; form.status.value = sidebarStatus.title; form.status.classList.add(form.status.value.replace(' ', '-')); form.status.addEventListener('change', function (event) { const classList = event.target.classList; classList.remove(...Object.keys(STATUSES).map(status => status.replace(' ', '-'))); classList.add(event.target.value.replace(' ', '-')); }); const submit = form.getElementsByClassName('btn')[0]; submit.id = 'status-change-submit'; submit.textContent = 'Update Status'; submit.disabled = false; form.addEventListener('submit', function (event) { event.preventDefault(); for (const control of event.target) { control.disabled = true; } const body = {}; for (const element of event.target) { if (element.localName === 'button') { continue; } body[element.name] = element.value; } fetch(event.target.action, { method: 'PUT', headers: {'content-type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify(body), }) .then(async function (response) { if (!response.ok) { return Promise.reject( new Error(`${response.status} ${response.statusText}\n${await response.text()}`) ); } sidebarStatus.classList.remove(...Object.keys(STATUSES).map(status => status.replace(' ', '-'))); sidebarStatus.classList.add(event.target.status.value.replace(' ', '-')); sidebarStatus.title = event.target.status; sidebarStatusDescription.textContent = event.target.statusDescription.value; }) .catch(showError) .then(function () { for (const control of event.target) { control.disabled = false; } }); }); templateCard.parentElement.getElementsByClassName('card')[0].before(card, document.createElement('hr')); } /** * ブックマーク登録/解除ボタンの登録数表示を更新します。 * @param {string} type - 「user」「favorite」のいずれか。 * @returns {Promise.<void>} */ async function updateFavoriteCounts(type) { const counts = {}; for (const favorite of (await getFavorites())[type]) { for (const tag of favorite.tags) { if (!(tag in counts)) { counts[tag] = 0; } counts[tag]++; } } for (const button of document.getElementsByName('favorite-' + type)) { button.getElementsByClassName('count')[0].textContent = counts[button.value] || 0; } } /** * ブックマーク登録/解除ボタンを追加します。 * @param {string} type - {@link FAVOLITE_TYPES}のいずれかの要素。 * @returns {Promise.<void>} */ async function insertFavoriteButtons(type) { const homeContent = document.getElementsByClassName('home-content')[0]; if (type === 'world' && !homeContent.querySelector('[name="type"][value="public"]')) { // privateワールド return; } const sibling = homeContent.querySelector('[role="group"]'); if (!sibling) { return; } const parent = sibling.closest('[class*="col-"]'); const result = /[a-z]+_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/.exec(location.pathname); if (!result) { return; } const id = result[0]; const buttons = document.getElementsByName('favorite-' + type); if (type === 'friend' && !(await getUserDetails()).friends.includes(id) || buttons[0]) { return; } const groupNameDisplayNamePairs = (await getFavoriteTypeGroupsPairs())[type]; const groupNames = Object.keys(groupNameDisplayNamePairs); if (parent.querySelector(`[name="favorite-${CSS.escape(type)}"][value="${CSS.escape(groupNames[0])}"]`)) { // 多重挿入の防止 return; } parent.insertAdjacentHTML('beforeend', '<div role="group" class="w-100 btn-group-lg btn-group-vertical mt-4">' + groupNames.sort().map(tag => h`<button type="button" class="btn btn-secondary" name="favorite-${type}" value="${tag}" disabled=""> <span aria-hidden="true" class="fa fa-star"></span>  <span class="name">${groupNameDisplayNamePairs[tag]}</span>  <span class="count">‒</span>⁄${MAX_FAVORITES_COUNT_PER_GROUP} </button>`).join('') + '</div>'); await updateFavoriteCounts(type); const tags = [].concat(...(await getFavorites())[type] .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_FAVORITES_COUNT_PER_GROUP) { button.disabled = false; } } parent.lastElementChild.addEventListener('click', async function (event) { const button = event.target.closest('button'); if (!button || button.name !== 'favorite-' + type) { return; } const buttons = document.getElementsByName('favorite-' + type); 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 getFavorites())[type]; 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, 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 updateFavoriteCounts(type); for (const button of buttons) { if (button.getElementsByClassName('count')[0].textContent < MAX_FAVORITES_COUNT_PER_GROUP) { button.disabled = false; } } }); } /** * フレンド一覧で次のページが確実に存在しない場合、次ページのボタンを無効化します。 * また、オンラインのフレンド数を表示します。 * @param {HTMLDivElement} group 「friend-group」クラスを持つ要素。 * @returns {void} */ function improveFriendList(group) { // フレンド一覧で次のページが確実に存在しない場合、次ページのボタンを無効化します const pager = group.querySelector('.friend-group > div:last-of-type'); const count = group.querySelectorAll('.friend-group > div:not(:last-of-type)').length; const nextPageButton = pager.getElementsByClassName('fa-angle-down')[0].closest('button'); nextPageButton.disabled = count < MAX_ITEMS_COUNT; // オンラインのフレンド数を表示します const heading = group.firstElementChild; if (heading.textContent.includes('Online')) { heading.textContent = `Online (${ MAX_ITEMS_COUNT * (/[0-9]+/.exec(pager.getElementsByClassName('page')[0].textContent)[0] - 1) + count + (nextPageButton.disabled ? '' : '+') })`; } } /** * ページ読み込み後に一度だけ実行する処理をすでに行っていれば `true`。 * @type {boolean} * @access private */ let headChildrenInserted = false; new MutationObserver(async function (mutations) { if (document.head && !headChildrenInserted) { headChildrenInserted = true; document.head.insertAdjacentHTML('beforeend', `<style> /*==================================== Edit Profile */ ` + Object.keys(STATUSES).map(status => ` [name="status"].${CSS.escape(status.replace(' ', '-'))}, [name="status"] option[value=${CSS.escape(status)}] { color: var(${STATUSES[status].color}); } `).join('') + ` /*==================================== フレンドのユーザーページ */ .btn[name^="favorite-"] { white-space: unset; } </style>`); // ユーザー情報を取得します // ページ名を改善します GreasemonkeyUtils.executeOnUnsafeContext(function (id) { const responseText = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'responseText'); responseText.get = new Proxy(responseText.get, { apply(get, thisArgument, argumentList) { const responseText = Reflect.apply(get, thisArgument, argumentList); if (thisArgument.status === 200 && new URL(thisArgument.responseURL).pathname === '/api/1/auth/user') { postMessage({ id, userDetails: JSON.parse(responseText) }, location.origin); } return responseText; }, }); Object.defineProperty(XMLHttpRequest.prototype, 'responseText', responseText); History.prototype.pushState = new Proxy(History.prototype.pushState, { apply(pushState, thisArgument, argumentList) { Reflect.apply(pushState, thisArgument, argumentList); document.title = document.title.split(' | ').slice(-1)[0]; }, }); }, [ ID ]); addEventListener('popstate', function () { document.title = document.title.split(' | ').slice(-1)[0]; }); } for (const mutation of mutations) { let parent = mutation.target; if (parent.id === 'home') { break; } if (/* URLを開いたとき */ parent.localName === 'head' && document.body || /* ページを移動したとき */ parent.id === 'app' || parent.classList.contains('home-content') || parent.parentElement && parent.parentElement.classList.contains('home-content')) { const homeContent = document.getElementsByClassName('home-content')[0]; if (!homeContent || homeContent.getElementsByClassName('fa-cog')[0]) { break; } let promise; if (location.pathname === '/home/profile') { // 「Edit Profile」ページなら promise = insertUpdateStatusForm(); } else if (location.pathname.startsWith('/home/user/')) { // ユーザーページ promise = insertFavoriteButtons('friend'); if (!document.title.includes('|')) { const displayName = document.getElementsByTagName('h2')[0].textContent; const name = document.getElementsByTagName('h3')[0].firstChild.data; document.title = `${displayName} — ${name} | ${document.title}`; } } else if (location.pathname.startsWith('/home/world/')) { // ワールドページ promise = insertFavoriteButtons('world'); if (!document.title.includes('|')) { const heading = document.querySelector('.home-content h3'); const name = heading.firstChild.data; const author = heading.getElementsByTagName('small')[0].textContent; document.title = `${name} ${author} | ${document.title}`; } } if (promise) { promise.catch(showError); } } if (parent.classList.contains('friend-container')) { parent = mutation.addedNodes[0]; } if (parent.classList.contains('friend-group')) { let groups = document.getElementsByClassName('friend-group'); const heading = groups[0].firstElementChild.textContent; if (groups.length === 1 || heading.includes('Online') && !heading.includes('(')) { groups = [parent]; } for (const group of groups) { improveFriendList(group); } break; } } }).observe(document, {childList: true, subtree: true});