-
-
Save g-gundam/8f9985e6aaa0dab6eecc556ddcbca370 to your computer and use it in GitHub Desktop.
// ==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(); |
I just added support for all boards archived by desuarchive. That includes:
- /a/ Anime & Manga
- /aco/ Adult Cartoons
- /an/ Animals & Nature
- /c/ Anime/Cute
- /cgl/ Cosplay & EGL
- /co/ Comics & Cartoons
- /d/ Hentai/Alternative
- /fit/ Fitness
- /g/ Technology
- /gif/ Adult GIF
- /his/ History & Humanities
- /int/ International
- /k/ Weapons
- /m/ Mecha
- /mlp/ Pony
- /mu/ Music
- /q/ 4chan Feedback
- /qa/ Question & Answer
- /r9k/ ROBOT9001
- /tg/ Traditional Games
- /trash/ Off-Topic
- /vr/ Retro Games
- /wsg/ Worksafe GIF
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.
@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.
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.
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.
Development has been moved here:
How This UserScript Was Born
>>>/g/86482161 (original)
>>>/g/86482161 (archive)
Why aren't (You) ghostposting on the archives?
https://desuarchive.org/g/ghost/