今日の天気をSlackへ送る処理を、予報の取得・通知文の作成・送信に分けます。
公開JSONから東京地方と東京の気温地点をコードで選び、対象日と発表日時を確認する練習です。
先頭の予報オブジェクトや気温配列の並びには依存しており、あらゆる形式変更を検出できる実装ではありません。
架空の予報で欠けている気温を確かめる
各article-番号.jsを別々のApps Scriptプロジェクトへ保存します。
article-3041.jsを保存し、demoWeatherを実行します。
10月2日の架空の晴れ予報と24℃から通知文を作り、気象庁の取得とSlackへの投稿は行いません。
// 各記事を別々のApps Scriptプロジェクトへ保存します。
function props_() { return PropertiesService.getScriptProperties(); }
function required_(key) {
const v = props_().getProperty(key);
if (!v) throw new Error(key + 'を設定してください');
return v;
}
function numericId_(v) {
if (typeof v === 'number' && !Number.isSafeInteger(v)) throw new Error('IDの精度が失われています');
const s = String(v);
if (!/^[1-9]\d*$/.test(s)) throw new Error('IDは正の数字で指定します');
return s;
}
function digest_(s) {
return Utilities.base64EncodeWebSafe(Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256, s, Utilities.Charset.UTF_8));
}
function key_(kind, destination, id) { return 'CGM5_' + digest_(kind + ':' + destination + ':' + id); }
function runLocked_(work) {
if (props_().getProperty('SEND_ENABLED') !== 'true') throw new Error('送信は無効です');
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) throw new Error('別の実行が処理中です');
try { return work(); } finally { lock.releaseLock(); }
}
function once_(kind, destination, plan, perform) {
const key = key_(kind, destination, plan.id);
const fingerprint = digest_(JSON.stringify(plan.payload));
const previous = props_().getProperty(key);
if (previous) {
const record = JSON.parse(previous);
if (record.fingerprint !== fingerprint) throw new Error('同じIDの入力が変更されています');
if (record.status === 'done') return false;
throw new Error('結果不明の処理を投稿先と照合してください');
}
// 通信前に記録。記録に失敗した場合は送信しません。
props_().setProperty(key, JSON.stringify({status:'sending', fingerprint:fingerprint}));
try {
perform();
props_().setProperty(key, JSON.stringify({status:'done', fingerprint:fingerprint}));
return true;
} catch (error) {
props_().setProperty(key, JSON.stringify({status:'unknown', fingerprint:fingerprint}));
throw new Error('処理結果を確定できません。自動再送せず照合してください');
}
}
function preview_(plans) {
Logger.log(JSON.stringify(plans.map(p => ({id:p.id, characters:p.text.length}))));
return plans;
}
function jst_(date, pattern) { return Utilities.formatDate(date, 'Asia/Tokyo', pattern); }
function validDate_(d) { return d instanceof Date && Number.isFinite(d.getTime()); }
function plainChat_(s) { return String(s).replace(/\[/g,'[').replace(/\]/g,']'); }
function slackUrl_() {
const u = required_('SLACK_WEBHOOK_URL');
if (!/^https:\/\/hooks\.slack\.com\/services\/[^/]+\/[^/]+\/[^/]+$/.test(u)) throw new Error('Webhookの形式を確認してください');
return u;
}
function slackPost_(text, url) {
if (text.length > 2800) throw new Error('通知文が長すぎます');
const escaped = text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
const r = UrlFetchApp.fetch(url, {method:'post', contentType:'application/json',
payload:JSON.stringify({text:escaped, blocks:[{type:'section',text:{type:'plain_text',text:text,emoji:false}}]}), muteHttpExceptions:true});
if (r.getResponseCode() !== 200 || r.getContentText().trim() !== 'ok') throw new Error('Slackの応答を確認してください');
}
function temperature_(v) {
if (v===null || v===undefined || v==='') return '未取得';
if (!/^-?\d+(?:\.\d+)?$/.test(String(v))) throw new Error('気温の形式が不正です');
return String(v)+'℃';
}
function weatherPlan_(data, now) {
const forecast=Array.isArray(data) && data[0];
if (!forecast || !Array.isArray(forecast.timeSeries)) throw new Error('予報形式が不正です');
const issued=new Date(forecast.reportDatetime), today=jst_(now,'yyyy-MM-dd');
if (!validDate_(issued) || jst_(issued,'yyyy-MM-dd')!==today || issued>now) throw new Error('予報の日付を確認してください');
const weatherSeries=forecast.timeSeries.find(t=>t.areas.some(a=>Array.isArray(a.weathers)));
const tempSeries=forecast.timeSeries.find(t=>t.areas.some(a=>Array.isArray(a.temps)));
if (!weatherSeries || !tempSeries) throw new Error('予報の項目がありません');
const area=weatherSeries.areas.find(a=>a.area.code==='130010');
const station=tempSeries.areas.find(a=>a.area.code==='44132');
const idx=weatherSeries.timeDefines.findIndex(t=>jst_(new Date(t),'yyyy-MM-dd')===today);
const hour=jst_(issued,'HH');
if (!['05','11','17'].includes(hour) || !area || !station || idx<0 || !area.weathers[idx]) throw new Error('地域・発表時刻・対象日を確認してください');
if (hour!=='17' && (!tempSeries.timeDefines[0] || jst_(new Date(tempSeries.timeDefines[0]),'yyyy-MM-dd')!==today)) throw new Error('気温の対象日を確認してください');
// 現行の気象庁画面に合わせ、当日の朝の最低は未取得とします。
// 17時発表は当日分の気温欄がないため、翌日分を流用しません。
const high=hour==='17' ? null : station.temps[0];
const text='【東京地方の予報】'+today+'\n'+area.weathers[idx]
+'\n朝の最低: 未取得 / 日中の最高: '+temperature_(high)
+'\n発表: '+forecast.reportDatetime+'\n出典: 気象庁(東京の予報)';
return {id:today+':130010',text:text,payload:{text:text}};
}
function weatherFixture_() {
return [{reportDatetime:'2026-10-02T11:00:00+09:00',timeSeries:[
{timeDefines:['2026-10-02T11:00:00+09:00'],areas:[{area:{code:'130010'},weathers:['晴れ(架空の練習データ)']}]},
{timeDefines:['2026-10-02T09:00:00+09:00'],areas:[{area:{code:'44132'},temps:['24']}]}]}];
}
function demoWeather() { const p=weatherPlan_(weatherFixture_(),new Date('2026-10-02T12:00:00+09:00')); Logger.log(JSON.stringify(p)); return p; }
function readWeatherPlan_() {
const r=UrlFetchApp.fetch('https://www.jma.go.jp/bosai/forecast/data/forecast/130000.json',{muteHttpExceptions:true});
if (r.getResponseCode()!==200) throw new Error('気象庁の取得応答を確認してください');
return weatherPlan_(JSON.parse(r.getContentText()),new Date());
}
function previewWeather() { return preview_([readWeatherPlan_()]); }
function sendWeather() {
return runLocked_(() => {
const url=slackUrl_(), dailyId=jst_(new Date(),'yyyy-MM-dd')+':130010';
const previous=props_().getProperty(key_('weather',url,dailyId));
if (previous && JSON.parse(previous).status==='done') return;
const p=readWeatherPlan_();
// 同じ日の初回正常投稿だけ。発表更新や再実行では再投稿しません。
const record=props_().getProperty(key_('weather',url,p.id));
if (record && JSON.parse(record).status==='done') return;
once_('weather',url,p,() => slackPost_(p.text,url));
});
}
日付・地域・欠測の確認結果
| 条件 | 結果 |
|---|---|
| 当日11時発表、日中の最高24℃ | 対象日と発表日時を添え、朝の最低は未取得と表示 |
| 気温の空欄と0 | 空欄は未取得、0は0℃として区別 |
| 17時発表の気温配列 | 翌日分を当日の気温へ流用しない |
| 前日の予報・未来の発表日時・未知の発表時刻 | 今日の予報として送らず停止 |
| 地域の項目や気温の対象日が違う | 誤った地域・日付を投稿せず停止 |
この例の対象は東京地方130010、気温地点は東京44132です。
気温は予報で、現在の観測気温ではありません。
最低気温や当日の気温がない場合は未取得と表示し、任意の値や翌日分を埋めません。
気象庁の現行の天気予報画面と、同画面が使う東京の公開JSONを2026年10月2日に取得し、現行画面の当日分の気温の扱いと照合しました。
ZIP内の取得時点のJSONはその日付の資料で、後日の最新予報ではありません。
公開JSONはこの画面の構成に依存するもので、将来の形式が保証された契約APIとは扱いません。
コードは05・11・17時台の発表を対象とし、発表時刻の分は検査しません。
必要な項目や対象日の不一致はエラーになりますが、配列順序などの変更を必ず検出できるわけではありません。
他地域を数字だけ置き換えて動作確認済みとは扱わないでください。
最新予報の確認とSlackへの送信
previewWeatherは公開JSONを読み、候補のIDと文字数を表示します。
日付・地域・予報本文は気象庁の予報と照合します。
SlackアプリのIncoming Webhooksで練習用チャンネルを選んで発行したURLを、Apps Scriptのプロジェクト設定のスクリプトプロパティSLACK_WEBHOOK_URLへ保存します。
SEND_ENABLEDは確認が済むまでfalseとし、送信前にtrueへ変更する必要があります。
Webhook URLを変更すると別の送信先として扱われ、同じ日の予報が再送され得ます。
同じ送信先には当日の初回正常投稿だけを送り、発表の更新で再投稿しません。
正常投稿が保存済みなら同日の再実行では気象庁への再取得も省きます。
日付が変わると別の候補になり、時刻と日付の判定はAsia/Tokyoです。
トリガーは送信する時間帯に1つだけ設定し、夜の17時発表で当日の気温がない場合は未取得のまま送ります。
取得失敗時は通知を止めるため、空の値を正常な予報として送信しません。
気象庁データの利用は気象庁の利用案内を確認してください。
通知文の出典名に加え、運用時は参照URLと、予報の一部を抽出・整形した通知であることも示します。
実際のApps Scriptからの取得・送信は未検証で、公開データをローカルで解析した結果と区別しています。
架空データと最新予報を分けて確認する手順
練習の段階から順に当てはめると、実際の予報をSlackへ送る前に確認することが決まります。
2026年10月2日の記事の確認内容にもとづく目安です。
実際のApps Scriptからの取得・送信は、この記事では未検証です。
- まず、架空の予報で練習する。article-3041.jsを別のApps Scriptプロジェクトに保存し、demoWeatherを実行する。架空の晴れ予報と24℃から通知文を作るだけで、気象庁の取得とSlackへの投稿は行わない。
- 実際のデータを確認するときは、previewWeatherを実行する。公開JSONを読み、候補のIDと文字数を表示する。日付・地域・予報本文は、気象庁の画面で照合する。
- 送信先のSLACK_WEBHOOK_URLをスクリプトプロパティに設定し、sendWeatherを手動で確認する。Webhook URLは、記事や公開コードに載せない。
- 送信するのは、同じ送信先に、当日の初回の正常な投稿だけ。発表が更新されても、再投稿しない。日付と時刻の判定は、Asia/Tokyoで行う。
- トリガーは、送信する時間帯に、1つだけ設定する。夜の17時発表で、当日の気温がない場合は、未取得のまま送る。
- 取得に失敗したときは、通知を止める。空の値を、正常な予報として送信しない。気象庁のデータを使うときは、利用案内を確認し、通知文にも出典を示す。
気温は予報で、現在の観測気温ではありません。
最低気温や当日の気温がないときは、未取得と表示し、任意の値や翌日分を埋めません。
送信結果が不明なときの停止と戻し方
スクリプトプロパティのSEND_ENABLEDがtrueになるまで、送信関数は入力の取得前に停止します。
送信先を管理する本人の許可を得て、送信する内容と練習用の送信先を確かめてから有効にします。
通信前にsending、処理完了後にdoneを保存し、通信や記録に失敗した場合はunknownなどの状態を残して止めます。
成功応答がなくても投稿先へ届いている可能性があるため、自動で再送しません。
結果が不明な場合はSEND_ENABLEDをfalseにし、トリガーを停止して、Slackの投稿とスクリプトプロパティの処理記録を照合します。
本例はスプレッドシートを使いません。
送信が確認できた記録はdoneへ整え、未送信を確認できた場合だけ再実行の準備をします。
同じ日の結果不明の記録が残った状態で予報本文が更新されると、入力変更として停止します。
その日のdone記録がある場合は、予報を再取得せず終了します。
送信済みの記録を消したり、新しいIDを付けたりすると再送できてしまうため、再実行の代わりに記録を消さないでください。
ロックは同じプロジェクト内の競合を防ぐもので、別のプロジェクトや旧トリガーからの投稿は防ぎません。
移行時は旧トリガーを止め、長期運用では保存件数と整理方法を決めます。
戻す際は新しいトリガーを止め、保存した旧コード・設定・シートのコピーを参照します。
処理済みの対象がある場合は、旧コードで同じ対象を再送しないように確認します。
検証した範囲と実サービスの確認
2026年10月2日の記録では、Node.jsで外部サービスを模した83ケースを確認しています。
2026年10月24日は本文コードとZIPのarticle-3041.jsの一致(改行正規化後)を確認し、東京の公開JSONを再取得しました。
同日17時発表の気温欄は翌25日分で、本コードでは当日の最高に流用しない設計です。
今回は83ケースの再実行、画面や実Slack投稿の確認はしていません。
ZIPを新しいフォルダーへ展開し、node test_integration.cjsを実行すると同じ条件を確認できます。
Googleの認可、実シートの更新、Chatwork・Slackへの実投稿、Driveからの実画像アップロード、トリガーの実行時刻は未検証です。
実サービスを使う際は、練習用の入力・送信先・認可範囲を確認してから1回手動で実行し、投稿先と保存状態を照合します。
関連記事・旧実装の参考資料
外部の参考コードと旧実装の説明は、今回の練習ZIPと内容が異なる場合があります。
現在の練習では本稿のZIPを使い、送信先と現行の仕様を確認します。





