Bangumi 年鉴

根据Bangumi的时光机数据生成年鉴

目前為 2025-01-21 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Bangumi 年鉴
// @description  根据Bangumi的时光机数据生成年鉴
// @namespace    syaro.io
// @version      1.2.6
// @author       神戸小鳥 @vickscarlet
// @license      MIT
// @include      /^https?://(bgm\.tv|chii\.in|bangumi\.tv)\/(user)\/.*/
// ==/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 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 },
    ];

    // 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(/<img (.*)\/?>/g, '<span class="img" $1></span>');
        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 = '<span>生成年鉴</span';

    const ytField = ma('fieldset');
    ytField.innerHTML = '<legend>选择年份与类型</legend>';
    const yearSelect = ce('select');
    yearSelect.innerHTML = new Array(year - 2007).fill(0)
        .map((_, i) => `<option value="${year - i}">${year - i}</option>`).join('');
    const typeSelect = ce('select');
    typeSelect.innerHTML = Object.entries(Types)
        .map(([value, name]) => `<option value="${value}">${name}</option>`).join('');
    ytField.appendChild(yearSelect);
    ytField.appendChild(typeSelect);
    const tagField = ma('fieldset');
    tagField.innerHTML = '<legend>选择过滤标签</legend>';
    const tagSelect = ce('select');
    tagField.appendChild(tagSelect);
    tagSelect.innerHTML = `<option value="">不筛选</option>`;
    const subtypeField = ma('fieldset');
    subtypeField.innerHTML = '<legend>选择包括的状态</legend>' + SubTypes
        .map(({ value, name, checked }) => `
        <div data-name="${name}">
            <input type="checkbox" id="yst_${value}" name="${name}" value="${value}" ${checked ? 'checked' : ''} />
            <label for="yst_${value}">${name}</label>
        </div>`)
        .join('');

    const changeType = async () => {
        const type = typeSelect.value;
        const action = TypeAction[type];
        subtypeField.querySelectorAll('div').forEach(e =>{
            const name = e.getAttribute('data-name').replace('$', action);
            e.querySelector('input').setAttribute('name', name);
            e.querySelector('label').innerText = name;
        });
        const tags = await ft(type);
        if (type != typeSelect.value) return;
        const last = tagSelect.value;
        const options = tags.map(t => `<option value="${t}">${t}</option>`).join('');
        tagSelect.innerHTML = `<option value="">不筛选</option>${options}`;
        if (tags.includes(last)) tagSelect.value = last;
    };
    typeSelect.onchange = changeType;
    changeType();

    let html2canvasloaded = false;
    const saveImage = (e, d) => {
        const done = () => {
            html2canvasloaded = true;
            html2canvas(e, {
                'allowTaint': true, 'logging': false, 'backgroundColor': '#1c1c1c'
            }).then(canvas => {
                const div = ce('div');
                div.id = 'kotori-report-canvas';
                div.appendChild(ce('div')).onclick = () => div.remove();
                div.appendChild(canvas);
                document.body.appendChild(div);
                d();
            });
        };
        if (html2canvasloaded) return done();
        const script = ce('script');
        script.type = 'text/javascript';
        script.src = 'https://html2canvas.hertzen.com/dist/html2canvas.min.js';
        script.onload = done;
        document.body.appendChild(script);
    }
    const go = ma('div');
    go.className = 'btn';
    go.innerText = '生成';
    const l = ['|', '/', '-', '\\'];
    const gen = async () => {
        go.onclick = null;
        let i = 0;
        const id = setInterval(() => go.innerText = `抓取数据中[${l[i++ % 4]}]`, 50);
        const y = parseInt(yearSelect.value) || year;
        const t = typeSelect.value || 'anime';
        const g = tagSelect.value;
        const sts = Array.from(subtypeField.querySelectorAll('input:checked')).map(e => e.value)
        const list = await collects(t, y, sts);
        go.onclick = gen;
        clearInterval(id);
        go.innerText = '生成';
        const filterList = list.filter(({ year, tags }) => year == y && (!g || g && tags.includes(g)));
        msw.set(false);
        let count = new Array(12).fill(0);
        const stars = new Array(11).fill(0);
        let last = -1;
        const lis = [];
        for (const { img, month, star } of filterList) {
            count[month]++;
            stars[star]++;
            let monthTag = '';
            if (month != last) {
                monthTag = `<span> ${month + 1}月</span > `;
                last = month;
            }
            lis.push(`<li> <img src="${img}">${monthTag}<div class="star star${star}"></div></li>`);
        }
        const eT = `<h1> ${y}年 Bangumi ${Types[t]}年鉴 @${uid} <br><br>总标记数:${filterList.length}</h1>`;
        const eU = `<ul class="l" type="${t}">${lis.join('')}</ul>`;
        const bU = (l, t, d = 0) => {
            const max = Math.max(...l);
            l = l.map((c, i) =>
                `<li><span>${i + d}${t}</span><span>${c}</span><div style="width:${c * 100 / max}%;"></div></li>`
            ).join('');
            return `<ul class="c">${l}</ul>`;
        }

        const content = ce('div');
        content.className = 'content';
        content.innerHTML = [
            eT,
            bU(count, '月', 1),
            bU(stars, '星'),
            eU
        ].join('');
        const close = ce('div');
        close.className = 'close';
        close.onclick = () => div.remove();
        const save = ce('div');
        save.className = 'save';
        const s = () => {
            save.onclick = null;
            saveImage(content, () => save.onclick = s)
        };
        save.onclick = s;
        const div = ce('div');
        div.appendChild(close);
        div.appendChild(content);
        div.appendChild(save);
        div.id = 'kotori-report';
        document.body.appendChild(div);
    };

    go.onclick = gen;
    document.querySelector('#headerProfile .actions').append(btn);

    // style
    const style = ce('style');
    document.head.appendChild(style);
    style.innerHTML = `
.btn {
    user - select: none;
    cursor: pointer;
}

#kotori-report-menu {
    color: #fff;
    position: fixed;
    display: block;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    padding: 20px;
    padding-top: 50px;
    background: #0d111788;
    backdrop-filter: blur(4px);
    border-radius: 10px;
    box-shadow: 2px 2px 10px #00000088;
    border: 1px solid #fc899422;
    min-width: 150px;
}

#kotori-report-menu::before {
    position: absolute;
    content: "菜单";
    padding: 0 20px;
    top: -1px;
    right: -1px;
    left: -1px;
    height: 30px;
    line-height: 30px;
    background: #fc8994;
    backdrop-filter: blur(4px);
    border-radius: 10px 10px 0 0;
}

#kotori-report-menu > li {
    margin - top: 10px;
}

#kotori-report-menu > li:first-child {
    margin - top: 0;
}

#kotori-report-menu > li > .btn {
    width: 100%;
    padding: 10px 0;
    background: #fc899444;
    border: inset 2px solid #fc8994;
    text-align: center;
    border-radius: 5px;
    transition: all 0.3s;
    font-family: consolas, 'courier new', monospace, courier;
}

#kotori-report-menu > li > .btn:hover {
    width: 100%;
    padding: 10px 0;
    background: #fc8994;
    border: 2px solid #fc8994 inset;
    text-align: center;
    border-radius: 5px;
    transition: all 0.3s;
}

#kotori-report-menu fieldset {
    display: flex;
    gap: 5px;
    min-inline-size: min-content;
    margin-inline: 1px;
    border-width: 1px;
    border-style: groove;
    border-color: threedface;
    border-image: initial;
    padding-block: 0.35em 0.625em;
    padding-inline: 0.75em;
}

#kotori-report-menu fieldset > div {
    display: flex;
    gap: 2px;
    justify-content: center;
}

#kotori-report-canvas,
#kotori-report {
    color: #fff;
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0,0,0,0.3);
    backdrop-filter: blur(2px);
    overflow: scroll;
    padding: 30px;
    scrollbar-width: none;
    -ms-overflow-style: none;
}

#kotori-report-canvas::-webkit-scrollbar,
#kotori-report::-webkit-scrollbar {
    display: none;
}

#kotori-report-canvas > div,
#kotori-report > .close {
    position: absolute;
    top: 0;
    right: 0;
    left: 0;
    bottom: 0;
}

#kotori-report > .save {
    position: absolute;
    top: 10px;
    right: 10px;
    width: 40px;
    height: 40px;
    background: #fc8994;
    border-radius: 40px;
    border: 4px solid #fc8994;
    cursor: pointer;
    box-shadow: 2px 2px 10px #00000088;
    user-select: none;
    line-height: 40px;
    background-size: 40px;
    background-image: url(data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMzMwIDMzMCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE2NSwwQzc0LjAxOSwwLDAsNzQuMDE4LDAsMTY1YzAsOTAuOTgsNzQuMDE5LDE2NSwxNjUsMTY1czE2NS03NC4wMiwxNjUtMTY1QzMzMCw3NC4wMTgsMjU1Ljk4MSwwLDE2NSwweiBNMTY1LDMwMGMtNzQuNDM5LDAtMTM1LTYwLjU2MS0xMzUtMTM1UzkwLjU2MSwzMCwxNjUsMzBzMTM1LDYwLjU2MSwxMzUsMTM1UzIzOS40MzksMzAwLDE2NSwzMDB6Ii8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTIxMS42NjcsMTI3LjEyMWwtMzEuNjY5LDMxLjY2NlY3NWMwLTguMjg1LTYuNzE2LTE1LTE1LTE1Yy04LjI4NCwwLTE1LDYuNzE1LTE1LDE1djgzLjc4N2wtMzEuNjY1LTMxLjY2NmMtNS44NTctNS44NTctMTUuMzU1LTUuODU3LTIxLjIxMywwYy01Ljg1OCw1Ljg1OS01Ljg1OCwxNS4zNTUsMCwyMS4yMTNsNTcuMjcxLDU3LjI3MWMyLjkyOSwyLjkzLDYuNzY4LDQuMzk1LDEwLjYwNiw0LjM5NWMzLjgzOCwwLDcuNjc4LTEuNDY1LDEwLjYwNy00LjM5M2w1Ny4yNzUtNTcuMjcxYzUuODU3LTUuODU3LDUuODU4LTE1LjM1NSwwLjAwMS0yMS4yMTVDMjI3LjAyMSwxMjEuMjY0LDIxNy41MjQsMTIxLjI2NCwyMTEuNjY3LDEyNy4xMjF6Ii8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE5NSwyNDBoLTYwYy04LjI4NCwwLTE1LDYuNzE1LTE1LDE1YzAsOC4yODMsNi43MTYsMTUsMTUsMTVoNjBjOC4yODQsMCwxNS02LjcxNywxNS0xNUMyMTAsMjQ2LjcxNSwyMDMuMjg0LDI0MCwxOTUsMjQweiIvPjwvc3ZnPg==);
    opacity: 0.8;
    z-index: 9999999999999;
}

#kotori-report > .content {
    width: 1078px;
    margin: 0 auto;
}

#kotori-report > .content > h1 {
    padding: 30px 0;
    text-align: center;
}

#kotori-report > .content > ul.l > li {
    display: inline-block;
    position: relative;
    width: 150px;
    height: 225px;
    margin: 2px;
    overflow: hidden;
}

#kotori-report > .content > ul.l[type="music"] > li {
    height: 155px;
}


#kotori-report > .content > ul.l > li:after {
    content: "";
    position: absolute;
    top: 0;
    right: 0;
    bottom: 5px;
    left: 0;
    border-width: 1px;
    border-style: solid;
    border-image: linear-gradient(to right, #ff0000 0%, #00fb00 100%) 1;
}

#kotori-report > .content > ul.l > li .star {
    display: block;
    position: absolute;
    bottom: 0;
    left: 2px;
    height: 5px;
    background: linear-gradient(
        to right,
        #ff0000 0px 11px,
        #00000000 11px 15px,
        #ff0000 15px 26px,
        #00000000 26px 30px,
        #ff3300 30px 41px,
        #00000000 41px 45px,
        #ffaa00 45px 56px,
        #00000000 56px 60px,
        #ffdd00 60px 71px,
        #00000000 71px 75px,
        #ffff22 75px 86px,
        #00000000 86px 90px,
        #ccff22 90px 101px,
        #00000000 101px 105px,
        #76ff57 105px 116px,
        #00000000 116px 120px,
        #00fb00 120px 131px,
        #00000000 131px 135px,
        #00fb00 135px 146px,
        #00000000 146px 150px
    );
}



#kotori-report > .content > ul.l > li .star.star0  { width: 0px; }
#kotori-report > .content > ul.l > li .star.star1  { width: 13px; }
#kotori-report > .content > ul.l > li .star.star2  { width: 28px; }
#kotori-report > .content > ul.l > li .star.star3  { width: 43px; }
#kotori-report > .content > ul.l > li .star.star4  { width: 58px; }
#kotori-report > .content > ul.l > li .star.star5  { width: 73px; }
#kotori-report > .content > ul.l > li .star.star6  { width: 88px; }
#kotori-report > .content > ul.l > li .star.star7  { width: 103px; }
#kotori-report > .content > ul.l > li .star.star8  { width: 118px; }
#kotori-report > .content > ul.l > li .star.star9  { width: 133px; }
#kotori-report > .content > ul.l > li .star.star10 { width: 148px; }

#kotori-report > .content > ul.l > li span {
    width: 50px;
    height: 30px;
    position: absolute;
    top: 0;
    left: 0;
    line-height: 30px;
    text-align: center;
    font-size: 18px;
    background: #8c49548c;
    backdrop-filter: blur(2px);
}

#kotori-report > .content > ul.l > li img {
    max-height: calc(100% - 5px);
    position: absolute;
    top: 0;
    left: 50%;
    transform: translateX(-50%);
}

#kotori-report > .content > ul.c {
    display: inline-block;
    position: relative;
    width: calc(50% - 4px);
    margin: 2px;
}

#kotori-report > .content > ul.c > li {
    display: block;
    position: relative;
    width: 100%;
    height: 20px;
    background: #0008;
    margin: 2px;
    line-height: 20px;
    backdrop-filter: blur(2px);
}

#kotori-report > .content > ul.c > li > span {
    position: absolute;
    left: 0;
    text-shadow: 0 0 2px #000;
}

#kotori-report > .content > ul.c > li > span:nth-child(2) {
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
}

#kotori-report > .content > ul.c > li > div {
    display: inline-block;
    height: 100%;
    background: #fc8994aa;
    margin: 0;
}

#kotori-report-canvas > canvas {
    position: absolute;
    top: 0;
    left: 50%;
    transform: translateX(-50%) scale(0.8);
}

@media screen and (min-width: 214px) {
    #kotori - report > .content {
        width: 154px;
    }
}
@media screen and (min-width: 368px) {
    #kotori - report > .content {
        width: 308px;
    }
}
@media screen and (min-width: 522px) {
    #kotori - report > .content {
        width: 462px;
    }
}
@media screen and (min-width: 616px) {
    #kotori - report > .content {
        width: 616px;
    }
}
@media screen and (min-width: 830px) {
    #kotori - report > .content {
        width: 770px;
    }
}
@media screen and (min-width: 924px) {
    #kotori - report > .content {
        width: 924px;
    }
}
@media screen and (min-width: 1138px) {
    #kotori - report > .content {
        width: 1078px;
    }
}
        `;

})();