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})();