// ==UserScript==
// @name MangaLib helper
// @version 0.0.4
// @description Улучшение UX и функционала для сайта
// @icon https://mangalib.me/icons/android-icon-192x192.png?333
// @match https://mangalib.me/*
// @match https://senkuro.com/*
// @match https://readmanga.live/*
// @match https://mangabuff.ru/*
// @grant unsafeWindow
// @grant GM.xmlHttpRequest
// @grant GM_cookie
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_getResourceText
// @grant GM_addStyle
// @run-at document-end
// @require https://ajax.googleapis.com/ajax/libs/jquery/2.2.0/jquery.js
// @require https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.min.js
// @namespace https://greasyfork.org/users/728771
// ==/UserScript==
GM_addStyle(`.link-wrapper {
position: relative;
padding-right: 16px;
}
.link-wrapper.fade a {
pointer-events: none;
opacity: 0.7;
}
.link-wrapper span {
position: absolute;
right: 0;
top: calc(50% - 10px);
cursor: pointer;
}`);
(() => {
const oldPushState = history.pushState;
history.pushState = function pushState() {
const ret = oldPushState.apply(this, arguments);
window.dispatchEvent(new Event('pushstate'));
window.dispatchEvent(new Event('locationchange'));
return ret;
};
const oldReplaceState = history.replaceState;
history.replaceState = function replaceState() {
const ret = oldReplaceState.apply(this, arguments);
window.dispatchEvent(new Event('replacestate'));
window.dispatchEvent(new Event('locationchange'));
return ret;
};
window.addEventListener('popstate', () => {
window.dispatchEvent(new Event('locationchange'));
});
$.fn.extend({
serializeJSON: function (exclude) {
exclude || (exclude = []);
return _.reduce(this.serializeArray(), function (hash, pair) {
pair.value && !(pair.name in exclude) && (hash[pair.name] = pair.value);
return hash;
}, {});
}
});
})();
function waitForElm(selector) {
return new Promise(resolve => {
if (document.querySelector(selector)) return resolve(document.querySelector(selector));
const observer = new MutationObserver(mutations => {
if (document.querySelector(selector)) {
observer.disconnect();
resolve(document.querySelector(selector));
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
});
}
class CustomPromise {
constructor(executor, timeout, defaultValue) {
this.promise = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
resolve(defaultValue); // Возвращаем дефолтное значение
}, timeout);
executor(resolve, reject).finally(() => clearTimeout(timer));
});
}
then(onFulfilled, onRejected) {
return this.promise.then(onFulfilled, onRejected);
}
catch(onRejected) {
return this.promise.catch(onRejected);
}
}
class DB {
static websites = {
SENKURO: 'senkuro',
MANGABUFF: 'mangabuff',
READMANGA: 'readmanga',
MANGALIB: 'mangalib',
};
static milliseconds = 3600000;
static _isExpired(data) {
return data?._expiresAfter && Date.now() >= data._expiresAfter;
}
static _generateExpireDate(value) {
if (!value) return false;
if (value === true) return this.milliseconds + Date.now();
if (!isNaN(value)) return +value + Date.now();
}
/**
*
* @param website
* @param slug
* @param prop
* @param defaultValue
* @param getExpired
* @returns {Promise<data|defaultValue>}
*/
static async get(website, slug, prop, defaultValue = null, getExpired = false) {
const websiteData = await GM.getValue(website, {});
const data = _.get(websiteData, [slug, prop], defaultValue);
if (!getExpired && this._isExpired(data)) return defaultValue;
return data && Object.hasOwn(data, '_value') ? data._value : data;
}
/**
*
* @param website
* @param slug
* @param prop
* @param data
* @param expiryMilliseconds
* @returns {Promise<data>}
*/
static async set(website, slug, prop, data, expiryMilliseconds = false) {
const oldData = await GM.getValue(website, {});
if (typeof data !== 'object') data = {_value: data};
data._expiresAfter = this._generateExpireDate(expiryMilliseconds);
const newData = _.set(oldData, [slug, prop], data);
await GM.setValue(website, newData);
return data && Object.hasOwn(data, '_value') ? data._value : data;
}
static async getAndDelete(website, slug, prop, defaultValue = null, getExpired = false) {
const data = this.get(website, slug, prop, defaultValue, getExpired);
await this.delete(website, slug, prop);
return data;
}
static async delete(website, slug, prop) {
const oldData = await GM.getValue(website, {});
if (_.unset(oldData, [slug, prop])) {
await GM.setValue(website, oldData);
}
return true;
}
static async flushExpired() {
const websites = await GM.listValues();
for (const website of websites) {
const data = await GM.getValue(website);
for (const slug in data) {
for (const prop in data[slug]) {
if (this._isExpired(data)) _.unset(data, [slug, prop]);
}
}
await GM.setValue(website, data);
}
}
static async flushDB() {
return Promise.all((await GM.listValues()).map(key => GM.deleteValue(key)));
}
static async _getCache(website, slug) {
return await DB.getAndDelete(website, slug, `invalidate`) ? false : await this.get(website, slug, 'cache');
}
}
class API {
static links = {
SENKURO: (slug) => `https://senkuro.com/manga/${slug}/chapters`,
READMANGA: (slug) => `https://readmanga.live/${slug}#chapters-list`,
MANGABUFF: (slug) => `https://mangabuff.ru/manga/${slug}`,
};
static hosts = {
MANGALIB: 'mangalib.me',
SENKURO: 'senkuro.com',
READMANGA: 'readmanga.live',
MANGABUFF: 'mangabuff.ru',
};
static prepareResponse(urlFunc, slug, chapter, lastChapterRead) {
return {url: urlFunc(slug), slug, chapter, lastChapterRead};
}
static async senkuro(slug, titles) {
const websiteSlug = await DB.get(DB.websites.MANGALIB, slug, DB.websites.SENKURO);
if (websiteSlug === false) return false;
const cache = await DB._getCache(DB.websites.SENKURO, websiteSlug);
if (cache) return this.prepareResponse(this.links.SENKURO, websiteSlug, cache.chapter, cache.lastChapterRead);
const slugs = websiteSlug ? [websiteSlug] : await new CustomPromise(resolve => {
//const symbolic = titles.filter(title => !/[a-zа-я]/i.test(title.toLowerCase()));
return GM_xmlhttpRequest({
method: "POST",
url: "https://api.senkuro.com/graphql",
data: `{"extensions":{"persistedQuery":{"sha256Hash":"a2ccb7472c4652e21a7914940cce335683d37abdecccfeb6bbf9674e0a5bda80","version":1}},"operationName":"search","variables":{"query":"${/*symbolic.length ? symbolic[0] : */titles[1]}","type":"MANGA"}}`,
onload: async response => {
const data = JSON.parse(response.responseText);
const slugs = [];
let entity = data.data.search.edges.find(entity => {
slugs.push(entity.node.slug);
const entityTitles = [entity.node.originalName, ...entity.node.titles.map(title => title.content)];
return entityTitles.filter(value => titles.includes(value)).length;
});
return resolve(entity?.node.slug ? [entity.node.slug] : slugs);
}
});
}, 5000);
for (const entitySlug of slugs) {
const data = await this._senkuroDetails(entitySlug);
if (data?.manga.alternativeNames.map(name => titles.includes(name.content))) {
await DB.set(DB.websites.SENKURO, data.manga.slug, 'cache', {
chapter: data.chapter,
lastChapterRead: data.lastChapterRead,
}, true);
await DB.set(DB.websites.MANGALIB, slug, DB.websites.SENKURO, data.manga.slug);
return this.prepareResponse(this.links.SENKURO, data.manga.slug, data.chapter, data.lastChapterRead);
}
}
return DB.set(DB.websites.MANGALIB, slug, DB.websites.SENKURO, false, true);
}
static async _senkuroDetails(slug) {
return new CustomPromise(resolve => {
let headers = {};
if (tokens.SENKURO) headers = {"authorization": `Bearer ${tokens.SENKURO}`};
return GM_xmlhttpRequest({
method: "POST",
url: `https://api.senkuro.com/graphql`,
data: `{"extensions":{"persistedQuery":{"sha256Hash":"a44132a9483c73f8db43edf8d171c8e108de93ad3c990148f8474ffc546901e9","version":1}},"operationName":"fetchManga","variables":{"slug": "${slug}"}}`,
headers,
onload: function (response) {
const data = JSON.parse(response.responseText);
const manga = data?.data?.manga;
if (!manga) return resolve();
const chapter = Math.max(...manga.branches.map(branch => branch.primaryTeamActivities?.[0].ranges?.map(range => range.end)).flat()) || manga.chapters;
const lastChapterRead = +manga.viewerBookmark?.number || 0;
return resolve({
chapter,
lastChapterRead: lastChapterRead > chapter ? chapter : lastChapterRead,
manga
});
}
});
}, 5000, {});
}
static async mangabuff(slug, titles) {
let websiteSlug = await DB.get(DB.websites.MANGALIB, slug, DB.websites.MANGABUFF);
if (websiteSlug === false) return false;
const cache = await DB._getCache(DB.websites.MANGABUFF, websiteSlug);
if (cache) return this.prepareResponse(this.links.MANGABUFF, websiteSlug, cache.chapter, cache.lastChapterRead);
if (!websiteSlug) {
websiteSlug = await new CustomPromise(resolve => {
return GM_xmlhttpRequest({
method: "GET",
url: `https://mangabuff.ru/search/suggestions?q=${titles[1]}`,
onload: async response => {
const data = JSON.parse(response.responseText);
const entity = data.find(entity => titles.includes(entity.name));
return resolve(entity?.slug);
}
});
}, 5000);
}
if (websiteSlug) {
const {chapter, lastChapterRead} = await this._mangabuffDetails(websiteSlug);
if (chapter) {
await DB.set(DB.websites.MANGABUFF, websiteSlug, 'cache', {
chapter,
lastChapterRead,
}, true);
await DB.set(DB.websites.MANGALIB, slug, DB.websites.MANGABUFF, websiteSlug);
return this.prepareResponse(this.links.MANGABUFF, websiteSlug, chapter, lastChapterRead);
}
}
return DB.set(DB.websites.MANGALIB, slug, DB.websites.MANGABUFF, false, true);
}
static async _mangabuffDetails(slug) {
return new CustomPromise(resolve => {
return GM_xmlhttpRequest({
method: "GET",
url: this.links.MANGABUFF(slug),
onload: async response => {
return resolve({
chapter: $(response.responseText).find('.hot-chapters__wrapper .hot-chapters__number').eq(0)[0]?.firstChild.nodeValue.trim(),
lastChapterRead: await DB.get(DB.websites.MANGABUFF, slug, `lastChapterRead`, 0)
});
}
});
}, 5000, {});
}
static async readmanga(slug, titles) {
let websiteSlug = await DB.get(DB.websites.MANGALIB, slug, DB.websites.READMANGA);
if (websiteSlug === false) return false;
const cache = await DB._getCache(DB.websites.READMANGA, websiteSlug);
if (cache) return this.prepareResponse(this.links.READMANGA, websiteSlug, cache.chapter, cache.lastChapterRead);
if (!websiteSlug) {
websiteSlug = await new CustomPromise(resolve => {
return GM_xmlhttpRequest({
method: "GET",
url: `https://readmanga.live/search/suggestion?query=${titles[0]}`,
onload: async response => {
const data = JSON.parse(response.responseText);
const websiteSlug = data.suggestions.find(suggestion => titles.filter(value => [suggestion.value, ...suggestion.names || []].includes(value)).length)?.link.replace('/', '');
return resolve(websiteSlug);
}
});
}, 5000);
}
if (websiteSlug) {
const {chapter, lastChapterRead} = await this._readmangaDetails(websiteSlug);
if (chapter) {
await DB.set(DB.websites.READMANGA, websiteSlug, 'cache', {
chapter,
lastChapterRead,
}, true);
await DB.set(DB.websites.MANGALIB, slug, DB.websites.READMANGA, websiteSlug);
return this.prepareResponse(this.links.READMANGA, websiteSlug, chapter, lastChapterRead);
}
}
return DB.set(DB.websites.MANGALIB, slug, DB.websites.READMANGA, false, true);
}
static async _readmangaDetails(slug) {
return new CustomPromise(resolve => {
return GM_xmlhttpRequest({
method: "GET",
url: this.links.READMANGA(slug),
onload: async response => {
return resolve({
chapter: $(response.responseText).find('.chapters tr a').length,
lastChapterRead: 0
});
}
});
}, 5000, {});
}
static requesters = {
SENKURO: this.senkuro.bind(API),
MANGABUFF: this.mangabuff.bind(API),
READMANGA: this.readmanga.bind(API)
};
static getSlugFromURL(website, url) {
try {
if (url && !url.includes(this.hosts[website])) return '';
return this._urlParsers[website](url);
} catch (e) {
return '';
}
}
static _urlParsers = {
SENKURO: (url) => new URL(url).pathname.match(/[^\/]+/g)[1],
MANGABUFF: (url) => new URL(url).pathname.match(/[^\/]+/g)[1],
READMANGA: (url) => new URL(url).pathname.match(/[^\/]+/g)[0],
};
}
class Mangalib {
// language=HTML
static dropdownDOM = `
<div class='dropdown button_block'>
<button class='dropbtn button button_primary button_block'>
Открыть на сайте
<i class='fa fa-caret-down'></i>
</button>
<div class='dropdown-content media-info-list paper'></div>
</div>
`;
// language=HTML
static modalDOM = `
<div class="modal" id="edit-link-modal">
<div class="modal__inner">
<div class="modal__content" data-size="small">
<div class="modal__header">
<div class="modal__title text-center">Изменить ссылку</div>
<div class="modal__close" data-close-modal>
<svg class="modal__close-icon">
<use xlink:href="#icon-close"></use>
</svg>
</div>
</div>
<div class="modal__body">
<form>
<div class="form__field">
<div class="form__label flex justify_between align-items_end">
<span>Ссылка на произведение</span>
</div>
<input type="url" name="link" class="form__input" placeholder="Ссылка на произведение"/>
</div>
<div class="form__footer">
<button class="button button_md button_green button_save" type="submit"
data-close-modal>
<i class="fa fa-floppy-o far fa-save fa-fw"></i>
Сохранить
</button>
<button class="button button_md button_red button_clean" data-close-modal>
<i class="fa fa-trash-o far fa-trash-alt fa-fw fa-sm"></i>
Удалить
</button>
</div>
</form>
</div>
</div>
</div>
</div>
`;
static async mangaPage() {
await waitForElm('.media-name__main');
const slug = new URL(unsafeWindow.location).pathname.match(/[^\/]+/g)[0];
const titles = [
$('.media-name__main').text().trim(),
$('.media-name__alt').text().trim(),
...$('.media-info-list__item_alt-names .media-info-list__value div').toArray().map(function (i) {
return i.innerText;
})
];
const teamsWrapper = $('.media-chapters-teams');
const tab = $('.tabs__item[data-key="chapters"]');
let lastChapter = 0;
let team;
if (teamsWrapper.length) {
const teams = teamsWrapper.find('.team-list-item');
for (const node of teams.toArray()) {
const vue = node.__vue__;
const propsData = vue.$options.propsData;
const isSubscribed = propsData.branch.is_subscribed;
const lastTeamChapter = propsData.lastCreatedChapters[propsData.branch.id].chapter_number;
if (isSubscribed || lastChapter < lastTeamChapter) {
lastChapter = lastTeamChapter;
team = node;
if (isSubscribed) break;
}
}
} else lastChapter = $('.media-chapters .media-chapters-list .media-chapter')[0]?.['__vue__'].$options.propsData.chapter.chapter_number || 0;
const state = $('.media-sidebar button').text().trim();
switch (state) {
case 'Читаю':
tab.click();
$('.media-sidebar button').after(this.dropdownDOM);
$(".dropdown-content").hide();
$(".dropbtn").click(() => $(".dropdown-content").toggle());
let dataArr = await Promise.all(Object.keys(API.requesters).map(async (website) => {
const data = (await API.requesters[website](slug, titles)) || {};
data.website = _.capitalize(website);
return data;
}));
dataArr = dataArr.sort((a, b) => {
if (!b.chapter) return -1;
return b.chapter - a.chapter || b.lastChapterRead - a.lastChapterRead;
});
$('.dropdown-content').append(dataArr.map(data => `
<div class="link-wrapper ${!data.url ? 'fade' : ''}">
<a href="${data.url || '#'}" class="media-info-list__item">
${data.website} | ${data.chapter || '0'} (${data.lastChapterRead || '0'})
</a>
<span class="edit-link" data-website="${data.website.toUpperCase()}" data-slug="${slug}" data-open-modal="#edit-link-modal">
<i class="fa fa-pencil"></i>
</span>
</div>
`));
$(".dropdown-content a").click(() => $(".dropdown-content").hide());
break;
case 'Senkuro':
case 'Readmanga':
case 'Mangabuff':
tab.click();
const data = await API.requesters[state](slug, titles);
if (data) $('.media-sidebar button').after(`
<div class="button button_block link-wrapper">
<a href="${data.url || '#'}" class="button button_block button_primary">
${state} | ${data.chapter || '0'} (${data.lastChapterRead || '0'})
</a>
<span class="edit-link" data-website="${state.toUpperCase()}" data-slug="${slug}" data-open-modal="#edit-link-modal">
<i class="fa fa-pencil"></i>
</span>
</div>`);
}
$('.page-modals').append(this.modalDOM);
$('.edit-link').on('click', async function (e) {
const modal = $('#edit-link-modal');
modal.data('website', $(this).data('website')).data('slug', $(this).data('slug'));
const websiteSlug = await DB.get(DB.websites.MANGALIB, slug, DB.websites[$(this).data('website')]);
modal.find('input').val(websiteSlug ? API.links[$(this).data('website')](websiteSlug) : '');
});
$('#edit-link-modal .button_save').on('click', async function (e) {
const data = $('#edit-link-modal').data();
const fields = {...{link: ''}, ...$(e.target.form).serializeJSON()};
if (fields.link) fields.link = API.getSlugFromURL(data.website, fields.link);
await DB.set(DB.websites.MANGALIB, slug, DB.websites[data.website], fields.link);
unsafeWindow.location.reload();
});
$('#edit-link-modal .button_clean').on('click', async function (e) {
const data = $('#edit-link-modal').data();
await DB.delete(DB.websites.MANGALIB, slug, DB.websites[data.website]);
unsafeWindow.location.reload();
});
tab.text(`${tab.text()} | ${lastChapter}`);
setTimeout(() => $(team).click(), 100);
}
static async chapterPage() {
$(window).scroll(function () {
const scrolledTo = window.scrollY + window.innerHeight;
const isReachBottom = document.body.scrollHeight === scrolledTo;
if (isReachBottom) {
$('.reader-bookmark').not(".is-marked").click();
$('.reader-next__btn.button_label_right')[0]?.click();
}
});
}
}
const tokens = {};
if (!unsafeWindow.$) unsafeWindow.$ = $;
unsafeWindow.GM_xmlhttpRequest = GM.xmlHttpRequest;
$(unsafeWindow.document).ready(() => {
(async () => {
await DB.flushExpired();
const host = unsafeWindow.location.host;
const path = new URL(unsafeWindow.location).pathname.replace(/^\//, '');
switch (host) {
case API.hosts.MANGALIB:
tokens.SENKURO = await DB.get(DB.websites.SENKURO, '_GLOBAL', `token`, '');
if (!path.includes('/')) await Mangalib.mangaPage();
else if (path.match(/v\d+\/c\d+/)) await Mangalib.chapterPage();
break;
case API.hosts.SENKURO:
GM_cookie.list({name: "access_token"}, async (cookies, error) => {
await DB.set(DB.websites.SENKURO, '_GLOBAL', `token`, cookies?.[0]?.value);
});
const setInvalidateCache = async () => {
const path = new URL(unsafeWindow.location).pathname.replace(/^\//, '');
if (path.match(/chapters\/\d+\/pages\/\d+/g)) {
const slug = path.match(/[^\/]+/g)[1]
await DB.set(DB.websites.SENKURO, slug, 'invalidate', true);
}
}
await setInvalidateCache();
window.addEventListener('locationchange', setInvalidateCache);
break;
case API.hosts.MANGABUFF:
const history = JSON.parse(localStorage.getItem('history')) || [];
const slug = path.match(/[^\/]+/g)[1];
const existingVisit = history.find(visit => visit.slug === slug);
await DB.set(DB.websites.MANGABUFF, slug, 'invalidate', true);
if (existingVisit) await DB.set(DB.websites.MANGABUFF, slug, 'lastChapterRead', +existingVisit.chapter);
break;
default:
console.log('NOT IMPLEMENTED');
}
})()
});