COMPANY SERVICE STAFF BLOG NEWS CONTACT

STAFF BLOG

スタッフブログ

TECHNICAL

テクログ

2025.02.28

GoogleのAPIを使うときシステム パラメータ”fields”を使うとレスポンスに含める内容を指定出来て便利(ただ全てのフィールドを指定できるわけでは無い)

テクログ

こんにちは!最近PageSpeed Insights APIを使ってみて
・Google Cloud APIには、全APIで共通して使えるパラメータの”システム パラメータ”がある
  ・ドキュメント
  ・個々のAPIのドキュメントからだとこれについて微妙に隠れた位置に書いてあって最初気付けなかった
・”システム パラメータ”には”fields”があり、レスポンスに含めるフィールドを指定できる
  ・PageSpeed Insights API 等は場合によっては数MBを超えるレスポンスが返ってくるので
   サイズを減らすのに便利
  ・”,”区切りで複数のフィールドを指定可能
・ただ”fields”は指定できるフィールドに制限があるので注意
  ・おそらくどのようなリクエスト内容でもレスポンスに確実に含まれるフィールドでないと指定できない
   ・ レスポンスに含まれていれば指定できるわけじゃない
  ・これに関して明記されてる箇所が見当たらず結構詰まった
みたいなことを知りました。のすけです!
以降はこれを調べた経緯など周辺の些末な話です。

まず
・複数のWebページの PageSpeed Insights のパフォーマンスを知りたい
 ・けどページ数分 PageSpeed Insights を開いてそれぞれURL貼り付けて実行して結果を待って……
  みたいなことをしたくない
・PageSpeed Insights APIを試しに使ってみたい
といった動機で以下のような処理を行うGoogle Apps Scriptを書きました。
・Chatworkへの投稿内容をwebhookでGoogle Apps ScriptにPOST
・Google Apps ScriptからPageSpeed Insights APIを実行して診断結果を得る
・Google Apps ScriptからChatwork API で診断結果を投稿
コードは以下のような感じです。

const COMMAND_GET_PAGE_SPEED = '/command pagespeed';

const CHATWORK_API_TOKEN = PropertiesService.getScriptProperties().getProperty('CHATWORK_API_TOKEN');
const CHATWORK_API_BASE_URL = 'https://api.chatwork.com/v2';

const PAGE_SPEED_INSIGHTS_API_KEY = PropertiesService.getScriptProperties().getProperty('PAGESPEED_INSIGHTS_API_KEY');
const PAGE_SPEED_INSIGHTS_API_BASE_URL = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed';

/**
 * Webhook受信時の処理を行う
 * @param {Object} e - イベント オブジェクト
 * @see {@link https://developers.google.com/apps-script/guides/web?hl=ja#request_parameters | ウェブアプリ  |  Apps Script  |  Google for Developers}
 */
function doPost(e) {
  const contentsJson = JSON.parse(e.postData.contents);
  const message = contentsJson.webhook_event.body;

  if (!message.startsWith(COMMAND_GET_PAGE_SPEED)) {
    return;
  }

  const pageSpeedInsights = new PageSpeedInsightsApiClient(PAGE_SPEED_INSIGHTS_API_KEY, PAGE_SPEED_INSIGHTS_API_BASE_URL);

  const chatworkPostRoomId = PropertiesService.getScriptProperties().getProperty('CHATWORK_ROOM_ID_MYCHAT');
  const chatwork = new ChatworkApiClient(CHATWORK_API_TOKEN, CHATWORK_API_BASE_URL);

  const speedTestUrls = message.split('\n').filter((line) => !line.includes(COMMAND_GET_PAGE_SPEED));

  const pageSpeedInsightsStrategy = ['desktop', 'mobile'];

  try {
    const postMessage = speedTestUrls.map((speedTestUrl) => {
      const pageSpeedInsightsResultText = pageSpeedInsightsStrategy.map((strategy) => {
        try {
          if (!UrlUtils.isValidUrl(speedTestUrl)) {
            throw new Error(`Invalid URL: ${speedTestUrl}`);
          }
          const pageSpeedJson = pageSpeedInsights.fetchResult(speedTestUrl, strategy, 'lighthouseResult.audits,lighthouseResult.categories.performance.score');
          const audit = PageSpeedResultFormatter.getAudits(pageSpeedJson);
          const performance = PageSpeedResultFormatter.getPerformance(pageSpeedJson);

          let contents = [`・${strategy}`, performance, audit].join('\n');
          let postMessage = ChatworkMessageFormatter.wrapWithCodeTags(contents);
          return postMessage;
        } catch (error) {
          let contents = [`・${strategy}`, error.message].join('\n');
          return ChatworkMessageFormatter.wrapWithCodeTags(contents);
        }
      }).join('\n');
      const urlText = `テストページURL: ${speedTestUrl}`;

      let postMessage = ChatworkMessageFormatter.wrapWithInfoTags(
        `PageSpeed Insights`,
        [urlText, pageSpeedInsightsResultText].join('\n')
      );
      return postMessage;
    }).join('\n');

    chatwork.postMessage(chatworkPostRoomId, postMessage);
  } catch (error) {
    const errorMessage = ChatworkMessageFormatter.wrapWithCodeTags(`Error - ${error.message}`);
    chatwork.postMessage(chatworkPostRoomId, errorMessage);
  }
}

