📌

X(旧Twitter)でいいねした画像と動画を、自動でダウンロードする方法

に公開1

はじめに

みなさん、画像収集(意味深) 捗っていますか?
画像収集されている方の中には、いいねをブックマーク代わりに使っている方もいると思います。
でも、1つ1つ画像を右クリックして名前を付けて保存もめんどくさいですよね。
しかも、TwitterAPIも無料プランは投稿の取得ができなくなりました。
そこで、スクロールするだけで自分がいいねした投稿の画像と動画を自動でダウンロードしてくれる コードを書きました(AIが)。しかも無料。これでいつもの画像収集がより捗ることでしょう。

動作環境

GoogleChrome:127.0.6533.120(Official Build)(64 ビット)
OS:Windows10
CPU:Core i5-1135G7
メモリ:16GB

使用方法

  1. GoogleChromeでブラウザ版Xにログインし、自分のプロフィールを開く
  2. F12を押し、開発者ツールを開く
  3. コンソールタブを押し、>に以下のコードを張り付けてEnterを押して実行する
    ※Warning: Don't paste code...みたいなエラーが発生する場合は、allow pastingと入力してEnterを押し、貼り付けを許可した後再度下記のコードを張り付け、Enterで実行してください。

https://boonx4m312s.hatenablog.com/entry/2024/06/03/140000

クリックでコードが表示されます
(function() {
    const originalXHR = window.XMLHttpRequest;
    
    function newXHR() {
        const realXHR = new originalXHR();

        realXHR.addEventListener('readystatechange', function() {
            if (realXHR.readyState === 4 && realXHR.responseURL.includes('graphql')) {
                console.log('GraphQL request detected (XHR):', realXHR.responseURL);

                const jsonText = realXHR.responseText;

                // JSONデータを処理してメディアURLを抽出
                const data = JSON.parse(jsonText);
                const mediaUrls = extractMediaUrls(data);

                // メディアファイルをダウンロード
                mediaUrls.forEach((url) => {
                    const fileName = url.split('/').pop().split('?')[0];
                    console.log('Downloading:', fileName);
                    downloadFile(url, fileName);
                });
            }
        }, false);

        return realXHR;
    }
    window.XMLHttpRequest = newXHR;
    
    // メディアのURLを抽出する関数
    function extractMediaUrls(data) {
        const mediaUrls = [];
        const mediaEntries = data.data.user.result.timeline_v2.timeline.instructions[0].entries;

        mediaEntries.forEach(entry => {
            const itemContent = entry.content?.itemContent;

            if (itemContent && itemContent.itemType === 'TimelineTweet') {
                const tweet = itemContent.tweet_results?.result;

                if (tweet && tweet.legacy && tweet.legacy.extended_entities) {
                    const media = tweet.legacy.extended_entities.media;

                    if (media) {
                        media.forEach(mediaItem => {
                            if (mediaItem.type === 'photo') {
                                mediaUrls.push(mediaItem.media_url_https);
                            } else if (mediaItem.type === 'video') {
                                const bestVariant = mediaItem.video_info.variants
                                    .filter(variant => variant.content_type === 'video/mp4' && !variant.url.includes('.m3u8'))
                                    .reduce((prev, curr) => (prev.bitrate > curr.bitrate ? prev : curr), {});
                                if (bestVariant.url) {
                                    mediaUrls.push(bestVariant.url);
                                }
                            }
                        });
                    }
                }
            }
        });

        return [...new Set(mediaUrls)]; // 重複するURLを削除
    }

    // 画像や動画をダウンロードする関数(Blobを使用)
    function downloadFile(url, fileName) {
        fetch(url)
            .then(response => response.blob())
            .then(blob => {
                const downloadUrl = window.URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.style.display = 'none';
                a.href = downloadUrl;
                a.download = fileName;
                document.body.appendChild(a);
                a.click();
                window.URL.revokeObjectURL(downloadUrl);
            })
            .catch(err => console.error('Download failed:', err));
    }
})();
  1. Xのいいねタブをクリック。すると、いいねした画像と動画がダウンロードされる。
  2. 下にスクロールする限りダウンロードされる。
  3. 終了する時はf5でページを更新するか、ブラウザを閉じる

