Skip to content

Instantly share code, notes, and snippets.

@guillemcanal
Created May 15, 2022 15:49
Show Gist options
  • Save guillemcanal/b8c2fb271dd8fd070dd0ace94198a9aa to your computer and use it in GitHub Desktop.
Save guillemcanal/b8c2fb271dd8fd070dd0ace94198a9aa to your computer and use it in GitHub Desktop.
Livechart.me Pimped List
// ==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