User:Former User aDB0haVymg/gadgets/trollShieldAlphaPilot.js
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
"use strict";
function trollJSContainerFunction () {
/**
* troll.js for Chinese Wikipedia
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
*/
/* global TrollJSRegistrar, trollJSBlacklist, trollJSUsernameParser,
trollJSPostExtractor, trollJSKiller, trollJSPageEligibilityModule, trollJSBlockUI
mw */
// script tunable constants
const trollJSAppIdentifier = "[trollJS v1]";
// UI interface message strings
const MSG_NO_TROLL = "Hooray! No troll in sight!";
const MSG_TROLL_REPORT_LINE = "$SLASHCOUNT post/s deleted for troll named $NAME";
const MSG_REPORT_HEADER = "Troll JS Gadget Report";
const MSG_PAGE_INELIGIBLE = trollJSAppIdentifier + ": skipping ineligible page.";
const MSG_DONE = trollJSAppIdentifier + ": done.";
// Troll JS Runtime
const trollJSApp = {
// app's runtime variables
registrar: null,
// app's runtime functions
/**
* Detects if a link contains a blacklisted username
* @param {Node} linkElmt the link to analyse
* @returns {Boolean} true or false
*/
_detectBlacklistedNameInLink: function(linkElmt) {
const userNameInLink = trollJSUsernameParser.getUserNameFromLink(linkElmt);
// if it's not a username link at all...
if (!userNameInLink) {
return false;
}
return trollJSBlacklist.isBlacklisted(userNameInLink);
},
/**
* Assesses if a post's owner is blacklisted or not
* @param {TrollJSPost} post the post to detect
* @returns {Boolean} true or false
*/
_detectBlacklistedOwnerInPost: function(post) {
let ownerName = post.getOwner();
return trollJSBlacklist.isBlacklisted(ownerName);
},
/**
* Generates the text of the paragraph element in the report div
* @returns {HTMLElement} the message text, in the form of a div
*/
_generateReportContentDiv: function() {
var newElmt = document.createElement("div");
const slashCountEntries = Object.entries(this.registrar.slashCountByTroll);
// if there is no troll found, then just leave silently
if (slashCountEntries.length === 0) {
return null;
}
// else, make a <ul> element
var newUlElmt = document.createElement("ul");
// give each troll a dedicated <li>
for (const thisEntry of slashCountEntries) {
// generate the text message string
const trollName = thisEntry[0];
const slashCount = thisEntry[1];
const msgString = MSG_TROLL_REPORT_LINE
.replace("$SLASHCOUNT", slashCount.toString()).replace("$NAME", trollName);
// create <li>, and put the msgString inside
const newLiElmt = document.createElement("li");
newLiElmt.innerText = msgString;
// add it to newUlElmt, at the end
newUlElmt.insertAdjacentElement("beforeend", newLiElmt);
}
// add ul element to newElmt
newElmt.insertAdjacentElement("beforeend", newUlElmt);
// done
return newElmt;
},
/**
* Send report to user
*/
reportToUser: function() {
/** Sample Report:
* <h3>Troll JS Gadget Report</h3>
* <p>Troll.js has banished [\d]+ posts from [\d]+ trolls</p>
*/
// Create elements
const reportDiv = document.createElement("div");
const reportHeader = document.createElement("h3");
const reportContent = this._generateReportContentDiv();
// leave silently if the last statement leaves us null.
if (!reportContent) {
return;
}
// fill in text
reportHeader.textContent = MSG_REPORT_HEADER;
// add to div and display
reportDiv.appendChild(reportHeader);
reportDiv.appendChild(reportContent);
if (reportDiv) {
mw.notify(reportDiv);
}
},
/**
* A function to start the whole operation
*/
start: async function () {
// verify that this page is indeed eligible first
if (trollJSPageEligibilityModule.assessPageEligibility() === false) {
console.log(MSG_PAGE_INELIGIBLE);
return;
}
// generate a registrar
this.registrar = new TrollJSRegistrar();
// get all links, and filter by
let allLinks = Array.from(document.querySelector("#bodyContent").querySelectorAll("a"));
const filteredLinks = allLinks.filter(this._detectBlacklistedNameInLink);
// get posts on everything, add to the register
for (const link of filteredLinks) {
this.registrar.addPost(
trollJSPostExtractor.getEntirePost(link)
);
}
// filter the posts such that only the ones with a blacklisted owner appear!
const allPosts = this.registrar.posts;
const filteredPosts = allPosts.filter(this._detectBlacklistedOwnerInPost);
// kill all of the posts with blacklisted owner!
for (const post of filteredPosts) {
// register it
this.registrar.markPostSlashed(post);
// slash it
trollJSKiller.killPost(post);
}
// all posts killed. report.
this.reportToUser();
console.log(MSG_DONE);
},
};
/**
* this file is part of...
* troll.js for Chinese Wikipedia
*
* This module will add a quick link on users' userpages
* Such that they can be easily blocked
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
*/
/* global trollJSBlacklist, mw */
/* exported trollJSBlockUI */
// "global" message strings as tunable constants
const ERR_INVALID_SUITABILITY = "Invalid suitability string";
const MSG_BLOCK_THIS_USER = "Block this user";
const MSG_UNBLOCK_THIS_USER = "Unblock this user";
const MSG_BLOCK_CONFIRMATION = "Are you sure you want to block $1?";
const MSG_UNBLOCK_CONFIRMATION = "Are you sure you want to unblock $1?";
const MSG_BLOCKED = "$1 has been blocked.";
const MSG_UNBLOCKED = "$1 has been unblocked.";
const MSG_CANCELLED = "Cancelled by user.";
// runtime variables
var thisUsername;
const trollJSBlockUI = {
/**
* Checks whether current page is a user, and the block status of the user
* @returns {string} one of "notuser", "blocked", or "notblocked"
*/
checkPageSuitability: function() {
// if there is a "contributions" link, we know it's a user-related page
// if not, we can return "notuser" directly
const contributionsLink = document.querySelector("#t-contributions a");
if (!contributionsLink) {
return "notuser";
}
thisUsername = this._extractUsernameFromLink(contributionsLink);
if (trollJSBlacklist.isBlacklisted(thisUsername)) {
return "blocked";
} else {
return "notblocked"
}
},
/**
* Extracts username from a "user contribution" link element
* Exploratory test passed 2020-05-07
* @param contributionsLink {HTMLElement} the link element
* @returns {string} the username
*/
_extractUsernameFromLink: function (contributionsLink) {
const usernameExtractorPattern = /^.+\/Special:(.+)\/(.+)$/gi;
const linkHref = decodeURI(contributionsLink.href);
return usernameExtractorPattern.exec(linkHref)[2];
},
/**
* Generates and insert into page the link
* @param {string} suitability output of this.checkPageSuitability()
*/
generateAndInsertLinkElmt: function(suitability) {
const contributionsLink = document.querySelector("#t-contributions");
// the new element contains a wrapper in the outside... (identical to contributionsLink, except the id)
const newWrapperElmt = contributionsLink.cloneNode(false);
newWrapperElmt.id = "t-trollJSToggleLink";
// ...and an <a> element in the inside, which should be brand new
var newLinkElmt;
if (suitability === "blocked") {
newLinkElmt = this.generateUnblockLinkElmt();
} else if (suitability === "notblocked") {
newLinkElmt = this.generateBlockLinkElmt();
} else {
throw new Error(ERR_INVALID_SUITABILITY);
}
newWrapperElmt.appendChild(newLinkElmt);
contributionsLink.insertAdjacentElement("afterend", newWrapperElmt);
},
/**
* A simple helper method to create the inner link.
* @returns {HTMLAnchorElement} the link element
* @private
*/
_generateInnerLinkElmt: function() {
return document.createElement("a");
},
/**
* Gets the link element that says "block this user"
* @returns {HTMLElement} the new element (should be an <a>)
*/
generateBlockLinkElmt: function() {
const newElmt = this._generateInnerLinkElmt();
newElmt.innerText = MSG_BLOCK_THIS_USER;
newElmt.addEventListener("click", this.blockLinkOnClick);
return newElmt;
},
/**
* Gets the link element that says "unblock this user"
* @returns {HTMLElement} the new element (should be an <a>)
*/
generateUnblockLinkElmt: function() {
const newElmt = this._generateInnerLinkElmt();
newElmt.innerText = MSG_UNBLOCK_THIS_USER;
newElmt.addEventListener("click", this.unblockLinkOnClick);
return newElmt;
},
/**
* onclick handler for the "block this user" link
* @param {Event} event default DOM event
*/
// eslint-disable-next-line no-unused-vars
blockLinkOnClick: async function(event) {
// prompt the user for confirmation
const promptText = MSG_BLOCK_CONFIRMATION.replace("$1", thisUsername);
const userResponse = window.confirm(promptText);
if (!userResponse) {
mw.notify(MSG_CANCELLED);
return;
}
// do block
trollJSBlacklist.addToBlackList(thisUsername);
// notify and done.
mw.notify(MSG_BLOCKED.replace("$1", thisUsername));
},
/**
* onclick handler for the "unblock this user" link
* @param {Event} event default DOM event
*/
// eslint-disable-next-line no-unused-vars
unblockLinkOnClick: async function(event) {
// prompt the user for confirmation
const promptText = MSG_UNBLOCK_CONFIRMATION.replace("$1", thisUsername);
const userResponse = window.confirm(promptText);
if (!userResponse) {
mw.notify(MSG_CANCELLED);
return;
}
// do unblock
trollJSBlacklist.removeFromBlackList(thisUsername);
// notify and done
mw.notify(MSG_UNBLOCKED.replace("$1", thisUsername));
},
/**
* This meta function starts the whole operation of block selector
*/
start: async function() {
const pageSuitability = this.checkPageSuitability();
// skip all non-user pages: return silently
if (pageSuitability === "notuser") {
return;
}
this.generateAndInsertLinkElmt(pageSuitability);
}
}/**
* Blacklist.js for Chinese Wikipedia
*
* This module manages the persistent storage and processing of blacklists
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
*/
/* global LocalStorage */
/* exported trollJSBlacklist */
const BLACKLIST_LOCAL_CACHE_AGE_LIMIT = 30 * 1000; // 30 seconds
const trollJSBlacklist = {
blackListCache: null, /* memory cache. this is only filled in runtime */
objectKey: "blacklist",
lastUpdatedTimestampKey: "updatetime",
/**
* Gets whatever's stored in MW API, and store into LocalStorage,
* only if necessary.
* @returns {Promise<void>} Resolves no matter what.
* NOTE - will ignore errors
* @private
*/
_pullRemoteChanges: async function() {
// check if the cache is too old
const currentTime = Date.now();
const localUpdateTime = this._getLocalUpdateTime();
if (currentTime - localUpdateTime < BLACKLIST_LOCAL_CACHE_AGE_LIMIT) {
// no need to update
return;
}
// check whether remote end has updated
const remoteUpdateTime = await RemoteStorage.getLastUpdateTime();
if (remoteUpdateTime > localUpdateTime) {
// need to refresh cache!
var newBlacklistObject;
try {
newBlacklistObject = await RemoteStorage.getBlackList();
} catch (error) {
// if it's not already present, just create an empty one and push it up.
if (error.toString().includes("The requested key is not present.")) {
newBlacklistObject = [];
RemoteStorage.pushBlackList(JSON.stringify(newBlacklistObject));
} else {
throw error;
}
}
LocalStorage.set(this.objectKey, newBlacklistObject);
} else {
// cache re-validated. Nothing to do.
}
// set new update time
this._updateLocalUpdateTime();
},
/**
* Gets the local cache updated time.
* @returns {number} the last update time. If not present, returns 0.
* @private
*/
_getLocalUpdateTime: function() {
const localStorageOutput = LocalStorage.get(this.lastUpdatedTimestampKey);
if (!localStorageOutput) {
return 0;
} else {
return localStorageOutput;
}
},
/**
* Sets the local cache update time to now.
* @private
*/
_updateLocalUpdateTime: function() {
LocalStorage.set(this.lastUpdatedTimestampKey, Date.now());
},
/**
* Loads blackListCache from persistent storage
* If the persistent storage is empty, it will create a new array
* If it's already loaded, then this does nothing
* NOTE - remember to call this everytime this object is called!
*/
_checkInitialisation: function() {
// try loading the array from persistent storage
if (!this.blackListCache) {
this.blackListCache = LocalStorage.get(this.objectKey);
// trigger a check for remote cache renewal
this._pullRemoteChanges();
}
// still nothing? gotta create our own
if (!this.blackListCache) {
this.blackListCache = [];
}
},
/**
* Saves the current version of in memory cache to localstorage
*/
commit: function() {
this._checkInitialisation();
LocalStorage.set(this.objectKey, this.blackListCache);
// push to remote!
RemoteStorage.pushBlackList(JSON.stringify(this.blackListCache));
RemoteStorage.renewLastUpdateTime();
this._updateLocalUpdateTime();
},
/**
* Gets all black listed usernames
* @returns {Iterable} a clone of the black list as an array
*/
getBlackList: function() {
this._checkInitialisation();
return Array.from(this.blackListCache);
},
/**
* determines whether a username is one of the blacklisted ones
* @param {String} username the username to check
* @returns {Boolean} true or false
*/
isBlacklisted: function(username) {
this._checkInitialisation();
// if the cache is empty, then this definitely is not in the black list
if (!this.blackListCache) {
return false;
}
// cache is not empty
if (this.blackListCache.includes(username)) {
return true;
} else {
return false;
}
},
/**
* Adds a username to the black list, if it's not already there
* @param {String} username username to add
*/
addToBlackList: function(username) {
this._checkInitialisation();
if (this.isBlacklisted(username) === false) {
this.blackListCache.push(username);
this.commit();
}
},
/**
* Removes a username from the black list,
* if it isn't already in it
* @param {String} username username to remove
*/
removeFromBlackList: function(username) {
this._checkInitialisation();
if (this.isBlacklisted(username) === true) {
// remove the username from the array
let usernameIndex = this.blackListCache.indexOf(username);
this.blackListCache.splice(usernameIndex, 1);
this.commit();
}
}
};
/**
* this file is part of...
* troll.js for Chinese Wikipedia
*
* This module determines whether the tool will fire on a specific page or not
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
*/
/* global mw */
/* exported trollJSPageEligibilityModule */
const trollJSPageEligibilityOptions = {
// all pages on the whitelist will be processed, regardless of logic
// format: regex literal
whiteList: [
],
// all pages on the blacklist will not be processed, regardless of logic
// when in doubt, this one will prevail
blackList: [
/Wikipedia:首页/gi
]
}
const trollJSPageEligibilityModule = {
commonWhiteListPrefix: "(Wikipedia(_talk)?|(User_)?talk)",
/**
* Gets the Wiki page title of the current page
* @returns {String} wgPageName
*/
getPageTitle: function() {
return mw.config.get("wgPageName");
},
/**
* Assess the current page's eligibility
* If it returns true, App will execute
* otherwise, App will skip this page
* @returns {Boolean} true or false
*/
assessPageEligibility: function() {
const currentPageName = this.getPageTitle();
// first check the black list
for (let BLItem of trollJSPageEligibilityOptions.blackList) {
if (currentPageName.match(BLItem)) {
return false;
}
}
// then check the white list
for (let WLItem of trollJSPageEligibilityOptions.whiteList) {
if (currentPageName.match(WLItem)) {
return true;
}
}
// real magic begins here
/** the logic's quite simple for now, but it's very much open for expansion. */
const pageMatchRegexp = new RegExp(`${this.commonWhiteListPrefix}:(.+)`, "gi");
if (currentPageName.match(pageMatchRegexp)) {
return true;
} else {
return false;
}
}
}/**
* this file is part of...
* troll.js for Chinese Wikipedia
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
*/
/* exported trollJSKiller */
/* global */
// this class specialises in processing specific DOM nodes
// to make it disappear
const trollJSKiller = {
/**
* Processes the given node, to help the user ignore it
* @param {TrollJSPost} post the post to kill
*/
killPost: function(post) {
let nodes = post.nodes;
nodes.forEach(this._killNode);
},
/**
* Processes individual DOMNode
* @param {Node} node the node to process. Must be a text leaf node
*/
_killNode: function(node) {
// if this not a text node, ignore it.
if (node.nodeType !== document.TEXT_NODE) {
return;
}
// get its parent element and remove this node
const parentElmt = node.parentElement;
parentElmt.removeChild(node);
// done.
}
}/**
* this file is part of...
* troll.js for Chinese Wikipedia
*
* This module handles storage of the user's blacklist preferences
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
*/
/* global trollJSAppIdentifier */
/* exported trollJSPersistentObjectStorage */
// facade for window.localStorage, handles storing and saving of JavaScript objects
const LocalStorage = {
KEY_PREFIX: "trollJS",
STORAGE_OBJ: window.localStorage,
/**
* Gets an object from the storage
* @param {String} key the key
* @returns {Object} the object. null if there's none
*/
get: function(key) {
// key must be something
if (!key) {
throw new TypeError(trollJSAppIdentifier + ": cannot get storage value of falsy key");
}
// try to fetch the object from low level localstorage
let objectString = this.STORAGE_OBJ.getItem(this.KEY_PREFIX + key);
if (!objectString) {
// nah, it's not set
return null;
} else {
// good job. Parse the string into an object
return JSON.parse(objectString);
}
},
/**
* Saves an object into the key
* @param {String} key must be a String
* @param {*} value can be any object
*/
set: function(key, value) {
// cannot process falsy values
if (!key || !value) {
throw new TypeError(trollJSAppIdentifier +
": falsy key or value passed to persistent object storage's set()");
}
let valueString = JSON.stringify(value);
let prefixedKey = this.KEY_PREFIX + key;
this.STORAGE_OBJ.setItem(prefixedKey, valueString);
},
/**
* Clears all items from the storage object
*/
destroy: function() {
let currentIndex = 0;
let currentKey = this.STORAGE_OBJ.key(0);
// iterate through all keys, and delete the ones that fits the prefix
while (currentKey !== null) {
// does the key match our prefix?
if (currentKey.indexOf(this.KEY_PREFIX) === 0) {
// yes!
// need to clear its content
this.STORAGE_OBJ.removeItem(currentKey);
}
// move on to the next one
currentIndex += 1;
currentKey = this.STORAGE_OBJ.key(currentIndex);
}
}
};/**
* this file is part of...
* troll.js for Chinese Wikipedia
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
*/
/* exported trollJSNavigator */
const trollJSNavigatorConfigs = {
ROOT_CONTAINER_CLASS: "mw-parser-output"
}
// navigator
// specialises in navigating the tree of DOM, in our way
const trollJSNavigator = {
/**
* Fetches the previous node of the given node
* By default it returns the node's previousSibling
* If there is no previous sibling, then it returns the last leaf of
* the given node's parent's previous sibling
* @param {Node} node the node to get its previous for
* @returns {Node} the previous node. Null if there isn't one
*/
getPrevious: function(node) {
let cursor = node;
// try getting the previous sibling
let prevSibling = cursor.previousSibling;
// if there isn't one, climb up the tree layers until there is one
while (!prevSibling) {
// climb up one layer with the cursor
cursor = cursor.parentNode;
// bail out if the parent is already mw-parser-output!
if (cursor.classList.contains(trollJSNavigatorConfigs.ROOT_CONTAINER_CLASS)) {
return null;
}
prevSibling = cursor.previousSibling;
}
// now we can commit the cursor to point to the prevSibling
cursor = prevSibling;
// climb down the ladder until we hit a leaf (the last leaf)
while (cursor.lastChild) {
cursor = cursor.lastChild;
}
return cursor;
},
/**
* Fetches the next node of the given node
* By default it returns the node's nextSibling
* If there is no next sibling, then it returns the first child of
* the given node's parent's next
* @param {Node} node the node to get its next for
* @returns {Node} the result. Null if there isn't one.
*/
getNext: function(node) {
let cursor = node;
// try to find a nextSibling
let nextSibling = cursor.nextSibling;
// if there's none, climb up a layer until there is one
while (!nextSibling) {
cursor = cursor.parentNode;
// DANGER: bail out if the parent is already mw-parser-output!
if (cursor.classList.contains(trollJSNavigatorConfigs.ROOT_CONTAINER_CLASS)) {
return null;
}
nextSibling = cursor.nextSibling;
}
// got the next sibling
// commit to cursor
cursor = nextSibling;
// now climb down the ladder until we hit a first leaf
while (cursor.firstChild) {
cursor = cursor.firstChild;
}
// done
return cursor;
}
};/**
* this file is part of...
* troll.js for Chinese Wikipedia
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
* @dependency post.js
*
*/
/* exported trollJSPostExtractor */
/* global TrollJSPost, trollJSNavigator */
// post Extractor
// specialises in capturing all nodes in a post
// factory method for TrollJSPost class
const trollJSPostExtractor = {
// a series of functions to call on provisionally deployed nodes
// if 1 of them return true on the node, then it is deemed invalid,
// & will be discarded.
invalidPostDetectors: [
// NOTE - as a rule of thumb, all items here should be completed in O(1) time.
// 1. posts with less than 3 nodes are definitely invalid
(post) => {
if (post.nodes.length <= 3) {
return true;
}
},
// 2. specifically detect for false positives in relation to
// the "vandal" template
(post) => {
// get the last node of the post
const nNodes = post.nodes.length;
const lastNode = post.nodes[nNodes - 1];
// check its grandparent node. if it looks like what we expect from a vandal template,
// return true.
const grandparent = lastNode.parentElement.parentElement;
if (
grandparent.classList.contains("plainlinks") &&
grandparent.id === post.getOwner() &&
grandparent.textContent.length > 35 &&
grandparent.textContent.length < 50
) {
// jackpot!
return true;
}
// no hit.
return false;
}
],
/**
* Factory method for Post class
* Gets a person's entire post from one of the nodes
* It seeks all previous siblings until it hits a time node, excludes it
* and seeks all later siblings until it hits another time node, and includes it
* @param {Node} node one node inside the zone
* @returns {TrollJSPost} a new Post object
*/
getEntirePost: function(node) {
let newPost = new TrollJSPost();
// if sourceNode has children, go down to its first child leaf first
let sourceNode = node;
while (sourceNode.firstChild) {
sourceNode = sourceNode.firstChild;
}
// seek all nodes ahead, until we run into a barrier node
// DO NOT add the barrier node itself
let cursorAhead = sourceNode;
while (TrollJSPost.detectBarrierNode(cursorAhead) === false) {
// did not hit a barrier
// add this one if it's not excluded,
// at the beginning because we're running to the front
newPost.unshiftNode(cursorAhead);
// and move on to the next one
cursorAhead = trollJSNavigator.getPrevious(cursorAhead);
}
// seek all nodes behind, until we run into a barrier node
// DO add the barrier node itself
let cursorBehind = sourceNode;
while (TrollJSPost.detectBarrierNode(cursorBehind) === false) {
// first one OR did not hit a barrier
// add this one if it's not excluded
// at the end because we're running towards the end
newPost.pushNode(cursorBehind);
// move on to the next one
cursorBehind = trollJSNavigator.getNext(cursorBehind);
}
// don't forget to add the barrier node itself
newPost.pushNode(cursorBehind);
newPost.timeNode = cursorBehind;
// hold on... run it through the invalidPostDetector first
for (const thisDetector of this.invalidPostDetectors) {
if (thisDetector(newPost) === true) {
// invalid post detected!
return null;
}
}
// got it.
return newPost;
}
};
/**
* this file is part of...
* troll.js for Chinese Wikipedia
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
* @dependency userNameExtractor.js (getOwner only)
*
*/
/* exported TrollJSPost */
/* global trollJSAppIdentifier, trollJSUsernameParser */
// tunable constants
const trollJSPostConfig = {
excludedClasses: [
/** CONFIG ITEM */
// any class in this array will cause an element to become ignored by the Posts
/^mw-headline$/,
/^mw-editsection.+$/
],
// a node's content OR title will be treated as a time
// if it matches one of these regex patterns
timeNodePatterns: [
// regular signature pattern (zh.wiki)
/(\d{4})年(\d\d?)月(\d\d?)日 \([一二三四五六日]\) (\d\d?):(\d\d?) (\(UTC\))/gi,
// {{unsigned}} template pattern (zh.wiki)
/—以上未簽名的留言(是)?由(.+)((對話.*))([於在](.+))?加入(的)?。/gi /** zh-hant */,
/—以上未签名的留言(是)?由(.+)((对话.*))([于在](.+))?加入(的)?。/gi /** zh-hans */,
// zh-yue wiki's signature pattern
/(\d{4})年(\d\d?)月(\d\d?)[號号日] \([一二三四五六日]\) \d\d:\d\d \(UTC\)/gi,
// enwiki signature time pattern
/\d\d:\d\d, \d\d? (January|February|March|April|May|June|July|August|September|October|November|December) \d{4} \(UTC\)/gi
]
};
// post class
// represent one post by one person
class TrollJSPost {
constructor() {
this.nodes = [];
this.timeNode = null;
this._cachedOwner = null;
}
/**
* Determines if this post is equal to another post
* @param {TrollJSPost} anotherPost the other post
* @returns {Boolean} true or false
*/
equals(anotherPost) {
// node list can't be empty!
if (this.nodes.length === 0) {
throw new Error(trollJSAppIdentifier +
" equalsTo() is called on an empty post");
}
// if the owners aren't equal, they are different posts
if (this._cachedOwner !== anotherPost._cachedOwner) {
return false;
}
// if they have different numbers of nodes, they can't be equal
if (this.nodes.length !== anotherPost.nodes.length) {
return false;
}
// then,
// two posts are equal iff every single node in this post
// is present in the other post
for (const currentNode of this.nodes) {
if (anotherPost.nodes.includes(currentNode) === false) {
return false;
}
}
// all tests passed. They are equal.
return true;
}
/**
* Detects if a node is excluded.
* Excluded nodes are exempt from deletion, and won't be included in a post object at all.
* Examples include mw-headline and mw-editsection* nodes
* @param {Node} node the node to check
* @returns {Boolean} true or false
*/
static detectExcludedNode(node) {
// falsy nodes are definitely excluded
if (!node) {
return true;
}
// determine node to check.
// if it's element, then nodeToCheck is node itself
// otherwise, it's the node's first element parent
let nodeToCheck;
if (node.nodeType === document.ELEMENT_NODE) {
nodeToCheck = node;
} else {
nodeToCheck = node.parentElement;
}
// iterate over all classes of the node,
// and check for a match with one of the excluded patterns
for (const cssClass of nodeToCheck.classList) {
for (const regexp of trollJSPostConfig.excludedClasses) {
if (cssClass.match(regexp)) {
return true;
}
}
}
// all tests done and none matched
return false;
}
/**
* checks whether a given node is a signature time (one that's left with 2020年5月14日 (四) 13:16 (UTC))
* @param {Node} node node to check
* @returns true or false
*/
static detectTimeNode(node) {
let stringsToCheck = [];
if (node.nodeType === document.TEXT_NODE) {
stringsToCheck.push(node.textContent);
// gets its parentnode's full text, if this node is the last child
// this is to take care of unsigned templates & results of local time tool
const parent = node.parentElement;
if (parent.lastChild === node) {
stringsToCheck.push(parent.textContent);
stringsToCheck.push(parent.title);
}
} else { /** wrong type, definitely not what we're looking for */
return false;
}
// iterate through timeNodePatterns and try to match stringToCheck one by one
for (const targetRegex of trollJSPostConfig.timeNodePatterns) {
// if one string in stringsToCheck matches, we're golden.
for (const stringToCheck of stringsToCheck) {
if (stringToCheck && stringToCheck.match(targetRegex)) {
return true;
}
}
}
// no match.
return false;
}
/**
* Check whether a node is a barrier, delinating different people's posts
* A post should contain a barrier at its end, but not at its beginning.
* Usually it's a time node, excluded node, or falsy (meaning we have run out of nodes)
* @param {Node} node Node to check
* @returns {Boolean} true or false
*/
static detectBarrierNode(node) {
if (!node) {
return true;
}
return (this.detectExcludedNode(node) || this.detectTimeNode(node));
}
/**
* Detects whether a particular node can be appended or not
* @param {Node} node the node to detect
* @returns {Boolean} true or false
*/
assessNodeAppendability(node) {
if (this.nodes.includes(node)) {
return false;
}
return TrollJSPost.detectExcludedNode(node) !== true;
}
/**
* Adds a node to the end of the list, if it doesn't already exist & is not excluded.
* @param {Node} nodeToAdd node to add
*/
pushNode(nodeToAdd) {
if (this.assessNodeAppendability(nodeToAdd) === true) {
this.nodes.push(nodeToAdd);
}
}
/**
* Adds a node to the beginning of the list, if it doesn't already exist & is not excluded.
* @param {Node} nodeToAdd node to add
*/
unshiftNode(nodeToAdd) {
if (this.assessNodeAppendability(nodeToAdd) === true) {
this.nodes.unshift(nodeToAdd);
}
}
/**
* Gets the owner of the post
* It's the last username that appears
* @returns {String} the username
*/
getOwner() {
// we only need to find the owner once
// if it's already been found before...
if (this._cachedOwner) {
return this._cachedOwner;
}
let result;
// iterate through all posts from beginning to end
// if we find a valid username, then mark the result as it
for (let i = 0; i < this.nodes.length; ++i) {
const thisNode = this.nodes[i];
let thisUsername = trollJSUsernameParser.getUserNameFromNode(thisNode);
if (thisUsername != null) {
result = thisUsername;
}
}
// now result should be the last username ever seen,
// and that's our lucky user
// hold on a moment... cache the result.
this._cachedOwner = result;
// all good
return result;
}
}
/**
* this file is part of...
* troll.js for Chinese Wikipedia
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
*/
/* exported TrollJSRegistrar */
// keeps track of which trolls and nodes have already been processed
class TrollJSRegistrar {
constructor() {
this.slashCountByTroll = {};
this.posts = [];
}
/**
* Increments a troll's slash count
* @param {String} trollName
*/
_incrementTrollSlashCount(trollName) {
// has this troll been recorded in our record yet?
// if not, create its record
if (!this.slashCountByTroll[trollName]) {
this.slashCountByTroll[trollName] = 0;
}
this.slashCountByTroll[trollName] += 1;
}
/**
* Mark a post slashed
* by adding one to its owner in slashCountByTroll
* @param {TrollJSPost} post
*/
markPostSlashed(post) {
const postOwner = post.getOwner();
this._incrementTrollSlashCount(postOwner);
}
/**
* Adds a post, if it doesn't already exist
* @param {TrollJSPost} newPost the post to add
*/
addPost(newPost) {
// don't do anything if newPost is falsy or empty!
if (newPost == null) {
return;
}
if (this.hasPost(newPost) === false) {
this.posts.push(newPost);
}
}
/**
* Checks whether a post already exists or not
* @param {TrollJSPost} post the post to check
* @returns true or false
*/
hasPost(post) {
// must call every single post individually
for (const existingPost of this.posts) {
if (post.equals(existingPost)) {
return true;
}
}
// no hit.
return false;
}
/**
* Retrieves all posts in the registrar.
* You can assume there'll be no duplicates
* @returns a list of posts
*/
getAllPosts() {
return this.posts;
}
}/**
* this file is part of...
* troll.js for Chinese Wikipedia
*
* This module handles interaction between the local storage cache and the remote MediaWiki-managed user options
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
*/
/* global mw */
/* exported MWApiOptionsInterface */
const MW_API_BLACKLIST_KEY = "userjs-trollJSBlacklist";
const MW_API_TIMESTAMP_KEY = "userjs-trollJSLastUpdated";
const apiEndpoint = new mw.Api();
const RemoteStorage = {
/**
* This makes a request to the MW API for currently stored user options.
* @param {string} key: the key to the value
* @returns {Promise<string>} whatever that's stored.
* @throws {Error} if the thing is not set
* @private
* Exploratory test completed 2020-05-11
*/
_readOptions: async function(key) {
const response = await apiEndpoint.post({
"action": "query",
"format": "json",
"meta": "userinfo",
"utf8": 1,
"uiprop": "options"
});
const extractedValue = response.query.userinfo.options[key];
if (!extractedValue) {
throw Error("The requested key is not present.");
}
return extractedValue;
},
/**
* This makes a request to the MW API to write a value.
* @param key {string} key to the value
* @param value {string} value to store
* @private
* Exploratory test passed 2020-05-11
*/
_writeOptions: async function(key, value) {
const queryParams = {
"action": "options",
"format": "json",
"optionname": key,
"optionvalue": value,
"utf8": 1
};
const response = await apiEndpoint.postWithEditToken(queryParams);
if (response.warnings) {
throw Error("Failed to write to options");
}
},
/**
* Gets the current blacklist stored on MediaWiki
* @returns {Promise<object>} whatever's stored.
* @throws {Error} if blacklist is not stored
*/
getBlackList: async function() {
const rawResponse = await this._readOptions(MW_API_BLACKLIST_KEY);
return JSON.parse(rawResponse);
},
/**
* Gets the last update time stored in MediaWiki User Preferences
* NOTE - this will parse the raw string into a number
* @returns {Promise<Number>} whatever's stored
* @throws {Error} if there's any error
*/
getLastUpdateTime: async function() {
const apiOutput = await this._readOptions(MW_API_TIMESTAMP_KEY);
return parseInt(apiOutput);
},
/**
* Pushes the blacklist into the API storage
* @param {string} newBlacklistJSON the new JSON to store in the key
* @throws {Error} if API returns an error
* WARNING - this *does not* update the timestamp
*/
pushBlackList: async function(newBlacklistJSON) {
return await this._writeOptions(MW_API_BLACKLIST_KEY, newBlacklistJSON);
},
/**
* Updates the last updated timestamp.
* @param {Number} timestamp the new timestamp. Will be stringified.
* @returns {Promise<void>}
*/
pushLastUpdateTime: async function(timestamp) {
return await this._writeOptions(MW_API_TIMESTAMP_KEY, JSON.stringify(timestamp));
},
/**
* Same as the function above, but automatically fills in the current time from
* Date.now()
*/
renewLastUpdateTime: async function() {
return await this.pushLastUpdateTime(Date.now());
}
}/**
* this file is part of...
* troll.js for Chinese Wikipedia
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
*/
/* exported trollJSUsernameParser */
/* global trollJSAppIdentifier */
const trollJSUserNameIDPrefix = "(User:|U:|User[ _]Talk:|UT:|Special:(用戶貢獻|用户贡献)\\/)";
// this object specialises in extracting username from nodes
const trollJSUsernameParser = {
// for example: "/wiki/User:Classy_Melissa" => "Classy Melissa"
//userNameExtractorPattern: /`(?<=^https.+\\/wiki\\/${trollJSUserNameIDPrefix}).+$`/gi,
/**
* Gets a username from a link node
* @param {Node} link the link to analyse
* @returns the user name. If there isn't one, it will return null.
*/
getUserNameFromLink: function(link) {
// first check that link is actually a link
if (this._detectLinkNode(link) === false) {
throw new TypeError(trollJSAppIdentifier +
" invalid node passed to userNameExtractor.getUserNameFromLink");
}
const extractorPattern = new RegExp(`${trollJSUserNameIDPrefix}(.+)[/]?$`, "gi");
const linkDestination = decodeURI(link.href);
// try to extract the user name with regex
const userNameMatches = extractorPattern.exec(linkDestination);
// did we get anything out?
if (!userNameMatches) {
return null;
} else {
// got something!
return userNameMatches[3].replace(/_/gi, " ");
}
},
/**
* Detects if a node is a link or not
* @param {Node} nodeToCheck the node to check
* @returns {Boolean} true or false
*/
_detectLinkNode: function(nodeToCheck) {
// if it's not an element, then definitely not a link
if (nodeToCheck.nodeType != document.ELEMENT_NODE) {
return false;
}
// then it's just a matter of tagname
if (nodeToCheck.tagName === 'A') {
return true;
} else {
return false;
}
},
/**
* Parses the username from a node
* If the node is not a link, this function will try to find a link in its parent elements
* @param {Node} nodeToCheck the node to check
* @returns {String} the username. If there isn't one, it will return null
*/
getUserNameFromNode: function(nodeToCheck) {
let linkNode = nodeToCheck;
while (this._detectLinkNode(linkNode) === false) {
// not a link yet
// need to go for its parent element
linkNode = linkNode.parentElement;
// if we have run out of parent elements...
if (!linkNode) {
// definitely no username here.
return null;
}
}
// got a link node
return this.getUserNameFromLink(linkNode);
}
}
/**
* main.js for Chinese Wikipedia
*
* This module simply launches all other necessary modules
*
* @author User:Classy Melissa @ zh.wikipedia.org
* @version 1
* @licence CC-0
*
*/
trollJSBlockUI.start();
trollJSApp.start();}
trollJSContainerFunction();