コードを簡単に解説

  1. Xのいいねページから、いいねした投稿の中から画像と動画が含まれるJSONをダウンロード
  2. ダウンロードしたJSONから画像と動画のURLを抽出
  3. URLからダウンロード
  4. スクロールすることで、新たに読み込んだJSONをダウンロードし、2へ戻る
    動画はビットレートが一番高いもののみをダウンロードするようにしています。また、重複したURLからはダウンロードしないようになっています。

最後に

これ、自分とか他人のポストとかメディアもダウンロードできるんじゃね...?
あくまでも自己責任で使用してください。分からん事あったらChatGPTにコードぶち込んで聞いてください。不具合あったら直して。

Discussion

たまごたまご

非常に助かりました。
しかし、一部ダウンロードできないファイルがあり、再現性があったため検証したところ、この実装だとアクセスレートが激しすぎて弾かれる場合があるようです。
そこで、jsonから抜き出したメディアファイルを即ダウンロードするのではなく、キューに溜め込んで一定の間隔(500ms)でダウンロードしていくという実装にしたところ、欠落がなくなりました。共有します。
ダウンロードフォルダ内で混ざりにくいように、ファイル名にXLikes-という修飾をする改変もしています。

let queue = []; //①ダウンロードキュー用の配列を作成
(function() {
    const originalXHR = window.XMLHttpRequest;
    
    function newXHR() {
        const realXHR = new originalXHR();

        realXHR.addEventListener('readystatechange', function() {
            if (realXHR.readyState === 4 && realXHR.responseURL.includes('graphql')) {
                console.log('GraphQL request detected (XHR):', realXHR.responseURL);

                const jsonText = realXHR.responseText;

                // JSONデータを処理してメディアURLを抽出
                const data = JSON.parse(jsonText);
                const mediaUrls = extractMediaUrls(data);

                // メディアファイルをダウンロード
                mediaUrls.forEach((url) => {
                    const fileName = url.split('/').pop().split('?')[0];
                    //②ダウンロード処理ではなくキューへの追加処理に変更
                    console.log('Added to Queue:', fileName);
                    queue.push({
                      url: url,
                      fileName: fileName
                    });
                });
            }
        }, false);

        return realXHR;
    }
    window.XMLHttpRequest = newXHR;
    
    // メディアのURLを抽出する関数
    function extractMediaUrls(data) {
        const mediaUrls = [];
        const mediaEntries = data.data.user.result.timeline_v2.timeline.instructions[0].entries;

        mediaEntries.forEach(entry => {
            const itemContent = entry.content?.itemContent;

            if (itemContent && itemContent.itemType === 'TimelineTweet') {
                const tweet = itemContent.tweet_results?.result;

                if (tweet && tweet.legacy && tweet.legacy.extended_entities) {
                    const media = tweet.legacy.extended_entities.media;

                    if (media) {
                        media.forEach(mediaItem => {
                            if (mediaItem.type === 'photo') {
                                mediaUrls.push(mediaItem.media_url_https);
                            } else if (mediaItem.type === 'video') {
                                const bestVariant = mediaItem.video_info.variants
                                    .filter(variant => variant.content_type === 'video/mp4' && !variant.url.includes('.m3u8'))
                                    .reduce((prev, curr) => (prev.bitrate > curr.bitrate ? prev : curr), {});
                                if (bestVariant.url) {
                                    mediaUrls.push(bestVariant.url);
                                }
                            }
                        });
                    }
                }
            }
        });

        return [...new Set(mediaUrls)]; // 重複するURLを削除
    }
    //③キューを回す処理
    let pointer = 0;
    setInterval(()=>{
      if(queue[pointer] != undefined){
        let task = queue[pointer++];
        console.log("Downloading: "+ task.fileName);
        downloadFile(task.url, "XLikes-"+task.fileName);
      }
    }, 100)
    // 画像や動画をダウンロードする関数(Blobを使用)
    function downloadFile(url, fileName) {
        fetch(url)
            .then(response => response.blob())
            .then(blob => {
                const downloadUrl = window.URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.style.display = 'none';
                a.href = downloadUrl;
                a.download = fileName;
                document.body.appendChild(a);
                a.click();
                window.URL.revokeObjectURL(downloadUrl);
            })
            .catch(err => console.error('Download failed:', err));
    }
})();

お役に立てば幸いです。