// ==UserScript==
// @name prompt-extractor.user.js
// @namespace https://github.com/toriato/userscripts/prompt-extractor.user.js
// @version 0.1.0
// @description 이미지로부터 Exif 정보를 추출해 사용자에게 보여줍니다
// @author Sangha Lee <[email protected]>
// @license MIT
// @match https://arca.live/b/*
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/exif-reader.min.js
// ==/UserScript==
GM_addStyle(/*css*/`
@keyframes spin {
from { transform:rotate(0deg) }
to { transform:rotate(360deg) }
}
figure.params {
margin: 0;
position: relative;
display: table;
}
figure.params img {
max-width: 100%;
}
/* 우측 상단 상태 아이콘 */
figure.params:not([data-params=""])::after {
position: absolute;
right: 0;
top: 0;
margin: .5em;
font-size: 2rem;
text-shadow: 0 0 4px black;
content: '❤️'
}
figure.params.loading::after {
animation: spin 1s infinite linear;
content: '🌀'
}
figure.params:not(.loading):not([data-params=""]):hover::after {
display: none;
}
figure.params figcaption {
transition: transform .25s, opacity .25s;
transform: scaleY(0);
transform-origin: top;
position: absolute;
left: 0;
top: 0;
overflow-y: auto;
max-height: 50%;
padding: .5em;
opacity: 0;
background-color: rgba(0, 0, 0, 0.5);
text-align: left;
pointer-events: none;
}
figure.params:not(.loading):not([data-params=""]):hover figcaption {
transform: scaleY(1);
opacity: 1;
pointer-events: inherit;
}
`)
/**
* UPNG.js - JS PNG Decoder/Encoder
* https://github.com/photopea/UPNG.js
* MIT License
*/
class UPNG {
static bin = {
nextZero: (data, p) => {
while (data[p] != 0) p++
return p
},
readUshort: (buff, p) =>
(buff[p] << 8) | buff[p + 1],
writeUshort: (buff, p, n) => {
buff[p] = (n >> 8) & 255
buff[p + 1] = n & 255
},
readUint: (buff, p) =>
(buff[p] * (256 * 256 * 256)) + ((buff[p + 1] << 16) | (buff[p + 2] << 8) | buff[p + 3]),
writeUint: (buff, p, n) => {
buff[p] = (n >> 24) & 255
buff[p + 1] = (n >> 16) & 255
buff[p + 2] = (n >> 8) & 255
buff[p + 3] = n & 255
},
readASCII: (buff, p, l) => {
let s = ''
for (let i = 0; i < l; i++)
s += String.fromCharCode(buff[p + i])
return s
},
writeASCII: (data, p, s) => {
for (let i = 0; i < s.length; i++)
data[p + i] = s.charCodeAt(i)
},
readBytes: (buff, p, l) => {
const arr = []
for (let i = 0; i < l; i++)
arr.push(buff[p + i]);
return arr
},
pad: (n) =>
n.length < 2 ? '0' + n : n,
readUTF8: function (buff, p, l) {
let s = ''
let ns;
for (var i = 0; i < l; i++) s += "%" + UPNB.bin.pad(buff[p + i].toString(16));
try { ns = decodeURIComponent(s); }
catch (e) { return UPNG.bin.readASCII(buff, p, l); }
return ns;
}
}
static magicNumbers = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
static decode(buff) {
const bin = UPNG.bin
const data = new Uint8Array(buff)
const texts = {}
for (let i = 0; i < 8; i++) {
if (data[i] !== UPNG.magicNumbers[i]) {
throw Error('Input file is not a PNG')
}
}
let offset = 8
while (offset < data.length) {
const len = bin.readUint(data, offset); offset += 4;
const type = bin.readASCII(data, offset, 4); offset += 4;
if (type == 'tEXt') {
const nz = bin.nextZero(data, offset);
const key = bin.readASCII(data, offset, nz - offset);
const textLen = offset + len - nz - 1;
texts[key] = bin.readASCII(data, nz + 1, textLen)
}
offset += len + 4;
}
return texts
}
}
/**
* @param {UIEvent} event
*/
function onLoad(event) {
/** @type {HTMLImageElement} */
const node = event.target
// 작은 이미지는 메타데이터 확인하지 않기
const rect = node.getBoundingClientRect()
if (rect.width < 128 || rect.height < 128) {
return
}
let src = new URL(node.src)
// 아카라이브에선 원본 이미지에만 Exif 데이터가 존재함
if (src.host.endsWith('namu.la')) {
src.searchParams.set('type', 'orig')
}
const $figure = document.createElement('figure')
$figure.classList.add('params', 'loading')
$figure.innerHTML = /*html*/`
${node.closest('p').innerHTML}
<figcaption></figcaption>
`
node.replaceWith($figure)
// Exif 로부터 파라미터 문자열 가져오기
let params = ''
new Promise((resolve, reject) => {
GM_xmlhttpRequest({
url: src.toString(),
responseType: 'arraybuffer',
onload: resolve,
onerror: reject
})
})
.then(res => {
const headers = Object.fromEntries(
res.responseHeaders
.split(/\r?\n/)
.map(v => {
const [key, value] = v.split(':', 2).map(v => v.trim())
return [key.toLowerCase(), value]
})
)
const contentType = headers['content-type']
switch (contentType) {
case 'image/png':
const texts = UPNG.decode(res.response)
params = texts['parameters'] ?? ''
break
default:
// 반환 받은 파일이 이미지가 아니라면 무시하기
if (!contentType.startsWith('image/')) {
return
}
try {
const tags = ExifReader.load(res.response)
if (tags?.UserComment?.value) {
params = String.fromCharCode(
// 첫 8바이트는 인코딩 타입이므로 디코딩 할 필요 없음
// https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/usercomment.html
...tags.UserComment.value.slice(8).filter(v => v !== 0)
)
}
} catch (err) {
// 메타데이터가 존재하지 않는 이미지라면 무시하기
if (err.name !== 'MetadataMissingError') {
throw err
}
}
}
})
.finally(() => {
$figure.classList.remove('loading')
$figure.dataset.params = params
$figure.querySelector('figcaption').innerHTML = params
})
}
const observer = new MutationObserver(mutations => {
for (let mutation of mutations) {
for (let node of mutation.addedNodes) {
// 노드가 이미지 태그가 아니라면 무시하기
if (!(node instanceof HTMLImageElement)) {
continue
}
node.addEventListener('load', onLoad)
}
}
})
observer.observe(
document,
{
attributes: true,
childList: true,
subtree: true
}
)