mikananiBgmScore

Mikan 蜜柑计划首页显示 Bangumi 评分 / 标签 / 链接。

// ==UserScript==
// @name         mikananiBgmScore
// @namespace    https://github.com/kjtsune/UserScripts
// @version      0.8
// @description  Mikan 蜜柑计划首页显示 Bangumi 评分 / 标签 / 链接。
// @author       kjtsune
// @match        https://mikanani.me/
// @match        https://mikanani.me/Home/MyBangumi
// @icon         https://www.google.com/s2/favicons?sz=64&domain=mikanani.me
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @license MIT
// ==/UserScript==
'use strict';

let config = {
    sortByScore: true,
    logLevel: 2,
    // 若 minScore 的值大于0.1,会隐藏低于该评分的条目。
    minScore: 0,
    // 清除无效标签的正则匹配规则
    tagsRegex: /\d{4}|TV|动画|小说|漫|轻改|游戏改|原创|[a-zA-Z]/,
    // 标签数量限制,填0禁用标签功能。
    tagsNum: 3,
    // https://next.bgm.tv/demo/access-token  解除 NSFW 条目限制。
    bgmToken: '',
};

let logger = {
    error: function (...args) {
        if (config.logLevel >= 1) {
            console.log('%cerror', 'color: yellow; font-style: italic; background-color: blue;', ...args);
        }
    },
    info: function (...args) {
        if (config.logLevel >= 2) {
            console.log('%cinfo', 'color: yellow; font-style: italic; background-color: blue;', ...args);
        }
    },
    debug: function (...args) {
        if (config.logLevel >= 3) {
            console.log('%cdebug', 'color: yellow; font-style: italic; background-color: blue;', ...args);
        }
    },
}

function createElementFromHTML(htmlString) {
    let div = document.createElement('div');
    div.innerHTML = htmlString.trim();
    return div.firstElementChild;
}

async function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

async function getJSON(url) {
    try {
        let headers = {
            'Authorization': `Bearer ${config.bgmToken}`,
            'User-Agent': 'mikanBgm/0.1 (https://github.com/kjtsune/UserScripts)'
        }
        let options = (config.bgmToken) ? { headers: headers } : null;
        const response = await fetch(url, options);
        logger.info(`fetch ${url}`)
        if (response.status >= 200 && response.status < 400)
            return await response.json();
        console.error(`Error fetching ${url}:`, response.status, response.statusText, await response.text());
    }
    catch (e) {
        console.error(`Error fetching ${url}:`, e);
    }
}

async function getBgmJson(bgmId) {
    let url = `https://api.bgm.tv/v0/subjects/${bgmId}`
    return await getJSON(url)
}

async function cleanBgmTags(tags) {
    tags = tags.filter(item => item.count >= 10 && !(config.tagsRegex.test(item.name)));
    let namesList = tags.map(item => item.name);
    return namesList;
}

async function getParsedBgmInfo(bgmId, stringify = false) {
    let bgmJson = await getBgmJson(bgmId);
    let score = (bgmJson) ? bgmJson.rating.score : 0.1;
    let summary = (bgmJson) ? bgmJson.summary : "18x or network error";
    let date = (bgmJson) ? bgmJson.date : new Date();
    let tags = (bgmJson) ? await cleanBgmTags(bgmJson.tags) : [];
    let res = { score: score, summary: summary, date: date, tags: tags };
    res = (stringify) ? JSON.stringify(res) : res;
    return res
}

function queryAllForArray(seletor, elementArray) {
    let result = [];
    for (const element of elementArray) {
        let res = element.querySelectorAll(seletor);
        if (!res) logger.error("queryAllForArray not result", seletor, element);
        result.push(...res);
    }
    return result
}

function multiTimesSeletor(storage = null, seletorAll = false, ...cssSeletor) {
    const seletor = cssSeletor[0]
    const restSeletor = cssSeletor.slice(1)
    if (!seletor) return storage;

    if (seletorAll) {
        storage = storage || [document]
        let res = queryAllForArray(seletor, storage);
        if (res) storage = res;
        storage && logger.debug('storage', storage.length, seletor, restSeletor);
        if (!restSeletor) {
            return storage;
        } else {
            return multiTimesSeletor(storage, true, ...restSeletor);
        }
    } else {
        storage = storage || document;
        const lastRes = storage;
        storage = storage.querySelector(seletor);
        storage && logger.debug('storage', storage, seletor);
        if (!storage) logger.error("not result", seletor, lastRes);
        if (!restSeletor) {
            return storage;
        } else {
            return multiTimesSeletor(storage, false, ...restSeletor);
        }
    }
}

async function myFetch(url, selector = null, selectAll = false) {
    let response = await fetch(url);
    let text = await response.text();
    const parser = new DOMParser();
    const htmlDocument = parser.parseFromString(text, "text/html");
    const element = htmlDocument.documentElement;
    if (!selector) return element;
    if (selectAll) {
        return element.querySelectorAll(selector);
    } else {
        return element.querySelector(selector);
    }
}

async function getBgmId(mikanUrl) {
    let selector = "p.bangumi-info > a[href*='tv/subject']";
    let bgm = await myFetch(mikanUrl, selector);
    if (bgm) bgm = bgm.href.split("/").slice(-1)[0];
    return bgm
}

class MyStorage {
    constructor(prefix, splitStr = '|', expireDay = 0, useGM = false) {
        this.prefix = prefix;
        this.splitStr = splitStr;
        this.expireDay = expireDay;
        this.expireMs = expireDay * 864E5;
        this._getItem = (useGM) ? GM_getValue : localStorage.getItem.bind(localStorage);
        this._setItem = (useGM) ? GM_setValue : localStorage.setItem.bind(localStorage);
        this._removeItem = (useGM) ? GM_deleteValue : localStorage.removeItem.bind(localStorage);
    }

    _dayToMs(day) {
        return day * 864E5;
    }

    _msToDay(ms) {
        return ms / 864E5;
    }

    _keyGenerator(key) {
        return `${this.prefix}${this.splitStr}${key}`
    }

    get(key, defalut = null) {
        key = this._keyGenerator(key);
        let res = this._getItem(key);
        if (this.expireMs && res) {
            res = JSON.parse(this._getItem(key)).value;
        }
        res = res || defalut;
        return res
    }

    set(key, value) {
        key = this._keyGenerator(key);
        if (this.expireMs) {
            value = JSON.stringify({ timestamp: Date.now(), value: value })
        }
        this._setItem(key, value)
    }

    del(key) {
        key = this._keyGenerator(key);
        try {
            this._removeItem(key);
        } catch (error) {
            // pass
        }
    }

    checkIsExpire(key, expireDay = null) {
        key = this._keyGenerator(key);
        let exists = this.useGM ? (this._getItem(key) !== undefined) : (key in localStorage)
        if (!exists) return true;
        if (!this.expireMs && exists) { return false };
        let data = JSON.parse(this._getItem(key))
        let timestamp = data.timestamp;
        if (!timestamp) throw `checkIsExpire not work , not timestamp, key: ${key}`;
        expireDay = (expireDay !== null) ? expireDay : this.expireDay;
        let expireMs = (expireDay !== null) ? expireDay * 864E5 : this.expireMs;
        if (timestamp + expireMs < Date.now()) {
            logger.info(key, "IsExpire, old:", new Date(timestamp).toLocaleDateString(), "expireDay:", expireDay);
            return true;
        } else {
            return false;
        }
    }
}

class BgmStorage extends MyStorage {
    constructor(prefix, splitStr = '|', expireDay = 0, useGM = false) {
        super(prefix, splitStr, expireDay, useGM);
    }

    bgmIsExpire(key) {
        let expireDay = 15;
        let airDate = this.get(key, Object).date;
        if (!airDate) { return true };
        let airedDay = this._msToDay(new Date().getTime() - new Date(airDate).getTime());
        switch (true) {
            case (airedDay < 10):
                expireDay = 1;
                break;
            case (airedDay < 20):
                expireDay = 2;
                break;
            case (airedDay < 180):
                expireDay = 5;
                break;
            default:
                expireDay = 15;
                break;
        }
        return this.checkIsExpire(key, expireDay);
        // return this.checkIsExpire(key, 0);
    }
}

