@pencil-agent/nano-pencil 2.0.0-beta.8 → 2.0.0

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