Kemono Save to Eagle

將 Kemono 作品圖片與動圖直接存入 Eagle

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला 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
//
// @author       Max
// @namespace    https://github.com/Max46656/EverythingInGreasyFork/tree/main/%E7%9C%81%E5%8A%9B/Kemono%20Save%20to%20Eagle
// @supportURL   https://github.com/Max46656/EverythingInGreasyFork/issues/new?assignees=&labels=bug%2Cuserscript&projects=&template=bug_report.yml&title=[Kemono%20儲存至%20Eagle]%20問題回報-V1.5.3
// @license      MPL2.0
//
// @version      1.5.4
// @match        https://kemono.cr/*/user/*/post/*
// @match        https://coomer.st/*/user/*/post/*
// @icon         https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=http://tw.eagle.cool&size=64
// @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
// ==/UserScript==

class EagleClient {
    /**
     * 將圖片或檔案加入 Eagle(失敗時無限重試,每 retryDelay 毫秒一次)
     * @param {string} urlOrBase64 - 圖片網址或 base64 data URL
     * @param {string} name - 檔案名稱
     * @param {string|string[]} folderId - 目標資料夾 ID
     * @param {number} [retryDelay=3000] - 重試間隔(毫秒)
     * @returns {Promise<boolean>} 是否最終成功
     */
    async save(urlOrBase64, name, folderId = [], retryDelay = 3000) {
        const PREFIX = `[${GM_info.script.name}]`;
        const data = {
            url: urlOrBase64,
            name,
            folderId: Array.isArray(folderId) ? folderId : [folderId],
            tags: [],
            website: location.href,
            headers: { referer: "https://kemono.cr/" }
        };
        let originalTitle = document.title;
        let blinkInterval = null;
        const sendRequest = () => new Promise((resolve) => {
            if (!document || !document.title) {
                console.warn(`${PREFIX} 分頁已關閉,停止 Eagle 儲存重試`);
                this.#stopTitleBlink(originalTitle, blinkInterval);
                resolve(false);
                return;
            }
            GM_xmlhttpRequest({
                url: "http://localhost:41595/api/item/addFromURL",
                method: "POST",
                headers: { "Content-Type": "application/json" },
                data: JSON.stringify(data),
                timeout: 1000,
                onload: (r) => {
                    if (r.status >= 200 && r.status < 300) {
                        console.log(`${PREFIX} ⭘ 已新增: ${name}`);
                        this.#stopTitleBlink(originalTitle, blinkInterval);
                        resolve(true);
                    } else {
                        console.warn(`${PREFIX} Eagle 回應非 2xx: ${r.status} ${r.statusText || '無狀態文字'}`);
                        this.#startTitleBlink(originalTitle, blinkInterval);
                        resolve(false);
                    }
                },
                onerror: (err) => {
                    console.error(`${PREFIX} 網路錯誤:`, err);
                    this.#startTitleBlink(originalTitle, blinkInterval);
                    resolve(false);
                },
                ontimeout: () => {
                    console.warn(`${PREFIX} 請求超時`);
                    this.#startTitleBlink(originalTitle, blinkInterval);
                    resolve(false);
                }
            });
        });
        while (true) {
            const ok = await sendRequest();
            if (ok) break;
            await new Promise(r => setTimeout(r, retryDelay));
        }
        return true;
    }

    #startTitleBlink(originalTitle, blinkIntervalRef) {
        if (blinkIntervalRef.value) return;

        let showError = true;
        blinkIntervalRef.value = setInterval(() => {
            if (!document || !document.title) {
                this.#stopTitleBlink(originalTitle, blinkIntervalRef);
                return;
            }
            document.title = showError ? 'Eagle 儲存失敗' : originalTitle;
            showError = !showError;
        }, 1000);

