Games By web crawler 7 installs Rating 0.0 (0) approved

Neopets - Stamp Album Helper

Neopets Stamp Album Helper highlights missing stamps and lets you copy them instantly using single or auto copy modes, making album completion simple.
helper neopets stamp album
Install
https://www.scriptneo.com/script/neopets-stamp-album-helper

Version selector


SHA256
6d46519f135c34cc61b54d59b607db35ff8cd6f04d42635fe9352e8691761620
Static scan
Score: 4
Flags: uses_gm_xmlhttprequest, uses_clipboard, base64_usage

Source code

// ==UserScript==
// @name         itemdb - Stamp Album Helper + Copy Missing (Single & Auto)
// @version      1.2.9
// @author       originally EatWooloos, updated by itemdb, auto-copy by ChatGPT
// @namespace    itemdb
// @description  Adds an info menu about missing stamps, copy buttons, auto-copy across albums, TP helpers, shows missing counts on the album links, and can open TP searches for missing stamps across ALL albums in batches
// @icon         https://itemdb.com.br/favicon.ico
// @match        *://*.neopets.com/stamps.phtml?type=album&page_id=*
// @connect      itemdb.com.br
// @connect      neopets.com
// @connect      www.neopets.com
// @grant        GM_xmlhttpRequest
// @grant        GM_setClipboard
// @grant        GM_openInTab
// @downloadURL  https://www.scriptneo.com/scripts/download.php?id=23
// @updateURL    https://www.scriptneo.com/scripts/download.php?id=23
// ==/UserScript==

/****************************************************************************************
 *
 *  < Stamp Album Helper by u/EatWooloo_As_Mutton and updated by itemdb >
 *  Extended with:
 *   - Copy Missing Stamps (this album)
 *   - Auto Copy Missing (all albums found in the header menu)
 *   - Auto Copy Missing (Names Only, all albums found in the header menu)  <-- NEW
 *   - Trading Post icon under each stamp image (links to TP exact search)
 *   - Button to open all Trading Post searches for missing stamps on the page
 *   - Album link counts: show how many stamps are missing in each album
 *   - Button to open Trading Post searches for ALL missing stamps across ALL albums
 *   - Batch opening for ALL-albums TP: 10 tabs, wait 20 seconds, repeat
 *
 ****************************************************************************************/

// Robust URL param parsing
const params = new URLSearchParams(window.location.search);
const albumIDStr = params.get("page_id");
const albumID = albumIDStr ? parseInt(albumIDStr, 10) : 0;
let owner = params.get("owner") || (typeof appInsightsUserName !== "undefined" ? appInsightsUserName : "");

let hasPremium = false;
if (!document.URL.includes("progress")) {
    hasPremium = !!$("#sswmenu .imgmenu").length;
}

/****************************************************************************************
 * Batch settings for opening lots of TP tabs
 ****************************************************************************************/
const OPEN_ALL_TP_BATCH_SIZE = 10;
const OPEN_ALL_TP_BATCH_WAIT_MS = 20000;

/****************************************************************************************
 * Selectors (critical: only real stamp images, not injected icon imgs)
 ****************************************************************************************/
const STAMP_IMG_SEL = ".content table td > img";

/****************************************************************************************
 * Networking helpers
 ****************************************************************************************/
function gmGet(url) {
    return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            method: "GET",
            url,
            headers: { "Content-Type": "text/html; charset=UTF-8" },
            onload: res => {
                if (res.status >= 200 && res.status < 300) resolve(res.responseText);
                else reject(new Error(res.status + " " + url));
            },
            onerror: err => reject(err)
        });
    });
}

function gmGetJson(url) {
    return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            method: "GET",
            url,
            headers: { "Content-Type": "application/json" },
            onload: res => {
                if (res.status >= 200 && res.status < 300) {
                    try {
                        resolve(JSON.parse(res.responseText));
                    } catch (e) {
                        reject(e);
                    }
                } else {
                    reject(new Error(res.status + " " + url));
                }
            },
            onerror: err => reject(err)
        });
    });
}

const sleep = ms => new Promise(r => setTimeout(r, ms));

async function countdownStatus(seconds, prefix) {
    for (let s = seconds; s > 0; s--) {
        $("#auto-copy-status").text(`${prefix} Waiting ${s}s...`);
        await sleep(1000);
    }
}

/****************************************************************************************
 * Album missing count cache
 ****************************************************************************************/
const ALBUM_COUNT_CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
const ALBUM_COUNT_CACHE_KEY = `idb_album_missing_counts::${owner || "__noowner__"}`;

function loadAlbumCountCache() {
    try {
        const raw = localStorage.getItem(ALBUM_COUNT_CACHE_KEY);
        if (!raw) return null;
        const obj = JSON.parse(raw);
        if (!obj || typeof obj !== "object") return null;
        if (!obj.ts || !obj.counts) return null;
        if (Date.now() - obj.ts > ALBUM_COUNT_CACHE_TTL_MS) return null;
        return obj;
    } catch {
        return null;
    }
}

function saveAlbumCountCache(counts) {
    try {
        localStorage.setItem(ALBUM_COUNT_CACHE_KEY, JSON.stringify({ ts: Date.now(), counts }));
    } catch {
        // ignore
    }
}

/****************************************************************************************
 * ItemDB data for the current album
 ****************************************************************************************/
let thisPage = {};
const format = new Intl.NumberFormat().format;

async function getStampList() {
    // Skip ItemDB API for album 0 to avoid 404
    if (!albumID || albumID <= 0) {
        console.warn("[itemdb] album_id is 0 or invalid, skipping ItemDB fetch for this page");
        addCopyButtons();
        addTradingPostIconsUnderImages();
        return;
    }

    try {
        const data = await gmGetJson("https://itemdb.com.br/api/v1/tools/album-helper?album_id=" + albumID);
        thisPage = data;
        replaceImages();
        addCopyButtons();
    } catch (e) {
        console.error("[itemdb] Failed to fetch album data for albumID", albumID, e);
        addCopyButtons();
        addTradingPostIconsUnderImages();
    }
}

if (albumIDStr !== null) {
    getStampList();
}

/****************************************************************************************
 * Styles
 ****************************************************************************************/
$("body").append(`
  <style>
    .fake-stamp { opacity: 25% !important; }
    .stamp-info { display: none; }
    .stamp-info.visible { display: block; text-align: center; }
    .stamp-info-table { width: 450px; margin: auto; border: 1px solid #b1b1b1; border-collapse: collapse; }
    .stamp-info-table td { padding: 6px; }
    .searchimg { width: 35px !important; height: 35px !important; }
    .content table img { cursor: pointer; }
    .stamp-selected {
      background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFoAAABaCAIAAAC3ytZVAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAECSURBVHhe7dBBEYAwEARBtKAnZqMQfhRzFtJba2D6uvfy7zhyHDmOHEc+OZ7DNvJxJB9H8nEkH0fycSQfR/JxJB9H8nEkH0fycSQfR/JxJB9H8nEkH0fycSQfR/JxJB9H8nEkH0fycSQfR/JxJB9H8nEkH0fycSQfR/JxJB9H8nEkH0fycSQfR/JxJB9H8nEkH0fycSQfR/JxJB9H8nEkH0fycSQfR/JxJB9H8nEkH0fycSQfR/JxJB9H8nEkH0fycSQfR/JxJH9yHH4cOY4cR47j971exW0rqwgJ0K4AAAAASUVORK5CYII=) no-repeat;
    }
    .stamp-info-arrow:hover { background: #dfdfdf; }

    .idb-copy-bar { text-align:center; margin: 10px 0 6px; }
    #copy-missing-btn, #auto-copy-missing-btn, #auto-copy-names-only-btn, #open-missing-tp-btn, #open-all-missing-tp-btn {
      display: inline-block;
      background: #4CAF50;
      color: #fff;
      padding: 6px 10px;
      font-size: 14px;
      border: none;
      cursor: pointer;
      border-radius: 4px;
      margin: 0 4px;
    }
    #auto-copy-missing-btn { background: #0d6efd; }
    #auto-copy-names-only-btn { background: #6f42c1; }
    #open-missing-tp-btn { background: #f0ad4e; color: #111; }
    #open-all-missing-tp-btn { background: #dc3545; }
    #copy-missing-btn:hover { background: #45a049; }
    #auto-copy-missing-btn:hover { background: #0b5ed7; }
    #auto-copy-names-only-btn:hover { background: #5f38aa; }
    #open-missing-tp-btn:hover { background: #ec971f; }
    #open-all-missing-tp-btn:hover { background: #bb2d3b; }

    #auto-copy-status {
      display:block;
      text-align:center;
      font-size:12px;
      color:#333;
      margin-top:6px;
      white-space:pre-line;
    }

    /* Trading Post icon under each stamp image */
    .idb-tp-under {
      display: block;
      margin-top: 2px;
      line-height: 0;
      cursor: pointer;
    }
    .idb-tp-under img {
      width: 18px !important;
      height: 18px !important;
      cursor: pointer;
    }

    /* Album missing counts on the header links */
    .idb-album-missing-count {
      display: inline-block;
      padding: 0 6px;
      border-radius: 999px;
      font-size: 11px;
      line-height: 16px;
      background: #e9ecef;
      color: #111;
      border: 1px solid #cfd4da;
      vertical-align: middle;
      margin-left: 4px;
    }
    .idb-album-missing-count.idb-zero {
      opacity: 0.7;
    }
  </style>
`);

/****************************************************************************************
 * Trading Post helpers
 ****************************************************************************************/
function tpExactUrlForName(name) {
    return (
        "https://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact&sort=newest&search_string=" +
        encodeURIComponent(name)
    );
}

function openTab(url) {
    if (typeof GM_openInTab === "function") {
        GM_openInTab(url, { active: false, insert: true, setParent: true });
        return;
    }
    window.open(url, "_blank", "noopener,noreferrer");
}

function ensureTradingPostIconUnderImage(imgEl) {
    const $img = $(imgEl);
    const name = ($img.attr("alt") || "").trim();
    if (!name || name === "No Stamp") return;

    const $td = $img.closest("td");
    if (!$td.length) return;

    if ($td.find(".idb-tp-under").length) return;

    const tpIcon = "http://images.neopets.com/themes/h5/basic/images/tradingpost-icon.png";
    const tpUrl = tpExactUrlForName(name);

    const $a = $(`<a class="idb-tp-under" target="_blank" tabindex="-1" title="Search Trading Post (Exact)"></a>`);
    $a.attr("href", tpUrl);
    $a.append(`<img src="${tpIcon}" alt="Trading Post">`);

    $img.after($a);
}

function addTradingPostIconsUnderImages() {
    $(STAMP_IMG_SEL).each(function () {
        ensureTradingPostIconUnderImage(this);
    });
}

/****************************************************************************************
 * Replace images for current album view
 ****************************************************************************************/
function replaceImages() {
    let infoContent = {};
    let totalNeeded = 0;

    $(STAMP_IMG_SEL).each(function (index, element) {
        let position, itemData;
        let name, rarity, img;

        if (thisPage[index + 1]) {
            ({ position, itemData } = thisPage[index + 1]);
            const obj = itemData || {};
            name = obj.name;
            rarity = obj.rarity;
            img = obj.image;
        } else {
            position = index + 1;
            name = "No Stamp";
            rarity = "r0";
            img = "http://images.neopets.com/items/stamp_blank.gif";
        }

        $(element).attr("position", position).attr("rarity", rarity);

        if ($(element).attr("alt") === "No Stamp" && name !== "No Stamp") {
            $(element)
                .addClass("fake-stamp")
                .attr("title", name)
                .attr("src", img)
                .attr("alt", name)
                .attr("rarity", rarity);

            if (itemData && itemData.price && itemData.price.value) {
                totalNeeded += itemData.price.value;
            }
        }

        infoContent[position] = createInfoContent(element, itemData);

        $(element).on("click", function () {
            $(".stamp-info").html(infoContent[position]).show();
            $(".content table td").removeClass("stamp-selected");
            $(element).parent().addClass("stamp-selected");
        });

        if (hasPremium && name !== "No Stamp") {
            $(element).on("dblclick", function () {
                sswopen(name);
            });
        }

        ensureTradingPostIconUnderImage(element);
    });

    $(".content center:last-of-type").after(
        `<p></p><center>You would need <b>${format(totalNeeded)} NP</b> to complete this album at this time</center>`
    );
}

/****************************************************************************************
 * Buttons (plus start album-link counts once)
 ****************************************************************************************/
let albumCountsStarted = false;

function addCopyButtons() {
    if ($("#copy-missing-btn").length) return;

    $(".content").prepend(`
    <div class="idb-copy-bar">
      <button id="copy-missing-btn">? Copy Missing Stamps (This Album)</button>
      <button id="auto-copy-missing-btn">? Auto Copy Missing (All Albums)</button>
      <button id="auto-copy-names-only-btn">? Auto Copy Missing (Names Only)</button>
      <button id="open-missing-tp-btn">? Open TP Searches (Missing on Page)</button>
      <button id="open-all-missing-tp-btn">? Open TP Searches (Missing in ALL Albums)</button>
      <span id="auto-copy-status"></span>
    </div>
  `);

    $("#copy-missing-btn").on("click", () => {
        const names = getCurrentAlbumMissingNamesFromDOM();
        if (names.length) {
            GM_setClipboard(names.join("\n"), { type: "text" });
            alert(`Copied ${names.length} missing stamps from this album.`);
        } else {
            alert("No missing stamps found on this album.");
        }
    });

    $("#open-missing-tp-btn").on("click", async () => {
        const names = getCurrentAlbumMissingNamesFromDOM();
        const uniq = Array.from(new Set(names));

        if (!uniq.length) {
            alert("No missing stamps found on this page.");
            return;
        }

        $("#open-missing-tp-btn").prop("disabled", true);
        $("#auto-copy-status").text(`Opening ${uniq.length} Trading Post searches...`);

        try {
            for (let i = 0; i < uniq.length; i++) {
                const name = uniq[i];
                openTab(tpExactUrlForName(name));
                $("#auto-copy-status").text(`Opening ${i + 1}/${uniq.length}: ${name}`);
                await sleep(150);
            }

            $("#auto-copy-status").text(`Done. Opened ${uniq.length} Trading Post searches.`);
        } catch (e) {
            console.error(e);
            $("#auto-copy-status").text(`Error opening TP tabs: ${e.message || e}`);
            alert(`Error opening TP tabs: ${e.message || e}`);
        } finally {
            $("#open-missing-tp-btn").prop("disabled", false);
        }
    });

    $("#open-all-missing-tp-btn").on("click", async () => {
        $("#open-all-missing-tp-btn").prop("disabled", true);
        $("#auto-copy-status").text("Building missing list across all albums...");

        try {
            const { albumList } = discoverAlbumsOnPage();
            if (!albumList.length) throw new Error("No album links found in the menu bar.");

            const missingSet = new Set();
            let processed = 0;

            for (const entry of albumList) {
                processed++;

                // Album 0 has no ItemDB mapping, so we cannot reliably get names to open TP searches
                if (!entry.id || entry.id <= 0) {
                    $("#auto-copy-status").text(`Skipping ${entry.name} (id 0): cannot resolve names for TP searches.`);
                    continue;
                }

                $("#auto-copy-status").text(`Scanning ${processed}/${albumList.length}: ${entry.name}`);

                let html;
                try {
                    html = await gmGet(absoluteAlbumUrl(entry.id));
                } catch (err) {
                    console.warn("[open-all-tp] Failed to fetch album HTML for", entry.id, entry.name, err);
                    continue;
                }

                const doc = new DOMParser().parseFromString(html, "text/html");

                let api;
                try {
                    api = await gmGetJson("https://itemdb.com.br/api/v1/tools/album-helper?album_id=" + entry.id);
                } catch (err) {
                    console.warn("[open-all-tp] Failed to fetch ItemDB data for album", entry.id, entry.name, err);
                    continue;
                }

                const missingForAlbum = extractMissingByComparing(doc, api);
                for (const itemName of missingForAlbum) {
                    missingSet.add(itemName);
                }

                await sleep(250);
            }

            const allMissing = Array.from(missingSet);
            if (!allMissing.length) {
                $("#auto-copy-status").text("Done. No missing stamps found across albums (or ItemDB failed).");
                alert("No missing stamps found across albums.");
                return;
            }

            const maxStr = prompt(
                `Found ${allMissing.length} missing stamps across albums.\n\nHow many Trading Post tabs should I open?\nEnter 0 for ALL.`,
                "0"
            );

            if (maxStr === null) {
                $("#auto-copy-status").text("Cancelled.");
                return;
            }

            let maxTabs = parseInt(String(maxStr).trim(), 10);
            if (!Number.isFinite(maxTabs) || maxTabs < 0) maxTabs = 0;

            const toOpen = maxTabs > 0 ? allMissing.slice(0, maxTabs) : allMissing.slice();

            const ok = confirm(
                `This will open ${toOpen.length} Trading Post tabs.\n\nBatching: ${OPEN_ALL_TP_BATCH_SIZE} tabs, wait ${Math.round(OPEN_ALL_TP_BATCH_WAIT_MS / 1000)} seconds, repeat.\n\nContinue?`
            );
            if (!ok) {
                $("#auto-copy-status").text("Cancelled.");
                return;
            }

            $("#auto-copy-status").text(`Opening ${toOpen.length} Trading Post searches in batches...`);

            let opened = 0;
            let batchNum = 0;

            while (opened < toOpen.length) {
                batchNum++;
                const batchStart = opened;
                const batchEnd = Math.min(opened + OPEN_ALL_TP_BATCH_SIZE, toOpen.length);
                const batchCount = batchEnd - batchStart;

                $("#auto-copy-status").text(
                    `Batch ${batchNum}: opening ${batchCount} tabs (${batchStart + 1}-${batchEnd} of ${toOpen.length})...`
                );

                for (let i = batchStart; i < batchEnd; i++) {
                    const name = toOpen[i];
                    openTab(tpExactUrlForName(name));
                    opened++;
                    await sleep(150);
                }

                if (opened < toOpen.length) {
                    await countdownStatus(
                        Math.round(OPEN_ALL_TP_BATCH_WAIT_MS / 1000),
                        `Batch ${batchNum} done. ${opened}/${toOpen.length} opened.`
                    );
                }
            }

            $("#auto-copy-status").text(`Done. Opened ${toOpen.length} Trading Post searches (all albums).`);
            alert(`Done.\nOpened: ${toOpen.length}\nTotal missing found: ${allMissing.length}`);
        } catch (e) {
            console.error(e);
            alert(`Open-all TP failed: ${e.message || e}`);
            $("#auto-copy-status").text(`Error: ${e.message || e}`);
        } finally {
            $("#open-all-missing-tp-btn").prop("disabled", false);
        }
    });

    // Existing: copies "Album Name | Item Name"
    $("#auto-copy-missing-btn").on("click", async () => {
        $("#auto-copy-missing-btn").prop("disabled", true);
        $("#auto-copy-status").text("Starting auto scan across albums...");

        try {
            const { albumList } = discoverAlbumsOnPage();
            if (!albumList.length) throw new Error("No album links found in the menu bar.");

            const allMissing = [];
            let processed = 0;

            for (const entry of albumList) {
                processed++;

                if (!entry.id || entry.id <= 0) {
                    $("#auto-copy-status").text("Skipping Front Page (id 0), no ItemDB data.");
                    continue;
                }

                $("#auto-copy-status").text(`Processing ${processed}/${albumList.length}: ${entry.name}`);

                let html;
                try {
                    html = await gmGet(absoluteAlbumUrl(entry.id));
                } catch (err) {
                    console.warn("[auto-copy] Failed to fetch album HTML for", entry.id, entry.name, err);
                    continue;
                }

                const doc = new DOMParser().parseFromString(html, "text/html");

                let api;
                try {
                    api = await gmGetJson("https://itemdb.com.br/api/v1/tools/album-helper?album_id=" + entry.id);
                } catch (err) {
                    console.warn("[auto-copy] Failed to fetch ItemDB data for album", entry.id, entry.name, err);
                    continue;
                }

                const missingForAlbum = extractMissingByComparing(doc, api);
                missingForAlbum.forEach(itemName => {
                    allMissing.push(`${entry.name} | ${itemName}`);
                });

                await sleep(350);
            }

            if (allMissing.length) {
                GM_setClipboard(allMissing.join("\n"), { type: "text" });
                $("#auto-copy-status").text(
                    `Done. Copied ${allMissing.length} missing items from ${albumList.length} albums.`
                );
                alert(`Auto-copy complete.\nAlbums scanned: ${albumList.length}\nMissing items copied: ${allMissing.length}`);
            } else {
                $("#auto-copy-status").text(`Done. No missing items found across ${albumList.length} albums.`);
                alert("Auto-copy complete. No missing items found.");
            }
        } catch (e) {
            console.error(e);
            alert(`Auto-copy failed: ${e.message || e}`);
            $("#auto-copy-status").text(`Error: ${e.message || e}`);
        } finally {
            $("#auto-copy-missing-btn").prop("disabled", false);
        }
    });

    // NEW: copies ONLY item names (unique), one per line
    $("#auto-copy-names-only-btn").on("click", async () => {
        $("#auto-copy-names-only-btn").prop("disabled", true);
        $("#auto-copy-status").text("Starting auto scan across albums (names only)...");

        try {
            const { albumList } = discoverAlbumsOnPage();
            if (!albumList.length) throw new Error("No album links found in the menu bar.");

            const nameSet = new Set();
            let processed = 0;

            for (const entry of albumList) {
                processed++;

                if (!entry.id || entry.id <= 0) {
                    $("#auto-copy-status").text("Skipping Front Page (id 0), no ItemDB data.");
                    continue;
                }

                $("#auto-copy-status").text(`Processing ${processed}/${albumList.length}: ${entry.name}`);

                let html;
                try {
                    html = await gmGet(absoluteAlbumUrl(entry.id));
                } catch (err) {
                    console.warn("[auto-copy-names-only] Failed to fetch album HTML for", entry.id, entry.name, err);
                    continue;
                }

                const doc = new DOMParser().parseFromString(html, "text/html");

                let api;
                try {
                    api = await gmGetJson("https://itemdb.com.br/api/v1/tools/album-helper?album_id=" + entry.id);
                } catch (err) {
                    console.warn("[auto-copy-names-only] Failed to fetch ItemDB data for album", entry.id, entry.name, err);
                    continue;
                }

                const missingForAlbum = extractMissingByComparing(doc, api);
                missingForAlbum.forEach(itemName => {
                    if (itemName && itemName !== "No Stamp") nameSet.add(itemName);
                });

                await sleep(350);
            }

            const namesOnly = Array.from(nameSet);

            if (namesOnly.length) {
                GM_setClipboard(namesOnly.join("\n"), { type: "text" });
                $("#auto-copy-status").text(
                    `Done. Copied ${namesOnly.length} missing stamp names (unique) from ${albumList.length} albums.`
                );
                alert(
                    `Auto-copy (names only) complete.\nAlbums scanned: ${albumList.length}\nUnique names copied: ${namesOnly.length}`
                );
            } else {
                $("#auto-copy-status").text(`Done. No missing items found across ${albumList.length} albums.`);
                alert("Auto-copy (names only) complete. No missing items found.");
            }
        } catch (e) {
            console.error(e);
            alert(`Auto-copy (names only) failed: ${e.message || e}`);
            $("#auto-copy-status").text(`Error: ${e.message || e}`);
        } finally {
            $("#auto-copy-names-only-btn").prop("disabled", false);
        }
    });

    if (!albumCountsStarted) {
        albumCountsStarted = true;
        initAlbumLinkMissingCounts().catch(err => console.warn("[album-counts] init failed", err));
    }
}

function getCurrentAlbumMissingNamesFromDOM() {
    const names = [];
    $(`${STAMP_IMG_SEL}.fake-stamp`).each(function () {
        const name = $(this).attr("alt");
        if (name && name !== "No Stamp") names.push(name);
    });
    return names;
}

/****************************************************************************************
 * Album link missing counts
 ****************************************************************************************/
function stripTrailingCount(name) {
    return (name || "")
        .replace(/\s*\(\s*\d+\s*\)\s*$/g, "")
        .replace(/\s*\(\s*na\s*\)\s*$/gi, "")
        .trim();
}

function setAlbumLinkCount(aEl, count) {
    const $a = $(aEl);
    const $b = $a.find("b").first();

    const countText = typeof count === "number" ? String(count) : "NA";
    const cls = typeof count === "number" && count === 0 ? "idb-album-missing-count idb-zero" : "idb-album-missing-count";

    if ($b.length) {
        const base = stripTrailingCount($b.text());
        $b.text(base);

        const existing = $a.find(".idb-album-missing-count").first();
        if (existing.length) {
            existing.text(countText).attr("class", cls);
        } else {
            $b.after(` <span class="${cls}">${countText}</span>`);
        }
    } else {
        const base = stripTrailingCount($a.text());
        $a.text(base + " ");
        const existing = $a.find(".idb-album-missing-count").first();
        if (existing.length) {
            existing.text(countText).attr("class", cls);
        } else {
            $a.append(`<span class="${cls}">${countText}</span>`);
        }
    }
}

