// ==UserScript==
// @name TGchan ID Tracker
// @namespace TGchanIDTracker
// @include *//tgchan.org/kusaba/quest/res/*
// @include *//tgchan.org/kusaba/questarch/res/*
// @include *//tgchan.org/kusaba/questdis/res/*
// @include *//tgchan.org/kusaba/graveyard/res/*
// @description Provides info about each poster and allows navigating poster's posts.
// @version 5
// @grant none
// @icon data:image/vnd.microsoft.icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAsSAAALEgHS3X78AAAANklEQVQokWNgoBOI2mJKpEomMvQgNAxRPUy4JGjjJJqoZoSrZmBgWOZzGlk/mlKILBMafxAAAE1pG/UEXzMMAAAAAElFTkSuQmCC
// ==/UserScript==
"use strict";
var timeStart = Date.now();
if (document.body.querySelector("#navUp")) { //sanity check; don't run the script if it already ran
return;
}
var threadID = document.querySelector("[id$=-y]").id;
var boardName = threadID.split("-")[1];
var posts = getPosts(); //extract and cache whatever stuff we need from DOM;
var userNodes = {}; //dictionary => posterId / user node ; we use these nodes to build a graph, connecting one user to the other
doPartialUnionFind(); //do a partial union-find run to determine if this is an image quest
var author = find(makeSet(posts[0].userID)); //author is the user that made the first post
var imageQuest = boardName != "questdis" && (author.fileCount / author.postCount) >= 0.5; //Image quests are when the author posts images more than 50% of the time.
if (!imageQuest) { //do normal union-find again from the start
userNodes = {};
doNormalUnionFind();
author = find(userNodes[posts[0].userID]);
}
else { //continue from the previous partial union-find, but adhere to certain union rules
doConditionalUnionFind(); //specifically, even if author's ID has a link to some other user's ID, we will not merge the two unless that other user posted files
var lastNumberInStringRegex = new RegExp("([0-9]+)([^0-9]*)$"); //regular expressions must be precompiled since we'll be using each thousands of times
doUserAnalytics(); //do some advanced analytics to determine which users could also be the author and consequently merge them with the author
}
setPostUsers(); //do final user unification
setPostCountElements(); //set correct post count to our cached elements based on which user they belong to
var hrefString = "/kusaba/" + boardName + "/res/" + threadID.split("-")[2] + ".html#";
setPostLinkElements(); //set correct targets to our up/down link elements based on which user they belong to
setPostUpdates(); //set a flag to posts that are updates
if (boardName != "questdis") {
setPostMetadata(); //add additional data next to our elements, such as when a poster made multiple posts within an update
}
//finally insert stuff into DOM
insertButtonIcons(); //add icons for our elements; f*ck firefox for using shitty emoji characters
insertCSS(); //add CSS for our elements
insertPostInfo(); //add poster info next to each post's ID
//console.log(getUsers()); //for debugging purposes; outputs a list of users and the IDs that each user uses
console.log(`TGchan ID Tracker run time = ${Date.now() - timeStart}ms`);
//THE END
//functions ; things which should be simple but are not
function getPosts() {
var defaultName = boardName == "questdis" ? "Anonymous" : "Suggestion";
var defaultFragment = createDefaultFragment(); //optimization => create a generic document fragment with post count and up/down link, which we'll be cloning
var postsIndex = {}; //dictionary => postID / post object
var postsArray = []; //array => post object
document.querySelectorAll(".postwidth").forEach(function(postHeaderElement) { //querySelectorAll is FASTER than getElementsByClassName when DOM is large
var postID = postHeaderElement.querySelector("span[id^=dnb]").id.split("-")[2];
if (!postsIndex[postID]) { //checking this may seem unnecessary, but it's required for compatibility with some imageboard extensions
var uidElement = postHeaderElement.querySelector(".uid");
var uid, name, trip;
uid = uidElement.textContent.substring(4);
trip = postHeaderElement.querySelector(".postertrip");
if (trip) { //use tripcode instead of name if it exists
name = trip.textContent;
}
else {
name = postHeaderElement.querySelector(".postername").textContent.trim();
if (name && name == defaultName) {
name = "";
}
}
postsIndex[postID] = {
postID: postID,
userID: uid,
name: name,
subject: postHeaderElement.querySelector(".filetitle"),
fileElement: postHeaderElement.querySelector(".filesize"),
activeContent: postHeaderElement.nextElementSibling.querySelector("img, iframe"), //if the blockquote contains icons (or iframes heh)
insertElement: uidElement, //element next to which we're gonna be inserting our stuff.
fragment: defaultFragment.cloneNode(true) //Optimization: Use a temporary documentFragment for each reply to minimize DOM insertions; all of the insertions are done at the end
};
}
});
//convert to an array. This may seem unnecessary, but it's done in a milisecond so... *shrug*; allows me to use forEach()
for (var postID in postsIndex) {
postsArray.push(postsIndex[postID]);
}
return postsArray;
}
function createDefaultFragment() {
var fragment = document.createDocumentFragment();
fragment.appendChild(document.createRange().createContextualFragment(
`<span class="postCount inherit"></span>
<a class="disabled"><svg class="navIcon"><use xlink:href="#navUp"/></svg></a>
<a class="disabled"><svg class="navIcon"><use xlink:href="#navDown"/></svg></a>`));
return fragment;
}
function doNormalUnionFind() {
posts.forEach(function(post) {
var user = !post.name ? find(makeSet(post.userID)) : union(makeSet(post.userID), makeSet(post.name));
user.postCount++;
if (post.fileElement || post.activeContent) {
user.fileCount++;
}
});
}
function doPartialUnionFind() {
posts.forEach(function(post) {
if (post.fileElement || post.activeContent) {
var user = !post.name ? find(makeSet(post.userID)) : union(makeSet(post.userID), makeSet(post.name));
user.postCount++;
user.fileCount++;
}
});
posts.forEach(function(post) {
if (!post.fileElement && !post.activeContent) {
var userFromID = userNodes[post.userID];
var userFromName = userNodes[post.name];
if (userFromID && userFromName) {
union(userFromID, userFromName).postCount++;
}
else if (userFromID) {
find(userFromID).postCount++;
}
else if (userFromName) {
find(userFromName).postCount++;
}
}
});
}
function doConditionalUnionFind() {
var alreadyCounted = {};
for (var userNode in userNodes) {
alreadyCounted[userNode] = true;
}
posts.forEach(function(post) {
if (!post.fileElement && !post.activeContent) {
var userFromID = find(makeSet(post.userID));
if (post.name) {
var userFromName = find(makeSet(post.name));
if (userFromID != author && userFromName != author) {
if (!alreadyCounted[userFromID.id] && !alreadyCounted[userFromName.id]) {
userFromID.postCount++;
}
union(userFromID, userFromName);
}
}
else {
if (!alreadyCounted[userFromID.id]) {
userFromID.postCount++;
}
}
}
});
posts.forEach(function(post) {
if (!post.fileElement && !post.activeContent) {
var userFromID = find(makeSet(post.userID));
if (post.name) {
var userFromName = find(makeSet(post.name));
if ((userFromID == author && userFromName != author && userFromName.fileCount > 0) || (userFromName == author && userFromID != author && userFromID.fileCount > 0)) {
console.log(`Merged ${userFromID == author ? userFromID : userFromName} with author (post ${post.postID}) (indirect name/ID link)`);
author = union(userFromID, userFromName);
}
}
}
});
}
function doUserAnalytics() {
//consider posters, who follow the file naming scheme, as being the quest author
//consider posters, who create posts that contain both icons and files, as the quest author
//consider posters, who post images at least 75% of the time and who posted 5+ images, as being the quest author. Actually, nevermind. This doesn't seem to work well.
var predictedFileName;
var lastFileNames = [];
var lastUsers = [];
lastUsers.push(author);
posts.forEach(function(post) {
if (post.fileElement && post.fileElement.firstChild.textContent.trim() == "File") {
var user = find(makeSet(post.userID));
var fileInfo = post.fileElement.lastChild.textContent.split(", "); //flash files have no original name
var fileName = fileInfo[2] ? fileInfo[2].split("\n")[0] : "";
if (post.postID == "1047") {
var asd = "asd";
}
if (user == author) {
var backwardsPredictedName = getPredictedFileName(fileName, -1);
for (let i = 0; i < lastUsers.length; i++) {
if (lastUsers[i] != author && lastFileNames[i] == backwardsPredictedName) { //also predict backwards :)
author = union(lastUsers[i], author);
console.log(`Merged ${lastUsers[i].id} with author (post ${post.postID}) for file naming scheme (backwards)`);
}
}
predictedFileName = getPredictedFileName(fileName, 1);
lastFileNames = [];
lastUsers = [];
}
else {
if (fileName == predictedFileName) {
author = union(user, author);
console.log(`Merged ${user.id} with author (post ${post.postID}) for file naming scheme`);
predictedFileName = getPredictedFileName(fileName, 1);
user = author;
}
else if (post.activeContent) {
author = union(user, author);
console.log(`Merged ${user.id} with author (post ${post.postID}) for making a post with file and icons`);
predictedFileName = getPredictedFileName(fileName, 1);
user = author;
}
/*else if (user.fileCount >= 5 && (user.fileCount / user.postCount) >= 0.75) {
author = union(user, author);
console.log(`Merged ${user.id} with author (post ${post.postID}) for being a common image poster`);
predictedFileName = getPredictedFileName(fileName, 1);
user = author;
}*/
lastFileNames.push(fileName);
lastUsers.push(user);
}
}
});
}
function setPostUsers() {
posts.forEach(function(post) {
post.user = find(makeSet(post.userID));
if (post.name) {
var userFromName = find(makeSet(post.name));
if (post.user != userFromName && userFromName == author) {
post.user = userFromName; //in case a post belongs to two unconnected users, heh, which can only happen if one of them is the author, set its owner to the one that is the author
}
}
});
}
function setPostCountElements() {
posts.forEach(function(post) {
post.fragment.firstChild.textContent = "(" + post.user.postCount + ")";
});
}
function setPostLinkElements() {
var lastPosts = {}; //dictionary => userID / post object; remember last post for each poster so that we can create links between them
posts.forEach(function(post) {
var lastPost = lastPosts[post.user.id];
if (lastPost) {
setLinkTarget(post.fragment.children[1], lastPost.postID);
setLinkTarget(lastPost.fragment.children[2], post.postID);
}
lastPosts[post.user.id] = post;
});
}
function setLinkTarget(link, targetPostID) {
link.className = "inherit";
link.href = hrefString + targetPostID;
link.onclick = function() { return highlight(targetPostID, true); };
//unfortunately, previews break links which navigate down to a later post. TGchan <:)
//link.className = "ref|"+boardName+"|"+threadID+"|" + targetPostID;
//addmouseoverevents(link, targetPostID);
}
/*function addmouseoverevents(link, targetPostID) {
if (link.addEventListener) {
link.addEventListener("mouseover", addreflinkpreview, false);
link.addEventListener("mouseout", delreflinkpreview, false);
}
else if (link.attachEvent) {
link.attachEvent("onmouseover", addreflinkpreview);
link.attachEvent("onmouseout", delreflinkpreview);
}
}*/
function setPostUpdates() {
// Updates are posts made by the author and, in case of image quests, author post that contain files or icons
var partialUpdateRegex = new RegExp("[0-9][ ]*/[ ]*[0-9]");
posts.forEach(function(post) {
if (post.user == author && (!imageQuest || (post.fileElement || post.activeContent || isPartialUpdateSubject(post.subject, partialUpdateRegex)))) {
post.isUpdate = true;
}
});
}
function isPartialUpdateSubject(subject, partialUpdateRegex) {
if (subject) {
return subject.textContent.trim().match(partialUpdateRegex);
}
return false;
}
function setPostMetadata() {
var lastPost = posts[(posts.length - 1)];
var suggestionsGroup = {}; //dictionary => userID / array[postID] ; a group of posts made in-between two updates, grouped by user ID
posts.forEach(function(post) {
if (!post.isUpdate) {
if (!suggestionsGroup[post.user.id]) {
suggestionsGroup[post.user.id] = [];
}
suggestionsGroup[post.user.id].push(post);
}
if (post.isUpdate || post == lastPost) { //process the group of suggestions
detectMultiposts(suggestionsGroup);
suggestionsGroup = {};
}
if (!post.isUpdate && post.fileElement) {
post.fragment.appendChild(getNewCrimsonSpan(" (non-update)"));
}
if (!post.isUpdate && post.user == author) {
post.fragment.appendChild(getNewCrimsonSpan(" (clarification)"));
}
});
}
function detectMultiposts(suggestionsGroup) {
for (var userID in suggestionsGroup) {
var user = suggestionsGroup[userID][0].user;
var suggestions = suggestionsGroup[userID];
var count = suggestions.length
if (count == user.postCount) {
for (let i = 0; i < count; i++) { //mark post count for (new) posters who only made posts in this update
suggestions[i].fragment.firstChild.className = "postCount crimson";
}
}
if (count > 1 && user != author) {
for (let i = 0; i < count; i++) { //mark posts of the users that made multiple posts inside an update
suggestions[i].fragment.appendChild(getNewCrimsonSpan(" post " + (i+1) + "/" + count));
}
}
}
}
function getNewCrimsonSpan(text) {
var newSpan = document.createElement("span");
newSpan.textContent = text;
newSpan.className = "crimson";
return newSpan;
}
function getPredictedFileName(fileName, inc) {
if (!fileName) {
return;
}
var matches = fileName.match(lastNumberInStringRegex);
if (!matches) {
return "";
}
//e.g. quest50.png => quest51.png
return fileName.replace(lastNumberInStringRegex, (+matches[1]+inc+"").padStart(matches[1].length,0)+"$2");
}
function insertCSS() {
var newStyle = document.createElement("style");
newStyle.innerText = " .inherit { color: inherit; } .crimson { color: crimson; } .disabled { color: grey; } .disabled:hover { color: grey; } .postCount { margin-left: 3px; } .navIcon { width: 12px; height: 18px; vertical-align: text-top; }";
document.body.appendChild(newStyle);
}
function insertButtonIcons() {
document.body.insertAdjacentHTML("beforeend",`
<div style="height: 0; width: 0; position: fixed;">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<symbol viewBox="0 0 24 24" id="navUp"><path stroke="currentColor" fill="none" stroke-width="3" stroke-miterlimit="10" d="M3 22.5l9-9 9 9M3 13.5l9-9 9 9"/></symbol>
<symbol viewBox="0 0 24 24" id="navDown"><path stroke="currentColor" fill="none" stroke-width="3" stroke-miterlimit="10" d="M3 11.5l9 9 9-9M3 2.5l9 9 9-9"/></symbol>
</svg>
</div>`);
}
function insertPostInfo() {
posts.forEach(function(post) {
post.insertElement.parentNode.insertBefore(post.fragment, post.insertElement.nextSibling);
});
}
function getUsers() {
var isIDRegEx = new RegExp("^[0-9a-f]{6}$");
var users = Object.values(userNodes).filter(u => u.parent == u).map(u => Object.keys(userNodes).filter(k => find(userNodes[k]) == u));
var sortedUsers = users.filter(u => u.length > 1 && find(userNodes[u[0]]) != author);
var anons = users.filter(u => u.length == 1 && u[0] != author.id);
sortedUsers.sort((a, b) => { return find(userNodes[b[0]]).postCount - find(userNodes[a[0]]).postCount; });
sortedUsers.splice(0, 0, users.filter(u => find(userNodes[u[0]]) == author)[0]);
sortedUsers.forEach(function(arr) {
arr.sort((a,b) => { var am = isIDRegEx.test(a); var bm = isIDRegEx.test(b); if (am && bm || !am && !bm) return a.localeCompare(b); else return am ? 1 : -1; });
arr.splice(0, 0, find(userNodes[arr[0]]).postCount);
});
anons.sort((a,b) => { return userNodes[b].postCount - userNodes[a].postCount; });
anons = anons.map( anonID => `${anonID} (${userNodes[anonID].postCount})` );
anons.splice(0, 0, "Anons:");
sortedUsers.push(anons);
return sortedUsers;
}
//Welcome to UNION-FIND. I'm using this algorithm to group id's and names
function makeSet(id) {
var userNode = userNodes[id];
if (!userNode) {
var newUser = { id: id, postCount: 0, fileCount: 0 }; //I'm a special snowflake, so I'm gonna be using postCount instead of rank/size to determine how to merge sets
newUser.parent = newUser;
userNodes[id] = newUser;
return newUser;
}
return userNode;
}
function find(node) { //find with included path compression
while (node.parent != node) {
var curr = node;
node = node.parent;
curr.parent = node.parent;
}
return node;
}
function union(node1, node2) {
var node1root = find(node1);
var node2root = find(node2);
if (node1root == node2root) {
return node1root;
}
if (node1root.postCount < node2root.postCount) {
var temp = node1root;
node1root = node2root;
node2root = temp;
}
node2root.parent = node1root;
node1root.postCount += node2root.postCount;
node1root.fileCount += node2root.fileCount;
return node1root;
}