Add YouTube.os.js: Reddit Comments on YouTube user script

This commit is contained in:
2026-09-13 13:57:29 -07:00
commit 4e35222571
2 changed files with 551 additions and 0 deletions

25
README.md Normal file
View File

@@ -0,0 +1,25 @@
# Reddit.openscript
User scripts for Reddit integrations powered by [OpenScript](https://github.com/GetOpenScript/OpenScript).
## Scripts
### 1. Reddit Comments on YouTube (`YouTube.os.js`)
Embeds relevant Reddit discussion threads and comments directly above the native YouTube comment section on any YouTube video page.
#### Features
- **Zero API Keys & Read-Only:** Queries public Reddit JSON endpoints without credentials or cookies. Completely anonymous.
- **Dual Comment Display:** YouTube comments remain visible and accessible directly beneath the Reddit discussion section.
- **Multiple Threads Support:** Displays clickable subreddit tabs sorted by `top` score.
- **Official Subreddit Detection:** Automatically detects subreddit links in video descriptions and badges matching threads as `Official`.
- **Collapsible Comment Trees:** Clean nested thread view with `[]` and `[+]` collapse toggles.
- **Native YouTube Theming:** Adapts seamlessly to YouTube's light and dark themes using YouTube CSS color variables.
- **Always Fresh:** No caching, so newly submitted threads appear on page navigation.
#### Installation
1. Install [OpenScript](https://chromewebstore.google.com/detail/openscript/dkelmgdchndagjemmodhkphdikhpnfol) (requires version `1.0.4` or newer for `OpenScript.fetch`).
2. Open the OpenScript popup and click **New Script**.
3. Copy and paste the contents of [`YouTube.os.js`](YouTube.os.js) into the editor.
4. Save the script and ensure it is enabled.
5. Open any YouTube video with Reddit discussions (e.g. trailers, music videos, podcasts).

526
YouTube.os.js Normal file
View File

@@ -0,0 +1,526 @@
// ==UserScript==
// @name Reddit Comments on YouTube
// @version 1.0.0
// @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 COMMENTS_SELECTOR = '#comments';
const DESC_SELECTOR = '#description, #description-inline-expander, ytd-text-inline-expander';
let currentVideoId = null;
let activePostId = null;
let cachedPosts = [];
let abortController = null;
const ensureStyles = () => {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
#${MOUNT_ID} {
margin-bottom: 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-inner {
padding: 4px 0;
}
.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: 12px 0;
font-size: 13px;
color: var(--yt-spec-text-secondary, #606060);
font-style: italic;
}
`;
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) return mount;
const target = document.querySelector(COMMENTS_SELECTOR);
if (!target || !target.parentNode) return null;
mount = document.createElement('div');
mount.id = MOUNT_ID;
target.parentNode.insertBefore(mount, target);
return mount;
};
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, id, parent_id } = 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) || [];
const rendered = renderFlatComments(items, postAuthor);
btn.replaceWith(rendered);
} 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 => {
const mount = getMount();
if (!mount) return;
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 => {
const mount = getMount();
if (!mount) return;
const officialSub = getOfficialSub();
if (officialSub) {
posts.sort((a, b) => (a.subreddit.toLowerCase() === officialSub ? -1 : b.subreddit.toLowerCase() === officialSub ? 1 : 0));
}
let tabs = mount.querySelector('.os-tabs');
if (!tabs) {
tabs = document.createElement('div');
tabs.className = 'os-tabs';
mount.prepend(tabs);
}
tabs.innerHTML = '';
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);
};
tabs.append(tab);
});
renderThread(activePost);
};
const updateForVideo = async videoId => {
ensureStyles();
const mount = getMount();
if (!mount) return;
mount.innerHTML = '<div class="os-status">Searching Reddit for discussion threads...</div>';
cachedPosts = [];
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 (!posts.length) {
mount.innerHTML = '';
return;
}
cachedPosts = posts;
renderTabs(posts);
} catch (err) {
mount.innerHTML = `<div class="os-status">Could not search Reddit (${err.message}).</div>`;
}
};
const check = () => {
const id = new URLSearchParams(location.search).get('v');
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()) {
renderTabs(cachedPosts);
}
return;
}
currentVideoId = id;
activePostId = null;
updateForVideo(id);
};
document.addEventListener('yt-navigate-finish', check);
setInterval(check, 1200);
check();