        window.addEventListener('unload', () => {
            this.#stopTitleBlink(originalTitle, blinkIntervalRef);
        }, { once: true });
    }

    #stopTitleBlink(originalTitle, blinkIntervalRef) {
        if (blinkIntervalRef.value) {
            clearInterval(blinkIntervalRef.value);
            blinkIntervalRef.value = null;
        }
        if (originalTitle && document && document.title) {
            document.title = originalTitle;
        }
    }

    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, div.post__files video, div.post__files audio";
    }

    /**
     * 蒐集頁面中所有圖片、影片、音檔
     * @returns {Array<{url: string, name: string, type: 'image'|'video'|'audio'}>}
     */
    fetchImages() {
        return Array.from(document.querySelectorAll(this.imageSelector)).map((media, index) => {
            let url = media.parentElement?.href || media.currentSrc || media.src || '';

            if (media.dataset.src) url = media.dataset.src;
            if (media.dataset.original) url = media.dataset.original;

            if (!url) return null;

            let title = document.querySelector("title")?.textContent?.trim() || "Kemono Post";
            let name = `${title} P${index + 1}`;

            const tagName = media.tagName.toLowerCase();
            const urlWithoutQuery = url.split(/[#?]/)[0];
            let ext = urlWithoutQuery.split('.').pop().toLowerCase();

            if (!['jpg','jpeg','png','gif','webp','avif','mp4','webm','ogg','mp3','wav'].includes(ext)) {
                if (tagName === 'img') ext = 'jpg';
                else if (tagName === 'video') ext = 'mp4';
                else if (tagName === 'audio') ext = 'mp3';
            }

            if (ext) name += `.${ext}`;

            return { url, name, type: tagName };
        }).filter(item => item !== null);
    }

    async getImageDataUrl(url) {
        if (!url.startsWith('blob:')) {
            return url;
        }

        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`fetch blob 失敗:${response.status} ${response.statusText}`);
            }

            const blob = await response.blob();

            return new Promise((resolve, reject) => {
                const reader = new FileReader();
                reader.onloadend = () => {
                    if (reader.result) {
                        resolve(reader.result); // data:image/...;base64,xxxx...
                    } else {
                        reject(new Error("FileReader 讀取失敗"));
                    }
                };
                reader.onerror = () => reject(new Error("FileReader 發生錯誤"));
                reader.readAsDataURL(blob);
            });
        } catch (err) {
            console.error("無法將 blob 轉為 base64:", url, err);
            return null;
        }
    }

    async handleImage(url, name, folderId) {
        const dataUrlOrOriginal = await this.getImageDataUrl(url);

        if (!dataUrlOrOriginal) {
            console.warn(`跳過無法處理的圖片:${name} (${url})`);
            return;
        }

        await this.eagle.save(dataUrlOrOriginal, 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, div.post__files video, div.post__files audio";;
        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) {
        while (true) {
            const el = document.querySelector(selector);
            if (el) return el;
            await new Promise(r => setTimeout(r, 200));
        }
    }

    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.after(container, timeoutWarning);

            const lastFolderId = await GM.getValue("eagle_last_folder");

            const fetchFoldersWithRetry = async () => {
                while (true) {
                    const timeoutPromise = new Promise((_, reject) =>
                                                       setTimeout(() => reject(new Error("TIMEOUT")), 2000)
                                                      );

                    try {
                        const folders = await Promise.race([
                            this.eagle.getFolderList(),
                            timeoutPromise
                        ]);

                        timeoutWarning.style.display = "none";
                        console.log(`[${GM_info.script.name}] Eagle 資料夾列表取得成功`);
                        return folders;

                    } catch (err) {
                        if (err.message === "TIMEOUT") {
                            timeoutWarning.style.display = "block";
                            console.warn(`[${GM_info.script.name}] Eagle 資料夾請求超時,將於 3 秒後自動重試...`);
                            await new Promise(r => setTimeout(r, 3000));
                            continue;
                        }

                        console.error(`[${GM_info.script.name}] 取得資料夾列表發生錯誤:`, err);
                        timeoutWarning.style.display = "block";
                        await new Promise(r => setTimeout(r, 3000));
                        continue;
                    }
                }
            };

            const folders = await fetchFoldersWithRetry();

            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(`[${GM_info.script.name}] 無法新增資料夾選擇器:`, 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()