Book Lister

Grays out read books in Inventory, SDB, Galleries, Trading Post, and Auctions for active pet.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Book Lister
// @namespace    http://tampermonkey.net/
// @version      2024-10-26
// @description  Grays out read books in Inventory, SDB, Galleries, Trading Post, and Auctions for active pet.
// @author       You
// @match        https://www.neopets.com/books_read.phtml?pet_name=*
// @match        https://www.neopets.com/moon/books_read.phtml?pet_name=*
// @match        https://www.neopets.com/safetydeposit.phtml*
// @match        https://www.neopets.com/inventory.phtml
// @match        https://www.neopets.com/gallery/index.phtml*
// @match        https://www.neopets.com/auctions.phtml*
// @match        https://www.neopets.com/genie.phtml
// @match        https://www.neopets.com/island/tradingpost.phtml*
// @match        https://raw.githubusercontent.com/mtkwock/Neopets-UserScripts/refs/heads/main/all_books.json*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=neopets.com
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

const CONFIG = {
    // How to mark books that are read
    lowlight: {
        background: 'gray',
        opacity: '50%',
        border: '',
    },
    // How to mark books that are unread
    highlight: {
        background: 'yellow',
        opacity: '',
        border: '',
    },

    // Comment out to remove graying/highlighting of locations.
    enabledLocations: [
        '/books_read.phtml', // Required for getting read books.
        '/moon/books_read.phtml', // Required for Booktastic Books
        '/mtkwock/Neopets-UserScripts/refs/heads/main/all_books.json', // Required for getting unread books

        '/safetydeposit.phtml', // Safety Deposit Box
        '/inventory.phtml', // Player Inventory
        '/gallery/index.phtml', // Player Galleries
        '/auctions.phtml', // Auction House
        '/genie.phtml', // Auction Genie
        '/island/tradingpost.phtml', // Trading Post
    ],
};

// Differentiators for the different types of books.
const BookSet = {
    read: 'READ',
    unread: 'UNREAD',
    readTastic: 'READT',
    unreadTastic: 'UNREADT', // Notably, this is calculated as a delta from (ALL booktastic) minus (readTastic)
};

function keyFor(petName, bookSet=BookSet.read) {
    return `${bookSet}_${petName}`;
}

// Since some pictures are actually divs with backgroundImage set to something.
function extractTasticSrc(src) {
    if (src.endsWith('")')) {
        src = src.substring(0, src.length - 2);
    }
    return src.match(/([^\/]*)\.\w+$/)[1];
}

function extractTastic(img) {
    return extractTasticSrc(img.src);
}

function getActivePetName() {
    // For more modern layouts, this is a more consistent way of getting Pet Name.
    const modern = document.getElementsByClassName('profile-dropdown-link')[0];
    if (modern) { return modern.innerText; }

    // For classic layouts like SDB.
    const quickrefA = [...document.getElementsByTagName('a')].filter((a) => a.href == "https://www.neopets.com/quickref.phtml" )[0].innerText;
    return quickrefA.trim();
}

async function getPetBooks(petName, set) {
    if (set == BookSet.unreadTastic) {
        // Only 180 Booktastic Books so we'll hardcode it...
        const unreadTastic = new Set([
            "boo_10001_chocolate_cake_recip", "bvb_101stalebread", "boo_tyrannian_omelettes", "boo_orangegrundo2", "bvb_4millionpierecipes", "bbo_ace_adventures", "bbo_achyfi_recipes", "bbo_book6", "bbo_book2", "book_stamps",
            "boo_slothy_recipies","boo_y21haltot_book_grundo_lit","bbo_antigravity_games","bbo_astro_beginner","bbo_krelattack","boo_avocado_cook","boo_orangegrundo1","bbo_book5","boo_bbbcbtbahtjtbbbboiywt","bbo_beverage_recipes",
            "bbo_beyond_neopia","bbo_binarystars_andyou","boo_ufo_book","bbo_bbookvalueguide","bbo_boxofbolts","bbo_build_yourown_tele","bbo_building_tech","bbo_collmoonrock","bbo_computing","bbo_constspot",
            "bbo_crater_dwellings","bbo_cratersofkreludor","bbo_creatures_afar","boo_y17haltot_insdkrldnmines","bbo_defendyour","boo_defenders_comic","bbo_defend_neohome","bbo_derlyn_bio","bbo_diary_babyspacefungus","bbo_moon_bounce",
            "bbo_pushbutton","bbo_easydecor_lowgravitysit","boo_elephante_fashion","bbo_shuttle_procedure","boo_freaky_handbook","bbo_galadv","bbo_galactic_adventures","bbo_gamesonkreludor","bbo_organized","bbo_grundo_beauty",
            "bbo_phrases_grundo","bbo_grundo_brainwash","bbo_grundos_inspace","bbo_guide_orangefurn","book_stargazingconstellations","bbo_krel_neocola","bbo_halfmoon","bbo_book12","book_ucmaraq_skeith","bbo_earn_np_easy",
            "bbo_howtoescape","book_ucmaraq_hissi","boo_howl","bbo_meteorite_identify","boo_insignificantquests","bbo_book4","bbo_book3","bbo_from_the_mine","bbo_noteasybeingpurple","bbo_book8",
            "bbo_kreating_angles","bbo_kreluberry","bbo_kreludor_architecture","bbo_krelbedtime","kbo_christmas_carols", "bbo_krelcookies","bbo_cookie_book","bbo_kreludan_crossword","bbo_krelencyc","bbo_book7",
            "bbo_krelfacts","bbo_krelfash","bbo_krelgames","bbo_home_decor","bbo_kreludan_life","boo_space_mystery","bbo_krel_physics","bbo_krel_poems","boo_orangegrundo4","bbo_krel_sundaes",
            "bbo_kreludorcolour","bbo_lb_corridormaps","bbo_kreludor_budget","bbo_kreludorrainyday_activity","bbo_book10","bbo_kreludor_places_intrest","bbo_lazerenergy","bbo_krel_learning","bbo_lennies_space","boo_lightspeed",
            "bbo_lowgravity_rock","bvb_madeofstars","bbo_krel_maths","bbo_shower_protection","bbo_meteor_sites","bbo_meteorite_craftbk","boo_mind_control_dummies","bbo_minoptechmanual","bbo_moonmissions","bbo_rock_formations",
            "boo_moonotonous","bbo_memory_dr_sloth_quote","bbo_alienaisha_mythandlegend","bbo_neocola_book","bbo_zerog","boo_nephrites_notes","bbo_nerkmid_values","boo_orangegrundo10","boo_orangegrundo6","boo_orangegrundo8",
            "bbo_lb_oxygenandyou","bbo_picnicing_onkreludor","boo_polarchuck_pastimes","bbo_book1","bbo_programming","bbo_purple_truth","boo_orangegrundo5","boo_returning_to_neopia","bbo_roboticcareand","bbo_robot_fashion",
            "bbo_lenny_repair","bbo_robot_rock","bbo_rbffandp","boo_defenderrobottellall","bbo_book11","boo_scoops_of_friendship_run","boo_secretsofsloth","bbo_universe_secrets","bbo_situational_gravity","bbo_slime_and_you",
            "bbo_exploration","bbo_spacefashion","book_gmc_space_fashion","bbo_spacerockencyclop","bbo_spacestationholi","bbo_station_schematics","bbo_lb_spyders","bbo_stairway","boo_stuck_in_space","bbo_lag_taleof",
            "bbo_bigintermediate_evilplots","bbo_thecode","boo_all_sloth","bbo_great_betrayal","bbo_book9","boo_world_domination","bbo_orange_happy","bbo_krelflag","boo_magic_prank","bbo_the_space_ace","boo_sunandyou",
            "boo_orangegrundo9","boo_timemanagement","bbo_scouting_tips","boo_orangegrundo3","bbo_underthesurface1","bbo_underthesurface2","boo_orangegrundo7","boo_virtupets_delivery_train","book_waleincare101","bbo_meteor_crash",
            "book_auroraborealis","boo_wintery_petpet_compan","bbo_wishstar","boo_outside_neopia","bbo_yesterdays_future","bbo_lb_zarexdiary","bbo_zenor_bio","bbo_zerogravity","bbo_grundo_zurroball"
        ]);
        const readString = await GM.getValue(keyFor(petName, BookSet.readTastic), '[]');
        const readTastic = JSON.parse(readString);
        for (const read of readTastic) {
            unreadTastic.delete(read);
        }
        return unreadTastic;
    }
    const readString = await GM.getValue(keyFor(petName, set), '[]');
    return new Set(JSON.parse(readString));
}