class PageSpeedInsightsApiClient {
  /**
   * @param {string} apiKey
   * @param {string} baseUrl
   */
  constructor(apiKey, baseUrl) {
    this.apiKey = apiKey;
    this.baseUrl = baseUrl;
  }

  /**
   * 診断結果を取得
   *
   * @param {string} speedTestUrl - パフォーマンスを計測したいページのURL
   * @param {string} strategy - 使用する分析戦略(デスクトップまたはモバイル)。デスクトップがデフォルト
   * @param {string} fields - 取得したいフィールド
   *   lighthouseResult.auditsについてはこれより下の階層を指定できないので注意
   * @returns {Object} PageSpeed Insights APIのレスポンスボディをJSON形式に変換したもの
   * @see {@link https://developers.google.com/speed/docs/insights/v5/reference/pagespeedapi/runpagespeed | Pagespeedapi: runpagespeed}
   */
  fetchResult(speedTestUrl, strategy = '', fields = '') {
    let params = {
      url: speedTestUrl,
      key: this.apiKey
    };

    if (strategy === 'desktop' || strategy === 'mobile') {
      params.strategy = strategy;
    }

    if (fields !== '') {
      params.fields = fields;
    }

    const apiUrl = UrlUtils.appendQueryToUrl(this.baseUrl, params);
    const options = {
      'method': 'get',
      'headers': {
        'Content-Type': 'application/json'
      }
    };

    try {
      const response = UrlFetchApp.fetch(apiUrl, options);
      if (response.getResponseCode() !== 200) {
        throw new Error(`PageSpeed Insights API request failed: status code ${response.getResponseCode()}`);
      }
      const responseBody = response.getContentText();
      const responseBodyJson = JSON.parse(responseBody);
      return responseBodyJson;
    } catch (error) {
      console.error("PageSpeed Insights API failed:", error);
      throw error;
    }
  }
}

class PageSpeedResultFormatter {
  /**
   * @param {Object} responseBodyJson
   * @return {string} 指標の評価まとめ
   */
  static getAudits(responseBodyJson) {
    const audits = responseBodyJson.lighthouseResult.audits;
    if (!audits) {
      throw new Error("Invalid Argument");
    }

    const metrics = [
      { text: "First Contentful Paint   : ", auditsKey: "first-contentful-paint", },
      { text: "Total Blocking Time      : ", auditsKey: "total-blocking-time", },
      { text: "Speed Index              : ", auditsKey: "speed-index", },
      { text: "Largest Contentful Paint : ", auditsKey: "largest-contentful-paint", },
      { text: "Cumulative Layout Shift  : ", auditsKey: "cumulative-layout-shift", },
    ];

    const results = metrics.map((metric) => {
      const scoreInt = Math.floor(parseFloat(audits[metric.auditsKey].score) * 100);
      const metricIcon = this.getMetricIcon(scoreInt);

      let metricText = `${metricIcon}${metric.text}${audits[metric.auditsKey].displayValue}`;
      return metricText;
    });

    return results.join('\n')
  }

