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
//
// @match        https://www.pixiv.net/artworks/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
// @grant        GM_xmlhttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @require      https://greasyfork.org/scripts/2963-gif-js/code/gifjs.js?version=8596
// @version      1.1.2
//
// @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=>{ r.status>=200&&r.status<300?console.log("✅ Added:",name):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.fetchIllust();
    }

    fetchIllust() {
        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;
    }

    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.illust=new PixivIllust(this.eagle);
        this.init();
    }

    init(){
        this.addButton();
        this.observeUrlChange(() => {
            this.addButton();
            this.illust.illust = this.illust.illustApi();
        });
    }

    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);
        });
    }

    async addButton(){
        try{
            const section=await this.waitForElement("section.gPBXUH");
            if(document.getElementById("save-to-eagle-btn")) return;

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

            const btn=document.createElement("button"); btn.id="save-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);
            });

            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);
            section.appendChild(container);
        }catch(e){console.error(e);}
    }

    observeUrlChange(callback){
        let oldHref=location.href;
        const title=document.querySelector("title");
        const observer=new MutationObserver(()=>{ if(oldHref!==location.href){ oldHref=location.href; callback(); } });
        observer.observe(title,{childList:true});
    }
}

new PixivEagleUI();