// ==UserScript==
// @name jpnkn to Reddit Style (v1.0.0 Full)
// @name:en jpnkn to Reddit Style (v1.0.0 Full)
// @name:ja jpnkn を Reddit 風に (v1.0.0 完全版)
// @name:ko jpnkn Reddit 스타일로 (v1.0.0 전체)
// @name:zh-CN jpnkn 论坛转 Reddit 风格 (v1.0.0 完整版)
// @name:zh-TW jpnkn 論壇轉 Reddit 風格 (v1.0.0 完整版)
// @license MIT
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description Transforms jpnkn threads into a Reddit-like nested view, with image blur, reply count filters (highlighting matched posts and showing full relevant trees), and more.
// @description:en Transforms jpnkn threads into a Reddit-like nested view, with image blur, reply count filters (highlighting matched posts and showing full relevant trees), and more.
// @description:ja jpnknのスレッドをRedditのようなネスト表示に変換し、画像ぼかし、返信数フィルター(該当スレッドをハイライトし関連ツリー全体表示)などの機能を提供します。
// @description:ko jpnkn 스레드를 Reddit과 유사한 중첩 보기로 변환하고, 이미지 블러, 답글 수 필터(일치하는 스레드 강조 표시 및 관련 전체 트리 표시) 등의 기능을 제공합니다.
// @description:zh-CN 将 jpnkn 论坛帖子转换为类似 Reddit 的嵌套楼中楼视图,提供图片模糊、回复数筛选(高亮符合条件的帖子并展示完整相关楼层树)等功能。
// @description:zh-TW 將 jpnkn 論壇帖子轉換為類似 Reddit 的巢狀樓中樓檢視,提供圖片模糊、回覆數篩選(高亮符合條件的帖子並展示完整相關樓層樹)等功能。
// @author NBXX (Enhanced by AI)
// @match https://bbs.jpnkn.com/test/read.cgi/*/*/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// --- Configuration ---
const MAX_PREVIEW_HEIGHT = '400px';
const INDENTATION_SIZE = 20;
const LAZY_LOAD_OFFSET = '200px';
const DEFAULT_BLUR_RADIUS = '10px'; // ぼかしの強度
let config = {
enableImageFeatures: true,
};
let postsDataMap = new Map();
let currentActiveFilterButton = null;
function loadSettings() {
config.enableImageFeatures = GM_getValue('jpnknRedditStyle_enableImageFeatures', true);
}
function saveSettings() {
GM_setValue('jpnknRedditStyle_enableImageFeatures', config.enableImageFeatures);
}
function setupMenu() {
GM_registerMenuCommand(
`${config.enableImageFeatures ? '✅ 画像プレビューとぼかしを無効化' : '❌ 画像プレビューとぼかしを有効化'}`,
toggleImageFeaturesAndReload,
'p'
);
}
function toggleImageFeaturesAndReload() {
config.enableImageFeatures = !config.enableImageFeatures;
saveSettings();
alert(`画像プレビューとぼかし機能は ${config.enableImageFeatures ? '有効' : '無効'} になりました。ページをリロードします。`);
location.reload();
}
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);
transition: box-shadow 0.3s ease-in-out, border-color 0.3s ease-in-out;
}
.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;
}
.media-preview-container img.image-blurred {
filter: blur(${DEFAULT_BLUR_RADIUS});
transition: filter 0.2s ease-in-out;
}
.media-preview-container img.image-blurred:hover { filter: blur(0px); }
.media-preview-container img.expanded { max-height: none; cursor: zoom-out; filter: blur(0px) !important; }
.media-toggle-btn, .video-toggle-btn { font-size: 0.8em; color: #007bff; cursor: pointer; margin-left: 5px; text-decoration: underline; display: inline-block; }
.toggle-replies-btn { cursor: pointer; color: #777; font-size: 0.8em; margin-left: 10px; }
.original-link.broken-link { text-decoration: line-through; color: #d9534f; }
.youtube-embed-container { margin-top: 8px; position: relative; padding-bottom: 56.25%; 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; }
nav.fixed-top .col-8 .mt-0.mx-1 button.jpnkn-filter-btn {
margin-left: 8px;
padding: 2px 8px;
cursor: pointer;
border: 1px solid #ccc;
background-color: #e7e7e7;
border-radius: 4px;
font-size: inherit;
color: #007bff;
text-decoration: none;
vertical-align: middle;
}
nav.fixed-top .col-8 .mt-0.mx-1 button.jpnkn-filter-btn:hover {
background-color: #d7d7d7;
border-color: #bbb;
text-decoration: underline;
}
nav.fixed-top .col-8 .mt-0.mx-1 button.jpnkn-filter-btn.active-filter {
background-color: #007bff;
color: white;
border-color: #0056b3;
text-decoration: none;
}
.reddit-post.highlighted-post {
border-color: rgba(255, 105, 180, 0.7) !important;
box-shadow: 0 0 12px 4px rgba(255, 105, 180, 0.6),
0 0 20px 8px rgba(255, 105, 180, 0.4);
}
`);
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');
}
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') { // Note: googleusercontent.com URLs are specific
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];
} else { // Basic support for direct YouTube links
const directYoutubeHosts = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'youtu.be'];
if (directYoutubeHosts.includes(parsedUrl.hostname)) {
if (parsedUrl.pathname === '/watch') {
videoId = parsedUrl.searchParams.get('v');
} else if (parsedUrl.pathname.startsWith('/embed/')) {
videoId = parsedUrl.pathname.split('/embed/')[1].split(/[?#]/)[0];
} else if (parsedUrl.hostname === 'youtu.be') { // youtu.be links might be proxied here
videoId = parsedUrl.pathname.slice(1).split(/[?#]/)[0];
}
}
}
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;
if (isImageLink(href)) {
link.classList.add('original-link');
const container = document.createElement('div');
container.className = 'media-preview-container';
const img = document.createElement('img');
img.dataset.src = href;
img.alt = 'Image Preview';
const toggleBtn = document.createElement('span');
toggleBtn.className = 'media-toggle-btn';
function manageImageState() {
const isCurrentlyVisible = container.style.display !== 'none';
if (isCurrentlyVisible) {
if (config.enableImageFeatures) img.classList.add('image-blurred');
else img.classList.remove('image-blurred');
if (img.dataset.src && !img.src) imageObserver.observe(img); // Observe if visible and not loaded
} else {
img.classList.remove('image-blurred'); // Not visible, no blur
}
}
if (config.enableImageFeatures) {
container.style.display = 'block';
toggleBtn.textContent = '[画像を隠す]';
if (img.dataset.src) imageObserver.observe(img); // Observe if initially visible
} else {
container.style.display = 'none';
toggleBtn.textContent = '[画像を表示]';
}
manageImageState(); // Apply initial blur state
img.addEventListener('click', () => {
img.classList.toggle('expanded');
if (img.classList.contains('expanded')) {
img.classList.remove('image-blurred'); // Expanded image should not be blurred
} else {
manageImageState(); // Re-apply blur if collapsed and feature is on
}
});
img.onerror = () => {
container.style.display = 'none';
link.classList.add('broken-link');
link.title = '画像読み込み失敗';
if (toggleBtn) toggleBtn.style.display = 'none';
};
toggleBtn.addEventListener('click', (e) => {
e.preventDefault();
const isCurrentlyVisible = container.style.display !== 'none';
container.style.display = isCurrentlyVisible ? 'none' : 'block';
toggleBtn.textContent = isCurrentlyVisible ? '[画像を表示]' : '[画像を隠す]';
manageImageState(); // Re-evaluate state after toggling visibility
});
container.appendChild(img);
link.insertAdjacentElement('afterend', toggleBtn);
toggleBtn.insertAdjacentElement('afterend', container);
} else { // Check for YouTube links
const videoId = getYouTubeVideoId(href);
if (videoId) {
link.classList.add('original-link');
const videoContainer = document.createElement('div');
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') {
if (!videoContainer.querySelector('iframe')) {
videoContainer.innerHTML = ''; // Clear previous
videoContainer.className = 'youtube-embed-container';
const iframe = document.createElement('iframe');
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`;
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share');
iframe.setAttribute('allowfullscreen', '');
iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
videoContainer.appendChild(iframe);
}
videoContainer.style.display = 'block';
toggleVideoBtn.textContent = '[動画を隠す]';
} else {
videoContainer.style.display = 'none';
toggleVideoBtn.textContent = '[動画を再生]';
// Optional: Stop video by removing iframe to free resources
// videoContainer.innerHTML = '';
// videoContainer.className = '';
}
});
link.insertAdjacentElement('afterend', toggleVideoBtn);
toggleVideoBtn.insertAdjacentElement('afterend', videoContainer);
}
}
});
}
function calculateRepliesBottomUp(post, visited) {
visited.add(post.id);
let count = 0;
if (post.children && post.children.length > 0) {
count = post.children.length; // Direct children count
for (const childPost of post.children) {
if (!visited.has(childPost.id)) { // Ensure child's count is done (should be via recursion order)
calculateRepliesBottomUp(childPost, visited);
}
count += (childPost.recursiveReplyCount || 0); // Add child's total sub-replies
}
}
post.recursiveReplyCount = count;
}
function calculateAllRecursiveReplies(currentPostsDataMap) {
const visited = new Set();
currentPostsDataMap.forEach(post => {
if (!visited.has(post.id)) {
calculateRepliesBottomUp(post, visited);
}
});
}
function createFilterButtons(currentPostsDataMap) {
const targetMenuLocation = document.querySelector('nav.fixed-top .col-8 .mt-0.mx-1');
const existingButtons = targetMenuLocation ? targetMenuLocation.querySelectorAll('button.jpnkn-filter-btn') : [];
existingButtons.forEach(btn => btn.remove());
if (currentActiveFilterButton && !document.body.contains(currentActiveFilterButton)) {
currentActiveFilterButton = null;
}
if (!targetMenuLocation) {
console.warn("jpnkn Reddit Style: Target menu location for filter buttons not found.");
return;
}
const thresholds = [5, 10, 15];
thresholds.forEach(threshold => {
const button = document.createElement('button');
button.classList.add('jpnkn-filter-btn');
button.textContent = `返信 >= ${threshold}`;
button.dataset.threshold = threshold;
button.addEventListener('click', (event) => {
applyFilter(threshold, currentPostsDataMap, event.currentTarget);
});
targetMenuLocation.appendChild(button);
});
const showAllButton = document.createElement('button');
showAllButton.classList.add('jpnkn-filter-btn');
showAllButton.textContent = 'すべて表示';
showAllButton.addEventListener('click', (event) => {
applyFilter(0, currentPostsDataMap, event.currentTarget);
});
targetMenuLocation.appendChild(showAllButton);
}
function expandReplies(post) {
if (post && post.domElement) {
const repliesWrapper = post.domElement.querySelector('.replies-wrapper');
if (repliesWrapper && post.children && post.children.length > 0) {
repliesWrapper.style.display = 'block';
const toggleBtn = post.dtElement.querySelector('.toggle-replies-btn');
if (toggleBtn) {
toggleBtn.textContent = `[-] (${post.children.length} replies)`;
}
}
}
}
function showAndExpandDescendantsRecursive(post) {
if (post && post.domElement) {
post.domElement.style.display = 'block';
expandReplies(post);
if (post.children) {
for (const child of post.children) {
showAndExpandDescendantsRecursive(child);
}
}
}
}
function applyFilter(minReplies, currentPostsDataMap, clickedButton) {
if (currentActiveFilterButton) {
currentActiveFilterButton.classList.remove('active-filter');
}
if (clickedButton && minReplies !== 0) { // "すべて表示" ボタンには active-filter を付けない
clickedButton.classList.add('active-filter');
currentActiveFilterButton = clickedButton;
} else {
currentActiveFilterButton = null;
}
currentPostsDataMap.forEach(post => {
if (post.domElement) {
post.domElement.style.display = 'none';
post.domElement.classList.remove('highlighted-post');
}
});
if (minReplies === 0) { // Show all
currentPostsDataMap.forEach(post => {
if (post.domElement) {
post.domElement.style.display = 'block';
// For "Show All", user's toggled state for replies is respected.
// If you want to force expand all, uncomment expandReplies(post);
// expandReplies(post);
}
});
return;
}
const directlyMatchedPostIds = new Set();
const ancestorIdsToShow = new Set();
currentPostsDataMap.forEach(post => {
if (post.recursiveReplyCount >= minReplies) {
directlyMatchedPostIds.add(post.id);
let current = post;
while (current) {
ancestorIdsToShow.add(current.id);
current = current.parentElement;
}
}
});
ancestorIdsToShow.forEach(postId => {
const post = currentPostsDataMap.get(postId);
if (post && post.domElement) {
post.domElement.style.display = 'block';
expandReplies(post); // Expand direct replies of this ancestor/matched post
if (directlyMatchedPostIds.has(post.id)) {
post.domElement.classList.add('highlighted-post');
}
}
});
directlyMatchedPostIds.forEach(postId => {
const matchedPost = currentPostsDataMap.get(postId);
if (matchedPost && matchedPost.children) {
for (const child of matchedPost.children) {
showAndExpandDescendantsRecursive(child); // Show and expand all descendants
}
}
});
}
function transformThread() {
const threadElement = document.getElementById('thread');
if (!threadElement) {
console.error("jpnkn Reddit Style: #thread element not found. Cannot transform.");
return;
}
// Avoid re-transforming if no new raw posts are detected.
// A simple check: if already transformed and no .res outside our container, assume no new content.
if (threadElement.dataset.transformed === 'true') {
const newRawPosts = document.body.querySelectorAll('div.res:not(.reddit-post div.res)'); // Check for .res not already part of transformed structure
let hasNewUnprocessed = false;
newRawPosts.forEach(rawPostNode => {
if(!rawPostNode.closest('.reddit-style-container')){
hasNewUnprocessed = true;
}
});
if (!hasNewUnprocessed) {
// console.log("jpnkn Reddit Style: Already transformed and no new unprocessed posts detected.");
return;
}
}
const originalPosts = Array.from(threadElement.querySelectorAll('div.res'));
// If no original posts and already transformed, nothing to do.
// If no original posts and NOT transformed, also nothing to do (or wait).
if (originalPosts.length === 0) {
if (threadElement.dataset.transformed !== 'true') {
console.log("jpnkn Reddit Style: No posts found to transform initially.");
}
return;
}
console.log(`jpnkn Reddit Style: Processing ${originalPosts.length} posts.`);
initializeImageObserver();
postsDataMap.clear(); // Clear data from previous transformations
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;
postsDataMap.set(resIndex, {
id: resIndex,
dtElement: dtElement.cloneNode(true),
ddElement: ddElement.cloneNode(true),
children: [],
replyToIds: [],
parentElement: null,
recursiveReplyCount: 0,
domElement: null
});
});
postsDataMap.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 (postsDataMap.has(targetId) && targetId !== post.id) {
post.replyToIds.push(targetId);
}
}
}
}
});
if (post.replyToIds.length > 0) {
const parentId = post.replyToIds[0]; // Simplistic: first reply-to is primary parent
if (postsDataMap.has(parentId)) {
const parentPost = postsDataMap.get(parentId);
parentPost.children.push(post);
post.parentElement = parentPost;
}
}
});
calculateAllRecursiveReplies(postsDataMap);
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;
post.domElement = postWrapper;
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
parentDomElement.appendChild(postWrapper);
if (post.children.length > 0) {
const repliesWrapper = document.createElement('div');
repliesWrapper.className = 'replies-wrapper';
repliesWrapper.style.display = 'block'; // Default to expanded
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)`;
});
const firstInfoNode = post.dtElement.firstChild;
if (firstInfoNode && firstInfoNode.nextSibling) { // Insert after first child, before second.
post.dtElement.insertBefore(document.createTextNode(' '), firstInfoNode.nextSibling);
post.dtElement.insertBefore(toggleRepliesBtn, firstInfoNode.nextSibling.nextSibling);
} else if (firstInfoNode) { // Only one child, append after it with a space.
post.dtElement.appendChild(document.createTextNode(' '));
post.dtElement.appendChild(toggleRepliesBtn);
} else { // No children in dt, just append.
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 = [];
postsDataMap.forEach(p => {
if (!p.parentElement) {
rootPosts.push(p);
}
});
rootPosts.sort((a, b) => parseInt(a.id) - parseInt(b.id));
rootPosts.forEach(post => renderPostRecursive(post, newThreadContainer));
threadElement.innerHTML = ''; // Clear original content before appending new
threadElement.appendChild(newThreadContainer);
createFilterButtons(postsDataMap); // Create/update filter buttons in the nav menu
threadElement.dataset.transformed = 'true';
console.log("jpnkn Reddit Style: Transformation complete.");
}
// --- Main Execution ---
loadSettings();
setupMenu();
const observerTarget = document.getElementById('thread');
if (observerTarget) {
let transformTimeout = null;
const mainObserver = new MutationObserver((mutationsList, obs) => {
let hasNewResElements = mutationsList.some(mutation =>
mutation.type === 'childList' &&
mutation.addedNodes.length > 0 &&
Array.from(mutation.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
node.classList && node.classList.contains('res') &&
!node.closest('.reddit-style-container') // Check if new .res is outside our structure
)
);
if (hasNewResElements) {
console.log("jpnkn Reddit Style: Detected new raw .res posts, preparing for re-transformation.");
// If already transformed, mark for re-transformation.
// Otherwise, the initial load will handle it.
if(observerTarget.dataset.transformed === 'true'){
observerTarget.removeAttribute('data-transformed');
}
clearTimeout(transformTimeout);
transformTimeout = setTimeout(() => {
transformThread();
}, 500); // Debounce
}
});
// Observe the entire body to catch .res elements added anywhere outside the transformed container
mainObserver.observe(document.body, { childList: true, subtree: true });
// Initial transformation attempts
setTimeout(() => {
if (observerTarget.dataset.transformed !== 'true' && observerTarget.querySelector('div.res')) {
transformThread();
}
}, 200); // Slightly increased initial timeout
setTimeout(() => { // Fallback
if (observerTarget.dataset.transformed !== 'true' && observerTarget.querySelector('div.res')) {
transformThread();
}
}, 1500);
} else {
console.error("jpnkn Reddit Style: #thread element not found for initial setup.");
}
})();