OnlyFans Image Downloader & Link Copier

Adds Download and Copy Link buttons to content images on hover

Size

21.4 KB

Version

1.1.1

Created

Dec 30, 2025

Updated

2 months ago

1// ==UserScript==
2// @name		OnlyFans Image Downloader & Link Copier
3// @description		Adds Download and Copy Link buttons to content images on hover
4// @version		1.1.1
5// @match		https://*.onlyfans.com/*
6// @icon		https://static2.onlyfans.com/static/prod/f/202512291444-dfa51caee3/icons/favicon.ico
7// @grant		GM.xmlhttpRequest
8// @grant		GM.setClipboard
9// ==/UserScript==
10(function() {
11    'use strict';
12
13    console.log('OnlyFans Image Downloader & Link Copier initialized');
14
15    // Debounce function to prevent excessive calls
16    function debounce(func, wait) {
17        let timeout;
18        return function executedFunction(...args) {
19            const later = () => {
20                clearTimeout(timeout);
21                func(...args);
22            };
23            clearTimeout(timeout);
24            timeout = setTimeout(later, wait);
25        };
26    }
27
28    // Add styles for the buttons
29    const styles = `
30        .of-image-buttons {
31            position: absolute;
32            top: 10px;
33            right: 10px;
34            display: flex;
35            gap: 8px;
36            z-index: 10000;
37            opacity: 0;
38            transition: opacity 0.2s ease;
39            pointer-events: none;
40        }
41
42        .of-image-buttons.visible {
43            opacity: 1;
44            pointer-events: auto;
45        }
46
47        .of-image-btn {
48            background: rgba(0, 0, 0, 0.7);
49            color: white;
50            border: none;
51            padding: 8px 16px;
52            border-radius: 20px;
53            cursor: pointer;
54            font-size: 13px;
55            font-weight: 500;
56            transition: all 0.2s ease;
57            backdrop-filter: blur(10px);
58            white-space: nowrap;
59        }
60
61        .of-image-btn:hover {
62            background: rgba(0, 0, 0, 0.85);
63            transform: scale(1.05);
64        }
65
66        .of-image-btn:active {
67            transform: scale(0.95);
68        }
69
70        .of-image-container {
71            position: relative;
72        }
73    `;
74
75    // Inject styles
76    const styleElement = document.createElement('style');
77    styleElement.textContent = styles;
78    document.head.appendChild(styleElement);
79
80    // Function to check if an element is a content image (not avatar, icon, or UI element)
81    function isContentImage(element) {
82        // Check if it's an img element
83        if (element.tagName !== 'IMG') return false;
84
85        // Exclude avatar images
86        if (element.closest('.g-avatar')) return false;
87
88        // Exclude header/navigation images
89        if (element.closest('.l-header')) return false;
90
91        // Exclude small icons (typically less than 50px)
92        if (element.naturalWidth < 50 || element.naturalHeight < 50) return false;
93
94        // Include images in post media holders
95        if (element.closest('.b-post-media-holder')) return true;
96
97        // Include images in timeline/feed
98        if (element.closest('.b-timeline')) return true;
99
100        // Include images in chat messages
101        if (element.closest('.b-chat__message')) return true;
102
103        return false;
104    }
105
106    // Function to check if an element is a content video
107    function isContentVideo(element) {
108        // Check if it's a video element or video container
109        if (element.tagName === 'VIDEO') return true;
110        
111        // Check if it's a video-js container
112        if (element.classList.contains('video-js')) return true;
113        
114        return false;
115    }
116
117    // Function to get video URL with best quality
118    function getVideoUrl(videoElement) {
119        let video = videoElement;
120        
121        // If it's a video-js container, find the actual video element
122        if (videoElement.classList.contains('video-js')) {
123            video = videoElement.querySelector('video');
124        }
125        
126        if (!video) return null;
127        
128        // Get all source elements
129        const sources = Array.from(video.querySelectorAll('source'));
130        
131        if (sources.length > 0) {
132            // Try to find the highest quality (source, 720p, then 240p)
133            const sourceQuality = sources.find(s => s.src.includes('_source'));
134            if (sourceQuality) return sourceQuality.src;
135            
136            const quality720 = sources.find(s => s.src.includes('_720p'));
137            if (quality720) return quality720.src;
138            
139            const quality240 = sources.find(s => s.src.includes('_240p'));
140            if (quality240) return quality240.src;
141            
142            // Return first source if no quality match
143            return sources[0].src;
144        }
145        
146        // Fallback to video src attribute
147        return video.src;
148    }
149
150    // Function to check if a URL is a video
151    function isVideoUrl(url) {
152        if (!url) return false;
153        
154        // Remove query parameters for extension check
155        const urlWithoutParams = url.split('?')[0];
156        
157        // Check for video extensions
158        const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.m3u8', '.mpd'];
159        const hasVideoExtension = videoExtensions.some(ext => urlWithoutParams.toLowerCase().endsWith(ext));
160        
161        // Check if URL contains common video patterns
162        const isVideoCdn = url.includes('cdn') && (url.includes('_source') || url.includes('_720p') || url.includes('_240p'));
163        
164        return hasVideoExtension || isVideoCdn;
165    }
166
167    // Function to get the actual image URL (handles background images too)
168    function getImageUrl(element, x, y) {
169        // First, try to get the direct image source
170        if (element.tagName === 'IMG' && element.src) {
171            // Make sure it's an image URL, not HTML or JS
172            const url = element.src;
173            if (isImageUrl(url)) {
174                return url;
175            }
176        }
177
178        // Check for background image on the element or its parent
179        const elementsToCheck = [element, element.parentElement, element.parentElement?.parentElement];
180        
181        for (const el of elementsToCheck) {
182            if (!el) continue;
183            
184            const bgImage = window.getComputedStyle(el).backgroundImage;
185            if (bgImage && bgImage !== 'none') {
186                const urlMatch = bgImage.match(/url\(['"]?(.*?)['"]?\)/);
187                if (urlMatch && urlMatch[1]) {
188                    const url = urlMatch[1];
189                    if (isImageUrl(url)) {
190                        return url;
191                    }
192                }
193            }
194        }
195
196        // If we still don't have a URL, try to find it from the element's attributes
197        const srcset = element.getAttribute('srcset');
198        if (srcset) {
199            const urls = srcset.split(',').map(s => s.trim().split(' ')[0]);
200            const validUrl = urls.find(url => isImageUrl(url));
201            if (validUrl) return validUrl;
202        }
203
204        return element.src;
205    }
206
207    // Function to check if a URL is an image
208    function isImageUrl(url) {
209        if (!url) return false;
210        
211        // Remove query parameters for extension check
212        const urlWithoutParams = url.split('?')[0];
213        
214        // Check for image extensions
215        const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
216        const hasImageExtension = imageExtensions.some(ext => urlWithoutParams.toLowerCase().endsWith(ext));
217        
218        // Check if URL contains common image CDN patterns
219        const isImageCdn = url.includes('cdn') || url.includes('thumbs') || url.includes('/files/');
220        
221        // Exclude HTML, JS, CSS files
222        const excludedExtensions = ['.html', '.htm', '.js', '.css', '.json', '.xml'];
223        const hasExcludedExtension = excludedExtensions.some(ext => urlWithoutParams.toLowerCase().endsWith(ext));
224        
225        return (hasImageExtension || isImageCdn) && !hasExcludedExtension;
226    }
227
228    // Function to download image
229    async function downloadImage(imageUrl, element) {
230        try {
231            console.log('Downloading image:', imageUrl);
232            
233            // Extract filename from URL
234            const urlParts = imageUrl.split('/');
235            const filename = urlParts[urlParts.length - 1].split('?')[0] || 'image.jpg';
236            
237            // Use GM.xmlhttpRequest to fetch the image
238            const response = await GM.xmlhttpRequest({
239                method: 'GET',
240                url: imageUrl,
241                responseType: 'blob',
242                headers: {
243                    'Referer': window.location.href
244                }
245            });
246
247            // Create a blob URL and trigger download
248            const blob = response.response;
249            const blobUrl = URL.createObjectURL(blob);
250            
251            const a = document.createElement('a');
252            a.href = blobUrl;
253            a.download = filename;
254            document.body.appendChild(a);
255            a.click();
256            document.body.removeChild(a);
257            
258            // Clean up the blob URL after a short delay
259            setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
260            
261            console.log('Image downloaded successfully');
262        } catch (error) {
263            console.error('Error downloading image:', error);
264            alert('Failed to download image. Please try again.');
265        }
266    }
267
268    // Function to download video
269    async function downloadVideo(videoUrl) {
270        try {
271            console.log('Downloading video:', videoUrl);
272            
273            // Extract filename from URL
274            const urlParts = videoUrl.split('/');
275            let filename = urlParts[urlParts.length - 1].split('?')[0];
276            
277            // Ensure it has .mp4 extension
278            if (!filename.includes('.')) {
279                filename += '.mp4';
280            }
281            
282            // Use GM.xmlhttpRequest to fetch the video
283            const response = await GM.xmlhttpRequest({
284                method: 'GET',
285                url: videoUrl,
286                responseType: 'blob',
287                headers: {
288                    'Referer': window.location.href
289                }
290            });
291
292            // Create a blob URL and trigger download
293            const blob = response.response;
294            const blobUrl = URL.createObjectURL(blob);
295            
296            const a = document.createElement('a');
297            a.href = blobUrl;
298            a.download = filename;
299            document.body.appendChild(a);
300            a.click();
301            document.body.removeChild(a);
302            
303            // Clean up the blob URL after a short delay
304            setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
305            
306            console.log('Video downloaded successfully');
307        } catch (error) {
308            console.error('Error downloading video:', error);
309            alert('Failed to download video. Please try again.');
310        }
311    }
312
313    // Function to copy link to clipboard
314    async function copyImageLink(imageUrl) {
315        try {
316            console.log('Copying link:', imageUrl);
317            await GM.setClipboard(imageUrl);
318            console.log('Link copied to clipboard');
319            
320            // Visual feedback
321            showNotification('Link copied to clipboard!');
322        } catch (error) {
323            console.error('Error copying link:', error);
324            alert('Failed to copy link. Please try again.');
325        }
326    }
327
328    // Function to copy video link to clipboard
329    async function copyVideoLink(videoUrl) {
330        try {
331            console.log('Copying video link:', videoUrl);
332            await GM.setClipboard(videoUrl);
333            console.log('Video link copied to clipboard');
334            
335            // Visual feedback
336            showNotification('Video link copied to clipboard!');
337        } catch (error) {
338            console.error('Error copying video link:', error);
339            alert('Failed to copy video link. Please try again.');
340        }
341    }
342
343    // Function to show notification
344    function showNotification(message) {
345        const notification = document.createElement('div');
346        notification.textContent = message;
347        notification.style.cssText = `
348            position: fixed;
349            top: 20px;
350            right: 20px;
351            background: rgba(0, 0, 0, 0.85);
352            color: white;
353            padding: 12px 20px;
354            border-radius: 8px;
355            z-index: 100000;
356            font-size: 14px;
357            font-weight: 500;
358            backdrop-filter: blur(10px);
359            animation: slideIn 0.3s ease;
360        `;
361        
362        document.body.appendChild(notification);
363        
364        setTimeout(() => {
365            notification.style.animation = 'slideOut 0.3s ease';
366            setTimeout(() => notification.remove(), 300);
367        }, 2000);
368    }
369
370    // Add animation styles
371    const animationStyles = `
372        @keyframes slideIn {
373            from {
374                transform: translateX(400px);
375                opacity: 0;
376            }
377            to {
378                transform: translateX(0);
379                opacity: 1;
380            }
381        }
382        
383        @keyframes slideOut {
384            from {
385                transform: translateX(0);
386                opacity: 1;
387            }
388            to {
389                transform: translateX(400px);
390                opacity: 0;
391            }
392        }
393    `;
394    styleElement.textContent += animationStyles;
395
396    // Function to create button container for an image
397    function createButtonContainer(imageElement) {
398        // Check if buttons already exist
399        if (imageElement.dataset.hasButtons === 'true') return;
400        
401        imageElement.dataset.hasButtons = 'true';
402
403        // Find the appropriate container (the element with position relative or the image itself)
404        let container = imageElement.closest('.b-post__media-bg') || 
405                       imageElement.closest('.b-post__media__item-inner') ||
406                       imageElement.parentElement;
407
408        // Ensure container has relative positioning
409        if (!container) {
410            container = imageElement.parentElement;
411        }
412        
413        const computedStyle = window.getComputedStyle(container);
414        if (computedStyle.position === 'static') {
415            container.style.position = 'relative';
416        }
417
418        // Create button container
419        const buttonContainer = document.createElement('div');
420        buttonContainer.className = 'of-image-buttons';
421
422        // Create Download button
423        const downloadBtn = document.createElement('button');
424        downloadBtn.className = 'of-image-btn';
425        downloadBtn.textContent = 'Download';
426        downloadBtn.addEventListener('click', async (e) => {
427            e.stopPropagation();
428            e.preventDefault();
429            
430            const imageUrl = getImageUrl(imageElement, e.clientX, e.clientY);
431            if (imageUrl && isImageUrl(imageUrl)) {
432                await downloadImage(imageUrl, imageElement);
433            } else {
434                console.error('Invalid image URL:', imageUrl);
435                alert('Could not find a valid image URL');
436            }
437        });
438
439        // Create Copy Link button
440        const copyBtn = document.createElement('button');
441        copyBtn.className = 'of-image-btn';
442        copyBtn.textContent = 'Copy Link';
443        copyBtn.addEventListener('click', async (e) => {
444            e.stopPropagation();
445            e.preventDefault();
446            
447            const imageUrl = getImageUrl(imageElement, e.clientX, e.clientY);
448            if (imageUrl && isImageUrl(imageUrl)) {
449                await copyImageLink(imageUrl);
450            } else {
451                console.error('Invalid image URL:', imageUrl);
452                alert('Could not find a valid image URL');
453            }
454        });
455
456        buttonContainer.appendChild(downloadBtn);
457        buttonContainer.appendChild(copyBtn);
458        container.appendChild(buttonContainer);
459
460        // Show/hide buttons on hover
461        let hideTimeout;
462        
463        const showButtons = () => {
464            clearTimeout(hideTimeout);
465            buttonContainer.classList.add('visible');
466        };
467
468        const hideButtons = () => {
469            hideTimeout = setTimeout(() => {
470                buttonContainer.classList.remove('visible');
471            }, 300);
472        };
473
474        container.addEventListener('mouseenter', showButtons);
475        container.addEventListener('mouseleave', hideButtons);
476        buttonContainer.addEventListener('mouseenter', showButtons);
477        buttonContainer.addEventListener('mouseleave', hideButtons);
478
479        console.log('Buttons added to image:', imageElement.src?.substring(0, 50));
480    }
481
482    // Function to create button container for a video
483    function createVideoButtonContainer(videoElement) {
484        // Check if buttons already exist
485        if (videoElement.dataset.hasButtons === 'true') return;
486        
487        videoElement.dataset.hasButtons = 'true';
488
489        // Find the appropriate container
490        let container = videoElement;
491        if (videoElement.tagName === 'VIDEO') {
492            container = videoElement.closest('.video-js') || videoElement.parentElement;
493        }
494
495        // Ensure container has relative positioning
496        const computedStyle = window.getComputedStyle(container);
497        if (computedStyle.position === 'static') {
498            container.style.position = 'relative';
499        }
500
501        // Create button container
502        const buttonContainer = document.createElement('div');
503        buttonContainer.className = 'of-image-buttons';
504
505        // Create Download button
506        const downloadBtn = document.createElement('button');
507        downloadBtn.className = 'of-image-btn';
508        downloadBtn.textContent = 'Download';
509        downloadBtn.addEventListener('click', async (e) => {
510            e.stopPropagation();
511            e.preventDefault();
512            
513            const videoUrl = getVideoUrl(videoElement);
514            if (videoUrl && isVideoUrl(videoUrl)) {
515                await downloadVideo(videoUrl);
516            } else {
517                console.error('Invalid video URL:', videoUrl);
518                alert('Could not find a valid video URL');
519            }
520        });
521
522        // Create Copy Link button
523        const copyBtn = document.createElement('button');
524        copyBtn.className = 'of-image-btn';
525        copyBtn.textContent = 'Copy Link';
526        copyBtn.addEventListener('click', async (e) => {
527            e.stopPropagation();
528            e.preventDefault();
529            
530            const videoUrl = getVideoUrl(videoElement);
531            if (videoUrl && isVideoUrl(videoUrl)) {
532                await copyVideoLink(videoUrl);
533            } else {
534                console.error('Invalid video URL:', videoUrl);
535                alert('Could not find a valid video URL');
536            }
537        });
538
539        buttonContainer.appendChild(downloadBtn);
540        buttonContainer.appendChild(copyBtn);
541        container.appendChild(buttonContainer);
542
543        // Show/hide buttons on hover
544        let hideTimeout;
545        
546        const showButtons = () => {
547            clearTimeout(hideTimeout);
548            buttonContainer.classList.add('visible');
549        };
550
551        const hideButtons = () => {
552            hideTimeout = setTimeout(() => {
553                buttonContainer.classList.remove('visible');
554            }, 300);
555        };
556
557        container.addEventListener('mouseenter', showButtons);
558        container.addEventListener('mouseleave', hideButtons);
559        buttonContainer.addEventListener('mouseenter', showButtons);
560        buttonContainer.addEventListener('mouseleave', hideButtons);
561
562        console.log('Buttons added to video:', videoUrl?.substring(0, 50));
563    }
564
565    // Function to process all images on the page
566    function processImages() {
567        const images = document.querySelectorAll('img');
568        
569        images.forEach(img => {
570            if (isContentImage(img) && img.dataset.hasButtons !== 'true') {
571                // Wait for image to load before adding buttons
572                if (img.complete) {
573                    createButtonContainer(img);
574                } else {
575                    img.addEventListener('load', () => createButtonContainer(img), { once: true });
576                }
577            }
578        });
579    }
580
581    // Function to process all videos on the page
582    function processVideos() {
583        // Find video-js containers and video elements
584        const videoContainers = document.querySelectorAll('.video-js');
585        
586        videoContainers.forEach(container => {
587            if (container.dataset.hasButtons !== 'true') {
588                const video = container.querySelector('video');
589                if (video) {
590                    createVideoButtonContainer(container);
591                }
592            }
593        });
594    }
595
596    // Function to process all media (images and videos)
597    function processMedia() {
598        processImages();
599        processVideos();
600    }
601
602    // Debounced version of processImages
603    const debouncedProcessImages = debounce(processImages, 300);
604
605    // Debounced version of processMedia
606    const debouncedProcessMedia = debounce(processMedia, 300);
607
608    // Initial processing
609    if (document.readyState === 'loading') {
610        document.addEventListener('DOMContentLoaded', processMedia);
611    } else {
612        processMedia();
613    }
614
615    // Watch for new images being added to the page
616    const observer = new MutationObserver(debouncedProcessMedia);
617    observer.observe(document.body, {
618        childList: true,
619        subtree: true
620    });
621
622    // Also process images when scrolling (for lazy-loaded images)
623    window.addEventListener('scroll', debouncedProcessMedia, { passive: true });
624
625    console.log('Image button observer started');
626})();