Skip to content

Instantly share code, notes, and snippets.

@roflsunriz
Last active May 24, 2025 20:24
Show Gist options
  • Save roflsunriz/8f1568e1e2bfa593dee83a3e448fe93f to your computer and use it in GitHub Desktop.
Save roflsunriz/8f1568e1e2bfa593dee83a3e448fe93f to your computer and use it in GitHub Desktop.
Image Collector2 : Webページにある画像を収集するユーザースクリプト (改訂整理版)
// ==UserScript==
// @name ImageCollector2
// @namespace imageCollector2
// @version 4.2
// @description 画像を収集して表示するユーザースクリプト
// @author roflsunriz
// @match *://*
// @match *://*/*
// @connect *
// @connect */*
// @connect imgur.com
// @connect flickr.com
// @connect pinterest.com
// @connect deviantart.com
// @connect artstation.com
// @connect 500px.com
// @connect unsplash.com
// @connect pexels.com
// @connect pixiv.net
// @connect tinypic.com
// @connect postimages.org
// @connect imgbox.com
// @connect imagebam.com
// @connect imagevenue.com
// @connect imageshack.us
// @connect photobucket.com
// @connect freeimage.host
// @connect ibb.co
// @connect imgbb.com
// @connect gyazo.com
// @connect twitter.com
// @connect x.com
// @connect instagram.com
// @connect facebook.com
// @connect reddit.com
// @connect tumblr.com
// @connect weibo.com
// @connect vk.com
// @connect example.com
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// @require https://cdn.jsdelivr.net/npm/fflate@0.8.2/umd/index.js
// @updateURL https://gist.githubusercontent.com/roflsunriz/8f1568e1e2bfa593dee83a3e448fe93f/raw/ImageCollector2.user.js
// @downloadURL https://gist.githubusercontent.com/roflsunriz/8f1568e1e2bfa593dee83a3e448fe93f/raw/ImageCollector2.user.js
// ==/UserScript==
(function () {
"use strict";
// グローバル設定
const CONFIG = {
debugMode: false, // デバッグログを表示するかどうか
showZipButton: true, // ZIPボタンを表示するかどうか
singleImageTest: false // 1枚だけZIPに追加するテストモード
};
// ログ用の関数
function logDebug(message) {
if (CONFIG.debugMode) {
console.log("[ImageCollector2] " + message);
}
}
function logError(message, error = null, context = {}) {
const errorMsg = `[ImageCollector2 ERROR] ${message}`;
console.error(errorMsg);
if (error) {
console.error("Error details:", error);
if (error.stack) {
console.error("Stack trace:", error.stack);
}
}
if (Object.keys(context).length > 0) {
console.error("Context:", context);
}
}
function logWarning(message, context = {}) {
const warnMsg = `[ImageCollector2 WARNING] ${message}`;
console.warn(warnMsg);
if (Object.keys(context).length > 0) {
console.warn("Context:", context);
}
}
// 初期化処理
function initialize() {
// fflateが読み込まれているかチェック
if (typeof fflate === "undefined") {
logWarning("fflateライブラリが読み込まれていません。ZIPダウンロード機能が制限されます。");
// フォールバック: fflateを動的に読み込む
const fflateScript = document.createElement("script");
fflateScript.src = "https://cdn.jsdelivr.net/npm/fflate@0.8.2/umd/index.js";
fflateScript.onload = function () {
logDebug("fflateライブラリを動的に読み込みました。");
initializeComponents();
};
fflateScript.onerror = function (error) {
logError("fflateライブラリの動的読み込みに失敗しました。ZIPダウンロード機能は使用できません。", error);
// fflateなしでも他の機能は使えるようにする
initializeComponents();
};
document.head.appendChild(fflateScript);
} else {
logDebug("fflateライブラリは正常に読み込まれています。");
initializeComponents();
}
}
// コンポーネントの初期化
function initializeComponents() {
try {
const badImageHandler = new BadImageHandler();
const uiBuilder = new UIBuilder(badImageHandler);
// モーダルをビルドしてからイベントハンドラを設定
uiBuilder.buildModal();
// DOM構築完了を確実にするため、次のフレームでイベントハンドラを初期化
requestAnimationFrame(() => {
try {
const uiBatchUpdater = new UIBatchUpdater(uiBuilder, badImageHandler);
const progressBar = new ProgressBar();
const toast = new Toast();
const zipDownloader = new ZipDownloader(uiBuilder, badImageHandler, toast, progressBar);
const uiEventHandler = new UIEventHandler(uiBuilder, zipDownloader);
const imageCollectorMain = new ImageCollectorMain(
uiBatchUpdater,
badImageHandler,
progressBar,
toast
);
const menuRegister = new MenuRegister(uiBuilder, imageCollectorMain);
// デバッグ設定コマンドを追加(UIBuilderインスタンスを渡す)
addConfigCommands(uiBuilder);
// デバッグ用にunsafeWindowにクラスを露出
unsafeWindow.ImageCollector2 = {
MenuRegister,
UIBuilder,
UIBatchUpdater,
UIEventHandler,
ImageCollectorMain,
BadImageHandler,
ImageSourceClassifier,
Toast,
ProgressBar,
RequestBatchLimiter,
ImageHostManager,
ZipDownloader,
zipDownloaderInstance: zipDownloader,
uiBuilderInstance: uiBuilder,
config: CONFIG
};
(function ensureMaterialIcons() {
if (document.head.querySelector('link[data-ic-material-icons]')) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/icon?family=Material+Icons';
link.dataset.icMaterialIcons = 'true'; // 二重挿入防止
document.head.appendChild(link);
})();
logDebug("コンポーネントの初期化が完了しました");
} catch (error) {
logError("遅延初期化中にエラーが発生しました", error);
throw error;
}
});
} catch (error) {
logError("コンポーネントの初期化中にエラーが発生しました", error);
throw error; // 初期化エラーは致命的なので再スロー
}
}
// 設定変更コマンドを追加
function addConfigCommands(uiBuilder) {
// デバッグモード切り替え
unsafeWindow.setImageCollectorDebug = function(value) {
CONFIG.debugMode = !!value;
console.log(`[ImageCollector2] デバッグモード: ${CONFIG.debugMode ? 'オン' : 'オフ'}`);
return CONFIG.debugMode;
};
// ZIPボタン表示/非表示切り替え
unsafeWindow.setImageCollectorZipButton = function(value) {
CONFIG.showZipButton = !!value;
console.log(`[ImageCollector2] ZIPボタン: ${CONFIG.showZipButton ? '表示' : '非表示'}`);
// UIBuilderインスタンスを使ってシャドウDOM内のZIPボタンにアクセス
if (uiBuilder) {
uiBuilder.setZipButtonVisibility(CONFIG.showZipButton);
}
return CONFIG.showZipButton;
};
// 単一画像テストモード切り替え
unsafeWindow.setSingleImageTest = function(value) {
CONFIG.singleImageTest = !!value;
console.log(`[ImageCollector2] 単一画像テストモード: ${CONFIG.singleImageTest ? 'オン' : 'オフ'}`);
return CONFIG.singleImageTest;
};
}
// クラス定義の後に初期化処理を実行
class MenuRegister {
constructor(uiBuilder, imageCollectorMain) {
this.uiBuilder = uiBuilder;
this.imageCollectorMain = imageCollectorMain;
this.registerMenu();
}
registerMenu() {
GM_registerMenuCommand("🚀起動", () => {
this.uiBuilder.showModal();
this.imageCollectorMain.collectImages();
});
}
}
class UIBuilder {
constructor(badImageHandler) {
this.modal = null;
this.gridContainer = null;
this.closeButton = null;
this.zipButton = null;
this.badImageHandler = badImageHandler;
this.imageData = new Map(); // 画像のデータを保存するためのMap
this.shadowHost = null;
this.shadowRoot = null;
}
buildModal() {
try {
// シャドウDOMホストを作成
this.shadowHost = document.createElement("div");
this.shadowHost.id = "image-collector-shadow-host";
// シャドウルートを作成(closed modeで外部からのアクセスを防ぐ)
this.shadowRoot = this.shadowHost.attachShadow({ mode: 'closed' });
// シャドウDOM内にスタイルを注入
this.injectShadowStyles();
// モーダルのベースを作成(シャドウDOM内)
this.modal = document.createElement("div");
this.modal.classList.add("image-collector-modal");
// グリッドコンテナを作成
this.gridContainer = document.createElement("div");
this.gridContainer.classList.add("ic","image-grid-container");
this.modal.appendChild(this.gridContainer);
// ZIPダウンロードボタンを作成
this.zipButton = document.createElement("button");
this.zipButton.classList.add("ic","zip-download-button");
this.zipButton.innerHTML = '<span class="ic material-icons">download</span>';
this.zipButton.setAttribute("data-state", "initial");
// 設定に基づいて表示/非表示を設定
this.zipButton.style.display = CONFIG.showZipButton ? 'flex' : 'none';
this.modal.appendChild(this.zipButton);
// 閉じるボタンを作成
this.closeButton = document.createElement("button");
this.closeButton.classList.add("ic","close-button");
this.closeButton.textContent = "×";
this.modal.appendChild(this.closeButton);
// シャドウDOM内に追加
this.shadowRoot.appendChild(this.modal);
// シャドウホストをドキュメントに追加
document.body.appendChild(this.shadowHost);
logDebug("モーダルの構築が完了しました");
} catch (error) {
logError("モーダルの構築中にエラーが発生しました", error);
throw error; // UI構築エラーは致命的
}
}
injectShadowStyles() {
const styleElement = document.createElement("style");
styleElement.textContent = `
.image-collector-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
display: none;
z-index: 9999;
backdrop-filter: blur(10px);
overflow-y: auto;
}
.ic.image-grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
padding: 20px;
max-width: 90%;
margin: 0 auto;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 15px;
backdrop-filter: blur(5px);
margin-top: 50px;
}
.ic.image-item {
position: relative;
overflow: hidden;
border-radius: 10px;
background-color: rgba(255, 255, 255, 0.2);
transition: transform 0.3s ease;
}
.ic.image-item:hover {
transform: scale(1.05);
}
.ic.grid-image {
width: 100%;
height: auto;
display: block;
opacity: 0.9;
transition: opacity 0.3s ease;
}
.ic.grid-image:hover {
opacity: 1;
}
.ic.close-button {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
z-index: 10000;
transition: color 0.3s ease;
}
.ic.close-button:hover {
color: #ff4444;
}
.ic.zip-download-button {
position: absolute;
top: 20px;
left: 20px;
background-color: rgba(0, 123, 255, 0.2);
border: 2px solid rgba(0, 123, 255, 0.4);
color: white;
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10000;
transition: all 0.3s ease;
box-shadow: 0 0 15px rgba(0, 123, 255, 0.3);
}
.ic.zip-download-button:hover {
background-color: rgba(0, 123, 255, 0.4);
transform: scale(1.1);
}
.ic.zip-download-button[data-state="processing"] {
background-color: rgba(255, 193, 7, 0.2);
border-color: rgba(255, 193, 7, 0.4);
animation: pulse 1.5s infinite;
}
.ic.zip-download-button[data-state="ready"] {
background-color: rgba(40, 167, 69, 0.2);
border-color: rgba(40, 167, 69, 0.4);
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.6);
}
70% {
box-shadow: 0 0 0 15px rgba(255, 193, 7, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 193, 7, 0);
}
}
/* マテリアルアイコン */
.ic.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/materialicons/v139/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2) format('woff2');
}
.ic.image-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.1);
padding: 15px;
border-radius: 10px;
text-align: center;
}
.ic.size-info {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 10px;
}
.ic.load-button {
background-color: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 5px;
padding: 8px 16px;
color: white;
cursor: pointer;
transition: background-color 0.3s ease;
}
.ic.load-button:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.ic.full-screen-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
z-index: 10002;
display: flex;
justify-content: center;
align-items: center;
}
.ic.full-screen-image {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
.ic.download-button {
position: absolute;
bottom: 20px;
right: 20px;
background-color: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 5px;
padding: 8px 16px;
color: white;
cursor: pointer;
transition: background-color 0.3s ease;
}
.ic.download-button:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.ic.full-screen-close-button {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
z-index: 10003;
transition: color 0.3s ease;
}
.ic.full-screen-close-button:hover {
color: #ff4444;
}
.ic.original-link-button {
position: absolute;
bottom: 20px;
left: 20px;
background-color: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 5px;
padding: 8px 16px;
color: white;
cursor: pointer;
transition: background-color 0.3s ease;
}
.ic.original-link-button:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.ic.file-name-display {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
color: white;
font-size: 1.2rem;
background-color: rgba(0, 0, 0, 0.7);
padding: 8px 16px;
border-radius: 4px;
z-index: 10003;
}
`;
this.shadowRoot.appendChild(styleElement);
}
createImageItem(imageUrl, metadata) {
try {
const imageItem = document.createElement("div");
imageItem.classList.add("ic","image-item");
if (!metadata) {
logError("画像のメタデータが見つかりません", null, { imageUrl });
return imageItem;
}
// 画像データをMapに保存
this.imageData.set(imageUrl, {
metadata: metadata,
blob: null, // 後でダウンロードした際にBlobを保存する
});
if (metadata.size > this.badImageHandler.maxFileSize) {
const placeholder = this.badImageHandler.createPlaceholder(imageUrl, metadata);
imageItem.appendChild(placeholder);
} else {
const img = document.createElement("img");
img.src = imageUrl;
img.alt = "Collected Image";
img.classList.add("ic","grid-image");
// 画像読み込みエラーのハンドリング
img.onerror = () => {
logWarning("画像の読み込みに失敗しました", { imageUrl });
// エラー時はプレースホルダーに置き換え
const errorPlaceholder = this.badImageHandler.createErrorPlaceholder(imageUrl);
imageItem.replaceChild(errorPlaceholder, img);
};
imageItem.appendChild(img);
}
return imageItem;
} catch (error) {
logError("画像アイテムの作成中にエラーが発生しました", error, { imageUrl });
// エラー時は空のアイテムを返す
const errorItem = document.createElement("div");
errorItem.classList.add("ic","image-item");
return errorItem;
}
}
addImageToGrid(imageUrl, metadata) {
const imageItem = this.createImageItem(imageUrl, metadata);
this.gridContainer.appendChild(imageItem);
}
showModal() {
this.modal.style.display = "block";
}
hideModal() {
this.gridContainer.innerHTML = ""; // グリッドコンテナの中身をクリア
this.modal.style.display = "none";
}
// ZIPボタンの表示/非表示を制御するメソッド
setZipButtonVisibility(visible) {
if (this.zipButton) {
this.zipButton.style.display = visible ? 'flex' : 'none';
}
}
}
class UIBatchUpdater {
constructor(uiBuilder, badImageHandler) {
this.uiBuilder = uiBuilder;
this.badImageHandler = badImageHandler;
this.imageQueue = [];
this.isProcessing = false;
}
// 高速パス用の画像追加メソッド
async addImagesFastPath(imageItems) {
try {
if (!Array.isArray(imageItems)) {
logError("imageItemsが配列ではありません", null, { imageItems: typeof imageItems });
return;
}
let successCount = 0;
let failureCount = 0;
for (const item of imageItems) {
try {
const { url, classification } = item;
let metadata;
// DOM要素からメタデータを取得できる場合
if (classification.element && classification.element.tagName === 'IMG') {
const elementMetadata = classification.element.naturalWidth > 0 ? {
width: classification.element.naturalWidth,
height: classification.element.naturalHeight,
size: 0 // ファイルサイズは不明
} : null;
if (elementMetadata && elementMetadata.width > 0 && elementMetadata.height > 0) {
metadata = elementMetadata;
logDebug(`DOM要素からメタデータ取得: ${url.substring(0, 50)}... (${metadata.width}x${metadata.height})`);
}
}
// DOM要素からメタデータが取得できない場合は最小限のチェック
if (!metadata) {
metadata = await this.badImageHandler.getImageMetadata(url);
if (!metadata) {
logWarning("高速パスでもメタデータ取得に失敗しました", { url });
failureCount++;
continue;
}
}
this.imageQueue.push({ url, metadata });
successCount++;
} catch (error) {
logError("高速パス画像処理中にエラーが発生しました", error, { url: item.url });
failureCount++;
}
}
logDebug(`高速パス処理完了: 成功=${successCount}, 失敗=${failureCount}`);
if (!this.isProcessing) {
this.processBatch();
}
} catch (error) {
logError("高速パス画像追加処理中に予期しないエラーが発生しました", error, { imageItemsLength: imageItems?.length });
}
}
async addImages(imageUrls) {
try {
if (!Array.isArray(imageUrls)) {
logError("imageUrlsが配列ではありません", null, { imageUrls: typeof imageUrls });
return;
}
let successCount = 0;
let failureCount = 0;
for (const url of imageUrls) {
try {
const metadata = await this.badImageHandler.getImageMetadata(url);
if (!metadata) {
logWarning("画像のメタデータ取得に失敗しました", { url });
failureCount++;
continue;
}
this.imageQueue.push({ url, metadata });
successCount++;
} catch (error) {
logError("画像メタデータの取得中にエラーが発生しました", error, { url });
failureCount++;
}
}
logDebug(`画像追加処理完了: 成功=${successCount}, 失敗=${failureCount}`);
if (!this.isProcessing) {
this.processBatch();
}
} catch (error) {
logError("画像追加処理中に予期しないエラーが発生しました", error, { imageUrlsLength: imageUrls?.length });
}
}
processBatch() {
try {
this.isProcessing = true;
const batchSize = 5; // 一度に追加する画像の数を減らす
const processNext = () => {
try {
const imagesToAdd = this.imageQueue.splice(0, batchSize);
imagesToAdd.forEach(({ url, metadata }) => {
try {
this.uiBuilder.addImageToGrid(url, metadata);
} catch (error) {
logError("グリッドへの画像追加中にエラーが発生しました", error, { url });
}
});
if (this.imageQueue.length > 0) {
requestAnimationFrame(processNext);
} else {
this.isProcessing = false;
logDebug("バッチ処理が完了しました");
}
} catch (error) {
logError("バッチ処理中にエラーが発生しました", error);
this.isProcessing = false;
}
};
requestAnimationFrame(processNext);
} catch (error) {
logError("バッチ処理の開始中にエラーが発生しました", error);
this.isProcessing = false;
}
}
}
class UIEventHandler {
constructor(uiBuilder, zipDownloader) {
this.uiBuilder = uiBuilder;
this.zipDownloader = zipDownloader;
this.setupEventHandlers();
}
setupEventHandlers() {
try {
// モーダル欄外クリックで閉じる
this.uiBuilder.modal.addEventListener("click", (e) => {
try {
if (e.target === this.uiBuilder.modal) {
this.uiBuilder.hideModal();
}
} catch (error) {
logError("モーダルクリックイベント処理中にエラーが発生しました", error);
}
});
// 閉じるボタン
this.uiBuilder.closeButton.addEventListener("click", () => {
try {
this.uiBuilder.hideModal();
} catch (error) {
logError("閉じるボタンクリック処理中にエラーが発生しました", error);
}
});
// ZIPダウンロードボタン
this.uiBuilder.zipButton.addEventListener("click", () => {
try {
const state = this.uiBuilder.zipButton.getAttribute("data-state");
if (state === "initial") {
// 準備開始
this.zipDownloader.prepareZip();
} else if (state === "ready") {
// ダウンロード開始
this.zipDownloader.downloadZip();
}
} catch (error) {
logError("ZIPボタンクリック処理中にエラーが発生しました", error);
}
});
// グリッド内の画像クリックで全画面表示
this.uiBuilder.gridContainer.addEventListener("click", (e) => {
try {
const img = e.target.closest(".ic.grid-image");
if (img) {
const imageUrl = img.src;
const originalUrl = img.dataset.originalUrl || imageUrl; // 元リンクを取得
this.showFullScreenImage(imageUrl, originalUrl);
}
} catch (error) {
logError("画像クリック処理中にエラーが発生しました", error);
}
});
logDebug("イベントハンドラーの設定が完了しました");
} catch (error) {
logError("イベントハンドラーの設定中にエラーが発生しました", error);
throw error; // イベントハンドラー設定エラーは致命的
}
}
showFullScreenImage(imageUrl, originalUrl) {
// フルスクリーン用のシャドウDOMホストを作成
const fullScreenShadowHost = document.createElement("div");
fullScreenShadowHost.id = "fullscreen-shadow-host";
// シャドウルートを作成
const fullScreenShadowRoot = fullScreenShadowHost.attachShadow({ mode: 'closed' });
// フルスクリーン用のスタイルを注入
const styleElement = document.createElement("style");
styleElement.textContent = `
.ic.full-screen-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
z-index: 10002;
display: flex;
justify-content: center;
align-items: center;
}
.ic.full-screen-image {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
.ic.download-button {
position: absolute;
bottom: 20px;
right: 20px;
background-color: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 5px;
padding: 8px 16px;
color: white;
cursor: pointer;
transition: background-color 0.3s ease;
}
.ic.download-button:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.ic.full-screen-close-button {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
z-index: 10003;
transition: color 0.3s ease;
}
.ic.full-screen-close-button:hover {
color: #ff4444;
}
.ic.original-link-button {
position: absolute;
bottom: 20px;
left: 20px;
background-color: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 5px;
padding: 8px 16px;
color: white;
cursor: pointer;
transition: background-color 0.3s ease;
}
.ic.original-link-button:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.ic.file-name-display {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
color: white;
font-size: 1.2rem;
background-color: rgba(0, 0, 0, 0.7);
padding: 8px 16px;
border-radius: 4px;
z-index: 10003;
}
`;
fullScreenShadowRoot.appendChild(styleElement);
const fullScreenContainer = document.createElement("div");
fullScreenContainer.classList.add("ic","full-screen-container");
// ファイル名を取得
const fileName = imageUrl.split("/").pop();
// ファイル名表示用の要素を作成
const fileNameDisplay = document.createElement("div");
fileNameDisplay.classList.add("ic","file-name-display");
fileNameDisplay.textContent = fileName;
const img = document.createElement("img");
img.src = imageUrl;
img.classList.add("ic","full-screen-image");
// ダウンロードボタン
const downloadButton = document.createElement("button");
downloadButton.textContent = "ダウンロード";
downloadButton.classList.add("ic","download-button");
downloadButton.addEventListener("click", () => {
this.downloadImage(imageUrl);
});
// 元リンクボタン
const originalLinkButton = document.createElement("button");
originalLinkButton.textContent = "元リンク";
originalLinkButton.classList.add("ic","original-link-button");
originalLinkButton.addEventListener("click", () => {
window.open(originalUrl, "_blank");
});
// 閉じるボタン
const closeButton = document.createElement("button");
closeButton.textContent = "×";
closeButton.classList.add("ic","full-screen-close-button");
closeButton.addEventListener("click", () => {
try {
document.body.removeChild(fullScreenShadowHost);
} catch (error) {
logError("フルスクリーンモーダルの閉じるボタンでエラーが発生しました", error);
}
});
// 欄外クリックでモーダルに戻る
fullScreenContainer.addEventListener("click", (e) => {
if (e.target === fullScreenContainer) {
try {
document.body.removeChild(fullScreenShadowHost);
} catch (error) {
logError("フルスクリーンモーダルの削除中にエラーが発生しました", error);
}
}
});
fullScreenContainer.appendChild(fileNameDisplay);
fullScreenContainer.appendChild(img);
fullScreenContainer.appendChild(downloadButton);
fullScreenContainer.appendChild(originalLinkButton);
fullScreenContainer.appendChild(closeButton);
// シャドウDOM内に追加
fullScreenShadowRoot.appendChild(fullScreenContainer);
// シャドウホストをドキュメントに追加
document.body.appendChild(fullScreenShadowHost);
}
downloadImage(imageUrl) {
try {
// 画像を直接ダウンロードするためのリクエスト
GM_xmlhttpRequest({
method: "GET",
url: imageUrl,
responseType: "blob",
onload: function (response) {
try {
const blob = response.response;
if (!blob) {
logError("ダウンロードしたBlobが空です", null, { imageUrl });
return;
}
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = imageUrl.split("/").pop();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
logDebug("画像のダウンロードが完了しました");
} catch (error) {
logError("ダウンロード処理中にエラーが発生しました", error, { imageUrl });
}
},
onerror: function (error) {
logError("画像のダウンロードに失敗しました", error, { imageUrl });
},
onabort: function () {
logWarning("画像のダウンロードが中断されました", { imageUrl });
},
ontimeout: function () {
logWarning("画像のダウンロードがタイムアウトしました", { imageUrl });
}
});
} catch (error) {
logError("ダウンロード要求の作成中にエラーが発生しました", error, { imageUrl });
}
}
}
class ImageHostManager {
constructor() {
this.hostPatterns = [
// 一般的なイメージホスト
/(?:https?:\/\/)?(?:www\.)?imgur\.com\//i,
/(?:https?:\/\/)?(?:www\.)?flickr\.com\//i,
/(?:https?:\/\/)?(?:www\.)?pinterest\.com\//i,
/(?:https?:\/\/)?(?:www\.)?deviantart\.com\//i,
/(?:https?:\/\/)?(?:www\.)?artstation\.com\//i,
/(?:https?:\/\/)?(?:www\.)?500px\.com\//i,
/(?:https?:\/\/)?(?:www\.)?unsplash\.com\//i,
/(?:https?:\/\/)?(?:www\.)?pexels\.com\//i,
/(?:https?:\/\/)?(?:www\.)?pixiv\.net\//i,
/(?:https?:\/\/)?(?:www\.)?tinypic\.com\//i,
/(?:https?:\/\/)?(?:www\.)?postimages\.org\//i,
/(?:https?:\/\/)?(?:www\.)?imgbox\.com\//i,
/(?:https?:\/\/)?(?:www\.)?imagebam\.com\//i,
/(?:https?:\/\/)?(?:www\.)?imagevenue\.com\//i,
/(?:https?:\/\/)?(?:www\.)?imageshack\.us\//i,
/(?:https?:\/\/)?(?:www\.)?photobucket\.com\//i,
/(?:https?:\/\/)?(?:www\.)?freeimage\.host\//i,
/(?:https?:\/\/)?(?:www\.)?ibb\.co\//i,
/(?:https?:\/\/)?(?:www\.)?imgbb\.com\//i,
/(?:https?:\/\/)?(?:www\.)?gyazo\.com\//i,
// SNS
/(?:https?:\/\/)?(?:www\.)?twitter\.com\//i,
/(?:https?:\/\/)?(?:www\.)?x\.com\//i,
/(?:https?:\/\/)?(?:www\.)?instagram\.com\//i,
/(?:https?:\/\/)?(?:www\.)?facebook\.com\//i,
/(?:https?:\/\/)?(?:www\.)?reddit\.com\//i,
/(?:https?:\/\/)?(?:www\.)?tumblr\.com\//i,
/(?:https?:\/\/)?(?:www\.)?weibo\.com\//i,
/(?:https?:\/\/)?(?:www\.)?vk\.com\//i,
// その他
/(?:https?:\/\/)?(?:www\.)?example\.com\//i,
];
}
isSupportedHost(url) {
return this.hostPatterns.some((pattern) => pattern.test(url));
}
addHostPattern(pattern) {
this.hostPatterns.push(new RegExp(pattern, "i"));
}
}
class ImageSourceClassifier {
constructor() {
this.trustedDomains = new Set([
window.location.hostname,
'cdn.jsdelivr.net',
'fonts.googleapis.com',
'fonts.gstatic.com'
]);
}
classifyImageSource(url, sourceElement = null) {
try {
const urlObj = new URL(url, window.location.href);
// 同一ドメインの画像は信頼できる
if (urlObj.hostname === window.location.hostname) {
return {
trustLevel: 'high',
reason: 'same-domain',
fastPath: true,
element: sourceElement
};
}
// 信頼できるCDNドメイン
if (this.trustedDomains.has(urlObj.hostname)) {
return {
trustLevel: 'high',
reason: 'trusted-cdn',
fastPath: true,
element: sourceElement
};
}
// DOM要素から直接取得した画像(既に表示されている)
if (sourceElement && (sourceElement.tagName === 'IMG' || sourceElement.tagName === 'SOURCE')) {
// 画像が既に読み込まれているかチェック
if (sourceElement.tagName === 'IMG' && sourceElement.complete && sourceElement.naturalWidth > 0) {
return {
trustLevel: 'medium',
reason: 'loaded-dom-element',
fastPath: true,
element: sourceElement
};
}
}
// HTTPSの画像は中程度の信頼度
if (urlObj.protocol === 'https:') {
return {
trustLevel: 'medium',
reason: 'https-external',
fastPath: false,
element: sourceElement
};
}
// その他は低信頼度
return {
trustLevel: 'low',
reason: 'untrusted-source',
fastPath: false,
element: sourceElement
};
} catch (error) {
logError("画像ソース分類中にエラーが発生しました", error, { url });
return {
trustLevel: 'low',
reason: 'classification-error',
fastPath: false,
element: sourceElement
};
}
}
getMetadataFromElement(element) {
try {
if (element.tagName === 'IMG') {
return {
width: element.naturalWidth || element.width || 0,
height: element.naturalHeight || element.height || 0,
size: 0, // DOM要素からはファイルサイズは取得できない
loaded: element.complete
};
}
return null;
} catch (error) {
logError("DOM要素からのメタデータ取得中にエラーが発生しました", error);
return null;
}
}
}
class ImageCollectorMain {
constructor(uiBatchUpdater, badImageHandler, progressBar, toast) {
this.uiBatchUpdater = uiBatchUpdater;
this.badImageHandler = badImageHandler;
this.progressBar = progressBar;
this.toast = toast;
this.requestLimiter = new RequestBatchLimiter();
this.imageHostManager = new ImageHostManager();
this.imageSourceClassifier = new ImageSourceClassifier();
}
async collectImages() {
try {
// トーストとプログレスバーを同時に表示
this.toast.show("画像収集を開始します...", "info");
this.progressBar.show();
this.progressBar.update(0); // 初期値を0%に設定
const imageData = new Map(); // URL -> {element, classification} のマップ
const fastPathImages = [];
const slowPathImages = [];
try {
// img要素から収集(DOM要素も保存)
document.querySelectorAll("img").forEach((img) => {
try {
const src = this.resolveUrl(img.src);
if (src) {
const classification = this.imageSourceClassifier.classifyImageSource(src, img);
imageData.set(src, { element: img, classification });
if (classification.fastPath) {
fastPathImages.push({ url: src, classification });
logDebug(`高速パス: ${src.substring(0, 50)}... (${classification.reason})`);
} else {
slowPathImages.push(src);
logDebug(`低速パス: ${src.substring(0, 50)}... (${classification.reason})`);
}
}
} catch (error) {
logWarning("img要素の処理中にエラーが発生しました", { src: img.src });
}
});
// picture要素から収集
document.querySelectorAll("picture source").forEach((source) => {
try {
const srcset = source.srcset.split(",").map((s) => s.trim().split(" ")[0]);
srcset.forEach((src) => {
try {
const resolvedSrc = this.resolveUrl(src);
if (resolvedSrc && !imageData.has(resolvedSrc)) {
const classification = this.imageSourceClassifier.classifyImageSource(resolvedSrc, source);
imageData.set(resolvedSrc, { element: source, classification });
if (classification.fastPath) {
fastPathImages.push({ url: resolvedSrc, classification });
logDebug(`高速パス(picture): ${resolvedSrc.substring(0, 50)}... (${classification.reason})`);
} else {
slowPathImages.push(resolvedSrc);
logDebug(`低速パス(picture): ${resolvedSrc.substring(0, 50)}... (${classification.reason})`);
}
}
} catch (error) {
logWarning("srcsetの処理中にエラーが発生しました", { src });
}
});
} catch (error) {
logWarning("picture要素の処理中にエラーが発生しました", { srcset: source.srcset });
}
});
// a要素から収集
document.querySelectorAll("a").forEach((a) => {
try {
const href = this.resolveUrl(a.href);
if (this.isImageUrl(href) && !imageData.has(href)) {
const classification = this.imageSourceClassifier.classifyImageSource(href);
imageData.set(href, { element: null, classification });
// a要素の画像は基本的に低速パス
slowPathImages.push(href);
logDebug(`低速パス(link): ${href.substring(0, 50)}... (${classification.reason})`);
}
} catch (error) {
logWarning("a要素の処理中にエラーが発生しました", { href: a.href });
}
});
// 背景画像から収集
document.querySelectorAll("*").forEach((element) => {
try {
const bgImage = window.getComputedStyle(element).backgroundImage;
if (bgImage && bgImage !== "none") {
const url = bgImage.replace(/^url\(["']?/, "").replace(/["']?\)$/, "");
const resolvedUrl = this.resolveUrl(url);
if (resolvedUrl && !imageData.has(resolvedUrl)) {
const classification = this.imageSourceClassifier.classifyImageSource(resolvedUrl);
imageData.set(resolvedUrl, { element: null, classification });
// 背景画像は基本的に低速パス
slowPathImages.push(resolvedUrl);
logDebug(`低速パス(bg): ${resolvedUrl.substring(0, 50)}... (${classification.reason})`);
}
}
} catch (error) {
logWarning("背景画像の処理中にエラーが発生しました", { element: element.tagName });
}
});
} catch (error) {
logError("DOM要素からの画像収集中にエラーが発生しました", error);
}
logDebug(`画像分類完了: 高速パス=${fastPathImages.length}, 低速パス=${slowPathImages.length}`);
this.toast.show(`画像を分類しました: 高速=${fastPathImages.length}, 通常=${slowPathImages.length}`, "info");
// 高速パスの画像を先に処理
if (fastPathImages.length > 0) {
this.toast.show("信頼できる画像を高速処理中...", "info");
await this.uiBatchUpdater.addImagesFastPath(fastPathImages);
this.progressBar.update(30); // 高速パス完了で30%
}
// 低速パスの画像を処理
if (slowPathImages.length > 0) {
this.toast.show("外部画像を検証中...", "info");
// SNSとイメージホストの特別処理
try {
for (const url of slowPathImages) {
try {
if (this.imageHostManager.isSupportedHost(url)) {
const resolvedUrl = await this.getSnsImageUrl(url);
if (resolvedUrl && resolvedUrl !== url) {
slowPathImages.push(resolvedUrl);
}
}
} catch (error) {
logWarning("SNS画像URL解決中にエラーが発生しました", { url });
}
}
} catch (error) {
logError("SNS画像処理中にエラーが発生しました", error);
}
// 不適切な画像を除去
let filteredUrls = [];
try {
const validationResults = await Promise.allSettled(
slowPathImages.map(async (url) => {
try {
const isValid = await this.badImageHandler.isValidImage(url);
return isValid ? url : null;
} catch (error) {
logWarning("画像検証中にエラーが発生しました", { url });
return null;
}
})
);
filteredUrls = validationResults
.filter(result => result.status === 'fulfilled' && result.value !== null)
.map(result => result.value);
const rejectedCount = validationResults.filter(result => result.status === 'rejected').length;
if (rejectedCount > 0) {
logWarning(`${rejectedCount}個の画像検証でエラーが発生しました`);
}
} catch (error) {
logError("画像フィルタリング中にエラーが発生しました", error);
filteredUrls = slowPathImages;
}
this.progressBar.update(60); // 検証完了で60%
// 低速パスの画像を追加
if (filteredUrls.length > 0) {
await this.uiBatchUpdater.addImages(filteredUrls);
}
}
const totalImages = fastPathImages.length + slowPathImages.length;
if (totalImages === 0) {
logWarning("処理対象の画像が0件です");
this.progressBar.hide();
this.toast.show("処理対象の画像が見つかりませんでした", "warning");
return;
}
// 処理完了
this.progressBar.update(100);
setTimeout(() => {
this.progressBar.hide();
this.toast.show(`${totalImages}枚の画像を収集しました!(高速:${fastPathImages.length}, 通常:${slowPathImages.length})`, "success");
logDebug(`画像収集完了: 合計${totalImages}枚 (高速:${fastPathImages.length}, 通常:${slowPathImages.length})`);
}, 500);
} catch (error) {
logError("画像収集処理中に予期しないエラーが発生しました", error);
this.toast.show("画像収集中に予期しないエラーが発生しました", "error");
this.progressBar.hide();
}
}
resolveUrl(url) {
try {
// リダイレクトURLの処理
if (url.includes("?http")) {
const redirectUrl = url.split("?http")[1];
if (redirectUrl) {
return new URL(`http${redirectUrl}`).href;
}
}
return new URL(url, window.location.href).href;
} catch (e) {
return null;
}
}
isImageUrl(url) {
if (!url) return false;
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"];
return imageExtensions.some((ext) => url.toLowerCase().endsWith(ext));
}
async getSnsImageUrl(url) {
// TwitterのURL処理
if (url.includes("twitter.com")) {
try {
const response = await GM_xmlhttpRequest({
method: "GET",
url: url,
onload: (response) => {
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, "text/html");
const metaImage = doc.querySelector('meta[property="og:image"]');
if (metaImage) {
return metaImage.content;
}
return null;
},
});
return response;
} catch (error) {
console.error("Twitter image fetch error:", error);
return null;
}
}
// 他のSNSも同様に処理
return url;
}
}
class BadImageHandler {
constructor() {
this.minSize = 50; // 最小幅・高さ(px)
this.maxSize = 5000; // 最大幅・高さ(px)
this.maxFileSize = 5 * 1024 * 1024; // 最大ファイルサイズ(5MB)
// 削除済み画像のサイズパターン
this.deletedImageSizes = [
{ width: 320, height: 320 }, // postimage.cc
{ width: 161, height: 81 }, // imgur.com
];
}
async isValidImage(url) {
if (!url) return false;
// URLが画像ファイルかどうかを確認
if (!this.isImageUrl(url)) return false;
// 画像のメタデータを取得
try {
const metadata = await this.getImageMetadata(url);
if (!metadata) return false;
// 削除済み画像かどうかをチェック
if (this.isDeletedImage(metadata)) {
return false;
}
// ファイル名を取得
const fileName = url.split("/").pop();
// サイズチェック
if (metadata.width < this.minSize || metadata.height < this.minSize) {
return false;
}
if (metadata.width > this.maxSize || metadata.height > this.maxSize) {
return false;
}
// ファイルサイズチェック
if (metadata.size > this.maxFileSize) {
return "large"; // 巨大ファイルの場合は特別な値を返す
}
return true;
} catch (e) {
console.error("画像のメタデータ取得中にエラーが発生しました:", e); // デバッグログ
return false;
}
}
isDeletedImage(metadata) {
return this.deletedImageSizes.some(
(size) => metadata.width === size.width && metadata.height === size.height
);
}
createPlaceholder(url, metadata) {
try {
const placeholder = document.createElement("div");
placeholder.classList.add("ic","image-placeholder");
const sizeInfo = document.createElement("div");
sizeInfo.classList.add("ic","size-info");
sizeInfo.textContent = `サイズ: ${metadata.width}x${metadata.height} (${this.formatBytes(
metadata.size
)})`;
const loadButton = document.createElement("button");
loadButton.classList.add("ic","load-button");
loadButton.textContent = "画像を読み込む";
loadButton.addEventListener("click", () => {
try {
placeholder.replaceWith(this.createImageElement(url));
} catch (error) {
logError("画像の読み込み中にエラーが発生しました", error, { url });
}
});
placeholder.appendChild(sizeInfo);
placeholder.appendChild(loadButton);
return placeholder;
} catch (error) {
logError("プレースホルダーの作成中にエラーが発生しました", error, { url });
// エラー時は最小限のプレースホルダーを返す
const errorPlaceholder = document.createElement("div");
errorPlaceholder.classList.add("ic","image-placeholder");
errorPlaceholder.textContent = "画像の表示に失敗しました";
return errorPlaceholder;
}
}
createErrorPlaceholder(url) {
try {
const placeholder = document.createElement("div");
placeholder.classList.add("ic","image-placeholder");
placeholder.style.backgroundColor = "rgba(220, 53, 69, 0.2)";
const errorText = document.createElement("div");
errorText.textContent = "画像の読み込みに失敗しました";
errorText.style.color = "#dc3545";
const retryButton = document.createElement("button");
retryButton.classList.add("ic","load-button");
retryButton.textContent = "再試行";
retryButton.addEventListener("click", () => {
try {
placeholder.replaceWith(this.createImageElement(url));
} catch (error) {
logError("画像の再読み込み中にエラーが発生しました", error, { url });
}
});
placeholder.appendChild(errorText);
placeholder.appendChild(retryButton);
return placeholder;
} catch (error) {
logError("エラープレースホルダーの作成中にエラーが発生しました", error, { url });
// 最終的なフォールバック
const fallbackPlaceholder = document.createElement("div");
fallbackPlaceholder.textContent = "エラー";
return fallbackPlaceholder;
}
}
createImageElement(url) {
const img = document.createElement("img");
img.src = url;
img.classList.add("ic","grid-image");
return img;
}
formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
isImageUrl(url) {
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"];
return imageExtensions.some((ext) => url.toLowerCase().endsWith(ext));
}
getImageMetadata(url) {
return new Promise((resolve, reject) => {
try {
// まず画像のサイズを取得
const img = new Image();
img.crossOrigin = "anonymous"; // CORS対応
const imageTimeoutId = setTimeout(() => {
logWarning("画像サイズ取得がタイムアウトしました", { url });
reject(new Error("Image size detection timeout"));
}, 8000); // 8秒のタイムアウト
img.onload = () => {
try {
clearTimeout(imageTimeoutId);
if (img.width === 0 || img.height === 0) {
logWarning("無効な画像サイズです", { url, width: img.width, height: img.height });
return reject(new Error("Invalid image dimensions"));
}
// 画像サイズが取得できたら、ファイルサイズを取得
GM_xmlhttpRequest({
method: "HEAD",
url: url,
timeout: 5000, // 5秒のタイムアウト
onload: function (response) {
try {
const headers = response.responseHeaders;
const contentLength = parseInt(headers.match(/Content-Length:\s*(\d+)/i)?.[1], 10);
const contentType = headers.match(/Content-Type:\s*([^;\n]+)/i)?.[1];
if (!contentType || !contentType.startsWith("image/")) {
logWarning("画像ではないコンテンツタイプです", { url, contentType });
return reject(new Error("Not an image"));
}
resolve({
width: img.width,
height: img.height,
size: contentLength || 0, // contentLengthが取得できない場合は0を返す
});
} catch (error) {
logError("メタデータ処理中にエラーが発生しました", error, { url });
reject(error);
}
},
onerror: function (error) {
// HEADリクエストが失敗してもサイズ情報は返す
logWarning("HEADリクエストに失敗しましたが、画像サイズは取得済みです", { url });
resolve({
width: img.width,
height: img.height,
size: 0, // サイズ不明
});
},
onabort: function () {
logWarning("HEADリクエストが中断されました", { url });
resolve({
width: img.width,
height: img.height,
size: 0,
});
},
ontimeout: function () {
logWarning("HEADリクエストがタイムアウトしました", { url });
resolve({
width: img.width,
height: img.height,
size: 0,
});
}
});
} catch (error) {
clearTimeout(imageTimeoutId);
logError("画像サイズ取得処理中にエラーが発生しました", error, { url });
reject(error);
}
};
img.onerror = (error) => {
clearTimeout(imageTimeoutId);
logWarning("画像の読み込みに失敗しました", { url });
reject(new Error("Image load error"));
};
img.src = url;
} catch (error) {
logError("メタデータ取得処理の開始中にエラーが発生しました", error, { url });
reject(error);
}
});
}
}
class Toast {
constructor() {
this.toastContainer = null;
this.shadowHost = null;
this.shadowRoot = null;
this.init();
}
init() {
// トースト用のシャドウDOMホストを作成
this.shadowHost = document.createElement("div");
this.shadowHost.id = "toast-shadow-host";
// シャドウルートを作成
this.shadowRoot = this.shadowHost.attachShadow({ mode: 'closed' });
// トースト用のスタイルを注入
const styleElement = document.createElement("style");
styleElement.textContent = `
.ic.toast-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 10001;
display: flex;
flex-direction: column;
gap: 10px;
}
.ic.toast {
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px 20px;
border-radius: 4px;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.ic.toast.show {
opacity: 1;
transform: translateY(0);
}
.ic.toast-info {
background-color: rgba(0, 123, 255, 0.9);
}
.ic.toast-success {
background-color: rgba(40, 167, 69, 0.9);
}
.ic.toast-warning {
background-color: rgba(255, 193, 7, 0.9);
}
.ic.toast-error {
background-color: rgba(220, 53, 69, 0.9);
}
`;
this.shadowRoot.appendChild(styleElement);
this.toastContainer = document.createElement("div");
this.toastContainer.classList.add("ic","toast-container");
this.shadowRoot.appendChild(this.toastContainer);
// シャドウホストをドキュメントに追加
document.body.appendChild(this.shadowHost);
}
show(message, type = "info", duration = 3000) {
const toast = document.createElement("div");
toast.classList.add("ic","toast", `ic.toast-${type}`);
toast.textContent = message;
this.toastContainer.appendChild(toast);
// アニメーションのために少し待ってから表示
setTimeout(() => {
toast.classList.add("show");
}, 10);
// 指定時間後に削除
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300); // アニメーション終了を待つ
}, duration);
}
}
class ProgressBar {
constructor() {
this.progressBar = null;
this.progressText = null;
this.progressContainer = null;
this.shadowHost = null;
this.shadowRoot = null;
this.init();
}
init() {
// プログレスバー用のシャドウDOMホストを作成
this.shadowHost = document.createElement("div");
this.shadowHost.id = "progress-shadow-host";
// シャドウルートを作成
this.shadowRoot = this.shadowHost.attachShadow({ mode: 'closed' });
// プログレスバー用のスタイルを注入
const styleElement = document.createElement("style");
styleElement.textContent = `
.ic.progress-container {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
width: 300px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 5px;
z-index: 10001;
display: none;
}
.ic.progress-bar {
height: 10px;
background-color: #4caf50;
border-radius: 5px;
width: 0%;
transition: width 0.3s ease;
}
.ic.progress-text {
text-align: center;
color: white;
margin-top: 5px;
font-size: 0.9rem;
}
`;
this.shadowRoot.appendChild(styleElement);
this.progressContainer = document.createElement("div");
this.progressContainer.classList.add("ic","progress-container");
this.progressBar = document.createElement("div");
this.progressBar.classList.add("ic","progress-bar");
this.progressText = document.createElement("div");
this.progressText.classList.add("ic","progress-text");
this.progressText.textContent = "0%";
this.progressContainer.appendChild(this.progressBar);
this.progressContainer.appendChild(this.progressText);
this.shadowRoot.appendChild(this.progressContainer);
// シャドウホストをドキュメントに追加
document.body.appendChild(this.shadowHost);
}
update(progress) {
const percentage = Math.min(100, Math.max(0, progress));
this.progressBar.style.width = `${percentage}%`;
this.progressText.textContent = `${Math.round(percentage)}%`;
}
show() {
this.progressContainer.style.display = "block";
}
hide() {
this.progressContainer.style.display = "none";
}
}
class RequestBatchLimiter {
constructor(batchSize = 5, delay = 1000) {
this.batchSize = batchSize;
this.delay = delay;
this.queue = [];
this.isProcessing = false;
}
addRequest(requestFn) {
this.queue.push(requestFn);
if (!this.isProcessing) {
this.processBatch();
}
}
async processBatch() {
try {
this.isProcessing = true;
let processedBatches = 0;
let totalErrors = 0;
while (this.queue.length > 0) {
const batch = this.queue.splice(0, this.batchSize);
processedBatches++;
try {
const results = await Promise.allSettled(batch.map((fn) => fn()));
// エラーをカウント
const batchErrors = results.filter(result => result.status === 'rejected').length;
totalErrors += batchErrors;
if (batchErrors > 0) {
logWarning(`バッチ${processedBatches}で${batchErrors}個のエラーが発生しました`);
// エラーの詳細をログ出力
results.forEach((result, index) => {
if (result.status === 'rejected') {
logError(`バッチ処理でエラーが発生しました`, result.reason, {
batchNumber: processedBatches,
taskIndex: index
});
}
});
}
} catch (error) {
logError(`バッチ${processedBatches}の処理中に予期しないエラーが発生しました`, error);
totalErrors++;
}
if (this.queue.length > 0) {
await new Promise((resolve) => setTimeout(resolve, this.delay));
}
}
if (totalErrors > 0) {
logWarning(`バッチ処理完了: ${processedBatches}バッチ処理、合計${totalErrors}個のエラー`);
} else {
logDebug(`バッチ処理完了: ${processedBatches}バッチ処理、エラーなし`);
}
} catch (error) {
logError("バッチ処理中に致命的なエラーが発生しました", error);
throw error; // 致命的なエラーは再スロー
} finally {
this.isProcessing = false;
}
}
}
class ZipDownloader {
constructor(uiBuilder, badImageHandler, toast, progressBar) {
this.uiBuilder = uiBuilder;
this.badImageHandler = badImageHandler;
this.toast = toast;
this.progressBar = progressBar;
this.filesData = new Map(); // ファイルデータを保存
this.downloadedUrls = new Set();
this.isProcessing = false;
// fflateが利用可能かチェック
this.fflateAvailable = typeof fflate !== "undefined";
if (!this.fflateAvailable) {
logError("fflateライブラリが見つかりません!ZIPダウンロード機能は使用できません。");
} else {
logDebug("ZipDownloader初期化完了、fflateライブラリが利用可能です");
}
// 設定オプション
this.options = {
maxZipSize: 500 * 1024 * 1024, // 500MB
maxImagesPerZip: 300, // 1つのZIPファイルあたりの最大画像数
compressionLevel: 6, // 圧縮レベル (0-9)
splitZipFiles: true, // 大きすぎる場合はZIPを分割するかどうか
};
}
// ログ出力(グローバル関数を使用)
logDebug(message) {
if (CONFIG.debugMode) {
console.log("[ZipDownloader] " + message);
}
}
logError(message, error = null, context = {}) {
logError(`[ZipDownloader] ${message}`, error, context);
}
logWarning(message, context = {}) {
logWarning(`[ZipDownloader] ${message}`, context);
}
async prepareZip() {
this.logDebug("prepareZip開始");
if (CONFIG.debugMode) {
console.log("[ZipDownloader] 現在の状態:", {
isProcessing: this.isProcessing,
filesDataSize: this.filesData.size,
downloadedUrlsCount: this.downloadedUrls.size
});
}
// 処理中なら重複実行しない
if (this.isProcessing) {
this.logDebug("既に処理中のため、prepareZipをスキップ");
this.toast.show("現在処理中です。しばらくお待ちください...", "info");
return;
}
// ZIP準備開始
try {
this.isProcessing = true;
this.logDebug("処理開始フラグをセット");
this.filesData.clear(); // データをクリア
this.uiBuilder.zipButton.setAttribute("data-state", "processing");
this.toast.show("ZIPファイルの準備を開始します...", "info");
this.progressBar.show();
this.progressBar.update(0);
// 画像のURLを収集
let imageUrls = Array.from(this.uiBuilder.imageData.keys());
// テストモードの場合は1枚だけにする
if (CONFIG.singleImageTest && imageUrls.length > 0) {
this.logDebug(`単一画像テストモード: 最初の1枚だけ処理します`);
imageUrls = [imageUrls[0]]; // 最初の画像だけを残す
this.toast.show("テストモード: 1枚だけZIPに追加します", "warning");
}
const total = imageUrls.length;
this.logDebug(`画像URL ${total}件を収集`);
if (total === 0) {
this.logDebug("画像URLが0件のため終了");
this.toast.show("ダウンロードできる画像がありません", "error");
this.progressBar.hide();
this.uiBuilder.zipButton.setAttribute("data-state", "initial");
this.isProcessing = false;
return;
}
let processed = 0;
let failed = 0;
let skipped = 0;
// 画像処理を完全に非同期処理
const processImages = async () => {
const imagesToProcess = [];
// まず全画像のメタデータ作成
for (const [index, url] of imageUrls.entries()) {
try {
this.logDebug(`画像情報収集 ${index + 1}/${total}: ${url.substring(0, 50)}...`);
// 画像データの情報を取得
const imageData = this.uiBuilder.imageData.get(url);
if (!imageData) {
this.logError("画像データが見つかりません", null, { url });
failed++;
continue;
}
this.logDebug(`メタデータ: width=${imageData.metadata?.width}, height=${imageData.metadata?.height}, size=${imageData.metadata?.size}, blobExists=${imageData.blob !== null}`);
// Blobがなければダウンロード
if (imageData.blob === null) {
this.logDebug(`Blobが存在しないためダウンロード開始: ${url}`);
try {
const blob = await this.fetchImageAsBlob(url);
if (blob) {
this.logDebug(`Blobダウンロード成功: ${blob.size} bytes`);
imageData.blob = blob;
this.uiBuilder.imageData.set(url, imageData);
} else {
this.logError("Blobダウンロード結果がnullです", null, { url });
failed++;
continue;
}
} catch (error) {
this.logError("画像のダウンロードに失敗しました", error, { url });
failed++;
continue;
}
} else {
this.logDebug(`既存のBlobを使用: ${imageData.blob.size} bytes`);
skipped++;
}
// メタデータとURLを保存
const fileName = this.getFilenameFromUrl(url);
imagesToProcess.push({ url, imageData, fileName });
} catch (error) {
this.logError("画像メタデータ処理中にエラーが発生しました", error, { url, index });
failed++;
}
}
this.logDebug(`並列処理開始: ${imagesToProcess.length}個の画像を処理します`);
this.toast.show(`${imagesToProcess.length}個の画像を並列処理中...`, "info");
// 画像をUint8Arrayに変換して保存
const processedEntries = await Promise.all(
imagesToProcess.map(async (item, index) => {
try {
const { url, imageData, fileName } = item;
if (!imageData.blob) {
this.logError("Blobが存在しません", null, { url, fileName });
return { url, fileName, success: false };
}
// ArrayBuffer→Uint8Arrayへの変換
this.logDebug(`画像変換(${index + 1}/${imagesToProcess.length}): ${fileName}`);
const ab = await imageData.blob.arrayBuffer();
const uint8 = new Uint8Array(ab);
if (uint8.byteLength === 0) {
this.logWarning("変換後のデータが空です", { url, fileName });
return { url, fileName, success: false };
}
return {
url,
fileName,
bytes: uint8,
success: true,
byteLength: uint8.byteLength
};
} catch (error) {
this.logError("画像処理中にエラーが発生しました", error, { url: item.url, fileName: item.fileName });
return {
url: item.url,
fileName: item.fileName,
success: false
};
}
})
);
// データを保存
for (const entry of processedEntries) {
if (entry.success) {
this.logDebug(`ZIPに追加するデータを保存: ${entry.fileName} (${entry.byteLength} bytes)`);
this.filesData.set(entry.fileName, entry.bytes);
this.downloadedUrls.add(entry.url);
processed++;
} else {
failed++;
}
// 進捗を更新
const progress = (processed / total) * 100;
this.progressBar.update(progress);
}
// 最終的な状態を表示
this.toast.show(`${processed}/${total} 画像が準備されました`, "info");
this.logDebug(`並列処理完了: 成功=${processed}, 失敗=${failed}`);
};
// 全画像を非同期処理
await processImages();
// テストモード表示
if (CONFIG.singleImageTest) {
this.logDebug("🧪 単一画像テストモードで実行されました");
this.toast.show("テストモード: 単一画像のみでZIPを生成します", "info");
}
// 準備完了
this.logDebug(`処理完了統計: total=${total}, processed=${processed}, failed=${failed}, skipped=${skipped}, filesCount=${this.filesData.size}`);
this.progressBar.hide();
if (failed > 0) {
this.logDebug(`${failed}枚の画像をZIPに含められませんでした`);
this.toast.show(`${failed}枚の画像をZIPに含められませんでした`, "warning");
}
if (processed > 0) {
this.logDebug("ZIPファイルの準備が完了");
this.toast.show(
"ZIPファイルの準備が完了しました!ボタンをクリックしてダウンロードしてください",
"success"
);
this.uiBuilder.zipButton.setAttribute("data-state", "ready");
this.uiBuilder.zipButton.innerHTML = '<span class="ic material-icons">save_alt</span>';
} else {
this.logError("処理された画像が0件です", null, { total, processed, failed });
this.toast.show("ZIPファイルの作成に失敗しました", "error");
this.uiBuilder.zipButton.setAttribute("data-state", "initial");
this.filesData.clear(); // 失敗時は明示的にクリア
}
} catch (error) {
this.logError("ZIP準備中にエラーが発生しました", error, {
total: imageUrls?.length,
filesDataSize: this.filesData.size
});
this.toast.show("ZIPファイルの準備に失敗しました", "error");
this.uiBuilder.zipButton.setAttribute("data-state", "initial");
this.filesData.clear(); // エラー時は明示的にクリア
} finally {
this.logDebug("prepareZip処理終了、isProcessingをfalseに設定");
this.isProcessing = false;
}
}
async downloadZip() {
this.logDebug("downloadZip開始");
this.logDebug(`現在の状態: isProcessing=${this.isProcessing}, filesDataSize=${this.filesData.size}, downloadedUrlsCount=${this.downloadedUrls.size}, fflateAvailable=${this.fflateAvailable}`);
// fflateのチェック
if (!this.fflateAvailable) {
this.toast.show("ZIPライブラリが読み込まれていないため、ダウンロードできません", "error");
this.logError("fflate利用不可のためダウンロード中止");
return;
}
// データ存在チェック
if (this.filesData.size === 0) {
this.logWarning("ファイルデータが空のため準備からやり直し");
this.toast.show("ZIPファイルが準備されていません。再度準備します...", "warning");
try {
await this.prepareZip();
} catch (error) {
this.logError("ZIP準備の再実行中にエラーが発生しました", error);
this.toast.show("ZIP準備の再実行に失敗しました", "error");
}
return;
}
// ファイル数をチェック
const fileEntries = Array.from(this.filesData.keys());
if (fileEntries.length === 0) {
this.logError("ZIPファイルが空です");
this.toast.show("ZIPファイルに画像が含まれていません", "error");
this.uiBuilder.zipButton.setAttribute("data-state", "initial");
return;
}
this.logDebug(`ZIP内のファイル数: ${fileEntries.length}`);
try {
this.isProcessing = true;
this.logDebug("ZIP生成処理開始");
this.toast.show("ZIPファイルを生成しています...", "info");
this.progressBar.show();
// ZIPファイルが大きすぎる場合は分割する
const totalEntries = fileEntries.length;
// 分割が必要かどうかを判断
const needsSplitting = this.options.splitZipFiles && totalEntries > this.options.maxImagesPerZip;
if (needsSplitting) {
await this.generateSplitZips(totalEntries);
} else {
// 通常の単一ZIPファイル生成
await this.generateSingleZip();
}
// 状態をリセット
this.logDebug("ダウンロード処理完了、UIをリセット");
this.progressBar.hide();
this.uiBuilder.zipButton.setAttribute("data-state", "initial");
this.uiBuilder.zipButton.innerHTML = '<span class="ic material-icons">download</span>';
// ファイルデータは保持 - 再度ダウンロードできるように
} catch (error) {
console.error("[ZipDownloader] ZIPダウンロード中にエラー:", error);
this.toast.show("ZIPファイルの生成に失敗しました", "error");
this.progressBar.hide();
this.uiBuilder.zipButton.setAttribute("data-state", "initial");
// エラー詳細をログに出力
this.logError("ZIPダウンロード中に詳細エラー情報", error, {
errorName: error.name,
errorMessage: error.message,
fflateAvailable: typeof fflate !== "undefined",
filesDataSize: this.filesData.size
});
} finally {
this.logDebug("downloadZip処理終了、isProcessingをfalseに設定");
this.isProcessing = false;
}
}
// 単一のZIPファイルを生成する
async generateSingleZip() {
try {
this.logDebug("単一ZIPファイル生成開始");
console.time("[ZipDownloader] ZIP生成時間");
// ファイルオブジェクト作成
const files = {};
this.filesData.forEach((data, filename) => {
files[filename] = data;
});
this.logDebug(`ZIP作成: ${Object.keys(files).length}個のファイルを圧縮`);
// fflateでZIPを生成
const zipOptions = {
level: this.options.compressionLevel,
mem: 8
};
// 進捗表示は未対応(fflateはコールバックベースの進捗情報なし)
const zipData = fflate.zipSync(files, zipOptions);
console.timeEnd("[ZipDownloader] ZIP生成時間");
if (!zipData) {
throw new Error("ZIP生成結果がnullです");
}
// Blobを作成
const content = new Blob([zipData], {type: "application/zip"});
this.logDebug(`ZIP生成完了: Uint8Array(${zipData.length}) → Blob(${content.size} bytes)`);
// ダウンロードリンクを作成して実行
const filename = `images_${this.getFormattedDate()}.zip`;
this.logDebug(`ダウンロードリンク作成: ${filename}`);
await this.triggerDownload(content, filename);
this.toast.show("ZIPファイルのダウンロードが開始されました", "success");
} catch (error) {
this.logError("単一ZIP生成中にエラーが発生しました", error, {
filesCount: Object.keys(files || {}).length,
compressionLevel: this.options.compressionLevel
});
this.toast.show("ZIPファイルの生成に失敗しました: " + error.message, "error");
throw error;
}
}
// ZIPファイルを分割して生成
async generateSplitZips(totalEntries) {
try {
this.logDebug(`分割ZIPファイル生成開始 (全${totalEntries}ファイル)`);
const fileEntries = Array.from(this.filesData.keys());
const totalParts = Math.ceil(totalEntries / this.options.maxImagesPerZip);
this.toast.show(`画像が多いため、${totalParts}個のZIPファイルに分割します`, "info");
this.logDebug(`${totalParts}個のZIPファイルに分割します`);
for (let part = 0; part < totalParts; part++) {
// この部分に含めるファイルの範囲
const startIdx = part * this.options.maxImagesPerZip;
const endIdx = Math.min((part + 1) * this.options.maxImagesPerZip, totalEntries);
this.logDebug(`パート${part + 1}: ファイル ${startIdx + 1}~${endIdx} を処理`);
this.toast.show(`パート${part + 1}/${totalParts}を準備中...`, "info");
// ファイルオブジェクト作成
const partFiles = {};
for (let i = startIdx; i < endIdx; i++) {
const filename = fileEntries[i];
partFiles[filename] = this.filesData.get(filename);
}
// パート番号付きのファイル名
const partFilename = `images_${this.getFormattedDate()}_part${part + 1}of${totalParts}.zip`;
// 進捗表示の調整(おおよその進捗)
const baseProgress = (part / totalParts) * 100;
this.progressBar.update(baseProgress);
// ZIPを生成
console.time(`[ZipDownloader] パート${part + 1}生成時間`);
const zipData = fflate.zipSync(partFiles, {
level: this.options.compressionLevel,
mem: 8
});
console.timeEnd(`[ZipDownloader] パート${part + 1}生成時間`);
// ダウンロード
this.logDebug(
`パート${part + 1} ダウンロード開始: ${partFilename} (${zipData.length} bytes)`
);
await this.triggerDownload(new Blob([zipData], {type: "application/zip"}), partFilename);
// 少し待ってからダウンロード
if (part < totalParts - 1) {
this.toast.show(
`パート${part + 1}/${totalParts}のダウンロードが開始されました。次のパートを準備中...`,
"success"
);
await new Promise((resolve) => setTimeout(resolve, 1500)); // 1.5秒待機
}
}
this.toast.show(`全${totalParts}個のZIPファイルのダウンロードを開始しました`, "success");
} catch (error) {
this.logError("分割ZIP生成中にエラーが発生しました", error, {
totalEntries,
totalParts,
maxImagesPerZip: this.options.maxImagesPerZip
});
this.toast.show("ZIPファイルの分割生成に失敗しました: " + error.message, "error");
throw error;
}
}
// ファイルのダウンロードをトリガーする
async triggerDownload(blob, filename) {
const self = this; // thisの参照を保持
return new Promise((resolve, reject) => {
try {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
self.logDebug(`ダウンロード開始: ${filename}`);
link.click();
// 少し待ってからクリーンアップ
setTimeout(() => {
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
resolve();
}, 100);
} catch (error) {
this.logError("ダウンロードリンク作成エラー", error, { filename });
reject(error);
}
});
}
async fetchImageAsBlob(url) {
this.logDebug(`fetchImageAsBlob開始: ${url.substring(0, 50)}...`);
const self = this; // thisの参照を保持
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
responseType: "blob",
timeout: 30000, // 30秒のタイムアウト
onload: function (response) {
try {
if (response && response.response) {
self.logDebug(
`画像ダウンロード成功: ${url.substring(0, 50)}... (${response.response.size} bytes)`
);
resolve(response.response);
} else {
self.logError("レスポンスまたはレスポンスデータが空です", null, { url: url.substring(0, 50) + "..." });
reject(new Error("Empty response"));
}
} catch (e) {
// ログの失敗で処理が止まらぬよう握りつぶす
self.logError("ログ出力で例外が発生しました", e);
reject(e);
}
},
onerror: function (error) {
self.logError("画像ダウンロード失敗", error, { url: url.substring(0, 50) + "..." });
reject(error);
},
onabort: function () {
self.logWarning("画像ダウンロードが中断されました", { url: url.substring(0, 50) + "..." });
reject(new Error("download aborted"));
},
ontimeout: function () {
self.logWarning("画像ダウンロードがタイムアウトしました", { url: url.substring(0, 50) + "..." });
reject(new Error("download timeout"));
},
});
});
}
getFilenameFromUrl(url) {
// URLから最後のパス部分を取得
let fileName = url.split("/").pop().split("?")[0];
// 拡張子がない場合は.jpgを追加
if (!fileName.includes(".")) {
fileName += ".jpg";
}
// 同名ファイルがある場合には連番を付ける
const fileNameBase = fileName.substring(0, fileName.lastIndexOf("."));
const extension = fileName.substring(fileName.lastIndexOf("."));
let counter = 1;
let newFileName = fileName;
while (this.filesData.has(newFileName)) {
newFileName = `${fileNameBase}_${counter}${extension}`;
counter++;
}
return newFileName;
}
getFormattedDate() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
return `${year}${month}${day}_${hours}${minutes}`;
}
}
// スクリプト起動時に初期化
initialize();
})();
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

ImageCollector2 実装仕様書

概要

ImageCollector2は、Webページ上の画像を効率的に収集し、ユーザーに視覚的に表示し、ダウンロードリンクを提供するUserscriptです。TampermonkeyやFirefoxの右クリックメニューから起動し、収集した画像をモーダルウィンドウ内でグリッドレイアウトで表示します。

  • 使用技術: Userscript (JavaScript), HTML, CSS
  • リクエスト: CORS制限を回避するため、全てGM_xmlhttpRequestを使用。fetchは使用しない。
  • 構造: クラスベースで構造化された実装を採用。
  • デバッグ: クラスはunsafeWindowに露出され、コンソールから操作可能。

処理の流れ

  1. 起動: Tampermonkeyメニューまたは右クリックメニュー(Firefoxのみ)から起動。
  2. 進捗表示: 進捗表示バーで収集状況を表示。
  3. 画像収集: 裏で画像収集を実行。
  4. モーダル表示: 収集した画像をグリッドレイアウトでモーダル内に表示。
  5. ユーザー操作: 全画面表示、ダウンロード、元リンク表示などの操作が可能。
  6. 終了: 閉じるボタンまたはモーダルの欄外クリックでモーダルを閉じる。

UIデザイン

モーダルスタイル

  • 背景: 黒めの灰色で覆い、モーダルが際立つデザイン。欄外クリックで閉じることが直感的に分かる。
  • モーダル本体: 半透明のガラススタイル(Frosted Glass)。
  • 閉じるボタン: モーダル内に配置。
  • グリッドレイアウト: 画像が美しく理路整然と配置され、少し透けたスタイル。
  • 全画面モード:
    • 画像をクリックで全画面表示。
    • 上部にファイル名を表示。
    • ダウンロードボタンと元リンクへのリンクを提供。
  • 巨大ファイル対応: ファイルサイズが大きすぎる画像はプレースホルダーを表示し、サイズ確認後に読み込み可能。

