Building The Star Wars Graph

Thousands of Star Wars fans have contributed to Star Wars wikis like the Wookieepedia Wiki - the open Star Wars encyclopedia that anyone can edit. This wiki collects information about all Star Wars films, including characters, planets, starships and much more. A subset of the information from Wookieepedia is available via REST API at SWAPI.co. SWAPI provides an API for fetching information about which characters appear in which films, the characters' home planets, and what starships they’ve piloted. This information is inherently a graph! So thanks to Wookieepedia and SWAPI.co we’re able to build the Star Wars Graph!

In order to build this graph we'll need to fetch data from SWAPI.co, an open REST API for Star Wars data. We'll use the Python requests package to make requests from the API (which will return JSON), we'll then use py2neo to execute Cypher queries against a Neo4j database using the JSON returned from the API as parameters for our queries. Because this is a REST API, many of the resources are returned as only URLs which we'll need to fetch later to fully populate the data in our graph. We'll use Neo4j as a type of queuing mechanism, creating relationships and nodes using the URL as a placeholder for a resource that needs to be fully hydrated later.

You can access a live read-only version of the Star Wars Graph here: http://52.2.207.222/browser/

This notebook assumes some basic knowledge of Python and some working knowledge of Neo4j. For a more general overview of Neo4j and graph databases, check out some of the other posts on my blog.

In [48]:
# import dependency packages
from py2neo import Graph    # install with `pip install py2neo`
import requests             # `pip install requests`
In [49]:
# Exploring the API
# what endpoints are available?
r = requests.get("http://swapi.co/api/")
r.json()
Out[49]:
{'films': 'http://swapi.co/api/films/',
 'people': 'http://swapi.co/api/people/',
 'planets': 'http://swapi.co/api/planets/',
 'species': 'http://swapi.co/api/species/',
 'starships': 'http://swapi.co/api/starships/',
 'vehicles': 'http://swapi.co/api/vehicles/'}

The datamodel

Based on the entities and the data we have available, this is the property graph data model that we'll be using:

Connect to Neo4j and add constraints

We'll be using the py2neo Python driver for Neo4j. We'll first connect to a running Neo4j instance and add constraints based on the data model we've defined above

In [50]:
# Connect to Neo4j instance
graph = Graph() # Use default graph

# create uniqueness constraints based on the datamodel
graph.cypher.execute("CREATE CONSTRAINT ON (f:Film) ASSERT f.url IS UNIQUE")
graph.cypher.execute("CREATE CONSTRAINT ON (p:Person) ASSERT p.url IS UNIQUE")
graph.cypher.execute("CREATE CONSTRAINT ON (v:Vehicle) ASSERT v.url IS UNIQUE")
graph.cypher.execute("CREATE CONSTRAINT ON (s:Starship) ASSERT s.url IS UNIQUE")
graph.cypher.execute("CREATE CONSTRAINT ON (p:Planet) ASSERT p.url IS UNIQUE")
Out[50]:

Inserting a single resource

Let's see how we can insert a single resource result from the SWAPI, a single Person object. First we'll look at the data returned for a Person entity, then we'll write a Cypher query that can take the JSON document returned by the API as a parameter object to insert it into our graph.

In [51]:
# Fetch a single person entity from the API
r = requests.get("http://swapi.co/api/people/1/")
params = r.json()
params
Out[51]:
{'birth_year': '19BBY',
 'created': '2014-12-09T13:50:51.644000Z',
 'edited': '2014-12-20T21:17:56.891000Z',
 'eye_color': 'blue',
 'films': ['http://swapi.co/api/films/7/',
  'http://swapi.co/api/films/6/',
  'http://swapi.co/api/films/3/',
  'http://swapi.co/api/films/2/',
  'http://swapi.co/api/films/1/'],
 'gender': 'male',
 'hair_color': 'blond',
 'height': '172',
 'homeworld': 'http://swapi.co/api/planets/1/',
 'mass': '77',
 'name': 'Luke Skywalker',
 'skin_color': 'fair',
 'species': ['http://swapi.co/api/species/1/'],
 'starships': ['http://swapi.co/api/starships/12/',
  'http://swapi.co/api/starships/22/'],
 'url': 'http://swapi.co/api/people/1/',
 'vehicles': ['http://swapi.co/api/vehicles/14/',
  'http://swapi.co/api/vehicles/30/']}
