├── 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 |
20 | {{ message }}
21 |
24 |
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 |
14 | {% else %}
15 |
Logged in as: {{username}}
16 |
17 | {% endif %}
18 |
19 |
20 |
How it works
21 |
22 | - Authenticate with Twitter
23 | - Choose how you'd like to prioritize your followers
24 | - Send out your message!
25 |
26 |
27 |
28 |
29 |
30 | {% if username %}
31 |
32 |
33 |
34 |
Send mass DM
35 |
Rank Followers by:
36 |
65 |
66 |
67 | {% if followers %}
68 |
Your Followers(ranked by {{rank_by}})
69 |
72 |
73 |
74 |
75 | | # |
76 | Username |
77 | Followers |
78 | Following |
79 |
80 |
81 |
82 | {% for follower in followers %}
83 |
84 | | {{loop.index}} |
85 | {{follower.screen_name}} |
86 | {{follower.followers_count}} |
87 | {{follower.friends_count}} |
88 |
89 | {% endfor %}
90 |
91 |
92 | {% elif dms_all_sent%}
93 |
All your followers have been sent DMs already :(
94 |
97 | {% elif has_fetched_followers %}
98 |
No followers matched your criteria
99 | {% else %}
100 |
You haven't fetched any followers yet!
101 |
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 | [](https://buildkite.com/drizzle/twitter-blast)
8 | [](https://codecov.io/gh/drizzleco/twitter-blast)
9 |
10 | ### CLI Version
11 |
12 | 
13 |
14 | ### Flask Version
15 |
16 | 
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 |
--------------------------------------------------------------------------------