Kemono 儲存至 Eagle

直接將 Kemono 上的圖片與動圖儲存到 Eagle

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Kemono Save to Eagle
// @name:zh-TW   Kemono 儲存至 Eagle
// @name:ja      Kemonoの畫像を直接Eagleに儲存
// @name:en      Kemono Save to Eagle
// @name:de      Kemono-Bilder direkt in Eagle speichern
// @name:es      Guardar imágenes de Kemono directamente en Eagle
// @description  將 Kemono 作品圖片與動圖直接存入 Eagle
// @description:zh-TW 直接將 Kemono 上的圖片與動圖儲存到 Eagle
// @description:ja Kemonoの作品畫像とアニメーションを直接Eagleに儲存します
// @description:en  Save Kemono images & animations directly into Eagle
// @description:de  Speichert Kemono-Bilder und Animationen direkt in Eagle
// @description:es  Guarda imágenes y animaciones de Kemono directamente en Eagle
//
// @version      1.4.0
// @match        https://kemono.cr/*/user/*/post/*
// @match        https://kemono.cr/*/user/*/post/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=kemono.cr
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @require      https://greasyfork.org/scripts/2963-gif-js/code/gifjs.js?version=8596
// @run-at       document-end
//
// @author       Max
// @namespace    https://github.com/Max46656
// @license      MPL2.0
// ==/UserScript==

class EagleClient {
    async save(urlOrBase64, name, folderId = []) {
        return new Promise(resolve => {
            const data = {
                url: urlOrBase64,
                name,
                folderId: Array.isArray(folderId) ? folderId : [folderId],
                tags: [],
                website: location.href,
                headers: { referer: "https://kemono.cr/" }
            }

            GM_xmlhttpRequest({
                url: "http://localhost:41595/api/item/addFromURL",
                method: "POST",
                headers: { "Content-Type": "application/json" },
                data: JSON.stringify(data),
                onload: r => {
                    if (r.status >= 200 && r.status < 300) {
                        console.log("⭘ 已新增:", name)
                    } else {
                        console.error("失敗:", r)
                    }
                    resolve()
                },
                onerror: e => {
                    console.error(e)
                    resolve()
                },
                ontimeout: e => {
                    console.error(e)
                    resolve()
                }
            })
        })
    }

    async getFolderList() {
        return new Promise(resolve => {
            GM_xmlhttpRequest({
                url: "http://localhost:41595/api/folder/list",
                method: "GET",
                onload: res => {
                    try {
                        const folders = JSON.parse(res.responseText).data || []
                        const list = []
                        const appendFolder = (f, prefix = "") => {
                            list.push({ id: f.id, name: prefix + f.name })
                            if (f.children && f.children.length) {
                                f.children.forEach(c => appendFolder(c, "└── " + prefix))
                            }
                        }
                        folders.forEach(f => appendFolder(f))
                        resolve(list)
                    } catch (e) {
                        console.error("解析資料夾列表失敗", e)
                        resolve([])
                    }
                },
                onerror: err => {
                    console.error(err)
                    resolve([])
                }
            })
        })
    }
}

class KemonoImage {
    constructor(eagleClient) {
        this.eagle = eagleClient
        this.images = this.fetchImages()
        this.imageSelector = "div.post__files img";
    }

    fetchImages() {
        return Array.from(document.querySelectorAll(this.imageSelector)).map((img, index) => ({
            url: img.parentElement.href == null ? img.src : img.parentElement.href,
            name: `${document.querySelector("title")?.textContent} P${index+1}` || `Kemono Image ${img.src.split('/').pop()}`
        }));
    }

    async handleImage(url, name, folderId) {
        await this.eagle.save(url, name, folderId)
        console.log("已送到 Eagle:", name)
    }
}

class KemonoEagleUI {
    constructor() {
        this.eagle = new EagleClient();
        this.kemono = new KemonoImage(this.eagle);
        this.i18n = new Localization();
        this.buttonContainerSelector = "div.post__body h2:last-of-type";
        this.imageSelector = "div.post__files img";
        this.processedSelector = "eagle-folder-select";
        this.init();
    }

    async init() {
        this.registerPositionMenu()
        this.addButtons()
        await this.addFolderSelect()
        this.addDownloadAllButton()
        this.observeDomChange(() => {
            this.addButtons()
            this.kemono.images = this.kemono.fetchImages()
        })
    }

