e站收藏统计

获取e站所有收藏,以及对所有标签进行排序以找到你最爱的标签,可按namespace分组,支持翻译

// ==UserScript==
// @name         e站收藏统计
// @namespace    Schwi
// @version      1.9
// @description  获取e站所有收藏,以及对所有标签进行排序以找到你最爱的标签,可按namespace分组,支持翻译
// @author       Schwi
// @match        *://e-hentai.org/*
// @match        *://exhentai.org/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/FileSaver.min.js
// @icon         https://e-hentai.org/favicon.ico
// @grant        GM_registerMenuCommand
// @noframes
// @license      GPL-3.0
// ==/UserScript==

(function () {
    'use strict';
    // 在 https://e-hentai.org/ 或 https://exhentai.org/ 任意页面运行即可

    // 是否翻译标签(需下载翻译文本)
    const config = {
        translationUrl: "https://raw.githubusercontent.com/EhTagTranslation/DatabaseReleases/master/db.text.json",
        favoritesUrl: location.origin + "/favorites.php?inline_set=dm_e"
    }

    async function fetchFavorites(url) {
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return await response.text();
        } catch (error) {
            console.error('Fetch error:', error);
        }
    }

    async function getFavoritesList(queryUrl) {
        let favList = [];
        let nextUrl = queryUrl.href;
        while (nextUrl) {
            let resp = await fetchFavorites(nextUrl);
            if (resp) {
                let doc = new DOMParser().parseFromString(resp, "text/html");
                let next = doc.scripts[3];
                let scriptContent = next.textContent || next.innerText;
                let match = scriptContent.match(/var nexturl="(.*?)"/);
                nextUrl = match && match[1];
                if (nextUrl && nextUrl.startsWith('http') && new URL(nextUrl).pathname != queryUrl.pathname) {
                    nextUrl = null;
                }
                favList = favList.concat(Array.from(doc.querySelectorAll(".itg.glte>tbody>tr")));
            }
        }
        return favList;
    }

    function parseFavorites(favList) {
        let myFavList = [];
        favList.forEach(fav => {
            let title = fav.querySelector(".glink").innerText;
            let url = fav.href;
            let reclass = fav.querySelector(".cn").innerText;
            let tags = [];
            fav.querySelectorAll("td>[title]").forEach(tag => {
                let title = tag.title;
                if (title.startsWith(":")) {
                    title = "temp" + title;
                }
                tags.push(title);
            });
            myFavList.push({ title, url, reclass, tags });
        });
        return myFavList;
    }

    async function getTranslate(translationUrl) {
        showProgress('正在获取翻译...');
        try {
            const response = await fetch(translationUrl);
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return await response.json();
        } catch (error) {
            console.error('Fetch error:', error);
        }
    }

    async function translateResult(myFavList, translate) {

        const reclassList = getReclassList(myFavList);
        const tagList = getTagList(myFavList);
        const groupedTagList = getGroupedTagList(myFavList);
        showProgress('正在翻译...');

        reclassList.forEach(reclass => {
            if (reclass.reclass.toLowerCase().replace(' ', '') in translate.data[1].data) {
                reclass.translate = translate.data[1].data[reclass.reclass.toLowerCase().replace(' ', '')].name;
                reclass.intro = translate.data[1].data[reclass.reclass.toLowerCase().replace(' ', '')].intro || '';
            }
        });
        tagList.forEach(fullTag => {
            let namespace = fullTag.tag.split(":")[0];
            let tag = fullTag.tag.split(":")[1];
            let data = translate.data.filter(title => title.namespace === namespace);
            let tagTranslate = tag;
            let intro = '';
            if (data.length > 0) {
                namespace = data[0].frontMatters.name;
                if (tag in data[0].data) {
                    tagTranslate = data[0].data[tag].name;
                    intro = data[0].data[tag].intro || '';
                }
            }
            fullTag.translate = `${namespace}:${tagTranslate}`;
            fullTag.intro = intro;
        });
        groupedTagList.forEach(group => {
            let data = translate.data.filter(title => title.namespace === group.namespace);
            if (data.length > 0) {
                group.translate = data[0].frontMatters.name;
                group.tags.forEach(tag => {
                    tag.translate = tag.tag;
                    if (tag.tag in data[0].data) {
                        tag.translate = data[0].data[tag.tag].name;
                        tag.intro = data[0].data[tag.tag].intro || '';
                    }
                })
            }

        })

        return {
            reclassList,
            tagList,
            groupedTagList,
            myFavList
        };
    }

    function sortByCount(list, key) {
        return list.sort((a, b) => (b.count - a.count) * 2 + (a[key].toUpperCase() > b[key].toUpperCase() ? 1 : -1));
    }

    function getReclassList(myFavList) {
        let reclassList = {};
        myFavList.forEach(fav => {
            if (fav.reclass in reclassList) {
                reclassList[fav.reclass].count++;
            } else {
                reclassList[fav.reclass] = { reclass: fav.reclass, translate: '', count: 1 };
            }
        });
        return sortByCount(Object.values(reclassList), "reclass"); // [{reclass, translate, count}]
    }

    function getTagList(myFavList) {
        let tagList = {};
        myFavList.forEach(fav => {
            fav.tags.forEach(tag => {
                if (tag in tagList) {
                    tagList[tag].count++;
                } else {
                    tagList[tag] = { tag: tag, translate: '', count: 1 };
                }
            });
        });
        return sortByCount(Object.values(tagList), "tag");// [{tag, translate, count}]
    }

    function getGroupedTagList(myFavList) {
        let groupedTagList = {};
        myFavList.forEach(fav => {
            fav.tags.forEach(fullTag => {
                let namespace = fullTag.split(":")[0];
                let tag = fullTag.split(":")[1];
                if (!(namespace in groupedTagList)) {
                    groupedTagList[namespace] = {};
                }
                if (fullTag in groupedTagList[namespace]) {
                    groupedTagList[namespace][fullTag].count++;
                } else {
                    groupedTagList[namespace][fullTag] = { tag: tag, translate: '', count: 1 };
                }
            });
        });
        for (let namespace in groupedTagList) {
            groupedTagList[namespace] = { namespace, translate: '', tags: sortByCount(Object.values(groupedTagList[namespace]), "tag") };
        }
        return Object.values(groupedTagList); // [{namespace, translate, tags:[{tag, translate, count}]}]
    }

    function showProgress(message) {
        let progressDiv = document.querySelector('#progressDiv');
        if (!progressDiv) {
            progressDiv = document.createElement('div');
            progressDiv.id = 'progressDiv';
            progressDiv.style.position = 'fixed';
            progressDiv.style.top = '10px';
            progressDiv.style.left = '50%';
            progressDiv.style.transform = 'translateX(-50%)';
            progressDiv.style.backgroundColor = 'rgba(0,0,0,0.8)';
            progressDiv.style.color = 'white';
            progressDiv.style.padding = '10px';
            progressDiv.style.borderRadius = '5px';
            progressDiv.style.zIndex = '10001'; // 调大 z-index
            document.body.appendChild(progressDiv);
        }
        progressDiv.innerText = message;
    }

    function hideProgress() {
        const progressDiv = document.querySelector('#progressDiv');
        if (progressDiv) {
            progressDiv.remove();
        }
    }

    function showResults(result) {
        let resultDiv = document.querySelector('#resultDiv');
        if (resultDiv) {
            resultDiv.remove();
        }
        resultDiv = document.createElement('div');
        resultDiv.id = 'resultDiv';
        resultDiv.translate = false; // https://github.com/EhTagTranslation/EhSyringe/issues/1290
        resultDiv.style.position = 'fixed';
        resultDiv.style.top = '5%';
        resultDiv.style.left = '5%';
        resultDiv.style.width = '90%';
        resultDiv.style.height = '90%';
        resultDiv.style.backgroundColor = 'white';
        resultDiv.style.border = '1px solid black';
        resultDiv.style.overflow = 'auto';
        resultDiv.style.zIndex = '10000';
        document.body.appendChild(resultDiv);

        const closeButton = document.createElement('button');
        closeButton.innerText = '关闭';
        closeButton.style.position = 'absolute';
        closeButton.style.top = '10px';
        closeButton.style.right = '10px';
        closeButton.style.backgroundColor = 'red';
        closeButton.style.color = 'white';
        closeButton.style.border = 'none';
        closeButton.style.borderRadius = '5px';
        closeButton.style.padding = '5px 10px';
        closeButton.style.cursor = 'pointer';
        closeButton.onclick = () => resultDiv.remove();
        resultDiv.appendChild(closeButton);

        const buttonContainer = document.createElement('div');
        buttonContainer.style.display = 'flex';
        buttonContainer.style.justifyContent = 'space-between';
        buttonContainer.style.marginTop = '20px';
        buttonContainer.style.marginBottom = '10px';

        const saveRawBtnContainer = document.createElement('div');
        saveRawBtnContainer.style.flex = '1';
        saveRawBtnContainer.style.textAlign = 'center';

        const saveRawBtn = document.createElement('button');
        saveRawBtn.id = 'saveBtnJSON';
        saveRawBtn.innerText = '保存JSON结果';
        saveRawBtn.style.backgroundColor = '#4CAF50';
        saveRawBtn.style.color = 'white';
        saveRawBtn.style.border = 'none';
        saveRawBtn.style.borderRadius = '5px';
        saveRawBtn.style.padding = '10px 20px';
        saveRawBtn.style.cursor = 'pointer';
        saveRawBtnContainer.appendChild(saveRawBtn);

        const saveTranslatedBtnContainer = document.createElement('div');
        saveTranslatedBtnContainer.style.flex = '1';
        saveTranslatedBtnContainer.style.textAlign = 'center';

        const saveTranslatedBtn = document.createElement('button');
        saveTranslatedBtn.id = 'saveBtnHTML';
        saveTranslatedBtn.innerText = '保存HTML结果';
        saveTranslatedBtn.style.backgroundColor = '#008CBA';
        saveTranslatedBtn.style.color = 'white';
        saveTranslatedBtn.style.border = 'none';
        saveTranslatedBtn.style.borderRadius = '5px';
        saveTranslatedBtn.style.padding = '10px 20px';
        saveTranslatedBtn.style.cursor = 'pointer';
        saveTranslatedBtnContainer.appendChild(saveTranslatedBtn);

        buttonContainer.appendChild(saveRawBtnContainer);
        buttonContainer.appendChild(saveTranslatedBtnContainer);

        resultDiv.appendChild(buttonContainer);

        const createTable = (data, headers, total = false) => {
            const table = document.createElement('table');
            table.style.border = '1px solid';
            table.style.width = '100%';
            table.style.borderCollapse = 'collapse';
            table.style.textAlign = 'center';

            const headerRow = document.createElement('tr');
            headerRow.style.height = '30px';
            headerRow.style.border = '1px solid';
            headers.forEach(header => {
                const th = document.createElement('th');
                th.style.border = '1px solid';
                th.innerText = header;
                headerRow.appendChild(th);
            });
            table.appendChild(headerRow);

            data.forEach((row, index) => {
                const tr = document.createElement('tr');
                tr.style.height = '30px';
                tr.style.border = '1px solid';
                tr.title = row.intro || '';

                const indexTd = document.createElement('td');
                indexTd.innerText = index + 1;
                tr.appendChild(indexTd);

                headers.forEach(header => {
                    if (header === 'Index') return; // Skip index column
                    const td = document.createElement('td');
                    td.style.border = '1px solid';
                    td.innerText = row[header.toLowerCase()];
                    tr.appendChild(td);
                });

                table.appendChild(tr);
            });
            if (total) {
                const totalRow = document.createElement('tr');
                totalRow.style.height = '30px';
                totalRow.style.border = '1px solid';
                const totalTd = document.createElement('td');
                totalTd.colSpan = headers.length - 1;
                totalTd.innerText = 'Total';
                totalTd.style.fontWeight = 'bold';
                totalTd.style.border = '1px solid';
                totalRow.appendChild(totalTd);
                const countTd = document.createElement('td');
                countTd.innerText = data.reduce((sum, row) => sum + row.count, 0);
                countTd.style.fontWeight = 'bold';
                countTd.style.border = '1px solid';
                totalRow.appendChild(countTd);
                table.appendChild(totalRow);
            }

            return table;
        };

        const createGroupedTable = (data, headers) => {
            const table = document.createElement('table');
            table.style.border = '1px solid';
            table.style.width = '100%';
            table.style.borderCollapse = 'collapse';
            table.style.textAlign = 'center';

            const headerRow = document.createElement('tr');
            headerRow.style.height = '30px';
            headerRow.style.border = '1px solid';
            headers.forEach(header => {
                const th = document.createElement('th');
                th.style.border = '1px solid';
                th.innerText = header;
                headerRow.appendChild(th);
            });
            table.appendChild(headerRow);

            data.forEach((group, groupIndex) => {
                const tags = group.tags;
                tags.forEach((tag, tagIndex) => {
                    const tr = document.createElement('tr');
                    tr.style.height = '30px';
                    tr.style.border = '1px solid';

                    if (tagIndex === 0) {
                        const namespaceTd = document.createElement('td');
                        namespaceTd.style.border = '1px solid';
                        namespaceTd.rowSpan = tags.length;
                        namespaceTd.innerText = group.namespace;
                        tr.appendChild(namespaceTd);
                        const namespaceTranslateTd = document.createElement('td');
                        namespaceTranslateTd.style.border = '1px solid';
                        namespaceTranslateTd.rowSpan = tags.length;
                        namespaceTranslateTd.innerText = group.translate;
                        tr.appendChild(namespaceTranslateTd);
                    }

                    const indexTd = document.createElement('td');
                    indexTd.style.border = '1px solid';
                    indexTd.innerText = tagIndex + 1;
                    indexTd.title = tag.intro || '';
                    tr.appendChild(indexTd);

                    const tagTd = document.createElement('td');
                    tagTd.style.border = '1px solid';
                    tagTd.innerText = tag.tag;
                    tagTd.title = tag.intro || '';
                    tr.appendChild(tagTd);

                    const translateTd = document.createElement('td');
                    translateTd.style.border = '1px solid';
                    translateTd.innerText = tag.translate;
                    translateTd.title = tag.intro || '';
                    tr.appendChild(translateTd);

                    const countTd = document.createElement('td');
                    countTd.style.border = '1px solid';
                    countTd.innerText = tag.count;
                    countTd.title = tag.intro || '';
                    tr.appendChild(countTd);

                    table.appendChild(tr);
                });
            })

            return table;
        };

        const resultContainer = document.createElement('div');
        resultContainer.style.display = 'flex';
        resultContainer.style.justifyContent = 'space-around';

        const rawResultDiv = document.createElement('div');
        rawResultDiv.style.width = '90%';
        rawResultDiv.style.border = '1px solid black';
        rawResultDiv.style.padding = '10px';

        const rawResultTitle = document.createElement('h3');
        rawResultTitle.innerText = '统计结果';
        rawResultDiv.appendChild(rawResultTitle);

        const rawReclassTitle = document.createElement('h4');
        rawReclassTitle.innerText = 'Reclass List';
        rawResultDiv.appendChild(rawReclassTitle);
        rawResultDiv.appendChild(createTable(result.reclassList, ['Index', 'Reclass', 'Translate', 'Count'], true));

        const rawTagTitle = document.createElement('h4');
        rawTagTitle.innerText = 'Tag List';
        rawResultDiv.appendChild(rawTagTitle);
        rawResultDiv.appendChild(createTable(result.tagList, ['Index', 'Tag', 'Translate', 'Count']));

        const rawGroupedTagTitle = document.createElement('h4');
        rawGroupedTagTitle.innerText = 'Grouped Tag List';
        rawResultDiv.appendChild(rawGroupedTagTitle);
        rawResultDiv.appendChild(createGroupedTable(result.groupedTagList, ['Namespace', 'Translate', 'Index', 'Tag', 'Translate', 'Count']));

        resultContainer.appendChild(rawResultDiv);

        resultDiv.appendChild(resultContainer);

        resultDiv.querySelector('#saveBtnJSON').onclick = () => {
            download('eh_collect.json', JSON.stringify(result, null, 2));
        };

        resultDiv.querySelector('#saveBtnHTML').onclick = () => {
            download('eh_collect.html', resultContainer.outerHTML);
        };
    }

    async function collect(config) {
        showProgress('正在获取收藏列表...');
        const queryUrl = new URL(config.favoritesUrl);
        const favList = await getFavoritesList(queryUrl);
        let myFavList = parseFavorites(favList);
        return myFavList;
    }

    function download(filename, data) {
        const blob = new Blob([data], { type: 'text/plain;charset=utf-8' });
        saveAs(blob, filename);
    }

    GM_registerMenuCommand("统计收藏", async () => {
        const collectList = await collect(config);
        const translate = await getTranslate(config.translationUrl);
        const result = await translateResult(collectList, translate);
        hideProgress();
        showResults(result);
    });
})();