├── .github ├── dependabot.yml └── workflows │ ├── docker-image.yml │ └── stale.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── insta-unfollower.py └── requirements.txt /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: docker-image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | # Publish semver tags as releases. 9 | tags: [ 'v*.*.*' ] 10 | pull_request: 11 | branches: 12 | - main 13 | - dev 14 | 15 | env: 16 | REGISTRY: ghcr.io 17 | IMAGE_NAME: ${{ github.repository }} 18 | 19 | jobs: 20 | build: 21 | 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: read 25 | packages: write 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v3 30 | 31 | - name: Setup Docker buildx 32 | uses: docker/setup-buildx-action@v2 33 | 34 | # Login against a Docker registry except on PR 35 | # https://github.com/docker/login-action 36 | - name: Log into registry ${{ env.REGISTRY }} 37 | if: github.event_name != 'pull_request' 38 | uses: docker/login-action@v2 39 | with: 40 | registry: ${{ env.REGISTRY }} 41 | username: ${{ github.actor }} 42 | password: ${{ github.token }} 43 | 44 | # Extract metadata (tags, labels) for Docker 45 | # https://github.com/docker/metadata-action 46 | - name: Extract Docker metadata 47 | id: meta 48 | uses: docker/metadata-action@v4 49 | with: 50 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 51 | tags: | 52 | type=schedule 53 | type=ref,event=branch 54 | type=ref,event=tag 55 | type=ref,event=pr 56 | 57 | # Build and push Docker image with Buildx (don't push on PR) 58 | # https://github.com/docker/build-push-action 59 | - name: Build and push Docker image 60 | id: build-and-push 61 | uses: docker/build-push-action@v3 62 | with: 63 | context: . 64 | push: ${{ github.event_name != 'pull_request' }} 65 | tags: ${{ steps.meta.outputs.tags }} 66 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Stale issue handler' 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | permissions: 8 | issues: write 9 | pull-requests: write 10 | 11 | jobs: 12 | stale: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Close Stale Issues 16 | uses: actions/stale@v5 17 | with: 18 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days' 19 | stale-pr-message: 'This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days.' 20 | days-before-issue-stale: 30 21 | days-before-pr-stale: 45 22 | days-before-issue-close: 5 23 | days-before-pr-close: 10 24 | exempt-issue-labels: 'blocked,must,should,keep' 25 | - name: Print outputs 26 | run: echo ${{ join(steps.stale.outputs.*, ',') }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # IDE files 4 | .idea 5 | 6 | # Cache files 7 | cache 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine 2 | LABEL maintainer="Kévin Darcel " 3 | 4 | WORKDIR /usr/src/insta-unfollower 5 | 6 | COPY insta-unfollower.py requirements.txt /usr/src/insta-unfollower/ 7 | 8 | RUN pip install --no-cache-dir -r requirements.txt 9 | 10 | ENTRYPOINT ["python", "-u", "insta-unfollower.py"] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Kévin Darcel 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Insta Unfollower 2 | =================== 3 | An Instagram script, allowing you to automatically unfollow accounts you are following but that doesn't follow you back. Without using the Instagram API. 4 | 5 | ## Installation 6 | - With Docker 7 | 8 | Clone repository, cd into directory then run: 9 | ``` 10 | docker build -t tuxity/insta-unfollower . 11 | docker run -d -v $(pwd)/cache:/usr/src/insta-unfollower/cache --env INSTA_USERNAME=myusername --env INSTA_PASSWORD=mypassword tuxity/insta-unfollower 12 | ``` 13 | 14 | - Without Docker 15 | ``` 16 | INSTA_USERNAME=myusername INSTA_PASSWORD=mypassword python3 insta-unfollower.py 17 | ``` 18 | Or 19 | ``` 20 | python3 insta-unfollower.py USERNAME PASSWORD 21 | ``` 22 | 23 | ## Roadmap 24 | - Username whitelist. 25 | - Better flow for calculating time between requests to avoid ban. 26 | - ~~Avoid re-log on instagram everytime when we run the script~~ done 27 | - ~~Keep followers and following lists in cache to speedup execution~~ done 28 | -------------------------------------------------------------------------------- /insta-unfollower.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | import time 7 | import random 8 | import requests, pickle 9 | import json 10 | import re 11 | from datetime import datetime 12 | 13 | cache_dir = 'cache' 14 | session_cache = '%s/session.txt' % (cache_dir) 15 | followers_cache = '%s/followers.json' % (cache_dir) 16 | following_cache = '%s/following.json' % (cache_dir) 17 | 18 | instagram_url = 'https://www.instagram.com' 19 | login_route = '%s/accounts/login/ajax/' % (instagram_url) 20 | profile_route = '%s/api/v1/users/web_profile_info/' % (instagram_url) 21 | followers_route = '%s/api/v1/friendships/%s/followers/' 22 | following_route = '%s/api/v1/friendships/%s/following/' 23 | unfollow_route = '%s/web/friendships/%s/unfollow/' 24 | 25 | session = requests.Session() 26 | 27 | 28 | class Credentials: 29 | def __init__(self): 30 | if os.environ.get('INSTA_USERNAME') and os.environ.get('INSTA_PASSWORD'): 31 | self.username = os.environ.get('INSTA_USERNAME') 32 | self.password = os.environ.get('INSTA_PASSWORD') 33 | elif len(sys.argv) > 1: 34 | self.username = sys.argv[1] 35 | self.password = sys.argv[2] 36 | else: 37 | sys.exit('Please provide INSTA_USERNAME and INSTA_PASSWORD environement variables or as an argument as such: ./insta-unfollower.py USERNAME PASSWORD.\nAborting...') 38 | 39 | credentials = Credentials() 40 | 41 | 42 | def init(): 43 | headers = { 44 | 'User-Agent': ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36') 45 | } 46 | 47 | res1 = session.get(instagram_url, headers=headers) 48 | ig_app_id = re.findall(r'X-IG-App-ID":"(.*?)"', res1.text)[0] 49 | 50 | res2 = session.get('https://www.instagram.com/data/shared_data/', headers=headers, cookies=res1.cookies) 51 | csrf = res2.json()['config']['csrf_token'] 52 | if csrf: 53 | headers['x-csrftoken'] = csrf 54 | # extra needed headers 55 | headers['accept-language'] = "en-GB,en-US;q=0.9,en;q=0.8,fr;q=0.7,es;q=0.6,es-MX;q=0.5,es-ES;q=0.4" 56 | headers['x-requested-with'] = "XMLHttpRequest" 57 | headers['accept'] = "*/*" 58 | headers['referer'] = "https://www.instagram.com/" 59 | headers['x-ig-app-id'] = ig_app_id 60 | ### 61 | cookies = res1.cookies.get_dict() 62 | cookies['csrftoken'] = csrf 63 | else: 64 | print("No csrf token found in code or empty, maybe you are temp ban? Wait 1 hour and retry") 65 | return False 66 | 67 | time.sleep(random.randint(2, 6)) 68 | 69 | return headers, cookies 70 | 71 | 72 | def login(headers, cookies): 73 | post_data = { 74 | 'username': credentials.username, 75 | 'enc_password': '#PWD_INSTAGRAM_BROWSER:0:{}:{}'.format(int(datetime.now().timestamp()), credentials.password) 76 | } 77 | 78 | response = session.post(login_route, headers=headers, data=post_data, cookies=cookies, allow_redirects=True) 79 | response_data = json.loads(response.text) 80 | 81 | if 'two_factor_required' in response_data: 82 | print('Please disable 2-factor authentication to login.') 83 | sys.exit(1) 84 | 85 | if 'message' in response_data and response_data['message'] == 'checkpoint_required': 86 | print('Please check Instagram app for a security confirmation that it is you trying to login.') 87 | sys.exit(1) 88 | 89 | return response_data['authenticated'], response.cookies.get_dict() 90 | 91 | 92 | # Note: this endpoint results are not getting updated directly after unfollowing someone 93 | def get_user_profile(username, headers): 94 | response = session.get(profile_route, params={'username': username}, headers=headers).json() 95 | return response['data']['user'] 96 | 97 | 98 | def get_followers_list(user_id, headers): 99 | followers_list = [] 100 | 101 | response = session.get(followers_route % (instagram_url, user_id), headers=headers).json() 102 | while response['status'] != 'ok': 103 | time.sleep(600) # querying too much, sleeping a bit before querying again 104 | response = session.get(followers_route % (instagram_url, user_id), headers=headers).json() 105 | 106 | print('.', end='', flush=True) 107 | 108 | followers_list.extend(response['users']) 109 | 110 | while 'next_max_id' in response: 111 | time.sleep(2) 112 | 113 | response = session.get(followers_route % (instagram_url, user_id), params={'max_id': response['next_max_id']}, headers=headers).json() 114 | while response['status'] != 'ok': 115 | time.sleep(600) # querying too much, sleeping a bit before querying again 116 | response = session.get(followers_route % (instagram_url, user_id), params={'max_id': response['next_max_id']}, headers=headers).json() 117 | 118 | print('.', end='', flush=True) 119 | 120 | followers_list.extend(response['users']) 121 | 122 | return followers_list 123 | 124 | 125 | def get_following_list(user_id, headers): 126 | follows_list = [] 127 | 128 | response = session.get(following_route % (instagram_url, user_id), headers=headers).json() 129 | while response['status'] != 'ok': 130 | time.sleep(600) # querying too much, sleeping a bit before querying again 131 | response = session.get(following_route % (instagram_url, user_id), headers=headers).json() 132 | 133 | print('.', end='', flush=True) 134 | 135 | follows_list.extend(response['users']) 136 | 137 | while 'next_max_id' in response: 138 | time.sleep(2) 139 | 140 | response = session.get(following_route % (instagram_url, user_id), params={'max_id': response['next_max_id']}, headers=headers).json() 141 | while response['status'] != 'ok': 142 | time.sleep(600) # querying too much, sleeping a bit before querying again 143 | response = session.get(following_route % (instagram_url, user_id), params={'max_id': response['next_max_id']}, headers=headers).json() 144 | 145 | print('.', end='', flush=True) 146 | 147 | follows_list.extend(response['users']) 148 | 149 | return follows_list 150 | 151 | 152 | # TODO: check with the new API 153 | def unfollow(user): 154 | if os.environ.get('DRY_RUN'): 155 | return True 156 | 157 | response = session.get(profile_route % (instagram_url, user['username'])) 158 | time.sleep(random.randint(2, 4)) 159 | 160 | csrf = re.findall(r"csrf_token\":\"(.*?)\"", response.text)[0] 161 | if csrf: 162 | session.headers.update({ 163 | 'x-csrftoken': csrf 164 | }) 165 | 166 | response = session.post(unfollow_route % (instagram_url, user['id'])) 167 | 168 | if response.status_code == 429: # Too many requests 169 | print('Temporary ban from Instagram. Grab a coffee watch a TV show and comeback later. I will try again...') 170 | return False 171 | 172 | response = json.loads(response.text) 173 | 174 | if response['status'] != 'ok': 175 | print('Error while trying to unfollow {}. Retrying in a bit...'.format(user['username'])) 176 | print('ERROR: {}'.format(response.text)) 177 | return False 178 | return True 179 | 180 | 181 | def main(): 182 | 183 | if os.environ.get('DRY_RUN'): 184 | print('DRY RUN MODE, script will not unfollow users!') 185 | 186 | if not os.path.isdir(cache_dir): 187 | os.makedirs(cache_dir) 188 | 189 | headers, cookies = init() 190 | 191 | if os.path.isfile(session_cache): 192 | with open(session_cache, 'rb') as f: 193 | session.cookies.update(pickle.load(f)) 194 | else: 195 | is_logged, cookies = login(headers, cookies) 196 | if is_logged == False: 197 | sys.exit('login failed, verify user/password combination') 198 | 199 | with open(session_cache, 'wb') as f: 200 | pickle.dump(session.cookies, f) 201 | 202 | time.sleep(random.randint(2, 4)) 203 | 204 | connected_user = get_user_profile(credentials.username, headers) 205 | 206 | print('You\'re now logged as {} ({} followers, {} following)'.format(connected_user['username'], connected_user['edge_followed_by']['count'], connected_user['edge_follow']['count'])) 207 | 208 | time.sleep(random.randint(2, 4)) 209 | 210 | following_list = [] 211 | if os.path.isfile(following_cache): 212 | with open(following_cache, 'r') as f: 213 | following_list = json.load(f) 214 | print('following list loaded from cache file') 215 | 216 | if len(following_list) != connected_user['edge_follow']['count']: 217 | if len(following_list) > 0: 218 | print('rebuilding following list...', end='', flush=True) 219 | else: 220 | print('building following list...', end='', flush=True) 221 | following_list = get_following_list(connected_user['id'], headers) 222 | print(' done') 223 | 224 | with open(following_cache, 'w') as f: 225 | json.dump(following_list, f) 226 | 227 | followers_list = [] 228 | if os.path.isfile(followers_cache): 229 | with open(followers_cache, 'r') as f: 230 | followers_list = json.load(f) 231 | print('followers list loaded from cache file') 232 | 233 | if len(followers_list) != connected_user['edge_followed_by']['count']: 234 | if len(following_list) > 0: 235 | print('rebuilding followers list...', end='', flush=True) 236 | else: 237 | print('building followers list...', end='', flush=True) 238 | followers_list = get_followers_list(connected_user['id'], headers) 239 | print(' done') 240 | 241 | with open(followers_cache, 'w') as f: 242 | json.dump(followers_list, f) 243 | 244 | followers_usernames = {user['username'] for user in followers_list} 245 | unfollow_users_list = [user for user in following_list if user['username'] not in followers_usernames] 246 | 247 | print('you are following {} user(s) who aren\'t following you:'.format(len(unfollow_users_list))) 248 | for user in unfollow_users_list: 249 | print(user['username']) 250 | 251 | if len(unfollow_users_list) > 0: 252 | print('Begin to unfollow users...') 253 | 254 | for user in unfollow_users_list: 255 | if not os.environ.get('UNFOLLOW_VERIFIED') and user['is_verified'] == True: 256 | print('Skipping {}...'.format(user['username'])) 257 | continue 258 | 259 | time.sleep(random.randint(5, 10)) 260 | 261 | print('Unfollowing {}...'.format(user['username'])) 262 | while unfollow(user) == False: 263 | sleep_time = random.randint(1, 3) * 1000 # High number on purpose 264 | print('Sleeping for {} seconds'.format(sleep_time)) 265 | time.sleep(sleep_time) 266 | 267 | print(' done') 268 | 269 | 270 | if __name__ == "__main__": 271 | main() 272 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.31.0 2 | --------------------------------------------------------------------------------