// ==UserScript==
// @name 8chan.moe reverse imeji search
// @namespace wappispace
// @match https://8chan.moe/*/res/*.html
// @match https://8chan.se/*/res/*.html
// @match https://8chan.cc/*/res/*.html
// @match https://8chan.moe/*/last/*.html
// @match https://8chan.se/*/last/*.html
// @match https://8chan.cc/*/last/*.html
// @match https://ascii2d.net/
// @match https://trace.moe/
// @grant GM.xmlHttpRequest
// @grant GM.getValues
// @grant GM.setValues
// @grant GM_addValueChangeListener
// @grant GM.registerMenuCommand
// @version 2.2.2
// @author anonator
// @description Adds reverse search links above imeji
// @license MIT
// ==/UserScript==
// You can access settings by clicking your userscript extension's icon in the
// browser toolbar, there you should see a settings button under the userscript
// Recent changes
// 2.2.2:
// - Add links to existing posts only when they scroll into view
// 2.2.1:
// - Add pixiv links regardless of filetype support
// 2.2.0:
// - Added trace.moe, enable in settings
// - Don't add links for unsupported filetypes
// 2.1.0:
// - Use thumbnail if filesize exceeds service limit
// - Add setting to always use thumbnails
// - Use postMessage to send imeji to ascii2d tab so litterbox isn't needed
// anymore (sadly doesn't work for google due to CSP)
(async () => {
const DEFAULT_CFG = {
pixiv: true,
iqdb: true,
saucenao: false,
ascii2d: true,
tracemoe: false,
google: true,
useThumbnails: false,
};
/** @type {DEFAULT_CFG} */
const cfg = await GM.getValues(DEFAULT_CFG);
// pretend to care about backwards compatibility
if (typeof GM_addValueChangeListener === "function") {
for (const key in cfg) {
GM_addValueChangeListener(key, (name, _oldValue, newValue, remote) => {
if (remote) {
cfg[name] = newValue ?? false;
}
});
}
}
// receive imeji using message across tabs
if (
window.opener &&
((cfg.ascii2d && window.location.origin === "https://ascii2d.net") ||
(cfg.tracemoe && window.location.origin === "https://trace.moe"))
) {
const parent = window.location.hash.substring(1);
if (!parent.includes("8chan")) return;
window.addEventListener("message", async (e) => {
if (e.origin !== parent) return;
console.log("Received imeji from 8chan, submitting form...");
if (window.location.origin === "https://ascii2d.net") {
const input = document.getElementById("file-form");
sendFileToInput(e.data, input);
const submit = document.querySelector(
"form#file_upload button[type=submit]",
);
submit.focus();
submit.click();
for (const btn of document.querySelectorAll(
"form button[type=submit]",
)) {
btn.setAttribute("disabled", "disabled");
}
}
if (window.location.origin === "https://trace.moe") {
const interval = setInterval(() => {
const droptarget = document.querySelector('div[class*="dropTarget"]');
if (droptarget) {
clearInterval(interval);
dropFile(e.data, droptarget);
}
}, 100);
}
console.log("Sending 'done' to 8chan...");
window.opener.postMessage("done", parent);
});
console.log("Initialized, sending 'ready' to 8chan...");
window.opener.postMessage("ready", parent);
return;
}
function dropFile(file, target) {
const dt = new DataTransfer();
dt.items.add(file);
const event = new DragEvent("drop", {
bubbles: true,
cancelable: true,
dataTransfer: dt,
});
target.dispatchEvent(event);
}
function sendFileToInput(file, input) {
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
const event = new Event("change", { bubbles: true });
input.dispatchEvent(event);
}
async function addImejiFile(url, input) {
const imeji = await getImeji(url);
sendFileToInput(imeji, input);
}
const mimeToExt = {
"image/gif": ".gif",
"image/webp": ".webp",
"image/png": ".png",
"image/jpeg": ".jpg",
"image/bmp": ".bmp",
};
async function getImeji(url) {
const response = await fetch(url);
const data = await response.blob();
const metadata = {};
let name = url.split("/").filter(Boolean).pop();
if (response.headers.has("content-type")) {
metadata.type = response.headers.get("content-type");
if (!name.endsWith(mimeToExt[metadata.type])) {
name = `${name}.${mimeToExt[metadata.type]}`;
}
}
return new File([data], name, metadata);
}
async function iqdbSubmit(url) {
// using a form to bypass anti-hotlinking
const form = document.createElement("form");
form.target = "_blank";
form.enctype = "multipart/form-data";
form.action = "https://iqdb.org/";
form.method = "post";
form.style = "display: none";
for (const n of [1, 2, 3, 4, 5, 6, 11, 13]) {
const s = document.createElement("input");
s.type = "checkbox";
s.name = "service[]";
s.value = n;
s.checked = true;
form.appendChild(s);
}
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.name = "file";
form.appendChild(fileInput);
const ig = document.createElement("input");
ig.type = "checkbox";
ig.name = "forcegray";
ig.checked = false;
form.appendChild(ig);
const submit = document.createElement("input");
submit.type = "submit";
submit.value = "submit";
form.appendChild(submit);
document.body.appendChild(form);
await addImejiFile(url, fileInput);
submit.click();
setTimeout(() => form.remove(), 60000);
}
async function saucenaoSubmit(url) {
const form = document.createElement("form");
form.target = "_blank";
form.enctype = "multipart/form-data";
form.action = "https://saucenao.com/search.php";
form.method = "post";
form.style = "display: none";
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.name = "file";
form.appendChild(fileInput);
document.body.appendChild(form);
await addImejiFile(url, fileInput);
form.submit();
setTimeout(() => form.remove(), 60000);
}
function urlToThumb(url) {
return `${location.origin}/.media/t_${url.split("/")[4].split(".")[0]}`;
}
const msgData = {
ascii2d: {
imeji: null,
win: null,
},
tracemoe: {
imeji: null,
win: null,
},
};
window.addEventListener("message", (e) => {
let key = "";
if (e.origin === "https://ascii2d.net") {
key = "ascii2d";
} else if (e.origin === "https://trace.moe") {
key = "tracemoe";
} else {
return;
}
switch (e.data) {
case "ready":
console.log(`${key} ready, sending image...`);
msgData[key].win.postMessage(msgData[key].imeji, e.origin);
msgData[key] = { imeji: null, win: null };
break;
case "done":
console.log(`${key} done`);
break;
}
});
function getImejiFileSize(link) {
const sizeLabel = link.parentElement.querySelector("span.sizeLabel");
const split = sizeLabel.textContent.split(" ");
let unit = 0;
switch (split[1]) {
case "KB":
unit = 1024;
break;
case "MB":
unit = 1024 * 1024;
break;
}
return Number.parseFloat(split[0]) * unit;
}
async function googleListener(e) {
e.preventDefault();
t = e.currentTarget;
t.style.pointerEvents = "none";
t.textContent = "[google...]";
try {
let url = t.parentElement.querySelector("a.originalNameLink").href;
// 20 MiB limit
if (
cfg.useThumbnails ||
getImejiFileSize(t) > 20971520 ||
url.endsWith(".mp4") ||
url.endsWith(".webm")
) {
url = urlToThumb(url);
}
url = await catbox(url);
window.open(
`https://lens.google.com/uploadbyurl?url=${encodeURIComponent(url)}&safe=off`,
);
} catch (e) {
console.error(e);
}
t.style.pointerEvents = "";
t.textContent = "[google]";
}
const RE_UNSUPPORTED =
/.(weba|m4a|mp3|og[ag]|opus|flac|wav|te?xt|m3u|pdf|sw[fl]|epub|json|gpg|svgz?)$/i;
const RE_VIDEO = /.(webm|m4v|mp4|og[mv]|avi|asx|mpe?g)$/i;
function isUnsupported(url) {
return RE_UNSUPPORTED.test(url);
}
function isVideo(url) {
return RE_VIDEO.test(url);
}
function hDisableAndGetUrl(t, name) {
t.style.pointerEvents = "none";
t.textContent = `[${name}...]`;
return t.parentElement.querySelector("a.originalNameLink").href;
}
function hEnable(t, name) {
t.style.pointerEvents = "";
t.textContent = `[${name}]`;
}
async function tracemoeListener(e) {
e.preventDefault();
if (msgData.tracemoe.win !== null) return;
t = e.currentTarget;
let url = hDisableAndGetUrl(t, "trace");
try {
// 25 MiB limit
if (cfg.useThumbnails || getImejiFileSize(t) > 26214400 || isVideo(url)) {
url = urlToThumb(url);
}
console.log("opening trace.moe tab...");
msgData.tracemoe = {
imeji: await getImeji(url),
win: window.open(`https://trace.moe/#${window.location.origin}`),
};
} catch (e) {
console.error(e);
}
hEnable(t, "trace");
}
async function ascii2dListener(e) {
e.preventDefault();
if (msgData.ascii2d.win !== null) return;
t = e.currentTarget;
let url = hDisableAndGetUrl(t, "ascii2d");
try {
// 10 MiB limit
if (cfg.useThumbnails || getImejiFileSize(t) > 10485760 || isVideo(url)) {
url = urlToThumb(url);
}
console.log("opening ascii2d tab...");
msgData.ascii2d = {
imeji: await getImeji(url),
win: window.open(`https://ascii2d.net/#${window.location.origin}`),
};
} catch (e) {
console.error(e);
}
hEnable(t, "ascii2d");
}
async function iqdbListener(e) {
e.preventDefault();
t = e.currentTarget;
let url = hDisableAndGetUrl(t, "iqdb");
try {
// 8 MiB limit
if (
cfg.useThumbnails ||
getImejiFileSize(t) > 8388608 ||
isVideo(url) ||
url.endsWith(".webp")
) {
url = urlToThumb(url);
}
await iqdbSubmit(url);
} catch (e) {
console.error(e);
}
hEnable(t, "iqdb");
}
async function saucenaoListener(e) {
e.preventDefault();
t = e.currentTarget;
let url = hDisableAndGetUrl(t, "sauce");
try {
// 15 MiB limit
if (
cfg.useThumbnails ||
getImejiFileSize(t) > 15728640 ||
isVideo(url) ||
url.endsWith(".bmp")
) {
url = urlToThumb(url);
}
await saucenaoSubmit(url);
} catch (e) {
console.error(e);
}
hEnable(t, "sauce");
}
function addReverseLink(link, name, listener) {
const a = document.createElement("a");
a.textContent = `[${name}]`;
a.style = "margin-right: 0.5ch;";
a.addEventListener("click", listener);
link.parentElement.insertAdjacentElement("afterend", a);
}
const rePixiv = /(\d+)_p\d+\.(jpg|png)/;
async function addReverseLinks(link) {
if (!isUnsupported(link.href)) {
if (cfg.google) {
addReverseLink(link, "google", googleListener);
}
if (cfg.tracemoe) {
addReverseLink(link, "trace", tracemoeListener);
}
if (cfg.ascii2d) {
addReverseLink(link, "ascii2d", ascii2dListener);
}
if (cfg.saucenao) {
addReverseLink(link, "sauce", saucenaoListener);
}
if (cfg.iqdb) {
addReverseLink(link, "iqdb", iqdbListener);
}
}
// filetype support doesn't matter for pixiv, just goes by filename
if (cfg.pixiv) {
const match = link.download.match(rePixiv);
if (match) {
const pixiv = document.createElement("a");
pixiv.style = "margin-right: 0.5ch";
pixiv.target = "_blank";
pixiv.textContent = "[pixiv]";
pixiv.href = `https://www.pixiv.net/artworks/${match[1]}`;
link.parentElement.insertAdjacentElement("afterend", pixiv);
}
}
}
const thread = document.getElementById("divThreads");
const obs = new MutationObserver((mutationList) => {
for (const mutation of mutationList) {
for (const node of mutation.addedNodes) {
if (
node.tagName === "DIV" &&
(node.classList.contains("postCell") ||
node.classList.contains("inlineQuote"))
) {
for (const post of node.querySelectorAll("a.originalNameLink")) {
addReverseLinks(post);
}
}
}
}
});
obs.observe(thread, { childList: true, subtree: true });
// instead of adding all links at once, wait for existing posts to scroll
// into view, hopefully lessens freezing on my garbage laptop
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const post = entry.target;
if (!post.dataset.reverseLinks) {
for (const item of post.querySelectorAll("a.originalNameLink")) {
addReverseLinks(item);
}
post.dataset.reverseLinks = "true";
observer.unobserve(post);
}
}
}
});
observer.observe(thread.querySelector("div.innerOP"));
for (const post of thread.querySelectorAll("div.postCell")) {
observer.observe(post);
}
const CATBOX_CACHE = {};
const RE_CATBOX_URL =
/^https?:\/\/(files|litter)\.catbox\.moe\/([a-z0-9]+\.\w+)$/i;
async function catbox(url) {
if (url in CATBOX_CACHE && CATBOX_CACHE[url].ttl > Date.now())
return CATBOX_CACHE[url].url;
const file = await getImeji(url);
const data = new FormData();
data.set("reqtype", "fileupload");
data.set("time", "1h");
data.set("fileToUpload", file);
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "POST",
url: "https://litterbox.catbox.moe/resources/internals/api.php",
timeout: 60000,
data,
onload: (resp) => {
const match = resp.responseText.match(RE_CATBOX_URL);
if (resp.status === 200 && match) {
CATBOX_CACHE[url] = {
url: resp.responseText,
ttl: Date.now() + 3600000,
};
console.log(`${url} uploaded to litterbox: ${resp.responseText}`);
resolve(resp.responseText);
} else {
console.error(resp.status, resp.responseText);
reject("Response is not a catbox url");
}
},
onerror: (error) => {
console.error(resp.status, resp.responseText);
reject(error);
},
});
});
}
GM.registerMenuCommand("Settings", () => {
if (document.getElementById("revImejiSettings")) return;
// ChatGPT styling, hope you like
const menu = document.createElement("dialog");
menu.id = "revImejiSettings";
menu.style =
"background-color: #1e1e1e; color: #fff; border: none; border-radius: 8px; padding: 0; width: 360px;";
menu.insertAdjacentHTML(
"afterbegin",
`
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background-color: #2a2a2a;">
<span style="font-weight: bold;">Reverse Imeji Settings</span>
<button id="revImejiSettingsClose" style="background: none; border: none; color: #fff; font-size: 18px; cursor: pointer;">×</button>
</div>
<!-- Divider -->
<div style="height: 1px; background-color: #444;"></div>
<!-- Content -->
<form id="revImejiSettingsForm" style="padding: 8px 16px; display: flex; flex-direction: column; gap: 10px;">
<label style="cursor: pointer;"><input type="checkbox" name="pixiv"> Pixiv</label>
<label style="cursor: pointer;"><input type="checkbox" name="iqdb"> IQDB</label>
<label style="cursor: pointer;"><input type="checkbox" name="saucenao"> SauceNao</label>
<label style="cursor: pointer;"><input type="checkbox" name="ascii2d"> Ascii2d</label>
<label style="cursor: pointer;"><input type="checkbox" name="tracemoe"> Trace.moe</label>
<label style="cursor: pointer;"><input type="checkbox" name="google"> Google Lens</label>
<hr />
<label style="cursor: pointer;"><input type="checkbox" name="useThumbnails"> Always use thumbnails (saves bandwith)</label>
<!-- Buttons -->
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px;">
<button id="revImejiSettingsDefault" type="button" style="background-color: #333; color: #fff; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer;">Default</button>
<button type="submit" style="background-color: #4caf50; color: #fff; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer;">Save</button>
</div>
</form>
`,
);
menu
.querySelector("#revImejiSettingsClose")
.addEventListener("click", () => {
menu.close();
});
function setCheckboxes(config) {
for (const cb of menu.querySelectorAll("input[type=checkbox]")) {
cb.checked = config[cb.name];
}
}
setCheckboxes(cfg);
menu
.querySelector("#revImejiSettingsDefault")
.addEventListener("click", () => {
setCheckboxes(DEFAULT_CFG);
});
menu
.querySelector("#revImejiSettingsForm")
.addEventListener("submit", (e) => {
e.preventDefault();
for (const cb of menu.querySelectorAll("input[type=checkbox]")) {
cfg[cb.name] = cb.checked;
}
GM.setValues(cfg);
menu.close();
});
menu.addEventListener("close", () => {
menu.remove();
});
document.body.appendChild(menu);
menu.showModal();
});
})();