Save Kemono images & animations directly into Eagle
// ==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()