GRCの代わりにSearch Console API+スプレッドシートでGoogle検索順位レポートを作る

2025年1月に、Googleのスクレイピング対策強化によってGRCが動かなくなってしまい、それまでGoogleの検索順位をGRCで取得していた方は代替サービスを導入や検討している方が多いと思います。

Search Consoleなら無料で使えるのと、Googleが公式で提供している情報ですので、それを使ってGRCのような検索順位レポートが作れるといいなと思い試しにスプレッドシートでスクリプトを作ってみました。

スプレッドシートでGoogle App Script(GAS)を実行し、Search Console APIを叩いて取得結果をシートに出力します。取得内容は、ピボットテーブルやグラフを使って適宜見やすくしてもらえるといいと思います。

最近ChatGPTの o3-mini-high が使えるようになったため、Google App Script(GAS)のソースコードはほぼChatGPTに出力してもらいました。

# 注意点
GRCのような検索順位取得ツールは、検索順位を観測したいキーワードを自分で設定して、そのキーワードで実際にGoogle検索してその順位を取得します。
Search Consoleの検索順位は、実際に誰かがGoogle検索した際のキーワードや検索順位になるため、例えばキーワードの検索ボリュームが非常に少なかったり、自サイトのキーワードでの順位が低い場合などで、該当期間に1回も検索結果に表示されなかった場合はSearch Consoleにはデータが存在しないため取得できません。

# イメージ
スプレッドシートの「設定」というシートに、Search Console APIの検索条件などを、1列1設定で記載します。列があるだけ繰り返し処理されます。
![Monosnap Search Console API 連携 - Google スプレッドシート 0007-03-23 23-30-27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108133/0cc017b7-67df-46e6-813a-bc5fd64da7ce.png)


- `有効1: 無効:0`:  対象のデータ処理を行うか。0の場合は処理をスキップします
- `出力先シート名`: 取得結果を出力するシート名を指定します
- `サイト`: Search Consoleのデータを見たいサイトのURLを指定します
- `開始日`: データ取得の開始日
- `終了日`: データ取得の終了日
- `ディメンション`: データ取得時のSearch Consoleのディメンションを選びます。Date,Query,Page,Device,Countryのいずれか複数を指定します。ディメンション指定しない場合、その項目は除かれた形でグルーピングされた結果が出力されます。
- `Impressions >=`: 表示回数が一定以上のデータだけを取得する場合、1以上の値を指定します
- `Clicks >=`: クリック回数が一定以上のデータだけを取得する場合、1以上の値を指定します
- `集計期間単位`: daily, weekly, monthly のいずれかを指定します。例えば monthly なら、月単位で集計した結果を取得します
- `共通キーワードorURL`, `キーワードorURL`: Search Console APIのフィルタ条件を、キーワードかURLで指定できます。 `共通キーワードorURL` は、すべてのフィルタに共通で適用されます。文字列にアスタリスク(*)を含む場合、部分一致として検索します。

# 出力結果イメージ
設定シートの `出力先シート名` のシートに出力されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108133/6d774179-320a-4805-86dd-eb5f9fef6091.png)

- `FilterKeyword`: API検索時に使用したキーワードorURL
- `Date`: 日付。monthlyの場合、その月の1日が入る。weeklyの場合、週の開始日(日曜日)の日付が設定される
- `Query`: 検索キーワード
- `Page`: URL
- `Device`: モバイルorデスクトップorタブレット
- `Country`: 国コード
- `Clicks`: クリック回数
- `Impressions`: 表示回数
- `CTR`: 表示あたりのクリック率
- `Position`: 平均検索順位
- `SumPosition`: 表示回数 * 平均検索順位

# ログイメージ
処理の実行中のログは「ログ」というシートに記載されます。
![Search Console API連携 - Google スプレッドシート 2025-03-23 04-39-25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108133/f0f67723-175f-4c36-876d-116c5f7ef5b9.png)

