// ==UserScript==
// @name AniCHAT - Discuss Anime Episodes
// @namespace https://greasyfork.org/en/users/781076-jery-js
// @version 1.1.3
// @description Get discussions from popular sites like MAL and AL 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/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_notification
// @require https://unpkg.com/axios/dist/axios.min.js
// @downloadURL none
// ==/UserScript==
/**************************
* CONSTANTS
***************************/
// seconds to wait before loading the discussions (to avoid spamming the service)
const TIMEOUT = 30000; // in milliseconds
// proxy to bypass the cors restriction on services like MAL
const PROXYURL = "https://proxy.cors.sh/"; //"https://test.cors.workers.dev/?"; //'https://corsproxy.io/?';
/***************************************************************
* ANIME SITES & SERVICES
***************************************************************/
const animeSites = [
{
name: "yugenanime",
url: ["yugenanime.tv"],
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],
timeout: 0,
},
];
const services = [
{
name: "MyAnimeList",
icon: "https://image.myanimelist.net/ui/OK6W_koKDTOqqqLDbIoPAiC8a86sHufn_jOI-JGtoCQ",
url: "https://myanimelist.net/",
clientId: "dbe5cec5a2f33fdda148a6014384b984",
proxyKey: "temp_2ed7d641dd52613591687200e7f7958b",
async getDiscussion(animeTitle, epNum) {
// get the discussion
let url = PROXYURL + `https://api.myanimelist.net/v2/forum/topics`;
let query = `${animeTitle} Episode ${epNum} Discussion`;
let response = await axios.get(url, {
params: {
q: query,
limit: 15,
},
headers: {
"X-MAL-CLIENT-ID": this.clientId,
"x-cors-api-key": this.proxyKey,
},
});
const topic = response.data.data.find(
(topic) => topic.title.toLowerCase().includes(animeTitle.toLowerCase()) && topic.title.toLowerCase().includes(epNum.toLowerCase())
);
// 1 secound pause to avoid being rate-limited
await new Promise((resolve) => setTimeout(resolve, 1000));
// get the chats from the discussion
url = PROXYURL + `https://api.myanimelist.net/v2/forum/topic/${topic.id}?limit=100`;
response = await axios.get(url, {
headers: {
"X-MAL-CLIENT-ID": this.clientId,
"x-cors-api-key": this.proxyKey,
},
});
const data = response.data.data;
let chats = [];
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 = bbcodeToHtml(post.body);
const timestamp = new Date(post.created_at).getTime();
chats.push(new Chat(user, userLink, avatar, msg, timestamp));
});
const discussion = new Discussion(topic.title, "https://myanimelist.net/forum/?topicid=" + topic.id, chats);
return discussion;
},
},
];
/***************************************************************
* 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) {
this.user = user;
this.userLink = userLink;
this.avatar = avatar;
this.msg = msg;
this.timestamp = timestamp;
}
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
function generateDiscussionArea() {
const discussionArea = document.createElement("div");
discussionArea.className = "discussion-area";
const discussionTitle = document.createElement("h3");
discussionTitle.className = "discussion-title";
const discussionTitleText = document.createElement("a");
discussionTitleText.textContent = `${site.getAnimeTitle()} Episode ${site.getEpNum()} Discussion`;
discussionTitleText.title = "Click to view the original discussion";
discussionTitleText.target = "_blank";
discussionTitle.appendChild(discussionTitleText);
const serviceIcon = document.createElement("img");
serviceIcon.className = "service-icon";
serviceIcon.title = "Powered by " + service.name;
serviceIcon.src = service.icon;
discussionTitle.appendChild(serviceIcon);
const discussionList = document.createElement("ul");
discussionList.className = "discussion-list";
discussionArea.appendChild(discussionTitle);
discussionArea.appendChild(discussionList);
return discussionArea;
}
// build a row for a single chat in the discussion
function buildChatRow(chat) {
const chatRow = document.createElement("li");
chatRow.className = "chat-row";
const userAvatar = document.createElement("div");
userAvatar.className = "user-avatar";
userAvatar.innerHTML = ``;
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);
chatRow.appendChild(userAvatar);
chatRow.appendChild(userMsg);
return chatRow;
}
// Countdown to show before load the discussions
function setLoadingTimeout() {
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;
}
.service-icon {
height: 20px;
}
.chat-row {
display: flex;
padding: 10px 0;
border-top: 1px 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[0];
chooseService(parseInt(GM_getValue("service", 1)));
// Register menu commands
// GM_registerMenuCommand("Show Options", showOptions);
/***************************************************************
* Functions for working of the script
***************************************************************/
// Show menu options as a prompt
function showOptions() {
let options = {
"Choose Service": chooseService,
};
let opt = prompt(
`${GM_info.script.name}\n\nChoose an option:\n${Object.keys(options)
.map((key, i) => `${i + 1}. ${key}`)
.join("\n")}`,
"1"
);
if (opt !== null) {
let index = parseInt(opt) - 1;
let selectedOption = Object.values(options)[index];
selectedOption();
}
}
// Prompt the user to choose a service
function chooseService(ch) {
let choice = typeof ch == "number" ? ch : parseInt(GM_getValue("service", 1));
if (typeof ch !== "number") {
const msg = `${GM_info.script.name}\n\nChoose a service:\n${services.map((s, i) => `${i + 1}. ${s.name}`).join("\n")}`;
choice = prompt(msg, choice);
}
if (choice == null) {
return;
} else choice = parseInt(choice);
let newService = services[choice - 1];
if (!newService) {
console.log("Invalid choice. Switch to a different service for now.");
return chooseService(parseInt(GM_getValue("service", 1)));
} else service = newService;
GM_setValue("service", choice);
if (typeof ch !== "number") {
GM_notification(`Switched to ${service.name} service.`, GM_info.script.name, service.icon);
}
console.log(`Switched to ${service.name} service.`);
return service;
}
// Convert BBCode to HTML
function bbcodeToHtml(bbcode) {
// Define the BBCode to HTML mappings
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\](.*?)\[\/code\]/g, html: "
$1
" },
{ bbcode: /\[quote\](.*?)\[\/quote\]/g, html: "
$1" }, { bbcode: /\[color=(.*?)\](.*?)\[\/color\]/g, html: '$2' }, { bbcode: /\[size=(.*?)\](.*?)\[\/size\]/g, html: '$2' }, { bbcode: /\[center\](.*?)\[\/center\]/g, html: '