In [52]:
# Define a parameterized Cypher query to insert a Person entity into the graph
# For resources referenced in the Person entity (like homeworld and starships) we create a relationship and node
# containing only the url. This new node acts as a placeholder that we'll need to fill in later

CREATE_PERSON_QUERY = '''
MERGE (p:Person {url: {url}})
SET p.birth_year = {birth_year},
    p.created = {created},
    p.edited = {edited},
    p.eye_color = {eye_color},
    p.gender = {gender},
    p.hair_color = {hair_color},
    p.height = {height},
    p.mass = {mass},
    p.name = {name},
    p.skin_color = {skin_color}
REMOVE p:Placeholder
WITH p
MERGE (home:Planet {url: {homeworld}})
ON CREATE SET home:Placeholder
CREATE UNIQUE (home)<-[:IS_FROM]-(p)
WITH p
UNWIND {species} AS specie
MERGE (s:Species {url: specie})
ON CREATE SET s:Placeholder
CREATE UNIQUE (p)-[:IS_SPECIES]->(s)
WITH DISTINCT p
UNWIND {starships} AS starship
MERGE (s:Starship {url: starship})
ON CREATE SET s:Placeholder
CREATE UNIQUE (p)-[:PILOTS]->(s)
WITH DISTINCT p
UNWIND {vehicles} AS vehicle
MERGE (v:Vehicle {url: vehicle})
ON CREATE SET v:Placeholder
CREATE UNIQUE (p)-[:PILOTS]->(v)
'''
In [53]:
# we can execute this query using py2neo
graph.cypher.execute(CREATE_PERSON_QUERY, params)
Out[53]:

