// ==UserScript==
// @name Kemono Original Image Helper
// @name:ja Kemono オリジナル画像ダウンローダー
// @name:zh-cn Kemono 原图下载助手
// @name:zh-tw Kemono 原圖下載助手
// @description Add download buttons to Kemono images with auto naming and viewer.
// @description:ja Kemono画像にダウンロードボタンを追加し、自動命名とビューアをサポートします。
// @description:zh-cn Kemono 图片添加下载按钮,自动命名并支持图片查看器。
// @description:zh-tw Kemono 圖片添加下載按鈕,自動命名並支援圖片檢視器。
// @namespace http://tampermonkey.net/
// @version 1.5
// @author LY
// @match https://kemono.su/*
// @grant GM_download
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_setValue
// @grant GM_getResourceText
// @license MIT
// @require https://cdn.jsdelivr.net/npm/@fancyapps/[email protected]/dist/fancybox/fancybox.umd.js
// @resource FANCY_CSS https://cdn.jsdelivr.net/npm/@fancyapps/[email protected]/dist/fancybox/fancybox.css
// ==/UserScript==
(function () {
'use strict';
const downloadFn = [];
// 注入样式:旋转动画 + 限制原图宽度
GM_addStyle(`
.kemono-download-btn .loading {
animation: spin 1s linear infinite;
transform-origin: center;
}
@keyframes spin {
0% { transform: rotate(0deg);}
100% { transform: rotate(360deg);}
}
.post__thumbnail ._expanded_425d1db img {
width: 100% !important;
}
.post__files {
display: grid;
grid-template-columns: repeat(auto-fill, 400px);
grid-row-gap: 20px;
grid-column-gap: 20px;
align-items: start;
}
.post__thumbnail:hover .batch-right-btn {
display: flex !important;
}
`);
const fancyCss = GM_getResourceText("FANCY_CSS");
GM_addStyle(fancyCss);
// 获取作者与标题
const getAuthor = () =>
document.querySelector('.post__user-name')?.textContent.trim() || 'unknown';
const getTitle = () =>
document.querySelector('h1.post__title span')?.textContent.trim().replace(/[\\/:*?"<>|]/g, '') || 'untitled';
const getTimestamp = () => {
const d = new Date();
const pad = n => n.toString().padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
};
const getImageKey = url => {
const match = url.match(/\/([^\/?#]+)\?f=/);
return match ? match[1] : null;
};
async function hasDownloaded (key) {
if (!key) return false;
const history = await GM_getValue('download_history', []);
return history.includes(key);
}
async function markDownloaded (key) {
if (!key) return;
const history = await GM_getValue('download_history', []);
if (!history.includes(key)) {
history.push(key);
await GM_setValue('download_history', history);
}
}
// SVG 图标模版(四状态)
const svgIcon = `
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<g class="download" fill="none">
<path d='M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z'/>
<path fill='#FFFFFF' d='M20 15a1 1 0 0 1 1 1v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4a1 1 0 1 1 2 0v4h14v-4a1 1 0 0 1 1-1M12 2a1 1 0 0 1 1 1v10.243l2.536-2.536a1 1 0 1 1 1.414 1.414l-4.066 4.066a1.25 1.25 0 0 1-1.768 0L7.05 12.121a1 1 0 1 1 1.414-1.414L11 13.243V3a1 1 0 0 1 1-1'/>
</g>
<g class="completed" style="display:none" fill="none" fill-rule="evenodd">
<path d="M5 13l4 4L19 7" fill="none" stroke="#E56F2E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<g class="loading" style="display:none" fill="none" fill-rule="evenodd">
<circle cx="12" cy="12" r="10" stroke="#E56F2E" stroke-width="4" opacity="0.3" fill="none"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#E56F2E" stroke-width="4" stroke-linecap="round" fill="none"/>
</g>
<g class="failed" style="display:none" fill="none" fill-rule="evenodd">
<path d='M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z'/>
<path fill='#F6200A' d='m12 13.414 5.657 5.657a1 1 0 0 0 1.414-1.414L13.414 12l5.657-5.657a1 1 0 0 0-1.414-1.414L12 10.586 6.343 4.929A1 1 0 0 0 4.93 6.343L10.586 12l-5.657 5.657a1 1 0 1 0 1.414 1.414z'/>
</g>
</svg>`;
function setIcon (svg, state) {
['download', 'loading', 'completed', 'failed'].forEach(s => {
const el = svg.querySelector(`.${s}`);
if (el) el.style.display = (s === state ? 'inline' : "none");
});
}
function createBtn (thumbnail, index, author, title) {
if (thumbnail.querySelector('.kemono-download-btn')) return;
const link = thumbnail.querySelector('a.fileThumb');
if (!link) return;
const btn = document.createElement('div');
btn.className = 'kemono-download-btn';
btn.innerHTML = svgIcon;
const svg = btn.querySelector('svg');
Object.assign(btn.style, {
position: 'absolute',
top: '8px',
right: '8px',
width: '32px',
height: '32px',
cursor: 'pointer',
borderRadius: '999px',
backgroundColor: 'rgba(0,0,0,0.75)',
'backdrop-filter': 'blur(4px)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
downloadFn[index] = async () => {
const originalURL = link.href;
const key = getImageKey(originalURL);
const filename = `${author}_${title}_${getTimestamp()}_${index + 1}.png`;
setIcon(svg, 'loading');
return new Promise((resolve) => {
GM_download({
url: originalURL,
name: filename,
onload: async () => {
await markDownloaded(key);
setIcon(svg, 'completed');
resolve(true);
},
onerror: (e, d) => {
console.log(e);
console.log(d);
setIcon(svg, 'failed');
resolve(false);
},
ontimeout: () => {
setIcon(svg, 'failed');
resolve(false);
},
});
})
}
btn.onclick = e => {
e.preventDefault();
e.stopPropagation();
downloadFn[index]();
};
// 初始时判断是否已下载
const originalHref = link.href;
const key = getImageKey(originalHref);
if (key) {
hasDownloaded(key).then(done => {
if (done) setIcon(svg, 'completed');
});
}
thumbnail.style.position = 'relative';
thumbnail.appendChild(btn);
const batchBtn = document.createElement('div');
batchBtn.className = 'batch-right-btn';
batchBtn.title = '下载从此图开始的右侧所有图';
// 下载按钮下方添加批量下载右侧图片
Object.assign(batchBtn.style, {
position: 'absolute',
top: '44px',
right: '8px',
width: '32px',
height: '32px',
borderRadius: '999px',
backgroundColor: 'rgba(0,0,0,0.75)',
display: 'none',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
});
batchBtn.classList.add('batch-right-btn');
// SVG 箭头图标(向右)
batchBtn.innerHTML = `
<svg viewBox="0 0 24 24" width="18" height="18">
<path d="M10 6l6 6-6 6" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
batchBtn.onclick = async e => {
e.stopPropagation();
e.preventDefault();
for (let left = index; left < downloadFn.length; left++) {
const downloadOk = await downloadFn[left]();
if (!downloadOk) break;
}
};
thumbnail.appendChild(batchBtn);
}
function enhanceImageViewer (thumbnails) {
const imageItems = [];
thumbnails.forEach((thumb, i) => {
const link = thumb.querySelector('a.fileThumb');
const img = thumb.querySelector('img');
if (link && img) {
const originalUrl = link.href;
const thumbSrc = img.src.startsWith('//') ? location.protocol + img.src : img.src;
const item = {
src: originalUrl,
thumbSrc: thumbSrc,
caption: img.alt || '', // 可添加图片描述
};
imageItems.push(item);
// 绑定点击事件
img.style.cursor = 'zoom-in';
img.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
Fancybox.show(imageItems, { startIndex: i });
});
}
});
}
function init () {
const all = document.querySelectorAll('.post__thumbnail');
if (all.length === 0) return false;
const author = getAuthor();
const title = getTitle();
all.forEach((thumb, i) => createBtn(thumb, i, author, title));
enhanceImageViewer(all);
return true;
}
let timer = setInterval(() => {
if (init()) {
clearInterval(timer);
}
}, 500);
})();