├── .gitattributes ├── LICENSE ├── README-FR.md ├── README.md ├── assets ├── config.png └── screenshot.jpg └── script.js /.gitattributes: -------------------------------------------------------------------------------- 1 | *.png filter=lfs diff=lfs merge=lfs -text 2 | *.jpg filter=lfs diff=lfs merge=lfs -text 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Johann Pardanaud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README-FR.md: -------------------------------------------------------------------------------- 1 | # Rain Forecast Widget 2 | 3 | Un widget iOS pour afficher avec précision les prévisions de pluie dans l'heure à venir. Basé sur les données de [Météo France](https://meteofrance.com/). Ne fonctionne qu'en France. 4 | 5 | # Fonctionnalités 6 | 7 | - ☔️ Affichage avec précision des prévisions de pluie pour l'heure à venir 8 | - 📍 Peut exploiter votre position actuelle 9 | - 🏙 Permet de configurer une ville spécifique à afficher 10 | - 📏 Supporte les trois tailles de widget 11 | - 🌓 Affichage adapté au dark mode 12 | - 🏴 Disponible en plusieurs langues 13 | 14 |

15 |
16 | 17 |

18 | 19 | # Installation 20 | 21 | - Téléchargez l'app [Scriptable](https://scriptable.app/). 22 | - Créez un nouveau script dans l'app et collez le contenu du fichier [script.js](./script.js). 23 | - Ajoutez un nouveau widget sur l'accueil de votre iPhone et sélectionnez Scriptable. 24 | - Modifiez les paramètres du widget et choisissez le nom du script que vous avez créé. 25 | - Les prévisions de pluie pour votre position actuelle devrait alors s'afficher! 🌈 26 | 27 | # Configuration 28 | 29 | Vous pouvez choisir d'afficher les prévisions de pluie pour une ville spécifique en modifiant les paramètres du widget et tapant le nom de la ville souhaitée dans le champ `Parameter`: 30 | 31 |

32 | 33 |