    async waitForElement(selector, timeout = 10000) {
        return new Promise((resolve, reject) => {
            const el = document.querySelector(selector)
            if (el) return resolve(el)
            const obs = new MutationObserver(() => {
                const e = document.querySelector(selector)
                if (e) {
                    obs.disconnect()
                    resolve(e)
                }
            })
            obs.observe(document.body, { childList: true, subtree: true })
            if (timeout) {
                setTimeout(() => {
                    obs.disconnect()
                    reject(new Error("Timeout:" + selector))
                }, timeout)
            }
        })
    }

    registerPositionMenu() {
        GM_registerMenuCommand(this.i18n.get("選擇按鈕位置"), () => {
            const select = document.createElement("select");
            const options = [
                { value: "↖", text: "↖" },
                { value: "↗", text: "↗" },
                { value: "↙", text: "↙" },
                { value: "↘", text: "↘" },
                { value: "↑", text: "↑" },
                { value: "↓", text: "↓" },
                { value: "←", text: "←" },
                { value: "→", text: "→" }
            ];
            options.forEach(opt => {
                const option = document.createElement("option");
                option.value = opt.value;
                option.textContent = opt.text;
                if (opt.value === this.buttonPosition) option.selected = true;
                select.appendChild(option);
            });
            const container = document.createElement("div");
            container.style.position = "fixed";
            container.style.top = "50%";
            container.style.left = "50%";
            container.style.transform = "translate(-50%, -50%)";
            container.style.color = "black";
            container.style.backgroundColor = "white";
            container.style.padding = "20px";
            container.style.border = "1px solid #ccc";
            container.style.zIndex = "10000";
            container.style.display = "flex";
            container.style.alignItems = "center";
            container.style.gap = "10px";
            const label = document.createElement("label");
            label.textContent = this.i18n.get("選擇按鈕位置:");
            label.style.marginRight = "10px";
            const confirmButton = document.createElement("button");
            confirmButton.textContent = "⭘";
            confirmButton.style.padding = "2px 8px";
            confirmButton.style.backgroundColor = "#28a745";
            confirmButton.style.color = "white";
            confirmButton.style.border = "none";
            confirmButton.style.borderRadius = "4px";
            confirmButton.style.cursor = "pointer";
            confirmButton.style.fontSize = "14px";
            confirmButton.title = this.i18n.get("確定選擇");
            confirmButton.setAttribute("aria-label", this.i18n.get("確定按鈕位置"));
            confirmButton.onclick = async () => {
                this.buttonPosition = select.value;
                await GM.setValue("buttonPosition", this.buttonPosition);
                document.querySelectorAll("[id^=save-to-eagle-btn]").forEach(btn => btn.parentElement.remove());
                this.addButtons(this.buttonPosition);
                container.remove();
            };
            select.onchange = async () => {
                this.buttonPosition = select.value;
                await GM.setValue("buttonPosition", this.buttonPosition);
                document.querySelectorAll("[id^=save-to-eagle-btn]").forEach(btn => btn.parentElement.remove());
                this.addButtons(this.buttonPosition);
            };
            container.appendChild(label);
            container.appendChild(select);
            container.appendChild(confirmButton);
            document.body.appendChild(container);
        });
    }

    async addFolderSelect() {
        try {
            const section = await this.waitForElement(this.buttonContainerSelector);
            if (document.getElementById(this.processedSelector)) return;
            const container = document.createElement("div");
            container.style.margin = "10px 0";
            container.style.display = "flex";
            container.style.alignItems = "center";
            container.style.gap = "8px";
            const folderLabel = document.createElement("label");
            folderLabel.textContent = this.i18n.get("Eagle 資料夾:");
            folderLabel.htmlFor = this.processedSelector;
            folderLabel.style.fontSize = "14px";
            folderLabel.style.fontWeight = "500";
            folderLabel.style.color = "#FFFFFF";
            const select = document.createElement("select");
            select.id = this.processedSelector;
            select.style.padding = "5px";
            select.style.fontSize = "14px";
            const timeoutWarning = document.createElement("div");
            timeoutWarning.id = "eagle-folder-timeout-warning";
            timeoutWarning.textContent = this.i18n.get("請檢查 Eagle 程式是否正常運行、沒有當機、已開啟「瀏覽器擴充功能支援」");
            timeoutWarning.style.color = "#e8a17d";
            timeoutWarning.style.fontSize = "13px";
            timeoutWarning.style.marginTop = "8px";
            timeoutWarning.style.display = "none";
            container.appendChild(folderLabel);
            container.appendChild(select);
            section.appendChild(container);
            section.appendChild(timeoutWarning);
            const lastFolderId = await GM.getValue("eagle_last_folder");
            const timeoutPromise = new Promise((_, reject) =>
                setTimeout(() => reject(new Error("TIMEOUT")), 2000)
            );
            let folders;
            try {
                folders = await Promise.race([
                    this.eagle.getFolderList(),
                    timeoutPromise
                ]);
            } catch (err) {
                if (err.message === "TIMEOUT") {
                    timeoutWarning.style.display = "block";
                    folders = await this.eagle.getFolderList();
                } else {
                    throw err;
                }
            }
            folders.forEach(f => {
                const option = document.createElement("option");
                option.value = f.id;
                option.textContent = f.name;
                if (f.id === lastFolderId) option.selected = true;
                select.appendChild(option);
            });
            select.addEventListener("change", async () => {
                await GM.setValue("eagle_last_folder", select.value);
            });
        } catch (e) {
            console.error("無法新增資料夾選擇器:", e);
        }
    }

    async addDownloadAllButton() {
        try {
            const section = await this.waitForElement(this.buttonContainerSelector);
            const select = document.getElementById(this.processedSelector);
            if (!select || document.getElementById("download-all-btn")) return;
            const container = document.createElement("div");
            container.style.margin = "10px 0";
            const btn = document.createElement("button");
            btn.id = "download-all-btn";
            btn.textContent = this.i18n.get("全部儲存到 Eagle");
            btn.style.padding = "5px 10px";
            btn.style.backgroundColor = "#282a2e"
            btn.style.color = "#e8a17d"
            btn.style.border = "2px solid #3b3e44CC";
            btn.style.borderRadius = "4px";
            btn.style.cursor = "pointer";
            btn.style.fontSize = "14px";
            btn.style.marginLeft = "10px";
            btn.onclick = async () => {
                const folderId = select.value;
                await GM.setValue("eagle_last_folder", folderId);
                const images = this.kemono.images;
                for (const [index, image] of images.entries()) {
                    await this.kemono.handleImage(image.url, image.name, folderId);
                    console.log(`已儲存圖片 ${index + 1}/${images.length}`);
                }
                console.log(`⭘ 已將 ${images.length} 張圖片儲存到 Eagle`);
            };
            container.appendChild(btn);
            select.parentElement.appendChild(container);
        } catch (e) {
            console.error("無法新增全部下載按鈕:", e);
        }
    }

    async addButtons() {
        try {
            const images = await this.waitForElement(this.imageSelector);
            const select = document.getElementById(this.processedSelector);
            if (!select) return;
            const positionStyles = {
                "↖": { top: "10px", left: "10px" },
                "↗": { top: "10px", right: "10px" },
                "↙": { bottom: "10px", left: "10px" },
                "↘": { bottom: "10px", right: "10px" },
                "↑": { top: "10px", left: "50%", transform: "translateX(-50%)" },
                "↓": { bottom: "10px", left: "50%", transform: "translateX(-50%)" },
                "←": { top: "50%", left: "10px", transform: "translateY(-50%)" },
                "→": { top: "50%", right: "10px", transform: "translateY(-50%)" }
            };
            const position = await GM.getValue("buttonPosition", "↖")
            document.querySelectorAll(this.imageSelector).forEach((img, index) => {
                if (img.parentElement.querySelector(`#save-to-eagle-btn-${index}`)) return;
                const container = document.createElement("div");
                container.style.position = "absolute";
                container.style.zIndex = "1000";
                Object.assign(container.style, positionStyles[position]);
                const btn = document.createElement("button");
                btn.id = `save-to-eagle-btn-${index}`;
                btn.textContent = this.i18n.get("儲存到 Eagle");
                btn.style.padding = "5px 10px";
                btn.style.backgroundColor = "#00000080"
                btn.style.color = "#e8a17d"
                btn.style.border = "none";
                btn.style.borderRadius = "4px";
                btn.style.cursor = "pointer";
                btn.style.fontSize = "12px";
                btn.onclick = async () => {
                    let folderId = await GM.getValue("eagle_last_folder");
                    const image = this.kemono.images[index];
                    await this.kemono.handleImage(image.url, image.name, folderId);
                };
                container.appendChild(btn);
                img.parentElement.style.position = "relative";
                img.parentElement.appendChild(container);
            });
        } catch (e) {
            console.error("無法新增按鈕:", e);
        }
    }

