Size
12.6 KB
Version
1.0.1
Created
Dec 3, 2025
Updated
14 days ago
1// ==UserScript==
2// @name YouTube Comment Exporter
3// @description Export YouTube video comments to a file
4// @version 1.0.1
5// @match https://*.youtube.com/*
6// @icon https://www.youtube.com/s/desktop/9123e71c/img/favicon_32x32.png
7// ==/UserScript==
8(function() {
9 'use strict';
10
11 console.log('YouTube Comment Exporter loaded');
12
13 // Debounce function to prevent excessive calls
14 function debounce(func, wait) {
15 let timeout;
16 return function executedFunction(...args) {
17 const later = () => {
18 clearTimeout(timeout);
19 func(...args);
20 };
21 clearTimeout(timeout);
22 timeout = setTimeout(later, wait);
23 };
24 }
25
26 // Function to extract all comments from the page
27 function extractComments() {
28 const comments = [];
29 const commentThreads = document.querySelectorAll('ytd-comment-thread-renderer');
30
31 console.log(`Found ${commentThreads.length} comment threads`);
32
33 commentThreads.forEach((thread, index) => {
34 try {
35 // Main comment
36 const commentRenderer = thread.querySelector('ytd-comment-renderer#comment');
37 if (commentRenderer) {
38 const author = commentRenderer.querySelector('#author-text')?.textContent?.trim() || 'Unknown';
39 const commentText = commentRenderer.querySelector('#content-text')?.textContent?.trim() || '';
40 const timestamp = commentRenderer.querySelector('.published-time-text a')?.textContent?.trim() || '';
41 const likes = commentRenderer.querySelector('#vote-count-middle')?.textContent?.trim() || '0';
42
43 const commentData = {
44 author: author,
45 text: commentText,
46 timestamp: timestamp,
47 likes: likes,
48 isReply: false
49 };
50
51 comments.push(commentData);
52
53 // Check for replies
54 const repliesSection = thread.querySelector('ytd-comment-replies-renderer');
55 if (repliesSection) {
56 const replyRenderers = repliesSection.querySelectorAll('ytd-comment-renderer');
57 replyRenderers.forEach(reply => {
58 const replyAuthor = reply.querySelector('#author-text')?.textContent?.trim() || 'Unknown';
59 const replyText = reply.querySelector('#content-text')?.textContent?.trim() || '';
60 const replyTimestamp = reply.querySelector('.published-time-text a')?.textContent?.trim() || '';
61 const replyLikes = reply.querySelector('#vote-count-middle')?.textContent?.trim() || '0';
62
63 comments.push({
64 author: replyAuthor,
65 text: replyText,
66 timestamp: replyTimestamp,
67 likes: replyLikes,
68 isReply: true,
69 replyTo: author
70 });
71 });
72 }
73 }
74 } catch (error) {
75 console.error(`Error extracting comment ${index}:`, error);
76 }
77 });
78
79 return comments;
80 }
81
82 // Function to get video information
83 function getVideoInfo() {
84 const title = document.querySelector('h1.ytd-watch-metadata yt-formatted-string')?.textContent?.trim() || 'Unknown Video';
85 const channel = document.querySelector('ytd-channel-name#channel-name yt-formatted-string a')?.textContent?.trim() || 'Unknown Channel';
86 const videoId = new URLSearchParams(window.location.search).get('v') || 'unknown';
87
88 return {
89 title: title,
90 channel: channel,
91 videoId: videoId,
92 url: window.location.href
93 };
94 }
95
96 // Function to export comments to JSON file
97 function exportToJSON(comments, videoInfo) {
98 const data = {
99 videoInfo: videoInfo,
100 exportDate: new Date().toISOString(),
101 totalComments: comments.length,
102 comments: comments
103 };
104
105 const jsonString = JSON.stringify(data, null, 2);
106 const blob = new Blob([jsonString], { type: 'application/json' });
107 const url = URL.createObjectURL(blob);
108
109 const a = document.createElement('a');
110 a.href = url;
111 a.download = `youtube-comments-${videoInfo.videoId}-${Date.now()}.json`;
112 document.body.appendChild(a);
113 a.click();
114 document.body.removeChild(a);
115 URL.revokeObjectURL(url);
116
117 console.log(`Exported ${comments.length} comments to JSON`);
118 }
119
120 // Function to export comments to CSV file
121 function exportToCSV(comments, videoInfo) {
122 const headers = ['Author', 'Comment', 'Timestamp', 'Likes', 'Is Reply', 'Reply To'];
123 const rows = comments.map(comment => [
124 comment.author,
125 comment.text.replace(/"/g, '""'), // Escape quotes
126 comment.timestamp,
127 comment.likes,
128 comment.isReply ? 'Yes' : 'No',
129 comment.replyTo || ''
130 ]);
131
132 const csvContent = [
133 headers.join(','),
134 ...rows.map(row => row.map(cell => `"${cell}"`).join(','))
135 ].join('\n');
136
137 const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
138 const url = URL.createObjectURL(blob);
139
140 const a = document.createElement('a');
141 a.href = url;
142 a.download = `youtube-comments-${videoInfo.videoId}-${Date.now()}.csv`;
143 document.body.appendChild(a);
144 a.click();
145 document.body.removeChild(a);
146 URL.revokeObjectURL(url);
147
148 console.log(`Exported ${comments.length} comments to CSV`);
149 }
150
151 // Function to export comments to TXT file
152 function exportToTXT(comments, videoInfo) {
153 let txtContent = 'YouTube Comments Export\n';
154 txtContent += `Video: ${videoInfo.title}\n`;
155 txtContent += `Channel: ${videoInfo.channel}\n`;
156 txtContent += `URL: ${videoInfo.url}\n`;
157 txtContent += `Export Date: ${new Date().toLocaleString()}\n`;
158 txtContent += `Total Comments: ${comments.length}\n`;
159 txtContent += `${'='.repeat(80)}\n\n`;
160
161 comments.forEach((comment, index) => {
162 if (comment.isReply) {
163 txtContent += ` ↳ Reply to ${comment.replyTo}\n`;
164 }
165 txtContent += `${comment.isReply ? ' ' : ''}Author: ${comment.author}\n`;
166 txtContent += `${comment.isReply ? ' ' : ''}Time: ${comment.timestamp} | Likes: ${comment.likes}\n`;
167 txtContent += `${comment.isReply ? ' ' : ''}Comment: ${comment.text}\n`;
168 txtContent += `${'-'.repeat(80)}\n\n`;
169 });
170
171 const blob = new Blob([txtContent], { type: 'text/plain;charset=utf-8;' });
172 const url = URL.createObjectURL(blob);
173
174 const a = document.createElement('a');
175 a.href = url;
176 a.download = `youtube-comments-${videoInfo.videoId}-${Date.now()}.txt`;
177 document.body.appendChild(a);
178 a.click();
179 document.body.removeChild(a);
180 URL.revokeObjectURL(url);
181
182 console.log(`Exported ${comments.length} comments to TXT`);
183 }
184
185 // Function to create and add the export button
186 function addExportButton() {
187 // Check if we're on a video page
188 if (!window.location.pathname.includes('/watch')) {
189 console.log('Not on a video page, skipping button creation');
190 return;
191 }
192
193 // Check if button already exists
194 if (document.getElementById('yt-comment-export-btn')) {
195 console.log('Export button already exists');
196 return;
197 }
198
199 // Wait for the actions menu to be available
200 const actionsMenu = document.querySelector('#actions #top-level-buttons-computed');
201 if (!actionsMenu) {
202 console.log('Actions menu not found, will retry...');
203 return;
204 }
205
206 console.log('Creating export button');
207
208 // Create button container
209 const buttonContainer = document.createElement('div');
210 buttonContainer.id = 'yt-comment-export-btn';
211 buttonContainer.style.cssText = 'display: inline-flex; align-items: center; margin-left: 8px;';
212
213 // Create the main export button
214 const exportButton = document.createElement('button');
215 exportButton.className = 'yt-spec-button-shape-next yt-spec-button-shape-next--tonal yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m';
216 exportButton.style.cssText = 'display: flex; align-items: center; gap: 8px; padding: 0 16px; height: 36px; border-radius: 18px; cursor: pointer;';
217 exportButton.innerHTML = `
218 <div style="display: flex; align-items: center; gap: 8px;">
219 <svg height="24" viewBox="0 0 24 24" width="24" fill="currentColor">
220 <path d="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7h-2zm-6 .67l2.59-2.58L17 11.5l-5 5-5-5 1.41-1.41L11 12.67V3h2z"/>
221 </svg>
222 <span>Export Comments</span>
223 </div>
224 `;
225
226 // Create dropdown menu
227 const dropdown = document.createElement('div');
228 dropdown.style.cssText = 'display: none; position: absolute; background: var(--yt-spec-raised-background); border-radius: 12px; box-shadow: 0 4px 32px rgba(0,0,0,0.1); padding: 8px 0; margin-top: 8px; z-index: 9999; min-width: 180px;';
229
230 const formats = [
231 { name: 'JSON', handler: exportToJSON },
232 { name: 'CSV', handler: exportToCSV },
233 { name: 'TXT', handler: exportToTXT }
234 ];
235
236 formats.forEach(format => {
237 const option = document.createElement('div');
238 option.style.cssText = 'padding: 12px 16px; cursor: pointer; color: var(--yt-spec-text-primary); font-size: 14px; transition: background 0.2s;';
239 option.textContent = `Export as ${format.name}`;
240 option.addEventListener('mouseenter', () => {
241 option.style.background = 'var(--yt-spec-badge-chip-background)';
242 });
243 option.addEventListener('mouseleave', () => {
244 option.style.background = 'transparent';
245 });
246 option.addEventListener('click', () => {
247 const comments = extractComments();
248 const videoInfo = getVideoInfo();
249
250 if (comments.length === 0) {
251 alert('No comments found. Please scroll down to load comments first.');
252 return;
253 }
254
255 format.handler(comments, videoInfo);
256 dropdown.style.display = 'none';
257 });
258 dropdown.appendChild(option);
259 });
260
261 // Toggle dropdown on button click
262 exportButton.addEventListener('click', (e) => {
263 e.stopPropagation();
264 const isVisible = dropdown.style.display === 'block';
265 dropdown.style.display = isVisible ? 'none' : 'block';
266
267 // Position dropdown below button
268 const rect = exportButton.getBoundingClientRect();
269 dropdown.style.top = `${rect.bottom}px`;
270 dropdown.style.left = `${rect.left}px`;
271 });
272
273 // Close dropdown when clicking outside
274 document.addEventListener('click', () => {
275 dropdown.style.display = 'none';
276 });
277
278 buttonContainer.appendChild(exportButton);
279 document.body.appendChild(dropdown);
280
281 actionsMenu.appendChild(buttonContainer);
282 console.log('Export button added successfully');
283 }
284
285 // Initialize the extension
286 function init() {
287 console.log('Initializing YouTube Comment Exporter');
288
289 // Add button when page loads
290 addExportButton();
291
292 // Watch for navigation changes (YouTube is a SPA)
293 const observer = new MutationObserver(debounce(() => {
294 addExportButton();
295 }, 1000));
296
297 observer.observe(document.body, {
298 childList: true,
299 subtree: true
300 });
301
302 console.log('YouTube Comment Exporter initialized');
303 }
304
305 // Wait for page to be ready
306 if (document.readyState === 'loading') {
307 document.addEventListener('DOMContentLoaded', init);
308 } else {
309 init();
310 }
311})();