Greasy Fork

Greasy Fork is available in English.

Bangumi 年鉴

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

当前为 2025-01-21 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Bangumi 年鉴
// @description  根据Bangumi的时光机数据生成年鉴
// @namespace    syaro.io
// @version      1.2.5
// @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 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 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 => `<option value="${t}">${t}</option>`).join('');
        tagSelect.innerHTML = `<option value="">不筛选</option>${options}`;
        if (tags.includes(last)) tagSelect.value = last;
    };
    typeSelect.onchange = changeType;
    changeType();
    const subtypeField = ma('fieldset');
    subtypeField.innerHTML = '<legend>选择包括的状态</legend>' + SubTypes
        .map(({ value, name, checked }) => `
        <div>
            <input type="checkbox" id="yst_${value}" name="${name}" value="${value}" ${checked ? 'checked' : ''} />
            <label for="yst_${value}">${name}</label>
        </div>`)
        .join('');


    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;
    }
}
        `;

})();