Created
May 15, 2022 15:49
-
-
Save guillemcanal/b8c2fb271dd8fd070dd0ace94198a9aa to your computer and use it in GitHub Desktop.
Livechart.me Pimped List
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Pimp My List | |
// @namespace http://tampermonkey.net/ | |
// @version 0.1 | |
// @description Pin unwatched animes on top of the list, remember filters, add priorities to "considered" animes | |
// @author Guillem CANAL | |
// @match https://www.livechart.me/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=livechart.me | |
// @grant none | |
// ==/UserScript== | |
// TODO: Extract Chrunchyroll links from the homepage | |
// [...document.querySelectorAll('article')] | |
// .map(e => ({id: e.getAttribute('data-anime-id'), link: e.querySelector('a.crunchyroll-icon')?.href || null})) | |
// .filter(e => e.link !== null) | |
// .reduce((acc, c) => { acc[c.id] = {type: "crunchyroll", link: c.link}; return acc;}, {}) | |
(function() { | |
'use strict'; | |
// utility functions/objects | |
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x); | |
const attr = name => el => el.getAttribute(name) || null; | |
const nonDigits = /[^\d]/g; | |
const toInt = v => parseInt((v || '0').replaceAll(nonDigits, '') || '0') || 0; | |
const pathname = window.location.pathname; | |
const storageGet = name => window.localStorage.getItem(name); | |
const storageSet = name => value => window.localStorage.setItem(name, value); | |
const toEntries = object => Object.entries(object); | |
const filterKeys = keys => entries => entries.filter(([k]) => !keys.includes(k)); | |
const toObject = entries => entries.reduce((o, [k,v]) => ({...o, [k]: v}), {}); | |
const omit = (...keys) => object => pipe(toEntries, filterKeys(keys), toObject)(object); | |
const match = (value, cases, defaultValue) => cases[value] || defaultValue; | |
const find = selector => el => el.querySelector(selector); | |
const innerText = el => el.innerText; | |
const sum = (a,b) => a+b; | |
const ifValue = (target, then) => value => value === target ? then : value; | |
// data functions | |
const sortByScore = (a, b) => a.score.average > b.score.average; | |
const computeScore = entry => { | |
// using a 0 to 100 scale for each scores | |
const scores = { | |
airing: entry.airing ? 100 : 0, | |
status: match(entry.status, {watching: 100, considering: 75, completed: 50, skipped: 25}, 0), | |
ranking: entry.ranking * 10, | |
// "considered" animes are scored between 50~100% using a priority score | |
priority: 50 + (entry.priority / 2), | |
// unwatched animes marked as "watching" are scored between 90~100%, 0% otherwize | |
completion: entry.status === 'watching' && entry.unwatched > 0 ? 90 + (entry.watched * 10 / entry.lastPublished) : 0 | |
}; | |
let weight = { | |
completion: 4, | |
status: 4, | |
airing: 1, | |
ranking: 4, | |
priority: 1, | |
}; | |
if (entry.status !== 'watching') { | |
weight = omit('airing')(weight); | |
} | |
if (entry.status === 'considering') { | |
weight = omit('ranking')(weight); | |
} | |
if (entry.status !== 'considering') { | |
weight = omit('priority')(weight); | |
} | |
// compute the weighted average | |
const average = Object | |
.entries(weight) | |
.map(([name, value]) => scores[name] * value) | |
.reduce(sum) | |
/ Object | |
.values(weight) | |
.reduce(sum); | |
return {scores, average}; | |
}; | |
const links = pipe(storageGet, JSON.parse)('links') || {}; | |
const allPriorities = () => pipe(storageGet, JSON.parse)('priorities') || {}; | |
const priorities = allPriorities(); | |
const extractAnimeData = el => { | |
const info = { | |
el: el, | |
id: attr('data-user-library-anime-id')(el), | |
title: attr('data-user-library-anime-title')(el), | |
status: attr('data-user-library-anime-status')(el), | |
total : pipe(attr('data-user-library-anime-episode-count'), toInt)(el), | |
next: pipe(attr('data-user-library-anime-countdown-label'), toInt)(el), | |
watched: pipe(attr('data-user-library-anime-episodes-watched'), toInt)(el), | |
ranking: pipe(find('span[data-user-library-anime-target="ownerRating"]'), innerText, toInt, ifValue(0, 5))(el) | |
}; | |
info.priority = priorities[info.id] || 0; | |
info.lastPublished = info.next !== 0 ? info.next - 1 : info.total; | |
info.unwatched = info.lastPublished - info.watched; | |
info.airing = info.next > 1; | |
info.link = links[info.id]?.link || '#'; | |
info.score = computeScore(info); | |
return info; | |
}; | |
const onMyListPage = () => { | |
storageSet('list_filters')(window.location.href); | |
const scoreTpl = (type, value) =>`<small style="white-space: nowrap; color: #444; display:block">${type}: ${value.toFixed(2)}</small>`; | |
const entries = [...document.querySelectorAll('tr')].map(extractAnimeData); | |
const lol = entries | |
.map(entry => { entry.el.prepend(document.createElement('td')); return entry; }) | |
.sort(sortByScore) | |
.forEach(entry => { | |
const $cell = entry.el.firstChild; | |
$cell.style['text-align'] = 'center'; | |
if (entry.unwatched && entry.status === "watching") { | |
$cell.innerHTML += `<a href="${entry.link}" target="_blank" style="display: inline-block; min-width: 50px; text-align: center; border-radius: 15px; padding: 2px 5px 2px 0; white-space: nowrap; ${entry.airing ? 'color: #fff; background-color: #3b97fc;' : 'color: #3b97fc; border: 1px solid #3b97fc;'}"><i class="icon-notifications"></i>${entry.unwatched}</a>`; | |
} | |
if (entry.status === 'considering') { | |
const priority = priorities[entry.id] || null; | |
const addOption = (value, text) => `<option value="${value}" ${priority == value ? "selected" : ""}>${text}</option>`; | |
$cell.innerHTML += `<select data-id="${entry.id}" class="priority"><option value=""></option>${addOption(100, '⮝')}${addOption(50, '⮞')}${addOption(25, '⮟')}</select>`; | |
} | |
$cell.innerHTML += scoreTpl('score', entry.score.average); | |
entry.el.parentNode.prepend(entry.el); | |
}); | |
document.body.addEventListener('change', e => { | |
if(e.target.classList.contains('priority')) { | |
const animeID = e.target.getAttribute('data-id'); | |
const score = parseInt(e.target.options[e.target.selectedIndex].value) || null; | |
if (score) { | |
pipe(JSON.stringify, storageSet('priorities'))({...allPriorities(), [animeID]: parseInt(score)}); | |
} else { | |
pipe(omit(animeID), JSON.stringify, storageSet('priorities'))(allPriorities()); | |
} | |
} | |
}); | |
}; | |
const onAnimeDetailPage = () => { | |
const animeID = pathname.split('/').slice(-1).find(e => true); | |
const crunchyrollLink = [...document.querySelectorAll('ul#streams-list > li > a')] | |
.find(e => (new URL(e.href).hostname === 'www.crunchyroll.com'))?.href || null; | |
if(!(animeID in links)) { | |
const newLinks = {...links, [animeID]: {type: "crunchyroll", link: crunchyrollLink}}; | |
pipe(JSON.stringify, storageSet('links'))(newLinks); | |
} | |
}; | |
const onEveryPage = () => { | |
const link = document.querySelector('a[title="My list"]') | |
link.href = storageGet('list_filters') || link.href; | |
}; | |
switch(true) { | |
case pathname.includes('library'): | |
onMyListPage(); | |
break; | |
case pathname.includes('anime'): | |
onAnimeDetailPage(); | |
break; | |
}; | |
onEveryPage(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment