Threads.net Media Downloader

Add download button for posts with images/videos on Threads.net

  1. // ==UserScript==
  2. // @name Threads.net Media Downloader
  3. // @namespace https://www.youtube.com/channel/UC26YHf9ASpeu68az2xRKn1w
  4. // @version 05-01-2025
  5. // @description Add download button for posts with images/videos on Threads.net
  6. // @author Kinnena, ICHx
  7. // @match https://www.threads.net/*
  8. // @match https://www.threads.com/*
  9. //
  10. // @icon https://cdn-icons-png.flaticon.com/512/12105/12105338.png
  11. // @grant GM_download
  12. // @license MIT
  13.  
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. function addButtonToElement(element) {
  20. if (element.querySelector('button.my-custom-button')) return;
  21.  
  22. const postContainer = element.closest('div[role="article"]') ||
  23. element.parentElement?.parentElement?.parentElement?.parentElement?.parentElement;
  24.  
  25. if (!postContainer) return;
  26.  
  27. const hasMedia = postContainer.querySelector('picture img, video');
  28. if (!hasMedia) return;
  29.  
  30. const button = document.createElement('button');
  31. button.textContent = 'Download';
  32. button.className = 'my-custom-button';
  33. Object.assign(button.style, {
  34. position: 'relative',
  35. background: '#0095f6',
  36. color: 'white',
  37. border: 'none',
  38. borderRadius: '4px',
  39. padding: '6px 12px',
  40. margin: '4px',
  41. cursor: 'pointer'
  42. });
  43.  
  44. button.addEventListener('click', function(event) {
  45. event.preventDefault();
  46. event.stopPropagation();
  47.  
  48. // Get post metadata
  49. const spanElement = postContainer.querySelector('span[class*="x1s688f"]');
  50. const timeElement = postContainer.querySelector('time');
  51. const spanText = (spanElement?.textContent || 'unknown').replace(/[^\w]/g, '_').substring(0, 30);
  52. const datetime = timeElement?.getAttribute('datetime');
  53.  
  54. // Format timestamp
  55. let formattedTime = '';
  56. if (datetime) {
  57. const date = new Date(datetime);
  58. formattedTime = [
  59. date.getFullYear(),
  60. String(date.getMonth() + 1).padStart(2, '0'),
  61. String(date.getDate()).padStart(2, '0'),
  62. '_',
  63. String(date.getHours()).padStart(2, '0'),
  64. String(date.getMinutes()).padStart(2, '0'),
  65. String(date.getSeconds()).padStart(2, '0')
  66. ].join('');
  67. }
  68.  
  69. // Collect media
  70. const mediaElements = [
  71. ...postContainer.querySelectorAll('picture img'),
  72. ...postContainer.querySelectorAll('video')
  73. ];
  74.  
  75. mediaElements.forEach((media, index) => {
  76. let url, type;
  77. if (media.tagName === 'IMG') {
  78. url = media.src;
  79. type = 'image';
  80. } else {
  81. url = media.src || media.querySelector('source')?.src;
  82. type = 'video';
  83. }
  84.  
  85. if (url) {
  86. const extension = getFileExtension(url) || (type === 'image' ? 'jpg' : 'mp4');
  87. const filename = `Threads_${spanText}_${formattedTime}_${index + 1}.${extension}`;
  88. GM_download({
  89. url: url,
  90. name: filename,
  91. onerror: (e) => console.error('Download error:', e)
  92. });
  93. }
  94. });
  95.  
  96. // Auto-like functionality
  97. const likeButton = postContainer.querySelector('[aria-label="讚"]');
  98. if (likeButton) {
  99. likeButton.click();
  100. }
  101. });
  102.  
  103. element.appendChild(button);
  104. }
  105.  
  106. function getFileExtension(url) {
  107. try {
  108. const cleanUrl = url.split(/[?#]/)[0];
  109. return cleanUrl.split('.').pop().toLowerCase();
  110. } catch {
  111. return null;
  112. }
  113. }
  114.  
  115. function scanForButtons() {
  116. document.querySelectorAll('div[class*="x1fc57z9"]').forEach(addButtonToElement);
  117. }
  118.  
  119. // Initial check
  120. scanForButtons();
  121. // Periodic check for new posts
  122. setInterval(scanForButtons, 1000);
  123. })();