async function getActivePetBooks(set) {
    return getPetBooks(getActivePetName(), set);
}

function setPetBooks(pet, bookString, set, showAlert) {
    GM.setValue(keyFor(pet, set), bookString).then((resolve, reject) => {
        if (showAlert) {
            alert(`Read list updated for ${pet}`);
        }
    });
}

class ReadBooksHandler {
    constructor() {
        const tables = [...document.getElementById('content').getElementsByTagName('table')];
        const petEl = document.getElementsByTagName('center')[0];
        this.bookTable = tables.filter((tbl) => tbl.align == 'center')[0];
        this.isTastic = false;

        const p = document.createElement('p');
        const a = document.createElement('a');
        a.innerText = 'Get Unread Books from GitHub!';
        a.href = 'https://raw.githubusercontent.com/mtkwock/Neopets-UserScripts/refs/heads/main/all_books.json?petName=' + this.getPetName();
        a.target = '_blank';
        p.appendChild(a);
        petEl.appendChild(p);
    }

    getAllBookNames() {
        const descriptionTds = [...this.bookTable.getElementsByTagName('td')].filter((td, idx) => idx > 2 && idx % 2);
        return descriptionTds.map((td) => {
            const d = td.childNodes[0].data;
            return d.substring(0, d.length-5);
        });
    }

    getPetName() {
        const params = new URLSearchParams(window.location.search);
        return params.get('pet_name');
    }

    setUp() {
        const allBooks = this.getAllBookNames();
        const books = JSON.stringify(allBooks);
        const bookSet = this.isTastic ? BookSet.readTastic : BookSet.read;
        setPetBooks(this.getPetName(), books, bookSet);

        getPetBooks(this.getPetName(), BookSet.unread).then((unreadBooks) => {
            for (const readBook of allBooks) {
                unreadBooks.delete(readBook);
            }

            setPetBooks(this.getPetName(), JSON.stringify([...unreadBooks]), BookSet.unread);
        });
    }
}

class UnreadBooksHandler {
    constructor() {
        this.petName;
        this.pre = document.getElementsByTagName('pre')[0];
        this.allBooks = JSON.parse(this.pre.innerText);
        this.parent = this.pre.parentElement;
        this.pre.style.display = 'none';
        this.logArea = document.createElement('textarea');
        this.logArea.value = 'Logs';
        this.logArea.cols = 70;
        this.logArea.rows = 5;
        this.parent.appendChild(this.logArea);
        this.parent.appendChild(document.createElement('br'))
        this.petInput = document.createElement('input');
        this.petInput.placeholder = 'Pet name';
        const params = new URLSearchParams(window.location.search);
        const petName = params.get('petName');
        if (petName) {
            this.petInput.value = petName;
        }
        this.parsePetButton = document.createElement('button');
        this.parsePetButton.innerText = 'Store and show unread books';
        this.parent.appendChild(this.petInput);
        this.parent.appendChild(this.parsePetButton);

        this.unreadTable = document.createElement('table');
        this.parent.appendChild(this.unreadTable);
    }

    log(str) {
        this.logArea.value = str;
    }

