@pencil-agent/nano-pencil 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +267 -267
  2. package/dist/build-meta.json +3 -3
  3. package/dist/core/export-html/AGENT.md +11 -11
  4. package/dist/core/export-html/template.css +971 -971
  5. package/dist/core/export-html/template.html +54 -54
  6. package/dist/core/mcp/mcp-client.d.ts +3 -1
  7. package/dist/core/mcp/mcp-client.js +6 -6
  8. package/dist/core/mcp/mcp-config.d.ts +3 -3
  9. package/dist/core/mcp/mcp-config.js +1 -1
  10. package/dist/core/mcp/mcp-manager.d.ts +5 -1
  11. package/dist/core/mcp/mcp-manager.js +1 -1
  12. package/dist/core/platform/config/resource-loader.d.ts +2 -0
  13. package/dist/core/platform/config/resource-loader.js +2 -2
  14. package/dist/core/runtime/agent-session.d.ts +12 -0
  15. package/dist/core/runtime/agent-session.js +8 -8
  16. package/dist/core/runtime/sdk.d.ts +8 -0
  17. package/dist/core/runtime/sdk.js +1 -1
  18. package/dist/extensions/builtin/AGENT.md +115 -115
  19. package/dist/extensions/builtin/browser/AGENT.md +17 -17
  20. package/dist/extensions/builtin/browser/agent-workspace/agent_helpers.py +12 -12
  21. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/amazon/product-search.md +198 -198
  22. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/archive-org/scraping.md +341 -341
  23. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/arxiv/scraping.md +311 -311
  24. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/arxiv-bulk/scraping.md +333 -333
  25. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/atlas/overview.md +70 -70
  26. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/booking-com/scraping.md +578 -578
  27. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/capterra/scraping.md +440 -440
  28. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/centilebrain/generate-estimates.md +110 -110
  29. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coingecko/scraping.md +325 -325
  30. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coinmarketcap/scraping.md +463 -463
  31. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coursera/scraping.md +360 -360
  32. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/craigslist/scraping.md +390 -390
  33. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/crossref/scraping.md +568 -568
  34. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/dev-to/scraping.md +323 -323
  35. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/duckduckgo/scraping.md +349 -349
  36. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/ebay/scraping.md +435 -435
  37. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/etsy/scraping.md +506 -506
  38. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/eventbrite/scraping.md +363 -363
  39. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/expedia/automation.md +168 -168
  40. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/facebook/groups.md +236 -236
  41. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/facebook/pages.md +295 -295
  42. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/framer/editor.md +108 -108
  43. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/fred/scraping.md +493 -493
  44. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/g2/scraping.md +580 -580
  45. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/genius/scraping.md +511 -511
  46. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/github/repo-actions.md +65 -65
  47. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/github/scraping.md +184 -184
  48. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/glassdoor/scraping.md +543 -543
  49. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/gmail/compose.md +122 -122
  50. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/goodreads/scraping.md +461 -461
  51. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/gutenberg/scraping.md +383 -383
  52. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/hackernews/scraping.md +243 -243
  53. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/howlongtobeat/scraping.md +473 -473
  54. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/imdb/scraping.md +271 -271
  55. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/itch-io/scraping.md +436 -436
  56. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/job-boards/indeed-glassdoor.md +1021 -1021
  57. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/letterboxd/scraping.md +349 -349
  58. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/linkedin/invitation-manager.md +109 -109
  59. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/loom/folder-enumeration.md +170 -170
  60. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/macrotrends/scraping.md +537 -537
  61. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/medium/article-hydration.md +120 -120
  62. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/medium/scraping.md +414 -414
  63. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/metacritic/scraping.md +477 -477
  64. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/musicbrainz/scraping.md +478 -478
  65. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/nasa/scraping.md +339 -339
  66. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/news-aggregation/multi-source.md +205 -205
  67. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/open-library/scraping.md +472 -472
  68. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/openalex/scraping.md +470 -470
  69. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/openstreetmap/scraping.md +490 -490
  70. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/package-registries/npm-pypi.md +478 -478
  71. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/polymarket/scraping.md +234 -234
  72. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/producthunt/scraping.md +307 -307
  73. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/pubmed/scraping.md +421 -421
  74. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/quora/scraping.md +364 -364
  75. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/rawg/scraping.md +352 -352
  76. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/reddit/scraping.md +124 -124
  77. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/rest-countries/scraping.md +233 -233
  78. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/sec-edgar/scraping.md +361 -361
  79. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/README.md +36 -36
  80. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/embedded-apps.md +72 -72
  81. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/knowledge-base.md +109 -109
  82. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/polaris-inputs.md +137 -137
  83. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/soundcloud/scraping.md +362 -362
  84. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/spotify/scraping.md +339 -339
  85. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/stackoverflow/scraping.md +435 -435
  86. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/steam/scraping.md +575 -575
  87. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/substack/scraping.md +338 -338
  88. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/thetechgeeks/pricing.md +52 -52
  89. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/tiktok/upload.md +107 -107
  90. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/tradingview/scraping.md +309 -309
  91. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/trello/boards-and-lists.md +88 -88
  92. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/trustpilot/scraping.md +375 -375
  93. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/walmart/scraping.md +444 -444
  94. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/wayback-machine/scraping.md +306 -306
  95. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/weather/scraping.md +398 -398
  96. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/wellfound/scraping.md +596 -596
  97. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/world-bank/scraping.md +356 -356
  98. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/xiaohongshu/scraping.md +84 -84
  99. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/youtube/scraping.md +418 -418
  100. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/zillow/scraping.md +433 -433
  101. package/dist/extensions/builtin/browser/browser.md +73 -73
  102. package/dist/extensions/builtin/browser/install.md +142 -142
  103. package/dist/extensions/builtin/browser/interaction-skills/connection.md +48 -48
  104. package/dist/extensions/builtin/browser/interaction-skills/cookies.md +3 -3
  105. package/dist/extensions/builtin/browser/interaction-skills/cross-origin-iframes.md +3 -3
  106. package/dist/extensions/builtin/browser/interaction-skills/dialogs.md +64 -64
  107. package/dist/extensions/builtin/browser/interaction-skills/downloads.md +3 -3
  108. package/dist/extensions/builtin/browser/interaction-skills/drag-and-drop.md +3 -3
  109. package/dist/extensions/builtin/browser/interaction-skills/dropdowns.md +3 -3
  110. package/dist/extensions/builtin/browser/interaction-skills/iframes.md +3 -3
  111. package/dist/extensions/builtin/browser/interaction-skills/network-requests.md +3 -3
  112. package/dist/extensions/builtin/browser/interaction-skills/print-as-pdf.md +3 -3
  113. package/dist/extensions/builtin/browser/interaction-skills/profile-sync.md +90 -90
  114. package/dist/extensions/builtin/browser/interaction-skills/screenshots.md +17 -17
  115. package/dist/extensions/builtin/browser/interaction-skills/scrolling.md +3 -3
  116. package/dist/extensions/builtin/browser/interaction-skills/shadow-dom.md +3 -3
  117. package/dist/extensions/builtin/browser/interaction-skills/tabs.md +69 -69
  118. package/dist/extensions/builtin/browser/interaction-skills/uploads.md +1 -1
  119. package/dist/extensions/builtin/browser/interaction-skills/viewport.md +3 -3
  120. package/dist/extensions/builtin/browser/src/browser_harness/AGENT.md +15 -15
  121. package/dist/extensions/builtin/browser/src/browser_harness/__init__.py +8 -8
  122. package/dist/extensions/builtin/browser/src/browser_harness/_ipc.py +90 -90
  123. package/dist/extensions/builtin/browser/src/browser_harness/admin.py +722 -722
  124. package/dist/extensions/builtin/browser/src/browser_harness/daemon.py +328 -328
  125. package/dist/extensions/builtin/browser/src/browser_harness/helpers.py +396 -396
  126. package/dist/extensions/builtin/browser/src/browser_harness/run.py +103 -103
  127. package/dist/extensions/builtin/discipline/skills/brainstorming/SKILL.md +33 -33
  128. package/dist/extensions/builtin/discipline/skills/executing-plans/SKILL.md +25 -25
  129. package/dist/extensions/builtin/discipline/skills/finishing-development-branch/SKILL.md +25 -25
  130. package/dist/extensions/builtin/discipline/skills/receiving-code-review/SKILL.md +22 -22
  131. package/dist/extensions/builtin/discipline/skills/requesting-code-review/SKILL.md +31 -31
  132. package/dist/extensions/builtin/discipline/skills/systematic-debugging/SKILL.md +28 -28
  133. package/dist/extensions/builtin/discipline/skills/test-driven-development/SKILL.md +32 -32
  134. package/dist/extensions/builtin/discipline/skills/using-git-worktrees/SKILL.md +25 -25
  135. package/dist/extensions/builtin/discipline/skills/verification-before-completion/SKILL.md +27 -27
  136. package/dist/extensions/builtin/discipline/skills/writing-plans/SKILL.md +26 -26
  137. package/dist/extensions/builtin/goal/README.md +67 -67
  138. package/dist/extensions/builtin/grub/README.md +112 -112
  139. package/dist/extensions/builtin/link-world/agent-workspace/README.md +16 -16
  140. package/dist/extensions/builtin/link-world/internet-search/internet-search.md +65 -65
  141. package/dist/extensions/builtin/link-world/link-world-agent.md +82 -82
  142. package/dist/extensions/builtin/link-world/linkworld.md +313 -313
  143. package/dist/extensions/builtin/link-world/network-routing/network-routing.md +67 -67
  144. package/dist/extensions/builtin/loop/README.md +92 -92
  145. package/dist/extensions/builtin/mcp/figma-design.md +68 -68
  146. package/dist/extensions/builtin/mcp/mcp-management.md +85 -85
  147. package/dist/extensions/builtin/recap/AGENT.md +15 -15
  148. package/dist/extensions/builtin/sal/README.md +72 -72
  149. package/dist/extensions/builtin/security-audit/README.md +289 -289
  150. package/dist/extensions/builtin/team/AGENT.md +112 -112
  151. package/dist/extensions/builtin/team/TESTING.md +299 -299
  152. package/dist/extensions/builtin/token-save/README.md +56 -56
  153. package/dist/extensions/optional/AGENT.md +10 -10
  154. package/dist/modes/interactive/interactive-mode.js +36 -36
  155. package/dist/modes/interactive/theme/dark.json +85 -85
  156. package/dist/modes/interactive/theme/light.json +84 -84
  157. package/dist/modes/interactive/theme/theme-schema.json +335 -335
  158. package/dist/modes/interactive/theme/warm.json +81 -81
  159. package/dist/node_modules/@pencil-agent/agent-core/dist/agent-loop.js +3 -2
  160. package/dist/node_modules/@pencil-agent/agent-core/dist/structured-adaptive-agent-loop.js +2 -1
  161. package/dist/node_modules/@pencil-agent/ai/dist/cli.js +0 -0
  162. package/docs/cc-agent-design.md +1297 -0
  163. package/docs/cc-tui-design.md +1333 -0
  164. package/docs/codex-goal-command-impl.md +1055 -1055
  165. package/docs/codex-goal-vs-grub.md +500 -500
  166. package/docs/custom-provider.md +27 -27
  167. package/docs/extensions.md +27 -27
  168. package/docs/keybindings.md +27 -27
  169. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/200/273/347/273/223.md" +250 -250
  170. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/212/245/345/221/212.md" +122 -122
  171. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210.md" +1222 -1222
  172. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210/345/256/236/347/216/260/346/212/245/345/221/212.md" +158 -158
  173. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210/345/257/271/346/257/224/345/210/206/346/236/220.md" +128 -128
  174. package/docs/loop /351/207/215/346/236/204/350/256/241/345/210/222.md" +320 -320
  175. package/docs/loop-usage-examples.md +214 -214
  176. package/docs/models.md +27 -27
  177. package/docs/nanoPencil-/345/255/246/344/271/240/350/256/241/345/210/222.md +170 -0
  178. package/docs/packages.md +27 -27
  179. package/docs/pi-design-philosophy.md +457 -457
  180. package/docs/planmode.md +1987 -1987
  181. package/docs/prompt-templates.md +27 -27
  182. package/docs/providers.md +27 -27
  183. package/docs/scan-report.md +3820 -0
  184. package/docs/sdk.md +27 -27
  185. package/docs/skills.md +27 -27
  186. package/docs/themes.md +27 -27
  187. package/docs/tui.md +27 -27
  188. package/docs//345/257/271/346/240/207Claude-Code.md +1775 -0
  189. package/docs//351/230/277/351/207/214/345/267/264/345/267/264/350/264/242/346/212/245/345/210/206/346/236/220/344/271/246.md +261 -0
  190. package/package.json +190 -190
  191. package/docs/ACP/345/215/217/350/256/256/351/233/206/346/210/220/345/274/200/345/217/221/346/226/207/346/241/243.md +0 -851
  192. package/docs/SDK-TESTING.md +0 -364
  193. package/docs/mem-core/346/212/200/346/234/257/346/226/207/346/241/243.md +0 -593
  194. package/docs/startup-performance-optimization.md +0 -301
  195. package/docs//350/256/244/347/237/245/345/234/260/345/233/276.md +0 -47
@@ -1,575 +1,575 @@
1
- # Steam — Scraping & Data Extraction
2
-
3
- Field-tested against store.steampowered.com on 2026-04-18. All code blocks validated with live requests.
4
-
5
- ## Fastest approach: App Details API (no auth, no browser)
6
-
7
- The `appdetails` endpoint is the primary source for all game data. No API key, no cookies, no auth required. Returns clean JSON for any appid.
8
-
9
- ```python
10
- import json
11
- from helpers import http_get
12
-
13
- def get_app(appid, cc="US"):
14
- """
15
- Fetch full game/DLC/software data by Steam appid.
16
- cc = ISO-3166 country code for correct regional pricing (default: US).
17
- Returns None if appid not found or no longer on Steam.
18
- """
19
- resp = http_get(
20
- f"https://store.steampowered.com/api/appdetails?appids={appid}&cc={cc}"
21
- )
22
- data = json.loads(resp)
23
- entry = data[str(appid)]
24
- if not entry["success"]:
25
- return None
26
- return entry["data"]
27
-
28
- game = get_app(292030) # The Witcher 3
29
- # game["name"] -> "The Witcher 3: Wild Hunt"
30
- # game["steam_appid"] -> 292030
31
- # game["type"] -> "game" | "dlc" | "demo" | "advertising" | "mod" | "video"
32
- # game["required_age"] -> 18 (int, 0 if no restriction)
33
- # game["is_free"] -> False
34
- # game["short_description"] -> plain-text one-liner
35
- # game["about_the_game"] -> HTML
36
- # game["detailed_description"]-> HTML
37
- # game["website"] -> "https://www.thewitcher.com/witcher3"
38
- # game["header_image"] -> URL to 460x215px header image
39
- # game["capsule_image"] -> URL to smaller capsule image
40
- # game["background"] -> URL to store page background
41
- # game["supported_languages"]-> HTML string with language list (use html.unescape())
42
- # game["developers"] -> ["CD PROJEKT RED"]
43
- # game["publishers"] -> ["CD PROJEKT RED"]
44
- # game["platforms"] -> {"windows": True, "mac": False, "linux": False}
45
- # game["metacritic"] -> {"score": 93, "url": "https://www.metacritic.com/..."}
46
- # game["genres"] -> [{"id": "3", "description": "RPG"}]
47
- # game["categories"] -> [{"id": 2, "description": "Single-player"}, ...]
48
- # game["release_date"] -> {"coming_soon": False, "date": "May 18, 2015"}
49
- # game["dlc"] -> [355880, 378649, ...] (list of DLC appids)
50
- # game["legal_notice"] -> copyright text
51
- # game["ratings"] -> per-region rating board data (ESRB, PEGI, USK, ...)
52
- # game["content_descriptors"]-> {"ids": [1, 5], "notes": "..."}
53
- # game["recommendations"] -> {"total": 812249}
54
- # game["achievements"] -> {"total": 78, "highlighted": [...]}
55
- # game["support_info"] -> {"url": "...", "email": "..."}
56
- # game["pc_requirements"] -> {"minimum": "<html>...", "recommended": "<html>..."}
57
- # game["mac_requirements"] -> same structure or []
58
- # game["linux_requirements"] -> same structure or []
59
- ```
60
-
61
- ---
62
-
63
- ## Price overview
64
-
65
- Prices are always in **cents** (integer). Use `final_formatted` for display.
66
-
67
- ```python
68
- game = get_app(292030)
69
- po = game.get("price_overview")
70
- # po is None for free-to-play games (is_free=True)
71
-
72
- if po:
73
- print(po["currency"]) # "USD"
74
- print(po["final"]) # 3999 (cents — $39.99)
75
- print(po["initial"]) # 3999 (original price in cents)
76
- print(po["discount_percent"]) # 0 (0–100)
77
- print(po["final_formatted"]) # "$39.99" (always present, ready to display)
78
- print(po["initial_formatted"]) # "" (EMPTY when not discounted!)
79
- # "$49.99" (only set when discount_percent > 0)
80
- ```
81
-
82
- **Critical**: `initial_formatted` is an empty string when `discount_percent == 0`.
83
- Always use `final_formatted` for displaying current price.
84
-
85
- ```python
86
- def price_display(game):
87
- """Returns (current_price_str, original_price_str_or_None, discount_pct)."""
88
- if game.get("is_free"):
89
- return ("Free", None, 0)
90
- po = game.get("price_overview")
91
- if not po:
92
- return ("N/A", None, 0)
93
- disc = po["discount_percent"]
94
- orig = po["initial_formatted"] if disc > 0 else None
95
- return (po["final_formatted"], orig, disc)
96
-
97
- # Witcher3: ("$39.99", None, 0)
98
- # Discounted game: ("$24.99", "$49.99", 50)
99
- # Dota2: ("Free", None, 0)
100
- ```
101
-
102
- ### Regional pricing
103
-
104
- Pass `cc=` (ISO-3166 country code) to get local currency:
105
-
106
- ```python
107
- get_app(292030, cc="GB")["price_overview"]
108
- # {"currency": "GBP", "initial": 2499, "final": 2499, ..., "final_formatted": "£24.99"}
109
-
110
- get_app(292030, cc="DE")["price_overview"]
111
- # {"currency": "EUR", "initial": 2999, "final": 2999, ..., "final_formatted": "29,99€"}
112
- ```
113
-
114
- ---
115
-
116
- ## Bulk / concurrent fetching
117
-
118
- 10 games in 0.54s with 5 workers — no rate-limit errors observed:
119
-
120
- ```python
121
- import json
122
- from concurrent.futures import ThreadPoolExecutor
123
- from helpers import http_get
124
-
125
- def fetch_game(appid, cc="US"):
126
- resp = http_get(
127
- f"https://store.steampowered.com/api/appdetails?appids={appid}&cc={cc}"
128
- )
129
- data = json.loads(resp)
130
- entry = data[str(appid)]
131
- return entry["data"] if entry["success"] else None
132
-
133
- appids = [292030, 570, 413150, 427520, 730, 550, 220, 400, 218620, 105600]
134
-
135
- with ThreadPoolExecutor(max_workers=5) as ex:
136
- games = list(ex.map(fetch_game, appids))
137
- # Completed in ~0.54s
138
- # games[i] is None if appid not found
139
- ```
140
-
141
- **Confirmed field values for common appids:**
142
- - `570` (Dota 2): `is_free=True`, `price_overview=None`, `required_age=0`
143
- - `292030` (Witcher 3): `is_free=False`, `required_age=18`, `metacritic.score=93`
144
- - `413150` (Stardew Valley): `is_free=False`, `required_age=0`, `metacritic=None`
145
- - `427520` (Factorio): `is_free=False`, `required_age=0`
146
-
147
- ---
148
-
149
- ## Partial field fetch (filters=)
150
-
151
- Fetch only specific fields to reduce payload size:
152
-
153
- ```python
154
- # Price only (tiny response)
155
- resp = http_get("https://store.steampowered.com/api/appdetails?appids=292030&filters=price_overview")
156
- data = json.loads(resp)["292030"]["data"]
157
- # data keys: ["price_overview"]
158
-
159
- # Basic metadata (no price, no media)
160
- resp = http_get("https://store.steampowered.com/api/appdetails?appids=292030&filters=basic")
161
- # data keys: about_the_game, capsule_image, capsule_imagev5, detailed_description, dlc,
162
- # header_image, is_free, legal_notice, linux_requirements, mac_requirements,
163
- # name, pc_requirements, required_age, reviews, short_description,
164
- # steam_appid, supported_languages, type, website
165
-
166
- # Multiple filters comma-separated
167
- resp = http_get("https://store.steampowered.com/api/appdetails?appids=292030&filters=screenshots,price_overview")
168
- # data keys: ["price_overview", "screenshots"]
169
- ```
170
-
171
- ---
172
-
173
- ## Media fields
174
-
175
- ### Screenshots
176
-
177
- ```python
178
- game = get_app(292030)
179
- for ss in game["screenshots"]: # 18 screenshots for Witcher 3
180
- print(ss["id"]) # 0, 1, 2, ...
181
- print(ss["path_thumbnail"]) # 600x338 JPEG URL
182
- print(ss["path_full"]) # 1920x1080 JPEG URL
183
- ```
184
-
185
- ### Movies / trailers
186
-
187
- ```python
188
- for m in game["movies"]: # 4 trailers for Witcher 3
189
- print(m["id"]) # integer
190
- print(m["name"]) # trailer title
191
- print(m["thumbnail"]) # thumbnail URL
192
- print(m["highlight"]) # bool — main trailer flag
193
- # m["webm"] -> None (old format, mostly absent)
194
- # m["mp4"] -> None (old format, mostly absent)
195
- # m["dash_av1"] -> dash_av1 stream URL (present on modern entries)
196
- # m["dash_h264"] -> dash_h264 stream URL
197
- # m["hls_h264"] -> HLS stream URL
198
- ```
199
-
200
- ---
201
-
202
- ## Ratings and content descriptors
203
-
204
- The `ratings` dict contains per-region rating board data for mature games:
205
-
206
- ```python
207
- game = get_app(292030)
208
-
209
- # ESRB (North America)
210
- esrb = game["ratings"].get("esrb", {})
211
- esrb["rating"] # "m" (lowercase) -> M for Mature
212
- esrb["descriptors"] # "Blood and Gore\r\nIntense Violence\r\nNudity\r\n..."
213
- esrb["use_age_gate"] # "true" (string, not bool)
214
- esrb["required_age"] # "17" (string, not int)
215
-
216
- # PEGI (Europe)
217
- pegi = game["ratings"].get("pegi", {})
218
- pegi["rating"] # "18"
219
- pegi["descriptors"] # "Violence\r\nBad language"
220
-
221
- # USK (Germany)
222
- usk = game["ratings"].get("usk", {})
223
- usk["rating"] # "18"
224
-
225
- # steam_germany (Germany digital-only classification)
226
- sg = game["ratings"].get("steam_germany", {})
227
- sg["rating"] # "16"
228
- sg["banned"] # "0" (1 = banned in Germany)
229
-
230
- # igrs (Indonesia)
231
- igrs = game["ratings"].get("igrs", {})
232
- igrs["rating"] # "BANNED" if banned there
233
- igrs["banned"] # "1"
234
-
235
- # Other keys: oflc, nzoflc, kgrb, dejus, mda, fpb, csrr, crl
236
- ```
237
-
238
- Content descriptor IDs (from `content_descriptors.ids`):
239
- - `1` = Some Nudity or Sexual Content
240
- - `5` = Frequent Violence or Gore
241
-
242
- ---
243
-
244
- ## Age-gated store pages
245
-
246
- **The `appdetails` API completely bypasses age gates.** It returns full data for any game regardless of rating or age restriction — no cookies needed.
247
-
248
- The **store webpage** (`store.steampowered.com/app/{appid}/`) redirects mature games to an age verification form:
249
-
250
- ```
251
- GET https://store.steampowered.com/app/292030/
252
- -> 302 -> https://store.steampowered.com/agecheck/app/292030/
253
- ```
254
-
255
- To bypass the age gate on the store page, send the `birthtime` cookie:
256
-
257
- ```python
258
- import urllib.request
259
-
260
- def get_store_page(appid):
261
- """Fetch game store HTML page, bypassing age gate."""
262
- req = urllib.request.Request(
263
- f"https://store.steampowered.com/app/{appid}/",
264
- headers={
265
- "User-Agent": "Mozilla/5.0",
266
- "Cookie": "birthtime=631152001; lastagecheckage=1-January-1990"
267
- }
268
- )
269
- with urllib.request.urlopen(req, timeout=15) as r:
270
- html = r.read().decode("utf-8", errors="replace")
271
- if "agecheck" in r.url:
272
- return None # Age gate not bypassed
273
- return html
274
- ```
275
-
276
- `birthtime=631152001` = January 1, 1990 in Unix time. Steam accepts any date before the current year minus the required age.
277
-
278
- ---
279
-
280
- ## Search
281
-
282
- ### storesearch API (title search, up to 10 results)
283
-
284
- ```python
285
- import json, urllib.parse
286
- from helpers import http_get
287
-
288
- def search_games(term, cc="US", lang="english"):
289
- """
290
- Returns up to 10 matching apps/DLC/bundles.
291
- No pagination — always exactly 10 results max.
292
- """
293
- q = urllib.parse.quote(term)
294
- resp = http_get(
295
- f"https://store.steampowered.com/api/storesearch/?term={q}&l={lang}&cc={cc}"
296
- )
297
- data = json.loads(resp)
298
- return data["items"]
299
-
300
- results = search_games("witcher")
301
- # [
302
- # {"type": "app", "name": "The Witcher 3: Wild Hunt", "id": 292030,
303
- # "price": {"currency": "USD", "initial": 3999, "final": 3999},
304
- # "tiny_image": "https://...", "metascore": "93",
305
- # "platforms": {"windows": True, "mac": False, "linux": False},
306
- # "streamingvideo": False},
307
- # {"type": "sub", ...}, # bundles have type="sub"
308
- # ...
309
- # ]
310
- ```
311
-
312
- **Search result fields:**
313
- - `id` — appid (or subid for bundles)
314
- - `type` — `"app"` | `"sub"` (bundle)
315
- - `name` — game title
316
- - `price` — `{"currency": "USD", "initial": cents, "final": cents}` — `None` for F2P
317
- - `metascore` — string e.g. `"93"`, `"0"` if no score
318
- - `platforms` — `{"windows": bool, "mac": bool, "linux": bool}`
319
- - `tiny_image` — 231x87px capsule image URL
320
- - `streamingvideo` — bool
321
-
322
- **Note:** `price` in search results has only `initial` and `final` — no `discount_percent` or `formatted` strings. Use `appdetails` for full pricing.
323
-
324
- ---
325
-
326
- ## Review scores and user reviews
327
-
328
- ```python
329
- import json, urllib.parse
330
- from helpers import http_get
331
-
332
- def get_reviews(appid, num=10, language="english", filter="recent",
333
- review_type="all", purchase_type="all", cursor="*"):
334
- """
335
- filter: "recent" | "updated" | "all"
336
- review_type: "all" | "positive" | "negative"
337
- purchase_type: "all" | "steam" | "non_steam_purchase"
338
- language: "english" | "all" | ISO code
339
- cursor: use returned cursor for next page (URL-encode it)
340
- """
341
- encoded_cursor = urllib.parse.quote(cursor)
342
- resp = http_get(
343
- f"https://store.steampowered.com/appreviews/{appid}"
344
- f"?json=1&num_per_page={num}&language={language}"
345
- f"&filter={filter}&review_type={review_type}"
346
- f"&purchase_type={purchase_type}&cursor={encoded_cursor}"
347
- )
348
- return json.loads(resp)
349
-
350
- result = get_reviews(292030, num=5, language="english")
351
-
352
- # result["success"] -> 1 (int, not bool)
353
- # result["cursor"] -> "AoJ4rq..." (base64, URL-encode for next page)
354
- # result["query_summary"]["review_score"] -> 9 (0–9 score)
355
- # result["query_summary"]["review_score_desc"] -> "Overwhelmingly Positive"
356
- # result["query_summary"]["total_positive"] -> 226883
357
- # result["query_summary"]["total_negative"] -> 7499
358
- # result["query_summary"]["total_reviews"] -> 234382 (steam purchase only)
359
- # result["reviews"] -> list of review objects
360
- ```
361
-
362
- **Review score descriptions (review_score int to string):**
363
-
364
- | Score | Description |
365
- |-------|-------------|
366
- | 9 | Overwhelmingly Positive |
367
- | 8 | Very Positive |
368
- | 7 | Mostly Positive |
369
- | 6 | Positive (Mixed) |
370
- | 5 | Mixed |
371
- | 4 | Mostly Negative |
372
- | 3 | Negative |
373
- | 2 | Mostly Negative |
374
- | 1 | Overwhelmingly Negative |
375
- | 0 | No reviews |
376
-
377
- **Confirmed scores:** Witcher 3 = 9, Counter-Strike 2 = 8, Stardew Valley = 9, Factorio = 9.
378
-
379
- ### Review object fields
380
-
381
- ```python
382
- review = result["reviews"][0]
383
- review["recommendationid"] # "221423937" — unique review ID
384
- review["voted_up"] # True/False — positive/negative
385
- review["votes_up"] # 213 — helpful votes
386
- review["votes_funny"] # 66
387
- review["weighted_vote_score"] # 0.8405... — Steam's helpfulness score
388
- review["comment_count"] # 20
389
- review["steam_purchase"] # True
390
- review["received_for_free"] # False
391
- review["written_during_early_access"]# False
392
- review["timestamp_created"] # 1774209092 (Unix timestamp)
393
- review["timestamp_updated"] # Unix timestamp
394
- review["language"] # "english"
395
- review["review"] # review text
396
- review["app_release_date"] # Unix timestamp of game release
397
-
398
- review["author"]["steamid"] # "76561198..."
399
- review["author"]["personaname"] # display name
400
- review["author"]["num_games_owned"] # 1039
401
- review["author"]["num_reviews"] # 180
402
- review["author"]["playtime_forever"] # 1146 (minutes total)
403
- review["author"]["playtime_last_two_weeks"] # minutes in last 2 weeks
404
- review["author"]["playtime_at_review"] # minutes at time of review
405
- review["author"]["last_played"] # Unix timestamp
406
- ```
407
-
408
- ### Cursor-based pagination
409
-
410
- ```python
411
- import urllib.parse, json
412
- from helpers import http_get
413
-
414
- def get_all_reviews(appid, max_pages=5, num_per_page=100, language="all"):
415
- """Paginate through reviews using cursor."""
416
- cursor = "*"
417
- all_reviews = []
418
- for _ in range(max_pages):
419
- resp = http_get(
420
- f"https://store.steampowered.com/appreviews/{appid}"
421
- f"?json=1&num_per_page={num_per_page}&language={language}"
422
- f"&filter=recent&cursor={urllib.parse.quote(cursor)}"
423
- )
424
- data = json.loads(resp)
425
- batch = data.get("reviews", [])
426
- if not batch:
427
- break
428
- all_reviews.extend(batch)
429
- cursor = data.get("cursor", "")
430
- if not cursor:
431
- break
432
- return all_reviews
433
- ```
434
-
435
- ---
436
-
437
- ## Featured games
438
-
439
- ### Featured items (rotating store front)
440
-
441
- ```python
442
- import json
443
- from helpers import http_get
444
-
445
- data = json.loads(http_get("https://store.steampowered.com/api/featured/"))
446
- # data["large_capsules"] -> 1-3 hero banner items
447
- # data["featured_win"] -> 10 featured items for Windows
448
- # data["featured_mac"] -> macOS featured
449
- # data["featured_linux"] -> Linux featured
450
- # data["status"] -> 1
451
-
452
- item = data["featured_win"][0]
453
- # item["id"] -> appid
454
- # item["name"] -> game title
455
- # item["discounted"] -> bool
456
- # item["discount_percent"] -> 0-100
457
- # item["original_price"] -> cents
458
- # item["final_price"] -> cents
459
- # item["currency"] -> "USD"
460
- # item["windows_available"] -> bool
461
- # item["mac_available"] -> bool
462
- # item["linux_available"] -> bool
463
- # item["large_capsule_image"] -> URL
464
- # item["small_capsule_image"] -> URL
465
- # item["header_image"] -> URL
466
- # item["controller_support"] -> "full" | "partial" | ""
467
- ```
468
-
469
- ### Featured categories (top sellers, specials, new releases, coming soon)
470
-
471
- ```python
472
- data = json.loads(http_get("https://store.steampowered.com/api/featuredcategories/"))
473
-
474
- # Named sections (most useful):
475
- specials = data["specials"]["items"] # 10 on-sale games
476
- top_sellers = data["top_sellers"]["items"] # 10 top sellers
477
- new_releases= data["new_releases"]["items"] # 30 new releases
478
- coming_soon = data["coming_soon"]["items"] # 10 upcoming games
479
-
480
- # Numbered keys "0" through "7" are spotlight banners (deals/events)
481
-
482
- item = top_sellers[0]
483
- # item["id"] -> appid
484
- # item["name"] -> game title
485
- # item["discounted"] -> bool
486
- # item["discount_percent"] -> 0-100
487
- # item["original_price"] -> cents (None for upcoming games)
488
- # item["final_price"] -> cents (0 for upcoming)
489
- # item["currency"] -> "USD"
490
- # item["discount_expiration"] -> Unix timestamp (present for active sales)
491
- # item["windows_available"] -> bool
492
- # item["mac_available"] -> bool
493
- # item["linux_available"] -> bool
494
- # item["header_image"] -> URL
495
- ```
496
-
497
- ---
498
-
499
- ## App list (all Steam apps)
500
-
501
- The `ISteamApps/GetAppList` API endpoint (v1, v2, v0001, v0002) currently returns **HTTP 404** from `api.steampowered.com` as of 2026-04-18. The endpoint is effectively retired without a Steamworks API key.
502
-
503
- **Workaround:** Use the featured categories and search APIs to discover appids, then batch-fetch via `appdetails`.
504
-
505
- ```python
506
- # Discover appids from top sellers + new releases
507
- import json
508
- from helpers import http_get
509
-
510
- def get_all_store_appids():
511
- data = json.loads(http_get("https://store.steampowered.com/api/featuredcategories/"))
512
- appids = set()
513
- for key in ["specials", "top_sellers", "new_releases", "coming_soon"]:
514
- for item in data.get(key, {}).get("items", []):
515
- appids.add(item["id"])
516
- for key in ["featured_win", "featured_mac", "featured_linux"]:
517
- for item in data.get(key, []):
518
- appids.add(item["id"])
519
- return sorted(appids)
520
-
521
- # Returns ~50 store-front appids (enough to seed further discovery)
522
- ```
523
-
524
- ---
525
-
526
- ## Rate limits
527
-
528
- Steam's public APIs are generous. Confirmed during testing:
529
-
530
- - **10 sequential requests in 1.59s** — all HTTP 200, no throttling
531
- - **10 concurrent requests (5 workers) in 0.54s** — all succeeded
532
- - **No `Retry-After` header** observed at any concurrency level
533
-
534
- Practical limits (undocumented, inferred from community reports):
535
- - ~200 requests/5 minutes per IP to `appdetails` before soft throttling (returns `success: false`)
536
- - Review API is more restrictive — keep to ~50 requests/minute
537
-
538
- ---
539
-
540
- ## Gotchas
541
-
542
- **`success: false` with no data field** — When an appid is invalid, removed, or unreleased, the response is `{"999999": {"success": false}}` with no `data` key. Always check `entry["success"]` before accessing `entry["data"]`.
543
-
544
- ```python
545
- entry = json.loads(resp)[str(appid)]
546
- if not entry["success"]:
547
- return None # game removed or never existed
548
- game = entry["data"]
549
- ```
550
-
551
- **Multiple appids in one call — not supported** — `appids=292030,570` returns HTTP 400. The API only accepts a single appid per call. Use `ThreadPoolExecutor` for bulk fetching.
552
-
553
- **`price_overview` is `None` for free games** — When `is_free=True`, the `price_overview` key is absent or `None`. Never index `game["price_overview"]["final"]` without a None check.
554
-
555
- **`initial_formatted` is empty string when not on sale** — When `discount_percent == 0`, `initial_formatted` is `""`. Only `final_formatted` is reliably present and non-empty. Use `final_formatted` for display in all cases.
556
-
557
- **Store page age gate** — `store.steampowered.com/app/{appid}/` redirects mature games to `/agecheck/app/{appid}/`. The `appdetails` API completely bypasses this — no cookies needed. For browser-based scraping of the store page, send `Cookie: birthtime=631152001; lastagecheckage=1-January-1990`.
558
-
559
- **`storesearch` always returns ≤ 10 results** — No pagination. `total` in the response is always 10, not the true result count. For finding specific games, this is sufficient. For catalog browsing, use `appdetails` with known appids.
560
-
561
- **`metascore` is string `"0"` in search results, int `93` in appdetails** — Inconsistent types. In `storesearch` results, `metascore` is a string (e.g. `"93"`, `"0"`). In `appdetails`, `metacritic` is a dict `{"score": 93, "url": "..."}` or absent entirely. Always `int()` the storesearch value.
562
-
563
- **`appdetails` returns `type: "dlc"` for DLC** — Check `game["type"]` before treating every appid as a standalone game. Type values: `"game"`, `"dlc"`, `"demo"`, `"advertising"`, `"mod"`, `"video"`.
564
-
565
- **`ratings` dict uses string booleans** — `use_age_gate` and `required_age` inside `ratings[board]` are strings (`"true"`, `"17"`), not native types. `banned` is also a string `"0"` or `"1"`.
566
-
567
- **`ISteamApps/GetAppList` is dead** — HTTP 404 for v1, v2, v0001, v0002 endpoints as of 2026-04-18. Use store front APIs and search to discover appids instead.
568
-
569
- **`supported_languages` is HTML** — The field contains escaped HTML like `English<strong>*</strong>, French`. Starred languages have full audio. Use `html.unescape()` and strip tags to get a clean list.
570
-
571
- **`release_date.date` is a locale string, not ISO** — Value is `"May 18, 2015"` not `"2015-05-18"`. Parse with `datetime.strptime(d, "%B %d, %Y")` or use regex.
572
-
573
- **Review `purchase_type` changes total counts** — `purchase_type=all` includes reviews from non-Steam purchases (physical, Humble, etc.). `purchase_type=steam` is Steam-only. Witcher 3 example: `all`=802,072 reviews, `steam`=234,385.
574
-
575
- **Currency requires `cc=` param** — Without `cc=`, you get USD by default. Pass `cc=GB` for GBP, `cc=DE` for EUR, etc. Country codes are ISO-3166 (2-letter, uppercase).
1
+ # Steam — Scraping & Data Extraction
2
+
3
+ Field-tested against store.steampowered.com on 2026-04-18. All code blocks validated with live requests.
4
+
5
+ ## Fastest approach: App Details API (no auth, no browser)
6
+
7
+ The `appdetails` endpoint is the primary source for all game data. No API key, no cookies, no auth required. Returns clean JSON for any appid.
8
+
9
+ ```python
10
+ import json
11
+ from helpers import http_get
12
+
13
+ def get_app(appid, cc="US"):
14
+ """
15
+ Fetch full game/DLC/software data by Steam appid.
16
+ cc = ISO-3166 country code for correct regional pricing (default: US).
17
+ Returns None if appid not found or no longer on Steam.
18
+ """
19
+ resp = http_get(
20
+ f"https://store.steampowered.com/api/appdetails?appids={appid}&cc={cc}"
21
+ )
22
+ data = json.loads(resp)
23
+ entry = data[str(appid)]
24
+ if not entry["success"]:
25
+ return None
26
+ return entry["data"]
27
+
28
+ game = get_app(292030) # The Witcher 3
29
+ # game["name"] -> "The Witcher 3: Wild Hunt"
30
+ # game["steam_appid"] -> 292030
31
+ # game["type"] -> "game" | "dlc" | "demo" | "advertising" | "mod" | "video"
32
+ # game["required_age"] -> 18 (int, 0 if no restriction)
33
+ # game["is_free"] -> False
34
+ # game["short_description"] -> plain-text one-liner
35
+ # game["about_the_game"] -> HTML
36
+ # game["detailed_description"]-> HTML
37
+ # game["website"] -> "https://www.thewitcher.com/witcher3"
38
+ # game["header_image"] -> URL to 460x215px header image
39
+ # game["capsule_image"] -> URL to smaller capsule image
40
+ # game["background"] -> URL to store page background
41
+ # game["supported_languages"]-> HTML string with language list (use html.unescape())
42
+ # game["developers"] -> ["CD PROJEKT RED"]
43
+ # game["publishers"] -> ["CD PROJEKT RED"]
44
+ # game["platforms"] -> {"windows": True, "mac": False, "linux": False}
45
+ # game["metacritic"] -> {"score": 93, "url": "https://www.metacritic.com/..."}
46
+ # game["genres"] -> [{"id": "3", "description": "RPG"}]
47
+ # game["categories"] -> [{"id": 2, "description": "Single-player"}, ...]
48
+ # game["release_date"] -> {"coming_soon": False, "date": "May 18, 2015"}
49
+ # game["dlc"] -> [355880, 378649, ...] (list of DLC appids)
50
+ # game["legal_notice"] -> copyright text
51
+ # game["ratings"] -> per-region rating board data (ESRB, PEGI, USK, ...)
52
+ # game["content_descriptors"]-> {"ids": [1, 5], "notes": "..."}
53
+ # game["recommendations"] -> {"total": 812249}
54
+ # game["achievements"] -> {"total": 78, "highlighted": [...]}
55
+ # game["support_info"] -> {"url": "...", "email": "..."}
56
+ # game["pc_requirements"] -> {"minimum": "<html>...", "recommended": "<html>..."}
57
+ # game["mac_requirements"] -> same structure or []
58
+ # game["linux_requirements"] -> same structure or []
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Price overview
64
+
65
+ Prices are always in **cents** (integer). Use `final_formatted` for display.
66
+
67
+ ```python
68
+ game = get_app(292030)
69
+ po = game.get("price_overview")
70
+ # po is None for free-to-play games (is_free=True)
71
+
72
+ if po:
73
+ print(po["currency"]) # "USD"
74
+ print(po["final"]) # 3999 (cents — $39.99)
75
+ print(po["initial"]) # 3999 (original price in cents)
76
+ print(po["discount_percent"]) # 0 (0–100)
77
+ print(po["final_formatted"]) # "$39.99" (always present, ready to display)
78
+ print(po["initial_formatted"]) # "" (EMPTY when not discounted!)
79
+ # "$49.99" (only set when discount_percent > 0)
80
+ ```
81
+
82
+ **Critical**: `initial_formatted` is an empty string when `discount_percent == 0`.
83
+ Always use `final_formatted` for displaying current price.
84
+
85
+ ```python
86
+ def price_display(game):
87
+ """Returns (current_price_str, original_price_str_or_None, discount_pct)."""
88
+ if game.get("is_free"):
89
+ return ("Free", None, 0)
90
+ po = game.get("price_overview")
91
+ if not po:
92
+ return ("N/A", None, 0)
93
+ disc = po["discount_percent"]
94
+ orig = po["initial_formatted"] if disc > 0 else None
95
+ return (po["final_formatted"], orig, disc)
96
+
97
+ # Witcher3: ("$39.99", None, 0)
98
+ # Discounted game: ("$24.99", "$49.99", 50)
99
+ # Dota2: ("Free", None, 0)
100
+ ```
101
+
102
+ ### Regional pricing
103
+
104
+ Pass `cc=` (ISO-3166 country code) to get local currency:
105
+
106
+ ```python
107
+ get_app(292030, cc="GB")["price_overview"]
108
+ # {"currency": "GBP", "initial": 2499, "final": 2499, ..., "final_formatted": "£24.99"}
109
+
110
+ get_app(292030, cc="DE")["price_overview"]
111
+ # {"currency": "EUR", "initial": 2999, "final": 2999, ..., "final_formatted": "29,99€"}
112
+ ```
113
+
114
+ ---
115
+
116
+ ## Bulk / concurrent fetching
117
+
118
+ 10 games in 0.54s with 5 workers — no rate-limit errors observed:
119
+
120
+ ```python
121
+ import json
122
+ from concurrent.futures import ThreadPoolExecutor
123
+ from helpers import http_get
124
+
125
+ def fetch_game(appid, cc="US"):
126
+ resp = http_get(
127
+ f"https://store.steampowered.com/api/appdetails?appids={appid}&cc={cc}"
128
+ )
129
+ data = json.loads(resp)
130
+ entry = data[str(appid)]
131
+ return entry["data"] if entry["success"] else None
132
+
133
+ appids = [292030, 570, 413150, 427520, 730, 550, 220, 400, 218620, 105600]
134
+
135
+ with ThreadPoolExecutor(max_workers=5) as ex:
136
+ games = list(ex.map(fetch_game, appids))
137
+ # Completed in ~0.54s
138
+ # games[i] is None if appid not found
139
+ ```
140
+
141
+ **Confirmed field values for common appids:**
142
+ - `570` (Dota 2): `is_free=True`, `price_overview=None`, `required_age=0`
143
+ - `292030` (Witcher 3): `is_free=False`, `required_age=18`, `metacritic.score=93`
144
+ - `413150` (Stardew Valley): `is_free=False`, `required_age=0`, `metacritic=None`
145
+ - `427520` (Factorio): `is_free=False`, `required_age=0`
146
+
147
+ ---
148
+
149
+ ## Partial field fetch (filters=)
150
+
151
+ Fetch only specific fields to reduce payload size:
152
+
153
+ ```python
154
+ # Price only (tiny response)
155
+ resp = http_get("https://store.steampowered.com/api/appdetails?appids=292030&filters=price_overview")
156
+ data = json.loads(resp)["292030"]["data"]
157
+ # data keys: ["price_overview"]
158
+
159
+ # Basic metadata (no price, no media)
160
+ resp = http_get("https://store.steampowered.com/api/appdetails?appids=292030&filters=basic")
161
+ # data keys: about_the_game, capsule_image, capsule_imagev5, detailed_description, dlc,
162
+ # header_image, is_free, legal_notice, linux_requirements, mac_requirements,
163
+ # name, pc_requirements, required_age, reviews, short_description,
164
+ # steam_appid, supported_languages, type, website
165
+
166
+ # Multiple filters comma-separated
167
+ resp = http_get("https://store.steampowered.com/api/appdetails?appids=292030&filters=screenshots,price_overview")
168
+ # data keys: ["price_overview", "screenshots"]
169
+ ```
170
+
171
+ ---
172
+
173
+ ## Media fields
174
+
175
+ ### Screenshots
176
+
177
+ ```python
178
+ game = get_app(292030)
179
+ for ss in game["screenshots"]: # 18 screenshots for Witcher 3
180
+ print(ss["id"]) # 0, 1, 2, ...
181
+ print(ss["path_thumbnail"]) # 600x338 JPEG URL
182
+ print(ss["path_full"]) # 1920x1080 JPEG URL
183
+ ```
184
+
185
+ ### Movies / trailers
186
+
187
+ ```python
188
+ for m in game["movies"]: # 4 trailers for Witcher 3
189
+ print(m["id"]) # integer
190
+ print(m["name"]) # trailer title
191
+ print(m["thumbnail"]) # thumbnail URL
192
+ print(m["highlight"]) # bool — main trailer flag
193
+ # m["webm"] -> None (old format, mostly absent)
194
+ # m["mp4"] -> None (old format, mostly absent)
195
+ # m["dash_av1"] -> dash_av1 stream URL (present on modern entries)
196
+ # m["dash_h264"] -> dash_h264 stream URL
197
+ # m["hls_h264"] -> HLS stream URL
198
+ ```
199
+
200
+ ---
201
+
202
+ ## Ratings and content descriptors
203
+
204
+ The `ratings` dict contains per-region rating board data for mature games:
205
+
206
+ ```python
207
+ game = get_app(292030)
208
+
209
+ # ESRB (North America)
210
+ esrb = game["ratings"].get("esrb", {})
211
+ esrb["rating"] # "m" (lowercase) -> M for Mature
212
+ esrb["descriptors"] # "Blood and Gore\r\nIntense Violence\r\nNudity\r\n..."
213
+ esrb["use_age_gate"] # "true" (string, not bool)
214
+ esrb["required_age"] # "17" (string, not int)
215
+
216
+ # PEGI (Europe)
217
+ pegi = game["ratings"].get("pegi", {})
218
+ pegi["rating"] # "18"
219
+ pegi["descriptors"] # "Violence\r\nBad language"
220
+
221
+ # USK (Germany)
222
+ usk = game["ratings"].get("usk", {})
223
+ usk["rating"] # "18"
224
+
225
+ # steam_germany (Germany digital-only classification)
226
+ sg = game["ratings"].get("steam_germany", {})
227
+ sg["rating"] # "16"
228
+ sg["banned"] # "0" (1 = banned in Germany)
229
+
230
+ # igrs (Indonesia)
231
+ igrs = game["ratings"].get("igrs", {})
232
+ igrs["rating"] # "BANNED" if banned there
233
+ igrs["banned"] # "1"
234
+
235
+ # Other keys: oflc, nzoflc, kgrb, dejus, mda, fpb, csrr, crl
236
+ ```
237
+
238
+ Content descriptor IDs (from `content_descriptors.ids`):
239
+ - `1` = Some Nudity or Sexual Content
240
+ - `5` = Frequent Violence or Gore
241
+
242
+ ---
243
+
244
+ ## Age-gated store pages
245
+
246
+ **The `appdetails` API completely bypasses age gates.** It returns full data for any game regardless of rating or age restriction — no cookies needed.
247
+
248
+ The **store webpage** (`store.steampowered.com/app/{appid}/`) redirects mature games to an age verification form:
249
+
250
+ ```
251
+ GET https://store.steampowered.com/app/292030/
252
+ -> 302 -> https://store.steampowered.com/agecheck/app/292030/
253
+ ```
254
+
255
+ To bypass the age gate on the store page, send the `birthtime` cookie:
256
+
257
+ ```python
258
+ import urllib.request
259
+
260
+ def get_store_page(appid):
261
+ """Fetch game store HTML page, bypassing age gate."""
262
+ req = urllib.request.Request(
263
+ f"https://store.steampowered.com/app/{appid}/",
264
+ headers={
265
+ "User-Agent": "Mozilla/5.0",
266
+ "Cookie": "birthtime=631152001; lastagecheckage=1-January-1990"
267
+ }
268
+ )
269
+ with urllib.request.urlopen(req, timeout=15) as r:
270
+ html = r.read().decode("utf-8", errors="replace")
271
+ if "agecheck" in r.url:
272
+ return None # Age gate not bypassed
273
+ return html
274
+ ```
275
+
276
+ `birthtime=631152001` = January 1, 1990 in Unix time. Steam accepts any date before the current year minus the required age.
277
+
278
+ ---
279
+
280
+ ## Search
281
+
282
+ ### storesearch API (title search, up to 10 results)
283
+
284
+ ```python
285
+ import json, urllib.parse
286
+ from helpers import http_get
287
+
288
+ def search_games(term, cc="US", lang="english"):
289
+ """
290
+ Returns up to 10 matching apps/DLC/bundles.
291
+ No pagination — always exactly 10 results max.
292
+ """
293
+ q = urllib.parse.quote(term)
294
+ resp = http_get(
295
+ f"https://store.steampowered.com/api/storesearch/?term={q}&l={lang}&cc={cc}"
296
+ )
297
+ data = json.loads(resp)
298
+ return data["items"]
299
+
300
+ results = search_games("witcher")
301
+ # [
302
+ # {"type": "app", "name": "The Witcher 3: Wild Hunt", "id": 292030,
303
+ # "price": {"currency": "USD", "initial": 3999, "final": 3999},
304
+ # "tiny_image": "https://...", "metascore": "93",
305
+ # "platforms": {"windows": True, "mac": False, "linux": False},
306
+ # "streamingvideo": False},
307
+ # {"type": "sub", ...}, # bundles have type="sub"
308
+ # ...
309
+ # ]
310
+ ```
311
+
312
+ **Search result fields:**
313
+ - `id` — appid (or subid for bundles)
314
+ - `type` — `"app"` | `"sub"` (bundle)
315
+ - `name` — game title
316
+ - `price` — `{"currency": "USD", "initial": cents, "final": cents}` — `None` for F2P
317
+ - `metascore` — string e.g. `"93"`, `"0"` if no score
318
+ - `platforms` — `{"windows": bool, "mac": bool, "linux": bool}`
319
+ - `tiny_image` — 231x87px capsule image URL
320
+ - `streamingvideo` — bool
321
+
322
+ **Note:** `price` in search results has only `initial` and `final` — no `discount_percent` or `formatted` strings. Use `appdetails` for full pricing.
323
+
324
+ ---
325
+
326
+ ## Review scores and user reviews
327
+
328
+ ```python
329
+ import json, urllib.parse
330
+ from helpers import http_get
331
+
332
+ def get_reviews(appid, num=10, language="english", filter="recent",
333
+ review_type="all", purchase_type="all", cursor="*"):
334
+ """
335
+ filter: "recent" | "updated" | "all"
336
+ review_type: "all" | "positive" | "negative"
337
+ purchase_type: "all" | "steam" | "non_steam_purchase"
338
+ language: "english" | "all" | ISO code
339
+ cursor: use returned cursor for next page (URL-encode it)
340
+ """
341
+ encoded_cursor = urllib.parse.quote(cursor)
342
+ resp = http_get(
343
+ f"https://store.steampowered.com/appreviews/{appid}"
344
+ f"?json=1&num_per_page={num}&language={language}"
345
+ f"&filter={filter}&review_type={review_type}"
346
+ f"&purchase_type={purchase_type}&cursor={encoded_cursor}"
347
+ )
348
+ return json.loads(resp)
349
+
350
+ result = get_reviews(292030, num=5, language="english")
351
+
352
+ # result["success"] -> 1 (int, not bool)
353
+ # result["cursor"] -> "AoJ4rq..." (base64, URL-encode for next page)
354
+ # result["query_summary"]["review_score"] -> 9 (0–9 score)
355
+ # result["query_summary"]["review_score_desc"] -> "Overwhelmingly Positive"
356
+ # result["query_summary"]["total_positive"] -> 226883
357
+ # result["query_summary"]["total_negative"] -> 7499
358
+ # result["query_summary"]["total_reviews"] -> 234382 (steam purchase only)
359
+ # result["reviews"] -> list of review objects
360
+ ```
361
+
362
+ **Review score descriptions (review_score int to string):**
363
+
364
+ | Score | Description |
365
+ |-------|-------------|
366
+ | 9 | Overwhelmingly Positive |
367
+ | 8 | Very Positive |
368
+ | 7 | Mostly Positive |
369
+ | 6 | Positive (Mixed) |
370
+ | 5 | Mixed |
371
+ | 4 | Mostly Negative |
372
+ | 3 | Negative |
373
+ | 2 | Mostly Negative |
374
+ | 1 | Overwhelmingly Negative |
375
+ | 0 | No reviews |
376
+
377
+ **Confirmed scores:** Witcher 3 = 9, Counter-Strike 2 = 8, Stardew Valley = 9, Factorio = 9.
378
+
379
+ ### Review object fields
380
+
381
+ ```python
382
+ review = result["reviews"][0]
383
+ review["recommendationid"] # "221423937" — unique review ID
384
+ review["voted_up"] # True/False — positive/negative
385
+ review["votes_up"] # 213 — helpful votes
386
+ review["votes_funny"] # 66
387
+ review["weighted_vote_score"] # 0.8405... — Steam's helpfulness score
388
+ review["comment_count"] # 20
389
+ review["steam_purchase"] # True
390
+ review["received_for_free"] # False
391
+ review["written_during_early_access"]# False
392
+ review["timestamp_created"] # 1774209092 (Unix timestamp)
393
+ review["timestamp_updated"] # Unix timestamp
394
+ review["language"] # "english"
395
+ review["review"] # review text
396
+ review["app_release_date"] # Unix timestamp of game release
397
+
398
+ review["author"]["steamid"] # "76561198..."
399
+ review["author"]["personaname"] # display name
400
+ review["author"]["num_games_owned"] # 1039
401
+ review["author"]["num_reviews"] # 180
402
+ review["author"]["playtime_forever"] # 1146 (minutes total)
403
+ review["author"]["playtime_last_two_weeks"] # minutes in last 2 weeks
404
+ review["author"]["playtime_at_review"] # minutes at time of review
405
+ review["author"]["last_played"] # Unix timestamp
406
+ ```
407
+
408
+ ### Cursor-based pagination
409
+
410
+ ```python
411
+ import urllib.parse, json
412
+ from helpers import http_get
413
+
414
+ def get_all_reviews(appid, max_pages=5, num_per_page=100, language="all"):
415
+ """Paginate through reviews using cursor."""
416
+ cursor = "*"
417
+ all_reviews = []
418
+ for _ in range(max_pages):
419
+ resp = http_get(
420
+ f"https://store.steampowered.com/appreviews/{appid}"
421
+ f"?json=1&num_per_page={num_per_page}&language={language}"
422
+ f"&filter=recent&cursor={urllib.parse.quote(cursor)}"
423
+ )
424
+ data = json.loads(resp)
425
+ batch = data.get("reviews", [])
426
+ if not batch:
427
+ break
428
+ all_reviews.extend(batch)
429
+ cursor = data.get("cursor", "")
430
+ if not cursor:
431
+ break
432
+ return all_reviews
433
+ ```
434
+
435
+ ---
436
+
437
+ ## Featured games
438
+
439
+ ### Featured items (rotating store front)
440
+
441
+ ```python
442
+ import json
443
+ from helpers import http_get
444
+
445
+ data = json.loads(http_get("https://store.steampowered.com/api/featured/"))
446
+ # data["large_capsules"] -> 1-3 hero banner items
447
+ # data["featured_win"] -> 10 featured items for Windows
448
+ # data["featured_mac"] -> macOS featured
449
+ # data["featured_linux"] -> Linux featured
450
+ # data["status"] -> 1
451
+
452
+ item = data["featured_win"][0]
453
+ # item["id"] -> appid
454
+ # item["name"] -> game title
455
+ # item["discounted"] -> bool
456
+ # item["discount_percent"] -> 0-100
457
+ # item["original_price"] -> cents
458
+ # item["final_price"] -> cents
459
+ # item["currency"] -> "USD"
460
+ # item["windows_available"] -> bool
461
+ # item["mac_available"] -> bool
462
+ # item["linux_available"] -> bool
463
+ # item["large_capsule_image"] -> URL
464
+ # item["small_capsule_image"] -> URL
465
+ # item["header_image"] -> URL
466
+ # item["controller_support"] -> "full" | "partial" | ""
467
+ ```
468
+
469
+ ### Featured categories (top sellers, specials, new releases, coming soon)
470
+
471
+ ```python
472
+ data = json.loads(http_get("https://store.steampowered.com/api/featuredcategories/"))
473
+
474
+ # Named sections (most useful):
475
+ specials = data["specials"]["items"] # 10 on-sale games
476
+ top_sellers = data["top_sellers"]["items"] # 10 top sellers
477
+ new_releases= data["new_releases"]["items"] # 30 new releases
478
+ coming_soon = data["coming_soon"]["items"] # 10 upcoming games
479
+
480
+ # Numbered keys "0" through "7" are spotlight banners (deals/events)
481
+
482
+ item = top_sellers[0]
483
+ # item["id"] -> appid
484
+ # item["name"] -> game title
485
+ # item["discounted"] -> bool
486
+ # item["discount_percent"] -> 0-100
487
+ # item["original_price"] -> cents (None for upcoming games)
488
+ # item["final_price"] -> cents (0 for upcoming)
489
+ # item["currency"] -> "USD"
490
+ # item["discount_expiration"] -> Unix timestamp (present for active sales)
491
+ # item["windows_available"] -> bool
492
+ # item["mac_available"] -> bool
493
+ # item["linux_available"] -> bool
494
+ # item["header_image"] -> URL
495
+ ```
496
+
497
+ ---
498
+
499
+ ## App list (all Steam apps)
500
+
501
+ The `ISteamApps/GetAppList` API endpoint (v1, v2, v0001, v0002) currently returns **HTTP 404** from `api.steampowered.com` as of 2026-04-18. The endpoint is effectively retired without a Steamworks API key.
502
+
503
+ **Workaround:** Use the featured categories and search APIs to discover appids, then batch-fetch via `appdetails`.
504
+
505
+ ```python
506
+ # Discover appids from top sellers + new releases
507
+ import json
508
+ from helpers import http_get
509
+
510
+ def get_all_store_appids():
511
+ data = json.loads(http_get("https://store.steampowered.com/api/featuredcategories/"))
512
+ appids = set()
513
+ for key in ["specials", "top_sellers", "new_releases", "coming_soon"]:
514
+ for item in data.get(key, {}).get("items", []):
515
+ appids.add(item["id"])
516
+ for key in ["featured_win", "featured_mac", "featured_linux"]:
517
+ for item in data.get(key, []):
518
+ appids.add(item["id"])
519
+ return sorted(appids)
520
+
521
+ # Returns ~50 store-front appids (enough to seed further discovery)
522
+ ```
523
+
524
+ ---
525
+
526
+ ## Rate limits
527
+
528
+ Steam's public APIs are generous. Confirmed during testing:
529
+
530
+ - **10 sequential requests in 1.59s** — all HTTP 200, no throttling
531
+ - **10 concurrent requests (5 workers) in 0.54s** — all succeeded
532
+ - **No `Retry-After` header** observed at any concurrency level
533
+
534
+ Practical limits (undocumented, inferred from community reports):
535
+ - ~200 requests/5 minutes per IP to `appdetails` before soft throttling (returns `success: false`)
536
+ - Review API is more restrictive — keep to ~50 requests/minute
537
+
538
+ ---
539
+
540
+ ## Gotchas
541
+
542
+ **`success: false` with no data field** — When an appid is invalid, removed, or unreleased, the response is `{"999999": {"success": false}}` with no `data` key. Always check `entry["success"]` before accessing `entry["data"]`.
543
+
544
+ ```python
545
+ entry = json.loads(resp)[str(appid)]
546
+ if not entry["success"]:
547
+ return None # game removed or never existed
548
+ game = entry["data"]
549
+ ```
550
+
551
+ **Multiple appids in one call — not supported** — `appids=292030,570` returns HTTP 400. The API only accepts a single appid per call. Use `ThreadPoolExecutor` for bulk fetching.
552
+
553
+ **`price_overview` is `None` for free games** — When `is_free=True`, the `price_overview` key is absent or `None`. Never index `game["price_overview"]["final"]` without a None check.
554
+
555
+ **`initial_formatted` is empty string when not on sale** — When `discount_percent == 0`, `initial_formatted` is `""`. Only `final_formatted` is reliably present and non-empty. Use `final_formatted` for display in all cases.
556
+
557
+ **Store page age gate** — `store.steampowered.com/app/{appid}/` redirects mature games to `/agecheck/app/{appid}/`. The `appdetails` API completely bypasses this — no cookies needed. For browser-based scraping of the store page, send `Cookie: birthtime=631152001; lastagecheckage=1-January-1990`.
558
+
559
+ **`storesearch` always returns ≤ 10 results** — No pagination. `total` in the response is always 10, not the true result count. For finding specific games, this is sufficient. For catalog browsing, use `appdetails` with known appids.
560
+
561
+ **`metascore` is string `"0"` in search results, int `93` in appdetails** — Inconsistent types. In `storesearch` results, `metascore` is a string (e.g. `"93"`, `"0"`). In `appdetails`, `metacritic` is a dict `{"score": 93, "url": "..."}` or absent entirely. Always `int()` the storesearch value.
562
+
563
+ **`appdetails` returns `type: "dlc"` for DLC** — Check `game["type"]` before treating every appid as a standalone game. Type values: `"game"`, `"dlc"`, `"demo"`, `"advertising"`, `"mod"`, `"video"`.
564
+
565
+ **`ratings` dict uses string booleans** — `use_age_gate` and `required_age` inside `ratings[board]` are strings (`"true"`, `"17"`), not native types. `banned` is also a string `"0"` or `"1"`.
566
+
567
+ **`ISteamApps/GetAppList` is dead** — HTTP 404 for v1, v2, v0001, v0002 endpoints as of 2026-04-18. Use store front APIs and search to discover appids instead.
568
+
569
+ **`supported_languages` is HTML** — The field contains escaped HTML like `English<strong>*</strong>, French`. Starred languages have full audio. Use `html.unescape()` and strip tags to get a clean list.
570
+
571
+ **`release_date.date` is a locale string, not ISO** — Value is `"May 18, 2015"` not `"2015-05-18"`. Parse with `datetime.strptime(d, "%B %d, %Y")` or use regex.
572
+
573
+ **Review `purchase_type` changes total counts** — `purchase_type=all` includes reviews from non-Steam purchases (physical, Humble, etc.). `purchase_type=steam` is Steam-only. Witcher 3 example: `all`=802,072 reviews, `steam`=234,385.
574
+
575
+ **Currency requires `cc=` param** — Without `cc=`, you get USD by default. Pass `cc=GB` for GBP, `cc=DE` for EUR, etc. Country codes are ISO-3166 (2-letter, uppercase).