├── requirements.txt ├── threader ├── __init__.py └── thread.py ├── pyproject.toml ├── LICENSE ├── README.md ├── setup.py ├── .gitignore └── demo.ipynb /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | twitter 3 | tqdm -------------------------------------------------------------------------------- /threader/__init__.py: -------------------------------------------------------------------------------- 1 | """Tools to quickly create twitter threads.""" 2 | from .thread import Threader 3 | 4 | __version__ = "0.1.1" -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit"] 3 | build-backend = "flit.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "threader" 7 | author = "Chris Holdgraf" 8 | author-email = "choldgraf@berkeley.edu" 9 | home-page = "https://github.com/choldgraf/threader" 10 | classifiers = ["License :: OSI Approved :: MIT License"] 11 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Chris Holdgraf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # threader 2 | Easy Twitter threads with Python. 3 | 4 | ## Installation 5 | 6 | You can install threader with `pip`: 7 | 8 | `pip install threader` 9 | 10 | Alternatively, clone this repository to your computer and then run either: 11 | 12 | `python setup.py install` 13 | 14 | or run 15 | 16 | `pip install -e path/to/cloned/folder` 17 | 18 | ## Usage 19 | 20 | Threader basically does one thing, illustrated by the following: 21 | 22 | ```python 23 | from TwitterAPI import TwitterAPI 24 | from threader import Threader 25 | 26 | keys = dict(consumer_key='XXX', 27 | consumer_secret='XXX', 28 | access_token_key='XXX', 29 | access_token_secret='XXX') 30 | api = TwitterAPI(**keys) 31 | 32 | tweets = ["Chris is testing a nifty little tool he made...", 33 | "It's for making it easier for him to thread tweets", 34 | "He heard that the real twitter power users all thread their tweets like pros", 35 | "but he also likes python, and automating things", 36 | "sometimes with unnecessary complexity...", 37 | "so let's see if this works :-D"] 38 | th = Threader(tweets, api, wait=2) 39 | th.send_tweets() 40 | ``` 41 | 42 | The preceding code resulted in the following twitter thread: 43 | 44 | https://twitter.com/choldgraf/status/979755644545777664 45 | 46 | Enjoy! 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # 3 | # Copyright (C) 2015 Chris Holdgraf 4 | # 5 | # 6 | # Adapted from MNE-Python 7 | 8 | import os 9 | import setuptools 10 | from numpy.distutils.core import setup 11 | from threader import __version__ 12 | 13 | version = __version__ 14 | 15 | descr = """Tools to quickly create twitter threads.""" 16 | 17 | DISTNAME = 'threader' 18 | DESCRIPTION = descr 19 | MAINTAINER = 'Chris Holdgraf' 20 | MAINTAINER_EMAIL = 'choldgraf@gmail.com' 21 | URL = 'https://github.com/choldgraf/threader' 22 | LICENSE = 'BSD (3-clause)' 23 | DOWNLOAD_URL = 'https://github.com/choldgraf/threader' 24 | VERSION = version 25 | 26 | 27 | if __name__ == "__main__": 28 | if os.path.exists('MANIFEST'): 29 | os.remove('MANIFEST') 30 | 31 | setup(name=DISTNAME, 32 | maintainer=MAINTAINER, 33 | include_package_data=False, 34 | maintainer_email=MAINTAINER_EMAIL, 35 | description=DESCRIPTION, 36 | license=LICENSE, 37 | url=URL, 38 | version=VERSION, 39 | download_url=DOWNLOAD_URL, 40 | long_description=open('README.md').read(), 41 | zip_safe=False, # the package can run out of an .egg file 42 | classifiers=['Intended Audience :: Developers', 43 | 'License :: OSI Approved', 44 | 'Programming Language :: Python', 45 | 'Topic :: Software Development', 46 | 'Topic :: Scientific/Engineering', 47 | 'Operating System :: OSX'], 48 | platforms='any', 49 | packages=['threader'], 50 | package_data={}, 51 | scripts=[]) 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # Mac 104 | .DS_Store 105 | 106 | # Sandbox 107 | sandbox.ipynb 108 | -------------------------------------------------------------------------------- /threader/thread.py: -------------------------------------------------------------------------------- 1 | from TwitterAPI import TwitterAPI 2 | from tqdm import tqdm 3 | from time import sleep 4 | 5 | class Threader(object): 6 | def __init__(self, tweets, api, user=None, wait=None, max_char=280, end_string=True): 7 | """Create a thread of tweets. 8 | 9 | Note that you will need your Twitter API / Application keys for 10 | this to work. 11 | 12 | Parameters 13 | ---------- 14 | tweets : list of strings 15 | The tweets to send out 16 | api : instance of TwitterAPI 17 | An active Twitter API object using the TwitterAPI package. 18 | user : string | None 19 | A user to include in the tweets. If None, no user will be 20 | included. 21 | wait : float | None 22 | The amount of time to wait between tweets. If None, they will 23 | be sent out as soon as possible. 24 | max_char : int 25 | The maximum number of characters allowed per tweet. Threader will 26 | check each string in `tweets` before posting anything, and raise an 27 | error if any string has more characters than max_char. 28 | end_string : bool 29 | Whether to include a thread count at the end of each tweet. E.g., 30 | "4/" or "5x". 31 | """ 32 | # Check twitter API 33 | if not isinstance(api, TwitterAPI): 34 | raise ValueError('api must be an instance of TwitterAPI') 35 | self.api = api 36 | 37 | # Check tweet list 38 | if not isinstance(tweets, list): 39 | raise ValueError('tweets must be a list') 40 | if not all(isinstance(it, str) for it in tweets): 41 | raise ValueError('all items in `tweets` must be a string') 42 | if len(tweets) < 2: 43 | raise ValueError('you must pass two or more tweets') 44 | 45 | # Other params 46 | self.user = user 47 | self.wait = wait 48 | self.sent = False 49 | self.end_string = end_string 50 | self.max_char = max_char 51 | 52 | # Construct our tweets 53 | self.generate_tweets(tweets) 54 | 55 | # Check user existence 56 | if isinstance(user, str): 57 | self._check_user(user) 58 | 59 | def _check_user(self, user): 60 | if user is not None: 61 | print('Warning: including users in threaded tweets can get your ' 62 | 'API token banned. Use at your own risk!') 63 | resp = self.api.request('users/lookup', params={'screen_name': user}) 64 | 65 | if not isinstance(resp.json(), list): 66 | err = resp.json().get('errors', None) 67 | if err is not None: 68 | raise ValueError('Error in finding username: {}\nError: {}'.format(user, err[0])) 69 | 70 | def generate_tweets(self, tweets): 71 | # Set up user ID to which we'll tweet 72 | user = '@{} '.format(self.user) if isinstance(self.user, str) else '' 73 | 74 | # Add end threading strings if specified 75 | self._tweets_orig = tweets 76 | self.tweets = [] 77 | for ii, tweet in enumerate(tweets): 78 | this_status = '{}{}'.format(user, tweet) 79 | if self.end_string is True: 80 | thread_char = '/' if (ii+1) != len(tweets) else 'x' 81 | end_str = '{}{}'.format(ii + 1, thread_char) 82 | this_status += ' {}'.format(end_str) 83 | else: 84 | this_status = tweet 85 | self.tweets.append(this_status) 86 | 87 | if not all(len(tweet) < int(self.max_char) for tweet in self.tweets): 88 | raise ValueError("Not all tweets are less than {} characters".format(int(self.max_char))) 89 | 90 | def send_tweets(self): 91 | """Send the queued tweets to twitter.""" 92 | if self.sent is True: 93 | raise ValueError('Already sent tweets, re-create object in order to send more.') 94 | self.tweet_ids_ = [] 95 | self.responses_ = [] 96 | self.params_ = [] 97 | 98 | # Now generate the tweets 99 | for ii, tweet in tqdm(enumerate(self.tweets)): 100 | # Create tweet and add metadata 101 | params = {'status': tweet} 102 | if len(self.tweet_ids_) > 0: 103 | params['in_reply_to_status_id'] = self.tweet_ids_[-1] 104 | 105 | # Send POST and get response 106 | resp = self.api.request('statuses/update', params=params) 107 | if 'errors' in resp.json().keys(): 108 | raise ValueError('Error in posting tweets:\n{}'.format( 109 | resp.json()['errors'][0])) 110 | self.responses_.append(resp) 111 | self.params_.append(params) 112 | self.tweet_ids_.append(resp.json()['id']) 113 | if isinstance(self.wait, (float, int)): 114 | sleep(self.wait) 115 | self.sent = True 116 | 117 | def __repr__(self): 118 | s = ['Threader'] 119 | s += ['Tweets', '------'] 120 | for tweet in self.tweets: 121 | s += [tweet] 122 | s = '\n'.join(s) 123 | return s 124 | -------------------------------------------------------------------------------- /demo.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "This package uses the [Twitter POST api](https://dev.twitter.com/rest/reference/post/statuses/update) in order to schedule / send a series of threaded tweets that refer to one another so that they form a \"chain\".\n", 8 | "\n", 9 | "Here's an example of how to use it:" 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": 1, 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "from TwitterAPI import TwitterAPI\n", 19 | "from threader import Threader\n", 20 | "import os" 21 | ] 22 | }, 23 | { 24 | "cell_type": "markdown", 25 | "metadata": {}, 26 | "source": [ 27 | "### Set up the Twitter API\n", 28 | "\n", 29 | "In order to post to Twitter from Python, you'll need access\n", 30 | "tokens for the Twitter API. To create these, check out\n", 31 | "https://developer.twitter.com/en/docs/basics/authentication/guides/single-user." 32 | ] 33 | }, 34 | { 35 | "cell_type": "code", 36 | "execution_count": 2, 37 | "metadata": {}, 38 | "outputs": [], 39 | "source": [ 40 | "keys = dict(consumer_key=os.environ['TWITTER_KEY_HLDGRF'],\n", 41 | " consumer_secret=os.environ['TWITTER_SECRET_HLDGRF'],\n", 42 | " access_token_key=os.environ['TWITTER_ACCESS_TOKEN_HLDGRF'],\n", 43 | " access_token_secret=os.environ['TWITTER_AT_SECRET_HLDGRF'])\n", 44 | "api = TwitterAPI(**keys)" 45 | ] 46 | }, 47 | { 48 | "cell_type": "markdown", 49 | "metadata": {}, 50 | "source": [ 51 | "### Construct our tweets\n", 52 | "\n", 53 | "Threader will automatically add \"end\" characters (if desired). You can check what the tweets will look like\n", 54 | "once they're posted before actually sending them to\n", 55 | "Twitter." 56 | ] 57 | }, 58 | { 59 | "cell_type": "code", 60 | "execution_count": 5, 61 | "metadata": {}, 62 | "outputs": [ 63 | { 64 | "data": { 65 | "text/plain": [ 66 | "Threader\n", 67 | "Tweets\n", 68 | "------\n", 69 | "OK this should work now1 1/\n", 70 | "does it work?! is it threaded?!1 2/\n", 71 | "maybe........1 3/\n", 72 | "fingers crossed!1 4x" 73 | ] 74 | }, 75 | "execution_count": 5, 76 | "metadata": {}, 77 | "output_type": "execute_result" 78 | } 79 | ], 80 | "source": [ 81 | "username = None\n", 82 | "tweets = [\"OK this should work now\", \"does it work?! is it threaded?!\", \"maybe........\", \"fingers crossed!\"]\n", 83 | "tweets = [ii + '1' for ii in tweets]\n", 84 | "th = Threader(tweets, api, wait=1, user=username)\n", 85 | "th" 86 | ] 87 | }, 88 | { 89 | "cell_type": "markdown", 90 | "metadata": {}, 91 | "source": [ 92 | "Now let's send them off!" 93 | ] 94 | }, 95 | { 96 | "cell_type": "code", 97 | "execution_count": 6, 98 | "metadata": { 99 | "collapsed": true 100 | }, 101 | "outputs": [ 102 | { 103 | "name": "stderr", 104 | "output_type": "stream", 105 | "text": [ 106 | "4it [00:10, 2.59s/it]\n" 107 | ] 108 | } 109 | ], 110 | "source": [ 111 | "th.send_tweets()" 112 | ] 113 | }, 114 | { 115 | "cell_type": "markdown", 116 | "metadata": {}, 117 | "source": [ 118 | "### If your tweets are too long, Threader won't send them!" 119 | ] 120 | }, 121 | { 122 | "cell_type": "code", 123 | "execution_count": 7, 124 | "metadata": {}, 125 | "outputs": [ 126 | { 127 | "ename": "ValueError", 128 | "evalue": "Not all tweets are less than 20 characters", 129 | "output_type": "error", 130 | "traceback": [ 131 | "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", 132 | "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", 133 | "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0musername\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 2\u001b[0m \u001b[0mtweets\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0;34m\"OK this should work now\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"does it work?! is it threaded?!\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"maybe........\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"fingers crossed!\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 3\u001b[0;31m \u001b[0mth\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mThreader\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtweets\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mapi\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0muser\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0musername\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mmax_char\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;36m20\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 4\u001b[0m \u001b[0mth\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", 134 | "\u001b[0;32m/mnt/c/Users/chold/Dropbox/github/publicRepos/threader/threader/thread.py\u001b[0m in \u001b[0;36m__init__\u001b[0;34m(self, tweets, api, user, wait, max_char, end_string)\u001b[0m\n\u001b[1;32m 51\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 52\u001b[0m \u001b[0;31m# Construct our tweets\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 53\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mgenerate_tweets\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtweets\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 54\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 55\u001b[0m \u001b[0;31m# Check user existence\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", 135 | "\u001b[0;32m/mnt/c/Users/chold/Dropbox/github/publicRepos/threader/threader/thread.py\u001b[0m in \u001b[0;36mgenerate_tweets\u001b[0;34m(self, tweets)\u001b[0m\n\u001b[1;32m 86\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 87\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0mall\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mlen\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtweet\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m<\u001b[0m \u001b[0mint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mmax_char\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mtweet\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mtweets\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 88\u001b[0;31m \u001b[0;32mraise\u001b[0m \u001b[0mValueError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"Not all tweets are less than {} characters\"\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mformat\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mmax_char\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 89\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 90\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0msend_tweets\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", 136 | "\u001b[0;31mValueError\u001b[0m: Not all tweets are less than 20 characters" 137 | ] 138 | } 139 | ], 140 | "source": [ 141 | "username = None\n", 142 | "tweets = [\"OK this should work now\", \"does it work?! is it threaded?!\", \"maybe........\", \"fingers crossed!\"]\n", 143 | "th = Threader(tweets, api, user=username, max_char=20)\n", 144 | "th" 145 | ] 146 | } 147 | ], 148 | "metadata": { 149 | "kernelspec": { 150 | "display_name": "Python 3", 151 | "language": "python", 152 | "name": "python3" 153 | }, 154 | "language_info": { 155 | "codemirror_mode": { 156 | "name": "ipython", 157 | "version": 3 158 | }, 159 | "file_extension": ".py", 160 | "mimetype": "text/x-python", 161 | "name": "python", 162 | "nbconvert_exporter": "python", 163 | "pygments_lexer": "ipython3", 164 | "version": "3.6.4" 165 | } 166 | }, 167 | "nbformat": 4, 168 | "nbformat_minor": 2 169 | } 170 | --------------------------------------------------------------------------------