{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Building The Star Wars Graph\n", "\n", "Thousands of Star Wars fans have contributed to Star Wars wikis like the [Wookieepedia Wiki](http://starwars.wikia.com/wiki/Main_Page) - 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](http://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!\n", "\n", "In order to build this graph we'll need to fetch data from [SWAPI.co](http://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](http://py2neo.org/2.0/) 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.\n", "\n", "This notebook assumes some basic knowledge of Python and some working knowledge of [Neo4j](http://neo4j.com). For a more general overview of Neo4j and graph databases, check out some of the other posts on [my blog](http://lyonwj.com).\n" ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "# import dependency packages\n", "from py2neo import Graph # install with `pip install py2neo`\n", "import requests # `pip install requests`" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'people': 'https://swapi.co/api/people/',\n", " 'planets': 'https://swapi.co/api/planets/',\n", " 'films': 'https://swapi.co/api/films/',\n", " 'species': 'https://swapi.co/api/species/',\n", " 'vehicles': 'https://swapi.co/api/vehicles/',\n", " 'starships': 'https://swapi.co/api/starships/'}" ] }, "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Exploring the API\n", "# what endpoints are available?\n", "r = requests.get(\"http://swapi.co/api/\")\n", "r.json()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## The datamodel\n", "\n", "Based on the entities and the data we have available, this is the property graph data model that we'll be using:\n", "\n", "![](https://dl.dropboxusercontent.com/u/67572426/Screenshot%202015-12-11%2011.59.31.png)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Connect to Neo4j and add constraints\n", "\n", "We'll be using the [py2neo](https://py2neo.org/v4/) 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" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "" ] }, "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Connect to Neo4j instance\n", "graph = Graph(\"bolt://localhost:7687\", auth=(\"neo4j\", \"\"))\n", "\n", "# create uniqueness constraints based on the datamodel\n", "graph.run(\"CREATE CONSTRAINT ON (f:Film) ASSERT f.url IS UNIQUE\")\n", "graph.run(\"CREATE CONSTRAINT ON (p:Person) ASSERT p.url IS UNIQUE\")\n", "graph.run(\"CREATE CONSTRAINT ON (v:Vehicle) ASSERT v.url IS UNIQUE\")\n", "graph.run(\"CREATE CONSTRAINT ON (s:Starship) ASSERT s.url IS UNIQUE\")\n", "graph.run(\"CREATE CONSTRAINT ON (p:Planet) ASSERT p.url IS UNIQUE\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Inserting a single resource\n", "\n", "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." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'name': 'Luke Skywalker',\n", " 'height': '172',\n", " 'mass': '77',\n", " 'hair_color': 'blond',\n", " 'skin_color': 'fair',\n", " 'eye_color': 'blue',\n", " 'birth_year': '19BBY',\n", " 'gender': 'male',\n", " 'homeworld': 'https://swapi.co/api/planets/1/',\n", " 'films': ['https://swapi.co/api/films/2/',\n", " 'https://swapi.co/api/films/6/',\n", " 'https://swapi.co/api/films/3/',\n", " 'https://swapi.co/api/films/1/',\n", " 'https://swapi.co/api/films/7/'],\n", " 'species': ['https://swapi.co/api/species/1/'],\n", " 'vehicles': ['https://swapi.co/api/vehicles/14/',\n", " 'https://swapi.co/api/vehicles/30/'],\n", " 'starships': ['https://swapi.co/api/starships/12/',\n", " 'https://swapi.co/api/starships/22/'],\n", " 'created': '2014-12-09T13:50:51.644000Z',\n", " 'edited': '2014-12-20T21:17:56.891000Z',\n", " 'url': 'https://swapi.co/api/people/1/'}" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Fetch a single person entity from the API\n", "r = requests.get(\"http://swapi.co/api/people/1/\")\n", "params = r.json()\n", "params" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "# Define a parameterized Cypher query to insert a Person entity into the graph\n", "# For resources referenced in the Person entity (like homeworld and starships) we create a relationship and node\n", "# containing only the url. This new node acts as a placeholder that we'll need to fill in later\n", "\n", "CREATE_PERSON_QUERY = '''\n", "MERGE (p:Person {url: {url}})\n", "SET p.birth_year = {birth_year},\n", " p.created = {created},\n", " p.edited = {edited},\n", " p.eye_color = {eye_color},\n", " p.gender = {gender},\n", " p.hair_color = {hair_color},\n", " p.height = {height},\n", " p.mass = {mass},\n", " p.name = {name},\n", " p.skin_color = {skin_color}\n", "REMOVE p:Placeholder\n", "WITH p\n", "MERGE (home:Planet {url: {homeworld}})\n", "ON CREATE SET home:Placeholder\n", "CREATE UNIQUE (home)<-[:IS_FROM]-(p)\n", "WITH p\n", "UNWIND {species} AS specie\n", "MERGE (s:Species {url: specie})\n", "ON CREATE SET s:Placeholder\n", "CREATE UNIQUE (p)-[:IS_SPECIES]->(s)\n", "WITH DISTINCT p\n", "UNWIND {starships} AS starship\n", "MERGE (s:Starship {url: starship})\n", "ON CREATE SET s:Placeholder\n", "CREATE UNIQUE (p)-[:PILOTS]->(s)\n", "WITH DISTINCT p\n", "UNWIND {vehicles} AS vehicle\n", "MERGE (v:Vehicle {url: vehicle})\n", "ON CREATE SET v:Placeholder\n", "CREATE UNIQUE (p)-[:PILOTS]->(v)\n", "'''" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "" ] }, "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# we can execute this query using py2neo\n", "graph.run(CREATE_PERSON_QUERY, params)" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "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. \n", "\n", "![](https://dl.dropboxusercontent.com/u/67572426/Screenshot%202015-12-11%2016.18.21.png)\n", "\n", "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." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Define Cypher queries\n", "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.\n", "\n", "#### Film\n", "\n", "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." ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'title': 'A New Hope',\n", " 'episode_id': 4,\n", " '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....\",\n", " 'director': 'George Lucas',\n", " 'producer': 'Gary Kurtz, Rick McCallum',\n", " 'release_date': '1977-05-25',\n", " 'characters': ['https://swapi.co/api/people/1/',\n", " 'https://swapi.co/api/people/2/',\n", " 'https://swapi.co/api/people/3/',\n", " 'https://swapi.co/api/people/4/',\n", " 'https://swapi.co/api/people/5/',\n", " 'https://swapi.co/api/people/6/',\n", " 'https://swapi.co/api/people/7/',\n", " 'https://swapi.co/api/people/8/',\n", " 'https://swapi.co/api/people/9/',\n", " 'https://swapi.co/api/people/10/',\n", " 'https://swapi.co/api/people/12/',\n", " 'https://swapi.co/api/people/13/',\n", " 'https://swapi.co/api/people/14/',\n", " 'https://swapi.co/api/people/15/',\n", " 'https://swapi.co/api/people/16/',\n", " 'https://swapi.co/api/people/18/',\n", " 'https://swapi.co/api/people/19/',\n", " 'https://swapi.co/api/people/81/'],\n", " 'planets': ['https://swapi.co/api/planets/2/',\n", " 'https://swapi.co/api/planets/3/',\n", " 'https://swapi.co/api/planets/1/'],\n", " 'starships': ['https://swapi.co/api/starships/2/',\n", " 'https://swapi.co/api/starships/3/',\n", " 'https://swapi.co/api/starships/5/',\n", " 'https://swapi.co/api/starships/9/',\n", " 'https://swapi.co/api/starships/10/',\n", " 'https://swapi.co/api/starships/11/',\n", " 'https://swapi.co/api/starships/12/',\n", " 'https://swapi.co/api/starships/13/'],\n", " 'vehicles': ['https://swapi.co/api/vehicles/4/',\n", " 'https://swapi.co/api/vehicles/6/',\n", " 'https://swapi.co/api/vehicles/7/',\n", " 'https://swapi.co/api/vehicles/8/'],\n", " 'species': ['https://swapi.co/api/species/5/',\n", " 'https://swapi.co/api/species/3/',\n", " 'https://swapi.co/api/species/2/',\n", " 'https://swapi.co/api/species/1/',\n", " 'https://swapi.co/api/species/4/'],\n", " 'created': '2014-12-10T14:23:31.880000Z',\n", " 'edited': '2015-04-11T09:46:52.774897Z',\n", " 'url': 'https://swapi.co/api/films/1/'}" ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Fetch a single Film entity from the API\n", "r = requests.get(\"http://swapi.co/api/films/1/\")\n", "params = r.json()\n", "params" ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [], "source": [ "# Insert a given Film into the graph, including Placeholder nodes for any new entities discovered\n", "CREATE_MOVIE_QUERY = '''\n", "MERGE (f:Film {url: {url}})\n", "SET f.created = {created},\n", " f.edited = {edited},\n", " f.episode_id = toInt({episode_id}),\n", " f.opening_crawl = {opening_crawl},\n", " f.release_date = {release_date},\n", " f.title = {title}\n", "WITH f\n", "UNWIND split({director}, \",\") AS director\n", "MERGE (d:Director {name: director})\n", "CREATE UNIQUE (f)-[:DIRECTED_BY]->(d)\n", "WITH DISTINCT f\n", "UNWIND split({producer}, \",\") AS producer\n", "MERGE (p:Producer {name: producer})\n", "CREATE UNIQUE (f)-[:PRODUCED_BY]->(p)\n", "WITH DISTINCT f\n", "UNWIND {characters} AS character\n", "MERGE (c:Person {url: character})\n", "ON CREATE SET c:Placeholder\n", "CREATE UNIQUE (c)-[:APPEARS_IN]->(f)\n", "WITH DISTINCT f\n", "UNWIND {planets} AS planet\n", "MERGE (p:Planet {url: planet})\n", "ON CREATE SET p:Placeholder\n", "CREATE UNIQUE (f)-[:TAKES_PLACE_ON]->(p)\n", "WITH DISTINCT f\n", "UNWIND {species} AS specie\n", "MERGE (s:Species {url: specie})\n", "ON CREATE SET s:Placeholder\n", "CREATE UNIQUE (s)-[:APPEARS_IN]->(f)\n", "WITH DISTINCT f\n", "UNWIND {starships} AS starship\n", "MERGE (s:Starship {url: starship})\n", "ON CREATE SET s:Placeholder\n", "CREATE UNIQUE (s)-[:APPEARS_IN]->(f)\n", "WITH DISTINCT f\n", "UNWIND {vehicles} AS vehicle\n", "MERGE (v:Vehicle {url: vehicle})\n", "ON CREATE SET v:Placeholder\n", "CREATE UNIQUE (v)-[:APPEARS_IN]->(f)\n", "'''\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Planet\n", "\n", "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?\"" ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'name': 'Tatooine',\n", " 'rotation_period': '23',\n", " 'orbital_period': '304',\n", " 'diameter': '10465',\n", " 'climate': 'arid',\n", " 'gravity': '1 standard',\n", " 'terrain': 'desert',\n", " 'surface_water': '1',\n", " 'population': '200000',\n", " 'residents': ['https://swapi.co/api/people/1/',\n", " 'https://swapi.co/api/people/2/',\n", " 'https://swapi.co/api/people/4/',\n", " 'https://swapi.co/api/people/6/',\n", " 'https://swapi.co/api/people/7/',\n", " 'https://swapi.co/api/people/8/',\n", " 'https://swapi.co/api/people/9/',\n", " 'https://swapi.co/api/people/11/',\n", " 'https://swapi.co/api/people/43/',\n", " 'https://swapi.co/api/people/62/'],\n", " 'films': ['https://swapi.co/api/films/5/',\n", " 'https://swapi.co/api/films/4/',\n", " 'https://swapi.co/api/films/6/',\n", " 'https://swapi.co/api/films/3/',\n", " 'https://swapi.co/api/films/1/'],\n", " 'created': '2014-12-09T13:50:49.641000Z',\n", " 'edited': '2014-12-21T20:48:04.175778Z',\n", " 'url': 'https://swapi.co/api/planets/1/'}" ] }, "execution_count": 9, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Fetch a single Film entity from the API\n", "r = requests.get(\"http://swapi.co/api/planets/1/\")\n", "params = r.json()\n", "params" ] }, { "cell_type": "code", "execution_count": 10, "metadata": {}, "outputs": [], "source": [ "# Update Planet entity in the graph\n", "CREATE_PLANET_QUERY = '''\n", "MERGE (p:Planet {url: {url}})\n", "SET p.created = {created},\n", " p.diameter = {diameter},\n", " p.edited = {edited},\n", " p.gravity = {gravity},\n", " p.name = {name},\n", " p.orbital_period = {orbital_period},\n", " p.population = {population},\n", " p.rotation_period = {rotation_period},\n", " p.surface_water = {surface_water}\n", "REMOVE p:Placeholder\n", "WITH p\n", "UNWIND split({climate}, \",\") AS c\n", "MERGE (cli:Climate {type: c})\n", "CREATE UNIQUE (p)-[:HAS_CLIMATE]->(cli)\n", "WITH DISTINCT p\n", "UNWIND split({terrain}, \",\") AS t\n", "MERGE (ter:Terrain {type: t})\n", "CREATE UNIQUE (p)-[:HAS_TERRAIN]->(ter)\n", "'''" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Species" ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'name': 'Droid',\n", " 'classification': 'artificial',\n", " 'designation': 'sentient',\n", " 'average_height': 'n/a',\n", " 'skin_colors': 'n/a',\n", " 'hair_colors': 'n/a',\n", " 'eye_colors': 'n/a',\n", " 'average_lifespan': 'indefinite',\n", " 'homeworld': None,\n", " 'language': 'n/a',\n", " 'people': ['https://swapi.co/api/people/2/',\n", " 'https://swapi.co/api/people/3/',\n", " 'https://swapi.co/api/people/8/',\n", " 'https://swapi.co/api/people/23/',\n", " 'https://swapi.co/api/people/87/'],\n", " 'films': ['https://swapi.co/api/films/2/',\n", " 'https://swapi.co/api/films/7/',\n", " 'https://swapi.co/api/films/5/',\n", " 'https://swapi.co/api/films/4/',\n", " 'https://swapi.co/api/films/6/',\n", " 'https://swapi.co/api/films/3/',\n", " 'https://swapi.co/api/films/1/'],\n", " 'created': '2014-12-10T15:16:16.259000Z',\n", " 'edited': '2015-04-17T06:59:43.869528Z',\n", " 'url': 'https://swapi.co/api/species/2/'}" ] }, "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Fetch a single Film entity from the API\n", "r = requests.get(\"http://swapi.co/api/species/2/\")\n", "params = r.json()\n", "params" ] }, { "cell_type": "code", "execution_count": 12, "metadata": {}, "outputs": [], "source": [ "# Update Species entity in the graph\n", "CREATE_SPECIES_QUERY = '''\n", "MERGE (s:Species {url: {url}})\n", "SET s.name = {name},\n", " s.language = {language},\n", " s.average_height = {average_height},\n", " s.average_lifespan = {average_lifespan},\n", " s.classification = {classification},\n", " s.created = {created},\n", " s.designation = {designation},\n", " s.eye_colors = {eye_colors},\n", " s.hair_colors = {hair_colors},\n", " s.skin_colors = {skin_colors}\n", "REMOVE s:Placeholder\n", "'''\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Starships\n", "\n", "A starship is defined as a vehicle that has hyperdrive capability. " ] }, { "cell_type": "code", "execution_count": 13, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'name': 'CR90 corvette',\n", " 'model': 'CR90 corvette',\n", " 'manufacturer': 'Corellian Engineering Corporation',\n", " 'cost_in_credits': '3500000',\n", " 'length': '150',\n", " 'max_atmosphering_speed': '950',\n", " 'crew': '165',\n", " 'passengers': '600',\n", " 'cargo_capacity': '3000000',\n", " 'consumables': '1 year',\n", " 'hyperdrive_rating': '2.0',\n", " 'MGLT': '60',\n", " 'starship_class': 'corvette',\n", " 'pilots': [],\n", " 'films': ['https://swapi.co/api/films/6/',\n", " 'https://swapi.co/api/films/3/',\n", " 'https://swapi.co/api/films/1/'],\n", " 'created': '2014-12-10T14:20:33.369000Z',\n", " 'edited': '2014-12-22T17:35:45.408368Z',\n", " 'url': 'https://swapi.co/api/starships/2/'}" ] }, "execution_count": 13, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Fetch a single Film entity from the API\n", "r = requests.get(\"http://swapi.co/api/starships/2/\")\n", "params = r.json()\n", "params" ] }, { "cell_type": "code", "execution_count": 14, "metadata": {}, "outputs": [], "source": [ "\n", "CREATE_STARSHIP_QUERY = '''\n", "MERGE (s:Starship {url: {url}})\n", "SET s.MGLT = {MGLT},\n", " s.consumables = {consumables},\n", " s.cost_in_credits = {cost_in_credits},\n", " s.created = {created},\n", " s.crew = {crew},\n", " s.edited = {edited},\n", " s.hyperdrive_rating = {hyperdrive_rating},\n", " s.length = {length},\n", " s.max_atmosphering_speed = {max_atmosphering_speed},\n", " s.model = {model},\n", " s.name = {name},\n", " s.passengers = {passengers}\n", "REMOVE s:Placeholder\n", "MERGE (m:Manufacturer {name: {manufacturer}})\n", "CREATE UNIQUE (s)-[:MANUFACTURED_BY]->(m)\n", "WITH s\n", "MERGE (c:StarshipClass {type: {starship_class}})\n", "CREATE UNIQUE (s)-[:IS_CLASS]->(c)\n", "'''" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Vehicles\n", "\n", "Any vehicles that lack a hyperdrive are called simply, vehicles..." ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "{'name': 'Sand Crawler',\n", " 'model': 'Digger Crawler',\n", " 'manufacturer': 'Corellia Mining Corporation',\n", " 'cost_in_credits': '150000',\n", " 'length': '36.8',\n", " 'max_atmosphering_speed': '30',\n", " 'crew': '46',\n", " 'passengers': '30',\n", " 'cargo_capacity': '50000',\n", " 'consumables': '2 months',\n", " 'vehicle_class': 'wheeled',\n", " 'pilots': [],\n", " 'films': ['https://swapi.co/api/films/5/', 'https://swapi.co/api/films/1/'],\n", " 'created': '2014-12-10T15:36:25.724000Z',\n", " 'edited': '2014-12-22T18:21:15.523587Z',\n", " 'url': 'https://swapi.co/api/vehicles/4/'}" ] }, "execution_count": 15, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Fetch a single Film entity from the API\n", "r = requests.get(\"http://swapi.co/api/vehicles/4/\")\n", "params = r.json()\n", "params" ] }, { "cell_type": "code", "execution_count": 16, "metadata": {}, "outputs": [], "source": [ "CREATE_VEHICLE_QUERY = '''\n", "MERGE (v:Vehicle {url: {url}})\n", "SET v.cargo_capacity = {cargo_capacity},\n", " v.consumables = {consumables},\n", " v.cost_in_credits = {cost_in_credits},\n", " v.created = {created},\n", " v.crew = {crew},\n", " v.edited = {edited},\n", " v.length = {length},\n", " v.max_atmosphering_speed = {max_atmosphering_speed},\n", " v.model = {model},\n", " v.name = {name},\n", " v.passengers = {passengers}\n", "REMOVE v:Placeholder\n", "MERGE (m:Manufacturer {name: {manufacturer}})\n", "CREATE UNIQUE (v)-[:MANUFACTURED_BY]->(m)\n", "WITH v\n", "MERGE (c:VehicleClass {type: {vehicle_class}})\n", "CREATE UNIQUE (v)-[:IS_CLASS]->(c)\n", "'''\n" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "### Crawl the graph\n", "\n", "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.\n", "\n", "#### Start with films\n", "\n", "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." ] }, { "cell_type": "code", "execution_count": 17, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Inserted film: http://swapi.co/api/films/1/\n", "Inserted film: http://swapi.co/api/films/2/\n", "Inserted film: http://swapi.co/api/films/3/\n", "Inserted film: http://swapi.co/api/films/4/\n", "Inserted film: http://swapi.co/api/films/5/\n", "Inserted film: http://swapi.co/api/films/6/\n", "Inserted film: http://swapi.co/api/films/7/\n" ] } ], "source": [ "# Fetch Movie entities and insert into graph \n", "for i in range(1,8):\n", " url = \"http://swapi.co/api/films/\" + str(i) + \"/\"\n", " r = requests.get(url)\n", " params = r.json()\n", " graph.run(CREATE_MOVIE_QUERY, params)\n", " print(\"Inserted film: \" + str(url))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We've now created Film nodes for each of the seven films, as well as created placeholder nodes for new entities that we've discovered while inserting the films." ] }, { "cell_type": "code", "execution_count": 18, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
labelnum
Planet21
Placeholder37
Starship37
Vehicle39
Person86
" ], "text/plain": [ " label | num \n", "-------------|-----\n", " Planet | 21 \n", " Placeholder | 37 \n", " Starship | 37 \n", " Vehicle | 39 \n", " Person | 86 " ] }, "execution_count": 18, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# How many Placeholder nodes are in the graph now?\n", "placeholder_count_query = '''\n", "MATCH (p:Placeholder) WITH p\n", "WITH collect(DISTINCT head(labels(p))) AS labels\n", "UNWIND labels AS label\n", "MATCH (p:Placeholder) WHERE head(labels(p))=label\n", "RETURN label, count(*) AS num\n", "'''\n", "\n", "result = graph.run(placeholder_count_query)\n", "result.to_table()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Fill in Placeholder nodes and crawl the graph\n", "\n", "We can now continue to populate our graph by crawling the API. \n", "\n", "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)." ] }, { "cell_type": "code", "execution_count": 19, "metadata": {}, "outputs": [], "source": [ "# Find a single Placeholder entity and return its url and type\n", "FIND_NEW_ENTITY_QUERY = '''\n", "MATCH (p:Placeholder)\n", "WITH rand() AS r, p ORDER BY r LIMIT 1\n", "WITH p\n", "RETURN p.url AS url, CASE WHEN head(labels(p))=\"Placeholder\" THEN labels(p)[1] ELSE head(labels(p)) END AS type\n", "'''" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Then we need a function to map the type of the Placeholder entity returned to the Cypher query that inserts that type of entity:" ] }, { "cell_type": "code", "execution_count": 20, "metadata": {}, "outputs": [], "source": [ "# get Cypher query for label\n", "def getQueryForLabel(label):\n", " if (label == 'Vehicle'):\n", " return CREATE_VEHICLE_QUERY\n", " elif (label == 'Species'):\n", " return CREATE_SPECIES_QUERY\n", " elif (label == 'Person'):\n", " return CREATE_PERSON_QUERY\n", " elif (label == 'Starship'):\n", " return CREATE_STARSHIP_QUERY\n", " elif (label == 'Planet'):\n", " return CREATE_PLANET_QUERY\n", " else:\n", " raise ValueError(\"Unknown label for entity: \" + str(label))" ] }, { "cell_type": "markdown", "metadata": { "collapsed": true }, "source": [ "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." ] }, { "cell_type": "code", "execution_count": 21, "metadata": {}, "outputs": [], "source": [ "# Fetch a single Placeholder entity from the graph\n", "# Get JSON for Placeholder entity from SWAPI\n", "# Update entity in graph (removing Placeholder label)\n", "# Loop until graph contains no more Placeholder nodes\n", "result = graph.run(FIND_NEW_ENTITY_QUERY)\n", "while result.forward():\n", " label = result.current[\"type\"]\n", " url = result.current[\"url\"]\n", " r = requests.get(url)\n", " params = r.json()\n", " graph.run(getQueryForLabel(label), params)\n", " result = graph.run(FIND_NEW_ENTITY_QUERY) " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "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:" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Who pilots the same vehicles as Luke Skywalker?\n", "![](https://dl.dropboxusercontent.com/u/67572426/Screenshot%202015-12-14%2011.51.22.png)\n", "![](https://dl.dropboxusercontent.com/u/67572426/Screenshot%202015-12-14%2011.48.40.png)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### What planets are most similar to Naboo?" ] }, { "cell_type": "code", "execution_count": 22, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
planetclimatesterrainssim
Muunilinst['temperate'][' forests', ' mountains']3
Corellia['temperate'][' forests']2
Chandrila['temperate'][' forests']2
Nal Hutta['temperate'][' swamps']2
Cato Neimoidia['temperate'][' forests']2
" ], "text/plain": [ " planet | climates | terrains | sim \n", "----------------|---------------|----------------------------|-----\n", " Muunilinst | ['temperate'] | [' forests', ' mountains'] | 3 \n", " Corellia | ['temperate'] | [' forests'] | 2 \n", " Chandrila | ['temperate'] | [' forests'] | 2 \n", " Nal Hutta | ['temperate'] | [' swamps'] | 2 \n", " Cato Neimoidia | ['temperate'] | [' forests'] | 2 " ] }, "execution_count": 22, "metadata": {}, "output_type": "execute_result" } ], "source": [ "planet_sim_query = '''\n", "MATCH (p:Planet {name: 'Naboo'})-[:HAS_CLIMATE]->(c:Climate)<-[:HAS_CLIMATE]-(o:Planet)\n", "MATCH (p)-[:HAS_TERRAIN]->(t:Terrain)<-[:HAS_TERRAIN]-(o)\n", "WITH DISTINCT o, collect(DISTINCT c.type) AS climates, collect(DISTINCT t.type) AS terrains\n", "RETURN o.name AS planet, climates, terrains, size(climates) + size(terrains) AS sim ORDER BY sim DESC LIMIT 5\n", "'''\n", "result = graph.run(planet_sim_query)\n", "result.to_table()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### TODO\n", "\n", "* 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." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Important Copyright information\n", "\n", "Star Wars and all associated names are copyright Lucasfilm ltd.\n", "All data comes from SWAPI.co" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.7.0" } }, "nbformat": 4, "nbformat_minor": 1 }