  /**
   * @param {Object} responseBodyJson
   * @return {string} パフォーマンス
   */
  static getPerformance(responseBodyJson) {
    const categories = responseBodyJson.lighthouseResult.categories;
    if (!categories) {
      throw new Error("Invalid Argument");
    }

    const scoreInt = Math.floor(parseFloat(categories.performance.score) * 100);
    const metricIcon = this.getMetricIcon(scoreInt);
    const performance = `${metricIcon}Performance              : ${scoreInt}`;
    return performance;
  }

  /**
   * PageSpeed Insights の指標の評価値と対応するアイコンを返す
   *
   * @param {number} scoreInt - 指標の評価値
   * @returns {string} アイコン
   */
  static getMetricIcon(scoreInt) {
    return scoreInt < 50 ? '🔺' : scoreInt < 90 ? '🟨' : '🟢';
  }
}

class ChatworkApiClient {
  /**
   * @param {string} apiToken
   * @param {string} baseUrl
   */
  constructor(apiToken, baseUrl) {
    this.apiToken = apiToken;
    this.baseUrl = baseUrl;
  }

  /**
   * チャットにメッセージを投稿する
   *
   * @param {string} roomId - メッセージを投稿するルームのID
   * @param {string} message - 投稿するメッセージ内容
   * @param {number} [selfUnread=0] - 自分の未読フラグ(0: 既読、1: 未読)
   * @returns {void}
   * @see {@link https://developer.chatwork.com/reference/post-rooms-room_id-messages | チャットにメッセージを投稿する}
   */
  postMessage(roomId, message, selfUnread = 0) {
    const endpointUrl = this.baseUrl + "/rooms/" + roomId + "/messages";

    const options = {
      method: "POST",
      headers: {
        'X-ChatWorkToken': this.apiToken
      },
      payload: {
        body: message,
        self_unread: selfUnread.toString()
      }
    };

    try {
      const response = UrlFetchApp.fetch(endpointUrl, options);
      if (response.getResponseCode() !== 200) {
        throw new Error(`Chatwork API request failed: status code ${response.getResponseCode()}`);
      }
    } catch (error) {
      console.error("Chatwork API post message failed:", error);
      throw error;
    }
  }
}

class ChatworkMessageFormatter {
  /**
   * メッセージをcodeタグで囲む
   *
   * @param {string} message - メッセージ
   * @returns {string} 整形後の文字列
   */
  static wrapWithCodeTags(message) {
    return `[code]${message}[/code]`;
  }

  /**
   * メッセージをinfoタグ、titleタグで整形する
   *
   * @param {string} message - メッセージ
   * @returns {string} 整形後の文字列
   */
  static wrapWithInfoTags(title = '', info) {
    let message = '';
    if (title !== '') {
      message += `[title]${title}[/title]`;
    }
    message += `${info}`;
    return `[info]${message}[/info]`;
  }
}

class UrlUtils {
  /**
   * @param {string} baseUrl - ベースURL
   * @param {Object} queryObject - クエリパラメータ生成用
   * @return {string} URL
   */
  static appendQueryToUrl(baseUrl, queryObject) {
    const queryString = Object.entries(queryObject)
      .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
      .join('&');

    return `${baseUrl}?${queryString}`;
  }

  /**
   * @param {string} url - 検証するURL
   * @return {boolean}
   */
  static isValidUrl(url) {
    try {
      const response = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
      const responseCode = response.getResponseCode();
      return responseCode >= 200 && responseCode < 300;
    } catch (error) {
      return false;
    }
  }
}

上記のコードを書くため色々試していて気になる点がありました。
PageSpeed Insights APIのレスポンスのサイズが大分でかいことです。
対象のページによるのですが、数MBを超えるオブジェクトが返ってきたりします。
何でそんな画像みたいなサイズなんだと8000行くらいあるオブジェクトを眺めていたら
画像をBase64エンコードしたテキストが含まれていたので実際画像でもあります。
道理ででかいわけです。
ただ処理で使いたい値はそのうちの数値いくつかだけで、あまりに使わないデータをたくさん取得しすぎているのが気になったため
レスポンスのデータを減らせないか調べました。
いかにもありそうな機能なのですがPageSpeed Insights APIのドキュメントを眺めるも (この時は) 見当たらず、検索してもそれらしい情報を見つけられず…
試しにAI(Perplexity)に聞いてみたところ
クエリパラメータの”fields”を使うとレスポンスオブジェクトに含めるデータを選べるとの回答がありました。
PageSpeed Insights APIのドキュメントに書かれていない(とこの時は思っていた)パラメータだったので
典型的なハルシネーション!いやーこれだからなーAIはなー!
とか思いつつも念のため試してみたら指定したフィールドだけのレスポンスオブジェクトを取得できました。
……いやー常々思っていたんですよね、人類はおろかだからマザーAIに管理されて生きていくべきだって

後から”fields”が存在すること前提で調べて分かったのですが
Google Cloud APIでは全APIで共通して使えるパラメータ”システム パラメータ“があり、 “fields”はその一つだったようです。
知らなかった……

何なら少し隠れた位置にあるもののPageSpeed Insights APIのドキュメントに一応書いてあるようでした。

まず右の閉じられているパネルのAPIタブを開きます
“Show standard parameters”をクリックします
すると少し下にスクロールするとあります
なぜこんなAPI Explorerを使わないと気付かないところに…

そんなこんなで無事にレスポンスのデータ量を減らせるようになったと思いきや
今度は指定するフィールドによってはエラーが出てそもそも正常にリクエストできなくなるという
別の問題が出てきました。
僕はおろかな人類なのでまずはAIに英知を授けていただこうとPerplexityに聞いてみたところ以下のような回答をいただきました。
・フィールドパスの指定をドット記法ではなくブラケット記法で記載してみてください
・フィールドパスに含まれるドット(.)やハイフン(-)をURLエンコードしてください
確かにそれっぽいと思ったので試してみるも特に改善せず。
他にも”fields”周りの書き方を色々試行錯誤するも上手くいかず。
……いやー常々思っていたんですよね、今のAIは信用しきってはいけないただの道具だって。ヒューマンライツ!

結局これに関してドキュメントに明記されている箇所を見つけられなかったので
色々試して振る舞いから挙動を確認していったところ
たとえレスポンスに含まれているフィールドでも、ドキュメントに書かれていないフィールドは指定したら
400 エラーになるようでした。

例えば lighthouseResult の audits についてですが
以下のような構造になっていて(一部抜粋)

"lighthouseResult": {
..........
	"audits": {
		"network-requests": {
..........
		},
		"non-composited-animations": {
..........
		},
..........
	},
}

ドキュメントの記載は以下のようになっています。

そのため、以下についてはfieldsで指定して正常にリクエストできますが
   lighthouseResult.audits
以下についてはできません。
   lighthouseResult.audits.network-requests
   lighthouseResult.audits.non-composited-animations

推測になりますが
どのようなリクエスト内容でもレスポンスに確実に含まれるフィールド
   → ドキュメントに記載
リクエスト内容によっては含まれない可能性があるフィールド
   → ドキュメントに明記されていない
で、レスポンスに確実に含まれるフィールドだけ”fields”で指定可能
というような挙動っぽいです。

レスポンスに存在しないフィールドならともかく、存在するフィールドなら
“fields”パラメータの書き方をちゃんとすれば正常にリクエストできるように思えたので
大分試行錯誤したのですが
そもそも取得できる対象じゃなかっただけのようです。
僕はこの挙動に気づくまで数時間溶かしました。

正直”fields”は、使ってもレスポンスオブジェクトのサイズを多少減らせるだけで
APIを使って実現したいこととは関係なく、大体の人にとって時間を費やしたいところではないと思うので
この記事がこの件に関して他の人の時間の節約に貢献出来たら幸いです。
以上です!

この記事を書いた人

のすけ

入社年2019年

出身地神奈川

業務内容システム開発

特技・趣味読書、ゲーム、アニメ

テクログに関する記事一覧

TOP