Posting to Twitter with a Python Program
Twitter and Python
I will use the excellent Twython wrapper for the Twitter API. It works with Python 2.6+ and Python 3.
I started by testing the tweet_schedule.py
program.
It doesn't run with Python 3,
it does some things I don't particularly want,
and it doesn't do some things that I do want.
But it was a good starting point.
That program was useful for testing, and to figure out how Twython works. And it served as the model for what became my first real Python program!
I reimplemented the project in Python 3, with significant changes to the logic. My version supports multiple Twitter identities. In a single run it checks the to-do lists for all identities, posting plain text messages as well as images with accompanying text.
Installing the Twython Module
TwythonDownload Twython from GitHub and install it. Something like this:
% mkdir ~/twitter % cd ~/twitter % git clone git://github.com/ryanmcgrath/twython.git % cd twython % sudo python setup.py install
These fragments of Python show how it's used:
from twython import Twython [...] twitter = Twython(AppKey, AppSecret, OauthToken, OauthTokenSecret) twitter.update_status(status='I posted this with #Python!')
Multiple Identities
I am maintaining just two Twitter identities right now. My main Twitter identity is @ToiletGuru, a mix of travel and Linux and cybersecurity, plus experiments with promoting a site that's a mix of travel and plumbing. The other is @conan_sysadmin, more strictly limited to Linux and networking and cybersecurity.
My program simply reads multiple schedule files, one for each identity. If it's time for one of them to post a message, the program selects the appropriate authentication data for that identity.
The schedule files are one line per record with "@" as the delimiter: date and time, then the attached image if any, then the content. The content can itself contain an "@" as in this example. Blank lines are ignored, lines with "#" as their first non-whitespace character are ignored, and white space at the beginning and end of each field will be removed.
$ more ~/twitter/scheduled-tweets-ToiletGuru # Technical updates 2021-06-02 21:34@@Working on a #Python program https://cromwell-intl.com/open-source/python-twitter-automation/python-program.html # Travel promotion 2021-05-30 12:02@@Just one week until the anniversary of #DDay https://cromwell-intl.com/travel/france/normandy/ # Timed promotion for toilet-guru.com 2021-09-21 12:02@@Just one week until Thomas Crapper's birthday! https://toilet-guru.com/thomas-crapper.php 2021-09-28 12:02@@It's Thomas Crapper's birthday! @ThomasCrapper https://toilet-guru.com/thomas-crapper.php $ more ~/twitter/scheduled-tweets-Conan 2021-01-22 08:41 @ robert-howard.jpg @ By Crom, 'tis Robert E. Howard's birthday!
Environment Variables and Locale
I tried running an early version of my program with a data file that contained non-ASCII data. Could it handle something like this:
Visit Sainte-Mère-Église and Новосибирск.
No. It choked on the non-ASCII "è" and "É" before getting close to the Cyrillic. It failed with variations of the following error message:
Traceback (most recent call last):
File "./tweeter.py", line 146, in <module>
print('tweet = '+tweet)
UnicodeEncodeError: 'ascii' codec can't encode character '\xe8' in position 49: ordinal not in range(128)
It took a while to figure this out. The problem was that I actually had no environment variable LANG set to anything. The result is that things are done in ASCII. Despite an unset LANG and thus default ASCII interpretation, I could copy UTF-8 text from one window and paste it into another. So far, this situation hadn't been a problem for me.
Python was interpreting the lack of environment variable LANG as a directive to deal with data as ASCII. That fails as soon as it hits the first non-ASCII character.
The fix was to set the environment variable LANG
appropriately in my shell startup file.
I also explicitly set LC_ALL,
and for good measure, I added an entry to
~/.vimrc:
set encoding=utf-8
For me the correct setting for environment variables
LANG and LC_ALL was en_US.utf8,
at least on Linux.
And that led to...
Linux versus FreeBSD
I leave at least one Linux computer running all the time when I'm at home, but I shut things down if I'm going to be away for more than a day. So, I planned to run the scheduled script on my web server.
My server runs FreeBSD. It's a Google Compute Engine instance in a data center somewhere in the Pacific Northwest. (Another page has a description of how to deploy FreeBSD in Google Cloud)
I have only noticed two differences between getting this code to work in both places.
First, Python is added as a package on FreeBSD,
so all the Python binaries are in /usr/local/bin
and not in /usr/bin as on Linux.
Second, and this is a subtle one,
the locale spelled en_US.utf8 on Linux
is en_US.UTF-8 on FreeBSD.
Upper versus lower case, and add or delete a dash.
Get that exactly right or else
things will not work as expected!
Update —
After an update, things were suddenly broken.
I eventually figured out I needed another environment
variable.
PYTHONIOENCODING needed to be set to
utf8.
I set that in my shell setup file and within the program. Here are some fragments of the Python program:
import locale [...] locale.setlocale(locale.LC_ALL, 'en_US.UTF-8')
Time Zones
My server keeps time in UTC. It resides three time zones away from my home, and sometimes I'm somewhere else, so why not UTC?
However, I want to specify tweet times in my home time zone. It's the east coast of North America, so whether I'm looking to communicate with friends or draw people to a web site, that time zone makes sense to me. So:
import locale import datetime import time [...] # Server runs on UTC, specify posts in EST/EDT: os.environ['TZ'] = 'America/New_York' time.tzset()
After the time.tzset() call,
datetime.datetime.now()
returns the time as it is in New York.
The Program
After importing the needed modules I define two functions.
checkTime() compares the current time to a
specified time.
It returns True if they match
and False otherwise.
tweetIt() is passed the identity name,
a string with the status text,
and a string that either names an image file to attach
or is empty.
It then uses Twython to upload the data to Twitter.
The main code then directs output into a log file, specifies where to find input data files, and sets the locale and time zone. The main loop, as simplified pseudo code, is:
import the modules
define the functions
for each identity {
read that identity's data file
for each line {
split the line into date/time, attachment, and text
if checkTime() says date/time is right now {
tweetIt()
}
}
}
Here is the full program.
#!/usr/local/bin/python3.6
import os
import sys
import locale
import datetime
import time
import re
import traceback
from twython import Twython
def tweetIt(identity, tweetText, tweetAttach):
# Yes, of course these have been replaced with random data!
if identity == 'ToiletGuru' :
AppKey='TW9uIEp1biAxMSAyMzowMTo1N'
AppSecret='I9SfhXcINLF4XAp9ku1UMvAgqaDnL69Y1rvOPooXxtcPwBOnqn'
OauthToken='2170513145-Bs4XDay16WfnRWxMkEZgx1Ap1Mg3caCqSfKiY7Z'
OauthTokenSecret='MLGTHDBoFbVG521uUwbSiLRrwwns+R9c8aKXeaWFCAdYg'
elif identity == 'Conan':
AppKey='8ohHU22fHb297Y2iyI986ZSN1'
AppSecret='0j7107KRDd9WZ7MsyVUQABt4RYshZYaWaL0pqsjZzbrLZ7bef6'
OauthToken='157304025713067307-jpULUBKhy8jbj8bGoJiTlbhr1NJm2YQ'
OauthTokenSecret='uEZt10giZJnN5ZGWQSRiRSvmgBLxv6I2ygt2xrfxpA0TB'
else:
AppKey='bogus'
AppSecret='bogus'
OauthToken='bogus'
OauthTokenSecret='bogus'
twitter = Twython(AppKey, AppSecret, OauthToken, OauthTokenSecret)
try:
# Just text:
if tweetAttach == '':
twitter.update_status(status=tweetText)
print(' Successfully tweeted!')
print(' '+tweetText)
# Or with an attachment:
else:
# twitter.update_status() insists on a non-empty status.
if tweetText == '':
tweetText = ' '
completeAttPath = attachDirectory+tweetAttach
attachment = open(completeAttPath, 'rb')
response = twitter.upload_media(media=attachment)
twitter.update_status(status=tweetText, media_ids=[response['media_id']])
attachment.close()
print(' Successfully tweeted!')
print(' content: '+tweetText)
print(' attached: '+tweetAttach)
except:
print('Error: Could not be tweeted.')
print(' content: '+tweetText)
print(' attached: '+tweetAttach)
print('Traceback:\n'+traceback.format_exc())
return
# Check time specification in the form "yyyy-mm-dd hh:mm"
def checkTime(nowTime, tweetTime):
# Does the current time match the specified time?
timeIsRight = False
targetDate, targetTime = tweetTime.split(" ")
# Is the request in the proper format?
if re.match('^[0-9]{4}-[0-9]{2}-[0-9]{2}$',targetDate) and re.match('^[0-9]{1,2}:[0-9]{2}$',targetTime):
# Is it the right day?
if nowTime.strftime("%Y-%m-%d") == targetDate:
# If so, is it the correct time?
if nowTime.strftime("%H:%M") == targetTime:
timeIsRight = True
else:
print(tweetTime+' -> Wrong date/time format!')
return timeIsRight
###############################################################################
# Main program starts here
# All cron job output is mailed to the owner unless redirected.
# Uncomment these two lines to append all output to a log file:
sys.stdout = open('/tmp/tweeter.log', 'a')
sys.stderr = open('/tmp/tweeter.log', 'a')
# Uncomment this line to discard standard output:
# sys.stdout = open('/dev/null', 'w')
# Data locations
dataDirectory = '/home/cromwell/twitter/'
attachDirectory = '/home/cromwell/twitter/attach/'
# file will be this base plus Twitter identity name
tweetFileBase = 'scheduled-tweets-'
# locale = en_US.UTF-8 FreeBSD
# en_US.utf8 Linux
locale.setlocale(locale.LC_ALL, 'en_US.UTF-8')
# Server runs on UTC, specify posts in EST/EDT:
os.environ['TZ'] = 'America/New_York'
time.tzset()
nowTime = datetime.datetime.now()
for identity in [ "ToiletGuru", "Conan" ] :
tweetFile = tweetFileBase+identity
# Read list of scheduled tweets
with open(dataDirectory+tweetFile, 'r') as tweetList:
for tweet in tweetList:
# Remove any leading/trailing white space.
tweet = tweet.strip()
# Blank lines result in empty strings, silently continue.
# Also skip any lines whose first non-blank character is "#".
if tweet != '' and not re.match('^#.*', tweet) :
try:
# Each line should be the following, attachment is optional:
# time@attachment@content
# in format:
# yyyy-mm-dd hh:mm@[attachment.jpg]@This message includes "@".
tweetTime, tweetAttach, tweetText = re.split('@', tweet, 2)
# Remove any leading/trailing white space.
tweetTime = tweetTime.strip()
tweetAttach = tweetAttach.strip()
tweetText = tweetText.strip()
if len(tweetText) > 280:
print('Tweet is too long:')
print(' '+tweetText)
elif tweetText == '' and tweetAttach == '':
print('Refusing to post empty tweet.')
elif checkTime(nowTime, tweetTime):
print('Trying to post for '+identity+' at '+tweetTime)
tweetIt(identity, tweetText, tweetAttach)
except ValueError:
print('Incorrect line format.')
print(' Line needs two "@" delimiters in the line, more can be in text:')
print(' yyyy-mm-dd hh:mm@[attachment.jpg]@This message includes "@".')
print('I found:')
print(' '+tweet)
pass
Scheduling With cron
I tell the cron daemon to run the program
every minute.
Note that you must set any needed environment variables
within the crontab file.
% crontab -l # Must set environment variable(s) LANG = en_US.UTF-8 PYTHONIOENCODING = utf8 # min hr day-of-month month day-of-week command * * * * * /home/cromwell/twitter/tweeter.py
It Works!
Now you can easily schedule tweets with a very simple interface. No need to struggle with the tedious point and click scheduling on TweetDeck.