Pixiv Save to Eagle

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

// ==UserScript==
// @name         Pixiv Save to Eagle
// @name:zh-TW   Pixiv 圖片儲存至 Eagle
// @name:ja      Pixivの畫像を直接Eagleに儲存
// @name:en      Pixiv Save to Eagle
// @name:de      Pixiv-Bilder direkt in Eagle speichern
// @name:es      Guardar imágenes de Pixiv directamente en Eagle
// @description  將 Pixiv 作品圖片與動圖直接存入 Eagle
// @description:zh-TW 直接將 Pixiv 上的圖片與動圖儲存到 Eagle
// @description:ja Pixivの作品畫像とアニメーションを直接Eagleに儲存します
// @description:en  Save Pixiv images & animations directly into Eagle
// @description:de  Speichert Pixiv-Bilder und Animationen direkt in Eagle
// @description:es  Guarda imágenes y animaciones de Pixiv directamente en Eagle
//
// @version      1.3.4
// @match        https://www.pixiv.net/artworks/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
// @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://www.pixiv.net/" }
            }

            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("✅ Added:", name)
                    } else {
                        console.error("Failed:", 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 PixivIllust {
    constructor(eagleClient) {
        this.eagle = eagleClient
        this.illust = this.fetchIllustInfo()
    }

    fetchIllustInfo() {
        const illustId = location.href.match(/artworks\/(\d+)/)?.[1]
        if (!illustId) return null
        if (!this.illust || this.illust.illustId != illustId) {
            const xhr = new XMLHttpRequest()
            xhr.open("GET", `/ajax/illust/${illustId}`, false)
            xhr.send()
            if (xhr.status === 200) {
                this.illust = JSON.parse(xhr.responseText).body
            }
        }
        return this.illust
    }

    fetchIllusts() {
        this.fetchIllustInfo()
        if (!this.illust) return []
        const baseName = `Pixiv @${this.illust.userName} ${this.illust.title}(${this.illust.illustId})`
        if (this.isGif()) {
            return [{ url: this.illust.urls.original, name: baseName + '.gif', type: 'gif' }]
        } else {
            const baseUrl = this.illust.urls.original
            console.log(this.illust.pageCount)
            return Array.from({ length: this.illust.pageCount }, (_, i) => ({
                url: baseUrl.replace('_p0.', `_p${i}.`),
                name: this.illust.pageCount > 1 ? baseName + `_p${i}` : baseName
            }))
        }
    }

    async handleIllust(image, folderId) {
        const { url, name, type } = image
        if (type === 'gif') {
            await this.handleGif(folderId, name)
        } else {
            await this.eagle.save(url, name, folderId)
            console.log("已送到 Eagle:", name)
        }
    }

    isSingle() {
        return (this.illust.illustType === 0 || this.illust.illustType === 1) && this.illust.pageCount === 1
    }

    isSet() {
        return this.illust.pageCount > 1
    }

    isGif() {
        return this.illust.illustType === 2
    }

    async handleSingle(folderId) {
        const illust = this.illust
        const url = illust.urls.original
        const name = `Pixiv @${illust.userName} ${illust.title}(${illust.illustId})`
        await this.eagle.save(url, name, folderId)
        console.log("已送到 Eagle:", name)
    }

    async handleSet(folderId) {
        const illust = this.illust
        const url = illust.urls.original
        const urls = Array.from({ length: illust.pageCount }, (_, i) => url.replace(/_p\d\./, `_p${i}.`))
        for (const [i, u] of urls.entries()) {
            const name = `Pixiv @${illust.userName} ${illust.title}(${illust.illustId})_p${i}`
        await this.eagle.save(u, name, folderId)
        }
        console.log(`已送 ${illust.pageCount} 張到 Eagle`)
    }

    async handleGif(folderId) {
        try {
            const illust = this.illust
            const xhr = new XMLHttpRequest()
            xhr.open("GET", `/ajax/illust/${illust.illustId}/ugoira_meta`, false)
            xhr.send()
            const frames = JSON.parse(xhr.responseText).body.frames

            const gif = new GIF({ workers: 1, quality: 10, workerScript: GIF_worker_URL })
            const gifFrames = new Array(frames.length)

            await Promise.all(frames.map((frame, idx) => new Promise((resolve, reject) => {
                const url = illust.urls.original.replace("ugoira0.", `ugoira${idx}.`)
                GM_xmlhttpRequest({
                    method: "GET",
                    url,
                    headers: { referer: "https://www.pixiv.net/" },
                    responseType: "arraybuffer",
                    onload: res => {
                        if (res.status >= 200 && res.status < 300) {
                            const suffix = url.split(".").pop()
                            const mime = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg" }[suffix]
                            const blob = new Blob([res.response], { type: mime })
                            const img = document.createElement("img")
                            const reader = new FileReader()
                            reader.onload = () => {
                                img.src = reader.result
                                img.onload = () => {
                                    gifFrames[idx] = { frame: img, option: { delay: frame.delay } }
                                    resolve()
                                }
                                img.onerror = () => reject(new Error("圖片載入失敗:" + url))
                            }
                            reader.readAsDataURL(blob)
                        } else {
                            reject(new Error(`下載失敗 ${res.status}: ${url}`))
                        }
                    },
                    onerror: reject,
                    ontimeout: reject
                })
            })))

            gifFrames.forEach(f => gif.addFrame(f.frame, f.option))
            gif.on("finished", async blob => {
                const reader = new FileReader()
                reader.onload = async () => {
                    const base64 = reader.result
                    const name = `Pixiv @${illust.userName} ${illust.title}(${illust.illustId}).gif`
            await this.eagle.save(base64, name, folderId)
                    console.log("已送動圖到 Eagle:", name)
                }
                reader.readAsDataURL(blob)
            })
            gif.render()
        } catch (e) {
            console.error("handleGif error:", e)
        }
    }
}

class PixivEagleUI {
    constructor() {
        this.eagle = new EagleClient()
        this.pixiv = new PixivIllust(this.eagle)
        this.buttonContainerSelector = "section section"
        this.imageSelector = "div[role='presentation'].sc-440d5b2c-0"
        this.buttonPosition = "↖"
        this.init()
    }

    async init() {
        this.buttonPosition = await GM.getValue("buttonPosition", "↖")
        console.log("buttonPosition (updated)", this.buttonPosition)
        this.registerPositionMenu()
        this.addFolderSelect()
        this.addButtons(this.buttonPosition)
        this.observeDomChange(() => {
            this.addButtons(this.buttonPosition)
        })
        //await this.observeWorkExpand()
    }

    async waitForElement(selector, interval = 1000) {
        return new Promise((resolve, reject) => {
            let intervalId;
            const checkElement = () => {
                const element = document.querySelector(selector);
                if (element) {
                    clearInterval(intervalId);
                    resolve(element);
                }
            };
            checkElement();
            intervalId = setInterval(checkElement, interval);
        });
    }

    async addFolderSelect() {
        try {
            const btnNav = await this.waitForElement(this.buttonContainerSelector)
            if (document.getElementById("save-all-to-eagle-btn")) return

            const container = document.createElement("div")
            container.classList.add("cNcUof")

            const btn = document.createElement("button")
            btn.id = "save-all-to-eagle-btn"
            btn.textContent = "Save to Eagle"
            btn.className = "charcoal-button"
            btn.dataset.variant = "Primary"

            const select = document.createElement("select")
            select.id = "eagle-folder-select"
            select.style.marginLeft = "8px"

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

            const folders = await this.eagle.getFolderList()
            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.onclick = async () => {
                const folderId = select.value
                await GM.setValue("eagle_last_folder", folderId)
            }
            btn.onclick = async () => {
                const folderId = select.value
                await GM.setValue("eagle_last_folder", folderId)
                this.illust.fetchIllust()
                if (this.illust.isSingle()) {
                    await this.illust.handleSingle(folderId)
                } else if (this.illust.isSet()) {
                    await this.illust.handleSet(folderId)
                } else if (this.illust.isGif()) {
                    await this.illust.handleGif(folderId)
                } else {
                    console.log("不支援此作品類型")
                }
            }

            container.appendChild(btn)
            container.appendChild(select)
            btnNav.appendChild(container)
        } catch (e) {
            console.error(e)
        }
    }

    async addButtons(position) {
        try {
            this.pixiv.fetchIllustInfo()
            if (!this.pixiv.illust) return

            await this.waitForElement(this.imageSelector)

            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 validPosition = positionStyles[position] ? position : "↖"
            const styles = positionStyles[validPosition]

            document.querySelectorAll(this.imageSelector).forEach((img, index) => {
                console.log(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, styles)
                console.log("index",index)
                const btn = document.createElement("button")
                btn.id = `save-to-eagle-btn-${index}`
                btn.textContent = "save to Eagle"
                btn.classList.add("charcoal-button")

                btn.onclick = async () => {
                    let folderId = await GM.getValue("eagle_last_folder");
                    const images = this.pixiv.fetchIllusts()
                    console.log("images[index]",images)
                    await this.pixiv.handleIllust(images[index], folderId)
                }

                container.appendChild(btn)
                img.parentElement.style.position = "relative"
                img.parentElement.appendChild(container)
            })
        } catch (e) {
            console.error("無法新增按鈕:", e)
        }
    }

    registerPositionMenu() {
        GM_registerMenuCommand("選擇按鈕位置", () => {
            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 = "選擇按鈕位置:"
            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 = "確定選擇"
            confirmButton.setAttribute("aria-label", "確定按鈕位置")
            confirmButton.onclick = async () => {
                await GM.setValue("buttonPosition", this.buttonPosition);
                console.log("儲存位置:", await GM.getValue("buttonPosition"));
                container.remove();
            };

            select.onchange = async () => {
                this.buttonPosition = select.value;
                console.log(select.value);
                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)
        })
    }

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

new PixivEagleUI()