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

Search Console│異常を毎週自動検知する|複数サイト対応のSEO監視ツール

Search ConsoleとGASを連携して、指標を決めて監視するツールを作成しました。

GCPを使いますが、無料でツールを作れます。

ブログ運営をできるだけ自動化したいという場合、問題を洗い出すのに導入すると最低限の保守ができると思います。

複数サイト対応しています。

Google Apps Scriptで作成した複数サイト対応のSearch Console異常検知ツールの実行画面。

ここからは、無料パートで解説したツールを実際に自分の環境で動かすまでの手順を、ひとつずつ追っていきます。

GAS自体は書いたことがあっても、「GCPプロジェクトとの紐付け」で止まってしまう人はとても多いです。

そしてもうひとつ、見落とされがちな関門が appsscript.json でのスコープ宣言です。

GASの『サービス』追加だけでは UrlFetchApp でSearch Console APIを直叩きする構成は通りません。

ここを正しくやらないと、最後の最後で 403 が返ってきます。

この記事では、そのGCP連携を新規プロジェクトの作成から、つまずきポイントを名指ししながら進めます。

全体の流れはこうです。

  1. GCPプロジェクトを新規作成する
  2. Search Console APIを有効化する
  3. OAuth同意画面(Google Auth Platform)を構成する
  4. OAuthクライアント(認証情報)を作成する
  5. GASプロジェクトを作り、コードを貼る
  6. appsscript.json にOAuthスコープを宣言する
  7. GASとGCPプロジェクトを紐付ける
  8. スクリプトプロパティ(Slack情報)をセットする
  9. Slackアプリを用意してBotを招待する
  10. 手動実行して権限を承認する
  11. テスト通知で配線を確認する
  12. トリガーを登録して自動運転に乗せる

所要時間は、初めてGCPを触る場合で40〜50分ほど。

一度通してしまえば2サイト目以降は配列に1行足すだけです。

目次

著者

WEB制作をしているデジタルノマド
WordPressのカスタマイズが好きで、色々と自作しています。

WordPressのカスタマイズに困ったらご相談ください!

事前準備:サチコの権限を確認しておく

手順に入る前にひとつだけ。

このツールを動かすGoogleアカウントが、監視したいサイトのSearch Consoleプロパティに「オーナー」または「フルユーザー」として登録されている必要があります。

Search ConsoleでGoogleアカウントの権限がオーナーまたはフルユーザーになっているかを確認する画面。

「制限付きユーザー」では Search Analytics の一部や URL Inspection API が叩けません。

複数アカウントを使い分けている人は、どのアカウントでGASを動かすかを最初に決めておいてください。

そのうえで、そのアカウントの権限をサチコ側で確認しておきます。

ここがずれていると、後半で原因の分かりにくいエラーに悩まされます。

サチコの「設定 > ユーザーと権限」で、自分のアカウントの権限が確認できます。

ステップ1:GCPプロジェクトを新規作成する

まず Google Cloud Console(console.cloud.google.com)を開きます。

GASを使っているGoogleアカウントと同じアカウントでログインしてください。

ここが別アカウントだと、後で紐付けできません。

画面上部、Googleロゴの右隣にあるプロジェクト選択のプルダウン(現在のプロジェクト名が表示されている部分)をクリックします。

初めてなら「My First Project」などと出ているはずです。

開いたダイアログの右上「新しいプロジェクト」をクリック。

プロジェクト作成画面で次を入力します。

  • プロジェクト名:gsc-monitor など分かりやすい名前。
  • 後から変更できますが、プロジェクトID(名前の下に自動生成される gsc-monitor-xxxxxx のような文字列)は変更できないので、ここで多少こだわってもOKです。
  • 場所(組織):個人利用なら「組織なし」のままで問題ありません。

「作成」を押すと、画面右上に作成中の通知が出ます。

10〜20秒ほどで完了するので、完了したら必ず画面上部のプルダウンから、いま作ったプロジェクトに切り替えてください。

ここで切り替え忘れたまま次のステップに進み、別のプロジェクトでAPIを有効化してしまう──これが最初のつまずきポイントです。

プルダウンに表示されているプロジェクト名が、今作ったものになっているか確認します。

後で使うので、プロジェクト番号を控えておきます。

Cloud Consoleの「ダッシュボード」または「プロジェクト設定」に「プロジェクト番号」(12桁前後の数字)が表示されています。

プロジェクトID(文字列)とプロジェクト番号(数字)は別物で、GASとの紐付けに使うのは番号のほうです。

Google Cloud Consoleのダッシュボードまたはプロジェクト設定に表示されたプロジェクト番号の確認画面。

ステップ2:Search Console APIを有効化する

プロジェクトが選択された状態で、左上のハンバーガーメニュー(≡)から「APIとサービス > ライブラリ」を開きます。

または検索バーに「ライブラリ」と入れても辿り着けます。

APIライブラリの検索ボックスに「Search Console」と入力します。

検索結果に「Google Search Console API」が出てきます。

https://console.cloud.google.com/marketplace/product/google/searchconsole.googleapis.com

これをクリックして、詳細ページの「有効にする」ボタンを押します。

APIライブラリの詳細ページでGoogle Search Console APIを有効にするボタンの位置。

ここで注意。

検索結果に似た名前のものが複数出ることがありますが、有効化するのは「Google Search Console API」です。

「Search Console」関連で別のものを有効にしても、このツールは動きません。

名前を正確に確認してください。

「有効にする」を押すと数秒で完了し、APIの管理画面に切り替わります。

これでこのGCPプロジェクトはSearch Console APIを叩けるようになりました。

Search Console APIの有効化が完了した後のAPI管理画面。

ステップ3:OAuth同意画面(Google Auth Platform)を構成する

ここが「GCPが不安」な人にとっての本丸です。

順を追えば難しくありませんが、項目が多いので落ち着いて進めます。

Google Cloud ConsoleでOAuth同意画面の設定を開始する画面。

このツールは、スクリプトを実行するあなた自身の権限でAPIを叩きます。

そのために「このプロジェクトはどんなアプリで、誰が使うのか」をGoogleに登録する作業がOAuth同意画面の構成です。

現在のGoogle Cloud Consoleでは、この設定が「Google Auth Platform」という画面に集約され、左メニューが「概要/ブランディング/対象(Audience)/クライアント/データアクセス」という構成に変わっています。

昔の「OAuth同意画面」を1ページで設定していた頃とは見た目が違いますが、やることの本質は同じです。

「Google Auth Platform」を開く

左メニューから「APIとサービス > OAuth同意画面」を開きます。

環境によっては「Google Auth Platform」という名前で直接表示されます。

まだ未構成の場合は「Google Auth Platform はまだ構成されていません」と出るので、「スタートガイド(始める)」をクリックします。

このスタートガイドが「アプリ情報 → 対象 → OAuthクライアント → 連絡先情報」という流れのウィザードになっています。

Google Auth Platformのスタートガイドでアプリ情報などを設定するウィザード画面。

ブランディング(アプリ情報)を入力する

まずアプリの基本情報(ブランディング)を入れます。

  • アプリ名:GSC異常監視 など。これは認証時に「○○がGoogleアカウントへのアクセスを求めています」と表示される名前です。自分しか使わないので分かれば何でも構いません。
  • ユーザーサポートメール:自分のメールアドレスを選択。
  • デベロッパーの連絡先情報:同じく自分のメールアドレス。

ロゴやアプリのドメインなどは任意なので空欄のままで問題ありません。

自分だけが使う内部ツールなので、ここを作り込む必要はありません。

対象(Audience):User Type を選ぶ

次に「対象(Audience)」で「User Type(ユーザーの種類)」を選びます。

  • 外部(External):個人のGoogleアカウントで運用するならこちらを選びます。Google Workspaceの組織アカウントでも、組織外を含む構成にするならこちら。
  • 内部(Internal):Google Workspaceを使っていて、組織内のアカウントだけで完結させる場合に選べます。選べるなら同意画面まわりがシンプルになります。

個人利用なら基本は「外部」でOKです。

対象(Audience):テストユーザーに自分を追加する

User Typeで「外部」を選んだ場合、アプリは最初「テスト」という公開ステータスになります。

テストステータスのアプリは、登録した「テストユーザー」しか認証を通せません。

「対象」画面の下のほうにある「テストユーザー」で「Add users(ユーザーを追加)」を押します。

そこにこのツールを動かすGoogleアカウントのメールアドレス(=あなた自身)を追加します。

対象画面のテストユーザー項目でツールを実行するGoogleアカウントを追加する手順。

ここを忘れると、後でGASを実行したときに認証が止まります。

具体的には、こういう画面が出て先に進めません。

テストユーザー未追加時に表示される、アクセスがブロックされた旨のエラー画面。

「アクセスをブロック: ○○ は Google の審査プロセスを完了していません」「エラー 403: access_denied」と出たら、原因はほぼこれです。

「このアプリは現在テスト中で、デベロッパーに承認されたテスターのみがアクセスできます」というのは、承認しようとしているアカウントがテストユーザーに入っていないという意味です。

複数アカウントを使っている人が、別アカウントで承認しようとして詰まるのも同じ原因です。

自分自身をテストユーザーに入れる──これを必ずやってください。

補足:「公開」する必要はありません。

自分だけが使うツールなので、アプリのステータスは「テスト」のままで問題ありません。

「アプリを公開」ボタンを押すとGoogleの審査対象になり得るので、押さないでください。

テストユーザーに自分を入れてあれば、テストステータスのまま運用できます。

データアクセス(スコープ)は、ここでは触らなくてよい

「データアクセス」または「スコープ」の画面が出たら、ここは何も追加せずに進めて構いません。

意外に思うかもしれませんが、このツールが使うスコープ(アクセス権限の範囲)は、同意画面側ではなく後述する appsscript.json の側で宣言します。

そして実際の同意は、GASを初めて実行するときに「このスクリプトはこういう権限が必要です」という形でまとめて行います。

ここで何か追加しようとして手が止まりがちですが、同意画面側のスコープはスルーで正解です。

「スコープは appsscript.json で宣言する」──この一点だけ、頭の片隅に置いて先へ進んでください。

ステップ4:OAuthクライアント(認証情報)を作成する

新しいGoogle Auth Platformでは、同意画面の構成を完了させるためにOAuthクライアント(認証情報)の作成が求められます。

スタートガイドのウィザードを進めていると、「OAuthクライアント」のステップが出てきます。

あるいは左メニューの「クライアント」から「クライアントを作成」で作れます。

「アプリケーションの種類」を聞かれたら、個人ツールなら「ウェブ アプリケーション」または「デスクトップ」のどちらかを選んでおけば構成は完了します。

名前は gsc-monitor-client など分かるものでOKです。

「作成」を押すと、クライアントIDとクライアントシークレットが発行されます。

ここがポイント:このツールは、いま作ったクライアントIDをコードに貼り付けて使うわけではありません。

実際の認証は、後で「GASプロジェクト ↔ GCPプロジェクト」を番号で紐付け、ScriptApp.getOAuthToken() と appsscript.json のスコープ宣言を通して行われます。

クライアント作成は、あくまで「同意画面(Auth Platform)の構成を完了させるための一手順」と捉えておけば大丈夫です。

クライアントシークレットは作成時にしか表示されない仕様ですが、このツールではコードに埋め込まないので、控え損ねても問題ありません。

これで「同意画面の構成」と「認証情報の作成」が完了です。

ステップ5:GASプロジェクトを作り、コードを貼る

ここからGAS側です。

script.google.com を開きます。

GCPと同じGoogleアカウントであることを確認して、「新しいプロジェクト」をクリックします。

エディタが開いたら、最初からある myFunction のひな形を含めて中身を全部削除します。

そこに、無料パートで配布したコード(コード.gs)を丸ごと貼り付けます。

貼り終えたら、左上のプロジェクト名(「無題のプロジェクト」)をクリックして、GSC異常監視 など分かりやすい名前に変更しておきます。

フロッピーディスクのアイコン、または Ctrl + S(Macは Cmd + S)で保存します。

この時点ではまだ実行しません。

先にスコープ宣言・GCPとの紐付け・設定の記入を済ませます。

SITES配列を自分のサイトに書き換える

コード上部の SITES 配列を、自分の監視したいサイトに書き換えます。

const SITES = [
  {
    name: 'サイトA',
    property: 'sc-domain:example.com',
    sampleUrls: ['https://example.com/'],
  },
];

property はサチコの「プロパティ」文字列を登録されている通りに入れます。

  • ドメインプロパティなら sc-domain:example.com
  • URLプレフィックス型なら https://example.com/(末尾スラッシュ必須)

自分がどちらのタイプで登録しているかは、サチコの画面左上のプロパティ選択で確認できます。

ここの表記がサチコの登録と1文字でも違うとデータが取得できないので、コピペで正確に入れてください。

ステップ6:appsscript.json にOAuthスコープを宣言する

この記事で一番ハマりやすい、隠れた必須ステップです。

結論から言うと、appsscript.json に必要なスコープを手で書かないと、コードが正しくても 403 で落ちます。

理由を簡単に説明します。

GASは普段、コードを読んで「このスクリプトにはどんな権限(スコープ)が必要か」を自動で判断してくれます。

ところが今回は、Search Console APIを UrlFetchApp で直接叩いています。

GASから見えるのは「外部にHTTPリクエストを送っている」ことだけで、その送り先がSearch Console APIだとまでは判断できません。

そのためSearch Consoleを読む権限(webmasters.readonly)が自動では付与されず、ScriptApp.getOAuthToken() が返すトークンにもその権限が含まれません。

結果、権限の足りないトークンでAPIを叩くことになり、403 が返ってくる、というわけです。

これを防ぐために、appsscript.json に必要なスコープを自分で宣言しておきます。

appsscript.json を表示する

appsscript.json(マニフェストファイル)は、初期状態ではエディタに表示されていません。

GASエディタ左メニューの歯車アイコン「プロジェクトの設定」を開きます。

「「appsscript.json」マニフェスト ファイルをエディタで表示する」にチェックを入れます。

エディタに戻ると、左のファイル一覧に appsscript.json が現れます。

スコープを書き込む

appsscript.json を開き、中身を次の内容に差し替えます。

{
  "timeZone": "Asia/Tokyo",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/webmasters.readonly",
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/spreadsheets"
  ]
}

各スコープの意味は次のとおりです。

  • webmasters.readonly:Search Console(サチコ)のデータを読み取るための権限。これが無いと 403 になります。
  • script.external_request:UrlFetchApp で外部に通信するための権限。Search Console APIの直叩きにも、Slackへの通知にも必要です。
  • spreadsheets:スプレッドシートを読み書きする権限。このツール単体では使いませんが、後でログをシートに残すなど拡張する余地を見て入れてあります。不要なら1行削除してOKです。

書き換えたら保存します。

このスコープ宣言が、後のステップ10「権限承認」の画面に出てくる権限一覧の元になります。

つまずきポイント:すでに一度コードを実行して権限承認まで済ませたあとで oauthScopes を足した場合、追加したスコープは「再承認」しないと有効になりません。

スコープを足したのに 403 が消えないときは、もう一度 checkNow を手動実行して、新しい権限の承認ダイアログを通してください。

ステップ7:GASとGCPプロジェクトを紐付ける

ここを飛ばすと、コードが正しくてもAPIが通りません。

GASエディタの左メニューから、歯車アイコンの「プロジェクトの設定」を開きます。

下のほうにスクロールすると「Google Cloud Platform(GCP)プロジェクト」というセクションがあります。

初期状態では「デフォルトのプロジェクト」が紐付いています。

このデフォルトプロジェクトでは自分でAPIを有効化したりできないため、ステップ1で作ったプロジェクトに切り替えます。

「プロジェクトを変更」ボタンを押します。

「GCPプロジェクト番号」の入力欄が出るので、ステップ1で控えておいたプロジェクト番号(12桁前後の数字)を入力します。

プロジェクトID(文字列)ではなく番号(数字)です。

ここを取り違える人が多いので注意。

「プロジェクトを設定」を押して、エラーが出なければ紐付け完了です。

うまくいかないとき:「番号が見つからない」「権限がない」と言われる場合、たいていは①番号の打ち間違い、②GASとGCPでログインアカウントが違う、③そのアカウントがGCPプロジェクトの権限を持っていない、のどれかです。

ステップ1〜2を同じアカウントでやったか、もう一度確認してください。

これで「GASプロジェクト」と「Search Console APIを有効化したGCPプロジェクト」が結びつきました。

GASがAPIを叩いたときに、ステップ2で有効化したAPIと、ステップ3で構成した同意画面、ステップ6で宣言したスコープが使われるようになります。

ステップ8:スクリプトプロパティをセットする

次に、Slack通知に必要な情報をGASに登録します。

コードに直接書かず、スクリプトプロパティに保存するのがセオリーです(コードを誰かに共有してもトークンが漏れません)。

GASエディタの「プロジェクトの設定」(ステップ7と同じ画面)を開き、一番下までスクロールすると「スクリプト プロパティ」のセクションがあります。

「スクリプト プロパティを追加」を押して、次の2つを登録します。

値は次のステップ9でSlackから取得するので、いったんこの画面の場所だけ確認して、ステップ9のあと戻ってきて入力しても構いません。

