@pencil-agent/nano-pencil 2.0.1 → 2.0.2

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 (186) 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/model/custom-providers.js +1 -1
  7. package/dist/core/model-registry.js +5 -5
  8. package/dist/extensions/builtin/AGENT.md +115 -115
  9. package/dist/extensions/builtin/browser/AGENT.md +17 -17
  10. package/dist/extensions/builtin/browser/agent-workspace/agent_helpers.py +12 -12
  11. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/amazon/product-search.md +198 -198
  12. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/archive-org/scraping.md +341 -341
  13. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/arxiv/scraping.md +311 -311
  14. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/arxiv-bulk/scraping.md +333 -333
  15. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/atlas/overview.md +70 -70
  16. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/booking-com/scraping.md +578 -578
  17. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/capterra/scraping.md +440 -440
  18. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/centilebrain/generate-estimates.md +110 -110
  19. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coingecko/scraping.md +325 -325
  20. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coinmarketcap/scraping.md +463 -463
  21. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coursera/scraping.md +360 -360
  22. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/craigslist/scraping.md +390 -390
  23. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/crossref/scraping.md +568 -568
  24. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/dev-to/scraping.md +323 -323
  25. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/duckduckgo/scraping.md +349 -349
  26. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/ebay/scraping.md +435 -435
  27. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/etsy/scraping.md +506 -506
  28. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/eventbrite/scraping.md +363 -363
  29. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/expedia/automation.md +168 -168
  30. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/facebook/groups.md +236 -236
  31. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/facebook/pages.md +295 -295
  32. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/framer/editor.md +108 -108
  33. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/fred/scraping.md +493 -493
  34. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/g2/scraping.md +580 -580
  35. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/genius/scraping.md +511 -511
  36. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/github/repo-actions.md +65 -65
  37. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/github/scraping.md +184 -184
  38. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/glassdoor/scraping.md +543 -543
  39. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/gmail/compose.md +122 -122
  40. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/goodreads/scraping.md +461 -461
  41. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/gutenberg/scraping.md +383 -383
  42. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/hackernews/scraping.md +243 -243
  43. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/howlongtobeat/scraping.md +473 -473
  44. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/imdb/scraping.md +271 -271
  45. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/itch-io/scraping.md +436 -436
  46. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/job-boards/indeed-glassdoor.md +1021 -1021
  47. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/letterboxd/scraping.md +349 -349
  48. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/linkedin/invitation-manager.md +109 -109
  49. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/loom/folder-enumeration.md +170 -170
  50. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/macrotrends/scraping.md +537 -537
  51. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/medium/article-hydration.md +120 -120
  52. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/medium/scraping.md +414 -414
  53. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/metacritic/scraping.md +477 -477
  54. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/musicbrainz/scraping.md +478 -478
  55. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/nasa/scraping.md +339 -339
  56. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/news-aggregation/multi-source.md +205 -205
  57. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/open-library/scraping.md +472 -472
  58. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/openalex/scraping.md +470 -470
  59. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/openstreetmap/scraping.md +490 -490
  60. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/package-registries/npm-pypi.md +478 -478
  61. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/polymarket/scraping.md +234 -234
  62. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/producthunt/scraping.md +307 -307
  63. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/pubmed/scraping.md +421 -421
  64. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/quora/scraping.md +364 -364
  65. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/rawg/scraping.md +352 -352
  66. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/reddit/scraping.md +124 -124
  67. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/rest-countries/scraping.md +233 -233
  68. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/sec-edgar/scraping.md +361 -361
  69. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/README.md +36 -36
  70. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/embedded-apps.md +72 -72
  71. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/knowledge-base.md +109 -109
  72. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/polaris-inputs.md +137 -137
  73. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/soundcloud/scraping.md +362 -362
  74. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/spotify/scraping.md +339 -339
  75. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/stackoverflow/scraping.md +435 -435
  76. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/steam/scraping.md +575 -575
  77. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/substack/scraping.md +338 -338
  78. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/thetechgeeks/pricing.md +52 -52
  79. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/tiktok/upload.md +107 -107
  80. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/tradingview/scraping.md +309 -309
  81. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/trello/boards-and-lists.md +88 -88
  82. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/trustpilot/scraping.md +375 -375
  83. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/walmart/scraping.md +444 -444
  84. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/wayback-machine/scraping.md +306 -306
  85. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/weather/scraping.md +398 -398
  86. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/wellfound/scraping.md +596 -596
  87. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/world-bank/scraping.md +356 -356
  88. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/xiaohongshu/scraping.md +84 -84
  89. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/youtube/scraping.md +418 -418
  90. package/dist/extensions/builtin/browser/agent-workspace/domain-skills/zillow/scraping.md +433 -433
  91. package/dist/extensions/builtin/browser/browser.md +73 -73
  92. package/dist/extensions/builtin/browser/install.md +142 -142
  93. package/dist/extensions/builtin/browser/interaction-skills/connection.md +48 -48
  94. package/dist/extensions/builtin/browser/interaction-skills/cookies.md +3 -3
  95. package/dist/extensions/builtin/browser/interaction-skills/cross-origin-iframes.md +3 -3
  96. package/dist/extensions/builtin/browser/interaction-skills/dialogs.md +64 -64
  97. package/dist/extensions/builtin/browser/interaction-skills/downloads.md +3 -3
  98. package/dist/extensions/builtin/browser/interaction-skills/drag-and-drop.md +3 -3
  99. package/dist/extensions/builtin/browser/interaction-skills/dropdowns.md +3 -3
  100. package/dist/extensions/builtin/browser/interaction-skills/iframes.md +3 -3
  101. package/dist/extensions/builtin/browser/interaction-skills/network-requests.md +3 -3
  102. package/dist/extensions/builtin/browser/interaction-skills/print-as-pdf.md +3 -3
  103. package/dist/extensions/builtin/browser/interaction-skills/profile-sync.md +90 -90
  104. package/dist/extensions/builtin/browser/interaction-skills/screenshots.md +17 -17
  105. package/dist/extensions/builtin/browser/interaction-skills/scrolling.md +3 -3
  106. package/dist/extensions/builtin/browser/interaction-skills/shadow-dom.md +3 -3
  107. package/dist/extensions/builtin/browser/interaction-skills/tabs.md +69 -69
  108. package/dist/extensions/builtin/browser/interaction-skills/uploads.md +1 -1
  109. package/dist/extensions/builtin/browser/interaction-skills/viewport.md +3 -3
  110. package/dist/extensions/builtin/browser/src/browser_harness/AGENT.md +15 -15
  111. package/dist/extensions/builtin/browser/src/browser_harness/__init__.py +8 -8
  112. package/dist/extensions/builtin/browser/src/browser_harness/_ipc.py +90 -90
  113. package/dist/extensions/builtin/browser/src/browser_harness/admin.py +722 -722
  114. package/dist/extensions/builtin/browser/src/browser_harness/daemon.py +328 -328
  115. package/dist/extensions/builtin/browser/src/browser_harness/helpers.py +396 -396
  116. package/dist/extensions/builtin/browser/src/browser_harness/run.py +103 -103
  117. package/dist/extensions/builtin/discipline/skills/brainstorming/SKILL.md +33 -33
  118. package/dist/extensions/builtin/discipline/skills/executing-plans/SKILL.md +25 -25
  119. package/dist/extensions/builtin/discipline/skills/finishing-development-branch/SKILL.md +25 -25
  120. package/dist/extensions/builtin/discipline/skills/receiving-code-review/SKILL.md +22 -22
  121. package/dist/extensions/builtin/discipline/skills/requesting-code-review/SKILL.md +31 -31
  122. package/dist/extensions/builtin/discipline/skills/systematic-debugging/SKILL.md +28 -28
  123. package/dist/extensions/builtin/discipline/skills/test-driven-development/SKILL.md +32 -32
  124. package/dist/extensions/builtin/discipline/skills/using-git-worktrees/SKILL.md +25 -25
  125. package/dist/extensions/builtin/discipline/skills/verification-before-completion/SKILL.md +27 -27
  126. package/dist/extensions/builtin/discipline/skills/writing-plans/SKILL.md +26 -26
  127. package/dist/extensions/builtin/goal/README.md +67 -67
  128. package/dist/extensions/builtin/grub/README.md +112 -112
  129. package/dist/extensions/builtin/link-world/agent-workspace/README.md +16 -16
  130. package/dist/extensions/builtin/link-world/internet-search/internet-search.md +65 -65
  131. package/dist/extensions/builtin/link-world/link-world-agent.md +82 -82
  132. package/dist/extensions/builtin/link-world/linkworld.md +313 -313
  133. package/dist/extensions/builtin/link-world/network-routing/network-routing.md +67 -67
  134. package/dist/extensions/builtin/loop/README.md +92 -92
  135. package/dist/extensions/builtin/mcp/figma-design.md +68 -68
  136. package/dist/extensions/builtin/mcp/mcp-management.md +85 -85
  137. package/dist/extensions/builtin/recap/AGENT.md +15 -15
  138. package/dist/extensions/builtin/sal/README.md +72 -72
  139. package/dist/extensions/builtin/security-audit/README.md +289 -289
  140. package/dist/extensions/builtin/team/AGENT.md +112 -112
  141. package/dist/extensions/builtin/team/TESTING.md +299 -299
  142. package/dist/extensions/builtin/token-save/README.md +56 -56
  143. package/dist/extensions/optional/AGENT.md +10 -10
  144. package/dist/modes/interactive/controllers/input-submit-controller.js +2 -2
  145. package/dist/modes/interactive/controllers/stream-render-controller.js +2 -2
  146. package/dist/modes/interactive/interactive-mode.js +19 -19
  147. package/dist/modes/interactive/theme/dark.json +85 -85
  148. package/dist/modes/interactive/theme/light.json +84 -84
  149. package/dist/modes/interactive/theme/theme-schema.json +335 -335
  150. package/dist/modes/interactive/theme/warm.json +81 -81
  151. package/dist/node_modules/@pencil-agent/ai/dist/cli.js +0 -0
  152. package/dist/node_modules/@pencil-agent/ai/dist/models.generated.js +1 -1
  153. 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 +851 -0
  154. package/docs/SDK-TESTING.md +364 -0
  155. package/docs/codex-goal-command-impl.md +1055 -1055
  156. package/docs/codex-goal-vs-grub.md +500 -500
  157. package/docs/custom-provider.md +27 -27
  158. package/docs/extensions.md +27 -27
  159. package/docs/keybindings.md +27 -27
  160. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/200/273/347/273/223.md" +250 -250
  161. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/212/245/345/221/212.md" +122 -122
  162. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210.md" +1222 -1222
  163. 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
  164. 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
  165. package/docs/loop /351/207/215/346/236/204/350/256/241/345/210/222.md" +320 -320
  166. package/docs/loop-usage-examples.md +214 -214
  167. package/docs/mem-core/346/212/200/346/234/257/346/226/207/346/241/243.md +593 -0
  168. package/docs/models.md +27 -27
  169. package/docs/packages.md +27 -27
  170. package/docs/pi-design-philosophy.md +457 -457
  171. package/docs/planmode.md +1987 -1987
  172. package/docs/prompt-templates.md +27 -27
  173. package/docs/providers.md +27 -27
  174. package/docs/sdk.md +27 -27
  175. package/docs/skills.md +27 -27
  176. package/docs/startup-performance-optimization.md +301 -0
  177. package/docs/themes.md +27 -27
  178. package/docs/tui.md +27 -27
  179. package/docs//350/256/244/347/237/245/345/234/260/345/233/276.md +47 -0
  180. package/package.json +190 -190
  181. package/docs/cc-agent-design.md +0 -1297
  182. package/docs/cc-tui-design.md +0 -1333
  183. package/docs/nanoPencil-/345/255/246/344/271/240/350/256/241/345/210/222.md +0 -170
  184. package/docs/scan-report.md +0 -3820
  185. package/docs//345/257/271/346/240/207Claude-Code.md +0 -1775
  186. 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 +0 -261
@@ -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).