// ==UserScript==
// @name Bangumi 年鉴
// @description 根据Bangumi的时光机数据生成年鉴
// @namespace syaro.io
// @version 1.2.3
// @author 神戸小鳥 @vickscarlet
// @license MIT
// @include /^https?://(bgm\.tv|chii\.in|bangumi\.tv)\/(user)\/.*/
// @downloadURL none
// ==/UserScript==
(async () => {
const origin = window.location.origin;
const uid = window.location.href.match(/\/user\/(.+)?(\/.*)?/)[1];
const year = new Date().getFullYear();
const ce = name => document.createElement(name);
const Types = {
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 },
];
// indexedDB cache
class DB {
constructor() { }
#dbName = 'mcache';
#version = 1;
#collection = 'pages';
#keyPath = 'url';
#db;
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 });
};
});
}
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);
}
}
const db = new DB();
await db.init();
const f = async url => {
const html = await fetch(url).then(res => res.text());
if (html.match(/503 Service Temporarily Unavailable/)) return null;
const e = ce('html');
e.innerHTML = html.replace(//g, '');
return e;
};
const fl = async (type, subtype, p = 1, expire = 30) => {
const url = `${origin}/${type}/list/${uid}/${subtype}?page=${p}`;
let data = await db.get(url);
if (data && data.time + expire * 60000 > Date.now()) return data;
const e = await f(`${origin}/${type}/list/${uid}/${subtype}?page=${p}`, 30);
const list = Array
.from(e.querySelectorAll('#browserItemList > li'))
.map(li => {
const data = { 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, 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.put(data);
return data;
}
const ft = async (type) => fl(type, 'collect').then(({ tags }) => tags)
const bsycs = async (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];
}
}
const cbt = async (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();
};
const collects = async (type, year, subtypes) => {
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 }) => {
if (fset.has(id))
return false;
fset.add(id);
return true;
})
.sort(({ time: a }, { time: b }) => b - a);
}
const menu = ce('ul');
document.body.appendChild(menu);
const ma = name => menu.appendChild(ce('li')).appendChild(ce(name));
menu.id = 'kotori-report-menu';
const msw = {
_: true,
get() { return this._ },
set(v) { this._ = v; menu.style.display = v ? 'block' : 'none'; },
toggle() { this.set(!this.get()); },
};
msw.toggle();
const btn = ce('a');
btn.onclick = () => msw.set(true);
btn.className = 'chiiBtn';
btn.href = 'javascript:void(0)';
btn.title = '生成年鉴';
btn.innerHTML = '生成年鉴选择年份与类型';
const yearSelect = ce('select');
yearSelect.innerHTML = new Array(year - 2007).fill(0)
.map((_, i) => ``).join('');
const typeSelect = ce('select');
typeSelect.innerHTML = Object.entries(Types)
.map(([value, name]) => ``).join('');
ytField.appendChild(yearSelect);
ytField.appendChild(typeSelect);
const tagField = ma('fieldset');
tagField.innerHTML = '';
const tagSelect = ce('select');
tagField.appendChild(tagSelect);
tagSelect.innerHTML = ``;
const changeType = async () => {
const type = typeSelect.value;
const tags = await ft(type);
if (type != typeSelect.value) return;
const last = tagSelect.value;
const options = tags.map(t => ``).join('');
tagSelect.innerHTML = `${options}`;
if (tags.includes(last)) tagSelect.value = last;
};
typeSelect.onchange = changeType;
changeType();
const subtypeField = ma('fieldset');
subtypeField.innerHTML = '' + SubTypes
.map(({ value, name, checked }) => `