diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8dd5aaf --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +.*.swp +/schedule.csv +/credentials.sh +/log/* +/venv diff --git a/credentials.sh.example b/credentials.sh.example new file mode 100644 index 0000000..5dacd11 --- /dev/null +++ b/credentials.sh.example @@ -0,0 +1,9 @@ +export TWITTER_HANDLE="MyScreenName" + +# Go to https://apps.twitter.com/ and create a new app with read/write +# access, then generate a user access token. + +export API_KEY='this comes from your app' +export API_SECRET='this comes from your app' +export ACCESS_TOKEN='this is for your user' +export ACCESS_TOKEN_SECRET='this is for your user' diff --git a/main.py b/main.py new file mode 100755 index 0000000..884ddfc --- /dev/null +++ b/main.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python + +import csv +import datetime +import codecs +import sys + +from os.path import dirname, join as pjoin + +from twitter_follow_bot import auto_follow + + +def job_valid_now(job): + start = parse_date(job['start_date']) + end = parse_date(job['end_date']) + return start <= datetime.date.today() <= end + + +def parse_date(date_string): + return datetime.datetime.strptime( + date_string, '%Y-%m-%d').date() + + +def run_job(job): + print("Running {}".format(job)) + + auto_follow_query = job.get('auto_follow') + if auto_follow_query is not None: + auto_follow(auto_follow_query) + + +def main(): + with open(pjoin(dirname(__file__), 'schedule.csv'), 'r') as f: + for row in csv.DictReader(f): + if job_valid_now(row): + run_job(row) + +if __name__ == '__main__': + sys.stdout = codecs.getwriter('utf-8')(sys.stdout) + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c7ce879 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +twitter==1.15.0 +pytz==2014.10 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..e1a629f --- /dev/null +++ b/run.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +THIS_SCRIPT=$(readlink -f $0) +THIS_DIR=$(dirname ${THIS_SCRIPT}) + +DATE_NOW=$(date +%Y-%m-%d_%H-%M-%S) +LOG_DIR=${THIS_DIR}/log + +function setup_log_output { + mkdir -p ${LOG_DIR} + LOG_FILE=${LOG_DIR}/${DATE_NOW}.log + ln -sf ${LOG_FILE} ${LOG_DIR}/latest +} + +function setup_virtualenv { + VENV_DIR=${THIS_DIR}/venv + + if [ ! -d "${VENV_DIR}" ]; then + if [ -s "${THIS_DIR}/.python_version" ]; then + virtualenv ${VENV_DIR} -p "$(cat ${THIS_DIR}/.python_version)" >> ${LOG_FILE} + else + virtualenv ${VENV_DIR} >> ${LOG_FILE} + fi + fi + source ${THIS_DIR}/venv/bin/activate +} + +function install_dependencies { + pip install -r ${THIS_DIR}/requirements.txt >> ${LOG_FILE} +} + +function source_settings_and_credentials { + for ENV_FILE in settings.sh credentials.sh + do + if [ -s "${THIS_DIR}/${ENV_FILE}" ]; then + source "${THIS_DIR}/${ENV_FILE}" + fi + done +} + +function delete_old_logs { + find ${LOG_DIR} -type f -iname '*.log' -mtime +30 -delete +} + +function run_main_code { + export PYTHONIOENCODING="utf-8" + command=${THIS_DIR}/main.py + # Line buffering, see http://unix.stackexchange.com/a/25378 + stdbuf -oL -eL run-one ${command} >> ${LOG_FILE} 2>&1 + RETCODE=$? + if [ ${RETCODE} != 0 ]; then + echo "$@ exited with code: ${RETCODE}" + git remote -v + tail -v -n 100 ${LOG_FILE} + exit 2 + fi + + grep -n '^ERROR:' ${LOG_FILE} +} + +setup_log_output +setup_virtualenv +install_dependencies +source_settings_and_credentials +run_main_code +delete_old_logs diff --git a/schedule.csv.example b/schedule.csv.example new file mode 100644 index 0000000..caa74b3 --- /dev/null +++ b/schedule.csv.example @@ -0,0 +1,2 @@ +start_date,end_date,auto_follow +2014-09-03,2014-09-04,"@SomeEvent excited" diff --git a/twitter_follow_bot.py b/twitter_follow_bot.py index 53c8e33..605371b 100644 --- a/twitter_follow_bot.py +++ b/twitter_follow_bot.py @@ -5,38 +5,138 @@ The Twitter Follow Bot library is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the -Free Software Foundation, either version 3 of the License, or (at your option) any -later version. +Free Software Foundation, either version 3 of the License, or (at your option) +any later version. -The Twitter Follow Bot library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +The Twitter Follow Bot library is distributed in the hope that it will be +useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public +License for more details. -You should have received a copy of the GNU General Public License along with the Twitter -Follow Bot library. If not, see http://www.gnu.org/licenses/. +You should have received a copy of the GNU General Public License along with +the Twitter Follow Bot library. If not, see http://www.gnu.org/licenses/. """ +from __future__ import unicode_literals + +import csv +import datetime + +from collections import OrderedDict + from twitter import Twitter, OAuth, TwitterHTTPError import os +from os.path import dirname, join as pjoin + +import logging +logger = logging.getLogger(__name__) + + +API_KEY = os.environ['API_KEY'] +API_SECRET = os.environ['API_SECRET'] +ACCESS_TOKEN = os.environ['ACCESS_TOKEN'] +ACCESS_TOKEN_SECRET = os.environ['ACCESS_TOKEN_SECRET'] +TWITTER_HANDLE = os.environ['TWITTER_HANDLE'] -# put your tokens, keys, secrets, and Twitter handle in the following variables -OAUTH_TOKEN = "" -OAUTH_SECRET = "" -CONSUMER_KEY = "" -CONSUMER_SECRET = "" -TWITTER_HANDLE = "" +DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ' -# put the full path and file name of the file you want to store your "already followed" -# list in +# put the full path and file name of the file you want to store your "already +# followed" list in ALREADY_FOLLOWED_FILE = "already-followed.csv" -t = Twitter(auth=OAuth(OAUTH_TOKEN, OAUTH_SECRET, - CONSUMER_KEY, CONSUMER_SECRET)) +t = Twitter(auth=OAuth(ACCESS_TOKEN, ACCESS_TOKEN_SECRET, + API_KEY, API_SECRET)) + + +class FollowLog(object): + + FOLLOW_LOG = pjoin(dirname(__file__), 'follow_log.csv') + FOLLOW_LOG_FIELDS = ('twitter_id', 'screen_name', 'follow_datetime', + 'unfollow_datetime', 'follow_reason') + + def __init__(self): + self._following = OrderedDict() + + @staticmethod + def _empty_row(twitter_id): + row = {k: None for k in FollowLog.FOLLOW_LOG_FIELDS} + row['twitter_id'] = twitter_id + return row + + @staticmethod + def _serialize_row(row): + row['follow_datetime'] = row['follow_datetime'].strftime( + DATETIME_FORMAT) + return row + + @staticmethod + def _deserialize_row(row): + row['follow_datetime'] = datetime.datetime.strptime( + row['follow_datetime'], DATETIME_FORMAT) + return row + + def __enter__(self): + self._load_from_csv() + return self + + def __exit__(self, exception_type, exception_value, traceback): + if exception_type is None: + self._save_to_csv() + + def _load_from_csv(self): + if not os.path.exists(self.FOLLOW_LOG): + self._following = OrderedDict() + return + + with open(self.FOLLOW_LOG, 'r') as f: + self._following = OrderedDict( + [(int(row['twitter_id']), self._deserialize_row(row)) + for row in csv.DictReader(f)]) + + def _save_to_csv(self): + tmp_filename = self.FOLLOW_LOG + '.tmp' + with open(tmp_filename, 'w') as f: + writer = csv.DictWriter(f, self.FOLLOW_LOG_FIELDS) + writer.writeheader() + for twitter_id, row in self._following.items(): + row['twitter_id'] = twitter_id + writer.writerow(self._serialize_row(row)) + + os.rename(tmp_filename, self.FOLLOW_LOG) + + def _get_or_create(self, twitter_id): + if twitter_id not in self._following: + self._following[twitter_id] = self._empty_row(twitter_id) + return self._following[twitter_id] + + def save_follow(self, twitter_id, reason=None): + entry = self._get_or_create(twitter_id) + + entry['follow_datetime'] = datetime.datetime.now() + entry['follow_reason'] = reason + + def save_unfollow(self, twitter_id): + entry = self._get_or_create(twitter_id) + entry['unfollow_datetime'] = datetime.datetime.now() + + def have_followed_before(self, twitter_id): + entry = self._following.get(twitter_id) + if entry is None: # no record of this twitter id. + return False + + if entry['follow_datetime'] is not None: + return True + + return False + + +def get_follow_log(): + return FollowLog() def search_tweets(q, count=100, result_type="recent"): """ - Returns a list of tweets matching a certain phrase (hashtag, word, etc.) + Returns a list of tweets matching a certain phrase (hashtag, word, etc.) """ return t.search.tweets(q=q, result_type=result_type, count=count) @@ -90,41 +190,25 @@ def auto_follow(q, count=100, result_type="recent"): """ result = search_tweets(q, count, result_type) - following = set(t.friends.ids(screen_name=TWITTER_HANDLE)["ids"]) - - # make sure the "already followed" file exists - if not os.path.isfile(ALREADY_FOLLOWED_FILE): - with open(ALREADY_FOLLOWED_FILE, "w") as out_file: - out_file.write("") - - # read in the list of user IDs that the bot has already followed in the - # past - do_not_follow = set() - dnf_list = [] - with open(ALREADY_FOLLOWED_FILE) as in_file: - for line in in_file: - dnf_list.append(int(line)) - do_not_follow.update(set(dnf_list)) - del dnf_list + to_follow = set() for tweet in result["statuses"]: - try: - if (tweet["user"]["screen_name"] != TWITTER_HANDLE and - tweet["user"]["id"] not in following and - tweet["user"]["id"] not in do_not_follow): + if tweet["user"]["screen_name"] == TWITTER_HANDLE: + continue + print('@{}:\n{}\n'.format( + tweet['user']['screen_name'], + tweet['text'])) + to_follow.add(tweet['user']['id']) - t.friendships.create(user_id=tweet["user"]["id"], follow=True) - following.update(set([tweet["user"]["id"]])) + already_following = set(t.friends.ids(screen_name=TWITTER_HANDLE)["ids"]) - print("followed %s" % (tweet["user"]["screen_name"])) - - except TwitterHTTPError as e: - print("error: %s" % (str(e))) - - # quit on error unless it's because someone blocked me - if "blocked" not in str(e).lower(): - quit() + to_follow -= already_following + print("Following {} users".format(len(to_follow))) + with get_follow_log() as follow_log: + for twitter_id in to_follow: + _follow(follow_log, twitter_id, + 'Tweet: `{}`'.format(tweet['text'])) def auto_follow_followers(): @@ -181,3 +265,18 @@ def auto_unfollow_nonfollowers(): if user_id not in users_keep_following: t.friendships.destroy(user_id=user_id) print("unfollowed %d" % (user_id)) + + +def _follow(follow_log, twitter_id, reason=None): + print(twitter_id) + try: + t.friendships.create(user_id=twitter_id, follow=True) + except TwitterHTTPError as e: + if 'blocked' in str(e).lower(): # ignore block errors + # details: {"errors":[{"code":162, + # "message":"You have been blocked from following this account + # at the request of the user."}]} + logging.info('Ignoring "blocked" exception') + logging.exception(e) + else: + follow_log.save_follow(twitter_id, reason)