diff --git a/.gitignore b/.gitignore index ce4121d..201e75f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ .vscode/ dist/ +data/ +*_log.txt +test.sh diff --git a/README.md b/README.md index 81245e3..e1af8e8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,36 @@ -# Delete Your Old Tweets +# Delete Your Old Tweets, Retweets And Likes +1. this application will require [NodeJS](https://nodejs.org/en/download/current) and [Yarn](https://classic.yarnpkg.com/lang/en/docs/install/) to run 1. [Download your Twitter archive](https://twitter.com/settings/download_your_data), which contains all your tweets. -1. Locate the JSON contains your tweets, which is `data/tweet.js` (or `data/twitter-circle-tweet.js`) in the archive. +2. Locate the JSON contains your tweets, which is `data/tweet.js` (`data/like.js` or `data/twitter-circle-tweet.js`) in the archive. + +3. Choose one of the following + +## To Delete tweets 1. Run this project with `yarn && yarn start data/tweet.js`. -1. It will wait you for login -1. After login, it will delete all your tweets. +2. It will wait you for login +3. After login, it will delete all your tweets. + +## To Delete Twitter circle tweets +1. Run this project with `yarn && yarn start data/twitter-circle-tweet.js`. +2. It will wait you for login +3. After login, it will delete all your circle tweets. + + +## To Unlike previously Liked tweets +1. Run this project with `yarn && yarn start data/like.js`. +2. It will wait you for login +3. After login, it will unlike your likes. + + +## Commandline Arguments + +| argumensts | | +| ------------- |:-------------:| +| -d, --debug | writes debug information to log file | +| -l, --log | writes log information to log file | +| -e, --exlog | writes all information from log and debug to log file | +| -n, --nolog | forces app to run without making a log file, could be helpful if removing large amounts of tweets/retweets/likes | +| -s, --skip | skips up to the index given, good if you had to close the app or it crashed and don't have time to rerun the entire file | +| -w, --wait | the delay used between actions, try not to use below 5000ms as this could cause rate limiting | +| -t, --timeout | the timeout amount used after tweet is loaded (helpful on low bandwidth connections), try not to use below 5000ms as this could cause rate limiting | \ No newline at end of file diff --git a/package.json b/package.json index be49265..b354b14 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "author": "", "license": "ISC", "dependencies": { + "commander": "^11.1.0", "puppeteer": "^20.0.0", "ts-node": "^10.9.1", "typescript": "^5.1.6" diff --git a/src/index.ts b/src/index.ts index 689a009..f101a94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,209 @@ -import puppeteer from "puppeteer"; -import { prompt } from "./utils/terminal"; +import { program } from "commander"; +import fs from "fs"; import path from "path"; +import puppeteer from "puppeteer"; import { loadData } from "./load-data"; +import { prompt } from "./utils/terminal"; + + +var fileValue; + -const jsonFileInput = path.resolve(process.cwd(), process.argv[2]); +program + .arguments("") + //this is here because for some reason it doesn't parse the file properly? + .action(function(file) { + fileValue = file; + }) + + .option("-d, --debug", + "writes debug information to log file" + ) + + .option("-l, --log", + "writes log information to log file", + false + ) + + .option("-e, --exlog", + "writes all information from log and debug to log file", + true + ) + + .option("-n, --nolog", + "forces app to run without making a log file, could be helpful if removing large amounts of tweets/retweets/likes" + ) + + .option( + "-s, --skip ", + "skips up to the index given, good if you had to close the app or it crashed and don't have time to rerun the entire file", + "0" + ) + + .option( + "-t, --timeout ", + "the timeout amount used after tweet is loaded (helpful on low bandwidth connections), try not to use below 5000ms as this could cause rate limiting", + "5000" + ) + + .option("-w, --wait ", + "the delay used between actions, try not to use below 5000ms as this could cause rate limiting", + "5000"); + +program.parse(); + +const options = program.opts(); +const log = options.log; +const extended_error = options.exlog; +const skipTo = parseInt(options.skip); +const timeout_amount = parseInt(options.timeout); +const delay_amount = parseInt(options.wait); +const jsonFileInput = path.resolve(process.cwd(), fileValue /*options.file*/);//again not sure if broken or just something wrong with my install +const log_name = Date.now() + "_log.txt"; (async () => { - const tweets = await loadData(jsonFileInput); - console.log(`Found ${tweets.length} tweets`); + + + //create new log file + if (log || extended_error){ + fs.writeFileSync(log_name, "Process Started"); + fs.appendFileSync(log_name, "\n" + process.argv); + } + + + var tweets; + var isLikes; + //checks for .js files + try { + tweets = await loadData(jsonFileInput); + console.log(`Found ${tweets.length} tweets`); + isLikes = !(typeof tweets[0].tweetId === "undefined"); + } catch (e) { + console.log("No tweet.js, twitter-circle-tweet.js or like.js, Exiting Program"); + process.exit(0); + } + + //browser instance const browser = await puppeteer.launch({ headless: false }); const page = await browser.newPage(); await page.goto("https://twitter.com/"); + //wait for interaction await prompt("Login and press enter to continue..."); - for (const tweet of tweets) { - await page.goto(`https://twitter.com/baruchiro/status/${tweet.id}`); - const options = await page.waitForSelector('article[data-testid="tweet"][tabindex="-1"] div[aria-label=More]', { visible: true }); - // click on the first found selector - await options?.click(); - await page.click('div[data-testid="Dropdown"] > div[role="menuitem"]'); - await page.click('div[data-testid="confirmationSheetConfirm"]'); + //check if user is logged in by clicking on the profile button + try { + await page.click('a[data-testid="AppTabBar_Profile_Link"'); + page.waitForNavigation({ timeout: timeout_amount }); + } catch (error) { + //might not always be a not logged in issue but in the case it's not log the error to see if we can fix it + if (extended_error) fs.appendFileSync(log_name, "\n" + error); + + //print to screen and exit log + console.log("Not logged in, Exiting Program"); + console.log(error); + + //close browser and the process + await browser.close(); + process.exit(0); } + //only require to see where you are in the list + var tweet_index = 0; + + for (const tweet of tweets) { + tweet_index++; + if (tweet_index < skipTo) continue; + if (isLikes) { + await page.goto(`https://twitter.com/x/status/${tweet.tweetId}`); + try { + //check for options menu, if it times out we log the error and continue to next instance + const options = await page.waitForSelector('article[data-testid="tweet"][tabindex="-1"] div[aria-label=More]', { + visible: true, + timeout: timeout_amount, + }); + await delay(delay_amount); + + try { + //check if its a liked tweet if it is un-like it + await page.click('div[data-testid="unlike"]'); + await delay(delay_amount * 2); + + //log it + console.log("unliked, " + tweet_index); + if (log) fs.appendFileSync(log_name, "\n" + "un-retweeted: #" + tweet_index + " ID: " + tweet.tweetId); + } catch (error) { + //log error and continue on + console.log("Error: probably already unliked"); + if (log) fs.appendFileSync(log_name, "\n" + "Errored: #" + tweet_index + " ID: " + tweet.tweetId); + if (extended_error) fs.appendFileSync(log_name, "\n" + error); + console.log(error); + } + } catch (error) { + // log error and continue on + console.log("Error: tweet unavalible"); + if (log) fs.appendFileSync(log_name, "\n" + "Errored: #" + tweet_index + " ID: " + tweet.tweetId); + if (extended_error) fs.appendFileSync(log_name, "\n" + error); + console.log(error); + } + } else { + await page.goto(`https://twitter.com/x/status/${tweet.id}`); + try { + //check for options menu, if it times out we log the error and continue to next instance + const options = await page.waitForSelector('article[data-testid="tweet"][tabindex="-1"] div[aria-label=More]', { + visible: true, + timeout: timeout_amount, + }); + await delay(delay_amount); + try { + //check if its a retweet if it is un-retweet it + await page.click('div[data-testid="unretweet"]'); + await delay(delay_amount); + + //confirm un-retweet + await page.click('div[data-testid="unretweetConfirm"]'); + await delay(delay_amount); + + //log it + console.log("Unretweeted, " + tweet_index); + if (log) fs.appendFileSync(log_name, "\n" + "un-retweeted: #" + tweet_index + " ID: " + tweet.id); + await delay(delay_amount); + } catch ( + e //if its not a retweet continue to tweet delete + ) { + // click on the first found selector + await options?.click(); + await delay(delay_amount); + + // select delete + await page.click('div[data-testid="Dropdown"] > div[role="menuitem"]'); + await delay(delay_amount); + + // confirm delete + await page.click('div[data-testid="confirmationSheetConfirm"]'); + await delay(delay_amount); + + //log it + console.log("Deleted, " + tweet_index); + if (log) fs.appendFileSync(log_name, "\n" + "Deleted: #" + tweet_index + " ID: " + tweet.id); + } + } catch (error) { + // log error and continue on + console.log("Error: probably already deleted"); + if (log) fs.appendFileSync(log_name, "\n" + "Errored: #" + tweet_index + " ID: " + tweet.id); + if (extended_error) fs.appendFileSync(log_name, "\n" + error); + console.log(error); + } + } + } + // close browser await browser.close(); })(); + +// delay function to help avoid any rate limiting or slow connection issues +function delay(ms: number) { + //only here just to do a quick skip + if (ms == 0) return; + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/load-data.ts b/src/load-data.ts index 05afbdc..a921ac8 100644 --- a/src/load-data.ts +++ b/src/load-data.ts @@ -3,6 +3,7 @@ declare global { YTD: { twitter_circle_tweet: TweetsCollection; tweets: TweetsCollection; + like: LikesCollection; }; } } @@ -16,11 +17,21 @@ type TweetsCollection = { }[]; }; +type LikesCollection = { + [key: string]: { + like: { + tweetId: string; + expandedUrl: string; + }; + }[]; +}; + // @ts-expect-error - We don't care about the window object global.window = { YTD: { twitter_circle_tweet: {}, tweets: {}, + like: {}, }, } as Window; @@ -35,6 +46,10 @@ export const loadData = async (file: string) => { console.log("Found tweets"); return extractTweets(global.window.YTD.tweets); } + if (Object.keys(global.window.YTD.like).length) { + console.log("Found likes"); + return extractLikes(global.window.YTD.like); + } console.log("No tweets found"); throw new Error("No tweets found"); }; @@ -47,3 +62,19 @@ const extractTweets = (tweets: TweetsCollection) => { })), ); }; +const extractLikes = (like: LikesCollection) => { + return Object.values(like).flatMap((arr) => + arr.map((obj) => ({ + tweetId: obj.like.tweetId, + expandedUrl: obj.like.expandedUrl, + })), + ); +}; +const extractLikes = (like: LikesCollection) => { + return Object.values(like).flatMap((arr) => + arr.map((obj) => ({ + tweetId: obj.like.tweetId, + expandedUrl: obj.like.expandedUrl, + })), + ); +}; diff --git a/yarn.lock b/yarn.lock index a4bbdc8..0f18b3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -508,7 +508,7 @@ colorette@^2.0.20: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== -commander@11.1.0: +commander@11.1.0, commander@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-11.1.0.tgz#62fdce76006a68e5c1ab3314dc92e800eb83d906" integrity sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==