EH收藏导出/入

在e-hentai或exhentai中, 导出/入收藏

// ==UserScript==
// @name         EH收藏导出/入
// @namespace    https://www.mohuangdiyu.com/
// @version      1.0
// @description  在e-hentai或exhentai中, 导出/入收藏
// @author       地狱 魔皇
// @match        *://e-hentai.org/*
// @match        *://exhentai.org/*
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @connect      e-hentai.org
// @connect      exhentai.org
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    const validSites = ['e-hentai.org', 'exhentai.org'];
    if (!validSites.some(site => location.hostname.includes(site))) {
        alert('此脚本仅支持 e-hentai 和 exhentai');
        return;
    }

    const styles = `
        .eh-mask {
            position: fixed;
            inset: 0;
            background: rgba(0,0,0,0.6);
            z-index: 9998;
        }
        .eh-popup {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: #fff;
            color: #111;
            padding: 20px;
            z-index: 9999;
            border-radius: 10px;
            min-width: 300px;
            max-height: 80vh;
            overflow: auto;
            box-shadow: 0 0 15px rgba(0,0,0,0.6);
            font-family: sans-serif;
        }
        .eh-popup h3 {
            margin-top: 0;
            color: #222;
        }
        .eh-popup select, .eh-popup button, .eh-popup input, .eh-popup label {
            width: 100%;
            margin-top: 10px;
            font-size: 14px;
            color: #000;
            background-color: #fff;
        }
        .eh-popup button {
            background-color: #007bff;
            color: #fff;
            border: none;
            padding: 8px;
            border-radius: 4px;
            cursor: pointer;
        }
        .eh-popup button:hover {
            background-color: #0056b3;
        }
        .eh-status {
            margin-top: 15px;
            font-size: 13px;
            max-height: 200px;
            overflow-y: auto;
            white-space: pre-wrap;
            background: #f9f9f9;
            color: #000;
            padding: 10px;
            border-radius: 5px;
            border: 1px solid #ccc;
        }
    `;

    function injectStyles() {
        const styleTag = document.createElement('style');
        styleTag.textContent = styles;
        document.head.appendChild(styleTag);
    }

    function fetchFavoriteCategories(callback) {
        const base = location.origin;
        GM_xmlhttpRequest({
            method: 'GET',
            url: base + '/favorites.php',
            onload: function (res) {
                const parser = new DOMParser();
                const doc = parser.parseFromString(res.responseText, 'text/html');
                const results = [
                    { name: 'All', id: 'all' }
                ];
                doc.querySelectorAll('div.fp').forEach(div => {
                    const onclick = div.getAttribute('onclick');
                    if (onclick && onclick.includes('favorites.php?favcat=')) {
                        const match = onclick.match(/favcat=(\d+)/);
                        const icon = div.querySelector('.i');
                        const name = icon ? icon.getAttribute('title') : null;
                        if (match && name) {
                            results.push({ id: match[1], name });
                        }
                    }
                });
                callback(results);
            },
            onerror: function () {
                alert('无法加载收藏夹列表');
            }
        });
    }

    function fetchAllPages(startUrl, callback, accumulated = []) {
        GM_xmlhttpRequest({
            method: 'GET',
            url: startUrl,
            onload: function (res) {
                const parser = new DOMParser();
                const doc = parser.parseFromString(res.responseText, 'text/html');

                const rows = doc.querySelectorAll('table.itg > tbody > tr');

                rows.forEach(tr => {
                    const a = tr.querySelector('td.gl3c.glname > a');
                    if (!a) return;

                    const url = a.href;
                    const title = a.querySelector('div.glink')?.textContent.trim() || '';
                    let note = a.querySelector('div.glfnote')?.textContent.trim() || '';
                    note = note.replace(/^Note:\s*/i, '');

                    // 查找以 posted_ 开头的 div
                    const postedDiv = tr.querySelector('div[id^="posted_"]');
                    const favName = postedDiv?.getAttribute('title') || '';

                    const imgElement = tr.querySelector('div.glthumb img');
                    const img_url = imgElement
                    ? (imgElement.getAttribute('data-src') || imgElement.getAttribute('src') || '').replace('s.exhentai.org', 'ehgt.org')
                    : '';

                    accumulated.push({
                        标题: title,
                        链接: url,
                        说明: note,
                        收藏夹: favName,
                        缩略图: img_url
                    });
                });

                const next = doc.querySelector('a#unext');
                if (next) {
                    fetchAllPages(next.href, callback, accumulated);
                } else {
                    callback(accumulated);
                }
            },
            onerror: function () {
                alert('无法加载收藏内容');
            }
        });
    }

    function fetchFavorites(favcat, callback) {
        const base = location.origin;
        const url = favcat === 'all' ? base + '/favorites.php?inline_set=dm_l' : `${base}/favorites.php?inline_set=dm_l&favcat=${favcat}`;
        fetchAllPages(url, callback);
    }

    function downloadExportedData(data, format) {
        if (format === 'json') {
            const json = data.map(item => ({ title: item['标题'], url: item['链接'], note: item['说明'], favname: item['收藏夹'], img_url: item['缩略图'] }));
            const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            triggerDownload(url, 'favorites.json');
        } else if (format === 'xlsx') {
            if (typeof XLSX === 'undefined') {
                alert('XLSX 库未加载');
                return;
            }
            const worksheet = XLSX.utils.json_to_sheet(data);
            const workbook = XLSX.utils.book_new();
            XLSX.utils.book_append_sheet(workbook, worksheet, '收藏');
            const wbout = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
            const blob = new Blob([wbout], { type: 'application/octet-stream' });
            const url = URL.createObjectURL(blob);
            triggerDownload(url, 'favorites.xlsx');
        }
    }

    function triggerDownload(url, filename) {
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        a.remove();
        URL.revokeObjectURL(url);
    }

    function showExportPopup() {
        if (document.querySelector('.eh-mask')) return;

        fetchFavoriteCategories(favCats => {
            const mask = document.createElement('div');
            mask.className = 'eh-mask';

            const popup = document.createElement('div');
            popup.className = 'eh-popup';

            const title = document.createElement('h3');
            title.textContent = '导出收藏';

            const formatSelect = document.createElement('select');
            ['json', 'xlsx'].forEach(fmt => {
                const opt = document.createElement('option');
                opt.value = fmt;
                opt.textContent = `导出格式:${fmt}`;
                formatSelect.appendChild(opt);
            });

            const favSelect = document.createElement('select');
            favCats.forEach(cat => {
                const opt = document.createElement('option');
                opt.value = cat.id;
                opt.textContent = `收藏夹:${cat.name}`;
                favSelect.appendChild(opt);
            });

            const exportBtn = document.createElement('button');
            exportBtn.textContent = '导出';
            exportBtn.onclick = () => {
                const format = formatSelect.value;
                const favcat = favSelect.value;
                fetchFavorites(favcat, data => {
                    downloadExportedData(data, format);
                });
                closePopup();
            };

            popup.appendChild(title);
            popup.appendChild(formatSelect);
            popup.appendChild(favSelect);
            popup.appendChild(exportBtn);

            document.body.appendChild(mask);
            document.body.appendChild(popup);

            function closePopup() {
                mask.remove();
                popup.remove();
            }
            mask.addEventListener('click', closePopup);
        });
    }
    function parseGalleryInfo(url) {
        const match = url.match(/\/g\/(\d+)\/([a-z0-9]+)\//);
        return match ? { gid: match[1], token: match[2] } : null;
    }

    function importFavoritesFromData(data, favcat, skipDuplicate, statusBox) {
        const base = location.origin;
        let cancelImport = false;

        // 添加取消按钮
        const cancelBtn = document.createElement('button');
        cancelBtn.textContent = '取消导入';
        cancelBtn.style.marginTop = '10px';
        cancelBtn.onclick = () => cancelImport = true;
        statusBox.parentElement.appendChild(cancelBtn);

        function extractGids(existingGids) {
            const gidSet = new Set();

            // 遍历existingGids数组
            for (let i = 0; i < existingGids.length; i++) {
                const url = existingGids[i]['链接'];
                const info = parseGalleryInfo(url);

                if (info && info.gid) {
                    gidSet.add(info.gid);
                }
            }

            return gidSet;
        }


        function doImport(existingGids) {
            let success = 0, fail = 0;

            const importOne = (item, index) => {
                if (cancelImport) {
                    statusBox.textContent += `\n🚫 导入已取消\n`;
                    return;
                }
                const info = parseGalleryInfo(item.url);
                if (!info) {
                    statusBox.textContent += `❌ 无效链接: ${item.url}\n`;
                    fail++;
                    return;
                }
                if (skipDuplicate && existingGids.has(info.gid)) {
                    statusBox.textContent += `⚠️ 已存在跳过: ${item.url}\n`;
                    return;
                }

                const formData = new URLSearchParams();
                formData.set('favcat', favcat);
                formData.set('favnote', item.note || '');
                formData.set('apply', 'Add to Favorites');
                formData.set('update', '1');

                GM_xmlhttpRequest({
                    method: 'POST',
                    url: `${base}/gallerypopups.php?gid=${info.gid}&t=${info.token}&act=addfav`,
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                    data: formData.toString(),
                    onload: () => {
                        if (cancelImport) return;
                        statusBox.textContent += `✅ 成功: ${item.url}\n`;
                        success++;
                    },
                    onerror: () => {
                        if (cancelImport) return;
                        statusBox.textContent += `❌ 失败: ${item.url}\n`;
                        fail++;
                    }
                });
            };

            for (let i = 0; i < data.length; i++) {
                if (cancelImport) break;
                importOne(data[i], i);
            }
        }

        if (skipDuplicate) {
            fetchAllPages(base + '/favorites.php', (existingGids) => {
                const gidSet = extractGids(existingGids);
                doImport(gidSet);
            });
        } else {
            doImport();
        }
    }


    function showImportPopup() {
        if (document.querySelector('.eh-mask')) return;

        fetchFavoriteCategories(favCats => {
            const mask = document.createElement('div');
            mask.className = 'eh-mask';

            const popup = document.createElement('div');
            popup.className = 'eh-popup';

            const title = document.createElement('h3');
            title.textContent = '导入收藏';

            const fileInput = document.createElement('input');
            fileInput.type = 'file';
            fileInput.accept = '.json,.xlsx';

            const favSelect = document.createElement('select');

            favCats.forEach(cat => {
                if (cat.name === "All") return;
                const opt = document.createElement('option');
                opt.value = cat.id;
                opt.textContent = `收藏夹:${cat.name}`;
                favSelect.appendChild(opt);
            });

            const skipCheckbox = document.createElement('input');
            skipCheckbox.type = 'checkbox';
            skipCheckbox.id = 'skipDup';
            const skipLabel = document.createElement('label');
            skipLabel.textContent = '跳过已存在的收藏';
            skipLabel.htmlFor = 'skipDup';

            const importBtn = document.createElement('button');
            importBtn.textContent = '开始导入';

            const statusBox = document.createElement('div');
            statusBox.className = 'eh-status';
            statusBox.textContent = '等待导入...';

            const controlBox = document.createElement('div');

            const checkboxMap = []; // 用于收集所有复选框状态

            importBtn.onclick = () => {
                const file = fileInput.files[0];
                if (!file) return alert('请先选择文件');

                const reader = new FileReader();
                reader.onload = function () {
                    let data;
                    if (file.name.endsWith('.json')) {
                        try {
                            data = JSON.parse(reader.result);
                        } catch (e) {
                            return alert('JSON格式错误');
                        }
                    } else if (file.name.endsWith('.xlsx')) {
                        if (typeof XLSX === 'undefined') return alert('XLSX 库未加载');
                        const workbook = XLSX.read(reader.result, { type: 'binary' });
                        const sheet = workbook.Sheets[workbook.SheetNames[0]];
                        data = XLSX.utils.sheet_to_json(sheet);
                    }

                    if (!Array.isArray(data)) return alert('导入数据无效');

                    // 清空旧的状态
                    statusBox.textContent = '';
                    statusBox.style.display = 'flex';
                    statusBox.style.flexWrap = 'wrap';
                    statusBox.style.gap = '12px';
                    statusBox.style.height = '50vh';
                    statusBox.style.overflowY = 'auto';


                    controlBox.style.marginBottom = '10px';
                    controlBox.style.display = 'flex';
                    controlBox.style.justifyContent = 'space-between';
                    controlBox.style.alignItems = 'center';
                    const counter = document.createElement('span');
                    counter.textContent = `已勾选 0 / ${data.length}`;

                    const selectAllBtn = document.createElement('button');
                    selectAllBtn.textContent = '全选';
                    selectAllBtn.onclick = () => {
                        checkboxMap.forEach(checkbox => checkbox.checked = true);
                        updateCounter();
                    };

                    const invertBtn = document.createElement('button');
                    invertBtn.textContent = '反选';
                    invertBtn.onclick = () => {
                        checkboxMap.forEach(checkbox => checkbox.checked = !checkbox.checked);
                        updateCounter();
                    };

                    function updateCounter() {
                        const checked = checkboxMap.filter(cb => cb.checked).length;
                        counter.textContent = `已勾选 ${checked} / ${data.length}`;
                    }

                    const buttonGroup = document.createElement('div');
                    buttonGroup.appendChild(selectAllBtn);
                    buttonGroup.appendChild(invertBtn);

                    controlBox.appendChild(counter);
                    controlBox.appendChild(buttonGroup);

                    // 构建列表
                    data.forEach((item, index) => {
                        const label = document.createElement('label');
                        label.style.display = 'flex';
                        label.style.alignItems = 'center';
                        label.style.flex = '1 1 calc(33.333% - 12px)';
                        label.style.padding = '10px';
                        label.style.marginBottom = '10px';
                        label.style.border = '1px solid #ccc';
                        label.style.borderRadius = '6px';
                        label.style.background = '#f4f4f4';
                        label.style.boxSizing = 'border-box';
                        label.style.gap = '8px';

                        const checkbox = document.createElement('input');
                        checkbox.type = 'checkbox';
                        checkbox.checked = true;
                        checkboxMap.push(checkbox);
                        checkbox.onclick = updateCounter;

                        const title = item.title || item.标题 || '无标题';
                        const note = item.note || item.说明 || '';
                        const url = item.url || item.链接 || '';
                        const favName = item.收藏夹 || item.favname;
                        const thumbUrl = item.img_url || item.缩略图

                        const img = document.createElement('img');
                        img.src = thumbUrl;
                        img.style.width = '60px';
                        img.style.height = 'auto';
                        img.style.flexShrink = '0';
                        img.style.borderRadius = '4px';
                        img.style.objectFit = 'cover';

                        const span = document.createElement('span');
                        span.textContent = title;
                        span.style.marginLeft = '8px';
                        span.title = `链接: ${url}\n说明: ${note}\n收藏夹: ${favName}`;

                        label.appendChild(checkbox);
                        label.appendChild(img)
                        label.appendChild(span);
                        statusBox.appendChild(label);
                    });

                    // 替换“开始导入”按钮行为
                    importBtn.textContent = '确认导入已勾选项';
                    importBtn.onclick = () => {
                        const selectedData = data.filter((_, i) => checkboxMap[i].checked);
                        if (selectedData.length === 0) return alert('请至少勾选一个收藏');
                        statusBox.textContent = `正在导入 ${selectedData.length} 个收藏...\n`;
                        importFavoritesFromData(selectedData, favSelect.value, skipCheckbox.checked, statusBox);
                    };
                };

                if (file.name.endsWith('.xlsx')) reader.readAsBinaryString(file);
                else reader.readAsText(file);
            };

            popup.appendChild(title);
            popup.appendChild(fileInput);
            popup.appendChild(favSelect);
            popup.appendChild(skipCheckbox);
            popup.appendChild(skipLabel);
            popup.appendChild(importBtn);
            popup.appendChild(statusBox);
            popup.appendChild(controlBox);

            document.body.appendChild(mask);
            document.body.appendChild(popup);

            function closePopup() {
                mask.remove();
                popup.remove();
            }
            mask.addEventListener('click', closePopup);
        });
    }

    function load() {
        GM_registerMenuCommand('📤 导出收藏', showExportPopup);
        GM_registerMenuCommand('📥 导入收藏', showImportPopup);

        // 自动加载 xlsx 库
        const script = document.createElement('script');
        script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
        document.head.appendChild(script);
    }

    injectStyles();
    load();
})();