function swapElements(element1, element2) {
    const parent1 = element1.parentNode;
    const parent2 = element2.parentNode;
    const temp = document.createElement('li');

    parent1.insertBefore(temp, element1);
    parent2.insertBefore(element1, element2);
    parent1.insertBefore(element2, temp);
    parent1.removeChild(temp);
}

function sortBangumi() {
    for (const day_group of document.querySelectorAll('div.sk-bangumi')) {
        if (day_group.querySelector('.sorted-marker')) return;

        let ls = Array.from(day_group.querySelectorAll('.an-ul > li'));
        let sorted_ls = Array.from(ls);
        sorted_ls.sort((a, b) => {
            const score_node_a = a.querySelector('div > a > img');
            const score_node_b = b.querySelector('div > a > img');
            if (!score_node_a || !score_node_b) return 0;
            const scoreA = parseFloat(score_node_a.parentElement.text.trim());
            const scoreB = parseFloat(score_node_b.parentElement.text.trim());
            return scoreB - scoreA; // 从大到小排序
        });

        for (const sorted_ele of sorted_ls) {
            let current_ls = Array.from(day_group.querySelectorAll('.an-ul > li'));
            let correct_idx = sorted_ls.indexOf(sorted_ele);
            let current_ele = current_ls[correct_idx];
            swapElements(sorted_ele, current_ele);
        }

        const marker = document.createElement('div');
        marker.className = 'sorted-marker';
        day_group.appendChild(marker);
    }
    logger.info('sortBangumi Done')
}


async function addScoreSummaryToHtml(mikanElementList) {
    let bgmIco = `<img style="width:16px;" src="data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAQAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALJu+f//////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsm75ELJu+cCybvn/sm75/7Ju+f+ybvn//////7Ju+f+ybvn/sm75/7Ju+f+ybvn/sm75/7Ju+f+ybvnAsm75ELJu+cCybvn/sm75/7Ju+f+ybvn/sm75////////////sm75/7Ju+f+ybvn/sm75/7Ju+f+ybvn/sm75/7Ju+cCwaPn/sGj5/9iz/P///////////////////////////////////////////////////////////9iz/P+waPn/rF/6/6xf+v//////////////////////////////////////////////////////////////////////rF/6/6lW+/+pVvv/////////////////////////////////zXn2/////////////////////////////////6lW+/+lTfz/pU38///////Nefb/zXn2/8159v//////zXn2///////Nefb//////8159v/Nefb/zXn2//////+lTfz/okT8/6JE/P//////////////////////2bb8/8159v/Nefb/zXn2/9m2/P//////////////////////okT8/546/f+eOv3//////8159v/Nefb/zXn2////////////////////////////zXn2/8159v/Nefb//////546/f+bMf7/mzH+//////////////////////////////////////////////////////////////////////+bMf7/lyj+wJco/v/Mk/7////////////////////////////////////////////////////////////Mk///lyj+wJQf/xCUH//AlB///5Qf//+UH///lB///5Qf//+aP///mj///5o///+UH///lB///5Qf//+UH///lB//wJQf/xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzXn2/5o////Nefb/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzXn2/wAAAAAAAAAAAAAAAM159v8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzXn2/wAAAAAAAAAAAAAAAAAAAAAAAAAAzXn2/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzXn2/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADNefb/AAAAAAAAAAAAAAAA+f8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/j8AAP3fAAD77wAA9/cAAA==">`
    for (const element of mikanElementList) {
        let scoreElement = element.nextElementSibling;
        if (scoreElement) continue;
        let mikanUrl = element.href;
        let mikanId = mikanUrl.split('/').slice(-1)[0];
        let bgmId = mikanBgmStorage.get(mikanId);
        let bgmInfo = bgmInfoStorage.get(bgmId);
        if (!bgmId || !bgmInfo) continue;
        let bgmUrl = `https://bgm.tv/subject/${bgmId}`
        let score = bgmInfo.score;
        let summary = bgmInfo.summary;
        let title = element.textContent;
        let pathName = element.pathname;
        let tags = bgmInfo.tags;
        let tasgWithSummary = summary;
        let tagsHtml = '';
        if (tags && tags.length > 0 && config.tagsNum > 0) {
            tags = tags.filter(name => !config.tagsRegex.test(name));
            tagsHtml = `<br>${tags.slice(0, config.tagsNum)}`;
            tasgWithSummary = `tags: ${tags}\n\n${summary}`
            element.insertAdjacentHTML("afterend", tagsHtml);
        }
        let bgmHtml = `<a href="${bgmUrl}" target="_blank" title="${tasgWithSummary}" id="bgmScore">${bgmIco} ${score}</a>`
        element.insertAdjacentHTML("afterend", bgmHtml);

        let minScore = config.minScore;
        let lowScore = (score <= minScore && score > 0.1 && minScore > 0.1) ? true : false;
        let mobileFatherElement = document.querySelectorAll(`a[href="${pathName}"`)[1];
        if (lowScore) {
            logger.info('delete', title, score, minScore);
            element.parentElement.parentElement.parentElement.remove();
        }
        if (!mobileFatherElement) continue;
        mobileFatherElement = mobileFatherElement.parentElement;
        let mobileElement = mobileFatherElement.querySelector('div');
        if (!mobileElement) continue;
        let mobileHtml = `<a href="${bgmUrl}" target="_blank" title="${tasgWithSummary}" id="bgmScore">${title} ${score}</a>`
        let newMobileElement = createElementFromHTML(mobileHtml);
        mobileElement.replaceWith(newMobileElement);
        if (tagsHtml) {
            newMobileElement.insertAdjacentHTML("afterend", tagsHtml);
        }
        // mobileFatherElement.replaceChild(newMobileElement, mobileElement);
        if (lowScore) {
            logger.info('delete', title, score, minScore);
            newMobileElement.parentElement.parentElement.remove();
        }
    }
}