function getAlbumLinkElements() {
    const links = Array.from(document.querySelectorAll('a[href*="stamps.phtml?type=album"][href*="page_id="]'));
    const seen = new Set();
    const out = [];

    for (const a of links) {
        const href = a.getAttribute("href") || "";
        const match = href.match(/page_id=(\d+)/);
        if (!match) continue;
        const id = Number(match[1]);
        if (seen.has(id)) continue;
        seen.add(id);

        const b = a.querySelector("b");
        const name = stripTrailingCount((b ? b.textContent : a.textContent) || "");

        out.push({ id, name, el: a });
    }

    out.sort((x, y) => x.id - y.id);
    return out;
}

async function missingCountFromHtmlOnly(albumUrl) {
    const html = await gmGet(albumUrl);
    const doc = new DOMParser().parseFromString(html, "text/html");
    return doc.querySelectorAll('.content table td > img[alt="No Stamp"]').length;
}

async function missingCountFromItemDb(albumId) {
    const albumUrl = absoluteAlbumUrl(albumId);
    let html = null;

    try {
        html = await gmGet(albumUrl);
    } catch (e) {
        console.warn("[album-counts] html fetch failed for", albumId, e);
        return null;
    }

    const doc = new DOMParser().parseFromString(html, "text/html");

    try {
        const api = await gmGetJson("https://itemdb.com.br/api/v1/tools/album-helper?album_id=" + albumId);
        const missing = extractMissingByComparing(doc, api);
        return missing.length;
    } catch (e) {
        console.warn("[album-counts] ItemDB failed for", albumId, e);
        try {
            return doc.querySelectorAll('.content table td > img[alt="No Stamp"]').length;
        } catch {
            return null;
        }
    }
}

async function initAlbumLinkMissingCounts() {
    const albumLinks = getAlbumLinkElements();
    if (!albumLinks.length) return;

    const cached = loadAlbumCountCache();
    const counts = cached && cached.counts ? { ...cached.counts } : {};

    for (const entry of albumLinks) {
        if (counts[String(entry.id)] !== undefined) {
            setAlbumLinkCount(entry.el, counts[String(entry.id)]);
        }
    }

    $("#auto-copy-status").text("Updating album missing counts...");

    for (let i = 0; i < albumLinks.length; i++) {
        const { id, name, el } = albumLinks[i];
        const key = String(id);

        if (counts[key] !== undefined) continue;

        $("#auto-copy-status").text(`Counting ${i + 1}/${albumLinks.length}: ${name}`);

        let c = null;

        if (id === 0) {
            try {
                c = await missingCountFromHtmlOnly(absoluteAlbumUrl(0));
            } catch {
                c = null;
            }
        } else {
            c = await missingCountFromItemDb(id);
        }

        if (typeof c === "number" && Number.isFinite(c)) {
            counts[key] = c;
            setAlbumLinkCount(el, c);
            saveAlbumCountCache(counts);
        } else {
            counts[key] = "NA";
            setAlbumLinkCount(el, "NA");
            saveAlbumCountCache(counts);
        }

        await sleep(250);
    }

    $("#auto-copy-status").text("Album missing counts updated.");
}

/****************************************************************************************
 * Album discovery and per album extraction for auto mode
 ****************************************************************************************/
function discoverAlbumsOnPage() {
    const links = Array.from(document.querySelectorAll('a[href*="stamps.phtml?type=album"][href*="page_id="]'));

    const seen = new Set();
    const albumList = [];

    for (const a of links) {
        const href = a.getAttribute("href") || "";
        const match = href.match(/page_id=(\d+)/);
        if (!match) continue;

        const id = Number(match[1]);
        if (seen.has(id)) continue;
        seen.add(id);

        const b = a.querySelector("b");
        const rawName = (b ? b.textContent : a.textContent) || "";
        const name = stripTrailingCount(rawName.trim().replace(/\s+/g, " "));

        albumList.push({ id, name, href });
    }

    if (!albumList.length) {
        for (let i = 1; i <= 49; i++) {
            albumList.push({
                id: i,
                name: `Album ${i}`,
                href: `/stamps.phtml?type=album&page_id=${i}` + (owner ? `&owner=${owner}` : "")
            });
        }
    }

    albumList.sort((a, b) => a.id - b.id);
    return { albumList };
}

