Censysを題材にウェブAPIのページ分割を学ぶ

作者

Shintaro Watanabe

公開

2024年12月30日

はじめに

2024年1月の記事「アドホックなログをSplunkに転送する」では、 httr2パッケージを利用して Splunk HEC(HTTP Event Collector)にログを転送する方法を紹介しました。

しかしhttr2の存在価値は、データの送信ではなく受信にあります。公式案内に「パイプ処理可能なAPIを提供し、明示的なリクエストオブジェクトを使用することで、たとえば組み込みのレート制限や再試行、OAuth、秘密情報の安全な管理といった、APIをラップするパッケージが直面する問題を(既存のhttrよりも)数多く解決します」とある通り、ウェブAPIからのデータ取得に必要な処理が関数化されているのが強みです。

この記事では、それらの処理の中でもページ分割(Pagination)を取り上げることにします。認証やレート制限よりもサイトごとの差異が出るので、コツをつかむ必要があるためです。そして題材とするウェブAPIは、Censys Search APIにします。Bob Rudis氏がcensysパッケージを作成されているのですが、旧APIにしか対応していないので、新API対応コードを例示するのは実用的でしょう。

ページ分割の概要

ページ分割とは、データを複数のページに分割して提供する仕組みです。Googleで何らかの単語を検索したとき、検索結果には最初の10件程度が表示され、ウェブページの下部でページ移動ができるようになっています。それです。もし仮にページ分割がなく数百万の結果を1枚に全件表示させたなら、長大な読み込み時間を要することになるでしょう。

ウェブAPIでも、ページ分割が実装されていることが一般的です。そして、実装方法には大きく分けて2通りあります。

第1は、OFFSETを使う方法です。「どこ(OFFSET)からいくつ(LIMIT)分」という指定で、人間には明解です。疑似的なHTTPリクエストを作るなら

GET /items?offset=100&limit=50

となります。SplunkのREST APIが、この方式を採用しています。

第2は、カーソルを利用する方法です。サーバーが結果を返すときに、前ページと次ページとの位置を表すランダムな文字列を提供します。利用者は、たとえば次のように次ページにアクセスします。

GET /items?cursor=eyJpZCI6MjB9&limit=50

第1の方式と比較して直感性は落ちますしランダムアクセスもしづらくなる反面、データの変更に強い利点があります。JJ Geewax『APIデザイン・パターン』(マイナビ出版、2022年)の第21章ではカーソル型が推奨されています。議論を詳しく知りたい場合には、ぜひ本書を手に取ってみてください。

CensysのSearch APIは、カーソル型を採用しています。ヘルプページから引用すると、レスポンスボディは次のような構造になります。(ちなみにデフォルトでは1ページあたり50件です。)

{
    "links":{ 
        "prev":"prevCursorToken", 
        "next":"nextCursorToken" 
    }, 
    [Rest of Response]
} 

単一結果を返すサーチ

Censys Search APIに親しむために、単一の結果を返すサーチ文を作ります。

リクエストの骨格

たとえばGoogle Public DNSのIPアドレスである「8.8.8.8」を検索したいとします。以下のコード片からスタートしましょう。

pacman::p_load(tidyverse, httr2)
resp <- 
  request("https://search.censys.io/api") %>% 
  req_url_path_append("v2/hosts/search") %>% 
  req_url_query(q="ip:8.8.8.8") %>% 
  req_dry_run()
GET /api/v2/hosts/search?q=ip%3A8.8.8.8 HTTP/1.1
Host: search.censys.io
User-Agent: httr2/1.0.7 r-curl/6.0.1 libcurl/8.10.1
Accept: */*
Accept-Encoding: deflate, gzip

上の例では、APIエンドポイントを指定し、機能ごとにパスを追加し、サーチ文をGETメソッドで定義します。最後にreq_dry_run()とあるのは確認の関数で、HTTPリクエストが返ります。実行するときにはreq_perform()とします。

しかし、このままでは動作しません。より正確に言うと、動作はしますが403エラーが返ります。なぜなら、Censys Search APIでは認証が必要とされているからです。

認証を追加する

Censys Search API Quick Start」に記載の通り、Censys Search APIではHTTP Basic認証が使われています。ユーザーID(API ID)とパスワード(Secret)とは、My Accountページに載っています。

認証情報を安全に保管するため、keyringパッケージを使ってOSに事前登録(一度きり)します。以下は一例で、ユーザー名やパスワードは架空のものです。

pacman::p_load(keyring)
keyring::key_set_with_value(
   "censys",
   username = "a1b23c4d-5e67-8f90-1a23-b4567890cdef",
   password = "a9BcD1EfGh2JkLmN3PqRsT4UvWxYz567"
)

このとき、ユーザー名を呼び出すにはkey_list("censys")$username、パスワードを呼び出すにはkey_get("censys", key_list("censys")$username)のようにします。

httr2では認証関連の関数が複数用意されていて、 Basic認証もサポートされています。関数req_auth_basic()を先のパイプラインに追加してみましょう。

pacman::p_load(keyring)
resp <- 
  request("https://search.censys.io/api") %>% 
  req_url_path_append("v2/hosts/search") %>% 
  # Basic認証を追加
  req_auth_basic(
    username = key_list("censys")$username,
    password = key_get("censys", key_list("censys")$username)
  ) %>% 
  req_url_query(q="ip:8.8.8.8") %>% 
  req_dry_run()
GET /api/v2/hosts/search?q=ip%3A8.8.8.8 HTTP/1.1
Host: search.censys.io
User-Agent: httr2/1.0.7 r-curl/6.0.1 libcurl/8.10.1
Accept: */*
Accept-Encoding: deflate, gzip
Authorization: <REDACTED>

レート制限を指定する

単一のリクエストを実行する段階ではレート制限(Rate Limits)を気にする必要はありませんが、のちのために準備しておきます。

Censysのレート制限は契約種別によって異なります。Solo(月額62米ドル)だと0.4/s、Teams(月額449米ドル)だと1/sとのことです。

httr2ではreq_throttle()を使ってレート制限を指定できます。下では「10秒間に4回」としました。

resp <- 
  request("https://search.censys.io/api") %>% 
  req_url_path_append("v2/hosts/search") %>% 
  req_auth_basic(
    username = key_list("censys")$username, 
    password = key_get("censys", key_list("censys")$username)
  ) %>% 
  # レート制限を追加
  req_throttle(rate = 4/10) %>% 
  req_url_query(q="ip:8.8.8.8") %>% 
  req_dry_run()
GET /api/v2/hosts/search?q=ip%3A8.8.8.8 HTTP/1.1
Host: search.censys.io
User-Agent: httr2/1.0.7 r-curl/6.0.1 libcurl/8.10.1
Accept: */*
Accept-Encoding: deflate, gzip
Authorization: <REDACTED>

レスポンスの処理

以上で準備が完了したので、実行します。

resp <- 
  request("https://search.censys.io/api") %>% 
  req_url_path_append("v2/hosts/search") %>% 
  req_auth_basic(
    username = key_list("censys")$username, 
    password = key_get("censys", key_list("censys")$username)
  ) %>% 
  req_throttle(rate = 4/10) %>% 
  req_url_query(q = "ip:8.8.8.8") %>% 
  req_perform()

リクエスト関連の関数req_で始まっているように、 レスポンス関連の関数resq_で始まります。

レスポンスボディを概観するには、resp_body_json()を使ってリスト化するとよいでしょう。str()glimpse()が有用です。

resp %>% 
  resp_body_json(simplifyVector = TRUE) %>%
  glimpse()
List of 3
 $ code  : int 200
 $ status: chr "OK"
 $ result:List of 5
  ..$ query   : chr "ip:8.8.8.8"
  ..$ total   : int 1
  ..$ duration: int 32
  ..$ hits    :'data.frame':    1 obs. of  6 variables:
  .. ..$ last_updated_at  : chr "2025-01-05T04:12:43.765Z"
  .. ..$ autonomous_system:'data.frame':    1 obs. of  5 variables:
  .. ..$ ip               : chr "8.8.8.8"
  .. ..$ dns              :'data.frame':    1 obs. of  1 variable:
  .. ..$ location         :'data.frame':    1 obs. of  8 variables:
  .. ..$ services         :List of 1
  ..$ links   :List of 2
  .. ..$ next: chr ""
  .. ..$ prev: chr ""

たとえばautonomous_systemフィールドを詳しく見るには、Purrrパッケージのpluck()を使います。

resp %>% 
  resp_body_json(simplifyVector = TRUE) %>% 
  pluck("result", "hits", "autonomous_system")
description bgp_prefix asn country_code name
GOOGLE 8.8.8.0/24 15169 US GOOGLE

上では空になっていますが、result$linksというフィールドが見えます。ページ分割された結果では、ここに文字列が入るのでしょう。

ページ分割のあるサーチ

Censysから単一の結果を取得する方法が理解できたので、ページ分割を取り扱います。

サンプルのサーチ文として、日本国内においてPalo Alto NetworksのGlobalProtect VPNサービスが動作しているという条件で検索してみましょう。クエリは次の通りです。

query <- r"(services.software: (vendor: "Palo Alto Networks" and product: "GlobalProtect") 
            and location.country: "Japan")"

デフォルトでは1ページ50件ですが、以下では時間と費用を節約するため5件としています。

resp <- 
  request("https://search.censys.io/api") %>% 
  req_url_path_append("v2/hosts/search") %>% 
  req_auth_basic(
    username = key_list("censys")$username, 
    password = key_get("censys", key_list("censys")$username)
  ) %>% 
  req_throttle(rate = 4/10) %>% 
  # 1ページに出力する件数を5件に指定(デフォルト50件、最大100件)
  req_url_query(per_page = 5) %>% 
  req_url_query(q = query) %>% 
  req_perform()
resp %>% 
  resp_body_json(simplifyVector = TRUE) %>%
  glimpse()
List of 3
 $ code  : int 200
 $ status: chr "OK"
 $ result:List of 5
  ..$ query   : chr "services.software: (vendor: \"Palo Alto Networks\" and product: \"GlobalProtect\")              and location.co"| __truncated__
  ..$ total   : int 7187
  ..$ duration: int 96
  ..$ hits    :'data.frame':    5 obs. of  8 variables:
  .. ..$ location         :'data.frame':    5 obs. of  8 variables:
  .. ..$ last_updated_at  : chr [1:5] "2025-01-05T12:30:41.489Z" "2025-01-05T05:38:04.106Z" "2025-01-05T04:15:14.436Z" "2025-01-05T02:37:56.196Z" ...
  .. ..$ autonomous_system:'data.frame':    5 obs. of  5 variables:
  .. ..$ ip               : chr [1:5] "165.1.232.140" "118.21.97.176" "121.119.210.18" "133.83.166.2" ...
  .. ..$ operating_system :'data.frame':    5 obs. of  7 variables:
  .. ..$ services         :List of 5
  .. ..$ matched_services :List of 5
  .. ..$ dns              :'data.frame':    5 obs. of  1 variable:
  ..$ links   :List of 2
  .. ..$ next: chr "eyJhbGciOiJFZERTQSJ9.eyJub25jZSI6IjNOZHJvV0xWTnN0UXRTaUNiNmRNVTNoK0VxT3E4YlIzVFR1eE1TM2JtUTQiLCJwYWdlIjoyLCJyZX"| __truncated__
  .. ..$ prev: chr ""

全体で8,555件あるそうです。1ページ5件とすると、1,712回の繰り返しが必要です。

カーソルを含む反復的なリクエストを実現するために、httr2は req_perform_iterative()関数を用意しています。

まず、ヘルパー関数として、レスポンスから次ページの文字列を取得としてリクエストを加工する関数を作ります。下において、「next」をバックティックで囲んでいる点に注意してください。nextはRのキーワードなので、エスケープする必要があります。

next_req <- function(resp, req) {
  cursor <- resp_body_json(resp)$result$links$`next`
  if (!is.null(cursor)) {
    req %>% req_url_query(cursor = cursor) 
  }
}

Censysのヘルプページによれば、リクエストパラメーター「cursor」でカーソル文字列を指定することで、特定のページを表示させられるそうです。そこで、リクエストオブジェクトにパラメーターを追加する処理を入れています。

最後に、先ほどのリクエストでreq_perform()としていた部分をreq_perform_iterative()に修正し、ヘルパー関数を指定します。また、ここでは最大3ページ分しか取得しないようにしています。

resps <- 
  request("https://search.censys.io/api") %>% 
  req_url_path_append("v2/hosts/search") %>% 
  req_auth_basic(
    username = key_list("censys")$username, 
    password = key_get("censys", key_list("censys")$username)
  ) %>% 
  req_throttle(rate = 4/10) %>% 
  req_url_query(per_page = 5) %>% 
  req_url_query(q = query) %>% 
  # 反復的にreq_perform()を実行する
  req_perform_iterative(next_req = next_req, max_reqs = 3)
Iterating ■■■■■■■■■■■                       33% | ETA:  4s
Iterating ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■  100% | ETA:  0s

この例では、3回リクエストを投げたことになります。そのためrespsは、各ページのレスポンスオブジェクトを要素として持つ要素数3のリストです。このことをstr()で確認しましょう。紙面節約のため、max.levelオプションを使って階層を掘らないようにします。

str(resps, max.level = 1)
List of 3
 $ :List of 7
  ..- attr(*, "class")= chr "httr2_response"
 $ :List of 7
  ..- attr(*, "class")= chr "httr2_response"
 $ :List of 7
  ..- attr(*, "class")= chr "httr2_response"

ここからサーチ結果を1つに集約するには、Purrrパッケージの関数群を使えばよいでしょう。下は一例で、AS関係のデータだけを抽出しています。jqに慣れているなら、resp_body_stringでJSON文字列として取得し、加工自体はjqで行うほうがよいかもしれません。

bodies_json <- map(resps, resp_body_json, simplifyVector = TRUE)
bodies_df <- map_dfr(bodies_json, pluck, "result", "hits") 
bodies_df %>% 
  select(autonomous_system) %>% 
  unnest(autonomous_system) %>% 
  select(bgp_prefix, asn, description)
bgp_prefix asn description
165.1.192.0/18 394089 GCP-ENTERPRISE-USER-TRAFFIC
118.16.0.0/13 4713 OCN NTT Communications Corporation
121.112.0.0/13 4713 OCN NTT Communications Corporation
133.83.0.0/16 2907 SINET-AS Research Organization of Information and Systems, National Institute of Informatics
150.18.0.0/16 23793 AIST National Institute of Advanced Industrial Science and Technology
165.85.0.0/18 394089 GCP-ENTERPRISE-USER-TRAFFIC
15.168.0.0/16 16509 AMAZON-02
153.240.0.0/13 4713 OCN NTT Communications Corporation
219.117.144.0/20 23784 POLEXCHENGE SQUARE ENIX CO., LTD.
153.240.0.0/13 4713 OCN NTT Communications Corporation
56.155.0.0/17 16509 AMAZON-02
122.216.0.0/14 17506 UCOM ARTERIA Networks Corporation
153.128.0.0/10 4713 OCN NTT Communications Corporation
18.182.0.0/16 16509 AMAZON-02
13.208.0.0/16 16509 AMAZON-02

おわりに

以上、httr2パッケージを使うと比較的容易にウェブAPIから情報が引き出せることを見てきました。

今回はアドホックな例ですが、パッケージ化を考えるのであれば エラー処理に関する関数も活用できます。

ウェブAPIはサーチ件数に制約があるので、req_cache()も検討する価値があります。これを使えば、たとえば同一のIPアドレスが繰り返し出現するようなケースでは、サーチ回数を節約するためにuniq()して別でサーチしておき、その結果を左結合することがあるかもしれません。req_cache()を使えば同一IPアドレスの結果はキャッシュを使いAPIのカウントを消費しないので、こうした回り道をせずにすむ可能性があります。