# 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()`.