let mikanBgmStorage = new MyStorage("mikan");
let bgmInfoStorage = new BgmStorage("bgm", undefined, 7);

async function storeMikanBgm(mikanElementList, storeBgmInfo = false) {
    let count = 0;

    async function checkBgmInfoExist(mkId) {
        let bgmId = mikanBgmStorage.get(mkId);
        if (!bgmId) return;
        if (bgmInfoStorage.bgmIsExpire(bgmId)) {
            bgmInfoStorage.set(bgmId, await getParsedBgmInfo(bgmId));
            count++;
        }
    }

    for (const element of mikanElementList) {
        let mikanUrl = element.href;
        let mikanId = mikanUrl.split('/').slice(-1)[0];
        let bgmId = mikanBgmStorage.get(mikanId)
        if (!bgmId) {
            bgmId = await getBgmId(mikanUrl);
            logger.info("fetch mikan", mikanId);
            mikanBgmStorage.set(mikanId, bgmId);
            logger.info(`set ${mikanId} to ${bgmId}`);
            await sleep(1000);
            count++;
        }
        if (storeBgmInfo) await checkBgmInfoExist(mikanId);
        await addScoreSummaryToHtml([element]);
    }
    count && logger.info('fetch count', count);
}

function backupMikanBgm() {
    let result = {};
    for (let key in localStorage) {
        if (key.indexOf('mikan|') != -1) {
            result[key] = localStorage.getItem(key);
        }
    }
    if (result) {
        result = JSON.stringify(result);
        console.log(result);
    }
}

function restoreMikanBgm(text) {
    let data = JSON.parse(text);
    for (let key in data) {
        if (key.indexOf('mikan|') != -1) {
            localStorage.setItem(key, data[key])
        }
    }
}

function countMikanBgm() {
    let count = 0;
    for (let key in localStorage) {
        if (key.indexOf('mikan|') != -1) {
            count++;
        }
    }
    console.log('mikan bgm count: ', count)
}

async function main() {
    let animeList = multiTimesSeletor(null, true, "div.sk-bangumi", "a[href^='/Home/Bangumi']");
    // animeList = animeList.slice(0, 10);
    await storeMikanBgm(animeList, true);
    await addScoreSummaryToHtml(animeList);
    logger.debug(animeList);
    if (config.sortByScore) { sortBangumi(); }
}

(function loop() {
    setTimeout(async function () {
        let start = Date.now()
        await main();
        let usedSec = (Date.now() - start) / 1000;
        if (usedSec > 0.01) logger.info(`used time ${usedSec}`);
        loop();
    }, 2000);
})();