-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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}}">>></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}}">>>{{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"> [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(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Development has been moved here: