Bangumi 年鉴

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

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

// ==UserScript==
// @name         Bangumi 年鉴
// @description  根据Bangumi的时光机数据生成年鉴
// @namespace    syaro.io
// @version      1.2.7
// @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 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 = `<svg fill="#ffde20" width="800px" height="800px" viewBox="43 159.5 21 21" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="${STAR_PATH}"></path></svg>`;
    const STAR_URL = URL.createObjectURL(new Blob([STAR_SVG], {type: 'image/svg+xml'}));
    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);
        }
    }

    function easeInQuad(curtime,begin,end,duration){
        let x = curtime/duration; //x值
        let y = x*x; //y值
        return begin+(end-begin)*y; //套入最初的公式
    }
    function easeOutQuad(curtime,begin,end,duration){
        let x = curtime/duration;         //x值
        let y = -x*x + 2*x;  //y值
        return begin+(end-begin)*y;        //套入最初的公式
    }
    function easeInoutQuad(curtime,begin,end,duration){
        if(curtime<duration/2){ //前半段时间
            return easeInQuad(curtime,begin,(begin+end)/2,duration/2);//改变量和时间都除以2
        }else{
            let curtime1 = curtime-duration/2; //注意时间要减去前半段时间
            let begin1 = (begin+end)/2;//初始量要加上前半段已经完成的
            return easeOutQuad(curtime1,begin1,end,duration/2);//改变量和时间都除以2
        }
    }

    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');
                const close = ce('div');
                close.style.height = canvas.style.height;
                div.id = 'kotori-report-canvas';
                div.appendChild(close).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]++;
            const IMG = `<img src="${img}">`
            const STAR = !star?'':`<div class="star"><img src="${STAR_URL}"><span>${star}</span></div>`;
            let MONTH = '';
            if (month != last) {
                MONTH = `<span> ${month + 1}月</span > `;
                last = month;
            }
            lis.push(`<li>${IMG}${MONTH}${STAR}</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) => {
            const max = Math.max(...l);
            l = l.map((c, i) =>
                `<li><span>${t(i)}</span><span>${c}</span><div style="width:${c * 100 / max}%;"></div></li>`
            ).join('');
            return `<ul class="c">${l}</ul>`;
        }

        const report = ce('div');
        const scroll = ce('div');
        const content = ce('div');
        const close = ce('div');
        const save = ce('div');
        report.id = 'kotori-report';
        scroll.className = 'scroll';
        content.className = 'content';
        close.className = 'close';
        save.className = 'save';
        content.innerHTML = [
            eT,
            bU(count, i=>i+1+'月'),
            bU(stars, i=>i?i+'星':'未评分'),
            eU
        ].join('');


        const saveFn = () => {
            save.onclick = null;
            saveImage(content, () => save.onclick = saveFn)
        };
        let ly = scroll.scrollTop || 0;
        let my = ly;
        let ey = ly;
        let interval = null;
        const scrollFn = (iey) => {
            ey = Math.max(Math.min(iey,scroll.scrollHeight-scroll.offsetHeight),0);
            ly = my;
            if(interval) clearInterval(interval);
            let times = 1;
            interval = setInterval(() => {
                if(times > 50) {
                    clearInterval(interval);
                    interval = null;
                    return;
                }
                my = easeOutQuad(times, ly, ey, 50);
                scroll.scroll({top: my})
                times++;
            }, 1)
        };
        const wheelFn = e => {
            e.preventDefault();
            scrollFn(ey + e.deltaY);
        }
        const keydownFn = e => {
            e.preventDefault();
            if(e.key == 'Escape') close.click();
            if(e.key == 'Home') scrollFn(0);
            if(e.key == 'End') scrollFn(scroll.scrollHeight - scroll.offsetHeight);
            if(e.key == 'ArrowUp') scrollFn(ey - 100);
            if(e.key == 'ArrowDown') scrollFn(ey + 100);
            if(e.key == 'PageUp') scrollFn(ey - scroll.offsetHeight);
            if(e.key == 'PageDown') scrollFn(ey + scroll.offsetHeight);
        };
        scroll.addEventListener('wheel', wheelFn);
        close.addEventListener('wheel', wheelFn)
        save.addEventListener('wheel', wheelFn)
        document.addEventListener('keydown', keydownFn);
        save.addEventListener('click', saveFn);
        close.addEventListener('click', ()=> {
            document.removeEventListener('keydown', keydownFn);
            report.remove()
        });
        scroll.appendChild(content);
        report.appendChild(close);
        report.appendChild(scroll);
        report.appendChild(save);
        document.body.appendChild(report);
    };

    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-canvas::-webkit-scrollbar, #kotori-report .scroll::-webkit-scrollbar { display: none; }

#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 {
    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;

    > li:first-child { margin-top: 0; }
    > li { 
        margin-top: 10px; 

        > .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;
        }
        > .btn:hover {
            width: 100%;
            padding: 10px 0;
            background: #fc8994;
            border: 2px solid #fc8994 inset;
            text-align: center;
            border-radius: 5px;
            transition: all 0.3s;
        }
    }

    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;

        > div {
            display: flex;
            gap: 2px;
            justify-content: center;
        }
    }
}

#kotori-report {
    color: #fff;
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;

    > .close {
        position: absolute;
        top: 0;
        right: 0;
        left: 0;
        bottom: 0;
        background: rgba(0,0,0,0.3);
        backdrop-filter: blur(2px);
    }

    > .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;
    }
    > .scroll {
        position: absolute;
        top: 0;
        bottom: 0;
        left: 50%;
        transform: translateX(-50%);
        overflow: scroll;

        > .content {
            width: 1078px;
            margin: 0 auto;

            > h1 {
                padding: 30px 0;
                text-align: center;
            }

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

                > li {
                    display: block;
                    position: relative;
                    width: 100%;
                    height: 20px;
                    background: #0008;
                    margin: 2px;
                    line-height: 20px;
                    backdrop-filter: blur(2px);

                    > span {
                        position: absolute;
                        left: 0;
                        text-shadow: 0 0 2px #000;
                    }

                    > span:nth-child(2) {
                        position: absolute;
                        left: 50%;
                        transform: translateX(-50%);
                    }

                    > div {
                        display: inline-block;
                        height: 100%;
                        background: #fc8994aa;
                        margin: 0;
                    }
                }
            }

            > ul.l[type="music"] > li { height: 150px; }
            > ul.l {
                line-height: 0;
                > li {
                    display: inline-block;
                    position: relative;
                    width: 150px;
                    height: 220px;
                    margin: 2px;
                    overflow: hidden;
                    border-width: 1px;
                    border-style: solid;
                    border-color: #ffde20;
                    box-sizing: border-box;

                    img {
                        max-height: 100%;
                        position: absolute;
                        top: 0;
                        left: 50%;
                        transform: translateX(-50%);
                    }

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

                    .star {
                        display: block;
                        position: absolute;
                        bottom: 3px;
                        right: 3px;
                        width: 20px;
                        height: 20px;
                        padding: 5px;
                        background: none;
                        > img {
                            opacity: 0.85;
                        }
                        > span {
                            position: absolute;
                            top: 50%;
                            left: 50%;
                            color: #f4a;
                            font-family: consolas, 'courier new', monospace, courier;
                            font-size: 18px;
                            font-weight: bold;
                            text-shadow: 0 0 2px #fff;
                            transform: translate(-50%, -50%);
                        }
                    }
                }
            }
        }
    }
}

#kotori-report-canvas {
    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;
    > div {
        position: absolute;
        top: 0;
        right: 0;
        left: 0;
        bottom: 0;
        background: rgba(0,0,0,0.3);
        backdrop-filter: blur(2px);
    }
    > canvas {
        position: absolute;
        top: 0;
        left: 50%;
        transform: translateX(-50%);
    }
}

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

})();