1+ // ==UserScript==
2+ // @name X-Downloader
3+ // @name :zh-CN X-Downloader
4+ // @name :zh-TW X-Downloader
5+ // @name :ja X-Downloader
6+ // @namespace hoothin
7+ // @version 2025-08-09
8+ // @description Enhances your Twitter (X) experience by adding a convenient download button to images and videos (GIFs), enabling easy, one-click saving of media.
9+ // @description :zh-CN 优化你的 Twitter (X) 浏览体验,直接在图片和视频(GIF)上添加一个便捷的下载按钮,一键轻松保存喜欢的媒体内容。
10+ // @description :zh-TW 優化您的 Twitter (X) 瀏覽體驗,直接在圖片及影片(GIF)上新增一個便捷的下載按鈕,一鍵輕鬆儲存喜愛的媒體內容。
11+ // @description :ja Twitter (X) の画像や動画(GIF)に便利なダウンロードボタンを追加し、ワンクリックでお気に入りのメディアを簡単に保存できるようにします。
12+ // @author hoothin
13+ // @match https://x.com/*
14+ // @match https://twitter.com/*
15+ // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
16+ // @grant none
17+ // ==/UserScript==
18+
19+ ( function ( ) {
20+ 'use strict' ;
21+ let downloadBtn = document . createElement ( "div" ) ;
22+ downloadBtn . style . cssText = "background: #000000aa; border-radius: 50%; transition: opacity ease 0.3s; position: absolute; top: 0; right: 0px; cursor: pointer; opacity: 0.5; padding: 5px;" ;
23+ downloadBtn . innerHTML = `<svg width="25" height="25" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>` ;
24+ downloadBtn . addEventListener ( "click" , e => {
25+ e . preventDefault ( ) ;
26+ e . stopPropagation ( ) ;
27+ let parent = downloadBtn . parentNode ;
28+ if ( ! parent ) return ;
29+ let img = parent . querySelector ( '[data-testid="tweetPhoto"]>img' ) ;
30+ if ( img ) {
31+ let newsrc = img . src . replace ( "_normal." , "." ) . replace ( "_200x200." , "." ) . replace ( "_mini." , "." ) . replace ( "_bigger." , "." ) . replace ( / _ x \d + \. / , "." ) ;
32+ if ( / \. s v g $ / . test ( newsrc ) ) return ;
33+ if ( newsrc == img . src ) {
34+ newsrc = newsrc . replace ( / \? f o r m a t = / i, "." ) . replace ( / \& n a m e = / i, ":" ) . replace ( / \. (? = [ ^ \. \/ ] * $ ) / , "?format=" ) . replace ( / ( : l a r g e | : m e d i u m | : s m a l l | : o r i g | : t h u m b | : [ \d x ] + ) / i, "" ) ;
35+ if ( newsrc != img . src ) {
36+ newsrc = newsrc + "&name=orig" ;
37+ }
38+ }
39+ window . open ( newsrc , "_blank" ) ;
40+ } else {
41+ while ( parent ) {
42+ if ( parent . nodeName == "ARTICLE" && parent . dataset && parent . dataset . testid == "tweet" ) {
43+ break ;
44+ }
45+ parent = parent . parentNode ;
46+ }
47+ if ( parent ) {
48+ let link = parent . querySelector ( 'a[role="link"][aria-label]' ) ;
49+ window . open ( `https://twitter.hoothin.com/?url=${ encodeURIComponent ( link ? link . href : document . location . href ) } ` , "_blank" ) ;
50+ }
51+ }
52+ } ) ;
53+ downloadBtn . addEventListener ( "mouseenter" , ( ) => {
54+ downloadBtn . style . opacity = 1 ;
55+ } ) ;
56+ downloadBtn . addEventListener ( "mouseleave" , ( ) => {
57+ downloadBtn . style . opacity = 0.5 ;
58+ } ) ;
59+ document . addEventListener ( "mouseenter" , e => {
60+ if ( e . target . dataset && e . target . dataset . testid == "tweetPhoto" ) {
61+ e . target . parentNode . appendChild ( downloadBtn ) ;
62+ } else if ( e . target . firstElementChild ) {
63+ if ( e . target . firstElementChild . getAttribute ( "role" ) == "progressbar" ) {
64+ e . target . parentNode . parentNode . appendChild ( downloadBtn ) ;
65+ }
66+ }
67+ } , true ) ;
68+ } ) ( ) ;
0 commit comments