├── Procfile ├── imgs ├── cli.gif └── flask.gif ├── .coveragerc ├── docker-compose.yml ├── Dockerfile ├── setup.py ├── tests ├── test_helpers.py └── test_twitter_blast.py ├── tox.ini ├── Makefile ├── static └── js │ └── main.js ├── requirements.txt ├── models.py ├── LICENSE ├── templates ├── base.html └── index.html ├── helpers.py ├── .gitignore ├── app.py ├── README.md └── twitter_blast.py /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn app:app 2 | -------------------------------------------------------------------------------- /imgs/cli.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drizzleco/twitter-blast/HEAD/imgs/cli.gif -------------------------------------------------------------------------------- /imgs/flask.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drizzleco/twitter-blast/HEAD/imgs/flask.gif -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = *tests* 3 | *.tox* 4 | 5 | [run] 6 | omit = *tests* 7 | *.tox* 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | web: 4 | build: . 5 | ports: 6 | - "5000:5000" 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | RUN mkdir /app 3 | COPY . /app 4 | WORKDIR /app 5 | RUN pip install --no-cache-dir -r requirements.txt 6 | EXPOSE 5000 7 | ENTRYPOINT gunicorn -b 0.0.0.0:5000 app:app 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages 2 | from setuptools import setup 3 | 4 | setup( 5 | name="twitter-blast", 6 | version="1.0.0", 7 | maintainer="Drizzle", 8 | description="Twitter Blast", 9 | packages=find_packages(), 10 | include_package_data=True, 11 | zip_safe=False, 12 | extras_require={"test": ["pytest"]}, 13 | ) 14 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from hypothesis import given 3 | import hypothesis.strategies as st 4 | import helpers 5 | 6 | 7 | @given(st.lists(st.integers(), min_size=1), st.integers(1)) 8 | def test_divide_into_chunks(l, n): 9 | assert list(helpers.divide_into_chunks(l, n)) == list( 10 | l[i : i + n] for i in range(0, len(l), max(1, n)) 11 | ) 12 | -------------------------------------------------------------------------------- /tests/test_twitter_blast.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from hypothesis import given 3 | import hypothesis.strategies as st 4 | import twitter_blast 5 | 6 | 7 | @given(st.integers(1, 6)) 8 | def test_prompt_ranking_value(monkeypatch, inp): 9 | monkeypatch.setattr("builtins.input", lambda x: str(inp)) 10 | assert twitter_blast.prompt_ranking_value() == ( 11 | twitter_blast.ranking_choices[inp - 1], 12 | "", 13 | ) 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py3 8 | 9 | [testenv] 10 | deps = 11 | -rrequirements.txt 12 | commands = 13 | python -m pytest --cov-config=.coveragerc --cov=./ tests/ 14 | codecov -t 2349930f-7ed4-485c-b5db-7b637cc02226 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ### help - help docs for this Makefile 2 | .PHONY: help 3 | help : 4 | @sed -n '/^###/p' Makefile 5 | 6 | ### install - install requirements in venv 7 | SECRETS_FILE := 'secrets.py' 8 | .PHONY: install 9 | install: 10 | python3 -m venv .venv; \ 11 | . .venv/bin/activate; \ 12 | pip install -r requirements.txt; \ 13 | test -f $(SECRETS_FILE) || echo 'HOSTED_CONSUMER_KEY = ""\nHOSTED_CONSUMER_SECRET = ""\nCONSUMER_KEY = ""\nCONSUMER_SECRET = ""\nSECRET_KEY = ""' > $(SECRETS_FILE) 14 | 15 | ### start - start server 16 | .PHONY: start 17 | start: 18 | . .venv/bin/activate; \ 19 | python app.py 20 | -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | function toggle_ranking(val) { 2 | window.history.replaceState(null, null, window.location.pathname); 3 | var url = new URL(window.location.href); 4 | url.searchParams.set('rank_by', val); 5 | window.location = url; 6 | 7 | } 8 | 9 | function filter() { 10 | let searchParams = new URLSearchParams(window.location.search); 11 | searchParams.set('value', $('#search-box input').val()); 12 | window.location = window.location.pathname + '?' + searchParams.toString(); 13 | } 14 | 15 | function showLoading(el) { 16 | $(el).siblings('.loading').attr('style', 'display: flex !important') 17 | } 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.4 2 | attrs==19.3.0 3 | black==19.10b0 4 | certifi==2020.4.5.2 5 | cffi==1.14.1 6 | chardet==3.0.4 7 | click==7.1.2 8 | codecov==2.1.8 9 | coverage==5.2.1 10 | cryptography==2.9.2 11 | Flask==1.1.2 12 | Flask-Cors==3.0.8 13 | Flask-JWT-Extended==3.24.1 14 | Flask-SQLAlchemy==2.4.3 15 | gunicorn==20.0.4 16 | hypothesis==5.23.9 17 | idna==2.9 18 | iniconfig==1.0.1 19 | itsdangerous==1.1.0 20 | Jinja2==2.11.2 21 | jwt==1.0.0 22 | MarkupSafe==1.1.1 23 | more-itertools==8.4.0 24 | oauthlib==3.1.0 25 | packaging==20.4 26 | pathspec==0.8.0 27 | pluggy==0.13.1 28 | py==1.9.0 29 | pycparser==2.20 30 | PyJWT==1.7.1 31 | pyparsing==2.4.7 32 | PySocks==1.7.1 33 | pytest==6.0.1 34 | pytest-cov==2.10.0 35 | regex==2020.6.8 36 | requests==2.23.0 37 | requests-oauthlib==1.3.0 38 | six==1.15.0 39 | sortedcontainers==2.2.2 40 | SQLAlchemy==1.3.17 41 | toml==0.10.1 42 | tweepy==3.8.0 43 | typed-ast==1.4.1 44 | urllib3==1.25.9 45 | Werkzeug==1.0.1 46 | -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | 3 | db = SQLAlchemy() # shared db instance 4 | 5 | 6 | class User(db.Model): 7 | id = db.Column(db.Integer, primary_key=True) 8 | username = db.Column(db.String, nullable=False) 9 | followers = db.relationship("Follower", backref="user") 10 | 11 | 12 | class Follower(db.Model): 13 | id = db.Column(db.Integer, primary_key=True) 14 | id_str = db.Column(db.String, nullable=False) 15 | name = db.Column(db.String) 16 | screen_name = db.Column(db.String) 17 | location = db.Column(db.String) 18 | description = db.Column(db.Text) 19 | followers_count = db.Column(db.Integer) 20 | friends_count = db.Column(db.Integer) 21 | listed_count = db.Column(db.Integer) 22 | favourites_count = db.Column(db.Integer) 23 | statuses_count = db.Column(db.Integer) 24 | created_at = db.Column(db.DateTime) 25 | verified = db.Column(db.Boolean) 26 | dm_sent = db.Column(db.Boolean, default=False) 27 | user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 drizzleco 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 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Twitter Blast 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | {% with messages = get_flashed_messages() %} 16 | {% if messages %} 17 | 18 | {% for message in messages %} 19 | 25 | 26 | {% endfor %} 27 | {% endif %} 28 | {% endwith %} 29 | {% block content %}{% endblock %} 30 | 31 | 34 | 37 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /helpers.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from models import Follower 3 | import tweepy, time 4 | 5 | 6 | RANK_BY = { 7 | "recent": {"column": None}, 8 | "followers_count": {"column": Follower.followers_count}, 9 | "following_count": {"column": Follower.friends_count}, 10 | "listed_count": {"column": Follower.listed_count}, 11 | "favourites_count": {"column": Follower.favourites_count}, 12 | "statuses_count": {"column": Follower.statuses_count}, 13 | } 14 | 15 | 16 | def divide_into_chunks(l: List, n: int): 17 | """ 18 | Split a list into chunks of n size 19 | 20 | params: 21 | l(list) - list to split up 22 | n(int) - size of sublists 23 | """ 24 | for i in range(0, len(l), n): 25 | yield l[i : i + n] 26 | 27 | 28 | def print_progress_bar( 29 | iteration: int, 30 | total: int, 31 | prefix: str = "", 32 | suffix: str = "", 33 | decimals: int = 1, 34 | length: int = 50, 35 | fill: str = "█", 36 | print_end: str = "\r", 37 | ): 38 | """ 39 | Call in a loop to create terminal progress bar. Print new line on complete 40 | 41 | params: 42 | iteration(int) - current iteration 43 | total(int) - total iterations 44 | prefix(str) - prefix string 45 | suffix(str) - suffix string 46 | decimals(int) - positive number of decimals in percent complete 47 | length(int) - character length of bar 48 | fill(str) - bar fill character 49 | print_end(str) - end character (e.g. "\r", "\r\n") 50 | """ 51 | percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total))) 52 | filled_length = int(length * iteration // total) 53 | bar = fill * filled_length + "-" * (length - filled_length) 54 | print("\r%s |%s| %s%% %s" % (prefix, bar, percent, suffix), end=print_end) 55 | if iteration == total: 56 | print() 57 | 58 | 59 | def rate_limit_handler(cursor: tweepy.Cursor): 60 | """ 61 | Handler for tweepy Cursors and automatically stops 62 | execution when rate limit is reached 63 | 64 | params: 65 | cursor(tweepy.Cursor) - cursor to handle 66 | """ 67 | while True: 68 | try: 69 | yield cursor.next() 70 | except tweepy.RateLimitError: 71 | print("Oh no!! We hit the rate limit. Resuming in 15 mins.") 72 | time.sleep(15 * 60) 73 | 74 | 75 | def bye(): 76 | """ 77 | Stop execution of script. 78 | """ 79 | print("Ok bye.") 80 | exit() 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | secrets.py 3 | *.sqlite 4 | .keys 5 | 6 | ### Python ### 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # pytype static type analyzer 138 | .pytype/ 139 | 140 | ### VisualStudioCode ### 141 | .vscode/* 142 | !.vscode/settings.json 143 | !.vscode/tasks.json 144 | !.vscode/launch.json 145 | !.vscode/extensions.json 146 | *.code-workspace 147 | 148 | ### VisualStudioCode Patch ### 149 | # Ignore all local history of files 150 | .history 151 | 152 | # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode 153 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from flask import ( 3 | Flask, 4 | jsonify, 5 | request, 6 | render_template, 7 | session, 8 | redirect, 9 | url_for, 10 | flash, 11 | ) 12 | from flask_cors import CORS 13 | import tweepy 14 | from flask import Flask 15 | from models import User, Follower, db 16 | import twitter_blast 17 | 18 | CALLBACK_URL = "http://127.0.0.1:5000" 19 | app = Flask(__name__) 20 | CORS(app) 21 | app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///twitter_blast.sqlite" 22 | app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 23 | db.app = app 24 | db.init_app(app) 25 | 26 | 27 | def generate_auth() -> tweepy.OAuthHandler: 28 | """ 29 | Generate a fresh tweepy auth handler 30 | 31 | returns: 32 | (tweepy.OAuthHandler) - fully set up auth with user tokens from session 33 | """ 34 | access_token = session.get("access_token") 35 | access_token_secret = session.get("access_token_secret") 36 | auth = tweepy.OAuthHandler( 37 | HOSTED_CONSUMER_KEY, HOSTED_CONSUMER_SECRET, CALLBACK_URL 38 | ) 39 | auth.set_access_token(access_token, access_token_secret) 40 | return auth 41 | 42 | 43 | @app.route("/") 44 | def home(): 45 | # check to make sure secrets are there 46 | if not HOSTED_CONSUMER_KEY or not HOSTED_CONSUMER_SECRET: 47 | return "

Oh no! You forgot to add your credentials to secrets.py!

" 48 | auth = generate_auth() 49 | token = request.args.get("oauth_token") 50 | verifier = request.args.get("oauth_verifier") 51 | if token: 52 | # oauth redirect 53 | auth.request_token = { 54 | "oauth_token": token, 55 | "oauth_token_secret": verifier, 56 | } 57 | try: 58 | auth.get_access_token(verifier) 59 | session["access_token"] = auth.access_token 60 | session["access_token_secret"] = auth.access_token_secret 61 | except tweepy.TweepError: 62 | print("Error! Failed to get access token.") 63 | elif not auth.access_token: 64 | # sign in prompt 65 | redirect_url = auth.get_authorization_url() 66 | return render_template("index.html", redirect_url=redirect_url) 67 | api = tweepy.API(auth) 68 | username = api.me().screen_name 69 | user = User.query.filter_by(username=username).first() 70 | if not user: 71 | # create user 72 | user = User(username=username) 73 | db.session.add(user) 74 | db.session.commit() 75 | rank_by = request.args.get("rank_by", "recent") 76 | value = request.args.get("value", "") 77 | has_fetched_followers = user.followers 78 | try: 79 | dms_all_sent = False 80 | followers = twitter_blast.ranked_followers( 81 | username, rank_by=rank_by, value=value 82 | ) 83 | except: 84 | dms_all_sent = True 85 | followers = None 86 | if username: 87 | return render_template( 88 | "index.html", 89 | username=username, 90 | dms_all_sent=dms_all_sent, 91 | has_fetched_followers=has_fetched_followers, 92 | followers=followers, 93 | ranking_choices=twitter_blast.ranking_choices, 94 | rank_by=rank_by, 95 | value=value, 96 | ) 97 | 98 | 99 | @app.route("/fetch", methods=["POST"]) 100 | def fetch(): 101 | auth = generate_auth() 102 | api = tweepy.API(auth) 103 | username = api.me().screen_name 104 | twitter_blast.fetch_followers(username, api) 105 | return redirect(url_for("home")) 106 | 107 | 108 | @app.route("/send", methods=["POST"]) 109 | def send(): 110 | auth = generate_auth() 111 | api = tweepy.API(auth) 112 | username = tweepy.API(auth).me().screen_name 113 | message = request.form.get("message") 114 | if not message: 115 | flash("Your message can't be empty!") 116 | return redirect(url_for("home")) 117 | real = request.form.get("real", False) 118 | rank_by = request.form.get("rank_by", "recent") 119 | value = request.form.get("value", "") 120 | twitter_blast.mass_dm_followers(username, message, rank_by, value, not real, api) 121 | if real: 122 | flash("Mass DM sent!") 123 | else: 124 | flash("Mass DM sent! Dry run was ON, so messages weren't actually sent!") 125 | return redirect(url_for("home")) 126 | 127 | 128 | @app.route("/reset", methods=["POST"]) 129 | def reset(): 130 | auth = generate_auth() 131 | username = tweepy.API(auth).me().screen_name 132 | twitter_blast.handle_reset(username) 133 | return redirect(url_for("home")) 134 | 135 | 136 | @app.route("/logout", methods=["GET"]) 137 | def logout(): 138 | session.clear() 139 | return redirect(url_for("home")) 140 | 141 | 142 | if __name__ == "__main__": 143 | from secrets import HOSTED_CONSUMER_KEY, HOSTED_CONSUMER_SECRET, SECRET_KEY 144 | 145 | app.secret_key = SECRET_KEY 146 | db.create_all() 147 | app.run(debug=True) 148 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content%} 4 |
5 |
6 |
7 |
8 |

Twitter Blast

9 |

Easily connect with all your followers by sending a mass DM!

10 | {% if not username %} 11 | 12 | Sign in with Twitter 13 | 14 | {% else %} 15 |

Logged in as: {{username}}

16 | 17 | {% endif %} 18 |
19 |
20 |
How it works
21 |
    22 |
  1. Authenticate with Twitter
  2. 23 |
  3. Choose how you'd like to prioritize your followers
  4. 24 |
  5. Send out your message!
  6. 25 |
26 |
27 |
28 |
29 |
30 | {% if username %} 31 |
32 |
33 |
34 |

Send mass DM

35 |
Rank Followers by:
36 |
37 | 43 | 48 |

Message:

49 | 50 |
51 | 52 | 55 |
56 | 57 |
58 |
59 |
60 | Loading... 61 |
62 | Sending mass DM! 63 |
64 |
65 |
66 |
67 | {% if followers %} 68 |

Your Followers(ranked by {{rank_by}})

69 |
70 | 71 |
72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | {% for follower in followers %} 83 | 84 | 85 | 86 | 87 | 88 | 89 | {% endfor %} 90 | 91 |
#UsernameFollowersFollowing
{{loop.index}}{{follower.screen_name}}{{follower.followers_count}}{{follower.friends_count}}
92 | {% elif dms_all_sent%} 93 |

