// ==UserScript==
// @name fantia-image-downloader
// @name:en fantia-image-downloader
// @name:zh-CN fantia-image-downloader
// @namespace https://fantia.jp/
// @version 0.2.0
// @description Fantiaに投稿された画像をzipファイルでダウンロードするシンプルなスクリプトです。
// @description:en Download all images posted to Fantia at once for each post.
// @description:zh-CN 一个简单的脚本,可以将Fantia上发布的图片下载成一个压缩文件。
// @author ame-chan
// @match https://fantia.jp/posts/*
// @icon https://fantia.jp/assets/customers/favicon-32x32-8ab6e1f6c630503f280adca20d089646e0ea67559d5696bb3b9f34469e15c168.png
// @grant none
// @license MIT
// @require https://cdn.jsdelivr.net/npm/[email protected]/umd/index.js
// ==/UserScript==
(() => {
const i18n = {
'en': {
'noname': 'untitled',
'initialButtonName': 'Image Download',
'progressDownloadImages': 'Downloading images',
'createZipFile': 'Creating zip file',
},
'zh': {
'noname': '无标题',
'initialButtonName': '图片下载',
'progressDownloadImages': '正在下载的图像',
'createZipFile': '正在创建压缩文件',
},
'ja': {
'noname': '無題',
'initialButtonName': '画像ダウンロード',
'progressDownloadImages': '画像ダウンロード中',
'createZipFile': '圧縮ファイル作成中',
},
};
const lang = (() => {
if (/^ja/.test(navigator.language)) {
return i18n['ja'];
} else if (/^zh/.test(navigator.language)) {
return i18n['zh'];
}
return i18n['en'];
})();
const getDLButton = () => document.querySelector('#fantiaDLbutton');
const getDLButtonWrapper = () => document.querySelector('.userjs-downloadBtn');
const getDate = () => {
const getNowDate = () => {
const date = new Date();
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yyyy}${mm}${dd}`;
};
const postDate = document.querySelector('.post-header .post-date');
const [date = false] = postDate?.textContent?.split(' ').filter(Boolean) || [];
if (!date) {
return getNowDate();
}
return date.replace(/\//g, '');
};
const getPostContent = () => {
const contentElms = document.querySelectorAll('.post-content');
const postData = [];
for (const contentElm of contentElms) {
const contentData = {
title: '',
imagePaths: [],
};
const titleElm = contentElm.querySelector('.post-content-title');
if (titleElm && titleElm.textContent) {
contentData.title = titleElm.textContent === '' ? lang['noname'] : titleElm.textContent;
}
const imageElms = contentElm.querySelectorAll('.image-module');
for (const imageElm of imageElms) {
const image = imageElm.querySelector('img[src]');
if (!image) continue;
const [imgId = false] = image.src.match(/\/[0-9]{8}\//) || [];
if (imgId) {
contentData.imagePaths.push(imgId.replace(/\//g, ''));
}
}
if (contentData.imagePaths.length > 0) {
postData.push(contentData);
}
}
return postData;
};
const getImageFormat = (arrayBuffer) => {
const arr = new Uint8Array(arrayBuffer).subarray(0, 4);
let header = '';
for (let i = 0; i < arr.length; i++) {
header += arr[i].toString(16);
}
if (/^89504e47/.test(header)) {
return 'png';
} else if (/^47494638/.test(header)) {
return 'gif';
} else if (/^424d/.test(header)) {
return 'bmp';
} else if (/^ffd8ff/.test(header)) {
return 'jpg';
}
return '';
};
const execDownload = async () => {
try {
// 新しいzipファイルを作成
const zip = new window.fflate.Zip();
const zipData = [];
const postContents = getPostContent();
const postContentsLength = postContents.length;
const postId = location.pathname.split('/').filter(Boolean).pop();
const baseURL = `https://fantia.jp/posts/${postId}/post_content_photo/`;
const changeProgress = (text, percentage) => {
const buttonElm = getDLButton();
if (!buttonElm) return;
if (!buttonElm.classList.contains('is-disabled')) {
buttonElm.classList.add('is-disabled');
}
buttonElm.textContent = `${text} ... ${percentage}%`;
};
let totalFiles = 0;
for (let i = 0; i < postContentsLength; i++) {
const content = postContents[i];
totalFiles += content.imagePaths.length;
}
let completedFiles = 0;
// ZIPデータを収集するためのイベントを設定
zip.ondata = (err, data, final) => {
if (err) {
console.error(err);
return;
}
zipData.push(data);
if (final) {
const blob = new Blob(zipData, {
type: 'application/zip',
});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `[${getDate()}] ${title}.zip`;
link.click();
// ボタン初期化
const buttonElm = getDLButton();
if (!buttonElm) return;
buttonElm.classList.remove('is-disabled');
buttonElm.textContent = lang['initialButtonName'];
}
};
const postTitleElm = document.querySelector('h1.post-title');
const title = postTitleElm?.textContent || lang['noname'];
// 画像のダウンロードと処理を並行して行う
await Promise.all(
postContents.map(async (data, contentIndex) => {
await Promise.all(
data.imagePaths.map(async (imagePath, imageIndex) => {
const response = await fetch(baseURL + imagePath);
const text = await response.text();
const dom = new DOMParser();
const html = dom.parseFromString(text, 'text/html');
const originalImage = html.querySelector('img[src*="cc.fantia.jp"]');
if (originalImage) {
const imageDataResp = await fetch(originalImage.src);
const binaryData = await imageDataResp.arrayBuffer();
const uint8arrayData = new Uint8Array(binaryData);
const url = new URL(originalImage.src);
const ext = url.pathname.split('.').pop() || getImageFormat(binaryData);
const fileName = `${data.title}_${contentIndex}_${imageIndex}.${ext}`;
// ファイルをZIPに追加
const fileEntry = new window.fflate.ZipPassThrough(fileName);
zip.add(fileEntry); // ストリームをZIPに追加
fileEntry.push(uint8arrayData, true); // データを追加し、終了を示す
completedFiles++;
const percentage = ((completedFiles / totalFiles) * 100).toFixed(2);
changeProgress(lang['progressDownloadImages'], percentage);
}
}),
);
}),
);
// ZIPの生成を開始
changeProgress(lang['createZipFile'], '100');
zip.end();
} catch (e) {
alert(e);
console.error(e);
}
};
const buttonStyle = `<style>
.userjs-downloadBtn {
display: flex;
align-items: center;
justify-content: center;
margin-top: 30px;
}
.userjs-downloadBtn__button {
position: relative;
appearance: none;
margin: 0;
padding: 12px 24px;
font-size: 16px;
color: #fff;
background-color: #00d1b2;
border: 0;
border-radius: 4px;
box-shadow: 0 3px 6px rgb(0 209 178 / 60%);
transition: filter 0.3s ease;
}
.userjs-downloadBtn__button:hover {
filter: saturate(130%);
}
.userjs-downloadBtn__button:active {
top: 1px;
box-shadow: none;
}
.userjs-downloadBtn__button.is-disabled {
pointer-events: none;
user-select: none;
color: #aaa;
background-color: #e0e0e0;
box-shadow: none;
}
</style>`;
const createDownloadButton = () => {
const postHeader = document.querySelector('.the-post .post-header');
const buttonHTML = `<div class="userjs-downloadBtn">
<button type="button" id="fantiaDLbutton" class="userjs-downloadBtn__button">${lang['initialButtonName']}</button>
</div>`;
document.head.insertAdjacentHTML('afterbegin', buttonStyle);
postHeader?.insertAdjacentHTML('afterend', buttonHTML);
const buttonElm = getDLButton();
buttonElm?.addEventListener('click', () => {
void execDownload();
});
};
const observeContents = () => {
const mainElm = document.querySelector('#main');
if (!mainElm) {
return console.warn('mainElm not found');
}
const dlbuttonWrapperElm = getDLButtonWrapper();
if (dlbuttonWrapperElm !== null) {
dlbuttonWrapperElm.remove();
}
let timer;
const postContentsArray = () => getPostContent().filter((data) => data.imagePaths.length);
const observer = new MutationObserver((_, obs) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
if (postContentsArray().length) {
obs.disconnect();
clearTimeout(timer);
const dlbuttonWrapperElm = getDLButtonWrapper();
if (dlbuttonWrapperElm === null) {
createDownloadButton();
}
window.removeEventListener('focus', observeContents);
} else {
console.warn('[fantia-downloader] download files not found.');
}
}, 100);
});
if (postContentsArray().length) {
createDownloadButton();
} else {
observer.observe(mainElm, {
childList: true,
subtree: true,
});
}
};
observeContents();
window.addEventListener('focus', observeContents);
})();