プロパティ値
SLACK_BOT_TOKENSlackアプリの Bot User OAuth Token(xoxb- で始まる文字列)
SLACK_CHANNEL通知先チャンネル(#seo-alert のような名前、またはチャンネルID C0XXXXXXX)

プロパティ名(左側)はスペルを正確に。

コードはこの名前でプロパティを読みに行くので、SLACK_BOT_TOKEN が SLACK_TOKEN になっているだけで動きません。

入力したら「スクリプト プロパティを保存」を押します。

GASのスクリプトプロパティを入力して保存する画面。

ステップ9:Slackアプリを用意してBotを招待する

ステップ8で必要になるSlackの2つの値を取得します。

Slackアプリをまだ持っていない前提で進めます。

Slackアプリを作る

api.slack.com/apps を開き、「Create New App」をクリックします。

「From scratch」を選び、アプリ名(GSC Monitor など)と、通知を送りたいワークスペースを選択して「Create App」。

chat:write 権限を付与する

アプリの管理画面左メニューから「OAuth & Permissions」を開きます。

下にスクロールして「Scopes」セクションの「Bot Token Scopes」で「Add an OAuth Scope」を押し、「chat:write」を追加します。

これがメッセージ投稿に必要な最小限の権限です。

ワークスペースにインストールしてトークンを取得

同じ「OAuth & Permissions」画面の上部に戻り、「Install to Workspace」(または Install to ~)を押します。

確認画面で「許可する」。

インストールが完了すると、「Bot User OAuth Token」として xoxb- で始まる文字列が表示されます。

これが SLACK_BOT_TOKEN の値です。

コピーして、ステップ8のスクリプトプロパティに貼り付けます。

Botを通知先チャンネルに招待する

最後に、Slack本体(アプリ)で通知を送りたいチャンネルを開き、メッセージ欄に次のように打ち込みます。

/invite @GSC Monitor

(@ のあとはステップ9で付けたアプリ名)

これでBotがチャンネルに参加します。

この招待を忘れると、トークンが正しくてもメッセージが投稿できません(not_in_channel というエラーになります)。

SLACK_CHANNEL の値には、このチャンネル名を # 込みで(例:#seo-alert)入れます。

チャンネルIDでも構いません(チャンネル名をクリックして開く詳細の一番下にあります)。

ここまでで、ステップ8のスクリプトプロパティ2つが両方とも埋まっているはずです。

埋まっているか確認してください。

実際のGAS

2つのスクリプトプロパティが設定されていることを確認する画面。

以下、実際のコードです。


/**
   * ============================================================
   *  GSC 異常監視ツール  (Google Apps Script)
   * ============================================================
 *  毎週月曜8時に複数サイトの Search Console を分析し、
 *  リライト候補・検索流入の落ち込み・インデックス要確認URLを Slack に通知します。
 *  (候補がゼロの日は通知しません)
 *
 *  検知する項目:
 *   1. クリック数の急落(前週同期間比)
 *   2. 表示回数の急落(前週同期間比)
 *   3. 平均掲載順位の悪化
 *   4. ページ×クエリ別のリライト候補(表示多い / CTR低い / 8〜20位)
 *   5. ページ別の流入落ち込み
 *   6. 順位急落・新規クエリ・消失クエリ
 *   7. モバイルだけ順位が弱いページ
 *   8. 代表URL・落ち込みURLのインデックス状態(URL Inspection API)
 *
 *  ※ Sitemaps APIの contents[].indexed は非推奨で0になりやすいため、
 *     インデックス数の一括判定には使いません。
 *
 * ------------------------------------------------------------
 *  ■ セットアップ手順
 * ------------------------------------------------------------
 *  1. script.google.com で新規プロジェクトを作成
 *  2. このコードを丸ごと貼り付け
 *  3. GCPプロジェクトで「Search Console API」を有効化し、
 *     Apps Scriptのプロジェクト設定でGCPプロジェクト番号を紐付け
 *     (※過去にやった手順と同じ。サービス追加は不要)
 *  4. 下の SITES 配列に監視したいサイトを登録
 *  5. スクリプトプロパティに以下の2つを登録(プロジェクトの設定 > スクリプト プロパティ)
 *       SLACK_BOT_TOKEN : Slackアプリの Bot User OAuth Token(xoxb- で始まる)
 *       SLACK_CHANNEL   : 通知先チャンネル(例: #seo-alert または チャンネルID C0XXXX)
 *     ※ Slackアプリには chat:write 権限を付与し、対象チャンネルにBotを招待しておくこと
 *  6. checkNow を一度手動実行して動作確認&権限承認
 *  7. setupTrigger を一度実行 → 毎週月曜8時の自動実行が登録される
 *
 *  ※ このスクリプトを動かすGoogleアカウントが、各サイトの
 *    Search Consoleプロパティに「オーナー」または「フルユーザー」
 *    として登録されている必要があります。
 * ============================================================
 */


// ============================================================
//  設定エリア(ここだけ編集すればOK)
// ============================================================

/**
 * 監視対象サイトの配列。サイトを増やすときはここに追加するだけ。
 *
 *  name       : 任意の表示名(Slack通知に出る)
 *  property   : Search Console の「プロパティ」文字列
 *             - ドメインプロパティの場合:  'sc-domain:example.com'
 *             - URLプレフィックス型の場合:  'https://example.com/'  (末尾スラッシュ必須・登録通りに)
 *  sampleUrls : URL Inspection APIで毎日確認する代表URL。未指定ならトップページを自動推定。
 */
const SITES = [
  {
    name: 'モトキデザイン',
    property: 'sc-domain:example.co.jp',
    sampleUrls: ['https://example.co.jp/'],
  },
  {
    name: 'CGメソッド',
    property: 'sc-domain:example.com',
    sampleUrls: ['https://example.com/'],
  },
  // { name: 'サイト3', property: 'https://example.com/', sampleUrls: ['https://example.com/'] },
];

/**
 * Slackの認証情報はスクリプトプロパティから読み込みます。
 *   SLACK_BOT_TOKEN : xoxb- で始まる Bot User OAuth Token
 *   SLACK_CHANNEL   : 通知先チャンネル(#名前 でも チャンネルID でも可)
 * ↑ コードに直書きせず、プロジェクトの「スクリプト プロパティ」に登録すること。
 */

/**
 * 異常判定のしきい値。運用しながら調整してください。
 */
const THRESHOLDS = {
  siteClickDropPct:        30,   // サイト全体のクリック数が前週比 -30% を超えて減ったら通知
  siteImpressionDropPct:   30,   // サイト全体の表示回数が前週比 -30% を超えて減ったら通知
  positionWorsen:          3.0,  // サイト全体の平均掲載順位が前週比で +3.0 以上悪化したら通知
  minClicksToAlert:        20,   // 元のクリック数がこれ未満のサイトは全体急落判定をスキップ
  minImpressionsToAlert:   200,  // 元の表示回数がこれ未満のサイトは全体急落判定をスキップ

  minRewriteImpressions:   300,  // リライト候補に出す最低表示回数
  minNearWinImpressions:   500,  // 4〜20位の伸びしろ候補に出す最低表示回数
  minPagePrevClicks:       10,   // 落ち込み判定に使う前週クリック数
  minPagePrevImpressions:  300,  // 落ち込み判定に使う前週表示回数
  pageDropPct:             40,   // ページ単位でクリック/表示が -40% 以上なら落ち込み候補
  disappearedDropPct:      90,   // 表示が -90% 以上ならインデックス検査対象に優先追加
  minQueryImpressions:     80,   // クエリ単位の分析対象にする最低表示回数
  rankDropPositions:       5.0,  // クエリ順位がこれ以上悪化したら通知候補
  mobilePositionGap:       5.0,  // モバイル順位がPCよりこれ以上悪ければ通知候補
};

const ANALYSIS_OPTIONS = {
  searchType: 'web',
  countryFilter: 'jpn',            // 日本に絞る。全世界を見るなら空文字にする。
  analyticsPageSize: 25000,        // Search Analytics APIの1回あたり最大行数
  pageMaxRows: 50000,              // ページ別データ取得上限
  queryMaxRows: 50000,             // ページ×クエリ取得上限
  deviceMaxRows: 25000,            // ページ×デバイス取得上限
  rewriteLimit: 5,                 // Slackに出すリライト候補数
  declineLimit: 5,                 // Slackに出す落ち込みページ数
  rankDropLimit: 5,                // Slackに出す順位急落クエリ数
  queryDiffLimit: 5,               // Slackに出す新規/消失クエリ数
  deviceGapLimit: 5,               // Slackに出すデバイス差分ページ数
  indexAlertLimit: 5,              // Slackに出すインデックス要確認URL数
  maxUrlInspectionsPerSite: 8,     // URL Inspection APIの1サイトあたり検査数
  inspectIndexTargets: true,
  checkSitemaps: false,            // サイトマップエラー監視が必要ならtrue
};


// ============================================================
//  メイン処理
// ============================================================

/**
 * 全サイトをチェックして、異常があればSlack通知する。
 * トリガーから毎週呼ばれるのはこの関数。
 */
function checkNow() {
  const allAlerts = [];   // { siteName, issues[] } の配列
  const errors = [];      // 取得自体に失敗したサイトのメモ
  const noCandidates = []; // チェック済みだが通知候補がなかったサイト

  SITES.forEach(function (site) {
    try {
      const issues = inspectSite_(site);
      if (issues.length > 0) {
        allAlerts.push({ siteName: site.name, issues: issues });
      } else {
        noCandidates.push(site.name);
      }
    } catch (e) {
      errors.push(site.name + ': ' + e.message);
      Logger.log('【エラー】' + site.name + ' / ' + e.message);
    }
  });

  if (allAlerts.length === 0 && errors.length === 0) {
    Logger.log('分析候補なし。通知は送信しません。チェック対象: ' + SITES.length
      + ' / 候補なし: ' + noCandidates.join(', '));
    return;
  }

  postSlack_(buildSlackMessage_(allAlerts, errors));
  Logger.log('Slackに通知しました。チェック対象: ' + SITES.length
    + ' / 通知対象: ' + allAlerts.length
    + ' / 候補なし: ' + (noCandidates.length > 0 ? noCandidates.join(', ') : 'なし')
    + ' / 取得失敗: ' + errors.length);
}


/**
 * 1サイトを検査し、見つかった異常の配列を返す。
 * 異常がなければ空配列。
 */
function inspectSite_(site) {
  const issues = [];

  // --- 1) サイト全体の前週比チェック ---
  const periods = getComparePeriods_();
  const current  = fetchPerformanceTotals_(site.property, periods.thisStart, periods.thisEnd);
  const previous = fetchPerformanceTotals_(site.property, periods.prevStart, periods.prevEnd);

  // クリック数の急落
  if (previous.clicks >= THRESHOLDS.minClicksToAlert) {
    const drop = pctChange_(previous.clicks, current.clicks);
    if (drop <= -THRESHOLDS.siteClickDropPct) {
      issues.push('? サイト全体のクリック数が急落: '
        + previous.clicks + ' → ' + current.clicks
        + ' (' + drop + '%)');
    }
  }

  // 表示回数の急落
  if (previous.impressions >= THRESHOLDS.minImpressionsToAlert) {
    const drop = pctChange_(previous.impressions, current.impressions);
    if (drop <= -THRESHOLDS.siteImpressionDropPct) {
      issues.push('? サイト全体の表示回数が急落: '
        + previous.impressions + ' → ' + current.impressions
        + ' (' + drop + '%)');
    }
  }

  // 平均掲載順位の悪化(数値が大きい=順位が下=悪化)
  if (current.position > 0 && previous.position > 0) {
    const worsen = current.position - previous.position;
    if (worsen >= THRESHOLDS.positionWorsen) {
      issues.push('? 平均掲載順位が悪化: '
        + previous.position.toFixed(1) + '位 → ' + current.position.toFixed(1) + '位'
        + ' (' + (worsen > 0 ? '+' : '') + worsen.toFixed(1) + ')');
    }
  }

  // --- 2) ページ別のGSCデータからリライト候補と落ち込みを分析 ---
  const currentPages = fetchPagePerformance_(site.property, periods.thisStart, periods.thisEnd);
  const previousPages = fetchPagePerformance_(site.property, periods.prevStart, periods.prevEnd);
  const currentPageQueries = fetchPageQueryPerformance_(site.property, periods.thisStart, periods.thisEnd);
  const previousPageQueries = fetchPageQueryPerformance_(site.property, periods.prevStart, periods.prevEnd);
  const topQueryByPage = mergeTopQueryMaps_(
    getTopQueryByPage_(currentPageQueries),
    getTopQueryByPage_(previousPageQueries)
  );
  const pageAnalysis = analyzePages_(currentPages, previousPages, topQueryByPage);
  const queryAnalysis = analyzeQueries_(currentPageQueries, previousPageQueries);
  const currentPageDevices = fetchPageDevicePerformance_(site.property, periods.thisStart, periods.thisEnd);
  const deviceGaps = analyzeDeviceGaps_(currentPageDevices);

  if (queryAnalysis.rewriteCandidates.length > 0) {
    issues.push(formatRewriteCandidates_(queryAnalysis.rewriteCandidates));
  }
  if (pageAnalysis.decliningPages.length > 0) {
    issues.push(formatDecliningPages_(pageAnalysis.decliningPages));
  }
  if (queryAnalysis.rankDrops.length > 0) {
    issues.push(formatRankDrops_(queryAnalysis.rankDrops));
  }
  if (queryAnalysis.newQueries.length > 0 || queryAnalysis.lostQueries.length > 0) {
    issues.push(formatQueryDiffs_(queryAnalysis.newQueries, queryAnalysis.lostQueries));
  }
  if (deviceGaps.length > 0) {
    issues.push(formatDeviceGaps_(deviceGaps));
  }

  // --- 3) インデックス状態がおかしそうなURLをサンプル検査 ---
  let inspectionTargets = [];
  let indexIssues = [];
  if (ANALYSIS_OPTIONS.inspectIndexTargets) {
    inspectionTargets = buildInspectionTargets_(site, currentPages, previousPages, pageAnalysis, queryAnalysis);
    indexIssues = inspectIndexTargets_(site.property, inspectionTargets);
    if (indexIssues.length > 0) {
      issues.push(formatIndexIssues_(indexIssues));
    }
  }

  // --- 4) 必要な時だけサイトマップエラーも見る(通常はオフ) ---
  if (ANALYSIS_OPTIONS.checkSitemaps) {
    const sitemapIssues = inspectSitemaps_(site.property);
    if (sitemapIssues.length > 0) {
      issues.push('?️ サイトマップ要確認\n      ' + sitemapIssues.join('\n      '));
    }
  }

  logSiteAnalysisSummary_(site, periods, current, previous, {
    currentPages: currentPages,
    previousPages: previousPages,
    currentPageQueries: currentPageQueries,
    previousPageQueries: previousPageQueries,
    currentPageDevices: currentPageDevices,
    pageAnalysis: pageAnalysis,
    queryAnalysis: queryAnalysis,
    deviceGaps: deviceGaps,
    inspectionTargets: inspectionTargets,
    indexIssues: indexIssues,
    issueCount: issues.length,
  });

  return issues;
}


// ============================================================
//  Search Console API 呼び出し(UrlFetchApp で直接)
// ============================================================

const SC_API_BASE = 'https://www.googleapis.com/webmasters/v3';
const URL_INSPECTION_API = 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect';

/**
 * 指定期間の検索パフォーマンス合計(クリック・表示・順位)を取得。
 */
function fetchPerformanceTotals_(property, startDate, endDate) {
  const url = SC_API_BASE + '/sites/'
    + encodeURIComponent(property) + '/searchAnalytics/query';

  const payload = {
    startDate: startDate,
    endDate: endDate,
    dimensions: [],          // 集計なし=全体合計が1行で返る
    dataState: 'final',
    type: ANALYSIS_OPTIONS.searchType,
    rowLimit: 1,
  };
  const countryFilter = buildCountryFilter_();
  if (countryFilter) {
    payload.dimensionFilterGroups = [countryFilter];
  }

  const res = UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });

  const code = res.getResponseCode();
  if (code !== 200) {
    throw new Error('検索パフォーマンス取得失敗 (HTTP ' + code + '): '
      + extractApiErrorMessage_(res));
  }

  const rows = (JSON.parse(res.getContentText()).rows) || [];
  if (rows.length === 0) {
    return { clicks: 0, impressions: 0, ctr: 0, position: 0 };
  }
  const r = rows[0];
  return {
    clicks:      r.clicks      || 0,
    impressions: r.impressions || 0,
    ctr:         r.ctr         || 0,
    position:    r.position    || 0,
  };
}

/**
 * ページ別の検索パフォーマンスを取得する。
 */
function fetchPagePerformance_(property, startDate, endDate) {
  return fetchSearchAnalyticsRows_(
    property,
    startDate,
    endDate,
    ['page'],
    ANALYSIS_OPTIONS.pageMaxRows
  ).map(function (row) {
    return toPageMetric_(row.keys[0], row);
  }).filter(function (row) {
    return row.page;
  });
}

/**
 * ページ×クエリの検索パフォーマンスを取得する。
 */
function fetchPageQueryPerformance_(property, startDate, endDate) {
  return fetchSearchAnalyticsRows_(
    property,
    startDate,
    endDate,
    ['page', 'query'],
    ANALYSIS_OPTIONS.queryMaxRows
  ).map(function (row) {
    return toPageQueryMetric_(row.keys[0], row.keys[1], row);
  }).filter(function (row) {
    return row.page && row.query;
  });
}

/**
 * ページ×デバイスの検索パフォーマンスを取得する。
 */
function fetchPageDevicePerformance_(property, startDate, endDate) {
  return fetchSearchAnalyticsRows_(
    property,
    startDate,
    endDate,
    ['page', 'device'],
    ANALYSIS_OPTIONS.deviceMaxRows
  ).map(function (row) {
    return {
      page: row.keys[0] || '',
      device: row.keys[1] || '',
      clicks: row.clicks,
      impressions: row.impressions,
      ctr: row.ctr,
      position: row.position,
    };
  }).filter(function (row) {
    return row.page && row.device;
  });
}

/**
 * ページごとの代表クエリを抽出する。
 */
function getTopQueryByPage_(rows) {
  const byPage = {};
  rows.forEach(function (row) {
    const positionBoost = Math.max(1, 25 - Math.min(Number(row.position || 25), 25));
    const score = Number(row.impressions || 0) * positionBoost;
    if (!byPage[row.page] || score > byPage[row.page].score) {
      byPage[row.page] = {
        query: row.query,
        clicks: row.clicks,
        impressions: row.impressions,
        ctr: row.ctr,
        position: row.position,
        score: score,
      };
    }
  });

  return byPage;
}

function mergeTopQueryMaps_(primary, fallback) {
  const merged = {};
  Object.keys(fallback || {}).forEach(function (page) {
    merged[page] = fallback[page];
  });
  Object.keys(primary || {}).forEach(function (page) {
    merged[page] = primary[page];
  });
  return merged;
}

/**
 * Search Analytics APIの汎用呼び出し。
 */
function fetchSearchAnalyticsRows_(property, startDate, endDate, dimensions, rowLimit) {
  const url = SC_API_BASE + '/sites/'
    + encodeURIComponent(property) + '/searchAnalytics/query';
  const rows = [];
  const maxRows = rowLimit || ANALYSIS_OPTIONS.analyticsPageSize;
  const pageSize = Math.min(ANALYSIS_OPTIONS.analyticsPageSize, maxRows);

  for (let startRow = 0; startRow < maxRows; startRow += pageSize) {
    const payload = {
      startDate: startDate,
      endDate: endDate,
      dimensions: dimensions,
      dataState: 'final',
      type: ANALYSIS_OPTIONS.searchType,
      rowLimit: pageSize,
      startRow: startRow,
    };
    const countryFilter = buildCountryFilter_();
    if (countryFilter) {
      payload.dimensionFilterGroups = [countryFilter];
    }

    const res = UrlFetchApp.fetch(url, {
      method: 'post',
      contentType: 'application/json',
      headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() },
      payload: JSON.stringify(payload),
      muteHttpExceptions: true,
    });

    const code = res.getResponseCode();
    if (code !== 200) {
      throw new Error('Search Analytics取得失敗 (HTTP ' + code + '): '
        + extractApiErrorMessage_(res));
    }

    const pageRows = (JSON.parse(res.getContentText()).rows || []).map(function (r) {
      return {
        keys: r.keys || [],
        clicks: Number(r.clicks || 0),
        impressions: Number(r.impressions || 0),
        ctr: Number(r.ctr || 0),
        position: Number(r.position || 0),
      };
    });

    rows.push.apply(rows, pageRows);
    if (pageRows.length < pageSize) break;
  }

  return rows;
}

function buildCountryFilter_() {
  if (!ANALYSIS_OPTIONS.countryFilter) return null;
  return {
    filters: [{
      dimension: 'country',
      operator: 'equals',
      expression: ANALYSIS_OPTIONS.countryFilter,
    }],
  };
}

/**
 * サイトに登録されたサイトマップ一覧を取得。
 * 各要素: { path, submitted, errors, warnings }
 */
function fetchSitemaps_(property) {
  const url = SC_API_BASE + '/sites/'
    + encodeURIComponent(property) + '/sitemaps';

  const res = UrlFetchApp.fetch(url, {
    method: 'get',
    headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() },
    muteHttpExceptions: true,
  });

  const code = res.getResponseCode();
  if (code !== 200) {
    throw new Error('サイトマップ取得失敗 (HTTP ' + code + '): '
      + extractApiErrorMessage_(res));
  }

  const sitemaps = (JSON.parse(res.getContentText()).sitemap) || [];
  return sitemaps.map(function (sm) {
    let submitted = 0;
    (sm.contents || []).forEach(function (c) {
      submitted += Number(c.submitted || 0);
    });
    return {
      path:      sm.path,
      submitted: submitted,
      errors:    Number(sm.errors   || 0),
      warnings:  Number(sm.warnings || 0),
    };
  });
}

/**
 * URL Inspection APIで代表URLのインデックス状態を確認する。
 */
function fetchUrlInspection_(property, inspectionUrl) {
  const res = UrlFetchApp.fetch(URL_INSPECTION_API, {
    method: 'post',
    contentType: 'application/json',
    headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() },
    payload: JSON.stringify({
      inspectionUrl: inspectionUrl,
      siteUrl: property,
      languageCode: 'ja-JP',
    }),
    muteHttpExceptions: true,
  });

  const code = res.getResponseCode();
  if (code !== 200) {
    throw new Error('URL Inspection取得失敗 (HTTP ' + code + '): '
      + extractApiErrorMessage_(res));
  }

  const body = JSON.parse(res.getContentText() || '{}');
  const indexStatus = (((body.inspectionResult || {}).indexStatusResult) || {});
  return {
    url: inspectionUrl,
    verdict: indexStatus.verdict || '',
    coverageState: indexStatus.coverageState || '',
    robotsTxtState: indexStatus.robotsTxtState || '',
    indexingState: indexStatus.indexingState || '',
    pageFetchState: indexStatus.pageFetchState || '',
    lastCrawlTime: indexStatus.lastCrawlTime || '',
    googleCanonical: indexStatus.googleCanonical || '',
    userCanonical: indexStatus.userCanonical || '',
  };
}

function inspectSitemaps_(property) {
  const issues = [];
  fetchSitemaps_(property).forEach(function (sm) {
    if (Number(sm.errors) > 0) {
      issues.push('エラー: ' + sm.path + '(' + sm.errors + '件)');
    }
    if (Number(sm.warnings) > 0) {
      issues.push('警告: ' + sm.path + '(' + sm.warnings + '件)');
    }
  });
  return issues;
}


// ============================================================
//  期間・計算ユーティリティ
// ============================================================

/**
 * 比較する2つの期間を返す。
 * GSCのデータは数日遅れるため、3日前を基準に直近7日と
 * その前の7日を比較する。
 */
function getComparePeriods_() {
  const tz = Session.getScriptTimeZone();
  const fmt = function (d) { return Utilities.formatDate(d, tz, 'yyyy-MM-dd'); };
  const dayMs = 24 * 60 * 60 * 1000;

  const base = new Date();
  base.setTime(base.getTime() - 3 * dayMs);  // 3日前を末尾に

  const thisEnd   = new Date(base.getTime());
  const thisStart = new Date(base.getTime() - 6 * dayMs);
  const prevEnd   = new Date(base.getTime() - 7 * dayMs);
  const prevStart = new Date(base.getTime() - 13 * dayMs);

  return {
    thisStart: fmt(thisStart), thisEnd: fmt(thisEnd),
    prevStart: fmt(prevStart), prevEnd: fmt(prevEnd),
  };
}

/** 変化率(%)。oldVal→newVal。マイナスが減少。 */
function pctChange_(oldVal, newVal) {
  if (oldVal === 0) return 0;
  return Math.round(((newVal - oldVal) / oldVal) * 100);
}

function toPageMetric_(page, row) {
  return {
    page: page || '',
    clicks: Number(row.clicks || 0),
    impressions: Number(row.impressions || 0),
    ctr: Number(row.ctr || 0),
    position: Number(row.position || 0),
  };
}

function toPageQueryMetric_(page, query, row) {
  return {
    page: page || '',
    query: query || '',
    key: makePageQueryKey_(page, query),
    clicks: Number(row.clicks || 0),
    impressions: Number(row.impressions || 0),
    ctr: Number(row.ctr || 0),
    position: Number(row.position || 0),
  };
}

function emptyPageMetric_(page) {
  return { page: page || '', clicks: 0, impressions: 0, ctr: 0, position: 0 };
}

function emptyPageQueryMetric_(page, query) {
  return {
    page: page || '',
    query: query || '',
    key: makePageQueryKey_(page, query),
    clicks: 0,
    impressions: 0,
    ctr: 0,
    position: 0,
  };
}

function mapByPage_(rows) {
  const map = {};
  rows.forEach(function (row) {
    if (row.page) map[row.page] = row;
  });
  return map;
}

function mapByPageQuery_(rows) {
  const map = {};
  rows.forEach(function (row) {
    if (row.key) map[row.key] = row;
  });
  return map;
}

function makePageQueryKey_(page, query) {
  return String(page || '') + '\n' + String(query || '');
}

/**
 * ページ単位のリライト候補と落ち込みページを抽出する。
 */
function analyzePages_(currentPages, previousPages, topQueryByPage) {
  const previousByPage = mapByPage_(previousPages);
  const currentByPage = mapByPage_(currentPages);
  const declineByPage = {};

  currentPages.forEach(function (cur) {
    const prev = previousByPage[cur.page] || emptyPageMetric_(cur.page);
    addDeclineCandidate_(declineByPage, cur, prev, topQueryByPage[cur.page] || null);
  });

  previousPages.forEach(function (prev) {
    const cur = currentByPage[prev.page] || emptyPageMetric_(prev.page);
    addDeclineCandidate_(declineByPage, cur, prev, topQueryByPage[prev.page] || null);
  });

  const decliningPages = Object.keys(declineByPage).map(function (page) {
    return declineByPage[page];
  }).sort(function (a, b) {
    return b.score - a.score;
  }).slice(0, ANALYSIS_OPTIONS.declineLimit);

  return {
    decliningPages: decliningPages,
  };
}

function analyzeQueries_(currentRows, previousRows) {
  const previousByKey = mapByPageQuery_(previousRows);
  const currentByKey = mapByPageQuery_(currentRows);
  const rewriteCandidates = [];
  const rankDrops = [];
  const newQueries = [];
  const lostQueries = [];

  currentRows.forEach(function (cur) {
    const prev = previousByKey[cur.key] || emptyPageQueryMetric_(cur.page, cur.query);
    const ctrPct = cur.ctr * 100;
    const expectedCtr = getExpectedCtrPct_(cur.position);
    const reasons = [];
    let score = 0;

    if (
      cur.impressions >= THRESHOLDS.minRewriteImpressions &&
      cur.position > 0 &&
      cur.position <= 10 &&
      ctrPct < expectedCtr
    ) {
      const lostClicks = Math.max(0, Math.round(cur.impressions * ((expectedCtr - ctrPct) / 100)));
      reasons.push('CTR低め ' + formatPct_(ctrPct) + '(順位目安 ' + formatPct_(expectedCtr) + ' / 推定取りこぼし ' + lostClicks + 'クリック)');
      score += lostClicks * 100 + cur.impressions;
    }

    if (
      cur.impressions >= THRESHOLDS.minNearWinImpressions &&
      cur.position >= 8 &&
      cur.position <= 20
    ) {
      reasons.push('8〜20位で伸びしろあり');
      score += Math.round(cur.impressions * Math.max(1, 21 - cur.position));
    }

    if (reasons.length > 0) {
      rewriteCandidates.push({
        page: cur.page,
        query: cur.query,
        current: cur,
        previous: prev,
        reasons: reasons,
        score: score,
      });
    }

    if (
      prev.impressions >= THRESHOLDS.minQueryImpressions &&
      prev.position > 0 &&
      cur.position > 0 &&
      (cur.position - prev.position) >= THRESHOLDS.rankDropPositions
    ) {
      rankDrops.push({
        page: cur.page,
        query: cur.query,
        current: cur,
        previous: prev,
        positionDrop: cur.position - prev.position,
        score: prev.impressions * (cur.position - prev.position),
      });
    }

    if (
      cur.impressions >= THRESHOLDS.minQueryImpressions &&
      prev.impressions === 0
    ) {
      newQueries.push({
        page: cur.page,
        query: cur.query,
        current: cur,
        score: cur.impressions,
      });
    }
  });

  previousRows.forEach(function (prev) {
    const cur = currentByKey[prev.key] || emptyPageQueryMetric_(prev.page, prev.query);
    if (
      prev.impressions >= THRESHOLDS.minQueryImpressions &&
      cur.impressions === 0
    ) {
      lostQueries.push({
        page: prev.page,
        query: prev.query,
        previous: prev,
        score: prev.impressions,
      });
    }
  });

  return {
    rewriteCandidates: rewriteCandidates.sort(sortByScore_).slice(0, ANALYSIS_OPTIONS.rewriteLimit),
    rankDrops: rankDrops.sort(sortByScore_).slice(0, ANALYSIS_OPTIONS.rankDropLimit),
    newQueries: newQueries.sort(sortByScore_).slice(0, ANALYSIS_OPTIONS.queryDiffLimit),
    lostQueries: lostQueries.sort(sortByScore_).slice(0, ANALYSIS_OPTIONS.queryDiffLimit),
  };
}

function analyzeDeviceGaps_(rows) {
  const byPage = {};
  rows.forEach(function (row) {
    if (!byPage[row.page]) byPage[row.page] = {};
    byPage[row.page][String(row.device).toLowerCase()] = row;
  });

  return Object.keys(byPage).map(function (page) {
    const group = byPage[page];
    const mobile = group.mobile;
    const desktop = group.desktop;
    if (!mobile || !desktop) return null;
    const gap = mobile.position - desktop.position;
    if (
      mobile.impressions < THRESHOLDS.minPagePrevImpressions ||
      gap < THRESHOLDS.mobilePositionGap
    ) {
      return null;
    }
    return {
      page: page,
      mobile: mobile,
      desktop: desktop,
      gap: gap,
      score: mobile.impressions * gap,
    };
  }).filter(function (item) {
    return item !== null;
  }).sort(sortByScore_).slice(0, ANALYSIS_OPTIONS.deviceGapLimit);
}

function sortByScore_(a, b) {
  return b.score - a.score;
}

function addDeclineCandidate_(declineByPage, cur, prev, topQuery) {
  if (!prev || !prev.page) return;
  const clickDrop = pctChange_(prev.clicks, cur.clicks);
  const impressionDrop = pctChange_(prev.impressions, cur.impressions);
  const enoughClicks = prev.clicks >= THRESHOLDS.minPagePrevClicks;
  const enoughImpressions = prev.impressions >= THRESHOLDS.minPagePrevImpressions;
  const isDrop =
    (enoughClicks && clickDrop <= -THRESHOLDS.pageDropPct) ||
    (enoughImpressions && impressionDrop <= -THRESHOLDS.pageDropPct);

  if (!isDrop) return;

  const lostClicks = Math.max(0, prev.clicks - cur.clicks);
  const lostImpressions = Math.max(0, prev.impressions - cur.impressions);
  const score = lostClicks * 30 + lostImpressions;
  const existing = declineByPage[prev.page];
  if (existing && existing.score >= score) return;

  declineByPage[prev.page] = {
    page: prev.page,
    current: cur,
    previous: prev,
    topQuery: topQuery,
    clickDrop: clickDrop,
    impressionDrop: impressionDrop,
    score: score,
    disappeared: enoughImpressions && impressionDrop <= -THRESHOLDS.disappearedDropPct,
  };
}

function getExpectedCtrPct_(position) {
  if (!position || position <= 0) return 0;
  if (position <= 1.5) return 30.0;
  if (position <= 2.5) return 20.0;
  if (position <= 3.5) return 15.0;
  if (position <= 4.5) return 10.0;
  if (position <= 5.5) return 8.0;
  if (position <= 7.5) return 5.0;
  if (position <= 10.5) return 3.0;
  if (position <= 20) return 1.0;
  return 0.5;
}

/**
 * 代表URLの設定がなければ、Search ConsoleプロパティからトップページURLを推定する。
 */
function getSampleUrls_(site) {
  if (site.sampleUrls && site.sampleUrls.length > 0) {
    return site.sampleUrls;
  }
  if (site.property.indexOf('sc-domain:') === 0) {
    return ['https://' + site.property.replace('sc-domain:', '') + '/'];
  }
  if (/^https?:\/\//.test(site.property)) {
    return [site.property];
  }
  return [];
}

function buildInspectionTargets_(site, currentPages, previousPages, pageAnalysis, queryAnalysis) {
  const targets = [];

  getSampleUrls_(site).forEach(function (url) {
    targets.push(url);
  });

  pageAnalysis.decliningPages.forEach(function (item) {
    if (item.disappeared || item.current.impressions === 0) {
      targets.push(item.page);
    }
  });

  queryAnalysis.rewriteCandidates.slice(0, 2).forEach(function (item) {
    targets.push(item.page);
  });

  currentPages.slice().sort(function (a, b) {
    return b.impressions - a.impressions;
  }).slice(0, 2).forEach(function (item) {
    targets.push(item.page);
  });

  return uniqueUrls_(targets).slice(0, ANALYSIS_OPTIONS.maxUrlInspectionsPerSite);
}

function inspectIndexTargets_(property, urls) {
  const issues = [];
  urls.forEach(function (url) {
    if (issues.length >= ANALYSIS_OPTIONS.indexAlertLimit) return;
    try {
      const result = fetchUrlInspection_(property, url);
      const reasons = getIndexIssueReasons_(result);
      if (reasons.length > 0) {
        issues.push({
          url: url,
          reasons: reasons,
          result: result,
        });
      }
    } catch (e) {
      issues.push({
        url: url,
        reasons: ['検査失敗: ' + e.message],
        result: null,
      });
    }
  });
  return issues;
}

function getIndexIssueReasons_(result) {
  const reasons = [];

  if (!result.verdict) {
    reasons.push('判定不明');
  } else if (result.verdict !== 'PASS') {
    reasons.push('判定: ' + result.verdict);
  }

  if (result.coverageState) {
    const state = result.coverageState;
    if (
      /not indexed|excluded|blocked|error|alternate|duplicate|noindex|検出|未登録|除外|ブロック/i.test(state)
    ) {
      reasons.push(state);
    }
  }

  if (result.robotsTxtState && result.robotsTxtState !== 'ALLOWED') {
    reasons.push('robots.txt: ' + result.robotsTxtState);
  }
  if (result.indexingState && result.indexingState !== 'INDEXING_ALLOWED') {
    reasons.push('indexing: ' + result.indexingState);
  }
  if (
    result.pageFetchState &&
    result.pageFetchState !== 'SUCCESSFUL' &&
    result.pageFetchState !== 'PAGE_FETCH_STATE_UNSPECIFIED'
  ) {
    reasons.push('取得: ' + result.pageFetchState);
  }

  if (
    result.googleCanonical &&
    result.userCanonical &&
    normalizeUrlForCompare_(result.googleCanonical) !== normalizeUrlForCompare_(result.userCanonical)
  ) {
    reasons.push('Google正規URLが別: ' + result.googleCanonical);
  }

  return uniqueStrings_(reasons);
}

function uniqueUrls_(urls) {
  const seen = {};
  const out = [];
  urls.forEach(function (url) {
    if (!url || !/^https?:\/\//.test(url)) return;
    const key = normalizeUrlForCompare_(url);
    if (seen[key]) return;
    seen[key] = true;
    out.push(url);
  });
  return out;
}

function uniqueStrings_(values) {
  const seen = {};
  const out = [];
  values.forEach(function (value) {
    if (!value || seen[value]) return;
    seen[value] = true;
    out.push(value);
  });
  return out;
}

function normalizeUrlForCompare_(url) {
  return String(url || '').trim().replace(/#.*$/, '')
    .replace(/^(https?):\/\/([^/?#]+)/i, function (_, scheme, host) {
      return scheme.toLowerCase() + '://' + host.toLowerCase();
    });
}

/**
 * Google APIのJSONエラーから人間が読めるmessageだけを取り出す。
 */
function extractApiErrorMessage_(res) {
  const text = res.getContentText() || '';
  try {
    const body = JSON.parse(text);
    if (body.error && body.error.message) {
      return body.error.message;
    }
  } catch (e) {
    // JSONではない場合は下の短縮テキストを使う。
  }
  return text.slice(0, 300);
}

function formatRewriteCandidates_(candidates) {
  const lines = candidates.slice(0, ANALYSIS_OPTIONS.rewriteLimit).map(function (item, index) {
    const cur = item.current;
    const prev = item.previous;
    const trendText = prev.impressions > 0
      ? '\n         前週比: 表示 ' + formatNumber_(prev.impressions) + ' → ' + formatNumber_(cur.impressions)
        + ' / クリック ' + formatNumber_(prev.clicks) + ' → ' + formatNumber_(cur.clicks)
      : '';

    return (index + 1) + '. ' + item.page
      + '\n         対象クエリ: "' + item.query + '"'
      + '\n         理由: ' + item.reasons.join(' / ')
      + '\n         現在: 表示 ' + formatNumber_(cur.impressions)
      + ' / クリック ' + formatNumber_(cur.clicks)
      + ' / CTR ' + formatPct_(cur.ctr * 100)
      + ' / 順位 ' + formatPosition_(cur.position)
      + trendText
      + '\n         提案: タイトル・導入文・見出しを主クエリの検索意図に寄せて更新';
  });

  return '? リライト候補(優先' + lines.length + '件)\n      ' + lines.join('\n      ');
}

function formatDecliningPages_(items) {
  const lines = items.slice(0, ANALYSIS_OPTIONS.declineLimit).map(function (item, index) {
    const cur = item.current;
    const prev = item.previous;
    const queryText = item.topQuery ? '\n         主クエリ: "' + item.topQuery.query + '"' : '';
    const indexText = item.disappeared ? '\n         メモ: 表示がほぼ消えているのでインデックス検査対象に追加' : '';

    return (index + 1) + '. ' + item.page
      + '\n         表示: ' + formatNumber_(prev.impressions) + ' → ' + formatNumber_(cur.impressions)
      + '(' + formatSignedPct_(item.impressionDrop) + ')'
      + ' / クリック: ' + formatNumber_(prev.clicks) + ' → ' + formatNumber_(cur.clicks)
      + '(' + formatSignedPct_(item.clickDrop) + ')'
      + queryText
      + indexText;
  });

  return '? 落ち込みページ(前週比)\n      ' + lines.join('\n      ');
}

function formatRankDrops_(items) {
  const lines = items.slice(0, ANALYSIS_OPTIONS.rankDropLimit).map(function (item, index) {
    return (index + 1) + '. ' + item.page
      + '\n         クエリ: "' + item.query + '"'
      + '\n         順位: ' + formatPosition_(item.previous.position) + ' → ' + formatPosition_(item.current.position)
      + '(+' + item.positionDrop.toFixed(1) + ')'
      + ' / 表示: ' + formatNumber_(item.previous.impressions) + ' → ' + formatNumber_(item.current.impressions);
  });
  return '? 順位急落クエリ\n      ' + lines.join('\n      ');
}

function formatQueryDiffs_(newQueries, lostQueries) {
  const blocks = [];
  if (newQueries.length > 0) {
    blocks.push('新規クエリ\n      ' + newQueries.slice(0, ANALYSIS_OPTIONS.queryDiffLimit).map(function (item, index) {
      return (index + 1) + '. "' + item.query + '"'
        + ' / 表示 ' + formatNumber_(item.current.impressions)
        + ' / 順位 ' + formatPosition_(item.current.position)
        + '\n         ' + item.page;
    }).join('\n      '));
  }
  if (lostQueries.length > 0) {
    blocks.push('消失クエリ\n      ' + lostQueries.slice(0, ANALYSIS_OPTIONS.queryDiffLimit).map(function (item, index) {
      return (index + 1) + '. "' + item.query + '"'
        + ' / 前週表示 ' + formatNumber_(item.previous.impressions)
        + ' / 前週順位 ' + formatPosition_(item.previous.position)
        + '\n         ' + item.page;
    }).join('\n      '));
  }
  return '? 新規/消失クエリ\n      ' + blocks.join('\n      ');
}

function formatDeviceGaps_(items) {
  const lines = items.slice(0, ANALYSIS_OPTIONS.deviceGapLimit).map(function (item, index) {
    return (index + 1) + '. ' + item.page
      + '\n         PC ' + formatPosition_(item.desktop.position)
      + ' / モバイル ' + formatPosition_(item.mobile.position)
      + '(差 +' + item.gap.toFixed(1) + ')'
      + ' / モバイル表示 ' + formatNumber_(item.mobile.impressions);
  });
  return '? モバイルだけ順位が弱いページ\n      ' + lines.join('\n      ');
}

function formatIndexIssues_(items) {
  const lines = items.slice(0, ANALYSIS_OPTIONS.indexAlertLimit).map(function (item, index) {
    return (index + 1) + '. ' + item.url
      + '\n         理由: ' + item.reasons.join(' / ');
  });
  return '? インデックス要確認URL\n      ' + lines.join('\n      ');
}

function formatNumber_(value) {
  return Math.round(Number(value || 0)).toLocaleString('ja-JP');
}

function formatPct_(value) {
  return Number(value || 0).toFixed(1) + '%';
}

function formatSignedPct_(value) {
  const rounded = Math.round(Number(value || 0));
  return (rounded > 0 ? '+' : '') + rounded + '%';
}

function formatPosition_(value) {
  return Number(value || 0).toFixed(1) + '位';
}

function logSiteAnalysisSummary_(site, periods, current, previous, summary) {
  const limitNotes = [];
  if (summary.currentPages.length >= ANALYSIS_OPTIONS.pageMaxRows) {
    limitNotes.push('当週ページ行');
  }
  if (summary.previousPages.length >= ANALYSIS_OPTIONS.pageMaxRows) {
    limitNotes.push('前週ページ行');
  }
  if (summary.currentPageQueries.length >= ANALYSIS_OPTIONS.queryMaxRows) {
    limitNotes.push('当週ページ×クエリ行');
  }
  if (summary.previousPageQueries.length >= ANALYSIS_OPTIONS.queryMaxRows) {
    limitNotes.push('前週ページ×クエリ行');
  }
  if (summary.currentPageDevices.length >= ANALYSIS_OPTIONS.deviceMaxRows) {
    limitNotes.push('ページ×デバイス行');
  }

  Logger.log('【分析サマリー】' + site.name
    + ' / 期間: ' + periods.thisStart + '〜' + periods.thisEnd
    + ' vs ' + periods.prevStart + '〜' + periods.prevEnd
    + ' / 条件: searchType=' + ANALYSIS_OPTIONS.searchType
    + ', country=' + (ANALYSIS_OPTIONS.countryFilter || 'all')
    + ' / 全体: クリック ' + formatNumber_(previous.clicks) + '→' + formatNumber_(current.clicks)
    + ', 表示 ' + formatNumber_(previous.impressions) + '→' + formatNumber_(current.impressions)
    + ', 順位 ' + formatPosition_(previous.position) + '→' + formatPosition_(current.position)
    + ' / 取得行: ページ ' + summary.currentPages.length + '/' + summary.previousPages.length
    + ', ページ×クエリ ' + summary.currentPageQueries.length + '/' + summary.previousPageQueries.length
    + ', ページ×デバイス ' + summary.currentPageDevices.length
    + ' / 候補: リライト ' + summary.queryAnalysis.rewriteCandidates.length
    + ', 落ち込み ' + summary.pageAnalysis.decliningPages.length
    + ', 順位急落 ' + summary.queryAnalysis.rankDrops.length
    + ', 新規 ' + summary.queryAnalysis.newQueries.length
    + ', 消失 ' + summary.queryAnalysis.lostQueries.length
    + ', モバイル差 ' + summary.deviceGaps.length
    + ', インデックス要確認 ' + summary.indexIssues.length + '/' + summary.inspectionTargets.length
    + ', Slack項目 ' + summary.issueCount
    + (limitNotes.length > 0 ? ' / 注意: ' + limitNotes.join(', ') + 'が取得上限に到達' : ''));
}


// ============================================================
//  Slack 通知
// ============================================================

/**
 * 異常情報からSlack用メッセージ文字列を組み立てる。
 */
function buildSlackMessage_(allAlerts, errors) {
  const tz = Session.getScriptTimeZone();
  const today = Utilities.formatDate(new Date(), tz, 'yyyy/MM/dd');

  let msg = '? *GSC分析レポート* (' + today + ')\n';

  allAlerts.forEach(function (a) {
    msg += '\n*■ ' + a.siteName + '*\n';
    a.issues.forEach(function (issue) {
      msg += '   • ' + issue + '\n';
    });
  });

  if (errors.length > 0) {
    msg += '\n*■ チェックに失敗したサイト*\n';
    errors.forEach(function (e) {
      msg += '   • ' + e + '\n';
    });
  }

  return msg;
}

/** Slackに投稿する(Bot Token / chat.postMessage 方式)。 */
function postSlack_(text) {
  const props = PropertiesService.getScriptProperties();
  const token   = props.getProperty('SLACK_BOT_TOKEN');
  const channel = props.getProperty('SLACK_CHANNEL');

  if (!token) {
    throw new Error('スクリプトプロパティ SLACK_BOT_TOKEN が設定されていません。');
  }
  if (!channel) {
    throw new Error('スクリプトプロパティ SLACK_CHANNEL が設定されていません。');
  }

  const res = UrlFetchApp.fetch('https://slack.com/api/chat.postMessage', {
    method: 'post',
    contentType: 'application/json; charset=utf-8',
    headers: { Authorization: 'Bearer ' + token },
    payload: JSON.stringify({
      channel: channel,
      text: text,
      unfurl_links: false,  // URLのプレビュー展開を抑制
    }),
    muteHttpExceptions: true,
  });

  // Slack APIは HTTP 200 でも body の ok:false でエラーを返すので両方チェック
  const body = JSON.parse(res.getContentText() || '{}');
  if (res.getResponseCode() !== 200 || body.ok !== true) {
    throw new Error('Slack投稿失敗: ' + (body.error || res.getContentText()));
  }
}


// ============================================================
//  トリガー設定 / テスト用
// ============================================================

/**
 * 毎週月曜8時の自動実行トリガーを登録する(最初に一度だけ実行)。
 * 既存の同名トリガーがあれば作り直す。
 */
function setupTrigger() {
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === 'checkNow') {
      ScriptApp.deleteTrigger(t);
    }
  });
  ScriptApp.newTrigger('checkNow')
    .timeBased()
    .onWeekDay(ScriptApp.WeekDay.MONDAY)
    .atHour(8)
    .create();
  Logger.log('毎週月曜8時のトリガーを登録しました。');
}