    updateTable(remainingBooks) {
        for (const child of this.unreadTable.children) {
            this.unreadTable.removeChild(child);
        }
        remainingBooks.sort();
        const ROW_LENGTH = 5;
        for (let i = 0; i < remainingBooks.length; i += ROW_LENGTH) {
            const tr = document.createElement('tr');

            for (let j = 0; j < ROW_LENGTH; j++) {
                const book = remainingBooks[i + j];
                console.log(book)
                if (!book) {
                    break;
                }
                const td = document.createElement('td');
                const swAnchor = document.createElement('a');
                const nameDiv = document.createElement('span');
                nameDiv.innerText = book;
                swAnchor.innerText = ' SW ';
                const searchName = book.replace(/\s/g, '+').replace(/&/g, '%26');
                swAnchor.href = 'https://www.neopets.com/shops/wizard.phtml?string=' + searchName;
                swAnchor.target = '_blank';
                td.appendChild(swAnchor);
                td.appendChild(nameDiv);

                tr.appendChild(td);
            }
            this.unreadTable.appendChild(tr);
        }
    }

    setUp() {
        this.parsePetButton.onclick = async () => {
            const petName = this.petInput.value;
            const readBooks = await getPetBooks(petName, BookSet.read);
            console.log("Read Books Length", readBooks.size);
            if (!readBooks.size) {
                this.log('No pet data for ' + petName + ', not storing anything.\nCheck spelling? Did you visit read book page?\nhttps://www.neopets.com/books_read.phtml?pet_name=' + petName);
                return;
            }
            const unreadBooks = new Set(this.allBooks);
            for (const book of readBooks) {
                unreadBooks.delete(book);
            }
            const unreadString = JSON.stringify([...unreadBooks]);
            setPetBooks(petName, unreadString, BookSet.unread);
            this.updateTable([...unreadBooks]);
            this.log(`Stored ${unreadBooks.size} unread books for ${petName}`);
        };
        if (this.petInput.value) {
            this.parsePetButton.click();
        }
    }
}

class ReadTasticHandler extends ReadBooksHandler {
    constructor() {
        super();
        this.isTastic = true;
    }

    getAllBookNames() {
        const imgTds = [...this.bookTable.getElementsByTagName('td')].filter((td, idx) => idx > 1 && !(idx % 2));
        return imgTds.map((imgTd) => {
            const img = imgTd.getElementsByTagName('img')[0];
            return extractTastic(img);
        });
    }
}

class AbstractBookHighlighter {
    constructor(doCalc=true) {
        this.activePetBooks;
        this.activePetTastic;
        this.activePetUnread;
        this.activePetUnreadTastic;
        this.calc = doCalc;
    }

    setUp() {
        if (!this.calc) {
            return;
        }
        Promise.all([
            getActivePetBooks(BookSet.read),
            getActivePetBooks(BookSet.readTastic),
            getActivePetBooks(BookSet.unread),
            getActivePetBooks(BookSet.unreadTastic),
        ]).then(
            ([readBooks, readTastic, unread, unreadTastic]) => {
                this.activePetBooks = readBooks;
                this.activePetTastic = readTastic;
                this.activePetUnread = unread;
                this.activePetUnreadTastic = unreadTastic;
                this.highlightBooks();
            }
        );
    }

    async getBooks() { return []; }

    async highlightBooks() {
        const books = await this.getBooks();
        for (const {name, imgName, highlightLocation} of books) {
            if (this.activePetBooks.has(name) || this.activePetTastic.has(imgName)) {
                this.lowlight(highlightLocation);
            } else if (this.activePetUnread.has(name) || this.activePetUnreadTastic.has(imgName)) {
                this.highlight(highlightLocation);
            }
        }
    }

    lowlight(location) {
        if (CONFIG.lowlight.background) {
            location.style.background = CONFIG.lowlight.background;
        }
        if (CONFIG.lowlight.opacity) {
            location.style.opacity = CONFIG.lowlight.opacity;
        }
        if (CONFIG.lowlight.border) {
            location.style.border = CONFIG.lowlight.border;
        }
    }

    highlight(location) {
        if (CONFIG.highlight.background) {
            location.style.background = CONFIG.highlight.background;
        }
        if (CONFIG.highlight.opacity) {
            location.style.opacity = CONFIG.highlight.opacity;
        }
        if (CONFIG.highlight.border) {
            location.style.border = CONFIG.highlight.border;
        }
    }
}

