Skip to content

Instantly share code, notes, and snippets.

@lbmaian
Last active May 6, 2023 14:58
Show Gist options
  • Save lbmaian/fef815da2628dc6e546ba1f7cd8212a5 to your computer and use it in GitHub Desktop.
Save lbmaian/fef815da2628dc6e546ba1f7cd8212a5 to your computer and use it in GitHub Desktop.
HoloTools Fixes and Enhancements
// ==UserScript==
// @name HoloTools Fixes and Enhancements
// @namespace https://gist.github.com/lbmaian/fef815da2628dc6e546ba1f7cd8212a5
// @downloadURL https://gist.github.com/lbmaian/fef815da2628dc6e546ba1f7cd8212a5/raw/holotools-fixes.user.js
// @updateURL https://gist.github.com/lbmaian/fef815da2628dc6e546ba1f7cd8212a5/raw/holotools-fixes.user.js
// @version 0.5
// @description Fixes for HoloTools
// @author lbmaian
// @match https://hololive.jetri.co/
// @match https://holodex.net/login
// @icon https://hololive.jetri.co/favicon-fbk.png
// @run-at document-start
// @grant GM_cookie
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// ==/UserScript==
(function() {
'use strict';
const DEBUG = false;
const logContext = '[Holodex Login Test]';
var debug;
if (DEBUG) {
debug = function(...args) {
console.debug(logContext, ...args);
};
} else {
debug = function() {};
}
function log(...args) {
console.log(logContext, ...args);
}
function info(...args) {
console.info(logContext, ...args);
}
function warn(...args) {
console.warn(logContext, ...args);
}
function error(...args) {
console.error(logContext, ...args);
}
//////// X-APIKEY GETTER ////////
// @match https://holodex.net/login is needed for below GM_cookie or Holodex login page fallback.
if (location.origin === 'https://holodex.net') {
// if (parent === self) { // note: can't check parent.location due to CORS security
// return;
// }
// For security reasons, document.cookie in cross-origin iframe doesn't contain SameSite (default) non-Secure cookies.
// It is available for non-iframes, but we might as well capture the API key rather than the HOLODEX_JWT cookie.
// debug('holodex.net document.cookie:', document.cookie);
// debug('holodex.net referrer:', document.referrer);
function proxyGetter(obj, prop, defineFunc) {
const origDesc = Object.getOwnPropertyDescriptor(obj, prop);
Object.defineProperty(obj, prop, {
...origDesc,
get: defineFunc(origDesc.get),
});
}
const origXhrResponseTextDesc = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'responseText');
proxyGetter(XMLHttpRequest.prototype, 'responseText', (origGetter) => function() {
const responseText = origGetter.call(this);
if (this.responseURL === 'https://holodex.net/api/v2/user/refresh') {
const response = JSON.parse(responseText);
debug('Holodex user refresh response:', response);
//parent.postMessage(response?.user?.api_key, 'https://hololive.jetri.co');
GM_setValue('holodexApiKey', response?.user?.api_key);
}
return responseText;
});
// function proxyProperty(obj, prop, descKey, defineFunc) {
// const origDesc = Object.getOwnPropertyDescriptor(obj, prop);
// if (!origDesc) {
// return warn('could not find', prop, 'on', obj);
// }
// const origFunc = origDesc[descKey];
// if (!origFunc) {
// return warn('could not find', descKey, 'for', prop, 'on', obj);
// }
// const func = defineFunc(origFunc, this);
// if (func.name !== origFunc.name) {
// Object.defineProperty(func, 'name', { value: origFunc.name }); // for console debugging purposes
// }
// Object.defineProperty(obj, prop, {
// ...origDesc,
// [descKey]: func,
// });
// }
// Spoof visibility APIs to always be visible, since Holodex checks for this?
// proxyProperty(Document.prototype, 'hidden', 'get', (orig) => function() {
// debug('document.hidden (orig):', orig.call(this));
// return true;
// });
// proxyProperty(Document.prototype, 'visibilityState', 'get', (orig) => function() {
// debug('document.visibilityState (orig):', orig.call(this));
// return 'visible';
// });
// proxyProperty(Document.prototype, 'hasFocus', 'value', (orig) => function hasFocus() {
// debug('document.hasFocus():', orig.call(this));
// return true;
// });
// proxyProperty(IntersectionObserverEntry.prototype, 'intersectionRatio', 'get', (orig) => function() {
// debug('IntersectionObserverEntry.intersectionRatio:', this, orig.call(this));
// return 1.0;
// });
// proxyProperty(IntersectionObserverEntry.prototype, 'isIntersecting', 'get', (orig) => function() {
// debug('IntersectionObserverEntry.isIntersecting:', this, orig.call(this));
// return true;
// });
// proxyProperty(IntersectionObserverEntry.prototype, 'isVisible', 'get', (orig) => function() {
// debug('IntersectionObserverEntry.isVisible:', this, orig.call(this));
// return true;
// });
return;
}
function getHolodexApiKey() {
return new Promise((resolve, reject) => {
//GM_setValue('holodexApiKey', null); // test note: uncomment to debug API key fetching
let holodexApiKey = GM_getValue('holodexApiKey');
if (holodexApiKey) {
debug('Holodex API key already stored');
return resolve(holodexApiKey);
}
//return reject('DEBUG TEST'); // test note: uncomment to debug Holodex login fallback
if (!window.GM_cookie || !GM_cookie.list) {
return reject('GM_cookie.list not supported');
}
GM_cookie.list({ url: 'https://holodex.net/login', name: 'HOLODEX_JWT' }, (cookies, error) => {
if (error) {
return reject('Error getting Holodex cookies: ' + error);
}
debug('Holodex cookies:', cookies);
const holodexAuthToken = cookies[0]?.value;
if (!holodexAuthToken) {
return reject('Could not find HOLODEX_JWT in cookies');
}
// Need to use this rather than native fetch/XHR for cross-origin requests with custom referer.
const url = 'https://holodex.net/api/v2/user/refresh';
function onerror(response) {
debug('Holodex user refresh response (error):', response);
let errorReason = '';
if (response.status) {
errorReason += ` with status ${response.status} (${response.request})`
}
if (response.error) {
errorReason += `: ${response.error}`;
}
const responseText = response.responseText;
if (responseText) {
errorReason += `: ${responseText}`;
}
reject(`Request to ${url} failed${errorReason}`);
}
GM_xmlhttpRequest({
url,
method: 'GET',
responseType: 'json',
headers: {
'authorization': 'BEARER ' + holodexAuthToken,
'referer': 'https://holodex.net/login',
},
onerror,
onload(response) {
if (response.status !== 200) {
return onerror(response);
}
debug('Holodex user refresh response:', response);
const holodexApiKey = response.response?.user?.api_key;
if (!holodexApiKey) {
reject(`Response from ${url} is missing API key: ${response.responseText}`);
} else {
resolve(holodexApiKey);
}
},
});
});
}).catch(e => {
debug(e, '- falling back to Holodex login');
// Not using hidden iframe technique due to potentially needing user input (to login)
// and to avoid potential CORS restrictions.
// let iframe = null;
let holodexLoginTab = null;
let holodexApiKeyListener = null;
return new Promise((resolve, reject) => {
//return reject('DEBUG TEST'); // test note: uncomment to debug user prompt fallback
// iframe = document.createElement('iframe');
// iframe.src = 'https://holodex.net/login';
// //iframe.referrerpolicy = 'no-referrer'; // doesn't work for bypassing CORS for document.cookie access
// // Ensure the Holodex iframe is hidden
// // display: none or visibility: hidden results in Holodex iframe's vue not properly loading;
// // workaround is to position the iframe behind existing body.
// //iframe.style.display = 'none';
// //iframe.style.visibility = 'hidden';
// iframe.style.position = 'fixed';
// iframe.style.zIndex = -1;
// function onMessage(evt) {
// if (evt.origin !== 'https://holodex.net') {
// return;
// }
// window.removeEventListener('message', onMessage);
// debug('retrieved Holodex API key from iframe:', evt.data);
// return resolve(evt.data);
// }
// window.addEventListener('message', onMessage);
// document.body.appendChild(iframe);
// debug('inserted temp Holodex login iframe');
//setTimeout(() => reject('timed out waiting for Holodex API key from iframe'), 2000);
holodexApiKeyListener = GM_addValueChangeListener('holodexApiKey', (key, oldValue, value, remote) => {
debug(key, 'changed from', oldValue, 'to', value);
if (!value) {
reject(key, 'is unexpectedly', value);
} else {
resolve(value);
}
});
holodexLoginTab = GM_openInTab('https://holodex.net/login', false);
holodexLoginTab.onclose = function(evt) {
reject('Holodex login window closed without fetching API key');
};
debug('Holodex login tab opened:', holodexLoginTab);
}).finally(() => {
// if (iframe) {
// iframe.remove();
// debug('removed temp Holodex login iframe');
// }
if (holodexApiKeyListener) {
GM_removeValueChangeListener(holodexApiKeyListener);
}
if (holodexLoginTab && !holodexLoginTab.closed) {
holodexLoginTab.onclose = null;
holodexLoginTab.close();
debug('Holodex login window closed');
}
});
}).catch(e => {
debug(e, '- user prompt fallback');
const holodexApiKey = prompt('Enter Holodex API key:');
if (!holodexApiKey) {
throw 'User did not enter Holodex API key';
} else {
return holodexApiKey;
}
}).then(holodexApiKey => {
if (!holodexApiKey) {
}
GM_setValue('holodexApiKey', holodexApiKey);
debug('Holodex API key:', holodexApiKey);
return holodexApiKey;
}).catch(e => {
alert(e);
throw e;
});
}
// const holodexApiKey = GM_getValue('holodexApiKey');
const holodexApiKeyPromise = getHolodexApiKey();
//////// HASH CHANGE LISTENER ////////
// Holotools is an SPA that has routes URL hashes (#abc) to a virtual page without reloading the page.
// Leave status/channels "page" (https://hololive.jetri.co/#/status) untouched, i.e. don't redirect to Holodex APIs.
// This requires reloading the page whenever navigating to or from #/status.
// Unfortunately, there's no event that can reliably capture all changes to location.hash,
// due to history.pushState not firing an event (neither hashchange nor popstate),
// hence a hack to poll location.hash changes upon any DOM mutation that the host app performs alongside the hash change.
// Still use the hashchange event handler to listen to location property assignment.
function listenForHashChange(handler) {
let curHash = location.hash;
function pollingHashMonitor() {
new MutationObserver(mutations => {
const oldHash = curHash;
const newHash = location.hash;
if (oldHash !== newHash) {
//debug('polling detected location.hash change from %s to %s', oldHash, newHash);
curHash = newHash;
handler(oldHash, newHash);
}
}).observe(document.body, {
childList: true,
subtree: true,
});
}
if (document.body) {
pollingHashMonitor();
} else {
window.addEventListener('DOMContentLoaded', pollingHashMonitor);
}
window.addEventListener('hashchange', evt => {
const i = evt.oldURL.indexOf('#');
const oldHash = i < 0 ? '' : evt.oldURL.substring(i);
const newHash = location.hash;
//debug('hashchange detected location.hash change from %s to %s', oldHash, newHash);
curHash = newHash;
handler(oldHash, newHash);
});
}
listenForHashChange((oldHash, newHash) => {
//log('URL hash changed from %s to %s', oldHash, newHash);
if (oldHash === '#/status' || newHash === '#/status') {
location.reload();
}
});
if (location.hash === '#/status') {
log('Holotools fixes not applied to status page:', location.href);
return;
}
//////// HTTP REQUEST INTERCEPTOR ////////
// Extremely basic sscanf
// Parameters:
// - formatStr: format string, only supports:
// %%, %d, %s (matches regex /[^/?&.]+/ to match url path components & query parameters)
// trailing ... (format string only needs to match start of url)
// If doesn't include query string (no ?), allows optional ending /
// - searchStr: string to search
// Returns: [match for 1st format specifier, match for 2nd format specifier, ...] or null if no match found
var sscanfUrl = (() => {
const cache = new Map();
const REGEX = 0;
const STRING = 1;
const FULLSTRING = 2;
return function sscanfUrl(formatStr, searchStr) {
let cached = cache.get(formatStr);
if (!cached) {
const cacheKey = formatStr;
const fullMatch = !formatStr.endsWith('...');
if (!fullMatch) {
formatStr = formatStr.substring(0, formatStr.length - 3);
}
const appendOptSlash = formatStr.includes('?') && formatStr.at(-1) !== '/';
if (formatStr.replaceAll('%%').includes('%')) {
formatStr = formatStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // from https://stackoverflow.com/a/6969486/344828
const formatSpecifiers = [];
formatStr = '^' + formatStr.replace(/%./g, m => {
if (m === '%%') {
return '%';
} else if (m === '%d') {
formatSpecifiers.push('d');
return '(-?\\d+)';
} else if (m === '%s') {
formatSpecifiers.push('s');
return '([^/?&.]+)';
} else {
throw Error(`format specifier '${m}' unsupported`);
}
});
if (appendOptSlash) {
formatStr += '/?';
}
if (fullMatch) {
formatStr += '$';
} else {
formatStr += '(.*)$';
}
cached = {type: REGEX, value: new RegExp(formatStr), formatSpecifiers};
} else {
cached = {type: fullMatch ? FULLSTRING : STRING, value: formatStr, formatSpecifiers: null};
}
debug('sscanfUrl caching for key', cacheKey, ':', cached);
cache.set(cacheKey, cached);
}
const value = cached.value;
switch (cached.type) {
case REGEX: {
const m = value.exec(searchStr);
if (m) {
return cached.formatSpecifiers.map((specifier, i) => {
const matched = m[i + 1];
if (specifier === 'd') {
return Number.parseInt(matched);
} else {
return matched;
}
});
} else {
return null;
}
}
case STRING:
if (searchStr.startsWith(value)) {
return [searchStr.slice(value.length)];
} else {
return null;
}
case FULLSTRING:
return searchStr === value ? [] : null;
}
}
})();
// Extremely basic sprintf, supporting:
// %%, %d, %s
function sprintf(formatStr, ...args) {
let i = 0;
let str = formatStr.replaceAll(/%./g, m => {
if (m === '%%') {
return '%';
} else if (m === '%d') {
return Math.trunc(args[i++]);
} else if (m === '%s') {
return args[i++];
} else {
throw Error(`format specifier '${m}' unsupported`);
}
});
if (formatStr.endsWith('...')) {
str = formatStr.slice(0, -3) + args[i];
}
return str;
}
// For debug logging: workaround for Chromium currently unable to show Headers contents in log.
if (DEBUG) {
Object.defineProperty(Headers.prototype, 'asMap', {
configurable: true,
enumerable: true,
get() {
return new Map(this);
},
});
}
// Allows interception of HTTP requests
// register method accepts a single configuration object with entries:
// - sourceUrlFormat: sscanfUrl format string for source URL (URL to intercept)
// - sourceUrlFormats: same as sourceUrlFormat except an array of such strings
// Either sourceUrlFormat or sourceUrlFormats must be specified.
// - destinationUrlFormat: sscanfUrl/sprintf format string for destination URL (URL to redirect to)
// - destinationUrlFormats: same as destinationUrlFormat except an array of such strings
// - transformRequest: function that transforms the request, returning one of:
// - string URL
// - Request
// - Request options object (2nd optional parameter of Request constructor)
// - Promise that resolves to one of the above
// and given parameters:
// - this: this configuration object
// - Request object
// - destinationUrlFormats if destinationUrlFormats is specified, else destinationUrlFormat
// - match for 1st format specifier in sourceUrlFormat
// - match for 2nd format specifier in sourceUrlFormat
// - ...
// If destinationUrlFormats is specified, transformRequest must also be specified.
// If destinationUrlFormat is specified instead, transformRequest can be omitted and defaults to sprintf.
// Note on proxying XMLHttpRequest (XHR):
// The actual XHR.open/setRequestHeader calls are delayed until XHR.send,
// which first calls transformRequest with a synthetic Request constructed from arguments passed to
// XHR.open/setRequestHeader (method, url, request headers).
// The returned URL/Request/Request-options are translated into arguments to XHR.open/setRequestHeader/send.
// If transformRequest returns a promise, async passed to XHR.open must be false,
// and the promise is asynchronously resolved before the actual XHR instance methods are called,
// and if the promise is rejected (i.e. throws), then an error event is dispatched to the XHR instance.
// - transformResponse: function that transforms the response text, presumably in the same format that the
// original request would've returned; parameters are:
// - this: this configuration object
// - response text for HTTP request to destination URL (XMLHttpRequest.responseText/Response.text())
// - destination URL (XMLHttpRequest.responseURL/Response.url)
// - match for 1st format specifier in destinationUrlFormat
// - match for 2nd format specifier in destinationUrlFormat
// - ...
// TODO: Somehow transform Response object instead? It's an async object though
class HttpRequestInterceptor {
stats = null;
statsKeyFunc = HttpRequestInterceptor.defaultStatsKeyFunc;
#origDescs = null;
#sourceToConfig = new Map();
#destToConfig = new Map();
// TODO: Make this private when TamperMonkey updates its ESLint to support it
static symRequestInfo = Symbol('requestInfo');
constructor() {
//this.stats = new Map(); // uncomment to record stats
}
register(config) {
debug('HttpRequestInterceptor.register:', config);
if (!config.sourceUrlFormat && !config.sourceUrlFormats) {
throw Error('either sourceUrlFormat or sourceUrlFormats must be specified');
}
if (!config.transformRequest && config.destinationUrlFormats) {
throw Error('transformRequest must specified if destinationUrlFormats is specified');
}
if (!config.transformResponse) {
throw Error('transformResponse must be specified');
}
const destInfo = {
format: config.destinationUrlFormat,
formats: config.destinationUrlFormats,
transform: config.transformRequest,
};
const sourceUrlFormats = config.sourceUrlFormats ?? [config.sourceUrlFormat];
for (const sourceUrlFormat of sourceUrlFormats) {
this.#sourceToConfig.set(sourceUrlFormat, config);
}
const destUrlFormats = config.destinationUrlFormats ?? (config.destinationUrlFormat ? [config.destinationUrlFormat] : sourceUrlFormats);
for (const destUrlFormat of destUrlFormats) {
this.#destToConfig.set(destUrlFormat, config);
}
}
get enabled() {
return this.#origDescs !== null;
}
enable() {
if (this.enabled) {
return;
}
log('enabling HTTP request interception for url:', document.URL);
this.#origDescs = [];
// Capture orig methods needed for XHR.send proxy
const { open: origOpen, setRequestHeader: origSetRequestHeader } = XMLHttpRequest.prototype;
this.#proxyMethod(XMLHttpRequest.prototype, 'open', (origOpen, interceptor) => function open(method, url) {
debug('XMLHttpRequest.open', ...arguments);
for (const [sourceUrlFormat, config] of interceptor.#sourceToConfig) {
const matched = sscanfUrl(sourceUrlFormat, url);
if (matched) {
debug('matched', matched, 'for source URL format', sourceUrlFormat, 'and config', config);
this[HttpRequestInterceptor.symRequestInfo] = {
config,
openArgs: arguments,
matched,
headers: {},
};
// Don't call orig open (will be done in send proxy)
//debug('XHR.open delayed:', this[HttpRequestInterceptor.symRequestInfo]);
return;
}
}
origOpen.apply(this, arguments);
});
this.#proxyMethod(XMLHttpRequest.prototype, 'setRequestHeader', (origSetRequestHeader) => function setRequestHeader(header, value) {
const info = this[HttpRequestInterceptor.symRequestInfo];
if (info) {
// Don't call orig setRequestHeader (will be done in send proxy)
//debug('XHR.setRequestHeader delayed:', header, value);
info.headers[header] = value;
} else {
origSetRequestHeader.call(this, header, value);
}
});
this.#proxyMethod(XMLHttpRequest.prototype, 'send', (origSend) => {
return function send(body) {
debug('XMLHttpRequest.send', ...arguments);
const xhr = this; // to avoid `this` in nested function below
const info = xhr[HttpRequestInterceptor.symRequestInfo];
if (info) {
delete xhr[HttpRequestInterceptor.symRequestInfo]; // probably unnecessary, but ensures no memleak
//debug('XHR.open/setRequestHeader no longer delayed:', info);
const { config, openArgs } = info;
const request = new Request(openArgs[1], {
method: openArgs[0],
headers: info.headers,
credentials: xhr.withCredentials ? 'include' : 'same-origin',
// Note: there's no equivalent of no-cors mode for XHR
});
const format = config.destinationUrlFormats ?? config.destinationUrlFormat ?? sourceUrlFormat;
const transformRequest = config.transformRequest ?? sprintf;
let dest = transformRequest.call(config, request, format, ...info.matched);
if (dest) {
function asyncError(error) {
const evt = new ProgressEvent('error');
evt.detail = error; // custom property in case user needs to access the error
xhr.dispatchEvent(evt);
}
function sendRequest(dest) {
if (typeof(dest) === 'string') {
if (dest.url !== request.url) {
dest = new Request(dest, request);
}
} else if (dest instanceof Request) {
// Use dest as-is
} else if (dest !== null && typeof(dest) === 'object') {
if (typeof(dest.then) === 'function') { // Promise
if (openArgs[2] === false) {
throw new TypeError('dest must not be a Promise when async is false');
}
return dest.then(sendRequest).catch(asyncError);
}
dest = new Request(dest.url, dest);
} else {
throw new TypeError(
'dest must be string, Request, Request options object, ' +
'or Promise that resolves to one of those');
}
debug('redirecting', request, 'to', dest);
openArgs[0] = dest.method;
openArgs[1] = dest.url;
origOpen.apply(xhr, openArgs);
for (const [header, value] of dest.headers) {
origSetRequestHeader.call(xhr, header, value);
}
origSend.call(xhr, body);
}
return sendRequest(dest);
}
}
origSend.apply(xhr, arguments);
};
});
this.#proxyXhrResponseProperty('response');
this.#proxyXhrResponseProperty('responseText');
this.#proxyXhrResponseProperty('responseXML');
this.#proxyMethod(window, 'fetch', (origFetch, interceptor) => function fetch(resource, options) {
debug('fetch', ...arguments);
const request = !(resource instanceof Request) || options ? new Request(resource, options) : resource;
for (const [sourceUrlFormat, config] of interceptor.#sourceToConfig) {
const matched = sscanfUrl(sourceUrlFormat, request.url);
debug('fetch match', sourceUrlFormat, request.url, matched);
if (matched) {
debug('matched', matched, 'for source URL format', sourceUrlFormat, 'and config', config);
const format = config.destinationUrlFormats ?? config.destinationUrlFormat ?? sourceUrlFormat;
const transformRequest = config.transformRequest ?? sprintf;
let dest = transformRequest.call(config, request, format, ...matched);
if (dest) {
return new Promise((resolve, reject) => {
function fetchRequest(dest) {
if (typeof(dest) === 'string') {
if (dest.url !== request.url) {
dest = new Request(dest, request);
}
} else if (dest instanceof Request) {
// Use dest as-is
} else if (dest !== null && typeof(dest) === 'object') {
if (typeof(dest.then) === 'function') { // Promise
return dest.then(fetchRequest).catch(reject);
}
dest = new Request(dest.url, dest);
} else {
return reject(new TypeError(
'dest must be string, Request, Request options object, ' +
'or Promise that resolves to one of those'));
}
debug('redirecting', request, 'to', dest);
resolve(origFetch.call(window, dest));
}
fetchRequest(dest);
});
}
break;
}
}
return origFetch.call(window, request);
});
this.#proxyResponsePropertyStatsOnly('body', 'get'); // TODO: implement non-binary handler for body stream if necessary, responseType should be body properties
this.#proxyResponsePropertyStatsOnly('arrayBuffer'); // assumed to always be binary
this.#proxyResponsePropertyStatsOnly('blob'); // TODO: implement non-binary handler (blob.text) if necessary, responseType should be the blob properties
this.#proxyResponsePropertyStatsOnly('formData'); // TODO: implement non-binary handler (non-file) if necessary
this.#proxyResponseProperty('json');
this.#proxyResponseProperty('text');
}
disable() {
if (this.enabled) {
log('disabling HTTP request interception for url:', document.URL);
for (const [obj, prop, origDesc] of this.#origDescs) {
Object.defineProperty(obj, prop, origDesc);
}
this.#origDescs = null;
}
}
#proxyMethod(obj, prop, defineFunc) {
return this.#proxyProperty(obj, prop, 'value', defineFunc);
}
#proxyGetter(obj, prop, defineFunc) {
return this.#proxyProperty(obj, prop, 'get', defineFunc);
}
#proxySetter(obj, prop, defineFunc) {
return this.#proxyProperty(obj, prop, 'set', defineFunc);
}
#proxyProperty(obj, prop, descKey, defineFunc) {
const origDesc = Object.getOwnPropertyDescriptor(obj, prop);
if (!origDesc) {
return warn('could not find', prop, 'on', obj);
}
// XXX: Possible bug in TamperMonkey where for native methods of window object, the following are the same:
// Object.getOwnPropertyDescriptor(window, prop).value
// Object.getOwnPropertyDescriptor(unsafeWindow, prop).value
// unsafeWindow[prop]
// which can result in "TypeError: Failed to execute '<method>' on 'Window': Illegal Invocation"
// when invoking unsafeWindow[prop].call(window, ...)
// for certain native methods and certain arguments (e.g. window.fetch with Request argument).
// Workaround is to special-case window methods to just use window[prop]
// instead of Object.getOwnPropertyDescriptor(window, prop).value.
const origFunc = obj === window && descKey === 'value' ? obj[prop] : origDesc[descKey];
if (!origFunc) {
return warn('could not find', descKey, 'for', prop, 'on', obj);
}
this.#origDescs.push([obj, prop, origDesc]);
const func = defineFunc(origFunc, this);
if (func.name !== origFunc.name) {
Object.defineProperty(func, 'name', { value: origFunc.name }); // for console debugging purposes
}
Object.defineProperty(obj, prop, {
...origDesc,
[descKey]: func,
});
}
#handleResponse(url, srcMethod, response, responseType, contentType) {
debug('handling intercepted HTTP request:\n', url, srcMethod, responseType, contentType, '\nresponse:\n', response);
if (this.stats) {
const statsKey = this.statsKeyFunc(url, srcMethod, responseType, contentType);
this.stats.set(statsKey, (this.stats.get(statsKey) || 0) + 1);
}
// TODO: handle json, document (XHR-only), formData (fetch-only)
if (responseType !== 'text') {
debug('not applying response handler for non-text responseType');
return response;
}
for (const [destUrlFormat, config] of this.#destToConfig) {
const m = sscanfUrl(destUrlFormat, url);
if (m) {
//debug(response handler for', url, ':', config.transformResponse);
if (config.transformResponse) {
return config.transformResponse(response, url, ...m);
}
break;
}
}
return response;
}
#proxyXhrResponseProperty(responseProp) {
this.#proxyGetter(XMLHttpRequest.prototype, responseProp, (origGetter, interceptor) => function() {
const srcMethod = 'XMLHttpRequest.' + responseProp;
debug(srcMethod, 'for', this);
return interceptor.#handleResponse(this.responseURL, srcMethod, origGetter.call(this), this.responseType || 'text',
this.getResponseHeader('content-type'));
});
}
#proxyResponseProperty(responseProp, descKey='value') {
this.#proxyProperty(Response.prototype, responseProp, descKey, (origGetter, interceptor) => function() {
debug('fetch', responseProp, 'for', this);
return origGetter.call(this).then((response) => {
return interceptor.#handleResponse(this.url, 'fetch', response, responseProp, this.headers.get('content-type'));
});
});
}
#proxyResponsePropertyStatsOnly(responseProp, descKey='value') {
if (this.stats) {
this.#proxyProperty(Response.prototype, responseProp, descKey, (origGetter, interceptor) => function() {
const statsKey = interceptor.statsKeyFunc(this.url, 'fetch', responseProp, this.headers.get('content-type'));
interceptor.stats.set(statsKey, (interceptor.stats.get(statsKey) || 0) + 1);
return origGetter.call(this);
});
}
}
static defaultStatsKeyFunc(url, responseProp, responseType, contentType) {
const protocolIdx = url.indexOf('://');
if (protocolIdx !== -1) {
url = url.substring(protocolIdx + 3);
}
const queryIdx = url.indexOf('?');
if (queryIdx !== -1) {
url = url.substring(0, queryIdx);
}
if (contentType) {
const mimeParamIdx = contentType.indexOf(';');
if (mimeParamIdx !== -1) {
contentType = contentType.substring(0, mimeParamIdx);
}
}
if (responseType) {
const mimeParamIdx = responseType.indexOf(';');
if (mimeParamIdx !== -1) {
responseType = responseType.substring(0, mimeParamIdx);
}
}
if (contentType !== responseType) {
responseType += '(' + contentType + ')';
}
return url + ',' + responseProp + ',' + responseType;
}
}
//////// HOLOTOOLS API => HOLODEX API ////////
const allChannels = new Map(); // channel id => holotools channel format
let joinedChannelIds = null; // "channel1,channel2,..." or null if needs to be recomputed (lazily computed)
function holodexToHolotoolsVideo(video) {
if (video.mentions) {
// TODO: update tooltip to include mentions somehow? via __vue__ property?
}
const channel = holodexToHolotoolsChannel(video.channel);
return {
id: video.id, // used for uniqueness checks
yt_video_key: video.id,
title: video.title,
status: video.status,
// TODO: move out and only sort those with same live_schedule
// Streams are sorted by live_end then live_start then live_schedule.
// Of those, it's only likely for live_schedule to be the same across multiple upcoming streams,
// and since the sort is unstable, it leads to upcoming streams with the same scheduled time being ordered inconsistently.
// So we'll be adding a small channel-based offset at the sort's granularity (seconds) to ensure consistent ordering.
live_schedule: video.start_scheduled ? new Date(Date.parse(video.start_scheduled) + channel.ordinal * 1000).toISOString() : null,
live_start: video.start_actual ?? null,
live_end: video.end_actual ?? null,
live_viewers: video.live_viewers ?? null,
channel: channel,
};
}
function holodexToHolotoolsChannel(channel, forceUpdate = false) {
const cachedChannel = allChannels.get(channel.id);
if (!forceUpdate && cachedChannel) {
return cachedChannel;
}
const holotoolsChannel = {
id: channel.id, // possibly unused
yt_channel_id: channel.id,
name: channel.english_name ?? channel.name, // channel.name is actual name, channel.english_name is manually translated name
description: channel.description ?? "", // omitted in holodex channels query, accessed but never used in holotools?
photo: channel.photo,
published_at: channel.published_at ?? "", // omitted in holodex channels query, only used in holotools status/channels page?
twitter_link: channel.twitter ?? "",
view_count: channel.view_count ?? 0, // omitted in holodex channels query, only used in holotools status/channels page?
subscriber_count: channel.subscriber_count ?? 0, // omitted in holodex videos query, only used in holotools status/channels page?
video_count: channel.video_count ?? 0, // omitted in holodex videos query, only used in holotools status/channels page?
video_original: channel.video_count ?? 0, // no equivalent in holodex, only used in holotools status/channels page?
};
// ordinal is a custom field, used in ordering upcoming videos with the same scheduled time.
if (cachedChannel) {
debug('channel updated in cache:', holotoolsChannel);
holotoolsChannel.ordinal = cachedChannel.ordinal;
} else {
debug('channel added to cache:', holotoolsChannel);
holotoolsChannel.ordinal = allChannels.size;
}
if (!allChannels.has(channel.id)) {
allChannels.set(channel.id, holotoolsChannel);
joinedChannelIds = null;
}
return holotoolsChannel;
}
function jsonParse(str) {
try {
return JSON.parse(str);
} catch (e) {
throw Error('could not parse JSON from: ' + str);
}
}
const interceptor = new HttpRequestInterceptor();
window.httpRequestInterceptor = interceptor; // for devtools console access
interceptor.register({
sourceUrlFormats: [
'https://api.holotools.app/v1/live?max_upcoming_hours=2190&hide_channel_desc=1',
'https://jetrico.sfo2.digitaloceanspaces.com/hololive/youtube.json'
],
destinationUrlFormat: 'https://holodex.net/api/v2/live?org=Hololive&include=mentions',
async transformRequest(request, holodexUrl) {
request.headers.set('X-APIKEY', await holodexApiKeyPromise);
// transformRequest(request, holodexUrl) {
// request.headers.set('X-APIKEY', holodexApiKey);
return new Request(holodexUrl, request);
},
transformResponse(responseText) {
const holodexVideos = jsonParse(responseText);
debug('Holodex live format:', holodexVideos);
const holotoolsVideos = {
live: [],
upcoming: [],
past: [],
};
for (const holodexVideo of holodexVideos) {
// TODO: for temp channels, filter for only added video ids
holotoolsVideos[holodexVideo.status].push(holodexToHolotoolsVideo(holodexVideo));
}
holotoolsVideos.ended = holotoolsVideos.past;
delete holotoolsVideos.past;
debug('converted to Holotools live format:', holotoolsVideos);
return JSON.stringify(holotoolsVideos);
}
});
let isFirstChannelsQuery = true;
let channelsToExclude = new Set();
interceptor.register({
sourceUrlFormat: 'https://api.holotools.app/v1/channels?offset=%d&limit=%d',
destinationUrlFormat: 'https://holodex.net/api/v2/channels?type=vtuber&org=Hololive&sort=published_at&offset=%d&limit=%d',
async transformRequest(request, holodexUrlFormat, offset, limit) {
request.headers.set('X-APIKEY', await holodexApiKeyPromise);
// transformRequest(request, holodexUrlFormat, offset, limit) {
// request.headers.set('X-APIKEY', holodexApiKey);
if (isFirstChannelsQuery) {
isFirstChannelsQuery = false;
if (offset !== 0) {
// Assume that holotools v1 channels queries always return the following YT channel ids in order
channelsToExclude = new Set([
"UCD8HOxPs4Xvsm8H0ZxXGiBw",
"UCa9Y57gfeY0Zro_noHRVrnw",
"UCwL7dgTxKo8Y4RFIKWaf8gA",
"UChAnqc_AY5_I3Px5dig3X1Q",
"UC0TXe_LYZ4scaW2XMyi5_kw",
"UC1CfXB_kRs3C-zaeTG3oGyg",
"UCKeAhJvy8zgXWbh9duVjIaQ",
"UCsehvfwaWF6nWuFnXI0AqZQ",
"UCJFZiqLMntJufDCHc6bQixg",
"UCXTpFs_3PqI41qX2d9tL2Rw",
"UCp-5t9SrOQwXMU7iIjQfARg",
"UC-hM6YJuNYVAmUWxeIr9FeA",
"UCdn5BQ06XqgXoAxIhbqw5Rg",
"UCS9uQI-jC3DE0L4IpXyvr6w",
"UCHj_mh57PVMXhAUDphUQDFA",
"UCGNI4MENvnsymYjKiZwv9eg",
"UC9mf_ZVpouoILRY9NUIaK-w",
"UCEzsociuFqVwgZuMaZqaCsg",
"UCAoy6rzhSf4ydcYjJw3WoVg",
"UCANDOlYTJT7N5jlRC3zfzVA",
"UCLbtM3JZfRTg8v2KGag-RMw",
"UCP0BspO_AMEe3aQqqpo89Dg",
"UCvaTdHTWBGv3MKj3KVqJVCw",
"UCQ0UDLQCjY0rmuxCDE38FGg",
"UCqm3BQLlJfvkTsX_hvm0UmA",
"UCdyqAaZDKHXg4Ahi7VENThQ",
"UCl_gCybOJRIgOXw6Qb4qJzQ",
"UC6t3-_N8A6ME1JShZHHqOMw",
"UCZlDXzGoo7d44bwdNObFacg",
"UC1opHUrw8rvnsadT-iGp7Cg",
"UCOyYb1c43VlX9rc_lT6NKQw",
"UCFTLzh12_nrtzqBPsTCqenA",
"UC7fk0CB07ly8oSl0aqKkqFg",
"UC1suqwovbL1kzsoaZgFZLKg",
"UCp3tgHXw_HI0QMk1K8qh3gQ",
"UC1DCedRgGHBdm81E1llLhOQ",
"UCNVEsYbiZjH5QLmGeSgTSzg",
"UCvInZx9h3jC2JzsIzoOebWg",
"UCDqI2jOz0weumE8s7paEk6g",
"UChSvpZYRPh0FvG4SJGSga3g",
"UCp6993wxpyDPHUpavwDFqgg",
"UCZgOv3YDEs-ZnZWDYVwJdmA",
"UCvzGlP9oQwU--Y0r9id_jnA",
"UCCzUftO8KOVkV4wQG1vkUvg",
"UC1uv2Oq6kNxgATlCiez59hw",
"UC5CwaMl1eIgY8h02uZw7u8A",
"UCFKOVgVbGmX65RxO3EtH3iw",
"UCAWSyEs_Io8MtpY3m-zqILA",
"UCUKD-uaobj9jiqB-VXt71mA",
"UCgZuwn-O7Szh9cAgHqJ6vjw",
"UCK9V2B22uJYu3N7eR_BT9QA",
"UCgNVXGlZIFK96XdEY20sVjg",
"UCL_qhgtOy0dy1Agp8vkySQg",
"UCHsx4Hqa-1ORjQTh9TYDhww",
"UCMwGHR0BTZuLsmjY_NT5Pwg",
"UCoSrY_IQQVpmIRZ9Xf-y93g",
"UCyl1z3jo3XHR1riLFKG5UAg",
"UCotXwY6s8pWmuWd_snKYjhg",
"UCfrWoRGlawPQDQxxeIDRP0Q",
"UCWsfcksUUpoEvhia0_ut0bA",
"UCYz_5n-uDuChHtLo7My1HnQ",
"UC727SQYUvx5pDDGQpTICNWg",
"UChgTyjG-pdNvxxhdsXfHQ5Q",
"UC8rcEBzJSleTkf_-agPM20g",
"UCsUj0dszADCGbF3gNrQEuSQ",
"UCO_aKKYxn4tvrqPjcTzZ6EQ",
"UCmbs8T6MWqUHP1tIQvSgKrg",
"UC3n5uGu18FoCy23ggWWp8tA",
"UCgmPnx-EEeOrZSg5Tiw7ZRQ",
"UCENwRMx5Yh42zWpzURebzTw",
"UCs9_O1tRPMQTHQ-N_L6FU2g",
"UC6eWCld0KwmyHFbAqK3V-Rw",
"UCIBY1ollUsauvVi4hW4cumw",
"UC_vMYWcDjmfdpH6r4TTn1MQ",
].slice(0, limit));
log('Holotools fixes injected too late - will exclude %d already-Holotools-fetched channels', channelsToExclude.size);
offset = 0;
limit = 100;
}
}
// return new Request(sprintf(holodexUrlFormat, offset, limit), request);
return sprintf(holodexUrlFormat, offset, limit);
},
transformResponse(responseText, url, offset, limit) {
const holodexChannels = jsonParse(responseText);
debug('Holodex channels format:', holodexChannels);
const count = holodexChannels.length;
const holotoolsChannels = {
count: count,
// Holodex doesn't provide total, so if count == limit, can't tell if we've actually reached the end,
// so trick holotools to fetch next batch by stating total > offset + limit
total: count < limit ? offset + count : offset + limit + 1,
channels: [],
};
const excludedChannels = new Set();
for (const channel of holodexChannels) {
const holotoolsChannel = holodexToHolotoolsChannel(channel, true); // called regardless of exclusion to populate cache
if (channelsToExclude.has(channel.id)) {
excludedChannels.add(channel.id);
} else {
holotoolsChannels.channels.push(holotoolsChannel);
}
}
debug('converted to Holotools channels format:', holotoolsChannels);
if (channelsToExclude.size > 0) {
debug('excluded already-Holotools-fetched channels:', excludedChannels);
channelsToExclude.clear();
}
return JSON.stringify(holotoolsChannels);
}
});
interceptor.enable();
//////// HOLOTOOLS UI IMPROVEMENTS ////////
if (location.hash.startsWith('#/watch')) {
// TODO: Fade out already added videos in add window.
function waitUntilElement(selector, root) {
root ??= document;
return new Promise(resolve => {
const element = document.querySelector(selector);
if (element) {
return resolve(element);
}
new MutationObserver((records, observer) => {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
return resolve(element);
}
}).observe(root, {
childList: true,
subtree: true
});
});
}
document.addEventListener('DOMContentLoaded', async () => {
const style = document.createElement('style');
style.textContent = `
.highlight-box::after {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
/* border: 5px solid red; */
box-shadow: inset 0 0 5px 5px red;
z-index: 2;
pointer-events: none;
}`;
document.head.appendChild(style);
const [liveVideosContainer, playerContainer] = await Promise.all([
waitUntilElement('div.live-videos', document.body),
waitUntilElement('div.player-container', document.body),
]);
debug('live-videos:', liveVideosContainer);
debug('player-container:', playerContainer);
function findLiveBox(target) {
while (target && target !== liveVideosContainer) {
if (target.tagName === 'DIV' && target.classList.contains('live-box')) {
return target;
}
target = target.parentNode;
}
return null;
}
const ytimgRegex = /^https:\/\/i\.ytimg\.com\/vi\/([A-Za-z0-9\-_]{11})\//;
function getVideoId(liveBox) {
const img = liveBox.querySelector(':scope > div.live-image > img');
return ytimgRegex.exec(img?.src)?.[1] ?? null;
}
let currentFrame = null;
function highlightVideo(liveBox, debugContext) {
const id = getVideoId(liveBox);
if (id) {
const iframe = document.getElementById('player-' + id);
debug(debugContext, {
id,
liveBox,
iframe,
});
if (currentFrame) {
currentFrame.classList.remove('highlight-box');
}
if (iframe) {
currentFrame = iframe.parentNode;
currentFrame.classList.add('highlight-box');
}
}
}
liveVideosContainer.addEventListener('mouseover', evt => {
const liveBox = findLiveBox(evt.target);
if (liveBox) {
// Ensure we're not just exiting and entering (or vice versa) elements of the same live box.
const relatedLiveBox = findLiveBox(evt.relatedTarget);
if (!relatedLiveBox || liveBox !== relatedLiveBox) {
highlightVideo(liveBox, evt.type);
}
}
});
liveVideosContainer.addEventListener('mouseout', evt => {
if (!liveVideosContainer.contains(evt.relatedTarget)) {
debug(evt.type, 'top bar', liveVideosContainer);
if (currentFrame) {
currentFrame.classList.remove('highlight-box');
currentFrame = null;
}
}
});
liveVideosContainer.addEventListener('click', evt => {
// Need to wait a moment for vue to apply its changes.
setTimeout(() => {
const target = document.elementFromPoint(evt.clientX, evt.clientY);
const liveBox = findLiveBox(target);
if (liveBox) {
highlightVideo(liveBox, evt.type);
}
}, 0);
}, {
// Needed since existing click event listeners on X button stop propagation
capture: true
});
let currentLiveBox = null;
function highlightLiveBox(iframe, debugContext) {
const id = iframe.id.substring('player-'.length);
const liveBox = liveVideosContainer.querySelector(`img[src^="https://i.ytimg.com/vi/${id}/"]`)?.closest('div.live-box');
debug(debugContext, {
id,
iframe,
liveBox,
});
if (currentLiveBox) {
currentLiveBox.classList.remove('highlight-box');
}
if (liveBox) {
currentLiveBox = liveBox;
currentLiveBox.classList.add('highlight-box');
}
}
playerContainer.addEventListener('mouseover', evt => {
const target = evt.target;
if (evt.target.tagName === 'IFRAME') {
highlightLiveBox(evt.target, evt.type);
}
});
playerContainer.addEventListener('mouseout', evt => {
if (evt.target.tagName === 'IFRAME') {
debug(evt.type, evt.target);
if (currentLiveBox) {
currentLiveBox.classList.remove('highlight-box');
currentLiveBox = null;
}
}
});
});
}
log('HoloTools fixes applied');
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment