// ==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.2.2
// @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()
}
fetchImages() {
return Array.from(document.querySelectorAll("div.post__files img")).map((img, index) => ({
url: img.src,
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.buttonContainerSelector = "h2#Files";
this.imageSelector = "div.post__files img";
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("選擇按鈕位置", () => {
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 () => {
this.buttonPosition = select.value;
console.log("選中位置:", select.value);
await GM.setValue("buttonPosition", this.buttonPosition);
console.log("儲存位置:", await GM.getValue("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;
console.log(select.value);
await GM.setValue("buttonPosition", this.buttonPosition);
console.log(await GM.getValue("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("eagle-folder-select")) return;
const container = document.createElement("div");
container.style.margin = "10px 0";
container.style.display = "flex";
container.style.alignItems = "center";
container.style.gap = "8px";
const LABEL_TEXT = "Eagle 資料夾:";
const folderLabel = document.createElement("label");
folderLabel.textContent = LABEL_TEXT;
folderLabel.htmlFor = "eagle-folder-select";
folderLabel.style.fontSize = "14px";
folderLabel.style.fontWeight = "500";
folderLabel.style.color = "#FFFFFF";
const select = document.createElement("select");
select.id = "eagle-folder-select";
select.style.padding = "5px";
select.style.fontSize = "14px";
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);
});
container.appendChild(folderLabel);
container.appendChild(select);
section.appendChild(container);
} catch (e) {
console.error("無法新增資料夾選擇器:", e);
}
}
async addDownloadAllButton() {
try {
const section = await this.waitForElement(this.buttonContainerSelector);
const select = document.getElementById("eagle-folder-select");
console.log(select,document.getElementById("download-all-btn"))
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 = "全部儲存到 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("eagle-folder-select");
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", "↖")
console.log("position",position, this.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 = "儲存到 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 })
}
}
new KemonoEagleUI()