mirror of
https://github.com/GetOpenScript/Reddit.openscript.git
synced 2026-09-18 08:05:45 +00:00
575 lines
17 KiB
JavaScript
575 lines
17 KiB
JavaScript
// ==UserScript==
|
||
// @name Reddit Comments on YouTube
|
||
// @version 1.0.1
|
||
// @description View Reddit discussion threads and comments directly above YouTube comments.
|
||
// @match https://www.youtube.com/*
|
||
// @run-at document_idle
|
||
// ==/UserScript==
|
||
|
||
const MOUNT_ID = 'os-reddit-mount';
|
||
const STYLE_ID = 'os-reddit-style';
|
||
const DESC_SELECTOR = '#description, #description-inline-expander, ytd-text-inline-expander';
|
||
|
||
let currentVideoId = null;
|
||
let activePostId = null;
|
||
let cachedPosts = [];
|
||
|
||
const ensureStyles = () => {
|
||
if (document.getElementById(STYLE_ID)) return;
|
||
const style = document.createElement('style');
|
||
style.id = STYLE_ID;
|
||
style.textContent = `
|
||
#${MOUNT_ID} {
|
||
margin: 16px 0 24px;
|
||
font-family: Roboto, Arial, sans-serif;
|
||
color: var(--yt-spec-text-primary, #0f0f0f);
|
||
}
|
||
.os-tabs {
|
||
display: flex;
|
||
gap: 8px;
|
||
overflow-x: auto;
|
||
padding: 4px 0 12px;
|
||
border-bottom: 1px solid var(--yt-spec-outline, rgba(0,0,0,0.1));
|
||
scrollbar-width: thin;
|
||
}
|
||
.os-tab {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 12px;
|
||
border-radius: 16px;
|
||
border: 1px solid var(--yt-spec-outline, rgba(0,0,0,0.1));
|
||
background: var(--yt-spec-badge-chip-background, rgba(0,0,0,0.05));
|
||
color: var(--yt-spec-text-secondary, #606060);
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
transition: background 0.2s, border-color 0.2s;
|
||
}
|
||
.os-tab:hover {
|
||
background: var(--yt-spec-button-chip-background-hover, rgba(0,0,0,0.1));
|
||
}
|
||
.os-tab.os-active {
|
||
background: #ff4500;
|
||
color: #fff;
|
||
border-color: #ff4500;
|
||
}
|
||
.os-tab .os-badge {
|
||
font-size: 10px;
|
||
padding: 1px 5px;
|
||
background: rgba(255,255,255,0.25);
|
||
border-radius: 8px;
|
||
}
|
||
.os-tab:not(.os-active) .os-badge {
|
||
background: var(--yt-spec-outline, rgba(0,0,0,0.1));
|
||
color: var(--yt-spec-text-primary, #0f0f0f);
|
||
}
|
||
.os-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin: 14px 0 12px;
|
||
gap: 12px;
|
||
}
|
||
.os-post-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
color: var(--yt-spec-text-primary, #0f0f0f);
|
||
text-decoration: none;
|
||
}
|
||
.os-post-title:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.os-post-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: var(--yt-spec-text-secondary, #606060);
|
||
}
|
||
.os-official-tag {
|
||
background: #0079d3;
|
||
color: #fff;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
}
|
||
.os-comment-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.os-comment {
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
.os-comment-tagline {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: var(--yt-spec-text-secondary, #606060);
|
||
margin-bottom: 4px;
|
||
}
|
||
.os-collapse {
|
||
background: none;
|
||
border: none;
|
||
color: var(--yt-spec-text-secondary, #606060);
|
||
cursor: pointer;
|
||
font-family: monospace;
|
||
font-size: 12px;
|
||
padding: 0 4px;
|
||
}
|
||
.os-author {
|
||
font-weight: 600;
|
||
color: var(--yt-spec-text-primary, #0f0f0f);
|
||
text-decoration: none;
|
||
}
|
||
.os-author.os-op {
|
||
color: #0079d3;
|
||
}
|
||
.os-score {
|
||
font-weight: 500;
|
||
}
|
||
.os-body {
|
||
color: var(--yt-spec-text-primary, #0f0f0f);
|
||
word-break: break-word;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.os-body p { margin: 4px 0; }
|
||
.os-body a { color: var(--yt-spec-brand-link-text, #065fd4); text-decoration: none; }
|
||
.os-body a:hover { text-decoration: underline; }
|
||
.os-body blockquote {
|
||
margin: 4px 0;
|
||
padding-left: 8px;
|
||
border-left: 3px solid var(--yt-spec-outline, rgba(0,0,0,0.2));
|
||
color: var(--yt-spec-text-secondary, #606060);
|
||
}
|
||
.os-body pre, .os-body code {
|
||
background: var(--yt-spec-badge-chip-background, rgba(0,0,0,0.05));
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
padding: 2px 4px;
|
||
}
|
||
.os-replies {
|
||
margin-left: 12px;
|
||
padding-left: 12px;
|
||
border-left: 2px solid var(--yt-spec-outline, rgba(0,0,0,0.1));
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
margin-top: 8px;
|
||
}
|
||
.os-collapsed > .os-body,
|
||
.os-collapsed > .os-replies,
|
||
.os-collapsed > .os-load-more {
|
||
display: none !important;
|
||
}
|
||
.os-load-more {
|
||
background: none;
|
||
border: 1px solid var(--yt-spec-outline, rgba(0,0,0,0.1));
|
||
border-radius: 12px;
|
||
padding: 4px 10px;
|
||
color: var(--yt-spec-brand-link-text, #065fd4);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
margin-top: 6px;
|
||
align-self: flex-start;
|
||
}
|
||
.os-load-more:hover {
|
||
background: var(--yt-spec-badge-chip-background, rgba(0,0,0,0.05));
|
||
}
|
||
.os-status {
|
||
padding: 10px 14px;
|
||
font-size: 13px;
|
||
border-radius: 8px;
|
||
background: var(--yt-spec-badge-chip-background, rgba(0,0,0,0.04));
|
||
color: var(--yt-spec-text-secondary, #606060);
|
||
margin-bottom: 8px;
|
||
}
|
||
.os-retry-btn {
|
||
margin-left: 8px;
|
||
color: var(--yt-spec-brand-link-text, #065fd4);
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
text-decoration: underline;
|
||
}
|
||
`;
|
||
document.head.append(style);
|
||
};
|
||
|
||
const formatScore = n => {
|
||
if (typeof n !== 'number') return '0';
|
||
if (Math.abs(n) >= 1e4) return `${(n / 1e3).toFixed(1)}k`;
|
||
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
|
||
return String(n);
|
||
};
|
||
|
||
const timeAgo = utc => {
|
||
const s = Math.max(0, Math.floor(Date.now() / 1e3 - utc));
|
||
if (s < 60) return `${s}s ago`;
|
||
const m = Math.floor(s / 60);
|
||
if (m < 60) return `${m}m ago`;
|
||
const h = Math.floor(m / 60);
|
||
if (h < 24) return `${h}h ago`;
|
||
const d = Math.floor(h / 24);
|
||
if (d < 30) return `${d}d ago`;
|
||
const mo = Math.floor(d / 30);
|
||
if (mo < 12) return `${mo}mo ago`;
|
||
return `${Math.floor(mo / 12)}y ago`;
|
||
};
|
||
|
||
const decodeHtml = str => {
|
||
const txt = document.createElement('textarea');
|
||
txt.innerHTML = str || '';
|
||
return txt.value;
|
||
};
|
||
|
||
const sanitizeHtml = html => {
|
||
const decoded = decodeHtml(html || '')
|
||
.replace(/href="\/((r|u)\/[^"]+)"/g, 'href="https://www.reddit.com/$1"')
|
||
.replace(/<a\b([^>]*)>/gi, '<a target="_blank" rel="noopener noreferrer" $1>');
|
||
const tpl = document.createElement('template');
|
||
tpl.innerHTML = decoded;
|
||
tpl.content.querySelectorAll('script, style, iframe, form, button').forEach(el => el.remove());
|
||
return tpl.innerHTML;
|
||
};
|
||
|
||
const getOfficialSub = () => {
|
||
const descEl = document.querySelector(DESC_SELECTOR);
|
||
const match = descEl?.textContent?.match(/reddit\.com\/r\/([^ /\n\r?]+)/i);
|
||
return match ? match[1].toLowerCase() : null;
|
||
};
|
||
|
||
const getMount = () => {
|
||
let mount = document.getElementById(MOUNT_ID);
|
||
if (mount && mount.isConnected) return mount;
|
||
|
||
const comments = document.querySelector('#comments, ytd-comments');
|
||
if (comments && comments.parentNode) {
|
||
mount = document.createElement('div');
|
||
mount.id = MOUNT_ID;
|
||
comments.parentNode.insertBefore(mount, comments);
|
||
return mount;
|
||
}
|
||
|
||
const meta = document.querySelector('#below > ytd-watch-metadata, #below > #watch-metadata');
|
||
if (meta && meta.parentNode) {
|
||
mount = document.createElement('div');
|
||
mount.id = MOUNT_ID;
|
||
meta.parentNode.insertBefore(mount, meta.nextSibling);
|
||
return mount;
|
||
}
|
||
|
||
const below = document.querySelector('#below');
|
||
if (below) {
|
||
mount = document.createElement('div');
|
||
mount.id = MOUNT_ID;
|
||
below.append(mount);
|
||
return mount;
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const waitForMount = async (maxAttempts = 40) => {
|
||
for (let i = 0; i < maxAttempts; i++) {
|
||
const m = getMount();
|
||
if (m) return m;
|
||
await new Promise(r => setTimeout(r, 250));
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const renderComments = (children, postAuthor) => {
|
||
const container = document.createElement('div');
|
||
container.className = 'os-comment-list';
|
||
|
||
for (const child of children) {
|
||
if (child.kind === 'more') {
|
||
const { count, children: moreIds } = child.data;
|
||
if (!moreIds?.length) continue;
|
||
const btn = document.createElement('button');
|
||
btn.className = 'os-load-more';
|
||
btn.textContent = `Load more comments (${count || moreIds.length})`;
|
||
btn.onclick = async () => {
|
||
btn.disabled = true;
|
||
btn.textContent = 'Loading...';
|
||
try {
|
||
const res = await OpenScript.fetch(
|
||
`https://www.reddit.com/api/morechildren.json?api_type=json&link_id=${activePostId}&children=${moreIds.slice(0, 20).join(',')}&sort=best`
|
||
);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const json = await res.json();
|
||
const items = json?.json?.data?.things?.map(t => t.data) || [];
|
||
btn.replaceWith(renderFlatComments(items, postAuthor));
|
||
} catch {
|
||
btn.textContent = 'Failed to load comments';
|
||
}
|
||
};
|
||
container.append(btn);
|
||
continue;
|
||
}
|
||
|
||
const { id, author, score, created_utc, body_html, replies } = child.data;
|
||
if (!body_html) continue;
|
||
|
||
const el = document.createElement('div');
|
||
el.className = 'os-comment';
|
||
el.id = `os-c-${id}`;
|
||
|
||
const tagline = document.createElement('div');
|
||
tagline.className = 'os-comment-tagline';
|
||
|
||
const toggle = document.createElement('button');
|
||
toggle.className = 'os-collapse';
|
||
toggle.textContent = '[–]';
|
||
toggle.onclick = () => {
|
||
const isCol = el.classList.toggle('os-collapsed');
|
||
toggle.textContent = isCol ? '[+]' : '[–]';
|
||
};
|
||
|
||
const authorLink = document.createElement('a');
|
||
authorLink.className = `os-author${author === postAuthor ? ' os-op' : ''}`;
|
||
authorLink.href = `https://www.reddit.com/u/${author}`;
|
||
authorLink.target = '_blank';
|
||
authorLink.rel = 'noopener noreferrer';
|
||
authorLink.textContent = author;
|
||
|
||
const metaSpan = document.createElement('span');
|
||
metaSpan.className = 'os-score';
|
||
metaSpan.textContent = `• ${formatScore(score)} points • ${timeAgo(created_utc)}`;
|
||
|
||
tagline.append(toggle, authorLink, metaSpan);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'os-body';
|
||
body.innerHTML = sanitizeHtml(body_html);
|
||
|
||
el.append(tagline, body);
|
||
|
||
if (replies?.data?.children?.length) {
|
||
const sub = renderComments(replies.data.children, postAuthor);
|
||
sub.className = 'os-replies';
|
||
el.append(sub);
|
||
}
|
||
|
||
container.append(el);
|
||
}
|
||
|
||
return container;
|
||
};
|
||
|
||
const renderFlatComments = (items, postAuthor) => {
|
||
const wrap = document.createDocumentFragment();
|
||
for (const item of items) {
|
||
if (!item.body_html) continue;
|
||
const el = document.createElement('div');
|
||
el.className = 'os-comment';
|
||
|
||
const tagline = document.createElement('div');
|
||
tagline.className = 'os-comment-tagline';
|
||
|
||
const authorLink = document.createElement('a');
|
||
authorLink.className = `os-author${item.author === postAuthor ? ' os-op' : ''}`;
|
||
authorLink.href = `https://www.reddit.com/u/${item.author}`;
|
||
authorLink.target = '_blank';
|
||
authorLink.rel = 'noopener noreferrer';
|
||
authorLink.textContent = item.author;
|
||
|
||
const meta = document.createElement('span');
|
||
meta.className = 'os-score';
|
||
meta.textContent = `• ${formatScore(item.score)} points • ${timeAgo(item.created_utc)}`;
|
||
|
||
tagline.append(authorLink, meta);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'os-body';
|
||
body.innerHTML = sanitizeHtml(item.body_html);
|
||
|
||
el.append(tagline, body);
|
||
wrap.append(el);
|
||
}
|
||
return wrap;
|
||
};
|
||
|
||
const loadPostComments = async (post, postContainer) => {
|
||
postContainer.innerHTML = '<div class="os-status">Loading Reddit comments...</div>';
|
||
try {
|
||
const res = await OpenScript.fetch(`https://www.reddit.com/comments/${post.id}.json?sort=best`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const [, commentsData] = await res.json();
|
||
const children = commentsData?.data?.children || [];
|
||
|
||
postContainer.innerHTML = '';
|
||
if (!children.length) {
|
||
postContainer.innerHTML = '<div class="os-status">No comments in this thread yet.</div>';
|
||
return;
|
||
}
|
||
postContainer.append(renderComments(children, post.author));
|
||
} catch (err) {
|
||
postContainer.innerHTML = `<div class="os-status">Unable to load Reddit comments (${err.message}).</div>`;
|
||
}
|
||
};
|
||
|
||
const renderThread = (post, mount) => {
|
||
let contentArea = mount.querySelector('.os-content-area');
|
||
if (!contentArea) {
|
||
contentArea = document.createElement('div');
|
||
contentArea.className = 'os-content-area';
|
||
mount.append(contentArea);
|
||
}
|
||
contentArea.innerHTML = '';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'os-header';
|
||
|
||
const left = document.createElement('div');
|
||
const title = document.createElement('a');
|
||
title.className = 'os-post-title';
|
||
title.href = `https://www.reddit.com${post.permalink}`;
|
||
title.target = '_blank';
|
||
title.rel = 'noopener noreferrer';
|
||
title.textContent = decodeHtml(post.title);
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'os-post-meta';
|
||
meta.textContent = `r/${post.subreddit} • Posted by u/${post.author} ${timeAgo(post.created_utc)} • ${formatScore(post.score)} points`;
|
||
|
||
left.append(title, meta);
|
||
header.append(left);
|
||
contentArea.append(header);
|
||
|
||
const commentsArea = document.createElement('div');
|
||
commentsArea.className = 'os-comments-area';
|
||
contentArea.append(commentsArea);
|
||
|
||
loadPostComments(post, commentsArea);
|
||
};
|
||
|
||
const renderTabs = (posts, mount) => {
|
||
mount.innerHTML = '';
|
||
const officialSub = getOfficialSub();
|
||
if (officialSub) {
|
||
posts.sort((a, b) => (a.subreddit.toLowerCase() === officialSub ? -1 : b.subreddit.toLowerCase() === officialSub ? 1 : 0));
|
||
}
|
||
|
||
const tabs = document.createElement('div');
|
||
tabs.className = 'os-tabs';
|
||
mount.append(tabs);
|
||
|
||
const activePost = posts.find(p => p.name === activePostId) || posts[0];
|
||
activePostId = activePost.name;
|
||
|
||
posts.forEach(post => {
|
||
const isOfficial = officialSub && post.subreddit.toLowerCase() === officialSub;
|
||
const tab = document.createElement('div');
|
||
tab.className = `os-tab${post.name === activePostId ? ' os-active' : ''}`;
|
||
tab.textContent = `r/${post.subreddit}`;
|
||
|
||
if (isOfficial) {
|
||
const off = document.createElement('span');
|
||
off.className = 'os-official-tag';
|
||
off.textContent = 'Official';
|
||
tab.append(off);
|
||
}
|
||
|
||
const badge = document.createElement('span');
|
||
badge.className = 'os-badge';
|
||
badge.textContent = `${formatScore(post.score)} ↑ • ${post.num_comments} 💬`;
|
||
tab.append(badge);
|
||
|
||
tab.onclick = () => {
|
||
if (activePostId === post.name) return;
|
||
activePostId = post.name;
|
||
tabs.querySelectorAll('.os-tab').forEach(t => t.classList.remove('os-active'));
|
||
tab.classList.add('os-active');
|
||
renderThread(post, mount);
|
||
};
|
||
|
||
tabs.append(tab);
|
||
});
|
||
|
||
renderThread(activePost, mount);
|
||
};
|
||
|
||
const updateForVideo = async videoId => {
|
||
ensureStyles();
|
||
cachedPosts = [];
|
||
activePostId = null;
|
||
|
||
const initialMount = getMount();
|
||
if (initialMount) {
|
||
initialMount.innerHTML = '<div class="os-status">Searching Reddit for discussion threads...</div>';
|
||
}
|
||
|
||
try {
|
||
const res = await OpenScript.fetch(
|
||
`https://www.reddit.com/search.json?q=url:'${encodeURIComponent(videoId)}'&sort=top&type=link`
|
||
);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const data = await res.json();
|
||
const posts = data?.data?.children?.map(c => c.data) || [];
|
||
|
||
if (videoId !== currentVideoId) return;
|
||
|
||
const mount = await waitForMount();
|
||
if (!mount || videoId !== currentVideoId) return;
|
||
|
||
if (!posts.length) {
|
||
mount.innerHTML = '<div class="os-status">No Reddit discussions found for this video.</div>';
|
||
return;
|
||
}
|
||
|
||
cachedPosts = posts;
|
||
renderTabs(posts, mount);
|
||
} catch (err) {
|
||
const mount = await waitForMount();
|
||
if (mount && videoId === currentVideoId) {
|
||
mount.innerHTML = `
|
||
<div class="os-status">
|
||
Could not search Reddit (${err.message}).
|
||
<button class="os-retry-btn">Retry</button>
|
||
</div>
|
||
`;
|
||
mount.querySelector('.os-retry-btn')?.addEventListener('click', () => updateForVideo(videoId));
|
||
}
|
||
}
|
||
};
|
||
|
||
const getVideoId = () => {
|
||
const search = new URLSearchParams(location.search);
|
||
if (search.get('v')) return search.get('v');
|
||
const match = location.pathname.match(/\/shorts\/([a-zA-Z0-9_-]+)/);
|
||
return match ? match[1] : null;
|
||
};
|
||
|
||
const check = () => {
|
||
const id = getVideoId();
|
||
if (!id) {
|
||
currentVideoId = null;
|
||
const mount = document.getElementById(MOUNT_ID);
|
||
if (mount) mount.innerHTML = '';
|
||
return;
|
||
}
|
||
if (id === currentVideoId) {
|
||
if (cachedPosts.length && !document.getElementById(MOUNT_ID)?.hasChildNodes()) {
|
||
const mount = getMount();
|
||
if (mount) renderTabs(cachedPosts, mount);
|
||
}
|
||
return;
|
||
}
|
||
currentVideoId = id;
|
||
updateForVideo(id);
|
||
};
|
||
|
||
document.addEventListener('yt-navigate-finish', check);
|
||
setInterval(check, 1000);
|
||
check();
|