YouTube Comment Exporter

Export YouTube video comments to a file

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})();
YouTube Comment Exporter | Robomonkey