// ==UserScript==
// @name kemono.su links for ppixiv
// @namespace https://www.pixiv.net/
// @version 1.3.7
// @description Add kemono.su buttons on ppixiv user dropdown
// @author EnergoStalin
// @match https://*.pixiv.net/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
// @license AGPL-3.0-only
// @grant GM_xmlhttpRequest
// @connect www.patreon.com
// @connect kemono.su
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
const cachedPatreonUsers = {};
const cachedRedirects = {};
const labelMatchingMap = {
"patreon": "patreon.com",
"fanbox": "Fanbox",
"fantia": "fantia.jp",
};
const labelList = Object.values(labelMatchingMap);
const patreonIdRegex = /"id":\s*"(\d+)",[\n\s]*"type":\s*"user"/sm;
const linkRegex = /[\W\s]((?:https?:\/\/)?(?:\w+[\.\/].+){2,})/g;
function filterInPlace(a, condition) {
let i = 0, j = 0;
while (i < a.length) {
const val = a[i];
if (condition(val, i, a)) a[j++] = val;
i++;
}
a.length = j;
return a;
}
function notifyUserUpdated(userId) {
unsafeWindow.ppixiv.userCache.callUserModifiedCallbacks(userId);
}
function normalizeUrl(url) {
url = url.trim();
if(!url.startsWith("http")) url = `https://${url}`;
return url;
}
function getLinksFromDescription(extraLinks) {
return removeDuplicates(
preprocessMatches(
Array.from(document.body.querySelector(".description").innerText.matchAll(linkRegex)).map(e => e[1])
),
extraLinks
);
}
function removeDuplicates(links, extraLinks) {
const labels = extraLinks.map(e => e.label);
return links.filter(e => !labels.includes(e.label));
}
function preprocessMatches(matches) {
return matches.map(e => {
try {
const url = new URL(normalizeUrl(e));
return {
label: labelMatchingMap[Object.keys(labelMatchingMap).filter(e => url.host.includes(e))[0]],
url
};
} catch {
return {label: null, url: null};
}
});
}
function normalizePatreonLink(link) {
if(typeof(link.url) === "string") link.url = new URL(normalizeUrl(link.url));
link.url.protocol = "https";
if(!link.url.host.startsWith("www.")) link.url.host = `www.${link.url.host}`;
}
async function ripPatreonId(link) {
const response = await GM.xmlHttpRequest({
method: "GET",
url: link,
});
return response.responseText.match(patreonIdRegex)[1];
}
function patreon(link, extraLinks, userInfo) {
normalizePatreonLink(link);
const url = link.url.toString();
const cachedId = cachedPatreonUsers[url];
if(!cachedId) {
ripPatreonId(url).then(id => {
cachedPatreonUsers[url] = id;
notifyUserUpdated(userInfo.userId);
}).catch(console.error)
} else {
extraLinks.push({
url: new URL(`https://kemono.su/patreon/user/${cachedId}`),
icon: "mat:money_off",
type: "kemono_patreon",
label: "Kemono patreon"
});
}
}
function fanbox(extraLinks, userInfo) {
extraLinks.push({
url: new URL(`https://kemono.su/fanbox/user/${userInfo.userId}`),
icon: "mat:money_off",
type: "kemono_fanbox",
label: "Kemono fanbox"
});
}
function fantia(link, extraLinks) {
const id = link.url.toString().split("/").pop();
extraLinks.push({
url: new URL(`https://kemono.su/fantia/user/${id}`),
icon: "mat:money_off",
type: "kemono_fantia",
label: "Kemono fantia"
});
}
async function checkRedirect(cache, link) {
const url = link.toString()
const value = cache[url]
const cacheHit = value !== undefined
if(!cacheHit) {
const response = await GM.xmlHttpRequest({
method: "GET",
redirect: "manual",
url: link,
});
cache[url] = response.finalUrl !== link.toString();
}
return {cacheHit, value}
}
function removeDeadLinks(extraLinks, userInfo) {
Promise.all(
extraLinks
.filter(e => e.label?.includes("Kemono"))
.map(e => checkRedirect(cachedRedirects, e.url))
).then((e) =>
// Fire user update if at least one cache miss occured
e.reduce((p, n) => p + n.cacheHit, 0) === e.length ? undefined : notifyUserUpdated(userInfo.userId)
).catch(console.error)
filterInPlace(extraLinks, e => !cachedRedirects[e?.url?.toString()])
}
function addUserLinks({ extraLinks, userInfo }) {
for(const link of [...extraLinks, ...getLinksFromDescription(extraLinks)]) {
switch(link.label) {
case "Fanbox": fanbox(extraLinks, userInfo); break;
case "patreon.com": patreon(link, extraLinks, userInfo); break;
case "fantia.jp": fantia(link, extraLinks); break;
}
}
removeDeadLinks(extraLinks, userInfo)
}
unsafeWindow.vviewHooks = {
addUserLinks
};
})();