Skip to content

Instantly share code, notes, and snippets.

@TapiocaFox
Last active February 7, 2025 07:53
Show Gist options
  • Save TapiocaFox/bffea5f9edd3b9b9ed3bee919bf1d034 to your computer and use it in GitHub Desktop.
Save TapiocaFox/bffea5f9edd3b9b9ed3bee919bf1d034 to your computer and use it in GitHub Desktop.
Twitter unliker based on criteria. By TapiocaFox.
// Twitter unliker based on criteria, such as keywords, languages, userid. By TapiocaFox 2025.
class FixedLengthQueue {
constructor(capacity) {
this.capacity = capacity;
this.queue = [];
}
enqueue(item) {
if (this.isFull()) {
this.dequeue();
}
this.queue.push(item);
}
dequeue() {
return this.queue.shift();
}
peek() {
return this.queue[0];
}
size() {
return this.queue.length;
}
isFull() {
return this.size() === this.capacity;
}
isEmpty() {
return this.size() === 0;
}
includes(item) {
return this.queue.includes(item);
}
clear() {
this.queue = [];
}
}
// Some CJK Utilities.
function containsJapaneseOrChinese(text) {
// Match Japanese and Chinese characters.
const regex = /[\u4e00-\u9fa5\u3040-\u30FF\u31F0-\u31FF]+/g;
return regex.test(text);
}
function containsJapanese(text) {
// Match Japanese Hiragana, Katagana.
const regex = /[\u3040-\u309f\u30a0-\u30ff]+/g;
return regex.test(text);
}
function containsOnlyChinese(text) {
// Match only Chinese characters.
return containsJapaneseOrChinese(text)&&!containsJapanese(text);
}
// Unlike it if the text contains below keywords.
const unwantedKeywords = ['Milkshake'];
function containsUnwantedKeywords(text) {
if(text) for(const keyword of unwantedKeywords) {
if(text.includes(keyword)) return true;
}
return false;
}
// Unlike it if the text is one of the languages below.
const unwantedLanguages = ['zh'];
function isUnwantedLanguage(lang) {
return unwantedLanguages.includes(lang);
}
// Get user id from tweet div.
function getUserIdFromDiv(tweetDiv) {
const a = tweetDiv.querySelector('[data-testid="User-Name"] a');
const href = a?a.href:null;
const regex = /http.*\/([^\/]+)/;
return href?href.match(regex)[1]:null;
}
// Unlike it if the user id is the list.
const unwantedUserIds = ['zach'];
function isUnwantedUser(tweetDiv) {
return unwantedUserIds.includes(getUserIdFromDiv(tweetDiv));
}
// The main function that the unliker uses to match criteria. You should modify here.
// The parameter next is a list: [unlikeButton, tweetString, lang, tweetDiv]
function matchCriteria(next) {
const text = next[1];
const lang = next[2];
const tweetDiv = next[3];
console.log(text?text:"null", '\nLang =', lang, ', UnwantedLang =', isUnwantedLanguage(lang), ', User =', getUserIdFromDiv(tweetDiv), ', UnwantedUser =', isUnwantedUser(tweetDiv), '\nJpOrZh =', containsJapaneseOrChinese(text), ', HasJp =', containsJapanese(text), ', OnlyZh =', containsOnlyChinese(text), ', unwantedKeyword =', containsUnwantedKeywords(text));
return isUnwantedUser(tweetDiv)||isUnwantedLanguage(lang)||containsOnlyChinese(text)||containsUnwantedKeywords(text)
}
// Test code.
// let text = "策セヨソ視新んリぞど文始仮エヌ性析モル説禁ケサヌ大世オケスシ談9記6接チレキラ行童ヤヱヌ朝字こ就含と応済はぼんち詰丼冨びぱ。"
// let text2 = "你好世界,helloworld。"
// let text3 = "Helloworld."
// console.log(containsJapaneseOrChinese(text), containsJapanese(text), containsOnlyChinese(text));
// console.log(containsJapaneseOrChinese(text2), containsJapanese(text2), containsOnlyChinese(text2));
// console.log(containsJapaneseOrChinese(text3), containsJapanese(text3), containsOnlyChinese(text3));
// Get the parentNode that contains tweets.
function getTweetDivs() {
return document.querySelector('[data-testid="cellInnerDiv"]').parentNode;
}
// We treat datetimes like UUIDs for tweets in lazy scrolling.
let datetimeQueue = new FixedLengthQueue(256);
function nextUnlike() {
let tweetDivs = getTweetDivs();
let tweetDiv = null;
// Retrive the first tweet such that the datetime wasn't seen before.
for(let i = 0; i < tweetDivs.childElementCount; i++) {
let time = tweetDivs.children[i].querySelector('time[datetime]');
if(time == null) continue;
let datetime = time.getAttribute('datetime');
if(!datetimeQueue.includes(datetime)) {
datetimeQueue.enqueue(datetime);
tweetDiv = tweetDivs.children[i];
break;
}
}
// Return null if nothing found.
if(tweetDiv == null) return null;
// Pre-processing.
let tweetText = tweetDiv.querySelector('[data-testid="tweetText"]');
let tweetString = tweetText?tweetText.innerText:null;
let langDiv = tweetDiv.querySelector('[lang]');
let lang = langDiv?langDiv.getAttribute('lang'):null
let unlikeButton = tweetDiv.querySelector('[data-testid="unlike"],[data-testid="like"]');
return [unlikeButton, tweetString, lang, tweetDiv];
}
// Async wait.
function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Configuration.
let tweetCountLimit = 1024*8;
let isStop = false;
let tweetCount = 0;
let unlikedCount = 0;
let unlikedFailedCount = 0;
let scrolledScrollHeight = 0;
let offsetFromLastScrolledScrollHeight = 0; // For recovery.
const minScrollOffset = 3000;
const scrollStrideRatio = 0.5; // Of the scroll height's offset.
const nextTweetWaitTime = 80;
let unlikeWaitTime = 1500;
const maxUnlikeWaitTime = 60000;
const loadingWaitTime = 1750;
let unlikedTweets = [];
let unlikeFailedTweets = [];
// If no unlike button is found, scroll to load more
const maxLoadRetries = 3;
async function loadAndGetNextUnlike() {
let next = nextUnlike();
if(next) return next;
for(let i=0; i < maxLoadRetries; i++) {
offsetFromLastScrolledScrollHeight = Math.max(minScrollOffset, scrollStrideRatio*(document.body.scrollHeight-scrolledScrollHeight));
scrolledScrollHeight = scrolledScrollHeight+offsetFromLastScrolledScrollHeight;
window.scrollTo(0, scrolledScrollHeight);
await wait(loadingWaitTime); // Shorter wait for loading more tweets
next = nextUnlike();
if(next) return next;
}
return null;
}
// Sometime the unlike buttons are not properly liked. But they still in your like list. So you want to like and unlike to remove it completely.
async function unlikeGracefully(next) {
let unlikeButton = next[0];
const isLiked = unlikeButton.getAttribute('data-testid') == 'unlike';
next[0].focus();
next[0].click();
await wait(unlikeWaitTime);
if(isLiked&&unlikeButton.getAttribute('data-testid') == 'like') return;
else if(isLiked&&unlikeButton.getAttribute('data-testid') == 'unlike') throw new Error("Unable to unlike. [Type 1]");
else if(!isLiked&&unlikeButton.getAttribute('data-testid') == 'like') throw new Error("Unable to unlike. [Type 2]");
next[0].focus();
next[0].click();
await wait(1.5*unlikeWaitTime);
if(unlikeButton.getAttribute('data-testid') == 'unlike') throw new Error("Unable to unlike. [Type 3]");
}
// Main logic here.
async function unlikeBaseOnCriteria() {
let next = await loadAndGetNextUnlike();
while (!isStop&&next && tweetCount < tweetCountLimit) {
try {
next[3].style.background = 'maroon';
if(matchCriteria(next)) {
let unlikedTweet = next[3]?next[3].querySelector('a[href*="status"]').href:null;
try {
await unlikeGracefully(next);
unlikedCount++;
if(unlikedTweet!=null)
unlikedTweets.push(unlikedTweet);
next[3].style.background = 'green';
console.log(`Unliked tweet with link: ${unlikedTweet}`);
}
catch (error) {
unlikedFailedCount++;
if(unlikedTweet!=null)
unlikeFailedTweets.push(unlikedTweet);
console.error(`Unliked tweet with link failed: ${unlikedTweet}\n`, error);
throw error
}
}
console.log(`Unliked ${unlikedCount} of ${++tweetCount} tweets. Failed to unlike: ${unlikedFailedCount} attempts.`);
await wait(nextTweetWaitTime);
next = nextUnlike();
if (!next && tweetCount < tweetCountLimit) {
next = await loadAndGetNextUnlike();
}
} catch (error) {
console.error('An error occurred:', error);
unlikeWaitTime = Math.min(unlikeWaitTime * 2, 60000); // Exponentially increase wait time if an error occurs
console.log(`Rate limit hit? Increasing wait time to ${unlikeWaitTime / 1000} seconds.`);
await wait(unlikeWaitTime); // Wait before retrying
}
}
if (next) {
console.log('Finished early. Stopped by user or rate-limiting..\ncount =', tweetCount, ', unliked =', unlikedCount, ', scrollHeight =', scrolledScrollHeight);
} else {
console.log('Finished.\ncount =', tweetCount, ', unliked =', unlikedCount, ', scrollHeight =', scrolledScrollHeight);
}
}
// Utility functions where you can use it in the console.
const limitIncrement = tweetCountLimit;
async function stopUnliking() {
isStop = true;
};
async function continueUnliking() {
isStop = false;
tweetCountLimit += limitIncrement;
scrolledScrollHeight -= offsetFromLastScrolledScrollHeight;
unlikeBaseOnCriteria();
};
const defaultUnlikeWaitTime = unlikeWaitTime;
function resetUnlikeWaitTime() {
unlikeWaitTime = defaultUnlikeWaitTime;
}
function printUnlikedTweets() {
let outputString = "Unliked tweets:\n";
unlikedTweets.forEach(tweetLink => {
outputString += `${tweetLink}\n`;
});
console.log(outputString);
}
function printUnlikeFailedTweets() {
let outputString = "Unlike failed tweets:\n";
unlikeFailedTweets.forEach(tweetLink => {
outputString += `${tweetLink}\n`;
});
console.log(outputString);
}
async function scrollUntilScrollHeight(height) {
while(document.body.scrollHeight < height) {
window.scrollTo(0, height);
console.log(`scrollHeight: ${document.body.scrollHeight}/${height}`);
await wait(250);
}
scrolledScrollHeight = document.body.scrollHeight;
}
// Start to unlike based on criteria.
if(confirm('Start unliking? If not, we will ask for the previous "scrollHeight" to resume from older position.'))
await unlikeBaseOnCriteria();
const targetScrollHeight = parseFloat(prompt('"scrollHeight" for "scrollUntilScrollHeight()". (Obtained from the last session)', '0'));
await scrollUntilScrollHeight(targetScrollHeight?targetScrollHeight:0);
await unlikeBaseOnCriteria();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment