GitHub検索APIでGitHub上のRパッケージを見つけるには?

Gepuro Task Viewsがあるでしょ、というツッコミが入りそうですが、意外とAPIでできたりしないかな?と思ったのでやってみた時のメモです。

結果とかコードはここに散らばっています(未整理):

GitHub検索APIの種類

GitHubの検索は、今のところ以下の種類があります。

この中で使えそうなものを考えてみましょう。

レポジトリ検索

レポジトリ検索ではlanguage:という記法で、レポジトリを言語で絞り込むことができます。

ということで、language:rでやれば勝利なのでは?と思うかもしれませんが、GitHubの言語判定は必ずしもあてにならなくて、例えばRcpp関連とかはC++のファイルがほとんどなので引っかかりません。

例えば、以下のようなクエリを投げても、RcppCore/RcppEigenは引っかかりません。

language:r rcppeigen

ユーザ検索

ユーザ検索でも同じく言語を絞り込めますが、上と同じ理由であてになりません。例えば以下のクエリで私は引っかかりません。

language:r yutannihilation

なぜかJavaScriptの人ということになってるんですが、たぶんRmarkdownから生成されるウェブサイト系にJavaScriptがたくさん含まれているからなんだと思います…

コード検索

残されたのはコード検索です。一つ前の記事の知識から、Rのパッケージには最低でもDESCRIPTIONNAMESPACEがあることがわかっています。

コード検索ではfilename:という記法でファイル名を検索できます。これに指定すればいいわけですが、問題はこの検索が完全一致ではないのでnamespace.cとかnamespace.phpみたいなやつが大量に引っかかることです。

コード検索ではまた、拡張子で絞り込むためのextension:が使えます。ここでもしNAMESPACENAMESPACE.Rとかいう名前だったらextension:rでかなり絞り込めたんですが、拡張子はないので、地道に違うやつを取り除いていくしかありません。

-を付けるとNOT検索になるので、とりあえず以下の拡張子のファイルを取り除きます。

extensions <- c(
  "c", "h",         # C
  "pl", "pm",       # Perl
  "html", "htm",    # HTML
  "cpp", "hpp",     # C++
  "java",           # Java
  "js",             # JavaScript
  "3", "n",         # Roff
  "ts",             # TypeScript
  "xml",            # XML
  "diff", "patch",  # Diff
  "cmd",            # Batchfile
  "rb",             # Ruby
  "php",            # PHP
  "cs",             # C#
  "txt",            # Text
  "json",           # JSON
  "e",              # Efieel
  "dart",           # Dart
  "dot",            # Graphviz
  "ll",             # LLVM
  "lisp",           # Lisp
  "py",             # Python
  "rst",            # reStructuredText
  "props",          # Weka?
  "plist",          # ?
  "scala",          # Scala
  "cmake",          # CMake
  "m"               # Objective-C
)

クエリを組み立てればこんな感じになるはずです。

extensions_query <- sprintf("-extension:%s", extensions) %>%
   paste(collapse = " ")
extensions_query
#> [1] "-extension:c -extension:h -extension:pl -extension:pm -extension:html -extension:htm ..."

GitHub検索APIの制限

ではこれでクエリを投げてみるか、という感じですが、ちょっと待ってください。まだだめです。GitHub検索APIのドキュメントをもうちょっと読んでみるとこんなことが書かれています。

Think of it the way you think of performing a search on Google. It’s designed to help you find the one result you’re looking for (or maybe the few results you’re looking for). Just like searching on Google, you sometimes want to see a few pages of search results so that you can find the item that best meets your needs. To satisfy that need, the GitHub Search API provides up to 1,000 results for each search.

ちょっと長めに引用しましたが、重要なのは最後の部分です。GitHub検索APIは先頭1000件までしか返してくれないのです。

GitHub上にあるRのパッケージは1000は優に超えるでしょう。このままでは検索できないので、もっとノイズを取り除いたうえで、検索結果を分割して2000件以下(昇順と降順に並び替えれるので先頭1000件と末尾1000件、あわせて2000件にはアクセスできる)になるようにする必要があります。

NAMESPACEDESCRIPTION

これは、何回か試しただけですが、DESCRIPTIONの方がノイズが多そうな感じでした。ということでNAMESPACEに賭けてみることにします。

大量にレポジトリを持っているユーザを除外する

ミラー用ユーザ

以下のユーザは、CRAN、Bioconductor、RForgeのミラーです。

ミラーなのでこのユーザのレポジトリが本体ではありません。除外してしまいましょう。

-user:cran -user:Bioconductor-mirror -userrforge

レポジトリを大量に持っているユーザ

大量に持っているといっても、1000はいかないので、そういうユーザは、

user:user1

で絞り込めばすぐにレポジトリの一覧をとれます。ここでは、そういうのに引っかかってこないレポジトリだけに絞り込みましょう。