    observeDomChange(callback) {
        const observer = new MutationObserver(() => {
            callback()
        })
        observer.observe(document.body, { childList: true, subtree: true })
    }

}

class Localization {
    constructor() {
        this.translations = {
            "Eagle 資料夾:": {
                "zh-TW": "Eagle 資料夾:",
                "ja": "Eagle フォルダー:",
                "en": "Eagle Folder:",
                "de": "Eagle Ordner:",
                "es": "Carpeta de Eagle:"
            },
            "全部儲存到 Eagle": {
                "zh-TW": "全部儲存到 Eagle",
                "ja": "すべてを Eagle に保存",
                "en": "Save All to Eagle",
                "de": "Alles in Eagle speichern",
                "es": "Guardar todo en Eagle"
            },
            "儲存到 Eagle": {
                "zh-TW": "儲存到 Eagle",
                "ja": "Eagle に保存",
                "en": "Save to Eagle",
                "de": "In Eagle speichern",
                "es": "Guardar en Eagle"
            },
            "選擇按鈕位置": {
                "zh-TW": "選擇按鈕位置",
                "ja": "ボタンの位置を選択",
                "en": "Select Button Position",
                "de": "Schaltflächenposition auswählen",
                "es": "Seleccionar posición del botón"
            },
            "選擇按鈕位置:": {
                "zh-TW": "選擇按鈕位置:",
                "ja": "ボタンの位置を選択:",
                "en": "Select button position:",
                "de": "Schaltflächenposition auswählen:",
                "es": "Seleccionar posición del botón:"
            },
            "確定選擇": {
                "zh-TW": "確定選擇",
                "ja": "選択を確定",
                "en": "Confirm Selection",
                "de": "Auswahl bestätigen",
                "es": "Confirmar selección"
            },
            "確定按鈕位置": {
                "zh-TW": "確定按鈕位置",
                "ja": "ボタン位置を確定",
                "en": "Confirm button position",
                "de": "Schaltflächenposition bestätigen",
                "es": "Confirmar posición del botón"
            },
            "請檢查 Eagle 程式是否正常運行、沒有當機、已開啟「瀏覽器擴充功能支援」": {
                "zh-TW": "✕ 請檢查 Eagle 程式是否正常運行、沒有當機、已開啟「瀏覽器擴充功能支援」",
                "ja": "✕ Eagle アプリが正常に動作しているか、クラッシュしていないか、「ブラウザ拡張機能サポート」が有効になっているかを確認してください",
                "en": "✕ Please check if the Eagle app is running normally, not crashed, and has 'Browser Extension Support' enabled",
                "de": "✕ Bitte überprüfen Sie, ob die Eagle-App normal läuft, nicht abgestürzt ist und 'Browser-Erweiterungsunterstützung' aktiviert ist",
                "es": "✕ Por favor, verifica si la aplicación Eagle está funcionando normalmente, no se ha bloqueado y tiene activado el 'Soporte para extensiones de navegador'"
            }
        };

        this.supportedLanguages = ["zh-TW", "ja", "en", "de", "es"];

        this.detectBrowserLanguage();
    }

    detectBrowserLanguage() {
        let detected;

        if (navigator.languages && navigator.languages.length > 0) {
            for (const lang of navigator.languages) {
                const normalized = this.normalizeLanguage(lang);
                if (this.supportedLanguages.includes(normalized)) {
                    detected = normalized;
                    break;
                }
            }
        } else if (navigator.language) {
            const normalized = this.normalizeLanguage(navigator.language);
            if (this.supportedLanguages.includes(normalized)) {
                detected = normalized;
            }
        }

        this.currentLanguage = detected || "zh-TW";
        console.log(`Localization: 偵測到瀏覽器語言,使用 ${this.currentLanguage}`);
    }

    normalizeLanguage(lang) {
        lang = lang.toLowerCase();

        if (lang.startsWith("zh")) {
            return "zh-TW";
        }

        const primary = lang.split("-")[0];
        return primary;
    }

    get(key) {
        const dict = this.translations[key];
        if (!dict) {
            console.warn(`缺少翻譯鍵:${key}`);
            return key;
        }
        return dict[this.currentLanguage] || dict["zh-TW"];
    }
}

new KemonoEagleUI()