Neopets: Alert and Post Notifications

Shows a native browser notification whenever you get an alert, random event, refresh your guild board and there's a new post, or refresh a topic and there's a new post.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Neopets: Alert and Post Notifications
// @namespace    https://github.com/saahphire/NeopetsUserscripts
// @version      1.0.0
// @description  Shows a native browser notification whenever you get an alert, random event, refresh your guild board and there's a new post, or refresh a topic and there's a new post.
// @author       saahphire
// @homepageURL  https://github.com/saahphire/NeopetsUserscripts
// @homepage     https://github.com/saahphire/NeopetsUserscripts
// @match        *://*.neopets.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=neopets.com
// @license      Unlicense
// @grant        GM.notification
// @grant        GM.setValue
// @grant        GM.getValue
// ==/UserScript==

// TODO cleanup

/*
•:•.•:•.•:•:•:•:•:•:•:••:•.•:•.•:•:•:•:•:•:•:•:•.•:•.•:•:•:•:•:•:•:••:•.•:•.•:•.•:•:•:•:•:•:•:•:•.•:•:•.•:•.••:•.•:•.••:
........................................................................................................................
☆ ⠂⠄⠄⠂⠁⠁⠂⠄⠄⠂✦ ⠂⠄⠄⠂⠁⠁⠂⠄⠄⠂☆ ⠂⠄⠄⠂⠁⠁⠂⠄⠄⠂✦ ⠂⠄⠄⠂⠁⠁⠂⠄⠂⠄⠄⠂☆ ⠂⠄⠄⠂⠁⠁⠂⠄⠄⠂✦ ⠂⠄⠄⠂⠁⠁⠂⠄⠂⠄⠄⠂☆ ⠂⠄⠄⠂⠁⠁⠂⠄⠄⠂✦
    This script does the following:
    - Adds native browser notifications for the following events (can be turned off individually):
        - Alerts (neomail, pound, trading post, etc)
        - Random events
        - New posts in your guild board
        - New posts in an open topic in the neoboards.
    There's an optional setting to turn off alerts for neomail from theneopetsteam, because it's usually spam.
    New posts are not automatically checked. You must refresh the page yourself. Anything else would be grounds for
    freezing. But at least now you can refresh from another tab without looking at it!

    Please note that there's no way to tell two alerts with the same name apart in the old layout. If you get an alert
    with a specific text, don't get a different alert or clear your alerts, and then get another with the same text,
    without visiting the beta layout for both alerts, you'll only be notified the first time.

    ✦ ⌇ saahphire
☆ ⠂⠄⠄⠂⠁⠁⠂⠄⠄⠂✦ ⠂⠄⠄⠂⠁⠁⠂⠄⠄⠂☆ ⠂⠄⠄⠂⠁⠁⠂⠄⠄⠂✦ ⠂⠄⠄⠂⠁⠁⠂⠄⠂⠄⠄⠂☆ ⠂⠄⠄⠂⠁⠁⠂⠄⠄⠂✦ ⠂⠄⠄⠂⠁⠁⠂⠄⠂⠄⠄⠂☆ ⠂⠄⠄⠂⠁⠁⠂⠄⠄⠂✦
........................................................................................................................
•:•.•:•.•:•:•:•:•:•:•:••:•.•:•.•:•:•:•:•:•:•:•:•.•:•.•:•:•:•:•:•:•:••:•.•:•.•:•.•:•:•:•:•:•:•:•:•.•:•:•.•:•.••:•.•:•.••:
*/

// Never remove a value from here. Set to false if you want it off, true if you want it on.
const settings = {
    alerts: true,
    randomevents: true,
    tntneomail: true,
    guildmessages: true,
    neoboardmessages: true,
    // So! If you only want the script to check for messages in specific topics, add their urls here, enclosed by quotes
    // and separated by commas. If you do that, no other topic will have notifications! For example,
    // neoboardallowlist: ["https://www.neopets.com/neoboards/topic.phtml?topic=164152582", "https://www.neopets.com/neoboards/topic.phtml?topic=164070442"]
    neoboardallowlist: []
}

const getStoredPost = async (id) => (await GM.getValue("posts", [])).find(post => post.id === id);

const setStoredPost = async (data) => {
    const allPosts = await GM.getValue("posts", []);
    const index = allPosts.findIndex(post => post.id === data.id);
    if(index !== -1) allPosts[index] = data;
    else allPosts.push(data);
    GM.setValue("posts", allPosts);
}

const purgePosts = async () => {
    const now = new Date().getTime();
    const allPosts = await GM.getValue("posts", []);
    const purged = allPosts.filter(post => now - post.timestamp < 36288000000);
    GM.setValue("posts", purged);
}

const notify = (title, message, url) => GM.notification({title: title, text: message, onclick: () => window.open(url, "_blank")});

