In this section, I’ll cover how I fetched and cleaned match data from the Clash Royale API.
How the Scraper Works (The “Spider” Approach)
To build a dataset, I used a spider scraper.
Start with Player A and fetch their recent matches (~25 battles).
Randomly pick one of their opponents (Player B).
Fetch Player B’s matches, pick one of their opponents (Player C), and repeat.
By constantly jumping from opponent to opponent, the script creates a continuous chain of real battle data.
Step-by-Step Implementation
Setup and Environment
First, we import our tools and set up file paths to save the scraped data.
Show imports and declarations
import csvimport hashlibimport osimport randomfrom concurrent.futures import ThreadPoolExecutorfrom itertools import islicefrom pathlib import Pathimport pyarrow as paimport pyarrow.csv as pvimport pyarrow.parquet as pqimport requestsfrom dotenv import load_dotenvfrom pybloom_live import ScalableBloomFilterimport osfrom pathlib import PathPARENT_DIR = Path.cwd().parentDATA_DIR = PARENT_DIR /"data"CSV_FILENAME = DATA_DIR /"raw"/"trophy_battles.csv"PARQUET_FILENAME = DATA_DIR /"processed"/"trophy_battles.parquet"CSV_FILENAME.parent.mkdir(parents=True, exist_ok=True)PARQUET_FILENAME.parent.mkdir(parents=True, exist_ok=True)load_dotenv()API_KEY = API_KEY # type: ignoreifnot API_KEY:raiseValueError("API_KEY not found")if API_KEY =="YOUR_OWN_API_KEY_HERE":raiseRuntimeError("You need to create your own API key")DATA_DIR = PARENT_DIR /"data"CSV_FILENAME = DATA_DIR /"raw"/"trophy_battles.csv"PARQUET_FILENAME = DATA_DIR /"processed"/"trophy_battles.parquet"HEADERS = {"Authorization": f"Bearer {API_KEY}"}
Configurations
We specify the initial player to start our web from, how many unique battles we want to collect, and how many requests to run at the same time (workers).
This function sends a request to the official Clash Royale API proxy to retrieve a player’s recent battle history.
def fetch_battle_log(player_tag): tag = player_tag if player_tag.startswith("%23") elsef"%23{player_tag.lstrip('#')}"try: res = requests.get(f"https://proxy.royaleapi.dev/v1/players/{tag}/battlelog", headers=HEADERS, timeout=10, )if res.status_code ==200:return res.json()else:print(f"API Error [{res.status_code}] for tag {tag}: {res.text}", flush=True)return []except requests.exceptions.RequestException as e:print(f"Request exception for {tag}: {e}", flush=True)return []
Cleaning & Extracting Features
Raw API responses contain a lot of extra information we don’t need. Here, we extract only 1v1 trophy matches and convert them into clean records containing:
A unique Battle ID (created by hashing the timestamp and player tags to ensure no duplicate matches are saved).
Because players encounter each other often, our scraper will naturally run into matches it has already seen. Normally, you might check a giant list of saved IDs, but as the dataset grows into millions of matches, that list takes up a lot of RAM.
Instead, I use a Bloom Filter; a smart memory-saving structure. It tells us if a match is definitely new or likely a duplicate, using a low amount of memory.
def filter_unseen(battles, bloom):def is_unseen(b):if b["battle_id"] in bloom:returnFalse bloom.add(b["battle_id"])returnTruereturnlist(filter(is_unseen, battles))
Picking the Next Player (“Random Walk with Teleportation”)
If our scraper only moves forward from opponent to opponent, it risks getting stuck in a small loop of active players.
To prevent this, the script uses a Random Walk with Teleportation:
90% of the time: It picks a random opponent from the current match (moving forward).
10% of the time (“Teleportation”): It jumps back to a previously seen player from a global pool.
This small chance to “teleport” breaks potential loops and ensures the scraper explores different parts of the player base.
def next_tags(opponents, pool, count=WORKERS):def pick():if opponents and random.random() >0.10:return random.choice(opponents)return random.choice(pool) if pool else START_PLAYER_TAGreturn [pick() for _ inrange(count)]
Combining Everything: The Data Stream
This function connects all the pieces: it fetches data concurrently, cleans it, deduplicates it using the Bloom Filter, and streams out new battles one by one.
def battle_stream(start_tag): bloom = ScalableBloomFilter(initial_capacity=1000, error_rate=0.001) pool = [start_tag] current_tags = [start_tag]with ThreadPoolExecutor(max_workers=WORKERS) as executor:whileTrue: results = executor.map(fetch_battle_log, current_tags) all_battles = [b for log in results for b in log] extracted =list(filter(None, map(extract_battle_data, all_battles))) new_battles = filter_unseen(extracted, bloom)yieldfrom new_battles opponents = [b["opponent_tag"] for b in new_battles]if opponents: pool[:] = pool[-100:] + opponents current_tags = next_tags(opponents, pool)
Saving and Storage Optimization
We save the data into two different file formats:
CSV: Plain text format, easy to open and check manually.
Parquet: A compressed, fast format optimized for large-scale data analysis.
When scraping large amounts of data from an API, the main challenge isn’t speed, it’s memory management.
Streaming Data directly to storage: Instead of keeping millions of matches in memory before saving, the script processes and writes matches on the go.
Memory-efficient Deduplication: A standard list or Python set() of 1 million IDs eats way more RAM. A Bloom Filter uses a tiny fraction of that memory by allowing a negligible trade-off: a tiny percentage of false positives (where a brand-new match might accidentally get tossed out as a duplicate). For a huge dataset, losing <0.1% of matches is well worth the better scaling.
Graph Traversal Strategy: Scraping player networks is essentially traversing a massive graph. While standard computer science algorithms (like Breadth-First Search, or a lossy version) work well, a Random Walk with Teleportation is simpler to implement, lightweight on memory and can be explained more easily to people without a computer science background.