// ==UserScript==
// @name Bangumi 年鉴
// @description 根据Bangumi的时光机数据生成年鉴
// @namespace syaro.io
// @version 1.3.3
// @author 神戸小鳥 @vickscarlet
// @license MIT
// @include /^https?://(bgm\.tv|chii\.in|bangumi\.tv)\/(user)\/.*/
// @downloadURL none
// ==/UserScript==
(async () => {
const uid = /\/user\/(.+)?(\/.*)?/.exec(window.location.href)?.[1];
const ce = name => document.createElement(name);
const STAR_PATH = 'M60.556381,172.206 C60.1080307,172.639 59.9043306,173.263 60.0093306,173.875 L60.6865811,177.791 C60.8976313,179.01 59.9211306,180 58.8133798,180 C58.5214796,180 58.2201294,179.931 57.9282291,179.779 L54.3844766,177.93 C54.1072764,177.786 53.8038262,177.714 53.499326,177.714 C53.1958758,177.714 52.8924256,177.786 52.6152254,177.93 L49.0714729,179.779 C48.7795727,179.931 48.4782224,180 48.1863222,180 C47.0785715,180 46.1020708,179.01 46.3131209,177.791 L46.9903714,173.875 C47.0953715,173.263 46.8916713,172.639 46.443321,172.206 L43.575769,169.433 C42.4480682,168.342 43.0707186,166.441 44.6289197,166.216 L48.5916225,165.645 C49.211123,165.556 49.7466233,165.17 50.0227735,164.613 L51.7951748,161.051 C52.143775,160.35 52.8220755,160 53.499326,160 C54.1776265,160 54.855927,160.35 55.2045272,161.051 L56.9769285,164.613 C57.2530787,165.17 57.7885791,165.556 58.4080795,165.645 L62.3707823,166.216 C63.9289834,166.441 64.5516338,168.342 63.423933,169.433 L60.556381,172.206 Z';
const STAR_SVG = ``;
const STAR_URL = URL.createObjectURL(new Blob([STAR_SVG], { type: 'image/svg+xml' }));
// indexedDB cache
class DB {
#dbName = 'mcache';
#version = 1;
#collection = 'pages';
#keyPath = 'url';
#db;
static #gdb;
static async initInstance() {
if (!this.#gdb) this.#gdb = await new DB().init();
return this.#gdb;
}
static instance() {
if (!this.#gdb) throw new Error('DB not initInstance');
return this.#gdb;
}
async init() {
this.#db = await new Promise((resolve, reject) => {
const request = window.indexedDB.open(this.#dbName, this.#version);
request.onerror = event => reject(event.target.error);
request.onsuccess = event => resolve(event.target.result);
request.onupgradeneeded = event => {
if (event.target.result.objectStoreNames.contains(this.#collection)) return;
event.target.result.createObjectStore(this.#collection, { keyPath: this.#keyPath });
};
});
return this;
}
async #store(handle, mode = 'readonly') {
return new Promise((resolve, reject) => {
const transaction = this.#db.transaction(this.#collection, mode);
const store = transaction.objectStore(this.#collection);
let result;
new Promise((rs, rj) => handle(store, rs, rj))
.then(ret => result = ret)
.catch(reject);
transaction.onerror = () => reject(new Error('Transaction error'));
transaction.oncomplete = () => resolve(result);
});
}
async get(key, index) {
return this.#store((store, resolve, reject) => {
if (index) store = store.index(index);
const request = store.get(key);
request.onerror = reject;
request.onsuccess = () => resolve(request.result);
})
.catch(null);
}
async put(data) {
return this.#store((store, resolve, reject) => {
const request = store.put(data);
request.onerror = reject;
request.onsuccess = () => resolve(true);
}, 'readwrite')
.catch(false);
}
async clear() {
return this.#store((store, resolve, reject) => {
const request = store.clear();
request.onerror = reject;
request.onsuccess = () => resolve(true);
}, 'readwrite')
.catch(false);
}
}
await DB.initInstance();
const Types = {
anime: '动画',
game: '游戏',
music: '音乐',
book: '图书',
real: '三次元',
}
const TypeAction = {
anime: '看',
game: '玩',
music: '听',
book: '读',
real: '看',
}
const SubTypes = [
{ value: 'collect', name: '$过', checked: true },
{ value: 'do', name: '在$', checked: false },
{ value: 'dropped', name: '抛弃', checked: false },
{ value: 'on_hold', name: '搁置', checked: false },
{ value: 'wish', name: '想$', checked: false },
];
// function easeIn(curtime, begin, end, duration) {
// let x = curtime / duration;
// let y = x * x;
// return begin + (end - begin) * y;
// }
function easeOut(curtime, begin, end, duration) {
let x = curtime / duration;
let y = -x * x + 2 * x;
return begin + (end - begin) * y;
}
// function easeInout(curtime, begin, end, duration) {
// if (curtime < duration / 2) {
// return easeIn(curtime, begin, (begin + end) / 2, duration / 2);
// } else {
// let curtime1 = curtime - duration / 2;
// let begin1 = (begin + end) / 2;
// return easeOut(curtime1, begin1, end, duration / 2);
// }
// }
function groupBy(list, group) {
const groups = new Map();
for (const item of list) {
const key = typeof group == 'function' ? group(item) : item[group];
if (groups.has(key)) groups.get(key).push(item);
else groups.set(key, [item]);
}
return groups;
}
function listAdd(first, ...adds) {
const ret = Array.from(first);
for (const list of adds) {
for (const i in list) {
if (i >= ret.length) ret.push(list[i]);
else ret[i] += list[i];
}
}
return ret;
}
// LOAD DATA START
async function f(url) {
const html = await fetch(window.location.origin + '/' + url).then(res => res.text());
if (html.includes('503 Service Temporarily Unavailable')) return null;
const e = ce('html');
e.innerHTML = html.replace(//g, '');
return e;
};
async function fl(type, subType, p = 1, expire = 30) {
const url = `${type}/list/${uid}/${subType}?page=${p}`;
let data = await DB.instance().get('0x1@' + url);
if (data && data.time + expire * 60000 > Date.now()) return data;
const e = await f(url);
const list = Array
.from(e.querySelectorAll('#browserItemList > li'))
.map(li => {
const data = { subtype: subType };
data.id = li.querySelector('a').href.split('/').pop();
const title = li.querySelector('h3');
data.title = title.querySelector('a').innerText;
data.jp_title = title.querySelector('small')?.innerText;
data.img = li.querySelector('span.img')
?.getAttribute('src').replace('cover/c', 'cover/l')
|| '//bgm.tv/img/no_icon_subject.png';
data.time = new Date(li.querySelector('span.tip_j').innerText);
data.year = data.time.getFullYear();
data.month = data.time.getMonth();
data.star = parseInt(li.querySelector('span.starlight')?.className.match(/stars(\d{1,2})/)[1]) || 0;
data.tags = li.querySelector('span.tip')?.textContent.trim().match(/标签:\s*(.*)/)?.[1].split(/\s+/) || [];
return data;
});
const max = Number(e.querySelector('span.p_edge')?.textContent.match(/\/\s*(\d+)\s*\)/)?.[1] || 1);
const time = Date.now();
data = { url: '0x1@' + url, list, max, time };
if (p == 1) {
const tags = Array
.from(e.querySelectorAll('#userTagList > li > a.l'))
.map(l => l.childNodes[1].textContent);
data.tags = tags;
}
await DB.instance().put(data);
return data;
}
async function ft(type) {
const { tags } = await fl(type, 'collect')
return tags
}
async function bsycs(type, subType, year) {
const { max } = await fl(type, subType);
console.info('Total', type, subType, max, 'page');
console.info('BSearch by year', year);
let startL = 1;
let startR = 1;
let endL = max;
let endR = max;
let dL = false;
let dR = false;
while (startL <= endL && startR <= endR) {
const mid = startL < endL
? Math.max(Math.min(Math.floor((startL + endL) / 2), endL), startL)
: Math.max(Math.min(Math.floor((startR + endR) / 2), endR), startR)
const { list } = await fl(type, subType, mid);
if (list.length == 0) return [1, 1];
const first = list[0].year;
const last = list[list.length - 1].year;
console.info(`\tBSearch page`, mid, ' ', '\t[', first, last, ']');
if (first > year && last < year) return [mid, mid];
if (last > year) {
if (!dL) startL = Math.min(mid + 1, endL);
if (!dR) startR = Math.min(mid + 1, endR);
} else if (first < year) {
if (!dL) endL = Math.max(mid - 1, startL);
if (!dR) endR = Math.max(mid - 1, startR);
} else if (first == last) {
if (!dL) endL = Math.max(mid - 1, startL);
if (!dR) startR = Math.min(mid + 1, endR);
} else if (first == year) {
startR = endR = mid;
if (!dL) endL = Math.min(mid + 1, endR);
} else if (last == year) {
startL = endL = mid;
if (!dL) startR = Math.min(mid + 1, endR);
}
if (startL == endL) dL = true;
if (startR == endR) dR = true;
if (dL && dR) return [startL, startR];
}
}
async function cbt(type, subtype, year) {
if (!year) return cbtAll(type, subtype);
return cbtYear(type, subtype, year);
};
async function cbtYear(type, subtype, year) {
const [start, end] = await bsycs(type, subtype, year);
console.info('Collect pages [', start, end, ']');
const ret = [];
for (let i = start; i <= end; i++) {
console.info('\tCollect page', i);
const { list } = await fl(type, subtype, i);
ret.push(list);
}
return ret.flat();
}
async function cbtAll(type, subtype) {
const { list, max } = await fl(type, subtype, 1);
console.info('Collect pages [', 1, max, ']');
const ret = [list];
for (let i = 2; i <= max; i++) {
console.info('\tCollect page', i);
const { list } = await fl(type, subtype, i);
ret.push(list);
}
return ret.flat();
}
async function collects({ type, subTypes, tag, year }) {
const ret = [];
for (const subtype of subTypes) {
const list = await cbt(type, subtype, year);
ret.push(list);
}
const fset = new Set();
return ret.flat()
.filter(({ id, year: y, tags }) => {
if (year && year != y) return false;
if (tag && !tags.includes(tag)) return false;
if (fset.has(id)) return false;
fset.add(id);
return true;
})
.sort(({ time: a }, { time: b }) => b - a);
}
// LOAD DATA END
// SAVE IMAGE START
const loaded = new Set();
async function loadScript(url) {
if (loaded.has(url)) return;
return new Promise((resolve, reject) => {
const script = ce('script');
script.type = 'text/javascript';
script.src = url;
script.onload = () => {
loaded.add(url);
resolve();
};
document.body.appendChild(script);
})
}
async function element2Canvas(element, done) {
await loadScript('https://html2canvas.hertzen.com/dist/html2canvas.min.js');
const canvas = await html2canvas(element, {
allowTaint: true,
logging: false,
backgroundColor: '#1c1c1c'
})
const div = ce('div');
const close = ce('div');
div.id = 'kotori-report-canvas';
close.style.height = canvas.style.height;
close.addEventListener('click', () => div.remove());
div.appendChild(close);
div.appendChild(canvas);
document.body.appendChild(div);
done();
}
// SAVE IMAGE END
// REPORT START
function propsGen(props) {
return Object.entries(props).map(([key, value]) => `${key}="${value}"`).join(' ');
}
function ulGen(list, props) {
return `