const parseAlert = (alert) => {
    return {
        message: (alert.querySelector("p") ?? alert.childNodes[2])?.textContent.trim(),
        id: alert.querySelector(".alert-x")?.dataset.delid,
        title: alert.querySelector("b, h4")?.textContent,
        url: alert.getElementsByTagName("a")[0]?.href
    }
}

const getAlertData = (lastAlert) => {
    const alert = document.querySelector(".eventIcon, #alerts li");
    if(!alert) return;
    const alertData = parseAlert(alert);
    if(alertData.message === lastAlert.message) alertData.id ||= lastAlert.id;
    return alertData;
}

const checkForAlerts = async () => {
    const lastAlert = await GM.getValue("alert", {});
    const newAlert = getAlertData(lastAlert);
    if(!newAlert?.message || newAlert.message === lastAlert.message) return;
    if(!settings.tntneomail && newAlert.message.contains("theneopetsteam")) return;
    notify(newAlert.title, newAlert.message, newAlert.url);
    GM.setValue("alert", {message: newAlert.message, id: newAlert.id});
}

const notifyRandomEvent = () => {
    notify("Something has happened!", document.querySelector(".randomEvent .copy").textContent, window.location.href);
}

const sanitizeNeoHTML = neoHTML => neoHTML.replaceAll(/<\/?(b|i|font|div)[^>]*>|<\/p>/g, "").replaceAll(/<img[^>]+>/g, "🖼️").replaceAll(/<br>|<p>/g, "\n");

const parseGuildPost = () => {
    return {
        message: sanitizeNeoHTML(document.querySelector("td[align='left'][valign='top'][width='300'] br + br").nextElementSibling.innerHTML),
        id: parseInt(document.querySelector("td[colspan='2'] td[align='left'] a").href.match(/_id=(\d+)/)[1]),
        url: document.querySelector("td[colspan='2'] td[align='left'] a").href.match(/(.+?)&/)[1],
        title: `${document.querySelector("table[cellspacing='1'] td[align='center'] a font").textContent} in your guild`
    }
}

const checkForGuildPost = async () => {
    if(document.querySelector("td[width='100%'] tr:last-child a font b").textContent === '<< Newer') return;
    const lastId = await GM.getValue("guild", 0);
    const newPost = parseGuildPost();
    if(newPost.id === lastId) return;
    GM.setValue("guild", newPost.id);
    if(lastId) notify(newPost.title, newPost.message, newPost.url);
}

const parseNeoboardPost = () => {
    return {
        message: sanitizeNeoHTML(document.querySelector("div > li:last-of-type .boardPostMessage").innerHTML),
        messageId: parseInt(document.querySelector("div > li:last-of-type button a").href.match(/regarding=(\d+)/)[1]),
        url: window.location.href,
        title: `${document.querySelector("div > li:last-of-type .postAuthorName").textContent} @ ${document.getElementsByTagName("h1")[0].textContent.trim()}`
    }
}

const checkForNeoboardPost = async () => {
    const isCurrentPage = !document.querySelector(".boardPageButton-active") || !document.querySelector(".boardPageButton-active ~ a");
    const currentPage = parseInt(window.location.href.match(/next=(\d+)/)[1] ?? 1);
    const topicId = parseInt(window.location.href.match(/topic.phtml\?topic=(\d+)/)[1]);
    const lastPost = await getStoredPost(topicId);
    if(!isCurrentPage) {
        if(lastPost && !lastPost.warned && currentPage >= lastPost.page) {
            notify("New Page!", `You're no longer in the last page of ${document.getElementsByTagName("h1")[0].textContent.trim()}!`, document.querySelector(".pageNav a:nth-last-child(2)").href);
            lastPost.warned = true;
            lastPost.timestamp = (new Date()).getTime();
            setStoredPost(lastPost);
        }
        return;
    }
    const newPost = parseNeoboardPost();
    if(lastPost?.messageId === newPost.messageId) {
        lastPost.warned = false;
        setStoredPost(lastPost);
        return;
    }
    setStoredPost({id: topicId, messageId: newPost.messageId, warned: false, timestamp: (new Date()).getTime(), page: currentPage});
    if(lastPost?.messageId) notify(newPost.title, newPost.message, newPost.url);
}

const canCheckNeoboardPost = () => {
    if(!settings.neoboardmessages) return false;
    if(!window.location.href.match('topic.phtml\\?topic=')) return false;
    if(settings.neoboardallowlist.length > 0 && !settings.neoboardallowlist.some(l => window.location.href.match(l))) return false;
    return true;
}

const initializeScript = async () => {
    await purgePosts();
    if(settings.alerts && document.querySelector("#header, nav-bell-icon__2020")) checkForAlerts();
    if(settings.randomevents && document.querySelector(".randomEvent")) notifyRandomEvent();
    if(settings.guildmessages && window.location.href.match('guild_board.phtml')) checkForGuildPost();
    if(canCheckNeoboardPost()) checkForNeoboardPost();
}

(function() {
    'use strict';
    initializeScript();
})();