trocco®︎とGoogle Custom Search APIでUGCを発掘する

# はじめに

私が技術調査や仕様検証をするとき、公式ドキュメントはもちろん重要ですが、QiitaやZennの技術記事を参考にすることもよくあります。というか本当に色々な記事に助けられながら知見を深めていっています。みなさんいつもありがとうございます（笑）

弊社primeNumberが提供しているtrocco®︎についても、ユーザーの方の技術記事は、既存ユーザーの利用拡大や、新規ユーザーのサービス理解に役立っているでしょう。そして更には、社内でプロダクト自体を改善していくためにも参考にしています。

そこで、ユーザーの方の技術記事（UGC／Uger Generated Contents）をいかに発掘するかというのがポイントになってきます。

trocco®︎を利用したQiita記事の発掘と共有については、タイトルはやや異なりますが[「Google Apps Scriptでデータを組織に流通させる：①BigQueryにクエリを叩いてSlackに投稿する」](https://qiita.com/SoySoySoyB/items/ac5ea008caef64dbe91c#qiita-api%E3%81%A7%E7%89%B9%E5%AE%9A%E3%81%AE%E3%82%AD%E3%83%BC%E3%83%AF%E3%83%BC%E3%83%89%E3%82%92%E5%90%AB%E3%82%80%E8%A8%98%E4%BA%8B%E3%82%92trocco%E3%81%A7%E5%8F%96%E5%BE%97%E3%81%99%E3%82%8B)の記事でまとめていますが、もう少し一般のものを拾うにはどうすればいいのだろうかとずっと考えていました。Googleの検索結果から拾えればいいのですが、直接スクレイピングするわけにもいかないですし。

と、しばらく解決策がなく塩漬けにしていたのですが、最近Google Custom Search APIというものを発見して、これでやりたいことができるとわかりました。ということで、今回はtrocco®︎でUGCを発掘する方法についてまとめようと思います。


# こんな方におすすめ

- 技術記事を発掘したい
- 特定のキーワードに関連する新着Webコンテンツを発掘したい


# コンテンツ検索の仕組み

Google検索で何らかのキーワードを入れて検索すると、通常は関連性が高い順に並んだ検索結果が表示されます。知りたいことを知るためにこれは便利なものですが、最新順で上から並んでくれれば、新しいコンテンツが発見できると思いませんか？

ただ、いちいち手で検索して上から見るのも面倒ですし、やっぱり自動化したいとなると、通常のGoogle検索では不十分です。そこで、様々なカスタマイズをした検索結果をデータで提供するサービスとして、「Custom Search API」というものがあります。

このAPIでは、エンドポイントを叩くことでカスタムの検索結果をJSONで取得することができます。ということは、

- APIとしてカスタムの検索方法を事前に定義しておく
- trocco®︎で事前定義した検索結果のデータを自動取得する
- 取得したデータを連携する

という流れで、特定のキーワードの新着コンテンツを自動で発掘できるようになるのです。

今回は、Zennの技術記事の発掘を例に、trocco®︎によるコンテンツ検索のやり方を紹介します。


# Google Custom Search APIとは

[公式ドキュメント](https://developers.google.com/custom-search/v1/overview?hl=ja)によると、

:::note
Custom Search JSON API を使用すると、プログラム可能検索エンジンから検索結果を取得して表示するウェブサイトやアプリケーションを開発できます。この API では、RESTful リクエストを使用して、ウェブ検索または画像検索の結果を JSON 形式で取得できます。
:::

というもので、カスタムの検索画面を表示できるもの（検索画面の提供）と別に、検索結果のデータのみを取得できるものがあります。

利用にあたっての前提条件としては、

> - 検索エンジン ID
>     - Custom Search JSON API を使用するには、まずプログラム可能検索エンジンを作成して設定する必要があります。プログラム可能検索エンジンをまだ作成していない場合は、まずプログラム可能検索エンジンのコントロール パネルにアクセスします。
> - API キー
>     - Custom Search JSON API では API キーを使用する必要があります。

の2つを準備する必要があり、料金は下記の通りです。

> Custom Search JSON API では、1 日あたり 100 件の検索クエリを無料で利用できます。これを超えて必要な場合は、API Console でbillingをお申し込みいただけます。追加リクエストの料金は、クエリ 1, 000 クエリあたり $5 で、1 日あたり 1 万クエリまでです。


# Google Custom Search APIの事前準備

まず、APIを利用するための設定を行ったあとに、プログラム可能検索エンジンの作成を進めていきます。


## Custom Search APIを有効化／APIキーの取得

はじめに、Google Cloudの[Custom Search API](https://console.cloud.google.com/apis/api/customsearch.googleapis.com/)でAPIを有効化します。また、Google Cloudの[認証情報](https://console.cloud.google.com/apis/credentials)でAPIキーを作成します。このAPIキーを後ほど認証のために利用します。


## プログラム可能検索エンジンの作成

[検索エンジン作成のコントロールパネル](https://programmablesearchengine.google.com/controlpanel/all)で「追加」をクリックすることで、検索エンジンを新規作成します。例として、`zenn.dev`に検索対象を限定した検索エンジンとします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3492462/83ae6746-f8d6-9a21-10ec-5e0b87147415.png)

作成すると、検索エンジンIDが発行されます。これを後ほど利用します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3492462/911b833f-9b36-f416-043b-e8da686eb8bf.png)

下部にリンクがあり、ここから利用のための公式ドキュメントにアクセスすることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3492462/464f8b53-f487-896d-1303-0cabf51d0a91.png)

なお、2種類ある違いについては下記の通りです。

> プログラム可能検索エンジンで検索対象が特定のサイト（10 サイト以下）のみに制限されている場合は、Custom Search Site Restricted JSON API を使用できます。この API は Custom Search JSON API に似ていますが、このバージョンに 1 日あたりのクエリ上限はありません。このバージョンを使用するには、プログラム可能検索エンジンのコントロール パネルの [検索するサイト] セクションに、検索するサイトが 10 個以下であること、グローバル トップレベル ドメイン パターンがないこと、[ウェブ全体を検索] がオフに設定されていることを確認してください。（出典：[公式ドキュメント](https://developers.google.com/custom-search/v1/site_restricted_api?hl=ja)）


# trocco®︎でGoogle Custom Search APIを叩く

これで事前準備ができたので、データ取得のための設定を進めていきます。APIを叩いてJSONデータを取得するので、転送元HTTPでデータを取得して、BigQueryに格納します。APIのエンドポイントは`https://www.googleapis.com/customsearch/v1`になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3492462/1ad6cdcd-d974-1e4c-d065-b94e6939a9fc.png)

APIの仕様として1リクエストで10コンテンツ分の検索結果が取得できますが、もう少しまとまった数を取得したいため、ページング設定としてオフセットベースを選択し、必要な値を入力します。

各種パラメータは、下記のように設定します。

- Key: API Keyの値
- cx: 検索エンジンの値
- q: 検索クエリのキーワード
- sort: date（新着順にソート）
- num: 取得数: 10件（最大値）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3492462/d99dd1c6-a7e1-7319-2c6e-6bd1b79f09d5.png)

なお、オフセットベースの設定ですが、これは一度のリクエストで返すデータ量に制限があるとき、例えば全体で100件あるのに10件しか戻ってこないときには、何回も取得する必要があり、こういった対応で利用するものです。

今回の設定では、10件ずつ取得していくので、

- 1回目: start = 1　⇒　1～10件目を取得
- 2回目: start = 1 + 10 = 11　⇒　11～20件目を取得
- …
- 10回目: start = 1 + 9 * 10 = 91　⇒　91～100件目を取得

というのを自動で行っており、1度の転送処理で100件分をまとめて取得できるようになります。

これで取得の設定ができたので、BigQuery側の設定を適宜行います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3492462/d5426592-c711-cdf6-d7c8-aea61ef9e860.png)

自動データ設定を行うと下図のようになりますが、記事データが入っている`items`のJSONデータは別途SQLでパースするので、このままの設定にしておきます。なお、別途処理にするのは取得した1レコードに対して、記事は10件ずつ入っているためです。また転送日時カラムとして、`trocco_transferred_at`を追加しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3492462/c5c75185-803b-9048-265d-1292a719793a.png)

これで、下図のようなデータが取得できます。itemsの部分に記事データがまとめて入っています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3492462/ff54722b-4d6b-cd09-ed2a-58bd7fe1b04a.png)


# 記事データをSQLでパースする

最後に、`items`の部分の記事データをパースします。JSONデータのパースのためのクエリなので、慣れない人はほえ～と思いながら見てください。

```sql
select
  split(json_value(i, '$.link'), '/')[3] as id,
  split(json_value(i, '$.link'), '/')[4] as type,
  json_value(i, '$.link') as url,
  json_value(i, '$.title') as title,
  json_value(i, '$.snippet') as snippet,
  coalesce(
    parse_date('%b %e, %Y', regexp_extract(json_value(i, '$.snippet'), '^[A-Z][a-z]* [0-9]*, [0-9]{4}')),
    date_sub(date(trocco_transferred_at, 'Asia/Tokyo'), interval cast(regexp_extract(json_value(i, '$.snippet'), '^([0-9]*) days ago') as integer) day),
    parse_date('%Y/%m/%d', regexp_extract(json_value(i, '$.snippet'), r'[0-9]{4}\/[0-9]{2}\/[0-9]{2}'))
  ) as guessed_date_published
from
  `project_id.dataset_id.zenn_google_custom_search_trocco`
left join
  unnest(json_query_array(items)) i
where
  array_length(split(json_value(i, '$.link'), '/')) > 4
  and not starts_with(json_value(i, '$.link'), 'https://zenn.dev/topics/')  -- topics
  and not starts_with(json_value(i, '$.link'), 'https://zenn.dev/p/')  -- publication
order by
  guessed_date_published desc
```

これでこうなります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3492462/29c5a421-0dcb-a3bb-5578-8fe127184816.png)


簡単に解説します。JSONデータとは、下記のような形式のデータで、KeyとValueが対応した構造になっているデータです。階層化されていたり、配列形式になっていることもあります。

```json
{
  "key1": "val1",
  "key2": "val2",
  "key3": 
    {
      "key4": "val4"
    }
}
```

`items`については、1つのデータに10記事分が入っているため、`[{1記事目分}, {2記事目分}, ..., {10記事目分}]`のようなデータになっていて、それを`unnest(json_query_array(items)) i`で1行1記事のデータに分割しています。この分割ができると、例えば`json_value(i, '$.link')`で記事のURLが取得できます。上2つは、記事URLからIDと種別（Article／Book／Scrap）を判別しています。

スニペット（検索時のURL下部に出てくるような文章）に日付や何日前のような情報がある場合は、下記のクエリで正規表現によって抽出して、記事の公開（更新？）日として算出しています。

```sql
  coalesce(
    parse_date('%b %e, %Y', regexp_extract(json_value(i, '$.snippet'), '^[A-Z][a-z]* [0-9]*, [0-9]{4}')),
    date_sub(date(trocco_transferred_at, 'Asia/Tokyo'), interval cast(regexp_extract(json_value(i, '$.snippet'), '^([0-9]*) days ago') as integer) day),
    parse_date('%Y/%m/%d', regexp_extract(json_value(i, '$.snippet'), r'[0-9]{4}\/[0-9]{2}\/[0-9]{2}'))
  ) as guessed_date_published
```

また、検索結果としては不要なものも混ざってきます。ここでは、トピックスとパブリケーションの記事一覧ページや、ユーザーのトップページを除外しています。

```sql
where
  array_length(split(json_value(i, '$.link'), '/')) > 4
  and not starts_with(json_value(i, '$.link'), 'https://zenn.dev/topics/')  -- topics
  and not starts_with(json_value(i, '$.link'), 'https://zenn.dev/p/')  -- publication
```

ということで、このようにSQLでデータの整形が可能ですが、trocco®︎のデータマート定義に設定することで、自動処理が可能になります。


# さいごに

ここまでが一連の流れになります。転送設定とデータマート定義をワークフローとしてまとめて、スケジュール実行してあげれば、毎日自動でコンテンツを発掘することができます。便利ですね。

今回は`zenn.dev`を対象にしていますが、対象とするURLを調整してあげると、それだけ広げていくことができますし（1検索エンジンの設定で複数ドメインも可能）、検索全体に広げることも可能です。もちろん、検索対象の件数とノイズのハンドリングを見ながらということになりますが、これらが全部自動化できるとかなり良さそうです。


# 参考

https://qiita.com/zak_y/items/42ca0f1ea14f7046108c