34 | 35 | Si vous obtenez une erreur suite au renseignement de la ville, vérifiez [dans la recherche de Météo France](https://meteofrance.com/) (tout en haut) si la ville existe bien. 36 | 37 | # Remerciements 38 | 39 | - [Simon B. Støvring](https://twitter.com/simonbs) pour avoir créé Scriptable 40 | - [Sunrise-Sunset](https://sunrise-sunset.org/api) pour leur API gratuite 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rain Forecast Widget 2 | 3 | [🇫🇷 Documentation en Français 🇫🇷](./README-FR.md) 4 | 5 | An iOS widget displaying precise rain forecast for the next hour. Relies on data provided by [Météo France](https://meteofrance.com/). Works only in France. 6 | 7 | # Features 8 | 9 | - ☔️ Display of precise rain forecast for the next hour 10 | - 📍 Can use your current location 11 | - 🏙 Allows to configure a specific town to display 12 | - 📏 Handles the three widget sizes 13 | - 🌓 Custom UI for light and dark modes 14 | - 🏴 Available in multiple languages 15 | 16 |

17 |
18 | 19 |

20 | 21 | # Installation 22 | 23 | - Download the [Scriptable](https://scriptable.app/) app. 24 | - Create a new script inside the app and past the contents of the [script.js](./script.js) file. 25 | - Add a new widget on your iPhone homescreen and select Scriptable. 26 | - Edit the widget parameters and select the name of the script you have created. 27 | - The rain forecast for your current location should displayed on your screen! 🌈 28 | 29 | # Configuration 30 | 31 | You can choose to display the rain forecast for a specific town by editing the widget parameters and by setting the name of the town you want in the `Parameter` field: 32 | 33 |

34 | 35 |

36 | 37 | If you get an error right after setting the town, check [in the search bar of Météo France](https://meteofrance.com/) (at the top) if the town exists. 38 | 39 | # Thanks to 40 | 41 | - [Simon B. Støvring](https://twitter.com/simonbs) for creating Scriptable 42 | - [Sunrise-Sunset](https://sunrise-sunset.org/api) for their free API 43 | -------------------------------------------------------------------------------- /assets/config.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:595879331535e74e6ee97d6ad953793a6b534d9bbcfdd19a30537dbb631416f9 3 | size 315615 4 | -------------------------------------------------------------------------------- /assets/screenshot.jpg: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:254069674a4740102fa348ede17456736f102dd87ba2a39386575fc228871e46 3 | size 840733 4 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | /** 2 | * French rain forecast inside a Scriptable widget. 3 | * 4 | * Created by Johann Pardanaud (https://johann.pardanaud.com/). 5 | * 6 | * Homepage: https://github.com/nesk/rain-forecast-widget 7 | */ 8 | 9 | //================================================ 10 | //=============== DEBUG ZONE START =============== 11 | //================================================ 12 | 13 | /** 14 | * Defines the size of the widget in debug mode. 15 | * 16 | * @const {(null|"small"|"medium"|"large")} 17 | */ 18 | const DEBUG_PRESENT_SIZE = null 19 | 20 | /** 21 | * Defines the town for which you want the forecast. 22 | * 23 | * @const {?string} 24 | */ 25 | const DEBUG_TOWN = null 26 | 27 | //================================================ 28 | //================ DEBUG ZONE END ================ 29 | //================================================ 30 | 31 | const lang = { 32 | translations: { 33 | 'title.full': { 34 | fr: 'Pluie à venir', 35 | en: 'Rain forecast', 36 | tr: 'Yağmur geliyor', 37 | de: 'Regenvorhersage', 38 | }, 39 | 'title.short': { 40 | fr: 'Pluie', 41 | en: 'Rain', 42 | tr: 'Yağmur', 43 | de: 'Regen', 44 | }, 45 | 'minutes': { 46 | fr: 'minutes', 47 | en: 'minutes', 48 | tr: 'dakika', 49 | de: 'minuten', 50 | }, 51 | 'rain.none': { 52 | fr: 'Temps sec', 53 | en: 'Dry weather', 54 | tr: 'Yağmursuz', 55 | de: 'Trockenes Wetter', 56 | }, 57 | 'rain.light': { 58 | fr: 'Pluie faible', 59 | en: 'Light rain', 60 | tr: 'Hafif yağmurlu', 61 | de: 'Leichter Regen', 62 | }, 63 | 'rain.moderate': { 64 | fr: 'Pluie modérée', 65 | en: 'Moderate rain', 66 | tr: 'Orta şiddetli yağmur', 67 | de: 'Mittlerer Regen', 68 | }, 69 | 'rain.heavy': { 70 | fr: 'Pluie forte', 71 | en: 'Heavy rain', 72 | tr: 'Yoğun yağış', 73 | de: 'Heftiger Regen', 74 | }, 75 | 'rain.unknown': { 76 | fr: 'Aucune information disponible', 77 | en: 'No information available', 78 | tr: 'Bilgi bulunmamaktadır', 79 | de: 'Keine Informationen verfügbar', 80 | }, 81 | 'error': { 82 | fr: 'Une erreur est survenue', 83 | en: 'An error occurred', 84 | tr: 'Bir hata oluştu', 85 | de: 'Es ist ein Fehler aufgetreten', 86 | }, 87 | }, 88 | 89 | translate(name) { 90 | const translation = this.translations[name] || {} 91 | const availableLanguages = Object.keys(translation) 92 | const preferredLanguages = Device.preferredLanguages().map(lang => lang.split('-')[0]) 93 | 94 | for (const lang of preferredLanguages) { 95 | if (availableLanguages.includes(lang)) { 96 | return translation[lang] 97 | } 98 | } 99 | 100 | return translation.en || '%UNKNOWN_TRANSLATION%' 101 | }, 102 | } 103 | 104 | const title = getWidgetSize() !== 'small' ? lang.translate('title.full') : lang.translate('title.short') 105 | const backgroundColors = [ 106 | Color.dynamic(new Color('#3182CE'), new Color('#2A4365')), 107 | Color.dynamic(new Color('#4299E1'), new Color('#2C5282')), 108 | ] 109 | 110 | try { 111 | const sessionValue = await getSessionValue() 112 | const jwt = convertSessionValueToJwt(sessionValue) 113 | 114 | let town = args.widgetParameter || DEBUG_TOWN 115 | ? await getTownInfos(jwt, args.widgetParameter || DEBUG_TOWN) 116 | : null 117 | 118 | let isCurrentLocation = false 119 | let location = town 120 | if (location === null) { 121 | location = await getCurrentLocation() 122 | isCurrentLocation = true 123 | } 124 | 125 | const { update_time: updatedAt, properties: townWithForecast } = await getForecastInfos(jwt, location) 126 | town = Object.assign(town || {}, townWithForecast) 127 | 128 | if (town.url === undefined) { 129 | town.url = await getTownInfos(town.name).url 130 | } 131 | 132 | const { sunrise, sunset } = (await getSunHours(location)) || {} 133 | const forecast = town.forecast.map(dataPoint => forecastDataPointToSFSymbol(dataPoint, sunrise, sunset)) 134 | 135 | applyWidget(createWidget( 136 | title, 137 | town.name, 138 | isCurrentLocation, 139 | forecast, 140 | backgroundColors, 141 | town.url, 142 | new Date(updatedAt) 143 | )) 144 | } catch (error) { 145 | console.error(error) 146 | applyWidget(createWidget(title, lang.translate('error'), false, [], backgroundColors)) 147 | } 148 | 149 | function cache() { 150 | const disk = FileManager.local() 151 | return { 152 | disk, 153 | directory: disk.joinPath(disk.libraryDirectory(), "hourly-weather"), 154 | _setupDirectory() { 155 | if (!this.disk.isDirectory(this.directory)) { 156 | this.disk.createDirectory(this.directory) 157 | } 158 | }, 159 | _getPath(key) { 160 | return `${this.directory}/${key}.json` 161 | }, 162 | has(key) { 163 | return this.disk.fileExists(this._getPath(key)) 164 | }, 165 | get(key) { 166 | try { 167 | return JSON.parse(this.disk.readString(this._getPath(key))) 168 | } catch (err) { 169 | return null 170 | } 171 | }, 172 | set(key, value) { 173 | this._setupDirectory() 174 | this.disk.writeString(this._getPath(key), JSON.stringify(value)) 175 | }, 176 | } 177 | } 178 | 179 | function assertRequestIsSuccessful(request) { 180 | const { statusCode } = request.response 181 | if (statusCode !== 200) { 182 | throw `Unexpected status code ${statusCode}` 183 | } 184 | } 185 | 186 | async function getSessionValue() { 187 | console.log('Requesting the session value...') 188 | const request = new Request("https://meteofrance.com") 189 | await request.load() 190 | assertRequestIsSuccessful(request) 191 | 192 | const cookie = request.response.cookies.find(({ name }) => name === 'mfsession') 193 | const sessionValue = cookie !== null ? cookie.value : null 194 | 195 | console.log(`Session value retrieved: ${sessionValue}`) 196 | return sessionValue 197 | } 198 | 199 | function convertSessionValueToJwt(sessionValue) { 200 | // Extracted from a script from Meteo France 201 | // See: https://meteofrance.com/sites/meteofrance.com/files/js/js_kzUxSvsVy7NKe1DoZyZTig_IUoshY06Pf7-ist1vhQc.js 202 | const jwt = sessionValue.replace(/[a-zA-Z]/g, function(e) { 203 | const t = e <= "Z" ? 65 : 97 204 | return String.fromCharCode(t + (e.charCodeAt(0) - t + 13) % 26) 205 | }) 206 | 207 | console.log(`Session value converted to JWT: ${jwt}`) 208 | return jwt 209 | } 210 | 211 | async function getCurrentLocation() { 212 | console.log('Requesting current location...') 213 | let latitude, longitude = null 214 | try { 215 | ({ latitude, longitude } = await Location.current()) 216 | cache().set('location', { latitude, longitude }) 217 | } catch (err) { 218 | console.log(err) 219 | console.log('Unable to request the current location, trying the cache...') 220 | if (cache().has('location')) { 221 | ({ latitude, longitude } = cache().get('location')) 222 | } else { 223 | throw new Error('Unable to retrieve the current location.') 224 | } 225 | } 226 | console.log(`Location retrieved: latitude=${latitude} longitude=${longitude}`) 227 | return { latitude, longitude } 228 | } 229 | 230 | async function getTownInfos(authorizationToken, townName) { 231 | const endpoint = 'https://meteofrance.com/search/all' 232 | 233 | console.log(`Searching for town "${townName}"...`) 234 | const request = new Request(`${endpoint}?term=${encodeURIComponent(townName)}`) 235 | request.headers = { Authorization: `Bearer ${authorizationToken}` } 236 | const payload = await request.loadString() 237 | assertRequestIsSuccessful(request) 238 | 239 | const results = JSON.parse(payload) 240 | const town = results.find(({ type }) => type === 'VILLE_FRANCE') 241 | if (!town) { 242 | console.log(`Town "${town.name}" not found.`) 243 | return null 244 | } 245 | 246 | const url = `https://meteofrance.com${town.alias}` 247 | console.log(`Town "${town.name}" found: latitude=${town.lat} longitude=${town.lng} url=${url}`) 248 | return { latitude: town.lat, longitude: town.lng, url } 249 | } 250 | 251 | async function getForecastInfos(authorizationToken, { latitude, longitude }) { 252 | const endpoint = 'https://rpcache-aa.meteofrance.com/internet2018client/2.0/nowcast/rain' 253 | 254 | console.log('Requesting the latest forecast infos...') 255 | const request = new Request(`${endpoint}?lat=${latitude}&lon=${longitude}`) 256 | request.headers = { Authorization: `Bearer ${authorizationToken}` } 257 | const payload = await request.loadString() 258 | assertRequestIsSuccessful(request) 259 | 260 | const forecast = JSON.parse(payload) 261 | const town = forecast.properties 262 | 263 | // Fix naming for Paris (otherwise searching for this town will fail) 264 | if (town.french_department == 75) { 265 | const match = town.name.match(/^Paris(\d+)/) 266 | if (match !== null) { 267 | town.name = `Paris ${match[1]}e` 268 | } 269 | } 270 | 271 | const rainDebug = town.forecast.map(({ rain_intensity }) => rain_intensity) 272 | console.log( 273 | `Latest forecast infos retrieved: ` + 274 | `${town.name} (${town.french_department}) at ${forecast.update_time} = ${rainDebug}` 275 | ) 276 | 277 | return forecast 278 | } 279 | 280 | async function getSunHours({ latitude, longitude }) { 281 | const endpoint = 'https://api.sunrise-sunset.org/json' 282 | 283 | console.log('Requesting the Sun hours...') 284 | const request = new Request(`${endpoint}?lat=${latitude}&lng=${longitude}&formatted=0`) 285 | 286 | try { 287 | const { results: { sunrise, sunset} } = await request.loadJSON() 288 | console.log(`Sun hours: sunrise=${sunrise} sunset=${sunset}`) 289 | 290 | return { 291 | sunrise: new Date(sunrise), 292 | sunset: new Date(sunset), 293 | } 294 | } catch (error) { 295 | console.error('Error while requesting the Sun hours.') 296 | console.error(error) 297 | 298 | return null 299 | } 300 | } 301 | 302 | function forecastDataPointToSFSymbol(dataPoint, sunrise = null, sunset = null) { 303 | const now = new Date() 304 | const isNight = sunrise && sunset ? now >= sunset || now < sunrise : false 305 | 306 | switch (dataPoint.rain_intensity) { 307 | case 1: 308 | return SFSymbol.named(!isNight ? 'cloud.sun.fill' : 'cloud.moon.fill') 309 | case 2: 310 | return SFSymbol.named(!isNight ? 'cloud.sun.rain.fill' : 'cloud.moon.rain.fill') 311 | case 3: 312 | return SFSymbol.named('cloud.rain.fill') 313 | case 4: 314 | return SFSymbol.named('cloud.heavyrain.fill') 315 | default: 316 | const sfSymbol = SFSymbol.named('questionmark.square.dashed') 317 | sfSymbol.suggestedColor = Color.white() 318 | return sfSymbol 319 | } 320 | } 321 | 322 | function getFormattedTime(date = new Date()) { 323 | const formatter = new DateFormatter() 324 | formatter.useShortTimeStyle() 325 | return formatter.string(date) 326 | } 327 | 328 | function getWidgetSize() { 329 | if (config.widgetFamily) { 330 | return config.widgetFamily 331 | } 332 | 333 | return DEBUG_PRESENT_SIZE 334 | } 335 | 336 | function createWidget(title, town, isCurrentLocation, forecast, backgroundColors, url = null, updatedAt = new Date()) { 337 | const widget = new ListWidget() 338 | 339 | const gradient = new LinearGradient() 340 | gradient.colors = backgroundColors 341 | gradient.locations = [0, 1] 342 | widget.backgroundGradient = gradient 343 | 344 | if (url) { 345 | widget.url = url 346 | } 347 | 348 | const headerStack = widget.addStack() 349 | headerStack.centerAlignContent() 350 | 351 | const titleText = headerStack.addText(title) 352 | titleText.textColor = Color.white() 353 | titleText.textOpacity = 0.9 354 | titleText.font = Font.systemFont(16) 355 | 356 | headerStack.addSpacer(null) 357 | 358 | const locationSymbol = SFSymbol.named(isCurrentLocation ? 'location.fill' : 'location.slash.fill') 359 | const locationImage = headerStack.addImage(locationSymbol.image) 360 | locationImage.tintColor = Color.white() 361 | locationImage.imageOpacity = 0.9 362 | locationImage.imageSize = new Size(12, 12) 363 | 364 | headerStack.addSpacer(8) 365 | 366 | const refreshImage = headerStack.addImage(SFSymbol.named('arrow.clockwise').image) 367 | refreshImage.tintColor = Color.white() 368 | refreshImage.imageOpacity = 0.9 369 | refreshImage.imageSize = new Size(12, 12) 370 | 371 | headerStack.addSpacer(4) 372 | 373 | const updateText = headerStack.addText(getFormattedTime(updatedAt)) 374 | updateText.textColor = Color.white() 375 | updateText.textOpacity = 0.9 376 | updateText.font = Font.systemFont(12) 377 | 378 | widget.addSpacer(5) 379 | 380 | const townText = widget.addText(town) 381 | townText.textColor = Color.white() 382 | townText.font = Font.systemFont(22) 383 | 384 | widget.addSpacer(10) 385 | 386 | const timings = [' 5', '10', '15', '20', '25', '30', '40', '50', '60'] 387 | 388 | if (getWidgetSize() === 'small') { 389 | forecast = forecast.slice(0, 4) 390 | } 391 | 392 | const forecastStack = widget.addStack() 393 | forecast.forEach((sfSymbol, index) => { 394 | const dataPointStack = forecastStack.addStack() 395 | dataPointStack.layoutVertically() 396 | 397 | const dataPointImage = dataPointStack.addImage(sfSymbol.image) 398 | dataPointImage.resizable = false 399 | dataPointImage.imageSize = new Size(25, 25) 400 | 401 | dataPointStack.addSpacer(5) 402 | 403 | const textStack = dataPointStack.addStack() 404 | textStack.addSpacer(5) 405 | 406 | const dataPointText = textStack.addText(timings[index]) 407 | dataPointText.textColor = Color.white() 408 | dataPointText.textOpacity = 0.9 409 | dataPointText.font = Font.regularMonospacedSystemFont(getWidgetSize() !== 'small' ? 12 : 11) 410 | dataPointText.centerAlignText() 411 | 412 | textStack.addSpacer(5) 413 | 414 | if (index + 1 < forecast.length) { 415 | forecastStack.addSpacer(null) 416 | } 417 | }) 418 | 419 | if (forecast.length > 0) { 420 | widget.addSpacer(5) 421 | 422 | const unitsText = widget.addText(lang.translate('minutes')) 423 | unitsText.textColor = Color.white() 424 | unitsText.textOpacity = 0.9 425 | unitsText.font = Font.regularMonospacedSystemFont(12) 426 | unitsText.centerAlignText() 427 | } 428 | 429 | if (getWidgetSize() === 'large') { 430 | widget.addSpacer(25) 431 | 432 | const descriptions = [ 433 | 'rain.none', 434 | 'rain.light', 435 | 'rain.moderate', 436 | 'rain.heavy', 437 | 'rain.unknown', 438 | ] 439 | 440 | for (let i = 1 ; i <= 5 ; i++) { 441 | const legendStack = widget.addStack() 442 | legendStack.centerAlignContent() 443 | 444 | const sfSymbol = forecastDataPointToSFSymbol({ rain_intensity: i }) 445 | const sfSymbolImage = legendStack.addImage(sfSymbol.image) 446 | sfSymbolImage.resizable = false 447 | sfSymbolImage.imageSize = new Size(25, 25) 448 | sfSymbolImage.tintColor = sfSymbol.suggestedColor 449 | 450 | legendStack.addSpacer(15) 451 | 452 | const descriptionText = legendStack.addText(lang.translate(descriptions[i - 1])) 453 | descriptionText.textColor = Color.white() 454 | descriptionText.textOpacity = 0.9 455 | descriptionText.font = Font.regularMonospacedSystemFont(12) 456 | 457 | widget.addSpacer(5) 458 | } 459 | } 460 | 461 | return widget 462 | } 463 | 464 | function applyWidget(widget) { 465 | Script.setWidget(widget) 466 | 467 | switch (DEBUG_PRESENT_SIZE) { 468 | case 'small': 469 | widget.presentSmall() 470 | break 471 | case 'medium': 472 | widget.presentMedium() 473 | break 474 | case 'large': 475 | widget.presentLarge() 476 | break 477 | } 478 | } 479 | --------------------------------------------------------------------------------