Last active
January 28, 2026 21:20
-
-
Save mdashlw/5bf72993f8d0f071d4be1cce1e56cedc to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Booru Dupes Metadata | |
| // @version 2.0.0 | |
| // @author mdashlw | |
| // @namespace Booru Scripts | |
| // @match https://*.derpibooru.org/* | |
| // @match https://*.trixiebooru.org/* | |
| // @match https://*.tantabus.ai/* | |
| // @match https://*.furbooru.org/* | |
| // @grant GM_xmlhttpRequest | |
| // @description 3/4/2025 | |
| // @updateURL https://gist.github.com/mdashlw/5bf72993f8d0f071d4be1cce1e56cedc/raw/booru-dupes-metadata.user.js | |
| // @downloadURL https://gist.github.com/mdashlw/5bf72993f8d0f071d4be1cce1e56cedc/raw/booru-dupes-metadata.user.js | |
| // ==/UserScript== | |
| if (location.pathname !== "/duplicate_reports") { | |
| return; | |
| } | |
| function xhr(details) { | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| ...details, | |
| timeout: 30_000, | |
| anonymous: true, | |
| onerror: (responseObject) => { | |
| reject("Error!"); | |
| console.error("XHR error response object:", responseObject); | |
| }, | |
| ontimeout: () => { | |
| reject("Timeout!"); | |
| }, | |
| onload: (responseObject) => { | |
| if (responseObject.status !== 200) { | |
| reject( | |
| `Error! ${responseObject.status} ${responseObject.statusText}`, | |
| ); | |
| console.error("XHR error response object:", responseObject); | |
| return; | |
| } | |
| resolve(responseObject.response); | |
| }, | |
| }); | |
| }); | |
| } | |
| function magick(url) { | |
| return xhr({ | |
| method: "GET", | |
| url: `https://duplicatebooru.zipfiled.info/api?url=${encodeURIComponent( | |
| url, | |
| )}`, | |
| responseType: "json", | |
| }); | |
| } | |
| async function fetchImages(ids) { | |
| const everythingFilterId = "56027"; | |
| const response = await xhr({ | |
| method: "GET", | |
| url: `/api/v1/json/search?${new URLSearchParams({ | |
| filter_id: everythingFilterId, | |
| q: ids.map((id) => `id:${id}`).join(" OR "), | |
| })}`, | |
| responseType: "json", | |
| }); | |
| return response.images; | |
| } | |
| function formatFraction(n, digits) { | |
| return new Intl.NumberFormat("en-US", { | |
| maximumFractionDigits: digits, | |
| }).format(n); | |
| } | |
| function formatBytes(bytes, binary) { | |
| if (!bytes) { | |
| return ""; | |
| } | |
| const b = binary ? 1024 : 1000; | |
| const units = binary ? ["Bytes", "KiB", "MiB"] : ["Bytes", "kB", "MB"]; | |
| const i = Math.floor(Math.log(bytes) / Math.log(b)); | |
| const value = bytes / Math.pow(b, i); | |
| return `${formatFraction(value, 1)} ${units[i]}`; | |
| } | |
| function limitWidthText(text, maxWidth) { | |
| const div = document.createElement("div"); | |
| div.textContent = text; | |
| div.title = text; | |
| div.style.maxWidth = maxWidth; | |
| div.style.overflow = "hidden"; | |
| div.style.textOverflow = "ellipsis"; | |
| div.style.whiteSpace = "nowrap"; | |
| return div; | |
| } | |
| for (const diffCell of document.querySelectorAll( | |
| ".grid--dupe-report-list__cell.dr__diff", | |
| )) { | |
| const targetCell = diffCell.previousSibling; | |
| const sourceCell = targetCell.previousSibling; | |
| const metadataButton = document.createElement("button"); | |
| metadataButton.type = "button"; | |
| metadataButton.className = "button"; | |
| metadataButton.textContent = "Metadata"; | |
| metadataButton.style.fontSize = "1.2em"; | |
| metadataButton.style.marginTop = "1em"; | |
| metadataButton.style.marginBottom = "0.5em"; | |
| metadataButton.addEventListener("click", () => { | |
| metadataButton.remove(); | |
| const imageContainers = [ | |
| sourceCell.querySelector(".image-container"), | |
| targetCell.querySelector(".image-container"), | |
| ]; | |
| const inputImageIds = imageContainers.map((c) => Number(c.dataset.imageId)); | |
| const inputImageUrls = imageContainers.map((c) => | |
| JSON.parse(c.dataset.uris).full.replace("/view/", "/download/"), | |
| ); | |
| diffCell.appendChild(document.createElement("br")); | |
| Promise.all([ | |
| fetchImages(inputImageIds), | |
| ...inputImageUrls.map(magick), | |
| ]).then(([images, ...infos]) => { | |
| const inputs = inputImageIds.map((id, idx) => ({ | |
| image: images.find((i) => i.id === id), | |
| info: infos.find((i) => i.__src === inputImageUrls[idx]), | |
| container: imageContainers[idx], | |
| })); | |
| const view = [ | |
| ["Hash", ({ info }) => [info.properties.signature]], | |
| ["File size", ({ info }) => [formatBytes(info.__size, true)]], | |
| ["Format", ({ info }) => [info.format]], | |
| [ | |
| "Dimensions", | |
| ({ info }) => [`${info.geometry.width}x${info.geometry.height}`], | |
| ], | |
| [ | |
| "Megapixels", | |
| ({ info }) => [formatFraction(info.pixels / 1_000_000, 2)], | |
| ], | |
| ["Color space", ({ info }) => [info.colorspace]], | |
| ["Type", ({ info }) => [info.type]], | |
| ["Depth", ({ info }) => [`${info.depth}-bit`]], | |
| ["Compression", ({ info }) => [info.compression]], | |
| ["Interlacing", ({ info }) => [info.interlace]], | |
| ["Orientation", ({ info }) => [info.orientation]], | |
| ["JPEG quality", ({ info }) => [info.quality]], | |
| [ | |
| "JPEG sampling factors", | |
| ({ info }) => [info.properties["jpeg:sampling-factor"]], | |
| ], | |
| [ | |
| "PNG color type", | |
| ({ info }) => [info.properties["png:IHDR.color_type"]], | |
| ], | |
| [ | |
| "PNG bit depth", | |
| ({ info }) => [info.properties["png:IHDR.bit_depth"]], | |
| ], | |
| [ | |
| "PNG palette colors", | |
| ({ info }) => [info.properties["png:PLTE.number_colors"]], | |
| ], | |
| [ | |
| "Original file size", | |
| ({ image }) => [formatBytes(image?.orig_size, true)], | |
| ], | |
| [ | |
| "Original file name", | |
| ({ image }) => [image?.name], | |
| { maxWidth: "60ch" }, | |
| ], | |
| [ | |
| "Sources", | |
| ({ image, container }) => | |
| image?.source_urls ?? JSON.parse(container.dataset.sourceUrls), | |
| { maxWidth: "60ch" }, | |
| ], | |
| ]; | |
| if (new Set(infos.map((info) => info.properties.signature)).size === 1) { | |
| view.unshift(["Pixel-identical?", () => ["Yes"]]); | |
| } | |
| const table = document.createElement("table"); | |
| const tbody = document.createElement("tbody"); | |
| table.appendChild(tbody); | |
| for (const [row, fn, options] of view) { | |
| const values = inputs.map(fn); | |
| const max = Math.max.apply( | |
| null, | |
| values.map((v) => v.length), | |
| ); | |
| if (max === 0 || values.every((l) => l.every((v) => !v))) { | |
| continue; | |
| } | |
| for (let i = 0; i < max; i++) { | |
| const tr = document.createElement("tr"); | |
| const td = document.createElement("td"); | |
| td.textContent = row; | |
| tr.appendChild(td); | |
| for (const v of values) { | |
| const td = document.createElement("td"); | |
| const value = v[i] || ""; | |
| if (options?.maxWidth) { | |
| td.appendChild(limitWidthText(value, options.maxWidth)); | |
| } else { | |
| td.textContent = value; | |
| } | |
| tr.appendChild(td); | |
| } | |
| tbody.appendChild(tr); | |
| } | |
| } | |
| diffCell.appendChild(table); | |
| }); | |
| }); | |
| diffCell.appendChild(metadataButton); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment