# itch.io — Scraping & Data Extraction
Field-tested against itch.io on 2026-04-18. All code blocks validated with live requests.
---
## TL;DR — fastest approaches by task
| Task | Method | Notes |
|---|---|---|
| Browse listings (36/page) | `http_get` HTML | Works, no key, no bot block |
| Game detail (name, price, rating) | `http_get` + JSON-LD | `',
html, re.DOTALL
):
ld = json.loads(block.strip())
if ld.get('@type') == 'Product':
ld_product = ld
break
# --- Info panel table (Status, Platforms, Genre, Tags, Author, etc.) ---
info = {}
panel_m = re.search(
r'class="game_info_panel_widget[^"]*"[^>]*>
(.*?)
',
html, re.DOTALL
)
if panel_m:
for row in re.finditer(
r'
([^<]+)
(.*?)
',
panel_m.group(1), re.DOTALL
):
key = row.group(1).strip()
val = re.sub(r'<[^>]+>', '', row.group(2)).strip()
# Multi-value fields become lists (Tags, Platforms, Genre, Links)
info[key] = [v.strip() for v in val.split(',')] if ',' in val else val
# --- Cover image ---
cover_m = re.search(r'`.
```python
import re
from helpers import http_get
def paginate_listing(base_url, max_pages=10):
"""
Scrape multiple pages from any itch.io browse URL.
base_url: https://itch.io/games/top-rated (no ?page= suffix)
Returns flat list of game dicts.
Stops when HTTP 404 or no found.
"""
all_games = []
page = 1
while page <= max_pages:
url = base_url if page == 1 else f"{base_url}?page={page}"
try:
html = http_get(url)
except Exception:
break # 404 = past last page
all_games.extend(parse_game_cards(html))
if not re.search(r']+rel="next"[^>]*/>', html):
break
page += 1
return all_games
# Confirmed: page 1 has
# page 2 has and
# past last page returns HTTP 404
# top-rated has at least 200 pages (each 36 games); page 300+ -> 404
```
---
## Browse URL patterns
All confirmed working via `http_get`:
```python
BASE = "https://itch.io/games"
# Sort orders
f"{BASE}/top-rated" # all-time top rated (rated by community, 0–5 stars)
f"{BASE}/newest" # most recently published
f"{BASE}/featured" # itch.io staff picks
f"{BASE}/on-sale" # discounted games
f"{BASE}/free" # free games only
# Genre/tag paths (append .xml for RSS)
f"{BASE}/tag-puzzle" # tag slug — prefix with 'tag-'
f"{BASE}/genre-action" # genre — prefix with 'genre-' (less common)
# Combine: tag + sort via separate pages (no combined URL that survives http_get)
# Note: https://itch.io/games/top-rated/tag-puzzle -> HTTP 403
# Note: ?tag= query param does NOT filter server-side (returns same games)
# Pagination
f"{BASE}/top-rated?page=2"
f"{BASE}/tag-puzzle?page=3"
# RSS equivalents (36 items, no pagination needed for small sets)
f"{BASE}/top-rated.xml"
f"{BASE}/tag-puzzle.xml"
f"{BASE}/tag-puzzle.xml?page=2"
# Search (54 results/page, no server-side pagination beyond page 1 via http_get)
"https://itch.io/search?q=platformer"
# Author profile
"https://.itch.io"
```
---
## API (requires key)
itch.io has an official REST API. A free key is issued per-account with no rate limit published.
Get one at: `https://itch.io/user/settings/api-keys`
Base URL: `https://itch.io/api/1//`
```python
import json
from helpers import http_get
ITCH_KEY = "your_api_key_here" # from https://itch.io/user/settings/api-keys
def api(path):
return json.loads(http_get(f"https://itch.io/api/1/{ITCH_KEY}/{path}"))
# Authenticated user info
api("me")
# -> {"user": {"id": ..., "username": "...", "url": "...", "display_name": "...", ...}}
# Games owned by authenticated user
api("my-games")
# -> {"games": [{"id": ..., "title": "...", "url": "...", "created_at": "...",
# "published": true/false, "min_price": 0, ...}, ...]}
# Download keys for a game (owner only)
api("game/434554/download_keys")
# Credentials (for authenticated purchases)
api("game/434554/credentials")
```
**Error structure:** invalid/missing key returns `{"errors": ["invalid key"]}` with HTTP 200.
Non-existent endpoints return HTTP 404.
**No unauthenticated game lookup API.** `https://itch.io/api/1/x/games` -> HTTP 404.
Use HTML scraping or RSS for unauthenticated game data.
---
## Gotchas
1. **Attribute order flips page 1 vs 2+.** On page 1, game cards use `class="game_cell ..." data-game_id="..."`. On pages 2+, the order is `data-game_id="..." class="game_cell ..."`. Always match `data-game_id` independently of class ordering.
2. **Ratings absent on tag/genre listing pages.** The `data-tooltip` with rating is often missing from card HTML on `/games/tag-*` pages even though the game has ratings. Fetch the detail page for `aggregateRating` via JSON-LD.
3. **`price_value` absent = Free.** Paid games have `
$7.99
`. Free games have no such element. Default to `'Free'` when absent.
4. **Free-game JSON-LD has no `offers` block.** Only paid games include the `offers` object. For free games, use absence of `offers` as the signal, not presence of `price: 0`.
5. **`/games/top-rated/tag-puzzle` returns HTTP 403.** Cannot combine sort + tag in a path. Use separate `/games/tag-puzzle` (top-rated is the default sort anyway).
6. **`?tag=` query param is ignored server-side.** `https://itch.io/games/top-rated?tag=puzzle` returns the same games as `?top-rated`. Use `/games/tag-puzzle` path instead.
7. **Download/purchase counts are not public.** No count field appears anywhere in the public HTML, JSON-LD, RSS, or unauthenticated API. Game owners see their stats in the dashboard only.
8. **Search beyond page 1 is AJAX-only.** `https://itch.io/search?q=X&page=2` via `http_get` returns the same 54 results as page 1. To get more search results use the browser and scroll/click "load more".
9. **RSS is capped at 36 items per page.** Paginate with `?page=N`. Very high page numbers (300+) return HTTP 404 on browse pages.
10. **Unicode zero-width space in some titles.** `\u200b` (zero-width space) appears at the start of certain titles (e.g. "Our Life: Beginnings & Always"). Strip with `.replace('\u200b', '').strip()` or `.strip()` alone won't remove it — use `title.replace('\u200b', '').strip()`.