# 事前設定
- Google Cloud Platform（GCP）の設定画面で、Search Console APIを有効にする。またプロジェクト番号を控えておく
- スプレッドシートメニューのApp Script起動
    - プロジェクトの設定
        - 「appsscript.json」マニフェスト ファイルをエディタで表示する チェックをONにする
        - Google Cloud Platform（GCP）プロジェクト の プロジェクト番号 を Search Console APIを有効にした GCP プロジェクトの番号に設定

![Monosnap Search Console API 連携 - プロジェクトの設定 - Apps Script 0007-03-23 23-54-40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108133/a8961263-1495-4aaf-9db0-ece031340080.png)


## appsscript.json の設定
![Monosnap Search Console API 連携 - プロジェクト編集者 - Apps Script 0007-03-23 23-52-20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108133/ea73cd15-9cf4-4931-9868-d626cff1bb37.png)

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

# Google App Script 処理概要
設定シートの各列（B列～最終列）に記載された各サイトごとの設定に基づき、Search Console API を実行してデータを取得し、出力先シートに書き出します。
 
 【処理の流れ】
1. 設定シートから、対象シート名、サイトURL、開始日／終了日、ディメンション、フィルター、閾値、集計単位、共通キーワード、個別キーワードを読み込みます。
2. 出力先シートを初期化し、ヘッダーを設定します。（ヘッダーの先頭列は "FilterKeyword" となります
3. 集計単位に応じた処理を実施します。
    - daily の場合：
        - 設定期間を1日単位に分割し、各日ごとに各キーワードの API リクエストを発行します。
        - 各APIレスポンスに対して、ページネーション処理を行い、全件取得します。
        - 取得した各行で、impressionsThreshold, clicksThreshold の閾値チェックを実施します。
    - weekly / monthly の場合：
        - 設定期間を週／月単位のセグメントに分割し、各キーワード×各セグメントの組み合わせで API リクエストを並列実行します。
        - ※この場合、もともとの dimensions から "date" は除外し、API には各セグメントの startDate と endDate を渡します。
        - 出力シートの Date 列には、各セグメントで API に渡した startDate を記録します。
        - 各レスポンスでの閾値チェックは行わず、全データ取得完了後に一括して閾値フィルタを適用します。
 4. API取得結果が rowLimit (25,000) に達した場合は、startRow を更新してページネーション処理を実施します。
 5. 各キーワードまたはセグメントごとに5000行処理したタイミングで進捗ログを出力します。
 6. 最終的に取得したデータを、5000行ごとにチャンク分割して出力先シートへ書き出し、その進捗もログに出力します。

# Google App Script ソースコード（コピペ用）
fetchSearchConsoleData という関数を実行すると処理が走ります。
初回実行時には、oauth認証でスプレッドシートへのアクセスなどの許可が求められるので、許可設定をしてください。

![Monosnap Search Console API 連携 - プロジェクト編集者 - Apps Script 0007-03-23 23-56-58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108133/b80237a4-ba22-4be6-9a59-8f4a2cf477d0.png)