ということで、まずはユーザ検索でレポジトリを100個以上持っているユーザを調べます。レポジトリ数の絞り込みにはrepos:という記法を使います。ここはある程度雑でもいいのでlanguage:rを使います。

ghパッケージを使ってみます。

res <- gh::gh("/search/users", q = "language:r repos:>100", page = 1L, per_page = 100L)

結果は3つの要素を持つリストになっています。total_countは検索結果の数、incomplete_resultsは時間内に検索が終わったか(これがTRUEの場合、結果がさらにある可能性がある)、itemsは検索結果です。

names(res)
#> [1] "total_count"        "incomplete_results" "items"             

res$total_count
#> [1] 229

res$incomplete_results
#> [1] FALSE

ユーザはこんな感じです。

purrr::map_chr(res$items, "login")
#>   [1] "hadley"             "jeroen"             "kbroman"            "hrbrmstr"           "karthik"           
#>   [6] "sckott"             "leeper"             "seankross"          "olgabot"            "cpsievert"

直感を働かせてさらにユーザを除外

検索してると、なんかこいつノイズっぽい、というやつがでてくるので、その辺は直感で除外しましょう。

ユーザ除外クエリを組み立てる。

こんな感じです。

users_query <- c("cran", "Bioconductor-mirror", "rforge", purrr::map_chr(res$items, "login")) %>%
    sprintf("-user:%s", .) %>%
    paste(collapse = " ")

users_query
#> [1] "-user:cran -user:Bioconductor-mirror -user:rforge -user:hadley ..."

キーワードで絞り込み

あと、ちょっとだけキーワードを加えて絞り込みます。

export

NAMESPACEは空でもいいですが、とはいえexportしていないと関数をなにも使うことができません。ということで、これはたぶんある、はず(あってる?)。

NOT D1tr

なんかよくわからないんですけど、ESSの中に入っている練習用のパッケージ?かなにかみたいです。必要ないので除外。

NAMESPACEが一番上のディレクトリにあるかでリクエストを分割

Rパッケージのためだけのレポジトリでは、NAMESPACEは一番上の階層に置かれます。しかし、ものによっては更にディレクトリを掘ってその中に入れることもあります。

github.com

ということで、path:/-path:/でリクエストを分けましょう。

packrat

注意点として、ディレクトリをさらに掘っている場合は、packratなどのようにパッケージのバージョンを固定するために作られたディレクトリがそのままGitに入っていることがあります。これを取り除くために、-path:/には-path:packratも指定しましょう。

キーワードを使い分けてリクエストを分割

あと、NAMESPACEに入ってそうなキーワードとしては以下のものがあります。

  • exportPattern
  • S3method
  • import

この辺もexportPatternNOT exportPatternとかを組み合わせてリクエストを分割していきましょう。

最終的にできたクエリ

こんな感じになりました。

filename:NAMESPACE fork:false export NOT D1tr path:/ exportPattern import {extensions_query} {users_query}
filename:NAMESPACE fork:false export NOT D1tr path:/ exportPattern NOT import {extensions_query} {users_query}
filename:NAMESPACE fork:false export NOT D1tr path:/ NOT exportPattern S3method {extensions_query} {users_query}
filename:NAMESPACE fork:false export NOT D1tr path:/ NOT exportPattern NOT S3method import {extensions_query} {users_query}
filename:NAMESPACE fork:false export NOT D1tr path:/ NOT exportPattern NOT S3method NOT import {extensions_query} {users_query}
filename:NAMESPACE fork:false export NOT D1tr -path:/ -path:packrat exportPattern import {extensions_query} {users_query}
filename:NAMESPACE fork:false export NOT D1tr -path:/ -path:packrat exportPattern NOT import {extensions_query} {users_query}
filename:NAMESPACE fork:false export NOT D1tr -path:/ -path:packrat NOT exportPattern S3method {extensions_query} {users_query}
filename:NAMESPACE fork:false export NOT D1tr -path:/ -path:packrat NOT exportPattern NOT S3method import {extensions_query} {users_query}
filename:NAMESPACE fork:false export NOT D1tr -path:/ -path:packrat NOT exportPattern NOT S3method NOT import {extensions_query} {users_query}

コード検索

だいたいこんな感じのコードです(ちょっと端折って書いたので動くかは不明)。

do_search <- function(page, query, sort = "indexed") {
  message("requesting page ", page, " ...")
  
  res <- gh::gh("/search/code",
                q = query,
                sort = sort,
                page = page,
                per_page = 100)
  
  message(sprintf("total count: %d, incomplete: %s", res$total_count, res$incomplete_results))
  
  repo     <- purrr::map_chr(res$items, c("repository", "name"))
  owner    <- purrr::map_chr(res$items, c("repository", "owner", "login"))
  filename <- purrr::map_chr(res$items, "name")
  path     <- purrr::map_chr(res$items, "path")

  tibble::tibble(owner, repo, filename, path)
}