/**
 * 通知のテスト用。実際の異常有無に関わらず、
 * いま取得できる数値をSlackに送って配線を確認する。
 */
function testNotify() {
  const lines = [];
  SITES.forEach(function (site) {
    try {
      const p = getComparePeriods_();
      const cur = fetchPerformanceTotals_(site.property, p.thisStart, p.thisEnd);
      lines.push('■ ' + site.name + '\n   今週: クリック ' + cur.clicks
        + ' / 表示 ' + cur.impressions
        + ' / 順位 ' + cur.position.toFixed(1));
    } catch (e) {
      lines.push('■ ' + site.name + '\n   取得エラー: ' + e.message);
    }
  });
  postSlack_('✅ *GSC監視ツール 接続テスト*\n\n' + lines.join('\n'));
  Logger.log('テスト通知を送信しました。');
}

ステップ10:手動実行して権限を承認する

いよいよ実行です。

準備が整ったので、まず checkNow を手動で走らせて権限承認を済ませます。

GASエディタ上部の関数選択プルダウンで「checkNow」を選び、「実行」を押します。

初回実行時、「承認が必要です」というダイアログが出ます。

ここで承認する権限の中身は、ステップ6で appsscript.json に書いたスコープがそのまま反映されます。

  1. 「権限を確認」をクリック
  2. アカウント選択画面で、このツールを動かすGoogleアカウント(=ステップ3でテストユーザーに入れたアカウント)を選択
  3. 「このアプリはGoogleで確認されていません」という警告が出ることがあります。これは自分で作ったテストステータスのアプリでは正常な挙動です。「詳細」をクリック →「(アプリ名)に移動(安全ではないページ)」をクリック
  4. 「○○がGoogleアカウントへのアクセスをリクエストしています」という権限一覧が出ます。Search Consoleのデータ閲覧、外部サービスへの接続(UrlFetchApp 用)などが並びます。内容を確認して「許可」

ステップ3で自分をテストユーザーに追加していないと、ここで「アクセスをブロックしました」と表示されて先に進めません。

その場合はステップ3に戻ってテストユーザー登録をしてください。

承認が通ると checkNow が実行されます。

実行ログ(エディタ下部、または「実行数」メニュー)を確認します。

  • 異常が検知されなかった場合:「分析候補なし。通知は送信しません。」とログに出ます。これは正常です。このツールは異常がなければSlackに送りません。
  • 異常が検知された場合:Slackにレポートが届きます。
  • エラーが出た場合:下の「つまずいたときの確認ポイント」を参照してください。

「通知が来ない=失敗」ではない、という点を覚えておいてください。

候補ゼロのときに沈黙するのが、このツールの正しい挙動です。

ステップ11:テスト通知で配線を確認する

ステップ10で「分析候補なし」だった場合、Slack通知の経路(トークン・チャンネル・Bot招待)が本当に正しいのか、まだ確認できていません。

それを確かめるのが testNotify 関数です。

testNotify は、異常の有無に関わらず現在の数値をそのままSlackに送る配線確認用の関数です。

関数選択プルダウンで「testNotify」を選び、「実行」します。

Slackの通知先チャンネルに、各サイトの「今週のクリック数/表示回数/順位」がまとまったメッセージが届けば、Slack連携は完全に正常です。

届かない場合は、エラーメッセージで原因が分かります。

  • not_in_channel … ステップ9のBot招待を忘れています
  • invalid_auth / not_authed … SLACK_BOT_TOKEN が間違っているか、xoxb- 以外を貼っています
  • channel_not_found … SLACK_CHANNEL の値が違います(# の有無、チャンネル名のタイプミス)
  • 「スクリプトプロパティ ~ が設定されていません」 … ステップ8のプロパティ名のスペル違い

testNotify が成功すれば、データ取得(ステップ10)と通知(ステップ11)の両方が確認できたことになります。

ステップ12:トリガーを登録して自動運転に乗せる

最後に、毎週自動で走るようにします。

手動で setupTrigger を一度だけ実行すれば、コードが時間トリガーを登録してくれます。

関数選択プルダウンで「setupTrigger」を選び、「実行」します。

ログに「毎週月曜8時のトリガーを登録しました。」と出れば完了です。

setupTrigger は同名の既存トリガーがあれば作り直すので、何度実行しても重複登録されません。

登録されたトリガーは、GASエディタ左メニューの時計アイコン「トリガー」から確認できます。

checkNow が「週ベース・毎週月曜・午前8時〜9時」で登録されているはずです。

実行時刻を変えたいときは、コードの setupTrigger 内の .onWeekDay(...) や .atHour(8) を書き換えて、setupTrigger をもう一度実行します。

トリガー画面から手で編集することもできます。

これで全工程が完了です。

来週の月曜以降、異常が検知されたときだけSlackにレポートが届くようになります。

つまずいたときの確認ポイント

セットアップでよく出るエラーと対処をまとめます。

HTTP 403(Search Analytics取得失敗) — 多くはスコープ宣言(ステップ6)かGCP連携(ステップ7)の問題です。

①appsscript.json に webmasters.readonly を宣言したか、②宣言後に再度 checkNow を実行して権限を再承認したか、③GCPプロジェクトでSearch Console APIを有効化したか、④GASのプロジェクト設定でそのGCPプロジェクト番号を紐付けたか、⑤GASとGCPが同じアカウントか、を順に確認します。

あわせて、実行アカウントがサチコのプロパティで「オーナー/フルユーザー」になっているかも確認してください。

HTTP 403(URL Inspection取得失敗)だけ出る — Search Analyticsは通るのにURL Inspectionだけ落ちる場合、サチコでの権限が「制限付き」の可能性があります。

「フルユーザー」以上に変更します。

「アクセスをブロックしました」「このアプリはテスト中です」「access_denied」 — ステップ3のテストユーザーに、いま承認しようとしているアカウントが入っていません。

Google Auth Platformの「対象」に戻って追加するか、正しいアカウントで承認し直します。

スコープを足したのに 403 が消えない — appsscript.json を編集しただけでは、すでに承認済みのトークンには反映されません。

もう一度 checkNow を手動実行して、追加スコープの承認ダイアログを通してください。

property 関連でデータが0行 — SITES の property 文字列が、サチコの登録と完全一致しているか確認します。

sc-domain: の付け忘れ、URLプレフィックス型の末尾スラッシュ抜けが定番です。

データはあるはずなのに「分析候補なし」が続く — これはエラーではなく、THRESHOLDS のしきい値に届く変化が実際に無いだけです。

挙動を見たいなら、一時的に THRESHOLDS の siteClickDropPct などを小さい値にして実行すると、判定の動きを確認できます。

確認後は元に戻してください。

実行時間が6分で打ち切られる — 監視サイト数が多い、または1サイトのデータ量が非常に大きい場合に起こります。

SITES を分割して別トリガーにする、ANALYSIS_OPTIONS の maxUrlInspectionsPerSite を減らす、などで1回あたりの処理を軽くします。

まとめ:一度通せば、あとは追加するだけ

セットアップの山は4つです。

GCPプロジェクトを作ってAPIを有効化すること、OAuth同意画面のテストユーザーに自分を入れること、appsscript.json に webmasters.readonly を宣言すること、GASとGCPプロジェクトを番号で紐付けること。

この4つさえ越えれば、あとはSlackの設定とトリガー登録で動き出します。

そして一度この基盤ができてしまえば、監視サイトを増やすのは SITES 配列に数行足すだけです。

GCPもSlackも作り直す必要はありません。

2サイト目、3サイト目はものの数分で追加できます。

毎週ひとつずつサチコを開いて目視する作業から解放されて、「対応すべき変化」だけが月曜の朝に届く。

その状態まで、この手順で辿り着けるはずです。

この記事で紹介しているコードは、GitHubのMOTOKI-LLC/WAZA-codeにもまとめています。

URL比較の修正と検証範囲

正規URLの比較では、ホスト名だけを小文字化し、パスの大文字小文字・末尾スラッシュ・クエリ文字列を維持します。

これらをすべて削除・小文字化すると、異なるページを同じURLとして扱って正規URLの不一致を見逃すためです。

フラグメントは比較から外しますが、クエリの並び替えなどを含む完全なURL同値判定ではありません。

比較関数をNode.jsで検証し、Search Console認証・実データ取得・通知の送信は今回実行していません。

スクリプトの変更前コピーを保存し、通知先と対象サイトを検証用に限定して結果を確認してください。

元に戻す場合は変更前コピーを復元しますが、旧比較では異なるURLを同一視する制約がある点に注意してください。

  • URLをコピーしました!

制作・運用のご依頼はこちら

MOTOKI合同会社のサービス

WordPressの制作・カスタマイズ・高速化・SEO対策・保守まで、Webサイトに関する業務を一括で承ります。料金はサービスごとに公開しています。

WAZAの有料記事のサブスクリプションも開始しました。

目次