TweetXer

Delete all your Tweets for free.

目前为 2024-11-16 提交的版本。查看 最新版本

// ==UserScript==
// @name         TweetXer
// @namespace    https://github.com/lucahammer/tweetXer/
// @version      0.7.0
// @description  Delete all your Tweets for free.
// @author       Luca,dbort,pReya,Micolithe,STrRedWolf
// @license      NoHarm-draft
// @match        https://x.com/*
// @icon         https://www.google.com/s2/favicons?domain=twitter.com
// @grant        unsafeWindow
// @supportURL   https://github.com/lucahammer/tweetXer/issues
// ==/UserScript==

var TweetsXer = {
    allowed_requests: [],
    TweetCount: 0,
    dId: "exportUpload",
    tIds: [],
    tId: "",
    ratelimitreset: 0,
    more: '[data-testid="tweet"] [aria-label="More"][data-testid="caret"]',
    skip: 0,
    total: 0,
    dCount: 0,
    lastHeaders: {},
    deleteURL: 'https://x.com/i/api/graphql/VaenaVgh5q5ih7kvyVjgtg/DeleteTweet',
    unfavURL: 'https://x.com/i/api/graphql/ZYKSe-w7KEslx3JhSIk5LA/UnfavoriteTweet',
    username: '',
    action: '',
    bookmarksURL: 'https://x.com/i/api/graphql/sLg287PtRrRWcUciNGFufQ/Bookmarks?',
    bookmarks: [],
    bookmarksNext: '',

    init() {
        // document.querySelector('header>div>div').setAttribute('class', '')
        TweetsXer.username = document.location.href.split('/')[3]
        this.createUploadForm()
        TweetsXer.initXHR()
        TweetsXer.getTweetCount()
        this.sleep(200)
    },

    sleep(ms) {
        return new Promise((resolve) => setTimeout(resolve, ms))
    },

    initXHR() {
        if (typeof AjaxMonitoring_notfired == "undefined") { var AjaxMonitoring_notfired = false }
        if (!AjaxMonitoring_notfired) {
            AjaxMonitoring_notfired = true

            /* NOTE: XMLHttpRequest actions happen in this sequence: at first "open"[readyState=1] happens, then "setRequestHeader", then "send", then "open"[readyState=2] */

            var XHR_SendOriginal = XMLHttpRequest.prototype.send
            XMLHttpRequest.prototype.send = function () {
                XHR_SendOriginal.apply(this, arguments)
            }

            var XHR_OpenOriginal = XMLHttpRequest.prototype.open
            XMLHttpRequest.prototype.open = function () {
                if (arguments[1] && arguments[1].includes("DeleteTweet")) {
                    // POST /DeleteTweet
                    TweetsXer.deleteURL = arguments[1]
                }
                XHR_OpenOriginal.apply(this, arguments)
            }

            var XHR_SetRequestHeaderOriginal = XMLHttpRequest.prototype.setRequestHeader
            XMLHttpRequest.prototype.setRequestHeader = function (a, b) {
                TweetsXer.lastHeaders[a] = b
                XHR_SetRequestHeaderOriginal.apply(this, arguments)
            }
        }
    },

    updateProgressBar() {
        document.getElementById('progressbar').setAttribute('value', this.dCount)
        document.getElementById("info").textContent = `${this.dCount} deleted`
    },

    processFile() {
        let tn = document.getElementById(`${TweetsXer.dId}_file`)
        if (tn.files && tn.files[0]) {
            let fr = new FileReader()
            fr.onloadend = function (evt) {
                // window.YTD.tweet_headers.part0
                // window.YTD.tweets.part0
                // window.YTD.like.part0
                let cutpoint = evt.target.result.indexOf('= ')
                let filestart = evt.target.result.slice(0, cutpoint)
                let json = JSON.parse(evt.target.result.slice(cutpoint + 1))

                if (filestart.includes('.tweet_headers.')) {
                    console.log('File contains Tweets.')
                    TweetsXer.action = 'untweet'
                    TweetsXer.tIds = json.map((x) => x.tweet.tweet_id)
                } else if (filestart.includes('.tweets.') || filestart.includes('.tweet.')) {
                    console.log('File contains Tweets.')
                    TweetsXer.action = 'untweet'
                    TweetsXer.tIds = json.map((x) => x.tweet.id_str)
                } else if (filestart.includes('.like.')) {
                    console.log('File contains Favs.')
                    TweetsXer.action = 'unfav'
                    TweetsXer.tIds = json.map((x) => x.like.tweetId)
                } else {
                    console.log('File contain not recognized. Please use a file from the Twitter data export.')
                }


                TweetsXer.total = TweetsXer.tIds.length
                document.getElementById('start').remove()
                TweetsXer.createProgressBar()

                TweetsXer.skip = document.getElementById('skipCount').value


                if (TweetsXer.action == 'untweet') {
                    if (TweetsXer.skip == 0) {
                        // If there is no amount set to skip, automatically try to skip the amount
                        // that has been deleted already. Difference of Tweeets in file to count on profile
                        // 5% tolerance to prevent skipping too much
                        TweetsXer.skip = TweetsXer.total - TweetsXer.TweetCount - parseInt(TweetsXer.total / 20)
                        TweetsXer.skip = Math.max(0, TweetsXer.skip)
                    }
                    console.log(`Skipping oldest ${TweetsXer.skip} Tweets`)
                    TweetsXer.tIds.reverse()
                    TweetsXer.tIds = TweetsXer.tIds.slice(TweetsXer.skip)
                    TweetsXer.dCount = TweetsXer.skip
                    TweetsXer.tIds.reverse()
                    document.getElementById(
                        `${TweetsXer.dId}_title`
                    ).textContent = `Deleting ${TweetsXer.total} Tweets`

                    TweetsXer.deleteTweets()
                } else if (TweetsXer.action == 'unfav') {
                    console.log(`Skipping oldest ${TweetsXer.skip} Tweets`)
                    TweetsXer.tIds = TweetsXer.tIds.slice(TweetsXer.skip)
                    TweetsXer.dCount = TweetsXer.skip
                    TweetsXer.tIds.reverse()
                    document.getElementById(
                        `${TweetsXer.dId}_title`
                    ).textContent = `Deleting ${TweetsXer.total} Favs`
                    TweetsXer.deleteFavs()
                } else {
                    document.getElementById(
                        `${TweetsXer.dId}_title`
                    ).textContent = `Please try a different file`
                }

            }
            fr.readAsText(tn.files[0])
        }
    },

    createUploadForm() {
        var h2_class = document.querySelectorAll("h2")[1]?.getAttribute("class") || ""
        var div = document.createElement("div")
        div.id = this.dId
        if (document.getElementById(this.dId)) { document.getElementById(this.dId).remove() }
        div.innerHTML = `<style>#${this.dId}{ z-index:99999; position: sticky; top:0px; left:0px; width:auto; margin:0 auto; padding: 20px 10%; background:#87CEFA; opacity:0.9; } #${this.dId} > *{padding:5px;}</style>
        <div style="color:black">
            <h2 class="${h2_class}" id="${this.dId}_title">TweetXer</h2>
            <p id="info">Select your tweet-headers.js from your Twitter Data Export to start the deletion of all your Tweets. </p>
        <p id="start">
          <input type="file" value="" id="${this.dId}_file"  />
          <a style="color:blue" href="#" id="toggleAdvanced">Advanced Options</a>
          <div id="advanced" style="display:none">
          <label for="skipCount">Enter how many Tweets to skip (useful for reruns) before selecting a file.</label>
          <input id="skipCount" type="number" value="0" />

          <p>To delete your Favs (aka Likes), select your like.js file.</p>
          <p>Instead of your tweet-headers.js file, you can use the tweets.js file. Unfaving is limited to 500 unfavs per 15 minutes.</p>
          <input id="exportBookmarks" type="button" value="Export Bookmarks" />

          <p><strong>No tweet-headers.js?</strong><br>
            If you are unable to get your data export, you can use the following option.<br>
            This option is much slower and less reliable. It can remove at most 4000 Tweets per hour.<br>
            <input id="slowDelete" type="button" value="Slow delete without file" />
          </p>

          <p><strong>Unfollow everyone</strong><br>
            It's time to let go. This will unfollow everyone you follow.<br>
            <input id="unfollowEveryone" type="button" value="Unfollow everyone" />
          </p>
        <p><input id="removeTweetXer" type="button" value="Remove TweetXer" /></p>
        </div>
        </p>
        </div>`
        document.body.insertBefore(div, document.body.firstChild)
        document.getElementById("toggleAdvanced").addEventListener("click", (() => {
            let adv = document.getElementById('advanced')
            if (adv.style.display == 'none') {
                adv.style.display = 'block'
            } else {
                adv.style.display = 'none'
            }
        }))
        document.getElementById(`${this.dId}_file`).addEventListener("change", this.processFile, false)
        document.getElementById("exportBookmarks").addEventListener("click", this.exportBookmarks, false)
        document.getElementById("slowDelete").addEventListener("click", this.slowDelete, false)
        document.getElementById("unfollowEveryone").addEventListener("click", this.unfollow, false)
        document.getElementById("removeTweetXer").addEventListener("click", this.removeTweetXer, false)

    },

    async exportBookmarks() {
        //document.getElementById('exportBookmarks').remove()
        //TweetsXer.createProgressBar()
        while (!('authorization' in TweetsXer.lastHeaders)) {
            await TweetsXer.sleep(1000)
        }
        let variables = ''
        while (TweetsXer.bookmarksNext.length > 0 || TweetsXer.bookmarks.length == 0) {
            if (TweetsXer.bookmarksNext.length > 0) {
                variables = `{"count":20,"cursor":"${TweetsXer.bookmarksNext}","includePromotedContent":true}`
            } else variables = '{"count":20,"includePromotedContent":false}'
            let response = await fetch(TweetsXer.bookmarksURL + new URLSearchParams({
                variables: variables,
                features: '{"graphql_timeline_v2_bookmark_timeline":true,"responsive_web_graphql_exclude_directive_enabled":true,"verified_phone_label_enabled":false,"responsive_web_home_pinned_timelines_enabled":true,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"tweetypie_unmention_optimization_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":false,"tweet_awards_web_tipping_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_media_download_video_enabled":false,"responsive_web_enhance_cards_enabled":false}'
            }), {
                "headers": {
                    "accept": "*/*",
                    "accept-language": 'en-US,en;q=0.5',
                    "authorization": TweetsXer.lastHeaders.authorization,
                    "content-type": "application/json",
                    "sec-fetch-dest": "empty",
                    "sec-fetch-mode": "cors",
                    "sec-fetch-site": "same-origin",
                    "x-client-transaction-id": TweetsXer.lastHeaders['X-Client-Transaction-Id'],
                    "x-client-uuid": TweetsXer.lastHeaders['x-client-uuid'],
                    "x-csrf-token": TweetsXer.lastHeaders['x-csrf-token'],
                    "x-twitter-active-user": "yes",
                    "x-twitter-auth-type": "OAuth2Session",
                    "x-twitter-client-language": 'en'
                },
                "referrer": 'https://x.com/i/bookmarks',
                "referrerPolicy": "strict-origin-when-cross-origin",
                "method": "GET",
                "mode": "cors",
                "credentials": "include"
            })

            if (response.status == 200) {
                let data = await response.json()
                data.data.bookmark_timeline_v2.timeline.instructions[0].entries.forEach((item) => {

                    if (item.entryId.includes('tweet')) {
                        TweetsXer.dCount++
                        TweetsXer.bookmarks.push(item.content.itemContent.tweet_results.result)
                    } else if (item.entryId.includes('cursor-bottom')) {
                        if (TweetsXer.bookmarksNext != item.content.value) {
                            TweetsXer.bookmarksNext = item.content.value
                        } else {
                            TweetsXer.bookmarksNext = ''
                        }
                    }
                })
                console.log(TweetsXer.bookmarks)
                //document.getElementById('progressbar').setAttribute('value', TweetsXer.dCount)
                document.getElementById("info").textContent = `${TweetsXer.dCount} Bookmarks collected`
            } else {
                console.log(response)
            }

            if (response.headers.get('x-rate-limit-remaining') < 1) {
                console.log('rate limit hit')
                let ratelimitreset = response.headers.get('x-rate-limit-reset')
                let sleeptime = ratelimitreset - Math.floor(Date.now() / 1000)
                while (sleeptime > 0) {
                    sleeptime = ratelimitreset - Math.floor(Date.now() / 1000)
                    document.getElementById("info").textContent = `Ratelimited. Waiting ${sleeptime} seconds. ${TweetsXer.dCount} deleted.`
                    await TweetsXer.sleep(1000)
                }
            }
        }
        let download = new Blob([JSON.stringify(TweetsXer.bookmarks)], {
            type: 'text/plain'
        })
        let bookmarksDownload = document.createElement("a")
        bookmarksDownload.id = 'bookmarksDownload'
        bookmarksDownload.innerText = 'Download'
        bookmarksDownload.href = window.URL.createObjectURL(download)
        bookmarksDownload.download = 'twitter-bookmarks.json'
        document.getElementById('advanced').appendChild(bookmarksDownload)
    },

    createProgressBar() {
        let progressbar = document.createElement("progress")
        progressbar.setAttribute('id', "progressbar")
        progressbar.setAttribute('value', this.dCount)
        progressbar.setAttribute('max', this.total)
        progressbar.setAttribute('style', 'width:100%')
        document.getElementById(this.dId).appendChild(progressbar)
    },

    async deleteFavs() {
        // 500 unfavs per 15 Minutes
        // x-rate-limit-remaining
        // x-rate-limit-reset
        while (!('authorization' in this.lastHeaders)) {
            await TweetsXer.sleep(1000)
        }
        TweetsXer.username = document.location.href.split('/')[3]

        while (this.tIds.length > 0) {
            this.tId = this.tIds.pop()
            let response = await fetch(this.unfavURL, {
                "headers": {
                    "accept": "*/*",
                    "accept-language": 'en-US,en;q=0.5',
                    "authorization": this.lastHeaders.authorization,
                    "content-type": "application/json",
                    "sec-fetch-dest": "empty",
                    "sec-fetch-mode": "cors",
                    "sec-fetch-site": "same-origin",
                    "x-client-transaction-id": this.lastHeaders['X-Client-Transaction-Id'],
                    "x-client-uuid": this.lastHeaders['x-client-uuid'],
                    "x-csrf-token": this.lastHeaders['x-csrf-token'],
                    "x-twitter-active-user": "yes",
                    "x-twitter-auth-type": "OAuth2Session",
                    "x-twitter-client-language": 'en'
                },
                "referrer": `https://x.com/${this.username}/likes`,
                "referrerPolicy": "strict-origin-when-cross-origin",
                "body": `{\"variables\":{\"tweet_id\":\"${this.tId}\"},\"queryId\":\"${this.unfavURL.split('/')[6]}\"}`,
                "method": "POST",
                "mode": "cors",
                "credentials": "include"
            })

            if (response.status == 200) {
                TweetsXer.dCount++
                TweetsXer.updateProgressBar()
            } else {
                console.log(response)
            }

            if (response.headers.get('x-rate-limit-remaining') < 1) {
                console.log('rate limit hit')
                let ratelimitreset = response.headers.get('x-rate-limit-reset')
                let sleeptime = ratelimitreset - Math.floor(Date.now() / 1000)
                while (sleeptime > 0) {
                    sleeptime = ratelimitreset - Math.floor(Date.now() / 1000)
                    document.getElementById("info").textContent = `Ratelimited. Waiting ${sleeptime} seconds. ${TweetsXer.dCount} deleted.`
                    await TweetsXer.sleep(1000)
                }
            }
        }
    },

    async deleteTweets() {
        while (!('authorization' in this.lastHeaders)) {
            await TweetsXer.sleep(1000)
        }
        TweetsXer.username = document.location.href.split('/')[3]

        while (this.tIds.length > 0) {
            this.tId = this.tIds.pop()
            try {
                let response = await fetch(this.deleteURL, {
                    "headers": {
                        "accept": "*/*",
                        "accept-language": 'en-US,en;q=0.5',
                        "authorization": this.lastHeaders.authorization,
                        "content-type": "application/json",
                        "sec-fetch-dest": "empty",
                        "sec-fetch-mode": "cors",
                        "sec-fetch-site": "same-origin",
                        "x-client-transaction-id": this.lastHeaders['X-Client-Transaction-Id'],
                        "x-client-uuid": this.lastHeaders['x-client-uuid'],
                        "x-csrf-token": this.lastHeaders['x-csrf-token'],
                        "x-twitter-active-user": "yes",
                        "x-twitter-auth-type": "OAuth2Session",
                        "x-twitter-client-language": 'en'
                    },
                    "referrer": `https://x.com/${this.username}/with_replies`,
                    "referrerPolicy": "strict-origin-when-cross-origin",
                    "body": `{\"variables\":{\"tweet_id\":\"${this.tId}\",\"dark_request\":false},\"queryId\":\"${this.deleteURL.split('/')[6]}\"}`,
                    "method": "POST",
                    "mode": "cors",
                    "credentials": "include",
                    "signal": AbortSignal.timeout(5000)
                })
                if (response.status == 200) {
                    TweetsXer.dCount++
                    TweetsXer.updateProgressBar()
                }
                else if (response.status == 429) {
                    this.tIds.push(this.tId)
                    console.log('Received status code 429. Waiting for 1 second before trying again.')
                    await TweetsXer.sleep(1000)
                }
                else {
                    console.log(response)
                }

            } catch (error) {
                if (error.Name === 'AbortError') {
                    this.tIds.push(this.tId)
                    console.log('Request timeout.')
                    let sleeptime = 15
                    while (sleeptime > 0) {
                        sleeptime--
                        document.getElementById("info").textContent = `Ratelimited. Waiting ${sleeptime} seconds. ${TweetsXer.dCount} deleted.`
                        await TweetsXer.sleep(1000)
                    }
                }
            }
        }
    },

    async getTweetCount() {
        await waitForElemToExist('header')
        await TweetsXer.sleep(1000)
        try {
            document.querySelector('[data-testid="AppTabBar_Profile_Link"]').click()
        } catch (error) {
            if (document.querySelector('[aria-label="Back"]')) {
                document.querySelector('[aria-label="Back"]').click()
                await TweetsXer.sleep(1000)
            }

            if (document.querySelector('[data-testid="app-bar-back"]')) {
                document.querySelector('[data-testid="app-bar-back"]').click()
                await TweetsXer.sleep(1000)
            }
            document.querySelector('[data-testid="DashButton_ProfileIcon_Link"]').click()
            await TweetsXer.sleep(1000)
            document.querySelector('[aria-label="Account"] a').click()
        }
        await waitForElemToExist('[data-testid="UserName"]')
        await TweetsXer.sleep(1000)

        try {
            TweetsXer.TweetCount = document.querySelector('[aria-label="Home timeline"]>div>div')
                .textContent.match(/((\d|,|\.|K)+) posts$/)[1]
                .replace(/\.(\d+)K/, '$1'.padEnd(4, '0'))
                .replace('K', '000')
                .replace(',', '')
        } catch (error) {
            try {
                TweetsXer.TweetCount = document.querySelector('[data-testid="TopNavBar"]>div>div')
                    .textContent.match(/((\d|,|\.|K)+) posts$/)[1]
                    .replace(/\.(\d+)K/, '$1'.padEnd(4, '0'))
                    .replace('K', '000')
                    .replace(',', '')
            }
            catch (error) {
                console.log("Wasn't able to find Tweet count on profile. Setting it to 1 million.")
                TweetsXer.TweetCount = 1000000 // prevents Tweets from being skipped because of tweet count of 0
            }
        }
        console.log(TweetsXer.TweetCount + " Tweets on profile.")
        console.log("You can close the console now to reduce the memory usage.")
        console.log("Reopen the console if there are issues to see if an error shows up.")
    },

    async slowDelete() {
        document.getElementById("toggleAdvanced").click()
        document.getElementById('start').remove()
        TweetsXer.total = TweetsXer.TweetCount
        TweetsXer.createProgressBar()

        document.querySelectorAll('[data-testid="ScrollSnap-List"] a')[1].click()
        await TweetsXer.sleep(2000)

        let unretweet, confirmURT, caret, menu, confirmation

        const more = '[data-testid="tweet"] [aria-label="More"][data-testid="caret"]'
        while (document.querySelectorAll(more).length > 0) {

            // give the Tweets a chance to load; increase/decrease if necessary
            // afaik the limit is 50 requests per minute
            await TweetsXer.sleep(1200)

            // hide recommended profiles and stuff
            document.querySelectorAll('[aria-label="Profile timelines"]+section [data-testid="cellInnerDiv"]>div>div>div').forEach(x => x.remove())
            document.querySelectorAll('[aria-label="Profile timelines"]+section [data-testid="cellInnerDiv"]>div>div>[role="link"]').forEach(x => x.remove())
            document.querySelector('[aria-label="Profile timelines"]').scrollIntoView({
                'behavior': 'smooth'
            })

            // if it is a Retweet, unretweet it
            unretweet = document.querySelector('[data-testid="unretweet"]')
            if (unretweet) {
                unretweet.click()
                confirmURT = await waitForElemToExist('[data-testid="unretweetConfirm"]')
                confirmURT.click()
            }

            // delete Tweet
            else {
                caret = await waitForElemToExist(more)
                caret.click()

                menu = await waitForElemToExist('[role="menuitem"]')
                if (menu.textContent.includes('@')) {
                    // don't unfollow people (because their Tweet is the reply tab)
                    caret.click()
                    document.querySelector('[data-testid="tweet"]').remove()
                } else {
                    menu.click()
                    confirmation = await waitForElemToExist('[data-testid="confirmationSheetConfirm"]')
                    if (confirmation) confirmation.click()
                }
            }

            TweetsXer.dCount++
            TweetsXer.updateProgressBar()

            // print to the console how many Tweets already got deleted
            // Change the 100 to how often you want an update.
            // 10 for every 10th Tweet, 1 for every Tweet, 100 for every 100th Tweet
            if (TweetsXer.dCount % 100 == 0) console.log(`${new Date().toUTCString()} Deleted ${TweetsXer.dCount} Tweets`)

        }

        console.log('No Tweets left. Please reload to confirm.')
    },

    async unfollow() {
        //document.getElementById("toggleAdvanced").click()
        let unfollowCount = 0
        let next_unfollow, menu

        document.querySelector('[href$="/following"]').click()
        await TweetsXer.sleep(1200)

        const accounts = '[data-testid="UserCell"]'
        while (document.querySelectorAll('[data-testid="UserCell"] [data-testid$="-unfollow"]').length > 0) {
            next_unfollow = document.querySelectorAll(accounts)[0]
            next_unfollow.scrollIntoView({
                'behavior': 'smooth'
            })

            next_unfollow.querySelector('[data-testid$="-unfollow"]').click()
            menu = await waitForElemToExist('[data-testid="confirmationSheetConfirm"]')
            menu.click()
            next_unfollow.remove()
            unfollowCount++
            if (unfollowCount % 10 == 0) console.log(`${new Date().toUTCString()} Unfollowed ${unfollowCount} accounts`)
            await TweetsXer.sleep(Math.floor(Math.random() * 200))
        }

        console.log('No accounts left. Please reload to confirm.')
    },
    removeTweetXer() {
        document.getElementById('exportUpload').remove()
    }
}

const waitForElemToExist = async (selector) => {
    return new Promise(resolve => {
        if (document.querySelector(selector)) {
            return resolve(document.querySelector(selector))
        }

        const observer = new MutationObserver(() => {
            if (document.querySelector(selector)) {
                resolve(document.querySelector(selector))
                observer.disconnect()
            }
        })

        observer.observe(document.body, {
            subtree: true,
            childList: true,
        })
    })
}

TweetsXer.init()