├── .env.local ├── images ├── banner_V1.png └── banner_V2.png ├── LICENSE ├── README.md └── main.py /.env.local: -------------------------------------------------------------------------------- 1 | GITHUB_USERNAME=XXXXXXXXXXXXXXXXXXX 2 | GITHUB_TOKEN=XXXXXXXXXXXXXXXXXXX 3 | -------------------------------------------------------------------------------- /images/banner_V1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marichu-kt/GitHub-Unfollowed/HEAD/images/banner_V1.png -------------------------------------------------------------------------------- /images/banner_V2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marichu-kt/GitHub-Unfollowed/HEAD/images/banner_V2.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mario 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 🐙 GitHub Follower Tracker 3 | 4 | ![Banner](images/banner_V2.png) 5 | 6 | **GitHub Follower Tracker** is a fast and interactive Python tool that checks which GitHub users you follow that don’t follow you back. It features animated terminal spinners, optional export to TXT/CSV, parallel requests for speed, and colorful CLI output. Ideal for managing your GitHub social connections in style. 7 | 8 | --- 9 | 10 | ## 🚀 Features 11 | 12 | - Identify users who don’t follow you back 13 | - Export results to `.txt` and/or `.csv` 14 | - Parallel data fetching for performance 15 | - ASCII spinner loading animations 16 | - Environment-based secure configuration 17 | 18 | --- 19 | 20 | ## ⚙️ Configuration 21 | 22 | To run the script, you **must create and configure a [`.env.local`](https://github.com/marichu-kt/GitHub-Unfollowed/blob/main/.env.local) file** in the root of the project with the content: 23 | 24 | ```env 25 | GITHUB_USERNAME=XXXXXXXXXXXXXXXXXXX 26 | GITHUB_TOKEN=XXXXXXXXXXXXXXXXXXX 27 | ``` 28 | 29 | Replace `XXXXXXXXXXXXXXXXXXX` with: 30 | 31 | - `GITHUB_USERNAME`: your GitHub username 32 | - `GITHUB_TOKEN`: your personal access token from [GitHub Settings → Developer settings → Personal access tokens](https://github.com/settings/tokens) 33 | 34 | --- 35 | 36 | ## 📁 Output Files 37 | 38 | - `not_following_back.txt`: Plain list with GitHub usernames and profile URLs 39 | - `not_following_back.csv`: Detailed list including name, bio, followers, etc. 40 | 41 | --- 42 | 43 | ## 📜 License 44 | 45 | This project is licensed under the [MIT License](LICENSE) — free to use, modify, and distribute. 46 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import csv 4 | import time 5 | import sys 6 | from colorama import init, Fore, Style 7 | from dotenv import load_dotenv 8 | from concurrent.futures import ThreadPoolExecutor, as_completed 9 | 10 | # Initialize colorama 11 | init(autoreset=True) 12 | 13 | # Load environment variables 14 | load_dotenv(dotenv_path=".env.local") 15 | USERNAME = os.getenv("GITHUB_USERNAME") 16 | TOKEN = os.getenv("GITHUB_TOKEN") 17 | 18 | # Validate credentials 19 | if not USERNAME or not TOKEN: 20 | print(Fore.RED + "❌ Error: GITHUB_USERNAME or GITHUB_TOKEN not set correctly in .env.local") 21 | exit(1) 22 | 23 | headers = { 24 | "Accept": "application/vnd.github+json", 25 | "Authorization": f"token {TOKEN}" 26 | } 27 | 28 | def print_banner(): 29 | banner = """ 30 | ██████╗ ██╗████████╗██╗ ██╗██╗ ██╗██████╗ 31 | ██╔════╝ ██║╚══██╔══╝██║ ██║██║ ██║██╔══██╗ 32 | ██║ ███╗██║ ██║ ███████║██║ ██║██████╔╝ 33 | ██║ ██║██║ ██║ ██╔══██║██║ ██║██╔══██╗ 34 | ╚██████╔╝██║ ██║ ██║ ██║╚██████╔╝██████╔╝ 35 | ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ 36 | 37 | ██╗ ██╗███╗ ██╗███████╗ ██████╗ ██╗ ██╗ ██████╗ ██╗ ██╗███████╗██████╗ 38 | ██║ ██║████╗ ██║██╔════╝██╔═══██╗██║ ██║ ██╔═══██╗██║ ██║██╔════╝██╔══██╗ 39 | ██║ ██║██╔██╗ ██║█████╗ ██║ ██║██║ ██║ ██║ ██║██║ █╗ ██║█████╗ ██║ ██║ 40 | ██║ ██║██║╚██╗██║██╔══╝ ██║ ██║██║ ██║ ██║ ██║██║███╗██║██╔══╝ ██║ ██║ 41 | ╚██████╔╝██║ ╚████║██║ ╚██████╔╝███████╗███████╗╚██████╔╝╚███╔███╔╝███████╗██████╔╝ 42 | ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚══════╝╚══════╝ ╚═════╝ ╚══╝╚══╝ ╚══════╝╚═════╝ 43 | """ 44 | print(Fore.CYAN + banner) 45 | print(Fore.MAGENTA + " → Created by @marichu_kt · GitHub: https://github.com/marichu-kt/GitHub-Unfollowed\n") 46 | 47 | def simulate_loading(message, duration=2): 48 | spinner = ["|", "/", "-", "\\"] 49 | t_end = time.time() + duration 50 | idx = 0 51 | while time.time() < t_end: 52 | sys.stdout.write(f"\r{message} {spinner[idx % len(spinner)]}") 53 | sys.stdout.flush() 54 | time.sleep(0.2) 55 | idx += 1 56 | sys.stdout.write(f"\r{message} ✓\n") 57 | sys.stdout.flush() 58 | 59 | def get_paginated_list(url_base): 60 | users = [] 61 | page = 1 62 | while True: 63 | url = f"{url_base}?per_page=100&page={page}" 64 | res = requests.get(url, headers=headers) 65 | if res.status_code != 200: 66 | print(Fore.RED + f"❌ Error ({res.status_code}) while accessing: {url}") 67 | break 68 | page_data = res.json() 69 | if not page_data: 70 | break 71 | users.extend([user["login"] for user in page_data]) 72 | page += 1 73 | return users 74 | 75 | def get_following(): 76 | simulate_loading("📥 Fetching users you FOLLOW...") 77 | return get_paginated_list(f"https://api.github.com/users/{USERNAME}/following") 78 | 79 | def get_followers(): 80 | simulate_loading("📥 Fetching users who FOLLOW you...") 81 | return get_paginated_list(f"https://api.github.com/users/{USERNAME}/followers") 82 | 83 | def get_user_details(username): 84 | url = f"https://api.github.com/users/{username}" 85 | res = requests.get(url, headers=headers) 86 | if res.status_code == 200: 87 | data = res.json() 88 | return { 89 | "login": data.get("login"), 90 | "name": data.get("name"), 91 | "followers": data.get("followers"), 92 | "following": data.get("following"), 93 | "html_url": data.get("html_url") 94 | } 95 | else: 96 | return { 97 | "login": username, 98 | "name": "", 99 | "followers": "", 100 | "following": "", 101 | "html_url": "" 102 | } 103 | 104 | def fetch_all_user_details(usernames, max_workers=10): 105 | detailed_users = [] 106 | with ThreadPoolExecutor(max_workers=max_workers) as executor: 107 | futures = {executor.submit(get_user_details, user): user for user in usernames} 108 | for future in as_completed(futures): 109 | try: 110 | user_info = future.result() 111 | detailed_users.append(user_info) 112 | except Exception as e: 113 | print(Fore.RED + f"⚠️ Error retrieving user data: {e}") 114 | return detailed_users 115 | 116 | def export_to_txt(users, filename): 117 | with open(filename, "w", encoding="utf-8") as f: 118 | for user in users: 119 | f.write(f"{user['login']} ({user['name'] or 'No name'}) - {user['html_url']}\n") 120 | print(Fore.YELLOW + f"📄 TXT file saved as {filename}") 121 | 122 | def export_to_csv(users, filename): 123 | with open(filename, "w", newline="", encoding="utf-8") as f: 124 | writer = csv.DictWriter(f, fieldnames=["login", "name", "followers", "following", "html_url"]) 125 | writer.writeheader() 126 | for user in users: 127 | writer.writerow(user) 128 | print(Fore.YELLOW + f"📊 CSV file saved as {filename}") 129 | 130 | def unfollow_users(usernames): 131 | print(Fore.YELLOW + "\n🚫 Unfollowing users...") 132 | for username in usernames: 133 | url = f"https://api.github.com/user/following/{username}" 134 | res = requests.delete(url, headers=headers) 135 | if res.status_code == 204: 136 | print(Fore.GREEN + f"✔️ Unfollowed {username}") 137 | else: 138 | print(Fore.RED + f"❌ Failed to unfollow {username}: {res.status_code}") 139 | 140 | def ask_action(): 141 | print(Fore.GREEN + "\nWhat do you want to do with the users who don't follow you back?") 142 | print("1. Export as TXT") 143 | print("2. Export as CSV") 144 | print("3. Export both TXT and CSV") 145 | print("4. Unfollow them") 146 | print("0. Do nothing") 147 | while True: 148 | choice = input("Select an option (0-4): ").strip() 149 | if choice in ["0", "1", "2", "3", "4"]: 150 | return choice 151 | else: 152 | print(Fore.RED + "❌ Invalid option. Please try again.") 153 | 154 | def main(): 155 | print_banner() 156 | 157 | # Debug print 158 | print(f"{Fore.CYAN}🔐 USERNAME: {USERNAME}") 159 | print(f"{Fore.CYAN}🔐 TOKEN: {TOKEN[:4]}********************************{TOKEN[-4:] if TOKEN else ''}") 160 | 161 | print(Fore.GREEN + Style.BRIGHT + "\n🔍 Analyzing your GitHub followers...\n") 162 | 163 | following = set(get_following()) 164 | followers = set(get_followers()) 165 | not_following_back = sorted(following - followers) 166 | 167 | if not not_following_back: 168 | print(Fore.GREEN + "🎉 Everyone you follow follows you back!") 169 | return 170 | 171 | print(Fore.YELLOW + f"⏳ Retrieving details for {len(not_following_back)} users who don't follow you back...\n") 172 | print(Fore.CYAN + "📊 Summary:") 173 | print(Fore.CYAN + f" - Users you follow: {len(following)}") 174 | print(Fore.CYAN + f" - Users who follow you: {len(followers)}") 175 | print(Fore.MAGENTA + f" - Not following you back: {len(not_following_back)}") 176 | detailed_users = fetch_all_user_details(not_following_back) 177 | 178 | action = ask_action() 179 | if action == "1": 180 | export_to_txt(detailed_users, "not_following_back.txt") 181 | elif action == "2": 182 | export_to_csv(detailed_users, "not_following_back.csv") 183 | elif action == "3": 184 | export_to_txt(detailed_users, "not_following_back.txt") 185 | export_to_csv(detailed_users, "not_following_back.csv") 186 | elif action == "4": 187 | unfollow_users([user["login"] for user in detailed_users]) 188 | else: 189 | print(Fore.BLUE + "🗂 No action taken.") 190 | 191 | if __name__ == "__main__": 192 | main() 193 | --------------------------------------------------------------------------------