您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Displays the possibly offensive word on MOTD using a dictionary of banned words.
// ==UserScript== // @name Nitro - MOTD Offensive Filter (Updated 2024) // @version 1.2.0 // @description Displays the possibly offensive word on MOTD using a dictionary of banned words. // @author existence // @match *://*.nitrotype.com/team/* // @match *://*.nitromath.com/team/* // @grant none // @license MIT // @namespace https://gf.qytechs.cn/users/858426 // @require https://cdnjs.cloudflare.com/ajax/libs/dexie/3.2.1-beta.2/dexie.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.3.1/papaparse.min.js // ==/UserScript== /* globals Dexie Papa */ let currentUser, authToken try { currentUser = JSON.parse(JSON.parse(localStorage.getItem("persist:nt")).user) authToken = localStorage.getItem("player_token") } catch { console.error("Failed to parse NT User data") return } const canMOTD = currentUser?.tag && ["captain", "officer"].includes(currentUser.teamRole) && window.location.pathname === `/team/${currentUser.tag}` if (!canMOTD) { console.error("MOTD not available on team announcements") return } const DICTIONARY_URL = "https://docs.google.com/spreadsheets/d/1AiRty-2WFOPJiN1hp017g64_0u6-YhnPdxa6a3Enbak/export?gid=0&format=csv&id=1AiRty-2WFOPJiN1hp017g64_0u6-YhnPdxa6a3Enbak" // Dictionary storage const db = new Dexie("NTMOTDFilterTool") db.version(1).stores({ badWords: "word, userDefined", }) db.open().catch(function (e) { console.error("Failed to open up the MOTD Filter database") }) ///////////// // Utils // ///////////// // https://github.com/dotcypress/runes#readme const HIGH_SURROGATE_START = 0xd800 const HIGH_SURROGATE_END = 0xdbff const LOW_SURROGATE_START = 0xdc00 const REGIONAL_INDICATOR_START = 0x1f1e6 const REGIONAL_INDICATOR_END = 0x1f1ff const FITZPATRICK_MODIFIER_START = 0x1f3fb const FITZPATRICK_MODIFIER_END = 0x1f3ff const VARIATION_MODIFIER_START = 0xfe00 const VARIATION_MODIFIER_END = 0xfe0f const DIACRITICAL_MARKS_START = 0x20d0 const DIACRITICAL_MARKS_END = 0x20ff const ZWJ = 0x200d const GRAPHEMS = [ 0x0308, // ( ◌̈ ) COMBINING DIAERESIS 0x0937, // ( ष ) DEVANAGARI LETTER SSA 0x0937, // ( ष ) DEVANAGARI LETTER SSA 0x093f, // ( ि ) DEVANAGARI VOWEL SIGN I 0x093f, // ( ि ) DEVANAGARI VOWEL SIGN I 0x0ba8, // ( ந ) TAMIL LETTER NA 0x0bbf, // ( ி ) TAMIL VOWEL SIGN I 0x0bcd, // ( ◌்) TAMIL SIGN VIRAMA 0x0e31, // ( ◌ั ) THAI CHARACTER MAI HAN-AKAT 0x0e33, // ( ำ ) THAI CHARACTER SARA AM 0x0e40, // ( เ ) THAI CHARACTER SARA E 0x0e49, // ( เ ) THAI CHARACTER MAI THO 0x1100, // ( ᄀ ) HANGUL CHOSEONG KIYEOK 0x1161, // ( ᅡ ) HANGUL JUNGSEONG A 0x11a8, // ( ᆨ ) HANGUL JONGSEONG KIYEOK ] function runes(string) { if (typeof string !== "string") { throw new Error("string cannot be undefined or null") } const result = [] let i = 0 let increment = 0 while (i < string.length) { increment += nextUnits(i + increment, string) if (isGraphem(string[i + increment])) { increment++ } if (isVariationSelector(string[i + increment])) { increment++ } if (isDiacriticalMark(string[i + increment])) { increment++ } if (isZeroWidthJoiner(string[i + increment])) { increment++ continue } result.push(string.substring(i, i + increment)) i += increment increment = 0 } return result } // Decide how many code units make up the current character. // BMP characters: 1 code unit // Non-BMP characters (represented by surrogate pairs): 2 code units // Emoji with skin-tone modifiers: 4 code units (2 code points) // Country flags: 4 code units (2 code points) // Variations: 2 code units function nextUnits(i, string) { const current = string[i] // If we don't have a value that is part of a surrogate pair, or we're at // the end, only take the value at i if (!isFirstOfSurrogatePair(current) || i === string.length - 1) { return 1 } const currentPair = current + string[i + 1] let nextPair = string.substring(i + 2, i + 5) // Country flags are comprised of two regional indicator symbols, // each represented by a surrogate pair. // See http://emojipedia.org/flags/ // If both pairs are regional indicator symbols, take 4 if (isRegionalIndicator(currentPair) && isRegionalIndicator(nextPair)) { return 4 } // If the next pair make a Fitzpatrick skin tone // modifier, take 4 // See http://emojipedia.org/modifiers/ // Technically, only some code points are meant to be // combined with the skin tone modifiers. This function // does not check the current pair to see if it is // one of them. if (isFitzpatrickModifier(nextPair)) { return 4 } return 2 } function isFirstOfSurrogatePair(string) { return string && betweenInclusive(string[0].charCodeAt(0), HIGH_SURROGATE_START, HIGH_SURROGATE_END) } function isRegionalIndicator(string) { return betweenInclusive(codePointFromSurrogatePair(string), REGIONAL_INDICATOR_START, REGIONAL_INDICATOR_END) } function isFitzpatrickModifier(string) { return betweenInclusive(codePointFromSurrogatePair(string), FITZPATRICK_MODIFIER_START, FITZPATRICK_MODIFIER_END) } function isVariationSelector(string) { return ( typeof string === "string" && betweenInclusive(string.charCodeAt(0), VARIATION_MODIFIER_START, VARIATION_MODIFIER_END) ) } function isDiacriticalMark(string) { return ( typeof string === "string" && betweenInclusive(string.charCodeAt(0), DIACRITICAL_MARKS_START, DIACRITICAL_MARKS_END) ) } function isGraphem(string) { return typeof string === "string" && GRAPHEMS.indexOf(string.charCodeAt(0)) !== -1 } function isZeroWidthJoiner(string) { return typeof string === "string" && string.charCodeAt(0) === ZWJ } function codePointFromSurrogatePair(pair) { const highOffset = pair.charCodeAt(0) - HIGH_SURROGATE_START const lowOffset = pair.charCodeAt(1) - LOW_SURROGATE_START return (highOffset << 10) + lowOffset + 0x10000 } function betweenInclusive(value, lower, upper) { return value >= lower && value <= upper } const multibyteLength = (text) => { return new TextEncoder().encode(text).length } ////////////////// // Components // ////////////////// /** Styles for the following components. */ const style = document.createElement("style") style.appendChild( document.createTextNode(` .nt-motd-reason { margin-top: 20px; margin-bottom: 10px; border-radius: 8px; padding: 10px; background-color: #333; } .nt-motd-reason.empty { display: none; } .nt-motd-reason-body { display: flex; flex-flow: row wrap; padding: 1rem; background-color: white; border-radius: 5px; font-family: "Roboto Mono", "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace; font-size: 12px; } .nt-motd-reason-word { display: flex; margin-right: 1ch; } .nt-motd-reason.disabled .nt-motd-reason-letter { cursor: default; } .nt-motd-reason .nt-motd-reason-letter { color: rgba(0, 0, 0, 0.3); } .nt-motd-reason-letter.offensive { color: #e00; font-weight: 600; } .nt-motd-reason-helper { color: #acacac; } .nt-motd-reason-submit { display: flex; justify-content: space-between; margin: 10px 0; } .nt-motd-reason-errors { border: 1px solid #D62F3A; border-radius: 5px; padding: 10px; background-color: rgba(214, 47, 58, 0.5); color: rgba(255, 255, 255, 0.8); font-size: 14px; font-style: italic; } .nt-motd-reason-error-validation { margin-bottom: 0; } .nt-motd-reason-error-bad-words { background-color: rgba(0, 0, 0, 0.2); padding: 10px; margin-bottom: 10px; border-radius: 5px; } .nt-motd-reason-error-bad-words ul { margin-bottom: 0; } .nt-motd-reason-error-bad-words.has-invalid-form { margin-top: 20px; }`) ) document.head.appendChild(style) /** Offensive Word Dictionary. */ const OffensiveWordDictionary = ((dictionaryURL) => { let loadError = null, loading = false, badWords = [] /** Grabs list of offensive words from the dictionary. */ const load = (init) => { // Load dictionary if (dictionaryURL) { loading = true return fetch(dictionaryURL) .then((resp) => { if (!resp.ok) { throw new Error("invalid response from dictionary url") } return resp.text() }) .then((resp) => { const parsedCSV = Papa.parse(resp) if (parsedCSV.data.length > 1) { badWords = parsedCSV.data .slice(1) .filter((row) => !!row[0]) .map((row) => row[0]) } if (badWords.length > 0) { db.badWords .bulkPut(badWords.map((word) => ({ word, userDefined: false }))) .then(() => console.info("MOTD Offensive Dictionary updated")) } }) .catch((err) => { console.error("Unable to fetch dictionary (fallback offline)", err) loadError = err if (init) { return db.badWords.each((badWord) => { badWords = badWords.concat(badWord.word) }) } }) .finally(() => { loading = false }) } // No dictionary? why? loadError = "Offensive Word Dictionary is required, please provide URL to listed words in CSV format." console.warn(loadError) if (init) { return db.badWords.each((badWord) => { badWords = badWords.concat(badWord.word) }) } } load(true) /** Gets list of offensive words found in dictionary. */ const findBadWords = (textRunes) => { let outputBadWords = [], outputBadLetters = [] badWords.forEach((word) => { const wordChunks = runes(word) for (let i = 0; i < textRunes.length; i++) { if (textRunes[i].toLowerCase() === wordChunks[0].toLowerCase()) { let matched = [i], wordIndex = 1, j = i + 1 for (; j < textRunes.length && wordIndex < wordChunks.length; j++) { const checkLetter = textRunes[j].toLowerCase(), offensiveLetter = wordChunks[wordIndex].toLowerCase() if (checkLetter === offensiveLetter) { matched = matched.concat(j) wordIndex++ continue } if ( checkLetter === null || (/[0-9]/.test(checkLetter) && !/^[0-9]+$/.test(word)) || (!/[a-z0-9]/i.test(checkLetter) && !/(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/.test( checkLetter )) ) { // Ignore punctuation, spacing or unmatched numbers // Examples: // std = past 7 days // g00k = pog 100k continue } matched = [] break } if (matched.length === wordChunks.length) { if (!outputBadWords.includes(word)) { outputBadWords = outputBadWords.concat(word) } for (const c of matched) { if (!outputBadLetters.includes(c)) { outputBadLetters = outputBadLetters.concat(c) } } } } } }) return [outputBadWords, outputBadLetters] } return { loading, loadError, badWords, findBadWords, load, } })(DICTIONARY_URL) /** UI Interface that allows users to select a fragment of the MOTD to check for offensive words. */ const MOTDReviewUI = ((dictionary) => { const root = document.createElement("div"), textReview = document.createElement("div"), validationError = document.createElement("div"), badWordsError = document.createElement("div"), submitContainer = document.createElement("div") root.classList.add("nt-motd-reason", "empty") validationError.classList.add("nt-motd-reason-errors", "nt-motd-reason-error-validation") validationError.style.display = "none" validationError.innerHTML = `You must at select at least one text.` badWordsError.classList.add("nt-motd-reason-errors", "nt-motd-reason-error-bad-words") badWordsError.style.display = "none" badWordsError.innerHTML = `<p>The following words are possibly offensive:</p><ul></ul>` submitContainer.classList.add("nt-motd-reason-submit") submitContainer.innerHTML = `<small class="nt-motd-reason-helper">This script only has a limited set of offensive words.</small> <div><button class="btn btn--compact2 btn--xs btn--secondary" type="button" title="Refresh Dictionary">Refresh Dictionary</button></div>` const badWordsContainer = badWordsError.querySelector(".nt-motd-reason-error-bad-words ul"), refreshDictionaryButton = submitContainer.querySelector("button") let letters = [], letterNodes = [], badWords = [] const reloadButtonClickHandler = () => { refreshDictionaryButton.disabled = true dictionary.load().then(() => { refreshDictionaryButton.disabled = false const [foundBadWords, badLetters] = dictionary.findBadWords(letters) textReview.querySelectorAll(".nt-motd-reason-letter").forEach((node) => { node.classList.remove("offensive") }) badWords = foundBadWords badLetters.forEach((i) => { if (letterNodes[i]) { letterNodes[i].classList.add("offensive") } }) refreshErrors(false) }) } refreshDictionaryButton.addEventListener("click", reloadButtonClickHandler) // Output textReview.classList.add("nt-motd-reason-body") const refreshErrors = (invalidForm) => { if (!invalidForm && badWords.length === 0) { validationError.style.display = "none" badWordsError.style.display = "none" return } validationError.style.display = invalidForm ? "" : "none" badWordsError.style.display = badWords.length > 0 ? "" : "none" if (invalidForm) { badWordsError.classList.add("has-invalid-form") } else { badWordsError.classList.remove("has-invalid-form") } const badWordListTemplate = document.createElement("li"), fragment = document.createDocumentFragment() badWordListTemplate.classList.add("nt-motd-reason-error-item") while (badWordsContainer.firstChild) { badWordsContainer.removeChild(badWordsContainer.firstChild) } badWords.forEach((word) => { const node = badWordListTemplate.cloneNode() node.textContent = word fragment.append(node) }) badWordsContainer.append(fragment) } const reset = (message) => { while (textReview.firstChild) { textReview.removeChild(textReview.firstChild) } if (!message) { root.classList.add("empty") return } badWords = [] letterNodes = [] letters = runes(message) refreshErrors(false) const [foundBadWords, badLetters] = dictionary.findBadWords(letters) badWords = foundBadWords const rootFragment = document.createDocumentFragment(), wordNodeTemplate = document.createElement("div"), letterNodeTemplate = document.createElement("div") wordNodeTemplate.classList.add("nt-motd-reason-word") letterNodeTemplate.classList.add("nt-motd-reason-letter") let wordFragment = document.createDocumentFragment(), wordFragmentEmpty = false for (let i = 0; i < letters.length; i++) { const char = letters[i] if (char === " ") { const wordRoot = wordNodeTemplate.cloneNode() wordRoot.append(wordFragment) rootFragment.append(wordRoot) wordFragment = document.createDocumentFragment() wordFragmentEmpty = true letterNodes = letterNodes.concat(null) continue } const letterNode = letterNodeTemplate.cloneNode() letterNode.textContent = char letterNode.dataset.letterindex = i if (badLetters.includes(i)) { letterNode.classList.add("offensive") } letterNodes = letterNodes.concat(letterNode) wordFragment.append(letterNode) wordFragmentEmpty = false } if (!wordFragmentEmpty) { const wordRoot = wordNodeTemplate.cloneNode() wordRoot.append(wordFragment) rootFragment.append(wordRoot) } refreshErrors(false) textReview.append(rootFragment) root.classList.remove("empty") } root.append(textReview, submitContainer, validationError, badWordsError) return { root, reset, } })(OffensiveWordDictionary) /////////////// // Backend // /////////////// const errorObserver = new MutationObserver(([mutation]) => { if (mutation.addedNodes.length === 0) { return } const motd = document.querySelector(".modal textarea.input-field") if (!motd) { console.warn("Could not find MOTD input") return } const errorTextNode = mutation.addedNodes[0] if (!errorTextNode.textContent.startsWith("This contains words that are possibly offensive.")) { return } errorTextNode.textContent = "Message contains content that is possibly offensive. Let's review." MOTDReviewUI.reset(motd.value) }) const modalObserver = new MutationObserver(([mutation]) => { for (const node of mutation.addedNodes) { if (node.classList?.contains("modal")) { errorObserver.observe(node.querySelector(".input-alert .bucket-content"), { childList: true }) const form = node.querySelector("form"), target = node.querySelector("p.tss.tc-ts") if (!form || !target) { console.error("Failed to attach Word Reviewer") return } MOTDReviewUI.reset() form.insertBefore(MOTDReviewUI.root, target) break } } }) modalObserver.observe(document.body, { childList: true })
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址