主なクラス

  1. MenuRegister

    • Tampermonkeyのメニューを管理。
    • 起動コマンドを登録。
  2. UIBuilder

    • モーダルやグリッドレイアウトのUIを構築。
  3. UIStyles

    • UI用のCSSスタイルを管理。
    • 半透明ガラススタイルやグリッドデザインを定義。
  4. UIBatchUpdater

    • 構築済みのUIに画像データをバッチ処理で追加。
  5. UIEventHandler

    • モーダルのクリック、全画面表示、ダウンロードなどのイベントを処理。
  6. ImageCollectorMain

    • 画像収集の心臓部。詳細は後述。
  7. BadImageHandler

    • 小さすぎる、大きすぎる、表示不可、リクエスト失敗などの画像を除去。
  8. Toast

    • トースト通知を表示(成功・失敗通知など)。
  9. ProgressBar

    • 収集進捗をプログレスバーで表示。
  10. RequestBatchLimiter

    • リクエスト制限とバッチ処理を管理。
    • サーバー負荷を考慮した非同期処理。

ImageCollectorMain の詳細

画像収集方法

以下の方法で画像を取得し、RequestBatchLimiterと連携して低負荷・高速処理を実現。

  1. img要素: src属性から取得。
  2. picture要素: source要素から取得。
  3. a要素: hrefから画像URLを取得。
  4. 背景画像: background-imageスタイルから取得。
  5. リダイレクト対応:
    • URL内に別のURLが含まれる場合(例: https://www.example.com/redirect?https://www.example2.com/example.jpg)、リダイレクトを検知・解決。
  6. SNS対応:
    • Twitter(X)やその他SNSの画像URLに対応。
  7. イメージホスト対応:
    • 世界の有名イメージホストサービスのURLをサポート。
    • URLリストは一元管理され、後から簡単に追加可能。

処理方針

  • 非同期処理: GM_xmlhttpRequestを使用し、非同期でリクエスト。
  • 負荷考慮: イメージホストおよびファーストパーティサーバーへの負荷を最小限に。
  • 柔軟性: 新しい収集方法やURLパターンを簡単に追加可能。

技術的制約と対策

  • CORS制限: fetchを使わず、GM_xmlhttpRequestで回避。
  • サーバー負荷: RequestBatchLimiterでリクエストを制限・バッチ化。
  • 巨大ファイル: BadImageHandlerでプレースホルダー表示を管理。

デバッグ

  • 全てのクラスはunsafeWindowに露出。
  • コンソールからクラスインスタンスを操作可能。

今後の拡張性

  • 新しい画像ホストやSNSのURLパターンを追加する際、設定ファイルを更新するだけで対応可能。
  • UIのカスタマイズ性を高めるため、UIStylesにテーマ機能を追加予定。
  • ファイル名はImageCollector2.user.jsとする。
@roflsunriz
Copy link
Author

roflsunriz commented Apr 16, 2025

「不適切な画像と判断されました」と表示されたときはブロックされているのでF12で開発者コンソールを開き、取得しようとした画像のサーバーアドレスを特定し、そのドメインをユーザースクリプトのメタデータの@connectに追加するようにしてください

@roflsunriz
Copy link
Author

Rawボタンを押してTampermonkeyにインストール。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment