Google Search Consoleの不要なサイトインデックス削除を自動化する(試み)

これは[株式会社POL テックカレンダー 2021](https://qiita.com/advent-calendar/2021/pol) 5日目の記事です。

株式会社POLでエンジニアをしている高橋です。
Google Search Console上の大量のサイトインデックスを自動で消すためのコードを作成してみたので、それを紹介します。

## 解決したい課題

Google Search Consoleでインデックスの削除リクエストを大量に送信したいが、特定のパスで始まるURLであれば条件指定できるが、特定のURL以外を一括で削除するといったことはGUIではできない。GUIではできないけそういった条件での削除をなんとかして行いたい。

![スクリーンショット 2021-12-05 12.38.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130513/5118acb8-7093-04f3-a784-ed941e8d0e24.png)

検索エンジンの提供会社に問い合わせして実施してもらうアプローチもありそうですが、エンジニアなのでエンジニアリングで解決してみようという試みです。

## アプローチ

1. [Programmable Search Engine](https://developers.google.com/custom-search)で検索結果を取得する
1. 取得した検索結果のURLに対して、[Indexing API](https://developers.google.com/search/apis/indexing-api/v3/quickstart)でURLの削除を実行する
1. これらの処理をGitHub Actions上で日次で実行する

本当はBingのような他の検索エンジンの対応も行いたいのですが、今回はGoogleだけ対応してみます。

## ソースコード

[GitHub](https://github.com/takahashik/delete-google-site-index.git)で見れます。

## 構成について、動かし方

`yarn run build`でdist配下に実行可能なjsを吐き出して、`yarn run exec`でnodeで実行するような構成にしています。

```json
package.jsonの抜粋
{
    "exec": "node node_modules/dotenv/config && node ./dist/bundle.js",
    "build": "node scripts/build.js"
}
```

## 構成の詳細
### ビルド周り

使い慣れているwebpackでも良かったのですが、早いと噂の[esbuild](https://github.com/evanw/esbuild)を気分転換に使ってみました。
設定が非常に簡潔なのと、ビルド実行時間が(小さいプロジェクトというのもありそうですが)とても短かったので驚きました。

```javascript
// scripts/build.js
require('esbuild')
  .build({
    entryPoints: ['src/index.ts'],
    bundle: true,
    platform: 'node',
    outfile: 'dist/bundle.js',
  })
  .catch(() => process.exit(1));
```

### API呼び出し周り

[google-api-nodejs-client](https://github.com/googleapis/google-api-nodejs-client)を使ってAPIの呼び出しをライブラリにお任せしてます。

#### Googleでの検索結果の取得
```typescript
// getUrl.ts
import {google} from 'googleapis';

const getUrlList = async (): Promise<{url: string}[]> => {
  const apiKey = process.env.SEARCH_API_KEY;
  const searchEngineId = process.env.SEARCH_ENGINE_ID;
  const query = process.env.SEARCH_QUERY;

  google.options({auth: apiKey});
  // 削除対象の件数を取得
  const {
    data: {searchInformation},
  } = await google.customsearch('v1').cse.siterestrict.list({
    cx: searchEngineId,
    q: query,
  });
  const {totalResults} = searchInformation || {totalResults: 0};

  if (isNaN(Number(totalResults))) {
    console.warn(`total result is not number: ${totalResults}`);
    return [];
  }

  // 100件までしかAPIで取得できないのでインデックスの上限を決める
  const totalIndex = Number(totalResults) > 100 ? 100 : Number(totalResults);
  // 10件ずつ取得するので1,11,21...のようなインデックスの配列を作る
  const indexes = [...Array(Number(totalIndex)).keys()].filter(
    index => (index - 1) % 10 === 0
  ) as number[];
  const data: {url: string}[] = [];

  for (const index of indexes) {
    const result = await google.customsearch('v1').cse.siterestrict.list({
      cx: searchEngineId,
      q: query,
      start: index,
    });

    const {
      data: {items},
    } = result;

    // 検索結果と件数に乖離があるので取得できなくなったら処理を止める
    if (!items?.length) break;

    data.push(
      ...(items
        ?.filter(item => item.link)
        .map(item => ({
          url: item.link as string,
        })) || [])
    );
  }

  return data;
};

export default getUrlList;
```

CustomサーチAPIの設定方法は[こちらの記事](https://qiita.com/zak_y/items/42ca0f1ea14f7046108c)を参考に設定しています。Web全体を検索する必要がないので、検索するサイトはインデックスの削除をしたいサイトのみに制限しています。

また検索条件ですが、`site:[ドメイン]`で全件検索できたら良かったのですがCustomサーチの仕様なのか期待した件数ヒットしないので、`[ドメイン]`を指定しています(挙動が変わる条件がわからない…)。

余談ですが関連記事を調べていたら[元インターン生の解説記事](https://note.com/realyutanemoto/n/nce9ba25df854)を偶然見つけて懐かしい気持ちになりました。

#### Google Search Consoleへの削除依頼処理

Googleが提供するWeb APIを用いてサイトのGoogle Search Consoleの情報にアクセスするには、API呼び出しアカウントに対してサイト所有者の権限を付与する必要があります。[Indexing APIを使用する前提条件](https://developers.google.com/search/apis/indexing-api/v3/prereqs?hl=ja)に従って、サービスアカウントに権限付与し、json形式の認証情報を読み込むことで権限周りの解決を行っています。

```typescript
// deleteUrl.ts
import {google} from 'googleapis';
import secret from '../secret.json';

// 削除申請したくないURLがわかっているので、正規表現で除外
const excludeList = [
  new RegExp('https://example.com$'),
  new RegExp('https://example.com/hoge$'),
  new RegExp('https://example.com/fuga/*'),
];

// indexing APIを呼ぶためにoAuthでJWTを取得する
const getOauth2Client = () => {
  const oauth2Client = new google.auth.JWT(
    secret.client_email,
    undefined,
    secret.private_key,
    ['https://www.googleapis.com/auth/indexing'],
    undefined
  );
  return oauth2Client;
};

// 正規表現で消したくないURLを除外するための処理
const checkUrl = ({url}: {url: string}) =>
  excludeList.filter(regexp => regexp.test(url)).length == 0;

// 削除処理の本体
const deleteUrls = async (data: {url: string}[]) => {
  const deleteTargets = data.filter(checkUrl);
  if (deleteTargets.length == 0) return [];

  const oauth2Client = getOauth2Client();
  const failedTargets: string[] = [];
  for (const target of deleteTargets) {
    try {
      await google
        .indexing({version: 'v3', auth: oauth2Client})
        .urlNotifications.publish({
          requestBody: {
            type: 'URL_DELETED',
            url: target.url,
          },
        });
      console.log(`url:${target.url} is deleted.`);
    } catch (e) {
      console.log(`url:${target.url} is failed. ${e}`);
      failedTargets.push(target.url);
      continue;
    }
  }

  return deleteTargets.filter(target => !failedTargets.includes(target.url));
};

export default deleteUrls;
```

### 検索と削除の実行

作成した上記2つの処理をindex.tsで呼び出す。

```typescript
// index.ts
import dotenv from 'dotenv';
import deleteUrls from './deleteUrl';
import getUrlList from './getUrl';

dotenv.config();

const handler = async () => {
  // 削除対象を取得
  console.log('operation start');
  const data = await getUrlList();
  console.log('delete target');
  console.log(data);
  const deletedData = await deleteUrls(data);
  return deletedData;
};

handler()
  .then(deletedData => {
    console.log('operation complete.');
    console.log(
      `deleted urls:\n ${deletedData.map(deleted => `${deleted.url}\n`)}`
    );
    process.exit(0);
  })
  .catch(e => {
    console.log('operation error.');
    console.error(e);
    process.exit(1);
  });
```

#### ローカルで実行

```bash
$ yarn run exec

yarn run v1.22.17
$ node node_modules/dotenv/config && node ./dist/bundle.js
operation start
url:https://example.com/1234567890.html is deleted.
operation complete.
deleted urls:
 https://example.com/1234567890.html
```

#### CIの設定

GitHub Actionsで日次で実行するようにしています。
呼び出し制限等で問題になりそうですが、エラーになった時に考えることにします。

```yaml
on:
  schedule:
    - cron: '0 0 * * *'
jobs:
  Deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: '16.13.1'
      - uses: actions/checkout@v2
      - name: Get yarn cache directory path
        id: yarn-cache-dir-path
        run: echo "::set-output name=dir::$(yarn cache dir)"
      - uses: actions/cache@v2
        id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
        with:
          path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-yarn-
      - name: Install dependencies
        run: yarn --frozen-lockfile
      - name: create secret json file
        env:
          JSON_FILE: ${{ secrets.JSON_FILE }}
          TO_PATH: secret.json
        run: echo $JSON_FILE > $TO_PATH

      - name: create env file
        env:
          ENV_FILE: ${{ secrets.ENV_FILE }}
          TO_PATH: .env
        run: echo -e $ENV_FILE > $TO_PATH
      - name: build
        run: yarn run build
      - name: exec
        run: yarn run exec
```

### 終わり

以上、サイトインデックス削除を自動化するコードの紹介でした。何故このようなコードを作る必要があるのかは来年のアドベントカレンダーのネタにするかもしれません。

明日は@guevara-netさん(ゲバさん)担当のapmlifyの記事です。