All your followers have been sent DMs already :(

94 |
95 | 96 |
97 | {% elif has_fetched_followers %} 98 |

No followers matched your criteria

99 | {% else %} 100 |

You haven't fetched any followers yet!

101 |
102 | 104 |
105 |
106 |
107 | Loading... 108 |
109 | Fetching followers! This may take a while. 110 |
111 |
112 | {% endif %} 113 |
114 |
115 |
116 | {% endif %} 117 | {% endblock %} 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitter Blast(CLI/Flask versions) 2 | 3 | Mass DM tool for Twitter to convert followers to another platform 4 | 5 | Solution to: https://github.com/balajis/twitter-export/ 6 | 7 | [![Build status](https://badge.buildkite.com/b37e3412702b06621289784b2088181bff2166799786bfa254.svg)](https://buildkite.com/drizzle/twitter-blast) 8 | [![codecov](https://codecov.io/gh/drizzleco/twitter-blast/branch/master/graph/badge.svg)](https://codecov.io/gh/drizzleco/twitter-blast) 9 | 10 | ### CLI Version 11 | 12 | ![twitter blast](imgs/cli.gif) 13 | 14 | ### Flask Version 15 | 16 | ![twitter blast](imgs/flask.gif) 17 | 18 | # Features 19 | 20 | - easy authentication using Sign in with Twitter 21 | - preview follower rankings before sending the real deal 22 | - defaults to dry run mode to prevent unintentionally sending out DMs 23 | - remembers when a DM has been sent to a follower so no unintentional double sends 24 | - automatically pauses execution to wait out rate limits 25 | 26 | # Getting Started(CLI version) 27 | 28 | 1. `make install` to install dependencies 29 | 2. Edit `secrets.py`(automatically created) in the same directory as `twitter_blast.py` and add your app credentials: 30 | 31 | - make sure your Twitter app has "Read, write, and Direct Messages" permission 32 | 33 | ```python 34 | HOSTED_CONSUMER_KEY = "" # for the flask app 35 | HOSTED_CONSUMER_SECRET = "" # for the flask app 36 | CONSUMER_KEY = "" # for the CLI version 37 | CONSUMER_SECRET = "" # for the CLI version 38 | SECRET_KEY = "" 39 | ``` 40 | 41 | 3. On first run, you'll be prompted to authorize with Twitter 42 | ``` 43 | $ python twitter_blast.py 44 | Visit to authorize with twitter: https://api.twitter.com/oauth/authorize?oauth_token=_______________________ 45 | Paste the verification code here: ________ 46 | ``` 47 | 4. `python twitter_blast.py fetch` to fetch your followers 48 | 5. `python twitter_blast.py preview` to test out the ranking system and see how your followers will be prioritized 49 | 6. `python twitter_blast.py send` to dry send a DM to your followers(add `--real` to send them for real!) 50 | 51 | # Getting Started(Flask version) 52 | 53 | 1. Complete steps 1 and 2 from above. 54 | 2. Add `http://127.0.0.1:5000` to your callback URLs in Twitter dev app settings 55 | 3. `make start` 56 | 57 | **OR** 58 | 59 | 3) `source .venv/bin/activate && python app.py` 60 | 61 | **OR** 62 | 63 | 3. `docker-compose up --build` 64 | 65 | # Usage(CLI version) 66 | 67 | ``` 68 | Usage: twitter_blast.py [OPTIONS] [send|fetch|preview|reset|delete_keys] 69 | 70 | Mass DM tool for Twitter to convert followers to another platform 71 | 72 | Options: 73 | --real Actually send DMs. 74 | --help Show this message and exit. 75 | ``` 76 | 77 | ### Actions 78 | 79 | - ### `fetch` 80 | - download followers to a local database 81 | ``` 82 | $ python twitter_blast.py fetch 83 | Logged in as: SuperTweeter123 84 | You've already fetched your followers. Are you sure you want to refetch them? This could take a while. [y/n]: y 85 | Fetching 50 followers 86 | Fetching follower ids! 87 | Fetching user objects from ids! 88 | Fetching 50/50 Followers |██████████████████████████████████████████████████| 100.0% Fetched 89 | Done! 90 | ``` 91 | 92 | * ### `preview` 93 | 94 | - show a preview of followers with specified ranking 95 | - these preview options are available for ranking your followers 96 | 97 | - recent - prioritize follower who most recently followed you 98 | - followers_count - prioritize followers with more followers 99 | - following_count - prioritize followers following more people 100 | - statuses_count - prioritize followers who have more tweets 101 | - listed_count - prioritize followers who appear in more lists 102 | - favourites_count - prioritize followers who have liked more tweets(British spelling cuz thats how twitter does it lol) 103 | - location - filter followers based on their location 104 | - description - filter followers based on their bio description 105 | 106 | - good for getting an idea of the follower prioritized by `send` 107 | 108 | ``` 109 | $ python twitter_blast.py preview 110 | Logged in as: SuperTweeter123 111 | Choose how you'd like to rank your followers: 112 | 1) recent 113 | 2) followers_count 114 | 3) following_count 115 | 4) statuses_count 116 | 5) listed_count 117 | 6) favourites_count 118 | 7) location 119 | 8) description 120 | Enter the number of your choice: 2 121 | ``` 122 | 123 | - filtering by location or description will prompt you to enter a string to search for 124 | 125 | ``` 126 | $ python twitter_blast.py preview 127 | Logged in as: SuperTweeter123 128 | Choose how you'd like to rank your followers: 129 | 1) recent 130 | 2) followers_count 131 | 3) following_count 132 | 4) statuses_count 133 | 5) listed_count 134 | 6) favourites_count 135 | 7) location 136 | 8) description 137 | Enter the number of your choice: 7 138 | Enter what you want to look for in location: cali 139 | ``` 140 | 141 | - opens in less(or your preferred pager) for easy navigation 142 | ``` 143 | Order of followers to be DM'ed(ranked by followers_count ). Followers whom a DM hasn't been sent are shown: 144 | nokia 145 | FundingTweets 146 | MonsterFunder 147 | MonsterFunders 148 | Motts 149 | StartUpsSolar 150 | Money360 151 | abcstoreshawaii 152 | 3DMCTweets 153 | gaaplug 154 | datezoholeg 155 | DSeviorINC 156 | PrinceSpeaks247 157 | HelpCrowdfund1 158 | FizzyDaysMovie 159 | MoneyTeddyBear 160 | hollywoodville 161 | : 162 | ``` 163 | 164 | * ### `send` 165 | 166 | - initiate sending mass DM to followers 167 | - defaults to dry run so no DMs are sent out for real 168 | - add `--real` to send DMs for real 169 | 170 | ``` 171 | $ python twitter_blast.py send 172 | Logged in as: SuperTweeter123 173 | Choose how you'd like to rank your followers: 174 | 1) recent 175 | 2) followers_count 176 | 3) following_count 177 | 4) statuses_count 178 | 5) listed_count 179 | 6) favourites_count 180 | 7) location 181 | 8) description 182 | Enter the number of your choice: 1 183 | 184 | NOTE: you may want to preview your followers rankings before sending 185 | What do you wanna say? Type your message below: 186 | hello world 187 | Here is your message one more time: 188 | 189 | hello world 190 | 191 | Are you sure you want to send this? [y/n]: y 192 | 193 | Dry run is ON. Messages are not actually being sent. Phew. Add the --real flag to send DMs 194 | Sending message to 50 followers 195 | Sending DM to EgoAthletics 196 | |██████████████████████████████████████████████████| 100.0% Sent 197 | ``` 198 | 199 | * ### `reset` 200 | - resets every followers' DM sent flags, so another mass DM can be initiated 201 | ``` 202 | $ python twitter_blast.py reset 203 | Logged in as: SuperTweeter123 204 | Followers DM sent flags reset! 205 | ``` 206 | * ### `delete_keys` 207 | 208 | - delete API keys stored from authentication 209 | 210 | ``` 211 | $ python twitter_blast.py reset 212 | Logged in as: SuperTweeter123 213 | Keys deleted! 214 | ``` 215 | 216 | # Behind the Scenes 217 | 218 | - Fetching followers data 219 | - fetches ids of followers first using `followers/ids` 220 | - `followers/ids` returns 5,000 user ids/request(max 15 requests every 15 minutes ) 221 | - TOTAL: 75,000 users every 15 minutes 222 | - then, fetches user object using `users/lookup` 223 | - `users/lookup` can get 100 user objects per request 224 | - with user-level auth, you can make 900 requests every 15 minutes 225 | - TOTAL: 90,000 users every 15 minutes 226 | - Ranking Followers 227 | - uses SQLAlchemy database queries to do the heavy lifting 228 | - Sending DMs 229 | - uses tweepy's wrapper for `direct_messages/events/new (message_create)` 230 | - updates database to keep track of which followers have been sent DMs 231 | -------------------------------------------------------------------------------- /twitter_blast.py: -------------------------------------------------------------------------------- 1 | import tweepy, time, click, os, pickle, pydoc 2 | from typing import List, Tuple 3 | from models import User, Follower, db 4 | from helpers import ( 5 | RANK_BY, 6 | bye, 7 | print_progress_bar, 8 | divide_into_chunks, 9 | rate_limit_handler, 10 | ) 11 | 12 | import app 13 | 14 | follower_keys = [ 15 | "id_str", 16 | "name", 17 | "screen_name", 18 | "location", 19 | "description", 20 | "followers_count", 21 | "friends_count", 22 | "listed_count", 23 | "created_at", 24 | "favourites_count", 25 | "verified", 26 | "statuses_count", 27 | ] 28 | 29 | ranking_choices = [ 30 | "recent", 31 | "followers_count", 32 | "following_count", 33 | "statuses_count", 34 | "listed_count", 35 | "favourites_count", 36 | "location", 37 | "description", 38 | ] 39 | 40 | 41 | def auth() -> tweepy.OAuthHandler: 42 | """ 43 | Reads in user keys if previously saved. If not saved, take user 44 | through Sign in with Twitter and serialize keys to a file. 45 | returns fully setup OAuthHandler 46 | 47 | returns: 48 | tweepy.OAuthHandler - OAuthHandler to plug into tweepy.API call 49 | """ 50 | auth = tweepy.OAuthHandler(CONSUMER_KEY, CONSUMER_SECRET) 51 | if os.path.isfile(".keys"): 52 | token = pickle.load(open(".keys", "rb")) 53 | auth.set_access_token(token[0], token[1]) 54 | else: 55 | try: 56 | redirect_url = auth.get_authorization_url() 57 | except tweepy.TweepError: 58 | print("Error! Failed to get request token.") 59 | print("Visit to authorize with twitter: ", redirect_url) 60 | verifier = input("Paste the verification code here: ") 61 | try: 62 | token = auth.get_access_token(verifier.strip()) 63 | except tweepy.TweepError: 64 | print("Error! Failed to get access token.") 65 | pickle.dump(token, open(".keys", "wb")) 66 | return auth 67 | 68 | 69 | def fetch_followers(username: str, api: tweepy.API): 70 | """ 71 | Use tweepy to fetch user's followers' ids and then fetch their user objects 72 | and save to the db. 73 | 74 | params: 75 | username(str) - username of user to fetch followers for 76 | api(tweepy.API) - tweepy api instance 77 | """ 78 | total_followers = api.me().followers_count 79 | print("Fetching {} followers".format(total_followers)) 80 | db.create_all() 81 | follower_ids = [] 82 | print("Fetching follower ids!") 83 | for id in rate_limit_handler(tweepy.Cursor(api.followers_ids, count=5000).items()): 84 | follower_ids.append(id) 85 | print("Fetching user objects from ids!") 86 | for list_of_100 in list(divide_into_chunks(follower_ids, 100)): 87 | for i, follower in enumerate(api.lookup_users(user_ids=list_of_100)): 88 | follower_dict = dict((k, follower.__dict__[k]) for k in follower_keys) 89 | user = User.query.filter_by(username=username).first() 90 | if not user: 91 | user = User(username=username) 92 | follower = Follower(**follower_dict) 93 | user.followers.append(follower) 94 | db.session.add(user) 95 | db.session.commit() 96 | print_progress_bar( 97 | i + 1, 98 | total_followers, 99 | prefix="Fetching {}/{} Followers".format(i + 1, total_followers), 100 | suffix="Fetched", 101 | ) 102 | print("Done!") 103 | 104 | 105 | def send_message(id: str, message: str, api: tweepy.API): 106 | """ 107 | Send message to user_id 108 | 109 | params: 110 | id(str) - user id of user as string 111 | message(str) - message to send 112 | api(tweepy.API) - tweepy api instance 113 | """ 114 | try: 115 | api.send_direct_message(id, message) 116 | 117 | except tweepy.RateLimitError: 118 | print("Oh no!! We hit the rate limit. Resuming tomorrow.") 119 | time.sleep(24 * 60 * 60) 120 | 121 | 122 | def ranked_followers(username: str, rank_by: str, value: str) -> List[Follower]: 123 | """ 124 | Rank followers based on specified criteria using db queries. Return a list 125 | of ranked followers' user id and their screen names who have not already 126 | been sent a DM. 127 | 128 | params: 129 | username(str) - username of follower to rank for 130 | rank_by(str) - criteria to rank by 131 | value(str) - value to search for. only used for location 132 | and description filter 133 | returns: 134 | list of followers ranked accordingly 135 | """ 136 | ranking = RANK_BY.get(rank_by, None) 137 | user = User.query.filter_by(username=username).first() 138 | followers_dm_not_sent = ( 139 | db.session.query(Follower).filter_by(user_id=user.id, dm_sent=False).all() 140 | ) 141 | if user.followers and not len(followers_dm_not_sent): 142 | raise Exception("DMs have been sent to all followers already :(") 143 | if ranking: 144 | return ( 145 | db.session.query(Follower) 146 | .filter_by(user_id=user.id, dm_sent=False) 147 | .order_by(db.desc(**ranking)) 148 | .all() 149 | ) 150 | else: 151 | return ( 152 | db.session.query(Follower) 153 | .filter( 154 | Follower.user_id == user.id, 155 | getattr(Follower, rank_by).like("%{}%".format(value)), 156 | Follower.dm_sent == False, 157 | ) 158 | .all() 159 | ) 160 | 161 | 162 | def mass_dm_followers( 163 | username: str, 164 | message: str, 165 | rank_by: str = "recent", 166 | value: str = "", 167 | dry_run: bool = True, 168 | api: tweepy.API = None, 169 | ): 170 | """ 171 | Send mass DM to all followers in order of specificed ranking and set 172 | dm_sent flag for that user to True. Allows for dry run where messages 173 | are not actually sent out and dm_sent flag is not changed. 174 | 175 | params: 176 | username(str) - user of followers to DM 177 | message(str) - message to send out 178 | rank_by(str) - ranking method 179 | value(str) - value to search for. only used for location 180 | and description filter 181 | dry_run(bool) - set to True to only pretend to send messages 182 | api(tweepy.API) - tweepy api instance 183 | """ 184 | user = User.query.filter_by(username=username).first() 185 | try: 186 | followers = ranked_followers(username, rank_by, value) 187 | except Exception as e: 188 | print(e) 189 | bye() 190 | total_followers = len(followers) 191 | if not total_followers: 192 | print("No followers matched your criteria :(") 193 | bye() 194 | print() 195 | if dry_run: 196 | print( 197 | "Dry run is ON. Messages are not actually being sent. Phew. Add the --real flag to send DMs" 198 | ) 199 | print("Sending message to {} followers".format(total_followers), end="\n\n") 200 | for i, follower in enumerate(followers): 201 | print("\033[F\033[KSending DM to {}".format(follower.screen_name)) 202 | print_progress_bar(i + 1, total_followers, suffix="Sent") 203 | if dry_run: 204 | time.sleep(0.01) 205 | else: 206 | send_message(follower.id_str, message, api) # Comment this out if testing 207 | db.session.query(Follower).filter_by( 208 | id_str=follower.id_str, user_id=user.id 209 | ).update({"dm_sent": True}) 210 | db.session.commit() 211 | 212 | 213 | # handlers 214 | def prompt_ranking_value() -> Tuple[str, str]: 215 | """ 216 | Prompt user for ranking choice and value if needed 217 | 218 | returns: 219 | ranking(str) - string value of ranking choice selected 220 | value(str) - string to search filter_by, if needed 221 | """ 222 | value = "" 223 | print("Choose how you'd like to rank your followers:") 224 | for num, choice in enumerate(ranking_choices): 225 | print("{}) {}".format(str(num + 1), choice)) 226 | ranking = ranking_choices[int(input("Enter the number of your choice: ")) - 1] 227 | if ranking == "location" or ranking == "description": 228 | value = input("Enter what you want to look for in {}: ".format(ranking)) 229 | return ranking, value 230 | 231 | 232 | def handle_fetch(): 233 | """ 234 | Fetch action 235 | """ 236 | if os.path.isfile("twitter_blast.sqlite"): 237 | refetch = input( 238 | "You've already fetched your followers. Are you sure you want to refetch them? This could take a while. [y/n]: " 239 | ) 240 | if refetch == "y": 241 | db.drop_all() 242 | db.create_all() 243 | fetch_followers(username, api) 244 | else: 245 | bye() 246 | else: 247 | fetch_followers(username, api) 248 | 249 | 250 | def handle_preview(): 251 | """ 252 | Preview action 253 | """ 254 | ranking, value = prompt_ranking_value() 255 | followers = "Order of followers to be DM'ed(ranked by {} {}). Followers whom a DM hasn't been sent are shown:\n".format( 256 | ranking, value 257 | ) 258 | try: 259 | ranked = ranked_followers(username, rank_by=ranking, value=value) 260 | if ranked: 261 | for follower in ranked: 262 | followers += follower.screen_name + "\n" 263 | else: 264 | followers += "No followers matched your criteria :(" 265 | except Exception as e: 266 | followers += str(e) 267 | pydoc.pager(followers) 268 | 269 | 270 | def handle_send(real: bool): 271 | """ 272 | Send action 273 | 274 | params: 275 | real(bool) - click real flag 276 | """ 277 | ranking, value = prompt_ranking_value() 278 | print("\nNOTE: you may want to preview your followers rankings before sending") 279 | message = input("What do you wanna say? Type your message below:\n") 280 | confirmed = input( 281 | "Here is your message one more time:\n\n{}\n\nAre you sure you want to send this? [y/n]: ".format( 282 | message 283 | ) 284 | ) 285 | if confirmed != "y": 286 | bye() 287 | if real: 288 | send = input( 289 | "Dry run is not set. Are you sure you want to initiate the mass DM?? [y/n]: " 290 | ) 291 | if send == "y": 292 | mass_dm_followers( 293 | username, message, rank_by=ranking, value=value, dry_run=False, api=api 294 | ) 295 | else: 296 | bye() 297 | else: 298 | mass_dm_followers( 299 | username, message, rank_by=ranking, value=value, dry_run=True, api=api 300 | ) 301 | 302 | 303 | def handle_reset(username: str): 304 | """ 305 | Reset all followers dm_sent column to False 306 | 307 | params: 308 | username(str) - user to reset follower flags for 309 | """ 310 | user_id = User.query.filter_by(username=username).first().id 311 | db.session.query(Follower).filter_by(user_id=user_id).update({"dm_sent": False}) 312 | db.session.commit() 313 | print("Followers DM sent flags reset!") 314 | 315 | 316 | def handle_delete_keys(): 317 | """ 318 | Delete keys file 319 | """ 320 | if os.path.isfile(".keys"): 321 | os.remove(".keys") 322 | print("Keys deleted!") 323 | else: 324 | print("You haven't been authorized yet.") 325 | 326 | 327 | # parser config 328 | @click.command() 329 | @click.argument( 330 | "action", type=click.Choice(["send", "fetch", "preview", "reset", "delete_keys"]), 331 | ) 332 | @click.option("--real", help="Actually send DMs.", is_flag=True) 333 | def twitter_blast(action, real): 334 | """ 335 | Mass DM tool for Twitter to convert followers to another platform 336 | """ 337 | if action == "fetch": 338 | handle_fetch() 339 | elif action == "preview": 340 | handle_preview() 341 | elif action == "send": 342 | handle_send(real) 343 | elif action == "reset": 344 | handle_reset(username) 345 | elif action == "delete_keys": 346 | handle_delete_keys() 347 | 348 | 349 | if __name__ == "__main__": 350 | from secrets import CONSUMER_KEY, CONSUMER_SECRET 351 | 352 | api = tweepy.API(auth()) 353 | username = api.me().screen_name 354 | print("Logged in as: " + username) 355 | twitter_blast() 356 | --------------------------------------------------------------------------------