今日の天気をSlackへ送る処理を、予報の取得・通知文の作成・送信に分けます。
旧HTMLのタグや配列番号に頼る抽出を置き換え、東京地方と東京の観測地点をコードで選び、対象日と発表日時を確認する練習です。
架空の予報で欠けている気温を確かめる
各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_WEBHOOK_URLを設定してからsendWeatherを手動で確認します。
同じ送信先には当日の初回正常投稿だけを送り、発表の更新で再投稿しません。
正常投稿が保存済みなら同日の再実行では気象庁への再取得も省きます。
日付が変わると別の候補になり、時刻と日付の判定はAsia/Tokyoです。
トリガーは送信する時間帯に1つだけ設定し、夜の17時発表で当日の気温がない場合は未取得のまま送ります。
取得失敗時は通知を止めるため、空の値を正常な予報として送信しません。
気象庁データの利用は気象庁の利用案内を確認し、通知文にも出典を示します。
実際のApps Scriptからの取得・送信は未検証で、公開データをローカルで解析した結果と区別しています。
送信結果が不明なときの停止と戻し方
スクリプトプロパティのSEND_ENABLEDがtrueになるまで、送信関数は入力の取得前に停止します。
送信先を管理する本人の許可を得て、送信する内容と練習用の送信先を確かめてから有効にします。
通信前にsending、処理完了後にdoneを保存し、通信や記録に失敗した場合はunknownなどの状態を残して止めます。
成功応答がなくても投稿先へ届いている可能性があるため、自動で再送しません。
結果が不明な場合はSEND_ENABLEDをfalseにし、トリガーを停止して、投稿先とシートを照合します。
送信が確認できた記録はdoneへ整え、未送信を確認できた場合だけ再実行の準備をします。
同じIDの本文や予約日時を変更すると入力変更として停止します。
送信済みの記録を消したり、新しいIDを付けたりすると再送できてしまうため、再実行の代わりに記録を消さないでください。
ロックは同じプロジェクト内の競合を防ぐもので、別のプロジェクトや旧トリガーからの投稿は防ぎません。
移行時は旧トリガーを止め、長期運用では保存件数と整理方法を決めます。
戻す際は新しいトリガーを止め、保存した旧コード・設定・シートのコピーを参照します。
処理済みの対象がある場合は、旧コードで同じ対象を再送しないように確認します。
検証した範囲と実サービスの確認
2026年10月2日に、Node.jsでApps Script・HTTP応答・ロック・保存状態・シートを模した83ケースを確認しました。
ZIPを新しいフォルダーへ展開し、node test_integration.cjsを実行すると同じ条件を確認できます。
Googleの認可、実シートの更新、Chatwork・Slackへの実投稿、Driveからの実画像アップロード、トリガーの実行時刻は未検証です。
実サービスを使う際は、練習用の入力・送信先・認可範囲を確認してから1回手動で実行し、投稿先と保存状態を照合します。
関連記事・旧実装の参考資料
外部の参考コードと旧実装の説明は、今回の練習ZIPと内容が異なる場合があります。
現在の練習では本稿のZIPを使い、送信先と現行の仕様を確認します。





