Intelligently unfollow users who haven't followed you back after a configurable grace period.
Author: nich (@nichxbt)
Smart Unfollow is an intelligent unfollowing system that:
- Tracks when you follow someone - Maintains a log of all follow actions with timestamps
- Respects a grace period - Gives users X days to follow you back before considering them for unfollow
- Identifies non-reciprocal follows - Finds users who haven't followed back after the grace period expires
- Safely unfollows - Removes these users with rate limiting and detailed logging
- Supports dry-run mode - Preview what would happen without making actual changes
This approach is smarter than a simple "unfollow non-followers" script because it accounts for the fact that people need time to see your follow and decide to follow back.
The simplest way to use Smart Unfollow is through the xactions configuration:
const { xactions } = require('xactions');
const client = new xactions({
username: process.env.X_USERNAME,
password: process.env.X_PASSWORD,
});
await client.smartUnfollow({
gracePeriodDays: 3, // Wait 3 days before unfollowing
maxUnfollows: 50, // Limit per session
dryRun: false, // Set to true to preview
excludeVerified: true, // Don't unfollow verified accounts
excludeList: ['nichxbt'], // Never unfollow these users
});| Option | Type | Default | Description |
|---|---|---|---|
gracePeriodDays |
number | 3 | Days to wait before unfollowing |
maxUnfollows |
number | 100 | Maximum unfollows per session |
dryRun |
boolean | false | Preview mode without actual unfollows |
excludeVerified |
boolean | true | Skip verified accounts |
excludeList |
string[] | [] | Usernames to never unfollow |
logFile |
string | ./follow_log.json |
Path to the follow tracking log |
Here's a complete, production-ready implementation:
smart-unfollow/
├── smart-unfollow.js # Main script
├── follow_log.json # Auto-generated follow tracking
├── unfollow_log.json # Auto-generated unfollow history
├── .env # Your credentials
└── package.json
{
"name": "smart-unfollow",
"version": "1.0.0",
"description": "Intelligently unfollow non-followers after grace period",
"main": "smart-unfollow.js",
"scripts": {
"start": "node smart-unfollow.js",
"dry-run": "node smart-unfollow.js --dry-run",
"log-follow": "node smart-unfollow.js --log-follow"
},
"dependencies": {
"puppeteer": "^21.0.0",
"dotenv": "^16.0.0"
}
}X_USERNAME=your_username
X_PASSWORD=your_password
X_EMAIL=your_email@example.com
GRACE_PERIOD_DAYS=3
MAX_UNFOLLOWS_PER_SESSION=50/**
* Smart Unfollow - Intelligent Non-Follower Removal
*
* Unfollows users who haven't followed back after a configurable grace period.
* Maintains persistent logs to track follow dates and unfollow history.
*
* @author nich (@nichxbt)
* @license MIT
*/
require('dotenv').config();
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
// ============================================================================
// CONFIGURATION
// ============================================================================
const CONFIG = {
// Credentials
username: process.env.X_USERNAME,
password: process.env.X_PASSWORD,
email: process.env.X_EMAIL,
// Grace period settings
gracePeriodDays: parseInt(process.env.GRACE_PERIOD_DAYS) || 3,
maxUnfollowsPerSession: parseInt(process.env.MAX_UNFOLLOWS_PER_SESSION) || 50,
// File paths
followLogPath: path.join(__dirname, 'follow_log.json'),
unfollowLogPath: path.join(__dirname, 'unfollow_log.json'),
// Rate limiting (milliseconds)
delayBetweenActions: 3000,
delayBetweenScrolls: 2000,
pageLoadDelay: 5000,
// Browser settings
headless: false,
slowMo: 50,
// Safety settings
excludeVerified: true,
excludeUsernames: ['nichxbt', 'elonmusk'], // Never unfollow these
// Selectors (may need updates if X changes their UI)
selectors: {
usernameInput: 'input[autocomplete="username"]',
passwordInput: 'input[name="password"]',
nextButton: '[role="button"]:has-text("Next")',
loginButton: '[data-testid="LoginForm_Login_Button"]',
followingLink: 'a[href$="/following"]',
userCell: '[data-testid="UserCell"]',
unfollowButton: '[data-testid="placementTracking"] [role="button"]',
confirmUnfollow: '[data-testid="confirmationSheetConfirm"]',
verifiedBadge: 'svg[aria-label="Verified account"]',
},
};
// ============================================================================
// LOGGING UTILITIES
// ============================================================================
class Logger {
static colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
};
static timestamp() {
return new Date().toISOString();
}
static log(message, color = 'reset') {
console.log(`${this.colors[color]}[${this.timestamp()}] ${message}${this.colors.reset}`);
}
static info(message) { this.log(`ℹ️ ${message}`, 'blue'); }
static success(message) { this.log(`✅ ${message}`, 'green'); }
static warning(message) { this.log(`⚠️ ${message}`, 'yellow'); }
static error(message) { this.log(`❌ ${message}`, 'red'); }
static action(message) { this.log(`🔄 ${message}`, 'cyan'); }
static dry(message) { this.log(`🧪 [DRY-RUN] ${message}`, 'magenta'); }
}
// ============================================================================
// FOLLOW LOG MANAGEMENT
// ============================================================================
class FollowLog {
constructor(filePath) {
this.filePath = filePath;
this.data = this.load();
}
load() {
try {
if (fs.existsSync(this.filePath)) {
const content = fs.readFileSync(this.filePath, 'utf8');
return JSON.parse(content);
}
} catch (error) {
Logger.warning(`Could not load follow log: ${error.message}`);
}
return { follows: {}, metadata: { created: new Date().toISOString() } };
}
save() {
try {
this.data.metadata.lastUpdated = new Date().toISOString();
fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
Logger.success(`Follow log saved with ${Object.keys(this.data.follows).length} entries`);
} catch (error) {
Logger.error(`Could not save follow log: ${error.message}`);
}
}
addFollow(username) {
const normalizedUsername = username.toLowerCase().replace('@', '');
if (!this.data.follows[normalizedUsername]) {
this.data.follows[normalizedUsername] = {
username: normalizedUsername,
followedAt: new Date().toISOString(),
status: 'following',
};
Logger.success(`Logged follow: @${normalizedUsername}`);
return true;
}
Logger.warning(`@${normalizedUsername} already in log`);
return false;
}
markUnfollowed(username, reason = 'manual') {
const normalizedUsername = username.toLowerCase().replace('@', '');
if (this.data.follows[normalizedUsername]) {
this.data.follows[normalizedUsername].status = 'unfollowed';
this.data.follows[normalizedUsername].unfollowedAt = new Date().toISOString();
this.data.follows[normalizedUsername].unfollowReason = reason;
}
}
getExpiredNonFollowers(gracePeriodDays) {
const now = new Date();
const expired = [];
for (const [username, entry] of Object.entries(this.data.follows)) {
if (entry.status !== 'following') continue;
const followedAt = new Date(entry.followedAt);
const daysSinceFollow = (now - followedAt) / (1000 * 60 * 60 * 24);
if (daysSinceFollow >= gracePeriodDays) {
expired.push({
username,
followedAt: entry.followedAt,
daysSinceFollow: Math.floor(daysSinceFollow),
});
}
}
return expired.sort((a, b) => b.daysSinceFollow - a.daysSinceFollow);
}
getStats() {
const follows = Object.values(this.data.follows);
return {
total: follows.length,
following: follows.filter(f => f.status === 'following').length,
unfollowed: follows.filter(f => f.status === 'unfollowed').length,
};
}
}
// ============================================================================
// UNFOLLOW LOG MANAGEMENT
// ============================================================================
class UnfollowLog {
constructor(filePath) {
this.filePath = filePath;
this.data = this.load();
}
load() {
try {
if (fs.existsSync(this.filePath)) {
return JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
}
} catch (error) {
Logger.warning(`Could not load unfollow log: ${error.message}`);
}
return { sessions: [], totalUnfollowed: 0 };
}
save() {
try {
fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
} catch (error) {
Logger.error(`Could not save unfollow log: ${error.message}`);
}
}
logSession(unfollowed, dryRun = false) {
const session = {
timestamp: new Date().toISOString(),
dryRun,
count: unfollowed.length,
users: unfollowed,
};
this.data.sessions.push(session);
if (!dryRun) {
this.data.totalUnfollowed += unfollowed.length;
}
this.save();
}
}
// ============================================================================
// SMART UNFOLLOW ENGINE
// ============================================================================
class SmartUnfollower {
constructor(config) {
this.config = config;
this.browser = null;
this.page = null;
this.followLog = new FollowLog(config.followLogPath);
this.unfollowLog = new UnfollowLog(config.unfollowLogPath);
this.sessionUnfollowed = [];
}
async initialize() {
Logger.info('Launching browser...');
this.browser = await puppeteer.launch({
headless: this.config.headless,
slowMo: this.config.slowMo,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--window-size=1280,800',
],
});
this.page = await this.browser.newPage();
await this.page.setViewport({ width: 1280, height: 800 });
await this.page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
);
Logger.success('Browser initialized');
}
async login() {
Logger.action('Navigating to X login...');
await this.page.goto('https://x.com/i/flow/login', {
waitUntil: 'networkidle2',
timeout: 60000
});
await this.delay(this.config.pageLoadDelay);
// Enter username
Logger.action('Entering username...');
await this.page.waitForSelector(this.config.selectors.usernameInput, { timeout: 30000 });
await this.page.type(this.config.selectors.usernameInput, this.config.username, { delay: 100 });
// Click Next
await this.page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll('[role="button"]'));
const nextButton = buttons.find(b => b.textContent.includes('Next'));
if (nextButton) nextButton.click();
});
await this.delay(3000);
// Check for email verification challenge
const emailInput = await this.page.$('input[data-testid="ocfEnterTextTextInput"]');
if (emailInput) {
Logger.warning('Email verification required...');
await emailInput.type(this.config.email, { delay: 100 });
await this.page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll('[role="button"]'));
const nextButton = buttons.find(b => b.textContent.includes('Next'));
if (nextButton) nextButton.click();
});
await this.delay(3000);
}
// Enter password
Logger.action('Entering password...');
await this.page.waitForSelector(this.config.selectors.passwordInput, { timeout: 30000 });
await this.page.type(this.config.selectors.passwordInput, this.config.password, { delay: 100 });
// Click Login
await this.page.waitForSelector(this.config.selectors.loginButton, { timeout: 10000 });
await this.page.click(this.config.selectors.loginButton);
await this.delay(this.config.pageLoadDelay);
Logger.success('Login successful');
}
async getFollowers() {
Logger.action('Fetching your followers list...');
await this.page.goto(`https://x.com/${this.config.username}/followers`, {
waitUntil: 'networkidle2',
timeout: 60000,
});
await this.delay(this.config.pageLoadDelay);
const followers = new Set();
let previousSize = 0;
let scrollAttempts = 0;
const maxScrollAttempts = 50;
while (scrollAttempts < maxScrollAttempts) {
// Extract usernames from current view
const usernames = await this.page.evaluate(() => {
const cells = document.querySelectorAll('[data-testid="UserCell"]');
return Array.from(cells).map(cell => {
const link = cell.querySelector('a[href^="/"]');
if (link) {
const href = link.getAttribute('href');
return href.replace('/', '').toLowerCase();
}
return null;
}).filter(Boolean);
});
usernames.forEach(u => followers.add(u));
// Check if we got new followers
if (followers.size === previousSize) {
scrollAttempts++;
} else {
scrollAttempts = 0;
previousSize = followers.size;
}
// Scroll down
await this.page.evaluate(() => window.scrollBy(0, 800));
await this.delay(this.config.delayBetweenScrolls);
Logger.info(`Found ${followers.size} followers so far...`);
// Stop if no new followers after several attempts
if (scrollAttempts >= 5) break;
}
Logger.success(`Total followers found: ${followers.size}`);
return followers;
}
async getFollowing() {
Logger.action('Fetching your following list...');
await this.page.goto(`https://x.com/${this.config.username}/following`, {
waitUntil: 'networkidle2',
timeout: 60000,
});
await this.delay(this.config.pageLoadDelay);
const following = new Map(); // username -> { verified, displayName }
let previousSize = 0;
let scrollAttempts = 0;
const maxScrollAttempts = 50;
while (scrollAttempts < maxScrollAttempts) {
// Extract user info from current view
const users = await this.page.evaluate(() => {
const cells = document.querySelectorAll('[data-testid="UserCell"]');
return Array.from(cells).map(cell => {
const link = cell.querySelector('a[href^="/"]');
const verified = cell.querySelector('svg[aria-label*="Verified"]') !== null;
const displayNameEl = cell.querySelector('[dir="ltr"] > span');
if (link) {
const href = link.getAttribute('href');
return {
username: href.replace('/', '').toLowerCase(),
verified,
displayName: displayNameEl ? displayNameEl.textContent : '',
};
}
return null;
}).filter(Boolean);
});
users.forEach(u => following.set(u.username, u));
if (following.size === previousSize) {
scrollAttempts++;
} else {
scrollAttempts = 0;
previousSize = following.size;
}
await this.page.evaluate(() => window.scrollBy(0, 800));
await this.delay(this.config.delayBetweenScrolls);
Logger.info(`Found ${following.size} following so far...`);
if (scrollAttempts >= 5) break;
}
Logger.success(`Total following found: ${following.size}`);
return following;
}
async unfollowUser(username) {
try {
await this.page.goto(`https://x.com/${username}`, {
waitUntil: 'networkidle2',
timeout: 30000,
});
await this.delay(2000);
// Find and click the Following button (which will show unfollow option)
const unfollowed = await this.page.evaluate(() => {
// Look for the "Following" button
const buttons = Array.from(document.querySelectorAll('[role="button"]'));
const followingBtn = buttons.find(b => {
const text = b.textContent || '';
return text.includes('Following') && !text.includes('Followers');
});
if (followingBtn) {
followingBtn.click();
return true;
}
return false;
});
if (!unfollowed) {
Logger.warning(`Could not find Following button for @${username}`);
return false;
}
await this.delay(1000);
// Confirm unfollow in the dialog
await this.page.evaluate(() => {
const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
if (confirmBtn) confirmBtn.click();
});
await this.delay(1000);
return true;
} catch (error) {
Logger.error(`Failed to unfollow @${username}: ${error.message}`);
return false;
}
}
async runSmartUnfollow(dryRun = false) {
Logger.info('='.repeat(60));
Logger.info('🧠 SMART UNFOLLOW - Starting Session');
Logger.info(` Grace Period: ${this.config.gracePeriodDays} days`);
Logger.info(` Max Unfollows: ${this.config.maxUnfollowsPerSession}`);
Logger.info(` Dry Run: ${dryRun ? 'YES' : 'NO'}`);
Logger.info('='.repeat(60));
await this.initialize();
try {
await this.login();
// Get current followers and following
const followers = await this.getFollowers();
const following = await this.getFollowing();
// Find non-followers
const nonFollowers = [];
for (const [username, userData] of following) {
if (!followers.has(username)) {
nonFollowers.push({ username, ...userData });
}
}
Logger.info(`Found ${nonFollowers.length} non-followers out of ${following.size} following`);
// Get expired entries from our log
const expiredFromLog = this.followLog.getExpiredNonFollowers(this.config.gracePeriodDays);
Logger.info(`Found ${expiredFromLog.length} users past grace period in follow log`);
// Cross-reference: only unfollow if they're in our log AND still non-followers
const toUnfollow = [];
for (const entry of expiredFromLog) {
const nonFollower = nonFollowers.find(nf => nf.username === entry.username);
if (nonFollower) {
// Check exclusions
if (this.config.excludeUsernames.includes(entry.username)) {
Logger.warning(`Skipping @${entry.username} (in exclude list)`);
continue;
}
if (this.config.excludeVerified && nonFollower.verified) {
Logger.warning(`Skipping @${entry.username} (verified account)`);
continue;
}
toUnfollow.push({
...entry,
verified: nonFollower.verified,
});
}
}
Logger.info('='.repeat(60));
Logger.info(`📋 UNFOLLOW CANDIDATES: ${toUnfollow.length}`);
Logger.info('='.repeat(60));
if (toUnfollow.length === 0) {
Logger.success('No users to unfollow! Everyone is either:');
Logger.info(' - Following you back');
Logger.info(' - Still within grace period');
Logger.info(' - In your exclude list');
return;
}
// Limit to max per session
const batch = toUnfollow.slice(0, this.config.maxUnfollowsPerSession);
Logger.info(`Processing ${batch.length} users (limited to ${this.config.maxUnfollowsPerSession} per session)`);
Logger.info('');
for (const user of batch) {
Logger.info(`@${user.username} - followed ${user.daysSinceFollow} days ago`);
}
Logger.info('');
if (dryRun) {
Logger.dry('='.repeat(60));
Logger.dry('DRY RUN COMPLETE - No actual unfollows performed');
Logger.dry(`Would have unfollowed ${batch.length} users`);
Logger.dry('='.repeat(60));
this.unfollowLog.logSession(batch.map(u => u.username), true);
return;
}
// Perform actual unfollows
Logger.action('Starting unfollow process...');
let successCount = 0;
for (let i = 0; i < batch.length; i++) {
const user = batch[i];
Logger.action(`[${i + 1}/${batch.length}] Unfollowing @${user.username}...`);
const success = await this.unfollowUser(user.username);
if (success) {
successCount++;
this.sessionUnfollowed.push(user.username);
this.followLog.markUnfollowed(user.username, 'smart-unfollow');
Logger.success(`Unfollowed @${user.username}`);
} else {
Logger.error(`Failed to unfollow @${user.username}`);
}
// Rate limiting
if (i < batch.length - 1) {
Logger.info(`Waiting ${this.config.delayBetweenActions / 1000}s before next action...`);
await this.delay(this.config.delayBetweenActions);
}
}
// Save logs
this.followLog.save();
this.unfollowLog.logSession(this.sessionUnfollowed, false);
// Summary
Logger.info('='.repeat(60));
Logger.success('SESSION COMPLETE');
Logger.info(` Attempted: ${batch.length}`);
Logger.info(` Successful: ${successCount}`);
Logger.info(` Failed: ${batch.length - successCount}`);
Logger.info('='.repeat(60));
} catch (error) {
Logger.error(`Session error: ${error.message}`);
throw error;
} finally {
await this.cleanup();
}
}
async logNewFollow(username) {
const added = this.followLog.addFollow(username);
if (added) {
this.followLog.save();
}
const stats = this.followLog.getStats();
Logger.info(`Follow log stats: ${stats.following} following, ${stats.unfollowed} unfollowed`);
}
async showStats() {
const stats = this.followLog.getStats();
const expired = this.followLog.getExpiredNonFollowers(this.config.gracePeriodDays);
Logger.info('='.repeat(60));
Logger.info('📊 SMART UNFOLLOW STATISTICS');
Logger.info('='.repeat(60));
Logger.info(`Total logged follows: ${stats.total}`);
Logger.info(`Currently following: ${stats.following}`);
Logger.info(`Unfollowed: ${stats.unfollowed}`);
Logger.info(`Past grace period: ${expired.length}`);
Logger.info(`Grace period: ${this.config.gracePeriodDays} days`);
Logger.info('='.repeat(60));
if (expired.length > 0) {
Logger.info('Users past grace period:');
expired.slice(0, 10).forEach(u => {
Logger.info(` @${u.username} - ${u.daysSinceFollow} days ago`);
});
if (expired.length > 10) {
Logger.info(` ... and ${expired.length - 10} more`);
}
}
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async cleanup() {
if (this.browser) {
await this.browser.close();
Logger.info('Browser closed');
}
}
}
// ============================================================================
// CLI INTERFACE
// ============================================================================
async function main() {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const logFollow = args.includes('--log-follow');
const showStats = args.includes('--stats');
const username = args.find(a => !a.startsWith('--'));
const unfollower = new SmartUnfollower(CONFIG);
try {
if (showStats) {
await unfollower.showStats();
} else if (logFollow && username) {
await unfollower.logNewFollow(username);
} else if (logFollow) {
Logger.error('Usage: node smart-unfollow.js --log-follow <username>');
process.exit(1);
} else {
await unfollower.runSmartUnfollow(dryRun);
}
} catch (error) {
Logger.error(`Fatal error: ${error.message}`);
console.error(error);
process.exit(1);
}
}
main();# Install dependencies
npm install
# Run dry-run first to see what would be unfollowed
npm run dry-run
# Run actual unfollow
npm start
# Log a new follow (when you follow someone)
node smart-unfollow.js --log-follow username123
# View statistics
node smart-unfollow.js --stats[2026-01-01T12:00:00.000Z] ℹ️ ============================================================
[2026-01-01T12:00:00.000Z] ℹ️ 🧠 SMART UNFOLLOW - Starting Session
[2026-01-01T12:00:00.000Z] ℹ️ Grace Period: 3 days
[2026-01-01T12:00:00.000Z] ℹ️ Max Unfollows: 50
[2026-01-01T12:00:00.000Z] ℹ️ Dry Run: NO
[2026-01-01T12:00:00.000Z] ℹ️ ============================================================
[2026-01-01T12:00:01.000Z] ✅ Browser initialized
[2026-01-01T12:00:05.000Z] ✅ Login successful
[2026-01-01T12:00:30.000Z] ✅ Total followers found: 1,523
[2026-01-01T12:00:55.000Z] ✅ Total following found: 892
[2026-01-01T12:00:55.000Z] ℹ️ Found 156 non-followers out of 892 following
[2026-01-01T12:00:55.000Z] ℹ️ Found 23 users past grace period in follow log
[2026-01-01T12:00:56.000Z] 🔄 [1/23] Unfollowing @inactive_user...
[2026-01-01T12:00:58.000Z] ✅ Unfollowed @inactive_user
...
When you follow someone, they might not see your follow immediately:
- They could be offline or busy
- They might have notifications turned off
- They could be in a different timezone
- They might check Twitter/X only weekly
- Your follow could get lost in a sea of notifications
A grace period (typically 3-7 days) gives people fair time to:
- See your follow notification - Not everyone checks social media daily
- Check out your profile - They need time to review your content
- Make a decision - Following back isn't always instant
- Actually follow back - Technical issues happen
| Audience Type | Recommended Grace Period |
|---|---|
| Active users | 3 days |
| General audience | 5-7 days |
| Business/Professional | 7-14 days |
| Celebrities/Influencers | Never (don't expect follow-back) |
- Start with 7 days - This is a fair window for most users
- Track your metrics - See what grace period works for your audience
- Adjust based on results - If many people follow back on day 5-7, extend it
- Consider time zones - International audiences may need more time
- Quality over quantity - Don't rush the unfollow process
Don't want to run code? Use the web dashboard:
The xactions.app dashboard provides:
- Visual Smart Unfollow - Same functionality with a beautiful UI
- Follow Tracking - Automatic logging of when you follow users
- Grace Period Settings - Customize your waiting period
- Exclude Lists - Protect important accounts
- Scheduling - Set up automated smart unfollow sessions
- Analytics - Track your follow/unfollow ratio over time
- No Code Required - Just connect and configure
- Visit xactions.app
- Connect your X account
- Navigate to Tools → Smart Unfollow
- Configure your grace period and exclusions
- Review candidates and start unfollowing
- Always use dry-run first - Preview before taking action
- Set reasonable limits - Don't unfollow too many at once
- Maintain your follow log - The system is only as good as your data
- Exclude important accounts - Protect business relationships
- Respect rate limits - Don't get your account restricted
- Regular backups - Keep copies of your follow_log.json
- Unfollow Non-Followers - Simple non-follower removal
- Unfollow Everyone - Mass unfollow tool
- Followers Scraping - Get your followers list
- Following Scraping - Get your following list
Built with ❤️ by @nichxbt