よくある質問:GASで天気をSlackに投稿するときのチェックリスト
記事のコードは、実際に気象庁から取得しますか?
最初に実行するdemoWeatherは、架空の予報だけから通知文を作り、気象庁の取得もSlackへの投稿も行いません。
実際のデータを読むのは、previewWeatherです。
最低気温が取得できないときは?
未取得と表示し、任意の値や翌日分を埋めません。
気温の空欄は未取得、0は0℃として区別します。
17時発表のデータで、気温はどうなりますか?
17時発表の気温配列では、翌日分を当日の気温へ流用しません。
当日の気温がない場合は、未取得のまま送ります。
どんなときに、送信を停止しますか?
前日の予報、未来の発表日時、未知の発表時刻、地域の項目や気温の対象日が違う場合は、誤った地域や日付を投稿せず、停止します。
他の地域でも使えますか?
この例の対象は、東京地方130010、気温地点は東京44132です。
他の地域を、数字だけ置き換えて、動作確認済みとは扱わないでください。
気象庁のデータは、将来も同じ形式ですか?
公開JSONは、気象庁の画面の構成に依存するもので、将来の形式が保証された契約APIとは扱いません。
コードは05・11・17時台を判定します。
時刻の分や配列の意味まで検証しているわけではなく、すべての形式変更で安全に停止すると保証はできません。