queries_tmpl <- c(
  "filename:NAMESPACE fork:false export NOT D1tr path:/ exportPattern import {extensions_query} {users_query}",
  "filename:NAMESPACE fork:false export NOT D1tr path:/ exportPattern NOT import {extensions_query} {users_query}",
  "filename:NAMESPACE fork:false export NOT D1tr path:/ NOT exportPattern S3method {extensions_query} {users_query}",
  "filename:NAMESPACE fork:false export NOT D1tr path:/ NOT exportPattern NOT S3method import {extensions_query} {users_query}",
  "filename:NAMESPACE fork:false export NOT D1tr path:/ NOT exportPattern NOT S3method NOT import {extensions_query} {users_query}",
  "filename:NAMESPACE fork:false export NOT D1tr -path:/ -path:packrat exportPattern import {extensions_query} {users_query}",
  "filename:NAMESPACE fork:false export NOT D1tr -path:/ -path:packrat exportPattern NOT import {extensions_query} {users_query}",
  "filename:NAMESPACE fork:false export NOT D1tr -path:/ -path:packrat NOT exportPattern S3method {extensions_query} {users_query}",
  "filename:NAMESPACE fork:false export NOT D1tr -path:/ -path:packrat NOT exportPattern NOT S3method import {extensions_query} {users_query}",
  "filename:NAMESPACE fork:false export NOT D1tr -path:/ -path:packrat NOT exportPattern NOT S3method NOT import {extensions_query} {users_query}"
)

queries <- map_chr(queries_tmpl, glue::glue)


for (i in seq_along(queries)) {
  csv_dir <- file.path(RESULT_NAMESPACE_DIR, sprintf("query%d", i))
  dir.create(csv_dir, showWarnings = FALSE)
  page_namespace <- get_next_page(csv_dir)
  
  if (page_namespace >= 10) {
    message(sprintf("%s has already 1000 records. Skip.", csv_dir))
    next
  }
  
  for (page in seq(page_namespace, 10)) {
    result <- do_search(page,
                        query = queries[i])
    
    write_csv(result,
              path = file.path(csv_dir, sprintf("page%d.csv", page)))

    # わりとたっぷりsleepしないとabuse rate limitにすぐひっかかる
    Sys.sleep(60)
  }
}

これで2時間ほどかかります。お茶でも飲んで待ちましょう。

ユーザを指定してコード検索

さっき除外したユーザについても検索します。↑のusers_queryuser:ユーザ名に置き換えるだけです。

結果

さて、実際どれくらいの数を捕捉できたんでしょう。この結果をGepuro Task Viewと比べてみましょう。DescriptionMinerをcloneしてきて以下のコードを流せば同じ結果になるはずです。

library(tidyverse)

# result以下に散らばっているCSVをすべて列挙
csvs <- list.files("results", pattern = ".*.csv", recursive = TRUE, full.names = TRUE)

# 読み込み。たまに列が数値になるときがあったので.default = col_character()を指定
l <- map(csvs, read_csv, col_types = list(.default = col_character()))
d <- bind_rows(l)

# 変な名前のやつが混じってるのでNAMESPACEだけに絞る
d %>%
  filter(filename == "NAMESPACE") %>%
  distinct(owner, repo)
#> # A tibble: 8,876 x 2
#>                owner          repo
#>                <chr>         <chr>
#>  1           swihart    libstableR
#>  2         jeffeaton           epp
#>  3      spedygiorgio   markovchain
#>  4      RomanHornung survmediation
#>  5         Spatial-R     EnvExpInd
#>  6    CreRecombinase          FGEM
#>  7           bplloyd        CoreHF
#>  8      SachaEpskamp       bootnet
#>  9          harrelfe         Hmisc
#> 10 simonpedrobolivar    coopbreed5
#> # ... with 8,866 more rows

その数、8876。

一方、Gepuro Task Viewsは…

res <- httr::GET("http://rpkg.gepuro.net/download")
j <- httr::content(res)

# ユーザ名/レポジトリ名 が入っている要素
pkg_names_vec <- map_chr(j$pkg_list, "pkg_name")

# / で分割してデータフレームに
pkg_names_mat <- stringr::str_split_fixed(pkg_names_vec, "/", 2)
colnames(pkg_names_mat) <- c("user", "repo")
pkg_names <- as_tibble(pkg_names_mat)

# Gepuro Task ViewsはcranもBioconductor-mirrorも入ってるので除外(rforgeはない)
x <- stringr::str_subset(pkg_names_vec, "^(?!cran/|^Bioconductor-mirror/)")

glimpse(x)
#>  chr [1:28022] "0xh3x/hellodublinr" "100sunflower100/MethylChiPAnno" "100sunflower100/git" "11010tianyi/latticist" ...

28022。ということで惨敗でした。。

感想

途中書いてませんでしたが、今回投げたクエリはすさまじく長くなるので、incomplete_resultsTRUEになりっぱなしでした。なのでたぶん、時間内には検索できないやつがあるんだろうなあと。なかなか厳しいですね。