// ==UserScript==
// @name AniList BTVA Connector
// @namespace https://github.com/ceeprus
// @version 1.0.13
// @description Swap a voice actor's AniList roles for their Behind The Voice Actors roles: games, dubs, movies and more
// @icon https://www.google.com/s2/favicons?sz=64&domain=anilist.co
// @author ceeprus
// @license MIT
// @match https://anilist.co/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_listValues
// @grant GM_deleteValue
// @connect www.behindthevoiceactors.com
// @run-at document-idle
// @downloadURL https://raw.githubusercontent.com/ceeprus/userscript/main/anilist/anilist-btva-connector.user.js
// @updateURL https://raw.githubusercontent.com/ceeprus/userscript/main/anilist/anilist-btva-connector.user.js
// ==/UserScript==
(function () {
'use strict';
// == pure: parse ==
const MEDIA = ['tv_shows', 'movies', 'games', 'shorts', 'commercials', 'narration'];
function parseRoles(html, baseUrl) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const roles = [];
for (const row of doc.querySelectorAll('div.credit_pics_half')) {
const medium = MEDIA.find(m => row.classList.contains('medium_msn_' + m));
const head = row.querySelector('a.credit_pic_title');
if (!medium || !head) continue;
const b = head.querySelector('b');
const year = b ? parseInt(b.textContent, 10) || null : null;
// BTVA appends "(2026 TV Show)"; no-year rows (ads) have no suffix.
const title = (b ? head.textContent.replace(/\s*\([^()]*\)\s*$/, '') : head.textContent).trim();
const titleUrl = new URL(head.getAttribute('href'), baseUrl).href;
for (const c of row.querySelectorAll('a.credit_pic_float')) {
const img = c.querySelector('img');
const src = img && (img.getAttribute('data-src') || img.getAttribute('src'));
roles.push({
character: (c.getAttribute('title') || '').trim(),
charUrl: new URL(c.getAttribute('href'), baseUrl).href,
thumb: src ? new URL(src, baseUrl).href : '',
title, titleUrl, year, medium,
});
}
}
return roles.filter(r => r.character);
}
function parseBtvaPage(html, baseUrl) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const vafn = doc.querySelector('#vafn');
if (!vafn) return null;
const native = doc.querySelector('h2.red');
return {
vafn: vafn.value,
sdd: (doc.querySelector('#sdd') || {}).value || '',
name: (doc.querySelector('#page_title') || {}).textContent?.trim() || '',
native: native ? native.textContent.trim() || null : null,
roles: parseRoles(html, baseUrl),
};
}
// == end pure: parse ==
// == pure: match ==
const fold = s => (s || '').normalize('NFD').replace(/[̀-ͯ]/g, '');
// AniList keeps long vowels (Shinichirou, Yuuki); BTVA drops them (Shinichiro, Yuki).
const shortVowels = s => s.replace(/([oO])u/g, '$1').replace(/([uU])u/g, '$1').replace(/([oO])o/g, '$1');
const slugPart = s => fold(s).replace(/[^A-Za-z0-9]+/g, ' ').trim().replace(/ /g, '-');
// Each name part is tried with and without long vowels: Yuuichi Inoue is Yuichi-Inoue on BTVA (Inoue keeps its ou).
function slugCandidates(name) {
const named = [name.first, name.last].filter(Boolean);
const parts = (named.length ? named : [name.full]).map(slugPart).filter(Boolean);
let combos = [''];
for (const p of parts) combos = combos.flatMap(c => [...new Set([shortVowels(p), p])].map(v => (c ? c + '-' : '') + v));
return [...new Set(combos)].filter(Boolean).slice(0, 4);
}
function nativeMatches(anilistNative, btvaNative, anilistFull, btvaName) {
const bare = s => (s || '').replace(/\s+/g, '');
if (bare(anilistNative) && bare(btvaNative)) return bare(anilistNative) === bare(btvaNative);
const key = s => shortVowels(slugPart(s)).toLowerCase();
return !!key(anilistFull) && key(anilistFull) === key(btvaName);
}
// == end pure: match ==
// == pure: view ==
const MEDIUM_LABEL = { tv_shows: 'Show', movies: 'Movie', games: 'Game', shorts: 'Short', commercials: 'Ad', narration: 'Narr.' };
// Returns [{year, roles}]; year undefined = one ungrouped block (Title sort). null year = "TBA".
function viewRoles(roles, tab, sort) {
const list = roles.filter(r => tab === 'all' || r.medium === tab);
if (!list.length) return [];
if (sort === 'title') return [{ year: undefined, roles: [...list].sort((a, b) => a.title.localeCompare(b.title)) }];
const groups = new Map();
for (const r of list) {
if (!groups.has(r.year)) groups.set(r.year, []);
groups.get(r.year).push(r);
}
// AniList lists TBA first under Newest and last under Oldest.
const rank = y => (y === null ? Infinity : y);
const years = [...groups.keys()].sort((a, b) => sort === 'oldest' ? rank(a) - rank(b) : rank(b) - rank(a));
return years.map(year => ({ year, roles: groups.get(year) }));
}
// BTVA's tab numbers count titles, not characters.
function tabCounts(roles) {
const counts = { all: 0 };
for (const m of Object.keys(MEDIUM_LABEL)) counts[m] = new Set(roles.filter(r => r.medium === m).map(r => r.titleUrl)).size;
counts.all = new Set(roles.map(r => r.titleUrl)).size;
return counts;
}
// == end pure: view ==
// == client ==
const BTVA = 'https://www.behindthevoiceactors.com/';
const DAY = 86400000;
const realDeps = () => ({
// anonymous:false sends the browser's BTVA cookies, so a solved Cloudflare check (cf_clearance) carries over.
gmRequest: o => new Promise((resolve, reject) => GM_xmlhttpRequest({
...o, anonymous: false, onload: resolve, onerror: reject, ontimeout: reject, timeout: 20000,
})),
gmGet: GM_getValue, gmSet: GM_setValue, gmList: GM_listValues, gmDelete: GM_deleteValue, now: Date.now,
});
// Normal BTVA pages also load Cloudflare's challenge-platform script, so only the interstitial itself counts.
const isChallenge = res => res.status === 403 || res.status === 503
|| /
Just a moment|cf-chl/i.test(res.responseText || '');
async function loadBtva(staff, deps = realDeps(), onStep = () => {}) {
const key = 'btva:' + staff.id;
const cached = deps.gmGet(key, null);
if (cached && deps.now() - cached.at < DAY) return cached.notfound ? { state: 'notfound' } : { state: 'ok', url: cached.url, roles: cached.roles };
// Yesterday's roles beat an error message when BTVA is blocked or down.
const fail = (r, why) => {
console.warn('[BTVA Connector]', r.state + ':', why); // the only clue when one browser works and another doesn't
return cached && cached.roles ? { state: 'ok', url: cached.url, roles: cached.roles } : r;
};
const pin = deps.gmGet('btvaPin:' + staff.id, '');
const urls = pin ? [pin] : slugCandidates(staff.name).map(s => BTVA + s + '/');
try {
for (const url of urls) {
const res = await deps.gmRequest({ method: 'GET', url });
if (isChallenge(res)) return fail({ state: 'blocked', url }, 'GET ' + res.status + ' ' + url);
if (res.status === 404) continue;
if (res.status !== 200) return fail({ state: 'error' }, 'GET ' + res.status + ' ' + url);
const page = parseBtvaPage(res.responseText, url);
if (!page) continue;
if (!pin && !nativeMatches(staff.name.native, page.native, staff.name.full, page.name)) continue;
onStep('rest');
// The request BTVA's own page sends on scroll: every role after the newest titles, in one response.
const rest = await deps.gmRequest({
method: 'POST', url: BTVA + 'actors_chars_ga.php',
data: new URLSearchParams({ vafn: page.vafn, sdd: page.sdd }).toString(),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
overrideMimeType: 'text/html; charset=windows-1252', // served as ISO-8859-1
});
if (isChallenge(rest)) return fail({ state: 'blocked', url }, 'POST ' + rest.status);
if (rest.status !== 200) return fail({ state: 'error' }, 'POST ' + rest.status);
const seen = new Set();
const roles = [...page.roles, ...parseRoles(rest.responseText, url)]
.filter(r => !seen.has(r.charUrl) && seen.add(r.charUrl));
// Userscript managers load every stored value on each page, so drop other VAs' expired caches.
for (const k of deps.gmList ? deps.gmList() : []) {
const v = k.startsWith('btva:') && deps.gmGet(k, null);
if (v && !(deps.now() - v.at < DAY)) deps.gmDelete(k);
}
deps.gmSet(key, { at: deps.now(), url, roles });
return { state: 'ok', url, roles };
}
} catch (e) {
return fail({ state: 'error' }, e); // offline, timeout or a parser bug: a Cloudflare refusal arrives as a 403 response
}
// A miss costs up to four page requests; remember it so the next click sends none. Fix link clears it.
deps.gmSet(key, { at: deps.now(), notfound: true });
return { state: 'notfound' };
}
// == end client ==
// == ui ==
const TABS = [['all', 'All'], ['tv_shows', 'Shows'], ['movies', 'Movies'], ['games', 'Games'], ['shorts', 'Shorts'], ['commercials', 'Ads'], ['narration', 'Narr.']];
const SORTS = [['newest', 'Newest'], ['oldest', 'Oldest'], ['title', 'Title']];
const PIN_RE = /^https:\/\/www\.behindthevoiceactors\.com\/[^/?#\s]+\/?$/;
// AniList theme colours as "r,g,b" custom properties; fallbacks are its dark theme. The switch copies its sort label (gray-600, hover gray-700, .3s).
const CSS = `
#btva-switch{flex:none;white-space:nowrap;margin:-6px 0;background:none;border:0;padding:6px 0;font:inherit;font-size:1.3rem;font-weight:600;color:rgb(var(--color-gray-600,116,136,153));cursor:pointer;transition:color .3s}
#btva-switch:hover,#btva-switch:focus-visible{color:rgb(var(--color-gray-700,139,160,178))}
.btva-tabs{display:flex;flex-wrap:wrap;gap:4px 20px;margin:0 0 20px}
.btva-tabs button{background:none;border:0;border-bottom:2px solid transparent;padding:4px 0;min-height:32px;font:inherit;font-size:1.3rem;font-weight:600;color:rgb(var(--color-text-light,139,160,178));cursor:pointer;transition:color .3s}
.btva-tabs button:hover,.btva-tabs button:focus-visible,.btva-tabs button[aria-selected=true]{color:rgb(var(--color-blue,61,180,242))}
.btva-tabs button[aria-selected=true]{border-bottom-color:currentColor}
.btva-tabs small{font-size:1.1rem;font-weight:400;margin-left:4px;font-variant-numeric:tabular-nums}
.btva-note,.btva-foot{font-size:1.4rem;color:rgb(var(--color-text-light,139,160,178));margin:20px 0}
.btva-foot{display:flex;justify-content:flex-end;gap:16px;font-size:1.3rem}
.btva-note a,.btva-foot a,.btva-link{color:rgb(var(--color-blue,61,180,242));background:none;border:0;padding:5px 0;margin:-5px 0;font:inherit;font-weight:600;cursor:pointer}
.btva-note a:hover,.btva-foot a:hover,.btva-link:hover{text-decoration:underline}
#btva-sort .option{cursor:pointer}
#btva-roles{margin-top:80px}
#btva-roles.btva-fallback .grid-wrap{display:grid;grid-template-columns:repeat(auto-fill,146px);gap:28px 30px;justify-content:space-between;margin-bottom:40px}
#btva-roles.btva-fallback .cover{display:block;height:209px;border-radius:4px;overflow:hidden;background:rgb(var(--color-foreground,21,31,46))}
#btva-roles.btva-fallback .cover img{width:100%;height:100%;object-fit:cover}
#btva-roles.btva-fallback .titles{padding-top:8px}
#btva-roles.btva-fallback .titles>.title{font-size:1.4rem;color:rgb(var(--color-text,139,160,178))}
#btva-roles.btva-fallback .role{font-size:1.1rem;margin-left:4px;color:rgb(var(--color-text-lighter,116,136,153))}
#btva-roles.btva-fallback .relation-title{display:block;margin-top:4px;font-size:1.2rem;color:rgb(var(--color-text-lighter,116,136,153))}
#btva-roles.btva-fallback h3.year{font-size:2.2rem;font-weight:800;color:rgb(var(--color-text,139,160,178));margin:22px 0 20px}`;
// Elements built "like" AniList's carry its data-v-* attributes, so Vue's scoped styles apply to them.
function like(ref, tag, cls) {
const el = document.createElement(tag);
if (cls) el.className = cls;
if (ref) for (const a of ref.attributes) if (a.name.startsWith('data-v-')) el.setAttribute(a.name, '');
return el;
}
const httpsOnly = url => (/^https:\/\//i.test(url || '') ? url : '');
function setLink(a, url) {
url = httpsOnly(url);
if (url) a.href = url; else a.removeAttribute('href');
a.target = '_blank';
a.rel = 'noopener noreferrer';
}
let current = null;
const thumbs = new Map(); // thumb URL -> promise of its blob URL ('' if that failed too); one fetch per page load
function mountStaff(staffId, deps) {
const filters = document.querySelector('.staff-wrap .filters');
const wrap = filters.closest('.staff-wrap');
const aniSort = filters.querySelector('.mini-select.sort');
const aniRoles = () => wrap.querySelector('.character-roles:not(#btva-roles)');
if (!document.getElementById('btva-style')) {
const style = document.createElement('style');
style.id = 'btva-style';
style.textContent = CSS;
document.head.append(style);
}
const st = { on: false, tab: 'all', sort: 'newest', data: null, staff: null };
const menuClose = new AbortController();
const btn = document.createElement('button');
btn.id = 'btva-switch';
btn.type = 'button';
btn.dataset.staff = String(staffId);
btn.textContent = 'Switch to BTVA';
filters.prepend(btn);
let sort;
if (aniSort) {
sort = aniSort.cloneNode(true);
sort.querySelector('.dropdown')?.remove();
} else {
sort = like(null, 'div', 'mini-select sort');
sort.append(like(null, 'span', 'label'));
}
sort.id = 'btva-sort';
sort.tabIndex = 0;
sort.setAttribute('role', 'button');
sort.setAttribute('aria-haspopup', 'listbox');
const sortLabel = sort.querySelector('.label');
sortLabel.textContent = 'Newest';
if (aniSort) aniSort.after(sort); else filters.append(sort);
const panel = like(aniRoles(), 'div', 'character-roles container');
panel.id = 'btva-roles';
// AniList's .filters bar has a negative bottom margin and overlays the first roles block; sit where its character list sits.
const ani = aniRoles(), rolesWrap = wrap.querySelector('.roles-wrap') || wrap;
if (ani) ani.after(panel); else rolesWrap.prepend(panel);
function closeMenu() {
sort.querySelector('.dropdown')?.remove();
sort.setAttribute('aria-expanded', 'false');
}
function openMenu() {
const dd = like(sortLabel, 'div', 'dropdown');
dd.setAttribute('role', 'listbox');
for (const [key, label] of SORTS) {
const opt = like(sortLabel, 'div', 'option' + (key === st.sort ? ' active' : ''));
opt.textContent = label;
opt.tabIndex = 0;
opt.setAttribute('role', 'option');
opt.setAttribute('aria-selected', String(key === st.sort));
opt.addEventListener('click', e => {
e.stopPropagation();
st.sort = key;
sortLabel.textContent = label;
closeMenu();
render();
sort.focus();
});
opt.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); opt.click(); } });
dd.append(opt);
}
// AniList's menu fades in through Vue's transition classes; borrow them so ours moves the same way.
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
dd.classList.add('dropdown-animation-enter', 'dropdown-animation-enter-active');
dd.addEventListener('animationend', () => dd.classList.remove('dropdown-animation-enter', 'dropdown-animation-enter-active'), { once: true });
}
sort.append(dd);
sort.setAttribute('aria-expanded', 'true');
}
sort.addEventListener('click', () => (sort.querySelector('.dropdown') ? closeMenu() : openMenu()));
sort.addEventListener('keydown', e => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!sort.querySelector('.dropdown')) openMenu();
const opts = [...sort.querySelectorAll('.option')], i = opts.indexOf(e.target);
const j = i < 0 ? opts.findIndex(o => o.classList.contains('active')) : Math.max(0, Math.min(opts.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)));
return opts[j]?.focus();
}
if (e.target !== sort) { if (e.key === 'Escape') { closeMenu(); sort.focus(); } return; }
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); sort.click(); }
if (e.key === 'Escape') closeMenu();
});
document.addEventListener('click', e => { if (!sort.contains(e.target)) closeMenu(); }, { signal: menuClose.signal });
// After "Open BTVA" the Cloudflare cookie exists; retry as soon as the user is back on this tab.
const retryBlocked = () => {
if (document.hidden || !st.on || st.data?.state !== 'blocked') return;
st.data = null;
load();
render();
};
document.addEventListener('visibilitychange', retryBlocked, { signal: menuClose.signal });
window.addEventListener('focus', retryBlocked, { signal: menuClose.signal });
function sync() {
const a = aniRoles();
if (a) a.style.display = st.on ? 'none' : '';
if (aniSort) aniSort.style.display = st.on ? 'none' : '';
sort.style.display = st.on ? '' : 'none';
panel.style.display = st.on ? '' : 'none';
}
function note(...parts) {
const p = document.createElement('p');
p.className = 'btva-note';
p.setAttribute('role', 'status');
p.append(...parts);
return p;
}
function link(text, url) {
const a = document.createElement('a');
setLink(a, url);
a.textContent = text;
return a;
}
function fixLink() {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btva-link';
b.textContent = 'Wrong person? Fix link';
b.addEventListener('click', () => {
const answer = deps.prompt('Paste this person\'s BTVA page link, like https://www.behindthevoiceactors.com/Shinichiro-Miki/', st.data?.url || '');
if (answer == null) return;
const url = answer.trim();
if (!PIN_RE.test(url)) {
panel.querySelector('.btva-warn')?.remove();
const warn = note('That isn\'t a BTVA person link. It looks like https://www.behindthevoiceactors.com/Shinichiro-Miki/');
warn.classList.add('btva-warn');
panel.append(warn);
return;
}
deps.gmSet('btvaPin:' + staffId, url.endsWith('/') ? url : url + '/');
deps.gmDelete('btva:' + staffId);
st.data = null;
load(); // first: it resets the loading step before the note is drawn
render();
});
return b;
}
function card(r, tpl) {
let c;
if (tpl) {
c = tpl.cloneNode(true);
c.querySelector('.relation-image')?.remove();
} else {
c = document.createElement('div');
c.className = 'role-card';
c.innerHTML = '
';
}
const cover = c.querySelector('.cover'), img = cover.querySelector('img');
setLink(cover, r.charUrl);
img.referrerPolicy = 'no-referrer';
img.loading = 'lazy';
img.alt = '';
img.className = 'image'; // AniList's .image.loaded fades in; add it once the picture is there
img.addEventListener('load', () => img.classList.add('loaded'));
const src = httpsOnly(r.thumb);
// Firefox sends no BTVA cookie with page images, so Cloudflare can refuse them; the manager's request carries it.
if (thumbs.has(src)) thumbs.get(src).then(u => { img.src = u || src; });
else {
img.src = src;
if (src) img.addEventListener('error', () => {
if (!thumbs.has(src)) thumbs.set(src, deps.gmRequest({ method: 'GET', url: src, responseType: 'blob' })
.then(res => (res.status === 200 && res.response ? URL.createObjectURL(res.response) : ''), () => ''));
thumbs.get(src).then(u => { if (u) img.src = u; });
}, { once: true });
}
const name = c.querySelector('.titles > .title');
const roleTpl = name.querySelector('.role');
setLink(name, r.charUrl);
name.replaceChildren(document.createTextNode(' ' + r.character));
if (st.tab === 'all') {
const tag = roleTpl || like(name, 'span', 'role');
tag.textContent = MEDIUM_LABEL[r.medium];
name.append(tag);
}
const rel = c.querySelector('.relation-title');
setLink(rel, r.titleUrl);
rel.textContent = r.title;
rel.title = r.title;
return c;
}
function render() {
panel.replaceChildren();
const d = st.data;
if (!d) return panel.append(note(st.step === 'rest' ? 'Loading more roles…' : 'Loading BTVA roles…'));
if (d.state === 'error') return panel.append(note('Couldn\'t load BTVA roles. Switch again to retry.'));
if (d.state === 'blocked') return panel.append(note('BTVA needs one visit before roles can load. ', link('Open BTVA', d.url), ' Roles load when you come back to this tab.'));
if (d.state === 'notfound') return panel.append(note('No BTVA page found for this person. ', fixLink()));
const counts = tabCounts(d.roles);
const tabs = document.createElement('div');
tabs.className = 'btva-tabs';
tabs.setAttribute('role', 'tablist');
for (const [key, label] of TABS) {
const t = document.createElement('button');
t.type = 'button';
t.setAttribute('role', 'tab');
t.id = 'btva-tab-' + key;
t.setAttribute('aria-controls', 'btva-tabpanel');
t.setAttribute('aria-selected', String(key === st.tab));
t.tabIndex = key === st.tab ? 0 : -1; // one tab stop; arrows move inside the list
const n = document.createElement('small');
n.textContent = counts[key];
t.append(label, ' ', n);
t.addEventListener('click', () => {
st.tab = key;
render();
panel.querySelector('[role=tab][aria-selected=true]')?.focus();
});
tabs.append(t);
}
// Arrows, Home and End move and select at once: switching a tab only redraws, it fetches nothing.
tabs.addEventListener('keydown', e => {
const i = TABS.findIndex(([key]) => key === st.tab), n = TABS.length;
const next = { ArrowRight: (i + 1) % n, ArrowLeft: (i - 1 + n) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
tabs.children[next].click();
});
panel.append(tabs);
const body = document.createElement('div');
body.id = 'btva-tabpanel';
body.setAttribute('role', 'tabpanel');
body.setAttribute('aria-labelledby', 'btva-tab-' + st.tab);
panel.append(body);
const live = aniRoles();
const cardTpl = live?.querySelector('.role-card') || null;
const yearTpl = live?.querySelector('h3.year') || null;
const gridTpl = live?.querySelector('.grid-wrap') || null;
panel.classList.toggle('btva-fallback', !cardTpl);
const groups = viewRoles(d.roles, st.tab, st.sort);
if (!groups.length) body.append(note('No roles in this category.'));
for (const g of groups) {
const block = like(yearTpl, 'div');
if (g.year !== undefined) {
const h = like(yearTpl, 'h3', 'year');
h.textContent = g.year === null ? 'TBA' : String(g.year);
block.append(h);
}
const grid = like(gridTpl, 'div', 'grid-wrap');
for (const r of g.roles) grid.append(card(r, cardTpl));
block.append(grid);
body.append(block);
}
const foot = document.createElement('div');
foot.className = 'btva-foot';
foot.append(link('Open on BTVA', d.url), fixLink());
panel.append(foot);
}
// One request chain at a time, even if the switch is toggled while it runs.
function load() {
st.loading ??= fetchRoles().finally(() => { st.loading = null; });
}
async function fetchRoles() {
try {
if (!st.staff) st.staff = await deps.fetchStaff(staffId);
st.step = null;
st.data = await deps.loadBtva(st.staff, undefined, step => {
st.step = step;
if (panel.isConnected && !st.data) render();
});
} catch (e) {
st.data = { state: 'error' };
}
if (panel.isConnected) render();
}
btn.addEventListener('click', () => {
st.on = !st.on;
btn.textContent = st.on ? 'Switch to AniList' : 'Switch to BTVA';
closeMenu();
sync();
if (!st.on) return;
if (st.data && st.data.state !== 'ok') st.data = null; // retry after a block or an error; a miss stays cached for 24h (Fix link clears it)
if (!st.data) load(); // first: it resets the loading step before the note is drawn
render();
});
sync();
current = {
staffId: String(staffId), btn, sync,
unmount() {
st.on = false;
sync();
menuClose.abort();
btn.remove();
sort.remove();
panel.remove();
},
};
}
async function fetchStaff(id) {
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ query: 'query($id:Int){Staff(id:$id){name{first last full native}}}', variables: { id } }),
});
const json = await res.json();
return { id, name: json.data.Staff.name };
}
function boot(deps) {
setInterval(() => {
const m = location.pathname.match(/^\/staff\/(\d+)/);
// Vue can rebuild the filters bar (button gone) or reuse it for another staff id.
if (current && (!m || current.staffId !== m[1] || !current.btn.isConnected)) { current.unmount(); current = null; }
if (!m) return;
if (!document.getElementById('btva-switch') && document.querySelector('.staff-wrap .filters')) mountStaff(Number(m[1]), deps);
else current?.sync(); // Vue may re-create AniList's grid while BTVA mode is on
}, 250);
}
// == end ui ==
if (typeof GM_xmlhttpRequest === 'function') {
boot({ loadBtva, fetchStaff, prompt: (msg, def) => window.prompt(msg, def), gmSet: GM_setValue, gmDelete: GM_deleteValue, gmRequest: realDeps().gmRequest });
}
})();