// ==UserScript==
// @name AniCHAT - Discuss Anime Episodes
// @namespace https://greasyfork.org/en/users/781076-jery-js
// @version 2.4.1
// @description Get discussions from popular sites like MAL and Reddit for the anime you are watching right below your episode
// @icon https://image.myanimelist.net/ui/OK6W_koKDTOqqqLDbIoPAiC8a86sHufn_jOI-JGtoCQ
// @author Jery
// @license MIT
// @match https://yugenanime.*/*
// @match https://yugenanime.tv/*
// @match https://yugenanime.sx/*
// @match https://animepahe.*/*
// @match https://animepahe.com/*/
// @match https://anitaku.*/*
// @match https://anitaku.bz/*
// @match https://gogoanime.*/*
// @match https://gogoanime.to/*
// @match https://gogoanime3.*/*
// @match https://gogoanime3.co/*
// @match https://aniwave.*/watch/*
// @match https://aniwave.to/watch/*
// @match https://aniwave.vc/watch/*
// @match https://aniwave.ti/watch/*
// @match https://aniwatchtv.*/watch/*
// @match https://aniwatchtv.to/watch/*
// @match https://hianime.*/watch/*
// @match https://hianime.to/watch/*
// @match https://kayoanime.*/*
// @match https://kayoanime.com/*
// @match https://kaas.*/*/*
// @match https://kaas.to/*/*
// @match https://kickassanimes.*/*/*
// @match https://kickassanimes.io/*/*
// @match https://*.kickassanime.*/*/*
// @match https://*.kickassanime.mx/*/*
// @match https://anix.*/*/*/*
// @match https://anix.to/*/*/*
// @match https://anix.ac/*/*/*
// @match https://anix.vc/*/*/*
// @match https://animeflix.*/watch/*
// @match https://animeflix.live/watch/*
// @match https://animehub.*/watch/*
// @match https://animehub.ac/watch/*
// @match https://animesuge.*/anime/*
// @match https://animesuge.to/anime/*
// @match https://*.miruro.*/watch?id=*
// @match https://*.miruro.tv/watch?id=*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_notification
// @grant GM.xmlHttpRequest
// @require https://unpkg.com/axios/dist/axios.min.js
// Using GM_fetch for bypassing CORS
// @require https://cdn.jsdelivr.net/npm/@trim21/gm-fetch@0.2.1
// @downloadURL none
// ==/UserScript==
/**************************
* CONSTANTS
***************************/
// seconds to wait before loading the discussions (to avoid spamming the service)
const TIMEOUT = 30000; // in milliseconds
/***************************************************************
* ANIME SITES & SERVICES
***************************************************************/
const animeSites = [
{
name: "yugenanime",
url: ["yugenanime.tv", "yugenanime.sx"],
chatArea: ".box.m-10-t.m-25-b.p-15",
getAnimeTitle: () => document.querySelector(".ani-info-ep a > h1").textContent,
getEpTitle: () => document.querySelector("h1.text-semi-bold.m-5-b").textContent,
getEpNum: () => window.location.href.split("/")[6],
styles: null,
},
{
name: "animepahe",
url: ["animepahe.ru", "animepahe.com"],
chatArea: ".theatre",
getAnimeTitle: () => document.querySelector(".theatre-info > h1 > a").textContent.split(' - ')[0],
getEpTitle: () => document.querySelector(".theatre-info > h1 > a").textContent.split(' - ')[0],
getEpNum: () => document.querySelector(".dropup.episode-menu > button").innerText.split("Episode ")[1],
styles: '.discussion-area { max-width:1100px; margin:15px auto 0; }',
},
{
name: "gogoanime",
url: ['gogoanime3', 'gogoanimehd', 'gogoanime', 'anitaku'],
chatArea: ".anime_video_body_comment_center",
getAnimeTitle: () => document.querySelector(".anime-info > a").textContent,
getEpTitle: () => document.querySelector(".anime-info > a").textContent,
getEpNum: () => window.location.href.split("-episode-")[1],
styles: `.chat-msg { color: white; font-size: 14px; } .discussion-title > a { font-size: 24px; color: goldenrod; }`
},
{
name: "aniwave",
url: ['aniwave', 'lite.aniwave'],
chatArea: "#comments",
getAnimeTitle: () => document.querySelector(".name .title").textContent,
getEpTitle: () => document.querySelector(".name .title").textContent,
getEpNum: () => window.location.href.split("/ep-")[1],
},
{
name: "hianime",
url: ["aniwatchtv", "hianime.to", "hianime.nz", "hianime.mm", "hianime.sx", "hianime"],
chatArea: ".show-comments",
getAnimeTitle: () => document.querySelector("h2.film-name > a").textContent,
getEpTitle: () => document.querySelector("div.ssli-detail > .ep-name").textContent,
getEpNum: () => waitForElm(".ssl-item.ep-item.active > .ssli-order").then(elm => elm.textContent),
styles: `.chat-row .user-avatar { width: auto; overflow: visible; }`
},
{
name: "kayoanime",
url: ["kayoanime.com"],
chatArea: "#the-post",
getAnimeTitle: () => document.querySelector("h1.entry-title").textContent.split(/Episode \d+ English.+/)[0].trim(),
getEpTitle: () => document.querySelector(".toggle-head").textContent.trim(),
getEpNum: () => document.querySelector("h1.entry-title").textContent.split(/Episode (\d+) English.+/)[1],
},
{
name: "kickassanime",
url: ["kaas", "kickassanimes", "kickassanime"],
chatArea: () => document.querySelector("#disqus_thread").parentElement,
getAnimeTitle: () => document.querySelector(".text-h6").textContent,
getEpTitle: () => document.querySelector(".text-h6").textContent,
getEpNum: () => document.querySelector(".d-block .text-overline").textContent.split("Episode")[1].trim(),
},
{
name: "anix",
url: ["anix"],
chatArea: () => document.querySelector("#disqus_thread").parentElement,
getAnimeTitle: () => document.querySelector(".ani-name").textContent,
getEpTitle: () => document.querySelector(".ani-name").textContent,
getEpNum: () => window.location.href.split("/ep-")[1],
},
{
name: "animeflix",
url: ["animeflix"],
chatArea: 'main',
getAnimeTitle: () => document.querySelector(".details .title").textContent,
getEpTitle: () => document.querySelector(".details .title").textContent,
getEpNum: () => window.location.href.split("-episode-")[1],
},
{
name: "animehub",
url: ["animehub"],
chatArea: 'mawdawin',
getAnimeTitle: () => document.querySelector(".dc-title").textContent,
getEpTitle: () => document.querySelector(".dc-title").textContent,
getEpNum: () => document.querySelector("#current_episode_name").textContent.split("Episode")[1].trim(),
},
{
name: "animesuge",
url: ["animesuge.to", "animesuge"],
chatArea: '#comment',
getAnimeTitle: () => document.querySelector("#media-info .maindata > h1").textContent,
getEpTitle: () => document.querySelector("#media-info .maindata > h1").textContent,
getEpNum: () => window.location.href.split("/ep-")[1],
},
{
name: "miruro",
url: ["miruro.tv"],
chatArea: () => document.querySelector("#disqus_thread").parentElement,
getAnimeTitle: () => document.querySelector(".anime-title > a").textContent.trim(),
getEpTitle: () => document.querySelector(".title-container .title").textContent.trim(),
getEpNum: () => document.querySelector(".title-container .ep-number").textContent.split(". ")[0],
styles: `#AniCHAT a:-webkit-any-link { color: lightblue; }`,
initDelay: 5000, // Time to wait (for page to load) before attaching the discussion area
}
];
const services = [
{
name: "MyAnimeList",
icon: "https://image.myanimelist.net/ui/OK6W_koKDTOqqqLDbIoPAiC8a86sHufn_jOI-JGtoCQ",
url: "https://myanimelist.net/",
_clientId: "dbe5cec5a2f33fdda148a6014384b984",
async getDiscussion(animeTitle, epNum) {
let animeId, topic, url, response, data;
let headers = {headers: {"X-MAL-CLIENT-ID": this._clientId, 'x-requested-with': 'XMLHttpRequest', 'origin': window.location.origin}};
// get the anime's MAL id using MAL API (or use Jikan API if title is too long)
try {
if (animeTitle.length > 500) {
url = `https://api.myanimelist.net/v2/anime?q=${animeTitle}&limit=1`;
response = await GM_fetch(url, headers);
data = await response.json();
animeId = data.data[0].node.id;
} else {
url = `https://api.jikan.moe/v4/anime?q=${animeTitle}&limit=1`;
animeId = GM_getValue('cachedId_'+url, null);
if (!animeId) {
response = await GM_fetch(url, headers);
data = await response.json();
animeId = data.data[0].mal_id;
GM_setValue('cachedId_'+url, animeId);
}
}
console.log(`animeId: ${animeId}`);
} catch (e) {
throw new Error(`Couldn't find the anime id. Retry after a while or switch to another service.\n${e.code} : ${e}`);
}
// get the discussion url from the anime
try {
url = `https://api.jikan.moe/v4/anime/${animeId}/forum`;
response = await GM_fetch(url, headers);
data = await response.json();
topic = data.data.find(it => it.title.includes(`Episode ${epNum} Discussion`));
console.log(`topic: ${topic}`);
} catch (e) {
throw new Error(`No discussion found. Retry after a while or switch to another service.\n${e.code} : ${e}`);
}
// get the forum page
try {
url = `https://api.myanimelist.net/v2/forum/topic/${topic.mal_id}?limit=100`;
response = await GM_fetch(url, headers);
data = await response.json();
console.log(`data: ${data}`);
} catch (e) {
throw new Error(`Error getting the discusssion (${topic}). Retry after a while or switch to another service.\n${e.code} : ${e}`);
}
let chats = [];
data.data.posts.forEach((post) => {
const user = post.created_by.name;
const userLink = "https://myanimelist.net/profile/" + user;
const avatar = post.created_by.forum_avator;
const msg = this._parseBBCode(post.body);
const timestamp = new Date(post.created_at).getTime();
chats.push(new Chat(user, userLink, avatar, msg, timestamp, null));
});
const discussion = new Discussion(topic.title, topic.url, chats);
return discussion;
},
_parseBBCode(bbcode) {
const mappings = [
{ bbcode: /\[b\](.*?)\[\/b\]/g, html: "$1" },
{ bbcode: /\[i\](.*?)\[\/i\]/g, html: "$1" },
{ bbcode: /\[u\](.*?)\[\/u\]/g, html: "$1" },
{ bbcode: /\[s\](.*?)\[\/s\]/g, html: "$1" },
{ bbcode: /\[url=(.*?)\](.*?)\[\/url\]/g, html: '$2' },
{ bbcode: /\[img.*?\](.*?)\[\/img\]/g, html: '' },
{ bbcode: /\[code\]([\s\S]*?)\[\/code\]/g, html: "
$1
" },
{ bbcode: /\[quote\]/g, html: '
' }, { bbcode: /\[quote=(.*?)\s*(message=\d+)?\]/g, html: '' }, { bbcode: /\[color=(.*?)\](.*?)\[\/color\]/g, html: '$2' }, { bbcode: /\[size=(.*?)\](.*?)\[\/size\]/g, html: '$2' }, { bbcode: /\[center\](.*?)\[\/center\]/g, html: '$1 Said:
' }, { bbcode: /\[\/quote\]/g, html: '$1' }, { bbcode: /\[list\](.*?)\[\/list\]/g, html: "$1
" }, { bbcode: /\[list=(.*?)\](.*?)\[\/list\]/g, html: '$2
' }, { bbcode: /\[\*\](.*?)\[\/\*\]/g, html: "$1 " }, { bbcode: /\[spoiler\]([\s\S]*?)\[\/spoiler\]/g, html: '' }, { bbcode: /\[spoiler=(.*?)\]([\s\S]*?)\[\/spoiler\]/g, html: '' }, { bbcode: /@(\S+)/g, html: '@$1' }, ]; let html = bbcode; for (const mapping of mappings) { html = html.replace(mapping.bbcode, mapping.html); } return html; } }, { name: "Reddit", icon: "https://www.redditstatic.com/desktop2x/img/favicon/apple-icon-57x57.png", url: "https://www.reddit.com/", _clientId: "dbe5cec5a2f33fdda148a6014384b984", async getDiscussion(animeTitle, epNum) { let animeId, topic, url, response, posts; let headers = {headers: {'x-requested-with': 'XMLHttpRequest', 'origin': window.location.origin}}; // get the anime's MAL id try { url = `https://api.jikan.moe/v4/anime?q=${animeTitle}&limit=1`; animeId = GM_getValue('cachedId_'+url, ''); if (animeId == '') { response = await GM_fetch(url, headers); data = await response.json(); if (data.data.length > 0) { animeId = data.data[0].mal_id; GM_setValue('cachedId_'+url, animeId); } } } catch (e) { throw new Error(`Couldn't find the anime id. Retry after a while or switch to another service.\n${e.code} : ${e.message}`); } // Get the discussion try { url = `https://api.reddit.com/r/anime/search.json?q=${animeTitle}+-+Episode+${epNum}+discussion+author:AutoLovepon&restrict_sr=on&include_over_18=on&sort=relevance&limit=50`; response = await axios.get(url); topic = response.data.data.children.find(it => it.data.title.includes(` - Episode ${epNum} discussion`) && it.data.selftext.includes(`[MyAnimeList](https://myanimelist.net/anime/${animeId}`))?.data; } catch (e) { throw new Error(`No discussion found. Retry after a while or switch to another service.\n${e.code} : ${e.message}`); } // get the comments in the discussion try { url = topic.url.replace('www.reddit.com', 'api.reddit.com'); response = await axios.get(url); posts = response.data[1].data.children; if (posts[0].data.author == "AutoModerator") posts.shift(); // skip the first bot post } catch (e) { throw new Error(`Error getting the discusssion. Retry after a while or switch to another service.\n${e.code} : ${e.message}`); } let chats = []; for (let post of posts) chats.push(this._processPost(post)); const discussion = new Discussion(topic.title, topic.url, chats); return discussion; }, _processPost(post) { const user = post.data.author; const userLink = "https://www.reddit.com/user/" + user; // const about = axios.get(`https://api.reddit.com/user/${user}/about`); const avatar = axios.get(`https://api.reddit.com/user/${user}/about`).then(r=>r.data.data.icon_img.split('?')[0]); const msg = ((el) => { el.innerHTML = post.data.body_html; return el.value; })(document.createElement('textarea')); const timestamp = post.data.created_utc * 1000; let replies = []; if (post.data.replies && post.data.replies.data) for (let reply of post.data.replies.data.children) replies.push(this._processPost(reply)); return new Chat(user, userLink, avatar, msg, timestamp, replies); } }, ]; /*************************************************************** * Classes for handling various data like settings & discussions ***************************************************************/ // User settings class UserSettings { constructor(usernames = {}) { this.usernames = usernames; } static load() { return GM_getValue("userSettings", new UserSettings()); } } // Class to hold each row of a discussion class Chat { constructor(user, userLink, avatar, msg, timestamp, replies) { this.user = user; this.userLink = userLink; this.avatar = avatar; this.msg = msg; this.timestamp = timestamp; this.replies = replies; } getRelativeTime() { const now = new Date().getTime(); const diff = now - this.timestamp; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); const weeks = Math.floor(days / 7); const months = Math.floor(days / 30); const years = Math.floor(days / 365); if (years > 0) { return `${years} ${years === 1 ? "year" : "years"} ago`; } else if (months > 0) { return `${months} ${months === 1 ? "month" : "months"} ago`; } else if (weeks > 0) { return `${weeks} ${weeks === 1 ? "week" : "weeks"} ago`; } else if (days > 0) { return `${days} ${days === 1 ? "day" : "days"} ago`; } else if (hours > 0) { return `${hours} ${hours === 1 ? "hour" : "hours"} ago`; } else if (minutes > 0) { return `${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`; } else { return `${seconds} ${seconds === 1 ? "second" : "seconds"} ago`; } } } // Class to hold the complete discussions class Discussion { constructor(title, link, chats) { this.title = title; this.link = link; this.chats = chats; } } /*************************************************************** * The UI elements ***************************************************************/ // generate the discussion area async function generateDiscussionArea() { const discussionArea = document.createElement("div"); discussionArea.id = "AniCHAT"; discussionArea.className = "discussion-area"; const discussionTitle = document.createElement("h3"); discussionTitle.className = "discussion-title"; const discussionTitleText = document.createElement("a"); discussionTitleText.textContent = `${await site.getAnimeTitle()} Episode ${await site.getEpNum()} Discussion`; discussionTitleText.title = "Click to view the original discussion"; discussionTitleText.target = "_blank"; discussionTitle.appendChild(discussionTitleText); const serviceSwitcher = buildServiceSwitcher(); discussionTitle.appendChild(serviceSwitcher); const discussionList = document.createElement("ul"); discussionList.className = "discussion-list"; discussionArea.appendChild(discussionTitle); discussionArea.appendChild(discussionList); return discussionArea; } function buildServiceSwitcher() { const servicesArea = document.createElement('div'); servicesArea.id = 'service-switcher'; servicesArea.innerHTML = `▶`; services.forEach(it => { servicesArea.innerHTML += `
`; }); servicesArea.querySelectorAll('.other').forEach(it => { it.addEventListener('click', () =>{ const serviceOpt = parseInt(it.getAttribute('data-opt')); console.log(serviceOpt); GM_setValue("service", serviceOpt); service = services[serviceOpt]; document.querySelector('.discussion-area').remove(); run(0); }); }); return servicesArea; } // build a row for a single chat in the discussion async function buildChatRow(chat) { const chatRow = document.createElement("li"); chatRow.className = "chat-row"; const chatContent = document.createElement("div"); chatContent.className = "chat-content"; const userAvatar = document.createElement("div"); userAvatar.className = "user-avatar"; userAvatar.innerHTML = `
`; if (chat.avatar instanceof Promise) chat.avatar.then(avatarUrl => userAvatar.firstChild.src = avatarUrl); else userAvatar.firstChild.src = chat.avatar; const userMsg = document.createElement("div"); userMsg.className = "user-msg"; const name = document.createElement("span"); name.className = "chat-name"; name.textContent = chat.user; const time = document.createElement("span"); time.className = "chat-time"; time.textContent = chat.getRelativeTime(); time.title = new Date(chat.timestamp).toLocaleString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "numeric", minute: "numeric", hour12: true, }); const msg = document.createElement("span"); msg.className = "chat-msg"; msg.innerHTML = chat.msg; userMsg.appendChild(name); userMsg.appendChild(time); userMsg.appendChild(msg); chatContent.appendChild(userAvatar); chatContent.appendChild(userMsg); chatRow.appendChild(chatContent); if (chat.replies && chat.replies.length > 0) { const repliesDiv = document.createElement("div"); repliesDiv.className = "reply"; for (let reply of chat.replies) { const replyRow = await buildChatRow(reply); repliesDiv.appendChild(replyRow); } chatRow.appendChild(repliesDiv); } return chatRow; } // Countdown to show before load the discussions function setLoadingTimeout(timeout) { let countdown = timeout; const loadingArea = document.createElement("div"); loadingArea.className = "loading-discussion"; const loadingElement = document.createElement("div"); loadingElement.innerHTML = `
`; loadingElement.style.cssText = `display: flex; align-items: center;`; const progressBar = document.createElement("div"); progressBar.className = "progress-bar"; progressBar.style.cssText = `width: "100%"; height: 10px; background-color: #ccc; position: relative;`; const progressFill = document.createElement("div"); progressFill.className = "progress-fill"; progressFill.style.cssText = `width: 0%; height: 100%; background-color: #4CAF50; position: absolute; top: 0; left: 0;`; const message = document.createElement("span"); message.textContent = `This ${ timeout / 1000 } secs timeout is set to reduce the load on the service and you can configure the TIMEOUT by editing the script (line 21)`; message.style.cssText = "font-size: 14px; color: darkgrey;"; progressBar.appendChild(progressFill); loadingElement.appendChild(message); loadingArea.appendChild(loadingElement); loadingArea.appendChild(progressBar); console.log("Countdown started: " + countdown + "ms"); const countdownInterval = setInterval(() => { countdown -= 100; const progressWidth = 100 - (countdown / timeout) * 100; progressFill.style.width = `${progressWidth}%`; if (countdown <= 0) { message.remove(); loadingElement.remove(); clearInterval(countdownInterval); } }, 100); return loadingArea; } // Add CSS styles to the page const styles = ` .discussion-area { border-radius: 10px; padding: 10px; } .discussion-title { display: flex; justify-content: space-between; margin-bottom: 20px; } .discussion-title > a { margin-right: 20px; } .service-icon { height: 25px; padding-right: 10px; } #service-switcher { width: 7%; transition: width 0.3s ease-in-out; overflow: hidden; display: flex; } #service-switcher:hover { width: ${8+5*services.length}%; } ul.discussion-list { overflow: auto; max-height: 90vh; } .chat-row { display: flex; flex-direction: column; padding: 10px 0; border-top: 1px solid #eee; } .chat-content { display: flex; flex-direction: row; } .chat-row > .reply { display: flex; flex-direction: column; padding-left: 55px; border-left: 0.7px solid #eee; } .user-avatar { width: 55px; height: 55px; margin-right: 10px; } .user-avatar > img { width: 55px; height: 55px; object-fit: cover; border-radius: 15px; } .user-msg { display: flex; flex-direction: column; } .chat-name { font-weight: bold; font-size: 15px; } .chat-time { font-size: 12px; font-weight: bold; padding-top: 5px; color: darkgrey; } .chat-msg { padding: 10px 0; } .chat-msg img { max-width: 100%; } .error-message { color: red; white-space: pre-wrap; } `; /*************************************************************** * Initialize all data and setup menu commands ***************************************************************/ // User settings let userSettings = UserSettings.load(); // Site instance let site = getCurrentSite(); // Service instance let service = services[GM_getValue("service", 0)]; /*************************************************************** * Functions for working of the script ***************************************************************/ // Returns a promise of the given element. Resolves when the element is found in the DOM. function waitForElm(selector) { return new Promise(resolve => { if (document.querySelector(selector)) { let elm = document.querySelector(selector); // console.log(`Element Found!!: ${elm.textContent}`); return resolve(elm); } const observer = new MutationObserver(mutations => { if (document.querySelector(selector)) { let elm = document.querySelector(selector); // console.log(`Element Detected!: ${elm.textContent}`); resolve(elm); observer.disconnect(); } }); observer.observe(document.body, { childList: true, subtree: true }); }); } // Get the current website based on the URL function getCurrentSite() { const currentUrl = window.location.href.toLowerCase(); return animeSites.find((website) => website.url.some((site) => currentUrl.includes(site))); } // Run the script async function run(timeout=TIMEOUT) { console.info(`Running AniCHAT on ${site.name}...`); // initialize the discussionArea const discussionArea = await generateDiscussionArea(); // Fallback techniques to use when chatArea cant be detected const selectors = [ { selector: () => site.chatArea && typeof site.chatArea === "string" ? document.querySelector(site.chatArea) : site.chatArea(), prepend: false }, { selector: () => document.querySelector('#main > .container'), prepend: false }, { selector: () => document.querySelector('#footer'), prepend: true }, { selector: () => document.querySelector('footer'), prepend: true }, { selector: () => document.body, prepend: false }, ]; for (let i = 0; i < selectors.length; i++) { try { const element = selectors[i].selector(); if (selectors[i].prepend) { element.prepend(discussionArea); } else { element.appendChild(discussionArea); } break; } catch (error) { continue; } } // Add custom css styles to the page const styleElement = document.createElement("style"); styleElement.textContent = styles + (site.styles || ''); discussionArea.append(styleElement); // Attach the loading element to the page discussionArea.appendChild(setLoadingTimeout(timeout)); // Load the discussion after a set timeout setTimeout(async () => { try { const discussion = await service.getDiscussion(await site.getAnimeTitle(), await site.getEpNum()); console.log(discussion); discussion.chats.forEach(async (chat) => { discussionArea.querySelector("ul").appendChild(await buildChatRow(chat)); }); discussionArea.querySelector(".discussion-title a").href = discussion.link; discussionArea.querySelector(".discussion-title a").textContent = discussion.title; } catch (error) { console.error(`${error.message}\n\n${error.stack}`); const errorElement = document.createElement("span"); errorElement.className = "error-message"; errorElement.textContent = `AniCHAT:\n${error.stack}\n\nCheck the console logs for more detail.`; discussionArea.appendChild(errorElement); } }, timeout); } // Workaround for SPA sites like Miruro for which the script doesn't auto reload on navigation function initScript() { const initDelay = site.initDelay || 0; setTimeout(run, initDelay); // Handle SPA navigation let lastUrl = location.href; new MutationObserver(() => { const url = location.href; if (url !== lastUrl) { lastUrl = url; console.log('URL changed, re-running AniCHAT'); setTimeout(run, initDelay); } }).observe(document.querySelector('body'), { subtree: true, childList: true }); } try { initScript(); } catch (e) { console.error(`${e.message}\n\n${e.stack}`); }