Generating a Daily Pokedex Entry
During New Year’s Eve my unofficially-adopted-brother Ethan told me that he was sad because he wasn’t going to have a Pokémon daily calendar in 2019. To me, that sounded like a problem technology could solve. Sure, I couldn’t give him a fancy flip calendar for 2019 but I could send him a text and email every day!
The Plan
The code to send the daily Pokédex entry didn’t need to be ‘production ready’ and to save time and cost I wanted to avoid the complexities of spinning up a cloud server and running a database because the code only needed to run for a few seconds a day. To me it sounded like the perfect job for an AWS Lambda function.
The rough steps I needed to achieve looked something like this:
- Find a list of all Pokémon and related information
- Put the Pokémon and their information in a CSV file
- Shuffle the CSV and give each row a date starting 2019-01-01
- Set up an AWS Lambda function to run once per day
- Write the Lambda function to read a Pokémon from the CSV and send an SMS using Twilio and an email using SendGrid
Creating the CSV
Finding a list of Pokémon in a useful structured format proved tricky but luckily after a while I stumbled across PokeAPI, a RESTful JSON API for Pokémon data!
By sending a GET request to https://pokeapi.co/api/v2/pokemon-species?limit=1000
and looking at the count
field in the returned JSON I was able to determine that there were 807 Pokémon to assign dates for. A quick check of the final Pokémon (show below) confirmed that the Pokémon ids were sequential (presumably in National Dex order) and that I was fine to move ahead with the next step.
{
"name": "zeraora",
"url": "https://pokeapi.co/api/v2/pokemon-species/807/"
}
Although in my initial plan I intended to store Pokémon data in the CSV file, after using the PokeAPI I felt it was easy enough to retrieve data on given a Pokémon’s species id that I could get away with a basic CSV file that looked like the table below. The values of both the id and date column were generating using LibreOffice Calc.
id | date |
---|---|
1 | 2019-01-01 |
2 | 2019-01-02 |
3 | 2019-01-03 |
Using this CSV format we can determine the Pokémon for a given date and then grab some relevant facts using PokeAPI. To avoid sending the Pokémon in a predictable order (namely, sequentially by Pokédex number) we shuffle the first column giving us something like this.
id | date |
---|---|
251 | 2019-01-01 |
25 | 2019-01-02 |
52 | 2019-01-03 |
Writing the AWS Lambda Handler Function
AWS Lambda and how useful it is (or not)1 is probably the subject of several blog posts on its own. It serves our needs in this situation because it allows a short function to be invoked at a regular interval 2 with a surprisingly generous free tier.
The key things our function needs to do are
-
Read the CSV and find today’s Pokémon’s id
-
Query to find information about today’s Pokémon
-
Build a string of facts to send
-
Send the SMS and email
Step 1: Read the CSV
Python comes with a CSV package built into its standard library so the only trick in this step is getting today’s date in the correct format.
import datetime
import csv
def get_pokemon_id():
today = datetime.datetime.today().strftime('%Y-%m-%d')
try:
with open('pokedex.csv') as f:
reader = csv.reader(f)
for row in reader:
if row[1] == today:
return row[0]
except:
print('Error opening pokedex.csv')
In other languages this would be written a little less explicitly but Python, by nature, is very verbose in expressing simple ideas.
Step 2: Query to Find Information About Today’s Pokémon
PokeAPI makes a distinction between a Pokémon and a Pokémon species, to allow a Pokémon to have multiple varieties that belong to the same species. To make this clearer PokeAPI kindly provides an explanation.
A Pokémon Species forms the basis for at least one Pokémon. Attributes of a Pokémon species are shared across all varieties of Pokémon within the species. A good example is Wormadam; Wormadam is the species which can be found in three different varieties, Wormadam-Trash, Wormadam-Sandy and Wormadam-Plant.
To get the full information we need to represent each Pokémon we fetch information for both the species and the specific variety of Pokémon. As each Pokémon species has a ‘default’ variety we first fetch the species and then request the default variety as indicated by the species data.
import requests
import json
BASE = 'https://pokeapi.co/api/v2/'
def get_pokemon_data(pokemon_species):
# Each species has a default variety that we'll use for height and weight
default_variety = next((v for v in pokemon_species['varieties'] if v['is_default'] is True), None)
if default_variety is not None:
r = requests.get(default_variety['pokemon']['url'])
return r.json()
return None
def get_species(id):
r = requests.get(BASE + 'pokemon-species/' + id)
return r.json()
# This will be our handler
def send():
id = get_pokemon_id()
print(f'Id is {id}')
pokemon_species = get_species(id)
pokemon = get_pokemon_data(pokemon_species)
name = pokemon_species['name']
print(f'Pokemon is {name}')
Step 3: Forming a String of Facts
Now we have both the Pokémon’s species and its specific variety we can begin to pull the information into a sentence. Our goal sentence looks something like this: “Arran! Today’s Pokémon is Weavile, the Sharp Claw Pokémon. Weavile is a black ice, and dark Pokémon which stands 1.1m tall and weighs 34kg. They live in cold regions, forming groups of four or five that hunt prey with impressive coordination.” We also want to label legendary Pokémon as being legendary.
import os
import random
LEGENDARY_IDS = [144, 145, 146, 150, 151, 243, 244, 245, 249, 250, 251, 377, 378, 379, 380, 381, 382, 383, 384, 385, 386, 480, 481, 482, 483, 484, 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 638, 639, 640, 641, 642, 643, 644, 645, 646, 647, 648, 649 ]
def get_text(pokemon, species):
if pokemon is None or species is None:
raise Exception('Failed to find information for todays Pokemon id')
id = species['id']
# Get types
types = ''
type_count = len(pokemon['types'])
for i, t in enumerate(pokemon['types']):
types = types + t['type']['name']
if type_count > 1 and i < type_count - 1:
types = types + (', ' if i < len(pokemon['types']) - 2 else ', and ' )
name = (next(x for x in species['names'] if x['language']['name'] == 'en'))['name']
flavor_texts = (x for x in species['flavor_text_entries'] if x['language']['name'] == 'en')
flavor_text = random.choice(list(flavor_texts))['flavor_text'].replace('\n',' ').replace('\t', ' ')
height = pokemon['height'] * 0.1
weight = pokemon['weight'] * 0.1
is_legendary = ' legendary ' if int(id) in LEGENDARY_IDS else ''
color = species['color']['name']
genus = (next(x for x in species['genera'] if x['language']['name'] == 'en'))['genus']
addresse = os.getenv("TO_WHOM")
text = f'{addresse}! Today\'s Pokémon is {name}, the {genus}. {name} is a {color} {types}{is_legendary} Pokemon which stands {height:.2g}m tall and weighs {weight:.2g}kg. {flavor_text}'
text = ' '.join(text.split()) # single space it all
return text
# This will be our handler
def send():
id = get_pokemon_id()
print(f'Id is {id}')
pokemon_species = get_species(id)
pokemon = get_pokemon_data(pokemon_species)
name = pokemon_species['name']
print(f'Pokemon is {name}')
text = get_text(pokemon, pokemon_species)
print(text)
The first thing we do is construct a sentence-like representation of the Pokémon’s types. Then we select the English representation of the Pokémon’s name and randomly choose a flavour text from the available choices. We also convert the height and weight into kilograms, select the English representation of the Pokémon’s genus, and fetch the name of the recipient from an environment variable.
Then we format the information into one long string, making sure to use sensible precision for the decimal values of height and weight, and strip out any double spaces and new lines.
Step 4: Sending
Sending is actually the easy part of this problem. Twilio and SendGrid provide Python libraries to make the process simple. We just need to add our API keys as environment variables to keep them secret, load them into our program, and then use them to send the text we’ve already produced. We can also store the destination email address and phone number as environment variables to keep them independent of our code.
import os
from twilio.rest import Client
def send_sms(text):
# Your Account SID from twilio.com/console
account_sid = os.getenv("TWILIO_SID")
# Your Auth Token from twilio.com/console
auth_token = os.getenv("TWILIO_AUTH_TOKEN")
client = Client(account_sid, auth_token)
client.messages.create(
to=os.getenv("TO_NUMBER"),
from_=os.getenv("TWILIO_FROM_NUMBER"),
body=text)
Sending the SMS is pretty straightforward. To send the email I chose to also send a picture of the Pokémon with the message.
import os
import sendgrid
from sendgrid.helpers.mail import *
def send_email(text, image_url):
# https://sendgrid.com/docs/ui/account-and-settings/api-keys/
sg = sendgrid.SendGridAPIClient(apikey=os.environ.get('SENDGRID_API_KEY'))
from_email = Email(os.environ.get('FROM_EMAIL'))
to_email = Email(os.environ.get('TO_EMAIL'))
today_long = datetime.datetime.today().strftime('%A %d %B %Y')
subject = f"Your Daily Pokemon for {today_long}"
content = Content("text/plain", text)
mail = Mail(from_email, subject, to_email, content)
mail.add_content(Content("text/html", f"<img src=\"{image_url}\"\> <br>" + text))
sg.client.mail.send.post(request_body=mail.get())
# This is our handler function
def send():
id = get_pokemon_id()
print(f'Id is {id}')
pokemon_species = get_species(id)
pokemon = get_pokemon_data(pokemon_species)
name = pokemon_species['name']
print(f'Pokemon is {name}')
text = get_text(pokemon, pokemon_species)
print(text)
sprite_url = pokemon['sprites']['front_default'] if random.random() < 0.9 else pokemon['sprites']['front_shiny']
send_email(text, sprite_url)
send_sms(text)
The picture that’s sent with the email has 10% chance of being a shiny pokémon, a treat that should occur a little over once per fortnight!
You can find the complete code on GitHub at arranf/DailyPokedex.
Deploying
As our function depends on the SendGrid and Twilio SDKs as well as requests we have to produce a deployment package, a bit of a pain in the ass. To do this repeatedly I created a script I could use from the root of repository whilst using the virtual environment.
rm function.zip
zip -r9 function.zip ./v-env/lib/python3.6/site-packages
zip -g function.zip function.py pokedex.csv