// ==UserScript==
// @name MALstreaming
// @namespace https://github.com/mattiadr/MALstreaming
// @version 5.35
// @author https://github.com/mattiadr
// @description Adds various anime and manga links to MAL
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JQAAgIMAAPn/AACA6QAAdTAAAOpgAAA6mAAAF2+SX8VGAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3wQRDic4ysC1kQAAA+lJREFUWMPtlk1sVFUUx3/n3vvmvU6nnXbESkTCR9DYCCQSFqQiMdEY4zeJuiBhwUISAyaIHzHGaDTxKyzEr6ULNboiRonRhQrRCMhGiDFGA+WjhQ4NVKbtzJuP9969Lt4wlGnBxk03vZv3cu495/7u/5x7cmX1xk8dczjUXG4+DzAPMA8AYNoNIunXudnZ2+enrvkvn2kADkhiiwM8o6YEEuLE4pxDK0GakZUIoiCOHXFiW2uNEqyjZdNaIbMB0Ero7gwQ4OJEDa0VSoR6lNDT5eMZRaUa0YgSjFZU6zG1ekK+y6er00eJECWWchiRMYp8VwBAOYyw1l0dQIlQrcfcvKSHT968j+5chg+/OMoHnx9FCdwzsIRdz24gGxhe2v0Le74/htaKFYvzbNm4knWrF3J9IYtSQq0e8+C2r+jwDXvefYjEWja98B2DQyU6fINty8cVCigl9HYHiMCOzWs4/HuR4XNl3n5mPbmsB0DgGyYrDR69ewXvvXgXgW+oNxLOX6ySJJaebp/+ZQWOD5fIZT2cS5WddRGCw9oU5rVtA1SqEfmcTxRZPE8RxZbe7oBXnlpH4BtGx0Ke2PkNt624jte3DzBWqjF4ZhzP6GYBOtw1qtC07Y2I0IgTisUKtyztBaB4voLWQl8hS1iLuL2/j0V9OQC+/fkkx4ZK3L9hGQt6Oyj0BCiR1qZpwV5dgRn7gBLh1Y8OcmpkAoDndv3E6IUQgCRx9BWy6b91bH64n7P7tvL8lrU4l/pOi6dSRZWSaShmJgDPKIbPTfLy+wdYfEMXB46M0JXLNE8ElWoEQK0e8/fJi8SJpa+QZemi7hmiOSphxESlQRRb/IzGKMHNBOCaJwTI53wOHhnBM5pCPqDRSFIHrTh1drzls/2Nffx18h+efGwV7+y8kyi2l+O5VKW1KxeycEEn2Q6PPwfHKE3WMVpwrg1AAK1TkaxzBBlDEGiSxLXsgW84cWacE2fGWX5TnnsHlnB8qEQ2SG+J1qnM0lTLaMVbO+5AJL2ijzy9l7FSDaMV4FIAh0MpoRxGfL1vECRtHiK0Gsj+w8OcHpmkeKFCWIv54dAQWx9fxfo1N/Lxl38wVJzgx1+HCGsx1XoMwN79gy1VfU9zujjB2dFJfE9dLtKpb0JrHeUwzW8u66Gm3N9yGJEkls6sR5I4+pcX2PTArez+7DcmK+lcWIsRgc5mzyhXoivSq5W0+klL9fZH6SWpL9VCy64ERLDW4lyaorAaE2Q0xihE0kqnmfepsaZSJPYanXCmjVt265rnaAKJkM9lsM7hXLPg2nyvFuuaALMdjumn+T9jzh8k8wDzAPMAcw7wLz7iq04ifbsDAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDE1LTA0LTE3VDE0OjM5OjU2LTA0OjAw6I0f5AAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxNS0wNC0xN1QxNDozOTo1Ni0wNDowMJnQp1gAAAAASUVORK5CYII=
// @run-at document-idle
// @supportURL https://github.com/mattiadr/MALstreaming/issues
// @match https://myanimelist.net/animelist/*
// @match https://myanimelist.net/ownlist/anime/*/edit*
// @match https://myanimelist.net/ownlist/anime/add?selected_series_id=*
// @match https://myanimelist.net/mangalist/*
// @match https://myanimelist.net/ownlist/manga/*/edit*
// @match https://myanimelist.net/ownlist/manga/add?selected_manga_id=*
// @match http://kissanime.ru/
// @match https://kissmanga.com/
// @match https://www1.9anime.nl/
// @match https://twist.moe/
// @require https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @grant window.close
// @connect *
// @downloadURL none
// ==/UserScript==
/* generic */
/*******************************************************************************************************************************************************************/
// array of all streaming services
const streamingServices = [
// anime
{ id: "kissanime", type: "anime", name: "Kissanime", domain: "http://kissanime.ru/" },
{ id: "nineanime", type: "anime", name: "9anime", domain: "https://www1.9anime.to/" },
{ id: "animetwist", type: "anime", name: "Anime Twist", domain: "https://twist.moe/" },
// manga
{ id: "kissmanga", type: "manga", name: "Kissmanga", domain: "https://kissmanga.com/" },
{ id: "mangadex", type: "manga", name: "MangaDex", domain: "https://mangadex.org/" },
{ id: "jaiminisbox", type: "manga", name: "Jaimini's Box", domain: "https://jaiminisbox.com/" },
];
// contains variable properties for anime/manga modes
let properties = {};
properties.anime = {
mode: "anime",
watching: ".list-unit.watching",
colHeader: "
",
commentsRegex: /Comments: ([\S ]+)(?= )/g,
iconAdd: ".icon-add-episode",
findProgress: ".data.progress",
findAiring: "span.content-status:contains('Airing')",
latest: "Latest ep is #",
notAired: "Not Yet Aired",
ep: "Ep.",
editPageBox: "#add_anime_comments",
};
properties.manga = {
mode: "manga",
watching: ".list-unit.reading",
colHeader: "",
commentsRegex: /Comments: ([\S ]+)(?=\n)/g,
iconAdd: ".icon-add-chapter",
findProgress: ".data.chapter",
findAiring: "span.content-status:contains('Publishing')",
latest: "Latest ch is #",
notAired: "Not Yet Published",
ep: "Ch.",
editPageBox: "#add_manga_comments",
};
// contains all functions to execute on page load
const pageLoad = {};
// contains all functions to get the episodes list from the streaming services
// must callback to putEpisodes(dataStream, episodes, timeMillis)
const getEpisodes = {};
// contains all functions to get the episode list url from the partial url
const getEplistUrl = {};
// contains all functions to execute the search on the streaming services
// must callback to putResults(results)
const searchSite = {};
// return an array that contains the streaming service and url relative to that service or false if comment is not valid
function getUrlFromComment(comment) {
let c = comment.split(" ");
if (c.length < 2) return false;
for (let i = 0; i < streamingServices.length; i++) {
if (streamingServices[i].id == c[0]) return c;
}
return false;
}
// estimate time before next chapter as min of last n chapters
function estimateTimeMillis(episodes, n) {
let prev = null;
let min = undefined;
for (let i = episodes.length - 1; i > Math.max(0, episodes.length - 1 - n); i--) {
if (!episodes[i]) continue;
if (prev && episodes[i].timestamp != prev) {
let diff = prev - episodes[i].timestamp;
if (!min || diff < min && diff > 0) min = diff;
}
prev = episodes[i].timestamp;
}
return episodes[episodes.length - 1].timestamp + min;
}
// returns the domain for the streaming service or false if ss doesn't exist
function getDomainById(id) {
for (let i = 0; i < streamingServices.length; i++) {
if (streamingServices[i].id == id) {
return streamingServices[i].domain;
}
}
return false;
}
/* anilist */
/*******************************************************************************************************************************************************************/
const anilist = {};
anilist.api = "https://graphql.anilist.co";
anilist.query = `\
query ($idMal: Int) {
Media(type: ANIME, idMal: $idMal) {
airingSchedule(notYetAired: true, perPage: 1) {
nodes {
episode
airingAt
}
}
}
}`;
// request time until next episode for the specified anime id
function requestTime(id) {
// prepare data
let data = {
query: anilist.query,
variables: { idMal: id }
};
// do request
GM_xmlhttpRequest({
method: "POST",
url: anilist.api,
headers: { "Content-Type": "application/json" },
data: JSON.stringify(data),
onload: function(resp) {
let res = JSON.parse(resp.response);
let times = GM_getValue("anilistTimes", {});
// get data from response
let sched = res.data.Media.airingSchedule.nodes[0];
let ep = sched.episode;
let timeMillis = sched.airingAt * 1000;
// set time, ep is episode the timer is referring to
times[id] = {
ep: ep,
timeMillis: timeMillis
};
// put times in GM value
GM_setValue("anilistTimes", times);
}
});
}
// puts timeMillis into dataStream, then calls back
function anilist_setTimeMillis(dataStream, canReload) {
let listitem = dataStream.parents(".list-item");
// anime is not airing, exit
if (listitem.find(properties.findAiring).length == 0) return;
let times = GM_getValue("anilistTimes", false);
// get anime id
let id = listitem.find(".data.title > .link").attr("href").split("/")[2];
let t = times ? times[id] : false;
if (times && t && Date.now() < t.timeMillis) {
// time doesn't need to update
// set timeMillis, this is used to check if anilist timer is referring to next episode
dataStream.data("timeMillis", t);
} else {
// add value change listener
let listenerId = GM_addValueChangeListener("anilistTimes", function(name, old_value, new_value, remote) {
// reload, avoid infinite loops
if (canReload) anilist_setTimeMillis(dataStream, false);
// remove listener
GM_removeValueChangeListener(listenerId);
});
// api request to anilist
requestTime(id);
}
}
/* kissanime */
/*******************************************************************************************************************************************************************/
const kissanime = {};
kissanime.base = "http://kissanime.ru/";
kissanime.anime = kissanime.base + "Anime/";
kissanime.search = kissanime.base + "Search/SearchSuggestx";
kissanime.server = "&s=rapidvideo";
// blacklisted urls
kissanime.epsBlacklist = [
"/Anime/Macross/Bunny_Hat-Macross_Special_-4208D135?id=73054",
"/Anime/Macross/Bunny_Hat_Raw-30th_Anniversary_Special_-0A1CD40E?id=73055",
"/Anime/Macross/Episode-011-original?id=35423"
];
// regexes
kissanime.regexWhitelist = /episode|movie|special|OVA/i;
kissanime.regexBlacklist = /\b_[a-z]+|recap|\.5/i;
kissanime.regexCountdown = /\d+(?=\), function)/;
// loads kissanime cookies and then calls back
function kissanime_loadCookies(callback) {
if (GM_getValue("KAloadcookies", false) + 30*1000 < Date.now()) {
GM_setValue("KAloadcookies", Date.now());
GM_openInTab(kissanime.base, true);
}
if (callback) {
setTimeout(function() {
callback();
}, 6000);
}
}
// function to execute when script is run on kissanime
pageLoad["kissanime"] = function() {
if (GM_getValue("KAloadcookies", false) && document.title != "Just a moment...") {
GM_setValue("KAloadcookies", false);
window.close();
}
}
getEpisodes["kissanime"] = function(dataStream, url) {
GM_xmlhttpRequest({
method: "GET",
url: kissanime.anime + url,
onload: function(resp) {
if (resp.status == 503) {
// loading CF cookies
kissanime_loadCookies(function() {
getEpisodes["kissanime"](dataStream, url);
});
} else if (resp.status == 200) {
// OK
let jqPage = $(resp.response);
let episodes = [];
// get anchors for the episodes
let as = jqPage.find(".listing").find("tr > td > a");
// get series title to remove it from episode name
let title = jqPage.find("#leftside > div:nth-child(1) > div.barContent > div:nth-child(2) > a").text();
// filter and add to episodes array
as.each(function() {
// title must match regexWhitelist, must not match regexBlacklist and href must not be in epsBlacklist to be considered a valid episode
if (kissanime.regexWhitelist.test(this.text) && !kissanime.regexBlacklist.test(this.text) && kissanime.epsBlacklist.indexOf(this.href) == -1) {
// prepend new object to array
episodes.unshift({
text: this.text.split(title)[1].substring(1).replace(/ 0+(?=\d+)/, " "),
href: kissanime.anime + this.href.split("/Anime/")[1] + kissanime.server
});
}
});
// get time until next episode
let timeMillis = Date.now() + parseInt(kissanime.regexCountdown.exec(resp.responseText));
// callback
putEpisodes(dataStream, episodes, timeMillis);
}
}
});
}
getEplistUrl["kissanime"] = function(partialUrl) {
return kissanime.anime + partialUrl;
}
searchSite["kissanime"] = function(id, title) {
GM_xmlhttpRequest({
method: "POST",
url: kissanime.search,
data: "type=Anime" + "&keyword=" + title,
headers: { "Content-Type": "application/x-www-form-urlencoded" },
onload: function(resp) {
if (resp.status == 503) {
// loading CF cookies
kissanime_loadCookies(function() {
searchSite["kissanime"](id, title);
});
} else if (resp.status == 200) {
// OK
let results = [];
let list = $(resp.responseText);
list.each(function() {
results.push({
title: this.text,
href: this.pathname.split("/")[2]
});
});
// callback
putResults(id, results);
}
}
});
}
/* 9anime */
/*******************************************************************************************************************************************************************/
const nineanime = {};
nineanime.base = "https://www1.9anime.nl/";
nineanime.anime = nineanime.base + "watch/";
nineanime.servers = nineanime.base + "ajax/film/servers/";
nineanime.search = nineanime.base + "search?keyword=";
nineanime.regexBlacklist = /preview|special|trailer|CAM/i;
// open captcha page
function nineanime_openCaptcha() {
if (GM_getValue("NAcaptcha", false) + 30*1000 < Date.now()) {
GM_setValue("NAcaptcha", Date.now());
GM_openInTab(nineanime.base, false);
}
}
// function to execute when script is run on nineanime
pageLoad["nineanime"] = function() {
// close window if opended by script
if (GM_getValue("NAcaptcha", false) && document.title != "WAF") {
GM_setValue("NAcaptcha", false);
window.close();
}
}
getEpisodes["nineanime"] = function(dataStream, url) {
GM_xmlhttpRequest({
method: "GET",
url: nineanime.servers + url.match(/(?<=\.)\w+$/)[0],
onload: function(resp) {
if (resp.status == 200) {
// successful response is a json with only html attribute, parse it
let json = null;
try {
json = JSON.parse(resp.response);
} catch (e) {
// solving captcha
nineanime_openCaptcha();
return;
}
// OK
let jqPage = $(json.html);
let episodes = [];
// get servers
let servers = jqPage.find("div.widget-body > .server");
let as = null;
// auto select server with the most videos
servers.each(function() {
let nas = $(this).find("li > a");
if (!as || nas.length > as.length) {
as = nas;
}
});
if (as) {
as.each(function() {
// ignore blacklisted episodes
if (!nineanime.regexBlacklist.test($(this).text())) {
// push episode to array
episodes.push({
text: "Episode " + $(this).text().replace(/^0+(?=\d+)/, ""),
href: nineanime.base + $(this).attr("href").substr(1),
});
}
});
}
// get time if available
GM_xmlhttpRequest({
method: "GET",
url: nineanime.anime + url,
onload: function(resp) {
if (resp.status == 200) {
// OK
let time = $(resp.response).find("#main > div > div.alert.alert-primary > i");
let timeMillis = undefined;
if (time.length !== 0) {
// timer is present
timeMillis = time.data("to") * 1000;
}
// callback
putEpisodes(dataStream, episodes, timeMillis);
} else {
// not OK, callback
putEpisodes(dataStream, episodes, undefined);
}
}
});
}
}
});
}
getEplistUrl["nineanime"] = function(partialUrl) {
return nineanime.anime + partialUrl;
}
searchSite["nineanime"] = function(id, title) {
GM_xmlhttpRequest({
method: "GET",
url: nineanime.search + encodeURI(title),
onload: function(resp) {
if (resp.status == 200) {
// OK
let jqPage = $(resp.response);
let results = [];
// get results from response
let list = jqPage.find("#main > div > div:nth-child(1) > div.widget-body > div.film-list > .item");
list = list.slice(0, 10);
// add to results
list.each(function() {
// get anchor for text and href
let a = $(this).find("a")[1];
// get episode count
let ep = $(this).find(".status > .ep").text().match(/(?<=\/)\d+/);
results.push({
title: a.text,
href: a.href.split("/")[4],
episodes: ep ? (ep[0] + " eps") : "1 ep"
});
});
// callback
putResults(id, results);
}
}
});
}
/* animetwist */
/*******************************************************************************************************************************************************************/
const animetwist = {};
animetwist.base = "https://twist.moe/";
animetwist.anime = animetwist.base + "a/";
animetwist.anime_suffix = "/last";
animetwist.dataRegex = /(?<=