Executing this query creates a Person node for Luke Skywalker and sets properties on this node for properties returned from the API (birth_year, name, height, etc). For other entities (like home planet, species, and the starships he's piloted) the API returns only the url for these entities - we must make an additional API request to hydrate these later. Using the url for these resources as a unique id, we create nodes and relationships to these nodes.

We'll later query the graph for these incomplete nodes so that we can hydrate these entities with a request to the SWAPI. Note that when we query the API to hydrate these entities we may end up adding more nodes and relationships (in addition to just adding properties) - this allows us to build our graph by "crawling" the API, using Neo4j as a queue to store the resources to be crawled asynchronously.

Define Cypher queries

We will now look at the JSON format for each type of entity and define the Cypher query to handle updating the graph for that type of entity.

Film

The films endpoint returns information about a single film as well as arrays of characters, planets, species, starships, and vehicles that appear in the film. Note that these arrays contain only a url for the entity. We will use the Cypher UNWIND statement to iterate through the elements of these arrays, inserting a Placeholder node which we will fill-in later with an additional call to SWAPI.

In [54]:
# Fetch a single Film entity from the API
r = requests.get("http://swapi.co/api/films/1/")
params = r.json()
params
Out[54]:
{'characters': ['http://swapi.co/api/people/1/',
  'http://swapi.co/api/people/2/',
  'http://swapi.co/api/people/3/',
  'http://swapi.co/api/people/4/',
  'http://swapi.co/api/people/5/',
  'http://swapi.co/api/people/6/',
  'http://swapi.co/api/people/7/',
  'http://swapi.co/api/people/8/',
  'http://swapi.co/api/people/9/',
  'http://swapi.co/api/people/10/',
  'http://swapi.co/api/people/12/',
  'http://swapi.co/api/people/13/',
  'http://swapi.co/api/people/14/',
  'http://swapi.co/api/people/15/',
  'http://swapi.co/api/people/16/',
  'http://swapi.co/api/people/18/',
  'http://swapi.co/api/people/19/',
  'http://swapi.co/api/people/81/'],
 'created': '2014-12-10T14:23:31.880000Z',
 'director': 'George Lucas',
 'edited': '2015-04-11T09:46:52.774897Z',
 'episode_id': 4,
 'opening_crawl': "It is a period of civil war.\r\nRebel spaceships, striking\r\nfrom a hidden base, have won\r\ntheir first victory against\r\nthe evil Galactic Empire.\r\n\r\nDuring the battle, Rebel\r\nspies managed to steal secret\r\nplans to the Empire's\r\nultimate weapon, the DEATH\r\nSTAR, an armored space\r\nstation with enough power\r\nto destroy an entire planet.\r\n\r\nPursued by the Empire's\r\nsinister agents, Princess\r\nLeia races home aboard her\r\nstarship, custodian of the\r\nstolen plans that can save her\r\npeople and restore\r\nfreedom to the galaxy....",
 'planets': ['http://swapi.co/api/planets/2/',
  'http://swapi.co/api/planets/3/',
  'http://swapi.co/api/planets/1/'],
 'producer': 'Gary Kurtz, Rick McCallum',
 'release_date': '1977-05-25',
 'species': ['http://swapi.co/api/species/4/',
  'http://swapi.co/api/species/5/',
  'http://swapi.co/api/species/3/',
  'http://swapi.co/api/species/2/',
  'http://swapi.co/api/species/1/'],
 'starships': ['http://swapi.co/api/starships/2/',
  'http://swapi.co/api/starships/3/',
  'http://swapi.co/api/starships/5/',
  'http://swapi.co/api/starships/9/',
  'http://swapi.co/api/starships/10/',
  'http://swapi.co/api/starships/11/',
  'http://swapi.co/api/starships/12/',
  'http://swapi.co/api/starships/13/'],
 'title': 'A New Hope',
 'url': 'http://swapi.co/api/films/1/',
 'vehicles': ['http://swapi.co/api/vehicles/4/',
  'http://swapi.co/api/vehicles/6/',
  'http://swapi.co/api/vehicles/7/',
  'http://swapi.co/api/vehicles/8/']}
In [55]:
# Insert a given Film into the graph, including Placeholder nodes for any new entities discovered
CREATE_MOVIE_QUERY = '''
MERGE (f:Film {url: {url}})
SET f.created = {created},
    f.edited = {edited},
    f.episode_id = toInt({episode_id}),
    f.opening_crawl = {opening_crawl},
    f.release_date = {release_date},
    f.title = {title}
WITH f
UNWIND split({director}, ",") AS director
MERGE (d:Director {name: director})
CREATE UNIQUE (f)-[:DIRECTED_BY]->(d)
WITH DISTINCT f
UNWIND split({producer}, ",") AS producer
MERGE (p:Producer {name: producer})
CREATE UNIQUE (f)-[:PRODUCED_BY]->(p)
WITH DISTINCT f
UNWIND {characters} AS character
MERGE (c:Person {url: character})
ON CREATE SET c:Placeholder
CREATE UNIQUE (c)-[:APPEARS_IN]->(f)
WITH DISTINCT f
UNWIND {planets} AS planet
MERGE (p:Planet {url: planet})
ON CREATE SET p:Placeholder
CREATE UNIQUE (f)-[:TAKES_PLACE_ON]->(p)
WITH DISTINCT f
UNWIND {species} AS specie
MERGE (s:Species {url: specie})
ON CREATE SET s:Placeholder
CREATE UNIQUE (s)-[:APPEARS_IN]->(f)
WITH DISTINCT f
UNWIND {starships} AS starship
MERGE (s:Starship {url: starship})
ON CREATE SET s:Placeholder
CREATE UNIQUE (s)-[:APPEARS_IN]->(f)
WITH DISTINCT f
UNWIND {vehicles} AS vehicle
MERGE (v:Vehicle {url: vehicle})
ON CREATE SET v:Placeholder
CREATE UNIQUE (v)-[:APPEARS_IN]->(f)
'''

Planet

Details about planets include the climate, residents, and terrain. Note that we are extracting climate and terrain into nodes. This will allow us to define queries that traverse the graph to answer questions like "Which planets are similar to each other?"

In [56]:
# Fetch a single Film entity from the API
r = requests.get("http://swapi.co/api/planets/1/")
params = r.json()
params
Out[56]:
{'climate': 'arid',
 'created': '2014-12-09T13:50:49.641000Z',
 'diameter': '10465',
 'edited': '2014-12-21T20:48:04.175778Z',
 'films': ['http://swapi.co/api/films/5/',
  'http://swapi.co/api/films/4/',
  'http://swapi.co/api/films/6/',
  'http://swapi.co/api/films/3/',
  'http://swapi.co/api/films/1/'],
 'gravity': '1 standard',
 'name': 'Tatooine',
 'orbital_period': '304',
 'population': '200000',
 'residents': ['http://swapi.co/api/people/1/',
  'http://swapi.co/api/people/2/',
  'http://swapi.co/api/people/4/',
  'http://swapi.co/api/people/6/',
  'http://swapi.co/api/people/7/',
  'http://swapi.co/api/people/8/',
  'http://swapi.co/api/people/9/',
  'http://swapi.co/api/people/11/',
  'http://swapi.co/api/people/43/',
  'http://swapi.co/api/people/62/'],
 'rotation_period': '23',
 'surface_water': '1',
 'terrain': 'desert',
 'url': 'http://swapi.co/api/planets/1/'}
In [57]:
# Update Planet entity in the graph
CREATE_PLANET_QUERY = '''
MERGE (p:Planet {url: {url}})
SET p.created = {created},
    p.diameter = {diameter},
    p.edited = {edited},
    p.gravity = {gravity},
    p.name = {name},
    p.orbital_period = {orbital_period},
    p.population = {population},
    p.rotation_period = {rotation_period},
    p.surface_water = {surface_water}
REMOVE p:Placeholder
WITH p
UNWIND split({climate}, ",") AS c
MERGE (cli:Climate {type: c})
CREATE UNIQUE (p)-[:HAS_CLIMATE]->(cli)
WITH DISTINCT p
UNWIND split({terrain}, ",") AS t
MERGE (ter:Terrain {type: t})
CREATE UNIQUE (p)-[:HAS_TERRAIN]->(ter)
'''

Species

In [58]:
# Fetch a single Film entity from the API
r = requests.get("http://swapi.co/api/species/2/")
params = r.json()
params
Out[58]:
{'average_height': 'n/a',
 'average_lifespan': 'indefinite',
 'classification': 'artificial',
 'created': '2014-12-10T15:16:16.259000Z',
 'designation': 'sentient',
 'edited': '2015-04-17T06:59:43.869528Z',
 'eye_colors': 'n/a',
 'films': ['http://swapi.co/api/films/7/',
  'http://swapi.co/api/films/5/',
  'http://swapi.co/api/films/4/',
  'http://swapi.co/api/films/6/',
  'http://swapi.co/api/films/3/',
  'http://swapi.co/api/films/2/',
  'http://swapi.co/api/films/1/'],
 'hair_colors': 'n/a',
 'homeworld': None,
 'language': 'n/a',
 'name': 'Droid',
 'people': ['http://swapi.co/api/people/2/',
  'http://swapi.co/api/people/3/',
  'http://swapi.co/api/people/8/',
  'http://swapi.co/api/people/23/',
  'http://swapi.co/api/people/87/'],
 'skin_colors': 'n/a',
 'url': 'http://swapi.co/api/species/2/'}
In [59]:
# Update Species entity in the graph
CREATE_SPECIES_QUERY = '''
MERGE (s:Species {url: {url}})
SET s.name = {name},
    s.language = {language},
    s.average_height = {average_height},
    s.average_lifespan = {average_lifespan},
    s.classification = {classification},
    s.created = {created},
    s.designation = {designation},
    s.eye_colors = {eye_colors},
    s.hair_colors = {hair_colors},
    s.skin_colors = {skin_colors}
REMOVE s:Placeholder
'''

Starships

A starship is defined as a vehicle that has hyperdrive capability.

In [60]:
# Fetch a single Film entity from the API
r = requests.get("http://swapi.co/api/starships/2/")
params = r.json()
params
Out[60]:
{'MGLT': '60',
 'cargo_capacity': '3000000',
 'consumables': '1 year',
 'cost_in_credits': '3500000',
 'created': '2014-12-10T14:20:33.369000Z',
 'crew': '165',
 'edited': '2014-12-22T17:35:45.408368Z',
 'films': ['http://swapi.co/api/films/6/',
  'http://swapi.co/api/films/3/',
  'http://swapi.co/api/films/1/'],
 'hyperdrive_rating': '2.0',
 'length': '150',
 'manufacturer': 'Corellian Engineering Corporation',
 'max_atmosphering_speed': '950',
 'model': 'CR90 corvette',
 'name': 'CR90 corvette',
 'passengers': '600',
 'pilots': [],
 'starship_class': 'corvette',
 'url': 'http://swapi.co/api/starships/2/'}
In [61]:
CREATE_STARSHIP_QUERY = '''
MERGE (s:Starship {url: {url}})
SET s.MGLT = {MGLT},
    s.consumables = {consumables},
    s.cost_in_credits = {cost_in_credits},
    s.created = {created},
    s.crew = {crew},
    s.edited = {edited},
    s.hyperdrive_rating = {hyperdrive_rating},
    s.length = {length},
    s.max_atmosphering_speed = {max_atmosphering_speed},
    s.model = {model},
    s.name = {name},
    s.passengers = {passengers}
REMOVE s:Placeholder
MERGE (m:Manufacturer {name: {manufacturer}})
CREATE UNIQUE (s)-[:MANUFACTURED_BY]->(m)
WITH s
MERGE (c:StarshipClass {type: {starship_class}})
CREATE UNIQUE (s)-[:IS_CLASS]->(c)
'''

Vehicles

Any vehicles that lack a hyperdrive are called simply, vehicles...

In [62]:
# Fetch a single Film entity from the API
r = requests.get("http://swapi.co/api/vehicles/4/")
params = r.json()
params
Out[62]:
{'cargo_capacity': '50000',
 'consumables': '2 months',
 'cost_in_credits': '150000',
 'created': '2014-12-10T15:36:25.724000Z',
 'crew': '46',
 'edited': '2014-12-22T18:21:15.523587Z',
 'films': ['http://swapi.co/api/films/5/', 'http://swapi.co/api/films/1/'],
 'length': '36.8',
 'manufacturer': 'Corellia Mining Corporation',
 'max_atmosphering_speed': '30',
 'model': 'Digger Crawler',
 'name': 'Sand Crawler',
 'passengers': '30',
 'pilots': [],
 'url': 'http://swapi.co/api/vehicles/4/',
 'vehicle_class': 'wheeled'}
In [63]:
CREATE_VEHICLE_QUERY = '''
MERGE (v:Vehicle {url: {url}})
SET v.cargo_capacity = {cargo_capacity},
    v.consumables = {consumables},
    v.cost_in_credits = {cost_in_credits},
    v.created = {created},
    v.crew = {crew},
    v.edited = {edited},
    v.length = {length},
    v.max_atmosphering_speed = {max_atmosphering_speed},
    v.model = {model},
    v.name = {name},
    v.passengers = {passengers}
REMOVE v:Placeholder
MERGE (m:Manufacturer {name: {manufacturer}})
CREATE UNIQUE (v)-[:MANUFACTURED_BY]->(m)
WITH v
MERGE (c:VehicleClass {type: {vehicle_class}})
CREATE UNIQUE (v)-[:IS_CLASS]->(c)
'''

Crawl the graph

Now that we've defined the Cypher queries to handle inserting data from SWAPI, we can start crawling the API by making HTTP requests to SWAPI and building our graph! But first we need a starting point.

Start with films

Since we know the films we want to insert into the graph (Episodes 1-6) we will start there. This loop starts with Episode I, fetches the film data from SWAPI then executes the CREATE_MOVIE_QUERY Cypher query using that data as a parameter, then loops through the remaining episodes.

In [64]:
# Fetch Movie entities and insert into graph 
for i in range(1,7):
    url = "http://swapi.co/api/films/" + str(i) + "/"
    r = requests.get(url)
    params = r.json()
    graph.cypher.execute(CREATE_MOVIE_QUERY, params)
    print("Inserted film: " + str(url))
Inserted film: http://swapi.co/api/films/1/
Inserted film: http://swapi.co/api/films/2/
Inserted film: http://swapi.co/api/films/3/
Inserted film: http://swapi.co/api/films/4/
Inserted film: http://swapi.co/api/films/5/
Inserted film: http://swapi.co/api/films/6/

We've now created Film nodes for each of the six films, as well as created placeholder nodes for new entities that we've discovered while inserting the films.

In [65]:
# How many Placeholder nodes are in the graph now?
placeholder_count_query = '''
MATCH (p:Placeholder) WITH p
WITH collect(DISTINCT head(labels(p))) AS labels
UNWIND labels AS label
MATCH (p:Placeholder) WHERE head(labels(p))=label
RETURN label, count(*) AS num
'''

result = graph.cypher.execute(placeholder_count_query)
result
Out[65]:
   | label       | num
---+-------------+-----
 1 | Person      |  81
 2 | Vehicle     |  39
 3 | Placeholder |  37
 4 | Planet      |  20
 5 | Starship    |  36

Fill in Placeholder nodes and crawl the graph

We can now continue to populate our graph by crawling the API.

First, we'll define a query to find a single Placeholder node in the graph and return the url for the placeholder. We will then use this url to make a request to SWAPI to populate the entity and its type (Vehicle, Starship, Person, etc).

In [66]:
# Find a single Placeholder entity and return its url and type
FIND_NEW_ENTITY_QUERY = '''
MATCH (p:Placeholder)
WITH rand() AS r, p ORDER BY r LIMIT 1
WITH p
RETURN p.url AS url, CASE WHEN head(labels(p))="Placeholder" THEN labels(p)[1] ELSE head(labels(p)) END AS type
'''

Then we need a function to map the type of the Placeholder entity returned to the Cypher query that inserts that type of entity:

In [67]:
# get Cypher query for label
def getQueryForLabel(label):
    if (label == 'Vehicle'):
        return CREATE_VEHICLE_QUERY
    elif (label == 'Species'):
        return CREATE_SPECIES_QUERY
    elif (label == 'Person'):
        return CREATE_PERSON_QUERY
    elif (label == 'Starship'):
        return CREATE_STARSHIP_QUERY
    elif (label == 'Planet'):
        return CREATE_PLANET_QUERY
    else:
        raise ValueError("Unknown label for entity: " + str(label))

Now we just need to define a loop to fetch a single Placeholder entity (any node with the label Placeholder), make a request to SWAPI for the JSON data for this resource and execute the Cypher query to insert that type of resource. Once that entity is populated in the graph we remove the Placeholder label from the node. Then we just loop until our graph no longer has any Placeholder nodes.

In [68]:
# Fetch a single Placeholder entity from the graph
# Get JSON for Placeholder entity from SWAPI
# Update entity in graph (removing Placeholder label)
# Loop until graph contains no more Placeholder nodes
result = graph.cypher.execute(FIND_NEW_ENTITY_QUERY)
while result:
    label = result.one.type
    url = result.one.url
    r = requests.get(url)
    params = r.json()
    graph.cypher.execute(getQueryForLabel(label), params)
    result = graph.cypher.execute(FIND_NEW_ENTITY_QUERY)   

And now we've built the Wookiepedia Graph by crawling SWAPI! We can now query our graph to make use of the data to learn more about the Star Wars universe:

Who pilots the same vehicles as Luke Skywalker?

What planets are most similar to Naboo?

In [144]:
planet_sim_query = '''
MATCH (p:Planet {name: 'Naboo'})-[:HAS_CLIMATE]->(c:Climate)<-[:HAS_CLIMATE]-(o:Planet)
MATCH (p)-[:HAS_TERRAIN]->(t:Terrain)<-[:HAS_TERRAIN]-(o)
WITH DISTINCT o, collect(DISTINCT c.type) AS climates, collect(DISTINCT t.type) AS terrains
RETURN o.name AS planet, climates, terrains, size(climates) + size(terrains) AS sim ORDER BY sim DESC LIMIT 5
'''
result = graph.cypher.execute(planet_sim_query)
result
Out[144]:
   | planet     | climates      | terrains                   | sim
---+------------+---------------+----------------------------+-----
 1 | Muunilinst | ['temperate'] | [' forests', ' mountains'] |   3
 2 | Endor      | ['temperate'] | [' mountains']             |   2
 3 | Nal Hutta  | ['temperate'] | [' swamps']                |   2
 4 | Corellia   | ['temperate'] | [' forests']               |   2
 5 | Coruscant  | ['temperate'] | [' mountains']             |   2

TODO

  • Error handling / retry - currently we just assume all requests will complete successfully. In reality we need to have at least some basic retry functionality for requests that do not complete as expected.

Important Copyright information

Star Wars and all associated names are copyright Lucasfilm ltd. All data comes from SWAPI.co

In [ ]: