Skip to content

Instantly share code, notes, and snippets.

@g-gundam
Last active June 22, 2023 13:28
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save g-gundam/8f9985e6aaa0dab6eecc556ddcbca370 to your computer and use it in GitHub Desktop.
Save g-gundam/8f9985e6aaa0dab6eecc556ddcbca370 to your computer and use it in GitHub Desktop.
This is a work-in-progress for a userscript that interleaves ghost posts from desuarchives into live threads on 4chan.
// ==UserScript==
// @name 4chan GhostPostMixer
// @namespace Violentmonkey Scripts
// @match https://boards.4channel.org/a/thread/*
// @match https://boards.4chan.org/aco/thread/*
// @match https://boards.4channel.org/an/thread/*
// @match https://boards.4channel.org/c/thread/*
// @match https://boards.4channel.org/cgl/thread/*
// @match https://boards.4channel.org/co/thread/*
// @match https://boards.4chan.org/d/thread/*
// @match https://boards.4channel.org/fit/thread/*
// @match https://boards.4channel.org/g/thread/*
// @match https://boards.4chan.org/gif/thread/*
// @match https://boards.4channel.org/his/thread/*
// @match https://boards.4channel.org/int/thread/*
// @match https://boards.4channel.org/k/thread/*
// @match https://boards.4channel.org/m/thread/*
// @match https://boards.4channel.org/mlp/thread/*
// @match https://boards.4channel.org/mu/thread/*
// @match https://boards.4channel.org/q/thread/*
// @match https://boards.4channel.org/qa/thread/*
// @match https://boards.4chan.org/r9k/thread/*
// @match https://boards.4channel.org/tg/thread/*
// @match https://boards.4chan.org/trash/thread/*
// @match https://boards.4channel.org/vr/thread/*
// @match https://boards.4channel.org/wsg/thread/*
// @version 1.1
// @author anon && a random husky lover from /an/
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @description Interleave ghost posts from the archives into 4chan threads. This is a prototype.
// ==/UserScript==
/*
* The idea needs to be expanded further.
* - More boards need to be supported.
* - It could use more error checking.
*/
// Based on https://gist.github.com/g-gundam/8f9985e6aaa0dab6eecc556ddcbca370
const template = `<div class="postContainer replyContainer {{postClass}}" id="pc{{postId}}">
<div class="sideArrows" id="sa{{postId}}">&gt;&gt;</div>
<div id="p{{postId}}" class="post reply {{replyClass}}">
<div class="postInfoM mobile" id="pim{{postId}}">
<span class="nameBlock"><span class="name">{{authorName}}</span><br /></span>
<span class="dateTime postNum" data-utc="unixTime">{{formattedDate}} <a href="#p{{postId}}" title="Link to this post">No.</a><a href="javascript:quote('{{postId}}');" title="Reply to this post">{{postId}}</a></span>
</div>
<div class="postInfo desktop" id="pi{{postId}}">
<span class="nameBlock"><span class="name">{{authorName}}</span> </span> {{timeHtml}}
<span class="postNum desktop"><a href="#p{{postId}}" title="Link to this post">No.</a><a href="javascript:quote('{{postId}}');" title="Reply to this post">{{postId}}</a></span>
</div>
{{fileBlock}}
<blockquote class="postMessage" id="m{{postId}}">{{contentHtml}}</blockquote>
</div>
</div>`;
const fileTemplate = `<div class="file" id="f{{postId}}">
<div class="fileText" id="fT{{postId}}">File: <a title="{{fileName}}" href="{{fileUrl}}" target="_blank">{{fileName}}</a> ({{fileMeta}})</div>
<a class="fileThumb" href="{{fileUrl}}" target="_blank">
<img src="{{fileUrl}}" width="{{fileWidth}}" height="{{fileHeight}}"/>
<div data-tip="" data-tip-cb="mShowFull" class="mFileInfo mobile">{{fileMeta}}</div>
</a>
</div>`
const backLinkTemplate = `<a href="#p{{postId}}" class="quotelink" data-function="highlight" data-backlink="true" data-board="an" data-post="{{postId}}">&gt;&gt;{{postId}}</a>`;
// https://gist.github.com/GitHub30/59e002a5f57a4df0decbd7ac23290f77
async function get(url) {
return new Promise((resolve) => {
GM.xmlHttpRequest({
method: "GET",
url,
onload: resolve,
});
});
}
function renderTemplate(template, data) {
return template.replace(/{{([^}]+)}}/g, (match, key) => data[key] ?? '');
}
function htmlToElement(html) {
var template = document.createElement('template');
html = html.trim(); // Never return a text node of whitespace as the result
template.innerHTML = html;
return template.content.firstChild;
}
function extractPostVariables(post) {
const postId = post.id;
const content = post.querySelector(".text");
const contentText = content?.innerText ?? "";
const contentHtml = content?.innerHTML?.replace(/https:\/\/desuarchive\.org\/\w+\/thread\/\d+\/#(\d+)/gi, '#p$1').replace(/backlink/gi, 'quotelink').replace(/\n/g, "<br />") ?? "";
const file = post.querySelector(".post_file");
const fileName = file?.querySelector(".post_file_filename")?.innerText ?? "";
const fileUrl = file?.querySelector(".post_file_filename")?.getAttribute('href') ?? "";
const fileMeta = (file?.querySelector(".post_file_metadata")?.innerText ?? "").trim();
const fileWidth = file ? post.querySelector(".thread_image_box img").width : 0;
const fileHeight = file ? post.querySelector(".thread_image_box img").height : 0;
const authorName = `${post.querySelector(".post_author").innerText} ${post.querySelector(".post_tripcode").innerText}`.trim();
const timeHtml = post.querySelector(".time_wrap").innerHTML;
return {
content: contentText,
contentHtml,
fileName,
fileUrl,
fileMeta,
fileWidth,
fileHeight,
postId,
authorName,
timeHtml,
};
}
// Create a new DOM element suitable for insertion into a 4chan thread.
function postTemplate(post, vars) {
const data = extractPostVariables(post);
// set some conditional parameters (we could extract these from the posts but the original code does it this way)
data.postId = vars.n ? `${vars.parentId}_${vars.n}` : vars.parentId;
data.replyClass = vars.deleted ? 'del' : 'ghost';
data.postClass = vars.deleted ? 'post-deleted': 'post-ghost';
if (data.fileUrl) {
data.fileBlock = renderTemplate(fileTemplate, data);
}
return htmlToElement(renderTemplate(template, data));
}
// Go throught the entire thread and fix all dead links if we inserted a deleted posts from the archive
// This works with the built in extension
function fixDeadLinks(postId) {
const deadLinks = Array.from(document.body.querySelectorAll('.thread .deadlink')).filter(e => e.innerText == `>>${postId}`);
for (const deadLink of deadLinks) {
deadLink.replaceWith(htmlToElement(renderTemplate(backLinkTemplate, { postId })));
}
}
function insertGhost(post, threadId) {
const [parentId, n] = post.id.split("_").map((x) => parseInt(x, 10));
//console.log('ag', {parentId, n})
let parent = document.getElementById(`pc${parentId}`);
if (n > 1) {
// if the n is higher than 1, we need to find the parent ghost post
// but in some cases, the parent ghost post was deleted, in which case we insert the
// ghost post after the main parent post.
parent = document.getElementById(`pc${parentId}_${n - 1}`) || parent;
}
if (parent) {
const newPost = postTemplate(post, { parentId, n });
parent.append(newPost);
}
}
function insertDeleted(post, posts) {
const postId = parseInt(post.id, 10);
let i = Array.prototype.findIndex.call(
posts,
(p) => parseInt(p.id) === postId
);
if (i === -1) return;
if (i === 0) {
// Deleted post is the first post in the thread. This would require recreating the entire thread.
// TODO: Just redirect to the archives, idk.
console.error('deleted post is first post in thread');
return;
}
const newPost = postTemplate(post, { parentId: postId, n: 0, deleted: true });
let before = posts[i - 1];
let target;
if (before) target = document.getElementById(`pc${before.id}`);
if (target) {
target.after(newPost);
} else {
// XXX - This is a terrible hack that I wish I didn't have to do.
// If I can find a way around it, I will.
// Apparently target.after(newPost) isn't as synchronous as it looks.
// I'm not sure why was it an issue, help?
console.log("f", postId, i);
setTimeout(() => {
const target = document.getElementById(`pc${before.id}`);
if (!target) return;
target.after(newPost);
}, 100);
}
fixDeadLinks(postId);
}
async function main() {
// Get thread id
const parts = window.location.pathname.split("/");
const threadId = parseInt(parts[3]);
const boardId = parts[1];
console.log('interlacing posts');
document.body.classList.add('interlacing-loader');
// Fetch thread from archives
const archiveUrl = `https://desuarchive.org/${boardId}/thread/${threadId}/`;
const res = await get(archiveUrl);
// TODO: Check if the thread actually exists on the archive
const parser = new DOMParser();
const doc = parser.parseFromString(res.responseText, "text/html");
const posts = doc.querySelectorAll("article.post");
const ghosts = doc.querySelectorAll("article.post.post_ghost");
const trash = doc.querySelectorAll(".icon-trash");
const deleted = Array.prototype.map.call(trash, (t) => t.closest("article"));
deleted.forEach((post) => insertDeleted(post, posts));
ghosts.forEach((post) => insertGhost(post, threadId));
// Update the thread stats with what we interlaced
console.log(`interlaced ${deleted.length} deleted posts and ${ghosts.length} ghost posts`);
document.body.querySelectorAll('.thread-stats .ts-replies').forEach(e => e.insertAdjacentElement('afterend', htmlToElement(`<span class="text-muted">&nbsp;[d: ${deleted.length}, g: ${ghosts.length}]</span>`)));
document.body.classList.remove('interlacing-loader');
}
// Add CSS
const css = `
div.post.ghost {
background-color: #ddd;
}
div.post.del {
background-color: #eab3b3;
}
.text-muted {
color: #6c757d!important;
}
.post-ghost {
margin-left: 2em;
}
body.interlacing-loader::before {
content: '';
position: fixed;
bottom: 0;
left: 0;
border-bottom: 0.4rem solid red;
animation: loading 2s linear infinite;
}
@keyframes loading {
0% {
left:0%;
right:100%;
width:0%;
}
10% {
left:0%;
right:75%;
width:25%;
}
90% {
right:0%;
left:75%;
width:25%;
}
100% {
left:100%;
right:0%;
width:0%;
}
}
`;
const style = document.createElement("style");
style.type = "text/css";
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
// Run main code
main();
@g-gundam
Copy link
Author

g-gundam commented Apr 13, 2022

How This UserScript Was Born

>>>/g/86482161 (original)
>>>/g/86482161 (archive)

Why aren't (You) ghostposting on the archives?
https://desuarchive.org/g/ghost/

@kurisufriend
Copy link

you could possibly circumvent hardcoding the archived boards by matching the full catchall (i.e. https://boards.4chan*.org/*/thread/*) then stopping if the response code is a 404 (which it is if the board isn't archived).
this would of course send a useless request for un-archived threads, but it proofs the script against changes in the board list.

@g-gundam
Copy link
Author

@kurisufriend I'll consider that for the future. I eventually want to properly support all boards on 4chan, but I won't get around to that for a while.

@g-gundam
Copy link
Author

g-gundam commented Apr 21, 2022

A friendly anon from /x/+/an/ contributed some improvements. I will be incorporating them here, but for those of you who want to install it now, go here: https://pastebin.com/eqv5RRsP . It's a significant improvement.

I posted my initial thoughts at omegachen.top archive. It includes some screenshots of how his version looks for the curious. Overall, I like it.

UPDATE 2022-04-25: omegachen.top/tech has migrated to 2chen.

@g-gundam
Copy link
Author

I updated the gist to include the latest changes. I only made minor formatting changes to standardize indentation. It's otherwise the same code from the pastebin.

@g-gundam
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment