Skip to content

Instantly share code, notes, and snippets.

@rxliuli
Created April 10, 2026 09:35
Show Gist options
  • Select an option

  • Save rxliuli/77ddc47e55b2d1b00b195f73acbc1b3a to your computer and use it in GitHub Desktop.

Select an option

Save rxliuli/77ddc47e55b2d1b00b195f73acbc1b3a to your computer and use it in GitHub Desktop.
Free weather widget for iOS Scriptable — powered by Open-Meteo, no API key, no subscription, no location access needed. Just set your city name.
// ===== 配置区:只需要改城市名 =====
const CITY_NAME = "杭州"
// ================================
const geoReq = new Request(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(CITY_NAME)}&count=1&language=zh`
)
const geoData = await geoReq.loadJSON()
const LATITUDE = geoData.results[0].latitude
const LONGITUDE = geoData.results[0].longitude
const TIMEZONE = geoData.results[0].timezone
// Open-Meteo 免费 API,无需 key
const url = `https://api.open-meteo.com/v1/forecast`
+ `?latitude=${LATITUDE}&longitude=${LONGITUDE}`
+ `&current=temperature_2m,weather_code,apparent_temperature`
+ `&hourly=temperature_2m,weather_code`
+ `&daily=temperature_2m_max,temperature_2m_min`
+ `&timezone=${encodeURIComponent(TIMEZONE)}`
+ `&forecast_days=2`
const req = new Request(url)
const data = await req.loadJSON()
const current = data.current
const daily = data.daily
const hourly = data.hourly
// WMO weather code -> 中文描述 + SF Symbol
function decodeWeather(code) {
const map = {
0: ["晴", "sun.max.fill"],
1: ["大致晴朗", "sun.max.fill"],
2: ["局部多云", "cloud.sun.fill"],
3: ["阴", "cloud.fill"],
45: ["有雾", "cloud.fog.fill"],
48: ["雾凇", "cloud.fog.fill"],
51: ["小毛毛雨", "cloud.drizzle.fill"],
53: ["毛毛雨", "cloud.drizzle.fill"],
55: ["大毛毛雨", "cloud.drizzle.fill"],
61: ["小雨", "cloud.rain.fill"],
63: ["中雨", "cloud.rain.fill"],
65: ["大雨", "cloud.heavyrain.fill"],
71: ["小雪", "cloud.snow.fill"],
73: ["中雪", "cloud.snow.fill"],
75: ["大雪", "cloud.snow.fill"],
80: ["阵雨", "cloud.rain.fill"],
81: ["强阵雨", "cloud.heavyrain.fill"],
82: ["暴雨", "cloud.heavyrain.fill"],
95: ["雷暴", "cloud.bolt.rain.fill"],
96: ["雷暴伴冰雹","cloud.bolt.rain.fill"],
99: ["强雷暴", "cloud.bolt.rain.fill"],
}
return map[code] || ["未知", "questionmark.circle"]
}
// 用本地时间匹配 Open-Meteo 返回的本地时间
const now = new Date()
const pad = n => String(n).padStart(2, "0")
const localPrefix = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}T${pad(now.getHours())}`
let startIdx = hourly.time.findIndex(t => t.startsWith(localPrefix))
if (startIdx < 0) startIdx = 0
// ===== 构建小组件 =====
const widget = new ListWidget()
widget.backgroundGradient = (() => {
const g = new LinearGradient()
g.colors = [new Color("#1e3c72"), new Color("#2a5298")]
g.locations = [0, 1]
return g
})()
widget.setPadding(14, 16, 14, 16)
// --- 顶部:城市 + 当前温度 + 状况 ---
const topStack = widget.addStack()
topStack.layoutHorizontally()
topStack.centerAlignContent()
const leftStack = topStack.addStack()
leftStack.layoutVertically()
const cityText = leftStack.addText(CITY_NAME)
cityText.font = Font.mediumSystemFont(14)
cityText.textColor = Color.white()
const tempText = leftStack.addText(`${Math.round(current.temperature_2m)}°`)
tempText.font = Font.lightSystemFont(42)
tempText.textColor = Color.white()
const [desc, symbolName] = decodeWeather(current.weather_code)
const descText = leftStack.addText(desc)
descText.font = Font.systemFont(12)
descText.textColor = new Color("#ffffff", 0.85)
topStack.addSpacer()
const rightStack = topStack.addStack()
rightStack.layoutVertically()
const symbol = SFSymbol.named(symbolName)
symbol.applyFont(Font.systemFont(36))
const symbolImg = rightStack.addImage(symbol.image)
symbolImg.imageSize = new Size(44, 44)
symbolImg.tintColor = Color.white()
rightStack.addSpacer(4)
const hiLo = rightStack.addText(
`H:${Math.round(daily.temperature_2m_max[0])}° L:${Math.round(daily.temperature_2m_min[0])}°`
)
hiLo.font = Font.systemFont(11)
hiLo.textColor = new Color("#ffffff", 0.85)
hiLo.rightAlignText()
widget.addSpacer(10)
// --- 底部:未来 6 小时逐时预报 ---
const hourlyStack = widget.addStack()
hourlyStack.layoutHorizontally()
hourlyStack.spacing = 4
for (let i = 0; i < 6; i++) {
const idx = startIdx + i
if (idx >= hourly.time.length) break
const hStack = hourlyStack.addStack()
hStack.layoutVertically()
hStack.centerAlignContent()
const rawHour = parseInt(hourly.time[idx].slice(11, 13), 10)
const hourLabel = i === 0 ? "现在" : rawHour + "时"
const hText = hStack.addText(hourLabel)
hText.font = Font.systemFont(10)
hText.textColor = new Color("#ffffff", 0.8)
hText.centerAlignText()
hStack.addSpacer(2)
const [, hSymName] = decodeWeather(hourly.weather_code[idx])
const hSym = SFSymbol.named(hSymName)
hSym.applyFont(Font.systemFont(16))
const hImg = hStack.addImage(hSym.image)
hImg.imageSize = new Size(20, 20)
hImg.tintColor = Color.white()
hStack.addSpacer(2)
const hTemp = hStack.addText(`${Math.round(hourly.temperature_2m[idx])}°`)
hTemp.font = Font.mediumSystemFont(12)
hTemp.textColor = Color.white()
hTemp.centerAlignText()
if (i < 5) hourlyStack.addSpacer()
}
// 刷新策略:30 分钟后再请求一次
widget.refreshAfterDate = new Date(Date.now() + 30 * 60 * 1000)
if (config.runsInWidget) {
Script.setWidget(widget)
} else {
widget.presentMedium()
}
Script.complete()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment