// ==UserScript==
// @name jpnkn to Reddit Style (Optimized)
// @name:en jpnkn to Reddit Style (Optimized)
// @name:ja jpnkn を Reddit 風に (最適化版)
// @name:ko jpnkn Reddit 스타일로 (최적화됨)
// @name:zh-CN jpnkn 论坛转 Reddit 风格 (优化版)
// @name:zh-TW jpnkn 論壇轉 Reddit 風格 (優化版)
// @license MIT
// @namespace http://tampermonkey.net/
// @version 0.9.1
// @description Transforms jpnkn threads into a Reddit-like nested view, lazy loads image previews, handles broken images, and embeds YouTube videos.
// @description:en Transforms jpnkn threads into a Reddit-like nested view, lazy loads image previews, handles broken images, and embeds YouTube videos.
// @description:ja jpnknのスレッドをRedditのようなネスト表示に変換し、画像プレビューを遅延読み込みし、壊れた画像を処理し、YouTube動画を埋め込みます。
// @description:ko jpnkn 스레드를 Reddit과 유사한 중첩 보기로 변환하고, 이미지 미리보기를 지연 로드하며, 깨진 이미지를 처리하고, YouTube 비디오를 삽입합니다.
// @description:zh-CN 将 jpnkn 论坛帖子转换为类似 Reddit 的嵌套楼中楼视图,支持图片预览懒加载、处理失效图片链接以及嵌入 YouTube 视频。
// @description:zh-TW 將 jpnkn 論壇帖子轉換為類似 Reddit 的巢狀樓中樓檢視,支援圖片預覽延遲載入、處理失效圖片連結以及嵌入 YouTube 影片。
// @author NBXX
// @match https://bbs.jpnkn.com/test/read.cgi/*/*/*
// @grant GM_addStyle
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// --- Configuration ---
const MAX_PREVIEW_HEIGHT = '400px';
const INDENTATION_SIZE = 20;
const SHOW_PREVIEWS_BY_DEFAULT = true; // For images (videos will have a button)
const LAZY_LOAD_OFFSET = '200px'; // Load images when they are 200px away from viewport
GM_addStyle(`
.reddit-style-container { padding: 10px; }
.reddit-post {
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 8px;
background-color: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.reddit-post > .original-post-content {
padding: 8px;
}
.reddit-post > .original-post-content dt { font-size: 0.9em; color: #555; }
.reddit-post > .original-post-content dd { margin-left: 1.5em; font-size: 1em; line-height: 1.4; }
.replies-wrapper {
margin-left: ${INDENTATION_SIZE}px;
padding-left: 10px;
border-left: 2px solid #e0e0e0;
margin-top: 5px;
}
.media-preview-container { margin-top: 8px; }
.media-preview-container img {
max-width: 100%;
max-height: ${MAX_PREVIEW_HEIGHT};
display: block;
border: 1px solid #ddd;
border-radius: 3px;
background-color: #f9f9f9;
cursor: zoom-in;
min-height: 50px; /* Placeholder height before loading */
}
.media-preview-container img.expanded {
max-height: none;
cursor: zoom-out;
}
.media-toggle-btn, .video-toggle-btn {
font-size: 0.8em;
color: #007bff;
cursor: pointer;
margin-left: 5px;
text-decoration: underline;
display: inline-block; /* Keep it on the same line */
}
.toggle-replies-btn {
cursor: pointer;
color: #777;
font-size: 0.8em;
margin-left: 10px;
}
.original-link.broken-link {
text-decoration: line-through;
color: #d9534f; /* Bootstrap's danger color */
}
.youtube-embed-container {
margin-top: 8px;
position: relative;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
height: 0;
overflow: hidden;
max-width: 100%;
background: #000;
}
.youtube-embed-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
`);
let imageObserver;
function initializeImageObserver() {
if (imageObserver) {
imageObserver.disconnect();
}
imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const src = img.dataset.src;
if (src) {
img.src = src;
img.removeAttribute('data-src'); // No need to load again
}
observer.unobserve(img);
}
});
}, { rootMargin: LAZY_LOAD_OFFSET });
}
function isImageLink(url) {
if (!url) return false;
try {
const path = new URL(url).pathname.toLowerCase();
return /\.(jpeg|jpg|gif|png|webp)$/.test(path);
} catch (e) { return false; }
}
function getYouTubeVideoId(url) {
if (!url) return null;
try {
const parsedUrl = new URL(url);
let videoId = null;
if (parsedUrl.hostname === 'youtu.be') {
videoId = parsedUrl.pathname.slice(1);
} else if (parsedUrl.hostname.includes('youtube.com') && parsedUrl.pathname === '/watch') {
videoId = parsedUrl.searchParams.get('v');
} else if (parsedUrl.hostname.includes('youtube.com') && parsedUrl.pathname.startsWith('/embed/')) {
videoId = parsedUrl.pathname.split('/embed/')[1].split('?')[0];
}
// Basic check for valid ID format
if (videoId && /^[a-zA-Z0-9_-]{11}$/.test(videoId)) {
return videoId;
}
return null;
} catch (e) {
return null;
}
}
function processPostContent(ddElement) {
const links = ddElement.querySelectorAll('a');
links.forEach(link => {
const href = link.href;
// 1. Image Previews (Lazy Loaded)
if (isImageLink(href)) {
link.classList.add('original-link');
const container = document.createElement('div');
container.className = 'media-preview-container';
container.style.display = SHOW_PREVIEWS_BY_DEFAULT ? 'block' : 'none';
const img = document.createElement('img');
img.dataset.src = href; // Store real src in data attribute for lazy loading
img.alt = 'Image Preview';
// img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; // 1x1 transparent gif
img.addEventListener('click', () => img.classList.toggle('expanded'));
img.onerror = () => {
container.style.display = 'none'; // Hide preview container
link.classList.add('broken-link');
link.title = '画像読み込み失敗';
if (toggleBtn) toggleBtn.style.display = 'none'; // Hide toggle if it exists
};
const toggleBtn = document.createElement('span');
toggleBtn.className = 'media-toggle-btn';
toggleBtn.textContent = SHOW_PREVIEWS_BY_DEFAULT ? '[画像を隠す]' : '[画像を表示]';
toggleBtn.addEventListener('click', (e) => {
e.preventDefault();
const isVisible = container.style.display !== 'none';
container.style.display = isVisible ? 'none' : 'block';
toggleBtn.textContent = isVisible ? '[画像を表示]' : '[画像を隠す]';
});
container.appendChild(img);
// Insert elements: toggle after link, container after that or parent
link.insertAdjacentElement('afterend', toggleBtn);
let insertTarget = link.nextSibling; // The toggle button
if(insertTarget) {
insertTarget.insertAdjacentElement('afterend', container);
} else {
link.parentElement.appendChild(container);
}
if (SHOW_PREVIEWS_BY_DEFAULT) { // Only observe if initially visible
imageObserver.observe(img);
} else {
// If not shown by default, only observe when user clicks "show"
toggleBtn.addEventListener('click', () => {
if (container.style.display !== 'none' && img.dataset.src) {
imageObserver.observe(img);
}
}, { once: true }); // Only need to set this up once
}
} // End Image Preview
// 2. YouTube Embeds
else {
const videoId = getYouTubeVideoId(href);
if (videoId) {
link.classList.add('original-link');
const videoContainer = document.createElement('div');
// videoContainer.className = 'youtube-embed-container'; // Apply this when iframe is added
videoContainer.style.display = 'none'; // Initially hidden
const toggleVideoBtn = document.createElement('span');
toggleVideoBtn.className = 'video-toggle-btn';
toggleVideoBtn.textContent = '[動画を再生]';
toggleVideoBtn.addEventListener('click', (e) => {
e.preventDefault();
if (videoContainer.style.display === 'none') {
// Create iframe on demand
if (!videoContainer.querySelector('iframe')) {
videoContainer.innerHTML = ''; // Clear previous content if any
videoContainer.className = 'youtube-embed-container'; // Set class for aspect ratio
const iframe = document.createElement('iframe');
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`; // Added autoplay
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
iframe.setAttribute('allowfullscreen', '');
videoContainer.appendChild(iframe);
}
videoContainer.style.display = 'block';
toggleVideoBtn.textContent = '[動画を隠す]';
} else {
videoContainer.style.display = 'none';
toggleVideoBtn.textContent = '[動画を再生]';
// Optional: videoContainer.innerHTML = ''; // To stop video and free resources
}
});
link.insertAdjacentElement('afterend', toggleVideoBtn);
let insertTarget = link.nextSibling; // The toggle button
if(insertTarget) {
insertTarget.insertAdjacentElement('afterend', videoContainer);
} else {
link.parentElement.appendChild(videoContainer);
}
} // End YouTube
}
});
}
function transformThread() {
const threadElement = document.getElementById('thread');
if (!threadElement || threadElement.dataset.transformed === 'true') {
// console.log("5ch Reddit Style: Thread element not found or already transformed.");
return;
}
const originalPosts = Array.from(threadElement.querySelectorAll('div.res'));
if (originalPosts.length === 0) {
// console.log("5ch Reddit Style: No posts found to transform.");
return;
}
console.log(`5ch Reddit Style: Found ${originalPosts.length} posts to process.`);
initializeImageObserver(); // Initialize or re-initialize observer
const postsData = new Map();
originalPosts.forEach(postEl => {
const resIndex = postEl.dataset.resIndex;
if (!resIndex) return;
const dtElement = postEl.querySelector('dt.info');
const ddElement = postEl.querySelector('dd');
if (!dtElement || !ddElement) return;
postsData.set(resIndex, {
id: resIndex,
dtElement: dtElement.cloneNode(true),
ddElement: ddElement.cloneNode(true),
children: [],
replyToIds: []
});
});
postsData.forEach(post => {
const replyLinks = post.ddElement.querySelectorAll('a');
replyLinks.forEach(link => {
const href = link.getAttribute('href');
if (href) {
const parts = href.split('/');
const targetPart = parts[parts.length - 1];
if (targetPart) {
const match = targetPart.match(/^(\d+)/);
if (match && match[1]) {
const targetId = match[1];
if (postsData.has(targetId) && targetId !== post.id) {
post.replyToIds.push(targetId);
}
}
}
}
});
if (post.replyToIds.length > 0) {
const parentId = post.replyToIds[0];
if (postsData.has(parentId)) {
postsData.get(parentId).children.push(post);
}
}
});
const newThreadContainer = document.createElement('div');
newThreadContainer.className = 'reddit-style-container';
function renderPostRecursive(post, parentDomElement) {
const postWrapper = document.createElement('div');
postWrapper.className = 'reddit-post';
postWrapper.dataset.postId = post.id;
const originalContentDiv = document.createElement('div');
originalContentDiv.className = 'original-post-content';
originalContentDiv.appendChild(post.dtElement);
originalContentDiv.appendChild(post.ddElement);
postWrapper.appendChild(originalContentDiv);
processPostContent(post.ddElement); // Process for images/videos on the cloned dd
parentDomElement.appendChild(postWrapper);
if (post.children.length > 0) {
const repliesWrapper = document.createElement('div');
repliesWrapper.className = 'replies-wrapper';
const toggleRepliesBtn = document.createElement('span');
toggleRepliesBtn.className = 'toggle-replies-btn';
toggleRepliesBtn.textContent = `[-] (${post.children.length} replies)`;
let repliesVisible = true;
toggleRepliesBtn.addEventListener('click', () => {
repliesVisible = !repliesVisible;
repliesWrapper.style.display = repliesVisible ? 'block' : 'none';
toggleRepliesBtn.textContent = repliesVisible ? `[-] (${post.children.length} replies)` : `[+] (${post.children.length} replies)`;
});
post.dtElement.appendChild(toggleRepliesBtn);
postWrapper.appendChild(repliesWrapper);
post.children.sort((a, b) => parseInt(a.id) - parseInt(b.id));
post.children.forEach(childPost => {
renderPostRecursive(childPost, repliesWrapper);
});
}
}
const rootPosts = [];
postsData.forEach(p => {
let isChild = false;
if (p.replyToIds.length > 0) {
const parentId = p.replyToIds[0];
if (postsData.has(parentId) && postsData.get(parentId).children.includes(p)) {
isChild = true;
}
}
if (!isChild) {
rootPosts.push(p);
}
});
rootPosts.sort((a, b) => parseInt(a.id) - parseInt(b.id));
rootPosts.forEach(post => renderPostRecursive(post, newThreadContainer));
threadElement.innerHTML = '';
threadElement.appendChild(newThreadContainer);
threadElement.dataset.transformed = 'true'; // Mark as transformed
console.log("5ch Reddit Style: Transformation complete.");
}
const observerTarget = document.getElementById('thread');
if (observerTarget) {
let transformTimeout = null;
const mainObserver = new MutationObserver((mutationsList, obs) => {
if (observerTarget.dataset.transformed === 'true') {
// If content is already transformed, we might want to handle new posts differently
// For now, we'll just prevent re-transforming the whole thing.
// A more advanced version would append new posts into the existing tree.
// For example, if MQTT adds new .res elements, they should be processed and appended.
// This basic script doesn't handle that incremental update gracefully yet.
return;
}
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
let hasResAdded = false;
for(const node of mutation.addedNodes){
if(node.nodeType === Node.ELEMENT_NODE && node.classList && node.classList.contains('res')){
hasResAdded = true;
break;
}
}
if(hasResAdded){
clearTimeout(transformTimeout);
transformTimeout = setTimeout(() => {
console.log("5ch Reddit Style: Detected content change, attempting transformation.");
transformThread();
}, 500); // Shortened debounce
return;
}
}
}
});
mainObserver.observe(observerTarget, { childList: true, subtree: false });
// Fallback:
setTimeout(() => {
if (observerTarget.dataset.transformed !== 'true') {
console.log("5ch Reddit Style: Fallback timeout, attempting transformation.");
transformThread();
}
}, 1500);
} else {
console.error("5ch Reddit Style: #thread element not found for MutationObserver.");
}
})();