function absoluteAlbumUrl(id) {
    const base = location.origin;
    const q = `type=album&page_id=${id}` + (owner ? `&owner=${owner}` : "");
    return `${base}/stamps.phtml?${q}`;
}

function extractMissingByComparing(doc, apiJson) {
    const imgs = Array.from(doc.querySelectorAll(".content table td > img"));
    const missing = [];

    imgs.forEach((img, idx) => {
        const position = idx + 1;
        const alt = img.getAttribute("alt") || "";
        const apiEntry = apiJson[position];
        const itemData = apiEntry && apiEntry.itemData ? apiEntry.itemData : null;

        if (alt === "No Stamp" && itemData && itemData.name && itemData.name !== "No Stamp") {
            missing.push(itemData.name);
        }
    });

    return missing;
}

/****************************************************************************************
 * Original info panel builder
 ****************************************************************************************/
function createInfoContent(imgElement, itemData) {
    const $img = $(imgElement),
        src = $img.attr("src"),
        stampName = $img.attr("alt"),
        position = $img.attr("position"),
        rarity = $img.attr("rarity");

    if (stampName === "No Stamp") {
        return `
<br>
<table class="stamp-info-table">
    <tr>
        <td class="stamp-info-arrow prev-arrow" rowspan="5"><img alt="Previous" src="http://images.neopets.com/themes/h5/premium/images/arrow-left.svg" style="width: 20px"></td>
        <td rowspan="5" style="width: 30%; text-align: center;"><img src="${src}"></td>
        <td style="text-align: center; font-weight: bold; padding: 12px;">${stampName}</td>
        <td class="stamp-info-arrow next-arrow" rowspan="5"><img alt="Next" src="http://images.neopets.com/themes/h5/premium/images/arrow-right.svg" style="width: 20px"></td>
    </tr>
    <tr>
        <td>Position: <b id="current-stamp-pos">${position}</b></td>
    </tr>
    <tr>
        <td>This stamp has not been released yet.</td>
    </tr>
    <tr>
        <td></td>
    </tr>
    <tr>
        <td style="text-align: center;"></td>
    </tr>
</table>
        `;
    }

    const hasStamp = $img.hasClass("fake-stamp") === false;
    const hasStampText = `Status: ${hasStamp ? '<b style="color: green">Collected!</b>' : '<b style="color: red">Not collected</b>'}`;

    const rarityText = r => {
        const rNum = parseInt(r.replace(/r/, ""), 10);
        if (rNum <= 74) return "r" + r;
        else if (rNum >= 75 && rNum <= 84) return `<strong style="color:green">r${r} (uncommon)</strong>`;
        else if (rNum >= 85 && rNum <= 89) return `<strong style="color:green">r${r} (rare)</strong>`;
        else if (rNum >= 90 && rNum <= 94) return `<strong style="color:green">r${r} (very rare)</strong>`;
        else if ((rNum >= 95 && rNum <= 98) || rNum === 100) return `<strong style="color:green">r${r} (ultra rare)</strong>`;
        else if (rNum === 99) return `<strong style="color:green">r${r} (super rare)</strong>`;
        else if (rNum >= 101 && rNum <= 104) return `<strong style="color:#aa4455">r${r} (special)</strong>`;
        else if (rNum >= 105 && rNum <= 110) return `<strong style="color:red">r${r} (MEGA RARE)</strong>`;
        else if (rNum >= 111 && rNum <= 179) return `<strong style="color:red">r${r} (RARITY ${rNum})</strong>`;
        else if (rNum === 180) return `<strong style="color:#666666">r${r} (retired)</strong>`;
        else if (rNum === 200) return `<strong style="color:red">r${r} (Artifact - 200)</strong>`;
        return `r${r}`;
    };

    const createHelper = itemName => {
        const linkmap = {
            ssw: { img: "http://images.neopets.com/premium/shopwizard/ssw-icon.svg" },
            sw: { url: "http://www.neopets.com/shops/wizard.phtml?string=%s", img: "http://images.neopets.com/themes/h5/basic/images/shopwizard-icon.png" },
            tp: { url: "http://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact&sort=newest&search_string=%s", img: "http://images.neopets.com/themes/h5/basic/images/tradingpost-icon.png" },
            au: { url: "http://www.neopets.com/genie.phtml?type=process_genie&criteria=exact&auctiongenie=%s", img: "http://images.neopets.com/themes/h5/basic/images/auction-icon.png" },
            sdb: { url: "http://www.neopets.com/safetydeposit.phtml?obj_name=%s&category=0", img: "http://images.neopets.com/images/emptydepositbox.gif" },
            jni: { url: "https://items.jellyneo.net/search/?name=%s&name_type=3", img: "http://images.neopets.com/items/toy_plushie_negg_fish.gif" },
            idb: { url: "https://itemdb.com.br/item/%s", img: "https://itemdb.com.br/favicon.svg" }
        };

        const slugify = text => {
            return text
                .toString()
                .normalize("NFD")
                .replace(/[\u0300-\u036f]/g, "")
                .toLowerCase()
                .trim()
                .replace(/\s+/g, "-")
                .replace(/[^\w-]+/g, "")
                .replace(/-{2,}/g, "-");
        };

        const combiner = (item, url, image) => {
            url = url.replace("%s", item);
            return `<a tabindex='-1' target='_blank' href='${url}'><img src='${image}' class='searchimg'></a>`;
        };

        const sswhelper = item => {
            if (!hasPremium) return "";
            return `<img item="${item}" class="stamp-ssw-helper searchimg" src="${linkmap.ssw.img}">`;
        };

        return `<span class="search-helper">
            ${sswhelper(itemName)}
            ${combiner(itemName, linkmap.sw.url, linkmap.sw.img)}
            ${combiner(itemName, linkmap.tp.url, linkmap.tp.img)}
            ${combiner(itemName, linkmap.au.url, linkmap.au.img)}
            ${combiner(itemName, linkmap.sdb.url, linkmap.sdb.img)}
            ${combiner(itemName, linkmap.jni.url, linkmap.jni.img)}
            ${combiner(slugify(itemName), linkmap.idb.url, linkmap.idb.img)}
        </span>`;
    };

    const slug = itemData && itemData.slug ? itemData.slug : "";
    const priceObj = itemData && itemData.price ? itemData.price : null;
    const priceText = priceObj && priceObj.value ? format(priceObj.value) + " NP" : "Unknown";
    const inflatedWarn = priceObj && priceObj.inflated ? "⚠️" : "";

    return `<br>
<table class="stamp-info-table" item="${stampName}">
    <tr>
        <td class="stamp-info-arrow prev-arrow" rowspan="5"><img alt="Previous" src="http://images.neopets.com/themes/h5/premium/images/arrow-left.svg" style="width: 20px"></td>
        <td rowspan="5" style="width: 30%; text-align: center;"><img src="${src}"></td>
        <td style="text-align: center; font-weight: bold; padding: 12px;">${stampName}<br>${rarityText(rarity)}</td>
        <td></td>
        <td class="stamp-info-arrow next-arrow" rowspan="5"><img alt="Next" src="http://images.neopets.com/themes/h5/premium/images/arrow-right.svg" style="width: 20px"></td>
    </tr>
    <tr>
        <td>Position: <b id="current-stamp-pos">${position}</b></td>
    </tr>
    <tr>
        <td>Price: ${slug ? `<a href="https://itemdb.com.br/item/${slug}" target="_blank"><b>${inflatedWarn}${priceText}</b></a>` : `<b>${inflatedWarn}${priceText}</b>`}</td>
    </tr>
    <tr>
        <td>${hasStampText}</td>
    </tr>
    <tr>
        <td style="text-align: center; padding: 16px 6px;">${createHelper(stampName)}</td>
    </tr>
</table>
    `;
}

/****************************************************************************************
 * Info panel shell and extra links
 ****************************************************************************************/
$(".content table").after(`<p class="stamp-info"></p>`);

if (hasPremium) {
    $(".content table").before(
        `<p style="text-align: center; font-style: italic; color: green; font-weight: bold">Double-click the stamp to search it<br>on the Super Shop Wizard!</p>`
    );
}

if (albumID > 0) {
    const idbLogo = `<img src="https://itemdb.com.br/favicon.svg" style="width: 30px; height: 30px; vertical-align: middle;">`;
    $(".content").append(
        `<p style="text-align: center;"><a href="https://itemdb.com.br/api/v1/tools/album-helper?album_id=${albumID}&redirect=true" target="_blank">${idbLogo}&nbsp;Album info&nbsp;${idbLogo}</a></p>`
    );
}

/****************************************************************************************
 * SSW icon
 ****************************************************************************************/
$("body").on("click", ".stamp-ssw-helper", function () {
    const item = $(this).attr("item");
    sswopen(item);
});

function sswopen(item) {
    if ($(".sswdrop").hasClass("panel_hidden")) {
        $("#sswmenu .imgmenu").click();
    }
    if ($("#ssw-tabs-1").hasClass("ui-tabs-hide")) {
        $("#button-new-search").click();
    }

    $("#price-limited").prop("checked", true);
    $("#price-limited")[0].checked = true;

    $("#ssw-criteria").val("exact");
    $("#searchstr").val(item);
    document.querySelector("#button-search").click();
    document.querySelector("#ssw-button-search").click();
    $("#button-new-search").click();
}

/****************************************************************************************
 * Stamp prev and next arrows
 ****************************************************************************************/
$("body").on("click", ".stamp-info-arrow", function () {
    const isNext = $(this).hasClass("next-arrow");
    const isPrev = $(this).hasClass("prev-arrow");

    const position = parseInt($("#current-stamp-pos").html(), 10);

    const newPosition = (function () {
        if (position === 25 && isNext) return 1;
        else if (position === 1 && isPrev) return 25;
        else if (isNext) return position + 1;
        else if (isPrev) return position - 1;
        return position;
    })();

    $(`img[position='${newPosition}']`).click();
});