@pencil-agent/nano-pencil 2.0.1 → 2.0.3

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 (188) 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/debug/index.js +9 -9
  118. package/dist/extensions/builtin/discipline/skills/brainstorming/SKILL.md +33 -33
  119. package/dist/extensions/builtin/discipline/skills/executing-plans/SKILL.md +25 -25
  120. package/dist/extensions/builtin/discipline/skills/finishing-development-branch/SKILL.md +25 -25
  121. package/dist/extensions/builtin/discipline/skills/receiving-code-review/SKILL.md +22 -22
  122. package/dist/extensions/builtin/discipline/skills/requesting-code-review/SKILL.md +31 -31
  123. package/dist/extensions/builtin/discipline/skills/systematic-debugging/SKILL.md +28 -28
  124. package/dist/extensions/builtin/discipline/skills/test-driven-development/SKILL.md +32 -32
  125. package/dist/extensions/builtin/discipline/skills/using-git-worktrees/SKILL.md +25 -25
  126. package/dist/extensions/builtin/discipline/skills/verification-before-completion/SKILL.md +27 -27
  127. package/dist/extensions/builtin/discipline/skills/writing-plans/SKILL.md +26 -26
  128. package/dist/extensions/builtin/goal/README.md +67 -67
  129. package/dist/extensions/builtin/goal/index.js +6 -6
  130. package/dist/extensions/builtin/grub/README.md +112 -112
  131. package/dist/extensions/builtin/link-world/agent-workspace/README.md +16 -16
  132. package/dist/extensions/builtin/link-world/internet-search/internet-search.md +65 -65
  133. package/dist/extensions/builtin/link-world/link-world-agent.md +82 -82
  134. package/dist/extensions/builtin/link-world/linkworld.md +313 -313
  135. package/dist/extensions/builtin/link-world/network-routing/network-routing.md +67 -67
  136. package/dist/extensions/builtin/loop/README.md +92 -92
  137. package/dist/extensions/builtin/mcp/figma-design.md +68 -68
  138. package/dist/extensions/builtin/mcp/mcp-management.md +85 -85
  139. package/dist/extensions/builtin/recap/AGENT.md +15 -15
  140. package/dist/extensions/builtin/sal/README.md +72 -72
  141. package/dist/extensions/builtin/security-audit/README.md +289 -289
  142. package/dist/extensions/builtin/team/AGENT.md +112 -112
  143. package/dist/extensions/builtin/team/TESTING.md +299 -299
  144. package/dist/extensions/builtin/token-save/README.md +56 -56
  145. package/dist/extensions/optional/AGENT.md +10 -10
  146. package/dist/modes/interactive/controllers/input-submit-controller.js +2 -2
  147. package/dist/modes/interactive/controllers/stream-render-controller.js +2 -2
  148. package/dist/modes/interactive/interactive-mode.js +19 -19
  149. package/dist/modes/interactive/theme/dark.json +85 -85
  150. package/dist/modes/interactive/theme/light.json +84 -84
  151. package/dist/modes/interactive/theme/theme-schema.json +335 -335
  152. package/dist/modes/interactive/theme/warm.json +81 -81
  153. package/dist/node_modules/@pencil-agent/ai/dist/cli.js +0 -0
  154. package/dist/node_modules/@pencil-agent/ai/dist/models.generated.js +1 -1
  155. 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
  156. package/docs/SDK-TESTING.md +364 -0
  157. package/docs/codex-goal-command-impl.md +1055 -1055
  158. package/docs/codex-goal-vs-grub.md +500 -500
  159. package/docs/custom-provider.md +27 -27
  160. package/docs/extensions.md +27 -27
  161. package/docs/keybindings.md +27 -27
  162. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/200/273/347/273/223.md" +250 -250
  163. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/212/245/345/221/212.md" +122 -122
  164. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210.md" +1222 -1222
  165. 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
  166. 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
  167. package/docs/loop /351/207/215/346/236/204/350/256/241/345/210/222.md" +320 -320
  168. package/docs/loop-usage-examples.md +214 -214
  169. package/docs/mem-core/346/212/200/346/234/257/346/226/207/346/241/243.md +593 -0
  170. package/docs/models.md +27 -27
  171. package/docs/packages.md +27 -27
  172. package/docs/pi-design-philosophy.md +457 -457
  173. package/docs/planmode.md +1987 -1987
  174. package/docs/prompt-templates.md +27 -27
  175. package/docs/providers.md +27 -27
  176. package/docs/sdk.md +27 -27
  177. package/docs/skills.md +27 -27
  178. package/docs/startup-performance-optimization.md +301 -0
  179. package/docs/themes.md +27 -27
  180. package/docs/tui.md +27 -27
  181. package/docs//350/256/244/347/237/245/345/234/260/345/233/276.md +47 -0
  182. package/package.json +190 -190
  183. package/docs/cc-agent-design.md +0 -1297
  184. package/docs/cc-tui-design.md +0 -1333
  185. package/docs/nanoPencil-/345/255/246/344/271/240/350/256/241/345/210/222.md +0 -170
  186. package/docs/scan-report.md +0 -3820
  187. package/docs//345/257/271/346/240/207Claude-Code.md +0 -1775
  188. 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,398 +1,398 @@
1
- # Weather APIs — Data Extraction
2
-
3
- Three free, no-auth weather APIs tested: **wttr.in** (simplest), **Open-Meteo** (most complete), **weather.gov / NWS** (US only, official).
4
-
5
- All work with `http_get` — no browser needed.
6
-
7
- ## Do this first: pick your API
8
-
9
- | Goal | Best API | Latency | Notes |
10
- |------|----------|---------|-------|
11
- | Quick current + 3-day forecast, any city name | wttr.in `?format=j1` | ~800ms | US + international |
12
- | Rich hourly/daily/historical, any coordinates | Open-Meteo | ~700ms | 10K req/day free |
13
- | City name → coordinates | Open-Meteo geocoding | ~700ms | Use with Open-Meteo forecast |
14
- | Official US forecasts with PoP and text | weather.gov NWS | ~90ms /points + ~70ms /forecast | US only, 2-call flow |
15
-
16
- **Never use a browser for any of these APIs.** All return JSON over plain HTTP.
17
-
18
- ---
19
-
20
- ## Fastest approach: wttr.in one-call current + 3-day forecast
21
-
22
- ```python
23
- import json
24
- data = json.loads(http_get("https://wttr.in/San+Francisco?format=j1"))
25
-
26
- # Current conditions
27
- cc = data['current_condition'][0]
28
- print(cc['temp_F'], '°F /', cc['temp_C'], '°C') # '47', '8'
29
- print(cc['FeelsLikeF'], '°F feels like') # '46'
30
- print(cc['humidity'], '%') # '80'
31
- print(cc['windspeedMiles'], 'mph', cc['winddir16Point']) # '3', 'SW'
32
- print(cc['weatherDesc'][0]['value']) # 'Partly cloudy'
33
- print(cc['precipMM'], 'mm precip') # '0.0'
34
- print(cc['visibility'], 'km', cc['visibilityMiles'], 'mi')
35
- print(cc['pressure'], 'hPa', cc['pressureInches'], 'inHg')
36
- print(cc['uvIndex']) # '0'
37
- print(cc['cloudcover'], '%') # '50'
38
- print(cc['observation_time']) # '10:48 AM' (UTC)
39
- print(cc['localObsDateTime']) # '2026-04-18 03:34 AM' (local)
40
-
41
- # 3-day forecast (today + 2 more)
42
- for day in data['weather']:
43
- print(day['date'], day['maxtempF'], '/', day['mintempF'], '°F')
44
- # also: maxtempC, mintempC, avgtempF, avgtempC, sunHour, uvIndex, totalSnow_cm
45
- astro = day['astronomy'][0]
46
- print(' sunrise:', astro['sunrise'], 'sunset:', astro['sunset'])
47
- print(' moon:', astro['moon_phase'], astro['moon_illumination'], '%')
48
- # Hourly breakdown (8 entries per day, every 3 hours: time 0,300,600,...,2100)
49
- for h in day['hourly']:
50
- print(h['time'], h['tempF'], '°F', h['weatherDesc'][0]['value'])
51
- # time is '0','300','600',...,'2100' (not HH:MM)
52
- # also: chanceofrain, chanceofsnow, chanceofthunder, chanceoffog, humidity, etc.
53
-
54
- # Location info
55
- na = data['nearest_area'][0]
56
- print(na['areaName'][0]['value']) # 'San Francisco'
57
- print(na['country'][0]['value']) # 'United States of America'
58
- print(na['latitude'], na['longitude']) # '37.775', '-122.418' (strings)
59
- print(na['region'][0]['value']) # 'California'
60
- ```
61
-
62
- **Works with city names, coordinates, airport codes (`~SFO`), and zip codes.**
63
-
64
- ---
65
-
66
- ## Open-Meteo: most complete free weather API
67
-
68
- ### Step 1: city name → coordinates (geocoding)
69
-
70
- ```python
71
- import json
72
- geo = json.loads(http_get("https://geocoding-api.open-meteo.com/v1/search?name=Chicago&count=1"))
73
- city = geo['results'][0]
74
- lat = city['latitude'] # 41.85003
75
- lon = city['longitude'] # -87.65005
76
- tz = city['timezone'] # 'America/Chicago'
77
- # Also available: city['elevation'], city['country'], city['country_code'],
78
- # city['admin1'] (state/province), city['population']
79
- ```
80
-
81
- Always use `count=1` and take `results[0]` for unambiguous city names. For "San Francisco" `results[0]` is always the California city (pop 864K).
82
-
83
- ### Current conditions (extended — preferred over current_weather)
84
-
85
- ```python
86
- data = json.loads(http_get(
87
- f"https://api.open-meteo.com/v1/forecast"
88
- f"?latitude={lat}&longitude={lon}"
89
- f"&current=temperature_2m,relative_humidity_2m,apparent_temperature,"
90
- f"precipitation,weathercode,windspeed_10m,winddirection_10m,"
91
- f"uv_index,surface_pressure"
92
- f"&timezone={tz}"
93
- ))
94
-
95
- cur = data['current']
96
- units = data['current_units']
97
- # cur keys and units (all confirmed):
98
- # temperature_2m °C (or °F with &temperature_unit=fahrenheit)
99
- # relative_humidity_2m %
100
- # apparent_temperature °C
101
- # precipitation mm
102
- # weathercode WMO code int (see table below)
103
- # windspeed_10m km/h (or mph with &windspeed_unit=mph)
104
- # winddirection_10m °
105
- # uv_index (unitless float)
106
- # surface_pressure hPa
107
- # time ISO8601 local time (e.g. '2026-04-18T10:45')
108
- # interval 900 (seconds — 15-min update cadence)
109
-
110
- print(cur['temperature_2m'], units['temperature_2m']) # 8.7 °C
111
- print(cur['apparent_temperature']) # 6.6
112
- print(cur['relative_humidity_2m']) # 80
113
- print(cur['windspeed_10m'], cur['winddirection_10m']) # 6.1 242
114
- print(cur['weathercode']) # 0 = clear sky
115
- ```
116
-
117
- The older `&current_weather=true` param works too — returns `data['current_weather']` with only temperature, windspeed, winddirection, weathercode, time, is_day, interval.
118
-
119
- ### Hourly forecast
120
-
121
- ```python
122
- data = json.loads(http_get(
123
- f"https://api.open-meteo.com/v1/forecast"
124
- f"?latitude={lat}&longitude={lon}"
125
- f"&hourly=temperature_2m,dewpoint_2m,apparent_temperature,"
126
- f"precipitation_probability,precipitation,rain,showers,snowfall,snow_depth,"
127
- f"weathercode,cloudcover,visibility,windspeed_10m,winddirection_10m,"
128
- f"windgusts_10m,uv_index"
129
- f"&forecast_days=3&timezone={tz}"
130
- ))
131
-
132
- hourly = data['hourly']
133
- units = data['hourly_units']
134
- # hourly is a dict of parallel arrays, all same length
135
- # time entries: ISO8601 strings, one per hour ('2026-04-18T00:00', etc.)
136
- # 3 forecast days → 72 entries
137
-
138
- for i, t in enumerate(hourly['time'][:5]):
139
- print(t,
140
- hourly['temperature_2m'][i], units['temperature_2m'],
141
- hourly['precipitation_probability'][i], units['precipitation_probability'],
142
- hourly['windspeed_10m'][i], units['windspeed_10m'])
143
-
144
- # Confirmed units (all from live response):
145
- # temperature_2m °C dewpoint_2m °C
146
- # apparent_temperature °C precipitation_probability %
147
- # precipitation mm rain mm
148
- # showers mm snowfall cm
149
- # snow_depth m weathercode wmo code
150
- # cloudcover % visibility m (not km!)
151
- # windspeed_10m km/h winddirection_10m °
152
- # windgusts_10m km/h uv_index (unitless)
153
- ```
154
-
155
- `forecast_days` defaults to 7, max is 16.
156
-
157
- ### Daily forecast
158
-
159
- ```python
160
- data = json.loads(http_get(
161
- f"https://api.open-meteo.com/v1/forecast"
162
- f"?latitude={lat}&longitude={lon}"
163
- f"&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,"
164
- f"apparent_temperature_min,precipitation_sum,rain_sum,snowfall_sum,"
165
- f"precipitation_hours,precipitation_probability_max,"
166
- f"windspeed_10m_max,windgusts_10m_max,winddirection_10m_dominant,"
167
- f"shortwave_radiation_sum,uv_index_max,sunrise,sunset"
168
- f"&timezone={tz}&forecast_days=7"
169
- ))
170
-
171
- daily = data['daily']
172
- units = data['daily_units']
173
- for i, date in enumerate(daily['time']):
174
- print(date,
175
- daily['temperature_2m_max'][i], '/', daily['temperature_2m_min'][i], units['temperature_2m_max'],
176
- f"precip={daily['precipitation_sum'][i]}{units['precipitation_sum']}",
177
- f"pop={daily['precipitation_probability_max'][i]}%",
178
- f"UV={daily['uv_index_max'][i]}",
179
- f"sunrise={daily['sunrise'][i]}",
180
- f"sunset={daily['sunset'][i]}")
181
- # sunrise/sunset are ISO8601 local datetimes ('2026-04-18T06:29')
182
- # shortwave_radiation_sum in MJ/m²
183
- ```
184
-
185
- ### Historical data (archive API)
186
-
187
- Different subdomain — `archive-api.open-meteo.com`:
188
-
189
- ```python
190
- data = json.loads(http_get(
191
- "https://archive-api.open-meteo.com/v1/archive"
192
- "?latitude=37.7749&longitude=-122.4194"
193
- "&start_date=2024-01-01&end_date=2024-01-07"
194
- "&daily=temperature_2m_max,precipitation_sum"
195
- "&timezone=America/Los_Angeles"
196
- ))
197
- # Returns same structure as forecast — daily dict of parallel arrays
198
- # Hourly also works: &hourly=temperature_2m,precipitation,weathercode
199
- # Data goes back to 1940 for most locations
200
- ```
201
-
202
- ### Unit overrides
203
-
204
- All unit conversions are server-side — just add params:
205
-
206
- ```
207
- &temperature_unit=fahrenheit # default: celsius
208
- &windspeed_unit=mph # default: kmh (also: ms, kn)
209
- &precipitation_unit=inch # default: mm
210
- ```
211
-
212
- ---
213
-
214
- ## weather.gov NWS (US only — 2-call flow)
215
-
216
- Required for official NWS text forecasts with probability-of-precipitation text and storm warnings.
217
-
218
- ```python
219
- import json, urllib.request, gzip
220
-
221
- def nws_get(url):
222
- """NWS requires a descriptive User-Agent or returns 403."""
223
- h = {
224
- "User-Agent": "(myapp.example.com, contact@example.com)",
225
- "Accept": "application/geo+json",
226
- }
227
- req = urllib.request.Request(url, headers=h)
228
- with urllib.request.urlopen(req, timeout=20) as r:
229
- data = r.read()
230
- if r.headers.get("Content-Encoding") == "gzip":
231
- data = gzip.decompress(data)
232
- return data.decode()
233
-
234
- # Call 1: resolve lat/lon to forecast office + grid cell (~90ms)
235
- pts = json.loads(nws_get("https://api.weather.gov/points/37.7749,-122.4194"))
236
- prop = pts['properties']
237
- office = prop['gridId'] # 'MTR'
238
- gx = prop['gridX'] # 85
239
- gy = prop['gridY'] # 105
240
- forecast_url = prop['forecast'] # 7-day
241
- hourly_url = prop['forecastHourly'] # hourly
242
-
243
- # Also available from /points: prop['timeZone'], prop['observationStations'],
244
- # prop['relativeLocation']['properties']['city'] and ['state']
245
-
246
- # Call 2: 7-day forecast (14 half-day periods) (~70ms)
247
- fc = json.loads(nws_get(forecast_url))
248
- for p in fc['properties']['periods']:
249
- print(p['name'], # 'Saturday', 'Saturday Night', 'Sunday', ...
250
- p['temperature'], p['temperatureUnit'], # 74 F
251
- p['windSpeed'], p['windDirection'], # '6 to 14 mph' 'SW'
252
- p['shortForecast'], # 'Mostly Sunny'
253
- p['probabilityOfPrecipitation']['value'], # 0 (integer percent)
254
- p['isDaytime']) # True/False
255
- # p['detailedForecast'] — plain English paragraph, e.g.
256
- # 'Sunny, with a high near 74. Southwest wind 6 to 14 mph.'
257
-
258
- # Hourly (156 hours out — ~6.5 days)
259
- fch = json.loads(nws_get(hourly_url))
260
- for p in fch['properties']['periods'][:5]:
261
- print(p['startTime'], # '2026-04-18T03:00:00-07:00'
262
- p['temperature'], '°F',
263
- p['shortForecast'],
264
- p['windSpeed'],
265
- f"humidity={p['relativeHumidity']['value']}%",
266
- f"dewpoint={p['dewpoint']['value']:.1f}°C")
267
- ```
268
-
269
- `/points` response is cached `max-age=20500` (~5.7 hours) at the CDN — safe to call once per session and reuse grid coordinates.
270
-
271
- ---
272
-
273
- ## WMO weather code table (Open-Meteo `weathercode`)
274
-
275
- ```python
276
- WMO_CODES = {
277
- 0: "Clear sky",
278
- 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
279
- 45: "Fog", 48: "Icy fog",
280
- 51: "Light drizzle", 53: "Moderate drizzle", 55: "Dense drizzle",
281
- 61: "Slight rain", 63: "Moderate rain", 65: "Heavy rain",
282
- 71: "Slight snow", 73: "Moderate snow", 75: "Heavy snow",
283
- 77: "Snow grains",
284
- 80: "Slight rain showers", 81: "Moderate rain showers", 82: "Violent rain showers",
285
- 85: "Slight snow showers", 86: "Heavy snow showers",
286
- 95: "Thunderstorm",
287
- 96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail",
288
- }
289
-
290
- def wmo_desc(code):
291
- return WMO_CODES.get(code, f"Unknown code {code}")
292
- ```
293
-
294
- ---
295
-
296
- ## Complete end-to-end pattern: city name → rich forecast
297
-
298
- ```python
299
- import json
300
-
301
- def get_weather(city: str) -> dict:
302
- """City name → current + 7-day daily forecast via Open-Meteo."""
303
- # 1. Geocode
304
- geo = json.loads(http_get(
305
- f"https://geocoding-api.open-meteo.com/v1/search?name={city.replace(' ', '+')}&count=1"
306
- ))
307
- if not geo.get('results'):
308
- raise ValueError(f"City not found: {city}")
309
- loc = geo['results'][0]
310
- lat, lon, tz = loc['latitude'], loc['longitude'], loc['timezone']
311
-
312
- # 2. Forecast (single call: current + daily)
313
- data = json.loads(http_get(
314
- f"https://api.open-meteo.com/v1/forecast"
315
- f"?latitude={lat}&longitude={lon}"
316
- f"&current=temperature_2m,relative_humidity_2m,apparent_temperature,"
317
- f"precipitation,weathercode,windspeed_10m,winddirection_10m,uv_index"
318
- f"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,"
319
- f"precipitation_probability_max,weathercode,sunrise,sunset"
320
- f"&timezone={tz}&forecast_days=7"
321
- ))
322
- return {"location": loc, "current": data['current'],
323
- "daily": data['daily'], "units": {
324
- "current": data['current_units'],
325
- "daily": data['daily_units'],
326
- }}
327
-
328
- result = get_weather("Tokyo")
329
- cur = result['current']
330
- print(f"{result['location']['name']}: {cur['temperature_2m']}°C feels like {cur['apparent_temperature']}°C")
331
- print(f"Humidity {cur['relative_humidity_2m']}%, wind {cur['windspeed_10m']} km/h")
332
- ```
333
-
334
- Total: 2 API calls, ~1400ms combined.
335
-
336
- ---
337
-
338
- ## Gotchas
339
-
340
- **wttr.in returns HTML (or ANSI art) instead of JSON if you forget `?format=j1`.**
341
- The `?format=j1` suffix is mandatory for JSON. Without it:
342
- - Browser `User-Agent` → full HTML page (~21KB)
343
- - `curl`/`Wget` User-Agent → ANSI escape-code ASCII art (~500B)
344
- Neither is parseable as JSON.
345
-
346
- **wttr.in text formats require a non-browser User-Agent.**
347
- `http_get()` sends `Mozilla/5.0` — wttr.in responds with an HTML page for `?format=%t`, `?format=3`, `?format=4`.
348
- Use `Wget/1.21` (or any non-browser UA) for text format endpoints:
349
- ```python
350
- import urllib.request, gzip
351
-
352
- def http_get_wttr(url):
353
- req = urllib.request.Request(url, headers={"User-Agent": "Wget/1.21", "Accept": "*/*"})
354
- with urllib.request.urlopen(req, timeout=20) as r:
355
- data = r.read()
356
- if r.headers.get("Content-Encoding") == "gzip":
357
- data = gzip.decompress(data)
358
- return data.decode()
359
-
360
- # Text format tokens (URL-encode %): %25l=location, %25C=condition desc,
361
- # %25t=temp, %25f=feels-like, %25h=humidity, %25w=wind
362
- print(http_get_wttr("https://wttr.in/London?format=%25t")) # '+55°F'
363
- print(http_get_wttr("https://wttr.in/Tokyo?format=3")) # 'tokyo: ☀️ +69°F'
364
- print(http_get_wttr("https://wttr.in/Berlin?format=%25l:+%25C+%25t+(feels+%25f)+%25h+%25w"))
365
- # 'berlin: Sunny +65°F (feels +65°F) 42% ↖5mph'
366
- ```
367
-
368
- **wttr.in `format=j1` returns only 3 days** (today + 2). Use Open-Meteo for longer forecasts (up to 16 days).
369
-
370
- **wttr.in `nearest_area.areaName` is often wrong.** The returned area name is a reverse-geocoded neighborhood, not the city you queried (`"Mccormickville"` for Chicago, `"Lomita Park"` for SFO airport). Use `request[0].query` for what was actually resolved.
371
-
372
- **wttr.in `hourly[].time` is `'0'`, `'300'`, `'600'`...`'2100'`** — not HH:MM strings. Parse as `int(time) // 100` for hours.
373
-
374
- **wttr.in `weatherDesc` is a list**: `cc['weatherDesc'][0]['value']`, not a string. Same for `areaName`, `country`, `region`, `weatherIconUrl`.
375
-
376
- **wttr.in unknown city returns HTTP 500**, not 404 or a JSON error.
377
-
378
- **Open-Meteo default timezone is GMT.** Always pass `&timezone={tz}` or daily `sunrise`/`sunset` values will be in UTC, and daily buckets will be wrong.
379
-
380
- **Open-Meteo `visibility` is in metres** (not km). Divide by 1000 to get km.
381
-
382
- **Open-Meteo returns HTTP 400 with JSON error body on bad params:**
383
- ```json
384
- {"reason": "Latitude must be in range of -90 to 90°. Given: 999.0.", "error": true}
385
- ```
386
- `http_get()` raises an exception on 4xx — catch `urllib.error.HTTPError` and read `e.read()` (may be gzip-compressed) for the reason.
387
-
388
- **weather.gov requires a descriptive `User-Agent`.** The NWS API blocks generic `python-urllib` or `Mozilla/5.0` agents sporadically. Always set `User-Agent: (yourapp.com, your@email.com)` or use your actual app name.
389
-
390
- **weather.gov is US-only.** `/points/{lat},{lon}` returns HTTP 404 for coordinates outside the US (including territories like Puerto Rico for some grid edges). Fall back to Open-Meteo for non-US locations.
391
-
392
- **weather.gov `windSpeed` is a string like `"6 to 14 mph"`**, not a number. Parse with regex if you need a numeric value.
393
-
394
- **weather.gov `probabilityOfPrecipitation` is a dict**: `p['probabilityOfPrecipitation']['value']`, with `p['probabilityOfPrecipitation']['unitCode']` = `'wmoUnit:percent'`.
395
-
396
- **Open-Meteo rate limit: 10,000 requests/day on the free tier.** The geocoding API and forecast API count separately. No rate limit headers are returned — track usage yourself.
397
-
398
- **weather.gov /points response is heavily cached** (`Cache-Control: public, max-age=20500`). Store the office/gridX/gridY and reuse — only call `/points` once per location.
1
+ # Weather APIs — Data Extraction
2
+
3
+ Three free, no-auth weather APIs tested: **wttr.in** (simplest), **Open-Meteo** (most complete), **weather.gov / NWS** (US only, official).
4
+
5
+ All work with `http_get` — no browser needed.
6
+
7
+ ## Do this first: pick your API
8
+
9
+ | Goal | Best API | Latency | Notes |
10
+ |------|----------|---------|-------|
11
+ | Quick current + 3-day forecast, any city name | wttr.in `?format=j1` | ~800ms | US + international |
12
+ | Rich hourly/daily/historical, any coordinates | Open-Meteo | ~700ms | 10K req/day free |
13
+ | City name → coordinates | Open-Meteo geocoding | ~700ms | Use with Open-Meteo forecast |
14
+ | Official US forecasts with PoP and text | weather.gov NWS | ~90ms /points + ~70ms /forecast | US only, 2-call flow |
15
+
16
+ **Never use a browser for any of these APIs.** All return JSON over plain HTTP.
17
+
18
+ ---
19
+
20
+ ## Fastest approach: wttr.in one-call current + 3-day forecast
21
+
22
+ ```python
23
+ import json
24
+ data = json.loads(http_get("https://wttr.in/San+Francisco?format=j1"))
25
+
26
+ # Current conditions
27
+ cc = data['current_condition'][0]
28
+ print(cc['temp_F'], '°F /', cc['temp_C'], '°C') # '47', '8'
29
+ print(cc['FeelsLikeF'], '°F feels like') # '46'
30
+ print(cc['humidity'], '%') # '80'
31
+ print(cc['windspeedMiles'], 'mph', cc['winddir16Point']) # '3', 'SW'
32
+ print(cc['weatherDesc'][0]['value']) # 'Partly cloudy'
33
+ print(cc['precipMM'], 'mm precip') # '0.0'
34
+ print(cc['visibility'], 'km', cc['visibilityMiles'], 'mi')
35
+ print(cc['pressure'], 'hPa', cc['pressureInches'], 'inHg')
36
+ print(cc['uvIndex']) # '0'
37
+ print(cc['cloudcover'], '%') # '50'
38
+ print(cc['observation_time']) # '10:48 AM' (UTC)
39
+ print(cc['localObsDateTime']) # '2026-04-18 03:34 AM' (local)
40
+
41
+ # 3-day forecast (today + 2 more)
42
+ for day in data['weather']:
43
+ print(day['date'], day['maxtempF'], '/', day['mintempF'], '°F')
44
+ # also: maxtempC, mintempC, avgtempF, avgtempC, sunHour, uvIndex, totalSnow_cm
45
+ astro = day['astronomy'][0]
46
+ print(' sunrise:', astro['sunrise'], 'sunset:', astro['sunset'])
47
+ print(' moon:', astro['moon_phase'], astro['moon_illumination'], '%')
48
+ # Hourly breakdown (8 entries per day, every 3 hours: time 0,300,600,...,2100)
49
+ for h in day['hourly']:
50
+ print(h['time'], h['tempF'], '°F', h['weatherDesc'][0]['value'])
51
+ # time is '0','300','600',...,'2100' (not HH:MM)
52
+ # also: chanceofrain, chanceofsnow, chanceofthunder, chanceoffog, humidity, etc.
53
+
54
+ # Location info
55
+ na = data['nearest_area'][0]
56
+ print(na['areaName'][0]['value']) # 'San Francisco'
57
+ print(na['country'][0]['value']) # 'United States of America'
58
+ print(na['latitude'], na['longitude']) # '37.775', '-122.418' (strings)
59
+ print(na['region'][0]['value']) # 'California'
60
+ ```
61
+
62
+ **Works with city names, coordinates, airport codes (`~SFO`), and zip codes.**
63
+
64
+ ---
65
+
66
+ ## Open-Meteo: most complete free weather API
67
+
68
+ ### Step 1: city name → coordinates (geocoding)
69
+
70
+ ```python
71
+ import json
72
+ geo = json.loads(http_get("https://geocoding-api.open-meteo.com/v1/search?name=Chicago&count=1"))
73
+ city = geo['results'][0]
74
+ lat = city['latitude'] # 41.85003
75
+ lon = city['longitude'] # -87.65005
76
+ tz = city['timezone'] # 'America/Chicago'
77
+ # Also available: city['elevation'], city['country'], city['country_code'],
78
+ # city['admin1'] (state/province), city['population']
79
+ ```
80
+
81
+ Always use `count=1` and take `results[0]` for unambiguous city names. For "San Francisco" `results[0]` is always the California city (pop 864K).
82
+
83
+ ### Current conditions (extended — preferred over current_weather)
84
+
85
+ ```python
86
+ data = json.loads(http_get(
87
+ f"https://api.open-meteo.com/v1/forecast"
88
+ f"?latitude={lat}&longitude={lon}"
89
+ f"&current=temperature_2m,relative_humidity_2m,apparent_temperature,"
90
+ f"precipitation,weathercode,windspeed_10m,winddirection_10m,"
91
+ f"uv_index,surface_pressure"
92
+ f"&timezone={tz}"
93
+ ))
94
+
95
+ cur = data['current']
96
+ units = data['current_units']
97
+ # cur keys and units (all confirmed):
98
+ # temperature_2m °C (or °F with &temperature_unit=fahrenheit)
99
+ # relative_humidity_2m %
100
+ # apparent_temperature °C
101
+ # precipitation mm
102
+ # weathercode WMO code int (see table below)
103
+ # windspeed_10m km/h (or mph with &windspeed_unit=mph)
104
+ # winddirection_10m °
105
+ # uv_index (unitless float)
106
+ # surface_pressure hPa
107
+ # time ISO8601 local time (e.g. '2026-04-18T10:45')
108
+ # interval 900 (seconds — 15-min update cadence)
109
+
110
+ print(cur['temperature_2m'], units['temperature_2m']) # 8.7 °C
111
+ print(cur['apparent_temperature']) # 6.6
112
+ print(cur['relative_humidity_2m']) # 80
113
+ print(cur['windspeed_10m'], cur['winddirection_10m']) # 6.1 242
114
+ print(cur['weathercode']) # 0 = clear sky
115
+ ```
116
+
117
+ The older `&current_weather=true` param works too — returns `data['current_weather']` with only temperature, windspeed, winddirection, weathercode, time, is_day, interval.
118
+
119
+ ### Hourly forecast
120
+
121
+ ```python
122
+ data = json.loads(http_get(
123
+ f"https://api.open-meteo.com/v1/forecast"
124
+ f"?latitude={lat}&longitude={lon}"
125
+ f"&hourly=temperature_2m,dewpoint_2m,apparent_temperature,"
126
+ f"precipitation_probability,precipitation,rain,showers,snowfall,snow_depth,"
127
+ f"weathercode,cloudcover,visibility,windspeed_10m,winddirection_10m,"
128
+ f"windgusts_10m,uv_index"
129
+ f"&forecast_days=3&timezone={tz}"
130
+ ))
131
+
132
+ hourly = data['hourly']
133
+ units = data['hourly_units']
134
+ # hourly is a dict of parallel arrays, all same length
135
+ # time entries: ISO8601 strings, one per hour ('2026-04-18T00:00', etc.)
136
+ # 3 forecast days → 72 entries
137
+
138
+ for i, t in enumerate(hourly['time'][:5]):
139
+ print(t,
140
+ hourly['temperature_2m'][i], units['temperature_2m'],
141
+ hourly['precipitation_probability'][i], units['precipitation_probability'],
142
+ hourly['windspeed_10m'][i], units['windspeed_10m'])
143
+
144
+ # Confirmed units (all from live response):
145
+ # temperature_2m °C dewpoint_2m °C
146
+ # apparent_temperature °C precipitation_probability %
147
+ # precipitation mm rain mm
148
+ # showers mm snowfall cm
149
+ # snow_depth m weathercode wmo code
150
+ # cloudcover % visibility m (not km!)
151
+ # windspeed_10m km/h winddirection_10m °
152
+ # windgusts_10m km/h uv_index (unitless)
153
+ ```
154
+
155
+ `forecast_days` defaults to 7, max is 16.
156
+
157
+ ### Daily forecast
158
+
159
+ ```python
160
+ data = json.loads(http_get(
161
+ f"https://api.open-meteo.com/v1/forecast"
162
+ f"?latitude={lat}&longitude={lon}"
163
+ f"&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,"
164
+ f"apparent_temperature_min,precipitation_sum,rain_sum,snowfall_sum,"
165
+ f"precipitation_hours,precipitation_probability_max,"
166
+ f"windspeed_10m_max,windgusts_10m_max,winddirection_10m_dominant,"
167
+ f"shortwave_radiation_sum,uv_index_max,sunrise,sunset"
168
+ f"&timezone={tz}&forecast_days=7"
169
+ ))
170
+
171
+ daily = data['daily']
172
+ units = data['daily_units']
173
+ for i, date in enumerate(daily['time']):
174
+ print(date,
175
+ daily['temperature_2m_max'][i], '/', daily['temperature_2m_min'][i], units['temperature_2m_max'],
176
+ f"precip={daily['precipitation_sum'][i]}{units['precipitation_sum']}",
177
+ f"pop={daily['precipitation_probability_max'][i]}%",
178
+ f"UV={daily['uv_index_max'][i]}",
179
+ f"sunrise={daily['sunrise'][i]}",
180
+ f"sunset={daily['sunset'][i]}")
181
+ # sunrise/sunset are ISO8601 local datetimes ('2026-04-18T06:29')
182
+ # shortwave_radiation_sum in MJ/m²
183
+ ```
184
+
185
+ ### Historical data (archive API)
186
+
187
+ Different subdomain — `archive-api.open-meteo.com`:
188
+
189
+ ```python
190
+ data = json.loads(http_get(
191
+ "https://archive-api.open-meteo.com/v1/archive"
192
+ "?latitude=37.7749&longitude=-122.4194"
193
+ "&start_date=2024-01-01&end_date=2024-01-07"
194
+ "&daily=temperature_2m_max,precipitation_sum"
195
+ "&timezone=America/Los_Angeles"
196
+ ))
197
+ # Returns same structure as forecast — daily dict of parallel arrays
198
+ # Hourly also works: &hourly=temperature_2m,precipitation,weathercode
199
+ # Data goes back to 1940 for most locations
200
+ ```
201
+
202
+ ### Unit overrides
203
+
204
+ All unit conversions are server-side — just add params:
205
+
206
+ ```
207
+ &temperature_unit=fahrenheit # default: celsius
208
+ &windspeed_unit=mph # default: kmh (also: ms, kn)
209
+ &precipitation_unit=inch # default: mm
210
+ ```
211
+
212
+ ---
213
+
214
+ ## weather.gov NWS (US only — 2-call flow)
215
+
216
+ Required for official NWS text forecasts with probability-of-precipitation text and storm warnings.
217
+
218
+ ```python
219
+ import json, urllib.request, gzip
220
+
221
+ def nws_get(url):
222
+ """NWS requires a descriptive User-Agent or returns 403."""
223
+ h = {
224
+ "User-Agent": "(myapp.example.com, contact@example.com)",
225
+ "Accept": "application/geo+json",
226
+ }
227
+ req = urllib.request.Request(url, headers=h)
228
+ with urllib.request.urlopen(req, timeout=20) as r:
229
+ data = r.read()
230
+ if r.headers.get("Content-Encoding") == "gzip":
231
+ data = gzip.decompress(data)
232
+ return data.decode()
233
+
234
+ # Call 1: resolve lat/lon to forecast office + grid cell (~90ms)
235
+ pts = json.loads(nws_get("https://api.weather.gov/points/37.7749,-122.4194"))
236
+ prop = pts['properties']
237
+ office = prop['gridId'] # 'MTR'
238
+ gx = prop['gridX'] # 85
239
+ gy = prop['gridY'] # 105
240
+ forecast_url = prop['forecast'] # 7-day
241
+ hourly_url = prop['forecastHourly'] # hourly
242
+
243
+ # Also available from /points: prop['timeZone'], prop['observationStations'],
244
+ # prop['relativeLocation']['properties']['city'] and ['state']
245
+
246
+ # Call 2: 7-day forecast (14 half-day periods) (~70ms)
247
+ fc = json.loads(nws_get(forecast_url))
248
+ for p in fc['properties']['periods']:
249
+ print(p['name'], # 'Saturday', 'Saturday Night', 'Sunday', ...
250
+ p['temperature'], p['temperatureUnit'], # 74 F
251
+ p['windSpeed'], p['windDirection'], # '6 to 14 mph' 'SW'
252
+ p['shortForecast'], # 'Mostly Sunny'
253
+ p['probabilityOfPrecipitation']['value'], # 0 (integer percent)
254
+ p['isDaytime']) # True/False
255
+ # p['detailedForecast'] — plain English paragraph, e.g.
256
+ # 'Sunny, with a high near 74. Southwest wind 6 to 14 mph.'
257
+
258
+ # Hourly (156 hours out — ~6.5 days)
259
+ fch = json.loads(nws_get(hourly_url))
260
+ for p in fch['properties']['periods'][:5]:
261
+ print(p['startTime'], # '2026-04-18T03:00:00-07:00'
262
+ p['temperature'], '°F',
263
+ p['shortForecast'],
264
+ p['windSpeed'],
265
+ f"humidity={p['relativeHumidity']['value']}%",
266
+ f"dewpoint={p['dewpoint']['value']:.1f}°C")
267
+ ```
268
+
269
+ `/points` response is cached `max-age=20500` (~5.7 hours) at the CDN — safe to call once per session and reuse grid coordinates.
270
+
271
+ ---
272
+
273
+ ## WMO weather code table (Open-Meteo `weathercode`)
274
+
275
+ ```python
276
+ WMO_CODES = {
277
+ 0: "Clear sky",
278
+ 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
279
+ 45: "Fog", 48: "Icy fog",
280
+ 51: "Light drizzle", 53: "Moderate drizzle", 55: "Dense drizzle",
281
+ 61: "Slight rain", 63: "Moderate rain", 65: "Heavy rain",
282
+ 71: "Slight snow", 73: "Moderate snow", 75: "Heavy snow",
283
+ 77: "Snow grains",
284
+ 80: "Slight rain showers", 81: "Moderate rain showers", 82: "Violent rain showers",
285
+ 85: "Slight snow showers", 86: "Heavy snow showers",
286
+ 95: "Thunderstorm",
287
+ 96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail",
288
+ }
289
+
290
+ def wmo_desc(code):
291
+ return WMO_CODES.get(code, f"Unknown code {code}")
292
+ ```
293
+
294
+ ---
295
+
296
+ ## Complete end-to-end pattern: city name → rich forecast
297
+
298
+ ```python
299
+ import json
300
+
301
+ def get_weather(city: str) -> dict:
302
+ """City name → current + 7-day daily forecast via Open-Meteo."""
303
+ # 1. Geocode
304
+ geo = json.loads(http_get(
305
+ f"https://geocoding-api.open-meteo.com/v1/search?name={city.replace(' ', '+')}&count=1"
306
+ ))
307
+ if not geo.get('results'):
308
+ raise ValueError(f"City not found: {city}")
309
+ loc = geo['results'][0]
310
+ lat, lon, tz = loc['latitude'], loc['longitude'], loc['timezone']
311
+
312
+ # 2. Forecast (single call: current + daily)
313
+ data = json.loads(http_get(
314
+ f"https://api.open-meteo.com/v1/forecast"
315
+ f"?latitude={lat}&longitude={lon}"
316
+ f"&current=temperature_2m,relative_humidity_2m,apparent_temperature,"
317
+ f"precipitation,weathercode,windspeed_10m,winddirection_10m,uv_index"
318
+ f"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,"
319
+ f"precipitation_probability_max,weathercode,sunrise,sunset"
320
+ f"&timezone={tz}&forecast_days=7"
321
+ ))
322
+ return {"location": loc, "current": data['current'],
323
+ "daily": data['daily'], "units": {
324
+ "current": data['current_units'],
325
+ "daily": data['daily_units'],
326
+ }}
327
+
328
+ result = get_weather("Tokyo")
329
+ cur = result['current']
330
+ print(f"{result['location']['name']}: {cur['temperature_2m']}°C feels like {cur['apparent_temperature']}°C")
331
+ print(f"Humidity {cur['relative_humidity_2m']}%, wind {cur['windspeed_10m']} km/h")
332
+ ```
333
+
334
+ Total: 2 API calls, ~1400ms combined.
335
+
336
+ ---
337
+
338
+ ## Gotchas
339
+
340
+ **wttr.in returns HTML (or ANSI art) instead of JSON if you forget `?format=j1`.**
341
+ The `?format=j1` suffix is mandatory for JSON. Without it:
342
+ - Browser `User-Agent` → full HTML page (~21KB)
343
+ - `curl`/`Wget` User-Agent → ANSI escape-code ASCII art (~500B)
344
+ Neither is parseable as JSON.
345
+
346
+ **wttr.in text formats require a non-browser User-Agent.**
347
+ `http_get()` sends `Mozilla/5.0` — wttr.in responds with an HTML page for `?format=%t`, `?format=3`, `?format=4`.
348
+ Use `Wget/1.21` (or any non-browser UA) for text format endpoints:
349
+ ```python
350
+ import urllib.request, gzip
351
+
352
+ def http_get_wttr(url):
353
+ req = urllib.request.Request(url, headers={"User-Agent": "Wget/1.21", "Accept": "*/*"})
354
+ with urllib.request.urlopen(req, timeout=20) as r:
355
+ data = r.read()
356
+ if r.headers.get("Content-Encoding") == "gzip":
357
+ data = gzip.decompress(data)
358
+ return data.decode()
359
+
360
+ # Text format tokens (URL-encode %): %25l=location, %25C=condition desc,
361
+ # %25t=temp, %25f=feels-like, %25h=humidity, %25w=wind
362
+ print(http_get_wttr("https://wttr.in/London?format=%25t")) # '+55°F'
363
+ print(http_get_wttr("https://wttr.in/Tokyo?format=3")) # 'tokyo: ☀️ +69°F'
364
+ print(http_get_wttr("https://wttr.in/Berlin?format=%25l:+%25C+%25t+(feels+%25f)+%25h+%25w"))
365
+ # 'berlin: Sunny +65°F (feels +65°F) 42% ↖5mph'
366
+ ```
367
+
368
+ **wttr.in `format=j1` returns only 3 days** (today + 2). Use Open-Meteo for longer forecasts (up to 16 days).
369
+
370
+ **wttr.in `nearest_area.areaName` is often wrong.** The returned area name is a reverse-geocoded neighborhood, not the city you queried (`"Mccormickville"` for Chicago, `"Lomita Park"` for SFO airport). Use `request[0].query` for what was actually resolved.
371
+
372
+ **wttr.in `hourly[].time` is `'0'`, `'300'`, `'600'`...`'2100'`** — not HH:MM strings. Parse as `int(time) // 100` for hours.
373
+
374
+ **wttr.in `weatherDesc` is a list**: `cc['weatherDesc'][0]['value']`, not a string. Same for `areaName`, `country`, `region`, `weatherIconUrl`.
375
+
376
+ **wttr.in unknown city returns HTTP 500**, not 404 or a JSON error.
377
+
378
+ **Open-Meteo default timezone is GMT.** Always pass `&timezone={tz}` or daily `sunrise`/`sunset` values will be in UTC, and daily buckets will be wrong.
379
+
380
+ **Open-Meteo `visibility` is in metres** (not km). Divide by 1000 to get km.
381
+
382
+ **Open-Meteo returns HTTP 400 with JSON error body on bad params:**
383
+ ```json
384
+ {"reason": "Latitude must be in range of -90 to 90°. Given: 999.0.", "error": true}
385
+ ```
386
+ `http_get()` raises an exception on 4xx — catch `urllib.error.HTTPError` and read `e.read()` (may be gzip-compressed) for the reason.
387
+
388
+ **weather.gov requires a descriptive `User-Agent`.** The NWS API blocks generic `python-urllib` or `Mozilla/5.0` agents sporadically. Always set `User-Agent: (yourapp.com, your@email.com)` or use your actual app name.
389
+
390
+ **weather.gov is US-only.** `/points/{lat},{lon}` returns HTTP 404 for coordinates outside the US (including territories like Puerto Rico for some grid edges). Fall back to Open-Meteo for non-US locations.
391
+
392
+ **weather.gov `windSpeed` is a string like `"6 to 14 mph"`**, not a number. Parse with regex if you need a numeric value.
393
+
394
+ **weather.gov `probabilityOfPrecipitation` is a dict**: `p['probabilityOfPrecipitation']['value']`, with `p['probabilityOfPrecipitation']['unitCode']` = `'wmoUnit:percent'`.
395
+
396
+ **Open-Meteo rate limit: 10,000 requests/day on the free tier.** The geocoding API and forecast API count separately. No rate limit headers are returned — track usage yourself.
397
+
398
+ **weather.gov /points response is heavily cached** (`Cache-Control: public, max-age=20500`). Store the office/gridX/gridY and reuse — only call `/points` once per location.