// ==UserScript==
// @name futaba-image-preview
// @namespace http://2chan.net/
// @version 0.7.4
// @description ふたばちゃんねるのスレッド上で「あぷ」「あぷ小」の画像をプレビュー表示する
// @author ame-chan
// @match http://*.2chan.net/b/res/*
// @match https://*.2chan.net/b/res/*
// @match http://kako.futakuro.com/futa/*
// @match https://kako.futakuro.com/futa/*
// @match https://tsumanne.net/si/data/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=2chan.net
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @license MIT
// @run-at document-idle
// @connect 2chan.net
// @connect img.2chan.net
// @connect dec.2chan.net
// @require https://cdn.jsdelivr.net/npm/exifreader@4.20.0/dist/exif-reader.min.js
// @downloadURL https://update.greasyfork.icu/scripts/471915/futaba-image-preview.user.js
// @updateURL https://update.greasyfork.icu/scripts/471915/futaba-image-preview.meta.js
// ==/UserScript==
(async () => {
'use strict';
const resNumberStorage = {};
let initExecCreateLink = false;
let initTimer;
const addedStyle = ``;
const settingsStyle = ``;
if (!document.querySelector('#userjs-preview-style')) {
document.head.insertAdjacentHTML('beforeend', addedStyle);
}
if (!document.querySelector('#fat-style')) {
document.head.insertAdjacentHTML('beforeend', settingsStyle);
}
const getCloseFileName = async () => JSON.parse((await GM_getValue('closeFileName')) || '[]');
const getMinSize = async () => {
const defaultValue = '480';
const storageValue = await GM_getValue('minSize');
return storageValue || defaultValue;
};
const hasFutakuroElm = () => document.querySelector('#fvw_menu') !== null;
// あぷ・あぷ小ファイルの文字列を見つけたらリンクに変換する(既にリンクになってたらスキップする)
const createAnchorLink = (elms) => {
const processNode = (node) => {
const regex = /((?]*>)(fu?)([0-9]{5,8})\.(jpe?g|png|webp|gif|bmp)(?![^<]*<\/a>))/g;
if (node.nodeType === 3) {
let textNode = node;
// テキストノードの親要素がaタグである場合、処理をスキップ
if (textNode.parentNode?.nodeName === 'A') {
return;
}
let match;
while ((match = regex.exec(textNode.data)) !== null) {
const [fullMatch, _, type, digits, ext] = match;
const url =
type === 'fu'
? `//dec.2chan.net/up2/src/${type}${digits}.${ext}`
: `//dec.2chan.net/up/src/${type}${digits}.${ext}`;
const anchor = document.createElement('a');
anchor.href = url;
anchor.classList.add('is-createLink');
anchor.dataset.from = 'userjs-preview';
anchor.textContent = fullMatch;
const nextTextNode = textNode.splitText(match.index);
nextTextNode.data = nextTextNode.data.substring(fullMatch.length);
textNode.parentNode.insertBefore(anchor, nextTextNode);
textNode = nextTextNode;
}
} else if (node.nodeType !== 1 || node.tagName !== 'BR') {
const childNodes = Array.from(node.childNodes);
childNodes.forEach((childNode) => processNode(childNode));
}
};
for (const el of elms) {
processNode(el);
}
};
const setFailedText = (linkElm) => {
if (linkElm && linkElm instanceof HTMLAnchorElement) {
linkElm.insertAdjacentHTML('afterend', 'データ取得失敗');
}
};
const getArrayBuffer = (path) => {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: path,
responseType: 'blob',
onload: ({ response }) => {
return resolve(response);
},
onerror: (error) => {
console.log(error);
},
});
});
};
class FileReaderEx extends FileReader {
constructor() {
super();
}
#readAs(blob, ctx) {
return new Promise((res, rej) => {
super.addEventListener('load', ({ target }) => target?.result && res(target.result));
super.addEventListener('error', ({ target }) => target?.error && rej(target.error));
super[ctx](blob);
});
}
readAsArrayBuffer(blob) {
return this.#readAs(blob, 'readAsArrayBuffer');
}
readAsDataURL(blob) {
return this.#readAs(blob, 'readAsDataURL');
}
}
const getPromptData = async (div, img) => {
const getGridPosition = ({ x, y }) => {
const xPos = Math.round((x - 0.1) / 0.2);
const yPos = Math.round((y - 0.1) / 0.2);
const column = String.fromCharCode(65 + xPos);
const row = yPos + 1;
return `${column}${row}`;
};
const escapeHtml = (str) => {
if (!str) return '';
return str
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
const createV4Prompt = (v4_prompt) => {
const charCaptions = v4_prompt?.caption?.char_captions;
let text = escapeHtml(v4_prompt?.caption?.base_caption) || '';
if (charCaptions) {
text += '
';
text += charCaptions.reduce((temp, data, idx) => {
temp += `▼character prompt ${idx + 1}
`;
temp += escapeHtml(data.char_caption) + '
';
if (data.centers && data.centers.length) {
for (const center of data.centers) {
temp += `position
`;
temp += `${getGridPosition(center)}
`;
}
}
return temp;
}, '');
}
return text;
};
const promptParser = (tags) => {
const parse = JSON.parse(tags?.['Comment']?.value || '{}');
if (Object.keys(parse).length === 0) {
if (img.src.endsWith('.webp')) {
const webpParseArray = tags?.['UserComment']?.value || [];
const text = webpParseArray
.filter((code) => code !== 0)
.map((code) => String.fromCharCode(code))
.join('');
const match = text.match(/Comment: ({.+})/);
if (match && JSON.parse(match[1])) {
return JSON.parse(match[1]);
}
}
return {};
}
return parse;
};
try {
const isExifImageType = img.src.endsWith('.png') || img.src.endsWith('.webp');
if (!isExifImageType || !window.ExifReader) return;
const buffer = await getArrayBuffer(img.src);
const data = await new FileReaderEx().readAsDataURL(buffer);
const tags = await window.ExifReader.load(data);
const parse = promptParser(tags);
const { prompt, v4_prompt } = parse;
if (!prompt && !v4_prompt) return;
const p = document.createElement('p');
let html = prompt;
p.classList.add('userjs-prompt');
if (v4_prompt) {
html = createV4Prompt(v4_prompt);
}
if (prompt || v4_prompt) {
p.innerHTML = html;
} else {
p.textContent = 'プロンプトがありません';
}
div.appendChild(p);
} catch (error) {
console.error(error);
}
};
const wrapperClickHandler = (e) => {
const self = e.currentTarget;
if (self.classList.contains('is-caution')) {
self.classList.remove('is-caution');
}
};
const makeCloseButton = () => {
const closeButtonElm = document.createElement('button');
closeButtonElm.classList.add('userjs-preview-close');
return closeButtonElm;
};
const setSetting = async () => {
const delay = (time = 500) => new Promise((resolve) => setTimeout(() => resolve(true), time));
const value = await getMinSize();
const toggleSetting = () => {
const settingElm = document.querySelector('[data-fat="settings"]');
settingElm?.classList.toggle('is-visible');
};
const saveSetting = async () => {
const minSizeElm = document.querySelector(`[data-fat="minSize"]`);
if (!minSizeElm) return;
const value = minSizeElm.value;
if (value === '' || /^[0-9]+$/.test(value) === false) {
alert('数値を入力してください');
return;
}
await GM_setValue('minSize', value);
const settingElm = document.querySelector('[data-fat="settings"]');
settingElm?.classList.remove('is-visible');
await delay(300);
location.reload();
};
const iconHTML = `
デフォルトの画像サイズを指定、0で非表示
`; const settingHTML = `