Skip to main content

netkeiba のデータをスクレイピングして LOD 化する(4)

Kiai

JSON-LD のコンテキストもどうにか定義し,半信半疑だった API Gateway を活用したサーバ負荷回避 リクエスト制限の回避も実現できた. これでようやくスクレピングによるデータ収集が始められる.

今回得た知見は,後ほど Zenn にまとめることとする.

全てのコードは Ningensei848/ML4Keiba においてある. シンプルな Node.js プロジェクト内部に Poetry プロジェクトを同居させた構成となっている.

API Gateway による多重リクエスト制限の回避

DoS 攻撃としてよく知られているように,短時間に極めて大量のリクエストをサーバに送ることは,それだけで悪意ある攻撃と見做されても仕方がない. リクエストをさばくロードバランサやプロキシサーバがリクエスト送信者の同一性を検知し,一定量を超えるとそれ以上応答が帰ってこなくなる. このような事態に陥ると,一定時間を待機すれば制限が解除される場合もあれば,永遠にその IP からはアクセスできなくなるという場合もある.

これがすなわち何らかの刑事罰に直結するというわけではないが,機械的なリクエストはあくまでジェントルに,悪意の有無に関わらず利己的な操作はすぐに排除されるものだと心に刻んでスクリプトを組まねばならない.

が,逆説的に,IP が同一のものでなければシャットダウンする理由はない. 別の IP からのリクエストを見比べて,それが同一のユーザから送られたものであると判断するには材料が足りないし,なによりすべてのリクエストに対してそんな処理を挟む余裕もない.

俗に「IP アドレスローテーション」とかいう work around らしい.

Cloud Functions (Gen2) を生やして Gateway でまとめる

プロキシサーバを用意する……というとなんか難しそうな,フロントエンド人類には大変厳しい世界が待っていそうな気配がする. と思われたのもつかの間,Google Cloud Functions をつかってリクエストを代理させるエンドポイントを用意し,大量のエンドポイント URL に対して API Gateway で一つにまとめてやればよいことに気がついた.

もちろん,コンソール画面でポチポチするのは大変厳しい.それが GUI の限界である. しかし,GCP には Cloud SDK が用意されており,そのうち gcloud CLI を使えば上記操作が半自動化できる.


というわけで用意したのが こちら である.

index.ts は実際に Function としてデプロイされるスクリプトだ. 単にクエリパラメータを読んで,そこに含まれた URL にリクエストを投げ,返ってきたデータをそのままこちらへ返してくれる.

deploy.tsopenapi.ts は,ローカルで Cloud Functions と API Gateway に関する諸々の処理を全部やってくれるスクリプトだ. namelist.txt に書き連ねたエントリポイントを元に Functions を生やし,その情報をもとに openapi.yaml を作成する.

と,上記の処理はすべて npm run exec:all --name=NAME --project=projectName で実行できるように NPM スクリプトに書いてある. これを有効化するために npm run gateway:describe:api --name=NAME --project=projectName で API インスタンスの URI を入手し,gcloud services enable my-api-123abc456def1.apigateway.my-project.cloud.goog で有効化すれば完了だ.

この実行が完了したら,npm run gateway:describe --name=NAME --project=projectName で実際に作成されたエンドポイントの情報を得られる. namelist.txt に書き連ねた名前の分だけ Cloud Functions に関数がデプロイされ,エントリポイントが生える. 実際に Python 等でスクレイピングする際には,この namelist.txt からパスを作成し,エンドポイントを叩く際にそのパスを参照させればよい.

データ構造とディレクトリ構成

集めたデータはすべて /data 以下に置いた. GitHub のリポジトリ容量制限に引っかかるかもしれないな~~と思いつつ,流石にそこまでは肥大化しないだろうと高を括っている.

現段階では,主に horse, race で大分類を行なった. 将来的には,jockey, trainer , owner, breeder のディレクトリをつくることになるだろう.

その直下には,csv, json, list といったデータの構造に関するディレクトリ構成としたが,これは冗長だしわかりにくいかもしれない. 二度手間ではあるが,この階層は消して,すぐ直下に YYYY 等が置かれるように変更するかもしれない.

前回の記事 で検討したように,各馬ごとに一ファイルを割り当てる方針 でディレクトリをつくる. 馬自身の血統やその他プロフ,戦績等は horse_id に紐づいているから,これを分割してやるのが都合が良い. YYYY で生年,XXXX で小分類,ZZ.tsv とすることで,各小分類には最大でも 100 ファイル程度しか格納されないようにした(ただし,ID に数字以外が含まれている場合は,この限りではない).同様にレース情報も race_id に紐づくようにした.

毎日決まった時間にデータを更新

GitHub Actions でスクレイピングを実行する. リクエストの大元の送信者が IP で BAN されるとは考えにくいし,負荷分散をやったことで現実的な時間で処理を終えることができる.

サラッと流したが,スクレイピングを実際に行なう Python コードはこちらにおいてある. 負荷分散を効率よく行なうために,HTTP リクエストのデファクトスタンダードであった requests を使うのではなく,aiohttp を採用した.

また,実装にはasyncio, aiohttp を利用した並列処理のサンプルコード | GitHub Gist を大いに参考にした. coroutine() という url, response を受け取る任意のコルーチン(並列実行したい処理を行なう関数(?))を定義するだけで使えることがわかるだろう.

まとめ

JSON-LD について触れていないのは,いまのところその定義に自信が持てていないからだ. 間違っている可能性も大いにあるため,今の段階では言及しない.

これまで,

  • Cloud Functions
  • API Gateway
  • ディレクトリ構成の検討
  • スクレイピング

をやってきた.

どうにかベースとなるデータはきれいに集まりそうである. が,まだまだそれはシード値として使えるというだけで,まったく全体を網羅することは出来ていない. 特に,血統情報が重要なのはわかりきっているのに,いまだ子だけで,父母,祖父母……について遡ることは出来ていない. 血統を遡ってスクレイピングするのは非常に手間がかかるであろうことはわかるので,またおいおいやっていく.

また次回 ✋