```javascript
function fetchSearchConsoleData() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var settingSheet = ss.getSheetByName("設定");
  if (!settingSheet) {
    Logger.log("設定シートが見つかりません。");
    return;
  }
  
  // ログシートの準備（進捗・エラー記録用）
  var logSheet = ss.getSheetByName("ログ") || ss.insertSheet("ログ");
  logSheet.clearContents();
  logSheet.getRange(1, 1, 1, 2).setValues([["実行日時(JST)", "ログ文字列"]]);
  
  // 設定シートから対象シート名と処理フラグ（B列～最終列）を取得
  var lastCol = settingSheet.getLastColumn();
  var targetSheetNames = settingSheet.getRange(2, 2, 1, lastCol - 1).getValues()[0];
  var processFlags = settingSheet.getRange(1, 2, 1, lastCol - 1).getValues()[0];
  var lastRow = settingSheet.getLastRow();
  
  // 各サイトごとにループ処理
  for (var i = 0; i < targetSheetNames.length; i++) {
    if (processFlags[i] != 1) {
      appendLog(logSheet, "Column " + (i+2) + " は処理フラグが1ではないためスキップします。");
      continue;
    }
    var targetSheetName = targetSheetNames[i];
    if (!targetSheetName || targetSheetName.toString().trim() === "") {
      appendLog(logSheet, "Column " + (i+2) + " の対象シート名が空白のためスキップします。");
      continue;
    }
    var col = i + 2;
    
    // 集計単位 (daily, weekly, monthly) の取得（Row11）
    var aggregationUnit = settingSheet.getRange(11, col).getValue();
    if (!aggregationUnit) { aggregationUnit = "daily"; }
    aggregationUnit = aggregationUnit.toString().trim().toLowerCase();
    
    // 各種設定値の取得
    var siteUrl = settingSheet.getRange(3, col).getValue();
    var startDate = settingSheet.getRange(4, col).getValue();
    var endDate = settingSheet.getRange(5, col).getValue();
    var dimStr = settingSheet.getRange(6, col).getValue();
    var country = settingSheet.getRange(7, col).getValue();
    var device = settingSheet.getRange(8, col).getValue();
    var impressionsThreshold = settingSheet.getRange(9, col).getValue();
    var clicksThreshold = settingSheet.getRange(10, col).getValue();
    // 共通キーワード(URL)の取得（12行目）
    var commonKeyword = settingSheet.getRange(12, col).getValue();
    
    // 出力先シートの準備
    var resultSheet = ss.getSheetByName(targetSheetName) || ss.insertSheet(targetSheetName);
    resultSheet.clearContents();
    var resultHeader = ["FilterKeyword", "Date", "Query", "Page", "Device", "Country", "Clicks", "Impressions", "CTR", "Position", "SumPosition"];
    resultSheet.getRange(1, 1, 1, resultHeader.length).setValues([resultHeader]);
    
    // 日付を "yyyy-MM-dd" の文字列に変換
    if (startDate instanceof Date) { startDate = Utilities.formatDate(startDate, "JST", "yyyy-MM-dd"); }
    if (endDate instanceof Date) { endDate = Utilities.formatDate(endDate, "JST", "yyyy-MM-dd"); }
    
    // ディメンション文字列を分割し、小文字に統一
    var dimensions = [];
    if (dimStr) {
      var arr = dimStr.split(",");
      for (var j = 0; j < arr.length; j++) {
        var d = arr[j].trim();
        if (d !== "") { dimensions.push(d.toLowerCase()); }
      }
    }
    // requiredDimsは日別出力用（先頭に"date"があるが出力時は除外）
    var requiredDims = ["date", "query", "page", "device", "country"];
    // outputDimsは週/月出力用（"date"は除外）
    var outputDims = ["query", "page", "device", "country"];
    
    // 設定シートから個別キーワードを取得（Row13以降）
    var keywordRange = settingSheet.getRange(13, col, lastRow - 12, 1);
    var keywordValues = keywordRange.getValues();
    appendLog(logSheet, "Processing site: " + targetSheetName + " (Column " + col + ")");
    var validKeywords = [];
    for (var j = 0; j < keywordValues.length; j++) {
      var keyword = keywordValues[j][0];
      if (keyword) { validKeywords.push(keyword); }
    }
    
    var allOutputData = [];
    
    // 共通の処理：指定したリクエストを実行して全件取得し、出力用行に整形する関数
    function processRequest(keyword, dateLabel, requestBody, dimsToOutput, useFilteredIndex) {
      var apiUrl = "https://www.googleapis.com/webmasters/v3/sites/" + encodeURIComponent(siteUrl) + "/searchAnalytics/query";
      var contextMsg = "for keyword '" + keyword + "' on " + dateLabel;
      var rows = fetchAllRows(apiUrl, requestBody, logSheet, contextMsg);
      // Dailyの場合は、各リクエストごとに閾値チェックを実施
      if (aggregationUnit === "daily") {
        rows = rows.filter(function(row) {
          return row.impressions >= impressionsThreshold && row.clicks >= clicksThreshold;
        });
      }
      var formatted = formatOutputRows(rows, keyword, dateLabel, dimsToOutput, dimensions, useFilteredIndex, logSheet);
      return formatted;
    }
    
    if (validKeywords.length > 0) {
      if (aggregationUnit === "daily") {
        var dailyDates = getDailyDates(new Date(startDate), new Date(endDate));
        for (var d = 0; d < dailyDates.length; d++) {
          var dayStr = dailyDates[d];
          for (var k = 0; k < validKeywords.length; k++) {
            var keyword = validKeywords[k];
            var filters = buildFilters(keyword, commonKeyword, country, device);
            var requestBody = {
              startDate: dayStr,
              endDate: dayStr,
              dimensions: dimensions,
              rowLimit: 25000,
              startRow: 0,
              dimensionFilterGroups: [{ filters: filters }]
            };
            var outputRows = processRequest(keyword, dayStr, requestBody, requiredDims.slice(1), false);
            allOutputData = allOutputData.concat(outputRows);
          }
        }
      } else { // weekly / monthly
        var segments = (aggregationUnit === "weekly" ? getWeeklySegments(new Date(startDate), new Date(endDate))
                                                       : getMonthlySegments(new Date(startDate), new Date(endDate)));
        for (var k = 0; k < validKeywords.length; k++) {
          var keyword = validKeywords[k];
          for (var s = 0; s < segments.length; s++) {
            var segment = segments[s];
            var segStart = Utilities.formatDate(segment.start, "JST", "yyyy-MM-dd");
            var segEnd = Utilities.formatDate(segment.end, "JST", "yyyy-MM-dd");
            var filters = buildFilters(keyword, commonKeyword, country, device);
            var requestBody = {
              startDate: segStart,
              endDate: segEnd,
              dimensions: dimensions.filter(function(d){ return d != "date"; }),
              rowLimit: 25000,
              startRow: 0,
              dimensionFilterGroups: [{ filters: filters }]
            };
            var outputRows = processRequest(keyword, segStart, requestBody, outputDims, true);
            allOutputData = allOutputData.concat(outputRows);
          }
        }
      }
    } else {
      // キーワード指定なしの場合（個別キーワードは空文字）
      if (aggregationUnit === "daily") {
        var dailyDates = getDailyDates(new Date(startDate), new Date(endDate));
        for (var d = 0; d < dailyDates.length; d++) {
          var dayStr = dailyDates[d];
          var filters = buildFilters("", commonKeyword, country, device);
          var requestBody = {
              startDate: dayStr,
              endDate: dayStr,
              dimensions: dimensions,
              rowLimit: 25000,
              startRow: 0,
              dimensionFilterGroups: []
          };
          if (filters.length > 0) {
            requestBody.dimensionFilterGroups.push({ filters: filters });
          }
          var outputRows = processRequest("", dayStr, requestBody, requiredDims.slice(1), false);
          allOutputData = allOutputData.concat(outputRows);
        }
      } else { // weekly/monthly
        var segments = (aggregationUnit === "weekly" ? getWeeklySegments(new Date(startDate), new Date(endDate))
                                                       : getMonthlySegments(new Date(startDate), new Date(endDate)));
        for (var s = 0; s < segments.length; s++) {
          var segment = segments[s];
          var segStart = Utilities.formatDate(segment.start, "JST", "yyyy-MM-dd");
          var segEnd = Utilities.formatDate(segment.end, "JST", "yyyy-MM-dd");
          var filters = buildFilters("", commonKeyword, country, device);
          var requestBody = {
              startDate: segStart,
              endDate: segEnd,
              dimensions: dimensions.filter(function(d){ return d != "date"; }),
              rowLimit: 25000,
              startRow: 0,
              dimensionFilterGroups: []
          };
          if (filters.length > 0) {
            requestBody.dimensionFilterGroups.push({ filters: filters });
          }
          var outputRows = processRequest("", segStart, requestBody, outputDims, true);
          allOutputData = allOutputData.concat(outputRows);
        }
      }
    }
    
    // weekly/monthlyの場合は、出力後に一括で閾値フィルタを適用
    if (aggregationUnit !== "daily") {
      var preFilterCount = allOutputData.length;
      allOutputData = allOutputData.filter(function(row) {
        // 出力フォーマット: [FilterKeyword, Date, Query, Page, Device, Country, Clicks, Impressions, ...]
        return Number(row[6]) >= clicksThreshold && Number(row[7]) >= impressionsThreshold;
      });
      appendLog(logSheet, "Weekly/Monthly: Pre-filter count = " + preFilterCount + ", after filter = " + allOutputData.length);
    }
    
    // 出力先シートへ書き込み（5000件ごとにチャンクで書き込み）
    if (allOutputData.length > 0) {
      var rowIndex = 2;
      for (var iChunk = 0; iChunk < allOutputData.length; iChunk += 5000) {
        var chunk = allOutputData.slice(iChunk, iChunk + 5000);
        resultSheet.getRange(rowIndex, 1, chunk.length, chunk[0].length).setValues(chunk);
        rowIndex += chunk.length;
        appendLog(logSheet, "出力書き込み進捗: " + (iChunk + chunk.length) + " 件処理済み (フィルタ適用前件数: " + (aggregationUnit === "daily" ? allOutputData.length : preFilterCount) + " 件)");
      }
      appendLog(logSheet, "出力先シート " + targetSheetName + " に " + allOutputData.length + " 件（フィルタ適用前件数）を書き出しました。");
    } else {
      appendLog(logSheet, "出力先シート " + targetSheetName + " にデータはありませんでした。");
    }
  }
  
  appendLog(logSheet, "全てのサイト・キーワードの処理が完了しました。");
  Logger.log("全てのサイト・キーワードの処理が完了しました。");
}

/**
 * buildFilters
 * 個別キーワードと共通キーワード、国、デバイスの条件からフィルター条件の配列を返す。
 * 個別キーワードが空文字の場合は個別キーワードのフィルタは追加しません。
 * ※キーワードに半角スペースが含まれる場合は、分割して各ワードを "contains" の条件で追加します。
 */
function buildFilters(individualKeyword, commonKeyword, country, device) {
  var filters = [];
  if (individualKeyword && individualKeyword.toString().trim() !== "") {
    var trimmed = individualKeyword.toString().trim();
    if (trimmed.indexOf(" ") !== -1) {
      var tokens = trimmed.split(" ");
      tokens.forEach(function(token) {
        token = token.trim();
        if (token !== "") {
          var operator = "contains";
          if (token.indexOf("*") !== -1) {
            token = token.replace(/\*/g, "");
          }
          if (token.indexOf("https://") === 0) {
            filters.push({ dimension: "page", operator: operator, expression: token });
          } else {
            filters.push({ dimension: "query", operator: operator, expression: token });
          }
        }
      });
    } else {
      var operator = "equals";
      if (trimmed.indexOf("*") !== -1) {
        trimmed = trimmed.replace(/\*/g, "");
        operator = "contains";
      }
      if (trimmed.indexOf("https://") === 0) {
        filters.push({ dimension: "page", operator: operator, expression: trimmed });
      } else {
        filters.push({ dimension: "query", operator: operator, expression: trimmed });
      }
    }
  }
  if (commonKeyword && commonKeyword.toString().trim() !== "") {
    var commonExpr = commonKeyword.toString().trim();
    if (commonExpr.indexOf(" ") !== -1) {
      var tokens = commonExpr.split(" ");
      tokens.forEach(function(token) {
        token = token.trim();
        if (token !== "") {
          var commonOperator = "contains";
          if (token.indexOf("*") !== -1) {
            token = token.replace(/\*/g, "");
          }
          if (token.indexOf("https://") === 0) {
            filters.push({ dimension: "page", operator: commonOperator, expression: token });
          } else {
            filters.push({ dimension: "query", operator: commonOperator, expression: token });
          }
        }
      });
    } else {
      var commonOperator = "equals";
      if (commonExpr.indexOf("*") !== -1) {
        commonExpr = commonExpr.replace(/\*/g, "");
        commonOperator = "contains";
      }
      if (commonExpr.indexOf("https://") === 0) {
        filters.push({ dimension: "page", operator: commonOperator, expression: commonExpr });
      } else {
        filters.push({ dimension: "query", operator: commonOperator, expression: commonExpr });
      }
    }
  }
  filters.push({ dimension: "query", operator: "notContains", expression: "#" });
  filters.push({ dimension: "page", operator: "notContains", expression: "#" });
  if (country && country.toString().trim() !== "") {
    filters.push({ dimension: "country", operator: "equals", expression: country.toString().trim() });
  }
  if (device && device.toString().trim() !== "") {
    filters.push({ dimension: "device", operator: "equals", expression: device.toString().trim() });
  }
  return filters;
}

/**
 * fetchAllRows
 * 指定されたAPIリクエストを実行し、ページネーションを含めた全件の行データを返す。
 * ※リクエスト前に、リクエストパラメータ（JSON文字列）をログ出力します。
 */
function fetchAllRows(apiUrl, requestBody, logSheet, contextMsg) {
  var allRows = [];
  // ログ出力：リクエストパラメータ
  appendLog(logSheet, "Request parameters " + contextMsg + ": " + JSON.stringify(requestBody));
  var response = UrlFetchApp.fetch(apiUrl, {
      method: "post",
      contentType: "application/json",
      payload: JSON.stringify(requestBody),
      headers: { Authorization: "Bearer " + ScriptApp.getOAuthToken() },
      muteHttpExceptions: true
  });
  var json;
  try {
    json = JSON.parse(response.getContentText());
  } catch (e) {
    appendLog(logSheet, "JSON parse error " + contextMsg + ": " + e.toString());
    return allRows;
  }
  if (response.getResponseCode() !== 200 || json.error) {
    appendLog(logSheet, "API error " + contextMsg + ": " + (json.error ? JSON.stringify(json.error) : response.getContentText()));
    return allRows;
  }
  if (json.rows) { allRows = json.rows; }
  appendLog(logSheet, "API response " + contextMsg + " returned " + (json.rows ? json.rows.length : 0) + " rows.");
  while (json.rows && json.rows.length === 25000) {
    var startRowVal = allRows.length;
    requestBody.startRow = startRowVal;
    // ログ出力：リクエストパラメータ（ページネーション）
    appendLog(logSheet, "Request parameters (pagination) " + contextMsg + ": " + JSON.stringify(requestBody));
    var additionalResponse = UrlFetchApp.fetch(apiUrl, {
        method: "post",
        contentType: "application/json",
        payload: JSON.stringify(requestBody),
        headers: { Authorization: "Bearer " + ScriptApp.getOAuthToken() },
        muteHttpExceptions: true
    });
    var additionalJson;
    try {
      additionalJson = JSON.parse(additionalResponse.getContentText());
    } catch (e) {
      appendLog(logSheet, "JSON parse error (pagination) " + contextMsg + ": " + e.toString());
      break;
    }
    appendLog(logSheet, "API pagination " + contextMsg + " (startRow: " + startRowVal + ") returned " + (additionalJson.rows ? additionalJson.rows.length : 0) + " rows.");
    if (additionalResponse.getResponseCode() !== 200 || additionalJson.error) {
      appendLog(logSheet, "API error (pagination) " + contextMsg + ": " + (additionalJson.error ? JSON.stringify(additionalJson.error) : additionalResponse.getContentText()));
      break;
    }
    if (additionalJson.rows) {
      allRows = allRows.concat(additionalJson.rows);
    }
    if (!additionalJson.rows || additionalJson.rows.length < 25000) break;
    json = additionalJson;
  }
  return allRows;
}

/**
 * formatOutputRows
 * APIから取得した行データを、出力用の形式に整形する。
 * useFilteredIndex が true の場合、dimensions から "date" を除いた配列でインデックス検索する。
 */
function formatOutputRows(rows, keyword, dateLabel, dims, dimensions, useFilteredIndex, logSheet) {
  var outputRows = [];
  for (var j = 0; j < rows.length; j++) {
    if (j > 0 && j % 5000 === 0) {
      appendLog(logSheet, "Processed " + j + " rows for keyword '" + keyword + "' at " + dateLabel);
    }
    var row = rows[j];
    var keys = row.keys;
    var outputRow = [];
    outputRow.push(keyword);
    outputRow.push(dateLabel);
    for (var d = 0; d < dims.length; d++) {
      var idx;
      if (useFilteredIndex) {
        var filtered = dimensions.filter(function(x){ return x != "date"; });
        idx = filtered.indexOf(dims[d]);
      } else {
        idx = dimensions.indexOf(dims[d]);
      }
      outputRow.push((idx >= 0 && keys && keys.length > idx) ? keys[idx] : "");
    }
    outputRow.push(row.clicks || 0);
    outputRow.push(row.impressions || 0);
    var ctrPercentage = (row.ctr || 0) * 100;
    ctrPercentage = Math.round(ctrPercentage * 100) / 100;
    outputRow.push(ctrPercentage + "%");
    outputRow.push(Math.round((row.position || 0) * 100) / 100);
    outputRow.push((row.impressions || 0) * (row.position || 0));
    outputRows.push(outputRow);
  }
  return outputRows;
}

/**
 * getDailyDates
 * 指定された開始日～終了日を、1日単位（"yyyy-MM-dd"形式）の文字列の配列で返す。
 */
function getDailyDates(startDate, endDate) {
  var dates = [];
  var current = new Date(startDate);
  while (current <= endDate) {
    dates.push(Utilities.formatDate(current, "JST", "yyyy-MM-dd"));
    current.setDate(current.getDate() + 1);
  }
  return dates;
}

/**
 * getWeeklySegments
 * 指定された開始日～終了日を、週単位（日曜日～土曜日）に分割して返す。
 * ※開始日は設定シートで指定された日付そのまま利用するため、週の途中からの開始もあり得る。
 */
function getWeeklySegments(startDate, endDate) {
  var segments = [];
  var current = new Date(startDate);
  while (current <= endDate) {
    var day = current.getDay(); // 0=Sunday, 6=Saturday
    var daysToSaturday = 6 - day;
    var segmentEnd = new Date(current);
    segmentEnd.setDate(current.getDate() + daysToSaturday);
    if (segmentEnd > endDate) { segmentEnd = new Date(endDate); }
    segments.push({ start: new Date(current), end: new Date(segmentEnd) });
    current = new Date(segmentEnd);
    current.setDate(segmentEnd.getDate() + 1);
  }
  return segments;
}

/**
 * getMonthlySegments
 * 指定された開始日～終了日を、月単位に分割して返す。
 * ※開始日は設定シートで指定された日付そのまま利用するため、月の途中からの開始もあり得る。
 */
function getMonthlySegments(startDate, endDate) {
  var segments = [];
  var current = new Date(startDate);
  while (current <= endDate) {
    var year = current.getFullYear();
    var month = current.getMonth();
    var lastDay = new Date(year, month + 1, 0);
    var segmentEnd = (lastDay > endDate ? new Date(endDate) : lastDay);
    segments.push({ start: new Date(current), end: new Date(segmentEnd) });
    current = new Date(segmentEnd);
    current.setDate(segmentEnd.getDate() + 1);
  }
  return segments;
}

/**
 * appendLog
 * 指定されたログシートに、現在日時とメッセージを追加する。
 */
function appendLog(sheet, message) {
  var now = new Date();
  var formattedDate = Utilities.formatDate(now, "JST", "yyyy-MM-dd HH:mm:ss");
  sheet.appendRow([formattedDate, message]);
}

```