class SdbBookHighlighter extends AbstractBookHighlighter {
    getBooks() {
        const allTrs = [...[...document.getElementById('content').getElementsByTagName('table')].filter((tbl) => tbl.cellPadding == '4')[0].getElementsByTagName('tr')];
        const trs = allTrs.slice(1, allTrs.length - 1);

        return trs.map((tr) => {
            const name = tr.children[1].children[0].innerText.split('(')[0].trim();
            const highlightLocation = tr.children[1];
            const imgName = extractTastic(tr.children[0].getElementsByTagName('img')[0]);
            return {name, highlightLocation, imgName};
        });
    }
}

class AuctionHighlighter extends AbstractBookHighlighter {
    getBooks() {
        const itemRows = [...document.getElementsByClassName('content')[0].getElementsByTagName('tr')].filter(tr => tr.children[0].innerText.match(/^\d+$/));

        return itemRows.map((row) => {
            return {
                name: row.children[2].innerText,
                imgName: extractTastic(row.children[1].getElementsByTagName('img')[0]),
                highlightLocation: row.children[2],
            };
        })
    }
}

class TradingPostHighlighter extends AbstractBookHighlighter {
    getBooks() {
        const itemTds = [...document.getElementsByTagName('td')].filter(td => td.firstChild && td.firstChild.tagName == 'IMG' && td.align == 'left');
        return itemTds.map((td) => {
            return {
                name: td.innerText.trim(),
                imgName: extractTastic(td.firstChild),
                highlightLocation: td,
            };
        });
    }
}

const BOOK_TYPES = new Set([
    'Book',
    'Booktastic Book',
    'Faerie Book',
    'Brightvale Books',
    'Moltaran Books',
    'Neovian Press',
    'Desert Scroll',
    'Qasalan Tablets',
]);

class InventoryHighlighter extends AbstractBookHighlighter {
    static isBook(gridItem) {
        return BOOK_TYPES.has(gridItem.children[0].getAttribute('data-itemtype'));
    }

    async getBooks() {
        let gridItems = [...document.getElementsByClassName('grid-item')];
        while(gridItems.length === 0) {
            await new Promise(resolve => setTimeout(resolve, 200));
            gridItems = [...document.getElementsByClassName('grid-item')];
        }
        const bookItems = gridItems.filter(InventoryHighlighter.isBook);
        return bookItems.map((bookItem) => {
            return {
                name: bookItem.getElementsByClassName('item-name')[0].innerText,
                imgName: extractTasticSrc(bookItem.children[0].style.backgroundImage),
                highlightLocation: bookItem,
            };
        });
    }
}

class GalleryHighlighter extends AbstractBookHighlighter {
    getBooks() {
        const form = document.getElementById('gallery_form');
        const items = [...form.getElementsByTagName('td')].filter(item => item.children.length > 1 && item.width == '140');
        return items.map((item) => {
            return {
                name: item.children[2].innerText,
                imgName: extractTastic(item.children[0]),
                highlightLocation: item,
            };
        });
    }
}

const pathToHandlers = {
    '/books_read.phtml': ReadBooksHandler,
    '/moon/books_read.phtml': ReadTasticHandler,
    '/mtkwock/Neopets-UserScripts/refs/heads/main/all_books.json': UnreadBooksHandler,

    '/safetydeposit.phtml': SdbBookHighlighter,
    '/auctions.phtml': AuctionHighlighter,
    '/genie.phtml': AuctionHighlighter,
    '/inventory.phtml': InventoryHighlighter,
    '/island/tradingpost.phtml': TradingPostHighlighter,
    '/gallery/index.phtml': GalleryHighlighter,
};

(function() {
    'use strict';
    const path = window.location.pathname;

    if (!CONFIG.enabledLocations.includes(path)) {
        console.log('Path not enabled');
        return;
    }

    if (path in pathToHandlers) {
        const handler = new pathToHandlers[path]();
        handler.setUp();
    } else {
        console.warn('Unhandled Path:', path);
    }
})();