Amazonで商品を見るセール会場へ

Slack│スプレッドシートのデータを時間別に通知する方法│GAS

スプレッドシートに用意した集計値を、対象時刻と更新時刻を確認してSlackへ送る例です。

旧Google AnalyticsアドオンやSlackAppライブラリの画面手順を置き換え、まず手入力の練習データで通知の選択を再現します。

目次

PVが0の集計行で通知を練習する

通知・予約投稿の練習コードと画像をダウンロード(ZIP)。

各article-番号.jsを別々のApps Scriptプロジェクトへ保存します。

article-2815.jsを保存し、demoReportを実行します。

10月2日9時のPVが0の架空データを通知候補にし、0を欠測と扱わない結果を表示します。

スプレッドシートやGA4の取得、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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
  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 reportPlans_(rows, now) {
  const current=jst_(now,'yyyy-MM-dd HH'), hour=Number(jst_(now,'HH'));
  if (![0,9,12,15,18,22].includes(hour)) return [];
  const ids=new Set(), times=new Set(), plans=[];
  for (const r of rows) {
    const at=r[0], pv=r[1], updated=r[2], id=String(r[3]);
    if (!validDate_(at) || !validDate_(updated) || !Number.isSafeInteger(pv) || pv<0 || !/^[A-Za-z0-9_-]+$/.test(id) || ids.has(id)) throw new Error('行の日時・PV・IDを確認してください');
    const bucket=jst_(at,'yyyy-MM-dd HH');
    if (times.has(bucket)) throw new Error('同じ対象時刻が重複しています');
    ids.add(id); times.add(bucket);
    if (bucket!==current) continue;
    if (updated>now || now-updated>3600000 || at>now) throw new Error('集計の更新時刻を確認してください');
    const text='【手動入力した集計値】対象 '+bucket+':00\nPV: '+pv+'\n更新: '+jst_(updated,'yyyy-MM-dd HH:mm');
    plans.push({id:id,text:text,payload:{text:text}});
  }
  return plans;
}
function demoReport() {
  const p=reportPlans_([[new Date('2026-10-02T09:00:00+09:00'),0,new Date('2026-10-02T09:03:00+09:00'),'practice-0900']],new Date('2026-10-02T09:05:00+09:00'));
  Logger.log(JSON.stringify(p)); return p;
}
function readReportPlans_() {
  const s=SpreadsheetApp.openById(required_('SPREADSHEET_ID')).getSheetByName('集計');
  if (!s) throw new Error('集計シートがありません');
  const rows=s.getLastRow()<2 ? [] : s.getRange(2,1,s.getLastRow()-1,4).getValues();
  return reportPlans_(rows,new Date());
}
function previewReport() { return preview_(readReportPlans_()); }
function getAnalytics() {
  return runLocked_(() => {
    const url=slackUrl_(), plans=readReportPlans_();
    for (const p of plans) once_('report',url,p,()=>slackPost_(p.text,url));
  });
}

集計シートの4列を用意する

集計シートの列入力条件
A列・対象時刻練習例は2026/10/02 09:00:00。日付時刻型で入力
B列・PV練習例は0。0以上の整数を数値として入力
C列・更新時刻練習例は2026/10/02 09:03:00。日付時刻型で入力
D列・レポートID練習例はpractice-0900。英数字・ハイフン・アンダースコアで重複させない

1行目に対象時刻、PV、更新時刻、レポートIDの見出しを付け、2行目からデータを入れます。

実シートを使う前に、対象時刻と更新時刻を現在の練習日時へ変え、スプレッドシートとプロジェクトのタイムゾーンをAsia/Tokyoにそろえます。

文字列のままの日時はエラーになります。

このコードはGA4から値を自動取得しません。

GA4のエクスポートなど、別途用意した値の集計範囲・指標・更新時刻を確認してシートへ入れます。

必要な取得処理はGA4 Data APIの公式資料と権限を確認し、通知処理とは分けて実装・検証します。

更新時刻と重複通知の確認結果

条件結果
当日9時の更新されたPV00の通知候補を作る
0・9・12・15・18・22時以外投稿候補にしない
現在の時間帯と違う対象時刻候補から除く
更新から1時間超・未来の更新時刻古い値や未来の値を現在の集計として送らず停止
同じ対象時刻・レポートIDの重複入力ミスとして投稿前に停止
同じIDの値を送信後に変更同じ処理の上書きと扱わず照合待ちで停止

SPREADSHEET_IDとSLACK_WEBHOOK_URLを設定し、previewReportで候補を確認します。

送信するgetAnalyticsへ5分間隔のトリガーを1つだけ設定すると、対象の時間帯に候補を探し、同じIDの正常投稿は繰り返しません。

旧例のSetTrigger1から6を毎日増やす設定は使いません。

対象時刻はその行の集計値が何を表すかを示す時刻です。

9時の行が8時台だけの値なのか、当日累計なのかをデータ作成側で統一し、通知文や元のレポートでも説明します。

今回の例は時間帯からPVを自動で合計するものではありません。

実行が止まって対象時間帯を過ぎると、その時間帯の候補を後追いしません。

深夜0時は翌日の日付として判定します。

GA4の反映遅延がある値を現在の確定値と断定せず、古い値なら更新待ちで止めます。

元シートは読み取りだけで、通知の処理済み状態はスクリプトプロパティへ保存します。

2026年10月2日にSlackの現行Webhook仕様を確認しました。

GA4取得・実シートの日時型・時間主導型トリガーは今回未検証です。

送信結果が不明なときの停止と戻し方

スクリプトプロパティの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を使い、送信先と現行の仕様を確認します。

次に学ぶ・作業環境を選ぶ

学習を続けたい方や、作業環境を整えたい方は、目的に合うガイドをご覧ください。

仕事効率化のおすすめ書籍

仕事用PCの選び方

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次