VRChat Web Pages Extender

Add features into VRChat Web Pages and improve user experience.

目前为 2023-01-03 提交的版本。查看 最新版本

// ==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://gf.qytechs.cn/users/137
// @version     2.13.0
// @match       https://vrchat.com/home
// @match       https://vrchat.com/home?*
// @match       https://vrchat.com/home#*
// @match       https://vrchat.com/home/*
// @require     https://gf.qytechs.cn/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://gf.qytechs.cn/scripts/371331
// ==/UserScript==

/*global Gettext, _, h, GreasemonkeyUtils */

'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); //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;

/**
 * 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',
	},
};

/**
 * 一つのブックマークグループの最大登録数。
 * @constant {Object.<number>}
 */
const MAX_FRIEND_FAVORITE_COUNT_PER_GROUP = 150;

/**
 * @type {Function}
 * @access private
 */
let resolveUserDetails;

/**
 * @type {Promise.<Object>}
 * @access private
 */
const userDetails = new Promise(function (resolve) {
	resolveUserDetails = resolve;
});

/**
 * キーにワールドIDを持つ連想配列。
 * @type {Object.<(string|string[]|boolean|number|Object.<(string|number)>[]|(string|number)[][]|null)>}
 */
const worlds = { };

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) {
		resolveUserDetails(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);
		}
	}
});

/**
 * ログインしているユーザーの情報を取得します。
 * @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 {void}
 */
function insertUpdateStatusControl()
{
	if (document.getElementsByName('update-status')[0]) {
		// すでに挿入済みなら
		return;
	}

	const editStatusDescriptionControl = document.querySelector('[role="button"][title="Edit Status"]');
	if (!editStatusDescriptionControl) {
		return;
	}

	editStatusDescriptionControl.insertAdjacentHTML('beforebegin', `<select name="update-status">
		<option value="">Select Status</option>
		${Object.keys(STATUSES)
			.map(status => h`<option value="${status}">⬤ ${STATUSES[status].label}</option>`).join('')}
	</select>`);
	editStatusDescriptionControl.previousElementSibling.addEventListener('change', async function (event) {
		if (event.target.value === '') {
			return;
		}

		event.target.disabled = true;

		try {
			const response = await fetch(location.pathname.replace('/home/user/', '/api/1/users/'), {
				method: 'PUT',
				headers: {'content-type': 'application/json'},
				credentials: 'same-origin',
				body: JSON.stringify({ status: event.target.value }),
			});
			if (!response.ok) {
				return Promise.reject(
					new Error(`${response.status}  ${response.statusText}\n${await response.text()}`),
				);
			}
		} catch (exception) {
			showError(exception);
		} finally {
			event.target.value = '';
			event.target.disabled = false;
		}
	});
}

/**
 * フレンドのブックマーク登録/解除ボタンの登録数表示を更新します。
 * @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 sibling = homeContent.querySelector('[role="group"]');
	if (!sibling) {
		return;
	}

	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];

	if (!(await getUserDetails()).friends.includes(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();
		}
	}
	sibling.insertAdjacentHTML('afterend', `<div role="group" class="w-100 btn-group-lg btn-group-vertical mt-4"
		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>
			&#xA0;<span class="name">${groupNameDisplayNamePairs[tag]}</span>
			&#xA0;<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;
			}
		}
	});
}

/**
 * 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 friendCount = location.querySelector('[aria-label="Invite Me"]').parentElement.previousElementSibling;
		counts = friendCount.cloneNode();
		counts.classList.add('instance-user-count-and-capacity');
		counts.style.whiteSpace = 'nowrap';
		friendCount.before(counts);
	}
	counts.textContent = (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>
			/*====================================
				Edit Profile
			*/
			` + Object.keys(STATUSES).map(status => `
				[name="update-status"].${CSS.escape(status.replace(' ', '-'))},
				[name="update-status"] option[value=${CSS.escape(status)}] {
					color: var(${STATUSES[status].color});
				}
			`).join('') + `

			/*====================================
				フレンドのユーザーページ
			*/
			.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);
						}
						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.parentElement.classList.contains('home-content'))
				|| /* ユーザーページ間を移動したとき */ mutation.type === 'characterData'
					&& mutation.target.parentElement.matches('.subheader *')) {
				if (location.pathname.startsWith('/home/user/')) {
					// ユーザーページ
					insertUpdateStatusControl();
					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/usr_"]').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/usr_"]').text;
					document.title = `${name} By ${author} - VRChat`;
				} else if (location.pathname.startsWith('/home/group/')) {
					// グループページ
					const name = document.querySelector('.home-content h2').textContent;
					const shortCodeAndDiscriminator
						= document.querySelector('[href^="https://vrc.group/"]').textContent;
					document.title = `${name} ⁂ ${shortCodeAndDiscriminator} - VRChat`;
				}
				break;
			} else if (mutation.target.title === 'Edit Status' && mutation.target.getAttribute('role') === 'button') {
				// 自分のユーザーページのStatus Description入力欄へ履歴を追加
				if (mutation.addedNodes[0]?.placeholder === 'Set a new status!') {
					mutation.target.insertAdjacentHTML('beforeend', `<datalist id="status-description-history">
						${(await getUserDetails())
							.statusHistory.map(statusDescription => h`<option>${statusDescription}</option>`).join('')}
					</datalist>`);
					mutation.addedNodes[0].setAttribute('list', 'status-description-history');
				} else if (mutation.removedNodes[0]?.placeholder === 'Set a new status!') {
					document.getElementById('status-description-history')?.remove();
				}
			}
		}
	}).observe(homeContents[0], {childList: true, characterData: true, subtree: true });

	observer.disconnect();
}).observe(document, {childList: true, subtree: true});

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址