Webサイトを運営していると、Google Analytics 4(GA4)やGoogle Search Consoleを「毎日確認したいけれど、つい忘れる」ということがありませんか?
私も個人サイト「Futari-Note」を運営する中で、数字を眺めるだけでなく、変化を自動でまとめ、改善するべきことまで知りたいと考えました。
そこで今回は、Google Apps Script(GAS)でGA4とSearch Consoleを集計し、Gmailに送信。ChatGPTで分析・通知する仕組みを構築しました。
スクリーンショットは使わず、図とコードで再現手順をまとめています。初めてGASを触る方にも、操作順に進められる構成にしました。
この記事で実現すること
- 毎朝:前日のGA4と、取得可能な最新のSearch Consoleデータを通知
- 毎週月曜:前週との比較、流入経路、人気ページ、検索クエリを集計
- Googleスプレッドシートへ履歴を保存
- ChatGPTで「どこを改善するか」を文章にまとめる
- エラー時に確認する場所を整理する
注意:Google側の自動集計・Gmail送信と、ChatGPTのタスク実行は別の仕組みです。ChatGPTにGmailを読ませる機能は、プラン・地域・接続状況・タスク実行環境によって利用できる範囲が異なります。Gmailまでの自動通知を確実な基本構成とし、ChatGPTは追加の分析機能として使います。
1. まずは全体像
ここで登場するサービスの役割は次のとおりです。
| サービス | 何をする? | 料金の考え方 |
|---|---|---|
| GA4 | アクセス数・ページ閲覧・流入元を取得 | 通常の利用では無料枠がある |
| Search Console | Google検索の表示回数・クリック・掲載順位を取得 | 無料 |
| Apps Script | APIの取得・保存・配信を自動実行 | 無料利用枠と実行制限あり |
| Google Sheets | 日次データとレポート履歴を蓄積 | 通常の個人利用で利用可能 |
| Gmail | レポートの送信先・予備通知 | 送信数などに制限あり |
| ChatGPT | 数値を解釈して改善案を考える | 機能や利用プランによる |
※料金・制限は利用形態によって変わるため、最新の各サービスの案内を確認してください。
2. 事前に必要なもの
- Googleアカウント
- 対象サイトが登録済みのGoogle Analytics 4
- 対象サイトのGoogle Search Consoleにアクセスできる権限
- Google Apps Scriptを利用できる環境
- (ChatGPT分析まで行う場合)ChatGPTと対象Gmailの接続、およびタスク機能の利用権限
重要なポイント: GA4とSearch Consoleのデータを読み取るGoogleアカウントでGASを作ると、権限設定が分かりやすくなります。
2-1. GA4の「プロパティID」を確認する
- Google Analyticsを開く
- 対象のサイトを選択する
- 左下の「管理」から「プロパティの詳細」を開く
- 数字のみのプロパティIDを確認する
必要:123456789 ← GA4のプロパティID(例)
不要:G-ABCD1234 ← 測定ID(別のもの)
2-2. Search Consoleのプロパティを確認する
Google Search Consoleを開き、対象サイトを選びます。
ドメインプロパティの場合、APIに渡す値は次の形式です。
sc-domain:example.com
URLプレフィックスプロパティなら https://example.com/ のように、登録したURLをそのまま使います。両者を混同しないようにしましょう。
3. Google Apps Scriptでプロジェクトを作る
- Google Apps Scriptを開く
- 「新しいプロジェクト」を作成
-
コード.gsまたはCode.gsにプログラムを貼り付ける - 左の「プロジェクトの設定」で 「appsscript.json マニフェスト ファイルをエディタで表示する」 にチェック
- エディタに戻り、表示された
appsscript.jsonを編集する
マニフェストは「このスクリプトがどのGoogleサービスの権限を要求するか」などを宣言する設定ファイルです。
Code.gsの中にJSONを貼り付けるわけではありません。
3-1. appsscript.json
次をそのまま設定します。
{
"timeZone": "Asia/Tokyo",
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/analytics.readonly",
"https://www.googleapis.com/auth/webmasters.readonly",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/script.external_request",
"https://www.googleapis.com/auth/script.send_mail",
"https://www.googleapis.com/auth/script.scriptapp"
]
}
ここで指定しているのは、GA4・Search Consoleの読み取り、スプレッドシート操作、外部API呼び出し、メール送信、トリガー作成などの権限です。信頼できるコードだけを承認してください。
3-2. Code.gs の設定値を変更する
この記事の末尾に、実行可能な Code.gs の全文を折りたたみで掲載しています。コードをすべてコピーしたら、最初に configureFutariNote の値を変更します。
function configureFutariNote() {
PropertiesService.getScriptProperties().setProperties({
GA4_PROPERTY_ID: '123456789', // 自分のGA4プロパティID
GSC_SITE_URL: 'sc-domain:example.com', // 自分のサイト
SPREADSHEET_ID: '', // 空欄なら新規作成
NOTIFY_EMAIL: 'your-email@gmail.com' // レポート受信先
});
setup();
}
※上の値はすべて説明用のダミーです。実際の自分の値に置き換えてください。configureFutariNote() を再実行すると、これらの設定値が再登録されます。
3-3. コードで何をしているのか?
処理の流れは次のとおりです。
主な関数は以下です。
| 関数 | 用途 |
|---|---|
configureFutariNote |
初期値の登録+トリガー設定 |
setup |
シートとトリガーの準備 |
testDaily |
日次テストメールの送信 |
testWeekly |
週次テストメールの送信 |
scheduledRun |
毎日実行。月曜日は週次も配信 |
4. Google Cloudで2つのAPIを有効化する
APIを呼び出すには、Apps Scriptと紐づいているGoogle Cloudプロジェクトで次の2つを有効化します。
「有効にする」を押したあと、反映まで数分かかる場合があります。
4-1. 今回つまずいたポイント:403エラー
実際に遭遇したのが、こんなエラーです。
Error: Google API HTTP 403
Google Search Console API has not been used in project XXXXX...
before or it is disabled.
Cloud ConsoleではAPIを「有効」にしたのに、GASから呼び出すと403になる。原因はAPIを有効にしたプロジェクトと、GASが参照しているプロジェクトが別だったことでした。
ここで間違えやすいのは「プロジェクトID」と「プロジェクト番号」です。
| 項目 | 形式 | 使う場面 |
|---|---|---|
| プロジェクトID | my-site-123456 |
Cloud ConsoleのURLなど |
| プロジェクト番号 | 123456789012 |
Apps ScriptのGCPプロジェクト切り替え |
| GA4プロパティID | 987654321 |
GA4 Data APIのレポート取得 |
4-2. プロジェクト番号を合わせる方法
- Cloud Consoleで「IAMと管理」→「設定」を開き、プロジェクト番号を確認する
- GASで「プロジェクトの設定」→「Google Cloud Platform(GCP)プロジェクト」を開く
- 番号が違う場合は「プロジェクトを変更」からCloud側の番号を設定する
- Googleの承認画面が表示されたら、スコープとアカウントを確認して再認証する
-
testDailyをもう一度実行する
注意: Apps ScriptのデフォルトCloudプロジェクトから標準Cloudプロジェクトへ変更すると、元のデフォルトプロジェクトには戻せません。またOAuth同意画面やテストユーザーの設定が必要になる場合があります。変更する前に対象プロジェクトが正しいか十分に確認してください。
参考:Google公式:Apps ScriptのCloudプロジェクト
5. テストしてみる
GASのエディタ上部にある関数選択欄から、次の順に実行します。
手順1:configureFutariNote
初期設定を行います。成功すると、次のようなログが表示されます(URL等は例)。
Ready. Google Sheet: https://docs.google.com/spreadsheets/d/...
Notifications to: your-email@gmail.com
Trigger: daily around 08:00 Asia/Tokyo (±15 min)
実行完了
GASの時間トリガーは必ずしも秒単位の定刻には動きません。ここでは8時前後の実行を想定しています。
手順2:testDaily と testWeekly
それぞれ実行し、Gmailに次のような件名のメールが届くか確認します。
[TEST] [FN-ANALYTICS][DAILY] Futari-Note ...
[TEST] [FN-ANALYTICS][WEEKLY] Futari-Note ...
テストでは、本番レポート用のシートは更新しない設計です。RUN_LOG にはテスト成功の記録が残ります。
手順3:scheduledRun
最後に本番形式で実行します。
成功時には、Gmailで [TEST] が付かないメールが届き、スプレッドシートの次のタブが更新されます。
| シート名 | 記録内容 |
|---|---|
GA4_DAILY |
日付・セッション・ユーザー・PV・エンゲージメント率 |
GSC_DAILY |
日付・クリック・表示回数・CTR・掲載順位 |
REPORTS |
送信済みレポート本文 |
RUN_LOG |
実行時刻・処理種別・成功/失敗 |
RUN_LOG の status が OK かを見れば、実行の状態を確認できます。ただし「実行完了」の表示だけでなく、実際のメール受信とデータの保存まで確認するのがおすすめです。
6. ChatGPTに改善提案を依頼する
ここまでで、ChatGPTがなくてもGmailへの自動通知は完成です。次に、届いたデータの解釈をChatGPTに任せます。
パターンA:ChatGPTの定期タスクを使う
対応している環境では、GmailをChatGPTに接続し、毎日・毎週決めた時間にレポートを読むタスクを設定できます。タスクによる接続アプリの利用可否は環境によるため、まず手動で対象メールを読めるかテストしてください。
ChatGPTに設定する指示文の例です。
毎朝9時頃、接続済みGmailから件名に
[FN-ANALYTICS][DAILY]を含む最新の本番レポートを探してください。件名に[TEST]があるものは除外してください。GA4とSearch Consoleの集計期間を明記し、前期間との変化、注意すべき点、今日の改善提案を最大1つ、日本語で簡潔に伝えてください。メールを読めないときは数値を推測せず、その旨を知らせてください。
週次の場合は [WEEKLY] を対象に、改善施策を「対象ページ・変更内容・見るべき指標」まで具体化すると便利です。
パターンB:Gmail新着をトリガーにする
ChatGPT WorkでGmailの新着イベントを扱える環境なら、[FN-ANALYTICS] を含む件名に反応するイベントトリガー型タスクも候補です。こちらは利用可能な環境とトリガーが確認できる場合だけ設定します。
ChatGPTのタスクと通知については公式ヘルプを参照してください。
ChatGPT側が使えない場合は?
- 最も簡単: Gmailアプリの通知を有効化する(集計メールは自動で届く)
- 分析は手動: Gmailの本文をChatGPTに貼り付け「改善施策TOP3を提案して」と依頼する
- より高度な自動化: OpenAI APIをGAS等から呼び出してAI要約したものをGmailで送信する(ChatGPTの月額契約とは別にAPI料金が必要になることがあります)
7. 通知レポートのイメージ
以下は説明用の架空の数値です。
【アクセス速報】
対象:GA4 10/9、Search Console 10/6
GA4
・セッション:120(前日比 +14%)
・ユーザー数:90(前日比 +8%)
・ページビュー:310(前日比 +21%)
Search Console
・クリック:43
・表示回数:980
・CTR:4.4%
AIからの提案
検索表示が増えている記事と、クリック率の低い記事を分けて確認。
まずは表示回数が多い記事のタイトルを優先して見直しましょう。
ここで重要なのは、GA4とSearch Consoleは異なる数字を測っているということです。
- GA4の「セッション」=サイト内の訪問セッション
- Search Consoleの「クリック」=Google検索結果などからのクリック
- データの集計基準や確定タイミングも異なる
2つの値が一致していなくても、即座に不具合とは限りません。また、アクセス数が小さいときは前日比のパーセントが大きく見えやすいため、数週間単位で判断しましょう。
8. 運用上の注意
① Search Consoleの集計遅延
当日の検索データがすぐ確定するとは限りません。コードは取得できる直近の確定データを探しますが、データがない日を「0クリック」と断定しないようにしています。
② OAuth・API権限
「CloudでAPIが有効か」と「GAS実行ユーザーがGA4/GSCへアクセスできるか」は別の話です。403が出たら両方を確認してください。
③ メールの二重配信
本番の自動実行は日付ごとの送信履歴で重複を抑えます。手動テストは [TEST] を付け、ChatGPTの分析対象から除外します。
④ セキュリティ
記事・GitHubへ公開する前に、メールアドレス、スプレッドシートID、OAuthトークンなどを含めないようにしましょう。プロパティIDだけでGoogleデータを閲覧できるわけではありませんが、公開に不要な実環境の識別子は置き換えるのが無難です。
⑤ 自動化できたかは翌日も確認
初回の手動成功は、翌朝の自動トリガーが必ず成功する保証にはなりません。翌日以降の RUN_LOG、受信メール、ChatGPT側の実行履歴まで確認します。
9. まとめ
今回の構成で、Webサイトのアクセス状況を**「自分から見に行くもの」から「毎朝届くもの」**へ変えられました。
いきなりAI連携まで完成させようとすると設定が多く感じますが、次の3段階に分けると進めやすいです。
個人ブログ、小さなサービス、社内サイトの運営など、「毎日Analyticsを開くのは負担だけど、変化は把握したい」という方の参考になればうれしいです。
参考資料
- Google Analytics Data API:runReport
- Google Search Console API:Search Analytics query
- Google Apps Script:Cloudプロジェクトの設定
- Google Apps Script:トリガーの時刻指定
- Google Apps Script:認証の仕組み
- OpenAI:ChatGPTのスケジュール済みタスク
- Qiita:Mermaid記法の案内
付録:Code.gs 全文(公開用サンプル)
configureFutariNote() の GA4_PROPERTY_ID・GSC_SITE_URL・NOTIFY_EMAIL を自分の値に置き換えてから実行してください。SPREADSHEET_ID は空欄なら新規作成します。実際の個人情報やIDは含めていません。
クリックして Code.gs 全文を見る(長いため折りたたみ)
/**
* Futari-Note | GA4 + Search Console automatic reporting
* Google Apps Script (V8 runtime)
* Required Script Properties:
* GA4_PROPERTY_ID = 123456789 (example: numeric GA4 property ID, NOT G-XXXXXXXX)
* NOTIFY_EMAIL = your Gmail address connected to ChatGPT
* Optional Script Properties:
* GSC_SITE_URL = sc-domain:futari-note-app.com
* SPREADSHEET_ID = existing spreadsheet ID (auto-created by setup)
*
* Install: paste Code.gs and appsscript.json, run configureFutariNote(),
* then run testDaily() and testWeekly().
*/
const FN = Object.freeze({
TZ: 'Asia/Tokyo',
DEFAULT_GSC_SITE: 'sc-domain:example.com',
PREFIX: '[FN-ANALYTICS]',
TAB_GA4: 'GA4_DAILY',
TAB_GSC: 'GSC_DAILY',
TAB_REPORTS: 'REPORTS',
TAB_LOGS: 'RUN_LOG'
});
/**
* One-click initial configuration for Futari-Note.
* Sets the known GA4 property, GSC domain property, existing spreadsheet,
* and the Gmail account connected to ChatGPT, then installs the GAS trigger.
* Replace GA4_PROPERTY_ID / GSC_SITE_URL / NOTIFY_EMAIL below before running.
* SPREADSHEET_ID empty means setup() creates a new spreadsheet.
* If you rerun this function, these four properties will be reset to these
* defaults; call setup() alone to reinstall a trigger without resetting them.
*/
function configureFutariNote() {
PropertiesService.getScriptProperties().setProperties({
GA4_PROPERTY_ID: '123456789',
GSC_SITE_URL: 'sc-domain:example.com',
SPREADSHEET_ID: '',
NOTIFY_EMAIL: 'your-email@gmail.com'
});
setup();
}
/** One-time initialization; creates the spreadsheet and one daily trigger. */
function setup() {
const props = PropertiesService.getScriptProperties();
const cfg = config_();
let id = props.getProperty('SPREADSHEET_ID');
if (!id) {
const ss = SpreadsheetApp.create('Futari-Note | GA4・GSC 自動分析');
id = ss.getId();
props.setProperty('SPREADSHEET_ID', id);
}
const ss = SpreadsheetApp.openById(id);
ensureSheet_(ss, FN.TAB_GA4,
['date', 'sessions', 'totalUsers', 'screenPageViews', 'engagementRate', 'keyEvents', 'updatedAt']);
ensureSheet_(ss, FN.TAB_GSC,
['date', 'clicks', 'impressions', 'ctr', 'position', 'hasData', 'updatedAt']);
ensureSheet_(ss, FN.TAB_REPORTS,
['reportId', 'subject', 'content', 'createdAt']);
ensureSheet_(ss, FN.TAB_LOGS,
['executedAt', 'job', 'status', 'message']);
// Avoid duplicate triggers on repeated setup() execution.
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === 'scheduledRun')
.forEach(t => ScriptApp.deleteTrigger(t));
ScriptApp.newTrigger('scheduledRun')
.timeBased().atHour(8).nearMinute(0).everyDays(1)
.inTimezone(FN.TZ).create();
Logger.log('Ready. Google Sheet: ' + ss.getUrl());
Logger.log('Notifications to: ' + cfg.email);
Logger.log('Trigger: daily around 08:00 Asia/Tokyo (±15 min)');
}
/** Once per day: daily report; Monday also generates a weekly report. */
function scheduledRun() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) throw new Error('Another report job is already running.');
try {
const props = PropertiesService.getScriptProperties();
const today = day_(0);
if (props.getProperty('LAST_SENT_DAILY') !== today) {
try {
runDaily_(false);
props.setProperty('LAST_SENT_DAILY', today);
} catch (e) {
reportFailure_('DAILY', e);
}
}
if (isMonday_() && props.getProperty('LAST_SENT_WEEKLY') !== today) {
try {
runWeekly_(false);
props.setProperty('LAST_SENT_WEEKLY', today);
} catch (e) {
reportFailure_('WEEKLY', e);
}
}
} finally {
lock.releaseLock();
}
}
/** Manual tests send a [TEST] email without changing scheduled-run state. */
function testDaily() { runDaily_(true); }
function testWeekly() { runWeekly_(true); }
function runDaily_(isTest) {
const cfg = config_();
const gaDate = day_(-1);
const gaPrevDate = day_(-2);
// Search Console is delayed; discover the latest date with finalized data.
const scDate = latestGscDate_(cfg.gsc);
const scPrevDate = shiftDate_(scDate, -1);
const ga = ga4Summary_(cfg.ga4, gaDate, gaDate);
const gaPrev = ga4Summary_(cfg.ga4, gaPrevDate, gaPrevDate);
const sc = gscSummary_(cfg.gsc, scDate, scDate);
const scPrev = gscSummary_(cfg.gsc, scPrevDate, scPrevDate);
const subject = `${FN.PREFIX}[DAILY] Futari-Note ${day_(0)}`;
const content = [
'Futari-Note アクセス速報',
`GA4: ${gaDate} (前回: ${gaPrevDate}) / GSC: ${scDate} (前回: ${scPrevDate})`,
'',
'【GA4】',
`セッション: ${num_(ga.sessions)} (${change_(ga.sessions, gaPrev.sessions)})`,
`ユーザー: ${num_(ga.totalUsers)} (${change_(ga.totalUsers, gaPrev.totalUsers)})`,
`PV: ${num_(ga.screenPageViews)} (${change_(ga.screenPageViews, gaPrev.screenPageViews)})`,
`エンゲージメント率: ${pct_(ga.engagementRate)}`,
`キーイベント: ${num_(ga.keyEvents)}`,
'',
'【Google Search Console・確定データ】',
sc.hasData
? `クリック: ${num_(sc.clicks)} (${scPrev.hasData ? change_(sc.clicks, scPrev.clicks) : '前回データなし'})` : 'クリック: データなし(0件とは限りません)',
sc.hasData ? `表示回数: ${num_(sc.impressions)} (${scPrev.hasData ? change_(sc.impressions, scPrev.impressions) : '前回データなし'})` : '表示回数: データなし',
sc.hasData ? `CTR: ${pct_(sc.ctr)} / 平均順位: ${dec_(sc.position)}` : 'CTR・平均順位: データなし',
'',
'【自動検知】',
...alerts_(ga, gaPrev, sc, scPrev),
'',
'補足: GA4は前日値のため後から変動する場合があります。GSCはデータ反映の遅延を考慮しています。',
'ChatGPTへの依頼: 異常の有無・推奨アクションを3行以内で説明してください。'
].join('\n');
if (!isTest) {
saveUpsert_(FN.TAB_GA4, [gaDate, ga.sessions, ga.totalUsers,
ga.screenPageViews, ga.engagementRate, ga.keyEvents, timestamp_()]);
saveUpsert_(FN.TAB_GSC, [scDate, sc.clicks, sc.impressions,
sc.ctr, sc.position, sc.hasData, timestamp_()]);
}
deliver_(subject, content, isTest);
log_('DAILY', 'OK', `${gaDate} / ${scDate}`);
}
function runWeekly_(isTest) {
const cfg = config_();
// Both sources use the same seven-date window ending at the latest
// available finalized GSC date, which can lag by more than three days.
const end = latestGscDate_(cfg.gsc);
const period = { start: shiftDate_(end, -6), end };
const previous = { start: shiftDate_(end, -13), end: shiftDate_(end, -7) };
const ga = ga4Summary_(cfg.ga4, period.start, period.end);
const gaPrev = ga4Summary_(cfg.ga4, previous.start, previous.end);
const sc = gscSummary_(cfg.gsc, period.start, period.end);
const scPrev = gscSummary_(cfg.gsc, previous.start, previous.end);
const channels = ga4Breakdown_(cfg.ga4, period, 'sessionDefaultChannelGroup', 'sessions', 5);
const landing = ga4Breakdown_(cfg.ga4, period, 'landingPage', 'sessions', 5);
const scPages = gscBreakdown_(cfg.gsc, period, 'page', 5);
const scQueries = gscBreakdown_(cfg.gsc, period, 'query', 5);
const subject = `${FN.PREFIX}[WEEKLY] Futari-Note ${period.start}〜${period.end}`;
const content = [
'Futari-Note 週間詳細レポート',
`対象: ${period.start}〜${period.end} / 比較: ${previous.start}〜${previous.end}`,
'',
'【GA4】',
`セッション: ${num_(ga.sessions)} (${change_(ga.sessions, gaPrev.sessions)})`,
`ユーザー: ${num_(ga.totalUsers)} (${change_(ga.totalUsers, gaPrev.totalUsers)})`,
`PV: ${num_(ga.screenPageViews)} (${change_(ga.screenPageViews, gaPrev.screenPageViews)})`,
`エンゲージメント率: ${pct_(ga.engagementRate)} (前期間 ${pct_(gaPrev.engagementRate)})`,
`キーイベント: ${num_(ga.keyEvents)} (前期間 ${num_(gaPrev.keyEvents)})`,
'',
'【Search Console】',
sc.hasData ? `クリック: ${num_(sc.clicks)} (${scPrev.hasData ? change_(sc.clicks, scPrev.clicks) : '前期間データなし'})` : 'クリック: データなし',
sc.hasData ? `表示回数: ${num_(sc.impressions)} (${scPrev.hasData ? change_(sc.impressions, scPrev.impressions) : '前期間データなし'})` : '表示回数: データなし',
sc.hasData ? `CTR: ${pct_(sc.ctr)}${scPrev.hasData ? ' (前期間 ' + pct_(scPrev.ctr) + ', ' + pointDiff_(sc.ctr, scPrev.ctr) + ')' : ''}` : 'CTR: データなし',
sc.hasData ? `平均掲載順位: ${dec_(sc.position)}${scPrev.hasData ? ' (前期間 ' + dec_(scPrev.position) + ', ' + rankDiff_(sc.position, scPrev.position) + ')' : ''}` : '平均掲載順位: データなし',
'',
'【流入チャネル(GA4セッション)】',
...formatGaBreakdown_(channels),
'',
'【ランディングページ上位(GA4セッション)】',
...formatGaBreakdown_(landing),
'',
'【Google検索流入ページ上位(GSCクリック)】',
...formatGscBreakdown_(scPages),
'',
'【検索クエリ上位(GSCクリック)】',
...formatGscBreakdown_(scQueries),
'',
'【自動検知】',
...alerts_(ga, gaPrev, sc, scPrev),
'',
'【ChatGPTへの分析依頼】',
'1. 改善優先度の高い課題を最大3つ抽出する。',
'2. 数値で確認できる事実と、原因についての仮説を分ける。',
'3. Futari-NoteのSEO、記事リライト、内部リンク、シミュレーターへの導線改善を具体的に提案する。',
'4. 小さい母数での変動を過大評価しない。取得したレポートにないデータを捏造しない。',
'',
'注: GSCの検索ページ/クエリ内訳は匿名化等により合計と一致しないことがあります。'
].join('\n');
deliver_(subject, content, isTest);
log_('WEEKLY', 'OK', `${period.start}〜${period.end}`);
}
function ga4Summary_(propertyId, start, end) {
const keys = ['sessions', 'totalUsers', 'screenPageViews', 'engagementRate', 'keyEvents'];
const data = ga4Query_(propertyId, start, end, [], keys, 1);
const values = (data.rows && data.rows[0] && data.rows[0].metricValues) || [];
const result = {};
keys.forEach((k, i) => result[k] = Number((values[i] || {}).value || 0));
return result;
}
function ga4Breakdown_(propertyId, period, dimension, metric, limit) {
const data = ga4Query_(propertyId, period.start, period.end, [dimension], [metric], limit);
return (data.rows || []).map(row => ({
name: String((row.dimensionValues[0] || {}).value || '(not set)'),
value: Number((row.metricValues[0] || {}).value || 0)
}));
}
function ga4Query_(propertyId, start, end, dimensions, metrics, limit) {
const body = {
dateRanges: [{ startDate: start, endDate: end }],
metrics: metrics.map(name => ({ name })),
limit: String(limit),
returnPropertyQuota: false
};
if (dimensions.length) {
body.dimensions = dimensions.map(name => ({ name }));
body.orderBys = [{ metric: { metricName: metrics[0] }, desc: true }];
}
return postGoogleJson_(
`https://analyticsdata.googleapis.com/v1beta/properties/${encodeURIComponent(propertyId)}:runReport`,
body
);
}
function latestGscDate_(siteUrl) {
const data = gscQuery_(siteUrl, day_(-12), day_(-2), ['date'], 100);
const dates = (data.rows || []).map(r => String((r.keys || [])[0] || ''))
.filter(value => /^\d{4}-\d{2}-\d{2}$/.test(value));
// No rows does not prove zero impressions or a finalized date.
return dates.sort().pop() || day_(-4);
}
function gscSummary_(siteUrl, start, end) {
const data = gscQuery_(siteUrl, start, end, [], 1);
const row = (data.rows || [])[0];
return row ? {
clicks: Number(row.clicks || 0), impressions: Number(row.impressions || 0),
ctr: Number(row.ctr || 0), position: Number(row.position || 0), hasData: true
} : { clicks: 0, impressions: 0, ctr: 0, position: 0, hasData: false };
}
function gscBreakdown_(siteUrl, period, dimension, limit) {
const data = gscQuery_(siteUrl, period.start, period.end, [dimension], limit);
return (data.rows || []).map(row => ({
name: String((row.keys || [])[0] || '(not set)'),
clicks: Number(row.clicks || 0),
impressions: Number(row.impressions || 0)
}));
}
function gscQuery_(siteUrl, start, end, dimensions, limit) {
return postGoogleJson_(
`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`,
{ startDate: start, endDate: end, dimensions, rowLimit: limit, type: 'web', dataState: 'final' }
);
}
function postGoogleJson_(url, payload) {
let lastError;
for (let attempt = 0; attempt < 3; attempt++) {
const response = UrlFetchApp.fetch(url, {
method: 'post', contentType: 'application/json',
headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` },
payload: JSON.stringify(payload), muteHttpExceptions: true
});
const code = response.getResponseCode();
const body = response.getContentText();
if (code >= 200 && code < 300) return JSON.parse(body || '{}');
lastError = new Error(`Google API HTTP ${code}: ${body.slice(0, 600)}`);
if (code !== 429 && code < 500) break;
Utilities.sleep((attempt + 1) * 1000);
}
throw lastError;
}
function deliver_(subject, body, isTest) {
const cfg = config_();
const title = isTest ? '[TEST] ' + subject : subject;
MailApp.sendEmail({ to: cfg.email, subject: title, body, name: 'Futari-Note Analytics' });
if (!isTest) {
saveUpsert_(FN.TAB_REPORTS, [title, title, body, timestamp_()]);
}
}
function alerts_(ga, gaPrev, sc, scPrev) {
const alerts = [];
if (gaPrev.sessions >= 20 && ga.sessions < gaPrev.sessions * 0.7)
alerts.push('⚠ GA4セッションが前期間比30%以上減少しました。');
if (gaPrev.screenPageViews >= 20 && ga.screenPageViews < gaPrev.screenPageViews * 0.7)
alerts.push('⚠ PVが前期間比30%以上減少しました。');
if (sc.hasData && scPrev.hasData && scPrev.impressions >= 20 &&
sc.impressions < scPrev.impressions * 0.7)
alerts.push('⚠ Google検索の表示回数が前期間比30%以上減少しました。');
if (ga.sessions === 0)
alerts.push('要確認: GA4セッションが0です。トラッキング設定やデータ反映を確認してください。');
if (!sc.hasData) alerts.push('GSC対象日の確定データを確認できませんでした(0件とは限りません)。');
if (!alerts.length) alerts.push('設定した閾値を超える減少は検出されませんでした。');
return alerts;
}
function formatGaBreakdown_(items) {
return items.length ? items.map((x, i) => `${i + 1}. ${x.name}: ${num_(x.value)}セッション`)
: ['該当データなし'];
}
function formatGscBreakdown_(items) {
return items.length ? items.map((x, i) =>
`${i + 1}. ${x.name}: ${num_(x.clicks)}クリック / ${num_(x.impressions)}表示`)
: ['該当データなし'];
}
function saveUpsert_(tabName, row) {
const id = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID');
if (!id) throw new Error('Run setup() to initialize the spreadsheet.');
const sheet = SpreadsheetApp.openById(id).getSheetByName(tabName);
if (!sheet) throw new Error(`Missing tab: ${tabName}`);
const lastRow = sheet.getLastRow();
if (lastRow >= 2) {
const keys = sheet.getRange(2, 1, lastRow - 1, 1).getValues();
const index = keys.findIndex(r => String(r[0]) === String(row[0]));
if (index >= 0) {
sheet.getRange(index + 2, 1, 1, row.length).setValues([row]);
return;
}
}
sheet.appendRow(row);
}
function ensureSheet_(ss, name, headers) {
const sheet = ss.getSheetByName(name) || ss.insertSheet(name);
if (sheet.getLastRow() === 0) {
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
sheet.setFrozenRows(1);
}
// Keep YYYY-MM-DD and report IDs as text to make reruns idempotent.
sheet.getRange(1, 1, sheet.getMaxRows(), 1).setNumberFormat('@');
return sheet;
}
function log_(job, status, message) {
const id = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID');
if (!id) return;
const tab = SpreadsheetApp.openById(id).getSheetByName(FN.TAB_LOGS);
tab.appendRow([timestamp_(), job, status, String(message).slice(0, 1000)]);
}
function reportFailure_(job, error) {
const msg = error && error.stack ? error.stack : String(error);
try { log_(job, 'ERROR', msg); } catch (_) { /* best effort */ }
try {
MailApp.sendEmail({
to: config_().email,
subject: `${FN.PREFIX}[ERROR] ${job} ${day_(0)}`,
body: `自動集計に失敗しました。\n${msg}\n\nGASの実行ログを確認してください。`
});
} catch (_) { /* inspect GAS execution log */ }
console.error(msg);
}
function config_() {
const p = PropertiesService.getScriptProperties();
const ga4 = (p.getProperty('GA4_PROPERTY_ID') || '').trim();
const email = (p.getProperty('NOTIFY_EMAIL') || '').trim();
const gsc = (p.getProperty('GSC_SITE_URL') || FN.DEFAULT_GSC_SITE).trim();
if (!/^\d+$/.test(ga4))
throw new Error('Set numeric GA4_PROPERTY_ID in Script Properties. Do not use a G- measurement ID.');
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
throw new Error('Set NOTIFY_EMAIL in Script Properties.');
return { ga4, email, gsc };
}
function day_(offset) {
const base = Utilities.formatDate(new Date(), FN.TZ, 'yyyy-MM-dd').split('-').map(Number);
const shifted = new Date(Date.UTC(base[0], base[1] - 1, base[2] + offset));
return Utilities.formatDate(shifted, 'UTC', 'yyyy-MM-dd');
}
function shiftDate_(ymd, offset) {
const d = ymd.split('-').map(Number);
return Utilities.formatDate(new Date(Date.UTC(d[0], d[1] - 1, d[2] + offset)),
'UTC', 'yyyy-MM-dd');
}
function isMonday_() {
const d = day_(0).split('-').map(Number);
return new Date(Date.UTC(d[0], d[1] - 1, d[2])).getUTCDay() === 1;
}
function timestamp_() { return Utilities.formatDate(new Date(), FN.TZ, 'yyyy-MM-dd HH:mm:ss'); }
function num_(n) { return Number(n || 0).toLocaleString('ja-JP'); }
function dec_(n) { return Number(n || 0).toFixed(2); }
function pct_(rate) { return (Number(rate || 0) * 100).toFixed(1) + '%'; }
function pointDiff_(current, previous) {
const d = (current - previous) * 100;
return (d >= 0 ? '+' : '') + d.toFixed(2) + 'pt';
}
function rankDiff_(current, previous) {
const improvement = previous - current;
return Math.abs(improvement) < 0.01 ? '変化なし' :
Math.abs(improvement).toFixed(2) + '位' + (improvement > 0 ? '改善' : '悪化');
}
function change_(current, previous) {
if (previous === 0) return current === 0 ? '増減なし' : '比較不可(前期間0)';
const diff = (current / previous - 1) * 100;
return (diff >= 0 ? '+' : '') + diff.toFixed(1) + '%';
}